feat: harden pipeline UX — preprocess, rembg alpha, remix/lightbox

Downscale to 2000px JPEG before rembg, random blend opacity, timeout
retries, per-variant remix prefill, lightbox, and longer SFTP idle.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Frank Schwenk
2026-07-16 23:35:06 +02:00
parent 8ef123393a
commit 83d4468b69
16 changed files with 594 additions and 50 deletions
+12
View File
@@ -13,9 +13,21 @@ DATA_HOST_DIR=./data
# Spec asked for 121212 which exceeds TCP max (65535). Use 12121. # Spec asked for 121212 which exceeds TCP max (65535). Use 12121.
SFTP_HOST_PORT=12121 SFTP_HOST_PORT=12121
# Minutes until SFTPGo closes an idle connection. Android/SSHJ clients
# often sit idle after upload; the default 15m shows up as a spurious EOF.
SFTPGO_IDLE_TIMEOUT=120
# --- Pipeline tuning (see make_random.py for background) ----------------- # --- Pipeline tuning (see make_random.py for background) -----------------
OUTPUT_COUNT=3 OUTPUT_COUNT=3
# Remix-form default / legacy; auto variants use the min/max range below.
BLEND_OPACITY=30% BLEND_OPACITY=30%
BLEND_OPACITY_MIN=10
BLEND_OPACITY_MAX=50
FILTER_TIMEOUT=120 FILTER_TIMEOUT=120
FILTER_TIMEOUT_LONG=300
MAX_FILTER_ATTEMPTS=8 MAX_FILTER_ATTEMPTS=8
MAX_EDGE_PX=2000
# birefnet-general OOMs on the ~8GB box; u2net + alpha ≈ `rembg i -a`
REMBG_MODEL=u2net
REMBG_ALPHA=1
NICE_LEVEL=18 NICE_LEVEL=18
+7
View File
@@ -4,6 +4,13 @@
# Runtime data (bind-mounted, never belongs in git) # Runtime data (bind-mounted, never belongs in git)
/data/ /data/
# Local rembg/withoutbg comparison scratch
/compare-bg/inputs/
/compare-bg/outputs/
/compare-bg/model-cache/
/compare-bg/compare.log
.venv-compare/
# Python # Python
__pycache__/ __pycache__/
*.pyc *.pyc
+4 -2
View File
@@ -25,6 +25,7 @@ ARG GMIC_DEB_URL=https://gmic.eu/get_file.php?file=linux/gmic_4.0.2_debian12_boo
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \ ca-certificates \
curl \ curl \
imagemagick \
python3 \ python3 \
python3-pip \ python3-pip \
&& curl -fsSL "$GMIC_DEB_URL" -o /tmp/gmic.deb \ && curl -fsSL "$GMIC_DEB_URL" -o /tmp/gmic.deb \
@@ -49,9 +50,10 @@ RUN pip install --break-system-packages -r requirements.txt
COPY app ./app COPY app ./app
# rembg downloads its ONNX model (u2net, ~176MB) from GitHub on first use # rembg downloads its ONNX model (default u2net) from GitHub on first use
# and caches it under $HOME/.u2net — persisted via the `rembg_cache` # and caches it under $HOME/.u2net — persisted via the `rembg_cache`
# volume in compose.yml so it survives container restarts. # volume in compose.yml so it survives container restarts. Alpha matting
# is enabled by default (REMBG_ALPHA=1).
# Overridden per-service in compose.yml (`worker` -> python -m app.worker, # Overridden per-service in compose.yml (`worker` -> python -m app.worker,
# `web` -> uvicorn app.main:app). # `web` -> uvicorn app.main:app).
+18 -2
View File
@@ -38,18 +38,34 @@ FILTERS_JSON = ASSETS_DIR / "filters.json"
# --- Processing ---------------------------------------------------------- # --- Processing ----------------------------------------------------------
OUTPUT_COUNT = _int_env("OUTPUT_COUNT", 3) OUTPUT_COUNT = _int_env("OUTPUT_COUNT", 3)
# Fallback / remix default. Auto variants pick randomly in
# [BLEND_OPACITY_MIN, BLEND_OPACITY_MAX] instead.
BLEND_OPACITY = os.environ.get("BLEND_OPACITY", "30%") BLEND_OPACITY = os.environ.get("BLEND_OPACITY", "30%")
BLEND_OPACITY_MIN = _int_env("BLEND_OPACITY_MIN", 10)
BLEND_OPACITY_MAX = _int_env("BLEND_OPACITY_MAX", 50)
FILTER_TIMEOUT = _int_env("FILTER_TIMEOUT", 120) FILTER_TIMEOUT = _int_env("FILTER_TIMEOUT", 120)
# Second-chance timeout for variants that hit FILTER_TIMEOUT during the
# normal pass — retried once at the end of the job (not during filter probes).
FILTER_TIMEOUT_LONG = _int_env("FILTER_TIMEOUT_LONG", 300)
MAX_FILTER_ATTEMPTS = _int_env("MAX_FILTER_ATTEMPTS", 8) MAX_FILTER_ATTEMPTS = _int_env("MAX_FILTER_ATTEMPTS", 8)
# Generous on purpose: the very first rembg call also downloads the ~176MB # Longest edge after preprocess (ImageMagick). Keeps rembg/gmic sane on
# u2net model, which can take a while depending on the link. # large camera JPEGs.
MAX_EDGE_PX = _int_env("MAX_EDGE_PX", 2000)
# Generous on purpose: the very first rembg call also downloads the model,
# which can take a while depending on the link.
REMBG_TIMEOUT = _int_env("REMBG_TIMEOUT", 600) REMBG_TIMEOUT = _int_env("REMBG_TIMEOUT", 600)
# birefnet-general OOMs on the ~8GB event box; u2net + alpha matting fits
# and is the practical equivalent of `rembg i -a`.
REMBG_MODEL = os.environ.get("REMBG_MODEL", "u2net")
REMBG_ALPHA = os.environ.get("REMBG_ALPHA", "1").strip().lower() in {"1", "true", "yes", "on"}
NICE_LEVEL = _int_env("NICE_LEVEL", 18) NICE_LEVEL = _int_env("NICE_LEVEL", 18)
# gmic CLI binary, override for local dev if not on PATH. rembg has no # gmic CLI binary, override for local dev if not on PATH. rembg has no
# equivalent here — it runs via `python -m app.rembg_cli` (see pipeline.py # equivalent here — it runs via `python -m app.rembg_cli` (see pipeline.py
# run_rembg / app/rembg_cli.py), not a standalone binary. # run_rembg / app/rembg_cli.py), not a standalone binary.
GMIC_BIN = os.environ.get("GMIC_BIN", "gmic") GMIC_BIN = os.environ.get("GMIC_BIN", "gmic")
# ImageMagick binary (`magick` on IM7, `convert` on IM6). Empty = auto-detect.
MAGICK_BIN = os.environ.get("MAGICK_BIN", "")
# Fixed post-processing chain applied to every composed variant, ported # Fixed post-processing chain applied to every composed variant, ported
# verbatim from make_random.py. # verbatim from make_random.py.
+40 -2
View File
@@ -8,7 +8,7 @@ from pathlib import Path
from typing import Any from typing import Any
from urllib.parse import quote from urllib.parse import quote
from fastapi import FastAPI, Form, HTTPException, Request from fastapi import FastAPI, Form, HTTPException, Query, Request
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
@@ -119,13 +119,49 @@ def job_file(job_id: str, rel_path: str) -> FileResponse:
@app.get("/jobs/{job_id}/remix", response_class=HTMLResponse) @app.get("/jobs/{job_id}/remix", response_class=HTMLResponse)
def remix_form(request: Request, job_id: str, error: str | None = None) -> HTMLResponse: def remix_form(
request: Request,
job_id: str,
error: str | None = None,
from_variant: str | None = Query(None, alias="from"),
) -> HTMLResponse:
job_root = _job_root(job_id) job_root = _job_root(job_id)
if not (job_root / "rembg.png").exists(): if not (job_root / "rembg.png").exists():
raise HTTPException(status_code=409, detail="Job hat noch kein Rembg-Ergebnis, Remix noch nicht moeglich.") raise HTTPException(status_code=409, detail="Job hat noch kein Rembg-Ergebnis, Remix noch nicht moeglich.")
assets = pipeline.load_assets() assets = pipeline.load_assets()
options = remix.build_remix_options(assets) options = remix.build_remix_options(assets)
prefill: dict[str, str] = {
"bg_filter": "",
"bg_blend": "",
"fg_filter": "",
"fg_blend": "",
"opacity": config.BLEND_OPACITY if config.BLEND_OPACITY in config.OPACITY_CHOICES else "30%",
}
if from_variant:
manifest = pipeline.read_manifest(job_id) or {}
match = next((v for v in manifest.get("variants", []) if v.get("id") == from_variant), None)
if match:
opacity = match.get("blend_opacity") or prefill["opacity"]
if opacity not in config.OPACITY_CHOICES:
# Snap odd random values (e.g. 37%) to nearest offered choice.
try:
pct = int(str(opacity).rstrip("%"))
nearest = min(
config.OPACITY_CHOICES,
key=lambda c: abs(int(c.rstrip("%")) - pct),
)
opacity = nearest
except ValueError:
opacity = prefill["opacity"]
prefill = {
"bg_filter": match.get("background_filter") or "",
"bg_blend": match.get("background_blend") or "",
"fg_filter": match.get("foreground_filter") or "",
"fg_blend": match.get("foreground_blend") or "",
"opacity": opacity,
}
return templates.TemplateResponse( return templates.TemplateResponse(
"remix.html", "remix.html",
{ {
@@ -134,6 +170,8 @@ def remix_form(request: Request, job_id: str, error: str | None = None) -> HTMLR
"job_id": job_id, "job_id": job_id,
"options": options, "options": options,
"opacity_choices": config.OPACITY_CHOICES, "opacity_choices": config.OPACITY_CHOICES,
"prefill": prefill,
"from_variant": from_variant,
"error": error, "error": error,
}, },
) )
+151 -20
View File
@@ -12,14 +12,16 @@ import json
import logging import logging
import random import random
import re import re
import shutil
import subprocess import subprocess
import sys import sys
import time import time
import uuid import uuid
from dataclasses import dataclass, field from contextlib import contextmanager
from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, Iterator
from . import config from . import config
@@ -27,6 +29,9 @@ logger = logging.getLogger("livef12.pipeline")
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") _ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
# Optional override for run_gmic timeout (used by the long-retry pass).
_filter_timeout_override: int | None = None
class PipelineError(RuntimeError): class PipelineError(RuntimeError):
"""Raised when a job (or a single variant) cannot be completed.""" """Raised when a job (or a single variant) cannot be completed."""
@@ -36,6 +41,10 @@ class FilterNotFoundError(PipelineError):
"""Raised when no working filter could be picked after retries.""" """Raised when no working filter could be picked after retries."""
class FilterTimeoutError(PipelineError):
"""Raised when a gmic step hits the active filter timeout."""
@dataclass @dataclass
class FilterAssets: class FilterAssets:
background_names: list[str] background_names: list[str]
@@ -57,6 +66,31 @@ def _nice(cmd: list[str]) -> list[str]:
return ["nice", "-n", str(config.NICE_LEVEL), *cmd] return ["nice", "-n", str(config.NICE_LEVEL), *cmd]
def _active_filter_timeout() -> int:
return _filter_timeout_override if _filter_timeout_override is not None else config.FILTER_TIMEOUT
@contextmanager
def filter_timeout(seconds: int) -> Iterator[None]:
"""Temporarily override FILTER_TIMEOUT for run_gmic (long-retry pass)."""
global _filter_timeout_override
previous = _filter_timeout_override
_filter_timeout_override = seconds
try:
yield
finally:
_filter_timeout_override = previous
def _magick_bin() -> str:
if config.MAGICK_BIN:
return config.MAGICK_BIN
for name in ("magick", "convert"):
if shutil.which(name):
return name
raise PipelineError("ImageMagick nicht installiert (magick/convert fehlt)")
def load_lines(path: Path) -> list[str]: def load_lines(path: Path) -> list[str]:
if not path.exists(): if not path.exists():
raise FileNotFoundError(f"Datei nicht gefunden: {path}") raise FileNotFoundError(f"Datei nicht gefunden: {path}")
@@ -96,12 +130,13 @@ def load_assets() -> FilterAssets:
def run_gmic(args: list[str], output_image: Path) -> tuple[bool, str, float]: def run_gmic(args: list[str], output_image: Path) -> tuple[bool, str, float]:
output_image.parent.mkdir(parents=True, exist_ok=True) output_image.parent.mkdir(parents=True, exist_ok=True)
timeout = _active_filter_timeout()
cmd = _nice([config.GMIC_BIN, *args, "-o", str(output_image)]) cmd = _nice([config.GMIC_BIN, *args, "-o", str(output_image)])
start = time.monotonic() start = time.monotonic()
try: try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=config.FILTER_TIMEOUT) proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
return False, f"Timeout nach {config.FILTER_TIMEOUT}s", time.monotonic() - start return False, f"Timeout nach {timeout}s", time.monotonic() - start
elapsed = time.monotonic() - start elapsed = time.monotonic() - start
if proc.returncode != 0: if proc.returncode != 0:
err = strip_ansi((proc.stderr or proc.stdout or "").strip()) err = strip_ansi((proc.stderr or proc.stdout or "").strip())
@@ -112,6 +147,14 @@ def run_gmic(args: list[str], output_image: Path) -> tuple[bool, str, float]:
return True, "", elapsed return True, "", elapsed
def _raise_on_gmic_failure(ok: bool, err: str, label: str) -> None:
if ok:
return
if err.startswith("Timeout nach"):
raise FilterTimeoutError(f"{label}: {err}")
raise PipelineError(f"{label}: {err}")
def gmic_filter_args(image: Path, full_command: str) -> list[str]: def gmic_filter_args(image: Path, full_command: str) -> list[str]:
if " " in full_command: if " " in full_command:
name, args = full_command.split(" ", 1) name, args = full_command.split(" ", 1)
@@ -163,6 +206,67 @@ def run_rembg(input_image: Path, output_image: Path) -> tuple[bool, str]:
return True, "" return True, ""
def preprocess_original(paths: JobPaths) -> JobPaths:
"""Downscale to MAX_EDGE_PX and normalize to sRGB JPEG as `original.jpg`.
Replaces any non-jpg original in the job dir. Side-effect free for the
inbox copy — only mutates jobs/<id>/.
"""
src = paths.original
if not src.exists():
raise PipelineError(f"Original fehlt: {src}")
dest = paths.root / "original.jpg"
resize = f"{config.MAX_EDGE_PX}x{config.MAX_EDGE_PX}>"
# Write to a temp name first so we can replace an existing original.jpg
# in-place without reading/writing the same path.
tmp = paths.root / f".original_pre_{uuid.uuid4().hex[:8]}.jpg"
cmd = _nice(
[
_magick_bin(),
str(src),
"-auto-orient",
"-resize",
resize,
"-colorspace",
"sRGB",
"-quality",
"92",
str(tmp),
]
)
try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
except subprocess.TimeoutExpired as exc:
tmp.unlink(missing_ok=True)
raise PipelineError("Preprocess Timeout nach 120s") from exc
if proc.returncode != 0 or not tmp.exists() or tmp.stat().st_size == 0:
tmp.unlink(missing_ok=True)
err = strip_ansi((proc.stderr or proc.stdout or "").strip())
err = err.splitlines()[-1] if err else f"Exit code {proc.returncode}"
raise PipelineError(f"Preprocess fehlgeschlagen: {err}")
tmp.replace(dest)
if src.resolve() != dest.resolve():
src.unlink(missing_ok=True)
logger.info(
"preprocessed %s -> %s (max_edge=%d)",
src.name,
dest.name,
config.MAX_EDGE_PX,
)
return JobPaths(
root=paths.root,
original=dest,
rembg=paths.rembg,
intermediates=paths.intermediates,
variants=paths.variants,
manifest=paths.manifest,
status=paths.status,
)
def pick_working_filter( def pick_working_filter(
names: list[str], names: list[str],
commands: dict[str, str], commands: dict[str, str],
@@ -292,7 +396,10 @@ def compose_variant(
bg_mode = bg_mode or rng.choice(assets.blend_modes) bg_mode = bg_mode or rng.choice(assets.blend_modes)
fg_mode = fg_mode or rng.choice(assets.blend_modes) fg_mode = fg_mode or rng.choice(assets.blend_modes)
opacity = opacity or config.BLEND_OPACITY if opacity is None:
lo = min(config.BLEND_OPACITY_MIN, config.BLEND_OPACITY_MAX)
hi = max(config.BLEND_OPACITY_MIN, config.BLEND_OPACITY_MAX)
opacity = f"{rng.randint(lo, hi)}%"
if bg_name: if bg_name:
bg_command = assets.commands.get(bg_name) bg_command = assets.commands.get(bg_name)
@@ -319,28 +426,22 @@ def compose_variant(
paths.variants.mkdir(parents=True, exist_ok=True) paths.variants.mkdir(parents=True, exist_ok=True)
ok, err = apply_filter(paths.original, bg_command, p["bg_filtered"]) ok, err = apply_filter(paths.original, bg_command, p["bg_filtered"])
if not ok: _raise_on_gmic_failure(ok, err, "Background-Filter fehlgeschlagen")
raise PipelineError(f"Background-Filter fehlgeschlagen: {err}")
ok, err = blend_layers_opacity(paths.original, p["bg_filtered"], bg_mode, opacity, p["step1"]) ok, err = blend_layers_opacity(paths.original, p["bg_filtered"], bg_mode, opacity, p["step1"])
if not ok: _raise_on_gmic_failure(ok, err, "Background-Blend fehlgeschlagen")
raise PipelineError(f"Background-Blend fehlgeschlagen: {err}")
ok, err = alpha_composite(p["step1"], paths.rembg, p["step2"]) ok, err = alpha_composite(p["step1"], paths.rembg, p["step2"])
if not ok: _raise_on_gmic_failure(ok, err, "Rembg-Alpha fehlgeschlagen")
raise PipelineError(f"Rembg-Alpha fehlgeschlagen: {err}")
ok, err = apply_filter(paths.rembg, fg_command, p["fg_filtered"]) ok, err = apply_filter(paths.rembg, fg_command, p["fg_filtered"])
if not ok: _raise_on_gmic_failure(ok, err, "Foreground-Filter fehlgeschlagen")
raise PipelineError(f"Foreground-Filter fehlgeschlagen: {err}")
ok, err = blend_layers_opacity(p["step2"], p["fg_filtered"], fg_mode, opacity, p["composed"]) ok, err = blend_layers_opacity(p["step2"], p["fg_filtered"], fg_mode, opacity, p["composed"])
if not ok: _raise_on_gmic_failure(ok, err, "Foreground-Blend fehlgeschlagen")
raise PipelineError(f"Foreground-Blend fehlgeschlagen: {err}")
ok, err = apply_filter_chain(p["composed"], config.POST_FILTERS, p["final"], tmp_dir, variant_id) ok, err = apply_filter_chain(p["composed"], config.POST_FILTERS, p["final"], tmp_dir, variant_id)
if not ok: _raise_on_gmic_failure(ok, err, "Post-Processing fehlgeschlagen")
raise PipelineError(f"Post-Processing fehlgeschlagen: {err}")
rel = lambda path: str(path.relative_to(paths.root)) rel = lambda path: str(path.relative_to(paths.root))
return { return {
@@ -367,12 +468,16 @@ def compose_variant(
def process_job(job_id: str, source_path: Path, original_suffix: str) -> None: def process_job(job_id: str, source_path: Path, original_suffix: str) -> None:
"""Full pipeline for a freshly ingested inbox file: copy, rembg once, """Full pipeline for a freshly ingested inbox file: preprocess, rembg
generate OUTPUT_COUNT variants, write manifest + status. once, generate OUTPUT_COUNT variants, write manifest + status.
`source_path` must already be a private copy (jobs/<id>/original.*) — `source_path` must already be a private copy (jobs/<id>/original.*) —
callers (worker.py) are responsible for copying out of inbox first, so callers (worker.py) are responsible for copying out of inbox first, so
the inbox file itself is never touched here. the inbox file itself is never touched here.
Variants that hit FILTER_TIMEOUT during the normal pass are retried
once at the end with FILTER_TIMEOUT_LONG. Filter probes in
pick_working_filter stay on the short timeout (skip, don't escalate).
""" """
paths = job_paths(job_id, original_suffix) paths = job_paths(job_id, original_suffix)
paths.root.mkdir(parents=True, exist_ok=True) paths.root.mkdir(parents=True, exist_ok=True)
@@ -380,6 +485,10 @@ def process_job(job_id: str, source_path: Path, original_suffix: str) -> None:
paths.variants.mkdir(parents=True, exist_ok=True) paths.variants.mkdir(parents=True, exist_ok=True)
write_status(paths, "processing", source_file=str(source_path.name)) write_status(paths, "processing", source_file=str(source_path.name))
try:
paths = preprocess_original(paths)
manifest: dict[str, Any] = { manifest: dict[str, Any] = {
"job_id": job_id, "job_id": job_id,
"original_file": paths.original.name, "original_file": paths.original.name,
@@ -388,7 +497,6 @@ def process_job(job_id: str, source_path: Path, original_suffix: str) -> None:
"variants": [], "variants": [],
} }
try:
ok, err = run_rembg(paths.original, paths.rembg) ok, err = run_rembg(paths.original, paths.rembg)
if not ok: if not ok:
raise PipelineError(f"rembg fehlgeschlagen: {err}") raise PipelineError(f"rembg fehlgeschlagen: {err}")
@@ -396,16 +504,39 @@ def process_job(job_id: str, source_path: Path, original_suffix: str) -> None:
assets = load_assets() assets = load_assets()
rng = random.Random() rng = random.Random()
variant_errors: list[str] = [] variant_errors: list[str] = []
timeout_retries: list[str] = []
for i in range(1, config.OUTPUT_COUNT + 1): for i in range(1, config.OUTPUT_COUNT + 1):
variant_id = f"v{i}" variant_id = f"v{i}"
try: try:
entry = compose_variant(paths, assets, variant_id=variant_id, source="auto", rng=rng) entry = compose_variant(paths, assets, variant_id=variant_id, source="auto", rng=rng)
manifest["variants"].append(entry) manifest["variants"].append(entry)
write_manifest(paths, manifest) write_manifest(paths, manifest)
except FilterTimeoutError as exc:
logger.warning("[%s] variant %s timed out (will retry long): %s", job_id, variant_id, exc)
timeout_retries.append(variant_id)
except PipelineError as exc: except PipelineError as exc:
logger.error("[%s] variant %s failed: %s", job_id, variant_id, exc) logger.error("[%s] variant %s failed: %s", job_id, variant_id, exc)
variant_errors.append(f"{variant_id}: {exc}") variant_errors.append(f"{variant_id}: {exc}")
if timeout_retries:
logger.info(
"[%s] long-retry %d variant(s) with timeout=%ds: %s",
job_id,
len(timeout_retries),
config.FILTER_TIMEOUT_LONG,
", ".join(timeout_retries),
)
with filter_timeout(config.FILTER_TIMEOUT_LONG):
for variant_id in timeout_retries:
try:
entry = compose_variant(paths, assets, variant_id=variant_id, source="auto", rng=rng)
manifest["variants"].append(entry)
write_manifest(paths, manifest)
except PipelineError as exc:
logger.error("[%s] variant %s long-retry failed: %s", job_id, variant_id, exc)
variant_errors.append(f"{variant_id}: {exc}")
if not manifest["variants"]: if not manifest["variants"]:
raise PipelineError("Keine Variante erfolgreich erzeugt: " + "; ".join(variant_errors)) raise PipelineError("Keine Variante erfolgreich erzeugt: " + "; ".join(variant_errors))
+33 -2
View File
@@ -5,26 +5,57 @@ the `rembg[cli]` extra (aiohttp, gradio, watchdog, ...) just to run a
single background removal. Calling the `remove()` API directly from a single background removal. Calling the `remove()` API directly from a
tiny script keeps the image smaller while still giving pipeline.py a tiny script keeps the image smaller while still giving pipeline.py a
subprocess boundary to apply `nice` and a hard timeout to. subprocess boundary to apply `nice` and a hard timeout to.
Defaults target the event box (≈8GB RAM): u2net + alpha matting.
`birefnet-general` (~928MB weights) OOMs here; override via REMBG_MODEL
/ REMBG_ALPHA if the host has more headroom.
""" """
from __future__ import annotations from __future__ import annotations
import os
import sys import sys
import traceback
from pathlib import Path from pathlib import Path
def _env_bool(name: str, default: bool) -> bool:
raw = os.environ.get(name)
if raw is None:
return default
return raw.strip().lower() in {"1", "true", "yes", "on"}
def main() -> int: def main() -> int:
if len(sys.argv) != 3: if len(sys.argv) != 3:
print("usage: python -m app.rembg_cli <input> <output>", file=sys.stderr) print("usage: python -m app.rembg_cli <input> <output>", file=sys.stderr)
return 2 return 2
from rembg import remove # Keep model-download progress bars from polluting error parsing upstream.
os.environ.setdefault("TQDM_DISABLE", "1")
try:
from rembg import new_session, remove
input_path = Path(sys.argv[1]) input_path = Path(sys.argv[1])
output_path = Path(sys.argv[2]) output_path = Path(sys.argv[2])
output_path.parent.mkdir(parents=True, exist_ok=True) output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_bytes(remove(input_path.read_bytes()))
model = os.environ.get("REMBG_MODEL", "u2net")
alpha = _env_bool("REMBG_ALPHA", True)
session = new_session(model)
output_path.write_bytes(
remove(
input_path.read_bytes(),
session=session,
alpha_matting=alpha,
)
)
return 0 return 0
except Exception as exc: # noqa: BLE001 - surface real cause to parent process
print(f"{type(exc).__name__}: {exc}", file=sys.stderr)
traceback.print_exc(file=sys.stderr)
return 1
if __name__ == "__main__": if __name__ == "__main__":
+65
View File
@@ -326,3 +326,68 @@ h2 {
margin-top: 1.5rem; margin-top: 1.5rem;
cursor: pointer; cursor: pointer;
} }
.variant-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 0.75rem;
margin-top: 0.35rem;
}
.variant-actions .button {
padding: 0.35rem 0.75rem;
min-height: 2.25rem;
line-height: 1.4rem;
font-size: 0.85rem;
}
img.lightboxable {
cursor: zoom-in;
}
/* --- lightbox --- */
body.lightbox-open {
overflow: hidden;
}
.lightbox {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: rgba(0, 0, 0, 0.92);
}
.lightbox[hidden] {
display: none;
}
.lightbox img {
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
object-fit: contain;
aspect-ratio: auto;
background: transparent;
}
.lightbox-close {
position: absolute;
top: 0.5rem;
right: 0.75rem;
border: 0;
background: transparent;
color: var(--text);
font-size: 2rem;
line-height: 1;
cursor: pointer;
padding: 0.25rem 0.5rem;
min-height: 2.75rem;
min-width: 2.75rem;
}
+45
View File
@@ -13,5 +13,50 @@
<main> <main>
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
<div id="lightbox" class="lightbox" hidden>
<button type="button" class="lightbox-close" aria-label="Schliessen">&times;</button>
<img id="lightbox-img" alt="">
</div>
<script>
(function () {
var box = document.getElementById("lightbox");
var img = document.getElementById("lightbox-img");
if (!box || !img) return;
function openLightbox(src, alt) {
img.src = src;
img.alt = alt || "";
box.hidden = false;
document.body.classList.add("lightbox-open");
}
function closeLightbox() {
box.hidden = true;
img.removeAttribute("src");
document.body.classList.remove("lightbox-open");
}
document.addEventListener("click", function (e) {
var target = e.target;
if (!(target instanceof HTMLImageElement)) return;
if (!target.classList.contains("lightboxable")) return;
e.preventDefault();
e.stopPropagation();
openLightbox(target.currentSrc || target.src, target.alt);
});
box.addEventListener("click", function (e) {
if (e.target === box || (e.target && e.target.classList.contains("lightbox-close"))) {
closeLightbox();
}
});
document.addEventListener("keydown", function (e) {
if (e.key === "Escape" && !box.hidden) closeLightbox();
});
})();
</script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -9,7 +9,7 @@
<li class="job-card"> <li class="job-card">
<a href="/jobs/{{ job.job_id }}"> <a href="/jobs/{{ job.job_id }}">
{% if job.thumbnail %} {% if job.thumbnail %}
<img class="thumb" src="/jobs/{{ job.job_id }}/files/{{ job.thumbnail }}" alt="Variante von Job {{ job.job_id }}" loading="lazy"> <img class="thumb lightboxable" src="/jobs/{{ job.job_id }}/files/{{ job.thumbnail }}" alt="Variante von Job {{ job.job_id }}" loading="lazy">
{% else %} {% else %}
<span class="thumb thumb-placeholder status-{{ job.status }}">{{ job.status }}</span> <span class="thumb thumb-placeholder status-{{ job.status }}">{{ job.status }}</span>
{% endif %} {% endif %}
+9 -4
View File
@@ -14,7 +14,7 @@
<div class="pair-grid"> <div class="pair-grid">
{% if original_name %} {% if original_name %}
<figure> <figure>
<img src="/jobs/{{ job_id }}/files/{{ original_name }}" alt="Original" loading="lazy"> <img class="lightboxable" src="/jobs/{{ job_id }}/files/{{ original_name }}" alt="Original" loading="lazy">
<figcaption> <figcaption>
Original &middot; Original &middot;
<a href="/jobs/{{ job_id }}/files/{{ original_name }}" download>Download</a> <a href="/jobs/{{ job_id }}/files/{{ original_name }}" download>Download</a>
@@ -23,7 +23,7 @@
{% endif %} {% endif %}
{% if has_rembg %} {% if has_rembg %}
<figure> <figure>
<img src="/jobs/{{ job_id }}/files/rembg.png" alt="Freigestellt (rembg)" loading="lazy"> <img class="lightboxable" src="/jobs/{{ job_id }}/files/rembg.png" alt="Freigestellt (rembg)" loading="lazy">
<figcaption> <figcaption>
Rembg &middot; Rembg &middot;
<a href="/jobs/{{ job_id }}/files/rembg.png" download>Download</a> <a href="/jobs/{{ job_id }}/files/rembg.png" download>Download</a>
@@ -46,7 +46,7 @@
<ul class="variant-grid"> <ul class="variant-grid">
{% for v in manifest.variants %} {% for v in manifest.variants %}
<li class="variant-card"> <li class="variant-card">
<img src="/jobs/{{ job_id }}/files/{{ v.file }}" alt="Variante {{ v.id }}" loading="lazy"> <img class="lightboxable" src="/jobs/{{ job_id }}/files/{{ v.file }}" alt="Variante {{ v.id }}" loading="lazy">
<div class="variant-meta"> <div class="variant-meta">
<strong>{{ v.id }}</strong> <span class="tag">{{ v.source }}</span> <strong>{{ v.id }}</strong> <span class="tag">{{ v.source }}</span>
<dl> <dl>
@@ -54,8 +54,13 @@
<dt>FG</dt><dd>{{ v.foreground_filter }} + {{ v.foreground_blend }}</dd> <dt>FG</dt><dd>{{ v.foreground_filter }} + {{ v.foreground_blend }}</dd>
<dt>Opacity</dt><dd>{{ v.blend_opacity }}</dd> <dt>Opacity</dt><dd>{{ v.blend_opacity }}</dd>
</dl> </dl>
<div class="variant-actions">
{% if has_rembg %}
<a class="button" href="/jobs/{{ job_id }}/remix?from={{ v.id }}">Remix</a>
{% endif %}
<a href="/jobs/{{ job_id }}/files/{{ v.file }}" download>Download</a> <a href="/jobs/{{ job_id }}/files/{{ v.file }}" download>Download</a>
</div> </div>
</div>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
@@ -68,7 +73,7 @@
<ul class="intermediate-grid"> <ul class="intermediate-grid">
{% for name in intermediates %} {% for name in intermediates %}
<li> <li>
<img src="/jobs/{{ job_id }}/files/intermediates/{{ name }}" alt="{{ name }}" loading="lazy"> <img class="lightboxable" src="/jobs/{{ job_id }}/files/intermediates/{{ name }}" alt="{{ name }}" loading="lazy">
<span>{{ name }}</span> <span>{{ name }}</span>
</li> </li>
{% endfor %} {% endfor %}
+8 -5
View File
@@ -4,6 +4,9 @@
<p class="back-link"><a href="/jobs/{{ job_id }}">&larr; Zurueck zum Job</a></p> <p class="back-link"><a href="/jobs/{{ job_id }}">&larr; Zurueck zum Job</a></p>
<h1>Remix &middot; Job {{ job_id }}</h1> <h1>Remix &middot; Job {{ job_id }}</h1>
{% if from_variant %}
<p class="job-status">Vorausgefüllt von {{ from_variant }}</p>
{% endif %}
{% if error %} {% if error %}
<p class="error-box">{{ error }}</p> <p class="error-box">{{ error }}</p>
@@ -13,35 +16,35 @@
<label for="bg_filter">Background-Filter</label> <label for="bg_filter">Background-Filter</label>
<select name="bg_filter" id="bg_filter" required> <select name="bg_filter" id="bg_filter" required>
{% for name in options.background_filters %} {% for name in options.background_filters %}
<option value="{{ name }}">{{ name }}</option> <option value="{{ name }}" {% if name == prefill.bg_filter %}selected{% endif %}>{{ name }}</option>
{% endfor %} {% endfor %}
</select> </select>
<label for="bg_blend">Background-Blend</label> <label for="bg_blend">Background-Blend</label>
<select name="bg_blend" id="bg_blend" required> <select name="bg_blend" id="bg_blend" required>
{% for mode in options.blend_modes %} {% for mode in options.blend_modes %}
<option value="{{ mode }}">{{ mode }}</option> <option value="{{ mode }}" {% if mode == prefill.bg_blend %}selected{% endif %}>{{ mode }}</option>
{% endfor %} {% endfor %}
</select> </select>
<label for="fg_filter">Foreground-Filter</label> <label for="fg_filter">Foreground-Filter</label>
<select name="fg_filter" id="fg_filter" required> <select name="fg_filter" id="fg_filter" required>
{% for name in options.foreground_filters %} {% for name in options.foreground_filters %}
<option value="{{ name }}">{{ name }}</option> <option value="{{ name }}" {% if name == prefill.fg_filter %}selected{% endif %}>{{ name }}</option>
{% endfor %} {% endfor %}
</select> </select>
<label for="fg_blend">Foreground-Blend</label> <label for="fg_blend">Foreground-Blend</label>
<select name="fg_blend" id="fg_blend" required> <select name="fg_blend" id="fg_blend" required>
{% for mode in options.blend_modes %} {% for mode in options.blend_modes %}
<option value="{{ mode }}">{{ mode }}</option> <option value="{{ mode }}" {% if mode == prefill.fg_blend %}selected{% endif %}>{{ mode }}</option>
{% endfor %} {% endfor %}
</select> </select>
<label for="opacity">Blend-Opacity</label> <label for="opacity">Blend-Opacity</label>
<select name="opacity" id="opacity" required> <select name="opacity" id="opacity" required>
{% for value in opacity_choices %} {% for value in opacity_choices %}
<option value="{{ value }}" {% if value == "30%" %}selected{% endif %}>{{ value }}</option> <option value="{{ value }}" {% if value == prefill.opacity %}selected{% endif %}>{{ value }}</option>
{% endfor %} {% endfor %}
</select> </select>
+30
View File
@@ -0,0 +1,30 @@
# Background-removal comparison
Inbox originals from `live.f12.rocks` vs. backends.
## Layout
- `inputs/` — originals from boka inbox
- `outputs/` — results, naming:
```
<stem>__rembg-u2net__no-alpha.png
<stem>__rembg-u2net__alpha.png
<stem>__rembg-default__no-alpha.png
<stem>__rembg-default__alpha.png
<stem>__rembg-birefnet-general__no-alpha.png
<stem>__rembg-birefnet-general__alpha.png
<stem>__withoutbg-open-weights.png
```
- `rembg-default` = `rembg.remove()` without session (library default = u2net)
- `rembg-u2net` = explicit `new_session("u2net")` — expect identical to default
- `withoutbg` open-weights includes matting in-graph (~2GB RAM claimed)
## Re-run
```bash
./run.sh
```
Skips existing non-empty outputs. Models cached in `model-cache/`.
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# Run rembg/withoutbg comparison in Python 3.11 (host is 3.14-only).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")" && pwd)"
IMG="livef12-bg-compare:py311"
LOG="$ROOT/compare.log"
docker build -t "$IMG" - <<'EOF'
FROM python:3.11-slim-bookworm
RUN apt-get update && apt-get install -y --no-install-recommends libgl1 libglib2.0-0 \
&& rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir 'rembg>=2.0.59,<2.1' 'pillow>=10.4,<11' \
'onnxruntime>=1.19,<1.20' withoutbg
WORKDIR /work
EOF
echo "Starting comparison; log: $LOG"
docker run --rm \
--name livef12-bg-compare \
-e TQDM_DISABLE=1 \
-e COMPARE_INPUT=/data/inputs \
-e COMPARE_OUTPUT=/data/outputs \
-e HOME=/cache \
-v "$ROOT/inputs:/data/inputs:ro" \
-v "$ROOT/outputs:/data/outputs" \
-v "$ROOT/run_compare.py:/work/run_compare.py:ro" \
-v "$ROOT/model-cache:/cache" \
"$IMG" python -u /work/run_compare.py 2>&1 | tee "$LOG"
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""Compare background-removal backends on the live.f12.rocks inbox uploads.
Outputs land next to each other with explicit names so Fränky can pick a winner:
<stem>__rembg-u2net__no-alpha.png
<stem>__rembg-u2net__alpha.png
<stem>__rembg-default__no-alpha.png
<stem>__rembg-default__alpha.png
<stem>__rembg-birefnet-general__no-alpha.png
<stem>__rembg-birefnet-general__alpha.png
<stem>__withoutbg-open-weights.png
`rembg-default` = rembg.remove() with no session (library default model).
`rembg-u2net` = rembg.new_session("u2net") explicitly.
withoutbg embeds matting in its open-weights graph — one output only.
"""
from __future__ import annotations
import os
import sys
import time
import traceback
from pathlib import Path
os.environ.setdefault("TQDM_DISABLE", "1")
INPUT_DIR = Path(os.environ.get("COMPARE_INPUT", "/data/inputs"))
OUTPUT_DIR = Path(os.environ.get("COMPARE_OUTPUT", "/data/outputs"))
EXTS = {".jpg", ".jpeg", ".png", ".webp"}
def log(msg: str) -> None:
print(msg, flush=True)
def run_rembg_named(data: bytes, label: str, model: str | None, alpha: bool, out: Path) -> None:
from rembg import new_session, remove
t0 = time.monotonic()
kwargs: dict = {"alpha_matting": alpha}
if model is None:
result = remove(data, **kwargs)
model_note = "default"
else:
session = new_session(model)
result = remove(data, session=session, **kwargs)
model_note = model
out.write_bytes(result)
log(f" OK {label} model={model_note} alpha={alpha} {time.monotonic()-t0:.1f}s -> {out.name} ({out.stat().st_size} bytes)")
def run_withoutbg(path: Path, out: Path) -> None:
from withoutbg import WithoutBG
t0 = time.monotonic()
model = WithoutBG.open_weights()
result = model.remove_background(str(path))
result.save(out)
log(f" OK withoutbg-open-weights {time.monotonic()-t0:.1f}s -> {out.name} ({out.stat().st_size} bytes)")
def process_one(path: Path) -> None:
stem = path.stem
log(f"=== {path.name} ===")
data = path.read_bytes()
jobs = [
("rembg-u2net", "u2net", False),
("rembg-u2net", "u2net", True),
("rembg-default", None, False),
("rembg-default", None, True),
("rembg-birefnet-general", "birefnet-general", False),
("rembg-birefnet-general", "birefnet-general", True),
]
for label, model, alpha in jobs:
tag = "alpha" if alpha else "no-alpha"
out = OUTPUT_DIR / f"{stem}__{label}__{tag}.png"
if out.exists() and out.stat().st_size > 0:
log(f" SKIP {out.name} (exists)")
continue
try:
run_rembg_named(data, label, model, alpha, out)
except Exception as exc: # noqa: BLE001
err = OUTPUT_DIR / f"{stem}__{label}__{tag}.ERROR.txt"
err.write_text(f"{type(exc).__name__}: {exc}\n\n{traceback.format_exc()}", encoding="utf-8")
log(f" FAIL {label} alpha={alpha}: {type(exc).__name__}: {exc}")
out_w = OUTPUT_DIR / f"{stem}__withoutbg-open-weights.png"
if out_w.exists() and out_w.stat().st_size > 0:
log(f" SKIP {out_w.name} (exists)")
else:
try:
run_withoutbg(path, out_w)
except Exception as exc: # noqa: BLE001
err = OUTPUT_DIR / f"{stem}__withoutbg-open-weights.ERROR.txt"
err.write_text(f"{type(exc).__name__}: {exc}\n\n{traceback.format_exc()}", encoding="utf-8")
log(f" FAIL withoutbg: {type(exc).__name__}: {exc}")
def main() -> int:
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
inputs = sorted(p for p in INPUT_DIR.iterdir() if p.is_file() and p.suffix.lower() in EXTS)
if not inputs:
log(f"No images in {INPUT_DIR}")
return 1
log(f"Comparing {len(inputs)} image(s); outputs -> {OUTPUT_DIR}")
for path in inputs:
process_one(path)
log("DONE")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+17 -1
View File
@@ -22,6 +22,9 @@ services:
environment: environment:
SFTP_USER: ${SFTP_USER:-livef12} SFTP_USER: ${SFTP_USER:-livef12}
SFTP_PASSWORD: ${SFTP_PASSWORD:-changeme} SFTP_PASSWORD: ${SFTP_PASSWORD:-changeme}
# Minutes. Default SFTPGo is 15 — too aggressive for Android clients
# that idle after a successful upload (shows up as EOF in the logs).
SFTPGO_COMMON__IDLE_TIMEOUT: ${SFTPGO_IDLE_TIMEOUT:-120}
volumes: volumes:
- ${DATA_HOST_DIR:-./data}:/srv/sftpgo/data - ${DATA_HOST_DIR:-./data}:/srv/sftpgo/data
- sftpgo_state:/var/lib/sftpgo - sftpgo_state:/var/lib/sftpgo
@@ -47,8 +50,14 @@ services:
DATA_DIR: /data DATA_DIR: /data
OUTPUT_COUNT: ${OUTPUT_COUNT:-3} OUTPUT_COUNT: ${OUTPUT_COUNT:-3}
BLEND_OPACITY: ${BLEND_OPACITY:-30%} BLEND_OPACITY: ${BLEND_OPACITY:-30%}
BLEND_OPACITY_MIN: ${BLEND_OPACITY_MIN:-10}
BLEND_OPACITY_MAX: ${BLEND_OPACITY_MAX:-50}
FILTER_TIMEOUT: ${FILTER_TIMEOUT:-120} FILTER_TIMEOUT: ${FILTER_TIMEOUT:-120}
FILTER_TIMEOUT_LONG: ${FILTER_TIMEOUT_LONG:-300}
MAX_FILTER_ATTEMPTS: ${MAX_FILTER_ATTEMPTS:-8} MAX_FILTER_ATTEMPTS: ${MAX_FILTER_ATTEMPTS:-8}
MAX_EDGE_PX: ${MAX_EDGE_PX:-2000}
REMBG_MODEL: ${REMBG_MODEL:-u2net}
REMBG_ALPHA: ${REMBG_ALPHA:-1}
NICE_LEVEL: ${NICE_LEVEL:-18} NICE_LEVEL: ${NICE_LEVEL:-18}
volumes: volumes:
- ${DATA_HOST_DIR:-./data}:/data - ${DATA_HOST_DIR:-./data}:/data
@@ -56,8 +65,9 @@ services:
- rembg_cache:/app/.home - rembg_cache:/app/.home
# Sequential-only by design (no threads/async in worker.py); these # Sequential-only by design (no threads/async in worker.py); these
# limits just make sure gmic/rembg can't starve the host too. # limits just make sure gmic/rembg can't starve the host too.
# Keep headroom on the ~8GB host — birefnet-general needs more than we have.
cpus: "1.0" cpus: "1.0"
mem_limit: 2g mem_limit: 4g
networks: networks:
- internal - internal
@@ -71,8 +81,14 @@ services:
DATA_DIR: /data DATA_DIR: /data
OUTPUT_COUNT: ${OUTPUT_COUNT:-3} OUTPUT_COUNT: ${OUTPUT_COUNT:-3}
BLEND_OPACITY: ${BLEND_OPACITY:-30%} BLEND_OPACITY: ${BLEND_OPACITY:-30%}
BLEND_OPACITY_MIN: ${BLEND_OPACITY_MIN:-10}
BLEND_OPACITY_MAX: ${BLEND_OPACITY_MAX:-50}
FILTER_TIMEOUT: ${FILTER_TIMEOUT:-120} FILTER_TIMEOUT: ${FILTER_TIMEOUT:-120}
FILTER_TIMEOUT_LONG: ${FILTER_TIMEOUT_LONG:-300}
MAX_FILTER_ATTEMPTS: ${MAX_FILTER_ATTEMPTS:-8} MAX_FILTER_ATTEMPTS: ${MAX_FILTER_ATTEMPTS:-8}
MAX_EDGE_PX: ${MAX_EDGE_PX:-2000}
REMBG_MODEL: ${REMBG_MODEL:-u2net}
REMBG_ALPHA: ${REMBG_ALPHA:-1}
NICE_LEVEL: ${NICE_LEVEL:-18} NICE_LEVEL: ${NICE_LEVEL:-18}
SITE_TITLE: live.f12.rocks SITE_TITLE: live.f12.rocks
volumes: volumes: