mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-06 22:18:36 +02:00
Remove assume_ai, add tophat front-end and rival margin, fix two CLI defects
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
a8f3536d3e
commit
cfefd9d819
@@ -0,0 +1,104 @@
|
||||
"""Render SYNTHETIC detection silhouettes for the CJK vendor text marks (data-safe).
|
||||
|
||||
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.
|
||||
|
||||
Covered here:
|
||||
qwen "千问AI生成" -- Alibaba Tongyi Qianwen, bottom-right, 3-lobed logo + text
|
||||
xinghui "星绘AI生成" -- ByteDance 星绘, bottom-right, 4-point sparkle + text
|
||||
|
||||
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-18: these two marks are NOT registered, and this script is kept as the
|
||||
method + the record of why. Measured on 14 hand-verified 千问 positives from the corpus,
|
||||
the current detect architecture (top-hat glyph blob -> binary TM_CCOEFF_NORMED) cannot
|
||||
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 is 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. Adding
|
||||
it therefore needs a detection front-end that does not depend on binarizing the glyph
|
||||
(grayscale/edge correlation on the raw top-hat, or a learned patch classifier) -- not a
|
||||
new silhouette. Shipping it on the current front-end would mean a detector that finds
|
||||
almost nothing and, at any threshold low enough to fire, fires on arbitrary corner text.
|
||||
|
||||
星绘 additionally has only ONE confirmed example in the corpus, so even a working
|
||||
front-end could not have its threshold calibrated yet.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
_ASSETS = Path(__file__).resolve().parents[1] / "src" / "remove_ai_watermarks" / "assets"
|
||||
# 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"
|
||||
|
||||
MARKS = {
|
||||
"qwen_alpha.png": "千问AI生成",
|
||||
"xinghui_alpha.png": "星绘AI生成",
|
||||
}
|
||||
|
||||
|
||||
def render(text: str, width: int = 335) -> np.ndarray:
|
||||
"""Binary glyph silhouette (255 = glyph), sized to the doubao asset's convention.
|
||||
|
||||
Matching doubao's 335px asset width keeps the `alpha_*_frac` numbers transferable,
|
||||
since these marks are the same house style and scale.
|
||||
"""
|
||||
probe = Image.new("L", (10, 10))
|
||||
d0 = ImageDraw.Draw(probe)
|
||||
size = 8
|
||||
while size < 200: # grow until the run fills the target width
|
||||
f = ImageFont.truetype(_FONT, size)
|
||||
if d0.textbbox((0, 0), text, font=f)[2] >= width * 0.98:
|
||||
break
|
||||
size += 1
|
||||
font = ImageFont.truetype(_FONT, size)
|
||||
bb = d0.textbbox((0, 0), text, font=font)
|
||||
w, h = bb[2] - bb[0], bb[3] - bb[1]
|
||||
pad = max(2, int(h * 0.12))
|
||||
im = Image.new("L", (w + 2 * pad, h + 2 * pad), 0)
|
||||
ImageDraw.Draw(im).text((pad - bb[0], pad - bb[1]), text, font=font, fill=255)
|
||||
return np.array(im)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
try:
|
||||
for name, text in MARKS.items():
|
||||
sil = render(text)
|
||||
Image.fromarray(sil).save(_ASSETS / name)
|
||||
print(f"wrote {_ASSETS / name} ({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
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Benchmark harness for the visible-mark detectors.
|
||||
|
||||
Run this before AND after any detector change. It re-runs perception over the
|
||||
hand-labelled ground truth and reports, per mark, how often a fire is correct --
|
||||
with Wilson intervals, so a change inside the noise is visible as such.
|
||||
|
||||
uv run python scripts/visible_eval.py # score current code
|
||||
uv run python scripts/visible_eval.py --save baseline # snapshot for comparison
|
||||
uv run python scripts/visible_eval.py --vs baseline # diff against a snapshot
|
||||
|
||||
WHAT THIS SET CAN AND CANNOT MEASURE -- read before quoting a number:
|
||||
|
||||
* PRECISION: sound. Every labelled crop is centred on the region a detector
|
||||
pointed at, so "the detector fired mark K here, was K actually there" is
|
||||
exactly the question the labels answer.
|
||||
* RECALL: NOT measurable here, and the harness refuses to print it. The labelled
|
||||
images were SAMPLED WHERE DETECTORS FIRED (relaxation additions plus controls),
|
||||
so images carrying a mark that every detector missed are absent by construction.
|
||||
Computing recall on this set would divide by a denominator that excludes exactly
|
||||
the failures recall is meant to expose, and would report a flattering number.
|
||||
Recall needs a RANDOM corpus sample laballed exhaustively -- a separate round.
|
||||
|
||||
* `other_ai_label` (千问 / 百度 / 星绘 / 抖音) counts as a FALSE fire for any
|
||||
registered mark, because it is a different vendor's label. It is tracked
|
||||
separately in the confusion output since it is the dominant jimeng failure.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
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")
|
||||
MARKS = ("gemini", "doubao", "jimeng", "samsung", "jimeng_pill")
|
||||
|
||||
|
||||
def wilson(k: int, n: int) -> tuple[float, float]:
|
||||
if n == 0:
|
||||
return (0.0, 0.0)
|
||||
z, p = 1.96, k / n
|
||||
d = 1 + z * z / n
|
||||
c = p + z * z / (2 * n)
|
||||
s = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n))
|
||||
return ((c - s) / d, (c + s) / d)
|
||||
|
||||
|
||||
def provenance_for(rec: dict) -> frozenset[str]:
|
||||
"""The provenance production would have: read from the file's own METADATA.
|
||||
|
||||
Never derive this from the labels. A relaxation arm only fires when provenance
|
||||
names the vendor, so label-derived provenance silently tells the detector the
|
||||
answer and every arm scores near-perfect (observed: gemini 99% instead of 34%).
|
||||
"""
|
||||
return frozenset(rec.get("provenance", []))
|
||||
|
||||
|
||||
def score(sensitivity: str = "auto") -> dict:
|
||||
recs = [json.loads(line) for line in GT.open()]
|
||||
per: dict[str, Counter] = {m: Counter() for m in MARKS}
|
||||
confusion: dict[str, Counter] = {m: Counter() for m in MARKS}
|
||||
missing = 0
|
||||
for rec in recs:
|
||||
img = imread(rec["path"])
|
||||
if img is None:
|
||||
missing += 1
|
||||
continue
|
||||
cands = wr._build_candidates(img)
|
||||
ctx = wr.Context(sensitivity=sensitivity, provenance=provenance_for(rec))
|
||||
fired = {d.candidate.key for d in wr.decide(cands, ctx)}
|
||||
for m in MARKS:
|
||||
# Only score a mark on images whose shown crop could rule on it. Scoring
|
||||
# outside that scope books real detections as false fires -- see the
|
||||
# adjudication note in visible_groundtruth.py.
|
||||
if m not in rec.get("adjudicated", []):
|
||||
continue
|
||||
if m not in fired:
|
||||
per[m]["fn"] += 1 if m in rec["present"] else 0
|
||||
continue
|
||||
if m in rec["present"]:
|
||||
per[m]["tp"] += 1
|
||||
else:
|
||||
per[m]["fp"] += 1
|
||||
for s in rec["seen"]:
|
||||
confusion[m][s] += 1
|
||||
scope = {m: sum(1 for r in recs if m in r.get("adjudicated", [])) for m in MARKS}
|
||||
return {
|
||||
"per": {m: dict(c) for m, c in per.items()},
|
||||
"scope": scope,
|
||||
"confusion": {m: dict(c) for m, c in confusion.items()},
|
||||
"missing": missing,
|
||||
"n": len(recs),
|
||||
"sensitivity": sensitivity,
|
||||
}
|
||||
|
||||
|
||||
def report(res: dict, prev: dict | None = None) -> None:
|
||||
print(f"\nground truth: {res['n']} images ({res['missing']} unreadable) sensitivity={res['sensitivity']}")
|
||||
print("=" * 78)
|
||||
print(
|
||||
f"{'mark':12s} {'scope':>6s} {'fires':>6s} {'correct':>8s} "
|
||||
f"{'precision':>11s} {'95% CI':>13s} {'missed':>7s} delta"
|
||||
)
|
||||
print("-" * 78)
|
||||
for m in MARKS:
|
||||
c = res["per"][m]
|
||||
tp, fp, fn = c.get("tp", 0), c.get("fp", 0), c.get("fn", 0)
|
||||
n = tp + fp
|
||||
scope = res["scope"].get(m, 0)
|
||||
if n == 0:
|
||||
print(f"{m:12s} {scope:6d} {0:6d} {'-':>8s} {'-':>11s} {'-':>13s} {fn:7d}")
|
||||
continue
|
||||
lo, hi = wilson(tp, n)
|
||||
delta = ""
|
||||
if prev:
|
||||
pc = prev["per"][m]
|
||||
pn = pc.get("tp", 0) + pc.get("fp", 0)
|
||||
if pn:
|
||||
d = tp / n - pc.get("tp", 0) / pn
|
||||
delta = f"{d:+.1%} (fires {pn}->{n})"
|
||||
print(f"{m:12s} {scope:6d} {n:6d} {tp:8d} {tp / n:10.0%} {lo:5.0%}-{hi:<6.0%} {fn:7d} {delta}")
|
||||
print("\nwhat the FALSE fires actually were:")
|
||||
for m in MARKS:
|
||||
conf = {k: v for k, v in res["confusion"][m].items() if k != m}
|
||||
if conf:
|
||||
print(f" {m:12s} {dict(sorted(conf.items(), key=lambda kv: -kv[1]))}")
|
||||
print("\n'scope' = images whose crop could rule on that mark; 'missed' = labelled marks it did not fire on.")
|
||||
print("NOTE: 'missed' is NOT recall -- this set was sampled where detectors fired, so images")
|
||||
print(" every detector missed are absent by construction. Use it only to catch a change")
|
||||
print(" LOSING marks it used to find; an unbiased random sample is needed for true recall.")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--save", metavar="NAME")
|
||||
ap.add_argument("--vs", metavar="NAME")
|
||||
ap.add_argument("--sensitivity", default="auto")
|
||||
a = ap.parse_args()
|
||||
res = score(a.sensitivity)
|
||||
prev = None
|
||||
if a.vs:
|
||||
f = SNAP / f"{a.vs}.json"
|
||||
if f.exists():
|
||||
prev = json.loads(f.read_text())
|
||||
else:
|
||||
print(f"(no snapshot {f}; showing absolute numbers)")
|
||||
report(res, prev)
|
||||
if a.save:
|
||||
SNAP.mkdir(parents=True, exist_ok=True)
|
||||
(SNAP / f"{a.save}.json").write_text(json.dumps(res, indent=1))
|
||||
print(f"\nsaved snapshot -> {SNAP / f'{a.save}.json'}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Consolidate the hand-labelled contact-sheet rounds into ONE ground-truth file.
|
||||
|
||||
Ground truth is `uid -> the set of visible marks actually present`, hand-labelled
|
||||
blind against contact sheets with a two-sided control in every round. Rounds so far:
|
||||
|
||||
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
|
||||
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:
|
||||
doubao | jimeng | pill | sparkle | other_ai_label | none | uncertain
|
||||
`other_ai_label` is a real visible AI label from a vendor we do NOT have a mark for
|
||||
(千问 / 百度 / 星绘 / 抖音); it is NOT a positive for any registered mark, but it is
|
||||
also not "clean" -- it is exactly what the relaxed jimeng detector confuses.
|
||||
`uncertain` rows are EXCLUDED from scoring rather than coerced, so a labeller's
|
||||
honest doubt never becomes a fabricated data point.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SEEN_TO_MARK = {
|
||||
"doubao": "doubao",
|
||||
"jimeng": "jimeng",
|
||||
"pill": "jimeng_pill",
|
||||
"sparkle": "gemini",
|
||||
}
|
||||
# Which marks a crop centred on `key` lets the labeller rule on (same corner = visible
|
||||
# in the same crop). Doubao and Jimeng share the bottom-right corner.
|
||||
_ADJUDICATES = {
|
||||
"doubao": ("doubao", "jimeng"),
|
||||
"jimeng": ("doubao", "jimeng"),
|
||||
"jimeng_pill": ("jimeng_pill",),
|
||||
"gemini": ("gemini",),
|
||||
"samsung": ("samsung",),
|
||||
}
|
||||
|
||||
ROUNDS = [
|
||||
("textmark", "labels.csv", "manifest.csv"),
|
||||
("gemini", "gemini_labels.csv", "gemini_manifest.csv"),
|
||||
]
|
||||
|
||||
|
||||
def metadata_provenance(path: str) -> list[str]:
|
||||
"""The vendor keys LOCAL METADATA confirms -- what cli._visible_provenance reads.
|
||||
|
||||
Must come from the file's own metadata, never from the labels: deriving it from
|
||||
the ground truth would hand the detector the answer it is being scored on (a
|
||||
relaxation only fires when provenance names the vendor, so label-derived
|
||||
provenance makes every arm look near-perfect). Read from the corpus `identify`
|
||||
sidecar, which is the same signal production computes.
|
||||
"""
|
||||
p = Path(path)
|
||||
sidecar = Path(str(p.parent).replace("/originals/", "/identify/")) / (p.name.split("_src")[0] + ".json")
|
||||
if not sidecar.exists():
|
||||
return []
|
||||
try:
|
||||
with sidecar.open() as fh:
|
||||
wm = " | ".join(json.load(fh).get("watermarks", []))
|
||||
except Exception:
|
||||
return []
|
||||
keys: list[str] = []
|
||||
if "China AIGC label" in wm:
|
||||
keys += ["doubao", "jimeng"]
|
||||
if "C2PA Content Credentials (Google LLC" in wm:
|
||||
keys.append("gemini")
|
||||
if "Samsung Galaxy AI" in wm:
|
||||
keys.append("samsung")
|
||||
return keys
|
||||
|
||||
|
||||
def main() -> None:
|
||||
root = Path(sys.argv[1] if len(sys.argv) > 1 else "data/spaces/_research_20260718_textmark_relaxation")
|
||||
out = root / "groundtruth.jsonl"
|
||||
rows: dict[str, dict] = {}
|
||||
stats: dict[str, int] = {}
|
||||
for round_name, labels_file, manifest_file in ROUNDS:
|
||||
with (root / labels_file).open() as fh:
|
||||
labels = {int(r["idx"]): r["seen"] for r in csv.DictReader(fh)}
|
||||
with (root / manifest_file).open() as fh:
|
||||
manifest_rows = list(csv.DictReader(fh))
|
||||
for m in manifest_rows:
|
||||
seen = labels.get(int(m["idx"]))
|
||||
if seen is None:
|
||||
continue
|
||||
stats[seen] = stats.get(seen, 0) + 1
|
||||
if seen == "uncertain":
|
||||
continue # excluded by design -- never coerce a doubt into a label
|
||||
rec = rows.setdefault(
|
||||
m["uid"],
|
||||
{"uid": m["uid"], "path": m["path"], "present": [], "seen": [], "rounds": [], "adjudicated": []},
|
||||
)
|
||||
# ADJUDICATION SCOPE -- load-bearing. A crop centred on one mark only lets
|
||||
# the labeller rule on marks visible IN THAT CROP. A pill crop (top-left)
|
||||
# says nothing about a bottom-right wordmark, so scoring jimeng against a
|
||||
# pill-round image would book real detections as false fires (~61% of pills
|
||||
# carry a wordmark). Bottom-right marks co-adjudicate each other: one crop
|
||||
# of that corner shows whichever of Doubao/Jimeng is there.
|
||||
for k in _ADJUDICATES.get(m["key"], (m["key"],)):
|
||||
if k not in rec["adjudicated"]:
|
||||
rec["adjudicated"].append(k)
|
||||
mark = SEEN_TO_MARK.get(seen)
|
||||
if mark and mark not in rec["present"]:
|
||||
rec["present"].append(mark)
|
||||
if seen not in rec["seen"]:
|
||||
rec["seen"].append(seen)
|
||||
if round_name not in rec["rounds"]:
|
||||
rec["rounds"].append(round_name)
|
||||
|
||||
for r in rows.values():
|
||||
r["provenance"] = metadata_provenance(r["path"])
|
||||
with out.open("w") as fh:
|
||||
for r in rows.values():
|
||||
fh.write(json.dumps(r) + "\n")
|
||||
print(f"wrote {out} images={len(rows)}")
|
||||
print("label distribution across all rounds:", dict(sorted(stats.items(), key=lambda kv: -kv[1])))
|
||||
n_pos = sum(1 for r in rows.values() if r["present"])
|
||||
print(f"images with at least one registered mark: {n_pos}; clean-of-registered-marks: {len(rows) - n_pos}")
|
||||
adj: dict[str, int] = {}
|
||||
for r in rows.values():
|
||||
for k in r["adjudicated"]:
|
||||
adj[k] = adj.get(k, 0) + 1
|
||||
print("images each mark can be SCORED on (adjudication scope):", dict(sorted(adj.items())))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Build an UNBIASED random sample for measuring visible-mark RECALL.
|
||||
|
||||
Every earlier labelling round sampled where detectors FIRED, so images that every
|
||||
detector missed were absent by construction and recall was unmeasurable. This round
|
||||
samples at random within a provenance class and shows the labeller the corners where
|
||||
a mark can physically be, so a MISSED mark is visible as such.
|
||||
|
||||
Design decisions that matter:
|
||||
|
||||
* SAMPLING FRAME is per provenance class, not the whole corpus. Recall is only
|
||||
meaningful against a denominator where the mark CAN occur: TC260 carriers for the
|
||||
ByteDance marks and the pill, Google-C2PA for the sparkle. Reporting one blended
|
||||
recall over all uploads would mostly measure how often each vendor appears.
|
||||
* NATIVE RESOLUTION crops, never a downscaled whole image: a 220px preview destroys a
|
||||
faint mark (measured in an earlier round), which would inflate the miss count with
|
||||
the labeller's own blindness rather than the detector's.
|
||||
* BOTH corners per image (top-left pill, bottom-right wordmark/strip/sparkle), so one
|
||||
pass adjudicates every registered mark instead of one mark per crop.
|
||||
* The detector's verdict is NOT shown and is not in the sheet order -- the manifest
|
||||
holds it and must not be opened until labelling ends.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import json
|
||||
import random
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from numpy.typing import NDArray
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
from remove_ai_watermarks.image_io import imread
|
||||
|
||||
CELL_W = 300
|
||||
COLS, ROWS = 4, 3
|
||||
PER = COLS * ROWS
|
||||
|
||||
|
||||
def corner_strip(img: NDArray[Any]) -> NDArray[Any] | None:
|
||||
"""Top-left and bottom-right corners stacked, at (near) native scale.
|
||||
|
||||
Crop size is a fraction of the SHORT side because that is what marks scale with
|
||||
(China's GB 45438-2025 sizes the mandated label off the shortest side).
|
||||
"""
|
||||
h, w = img.shape[:2]
|
||||
short = min(h, w)
|
||||
cw, ch = int(short * 0.46), int(short * 0.17)
|
||||
cw, ch = min(cw, w), min(ch, h)
|
||||
tl = img[0:ch, 0:cw]
|
||||
br = img[h - ch : h, w - cw : w]
|
||||
strip = np.vstack([tl, np.full((6, cw, 3), 90, np.uint8), br])
|
||||
s = min(1.0, CELL_W / strip.shape[1]) # never UPSCALE past native
|
||||
if s < 1.0:
|
||||
strip = cv2.resize(strip, (CELL_W, max(1, int(strip.shape[0] * s))), interpolation=cv2.INTER_AREA)
|
||||
return strip
|
||||
|
||||
|
||||
def main() -> None:
|
||||
scan = Path(sys.argv[1])
|
||||
out = Path(sys.argv[2])
|
||||
n_tc260 = int(sys.argv[3]) if len(sys.argv) > 3 else 160
|
||||
n_google = int(sys.argv[4]) if len(sys.argv) > 4 else 80
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
recs = [json.loads(line) for line in scan.open() if '"marks"' in line]
|
||||
seen: dict[tuple, dict] = {}
|
||||
for r in recs: # exact-duplicate uploads share the whole NCC vector
|
||||
seen.setdefault(tuple(r.get("shape", ())) + tuple(sorted((k, m["conf"]) for k, m in r["marks"].items())), r)
|
||||
uniq = list(seen.values())
|
||||
|
||||
tc = [r for r in uniq if r["cls"] == "tc260"]
|
||||
goog = [r for r in uniq if r["cls"] == "neg" and "Google" in r.get("platform", "")]
|
||||
rng = random.Random(2026) # noqa: S311 -- sampling, not cryptography
|
||||
rng.shuffle(tc)
|
||||
rng.shuffle(goog)
|
||||
picked = [("tc260", r) for r in tc[:n_tc260]] + [("google", r) for r in goog[:n_google]]
|
||||
rng.shuffle(picked)
|
||||
|
||||
manifest, cells = [], []
|
||||
for cls, r in picked:
|
||||
img = imread(r["path"])
|
||||
if img is None:
|
||||
continue
|
||||
strip = corner_strip(img)
|
||||
if strip is None:
|
||||
continue
|
||||
cells.append(strip)
|
||||
manifest.append(
|
||||
{
|
||||
"idx": len(cells) - 1,
|
||||
"uid": r["uid"],
|
||||
"cls": cls,
|
||||
"path": r["path"],
|
||||
"fired": "|".join(sorted(k for k, m in r["marks"].items() if m["strict"])),
|
||||
**{f"ncc_{k}": m["conf"] for k, m in r["marks"].items()},
|
||||
}
|
||||
)
|
||||
|
||||
cell_h = max(c.shape[0] for c in cells)
|
||||
for si in range(0, len(cells), PER):
|
||||
chunk = cells[si : si + PER]
|
||||
sheet = np.full((ROWS * (cell_h + 26), COLS * (CELL_W + 8), 3), 40, np.uint8)
|
||||
for i, c in enumerate(chunk):
|
||||
rr, cc = divmod(i, COLS)
|
||||
y0, x0 = rr * (cell_h + 26) + 22, cc * (CELL_W + 8) + 4
|
||||
sheet[y0 : y0 + c.shape[0], x0 : x0 + c.shape[1]] = c
|
||||
cv2.putText(sheet, f"{si + i:03d}", (x0, y0 - 6), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 255), 1)
|
||||
cv2.imwrite(str(out / f"r{si // PER:02d}.png"), sheet)
|
||||
|
||||
with (out / "MANIFEST_DO_NOT_OPEN.csv").open("w", newline="") as fh:
|
||||
w = csv.DictWriter(fh, fieldnames=list(manifest[0]))
|
||||
w.writeheader()
|
||||
w.writerows(manifest)
|
||||
print(f"cells={len(cells)} sheets={(len(cells) + PER - 1) // PER} -> {out}")
|
||||
print("each cell = TOP-LEFT corner above, BOTTOM-RIGHT corner below, near native scale")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Build BLIND contact sheets for hand-labelling relaxation additions.
|
||||
|
||||
Crops are centered on the DETECTED REGION (not the corner), padded by ~0.9x the mark
|
||||
size, and resized to 240px with INTER_NEAREST -- a downscaled preview destroys a faint
|
||||
mark, so nothing here may smooth. The manifest is written to a separate file that must
|
||||
NOT be read until labelling is finished.
|
||||
|
||||
Each sheet mixes three strata in shuffled order:
|
||||
add - the relaxation additions whose precision we are measuring
|
||||
pos - strict-consistent detections (a mark is really there): labeller sensitivity
|
||||
clean - verified-clean negatives (no mark can be there): labeller specificity
|
||||
The two control strata are what make a low measured precision trustworthy.
|
||||
"""
|
||||
|
||||
import csv
|
||||
import json
|
||||
import random
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from numpy.typing import NDArray
|
||||
|
||||
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
|
||||
|
||||
CELL = 240
|
||||
COLS, ROWS = 6, 3
|
||||
PER = COLS * ROWS
|
||||
|
||||
|
||||
def region_for(path: str, key: str) -> tuple[int, int, int, int] | None:
|
||||
"""Re-detect to recover the mark bbox (Candidate carries no region)."""
|
||||
img = imread(path)
|
||||
if img is None:
|
||||
return None
|
||||
mark = next(m for m in wr._REGISTRY if m.key == key)
|
||||
d = mark.detect(img, provenance=True)
|
||||
return d.region if d.region else None
|
||||
|
||||
|
||||
def crop(path: str, region: tuple[int, int, int, int] | None, pad_factor: float = 0.9) -> NDArray[Any] | None:
|
||||
img = imread(path)
|
||||
if img is None:
|
||||
return None
|
||||
h, w = img.shape[:2]
|
||||
if region:
|
||||
x, y, rw, rh = region
|
||||
else:
|
||||
return None
|
||||
px, py = int(rw * pad_factor), int(rh * pad_factor)
|
||||
x0, y0 = max(0, x - px), max(0, y - py)
|
||||
x1, y1 = min(w, x + rw + px), min(h, y + rh + py)
|
||||
c = img[y0:y1, x0:x1]
|
||||
if c.size == 0:
|
||||
return None
|
||||
s = CELL / max(c.shape[0], c.shape[1])
|
||||
return cv2.resize(c, (max(1, int(c.shape[1] * s)), max(1, int(c.shape[0] * s))), interpolation=cv2.INTER_NEAREST)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
with open(sys.argv[1]) as fh:
|
||||
items = json.load(fh) # [{uid,path,key,stratum,conf}]
|
||||
outdir = Path(sys.argv[2])
|
||||
outdir.mkdir(parents=True, exist_ok=True)
|
||||
random.Random(1234).shuffle(items) # noqa: S311 -- sheet ordering, not cryptography
|
||||
|
||||
manifest = []
|
||||
cells = []
|
||||
for it in items:
|
||||
reg = region_for(it["path"], it["key"])
|
||||
c = crop(it["path"], reg)
|
||||
if c is None:
|
||||
continue
|
||||
cells.append((it, c))
|
||||
|
||||
for si in range(0, len(cells), PER):
|
||||
chunk = cells[si : si + PER]
|
||||
sheet = np.full((ROWS * (CELL + 26), COLS * (CELL + 8), 3), 40, np.uint8)
|
||||
for i, (it, c) in enumerate(chunk):
|
||||
r, col = divmod(i, COLS)
|
||||
y0 = r * (CELL + 26) + 22
|
||||
x0 = col * (CELL + 8) + 4
|
||||
sheet[y0 : y0 + c.shape[0], x0 : x0 + c.shape[1]] = c
|
||||
label = f"{si + i:04d}" # index ONLY -- no stratum, no confidence
|
||||
cv2.putText(sheet, label, (x0, y0 - 6), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 255), 1)
|
||||
manifest.append({"idx": si + i, **it})
|
||||
cv2.imwrite(str(outdir / f"sheet_{si // PER:03d}.png"), sheet)
|
||||
|
||||
with open(outdir / "MANIFEST_DO_NOT_OPEN.csv", "w", newline="") as fh:
|
||||
w = csv.DictWriter(fh, fieldnames=list(manifest[0]))
|
||||
w.writeheader()
|
||||
w.writerows(manifest)
|
||||
print(f"sheets={(len(cells) + PER - 1) // PER} cells={len(cells)} -> {outdir}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user