diff --git a/arhdf.log b/arhdf.log new file mode 100644 index 0000000..c343a3c --- /dev/null +++ b/arhdf.log @@ -0,0 +1,51 @@ + +=== Channel: Adhesion:Retrace === + DimScaling shape: (2, 2) + DimScaling[0,:]: [8.656250e-06 1.665625e-05] (row 0) + DimScaling[1,:]: [-4.e-06 4.e-06] (row 1) + --- If [start, end] interpretation --- + xreal (row1 range) = 8e-06 + yreal (row0 range) = 7.999999999999998e-06 + --- If [step, offset] interpretation --- + row0: step=8.656249999999648e-06 offset=1.6656249999999646e-05 + row1: step=-4e-06 offset=4e-06 + DimUnits: ['m' 'm'] + DataUnits: N + DimExtents ('Resolution 0'): [512 384] + +=== Channel: FFMZSensor:Retrace === + DimScaling shape: (2, 2) + DimScaling[0,:]: [8.656250e-06 1.665625e-05] (row 0) + DimScaling[1,:]: [-4.e-06 4.e-06] (row 1) + --- If [start, end] interpretation --- + xreal (row1 range) = 8e-06 + yreal (row0 range) = 7.999999999999998e-06 + --- If [step, offset] interpretation --- + row0: step=8.656249999999648e-06 offset=1.6656249999999646e-05 + row1: step=-4e-06 offset=4e-06 + DimUnits: ['m' 'm'] + DataUnits: m + DimExtents ('Resolution 0'): [512 384] + +=== Channel: MaxForce:Retrace === + DimScaling shape: (2, 2) + DimScaling[0,:]: [8.656250e-06 1.665625e-05] (row 0) + DimScaling[1,:]: [-4.e-06 4.e-06] (row 1) + --- If [start, end] interpretation --- + xreal (row1 range) = 8e-06 + yreal (row0 range) = 7.999999999999998e-06 + --- If [step, offset] interpretation --- + row0: step=8.656249999999648e-06 offset=1.6656249999999646e-05 + row1: step=-4e-06 offset=4e-06 + DimUnits: ['m' 'm'] + DataUnits: N + DimExtents ('Resolution 0'): [512 384] + +=== 2D dataset shapes === + Image/DataSet/Resolution 0/Frame 0/Adhesion:Retrace/Image shape=(512, 384) + Image/DataSet/Resolution 0/Frame 0/FFMZSensor:Retrace/Image shape=(512, 384) + Image/DataSet/Resolution 0/Frame 0/MaxForce:Retrace/Image shape=(512, 384) + Image/DataSetInfo/Global/Channels/Adhesion:Retrace/Thumbnail shape=(128, 128) + Image/DataSetInfo/Global/Channels/FFMZSensor:Retrace/Thumbnail shape=(128, 128) + Image/DataSetInfo/Global/Channels/MaxForce:Retrace/Thumbnail shape=(128, 128) + Image/DataSetInfo/Global/Thumbnail shape=(128, 128) diff --git a/backend/importers/ergo_hdf5.py b/backend/importers/ergo_hdf5.py index 5f7e01c..a920bd0 100644 --- a/backend/importers/ergo_hdf5.py +++ b/backend/importers/ergo_hdf5.py @@ -5,9 +5,10 @@ Asylum Research instruments store scan metadata in a sidecar group rather than as dataset attributes. This importer reads physical dimensions from: Image/DataSetInfo/Global/Channels//ImageDims - DimScaling – (2,2) array: [[px_size_x, offset_x], [px_size_y, offset_y]] - DimExtents – pixel counts [xres, yres] (stored in a child group) - DimUnits – lateral unit strings + DimScaling – (2,2) array: [[Y_start, Y_end], [X_start, X_end]] + absolute physical coordinate ranges in DimUnits + DimExtents – pixel counts [yres, xres] (stored in a child group, not used for sizing) + DimUnits – lateral unit strings [Y_unit, X_unit] DataUnits – Z unit string If the sidecar group is absent (generic HDF5), standard dataset attributes @@ -78,6 +79,12 @@ def _ar_image_dims(f, ds_name: str) -> dict | None: and the metadata lives at: "Image/DataSetInfo/Global/Channels//ImageDims" + DimScaling is a (2, 2) array of *absolute physical coordinate ranges* + (not per-pixel step sizes), stored Y-first: + scaling[0, :] = [Y_start, Y_end] + scaling[1, :] = [X_start, X_end] + Both values are in the unit given by DimUnits. + Returns a dict with xreal, yreal, xoff, yoff, si_unit_xy, si_unit_z, or None if the group isn't found. """ @@ -93,30 +100,22 @@ def _ar_image_dims(f, ds_name: str) -> dict | None: if not isinstance(grp, h5py.Group): return None - scaling = grp.attrs.get("DimScaling") # shape (2, 2): [[px_x, off_x], [px_y, off_y]] - dim_units = grp.attrs.get("DimUnits") # array of unit strings, e.g. ['m', 'm'] + scaling = grp.attrs.get("DimScaling") # shape (2, 2): [[Y_start, Y_end], [X_start, X_end]] + dim_units = grp.attrs.get("DimUnits") # array of unit strings, e.g. ['m', 'm'] (Y then X) data_units = grp.attrs.get("DataUnits") # Z unit string, e.g. 'N' if scaling is None or np.asarray(scaling).shape != (2, 2): return None scaling = np.asarray(scaling, dtype=np.float64) - px_x, off_x = float(scaling[0, 0]), float(scaling[0, 1]) - px_y, off_y = float(scaling[1, 0]), float(scaling[1, 1]) + # Y axis first (row-major), then X — matching numpy's (rows, cols) convention. + y_start, y_end = float(scaling[0, 0]), float(scaling[0, 1]) + x_start, x_end = float(scaling[1, 0]), float(scaling[1, 1]) - # DimExtents gives pixel counts; use to compute total physical size. - extents_grp = None - for child_name in grp: - child = grp[child_name] - if isinstance(child, h5py.Group) and "DimExtents" in child.attrs: - extents_grp = child - break - - xres, yres = 1, 1 - if extents_grp is not None: - ext = np.asarray(extents_grp.attrs["DimExtents"]) - if ext.size >= 2: - xres, yres = int(ext[0]), int(ext[1]) + xreal = abs(x_end - x_start) or 1e-6 + yreal = abs(y_end - y_start) or 1e-6 + xoff = min(x_start, x_end) + yoff = min(y_start, y_end) def _decode(raw, default="m") -> str: if raw is None: @@ -127,12 +126,20 @@ def _ar_image_dims(f, ds_name: str) -> dict | None: return raw.decode("utf-8", errors="replace").strip() or default return str(raw).strip() or default + # DimUnits is [Y_unit, X_unit]; X unit is the canonical lateral unit. + if dim_units is not None and len(dim_units) >= 2: + xy_unit = _decode(dim_units[1]) + elif dim_units is not None and len(dim_units) >= 1: + xy_unit = _decode(dim_units[0]) + else: + xy_unit = "m" + return { - "xreal": abs(px_x * xres) or 1e-6, - "yreal": abs(px_y * yres) or 1e-6, - "xoff": off_x, - "yoff": off_y, - "si_unit_xy": _decode(dim_units[0] if dim_units is not None and len(dim_units) >= 1 else None), + "xreal": xreal, + "yreal": yreal, + "xoff": xoff, + "yoff": yoff, + "si_unit_xy": xy_unit, "si_unit_z": _decode(data_units), } diff --git a/backend/nodes/view_3d.py b/backend/nodes/view_3d.py index 6731a97..86c4a41 100644 --- a/backend/nodes/view_3d.py +++ b/backend/nodes/view_3d.py @@ -3,7 +3,7 @@ import base64 import io import numpy as np from backend.node_registry import register_node -from backend.execution_context import emit_mesh +from backend.execution_context import emit_mesh, emit_warning from backend.data_types import ( COLORMAPS, DataField, @@ -164,6 +164,14 @@ class View3D: data = field.data yres, xres = data.shape + phys_ratio = field.xreal / field.yreal if field.yreal else 1.0 + pixel_ratio = xres / yres if yres else 1.0 + if abs(phys_ratio / pixel_ratio - 1.0) > 0.02: + emit_warning( + f"Non-square pixels ({xres}\u00d7{yres} px). " + f"The 3D surface shows the physical scan area, not the pixel grid." + ) + step_y = max(1, yres // resolution) step_x = max(1, xres // resolution) z = data[::step_y, ::step_x].astype(np.float32) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 3837a63..1181e57 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -852,6 +852,7 @@ function Flow() { const [status, setStatus] = useState({ text: 'Connecting…', level: 'info' }); const [contextMenu, setContextMenu] = useState(null); const [isCanvasRightZooming, setIsCanvasRightZooming] = useState(false); + const [executingNodeId, setExecutingNodeId] = useState(null); const flowContainerRef = useRef(null); const panTimerRef = useRef(null); @@ -1285,15 +1286,19 @@ function Flow() { ...n, data: { ...n.data, processingTimeMs: null }, }))); + setExecutingNodeId(null); setStatus({ text: 'Running workflow…', level: 'info' }); break; case 'executing': + setExecutingNodeId(String(msg.data.node)); setStatus({ text: `Executing node ${msg.data.node}…`, level: 'info' }); break; case 'execution_complete': + setExecutingNodeId(null); setStatus({ text: 'Done.', level: 'info' }); break; case 'execution_error': + setExecutingNodeId(null); setStatus({ text: 'Error: ' + msg.data.message, level: 'error' }); console.error('[tono] execution error', msg.data); break; @@ -1924,7 +1929,8 @@ function Flow() { onResizeGroup: resizeGroup, onRenameGroup: renameGroup, onUngroup: ungroupGroup, - }), [onRuntimeValuesChange, onWidgetChange, openFileBrowser, onManualTrigger, renameGroup, resizeGroup, toggleGroupCollapse, ungroupGroup]); + executingNodeId, + }), [onRuntimeValuesChange, onWidgetChange, openFileBrowser, onManualTrigger, renameGroup, resizeGroup, toggleGroupCollapse, ungroupGroup, executingNodeId]); const clearGraph = useCallback(() => { setNodes([]); diff --git a/frontend/src/CustomNode.jsx b/frontend/src/CustomNode.jsx index a8ba017..8ea5449 100644 --- a/frontend/src/CustomNode.jsx +++ b/frontend/src/CustomNode.jsx @@ -413,9 +413,16 @@ function LayerGalleryPreview({ overlay }) { const layers = Array.isArray(overlay?.layers) ? overlay.layers : []; const [index, setIndex] = useState(0); + // Reset to 0 only when the layer names change (different file/channels loaded), + // not on every graph re-run which produces a new overlay object reference. + const layerNamesKey = layers.map((l) => l.name ?? '').join('\0'); + const prevLayerNamesKeyRef = useRef(layerNamesKey); useEffect(() => { - setIndex(0); - }, [overlay]); + if (layerNamesKey !== prevLayerNamesKeyRef.current) { + prevLayerNamesKeyRef.current = layerNamesKey; + setIndex(0); + } + }, [layerNamesKey]); useEffect(() => { if (layers.length === 0) { @@ -1168,6 +1175,8 @@ function CustomNode({ id, data }) { })(); return ( + <> + {ctx?.executingNodeId === id &&