fix H5 scaling and 3D view, carousel reset

This commit is contained in:
2026-03-30 21:39:44 -07:00
parent c5c861717a
commit 8a70b0af05
7 changed files with 218 additions and 29 deletions

51
arhdf.log Normal file
View File

@@ -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)

View File

@@ -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: than as dataset attributes. This importer reads physical dimensions from:
Image/DataSetInfo/Global/Channels/<channel>/ImageDims Image/DataSetInfo/Global/Channels/<channel>/ImageDims
DimScaling (2,2) array: [[px_size_x, offset_x], [px_size_y, offset_y]] DimScaling (2,2) array: [[Y_start, Y_end], [X_start, X_end]]
DimExtents pixel counts [xres, yres] (stored in a child group) absolute physical coordinate ranges in DimUnits
DimUnits lateral unit strings 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 DataUnits Z unit string
If the sidecar group is absent (generic HDF5), standard dataset attributes 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: and the metadata lives at:
"Image/DataSetInfo/Global/Channels/<channel>/ImageDims" "Image/DataSetInfo/Global/Channels/<channel>/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, Returns a dict with xreal, yreal, xoff, yoff, si_unit_xy, si_unit_z,
or None if the group isn't found. 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): if not isinstance(grp, h5py.Group):
return None return None
scaling = grp.attrs.get("DimScaling") # shape (2, 2): [[px_x, off_x], [px_y, off_y]] 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'] 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' data_units = grp.attrs.get("DataUnits") # Z unit string, e.g. 'N'
if scaling is None or np.asarray(scaling).shape != (2, 2): if scaling is None or np.asarray(scaling).shape != (2, 2):
return None return None
scaling = np.asarray(scaling, dtype=np.float64) scaling = np.asarray(scaling, dtype=np.float64)
px_x, off_x = float(scaling[0, 0]), float(scaling[0, 1]) # Y axis first (row-major), then X — matching numpy's (rows, cols) convention.
px_y, off_y = float(scaling[1, 0]), float(scaling[1, 1]) 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. xreal = abs(x_end - x_start) or 1e-6
extents_grp = None yreal = abs(y_end - y_start) or 1e-6
for child_name in grp: xoff = min(x_start, x_end)
child = grp[child_name] yoff = min(y_start, y_end)
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])
def _decode(raw, default="m") -> str: def _decode(raw, default="m") -> str:
if raw is None: 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 raw.decode("utf-8", errors="replace").strip() or default
return str(raw).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 { return {
"xreal": abs(px_x * xres) or 1e-6, "xreal": xreal,
"yreal": abs(px_y * yres) or 1e-6, "yreal": yreal,
"xoff": off_x, "xoff": xoff,
"yoff": off_y, "yoff": yoff,
"si_unit_xy": _decode(dim_units[0] if dim_units is not None and len(dim_units) >= 1 else None), "si_unit_xy": xy_unit,
"si_unit_z": _decode(data_units), "si_unit_z": _decode(data_units),
} }

View File

