nx_imgorient.nx
buildroot/runtime/nx_imgorient.nx
about
nx_imgorient.nx -- DIHEDRAL (D4) ORIENTATION-INVARIANT perceptual matching.
MEASURED MOTIVATION (nx_imgbench, 2026-07-23): the live reverse-image client scores
mirror-horizontal recall@1 = 0 permille
rotate-90 recall@1 = 31 permille
rotate-180 recall@1 = 31 permille
and det@10 = ZERO for all three -- i.e. a horizontally mirrored copy of an indexed image is, to the
production engine, a completely different picture. Mirroring is not exotic: it is what every meme
repost, every "flipped to dodge copyright detection" upload, and every front-camera selfie does.
THE FIX, and why it is nearly free: the eight rigid symmetries of the square (identity, three
rotations, and those four composed with a mirror) form the dihedral group D4. Because D4 is a GROUP,
the orbit {g.A : g in D4} of an image A is IDENTICAL to the orbit of any dihedral variant of A --
applying h to a rotated copy just re-labels which element of the same eight you land on. Therefore:
* the MULTISET of the eight orientation fingerprints is a dihedral INVARIANT of the image, and
* min-of-eight is a CANONICAL FORM: canonical(A) == canonical(g.A) for every g, exactly, always.
No training, no floats, no index change, no extra storage -- pure integer algebra over the existing
nx_phash dHash. Eight extra 9x8 downscales per image is nothing.
TWO SERVING MODES, deliberately both offered (they trade different things):
canonical -- store min-of-8, one probe per query. Cheapest; loses WHICH orientation matched and
very slightly raises collision odds (eight images now share one bucket key).
query-side -- store the plain dHash unchanged, compute the query's eight and probe eight times.
Zero information lost, zero index migration, reports the matching orientation.
Costs eight index probes, which a sublinear index (nx_phash_index BK-tree) absorbs.
INDEPENDENCE NOTE (rigor, deliberate): the geometric transforms here are implemented from scratch
rather than imported from nx_imgxform. nx_imgxform is the ADVERSARY that the ruler attacks us with;
this file is the DEFENCE. Sharing one implementation would let a single indexing bug cancel itself
out and score as a pass -- the defence must be able to disagree with the attack.
genealogy_id: dihedral_group_d4 (classical) over krawetz_2011 dHash
license_tier: ORIGINAL
dependencies 1 imports · 1 importers
imports: nx_phash.nx
imported by: nx_imgsearch_tier.nx
structs
| none |
consts
| 36 | const OR_COUNT: i64 = 8 // |D4| |
| 38 | const OR_IDENT: i64 = 0 |
| 39 | const OR_ROT90: i64 = 1 |
| 40 | const OR_ROT180: i64 = 2 |
| 41 | const OR_ROT270: i64 = 3 |
| 42 | const OR_FLIPH: i64 = 4 |
| 43 | const OR_TRANSP: i64 = 5 |
| 44 | const OR_FLIPV: i64 = 6 |
| 45 | const OR_ANTIT: i64 = 7 |
functions
| 47 | func nx_orient_count() -> i64 { return OR_COUNT } called by 1: main |
| 49 | func nx_orient_name(k: i64) -> *u8 |
| 62 | func or_swaps_dims(k: i64) -> i64 called by 1: nx_orient_dims |
| 71 | func nx_orient_dims(k: i64, w: i64, h: i64, outwh: *i64) -> i64 |
| 79 | func nx_orient_apply(k: i64, gray: *u8, w: i64, h: i64, outwh: *i64) -> *u8 |
| 107 | func nx_orient_hashes(gray: *u8, w: i64, h: i64, out8: *i64) -> i64 |
| 122 | func or_ult(a: i64, b: i64) -> i64 |
| 132 | func nx_orient_canonical(gray: *u8, w: i64, h: i64) -> i64 |
| 145 | func nx_orient_canonical_of(h8: *i64) -> i64 calls 1: or_ult |
| 158 | func nx_orient_best_hamming(q8: *i64, target: i64, out_k: *i64) -> i64 |