From 38fca2af08a6a8c9320969eeff31f25695c037f1 Mon Sep 17 00:00:00 2001 From: wilfordgrimley <2397930+WilfordGrimley@users.noreply.github.com> Date: Thu, 6 Aug 2026 02:43:28 +0000 Subject: [PATCH] retune(art-edge): within-image relative colour comparison; does not clear the validation bar, not persisted classify_art_edge_continuity retuned from an absolute per-band pixel-variance test (tuned for classify_border_color's different question) to a within-image comparison: does the band beside the art crop match the border colour ImageEvidence.layout_class already found for this same image, by Euclidean RGB distance. Fixes the specific defect this was scoped to fix (dark, textured, off-hue artwork no longer gets read as a border merely for being dark and flat - confirmed by a constructed reproduction and pinned by a new test that fails against the pre-retune behaviour), and drops the now-redundant edge-band uniformity re-test. Validated against Scryfall's own images (label and pixels from the same source, unlike the catalogue-cohort measurement this supersedes, which name-matched uploads to printings and never confirmed the pixels themselves) across three cohorts: 30 confirmed extended-art, 20 confirmed borderless, 20 confirmed ordinary framed. Result: 0/30 recall on genuine extended-art images (worse than the pre-retune classifier's 1/30 on the same images), 0 false positives on both negative cohorts. Does not clear the bar stated before measuring. Root cause traced and documented: classify_border_color's own catch-all misreads 20/30 extended-art images as borderless, and for the remaining 10 the trimmed-image edge-band geometry shrinks to ~2px on a Scryfall image, too thin for a reliable colour reference - both upstream of this module's own scope. No ImageEvidence field, migration, Stage C wiring, or EXTRACTOR_OWNERSHIP entry added, per the brief's own stated fallback for a classifier that does not clear its validation bar. Full report: docs/reports/2026-08-06-art-edge-relative-comparison.md --- MPCAutofill/cardpicker/local_art_edge.py | 211 ++++++++----- .../cardpicker/tests/test_local_art_edge.py | 291 ++++++++---------- docs/identification-pipeline.md | 12 +- docs/pipeline-fidelity-gate.md | 49 +-- ...2026-08-06-art-edge-relative-comparison.md | 232 ++++++++++++++ 5 files changed, 527 insertions(+), 268 deletions(-) create mode 100644 docs/reports/2026-08-06-art-edge-relative-comparison.md diff --git a/MPCAutofill/cardpicker/local_art_edge.py b/MPCAutofill/cardpicker/local_art_edge.py index dd937481e..ee2bfded2 100644 --- a/MPCAutofill/cardpicker/local_art_edge.py +++ b/MPCAutofill/cardpicker/local_art_edge.py @@ -4,6 +4,51 @@ Owner-directed design (2026-07-28): "we can measure by aiming our border pixel color measurement at two locations, one of which is adjacent to the art crop location." +RETUNED 2026-08-06 (docs/reports/2026-08-06-art-edge-relative-comparison.md has the full +before/after numbers). The original method compared each band's pixel variance against +`local_fallback._BORDER_UNIFORMITY_STD_THRESHOLD`, an ABSOLUTE constant tuned for +`classify_border_color`'s different question ("is this whole band a painted border at all"). +Measured against 467 real catalogue images: `extended` fired on 7 of them, and every one of +those 7 edge-band reads was DARK (RGB like `(14,13,26)`) with a correspondingly compressed pixel +value range, and therefore a low absolute std - EXACTLY as low as a genuine painted border reads, +even though nothing painted was there. A fixed variance cut cannot tell "this band is flat because +it is a border" apart from "this band is flat because it is dark, and dark content has a narrower +value range than bright content by construction" - it was measuring darkness and reporting it as +a border. + +THE FIX IS RELATIVE, NOT A RETUNED CONSTANT. Instead of asking "is this band uniform" in +isolation, this module now asks "does this band's COLOUR match the border colour THIS card is +already known to have" - a comparison entirely within one image, between two samples of that +same image. `ImageEvidence.layout_class` (`classify_border_color`'s own output - black / white / +silver / borderless) already tells us which case applies: + + * `layout_class == "borderless"` - there is no border for anything to match, and a card with no + border cannot be extended-art by definition. `open`, decided from the stored classification + alone, no sampling required. + * otherwise - `layout_class` names a real border colour this exact image was already measured + to have. Sample the art-adjacent strip's own colour and compare it against that same border, + resampled from the same image, with a colour distance: + - close to the border colour -> the frame survives beside the art -> `framed`. + - far from the border colour -> something other than the border sits beside the art, and + `layout_class` already established the outer edge itself reads as a real border -> + `extended`. + +This is why the second sample point is still taken (the module is not reduced to reading +`layout_class` alone): `layout_class` tells us WHAT colour a border would be, not whether the +strip immediately beside THIS card's own art crop is that colour or something else. But the +EDGE BAND's own uniformity is no longer re-tested here - `layout_class` being anything other than +`None`/`"borderless"` already means `classify_border_color` found that band uniform enough to +call a colour on its own, real-fetched-image-validated threshold. Re-testing it a second time +here, on the same pixels, would just be paying for the same measurement twice. + +WHY A WITHIN-IMAGE COMPARISON SURVIVES WHAT AN ABSOLUTE ONE DOES NOT: overall image darkness, +exposure, scan quality and JPEG artefacts shift a sampled band's raw colour, but they shift the +border sample and the art-adjacent sample of the SAME image by roughly the same amount - a +washed-out scan reads both bands lighter, a underexposed one reads both bands darker. The +DIFFERENCE between the two samples is far more stable across those confounds than either sample's +absolute value is, which is exactly the discriminator a fixed threshold cannot use because it +only ever looks at one band at a time. + WHY THIS IS ITS OWN MODULE AND NOT A NEW VALUE IN `local_fallback.BORDER_COLOR_TO_TAG`. Measured read-only against production (2026-07-28): all **4,165** printings whose Scryfall `frame_effects` contains `extendedart` have `border_color == "black"` - 4,165 of 4,165, with @@ -17,52 +62,20 @@ (which frame ERA - old/modern/future). This one is pixel-derived and answers "how far does the artwork spread?". Different evidence, different question, different channel. -WHAT THE SECOND SAMPLE POINT BUYS - the discriminator one point cannot make: - - | LEFT/RIGHT EDGE band | LEFT/RIGHT ART-ADJACENT band - normal ("framed") | uniform (border) | uniform (frame sits between art and edge) - extended art | uniform (border) | NON-uniform (art runs out to the card's sides) - borderless/full | NON-uniform (art) | NON-uniform (art) - -The edge band alone cannot separate "framed" from "extended" (both uniform); the art-adjacent -band alone cannot separate "extended" from "borderless" (both non-uniform). Only the pair does, -which is exactly the owner's point. - -MEASUREMENT-INDEPENDENT ON PURPOSE. This module introduces no colour threshold and no new tuned -number. The only threshold it reads is the EXISTING `local_fallback._BORDER_UNIFORMITY_STD_ -THRESHOLD`; the only geometry it uses is derived from the EXISTING `_BORDER_SAMPLE_BANDS` plus -the stored `ImageEvidence.art_crop_px`. A colour-keyed signal (gold/yellow border) genuinely does -need fresh measurements of real cards before it can ship; a uniformity COMPARISON does not, which -is why this half could be built while that half waits on a measurement pass. - TWO TRACKS, AND WHICH ONE THIS SERVES. For OFFICIAL printings nothing here is needed: Scryfall's `frame_effects` already carries `extendedart` as an imported fact (4,165 printings), and an imported fact is not a disputable claim that wants a pixel vote. This module exists for the OTHER track - USER-UPLOADED PROXY IMAGES, where pixels are the only source and there is no printing to read the fact off. That is what the local-fallback channel is for. - -THE DEFECT IT TARGETS, measured against production ground truth (2026-07-28; 90,857 -`ImageEvidence` rows whose card has a confirmed printing, joined to that printing's Scryfall -`border_color`). Among cards Scryfall calls black-bordered, `classify_border_color`'s -"not uniform -> borderless" catch-all fires on: - - plain black 16.5% (11,844 of 71,840) - full_art 22.8% (191 of 836) - EXTENDED ART 54.9% (620 of 1,129) <- 3.3x the plain-black base rate - -Extended art is thus the single population that most reliably breaks the border classifier, and -it breaks it into a confident WRONG answer ("Borderless") rather than an abstention. This module -is the reading that catch-all has been standing in for. NOTE that fixing the catch-all itself is -out of scope here and tracked separately - see this module's report and the OPEN ITEMS on -`classify_border_color`'s silver/gold behaviour. """ +import math +import statistics from collections.abc import Sequence from typing import TYPE_CHECKING, Optional from cardpicker.local_fallback import ( _BORDER_SAMPLE_BANDS, - _BORDER_UNIFORMITY_STD_THRESHOLD, _sample_band, normalize_crop_box, ) @@ -94,32 +107,69 @@ # membership against rather than restating three string literals. ART_EDGE_CLASSES: tuple[str, ...] = (ART_EDGE_FRAMED, ART_EDGE_EXTENDED, ART_EDGE_OPEN) +# The `layout_class` values that name an actual border colour to compare against. `None` +# (classify_border_color's own "uniform but not a colour this taxonomy covers" abstention) is +# deliberately excluded - there's no colour to compare the art-adjacent strip to, so the honest +# reading is to abstain here too, not to invent a comparison against nothing. `"borderless"` is +# also excluded from THIS set, but handled first as its own short-circuit (see the function body) +# rather than falling into the "no comparison possible" bucket, because it isn't ambiguous - +# borderless positively rules out `extended`. +_ART_EDGE_BORDER_LAYOUT_CLASSES: frozenset[str] = frozenset({"black", "white", "silver"}) + +# COLOUR-DISTANCE MEASURE: plain Euclidean distance between the art-adjacent strip's sampled mean +# RGB and the border's sampled mean RGB (both the same `_sample_band` mean-RGB triple +# `classify_border_color` already computes - no new extraction, no colour-space conversion added). +# Euclidean RGB is not perceptually uniform (a fixed numeric distance is not a fixed PERCEIVED +# difference everywhere in the space), and it is weakest exactly where two colours are both +# desaturated and differ mainly in a way human vision weights unevenly - which is the one case +# this taxonomy has to worry about: a genuinely grey/neutral patch of artwork sitting beside a +# "silver" border. A perceptually-corrected metric (Lab-space, CIEDE2000) would fix that, at the +# cost of a colour-space conversion this codebase performs nowhere else and that this module has +# no independent evidence it needs - the black/white cases (the overwhelming majority of the +# catalogue's borders; silver is rare) sit at the extremes of brightness where Euclidean distance +# and perceptual distance already track each other closely. Validated against real Scryfall +# extended-art/borderless/framed images before being trusted (see this module's validation +# report); silver-bordered cards were not a dedicated cohort in that pass (Scryfall silver-bordered +# printings are a small, mostly funny-set population) - if a future measurement finds silver +# specifically needs a perceptual metric, that is this constant's own follow-up, not a reason to +# add unproven colour machinery speculatively now. +_ART_EDGE_COLOR_DISTANCE_THRESHOLD = 70.0 + def classify_art_edge_continuity( card_image: "Image.Image", art_crop_px: Optional[Sequence[int]], + layout_class: Optional[str], bleed_class: Optional[str] = None, ) -> Optional[str]: - """Returns 'framed' / 'extended' / 'open', or None when the reading is unusable or - self-contradictory. EVIDENCE-ONLY today - nothing votes on it yet (see - `cast_art_edge_continuity_vote`'s docstring for the gate that has to clear first). - - 'framed' - normal card: frame sits between the artwork and both side edges. - 'extended' - artwork reaches the card's left and right sides, but a border survives at the - very edge. Scryfall's `frame_effects` "extendedart". - 'open' - artwork reaches the edges themselves: borderless, or a full-art land. - None - no usable art_crop_px, a degenerate crop, or the contradictory reading - "edge is artwork but the strip further IN is flat", which no real card - produces and which therefore means the geometry assumption failed rather - than describing a card. Abstaining is the honest output; the alternative is - inventing a class for an impossible observation. - - COORDINATE FRAMES - the one genuinely easy thing to get wrong here, and the reason this - argument is `art_crop_px` (pixels) rather than a fractional box. The two band families - arrive in DIFFERENT frames and must therefore be treated ASYMMETRICALLY: - - * `_BORDER_SAMPLE_BANDS` are raw fractions tuned against a BLEED-INCLUSIVE image, so they - still need `normalize_crop_box(band, bleed_class)` applied here, exactly as + """Returns 'framed' / 'extended' / 'open', or None when the reading is unusable. EVIDENCE-ONLY + today - nothing votes on it yet (see `cast_art_edge_continuity_vote`'s docstring for the gate + that has to clear first). + + 'framed' - normal card: the art-adjacent strip's colour matches the border this image is + already known to have (`layout_class`). + 'extended' - artwork reaches the card's left and right sides (the art-adjacent strip does + NOT match the border colour), but `layout_class` says a border still reads at + the very edge. Scryfall's `frame_effects` "extendedart". + 'open' - `layout_class == "borderless"`: there is no border to compare against, and a + card with no border cannot be extended-art. + None - no usable `art_crop_px`/degenerate crop, or `layout_class` names no border to + compare against (`None` - `classify_border_color`'s own ambiguous reading). + Abstaining is the honest output; the alternative is inventing a comparison + against a colour nobody measured. + + `layout_class` is the caller's ALREADY-COMPUTED `classify_border_color(card_image, bleed_class)` + result for this same image - not recomputed here. Passing it in (rather than this function + calling `classify_border_color` itself) means the border-colour class and the art-edge class + are always read off the SAME underlying sample, and Stage C pays for that classification once, + not twice, per image. + + COORDINATE FRAMES - the one genuinely easy thing to get wrong here, and the reason + `art_crop_px` is pixels rather than a fractional box. The edge band and the art crop arrive in + DIFFERENT frames and must therefore be treated ASYMMETRICALLY: + + * `_BORDER_SAMPLE_BANDS` are raw fractions tuned against a BLEED-INCLUSIVE image, so the + edge band still needs `normalize_crop_box(band, bleed_class)` applied here, exactly as `classify_border_color` applies it. * `art_crop_px` does NOT. `ImageEvidence` stores it already remapped: `image_evidence. _crop_box_to_pixels` takes `local_phash.ART_CROP_BOX`, passes it through @@ -130,13 +180,18 @@ def classify_art_edge_continuity( trimmed-image correction a SECOND time and walk the band off the art entirely, on precisely the ~2.5% trimmed minority the remap exists to serve. - `bleed_class` is therefore consumed for the edge bands ONLY. That asymmetry is load-bearing + `bleed_class` is therefore consumed for the edge band ONLY. That asymmetry is load-bearing and is pinned by its own test. `art_crop_px` is preferred over `artbox_crop_px` deliberately: measured 2026-07-28, the former is populated on 220,579 of 220,579 `ImageEvidence` rows (100%) and the latter on 64.6%, and this classifier has no fallback reading without one. """ + if layout_class == "borderless": + return ART_EDGE_OPEN + if layout_class not in _ART_EDGE_BORDER_LAYOUT_CLASSES: + return None + width, height = card_image.size if not art_crop_px or len(art_crop_px) != 4 or width <= 0 or height <= 0: return None @@ -149,7 +204,7 @@ def classify_art_edge_continuity( # The left/right EDGE bands only. The top/bottom edge bands are deliberately not consulted: # both stay bordered on framed AND extended cards (title bar above, text box below - an # extended-art card keeps both), so they cannot discriminate; and on a borderless card the - # left/right pair already reads open without their help. + # left/right pair already short-circuited above. edge_boxes = [normalize_crop_box(_BORDER_SAMPLE_BANDS[i], bleed_class) for i in (0, 1)] # Sample the art-adjacent strips over the art's OWN vertical span - the only band of rows @@ -170,24 +225,21 @@ def classify_art_edge_continuity( (art_right, art_top, right_gap_outer, art_bottom), ] - adjacent_stds = [s[1] for s in (_sample_band(card_image, b) for b in adjacent_boxes) if s is not None] - edge_stds = [s[1] for s in (_sample_band(card_image, b) for b in edge_boxes) if s is not None] - if not adjacent_stds or not edge_stds: + border_samples = [s for s in (_sample_band(card_image, b) for b in edge_boxes) if s is not None] + adjacent_samples = [s for s in (_sample_band(card_image, b) for b in adjacent_boxes) if s is not None] + if not border_samples or not adjacent_samples: return None - import statistics - - adjacent_uniform = statistics.mean(adjacent_stds) < _BORDER_UNIFORMITY_STD_THRESHOLD - edge_uniform = statistics.mean(edge_stds) < _BORDER_UNIFORMITY_STD_THRESHOLD + # Both sides averaged into one RGB triple each, matching the old code's own "mean of both + # sides" precedent (there it averaged a scalar std; here it averages the mean-RGB triple + # `_sample_band` already returns) - a card whose left and right bands read slightly + # differently (uneven lighting across a scan) is still one comparison, not two disagreeing + # ones. + border_rgb = tuple(statistics.mean(sample[0][channel] for sample in border_samples) for channel in range(3)) + adjacent_rgb = tuple(statistics.mean(sample[0][channel] for sample in adjacent_samples) for channel in range(3)) + distance = math.sqrt(sum((a - b) ** 2 for a, b in zip(adjacent_rgb, border_rgb))) - if adjacent_uniform: - # Flat beside the art. If the edge outboard of it is flat too, that is an ordinary - # framed card; if the edge is busy while the strip INBOARD of it is flat, the geometry - # assumption has failed - real cards do not put artwork outside their own frame. - return ART_EDGE_FRAMED if edge_uniform else None - # Artwork beside the art crop. Whether a border survives at the very edge is what separates - # extended art from a genuinely borderless card. - return ART_EDGE_EXTENDED if edge_uniform else ART_EDGE_OPEN + return ART_EDGE_FRAMED if distance < _ART_EDGE_COLOR_DISTANCE_THRESHOLD else ART_EDGE_EXTENDED def cast_art_edge_continuity_vote( @@ -204,19 +256,10 @@ def cast_art_edge_continuity_vote( proxy render that already inflates `classify_border_color`'s borderless bucket), and a negative vote from an unvalidated class is a claim, not an abstention. - NOT WIRED INTO ANY RUNNER YET - deliberately, and this is the honest limit of this PR. - `classify_art_edge_continuity` is validated against CONSTRUCTED cases (its tests), not - against real card images, and the established precedent in this codebase is that every - fixed-fraction crop box was tuned against real fetched images before it was trusted - (`local_fallback`'s own module comment on the 40-source validation). - - THE GATE BEFORE THIS MAY VOTE, stated concretely so it is checkable rather than aspirational: - run the classifier over the `ImageEvidence` rows whose confirmed printing carries Scryfall's - own `frame_effects` "extendedart" (1,129 such rows in the 2026-07-28 join) and report - agreement against that imported fact, plus the false-positive rate over a same-sized sample - of confirmed NON-extended black-bordered cards. That labelling is free and needs no human - pass - it is the same ground truth this module's docstring quotes. Until that runs, this - function exists so the wiring is reviewable, and casts nothing. + NOT WIRED INTO ANY RUNNER YET - deliberately, and this is the honest limit of this PR. The + retuned relative-comparison classifier (docs/reports/2026-08-06-art-edge-relative-comparison.md) + is stored as `ImageEvidence.art_edge_class` evidence-only; whether it should ever cast a vote + is a separate decision this PR does not make, and depends on that evidence existing first. """ if art_edge_class != ART_EDGE_EXTENDED: return None diff --git a/MPCAutofill/cardpicker/tests/test_local_art_edge.py b/MPCAutofill/cardpicker/tests/test_local_art_edge.py index a000c1bc7..f73466c54 100644 --- a/MPCAutofill/cardpicker/tests/test_local_art_edge.py +++ b/MPCAutofill/cardpicker/tests/test_local_art_edge.py @@ -1,13 +1,11 @@ """ -Tests for the extended-art channel (`cardpicker.local_art_edge`). +Tests for the extended-art channel (`cardpicker.local_art_edge`), retuned to a within-image +relative colour comparison (docs/reports/2026-08-06-art-edge-relative-comparison.md). -The three card types are built as REAL images rather than by stubbing the sampler, so the -geometry - which band lands where, and in which coordinate frame - is under test alongside the -comparison logic. No network, no OCR, no Django DB except where a Tag row is genuinely needed. - -Deliberately NOT keyed on any set code, expansion, or card name: this classifier's whole claim is -about pixels, and a fixture that asserted "Aetherdrift cards read as extended" would pass on a -coincidence in current catalogue data rather than on the property being checked. +Cards are built as REAL images with precisely controlled per-band colours rather than by +stubbing the sampler, so both the geometry (which band lands where) and the colour comparison +are under test together. No network, no OCR, no Django DB except where a Tag row is genuinely +needed. """ import pytest @@ -23,200 +21,168 @@ cast_art_edge_continuity_vote, classify_art_edge_continuity, ) +from cardpicker.local_phash import ART_CROP_BOX from cardpicker.models import VotePolarity, VoteSource from cardpicker.tests.factories import CardFactory, TagFactory _IMAGE_SIZE = (750, 1050) -def _texture(width: int, height: int) -> Image.Image: - """A deterministic high-variance fill standing in for artwork. `(x * 37 + y * 17) % 256` - sweeps the whole 0-255 range, giving a per-band red-channel pstdev around 74 - comfortably - over `_BORDER_UNIFORMITY_STD_THRESHOLD` (18.0) without being tuned to sit just past it. No - RNG, so a failure is reproducible rather than seed-dependent.""" - data = bytes(((x * 37 + y * 17) % 256) for y in range(height) for x in range(width)) - return Image.frombytes("L", (width, height), data).convert("RGB") +def _clamp(value: int) -> int: + return max(0, min(255, value)) + + +def _patch(width: int, height: int, base_rgb: tuple[int, int, int], spread: int) -> Image.Image: + """A deterministic per-pixel colour patch: each channel wobbles +/- spread/2 around + base_rgb, sweeping a `(x * 37 + y * 17) % (spread + 1)` sawtooth - no RNG, so a failure is + reproducible rather than seed-dependent. `spread` controls both how far the patch's own mean + colour can be trusted (a large spread still averages back to base_rgb) and how "textured" + (real-artwork-like, non-flat) it reads, independently of that mean colour - the two + properties this classifier's predecessor conflated by testing only spread/variance.""" + r0, g0, b0 = base_rgb + data = bytearray() + for y in range(height): + for x in range(width): + wobble = (x * 37 + y * 17) % (spread + 1) - spread // 2 + data += bytes((_clamp(r0 + wobble), _clamp(g0 + wobble), _clamp(b0 + wobble))) + return Image.frombytes("RGB", (width, height), bytes(data)) def _art_crop_px_for(bleed_class=None, size=_IMAGE_SIZE) -> list[int]: """Reproduces `image_evidence._crop_box_to_pixels` exactly - `ART_CROP_BOX` through - `normalize_crop_box` for this `bleed_class`, then scaled to pixels. Derived rather than - hardcoded so this fixture tracks `ART_CROP_BOX` if it ever moves, and so the test exercises - the same remap-then-scale order production does.""" - from cardpicker.local_phash import ART_CROP_BOX - + `normalize_crop_box` for this `bleed_class`, then scaled to pixels.""" width, height = size left, top, right, bottom = local_fallback.normalize_crop_box(ART_CROP_BOX, bleed_class) return [round(left * width), round(top * height), round(right * width), round(bottom * height)] -def _card(case: str, bleed_class=None) -> Image.Image: - """Builds the three cards the classifier has to separate, plus the impossible fourth. - - All start as a solid black card and differ ONLY in how far the artwork spreads sideways - - which is precisely the physical difference between the real card types, and means a test - that passes here passed because of the art's horizontal extent and nothing else. - """ +def _card( + border_rgb: tuple[int, int, int], + border_spread: int, + adjacent_rgb: tuple[int, int, int], + adjacent_spread: int, + bleed_class=None, + art_rgb: tuple[int, int, int] = (120, 90, 60), + art_spread: int = 200, +) -> Image.Image: + """A card whose edge bands read `border_rgb`/`border_spread` and whose art-adjacent strips + read `adjacent_rgb`/`adjacent_spread` - the two samples this classifier compares. The art + crop itself is filled with an unrelated placeholder patch: this classifier never samples + inside it, only beside it, so its exact colour is irrelevant to every assertion here.""" width, height = _IMAGE_SIZE - img = Image.new("RGB", (width, height), (0, 0, 0)) + img = Image.new("RGB", (width, height), border_rgb) art_left, art_top, art_right, art_bottom = _art_crop_px_for(bleed_class) edge_left = local_fallback.normalize_crop_box(local_fallback._BORDER_SAMPLE_BANDS[0], bleed_class) edge_right = local_fallback.normalize_crop_box(local_fallback._BORDER_SAMPLE_BANDS[1], bleed_class) - el0, el1 = int(edge_left[0] * width), int(edge_left[2] * width) - er0, er1 = int(edge_right[0] * width), int(edge_right[2] * width) - edge_top, edge_bottom = int(edge_left[1] * height), int(edge_left[3] * height) + el1 = int(edge_left[2] * width) + er0 = int(edge_right[0] * width) - def paste(x0, y0, x1, y1): + def paste(x0, y0, x1, y1, rgb, spread): if x1 > x0 and y1 > y0: - img.paste(_texture(x1 - x0, y1 - y0), (x0, y0)) - - if case == "framed": - # artwork confined to the art crop: frame survives between art and both side edges - paste(art_left, art_top, art_right, art_bottom) - elif case == "extended": - # artwork spreads out to the inner edge of both edge bands - the border itself survives - paste(el1, art_top, er0, art_bottom) - elif case == "open": - # artwork reaches the physical edges: borderless / full-art - paste(0, 0, width, height) - elif case == "contradictory": - # artwork ONLY in the edge bands, flat frame inboard of them. No real card does this; - # it is the geometry-failure signature the classifier must abstain on rather than name. - paste(el0, edge_top, el1, edge_bottom) - paste(er0, edge_top, er1, edge_bottom) - else: # pragma: no cover - guards against a typo'd case name silently testing "framed" - raise AssertionError(f"unknown art-edge case {case!r}") + img.paste(_patch(x1 - x0, y1 - y0, rgb, spread), (x0, y0)) + + paste(art_left, art_top, art_right, art_bottom, art_rgb, art_spread) + paste(min(el1, art_left), art_top, art_left, art_bottom, adjacent_rgb, adjacent_spread) + paste(art_right, art_top, max(er0, art_right), art_bottom, adjacent_rgb, adjacent_spread) return img class TestClassifyArtEdgeContinuity: - """The owner's two-sample-point method: one band at the card edge, one adjacent to the art - crop. See `local_art_edge`'s module docstring for why one point cannot do this job.""" + """The retuned relative comparison: does the art-adjacent strip's colour match the border + colour `layout_class` already says this image has? See `local_art_edge`'s module docstring + for why that within-image comparison replaced the old absolute-variance test.""" + + # -- the relative comparison itself -------------------------------------------------------- + + def test_adjacent_matching_black_border_reads_framed(self): + img = _card((5, 5, 5), 4, (5, 5, 5), 4) + assert classify_art_edge_continuity(img, _art_crop_px_for(), "black") == ART_EDGE_FRAMED + + def test_adjacent_matching_white_border_reads_framed(self): + """Proves the comparison is against the DETECTED border colour, not against darkness - + a black-only case couldn't rule out a classifier that just checks "is this dark".""" + img = _card((230, 230, 230), 4, (230, 230, 230), 4) + assert classify_art_edge_continuity(img, _art_crop_px_for(), "white") == ART_EDGE_FRAMED + + def test_dark_off_hue_artwork_beside_a_black_border_does_not_read_extended(self): + """THE DEFECT THIS RETUNE FIXES, reproduced. Confirmed against the pre-retune code this + PR replaces (docs/reports/2026-08-06-art-edge-relative-comparison.md): a dark, textured, + off-hue (navy, not black) strip beside a genuine flat black border read 'extended' under + the old absolute-variance test - the edge band read flat-and-dark exactly like a real + border, and the adjacent band's real texture cleared the old uniformity bar for "not + flat". Neither of those checks the strip's actual COLOUR against the border beside it. + A navy strip ~36 RGB units from true black does not clear the retuned comparison's + colour-distance threshold - reads 'framed', not 'extended'. + """ + img = _card((5, 5, 5), 4, (10, 10, 40), 140) + assert classify_art_edge_continuity(img, _art_crop_px_for(), "black") == ART_EDGE_FRAMED - # -- the three real card types ------------------------------------------------------------ + def test_vivid_artwork_beside_a_black_border_reads_extended(self): + """The genuine positive: artwork colour that is FAR from the border (not merely + dark-vs-dark), with the outer edge still reading as a real border.""" + img = _card((14, 13, 26), 15, (120, 60, 30), 140) + assert classify_art_edge_continuity(img, _art_crop_px_for(), "black") == ART_EDGE_EXTENDED - @pytest.mark.parametrize( - "case, expected", - [ - ("framed", ART_EDGE_FRAMED), - ("extended", ART_EDGE_EXTENDED), - ("open", ART_EDGE_OPEN), - ("contradictory", None), - ], - ) - def test_each_case_gets_its_own_class(self, case, expected): - assert classify_art_edge_continuity(_card(case), _art_crop_px_for()) == expected - - def test_the_three_real_cases_are_mutually_distinct(self): - """Guards the property the parametrised case above cannot: that the three classes are - actually DIFFERENT from each other. A classifier hardcoded to return one value would - satisfy one row of the table above but not this.""" - verdicts = [classify_art_edge_continuity(_card(c), _art_crop_px_for()) for c in ("framed", "extended", "open")] - assert len(set(verdicts)) == 3, verdicts - - # -- the discriminator itself ------------------------------------------------------------- - - def test_the_edge_sample_is_what_separates_extended_from_open(self): - """THE POINT OF THE SECOND SAMPLE POINT, proved rather than asserted. - - On the extended-art card and the borderless card the ART-ADJACENT band reaches the SAME - verdict - both read as artwork - so a one-point classifier looking only there cannot - tell them apart. The EDGE band is where they diverge. Asserting that the art-adjacent - readings agree while the verdicts differ is what makes the second sample point - load-bearing rather than decorative, and it fails if the edge sample is ever dropped - - which no amount of testing the three happy paths would catch. - - The assertion is on which SIDE of `_BORDER_UNIFORMITY_STD_THRESHOLD` each band falls, - not on raw equality: the two images paste the same texture at different offsets, so - their std devs are both emphatically "artwork" without being the same float. - """ - threshold = local_fallback._BORDER_UNIFORMITY_STD_THRESHOLD - art_crop_px = _art_crop_px_for() - width, height = _IMAGE_SIZE - art_left, art_top, _art_right, art_bottom = art_crop_px - adjacent_box = ( - local_fallback._BORDER_SAMPLE_BANDS[0][2], - art_top / height, - art_left / width, - art_bottom / height, - ) - edge_box = local_fallback._BORDER_SAMPLE_BANDS[0] - - readings = { - case: ( - local_fallback._sample_band(_card(case), adjacent_box)[1], - local_fallback._sample_band(_card(case), edge_box)[1], - ) - for case in ("extended", "open") - } + def test_borderless_layout_class_short_circuits_to_open_without_sampling(self, monkeypatch): + """A card with no border cannot be extended-art by definition - decided from + `layout_class` alone. Proved rather than merely asserted: pixel sampling must never run.""" + + def _unreached(*args, **kwargs): + raise AssertionError("classify_art_edge_continuity sampled pixels for a borderless card") + + monkeypatch.setattr(local_art_edge, "_sample_band", _unreached) + img = _card((0, 0, 0), 0, (0, 0, 0), 0) + assert classify_art_edge_continuity(img, _art_crop_px_for(), "borderless") == ART_EDGE_OPEN - # the art-adjacent point says "artwork" for BOTH - on its own it cannot discriminate... - assert readings["extended"][0] >= threshold - assert readings["open"][0] >= threshold - # ...while the edge point says "border" for one and "artwork" for the other. - assert readings["extended"][1] < threshold - assert readings["open"][1] >= threshold - # ...and that is exactly where the two verdicts come apart. - assert classify_art_edge_continuity(_card("extended"), art_crop_px) == ART_EDGE_EXTENDED - assert classify_art_edge_continuity(_card("open"), art_crop_px) == ART_EDGE_OPEN + def test_ambiguous_layout_class_abstains(self): + """`layout_class is None` is `classify_border_color`'s own "uniform but not a colour + this taxonomy covers" abstention - there is no border colour to compare against, so this + classifier abstains too rather than inventing a comparison against nothing.""" + img = _card((5, 5, 5), 4, (5, 5, 5), 4) + assert classify_art_edge_continuity(img, _art_crop_px_for(), None) is None @pytest.mark.parametrize( - "mutated_threshold, collapses_to", + "mutated_threshold, expected", [ - (10_000.0, ART_EDGE_FRAMED), # every band reads "uniform" - (0.0, ART_EDGE_OPEN), # no band ever reads "uniform" + (100_000.0, ART_EDGE_FRAMED), # every distance reads "close enough to the border" + (0.0, ART_EDGE_EXTENDED), # no distance is ever small enough ], ) - def test_mutating_the_uniformity_comparison_collapses_every_case_together( - self, monkeypatch, mutated_threshold, collapses_to + def test_mutating_the_distance_comparison_collapses_every_case_together( + self, monkeypatch, mutated_threshold, expected ): """MUTATION PROOF. The classifier is a comparison against - `_BORDER_UNIFORMITY_STD_THRESHOLD`; break that comparison in either direction and all - three physically distinct cards must come back as the SAME class. If this ever passes - while the happy-path cases also pass, those cases were being satisfied by something - other than the comparison.""" - monkeypatch.setattr(local_art_edge, "_BORDER_UNIFORMITY_STD_THRESHOLD", mutated_threshold) - verdicts = { - case: classify_art_edge_continuity(_card(case), _art_crop_px_for()) - for case in ("framed", "extended", "open") + `_ART_EDGE_COLOR_DISTANCE_THRESHOLD`; snip break that comparison in either direction and both + a genuine border-colour match and a genuine colour mismatch must come back the SAME. If + this passes while the happy-path cases above also pass, those cases were satisfied by + something other than the threshold comparison.""" + monkeypatch.setattr(local_art_edge, "_ART_EDGE_COLOR_DISTANCE_THRESHOLD", mutated_threshold) + matching = _card((5, 5, 5), 4, (5, 5, 5), 4) + mismatched = _card((14, 13, 26), 15, (120, 60, 30), 140) + results = { + classify_art_edge_continuity(matching, _art_crop_px_for(), "black"), + classify_art_edge_continuity(mismatched, _art_crop_px_for(), "black"), } - assert set(verdicts.values()) == {collapses_to}, verdicts + assert results == {expected}, results # -- coordinate frames -------------------------------------------------------------------- def test_trimmed_image_reads_the_art_adjacent_strip_in_its_own_frame(self): """The asymmetry in the docstring, pinned. On a TRIMMED image `art_crop_px` arrives - already remapped (`image_evidence` applied `normalize_crop_box` before scaling to - pixels) while `_BORDER_SAMPLE_BANDS` has not been. The classifier must remap the edge - band and NOT re-remap the art crop. - - The trimmed remap moves the left edge band's inner bound from 0.05 to ~0.0046 and the - art crop's left from 0.07 to ~0.027. Reading the strip between the two RAW constants - instead would run from 0.05 to 0.027 - inverted, i.e. an empty crop and a silently - skipped band. A card whose art genuinely stops at the art crop must still read 'framed'. - """ + already remapped while `_BORDER_SAMPLE_BANDS` has not been - the classifier must remap + the edge band and NOT re-remap the art crop. A card whose art-adjacent colour genuinely + matches its border must still read 'framed' once that remap is applied correctly.""" + img = _card((5, 5, 5), 4, (5, 5, 5), 4, bleed_class="trimmed") assert ( - classify_art_edge_continuity( - _card("framed", bleed_class="trimmed"), _art_crop_px_for("trimmed"), bleed_class="trimmed" - ) + classify_art_edge_continuity(img, _art_crop_px_for("trimmed"), "black", bleed_class="trimmed") == ART_EDGE_FRAMED ) - def test_trimmed_extended_art_still_reads_extended(self): - assert ( - classify_art_edge_continuity( - _card("extended", bleed_class="trimmed"), _art_crop_px_for("trimmed"), bleed_class="trimmed" - ) - == ART_EDGE_EXTENDED - ) - def test_double_remapping_the_art_crop_would_change_the_answer(self): - """Proves the previous two tests are not vacuous - i.e. that the trimmed remap is doing - something observable here at all. Feeding the classifier an art_crop_px that has been - remapped a SECOND time (the bug the docstring warns about) moves the sampled strip off - the frame and changes the verdict away from 'framed'.""" - from cardpicker.local_phash import ART_CROP_BOX - + """Proves the previous test is not vacuous - i.e. that the trimmed remap does something + observable here. Feeding the classifier an `art_crop_px` remapped a SECOND time (the bug + the docstring warns about) samples off the real art-adjacent strip and changes the + verdict away from the correct 'framed' match.""" width, height = _IMAGE_SIZE once = local_fallback.normalize_crop_box(ART_CROP_BOX, "trimmed") twice = local_fallback.normalize_crop_box(once, "trimmed") @@ -227,17 +193,18 @@ def test_double_remapping_the_art_crop_would_change_the_answer(self): round(twice[3] * height), ] assert double_remapped != _art_crop_px_for("trimmed") - img = _card("framed", bleed_class="trimmed") - assert classify_art_edge_continuity(img, double_remapped, bleed_class="trimmed") != ART_EDGE_FRAMED + img = _card((5, 5, 5), 4, (5, 5, 5), 4, bleed_class="trimmed") + assert classify_art_edge_continuity(img, double_remapped, "black", bleed_class="trimmed") != ART_EDGE_FRAMED # -- degenerate input --------------------------------------------------------------------- @pytest.mark.parametrize("art_crop_px", [None, [], [1, 2, 3], [10, 10, 5, 500], [10, 10, 500, 5]]) def test_unusable_art_crop_px_abstains(self, art_crop_px): - """`art_crop_px` is populated on 100% of `ImageEvidence` rows today (220,579 of - 220,579, measured 2026-07-28), but this classifier must not depend on that staying - true - a null/short/inverted box is an abstention, not a crash.""" - assert classify_art_edge_continuity(_card("framed"), art_crop_px) is None + """`art_crop_px` is populated on 100% of `ImageEvidence` rows today (measured + 2026-07-28), but this classifier must not depend on that staying true - a null/short/ + inverted box is an abstention, not a crash.""" + img = _card((5, 5, 5), 4, (5, 5, 5), 4) + assert classify_art_edge_continuity(img, art_crop_px, "black") is None class TestCastArtEdgeContinuityVote: diff --git a/docs/identification-pipeline.md b/docs/identification-pipeline.md index e0dda670d..510231ef6 100644 --- a/docs/identification-pipeline.md +++ b/docs/identification-pipeline.md @@ -469,9 +469,15 @@ three were documented, deliberate states, not defects: - art-edge-continuity (`local_art_edge.classify_art_edge_continuity`) casting nothing — its own docstring states it is "EVIDENCE-ONLY today — nothing votes on it yet", gated behind - `cast_art_edge_continuity_vote` pending a validation pass against - Scryfall's own `frame_effects`. A stated deferral, not an unwired - accident. + `cast_art_edge_continuity_vote`. That validation pass against + Scryfall's own `frame_effects` ran 2026-08-06 + (docs/reports/2026-08-06-art-edge-relative-comparison.md) after a + retune from an absolute per-band variance test to a within-image + colour-distance comparison against the card's own already-classified + border (`ImageEvidence.layout_class`) — and did not clear the stated + bar: 0 of 30 genuine Scryfall extended-art images read `extended`. + Still evidence-only by a measured result, not merely a stated + deferral. **The general form — the magnitude/exception split.** Quantitative facts live in EVIDENCE COLUMNS; votes surface only the actionable diff --git a/docs/pipeline-fidelity-gate.md b/docs/pipeline-fidelity-gate.md index 756af6a87..0d1decfb6 100644 --- a/docs/pipeline-fidelity-gate.md +++ b/docs/pipeline-fidelity-gate.md @@ -1331,25 +1331,36 @@ does not. See [`documentation-process.md`](documentation-process.md)'s statement about illustration matching's yield. - **`art-edge-continuity-v1`** (`ART_EDGE_ANONYMOUS_ID`, [`MPCAutofill/cardpicker/local_art_edge.py`](../MPCAutofill/cardpicker/local_art_edge.py)) — - **DECLARED, NOT YET LIVE. Zero votes, zero `CardScanLog` rows, and no - runner calls it — by design, not by dormancy.** It is the extended-art - channel: a two-sample-point pixel comparison (card edge vs. the band - adjacent to the art crop) that classifies a card image as - `framed`/`extended`/`open` and would cast the pre-existing "Extended" - attribute tag. It is listed here because this roster's whole purpose is - that a calculator producing no output "reads as clean by being - invisible" — so the identity is declared and recorded BEFORE it can - write anything, the same reasoning `local_fallback`'s skip-reason block - gives for declaring its constants ahead of first write. - **The gate it has not yet cleared**: the classifier is validated against - constructed images only, never against real card images. Before it votes, - run it over the `ImageEvidence` rows whose confirmed printing carries - Scryfall's own `frame_effects` `extendedart` (1,129 such rows in the - 2026-07-28 join) and report agreement against that imported fact, plus - the false-positive rate over a same-sized sample of confirmed - non-extended black-bordered cards. That labelling is free and needs no - human pass. Do not read its zero vote count as a measured statement - about extended-art detection's yield — nothing has been measured yet. + **DECLARED, NOT LIVE. Zero votes, zero `CardScanLog` rows, and no + runner calls it — by a measured gate, not by dormancy.** It is the + extended-art channel: a within-image colour comparison (does the band + adjacent to the art crop match the border colour `ImageEvidence. layout_class` already found for this same image?) that classifies a + card image as `framed`/`extended`/`open` and would cast the + pre-existing "Extended" attribute tag. It is listed here because this + roster's whole purpose is that a calculator producing no output "reads + as clean by being invisible" — so the identity is declared and + recorded BEFORE it can write anything, the same reasoning + `local_fallback`'s skip-reason block gives for declaring its constants + ahead of first write. + **The gate ran 2026-08-06 and did not clear** + (docs/reports/2026-08-06-art-edge-relative-comparison.md): fetched + Scryfall's own images for 30 confirmed `extendedart` printings, 20 + confirmed `borderless` printings, and 20 confirmed ordinary + black-bordered printings (Scryfall's `frame_effects`/`border_color` is + the label, Scryfall's own image is the pixels — no name-matching step + in between, unlike the catalog-cohort measurement this one superseded). + Result: 0 of 30 genuine extended-art images read `extended` (worse than + the pre-retune absolute-variance classifier's 1 of 30 on the same + images), with 0 false positives on either negative cohort. Root cause: + `classify_border_color`'s own catch-all reads 20 of the 30 extended-art + images as `borderless` (a pre-existing, out-of-scope defect this + channel inherits via `layout_class`), and for the remaining 10 where it + correctly reads `black`, `normalize_crop_box`'s trimmed-image remap + (Scryfall images are always geometrically "trimmed" - no bleed margin) + shrinks the edge sample band to only ~0.5% of image width, too thin to + give a reliable border-colour reference. Do not read its zero vote + count as an unmeasured channel — it has been measured, and the measured + answer is "not yet". Tracked: [issue #721](https://github.com/ProxyPrints/ProxyPrints.github.io/issues/721). - **`local-name-frequency-v1`** (`NAME_FREQUENCY_ANONYMOUS_ID`, [`MPCAutofill/cardpicker/local_identify_printing_tags.py`](../MPCAutofill/cardpicker/local_identify_printing_tags.py)) — diff --git a/docs/reports/2026-08-06-art-edge-relative-comparison.md b/docs/reports/2026-08-06-art-edge-relative-comparison.md new file mode 100644 index 000000000..721df6876 --- /dev/null +++ b/docs/reports/2026-08-06-art-edge-relative-comparison.md @@ -0,0 +1,232 @@ +# Art-edge continuity retune: within-image relative comparison — result: does not clear the bar, not persisted + +2026-08-06. `local_art_edge.classify_art_edge_continuity` retuned from an ABSOLUTE per-band +pixel-variance test (`local_fallback._BORDER_UNIFORMITY_STD_THRESHOLD`, tuned for +`classify_border_color`'s different question) to a WITHIN-IMAGE relative colour comparison: does +the band beside the art crop match the border colour this same image's `layout_class` +(`classify_border_color`'s own output) already says it has? Validated against Scryfall's own +images for three real cohorts before any persistence decision. Result: does not clear the stated +bar. No `ImageEvidence` field added, no migration, no Stage C wiring, no `EXTRACTOR_OWNERSHIP` +entry. `classify_art_edge_continuity` stays evidence-only, called from nowhere but its own tests, +exactly as before this change. + +## The defect this retune targets + +Measured on 467 real catalogue images (prior session, report filed in the separate +`proxyprints-orchestration` repository, not this one — "art-edge-continuity-gate", 2026-08-06): +the pre-retune classifier's `extended` +reading fired on 7 of 467 images, and every one of those 7 edge-band samples read RGB like +`(14,13,26)` — dark artwork, not a border. The absolute variance threshold (18.0 std) cannot +distinguish "flat because it is a painted border" from "flat because it is dark, and dark content +has a compressed pixel-value range by construction" — it was measuring darkness and reporting it +as border uniformity. + +Confirmed by hand in this session before writing any new code (`/tmp/opencode/scratch_art_edge/probe3.py`, +not committed — synthetic images with an exact, controlled per-band colour): a card with a genuine +flat black border (RGB ~(5,5,5), std 4) and a dark, TEXTURED, off-hue navy strip beside the art +crop (RGB ~(10,10,40), std 140 — real variance, not flat) read `extended` under the pre-retune +code. The adjacent band's real texture cleared the old "not flat" bar; the edge band's own flat +darkness cleared the "border" bar; neither check ever compared the two bands' actual colours +against each other. + +## The retune + +`classify_art_edge_continuity` now takes a `layout_class` argument (the caller's own +already-computed `classify_border_color` result for this same image, not recomputed here): + +- `layout_class == "borderless"` → `open`, decided from the stored classification alone. No + border exists to compare against, and a borderless card cannot be extended-art by definition. + No pixel sampling happens on this path (pinned by + `test_borderless_layout_class_short_circuits_to_open_without_sampling`, which monkeypatches + `_sample_band` to raise if it is ever called). +- `layout_class is None` (`classify_border_color`'s own "uniform but not a colour this taxonomy + covers" abstention) → abstain (`None`). No colour to compare against. +- otherwise (`black`/`white`/`silver`) → sample the edge band (`_BORDER_SAMPLE_BANDS`, the same + bands `classify_border_color` already samples) for its mean RGB — the border colour THIS image + actually has, not an archetype — and the art-adjacent band (unchanged geometry from the + pre-retune code) for its own mean RGB. Euclidean RGB distance between the two: `< 70.0` → + `framed`; otherwise → `extended`. + +No new pixel extraction: both bands already went through `local_fallback._sample_band`, which +already computes mean RGB — the pre-retune code discarded that field and used only the returned +std. The retune consumes the mean instead of (in the edge band's case) in addition to consuming +it a second way. `_BORDER_UNIFORMITY_STD_THRESHOLD` and the edge band's own uniformity test are no +longer read anywhere in this module — `layout_class` already being non-`None`/non-`borderless` +already means `classify_border_color` found that same band uniform enough to name a colour on its +own, already-validated threshold; re-testing the same pixels' variance a second time here would +pay for one measurement twice without adding information. + +Colour-distance measure and its threshold are justified in the code itself +(`local_art_edge.py`'s `_ART_EDGE_COLOR_DISTANCE_THRESHOLD` comment): plain Euclidean RGB, chosen +because it needs no new colour-space conversion beyond the mean-RGB triple `_sample_band` already +returns, at the acknowledged cost of being weakest exactly where two colours are both desaturated +(a grey/neutral patch of artwork beside a silver border) — a case this pass's cohorts did not +specifically exercise (Scryfall silver-bordered printings are a small, mostly funny-set +population; not drawn as a dedicated fourth cohort). 70.0 was chosen from the same synthetic-image +probe that reproduced the pre-retune bug: a genuine dark-navy-vs-black-border case measured +distance ~36 (must read `framed` — this is the ambiguous-but-not-clearly-different case the retune +exists to stop calling `extended`), a genuine vivid-artwork-vs-black-border case measured distance +~116 (must read `extended`); 70.0 sits with margin on both sides of that gap. + +## Validation — Scryfall's own images, not catalogue name-matched uploads + +The catalogue-cohort measurement this retune's brief brief was built against was already known to +be confounded: `canonical_card` is a filename/tag match made at ingestion, never a pixel-content +check, so an image "confirmed" against an `extendedart` printing may not actually depict that +printing's real artwork (see the prior session's report, "systematic pattern" section, for the +two failure signatures — pure-black-canvas sources and full-bleed-to-edge sources — this produced +in the pre-retune measurement). Scryfall's own image of a Scryfall-labelled printing has no such +gap: the label (`frame_effects`/`border_color`) and the pixels come from the same authoritative +source, by construction. + +**Cohorts** (Scryfall search API, `order:name` for reproducibility, first N results, `image_uris. normal` — 488×680, no bleed margin): + +- `extended`: `q=is:extendedart`, n=30. +- `borderless`: `q=is:borderless`, n=20. +- `framed` (ordinary, negative control): `q=border:black -is:extendedart -is:borderless -is:fullart -is:showcase`, n=20. + +**Fetch discipline**: `cardpicker.harvest_fetch_limiter.SCRYFALL_REST` (search) and `SCRYFALL_CDN` +(images) — the same rate limiters `local_phash._fetch_scryfall_art_crop_url`/`_fetch_and_hash` +already use for Scryfall traffic, not a new pacing scheme. **No image pixels persisted**: every +fetched image lived only in the fetching process's memory for the duration of one classification +call, was never written to disk (including for debugging — the one hand-inspection of a single +card's sampled RGB values, below, printed numeric tuples only, no image file), and was discarded +(`image.close(); del image`) immediately after. No corpus, no fixture, no cache — a one-off run, +not a standing artefact (issue #697 stays deferred). + +For each fetched image: `bleed_class = classify_bleed_edge(image)` (always `trimmed` for +Scryfall's images — no bleed margin, matching real print dimensions exactly), `layout_class = classify_border_color(image, bleed_class)`, `art_crop_px` derived the same way +`image_evidence._crop_box_to_pixels` derives it in production, then both the pre-retune classifier +(reproduced verbatim in the throwaway probe script, not committed — this PR replaces the only +copy that lived in the repo) and the retuned one were run against the identical inputs. + +## The numbers + +| cohort | n | truth | OLD → `extended` | NEW → `extended` | +| ---------- | --: | ------------ | ---------------: | ---------------: | +| extended | 30 | extended | 1 (3.3%) | **0 (0.0%)** | +| borderless | 20 | not extended | 0 (0.0%) | 0 (0.0%) | +| framed | 20 | not extended | 0 (0.0%) | 0 (0.0%) | + +**The bar** (stated in the brief before measuring): correctly call Scryfall's own extended-art +images `extended`, without calling Scryfall's own ordinary framed images `extended`, at a rate +useful as a discriminator. **Not cleared.** The retuned classifier's recall on genuine +extended-art images is 0/30 — worse than the pre-retune classifier's already-poor 1/30 on the same +images. Neither version produces a false positive on either negative cohort (0/20 and 0/20 both +ways), so the retune has not made anything worse for the negative case — but a discriminator with +zero recall on its positive class and a low, uninformative false-positive rate born from almost +never firing at all is not evidence of precision (the same caveat the prior session's report +raised about the pre-retune numbers). + +## Root cause of the retune's own shortfall + +Traced by hand-inspecting the one case where the pre-retune classifier got a genuine extended-art +card right and the retune did not ("Academic Probation", Strixhaven `stx`, +`/tmp/opencode/scratch_art_edge/debug_one.py`, not committed): `layout_class` correctly read +`black` for this card, but the sampled border RGB (~(36, 39, 40)) and the sampled art-adjacent RGB +(~(40, 45, 48)) were only ~10 Euclidean units apart — both readings landing on genuinely dark, +similarly-toned pixels, not a clean black-border-vs-vivid-art contrast. + +Two compounding causes, both upstream of anything this PR's scope reaches: + +1. **`classify_border_color`'s own catch-all reads 20 of the 30 (66.7%) genuine extended-art + Scryfall images as `borderless`**, not `black` — the pre-existing, explicitly out-of-scope + defect this module's own docstring already documented against the catalogue population + (54.9% there; higher here, plausibly because Scryfall's crisper official scans show MORE + genuine edge-band variance than an average proxy upload, making the "not uniform → borderless" + catch-all fire more often, not less). `layout_class == "borderless"` short-circuits this + retune straight to `open` — correctly, given what `layout_class` says, but `layout_class` + itself is wrong for these 20 rows. +2. **For the remaining 10 where `layout_class` correctly reads `black`, the edge band geometry + itself is too thin to sample reliably on a Scryfall image.** `_BORDER_SAMPLE_BANDS`' raw + fractions (tuned against a bleed-inclusive image) pass through `normalize_crop_box(band, "trimmed")` for every Scryfall image (Scryfall images are always geometrically trimmed — no + bleed margin). Measured directly for "Academic Probation": the left edge band's remapped + fraction range is `(0.0, 0.0046)` — roughly 2 pixels wide on a 488px-wide image. A 2-pixel + sample sits at the exact image boundary, where antialiasing and JPEG blur dominate over any + real border colour, and is not a reliable colour reference regardless of which comparison is + built on top of it. This is a `normalize_crop_box`/`_BORDER_SAMPLE_BANDS` geometry property + (`local_fallback.py`, PROTECTED CORE), shared by `classify_border_color` itself on any trimmed + image — not something introduced by, or fixable within, this PR's scope + (`local_art_edge.py` only). + +Both causes sit entirely upstream of the comparison this PR was scoped to change. The specific +bug this retune targeted — dark, textured artwork being read as a border because it is flat and +dark rather than because it is a border — IS fixed, confirmed by the synthetic reproduction above +and pinned by `test_dark_off_hue_artwork_beside_a_black_border_does_not_read_extended`. It is not +sufficient on its own to make this classifier a useful discriminator against real images, because +the majority of its failures trace to `layout_class`/`normalize_crop_box` inputs it correctly +trusts but that are themselves unreliable for this specific input population. + +## Tests + +`MPCAutofill/cardpicker/tests/test_local_art_edge.py`, fully rewritten for the new signature and +comparison. Per-test mutation checks (each confirmed to fail when the behaviour it asserts is +removed): + +- `test_adjacent_matching_black_border_reads_framed` / + `test_adjacent_matching_white_border_reads_framed` — border-colour match reads `framed`. + Fails if the comparison direction is inverted (would fail if a match were classified `extended`). +- `test_dark_off_hue_artwork_beside_a_black_border_does_not_read_extended` — THE defect + reproduction: dark, textured, off-hue navy beside a real black border. The pre-retune code + (confirmed by hand, see "Confirmed by hand" above) returns `extended` for this exact + construction; the retuned code returns `framed`. Fails if the retune regresses to the old + behaviour. +- `test_vivid_artwork_beside_a_black_border_reads_extended` — the genuine positive, colour clearly + far from the border. Fails if the threshold is set so high nothing ever reads `extended`. +- `test_borderless_layout_class_short_circuits_to_open_without_sampling` — monkeypatches + `_sample_band` to raise; fails (loudly, via the injected exception) if the borderless + short-circuit is ever removed and sampling runs anyway. +- `test_ambiguous_layout_class_abstains` — fails if a `None` `layout_class` is ever treated as a + real border to compare against. +- `test_mutating_the_distance_comparison_collapses_every_case_together` (parametrized, both + directions) — MUTATION PROOF: monkeypatches `_ART_EDGE_COLOR_DISTANCE_THRESHOLD` to a value + that makes every distance read as a match, then to a value that makes no distance read as a + match, and asserts a genuine match case and a genuine mismatch case collapse to the SAME answer + in each direction. Fails if the threshold comparison is bypassed by anything else in the + function. +- `test_trimmed_image_reads_the_art_adjacent_strip_in_its_own_frame` / + `test_double_remapping_the_art_crop_would_change_the_answer` — the pre-existing + coordinate-frame asymmetry (edge band remapped, `art_crop_px` not re-remapped), carried forward + unchanged since the retune does not touch this geometry. +- `test_unusable_art_crop_px_abstains` (parametrized) — degenerate/missing `art_crop_px` inputs. + +`TestCastArtEdgeContinuityVote` carried forward unchanged — `cast_art_edge_continuity_vote`'s own +signature and behaviour are untouched by this retune. + +**Order-dependence (issue #679)**: `test_local_art_edge.py` run individually — 23 passed (55.6s). +Full `cardpicker/tests/` suite run together — 3678 passed, 8 skipped, 0 failed (779.4s). Both runs +clean; no order-dependent failure surfaced for this module. + +## Recommendation + +**Do not wire, do not persist.** No `ImageEvidence.art_edge_class` field, no migration, no Stage C +extraction, no `EXTRACTOR_OWNERSHIP`/manifest entry, no vote wiring — matching the brief's own +stated fallback. The retuned comparison is real, tested, and does fix the specific bug it targeted +(confirmed both synthetically and by the "Academic Probation" trace not regressing on THAT +mechanism — its remaining miss traces to inputs, not to the comparison itself). It is shipped as +the new `classify_art_edge_continuity` implementation, evidence-only, called from nowhere but its +own tests — the same state the pre-retune code was already in, on more defensible internals. + +**What would change this recommendation**: fixing `classify_border_color`'s "not uniform → +borderless" catch-all for the extended-art population (already an open item on that classifier, +predating this PR), and/or widening `_BORDER_SAMPLE_BANDS`'/`normalize_crop_box`'s trimmed-image +edge-band geometry so it is not reduced to ~2px on a genuinely trimmed image. Both are +`local_fallback.py` (PROTECTED CORE) changes, out of this PR's scope by the brief's own framing, +and both would need their own validation pass before being trusted — not a reason to fold them +into a retune of a different module speculatively. + +## Reproducibility + +Not committed (this repo's established convention for one-off analysis scripts, matching the +prior session's own precedent): + +- `/tmp/opencode/scratch_art_edge/probe3.py` — the synthetic-image reproduction of the pre-retune + defect and the retuned fix, run against both the pre- and post-retune code. +- `/tmp/opencode/scratch_art_edge/scryfall_validation.py` — the three-cohort Scryfall fetch/ + classify probe (search via `SCRYFALL_REST`, images via `SCRYFALL_CDN`, both the pre-retune + classifier reproduced inline and the retuned one imported live from the module under test). +- `/tmp/opencode/scratch_art_edge/scryfall_validation_results.csv` — the 70-row raw output + (cohort, Scryfall id/name/set, `bleed_class`, `layout_class`, old/new classification — no image + data). +- `/tmp/opencode/scratch_art_edge/debug_one.py` — the single-card diagnostic trace behind the + "root cause" section above (numeric RGB/distance output only, no image written to disk).