/* ───────────────────────────────────────────────────────────────── CARDGAUGE GRADEFRAME — guided capture, phase 1 19 Sept 2026 WHY THIS EXISTS As of yesterday the pre-screen's range is set by the photo as much as by the card: the grade ceiling comes from the worst factor, and the WIDTH comes from doubt -- no back, an unreadable surface, a poor photo. A glared hand-held shot comes back "5-8", the same card shot flat and square comes back "8". So capture quality is not cosmetic here; it is the difference between an answer and a shrug. WHAT IT DOES NOT DO It does not make cards grade higher. Better evidence narrows the range; it never lifts the ceiling. If GradeFrame ever starts handing out better grades for the same card, something is wrong. WHY NOT OPENCV business.html loads opencv.js for multi-card detection and it works well there -- a one-off 8MB download before a shop scans a table. A collector opening a camera on a phone is a different bargain, and a live preview that stutters is worse than no preview. Everything here is plain canvas maths on a 320px-wide copy of the frame, a few milliseconds per pass. DETECTION, honestly described It finds the card's four sides by looking for where brightness changes sharply, scanning inward from each edge along several lines, then takes the median. That is enough to know whether a card is there, whether the whole of it is in frame, and how far it is rotated. It is NOT a four-corner homography: a card held at a steep angle is refused rather than corrected, because a warp from a bad guess produces a confident-looking image built on nothing. ───────────────────────────────────────────────────────────────── */ (function (global) { 'use strict'; var CARD_RATIO = 88.9 / 63.5; // a standard card, 2.5" x 3.5" var OUT_W = 900, OUT_H = Math.round(900 * CARD_RATIO); /* Thresholds. Each one refuses something a person can fix in a second, which is the only kind of refusal worth making. */ var LOCK = { minCoverage: 0.45, // card fills less than this of the frame: too far away maxCoverage: 0.97, // card is cropped by the frame edge maxRotation: 4.0, // degrees; more than this and it is not square minSharp: 9, // below this the photo is soft maxGlare: 0.06 // fraction of card pixels that are blown out }; function el(tag, cls, html) { var n = document.createElement(tag); if (cls) n.className = cls; if (html != null) n.innerHTML = html; return n; } /* ── frame analysis ──────────────────────────────────────────── */ function analyse(video, work) { var vw = video.videoWidth, vh = video.videoHeight; if (!vw || !vh) return null; var W = 320, H = Math.round(vh / vw * 320); work.width = W; work.height = H; var ctx = work.getContext('2d', { willReadFrequently: true }); ctx.drawImage(video, 0, 0, W, H); var data = ctx.getImageData(0, 0, W, H).data; /* grey once; everything below reads this */ var g = new Float32Array(W * H); for (var i = 0, p = 0; i < data.length; i += 4, p++) { g[p] = (data[i] * 0.299 + data[i + 1] * 0.587 + data[i + 2] * 0.114); } var at = function (x, y) { return g[y * W + x]; }; /* Scan inward from each side along several lines and record where brightness first changes sharply. The median of those crossings is the edge; the spread of them is how tilted the card is. */ function edgeScan(fixedVals, from, to, horizontal) { var hits = []; for (var k = 0; k < fixedVals.length; k++) { var f = fixedVals[k]; var prev = horizontal ? at(from, f) : at(f, from); var step = to > from ? 1 : -1; for (var v = from + step; v !== to; v += step) { var cur = horizontal ? at(v, f) : at(f, v); if (Math.abs(cur - prev) > 26) { hits.push(v); break; } prev = cur; } } if (hits.length < 2) return null; hits.sort(function (a, b) { return a - b; }); return { med: hits[Math.floor(hits.length / 2)], first: hits[0], last: hits[hits.length - 1], n: hits.length }; } var ys = [Math.round(H * .25), Math.round(H * .4), Math.round(H * .5), Math.round(H * .6), Math.round(H * .75)]; var xs = [Math.round(W * .25), Math.round(W * .4), Math.round(W * .5), Math.round(W * .6), Math.round(W * .75)]; var L = edgeScan(ys, 1, W - 2, true); var R = edgeScan(ys, W - 2, 1, true); var T = edgeScan(xs, 1, H - 2, false); var B = edgeScan(xs, H - 2, 1, false); if (!L || !R || !T || !B) return { found: false }; var left = L.med, right = R.med, top = T.med, bottom = B.med; var cw = right - left, ch = bottom - top; if (cw < W * 0.2 || ch < H * 0.2) return { found: false }; /* Rotation from how much the two vertical edges drift across the lines we scanned. Same idea horizontally; the larger wins. */ var dy = (ys[ys.length - 1] - ys[0]) || 1; var rotV = Math.atan2((L.last - L.first), dy) * 180 / Math.PI; var dx = (xs[xs.length - 1] - xs[0]) || 1; var rotH = Math.atan2((T.last - T.first), dx) * 180 / Math.PI; var rotation = Math.abs(rotV) > Math.abs(rotH) ? rotV : rotH; /* Sharpness: mean absolute Laplacian inside the card. */ var sharp = 0, sN = 0, glare = 0, gN = 0; var x0 = Math.max(2, Math.round(left + cw * .12)), x1 = Math.min(W - 3, Math.round(right - cw * .12)); var y0 = Math.max(2, Math.round(top + ch * .12)), y1 = Math.min(H - 3, Math.round(bottom - ch * .12)); for (var y = y0; y < y1; y += 2) { for (var x = x0; x < x1; x += 2) { var c = at(x, y); sharp += Math.abs(4 * c - at(x - 1, y) - at(x + 1, y) - at(x, y - 1) - at(x, y + 1)); sN++; if (c > 248) glare++; gN++; } } sharp = sN ? sharp / sN : 0; var glareFrac = gN ? glare / gN : 0; var coverage = (cw * ch) / (W * H); var aspect = ch / Math.max(1, cw); return { found: true, box: { left: left / W, right: right / W, top: top / H, bottom: bottom / H }, coverage: coverage, rotation: rotation, sharp: sharp, glare: glareFrac, aspect: aspect }; } /* What is wrong, in the order a person would fix it. */ function verdict(a) { if (!a || !a.found) return { ok: false, msg: 'Place the card on a plain surface', checks: [] }; var checks = []; var whole = a.box.left > 0.02 && a.box.right < 0.98 && a.box.top > 0.02 && a.box.bottom < 0.98; var size = a.coverage >= LOCK.minCoverage && a.coverage <= LOCK.maxCoverage; var square = Math.abs(a.rotation) <= LOCK.maxRotation; var focus = a.sharp >= LOCK.minSharp; var clean = a.glare <= LOCK.maxGlare; checks.push(['Whole card in frame', whole]); checks.push(['Close enough', size]); checks.push(['Square to the camera', square]); checks.push(['In focus', focus]); checks.push(['Glare under control', clean]); var msg = !whole ? 'Move back — the card is touching the edge' : !size ? (a.coverage < LOCK.minCoverage ? 'Move closer' : 'Move back a little') : !square ? 'Straighten the card' : !focus ? 'Hold still — it is soft' : !clean ? 'Tilt away from the light — glare on the surface' : 'Hold it there'; return { ok: whole && size && square && focus && clean, msg: msg, checks: checks, a: a }; } /* ── capture ─────────────────────────────────────────────────── */ /* Rotate by the measured angle, crop to the detected card, output a fixed-size upright image. A small rotation is corrected; a large one was refused before we got here. */ function grab(video, a) { var vw = video.videoWidth, vh = video.videoHeight; var full = document.createElement('canvas'); full.width = vw; full.height = vh; var fx = full.getContext('2d'); fx.translate(vw / 2, vh / 2); fx.rotate(-(a.rotation || 0) * Math.PI / 180); fx.translate(-vw / 2, -vh / 2); fx.drawImage(video, 0, 0); var sx = a.box.left * vw, sy = a.box.top * vh; var sw = (a.box.right - a.box.left) * vw, sh = (a.box.bottom - a.box.top) * vh; var out = document.createElement('canvas'); out.width = OUT_W; out.height = OUT_H; out.getContext('2d').drawImage(full, sx, sy, sw, sh, 0, 0, OUT_W, OUT_H); return out; } function toBlob(canvas) { return new Promise(function (res) { canvas.toBlob(res, 'image/jpeg', 0.92); }); } /* ── MEASUREMENT, WHEN THE MAT IS IN SHOT ──────────────────────── This is the only reason the printed mat exists. A photograph on its own has no scale: 40 pixels of border could be 2mm or 6mm, so every centring judgement from a phone is really a guess dressed as a number. The 20mm calibration square fixes that -- find it in the same photo and you know how many pixels a millimetre is. Then two things become real: 1. A SANITY CHECK. A card is 63.5 x 88.9mm. If the measured card comes out 61mm, the print was not at 100% and every measurement below is wrong -- so it says so and stops, rather than reporting confident nonsense from a shrunk sheet. 2. CENTRING, measured. Scan in from each card edge to the first strong change: that is where the border meets the artwork. Compare left against right and top against bottom. WHERE IT REFUSES, and these matter more than where it works: - no square found: no scale, no measurement - card size disagrees with 63.5 x 88.9 by more than 4%: the print scale is wrong, or the card is not standard size - no clear border: full-bleed cards, dark borders on dark artwork, chrome. Roughly half of modern cards have no border to measure, and inventing one would be the worst outcome here. It reports the numbers and what range they are consistent with. It does not turn them into a grade -- PSA measures on the slab, from the card itself, and a phone on a kitchen table is not that. */ function measureWithMat(canvas, cardBox) { var W = canvas.width, H = canvas.height; var ctx = canvas.getContext('2d', { willReadFrequently: true }); var img = ctx.getImageData(0, 0, W, H).data; var lum = function (x, y) { var i = (y * W + x) * 4; return img[i] * 0.299 + img[i + 1] * 0.587 + img[i + 2] * 0.114; }; /* ---- 1. the calibration square ---- */ /* Coarse grid first: find the darkest patch, then grow it while it stays dark. Cheap, and a solid black square is the darkest thing on a white sheet by a distance. */ var step = Math.max(4, Math.round(W / 160)); var best = null; for (var y = step; y < H - step; y += step) { for (var x = step; x < W - step; x += step) { var v = lum(x, y); if (v < 60 && (!best || v < best.v)) best = { x: x, y: y, v: v }; } } if (!best) return { ok: false, reason: 'no_square' }; function grow(dx, dy) { var x = best.x, y = best.y, n = 0; while (x > 0 && x < W - 1 && y > 0 && y < H - 1 && lum(x, y) < 90 && n < Math.max(W, H)) { x += dx; y += dy; n++; } return n; } var left = grow(-1, 0), right = grow(1, 0), up = grow(0, -1), down = grow(0, 1); var sqW = left + right, sqH = up + down; if (sqW < 12 || sqH < 12) return { ok: false, reason: 'no_square' }; var squareness = Math.min(sqW, sqH) / Math.max(sqW, sqH); if (squareness < 0.8) return { ok: false, reason: 'no_square' }; var pxPerMm = ((sqW + sqH) / 2) / 20; // the square is 20mm /* ---- 2. does the card measure like a card? ---- */ var cardWpx = (cardBox.right - cardBox.left) * W; var cardHpx = (cardBox.bottom - cardBox.top) * H; var cardWmm = cardWpx / pxPerMm, cardHmm = cardHpx / pxPerMm; var wErr = Math.abs(cardWmm - 63.5) / 63.5; var hErr = Math.abs(cardHmm - 88.9) / 88.9; /* 2.5%, not 4%. "Fit to page" on US Letter shrinks an A4-proportioned sheet by about 4%, which a 4% tolerance waves straight through -- and that is the single most likely way somebody prints this wrong. A real 100% print lands inside 1%, so the tighter bar costs nothing and catches the common mistake. */ if (wErr > 0.025 || hErr > 0.025) { return { ok: false, reason: 'scale_off', /* which way, because "reprint" is useless without it */ printedTooSmall: cardWmm > 63.5, cardMm: { w: +cardWmm.toFixed(1), h: +cardHmm.toFixed(1) }, pxPerMm: +pxPerMm.toFixed(2) }; } /* ---- 3. the printed border, if this card has one ---- */ var x0 = Math.round(cardBox.left * W), x1 = Math.round(cardBox.right * W); var y0 = Math.round(cardBox.top * H), y1 = Math.round(cardBox.bottom * H); /* Walk in from a card edge until brightness changes hard: that is border meeting artwork. Sampled on several lines and taking the median keeps one dark logo from moving the answer. */ function borderWidth(fixedList, from, dir, horizontal) { var widths = []; for (var i = 0; i < fixedList.length; i++) { var f = fixedList[i]; var base = horizontal ? lum(from + dir * 3, f) : lum(f, from + dir * 3); for (var d = 4; d < 140; d++) { var cur = horizontal ? lum(from + dir * d, f) : lum(f, from + dir * d); if (Math.abs(cur - base) > 40) { widths.push(d); break; } } } if (widths.length < 3) return null; widths.sort(function (a, b) { return a - b; }); return widths[Math.floor(widths.length / 2)]; } var ysamp = [], xsamp = []; for (var k = 1; k <= 5; k++) { ysamp.push(Math.round(y0 + (y1 - y0) * k / 6)); xsamp.push(Math.round(x0 + (x1 - x0) * k / 6)); } var bl = borderWidth(ysamp, x0, +1, true); var br = borderWidth(ysamp, x1, -1, true); var bt = borderWidth(xsamp, y0, +1, false); var bb = borderWidth(xsamp, y1, -1, false); var out = { ok: true, pxPerMm: +pxPerMm.toFixed(2), cardMm: { w: +cardWmm.toFixed(1), h: +cardHmm.toFixed(1) }, centering: null }; if (bl && br && bt && bb) { var lr = bl / (bl + br), tb = bt / (bt + bb); /* A border under a millimetre is thinner than this method can honestly resolve from a phone photo. */ var thin = Math.min(bl, br, bt, bb) / pxPerMm < 1.0; out.centering = { leftRight: Math.round(lr * 100) + '/' + Math.round((1 - lr) * 100), topBottom: Math.round(tb * 100) + '/' + Math.round((1 - tb) * 100), leftMm: +(bl / pxPerMm).toFixed(1), rightMm: +(br / pxPerMm).toFixed(1), topMm: +(bt / pxPerMm).toFixed(1), bottomMm: +(bb / pxPerMm).toFixed(1), worst: Math.round(Math.max(lr, 1 - lr, tb, 1 - tb) * 100), reliable: !thin }; } else { out.borderless = true; // full bleed, or a border the photo cannot separate } return out; } /* ── the flow ────────────────────────────────────────────────── */ var STEPS = [ { key: 'front', title: '1 — Front', hint: 'Card flat, phone directly above, whole card in the frame.' }, { key: 'back', title: '2 — Back', hint: 'Flip it over and line it up the same way. The back is where centring and edge wear show.' } ]; function open(opts) { opts = opts || {}; var onDone = opts.onDone || function () {}; var onEvent = opts.onEvent || function () {}; var wantSurface = opts.surface !== false; var shots = {}, stepIdx = 0, stream = null, raf = 0, lastVerdict = null, holdFrames = 0; var root = el('div', 'gf-root'); root.innerHTML = '