"""Corner close-ups for grading, cut out of a full-card photo. Corners are the category the grader struggled with most, and the reason is mechanical rather than a prompt problem: a card corner is a tiny fraction of the frame, so once a full-card photo is scaled down for the vision model there are barely any pixels left where the whitening and fraying actually live. A human grader solves this with a loupe. This does the same thing — find the card in the photo, cut out each of the four corners, and upscale them into their own images so the detail survives. Pillow is an optional dependency. Without it everything still works, just without the close-ups, so this never becomes a hard requirement. """ import io try: from PIL import Image, ImageChops, ImageFilter, ImageOps except ImportError: Image = None ImageChops = None ImageFilter = None ImageOps = None # Fraction of the card's width/height each corner crop covers. A card corner's # actual wear lives in the outer few millimetres, but a crop that tight loses # the context needed to judge whether an edge is cut straight. CORNER_FRACTION = 0.28 # How deep an edge strip reaches into the card, as a fraction of the # perpendicular dimension. Kept deliberately shallow: whitening sits in the # outermost millimetre or two, so a deeper strip is mostly card interior and # the wear ends up a sliver at one end of a frame full of artwork — which is # exactly how it gets overlooked. Shallow enough that the cut edge dominates # what's on screen, with just enough border either side to give it context. EDGE_FRACTION = 0.07 # Upscale target for the long edge of each crop. Large enough that fine # whitening survives, small enough to stay well inside the model's per-image # cap (and its token cost). CORNER_TARGET_PX = 700 # Edge strips are long and thin, so magnifying them by their LONG axis (the # way a squarish corner crop is handled) does nothing useful — that axis is # already big. What matters is how many pixels lie across the strip, since # that's the direction a whitening band is measured in. So these target the # short axis, with a cap on the long one to stay inside the model's per-image # pixel limit. EDGE_SHORT_TARGET_PX = 340 EDGE_LONG_CAP_PX = 2500 # Surface inspection, as a band-pass rather than a plain high-pass. A plain # high-pass keeps the very finest detail, which on a printed card means the # halftone dot rosettes — they swamp the picture and hide the very marks # being looked for. Scratches and print lines sit in a band between those # dots and the artwork itself, so the fine radius blurs the dots away and # the coarse one takes the artwork out, leaving what's in between. SURFACE_FINE_RADIUS = 1.4 SURFACE_COARSE_RADIUS = 6.0 SURFACE_AUTOCONTRAST_CUTOFF = 0.4 # The card face is split into quadrants for surface inspection, so each one # keeps enough resolution to show a hairline scratch. Rows x columns. SURFACE_TILES = (2, 2) SURFACE_TILE_TARGET_PX = 1150 SURFACE_QUADRANTS = ("upper-left", "upper-right", "lower-left", "lower-right") # Below this the source photo has no detail worth zooming into — upscaling it # would just produce a convincing-looking blur for the model to over-read. MIN_SOURCE_PX = 600 CORNERS = ("top-left", "top-right", "bottom-left", "bottom-right") EDGES = ("top-edge", "right-edge", "bottom-edge", "left-edge") def available(): return Image is not None # Magic numbers, so an image is identified by what it actually is rather than # by what its filename claims. Phones routinely hand over names the extension # check can't cope with — no extension at all, a content:// URI, or .HEIC — # and rejecting a perfectly readable photo over its name is indefensible. _SIGNATURES = ( (b"\x89PNG\r\n\x1a\n", "png"), (b"\xff\xd8\xff", "jpeg"), (b"GIF87a", "gif"), (b"GIF89a", "gif"), (b"BM", "bmp"), (b"II*\x00", "tiff"), (b"MM\x00*", "tiff"), ) # Formats the Anthropic API accepts directly; anything else has to be # converted before it can be sent. DIRECTLY_SUPPORTED = {"png", "jpeg", "gif", "webp"} def sniff_format(image_bytes): """Identify an image from its leading bytes. Returns a short name or None.""" if not image_bytes: return None head = image_bytes[:32] for signature, name in _SIGNATURES: if head.startswith(signature): return name if head[:4] == b"RIFF" and head[8:12] == b"WEBP": return "webp" # HEIC/HEIF (iPhone's default) declares itself in an 'ftyp' box. if head[4:8] == b"ftyp": brand = head[8:12] if brand in (b"heic", b"heix", b"hevc", b"hevx", b"mif1", b"msf1", b"heim"): return "heic" if brand in (b"avif", b"avis"): return "avif" return None def normalize_upload(image_bytes, filename="upload"): """Return (bytes, filename, error) with the image in a sendable format. Passes through anything the API already accepts. Anything else that Pillow can open — HEIC with the right plugin, BMP, TIFF, AVIF — is re-encoded as JPEG rather than refused, since the bytes are perfectly good and only the container is wrong. """ fmt = sniff_format(image_bytes) if fmt in DIRECTLY_SUPPORTED: stem = filename.rsplit(".", 1)[0] if "." in filename else filename return image_bytes, "{}.{}".format(stem or "upload", "jpg" if fmt == "jpeg" else fmt), None if Image is None: return None, None, ( "That file is {} and this app can only send PNG, JPEG, GIF or WebP. " "Installing pillow (python3 -m pip install --user pillow) would let " "it convert automatically.".format(fmt or "an unrecognised format")) try: img = Image.open(io.BytesIO(image_bytes)) img.load() if img.mode not in ("RGB", "L"): img = img.convert("RGB") buffer = io.BytesIO() img.save(buffer, format="JPEG", quality=92) stem = filename.rsplit(".", 1)[0] if "." in filename else filename return buffer.getvalue(), "{}.jpg".format(stem or "upload"), None except Exception: if fmt in ("heic", "avif"): return None, None, ( "That photo is {} format, which needs an extra decoder. Either " "install it (python3 -m pip install --user pillow-heif) or set " "your phone's camera to save JPEG instead of " "High Efficiency.".format(fmt.upper())) return None, None, ( "Couldn't read that file — it doesn't look like a readable image.") def _detect_card_box(img): """Best-effort bounding box of the card within the photo. Works by estimating the background colour from the photo's own corners and then finding the rows and columns that stop looking like background. That beats edge-density detection here: a card's interior is full of artwork and text, so edge density peaks in the middle and gives a box that drifts several percent past the real cut line. For edge strips that slop matters — it fills the strip with desk or mat instead of the card's border, which is the whole thing being examined. Returns None when the result doesn't look like a card, so the caller can fall back to treating the whole frame as the card. """ rgb = img.convert("RGB") # Detect at a fairly high resolution. Downscaling harder is cheaper, but # it blurs the outermost pixels of the card into the background — and on # a pale background, a heavily whitened edge then reads AS background, so # the boundary walks inward past the wear and the measurement misses the # very thing it is looking for. Found exactly that way round in testing. scale = 800.0 / max(rgb.size) if scale < 1: rgb = rgb.resize((max(1, int(rgb.width * scale)), max(1, int(rgb.height * scale))), Image.BILINEAR) else: scale = 1.0 w, h = rgb.size if w < 20 or h < 20: return None px = rgb.load() # Estimate the background from the four image corners. If the card fills # the frame these samples are card, every pixel then reads as "not # background", and the box correctly comes back as the whole image. patch = max(2, min(w, h) // 25) samples = [] for cx, cy in ((0, 0), (w - patch, 0), (0, h - patch), (w - patch, h - patch)): for x in range(cx, min(w, cx + patch)): for y in range(cy, min(h, cy + patch)): samples.append(px[x, y]) bg = tuple(sum(c[i] for c in samples) // len(samples) for i in range(3)) def differs(p): return abs(p[0] - bg[0]) + abs(p[1] - bg[1]) + abs(p[2] - bg[2]) > 90 row_counts = [0] * h col_counts = [0] * w for y in range(h): for x in range(w): if differs(px[x, y]): row_counts[y] += 1 col_counts[x] += 1 def span(counts, extent): # A real card edge makes most of a row/column stop being background # at once, so key off a share of the perpendicular extent rather than # off the peak — that keeps a few stray specks of noise in the # background from widening the box. cutoff = extent * 0.35 hits = [i for i, c in enumerate(counts) if c >= cutoff] return (hits[0], hits[-1]) if hits else None rows, cols = span(row_counts, w), span(col_counts, h) if not rows or not cols: return None box = (int(cols[0] / scale), int(rows[0] / scale), int(round(cols[1] / scale)), int(round(rows[1] / scale))) box = (max(0, box[0]), max(0, box[1]), min(img.width, box[2] + 1), min(img.height, box[3] + 1)) bw, bh = box[2] - box[0], box[3] - box[1] if bw < 40 or bh < 40: return None # A tiny box means detection latched onto something that isn't the card. if float(bw * bh) / float(img.width * img.height) < 0.15: return None return box def _surface_map(piece): """A band-pass view that isolates surface texture from the artwork. Scratches, print lines and dents are low-contrast marks sitting on artwork that is far higher contrast than they are — which is exactly why they vanish in a normal view. Subtracting a heavily blurred copy cancels the smooth artwork; subtracting from a lightly blurred copy rather than the raw pixels first drops the halftone dots, which otherwise dominate the result on any printed card. What survives is the band where surface damage lives, and stretching the contrast makes it legible. The output deliberately exaggerates: holo texture, foil patterns and JPEG blocking all light up alongside real damage, so it is only ever shown beside the untouched crop for comparison, never on its own. """ grey = piece.convert("L") fine = grey.filter(ImageFilter.GaussianBlur(SURFACE_FINE_RADIUS)) coarse = grey.filter(ImageFilter.GaussianBlur(SURFACE_COARSE_RADIUS)) band = ImageChops.difference(fine, coarse) return ImageOps.autocontrast(band, cutoff=SURFACE_AUTOCONTRAST_CUTOFF) def _band_stats(luma_px, hsv_px, x, y0, y1): """Mean luma and saturation down one column of a band. Luma rather than HSV's "value": V is max(R,G,B), which makes a saturated yellow border and bare white paper both read as 255 — the exact case this is trying to measure. Luma weights the channels the way brightness is actually perceived, so yellow lands near 212 and white at 255, leaving a real difference to detect. """ n = 0 l_total = 0 s_total = 0 for y in range(y0, y1): l_total += luma_px[x, y] s_total += hsv_px[x, y][1] n += 1 if not n: return None, None return l_total / float(n), s_total / float(n) def _column_reference(luma_px, hsv_px, x, y0, y1): """Median luma/saturation down a column, plus how much it varies. Median rather than mean so a few pixels of text don't drag the baseline, and the spread is returned so the caller can throw the column out entirely when the reference clearly isn't uniform border. """ lumas = [] sats = [] for y in range(y0, y1): lumas.append(luma_px[x, y]) sats.append(hsv_px[x, y][1]) if not lumas: return None, None, None lumas.sort() sats.sort() n = len(lumas) spread = lumas[int(n * 0.9)] - lumas[int(n * 0.1)] return lumas[n // 2], sats[n // 2], spread def _measure_one_edge(card, geometry): """Whitening measurement for the TOP edge of whatever is passed in. Each of the four edges is rotated to the top before calling this, so the logic only ever has to handle one orientation. The measurement is a local, column-by-column comparison: the outermost sliver of border is compared against the same border slightly deeper in, at the same x. Whitening is the paper core showing through, so the outer band goes lighter and loses saturation relative to the reference — while a lighting gradient, a coloured border, or a dark card all affect both bands together and cancel out. That self-referencing is the point: it needs no idea what the card is supposed to look like. """ inset, band, gap = geometry w, h = card.size if h < inset + gap + band * 2 or w < 20: return None luma = card.convert("L").load() hsv = card.convert("HSV").load() edge_y = (inset, inset + band) # Corners have their own category and their own rounding, so leave them # out — otherwise every card's four rounded corners inflate every edge. margin = max(2, int(w * 0.05)) # Read the outermost band once per column. Comparing against a band # further into the card was tried first and cannot work generally: a # Pokemon border is barely ten pixels deep, so any reference deep enough # to be separate lands on the copyright line or the artwork, and a bright # border measured against dark text reads as whitening down the entire # edge. The baseline instead comes from the card's own border, worked out # by the caller across all four edges at once. # Also read a band just INSIDE the edge band, per column. Whitening is # confined to the outermost millimetre or two at the cut, so it shows up # as a step between these two bands. Glare, a glossy sleeve catching the # light, or simply one side of the photo being brighter lifts BOTH bands # together and produces no step — which is how the two get told apart. inner_y = (edge_y[1] + max(1, band // 4), edge_y[1] + max(1, band // 4) + band) if inner_y[1] > h: inner_y = None columns = [] for x in range(margin, w - margin): l_edge, s_edge = _band_stats(luma, hsv, x, *edge_y) if l_edge is None: continue if inner_y is None: columns.append((l_edge, s_edge, None, None, None)) continue l_in, s_in, spread = _column_reference(luma, hsv, x, *inner_y) columns.append((l_edge, s_edge, l_in, s_in, spread)) if len(columns) < 20: return None return columns def _score_edge(columns, base_l, base_s): """Share of an edge's columns that read as whitened. A column has to clear two independent tests. The first compares it with the card's own border pooled across all four edges, which catches wear wherever it sits. The second requires an actual step between the outermost band and the band just inside it, which is what makes it wear rather than lighting: a bright reflection raises both bands equally and fails this test, while paper showing through at the cut does not. """ whitened = 0 deltas = [] counted = 0 for l_edge, s_edge, l_in, s_in, spread in columns: d_light = l_edge - base_l d_desat = base_s - s_edge pooled = d_desat >= 55 or d_light >= 28 or (d_light >= 10 and d_desat >= 20) if l_in is None: local = True # no inner band available; fall back to pooled only elif spread is not None and spread > 55: # The inner band landed on text or artwork, so it cannot confirm # anything. Skip the column rather than guess — a false "clean" is # cheaper here than a false accusation of wear. continue else: local = (l_edge - l_in) >= 8 or (s_in - s_edge) >= 18 counted += 1 if pooled and local: whitened += 1 deltas.append(max(d_light, d_desat * 0.4)) if counted < 20: return None return { "percent": round(100.0 * whitened / counted, 1), "mean_lift": round(sum(deltas) / float(len(deltas)), 1) if deltas else 0.0, "columns_used": counted, "baseline_luma": round(base_l, 1), "baseline_saturation": round(base_s, 1), } def edge_wear_profile(image_bytes): """Measure whitening along all four edges. Returns None if unavailable. Gives back, per edge, the share of its length that reads as whitened and how strong the lift is — numbers a vision model cannot produce by eye, and which are immune to the thing that kept defeating it: telling a genuine pale band apart from the cut line and the border's own anti-aliasing. """ if Image is None: return None try: img = Image.open(io.BytesIO(image_bytes)) img.load() if img.mode not in ("RGB", "L"): img = img.convert("RGB") box = _detect_card_box(img) or (0, 0, img.width, img.height) card = img.crop(box).convert("RGB") short = min(card.size) geometry = ( # Only just enough to clear the cut line and its anti-aliasing. # Whitening starts AT the cut, so an inset chosen to be safe # against background bleed instead steps straight over the thing # being measured — that alone was halving the signal. max(2, int(round(short * 0.004))), max(4, int(round(short * 0.011))), # band thickness max(8, int(round(short * 0.020))), # depth of the reference band ) rotations = { "top": 0, "right": 90, # rotating 90 CCW brings the right edge to the top "bottom": 180, "left": 270, } collected = {} for name, angle in rotations.items(): face = card if angle == 0 else card.rotate(angle, expand=True) collected[name] = _measure_one_edge(face, geometry) if all(v is None for v in collected.values()): return None # Baseline from ALL four edges pooled, not each edge against itself. # Whitening only ever raises luma and lowers saturation, so unworn # border sits at the low end of one and the high end of the other, # and quartiles across the whole card find it. Scoring an edge # against only its own length silently fails on the case that # matters most — an edge worn evenly end to end, where the baseline # becomes the wear and the damage cancels itself out. Pooling means # three clean edges anchor the fourth. pooled = [c for cols in collected.values() if cols for c in cols] if len(pooled) < 40: return None lumas = sorted(c[0] for c in pooled) sats = sorted(c[1] for c in pooled) base_l = lumas[int(len(lumas) * 0.25)] base_s = sats[int(len(sats) * 0.75)] # Decide whether this card can be measured at all before reporting a # number for it. The method detects paper showing through a printed # border, which presumes the border is darker and more saturated than # bare card stock. Silver, white, foil and refractor borders break # that presumption outright — they are already pale and colourless, # so ordinary variation in them reads exactly like wear, and the # result is a confident accusation against a clean card. Refusing to # answer is the right outcome there; a wrong number is worse than no # number, because it drags the whole grade down with it. iqr = lumas[int(len(lumas) * 0.75)] - lumas[int(len(lumas) * 0.25)] reason = None if base_l >= 205 and base_s <= 45: reason = ("the card's border is white, silver or foil, where paper " "showing through looks the same as the border itself") elif iqr >= 70: reason = ("the border's brightness varies too much across the card " "— typical of a refractor or prismatic finish — for a " "whitening measurement to mean anything") edges = {name: (_score_edge(cols, base_l, base_s) if cols else None) for name, cols in collected.items()} return { "edges": edges, "reliable": reason is None, "reason": reason, "border_luma": round(base_l, 1), "border_saturation": round(base_s, 1), } except Exception: return None def _border_width(px, size, side, border_rgb, skip=0, tolerance=70): """How far the uniform border reaches in from one side, in pixels. Sampled along several lines and taken as the median, so a logo or a bit of artwork touching the border on one line doesn't decide the answer. Corners are avoided — their rounding would read as a wider border. `skip` steps over the cut line and its anti-aliasing before measuring; without it the very first pixel is still background and every side reads as a zero-width border. """ w, h = size along = h if side in ("left", "right") else w depth_limit = int((w if side in ("left", "right") else h) * 0.30) if depth_limit < 3: return None def matches(p): return (abs(p[0] - border_rgb[0]) + abs(p[1] - border_rgb[1]) + abs(p[2] - border_rgb[2])) <= tolerance # Walk inward one row at a time asking how much of that row is still # border, rather than stopping at the first pixel that isn't. Text # printed inside the border — a vintage nameplate, a modern copyright # line, the collector number — otherwise halts the scan almost at the cut # and reports a border a fraction of its real width. Those characters are # thin, so the row they sit on is still mostly border; the design proper # takes the whole row at once, which is the transition being looked for. positions = [int(along * (0.2 + 0.6 * i / 24.0)) for i in range(25)] positions = [p for p in positions if 0 <= p < along] if not positions: return None for depth in range(skip, depth_limit): hits = 0 for pos in positions: if side == "left": p = px[depth, pos] elif side == "right": p = px[w - 1 - depth, pos] elif side == "top": p = px[pos, depth] else: p = px[pos, h - 1 - depth] if matches(p): hits += 1 if hits < len(positions) * 0.5: return depth return depth_limit def centering_profile(image_bytes): """Measure how well centred the card's design is inside its border. Centering is the one PSA category that is purely geometric — it is a ratio of border widths, with published tolerances attached — so it can be measured outright rather than estimated. Returns the widths, the left/right and top/bottom ratios, and the worse of the two, which is what a grader keys off. Returns None when the card has no uniform border to measure against (full-bleed modern cards) or the read looks implausible, so the caller can fall back to the model's own eye rather than trust a bad number. """ if Image is None: return None try: img = Image.open(io.BytesIO(image_bytes)) img.load() card = img.crop(_detect_card_box(img) or (0, 0, img.width, img.height)) card = card.convert("RGB") w, h = card.size if w < 60 or h < 60: return None px = card.load() # The border colour, sampled just inside the cut at the midpoint of # each side — far from corners and from any design element. inset = max(2, int(min(w, h) * 0.012)) samples = [px[inset, h // 2], px[w - 1 - inset, h // 2], px[w // 2, inset], px[w // 2, h - 1 - inset]] border_rgb = tuple(sorted(c[i] for c in samples)[len(samples) // 2] for i in range(3)) widths = {side: _border_width(px, (w, h), side, border_rgb, skip=inset) for side in ("left", "right", "top", "bottom")} if any(v is None for v in widths.values()): return None # A border that vanishes, or that swallows a third of the card, means # this isn't a bordered card or the sample missed — either way the # ratio would be meaningless. if min(widths.values()) < 2: return None if max(widths["left"], widths["right"]) > w * 0.28: return None if max(widths["top"], widths["bottom"]) > h * 0.28: return None def ratio(a, b): total = float(a + b) if total <= 0: return None bigger = 100.0 * max(a, b) / total return round(bigger, 1) horizontal = ratio(widths["left"], widths["right"]) vertical = ratio(widths["top"], widths["bottom"]) if horizontal is None or vertical is None: return None return { "widths_px": widths, "horizontal": horizontal, "vertical": vertical, "worst": round(max(horizontal, vertical), 1), "horizontal_label": "{:.0f}/{:.0f}".format(horizontal, 100 - horizontal), "vertical_label": "{:.0f}/{:.0f}".format(vertical, 100 - vertical), "wider_side": ("left" if widths["left"] > widths["right"] else "right", "top" if widths["top"] > widths["bottom"] else "bottom"), } except Exception: return None def _edge_enhanced(strip): """Whitening map of an edge strip, keyed on colour saturation. Edge whitening is physically the paper core showing through a printed border, so its signature is a loss of saturation rather than a change in brightness. Mapping saturation and inverting it therefore isolates exactly the thing being looked for: worn paper lights up, printed colour goes dark, whatever the border's colour happens to be. Brightness-based contrast stretching was tried first and is actively misleading here — on a light border (a yellow Pokemon frame, a white 1980s border) it pushes the border itself to near-white and buries the very wear it was meant to reveal. The limit worth knowing: on an already-unsaturated white border there is no saturation left to lose, so this map stays flat and the untouched strip beside it has to carry the judgement. """ saturation = strip.convert("HSV").split()[1] return ImageOps.autocontrast( ImageOps.invert(saturation), cutoff=1).convert("RGB") def _stack(top, bottom, gap=10): top = top.convert("RGB") bottom = bottom.convert("RGB") w = max(top.width, bottom.width) canvas = Image.new("RGB", (w, top.height + gap + bottom.height), (18, 18, 18)) canvas.paste(top, (0, 0)) canvas.paste(bottom, (0, top.height + gap)) return canvas def _side_by_side(left, right, gap=14): """Untouched crop beside its surface map, so one can check the other.""" left = left.convert("RGB") right = right.convert("RGB") h = max(left.height, right.height) canvas = Image.new("RGB", (left.width + gap + right.width, h), (18, 18, 18)) canvas.paste(left, (0, 0)) canvas.paste(right, (left.width + gap, 0)) return canvas def _encode(img): buffer = io.BytesIO() img.save(buffer, format="JPEG", quality=92) return buffer.getvalue() def _resized(piece, factor): if factor > 1: piece = piece.resize( (max(1, int(piece.width * factor)), max(1, int(piece.height * factor))), Image.LANCZOS, ) return piece if piece.mode == "RGB" else piece.convert("RGB") def _magnify(piece, target_px): """Scale a roughly square crop so its long edge hits target_px.""" return _resized(piece, target_px / float(max(piece.size))) def _magnify_strip(piece): """Scale a long thin strip by its short axis, capping the long one. Targeting the long axis here would be a no-op — it's already large — and would leave the across-the-strip detail, which is the part that actually shows whitening, at whatever the source happened to give. """ factor = EDGE_SHORT_TARGET_PX / float(min(piece.size)) factor = min(factor, EDGE_LONG_CAP_PX / float(max(piece.size))) return _resized(piece, factor) def detail_crops(image_bytes, filename="card.jpg", corners=True, edges=True, surface=True): """Magnified crops of a card's corners and edge strips, in that order. Returns [(bytes, filename), ...]. Empty when Pillow is missing, the image is too small to zoom into, or anything goes wrong — grading then proceeds on the full photo alone, which is the pre-Pillow behaviour. Corners and edges are cropped separately rather than relying on the corner crops alone: a corner crop only covers the ends of each side, so whitening running along the middle of an edge falls between them. """ if Image is None: return [] try: img = Image.open(io.BytesIO(image_bytes)) img.load() if img.mode not in ("RGB", "L"): img = img.convert("RGB") if max(img.size) < MIN_SOURCE_PX: return [] box = _detect_card_box(img) or (0, 0, img.width, img.height) card = img.crop(box) w, h = card.width, card.height stem = filename.rsplit(".", 1)[0] crops = [] if corners: cw = max(1, int(w * CORNER_FRACTION)) ch = max(1, int(h * CORNER_FRACTION)) regions = { "top-left": (0, 0, cw, ch), "top-right": (w - cw, 0, w, ch), "bottom-left": (0, h - ch, cw, h), "bottom-right": (w - cw, h - ch, w, h), } for name in CORNERS: piece = card.crop(regions[name]) if min(piece.size) < 8: continue crops.append((_encode(_magnify(piece, CORNER_TARGET_PX)), "{}-{}.jpg".format(stem, name))) if edges: # Pull in a couple of pixels first. Box detection lands within # about two pixels of the cut, and any background left in the # strip is a problem specifically for the saturation map: an # unsaturated backdrop (a dark mat, a white desk) reads as bright # there, sitting exactly where whitening would be and faking it # on a perfectly clean card. Costs a sliver of the real edge, # which is worth it to kill a false positive. inset = max(4, int(round(min(w, h) * 0.012))) face = card.crop((inset, inset, max(inset + 1, w - inset), max(inset + 1, h - inset))) fw, fh = face.size ew = max(1, int(fw * EDGE_FRACTION)) eh = max(1, int(fh * EDGE_FRACTION)) regions = { "top-edge": (0, 0, fw, eh), "right-edge": (fw - ew, 0, fw, fh), "bottom-edge": (0, fh - eh, fw, fh), "left-edge": (0, 0, ew, fh), } card_for_edges = face for name in EDGES: piece = card_for_edges.crop(regions[name]) if min(piece.size) < 8: continue # Deliberately the plain strip, with no processed companion. # A saturation map was tried here (worn paper is desaturated, # so in principle whitening should light up) and measurably # backfired: every card, clean ones included, then came back # "minor whitening", with the location moving between runs. # The cut line itself and the border's own anti-aliasing # produce a signal indistinguishable from light wear, so the # map added noise the model anchored on rather than evidence. # Surface keeps its processed companion because there the # signal is genuinely separable; edges do better without one. crops.append((_encode(_magnify_strip(piece)), "{}-{}.jpg".format(stem, name))) if surface: rows, cols = SURFACE_TILES index = 0 for ry in range(rows): for cx in range(cols): piece = card.crop(( int(w * cx / cols), int(h * ry / rows), int(w * (cx + 1) / cols), int(h * (ry + 1) / rows), )) if min(piece.size) < 16: index += 1 continue combo = _side_by_side(piece, _surface_map(piece)) name = SURFACE_QUADRANTS[index] if index < len(SURFACE_QUADRANTS) \ else "region{}".format(index) crops.append((_encode(_magnify(combo, SURFACE_TILE_TARGET_PX)), "{}-surface-{}.jpg".format(stem, name))) index += 1 return crops except Exception: # A grading run is worth more than a perfect crop — never let an # image-processing failure take out the whole request. return [] def corner_crops(image_bytes, filename="card.jpg"): """Corner close-ups only. Kept for callers that don't want edge strips.""" return detail_crops(image_bytes, filename, corners=True, edges=False)