linoyts HF Staff commited on
Commit
2f56ca2
·
verified ·
1 Parent(s): ca2f59f

Upload app.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. app.py +94 -46
app.py CHANGED
@@ -19,30 +19,33 @@ from diffusers import LTX2InContextPipeline
19
  from diffusers.pipelines.ltx2.pipeline_ltx2_ic_lora import LTX2ReferenceCondition
20
  from diffusers.pipelines.ltx2.utils import DISTILLED_SIGMA_VALUES
21
  from diffusers.utils import load_video, encode_video
 
22
 
23
  # --- Config -----------------------------------------------------------------
24
  BASE_MODEL = "diffusers/LTX-2.3-Distilled-Diffusers"
25
  LORA_REPO = "linoyts/ltx2.3-inpainting-lora"
26
  LORA_FILE = "ltx-2.3-22b-ic-lora-inpainting.safetensors"
27
  LORA_SCALE = 1.0
 
28
  FPS = 24
29
  NUM_STEPS = len(DISTILLED_SIGMA_VALUES) # 8
30
- MASK_FILL = 128 # masked region painted neutral grey in the reference the model fills
31
  MAX_SEED = np.iinfo(np.int32).max
32
  HF_TOKEN = os.environ.get("HF_TOKEN")
33
 
34
  RES_PRESETS = {"Fast (768×448)": (768, 448), "Quality (960×544)": (960, 544)}
35
  FRAME_CHOICES = [49, 73, 97, 121]
36
 
37
- # --- Load pipeline once at module scope (ZeroGPU registers it) ---------------
38
  pipe = LTX2InContextPipeline.from_pretrained(BASE_MODEL, torch_dtype=torch.bfloat16)
39
  pipe.to("cuda")
40
  pipe.vae.enable_tiling()
41
-
42
  _lora_path = hf_hub_download(LORA_REPO, LORA_FILE, token=HF_TOKEN)
43
  pipe.load_lora_weights(load_file(_lora_path), adapter_name="inpaint")
44
  pipe.set_adapters("inpaint", LORA_SCALE)
45
 
 
 
 
46
 
47
  # --- Helpers ----------------------------------------------------------------
48
  def _resample(frames, n):
@@ -58,15 +61,29 @@ def _pick_resolution(first_frame: Image.Image, preset: str):
58
 
59
 
60
  def first_frame(video):
61
- """Populate the mask editor with the uploaded video's first frame."""
62
  if video is None:
63
  return None
64
  frames = load_video(video)
65
  return np.array(frames[0].convert("RGB")) if frames else None
66
 
67
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  def _mask_from_editor(editor_value, width, height):
69
- """Extract a binary mask (H,W) from a gr.ImageEditor value — union of painted layers."""
70
  if not editor_value:
71
  return None
72
  layers = editor_value.get("layers") or []
@@ -87,16 +104,41 @@ def _mask_from_editor(editor_value, width, height):
87
  return np.array(m) > 127
88
 
89
 
 
 
 
 
 
 
 
 
90
  def _duration(*args, **kwargs):
91
- preset = args[3] if len(args) > 3 else "Fast"
92
- num_frames = args[4] if len(args) > 4 else 73
93
  per_frame = 1.6 if "Quality" in str(preset) else 1.0
94
- return int(50 + int(num_frames) * per_frame)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
 
96
 
97
  # --- Inference --------------------------------------------------------------
98
  @spaces.GPU(duration=_duration)
99
- def inpaint(video, mask_editor, prompt, preset, num_frames, mask_mode, seed, randomize,
100
  progress=gr.Progress(track_tqdm=True)):
101
  if video is None:
102
  raise gr.Error("Please upload a video.")
