1031 lines
34 KiB
Python
1031 lines
34 KiB
Python
import base64
|
|
import io
|
|
import json
|
|
|
|
import pytest
|
|
import torch
|
|
from PIL import Image
|
|
|
|
from src.Comfyui_Fd_Nodes import sam2_segment_node as sam2_module
|
|
from src.Comfyui_Fd_Nodes import zhiyi_controlnet_aux_node as aux_module
|
|
from src.Comfyui_Fd_Nodes import zhiyi_dwpose_detect_node as dwpose_module
|
|
from src.Comfyui_Fd_Nodes import zhiyi_rmbg_segment_node as rmbg_module
|
|
from src.Comfyui_Fd_Nodes.config import (
|
|
CUSTOM_SERVICE_URL_PRESET,
|
|
DWPOSE_SERVICE_ENVS,
|
|
RMBG_SERVICE_ENVS,
|
|
SAM2_SERVICE_ENVS,
|
|
)
|
|
from src.Comfyui_Fd_Nodes.nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
|
|
from src.Comfyui_Fd_Nodes.v2 import controlnet_aux_v2_node as aux_v2_module
|
|
from src.Comfyui_Fd_Nodes.v2.constants import _validate_class_tables
|
|
from src.Comfyui_Fd_Nodes.v2.controlnet_aux_v2_node import (
|
|
ZhiYiDepthAnythingV2PreprocessorNodeV2,
|
|
ZhiYiDWPoseDetectNodeV2,
|
|
ZhiYiLineArtPreprocessorNodeV2,
|
|
)
|
|
from src.Comfyui_Fd_Nodes.v2.env import (
|
|
ENV_OPTIONS,
|
|
CUSTOM_ENV,
|
|
normalize_env,
|
|
resolve_service_env,
|
|
)
|
|
from src.Comfyui_Fd_Nodes.v2.rmbg_segment_v2_node import (
|
|
ZhiYiBodySegmentNodeV2,
|
|
ZhiYiClothesSegmentNodeV2,
|
|
ZhiYiFashionSegmentNodeV2,
|
|
ZhiYiRMBGNodeV2,
|
|
)
|
|
from src.Comfyui_Fd_Nodes.v2.sam2_segment_v2_node import ZhiYiSAM2SegmentNodeV2
|
|
from src.Comfyui_Fd_Nodes.zhiyi_rmbg_segment_node import (
|
|
BODY_CLASSES,
|
|
CLOTHES_CLASSES,
|
|
FASHION_CLASSES,
|
|
ZhiYiBodySegmentNode,
|
|
ZhiYiClothesSegmentNode,
|
|
ZhiYiFashionSegmentNode,
|
|
ZhiYiRMBGNode,
|
|
)
|
|
from src.Comfyui_Fd_Nodes.zhiyi_controlnet_aux_node import (
|
|
ZhiYiDepthAnythingV2PreprocessorNode,
|
|
ZhiYiLineArtPreprocessorNode,
|
|
)
|
|
from src.Comfyui_Fd_Nodes.zhiyi_dwpose_detect_node import ZhiYiDWPoseDetectNode
|
|
from src.Comfyui_Fd_Nodes.sam2_segment_node import ZhiYiSAM2SegmentNode
|
|
|
|
V2_CLASSES = [
|
|
ZhiYiRMBGNodeV2,
|
|
ZhiYiClothesSegmentNodeV2,
|
|
ZhiYiFashionSegmentNodeV2,
|
|
ZhiYiBodySegmentNodeV2,
|
|
ZhiYiSAM2SegmentNodeV2,
|
|
ZhiYiDWPoseDetectNodeV2,
|
|
ZhiYiLineArtPreprocessorNodeV2,
|
|
ZhiYiDepthAnythingV2PreprocessorNodeV2,
|
|
]
|
|
|
|
OLD_CLASSES = [
|
|
ZhiYiRMBGNode,
|
|
ZhiYiClothesSegmentNode,
|
|
ZhiYiFashionSegmentNode,
|
|
ZhiYiBodySegmentNode,
|
|
ZhiYiSAM2SegmentNode,
|
|
ZhiYiDWPoseDetectNode,
|
|
ZhiYiLineArtPreprocessorNode,
|
|
ZhiYiDepthAnythingV2PreprocessorNode,
|
|
]
|
|
|
|
MAPPING_KEYS = [
|
|
"ZhiYiRMBGNodeV2",
|
|
"ZhiYiClothesSegmentNodeV2",
|
|
"ZhiYiFashionSegmentNodeV2",
|
|
"ZhiYiBodySegmentNodeV2",
|
|
"ZhiYiSAM2SegmentNodeV2",
|
|
"ZhiYiDWPoseDetectNodeV2",
|
|
"ZhiYiLineArtPreprocessorNodeV2",
|
|
"ZhiYiDepthAnythingV2PreprocessorNodeV2",
|
|
]
|
|
|
|
DISPLAY_NAMES = [
|
|
"知衣-RMBG2.0背景去除 v2",
|
|
"知衣-衣物语义分割 v2",
|
|
"知衣-时尚单品分割 v2",
|
|
"知衣-身体部位分割 v2",
|
|
"知衣-SAM2抠图 v2",
|
|
"知衣-DWPose姿态检测 v2",
|
|
"知衣-LineArt线稿预处理 v2",
|
|
"知衣-DepthAnythingV2深度图预处理 v2",
|
|
]
|
|
|
|
|
|
def _image_data_url(size=(2, 2), color=(255, 0, 0)):
|
|
image = Image.new("RGB", size, color)
|
|
buffer = io.BytesIO()
|
|
image.save(buffer, format="PNG")
|
|
encoded = base64.b64encode(buffer.getvalue()).decode("ascii")
|
|
return f"data:image/png;base64,{encoded}"
|
|
|
|
|
|
def _mask_data_url(size=(2, 2)):
|
|
image = Image.new("L", size, 255)
|
|
buffer = io.BytesIO()
|
|
image.save(buffer, format="PNG")
|
|
encoded = base64.b64encode(buffer.getvalue()).decode("ascii")
|
|
return f"data:image/png;base64,{encoded}"
|
|
|
|
|
|
class DummyResponse:
|
|
def __init__(self, status_code=200, data=None, text=""):
|
|
self.status_code = status_code
|
|
self._data = data
|
|
self.text = text
|
|
|
|
def json(self):
|
|
if isinstance(self._data, Exception):
|
|
raise self._data
|
|
return self._data
|
|
|
|
|
|
def _segment_response(request_id="seg-1", size=(2, 2), classes=None):
|
|
return {
|
|
"image_size": {"width": size[0], "height": size[1]},
|
|
"processed_image": _image_data_url(size=size, color=(0, 255, 0)),
|
|
"mask": _mask_data_url(size=size),
|
|
"mask_image": _image_data_url(size=size, color=(0, 0, 255)),
|
|
"classes": classes or [],
|
|
"request_id": request_id,
|
|
"model_name": "rmbg",
|
|
"device": "cpu",
|
|
"inference_ms": 5.0,
|
|
"total_ms": 10.0,
|
|
}
|
|
|
|
|
|
def _pose_response(request_id="pose-1", size=(2, 2), people_count=1):
|
|
return {
|
|
"image_size": {"width": size[0], "height": size[1]},
|
|
"pose_image": _image_data_url(size=size, color=(0, 255, 0)),
|
|
"openpose_json": {"people": [{"pose_keypoints_2d": [1, 2, 0.9]}]},
|
|
"people_count": people_count,
|
|
"request_id": request_id,
|
|
"model_name": "DWPose",
|
|
"device": "cpu",
|
|
"inference_ms": 12.3,
|
|
"total_ms": 45.6,
|
|
}
|
|
|
|
|
|
def _preprocess_response(request_id="pre-1", size=(2, 2)):
|
|
return {
|
|
"image_size": {"width": size[0], "height": size[1]},
|
|
"processed_image": _image_data_url(size=size, color=(0, 255, 0)),
|
|
"request_id": request_id,
|
|
"preprocessor": "lineart",
|
|
"model_name": "Lineart",
|
|
"device": "cpu",
|
|
"inference_ms": 5.0,
|
|
"total_ms": 10.0,
|
|
}
|
|
|
|
|
|
def _sam2_response(request_id="sam2-1", size=(2, 2)):
|
|
return {
|
|
"image_size": {"width": size[0], "height": size[1]},
|
|
"mask": _mask_data_url(size=size),
|
|
"count": 1,
|
|
"masks": [{"score": 0.9}],
|
|
"request_id": request_id,
|
|
}
|
|
|
|
|
|
# ---------- 1. 注册与元数据 ----------
|
|
|
|
|
|
def test_v2_registration_and_metadata():
|
|
for key, cls, name in zip(MAPPING_KEYS, V2_CLASSES, DISPLAY_NAMES):
|
|
assert NODE_CLASS_MAPPINGS[key] is cls
|
|
assert NODE_DISPLAY_NAME_MAPPINGS[key] == name
|
|
|
|
for v2_cls, old_cls in zip(V2_CLASSES, OLD_CLASSES):
|
|
assert v2_cls.RETURN_TYPES == old_cls.RETURN_TYPES
|
|
assert v2_cls.RETURN_NAMES == old_cls.RETURN_NAMES
|
|
assert v2_cls.FUNCTION == old_cls.FUNCTION
|
|
assert v2_cls.CATEGORY == old_cls.CATEGORY
|
|
assert v2_cls.OUTPUT_NODE == old_cls.OUTPUT_NODE
|
|
|
|
|
|
# ---------- 2. INPUT_TYPES 结构 ----------
|
|
|
|
|
|
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 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"
|
|
assert required_keys[3] == "service_url"
|
|
else:
|
|
assert required_keys[0] == "image"
|
|
assert required_keys[1] == "env"
|
|
assert required_keys[2] == "service_url"
|
|
|
|
|
|
def test_input_types_business_params_match_old():
|
|
switch_filters = {
|
|
ZhiYiDWPoseDetectNodeV2: {"return_pose_image", "return_openpose_json"},
|
|
ZhiYiLineArtPreprocessorNodeV2: {"return_image"},
|
|
}
|
|
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 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())]
|
|
assert filtered == old_keys[2:], v2_cls.__name__
|
|
|
|
|
|
def test_env_options_shape():
|
|
assert ENV_OPTIONS == ["灰度环境", "开发环境", "海外环境", CUSTOM_SERVICE_URL_PRESET]
|
|
assert all(isinstance(option, str) for option in ENV_OPTIONS)
|
|
assert CUSTOM_ENV == "custom"
|
|
|
|
|
|
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)
|
|
|
|
|
|
# ---------- 3. env 默认值 ----------
|
|
|
|
|
|
def test_env_defaults():
|
|
for cls in [ZhiYiClothesSegmentNodeV2, ZhiYiFashionSegmentNodeV2, ZhiYiBodySegmentNodeV2]:
|
|
assert cls.INPUT_TYPES()["required"]["env"][1]["default"] == "开发环境"
|
|
|
|
for cls in [ZhiYiDWPoseDetectNodeV2, ZhiYiLineArtPreprocessorNodeV2, ZhiYiDepthAnythingV2PreprocessorNodeV2, ZhiYiSAM2SegmentNodeV2]:
|
|
assert cls.INPUT_TYPES()["required"]["env"][1]["default"] == "灰度环境"
|
|
|
|
|
|
# ---------- 4. 多选 options ----------
|
|
|
|
|
|
def test_class_combo_options_match_classes():
|
|
for cls, classes in [
|
|
(ZhiYiClothesSegmentNodeV2, CLOTHES_CLASSES),
|
|
(ZhiYiFashionSegmentNodeV2, FASHION_CLASSES),
|
|
(ZhiYiBodySegmentNodeV2, BODY_CLASSES),
|
|
]:
|
|
input_type, config = cls.INPUT_TYPES()["required"]["classes"]
|
|
assert input_type == "COMBO"
|
|
options = config["options"]
|
|
assert options == list(classes)
|
|
assert len(options) == len(set(options)), "value 重复"
|
|
assert all(isinstance(option, str) and option for option in options)
|
|
|
|
|
|
def test_class_defaults_are_valid():
|
|
clothes = ZhiYiClothesSegmentNodeV2.INPUT_TYPES()["required"]["classes"]
|
|
fashion = ZhiYiFashionSegmentNodeV2.INPUT_TYPES()["required"]["classes"]
|
|
body = ZhiYiBodySegmentNodeV2.INPUT_TYPES()["required"]["classes"]
|
|
|
|
assert clothes[1]["default"] == ["Upper-clothes"]
|
|
assert fashion[1]["default"] == ["shirt, blouse"]
|
|
assert body[1]["default"] == ["Face", "Hair", "Top-clothes", "Bottom-clothes"]
|
|
|
|
assert all(item in CLOTHES_CLASSES for item in clothes[1]["default"])
|
|
assert all(item in FASHION_CLASSES for item in fashion[1]["default"])
|
|
assert all(item in BODY_CLASSES for item in body[1]["default"])
|
|
|
|
|
|
# ---------- 5. 开关默认值 ----------
|
|
|
|
|
|
def test_switch_defaults_true():
|
|
dwpose_inputs = ZhiYiDWPoseDetectNodeV2.INPUT_TYPES()["required"]
|
|
assert dwpose_inputs["return_pose_image"][1]["default"] is True
|
|
assert dwpose_inputs["return_openpose_json"][1]["default"] is True
|
|
|
|
lineart_inputs = ZhiYiLineArtPreprocessorNodeV2.INPUT_TYPES()["required"]
|
|
assert lineart_inputs["return_image"][1]["default"] is True
|
|
|
|
|
|
# ---------- 6. resolve_service_env ----------
|
|
|
|
|
|
def test_resolve_service_env():
|
|
assert resolve_service_env("灰度环境", DWPOSE_SERVICE_ENVS) == DWPOSE_SERVICE_ENVS["gray"]
|
|
assert resolve_service_env("开发环境", RMBG_SERVICE_ENVS) == RMBG_SERVICE_ENVS["dev"]
|
|
assert resolve_service_env("海外环境", DWPOSE_SERVICE_ENVS) == DWPOSE_SERVICE_ENVS["overseas"]
|
|
assert resolve_service_env("overseas", SAM2_SERVICE_ENVS) == SAM2_SERVICE_ENVS["overseas"]
|
|
assert resolve_service_env("gray", SAM2_SERVICE_ENVS) == SAM2_SERVICE_ENVS["gray"]
|
|
assert resolve_service_env("dev", SAM2_SERVICE_ENVS) == SAM2_SERVICE_ENVS["dev"]
|
|
assert resolve_service_env(None, DWPOSE_SERVICE_ENVS) is None
|
|
assert resolve_service_env("", DWPOSE_SERVICE_ENVS) is None
|
|
assert resolve_service_env(CUSTOM_SERVICE_URL_PRESET, DWPOSE_SERVICE_ENVS) is None
|
|
assert resolve_service_env("custom", DWPOSE_SERVICE_ENVS) is None
|
|
|
|
with pytest.raises(RuntimeError, match="未知的 env: 不存在"):
|
|
resolve_service_env("不存在", DWPOSE_SERVICE_ENVS)
|
|
|
|
# 旧工作流里保存的 {text, value} 对象会被归一化后正常解析
|
|
assert resolve_service_env(
|
|
{"text": "灰度环境", "value": "gray"}, DWPOSE_SERVICE_ENVS
|
|
) == DWPOSE_SERVICE_ENVS["gray"]
|
|
|
|
with pytest.raises(RuntimeError, match="自定义"):
|
|
resolve_service_env("不存在", DWPOSE_SERVICE_ENVS)
|
|
|
|
|
|
def test_normalize_env_legacy_forms():
|
|
assert normalize_env("灰度环境") == "gray"
|
|
assert normalize_env("开发环境") == "dev"
|
|
assert normalize_env("海外环境") == "overseas"
|
|
assert normalize_env(CUSTOM_SERVICE_URL_PRESET) == "custom"
|
|
assert normalize_env("gray") == "gray"
|
|
assert normalize_env("dev") == "dev"
|
|
assert normalize_env("overseas") == "overseas"
|
|
assert normalize_env("custom") == "custom"
|
|
assert normalize_env(None) is None
|
|
assert normalize_env("") is None
|
|
with pytest.raises(RuntimeError):
|
|
normalize_env("不存在")
|
|
with pytest.raises(RuntimeError):
|
|
normalize_env(123)
|
|
|
|
|
|
def test_overseas_env_urls_cover_all_services():
|
|
gateway = "http://fd-oversease-access-hz.external.sometimes.cn/gpu/online-server-gray"
|
|
assert SAM2_SERVICE_ENVS["overseas"] == f"{gateway}/model-api-sam2-hiera-base-plus"
|
|
assert DWPOSE_SERVICE_ENVS["overseas"] == f"{gateway}/model-api-dwpose"
|
|
assert RMBG_SERVICE_ENVS["overseas"] == f"{gateway}/model-api-dwpose"
|
|
|
|
|
|
# ---------- 7. 环境 URL 解析(mock 请求) ----------
|
|
|
|
|
|
def test_rmbg_v2_dev_env_url(monkeypatch):
|
|
node = ZhiYiRMBGNodeV2()
|
|
calls = []
|
|
|
|
def fake_request(method, url, timeout, **kwargs):
|
|
calls.append(url)
|
|
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)
|
|
|
|
node.remove_background(
|
|
image=torch.zeros((1, 2, 2, 3), dtype=torch.float32),
|
|
env="dev",
|
|
service_url="https://custom.example.com/v1/rmbg",
|
|
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,
|
|
)
|
|
assert calls == ["http://10.1.0.230:8003/v1/rmbg"]
|
|
|
|
|
|
def test_dwpose_v2_gray_env_url(monkeypatch):
|
|
node = ZhiYiDWPoseDetectNodeV2()
|
|
calls = []
|
|
|
|
def fake_request(method, url, timeout, **kwargs):
|
|
calls.append(url)
|
|
return DummyResponse(data=_pose_response())
|
|
|
|
monkeypatch.setattr(dwpose_module.requests, "request", fake_request)
|
|
|
|
node.detect_pose(
|
|
image=torch.zeros((1, 2, 2, 3), dtype=torch.float32),
|
|
env="gray",
|
|
service_url="https://custom.example.com/v1/pose",
|
|
resolution=512,
|
|
detect_body=True,
|
|
detect_hand=True,
|
|
detect_face=False,
|
|
xinsr_stick_scaling=False,
|
|
return_pose_image=True,
|
|
return_openpose_json=True,
|
|
upscale_method="INTER_CUBIC",
|
|
max_concurrency=1,
|
|
timeout=30,
|
|
)
|
|
assert calls == ["http://model-api-dwpose-svc.online-server-gray:8001/v1/pose"]
|
|
|
|
|
|
def test_lineart_v2_custom_env_url(monkeypatch):
|
|
node = ZhiYiLineArtPreprocessorNodeV2()
|
|
calls = []
|
|
|
|
def fake_request(method, url, timeout, **kwargs):
|
|
calls.append(url)
|
|
return DummyResponse(data=_preprocess_response())
|
|
|
|
monkeypatch.setattr(aux_module.requests, "request", fake_request)
|
|
|
|
node.preprocess(
|
|
image=torch.zeros((1, 2, 2, 3), dtype=torch.float32),
|
|
env="custom",
|
|
service_url="https://custom.example.com/v1/lineart",
|
|
resolution=512,
|
|
coarse=False,
|
|
return_image=True,
|
|
upscale_method="INTER_CUBIC",
|
|
max_concurrency=1,
|
|
timeout=30,
|
|
)
|
|
assert calls == ["https://custom.example.com/v1/lineart"]
|
|
|
|
|
|
def test_sam2_v2_dev_env_url(monkeypatch):
|
|
node = ZhiYiSAM2SegmentNodeV2()
|
|
calls = []
|
|
|
|
def fake_request(method, url, timeout, **kwargs):
|
|
calls.append(url)
|
|
return DummyResponse(data=_sam2_response())
|
|
|
|
monkeypatch.setattr(sam2_module.requests, "request", fake_request)
|
|
|
|
node.segment(
|
|
images=torch.zeros((1, 2, 2, 3), dtype=torch.float32),
|
|
bboxes=[[[0, 0, 2, 2]]],
|
|
env="dev",
|
|
service_url="https://custom.example.com/v1/segment",
|
|
dilate=0,
|
|
blur=0,
|
|
background="Alpha",
|
|
background_color="#222222",
|
|
max_concurrency=1,
|
|
timeout=30,
|
|
)
|
|
assert calls == ["http://10.1.0.230:8002/v1/segment"]
|
|
|
|
|
|
def test_dwpose_v2_overseas_env_url(monkeypatch):
|
|
node = ZhiYiDWPoseDetectNodeV2()
|
|
calls = []
|
|
|
|
def fake_request(method, url, timeout, **kwargs):
|
|
calls.append(url)
|
|
return DummyResponse(data=_pose_response())
|
|
|
|
monkeypatch.setattr(dwpose_module.requests, "request", fake_request)
|
|
|
|
node.detect_pose(
|
|
image=torch.zeros((1, 2, 2, 3), dtype=torch.float32),
|
|
env="overseas",
|
|
service_url="https://custom.example.com/v1/pose",
|
|
resolution=512,
|
|
detect_body=True,
|
|
detect_hand=True,
|
|
detect_face=False,
|
|
xinsr_stick_scaling=False,
|
|
return_pose_image=True,
|
|
return_openpose_json=True,
|
|
upscale_method="INTER_CUBIC",
|
|
max_concurrency=1,
|
|
timeout=30,
|
|
)
|
|
assert calls == [
|
|
"http://fd-oversease-access-hz.external.sometimes.cn/gpu/online-server-gray/model-api-dwpose/v1/pose"
|
|
]
|
|
|
|
|
|
def test_sam2_v2_overseas_env_url(monkeypatch):
|
|
node = ZhiYiSAM2SegmentNodeV2()
|
|
calls = []
|
|
|
|
def fake_request(method, url, timeout, **kwargs):
|
|
calls.append(url)
|
|
return DummyResponse(data=_sam2_response())
|
|
|
|
monkeypatch.setattr(sam2_module.requests, "request", fake_request)
|
|
|
|
node.segment(
|
|
images=torch.zeros((1, 2, 2, 3), dtype=torch.float32),
|
|
bboxes=[[[0, 0, 2, 2]]],
|
|
env="overseas",
|
|
service_url="https://custom.example.com/v1/segment",
|
|
dilate=0,
|
|
blur=0,
|
|
background="Alpha",
|
|
background_color="#222222",
|
|
max_concurrency=1,
|
|
timeout=30,
|
|
)
|
|
assert calls == [
|
|
"http://fd-oversease-access-hz.external.sometimes.cn/gpu/online-server-gray/model-api-sam2-hiera-base-plus/v1/segment"
|
|
]
|
|
|
|
|
|
def test_lineart_v2_overseas_env_url(monkeypatch):
|
|
node = ZhiYiLineArtPreprocessorNodeV2()
|
|
calls = []
|
|
|
|
def fake_request(method, url, timeout, **kwargs):
|
|
calls.append(url)
|
|
return DummyResponse(data=_preprocess_response())
|
|
|
|
monkeypatch.setattr(aux_module.requests, "request", fake_request)
|
|
|
|
node.preprocess(
|
|
image=torch.zeros((1, 2, 2, 3), dtype=torch.float32),
|
|
env="海外环境",
|
|
service_url="https://custom.example.com/v1/lineart",
|
|
resolution=512,
|
|
coarse=False,
|
|
return_image=True,
|
|
upscale_method="INTER_CUBIC",
|
|
max_concurrency=1,
|
|
timeout=30,
|
|
)
|
|
assert calls == [
|
|
"http://fd-oversease-access-hz.external.sometimes.cn/gpu/online-server-gray/model-api-dwpose/v1/lineart"
|
|
]
|
|
|
|
|
|
# ---------- 8. mock 请求 payload ----------
|
|
|
|
|
|
def test_clothes_v2_classes_array_in_payload(monkeypatch):
|
|
node = ZhiYiClothesSegmentNodeV2()
|
|
payloads = []
|
|
|
|
def fake_request(method, url, timeout, **kwargs):
|
|
payloads.append(kwargs["json"])
|
|
return DummyResponse(data=_segment_response(classes=["Upper-clothes", "Pants"]))
|
|
|
|
monkeypatch.setattr(rmbg_module.requests, "request", fake_request)
|
|
|
|
node.segment(
|
|
image=torch.zeros((1, 2, 2, 3), dtype=torch.float32),
|
|
env="dev",
|
|
service_url="",
|
|
classes=["Upper-clothes", "Pants"],
|
|
process_res=512,
|
|
mask_blur=0,
|
|
mask_offset=0,
|
|
invert_output=False,
|
|
background="Alpha",
|
|
background_color="#222222",
|
|
return_image=True,
|
|
return_mask=True,
|
|
return_mask_image=False,
|
|
max_concurrency=1,
|
|
timeout=30,
|
|
)
|
|
assert payloads[0]["classes"] == ["Upper-clothes", "Pants"]
|
|
|
|
|
|
def test_clothes_v2_rejects_invalid_class(monkeypatch):
|
|
node = ZhiYiClothesSegmentNodeV2()
|
|
|
|
def fake_request(method, url, timeout, **kwargs):
|
|
raise AssertionError("不应发出请求")
|
|
|
|
monkeypatch.setattr(rmbg_module.requests, "request", fake_request)
|
|
|
|
with pytest.raises(RuntimeError, match="不支持的分割类别"):
|
|
node.segment(
|
|
image=torch.zeros((1, 2, 2, 3), dtype=torch.float32),
|
|
env="dev",
|
|
service_url="",
|
|
classes=["Not-a-class"],
|
|
process_res=512,
|
|
mask_blur=0,
|
|
mask_offset=0,
|
|
invert_output=False,
|
|
background="Alpha",
|
|
background_color="#222222",
|
|
return_image=True,
|
|
return_mask=True,
|
|
return_mask_image=False,
|
|
max_concurrency=1,
|
|
timeout=30,
|
|
)
|
|
|
|
|
|
def test_dwpose_v2_switches_false_payload_and_pass_through(monkeypatch):
|
|
node = ZhiYiDWPoseDetectNodeV2()
|
|
payloads = []
|
|
|
|
def fake_request(method, url, timeout, **kwargs):
|
|
payloads.append(kwargs["json"])
|
|
return DummyResponse(data=_pose_response())
|
|
|
|
monkeypatch.setattr(dwpose_module.requests, "request", fake_request)
|
|
|
|
image = torch.rand((1, 2, 2, 3), dtype=torch.float32)
|
|
pose_image, pose_keypoint, info_text = node.detect_pose(
|
|
image=image,
|
|
env="gray",
|
|
service_url="",
|
|
resolution=512,
|
|
detect_body=True,
|
|
detect_hand=True,
|
|
detect_face=False,
|
|
xinsr_stick_scaling=False,
|
|
return_pose_image=False,
|
|
return_openpose_json=False,
|
|
upscale_method="INTER_CUBIC",
|
|
max_concurrency=1,
|
|
timeout=30,
|
|
)
|
|
assert payloads[0]["return_pose_image"] is False
|
|
assert payloads[0]["return_openpose_json"] is False
|
|
assert pose_image.shape == image.shape
|
|
assert pose_image.dtype == image.dtype
|
|
assert torch.equal(pose_image, image)
|
|
assert pose_keypoint == [[]]
|
|
assert json.loads(info_text)["return_pose_image"] is False
|
|
assert json.loads(info_text)["return_openpose_json"] is False
|
|
|
|
|
|
def test_lineart_v2_return_image_false_pass_through(monkeypatch):
|
|
node = ZhiYiLineArtPreprocessorNodeV2()
|
|
payloads = []
|
|
|
|
def fake_request(method, url, timeout, **kwargs):
|
|
payloads.append(kwargs["json"])
|
|
return DummyResponse(data={})
|
|
|
|
monkeypatch.setattr(aux_module.requests, "request", fake_request)
|
|
|
|
image = torch.rand((1, 2, 2, 3), dtype=torch.float32)
|
|
result_image, info_text = node.preprocess(
|
|
image=image,
|
|
env="gray",
|
|
service_url="",
|
|
resolution=512,
|
|
coarse=False,
|
|
return_image=False,
|
|
upscale_method="INTER_CUBIC",
|
|
max_concurrency=1,
|
|
timeout=30,
|
|
)
|
|
assert payloads[0]["return_image"] is False
|
|
assert result_image.shape == image.shape
|
|
assert result_image.dtype == image.dtype
|
|
assert torch.equal(result_image, image)
|
|
assert json.loads(info_text)["return_image"] is False
|
|
|
|
|
|
def test_sam2_v2_three_envs_post_urls(monkeypatch):
|
|
node = ZhiYiSAM2SegmentNodeV2()
|
|
urls = []
|
|
|
|
def fake_request(method, url, timeout, **kwargs):
|
|
urls.append(url)
|
|
return DummyResponse(data=_sam2_response())
|
|
|
|
monkeypatch.setattr(sam2_module.requests, "request", fake_request)
|
|
|
|
for env in ["gray", "dev", "custom"]:
|
|
node.segment(
|
|
images=torch.zeros((1, 2, 2, 3), dtype=torch.float32),
|
|
bboxes=[[[0, 0, 2, 2]]],
|
|
env=env,
|
|
service_url="https://custom.example.com/v1/segment",
|
|
dilate=0,
|
|
blur=0,
|
|
background="Alpha",
|
|
background_color="#222222",
|
|
max_concurrency=1,
|
|
timeout=30,
|
|
)
|
|
|
|
assert urls == [
|
|
"http://model-api-sam2-hiera-base-plus-svc.online-server-gray:8000/v1/segment",
|
|
"http://10.1.0.230:8002/v1/segment",
|
|
"https://custom.example.com/v1/segment",
|
|
]
|
|
|
|
|
|
def test_rmbg_v2_legacy_env_cannot_override_deployment(monkeypatch):
|
|
node = ZhiYiRMBGNodeV2()
|
|
captured = {}
|
|
|
|
def execute(**kwargs):
|
|
captured.update(kwargs)
|
|
return None
|
|
|
|
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. 老节点回归保护 ----------
|
|
|
|
|
|
def test_old_dwpose_payload_still_returns_true():
|
|
node = ZhiYiDWPoseDetectNode()
|
|
payload = node._build_payload("data:image/png;base64,x", 512, True, True, False, False, "INTER_CUBIC")
|
|
assert payload["return_pose_image"] is True
|
|
assert payload["return_openpose_json"] is True
|
|
|
|
|
|
def test_old_controlnet_payload_still_returns_image_true():
|
|
node = ZhiYiLineArtPreprocessorNode()
|
|
payload = node._build_common_payload("data:image/png;base64,x", 512, "INTER_CUBIC")
|
|
assert payload["return_image"] is 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"
|
|
|
|
|
|
def test_v2_node_switch_short_circuit(monkeypatch):
|
|
def assert_no_request(method, url, timeout, **kwargs):
|
|
raise AssertionError("不应发出请求")
|
|
|
|
rmbg_node = ZhiYiRMBGNodeV2()
|
|
monkeypatch.setattr(rmbg_module.requests, "request", assert_no_request)
|
|
image = torch.zeros((1, 2, 2, 3), dtype=torch.float32)
|
|
result_image, mask, mask_image, info_text = rmbg_node.remove_background(
|
|
image=image,
|
|
env="dev",
|
|
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,
|
|
node_switch=1,
|
|
)
|
|
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()
|
|
pose_image, keypoints, info_text = dwpose_node.detect_pose(
|
|
image=image,
|
|
env="不存在",
|
|
service_url="",
|
|
resolution=512,
|
|
detect_body=True,
|
|
detect_hand=True,
|
|
detect_face=False,
|
|
xinsr_stick_scaling=False,
|
|
return_pose_image=True,
|
|
return_openpose_json=True,
|
|
upscale_method="INTER_CUBIC",
|
|
max_concurrency=1,
|
|
timeout=30,
|
|
node_switch=1,
|
|
)
|
|
assert torch.equal(pose_image, image)
|
|
assert keypoints == []
|
|
assert json.loads(info_text)["skipped"] is True
|
|
|
|
|
|
def test_v2_background_color_validation(monkeypatch):
|
|
node = ZhiYiRMBGNodeV2()
|
|
|
|
def fake_request(method, url, timeout, **kwargs):
|
|
return DummyResponse(data=_segment_response())
|
|
|
|
monkeypatch.setattr(rmbg_module.requests, "request", fake_request)
|
|
|
|
with pytest.raises(RuntimeError, match="background_color 不是有效 hex 颜色"):
|
|
node.remove_background(
|
|
image=torch.zeros((1, 2, 2, 3), dtype=torch.float32),
|
|
env="dev",
|
|
service_url="",
|
|
process_res=1024,
|
|
sensitivity=1.0,
|
|
mask_blur=0,
|
|
mask_offset=0,
|
|
invert_output=False,
|
|
refine_foreground=False,
|
|
background="Color",
|
|
background_color="#zzzzzz",
|
|
return_image=True,
|
|
return_mask=True,
|
|
return_mask_image=False,
|
|
max_concurrency=1,
|
|
timeout=30,
|
|
)
|
|
|
|
|
|
def test_sam2_v2_node_switch(monkeypatch):
|
|
def assert_no_request(method, url, timeout, **kwargs):
|
|
raise AssertionError("不应发出请求")
|
|
|
|
monkeypatch.setattr(sam2_module.requests, "request", assert_no_request)
|
|
node = ZhiYiSAM2SegmentNodeV2()
|
|
image = torch.zeros((1, 2, 2, 3), dtype=torch.float32)
|
|
result_image, mask, mask_image, info_text = node.segment(
|
|
images=image,
|
|
bboxes=[[[0, 0, 2, 2]]],
|
|
env="不存在",
|
|
service_url="",
|
|
dilate=0,
|
|
blur=0,
|
|
background="Alpha",
|
|
background_color="#222222",
|
|
max_concurrency=1,
|
|
timeout=30,
|
|
node_switch=1,
|
|
)
|
|
assert torch.equal(result_image, image)
|
|
assert mask.shape == (1, 2, 2)
|
|
assert mask_image.shape == (1, 2, 2, 3)
|
|
assert json.loads(info_text)["skipped"] is True
|
|
|
|
|
|
def test_sam2_v2_empty_bbox_pass_through_no_request(monkeypatch):
|
|
def assert_no_request(method, url, timeout, **kwargs):
|
|
raise AssertionError("不应发出请求")
|
|
|
|
monkeypatch.setattr(sam2_module.requests, "request", assert_no_request)
|
|
node = ZhiYiSAM2SegmentNodeV2()
|
|
image = torch.ones((1, 2, 2, 3), dtype=torch.float32)
|
|
result_image, mask, mask_image, info_text = node.segment(
|
|
images=image,
|
|
bboxes=[],
|
|
env="gray",
|
|
service_url="",
|
|
dilate=0,
|
|
blur=0,
|
|
background="Alpha",
|
|
background_color="#222222",
|
|
max_concurrency=1,
|
|
timeout=30,
|
|
)
|
|
assert torch.equal(result_image, image)
|
|
assert mask.shape == (1, 2, 2)
|
|
assert json.loads(info_text)["items"][0]["skipped"] is True
|
|
|
|
|
|
def test_custom_env_falls_back_to_fd_var(monkeypatch):
|
|
"""env=custom 时保留老节点的 FD_* 环境变量兜底行为。"""
|
|
node = ZhiYiDWPoseDetectNodeV2()
|
|
calls = []
|
|
|
|
def fake_request(method, url, timeout, **kwargs):
|
|
calls.append(url)
|
|
return DummyResponse(data=_pose_response())
|
|
|
|
monkeypatch.setattr(dwpose_module.requests, "request", fake_request)
|
|
monkeypatch.setattr(dwpose_module, "FD_DWPOSE_POSE_URL", "http://fd-env.example.com:9000/v1/pose")
|
|
|
|
node.detect_pose(
|
|
image=torch.zeros((1, 2, 2, 3), dtype=torch.float32),
|
|
env="custom",
|
|
service_url="",
|
|
resolution=512,
|
|
detect_body=True,
|
|
detect_hand=True,
|
|
detect_face=False,
|
|
xinsr_stick_scaling=False,
|
|
return_pose_image=True,
|
|
return_openpose_json=True,
|
|
upscale_method="INTER_CUBIC",
|
|
max_concurrency=1,
|
|
timeout=30,
|
|
)
|
|
assert calls == ["http://fd-env.example.com:9000/v1/pose"]
|
|
|
|
|
|
# ---------- Review 修复后的补充测试 ----------
|
|
|
|
|
|
def test_class_widgets_have_multiselect_markers():
|
|
for cls in [ZhiYiClothesSegmentNodeV2, ZhiYiFashionSegmentNodeV2, ZhiYiBodySegmentNodeV2]:
|
|
input_type, config = cls.INPUT_TYPES()["required"]["classes"]
|
|
assert input_type == "COMBO"
|
|
assert config["multi_select"]
|
|
assert all(isinstance(o, str) and o for o in config["options"])
|
|
|
|
|
|
def test_sam2_v2_node_switch_batch_mask(monkeypatch):
|
|
def assert_no_request(method, url, timeout, **kwargs):
|
|
raise AssertionError("不应发出请求")
|
|
|
|
monkeypatch.setattr(sam2_module.requests, "request", assert_no_request)
|
|
node = ZhiYiSAM2SegmentNodeV2()
|
|
images = torch.rand((3, 2, 2, 3), dtype=torch.float32)
|
|
result_image, mask, mask_image, info_text = node.segment(
|
|
images=images,
|
|
bboxes=[[[0, 0, 2, 2]]],
|
|
env="不存在",
|
|
service_url="",
|
|
dilate=0,
|
|
blur=0,
|
|
background="Alpha",
|
|
background_color="#222222",
|
|
max_concurrency=1,
|
|
timeout=30,
|
|
node_switch=1,
|
|
)
|
|
assert result_image.shape == (3, 2, 2, 3)
|
|
assert mask.shape == (3, 2, 2)
|
|
assert mask.dtype == images.dtype
|
|
assert mask.device == images.device
|
|
assert mask_image.shape == (3, 2, 2, 3)
|
|
assert torch.equal(result_image, images)
|
|
assert json.loads(info_text)["skipped"] is True
|
|
|
|
|
|
def test_dwpose_v2_service_url_default_reads_env(monkeypatch):
|
|
monkeypatch.setattr(aux_v2_module, "FD_DWPOSE_POSE_URL", "http://fd-env.example.com:9000/v1/pose")
|
|
default = ZhiYiDWPoseDetectNodeV2.INPUT_TYPES()["required"]["service_url"][1]["default"]
|
|
assert default == "http://fd-env.example.com:9000/v1/pose"
|
|
|
|
|
|
def test_dwpose_v2_gray_env_overrides_env_default(monkeypatch):
|
|
monkeypatch.setattr(aux_v2_module, "FD_DWPOSE_POSE_URL", "http://fd-env.example.com:9000/v1/pose")
|
|
calls = []
|
|
|
|
def fake_request(method, url, timeout, **kwargs):
|
|
calls.append(url)
|
|
return DummyResponse(data=_pose_response())
|
|
|
|
monkeypatch.setattr(dwpose_module.requests, "request", fake_request)
|
|
node = ZhiYiDWPoseDetectNodeV2()
|
|
node.detect_pose(
|
|
image=torch.zeros((1, 2, 2, 3), dtype=torch.float32),
|
|
env="gray",
|
|
service_url="http://fd-env.example.com:9000/v1/pose",
|
|
resolution=512,
|
|
detect_body=True,
|
|
detect_hand=True,
|
|
detect_face=False,
|
|
xinsr_stick_scaling=False,
|
|
return_pose_image=True,
|
|
return_openpose_json=True,
|
|
upscale_method="INTER_CUBIC",
|
|
max_concurrency=1,
|
|
timeout=30,
|
|
)
|
|
assert calls == ["http://model-api-dwpose-svc.online-server-gray:8001/v1/pose"]
|
|
|
|
|
|
def test_sam2_v2_invalid_background_zero_requests(monkeypatch):
|
|
def assert_no_request(method, url, timeout, **kwargs):
|
|
raise AssertionError("不应发出请求")
|
|
|
|
monkeypatch.setattr(sam2_module.requests, "request", assert_no_request)
|
|
node = ZhiYiSAM2SegmentNodeV2()
|
|
with pytest.raises(RuntimeError, match="background 无效"):
|
|
node.segment(
|
|
images=torch.zeros((1, 2, 2, 3), dtype=torch.float32),
|
|
bboxes=[[[0, 0, 2, 2]]],
|
|
env="gray",
|
|
service_url="",
|
|
dilate=0,
|
|
blur=0,
|
|
background="NotARealBackground",
|
|
background_color="#222222",
|
|
max_concurrency=1,
|
|
timeout=30,
|
|
)
|
|
|
|
|
|
def test_sam2_v2_invalid_color_zero_requests(monkeypatch):
|
|
def assert_no_request(method, url, timeout, **kwargs):
|
|
raise AssertionError("不应发出请求")
|
|
|
|
monkeypatch.setattr(sam2_module.requests, "request", assert_no_request)
|
|
node = ZhiYiSAM2SegmentNodeV2()
|
|
with pytest.raises(RuntimeError, match="background_color 不是有效 hex 颜色"):
|
|
node.segment(
|
|
images=torch.zeros((1, 2, 2, 3), dtype=torch.float32),
|
|
bboxes=[[[0, 0, 2, 2]]],
|
|
env="gray",
|
|
service_url="",
|
|
dilate=0,
|
|
blur=0,
|
|
background="Color",
|
|
background_color="#zzzzzz",
|
|
max_concurrency=1,
|
|
timeout=30,
|
|
)
|
|
|
|
|
|
def test_class_tables_reject_bad_tables():
|
|
with pytest.raises(RuntimeError, match="缺少翻译"):
|
|
_validate_class_tables(["a", "b"], {"a": "甲"})
|
|
with pytest.raises(RuntimeError, match="多余翻译"):
|
|
_validate_class_tables(["a"], {"a": "甲", "b": "乙"})
|
|
with pytest.raises(RuntimeError, match="类别重复"):
|
|
_validate_class_tables(["a", "a"], {"a": "甲"})
|
|
with pytest.raises(RuntimeError, match="空翻译"):
|
|
_validate_class_tables(["a"], {"a": ""})
|