Make InvisibleOptions engine knobs only and pin the forwarding

`InvisibleOptions` promises in its docstring that every default mirrors
`InvisibleEngine`. Two fields made that promise cost something to keep: `force` is
not an engine parameter at all, and `controlnet_scale` was a third spelling of the
engine's `controlnet_conditioning_scale`. The mirror test carried an exception
table for each. This removes both, so the comparison needs no exceptions -- a field
that needs one is a field that belongs somewhere else.

`force` decides WHETHER the engine runs, which is settled before it is built, so it
joins `backend` and `sensitivity` as a parameter of `remove_all` and `remove_batch`
and is threaded to `_run_invisible` as its own argument. `controlnet_scale` takes
the engine's own name; the click option stays `--controlnet-scale` and is now
translated exactly once instead of at three forwarding sites.

Safe to do today: both symbols landed after 0.25.0 and have never been published.

The forwarding turned out to be the weaker half. A defaults comparison cannot see a
hardcoded literal at the seam, and `_run_invisible` passed the entire suite with
`controlnet_conditioning_scale` pinned to a constant. Each of the two knobs also
reaches the engine through TWO paths -- `remove_all` versus `remove_batch(mode="all")`
for `force`, `_run_invisible` versus `_batch_engine` for the scale -- and guarding one
left the other free to hardcode with a green suite. So:

  * `test_every_field_arrives_at_the_engine_with_the_caller_s_value` drives the real
    seam with all 13 fields set off their defaults; mutating any one of them to its
    default fails it.
  * `test_force_reaches_the_scrub_gate_in_every_scrubbing_mode` and
    `test_batch_controlnet_scale_flows_to_the_cached_engine` are parametrized over
    both modes, so neither path can be pinned alone.

