Use deployment routing and preserve refined foreground in remote RMBG

This commit is contained in:
xuyucai
2026-11-04 20:00:42 +08:00
parent f7af510ad8
commit 52638dfe8c
10 changed files with 222 additions and 192 deletions
+13 -8
View File
@@ -21,20 +21,25 @@ URL 模式沿用现有 OSS 上传配置:`FD_OSS_BUCKET_NAME`、`FD_OSS_ENDPOIN
若模型服务开启鉴权,分别配置 `FD_SAM2_API_KEY` 和 `FD_DWPOSE_API_KEY`,节点会对推理与健康检查请求附加 `Authorization: Bearer <KEY>`。未配置时不附加鉴权头。
## 远程 RMBG 传输质量(单选)
## 远程 RMBG 部署环境与传输
`ZhiYiRMBGNode` / `ZhiYiRMBGNodeV2` 在末尾提供可选单选项 `image_encoding`:
`ZhiYiRMBGNode` / `ZhiYiRMBGNodeV2` 使用 Comfy 进程环境变量路由,不再提供 `env`、`service_url`、`service_url_preset` 或三个 `return_*` 节点输入。旧 API JSON 中残留的这些字段会被忽略;不会覆盖部署配置。
- `png`(默认):无损 PNG 推理输入;旧工作流缺少该字段时仍使用 PNG。
- `jpeg_q95`:原尺寸 RGB JPEG 推理副本,quality=95、4:2:0、optimize=true、非 progressive。原输入无论 PNG 或 JPEG 都统一重编码为 Q95。
| `FD_COMFY_RUNTIME_ENV` | 默认服务 | `image_encoding` 缺省值 |
|---|---|---|
| `dev`(未设置时) | `http://10.1.0.230:8003/v1/rmbg` | `png` |
| `domestic` | `http://model-api-dwpose-svc.online-server-gray:8001/v1/rmbg` | `png` |
| `overseas` | `FD_OVERSEAS_GPU_GATEWAY` 下的 `/model-api-dwpose/v1/rmbg` | `jpeg_q95` |
质量选择与 `env` / `service_url` / `image_delivery` 独立。两种质量均仅请求无损 8 位灰度 PNG mask(`return_image=false`、`return_mask=true`、`return_mask_image=false`),客户端用压缩前原始 RGB 与 mask 合成 Alpha 透明图或 Color 背景换色图,并本地生成 MASK_IMAGE。保留旧 return 入参以兼容 JSON,RMBG 请求固定使用上述实际返回开关;衣物/时尚/身体分割仍沿用原返回参数,不新增质量选项。
部署可用 `FD_RMBG_URL` 覆盖服务地址(包括生产依赖地址);非法运行环境在加载配置时明确失败。修改部署变量需重启进程。国内集群与 102 保持无损 PNG,海外默认 JPEG Q95;节点末尾仍可用 `image_encoding=png/jpeg_q95` 手动指定,已保存的显式选择优先于缺省值。Q95 使用原尺寸 RGB、quality=95、4:2:0、optimize=true、非 progressive;有损压缩可能改变 mask 边界和背景残留。
不再读取 `FD_RMBG_REMOTE_OPTIMIZATION` 或 `FD_OVERSEAS_RMBG_MASK_ONLY`。刷新界面后即可在节点选择质量;无需通过环境变量切换或重启进程。INFO 和正式调用日志记录 `image_encoding`、`mask_only_response`,可直接确认 Q95 是否生效。
- `refine_foreground=false`:只请求无损 8 位灰度 PNG mask,用压缩前原始 RGB 本地合成 Alpha/Color 输出。
- `refine_foreground=true`:请求精修图片和 mask,使用服务端精修后的像素;Alpha 保留 RGBA,不用原图覆盖精修结果。服务端漏返图片时明确失败。
- MASK_IMAGE 始终本地从 mask 生成,不传输重复预览。
仅 mask 响应要求 `image_size` 与原图一致,灰度 PNG 的实际尺寸与 `output_size` 一致,且输出尺寸属于原图尺寸或声明的 `inference_size`。推理尺寸的 mask 按现有 BILINEAR 规则恢复原尺寸;缺失、损坏、错误尺寸、非灰度 PNG 或服务仍返回成图均明确报错。
仅 mask 响应校验声明尺寸、原图尺寸、8 位灰度 PNG 编码与返回字段;推理尺寸 mask 按 BILINEAR 恢复原尺寸,异常明确失败。INFO 和调用日志记录编码、返回模式及实际服务地址,INFO 额外记录运行环境。
选择 Q95 时,`image_delivery=url` 上传内容 MD5 命名的 `.jpg`,base64 模式使用 `data:image/jpeg;base64,`。服务端 `image_format=png_base64` 是响应格式,保持不变。JPEG 有损压缩可能改变分割边界和背景残留,可选择 PNG 比较效果。该选项不自动替换工作流中的本地 RMBG 或 ClothesSegment,款式上身 #313 保持现状。
保留 `image_delivery=base64/url` 及既有海外 URL 传输约束;Q95 URL 上传 `.jpg`,Base64 使用 `data:image/jpeg;base64,`,服务端响应仍为 PNG。不再读取旧 `FD_RMBG_REMOTE_OPTIMIZATION` / `FD_OVERSEAS_RMBG_MASK_ONLY`。衣物/时尚/身体分割及本地 RMBG 不受本次契约调整影响。
> [!NOTE]
> This projected was created with a [cookiecutter](https://github.com/Comfy-Org/cookiecutter-comfy-extension) template. It helps you start writing custom nodes without worrying about the Python setup.
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "Comfyui_Fd_Nodes"
version = "53.8.73" # 999开头的版本是debug版
version = "53.8.74" # 999开头的版本是debug版
description = "Custom nodes for fd project"
authors = [
{name = "xuyucai", email = "986327386@qq.com"}
+10
View File
@@ -139,6 +139,16 @@ def _derive_dwpose_preprocess_url(endpoint: str) -> str:
FD_CONTROLNET_AUX_LINEART_URL = os.getenv("FD_CONTROLNET_AUX_LINEART_URL") or _derive_dwpose_preprocess_url("lineart")
FD_CONTROLNET_AUX_DEPTH_ANYTHING_V2_URL = os.getenv("FD_CONTROLNET_AUX_DEPTH_ANYTHING_V2_URL") or _derive_dwpose_preprocess_url("depth-anything-v2")
FD_RMBG_URL = os.getenv("FD_RMBG_URL", "http://10.1.0.230:8003/v1/rmbg")
# RMBG routing belongs to the Comfy process, never to a saved workflow.
FD_COMFY_RUNTIME_ENV = os.getenv("FD_COMFY_RUNTIME_ENV", "dev")
if FD_COMFY_RUNTIME_ENV not in ("dev", "domestic", "overseas"):
raise ValueError("FD_COMFY_RUNTIME_ENV must be dev, domestic or overseas")
RMBG_RUNTIME_URL = os.getenv("FD_RMBG_URL") or {
"dev": "http://10.1.0.230:8003/v1/rmbg",
"domestic": "http://model-api-dwpose-svc.online-server-gray:8001/v1/rmbg",
"overseas": f"{FD_OVERSEAS_DWPOSE_URL}/v1/rmbg",
}[FD_COMFY_RUNTIME_ENV]
RMBG_DEFAULT_IMAGE_ENCODING = "jpeg_q95" if FD_COMFY_RUNTIME_ENV == "overseas" else "png"
FD_CLOTHES_SEGMENT_URL = os.getenv("FD_CLOTHES_SEGMENT_URL", "http://10.1.0.230:8003/v1/segment/clothes")
FD_FASHION_SEGMENT_URL = os.getenv("FD_FASHION_SEGMENT_URL", "http://10.1.0.230:8003/v1/segment/fashion")
FD_BODY_SEGMENT_URL = os.getenv("FD_BODY_SEGMENT_URL", "http://10.1.0.230:8003/v1/segment/body")
@@ -0,0 +1,24 @@
import { app } from "../../scripts/app.js";
// LiteGraph stores widgets positionally. Remove retired fields before loading
// an old workflow so process_res/refine/background/encoding cannot shift.
app.registerExtension({
name: "ZhiYiRMBG.DeploymentInputs",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (!["ZhiYiRMBGNode", "ZhiYiRMBGNodeV2"].includes(nodeData.name)) return;
const configure = nodeType.prototype.configure;
nodeType.prototype.configure = function (info) {
const values = info.widgets_values;
if (Array.isArray(values) && typeof values[0] === "string") {
const v2 = nodeData.name === "ZhiYiRMBGNodeV2";
const indices = v2
? [2, 3, 4, 5, 6, 7, 8, 9, 13, 14, 15, 16, 17, 18]
: [1, 2, 3, 4, 5, 6, 7, 8, 12, 13, 14, 15, 17, 18];
info = { ...info, widgets_values: indices.map((index, i) => (
values[index] ?? this.widgets[i].value
)) };
}
return configure.call(this, info);
};
},
});
@@ -5,7 +5,6 @@ from ..config import (
FD_BODY_SEGMENT_URL,
FD_CLOTHES_SEGMENT_URL,
FD_FASHION_SEGMENT_URL,
FD_RMBG_URL,
RMBG_SERVICE_ENVS,
)
from ..utils.image_payload import (
@@ -18,108 +17,14 @@ from ..zhiyi_rmbg_segment_node import (
BODY_CLASSES,
CLOTHES_CLASSES,
FASHION_CLASSES,
RMBG_IMAGE_ENCODINGS,
RMBG_IMAGE_ENCODING_CONFIG,
ZhiYiRMBGNode,
_RmbgSegmentApiBase,
)
from .env import resolve_service_env, service_env_options
class ZhiYiRMBGNodeV2(_RmbgSegmentApiBase):
"""知衣 RMBG 2.0 背景去除节点 V2 - 支持灰度/开发/海外环境一键切换。"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"env": (service_env_options(), {
"default": "开发环境",
"tooltip": "灰度/开发/海外环境使用内置地址(忽略 service_url 与 FD_* 环境变量);自定义使用 service_url",
}),
"service_url": ("STRING", {
"default": FD_RMBG_URL or "http://10.1.0.230:8003/v1/rmbg",
"multiline": False,
"tooltip": "RMBG 2.0 背景去除接口地址,例如 http://host:8003/v1/rmbg;仅环境=自定义时使用",
}),
"process_res": ("INT", {"default": 1024, "min": 256, "max": 2048, "step": 64}),
"sensitivity": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.05}),
"mask_blur": ("INT", {"default": 0, "min": 0, "max": 64, "step": 1}),
"mask_offset": ("INT", {"default": 0, "min": -64, "max": 64, "step": 1}),
"invert_output": ("BOOLEAN", {"default": False}),
"refine_foreground": ("BOOLEAN", {"default": False}),
"background": (BACKGROUND_MODES, {"default": "Alpha"}),
"background_color": ("STRING", {"default": "#222222"}),
"return_image": ("BOOLEAN", {"default": True}),
"return_mask": ("BOOLEAN", {"default": True}),
"return_mask_image": ("BOOLEAN", {"default": False}),
"max_concurrency": ("INT", {"default": 1, "min": 1, "max": 16, "step": 1}),
"timeout": ("INT", {"default": 180, "min": 10, "max": 1200, "step": 10}),
},
"optional": {
"health_check": ("BOOLEAN", {"default": False}),
"node_switch": ("INT", {"default": 0, "min": 0, "max": 1, "step": 1}),
"image_delivery": (IMAGE_DELIVERY_OPTIONS, image_delivery_input_config()),
"image_encoding": (RMBG_IMAGE_ENCODINGS, RMBG_IMAGE_ENCODING_CONFIG),
},
}
RETURN_TYPES = ("IMAGE", "MASK", "IMAGE", "JSON")
RETURN_NAMES = ("IMAGE", "MASK", "MASK_IMAGE", "INFO")
FUNCTION = "remove_background"
CATEGORY = "知衣/抠图"
OUTPUT_NODE = False
def remove_background(
self,
image,
env,
service_url,
process_res,
sensitivity,
mask_blur,
mask_offset,
invert_output,
refine_foreground,
background,
background_color,
return_image,
return_mask,
return_mask_image,
max_concurrency,
timeout,
health_check=False,
node_switch=0,
image_delivery=IMAGE_DELIVERY_BASE64,
image_encoding: str = "png",
):
resolved_base = resolve_service_env(env, RMBG_SERVICE_ENVS)
return self._execute(
image=image,
service_url=resolved_base or service_url,
endpoint="rmbg",
default_url=FD_RMBG_URL or "http://10.1.0.230:8003/v1/rmbg",
health_key="rmbg",
label="知衣RMBG2.0背景去除",
process_res=process_res,
mask_blur=mask_blur,
mask_offset=mask_offset,
invert_output=invert_output,
background=background,
background_color=background_color,
return_image=return_image,
return_mask=return_mask,
return_mask_image=return_mask_image,
max_concurrency=max_concurrency,
timeout=timeout,
health_check=health_check,
node_switch=node_switch,
service_url_preset=CUSTOM_SERVICE_URL_PRESET,
image_delivery=image_delivery,
image_encoding=image_encoding,
extra={"sensitivity": float(sensitivity), "refine_foreground": bool(refine_foreground)},
info_extra={"sensitivity": float(sensitivity), "refine_foreground": bool(refine_foreground)},
)
class ZhiYiRMBGNodeV2(ZhiYiRMBGNode):
"""RMBG V2 shares the deployment-controlled RMBG contract with V1."""
class ZhiYiClothesSegmentNodeV2(_RmbgSegmentApiBase):
+47 -48
View File
@@ -18,7 +18,9 @@ from .config import (
FD_CLOTHES_SEGMENT_URL,
FD_DWPOSE_API_KEY,
FD_FASHION_SEGMENT_URL,
FD_RMBG_URL,
FD_COMFY_RUNTIME_ENV,
RMBG_DEFAULT_IMAGE_ENCODING,
RMBG_RUNTIME_URL,
RMBG_SERVICE_URL_PRESETS,
)
from .utils.error_utils import ERROR_TIMEOUT, normalize_error_message
@@ -44,8 +46,8 @@ DEFAULT_BODY_SEGMENT_URL = "http://10.1.0.230:8003/v1/segment/body"
BACKGROUND_MODES = ["Alpha", "Color"]
RMBG_IMAGE_ENCODINGS = ["png", "jpeg_q95"]
RMBG_IMAGE_ENCODING_CONFIG = {
"default": "png",
"tooltip": "传输质量(单选):png 无损;jpeg_q95 使用 JPEG Q95 推理副本节省流量,成图仍用原始 RGB。",
"default": RMBG_DEFAULT_IMAGE_ENCODING,
"tooltip": "传输质量:png 无损;jpeg_q95 节省流量。未精修时用原始 RGB 合成,精修时保留远端图片。",
}
@@ -530,6 +532,8 @@ class _RmbgSegmentApiBase:
)
result = self._post_process(service_url, payload, timeout, label)
if payload_params["is_rmbg"] and not result.get("mask"):
raise RuntimeError("RMBG 响应缺少 mask")
if payload_params["mask_only_response"]:
mask = self._rmbg_mask_only_to_tensor(result, original_image.size)
elif result.get("mask"):
@@ -537,7 +541,15 @@ class _RmbgSegmentApiBase:
else:
mask = self._empty_mask_tensor(original_image.size)
if result.get("mask"):
if payload_params["extra"].get("refine_foreground") and payload_params["is_rmbg"]:
# The refined foreground RGB cannot be reconstructed from the source + mask.
processed_image = self._decode_png_data_url(result.get("processed_image"), "processed_image")
if processed_image.size != original_image.size:
processed_image = processed_image.resize(original_image.size, Image.Resampling.BILINEAR)
mode = "RGBA" if payload_params["background"] == "Alpha" else "RGB"
pixels = np.asarray(processed_image.convert(mode), dtype=np.float32) / 255.0
result_image = torch.from_numpy(pixels.copy()).unsqueeze(0)
elif result.get("mask"):
result_image = self._compose_output_image(
image_tensor,
mask,
@@ -626,11 +638,11 @@ class _RmbgSegmentApiBase:
RMBG_SERVICE_URL_PRESETS,
)
final_service_url = _normalize_rmbg_segment_url(selected_service_url, default_url, endpoint)
mask_only_response = endpoint == "rmbg"
mask_only_response = endpoint == "rmbg" and not extra.get("refine_foreground", False)
if image_encoding not in RMBG_IMAGE_ENCODINGS:
raise ValueError(f"不支持的传输质量: {image_encoding}")
if mask_only_response:
return_image, return_mask, return_mask_image = False, True, False
if endpoint == "rmbg":
return_image, return_mask, return_mask_image = not mask_only_response, True, False
image_tensors = self._expand_images(image)
if not image_tensors:
raise RuntimeError("未提供图片")
@@ -649,6 +661,7 @@ class _RmbgSegmentApiBase:
"return_mask": return_mask,
"return_mask_image": return_mask_image,
"mask_only_response": mask_only_response,
"is_rmbg": endpoint == "rmbg",
"image_encoding": image_encoding,
"extra": extra,
}
@@ -692,15 +705,10 @@ class ZhiYiRMBGNode(_RmbgSegmentApiBase):
"""知衣 RMBG 2.0 背景去除节点 - 纯 API 调用,不加载本地模型。"""
@classmethod
def INPUT_TYPES(cls):
def INPUT_TYPES(cls) -> dict:
return {
"required": {
"image": ("IMAGE",),
"service_url": ("STRING", {
"default": FD_RMBG_URL or DEFAULT_RMBG_URL,
"multiline": False,
"tooltip": "RMBG 2.0 背景去除接口地址,例如 http://host:8003/v1/rmbg",
}),
"process_res": ("INT", {"default": 1024, "min": 256, "max": 2048, "step": 64}),
"sensitivity": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.05}),
"mask_blur": ("INT", {"default": 0, "min": 0, "max": 64, "step": 1}),
@@ -709,19 +717,12 @@ class ZhiYiRMBGNode(_RmbgSegmentApiBase):
"refine_foreground": ("BOOLEAN", {"default": False}),
"background": (BACKGROUND_MODES, {"default": "Alpha"}),
"background_color": ("STRING", {"default": "#222222"}),
"return_image": ("BOOLEAN", {"default": True}),
"return_mask": ("BOOLEAN", {"default": True}),
"return_mask_image": ("BOOLEAN", {"default": False}),
"max_concurrency": ("INT", {"default": 1, "min": 1, "max": 16, "step": 1}),
"timeout": ("INT", {"default": 180, "min": 10, "max": 1200, "step": 10}),
},
"optional": {
"health_check": ("BOOLEAN", {"default": False}),
"node_switch": ("INT", {"default": 0, "min": 0, "max": 1, "step": 1}),
"service_url_preset": (service_url_preset_options(RMBG_SERVICE_URL_PRESETS), {
"default": CUSTOM_SERVICE_URL_PRESET,
"tooltip": "选择部署预设时忽略 service_url;选择自定义时使用 service_url 文本框",
}),
"image_delivery": (IMAGE_DELIVERY_OPTIONS, image_delivery_input_config()),
"image_encoding": (RMBG_IMAGE_ENCODINGS, RMBG_IMAGE_ENCODING_CONFIG),
},
@@ -735,32 +736,29 @@ class ZhiYiRMBGNode(_RmbgSegmentApiBase):
def remove_background(
self,
image,
service_url,
process_res,
sensitivity,
mask_blur,
mask_offset,
invert_output,
refine_foreground,
background,
background_color,
return_image,
return_mask,
return_mask_image,
max_concurrency,
timeout,
health_check=False,
node_switch=0,
service_url_preset=None,
image_delivery=IMAGE_DELIVERY_BASE64,
image_encoding: str = "png",
):
image: torch.Tensor,
process_res: int,
sensitivity: float,
mask_blur: int,
mask_offset: int,
invert_output: bool,
refine_foreground: bool,
background: str,
background_color: str,
max_concurrency: int,
timeout: int,
health_check: bool = False,
node_switch: int = 0,
image_delivery: str = IMAGE_DELIVERY_BASE64,
image_encoding: str = RMBG_DEFAULT_IMAGE_ENCODING,
**legacy_inputs: object,
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, str]:
# Old API graphs may still contain routing/return fields; deployment wins.
return self._execute(
image=image,
service_url=service_url,
service_url=RMBG_RUNTIME_URL,
endpoint="rmbg",
default_url=FD_RMBG_URL or DEFAULT_RMBG_URL,
default_url=RMBG_RUNTIME_URL,
health_key="rmbg",
label="知衣RMBG2.0背景去除",
process_res=process_res,
@@ -769,18 +767,19 @@ class ZhiYiRMBGNode(_RmbgSegmentApiBase):
invert_output=invert_output,
background=background,
background_color=background_color,
return_image=return_image,
return_mask=return_mask,
return_mask_image=return_mask_image,
return_image=bool(refine_foreground),
return_mask=True,
return_mask_image=False,
max_concurrency=max_concurrency,
timeout=timeout,
health_check=health_check,
node_switch=node_switch,
service_url_preset=service_url_preset,
service_url_preset=CUSTOM_SERVICE_URL_PRESET,
image_delivery=image_delivery,
image_encoding=image_encoding,
extra={"sensitivity": float(sensitivity), "refine_foreground": bool(refine_foreground)},
info_extra={"sensitivity": float(sensitivity), "refine_foreground": bool(refine_foreground)},
info_extra={"sensitivity": float(sensitivity), "refine_foreground": bool(refine_foreground),
"runtime_env": FD_COMFY_RUNTIME_ENV},
)
+1
View File
@@ -105,6 +105,7 @@ def test_node_quality_ignores_old_environment_switch(monkeypatch, node_cls: type
monkeypatch.setattr(module, "tensor_to_image_payload", lambda *args: "data:image/png;base64,input")
monkeypatch.setattr(module, "tensor_to_jpeg_q95_payload", lambda *args: "data:image/jpeg;base64,input")
node = node_cls()
monkeypatch.setattr(module, "RMBG_RUNTIME_URL", "http://domestic.example/v1/rmbg")
def post(url: str, payload: dict, timeout: int, label: str) -> dict:
assert url == "http://domestic.example/v1/rmbg"
+82
View File
@@ -0,0 +1,82 @@
"""Deployment routing, legacy graph isolation and refined pixel preservation."""
import base64
import io
import json
import os
from pathlib import Path
import subprocess
import sys
import numpy as np
from PIL import Image
import pytest
import torch
from src.Comfyui_Fd_Nodes import zhiyi_rmbg_segment_node as module
from src.Comfyui_Fd_Nodes.v2.rmbg_segment_v2_node import ZhiYiRMBGNodeV2
from tests.test_rmbg_mask_only import call_node, mask_response
@pytest.mark.parametrize("runtime,quality,host", [
("dev", "png", "10.1.0.230:8003"),
("domestic", "png", "model-api-dwpose-svc.online-server-gray:8001"),
("overseas", "jpeg_q95", "overseas.example/model-api-dwpose"),
])
@pytest.mark.parametrize("override", [False, True])
def test_runtime_config(runtime: str, quality: str, host: str, override: bool) -> None:
env = {**os.environ, "FD_COMFY_RUNTIME_ENV": runtime, "FD_OVERSEAS_GPU_GATEWAY": "http://overseas.example"}
env.pop("FD_RMBG_URL", None)
if override:
env["FD_RMBG_URL"] = "http://configured.example/v1/rmbg"
config = Path(module.__file__).with_name("config.py")
code = "import runpy,json,sys; c=runpy.run_path(sys.argv[1]); print(json.dumps([c['RMBG_RUNTIME_URL'],c['RMBG_DEFAULT_IMAGE_ENCODING']]))"
result = subprocess.run([sys.executable, "-c", code, str(config)], env=env, capture_output=True, text=True, check=True)
assert json.loads(result.stdout) == [f"http://{host}/v1/rmbg" if not override else env["FD_RMBG_URL"], quality]
def test_invalid_runtime_fails_on_load() -> None:
env = {**os.environ, "FD_COMFY_RUNTIME_ENV": "typo"}
result = subprocess.run([sys.executable, str(Path(module.__file__).with_name("config.py"))],
env=env, capture_output=True, text=True)
assert result.returncode != 0 and "FD_COMFY_RUNTIME_ENV must be" in result.stderr
@pytest.mark.parametrize("node_cls", [module.ZhiYiRMBGNode, ZhiYiRMBGNodeV2])
@pytest.mark.parametrize("background", ["Alpha", "Color"])
def test_refine_preserves_server_pixels(monkeypatch, node_cls: type, background: str) -> None:
node = node_cls()
mode = "RGBA" if background == "Alpha" else "RGB"
color = (37, 111, 207, 128) if mode == "RGBA" else (37, 111, 207)
refined = Image.new(mode, (5, 3), color)
buffer = io.BytesIO()
refined.save(buffer, format="PNG")
response = mask_response()
response["processed_image"] = base64.b64encode(buffer.getvalue()).decode()
def post(url: str, payload: dict, timeout: int, label: str) -> dict:
assert (payload["return_image"], payload["return_mask"], payload["return_mask_image"]) == (True, True, False)
assert payload["refine_foreground"] is True
return response
monkeypatch.setattr(node, "_post_process", post)
image = torch.zeros(1, 3, 5, 3)
output, mask, preview, text = call_node(node, image, refine_foreground=True, background=background,
return_image=False, return_mask=False)
assert torch.equal(output, torch.from_numpy(np.asarray(refined).copy().astype(np.float32) / 255).unsqueeze(0))
assert torch.equal(preview, mask.unsqueeze(-1).expand(-1, -1, -1, 3))
assert json.loads(text)["mask_only_response"] is False
def test_refine_missing_image_fails(monkeypatch) -> None:
node = ZhiYiRMBGNodeV2()
monkeypatch.setattr(node, "_post_process", lambda *args: mask_response())
with pytest.raises(RuntimeError, match="processed_image"):
call_node(node, torch.zeros(1, 3, 5, 3), refine_foreground=True)
@pytest.mark.parametrize("node_cls", [module.ZhiYiRMBGNode, ZhiYiRMBGNodeV2])
def test_retired_controls_are_not_node_inputs(node_cls: type) -> None:
fields = node_cls.INPUT_TYPES()
assert not {"env", "service_url", "service_url_preset", "return_image", "return_mask", "return_mask_image"} & (
fields["required"].keys() | fields["optional"].keys())
+16 -8
View File
@@ -31,6 +31,11 @@ from src.Comfyui_Fd_Nodes.zhiyi_rmbg_segment_node import (
)
@pytest.fixture(autouse=True)
def rmbg_deployment_url(monkeypatch):
monkeypatch.setattr(rmbg_module, "RMBG_RUNTIME_URL", "https://example.com/api/v1/rmbg")
def _image_data_url(size=(2, 2), color=(0, 255, 0), mode="RGB"):
image = Image.new(mode, size, color)
buffer = io.BytesIO()
@@ -101,11 +106,11 @@ def test_rmbg_and_segment_node_metadata():
fashion_inputs = ZhiYiFashionSegmentNode.INPUT_TYPES()
body_inputs = ZhiYiBodySegmentNode.INPUT_TYPES()
assert rmbg_inputs["required"]["service_url"][1]["default"].endswith("/v1/rmbg")
assert "service_url" not in rmbg_inputs["required"]
assert rmbg_inputs["required"]["process_res"][1]["default"] == 1024
assert rmbg_inputs["required"]["sensitivity"][1]["default"] == 1.0
assert rmbg_inputs["required"]["refine_foreground"][1]["default"] is False
assert rmbg_inputs["required"]["return_mask_image"][1]["default"] is False
assert not {"return_image", "return_mask", "return_mask_image"} & rmbg_inputs["required"].keys()
assert clothes_inputs["required"]["service_url"][1]["default"].endswith("/v1/segment/clothes")
assert fashion_inputs["required"]["service_url"][1]["default"].endswith("/v1/segment/fashion")
assert body_inputs["required"]["service_url"][1]["default"].endswith("/v1/segment/body")
@@ -141,6 +146,8 @@ def test_rmbg_and_segment_node_metadata():
assert node_cls.RETURN_TYPES == ("IMAGE", "MASK", "IMAGE", "JSON")
assert node_cls.RETURN_NAMES == ("IMAGE", "MASK", "MASK_IMAGE", "INFO")
input_types = node_cls.INPUT_TYPES()
if node_cls is ZhiYiRMBGNode:
continue
assert list(input_types["optional"])[2:4] == ["service_url_preset", "image_delivery"]
preset_options, preset_config = input_types["optional"]["service_url_preset"]
assert preset_options == [CUSTOM_SERVICE_URL_PRESET, "10.1.0.230"]
@@ -257,6 +264,7 @@ def test_parse_classes_preserves_fashion_comma_names():
def test_rmbg_posts_full_payload_and_decodes_outputs(monkeypatch):
monkeypatch.setattr(rmbg_module, "RMBG_RUNTIME_URL", "http://example.com:8003/v1/rmbg")
node = ZhiYiRMBGNode()
images = torch.zeros((1, 2, 2, 3), dtype=torch.float32)
calls = []
@@ -276,7 +284,7 @@ def test_rmbg_posts_full_payload_and_decodes_outputs(monkeypatch):
assert payload["refine_foreground"] is True
assert payload["background"] == "Color"
assert payload["background_color"] == "#ffffff"
assert payload["return_image"] is False
assert payload["return_image"] is True
assert payload["return_mask"] is True
assert payload["return_mask_image"] is False
assert payload["image_format"] == "png_base64"
@@ -284,7 +292,7 @@ def test_rmbg_posts_full_payload_and_decodes_outputs(monkeypatch):
request_id="rmbg-1",
classes=[],
preprocessor="RMBGPreprocessor",
processed_image=None,
processed_image=_image_data_url(color=(0, 255, 0)),
mask_image=None,
)
data["model_name"] = "RMBG-2.0"
@@ -319,8 +327,8 @@ def test_rmbg_posts_full_payload_and_decodes_outputs(monkeypatch):
assert result_image.shape == (1, 2, 2, 3)
assert mask.shape == (1, 2, 2)
assert mask_image.shape == (1, 2, 2, 3)
# Compose output and grayscale preview locally from the returned mask.
assert torch.equal(result_image[0, 0, 0], torch.tensor([0.0, 0.0, 0.0]))
# Preserve the refined server RGB; only the preview is generated locally.
assert torch.equal(result_image[0, 0, 0], torch.tensor([0.0, 1.0, 0.0]))
assert torch.equal(mask, torch.tensor([[[1.0, 0.0], [0.0, 1.0]]]))
assert torch.equal(mask_image[0, 0, 0], torch.tensor([1.0, 1.0, 1.0]))
assert info["service_url"] == "http://example.com:8003/v1/rmbg"
@@ -543,7 +551,7 @@ def test_image_delivery_appended_and_default_preserves_signature(node_cls, endpo
inputs = node_cls.INPUT_TYPES()
is_v2 = "env" in inputs["required"]
old_optional = ["health_check", "node_switch"]
if not is_v2:
if not is_v2 and endpoint != "rmbg":
old_optional.append("service_url_preset")
assert list(inputs["optional"]) == old_optional + ["image_delivery"] + (["image_encoding"] if endpoint == "rmbg" else [])
options, config = inputs["optional"]["image_delivery"]
@@ -551,7 +559,7 @@ def test_image_delivery_appended_and_default_preserves_signature(node_cls, endpo
assert config == image_payload.image_delivery_input_config()
method = getattr(node_cls, node_cls.FUNCTION)
parameters = inspect.signature(method).parameters
assert list(parameters) == ["self", *inputs["required"], *inputs["optional"]]
assert list(parameters) == ["self", *inputs["required"], *inputs["optional"], *(["legacy_inputs"] if endpoint == "rmbg" else [])]
assert parameters["image_delivery"].default == "base64"
+25 -29
View File
@@ -201,7 +201,9 @@ def test_input_types_env_first_service_url_second():
for cls in V2_CLASSES:
input_types = cls.INPUT_TYPES()
required_keys = list(input_types["required"])
if cls is ZhiYiSAM2SegmentNodeV2:
if cls is ZhiYiRMBGNodeV2:
assert required_keys[1] == "process_res"
elif cls is ZhiYiSAM2SegmentNodeV2:
assert required_keys[0] == "images"
assert required_keys[1] == "bboxes"
assert required_keys[2] == "env"
@@ -220,7 +222,9 @@ def test_input_types_business_params_match_old():
for v2_cls, old_cls in zip(V2_CLASSES, OLD_CLASSES):
v2_keys = list(v2_cls.INPUT_TYPES()["required"])
old_keys = list(old_cls.INPUT_TYPES()["required"])
if v2_cls is ZhiYiSAM2SegmentNodeV2:
if v2_cls is ZhiYiRMBGNodeV2:
assert v2_keys == old_keys
elif v2_cls is ZhiYiSAM2SegmentNodeV2:
assert v2_keys[4:] == old_keys[3:], v2_cls.__name__
else:
filtered = [key for key in v2_keys[3:] if key not in switch_filters.get(v2_cls, set())]
@@ -235,6 +239,9 @@ def test_env_options_shape():
def test_env_combo_is_string_options():
for cls in V2_CLASSES:
if cls is ZhiYiRMBGNodeV2:
assert "env" not in cls.INPUT_TYPES()["required"]
continue
options, config = cls.INPUT_TYPES()["required"]["env"]
assert all(isinstance(option, str) for option in options)
@@ -243,7 +250,7 @@ def test_env_combo_is_string_options():
def test_env_defaults():
for cls in [ZhiYiRMBGNodeV2, ZhiYiClothesSegmentNodeV2, ZhiYiFashionSegmentNodeV2, ZhiYiBodySegmentNodeV2]:
for cls in [ZhiYiClothesSegmentNodeV2, ZhiYiFashionSegmentNodeV2, ZhiYiBodySegmentNodeV2]:
assert cls.INPUT_TYPES()["required"]["env"][1]["default"] == "开发环境"
for cls in [ZhiYiDWPoseDetectNodeV2, ZhiYiLineArtPreprocessorNodeV2, ZhiYiDepthAnythingV2PreprocessorNodeV2, ZhiYiSAM2SegmentNodeV2]:
@@ -353,7 +360,8 @@ def test_rmbg_v2_dev_env_url(monkeypatch):
def fake_request(method, url, timeout, **kwargs):
calls.append(url)
return DummyResponse(data=_segment_response())
return DummyResponse(data={**_segment_response(), "processed_image": None, "mask_image": None,
"inference_size": {"width": 2, "height": 2}, "output_size": {"width": 2, "height": 2}})
monkeypatch.setattr(rmbg_module.requests, "request", fake_request)
@@ -695,33 +703,18 @@ def test_sam2_v2_three_envs_post_urls(monkeypatch):
]
def test_rmbg_v2_invalid_env_raises(monkeypatch):
def test_rmbg_v2_legacy_env_cannot_override_deployment(monkeypatch):
node = ZhiYiRMBGNodeV2()
captured = {}
def fake_request(method, url, timeout, **kwargs):
raise AssertionError("不应发出请求")
def execute(**kwargs):
captured.update(kwargs)
return None
monkeypatch.setattr(rmbg_module.requests, "request", fake_request)
with pytest.raises(RuntimeError, match="未知的 env: 不存在"):
node.remove_background(
image=torch.zeros((1, 2, 2, 3), dtype=torch.float32),
env="不存在",
service_url="",
process_res=1024,
sensitivity=1.0,
mask_blur=0,
mask_offset=0,
invert_output=False,
refine_foreground=False,
background="Alpha",
background_color="#222222",
return_image=True,
return_mask=True,
return_mask_image=False,
max_concurrency=1,
timeout=30,
)
monkeypatch.setattr(node, "_execute", execute)
kwargs = {name: spec[1]["default"] for name, spec in node.INPUT_TYPES()["required"].items() if name != "image"}
node.remove_background(image=torch.zeros(1, 2, 2, 3), env="不存在", service_url="http://wrong", **kwargs)
assert captured["service_url"] == rmbg_module.RMBG_RUNTIME_URL
# ---------- 9. 老节点回归保护 ----------
@@ -742,6 +735,8 @@ def test_old_controlnet_payload_still_returns_image_true():
def test_old_nodes_unchanged_input_types():
for old_cls in OLD_CLASSES:
if old_cls is ZhiYiRMBGNode:
continue
input_types = old_cls.INPUT_TYPES()
assert "env" not in input_types["required"]
assert list(input_types["required"])[1] == "bboxes" or list(input_types["required"])[1] == "service_url"
@@ -773,7 +768,8 @@ def test_v2_node_switch_short_circuit(monkeypatch):
timeout=30,
node_switch=1,
)
assert torch.equal(result_image, image)
assert result_image.shape == (1, 2, 2, 4)
assert torch.count_nonzero(result_image) == 0
assert json.loads(info_text)["skipped"] is True
dwpose_node = ZhiYiDWPoseDetectNodeV2()