Files
limbicnation 3168ecdd67 fix: remove schemas.py, use src.validation consistently
- Remove duplicate schemas.py (near-duplicate of src/validation.py)
- Update test_node_smoke.py to import from src.validation
- All 9 smoke tests pass
2026-04-24 05:29:36 +02:00

295 lines
10 KiB
Python

"""Smoke tests that actually invoke each ComfyUI node's entry function.
The original CI only imported modules, which is why PR #15 shipped with
four NameError-class bugs (validate_node_params, log, _log_gpu_memory,
invert_mask) that crash at call time. These tests catch that class of
regression by constructing each node and calling its FUNCTION.
Keep the fixtures small so the suite stays fast in CI — the point is
plumbing/integration correctness, not algorithm quality.
"""
from __future__ import annotations
import numpy as np
import pytest
import torch
# --- synthetic inputs ------------------------------------------------------
IMG_H, IMG_W = 128, 128 # node validation requires >= 64x64
@pytest.fixture
def rgb_image_tensor() -> torch.Tensor:
"""A single-image batch with a solid foreground blob on a flat background.
Shape: (1, H, W, 3), dtype=float32, range [0, 1] — matches ComfyUI's
IMAGE contract.
"""
rng = np.random.default_rng(seed=42)
img = np.full((IMG_H, IMG_W, 3), 220, dtype=np.uint8) # light grey bg
img[32:96, 32:96] = rng.integers(0, 80, size=(64, 64, 3), dtype=np.uint8)
tensor = torch.from_numpy(img.astype(np.float32) / 255.0)
return tensor.unsqueeze(0) # add batch dim
@pytest.fixture
def mask_tensor() -> torch.Tensor:
"""A single-image mask with a solid rectangular foreground region."""
mask = np.zeros((IMG_H, IMG_W), dtype=np.float32)
mask[32:96, 32:96] = 1.0
return torch.from_numpy(mask).unsqueeze(0)
# --- schemas module --------------------------------------------------------
def test_schemas_validate_node_params_normalizes_and_rejects():
from src.validation import validate_node_params
from pydantic import ValidationError
v = validate_node_params(
iterations=3,
margin=10,
confidence_threshold=0.6,
scaling_method="LANCZOS",
invert_mask=True,
)
assert v.iterations == 3
# validator lowercases the enum; main uses lowercase scaling_method
# internally (upper-cased again at the PIL resampling lookup site).
assert v.scaling_method == "lanczos"
assert v.invert_mask is True
# Out-of-range iterations should fail (main's range is 1-100).
with pytest.raises(ValidationError):
validate_node_params(iterations=9999)
with pytest.raises(ValidationError):
validate_node_params(scaling_method="BOGUS")
# --- nodes.py smoke --------------------------------------------------------
def test_transparency_background_remover_runs(rgb_image_tensor):
from nodes import TransparencyBackgroundRemover
node = TransparencyBackgroundRemover()
image_out, mask_out = node.remove_background(
rgb_image_tensor,
tolerance=30,
edge_sensitivity=0.5,
foreground_bias=0.7,
color_clusters=4,
binary_threshold=128,
output_format="RGBA",
output_size="ORIGINAL",
scaling_method="NEAREST",
auto_adjust=False,
edge_detection_mode="AUTO",
)
assert isinstance(image_out, torch.Tensor)
assert isinstance(mask_out, torch.Tensor)
assert image_out.shape[0] == 1
assert image_out.shape[1:3] == (IMG_H, IMG_W)
def test_transparency_background_remover_batch_runs(rgb_image_tensor):
from nodes import TransparencyBackgroundRemoverBatch
node = TransparencyBackgroundRemoverBatch()
batch = torch.cat([rgb_image_tensor, rgb_image_tensor], dim=0)
image_out, mask_out, report = node.batch_remove_background(
batch,
tolerance=30,
edge_sensitivity=0.5,
auto_adjust=False,
foreground_bias=0.7,
color_clusters=4,
edge_refinement=True,
dither_handling=False,
binary_threshold=128,
progress_reporting=True,
)
assert image_out.shape[0] == 2
assert mask_out.shape[0] == 2
assert isinstance(report, str) and "Batch" in report or "Total" in report
# --- grabcut_nodes.py smoke ------------------------------------------------
def test_auto_grabcut_remover_runs(rgb_image_tensor):
"""Smoke-test the node that PR #15's bugs primarily affected.
If validate_node_params, log, or invert_mask aren't wired correctly,
this will NameError — which is exactly the regression we want to catch.
"""
from grabcut_nodes import AutoGrabCutRemover
node = AutoGrabCutRemover()
image_out, mask_out, bbox, conf, metrics, bbox_tensor = node.remove_background(
rgb_image_tensor,
object_class="auto",
confidence_threshold=0.5,
grabcut_iterations=2, # keep it fast
margin_pixels=10,
edge_refinement=0.5,
edge_blur_amount=0.0,
bbox_safety_margin=10,
min_bbox_size=64,
fallback_margin_percent=0.20,
binary_threshold=200,
output_size="ORIGINAL",
scaling_method="NEAREST",
auto_adjust=False,
output_format="RGBA",
invert_mask=False,
)
assert isinstance(image_out, torch.Tensor)
assert isinstance(mask_out, torch.Tensor)
assert image_out.shape[0] == 1
assert isinstance(bbox, str)
assert isinstance(conf, float)
assert isinstance(metrics, str)
def test_auto_grabcut_remover_invert_mask_affects_output(rgb_image_tensor):
"""invert_mask=True should produce a materially different alpha.
A strict `mask_inverted == 1 - mask_normal` check isn't safe here —
GrabCut + YOLO is not bit-deterministic across calls (CUDA
non-determinism, internal RNG). The contract we actually care about
is that the flag is wired through at all: identical inputs with
different `invert_mask` must not produce byte-identical outputs.
"""
from grabcut_nodes import AutoGrabCutRemover
node = AutoGrabCutRemover()
common = dict(
object_class="auto",
confidence_threshold=0.5,
grabcut_iterations=2,
margin_pixels=10,
edge_refinement=0.5,
edge_blur_amount=0.0,
bbox_safety_margin=10,
min_bbox_size=64,
fallback_margin_percent=0.20,
binary_threshold=200,
output_size="ORIGINAL",
scaling_method="NEAREST",
auto_adjust=False,
output_format="RGBA",
)
_, mask_normal, _, _, _, _ = node.remove_background(rgb_image_tensor, invert_mask=False, **common)
_, mask_inverted, _, _, _, _ = node.remove_background(rgb_image_tensor, invert_mask=True, **common)
assert mask_normal.shape == mask_inverted.shape
assert not torch.equal(mask_normal, mask_inverted), \
"invert_mask=True produced an identical output to invert_mask=False — flag not wired through"
def test_auto_grabcut_remover_rejects_invalid_params(rgb_image_tensor):
"""Pydantic validation should surface bad params as ValueError, not NameError."""
from grabcut_nodes import AutoGrabCutRemover
node = AutoGrabCutRemover()
with pytest.raises(ValueError, match="Invalid parameters"):
node.remove_background(
rgb_image_tensor,
object_class="auto",
confidence_threshold=0.5,
grabcut_iterations=999, # out of range
margin_pixels=10,
edge_refinement=0.5,
edge_blur_amount=0.0,
bbox_safety_margin=10,
min_bbox_size=64,
fallback_margin_percent=0.20,
binary_threshold=200,
output_size="ORIGINAL",
scaling_method="NEAREST",
auto_adjust=False,
output_format="RGBA",
invert_mask=False,
)
def test_grabcut_refinement_runs(rgb_image_tensor, mask_tensor):
from grabcut_nodes import GrabCutRefinement
node = GrabCutRefinement()
image_out, mask_out = node.refine_mask(
rgb_image_tensor,
mask_tensor,
grabcut_iterations=2,
edge_refinement=0.5,
edge_blur_amount=0.0,
expand_margin=5,
bbox_safety_margin=10,
min_bbox_size=64,
output_size="ORIGINAL",
scaling_method="NEAREST",
invert_mask=False,
)
assert image_out.shape[0] == 1
assert mask_out.shape[0] == 1
assert image_out.shape[1:3] == (IMG_H, IMG_W)
assert mask_out.shape[1:] == (IMG_H, IMG_W)
def test_grabcut_refinement_resize_stacks_uniformly(rgb_image_tensor, mask_tensor):
"""Regression for the pre-fix torch.stack shape mismatch.
If any branch inside the per-item loop fell through with the
original-sized tensor while others got resized, torch.stack would
raise on mixed shapes. This exercises the target_size != ORIGINAL
path end-to-end.
"""
from grabcut_nodes import GrabCutRefinement
node = GrabCutRefinement()
batch = torch.cat([rgb_image_tensor, rgb_image_tensor], dim=0)
mask_batch = torch.cat([mask_tensor, mask_tensor], dim=0)
image_out, mask_out = node.refine_mask(
batch,
mask_batch,
grabcut_iterations=2,
edge_refinement=0.5,
edge_blur_amount=0.0,
expand_margin=5,
bbox_safety_margin=10,
min_bbox_size=64,
output_size="512x512",
scaling_method="NEAREST",
invert_mask=True,
)
assert image_out.shape[0] == 2
assert mask_out.shape[0] == 2
# Aspect-preserving resize: output fits within 512 on both axes.
assert image_out.shape[1] <= 512 and image_out.shape[2] <= 512
assert mask_out.shape[1] <= 512 and mask_out.shape[2] <= 512
# --- module-level contract -------------------------------------------------
def test_all_nodes_declare_comfyui_metadata():
"""All 4 registered nodes must declare OUTPUT_NODE / DESCRIPTION / IS_CHANGED."""
from nodes import NODE_CLASS_MAPPINGS as N
from grabcut_nodes import NODE_CLASS_MAPPINGS as G
combined = {**N, **G}
assert len(combined) == 4, f"expected 4 node classes, got {list(combined)}"
for name, cls in combined.items():
assert hasattr(cls, "OUTPUT_NODE"), f"{name}: missing OUTPUT_NODE"
assert hasattr(cls, "DESCRIPTION") and cls.DESCRIPTION, f"{name}: missing DESCRIPTION"
assert callable(getattr(cls, "IS_CHANGED", None)), f"{name}: missing IS_CHANGED"
# IS_CHANGED must be deterministic for the same scalar inputs.
h1 = cls.IS_CHANGED(None, foo=1, bar="x")
h2 = cls.IS_CHANGED(None, foo=1, bar="x")
assert h1 == h2, f"{name}: IS_CHANGED not deterministic"
h3 = cls.IS_CHANGED(None, foo=2, bar="x")
assert h1 != h3, f"{name}: IS_CHANGED insensitive to inputs"