From fa9aeaa4a9dc8adb1b2402646e29f2e942cf644a Mon Sep 17 00:00:00 2001 From: matei jordache Date: Fri, 27 Mar 2026 21:37:39 -0700 Subject: [PATCH] add flip node --- backend/node_menu.py | 1 + backend/nodes/__init__.py | 1 + backend/nodes/flip_field.py | 93 +++++++++++++++++++++++++++++++++++++ tests/test_nodes.py | 76 ++++++++++++++++++++++++++++++ 4 files changed, 171 insertions(+) create mode 100644 backend/nodes/flip_field.py diff --git a/backend/node_menu.py b/backend/node_menu.py index f8175b6..b661186 100644 --- a/backend/node_menu.py +++ b/backend/node_menu.py @@ -39,6 +39,7 @@ MENU_LAYOUT: dict[str, list[str]] = { "ColormapAdjust", "CropResizeField", "RotateField", + "FlipField", ], "Filter": [ "GaussianFilter", diff --git a/backend/nodes/__init__.py b/backend/nodes/__init__.py index 234cf27..7cd328d 100644 --- a/backend/nodes/__init__.py +++ b/backend/nodes/__init__.py @@ -20,6 +20,7 @@ from backend.nodes import ( colormap_adjust, crop_resize_field, rotate_field, + flip_field, # Level plane_level_field, poly_level_field, diff --git a/backend/nodes/flip_field.py b/backend/nodes/flip_field.py new file mode 100644 index 0000000..cefbf3e --- /dev/null +++ b/backend/nodes/flip_field.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from copy import deepcopy + +import numpy as np + +from backend.data_types import DataField +from backend.node_registry import register_node + + +@register_node(display_name="Flip") +class FlipField: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "field": ("DATA_FIELD",), + "axis": (["x", "y"], {"default": "x"}), + } + } + + RETURN_TYPES = ("DATA_FIELD",) + RETURN_NAMES = ("field",) + FUNCTION = "process" + + DESCRIPTION = ( + "Reflect a DATA_FIELD across the X axis (top/bottom) or Y axis (left/right). " + "Physical extents are preserved, and stored markup overlays are mirrored with the data." + ) + + def process(self, field: DataField, axis: str) -> tuple: + axis_name = str(axis).strip().lower() + if axis_name == "x": + flipped = np.flipud(field.data).copy() + elif axis_name == "y": + flipped = np.fliplr(field.data).copy() + else: + raise ValueError(f"Unknown flip axis: {axis}") + + return ( + field.replace( + data=np.asarray(flipped, dtype=np.float64), + overlays=self._flip_overlays(field.overlays, axis_name), + ), + ) + + @classmethod + def _flip_overlays(cls, overlays: list[dict] | None, axis: str) -> list[dict]: + if not isinstance(overlays, list): + return [] + + flipped_overlays = [] + for overlay in overlays: + if not isinstance(overlay, dict): + continue + + next_overlay = deepcopy(overlay) + if str(next_overlay.get("kind", "")).strip().lower() == "markup": + next_overlay["shapes"] = [ + cls._flip_markup_shape(shape, axis) + for shape in next_overlay.get("shapes", []) + if isinstance(shape, dict) + ] + flipped_overlays.append(next_overlay) + return flipped_overlays + + @staticmethod + def _flip_markup_shape(shape: dict, axis: str) -> dict: + flipped = deepcopy(shape) + kind = str(flipped.get("kind", "")).strip().lower() + + try: + x1 = float(flipped.get("x1")) + y1 = float(flipped.get("y1")) + x2 = float(flipped.get("x2")) + y2 = float(flipped.get("y2")) + except (TypeError, ValueError): + return flipped + + if axis == "x": + y1, y2 = 1.0 - y1, 1.0 - y2 + else: + x1, x2 = 1.0 - x1, 1.0 - x2 + + if kind in {"rectangle", "circle"}: + x1, x2 = sorted((x1, x2)) + y1, y2 = sorted((y1, y2)) + + flipped["x1"] = float(np.clip(x1, 0.0, 1.0)) + flipped["y1"] = float(np.clip(y1, 0.0, 1.0)) + flipped["x2"] = float(np.clip(x2, 0.0, 1.0)) + flipped["y2"] = float(np.clip(y2, 0.0, 1.0)) + return flipped diff --git a/tests/test_nodes.py b/tests/test_nodes.py index 366f311..33e14c2 100644 --- a/tests/test_nodes.py +++ b/tests/test_nodes.py @@ -256,6 +256,81 @@ def test_rotate_field_overlay_warning(): print(" PASS\n") +def test_flip_field(): + print("=== Test: FlipField ===") + from backend.nodes.flip_field import FlipField + from backend.node_registry import get_node_info + + node = FlipField() + data = np.arange(1, 10, dtype=np.float64).reshape(3, 3) + markup_overlay = { + "kind": "markup", + "shapes": [ + {"kind": "line", "x1": 0.1, "y1": 0.2, "x2": 0.9, "y2": 0.8, "width": 2, "color": "#ffffff"}, + {"kind": "rectangle", "x1": 0.15, "y1": 0.1, "x2": 0.45, "y2": 0.6, "width": 3, "color": "#ff0000"}, + ], + } + annotation_overlay = { + "kind": "annotation", + "show_scale_bar": True, + "show_color_map": False, + "text_size": 14.0, + } + field = DataField( + data=data, + xreal=3.0, + yreal=4.0, + xoff=10.0, + yoff=20.0, + si_unit_xy="nm", + si_unit_z="nm", + overlays=[markup_overlay, annotation_overlay], + ) + + assert get_node_info("FlipField")["category"] == "Modify" + + flipped_x, = node.process(field, axis="x") + assert np.array_equal(flipped_x.data, np.flipud(data)) + assert flipped_x.xreal == field.xreal + assert flipped_x.yreal == field.yreal + assert flipped_x.xoff == field.xoff + assert flipped_x.yoff == field.yoff + assert flipped_x.si_unit_xy == field.si_unit_xy + assert flipped_x.si_unit_z == field.si_unit_z + assert np.isclose(flipped_x.overlays[0]["shapes"][0]["x1"], 0.1) + assert np.isclose(flipped_x.overlays[0]["shapes"][0]["y1"], 0.8) + assert np.isclose(flipped_x.overlays[0]["shapes"][0]["x2"], 0.9) + assert np.isclose(flipped_x.overlays[0]["shapes"][0]["y2"], 0.2) + assert np.isclose(flipped_x.overlays[0]["shapes"][1]["x1"], 0.15) + assert np.isclose(flipped_x.overlays[0]["shapes"][1]["y1"], 0.4) + assert np.isclose(flipped_x.overlays[0]["shapes"][1]["x2"], 0.45) + assert np.isclose(flipped_x.overlays[0]["shapes"][1]["y2"], 0.9) + assert flipped_x.overlays[1] == annotation_overlay + + flipped_y, = node.process(field, axis="y") + assert np.array_equal(flipped_y.data, np.fliplr(data)) + assert np.isclose(flipped_y.overlays[0]["shapes"][0]["x1"], 0.9) + assert np.isclose(flipped_y.overlays[0]["shapes"][0]["y1"], 0.2) + assert np.isclose(flipped_y.overlays[0]["shapes"][0]["x2"], 0.1) + assert np.isclose(flipped_y.overlays[0]["shapes"][0]["y2"], 0.8) + assert np.isclose(flipped_y.overlays[0]["shapes"][1]["x1"], 0.55) + assert np.isclose(flipped_y.overlays[0]["shapes"][1]["y1"], 0.1) + assert np.isclose(flipped_y.overlays[0]["shapes"][1]["x2"], 0.85) + assert np.isclose(flipped_y.overlays[0]["shapes"][1]["y2"], 0.6) + assert flipped_y.overlays[1] == annotation_overlay + + assert field.overlays[0]["shapes"][0]["x1"] == markup_overlay["shapes"][0]["x1"] + assert field.overlays[0]["shapes"][0]["y1"] == markup_overlay["shapes"][0]["y1"] + + try: + node.process(field, axis="diagonal") + raise AssertionError("Expected invalid flip axis to raise ValueError") + except ValueError: + pass + + print(" PASS\n") + + def test_view3d_normalizes_small_physical_extents_for_display(): print("=== Test: View3D extent normalization ===") from backend.nodes.view_3d import View3D @@ -2437,6 +2512,7 @@ if __name__ == "__main__": test_median_filter() test_crop_resize_field() test_rotate_field() + test_flip_field() test_colormap_adjust() test_edge_detect() test_fft_filter_1d()