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:
Victor Kuznetsov
2026-07-20 08:14:50 -07:00
co-authored by Claude Opus 4.8
parent a8f3536d3e
commit cfefd9d819
26 changed files with 1715 additions and 257 deletions
+104
View File
@@ -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()
+164
View File
@@ -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()
+134
View File
@@ -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()
+126
View File
@@ -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()
+101
View File
@@ -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()