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- app.py +55 -34
- pipeline/composite.py +121 -127
- pipeline/crop.py +23 -12
- pipeline/lama.py +51 -48
- pipeline/video.py +5 -3
- 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
|
| 385 |
frame_paths: list,
|
| 386 |
crop_region: CropRegion,
|
| 387 |
inpaint_mask: np.ndarray,
|
|
|
|
| 388 |
mode: str,
|
| 389 |
total: int,
|
| 390 |
progress,
|
| 391 |
-
) ->
|
| 392 |
"""
|
| 393 |
-
GPU-accelerated inpainting
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 398 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 399 |
if mode == "Fast (LaMa)":
|
| 400 |
-
from pipeline.lama import
|
| 401 |
|
| 402 |
-
def
|
| 403 |
progress(
|
| 404 |
-
0.20 + 0.
|
| 405 |
-
desc=f"LaMa
|
| 406 |
)
|
| 407 |
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 412 |
from pipeline.vace import inpaint_frames_vace
|
| 413 |
-
|
| 414 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 429 |
-
|
| 430 |
-
|
| 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:
|
| 464 |
progress(0.15, desc="Starting inpaintingβ¦")
|
| 465 |
-
|
| 466 |
-
frame_paths, crop_region, inpaint_mask,
|
|
|
|
| 467 |
)
|
| 468 |
|
| 469 |
-
# ββ CPU:
|
| 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
|
| 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 |
-
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
Design
|
| 12 |
------
|
| 13 |
Two-stage blend:
|
| 14 |
-
1. Mask feathering: the inpaint mask is Gaussian-blurred to create a
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 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
|
| 29 |
|
| 30 |
from pipeline.crop import CropRegion
|
| 31 |
|
| 32 |
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
original: np.ndarray,
|
| 35 |
inpainted_crop: np.ndarray,
|
| 36 |
crop_region: CropRegion,
|
| 37 |
-
|
| 38 |
-
feather_radius: int = 8,
|
| 39 |
) -> np.ndarray:
|
| 40 |
"""
|
| 41 |
-
Blend
|
| 42 |
|
| 43 |
Parameters
|
| 44 |
----------
|
| 45 |
original : np.ndarray
|
| 46 |
-
Full-frame image (H
|
| 47 |
-
on a copy β caller's array is not mutated.
|
| 48 |
inpainted_crop : np.ndarray
|
| 49 |
-
Inpainted crop
|
| 50 |
-
Must match crop_region dimensions exactly.
|
| 51 |
crop_region : CropRegion
|
| 52 |
Defines where the crop sits in the full frame.
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 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
|
| 65 |
"""
|
| 66 |
cr = crop_region
|
| 67 |
result = original.copy()
|
| 68 |
|
| 69 |
-
|
| 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 |
-
] =
|
| 103 |
|
| 104 |
return result
|
| 105 |
|
| 106 |
|
| 107 |
-
def
|
| 108 |
-
|
| 109 |
-
|
| 110 |
crop_region: CropRegion,
|
| 111 |
inpaint_mask: np.ndarray,
|
| 112 |
feather_radius: int = 8,
|
| 113 |
-
) ->
|
| 114 |
"""
|
| 115 |
-
|
| 116 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
|
| 118 |
Parameters
|
| 119 |
----------
|
| 120 |
-
|
| 121 |
-
Full-frame
|
| 122 |
-
|
| 123 |
-
|
| 124 |
crop_region : CropRegion
|
| 125 |
inpaint_mask : np.ndarray
|
| 126 |
-
|
| 127 |
feather_radius : int
|
|
|
|
| 128 |
|
| 129 |
Returns
|
| 130 |
-------
|
| 131 |
-
|
| 132 |
-
Full-frame
|
| 133 |
"""
|
| 134 |
-
|
| 135 |
-
|
| 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 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
|
|
|
|
|
|
| 151 |
"""
|
| 152 |
-
|
| 153 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
|
| 155 |
Parameters
|
| 156 |
----------
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
|
| 162 |
Returns
|
| 163 |
-------
|
| 164 |
-
np.ndarray
|
| 165 |
-
|
| 166 |
"""
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 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
|
| 182 |
-
original: np.ndarray,
|
| 183 |
inpainted_crop: np.ndarray,
|
| 184 |
-
|
| 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 |
-
|
| 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
|
| 228 |
-
#
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
|
| 236 |
-
|
| 237 |
-
|
| 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
|
| 34 |
-
#
|
|
|
|
| 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
|
| 60 |
frame_paths: List[Path],
|
| 61 |
crop_region: CropRegion,
|
| 62 |
inpaint_mask: np.ndarray,
|
| 63 |
progress_fn=None,
|
| 64 |
-
) ->
|
| 65 |
"""
|
| 66 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 76 |
-
Must match crop_region dimensions.
|
| 77 |
progress_fn : callable, optional
|
| 78 |
-
Called as ``progress_fn(i)``
|
| 79 |
-
0-based frame index. Use for Gradio progress reporting.
|
| 80 |
|
| 81 |
-
|
| 82 |
-
------
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
one per input frame. Full-frame compositing is done in composite.py.
|
| 86 |
"""
|
| 87 |
model = _get_model()
|
| 88 |
-
#
|
| 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 |
-
|
| 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
|
| 108 |
-
|
| 109 |
crop_region: CropRegion,
|
| 110 |
inpaint_mask: np.ndarray,
|
| 111 |
-
|
|
|
|
| 112 |
"""
|
| 113 |
-
Run LaMa on
|
| 114 |
-
Convenience wrapper used by the preview step in app.py.
|
| 115 |
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
mask_pil = _mask_to_pil(inpaint_mask)
|
| 120 |
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 421 |
-
29.97:
|
| 422 |
-
|
|
|
|
|
|
|
| 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
|