for add path, use for add polygons, use for add polygons lasso, use <4> for select vertices, use <5> for select shapes, use <2> for insert vertex, use <1> for remove vertex', status='Ready', tooltip=Tooltip(visible=False, text=''), theme='dark', title='Intensity', mouse_over_canvas=False, mouse_move_callbacks=[], mouse_drag_callbacks=[], mouse_double_click_callbacks=[], mouse_wheel_callbacks=[], _persisted_mouse_event={}, _mouse_drag_gen={}, _mouse_wheel_gen={}, _keymap={})"
+ ]
+ },
+ "execution_count": 3,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "from rsm3d.data_viz import IntensityNapariViewer\n",
+ "import numpy as np\n",
+ "_, _, df = loader.load()\n",
+ "frames = list(df.intensity)\n",
+ "print(frames[0].shape)\n",
+ "\n",
+ "viewer = IntensityNapariViewer(\n",
+ " frames, # list of 2D frames\n",
+ " name=\"Intensity\",\n",
+ " log_view=True,\n",
+ " contrast_percentiles=(1.0, 99.8),\n",
+ " cmap=\"inferno\",\n",
+ " rendering=\"attenuated_mip\",\n",
+ " add_timeseries=True,\n",
+ " add_volume=True,\n",
+ " scale_tzyx=(1.0, 1.0, 1.0),\n",
+ " pad_value=np.nan, # or 0.0 if you prefer black padding\n",
+ ")\n",
+ "viewer.launch() # this will open the napari viewer window\n",
+ "\n"
+ ]
+ },
{
"cell_type": "markdown",
"id": "6bd7edf4",
@@ -78,7 +126,7 @@
},
{
"cell_type": "code",
- "execution_count": 9,
+ "execution_count": 3,
"id": "83f2885f",
"metadata": {},
"outputs": [],
@@ -106,7 +154,7 @@
},
{
"cell_type": "code",
- "execution_count": 10,
+ "execution_count": 4,
"id": "92a26c9f",
"metadata": {},
"outputs": [],
@@ -157,16 +205,15 @@
},
{
"cell_type": "code",
- "execution_count": 9,
+ "execution_count": null,
"id": "42d405a3",
"metadata": {},
"outputs": [],
"source": [
- "from rsm3d.data_io import write_rsm_volume_to_vtr, write_rsm_volume_to_vtk\n",
+ "from rsm3d.data_io import write_rsm_volume_to_vtr\n",
"rsm = grid\n",
"edges = (xax, yax, zax)\n",
"filename = out_vtr\n",
- "write_rsm_volume_to_vtk(rsm, edges, filename.replace('.vtr', '.vtk'))\n",
"write_rsm_volume_to_vtr(rsm, edges, filename, binary=False, compress=True)\n"
]
}
diff --git a/rsm3d/data_viz.py b/rsm3d/data_viz.py
index d3c327d..9b9f3fb 100644
--- a/rsm3d/data_viz.py
+++ b/rsm3d/data_viz.py
@@ -1,6 +1,6 @@
from __future__ import annotations
import numpy as np
-from typing import Optional, Tuple, Iterable, Dict, Any
+from typing import Optional, Tuple, Iterable, Dict, Any, Sequence, Union
try:
import napari # type: ignore
@@ -526,4 +526,219 @@ def _toggle_coords(viewer):
def _require_viewer(self) -> None:
if self.viewer is None:
- raise RuntimeError("Call launch() before adding overlays or flipping axes.")
\ No newline at end of file
+ raise RuntimeError("Call launch() before adding overlays or flipping axes.")
+
+
+Array2D = np.ndarray # (Y, X)
+
+def _robust_percentiles(a: np.ndarray, lo=1.0, hi=99.8) -> Tuple[float, float]:
+ a = np.asarray(a)
+ m = np.isfinite(a)
+ if not m.any():
+ return 0.0, 1.0
+ v = np.percentile(a[m], [lo, hi])
+ if v[0] == v[1]:
+ v[1] = v[0] + 1e-6
+ return float(v[0]), float(v[1])
+
+def _stack_list_of_2d(
+ frames: Sequence[Array2D],
+ *,
+ pad_value: float = np.nan,
+ dtype: np.dtype = np.float32,
+) -> np.ndarray:
+ if len(frames) == 0:
+ raise ValueError("Empty intensity list.")
+
+ shapes = []
+ clean_frames: list[np.ndarray] = []
+ for i, f in enumerate(frames):
+ if f is None:
+ continue
+ a = np.asarray(f)
+ if a.ndim != 2:
+ raise ValueError(f"Frame {i} is not 2D (shape={a.shape})")
+ shapes.append(a.shape)
+ clean_frames.append(a)
+
+ if not clean_frames:
+ raise ValueError("All frames were None/invalid.")
+
+ max_y = max(s[0] for s in shapes)
+ max_x = max(s[1] for s in shapes)
+ if all(s == (max_y, max_x) for s in shapes):
+ # Allow copy when needed by dropping copy=False
+ return np.stack([np.asarray(f, dtype=dtype) for f in clean_frames], axis=0)
+ stacked = np.full((len(clean_frames), max_y, max_x), pad_value, dtype=dtype)
+ for t, a in enumerate(clean_frames):
+ y, x = a.shape
+ stacked[t, :y, :x] = a
+ return stacked
+
+def _maybe_series_to_list(obj):
+ # Avoid hard dep on pandas; detect lightly
+ if hasattr(obj, "to_numpy") and hasattr(obj, "values") and hasattr(obj, "iloc"):
+ try:
+ return list(obj.to_numpy())
+ except Exception:
+ try:
+ return list(obj.values)
+ except Exception:
+ return list(obj)
+ return obj
+
+def _to_tyx_any(intensity: Union[np.ndarray, Sequence[Array2D]]) -> np.ndarray:
+ """
+ Accept:
+ โข list/tuple of 2D arrays
+ โข pandas Series of 2D arrays
+ โข 1D object ndarray of 2D arrays
+ โข 3D ndarray (T,Y,X)
+ โข 2D ndarray (Y,X)
+ Returns (T, Y, X).
+ """
+ intensity = _maybe_series_to_list(intensity)
+
+ # List/tuple โ stack
+ if isinstance(intensity, (list, tuple)):
+ return _stack_list_of_2d(intensity)
+
+ a = np.asarray(intensity)
+ # 3D numeric array already
+ if a.ndim == 3 and a.dtype != object:
+ return a
+ # 2D numeric array
+ if a.ndim == 2 and a.dtype != object:
+ return a[None, ...]
+
+ # 1D object array โ elements should be 2D arrays
+ if a.ndim == 1 and a.dtype == object:
+ return _stack_list_of_2d(list(a))
+
+ raise ValueError(
+ f"Unsupported intensity shape {a.shape}; expected a list/Series/1D-object array "
+ f"of 2D frames, a 2D array, or a 3D (T,Y,X) array."
+ )
+
+class IntensityNapariViewer:
+ """
+ Napari viewer for raw intensity frames.
+
+ Parameters
+ ----------
+ intensity : list/Series/1D-object-ndarray of 2D frames OR 2D/3D ndarray
+ name : str
+ log_view : bool
+ contrast_percentiles : (float, float)
+ cmap : str
+ rendering : str ('attenuated_mip', 'mip', 'translucent', 'additive', 'minip')
+ add_timeseries : bool # (T,Y,X) with time slider
+ add_volume : bool # treat T as Z for 3D
+ scale_tzyx : (float, float, float) # spacing for (T/Z, Y, X)
+ pad_value : float # pad value for mismatched frames
+ """
+ def __init__(
+ self,
+ intensity: Union[np.ndarray, Sequence[Array2D]],
+ *,
+ name: str = "Intensity",
+ log_view: bool = True,
+ contrast_percentiles: Tuple[float, float] = (1.0, 99.8),
+ cmap: str = "inferno",
+ rendering: str = "attenuated_mip",
+ add_timeseries: bool = True,
+ add_volume: bool = True,
+ scale_tzyx: Tuple[float, float, float] = (1.0, 1.0, 1.0),
+ pad_value: float = np.nan,
+ ):
+ self._name = name
+ self._log = bool(log_view)
+ self._p_lo, self._p_hi = map(float, contrast_percentiles)
+ self._cmap = cmap
+ self._rendering = rendering
+ self._add_ts = bool(add_timeseries)
+ self._add_vol = bool(add_volume)
+ self._scale = tuple(map(float, scale_tzyx))
+ self._pad_value = float(pad_value)
+
+ # Coerce to (T,Y,X)
+ tyx = _to_tyx_any(intensity)
+ # If we got here through an object array path, ensure dtype float32
+ self._raw_tyx = tyx.astype(np.float32, copy=False)
+
+ self._viewer: Optional[napari.Viewer] = None
+ self._layer_ts = None
+ self._layer_vol = None
+
+ @classmethod
+ def from_loader(cls, loader, **kwargs) -> "IntensityNapariViewer":
+ setup, UB, df = loader.load()
+ intensity = getattr(df, "intensity", None)
+ if intensity is None:
+ raise ValueError("Loader returned df without 'intensity'")
+ return cls(intensity, **kwargs)
+
+ def launch(self) -> napari.Viewer:
+ """Show only the intensity frames as 2D slices with a draggable ROI."""
+ v = napari.Viewer(title=self._name)
+ self._viewer = v
+
+ # Prepare data (F, H, W)
+ data = self._prepare_data(self._raw_tyx)
+ lo, hi = _robust_percentiles(data, self._p_lo, self._p_hi)
+
+ # Single image layer renamed to Intensity(F,H,W)
+ self._layer_ts = v.add_image(
+ data,
+ name=f"{self._name} (F,H,W)",
+ contrast_limits=(lo, hi),
+ colormap=self._cmap,
+ blending="translucent",
+ scale=self._scale,
+ )
+ v.dims.ndisplay = 2
+
+ # Hide any accidental extra layers
+ for layer in list(v.layers):
+ if layer is not self._layer_ts:
+ layer.visible = False
+
+ # Add a centered, half-size rectangle ROI on the H-W plane
+ _, H, W = data.shape
+ half_h = H / 4.0
+ half_w = W / 4.0
+ y0 = H / 2.0 - half_h
+ x0 = W / 2.0 - half_w
+ y1 = H / 2.0 + half_h
+ x1 = W / 2.0 + half_w
+ rect = np.array([
+ [y0, x0],
+ [y0, x1],
+ [y1, x1],
+ [y1, x0],
+ ])
+ shapes = v.add_shapes(
+ [rect],
+ shape_type="rectangle",
+ edge_color="red",
+ face_color="transparent",
+ name="ROI",
+ )
+ shapes.editable = True
+ shapes.mode = "transform"
+
+ return v
+
+ def close(self):
+ if self._viewer is not None:
+ try:
+ self._viewer.close()
+ except Exception:
+ pass
+ self._viewer = None
+
+ def _prepare_data(self, tyx: np.ndarray) -> np.ndarray:
+ a = tyx.astype(np.float32, copy=False)
+ if self._log:
+ a = np.log1p(np.maximum(a, 0.0))
+ return a
\ No newline at end of file
diff --git a/rsm3d/spec_parser.py b/rsm3d/spec_parser.py
index 8bca51e..c663b73 100644
--- a/rsm3d/spec_parser.py
+++ b/rsm3d/spec_parser.py
@@ -727,17 +727,20 @@
class ExperimentSetup:
"""
- Load experiment parameters from a YAML file. Wavelength is optional:
+ Load experiment parameters from a YAML file. Wavelength is optional:
โข if provided and >1e-3 ร
, used directly
โข if provided in meters (<1e-3), converted to ร
- โข if omitted or nonโpositive, computed from energy [ร
] = 12.3984193 / E[keV]
- Required YAML keys: distance, pitch, ycenter, xcenter,
- xpixels, ypixels, phi, theta, dtheta, energy
- Optional key: wavelength
+ โข if omitted or nonโpositive, computed from energy [ร
] = 12.398419843320026 / E[keV]
+
+ Required keys (either top-level or inside `ExperimentSetup:`):
+ distance, pitch, ycenter, xcenter, xpixels, ypixels, energy
+
+ Optional key:
+ wavelength
"""
REQUIRED_KEYS = (
"distance", "pitch", "ycenter", "xcenter",
- "xpixels", "ypixels", "phi", "theta", "dtheta", "energy",
+ "xpixels", "ypixels", "energy",
)
def __init__(
@@ -748,29 +751,32 @@ def __init__(
xcenter: int,
xpixels: int,
ypixels: int,
- phi: float,
- theta: float,
- dtheta: float,
energy: float,
wavelength: float | None = None,
):
# detector geometry
- self.distance = distance
- self.pitch = pitch
- self.ycenter = ycenter
- self.xcenter = xcenter
- self.xpixels = xpixels
- self.ypixels = ypixels
- # scan angles
- self.phi = phi
- self.theta = theta
- self.dtheta = dtheta
- # beam energy
- self.energy = energy
- self.energy_keV = energy
-
- # wavelength handling
- # wavelength handling: allow None, numeric, or numericโstring; fallback to energy
+ self.distance = float(distance)
+ self.pitch = float(pitch)
+ self.ycenter = int(ycenter)
+ self.xcenter = int(xcenter)
+ self.xpixels = int(xpixels)
+ self.ypixels = int(ypixels)
+
+ # beam energy (keV)
+ self.energy = float(energy)
+ self.energy_keV = float(energy)
+
+ # basic validation
+ if self.distance <= 0:
+ raise ValueError("ExperimentSetup: 'distance' must be > 0")
+ if self.pitch <= 0:
+ raise ValueError("ExperimentSetup: 'pitch' must be > 0")
+ if self.xpixels <= 0 or self.ypixels <= 0:
+ raise ValueError("ExperimentSetup: 'xpixels' and 'ypixels' must be > 0")
+ if self.energy_keV <= 0:
+ raise ValueError("ExperimentSetup: 'energy' (keV) must be > 0")
+
+ # wavelength handling: allow None, numeric, or numeric-string; fallback to energy
lam_A: float | None = None
if wavelength is not None:
try:
@@ -784,34 +790,114 @@ def __init__(
# if missing or non-positive, compute from energy
if lam_A is None or lam_A <= 0.0:
- if self.energy_keV > 0.0:
- lam_A = self._energy_keV_to_lambda_A(self.energy_keV)
- else:
- raise ValueError("ExperimentSetup: energy must be > 0 to derive wavelength")
+ lam_A = self._energy_keV_to_lambda_A(self.energy_keV)
if lam_A <= 0.0:
raise ValueError("ExperimentSetup: computed wavelength is non-positive")
self.wavelength = lam_A
-
- def _energy_keV_to_lambda_A(self, E_keV: float) -> float:
+
+ @staticmethod
+ def _energy_keV_to_lambda_A(E_keV: float) -> float:
"""ฮป[ร
] = 12.398419843320026 / E[keV]."""
return 12.398419843320026 / float(E_keV)
+ # ---------- YAML helpers ----------
+ @staticmethod
+ def _to_float(v):
+ if v is None:
+ return None
+ try:
+ return float(v)
+ except (TypeError, ValueError):
+ # tolerate strings like "1_024" or with spaces
+ try:
+ return float(str(v).replace("_", "").strip())
+ except Exception:
+ raise ValueError(f"Expected float-compatible value, got {v!r}")
+
+ @staticmethod
+ def _to_int(v):
+ if v is None:
+ return None
+ try:
+ return int(v)
+ except (TypeError, ValueError):
+ try:
+ return int(float(str(v).replace("_", "").strip()))
+ except Exception:
+ raise ValueError(f"Expected int-compatible value, got {v!r}")
+
+ @classmethod
+ def _extract_section(cls, data: dict) -> dict:
+ """
+ Accept either flat YAML or nested mappings.
+ Priority:
+ 1) data["ExperimentSetup"]
+ 2) data["experiment"]
+ 3) data["experiment_setup"]
+ 4) flat top-level (data itself)
+ 5) any nested dict that seems to contain necessary keys
+ """
+ if not isinstance(data, dict):
+ raise ValueError("Top-level YAML must be a mapping of keys to values.")
+
+ # common section names
+ for key in ("ExperimentSetup", "experiment", "experiment_setup"):
+ sec = data.get(key)
+ if isinstance(sec, dict):
+ return sec
+
+ # flat?
+ if any(k in data for k in cls.REQUIRED_KEYS):
+ return data
+
+ # last resort: scan nested dicts
+ for v in data.values():
+ if isinstance(v, dict) and any(k in v for k in cls.REQUIRED_KEYS):
+ return v
+
+ # nothing suitable found
+ raise ValueError(
+ "Could not find experiment setup in YAML. "
+ "Expected an 'ExperimentSetup' section or flat keys."
+ )
+
@classmethod
def from_yaml(cls, path: str | Path):
p = Path(path)
if not p.is_file():
raise FileNotFoundError(f"Experiment YAML not found: {p}")
+
with p.open("r", encoding="utf-8") as f:
- data = yaml.safe_load(f)
- if not isinstance(data, dict):
- raise ValueError("Top-level YAML must be a mapping of keys to values.")
- missing = [k for k in cls.REQUIRED_KEYS if k not in data]
+ doc = yaml.safe_load(f) or {}
+
+ sec = cls._extract_section(doc)
+
+ # Build a merged view that allows some keys to be at top-level and some inside the section
+ merged = {}
+ for k in cls.REQUIRED_KEYS + ("wavelength",):
+ if k in sec:
+ merged[k] = sec[k]
+ elif k in doc:
+ merged[k] = doc[k]
+
+ # Validate presence
+ missing = [k for k in cls.REQUIRED_KEYS if merged.get(k) in (None, "", "None", "null")]
if missing:
- raise ValueError(f"Missing required keys in YAML: {missing!r}")
- # extract required params + optional wavelength
- params = {k: data[k] for k in cls.REQUIRED_KEYS}
- params["wavelength"] = data.get("wavelength", None)
+ raise ValueError(f"Missing required keys in YAML: {missing}")
+
+ # Coerce types
+ params = {
+ "distance": cls._to_float(merged["distance"]),
+ "pitch": cls._to_float(merged["pitch"]),
+ "ycenter": cls._to_int(merged["ycenter"]),
+ "xcenter": cls._to_int(merged["xcenter"]),
+ "xpixels": cls._to_int(merged["xpixels"]),
+ "ypixels": cls._to_int(merged["ypixels"]),
+ "energy": cls._to_float(merged["energy"]),
+ "wavelength": merged.get("wavelength", None),
+ }
+
return cls(**params)
def __repr__(self):
@@ -819,10 +905,99 @@ def __repr__(self):
f""
+ f"energy={self.energy} keV, wavelength={self.wavelength} ร
>"
)
+# class ExperimentSetup:
+# """
+# Load experiment parameters from a YAML file. Wavelength is optional:
+# โข if provided and >1e-3 ร
, used directly
+# โข if provided in meters (<1e-3), converted to ร
+# โข if omitted or nonโpositive, computed from energy [ร
] = 12.3984193 / E[keV]
+# Required YAML keys: distance, pitch, ycenter, xcenter,
+# xpixels, ypixels, phi, theta, dtheta, energy
+# Optional key: wavelength
+# """
+# REQUIRED_KEYS = (
+# "distance", "pitch", "ycenter", "xcenter",
+# "xpixels", "ypixels", "energy",
+# )
+
+# def __init__(
+# self,
+# distance: float,
+# pitch: float,
+# ycenter: int,
+# xcenter: int,
+# xpixels: int,
+# ypixels: int,
+# energy: float,
+# wavelength: float | None = None,
+# ):
+# # detector geometry
+# self.distance = distance
+# self.pitch = pitch
+# self.ycenter = ycenter
+# self.xcenter = xcenter
+# self.xpixels = xpixels
+# self.ypixels = ypixels
+# # beam energy
+# self.energy = energy
+# self.energy_keV = energy
+
+# # wavelength handling
+# # wavelength handling: allow None, numeric, or numericโstring; fallback to energy
+# lam_A: float | None = None
+# if wavelength is not None:
+# try:
+# lam_A = float(wavelength)
+# except (TypeError, ValueError):
+# lam_A = None
+
+# # if given in meters (small positive), convert to ร
+# if lam_A is not None and 0.0 < lam_A < 1e-3:
+# lam_A *= 1e10
+
+# # if missing or non-positive, compute from energy
+# if lam_A is None or lam_A <= 0.0:
+# if self.energy_keV > 0.0:
+# lam_A = self._energy_keV_to_lambda_A(self.energy_keV)
+# else:
+# raise ValueError("ExperimentSetup: energy must be > 0 to derive wavelength")
+
+# if lam_A <= 0.0:
+# raise ValueError("ExperimentSetup: computed wavelength is non-positive")
+# self.wavelength = lam_A
+
+# def _energy_keV_to_lambda_A(self, E_keV: float) -> float:
+# """ฮป[ร
] = 12.398419843320026 / E[keV]."""
+# return 12.398419843320026 / float(E_keV)
+
+# @classmethod
+# def from_yaml(cls, path: str | Path):
+# p = Path(path)
+# if not p.is_file():
+# raise FileNotFoundError(f"Experiment YAML not found: {p}")
+# with p.open("r", encoding="utf-8") as f:
+# data = yaml.safe_load(f)
+# if not isinstance(data, dict):
+# raise ValueError("Top-level YAML must be a mapping of keys to values.")
+# missing = [k for k in cls.REQUIRED_KEYS if k not in data]
+# if missing:
+# raise ValueError(f"Missing required keys in YAML: {missing!r}")
+# # extract required params + optional wavelength
+# params = {k: data[k] for k in cls.REQUIRED_KEYS}
+# params["wavelength"] = data.get("wavelength", None)
+# return cls(**params)
+
+# def __repr__(self):
+# return (
+# f""
+# )
+
# class ExperimentSetup:
# """Load experiment parameters strictly from a YAML file (no defaults)."""
# REQUIRED_KEYS = (