Files
remove-ai-watermarks/scripts/resource_ceilings.py
T
Victor KuznetsovandClaude Opus 4.8 633fc3aa52 Report failed writes instead of crashing, and reject directories at parse time
The Tier E adversarial sweep (new, scripts/robustness_suite.py) drove the real CLI
over truncated, corrupt, zero-byte, absurdly-shaped and bomb inputs, unicode and
RTL paths, hostile output directories and concurrent runs. It found two crashes;
the /simplify review then reproduced a third and worse one.

1. A FAILED WRITE CRASHED ON THE SIZE REPORT. image_io.imwrite is contractually
   non-raising and returns False, but write_bgr_with_alpha discarded that bool and
   returned None, so no caller could tell a failed write from a successful one.
   Every write site then ran output.stat() to print the size, so a read-only
   destination died with a bare FileNotFoundError pointing at the stat rather than
   the write. The fix is deliberately NOT uniform: single-image commands exit via
   the new cli._write_output_or_exit; api._write_visible_result RAISES so a library
   caller gets an accurate error instead of a confusing FileNotFoundError from the
   downstream metadata strip; and the batch sites raise but never SystemExit,
   because the batch loop counts per-image exceptions and aborting would kill the
   whole run.

2. BATCH LOST DATA SILENTLY. Into a read-only output directory it wrote ZERO files
   for 2 inputs and exited 0 -- no traceback, no error, an empty output directory a
   wrapping service would read as a completed run. The robustness harness could not
   see this class at all, since it scored exit codes and traceback markers and this
   failure has neither; it now asserts on the artifacts written.

3. A DIRECTORY PASSED AS THE IMAGE crashed the metadata scanner with
   IsADirectoryError, because click.Path(exists=True) accepts directories. Fixed
   with dir_okay=False on all six source arguments, so argument parsing refuses it.

Also adds Tier B4 (scripts/resource_ceilings.py): peak RSS per fill backend from
1 MP to 25 MP, one fresh process per cell. migan 603->775 MB and lama 4679->4779 MB
are flat in input size, confirming the crop-around-the-mask design and both
documented figures; cv2 is the only backend that grows (74->440 MB, 5.9x). The
harness's own no-op check originally allocated a full-frame temp before reading
peak RSS and inflated the numbers with input size -- it now compares only the mask
box, and the conclusion survived re-measurement.

And scripts/real_examples_e2e.py, which drives every command over real corpus
examples and checks the outcome rather than the exit code: 6/6 provenance classes
identified, 10/10 metadata strips re-scan clean, all three fill backends write,
diffusion on MPS writes genuinely changed images. It records samsung as a real
partial (the faintest mark, 0.431 -> 0.404 against a 0.40 gate on the weakest of
its 3 corpus positives) and treats the gated pill's refusal to act as correct.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 22:45:02 -07:00

157 lines
7.0 KiB
Python

