File size: 8,183 Bytes
a3953cb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
"""
bgremove.py β€” POST /bgremove/upload  and  GET /bgremove/result/{job_id}

INDEPENDENT of the LaMa/watermark-removal pipeline, Real-ESRGAN upscale pipeline,
and object-removal pipeline.
Do not import from jobs_store.py, upscale_jobs_store.py, schemas.py, generate.py,
inpaint_engine.py, upscale_engine.py, or any other pipeline module.

Design
------
Background removal, like upscaling, has no user input after the image is
chosen β€” so upload and inference kick-off are combined into a single POST
/bgremove/upload endpoint.

Flow
----
1. POST /bgremove/upload (multipart file)
   a. Validate content-type.
   b. EXIF-correct and convert to RGB via Pillow.
   c. Assign UUID job_id; create storage/bgremove/{folder_name}/.
   d. Save EXIF-corrected image as original.jpg.
   e. Persist BgRemoveJob(status="pending") to bgremove_jobs.json.
   f. Immediately advance status to "processing" and enqueue BackgroundTask.
   g. Return {"job_id": job_id} immediately (inference runs in background).

2. GET /bgremove/result/{job_id}  (polling)
   Returns {"job_id", "status", "result_image_url", "error_message"}.
   result_image_url is a /storage/... URL served by the existing StaticFiles
   mount in main.py β€” no extra route needed.

3. run_bgremove_job (background worker)
   Runs after the POST response is sent; has no access to the Request object.
   Receives the engine as an explicit argument.
   Updates job status to "completed" or "failed"; never raises.

Storage layout (kept entirely separate from all other storage paths)
---------------------------------------------------------------------
  storage/
    bgremove/
      {folder_name}/
        original.jpg   β€” EXIF-corrected upload (JPEG)
        result.png     β€” RGBA background-removed output (PNG, for alpha channel)

These are served as:
  /storage/bgremove/{folder_name}/original.jpg
  /storage/bgremove/{folder_name}/result.png
via the existing app.mount("/storage", StaticFiles(...)) in main.py.

Why PNG for result?
-------------------
JPEG does not support an alpha channel.  The background removal output is RGBA
(foreground pixels are opaque/semi-transparent, background pixels are
transparent).  PNG losslessly encodes all four channels, so result.png
preserves the transparency that makes background removal useful.
"""

from __future__ import annotations

import io
import traceback
import uuid
from datetime import datetime, timezone
from pathlib import Path

from fastapi import APIRouter, BackgroundTasks, File, HTTPException, Request, UploadFile
from PIL import Image, ImageOps

from app.config import STORAGE_DIR
from app.utils import job_folder_name
from app.bgremove_jobs_store import BgRemoveJob, create_job, get_job, update_job
from app.services.bgremove_engine import BaseBackgroundRemovalEngine

router = APIRouter()

# Background-removal jobs live in a dedicated sub-directory of storage/ so they
# can never collide with watermark-removal, upscale, or object-removal folders.
_BGREMOVE_STORAGE = STORAGE_DIR / "bgremove"


# ---------------------------------------------------------------------------
# Background worker
# ---------------------------------------------------------------------------

def run_bgremove_job(
    job_id:        str,
    folder_name:   str,
    original_path: Path,
    engine:        BaseBackgroundRemovalEngine,
) -> None:
    """Run BiRefNet background removal and save result.png; update job status.

    Called by FastAPI BackgroundTasks after the /bgremove/upload response is sent.
    Has no access to the Request object β€” engine is passed as an explicit arg.
    Updates status to "completed" or "failed"; never raises.

    Pipeline
    --------
    original.jpg (RGB) β†’ engine.remove_background() β†’ RGBA PIL β†’ result.png (PNG)

    The result is saved as PNG (not JPEG) because JPEG cannot encode the alpha
    channel that carries the transparency mask.
    """
    job_dir = _BGREMOVE_STORAGE / folder_name
    result_path = job_dir / "result.png"

    try:
        pil_img = Image.open(original_path).convert("RGB")

        # Run background removal β€” returns RGBA PIL Image
        rgba_img = engine.remove_background(pil_img)

        # Save as PNG to preserve the alpha channel
        rgba_img.save(str(result_path), format="PNG")

        # Build the URL path relative to the /storage mount.
        rel = result_path.relative_to(STORAGE_DIR)
        update_job(job_id, status="completed", result_image=f"/{rel.as_posix()}")

    except Exception:
        update_job(
            job_id,
            status="failed",
            error_message=traceback.format_exc(),
        )


# ---------------------------------------------------------------------------
# POST /bgremove/upload
# ---------------------------------------------------------------------------

@router.post("/upload")
async def bgremove_upload(
    file:             UploadFile,
    background_tasks: BackgroundTasks,
    request:          Request,
) -> dict:
    """Accept an image, EXIF-correct it, persist it, and kick off background removal.

    Form fields
    -----------
    file : image file (multipart)

    Returns ``{"job_id": <str>}`` immediately; background removal runs in the
    background.  Poll GET /bgremove/result/{job_id} for completion.
    """
    # --- Content-type guard --------------------------------------------------
    if not (file.content_type or "").startswith("image/"):
        raise HTTPException(
            status_code=415,
            detail=f"Expected an image file, got content-type '{file.content_type}'.",
        )

    # --- Read and EXIF-correct -----------------------------------------------
    raw_bytes = await file.read()
    if not raw_bytes:
        raise HTTPException(status_code=400, detail="Uploaded file is empty.")

    try:
        pil_img = Image.open(io.BytesIO(raw_bytes))
        pil_img = ImageOps.exif_transpose(pil_img)
        pil_img = pil_img.convert("RGB")
    except Exception as exc:
        raise HTTPException(status_code=422, detail=f"Could not decode image: {exc}")

    # --- Persist original image ----------------------------------------------
    job_id     = str(uuid.uuid4())
    created_at = datetime.now(timezone.utc)
    folder     = job_folder_name(job_id, created_at)
    job_dir    = _BGREMOVE_STORAGE / folder
    job_dir.mkdir(parents=True, exist_ok=True)

    original_path = job_dir / "original.jpg"
    pil_img.save(str(original_path), format="JPEG", quality=95)

    # --- Create job record ---------------------------------------------------
    orig_rel = original_path.relative_to(STORAGE_DIR)
    job = BgRemoveJob(
        id             = job_id,
        original_image = f"/{orig_rel.as_posix()}",
        status         = "processing",
        created_at     = created_at.isoformat(),
    )
    create_job(job)

    # --- Enqueue background removal ------------------------------------------
    engine: BaseBackgroundRemovalEngine = request.app.state.bgremove_engine

    background_tasks.add_task(
        run_bgremove_job,
        job_id        = job_id,
        folder_name   = folder,
        original_path = original_path,
        engine        = engine,
    )

    return {"job_id": job_id}


# ---------------------------------------------------------------------------
# GET /bgremove/result/{job_id}
# ---------------------------------------------------------------------------

@router.get("/result/{job_id}")
def bgremove_result(job_id: str) -> dict:
    """Return the current status of a background removal job.

    Response fields:
        job_id           : str
        status           : "pending" | "processing" | "completed" | "failed"
        result_image_url : str | null  β€” /storage/bgremove/{folder}/result.png
        error_message    : str | null
    """
    job = get_job(job_id)
    if job is None:
        raise HTTPException(
            status_code=404,
            detail=f"Background removal job '{job_id}' not found.",
        )

    return {
        "job_id":           job.id,
        "status":           job.status,
        "result_image_url": job.result_image,
        "error_message":    job.error_message,
    }