Also fixes an order-dependent test surfaced by the added tests reshuffling the xdist
shards. `test_visible_path_decodes_file_once` counted every `image_io.imread` in the
process, but the Gemini engine loads its own bundled capture assets on first
construction, so the count was 3 on a cold engine and 1 on a warm one and the test
passed only when an earlier test happened to build the engine first. It now counts
decodes of the SOURCE, which is the invariant it exists for, and still fails when the
shared decode is broken. The production path was never wrong: the source bitmap is
decoded exactly once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Victor Kuznetsov
2026-08-04 19:33:54 -07:00
co-authored by Claude Opus 5
parent 13095fb45c
commit 4a896cd4b5
8 changed files with 227 additions and 39 deletions
+30 -14
View File
@@ -216,11 +216,18 @@ def remove_visible(
class InvisibleOptions:
"""The invisible stage's knobs, as one value instead of a dozen parameters.
Every default MIRRORS ``InvisibleEngine``, so a bare ``InvisibleOptions()`` behaves
exactly like calling the engine with no arguments; two once drifted and broke
silently, so ``TestInvisibleOptionsMirrorTheEngine`` compares the two signatures
field by field. Immutable so a batch can build it once and reuse it across every
image while the engine itself is cached separately.
ENGINE KNOBS ONLY, under the engine's own names and defaults, so a bare
``InvisibleOptions()`` behaves exactly like calling the engine with no arguments.
The engine takes them across TWO callables -- ``__init__`` for the ones that shape
the loaded stack, ``remove_watermark`` for the per-image ones -- so this is not a
splat-through bag; ``_run_invisible`` forwards each field to the right one.
``TestInvisibleOptionsMirrorTheEngine`` compares the signatures field by field with
no exception table to maintain: a decision made before the engine runs, like
``force``, is a parameter of ``remove_all`` next to ``backend`` and ``sensitivity``,
not a knob smuggled in here.
Immutable so a batch can build it once and reuse it across every image while the
engine itself is cached separately.
"""
strength: float | None = None
@@ -231,13 +238,11 @@ class InvisibleOptions:
unsharp: float = 0.0
adaptive_polish: bool | None = None
max_resolution: int = 0
controlnet_scale: float = 1.0
controlnet_conditioning_scale: float = 1.0
cpu_offload: bool = False
tile: bool = False
tile_size: int = 1024
tile_overlap: int = 128
# Scrub even when no invisible watermark is locally detectable.
force: bool = False
# What the invisible stage did. "unavailable" is the one outcome the caller must
@@ -365,6 +370,7 @@ def remove_all(
backend: Backend = "auto",
sensitivity: Sensitivity = "auto",
invisible: InvisibleOptions | None = None,
force: bool = False,
engine: Any | None = None,
progress: Callable[[str, str], None] | None = None,
) -> RemoveAllResult:
@@ -374,6 +380,11 @@ def remove_all(
the point of staging is that the user never sees a partial output file during a long
model download, and writing the partial next to the final defeats that.
``force`` scrubs even when no invisible watermark is locally detectable. It sits
here rather than in ``InvisibleOptions`` because it decides WHETHER the engine runs,
which is settled before the engine is built; the options carry only what the engine
itself takes.
``engine`` accepts an already-constructed ``InvisibleEngine`` so a batch can build
the model once; leave it None to construct one per call.
@@ -425,7 +436,7 @@ def remove_all(
raise OSError(f"failed to write the staged intermediate: {staged}")
# ── 2. Invisible watermark ──
outcome = _run_invisible(src, staged, staged, opts, engine, say, evidence)
outcome = _run_invisible(src, staged, staged, opts, engine, say, evidence, force)
# ── 3. AI metadata ──
# Read the pristine ORIGINAL for provenance above and the STAGED file here:
@@ -458,6 +469,7 @@ def _run_invisible(
engine: Any | None,
say: Callable[[str, str], None],
evidence: _SourceEvidence,
force: bool,
) -> InvisibleOutcome:
"""Run, or deliberately skip, the diffusion scrub.
@@ -472,7 +484,7 @@ def _run_invisible(
if not is_available():
say("invisible", "unavailable")
return "unavailable"
if not (opts.force or evidence.has_invisible_target()):
if not (force or evidence.has_invisible_target()):
say("invisible", "no-signal")
return "no-signal"
@@ -494,7 +506,7 @@ def _run_invisible(
pipeline=opts.pipeline,
hf_token=opts.hf_token,
progress_callback=lambda message: say("invisible", message),
controlnet_conditioning_scale=opts.controlnet_scale,
controlnet_conditioning_scale=opts.controlnet_conditioning_scale,
cpu_offload=opts.cpu_offload,
)
engine.remove_watermark(
@@ -538,6 +550,7 @@ def remove_batch(
backend: Backend = "auto",
sensitivity: Sensitivity = "auto",
invisible: InvisibleOptions | None = None,
force: bool = False,
engine: Any | None = None,
progress: Callable[[Path, str, str], None] | None = None,
) -> BatchSummary:
@@ -545,8 +558,8 @@ def remove_batch(
Never raises for a single bad image: a per-file failure is counted and recorded in
``BatchSummary.errors`` so one unreadable file cannot abandon the rest of the
directory. ``engine`` is threaded straight through, so a caller that passes a
constructed ``InvisibleEngine`` loads the model once for the whole run.
directory. ``force`` and ``engine`` are threaded straight through, so a caller that
passes a constructed ``InvisibleEngine`` loads the model once for the whole run.
``progress`` receives ``(path, stage, detail)``. Every image ends with exactly one
terminal stage -- ``done`` or ``failed`` -- whatever the mode does in between, so a
@@ -567,7 +580,7 @@ def remove_batch(
for img_path in sorted(p for p in src_dir.iterdir() if is_supported_format(p)):
out_path = out_dir / img_path.name
try:
outcome = _run_batch_one(img_path, out_path, mode, backend, sensitivity, invisible, engine, say)
outcome = _run_batch_one(img_path, out_path, mode, backend, sensitivity, invisible, force, engine, say)
except Exception as exc:
failed += 1
errors.append((img_path, str(exc)))
@@ -590,6 +603,7 @@ def _run_batch_one(
backend: Backend,
sensitivity: Sensitivity,
invisible: InvisibleOptions | None,
force: bool,
engine: Any | None,
say: Callable[[Path, str, str], None],
) -> InvisibleOutcome | None:
@@ -604,6 +618,7 @@ def _run_batch_one(
backend=backend,
sensitivity=sensitivity,
invisible=invisible,
force=force,
engine=engine,
progress=lambda stage, detail: say(img_path, stage, detail),
)
@@ -653,6 +668,7 @@ def _run_batch_one(
engine,
lambda stage, detail: say(img_path, stage, detail),
_SourceEvidence(img_path),
force,
)
if not out_path.exists():
# Keep the output directory COMPLETE even when the pixels are deliberately
+5 -5
View File
@@ -1496,13 +1496,13 @@ def cmd_all(
unsharp=unsharp,
adaptive_polish=adaptive_polish,
max_resolution=max_resolution,
controlnet_scale=controlnet_scale,
controlnet_conditioning_scale=controlnet_scale,
cpu_offload=cpu_offload,
tile=tile,
tile_size=tile_size,
tile_overlap=tile_overlap,
force=force,
),
force=force,
progress=progress,
)
except MetadataStripIncomplete as e:
@@ -1560,7 +1560,7 @@ def _batch_engine(mode: str, options: InvisibleOptions) -> object | None:
return InvisibleEngine(
pipeline=options.pipeline,
hf_token=options.hf_token,
controlnet_conditioning_scale=options.controlnet_scale,
controlnet_conditioning_scale=options.controlnet_conditioning_scale,
cpu_offload=options.cpu_offload,
)
@@ -1642,12 +1642,11 @@ def cmd_batch(
unsharp=unsharp,
adaptive_polish=adaptive_polish,
max_resolution=max_resolution,
controlnet_scale=controlnet_scale,
controlnet_conditioning_scale=controlnet_scale,
cpu_offload=cpu_offload,
tile=tile,
tile_size=tile_size,
tile_overlap=tile_overlap,
force=force,
)
with Progress(
@@ -1678,6 +1677,7 @@ def cmd_batch(
backend=backend, # type: ignore[arg-type]
sensitivity=_parse_sensitivity(sensitivity),
invisible=invisible_options,
force=force,
engine=_batch_engine(mode, invisible_options),
progress=on_progress,
)