diff --git a/docs/superpowers/specs/2026-09-03-background-removal-design.md b/docs/superpowers/specs/2026-09-03-background-removal-design.md new file mode 100644 index 0000000..64bfce8 --- /dev/null +++ b/docs/superpowers/specs/2026-09-03-background-removal-design.md @@ -0,0 +1,86 @@ +# 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.1–2.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.