swap-cli 0.1.1__tar.gz → 0.1.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {swap_cli-0.1.1 → swap_cli-0.1.2}/.gitignore +3 -0
- {swap_cli-0.1.1 → swap_cli-0.1.2}/PKG-INFO +1 -1
- {swap_cli-0.1.1 → swap_cli-0.1.2}/pyproject.toml +4 -1
- swap_cli-0.1.2/src/swap_cli/assets/watermark_default.png +0 -0
- {swap_cli-0.1.1 → swap_cli-0.1.2}/src/swap_cli/cli.py +160 -2
- {swap_cli-0.1.1 → swap_cli-0.1.2}/src/swap_cli/config.py +54 -0
- swap_cli-0.1.2/src/swap_cli/display.py +350 -0
- {swap_cli-0.1.1 → swap_cli-0.1.2}/src/swap_cli/gui.py +242 -13
- {swap_cli-0.1.1 → swap_cli-0.1.2}/src/swap_cli/runtime.py +150 -9
- swap_cli-0.1.2/src/swap_cli/version.py +1 -0
- swap_cli-0.1.2/src/swap_cli/watermark.py +786 -0
- {swap_cli-0.1.1 → swap_cli-0.1.2}/tests/test_config.py +36 -0
- {swap_cli-0.1.1 → swap_cli-0.1.2}/tests/test_runtime_timeout.py +33 -3
- {swap_cli-0.1.1 → swap_cli-0.1.2}/tests/test_virtual_camera.py +50 -0
- swap_cli-0.1.2/tests/test_watermark.py +918 -0
- swap_cli-0.1.1/src/swap_cli/display.py +0 -177
- swap_cli-0.1.1/src/swap_cli/version.py +0 -1
- {swap_cli-0.1.1 → swap_cli-0.1.2}/.github/workflows/ci.yml +0 -0
- {swap_cli-0.1.1 → swap_cli-0.1.2}/.github/workflows/release.yml +0 -0
- {swap_cli-0.1.1 → swap_cli-0.1.2}/CHANGELOG.md +0 -0
- {swap_cli-0.1.1 → swap_cli-0.1.2}/LICENSE.md +0 -0
- {swap_cli-0.1.1 → swap_cli-0.1.2}/README.md +0 -0
- {swap_cli-0.1.1 → swap_cli-0.1.2}/docs/RELEASING.md +0 -0
- {swap_cli-0.1.1 → swap_cli-0.1.2}/scripts/mirror_voices.py +0 -0
- {swap_cli-0.1.1 → swap_cli-0.1.2}/src/swap_cli/__init__.py +0 -0
- {swap_cli-0.1.1 → swap_cli-0.1.2}/src/swap_cli/__main__.py +0 -0
- {swap_cli-0.1.1 → swap_cli-0.1.2}/src/swap_cli/camera.py +0 -0
- {swap_cli-0.1.1 → swap_cli-0.1.2}/src/swap_cli/devices.py +0 -0
- {swap_cli-0.1.1 → swap_cli-0.1.2}/src/swap_cli/license.py +0 -0
- {swap_cli-0.1.1 → swap_cli-0.1.2}/src/swap_cli/rvc_catalog.py +0 -0
- {swap_cli-0.1.1 → swap_cli-0.1.2}/src/swap_cli/voice_engines/__init__.py +0 -0
- {swap_cli-0.1.1 → swap_cli-0.1.2}/src/swap_cli/voice_engines/rvc_converter.py +0 -0
- {swap_cli-0.1.1 → swap_cli-0.1.2}/src/swap_cli/voice_engines/rvc_engine.py +0 -0
- {swap_cli-0.1.1 → swap_cli-0.1.2}/src/swap_cli/voice_library.py +0 -0
- {swap_cli-0.1.1 → swap_cli-0.1.2}/src/swap_cli/voice_ops.py +0 -0
- {swap_cli-0.1.1 → swap_cli-0.1.2}/src/swap_cli/voice_prereq.py +0 -0
- {swap_cli-0.1.1 → swap_cli-0.1.2}/src/swap_cli/voice_router.py +0 -0
- {swap_cli-0.1.1 → swap_cli-0.1.2}/src/swap_cli/voice_track.py +0 -0
- {swap_cli-0.1.1 → swap_cli-0.1.2}/src/swap_cli/voices/__init__.py +0 -0
- {swap_cli-0.1.1 → swap_cli-0.1.2}/tests/test_cuda_torch.py +0 -0
- {swap_cli-0.1.1 → swap_cli-0.1.2}/tests/test_devices.py +0 -0
- {swap_cli-0.1.1 → swap_cli-0.1.2}/tests/test_engine_wiring.py +0 -0
- {swap_cli-0.1.1 → swap_cli-0.1.2}/tests/test_engines.py +0 -0
- {swap_cli-0.1.1 → swap_cli-0.1.2}/tests/test_fairseq_patch.py +0 -0
- {swap_cli-0.1.1 → swap_cli-0.1.2}/tests/test_rvc.py +0 -0
- {swap_cli-0.1.1 → swap_cli-0.1.2}/tests/test_rvc_catalog.py +0 -0
- {swap_cli-0.1.1 → swap_cli-0.1.2}/tests/test_settings_modal.py +0 -0
- {swap_cli-0.1.1 → swap_cli-0.1.2}/tests/test_silent_threshold.py +0 -0
- {swap_cli-0.1.1 → swap_cli-0.1.2}/tests/test_sola.py +0 -0
- {swap_cli-0.1.1 → swap_cli-0.1.2}/tests/test_voice_prereq.py +0 -0
- {swap_cli-0.1.1 → swap_cli-0.1.2}/tests/test_voice_router.py +0 -0
- {swap_cli-0.1.1 → swap_cli-0.1.2}/tools/build_library.py +0 -0
- {swap_cli-0.1.1 → swap_cli-0.1.2}/tools/personas.yaml +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: swap-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: Real-time deepfake on your desktop. Bring your own Decart API key.
|
|
5
5
|
Project-URL: Homepage, https://github.com/BlAcQW/swap-cli
|
|
6
6
|
Project-URL: Repository, https://github.com/BlAcQW/swap-cli
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "swap-cli"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.2"
|
|
4
4
|
description = "Real-time deepfake on your desktop. Bring your own Decart API key."
|
|
5
5
|
authors = [{ name = "BlAcQW", email = "enochhenyo@gmail.com" }]
|
|
6
6
|
readme = "README.md"
|
|
@@ -106,6 +106,9 @@ allow-direct-references = true
|
|
|
106
106
|
|
|
107
107
|
[tool.hatch.build.targets.wheel]
|
|
108
108
|
packages = ["src/swap_cli"]
|
|
109
|
+
# Ship the bundled watermark template (and any future data assets) even
|
|
110
|
+
# though *.png would otherwise be excluded by VCS-based file selection.
|
|
111
|
+
artifacts = ["src/swap_cli/assets/*.png"]
|
|
109
112
|
|
|
110
113
|
[tool.ruff]
|
|
111
114
|
line-length = 100
|
|
Binary file
|
|
@@ -194,6 +194,36 @@ def run(
|
|
|
194
194
|
),
|
|
195
195
|
),
|
|
196
196
|
] = True,
|
|
197
|
+
remove_watermark: Annotated[
|
|
198
|
+
bool | None,
|
|
199
|
+
typer.Option(
|
|
200
|
+
"--remove-watermark/--no-remove-watermark",
|
|
201
|
+
help=(
|
|
202
|
+
"Remove the Decart 'AI Generated' watermark from each frame "
|
|
203
|
+
"via template-match + inpaint. A default template ships with "
|
|
204
|
+
"the app; capture your own with `swap capture-watermark` or "
|
|
205
|
+
"the W key. Omit to use your saved preference."
|
|
206
|
+
),
|
|
207
|
+
),
|
|
208
|
+
] = None,
|
|
209
|
+
watermark_template: Annotated[
|
|
210
|
+
Path | None,
|
|
211
|
+
typer.Option(
|
|
212
|
+
"--watermark-template",
|
|
213
|
+
help="Path to the watermark template PNG (overrides saved config).",
|
|
214
|
+
),
|
|
215
|
+
] = None,
|
|
216
|
+
watermark_removal: Annotated[
|
|
217
|
+
str | None,
|
|
218
|
+
typer.Option(
|
|
219
|
+
"--watermark-removal",
|
|
220
|
+
help=(
|
|
221
|
+
"How to hide the badge: 'reconstruct' (rebuild the background, "
|
|
222
|
+
"invisible) or 'blur' (smear it into a soft patch — always works). "
|
|
223
|
+
"Omit to use your saved preference."
|
|
224
|
+
),
|
|
225
|
+
),
|
|
226
|
+
] = None,
|
|
197
227
|
) -> None:
|
|
198
228
|
"""Open a realtime Decart session and stream until you press Q."""
|
|
199
229
|
cfg = config.load()
|
|
@@ -219,6 +249,17 @@ def run(
|
|
|
219
249
|
if status.cached:
|
|
220
250
|
console.print(f"[dim]license: cached ({status.reason})[/dim]")
|
|
221
251
|
|
|
252
|
+
# Tri-state precedence: an explicit --remove-watermark / --no-remove-
|
|
253
|
+
# watermark wins; when omitted (None) fall back to saved config. This
|
|
254
|
+
# keeps --no-remove-watermark working even when config defaults it on.
|
|
255
|
+
wm_enabled = (
|
|
256
|
+
remove_watermark if remove_watermark is not None else cfg.remove_watermark
|
|
257
|
+
)
|
|
258
|
+
wm_template = (
|
|
259
|
+
str(watermark_template) if watermark_template else cfg.watermark_template
|
|
260
|
+
)
|
|
261
|
+
wm_removal = watermark_removal if watermark_removal is not None else cfg.watermark_removal
|
|
262
|
+
|
|
222
263
|
opts = RunOptions(
|
|
223
264
|
decart_api_key=cfg.decart_api_key,
|
|
224
265
|
reference=reference,
|
|
@@ -227,15 +268,24 @@ def run(
|
|
|
227
268
|
camera_device=device,
|
|
228
269
|
record=record,
|
|
229
270
|
virtual_camera=vcam,
|
|
271
|
+
remove_watermark=wm_enabled,
|
|
272
|
+
watermark_template=wm_template,
|
|
273
|
+
watermark_method=cfg.watermark_method,
|
|
274
|
+
watermark_removal=wm_removal,
|
|
275
|
+
watermark_threshold=cfg.watermark_threshold,
|
|
276
|
+
watermark_inpaint_radius=cfg.watermark_inpaint_radius,
|
|
277
|
+
watermark_template_width=cfg.watermark_template_width,
|
|
230
278
|
)
|
|
231
279
|
|
|
280
|
+
_wm_status = "[green]on[/green]" if opts.remove_watermark else "[dim]off[/dim]"
|
|
232
281
|
console.print(
|
|
233
282
|
Panel.fit(
|
|
234
283
|
f"model: [bold]{opts.model_name}[/bold]\n"
|
|
235
284
|
f"reference: {opts.reference or '[dim]none[/dim]'}\n"
|
|
236
285
|
f"camera device: {opts.camera_device}\n"
|
|
237
|
-
f"record: {opts.record or '[dim]off[/dim]'}\n
|
|
238
|
-
"
|
|
286
|
+
f"record: {opts.record or '[dim]off[/dim]'}\n"
|
|
287
|
+
f"watermark removal: {_wm_status}\n\n"
|
|
288
|
+
"[dim]Press [bold]Q[/bold] to quit · [bold]W[/bold] to capture the watermark.[/dim]",
|
|
239
289
|
title="▶ swap · live",
|
|
240
290
|
border_style="cyan",
|
|
241
291
|
)
|
|
@@ -250,6 +300,78 @@ def run(
|
|
|
250
300
|
raise typer.Exit(1) from err
|
|
251
301
|
|
|
252
302
|
|
|
303
|
+
@app.command(name="capture-watermark")
|
|
304
|
+
def capture_watermark(
|
|
305
|
+
snapshot: Annotated[
|
|
306
|
+
Path,
|
|
307
|
+
typer.Argument(
|
|
308
|
+
help="Snapshot PNG/JPG containing the watermark to crop out.",
|
|
309
|
+
),
|
|
310
|
+
],
|
|
311
|
+
roi: Annotated[
|
|
312
|
+
str | None,
|
|
313
|
+
typer.Option(
|
|
314
|
+
"--roi",
|
|
315
|
+
help="Crop region as x,y,w,h in pixels. Omit to crop interactively.",
|
|
316
|
+
),
|
|
317
|
+
] = None,
|
|
318
|
+
) -> None:
|
|
319
|
+
"""Crop a watermark template from a snapshot for `--remove-watermark`.
|
|
320
|
+
|
|
321
|
+
Take a normal snapshot of a live frame that shows the badge, then run
|
|
322
|
+
this to save the watermark template. Pass --roi x,y,w,h for a headless
|
|
323
|
+
crop, or omit it to drag-select in a window.
|
|
324
|
+
"""
|
|
325
|
+
import cv2 # local import: cv2 isn't needed for `swap version` etc.
|
|
326
|
+
|
|
327
|
+
from .display import default_watermark_template_path
|
|
328
|
+
|
|
329
|
+
if not snapshot.exists():
|
|
330
|
+
err_console.print(f"[red]snapshot not found: {snapshot}[/red]")
|
|
331
|
+
raise typer.Exit(2)
|
|
332
|
+
img = cv2.imread(str(snapshot))
|
|
333
|
+
if img is None:
|
|
334
|
+
err_console.print(f"[red]could not read image: {snapshot}[/red]")
|
|
335
|
+
raise typer.Exit(2)
|
|
336
|
+
|
|
337
|
+
if roi:
|
|
338
|
+
try:
|
|
339
|
+
x, y, w, h = (int(v.strip()) for v in roi.split(","))
|
|
340
|
+
except ValueError as err:
|
|
341
|
+
err_console.print("[red]--roi must be 'x,y,w,h' integers[/red]")
|
|
342
|
+
raise typer.Exit(2) from err
|
|
343
|
+
else:
|
|
344
|
+
try:
|
|
345
|
+
x, y, w, h = (int(v) for v in cv2.selectROI("select watermark", img))
|
|
346
|
+
cv2.destroyAllWindows()
|
|
347
|
+
except Exception as err: # noqa: BLE001 — headless without a display
|
|
348
|
+
err_console.print(
|
|
349
|
+
f"[red]interactive crop unavailable ({err}). Pass --roi x,y,w,h.[/red]"
|
|
350
|
+
)
|
|
351
|
+
raise typer.Exit(2) from err
|
|
352
|
+
|
|
353
|
+
if w <= 0 or h <= 0:
|
|
354
|
+
err_console.print("[red]empty selection — nothing saved.[/red]")
|
|
355
|
+
raise typer.Exit(2)
|
|
356
|
+
|
|
357
|
+
crop = img[y : y + h, x : x + w]
|
|
358
|
+
dest = default_watermark_template_path()
|
|
359
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
360
|
+
if not cv2.imwrite(str(dest), crop):
|
|
361
|
+
err_console.print(f"[red]failed to write template → {dest}[/red]")
|
|
362
|
+
raise typer.Exit(1)
|
|
363
|
+
config.update(watermark_template=str(dest))
|
|
364
|
+
console.print(
|
|
365
|
+
Panel.fit(
|
|
366
|
+
f"saved: [bold]{dest}[/bold]\n"
|
|
367
|
+
f"region: {x},{y} {w}×{h}\n\n"
|
|
368
|
+
"[dim]Run [bold]swap run --remove-watermark[/bold] to use it.[/dim]",
|
|
369
|
+
title="✂ watermark template",
|
|
370
|
+
border_style="green",
|
|
371
|
+
)
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
|
|
253
375
|
@app.command()
|
|
254
376
|
def voice(
|
|
255
377
|
voice: Annotated[
|
|
@@ -1088,6 +1210,11 @@ async def _doctor() -> None:
|
|
|
1088
1210
|
f"[yellow]⚠ {vcam_check.label} — {vcam_check.hint}[/yellow]",
|
|
1089
1211
|
)
|
|
1090
1212
|
|
|
1213
|
+
# Watermark removal template (Sprint 15) — show whether a captured
|
|
1214
|
+
# template exists and is loadable, so users know if --remove-watermark
|
|
1215
|
+
# will actually do anything.
|
|
1216
|
+
table.add_row("watermark template", _watermark_template_label())
|
|
1217
|
+
|
|
1091
1218
|
# macOS-only: customtkinter needs Tcl/Tk >= 8.6.9. The system Python
|
|
1092
1219
|
# ships 8.5.9 which fails silently or renders broken windows. Surface
|
|
1093
1220
|
# this here so users know to switch to python.org Python or
|
|
@@ -1106,6 +1233,37 @@ async def _doctor() -> None:
|
|
|
1106
1233
|
sys.exit(1)
|
|
1107
1234
|
|
|
1108
1235
|
|
|
1236
|
+
def _watermark_template_label() -> str:
|
|
1237
|
+
cfg = config.load()
|
|
1238
|
+
source = "custom"
|
|
1239
|
+
template = cfg.watermark_template
|
|
1240
|
+
if not template:
|
|
1241
|
+
from .watermark import bundled_template_path
|
|
1242
|
+
|
|
1243
|
+
bundled = bundled_template_path()
|
|
1244
|
+
if bundled is None:
|
|
1245
|
+
return "[dim]none — run `swap capture-watermark`[/dim]"
|
|
1246
|
+
template = str(bundled)
|
|
1247
|
+
source = "bundled default"
|
|
1248
|
+
path = Path(template)
|
|
1249
|
+
if not path.exists():
|
|
1250
|
+
return f"[yellow]⚠ missing: {path}[/yellow]"
|
|
1251
|
+
try:
|
|
1252
|
+
import cv2
|
|
1253
|
+
|
|
1254
|
+
img = cv2.imread(str(path))
|
|
1255
|
+
if img is None or img.size == 0:
|
|
1256
|
+
return f"[yellow]⚠ unreadable: {path}[/yellow]"
|
|
1257
|
+
h, w = img.shape[:2]
|
|
1258
|
+
ref = cfg.watermark_template_width or 1280
|
|
1259
|
+
return (
|
|
1260
|
+
f"[green]✓ {w}×{h} {path.name} ({source})[/green] "
|
|
1261
|
+
f"[dim]· multi-scale match (ref {ref}px)[/dim]"
|
|
1262
|
+
)
|
|
1263
|
+
except Exception: # noqa: BLE001 — doctor must never crash
|
|
1264
|
+
return f"[green]✓ {path.name} ({source})[/green]"
|
|
1265
|
+
|
|
1266
|
+
|
|
1109
1267
|
def _camera_probe_label() -> str:
|
|
1110
1268
|
try:
|
|
1111
1269
|
import cv2
|
|
@@ -38,6 +38,22 @@ class Config:
|
|
|
38
38
|
# (skip Faiss retrieval). Trades timbre quality for big speedup —
|
|
39
39
|
# essential on weak GPUs or when using voices with huge .index files.
|
|
40
40
|
voice_fast: bool = False
|
|
41
|
+
# Sprint 15: per-frame watermark removal. The Decart "AI Generated"
|
|
42
|
+
# badge roams the frame, so removal runs template-match + inpaint on
|
|
43
|
+
# every frame. All optional — older config files load fine. Off by
|
|
44
|
+
# default so existing users aren't surprised by the latency cost.
|
|
45
|
+
remove_watermark: bool = False
|
|
46
|
+
watermark_template: str | None = None # path to captured watermark PNG
|
|
47
|
+
watermark_method: str = "template" # "template" | "threshold"
|
|
48
|
+
# How a located badge is hidden: "reconstruct" (invisible rebuild) or
|
|
49
|
+
# "blur" (smear into an unreadable soft patch). Detection is separate.
|
|
50
|
+
watermark_removal: str = "reconstruct" # "reconstruct" | "blur"
|
|
51
|
+
watermark_threshold: float = 0.50 # matchTemplate confidence gate (0..1)
|
|
52
|
+
watermark_inpaint_radius: int = 3
|
|
53
|
+
# Frame width the captured template was grabbed at. Decart's output
|
|
54
|
+
# resolution varies, so this centers the multi-scale match exactly for a
|
|
55
|
+
# user-captured template. None → the 1280px bundled-default assumption.
|
|
56
|
+
watermark_template_width: int | None = None
|
|
41
57
|
|
|
42
58
|
@property
|
|
43
59
|
def is_complete(self) -> bool:
|
|
@@ -69,6 +85,13 @@ def load() -> Config:
|
|
|
69
85
|
last_voice_output=_int_or_none(data.get("last_voice_output")),
|
|
70
86
|
voice_engine=_clean(data.get("voice_engine")) or "rvc",
|
|
71
87
|
voice_fast=bool(data.get("voice_fast", False)),
|
|
88
|
+
remove_watermark=bool(data.get("remove_watermark", False)),
|
|
89
|
+
watermark_template=_clean(data.get("watermark_template")),
|
|
90
|
+
watermark_method=_clean(data.get("watermark_method")) or "template",
|
|
91
|
+
watermark_removal=_clean(data.get("watermark_removal")) or "reconstruct",
|
|
92
|
+
watermark_threshold=_float_or_none(data.get("watermark_threshold")) or 0.50,
|
|
93
|
+
watermark_inpaint_radius=_int_or_none(data.get("watermark_inpaint_radius")) or 3,
|
|
94
|
+
watermark_template_width=_int_or_none(data.get("watermark_template_width")),
|
|
72
95
|
)
|
|
73
96
|
|
|
74
97
|
|
|
@@ -98,6 +121,20 @@ def save(cfg: Config) -> Path:
|
|
|
98
121
|
body.append(f'voice_engine = "{_escape(cfg.voice_engine)}"')
|
|
99
122
|
if cfg.voice_fast:
|
|
100
123
|
body.append("voice_fast = true")
|
|
124
|
+
if cfg.remove_watermark:
|
|
125
|
+
body.append("remove_watermark = true")
|
|
126
|
+
if cfg.watermark_template:
|
|
127
|
+
body.append(f'watermark_template = "{_escape(cfg.watermark_template)}"')
|
|
128
|
+
if cfg.watermark_method and cfg.watermark_method != "template":
|
|
129
|
+
body.append(f'watermark_method = "{_escape(cfg.watermark_method)}"')
|
|
130
|
+
if cfg.watermark_removal and cfg.watermark_removal != "reconstruct":
|
|
131
|
+
body.append(f'watermark_removal = "{_escape(cfg.watermark_removal)}"')
|
|
132
|
+
if cfg.watermark_threshold != 0.50:
|
|
133
|
+
body.append(f"watermark_threshold = {cfg.watermark_threshold}")
|
|
134
|
+
if cfg.watermark_inpaint_radius != 3:
|
|
135
|
+
body.append(f"watermark_inpaint_radius = {cfg.watermark_inpaint_radius}")
|
|
136
|
+
if cfg.watermark_template_width is not None:
|
|
137
|
+
body.append(f"watermark_template_width = {cfg.watermark_template_width}")
|
|
101
138
|
|
|
102
139
|
text = "\n".join(body) + "\n"
|
|
103
140
|
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
@@ -124,6 +161,17 @@ def update(**kwargs: Any) -> Config:
|
|
|
124
161
|
last_voice_output=kwargs.get("last_voice_output", current.last_voice_output),
|
|
125
162
|
voice_engine=kwargs.get("voice_engine", current.voice_engine),
|
|
126
163
|
voice_fast=kwargs.get("voice_fast", current.voice_fast),
|
|
164
|
+
remove_watermark=kwargs.get("remove_watermark", current.remove_watermark),
|
|
165
|
+
watermark_template=kwargs.get("watermark_template", current.watermark_template),
|
|
166
|
+
watermark_method=kwargs.get("watermark_method", current.watermark_method),
|
|
167
|
+
watermark_removal=kwargs.get("watermark_removal", current.watermark_removal),
|
|
168
|
+
watermark_threshold=kwargs.get("watermark_threshold", current.watermark_threshold),
|
|
169
|
+
watermark_inpaint_radius=kwargs.get(
|
|
170
|
+
"watermark_inpaint_radius", current.watermark_inpaint_radius
|
|
171
|
+
),
|
|
172
|
+
watermark_template_width=kwargs.get(
|
|
173
|
+
"watermark_template_width", current.watermark_template_width
|
|
174
|
+
),
|
|
127
175
|
)
|
|
128
176
|
save(merged)
|
|
129
177
|
return merged
|
|
@@ -148,5 +196,11 @@ def _int_or_none(value: Any) -> int | None:
|
|
|
148
196
|
return None
|
|
149
197
|
|
|
150
198
|
|
|
199
|
+
def _float_or_none(value: Any) -> float | None:
|
|
200
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
201
|
+
return float(value)
|
|
202
|
+
return None
|
|
203
|
+
|
|
204
|
+
|
|
151
205
|
def _escape(value: str) -> str:
|
|
152
206
|
return value.replace("\\", "\\\\").replace('"', '\\"')
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"""Render an aiortc remote video track in a cv2.imshow window.
|
|
2
|
+
|
|
3
|
+
Also handles snapshot-on-keypress, optional MP4 recording, and
|
|
4
|
+
(Sprint 14k) optional output to a virtual camera device so apps like
|
|
5
|
+
Zoom/Meet/Discord see the deepfake stream directly.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import time
|
|
12
|
+
from contextlib import suppress
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import TYPE_CHECKING, Any
|
|
15
|
+
|
|
16
|
+
import cv2
|
|
17
|
+
import numpy as np
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from aiortc.mediastreams import MediaStreamTrack
|
|
21
|
+
|
|
22
|
+
from .watermark import WatermarkRemover
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
WINDOW_TITLE = "swap — Lucy 2 live"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Display:
|
|
29
|
+
"""Pulls frames from a remote MediaStreamTrack and renders them.
|
|
30
|
+
|
|
31
|
+
Press Q in the window or call `stop()` to terminate the loop.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
track: MediaStreamTrack,
|
|
37
|
+
*,
|
|
38
|
+
record_path: Path | None = None,
|
|
39
|
+
on_quit: callable = lambda: None, # type: ignore[assignment]
|
|
40
|
+
virtual_camera: bool = False,
|
|
41
|
+
watermark: WatermarkRemover | None = None,
|
|
42
|
+
show_window: bool = True,
|
|
43
|
+
) -> None:
|
|
44
|
+
self._track = track
|
|
45
|
+
self._record_path = record_path
|
|
46
|
+
self._on_quit = on_quit
|
|
47
|
+
self._virtual_camera = virtual_camera
|
|
48
|
+
# Show the cv2 preview window. False in GUI mode: the session runs on a
|
|
49
|
+
# worker thread, and macOS forbids OpenCV HighGUI off the main thread, so
|
|
50
|
+
# the GUI renders frames itself (tkinter, main thread) via latest_frame().
|
|
51
|
+
self._show_window = show_window
|
|
52
|
+
# Sprint 15: optional per-frame watermark remover. None = no-op.
|
|
53
|
+
# Injected as a configured instance so _loop stays thin and the
|
|
54
|
+
# remover is unit-testable in isolation.
|
|
55
|
+
self._watermark = watermark
|
|
56
|
+
self._writer: cv2.VideoWriter | None = None
|
|
57
|
+
# pyvirtualcam.Camera — lazy-init on first frame so we know the
|
|
58
|
+
# actual width/height from Decart's stream rather than guessing.
|
|
59
|
+
self._vcam: Any = None
|
|
60
|
+
self._task: asyncio.Task[None] | None = None
|
|
61
|
+
self._stopped = asyncio.Event()
|
|
62
|
+
self._latest_bgr: np.ndarray | None = None
|
|
63
|
+
# Raw (pre-removal) frame, kept so the W-key capture grabs the badge
|
|
64
|
+
# even while watermark removal is on.
|
|
65
|
+
self._latest_raw_bgr: np.ndarray | None = None
|
|
66
|
+
|
|
67
|
+
def start(self) -> None:
|
|
68
|
+
self._task = asyncio.create_task(self._loop())
|
|
69
|
+
|
|
70
|
+
async def stop(self) -> None:
|
|
71
|
+
self._stopped.set()
|
|
72
|
+
if self._task:
|
|
73
|
+
self._task.cancel()
|
|
74
|
+
with suppress(asyncio.CancelledError):
|
|
75
|
+
await self._task
|
|
76
|
+
if self._writer is not None:
|
|
77
|
+
self._writer.release()
|
|
78
|
+
if self._vcam is not None:
|
|
79
|
+
with suppress(Exception):
|
|
80
|
+
self._vcam.close()
|
|
81
|
+
self._vcam = None
|
|
82
|
+
if self._show_window:
|
|
83
|
+
cv2.destroyAllWindows()
|
|
84
|
+
|
|
85
|
+
def snapshot(self, dest: Path) -> bool:
|
|
86
|
+
"""Save the most recent rendered frame as JPEG. Returns success."""
|
|
87
|
+
if self._latest_bgr is None:
|
|
88
|
+
return False
|
|
89
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
return cv2.imwrite(str(dest), self._latest_bgr)
|
|
91
|
+
|
|
92
|
+
def latest_frame(self) -> np.ndarray | None:
|
|
93
|
+
"""Most recent processed (cleaned) frame — for the in-app GUI preview.
|
|
94
|
+
The render loop rebinds this each frame, so the read is atomic."""
|
|
95
|
+
return self._latest_bgr
|
|
96
|
+
|
|
97
|
+
def latest_raw_frame(self) -> np.ndarray | None:
|
|
98
|
+
"""Most recent RAW (pre-removal) frame — for GUI watermark capture."""
|
|
99
|
+
return self._latest_raw_bgr
|
|
100
|
+
|
|
101
|
+
async def _loop(self) -> None:
|
|
102
|
+
if self._show_window:
|
|
103
|
+
cv2.namedWindow(WINDOW_TITLE, cv2.WINDOW_NORMAL)
|
|
104
|
+
cv2.resizeWindow(WINDOW_TITLE, 960, 540)
|
|
105
|
+
first_frame = True
|
|
106
|
+
try:
|
|
107
|
+
while not self._stopped.is_set():
|
|
108
|
+
frame = await self._track.recv()
|
|
109
|
+
bgr = frame.to_ndarray(format="bgr24")
|
|
110
|
+
self._latest_raw_bgr = bgr # keep the un-cleaned frame for W capture
|
|
111
|
+
# Sprint 15: strip the Decart watermark before anything
|
|
112
|
+
# downstream sees the frame. process() never raises — it
|
|
113
|
+
# returns the frame unchanged on any failure.
|
|
114
|
+
if self._watermark is not None:
|
|
115
|
+
bgr = self._watermark.process(bgr)
|
|
116
|
+
self._latest_bgr = bgr
|
|
117
|
+
self._maybe_init_writer(bgr.shape, fps_guess=20)
|
|
118
|
+
if self._writer is not None:
|
|
119
|
+
self._writer.write(bgr)
|
|
120
|
+
# Sprint 14k: also push the frame to the OBS Virtual Camera
|
|
121
|
+
# driver so Zoom/Meet/Discord pick it up as a real camera.
|
|
122
|
+
# pyvirtualcam expects RGB.
|
|
123
|
+
if self._virtual_camera:
|
|
124
|
+
self._maybe_init_vcam(bgr.shape, fps_guess=20)
|
|
125
|
+
if self._vcam is not None:
|
|
126
|
+
try:
|
|
127
|
+
rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
|
|
128
|
+
self._vcam.send(rgb)
|
|
129
|
+
self._vcam.sleep_until_next_frame()
|
|
130
|
+
except Exception as err: # noqa: BLE001
|
|
131
|
+
print(f"[display] vcam send error: {err}", flush=True)
|
|
132
|
+
# Don't tear the driver down on a single bad frame.
|
|
133
|
+
if not self._show_window:
|
|
134
|
+
# GUI renders the preview itself (tkinter, main thread) and
|
|
135
|
+
# drives capture/stop — no cv2 window/keys here. Yield so a
|
|
136
|
+
# non-vcam session doesn't spin the loop hot.
|
|
137
|
+
if not self._virtual_camera:
|
|
138
|
+
await asyncio.sleep(0)
|
|
139
|
+
first_frame = False
|
|
140
|
+
continue
|
|
141
|
+
cv2.imshow(WINDOW_TITLE, bgr)
|
|
142
|
+
if first_frame:
|
|
143
|
+
# Flash topmost so the cv2 window pops above the tk GUI on
|
|
144
|
+
# Windows. We don't want it pinned forever — just one beat.
|
|
145
|
+
with suppress(Exception):
|
|
146
|
+
cv2.setWindowProperty(WINDOW_TITLE, cv2.WND_PROP_TOPMOST, 1)
|
|
147
|
+
cv2.waitKey(1)
|
|
148
|
+
cv2.setWindowProperty(WINDOW_TITLE, cv2.WND_PROP_TOPMOST, 0)
|
|
149
|
+
first_frame = False
|
|
150
|
+
key = cv2.waitKey(1) & 0xFF
|
|
151
|
+
if key in (ord("q"), ord("Q"), 27): # q or ESC
|
|
152
|
+
self._on_quit()
|
|
153
|
+
self._stopped.set()
|
|
154
|
+
break
|
|
155
|
+
if key in (ord("w"), ord("W")): # Sprint 15: capture watermark
|
|
156
|
+
self._capture_watermark_template()
|
|
157
|
+
except asyncio.CancelledError:
|
|
158
|
+
raise
|
|
159
|
+
except Exception as err: # noqa: BLE001 — show + exit cleanly
|
|
160
|
+
# The Decart remote track raises (often with an empty message) when
|
|
161
|
+
# the connection drops. End the session cleanly so it doesn't hang
|
|
162
|
+
# in "reconnecting" — the user can click Live again.
|
|
163
|
+
detail = str(err) or err.__class__.__name__
|
|
164
|
+
print(f"[display] stream ended: {detail} — stopping session.", flush=True)
|
|
165
|
+
with suppress(Exception):
|
|
166
|
+
self._on_quit()
|
|
167
|
+
self._stopped.set()
|
|
168
|
+
finally:
|
|
169
|
+
if self._show_window:
|
|
170
|
+
cv2.destroyAllWindows()
|
|
171
|
+
|
|
172
|
+
def capture_watermark(self, roi: tuple[int, int, int, int]) -> None:
|
|
173
|
+
"""Public entry for the GUI: capture the badge template from a caller-
|
|
174
|
+
supplied ROI (the GUI's tk drag-selector), instead of cv2.selectROI."""
|
|
175
|
+
self._capture_watermark_template(roi=roi)
|
|
176
|
+
|
|
177
|
+
def _capture_watermark_template(
|
|
178
|
+
self, roi: tuple[int, int, int, int] | None = None
|
|
179
|
+
) -> None:
|
|
180
|
+
"""Select the watermark on the current frame and save it as the template
|
|
181
|
+
PNG (Sprint 15). `roi` is supplied by the GUI's tk selector; when None
|
|
182
|
+
(CLI / cv2-window mode) we fall back to cv2.selectROI on the W key.
|
|
183
|
+
|
|
184
|
+
Captures from the RAW (pre-removal) frame so it works even while
|
|
185
|
+
removal is on but not matching. Persists the path AND the frame width
|
|
186
|
+
so the multi-scale match centers exactly, then HOT-RELOADS the live
|
|
187
|
+
remover so removal starts immediately — no restart needed.
|
|
188
|
+
"""
|
|
189
|
+
# Prefer the raw frame; fall back to the displayed one.
|
|
190
|
+
source = self._latest_raw_bgr
|
|
191
|
+
if source is None:
|
|
192
|
+
source = self._latest_bgr
|
|
193
|
+
if source is None:
|
|
194
|
+
print("[display] no frame yet — can't capture watermark.", flush=True)
|
|
195
|
+
return
|
|
196
|
+
try:
|
|
197
|
+
from . import config as _config
|
|
198
|
+
|
|
199
|
+
if roi is None:
|
|
200
|
+
roi = cv2.selectROI(
|
|
201
|
+
"select watermark — ENTER to save, C to cancel",
|
|
202
|
+
source,
|
|
203
|
+
showCrosshair=False,
|
|
204
|
+
)
|
|
205
|
+
cv2.destroyWindow("select watermark — ENTER to save, C to cancel")
|
|
206
|
+
x, y, w, h = (int(v) for v in roi)
|
|
207
|
+
if w <= 0 or h <= 0:
|
|
208
|
+
print("[display] watermark capture cancelled.", flush=True)
|
|
209
|
+
return
|
|
210
|
+
# Guard against a stray click saving a junk template that then
|
|
211
|
+
# matches nothing — the badge is ~150–260px wide.
|
|
212
|
+
if w < 20 or h < 10:
|
|
213
|
+
print(
|
|
214
|
+
f"[display] selection too small ({w}x{h}) — press W again "
|
|
215
|
+
"and drag a box around the whole badge.",
|
|
216
|
+
flush=True,
|
|
217
|
+
)
|
|
218
|
+
return
|
|
219
|
+
crop = source[y : y + h, x : x + w]
|
|
220
|
+
crop = _tighten_to_badge(crop) # shrink a loose box to the strokes
|
|
221
|
+
frame_width = int(source.shape[1])
|
|
222
|
+
dest = default_watermark_template_path()
|
|
223
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
224
|
+
if not cv2.imwrite(str(dest), crop):
|
|
225
|
+
print(f"[display] failed to write template → {dest}", flush=True)
|
|
226
|
+
return
|
|
227
|
+
_config.update(
|
|
228
|
+
watermark_template=str(dest),
|
|
229
|
+
watermark_template_width=frame_width,
|
|
230
|
+
)
|
|
231
|
+
print(
|
|
232
|
+
f"[display] watermark template saved → {dest} "
|
|
233
|
+
f"(from {frame_width}px-wide frame)",
|
|
234
|
+
flush=True,
|
|
235
|
+
)
|
|
236
|
+
# Hot-reload: rebuild the live remover from the just-saved config so
|
|
237
|
+
# removal starts THIS frame — no restart. Pressing W is a clear
|
|
238
|
+
# intent to remove, so we enable even if the toggle was off.
|
|
239
|
+
from .watermark import WatermarkRemover
|
|
240
|
+
|
|
241
|
+
new = WatermarkRemover.from_config(_config.load(), enabled=True)
|
|
242
|
+
if new is not None:
|
|
243
|
+
self._watermark = new
|
|
244
|
+
print(
|
|
245
|
+
"[display] watermark removal now using the captured template "
|
|
246
|
+
"(live) — if the badge is still visible, press W again and "
|
|
247
|
+
"box it tighter.",
|
|
248
|
+
flush=True,
|
|
249
|
+
)
|
|
250
|
+
except Exception as err: # noqa: BLE001 — capture is best-effort
|
|
251
|
+
print(f"[display] watermark capture error: {err}", flush=True)
|
|
252
|
+
|
|
253
|
+
def _maybe_init_writer(self, shape: tuple[int, ...], fps_guess: int) -> None:
|
|
254
|
+
if self._record_path is None or self._writer is not None:
|
|
255
|
+
return
|
|
256
|
+
h, w = shape[:2]
|
|
257
|
+
fourcc = cv2.VideoWriter_fourcc(*"mp4v") # type: ignore[attr-defined]
|
|
258
|
+
self._record_path.parent.mkdir(parents=True, exist_ok=True)
|
|
259
|
+
self._writer = cv2.VideoWriter(str(self._record_path), fourcc, fps_guess, (w, h))
|
|
260
|
+
|
|
261
|
+
def _maybe_init_vcam(self, shape: tuple[int, ...], fps_guess: int) -> None:
|
|
262
|
+
"""Open pyvirtualcam.Camera on the first frame so we use the
|
|
263
|
+
actual stream resolution. Silently no-ops if pyvirtualcam isn't
|
|
264
|
+
installed or no virtual camera driver is registered — preview
|
|
265
|
+
window keeps working in that case."""
|
|
266
|
+
if self._vcam is not None:
|
|
267
|
+
return
|
|
268
|
+
try:
|
|
269
|
+
import pyvirtualcam # type: ignore[import-not-found]
|
|
270
|
+
except ImportError:
|
|
271
|
+
# Pure-Python wrapper missing; ship hint via the doctor row.
|
|
272
|
+
self._virtual_camera = False
|
|
273
|
+
print(
|
|
274
|
+
"[display] vcam: pyvirtualcam not installed — `pip install pyvirtualcam`",
|
|
275
|
+
flush=True,
|
|
276
|
+
)
|
|
277
|
+
return
|
|
278
|
+
h, w = shape[:2]
|
|
279
|
+
try:
|
|
280
|
+
# backend=None lets pyvirtualcam pick the right one per OS:
|
|
281
|
+
# Windows → OBS Virtual Camera, macOS → OBS, Linux → v4l2loopback.
|
|
282
|
+
self._vcam = pyvirtualcam.Camera(width=w, height=h, fps=fps_guess)
|
|
283
|
+
print(
|
|
284
|
+
f"[display] vcam ready: '{self._vcam.device}' "
|
|
285
|
+
f"{w}x{h}@{fps_guess}fps — Zoom/Meet/Discord can pick it now.",
|
|
286
|
+
flush=True,
|
|
287
|
+
)
|
|
288
|
+
except Exception as err: # noqa: BLE001
|
|
289
|
+
# Driver not installed, busy, or fps unsupported. Disable vcam
|
|
290
|
+
# for this session and keep the preview window healthy.
|
|
291
|
+
self._virtual_camera = False
|
|
292
|
+
self._vcam = None
|
|
293
|
+
print(
|
|
294
|
+
f"[display] vcam unavailable: {err}\n"
|
|
295
|
+
"[display] Install OBS Studio for the OBS Virtual Camera driver: "
|
|
296
|
+
"https://obsproject.com/download",
|
|
297
|
+
flush=True,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _tighten_to_badge(crop: np.ndarray) -> np.ndarray:
|
|
302
|
+
"""Shrink a (possibly loose) selection to the badge's bright strokes, so a
|
|
303
|
+
sloppy drag still yields a tight, background-free template that matches
|
|
304
|
+
with high confidence. Uses the same white top-hat as the matcher; returns
|
|
305
|
+
the original crop if no clear strokes are found."""
|
|
306
|
+
try:
|
|
307
|
+
if crop.size == 0 or crop.shape[0] < 8 or crop.shape[1] < 8:
|
|
308
|
+
return crop
|
|
309
|
+
gray = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY)
|
|
310
|
+
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (21, 21))
|
|
311
|
+
top = cv2.morphologyEx(gray, cv2.MORPH_TOPHAT, kernel)
|
|
312
|
+
_ret, strokes = cv2.threshold(top, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
|
|
313
|
+
ys, xs = np.where(strokes > 0)
|
|
314
|
+
if xs.size < 20: # not enough signal — keep the user's box
|
|
315
|
+
return crop
|
|
316
|
+
m = 6 # small margin around the strokes
|
|
317
|
+
x0 = max(0, int(xs.min()) - m)
|
|
318
|
+
y0 = max(0, int(ys.min()) - m)
|
|
319
|
+
x1 = min(crop.shape[1], int(xs.max()) + m + 1)
|
|
320
|
+
y1 = min(crop.shape[0], int(ys.max()) + m + 1)
|
|
321
|
+
tight = crop[y0:y1, x0:x1]
|
|
322
|
+
if tight.shape[0] < 8 or tight.shape[1] < 20:
|
|
323
|
+
return crop
|
|
324
|
+
print(
|
|
325
|
+
f"[display] tightened selection {crop.shape[1]}x{crop.shape[0]} "
|
|
326
|
+
f"-> {tight.shape[1]}x{tight.shape[0]} (badge strokes)",
|
|
327
|
+
flush=True,
|
|
328
|
+
)
|
|
329
|
+
return tight
|
|
330
|
+
except Exception: # noqa: BLE001 — tightening is best-effort
|
|
331
|
+
return crop
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def default_watermark_template_path() -> Path:
|
|
335
|
+
"""Canonical location for the captured watermark template PNG."""
|
|
336
|
+
from platformdirs import user_config_dir
|
|
337
|
+
|
|
338
|
+
from .config import APP_NAME
|
|
339
|
+
|
|
340
|
+
return Path(user_config_dir(APP_NAME)) / "watermarks" / "watermark.png"
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def default_snapshot_path() -> Path:
|
|
344
|
+
ts = time.strftime("%Y%m%d-%H%M%S")
|
|
345
|
+
return Path.cwd() / "snapshots" / f"swap-{ts}.jpg"
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def default_recording_path() -> Path:
|
|
349
|
+
ts = time.strftime("%Y%m%d-%H%M%S")
|
|
350
|
+
return Path.cwd() / "recordings" / f"swap-{ts}.mp4"
|