Drop in any image. Get back a print-ready die-cut sticker.
Everything runs in the browser. No server, no upload, no model download. Clone
it, npm install, npm run dev, drag a picture in.
For images the built-in cut-out cannot handle, it will say so rather than guess, and you can point it at a segmentation provider with your own API key. What comes back from that provider is validated before it is used.
Online sticker printers take whatever the customer sends. A vector file if you are lucky, more often a JPEG off a phone, sometimes a photograph of something drawn on a sheet of paper. Almost none of it can be printed as supplied, because a die-cut sticker needs four things the customer's file does not have:
- A subject separated from its background. Otherwise the sticker is a rectangle with a picture of somebody's desk on it.
- A cut path. The blade has to be told where to travel. That path is a vector outline, and no raster file contains one.
- A border. Cutting is mechanical and has tolerance, so the blade needs a few millimetres of margin to eat into. Cut tight to the artwork and any drift takes a slice out of the design.
- Enough resolution, and no detail too fine to survive ink spread.
So the printers employ design teams. A person opens each order, masks the subject, draws the outline, adds the border, renders a proof, and sends it back for approval. It is skilled work, it is the same work every time, and it is one of the largest variable costs in the business.
This is that job, done by the machine, in well under a second.
It does not do it as well as a person. The last section of this README is an honest account of where it falls down.
Given one image, in a single pass:
| Stage | What happens |
|---|---|
| Background | Estimates the background colour from the frame, then cuts the subject out with a connectivity-aware flood fill. |
| Edge | Solves the compositing equation on every partially covered pixel to remove the colour the old background left behind. |
| Cleanup | Deletes disconnected specks: dust, compression artefacts, shadow fragments. |
| Border | Grows a true circular offset around the artwork and fills it white. |
| Cut path | Traces the border, simplifies it, and fits smooth cubic curves to it. |
| Checks | Reports what will still go wrong on the press, with the exact pixels highlighted. |
| Export | Writes an SVG with the artwork and a CutContour path, sized in millimetres. |
If the cut-out did not work, it says so instead of handing back something that looks finished:
Every finding points at pixels:
That rule is enforced by the Issue type, which has no way to express a finding
without regions. A report that says "resolution is low" is an opinion. One that
draws a box round the soft areas is evidence the user can check in a second, and
arguing with it is cheap. So the checks return geometry, not sentences.
The naive approach is to threshold every pixel on its distance from the background colour. It fails immediately, and always in the same way: a drawing of a face on white paper has white in the eyes, so the eyes get deleted. Nearly every complaint that a background remover "ate a hole in my logo" is a per-pixel threshold.
The fix is connectivity. A flood fill seeded from the frame of the image can only reach background that is actually connected to the outside. The white in the eyes is enclosed, so it is never reached, so it survives. Being the right colour is what makes a pixel passable; being reachable is what makes it background.
Distance is measured in CIE L*a*b*, not RGB, so that "how different is this" means what a person means by it. The tolerance widens automatically in proportion to how uneven the frame turned out to be, which covers a sheet of paper under a desk lamp without the user having to find a slider.
A hard mask gives hard edges, and a hard-edged cut-out looks like it was done with pinking shears. So after the flood fill, a narrow band is taken around the boundary and coverage inside it is measured rather than thresholded. An edge pixel is physically a mixture of subject and background, so how far it has travelled from the background colour towards the local subject colour is how much of it the subject covers:
alpha = |C - B| / |F - B|
The band is defined geometrically, two pixels either side of the boundary, not by another colour tolerance. The width of an anti-aliased edge is a property of how the image was rasterised. It has nothing to do with how different the two colours happen to be, and a tolerance-shaped band gets it wrong in both directions.
This is the artefact that gives away cheap background removal: a grey rim in the colour of whatever the subject was photographed against.
It is not a mystery and it does not need a heuristic. A boundary pixel is a mixture, and that is the compositing equation:
C = a * F + (1 - a) * B
Removing the background by setting alpha and leaving the colour alone keeps the
(1 - a) * B term. Since a and B are both known by that point, the true
subject colour can simply be solved for:
F = (C - (1 - a) * B) / a
The only care needed is that the solve is ill-conditioned as a approaches
zero. Below 12% coverage the division amplifies noise by more than eight times,
so those pixels keep their observed colour and let alpha do the work. They are
nearly invisible either way.
All of this happens in linear light. Compositing is a statement about quantities of light, and doing it on gamma-encoded values makes every edge systematically too dark, which is the same grey rim arriving by a different route.
The border is a dilation of the artwork mask, built on an exact Euclidean distance transform rather than repeated 3x3 passes. Two reasons. Cost stays constant instead of growing with the radius, and a 2 mm border at 300 dpi is a 24 pixel radius. More visibly, the result is a true circle: iterated neighbourhood dilation grows a square or a diamond, and on a round sticker the corners of that square are plainly visible in the cut line.
The outline is then traced with marching squares, reduced with Ramer-Douglas-Peucker, and fitted with a centripetal Catmull-Rom spline converted to cubic Beziers.
Three decisions inside that sentence are worth pulling out, because each one has a wrong answer that looks fine until it does not:
-
Marching squares has two ambiguous cases, where the foreground occupies two diagonally opposite corners of a cell. The boundary can be drawn as two separate corners or as one connected pass, and the choice has to match the connectivity used everywhere else in the pipeline. It is resolved here to keep the foreground joined. Resolved the other way, a diagonal hairline shatters into a string of disconnected diamonds, and the cut path falls apart at exactly the places a hand-drawn design is thinnest.
-
Simplification has to interpolate, not approximate. Douglas-Peucker only ever discards points; it never invents one. So the simplified outline is a subset of the traced outline, and the tolerance is a hard bound on how far the path can be from where it should be. At 0.75 px that bound is far inside any cutter's mechanical tolerance, while the point count drops by more than an order of magnitude.
-
The spline is centripetal, not uniform. With uniform parameterisation, a short segment next to a long one makes a Catmull-Rom spline overshoot and loop back on itself. Those cusps are not a cosmetic problem: they are real cuts through the artwork. Centripetal parameterisation is provably free of cusps and self-intersections, which is the entire reason it is used here.
Ink spreads on vinyl. A stroke under about half a millimetre either drops out or bleeds into its neighbour, and it is invisible on screen, because the monitor is showing the artwork ten times larger than it will be printed.
The test is a morphological opening: erode by half the minimum stroke width, then dilate back. Anything narrower than the structuring element is destroyed by the erosion and cannot be restored, so whatever the opening fails to give back is, by definition, too thin.
The first implementation looked for ridges in the distance transform instead, which is how a medial axis is normally found. It was worse. The distance field has a small flat plateau at every convex corner, every plateau is a local maximum, and the result was that every corner of every shape got reported as a hairline. Opening has no such failure mode. It does round off sharp corners, which leaves a sliver of a pixel or two at each one, and those are dealt with by the minimum-area filter rather than by another special case.
A transparent PNG is not a print-ready sticker. The cutter has to be told where
to cut, and the convention the industry settled on is a vector path drawn in a
spot colour named CutContour. The RIP finds it by name, pulls it out to drive
the blade, and does not print it.
Plain SVG has no notion of spot colours, so the name is carried three ways at
once, because different tools look in different places and it costs nothing to
satisfy all of them: as an element id, as an inkscape:label, and in a
<metadata> block written for a human.
Physical size is declared in millimetres on the root element with the viewBox
in pixels. That pairing is what makes the file scale-correct: it opens at the
size the sticker will actually be, whatever the resolution of the artwork
inside it.
The parts I would want to be asked about.
Every finding must point at pixels. Covered above. It is the one rule the type system enforces, because it is the one that decides whether the output is useful or is just a tool having opinions.
The model is optional, and it is not trusted. Background removal by segmentation network is the obvious 2026 answer. It is not the default here, because on the input this tool is actually for, artwork on a flat or near-flat background, a well-implemented flood fill with a perceptual metric matches it, and bundling a model would cost a 40 MB download, a runtime, a cold start and the ability to work offline for every user regardless of whether they needed it.
But refusing an image is not the same as handling it, so there is a second path. The user supplies their own key, points the tool at a segmentation provider, and the returned mask is used in place of the flood fill. The design decision is what happens next: the mask is treated as untrusted input. Before it is adopted it has to be the right dimensions, cover a plausible share of the frame, and not arrive as confetti. Once adopted, every print check runs on it unchanged. If it fails, the tool says which test it failed and falls back to the local matte.
It sits in the side panel under Cut-out engine, and when the built-in matte fails the way out is offered next to the failure rather than in a panel the user has to go looking for. The first version had the panel and nothing else, and nobody found it.
That is the shape I want any model in a pipeline to have. The model is a
strategy behind an interface, its output is validated by something
deterministic, and the failure mode is a sentence to the user rather than a
ruined print run. core/alpha/provider.ts is that boundary and it is where I
would start reading.
The provider is asked once per image, not once per adjustment. The cut-out does not depend on sticker width, so re-asking on every slider move would spend the user's quota to receive the same mask back.
The key is held in memory for the session, never written to storage, and the image goes to the endpoint the user typed and nowhere else.
Refuse rather than guess. The estimator returns a confidence, and when the frame is not one colour the pipeline says so and marks the result a blocker. Producing a plausible but wrong cut silently is the worst available behaviour, because the customer only finds out after it is printed.
Holes are filled for the cut path and only for the cut path. A blade cannot cut the counter out of a letter O and leave the middle floating. But the printed artwork should still have its hole. These are two different masks that happen to be derived from the same alpha, and conflating them gets one of the two wrong. There is a test for each direction.
Everything in core/ is free of the DOM. It takes typed arrays and returns
typed arrays. That is what lets the whole pipeline run in a worker unchanged,
and what lets every algorithm be tested against hand-built fixtures in plain
Node instead of against screenshots.
Constants over knobs. Four options are exposed. Everything else is a named constant with a comment explaining the value. Exposing a tunable is a promise that the user can make a better decision about it than the code can, and for things like the ill-conditioning threshold in the decontamination solve, that is not true.
Hand-built fixtures, no golden files. Every test image is small enough to reason about pixel by pixel, so the assertions are exact values rather than tolerances chosen to make the suite pass. A 10 px square traces to a path enclosing exactly 99.5 units, not "about 100", and the half unit is the four chamfered corners. Golden-file comparison would have caught none of the four real bugs this suite caught during the build.
Worth writing down, because the fixes are more interesting than the original code.
Given a screenshot of a dark interface, the first working version returned a detailed, plausible-looking sticker that was complete nonsense, and reported nothing unusual about it. It even noted, as a piece of routine housekeeping, that it had removed three and a half thousand stray fragments.
The background estimator was not wrong. The frame of that screenshot really is one uniform colour, so confidence was high. The problem is that the content was also close to that colour, so the flood fill leaked through the whole image. Measuring the input could never have caught this. The failure is only visible in the output.
So the fix is a check on the result rather than a better estimate of the input, and the evidence turned out to be sitting in data the pipeline already had:
- piece count, because a sticker is one thing and a matte returning hundreds of pieces has found texture;
- compactness, perimeter squared over area normalised so a circle is 1, because a die line that wanders through the middle of the picture scores an order of magnitude higher than any real silhouette;
- coverage, because keeping everything or nothing is not a cut-out.
Any of those out of range is now a blocker that names the number it measured,
and the download stops being presented as a finished file. test/separability.test.ts
builds the failing screenshot from scratch and asserts the refusal.
The general lesson is the one I would repeat: a pipeline that can fail silently will, and the signal is usually already in an intermediate value that is being thrown away. Here it was being printed, as a note, in a list the user had no reason to read.
On a 1536 x 1024 image a pass took nearly five seconds. Dragging a slider queued a run per frame of the drag, and while those were grinding through the worker the interface showed nothing at all: same picture, same numbers, no indicator. Stale results were already being discarded by request id, so the code was correct, and it was still unusable.
Two changes. Option changes are debounced, so a drag produces one run at the end instead of a dozen. And the stage now dims and shows an indicator while a run is in flight, keeping the previous result on screen rather than blanking, because flashing empty on every adjustment is worse than a slightly stale picture.
Correct and unusable is a category I want to be quicker to notice.
Listing these is not modesty. Each one is a decision with a reason.
-
AI upscaling. The tool detects that an image has been enlarged and says so, but it does not fix it. Doing it properly means a super-resolution model, which contradicts the no-download decision above. Reporting honestly is worth more than a plausible hallucination of detail that was never in the file.
-
Vectorising a photograph. Turning a phone snap of a drawing into clean vector line art is a genuinely hard problem and a project of its own.
-
Busy backgrounds, without a provider. The built-in matte detects and declines them. With a key configured they go to the provider instead. What is not done is bundling a model so this works offline, for the reasons in the design decisions above.
-
CMYK and colour management. Everything here is sRGB. Real print needs a profile conversion and an out-of-gamut warning, which needs the press profile, which is not something a browser tool can guess.
-
Multi-sticker sheets and nesting. One image, one sticker. Packing several onto a sheet is a bin-packing problem with nothing to do with the rest of this.
Known rough edges, as opposed to decisions: the matte is still the slowest stage and about half of it is the Lab conversion, which could be tiled and cached; very large inputs are downscaled to 2400 px on the longest side before processing, which is invisible for stickers of any normal size but is a limit worth knowing about.
Measured on the pipeline alone, one core, no worker overhead. Source images are noisy so the flood fill cannot take a shortcut.
| Source | Total | Matte | Cut path | Checks |
|---|---|---|---|---|
| 600 x 600 | 199 ms | 86 ms | 38 ms | 20 ms |
| 1200 x 1200 | 450 ms | 181 ms | 54 ms | 104 ms |
| 2400 x 2400 | 1545 ms | 726 ms | 172 ms | 292 ms |
The sample in the screenshots above, 900 x 900, completes in about 450 ms end to end in the browser including decode and PNG encode.
Two changes account for most of the current numbers, and both were found by measuring rather than by guessing:
- The sRGB transfer function was being evaluated per channel per pixel. There are only 256 possible inputs, so it collapses into a lookup table. That alone cut the matte by more than half.
- The edge band was originally a dilation minus an erosion, which is two distance transforms. Seeding a single transform from the boundary pixels gives the same band for one.
Together: 4.1 s to 1.5 s on the largest case.
npm install
npm run dev # http://localhost:5173
npm test # 117 unit tests
npm run build # type check, then a static bundle in dist/Node 20 or newer. No other dependencies: React, Vite, TypeScript and Vitest are the entire tree.
The screenshots in this README are regenerated from the running application
rather than drawn by hand, and verify.mjs asserts the end-to-end behaviour the
unit tests cannot reach:
npm run build && npx vite preview --port 4173 &
node shot.mjs && python3 make_hero.py
node verify.mjssrc/
core/ no DOM, no React, fully unit tested
color.ts sRGB, CIE Lab, the lookup table
image.ts buffer helpers, padding, cropping, compositing
distanceTransform.ts exact Euclidean EDT (Felzenszwalb-Huttenlocher)
morphology.ts dilate, erode, connected components, hole filling
alpha/ background estimation, matting, decontamination, provider contract
contour/ marching squares, simplification, spline fitting
checks/ separability, resolution, thin strokes, near-white, islands
export/ print-ready SVG
pipeline.ts the whole job, start to finish
platform/ the only files that know about the DOM, including the provider call
workers/ runs the pipeline off the main thread
components/ React
test/ 117 tests over hand-built fixtures
- Felzenszwalb, P. and Huttenlocher, D. Distance Transforms of Sampled Functions. Theory of Computing 8 (2012), 415-428.
- Yuksel, C., Schaefer, S. and Keyser, J. Parameterization and Applications of Catmull-Rom Curves. Computer-Aided Design 43 (2011).
- Porter, T. and Duff, T. Compositing Digital Images. SIGGRAPH 1984.
MIT.




