mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-07 06:28:36 +02:00
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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
c2afb92832
commit
633fc3aa52
@@ -105,7 +105,13 @@ def _write_visible_result(
|
||||
|
||||
shutil.copyfile(source_path, out_path)
|
||||
else:
|
||||
image_io.write_bgr_with_alpha(out_path, result, loaded.alpha)
|
||||
# imwrite is contractually NON-RAISING, so this bool is the only signal the file
|
||||
# was not created. Unchecked, the metadata strip below ran on a nonexistent path
|
||||
# and surfaced as a confusing "cannot read image <INPUT>" naming the OUTPUT path
|
||||
# (Tier E, 2026-07-20). Raise here so a library caller and the CLI both get an
|
||||
# accurate message about the write.
|
||||
if not image_io.write_bgr_with_alpha(out_path, result, loaded.alpha):
|
||||
raise OSError(f"failed to write output (is the destination writable?): {out_path}")
|
||||
|
||||
if strip_metadata:
|
||||
from remove_ai_watermarks import metadata
|
||||
|
||||
@@ -410,6 +410,22 @@ def _parse_sensitivity(value: str) -> watermark_registry.Sensitivity:
|
||||
EXIT_NO_VISIBLE_MARK = 2
|
||||
|
||||
|
||||
def _write_output_or_exit(output: Path, bgr: NDArray[Any], alpha: NDArray[Any] | None) -> None:
|
||||
"""Write the final image, or fail with a readable error instead of a traceback.
|
||||
|
||||
`image_io.imwrite` is contractually NON-RAISING: it returns False when the codec
|
||||
rejects the image or the path cannot be written. Every caller here follows its write
|
||||
with `output.stat()` to report the size, so a silently-failed write (read-only
|
||||
directory, full disk) died with a bare `FileNotFoundError` traceback pointing at the
|
||||
stat, not at the write. Found by the Tier E adversarial sweep 2026-07-20.
|
||||
Regression: `tests/test_cli_robustness.py::TestFailedWriteIsReported`.
|
||||
"""
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not image_io.write_bgr_with_alpha(output, bgr, alpha):
|
||||
console.print(f" Error: failed to write output (is the destination writable?): {output}")
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
def _no_visible_mark_exit(source: Path) -> NoReturn:
|
||||
"""Explain why no visible watermark was removed, then exit non-zero.
|
||||
|
||||
@@ -566,8 +582,11 @@ def _run_visible_auto(
|
||||
except RuntimeError as e: # selected migan/lama backend whose extra is absent
|
||||
console.print(f" Error: {e}")
|
||||
raise SystemExit(1) from e
|
||||
except (ValueError, OSError) as e: # unreadable / truncated / non-image input
|
||||
console.print(f" Error: cannot read image {source.name}: {e}")
|
||||
except (ValueError, OSError) as e:
|
||||
# Covers BOTH an unreadable input and an unwritable output, so the message must
|
||||
# not assert which: it used to say "cannot read image <input>" while quoting the
|
||||
# OUTPUT path, blaming the wrong file (Tier E, 2026-07-20).
|
||||
console.print(f" Error: {e}")
|
||||
raise SystemExit(1) from e
|
||||
|
||||
elapsed = time.monotonic() - t0
|
||||
@@ -630,8 +649,7 @@ def _run_visible_explicit(
|
||||
raise SystemExit(1) from e
|
||||
elapsed = time.monotonic() - t0
|
||||
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
image_io.write_bgr_with_alpha(output, result, alpha)
|
||||
_write_output_or_exit(output, result, alpha)
|
||||
if strip_metadata:
|
||||
try:
|
||||
from remove_ai_watermarks.metadata import remove_ai_metadata
|
||||
@@ -646,7 +664,7 @@ def _run_visible_explicit(
|
||||
|
||||
|
||||
@main.command("visible")
|
||||
@click.argument("source", type=click.Path(exists=True, path_type=Path))
|
||||
@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
||||
@click.option(
|
||||
"-o", "--output", type=click.Path(path_type=Path), default=None, help="Output path (default: <source>_clean.<ext>)."
|
||||
)
|
||||
@@ -729,7 +747,7 @@ def _parse_region(spec: str) -> tuple[int, int, int, int]:
|
||||
|
||||
|
||||
@main.command("erase")
|
||||
@click.argument("source", type=click.Path(exists=True, path_type=Path))
|
||||
@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
||||
@click.option("--region", "regions", multiple=True, required=True, help="x,y,w,h box to erase (repeatable).")
|
||||
@click.option(
|
||||
"-o", "--output", type=click.Path(path_type=Path), default=None, help="Output path (default: <source>_clean.<ext>)."
|
||||
@@ -787,8 +805,7 @@ def cmd_erase(
|
||||
raise SystemExit(1) from e
|
||||
elapsed = time.monotonic() - t0
|
||||
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
image_io.write_bgr_with_alpha(output, result, alpha)
|
||||
_write_output_or_exit(output, result, alpha)
|
||||
|
||||
if strip_metadata:
|
||||
try:
|
||||
@@ -805,7 +822,7 @@ def cmd_erase(
|
||||
|
||||
# ── Invisible watermark removal ──
|
||||
@main.command("invisible")
|
||||
@click.argument("source", type=click.Path(exists=True, path_type=Path))
|
||||
@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
||||
@click.option(
|
||||
"-o", "--output", type=click.Path(path_type=Path), default=None, help="Output path (default: <source>_clean.<ext>)."
|
||||
)
|
||||
@@ -941,7 +958,7 @@ def cmd_invisible(
|
||||
|
||||
# ── Metadata operations ──
|
||||
@main.command("metadata")
|
||||
@click.argument("source", type=click.Path(exists=True, path_type=Path))
|
||||
@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
||||
@click.option("--check", is_flag=True, help="Check for AI metadata (don't modify).")
|
||||
@click.option("--remove", is_flag=True, help="Remove AI metadata.")
|
||||
@click.option(
|
||||
@@ -1008,7 +1025,7 @@ def cmd_metadata(
|
||||
|
||||
# ── Provenance identification ──
|
||||
@main.command("identify")
|
||||
@click.argument("source", type=click.Path(exists=True, path_type=Path))
|
||||
@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
||||
@click.option(
|
||||
"--no-visible",
|
||||
is_flag=True,
|
||||
@@ -1077,7 +1094,7 @@ def cmd_identify(ctx: click.Context, source: Path, no_visible: bool, as_json: bo
|
||||
|
||||
# ── Combined "all" mode ──
|
||||
@main.command("all")
|
||||
@click.argument("source", type=click.Path(exists=True, path_type=Path))
|
||||
@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
||||
@click.option(
|
||||
"-o", "--output", type=click.Path(path_type=Path), default=None, help="Output path (default: <source>_clean.<ext>)."
|
||||
)
|
||||
@@ -1278,12 +1295,11 @@ def cmd_all(
|
||||
# The invisible step (and downstream cv2.IMREAD_COLOR paths) drops alpha,
|
||||
# so re-attach the original alpha plane unchanged when writing the final
|
||||
# output for transparent formats.
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
final_bgr, _ = image_io.read_bgr_and_alpha(tmp_path)
|
||||
if final_bgr is None:
|
||||
console.print(f"Error: Failed to read intermediate file: {tmp_path}")
|
||||
raise SystemExit(1)
|
||||
image_io.write_bgr_with_alpha(output, final_bgr, alpha)
|
||||
_write_output_or_exit(output, final_bgr, alpha)
|
||||
|
||||
finally:
|
||||
# Clean up temp file if it still exists
|
||||
@@ -1319,8 +1335,10 @@ def _passthrough_copy(img_path: Path, out_path: Path) -> None:
|
||||
"""Copy the input's pixels through to ``out_path`` unchanged (the invisible-mode skip
|
||||
paths), so the output dir stays complete without touching the pixels."""
|
||||
src_bgr, src_alpha = image_io.read_bgr_and_alpha(img_path)
|
||||
if src_bgr is not None:
|
||||
image_io.write_bgr_with_alpha(out_path, src_bgr, src_alpha)
|
||||
if src_bgr is not None and not image_io.write_bgr_with_alpha(out_path, src_bgr, src_alpha):
|
||||
# The point of this copy is to keep the output dir COMPLETE. A silently-dropped
|
||||
# copy defeats that and leaves a hole the caller cannot see (Tier E, 2026-07-20).
|
||||
raise OSError(f"failed to copy input through to output: {out_path}")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -1455,7 +1473,12 @@ def _process_batch_image(
|
||||
sensitivity=options.sensitivity,
|
||||
)
|
||||
|
||||
image_io.write_bgr_with_alpha(out_path, result, alpha)
|
||||
# RAISE, never SystemExit: the batch loop catches per-image exceptions, counts
|
||||
# them and exits non-zero. Discarding this flag made a read-only output directory
|
||||
# produce ZERO files and still exit 0 -- silent data loss that also contradicted
|
||||
# the documented batch contract (Tier E, 2026-07-20).
|
||||
if not image_io.write_bgr_with_alpha(out_path, result, alpha):
|
||||
raise OSError(f"failed to write output (is the destination writable?): {out_path}")
|
||||
saved_alpha = alpha
|
||||
|
||||
if mode in ("invisible", "all"):
|
||||
@@ -1479,8 +1502,8 @@ def _process_batch_image(
|
||||
# so re-attach the cached alpha when the input had transparency.
|
||||
if mode == "all" and saved_alpha is not None:
|
||||
final_bgr, _ = image_io.read_bgr_and_alpha(out_path)
|
||||
if final_bgr is not None:
|
||||
image_io.write_bgr_with_alpha(out_path, final_bgr, saved_alpha)
|
||||
if final_bgr is not None and not image_io.write_bgr_with_alpha(out_path, final_bgr, saved_alpha):
|
||||
raise OSError(f"failed to re-attach alpha to output: {out_path}")
|
||||
|
||||
return synthid_skipped
|
||||
|
||||
|
||||
@@ -221,8 +221,8 @@ def read_bgr_and_alpha(path: str | Path) -> tuple[NDArray[Any] | None, NDArray[A
|
||||
return image, None
|
||||
|
||||
|
||||
def write_bgr_with_alpha(path: str | Path, bgr: NDArray[Any], alpha: NDArray[Any] | None) -> None:
|
||||
"""Write BGR (with optional alpha) to ``path``.
|
||||
def write_bgr_with_alpha(path: str | Path, bgr: NDArray[Any], alpha: NDArray[Any] | None) -> bool:
|
||||
"""Write BGR (with optional alpha) to ``path``. Returns ``imwrite``'s success flag.
|
||||
|
||||
When ``alpha`` is provided and the output extension supports it, the original
|
||||
alpha plane is rejoined unchanged. The watermark region is NOT made transparent:
|
||||
@@ -230,10 +230,15 @@ def write_bgr_with_alpha(path: str | Path, bgr: NDArray[Any], alpha: NDArray[Any
|
||||
transparent hole that renders as a white box on any non-transparent viewer
|
||||
(issue #30). Preserving the input alpha keeps genuinely transparent backgrounds
|
||||
intact without inventing new holes.
|
||||
|
||||
Returning the flag is load-bearing: :func:`imwrite` is contractually non-raising, so
|
||||
this is the ONLY signal a caller gets that the file was not created. Discarding it let
|
||||
a failed write (read-only directory, full disk) run on to ``output.stat()`` and die
|
||||
with a bare ``FileNotFoundError`` traceback instead of a readable error.
|
||||
Regression: ``tests/test_cli_robustness.py::TestFailedWriteIsReported``.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
if alpha is None or Path(path).suffix.lower() not in ALPHA_FORMATS:
|
||||
imwrite(path, bgr)
|
||||
return
|
||||
imwrite(path, np.dstack([bgr, alpha]))
|
||||
return imwrite(path, bgr)
|
||||
return imwrite(path, np.dstack([bgr, alpha]))
|
||||
|
||||
Reference in New Issue
Block a user