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:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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__":
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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">×</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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 ·
|
Original ·
|
||||||
<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 ·
|
Rembg ·
|
||||||
<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 %}
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
<p class="back-link"><a href="/jobs/{{ job_id }}">← Zurueck zum Job</a></p>
|
<p class="back-link"><a href="/jobs/{{ job_id }}">← Zurueck zum Job</a></p>
|
||||||
|
|
||||||
<h1>Remix · Job {{ job_id }}</h1>
|
<h1>Remix · 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>
|
||||||
|
|
||||||
|
|||||||
@@ -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/`.
|
||||||
Executable
+28
@@ -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"
|
||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user