"""Tier B4: peak RSS and wall time per fill backend across input sizes.
WHY THIS EXISTS
The docs describe the backends in CAPABILITY prose -- "MI-GAN is the memory-tight pick",
"big-LaMa does not fit a minimal droplet", "~0.6-0.9 GB regardless of upload size". Those
numbers were measured once, informally, and are now load-bearing for a real deployment
decision (the free tier runs `migan`, the paid tier runs `lama`). This measures them.
WHAT IT MEASURES
For each (backend, input size): peak RSS of a FRESH process doing exactly one erase, and
the wall time. A fresh subprocess per measurement is the point -- peak RSS inside a
long-lived process is contaminated by whatever ran before it, and the question here is
what a per-request worker actually needs.
The mask is a fixed, small corner region at every size, because the claim under test is
that the learned backends crop around the mask and so their memory is bounded by the MARK
size, not the image size. If that holds, the curve is flat in input size; if it does not,
it climbs and the droplet sizing is wrong.
READING IT
RSS is the peak resident set of the whole worker, which includes the interpreter, numpy,
cv2 and (for the learned backends) onnxruntime plus the model. That is the honest number
for sizing a container -- not the model tensor alone.
DATA SAFETY
Generates its own synthetic inputs. Reads nothing from the corpus, writes nothing tracked.
uv run python scripts/resource_ceilings.py # cv2 + whatever is installed
uv run python scripts/resource_ceilings.py --max-mp 25 # push to 25 MP
"""
from __future__ import annotations
import argparse
import json
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
REPO = Path(__file__).resolve().parents[1]
_UV = shutil.which("uv") or "uv"
# (label, width, height) -- 1 MP up to 25 MP, the range a phone photo upload spans.
SIZES = (("1MP", 1000, 1000), ("4MP", 2000, 2000), ("12MP", 4000, 3000), ("25MP", 5000, 5000))
# The child process: build an image, erase one small corner region, report peak RSS.
# Kept as a string so each measurement is a genuinely fresh interpreter.
_CHILD = """
import json, resource, sys, time
import numpy as np
from remove_ai_watermarks.region_eraser import erase
w, h, backend = int(sys.argv[1]), int(sys.argv[2]), sys.argv[3]
rng = np.random.default_rng(0)
# Textured, not flat: a flat image compresses and can let a backend shortcut work.
img = rng.integers(0, 255, (h, w, 3), dtype=np.uint8)
box = (w - 320, h - 90, 300, 70) # a mark-sized corner region at every input size
t0 = time.monotonic()
try:
out = erase(img, boxes=[box], backend=backend)
# Assert the fill actually RAN. A wrong call signature or a silently-declining
# backend would otherwise report the process's numpy footprint as if it were the
# backend's cost -- the first version of this script did exactly that on all 12
# cells and the numbers looked plausible.
# Compare ONLY the mask box. A full-frame `(out != img).any()` allocates a boolean
# temp the size of the image BEFORE getrusage is read -- 75 MB at 25 MP, ~17% of the
# cv2 figure, and it GROWS with the input, so the harness would partly manufacture
# the very "cv2 scales with input size" conclusion it is measuring.
bx, by, bw_, bh_ = box
changed = int((out[by:by+bh_, bx:bx+bw_] != img[by:by+bh_, bx:bx+bw_]).any())
err = "" if changed else "NO-OP: backend did not modify the masked region"
except Exception as e:
err = f"{type(e).__name__}: {e}"[:200]
elapsed = time.monotonic() - t0
peak_kb = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
print("RESULT" + json.dumps({"peak_kb": peak_kb, "sec": round(elapsed, 2), "error": err}))
"""
def peak_rss_mb(peak_kb: int) -> float:
"""ru_maxrss is BYTES on macOS and KILOBYTES on Linux -- normalize to MB.
Getting this wrong silently reports 1024x off, which would look like a dramatic
finding rather than a unit bug.
"""
return peak_kb / (1024 * 1024) if sys.platform == "darwin" else peak_kb / 1024
def measure(backend: str, w: int, h: int, timeout: int = 900) -> dict[str, object] | None:
with tempfile.NamedTemporaryFile("w", suffix=".py", delete=False) as fh:
fh.write(_CHILD)
child = fh.name
try:
p = subprocess.run( # noqa: S603 -- fixed argv, our own child script
[_UV, "run", "python", child, str(w), str(h), backend],
cwd=REPO,
capture_output=True,
text=True,
timeout=timeout,
check=False,
)
except subprocess.TimeoutExpired:
return {"error": f"TIMEOUT >{timeout}s", "sec": float(timeout), "peak_mb": float("nan")}
finally:
Path(child).unlink(missing_ok=True)
line = next((x for x in p.stdout.splitlines() if x.startswith("RESULT")), None)
if line is None:
return {"error": (p.stderr or p.stdout)[-200:], "sec": 0.0, "peak_mb": float("nan")}
data = json.loads(line[len("RESULT") :])
return {"error": data["error"], "sec": data["sec"], "peak_mb": round(peak_rss_mb(data["peak_kb"]), 1)}
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--max-mp", type=int, default=25, help="skip sizes above this megapixel count")
a = ap.parse_args()
from remove_ai_watermarks.region_eraser import lama_available, migan_available
backends = ["cv2"] + (["migan"] if migan_available() else []) + (["lama"] if lama_available() else [])
sizes = [s for s in SIZES if (s[1] * s[2]) / 1e6 <= a.max_mp + 0.5]
print(f"backends: {backends}\nsizes: {[s[0] for s in sizes]}")
print("\nOne FRESH process per cell; the mask is a fixed small corner at every size.")
print("If the learned backends really crop around the mask, RSS stays flat in input size.\n")
print(f"{'backend':8s} {'size':6s} {'peak RSS':>11s} {'wall':>8s} note")
rows: list[tuple[str, str, dict[str, object]]] = []
for backend in backends:
for label, w, h in sizes:
r = measure(backend, w, h)
if r is None:
continue
rows.append((backend, label, r))
note = str(r["error"])[:48] or "ok"
print(f"{backend:8s} {label:6s} {r['peak_mb']:8} MB {r['sec']:7}s {note}", flush=True)
print(f"\n{'=' * 72}\nRESOURCE CEILINGS\n{'=' * 72}")
for backend in backends:
cells = [(lbl, r) for b, lbl, r in rows if b == backend and not r["error"]]
if not cells:
continue
peaks = [float(r["peak_mb"]) for _, r in cells] # type: ignore[arg-type]
lo, hi = min(peaks), max(peaks)
growth = "flat in input size" if hi <= lo * 1.6 else f"GROWS {hi / max(lo, 0.1):.1f}x with input size"
print(f" {backend:6s} peak {lo:.0f}-{hi:.0f} MB across {cells[0][0]}..{cells[-1][0]} -- {growth}")
print("\nRSS is the whole worker (interpreter + numpy + cv2 + any model), i.e. the")
print("number to size a container with, not the model tensor alone.")
if __name__ == "__main__":
main()