Dan Matthew

Writing · 6 Sep 2026

A stamp grader that shows its work and refuses to guess

Stamp Grader measures centering from a photo with classical CV, draws the result on your image, and treats cannot-determine as a valid answer.

#python #opencv #pyodide #showdev

Most consumer stamp apps answer "what is it worth?" with a number tuned to sell a subscription. Stamp Grader answers a smaller and more honest question: how evenly does the printed design sit inside its margins? That property is called centering, and it is one of the main things that separates a nice copy of a stamp from an ordinary one. Centering is geometry, so I measure it, draw the measurement on your photo, and let you check my work. No model, no server, no valuation.

It ships as a Python library on PyPI and as a browser app that runs the same code on your device. Here is how it is built, and how one codebase ends up in both places.

Photo one stamp, phone or scan Classical CV core OpenCV + NumPy, one module, no model Segment + classify outline Otsu, contours, rect / triangle / round Rectify + find design box perspective warp, saturation / darkness mask Margins → centering score worst-axis ratio, 0 - 100, drawn on the image Honesty gate refuse when the photo can't tell; flag low confidence "cannot measure" is a result too: same contract keys, status = unsupported / not_applicable One wheel pure-Python .whl, 24 KB same bytes, both surfaces PyPI library pip install stamp-grader CLI + measure() + JSON Browser app Pyodide runs the wheel on device, nothing uploaded Cloudflare static assets
⤢ Enlarge

Why classical CV and not a model

Centering has a definition: the gap between the design and each perforated edge, and how balanced the opposite gaps are. That is a measurement, not a classification, and a measurement can be shown. The whole core is OpenCV and NumPy in one module, src/stamp_grader/__init__.py, with no weights to ship. Every step leaves an artifact I can draw: the outline, the corners, the design box. If the score is wrong you can see where it went wrong, which is the entire point. A network that outputs "87" gives you nothing to argue with.

The pipeline

Segment. An Otsu threshold on the grey image, inverted if the corners read bright, then a morphological close. The stamp needs a contrasting background, and the app says so up front rather than failing mysteriously.

Classify the outline. Circularity, extent, solidity, and a polygon fit sort the contour into rectangle, diamond, triangle, round, or irregular. An early prototype scored a triangular stamp as a rectangle and produced a confident, meaningless number. detect_shape() exists so that never happens again.

Find corners and rectify. I fit a line to each edge from the convex hull and intersect adjacent lines. That held up across camera angles where approxPolyDP did not. A perspective warp then flattens the phone photo. A caller that already knows the corners can pass its own quad and skip segmentation entirely.

Find the design. A saturation mask for colour stamps, a darkness mask for monochrome ones, with the perforation fringe excluded so the notches are not read as ink. The margins are the gaps between that box and the stamp edge. The score is the worst-axis ratio of opposite margins, 0 to 100, so a stamp cannot hide a bad vertical behind a perfect horizontal.

The extras. Triangles, diamonds, and round stamps get their own margin logic (rays from each side, or radial from the centroid). Perforation gauge comes from autocorrelating the scalloped edge profile. A cancellation flag fires when a chunky dark mark reaches into an otherwise clean margin.

The honesty gate

The rule I kept coming back to while building this: a confident wrong answer is the worst possible output. So refusing is a first-class result, not an exception path.

Every call returns the same contract keys with a machine-readable status. measured means the number is real. unsupported means retry with a better crop. not_applicable means the question does not apply, such as a die-cut stamp that hugs its artwork and has no margin to centre. Each reason is also classed as capture or subject, so the caller knows whether a re-shoot might help or whether the stamp is simply like that.

The gauge declines past seven degrees of tilt, because the edge profile aliases and the answer would be noise dressed up as a number. The cancel detector abstains on any side that is not paper-bright. The Superb/XF/VF label is named centering_label, flagged as uncalibrated, and can be suppressed, because in an archive a bare label reads as a grade, and this is not a grading service.

"Cannot determine" is a valid answer. Silence beats a lie. Seventeen behavioural tests pin all of this down on every push, refusals included. The refusals are tested as carefully as the measurements, because they are the feature.

One wheel, two surfaces

This is the part I am most pleased with. The library is pure Python over numpy and opencv-python-headless, so the 24 KB wheel that python -m build produces is the same file committed to app/. The browser app loads Pyodide from jsDelivr, pulls in its numpy and opencv packages, writes the wheel into Pyodide's virtual filesystem, and calls stamp_grader.measure() on your photo. The annotated overlay comes back out of that filesystem as a blob. No server ever sees the image. A service worker caches the engine after the first run, so from then on it works offline.

Deploys are equally plain. wrangler.jsonc points Cloudflare Workers static assets at ./app with no Worker code at all, and a v* tag triggers PyPI Trusted Publishing over OIDC, so there is no API token to rotate. The CLI, the library, and the web app cannot disagree with each other, because they are the same bytes.

What I would still fix

Design-box stability across captures. The same stamp can score a few points apart between two photos, depending on where the design mask lands. Until there is a capture-to-capture cross-check, read a single score as approximate. The result contract is shaped so that PhilatelyOS, my larger stamp-cataloguing platform, can hand over crops and geometry someday. Today it stands alone, and that is fine.

If you collect, try it on a stamp at stamp-grader.danmat.dev. The photo never leaves your device. If you want the library, pip install stamp-grader from PyPI gets you the same measure() call, and the source is at github.com/DanMat/stamp-grader. I would like to hear where the design box goes wrong on your stamps.