@@ -3,7 +3,7 @@ import base64
import io import io
import numpy as np import numpy as np
from backend.node_registry import register_node 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 ( from backend.data_types import (
COLORMAPS, COLORMAPS,
DataField, DataField,
@@ -164,6 +164,14 @@ class View3D:
data = field.data data = field.data
yres, xres = data.shape 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_y = max(1, yres // resolution)
step_x = max(1, xres // resolution) step_x = max(1, xres // resolution)
z = data[::step_y, ::step_x].astype(np.float32) z = data[::step_y, ::step_x].astype(np.float32)

View File

@@ -852,6 +852,7 @@ function Flow() {
const [status, setStatus] = useState({ text: 'Connecting…', level: 'info' }); const [status, setStatus] = useState({ text: 'Connecting…', level: 'info' });
const [contextMenu, setContextMenu] = useState(null); const [contextMenu, setContextMenu] = useState(null);
const [isCanvasRightZooming, setIsCanvasRightZooming] = useState(false); const [isCanvasRightZooming, setIsCanvasRightZooming] = useState(false);
const [executingNodeId, setExecutingNodeId] = useState(null);
const flowContainerRef = useRef(null); const flowContainerRef = useRef(null);
const panTimerRef = useRef(null); const panTimerRef = useRef(null);
@@ -1285,15 +1286,19 @@ function Flow() {
...n, ...n,
data: { ...n.data, processingTimeMs: null }, data: { ...n.data, processingTimeMs: null },
}))); })));
setExecutingNodeId(null);
setStatus({ text: 'Running workflow…', level: 'info' }); setStatus({ text: 'Running workflow…', level: 'info' });
break; break;
case 'executing': case 'executing':
setExecutingNodeId(String(msg.data.node));
setStatus({ text: `Executing node ${msg.data.node}`, level: 'info' }); setStatus({ text: `Executing node ${msg.data.node}`, level: 'info' });
break; break;
case 'execution_complete': case 'execution_complete':
setExecutingNodeId(null);
setStatus({ text: 'Done.', level: 'info' }); setStatus({ text: 'Done.', level: 'info' });
break; break;
case 'execution_error': case 'execution_error':
setExecutingNodeId(null);
setStatus({ text: 'Error: ' + msg.data.message, level: 'error' }); setStatus({ text: 'Error: ' + msg.data.message, level: 'error' });
console.error('[tono] execution error', msg.data); console.error('[tono] execution error', msg.data);
break; break;
@@ -1924,7 +1929,8 @@ function Flow() {
onResizeGroup: resizeGroup, onResizeGroup: resizeGroup,
onRenameGroup: renameGroup, onRenameGroup: renameGroup,
onUngroup: ungroupGroup, onUngroup: ungroupGroup,
}), [onRuntimeValuesChange, onWidgetChange, openFileBrowser, onManualTrigger, renameGroup, resizeGroup, toggleGroupCollapse, ungroupGroup]); executingNodeId,
}), [onRuntimeValuesChange, onWidgetChange, openFileBrowser, onManualTrigger, renameGroup, resizeGroup, toggleGroupCollapse, ungroupGroup, executingNodeId]);
const clearGraph = useCallback(() => { const clearGraph = useCallback(() => {
setNodes([]); setNodes([]);

View File

@@ -413,9 +413,16 @@ function LayerGalleryPreview({ overlay }) {
const layers = Array.isArray(overlay?.layers) ? overlay.layers : []; const layers = Array.isArray(overlay?.layers) ? overlay.layers : [];
const [index, setIndex] = useState(0); 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(() => { useEffect(() => {
setIndex(0); if (layerNamesKey !== prevLayerNamesKeyRef.current) {
}, [overlay]); prevLayerNamesKeyRef.current = layerNamesKey;
setIndex(0);
}
}, [layerNamesKey]);
useEffect(() => { useEffect(() => {
if (layers.length === 0) { if (layers.length === 0) {
@@ -1168,6 +1175,8 @@ function CustomNode({ id, data }) {
})(); })();
return ( return (
<>
{ctx?.executingNodeId === id && <div className="node-executing-glow" aria-hidden="true" />}
<div className="custom-node"> <div className="custom-node">
{/* Title */} {/* Title */}
<div className="node-title drag-handle" style={{ background: catColor }}> <div className="node-title drag-handle" style={{ background: catColor }}>
@@ -1519,6 +1528,7 @@ function CustomNode({ id, data }) {
)} )}
</div> </div>
</div> </div>
</>
); );
} }

View File

@@ -230,6 +230,7 @@ html, body, #root {
/* ── Custom node ───────────────────────────────────────────────────── */ /* ── Custom node ───────────────────────────────────────────────────── */
.custom-node { .custom-node {
position: relative;
background: var(--bg-surface); background: var(--bg-surface);
border: 1px solid var(--border-default); border: 1px solid var(--border-default);
border-radius: 6px; border-radius: 6px;
@@ -1427,6 +1428,43 @@ html, body, #root {
height: 8px !important; height: 8px !important;
} }
/* ── Executing node glow ───────────────────────────────────────────── */
.node-executing-glow {
position: absolute;
inset: -3px;
border-radius: 9px;
overflow: hidden;
pointer-events: none;
z-index: 0;
}
.node-executing-glow::before {
content: '';
position: absolute;
/* large centered square so conic sweep stays circular on any node size */
width: 800px;
height: 800px;
top: 50%;
left: 50%;
margin-top: -400px;
margin-left: -400px;
background: conic-gradient(
from 0deg,
transparent 0deg,
transparent 310deg,
rgba(59, 130, 246, 0.12) 330deg,
rgba(99, 102, 241, 0.55) 348deg,
rgba(167, 139, 250, 1) 354deg,
rgba(255, 255, 255, 0.9) 357deg,
rgba(167, 139, 250, 0.7) 360deg
);
animation: node-glow-rotate 1.4s linear infinite;
transform-origin: center center;
}
@keyframes node-glow-rotate {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* ── Text Note node ────────────────────────────────────────────────── */ /* ── Text Note node ────────────────────────────────────────────────── */
.text-note-node { .text-note-node {
width: 100%; width: 100%;

69
scripts/inspect_h5.py Normal file
View File

@@ -0,0 +1,69 @@
"""
Inspect DimScaling and DimExtents metadata in an Asylum Research HDF5 file.
Usage:
python scripts/inspect_h5.py /path/to/your/file.h5
"""
import sys
import numpy as np
try:
import h5py
except ImportError:
print("pip install h5py")
sys.exit(1)
if len(sys.argv) < 2:
print("Usage: python scripts/inspect_h5.py <file.h5>")
sys.exit(1)
path = sys.argv[1]
with h5py.File(path, "r") as f:
channels_path = "Image/DataSetInfo/Global/Channels"
grp = f.get(channels_path)
if grp is None:
print("No Image/DataSetInfo/Global/Channels group found")
sys.exit(1)
for ch_name in grp:
dims_path = f"{channels_path}/{ch_name}/ImageDims"
dims_grp = f.get(dims_path)
if dims_grp is None:
print(f"{ch_name}: no ImageDims group")
continue
print(f"\n=== Channel: {ch_name} ===")
scaling = dims_grp.attrs.get("DimScaling")
if scaling is not None:
s = np.asarray(scaling, dtype=float)
print(f" DimScaling shape: {s.shape}")
print(f" DimScaling[0,:]: {s[0]} (row 0)")
print(f" DimScaling[1,:]: {s[1]} (row 1)")
print(f" --- If [start, end] interpretation ---")
print(f" xreal (row1 range) = {abs(s[1,1] - s[1,0])}")
print(f" yreal (row0 range) = {abs(s[0,1] - s[0,0])}")
print(f" --- If [step, offset] interpretation ---")
print(f" row0: step={s[0,0]} offset={s[0,1]}")
print(f" row1: step={s[1,0]} offset={s[1,1]}")
else:
print(" DimScaling: not found")
dim_units = dims_grp.attrs.get("DimUnits")
print(f" DimUnits: {dim_units}")
data_units = dims_grp.attrs.get("DataUnits")
print(f" DataUnits: {data_units}")
for child_name in dims_grp:
child = dims_grp[child_name]
if isinstance(child, h5py.Group) and "DimExtents" in child.attrs:
ext = np.asarray(child.attrs["DimExtents"])
print(f" DimExtents ('{child_name}'): {ext}")
print("\n=== 2D dataset shapes ===")
def _visit(name, obj):
if isinstance(obj, h5py.Dataset) and obj.ndim == 2:
print(f" {name} shape={obj.shape}")
f.visititems(_visit)