"""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()