mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-06 22:18:36 +02:00
Restructure documentation, validate metadata removal, consolidate assets
This commit is contained in:
+1
-1
@@ -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 |
@@ -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)]))
|
||||
|
||||
@@ -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).",
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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] = {}
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user