Restructure documentation, validate metadata removal, consolidate assets

This commit is contained in:
Victor Kuznetsov
2026-07-25 21:08:04 -07:00
parent 214c9bb3e7
commit 03cd00f132
172 changed files with 2356 additions and 2863 deletions
+1 -1
View File
@@ -312,7 +312,7 @@ def cmd_train(pattern: str, model_path: str, schema: FeatureSchema = "v2") -> No
from sklearn.metrics import average_precision_score, roc_auc_score
# keep the feature vector, not the record: a parsed scan record is an order of
# magnitude larger than the row it collapses to, and the corpus is ~5-digit.
# magnitude larger than the row it collapses to.
labeled: dict[str, tuple[list[float], int, str]] = {}
missing_hash_index = 0
for r in iter_records(pattern):
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

+6 -6
View File
@@ -41,8 +41,8 @@ THE MEASUREMENT
selects -- the script writes a contact sheet for exactly that.
DATA SAFETY
Corpus images are user uploads: read-only, local analysis, gitignored output. The
template is font-rendered synthetic, never cut from a user upload.
Treat input datasets as sensitive and read-only, and keep output gitignored. The
template is font-rendered synthetic, never cut from an input image.
uv run python scripts/cjk_tail_probe.py --n 6000
"""
@@ -66,12 +66,12 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
sys.path.insert(0, str(Path(__file__).parent))
REPO = Path(__file__).resolve().parents[1]
CORPUS = REPO / "data" / "spaces" / "originals"
OUT = REPO / "data" / "spaces" / "_cjk_tail_probe.jsonl"
CORPUS = REPO / ".local-eval" / "originals"
OUT = REPO / ".local-eval" / "cjk-tail-probe.jsonl"
# Cached under the gitignored data dir, not in scripts/: this is a probe artifact, not a
# product asset. If the tail mark is ever registered, `render_vendor_silhouettes.py` is
# what writes the committed silhouette into src/.../assets/.
TAIL_PNG = REPO / "data" / "spaces" / "_cjk_tail_silhouette.png"
TAIL_PNG = REPO / ".local-eval" / "cjk-tail-silhouette.png"
# The tail is a fraction of a full vendor mark's width (`豆包AI生成` is ~5 CJK widths,
# `AI生成` ~3), and the prefix length differs per vendor, so the size is genuinely
@@ -262,7 +262,7 @@ def contact_sheet(rows: list[dict[str, Any]], thresh: float, limit: int = 30) ->
if crop.size:
tiles.append(cv2.resize(crop, (320, 96), interpolation=cv2.INTER_AREA))
if tiles:
dest = REPO / "data" / "spaces" / "_cjk_tail_sheet.png"
dest = REPO / ".local-eval" / "cjk-tail-sheet.png"
cv2.imwrite(str(dest), np.vstack(tiles))
print(f"\ncontact sheet ({len(tiles)} crops, score >= {thresh:.3f}) -> {dest}")
print("scores: " + ", ".join(f"{r['tail_score']:.2f}" for r in picks[: len(tiles)]))
+5 -5
View File
@@ -14,12 +14,12 @@ Two jobs in one pass:
first audit naively scanned the first megabyte.
This is how new detector gaps get found (it is what surfaced the JPEG-EXIF
``{"AIGC":{...}}`` form). Re-run after collecting a fresh corpus batch.
``{"AIGC":{...}}`` form). Re-run after collecting a fresh evaluation batch.
Usage:
uv run python scripts/corpus_gap_scan.py --corpus data/spaces/originals
uv run python scripts/corpus_gap_scan.py --corpus data/spaces/originals \\
--report data/spaces/detector_report.csv
uv run python scripts/corpus_gap_scan.py --corpus .local-eval/originals
uv run python scripts/corpus_gap_scan.py --corpus .local-eval/originals \\
--report .local-eval/detector-report.csv
"""
from __future__ import annotations
@@ -122,7 +122,7 @@ def _row(rep) -> dict[str, str]: # noqa: ANN001 (ProvenanceReport)
@click.option(
"--corpus",
type=click.Path(exists=True, file_okay=False, path_type=Path),
default=Path("data/spaces/originals"),
default=Path(".local-eval/originals"),
show_default=True,
help="Directory of images to scan (recursively).",
)
+3 -3
View File
@@ -57,8 +57,8 @@ READING `mask_hit`
top-left geometry box, so it covers the stamp by definition. Reported, and flagged.
DATA SAFETY
Corpus images are user uploads: read-only, local analysis, gitignored output under
data/spaces/. Records source filenames and measurements, never image content.
Treat input datasets as sensitive and read-only. Keep generated reports under
.local-eval/. Reports record source filenames and measurements, never image content.
uv run python scripts/detector_response.py --n 12 # trial, measures throughput
uv run python scripts/detector_response.py --n 150 # the real run, resumable
@@ -84,7 +84,7 @@ sys.path.insert(0, str(Path(__file__).parent))
from fill_quality import SLOT_STAMPABLE, STAMPABLE, clean_sources, stamp_any, texture_of
REPO = Path(__file__).resolve().parents[1]
OUT = REPO / "data" / "spaces" / "_detector_response.jsonl"
OUT = REPO / ".local-eval" / "detector-response.jsonl"
# 1.0 = the geometry/opacity the engine's own constants assume. The sweep reaches below
# it (a mark rendered smaller, or a faint translucent overlay -- the class the tophat
+5 -5
View File
@@ -24,8 +24,8 @@ WHAT IT DOES NOT MEASURE
fill numbers -- this isolates the FILL. Detection accuracy is Tier C's job.
DATA SAFETY
Corpus images are user uploads: read-only, local analysis, output under a gitignored
data/spaces/ path. No image content is written into the report.
Treat input datasets as sensitive and read-only. Keep output under the gitignored
.local-eval/ path. No image content is written into the report.
uv run python scripts/fill_quality.py --n 60
"""
@@ -50,8 +50,8 @@ sys.path.insert(0, str(Path(__file__).parent))
from invisible_quality_audit import _ssim # reuse, do not reimplement a third SSIM
REPO = Path(__file__).resolve().parents[1]
CORPUS = REPO / "data" / "spaces" / "originals"
OUT = REPO / "data" / "spaces" / "_fill_quality.jsonl"
CORPUS = REPO / ".local-eval" / "originals"
OUT = REPO / ".local-eval" / "fill-quality.jsonl"
# Text marks: a bundled alpha PNG plus the engine's own corner geometry.
STAMPABLE = ("doubao", "jimeng", "samsung")
@@ -216,7 +216,7 @@ def clean_sources(n: int, seed: int = 11) -> list[Path]:
continue
if any(d.detected for d in detect_marks(img)):
continue
except Exception: # noqa: S112 -- a bad corpus file just is not a candidate
except Exception: # noqa: S112 -- an unreadable local file is not a candidate
continue
out.append(path)
return out
+5 -5
View File
@@ -9,12 +9,12 @@ SSIM alone does NOT equal "bad": a high-texture image legitimately changes under
the SDXL scrub. Use the ranked output to pick candidates, then look at them to
name the failure classes (garbled text, deformed faces, over-smoothed detail).
Operates on gitignored data only (data/spaces/...); writes nothing tracked.
Operates on gitignored local data only; writes nothing tracked.
uv run python scripts/invisible_quality_audit.py \
--originals data/spaces/originals/2026-06-03 \
--cleaned data/spaces/results/2026-06-03 \
--out data/spaces/_quality_audit.csv --worst 25
--originals .local-eval/originals \
--cleaned .local-eval/results \
--out .local-eval/quality-audit.csv --worst 25
"""
from __future__ import annotations
@@ -60,7 +60,7 @@ def _stem(name: str) -> str:
@click.command()
@click.option("--originals", type=click.Path(exists=True, file_okay=False, path_type=Path), required=True)
@click.option("--cleaned", type=click.Path(exists=True, file_okay=False, path_type=Path), required=True)
@click.option("--out", type=click.Path(path_type=Path), default=Path("data/spaces/_quality_audit.csv"))
@click.option("--out", type=click.Path(path_type=Path), default=Path(".local-eval/quality-audit.csv"))
@click.option("--worst", type=int, default=25, help="Print the N lowest-SSIM pairs.")
def main(originals: Path, cleaned: Path, out: Path, worst: int) -> None:
logging.basicConfig(level=logging.WARNING, format="%(message)s")
+4 -4
View File
@@ -35,7 +35,7 @@ THE MEASUREMENT
percentage is an upper bound on what a denser ladder buys.
DATA SAFETY
Corpus images are user uploads: read-only, local analysis, gitignored output.
Treat input datasets as sensitive and read-only, and keep output gitignored.
uv run python scripts/ladder_headroom.py --mark doubao --n 4000
"""
@@ -60,8 +60,8 @@ import numpy as np
sys.path.insert(0, str(Path(__file__).parent.parent))
REPO = Path(__file__).resolve().parents[1]
CORPUS = REPO / "data" / "spaces" / "originals"
OUT = REPO / "data" / "spaces" / "_ladder_headroom.jsonl"
CORPUS = REPO / ".local-eval" / "originals"
OUT = REPO / ".local-eval" / "ladder-headroom.jsonl"
# The rungs the product ships today, and the dense ladder under evaluation. The dense one
# is geometric with a ~6% step, chosen from the measured half-width of a rung's lobe
@@ -189,7 +189,7 @@ def main() -> None:
# for a mark with rivals, `dense_crosses` would ignore the competitive margin and read
# optimistically. Widening the binary front-end is a separate experiment.
ap.add_argument("--mark", default="doubao", choices=["doubao"])
ap.add_argument("--n", type=int, default=4000, help="corpus files to scan")
ap.add_argument("--n", type=int, default=4000, help="local files to scan")
ap.add_argument("--workers", type=int, default=max(1, (os.cpu_count() or 4) - 2))
ap.add_argument("--out", type=Path, default=OUT)
ap.add_argument("--report-only", action="store_true")
+12 -6
View File
@@ -11,11 +11,11 @@ metadata, never the coded image.
A no-op control set (clean images with no AI metadata) verifies the stripper
neither ADDS a signal nor corrupts pixels on files it should leave alone.
Operates on gitignored data only (data/spaces/...); writes nothing tracked.
Operates on gitignored local data only; writes nothing tracked.
uv run python scripts/metadata_removal_audit.py \
--corpus data/spaces/originals --identify data/spaces/identify \
--out data/spaces/_metadata_removal_audit.csv --jobs 8
--corpus .local-eval/originals --identify .local-eval/identify \
--out .local-eval/metadata-removal-audit.csv --jobs 8
"""
from __future__ import annotations
@@ -157,15 +157,21 @@ def _candidate_paths(corpus: Path, identify: Path | None, clean_sample: int) ->
@click.command()
@click.option(
"--corpus", type=click.Path(exists=True, file_okay=False, path_type=Path), default=Path("data/spaces/originals")
"--corpus",
type=click.Path(exists=True, file_okay=False, path_type=Path),
default=Path(".local-eval/originals"),
)
@click.option(
"--identify",
type=click.Path(path_type=Path),
default=Path("data/spaces/identify"),
default=Path(".local-eval/identify"),
help="identify-JSON dir to pick carriers (skip = scan all).",
)
@click.option("--out", type=click.Path(path_type=Path), default=Path("data/spaces/_metadata_removal_audit.csv"))
@click.option(
"--out",
type=click.Path(path_type=Path),
default=Path(".local-eval/metadata-removal-audit.csv"),
)
@click.option(
"--clean-sample", type=int, default=1500, help="No-op control: N clean images to prove the strip is a no-op."
)
+3 -3
View File
@@ -19,7 +19,7 @@ THE CORROBORATION PROXY AND ITS BIAS
wordmark -- so measured precision is a LOWER BOUND, not a point estimate. Do not quote
it as if it were exact.
Corpus images are user uploads: read-only, local analysis, gitignored output.
Treat input datasets as sensitive and read-only, and keep output gitignored.
uv run python scripts/pill_gate_audit.py --jobs 7
"""
@@ -40,8 +40,8 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
REPO = Path(__file__).resolve().parents[1]
POSITIVES = REPO / "data" / "spaces" / "_visible_positives.jsonl"
OUT = REPO / "data" / "spaces" / "_pill_gate_audit.jsonl"
POSITIVES = REPO / ".local-eval" / "visible-positives.jsonl"
OUT = REPO / ".local-eval" / "pill-gate-audit.jsonl"
def wilson(k: int, n: int, z: float = 1.96) -> tuple[float, float]:
+8 -8
View File
@@ -1,9 +1,9 @@
"""End-to-end confidence run: drive the ACTUAL CLI over REAL corpus examples.
"""End-to-end confidence run over local evaluation examples.
WHY THIS EXISTS AND WHAT IT IS NOT
The 849-test suite and `smoke_matrix.py` prove the code paths behave on fixtures and
synthetic inputs. This is the other half: run the real `remove-ai-watermarks` entry point,
as a user would, over real corpus images spanning every command and every provenance
as a user would, over local evaluation images spanning every command and provenance
class, and CHECK THE OUTPUT -- not that it exited 0, but that it did the right thing (the
mark is actually gone on re-detect, the metadata actually strips, the diffusion actually
writes a changed image). A green exit is not evidence the work happened.
@@ -22,7 +22,7 @@ WHAT IT COVERS
cv2/numpy and fast.
DATA SAFETY
Corpus images are user uploads: read-only, local analysis, outputs to a gitignored temp
Treat input datasets as sensitive and read-only. Output stays in a gitignored temp
dir. Records example uids and pass/fail, never image content.
uv run python scripts/real_examples_e2e.py # fast surface (no diffusion)
@@ -44,9 +44,9 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
REPO = Path(__file__).resolve().parents[1]
CORPUS = REPO / "data" / "spaces" / "originals"
DATASETS = REPO / "data" / "spaces" / "_visible_datasets"
SAMPLES = REPO / "data" / "samples"
CORPUS = REPO / ".local-eval" / "originals"
DATASETS = REPO / ".local-eval" / "visible-datasets"
SAMPLES = REPO / "data" / "fixtures" / "provenance"
_UV = shutil.which("uv") or "uv" # full path avoids the partial-executable lint
@@ -64,7 +64,7 @@ def run(args: list[str], timeout: int = 300) -> tuple[int, str]:
def find_visible_positive(mark: str) -> Path | None:
"""A real corpus image the parity run bucketed as carrying this mark, that the current
"""A local evaluation image bucketed as carrying this mark, which the current
detector STILL fires on (the bucket was built by an older run; re-confirm live)."""
from remove_ai_watermarks.image_io import imread
from remove_ai_watermarks.watermark_registry import detect_marks
@@ -111,7 +111,7 @@ class Results:
for line in out.strip().splitlines()[-12:]:
print(f" {line}")
if not bad:
print(" every command produced the right result on real corpus examples")
print(" every command produced the right result on local evaluation examples")
return 1 if bad else 0
+2 -3
View File
@@ -8,9 +8,8 @@ content), used ONLY to (a) detect the pill by edge-NCC in the top-left corner an
inpaint backend (MI-GAN/cv2), so the silhouette need not be pixel-accurate, and the
synthetic render keeps corpus/user content out of the tracked repo (data-safety).
Detection was calibrated on the retained local corpus (61 real positives + jimeng
negatives): edge-NCC threshold ~0.22 in the top-left ROI. Re-run to regenerate the
asset: uv run python scripts/render_pill_silhouette.py
The calibrated edge-NCC threshold is 0.22 in the top-left ROI. Re-run to regenerate
the asset: uv run python scripts/render_pill_silhouette.py
Requires a CJK font (macOS STHeiti by default); the asset itself is committed, so this
script only runs when regenerating it (never in CI).
+34 -137
View File
@@ -1,95 +1,11 @@
"""Render SYNTHETIC detection silhouettes for the CJK vendor text marks (data-safe).
"""Render synthetic detection silhouettes for vendor text marks.
Adding a mark needs only a DETECTION silhouette, and it must be font-rendered rather
than derived from user uploads: the corpus is real user content and may never reach a
tracked asset (see the repo CLAUDE.md data-safety rule). Seeing real samples to learn
the glyphs, weight and layout is fine; the committed template stays synthetic.
Committed assets must be font-rendered and contain no source-image pixels. Local
evaluation inputs may be used only to learn glyphs, weight, layout, and detector
thresholds. Candidate assets stay outside the installed package until calibrated.
Covered here:
qwen "千问AI生成" -- Alibaba Tongyi Qianwen, bottom-right, 3-lobed logo + text
xinghui "星绘AI生成" -- ByteDance 星绘, bottom-right, 4-point sparkle + text
yuanbao "元宝\nAI生成" -- Tencent Yuanbao, bottom-right, two-line italic block
(REGISTERED 2026-07-25 after fixing the negative-shear clipping in this
renderer and matching both light and dark mark polarities)
kling "可灵AI 3.0" -- Kuaishou Kling, bottom-right, spiral logo + text
(REGISTERED 2026-07-21, kling_engine.py)
The leading LOGO is deliberately NOT rendered. It is the part that varies most between
releases and is hardest to reproduce synthetically, while the CJK run is stable and is
what actually discriminates one vendor from another (the shared `AI生成` tail is exactly
what does NOT discriminate -- see the rival-margin mechanism in _text_mark_engine).
Regenerate with: uv run python scripts/render_vendor_silhouettes.py
STATUS 2026-07-21: `qwen_alpha.png` IS registered (`qwen_engine.py`) -- the 2026-07-18
blocker quoted below turned out to be mis-sized GEOMETRY (two size modes + a locate box
that clipped the first glyph), not segmentation, and was solved by the TC260-producer
cohort harvest + `vendor_mark_calibrate.py` (117 labelled frames; full record in
`docs/verification-plan.md`). `xinghui_alpha.png` is still NOT registered: one confirmed
corpus example is nothing to calibrate a gate against.
--- the 2026-07-18 record, kept as the history of the failed first attempt ---
Measured on 14 hand-verified 千问 positives from the corpus,
the then-current detect architecture (top-hat glyph blob -> binary TM_CCOEFF_NORMED)
could not see this mark AT ALL:
same pipeline, each mark scored with its OWN template, on real positives
doubao n=40 mean NCC 0.723 median 0.835 >= 0.40 gate: 82%
qwen n=14 mean NCC 0.170 median 0.179 >= 0.40 gate: 0%
Three checks ruled out the obvious explanations, in order:
1. NOT the synthetic render. A template cut from an ACTUAL Qwen mark scores the same
as the font-rendered one (real-vs-real 0.307 vs synthetic 0.308) -- and real masks
do not even match EACH OTHER.
2. NOT the morphology kernel size. Scaling MORPH_OPEN/CLOSE with the box height (they
are fixed 5px, ~9% of a 57px-tall box) gained only +0.014 mean and moved nothing
across the gate.
3. NOT the appearance thresholds. Sweeping tophat_delta / logo_min_luma / kernel
reached at best mean 0.35 with 4/14 over the gate.
The blocker was named SEGMENTATION on a faint mark: Doubao is stamped bold and opaque,
so the white top-hat returns a clean glyph blob; the Qwen mark is a thin translucent
overlay that shatters into specks, and no template can match a blob that is not there.
The `tophat` front-end (built later, for doubao) removed that blocker -- and 千问 STILL
did not register, because the real residual was geometry. See the 2026-07-21 status
above.
星绘 additionally has only ONE confirmed example in the corpus, so even a working
front-end could not have its threshold calibrated yet.
UPDATE 2026-07-20: the named blocker is GONE, and the retry is still inconclusive.
`detect_frontend="tophat"` (built later, for doubao) is exactly the "grayscale correlation
on the raw top-hat" this note asked for, so the 2026-07-18 ruling rests on a premise that
no longer holds and must not simply be inherited. Two things were measured against it, and
neither settles the question:
* A GENERIC template of the shared `AI生成` tail -- attractive because GB 45438-2025
guarantees that run across vendors, so one template would cover 千问 / 百度 / 星绘 and
anything compliant that ships next. Measured on the tophat front-end at the shipped
3-rung ladder: a bold 千问 positive scores 0.407 against clean corners at p99 0.298 /
max 0.321. It separates on that one frame, but only by a hair, and a 4-glyph template
is inherently less specific than a 6-glyph one -- the shorter the run, the more
arbitrary corner structure correlates with it.
* The FULL 千问 template on the same front-end scores 0.248 against a clean max of 0.537,
i.e. no separation at all -- WORSE than the generic tail, which is the opposite of
what the specificity argument predicts and is itself a reason to distrust n=1.
The blocker is now EVIDENCE, not architecture: this session found exactly one 千问 and one
百度 positive (both by eyeballing doubao-provenance misses), and the 14 positives quoted
above were not preserved anywhere the current scripts can reach. Nothing should be
registered off a single frame.
UPDATE 2026-07-21 (the resolution): the evidence arrived via the TC260 producer-USCC
cohort trick (`scripts/vendor_cohort_harvest.py` -- 117 labelled 千问 frames from metadata
alone), and the registration shipped the same day (`qwen_engine.py`). The "no separation
at all" reading above was the MIS-SIZED geometry, not the mark: at the fitted geometry the
full template separates the cohort from clean corners 0.662 vs 0.134 (p50). The traps
below still bind any NEXT vendor: score with `alpha_height_frac`, not the silhouette's own
aspect ratio (the latter inflated the clean p99 from 0.30 to 0.58 and made every
comparison meaningless); keep the ladder at the shipped rungs for gate-setting, since a
wide sweep hands clean corners many extra chances to match; and re-filter the clean arm
per candidate -- the 2026-07-18 `present: []` labels mean "no REGISTERED mark", so qwen
-cohort frames visibly carrying 千问AI生成 sat in it (see `vendor_mark_calibrate.load_sets`).
Regenerate with:
uv run python scripts/render_vendor_silhouettes.py
"""
from __future__ import annotations
@@ -101,7 +17,13 @@ from typing import Any
import numpy as np
from PIL import Image, ImageDraw, ImageFont
_ASSETS = Path(__file__).resolve().parents[1] / "src" / "remove_ai_watermarks" / "assets"
_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(_ROOT / "src"))
from remove_ai_watermarks.watermark_registry import mark_keys # noqa: E402
_PACKAGE_ASSETS = _ROOT / "src" / "remove_ai_watermarks" / "assets"
_CANDIDATE_ASSETS = _ROOT / "scripts" / "assets" / "visible-mark-candidates"
# STHeiti Medium approximates the semibold CJK sans these marks are set in; the exact
# family is unpublished for every vendor (GB 45438-2025 only requires a legible face).
_FONT = "/System/Library/Fonts/STHeiti Medium.ttc"
@@ -110,55 +32,34 @@ MARKS = {
"qwen_alpha.png": "千问AI生成",
"xinghui_alpha.png": "星绘AI生成",
# Yuanbao's stamp is a TWO-LINE block (元宝 over AI生成), left-aligned, tightly
# stacked and ITALIC-SLANTED (measured on the 2026-07-21 cohort sheet + real tophat
# responses); a rare one-line variant exists but the stacked block is dominant.
# stacked and ITALIC-SLANTED. A rare one-line variant exists, but the stacked block
# is dominant.
"yuanbao_alpha.png": "元宝\nAI生成",
# Kling (可灵) stamps a thin light-gray one-line "可灵AI 3.0" bottom-right (an
# "Omni" suffix variant and a latin "KlingAI 3.0" variant also exist; the CJK
# run without the suffix is the common core). The leading spiral logo is NOT
# rendered (logos vary; the text run discriminates).
"kling_alpha.png": "可灵AI 3.0",
# The "cat-logo" cohort (USCC 91110108562144110X) stamps an outline cat-head +
# bold "AI生成", bottom-right. PARKED 2026-07-21: the cohort is 19 copies of
# only 2 unique carriers -- nothing to calibrate recall against (the xinghui
# rule). The probe is ready: this silhouette scores 0.50 on the mark vs 0.333
# max on a diverse clean arm, so registration is a gate pick (0.42) the moment
# more unique carriers arrive.
# The "cat-logo" candidate stamps an outline cat-head plus bold "AI生成",
# bottom-right. It remains unregistered pending sufficient calibration coverage.
"catlogo_alpha.png": "CATLOGO", # sentinel: drawn by draw_catlogo(), not font-rendered
# RunningHub (ComfyUI platform, USCC 91340100MAEB4N8H76, 73-frame cohort
# 2026-07-22): white one-line "RunningHub AI生成" text mark.
# RunningHub top-left text mark.
"runninghub_alpha.png": "RunningHub AI生成",
# LibLibAI / 哩布哩布AI (USCC 91110105MACJ6K1C8A, 15-frame cohort): white
# "LibLibAI" wordmark with a triangle logo (logo not rendered, logos vary).
# LibLibAI bottom-center wordmark.
"liblib_alpha.png": "LibLibAI",
# Zhipu Qingyan (USCC 91110108MA01KP2T5U, 7-frame cohort): white bold
# "清言·AI生成" with a circular logo (logo not rendered). PARKED 2026-07-22
# as a measured negative: on both front-ends the cohort scores 0.34-0.39
# against a clean-arm max of 0.34-0.37 -- no separation at any render/box
# setting (text-only and logo-composite templates both plateau ~0.34 raw;
# the white semi-transparent text on variable backgrounds is the wall).
# Silhouette stays as the starting point for a structural/learned lever.
# Zhipu Qingyan candidate text mark.
"qingyan_alpha.png": "清言·AI生成",
# MiniMax / Hailuo (6-frame cohort): "MINIMAX" + "Hailuo AI" latin wordmarks.
# PARKED 2026-07-22: only 1 of the 6 cohort frames carries a visible mark --
# nothing to calibrate recall against (the xinghui rule). Registration is a
# gate pick once more unique carriers arrive.
# MiniMax / Hailuo candidate wordmark.
"hailuo_alpha.png": "Hailuo AI",
# Baidu (USCC 91110000802100433B, 16-frame cohort): white bold "百度" text
# + a separate white rounded tag with dark "AI生成", bottom-right. Detection
# keys on the 百度 text run ONLY: a two-component template (text+pill) scored
# at clean-arm levels (pill = bright-blob magnet, clean p95 0.45-0.55 vs cohort
# ~0.5, no separation on either front-end, 2026-07-22); the text-only silhouette
# separates (cohort 0.39-0.65 vs clean max 0.352). The white tag is removed
# with the mark because the fill blob covers both bright components.
# Baidu bottom-right text run.
"baidu_alpha.png": "百度",
}
_REGISTERED = {f"{key}_alpha.png" for key in mark_keys()} & MARKS.keys()
# Per-mark post-processing for the multi-line / slanted stamps (see render()).
MARK_OPTS: dict[str, dict[str, Any]] = {
# Re-fitted 2026-07-25 after the old affine transform was found to clip the
# lower line and retain a large blank right half. Hiragino Sans GB W6, tight
# leading, a 2px dilation, and -0.60 shear match the standard Yuanbao stamp.
# Hiragino Sans GB W6, tight leading, dilation, and negative shear match the
# standard Yuanbao stamp without clipping the lower line.
"yuanbao_alpha.png": {
"gap_frac": 0.05,
"dilate": 2,
@@ -166,14 +67,9 @@ MARK_OPTS: dict[str, dict[str, Any]] = {
"font": "/System/Library/Fonts/Hiragino Sans GB.ttc",
"font_index": 2,
},
# Qingyan's real stamp is a heavier weight than STHeiti Medium -- Hiragino
# Sans GB W6 matches the measured stroke (2026-07-22; with Medium the
# silhouette aspect came out 0.19 vs the real 0.28 and NCC plateaued ~0.3).
# Qingyan uses a heavier weight than STHeiti Medium.
"qingyan_alpha.png": {"font": "/System/Library/Fonts/Hiragino Sans GB.ttc", "font_index": 2},
# LibLibAI's wordmark is set in an Arial-class grotesque, not STHeiti:
# measured 2026-07-22 across 7 candidate fonts, Arial lifts the cohort
# positives from 0.31-0.47 to 0.42-0.73 while the full-corpus false-fire arm
# DROPS to max 0.398 (generic latin UI text matches the wrong font less).
# LibLibAI uses an Arial-class grotesque.
"liblib_alpha.png": {"font": "/System/Library/Fonts/Supplemental/Arial.ttf"},
}
@@ -237,10 +133,8 @@ def render(text: str, width: int = 335, opts: dict[str, Any] | None = None) -> n
def draw_catlogo(width: int = 335) -> np.ndarray:
"""The cat-logo mark: an outline cat-head (integrated pointy ears, two dot eyes)
+ a bold "AI生成" run, drawn synthetically from the measured layout (cat ~1.08x
the glyph height, stroke ~9%, gap ~35%). Proportions were iterated against a real
tophat response (2026-07-21): a solid filled head scored 0.35, this outline form
0.50 -- the parked probe, see MARKS."""
+ a bold "AI生成" run, drawn synthetically from the calibrated layout. The outline
form is the parked candidate described in MARKS."""
probe = Image.new("L", (10, 10))
d0 = ImageDraw.Draw(probe)
text = "AI生成"
@@ -292,8 +186,11 @@ def main() -> None:
try:
for name, text in MARKS.items():
sil = draw_catlogo() if text == "CATLOGO" else render(text, opts=MARK_OPTS.get(name))
Image.fromarray(sil).save(_ASSETS / name)
print(f"wrote {_ASSETS / name} ({sil.shape[1]}x{sil.shape[0]}) text={text!r}")
output_dir = _PACKAGE_ASSETS if name in _REGISTERED else _CANDIDATE_ASSETS
output_dir.mkdir(parents=True, exist_ok=True)
output = output_dir / name
Image.fromarray(sil).save(output)
print(f"wrote {output} ({sil.shape[1]}x{sil.shape[0]}) text={text!r}")
except OSError as e:
print(f"Font not found ({e}); install a CJK font or edit _FONT.", file=sys.stderr)
raise SystemExit(1) from e
+9 -9
View File
@@ -14,10 +14,10 @@ WHAT "PASS" MEANS HERE
the exit code looks tidy.
WHY THESE INPUTS
Every case is drawn from something real: ~0.2% of corpus uploads are truncated, ~2% carry
a mismatched extension, Unicode filenames were issue #17, and a wrapping service will run
The suite covers truncated files, mismatched extensions, Unicode filenames, and
concurrent access because a wrapping service may run
concurrent jobs against one path. Decompression bombs and absurd geometry are the cheap
denial-of-service shapes any tool taking user uploads must survive.
denial-of-service shapes any tool taking untrusted files must survive.
DATA SAFETY
Builds its own inputs (synthetic, or truncated copies of committed fixtures) inside a
@@ -43,7 +43,7 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
REPO = Path(__file__).resolve().parents[1]
SAMPLES = REPO / "data" / "samples"
SAMPLES = REPO / "data" / "fixtures" / "provenance"
_UV = shutil.which("uv") or "uv"
# A traceback in the output means the failure escaped the error handling, whatever the
@@ -103,7 +103,7 @@ def graceful(res: Results, case: str, cmd: str, args: list[str], timeout: int =
def make_inputs(tmp: Path) -> dict[str, Path]:
"""Build the adversarial corpus. Each entry is something a real upload can be."""
"""Build the adversarial corpus from representative malformed inputs."""
import numpy as np
from remove_ai_watermarks.image_io import imwrite
@@ -115,7 +115,7 @@ def make_inputs(tmp: Path) -> dict[str, Path]:
imwrite(good, np.full((600, 800, 3), 128, np.uint8))
made["good"] = good
# Truncated: a real PNG cut mid-stream (~0.2% of real uploads).
# Truncated PNG cut mid-stream.
src = SAMPLES / "chatgpt-1.png"
if src.exists():
raw = src.read_bytes()
@@ -159,7 +159,7 @@ def make_inputs(tmp: Path) -> dict[str, Path]:
shutil.copy2(good, rtl)
made["rtl_filename"] = rtl
# Mismatched extension: PNG content named .jpg (~2% of real uploads).
# Mismatched extension: PNG content named .jpg.
mismatch = tmp / "png_named_jpg.jpg"
shutil.copy2(good, mismatch)
made["mismatched_extension"] = mismatch
@@ -280,8 +280,8 @@ def check_batch_silent_loss(res: Results, tmp: Path, inputs: dict[str, Path]) ->
"""The nastiest shape: NO output files AND a success exit code.
`graceful()` cannot see this class -- it scores exit code and traceback markers, and a
run that writes nothing while exiting 0 has neither. Corpus-reproduced 2026-07-20:
`batch --mode visible` into a read-only directory wrote 0 of 2 files and exited 0, so a
run that writes nothing while exiting 0 has neither. A regression case showed that
`batch --mode visible` into a read-only directory could write no files and exit 0, so a
wrapping service would treat an empty output directory as a completed run. Any check
for a silent no-op must assert on the ARTIFACTS, not on the status.
"""
+1 -1
View File
@@ -157,7 +157,7 @@ def read_full_exif(
for t, v in tags.items():
name = tag_names.get(t, {}).get("name", f"tag_{t}")
if name == "MakerNote" and isinstance(v, bytes):
# full hex, no cap: measured on real uploads, Apple is ~2 KB
# Full hex, no cap: some Apple manifests are several kilobytes.
# but Canon reaches 28 KB and Sony 38 KB (AF data, serials,
# embedded previews) -- a cap would silently drop exactly the
# camera-original evidence this scan exists to preserve
+9 -9
View File
@@ -1,8 +1,8 @@
"""Tier A1: diff today's `identify` against the verdicts recorded in the corpus sidecars.
"""Tier A1: diff today's `identify` against previously recorded verdict sidecars.
`data/spaces/identify/<day>/<uid>.json` holds the verdict a past run produced for
`data/spaces/originals/<day>/<uid>_src.<ext>`. Re-running identify and diffing turns the
corpus into a ~39k-image behavioral regression suite that needs no new labelling.
An identify-results directory holds the verdict a past run produced for each image in a
local source directory. Re-running identify and diffing provides a behavioral regression
suite without requiring new labels.
WHAT A DIFF MEANS -- READ THIS BEFORE PANICKING
The sidecars were written by OLDER versions, so an intended improvement shows up as a
@@ -27,8 +27,8 @@ WHY FAMILIES, NOT RAW STRINGS
so the comparison tracks BEHAVIOR, not phrasing.
DATA SAFETY
Corpus images are user uploads: read-only, local analysis. Output goes to a gitignored
path under data/spaces/ and records uids, never image content.
Treat input datasets as sensitive and read-only. Output goes under the gitignored
.local-eval/ directory and records identifiers, never image content.
uv run python scripts/sidecar_regression.py --sample 500 # representative trial
uv run python scripts/sidecar_regression.py # full corpus, resumable
@@ -53,9 +53,9 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
REPO = Path(__file__).resolve().parents[1]
IDENTIFY_DIR = REPO / "data" / "spaces" / "identify"
ORIGINALS = REPO / "data" / "spaces" / "originals"
OUT = REPO / "data" / "spaces" / "_sidecar_regression.jsonl"
IDENTIFY_DIR = REPO / ".local-eval" / "identify"
ORIGINALS = REPO / ".local-eval" / "originals"
OUT = REPO / ".local-eval" / "sidecar-regression.jsonl"
# Map a watermark description to a stable behavior family. Order matters: the first
# matching pattern wins, so put the specific tokens above the generic ones.
+5 -5
View File
@@ -49,8 +49,8 @@ from dataclasses import dataclass, field
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
SAMPLES = REPO / "data" / "samples"
CORPUS = REPO / "data" / "spaces" / "originals"
SAMPLES = REPO / "data" / "fixtures" / "provenance"
CORPUS = REPO / ".local-eval" / "originals"
EXIT_NO_VISIBLE_MARK = 2
@@ -101,7 +101,7 @@ class Runner:
def corpus_pick(n: int, suffixes: tuple[str, ...]) -> list[Path]:
"""Real uploads, chosen deterministically so a failure is reproducible."""
"""Local evaluation files, chosen deterministically so a failure is reproducible."""
if not CORPUS.exists():
return []
pool = [p for p in CORPUS.glob("*/*") if p.suffix.lower() in suffixes]
@@ -117,7 +117,7 @@ def main() -> None:
)
a = ap.parse_args()
tmp = Path(tempfile.mkdtemp(prefix="raiw-smoke-"))
tmp = Path(tempfile.mkdtemp(prefix="remove-ai-watermarks-smoke-"))
r = Runner(tmp)
doubao = SAMPLES / "doubao-1.png"
chatgpt = SAMPLES / "chatgpt-1.png"
@@ -312,7 +312,7 @@ def main() -> None:
picks += [(label, p) for p in corpus_pick(2, (suf,))]
picks += [("png", p) for p in corpus_pick(3, (".png",))]
if not picks:
r.skip("real-format rows", "corpus not present (data/spaces/originals)")
r.skip("real-format rows", "local evaluation dataset not present")
for label, p in picks:
r.run(f"identify real {label}", ["identify", str(p), "--json"])
r.run(
+11 -7
View File
@@ -1,11 +1,11 @@
"""Ingest and inspect the local SynthID reference corpus.
Copies images into ``data/synthid_corpus/images/<label>/`` and records one row
Copies images into ``data/synthid/originals/`` and records one row
per image in ``manifest.csv`` (sha256, resolution, format, C2PA issuer, and the
external verification level). Dogfoods the project's own C2PA detector so the
recorded metadata matches what the library sees.
See ``data/synthid_corpus/README.md`` for the collection protocol.
See ``data/synthid/README.md`` for the collection protocol.
Usage:
uv run python scripts/synthid_corpus.py ingest IMAGES... --label pos \\
@@ -34,8 +34,8 @@ from remove_ai_watermarks.noai.c2pa import extract_c2pa_info
log = logging.getLogger(__name__)
console = Console()
DEFAULT_ROOT = Path(__file__).resolve().parent.parent / "data" / "synthid_corpus"
LABELS = ("pos", "neg", "cleaned")
DEFAULT_ROOT = Path(__file__).resolve().parent.parent / "data" / "synthid"
LABELS = ("pos", "neg")
VERIFIED_VIA = ("gemini-app", "openai-verify", "synthid-portal", "c2pa-metadata", "third-party", "none")
FIELDNAMES = [
"sha256",
@@ -144,7 +144,7 @@ def ingest(
root: Path,
) -> None:
"""Copy IMAGES into the corpus and append rows to the manifest."""
dest_dir = root / "images" / label
dest_dir = root / "originals"
dest_dir.mkdir(parents=True, exist_ok=True)
rows = _read_manifest(root)
@@ -160,8 +160,12 @@ def ingest(
seen.add(digest)
width, height, fmt, issuer, synthid = _probe(src)
stored_name = f"{digest[:8]}-{src.name}"
shutil.copy2(src, dest_dir / stored_name)
stored_name = src.name
destination = dest_dir / stored_name
if destination.exists() and _sha256(destination) != digest:
raise click.ClickException(f"filename already exists with different content: {stored_name}")
if not destination.exists():
shutil.copy2(src, destination)
new_rows.append(
{
+10 -10
View File
@@ -30,12 +30,12 @@ THE KEY
WHAT IT COSTS
Metadata only. The expensive pixel pass is NOT re-run: which detectors fired is
joined from `_visible_positives.jsonl` (the completed full-corpus artifact), per
joined from `_visible_positives.jsonl` (a completed local evaluation artifact), per
the standing rule against relaunching finished sweeps to re-check them.
DATA SAFETY
Corpus images are real user uploads: read-only, local analysis, gitignored
output. Contact sheets stay under `data/spaces/`; nothing here is committed.
Treat input datasets as sensitive and read-only. Contact sheets stay under
`.local-eval/`; nothing generated here is committed.
uv run python scripts/vendor_cohort_harvest.py
uv run python scripts/vendor_cohort_harvest.py --report-only --sheets 12
@@ -57,10 +57,10 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
sys.path.insert(0, str(Path(__file__).parent))
REPO = Path(__file__).resolve().parents[1]
CORPUS = REPO / "data" / "spaces" / "originals"
OUT = REPO / "data" / "spaces" / "_vendor_cohorts.jsonl"
FIRED = REPO / "data" / "spaces" / "_visible_positives.jsonl"
SHEET_DIR = REPO / "data" / "spaces" / "_vendor_cohort_sheets"
CORPUS = REPO / ".local-eval" / "originals"
OUT = REPO / ".local-eval" / "vendor-cohorts.jsonl"
FIRED = REPO / ".local-eval" / "visible-positives.jsonl"
SHEET_DIR = REPO / ".local-eval" / "vendor-cohort-sheets"
# A producer code is `001` + `1` + USCC(18) + a 5-digit app/product suffix, so two
# codes sharing the USCC are the same legal entity registering different products.
@@ -95,7 +95,7 @@ def _one(path_str: str) -> dict[str, Any] | None:
def load_fired() -> dict[str, list[str]]:
"""path -> detector keys that fired, from the completed full-corpus artifact."""
"""Map each path to detector keys from a completed local evaluation artifact."""
if not FIRED.exists():
print(f"WARNING: {FIRED.name} missing; cohorts will show no detector state")
return {}
@@ -112,7 +112,7 @@ def scan(limit: int, workers: int, out_path: Path) -> list[dict[str, Any]]:
pool = sorted(glob.glob(str(CORPUS / "*" / "*")))
if limit:
pool = pool[:limit]
print(f"scanning {len(pool)} corpus files for TC260 labels workers={workers}", flush=True)
print(f"scanning {len(pool)} local files for TC260 labels workers={workers}", flush=True)
rows: list[dict[str, Any]] = []
out_path.parent.mkdir(parents=True, exist_ok=True)
with open(out_path, "w", encoding="utf-8") as fh, ProcessPoolExecutor(max_workers=workers) as ex:
@@ -218,7 +218,7 @@ def sheets(rows: list[dict[str, Any]], fired: dict[str, list[str]], per: int, mi
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--limit", type=int, default=0, help="cap files scanned (0 = whole corpus)")
ap.add_argument("--limit", type=int, default=0, help="cap files scanned (0 = all)")
ap.add_argument("--workers", type=int, default=max(1, (os.cpu_count() or 4) - 2))
ap.add_argument("--out", type=Path, default=OUT)
ap.add_argument("--report-only", action="store_true")
+8 -8
View File
@@ -26,7 +26,7 @@ THE TRAPS, INHERITED FROM THE 2026-07-18 MEASUREMENT
chances to match, which flatters the positives and the negatives alike
DATA SAFETY
Corpus images are real user uploads: read-only, local, gitignored output. The template
Treat input datasets as sensitive and read-only, and keep output gitignored. The template
is font-rendered synthetic (`render_vendor_silhouettes.py`), never cut from an upload.
uv run python scripts/vendor_mark_calibrate.py --cohort 91440101MA9Y9T4H7A \\
@@ -47,9 +47,9 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
sys.path.insert(0, str(Path(__file__).parent))
REPO = Path(__file__).resolve().parents[1]
COHORTS = REPO / "data" / "spaces" / "_vendor_cohorts.jsonl"
SHEET_DIR = REPO / "data" / "spaces" / "_vendor_calib_sheets"
OUT = REPO / "data" / "spaces" / "_vendor_calibration.jsonl"
COHORTS = REPO / ".local-eval" / "vendor-cohorts.jsonl"
SHEET_DIR = REPO / ".local-eval" / "vendor-calibration-sheets"
OUT = REPO / ".local-eval" / "vendor-calibration.jsonl"
def build_config(
@@ -103,7 +103,7 @@ def _score(args: ScoreArgs) -> dict[str, Any] | None:
return {"path": path_str, "score": round(float(score), 4), "box": box}
NEGATIVES = REPO / "data" / "spaces" / "_research_20260718_textmark_relaxation" / "groundtruth.jsonl"
NEGATIVES = REPO / ".local-eval" / "textmark-relaxation" / "groundtruth.jsonl"
def load_sets(cohort: str) -> tuple[list[str], list[str]]:
@@ -332,7 +332,7 @@ def fit_geometry(
rows.append(r)
strong = [r for r in rows if r["best"] >= floor]
fit_out = REPO / "data" / "spaces" / f"_vendor_fit_{paths_name}.jsonl"
fit_out = REPO / ".local-eval" / f"vendor-fit-{paths_name}.jsonl"
fit_out.write_text("\n".join(json.dumps(r) for r in rows), encoding="utf-8")
print(f"\n{'=' * 78}\nGEOMETRY FIT (n={len(rows)}, usable best>={floor}: {len(strong)})\n{'=' * 78}")
print(f"rows -> {fit_out}")
@@ -428,11 +428,11 @@ def fit_geometry(
print("fitted geometry before reading any gate off the clean arm.")
FIRED = REPO / "data" / "spaces" / "_visible_positives.jsonl"
FIRED = REPO / ".local-eval" / "visible-positives.jsonl"
def _fired_pool(mark: str, limit: int, seed: int = 7) -> list[str]:
"""Paths where ``mark`` fired, from the COMPLETED full-corpus artifact -- the
"""Paths where ``mark`` fired, from a completed local evaluation artifact -- the
standing rule: detector firings are joined, never re-run."""
import random
+6 -6
View File
@@ -1,7 +1,7 @@
"""Rebuild the visible-watermark alpha assets from controlled captures.
The committed, reproducible build of the bundled visible-mark assets -- the inputs
live in ``data/<engine>_capture/captures/`` (committed solid-colour captures run
The committed, reproducible build of the bundled visible-mark assets. The inputs
live in ``data/calibration/<engine>/`` (committed solid-color captures run
through the generator). Re-run after re-capturing.
**Doubao "豆包AI生成" strip and Jimeng "★ 即梦AI" wordmark** are fixed
@@ -74,21 +74,21 @@ class EngineSpec:
_SPECS: dict[str, EngineSpec] = {
"doubao": EngineSpec(
"doubao",
_ROOT / "data" / "doubao_capture" / "captures",
_ROOT / "data" / "calibration" / "doubao",
"doubao_black_1x1_1.png",
"doubao_gray_1x1_1.png",
_ROOT / "src" / "remove_ai_watermarks" / "assets" / "doubao_alpha.png",
),
"jimeng": EngineSpec(
"jimeng",
_ROOT / "data" / "jimeng_capture" / "captures",
_ROOT / "data" / "calibration" / "jimeng",
"jimeng_cap_A.png", # black seed
"jimeng_cap_C.png", # gray seed
_ROOT / "src" / "remove_ai_watermarks" / "assets" / "jimeng_alpha.png",
),
"samsung": EngineSpec(
"samsung",
_ROOT / "data" / "samsung_capture" / "captures",
_ROOT / "data" / "calibration" / "samsung",
"samsung_black_1.png", # black flat edit (mark on true black, bottom-left)
"samsung_gray_1.png", # gray flat edit
_ROOT / "src" / "remove_ai_watermarks" / "assets" / "samsung_alpha.png",
@@ -109,7 +109,7 @@ _HALO_PAD = 7 # keep this many px of halo around the glyph body in the saved as
# PURE-BLACK background (so the engine reads alpha = max(R,G,B)/255 directly, no
# background fit). Its assets are the sparkle-on-black CAPTURE at two fixed logo
# sizes (the engine interpolates between them), not an alpha map.
_GEMINI_CAPTURE = _ROOT / "data" / "gemini_capture" / "captures" / "gemini_black_2048.png"
_GEMINI_CAPTURE = _ROOT / "data" / "calibration" / "gemini" / "gemini_black_2048.png"
_GEMINI_ASSETS: dict[int, Path] = {
96: _ROOT / "src" / "remove_ai_watermarks" / "assets" / "gemini_bg_96.png",
48: _ROOT / "src" / "remove_ai_watermarks" / "assets" / "gemini_bg_48.png",
+2 -2
View File
@@ -39,8 +39,8 @@ sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from remove_ai_watermarks import watermark_registry as wr
from remove_ai_watermarks.image_io import imread
GT = Path("data/spaces/_research_20260718_textmark_relaxation/groundtruth.jsonl")
SNAP = Path("data/spaces/_research_20260718_textmark_relaxation/snapshots")
GT = Path(".local-eval/textmark-relaxation/groundtruth.jsonl")
SNAP = Path(".local-eval/textmark-relaxation/snapshots")
MARKS = ("gemini", "doubao", "jimeng", "samsung", "jimeng_pill")
+3 -3
View File
@@ -6,8 +6,8 @@ blind against contact sheets with a two-sided control in every round. Rounds so
2026-07-18 text-mark/pill round : 423 cells (doubao / jimeng / jimeng_pill arms)
2026-07-18 gemini round : 356 cells (gemini relaxation additions)
DATA SAFETY: the corpus is real user uploads. This script reads the gitignored
corpus and writes a gitignored ground-truth file. Neither the images nor this
DATA SAFETY: treat the input dataset as sensitive. This script reads a gitignored
dataset and writes a gitignored ground-truth file. Neither the images nor this
output may be committed; only the harness is. See the repo CLAUDE.md.
The labels record what the LABELLER SAW in the crop, one of:
@@ -77,7 +77,7 @@ def metadata_provenance(path: str) -> list[str]:
def main() -> None:
root = Path(sys.argv[1] if len(sys.argv) > 1 else "data/spaces/_research_20260718_textmark_relaxation")
root = Path(sys.argv[1] if len(sys.argv) > 1 else ".local-eval/textmark-relaxation")
out = root / "groundtruth.jsonl"
rows: dict[str, dict] = {}
stats: dict[str, int] = {}
+7 -7
View File
@@ -1,14 +1,14 @@
"""Parallel detection pass: list every corpus image carrying a known visible mark.
Why this exists separately from `visible_removal_audit.py`: that audit is single-process,
so a full-corpus sweep costs ~10 h and running it once per backend costs ~30 h. But its
so repeated full-dataset sweeps waste time. Its
expensive half is DETECTION, and detection does not depend on the fill backend. Splitting
it out means detecting once in parallel and then feeding the positives to the audit via
its `--paths-file` seam, over a few thousand images instead of forty thousand.
CRASH TOLERANCE IS NOT OPTIONAL AT THIS SCALE
cv2/libpng decode native-crash on some real uploads. A plain `ProcessPoolExecutor.map`
over 39k files then DEADLOCKS: the worker dies without a Python traceback and the parent
cv2/libpng can crash natively on malformed images. A plain `ProcessPoolExecutor.map`
over a large dataset can deadlock: the worker dies without a Python traceback and the parent
waits forever on a result that never arrives (observed 2026-07-19 -- 26 min of work lost
because results were only written at the end). So this script:
* writes every result to JSONL as it arrives -- a kill never costs more than a batch;
@@ -16,7 +16,7 @@ CRASH TOLERANCE IS NOT OPTIONAL AT THIS SCALE
* runs a FRESH pool per batch with a timeout, so one poisoned file costs one batch,
and that batch is retried serially to find and record the offender.
Corpus images are user uploads: read-only, local analysis, gitignored output.
Treat input datasets as sensitive and read-only, and keep output gitignored.
uv run python scripts/visible_positives.py --jobs 6
"""
@@ -41,9 +41,9 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
from remove_ai_watermarks.noai.constants import SUPPORTED_FORMATS as _EXTS
REPO = Path(__file__).resolve().parents[1]
CORPUS = REPO / "data" / "spaces" / "originals"
OUT = REPO / "data" / "spaces" / "_visible_positives.jsonl"
PATHS = REPO / "data" / "spaces" / "_visible_positives.txt"
CORPUS = REPO / ".local-eval" / "originals"
OUT = REPO / ".local-eval" / "visible-positives.jsonl"
PATHS = REPO / ".local-eval" / "visible-positives.txt"
def _one(path: str) -> dict[str, object]:
+12 -6
View File
@@ -15,11 +15,11 @@ fill), not ``cv2``. Removal SUCCESS (detector-clean) is backend-independent, so
is fine for a fast pass/fail sweep, but only migan/lama reflect the recovered-region
quality a user actually gets. Run migan when validating the visible pipeline for real.
Operates on gitignored data only (data/spaces/...); writes nothing tracked.
Operates on gitignored local data only; writes nothing tracked.
uv run python scripts/visible_removal_audit.py \
--corpus data/spaces/originals --out data/spaces/_visible_audit.csv \
--dataset-root data/spaces/_visible_datasets
--corpus .local-eval/originals --out .local-eval/visible-audit.csv \
--dataset-root .local-eval/visible-datasets
"""
from __future__ import annotations
@@ -48,10 +48,16 @@ def _rel(p: Path, corpus: Path) -> str:
@click.command()
@click.option(
"--corpus", type=click.Path(exists=True, file_okay=False, path_type=Path), default=Path("data/spaces/originals")
"--corpus",
type=click.Path(exists=True, file_okay=False, path_type=Path),
default=Path(".local-eval/originals"),
)
@click.option("--out", type=click.Path(path_type=Path), default=Path(".local-eval/visible-audit.csv"))
@click.option(
"--dataset-root",
type=click.Path(path_type=Path),
default=Path(".local-eval/visible-datasets"),
)
@click.option("--out", type=click.Path(path_type=Path), default=Path("data/spaces/_visible_audit.csv"))
@click.option("--dataset-root", type=click.Path(path_type=Path), default=Path("data/spaces/_visible_datasets"))
@click.option(
"--paths-file",
type=click.Path(exists=True, path_type=Path),