From 2c9efb174153559fd2f2ffecef600564ad4316fa Mon Sep 17 00:00:00 2001 From: "Dr.Lt.Data" Date: Sun, 1 Nov 2026 12:10:52 +0900 Subject: [PATCH] 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. --- docs/E2E_TEST_STRATEGY.md | 58 ++++++++- modules/impact/core.py | 2 +- tests/README.md | 33 +++++ tests/test_e2e_mask_to_segs.py | 107 +++++++++++++++ tests/test_e2e_mask_tutorials.py | 64 +++++++++ tests/test_mask_to_segs.py | 215 +++++++++++++++++++++++++++++++ 6 files changed, 476 insertions(+), 3 deletions(-) create mode 100644 tests/test_e2e_mask_to_segs.py create mode 100644 tests/test_e2e_mask_tutorials.py create mode 100644 tests/test_mask_to_segs.py diff --git a/docs/E2E_TEST_STRATEGY.md b/docs/E2E_TEST_STRATEGY.md index 8db70cf0..2cac5f97 100644 --- a/docs/E2E_TEST_STRATEGY.md +++ b/docs/E2E_TEST_STRATEGY.md @@ -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 diff --git a/modules/impact/core.py b/modules/impact/core.py index a75a5bfd..ae28a13f 100644 --- a/modules/impact/core.py +++ b/modules/impact/core.py @@ -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] diff --git a/tests/README.md b/tests/README.md index 45f5679f..fde93a09 100644 --- a/tests/README.md +++ b/tests/README.md @@ -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, diff --git a/tests/test_e2e_mask_to_segs.py b/tests/test_e2e_mask_to_segs.py new file mode 100644 index 00000000..cbfba21a --- /dev/null +++ b/tests/test_e2e_mask_to_segs.py @@ -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"] diff --git a/tests/test_e2e_mask_tutorials.py b/tests/test_e2e_mask_tutorials.py new file mode 100644 index 00000000..bd4d59f7 --- /dev/null +++ b/tests/test_e2e_mask_tutorials.py @@ -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 diff --git a/tests/test_mask_to_segs.py b/tests/test_mask_to_segs.py new file mode 100644 index 00000000..fe3dd7ba --- /dev/null +++ b/tests/test_mask_to_segs.py @@ -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)