From 83d4468b6991f6f87e9917bc01bf17070847376a Mon Sep 17 00:00:00 2001 From: Frank Schwenk Date: Thu, 16 Jul 2026 23:35:06 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20harden=20pipeline=20UX=20=E2=80=94=20pr?= =?UTF-8?q?eprocess,=20rembg=20alpha,=20remix/lightbox?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Downscale to 2000px JPEG before rembg, random blend opacity, timeout retries, per-variant remix prefill, lightbox, and longer SFTP idle. Co-authored-by: Cursor --- .env.example | 12 +++ .gitignore | 7 ++ Dockerfile | 6 +- app/config.py | 20 ++++- app/main.py | 42 ++++++++- app/pipeline.py | 183 ++++++++++++++++++++++++++++++++------ app/rembg_cli.py | 43 +++++++-- app/static/style.css | 65 ++++++++++++++ app/templates/base.html | 45 ++++++++++ app/templates/index.html | 2 +- app/templates/job.html | 15 ++-- app/templates/remix.html | 13 +-- compare-bg/README.md | 30 +++++++ compare-bg/run.sh | 28 ++++++ compare-bg/run_compare.py | 115 ++++++++++++++++++++++++ compose.yml | 18 +++- 16 files changed, 594 insertions(+), 50 deletions(-) create mode 100644 compare-bg/README.md create mode 100755 compare-bg/run.sh create mode 100644 compare-bg/run_compare.py diff --git a/.env.example b/.env.example index 74e531d..37c3da3 100644 --- a/.env.example +++ b/.env.example @@ -13,9 +13,21 @@ DATA_HOST_DIR=./data # Spec asked for 121212 which exceeds TCP max (65535). Use 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) ----------------- OUTPUT_COUNT=3 +# Remix-form default / legacy; auto variants use the min/max range below. BLEND_OPACITY=30% +BLEND_OPACITY_MIN=10 +BLEND_OPACITY_MAX=50 FILTER_TIMEOUT=120 +FILTER_TIMEOUT_LONG=300 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 diff --git a/.gitignore b/.gitignore index bd9f815..b36b196 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,13 @@ # Runtime data (bind-mounted, never belongs in git) /data/ +# Local rembg/withoutbg comparison scratch +/compare-bg/inputs/ +/compare-bg/outputs/ +/compare-bg/model-cache/ +/compare-bg/compare.log +.venv-compare/ + # Python __pycache__/ *.pyc diff --git a/Dockerfile b/Dockerfile index 65ef10d..8600359 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 \ ca-certificates \ curl \ + imagemagick \ python3 \ python3-pip \ && 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 -# 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` -# 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, # `web` -> uvicorn app.main:app). diff --git a/app/config.py b/app/config.py index 06ebd2a..3c095f2 100644 --- a/app/config.py +++ b/app/config.py @@ -38,18 +38,34 @@ FILTERS_JSON = ASSETS_DIR / "filters.json" # --- Processing ---------------------------------------------------------- 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_MIN = _int_env("BLEND_OPACITY_MIN", 10) +BLEND_OPACITY_MAX = _int_env("BLEND_OPACITY_MAX", 50) 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) -# Generous on purpose: the very first rembg call also downloads the ~176MB -# u2net model, which can take a while depending on the link. +# Longest edge after preprocess (ImageMagick). Keeps rembg/gmic sane on +# 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) +# 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) # 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 # run_rembg / app/rembg_cli.py), not a standalone binary. 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 # verbatim from make_random.py. diff --git a/app/main.py b/app/main.py index 6164f55..e3b62fb 100644 --- a/app/main.py +++ b/app/main.py @@ -8,7 +8,7 @@ from pathlib import Path from typing import Any 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.staticfiles import StaticFiles 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) -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) if not (job_root / "rembg.png").exists(): raise HTTPException(status_code=409, detail="Job hat noch kein Rembg-Ergebnis, Remix noch nicht moeglich.") assets = pipeline.load_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( "remix.html", { @@ -134,6 +170,8 @@ def remix_form(request: Request, job_id: str, error: str | None = None) -> HTMLR "job_id": job_id, "options": options, "opacity_choices": config.OPACITY_CHOICES, + "prefill": prefill, + "from_variant": from_variant, "error": error, }, ) diff --git a/app/pipeline.py b/app/pipeline.py index 82c91a9..e5e8768 100644 --- a/app/pipeline.py +++ b/app/pipeline.py @@ -12,14 +12,16 @@ import json import logging import random import re +import shutil import subprocess import sys import time import uuid -from dataclasses import dataclass, field +from contextlib import contextmanager +from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path -from typing import Any +from typing import Any, Iterator from . import config @@ -27,6 +29,9 @@ logger = logging.getLogger("livef12.pipeline") _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): """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.""" +class FilterTimeoutError(PipelineError): + """Raised when a gmic step hits the active filter timeout.""" + + @dataclass class FilterAssets: background_names: list[str] @@ -57,6 +66,31 @@ def _nice(cmd: list[str]) -> list[str]: 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]: if not path.exists(): 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]: output_image.parent.mkdir(parents=True, exist_ok=True) + timeout = _active_filter_timeout() cmd = _nice([config.GMIC_BIN, *args, "-o", str(output_image)]) start = time.monotonic() 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: - 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 if proc.returncode != 0: 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 +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]: if " " in full_command: name, args = full_command.split(" ", 1) @@ -163,6 +206,67 @@ def run_rembg(input_image: Path, output_image: Path) -> tuple[bool, str]: 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//. + """ + 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( names: list[str], commands: dict[str, str], @@ -292,7 +396,10 @@ def compose_variant( bg_mode = bg_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: bg_command = assets.commands.get(bg_name) @@ -319,28 +426,22 @@ def compose_variant( paths.variants.mkdir(parents=True, exist_ok=True) ok, err = apply_filter(paths.original, bg_command, p["bg_filtered"]) - if not ok: - raise PipelineError(f"Background-Filter fehlgeschlagen: {err}") + _raise_on_gmic_failure(ok, err, "Background-Filter fehlgeschlagen") ok, err = blend_layers_opacity(paths.original, p["bg_filtered"], bg_mode, opacity, p["step1"]) - if not ok: - raise PipelineError(f"Background-Blend fehlgeschlagen: {err}") + _raise_on_gmic_failure(ok, err, "Background-Blend fehlgeschlagen") ok, err = alpha_composite(p["step1"], paths.rembg, p["step2"]) - if not ok: - raise PipelineError(f"Rembg-Alpha fehlgeschlagen: {err}") + _raise_on_gmic_failure(ok, err, "Rembg-Alpha fehlgeschlagen") ok, err = apply_filter(paths.rembg, fg_command, p["fg_filtered"]) - if not ok: - raise PipelineError(f"Foreground-Filter fehlgeschlagen: {err}") + _raise_on_gmic_failure(ok, err, "Foreground-Filter fehlgeschlagen") ok, err = blend_layers_opacity(p["step2"], p["fg_filtered"], fg_mode, opacity, p["composed"]) - if not ok: - raise PipelineError(f"Foreground-Blend fehlgeschlagen: {err}") + _raise_on_gmic_failure(ok, err, "Foreground-Blend fehlgeschlagen") ok, err = apply_filter_chain(p["composed"], config.POST_FILTERS, p["final"], tmp_dir, variant_id) - if not ok: - raise PipelineError(f"Post-Processing fehlgeschlagen: {err}") + _raise_on_gmic_failure(ok, err, "Post-Processing fehlgeschlagen") rel = lambda path: str(path.relative_to(paths.root)) return { @@ -367,12 +468,16 @@ def compose_variant( def process_job(job_id: str, source_path: Path, original_suffix: str) -> None: - """Full pipeline for a freshly ingested inbox file: copy, rembg once, - generate OUTPUT_COUNT variants, write manifest + status. + """Full pipeline for a freshly ingested inbox file: preprocess, rembg + once, generate OUTPUT_COUNT variants, write manifest + status. `source_path` must already be a private copy (jobs//original.*) — callers (worker.py) are responsible for copying out of inbox first, so 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.root.mkdir(parents=True, exist_ok=True) @@ -380,15 +485,18 @@ def process_job(job_id: str, source_path: Path, original_suffix: str) -> None: paths.variants.mkdir(parents=True, exist_ok=True) write_status(paths, "processing", source_file=str(source_path.name)) - manifest: dict[str, Any] = { - "job_id": job_id, - "original_file": paths.original.name, - "rembg_file": paths.rembg.name, - "created_at": now_iso(), - "variants": [], - } try: + paths = preprocess_original(paths) + + manifest: dict[str, Any] = { + "job_id": job_id, + "original_file": paths.original.name, + "rembg_file": paths.rembg.name, + "created_at": now_iso(), + "variants": [], + } + ok, err = run_rembg(paths.original, paths.rembg) if not ok: 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() rng = random.Random() variant_errors: list[str] = [] + timeout_retries: list[str] = [] + for i in range(1, config.OUTPUT_COUNT + 1): variant_id = f"v{i}" try: entry = compose_variant(paths, assets, variant_id=variant_id, source="auto", rng=rng) manifest["variants"].append(entry) 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: logger.error("[%s] variant %s failed: %s", job_id, 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"]: raise PipelineError("Keine Variante erfolgreich erzeugt: " + "; ".join(variant_errors)) diff --git a/app/rembg_cli.py b/app/rembg_cli.py index da1a562..cf98989 100644 --- a/app/rembg_cli.py +++ b/app/rembg_cli.py @@ -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 tiny script keeps the image smaller while still giving pipeline.py a 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 +import os import sys +import traceback 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: if len(sys.argv) != 3: print("usage: python -m app.rembg_cli ", file=sys.stderr) return 2 - from rembg import remove + # Keep model-download progress bars from polluting error parsing upstream. + os.environ.setdefault("TQDM_DISABLE", "1") - input_path = Path(sys.argv[1]) - output_path = Path(sys.argv[2]) - output_path.parent.mkdir(parents=True, exist_ok=True) - output_path.write_bytes(remove(input_path.read_bytes())) - return 0 + try: + from rembg import new_session, remove + + input_path = Path(sys.argv[1]) + output_path = Path(sys.argv[2]) + output_path.parent.mkdir(parents=True, exist_ok=True) + + 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 + 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__": diff --git a/app/static/style.css b/app/static/style.css index aee65a2..c67164c 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -326,3 +326,68 @@ h2 { margin-top: 1.5rem; 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; +} diff --git a/app/templates/base.html b/app/templates/base.html index 8f6bb9d..0154cab 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -13,5 +13,50 @@
{% block content %}{% endblock %}
+ + + + diff --git a/app/templates/index.html b/app/templates/index.html index 84e9c61..64cf167 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -9,7 +9,7 @@
  • {% if job.thumbnail %} - Variante von Job {{ job.job_id }} + Variante von Job {{ job.job_id }} {% else %} {{ job.status }} {% endif %} diff --git a/app/templates/job.html b/app/templates/job.html index 7c9de1b..a1e45b4 100644 --- a/app/templates/job.html +++ b/app/templates/job.html @@ -14,7 +14,7 @@
    {% if original_name %}
    - Original + Original
    Original · Download @@ -23,7 +23,7 @@ {% endif %} {% if has_rembg %}
    - Freigestellt (rembg) + Freigestellt (rembg)
    Rembg · Download @@ -46,7 +46,7 @@
      {% for v in manifest.variants %}
    • - Variante {{ v.id }} + Variante {{ v.id }}
      {{ v.id }} {{ v.source }}
      @@ -54,7 +54,12 @@
      FG
      {{ v.foreground_filter }} + {{ v.foreground_blend }}
      Opacity
      {{ v.blend_opacity }}
      - Download +
      + {% if has_rembg %} + Remix + {% endif %} + Download +
    • {% endfor %} @@ -68,7 +73,7 @@
        {% for name in intermediates %}
      • - {{ name }} + {{ name }} {{ name }}
      • {% endfor %} diff --git a/app/templates/remix.html b/app/templates/remix.html index 38436a7..027127d 100644 --- a/app/templates/remix.html +++ b/app/templates/remix.html @@ -4,6 +4,9 @@

        Remix · Job {{ job_id }}

        + {% if from_variant %} +

        Vorausgefüllt von {{ from_variant }}

        + {% endif %} {% if error %}

        {{ error }}

        @@ -13,35 +16,35 @@ diff --git a/compare-bg/README.md b/compare-bg/README.md new file mode 100644 index 0000000..d0027eb --- /dev/null +++ b/compare-bg/README.md @@ -0,0 +1,30 @@ +# Background-removal comparison + +Inbox originals from `live.f12.rocks` vs. backends. + +## Layout + +- `inputs/` — originals from boka inbox +- `outputs/` — results, naming: + +``` +__rembg-u2net__no-alpha.png +__rembg-u2net__alpha.png +__rembg-default__no-alpha.png +__rembg-default__alpha.png +__rembg-birefnet-general__no-alpha.png +__rembg-birefnet-general__alpha.png +__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/`. diff --git a/compare-bg/run.sh b/compare-bg/run.sh new file mode 100755 index 0000000..86b6818 --- /dev/null +++ b/compare-bg/run.sh @@ -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" diff --git a/compare-bg/run_compare.py b/compare-bg/run_compare.py new file mode 100644 index 0000000..96f4e32 --- /dev/null +++ b/compare-bg/run_compare.py @@ -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: + + __rembg-u2net__no-alpha.png + __rembg-u2net__alpha.png + __rembg-default__no-alpha.png + __rembg-default__alpha.png + __rembg-birefnet-general__no-alpha.png + __rembg-birefnet-general__alpha.png + __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()) diff --git a/compose.yml b/compose.yml index 14a190f..dc01d15 100644 --- a/compose.yml +++ b/compose.yml @@ -22,6 +22,9 @@ services: environment: SFTP_USER: ${SFTP_USER:-livef12} 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: - ${DATA_HOST_DIR:-./data}:/srv/sftpgo/data - sftpgo_state:/var/lib/sftpgo @@ -47,8 +50,14 @@ services: DATA_DIR: /data OUTPUT_COUNT: ${OUTPUT_COUNT:-3} 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_LONG: ${FILTER_TIMEOUT_LONG:-300} 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} volumes: - ${DATA_HOST_DIR:-./data}:/data @@ -56,8 +65,9 @@ services: - rembg_cache:/app/.home # Sequential-only by design (no threads/async in worker.py); these # 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" - mem_limit: 2g + mem_limit: 4g networks: - internal @@ -71,8 +81,14 @@ services: DATA_DIR: /data OUTPUT_COUNT: ${OUTPUT_COUNT:-3} 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_LONG: ${FILTER_TIMEOUT_LONG:-300} 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} SITE_TITLE: live.f12.rocks volumes: