Skip to content

feat: more accurate background removal for clothes - #278

Open
TheBlackSide243 wants to merge 2 commits into
Anyesh:mainfrom
TheBlackSide243:pr/background-removal-accuracy
Open

TheBlackSide243 wants to merge 2 commits into
Anyesh:mainfrom
TheBlackSide243:pr/background-removal-accuracy

Conversation

@TheBlackSide243

@TheBlackSide243 TheBlackSide243 commented Oct 18, 2026 •

Copy link
Copy Markdown

Description

u2net often gets clothes wrong: a white print or a light patch on a shirt is taken for
background and punched out, and bits of the bed or floor the garment lies on survive.

  • Default rembg model is now birefnet-general-lite (baked into the image like u2net was).
    On my NAS (x86, CPU only) it takes about 5 s per photo; it is heavier than u2net, so on a Pi
    it will be noticeably slower - happy to keep u2net as the default and only document the
    option if you prefer.
  • Every cut-out is cleaned (services/mask_cleanup.py, numpy/scipy, already dependencies through
    imagehash): small holes inside the garment are filled back from the original photo, loose
    specks of background are dropped (both shoes of a pair are kept), and a result that lost the
    garment altogether raises instead of wiping the photo.
  • Running the removal again starts from the backed-up original instead of the already processed
    image, and the backup is rotated together with the photo.

This is one of a set of small, independent PRs (each branches from main and can be reviewed or
dropped on its own); they only overlap in the nav/router registration lines.

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update
  • Refactoring (no functional changes)
  • CI/CD or build changes

Checklist

  • I have read the CONTRIBUTING guide
  • My code follows the project's coding style
  • I have added tests that prove my fix/feature works
  • New and existing tests pass locally
  • I have updated documentation as needed
  • My changes don't introduce new warnings or errors

Testing

  • Backend: ruff check, ruff format --check, migration heads check and the full pytest suite on top of current main (2035 passed).
  • Frontend: npm run lint, i18n:check, vocab:check, tsc --noEmit and vitest (693 passed).
  • Running on my own self-hosted instance (Docker Compose on TrueNAS, Ollama).

Test Environment

  • Docker Compose
  • Kubernetes
  • Local development

🤖 Generated with Claude Code

Summary by Sourcery

Improve clothing background removal accuracy and protect original images during repeated processing and transformations.

New Features:

  • Improve clothing cut-outs by using the more accurate birefnet-general-lite model by default and cleaning generated masks to preserve garment details while removing background artifacts.

Bug Fixes:

  • Prevent background removal from erasing the original photo when no garment is detected.
  • Ensure repeated background-removal runs use the backed-up original and preserve that backup through image rotation.

Enhancements:

  • Make original-image backups resilient to interrupted writes and failed rotations.

Documentation:

  • Update background-removal configuration and usage documentation for the new default model and mask cleanup behavior.

Tests:

  • Add coverage for mask cleanup, garment preservation, safe failure handling, repeat processing, atomic backups, and backup rotation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sourcery-ai

sourcery-ai Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

Reviewer's Guide

The PR improves clothing background removal by defaulting to the baked-in birefnet-general-lite model, cleaning provider masks to recover garment fabric and remove stray background, and making retries and rotations preserve a correctly oriented original backup.

Sequence diagram for cleaned background removal

sequenceDiagram
    participant ImageService
    participant OriginalPhoto
    participant Provider as BackgroundRemovalProvider
    participant MaskCleanup as clean_cutout
    participant Output

    ImageService->>OriginalPhoto: Image.open(source_full)
    ImageService->>Provider: remove(image)
    Provider-->>ImageService: cutout
    ImageService->>MaskCleanup: clean_cutout(image, cutout)
    alt garment missing
        MaskCleanup-->>ImageService: NoGarmentFound
        ImageService-->>Output: Keep original photo
    else valid garment
        MaskCleanup-->>ImageService: cleaned cutout
        ImageService->>Output: Composite onto background
    end
Loading

Flow diagram for retrying background removal from the original

flowchart TD
    A["Remove background requested"] --> B{"Backup original exists?"}
    B -->|Yes| C["Use backup original"]
    B -->|No| D["Use current original photo"]
    D --> E["Create backup after processing starts"]
    C --> F["Run provider and clean_cutout"]
    E --> F
    F --> G["Save cleaned cut-out"]
Loading

File-Level Changes

Change Details Files
Improve garment cutout quality with mask post-processing and safety checks.
  • Add connected-component filtering to remove small background specks while retaining separate garment pieces.
  • Fill small internal mask holes using pixels from the original image, while preserving larger openings.
  • Reject near-empty masks so failed removal cannot replace the source photo.
  • Add focused unit tests for holes, openings, specks, multiple items, empty results, and size mismatches.
backend/app/services/mask_cleanup.py
backend/app/services/image_service.py
backend/tests/test_mask_cleanup.py
Make repeated removal and image rotation operate consistently on the backed-up original.
  • Use the original backup as the source for subsequent background-removal runs.
  • Create the backup only after successful cleanup, preventing failed removals from leaving stale backups.
  • Rotate the background-removal backup together with the displayed image.
  • Test redo behavior, failed-removal backup handling, and backup rotation.
backend/app/services/image_service.py
backend/tests/test_mask_cleanup.py
Switch the default rembg model to a baked-in, clothes-focused model across runtime configuration and deployment artifacts.
  • Set birefnet-general-lite as the application, provider, container-build, Compose, and Kubernetes default.
  • Bake and document the new model while retaining documented alternatives and model-volume behavior.
  • Update background-removal documentation to describe cleanup and fallback behavior.
backend/app/config.py
backend/app/services/background_removal.py
backend/Dockerfile
docker-compose.yml
docker-compose.prod.yml
k8s/configmap.yaml
README.md

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@github-actions github-actions Bot added docker Pull requests that update docker code python Pull requests that update python code labels Oct 18, 2026

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 6 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="backend/app/services/image_service.py" line_range="323" />
<code_context>
+        backup_full = self.storage_path / f"{image_path.rsplit('.', 1)[0]}_orig.jpg"
+        if backup_full.exists():
+            backup = _flatten_to_rgb(Image.open(backup_full)).rotate(angle, expand=True)
+            backup.save(backup_full, "JPEG", quality=95)
+
         return self._save_all_sizes(rotated, image_path)
</code_context>
<issue_to_address>
**Backup and image orientations diverge**

When a backup exists and saving the rotated image or one of its variants fails after the backup save succeeds, `rotate_image` has already overwritten `backup_full`, while `_save_all_sizes` leaves the displayed image or a variant unchanged. A later restore or background-removal retry uses the rotated backup against the old image orientation.

Save the rotated backup only after `_save_all_sizes` succeeds, or restore its previous contents if saving the image or its variants fails.
</issue_to_address>

### Comment 2
<location path="backend/app/services/image_service.py" line_range="266" />
<code_context>
         backup_full = self.storage_path / backup_path
+        # A second removal starts again from the true original, so redoing a bad
+        # cut-out does not cut a cut-out.
+        source_full = backup_full if backup_full.exists() else original_full
+
+        image = Image.open(source_full).convert("RGB")
</code_context>
<issue_to_address>
**Concurrent edits leave mismatched orientations**

When a background-removal request or worker overlaps a rotation for the same item, `remove_background` reads the source before `rotate_image` updates the image and backup, then saves a cut-out in the earlier orientation while the backup has the later one. Restoring the backup changes the item's orientation unexpectedly.

Serialize rotation and background removal per item so they cannot read or write images concurrently.

Also at `backend/app/services/image_service.py:320-323`.
</issue_to_address>

### Comment 3
<location path="backend/app/services/mask_cleanup.py" line_range="17" />
<code_context>
+MIN_COVERAGE = 0.02
+# Pieces smaller than this share of the biggest one are background specks. Generous
+# enough that the second shoe of a pair or a sock is always kept.
+KEEP_RATIO = 0.05
+# Holes up to this share of the garment are fabric the model missed; bigger ones are
+# real openings, such as the space inside a bag handle.
</code_context>
<issue_to_address>
**Valid garment parts disappear**

When a legitimate disconnected garment part has less than 5% of the largest component's mask area, `clean_cutout` uses `KEEP_RATIO` to drop that component along with background specks, making the legitimate garment part transparent in the saved cut-out.

Adjust the component-filtering policy so it preserves legitimate small garment parts while removing background specks.

Also at `backend/app/services/mask_cleanup.py:50-51`.
</issue_to_address>

### Comment 4
<location path="backend/app/services/mask_cleanup.py" line_range="43" />
<code_context>
+    pixels = np.array(cut, dtype=np.uint8)
+    alpha = pixels[..., 3]
+    solid = alpha >= OPAQUE
+    if solid.mean() < MIN_COVERAGE:
+        raise NoGarmentFound("No garment found in the photo; the original was kept")
+
</code_context>
<issue_to_address>
**Small garments are rejected**

When a valid garment occupies less than 2% of the image, `clean_cutout` compares `solid.mean()` with `MIN_COVERAGE` and raises `NoGarmentFound`, so background removal fails and leaves the original photo unchanged.

