feat: improve remove background with edge smoothing, color decontamination, output formats

- Expose birefnet-hr-matting in UI (People/Ultra) and fix model defaults
  (People/Max now uses birefnet-matting for true alpha matting)
- Add output format selector (PNG/WebP/AVIF) with lossless alpha support
- Add edge smoothing post-processing (Off/Light/Medium/Strong) via
  morphological mask refinement to reduce gray halo artifacts
- Add color decontamination to remove background color spill from
  semi-transparent edge pixels
- Thread new settings through full stack: frontend -> API schema ->
  Python sidecar -> Sharp effects pipeline
- Add i18n keys for all 21 locales
- Add unit tests for new option serialization (3 tests)
- Add integration tests for new settings validation (4 tests)
This commit is contained in:
SnapOtter
2026-06-05 23:05:25 +08:00
parent f7282afe92
commit 80957f6e10
28 changed files with 485 additions and 15 deletions
+79 -1
View File
@@ -5,10 +5,77 @@ import os
def emit_progress(percent, stage):
"""Emit structured progress to stderr for bridge.ts to capture."""
print(json.dumps({"progress": percent, "stage": stage}), file=sys.stderr, flush=True)
def _refine_edges(image_bytes, level):
"""Morphological mask refinement to reduce gray halos on edges.
level: 1=light, 2=medium, 3=strong
"""
import cv2
import numpy as np
from PIL import Image
import io
img = Image.open(io.BytesIO(image_bytes)).convert("RGBA")
arr = np.array(img)
alpha = arr[:, :, 3]
kernel_size = 1 + level
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (kernel_size, kernel_size))
alpha = cv2.morphologyEx(alpha, cv2.MORPH_CLOSE, kernel)
sigma = 0.3 + level * 0.3
alpha = cv2.GaussianBlur(alpha, (0, 0), sigma)
arr[:, :, 3] = alpha
out = Image.fromarray(arr, "RGBA")
buf = io.BytesIO()
out.save(buf, format="PNG")
return buf.getvalue()
def _decontaminate_edges(image_bytes):
"""Remove background color spill from semi-transparent edge pixels."""
import numpy as np
from PIL import Image
import io
img = Image.open(io.BytesIO(image_bytes)).convert("RGBA")
arr = np.array(img, dtype=np.float32)
alpha = arr[:, :, 3] / 255.0
rgb = arr[:, :, :3]
bg_mask = alpha < 0.04
if not np.any(bg_mask):
return image_bytes
bg_color = np.zeros(3, dtype=np.float32)
for c in range(3):
channel = rgb[:, :, c]
bg_pixels = channel[bg_mask]
if len(bg_pixels) > 0:
bg_color[c] = np.median(bg_pixels)
edge_mask = (alpha > 0.04) & (alpha < 0.96)
if not np.any(edge_mask):
return image_bytes
a = alpha[edge_mask, np.newaxis]
fg = rgb[edge_mask]
corrected = (fg - bg_color[np.newaxis, :] * (1.0 - a)) / np.maximum(a, 0.01)
corrected = np.clip(corrected, 0, 255)
rgb[edge_mask] = corrected
arr[:, :, :3] = rgb
result = np.clip(arr, 0, 255).astype(np.uint8)
out = Image.fromarray(result, "RGBA")
buf = io.BytesIO()
out.save(buf, format="PNG")
return buf.getvalue()
ALLOWED_MODELS = {
"u2net",
"isnet-general-use",
@@ -167,6 +234,17 @@ def main():
emit_progress(80, "Background removed")
edge_refine = settings.get("edgeRefine", 0)
decontaminate = settings.get("decontaminate", False)
if edge_refine and edge_refine > 0:
emit_progress(85, "Refining edges")
output_data = _refine_edges(output_data, int(edge_refine))
if decontaminate:
emit_progress(90, "Removing color spill")
output_data = _decontaminate_edges(output_data)
# Always return transparent PNG. All background compositing
# (solid color, gradient, blur, shadow) is handled by Node.js/Sharp.
+2
View File
@@ -8,6 +8,8 @@ import { type ProgressCallback, parseStdoutJson, runPythonWithProgress } from ".
export interface RemoveBackgroundOptions {
model?: string;
backgroundColor?: string;
edgeRefine?: number;
decontaminate?: boolean;
}
const MAX_REMBG_PX = Number(process.env.MAX_REMBG_PX) || 2048;
+7
View File
@@ -587,6 +587,13 @@ export const ar: TranslationKeys = {
intensity: "الشدة",
addShadow: "إضافة ظل",
opacity: "الشفافية",
outputFormat: "Output Format",
edgeSmoothing: "Edge Smoothing",
edgeSmoothingOff: "Off",
edgeSmoothingLight: "Light",
edgeSmoothingMedium: "Medium",
edgeSmoothingStrong: "Strong",
colorDecontamination: "Color Decontamination",
rendering: "جاري المعالجة...",
submit: "إزالة الخلفية",
submitBatch: "إزالة الخلفية ({count} ملف)",
+7
View File
@@ -595,6 +595,13 @@ export const de: TranslationKeys = {
intensity: "Intensitaet",
addShadow: "Schatten hinzufuegen",
opacity: "Deckkraft",
outputFormat: "Output Format",
edgeSmoothing: "Edge Smoothing",
edgeSmoothingOff: "Off",
edgeSmoothingLight: "Light",
edgeSmoothingMedium: "Medium",
edgeSmoothingStrong: "Strong",
colorDecontamination: "Color Decontamination",
rendering: "Wird gerendert...",
submit: "Hintergrund entfernen",
submitBatch: "Hintergrund entfernen ({count} Dateien)",
+7
View File
@@ -545,6 +545,13 @@ export const en = {
intensity: "Intensity",
addShadow: "Add Shadow",
opacity: "Opacity",
outputFormat: "Output Format",
edgeSmoothing: "Edge Smoothing",
edgeSmoothingOff: "Off",
edgeSmoothingLight: "Light",
edgeSmoothingMedium: "Medium",
edgeSmoothingStrong: "Strong",
colorDecontamination: "Color Decontamination",
rendering: "Rendering...",
submit: "Remove Background",
submitBatch: "Remove Background ({count} files)",
+7
View File
@@ -579,6 +579,13 @@ export const es: TranslationKeys = {
intensity: "Intensidad",
addShadow: "Agregar sombra",
opacity: "Opacidad",
outputFormat: "Output Format",
edgeSmoothing: "Edge Smoothing",
edgeSmoothingOff: "Off",
edgeSmoothingLight: "Light",
edgeSmoothingMedium: "Medium",
edgeSmoothingStrong: "Strong",
colorDecontamination: "Color Decontamination",
rendering: "Renderizando...",
submit: "Eliminar fondo",
submitBatch: "Eliminar fondo ({count} archivos)",
+7
View File
@@ -596,6 +596,13 @@ export const fr: TranslationKeys = {
intensity: "Intensite",
addShadow: "Ajouter une ombre",
opacity: "Opacite",
outputFormat: "Output Format",
edgeSmoothing: "Edge Smoothing",
edgeSmoothingOff: "Off",
edgeSmoothingLight: "Light",
edgeSmoothingMedium: "Medium",
edgeSmoothingStrong: "Strong",
colorDecontamination: "Color Decontamination",
rendering: "Rendu en cours...",
submit: "Supprimer l'arriere-plan",
submitBatch: "Supprimer l'arriere-plan ({count} fichiers)",
+7
View File
@@ -583,6 +583,13 @@ export const hi: TranslationKeys = {
intensity: "तीव्रता",
addShadow: "शैडो जोड़ें",
opacity: "ओपेसिटी",
outputFormat: "Output Format",
edgeSmoothing: "Edge Smoothing",
edgeSmoothingOff: "Off",
edgeSmoothingLight: "Light",
edgeSmoothingMedium: "Medium",
edgeSmoothingStrong: "Strong",
colorDecontamination: "Color Decontamination",
rendering: "रेंडर हो रहा है...",
submit: "बैकग्राउंड हटाएं",
submitBatch: "बैकग्राउंड हटाएं ({count} फाइलें)",
+7
View File
@@ -593,6 +593,13 @@ export const id: TranslationKeys = {
intensity: "Intensitas",
addShadow: "Tambah Bayangan",
opacity: "Opasitas",
outputFormat: "Output Format",
edgeSmoothing: "Edge Smoothing",
edgeSmoothingOff: "Off",
edgeSmoothingLight: "Light",
edgeSmoothingMedium: "Medium",
edgeSmoothingStrong: "Strong",
colorDecontamination: "Color Decontamination",
rendering: "Merender...",
submit: "Hapus Latar Belakang",
submitBatch: "Hapus Latar Belakang ({count} file)",
+7
View File
@@ -592,6 +592,13 @@ export const it: TranslationKeys = {
intensity: "Intensita",
addShadow: "Aggiungi ombra",
opacity: "Opacita",
outputFormat: "Output Format",
edgeSmoothing: "Edge Smoothing",
edgeSmoothingOff: "Off",
edgeSmoothingLight: "Light",
edgeSmoothingMedium: "Medium",
edgeSmoothingStrong: "Strong",
colorDecontamination: "Color Decontamination",
rendering: "Rendering...",
submit: "Rimuovi sfondo",
submitBatch: "Rimuovi sfondo ({count} file)",
+7
View File
@@ -552,6 +552,13 @@ export const ja: TranslationKeys = {
intensity: "強度",
addShadow: "シャドウ追加",
opacity: "不透明度",
outputFormat: "Output Format",
edgeSmoothing: "Edge Smoothing",
edgeSmoothingOff: "Off",
edgeSmoothingLight: "Light",
edgeSmoothingMedium: "Medium",
edgeSmoothingStrong: "Strong",
colorDecontamination: "Color Decontamination",
rendering: "レンダリング中...",
submit: "背景を除去",
submitBatch: "背景を除去({count}ファイル)",
+7
View File
@@ -539,6 +539,13 @@ export const ko: TranslationKeys = {
intensity: "강도",
addShadow: "그림자 추가",
opacity: "불투명도",
outputFormat: "Output Format",
edgeSmoothing: "Edge Smoothing",
edgeSmoothingOff: "Off",
edgeSmoothingLight: "Light",
edgeSmoothingMedium: "Medium",
edgeSmoothingStrong: "Strong",
colorDecontamination: "Color Decontamination",
rendering: "렌더링 중...",
submit: "배경 제거",
submitBatch: "배경 제거 ({count}개 파일)",
+7
View File
@@ -593,6 +593,13 @@ export const nl: TranslationKeys = {
intensity: "Intensiteit",
addShadow: "Schaduw toevoegen",
opacity: "Dekking",
outputFormat: "Output Format",
edgeSmoothing: "Edge Smoothing",
edgeSmoothingOff: "Off",
edgeSmoothingLight: "Light",
edgeSmoothingMedium: "Medium",
edgeSmoothingStrong: "Strong",
colorDecontamination: "Color Decontamination",
rendering: "Renderen...",
submit: "Achtergrond verwijderen",
submitBatch: "Achtergrond verwijderen ({count} bestanden)",
+7
View File
@@ -596,6 +596,13 @@ export const pl: TranslationKeys = {
intensity: "Intensywność",
addShadow: "Dodaj cień",
opacity: "Krycie",
outputFormat: "Output Format",
edgeSmoothing: "Edge Smoothing",
edgeSmoothingOff: "Off",
edgeSmoothingLight: "Light",
edgeSmoothingMedium: "Medium",
edgeSmoothingStrong: "Strong",
colorDecontamination: "Color Decontamination",
rendering: "Renderowanie...",
submit: "Usuń tło",
submitBatch: "Usuń tło ({count} plików)",
+7
View File
@@ -592,6 +592,13 @@ export const ptBR: TranslationKeys = {
intensity: "Intensidade",
addShadow: "Adicionar sombra",
opacity: "Opacidade",
outputFormat: "Output Format",
edgeSmoothing: "Edge Smoothing",
edgeSmoothingOff: "Off",
edgeSmoothingLight: "Light",
edgeSmoothingMedium: "Medium",
edgeSmoothingStrong: "Strong",
colorDecontamination: "Color Decontamination",
rendering: "Renderizando...",
submit: "Remover fundo",
submitBatch: "Remover fundo ({count} arquivos)",
+7
View File
@@ -594,6 +594,13 @@ export const ru: TranslationKeys = {
intensity: "Интенсивность",
addShadow: "Добавить тень",
opacity: "Непрозрачность",
outputFormat: "Output Format",
edgeSmoothing: "Edge Smoothing",
edgeSmoothingOff: "Off",
edgeSmoothingLight: "Light",
edgeSmoothingMedium: "Medium",
edgeSmoothingStrong: "Strong",
colorDecontamination: "Color Decontamination",
rendering: "Рендеринг...",
submit: "Удалить фон",
submitBatch: "Удалить фон ({count} файлов)",
+7
View File
@@ -591,6 +591,13 @@ export const sv: TranslationKeys = {
intensity: "Intensitet",
addShadow: "Lagg till skugga",
opacity: "Opacitet",
outputFormat: "Output Format",
edgeSmoothing: "Edge Smoothing",
edgeSmoothingOff: "Off",
edgeSmoothingLight: "Light",
edgeSmoothingMedium: "Medium",
edgeSmoothingStrong: "Strong",
colorDecontamination: "Color Decontamination",
rendering: "Renderar...",
submit: "Ta bort bakgrund",
submitBatch: "Ta bort bakgrund ({count} filer)",
+7
View File
@@ -583,6 +583,13 @@ export const th: TranslationKeys = {
intensity: "ความเข้ม",
addShadow: "เพิ่มเงา",
opacity: "ความทึบ",
outputFormat: "Output Format",
edgeSmoothing: "Edge Smoothing",
edgeSmoothingOff: "Off",
edgeSmoothingLight: "Light",
edgeSmoothingMedium: "Medium",
edgeSmoothingStrong: "Strong",
colorDecontamination: "Color Decontamination",
rendering: "กำลังเรนเดอร์...",
submit: "ลบพื้นหลัง",
submitBatch: "ลบพื้นหลัง ({count} ไฟล์)",
+7
View File
@@ -595,6 +595,13 @@ export const tr: TranslationKeys = {
intensity: "Yoğunluk",
addShadow: "Gölge Ekle",
opacity: "Opaklık",
outputFormat: "Output Format",
edgeSmoothing: "Edge Smoothing",
edgeSmoothingOff: "Off",
edgeSmoothingLight: "Light",
edgeSmoothingMedium: "Medium",
edgeSmoothingStrong: "Strong",
colorDecontamination: "Color Decontamination",
rendering: "İşleniyor...",
submit: "Arka Planı Kaldır",
submitBatch: "Arka Planı Kaldır ({count} dosya)",
+7
View File
@@ -594,6 +594,13 @@ export const uk: TranslationKeys = {
intensity: "Інтенсивність",
addShadow: "Додати тінь",
opacity: "Непрозорість",
outputFormat: "Output Format",
edgeSmoothing: "Edge Smoothing",
edgeSmoothingOff: "Off",
edgeSmoothingLight: "Light",
edgeSmoothingMedium: "Medium",
edgeSmoothingStrong: "Strong",
colorDecontamination: "Color Decontamination",
rendering: "Рендеринг...",
submit: "Видалити фон",
submitBatch: "Видалити фон ({count} файлів)",
+7
View File
@@ -594,6 +594,13 @@ export const vi: TranslationKeys = {
intensity: "Cường độ",
addShadow: "Thêm bóng đổ",
opacity: "Độ mờ",
outputFormat: "Output Format",
edgeSmoothing: "Edge Smoothing",
edgeSmoothingOff: "Off",
edgeSmoothingLight: "Light",
edgeSmoothingMedium: "Medium",
edgeSmoothingStrong: "Strong",
colorDecontamination: "Color Decontamination",
rendering: "Đang kết xuất...",
submit: "Xóa nền",
submitBatch: "Xóa nền ({count} tệp)",
+7
View File
@@ -537,6 +537,13 @@ export const zhCN: TranslationKeys = {
intensity: "强度",
addShadow: "添加阴影",
opacity: "不透明度",
outputFormat: "Output Format",
edgeSmoothing: "Edge Smoothing",
edgeSmoothingOff: "Off",
edgeSmoothingLight: "Light",
edgeSmoothingMedium: "Medium",
edgeSmoothingStrong: "Strong",
colorDecontamination: "Color Decontamination",
rendering: "渲染中...",
submit: "移除背景",
submitBatch: "移除背景({count} 个文件)",
+7
View File
@@ -536,6 +536,13 @@ export const zhTW: TranslationKeys = {
intensity: "強度",
addShadow: "加入陰影",
opacity: "不透明度",
outputFormat: "Output Format",
edgeSmoothing: "Edge Smoothing",
edgeSmoothingOff: "Off",
edgeSmoothingLight: "Light",
edgeSmoothingMedium: "Medium",
edgeSmoothingStrong: "Strong",
colorDecontamination: "Color Decontamination",
rendering: "算繪中...",
submit: "移除背景",
submitBatch: "移除背景({count}個檔案)",