mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-06 22:18:36 +02:00
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>
157 lines
7.0 KiB
Python
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()
|