Avoid rejecting a mask solely because its coverage of the entire photo is below 2%.
</issue_to_address>

### Comment 5
<location path="backend/app/services/image_service.py" line_range="275" />
<code_context>
         # First removal wins: a second removal must not overwrite the true
         # original with an already-processed image
         if not backup_full.exists():
             shutil.copy2(original_full, backup_full)

-        image = Image.open(original_full).convert("RGB")
</code_context>
<issue_to_address>
**Interrupted backup writes poison recovery**

When a process stops or a filesystem write fails during backup creation or rotation, `shutil.copy2` and `backup.save` write directly to the backup path, so a failed or interrupted write can leave a partial file that still exists. A later removal trusts that path as its source, or restore tries to open the damaged backup, leaving retries or restoration failing instead of recovering the original photo.

Write backups to temporary files, validate and flush them, then atomically replace the destination.

Also at `backend/app/services/image_service.py:323`.
</issue_to_address>

### Comment 6
<location path="backend/app/services/image_service.py" line_range="266" />
<code_context>
         backup_full = self.storage_path / backup_path
+        # A second removal starts again from the true original, so redoing a bad
+        # cut-out does not cut a cut-out.
+        source_full = backup_full if backup_full.exists() else original_full
+
+        image = Image.open(source_full).convert("RGB")
</code_context>
<issue_to_address>
**Bulk redo skips processed items**

When a user selects an item that already has a background-removal backup for bulk removal, the bulk-remove endpoint excludes items with `original_image_path` from `to_queue`, so it never reaches `ImageService.remove_background` to use the saved original; users cannot redo removal through the bulk action.

Allow bulk removal to queue already-processed items now that `remove_background` reprocesses the backup, while preserving the backup path.
</issue_to_address>

Sourcery assessment

Needs a human reviewer. 6 findings to address first, and a bad mask or model result can be saved as the clothing image, but the original photo is retained in a backup and can be restored or processed again. Reverting the code would not undo images already processed.

Blocking findings: backend/app/services/image_service.py:323, backend/app/services/image_service.py:266, backend/app/services/mask_cleanup.py:17, backend/app/services/mask_cleanup.py:43, backend/app/services/image_service.py:275, and 1 more


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

Comment thread backend/app/services/image_service.py Outdated
backup_full = self.storage_path / f"{image_path.rsplit('.', 1)[0]}_orig.jpg"
if backup_full.exists():
backup = _flatten_to_rgb(Image.open(backup_full)).rotate(angle, expand=True)
backup.save(backup_full, "JPEG", quality=95)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Backup and image orientations diverge

When a backup exists and saving the rotated image or one of its variants fails after the backup save succeeds, rotate_image has already overwritten backup_full, while _save_all_sizes leaves the displayed image or a variant unchanged. A later restore or background-removal retry uses the rotated backup against the old image orientation.

Save the rotated backup only after _save_all_sizes succeeds, or restore its previous contents if saving the image or its variants fails.

Prompt for AI agents
In `backend/app/services/image_service.py` at line 323:

**Backup and image orientations diverge**

When a backup exists and saving the rotated image or one of its variants fails after the backup save succeeds, `rotate_image` has already overwritten `backup_full`, while `_save_all_sizes` leaves the displayed image or a variant unchanged. A later restore or background-removal retry uses the rotated backup against the old image orientation.

Save the rotated backup only after `_save_all_sizes` succeeds, or restore its previous contents if saving the image or its variants fails.

backup_full = self.storage_path / backup_path
# A second removal starts again from the true original, so redoing a bad
# cut-out does not cut a cut-out.
source_full = backup_full if backup_full.exists() else original_full

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High · Concurrent edits leave mismatched orientations

When a background-removal request or worker overlaps a rotation for the same item, remove_background reads the source before rotate_image updates the image and backup, then saves a cut-out in the earlier orientation while the backup has the later one. Restoring the backup changes the item's orientation unexpectedly.

Serialize rotation and background removal per item so they cannot read or write images concurrently.

Also at backend/app/services/image_service.py:320-323.

Prompt for AI agents
In `backend/app/services/image_service.py` at line 266:

**Concurrent edits leave mismatched orientations**

When a background-removal request or worker overlaps a rotation for the same item, `remove_background` reads the source before `rotate_image` updates the image and backup, then saves a cut-out in the earlier orientation while the backup has the later one. Restoring the backup changes the item's orientation unexpectedly.

Serialize rotation and background removal per item so they cannot read or write images concurrently.

Also at `backend/app/services/image_service.py:320-323`.

