Files
redefined-designs/docs/superpowers/specs/2026-09-03-background-removal-design.md
T
bermudalambandClaude Opus 5 a4fc90b559
Linting / lint (pull_request) Successful in 2m18s
SonarQube Analysis / sonarqube (pull_request) Successful in 28m0s
docs(intake): design background removal for submitted photos (#281)
The design behind #281, written before any code so the decisions can be argued with while they are still cheap to change. The implementation follows on this branch.

Six decisions, each recorded with what it rests on rather than just what it is. The one that matters most is that `model=u2net` goes on every request: the sidecar's default is `bria-rmbg`, which is licensed non-commercial, and it is reached by simply not specifying a model — a silent licensing problem that produces a perfectly good image. A test asserts the parameter is present, because nothing in the output would reveal its absence.

The other consequential one is that the submitter's tick records an intent rather than doing the work during their upload. Inline removal would make them wait, would put a CPU-heavy model run in a path anyone holding a link can trigger — the surface #227 exists to bound — and would force a choice, when the sidecar is unreachable, between failing their submission and silently ignoring what they asked for. Recording the intent means the submission always succeeds and keeps its original photo, and the cut-out arrives with the AI draft seconds later.

Everything else follows the rule the pipeline already runs on: a submission is the only irreplaceable thing here. The original is never destroyed, every failure path leaves the photo exactly as it was, and an unset REMBG_URL means the feature simply does not exist rather than that the environment is broken.

Documents what is not established too — quality on a real photograph is unknown, because the engine evaluation used a generated rectangle on a flat ground. The per-photo control and Restore original are what make a poor result survivable rather than something to prevent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 10:20:28 -05:00

7.9 KiB
Raw Blame History

Background removal for submitted photos

Issue: #281. Follows #220 (the intake pipeline) and docs/ops/image-background-removal-stack.md (the engine evaluation).

A consignor photographs an item on a kitchen table. The shop wants it on a clean transparent ground, consistent with everything else in the catalogue. This adds that, in two places: the submitter asks for it when they upload, and the admin can apply or undo it per photo while reviewing.

Decisions, and what each one rests on

The engine is a rembg sidecar, reached at http://rembg-syn:7000 (published on 32700). No Python or ONNX enters the Node image; the application makes one HTTP call. Measured 2026-09-02: 1.12.3 s warm for a 2000×1500 image.

model=u2net is sent on every request, always. This is the single most important line in this document. The image's default model is bria-rmbg, and BRIA's RMBG models are licensed non-commercial — this is a shop. The default is reached by simply not specifying one, which makes it a silent licensing problem that produces a perfectly good image. u2net is Apache-2.0, and also ten times faster and a sixth the size. A test asserts the parameter is present, because nothing about the output would reveal its absence.

The original photo is never destroyed. Removal writes a new file and points the row at it, recording where the original went; restoring swaps back. This is the rule the whole pipeline is built on — the submitter's photos are often the only copy of an item no longer in their hands — and it is why Discard in the review queue deletes nothing either. Background removal is exactly the operation that produces occasional bad results on an unusual object, so it must be undoable.

The result is a transparent PNG. The storefront has a dark theme, and a flat white background would show as a bright box behind every product in it. Writing a new file makes the JPEG-to-PNG change free, since no existing path is renamed. Cost: measured 1.7× the input JPEG's bytes, which makes #269's pagination work more valuable than it already was.

The submitter's tick is an intent, honoured by the drafting worker — not work done during their upload. This is the decision with the most consequence. Doing it inline would make the submitter wait, put a CPU-heavy model run in a path anyone holding a link can trigger (the surface #227 exists to bound), and force a choice between failing their submission and silently ignoring what they asked for when the sidecar is unreachable. Recording the intent means the submission always succeeds and keeps its original photo, and the cut-out arrives seconds later with the AI draft.

Architecture

/submit/:token          POST /api/intake/:token          drafting worker
  photos + note            item + item_images               ├─ AI draft (#223)
  ☑ remove background      item_drafts.remove_background    └─ background removal
                                                                 └─ POST rembg-syn/api/remove
Review queue            POST .../images/:imageId/remove-background
  per-photo control     POST .../images/:imageId/restore-original

Two entry points, one shared implementation. The worker's path and the admin's path both call the same module, so a cut-out obtained either way is identical and either can be undone the same way.

Configuration

REMBG_URL — optional. Absent, the feature does not exist: the submitter sees no checkbox, the admin sees no control, and the worker skips removal. This mirrors ANTHROPIC_API_KEY, and for the same reason: an unconfigured environment must be a working one, not a broken one. Both compose files carry it with an empty default so an unset variable cannot fail a deploy.

Data

Two additions:

  • item_drafts.remove_background BOOLEAN NOT NULL DEFAULT true — the submitter's intent, per submission. Default true because the checkbox defaults to ticked, and because a row created before this migration should behave like the new default rather than needing a backfill.
  • item_images.original_image_path TEXT — null until a photo has been cut out, then the path of the file it came from. Null is therefore also the answer to "can this be restored?", which keeps one fact in one place.

Intent is per submission because that is how it is expressed; the swap is per image because that is how it is undone.

The submitter's page

A checkbox above the send button, ticked by default, labelled in words a consignor will understand — describing the outcome ("remove the background from my photos") rather than the technique. It is sent as a field alongside the existing note; the route reads it the same way, defaulting to true when absent so an older client or a curl call behaves like the current default.

The review queue

A control on each photo on the draft card. Where a photo has been cut out it offers Restore original instead. Both call the endpoints above and reload the card.

Failure handling

The governing rule is the one the pipeline already follows: a submission is the only irreplaceable thing here.

What fails What happens
REMBG_URL unset No checkbox, no control, no removal. Submission unaffected.
Sidecar unreachable or times out Logged. The photo keeps its original. The submission and the draft are unaffected.
Sidecar returns a non-image Same. Nothing is written.
The admin's request fails 502 with a message saying the removal service did not answer. The row is untouched, so the photo is exactly as it was.
A cut-out is poor The admin restores the original. Nothing was lost.

Removal never fails a submission and never fails a draft. It is a separate step in the worker, after the draft is written, with its own catch — the same shape as the notification in #224.

The sidecar takes roughly 40 seconds to answer after a container start, and its first call per model downloads 168 MB. Requests therefore carry a generous timeout, and the ops document records pre-warming.

Testing

  • Unit for the pure parts: deriving the cut-out's path from the original's, and deciding whether a photo can be restored.
  • Integration against a stubbed sidecar. The stub asserts model=u2net is present, and the cases are: a successful swap, a restore, a sidecar that 500s, a sidecar that is unreachable, and a sidecar that returns something that is not an image. Each of the last three must leave the row untouched.
  • The worker path, with the intent set and unset, asserting removal happens in the first case and not the second, and that a removal failure leaves the draft ready regardless.
  • E2E scoped to the photo under test, covering the submitter's checkbox and the admin's control.

No test contacts the real sidecar. A test that does is a defect in the test — it makes the suite depend on a service that takes 40 seconds to start.

Out of scope

Quality. Nothing here establishes how u2net handles a chipped vase on a patterned rug. The evaluation used a generated rectangle on a flat ground, which says nothing about real consignor photographs. The first real submission is the test, and the admin's per-photo control plus Restore is what makes a poor result survivable rather than something to prevent.

Bulk application to the existing catalogue. The control lives on the review queue only. Applying it to already-published items is a separate piece of work and a separate decision about what happens to a live product image.

A white-matted variant. Transparent adapts to both themes, which is enough. A second rendition doubles storage and adds a path to track, and is only worth it if page weight proves to be a real problem.

Concurrency limits. The sidecar is assumed to handle one request at a time. --threads exists and was not exercised. If several photos are being processed and the NAS suffers, that is the thing to revisit — and the worker already processes submissions one at a time.