From d751043b3ca7b228e123b833048c3a479ff84d62 Mon Sep 17 00:00:00 2001 From: jamesdward Date: Mon, 6 Jul 2026 10:13:36 +0100 Subject: [PATCH] feat: recogniser measurers for grammar 002 with WB-robust ink matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - measurer registry keyed by structure.type; image measured once per family; 001 band path byte-identical - measure_grid.py: grid module via edge-projection autocorrelation, flat-ink extraction, parent/product separation, overprint residual verification - two-path ink_set match: absolute Lab delta-E vs relationship path that estimates a per-channel white-balance gain (saturation-aware consensus estimator, one-sided scoring of clipped channels) — ink relationships survive casts that destroy absolute values (SI-020, SI-021) - 19 grid tests incl. clipping regression; 107 total green --- SPEC-ISSUES.md | 85 +++++ recogniser/claim.py | 34 +- recogniser/measure_grid.py | 610 ++++++++++++++++++++++++++++++++++ recogniser/score.py | 373 ++++++++++++++++++++- tests/test_recogniser.py | 32 +- tests/test_recogniser_grid.py | 367 ++++++++++++++++++++ 6 files changed, 1487 insertions(+), 14 deletions(-) create mode 100644 recogniser/measure_grid.py create mode 100644 tests/test_recogniser_grid.py diff --git a/SPEC-ISSUES.md b/SPEC-ISSUES.md index 6ab1ad5..67f85e8 100644 --- a/SPEC-ISSUES.md +++ b/SPEC-ISSUES.md @@ -302,3 +302,88 @@ consequence:** the meta-grammar should state, for a multiply-overprint colour mo overprint is defined only pairwise and deeper stacks are out of grammar (this implementation), or (b) a committed n-ink compositing order + rounding so deep overlaps are themselves derivable and verifiable — since without one, "overlap == multiply(parents)" is ambiguous the moment three inks meet. + +## SI-020 · §5/§6.2 · decided-here — A relationship-mode (white-balance-robust) colour match has no defined scoring + +Milestone 2 requires "ink-set matching with colour measured as relationships/orderings not absolutes — +it must survive white-balance shift", and audit 002 §6 says a print enrolment "would commit ink +*relationships* (orderings, ratios, multiply consistency) rather than absolute values". But the spec +defines only an **absolute** colour distance (delta-E76 against declared inks, SI-001): it names no way +to score a match that is allowed to differ from the declared inks by a global illuminant/white-balance +cast. **Choice made (grid family, `recogniser/measure_grid.py` + `_score_ink_grid` in `score.py`):** the +grid ink-set match is **two-path** and the feature agreement is the **max** of + + * ABSOLUTE — greedy (Hungarian-approx) assignment of measured inks to sheet inks in Lab, mean + clipped-linear agreement on delta-E76 / `delta_e`; and + * RELATIONSHIP — estimate one per-channel diagonal correction gain `g` (sRGB BGR) that maps the + measured inks onto the sheet inks, as the largest **consensus cluster** of per-channel + `sheet/measured` ratios over the assignment (`_consensus_ratio`: biggest set of ratios mutually + within ±5%, deterministic tie-breaks; not least squares and not a plain median, because a global + cast breaks the multiply overprint arithmetic, so overprint colours leak into the extracted ink set + and get misassigned — up to ~half the ratios can be junk, which drags a median off but cannot + imitate the tight cluster the true inks form). The estimate is **saturation-aware**: a channel + observation measured at ≥ `CLIP_HIGH=250` was (or may have been) clipped by the cast — its true + pre-gain value is unknowable, only a lower bound survives — and one at ≤ `CLIP_LOW=5` is + floored/unstable, so neither feeds the ratio pool; a channel with fewer than `MIN_GAIN_OBS=2` + unclipped observations falls back **neutrally to gain 1.0** with a caveat in the working (neutral, + not the cross-channel median, because white balance is per-channel by definition and borrowing + another channel's cast would fabricate a correction). Undo `g` on the full extracted palette, + **re-separate products in the corrected space** (where the multiply arithmetic holds again) to drop + the leaked overprints, and re-measure delta-E76 — scoring the clipped channels of clipped inks + **one-sidedly** (`_clip_aware_delta_e`): after correction a clipped channel is only a lower bound + on the true ink channel, so if the sheet channel is at or above that bound the observation is + *consistent* and the channel scores as a match (a clipped channel cannot match; that is measurement + loss, not identity mismatch — its contribution is capped, the ink is not zeroed), while a bound + already past the sheet value keeps its real mismatch. The claim's working publishes both + agreements, the correction gain, the *implied applied* gain (`1/g`, which recovers the white + balance that was applied), a `gain_in_bounds` flag, a per-channel rank correlation (a positive + diagonal gain is order-preserving, so high rank correlation corroborates that the ink *orderings* + survive), and a `clipping` block (thresholds, clipped-ink count, gain-fallback channels). + +Several numbers are **v0 constants, not spec values**: the gain bounds `GAIN_MIN=0.5 / GAIN_MAX=2.0` (a +gain outside them is more than a plausible cast, so the relationship path is rejected); the credibility +floor `MIN_INKS_FOR_GAIN=3` (a global-cast claim from one or two inks over-fits — a free per-channel +gain plus assignment freedom can map a handful of colours onto any palette — so below it the +relationship path is disabled, which is also what keeps cross-grammar discrimination honest: a two-ink +001 band fragment cannot borrow the grid ink-set's leniency); the saturation constants +`CLIP_HIGH=250 / CLIP_LOW=5 / MIN_GAIN_OBS=2` and the consensus window `GAIN_CONSENSUS_REL_TOL=0.05`; +and the two-path combinator itself (taking the **max**). The `max` is a decision: it says "a match +counts if EITHER the absolute values OR a single global-cast-corrected version of them lands within +tolerance", trading a higher false-accept rate under adversarial casts for the print-robustness the +milestone demands. **Spec consequence:** the spec should define (a) whether relationship-mode colour +matching is permitted at all and, if so, its scoring (absolute vs relationship vs their combination); +(b) the sane bound on a correction transform (diagonal-gain only? full 3×3? bounds?); (c) how few inks +may support a relationship claim — because a colour signature that may be rescaled per channel is +strictly weaker than one that may not, and two conforming recognisers must agree on how much weaker; +and (d) how clipped (saturated) channel observations are treated — a cast that clips a bright ink +destroys information *asymmetrically* (a lower bound survives), and a conforming recogniser must +neither count that loss as identity mismatch nor let it bias the cast estimate. + +## SI-021 · §5 · decided-here — Colour-only overprint verification cannot associate a wildly-corrupted overlap, and a single-hue ramp mimics a broken product + +Audit 002 §5 makes the multiply overprint a **self-verifying** check: "a fragment where inks cross +contains parents and product together, and c₁ × c₂ ≠ c₃ flags an unfaithful reproduction." The audit +performs this having already *identified* the overlap region and its two parents (visually); a +pixels-only recogniser must instead recover the association from colour alone, and two facts make that +lossy. (1) A genuine multiply product is **darker** than both parents; a *wildly* corrupted overlap +(e.g. an additive `a+b` blend, which is *lighter* than either parent) is too far from any multiply pair +to be associated with its parents at all — it reads as a spurious extra ink, caught (if anywhere) by +the ink-set match, not the overprint check. So the overprint-consistency residual can only catch +overprints whose arithmetic **drifted** (stayed in the product's neighbourhood, darker than both +parents), which is the realistic "unfaithful reproduction" the audit describes; a gross blend is a +different failure mode. (2) A single-hue **lightness ramp** — e.g. a 001 band surface's anti-aliased +greens — contains colours where a darker green really is ≈ `multiply(two lighter greens)`, so a naïve +"is this colour a product of two others?" test manufactures phantom broken overprints and raises a +false verification flag when a grid sheet is scored against a band image. **Choice made +(`separate_products`):** a colour is a product only if a pair (i) **contains** it (darker per channel, +since multiply darkens) and (ii) for a *broken* (drifted) product, the parents are **distinct in hue** +(OpenCV hue distance ≥ `MIN_PARENT_HUE=15`) — an overprint is between two *different* inks, so a +single-hue ramp is excluded by construction. Faithful products are matched tight +(`PRODUCT_SEP_TOL=8`, self- or distinct-pair) and broken ones in a wider band +(`PRODUCT_BROKEN_TOL=40`, distinct-hue); the overprint verification tolerance `OVERPRINT_RESIDUAL_TOL=12` +(RGB units) sits between them so a clean render passes at residual 0 and a drifted overprint flags. These +four constants are **v0 choices**. **Spec consequence:** the meta-grammar should state that overprint +consistency is a **weight-0 verification** signal defined only for in-neighbourhood arithmetic drift +between two distinct inks (not a detector of arbitrary blend corruption), and should say whether the +overlap region may be assumed pre-identified (as the audit had it) or must be recovered from pixels — +because the two impose very different robustness requirements on a conforming recogniser. diff --git a/recogniser/claim.py b/recogniser/claim.py index 8214183..255793f 100644 --- a/recogniser/claim.py +++ b/recogniser/claim.py @@ -26,10 +26,22 @@ from sheets import list_sheets from recogniser import measure as _measure +from recogniser import measure_grid as _measure_grid from recogniser import score as _score CLAIM_NOTE = "identity claim -- unverified until resolution completes" +# Measurement dispatch registry, keyed by a sheet's ``structure.type`` (spec 8 +# step 2). Each family turns the image into ``{"measurements", "working"}`` in the +# same shape; ``recognise`` runs each family present ONCE on the image (not once +# per sheet) and scores each sheet against its own family's measurements. Adding a +# structure type is one entry here plus a measurer module -- 001 (band) behaviour +# is untouched because it keeps scoring against ``measure.measure_surface``. +STRUCTURE_MEASURERS = { + "band": _measure.measure_surface, + "grid": _measure_grid.measure_grid_surface, +} + def _jsonable(obj): """Recursively coerce numpy scalars/arrays to plain Python for json.dumps.""" @@ -58,10 +70,25 @@ def recognise(image_or_path, grammars_dir="grammars", image_ref=None) -> dict: image_ref = str(image_or_path) if not isinstance(image_or_path, np.ndarray) \ else "" - measured = _measure.measure_surface(image) sheets = list_sheets(grammars_dir) - results = [_score.score_sheet(sheet, measured) for sheet in sheets] + # Run each measurer family the present sheets need, ONCE per family on the + # image (spec 8 step 2). A sheet whose structure.type has no measurer family + # is scored against empty measurements -- graceful, never a crash (the scorer + # reports every feature unobserved). + empty = {"measurements": {}, "working": {}} + measured_by_type = {} + for sheet in sheets: + stype = sheet.get("structure", {}).get("type") + if stype in measured_by_type or stype not in STRUCTURE_MEASURERS: + continue + measured_by_type[stype] = STRUCTURE_MEASURERS[stype](image) + + results = [] + for sheet in sheets: + stype = sheet.get("structure", {}).get("type") + measured = measured_by_type.get(stype, empty) + results.append(_score.score_sheet(sheet, measured)) # Sort by confidence, then sheet id, so the order is deterministic on ties. results.sort(key=lambda r: (-r["aggregate_confidence"], str(r["sheet_id"]))) @@ -70,7 +97,8 @@ def recognise(image_or_path, grammars_dir="grammars", image_ref=None) -> dict: "image_ref": image_ref, "image_shape": list(image.shape), "results": results, - "measurement_working": measured["working"], + "measurement_working": {stype: m["working"] + for stype, m in measured_by_type.items()}, "recogniser_version": "v0", } return _jsonable(claim) diff --git a/recogniser/measure_grid.py b/recogniser/measure_grid.py new file mode 100644 index 0000000..9693027 --- /dev/null +++ b/recogniser/measure_grid.py @@ -0,0 +1,610 @@ +"""Classical, deterministic measurers for GRID grammars (audit 002 / iso-002). + +This is the grid-family sibling of ``recogniser/measure.py`` (which serves the +*band* family, grammar 001). The public pipeline (``recogniser/claim.py``) +dispatches on the sheet's ``structure.type``: ``band`` -> ``measure.py``, +``grid`` -> this module. Both return the same shape -- +``{"measurements": {name: Measurement}, "working": {...}}`` with the same +``Measurement`` dataclass -- so the scorer consumes either uniformly. + +Everything here is classical CV (numpy + OpenCV only, no learned models) and +deterministic: the same image yields byte-identical measurements (README +principle 4). A measurer NEVER looks at a grammar sheet -- it turns pixels into a +number plus an honest sample count -- so each measurer is unit-tested against +synthetic ground truth from ``generator/grid.py`` before the pipeline trusts it. + +What we measure, and why (Pattern Grammar Audit 002 s2 / s5 / s6): + + * GRID MODULE (audit s2 method: edge-projection autocorrelation). Project the + gradient magnitude onto x and onto y, autocorrelate each 1-D profile, and + read the dominant common period. Autocorrelation is shift-invariant, so a + crop that does not start on a module boundary recovers the same period + (robust to the fragment not being module-aligned). Role: normalisation -- + anchors scale, identifies nothing (SI-006). + + * FLAT-INK EXTRACTION. The generated surfaces are exactly flat (no + anti-aliasing, generator docstring), so the distinct ink colours are exact + pixel values. We count unique colours (excluding the white ground) with a + small Euclidean quantisation guard so a JPEG'd / degraded input still + clusters to the same centroids, and keep colours holding a real area + fraction. + + * PARENT / PRODUCT SEPARATION (audit s2 overprint arithmetic). A colour c3 is + a PRODUCT (evidence of two-ink layering, not a member of the ink set) when + some pair of *distinct* other extracted colours (c1, c2) satisfies + ``multiply(c1, c2) ~= c3`` under the generator's exact rounding + ``(c1*c2 + 127)//255``. Products are separated out; the candidate INK SET is + the parents plus any colour not explainable as a product. Per product we + record the residual ``|measured - multiply(parents)|`` -- that residual is + the overprint-consistency verification measurement (audit s5: the arithmetic + is a self-verifying colour check; ``c1*c2 != c3`` flags an unfaithful + reproduction). + + Note on a palette coincidence: for the ISO master set red #C5111D equals + multiply(#D52EB2, #EC5F2A) exactly. When both a lone red ink and a + magenta*orange overprint occur they are the *same* pixel value and merge into + one cluster, which frequency-ordered separation accepts as the (frequent) + red ink -- so the coincidence is harmless. Every other master ink sits >= 43 + units from any distinct-pair product (measured), so the separation tolerance + below never misclassifies a real ink as a product. + + * STRIPE RHYTHM (verification; audit s2: bar M/2, pitch 1M, duty 0.5). Where a + striped region exists we run-length measure bar/gap in module units and + report the duty; if no striped region is found we report it unobserved + (n = 0), never a failure. + + * PRIMITIVE FREQUENCY MIX (audit s6 point 2; sheet ``status: unmeasured``, + SI-008). A rough connected-component classifier bins components into + {circle_like, rectangle_like, stripe_like, diagonal_like, other}. The sheet + declares this feature unmeasured, so the scorer SKIPS it regardless (SI-008); + we expose the measured mix in the working as "measured but uncommitted", + with an honest caveat that overlapping primitives merge into one component so + the classifier is coarse. We deliberately do not over-engineer it. + + * INK-SET matching is NOT done here. The two-path (absolute + relationship / + white-balance-gain) match lives in ``recogniser/score.py`` because it needs + the sheet's expected inks; this module only hands over the extracted ink set + (BGR triples) and the products. Strict measurement/scoring separation holds: + this module never sees a sheet. +""" + +from __future__ import annotations + +import cv2 +import numpy as np + +from recogniser.measure import Measurement + +# --- constants (v0 choices, documented) ------------------------------------- + +# Grid-module autocorrelation search window (px). The audit modules are 69-72 px; +# we search a generous band so smaller test renders and larger surfaces both +# resolve. The fundamental (not a harmonic 2M/3M) is chosen as the smallest lag +# that is a strong local autocorrelation peak. +MIN_MODULE_PX = 8 +MAX_MODULE_PX = 500 + +# A colour is the white ground when every channel is at least this bright. The +# ground is exactly 255; the threshold tolerates JPEG softening without ever +# swallowing an ink (the lightest master inks keep a channel well below 245). +WHITE_MIN = 250 + +# Quantisation guard for flat-ink extraction: unique colours within this +# Euclidean BGR distance of an already-accepted cluster centroid are merged into +# it. 0 on a clean render (colours are exact); absorbs JPEG/degradation spread. +INK_MERGE_DIST = 8.0 + +# A colour cluster holding less than this fraction of the surface is treated as +# noise (JPEG ringing, thin AA-free edges are exact so this mostly drops +# compression artefacts) and dropped from the ink/product analysis. +MIN_INK_FRACTION = 0.003 + +# Parent/product separation is two-tier (Chebyshev per-channel on the exact +# multiply). A colour is a FAITHFUL product when it matches multiply of a pair of +# other inks -- SELF pairs (same ink overlapping itself, ink*ink) OR distinct +# pairs -- within PRODUCT_SEP_TOL. Clean products match at 0; every real master +# ink is >= 17 units from any self product and >= 43 from any distinct product +# (measured), so a tight 8 never misclassifies a real ink as a product (the sole +# 0-distance coincidence, red == magenta*orange, is the SAME pixel value and +# collapses by colour-merge). Faithful products are excluded from the ink set with +# a small residual. +PRODUCT_SEP_TOL = 8 + +# A colour that a DISTINCT-hue pair multiplies close-to-but-not-onto (residual in +# (PRODUCT_SEP_TOL, PRODUCT_BROKEN_TOL], contained, parents hue-distinct) is a +# BROKEN overprint: the arithmetic drifted (audit s5 "c1*c2 != c3"). It is excluded +# from the ink set and its (large) residual drives the overprint-consistency flag. +# The distinct-hue requirement stops a single-hue lightness ramp (a 001 band +# surface's anti-aliased greens, where a darker green ~= multiply of two lighter +# greens) from ever registering as a broken overprint. See SPEC-ISSUES SI-020. +PRODUCT_BROKEN_TOL = 40 + +# Multiply always darkens: an overprint colour is <= BOTH parents per channel. +# Requiring this containment (with a small slack for degradation) before a colour +# is called a product stops a noisy non-grid image (e.g. a 001 band surface's +# anti-aliased greens) from ever pairing into a spurious "broken" product and +# raising a false overprint-verification flag: two greens cannot be contained by +# two distinct other inks. A genuine (even mildly unfaithful) overprint stays +# darker than its parents and is still caught. +CONTAINMENT_SLACK = 4 + +# An overprint is a product of two DISTINCT inks (audit s2), so its parents must +# differ in HUE, not merely in lightness. This is the guard that keeps a +# single-hue lightness ramp -- e.g. a 001 band surface's anti-aliased green ramp, +# where a darker green really is ~multiply(two lighter greens) -- from ever being +# read as an overprint: all its colours share one hue. Threshold in OpenCV hue +# units (0..179, ~2 deg each); circular. Real ISO ink pairs that actually overlap +# differ by well over this; near-identical-hue inks (teal vs teal-grey) are +# conservatively skipped rather than risk a false flag (weight-0 verification). +MIN_PARENT_HUE = 15 + +# Stripe detection: a column is "striped" when its interior ink/gap runs are +# within this fraction of the module of the expected M/2 bar/gap. +STRIPE_RUN_TOL_FRAC = 0.30 + + +# --- multiply (matches generator.grid exactly) ------------------------------ + + +def _multiply(a, b): + """Channel-wise multiply overprint ``(a*b + 127)//255`` (generator.grid). + + Re-implemented here (rather than imported from ``generator``) so the + recogniser stays independent of the generator, but the arithmetic is + byte-identical to ``generator.grid.multiply`` -- the audit s2 rule verified to + the integer. + """ + a = np.asarray(a, dtype=np.int64) + b = np.asarray(b, dtype=np.int64) + return (a * b + 127) // 255 + + +def _hue(bgr) -> int: + """OpenCV HSV hue (0..179) of a single BGR colour (for distinct-ink test).""" + px = np.array([[[int(bgr[0]), int(bgr[1]), int(bgr[2])]]], dtype=np.uint8) + return int(cv2.cvtColor(px, cv2.COLOR_BGR2HSV)[0, 0, 0]) + + +def _hue_dist(h1: int, h2: int) -> int: + """Circular distance between two OpenCV hues (period 180).""" + d = abs(h1 - h2) % 180 + return min(d, 180 - d) + + +# --- grid module via edge-projection autocorrelation (audit s2) ------------- + + +def _axis_autocorr_period(profile: np.ndarray): + """Dominant period of a 1-D edge projection via autocorrelation. + + Returns ``(period_px, strength)`` where strength is the peak autocorrelation + normalised by lag-0 (in [0, 1]); ``(None, 0.0)`` if nothing resolves. The + fundamental is the SMALLEST lag that is a strong local maximum, so a lattice + with structure at 2M/4M (whose autocorrelation also peaks there) still + reports M, not a multiple. + """ + profile = profile.astype(np.float64) + profile = profile - profile.mean() + if profile.size < 2 * MIN_MODULE_PX or not np.any(profile): + return None, 0.0 + full = np.correlate(profile, profile, mode="full") + ac = full[full.size // 2 :] # non-negative lags + zero = ac[0] if ac[0] != 0 else 1.0 + lo = MIN_MODULE_PX + hi = min(ac.size - 2, MAX_MODULE_PX) + if hi <= lo: + return None, 0.0 + window = ac[lo : hi + 1] + peak = float(window.max()) + if peak <= 0: + return None, 0.0 + # Local maxima at >= 40% of the window peak; take the smallest lag (the + # fundamental) among them. + thresh = 0.4 * peak + for lag in range(lo, hi + 1): + if ac[lag] < thresh: + continue + if ac[lag] >= ac[lag - 1] and ac[lag] >= ac[lag + 1]: + return lag, float(ac[lag] / zero) + lag = lo + int(np.argmax(window)) + return lag, float(ac[lag] / zero) + + +def detect_grid_module(surface: np.ndarray): + """Detect the square module M (px) by edge-projection autocorrelation. + + Returns ``(module_px, n_cells, detail)``. ``module_px`` is ``None`` when no + lattice resolves. Method (audit s2): sum the absolute x-gradient over rows to + get a vertical-edge profile and the absolute y-gradient over columns for a + horizontal-edge profile, autocorrelate each, and reconcile the two axis + periods (a square lattice shares one M). Shift-invariant, so an off-grid crop + recovers the same M. + """ + grey = surface.astype(np.float64).sum(axis=2) + dx = np.abs(np.diff(grey, axis=1)).sum(axis=0) # vertical edges, length W-1 + dy = np.abs(np.diff(grey, axis=0)).sum(axis=1) # horizontal edges, length H-1 + px, sx = _axis_autocorr_period(dx) + py, sy = _axis_autocorr_period(dy) + + if px is None and py is None: + return None, 0, {"x_period": None, "y_period": None, + "reason": "no lattice resolved"} + # Reconcile: if both axes resolved and agree within 2 px, average; otherwise + # take the stronger axis (a striped fragment shows edges on one axis only). + if px is not None and py is not None and abs(px - py) <= 2: + module = int(round((px + py) / 2.0)) + elif px is not None and py is not None: + module = px if sx >= sy else py + else: + module = px if px is not None else py + + h, w = surface.shape[:2] + n_cells = max(1, (h // module) * (w // module)) if module else 0 + detail = {"x_period": px, "y_period": py, + "x_strength": round(sx, 4), "y_strength": round(sy, 4), + "module_px": module} + return module, n_cells, detail + + +# --- flat-ink extraction ---------------------------------------------------- + + +def extract_flat_inks(surface: np.ndarray): + """Extract distinct flat ink colours (excluding white) with area fractions. + + Returns ``(inks, detail)`` where ``inks`` is a list of ``(bgr_tuple, + fraction)`` sorted by fraction desc then colour (deterministic). Method: + exclude near-white ground pixels, take unique colours with counts, then a + frequency-ordered greedy clustering with an ``INK_MERGE_DIST`` Euclidean + guard so degraded inputs cluster to the same centroids a clean render gives + exactly. Colours below ``MIN_INK_FRACTION`` of the surface are dropped as + noise. + """ + h, w = surface.shape[:2] + total = float(h * w) + flat = surface.reshape(-1, 3) + ground = np.all(flat >= WHITE_MIN, axis=1) + body = flat[~ground] + if body.shape[0] == 0: + return [], {"total_px": total, "n_clusters": 0, "note": "all-white surface"} + + colours, counts = np.unique(body, axis=0, return_counts=True) + colours = colours.astype(np.int64) + # Deterministic order: most frequent first, ties by BGR value. + order = np.lexsort((colours[:, 2], colours[:, 1], colours[:, 0], -counts)) + + centroids = [] # float BGR + weights = [] # pixel counts + sums = [] # weighted BGR sums (for centroid update) + for idx in order: + c = colours[idx].astype(np.float64) + n = int(counts[idx]) + assigned = None + best = INK_MERGE_DIST + for k, cen in enumerate(centroids): + d = float(np.sqrt(((cen - c) ** 2).sum())) + if d <= best: + best = d + assigned = k + if assigned is None: + centroids.append(c.copy()) + sums.append(c * n) + weights.append(n) + else: + sums[assigned] = sums[assigned] + c * n + weights[assigned] += n + centroids[assigned] = sums[assigned] / weights[assigned] + + inks = [] + for cen, wgt in zip(centroids, weights): + frac = wgt / total + if frac < MIN_INK_FRACTION: + continue + bgr = tuple(int(round(v)) for v in cen) + inks.append((bgr, frac)) + inks.sort(key=lambda t: (-t[1], t[0])) + detail = {"total_px": total, "n_clusters": len(inks)} + return inks, detail + + +# --- parent / product separation (audit s2 overprint arithmetic) ------------ + + +def separate_products(inks): + """Split extracted inks into a candidate ink set and multiply products. + + ``inks`` is ``[(bgr, fraction), ...]`` (frequency-ordered). Returns + ``(ink_set, products, detail)``: + + * ``ink_set`` -- ``[(bgr, fraction), ...]`` parents + colours not + explainable as a product (the identification-bearing set). + * ``products`` -- list of dicts ``{colour, parents, predicted, residual, + broken}`` where ``residual`` is the Chebyshev distance between the measured + colour and ``multiply(parents)`` (audit s5 overprint-consistency signal), + and ``broken`` marks a drifted (unfaithful) overprint. + + Two-tier (see the ``PRODUCT_SEP_TOL`` / ``PRODUCT_BROKEN_TOL`` constants), + both requiring containment (the product is darker than both parents, since + multiply darkens): + + * FAITHFUL -- a self pair (ink*ink) OR a distinct pair whose multiply lands + within ``PRODUCT_SEP_TOL`` of the colour (residual ~0 on a clean render). + * BROKEN -- a DISTINCT-HUE pair whose multiply lands within + ``PRODUCT_BROKEN_TOL`` but beyond ``PRODUCT_SEP_TOL``: the arithmetic + drifted (audit s5). The distinct-hue requirement stops a single-hue + lightness ramp from ever registering as a broken overprint. + + Both kinds are removed from the ink set. Frequency order matters: parents + (frequent lone-ink regions) are considered before their (rarer) products, so a + product is only ever explained by colours already accepted as inks. + """ + colours = [np.array(bgr, dtype=np.int64) for bgr, _ in inks] + hues = [_hue(bgr) for bgr, _ in inks] + n = len(colours) + is_product = [False] * n + products = [] + + for k in range(n): + best = None # (residual, i, j, broken) + for i in range(n): + if i == k or is_product[i]: + continue + for j in range(i, n): # j == i allows self-overlap products (ink*ink) + if j == k or is_product[j]: + continue + # Containment: c_k must be darker than both parents (multiply + # darkens), else it is a lone ink / artefact, not an overprint. + if np.any(colours[k] > colours[i] + CONTAINMENT_SLACK) or \ + np.any(colours[k] > colours[j] + CONTAINMENT_SLACK): + continue + d = int(np.abs(_multiply(colours[i], colours[j]) - colours[k]).max()) + distinct_hue = _hue_dist(hues[i], hues[j]) >= MIN_PARENT_HUE + if d <= PRODUCT_SEP_TOL: + broken = False + elif d <= PRODUCT_BROKEN_TOL and distinct_hue: + broken = True # drifted arithmetic between two distinct inks + else: + continue + if best is None or d < best[0]: + best = (d, i, j, broken) + if best is not None: + d, i, j, broken = best + is_product[k] = True + products.append({ + "colour": tuple(int(v) for v in colours[k]), + "parents": (tuple(int(v) for v in colours[i]), + tuple(int(v) for v in colours[j])), + "predicted": tuple(int(v) for v in _multiply(colours[i], colours[j])), + "residual": int(d), + "broken": broken, + }) + + ink_set = [inks[k] for k in range(n) if not is_product[k]] + detail = {"n_inks": len(ink_set), "n_products": len(products), + "n_broken": sum(1 for p in products if p["broken"])} + return ink_set, products, detail + + +# --- stripe rhythm (verification; audit s2) --------------------------------- + + +def measure_stripe_duty(surface: np.ndarray, module_px): + """Measure the stripe duty over any horizontally-striped region. + + Returns ``(duty, n_regions, detail)``. A column is "striped" when its + interior ink/gap runs are all within ``STRIPE_RUN_TOL_FRAC`` of the expected + M/2 bar/gap and it holds >= 2 bars; contiguous striped columns are grouped + into regions. ``duty`` is the mean ink fraction over striped columns; when no + striped region is found ``n_regions == 0`` and ``duty`` is ``None`` (reported + unobserved, never a failure -- audit s5). + """ + if not module_px or module_px < 4: + return None, 0, {"reason": "no module to anchor stripe rhythm"} + half = module_px / 2.0 + tol = STRIPE_RUN_TOL_FRAC * module_px + ink = (surface != 255).any(axis=2) # any ink (incl. overprints) + h, w = ink.shape + + striped_cols = [] + duties = [] + for x in range(w): + col = ink[:, x] + runs = _run_lengths(col) + if len(runs) < 4: + continue + interior = runs[1:-1] # drop partial leading/trailing runs + bar_runs = [n for on, n in interior if on] + gap_runs = [n for on, n in interior if not on] + if len(bar_runs) < 2: + continue + if all(abs(n - half) <= tol for n in bar_runs) and \ + all(abs(n - half) <= tol for n in gap_runs): + striped_cols.append(x) + duties.append(float(col.mean())) + + if not striped_cols: + return None, 0, {"reason": "no striped region found"} + + # Group contiguous striped columns into regions (sample_unit striped_regions). + n_regions = 1 + for a, b in zip(striped_cols, striped_cols[1:]): + if b - a > 1: + n_regions += 1 + duty = float(np.mean(duties)) + detail = {"n_striped_columns": len(striped_cols), "n_regions": n_regions, + "expected_duty": 0.5, "measured_duty": round(duty, 4)} + return duty, n_regions, detail + + +def _run_lengths(mask_1d): + """Lengths of consecutive True/False runs down a bool vector: [(on, n), ...].""" + runs = [] + if mask_1d.size == 0: + return runs + cur = bool(mask_1d[0]) + length = 1 + for v in mask_1d[1:]: + if bool(v) == cur: + length += 1 + else: + runs.append((cur, length)) + cur = bool(v) + length = 1 + runs.append((cur, length)) + return runs + + +# --- primitive frequency mix (SI-008: measured but uncommitted) ------------- + + +def measure_primitive_mix(surface: np.ndarray, module_px): + """Rough connected-component classification of the primitive mix. + + Returns ``(mix, n_components, detail)``. Bins each ink component into + {circle_like, rectangle_like, stripe_like, diagonal_like, other} by simple + shape statistics (extent, aspect, circularity, and internal stripe periodicity). + This is deliberately coarse: overlapping primitives merge into one component, + so the counts are indicative, not exact -- honesty note carried in the detail. + The sheet declares ``primitive_frequency_mix`` unmeasured (SI-008), so the + scorer skips this regardless; it is exposed only as working evidence. + """ + ink = (surface != 255).any(axis=2).astype(np.uint8) + n_labels, labels, stats, _ = cv2.connectedComponentsWithStats(ink, connectivity=8) + bins = {"circle_like": 0, "rectangle_like": 0, "stripe_like": 0, + "diagonal_like": 0, "other": 0} + min_area = max(16, (module_px * module_px) // 8) if module_px else 16 + n_components = 0 + for lab in range(1, n_labels): + area = int(stats[lab, cv2.CC_STAT_AREA]) + if area < min_area: + continue + n_components += 1 + bw = int(stats[lab, cv2.CC_STAT_WIDTH]) + bh = int(stats[lab, cv2.CC_STAT_HEIGHT]) + extent = area / float(bw * bh) if bw * bh else 0.0 + aspect = bw / float(bh) if bh else 0.0 + comp = (labels[stats[lab, cv2.CC_STAT_TOP]:stats[lab, cv2.CC_STAT_TOP] + bh, + stats[lab, cv2.CC_STAT_LEFT]:stats[lab, cv2.CC_STAT_LEFT] + bw] + == lab) + # Internal stripe periodicity: a stripe block's centre column alternates. + mid = comp[:, bw // 2] if bw else np.array([], dtype=bool) + runs = [n for on, n in _run_lengths(mid) if on] + striped = (module_px and len(runs) >= 2 + and all(abs(n - module_px / 2.0) <= 0.4 * module_px for n in runs)) + if striped: + bins["stripe_like"] += 1 + elif extent >= 0.95 and 0.8 <= aspect <= 1.25: + bins["rectangle_like"] += 1 + elif 0.70 <= extent <= 0.85 and 0.8 <= aspect <= 1.25: + bins["circle_like"] += 1 # inscribed circle fills ~pi/4 of its box + elif extent <= 0.6: + bins["diagonal_like"] += 1 # staircase leaves the box sparsely filled + else: + bins["other"] += 1 + detail = {"caveat": "overlapping primitives merge into one component; counts " + "are coarse and this feature is uncommitted (SI-008)", + "min_component_area": min_area} + return bins, n_components, detail + + +# --- top-level grid-surface measurement ------------------------------------- + + +def measure_grid_surface(image: np.ndarray) -> dict: + """Measure every iso-002 (grid) locus feature present in ``image`` (BGR uint8). + + Returns ``{"measurements": {name: Measurement}, "working": {...}}`` keyed by + the sheet ``measure`` names the scorer consumes: + ``grid_module_detect``, ``ink_set_match`` (the extracted ink SET; the + two-path colour match is done in the scorer), ``overprint_multiply_consistency``, + ``stripe_duty``, ``primitive_frequency_mix``, and ``staircase_step_angle``. + Features not observable come back ``n == 0`` / ``value None`` (honesty rule). + """ + if image.ndim != 3 or image.shape[2] != 3: + raise ValueError("image must be (H, W, 3) BGR uint8") + + working = {"family": "grid"} + steps = [] + + # 1. Grid module (audit s2 edge-projection autocorrelation). + module_px, n_cells, grid_detail = detect_grid_module(image) + working["grid"] = grid_detail + steps.append(f"grid module = {module_px} px (edge-projection autocorrelation)" + if module_px else "grid module: no lattice resolved") + grid_m = Measurement("grid_module_detect", module_px, n_cells if module_px else 0, + grid_detail) + + # 2. Flat inks -> parent/product separation. + inks_all, ink_detail = extract_flat_inks(image) + ink_set, products, sep_detail = separate_products(inks_all) + steps.append(f"extracted {len(inks_all)} flat colours -> {len(ink_set)} inks, " + f"{len(products)} products") + working["inks"] = { + "extracted": [{"bgr": list(bgr), "fraction": round(f, 5)} for bgr, f in inks_all], + "ink_set": [{"bgr": list(bgr), "fraction": round(f, 5)} for bgr, f in ink_set], + "products": products, + **ink_detail, **sep_detail, + } + ink_measurement = Measurement( + feature_measure_name="ink_set_match", + value=[list(bgr) for bgr, _ in ink_set], + n=len(ink_set), + detail={"order": "BGR ink-set centroids (products excluded)", + "fractions": [round(f, 5) for _, f in ink_set], + "products": products, + # Full extracted palette (inks + products) with fractions, so the + # scorer's relationship path can undo an estimated white-balance + # gain and re-separate products in the corrected space where the + # multiply arithmetic holds again (SI-020). + "extracted": [[list(bgr), round(f, 5)] for bgr, f in inks_all]}, + ) + + # 3. Overprint consistency (verification): the WORST product residual. Clean + # renders give 0 (exact multiply); a broken overprint gives a large + # residual that the scorer turns into a verification flag (audit s5). + if products: + residuals = [p["residual"] for p in products] + overprint = Measurement( + "overprint_multiply_consistency", + float(max(residuals)), + len(products), + {"per_product_residual": residuals, + "mean_residual": round(float(np.mean(residuals)), 3), + "max_residual": int(max(residuals))}, + ) + else: + overprint = Measurement("overprint_multiply_consistency", None, 0, + {"reason": "no two-ink overlaps observed"}) + + # 4. Stripe rhythm (verification). + duty, n_stripe, stripe_detail = measure_stripe_duty(image, module_px) + working["stripe"] = stripe_detail + stripe = Measurement("stripe_duty", duty, n_stripe, stripe_detail) + + # 5. Primitive mix (measured but uncommitted -- SI-008). + mix, n_comp, mix_detail = measure_primitive_mix(image, module_px) + working["primitive_mix"] = {"mix": mix, "n_components": n_comp, **mix_detail} + primitive_mix = Measurement("primitive_frequency_mix", mix, n_comp, + {"note": "measured but uncommitted (SI-008)", **mix_detail}) + + # 6. Staircase angle: not separately measured in the v0 grid family (weight-0 + # verification; robustly recovering 45deg from a cell staircase inside a + # free composition is not worth fake precision). Reported unobserved with + # an honest reason; the primitive classifier's diagonal_like count is in + # the working as coarse evidence. + staircase = Measurement("staircase_step_angle", None, 0, + {"reason": "staircase angle not measured in v0 grid " + "family; see primitive_mix.diagonal_like"}) + + working["normalisation_steps"] = steps + return { + "measurements": { + "grid_module_detect": grid_m, + "ink_set_match": ink_measurement, + "overprint_multiply_consistency": overprint, + "stripe_duty": stripe, + "primitive_frequency_mix": primitive_mix, + "staircase_step_angle": staircase, + }, + "working": working, + } diff --git a/recogniser/score.py b/recogniser/score.py index 2a88c29..7e5db46 100644 --- a/recogniser/score.py +++ b/recogniser/score.py @@ -82,6 +82,51 @@ IDENTIFIED_THRESHOLD = 0.70 CANDIDATE_THRESHOLD = 0.40 +# White-balance relationship path (grid ink match, SI-020). A single per-channel +# diagonal correction gain must fall in these bounds to count as a plausible +# illuminant/white-balance shift; a gain outside them means the colours differ by +# more than a global cast, so the relationship path is rejected and only the +# absolute match stands. +GAIN_MIN = 0.5 +GAIN_MAX = 2.0 + +# Channel-saturation guards for the relationship path (SI-020). A measured +# channel at >= CLIP_HIGH was (or may have been) clipped by the cast: its true +# pre-gain value is unknowable (only a lower bound survives), so it must not +# feed the gain estimate, and after correction it can only be scored one-sidedly. +# Symmetrically, a channel at <= CLIP_LOW is too small for a stable sheet/measured +# ratio (and may have been floored by a gain < 1). A channel's gain needs at +# least MIN_GAIN_OBS unclipped observations; with fewer it falls back NEUTRALLY +# to 1.0 with a caveat in the working (documented choice: neutral, not the +# cross-channel median, because white balance is per-channel by definition and +# borrowing another channel's cast would fabricate a correction). +CLIP_HIGH = 250.0 +CLIP_LOW = 5.0 +MIN_GAIN_OBS = 2 + +# Consensus window for the per-channel gain estimate (SI-020). A genuine global +# cast makes every true-ink ratio agree to within ~1%; overprint colours that +# leak into the extracted ink set under the cast produce scattered junk ratios. +# The estimator takes the LARGEST cluster of ratios that mutually agree within +# this relative tolerance (deterministic; ties broken by tighter spread, then +# smaller ratio) -- robust up to half the observations being junk, where a plain +# median already breaks at ~1/3. +GAIN_CONSENSUS_REL_TOL = 0.05 + +# The relationship (white-balance-gain) path needs at least this many measured +# inks to be credible evidence of a GLOBAL colour cast (SI-020). With fewer, a +# free per-channel gain plus assignment freedom can overfit a handful of colours +# onto any palette -- so below this the relationship path is disabled and only the +# absolute match stands, keeping cross-grammar discrimination honest (a two-ink +# band fragment cannot borrow the grid ink-set's leniency). +MIN_INKS_FOR_GAIN = 3 + +# Overprint-consistency verification tolerance (SI-020): Chebyshev distance (RGB +# units) between a measured overprint colour and the exact multiply of its +# parents. Clean renders sit at 0; a residual beyond this reads as broken +# arithmetic (audit s5 "c1*c2 != c3") and flags the claim's verification. +OVERPRINT_RESIDUAL_TOL = 12.0 + # Sample-size saturation constants per declared sample_unit (SI-002). Small # integers: two boundaries already give real cascade evidence, a couple of inks # complete the pair, but periods are cheap so they saturate slower. @@ -130,6 +175,13 @@ def _hex_to_lab(hex_value: str) -> np.ndarray: return lab[0, 0].astype(np.float64) +def _hex_to_bgr255(hex_value: str) -> np.ndarray: + """'#RRGGBB' -> np.array([B, G, R], float 0..255) for gain estimation.""" + h = hex_value.lstrip("#") + r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16) + return np.array([b, g, r], dtype=np.float64) + + def _bgr_to_lab(bgr) -> np.ndarray: """Mean BGR triple (0..255) -> CIE Lab, same float path as _hex_to_lab.""" arr = np.array([[[float(bgr[0]), float(bgr[1]), float(bgr[2])]]], @@ -169,6 +221,307 @@ def _score_ink(feature: dict, measurement) -> dict: } +def _greedy_assign_matrix(dist): + """Greedy min-distance assignment over a precomputed matrix (Hungarian approx). + + ``dist[mi][ei]`` is the distance between measured ink ``mi`` and expected ink + ``ei``. For <= 9 inks a global-greedy assignment (repeatedly take the smallest + available pair, each expected used once) is within a hair of optimal and fully + deterministic. Returns ``[(mi, ei, distance), ...]`` covering every measured + ink (sorted by ``mi``). If there are more measured inks than expected, the + surplus fall back to their nearest expected (with reuse) so every measured + ink is scored. + """ + cand = [] + for mi, row in enumerate(dist): + for ei, d in enumerate(row): + cand.append((d, mi, ei)) + cand.sort() + used_m, used_e = set(), set() + pairs = [] + for d, mi, ei in cand: + if mi in used_m or ei in used_e: + continue + pairs.append((mi, ei, d)) + used_m.add(mi) + used_e.add(ei) + if len(used_m) == len(dist): + break + for mi, row in enumerate(dist): + if mi in used_m: + continue + ei = int(np.argmin(row)) + pairs.append((mi, ei, row[ei])) + pairs.sort(key=lambda p: p[0]) + return pairs + + +def _greedy_assign(measured_labs, expected_labs): + """Greedy assignment measured<->expected on plain delta-E76 in Lab.""" + dist = [[_delta_e76(ml, el) for el in expected_labs] for ml in measured_labs] + return _greedy_assign_matrix(dist) + + +def _rank_corr(measured_vals, expected_vals): + """Spearman rank correlation between two equal-length sequences. + + A single positive per-channel gain is monotonic, so it preserves the + per-channel ORDERING of the inks -- a rank correlation ~1 corroborates that + the ink relationships (orderings) survive the shift even where absolute values + do not (audit s6). Ties are averaged; degenerate (constant) input scores 0. + """ + m = np.asarray(measured_vals, dtype=np.float64) + e = np.asarray(expected_vals, dtype=np.float64) + if m.size < 2: + return 0.0 + rm = _ranks(m) + re = _ranks(e) + if rm.std() == 0 or re.std() == 0: + return 0.0 + return float(np.corrcoef(rm, re)[0, 1]) + + +def _ranks(values: np.ndarray) -> np.ndarray: + """Average ranks of ``values`` (ties share the mean rank). Deterministic.""" + order = np.argsort(values, kind="stable") + ranks = np.empty(values.size, dtype=np.float64) + ranks[order] = np.arange(values.size, dtype=np.float64) + # Average ties so a gain that maps equal values to equal values scores 1. + for v in np.unique(values): + idx = values == v + ranks[idx] = float(np.mean(ranks[idx])) + return ranks + + +def _consensus_ratio(ratios): + """Largest-consensus estimate of a shared ratio (SI-020 gain estimator). + + Returns the median of the biggest cluster of ``ratios`` that mutually agree + within ``GAIN_CONSENSUS_REL_TOL`` (relative), or ``None`` if fewer than + ``MIN_GAIN_OBS`` values are given. Deterministic: candidate clusters are + anchored at each ratio in sorted order; ties on size break by tighter spread, + then by smaller median. Chosen over a plain median because the junk fraction + (overprint colours leaking into the ink set under a colour cast, then + misassigned) can reach ~1/2, where a median is already dragged off; the true + cast forms a tight cluster the junk cannot imitate. + """ + if len(ratios) < MIN_GAIN_OBS: + return None + ordered = sorted(ratios) + best = None # (count, -spread, median) maximised + for anchor in ordered: + members = [r for r in ordered + if abs(r - anchor) <= GAIN_CONSENSUS_REL_TOL * anchor] + if len(members) < MIN_GAIN_OBS: + continue + spread = members[-1] - members[0] + med = float(np.median(members)) + key = (len(members), -spread, -med) + if best is None or key > best[0]: + best = (key, med) + return best[1] if best is not None else None + + +def _clip_aware_delta_e(corrected_bgr, clipped_high, expected_bgr, expected_lab): + """Delta-E76 of a gain-corrected ink vs a sheet ink, one-sided on clipped channels. + + A channel measured at >= CLIP_HIGH was saturated by the cast, so after + correction it is only a LOWER BOUND on the true ink channel (true post-gain + value >= 255, hence true ink >= 255 * correction_gain). A clipped channel + therefore cannot *match* -- that is measurement loss, not identity mismatch + (SI-020) -- so its contribution is capped one-sidedly: if the sheet channel is + at or above the bound, the observation is CONSISTENT with the sheet ink and + the channel is scored as a match (substituted by the sheet value); if the + sheet channel is BELOW the bound, even the most charitable reading exceeds it + and the (real) mismatch is kept. Unclipped channels score normally. + """ + if not any(clipped_high): + return _delta_e76(_bgr_to_lab(corrected_bgr), expected_lab) + adjusted = np.array(corrected_bgr, dtype=np.float64) + for ch in range(3): + if clipped_high[ch] and expected_bgr[ch] >= adjusted[ch]: + adjusted[ch] = expected_bgr[ch] + return _delta_e76(_bgr_to_lab(adjusted), expected_lab) + + +def _score_ink_grid(feature: dict, measurement) -> dict: + """Two-path ink-set match for GRID sheets: absolute vs relationship (SI-020). + + The milestone requirement: ink-set matching with colour measured as + relationships/orderings, not absolutes, so it survives a white-balance shift + (audit s6). Two paths, and the feature agreement is their MAX: + + (a) ABSOLUTE. Greedy-assign measured inks to sheet inks in Lab; per-ink + agreement is the clipped-linear curve on delta-E76 / delta_e; agreement + is the mean over measured inks. This is the print-fragile path -- a + global colour cast pushes every delta-E up and it degrades. + + (b) RELATIONSHIP. Estimate a single per-channel diagonal correction gain + ``g`` (sRGB BGR, "map measured onto sheet") as the largest CONSENSUS + cluster of per-channel sheet/measured ratios over the assignment + (``_consensus_ratio``), SATURATION-AWARE: channel observations measured + at >= CLIP_HIGH (clipped by the cast; true value unknowable) or + <= CLIP_LOW (floored/unstable) are excluded, and a channel with fewer + than MIN_GAIN_OBS unclipped observations falls back neutrally to 1.0 + with a caveat. Apply ``g`` to the full extracted palette, re-separate + products in the corrected space, re-assign and re-measure delta-E76 -- + scoring the clipped channels of clipped inks ONE-SIDEDLY + (``_clip_aware_delta_e``: a clipped channel is a lower bound, so + consistency with the sheet ink counts as a match; measurement loss is + not identity mismatch). If a single global gain within + [GAIN_MIN, GAIN_MAX] explains the shift, the ink RELATIONSHIPS + (ratios/orderings) hold, so this path stays high where (a) fell. The + IMPLIED APPLIED gain (1/g) is reported -- it recovers the white balance + that was applied. + + A per-channel rank correlation corroborates the ordering survives (a positive + gain is rank-preserving). The working carries both paths, the gain, the + implied applied gain, the in-bounds flag, the rank correlation and the + clipped-ink handling. + """ + expected_hexes = feature.get("expected") or [] + tol = feature.get("tolerance") or {} + delta_e_tol = float(tol.get("delta_e", 10.0)) if isinstance(tol, dict) else 10.0 + expected_labs = [_hex_to_lab(h) for h in expected_hexes] + expected_bgr = [_hex_to_bgr255(h) for h in expected_hexes] + + observed = measurement.value or [] + measured_bgr = [np.array([float(c) for c in bgr], dtype=np.float64) for bgr in observed] + measured_labs = [_bgr_to_lab(bgr) for bgr in measured_bgr] + if not measured_bgr or not expected_labs: + return {"agreement": 0.0, "expected": expected_hexes, "measured": [], + "detail": {"reason": "no inks to match"}} + + # (a) absolute path. + assign_abs = _greedy_assign(measured_labs, expected_labs) + abs_deltas = [d for _, _, d in assign_abs] + per_ink_abs = [agreement_linear(d, 0.0, delta_e_tol) for d in abs_deltas] + agreement_abs = float(np.mean(per_ink_abs)) + + # (b) relationship path: a single per-channel diagonal correction gain that + # maps measured -> sheet, estimated as the largest CONSENSUS cluster of + # per-channel sheet/measured ratios over the absolute assignment + # (_consensus_ratio). Consensus (not least squares, not a plain median) + # because a global colour cast breaks the multiply overprint arithmetic, so + # overprint colours leak into the extracted ink set under white balance and + # get misassigned -- up to ~half the ratios can be junk, which drags a median + # but cannot imitate the tight cluster the true inks form. The leaked colours + # are re-separated in corrected space below; any that remain score ~0 in the + # agreement mean (they are not sheet inks), so the path is not flattered. + # Saturation-aware (SI-020): a channel observation at >= CLIP_HIGH is clipped + # (true pre-gain value unknowable) and one at <= CLIP_LOW is unstable/floored, + # so neither feeds the ratio; a channel with fewer than MIN_GAIN_OBS unclipped + # observations falls back neutrally to gain 1.0 with a caveat. + m_stack = np.array([measured_bgr[mi] for mi, _, _ in assign_abs]) # (k,3) + s_stack = np.array([expected_bgr[ei] for _, ei, _ in assign_abs]) # (k,3) + gain = np.ones(3, dtype=np.float64) + gain_fallback_channels = [] + for ch in range(3): + ratios = [s_stack[i, ch] / m_stack[i, ch] + for i in range(len(m_stack)) + if CLIP_LOW < m_stack[i, ch] < CLIP_HIGH] + estimate = _consensus_ratio(ratios) + if estimate is not None: + gain[ch] = estimate + else: + gain_fallback_channels.append("BGR"[ch]) + in_bounds = bool(np.all((gain >= GAIN_MIN) & (gain <= GAIN_MAX))) + enough_inks = len(measured_bgr) >= MIN_INKS_FOR_GAIN + relationship_ok = in_bounds and enough_inks + + # Undo the gain on the FULL extracted palette (inks + products) and re-separate + # products in the corrected space -- there the multiply overprint arithmetic + # holds again, so the couple of overprint colours that leaked into the ink set + # under white balance are correctly removed before the corrected ink set is + # matched (SI-020). Falls back to the ink set alone if the full palette was not + # carried in the measurement. Each corrected colour carries a clip mask + # (which channels were measured saturated), so the re-match can score clipped + # channels one-sidedly (see _clip_aware_delta_e). + from recogniser import measure_grid as _mg + extracted = getattr(measurement, "detail", {}).get("extracted") + if extracted: + raw_palette = [np.array(bgr, dtype=np.float64) for bgr, _ in extracted] + corrected_palette = [(tuple(np.clip(r * gain, 0.0, 255.0)), f) + for r, (_, f) in zip(raw_palette, extracted)] + # Clip mask per corrected colour. The gain map is injective on unclipped + # values, so keying by the corrected tuple is safe; on the rare collision + # (two clipped raws collapsing) the masks are OR-ed. + clip_by_tuple = {} + for r, (ct, _) in zip(raw_palette, corrected_palette): + mask = tuple(bool(v >= CLIP_HIGH) for v in r) + prev = clip_by_tuple.get(ct) + clip_by_tuple[ct] = mask if prev is None else \ + tuple(a or b for a, b in zip(prev, mask)) + corrected_set, _, _ = _mg.separate_products(corrected_palette) + corrected_ink_bgr = [np.array(bgr, dtype=np.float64) for bgr, _ in corrected_set] + clip_masks = [clip_by_tuple[bgr] for bgr, _ in corrected_set] + else: + corrected_ink_bgr = [np.clip(bgr * gain, 0.0, 255.0) for bgr in measured_bgr] + clip_masks = [tuple(bool(v >= CLIP_HIGH) for v in bgr) for bgr in measured_bgr] + + dist_rel = [[_clip_aware_delta_e(c, mask, expected_bgr[ei], expected_labs[ei]) + for ei in range(len(expected_labs))] + for c, mask in zip(corrected_ink_bgr, clip_masks)] + assign_rel = _greedy_assign_matrix(dist_rel) + rel_deltas = [d for _, _, d in assign_rel] + per_ink_rel = [agreement_linear(d, 0.0, delta_e_tol) for d in rel_deltas] + agreement_rel = float(np.mean(per_ink_rel)) if relationship_ok else 0.0 + n_clipped_inks = sum(1 for mask in clip_masks if any(mask)) + + # Rank-order corroboration (per channel, over the absolute assignment). + rank_corrs = [] + for ch in range(3): + rank_corrs.append(_rank_corr([m_stack[i, ch] for i in range(len(m_stack))], + [s_stack[i, ch] for i in range(len(s_stack))])) + rank_corr = float(np.mean(rank_corrs)) + + implied_applied_gain = [round(float(1.0 / g), 4) if g != 0 else None for g in gain] + agreement = max(agreement_abs, agreement_rel) + + # Per-ink detail: the absolute assignment (measured ink -> nearest sheet ink + # and its delta-E). The relationship path re-separates in corrected space so + # its ink set differs; it is summarised in aggregate below, not paired here. + per_ink = [{ + "measured_bgr": [round(float(measured_bgr[mi][c]), 1) for c in range(3)], + "matched_ink": expected_hexes[ei], + "delta_e_absolute": round(dabs, 3), + } for mi, ei, dabs in assign_abs] + return { + "agreement": agreement, + "expected": expected_hexes, + "measured": [p["measured_bgr"] for p in per_ink], + "detail": { + "delta_e_tolerance": delta_e_tol, + "path": "max(absolute, relationship) (SI-020)", + "agreement_absolute": round(agreement_abs, 4), + "agreement_relationship": round(agreement_rel, 4), + "mean_delta_e_absolute": round(float(np.mean(abs_deltas)), 3), + "mean_delta_e_relationship": round(float(np.mean(rel_deltas)), 3), + "correction_gain_bgr": [round(float(g), 4) for g in gain], + "implied_applied_gain_bgr": implied_applied_gain, + "gain_in_bounds": in_bounds, + "relationship_applicable": relationship_ok, + "n_inks": len(measured_bgr), + # Saturation handling (SI-020): channels measured >= CLIP_HIGH are + # excluded from the gain estimate and scored one-sidedly after + # correction (consistent-with-sheet counts as match; a bound already + # past the sheet value keeps its real mismatch). + "clipping": { + "clip_high": CLIP_HIGH, + "clip_low": CLIP_LOW, + "n_clipped_inks": n_clipped_inks, + "gain_fallback_channels": gain_fallback_channels, + "note": "clipped channels excluded from gain estimate; " + "one-sided (lower-bound) scoring after correction; " + "fallback channels use neutral gain 1.0", + }, + "rank_correlation_bgr": [round(c, 4) for c in rank_corrs], + "rank_correlation": round(rank_corr, 4), + "per_ink": per_ink, + }, + } + + def _resolve_relation(feature: dict, measurements: dict): """Return (measured_diff, expected, tolerance, n, detail) for a relation_* feature. @@ -209,6 +562,7 @@ def score_sheet(sheet: dict, measured: dict) -> dict: """ measurements = measured["measurements"] features = sheet.get("signature_locus", {}).get("features", []) + structure_type = sheet.get("structure", {}).get("type") per_feature = [] observed_weight = 0.0 # id-feature weight we actually scored @@ -268,11 +622,28 @@ def score_sheet(sheet: dict, measured: dict) -> dict: entry["note"] = "no inks observed" per_feature.append(entry) continue - ink = _score_ink(feature, m) + # Grid sheets use the two-path (absolute + white-balance-relationship) + # match (SI-020); band sheets keep the byte-identical v0 absolute match. + ink = (_score_ink_grid(feature, m) if structure_type == "grid" + else _score_ink(feature, m)) agree = ink["agreement"] n = m.n entry["measured"] = ink["measured"] entry["detail"] = ink["detail"] + elif measure_name == "overprint_multiply_consistency": + # Verification (audit s5): agreement falls with the worst product + # residual; a large residual (broken multiply arithmetic) flags the + # claim. No overlaps observed -> unobserved, NOT a failure. + m = measurements.get("overprint_multiply_consistency") + if m is None or m.n <= 0 or m.value is None: + entry["note"] = "no two-ink overlaps observed (overprint unverified)" + per_feature.append(entry) + continue + residual = float(m.value) + agree = agreement_linear(residual, 0.0, OVERPRINT_RESIDUAL_TOL) + n = m.n + entry["measured"] = {"max_product_residual": round(residual, 3)} + entry["detail"] = getattr(m, "detail", {}) elif measure_name in measurements: m = measurements[measure_name] if m.n <= 0 or m.value is None: diff --git a/tests/test_recogniser.py b/tests/test_recogniser.py index 180fb8d..7c7919e 100644 --- a/tests/test_recogniser.py +++ b/tests/test_recogniser.py @@ -275,22 +275,34 @@ def test_iso002_does_not_crash_and_scores_below_001(surface0): assert r002["verdict"] != "identified" -def test_iso002_unknown_measures_reported_unobserved_gracefully(surface0): - """Measures 002 names but recogniser v0 has no measurer for - (grid_module_detect, stripe_duty, staircase_step_angle, - overprint_multiply_consistency) are reported unobserved with a note -- never - a crash. primitive_frequency_mix is skipped as sheet-unmeasured (SI-008).""" +def test_iso002_grid_family_measures_gracefully_on_band_image(surface0): + """Phase 6 changed this: the grid measurer family now EXISTS, so a 001 band + image is also measured by it (claim.STRUCTURE_MEASURERS) and iso-002 is scored + against real grid measurements -- honestly, and still well below 001. + + The pre-Phase-6 version of this test asserted 'no measurer for grid_module'; + that documented the absence this phase fills. The invariant that MUST hold is + honest cross-discrimination: iso-002's nine-ink address is not met by a band + surface's two greens, and primitive_frequency_mix stays reserved (SI-008).""" r002 = _result(recognise(surface0, str(GRAMMARS)), "iso-002") - grid = _feature(r002, "grid_module") - assert grid["observed"] is False - assert "no measurer" in grid.get("note", "") + # The grid family ran end to end: the grid module (weight-0 normalisation) and + # the ink-set extractor both produced real measurements -- never a crash. + assert _feature(r002, "grid_module")["observed"] is True + assert _feature(r002, "ink_set")["observed"] is True + # ...but two greens are not the nine ISO inks: colour agreement stays low and + # the relationship path is disabled (< 3 inks, SI-020), so no leniency leaks. + assert _feature(r002, "ink_set")["agreement"] < 0.34 + + # primitive_frequency_mix is reserved + skipped regardless (SI-008). prim = _feature(r002, "primitive_frequency_mix") assert prim["observed"] is False assert "SI-008" in prim.get("note", "") - # Its one real measurer (ink_set) did run: colour is scored, structure is not. - assert _feature(r002, "ink_set")["observed"] is True + # No two-ink overprints on a two-green band surface: overprint unobserved, + # which is NOT a verification failure. + assert _feature(r002, "overprint_consistency")["observed"] is False + assert "iso-002" not in [r002["sheet_id"]] or r002["verdict"] != "identified" # ========================================================================= diff --git a/tests/test_recogniser_grid.py b/tests/test_recogniser_grid.py new file mode 100644 index 0000000..709f9a5 --- /dev/null +++ b/tests/test_recogniser_grid.py @@ -0,0 +1,367 @@ +"""Tests for the GRID recogniser family (Phase 6; grammar iso-002 / audit 002). + +Project rule (README, tests row): every measurer is validated against synthetic +ground truth from the generator BY MEASUREMENT before the pipeline trusts it. So +this file measures the audit-002 quantities back out of ``generator.grid`` renders +where the ground truth (module, ink subset, exact multiply overprints) is known: + + 1. Grid module via edge-projection autocorrelation -- on clean renders and on + crops that do NOT start on a module boundary. + 2. Flat-ink extraction + parent/product separation -- the recovered ink set is + the ground-truth subset (within small delta-E) and no multiply product leaks + into it. + 3. Overprint consistency -- residual ~0 on a clean render; a drifted overprint + (broken multiply arithmetic, audit s5) raises the claim's verification flag. + 4. WHITE-BALANCE ROBUSTNESS (the point of the phase, audit s6 / SI-020): under a + per-channel gain the ABSOLUTE colour match collapses but the RELATIONSHIP + path recovers -- agreement stays high, and the estimated (implied applied) + gain matches the gain that was applied. + 5. Full pipeline -- a 002 render is top and candidate-or-better against iso-002, + bar-cascade-001 scores below it, and the reverse direction still holds. + 6. Determinism -- same image -> byte-identical claim JSON. +""" + +import copy +import json +from pathlib import Path + +import numpy as np +import pytest + +from sheets import load_sheet +from generator import grid +from battery import degrade +from recogniser import measure_grid as mg +from recogniser import score +from recogniser.claim import recognise + +REPO_ROOT = Path(__file__).resolve().parent.parent +GRAMMARS = REPO_ROOT / "grammars" +ISO_SHEET = GRAMMARS / "iso-002.yaml" + +COLS, ROWS, MODULE = 10, 7, 64 +# A subset with genuine two-ink overlaps and no internal red coincidence. +SUBSET = ["#FAFF54", "#479F8C", "#D52EB2", "#75FB63", "#EC5F2A"] +# A subset whose inks do not clip a channel under moderate white balance, so the +# relationship path can fully recover (audit s6 print-fragility demonstration). +WB_SUBSET = ["#479F8C", "#D52EB2", "#75FB63", "#C5111D", "#96CBC4"] + + +@pytest.fixture(scope="module") +def sheet(): + return load_sheet(ISO_SHEET) + + +@pytest.fixture(scope="module") +def render(sheet): + return grid.render_with_truth(sheet, cols=COLS, rows=ROWS, module_px=MODULE, + seed=2, ink_subset=SUBSET, density=0.55) + + +def _feature(result, fid): + for f in result["per_feature"]: + if f["id"] == fid: + return f + raise KeyError(fid) + + +def _result(claim, sheet_id): + for r in claim["results"]: + if r["sheet_id"] == sheet_id: + return r + raise KeyError(sheet_id) + + +def _nearest_delta_e(bgr, hexes): + lab = score._bgr_to_lab(np.array(bgr, dtype=np.float64)) + return min(score._delta_e76(lab, score._hex_to_lab(h)) for h in hexes) + + +# ========================================================================= +# 1. Grid module (audit s2 edge-projection autocorrelation) +# ========================================================================= + + +def test_grid_module_recovered_on_clean_render(render): + surface, _ = render + module, n_cells, _ = mg.detect_grid_module(surface) + assert abs(module - MODULE) <= 2 + assert n_cells > 0 + + +@pytest.mark.parametrize("dy,dx", [(29, 41), (13, 7), (50, 33)]) +def test_grid_module_robust_to_non_aligned_crop(render, dy, dx): + """Autocorrelation is shift-invariant, so a crop that does not start on a + module boundary still recovers M (audit s2: robust to alignment).""" + surface, _ = render + crop = surface[dy:, dx:] + module, _, _ = mg.detect_grid_module(crop) + assert abs(module - MODULE) <= 2 + + +# ========================================================================= +# 2. Flat-ink extraction and parent/product separation +# ========================================================================= + + +def test_ink_set_recovers_ground_truth_subset(render): + """The recovered ink set equals the ground-truth subset within small delta-E, + and holds no more colours than were actually inked.""" + surface, gt = render + inks, _ = mg.extract_flat_inks(surface) + ink_set, products, _ = mg.separate_products(inks) + assert len(ink_set) == len(gt.ink_subset) + for bgr, _frac in ink_set: + assert _nearest_delta_e(bgr, gt.ink_subset) < 5.0 + + +def test_products_are_separated_from_parents(render): + """No multiply product colour leaks into the ink set: every recorded overlap + product is absent from the ink set (audit s2/s5 -- products are evidence of + layering, not members of the ink set).""" + surface, gt = render + inks, _ = mg.extract_flat_inks(surface) + ink_set, products, _ = mg.separate_products(inks) + assert len(products) > 0 + ink_colours = {bgr for bgr, _ in ink_set} + # Ground-truth two-ink (distinct) product colours must not appear as inks. + for o in gt.overlaps: + if o.inks[0] == o.inks[1]: + continue + assert tuple(int(v) for v in o.product) not in ink_colours + + +def test_extraction_deterministic(render): + surface, _ = render + a = mg.separate_products(mg.extract_flat_inks(surface)[0])[0] + b = mg.separate_products(mg.extract_flat_inks(surface)[0])[0] + assert a == b + + +# ========================================================================= +# 3. Overprint consistency (verification; audit s5) +# ========================================================================= + + +def test_overprint_residual_zero_on_clean_render(render): + surface, _ = render + m = mg.measure_grid_surface(surface) + op = m["measurements"]["overprint_multiply_consistency"] + assert op.n > 0 # overlaps observed + assert op.value == 0.0 # exact multiply everywhere (audit s2) + + +def _distinct_hue(a, b): + return mg._hue_dist(mg._hue(grid._hex_to_bgr(a)), + mg._hue(grid._hex_to_bgr(b))) >= mg.MIN_PARENT_HUE + + +def _corrupt_one_overprint(surface, gt, delta=22): + """Return a copy with one distinct-ink overlap's arithmetic drifted darker. + + Models an unfaithful reproduction (audit s5 "c1*c2 != c3"): the overlap stays + darker than both parents (still a plausible overprint region) but no longer + equals the exact multiply, so the residual jumps well past the tolerance.""" + corrupt = surface.copy().astype(np.int64) + idx = {h: i for i, h in enumerate(gt.ink_subset)} + two = gt.depth == 2 + lo = np.minimum(gt.label_first, gt.label_second) + hi = np.maximum(gt.label_first, gt.label_second) + for o in gt.overlaps: + a, b = o.inks + if a == b or not _distinct_hue(a, b): + continue + sel = two & (lo == min(idx[a], idx[b])) & (hi == max(idx[a], idx[b])) + if not sel.any(): + continue + corrupt[sel] = np.clip(np.array(o.product) - delta, 0, 255) + return corrupt.astype(np.uint8), o + pytest.skip("no distinct-hue overlap to corrupt in this render") + + +def test_broken_overprint_flags_verification(render, sheet): + """A drifted overprint gives a large residual, and the claim's verification + flags fire for iso-002 (audit s5). A clean render does NOT flag.""" + surface, gt = render + clean = _result(recognise(surface, str(GRAMMARS)), "iso-002") + assert "overprint_consistency" not in clean["verification_failures"] + + corrupt, _o = _corrupt_one_overprint(surface, gt) + m = mg.measure_grid_surface(corrupt) + op = m["measurements"]["overprint_multiply_consistency"] + assert op.value > score.OVERPRINT_RESIDUAL_TOL # residual is large + + r = _result(recognise(corrupt, str(GRAMMARS)), "iso-002") + assert "overprint_consistency" in r["verification_failures"] + assert _feature(r, "overprint_consistency")["agreement"] == 0.0 + + +# ========================================================================= +# 4. WHITE BALANCE -- the point of the phase (audit s6, SI-020) +# ========================================================================= + + +@pytest.mark.parametrize("gains", [(1.15, 1.0, 0.85), (0.85, 1.0, 1.15)]) +def test_white_balance_relationship_path_recovers(sheet, gains): + """Under a per-channel white-balance gain the ABSOLUTE colour match collapses + but the RELATIONSHIP path recovers: agreement stays high (>= 0.7), the + absolute-only match would fail, and the estimated (implied applied) gain + matches the gain that was applied. THIS is the milestone requirement.""" + surface = grid.render(sheet, cols=COLS, rows=ROWS, module_px=MODULE, + seed=1, ink_subset=WB_SUBSET, density=0.5) + shifted = degrade.white_balance(surface, gains) + + r = _result(recognise(shifted, str(GRAMMARS)), "iso-002") + ink = _feature(r, "ink_set") + d = ink["detail"] + + # Absolute path is print-fragile: a global cast pushes every delta-E out. + assert d["agreement_absolute"] < 0.2 + # Relationship path recovers the ink relationships under the cast. + assert d["agreement_relationship"] >= 0.7 + assert ink["agreement"] >= 0.7 # feature = max(abs, rel) + # The estimated gain recovers the applied white balance (BGR order). + implied = d["implied_applied_gain_bgr"] + for got, applied in zip(implied, gains): + assert abs(got - applied) < 0.1 + assert d["gain_in_bounds"] is True + # Ordering (rank) relationships survive a positive gain (cheap corroboration; + # a couple of overprint colours leak into the raw ink set under the cast, so + # this is a soft positive-correlation check, not the load-bearing signal). + assert d["rank_correlation"] > 0.4 + # And iso-002 is still recognised as at least a candidate under the cast. + assert r["aggregate_confidence"] >= score.CANDIDATE_THRESHOLD + + +def test_white_balance_with_channel_clipping_recovers(sheet): + """Director smoke case (regression): seed 3, cols 10, rows 7, module 64 -- + the seeded subset holds bright-channel inks (#FAFF54, #75FB63, #95C3F9 ...) + whose saturated channels the warm (1.15, 1.0, 0.85) cast clips. Clipped + observations must not bias the gain estimate (saturation-aware consensus, + SI-020) and clipped channels are scored one-sidedly after correction + (measurement loss, not identity mismatch). Before that rule this exact case + scored 0.372 / not_recognised.""" + surface = grid.render(sheet, cols=COLS, rows=ROWS, module_px=MODULE, seed=3) + shifted = degrade.white_balance(surface, (1.15, 1.0, 0.85)) + + claim = recognise(shifted, str(GRAMMARS)) + assert claim["results"][0]["sheet_id"] == "iso-002" # still the top sheet + + r = _result(claim, "iso-002") + # Comfortably above the candidate line (requirement is >= 0.40). + assert r["aggregate_confidence"] >= 0.55 + assert r["verdict"] in ("candidate", "identified") + + ink = _feature(r, "ink_set") + d = ink["detail"] + assert d["agreement_absolute"] < 0.2 # absolute path still collapses + assert d["agreement_relationship"] >= 0.6 # relationship path recovers + # The estimated gain recovers the applied cast despite the clipped channels. + for got, applied in zip(d["implied_applied_gain_bgr"], (1.15, 1.0, 0.85)): + assert abs(got - applied) < 0.05 + # Clipped-ink handling is reported in the working. + clip = d["clipping"] + assert clip["n_clipped_inks"] > 0 + assert clip["gain_fallback_channels"] == [] # enough unclipped observations + + +def test_absolute_only_match_would_fail_under_white_balance(sheet): + """Corroborates the above: with the relationship path removed, the same shift + drops the colour agreement below the candidate line -- showing the recovery is + the relationship path's doing, not luck.""" + surface = grid.render(sheet, cols=COLS, rows=ROWS, module_px=MODULE, + seed=1, ink_subset=WB_SUBSET, density=0.5) + shifted = degrade.white_balance(surface, (1.15, 1.0, 0.85)) + r = _result(recognise(shifted, str(GRAMMARS)), "iso-002") + assert _feature(r, "ink_set")["detail"]["agreement_absolute"] < 0.2 + + +def test_relationship_path_disabled_below_min_inks(sheet): + """A global-gain claim from too few inks is not credible (SI-020): with fewer + than MIN_INKS_FOR_GAIN measured inks the relationship path is disabled, so a + tiny palette cannot borrow the grid ink-set's leniency.""" + two_ink = grid.render(sheet, cols=COLS, rows=ROWS, module_px=MODULE, seed=4, + ink_subset=["#D52EB2", "#75FB63"], density=0.25) + shifted = degrade.white_balance(two_ink, (1.15, 1.0, 0.85)) + r = _result(recognise(shifted, str(GRAMMARS)), "iso-002") + d = _feature(r, "ink_set")["detail"] + if d["n_inks"] < score.MIN_INKS_FOR_GAIN: + assert d["relationship_applicable"] is False + assert d["agreement_relationship"] == 0.0 + + +# ========================================================================= +# 5. Full pipeline and cross-direction discrimination +# ========================================================================= + + +def test_grid_render_top_is_iso002(render): + """A clean 002 render is the top verdict against iso-002 (candidate-or-better), + with coverage reflecting the unmeasured primitive mix (SI-008): only the ink + set carries measurable identification weight, so coverage is 1.0 and the + reserved mix weight is renormalised away, not counted.""" + surface, _ = render + claim = recognise(surface, str(GRAMMARS)) + top = claim["results"][0] + assert top["sheet_id"] == "iso-002" + assert top["aggregate_confidence"] >= score.CANDIDATE_THRESHOLD + assert top["verdict"] in ("candidate", "identified") + # SI-008: the primitive mix is reserved + skipped, so it is not in coverage. + prim = _feature(top, "primitive_frequency_mix") + assert prim["observed"] is False and "SI-008" in prim.get("note", "") + assert top["coverage"] == pytest.approx(1.0, abs=1e-6) + + +def test_bar_cascade_001_scores_below_iso002_on_grid_render(render): + """Cross-discrimination one way: on a 002 grid render, bar-cascade-001 scores + below iso-002 and is not identified (Phase 7 depends on honest degradation).""" + surface, _ = render + claim = recognise(surface, str(GRAMMARS)) + r001 = _result(claim, "bar-cascade-001") + r002 = _result(claim, "iso-002") + assert r001["aggregate_confidence"] < r002["aggregate_confidence"] + assert r001["verdict"] != "identified" + + +def test_cross_direction_band_render_still_top_001(): + """Cross-discrimination the other way (existing behaviour preserved with the + real grid measurers now present): a 001 band render is still top as + bar-cascade-001 and iso-002 stays well below it.""" + from generator import cascade + band_sheet = load_sheet(GRAMMARS / "bar-cascade-001.yaml") + surf = cascade.render(band_sheet, n_bands=5, module_px=200, seed=0, + orientation_deg=0.0) + claim = recognise(surf, str(GRAMMARS)) + top = claim["results"][0] + assert top["sheet_id"] == "bar-cascade-001" + assert top["verdict"] == "identified" + r002 = _result(claim, "iso-002") + assert r002["aggregate_confidence"] < top["aggregate_confidence"] + assert r002["verdict"] != "identified" + + +# ========================================================================= +# 6. Determinism +# ========================================================================= + + +def test_determinism_claim_byte_identical(render): + surface, _ = render + a = json.dumps(recognise(surface, str(GRAMMARS)), sort_keys=True) + b = json.dumps(recognise(surface, str(GRAMMARS)), sort_keys=True) + assert a == b + + +def test_measure_grid_surface_shape(): + """Every grid measurer returns a Measurement with the v0 shape (name, value, + n, detail).""" + sheet = load_sheet(ISO_SHEET) + surface = grid.render(sheet, cols=COLS, rows=ROWS, module_px=MODULE, seed=0) + m = mg.measure_grid_surface(surface) + for name in ("grid_module_detect", "ink_set_match", + "overprint_multiply_consistency", "stripe_duty", + "primitive_frequency_mix", "staircase_step_angle"): + meas = m["measurements"][name] + assert meas.feature_measure_name == name + assert isinstance(meas.n, int) + assert isinstance(meas.detail, dict)