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:
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user