Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3064f62192 | ||
|
|
0b53dade06 | ||
|
|
27846f661e | ||
|
|
8802704dfc | ||
|
|
a544572e8a | ||
|
|
befd3caa1d | ||
|
|
d396298130 | ||
|
|
b2ab86a8ad | ||
|
|
746635b570 | ||
|
|
25e94b0c22 | ||
|
|
606ccda721 | ||
|
|
3f24ce2f80 | ||
|
|
394a6911bb | ||
|
|
22696f3ec7 | ||
|
|
15b23c37c1 | ||
|
|
27d9494123 | ||
|
|
802ee4b857 | ||
|
|
1ab49bce1d |
@ -10,4 +10,5 @@ hf_cache
|
|||||||
output*
|
output*
|
||||||
.python-version
|
.python-version
|
||||||
Makefile
|
Makefile
|
||||||
docker-compose.yml
|
compose.yml
|
||||||
|
build
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -6,3 +6,4 @@ output.png
|
|||||||
output*.png
|
output*.png
|
||||||
mask*.png
|
mask*.png
|
||||||
*.jpg
|
*.jpg
|
||||||
|
.env
|
||||||
|
|||||||
24
Dockerfile
24
Dockerfile
@ -1,10 +1,11 @@
|
|||||||
# BiRefNet background removal service — CUDA 12.4 runtime image.
|
# BiRefNet background removal service — CUDA 12.6 inference image.
|
||||||
FROM nvidia/cuda:12.4.1-cudnn-runtime-ubuntu22.04
|
# torch's +cu126 wheels bundle their own CUDA/cuDNN, so no nvidia/cuda base is
|
||||||
|
# needed; the GPU driver is injected by the nvidia container runtime at run time.
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
ENV DEBIAN_FRONTEND=noninteractive \
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
PYTHONUNBUFFERED=1 \
|
|
||||||
UV_PYTHON_INSTALL_DIR=/opt/python \
|
|
||||||
UV_PROJECT_ENVIRONMENT=/app/.venv \
|
UV_PROJECT_ENVIRONMENT=/app/.venv \
|
||||||
|
UV_PYTHON_PREFERENCE=only-system \
|
||||||
UV_COMPILE_BYTECODE=1 \
|
UV_COMPILE_BYTECODE=1 \
|
||||||
UV_LINK_MODE=copy \
|
UV_LINK_MODE=copy \
|
||||||
HF_HOME=/app/hf_cache \
|
HF_HOME=/app/hf_cache \
|
||||||
@ -13,18 +14,13 @@ ENV DEBIAN_FRONTEND=noninteractive \
|
|||||||
# uv: fast, reproducible Python + dependency management.
|
# uv: fast, reproducible Python + dependency management.
|
||||||
COPY --from=ghcr.io/astral-sh/uv:0.9 /uv /uvx /bin/
|
COPY --from=ghcr.io/astral-sh/uv:0.9 /uv /uvx /bin/
|
||||||
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends ca-certificates \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install Python + dependencies first so this layer is cached across code changes.
|
# Install dependencies first so this layer is cached across code changes.
|
||||||
# The BuildKit cache mount keeps the uv download cache warm across rebuilds.
|
# The BuildKit cache mount keeps the uv download cache warm across rebuilds.
|
||||||
COPY pyproject.toml ./
|
COPY pyproject.toml uv.lock ./
|
||||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
uv python install 3.12 \
|
uv sync --no-install-project --no-dev --frozen
|
||||||
&& uv sync --no-install-project --no-dev
|
|
||||||
|
|
||||||
# Application code.
|
# Application code.
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
@ -35,4 +31,4 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
|||||||
ENV PATH="/app/.venv/bin:${PATH}"
|
ENV PATH="/app/.venv/bin:${PATH}"
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
CMD ["birefnet-service"]
|
CMD ["rmbg-as-a-service"]
|
||||||
|
|||||||
22
Makefile
22
Makefile
@ -11,7 +11,7 @@ BLUR ?= 0
|
|||||||
|
|
||||||
.DEFAULT_GOAL := help
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
.PHONY: help build run up stop down logs log ps test test-mask dev sync shell clean fmt
|
.PHONY: help build run up stop down logs log ps test test-mask dev sync shell clean fmt drytestpub testpub bump
|
||||||
|
|
||||||
help: ## Show this help
|
help: ## Show this help
|
||||||
@grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) \
|
@grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) \
|
||||||
@ -45,10 +45,10 @@ sync: ## Install dependencies locally with uv
|
|||||||
uv sync
|
uv sync
|
||||||
|
|
||||||
dev: sync ## Run the service locally (no Docker; needs local CUDA)
|
dev: sync ## Run the service locally (no Docker; needs local CUDA)
|
||||||
uv run birefnet-service
|
uv run rmbg-as-a-service
|
||||||
|
|
||||||
shell: ## Open a shell inside a fresh container
|
shell: ## Open a shell inside a fresh container
|
||||||
$(COMPOSE) run --rm --entrypoint bash birefnet
|
$(COMPOSE) run --rm --entrypoint bash rmbg
|
||||||
|
|
||||||
fmt: ## Format code with ruff
|
fmt: ## Format code with ruff
|
||||||
uv run ruff format src scripts
|
uv run ruff format src scripts
|
||||||
@ -56,3 +56,19 @@ fmt: ## Format code with ruff
|
|||||||
clean: ## Stop the service and remove build artifacts
|
clean: ## Stop the service and remove build artifacts
|
||||||
-$(COMPOSE) down
|
-$(COMPOSE) down
|
||||||
rm -f $(OUTPUT) mask.png
|
rm -f $(OUTPUT) mask.png
|
||||||
|
|
||||||
|
bump: ## Bump the patch version in pyproject.toml (0.0.1 -> 0.0.2)
|
||||||
|
uv version --bump patch
|
||||||
|
|
||||||
|
drytestpub: ## Publish dist/ to TestPyPI (UV_PUBLISH_TOKEN from .env)
|
||||||
|
uv build && \
|
||||||
|
set -a && . ./.env && set +a && \
|
||||||
|
uv publish --publish-url https://test.pypi.org/legacy/ -t "$$UV_PUBLISH_TOKEN" --dry-run
|
||||||
|
|
||||||
|
testpub: ## Publish dist/ to TestPyPI (UV_PUBLISH_TOKEN from .env)
|
||||||
|
uv build && \
|
||||||
|
set -a && . ./.env && set +a && \
|
||||||
|
uv publish --publish-url https://test.pypi.org/legacy/ -t "$$UV_PUBLISH_TOKEN"
|
||||||
|
|
||||||
|
publish:
|
||||||
|
rm -rf dist/ && uv build && uv publish
|
||||||
|
|||||||
34
README.md
34
README.md
@ -30,15 +30,37 @@ while a model downloads and loads.
|
|||||||
|
|
||||||
### Web UI
|
### Web UI
|
||||||
|
|
||||||
Open **http://localhost:8000/** — a two-tab test page (handy over SSH):
|
Open **http://localhost:8000/** — a single-page test app (handy over SSH):
|
||||||
|
|
||||||
- **Auto remove** — pick a model variant + resolution.
|
- **Auto remove** — pick a model variant + resolution.
|
||||||
- **Prompt segment** — type what to keep (e.g. `the dog`), tune the
|
- **Prompt segment** — type what to keep (e.g. `the dog`), tune the
|
||||||
GroundingDINO box / text thresholds.
|
GroundingDINO box / text thresholds.
|
||||||
|
|
||||||
Both tabs support a transparency checkerboard preview, click-to-zoom lightbox,
|
Both modes support a transparency checkerboard preview, click-to-zoom lightbox,
|
||||||
optional crop-to-subject, and download.
|
optional crop-to-subject, and download.
|
||||||
|
|
||||||
|
#### Keyboard shortcuts
|
||||||
|
|
||||||
|
The UI is fully keyboard-drivable. Shortcuts are ignored while typing in a
|
||||||
|
field and while Ctrl/Cmd/Alt is held.
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
|---------------------|-----------------------------------------------|
|
||||||
|
| `B` | Toggle the controls sidebar |
|
||||||
|
| `U` | Open the file picker to upload an image |
|
||||||
|
| `I` / `O` | Show the input / output image |
|
||||||
|
| `F` / `Z` | Open the zoom view for the visible image |
|
||||||
|
| `S` | Save (download PNG), once a result exists |
|
||||||
|
|
||||||
|
In the zoom view:
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
|---------------------------|-----------------------------------------|
|
||||||
|
| `F` / `Z` / `Esc` | Close the zoom view |
|
||||||
|
| `+` / `-` | Zoom in / out (1×–8×) |
|
||||||
|
| `0` | Reset zoom & pan |
|
||||||
|
| Arrows or `H` `J` `K` `L` | Pan (while zoomed past 1×) |
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
### `POST /predict` — automatic background removal
|
### `POST /predict` — automatic background removal
|
||||||
@ -105,10 +127,10 @@ make dev # uv sync + run the server locally
|
|||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
src/birefnet_service/model.py BiRefNet / RMBG-2.0 wrapper + compositing
|
src/rmbg_as_a_service/model.py BiRefNet / RMBG-2.0 wrapper + compositing
|
||||||
src/birefnet_service/prompt_segment.py GroundingDINO + SAM pipeline
|
src/rmbg_as_a_service/prompt_segment.py GroundingDINO + SAM pipeline
|
||||||
src/birefnet_service/server.py LitServe /predict + /segment + web UI
|
src/rmbg_as_a_service/server.py LitServe /predict + /segment + web UI
|
||||||
src/birefnet_service/static/ web UI (index.html)
|
src/rmbg_as_a_service/static/ web UI (index.html + styles.css)
|
||||||
scripts/client.py stdlib-only test client
|
scripts/client.py stdlib-only test client
|
||||||
Dockerfile / compose.yml CUDA image + nvidia runtime
|
Dockerfile / compose.yml CUDA image + nvidia runtime
|
||||||
Makefile build / run / test shortcuts
|
Makefile build / run / test shortcuts
|
||||||
|
|||||||
32
build/Dockerfile
Normal file
32
build/Dockerfile
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
# rmbg-as-a-service — slim CUDA inference image.
|
||||||
|
# torch's +cu126 wheels bundle their own CUDA/cuDNN, so no nvidia/cuda base is needed;
|
||||||
|
# the GPU driver is injected by the nvidia container runtime at run time.
|
||||||
|
|
||||||
|
# ---- builder: install everything into an isolated venv ----
|
||||||
|
FROM python:3.12-slim AS builder
|
||||||
|
|
||||||
|
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||||
|
|
||||||
|
RUN python -m venv /opt/venv
|
||||||
|
ENV PATH="/opt/venv/bin:${PATH}"
|
||||||
|
|
||||||
|
# rmbg-as-a-service from PyPI; CUDA torch from the PyTorch index (its +cu126 build
|
||||||
|
# outranks the plain wheel by local-version ordering).
|
||||||
|
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||||
|
pip install \
|
||||||
|
--extra-index-url https://download.pytorch.org/whl/cu126 \
|
||||||
|
rmbg-as-a-service==0.0.3 hf-transfer
|
||||||
|
|
||||||
|
# ---- runtime: just Python + the prepared venv ----
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
|
HF_HOME=/app/hf_cache \
|
||||||
|
HF_HUB_ENABLE_HF_TRANSFER=1 \
|
||||||
|
PORT=8000 \
|
||||||
|
PATH="/opt/venv/bin:${PATH}"
|
||||||
|
|
||||||
|
COPY --from=builder /opt/venv /opt/venv
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
CMD ["rmbg-as-a-service"]
|
||||||
53
build/Makefile
Normal file
53
build/Makefile
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
# mindthemath/rmbg image — build the published-package container, test it, push it.
|
||||||
|
# Override inline, e.g.: make release TAG=0.0.2
|
||||||
|
|
||||||
|
COMPOSE ?= docker compose
|
||||||
|
PYTHON ?= python3
|
||||||
|
IMAGE ?= mindthemath/rmbg
|
||||||
|
TAG ?= latest
|
||||||
|
PORT ?= 8000
|
||||||
|
INPUT ?= ../test.jpg
|
||||||
|
OUTPUT ?= output.png
|
||||||
|
BG ?= alpha
|
||||||
|
|
||||||
|
# Exported so compose.yml's ${TAG} interpolation picks it up.
|
||||||
|
export TAG
|
||||||
|
|
||||||
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
|
.PHONY: help build run up stop down logs log ps shell test push release clean
|
||||||
|
|
||||||
|
help: ## Show this help
|
||||||
|
@grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) \
|
||||||
|
| awk 'BEGIN{FS=":.*?## "}{printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}'
|
||||||
|
|
||||||
|
build: ## Build the mindthemath/rmbg image
|
||||||
|
$(COMPOSE) build
|
||||||
|
|
||||||
|
run up: ## Start the service (GPU) in the background
|
||||||
|
$(COMPOSE) up -d
|
||||||
|
|
||||||
|
stop down: ## Stop and remove the service container
|
||||||
|
$(COMPOSE) down
|
||||||
|
|
||||||
|
logs log: ## Follow service logs
|
||||||
|
$(COMPOSE) logs -f
|
||||||
|
|
||||||
|
ps: ## Show service status
|
||||||
|
$(COMPOSE) ps
|
||||||
|
|
||||||
|
shell: ## Open a shell inside a fresh container
|
||||||
|
$(COMPOSE) run --rm --entrypoint bash rmbg
|
||||||
|
|
||||||
|
test: ## Send INPUT to the running service, save OUTPUT
|
||||||
|
$(PYTHON) ../scripts/client.py --url http://localhost:$(PORT) \
|
||||||
|
--input $(INPUT) --output $(OUTPUT) --background $(BG)
|
||||||
|
|
||||||
|
push: build ## Push mindthemath/rmbg:$(TAG) to Docker Hub (needs docker login)
|
||||||
|
docker push $(IMAGE):$(TAG)
|
||||||
|
|
||||||
|
release: build push ## Build then push mindthemath/rmbg:$(TAG)
|
||||||
|
|
||||||
|
clean: ## Stop the service and remove the built image
|
||||||
|
-$(COMPOSE) down
|
||||||
|
-docker image rm $(IMAGE):$(TAG)
|
||||||
31
build/compose.yml
Normal file
31
build/compose.yml
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
# mindthemath/rmbg — runs the image built from the published (TestPyPI) package.
|
||||||
|
services:
|
||||||
|
rmbg:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: mindthemath/rmbg:${TAG:-latest}
|
||||||
|
container_name: rmbg
|
||||||
|
ports:
|
||||||
|
- "${PORT:-8001}:8000"
|
||||||
|
environment:
|
||||||
|
- NVIDIA_VISIBLE_DEVICES=all
|
||||||
|
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
|
||||||
|
# Default variant/resolution; both are also selectable per request.
|
||||||
|
- BIREFNET_MODEL=${BIREFNET_MODEL:-general}
|
||||||
|
- BIREFNET_RESOLUTION=${BIREFNET_RESOLUTION:-1024}
|
||||||
|
# Use the nvidia-container-runtime for GPU acceleration.
|
||||||
|
runtime: nvidia
|
||||||
|
volumes:
|
||||||
|
# Persist downloaded BiRefNet weights across container restarts.
|
||||||
|
- hf-cache:/app/hf_cache
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 30
|
||||||
|
start_period: 180s
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
hf-cache:
|
||||||
191
build/requirements.txt
Normal file
191
build/requirements.txt
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
# This file was autogenerated by uv via the following command:
|
||||||
|
# uv pip compile pyproject.toml -o build/requirements.txt
|
||||||
|
annotated-doc==0.0.4
|
||||||
|
# via fastapi
|
||||||
|
annotated-types==0.7.0
|
||||||
|
# via pydantic
|
||||||
|
anyio==4.13.0
|
||||||
|
# via
|
||||||
|
# starlette
|
||||||
|
# watchfiles
|
||||||
|
certifi==2026.4.22
|
||||||
|
# via requests
|
||||||
|
charset-normalizer==3.4.7
|
||||||
|
# via requests
|
||||||
|
click==8.3.3
|
||||||
|
# via uvicorn
|
||||||
|
cuda-bindings==12.9.6
|
||||||
|
# via torch
|
||||||
|
cuda-pathfinder==1.5.4
|
||||||
|
# via cuda-bindings
|
||||||
|
cuda-toolkit==12.6.3
|
||||||
|
# via torch
|
||||||
|
einops==0.8.2
|
||||||
|
# via rmbg-as-a-service (pyproject.toml)
|
||||||
|
fastapi==0.136.1
|
||||||
|
# via litserve
|
||||||
|
filelock==3.29.0
|
||||||
|
# via
|
||||||
|
# huggingface-hub
|
||||||
|
# torch
|
||||||
|
# transformers
|
||||||
|
fsspec==2026.4.0
|
||||||
|
# via
|
||||||
|
# huggingface-hub
|
||||||
|
# torch
|
||||||
|
h11==0.16.0
|
||||||
|
# via uvicorn
|
||||||
|
hf-xet==1.5.0
|
||||||
|
# via huggingface-hub
|
||||||
|
httptools==0.7.1
|
||||||
|
# via uvicorn
|
||||||
|
huggingface-hub==0.36.2
|
||||||
|
# via
|
||||||
|
# timm
|
||||||
|
# tokenizers
|
||||||
|
# transformers
|
||||||
|
idna==3.15
|
||||||
|
# via
|
||||||
|
# anyio
|
||||||
|
# requests
|
||||||
|
jinja2==3.1.6
|
||||||
|
# via torch
|
||||||
|
kornia==0.8.2
|
||||||
|
# via rmbg-as-a-service (pyproject.toml)
|
||||||
|
kornia-rs==0.1.11
|
||||||
|
# via kornia
|
||||||
|
litserve==0.2.17
|
||||||
|
# via rmbg-as-a-service (pyproject.toml)
|
||||||
|
markupsafe==3.0.3
|
||||||
|
# via jinja2
|
||||||
|
mpmath==1.3.0
|
||||||
|
# via sympy
|
||||||
|
networkx==3.6.1
|
||||||
|
# via torch
|
||||||
|
numpy==2.4.5
|
||||||
|
# via
|
||||||
|
# rmbg-as-a-service (pyproject.toml)
|
||||||
|
# torchvision
|
||||||
|
# transformers
|
||||||
|
nvidia-cublas-cu12==12.6.4.1
|
||||||
|
# via
|
||||||
|
# cuda-toolkit
|
||||||
|
# nvidia-cudnn-cu12
|
||||||
|
# nvidia-cusolver-cu12
|
||||||
|
nvidia-cuda-cupti-cu12==12.6.80
|
||||||
|
# via cuda-toolkit
|
||||||
|
nvidia-cuda-nvrtc-cu12==12.6.85
|
||||||
|
# via cuda-toolkit
|
||||||
|
nvidia-cuda-runtime-cu12==12.6.77
|
||||||
|
# via cuda-toolkit
|
||||||
|
nvidia-cudnn-cu12==9.10.2.21
|
||||||
|
# via torch
|
||||||
|
nvidia-cufft-cu12==11.3.0.4
|
||||||
|
# via cuda-toolkit
|
||||||
|
nvidia-cufile-cu12==1.11.1.6
|
||||||
|
# via cuda-toolkit
|
||||||
|
nvidia-curand-cu12==10.3.7.77
|
||||||
|
# via cuda-toolkit
|
||||||
|
nvidia-cusolver-cu12==11.7.1.2
|
||||||
|
# via cuda-toolkit
|
||||||
|
nvidia-cusparse-cu12==12.5.4.2
|
||||||
|
# via
|
||||||
|
# cuda-toolkit
|
||||||
|
# nvidia-cusolver-cu12
|
||||||
|
nvidia-cusparselt-cu12==0.7.1
|
||||||
|
# via torch
|
||||||
|
nvidia-nccl-cu12==2.28.9
|
||||||
|
# via torch
|
||||||
|
nvidia-nvjitlink-cu12==12.6.85
|
||||||
|
# via
|
||||||
|
# cuda-toolkit
|
||||||
|
# nvidia-cufft-cu12
|
||||||
|
# nvidia-cusolver-cu12
|
||||||
|
# nvidia-cusparse-cu12
|
||||||
|
nvidia-nvshmem-cu12==3.4.5
|
||||||
|
# via torch
|
||||||
|
nvidia-nvtx-cu12==12.6.77
|
||||||
|
# via cuda-toolkit
|
||||||
|
packaging==26.2
|
||||||
|
# via
|
||||||
|
# huggingface-hub
|
||||||
|
# kornia
|
||||||
|
# transformers
|
||||||
|
pillow==12.2.0
|
||||||
|
# via
|
||||||
|
# rmbg-as-a-service (pyproject.toml)
|
||||||
|
# torchvision
|
||||||
|
pydantic==2.13.4
|
||||||
|
# via fastapi
|
||||||
|
pydantic-core==2.46.4
|
||||||
|
# via pydantic
|
||||||
|
python-dotenv==1.2.2
|
||||||
|
# via uvicorn
|
||||||
|
pyyaml==6.0.3
|
||||||
|
# via
|
||||||
|
# huggingface-hub
|
||||||
|
# timm
|
||||||
|
# transformers
|
||||||
|
# uvicorn
|
||||||
|
pyzmq==27.1.0
|
||||||
|
# via litserve
|
||||||
|
regex==2026.5.9
|
||||||
|
# via transformers
|
||||||
|
requests==2.34.2
|
||||||
|
# via
|
||||||
|
# huggingface-hub
|
||||||
|
# transformers
|
||||||
|
safetensors==0.7.0
|
||||||
|
# via
|
||||||
|
# timm
|
||||||
|
# transformers
|
||||||
|
setuptools==81.0.0
|
||||||
|
# via torch
|
||||||
|
starlette==1.0.0
|
||||||
|
# via fastapi
|
||||||
|
sympy==1.14.0
|
||||||
|
# via torch
|
||||||
|
timm==1.0.27
|
||||||
|
# via rmbg-as-a-service (pyproject.toml)
|
||||||
|
tokenizers==0.22.2
|
||||||
|
# via transformers
|
||||||
|
torch==2.11.0+cu126
|
||||||
|
# via
|
||||||
|
# rmbg-as-a-service (pyproject.toml)
|
||||||
|
# kornia
|
||||||
|
# timm
|
||||||
|
# torchvision
|
||||||
|
torchvision==0.26.0+cu126
|
||||||
|
# via
|
||||||
|
# rmbg-as-a-service (pyproject.toml)
|
||||||
|
# timm
|
||||||
|
tqdm==4.67.3
|
||||||
|
# via
|
||||||
|
# huggingface-hub
|
||||||
|
# transformers
|
||||||
|
transformers==4.57.6
|
||||||
|
# via rmbg-as-a-service (pyproject.toml)
|
||||||
|
triton==3.6.0
|
||||||
|
# via torch
|
||||||
|
typing-extensions==4.15.0
|
||||||
|
# via
|
||||||
|
# fastapi
|
||||||
|
# huggingface-hub
|
||||||
|
# pydantic
|
||||||
|
# pydantic-core
|
||||||
|
# torch
|
||||||
|
# typing-inspection
|
||||||
|
typing-inspection==0.4.2
|
||||||
|
# via
|
||||||
|
# fastapi
|
||||||
|
# pydantic
|
||||||
|
urllib3==2.7.0
|
||||||
|
# via requests
|
||||||
|
uvicorn==0.47.0
|
||||||
|
# via litserve
|
||||||
|
uvloop==0.22.1
|
||||||
|
# via uvicorn
|
||||||
|
watchfiles==1.1.1
|
||||||
|
# via uvicorn
|
||||||
|
websockets==16.0
|
||||||
|
# via uvicorn
|
||||||
@ -1,8 +1,8 @@
|
|||||||
services:
|
services:
|
||||||
birefnet:
|
rmbg:
|
||||||
build: .
|
build: .
|
||||||
image: birefnet-service:latest
|
image: rmbg-as-a-service:latest
|
||||||
container_name: birefnet-service
|
container_name: rmbg-as-a-service
|
||||||
ports:
|
ports:
|
||||||
- "${PORT:-8000}:8000"
|
- "${PORT:-8000}:8000"
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "rmbg-as-a-service"
|
name = "rmbg-as-a-service"
|
||||||
version = "0.1.0"
|
version = "0.0.3"
|
||||||
description = "Background removal as a GPU-accelerated API"
|
description = "Background removal as a GPU-accelerated API"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12,<3.13"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"torch==2.5.1",
|
"torch==2.11.0",
|
||||||
"torchvision==0.20.1",
|
"torchvision==0.26.0",
|
||||||
"transformers>=4.44,<5",
|
"transformers>=4.44,<5",
|
||||||
"timm>=1.0.0",
|
"timm>=1.0.0",
|
||||||
"einops>=0.8.0",
|
"einops>=0.8.0",
|
||||||
@ -17,7 +17,7 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
birefnet-service = "birefnet_service.server:run"
|
rmbg-as-a-service = "rmbg_as_a_service.server:run"
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = ["ruff>=0.6.0"]
|
dev = ["ruff>=0.6.0"]
|
||||||
@ -27,17 +27,24 @@ requires = ["hatchling"]
|
|||||||
build-backend = "hatchling.build"
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel]
|
[tool.hatch.build.targets.wheel]
|
||||||
packages = ["src/birefnet_service"]
|
packages = ["src/rmbg_as_a_service"]
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.sdist]
|
||||||
|
include = [
|
||||||
|
"src/rmbg_as_a_service",
|
||||||
|
"README.md",
|
||||||
|
"pyproject.toml",
|
||||||
|
]
|
||||||
|
|
||||||
# BiRefNet (torch) needs CUDA wheels; pull torch/torchvision from the PyTorch index.
|
# BiRefNet (torch) needs CUDA wheels; pull torch/torchvision from the PyTorch index.
|
||||||
[[tool.uv.index]]
|
[[tool.uv.index]]
|
||||||
name = "pytorch-cu124"
|
name = "pytorch-cu126"
|
||||||
url = "https://download.pytorch.org/whl/cu124"
|
url = "https://download.pytorch.org/whl/cu126"
|
||||||
explicit = true
|
explicit = true
|
||||||
|
|
||||||
[tool.uv.sources]
|
[tool.uv.sources]
|
||||||
torch = { index = "pytorch-cu124" }
|
torch = { index = "pytorch-cu126" }
|
||||||
torchvision = { index = "pytorch-cu124" }
|
torchvision = { index = "pytorch-cu126" }
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
line-length = 100
|
line-length = 100
|
||||||
|
|||||||
@ -1,425 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<title>Background Removal & Segmentation</title>
|
|
||||||
<style>
|
|
||||||
:root { color-scheme: dark; }
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
body {
|
|
||||||
margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
|
||||||
background: #15171c; color: #e8e8ea; padding: 24px;
|
|
||||||
}
|
|
||||||
h1 { font-size: 1.25rem; font-weight: 600; margin: 0 0 4px; }
|
|
||||||
.sub { color: #8a8f99; font-size: .85rem; margin-bottom: 16px; }
|
|
||||||
.wrap { max-width: 1100px; margin: 0 auto; }
|
|
||||||
|
|
||||||
.tabs { display: flex; gap: 4px; margin-bottom: 16px; border-bottom: 1px solid #2a2f3a; }
|
|
||||||
.tab { background: none; border: 0; color: #8a8f99; font-size: .9rem; font-weight: 600;
|
|
||||||
padding: 10px 16px; cursor: pointer; border-bottom: 2px solid transparent; }
|
|
||||||
.tab.active { color: #e8e8ea; border-bottom-color: #5b8cff; }
|
|
||||||
|
|
||||||
#drop {
|
|
||||||
border: 2px dashed #3a3f4b; border-radius: 12px; padding: 36px;
|
|
||||||
text-align: center; cursor: pointer; transition: border-color .15s, background .15s;
|
|
||||||
}
|
|
||||||
#drop.over { border-color: #5b8cff; background: #1c2230; }
|
|
||||||
#drop p { margin: 6px 0; color: #8a8f99; }
|
|
||||||
|
|
||||||
.controls { display: flex; gap: 12px; align-items: flex-end; margin: 14px 0; flex-wrap: wrap; }
|
|
||||||
.controls[hidden] { display: none; }
|
|
||||||
label.field { display: flex; flex-direction: column; gap: 4px; font-size: .72rem;
|
|
||||||
color: #8a8f99; text-transform: uppercase; letter-spacing: .04em; }
|
|
||||||
select, input[type=number], input[type=text] {
|
|
||||||
background: #2a2f3a; color: #e8e8ea; border: 1px solid #3a3f4b;
|
|
||||||
border-radius: 8px; padding: 8px 10px; font-size: .9rem;
|
|
||||||
}
|
|
||||||
input[type=number] { width: 78px; }
|
|
||||||
input[type=number]:disabled { opacity: .45; }
|
|
||||||
input[type=text]#prompt { width: 320px; }
|
|
||||||
.check { display: flex; align-items: center; gap: 6px; font-size: .85rem;
|
|
||||||
color: #e8e8ea; cursor: pointer; align-self: end; padding-bottom: 8px; }
|
|
||||||
.check input { width: 15px; height: 15px; accent-color: #5b8cff; cursor: pointer; }
|
|
||||||
|
|
||||||
/* help tooltips */
|
|
||||||
.help { display: inline-flex; align-items: center; justify-content: center;
|
|
||||||
width: 14px; height: 14px; margin-left: 5px; border-radius: 50%;
|
|
||||||
border: 1px solid #4a4f5b; color: #8a8f99; font-size: 9px; font-weight: 700;
|
|
||||||
font-style: normal; cursor: help; position: relative; vertical-align: middle; }
|
|
||||||
.help:hover { color: #e8e8ea; border-color: #5b8cff; }
|
|
||||||
.help:hover::after {
|
|
||||||
content: attr(data-tip); position: absolute; bottom: 150%; left: 50%;
|
|
||||||
transform: translateX(-50%); width: 220px; background: #0c0d11;
|
|
||||||
color: #d8d9dc; border: 1px solid #3a3f4b; border-radius: 6px;
|
|
||||||
padding: 7px 9px; font-size: .72rem; font-weight: 400; line-height: 1.4;
|
|
||||||
text-transform: none; letter-spacing: normal; white-space: normal;
|
|
||||||
z-index: 50; pointer-events: none; }
|
|
||||||
|
|
||||||
button.go {
|
|
||||||
background: #5b8cff; color: #fff; border: 0; border-radius: 8px;
|
|
||||||
padding: 10px 18px; font-size: .9rem; cursor: pointer; font-weight: 600;
|
|
||||||
}
|
|
||||||
button.go:disabled { background: #3a3f4b; cursor: not-allowed; }
|
|
||||||
button.ghost { background: #2a2f3a; color: #fff; border: 0; border-radius: 8px;
|
|
||||||
padding: 10px 18px; font-size: .9rem; cursor: pointer; font-weight: 600; }
|
|
||||||
.go-row { display: flex; gap: 12px; align-items: center; margin: 14px 0; flex-wrap: wrap; }
|
|
||||||
.status { color: #8a8f99; font-size: .85rem; }
|
|
||||||
.status.err { color: #ff6b6b; }
|
|
||||||
.hint { color: #6b7280; font-size: .78rem; margin: -4px 0 4px; }
|
|
||||||
|
|
||||||
.panels { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 16px; }
|
|
||||||
.panel { background: #1c1f27; border-radius: 12px; padding: 12px; }
|
|
||||||
.panel h2 { font-size: .8rem; font-weight: 600; color: #8a8f99; margin: 0 0 8px;
|
|
||||||
text-transform: uppercase; letter-spacing: .05em; }
|
|
||||||
.imgbox {
|
|
||||||
min-height: 260px; display: flex; align-items: center; justify-content: center;
|
|
||||||
border-radius: 8px; overflow: hidden;
|
|
||||||
}
|
|
||||||
.checker {
|
|
||||||
background-image:
|
|
||||||
linear-gradient(45deg, #2a2f3a 25%, transparent 25%),
|
|
||||||
linear-gradient(-45deg, #2a2f3a 25%, transparent 25%),
|
|
||||||
linear-gradient(45deg, transparent 75%, #2a2f3a 75%),
|
|
||||||
linear-gradient(-45deg, transparent 75%, #2a2f3a 75%);
|
|
||||||
background-size: 22px 22px;
|
|
||||||
background-position: 0 0, 0 11px, 11px -11px, -11px 0;
|
|
||||||
background-color: #20242d;
|
|
||||||
}
|
|
||||||
.imgbox img { max-width: 100%; max-height: 70vh; display: block; }
|
|
||||||
.imgbox img[src] { cursor: zoom-in; }
|
|
||||||
@media (max-width: 720px) { .panels { grid-template-columns: 1fr; } }
|
|
||||||
|
|
||||||
/* lightbox */
|
|
||||||
.lightbox { position: fixed; inset: 0; z-index: 100; background: rgba(12,13,17,.97);
|
|
||||||
display: flex; align-items: center; justify-content: center; }
|
|
||||||
.lightbox[hidden] { display: none; }
|
|
||||||
.lb-stage { width: 100vw; height: 100vh; overflow: hidden;
|
|
||||||
display: flex; align-items: center; justify-content: center; }
|
|
||||||
.lb-stage img { max-width: 100vw; max-height: 100vh; transform-origin: 0 0;
|
|
||||||
cursor: grab; user-select: none; -webkit-user-drag: none; will-change: transform; }
|
|
||||||
.lb-stage.grabbing img { cursor: grabbing; }
|
|
||||||
.lb-bar { position: fixed; top: 0; left: 0; right: 0; padding: 14px 20px;
|
|
||||||
z-index: 2; display: flex; justify-content: space-between; align-items: center;
|
|
||||||
color: #8a8f99; font-size: .8rem; pointer-events: none; }
|
|
||||||
.lb-close { pointer-events: auto; background: #2a2f3a; color: #e8e8ea;
|
|
||||||
border: 1px solid #3a3f4b; border-radius: 8px; width: 34px; height: 34px;
|
|
||||||
font-size: 1rem; line-height: 1; padding: 0; cursor: pointer;
|
|
||||||
display: flex; align-items: center; justify-content: center; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="wrap">
|
|
||||||
<h1>Background Removal & Segmentation</h1>
|
|
||||||
<div class="sub">Automatic removal, or prompt-conditioned segmentation.</div>
|
|
||||||
|
|
||||||
<div class="tabs">
|
|
||||||
<button class="tab active" data-tab="auto">Auto remove</button>
|
|
||||||
<button class="tab" data-tab="prompt">Prompt segment</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="drop">
|
|
||||||
<p><strong>Drop an image here</strong> or click to choose</p>
|
|
||||||
<p id="fname">No file selected</p>
|
|
||||||
<input id="file" type="file" accept="image/*" hidden />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Auto (BiRefNet / RMBG-2.0) controls -->
|
|
||||||
<div class="controls" id="ctl-auto">
|
|
||||||
<label class="field">Model
|
|
||||||
<select id="model">
|
|
||||||
<option value="general">general — clean single subjects (fast)</option>
|
|
||||||
<option value="HR" selected>HR — large / detailed scenes</option>
|
|
||||||
<option value="portrait">portrait — people</option>
|
|
||||||
<option value="matting">matting — soft edges / hair</option>
|
|
||||||
<option value="lite">lite — fastest</option>
|
|
||||||
<option value="rmbg2">rmbg2 — BRIA RMBG-2.0</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label class="field">Resolution
|
|
||||||
<select id="resolution">
|
|
||||||
<option value="1024">1024</option>
|
|
||||||
<option value="1536">1536</option>
|
|
||||||
<option value="2048">2048</option>
|
|
||||||
<option value="2560" selected>2560</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Prompt (GroundingDINO + SAM) controls -->
|
|
||||||
<div class="controls" id="ctl-prompt" hidden>
|
|
||||||
<label class="field">Prompt — what to keep
|
|
||||||
<input type="text" id="prompt" placeholder="e.g. the dog · cow. person." />
|
|
||||||
</label>
|
|
||||||
<label class="field">
|
|
||||||
<span>Box threshold<span class="help" data-tip="Minimum confidence for GroundingDINO to keep a detected box. Lower finds more (and looser) objects; higher keeps only strong matches.">?</span></span>
|
|
||||||
<input type="number" id="boxThr" value="0.3" min="0" max="1" step="0.05" />
|
|
||||||
</label>
|
|
||||||
<label class="field">
|
|
||||||
<span>Text threshold<span class="help" data-tip="How strongly a detection must match your prompt words. Lower = looser word matching; higher = stricter.">?</span></span>
|
|
||||||
<input type="number" id="textThr" value="0.25" min="0" max="1" step="0.05" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Shared output controls -->
|
|
||||||
<div class="controls">
|
|
||||||
<label class="field">Background
|
|
||||||
<select id="background">
|
|
||||||
<option value="alpha" selected>transparent</option>
|
|
||||||
<option value="white">white</option>
|
|
||||||
<option value="black">black</option>
|
|
||||||
<option value="gray">gray</option>
|
|
||||||
<option value="green">green</option>
|
|
||||||
<option value="blue">blue</option>
|
|
||||||
<option value="red">red</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label class="field">
|
|
||||||
<span>Edge offset (px)<span class="help" data-tip="Grow (+) or shrink (−) the cutout edge by N pixels. A small negative value trims a leftover background-colored fringe around hair or fur.">?</span></span>
|
|
||||||
<input type="number" id="maskOffset" value="0" min="-20" max="20" step="1" />
|
|
||||||
</label>
|
|
||||||
<label class="field">
|
|
||||||
<span>Feather (px)<span class="help" data-tip="Gaussian blur applied to the mask edge, in pixels. Softens the cutout for smoother compositing onto a new background.">?</span></span>
|
|
||||||
<input type="number" id="maskBlur" value="0" min="0" max="64" step="1" />
|
|
||||||
</label>
|
|
||||||
<label class="check"><input type="checkbox" id="crop" checked /> Crop to subject</label>
|
|
||||||
<label class="field">Margin (in)
|
|
||||||
<input type="number" id="cropMargin" value="0" min="0" step="0.1" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="hint" id="hint"></div>
|
|
||||||
|
|
||||||
<div class="go-row">
|
|
||||||
<button class="go" id="go" disabled>Remove background</button>
|
|
||||||
<a id="dl" download="cutout.png"><button id="dlbtn" class="ghost" disabled>Download PNG</button></a>
|
|
||||||
<span id="status" class="status"></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="panels">
|
|
||||||
<div class="panel">
|
|
||||||
<h2>Original</h2>
|
|
||||||
<div class="imgbox"><img id="src" alt="" /></div>
|
|
||||||
</div>
|
|
||||||
<div class="panel">
|
|
||||||
<h2>Result</h2>
|
|
||||||
<div class="imgbox checker"><img id="out" alt="" /></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="lightbox" class="lightbox" hidden>
|
|
||||||
<div class="lb-bar">
|
|
||||||
<span>scroll to zoom · drag to pan · double-click resets · Esc closes</span>
|
|
||||||
<button class="lb-close" id="lbClose" title="Close">✕</button>
|
|
||||||
</div>
|
|
||||||
<div class="lb-stage" id="lbStage"><img id="lbImg" alt="" /></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const drop = document.getElementById('drop');
|
|
||||||
const fileInput = document.getElementById('file');
|
|
||||||
const fname = document.getElementById('fname');
|
|
||||||
const go = document.getElementById('go');
|
|
||||||
const dl = document.getElementById('dl');
|
|
||||||
const dlbtn = document.getElementById('dlbtn');
|
|
||||||
const statusEl = document.getElementById('status');
|
|
||||||
const srcImg = document.getElementById('src');
|
|
||||||
const outImg = document.getElementById('out');
|
|
||||||
const hint = document.getElementById('hint');
|
|
||||||
|
|
||||||
const modelSel = document.getElementById('model');
|
|
||||||
const resSel = document.getElementById('resolution');
|
|
||||||
const promptInput = document.getElementById('prompt');
|
|
||||||
const boxThr = document.getElementById('boxThr');
|
|
||||||
const textThr = document.getElementById('textThr');
|
|
||||||
const bgSel = document.getElementById('background');
|
|
||||||
const maskOffset = document.getElementById('maskOffset');
|
|
||||||
const maskBlur = document.getElementById('maskBlur');
|
|
||||||
const cropChk = document.getElementById('crop');
|
|
||||||
const cropMargin = document.getElementById('cropMargin');
|
|
||||||
|
|
||||||
const ctlAuto = document.getElementById('ctl-auto');
|
|
||||||
const ctlPrompt = document.getElementById('ctl-prompt');
|
|
||||||
|
|
||||||
let selectedFile = null;
|
|
||||||
let tab = 'auto';
|
|
||||||
|
|
||||||
const HINTS = {
|
|
||||||
auto: 'Large or busy scenes segment best with HR at 2048+. The general model expects a clear single subject at 1024.',
|
|
||||||
prompt: 'Type what to keep, e.g. "the dog" (or several: "cow. person."). Lower the box threshold to detect more / fainter objects.',
|
|
||||||
};
|
|
||||||
|
|
||||||
function setTab(name) {
|
|
||||||
tab = name;
|
|
||||||
document.querySelectorAll('.tab').forEach(t =>
|
|
||||||
t.classList.toggle('active', t.dataset.tab === name));
|
|
||||||
ctlAuto.hidden = name !== 'auto';
|
|
||||||
ctlPrompt.hidden = name !== 'prompt';
|
|
||||||
go.textContent = name === 'auto' ? 'Remove background' : 'Segment';
|
|
||||||
hint.textContent = HINTS[name];
|
|
||||||
}
|
|
||||||
document.querySelectorAll('.tab').forEach(t =>
|
|
||||||
t.addEventListener('click', () => setTab(t.dataset.tab)));
|
|
||||||
cropChk.addEventListener('change', () => { cropMargin.disabled = !cropChk.checked; });
|
|
||||||
|
|
||||||
function setStatus(msg, isErr) {
|
|
||||||
statusEl.textContent = msg;
|
|
||||||
statusEl.className = 'status' + (isErr ? ' err' : '');
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickFile(file) {
|
|
||||||
if (!file || !file.type.startsWith('image/')) {
|
|
||||||
setStatus('Please choose an image file.', true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
selectedFile = file;
|
|
||||||
fname.textContent = file.name + ' (' + Math.round(file.size / 1024) + ' KB)';
|
|
||||||
srcImg.src = URL.createObjectURL(file);
|
|
||||||
outImg.removeAttribute('src');
|
|
||||||
dlbtn.disabled = true;
|
|
||||||
go.disabled = false;
|
|
||||||
setStatus('');
|
|
||||||
}
|
|
||||||
|
|
||||||
drop.addEventListener('click', () => fileInput.click());
|
|
||||||
fileInput.addEventListener('change', e => pickFile(e.target.files[0]));
|
|
||||||
['dragenter', 'dragover'].forEach(ev =>
|
|
||||||
drop.addEventListener(ev, e => { e.preventDefault(); drop.classList.add('over'); }));
|
|
||||||
['dragleave', 'drop'].forEach(ev =>
|
|
||||||
drop.addEventListener(ev, e => { e.preventDefault(); drop.classList.remove('over'); }));
|
|
||||||
drop.addEventListener('drop', e => pickFile(e.dataTransfer.files[0]));
|
|
||||||
|
|
||||||
function fileToBase64(file) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const r = new FileReader();
|
|
||||||
r.onload = () => resolve(r.result.split(',')[1]); // strip data URL prefix
|
|
||||||
r.onerror = reject;
|
|
||||||
r.readAsDataURL(file);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- lightbox: click to inspect, scroll to zoom, drag to pan ---
|
|
||||||
const lightbox = document.getElementById('lightbox');
|
|
||||||
const lbStage = document.getElementById('lbStage');
|
|
||||||
const lbImg = document.getElementById('lbImg');
|
|
||||||
const lbClose = document.getElementById('lbClose');
|
|
||||||
let lbScale = 1, lbTx = 0, lbTy = 0, lbDrag = null;
|
|
||||||
|
|
||||||
function lbApply() {
|
|
||||||
lbImg.style.transform = `translate(${lbTx}px, ${lbTy}px) scale(${lbScale})`;
|
|
||||||
}
|
|
||||||
function lbReset() { lbScale = 1; lbTx = 0; lbTy = 0; lbApply(); }
|
|
||||||
|
|
||||||
function openLightbox(src, isResult) {
|
|
||||||
if (!src) return;
|
|
||||||
lbImg.src = src;
|
|
||||||
lbImg.classList.toggle('checker', !!isResult);
|
|
||||||
lbReset();
|
|
||||||
lightbox.hidden = false;
|
|
||||||
}
|
|
||||||
function closeLightbox() { lightbox.hidden = true; lbImg.removeAttribute('src'); }
|
|
||||||
|
|
||||||
srcImg.addEventListener('click', () => openLightbox(srcImg.getAttribute('src'), false));
|
|
||||||
outImg.addEventListener('click', () => openLightbox(outImg.getAttribute('src'), true));
|
|
||||||
lbClose.addEventListener('click', closeLightbox);
|
|
||||||
lightbox.addEventListener('mousedown', e => {
|
|
||||||
if (e.target === lightbox || e.target === lbStage) closeLightbox();
|
|
||||||
});
|
|
||||||
document.addEventListener('keydown', e => {
|
|
||||||
if (e.key === 'Escape' && !lightbox.hidden) closeLightbox();
|
|
||||||
});
|
|
||||||
|
|
||||||
lbStage.addEventListener('wheel', e => {
|
|
||||||
e.preventDefault();
|
|
||||||
const rect = lbImg.getBoundingClientRect();
|
|
||||||
const cx = e.clientX - rect.left, cy = e.clientY - rect.top;
|
|
||||||
const factor = e.deltaY < 0 ? 1.2 : 1 / 1.2;
|
|
||||||
const newScale = Math.min(8, Math.max(1, lbScale * factor));
|
|
||||||
const ratio = newScale / lbScale;
|
|
||||||
lbTx -= cx * (ratio - 1);
|
|
||||||
lbTy -= cy * (ratio - 1);
|
|
||||||
lbScale = newScale;
|
|
||||||
if (lbScale === 1) { lbTx = 0; lbTy = 0; }
|
|
||||||
lbApply();
|
|
||||||
}, { passive: false });
|
|
||||||
|
|
||||||
lbImg.addEventListener('mousedown', e => {
|
|
||||||
e.preventDefault();
|
|
||||||
lbDrag = { x: e.clientX, y: e.clientY, tx: lbTx, ty: lbTy };
|
|
||||||
lbStage.classList.add('grabbing');
|
|
||||||
});
|
|
||||||
window.addEventListener('mousemove', e => {
|
|
||||||
if (!lbDrag) return;
|
|
||||||
lbTx = lbDrag.tx + (e.clientX - lbDrag.x);
|
|
||||||
lbTy = lbDrag.ty + (e.clientY - lbDrag.y);
|
|
||||||
lbApply();
|
|
||||||
});
|
|
||||||
window.addEventListener('mouseup', () => {
|
|
||||||
lbDrag = null;
|
|
||||||
lbStage.classList.remove('grabbing');
|
|
||||||
});
|
|
||||||
lbImg.addEventListener('dblclick', e => { e.preventDefault(); lbReset(); });
|
|
||||||
|
|
||||||
go.addEventListener('click', async () => {
|
|
||||||
if (!selectedFile) return;
|
|
||||||
if (tab === 'prompt' && !promptInput.value.trim()) {
|
|
||||||
setStatus('Enter a prompt describing what to keep.', true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
go.disabled = true;
|
|
||||||
dlbtn.disabled = true;
|
|
||||||
setStatus('Processing… (first use of a model downloads its weights)');
|
|
||||||
const t0 = performance.now();
|
|
||||||
try {
|
|
||||||
const b64 = await fileToBase64(selectedFile);
|
|
||||||
const shared = {
|
|
||||||
image: b64,
|
|
||||||
background: bgSel.value,
|
|
||||||
mask_offset: parseInt(maskOffset.value, 10) || 0,
|
|
||||||
mask_blur: parseInt(maskBlur.value, 10) || 0,
|
|
||||||
crop: cropChk.checked,
|
|
||||||
crop_margin: parseFloat(cropMargin.value) || 0,
|
|
||||||
};
|
|
||||||
let endpoint, body;
|
|
||||||
if (tab === 'auto') {
|
|
||||||
endpoint = '/predict';
|
|
||||||
body = { ...shared, model: modelSel.value, resolution: parseInt(resSel.value, 10) };
|
|
||||||
} else {
|
|
||||||
endpoint = '/segment';
|
|
||||||
body = { ...shared, prompt: promptInput.value.trim(),
|
|
||||||
box_threshold: parseFloat(boxThr.value) || 0.3,
|
|
||||||
text_threshold: parseFloat(textThr.value) || 0.25 };
|
|
||||||
}
|
|
||||||
const resp = await fetch(endpoint, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
if (!resp.ok) throw new Error('HTTP ' + resp.status + ': ' + (await resp.text()));
|
|
||||||
const data = await resp.json();
|
|
||||||
const dataUrl = 'data:image/png;base64,' + data.image;
|
|
||||||
outImg.src = dataUrl;
|
|
||||||
dl.href = dataUrl;
|
|
||||||
dl.download = selectedFile.name.replace(/\.[^.]+$/, '') + '.png';
|
|
||||||
dlbtn.disabled = false;
|
|
||||||
const secs = ((performance.now() - t0) / 1000).toFixed(1);
|
|
||||||
if (tab === 'auto') {
|
|
||||||
setStatus(`Done — ${data.width}×${data.height} · ${data.model} @ ${data.resolution} · ${secs}s`);
|
|
||||||
} else {
|
|
||||||
const n = data.detections;
|
|
||||||
setStatus(`Done — ${n} object${n === 1 ? '' : 's'} matched "${data.prompt}" · ` +
|
|
||||||
`${data.width}×${data.height} · ${secs}s` +
|
|
||||||
(n === 0 ? ' (try a lower box threshold)' : ''));
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setStatus(err.message || String(err), true);
|
|
||||||
} finally {
|
|
||||||
go.disabled = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
setTab('auto');
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -41,13 +41,15 @@ from pathlib import Path
|
|||||||
|
|
||||||
import litserve as ls
|
import litserve as ls
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse, Response
|
||||||
from PIL import Image, ImageOps
|
from PIL import Image, ImageOps
|
||||||
|
|
||||||
from .model import BiRefNetService
|
from .model import BiRefNetService
|
||||||
from .prompt_segment import PromptSegmenter
|
from .prompt_segment import PromptSegmenter
|
||||||
|
|
||||||
_UI_HTML = (Path(__file__).parent / "static" / "index.html").read_text(encoding="utf-8")
|
_STATIC = Path(__file__).parent / "static"
|
||||||
|
_UI_HTML = (_STATIC / "index.html").read_text(encoding="utf-8")
|
||||||
|
_UI_CSS = (_STATIC / "styles.css").read_text(encoding="utf-8")
|
||||||
|
|
||||||
# Lazily-created prompt segmenter (DINO + SAM), shared by the /segment route.
|
# Lazily-created prompt segmenter (DINO + SAM), shared by the /segment route.
|
||||||
_segmenter: PromptSegmenter | None = None
|
_segmenter: PromptSegmenter | None = None
|
||||||
@ -132,6 +134,10 @@ def run() -> None:
|
|||||||
def index() -> str:
|
def index() -> str:
|
||||||
return _UI_HTML
|
return _UI_HTML
|
||||||
|
|
||||||
|
@server.app.get("/styles.css")
|
||||||
|
def styles() -> Response:
|
||||||
|
return Response(_UI_CSS, media_type="text/css")
|
||||||
|
|
||||||
@server.app.post("/segment")
|
@server.app.post("/segment")
|
||||||
def segment(payload: dict) -> dict:
|
def segment(payload: dict) -> dict:
|
||||||
"""Prompt-conditioned segmentation (GroundingDINO + SAM)."""
|
"""Prompt-conditioned segmentation (GroundingDINO + SAM)."""
|
||||||
524
src/rmbg_as_a_service/static/index.html
Normal file
524
src/rmbg_as_a_service/static/index.html
Normal file
@ -0,0 +1,524 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>RMBG_SERVICE // BACKGROUND REMOVAL</title>
|
||||||
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container" id="container">
|
||||||
|
|
||||||
|
<button class="sidebar-toggle" id="sidebarToggle" title="Toggle panel" aria-label="Toggle panel">‹</button>
|
||||||
|
|
||||||
|
<!-- ============ CONTROL SIDEBAR ============ -->
|
||||||
|
<aside class="controls panel">
|
||||||
|
|
||||||
|
<header class="app-header">
|
||||||
|
<div class="app-title">RMBG_SERVICE</div>
|
||||||
|
<div class="app-sub">[ BACKGROUND REMOVAL // SEGMENTATION ]</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- upload -->
|
||||||
|
<section class="upload-section">
|
||||||
|
<div class="upload-box" id="drop">
|
||||||
|
<input id="file" type="file" accept="image/*" />
|
||||||
|
<div class="upload-text">▲ DROP IMAGE</div>
|
||||||
|
<div class="upload-hint" id="fname">NO FILE SELECTED</div>
|
||||||
|
<div class="upload-stats" id="fstats"></div>
|
||||||
|
<img class="preview-thumb" id="thumb" alt="" hidden />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- mode -->
|
||||||
|
<div class="control-group">
|
||||||
|
<div class="control-label">// MODE</div>
|
||||||
|
<div class="radio-row">
|
||||||
|
<label><input type="radio" name="mode" value="auto" checked /> AUTO REMOVE</label>
|
||||||
|
<label><input type="radio" name="mode" value="prompt" /> PROMPT SEGMENT</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- AUTO controls -->
|
||||||
|
<div class="control-group" id="ctl-auto">
|
||||||
|
<div class="control-label">// MODEL</div>
|
||||||
|
<div class="select-wrap">
|
||||||
|
<select id="model">
|
||||||
|
<option value="general">GENERAL — CLEAN SINGLE SUBJECTS (FAST)</option>
|
||||||
|
<option value="HR" selected>HR — LARGE / DETAILED SCENES</option>
|
||||||
|
<option value="portrait">PORTRAIT — PEOPLE</option>
|
||||||
|
<option value="matting">MATTING — SOFT EDGES / HAIR</option>
|
||||||
|
<option value="lite">LITE — FASTEST</option>
|
||||||
|
<option value="rmbg2">RMBG2 — BRIA RMBG-2.0</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="control-label">// RESOLUTION</div>
|
||||||
|
<div class="select-wrap">
|
||||||
|
<select id="resolution">
|
||||||
|
<option value="1024">1024</option>
|
||||||
|
<option value="1536">1536</option>
|
||||||
|
<option value="2048" selected>2048</option>
|
||||||
|
<option value="2560">2560</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PROMPT controls -->
|
||||||
|
<div class="control-group" id="ctl-prompt">
|
||||||
|
<div class="control-label">// PROMPT — WHAT TO KEEP</div>
|
||||||
|
<input type="text" id="prompt" placeholder="e.g. THE DOG . COW. PERSON." />
|
||||||
|
|
||||||
|
<div class="slider-container">
|
||||||
|
<div class="control-label">BOX THRESHOLD
|
||||||
|
<span class="help" data-tip="Minimum confidence for GroundingDINO to keep a detected box. Lower finds more (and looser) objects; higher keeps only strong matches.">?</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" class="slider" id="boxThr" min="0" max="1" step="0.05" value="0.3" />
|
||||||
|
<div class="slider-value" id="boxThrVal">0.30</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="slider-container">
|
||||||
|
<div class="control-label">TEXT THRESHOLD
|
||||||
|
<span class="help" data-tip="How strongly a detection must match your prompt words. Lower = looser word matching; higher = stricter.">?</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" class="slider" id="textThr" min="0" max="1" step="0.05" value="0.25" />
|
||||||
|
<div class="slider-value" id="textThrVal">0.25</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- OUTPUT drawer -->
|
||||||
|
<div class="control-group drawer" id="outputDrawer">
|
||||||
|
<div class="drawer-header">
|
||||||
|
<span class="drawer-caret">▸</span>
|
||||||
|
<span class="section-title">OUTPUT</span>
|
||||||
|
</div>
|
||||||
|
<div class="drawer-content">
|
||||||
|
|
||||||
|
<div class="control-label">// BACKGROUND</div>
|
||||||
|
<div class="select-wrap">
|
||||||
|
<select id="background">
|
||||||
|
<option value="alpha" selected>TRANSPARENT</option>
|
||||||
|
<option value="white">WHITE</option>
|
||||||
|
<option value="black">BLACK</option>
|
||||||
|
<option value="gray">GRAY</option>
|
||||||
|
<option value="green">GREEN</option>
|
||||||
|
<option value="blue">BLUE</option>
|
||||||
|
<option value="red">RED</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="slider-container">
|
||||||
|
<div class="control-label">EDGE OFFSET <span class="unit">PX</span>
|
||||||
|
<span class="help" data-tip="Grow (+) or shrink (-) the cutout edge by N pixels. A small negative value trims a leftover background-colored fringe around hair or fur.">?</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" class="slider" id="maskOffset" min="-20" max="20" step="1" value="0" />
|
||||||
|
<div class="slider-value" id="maskOffsetVal">0</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="slider-container">
|
||||||
|
<div class="control-label">FEATHER <span class="unit">PX</span>
|
||||||
|
<span class="help" data-tip="Gaussian blur applied to the mask edge, in pixels. Softens the cutout for smoother compositing onto a new background.">?</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" class="slider" id="maskBlur" min="0" max="64" step="1" value="0" />
|
||||||
|
<div class="slider-value" id="maskBlurVal">0</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="check-row"><input type="checkbox" id="crop" checked /> CROP TO SUBJECT</label>
|
||||||
|
|
||||||
|
<div class="slider-container" id="cropMarginWrap">
|
||||||
|
<div class="control-label">CROP MARGIN <span class="unit">IN</span></div>
|
||||||
|
<input type="range" class="slider" id="cropMargin" min="0" max="2" step="0.25" value="0" />
|
||||||
|
<div class="slider-value" id="cropMarginVal">0.00</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- actions -->
|
||||||
|
<div class="action-row">
|
||||||
|
<button class="action-btn primary" id="go" disabled>REMOVE BACKGROUND</button>
|
||||||
|
<a id="dl" download="cutout.png"><button class="action-btn" id="dlbtn" disabled>▼ DOWNLOAD PNG</button></a>
|
||||||
|
</div>
|
||||||
|
<button class="reset-btn" id="reset">✕ CLEAR</button>
|
||||||
|
|
||||||
|
<div class="status" id="status"></div>
|
||||||
|
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- ============ PREVIEW ============ -->
|
||||||
|
<main class="preview-container panel">
|
||||||
|
<div class="preview-tabs">
|
||||||
|
<button class="preview-tab active" id="tabInput" data-view="input" disabled>INPUT [I]</button>
|
||||||
|
<button class="preview-tab" id="tabOutput" data-view="output" disabled>OUTPUT [O]</button>
|
||||||
|
</div>
|
||||||
|
<div class="preview-stage">
|
||||||
|
<div class="no-preview" id="noPreview">[ NO IMAGE LOADED ]</div>
|
||||||
|
<img class="preview-canvas" id="src" alt="" hidden />
|
||||||
|
<img class="preview-canvas checker" id="out" alt="" hidden />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- lightbox -->
|
||||||
|
<div id="lightbox" class="lightbox" hidden>
|
||||||
|
<div class="lb-bar">
|
||||||
|
<span>SCROLL / +− ZOOM · DRAG OR HJKL / ARROWS PAN · 0 RESETS · F / Z / ESC CLOSE</span>
|
||||||
|
<button class="lb-close" id="lbClose" title="Close">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="lb-stage" id="lbStage"><img id="lbImg" alt="" /></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const $ = id => document.getElementById(id);
|
||||||
|
|
||||||
|
const container = $('container');
|
||||||
|
const sidebarToggle = $('sidebarToggle');
|
||||||
|
const drop = $('drop');
|
||||||
|
const fileInput = $('file');
|
||||||
|
const fname = $('fname');
|
||||||
|
const fstats = $('fstats');
|
||||||
|
const thumb = $('thumb');
|
||||||
|
const go = $('go');
|
||||||
|
const dl = $('dl');
|
||||||
|
const dlbtn = $('dlbtn');
|
||||||
|
const statusEl = $('status');
|
||||||
|
const reset = $('reset');
|
||||||
|
const srcImg = $('src');
|
||||||
|
const outImg = $('out');
|
||||||
|
const noPreview = $('noPreview');
|
||||||
|
|
||||||
|
const modelSel = $('model');
|
||||||
|
const resSel = $('resolution');
|
||||||
|
const promptInput= $('prompt');
|
||||||
|
const boxThr = $('boxThr');
|
||||||
|
const textThr = $('textThr');
|
||||||
|
const bgSel = $('background');
|
||||||
|
const maskOffset = $('maskOffset');
|
||||||
|
const maskBlur = $('maskBlur');
|
||||||
|
const cropChk = $('crop');
|
||||||
|
const cropMargin = $('cropMargin');
|
||||||
|
const cropMarginWrap = $('cropMarginWrap');
|
||||||
|
|
||||||
|
const ctlAuto = $('ctl-auto');
|
||||||
|
const ctlPrompt = $('ctl-prompt');
|
||||||
|
const tabInput = $('tabInput');
|
||||||
|
const tabOutput = $('tabOutput');
|
||||||
|
|
||||||
|
let selectedFile = null;
|
||||||
|
let mode = 'auto';
|
||||||
|
|
||||||
|
/* ---- mode ---- */
|
||||||
|
function setGroupEnabled(group, enabled) {
|
||||||
|
group.classList.toggle('disabled', !enabled);
|
||||||
|
group.querySelectorAll('input, select').forEach(el => { el.disabled = !enabled; });
|
||||||
|
}
|
||||||
|
function setMode(m) {
|
||||||
|
mode = m;
|
||||||
|
setGroupEnabled(ctlAuto, m === 'auto');
|
||||||
|
setGroupEnabled(ctlPrompt, m === 'prompt');
|
||||||
|
go.textContent = m === 'auto' ? 'REMOVE BACKGROUND' : 'SEGMENT';
|
||||||
|
}
|
||||||
|
document.querySelectorAll('input[name="mode"]').forEach(r =>
|
||||||
|
r.addEventListener('change', () => setMode(r.value)));
|
||||||
|
|
||||||
|
/* ---- drawers ---- */
|
||||||
|
document.querySelectorAll('.drawer-header').forEach(h =>
|
||||||
|
h.addEventListener('click', () => h.closest('.drawer').classList.toggle('open')));
|
||||||
|
|
||||||
|
/* ---- sidebar toggle (button + 'b' key) ---- */
|
||||||
|
function toggleSidebar() {
|
||||||
|
const collapsed = container.classList.toggle('sidebar-collapsed');
|
||||||
|
sidebarToggle.innerHTML = collapsed ? '›' : '‹';
|
||||||
|
}
|
||||||
|
sidebarToggle.addEventListener('click', toggleSidebar);
|
||||||
|
|
||||||
|
/* ---- sliders ---- */
|
||||||
|
function wireSlider(input, out, fmt) {
|
||||||
|
const update = () => { out.textContent = fmt(input.value); };
|
||||||
|
input.addEventListener('input', update);
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
wireSlider(boxThr, $('boxThrVal'), v => (+v).toFixed(2));
|
||||||
|
wireSlider(textThr, $('textThrVal'), v => (+v).toFixed(2));
|
||||||
|
wireSlider(maskOffset, $('maskOffsetVal'), v => (v > 0 ? '+' : '') + v);
|
||||||
|
wireSlider(maskBlur, $('maskBlurVal'), v => String(v));
|
||||||
|
wireSlider(cropMargin, $('cropMarginVal'), v => (+v).toFixed(2));
|
||||||
|
|
||||||
|
function syncCrop() {
|
||||||
|
cropMarginWrap.classList.toggle('disabled', !cropChk.checked);
|
||||||
|
}
|
||||||
|
cropChk.addEventListener('change', syncCrop);
|
||||||
|
|
||||||
|
/* ---- status ---- */
|
||||||
|
function setStatus(msg, isErr) {
|
||||||
|
statusEl.textContent = msg;
|
||||||
|
statusEl.className = 'status' + (isErr ? ' err' : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- preview tabs ---- */
|
||||||
|
function refreshTabs() {
|
||||||
|
const hasSrc = !!srcImg.getAttribute('src');
|
||||||
|
const hasOut = !!outImg.getAttribute('src');
|
||||||
|
tabInput.disabled = !hasSrc;
|
||||||
|
tabOutput.disabled = !hasOut;
|
||||||
|
noPreview.hidden = hasSrc || hasOut;
|
||||||
|
}
|
||||||
|
function showView(v) {
|
||||||
|
if ((v === 'input' && tabInput.disabled) ||
|
||||||
|
(v === 'output' && tabOutput.disabled)) return;
|
||||||
|
tabInput.classList.toggle('active', v === 'input');
|
||||||
|
tabOutput.classList.toggle('active', v === 'output');
|
||||||
|
srcImg.hidden = v !== 'input';
|
||||||
|
outImg.hidden = v !== 'output';
|
||||||
|
}
|
||||||
|
document.querySelectorAll('.preview-tab').forEach(t =>
|
||||||
|
t.addEventListener('click', () => showView(t.dataset.view)));
|
||||||
|
|
||||||
|
/* ---- file handling ---- */
|
||||||
|
function pickFile(file) {
|
||||||
|
if (!file || !file.type.startsWith('image/')) {
|
||||||
|
setStatus('PLEASE CHOOSE AN IMAGE FILE.', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selectedFile = file;
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
const kb = Math.round(file.size / 1024);
|
||||||
|
fname.textContent = file.name;
|
||||||
|
fstats.textContent = kb + ' KB';
|
||||||
|
thumb.src = url;
|
||||||
|
thumb.hidden = false;
|
||||||
|
drop.classList.add('has-file');
|
||||||
|
srcImg.onload = () => {
|
||||||
|
fstats.textContent =
|
||||||
|
srcImg.naturalWidth + '×' + srcImg.naturalHeight + ' PX · ' + kb + ' KB';
|
||||||
|
};
|
||||||
|
srcImg.src = url;
|
||||||
|
outImg.removeAttribute('src');
|
||||||
|
dlbtn.disabled = true;
|
||||||
|
go.disabled = false;
|
||||||
|
setStatus('');
|
||||||
|
refreshTabs();
|
||||||
|
showView('input');
|
||||||
|
}
|
||||||
|
|
||||||
|
fileInput.addEventListener('change', e => pickFile(e.target.files[0]));
|
||||||
|
['dragenter', 'dragover'].forEach(ev =>
|
||||||
|
drop.addEventListener(ev, e => { e.preventDefault(); drop.classList.add('dragover'); }));
|
||||||
|
['dragleave', 'drop'].forEach(ev =>
|
||||||
|
drop.addEventListener(ev, e => { e.preventDefault(); drop.classList.remove('dragover'); }));
|
||||||
|
drop.addEventListener('drop', e => pickFile(e.dataTransfer.files[0]));
|
||||||
|
|
||||||
|
reset.addEventListener('click', () => {
|
||||||
|
selectedFile = null;
|
||||||
|
fileInput.value = '';
|
||||||
|
fname.textContent = 'NO FILE SELECTED';
|
||||||
|
fstats.textContent = '';
|
||||||
|
thumb.removeAttribute('src');
|
||||||
|
thumb.hidden = true;
|
||||||
|
drop.classList.remove('has-file');
|
||||||
|
srcImg.removeAttribute('src'); srcImg.hidden = true;
|
||||||
|
outImg.removeAttribute('src'); outImg.hidden = true;
|
||||||
|
go.disabled = true;
|
||||||
|
dlbtn.disabled = true;
|
||||||
|
setStatus('');
|
||||||
|
refreshTabs();
|
||||||
|
tabInput.classList.add('active');
|
||||||
|
tabOutput.classList.remove('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
function fileToBase64(file) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const r = new FileReader();
|
||||||
|
r.onload = () => resolve(r.result.split(',')[1]);
|
||||||
|
r.onerror = reject;
|
||||||
|
r.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- lightbox ---- */
|
||||||
|
const lightbox = $('lightbox');
|
||||||
|
const lbStage = $('lbStage');
|
||||||
|
const lbImg = $('lbImg');
|
||||||
|
const lbClose = $('lbClose');
|
||||||
|
let lbScale = 1, lbTx = 0, lbTy = 0, lbDrag = null;
|
||||||
|
|
||||||
|
function lbApply() { lbImg.style.transform = `translate(${lbTx}px, ${lbTy}px) scale(${lbScale})`; }
|
||||||
|
function lbReset() { lbScale = 1; lbTx = 0; lbTy = 0; lbApply(); }
|
||||||
|
|
||||||
|
function openLightbox(src, isResult) {
|
||||||
|
if (!src) return;
|
||||||
|
lbImg.src = src;
|
||||||
|
lbImg.classList.toggle('checker', !!isResult);
|
||||||
|
lbReset();
|
||||||
|
lightbox.hidden = false;
|
||||||
|
}
|
||||||
|
function closeLightbox() { lightbox.hidden = true; lbImg.removeAttribute('src'); }
|
||||||
|
|
||||||
|
/* which preview image is currently shown */
|
||||||
|
function currentImg() {
|
||||||
|
return tabOutput.classList.contains('active') ? outImg : srcImg;
|
||||||
|
}
|
||||||
|
/* 'f' / 'z' — open the zoom view for the visible image, or close it */
|
||||||
|
function toggleZoom() {
|
||||||
|
if (!lightbox.hidden) { closeLightbox(); return; }
|
||||||
|
const img = currentImg();
|
||||||
|
const src = img.getAttribute('src');
|
||||||
|
if (src) openLightbox(src, img === outImg);
|
||||||
|
}
|
||||||
|
/* keyboard zoom — anchored on the centre of the stage */
|
||||||
|
function lbZoom(factor) {
|
||||||
|
const stageRect = lbStage.getBoundingClientRect();
|
||||||
|
const imgRect = lbImg.getBoundingClientRect();
|
||||||
|
const cx = stageRect.left + stageRect.width / 2 - imgRect.left;
|
||||||
|
const cy = stageRect.top + stageRect.height / 2 - imgRect.top;
|
||||||
|
const newScale = Math.min(8, Math.max(1, lbScale * factor));
|
||||||
|
const ratio = newScale / lbScale;
|
||||||
|
lbTx -= cx * (ratio - 1);
|
||||||
|
lbTy -= cy * (ratio - 1);
|
||||||
|
lbScale = newScale;
|
||||||
|
if (lbScale === 1) { lbTx = 0; lbTy = 0; }
|
||||||
|
lbApply();
|
||||||
|
}
|
||||||
|
/* keyboard pan — dx/dy in {-1,0,1}; only meaningful while zoomed in */
|
||||||
|
function lbPan(dx, dy) {
|
||||||
|
if (lbScale <= 1) return;
|
||||||
|
lbTx += dx * 80;
|
||||||
|
lbTy += dy * 80;
|
||||||
|
lbApply();
|
||||||
|
}
|
||||||
|
|
||||||
|
srcImg.addEventListener('click', () => openLightbox(srcImg.getAttribute('src'), false));
|
||||||
|
outImg.addEventListener('click', () => openLightbox(outImg.getAttribute('src'), true));
|
||||||
|
lbClose.addEventListener('click', closeLightbox);
|
||||||
|
lightbox.addEventListener('mousedown', e => {
|
||||||
|
if (e.target === lightbox || e.target === lbStage) closeLightbox();
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||||
|
const k = e.key.toLowerCase();
|
||||||
|
|
||||||
|
// --- zoom view: capture all navigation keys while it's open ---
|
||||||
|
if (!lightbox.hidden) {
|
||||||
|
if (e.key === 'Escape' || k === 'f' || k === 'z') closeLightbox();
|
||||||
|
else if (k === '+' || k === '=') lbZoom(1.25);
|
||||||
|
else if (k === '-' || k === '_') lbZoom(1 / 1.25);
|
||||||
|
else if (k === '0') lbReset();
|
||||||
|
else if (e.key === 'ArrowLeft' || k === 'h') lbPan( 1, 0);
|
||||||
|
else if (e.key === 'ArrowRight' || k === 'l') lbPan(-1, 0);
|
||||||
|
else if (e.key === 'ArrowUp' || k === 'k') lbPan( 0, 1);
|
||||||
|
else if (e.key === 'ArrowDown' || k === 'j') lbPan( 0, -1);
|
||||||
|
else return;
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- main UI shortcuts (ignored while typing in a field) ---
|
||||||
|
const t = e.target;
|
||||||
|
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT')) return;
|
||||||
|
if (k === 'b') toggleSidebar();
|
||||||
|
else if (k === 'u') fileInput.click();
|
||||||
|
else if (k === 'i') showView('input');
|
||||||
|
else if (k === 'o') showView('output');
|
||||||
|
else if (k === 'f' || k === 'z') toggleZoom();
|
||||||
|
else if (k === 's' && !dlbtn.disabled) dl.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
lbStage.addEventListener('wheel', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const rect = lbImg.getBoundingClientRect();
|
||||||
|
const cx = e.clientX - rect.left, cy = e.clientY - rect.top;
|
||||||
|
const factor = e.deltaY < 0 ? 1.2 : 1 / 1.2;
|
||||||
|
const newScale = Math.min(8, Math.max(1, lbScale * factor));
|
||||||
|
const ratio = newScale / lbScale;
|
||||||
|
lbTx -= cx * (ratio - 1);
|
||||||
|
lbTy -= cy * (ratio - 1);
|
||||||
|
lbScale = newScale;
|
||||||
|
if (lbScale === 1) { lbTx = 0; lbTy = 0; }
|
||||||
|
lbApply();
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
lbImg.addEventListener('mousedown', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
lbDrag = { x: e.clientX, y: e.clientY, tx: lbTx, ty: lbTy };
|
||||||
|
lbStage.classList.add('grabbing');
|
||||||
|
});
|
||||||
|
window.addEventListener('mousemove', e => {
|
||||||
|
if (!lbDrag) return;
|
||||||
|
lbTx = lbDrag.tx + (e.clientX - lbDrag.x);
|
||||||
|
lbTy = lbDrag.ty + (e.clientY - lbDrag.y);
|
||||||
|
lbApply();
|
||||||
|
});
|
||||||
|
window.addEventListener('mouseup', () => {
|
||||||
|
lbDrag = null;
|
||||||
|
lbStage.classList.remove('grabbing');
|
||||||
|
});
|
||||||
|
lbImg.addEventListener('dblclick', e => { e.preventDefault(); lbReset(); });
|
||||||
|
|
||||||
|
/* ---- run ---- */
|
||||||
|
go.addEventListener('click', async () => {
|
||||||
|
if (!selectedFile) return;
|
||||||
|
if (mode === 'prompt' && !promptInput.value.trim()) {
|
||||||
|
setStatus('ENTER A PROMPT DESCRIBING WHAT TO KEEP.', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
go.disabled = true;
|
||||||
|
dlbtn.disabled = true;
|
||||||
|
setStatus('PROCESSING… (FIRST USE OF A MODEL DOWNLOADS ITS WEIGHTS)');
|
||||||
|
const t0 = performance.now();
|
||||||
|
try {
|
||||||
|
const b64 = await fileToBase64(selectedFile);
|
||||||
|
const shared = {
|
||||||
|
image: b64,
|
||||||
|
background: bgSel.value,
|
||||||
|
mask_offset: parseInt(maskOffset.value, 10) || 0,
|
||||||
|
mask_blur: parseInt(maskBlur.value, 10) || 0,
|
||||||
|
crop: cropChk.checked,
|
||||||
|
crop_margin: parseFloat(cropMargin.value) || 0,
|
||||||
|
};
|
||||||
|
let endpoint, body;
|
||||||
|
if (mode === 'auto') {
|
||||||
|
endpoint = '/predict';
|
||||||
|
body = { ...shared, model: modelSel.value, resolution: parseInt(resSel.value, 10) };
|
||||||
|
} else {
|
||||||
|
endpoint = '/segment';
|
||||||
|
body = { ...shared, prompt: promptInput.value.trim(),
|
||||||
|
box_threshold: parseFloat(boxThr.value) || 0.3,
|
||||||
|
text_threshold: parseFloat(textThr.value) || 0.25 };
|
||||||
|
}
|
||||||
|
const resp = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error('HTTP ' + resp.status + ': ' + (await resp.text()));
|
||||||
|
const data = await resp.json();
|
||||||
|
const dataUrl = 'data:image/png;base64,' + data.image;
|
||||||
|
outImg.src = dataUrl;
|
||||||
|
dl.href = dataUrl;
|
||||||
|
dl.download = selectedFile.name.replace(/\.[^.]+$/, '') + '.png';
|
||||||
|
dlbtn.disabled = false;
|
||||||
|
refreshTabs();
|
||||||
|
showView('output');
|
||||||
|
const secs = ((performance.now() - t0) / 1000).toFixed(1);
|
||||||
|
if (mode === 'auto') {
|
||||||
|
setStatus(`DONE — ${data.width}×${data.height} · ${data.model} @ ${data.resolution} · ${secs}S`);
|
||||||
|
} else {
|
||||||
|
const n = data.detections;
|
||||||
|
setStatus(`DONE — ${n} OBJECT${n === 1 ? '' : 'S'} MATCHED "${data.prompt}" · ` +
|
||||||
|
`${data.width}×${data.height} · ${secs}S` +
|
||||||
|
(n === 0 ? ' (TRY A LOWER BOX THRESHOLD)' : ''));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setStatus(err.message || String(err), true);
|
||||||
|
} finally {
|
||||||
|
go.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ---- init ---- */
|
||||||
|
setMode('auto');
|
||||||
|
syncCrop();
|
||||||
|
refreshTabs();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1017
src/rmbg_as_a_service/static/styles.css
Normal file
1017
src/rmbg_as_a_service/static/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user