Clamp mask values before contour detection

Clamp finite mask values to [0, 1] before uint8 contour conversion while
preserving input arithmetic dtype, batch handling and combined behavior.

Add CPU and native-workflow regression coverage for range boundaries,
tutorial mask behavior, widget serialization and fresh browser execution.
Document the isolated test environment and regression verification scope.
This commit is contained in:
Dr.Lt.Data
2026-11-01 12:39:49 +09:00
parent d9bd2bfc66
commit 2c9efb1741
6 changed files with 476 additions and 3 deletions
+56 -2
View File
@@ -15,6 +15,8 @@ that setup does not extend the supported import scope.
| `tests/e2e_dd_compat.py` | Load the prepared Detailer graph through the frontend file input, click Run | Successful SEGSDetailer inference with DifferentialDiffusion, output image loaded in the UI, non-degenerate image, changed pixels by mean/std comparison |
| `tests/test_e2e_switches.py` | Create and wire nodes; import prepared graphs and UI-created JSON, reload Chrome, delete connected nodes, reconnect and click Run | Slot identities and typed links, list order, non-default selection and routing, fresh output after reconnecting, output preservation when a Reroute disconnects during loading |
| `tests/test_e2e_widget_catalog.py` | Load the UI-created catalog and pan to each node at 100% zoom | Every registered node exactly once; live widget names, order, and values match the fixed baseline under positional and named restoration |
| `tests/test_e2e_mask_to_segs.py` | Import a prepared bicubic mask graph, Run, native Export/reload, change the resize widget and Run again | Contour pixels match an explicit clamp reference; saved widget names/order/values survive both restoration modes; changed inputs produce uncached mask outputs |
| `tests/test_e2e_mask_tutorials.py` | Load original tutorial PNG/JSON files, native Export and reload | MaskToSEGS widget names/order/values survive both restoration modes, including the default contour_fill omitted by older four-value workflows |
| `tests/test_e2e_lora_wildcard_source.py` | Search for the node, select non-default values, native Export and file import | Delayed creation responses cannot overwrite saved widget names/order/values or category options in either restoration mode |
| `tests/e2e_lora_wildcard_inference.py` | Import a prepared graph, choose modes, Run, native Export and reload | Real SD1.5 LoRA loading and BREAK conditioning match core-node reference images pixel-for-pixel; four modes preserve output count/order/text and native saved widgets |
| `tests/test_e2e_legacy_sam.py` | Open the legacy SAM dialog from a node and Clipspace, detect twice, save/paste, export and reload | No removed imports, one Clipspace button after reopening/refresh, real detector pixels in saved masks, unchanged RGB, widget names/order/values in both restoration modes |
@@ -173,12 +175,64 @@ python main.py --listen 127.0.0.1 --port 18188 \
--whitelist-custom-nodes comfyui-impact-pack comfyui-impact-subpack
```
Only Impact Pack is needed for the wildcard UI test. When testing inside a
separate base directory, use ComfyUI's `--base-directory`, `--models-directory`,
Only Impact Pack is needed for the wildcard and Mask-to-SEGS UI tests. When
testing inside a separate base directory, use ComfyUI's `--base-directory`, `--models-directory`,
and `--input-directory` options to keep test data separate and reuse local models.
## Run
Mask-to-SEGS needs no models. Use a Python environment with the ComfyUI and
Impact Pack dependencies, `tests/requirements.txt`, and Playwright; install Google
Chrome for the browser test. Start the isolated server above with `--cpu` to run
without a GPU, and make sure its Impact Pack checkout is the one under test.
The full widget catalog also needs the Full Cache/sample configuration above.
The standalone CPU command is documented in `tests/README.md`.
Its Chrome test imports a prepared execution graph,
clicks Run, and compares a bicubic mask's output pixels with a core MaskComposite
clamp reference. Native Export and reload preserve non-default widget values,
names, and order under both restoration modes. Browser previews must match the
server images. The fixed node catalog remains unchanged.
After reload, the test changes ImageScaleBy through its widget and checks the
submitted value, cache status of the affected nodes, changed preview filenames,
output dimensions and exact agreement with the reference pixels. Execution
history is saved for both runs so cached replays cannot count as new computation.
```bash
IMPACT_E2E_BASE_URL=http://127.0.0.1:18188 \
IMPACT_E2E_ARTIFACTS=/tmp/impact-mask-to-segs-e2e \
python -m pytest tests/test_e2e_mask_to_segs.py -v
```
For RGR, run the CPU and Chrome mask tests on the original code, the fix, and the
original code again, keeping tests and fixtures unchanged. Both Red runs must
fail on the same behavioral assertions. Restore the fix and rerun the mask tests
and `tests/test_e2e_widget_catalog.py` before preparing the PR.
The CPU boundary and mixed-batch cases must also reject forced float32 conversion
and clamping only the first batch, respectively.
Chrome Red runs stop at the initial pixel mismatch. Native reload and the changed
input run are checked in Green and after restoring the fix.
The tutorial regression suite loads the original `mask_to_segs.png`,
`maskpointer.json` and `segs-manipulation.png` from the extension-tutorials
checkout, then exports and reloads each through the UI. It checks saved widgets,
including the old four-value layout; missing media/model assets do not prevent
this loading check. Original saved values are checked before loading so fixture
updates cannot silently remove the legacy-layout case. Both mask E2E suites
compare JSON values with their types, distinguishing Boolean controls from 0/1.
The tutorial loading test does not execute those workflows. The CPU suite separately
tests the tutorial's holes, nested regions, fill options, expanded crop, drop-size
boundaries, combined regions and mask-list coordinates.
```bash
IMPACT_TUTORIALS_DIR=/path/to/ComfyUI-extension-tutorials \
IMPACT_E2E_BASE_URL=http://127.0.0.1:18188 \
python -m pytest tests/test_e2e_mask_tutorials.py -v
```
Keep the original tutorial files unchanged when comparing the base and fix. The
reviewed examples are from tutorial commit `ca31a3bae14dbede5366de4904dfedea2697b082`.
From the Impact Pack root:
```bash
+1 -1
View File
@@ -1350,7 +1350,7 @@ def mask_to_segs(mask, combined, crop_factor, bbox_fill, drop_size=1, label='A',
result.append(item)
else:
mask_i_uint8 = (mask_i * 255.0).astype(np.uint8)
mask_i_uint8 = (np.clip(mask_i, 0, 1) * 255.0).astype(np.uint8)
contours, ctree = cv2.findContours(mask_i_uint8, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
for j, contour in enumerate(contours):
hierarchy = ctree[0][j]
+33
View File
@@ -22,6 +22,39 @@ to pytest with the same configuration.
The three TXT encoding fixtures in `tests/wildcards/samples/` are included
in the repository. The older server-dependent suites below are run separately.
## Mask-to-SEGS regression tests
Run separately from the stubbed wildcard tests, using a real ComfyUI installation
on CPU without models. Use its Python environment with the Impact Pack dependencies
and `tests/requirements.txt` installed; `IMPACT_COMFY_ROOT` points to the directory
containing ComfyUI's `nodes.py` and `comfy/`:
```bash
IMPACT_COMFY_ROOT=/path/to/ComfyUI python -m pytest tests/test_mask_to_segs.py -v
```
Checks cover finite out-of-range values before contour quantization, bicubic
overshoot, adjacent values at the quantization threshold, mixed ranges in every
batch, input immutability, and existing combined behavior.
Tutorial-derived cases also cover holes and nested regions, bbox/contour fill,
expanded edge crops, `drop_size` boundaries, disconnected combined regions and
the mask-list consumer's image coordinates.
Only unused server registration is substituted, using pytest's scoped
monkeypatch cleanup. `tests/test_e2e_mask_to_segs.py` separately executes the bicubic
workflow in Chrome, compares preview pixels with an explicit core-node clamp,
and checks native saved widget names/order/values in both restoration modes.
After reload it changes the resize factor through the UI, verifies the changed
prompt and uncached execution path, and checks new filenames, dimensions and pixels.
See [E2E setup and commands](../docs/E2E_TEST_STRATEGY.md) for the isolated CPU
server, Playwright/Chrome requirements, and catalog sample configuration.
`tests/test_e2e_mask_tutorials.py` separately loads the original tutorial PNG/JSON
files, checks their saved controls, exports and reloads them under both restoration
modes. Set `IMPACT_TUTORIALS_DIR` to the `ComfyUI-extension-tutorials` checkout.
This loading test needs no referenced media or models and performs no inference.
Missing required environment variables fail with setup guidance; they do not
silently skip an explicitly requested regression suite.
## Chrome UI E2E
`python3 tests/e2e_wildcards.py` creates and connects nodes, edits widgets,
+107
View File
@@ -0,0 +1,107 @@
"""Bicubic mask execution and native workflow restoration through Chrome.
Use an isolated ComfyUI server with Impact Pack; no models are required.
The reference branch uses core MaskComposite to clamp the same resized mask.
"""
from argparse import Namespace
import json
import os
from pathlib import Path
import pytest
from e2e_ui import browser_session
from test_e2e_widget_catalog import export_workflow, setting, show_node, widget_state
@pytest.mark.parametrize("restore_by_name", [False, True], ids=["positional", "named"])
def test_bicubic_mask_pixels_and_native_widgets(tmp_path, restore_by_name):
args = Namespace(base_url=os.environ.get("IMPACT_E2E_BASE_URL", "http://127.0.0.1:18188"),
browser_channel="chrome", headed=False, timeout=30,
artifacts=Path(os.environ.get("IMPACT_E2E_ARTIFACTS", str(tmp_path))) / str(restore_by_name))
prompt = {
"base": {"class_type": "SolidMask", "inputs": {"value": 0.0, "width": 64, "height": 64}},
"patch": {"class_type": "SolidMask", "inputs": {"value": 1.0, "width": 32, "height": 32}},
"pattern": {"class_type": "MaskComposite", "inputs": {
"destination": ["base", 0], "source": ["patch", 0], "x": 16, "y": 16, "operation": "add"}},
"image": {"class_type": "MaskToImage", "inputs": {"mask": ["pattern", 0]}},
"scale": {"class_type": "ImageScaleBy", "inputs": {
"image": ["image", 0], "upscale_method": "bicubic", "scale_by": 4.0}},
"mask": {"class_type": "ImageToMask", "inputs": {"image": ["scale", 0], "channel": "red"}},
"zero": {"class_type": "SolidMask", "inputs": {"value": 0.0, "width": 256, "height": 256}},
"clamp": {"class_type": "MaskComposite", "inputs": {
"destination": ["mask", 0], "source": ["zero", 0], "x": 0, "y": 0, "operation": "add"}},
}
expected_widgets = [{"name": name, "value": value} for name, value in [
("combined", False), ("crop_factor", 2.2), ("bbox_fill", False),
("drop_size", 7), ("contour_fill", True),
]]
for branch, source in [("actual", "mask"), ("reference", "clamp")]:
prompt.update({
f"{branch}_segs": {"class_type": "MaskToSEGS", "inputs": {
"mask": [source, 0], **{widget["name"]: widget["value"] for widget in expected_widgets}}},
f"{branch}_mask": {"class_type": "SegsToCombinedMask", "inputs": {"segs": [f"{branch}_segs", 0]}},
f"{branch}_image": {"class_type": "MaskToImage", "inputs": {"mask": [f"{branch}_mask", 0]}},
f"{branch}_preview": {"class_type": "PreviewImage", "inputs": {"images": [f"{branch}_image", 0]}},
})
with browser_session(args) as ui:
ui.page.keyboard.press("Escape")
setting(ui, "Restore widget values", "Comfy.Workflow.NamedValuesRestore", restore_by_name)
ui.new_workflow()
ui.load_workflow({"name": "bicubic-mask-api.json", "mimeType": "application/json",
"buffer": json.dumps(prompt).encode()}, len(prompt))
before = {}
for node_id in ("actual_segs", "reference_segs"):
show_node(ui, node_id)
before[node_id] = widget_state(ui, node_id)
# Preserve JSON types: Python equality alone accepts False == 0.
assert json.dumps(before[node_id]["widgets"], sort_keys=True) == json.dumps(expected_widgets, sort_keys=True)
expected_values = {node_id: [widget["value"] for widget in expected_widgets] for node_id in before}
workflow = args.artifacts / "bicubic-mask-native.json"
export_workflow(ui, workflow)
saved = json.loads(workflow.read_text())
values = {str(node["id"]): node["widgets_values"] for node in saved["nodes"] if str(node["id"]) in before}
assert json.dumps(values, sort_keys=True) == json.dumps(expected_values, sort_keys=True)
previous_outputs = None
reload_scale = 3.0 if restore_by_name else 2.0
for reload_workflow in (False, True):
if reload_workflow:
ui.new_workflow()
ui.load_workflow(workflow, len(prompt))
for node_id, state in before.items():
show_node(ui, node_id)
assert json.dumps(widget_state(ui, node_id), sort_keys=True) == json.dumps(state, sort_keys=True)
reexported = args.artifacts / "bicubic-mask-reloaded.json"
export_workflow(ui, reexported)
restored = json.loads(reexported.read_text())
values = {str(node["id"]): node["widgets_values"] for node in restored["nodes"] if str(node["id"]) in before}
assert json.dumps(values, sort_keys=True) == json.dumps(expected_values, sort_keys=True)
show_node(ui, "scale")
ui.set_number("ImageScaleBy", "scale_by", reload_scale)
entry = ui.run()
(args.artifacts / f"execution-{int(reload_workflow)}.json").write_text(json.dumps(entry, indent=2) + "\n")
if reload_workflow:
assert entry["prompt"][2]["scale"]["inputs"]["scale_by"] == reload_scale
cached = {node for event, data in entry["status"]["messages"]
if event == "execution_cached" for node in data["nodes"]}
changed = {"scale", "mask", "clamp", "actual_segs", "reference_segs",
"actual_mask", "reference_mask", "actual_image", "reference_image",
"actual_preview", "reference_preview"}
assert cached.isdisjoint(changed), f"Changed mask path reused execution cache: {cached & changed}"
for node_id in ("actual_preview", "reference_preview"):
filename = entry["outputs"][node_id]["images"][0]["filename"]
assert filename != previous_outputs[node_id]["images"][0]["filename"]
ui.page.get_by_role("button", name="Fit View (.)", exact=True).click()
images = ui.images(entry)
actual = images["actual_preview"][0]
reference = images["reference_preview"][0]
size = int(64 * reload_scale) if reload_workflow else 256
assert actual.size == reference.size == (size, size)
if not reload_workflow:
# Pixel baseline: PyTorch 2.10.0 and OpenCV 4.13.0.
# Investigate dependency changes before updating these expectations.
assert reference.getbbox() == (59, 59, 197, 197)
assert reference.convert("L").histogram()[255] == 17460
pixels_match = actual.tobytes() == reference.tobytes()
assert pixels_match, "Bicubic mask contour includes wrapped out-of-range pixels"
previous_outputs = entry["outputs"]
+64
View File
@@ -0,0 +1,64 @@
"""Native PNG/JSON compatibility using the original extension tutorials.
Set IMPACT_TUTORIALS_DIR to a ComfyUI-extension-tutorials checkout.
This checks loading and saved widgets; it does not run model inference.
"""
from argparse import Namespace
import json
import os
from pathlib import Path
from PIL import Image
import pytest
from e2e_ui import browser_session
from test_e2e_widget_catalog import export_workflow, setting, show_node, widget_state
@pytest.mark.parametrize("restore_by_name", [False, True], ids=["positional", "named"])
def test_native_mask_tutorial_widgets(tmp_path, restore_by_name):
tutorials_dir = os.environ.get("IMPACT_TUTORIALS_DIR")
if not tutorials_dir:
pytest.fail("Set IMPACT_TUTORIALS_DIR to a ComfyUI-extension-tutorials checkout.", pytrace=False)
tutorials = Path(tutorials_dir) / "ComfyUI-Impact-Pack/workflow"
args = Namespace(base_url=os.environ.get("IMPACT_E2E_BASE_URL", "http://127.0.0.1:18188"),
browser_channel="chrome", headed=False, timeout=30,
artifacts=Path(os.environ.get("IMPACT_E2E_ARTIFACTS", str(tmp_path))) / f"tutorial-{restore_by_name}")
names = ["combined", "crop_factor", "bbox_fill", "drop_size", "contour_fill"]
cases = [
("mask_to_segs.png", 5, {"3": [False, 1, False, 50, False]}),
# Four saved values: the absent contour_fill must retain its False default.
("maskpointer.json", 4, {"13": [True, 2, False, 10, False], "57": [False, 2, False, 10, False]}),
("segs-manipulation.png", 5, {"2": [False, 3, False, 10, False]}),
]
with browser_session(args) as ui:
ui.page.keyboard.press("Escape")
setting(ui, "Restore widget values", "Comfy.Workflow.NamedValuesRestore", restore_by_name)
for filename, saved_count, expected in cases:
path = tutorials / filename
if path.suffix == ".png":
with Image.open(path) as image:
original = json.loads(image.info["workflow"])
else:
original = json.loads(path.read_text())
original_values = {str(node["id"]): node["widgets_values"] for node in original["nodes"] if str(node["id"]) in expected}
expected_original = {node_id: values[:saved_count] for node_id, values in expected.items()}
assert json.dumps(original_values, sort_keys=True) == json.dumps(expected_original, sort_keys=True), (
f"Tutorial saved controls changed: {filename}")
for loading_original in (True, False):
ui.new_workflow()
# Missing media/models do not prevent checking the saved controls.
ui.load_workflow(path, len(original["nodes"]), check_node_errors=False)
for node_id, values in expected.items():
show_node(ui, node_id)
state = widget_state(ui, node_id)
assert state["type"] == "MaskToSEGS"
wanted = [{"name": name, "value": value} for name, value in zip(names, values)]
assert json.dumps(state["widgets"], sort_keys=True) == json.dumps(wanted, sort_keys=True)
exported = args.artifacts / f"{Path(filename).stem}-{int(loading_original)}.json"
export_workflow(ui, exported)
saved = json.loads(exported.read_text())
values = {str(node["id"]): node["widgets_values"] for node in saved["nodes"] if str(node["id"]) in expected}
# Preserve JSON types: Python equality alone accepts False == 0.
assert json.dumps(values, sort_keys=True) == json.dumps(expected, sort_keys=True)
path = exported
+215
View File
@@ -0,0 +1,215 @@
"""CPU integration checks; run separately from the stubbed wildcard tests.
IMPACT_COMFY_ROOT=/path/to/ComfyUI python -m pytest tests/test_mask_to_segs.py -v
Only unused server registration is substituted.
"""
import os
from pathlib import Path
from types import SimpleNamespace
import numpy as np
import pytest
import torch
@pytest.fixture(scope="module")
def runtime():
comfy_root = os.environ.get("IMPACT_COMFY_ROOT")
if not comfy_root:
pytest.fail("Set IMPACT_COMFY_ROOT to the ComfyUI directory containing nodes.py and comfy/.", pytrace=False)
with pytest.MonkeyPatch.context() as patch:
patch.syspath_prepend(comfy_root)
from comfy.cli_args import args
patch.setattr(args, "cpu", True)
import nodes
from aiohttp import web
from server import PromptServer
patch.setattr(PromptServer, "instance", SimpleNamespace(
routes=web.RouteTableDef(), add_on_prompt_handler=lambda handler: None), raising=False)
from comfy_extras.nodes_mask import ImageToMask
from impact import core, segs_nodes
assert Path(core.__file__).resolve().parents[2] == Path(__file__).resolve().parents[1]
yield SimpleNamespace(core=core, segs_nodes=segs_nodes, nodes=nodes, ImageToMask=ImageToMask)
@pytest.mark.parametrize("value,expected_count", [(-1.0, 0), (-0.125, 0), (256 / 255, 1), (2.0, 1), (65504.0, 1)])
@pytest.mark.parametrize("contour_fill", [False, True])
@pytest.mark.parametrize("dtype", [torch.float16, torch.float32, torch.float64])
def test_out_of_range_rectangle(runtime, value, expected_count, contour_fill, dtype):
mask = torch.zeros((1, 32, 48), dtype=dtype)
mask[:, 4:20, 8:32] = value
before = mask.clone()
shape, segments = runtime.segs_nodes.MaskToSEGS.doit(mask, False, 1.0, False, 1, contour_fill)[0]
assert shape == (32, 48)
assert len(segments) == expected_count
for seg in segments:
assert seg.bbox == (8, 4, 32, 20)
assert tuple(seg.crop_region) == seg.bbox
np.testing.assert_array_equal(seg.cropped_mask, np.ones((16, 24), dtype=np.float32))
torch.testing.assert_close(mask, before, rtol=0, atol=0)
@pytest.mark.parametrize("dtype", [np.float16, np.float32, np.float64])
@pytest.mark.parametrize("batched", [False, True])
@pytest.mark.parametrize("combined,contour_fill", [(False, False), (False, True), (True, False)])
def test_normal_masks_preserve_values_geometry_and_batch_order(runtime, dtype, batched, combined, contour_fill):
mask = np.zeros((2, 32, 48), dtype=dtype)
mask[0, 4:20, 8:32] = 0.375
mask[1, 8:24, 16:40] = 0.75
if not batched:
mask = mask[0]
before = mask.copy()
shape, segments = runtime.core.mask_to_segs(mask, combined, 1.0, False, 1, is_contour=contour_fill)
assert shape == (32, 48)
assert len(segments) == (2 if batched else 1)
for index, seg in enumerate(segments):
x, y = 8 + 8 * index, 4 + 4 * index
# The combined path's historical max coordinates are inclusive.
width, height = (23, 15) if combined else (24, 16)
assert seg.bbox == (x, y, x + width, y + height)
assert tuple(seg.crop_region) == seg.bbox
value = 1.0 if contour_fill else (0.375, 0.75)[index]
np.testing.assert_array_equal(seg.cropped_mask, np.full((height, width), value))
assert seg.cropped_image is None and seg.confidence == 1.0 and seg.label == 'A'
assert seg.control_net_wrapper is None
np.testing.assert_array_equal(mask, before)
@pytest.mark.parametrize("dtype", [np.float16, np.float32, np.float64])
@pytest.mark.parametrize("contour_fill", [False, True])
def test_adjacent_values_at_quantization_boundary(runtime, dtype, contour_fill):
threshold = dtype(1 / 255)
values = [np.nextafter(threshold, dtype(0)), threshold, np.nextafter(threshold, dtype(1))]
mask = np.zeros((24, 64), dtype=dtype)
for index, value in enumerate(values):
mask[4:20, 4 + index * 20:16 + index * 20] = value
before = mask.copy()
shape, segments = runtime.core.mask_to_segs(mask, False, 1.0, False, 1, is_contour=contour_fill)
assert shape == (24, 64)
segments.sort(key=lambda seg: seg.bbox)
# Existing arithmetic in each input dtype excludes only the predecessor.
assert [seg.bbox for seg in segments] == [(24, 4, 36, 20), (44, 4, 56, 20)]
for seg, value in zip(segments, values[1:]):
expected = 1.0 if contour_fill else value
expected_dtype = np.float32 if contour_fill else np.result_type(dtype, np.float32)
assert seg.cropped_mask.dtype == expected_dtype
np.testing.assert_array_equal(seg.cropped_mask, np.full((16, 12), expected, dtype=expected_dtype))
np.testing.assert_array_equal(mask, before)
@pytest.mark.parametrize("dtype", [np.float16, np.float32, np.float64])
@pytest.mark.parametrize("contour_fill", [False, True])
@pytest.mark.parametrize("tensor_input", [False, True], ids=["numpy_core", "tensor_node"])
def test_mixed_ranges_in_every_batch(runtime, dtype, contour_fill, tensor_input):
values = [(0.375, -1.0, 256 / 255), (-0.125, 256 / 255, 0.75), (256 / 255, 0.5, -1.0)]
mask = np.zeros((3, 24, 64), dtype=dtype)
for batch, row in enumerate(values):
for column, value in enumerate(row):
mask[batch, 4:20, 4 + column * 20:16 + column * 20] = value
before = mask.copy()
if tensor_input:
shape, segments = runtime.segs_nodes.MaskToSEGS.doit(
torch.from_numpy(mask), False, 1.0, False, 1, contour_fill)[0]
else:
shape, segments = runtime.core.mask_to_segs(mask, False, 1.0, False, 1, is_contour=contour_fill)
assert shape == (24, 64)
assert len(segments) == 6
for batch, row in enumerate(values):
expected = [(column, value) for column, value in enumerate(row) if value > 0]
items = sorted(segments[batch * 2:batch * 2 + 2], key=lambda seg: seg.bbox)
for seg, (column, value) in zip(items, expected):
assert seg.bbox == (4 + column * 20, 4, 16 + column * 20, 20)
assert tuple(seg.crop_region) == seg.bbox
expected_value = 1.0 if contour_fill or value > 1 else value
np.testing.assert_array_equal(seg.cropped_mask, np.full((16, 12), expected_value))
np.testing.assert_array_equal(mask, before)
def test_empty_masks_and_combined_path_remain_unchanged(runtime):
assert runtime.core.mask_to_segs(None, False, 1.0, False) == ([],)
assert runtime.core.mask_to_segs(np.zeros((2, 32, 48)), False, 1.0, False) == ((32, 48), [])
mask = np.zeros((32, 48), dtype=np.float32)
mask[4:20, 8:32] = 2.0
_, segments = runtime.core.mask_to_segs(mask, True, 1.0, False)
np.testing.assert_array_equal(segments[0].cropped_mask, np.full((15, 23), 2.0))
@pytest.mark.parametrize("contour_fill", [False, True])
def test_bicubic_image_to_mask(runtime, contour_fill):
image = torch.zeros((1, 64, 64, 3))
image[:, 16:48, 16:48, :] = 1
scaled = runtime.nodes.ImageScaleBy().upscale(image, "bicubic", 4.0)[0]
mask = runtime.ImageToMask.execute(scaled, "red")[0]
assert mask.min() < 0 and mask.max() > 1
before = mask.clone()
shape, segments = runtime.segs_nodes.MaskToSEGS.doit(mask, False, 1.0, False, 10, contour_fill)[0]
assert shape == (256, 256)
# Pixel baseline: PyTorch 2.10.0 and OpenCV 4.13.0.
# Investigate dependency changes before updating these expectations.
assert [seg.bbox for seg in segments] == [(59, 59, 197, 197)]
if contour_fill:
assert np.count_nonzero(segments[0].cropped_mask) == 17460
else:
assert 0 <= segments[0].cropped_mask.min() <= segments[0].cropped_mask.max() <= 1
torch.testing.assert_close(mask, before, rtol=0, atol=0)
@pytest.mark.parametrize("bbox_fill", [False, True])
@pytest.mark.parametrize("contour_fill", [False, True])
def test_tutorial_holes_and_expanded_crop(runtime, bbox_fill, contour_fill):
# mask_to_segs.png explains holes/fill; segs-manipulation.png uses crop_factor=3.
outer = np.zeros((64, 80), dtype=np.float32)
outer[4:28, 4:28] = 1
for y in (4, 24):
for x in (4, 24):
outer[y:y + 4, x:x + 4] = 0
mask = outer * 0.375
mask[10:22, 10:22] = 0
mask[14:18, 14:18] = 0.75 # Nested island must not become an extra SEG.
before = mask.copy()
segs = runtime.segs_nodes.MaskToSEGS.doit(
torch.from_numpy(mask).unsqueeze(0), False, 3.0, bbox_fill, 10, contour_fill)[0]
assert segs[0] == (64, 80) and len(segs[1]) == 1
seg = segs[1][0]
assert seg.bbox == (4, 4, 28, 28)
assert tuple(seg.crop_region) == (0, 0, 72, 64)
expected = outer.copy() if contour_fill else before.copy()
if bbox_fill:
expected[4:28, 4:28] = 1
np.testing.assert_array_equal(seg.cropped_mask, expected[:, :72])
# The tutorial's mask-list consumer restores image coordinates and quantizes.
masks = runtime.segs_nodes.SEGSToMaskList().doit(segs)[0]
assert len(masks) == 1 and masks[0].shape == (1, 64, 80)
quantized = expected.copy()
quantized[expected == 0.375] = 95 / 255
quantized[expected == 0.75] = 191 / 255
np.testing.assert_array_equal(masks[0][0].numpy(), quantized)
np.testing.assert_array_equal(mask, before)
@pytest.mark.parametrize("drop_size", [10, 50])
def test_tutorial_drop_size_boundary(runtime, drop_size):
# Values saved in mask_to_segs.png and segs-manipulation.png.
size = drop_size + 1
mask = np.zeros((size + 8, 3 * (size + 8)), dtype=np.float32)
for index, (height, width) in enumerate([(size, drop_size), (drop_size, size), (size, size)]):
x = 4 + index * (size + 8)
mask[4:4 + height, x:x + width] = 1
segs = runtime.segs_nodes.MaskToSEGS.doit(torch.from_numpy(mask), False, 1.0, False, drop_size)[0]
x = 4 + 2 * (size + 8)
assert [seg.bbox for seg in segs[1]] == [(x, 4, x + size, 4 + size)]
def test_tutorial_combined_disconnected_regions(runtime):
# maskpointer.json stores both combined modes with crop_factor=2.
mask = np.zeros((64, 96), dtype=np.float32)
mask[8:24, 8:24] = 0.5
mask[32:48, 56:72] = 1
before = mask.copy()
separate = runtime.segs_nodes.MaskToSEGS.doit(torch.from_numpy(mask), False, 2.0, False, 10)[0]
assert sorted(seg.bbox for seg in separate[1]) == [(8, 8, 24, 24), (56, 32, 72, 48)]
combined = runtime.segs_nodes.MaskToSEGS.doit(torch.from_numpy(mask), True, 2.0, False, 10)[0]
assert len(combined[1]) == 1 and combined[1][0].bbox == (8, 8, 71, 47)
assert tuple(combined[1][0].crop_region) == (0, 0, 96, 64)
np.testing.assert_array_equal(combined[1][0].cropped_mask, before)
np.testing.assert_array_equal(mask, before)