Repository navigation
Eval: check E3, PDQ and pHash distance - #17
Conversation
pdq.py implements PDQ in numpy from the algorithm of Meta's reference, repeating its float32 arithmetic, and reproduces the hashes Meta's C++ reference prints for its eight regression images. render_copies.py saves each report image through the eval page's own render(), masked at the operating point and unmasked as the control; hash_distance.py reports the share of masked copies within PDQ distance 31 of their original, with a bootstrap interval and by masked share, and the distributions of both distances. The page handling of run_detect.py moves into EvalPage, which both use; with a real eval page, an image runs only once review.csv keeps it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
hash_distance.py refuses copies rendered from another version of the manifest. render_copies.py checks every turned image's EXIF orientation tag before the browser starts, through a helper it shares with run_detect.py, and writes its copies to a .part folder that is renamed once all are written. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
06pratyush
left a comment
There was a problem hiding this comment.
Approving. Besides reading the code, I ran:
- the eval tests at the top of the stack on Windows: 104 passed, none skipped, so the PDQ reference test downloaded Meta's eight images and matched all of them;
render_copies.pyandhash_distance.pyagainst the real eval page from #20, on a two-image stand-in set (the public-domain test photo, upright and stored turned 90°), at padding 0.10 and threshold 0.50. Each copy got one mask, rounded outward as contracts §2 says, covering 9.5% of the picture. The masked copies are at PDQ distance 44 from their originals and pHash distance 14; the plain copies at 0 and 0. The turned photo gives the same distances.
Two notes, neither blocking:
1. The E3 sentence in part E says more than the test checks. It reads as if eval/pdq.py gives the C++ hashes after shrinking each picture to at most 512 px a side, keeping its shape. test_pdq_matches_metas_reference_hashes never runs that shrink. It repeats the C++ tool's resize instead: it stretches each image with a side over 512 px to 512 × 512 by nearest neighbour, then calls hash_luma. E3 calls hash_image, which shrinks with Image.thumbnail((512, 512)), the call Meta's Python reference makes. The two paths differ:
- Meta's
bridge-1-original.jpg(1600 × 1004 px) becomes 512 × 321 bythumbnail, against 512 × 512 in the C++ tool. - Meta's own Python test expects hashes 4 to 8 bits from the C++ ones on these eight images, and allows up to 16.
test_hash_image_shrinks_to_512_keeping_the_shapecompareshash_imageonly with the samethumbnailcall.- All 800 report images in the manifest have a side over 512 px, so E3 always takes the path that no reference checks.
Suggested wording:
PDQ comes from
eval/pdq.py, which shrinks each picture to at most 512 px a side, keeping its shape, as Meta's Python reference does. On the eight regression images of Meta's C++ reference, stretched to 512 × 512 by nearest neighbour as that reference does, it gives the same hashes (distance 0); the shrink itself is not compared with a reference.
To check the shrink as well, hash_image could be held to the hashes Meta's Python test expects for the same eight images.
2. render_copies.py renders only the images of the split that have a none line in the detections file. It stops only if none of them has one, and gives no warning for the rest.
- With a detections file from
run_detect.py --split report --limit 100, E3 would cover 100 of the 800 report images. - With
--limit 100and the default--split all, it would cover 75. render_info.jsonand the E3 report would still say"split": "report", and only the image count would show the gap.- The run's
run_info.jsonrecords the limit, butrender_copies.pydoesn't read it, and the run line has neither the split nor the limit. score.pyreportsimagesInSplitbeside its count; E3 has no such field.
render_copies.py could stop unless every image of the split has a none line, and say how many are missing. That would also catch a detections file made on an older manifest: render_copies.py skips the run line, so it never compares the run's manifestSha256 with the manifest's.
Check E3 of protocol part E: how far each saved copy is from its original under the perceptual hashes reverse image search uses. Stacked on #15; merge that first.
What is in it
eval/pdq.py: PDQ, Meta's 256-bit perceptual hash, in numpy. It is written from the algorithm of Meta's reference implementation (ThreatExchangepdq/cpp, BSD), not copied from it, and repeats its float32 arithmetic step for step:It reproduces the hashes Meta's C++ reference publishes for its eight regression images exactly: distance 0, quality 100.
eval/tests/pdq_reference.csvpins those images to a ThreatExchange commit by SHA-256, and a test downloads them intodata/and checks every hash. It skips when offline.eval/render_copies.py DETECTIONS --pad P --threshold T --page URLsaves every report image twice through the eval page's ownrender()(contract §3): with its masks at the operating point, and without masks as the control. The copies stay in the git-ignoreddata/rendered/, with arender_info.jsonof masks, SHA-256s and versions.eval/hash_distance.py data/rendered/<id>reports:imagehash.phash) distances;The JSON and CSV it writes hold distances, qualities and shares only, never hashes or pixels.
Review guard. The page handling of
run_detect.pymoves into anEvalPageclass that both scripts use. With a real eval page, they now refuse any image thatreview.csvdoesn't keep, because part B runs no detector on an unreviewed image. The stub runs no detector, so it isn't affected. This covers part of what @06pratyush raised on Eval: check E2, saved files segment by segment #15 and Eval: yaw labelling page and Cohen's kappa #13.8 new tests (101 eval tests pass). On a made-up textured photo with 8.9% of it masked, the masked copies came out at PDQ distance 28 to 30 from the original, still within 31, against 2 for the plain copies. That is the README's warning that a reverse image search may still match a covered copy.
For both of us to agree
Part E of the protocol now states:
New eval-only dependencies: ImageHash 4.3.2 (BSD-2-Clause), scipy 1.18.1 (BSD-3-Clause), PyWavelets 1.10.0 (MIT and BSD-3-Clause).
🤖 Generated with Claude Code