Jack Wu commited on
Commit
4f8616d
Β·
1 Parent(s): b1a5003

refactor: implement streaming LaMa inpainting, optimize crop aspect ratio matching, and add support for high-frame-rate video presets

Browse files
Files changed (6) hide show
  1. app.py +55 -34
  2. pipeline/composite.py +121 -127
  3. pipeline/crop.py +23 -12
  4. pipeline/lama.py +51 -48
  5. pipeline/video.py +5 -3
  6. requirements.txt +3 -0
app.py CHANGED
@@ -381,37 +381,66 @@ def on_preview_crop(editor_value: dict | None, meta_state: dict | None, context_
381
 
382
 
383
  @spaces.GPU(duration=180)
384
- def _inpaint_frames_gpu(
385
  frame_paths: list,
386
  crop_region: CropRegion,
387
  inpaint_mask: np.ndarray,
 
388
  mode: str,
389
  total: int,
390
  progress,
391
- ) -> list:
392
  """
393
- GPU-accelerated inpainting step.
394
-
395
- This is the *only* function that holds the ZeroGPU allocation.
396
- Frame extraction, compositing, and video encoding are all CPU-only and
397
- run outside this function to avoid burning GPU quota on I/O work.
 
 
 
 
 
 
 
398
  """
 
 
 
 
 
399
  if mode == "Fast (LaMa)":
400
- from pipeline.lama import inpaint_frames_lama
401
 
402
- def _lama_progress(i: int) -> None:
403
  progress(
404
- 0.20 + 0.70 * ((i + 1) / total),
405
- desc=f"LaMa frame {i + 1}/{total}…",
406
  )
407
 
408
- return inpaint_frames_lama(
409
- frame_paths, crop_region, inpaint_mask, progress_fn=_lama_progress
410
- )
411
- else: # Quality (VACE)
 
 
 
 
 
 
 
 
 
412
  from pipeline.vace import inpaint_frames_vace
413
- progress(0.5, desc="Running VACE-14B…")
414
- return inpaint_frames_vace(frame_paths, crop_region, inpaint_mask)
 
 
 
 
 
 
 
415
 
416
 
417
  def run_pipeline(
@@ -425,9 +454,9 @@ def run_pipeline(
425
  """
426
  Pipeline orchestrator β€” CPU work only.
427
 
428
- GPU allocation is acquired and released inside _inpaint_frames_gpu; the
429
- rest of the pipeline (frame extraction, compositing, encoding) is pure
430
- CPU/disk I/O and does not consume GPU quota.
431
  """
432
  if video_path is None:
433
  raise gr.Error("Upload a video first.")
@@ -460,22 +489,14 @@ def run_pipeline(
460
  frame_paths = extract_frames(safe_video, ws.frames_dir, fps=meta.fps)
461
  total = len(frame_paths)
462
 
463
- # ── GPU: inpainting only ───────────────────────────────────
464
  progress(0.15, desc="Starting inpainting…")
465
- inpainted_crops = _inpaint_frames_gpu(
466
- frame_paths, crop_region, inpaint_mask, mode, total, progress
 
467
  )
468
 
469
- # ── CPU: composite ──────────────────────────────────────────
470
- progress(0.90, desc="Compositing frames…")
471
- from pipeline.composite import composite_frame
472
-
473
- for i, (fp, crop) in enumerate(zip(frame_paths, inpainted_crops)):
474
- original = np.array(Image.open(fp).convert("RGB"))
475
- composited = composite_frame(original, crop, crop_region, inpaint_mask)
476
- Image.fromarray(composited).save(ws.out_frames_dir / f"{i+1:06d}.png")
477
-
478
- # ── CPU: encode + mux ───────────────────────────────────────
479
  progress(0.95, desc="Encoding video…")
480
  silent_path = ws.path("silent.mp4")
481
  frames_to_video(ws.out_frames_dir, silent_path, meta)
@@ -490,7 +511,7 @@ def run_pipeline(
490
  except OSError:
491
  pass
492
  raise
493
- # ws (frames, composited pngs, silent.mp4) cleaned up here
494
 
495
  progress(1.0, desc="Done!")
496
  return final_path, f"βœ“ Done β€” {total} frames processed ({mode})"
 
381
 
382
 
383
  @spaces.GPU(duration=180)
384
+ def _inpaint_composite_save_gpu(
385
  frame_paths: list,
386
  crop_region: CropRegion,
387
  inpaint_mask: np.ndarray,
388
+ out_dir,
389
  mode: str,
390
  total: int,
391
  progress,
392
+ ) -> None:
393
  """
394
+ GPU-accelerated inpainting with immediate per-frame compositing and disk save.
395
+
396
+ Architecture
397
+ ------------
398
+ - The feathered alpha map is pre-computed **once** (static mask for the whole
399
+ video) so the Gaussian blur runs exactly once instead of once per frame.
400
+ - For LaMa (per-frame independent model): streams one frame at a time β€”
401
+ never holds more than one inpainted crop in RAM.
402
+ - For VACE (temporal model): must process the full sequence at once for
403
+ temporal coherence, then composites and saves frame-by-frame.
404
+ - Saves composited PNGs directly to *out_dir* so the caller never holds
405
+ the full crop list in memory.
406
  """
407
+ from pipeline.composite import composite_with_alpha, feathered_alpha
408
+
409
+ alpha = feathered_alpha(inpaint_mask) # pre-compute once (static mask)
410
+ out_dir = Path(out_dir)
411
+
412
  if mode == "Fast (LaMa)":
413
+ from pipeline.lama import inpaint_frames_lama_stream
414
 
415
+ def _prog(i: int) -> None:
416
  progress(
417
+ 0.20 + 0.65 * ((i + 1) / total),
418
+ desc=f"LaMa {i + 1}/{total}…",
419
  )
420
 
421
+ for i, (fp, crop) in enumerate(
422
+ zip(
423
+ frame_paths,
424
+ inpaint_frames_lama_stream(
425
+ frame_paths, crop_region, inpaint_mask, _prog
426
+ ),
427
+ )
428
+ ):
429
+ original = np.array(Image.open(fp).convert("RGB"))
430
+ composited = composite_with_alpha(original, crop, crop_region, alpha)
431
+ Image.fromarray(composited).save(out_dir / f"{i + 1:06d}.png")
432
+
433
+ else: # Quality (VACE) β€” temporal model requires the full frame sequence
434
  from pipeline.vace import inpaint_frames_vace
435
+
436
+ progress(0.45, desc="Running VACE-14B…")
437
+ crops = inpaint_frames_vace(frame_paths, crop_region, inpaint_mask)
438
+
439
+ progress(0.85, desc="Compositing…")
440
+ for i, (fp, crop) in enumerate(zip(frame_paths, crops)):
441
+ original = np.array(Image.open(fp).convert("RGB"))
442
+ composited = composite_with_alpha(original, crop, crop_region, alpha)
443
+ Image.fromarray(composited).save(out_dir / f"{i + 1:06d}.png")
444
 
445
 
446
  def run_pipeline(
 
454
  """
455
  Pipeline orchestrator β€” CPU work only.
456
 
457
+ GPU allocation is acquired and released inside _inpaint_composite_save_gpu.
458
+ Frame extraction, and video encoding/muxing are pure CPU/disk I/O and do
459
+ not consume GPU quota.
460
  """
461
  if video_path is None:
462
  raise gr.Error("Upload a video first.")
 
489
  frame_paths = extract_frames(safe_video, ws.frames_dir, fps=meta.fps)
490
  total = len(frame_paths)
491
 
492
+ # ── GPU: inpaint + composite + save ────────────────────────────
493
  progress(0.15, desc="Starting inpainting…")
494
+ _inpaint_composite_save_gpu(
495
+ frame_paths, crop_region, inpaint_mask,
496
+ ws.out_frames_dir, mode, total, progress,
497
  )
498
 
499
+ # ── CPU: encode + mux ───────────────────────────────────────────
 
 
 
 
 
 
 
 
 
500
  progress(0.95, desc="Encoding video…")
501
  silent_path = ws.path("silent.mp4")
502
  frames_to_video(ws.out_frames_dir, silent_path, meta)
 
511
  except OSError:
512
  pass
513
  raise
514
+ # ws cleaned up here by context manager
515
 
516
  progress(1.0, desc="Done!")
517
  return final_path, f"βœ“ Done β€” {total} frames processed ({mode})"
pipeline/composite.py CHANGED
@@ -4,20 +4,21 @@ pipeline/composite.py
4
  Feathered alpha compositing: paste an inpainted crop back into the
5
  original full frame.
6
 
7
- The feather/blend zone creates a smooth transition along the crop border,
8
- avoiding hard edge seams. Only pixels inside the inpaint mask are replaced;
9
- the rest of the crop (the "context" ring) is discarded.
 
 
 
10
 
11
  Design
12
  ------
13
  Two-stage blend:
14
- 1. Mask feathering: the inpaint mask is Gaussian-blurred to create a
15
- soft alpha ramp around the watermark boundary. This blends between
16
- the inpainted content and the original frame within the crop.
17
- 2. Crop-border feathering (optional, default off): a second linear ramp
18
- from the crop border inward, so the context ring blends too. Usually
19
- not needed because context pixels in the crop are identical to the
20
- original frame anyway (LaMa / VACE don't alter them much).
21
 
22
  All operations are pure NumPy + PIL β€” no cv2 required.
23
  """
@@ -25,194 +26,187 @@ All operations are pure NumPy + PIL β€” no cv2 required.
25
  from __future__ import annotations
26
 
27
  import numpy as np
28
- from PIL import Image, ImageFilter
29
 
30
  from pipeline.crop import CropRegion
31
 
32
 
33
- def composite_frame(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  original: np.ndarray,
35
  inpainted_crop: np.ndarray,
36
  crop_region: CropRegion,
37
- inpaint_mask: np.ndarray,
38
- feather_radius: int = 8,
39
  ) -> np.ndarray:
40
  """
41
- Blend an inpainted crop back into the original full frame.
42
 
43
  Parameters
44
  ----------
45
  original : np.ndarray
46
- Full-frame image (H x W x 3, uint8 RGB). Modified in-place
47
- on a copy β€” caller's array is not mutated.
48
  inpainted_crop : np.ndarray
49
- Inpainted crop image (crop_h x crop_w x 3, uint8 RGB).
50
- Must match crop_region dimensions exactly.
51
  crop_region : CropRegion
52
  Defines where the crop sits in the full frame.
53
- inpaint_mask : np.ndarray
54
- Crop-local binary mask (crop_h x crop_w, uint8). 255=inpaint, 0=keep.
55
- Same mask that was passed to the inpainting model.
56
- feather_radius : int
57
- Gaussian blur radius applied to the mask before blending.
58
- Larger values = softer transition.
59
- Set to 0 to disable feathering (hard composite).
60
 
61
  Returns
62
  -------
63
  np.ndarray
64
- Full-frame output (H x W x 3, uint8 RGB) with watermark removed.
65
  """
66
  cr = crop_region
67
  result = original.copy()
68
 
69
- # Sanity check dimensions
70
- expected_h, expected_w = cr.frame_h, cr.frame_w
71
- actual_h, actual_w = inpainted_crop.shape[:2]
72
- if (actual_h, actual_w) != (expected_h, expected_w):
73
- # Resize inpainted crop to match crop region (handles VACE resize case)
74
- inpainted_crop = np.array(
75
- Image.fromarray(inpainted_crop).resize(
76
- (expected_w, expected_h), Image.LANCZOS
77
- )
78
- )
79
 
80
- # ------------------------------------------------------------------
81
- # Build the blend alpha from the inpaint mask
82
- # ------------------------------------------------------------------
83
- alpha = _feathered_alpha(inpaint_mask, feather_radius) # float32, 0..1
84
-
85
- # ------------------------------------------------------------------
86
- # Composite: result = alpha * inpainted + (1 - alpha) * original_crop
87
- # ------------------------------------------------------------------
88
  original_crop = result[
89
  cr.frame_y : cr.frame_y + cr.frame_h,
90
  cr.frame_x : cr.frame_x + cr.frame_w,
91
  ].astype(np.float32)
92
 
93
- inpainted_f = inpainted_crop.astype(np.float32)
94
  alpha_3 = alpha[:, :, np.newaxis] # broadcast over RGB channels
95
-
96
- blended = alpha_3 * inpainted_f + (1.0 - alpha_3) * original_crop
97
- blended_uint8 = np.clip(blended, 0, 255).astype(np.uint8)
98
-
99
  result[
100
  cr.frame_y : cr.frame_y + cr.frame_h,
101
  cr.frame_x : cr.frame_x + cr.frame_w,
102
- ] = blended_uint8
103
 
104
  return result
105
 
106
 
107
- def composite_frames(
108
- original_frame_paths,
109
- inpainted_crops: list[np.ndarray],
110
  crop_region: CropRegion,
111
  inpaint_mask: np.ndarray,
112
  feather_radius: int = 8,
113
- ) -> list[np.ndarray]:
114
  """
115
- Batch version: composite a list of inpainted crops onto their
116
- corresponding original frames.
 
 
 
 
117
 
118
  Parameters
119
  ----------
120
- original_frame_paths : List[Path]
121
- Full-frame PNG paths (same order as inpainted_crops).
122
- inpainted_crops : List[np.ndarray]
123
- One inpainted crop per frame.
124
  crop_region : CropRegion
125
  inpaint_mask : np.ndarray
126
- Shared mask (same for all frames β€” watermark is static).
127
  feather_radius : int
 
128
 
129
  Returns
130
  -------
131
- List[np.ndarray]
132
- Full-frame composited images (uint8 RGB), one per input frame.
133
  """
134
- # Pre-compute feathered alpha once (shared mask)
135
- alpha = _feathered_alpha(inpaint_mask, feather_radius)
136
-
137
- composited: list[np.ndarray] = []
138
- for frame_path, crop in zip(original_frame_paths, inpainted_crops):
139
- original = np.array(Image.open(frame_path).convert("RGB"))
140
- frame_out = _composite_with_alpha(original, crop, crop_region, alpha)
141
- composited.append(frame_out)
142
-
143
- return composited
144
 
145
 
146
- # ---------------------------------------------------------------------------
147
- # Private helpers
148
- # ---------------------------------------------------------------------------
149
-
150
- def _feathered_alpha(mask: np.ndarray, radius: int) -> np.ndarray:
 
 
151
  """
152
- Convert a uint8 binary mask (0/255) to a float32 alpha map (0..1)
153
- with a Gaussian-blurred soft edge.
 
 
 
 
154
 
155
  Parameters
156
  ----------
157
- mask : np.ndarray
158
- Crop-local binary mask (H x W, uint8).
159
- radius : int
160
- Gaussian blur radius. 0 = hard composite.
 
 
 
 
161
 
162
  Returns
163
  -------
164
- np.ndarray
165
- Float32 alpha map (H x W), values in [0.0, 1.0].
166
  """
167
- alpha_f = mask.astype(np.float32) / 255.0
168
-
169
- if radius > 0:
170
- # Use scipy gaussian_filter on float32 directly β€” avoids uint8 quantisation
171
- # that would staircase the feather ramp over 8 discrete levels.
172
- # sigma: radius β‰ˆ 3Οƒ is the standard Gaussian convention.
173
- from scipy.ndimage import gaussian_filter
174
- sigma = max(radius / 3.0, 0.5)
175
- alpha_f = gaussian_filter(alpha_f, sigma=sigma)
176
- alpha_f = np.clip(alpha_f, 0.0, 1.0)
177
 
178
- return alpha_f
179
 
 
 
 
180
 
181
- def _composite_with_alpha(
182
- original: np.ndarray,
183
  inpainted_crop: np.ndarray,
184
- crop_region: CropRegion,
185
- alpha: np.ndarray,
186
  ) -> np.ndarray:
187
- """
188
- Internal composite given a pre-computed alpha map.
189
- Returns a copy of original with the crop region blended in.
190
- """
191
- cr = crop_region
192
- result = original.copy()
193
-
194
- # Resize crop if needed (e.g. VACE returned a different resolution)
195
  expected_h, expected_w = cr.frame_h, cr.frame_w
196
  actual_h, actual_w = inpainted_crop.shape[:2]
197
  if (actual_h, actual_w) != (expected_h, expected_w):
198
- inpainted_crop = np.array(
199
  Image.fromarray(inpainted_crop).resize(
200
  (expected_w, expected_h), Image.LANCZOS
201
  )
202
  )
203
-
204
- original_crop = result[
205
- cr.frame_y : cr.frame_y + cr.frame_h,
206
- cr.frame_x : cr.frame_x + cr.frame_w,
207
- ].astype(np.float32)
208
-
209
- alpha_3 = alpha[:, :, np.newaxis]
210
- blended = alpha_3 * inpainted_crop.astype(np.float32) + (1.0 - alpha_3) * original_crop
211
- blended_uint8 = np.clip(blended, 0, 255).astype(np.uint8)
212
-
213
- result[
214
- cr.frame_y : cr.frame_y + cr.frame_h,
215
- cr.frame_x : cr.frame_x + cr.frame_w,
216
- ] = blended_uint8
217
-
218
- return result
 
4
  Feathered alpha compositing: paste an inpainted crop back into the
5
  original full frame.
6
 
7
+ Public API
8
+ ----------
9
+ - feathered_alpha(mask, radius) β€” convert mask β†’ float alpha (call once)
10
+ - composite_with_alpha(orig, crop, cr, alpha) β€” blend with a pre-computed alpha
11
+ - composite_frame(orig, crop, cr, mask) β€” convenience: alpha + blend in one call
12
+ - composite_frames(paths, crops, cr, mask) β€” batch; pre-computes alpha once
13
 
14
  Design
15
  ------
16
  Two-stage blend:
17
+ 1. Mask feathering: the inpaint mask is Gaussian-blurred to create a soft alpha
18
+ ramp around the watermark boundary, blending inpainted ↔ original content.
19
+ 2. Crop-border feathering (optional, default off): a linear ramp from the crop
20
+ border inward. Usually unnecessary because context pixels in the crop are
21
+ identical to the original frame.
 
 
22
 
23
  All operations are pure NumPy + PIL β€” no cv2 required.
24
  """
 
26
  from __future__ import annotations
27
 
28
  import numpy as np
29
+ from PIL import Image
30
 
31
  from pipeline.crop import CropRegion
32
 
33
 
34
+ # ---------------------------------------------------------------------------
35
+ # Public API
36
+ # ---------------------------------------------------------------------------
37
+
38
+ def feathered_alpha(mask: np.ndarray, radius: int = 8) -> np.ndarray:
39
+ """
40
+ Convert a uint8 binary mask (0/255) to a float32 alpha map (0.0–1.0)
41
+ with a Gaussian-blurred soft edge.
42
+
43
+ **Call this once per unique mask** and pass the result to
44
+ :func:`composite_with_alpha` to avoid recomputing the Gaussian blur
45
+ on every frame (the mask is static for the whole video).
46
+
47
+ Parameters
48
+ ----------
49
+ mask : np.ndarray
50
+ Crop-local binary mask (H Γ— W, uint8). 255 = inpaint, 0 = keep.
51
+ radius : int
52
+ Gaussian blur radius. 0 = hard composite (no feathering).
53
+
54
+ Returns
55
+ -------
56
+ np.ndarray
57
+ Float32 alpha map (H Γ— W), values in [0.0, 1.0].
58
+ """
59
+ alpha_f = mask.astype(np.float32) / 255.0
60
+
61
+ if radius > 0:
62
+ # scipy gaussian_filter on float32 β€” avoids the 8-level staircase that
63
+ # uint8 Gaussian quantisation would produce over the feather ramp.
64
+ # sigma: radius β‰ˆ 3Οƒ is the standard Gaussian convention.
65
+ from scipy.ndimage import gaussian_filter
66
+ sigma = max(radius / 3.0, 0.5)
67
+ alpha_f = gaussian_filter(alpha_f, sigma=sigma)
68
+ alpha_f = np.clip(alpha_f, 0.0, 1.0)
69
+
70
+ return alpha_f
71
+
72
+
73
+ def composite_with_alpha(
74
  original: np.ndarray,
75
  inpainted_crop: np.ndarray,
76
  crop_region: CropRegion,
77
+ alpha: np.ndarray,
 
78
  ) -> np.ndarray:
79
  """
80
+ Blend *inpainted_crop* into *original* using a pre-computed *alpha* map.
81
 
82
  Parameters
83
  ----------
84
  original : np.ndarray
85
+ Full-frame image (H Γ— W Γ— 3, uint8 RGB). Not mutated.
 
86
  inpainted_crop : np.ndarray
87
+ Inpainted crop (crop_h Γ— crop_w Γ— 3, uint8 RGB).
 
88
  crop_region : CropRegion
89
  Defines where the crop sits in the full frame.
90
+ alpha : np.ndarray
91
+ Float32 alpha map (crop_h Γ— crop_w), pre-computed by
92
+ :func:`feathered_alpha`.
 
 
 
 
93
 
94
  Returns
95
  -------
96
  np.ndarray
97
+ Full-frame output (H Γ— W Γ— 3, uint8 RGB).
98
  """
99
  cr = crop_region
100
  result = original.copy()
101
 
102
+ inpainted_crop = _resize_crop_if_needed(inpainted_crop, cr)
 
 
 
 
 
 
 
 
 
103
 
 
 
 
 
 
 
 
 
104
  original_crop = result[
105
  cr.frame_y : cr.frame_y + cr.frame_h,
106
  cr.frame_x : cr.frame_x + cr.frame_w,
107
  ].astype(np.float32)
108
 
 
109
  alpha_3 = alpha[:, :, np.newaxis] # broadcast over RGB channels
110
+ blended = alpha_3 * inpainted_crop.astype(np.float32) + (1.0 - alpha_3) * original_crop
 
 
 
111
  result[
112
  cr.frame_y : cr.frame_y + cr.frame_h,
113
  cr.frame_x : cr.frame_x + cr.frame_w,
114
+ ] = np.clip(blended, 0, 255).astype(np.uint8)
115
 
116
  return result
117
 
118
 
119
+ def composite_frame(
120
+ original: np.ndarray,
121
+ inpainted_crop: np.ndarray,
122
  crop_region: CropRegion,
123
  inpaint_mask: np.ndarray,
124
  feather_radius: int = 8,
125
+ ) -> np.ndarray:
126
  """
127
+ Blend an inpainted crop back into the original full frame.
128
+
129
+ Convenience wrapper that computes the feathered alpha internally.
130
+ When processing many frames with the same mask, prefer calling
131
+ :func:`feathered_alpha` once and :func:`composite_with_alpha` per frame
132
+ to avoid recomputing the Gaussian blur 450 times.
133
 
134
  Parameters
135
  ----------
136
+ original : np.ndarray
137
+ Full-frame image (H Γ— W Γ— 3, uint8 RGB). Not mutated.
138
+ inpainted_crop : np.ndarray
139
+ Inpainted crop (crop_h Γ— crop_w Γ— 3, uint8 RGB).
140
  crop_region : CropRegion
141
  inpaint_mask : np.ndarray
142
+ Crop-local binary mask (crop_h Γ— crop_w, uint8). 255=inpaint, 0=keep.
143
  feather_radius : int
144
+ Gaussian blur radius. 0 = hard composite.
145
 
146
  Returns
147
  -------
148
+ np.ndarray
149
+ Full-frame output (H Γ— W Γ— 3, uint8 RGB).
150
  """
151
+ alpha = feathered_alpha(inpaint_mask, feather_radius)
152
+ return composite_with_alpha(original, inpainted_crop, crop_region, alpha)
 
 
 
 
 
 
 
 
153
 
154
 
155
+ def composite_frames(
156
+ original_frame_paths,
157
+ inpainted_crops: list[np.ndarray],
158
+ crop_region: CropRegion,
159
+ inpaint_mask: np.ndarray,
160
+ feather_radius: int = 8,
161
+ ) -> list[np.ndarray]:
162
  """
163
+ Batch composite: pre-computes the feathered alpha *once*, then composites
164
+ each (frame, crop) pair.
165
+
166
+ Note: returns all composited frames in memory simultaneously. For large
167
+ clips prefer iterating with :func:`feathered_alpha` + :func:`composite_with_alpha`
168
+ and saving each result immediately.
169
 
170
  Parameters
171
  ----------
172
+ original_frame_paths : list[Path]
173
+ Full-frame PNG paths (same order as inpainted_crops).
174
+ inpainted_crops : list[np.ndarray]
175
+ One inpainted crop per frame.
176
+ crop_region : CropRegion
177
+ inpaint_mask : np.ndarray
178
+ Shared mask (static watermark β€” same for all frames).
179
+ feather_radius : int
180
 
181
  Returns
182
  -------
183
+ list[np.ndarray]
184
+ Composited full-frame images (uint8 RGB), one per input frame.
185
  """
186
+ alpha = feathered_alpha(inpaint_mask, feather_radius)
187
+ return [
188
+ composite_with_alpha(
189
+ np.array(Image.open(fp).convert("RGB")), crop, crop_region, alpha
190
+ )
191
+ for fp, crop in zip(original_frame_paths, inpainted_crops)
192
+ ]
 
 
 
193
 
 
194
 
195
+ # ---------------------------------------------------------------------------
196
+ # Private helpers
197
+ # ---------------------------------------------------------------------------
198
 
199
+ def _resize_crop_if_needed(
 
200
  inpainted_crop: np.ndarray,
201
+ cr: CropRegion,
 
202
  ) -> np.ndarray:
203
+ """Resize *inpainted_crop* to match *cr* dimensions if they differ."""
 
 
 
 
 
 
 
204
  expected_h, expected_w = cr.frame_h, cr.frame_w
205
  actual_h, actual_w = inpainted_crop.shape[:2]
206
  if (actual_h, actual_w) != (expected_h, expected_w):
207
+ return np.array(
208
  Image.fromarray(inpainted_crop).resize(
209
  (expected_w, expected_h), Image.LANCZOS
210
  )
211
  )
212
+ return inpainted_crop
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
pipeline/crop.py CHANGED
@@ -224,19 +224,30 @@ def find_target_resolution(
224
  (target_w, target_h) : Tuple[int, int]
225
  VACE resolution to use. Always >= (required_w, required_h).
226
  """
227
- # VACE_RESOLUTIONS is sorted ascending by area; the first entry that
228
- # satisfies both constraints is therefore the minimum-area fit.
229
- best = next(
230
- ((w, h) for (w, h) in VACE_RESOLUTIONS if w >= required_w and h >= required_h),
231
- None,
232
- )
233
- if best is not None:
234
- return best
 
 
 
 
 
 
 
 
 
 
 
 
 
235
 
236
- # Fallback: round up to nearest multiple of 32
237
- fallback_w = _ceil_to_multiple(required_w, 32)
238
- fallback_h = _ceil_to_multiple(required_h, 32)
239
- return (fallback_w, fallback_h)
240
 
241
 
242
  def compute_crop_region(
 
224
  (target_w, target_h) : Tuple[int, int]
225
  VACE resolution to use. Always >= (required_w, required_h).
226
  """
227
+ # VACE_RESOLUTIONS is sorted ascending by area. The first entry satisfying
228
+ # both constraints is minimum-area by construction. When multiple entries
229
+ # share that minimum area (e.g. 512Γ—768 and 768Γ—512 have identical pixel
230
+ # counts), break the tie by preferring the one whose aspect ratio is closest
231
+ # to required_w / required_h β€” prevents a landscape canvas for a portrait crop.
232
+ fitting = [
233
+ (w, h) for (w, h) in VACE_RESOLUTIONS
234
+ if w >= required_w and h >= required_h
235
+ ]
236
+ if not fitting:
237
+ # Fallback: round up to nearest multiple of 32
238
+ return (
239
+ _ceil_to_multiple(required_w, 32),
240
+ _ceil_to_multiple(required_h, 32),
241
+ )
242
+
243
+ min_area = fitting[0][0] * fitting[0][1]
244
+ candidates = [(w, h) for (w, h) in fitting if w * h == min_area]
245
+
246
+ if len(candidates) == 1:
247
+ return candidates[0]
248
 
249
+ target_ratio = required_w / required_h
250
+ return min(candidates, key=lambda wh: abs(wh[0] / wh[1] - target_ratio))
 
 
251
 
252
 
253
  def compute_crop_region(
pipeline/lama.py CHANGED
@@ -12,13 +12,18 @@ Pipeline for each frame:
12
  2. Run LaMa on the crop with the dilated inpaint mask.
13
  3. Return the inpainted crop; compositing is handled by composite.py.
14
 
 
 
 
 
 
15
  License: LaMa is Apache 2.0.
16
  """
17
 
18
  from __future__ import annotations
19
 
20
  from pathlib import Path
21
- from typing import List
22
 
23
  import numpy as np
24
  from PIL import Image
@@ -30,8 +35,9 @@ from pipeline.crop import CropRegion
30
  # Model singleton
31
  # ---------------------------------------------------------------------------
32
  # Loaded lazily on first call; shared across all frames in a run.
33
- # Tracks the device it was loaded on β€” reloads if GPU becomes available
34
- # after a cold CPU-only initialisation (ZeroGPU warm/cold start handling).
 
35
  _lama_model = None
36
  _lama_device: str | None = None
37
 
@@ -41,10 +47,6 @@ def _get_model():
41
  import torch
42
  current_device = "cuda" if torch.cuda.is_available() else "cpu"
43
  # One-way latch: only reload when *upgrading* from CPU to GPU.
44
- # After the @spaces.GPU call ends the process returns to CPU, but we keep
45
- # the model reference β€” it will be valid again on the next GPU allocation.
46
- # Reloading on every cpu→cuda transition (once per cold start) is correct;
47
- # reloading on cuda→cpu would double the startup cost for no benefit.
48
  if _lama_model is None or (current_device == "cuda" and _lama_device != "cuda"):
49
  from simple_lama_inpainting import SimpleLama # type: ignore
50
  _lama_model = SimpleLama()
@@ -56,75 +58,76 @@ def _get_model():
56
  # Public API
57
  # ---------------------------------------------------------------------------
58
 
59
- def inpaint_frames_lama(
60
  frame_paths: List[Path],
61
  crop_region: CropRegion,
62
  inpaint_mask: np.ndarray,
63
  progress_fn=None,
64
- ) -> List[np.ndarray]:
65
  """
66
- Run LaMa inpainting on the crop region of each frame.
 
 
 
 
67
 
68
  Parameters
69
  ----------
70
  frame_paths : List[Path]
71
  Ordered list of full-frame PNG paths.
72
  crop_region : CropRegion
73
- Defines the rectangle to extract from each frame.
74
  inpaint_mask : np.ndarray
75
- Crop-local binary mask (H x W, uint8). 255=inpaint, 0=keep.
76
- Must match crop_region dimensions.
77
  progress_fn : callable, optional
78
- Called as ``progress_fn(i)`` after each frame where i is the
79
- 0-based frame index. Use for Gradio progress reporting.
80
 
81
- Returns
82
- -------
83
- List[np.ndarray]
84
- List of inpainted crop images (H x W x 3, uint8 RGB),
85
- one per input frame. Full-frame compositing is done in composite.py.
86
  """
87
  model = _get_model()
88
- # Create mask PIL image once β€” it is identical for every frame
89
  mask_pil = _mask_to_pil(inpaint_mask)
90
- results: List[np.ndarray] = []
91
 
92
  for i, frame_path in enumerate(frame_paths):
93
  crop_np = _load_crop(frame_path, crop_region)
94
- crop_pil = Image.fromarray(crop_np)
95
-
96
- # simple-lama-inpainting expects (image: PIL.Image, mask: PIL.Image)
97
- # mask must be mode "L": 255=inpaint, 0=keep
98
- inpainted_pil: Image.Image = model(crop_pil, mask_pil)
99
- results.append(np.array(inpainted_pil.convert("RGB")))
100
-
101
  if progress_fn is not None:
102
  progress_fn(i)
103
-
104
- return results
105
 
106
 
107
- def inpaint_image_lama(
108
- image: np.ndarray,
109
  crop_region: CropRegion,
110
  inpaint_mask: np.ndarray,
111
- ) -> np.ndarray:
 
112
  """
113
- Run LaMa on a single already-loaded image array (H x W x 3 uint8 RGB).
114
- Convenience wrapper used by the preview step in app.py.
115
 
116
- Returns the inpainted crop (crop_region dimensions, RGB uint8).
117
- """
118
- model = _get_model()
119
- mask_pil = _mask_to_pil(inpaint_mask)
120
 
121
- cr = crop_region
122
- crop_np = image[
123
- cr.frame_y : cr.frame_y + cr.frame_h,
124
- cr.frame_x : cr.frame_x + cr.frame_w,
125
- ]
126
- inpainted_pil: Image.Image = model(Image.fromarray(crop_np), mask_pil)
127
- return np.array(inpainted_pil.convert("RGB"))
 
 
 
 
 
 
 
 
 
 
128
 
129
 
130
  # ---------------------------------------------------------------------------
@@ -135,7 +138,7 @@ def _load_crop(frame_path: Path, crop_region: CropRegion) -> np.ndarray:
135
  """Load a frame and return only the crop region (RGB uint8)."""
136
  img = Image.open(frame_path).convert("RGB")
137
  cr = crop_region
138
- # PIL box is (left, upper, right, lower)
139
  box = (
140
  cr.frame_x,
141
  cr.frame_y,
 
12
  2. Run LaMa on the crop with the dilated inpaint mask.
13
  3. Return the inpainted crop; compositing is handled by composite.py.
14
 
15
+ Public API
16
+ ----------
17
+ - inpaint_frames_lama_stream β€” generator: one crop at a time (memory-efficient)
18
+ - inpaint_frames_lama β€” list version (convenience wrapper around the above)
19
+
20
  License: LaMa is Apache 2.0.
21
  """
22
 
23
  from __future__ import annotations
24
 
25
  from pathlib import Path
26
+ from typing import Generator, List
27
 
28
  import numpy as np
29
  from PIL import Image
 
35
  # Model singleton
36
  # ---------------------------------------------------------------------------
37
  # Loaded lazily on first call; shared across all frames in a run.
38
+ # Tracks the device it was loaded on β€” reloads only when upgrading from CPU
39
+ # to GPU (ZeroGPU warm/cold start handling). Never reloads on cuda→cpu
40
+ # because the process stays alive and the weights remain valid.
41
  _lama_model = None
42
  _lama_device: str | None = None
43
 
 
47
  import torch
48
  current_device = "cuda" if torch.cuda.is_available() else "cpu"
49
  # One-way latch: only reload when *upgrading* from CPU to GPU.
 
 
 
 
50
  if _lama_model is None or (current_device == "cuda" and _lama_device != "cuda"):
51
  from simple_lama_inpainting import SimpleLama # type: ignore
52
  _lama_model = SimpleLama()
 
58
  # Public API
59
  # ---------------------------------------------------------------------------
60
 
61
+ def inpaint_frames_lama_stream(
62
  frame_paths: List[Path],
63
  crop_region: CropRegion,
64
  inpaint_mask: np.ndarray,
65
  progress_fn=None,
66
+ ) -> Generator[np.ndarray, None, None]:
67
  """
68
+ Streaming LaMa inpainting: yields one inpainted crop per frame.
69
+
70
+ Memory-efficient β€” never holds more than one crop in RAM at a time.
71
+ Use this when compositing and saving happen immediately after each yield
72
+ (e.g. in the GPU pipeline loop).
73
 
74
  Parameters
75
  ----------
76
  frame_paths : List[Path]
77
  Ordered list of full-frame PNG paths.
78
  crop_region : CropRegion
 
79
  inpaint_mask : np.ndarray
80
+ Crop-local binary mask (H Γ— W, uint8). 255 = inpaint, 0 = keep.
 
81
  progress_fn : callable, optional
82
+ Called as ``progress_fn(i)`` (0-based frame index) after each frame.
 
83
 
84
+ Yields
85
+ ------
86
+ np.ndarray
87
+ Inpainted crop (crop_h Γ— crop_w Γ— 3, uint8 RGB).
 
88
  """
89
  model = _get_model()
90
+ # Build the mask PIL image once β€” identical for every frame.
91
  mask_pil = _mask_to_pil(inpaint_mask)
 
92
 
93
  for i, frame_path in enumerate(frame_paths):
94
  crop_np = _load_crop(frame_path, crop_region)
95
+ inpainted_pil: Image.Image = model(Image.fromarray(crop_np), mask_pil)
 
 
 
 
 
 
96
  if progress_fn is not None:
97
  progress_fn(i)
98
+ yield np.array(inpainted_pil.convert("RGB"))
 
99
 
100
 
101
+ def inpaint_frames_lama(
102
+ frame_paths: List[Path],
103
  crop_region: CropRegion,
104
  inpaint_mask: np.ndarray,
105
+ progress_fn=None,
106
+ ) -> List[np.ndarray]:
107
  """
108
+ Run LaMa inpainting on all frames and return a list of inpainted crops.
 
109
 
110
+ Convenience wrapper around :func:`inpaint_frames_lama_stream` that
111
+ materialises the full list. Prefer the streaming version when compositing
112
+ immediately to avoid holding all crops in RAM simultaneously.
 
113
 
114
+ Parameters
115
+ ----------
116
+ frame_paths : List[Path]
117
+ crop_region : CropRegion
118
+ inpaint_mask : np.ndarray
119
+ Crop-local binary mask (H Γ— W, uint8). 255 = inpaint, 0 = keep.
120
+ progress_fn : callable, optional
121
+ Called as ``progress_fn(i)`` after each frame.
122
+
123
+ Returns
124
+ -------
125
+ List[np.ndarray]
126
+ Inpainted crop images (crop_h Γ— crop_w Γ— 3, uint8 RGB), one per frame.
127
+ """
128
+ return list(
129
+ inpaint_frames_lama_stream(frame_paths, crop_region, inpaint_mask, progress_fn)
130
+ )
131
 
132
 
133
  # ---------------------------------------------------------------------------
 
138
  """Load a frame and return only the crop region (RGB uint8)."""
139
  img = Image.open(frame_path).convert("RGB")
140
  cr = crop_region
141
+ # PIL box is (left, upper, right, lower) β€” exclusive right/lower
142
  box = (
143
  cr.frame_x,
144
  cr.frame_y,
pipeline/video.py CHANGED
@@ -417,9 +417,11 @@ def _fps_str(fps: float) -> str:
417
  """Convert fps float to a clean string for FFmpeg -framerate."""
418
  # Keep common exact fractions (24000/1001, 30000/1001, etc.)
419
  common = {
420
- 23.976: "24000/1001",
421
- 29.97: "30000/1001",
422
- 59.94: "60000/1001",
 
 
423
  }
424
  for approx, s in common.items():
425
  if abs(fps - approx) < 0.01:
 
417
  """Convert fps float to a clean string for FFmpeg -framerate."""
418
  # Keep common exact fractions (24000/1001, 30000/1001, etc.)
419
  common = {
420
+ 23.976: "24000/1001",
421
+ 29.97: "30000/1001",
422
+ 47.952: "48000/1001", # 48p NTSC
423
+ 59.94: "60000/1001",
424
+ 119.88: "120000/1001", # 120p (S1II high-frame-rate mode)
425
  }
426
  for approx, s in common.items():
427
  if abs(fps - approx) < 0.01:
requirements.txt CHANGED
@@ -2,6 +2,9 @@
2
  # Pinned ranges are intentionally loose so HF's resolver can pick compatible versions.
3
 
4
  # ── Core ──────────────────────────────────────────────────────────────────
 
 
 
5
  gradio>=4.44.0,<5.0.0
6
  numpy>=1.24.0
7
  Pillow>=10.0.0
 
2
  # Pinned ranges are intentionally loose so HF's resolver can pick compatible versions.
3
 
4
  # ── Core ──────────────────────────────────────────────────────────────────
5
+ # Keep <5.0.0: Gradio 5 changed the ImageEditor layer format in a breaking way
6
+ # (_get_mask_from_editor relies on layers[*] being RGBA numpy arrays).
7
+ # Revisit once the new API is stable and _get_mask_from_editor is updated.
8
  gradio>=4.44.0,<5.0.0
9
  numpy>=1.24.0
10
  Pillow>=10.0.0