← Back to all posts
AITutorial

Let's Build a Print-Ready Die-Cut Sticker SaaS from scratch in Golang & Next.js [Part 4]

Karan Kashyap

Karan Kashyap

July 3, 2026

Let's Build a Print-Ready Die-Cut Sticker SaaS from scratch in Golang & Next.js [Part 4]

Part 4: The Core Imaging Pipeline

Everything up to this point — auth, storage, the GraphQL surface — exists to get a file safely into R2 and a row safely into Postgres. This part is about what happens to the pixels: turning an arbitrary photo into a clean cutout, a die-cut path a printer can actually cut along, and a report on whether the result will print well at all.

This package has one rule that shaped every decision in it: no I/O, no queue, no network calls. core-imaging takes an image.Image in and returns a Result out. That constraint is what makes it possible to unit-test the whole pipeline with synthetic images in milliseconds, instead of needing real photos and a running worker.

The entry point

packages/core-imaging/imaging.go
go
1// packages/core-imaging/imaging.go
2type Options struct {
3 OffsetMm float64 // border width in mm, default 3.0
4 Smoothing float64 // contour smoothing [0–1], default 0.5
5 CutMode CutMode // DIE_CUT or KISS_CUT
6 PhysicalWMm float64 // sticker width in mm, for DPI/bleed
7 PhysicalHMm float64
8}
9
10type Result struct {
11 Cutout *image.NRGBA // background removed, clean alpha
12 ContourSVG string // die-cut path as SVG
13 Readiness ReadinessReport // per-check pass/warn/fail
14 Meta Meta // confidence, dimensions, DPI
15}
16
17func Process(img image.Image, opts Options) (*Result, error)

Four stages run in sequence: downscale → background removal → contour tracing → readiness checks. Every stage is also an exported function on its own, so a test can call removeBackground directly without paying for the rest of the pipeline.

Downscale first, upsample the result

A 4000×4000 photo run through per-pixel flood fill and morphological ops is slow — most of that is wasted resolution nobody can see in a sticker outline anyway. Anything over 1000px on the long side gets downscaled before processing, and only the final cutout is upsampled back to original size:

packages/core-imaging/imaging.go
go
1// packages/core-imaging/imaging.go
2const maxProcessPx = 1000
3
4processImg := rgba
5scale := 1.0
6if origW > maxProcessPx || origH > maxProcessPx {
7 scale = float64(maxProcessPx) / float64(max(origW, origH))
8 processImg = downscale(rgba, int(float64(origW)*scale), int(float64(origH)*scale))
9}

This is the difference between a 2000×2000 image taking ~60 seconds and taking under 10 — the contour only needs to be accurate to sub-millimeter precision, not to the original pixel grid.

Background removal without a vision model

No OpenCV, no ONNX runtime, no bundled model weights — background removal is a flood fill that starts at the image border and grows inward through anything close enough in color to what it finds there, entirely in pure Go:

packages/core-imaging/bgremoval.go
go
1// packages/core-imaging/bgremoval.go
2func removeBackground(src *image.NRGBA) (*image.NRGBA, float64, error) {
3 bgColor := sampleBorderColor(src)
4 mask := buildAlphaMask(src, bgColor)
5 mask = morphologicalCleanup(mask, w, h)
6 // ...apply mask as alpha channel, compute confidence
7}

The tricky part isn't the flood fill itself, it's stopping it from leaking into the subject. A sticker with text on it — something like a circular "MADE WITH LOVE" wrapped around a logo — has thin gaps between letters that a naive flood fill will happily pour through, eating a hole in the middle of the design. The fix is an edge barrier: pixels with a strong local gradient get marked as walls the flood can't cross, and those walls get dilated by a pixel so narrow gaps close up:

packages/core-imaging/bgremoval.go
go
1// packages/core-imaging/bgremoval.go
2func buildEdgeBarrier(img *image.NRGBA, w, h int, bounds image.Rectangle, threshold float64, dilateR int) []bool {
3 // Sobel-style gradient magnitude per pixel, thresholded, then dilated
4 // by dilateR so 1px letter gaps can't act as flood channels.
5}

The color tolerance for "is this background" isn't a fixed number either — it's derived per image from the variance of the border pixels themselves (mean + 2·stddev, clamped to a sane range), so a flat studio-white background gets a tight tolerance and a noisier photo gets a looser one.

A confidence score, not just a yes/no