Comment thread backend/app/services/mask_cleanup.py Outdated
MIN_COVERAGE = 0.02
# Pieces smaller than this share of the biggest one are background specks. Generous
# enough that the second shoe of a pair or a sock is always kept.
KEEP_RATIO = 0.05

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Valid garment parts disappear

When a legitimate disconnected garment part has less than 5% of the largest component's mask area, clean_cutout uses KEEP_RATIO to drop that component along with background specks, making the legitimate garment part transparent in the saved cut-out.

Adjust the component-filtering policy so it preserves legitimate small garment parts while removing background specks.

Also at backend/app/services/mask_cleanup.py:50-51.

Prompt for AI agents
In `backend/app/services/mask_cleanup.py` at line 17:

**Valid garment parts disappear**

When a legitimate disconnected garment part has less than 5% of the largest component's mask area, `clean_cutout` uses `KEEP_RATIO` to drop that component along with background specks, making the legitimate garment part transparent in the saved cut-out.

Adjust the component-filtering policy so it preserves legitimate small garment parts while removing background specks.

Also at `backend/app/services/mask_cleanup.py:50-51`.

pixels = np.array(cut, dtype=np.uint8)
alpha = pixels[..., 3]
solid = alpha >= OPAQUE
if solid.mean() < MIN_COVERAGE:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Small garments are rejected

When a valid garment occupies less than 2% of the image, clean_cutout compares solid.mean() with MIN_COVERAGE and raises NoGarmentFound, so background removal fails and leaves the original photo unchanged.

Avoid rejecting a mask solely because its coverage of the entire photo is below 2%.

Prompt for AI agents
In `backend/app/services/mask_cleanup.py` at line 43:

**Small garments are rejected**

When a valid garment occupies less than 2% of the image, `clean_cutout` compares `solid.mean()` with `MIN_COVERAGE` and raises `NoGarmentFound`, so background removal fails and leaves the original photo unchanged.

Avoid rejecting a mask solely because its coverage of the entire photo is below 2%.

Comment thread backend/app/services/image_service.py Outdated
# First removal wins: a second removal must not overwrite the true
# original with an already-processed image
if not backup_full.exists():
shutil.copy2(original_full, backup_full)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Interrupted backup writes poison recovery

When a process stops or a filesystem write fails during backup creation or rotation, shutil.copy2 and backup.save write directly to the backup path, so a failed or interrupted write can leave a partial file that still exists. A later removal trusts that path as its source, or restore tries to open the damaged backup, leaving retries or restoration failing instead of recovering the original photo.

Write backups to temporary files, validate and flush them, then atomically replace the destination.

Also at backend/app/services/image_service.py:323.

Prompt for AI agents
In `backend/app/services/image_service.py` at line 275:

**Interrupted backup writes poison recovery**

When a process stops or a filesystem write fails during backup creation or rotation, `shutil.copy2` and `backup.save` write directly to the backup path, so a failed or interrupted write can leave a partial file that still exists. A later removal trusts that path as its source, or restore tries to open the damaged backup, leaving retries or restoration failing instead of recovering the original photo.

Write backups to temporary files, validate and flush them, then atomically replace the destination.

Also at `backend/app/services/image_service.py:323`.

backup_full = self.storage_path / backup_path
# A second removal starts again from the true original, so redoing a bad
# cut-out does not cut a cut-out.
source_full = backup_full if backup_full.exists() else original_full

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Bulk redo skips processed items

When a user selects an item that already has a background-removal backup for bulk removal, the bulk-remove endpoint excludes items with original_image_path from to_queue, so it never reaches ImageService.remove_background to use the saved original; users cannot redo removal through the bulk action.

Allow bulk removal to queue already-processed items now that remove_background reprocesses the backup, while preserving the backup path.

Prompt for AI agents
In `backend/app/services/image_service.py` at line 266:

**Bulk redo skips processed items**

When a user selects an item that already has a background-removal backup for bulk removal, the bulk-remove endpoint excludes items with `original_image_path` from `to_queue`, so it never reaches `ImageService.remove_background` to use the saved original; users cannot redo removal through the bulk action.

Allow bulk removal to queue already-processed items now that `remove_background` reprocesses the backup, while preserving the backup path.

- Backups are written through a temporary file and swapped in, so an
  interrupted write never leaves a truncated original to restore from.
- Rotation turns the backup only after the photo and its sizes are saved.
- Smaller detached parts (straps, hoods) are kept and small garments such
  as jewellery are no longer rejected as empty cut-outs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docker Pull requests that update docker code python Pull requests that update python code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants