feat: a report that shows what the near-dup gates decide about real artwork (4223)
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 6s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 33s
Build images / build-web (push) Successful in 58s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 1m52s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m22s

The three pixel constants added with the #4223 fix were chosen without ever
measuring real files — CI only has synthetic split/solid fixtures, and FC
verifies nowhere else. This prints the measurements they should have been
chosen from: per pair, the hash distance, the mean drift, the changed-pixel
fraction, the verdict, and which gate produced it.

It drives the real find_similar with the real confirm rather than restating
the decision, so it cannot drift from what the importer does. Read-only:
opens files, touches no database.

Also splits fingerprint_diff out of fingerprints_match — same computation,
now returning the numbers instead of only the boolean, so the report can show
how far a pair sat from a limit rather than which side of it it fell on.

Runs inside the published :dev image (PIL + imagehash already there, no local
env needed) with the art folder mounted read-only — rule 147's channel, so
nothing has to reach main to be tried.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
This commit is contained in:
2026-09-21 06:30:54 -04:00
co-authored by Claude Opus 5
parent 84f873eb1b
commit 3313c3b10a
2 changed files with 226 additions and 13 deletions
+29 -13
View File
@@ -122,6 +122,31 @@ def fingerprint_path(path) -> Image.Image | None:
return None
def fingerprint_diff(
a, b, *, changed_level: int = FINGERPRINT_CHANGED_LEVEL,
) -> tuple[float, float] | None:
"""(mean absolute difference, fraction of pixels past `changed_level`) for
two fingerprints. None if either is missing or the comparison fails.
This is the MEASUREMENT behind `fingerprints_match`, split out so the
calibration report (scripts/phash_gate_report.py) can show how far a pair
sat from the limits instead of only which side of them it fell on. The
constants were chosen without a real-library sample; the numbers this
returns are what moves them.
"""
if a is None or b is None:
return None
try:
diff = ImageChops.difference(a, b)
hist = diff.histogram()
total = sum(hist)
if not total:
return None
return (ImageStat.Stat(diff).mean[0], sum(hist[changed_level:]) / total)
except Exception:
return None
def fingerprints_match(
a, b,
*,
@@ -132,20 +157,11 @@ def fingerprints_match(
"""True when two fingerprints are the same picture: no large global
drift AND no meaningful local region that differs. False on any
failure."""
if a is None or b is None:
return False
try:
diff = ImageChops.difference(a, b)
if ImageStat.Stat(diff).mean[0] > max_mean_diff:
return False
hist = diff.histogram()
total = sum(hist)
if not total:
return False
changed = sum(hist[changed_level:])
return (changed / total) <= max_changed_fraction
except Exception:
measured = fingerprint_diff(a, b, changed_level=changed_level)
if measured is None:
return False
mean, changed_fraction = measured
return mean <= max_mean_diff and changed_fraction <= max_changed_fraction
def aspect_matches(
+197
View File
@@ -0,0 +1,197 @@
"""What the near-duplicate gates would decide about a folder of real artwork.
Read-only. Opens image files, touches no database, imports nothing from the
app but `utils/phash.py` itself — so what it reports is what the importer
would actually do, not a re-implementation that could drift from it.
## Why this exists
Issue #4223 replaced a single 64-bit pHash comparison with three gates
(threshold -> aspect ratio -> pixel confirm). The threshold is the operator's
dial and the aspect tolerance mirrors the video path, but the three pixel
constants — FINGERPRINT_MAX_MEAN_DIFF, FINGERPRINT_CHANGED_LEVEL,
FINGERPRINT_MAX_CHANGED_FRACTION — were chosen without ever measuring real
artwork, because FC verifies in CI and CI has only synthetic fixtures. This
prints the measurements those constants should have been chosen from.
Point it at a folder whose right answer you already know — a variant set that
should stay whole, or an image you have at two resolutions that should
collapse — and read the MARGIN column. A pair that lands just inside a limit
is the one that will flip on the next slightly-different file.
## Usage
python scripts/phash_gate_report.py <dir> [--threshold N] [--recursive]
python scripts/phash_gate_report.py <file_a> <file_b> [...]
No local Python environment needed — run it inside the published image, which
already has PIL and imagehash, with the folder mounted read-only:
docker run --rm --entrypoint python -e PYTHONPATH=/app \
-v /path/to/art:/data:ro -v "$PWD/scripts":/scripts:ro \
git.fabledsword.com/bvandeusen/fabledcurator:dev \
/scripts/phash_gate_report.py /data
(The image ships `backend/` at /app but not `scripts/`, hence the second
mount and PYTHONPATH. The `:ro` on the art folder is the point — this reads.)
`:dev` is the rolling channel this branch publishes to (rule 147) — the same
bytes that would run in the app, without merging anything to main.
"""
import argparse
import itertools
import sys
from pathlib import Path
import imagehash
from PIL import Image
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from backend.app.utils.phash import ( # noqa: E402
ASPECT_TOL,
FINGERPRINT_MAX_CHANGED_FRACTION,
FINGERPRINT_MAX_MEAN_DIFF,
HASH_SIZE,
aspect_matches,
compute_phash,
find_similar,
fingerprint,
fingerprint_diff,
fingerprints_match,
)
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tif", ".tiff"}
DEFAULT_THRESHOLD = 24
class Shot:
"""One image, measured once."""
def __init__(self, path: Path):
self.path = path
self.phash = None
self.width = 0
self.height = 0
self.fingerprint = None
self.error = None
try:
with Image.open(path) as im:
self.width, self.height = im.size
self.phash = compute_phash(im)
self.fingerprint = fingerprint(im)
except Exception as exc:
self.error = str(exc)
@property
def ok(self) -> bool:
return self.phash is not None and self.fingerprint is not None
@property
def label(self) -> str:
return f"{self.path.name} ({self.width}x{self.height})"
def collect(paths, recursive: bool) -> list[Path]:
found: list[Path] = []
for p in paths:
p = Path(p)
if p.is_dir():
walk = p.rglob("*") if recursive else p.glob("*")
found += sorted(f for f in walk if f.suffix.lower() in IMAGE_EXTS)
elif p.is_file():
found.append(p)
return found
def verdict(a: Shot, b: Shot, threshold: int) -> tuple[str, str]:
"""Run the REAL find_similar for "a is being imported, b is in the
library". Returns (verdict, the gate that decided it)."""
rel, _ = find_similar(
a.phash, a.width, a.height,
[(b.phash, b.width, b.height, 1)],
threshold,
confirm=lambda _: fingerprints_match(a.fingerprint, b.fingerprint),
)
if rel == "larger_exists":
return ("DROP", f"{a.path.name} dropped; {b.path.name} kept (>= in both)")
if rel == "smaller_exists":
return ("SUPERSEDE", f"{a.path.name} replaces {b.path.name}'s file")
# Not a match — say which gate refused, cheapest first, since that is the
# constant to move if the answer is wrong.
dist = imagehash.hex_to_hash(a.phash) - imagehash.hex_to_hash(b.phash)
if dist > threshold:
return ("KEEP BOTH", f"hash distance {dist} > threshold {threshold}")
if not aspect_matches(a.width, a.height, b.width, b.height):
return ("KEEP BOTH", "aspect ratios differ")
return ("KEEP BOTH", "pixels differ")
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
ap.add_argument("paths", nargs="+", help="a directory, or two or more files")
ap.add_argument("--threshold", type=int, default=DEFAULT_THRESHOLD,
help=f"phash_threshold to simulate (default {DEFAULT_THRESHOLD})")
ap.add_argument("--recursive", action="store_true", help="walk subdirectories")
ap.add_argument("--limit", type=int, default=60,
help="refuse more than this many images (pairs grow as N^2)")
args = ap.parse_args()
files = collect(args.paths, args.recursive)
if len(files) < 2:
print(f"Need at least 2 images; found {len(files)}.", file=sys.stderr)
return 2
if len(files) > args.limit:
print(
f"{len(files)} images would be {len(files) * (len(files) - 1) // 2} "
f"pairs. Narrow the folder or raise --limit.", file=sys.stderr
)
return 2
print(f"hash_size={HASH_SIZE} ({HASH_SIZE * HASH_SIZE} bits) "
f"threshold={args.threshold} aspect_tol={ASPECT_TOL}")
print(f"pixel limits: mean <= {FINGERPRINT_MAX_MEAN_DIFF}, "
f"changed <= {FINGERPRINT_MAX_CHANGED_FRACTION:.1%}\n")
shots = [Shot(f) for f in files]
for s in shots:
if not s.ok:
print(f" ! unreadable, excluded: {s.path.name}{s.error}")
shots = [s for s in shots if s.ok]
if len(shots) < 2:
print("Not enough readable images.", file=sys.stderr)
return 2
print(f"{'verdict':<10} {'dist':>5} {'mean':>7} {'changed':>8} pair")
print("-" * 78)
counts: dict[str, int] = {}
rows = []
for a, b in itertools.combinations(shots, 2):
v, why = verdict(a, b, args.threshold)
counts[v] = counts.get(v, 0) + 1
dist = imagehash.hex_to_hash(a.phash) - imagehash.hex_to_hash(b.phash)
measured = fingerprint_diff(a.fingerprint, b.fingerprint)
mean, changed = measured if measured else (float("nan"), float("nan"))
rows.append((v, dist, mean, changed, a, b, why))
# Closest pairs first: the interesting decisions are the near-misses at
# both limits, not the obvious strangers at the bottom of the list.
for v, dist, mean, changed, a, b, why in sorted(rows, key=lambda r: r[1]):
print(f"{v:<10} {dist:>5} {mean:>7.2f} {changed:>7.2%} "
f"{a.label} vs {b.label}")
print(f"{'':<10} {'':>5} {'':>7} {'':>8} -> {why}")
print("\n" + " ".join(f"{k}: {n}" for k, n in sorted(counts.items())))
print(
"\nRead the margin, not just the verdict. A pair you consider the SAME "
f"image should sit far under mean {FINGERPRINT_MAX_MEAN_DIFF} / changed "
f"{FINGERPRINT_MAX_CHANGED_FRACTION:.0%}; a pair you consider DIFFERENT "
"artwork\nshould sit far over. Anything that only just cleared a limit "
"is what will flip on the next file, and is the reason to move a constant."
)
return 0
if __name__ == "__main__":
raise SystemExit(main())