Background removal on a low-contrast photo — subject and background nearly the same color — is going to be wrong in a way a human should check before printing. Rather than silently producing a bad cutout, the pipeline scores its own result:

packages/core-imaging/bgremoval.go
go
1// packages/core-imaging/bgremoval.go
2func computeConfidence(mask []uint8, w, h int, bgColor color.NRGBA, src *image.NRGBA) float64 {
3 fgRatio := float64(fgCount) / float64(total)
4 confidence := 1.0
5 if fgRatio > 0.9 || fgRatio < 0.05 {
6 confidence = 0.4 // removed almost nothing, or almost everything
7 } else if fgRatio > 0.8 || fgRatio < 0.1 {
8 confidence = 0.6
9 }
10 confidence -= noiseScore(mask, w, h) * 0.3 // speckled masks lose points too
11 return confidence
12}

Confidence below 0.7 sets Meta.TouchUpSuggested = true, which the API exposes on the Design.confidence field and the Studio UI turns into a dismissible "manual touch-up suggested" banner. The mask itself gets one more pass — erosion then dilation to kill speckle, then a 2px feather so the cutout edge doesn't look like it was cut with scissors.

From alpha mask to a cuttable path

The contour has to satisfy something a raw pixel mask never guarantees: a single closed loop, no self-intersections, nothing a die-cutting blade can't physically follow. The approach is convex hull, not full contour tracing — for sticker-shaped subjects (one blob, no holes) it's simpler and structurally guarantees a valid closed path:

packages/core-imaging/contour.go
go
1// packages/core-imaging/contour.go
2dilated := dilateBoolean(mask, w, h, offsetPx) // push the boundary out by the border width
3hullPts := foregroundHullPoints(dilated, w, h) // subsample foreground pixels
4hull := convexHull(hullPts) // Graham scan
5smoothed := chaikinSmooth(hull, iterations) // round off the hull's straight edges

Graham scan needs a pivot point guaranteed to be on the hull — normally the bottom-most point, but image coordinates have Y growing downward, so the pivot here is the top-most point instead; using the bottom would silently produce a degenerate partial arc instead of a full hull. Chaikin subdivision then rounds off what would otherwise be a faceted polygon, and the smoothed points get converted to cubic Bézier segments and written into a spot-color SVG layer named CutContour — the naming convention print shops expect:

packages/core-imaging/contour.go
go
1// packages/core-imaging/contour.go
2return fmt.Sprintf(`<svg ...>
3 <g id="%s" inkscape:label="%s" inkscape:groupmode="layer">
4 <path d="%s" fill="none" stroke="%s" stroke-width="0.72"/>
5 </g>
6</svg>`, cutLabel, cutLabel, pathData, strokeColor)

Four print-readiness checks

Before a design ships to a printer, four independent checks run against the cutout and get bundled into a ReadinessReport — DPI, bleed, safe area, and color gamut, each returning PASS, WARN, or FAIL with a human-readable message. DPI is the simplest and the one most likely to actually block a print:

packages/core-imaging/readiness.go
go
1// packages/core-imaging/readiness.go
2func checkDPI(bounds image.Rectangle, opts Options) (CheckResult, float64) {
3 dpi := min(width_px / (physicalWMm / 25.4), height_px / (physicalHMm / 25.4))
4 switch {
5 case dpi >= 300:
6 return CheckResult{Status: CheckPass, ...}, dpi
7 case dpi >= 150:
8 return CheckResult{Status: CheckWarn, Message: "...print may appear slightly soft."}, dpi
9 default:
10 return CheckResult{Status: CheckFail, Message: "...print will be blurry."}, dpi
11 }
12}

Bleed measures the distance from the image edge to the nearest opaque pixel and flags anything under 3mm; safe-area looks for high-gradient (i.e. "busy") content sitting inside the trim margin, where a cutting tolerance could clip it; color gamut is a saturation heuristic — bright, highly saturated pixels are the ones CMYK printing tends to mute or shift, so a high percentage of them earns a warning rather than a silent surprise on the printed sticker.

The pipeline, visually

DieCitGo - Visual Pipeline

Testing without a fixture library

There's no folder of curated product photos backing these tests — instead, regression_test.go procedurally generates around thirty synthetic cases (solid shapes, gradients, different sizes, both cut modes, a range of background colors) and asserts every one of them makes it through Process without error. It trades photographic realism for something more useful in CI: complete determinism and zero binary assets in the repo.