@@ -114,29 +156,27 @@ def inpaint(video, mask_editor, prompt, preset, num_frames, mask_mode, seed, ran
114
  width, height = _pick_resolution(frames[0], preset)
115
  num_frames = int(num_frames)
116
 
117
- mask = _mask_from_editor(mask_editor, width, height)
118
- if mask is None:
119
- raise gr.Error("Draw a mask over the region to inpaint (use the brush on the frame).")
 
 
 
 
 
 
 
 
120
 
121
  orig = [np.array(f.convert("RGB").resize((width, height), Image.LANCZOS))
122
  for f in _resample(frames, num_frames)]
123
 
124
- fill = 0 if mask_mode == "black" else MASK_FILL
125
- if mask_mode == "attn":
126
- # Full original video as reference; attention mask tells the model to ignore the
127
- # reference (and regenerate from prompt) inside the painted region.
128
- ref = [Image.fromarray(fr) for fr in orig]
129
- am = np.ones((num_frames, height, width), dtype=np.float32)
130
- am[:, mask] = 0.0
131
- attn_mask = torch.from_numpy(am)[None, None] # (1,1,F,H,W)
132
- else:
133
- # Masked region painted a flat color in the reference; the model fills it.
134
- ref = []
135
- for fr in orig:
136
- m = fr.copy()
137
- m[mask] = fill
138
- ref.append(Image.fromarray(m))
139
- attn_mask = None
140
 
141
  ref_cond = LTX2ReferenceCondition(frames=ref, strength=1.0)
142
  video_out, _audio = pipe(
@@ -160,7 +200,7 @@ def inpaint(video, mask_editor, prompt, preset, num_frames, mask_mode, seed, ran
160
  return_dict=False,
161
  )
162
 
163
- # Composite: keep original pixels outside the mask, generated pixels inside (feathered).
164
  gen = (np.clip(video_out[0], 0, 1) * 255).astype(np.uint8)
165
  soft = np.array(Image.fromarray((mask * 255).astype(np.uint8)).filter(ImageFilter.GaussianBlur(3))) / 255.0
166
  soft = soft[None, :, :, None]
@@ -178,30 +218,33 @@ with gr.Blocks(title="LTX-2.3 Video Inpainting") as demo:
178
  gr.Markdown(
179
  "# 🪄 LTX-2.3 Video Inpainting\n"
180
  "Mask a region of a video and regenerate it from a prompt, keeping the rest of the frame intact. "
181
- "Upload a clip, **brush over the area to replace** on the first frame, describe what should appear there. "
182
- "The mask is applied across all frames. "
183
  "IC-LoRA: [`linoyts/ltx2.3-inpainting-lora`](https://huggingface.co/linoyts/ltx2.3-inpainting-lora) · "
184
- "base: distilled LTX-2.3."
185
  )
186
  with gr.Row():
187
  with gr.Column():
188
  video_in = gr.Video(label="Input video")
189
- mask_editor = gr.ImageEditor(
190
- label="Brush the region to inpaint (loads from the video's first frame)",
191
- type="numpy",
192
- layers=False,
193
- brush=gr.Brush(colors=["#ff2d55"], color_mode="fixed"),
194
- )
195
- prompt = gr.Textbox(
196
- label="What should fill the masked region",
197
- placeholder="a lush green bush with small white flowers",
198
- lines=2,
199
- )
 
 
 
 
 
200
  with gr.Accordion("Settings", open=False):
201
  preset = gr.Dropdown(list(RES_PRESETS), value="Fast (768×448)", label="Resolution")
202
  num_frames = gr.Dropdown(FRAME_CHOICES, value=73, label="Frames (24fps)")
203
- mask_mode = gr.Radio(["attn", "grey", "black"], value="attn",
204
- label="Mask mechanism (debug)")
205
  randomize = gr.Checkbox(True, label="Randomize seed")
206
  seed = gr.Slider(0, MAX_SEED, value=42, step=1, label="Seed")
207
  run = gr.Button("Inpaint", variant="primary")
@@ -209,10 +252,15 @@ with gr.Blocks(title="LTX-2.3 Video Inpainting") as demo:
209
  video_out = gr.Video(label="Inpainted result")
210
  used_seed = gr.Number(label="Seed used", interactive=False)
211
 
 
 
 
 
212
  video_in.change(first_frame, inputs=video_in, outputs=mask_editor)
 
213
  run.click(
214
  inpaint,
215
- inputs=[video_in, mask_editor, prompt, preset, num_frames, mask_mode, seed, randomize],
216
  outputs=[video_out, used_seed],
217
  )
218
 
 
19
  from diffusers.pipelines.ltx2.pipeline_ltx2_ic_lora import LTX2ReferenceCondition
20
  from diffusers.pipelines.ltx2.utils import DISTILLED_SIGMA_VALUES
21
  from diffusers.utils import load_video, encode_video
22
+ from transformers import Sam3Model, Sam3Processor
23
 
24
  # --- Config -----------------------------------------------------------------
25
  BASE_MODEL = "diffusers/LTX-2.3-Distilled-Diffusers"
26
  LORA_REPO = "linoyts/ltx2.3-inpainting-lora"
27
  LORA_FILE = "ltx-2.3-22b-ic-lora-inpainting.safetensors"
28
  LORA_SCALE = 1.0
29
+ SAM3_REPO = "facebook/sam3"
30
  FPS = 24
31
  NUM_STEPS = len(DISTILLED_SIGMA_VALUES) # 8
 
32
  MAX_SEED = np.iinfo(np.int32).max
33
  HF_TOKEN = os.environ.get("HF_TOKEN")
34
 
35
  RES_PRESETS = {"Fast (768×448)": (768, 448), "Quality (960×544)": (960, 544)}
36
  FRAME_CHOICES = [49, 73, 97, 121]
37
 
38
+ # --- Load models once at module scope (ZeroGPU registers them) ---------------
39
  pipe = LTX2InContextPipeline.from_pretrained(BASE_MODEL, torch_dtype=torch.bfloat16)
40
  pipe.to("cuda")
41
  pipe.vae.enable_tiling()
 
42
  _lora_path = hf_hub_download(LORA_REPO, LORA_FILE, token=HF_TOKEN)
43
  pipe.load_lora_weights(load_file(_lora_path), adapter_name="inpaint")
44
  pipe.set_adapters("inpaint", LORA_SCALE)
45
 
46
+ sam3 = Sam3Model.from_pretrained(SAM3_REPO, token=HF_TOKEN).to("cuda")
47
+ sam3_processor = Sam3Processor.from_pretrained(SAM3_REPO, token=HF_TOKEN)
48
+
49
 
50
  # --- Helpers ----------------------------------------------------------------
51
  def _resample(frames, n):
 
61
 
62
 
63
  def first_frame(video):
 
64
  if video is None:
65
  return None
66
  frames = load_video(video)
67
  return np.array(frames[0].convert("RGB")) if frames else None
68
 
69
 
70
+ def _sam3_mask(image: Image.Image, text: str, score_thr: float = 0.5):
71
+ """Run SAM3 text-prompted segmentation; return a union boolean mask (H,W) at image size."""
72
+ inputs = sam3_processor(images=image, text=text, return_tensors="pt").to("cuda")
73
+ with torch.no_grad():
74
+ outputs = sam3(**inputs)
75
+ res = sam3_processor.post_process_instance_segmentation(
76
+ outputs, threshold=score_thr, mask_threshold=0.5,
77
+ target_sizes=inputs.get("original_sizes").tolist(),
78
+ )[0]
79
+ masks = res["masks"]
80
+ if masks is None or len(masks) == 0:
81
+ return None
82
+ m = masks.cpu().numpy().astype(bool)
83
+ return np.any(m, axis=0) # union of all matching instances
84
+
85
+
86
  def _mask_from_editor(editor_value, width, height):
 
87
  if not editor_value:
88
  return None
89
  layers = editor_value.get("layers") or []
 
104
  return np.array(m) > 127
105
 
106
 
107
+ def _overlay(image: Image.Image, mask: np.ndarray):
108
+ img = image.convert("RGBA")
109
+ m = Image.fromarray((mask * 255).astype(np.uint8)).resize(image.size, Image.NEAREST)
110
+ ov = Image.new("RGBA", image.size, (255, 45, 85, 0))
111
+ ov.putalpha(m.point(lambda v: int(v * 0.5)))
112
+ return Image.alpha_composite(img, ov).convert("RGB")
113
+
114
+
115
  def _duration(*args, **kwargs):
116
+ preset = next((a for a in args if a in RES_PRESETS), "Fast")
117
+ num_frames = next((a for a in args if a in FRAME_CHOICES), 73)
118
  per_frame = 1.6 if "Quality" in str(preset) else 1.0
119
+ return int(60 + int(num_frames) * per_frame)
120
+
121
+
122
+ # --- SAM3 mask preview (cheap GPU call) -------------------------------------
123
+ @spaces.GPU(duration=40)
124
+ def preview_mask(video, mask_text, progress=gr.Progress(track_tqdm=True)):
125
+ if video is None:
126
+ raise gr.Error("Upload a video first.")
127
+ if not mask_text.strip():
128
+ raise gr.Error("Type what to mask, e.g. 'the cat'.")
129
+ frames = load_video(video)
130
+ if not frames:
131
+ raise gr.Error("Could not read the video.")
132
+ f0 = frames[0].convert("RGB")
133
+ mask = _sam3_mask(f0, mask_text.strip())
134
+ if mask is None:
135
+ raise gr.Error(f"SAM3 found no '{mask_text}' in the first frame. Try a different phrase.")
136
+ return _overlay(f0, mask)
137
 
138
 
139
  # --- Inference --------------------------------------------------------------
140
  @spaces.GPU(duration=_duration)
141
+ def inpaint(video, mask_source, mask_text, mask_editor, prompt, preset, num_frames, seed, randomize,
142
  progress=gr.Progress(track_tqdm=True)):
143
  if video is None:
144
  raise gr.Error("Please upload a video.")
 
156
  width, height = _pick_resolution(frames[0], preset)
157
  num_frames = int(num_frames)
158
 
159
+ if mask_source == "Text (SAM3)":
160
+ if not mask_text.strip():
161
+ raise gr.Error("Type what to mask, e.g. 'the cat'.")
162
+ sam_mask = _sam3_mask(frames[0].convert("RGB"), mask_text.strip())
163
+ if sam_mask is None:
164
+ raise gr.Error(f"SAM3 found no '{mask_text}' in the first frame.")
165
+ mask = np.array(Image.fromarray((sam_mask * 255).astype(np.uint8)).resize((width, height), Image.NEAREST)) > 127
166
+ else:
167
+ mask = _mask_from_editor(mask_editor, width, height)
168
+ if mask is None:
169
+ raise gr.Error("Draw a mask with the brush, or switch to Text (SAM3) masking.")
170
 
171
  orig = [np.array(f.convert("RGB").resize((width, height), Image.LANCZOS))
172
  for f in _resample(frames, num_frames)]
173
 
174
+ # Full original video as reference; the attention mask makes the model ignore the
175
+ # reference (and regenerate from the prompt) inside the masked region.
176
+ ref = [Image.fromarray(fr) for fr in orig]
177
+ am = np.ones((num_frames, height, width), dtype=np.float32)
178
+ am[:, mask] = 0.0
179
+ attn_mask = torch.from_numpy(am)[None, None] # (1,1,F,H,W)
 
 
 
 
 
 
 
 
 
 
180
 
181
  ref_cond = LTX2ReferenceCondition(frames=ref, strength=1.0)
182
  video_out, _audio = pipe(
 
200
  return_dict=False,
201
  )
202
 
203
+ # Composite generated pixels inside the mask over the original (feathered edges).
204
  gen = (np.clip(video_out[0], 0, 1) * 255).astype(np.uint8)
205
  soft = np.array(Image.fromarray((mask * 255).astype(np.uint8)).filter(ImageFilter.GaussianBlur(3))) / 255.0
206
  soft = soft[None, :, :, None]
 
218
  gr.Markdown(
219
  "# 🪄 LTX-2.3 Video Inpainting\n"
220
  "Mask a region of a video and regenerate it from a prompt, keeping the rest of the frame intact. "
221
+ "Pick the area with a **text prompt (SAM3 auto-mask)** or by **brushing** on the first frame; "
222
+ "the mask applies across all frames. "
223
  "IC-LoRA: [`linoyts/ltx2.3-inpainting-lora`](https://huggingface.co/linoyts/ltx2.3-inpainting-lora) · "
224
+ "auto-mask: [SAM3](https://huggingface.co/facebook/sam3) · base: distilled LTX-2.3."
225
  )
226
  with gr.Row():
227
  with gr.Column():
228
  video_in = gr.Video(label="Input video")
229
+ mask_source = gr.Radio(["Text (SAM3)", "Brush"], value="Text (SAM3)", label="How to mask")
230
+
231
+ with gr.Group(visible=True) as text_group:
232
+ mask_text = gr.Textbox(label="Object(s) to mask", placeholder="the cat")
233
+ preview_btn = gr.Button("Preview mask")
234
+ mask_preview = gr.Image(label="SAM3 mask preview", type="pil", interactive=False)
235
+
236
+ with gr.Group(visible=False) as brush_group:
237
+ mask_editor = gr.ImageEditor(
238
+ label="Brush the region to inpaint (loads from the video's first frame)",
239
+ type="numpy", layers=False,
240
+ brush=gr.Brush(colors=["#ff2d55"], color_mode="fixed"),
241
+ )
242
+
243
+ prompt = gr.Textbox(label="What should fill the masked region",
244
+ placeholder="a lush green bush with small white flowers", lines=2)
245
  with gr.Accordion("Settings", open=False):
246
  preset = gr.Dropdown(list(RES_PRESETS), value="Fast (768×448)", label="Resolution")
247
  num_frames = gr.Dropdown(FRAME_CHOICES, value=73, label="Frames (24fps)")
 
 
248
  randomize = gr.Checkbox(True, label="Randomize seed")
249
  seed = gr.Slider(0, MAX_SEED, value=42, step=1, label="Seed")
250
  run = gr.Button("Inpaint", variant="primary")
 
252
  video_out = gr.Video(label="Inpainted result")
253
  used_seed = gr.Number(label="Seed used", interactive=False)
254
 
255
+ def _toggle(src):
256
+ return gr.update(visible=src == "Text (SAM3)"), gr.update(visible=src == "Brush")
257
+
258
+ mask_source.change(_toggle, inputs=mask_source, outputs=[text_group, brush_group])
259
  video_in.change(first_frame, inputs=video_in, outputs=mask_editor)
260
+ preview_btn.click(preview_mask, inputs=[video_in, mask_text], outputs=mask_preview)
261
  run.click(
262
  inpaint,
263
+ inputs=[video_in, mask_source, mask_text, mask_editor, prompt, preset, num_frames, seed, randomize],
264
  outputs=[video_out, used_seed],
265
  )
266