Use deployment routing and preserve refined foreground in remote RMBG
This commit is contained in:
@@ -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
@@ -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"}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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())
|
||||
@@ -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
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user