packages/core-imaging/regression_test.go
go
1// packages/core-imaging/regression_test.go
2cases = append(cases, regressionCase{
3 name: "clear_circle",
4 img: makeCircleImage(200, 200),
5 opts: coreimaging.Options{OffsetMm: 3, Smoothing: 0.5, PhysicalWMm: 50, PhysicalHMm: 50},
6 wantPass: true,
7})

The performance target from the pipeline's design goal — sub-10-second processing at 2000×2000 — is enforced directly as a test, not just measured by hand:

packages/core-imaging/benchmark_test.go
go
1// packages/core-imaging/benchmark_test.go
2func TestProcess_2000x2000_Under10s(t *testing.T) {
3 if testing.Short() {
4 t.Skip("skipping performance test in short mode")
5 }
6 start := time.Now()
7 result, err := coreimaging.Process(make2000x2000Image(), opts)
8 elapsed := time.Since(start)
9 if elapsed > 10*time.Second {
10 t.Errorf("processing took %v, want <10s", elapsed)
11 }
12}

testing.Short() keeps this out of the fast local loop while CI still runs it on every push.

Verify it

bash
bash
1go test ./packages/core-imaging/...
2go test -run TestProcess_2000x2000_Under10s ./packages/core-imaging/...

Both should pass green, and the second prints the actual elapsed time for the 2000×2000 case — worth watching if you touch anything in the mask or hull code, since it's the one number that quietly tells you if an "optimization" made things worse.

None of this runs against a real design yet, though — it's a library sitting idle until something calls it.

Next up — Part 5: wiring core-imaging into a durable job queue, so the worker actually runs it against uploaded files and pushes live status back to the client.

Ready to Build Something Extraordinary?

Let's discuss your idea. We'll show you how AI-powered development can compress your timeline and budget — without cutting corners.

We respond within 24 hours. No sales pitch — just a straight conversation about your project.

More from the Blog

Explore more engineering insights, case studies, and technical deep-dives.

View all posts →
Let's Build a Print-Ready Die-Cut Sticker SaaS from scratch in Golang & Next.js [Part 6]
AITutorial

Let's Build a Print-Ready Die-Cut Sticker SaaS from scratch in Golang & Next.js [Part 6]

DieCutGo Studio turns any uploaded artwork into a print-ready die-cut sticker — background removal, contour tracing, print-readiness checks, mockups, and a shareable storefront, all backed by a Go pipeline fast enough to feel instant. Over this series I'll walk through how the whole thing is built, starting today with the least glamorous but most consequential decision: how the repo itself is laid out.

Karan KashyapJul 4, 2026
Let's Build a Print-Ready Die-Cut Sticker SaaS from scratch in Golang & Next.js [Part 5]
AITutorial

Let's Build a Print-Ready Die-Cut Sticker SaaS from scratch in Golang & Next.js [Part 5]

DieCutGo Studio turns any uploaded artwork into a print-ready die-cut sticker — background removal, contour tracing, print-readiness checks, mockups, and a shareable storefront, all backed by a Go pipeline fast enough to feel instant. Over this series I'll walk through how the whole thing is built, starting today with the least glamorous but most consequential decision: how the repo itself is laid out.

Karan KashyapJul 3, 2026
Let's Build a Print-Ready Die-Cut Sticker SaaS from scratch in Golang & Next.js [Part 3]
AITutorial

Let's Build a Print-Ready Die-Cut Sticker SaaS from scratch in Golang & Next.js [Part 3]

DieCutGo Studio turns any uploaded artwork into a print-ready die-cut sticker — background removal, contour tracing, print-readiness checks, mockups, and a shareable storefront, all backed by a Go pipeline fast enough to feel instant. Over this series I'll walk through how the whole thing is built, starting today with the least glamorous but most consequential decision: how the repo itself is laid out.

Karan KashyapJul 2, 2026
Let's Build a Print-Ready Die-Cut Sticker SaaS from scratch in Golang & Next.js [Part 2]
AITutorial

Let's Build a Print-Ready Die-Cut Sticker SaaS from scratch in Golang & Next.js [Part 2]

DieCutGo Studio turns any uploaded artwork into a print-ready die-cut sticker — background removal, contour tracing, print-readiness checks, mockups, and a shareable storefront, all backed by a Go pipeline fast enough to feel instant. Over this series I'll walk through how the whole thing is built, starting today with the least glamorous but most consequential decision: how the repo itself is laid out.

Karan KashyapJul 2, 2026