From 5b82b7fd4c607e40847b60a42ea842920376cf86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Fri, 5 Dec 2025 17:24:19 +0100 Subject: [PATCH 001/118] Design a system to identify (and sweep across) rectangular grid windows --- src/instamatic/_typing.py | 1 + src/instamatic/config/scripts/measure_grid.py | 225 ++++++++++++++++++ 2 files changed, 226 insertions(+) create mode 100644 src/instamatic/config/scripts/measure_grid.py diff --git a/src/instamatic/_typing.py b/src/instamatic/_typing.py index 59d0efad..55e38411 100644 --- a/src/instamatic/_typing.py +++ b/src/instamatic/_typing.py @@ -7,4 +7,5 @@ AnyPath = Union[str, os.PathLike] int_nm = Annotated[int, 'Length expressed in nanometers'] +float_nm = Annotated[float, 'Length expressed in nanometers'] float_deg = Annotated[float, 'Angle expressed in degrees'] diff --git a/src/instamatic/config/scripts/measure_grid.py b/src/instamatic/config/scripts/measure_grid.py new file mode 100644 index 00000000..ab8c1703 --- /dev/null +++ b/src/instamatic/config/scripts/measure_grid.py @@ -0,0 +1,225 @@ +from __future__ import annotations + +from dataclasses import dataclass +from itertools import chain +from typing import TYPE_CHECKING, Iterator, Optional, Sequence + +import numpy as np +from scipy.optimize import minimize +from typing_extensions import Literal, Self + +from instamatic._typing import float_nm, int_nm +from instamatic.utils.iterating import pairwise + +if TYPE_CHECKING: + from instamatic.controller import TEMController + + +global _ctrl +_ctrl: TEMController + + +X = np.array([1, 0], dtype=float) +Y = np.array([0, 1], dtype=float) +Array = np.ndarray +Vector2 = Sequence[float] + + +class Sweeper: + """A simple descriptor of stage movement with fixed heading.""" + + step: int_nm = 1000 + + def __init__(self, origin: Vector2, heading: Vector2) -> None: + self.origin = np.array([origin[0], origin[1]], dtype=float) + self.heading = np.array([heading[0], heading[1]], dtype=float) + self.position = np.array([origin[0], origin[1]], dtype=float) + + +class EdgeSweeper(Sweeper): + """Used to determine the edge of the stage based on camera feedback.""" + + step: int_nm = 10_000 # largest step size allowed + precision: int_nm = 1 # smallest step size allowed + threshold: float = 0.01 # fraction of light_max that signals the edge + light_max: int = -1 # maximum light observed at any point by any sweeper + + def peak(self) -> int: + """Return light (image sum) at current position, update light max.""" + light = int(_ctrl.get_image().sum()) + EdgeSweeper.light_max = max(light, self.light_max) + return light + + def walk(self, dx: float, dy: float) -> None: + """Change sweeper position by a (dx, dy) vector.""" + x, y = _ctrl.stage.xy + x = int(x + dx) + y = int(y + dy) + _ctrl.stage.set(x=x, y=y) + self.position = np.array([x, y], dtype=float) + + +class CrudeEdgeSweeper(EdgeSweeper): + """Moves monotonously, stops when intensity fract < max * threshold.""" + + def sweep(self) -> None: + """Walk steps into heading until peaked light is below threshold.""" + _ctrl.stage.set(int(self.origin[0]), int(self.origin[1])) + light_here: int = self.peak() + while light_here > self.light_max * self.threshold: + dx: float = self.heading[0].item() * self.step + dy: float = self.heading[1].item() * self.step + self.walk(dx=dx, dy=dy) + light_here: int = self.peak() + + +class BinaryEdgeSweeper(EdgeSweeper): + """A stage-state descriptor used to binary-search the grid edge.""" + + def breed(self, other: Self) -> Self: + """Return a new instance with mean heading and position.""" + o = (self.position + other.position) / 2 + h = (s := self.heading + other.heading) / float(np.linalg.norm(s)) + return self.__class__(origin=o, heading=h) + + def sweep(self) -> None: + """Bin-search the edge based on peaked light vs max * threshold.""" + _ctrl.stage.set(int(self.origin[0]), int(self.origin[1])) + _step = self.step + _mult = 1.0 + while not self.precision > _step > -self.precision: + dx: float = self.heading[0].item() * _step + dy: float = self.heading[1].item() * _step + self.walk(dx=dx, dy=dy) + light_here = self.peak() + if light_here > self.threshold * self.light_max: + _step = _mult * abs(_step) + else: + _mult = 0.5 + _step = -_mult * abs(_step) + + +class RectangularGridWindow: + """Describes one rectangular window without assumptions about the grid. + + Geometry is described using five immutable float parameters (nm / radian): + + - center_x: coordinate of the window center on the X axis; + - center_y: coordinate of the window center on the Y axis; + - width: length of window side aligned with the direction of X axis; + - height: length of window side aligned with the direction or Y axis; + - theta: signed angle from X towards X-aligned edge (positive towards Y); + """ + + def __init__(self, x: float, y: float, w: float, h: float, t: float): + t = (t + (np.pi / 2)) % np.pi - (np.pi / 2) # cast to [-pi/2, pi/2] + if not -np.pi / 4 < t < np.pi / 4: # cast to [-pi/4, pi/4] + w, h, t = h, w, (np.pi - t) % np.pi - np.pi / 2 + + self.center_x: float_nm = x + self.center_y: float_nm = y + self.width = w = abs(w) + self.height = h = abs(h) + self.theta: float = t # expressed in radian + + self.center = c = np.array([x, y], dtype=float) + self.w_axis = wa = 0.5 * w * np.array([np.cos(t), np.sin(t)], dtype=float) + self.h_axis = ha = 0.5 * h * np.array([-np.sin(t), np.cos(t)], dtype=float) + self.corners = np.vstack([c + wa + ha, c + wa - ha, c - wa - ha, c - wa + ha]) + + @staticmethod + def edge_dist2_sum(geom: tuple[float, float, float, float, float], xys: Array) -> float: + """scipy.optimize.minimize fitting func; for geometry see cls docs.""" + center_x, center_y, width, height, theta = geom + center = np.array([center_x, center_y], dtype=float) + w_axis = 0.5 * width * np.array([np.cos(theta), np.sin(theta)]) + h_axis = 0.5 * height * np.array([-np.sin(theta), np.cos(theta)]) + w_axis_n = w_axis / np.linalg.norm(w_axis) + h_axis_n = h_axis / np.linalg.norm(h_axis) + d1 = np.abs(np.dot(xys - (center + w_axis), w_axis_n)) + d2 = np.abs(np.dot(xys - (center - w_axis), w_axis_n)) + d3 = np.abs(np.dot(xys - (center + h_axis), h_axis_n)) + d4 = np.abs(np.dot(xys - (center - h_axis), h_axis_n)) + return np.sum(np.min([d1, d2, d3, d4], axis=0) ** 2) + + @classmethod + def from_star_search(cls, order: Literal[1, 2, 3, 4, 5] = 3) -> Self: + """Return new using `EdgeSweeper`s scanning around current position.""" + origin = np.array(*_ctrl.stage.xy, dtype=float) + css: dict[str, CrudeEdgeSweeper] = { + '+X': CrudeEdgeSweeper(origin=origin, heading=+X), + '-X': CrudeEdgeSweeper(origin=origin, heading=-X), + '+Y': CrudeEdgeSweeper(origin=origin, heading=+Y), + '-Y': CrudeEdgeSweeper(origin=origin, heading=-Y), + } + for cs in css.values(): + cs.sweep() + center_x = (css['+X'].position[0] - css['-X'].position[0]) / 2 + center_y = (css['+Y'].position[0] - css['-Y'].position[0]) / 2 + center = np.array([center_x, center_y], dtype=float) + + bss = [BinaryEdgeSweeper(origin=center, heading=d) for d in (X, Y, -X, -Y)] + for bs in bss: + bs.sweep() + + def bisectors(sweepers: list[BinaryEdgeSweeper]) -> list[BinaryEdgeSweeper]: + new = [a.breed(b) for a, b in pairwise([*sweepers, sweepers[0]])] + for ns in new: + ns.sweep() + return new + + for _ in range(1, order): + bss = list(chain.from_iterable(zip(bss, bisectors(bss)))) + + edge_xy = np.vstack([bs.position for bs in bss]) # Nx2 + return cls.from_edge_xys(edge_xy) + + @classmethod + def from_edge_xys(cls, edge_xys: Array) -> Self: + """Return new by fitting the edge to a Nx2 list of edge positions.""" + xys_com = np.mean(edge_xys, axis=0) + xys_deltas = edge_xys - xys_com + xys_cov = np.cov(xys_deltas.T) + eigenvalues, eigenvectors = np.linalg.eigh(xys_cov) + eigenvector_proj = xys_deltas @ eigenvectors + width0 = eigenvector_proj[:, 1].max() - eigenvector_proj[:, 1].min() + height0 = eigenvector_proj[:, 0].max() - eigenvector_proj[:, 0].min() + theta0 = np.arctan2(eigenvectors[1, 1], eigenvectors[0, 1]) + guess = np.array([xys_com[0], xys_com[1], width0, height0, theta0]) + res = minimize(cls.edge_dist2_sum, guess, args=(edge_xys,), method='Powell') + return cls(*res.x) + + def x_intersections(self, y: int_nm) -> Optional[tuple[float, float]]: + """Return (x_min, x_max) for a horizontal line intersecting at y.""" + intersection_xs: list[float] = [] + + for x1, y1, x2, y2 in pairwise([*self.corners, self.corners[0]]): + if y1 == y2: # work with edge case , close to zero + continue + intersection_fraction = (y - y1) / (y2 - y1) + if not 0 < intersection_fraction < 1: + continue # does not intersect + intersection_xs.append(x1 + (x2 - x1) * intersection_fraction) + + if len(intersection_xs) < 2: + return None + return min(intersection_xs), max(intersection_xs) + + def plan_x_sweeping(self, step: int_nm = 1000) -> Iterator[XSweep]: + """Yield `XSweep`s every `step` nm across the whole grid window.""" + ys = [c[1] for c in self.corners] + y_min, y_max = int(min(ys)), int(max(ys)) + + for i, y in enumerate(range(y_min, y_max + 1, step)): + if x_intersections := self.x_intersections(y): + x_start = int(x_intersections[i % 2]) + x_end = int(x_intersections[(i + 1) % 2]) + yield XSweep(i=i, x=x_start, y=y, d=x_end - x_start) + + +@dataclass +class XSweep: + i: int # sweep index or identifier + x: int_nm # starting x position + y: int_nm # starting y position + d: int_nm # total x span to cover From eb3f98642e1cb5049244471812827d0139b8123a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Tue, 16 Dec 2025 16:01:29 +0100 Subject: [PATCH 002/118] Bugfixes with chatgpt --- .../config/scripts/benchmark_movie_rates.py | 434 ++++++++++++++++++ src/instamatic/config/scripts/measure_grid.py | 77 +++- 2 files changed, 498 insertions(+), 13 deletions(-) create mode 100644 src/instamatic/config/scripts/benchmark_movie_rates.py diff --git a/src/instamatic/config/scripts/benchmark_movie_rates.py b/src/instamatic/config/scripts/benchmark_movie_rates.py new file mode 100644 index 00000000..1d42950f --- /dev/null +++ b/src/instamatic/config/scripts/benchmark_movie_rates.py @@ -0,0 +1,434 @@ +#!/usr/bin/env python3 +"""benchmark_movie_rates.py. + +Benchmark achievable frame rates using TEMController.get_movie. + +Key behaviour: +- Uses `with ctrl.cam.blocked():` to avoid interference during timed acquisition. +- Optional warm-up cycle (single tiny-frame) before timed measurement. +- For each exposure requested, runs N rounds. Each round collects frames + until at least `--min-duration` seconds elapse (or until a safe max frames limit). +- Does not save image data; only timestamps and basic header-derived times are recorded. +- Optional light processing per frame (`--process`) to emulate CPU work (np.mean). +- Prints summary after measurements and optionally writes raw CSV. + +Use: + python benchmark_movie_rates.py --exposures 0.01,0.02 --variable-headers BeamShift --rounds 3 +""" + +from __future__ import annotations + +import argparse +import csv +import dataclasses +import math +import time +from pathlib import Path +from statistics import mean, stdev +from typing import Generator, Optional + +import matplotlib.pyplot as plt + +from instamatic.controller import TEMController, _ctrl, initialize +from instamatic.utils.iterating import pairwise + +if not _ctrl: + _ctrl: TEMController = initialize() + + +@dataclasses.dataclass +class FrameStamp: + """Timestamps and minimal metadata for a single yielded frame.""" + + frame_index: int + t0: float # perf_counter right before calling next(gen) + t1: float # perf_counter right after next(gen) returned + h0: Optional[float] = None # header-reported 'ImageGetTimeStart' if present + h1: Optional[float] = None # header-reported 'ImageGetTimeEnd' if present + + +@dataclasses.dataclass +class RoundResult: + """Results for a single timed round at one exposure and header + configuration.""" + + index: int + exposure: float + header_keys: tuple + header_keys_common: tuple + frames: list[FrameStamp] + t_start: float + t_end: float + + @property + def n_frames(self) -> int: + return len(self.frames) + + @property + def duration(self) -> float: + return self.t_end - self.t_start + + @property + def fps(self) -> float: + return self.n_frames / self.duration if self.duration > 0 else float('nan') + + def inter_frame_intervals(self) -> list[float]: + """Return list of intervals between successive t1 frame yield times.""" + return [g.t1 - f.t1 for f, g in pairwise(self.frames)] + + def mean_inter_frame_interval(self) -> Optional[float]: + ints = self.inter_frame_intervals() + return mean(ints) if ints else None + + def mean_header_duration(self) -> Optional[float]: + durations = [f.h1 - f.h0 for f in self.frames if f.h0 and f.h1] + return mean(durations) if durations else None + + +class MovieBench: + """Benchmark runner using a TEMController instance.""" + + def __init__( + self, + n_rounds: int = 3, + n_frames: int = 1000, + ) -> None: + self.rounds = int(n_rounds) + self.n_frames = int(n_frames) + + if _ctrl is None: + raise RuntimeError('No TEMController instance available.') + + if not getattr(_ctrl, 'cam', None): + raise RuntimeError("Controller has no 'cam' attribute.") + if not hasattr(_ctrl.cam, 'blocked'): + raise RuntimeError("Camera does not provide 'blocked()' context manager.") + + def _warm_up(self, exposure: float, header_keys: tuple, header_keys_common: tuple) -> None: + """Optional tiny dummy call warming camera / generator start-up.""" + + try: + hk, hkc = header_keys, header_keys_common + gen = _ctrl.get_movie(1, exposure, header_keys=hk, header_keys_common=hkc) + next(gen) + gen.close() + except (StopIteration, RuntimeError): + pass + + def run_round( + self, + exposure: float, + header_keys: tuple, + header_keys_common: tuple, + ) -> RoundResult: + frames: list[FrameStamp] = [] + hk, hkc = header_keys, header_keys_common + gen = _ctrl.get_movie(self.n_frames, exposure, header_keys=hk, header_keys_common=hkc) + t_start = time.perf_counter() + try: + for i in range(self.n_frames): + t0 = time.perf_counter() + try: + img, header = next(gen) + except StopIteration: + break + t1 = time.perf_counter() + h0 = header.get('ImageGetTimeStart') + h1 = header.get('ImageGetTimeEnd') + frames.append(FrameStamp(frame_index=i + 1, t0=t0, t1=t1, h0=h0, h1=h1)) + # _ = float(np.mean(img)) # if benchmarking later? + finally: + try: + gen.close() + except Exception: + pass + t_end = time.perf_counter() + return RoundResult( + exposure=exposure, + header_keys=header_keys, + header_keys_common=header_keys_common, + index=0, + frames=frames, + t_start=t_start, + t_end=t_end, + ) + + def run( + self, + exposures: list[float], + header_keys: tuple = (), + header_keys_common: tuple = (), + warmup: bool = True, + ) -> Generator[RoundResult]: + """Run benchmark across provided exposure times and header + configurations.""" + hk, hkc = header_keys, header_keys_common + for e in exposures: + for r in range(self.rounds): + if warmup and r == 0: + self._warm_up(exposure=e, header_keys=hk, header_keys_common=hkc) + with _ctrl.cam.blocked(): + res = self.run_round(exposure=e, header_keys=hk, header_keys_common=hkc) + res.index = r + 1 + yield res + + +# ------------------------- +# Reporting utilities +# ------------------------- +def summarize_result(result: RoundResult) -> dict: + """Return a dict with human-friendly summarized numbers for a run.""" + inter_intervals = result.inter_frame_intervals() + mean_interval = mean(inter_intervals) if inter_intervals else None + std_interval = stdev(inter_intervals) if len(inter_intervals) >= 2 else None + header_mean = result.mean_header_duration() + header_time_ratio = None + if header_mean is not None and mean_interval is not None: + header_time_ratio = header_mean / mean_interval if mean_interval > 0 else None + + dead_time_est = ( + (mean_interval - result.exposure) if (mean_interval and result.exposure) else None + ) + + return { + 'exposure_seconds': result.exposure, + 'round_index': result.index, + 'n_frames': result.n_frames, + 'duration_s': result.duration, + 'fps_measured': result.fps, + 'init_time_est_s': (result.frames[0].t1 - result.t_start) if result.frames else None, + 'mean_interframe_s': mean_interval, + 'std_interframe_s': std_interval, + 'min_interframe_s': min(inter_intervals) if inter_intervals else None, + 'max_interframe_s': max(inter_intervals) if inter_intervals else None, + 'dead_time_est_s': dead_time_est, + 'header_mean_s': header_mean, + 'header_time_ratio': header_time_ratio, + } + + +def print_run_summary(summ: dict) -> None: + print( + f'Exposure {summ["exposure_seconds"]:.6f}s | Round {summ["round_index"]} | frames={summ["n_frames"]} | duration={summ["duration_s"]:.4f}s' + ) + print(f' fps={summ["fps_measured"]:.3f} | init_time≈{summ["init_time_est_s"]:.6f}s') + print( + f' interframe mean={summ["mean_interframe_s"]:.6f}s ±{summ["std_interframe_s"] or 0:.6f}s | min={summ["min_interframe_s"]:.6f}s max={summ["max_interframe_s"]:.6f}s' + ) + if summ['dead_time_est_s'] is not None: + print(f' dead_time_est = mean_interframe - exposure = {summ["dead_time_est_s"]:.6f}s') + if summ['header_mean_s'] is not None: + print( + f' header_mean = {summ["header_mean_s"]:.6f}s | header_time_ratio = {summ["header_time_ratio"]:.3f}' + ) + + +def print_aggregated_table(results: list[RoundResult]) -> None: + """Print a CSV-like aggregated summary grouped by exposure.""" + grouped: dict[float, list[RoundResult]] = {} + for r in results: + grouped.setdefault(r.exposure, []).append(r) + + header = [ + 'exposure_s', + 'rounds', + 'frames_avg', + 'fps_mean', + 'fps_std', + 'dead_time_mean_s', + 'header_mean_s', + 'header_time_ratio', + ] + print('\nAggregated summary (CSV):') + print(','.join(header)) + + for exposure in sorted(grouped.keys()): + runs = grouped[exposure] + fps_vals = [rr.fps for rr in runs if not math.isnan(rr.fps)] + frames_avg = mean([rr.n_frames for rr in runs]) if runs else 0 + fps_mean = mean(fps_vals) if fps_vals else float('nan') + fps_std = stdev(fps_vals) if len(fps_vals) >= 2 else 0.0 + + dead_times = [] + header_means = [] + for rr in runs: + mi = rr.mean_inter_frame_interval() + if mi is not None: + dead_times.append(mi - rr.exposure) + hm = rr.mean_header_duration() + if hm is not None: + header_means.append(hm) + + dead_mean = mean(dead_times) if dead_times else float('nan') + header_mean = mean(header_means) if header_means else float('nan') + header_ratio = ( + (header_mean / (dead_mean + exposure)) + if (not math.isnan(header_mean) and not math.isnan(dead_mean)) + else float('nan') + ) + + row = [ + exposure, + len(runs), + frames_avg, + fps_mean, + fps_std, + dead_mean, + header_mean, + header_ratio, + ] + print(','.join([f'{v:.6g}' if isinstance(v, float) else str(v) for v in row])) + + +def save_raw_csv(results: list[RoundResult]) -> None: + filename = Path.cwd() / f'benchmark_movie_raw_{int(time.time())}.csv' + with filename.open('w', newline='') as fh: + writer = csv.writer(fh) + writer.writerow(['exposure_s', 'round', 'frame_index', 't0', 't1', 'h0', 'h1']) + for r in results: + for f in r.frames: + writer.writerow([r.exposure, r.index, f.frame_index, f.t0, f.t1, f.h0, f.h1]) + print(f'Raw timestamps saved to: {filename}') + + +# ------------------------- +# CLI helpers +# ------------------------- +def parse_exposures(arg: Optional[str]) -> list[float]: + """Parse comma-separated exposures or default to 1/10..1/100 s.""" + if not arg: + defaults_fps = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100] + return [1.0 / f for f in defaults_fps] + parts = [p.strip() for p in arg.split(',') if p.strip()] + exposures: list[float] = [] + for p in parts: + try: + exposures.append(float(p)) + except Exception: + raise argparse.ArgumentTypeError(f'Invalid exposure value: {p}') + return exposures + + +def parse_header_keys(arg: Optional[str]) -> tuple: + if (arg is None) or (arg == ''): + return () + return tuple(x.strip() for x in arg.split(',') if x.strip()) + + +def generate_plots(results: list[RoundResult]) -> None: + """Generate basic plots: exposure vs fps, exposure vs dead-time.""" + + # Aggregate by exposure + grouped: dict[float, list[RoundResult]] = {} + for r in results: + grouped.setdefault(r.exposure, []).append(r) + + exposures = sorted(grouped.keys()) + fps_means = [] + dead_time_means = [] + + for exp in exposures: + runs = grouped[exp] + # fps mean + fps_vals = [rr.fps for rr in runs if not math.isnan(rr.fps)] + fps_means.append(mean(fps_vals) if fps_vals else float('nan')) + dead_times = [] + for rr in runs: + mi = rr.mean_inter_frame_interval() + if mi is not None: + dead_times.append(mi - rr.exposure) + dead_time_means.append(mean(dead_times) if dead_times else float('nan')) + + # ---- PLOT 1: Exposure vs FPS ---- + plt.figure() + plt.plot(exposures, fps_means, marker='o') + plt.xlabel('Exposure (s)') + plt.ylabel('FPS (measured)') + plt.title('Exposure vs Measured FPS') + plt.grid(True) + plt.show() + + # ---- PLOT 2: Exposure vs Dead Time ---- + plt.figure() + plt.plot(exposures, dead_time_means, marker='o') + plt.xlabel('Exposure (s)') + plt.ylabel('Dead time (s)') + plt.title('Exposure vs Dead Time') + plt.grid(True) + plt.show() + + +def main(argv: Optional[list[str]] = None) -> None: + parser = argparse.ArgumentParser( + description='Benchmark TEMController.get_movie to measure achievable frame rates.' + ) + parser.add_argument( + '--exposures', + type=str, + default=None, + help="Comma-separated exposures in seconds, e.g. '0.01,0.02'. Default: 1/10..1/100.", + ) + parser.add_argument( + '--variable-headers', + type=str, + default=None, + help='Comma-separated variable header keys to collect per frame. Use empty string to disable.', + ) + parser.add_argument( + '--common-headers', + type=str, + default=None, + help='Comma-separated common header keys to collect once before movie. Use empty string to disable.', + ) + parser.add_argument( + '--n_rounds', type=int, default=3, help='Rounds per exposure. Default: 3' + ) + parser.add_argument( + '--n_frames', type=int, default=10, help='Number of frames to be collected. Default: 10' + ) + parser.add_argument('--no-warmup', action='store_true', help='Disable warmup dummy frame.') + parser.add_argument( + '--plot', + action='store_true', + help='Generate simple matplotlib plots after the benchmark.', + ) + args = parser.parse_args(argv) + + exposures = parse_exposures(args.exposures) + variable_headers = parse_header_keys(args.variable_headers) + common_headers = parse_header_keys(args.common_headers) + + bench = MovieBench( + n_rounds=args.n_rounds, + n_frames=args.n_frames, + ) + + print('=== benchmark_movie_rates: starting benchmark ===') + print(f'Exposures (s): {exposures}') + print(f'Variable header keys: {variable_headers or "(none)"}') + print(f'Common header keys: {common_headers or "(none)"}') + print(f'Rounds per exposure: {args.n_rounds}') + + results: list[RoundResult] = [] + results_generator = bench.run( + exposures=exposures, + header_keys=variable_headers, + header_keys_common=common_headers, + warmup=(not args.no_warmup), + ) + + # Print per-run summaries + for res in results_generator: + summ = summarize_result(res) + print() + print_run_summary(summ) + results.append(res) + print_aggregated_table(results) + save_raw_csv(results) + generate_plots(results) + + print('\n=== benchmark_movie_rates: finished ===') + + +if __name__ == '__main__': + main() diff --git a/src/instamatic/config/scripts/measure_grid.py b/src/instamatic/config/scripts/measure_grid.py index ab8c1703..ce982ddb 100644 --- a/src/instamatic/config/scripts/measure_grid.py +++ b/src/instamatic/config/scripts/measure_grid.py @@ -4,19 +4,18 @@ from itertools import chain from typing import TYPE_CHECKING, Iterator, Optional, Sequence +import matplotlib.pyplot as plt import numpy as np +from matplotlib.patches import Polygon from scipy.optimize import minimize from typing_extensions import Literal, Self from instamatic._typing import float_nm, int_nm +from instamatic.controller import TEMController, _ctrl, initialize from instamatic.utils.iterating import pairwise -if TYPE_CHECKING: - from instamatic.controller import TEMController - - -global _ctrl -_ctrl: TEMController +if not _ctrl: + _ctrl: TEMController = initialize() X = np.array([1, 0], dtype=float) @@ -47,7 +46,7 @@ class EdgeSweeper(Sweeper): def peak(self) -> int: """Return light (image sum) at current position, update light max.""" light = int(_ctrl.get_image().sum()) - EdgeSweeper.light_max = max(light, self.light_max) + EdgeSweeper.light_max = max(light, EdgeSweeper.light_max) return light def walk(self, dx: float, dy: float) -> None: @@ -79,8 +78,10 @@ class BinaryEdgeSweeper(EdgeSweeper): def breed(self, other: Self) -> Self: """Return a new instance with mean heading and position.""" o = (self.position + other.position) / 2 - h = (s := self.heading + other.heading) / float(np.linalg.norm(s)) - return self.__class__(origin=o, heading=h) + n = np.linalg.norm(s := self.heading + other.heading) + if n == 0: + raise ValueError('Degenerate bisector') + return self.__class__(origin=o, heading=s / n) def sweep(self) -> None: """Bin-search the edge based on peaked light vs max * threshold.""" @@ -145,7 +146,7 @@ def edge_dist2_sum(geom: tuple[float, float, float, float, float], xys: Array) - @classmethod def from_star_search(cls, order: Literal[1, 2, 3, 4, 5] = 3) -> Self: """Return new using `EdgeSweeper`s scanning around current position.""" - origin = np.array(*_ctrl.stage.xy, dtype=float) + origin = np.array(_ctrl.stage.xy, dtype=float) css: dict[str, CrudeEdgeSweeper] = { '+X': CrudeEdgeSweeper(origin=origin, heading=+X), '-X': CrudeEdgeSweeper(origin=origin, heading=-X), @@ -154,8 +155,8 @@ def from_star_search(cls, order: Literal[1, 2, 3, 4, 5] = 3) -> Self: } for cs in css.values(): cs.sweep() - center_x = (css['+X'].position[0] - css['-X'].position[0]) / 2 - center_y = (css['+Y'].position[0] - css['-Y'].position[0]) / 2 + center_x = (css['+X'].position[0] + css['-X'].position[0]) / 2 + center_y = (css['+Y'].position[1] + css['-Y'].position[1]) / 2 center = np.array([center_x, center_y], dtype=float) bss = [BinaryEdgeSweeper(origin=center, heading=d) for d in (X, Y, -X, -Y)] @@ -187,7 +188,9 @@ def from_edge_xys(cls, edge_xys: Array) -> Self: theta0 = np.arctan2(eigenvectors[1, 1], eigenvectors[0, 1]) guess = np.array([xys_com[0], xys_com[1], width0, height0, theta0]) res = minimize(cls.edge_dist2_sum, guess, args=(edge_xys,), method='Powell') - return cls(*res.x) + new = cls(*res.x) + new._edge_xys = edge_xys + return new def x_intersections(self, y: int_nm) -> Optional[tuple[float, float]]: """Return (x_min, x_max) for a horizontal line intersecting at y.""" @@ -216,6 +219,49 @@ def plan_x_sweeping(self, step: int_nm = 1000) -> Iterator[XSweep]: x_end = int(x_intersections[(i + 1) % 2]) yield XSweep(i=i, x=x_start, y=y, d=x_end - x_start) + def plot(self, ax=None, pad: float = 0.1) -> None: + """Plot a simple visual representation of the window geometry.""" + if ax is None: + _, ax = plt.subplots() + + corners = self.corners + cx, cy = self.center + xmin, ymin = corners.min(axis=0) + xmax, ymax = corners.max(axis=0) + dx, dy = xmax - xmin, ymax - ymin + + ax.set_facecolor('0.85') + ax.add_patch( + Polygon( + corners, + closed=True, + facecolor='white', + edgecolor='black', + linewidth=1.5, + zorder=1, + ) + ) + + ax.plot(corners[:, 0], corners[:, 1], 'ro', zorder=2) + ax.plot(cx, cy, 'r+', markersize=10, markeredgewidth=2, zorder=3) + + wx, wy = self.w_axis + hx, hy = self.h_axis + ax.arrow(cx, cy, wx, wy, color='C0', width=0, head_width=0, zorder=4) + ax.arrow(cx, cy, -wx, -wy, color='C0', width=0, head_width=0, zorder=4) + ax.arrow(cx, cy, hx, hy, color='C1', width=0, head_width=0, zorder=4) + ax.arrow(cx, cy, -hx, -hy, color='C1', width=0, head_width=0, zorder=4) + + if hasattr(self, '_edge_xys'): + xys = self._edge_xys + ax.plot(xys[:, 0], xys[:, 1], 'bx', markersize=6, zorder=5) + + ax.set_aspect('equal', adjustable='box') + ax.set_xlim(xmin - pad * dx, xmax + pad * dx) + ax.set_ylim(ymin - pad * dy, ymax + pad * dy) + ax.set_xlabel('x / nm') + ax.set_ylabel('y / nm') + @dataclass class XSweep: @@ -223,3 +269,8 @@ class XSweep: x: int_nm # starting x position y: int_nm # starting y position d: int_nm # total x span to cover + + +if __name__ == '__main__': + rgw = RectangularGridWindow.from_star_search() + rgw.plot() From 20997ab4b8a3515dafb2cfc476ac8d96f1915839 Mon Sep 17 00:00:00 2001 From: Daniel Tchon Date: Tue, 16 Dec 2025 18:33:42 +0100 Subject: [PATCH 003/118] Don't crash if you can't save raw csv files --- src/instamatic/config/scripts/benchmark_movie_rates.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/instamatic/config/scripts/benchmark_movie_rates.py b/src/instamatic/config/scripts/benchmark_movie_rates.py index 1d42950f..a3043951 100644 --- a/src/instamatic/config/scripts/benchmark_movie_rates.py +++ b/src/instamatic/config/scripts/benchmark_movie_rates.py @@ -424,7 +424,10 @@ def main(argv: Optional[list[str]] = None) -> None: print_run_summary(summ) results.append(res) print_aggregated_table(results) - save_raw_csv(results) + try: + save_raw_csv(results) + except PermissionError: + pass generate_plots(results) print('\n=== benchmark_movie_rates: finished ===') From 37a87634a10ac5383ac1cc8f9fa67a47a86d7b46 Mon Sep 17 00:00:00 2001 From: Daniel Tchon Date: Tue, 16 Dec 2025 20:24:51 +0100 Subject: [PATCH 004/118] Convenience features used when debugging --- src/instamatic/config/scripts/measure_grid.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/instamatic/config/scripts/measure_grid.py b/src/instamatic/config/scripts/measure_grid.py index ce982ddb..f8161e74 100644 --- a/src/instamatic/config/scripts/measure_grid.py +++ b/src/instamatic/config/scripts/measure_grid.py @@ -40,12 +40,13 @@ class EdgeSweeper(Sweeper): step: int_nm = 10_000 # largest step size allowed precision: int_nm = 1 # smallest step size allowed - threshold: float = 0.01 # fraction of light_max that signals the edge + threshold: float = 0.05 # fraction of light_max that signals the edge light_max: int = -1 # maximum light observed at any point by any sweeper def peak(self) -> int: """Return light (image sum) at current position, update light max.""" - light = int(_ctrl.get_image().sum()) + img, _ = _ctrl.get_image() + light = int(img.sum()) EdgeSweeper.light_max = max(light, EdgeSweeper.light_max) return light @@ -70,6 +71,7 @@ def sweep(self) -> None: dy: float = self.heading[1].item() * self.step self.walk(dx=dx, dy=dy) light_here: int = self.peak() + print(f'CRUDE EDGE POSITION: {self.position}') class BinaryEdgeSweeper(EdgeSweeper): @@ -98,6 +100,7 @@ def sweep(self) -> None: else: _mult = 0.5 _step = -_mult * abs(_step) + print(f'BINARY EDGE POSITION: {self.position}') class RectangularGridWindow: @@ -262,6 +265,8 @@ def plot(self, ax=None, pad: float = 0.1) -> None: ax.set_xlabel('x / nm') ax.set_ylabel('y / nm') + plt.show() + @dataclass class XSweep: @@ -273,4 +278,9 @@ class XSweep: if __name__ == '__main__': rgw = RectangularGridWindow.from_star_search() + for a in 'center_x center_y width height theta center w_axis h_axis corners'.split(): + try: + print(f'{a}: {getattr(rgw, a)}') + except Exception: + pass rgw.plot() From 4229ae4541cb8bedb4f75b13bfc2c794a5a41896 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Fri, 19 Dec 2025 15:11:57 +0100 Subject: [PATCH 005/118] Reworking grit tools into separate instamatic.grid (more to put here) --- src/instamatic/config/scripts/measure_grid.py | 79 -------- src/instamatic/grid/__init__.py | 0 src/instamatic/grid/sweepers.py | 140 +++++++++++++ src/instamatic/grid/window.py | 186 ++++++++++++++++++ src/instamatic/utils/iterating.py | 6 +- 5 files changed, 330 insertions(+), 81 deletions(-) create mode 100644 src/instamatic/grid/__init__.py create mode 100644 src/instamatic/grid/sweepers.py create mode 100644 src/instamatic/grid/window.py diff --git a/src/instamatic/config/scripts/measure_grid.py b/src/instamatic/config/scripts/measure_grid.py index f8161e74..4003bb36 100644 --- a/src/instamatic/config/scripts/measure_grid.py +++ b/src/instamatic/config/scripts/measure_grid.py @@ -24,85 +24,6 @@ Vector2 = Sequence[float] -class Sweeper: - """A simple descriptor of stage movement with fixed heading.""" - - step: int_nm = 1000 - - def __init__(self, origin: Vector2, heading: Vector2) -> None: - self.origin = np.array([origin[0], origin[1]], dtype=float) - self.heading = np.array([heading[0], heading[1]], dtype=float) - self.position = np.array([origin[0], origin[1]], dtype=float) - - -class EdgeSweeper(Sweeper): - """Used to determine the edge of the stage based on camera feedback.""" - - step: int_nm = 10_000 # largest step size allowed - precision: int_nm = 1 # smallest step size allowed - threshold: float = 0.05 # fraction of light_max that signals the edge - light_max: int = -1 # maximum light observed at any point by any sweeper - - def peak(self) -> int: - """Return light (image sum) at current position, update light max.""" - img, _ = _ctrl.get_image() - light = int(img.sum()) - EdgeSweeper.light_max = max(light, EdgeSweeper.light_max) - return light - - def walk(self, dx: float, dy: float) -> None: - """Change sweeper position by a (dx, dy) vector.""" - x, y = _ctrl.stage.xy - x = int(x + dx) - y = int(y + dy) - _ctrl.stage.set(x=x, y=y) - self.position = np.array([x, y], dtype=float) - - -class CrudeEdgeSweeper(EdgeSweeper): - """Moves monotonously, stops when intensity fract < max * threshold.""" - - def sweep(self) -> None: - """Walk steps into heading until peaked light is below threshold.""" - _ctrl.stage.set(int(self.origin[0]), int(self.origin[1])) - light_here: int = self.peak() - while light_here > self.light_max * self.threshold: - dx: float = self.heading[0].item() * self.step - dy: float = self.heading[1].item() * self.step - self.walk(dx=dx, dy=dy) - light_here: int = self.peak() - print(f'CRUDE EDGE POSITION: {self.position}') - - -class BinaryEdgeSweeper(EdgeSweeper): - """A stage-state descriptor used to binary-search the grid edge.""" - - def breed(self, other: Self) -> Self: - """Return a new instance with mean heading and position.""" - o = (self.position + other.position) / 2 - n = np.linalg.norm(s := self.heading + other.heading) - if n == 0: - raise ValueError('Degenerate bisector') - return self.__class__(origin=o, heading=s / n) - - def sweep(self) -> None: - """Bin-search the edge based on peaked light vs max * threshold.""" - _ctrl.stage.set(int(self.origin[0]), int(self.origin[1])) - _step = self.step - _mult = 1.0 - while not self.precision > _step > -self.precision: - dx: float = self.heading[0].item() * _step - dy: float = self.heading[1].item() * _step - self.walk(dx=dx, dy=dy) - light_here = self.peak() - if light_here > self.threshold * self.light_max: - _step = _mult * abs(_step) - else: - _mult = 0.5 - _step = -_mult * abs(_step) - print(f'BINARY EDGE POSITION: {self.position}') - - class RectangularGridWindow: """Describes one rectangular window without assumptions about the grid. diff --git a/src/instamatic/grid/__init__.py b/src/instamatic/grid/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/instamatic/grid/sweepers.py b/src/instamatic/grid/sweepers.py new file mode 100644 index 00000000..e5cd4089 --- /dev/null +++ b/src/instamatic/grid/sweepers.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, ClassVar, Literal, Sequence + +import numpy as np +from typing_extensions import Self + +from instamatic._typing import float_nm, int_nm +from instamatic.controller import TEMController, _ctrl, initialize + +if not _ctrl: + _ctrl: TEMController = initialize() + + +Vector2 = Sequence[float] + + +def cross2d(a: np.ndarray, b: np.ndarray) -> float: + """A scalar 2d cross product between two arrays of length 2.""" + return (a[0] * b[1] - a[1] * b[0]).item() + + +class InstanceAutoNameRegistry: + """Autosave each subclass instance in `cls.INSTANCES` dict under `name`""" + + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + cls.INSTANCES: dict[Any, Self] = {} + + def __post_init__(self): + self.__class__.INSTANCES[getattr(self, 'name')] = self + + +class Sweeper: + """A simple descriptor of stage movement with fixed heading.""" + + def __init__(self, origin: Vector2, heading: Vector2) -> None: + self.origin = np.array([origin[0], origin[1]], dtype=float) + self.heading = np.array([heading[0], heading[1]], dtype=float) + self.position = np.array([origin[0], origin[1]], dtype=float) + + def dist2segment(self, x1: float, y1: float, x2: float, y2: float) -> float: + """Dist to intercept a segment or +inf, stackoverflow.com/q/2931573.""" + ray1o = self.origin + ray1h = self.heading + ray2o = np.array([x1, y1], dtype=float) + ray2h = np.array([x2 - x1, y2 - y1], dtype=float) + delta = ray2o - ray1o + cross = cross2d(ray1h, ray2h) + if cross == 0: + return np.inf + ray1d = cross2d(delta, ray2h) / cross + ray2d = cross2d(delta, ray1h) / cross + if ray1d >= 0 and 0 <= ray2d <= 1: + return ray1d * np.linalg.norm(self.heading) + return np.inf + + +@dataclass +class EdgeSweeperTeam(InstanceAutoNameRegistry): + """Stores a set of shared variables between the members of sweeper team.""" + + name: str = '' # identifier used for registration in INSTANCES + step_size: int_nm = 10_000 # largest step size allowed + precision: int_nm = 1 # smallest step size allowed + threshold: float = 0.01 # fraction of light_max that signals the edge + light_max: int = -1 # maximum light observed at any point by any sweeper + + +default_edge_sweeper_team = EdgeSweeperTeam() + + +class EdgeSweeper(Sweeper): + """Used to determine the edge of the stage based on camera feedback.""" + + def __init__(self, origin: Vector2, heading: Vector2, team: str = '') -> None: + self.history: list[Vector2] = [] + self.team = EdgeSweeperTeam.INSTANCES[team] + super().__init__(origin, heading) + + def peak(self) -> int: + """Return light (image sum) at current position, update light max.""" + light = int(_ctrl.get_image(header_keys=())[0].sum()) + self.team.light_max = max(light, self.team.light_max) + return light + + def goto(self, x: int_nm, y: int_nm) -> None: + """Change sweeper position to `x`, `y` and update current position.""" + _ctrl.stage.set(x=x, y=y) + self.history.append([x, y]) + self.position = np.array([x, y], dtype=float) + + def step(self, length: float_nm) -> None: + """Change sweeper position by `length` in `heading` direction.""" + x0, y0 = _ctrl.stage.xy + x1 = int(x0 + self.heading[0].item() * length) + y1 = int(x0 + self.heading[1].item() * length) + self.goto(x1, y1) + + +class MarchingEdgeSweeper(EdgeSweeper): + """Moves monotonously, stops when intensity fract < max * threshold.""" + + def sweep(self) -> None: + """Walk steps into heading until peaked light is below threshold.""" + self.goto(x=int(self.origin[0]), y=int(self.origin[1])) + light_here: int = self.peak() + while light_here > self.team.light_max * self.team.threshold: + self.step(length=self.team.step_size) + light_here: int = self.peak() + + +class BinaryEdgeSweeper(EdgeSweeper): + """A stage-state descriptor used to binary-search the grid edge.""" + + def breed(self, other: Self) -> Self: + """Return a new instance with mean heading and position.""" + o = (self.position + other.position) / 2 + n = np.linalg.norm(s := self.heading + other.heading) + if n == 0: + raise ValueError('Cannot breed sweepers with parallel heading') + return self.__class__(origin=o, heading=s / n) + + def sweep(self) -> None: + """Bin-search the edge based on peaked light vs max * threshold.""" + self.goto(x=int(self.origin[0]), y=int(self.origin[1])) + step_size: float_nm = self.team.step_size + direction: Literal[1, -1] + refining: bool = False + while step_size > self.team.precision: + light_here = self.peak() + if light_here > self.team.threshold * self.team.light_max: + direction = 1 + else: + direction = -1 + refining = True + if refining: + step_size *= 0.5 + self.step(length=direction * step_size) diff --git a/src/instamatic/grid/window.py b/src/instamatic/grid/window.py new file mode 100644 index 00000000..f91ed3cb --- /dev/null +++ b/src/instamatic/grid/window.py @@ -0,0 +1,186 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from itertools import chain +from typing import Literal, Optional, Sequence + +import numpy as np +from matplotlib import pyplot as plt +from matplotlib.patches import Polygon +from scipy.optimize import minimize +from typing_extensions import Self + +from instamatic._typing import float_nm, int_nm +from instamatic.controller import TEMController, _ctrl, initialize +from instamatic.grid.sweepers import BinaryEdgeSweeper, EdgeSweeperTeam, MarchingEdgeSweeper +from instamatic.utils.iterating import pairwise + +if not _ctrl: + _ctrl: TEMController = initialize() + + +X = np.array([1, 0], dtype=float) +Y = np.array([0, 1], dtype=float) + + +class ConvexPolygonGridWindow(ABC): + """Describes one convex polygon window without assumptions about grid.""" + + center: np.ndarray = ... + corners: Sequence[np.ndarray] = ... + + @classmethod + def from_sweeping(cls, order: Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10] = 3) -> Self: + """Return new using `EdgeSweeper`s scanning around current position.""" + origin = np.array(_ctrl.stage.xy, dtype=int) + team = str(origin) + _ = EdgeSweeperTeam(name=team) + + # define and sweep with initial marching sweepers to approx. grid center + dirs = [+X, -X, +Y, -Y] + mess = [MarchingEdgeSweeper(origin=origin, heading=d, team=team) for d in dirs] + for mes in mess: + mes.sweep() + center_x = (mess[0].position[0] + mess[1].position[0]) / 2 + center_y = (mess[2].position[1] + mess[3].position[1]) / 2 + center = np.array([center_x, center_y], dtype=float) + + # define binary sweepers, step to edge of marchers-probed region & sweep + mess_position_pairs = list(pairwise([mes.position for mes in mess], closed=True)) + bess = [BinaryEdgeSweeper(origin=center, heading=d) for d in (X, Y, -X, -Y)] + for bes in bess: + dists = [bes.dist2segment(*p1, *p2) for p1, p2 in mess_position_pairs] + safe_dist = min(dists) - bes.team.step_size + if np.isfinite(safe_dist) and safe_dist > 0: + bes.step(safe_dist) + bes.sweep() + + # for each order, create a new generation of beam sweepers and sweep + def bisectors(sweepers: list[BinaryEdgeSweeper]) -> list[BinaryEdgeSweeper]: + new = [a.breed(b) for a, b in pairwise(sweepers, closed=True)] + for ns in new: + ns.sweep() + return new + + for _ in range(1, order): + bess = list(chain.from_iterable(zip(bess, bisectors(bess)))) + + edge_xy = np.vstack([bes.position for bes in bess]) # Nx2 + return cls.from_edge_xys(edge_xy) # TODO continue refactoring + + @classmethod + @abstractmethod + def from_edge_xys(cls, edge_xy: np.ndarray) -> Self: ... + + def plot(self, ax=None, pad: float = 0.1) -> None: + """Plot a simple visual representation of the window geometry.""" + if ax is None: + _, ax = plt.subplots() + + corners = self.corners + cx, cy = self.center + xmin, ymin = corners.min(axis=0) + xmax, ymax = corners.max(axis=0) + dx, dy = xmax - xmin, ymax - ymin + + ax.set_facecolor('0.85') + ax.add_patch( + Polygon( + corners, + closed=True, + facecolor='white', + edgecolor='black', + linewidth=1.5, + zorder=1, + ) + ) + + ax.plot(corners[:, 0], corners[:, 1], 'ro', zorder=2) + ax.plot(cx, cy, 'r+', markersize=10, markeredgewidth=2, zorder=3) + + if hasattr(self, '_edge_xys'): + xys = self._edge_xys + ax.plot(xys[:, 0], xys[:, 1], 'bx', markersize=6, zorder=5) + + ax.set_aspect('equal', adjustable='box') + ax.set_xlim(xmin - pad * dx, xmax + pad * dx) + ax.set_ylim(ymin - pad * dy, ymax + pad * dy) + ax.set_xlabel('x / nm') + ax.set_ylabel('y / nm') + + plt.show() + + def x_intersections(self, y: float_nm) -> Optional[tuple[float, float]]: + """Return (x_min, x_max) for a horizontal line intersecting at y.""" + intersection_xs: list[float] = [] + for x1, y1, x2, y2 in pairwise(self.corners, closed=True): + if y1 == y2: # work with edge case , close to zero + continue + intersection_fraction = (y - y1) / (y2 - y1) + if not 0 < intersection_fraction < 1: + continue # does not intersect + intersection_xs.append(x1 + (x2 - x1) * intersection_fraction) + if len(intersection_xs) < 2: + return None + return min(intersection_xs), max(intersection_xs) + + +class RectangularGridWindow(ConvexPolygonGridWindow): + """Describes one rectangular window without assumptions about the grid. + + Geometry is described using five immutable float scalars (nm / radian): + + - center_x: coordinate of the window center on the X axis; + - center_y: coordinate of the window center on the Y axis; + - width: length of window side aligned with the direction of X axis; + - height: length of window side aligned with the direction or Y axis; + - theta: signed angle from X towards X-aligned edge (positive towards Y); + """ + + def __init__(self, x: float, y: float, w: float, h: float, t: float): + t = (t + (np.pi / 2)) % np.pi - (np.pi / 2) # cast to [-pi/2, pi/2] + if not -np.pi / 4 < t < np.pi / 4: # cast to [-pi/4, pi/4] + w, h, t = h, w, (np.pi - t) % np.pi - np.pi / 2 + + self.center_x: float_nm = x + self.center_y: float_nm = y + self.width = w = abs(w) + self.height = h = abs(h) + self.theta: float = t # expressed in radian + + self.center = c = np.array([x, y], dtype=float) + self.w_axis = wa = 0.5 * w * np.array([np.cos(t), np.sin(t)], dtype=float) + self.h_axis = ha = 0.5 * h * np.array([-np.sin(t), np.cos(t)], dtype=float) + self.corners = np.vstack([c + wa + ha, c + wa - ha, c - wa - ha, c - wa + ha]) + + @classmethod + def from_edge_xys(cls, edge_xys: np.ndarray) -> Self: + """Return new by fitting the edge to a Nx2 list of edge positions.""" + xys_com = np.mean(edge_xys, axis=0) + xys_deltas = edge_xys - xys_com + xys_cov = np.cov(xys_deltas.T) + eigenvalues, eigenvectors = np.linalg.eigh(xys_cov) + eigenvector_proj = xys_deltas @ eigenvectors + width0 = eigenvector_proj[:, 1].max() - eigenvector_proj[:, 1].min() + height0 = eigenvector_proj[:, 0].max() - eigenvector_proj[:, 0].min() + theta0 = np.arctan2(eigenvectors[1, 1], eigenvectors[0, 1]) + guess = np.array([xys_com[0], xys_com[1], width0, height0, theta0]) + res = minimize(cls.edge_dist2_sum, guess, args=(edge_xys,), method='Powell') + new = cls(*res.x) + new._edge_xys = edge_xys + return new + + @staticmethod + def edge_d2_sum(geom: tuple[float, float, float, float, float], xys: np.ndarray) -> float: + """scipy.optimize.minimize fitting func; for geometry see cls docs.""" + center_x, center_y, width, height, theta = geom + center = np.array([center_x, center_y], dtype=float) + w_axis = 0.5 * width * np.array([np.cos(theta), np.sin(theta)]) + h_axis = 0.5 * height * np.array([-np.sin(theta), np.cos(theta)]) + w_axis_n = w_axis / np.linalg.norm(w_axis) + h_axis_n = h_axis / np.linalg.norm(h_axis) + d1 = np.abs(np.dot(xys - (center + w_axis), w_axis_n)) + d2 = np.abs(np.dot(xys - (center - w_axis), w_axis_n)) + d3 = np.abs(np.dot(xys - (center + h_axis), h_axis_n)) + d4 = np.abs(np.dot(xys - (center - h_axis), h_axis_n)) + return np.sum(np.min([d1, d2, d3, d4], axis=0) ** 2) diff --git a/src/instamatic/utils/iterating.py b/src/instamatic/utils/iterating.py index c9a15504..d0bb9b51 100644 --- a/src/instamatic/utils/iterating.py +++ b/src/instamatic/utils/iterating.py @@ -6,13 +6,15 @@ T = TypeVar('T') -def pairwise(iterable: Iterable[T]) -> Iterator[tuple[T, T]]: +def pairwise(iterable: Iterable[T], closed: bool = False) -> Iterator[tuple[T, T]]: """Yield pairs of subsequent iterable elements: 'abc' -> (a, b), (b, c)""" iterator = iter(iterable) - left = next(iterator, None) + first = left = next(iterator, None) for right in iterator: yield left, right left = right + if closed and first is not None: + yield left, first def sawtooth(iterator: Iterable[T]) -> Iterator[T]: From b1ecb2b799e98889ee8316c84dbe3d34924bd57c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Fri, 19 Dec 2025 19:18:45 +0100 Subject: [PATCH 006/118] Collect movies using tcp stream instead of http request to allow higher fps --- src/instamatic/camera/camera_serval.py | 149 +++++++++++++++++++++---- 1 file changed, 125 insertions(+), 24 deletions(-) diff --git a/src/instamatic/camera/camera_serval.py b/src/instamatic/camera/camera_serval.py index 89eac22f..fd1022a5 100644 --- a/src/instamatic/camera/camera_serval.py +++ b/src/instamatic/camera/camera_serval.py @@ -1,11 +1,15 @@ from __future__ import annotations import atexit +import json import logging import math +import socket +import time from io import BytesIO from itertools import batched from typing import Generator, List, Optional, Sequence, Tuple, Union +from urllib.parse import urlparse import numpy as np import tifffile @@ -30,6 +34,8 @@ class CameraServal(CameraBase): MIN_EXPOSURE = 0.000001 MAX_EXPOSURE = 10.0 BAD_EXPOSURE_MSG = 'Requested exposure exceeds native Serval support (>0-10s)' + TCP_CHUNK_SIZE = 4096 + TCP_PORT_OFFSET = +1 # TCP PORT = HTTP_PORT + TCP_PORT_OFFSET def __init__(self, name='serval'): """Initialize camera module.""" @@ -122,19 +128,27 @@ def _get_image_stack(self, n_frames: int, exposure: float, **_) -> list[np.ndarr def get_movie( self, n_frames: int, exposure: Optional[float] = None, **kwargs ) -> Generator[np.ndarray, None, None]: - """A generator yielding images using a mode with minimal dead time. If - the exposure is not given, the default value is read from the config - file. Binning is ignored. + """Yield `n_frames` images received via a TCP stream with minimal dead + time. If the exposure is not given, the default value is read from the + config file. Binning is ignored. n_frames: `int` Number of frames to collect exposure: `float` or `None` Exposure time in seconds. """ - logger.debug(f'Collecting {n_frames}-frame movie with exposure {exposure} s') - mode = 'AUTOTRIGSTART_TIMERSTOP' if self.dead_time else 'CONTINUOUS' + logger.debug(f'Collecting {n_frames}-frame movie with exposure {exposure} s via TCP') + mode: str = 'AUTOTRIGSTART_TIMERSTOP' if self.dead_time else 'CONTINUOUS' + exposure: float = self.default_exposure if exposure is None else exposure + + http_url = urlparse(self.conn.url) + tcp_host = http_url.hostname + tcp_port = (http_url.port or 8080) + self.TCP_PORT_OFFSET + self.conn.measurement_stop() previous_config = self.conn.detector_config + previous_destination = self.conn.destination + try: self.conn.set_detector_config( TriggerMode=mode, @@ -142,13 +156,111 @@ def get_movie( TriggerPeriod=exposure + self.dead_time, nTriggers=n_frames, ) - self.conn.measurement_start() - for i in range(n_frames): - response = self.conn.get_request('/measurement/image') - yield tifffile.imread(BytesIO(response.content)) + self.conn.destination = { # listen mode: serval waits for us to connect + 'Image': [ + { + 'Base': f'tcp://listen@0.0.0.0:{tcp_port}', + 'Format': 'jsonimage', + 'Mode': 'count', + } + ] + } + + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.settimeout(max(2.0, (exposure + self.dead_time) * 5)) + for attempt in range(10): + try: + sock.connect((tcp_host, tcp_port)) + break + except ConnectionRefusedError: + if attempt == 9: + raise + time.sleep(0.05) + + with sock: + self.conn.measurement_start() + yield from self._tcp_stream(sock, n_frames) + finally: - self.conn.measurement_stop() - self.conn.set_detector_config(**previous_config) + try: + self.conn.measurement_stop() + except Exception as e: + logger.error(f'Error stopping measurement: {e}') + try: + self.conn.destination = previous_destination + self.conn.set_detector_config(**previous_config) + except Exception as e: + logger.error(f'Error restoring config: {e}') + + def _tcp_stream(self, sock: socket.socket, n_frames: int) -> Generator[np.ndarray]: + """Parse 'jsonimage', yield images from a raw data via TCP socket.""" + buffer = bytearray() + frames_yielded = 0 + + while frames_yielded < n_frames: # Read until enough to identify the JSON header + chunk = sock.recv(self.TCP_CHUNK_SIZE) + if not chunk: + break + buffer.extend(chunk) + + while True: + if not buffer: + break + + # Scanning json-image for {}-delimited JSON header + brace_depth: int = 0 + header_start_idx: int = -1 + header_end_idx: int = -1 + next_closing_idx: int = -1 + scanning_idx = 0 + while header_end_idx < 0: + if next_closing_idx < scanning_idx: + try: + next_closing_idx = buffer.index(b'}', scanning_idx) + except ValueError: + break # read data until a closing bracket is found + try: + next_opening_idx = buffer.index(b'{', scanning_idx, next_closing_idx) + if brace_depth == 0: + header_start_idx = next_opening_idx + except ValueError: + brace_depth -= 1 + scanning_idx = next_closing_idx + 1 + if brace_depth == 0: + header_end_idx = next_closing_idx + 1 + else: + brace_depth += 1 + scanning_idx = next_opening_idx + 1 + if header_end_idx == -1: + break # propagate break to read more + + # Reading in and parsing information in the json header + json_bytes = buffer[header_start_idx:header_end_idx] + header_str = json_bytes.decode('utf-8') + header_dict = json.loads(header_str) + width_default, height_default = self.get_image_dimensions() + width = header_dict.get('width', width_default) + height = header_dict.get('height', height_default) + bit_depth = header_dict.get('bitDepth', 16) + data_size = header_dict.get('dataSize', width * height * bit_depth // 8) + del buffer[:header_end_idx] + + # Reading in missing data + if len(buffer) < data_size: + while (missing := data_size - len(buffer)) > 0: + chunk = sock.recv(min(missing, 65536)) # read large chunks + if not chunk: + raise ConnectionError('Socket closed mid-frame') + buffer.extend(chunk) + dt = np.uint32 if bit_depth > 16 else np.uint16 if bit_depth > 8 else np.uint8 + img_array = np.frombuffer(buffer[:data_size], dtype=dt) + shape = (height, width) if width and height else self.get_image_dimensions() + yield img_array.reshape(shape) + frames_yielded += 1 + del buffer[:data_size] + if frames_yielded >= n_frames: + return def get_image_dimensions(self) -> Tuple[int, int]: """Get the binned dimensions reported by the camera.""" @@ -169,19 +281,8 @@ def establish_connection(self) -> None: ) self.conn.set_detector_config(**self.detector_config) - self.conn.destination = { - 'Image': [ - { - # Where to place the preview files (HTTP end-point: GET localhost:8080/measurement/image) - 'Base': 'http://localhost', - # What (image) format to provide the files in. - 'Format': 'tiff', - # What data to build a frame from - 'Mode': 'count', - # 'QueueSize': 2, - } - ], - } + img_dest = {'Base': 'http://localhost', 'Format': 'tiff', 'Mode': 'count'} + self.conn.destination = {'Image': [img_dest]} def release_connection(self) -> None: """Release the connection to the camera.""" From 7cb5b731ddcc722b8935b4bf32bd68197df83f67 Mon Sep 17 00:00:00 2001 From: Daniel Tchon Date: Fri, 19 Dec 2025 21:17:51 +0100 Subject: [PATCH 007/118] Not faster but does connect (and raises errors, r=None in videoframe). TODO: investigate why. --- src/instamatic/camera/camera_serval.py | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/src/instamatic/camera/camera_serval.py b/src/instamatic/camera/camera_serval.py index fd1022a5..2a338a3f 100644 --- a/src/instamatic/camera/camera_serval.py +++ b/src/instamatic/camera/camera_serval.py @@ -159,27 +159,21 @@ def get_movie( self.conn.destination = { # listen mode: serval waits for us to connect 'Image': [ { - 'Base': f'tcp://listen@0.0.0.0:{tcp_port}', + 'Base': f'tcp://connect@191.0.0.1:{tcp_port}', 'Format': 'jsonimage', 'Mode': 'count', } ] } - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - sock.settimeout(max(2.0, (exposure + self.dead_time) * 5)) - for attempt in range(10): - try: - sock.connect((tcp_host, tcp_port)) - break - except ConnectionRefusedError: - if attempt == 9: - raise - time.sleep(0.05) - + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + listener.bind(('191.0.0.1', tcp_port)) + listener.listen(1) + self.conn.measurement_start() + sock, addr = listener.accept() + listener.close() with sock: - self.conn.measurement_start() yield from self._tcp_stream(sock, n_frames) finally: From a566ecc25ae55d482ac7c5c3f2ec0a996c311dd7 Mon Sep 17 00:00:00 2001 From: Daniel Tchon Date: Thu, 15 Jan 2026 20:24:59 +0100 Subject: [PATCH 008/118] This can read movie @ 10 fps no problem, but not 100 fps. --- src/instamatic/camera/camera_serval.py | 42 +++++++++++++++++--------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/src/instamatic/camera/camera_serval.py b/src/instamatic/camera/camera_serval.py index 2a338a3f..85e77d74 100644 --- a/src/instamatic/camera/camera_serval.py +++ b/src/instamatic/camera/camera_serval.py @@ -5,7 +5,7 @@ import logging import math import socket -import time +import threading from io import BytesIO from itertools import batched from typing import Generator, List, Optional, Sequence, Tuple, Union @@ -149,13 +149,12 @@ def get_movie( previous_config = self.conn.detector_config previous_destination = self.conn.destination + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) try: - self.conn.set_detector_config( - TriggerMode=mode, - ExposureTime=exposure, - TriggerPeriod=exposure + self.dead_time, - nTriggers=n_frames, - ) + listener.bind(('0.0.0.0', tcp_port)) + listener.listen(1) + listener.settimeout(60) self.conn.destination = { # listen mode: serval waits for us to connect 'Image': [ { @@ -165,18 +164,33 @@ def get_movie( } ] } + self.conn.set_detector_config( + TriggerMode=mode, + ExposureTime=exposure, + TriggerPeriod=exposure + self.dead_time, + nTriggers=n_frames, + ) - listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - listener.bind(('191.0.0.1', tcp_port)) - listener.listen(1) - self.conn.measurement_start() - sock, addr = listener.accept() - listener.close() + def trigger_worker(): + try: + self.conn.measurement_start() + except Exception as e: + logger.error(f"Trigger thread failed: {e}") + + trigger_thread = threading.Thread(target=trigger_worker) + trigger_thread.start() + + try: + sock, addr = listener.accept() + except socket.timeout: + raise TimeoutError("Serval failed to connect back within 60 seconds.") with sock: yield from self._tcp_stream(sock, n_frames) + trigger_thread.join(timeout=2.0) + finally: + listener.close() try: self.conn.measurement_stop() except Exception as e: From 5a71ef8b5e112b4c03169197c6b2c13cb12301b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Fri, 16 Jan 2026 18:16:33 +0100 Subject: [PATCH 009/118] First fast design of movie reader/scanner, capable of reading ~2000fps (up to a few hundred) --- src/instamatic/camera/camera_serval.py | 182 ++++++++++++------------- 1 file changed, 89 insertions(+), 93 deletions(-) diff --git a/src/instamatic/camera/camera_serval.py b/src/instamatic/camera/camera_serval.py index 85e77d74..6e5f0d86 100644 --- a/src/instamatic/camera/camera_serval.py +++ b/src/instamatic/camera/camera_serval.py @@ -8,12 +8,13 @@ import threading from io import BytesIO from itertools import batched -from typing import Generator, List, Optional, Sequence, Tuple, Union +from typing import Generator, Iterator, List, Optional, Sequence, Tuple, Union from urllib.parse import urlparse import numpy as np import tifffile from serval_toolkit.camera import Camera as ServalCamera +from typing_extensions import Self from instamatic.camera.camera_base import CameraBase @@ -34,8 +35,6 @@ class CameraServal(CameraBase): MIN_EXPOSURE = 0.000001 MAX_EXPOSURE = 10.0 BAD_EXPOSURE_MSG = 'Requested exposure exceeds native Serval support (>0-10s)' - TCP_CHUNK_SIZE = 4096 - TCP_PORT_OFFSET = +1 # TCP PORT = HTTP_PORT + TCP_PORT_OFFSET def __init__(self, name='serval'): """Initialize camera module.""" @@ -142,8 +141,8 @@ def get_movie( exposure: float = self.default_exposure if exposure is None else exposure http_url = urlparse(self.conn.url) - tcp_host = http_url.hostname - tcp_port = (http_url.port or 8080) + self.TCP_PORT_OFFSET + host = http_url.hostname + port = (http_url.port or 8080) + 1 self.conn.measurement_stop() previous_config = self.conn.detector_config @@ -152,16 +151,16 @@ def get_movie( listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) try: - listener.bind(('0.0.0.0', tcp_port)) + listener.bind(('0.0.0.0', port)) listener.listen(1) - listener.settimeout(60) - self.conn.destination = { # listen mode: serval waits for us to connect + new_destination = { + 'Base': f'tcp://connect@{host}:{port}', + 'Format': 'jsonimage', + 'Mode': 'count', + } + self.conn.destination = { 'Image': [ - { - 'Base': f'tcp://connect@191.0.0.1:{tcp_port}', - 'Format': 'jsonimage', - 'Mode': 'count', - } + new_destination, ] } self.conn.set_detector_config( @@ -171,22 +170,14 @@ def get_movie( nTriggers=n_frames, ) - def trigger_worker(): - try: - self.conn.measurement_start() - except Exception as e: - logger.error(f"Trigger thread failed: {e}") - - trigger_thread = threading.Thread(target=trigger_worker) + trigger_thread = threading.Thread(target=self.conn.measurement_start) trigger_thread.start() - try: sock, addr = listener.accept() except socket.timeout: - raise TimeoutError("Serval failed to connect back within 60 seconds.") + raise TimeoutError('Serval failed to connect back within 60 seconds.') with sock: - yield from self._tcp_stream(sock, n_frames) - + yield from ServalMovieDeserializer(sock, n_frames) trigger_thread.join(timeout=2.0) finally: @@ -201,75 +192,6 @@ def trigger_worker(): except Exception as e: logger.error(f'Error restoring config: {e}') - def _tcp_stream(self, sock: socket.socket, n_frames: int) -> Generator[np.ndarray]: - """Parse 'jsonimage', yield images from a raw data via TCP socket.""" - buffer = bytearray() - frames_yielded = 0 - - while frames_yielded < n_frames: # Read until enough to identify the JSON header - chunk = sock.recv(self.TCP_CHUNK_SIZE) - if not chunk: - break - buffer.extend(chunk) - - while True: - if not buffer: - break - - # Scanning json-image for {}-delimited JSON header - brace_depth: int = 0 - header_start_idx: int = -1 - header_end_idx: int = -1 - next_closing_idx: int = -1 - scanning_idx = 0 - while header_end_idx < 0: - if next_closing_idx < scanning_idx: - try: - next_closing_idx = buffer.index(b'}', scanning_idx) - except ValueError: - break # read data until a closing bracket is found - try: - next_opening_idx = buffer.index(b'{', scanning_idx, next_closing_idx) - if brace_depth == 0: - header_start_idx = next_opening_idx - except ValueError: - brace_depth -= 1 - scanning_idx = next_closing_idx + 1 - if brace_depth == 0: - header_end_idx = next_closing_idx + 1 - else: - brace_depth += 1 - scanning_idx = next_opening_idx + 1 - if header_end_idx == -1: - break # propagate break to read more - - # Reading in and parsing information in the json header - json_bytes = buffer[header_start_idx:header_end_idx] - header_str = json_bytes.decode('utf-8') - header_dict = json.loads(header_str) - width_default, height_default = self.get_image_dimensions() - width = header_dict.get('width', width_default) - height = header_dict.get('height', height_default) - bit_depth = header_dict.get('bitDepth', 16) - data_size = header_dict.get('dataSize', width * height * bit_depth // 8) - del buffer[:header_end_idx] - - # Reading in missing data - if len(buffer) < data_size: - while (missing := data_size - len(buffer)) > 0: - chunk = sock.recv(min(missing, 65536)) # read large chunks - if not chunk: - raise ConnectionError('Socket closed mid-frame') - buffer.extend(chunk) - dt = np.uint32 if bit_depth > 16 else np.uint16 if bit_depth > 8 else np.uint8 - img_array = np.frombuffer(buffer[:data_size], dtype=dt) - shape = (height, width) if width and height else self.get_image_dimensions() - yield img_array.reshape(shape) - frames_yielded += 1 - del buffer[:data_size] - if frames_yielded >= n_frames: - return - def get_image_dimensions(self) -> Tuple[int, int]: """Get the binned dimensions reported by the camera.""" binning = self.get_binning() @@ -300,6 +222,80 @@ def release_connection(self) -> None: logger.info(msg) +class ServalMovieDeserializer(Iterator[np.ndarray]): + """Deserializes Serval camera TCP byte stream from socket into images.""" + + def __init__(self, sock: socket.socket, n_frames: int): + self.sock = sock + self.buffer = bytearray(65535) + self.buffer_size = 0 + self.offset = 0 + self.i_frame = 0 + self.n_frames = n_frames + + self.width: int = 0 + self.height: int = 0 + self.depth: int = 0 + self.size: int = 0 + self.dtype: np.dtype = np.uint32 + + def find_header_size(self) -> int: + """Find the index of curly bracket that closes current header + 1.""" + bracket_depth: int = 1 + header_end_idx: int = -1 + next_closing_idx: int = -1 + scanning_idx: int = self.offset + 1 + + while header_end_idx < 0: + if next_closing_idx < scanning_idx: + try: + next_closing_idx = self.buffer.index(b'}', scanning_idx) + except ValueError: + return -1 + try: + next_opening_idx = self.buffer.index(b'{', scanning_idx, next_closing_idx) + except ValueError: + bracket_depth -= 1 + if bracket_depth == 0: + return next_closing_idx + 1 + scanning_idx = next_closing_idx + 1 + else: + bracket_depth += 1 + scanning_idx = next_opening_idx + 1 + return -1 + + def reconfigure_from_header(self, header_size: int) -> None: + json_bytes = self.buffer[self.offset : header_size] + header_str = json_bytes.decode('utf-8') + header_dict = json.loads(header_str) + self.width = w = header_dict['width'] + self.height = h = header_dict['height'] + self.depth = d = header_dict['bitDepth'] // 8 + self.size = header_dict.get('dataSize', w * h * d) + self.dtype = np.uint32 if d > 2 else np.uint16 if d > 1 else np.uint8 + self.buffer += bytearray(self.n_frames * (header_size + self.size)) + + def __next__(self) -> np.ndarray: + if self.i_frame >= self.n_frames: + raise StopIteration + header_size = self.find_header_size() + while header_size < 0: + self.buffer_size += self.sock.recv_into(memoryview(self.buffer)[self.buffer_size :]) + header_size = self.find_header_size() + if self.i_frame == 0: + self.reconfigure_from_header(header_size) + image_start = header_size + image_end = image_start + self.size + while self.buffer_size < image_end: + self.buffer_size += self.sock.recv_into(memoryview(self.buffer)[self.buffer_size :]) + image_view = memoryview(self.buffer)[image_start:image_end] + img_array = np.frombuffer(image_view, dtype=self.dtype) + image = img_array.reshape((self.height, self.width)) + self.offset = image_end + self.i_frame += 1 + return image + + if __name__ == '__main__': cam = CameraServal() from IPython import embed From b6e1a32c3f6e706692dd9f779f40cfb0509261b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Fri, 16 Jan 2026 20:13:51 +0100 Subject: [PATCH 010/118] New implementation of serval `get_movie` theoretically reading ~1k frames/s --- src/instamatic/camera/camera_serval.py | 134 +++++++++---------------- tests/test_serval_movie.py | 59 +++++++++++ 2 files changed, 107 insertions(+), 86 deletions(-) create mode 100644 tests/test_serval_movie.py diff --git a/src/instamatic/camera/camera_serval.py b/src/instamatic/camera/camera_serval.py index 6e5f0d86..bfbecfab 100644 --- a/src/instamatic/camera/camera_serval.py +++ b/src/instamatic/camera/camera_serval.py @@ -8,13 +8,13 @@ import threading from io import BytesIO from itertools import batched +from math import prod from typing import Generator, Iterator, List, Optional, Sequence, Tuple, Union from urllib.parse import urlparse import numpy as np import tifffile from serval_toolkit.camera import Camera as ServalCamera -from typing_extensions import Self from instamatic.camera.camera_base import CameraBase @@ -43,6 +43,7 @@ def __init__(self, name='serval'): self.dead_time = ( self.detector_config['TriggerPeriod'] - self.detector_config['ExposureTime'] ) + self.movie_bufsize = 2 * 4 * prod(self.dimensions) logger.info(f'Camera {self.get_name()} initialized') atexit.register(self.release_connection) @@ -102,21 +103,14 @@ def _get_image_null(self, **_) -> np.ndarray: def _get_image_single(self, exposure: float, **_) -> np.ndarray: """Request a single frame in the mode in a trigger collection mode.""" logger.debug(f'Collecting a single image with exposure {exposure} s') - # Upload exposure settings (Note: will do nothing if no change in settings) self.conn.set_detector_config( ExposureTime=exposure, TriggerPeriod=exposure + self.dead_time, ) - - # Check if measurement is running. If not: start db = self.conn.dashboard if db['Measurement'] is None or db['Measurement']['Status'] != 'DA_RECORDING': self.conn.measurement_start() - - # Start the acquisition self.conn.trigger_start() - - # Request a frame. Will be streamed *after* the exposure finishes response = self.conn.get_request('/measurement/image') return tifffile.imread(BytesIO(response.content)) @@ -141,8 +135,9 @@ def get_movie( exposure: float = self.default_exposure if exposure is None else exposure http_url = urlparse(self.conn.url) - host = http_url.hostname - port = (http_url.port or 8080) + 1 + tcp_port = (http_url.port or 8080) + 1 + tcp_base = f'tcp://connect@{http_url.hostname}:{tcp_port}' + tcp_dest = {'Base': tcp_base, 'Format': 'jsonimage', 'Mode': 'count'} self.conn.measurement_stop() previous_config = self.conn.detector_config @@ -150,17 +145,13 @@ def get_movie( listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + listener.settimeout(5.0) try: - listener.bind(('0.0.0.0', port)) + listener.bind(('0.0.0.0', tcp_port)) listener.listen(1) - new_destination = { - 'Base': f'tcp://connect@{host}:{port}', - 'Format': 'jsonimage', - 'Mode': 'count', - } self.conn.destination = { 'Image': [ - new_destination, + tcp_dest, ] } self.conn.set_detector_config( @@ -169,16 +160,13 @@ def get_movie( TriggerPeriod=exposure + self.dead_time, nTriggers=n_frames, ) - - trigger_thread = threading.Thread(target=self.conn.measurement_start) - trigger_thread.start() + threading.Thread(target=self.conn.measurement_start, daemon=True).start() try: sock, addr = listener.accept() except socket.timeout: - raise TimeoutError('Serval failed to connect back within 60 seconds.') + raise TimeoutError('Serval failed to connect back within 5 seconds.') with sock: - yield from ServalMovieDeserializer(sock, n_frames) - trigger_thread.join(timeout=2.0) + yield from ServalMovieDeserializer(sock, n_frames, self.movie_bufsize) finally: listener.close() @@ -196,11 +184,7 @@ def get_image_dimensions(self) -> Tuple[int, int]: """Get the binned dimensions reported by the camera.""" binning = self.get_binning() dim_x, dim_y = self.get_camera_dimensions() - - dim_x = int(dim_x / binning) - dim_y = int(dim_y / binning) - - return dim_x, dim_y + return int(dim_x / binning), int(dim_y / binning) def establish_connection(self) -> None: """Establish connection to the camera.""" @@ -225,75 +209,53 @@ def release_connection(self) -> None: class ServalMovieDeserializer(Iterator[np.ndarray]): """Deserializes Serval camera TCP byte stream from socket into images.""" - def __init__(self, sock: socket.socket, n_frames: int): - self.sock = sock - self.buffer = bytearray(65535) - self.buffer_size = 0 - self.offset = 0 - self.i_frame = 0 - self.n_frames = n_frames - - self.width: int = 0 - self.height: int = 0 - self.depth: int = 0 + def __init__(self, sock: socket.socket, n_frames: int, bufsize: int) -> None: + self.sock: socket.socket = sock + self.buffer = bytearray(bufsize) + self.view = memoryview(self.buffer) + self.used: int = 0 + self.i_frame: int = 0 + self.n_frames: int = n_frames + self.shape: tuple[int, int] = (0, 0) self.size: int = 0 self.dtype: np.dtype = np.uint32 - def find_header_size(self) -> int: - """Find the index of curly bracket that closes current header + 1.""" - bracket_depth: int = 1 - header_end_idx: int = -1 - next_closing_idx: int = -1 - scanning_idx: int = self.offset + 1 - - while header_end_idx < 0: - if next_closing_idx < scanning_idx: - try: - next_closing_idx = self.buffer.index(b'}', scanning_idx) - except ValueError: - return -1 - try: - next_opening_idx = self.buffer.index(b'{', scanning_idx, next_closing_idx) - except ValueError: - bracket_depth -= 1 - if bracket_depth == 0: - return next_closing_idx + 1 - scanning_idx = next_closing_idx + 1 - else: - bracket_depth += 1 - scanning_idx = next_opening_idx + 1 - return -1 - - def reconfigure_from_header(self, header_size: int) -> None: - json_bytes = self.buffer[self.offset : header_size] - header_str = json_bytes.decode('utf-8') + def _recv_more(self) -> None: + if not (n := self.sock.recv_into(self.view[self.used :])): + raise EOFError + self.used += n + + def _read_until(self, token: bytes) -> int: + while True: + idx = self.buffer.find(token, 0, self.used) + if idx >= 0: + return idx + len(token) + self._recv_more() + + def read_image_shape_from_header(self, header_size: int) -> None: + """Read shape, size, dtype of all images from the first header.""" + header_str = self.buffer[:header_size].decode('utf-8') header_dict = json.loads(header_str) - self.width = w = header_dict['width'] - self.height = h = header_dict['height'] - self.depth = d = header_dict['bitDepth'] // 8 - self.size = header_dict.get('dataSize', w * h * d) + d = header_dict['bitDepth'] // 8 + self.shape = (header_dict['height'], header_dict['width']) + self.size = header_dict.get('dataSize', prod(self.shape) * d) self.dtype = np.uint32 if d > 2 else np.uint16 if d > 1 else np.uint8 - self.buffer += bytearray(self.n_frames * (header_size + self.size)) def __next__(self) -> np.ndarray: + """Recv as much data as needed and use it to yield next frame ASAP.""" if self.i_frame >= self.n_frames: raise StopIteration - header_size = self.find_header_size() - while header_size < 0: - self.buffer_size += self.sock.recv_into(memoryview(self.buffer)[self.buffer_size :]) - header_size = self.find_header_size() + header_end = self._read_until(b'}') if self.i_frame == 0: - self.reconfigure_from_header(header_size) - image_start = header_size - image_end = image_start + self.size - while self.buffer_size < image_end: - self.buffer_size += self.sock.recv_into(memoryview(self.buffer)[self.buffer_size :]) - image_view = memoryview(self.buffer)[image_start:image_end] - img_array = np.frombuffer(image_view, dtype=self.dtype) - image = img_array.reshape((self.height, self.width)) - self.offset = image_end + self.read_image_shape_from_header(header_end) + while self.used < header_end + self.size: + self._recv_more() + i, j = header_end, header_end + self.size + frame = np.frombuffer(self.buffer[i:j], dtype=self.dtype).reshape(self.shape).copy() + self.buffer[: self.used - j] = self.buffer[j : self.used] + self.used -= j self.i_frame += 1 - return image + return frame if __name__ == '__main__': diff --git a/tests/test_serval_movie.py b/tests/test_serval_movie.py new file mode 100644 index 00000000..5d22287c --- /dev/null +++ b/tests/test_serval_movie.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import numpy as np + +from instamatic.camera.camera_serval import ServalMovieDeserializer + +rng = np.random.default_rng(1337) +json_image = rng.integers(low=0, high=256, size=(512, 512), dtype=np.uint16) +json_header = b"""{ + "timeAtFrame": 1655990130.181, + "frameNumber": 14, + "measurementID": "None", + "dataSize": 524288, + "bitDepth": 16, + "isPreviewSampled": true, + "thresholdID": 0, + "pixelEventNumber": 0, + "tdc1EventNumber": 0, + "tdc2EventNumber": 0, + "integrationSize": 0, + "integrationMode": "None", + "width": 512, + "height": 512, + "corrections": [] +}""" +json_bytes = json_header + bytearray(json_image.data) +movie_bufsize = 2 * 4 * 512 * 512 + + +class MockSocket: + """A simple socket-like object that serves predefined bytes.""" + + def __init__(self, data: bytes): + self._buf = memoryview(data) + self._offset = 0 + + def recv_into(self, b: memoryview) -> int: + n = min(len(b), len(self._buf) - self._offset) + if n == 0: + return 0 + b[:n] = self._buf[self._offset : self._offset + n] + self._offset += n + return n + + +def test_serval_movie_deserializer1() -> None: + """Check that ServalMovieDeserializer can yield one image correctly.""" + sock = MockSocket(json_bytes) + smd = ServalMovieDeserializer(sock, n_frames=1, bufsize=movie_bufsize) # type: ignore + image = next(smd) + np.testing.assert_array_equal(image, json_image) + + +def test_serval_movie_deserializer100() -> None: + """Check that ServalMovieDeserializer can yield 1000 images correctly.""" + sock = MockSocket(json_bytes * 100) + smd = ServalMovieDeserializer(sock, n_frames=100, bufsize=movie_bufsize) # type: ignore + for image in smd: + np.testing.assert_array_equal(image, json_image) From 1679d38190ad16dbb1f94292955fd6615eec9812 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Mon, 19 Jan 2026 18:46:43 +0100 Subject: [PATCH 011/118] First rough implementation of the SPED multiprocessing image logic --- src/instamatic/experiments/sped/__init__.py | 0 src/instamatic/experiments/sped/diffhunt.py | 107 ++++++++++++++++++ src/instamatic/experiments/sped/experiment.py | 86 ++++++++++++++ src/instamatic/experiments/sped/util.py | 18 +++ 4 files changed, 211 insertions(+) create mode 100644 src/instamatic/experiments/sped/__init__.py create mode 100644 src/instamatic/experiments/sped/diffhunt.py create mode 100644 src/instamatic/experiments/sped/experiment.py create mode 100644 src/instamatic/experiments/sped/util.py diff --git a/src/instamatic/experiments/sped/__init__.py b/src/instamatic/experiments/sped/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/instamatic/experiments/sped/diffhunt.py b/src/instamatic/experiments/sped/diffhunt.py new file mode 100644 index 00000000..7ef46c60 --- /dev/null +++ b/src/instamatic/experiments/sped/diffhunt.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +import multiprocessing as mp +import multiprocessing.shared_memory +import queue +import uuid + +import numpy as np + +from instamatic.experiments.sped.util import SPEDLoc + +N_PROCESSORS = 4 + + +mp.set_start_method('spawn', force=True) + + +class DiffHuntDispatcher: + """Proxy class: ask workers on other processes if image has diffraction""" + + def __init__(self, shape, dtype): + self.shape: tuple[int, int] = shape + self.dtype: np.dtype = dtype + + self.buffer: np.ndarray = np.array([], dtype=dtype) + self.buffer_name: str = '' + self.buffer_ptr: int = 0 + + self.queries = mp.Queue() + self.answers = mp.Queue() + self.workers: list[mp.Process] = self.initialize_workers() + + def initialize_workers(self) -> list[mp.Process]: + """Run once at the start of experiment to spawn eval processes.""" + args = (self.queries, self.answers, self.dtype) + workers = [] + for i in range(N_PROCESSORS): + p = mp.Process(target=diff_hunt_worker, args=(i, *args), daemon=1) + workers.append(p) + p.start() + return workers + + def switch_buffer(self, n_frames: int = 100, name: str = None) -> None: + """Configure a new mp shared memory space to buffer a frame stack.""" + self.buffer_name = name if name is not None else 'SPED_' + uuid.uuid4().hex + buffer_shape = (n_frames, self.shape[0], self.shape[1]) + size = np.prod(buffer_shape) * np.dtype(self.dtype).itemsize + shm = mp.shared_memory.SharedMemory(name=self.buffer_name, create=True, size=size) + self.buffer = np.ndarray(buffer_shape, dtype=self.dtype, buffer=shm.buf) + self.buffer_ptr: int = 0 + + def submit(self, frame: np.ndarray) -> None: + """Request eval of 1 frame from buffer stored on some shared memory.""" + if self.buffer_ptr >= self.buffer.shape[0]: + raise RuntimeError(f'{self.buffer_name} buffer overflow') + self.buffer[self.buffer_ptr, :, :] = frame + self.queries.put((self.buffer_name, self.buffer.shape, self.buffer_ptr)) + self.buffer_ptr += 1 + + def poll(self): + try: + return self.answers.get_nowait() + except queue.Empty: + return None + + def close(self): + self.queries.put(None) + for p in self.workers: + p.join() + + +def diff_hunt_worker( + worker_id: int, + queries: mp.Queue, + answers: mp.Queue, + dtype: np.dtype, +): + """Evaluates if shared frames have diffraction on a separate processor.""" + buffer: np.ndarray = np.array([], dtype=dtype) + buffer_name: str = '' + + while True: + q = queries.get(block=True) + + if q is None: + queries.put(None) + return + + assert isinstance(q, tuple) and len(q) == 3 + q_buffer_name, q_buffer_shape, q_buffer_ptr = q + + if q_buffer_name != buffer_name: + buffer_name = q_buffer_name + shm = mp.shared_memory.SharedMemory(name=buffer_name) + buffer = np.ndarray(q_buffer_shape, dtype=dtype, buffer=shm.buf) + + frame = buffer[q_buffer_ptr] + has_diffraction: bool = detect_diffraction(frame) + answers.put((q_buffer_ptr, has_diffraction)) + + +def detect_diffraction(frame: np.ndarray) -> bool: + return False + + +def save_frame(buffer_name: str, q_buffer_ptr: int, worker_id: int): + """Do this on the main thread I guess since it has meta information?""" diff --git a/src/instamatic/experiments/sped/experiment.py b/src/instamatic/experiments/sped/experiment.py new file mode 100644 index 00000000..b88e2d49 --- /dev/null +++ b/src/instamatic/experiments/sped/experiment.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from typing import Union + +import numpy as np + +from instamatic.calibrate import CalibMovieDelays +from instamatic.calibrate.calibrate_stage_translation import CalibStageTranslationX +from instamatic.experiments.experiment_base import ExperimentBase +from instamatic.experiments.fast_adt.experiment import FastADTMissingCalibError +from instamatic.grid.window import RectangularGridWindow + + +class Experiment(ExperimentBase): + name = 'SPED' + + def __init__(self, ctrl, **kwargs): + super().__init__() + self.ctrl = ctrl + + self.exposure = 0.1 + self.speed: Union[float, int] = 1.0 + self.xy_resolution = 2 + + def get_dead_time( + self, + exposure: float = 0.0, + header_keys_variable: tuple = (), + header_keys_common: tuple = (), + ) -> float: + """Get time between get_movie frames from any source available or 0.""" + try: + return self.ctrl.cam.dead_time + except AttributeError: + pass + print('`cam.dead_time` not found. Looking for calibrated estimate...') + try: + c = CalibMovieDelays.from_file(exposure, header_keys_variable, header_keys_common) + except RuntimeWarning: + return 0.0 + else: + return c.dead_time + + def get_stage_translation(self) -> CalibStageTranslationX: + """Get rotation calibration if present; otherwise warn & terminate.""" + try: + return CalibStageTranslationX.from_file() + except OSError: + print(m1 := 'This script requires stage rotation to be calibrated.') + print(m2 := 'Please run `instamatic.calibrate_stage_rotation` first.') + raise FastADTMissingCalibError(m1 + ' ' + m2) + + def determine_translation_speed(self) -> None: + detector_dead_time = self.get_dead_time(self.exposure) + time_for_one_frame = self.exposure + detector_dead_time + trans_calib = self.get_stage_translation() + mot_plan = trans_calib.plan_motion(time_for_one_frame / self.xy_resolution) + self.exposure = abs(mot_plan.pace * self.xy_resolution) - detector_dead_time + self.speed = mot_plan.speed + + def start_collection(self, **params) -> None: + # precalculate sliding speeds + self.determine_translation_speed() + + # plan the scanning of current grid window + win = RectangularGridWindow.from_sweeping(order=3) + y = np.min(win.corners[:, 1]) + (0.5 * self.xy_resolution) + scans: dict[int, tuple[float, float]] = {} + for i, x in enumerate(win.x_intersections(y)): + if x is None: + break + scans[y] = (x[0], x[1]) if i % 2 else (x[1], x[0]) + y += self.xy_resolution + + # for each scan, collect a movie + for y, (x0, x1) in scans.items(): + self.ctrl.stage.set(x=x0, y=y) + x_n = int(np.ceil(abs(x1 - x0) / self.xy_resolution)) + movie = self.ctrl.get_movie(n_frames=x_n, exposure=self.exposure) + self.ctrl.stage.set_with_speed(x=x1, speed=self.speed) + for x_i, (image, meta) in enumerate(movie): + ... # send image to multiprocessor analyzer + + return + + def finalize(self) -> None: ... diff --git a/src/instamatic/experiments/sped/util.py b/src/instamatic/experiments/sped/util.py new file mode 100644 index 00000000..af795828 --- /dev/null +++ b/src/instamatic/experiments/sped/util.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from typing import NamedTuple, Optional + +from fontTools.misc.cython import returns + + +class SPEDLoc(NamedTuple): + """Universal SPED indexing/locating format for grid, scans, frames etc.""" + + grid_i: int + grid_j: int + scan: Optional[int] = None + step: Optional[int] = None + + @property + def name(self) -> str: + return 'SPED_' + '_'.join(str(i) for i in self if i is not None) From 9026a5b46b966b7ad1776bb9a308c7665eb94270 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Tue, 20 Jan 2026 19:31:06 +0100 Subject: [PATCH 012/118] Mostly finished scaling and formalized multiprocessing logic. --- src/instamatic/experiments/sped/diffhunt.py | 278 +++++++++++++++----- 1 file changed, 211 insertions(+), 67 deletions(-) diff --git a/src/instamatic/experiments/sped/diffhunt.py b/src/instamatic/experiments/sped/diffhunt.py index 7ef46c60..448d602c 100644 --- a/src/instamatic/experiments/sped/diffhunt.py +++ b/src/instamatic/experiments/sped/diffhunt.py @@ -3,100 +3,244 @@ import multiprocessing as mp import multiprocessing.shared_memory import queue +import threading import uuid +from dataclasses import dataclass, field +from typing import Optional import numpy as np +import pandas as pd +from typing_extensions import Literal -from instamatic.experiments.sped.util import SPEDLoc +from instamatic._typing import AnyPath N_PROCESSORS = 4 mp.set_start_method('spawn', force=True) +Task = Literal['PROCESS', 'WRITE', 'TERMINATE'] +Event = Literal['PROCESSING', 'PROCESSED', 'SWITCHED', 'TERMINATED'] + + +@dataclass(frozen=True) +class Command: + """Schema used to communicate commands from dispatcher to any worker.""" + + task: Task + buffer_name: Optional[str] = None + buffer_pointer: Optional[int] = None + kwargs: Optional[dict] = None + + +@dataclass(frozen=True) +class Feedback: + """Schema used to communicate feedback from any worker to dispatcher.""" + + event: Event + worker_id: int + buffer_name: Optional[str] = None + buffer_pointer: Optional[int] = None + details: Optional[dict] = None + class DiffHuntDispatcher: """Proxy class: ask workers on other processes if image has diffraction""" + @dataclass + class Worker: + buffer: str = '' + busy: bool = False + pointer: Optional[int] = None + process: mp.Process = None + + @dataclass + class Buffer: + frames: np.ndarray + name: str = field(default_factory=lambda: uuid.uuid4().hex) + pointer: int = 0 + pointers: set[int] = field(default_factory=set) # currently processed + workers: set[int] = field(default_factory=set) # attached to buffer + def __init__(self, shape, dtype): self.shape: tuple[int, int] = shape self.dtype: np.dtype = dtype - self.buffer: np.ndarray = np.array([], dtype=dtype) - self.buffer_name: str = '' - self.buffer_ptr: int = 0 - - self.queries = mp.Queue() - self.answers = mp.Queue() - self.workers: list[mp.Process] = self.initialize_workers() + self.commands: mp.Queue[Command] = mp.Queue() + self.feedback: mp.Queue[Feedback] = mp.Queue() + self.workers: dict[int, DiffHuntDispatcher.Worker] = self.initialize_workers() + self.buffers: dict[str, DiffHuntDispatcher.Buffer] = {} + self.history = pd.DataFrame(columns=['buffer', 'pointer', 'has_diffraction', 'header']) + self.history.set_index(['buffer', 'pointer'], inplace=True) - def initialize_workers(self) -> list[mp.Process]: + def initialize_workers(self) -> dict[int, DiffHuntDispatcher.Worker]: """Run once at the start of experiment to spawn eval processes.""" - args = (self.queries, self.answers, self.dtype) - workers = [] + workers = {} for i in range(N_PROCESSORS): - p = mp.Process(target=diff_hunt_worker, args=(i, *args), daemon=1) - workers.append(p) - p.start() + worker = DiffHuntWorker(i, self.commands, self.feedback, self.dtype) + worker.start() + workers[i] = self.Worker(process=worker) return workers def switch_buffer(self, n_frames: int = 100, name: str = None) -> None: """Configure a new mp shared memory space to buffer a frame stack.""" - self.buffer_name = name if name is not None else 'SPED_' + uuid.uuid4().hex - buffer_shape = (n_frames, self.shape[0], self.shape[1]) - size = np.prod(buffer_shape) * np.dtype(self.dtype).itemsize - shm = mp.shared_memory.SharedMemory(name=self.buffer_name, create=True, size=size) - self.buffer = np.ndarray(buffer_shape, dtype=self.dtype, buffer=shm.buf) - self.buffer_ptr: int = 0 - - def submit(self, frame: np.ndarray) -> None: - """Request eval of 1 frame from buffer stored on some shared memory.""" - if self.buffer_ptr >= self.buffer.shape[0]: - raise RuntimeError(f'{self.buffer_name} buffer overflow') - self.buffer[self.buffer_ptr, :, :] = frame - self.queries.put((self.buffer_name, self.buffer.shape, self.buffer_ptr)) - self.buffer_ptr += 1 - - def poll(self): - try: - return self.answers.get_nowait() - except queue.Empty: - return None - - def close(self): - self.queries.put(None) - for p in self.workers: - p.join() - - -def diff_hunt_worker( - worker_id: int, - queries: mp.Queue, - answers: mp.Queue, - dtype: np.dtype, -): - """Evaluates if shared frames have diffraction on a separate processor.""" - buffer: np.ndarray = np.array([], dtype=dtype) - buffer_name: str = '' - - while True: - q = queries.get(block=True) - - if q is None: - queries.put(None) - return - - assert isinstance(q, tuple) and len(q) == 3 - q_buffer_name, q_buffer_shape, q_buffer_ptr = q - - if q_buffer_name != buffer_name: - buffer_name = q_buffer_name + shape = (n_frames, self.shape[0], self.shape[1]) + size = np.prod(shape) * np.dtype(self.dtype).itemsize + shm = mp.shared_memory.SharedMemory(name=name, create=True, size=size) + frames = np.ndarray(shape, dtype=self.dtype, buffer=shm.buf) + self.buffers[name] = self.Buffer(frames=frames, name=name) + + def write_buffer(self, path: AnyPath) -> None: + """Save all the frames with diffraction in an active buffer.""" + ab = list(self.buffers.values())[-1] # last i.e. active buffer + h = self.history + to_save = h[(h['buffer'] == ab.name) & h['has_diffraction']] + for ptr, h in to_save[['pointer', 'header']]: + self.emit('WRITE', ab.name, ptr, kwargs={'path': path, 'header': h}) + + # COMMANDING METHODS THAT DISPATCH COMMANDS TO WORKERS + + def emit(self, task: Task, *args, **kwargs) -> None: + """Shorthand to create and put Command in the self.commands queue.""" + self.commands.put(Command(task, *args, **kwargs)) + + def process(self, frame: np.ndarray, header: Optional[dict]) -> None: + """Request 'PROCESS_FRAME' from buffer stored on some shared memory.""" + ab = list(self.buffers.values())[-1] # last i.e. active buffer + if ab.pointer >= ab.frames.shape[0]: + raise RuntimeError(f'{ab.name} buffer overflow') + ab.frames[ab.pointer, :, :] = frame + self.emit('PROCESS', buffer_name=ab.name, buffer_pointer=ab.pointer) + ab.pointers.add(ab.pointer) + ab.pointer += 1 + self.history.loc[(ab.name, ab.pointer), 'header'] = header + self.history.loc[(ab.name, ab.pointer), 'has_diffraction'] = None + + def terminate_workers(self) -> None: + """Command all workers to 'TERMINATE' and report the success.""" + for _ in self.workers: + self.emit('TERMINATE') + + # HANDLE FEEDBACK INCOMING FROM THE WORKERS + + def handle_feedback(self, stop_event: threading.Event) -> None: + """To be called in a separate thread to handle incoming feedback.""" + while not stop_event.is_set(): + try: + fb: Feedback = self.feedback.get(timeout=0.05) + except queue.Empty: + continue + + worker = self.workers.get(fb.worker_id) + + if fb.event == 'PROCESSING': + worker.busy = True + worker.buffer = fb.buffer_name + worker.pointer = fb.buffer_pointer + buffer = self.buffers.get(fb.buffer_name) + buffer.workers.add(fb.worker_id) + buffer.pointers.add(fb.buffer_pointer) + + elif fb.event == 'PROCESSED_FRAME': + idx = (fb.buffer_name, fb.buffer_pointer) + has = fb.details.get('has_diffraction', 'False') + self.history.at[idx, 'has_diffraction'] = has + worker.busy = False + worker.pointer = None + buffer = self.buffers.get(fb.buffer_name) + buffer.pointers.discard(fb.buffer_pointer) + + elif fb.event == 'SWITCHED': + if old_buffer := worker.buffer: + self.buffers.get(old_buffer).workers.discard(fb.worker_id) + worker.buffer = fb.buffer_name + self.buffers.get(fb.buffer_name).workers.add(fb.worker_id) + self._maybe_release_buffer(self.buffers.get(fb.buffer_name)) + + elif fb.event == 'TERMINATE': + self._terminate_worker(fb.worker_id) + + else: + raise ValueError(f'Unknown feedback event {fb.event}') + + def _maybe_release_buffer(self, buffer: DiffHuntDispatcher.Buffer): + """If the buffer has no workers and no plans, release its memory.""" + if not buffer.workers and not buffer.pointers: + del self.buffers[buffer.name] + try: + buffer.frames.base.close() + buffer.frames.base.unlink() + except Exception as e: + print(f'Warning: could not release buffer {buffer.name}: {e}') + + def _terminate_worker(self, worker_id: int) -> None: + """Once the worker is ready to terminate, join and close it.""" + worker = self.workers.pop(worker_id) + if worker.buffer: + buffer = self.buffers.get(worker.buffer) + if buffer: + buffer.workers.discard(worker_id) + self._maybe_release_buffer(buffer) + worker.process.join() + worker.process.close() + + +class DiffHuntWorker(mp.Process): + """Stateful diffraction-hunting work process handled by the dispatcher.""" + + def __init__( + self, + worker_id: int, + commands: mp.Queue, + feedback: mp.Queue, + dtype: np.dtype, + ): + super().__init__(daemon=True) + self.worker_id = worker_id + self.commands = commands + self.feedback = feedback + self.dtype = dtype + + self.buffer: np.ndarray = np.array([], dtype=dtype) + self.buffer_name: str = '' + + def emit(self, event: Event, *args, **kwargs) -> None: + """Put worker_id followed by all args in the self.feedback queue.""" + self.feedback.put(Feedback(event, self.worker_id, *args, **kwargs)) + + def run(self) -> None: + """Main loop passing incoming commands to respective methods.""" + while True: + cmd, *args = self.commands.get(block=True) + + if cmd == 'TERMINATE': + self.emit('TERMINATED') + return + + if cmd == 'PROCESS_FRAME': + self._process_frame(*args) + continue + + raise ValueError(f'Unknown command: {cmd}') + + def _process_frame( + self, + buffer_name: str, + buffer_shape: tuple, + frame_index: int, + ) -> None: + """Handles the 'PROCESS FRAME' command.""" + + if buffer_name != self.buffer_name: + self.buffer_name = buffer_name shm = mp.shared_memory.SharedMemory(name=buffer_name) - buffer = np.ndarray(q_buffer_shape, dtype=dtype, buffer=shm.buf) + self.buffer = np.ndarray(buffer_shape, dtype=self.dtype, buffer=shm.buf) + self.emit('SWITCHED', buffer_name=buffer_name) - frame = buffer[q_buffer_ptr] - has_diffraction: bool = detect_diffraction(frame) - answers.put((q_buffer_ptr, has_diffraction)) + self.emit('PROCESSING', buffer_name=buffer_name, buffer_pointer=frame_index) + frame = self.buffer[frame_index] + d = {'has_diffraction': detect_diffraction(frame)} + self.emit('PROCESSED', buffer_name=buffer_name, buffer_pointer=frame_index, details=d) def detect_diffraction(frame: np.ndarray) -> bool: From 306f3996ff24eddff132bb6e3ced65c42a797d44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Tue, 20 Jan 2026 20:16:03 +0100 Subject: [PATCH 013/118] Some fixes to the logic: TODO diffraction algos, entry points, testing, --- src/instamatic/experiments/sped/diffhunt.py | 76 ++++++++++++--------- 1 file changed, 45 insertions(+), 31 deletions(-) diff --git a/src/instamatic/experiments/sped/diffhunt.py b/src/instamatic/experiments/sped/diffhunt.py index 448d602c..df75d5c9 100644 --- a/src/instamatic/experiments/sped/diffhunt.py +++ b/src/instamatic/experiments/sped/diffhunt.py @@ -6,6 +6,7 @@ import threading import uuid from dataclasses import dataclass, field +from pathlib import Path from typing import Optional import numpy as np @@ -13,6 +14,7 @@ from typing_extensions import Literal from instamatic._typing import AnyPath +from instamatic.formats import write_tiff N_PROCESSORS = 4 @@ -30,6 +32,7 @@ class Command: task: Task buffer_name: Optional[str] = None buffer_pointer: Optional[int] = None + buffer_shape: Optional[tuple[int, int, int]] = None kwargs: Optional[dict] = None @@ -57,7 +60,8 @@ class Worker: @dataclass class Buffer: frames: np.ndarray - name: str = field(default_factory=lambda: uuid.uuid4().hex) + name: str + shm: mp.shared_memory.SharedMemory pointer: int = 0 pointers: set[int] = field(default_factory=set) # currently processed workers: set[int] = field(default_factory=set) # attached to buffer @@ -84,19 +88,22 @@ def initialize_workers(self) -> dict[int, DiffHuntDispatcher.Worker]: def switch_buffer(self, n_frames: int = 100, name: str = None) -> None: """Configure a new mp shared memory space to buffer a frame stack.""" + name = name if name is not None else uuid.uuid4().hex shape = (n_frames, self.shape[0], self.shape[1]) size = np.prod(shape) * np.dtype(self.dtype).itemsize shm = mp.shared_memory.SharedMemory(name=name, create=True, size=size) frames = np.ndarray(shape, dtype=self.dtype, buffer=shm.buf) - self.buffers[name] = self.Buffer(frames=frames, name=name) + self.buffers[name] = self.Buffer(frames=frames, name=name, shm=shm) def write_buffer(self, path: AnyPath) -> None: """Save all the frames with diffraction in an active buffer.""" ab = list(self.buffers.values())[-1] # last i.e. active buffer h = self.history - to_save = h[(h['buffer'] == ab.name) & h['has_diffraction']] - for ptr, h in to_save[['pointer', 'header']]: - self.emit('WRITE', ab.name, ptr, kwargs={'path': path, 'header': h}) + to_save = h[(h.index.get_level_values('buffer') == ab.name) & h['has_diffraction']] + for t in to_save.itertuples(): + buffer_name, pointer = t.Index + h = t.header + self.emit('WRITE', buffer_name, pointer, kwargs={'path': path, 'header': h}) # COMMANDING METHODS THAT DISPATCH COMMANDS TO WORKERS @@ -105,16 +112,16 @@ def emit(self, task: Task, *args, **kwargs) -> None: self.commands.put(Command(task, *args, **kwargs)) def process(self, frame: np.ndarray, header: Optional[dict]) -> None: - """Request 'PROCESS_FRAME' from buffer stored on some shared memory.""" + """Request 'PROCESS' from buffer stored on some shared memory.""" ab = list(self.buffers.values())[-1] # last i.e. active buffer if ab.pointer >= ab.frames.shape[0]: raise RuntimeError(f'{ab.name} buffer overflow') ab.frames[ab.pointer, :, :] = frame - self.emit('PROCESS', buffer_name=ab.name, buffer_pointer=ab.pointer) - ab.pointers.add(ab.pointer) - ab.pointer += 1 + self.emit('PROCESS', ab.name, buffer_pointer=ab.pointer, buffer_shape=ab.frames.shape) self.history.loc[(ab.name, ab.pointer), 'header'] = header self.history.loc[(ab.name, ab.pointer), 'has_diffraction'] = None + ab.pointers.add(ab.pointer) + ab.pointer += 1 def terminate_workers(self) -> None: """Command all workers to 'TERMINATE' and report the success.""" @@ -141,9 +148,9 @@ def handle_feedback(self, stop_event: threading.Event) -> None: buffer.workers.add(fb.worker_id) buffer.pointers.add(fb.buffer_pointer) - elif fb.event == 'PROCESSED_FRAME': + elif fb.event == 'PROCESSED': idx = (fb.buffer_name, fb.buffer_pointer) - has = fb.details.get('has_diffraction', 'False') + has = fb.details.get('has_diffraction', False) self.history.at[idx, 'has_diffraction'] = has worker.busy = False worker.pointer = None @@ -157,7 +164,7 @@ def handle_feedback(self, stop_event: threading.Event) -> None: self.buffers.get(fb.buffer_name).workers.add(fb.worker_id) self._maybe_release_buffer(self.buffers.get(fb.buffer_name)) - elif fb.event == 'TERMINATE': + elif fb.event == 'TERMINATED': self._terminate_worker(fb.worker_id) else: @@ -166,12 +173,13 @@ def handle_feedback(self, stop_event: threading.Event) -> None: def _maybe_release_buffer(self, buffer: DiffHuntDispatcher.Buffer): """If the buffer has no workers and no plans, release its memory.""" if not buffer.workers and not buffer.pointers: - del self.buffers[buffer.name] try: - buffer.frames.base.close() - buffer.frames.base.unlink() + buffer.shm.close() + buffer.shm.unlink() except Exception as e: print(f'Warning: could not release buffer {buffer.name}: {e}') + finally: + self.buffers.pop(buffer.name, None) def _terminate_worker(self, worker_id: int) -> None: """Once the worker is ready to terminate, join and close it.""" @@ -201,7 +209,7 @@ def __init__( self.feedback = feedback self.dtype = dtype - self.buffer: np.ndarray = np.array([], dtype=dtype) + self.frames: np.ndarray = np.array([], dtype=dtype) self.buffer_name: str = '' def emit(self, event: Event, *args, **kwargs) -> None: @@ -211,41 +219,47 @@ def emit(self, event: Event, *args, **kwargs) -> None: def run(self) -> None: """Main loop passing incoming commands to respective methods.""" while True: - cmd, *args = self.commands.get(block=True) + cmd: Command = self.commands.get(block=True) + + if cmd.task == 'PROCESS': + self._process(cmd.buffer_name, cmd.buffer_shape, cmd.buffer_pointer) + + elif cmd.task == 'WRITE': + self._write(cmd.buffer_name, cmd.buffer_pointer, **cmd.kwargs) - if cmd == 'TERMINATE': + elif cmd.task == 'TERMINATE': self.emit('TERMINATED') return - if cmd == 'PROCESS_FRAME': - self._process_frame(*args) - continue - - raise ValueError(f'Unknown command: {cmd}') + else: + raise ValueError(f'Unknown command: {cmd}') - def _process_frame( + def _process( self, buffer_name: str, buffer_shape: tuple, frame_index: int, ) -> None: - """Handles the 'PROCESS FRAME' command.""" + """Handles the 'PROCESS' frame command.""" if buffer_name != self.buffer_name: self.buffer_name = buffer_name shm = mp.shared_memory.SharedMemory(name=buffer_name) - self.buffer = np.ndarray(buffer_shape, dtype=self.dtype, buffer=shm.buf) + self.frames = np.ndarray(buffer_shape, dtype=self.dtype, buffer=shm.buf) self.emit('SWITCHED', buffer_name=buffer_name) self.emit('PROCESSING', buffer_name=buffer_name, buffer_pointer=frame_index) - frame = self.buffer[frame_index] + frame = self.frames[frame_index] d = {'has_diffraction': detect_diffraction(frame)} self.emit('PROCESSED', buffer_name=buffer_name, buffer_pointer=frame_index, details=d) + def _write(self, buffer_name: str, frame_index: int, **kwargs) -> None: + """Handles the 'WRITE' command or runs after _process if auto-write.""" + path = kwargs.get('path', '') + header = kwargs.get('header', None) + fn = str(Path(path).resolve() / f'{buffer_name}_{frame_index:04d}.tiff') + write_tiff(fname=fn, data=self.frames[frame_index], header=header) + def detect_diffraction(frame: np.ndarray) -> bool: return False - - -def save_frame(buffer_name: str, q_buffer_ptr: int, worker_id: int): - """Do this on the main thread I guess since it has meta information?""" From 7e6dbfa61f5822aea0a70194c2e0759e722ade3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Wed, 21 Jan 2026 14:01:23 +0100 Subject: [PATCH 014/118] Very decent chatgpt algorithm that is WAY too slow, saving for history --- src/instamatic/experiments/sped/detection.py | 177 ++++++++++++++++++ .../sped/{diffhunt.py => dispatch.py} | 4 - 2 files changed, 177 insertions(+), 4 deletions(-) create mode 100644 src/instamatic/experiments/sped/detection.py rename src/instamatic/experiments/sped/{diffhunt.py => dispatch.py} (99%) diff --git a/src/instamatic/experiments/sped/detection.py b/src/instamatic/experiments/sped/detection.py new file mode 100644 index 00000000..c9bbf65f --- /dev/null +++ b/src/instamatic/experiments/sped/detection.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +import time + +import matplotlib.pyplot as plt +import numpy as np +from scipy import ndimage as ndi +from skimage.feature import blob_log # optional alternative +from skimage.measure import label +from skimage.morphology import binary_dilation, disk, opening + + +def detect_diffraction_peaks( + image: np.ndarray, + opening_radius: int = 31, + central_fraction: float = 0.005, + central_dilate: int = 5, + thr_sigma: float = 5.0, + thr_floor: float = 2.0, + max_filter_size: int = 3, + min_distance_px: int = 1, + method: str = 'localmax', +): + """A diffraction detection method suggested by ChatGPT to be refined Detect + candidate diffraction peaks in a single image. + + Parameters + ---------- + image : 2D ndarray (integer counts) + opening_radius : radius of disk used to compute background via morphological opening + central_fraction : fraction of peak used to identify the primary beam component + central_dilate : dilation radius for central-beam mask (pixels) + thr_sigma : threshold = median + thr_sigma * std (adaptive) + thr_floor : minimal absolute threshold (counts) + max_filter_size : neighborhood size for local-maximum test + method : "localmax" (fast) or "blob_log" (scale-aware blob detection) + + Returns + ------- + result : dict with keys + n_peaks_total, n_peaks_outside_center, peaks (N x 2 array of (y,x)), + processed (top-hat with center masked), thr, center_pos, center_radius + """ + img = image.astype(np.float32) + peak_pos = tuple(np.unravel_index(np.argmax(img), img.shape)) + peak_val = float(img[peak_pos]) + + # background estimate (large-scale) + selem = disk(opening_radius) + background = opening(img, selem) + + top_hat = img - background + top_hat[top_hat < 0] = 0.0 + + # central beam mask via connected component containing the peak + central_thr = peak_val * central_fraction + central_mask = img > central_thr + lbl = label(central_mask) + peak_label = ( + lbl[peak_pos] + if (0 <= peak_pos[0] < lbl.shape[0] and 0 <= peak_pos[1] < lbl.shape[1]) + else 0 + ) + if peak_label != 0: + central_comp = lbl == peak_label + central_comp = binary_dilation(central_comp, footprint=disk(central_dilate)) + else: + central_comp = np.zeros_like(img, dtype=bool) + + processed = top_hat.copy() + processed[central_comp] = 0.0 + + # threshold + adaptive_thr = np.median(processed) + thr_sigma * np.std(processed) + thr = max(thr_floor, adaptive_thr) + + if method == 'localmax': + neighborhood = ndi.maximum_filter(processed, size=max_filter_size) + local_max = (processed == neighborhood) & (processed >= thr) + # remove border pixels + local_max[0, :] = local_max[-1, :] = local_max[:, 0] = local_max[:, -1] = False + peaks = np.column_stack(np.nonzero(local_max)) + elif method == 'blob_log': + # scale-aware detection (might be slower) + blobs = blob_log( + processed, min_sigma=1, max_sigma=4, threshold=thr / float(processed.max() + 1e-12) + ) + # blob_log returns (y, x, sigma) + peaks = blobs[:, :2].astype(int) if blobs.size else np.empty((0, 2), int) + else: + raise ValueError('unknown method') + + total_peaks = peaks.shape[0] + + # central radius (largest distance of any central component pixel from peak) + comp_coords = np.column_stack(np.nonzero(central_comp)) + if comp_coords.size: + comp_dists = np.sqrt(((comp_coords - np.array(peak_pos)) ** 2).sum(axis=1)) + center_radius = float(comp_dists.max()) + else: + center_radius = 0.0 + + # count only those peaks with distance > center_radius + if peaks.shape[0] > 0: + dists = np.sqrt(((peaks - np.array(peak_pos)) ** 2).sum(axis=1)) + outside_mask = dists > center_radius + peaks_outside = peaks[outside_mask] + n_outside = peaks_outside.shape[0] + else: + peaks_outside = np.empty((0, 2), int) + n_outside = 0 + + return { + 'n_peaks_total': int(total_peaks), + 'n_peaks_outside_center': int(n_outside), + 'peaks': peaks, + 'peaks_outside': peaks_outside, + 'processed': processed, + 'thr': float(thr), + 'center_pos': peak_pos, + 'center_radius': center_radius, + } + + +def plot_diffraction_debug(image, results): + """Visualize diffraction detection results. + + - grayscale log image + - green dots: all detected peaks + - red dots: peaks outside central beam + - cyan dot: center + """ + + # --- log-scaled image --- + img_log = np.log10(image.astype(np.float32) + 1.0) + + fig, ax = plt.subplots(figsize=(6, 6)) + ax.imshow(img_log, cmap='gray') + ax.set_title('Diffraction detection debug') + ax.axis('off') + + # --- peaks --- + peaks = results.get('peaks', np.empty((0, 2))) + if len(peaks): + ax.scatter(peaks[:, 1], peaks[:, 0], s=10, c='lime', marker='o', label='peaks') + + # --- peaks outside central beam --- + peaks_out = results.get('peaks_outside', np.empty((0, 2))) + if len(peaks_out): + ax.scatter( + peaks_out[:, 1], peaks_out[:, 0], s=14, c='red', marker='x', label='peaks outside' + ) + + # --- center --- + center = results.get('center_pos', None) + if center is not None: + ax.scatter(center[1], center[0], s=40, c='cyan', marker='s', label='center_pos') + + ax.legend(loc='lower right', fontsize=8) + plt.tight_layout() + plt.show() + + +if __name__ == '__main__': + from PIL import Image + + path = r'C:\Users\tchon\x\Instamatic_RATS_cRED_benchmark\instamatic_19\tiff\00020.tiff' + tiff = Image.open(path) + image = np.array(tiff) + print(f'{image.shape=} {image.dtype=} {image.max()=} {image.min()=}') + t0 = time.perf_counter() + for i in range(10): + results = detect_diffraction_peaks(image) + t1 = time.perf_counter() + print(f'TIME TAKEN: {t1 - t0}, PER ROUND: {(t1 - t0) / 100}') + print(results) + plot_diffraction_debug(image, results) diff --git a/src/instamatic/experiments/sped/diffhunt.py b/src/instamatic/experiments/sped/dispatch.py similarity index 99% rename from src/instamatic/experiments/sped/diffhunt.py rename to src/instamatic/experiments/sped/dispatch.py index df75d5c9..1f506d6b 100644 --- a/src/instamatic/experiments/sped/diffhunt.py +++ b/src/instamatic/experiments/sped/dispatch.py @@ -259,7 +259,3 @@ def _write(self, buffer_name: str, frame_index: int, **kwargs) -> None: header = kwargs.get('header', None) fn = str(Path(path).resolve() / f'{buffer_name}_{frame_index:04d}.tiff') write_tiff(fname=fn, data=self.frames[frame_index], header=header) - - -def detect_diffraction(frame: np.ndarray) -> bool: - return False From fdef9affd1e65d19e4cf4b21f582a466988c09c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Wed, 21 Jan 2026 16:07:28 +0100 Subject: [PATCH 015/118] Decent fast algo (20-40ms), but can we do better? --- src/instamatic/experiments/sped/detection.py | 325 ++++++++++++------- 1 file changed, 199 insertions(+), 126 deletions(-) diff --git a/src/instamatic/experiments/sped/detection.py b/src/instamatic/experiments/sped/detection.py index c9bbf65f..cb61e90a 100644 --- a/src/instamatic/experiments/sped/detection.py +++ b/src/instamatic/experiments/sped/detection.py @@ -4,124 +4,175 @@ import matplotlib.pyplot as plt import numpy as np +from matplotlib.patches import Circle from scipy import ndimage as ndi -from skimage.feature import blob_log # optional alternative -from skimage.measure import label -from skimage.morphology import binary_dilation, disk, opening -def detect_diffraction_peaks( +def detect_diffraction_fast( image: np.ndarray, - opening_radius: int = 31, - central_fraction: float = 0.005, - central_dilate: int = 5, - thr_sigma: float = 5.0, - thr_floor: float = 2.0, - max_filter_size: int = 3, - min_distance_px: int = 1, - method: str = 'localmax', + beam_radius_px: int = 40, + q: float = 99.9, + min_peaks: int = 5, + mask: np.ndarray | None = None, + n_radial_bins: int = 20, + bg_stat: str = 'median', # "median" or "q" + bg_q: float = 0.8, # used if bg_stat == "q" ): - """A diffraction detection method suggested by ChatGPT to be refined Detect - candidate diffraction peaks in a single image. - - Parameters - ---------- - image : 2D ndarray (integer counts) - opening_radius : radius of disk used to compute background via morphological opening - central_fraction : fraction of peak used to identify the primary beam component - central_dilate : dilation radius for central-beam mask (pixels) - thr_sigma : threshold = median + thr_sigma * std (adaptive) - thr_floor : minimal absolute threshold (counts) - max_filter_size : neighborhood size for local-maximum test - method : "localmax" (fast) or "blob_log" (scale-aware blob detection) - - Returns - ------- - result : dict with keys - n_peaks_total, n_peaks_outside_center, peaks (N x 2 array of (y,x)), - processed (top-hat with center masked), thr, center_pos, center_radius + """Fast diffraction detector with radial-binned background subtraction. + + - center estimated via blurred ROI + - excludes (mask == False) and excludes rr <= beam_radius_px + - estimates background as a function of radius using n_radial_bins shells + - subtracts that per-shell background + - detects peaks via global quantile threshold on residual """ + img = image.astype(np.float32) - peak_pos = tuple(np.unravel_index(np.argmax(img), img.shape)) - peak_val = float(img[peak_pos]) - - # background estimate (large-scale) - selem = disk(opening_radius) - background = opening(img, selem) - - top_hat = img - background - top_hat[top_hat < 0] = 0.0 - - # central beam mask via connected component containing the peak - central_thr = peak_val * central_fraction - central_mask = img > central_thr - lbl = label(central_mask) - peak_label = ( - lbl[peak_pos] - if (0 <= peak_pos[0] < lbl.shape[0] and 0 <= peak_pos[1] < lbl.shape[1]) - else 0 - ) - if peak_label != 0: - central_comp = lbl == peak_label - central_comp = binary_dilation(central_comp, footprint=disk(central_dilate)) - else: - central_comp = np.zeros_like(img, dtype=bool) - - processed = top_hat.copy() - processed[central_comp] = 0.0 - - # threshold - adaptive_thr = np.median(processed) + thr_sigma * np.std(processed) - thr = max(thr_floor, adaptive_thr) - - if method == 'localmax': - neighborhood = ndi.maximum_filter(processed, size=max_filter_size) - local_max = (processed == neighborhood) & (processed >= thr) - # remove border pixels - local_max[0, :] = local_max[-1, :] = local_max[:, 0] = local_max[:, -1] = False - peaks = np.column_stack(np.nonzero(local_max)) - elif method == 'blob_log': - # scale-aware detection (might be slower) - blobs = blob_log( - processed, min_sigma=1, max_sigma=4, threshold=thr / float(processed.max() + 1e-12) - ) - # blob_log returns (y, x, sigma) - peaks = blobs[:, :2].astype(int) if blobs.size else np.empty((0, 2), int) - else: - raise ValueError('unknown method') - - total_peaks = peaks.shape[0] - - # central radius (largest distance of any central component pixel from peak) - comp_coords = np.column_stack(np.nonzero(central_comp)) - if comp_coords.size: - comp_dists = np.sqrt(((comp_coords - np.array(peak_pos)) ** 2).sum(axis=1)) - center_radius = float(comp_dists.max()) - else: - center_radius = 0.0 - - # count only those peaks with distance > center_radius - if peaks.shape[0] > 0: - dists = np.sqrt(((peaks - np.array(peak_pos)) ** 2).sum(axis=1)) - outside_mask = dists > center_radius - peaks_outside = peaks[outside_mask] - n_outside = peaks_outside.shape[0] - else: - peaks_outside = np.empty((0, 2), int) - n_outside = 0 + + # center (fast, robust) + cy0, cx0 = np.unravel_index(np.argmax(img), img.shape) + cy, cx = estimate_beam_center(image, expected=(cy0, cx0), roi_half=64, sigma=3.0) + + # geometry + yy, xx = np.indices(img.shape) + rr = np.sqrt((yy - cy) ** 2 + (xx - cx) ** 2) + + # combine masks: user mask AND outside beam radius + valid = mask.astype(bool) if mask is not None else np.ones(img.shape, dtype=bool) + valid &= rr > beam_radius_px + + vals = img[valid] + if vals.size == 0: + return {'has_diffraction': False, 'n_peaks': 0, 'mask': valid, 'center': (cy, cx)} + + # --- radial binning setup --- + # Bin edges: from beam_radius_px to max radius in valid region + r_max = float(rr[valid].max()) + bin_edges = np.linspace(beam_radius_px, r_max, n_radial_bins + 1).astype(np.float32) + + # Assign each valid pixel to a bin id in [0, n_radial_bins-1] + r_valid = rr[valid] + bin_id = np.digitize(r_valid, bin_edges) - 1 + bin_id = np.clip(bin_id, 0, n_radial_bins - 1) + + # --- per-bin background statistic (robust) --- + bg_per_bin = np.zeros(n_radial_bins, dtype=np.float32) + for b in range(n_radial_bins): + v = vals[bin_id == b] + if v.size == 0: + bg_per_bin[b] = 0.0 + else: + if bg_stat == 'median': + bg_per_bin[b] = np.median(v) + elif bg_stat == 'q': + bg_per_bin[b] = np.quantile(v, bg_q) + else: + raise ValueError("bg_stat must be 'median' or 'q'") + + # --- subtract radial background --- + processed = np.zeros_like(img, dtype=np.float32) + processed[valid] = vals - bg_per_bin[bin_id] + processed[processed < 0] = 0.0 + + # --- peak threshold via global quantile --- + pvals = processed[valid] + if pvals.size == 0: + return {'has_diffraction': False, 'n_peaks': 0, 'mask': valid, 'center': (cy, cx)} + + thr_per_bin = np.zeros(n_radial_bins, dtype=np.float32) + for b in range(n_radial_bins): + v = pvals[bin_id == b] + thr_per_bin[b] = 2 * np.percentile(v, q) if v.size else np.inf + + # Each valid pixel compares against its bin's threshold + keep = pvals >= thr_per_bin[bin_id] + + # Build a boolean peak mask efficiently + peak_mask = np.zeros_like(valid, dtype=bool) + valid_idx = np.flatnonzero(valid) + peak_mask.flat[valid_idx[keep]] = True + + peaks = peaks_one_per_cluster(peak_mask, processed, min_dist=5) + n_peaks = int(peaks.shape[0]) + + # Bin radii (useful for plotting) + bin_centers = 0.5 * (bin_edges[:-1] + bin_edges[1:]) return { - 'n_peaks_total': int(total_peaks), - 'n_peaks_outside_center': int(n_outside), + 'has_diffraction': n_peaks >= min_peaks, + 'n_peaks': n_peaks, 'peaks': peaks, - 'peaks_outside': peaks_outside, - 'processed': processed, - 'thr': float(thr), - 'center_pos': peak_pos, - 'center_radius': center_radius, + 'center': (cy, cx), + 'beam_radius': beam_radius_px, + 'thr_per_bin': thr_per_bin, + 'mask': valid, + # radial background diagnostics: + 'radial_bin_edges': bin_edges, # length n_bins+1 + 'radial_bin_centers': bin_centers, # length n_bins + 'bg_per_bin': bg_per_bin, # length n_bins + 'n_radial_bins': n_radial_bins, } +def estimate_beam_center( + image: np.ndarray, + expected: tuple[int, int] | None, + roi_half: int = 64, + sigma: float = 3.0, +): + """Estimate beam center by Gaussian-blurring a small ROI and taking its + maximum. + + expected_center: if None, uses image center + roi_half: ROI is (2*roi_half) x (2*roi_half) + sigma: Gaussian sigma in pixels (2–5 is typical) + """ + h, w = image.shape + cy0, cx0 = expected + + y0 = max(0, cy0 - roi_half) + y1 = min(h, cy0 + roi_half) + x0 = max(0, cx0 - roi_half) + x1 = min(w, cx0 + roi_half) + + roi = image[y0:y1, x0:x1].astype(np.float32) + roi_blur = ndi.gaussian_filter(roi, sigma=sigma, mode='nearest') + iy, ix = np.unravel_index(np.argmax(roi_blur), roi_blur.shape) + return y0 + int(iy), x0 + int(ix) + + +def peaks_one_per_cluster(hot_mask: np.ndarray, intensity: np.ndarray, min_dist: int = 5): + """ + hot_mask: boolean mask of candidate pixels (True = candidate) + intensity: float/int image used to pick the representative (processed) + min_dist: merge radius (pixels). Pixels within ~min_dist get clustered. + Returns: (K,2) array of (y,x) peak positions (one per cluster) + """ + if not hot_mask.any(): + return np.empty((0, 2), dtype=int) + + # Merge nearby pixels into clusters + structure = ndi.generate_binary_structure(2, 1) # 4-connectivity + dilated = ndi.binary_dilation(hot_mask, iterations=min_dist, structure=structure) + + # Label clusters + lbl, n = ndi.label(dilated, structure=structure) + if n == 0: + return np.empty((0, 2), dtype=int) + + peaks = [] + for k in range(1, n + 1): + region = (lbl == k) & hot_mask # restrict back to original hot pixels + ys, xs = np.nonzero(region) + if ys.size == 0: + continue + vals = intensity[ys, xs] + j = int(np.argmax(vals)) + peaks.append((int(ys[j]), int(xs[j]))) + + return np.array(peaks, dtype=int) + + def plot_diffraction_debug(image, results): """Visualize diffraction detection results. @@ -139,39 +190,61 @@ def plot_diffraction_debug(image, results): ax.set_title('Diffraction detection debug') ax.axis('off') + # --- mask overlay (red, 25% opacity) --- + mask = results.get('mask', None) + if mask is not None: + # mask == False → excluded area + overlay = np.zeros((*mask.shape, 4), dtype=np.float32) + overlay[~mask] = (1.0, 0.0, 0.0, 0.25) # RGBA + ax.imshow(overlay) + + # --- bins --- + cy, cx = center = results.get('center', None) + h, w = image.shape + ax.set_xlim(0, w) + ax.set_ylim(h, 0) + + edges = results.get('radial_bin_edges', None) + for r in edges: + ax.add_patch(Circle((cx, cy), float(r), fill=False, linewidth=0.5, clip_on=True)) + # --- peaks --- peaks = results.get('peaks', np.empty((0, 2))) if len(peaks): - ax.scatter(peaks[:, 1], peaks[:, 0], s=10, c='lime', marker='o', label='peaks') - - # --- peaks outside central beam --- - peaks_out = results.get('peaks_outside', np.empty((0, 2))) - if len(peaks_out): - ax.scatter( - peaks_out[:, 1], peaks_out[:, 0], s=14, c='red', marker='x', label='peaks outside' - ) + ax.scatter(peaks[:, 1], peaks[:, 0], s=2, c='lime', marker='o', label='peaks') # --- center --- - center = results.get('center_pos', None) + center = results.get('center', None) if center is not None: - ax.scatter(center[1], center[0], s=40, c='cyan', marker='s', label='center_pos') + ax.scatter(center[1], center[0], s=4, c='cyan', marker='s', label='center_pos') ax.legend(loc='lower right', fontsize=8) plt.tight_layout() plt.show() +def make_cross_mask(): + c = 511 / 2 + yy, xx = np.indices((512, 512)) + vertical = np.abs(xx - c) <= 1.9 + horizontal = np.abs(yy - c) <= 1.9 + cross = vertical | horizontal + return ~cross + + if __name__ == '__main__': from PIL import Image - path = r'C:\Users\tchon\x\Instamatic_RATS_cRED_benchmark\instamatic_19\tiff\00020.tiff' - tiff = Image.open(path) - image = np.array(tiff) - print(f'{image.shape=} {image.dtype=} {image.max()=} {image.min()=}') - t0 = time.perf_counter() - for i in range(10): - results = detect_diffraction_peaks(image) - t1 = time.perf_counter() - print(f'TIME TAKEN: {t1 - t0}, PER ROUND: {(t1 - t0) / 100}') - print(results) - plot_diffraction_debug(image, results) + for i in range(0, 5): + path = rf'C:\Users\tchon\x\Instamatic_RATS_cRED_benchmark\instamatic_19\tiff\000{i:02d}.tiff' + path = rf'C:\Users\tchon\x\granat\experiment_2\tiff\000{i:02d}.tiff' + tiff = Image.open(path) + image = np.array(tiff) + + for h in [10, 20]: + t0 = time.perf_counter() + results = detect_diffraction_fast(image, n_radial_bins=h, mask=make_cross_mask()) + t1 = time.perf_counter() + print(f'TIME TAKEN: {t1 - t0}') + print(len(results['peaks'])) + plot_diffraction_debug(image, results) From 9c9fd1f422f9cee2679d41b3f9c5c3df86caa4f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Wed, 21 Jan 2026 19:29:26 +0100 Subject: [PATCH 016/118] Accurate (40-100ms) algo that successfully finds 0 refls if empty, and up to ~70 if diffraction --- src/instamatic/experiments/sped/detection.py | 180 ++++++++----------- 1 file changed, 75 insertions(+), 105 deletions(-) diff --git a/src/instamatic/experiments/sped/detection.py b/src/instamatic/experiments/sped/detection.py index cb61e90a..6cea37c9 100644 --- a/src/instamatic/experiments/sped/detection.py +++ b/src/instamatic/experiments/sped/detection.py @@ -8,132 +8,104 @@ from scipy import ndimage as ndi -def detect_diffraction_fast( - image: np.ndarray, - beam_radius_px: int = 40, - q: float = 99.9, - min_peaks: int = 5, +def ring_threshold_detection( + frame: np.ndarray, + min_radius: int = 40, + percentile: float = 99.0, + threshold_mult: float = 3.0, + min_peak_count: int = 10, + min_peak_sep: int = 5, mask: np.ndarray | None = None, - n_radial_bins: int = 20, - bg_stat: str = 'median', # "median" or "q" - bg_q: float = 0.8, # used if bg_stat == "q" + n_bins: int = 10, ): """Fast diffraction detector with radial-binned background subtraction. - - center estimated via blurred ROI - - excludes (mask == False) and excludes rr <= beam_radius_px - - estimates background as a function of radius using n_radial_bins shells - - subtracts that per-shell background - - detects peaks via global quantile threshold on residual + - estimate center of incident beam based on a blurred central ROI + - excludes (mask == False) and excludes rr <= beam_radius_px regions + - estimates background and reflection threshold in `n_bins` radial shells + - reflections must exceed `percentile` * `threshold_mult` of their ring + - the algorithm is good at finding a small number of strongest reflections """ - img = image.astype(np.float32) + cy, cx = estimate_beam_center(frame, sigma=3.0) + ys, xs = np.indices(frame.shape) + rr = np.sqrt((ys - cy) ** 2 + (xs - cx) ** 2) + valid = mask.astype(bool) if mask is not None else np.ones(frame.shape, dtype=bool) + valid &= rr > min_radius - # center (fast, robust) - cy0, cx0 = np.unravel_index(np.argmax(img), img.shape) - cy, cx = estimate_beam_center(image, expected=(cy0, cx0), roi_half=64, sigma=3.0) + valid_idx = np.flatnonzero(valid) + if valid_idx.size == 0: + return {'has_diffraction': False, 'n_peaks': 0, 'mask': valid, 'center': (cy, cx)} - # geometry - yy, xx = np.indices(img.shape) - rr = np.sqrt((yy - cy) ** 2 + (xx - cx) ** 2) + vals = frame.flat[valid_idx].astype(np.float32, copy=False) + rr_vals = rr.flat[valid_idx].astype(np.float32, copy=False) - # combine masks: user mask AND outside beam radius - valid = mask.astype(bool) if mask is not None else np.ones(img.shape, dtype=bool) - valid &= rr > beam_radius_px + r_max = float(rr_vals.max()) + bin_edges = np.linspace(min_radius, r_max, n_bins + 1).astype(np.float32) - vals = img[valid] - if vals.size == 0: - return {'has_diffraction': False, 'n_peaks': 0, 'mask': valid, 'center': (cy, cx)} + bin_ids = np.digitize(rr_vals, bin_edges) - 1 + np.clip(bin_ids, 0, n_bins - 1, out=bin_ids) - # --- radial binning setup --- - # Bin edges: from beam_radius_px to max radius in valid region - r_max = float(rr[valid].max()) - bin_edges = np.linspace(beam_radius_px, r_max, n_radial_bins + 1).astype(np.float32) - - # Assign each valid pixel to a bin id in [0, n_radial_bins-1] - r_valid = rr[valid] - bin_id = np.digitize(r_valid, bin_edges) - 1 - bin_id = np.clip(bin_id, 0, n_radial_bins - 1) - - # --- per-bin background statistic (robust) --- - bg_per_bin = np.zeros(n_radial_bins, dtype=np.float32) - for b in range(n_radial_bins): - v = vals[bin_id == b] - if v.size == 0: - bg_per_bin[b] = 0.0 - else: - if bg_stat == 'median': - bg_per_bin[b] = np.median(v) - elif bg_stat == 'q': - bg_per_bin[b] = np.quantile(v, bg_q) - else: - raise ValueError("bg_stat must be 'median' or 'q'") - - # --- subtract radial background --- - processed = np.zeros_like(img, dtype=np.float32) - processed[valid] = vals - bg_per_bin[bin_id] - processed[processed < 0] = 0.0 - - # --- peak threshold via global quantile --- - pvals = processed[valid] - if pvals.size == 0: - return {'has_diffraction': False, 'n_peaks': 0, 'mask': valid, 'center': (cy, cx)} + # Per-bin bg and thresholds (still a small loop) + backgrounds = np.zeros(n_bins, dtype=np.float32) + thresholds = np.full(n_bins, np.inf, dtype=np.float32) - thr_per_bin = np.zeros(n_radial_bins, dtype=np.float32) - for b in range(n_radial_bins): - v = pvals[bin_id == b] - thr_per_bin[b] = 2 * np.percentile(v, q) if v.size else np.inf + # residuals in 1D + resid_all = np.zeros_like(vals, dtype=np.float32) - # Each valid pixel compares against its bin's threshold - keep = pvals >= thr_per_bin[bin_id] + for b in range(n_bins): + sel = bin_ids == b + if not np.any(sel): + continue + v = vals[sel] + bg = np.median(v) + backgrounds[b] = bg - # Build a boolean peak mask efficiently - peak_mask = np.zeros_like(valid, dtype=bool) - valid_idx = np.flatnonzero(valid) + r = v - bg + r[r < 0] = 0.0 + resid_all[sel] = r + + thresholds[b] = threshold_mult * np.percentile(r, percentile) if r.size else np.inf + + # Candidate selection purely in 1D + keep = resid_all >= thresholds[bin_ids] + + # Scatter to 2D only once (needed for clustering / argmax) + processed = np.zeros(frame.shape, dtype=np.float32) + processed.flat[valid_idx] = resid_all + + peak_mask = np.zeros(frame.shape, dtype=bool) peak_mask.flat[valid_idx[keep]] = True - peaks = peaks_one_per_cluster(peak_mask, processed, min_dist=5) + peaks = peaks_one_per_cluster(peak_mask, processed, min_dist=min_peak_sep) n_peaks = int(peaks.shape[0]) - # Bin radii (useful for plotting) bin_centers = 0.5 * (bin_edges[:-1] + bin_edges[1:]) return { - 'has_diffraction': n_peaks >= min_peaks, + 'has_diffraction': n_peaks >= min_peak_count, 'n_peaks': n_peaks, 'peaks': peaks, 'center': (cy, cx), - 'beam_radius': beam_radius_px, - 'thr_per_bin': thr_per_bin, + 'beam_radius': min_radius, + 'thr_per_bin': thresholds, 'mask': valid, - # radial background diagnostics: - 'radial_bin_edges': bin_edges, # length n_bins+1 - 'radial_bin_centers': bin_centers, # length n_bins - 'bg_per_bin': bg_per_bin, # length n_bins - 'n_radial_bins': n_radial_bins, + 'radial_bin_edges': bin_edges, + 'radial_bin_centers': bin_centers, + 'bg_per_bin': backgrounds, + 'n_radial_bins': n_bins, } -def estimate_beam_center( - image: np.ndarray, - expected: tuple[int, int] | None, - roi_half: int = 64, - sigma: float = 3.0, -): - """Estimate beam center by Gaussian-blurring a small ROI and taking its - maximum. - - expected_center: if None, uses image center - roi_half: ROI is (2*roi_half) x (2*roi_half) - sigma: Gaussian sigma in pixels (2–5 is typical) - """ +def estimate_beam_center(frame: np.ndarray, sigma: float = 3.0): + """Estimate beam center by Gaussian-blurring a small ROI and taking max.""" h, w = image.shape - cy0, cx0 = expected + cy0, cx0 = np.unravel_index(np.argmax(frame), frame.shape) - y0 = max(0, cy0 - roi_half) - y1 = min(h, cy0 + roi_half) - x0 = max(0, cx0 - roi_half) - x1 = min(w, cx0 + roi_half) + y0 = max(0, cy0 - h // 8) + y1 = min(h - 1, cy0 + h // 8) + x0 = max(0, cx0 - w // 8) + x1 = min(w - 1, cx0 + w // 8) roi = image[y0:y1, x0:x1].astype(np.float32) roi_blur = ndi.gaussian_filter(roi, sigma=sigma, mode='nearest') @@ -235,16 +207,14 @@ def make_cross_mask(): if __name__ == '__main__': from PIL import Image - for i in range(0, 5): + for i in range(0, 80): path = rf'C:\Users\tchon\x\Instamatic_RATS_cRED_benchmark\instamatic_19\tiff\000{i:02d}.tiff' - path = rf'C:\Users\tchon\x\granat\experiment_2\tiff\000{i:02d}.tiff' tiff = Image.open(path) image = np.array(tiff) - for h in [10, 20]: - t0 = time.perf_counter() - results = detect_diffraction_fast(image, n_radial_bins=h, mask=make_cross_mask()) - t1 = time.perf_counter() - print(f'TIME TAKEN: {t1 - t0}') - print(len(results['peaks'])) - plot_diffraction_debug(image, results) + t0 = time.perf_counter() + results = ring_threshold_detection(image, mask=make_cross_mask()) + t1 = time.perf_counter() + print(f'TIME TAKEN: {t1 - t0}') + print(len(results['peaks'])) + plot_diffraction_debug(image, results) From 4b1cd2103fe0d0d98a886914c7c75d89fd979a16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Thu, 22 Jan 2026 17:28:55 +0100 Subject: [PATCH 017/118] Optimized (20-30 ms) algo that successfully finds 0 refls if empty, and up to ~70 if diffraction --- src/instamatic/experiments/sped/detection.py | 182 +++++++++---------- 1 file changed, 87 insertions(+), 95 deletions(-) diff --git a/src/instamatic/experiments/sped/detection.py b/src/instamatic/experiments/sped/detection.py index 6cea37c9..80855e9f 100644 --- a/src/instamatic/experiments/sped/detection.py +++ b/src/instamatic/experiments/sped/detection.py @@ -1,6 +1,9 @@ +"""This module collects functions responsible for identifying diffraction.""" + from __future__ import annotations -import time +from dataclasses import dataclass +from typing import Optional, Sequence import matplotlib.pyplot as plt import numpy as np @@ -8,7 +11,18 @@ from scipy import ndimage as ndi -def ring_threshold_detection( +@dataclass +class DiffHuntResults: + """Stores and normalizes basic results of diffraction detection.""" + + success: bool + bin_center: Optional[tuple[float, float]] = None + bin_edges: Optional[Sequence[float]] = None + peaks: Optional[np.ndarray] = None + mask: Optional[np.ndarray] = None + + +def ring_quartile_detection( frame: np.ndarray, min_radius: int = 40, percentile: float = 99.0, @@ -35,7 +49,7 @@ def ring_threshold_detection( valid_idx = np.flatnonzero(valid) if valid_idx.size == 0: - return {'has_diffraction': False, 'n_peaks': 0, 'mask': valid, 'center': (cy, cx)} + DiffHuntResults(success=False, bin_center=(cy, cx), mask=valid) vals = frame.flat[valid_idx].astype(np.float32, copy=False) rr_vals = rr.flat[valid_idx].astype(np.float32, copy=False) @@ -50,9 +64,6 @@ def ring_threshold_detection( backgrounds = np.zeros(n_bins, dtype=np.float32) thresholds = np.full(n_bins, np.inf, dtype=np.float32) - # residuals in 1D - resid_all = np.zeros_like(vals, dtype=np.float32) - for b in range(n_bins): sel = bin_ids == b if not np.any(sel): @@ -61,135 +72,120 @@ def ring_threshold_detection( bg = np.median(v) backgrounds[b] = bg - r = v - bg - r[r < 0] = 0.0 - resid_all[sel] = r - - thresholds[b] = threshold_mult * np.percentile(r, percentile) if r.size else np.inf + threshold_perc = max(1.0, np.percentile(v, percentile) - bg) + thresholds[b] = threshold_mult * threshold_perc if v.size else np.inf # Candidate selection purely in 1D - keep = resid_all >= thresholds[bin_ids] + keep = vals >= (thresholds[bin_ids] + backgrounds[bin_ids]) # Scatter to 2D only once (needed for clustering / argmax) - processed = np.zeros(frame.shape, dtype=np.float32) - processed.flat[valid_idx] = resid_all - peak_mask = np.zeros(frame.shape, dtype=bool) peak_mask.flat[valid_idx[keep]] = True - peaks = peaks_one_per_cluster(peak_mask, processed, min_dist=min_peak_sep) + peaks = cluster_peak_mask(peak_mask, frame, min_dist=min_peak_sep) n_peaks = int(peaks.shape[0]) - bin_centers = 0.5 * (bin_edges[:-1] + bin_edges[1:]) - - return { - 'has_diffraction': n_peaks >= min_peak_count, - 'n_peaks': n_peaks, - 'peaks': peaks, - 'center': (cy, cx), - 'beam_radius': min_radius, - 'thr_per_bin': thresholds, - 'mask': valid, - 'radial_bin_edges': bin_edges, - 'radial_bin_centers': bin_centers, - 'bg_per_bin': backgrounds, - 'n_radial_bins': n_bins, - } + return DiffHuntResults( + success=n_peaks >= min_peak_count, + bin_center=(cy, cx), + bin_edges=bin_edges, + peaks=peaks, + mask=valid, + ) -def estimate_beam_center(frame: np.ndarray, sigma: float = 3.0): +def estimate_beam_center(frame: np.ndarray, sigma: float = 3.0) -> tuple[int, int]: """Estimate beam center by Gaussian-blurring a small ROI and taking max.""" - h, w = image.shape + h, w = frame.shape cy0, cx0 = np.unravel_index(np.argmax(frame), frame.shape) y0 = max(0, cy0 - h // 8) - y1 = min(h - 1, cy0 + h // 8) + y1 = min(h, cy0 + h // 8) x0 = max(0, cx0 - w // 8) - x1 = min(w - 1, cx0 + w // 8) + x1 = min(w, cx0 + w // 8) - roi = image[y0:y1, x0:x1].astype(np.float32) + roi = frame[y0:y1, x0:x1].astype(np.float32) roi_blur = ndi.gaussian_filter(roi, sigma=sigma, mode='nearest') iy, ix = np.unravel_index(np.argmax(roi_blur), roi_blur.shape) return y0 + int(iy), x0 + int(ix) -def peaks_one_per_cluster(hot_mask: np.ndarray, intensity: np.ndarray, min_dist: int = 5): +NEIGHBOUR_PLUS = ndi.generate_binary_structure(2, 1) + + +def cluster_peak_mask(peak_mask: np.ndarray, frame: np.ndarray, min_dist: int = 5): """ - hot_mask: boolean mask of candidate pixels (True = candidate) - intensity: float/int image used to pick the representative (processed) + peak_mask: boolean mask of all peak-candidate pixels (True = candidate) + frame: image used to pick the representative (processed) min_dist: merge radius (pixels). Pixels within ~min_dist get clustered. - Returns: (K,2) array of (y,x) peak positions (one per cluster) + Returns: (K,2) array of (y,x) peak positions (one per cluster @ peak_mask) """ - if not hot_mask.any(): - return np.empty((0, 2), dtype=int) - # Merge nearby pixels into clusters - structure = ndi.generate_binary_structure(2, 1) # 4-connectivity - dilated = ndi.binary_dilation(hot_mask, iterations=min_dist, structure=structure) + if not peak_mask.any(): + return np.empty((0, 2), dtype=int) - # Label clusters - lbl, n = ndi.label(dilated, structure=structure) + # define a region around each peak found on peak_mask and label them + dilated = ndi.binary_dilation(peak_mask, iterations=min_dist, structure=NEIGHBOUR_PLUS) + lbl, n = ndi.label(dilated, structure=NEIGHBOUR_PLUS) if n == 0: return np.empty((0, 2), dtype=int) + # limit the view to only regions with candidate pixels (not entire frame) + ys, xs = np.nonzero(peak_mask) + labs = lbl[ys, xs] # label per candidate pixel (1..n) + vals = frame[ys, xs] # raw intensity per candidate pixel + + # drop candidates that somehow map to background (shouldn't happen) + keep = labs > 0 + ys, xs, labs, vals = ys[keep], xs[keep], labs[keep], vals[keep] + + # for each label, choose index of max intensity + order = np.argsort(labs, kind='stable') + ys, xs, labs, vals = ys[order], xs[order], labs[order], vals[order] + + # find segment boundaries and for each segment, find max within that segment + boundaries = np.flatnonzero(np.r_[True, labs[1:] != labs[:-1]]) peaks = [] - for k in range(1, n + 1): - region = (lbl == k) & hot_mask # restrict back to original hot pixels - ys, xs = np.nonzero(region) - if ys.size == 0: - continue - vals = intensity[ys, xs] - j = int(np.argmax(vals)) + for i, start in enumerate(boundaries): + end = boundaries[i + 1] if i + 1 < len(boundaries) else len(labs) + j = start + int(np.argmax(vals[start:end])) peaks.append((int(ys[j]), int(xs[j]))) return np.array(peaks, dtype=int) -def plot_diffraction_debug(image, results): - """Visualize diffraction detection results. - - - grayscale log image - - green dots: all detected peaks - - red dots: peaks outside central beam - - cyan dot: center - """ - - # --- log-scaled image --- - img_log = np.log10(image.astype(np.float32) + 1.0) +def plot_diffraction_debug( + frame: np.ndarray, + results: DiffHuntResults, +) -> None: + """Visualize detection results: log-scale image, dots @ peaks & center.""" fig, ax = plt.subplots(figsize=(6, 6)) - ax.imshow(img_log, cmap='gray') + h, w = frame.shape + ax.set_xlim(0, w) + ax.set_ylim(h, 0) ax.set_title('Diffraction detection debug') ax.axis('off') - # --- mask overlay (red, 25% opacity) --- - mask = results.get('mask', None) - if mask is not None: - # mask == False → excluded area + img_log = np.log10(frame.astype(np.float32) + 1.0) + ax.imshow(img_log, cmap='gray') + + if (mask := results.mask) is not None: # False == excluded areas = red tint overlay = np.zeros((*mask.shape, 4), dtype=np.float32) overlay[~mask] = (1.0, 0.0, 0.0, 0.25) # RGBA ax.imshow(overlay) - # --- bins --- - cy, cx = center = results.get('center', None) - h, w = image.shape - ax.set_xlim(0, w) - ax.set_ylim(h, 0) + if (center := results.bin_center) is not None: # bin center and edges + cy, cx = center + ax.scatter(center[1], center[0], s=4, c='cyan', marker='s', label='center') + if (edges := results.bin_edges) is not None: + for r in edges: + p = Circle((cx, cy), float(r), fill=False, linewidth=0.5, clip_on=True) + ax.add_patch(p) - edges = results.get('radial_bin_edges', None) - for r in edges: - ax.add_patch(Circle((cx, cy), float(r), fill=False, linewidth=0.5, clip_on=True)) - - # --- peaks --- - peaks = results.get('peaks', np.empty((0, 2))) - if len(peaks): + if (peaks := results.peaks) is not None: ax.scatter(peaks[:, 1], peaks[:, 0], s=2, c='lime', marker='o', label='peaks') - # --- center --- - center = results.get('center', None) - if center is not None: - ax.scatter(center[1], center[0], s=4, c='cyan', marker='s', label='center_pos') - ax.legend(loc='lower right', fontsize=8) plt.tight_layout() plt.show() @@ -207,14 +203,10 @@ def make_cross_mask(): if __name__ == '__main__': from PIL import Image - for i in range(0, 80): - path = rf'C:\Users\tchon\x\Instamatic_RATS_cRED_benchmark\instamatic_19\tiff\000{i:02d}.tiff' + mask = make_cross_mask() + for i in range(0, 50): + path = rf'C:\Users\tchon\x\Instamatic_RATS_cRED_benchmark\instamatic_19\tiff\00{i:03d}.tiff' tiff = Image.open(path) image = np.array(tiff) - - t0 = time.perf_counter() - results = ring_threshold_detection(image, mask=make_cross_mask()) - t1 = time.perf_counter() - print(f'TIME TAKEN: {t1 - t0}') - print(len(results['peaks'])) + results = ring_quartile_detection(image, mask=mask) plot_diffraction_debug(image, results) From e568f92234e8e80fc6e9f11016b200a570a3d703 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Thu, 22 Jan 2026 20:49:19 +0100 Subject: [PATCH 018/118] Working implementation of Ulam spiral in hex and rect space for Grid window indexing --- src/instamatic/experiments/sped/pairing.py | 206 +++++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 src/instamatic/experiments/sped/pairing.py diff --git a/src/instamatic/experiments/sped/pairing.py b/src/instamatic/experiments/sped/pairing.py new file mode 100644 index 00000000..66effc40 --- /dev/null +++ b/src/instamatic/experiments/sped/pairing.py @@ -0,0 +1,206 @@ +"""This module deals with pairing functions used to map between two coordinate +systems: a 1D-periodic series of natural numbers infinite in one direction +and a 2D-periodic lattice of whole numbers infinite in both directions. +Either space is to be used when indexing individual windows on a sample grid: +1D for ordering on a list and 2D for locating in 2D ij-indexed space. + +The space indexing schemes used in this module are as follows: +- ij - orthogonal 2D grid: i goes right (alongside x), j goes up (with y); + This is the typical Cartesian setting used in mathematics. +- ulam - 1D idx of ortho 2D grid: 0=center, 1 right, then spiral anti-clockwise. + Cartesian distance between two subsequent cells is always 1. + Maximum distance from zero grows steadily step-wise: 1x0, 8x1, 16x2, 24x3... +- uv - hexagonal 2D grid (side-flat): u goes right, v +120deg anti-clockwise + Most popular hexagonal setting, i+1,j or i,j+1 distance is always 2r +- spiral - 1D idx of hex 2D grid: 0=center, 1 right, then spiral anti-clockwise + Cartesian distance between two subsequent cells is always 2r. + Hex-Manhattan distance from zero grows steadily step-wise: 1x0, 6x1, 12x2... + +For further details on the qrs space, see: +https://www.redblobgames.com/grids/hexagons/ and https://doi.org/10.1117/1.JEI.22.1.010502 +""" + +from __future__ import annotations + +import math + + +def ulam2ij(u: int) -> tuple[int, int]: + """Convert from index in 1D Ulam to orthogonal (i, j) coordinates.""" + if u < 0: + i, j = ulam2ij(-u) + return -i, -j + if u == 0: + return 0, 0 + + # Calculate Chebyshev distance k in the ij-space: + # k-th ring starts at minimum value of u0 = (2k-1)^2 at coords (k, 1-k) + # k-th ring ends at maximum value of u1 = (2k+1)^2-1 at coords (k, -k) + k = math.ceil((math.sqrt(u + 1) - 1) / 2) + u0 = (2 * k - 1) ** 2 + offset = u - u0 + + if offset <= 2 * k - 1: # segment 1: go upwards from bottom-right corner + return k, -k + 1 + offset + elif offset <= 4 * k - 1: # segment 2: go left from top-right corner + return 3 * k - 1 - offset, k + elif offset <= 6 * k - 1: # segment 3: go down from top-left corner + return -k, 5 * k - 1 - offset + return offset - 7 * k + 1, -k # segment 4: go right from bottom-left corner + + +def ij2ulam(i: int, j: int) -> int: + """Convert from index in orthogonal (i, j) to 1D Ulam coordinates.""" + if i == 0 and j == 0: + return 0 + + k = max(abs(i), abs(j)) # Chebyshev distance k in the ij-space + u0 = (2 * k - 1) ** 2 # Lowest Ulam index on ring k at coords (k, 1-k) + + if i == k and -k + 1 <= j <= k: # segment 1 + return u0 + j + k - 1 + elif j == k and -k <= i <= (k - 1): # segment 2 + return u0 + (2 * k - 1) + (k - i) + elif i == -k and -k <= j <= (k - 1): # segment 3 + return u0 + (4 * k - 1) + (k - j) + return u0 + (6 * k - 1) + (i + k) # segment 4 + + +# Spiral directions (axial-like coords): +# start of ring k is (k, 0), then walk CCW with these step directions +_DIRS: list[tuple[int, int]] = [(0, 1), (-1, 0), (-1, -1), (0, -1), (1, 0), (1, 1)] + + +def spiral2uv(n: int) -> tuple[int, int]: + """Convert from 1D hex spiral index to hexagonal (u, v) coordinates. + + Inverse of the user's uv2spiral() that: + - ring k starts at (u,v) = (1, 1-k) (right above bottom-right corner) + - walks counter-clockwise with 6 segments, each of length k + - ring k has 6k points, indices s0..s0+6k-1 + """ + if n < 0: + raise ValueError('n must be >= 0') + if n == 0: + return (0, 0) + + # Find ring k such that max index on ring k is 3*k*(k+1) + k = math.ceil((math.sqrt(12 * n + 9) - 3) / 6) + + # First index on ring k + s0 = 1 + 3 * (k - 1) * k + t = n - s0 # offset along ring: 0 .. 6k-1 + + if not (0 <= t <= 6 * k - 1): + raise AssertionError(f'Internal error: n={n}, k={k}, s0={s0}, t={t}') + + # Segment 1: up the right-bottom edge (u-v=k), u = 1..k + if t < k: + u = t + 1 + v = u - k + return (u, v) + + # Segment 2: up the right-up edge (u=k), v = 1..k + if t < 2 * k: + u = k + v = (t - k) + 1 + return (u, v) + + # Segment 3: along the top edge (v=k), u = k-1 .. 0 + if t < 3 * k: + v = k + u = (3 * k - 1) - t + return (u, v) + + # Segment 4: down the upper-left edge (v-u=k), u = -1 .. -k + if t < 4 * k: + u = (3 * k) - t - 1 + v = u + k + return (u, v) + + # Segment 5: down the left edge (u=-k), v = -1 .. -k + if t < 5 * k: + u = -k + v = (4 * k) - t - 1 + return (u, v) + + # Segment 6: along the bottom edge (v=-k), u = -k+1 .. 0 + v = -k + u = t - 6 * k + 1 + return (u, v) + + +def uv2spiral(u: int, v: int) -> int: + """Convert from index in hexagonal (u, v) coordinates to 1D hex spiral.""" + + if u == 0 and v == 0: + return 0 + + k = max(abs(u), abs(v), abs(u - v)) # Hex "radius" in (u,v) system + s0 = 1 + 3 * (k - 1) * k # first index on ring k + # point with lowest s0 lies right above bottom right corner of the hexagon + + if u - v == k and u > 0: # segment 1: up the right-bottom edge + return s0 + u - 1 + elif u == k: # segment 2: up the right-up edge + return s0 + k + v - 1 + elif v == k: # segment 3: right the top edge + return s0 + 2 * k - u + v - 1 + elif v - u == k: + return s0 + 3 * k - u - 1 + elif u == -k: + return s0 + 4 * k - v - 1 + return s0 + 5 * k + u - v - 1 + + +if __name__ == '__main__': # tests + """Map ulam and spiral indices onto a 2x2 matrix of (i,j) coordinates.""" + + import numpy as np + + # grid definition + xs = np.arange(-4, 5) + ys = np.arange(4, -5, -1) # top row first: (·,4) down to (·,-4) + + # 9x9x2 array of (i,j) + ij_grid = np.empty((9, 9, 2), dtype=int) + for r, j in enumerate(ys): + for c, i in enumerate(xs): + ij_grid[r, c] = (i, j) + + # pretty-print ij grid + print('ij grid:') + for row in ij_grid: + print(' '.join(f'({i:2d},{j:2d})' for i, j in row)) + + print() + + # 9x9 array of Ulam indices + ulam_grid = np.empty((9, 9), dtype=int) + for r in range(9): + for c in range(9): + i, j = ij_grid[r, c] + ulam_grid[r, c] = ij2ulam(i, j) + + # pretty-print ulam grid + print('Ulam index grid:') + for row in ulam_grid: + print(' '.join(f'{n:4d}' for n in row)) + + print() + + # 9x9 array of Spiral indices + spiral_grid = np.empty((9, 9), dtype=int) + for r in range(9): + for c in range(9): + u, v = ij_grid[r, c] + spiral_grid[r, c] = uv2spiral(u, v) + + # pretty-print ulam grid + print('Spiral index grid:') + for i, row in enumerate(spiral_grid): + print(' ' * i + ' '.join(f'{n:3d}' for n in row)) + + for i in range(100): + assert ij2ulam(*ulam2ij(i)) == i + assert uv2spiral(*spiral2uv(i)) == i, f'Mismatch for {i}' From 95cfa1cff54844a3d0bf5fa98588651f3768e4ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Fri, 23 Jan 2026 15:45:10 +0100 Subject: [PATCH 019/118] Define an initial shape of SPEDState class that will hold experiment history --- src/instamatic/experiments/sped/state.py | 63 +++++++++++++++++++ .../{experiments/sped => grid}/pairing.py | 2 +- 2 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 src/instamatic/experiments/sped/state.py rename src/instamatic/{experiments/sped => grid}/pairing.py (99%) diff --git a/src/instamatic/experiments/sped/state.py b/src/instamatic/experiments/sped/state.py new file mode 100644 index 00000000..17983f45 --- /dev/null +++ b/src/instamatic/experiments/sped/state.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import numpy as np +import pandas as pd + +from instamatic.grid.window import ConvexPolygonGridWindow + + +class SPEDState: + """Stores the current state of the SPED experiment in history dataframe.""" + + def __init__(self) -> None: + self.grids: list[int] = [] + self.windows: dict[tuple[int, int], ConvexPolygonGridWindow] = {} + self.scans: pd.DataFrame = pd.DataFrame() + self.steps: pd.DataFrame = pd.DataFrame() + self.init_dataframes() + + def init_dataframes(self) -> None: + """Create a new empty history with required index and columns.""" + self.scans = pd.DataFrame( + { + 'grid': pd.Series(dtype=np.uint8), + 'window': pd.Series(dtype=np.uint16), + 'scan': pd.Series(dtype=np.uint16), + 'x0': pd.Series(dtype=np.int32), + 'y0': pd.Series(dtype=np.int32), + 'direction': pd.Series(dtype=np.str_), + 'span': pd.Series(dtype=np.uint32), + } + ) + self.scans.set_index(['grid', 'window', 'scan'], inplace=True) + self.steps = pd.DataFrame( + { + 'grid': pd.Series(dtype=np.uint8), + 'window': pd.Series(dtype=np.uint16), + 'scan': pd.Series(dtype=np.uint16), + 'step': pd.Series(dtype=np.uint16), + 'success': pd.Series(dtype=pd.BooleanDtype), + 'n_peaks': pd.Series(dtype=np.uint16), + } + ) + self.steps.set_index(['grid', 'window', 'scan', 'step'], inplace=True) + + def add_scan(self, grid: int, window: int, scan: int, n_frames: int) -> None: + """Pre-allocate scan space in the history dataframe for performance.""" + new = pd.DataFrame( + { + 'grid': np.full(n_frames, grid, dtype=np.uint8), + 'window': np.full(n_frames, window, dtype=np.uint16), + 'scan': np.full(n_frames, scan, dtype=np.uint16), + 'step': np.arange(n_frames, dtype=np.uint16), + 'success': pd.array([pd.NA] * n_frames, dtype='boolean'), + 'n_peaks': np.zeros(n_frames, dtype=np.uint16), + } + ) + new.set_index(['grid', 'window', 'scan', 'step'], inplace=True) + self.steps = pd.concat([self.steps, new], copy=False) + + def add_step(self, idx, *, success: bool, n_peaks: int) -> None: + """Add a single result line to the history dataframe.""" + self.steps.at[idx, 'success'] = bool(success) + self.steps.at[idx, 'n_peaks'] = int(n_peaks) diff --git a/src/instamatic/experiments/sped/pairing.py b/src/instamatic/grid/pairing.py similarity index 99% rename from src/instamatic/experiments/sped/pairing.py rename to src/instamatic/grid/pairing.py index 66effc40..152bf187 100644 --- a/src/instamatic/experiments/sped/pairing.py +++ b/src/instamatic/grid/pairing.py @@ -196,7 +196,7 @@ def uv2spiral(u: int, v: int) -> int: u, v = ij_grid[r, c] spiral_grid[r, c] = uv2spiral(u, v) - # pretty-print ulam grid + # pretty-print spiral indices print('Spiral index grid:') for i, row in enumerate(spiral_grid): print(' ' * i + ' '.join(f'{n:3d}' for n in row)) From 14691a01d61583be0a0ea460cf4cf5901d6bdeab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Fri, 23 Jan 2026 20:58:30 +0100 Subject: [PATCH 020/118] Adding Grid implementation --- src/instamatic/_collections.py | 31 ++- src/instamatic/experiments/sped/experiment.py | 4 +- src/instamatic/experiments/sped/state.py | 4 +- src/instamatic/grid/grid.py | 180 ++++++++++++++++++ src/instamatic/grid/pairing.py | 9 + src/instamatic/grid/window.py | 27 ++- 6 files changed, 246 insertions(+), 9 deletions(-) create mode 100644 src/instamatic/grid/grid.py diff --git a/src/instamatic/_collections.py b/src/instamatic/_collections.py index a2c9852b..3d1d1ba7 100644 --- a/src/instamatic/_collections.py +++ b/src/instamatic/_collections.py @@ -3,8 +3,12 @@ import logging import string from collections import UserDict +from collections.abc import MutableMapping from dataclasses import dataclass -from typing import Any +from typing import Any, Iterator, TypeVar + +T1 = TypeVar('T1') +T2 = TypeVar('T2') class NoOverwriteDict(UserDict): @@ -53,3 +57,28 @@ def format_field(self, value: Any, format_spec: str) -> str: partial_formatter = PartialFormatter() + + +class VersionedDict(MutableMapping[T1, T2]): + """A dict whose version changes with every mutation; useful for caching.""" + + def __init__(self) -> None: + self._d: dict[T1, T2] = {} + self.version = 0 + + def __getitem__(self, k: T1) -> T2: + return self._d[k] + + def __iter__(self) -> Iterator[T1]: + return iter(self._d) + + def __len__(self) -> int: + return len(self._d) + + def __setitem__(self, k, v) -> None: + self._d[k] = v + self.version += 1 + + def __delitem__(self, k) -> None: + del self._d[k] + self.version += 1 diff --git a/src/instamatic/experiments/sped/experiment.py b/src/instamatic/experiments/sped/experiment.py index b88e2d49..46a23096 100644 --- a/src/instamatic/experiments/sped/experiment.py +++ b/src/instamatic/experiments/sped/experiment.py @@ -8,7 +8,7 @@ from instamatic.calibrate.calibrate_stage_translation import CalibStageTranslationX from instamatic.experiments.experiment_base import ExperimentBase from instamatic.experiments.fast_adt.experiment import FastADTMissingCalibError -from instamatic.grid.window import RectangularGridWindow +from instamatic.grid.window import RectangularWindow class Experiment(ExperimentBase): @@ -63,7 +63,7 @@ def start_collection(self, **params) -> None: self.determine_translation_speed() # plan the scanning of current grid window - win = RectangularGridWindow.from_sweeping(order=3) + win = RectangularWindow.from_sweeping(order=3) y = np.min(win.corners[:, 1]) + (0.5 * self.xy_resolution) scans: dict[int, tuple[float, float]] = {} for i, x in enumerate(win.x_intersections(y)): diff --git a/src/instamatic/experiments/sped/state.py b/src/instamatic/experiments/sped/state.py index 17983f45..a3091fa4 100644 --- a/src/instamatic/experiments/sped/state.py +++ b/src/instamatic/experiments/sped/state.py @@ -3,7 +3,7 @@ import numpy as np import pandas as pd -from instamatic.grid.window import ConvexPolygonGridWindow +from instamatic.grid.window import ConvexPolygonWindow class SPEDState: @@ -11,7 +11,7 @@ class SPEDState: def __init__(self) -> None: self.grids: list[int] = [] - self.windows: dict[tuple[int, int], ConvexPolygonGridWindow] = {} + self.windows: dict[tuple[int, int], ConvexPolygonWindow] = {} self.scans: pd.DataFrame = pd.DataFrame() self.steps: pd.DataFrame = pd.DataFrame() self.init_dataframes() diff --git a/src/instamatic/grid/grid.py b/src/instamatic/grid/grid.py new file mode 100644 index 00000000..cfeb2f97 --- /dev/null +++ b/src/instamatic/grid/grid.py @@ -0,0 +1,180 @@ +from __future__ import annotations + +import copy +from typing import Annotated, Generic, Tuple, TypeVar, Union, cast + +import numpy as np +from matplotlib import pyplot as plt +from matplotlib.axes import Axes +from matplotlib.figure import Figure +from matplotlib.patches import Polygon +from matplotlib.ticker import FuncFormatter + +from instamatic._collections import VersionedDict +from instamatic._typing import float_nm, int_nm +from instamatic.grid.pairing import * +from instamatic.grid.window import ConvexPolygonWindow, HexagonalWindow, RectangularWindow +from instamatic.utils.iterating import pairwise + +DualIndex = tuple[int, int] +UlamIndex = Annotated[int, 'positive'] +WindowIndex = Union[DualIndex, UlamIndex] +WindowType = TypeVar('WindowType', bound=ConvexPolygonWindow) + + +class ConvexPolygonGrid(Generic[WindowType]): + window_type: type[WindowType] + pairing_function: PairingFunction + pairing_inverse: PairingInverse + + def __init__(self) -> None: + self.windows: VersionedDict[int, WindowType] = VersionedDict() + self.default_spacing: int_nm = 10_000 + self._spacing_cache_version = 0 + self._spacing = 10_000 + + def _estimate_spacing(self) -> float_nm: + """Estimate actual spacing between all defined windows.""" + if 0 not in self.windows or len(self.windows) < 2: + return float(self.default_spacing) + + w0 = self.windows[0] + w_axis = np.asarray(w0.w_axis, dtype=float) + h_axis = np.asarray(w0.h_axis, dtype=float) + w_hat = w_axis / np.linalg.norm(w_axis) + h_hat = h_axis / np.linalg.norm(h_axis) + + ijs = self.windows_ij.astype(float) # (N,2) + centers = self.windows_xy.astype(float) # (N,2) + deltas = centers - np.asarray(w0.center, dtype=float) + + mask = ~((ijs[:, 0] == 0) & (ijs[:, 1] == 0)) + ijs = ijs[mask] + deltas = deltas[mask] + + # Solve deltas ≈ [i j] @ [w_step; h_step] + # i.e. two independent least squares, one per coordinate component. + m, *_ = np.linalg.lstsq(ijs, deltas, rcond=None) + step_w, step_h = m[0], m[1] + + # Only use estimates along w/h axis if i/j coordinate changes + s_candidates: list[float] = [] + if np.any(ijs[:, 0] != 0): + if np.isfinite(s_w := float(np.dot(step_w - 2.0 * w_axis, w_hat))): + s_candidates.append(s_w) + if np.any(ijs[:, 1] != 0): + if np.isfinite(s_h := float(np.dot(step_h - 2.0 * h_axis, h_hat))): + s_candidates.append(s_h) + + if not s_candidates: + return float(self.default_spacing) + return float(max(0.0, float(np.mean(s_candidates)))) + + @property + def coords(self) -> tuple[np.ndarray, np.ndarray]: + """Coordinate vectors along "w" and "h" dirs derived from window 0.""" + s = float(self.spacing) + w0 = self.windows[0] + step_w = 2.0 * w0.w_axis + s * w0.w_axis / np.linalg.norm(w0.w_axis) + step_h = 2.0 * w0.h_axis + s * w0.h_axis / np.linalg.norm(w0.h_axis) + return step_w, step_h + + @property + def spacing(self) -> float_nm: + """Cached property of self.windows: stores spacing between windows.""" + if self._spacing_cache_version < self.windows.version: + self._spacing = self._estimate_spacing() + self._spacing_cache_version = self.windows.version + return self._spacing + + @property + def windows_ij(self) -> np.ndarray: + """A Nx2 array of all existing window dual indices in windows order.""" + ulam_indices = list(self.windows.keys()) + return np.array([self.pairing_inverse(u) for u in ulam_indices], dtype=int) + + @property + def windows_xy(self) -> np.ndarray: + """A Nx2 array of all existing window centers in windows order.""" + return np.array([w.center for w in self.windows.values()], dtype=float) + + def nearest_window(self, idx: WindowIndex) -> UlamIndex: + """Return Ulam index of existing window nearest to the one with idx.""" + predicted_center = self.predict_center(idx) + offsets2 = np.sum((self.windows_xy - predicted_center) ** 2, axis=1) + nearest = int(np.argmin(offsets2)) + return list(self.windows.keys())[nearest] + + def plot(self, show: bool = True) -> tuple[Figure, Axes]: + """Plot grid windows as white polygons on black bg with Ulam labels.""" + + fig, ax = plt.subplots(figsize=(5, 5), dpi=100) + fig.patch.set_facecolor('black') + ax.set_facecolor('black') + + ax.set_aspect('equal', adjustable='box') + ax.set_xlabel('x / um', color='white') + ax.set_ylabel('y / um', color='white') + + ax.tick_params(colors='white', direction='out') + for spine in ax.spines.values(): + spine.set_color('white') + + if not self.windows: + plt.show() + return + + patch_kw = {'facecolor': 'white', 'edgecolor': 'white', 'closed': True} + text_kw = {'color': 'black', 'ha': 'center', 'va': 'center', 'fontsize': 10} + for ulam_idx, w in self.windows.items(): + corners = np.asarray(w.corners, dtype=float) + ax.add_patch(Polygon(corners, **patch_kw)) + cx, cy = w.center + ax.text(cx, cy, str(ulam_idx), **text_kw) + + ax.autoscale() + ax.xaxis.set_major_formatter(FuncFormatter(lambda x, _: f'{x * 1e-3:g}')) + ax.yaxis.set_major_formatter(FuncFormatter(lambda y, _: f'{y * 1e-3:g}')) + + # draw explicit x/y axes through origin for orientation + ax.axhline(0, color='white', linewidth=1.0, alpha=0.6, zorder=0) + ax.axvline(0, color='white', linewidth=1.0, alpha=0.6, zorder=0) + + if show: + plt.show() + return fig, ax + + def predict_center(self, idx: WindowIndex) -> np.ndarray: + """Predict center position of window idx given the rest of the grid.""" + ij: DualIndex = self.pairing_inverse(idx) if isinstance(idx, int) else idx + w, h = self.coords + return self.windows[0].center + w * ij[0] + h * ij[1] + + def predict_window(self, idx: WindowIndex) -> WindowType: + """Predict the window of index idx given the rest of the grid.""" + w0_delta = self.predict_center(idx) - self.windows[0].center + return cast(WindowType, w0.translated(w0_delta)) + + +class HexagonalGrid(ConvexPolygonGrid[HexagonalWindow]): + window_type = HexagonalWindow + pairing_function = staticmethod(uv2spiral) + pairing_inverse = staticmethod(spiral2uv) + + +class RectangularGrid(ConvexPolygonGrid[RectangularWindow]): + window_type = RectangularWindow + pairing_function = staticmethod(ij2ulam) + pairing_inverse = staticmethod(ulam2ij) + + +if __name__ == '__main__': + g = RectangularGrid() + w0 = RectangularWindow(0, 0, 50_000, 50_000, np.deg2rad(0)) + g.windows[0] = w0 + for i in range(20): + p = g.predict_window(i) + if np.linalg.norm(p.center - w0.center) < 400_000: + g.windows[i] = p + + g.plot() diff --git a/src/instamatic/grid/pairing.py b/src/instamatic/grid/pairing.py index 152bf187..02ea231c 100644 --- a/src/instamatic/grid/pairing.py +++ b/src/instamatic/grid/pairing.py @@ -23,6 +23,15 @@ from __future__ import annotations import math +from typing import Protocol + + +class PairingFunction(Protocol): + def __call__(self, i: int, j: int, /) -> int: ... + + +class PairingInverse(Protocol): + def __call__(self, n: int, /) -> tuple[int, int]: ... def ulam2ij(u: int) -> tuple[int, int]: diff --git a/src/instamatic/grid/window.py b/src/instamatic/grid/window.py index f91ed3cb..63dae3c1 100644 --- a/src/instamatic/grid/window.py +++ b/src/instamatic/grid/window.py @@ -23,11 +23,13 @@ Y = np.array([0, 1], dtype=float) -class ConvexPolygonGridWindow(ABC): +class ConvexPolygonWindow(ABC): """Describes one convex polygon window without assumptions about grid.""" - center: np.ndarray = ... - corners: Sequence[np.ndarray] = ... + center: np.ndarray = ... # 2-element array describing the center of window + w_axis: np.ndarray = ... # from center towards the center of side in X dir + h_axis: np.ndarray = ... # from center towards the center of side in Y dir + corners: Sequence[np.ndarray] = ... # a Nx2 list of center coordinates @classmethod def from_sweeping(cls, order: Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10] = 3) -> Self: @@ -125,7 +127,7 @@ def x_intersections(self, y: float_nm) -> Optional[tuple[float, float]]: return min(intersection_xs), max(intersection_xs) -class RectangularGridWindow(ConvexPolygonGridWindow): +class RectangularWindow(ConvexPolygonWindow): """Describes one rectangular window without assumptions about the grid. Geometry is described using five immutable float scalars (nm / radian): @@ -184,3 +186,20 @@ def edge_d2_sum(geom: tuple[float, float, float, float, float], xys: np.ndarray) d3 = np.abs(np.dot(xys - (center + h_axis), h_axis_n)) d4 = np.abs(np.dot(xys - (center - h_axis), h_axis_n)) return np.sum(np.min([d1, d2, d3, d4], axis=0) ** 2) + + def translated(self, delta: np.ndarray) -> Self: + """Return a new window translated by (dx, dy) in nm.""" + d = np.asarray(delta, dtype=float).reshape( + 2, + ) + return type(self)( + float(self.center_x + d[0]), + float(self.center_y + d[1]), + float(self.width), + float(self.height), + float(self.theta), + ) + + +class HexagonalWindow(ConvexPolygonWindow): + """TODO: To be completed later to support a hexagonal lattice""" From 47f7bab9fb8e494f5d453391f7eb17cc38758e8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Mon, 26 Jan 2026 13:37:45 +0100 Subject: [PATCH 021/118] Implement (largely GPT) the HexagonalWindow: now both are defined, indexed, plot correctly --- src/instamatic/grid/grid.py | 18 +++++-- src/instamatic/grid/window.py | 97 ++++++++++++++++++++++++++++++++++- 2 files changed, 110 insertions(+), 5 deletions(-) diff --git a/src/instamatic/grid/grid.py b/src/instamatic/grid/grid.py index cfeb2f97..d01b954b 100644 --- a/src/instamatic/grid/grid.py +++ b/src/instamatic/grid/grid.py @@ -4,6 +4,7 @@ from typing import Annotated, Generic, Tuple, TypeVar, Union, cast import numpy as np +from fontTools.misc.cython import returns from matplotlib import pyplot as plt from matplotlib.axes import Axes from matplotlib.figure import Figure @@ -153,7 +154,7 @@ def predict_center(self, idx: WindowIndex) -> np.ndarray: def predict_window(self, idx: WindowIndex) -> WindowType: """Predict the window of index idx given the rest of the grid.""" w0_delta = self.predict_center(idx) - self.windows[0].center - return cast(WindowType, w0.translated(w0_delta)) + return cast(WindowType, self.windows[0].translated(w0_delta)) class HexagonalGrid(ConvexPolygonGrid[HexagonalWindow]): @@ -170,11 +171,22 @@ class RectangularGrid(ConvexPolygonGrid[RectangularWindow]): if __name__ == '__main__': g = RectangularGrid() - w0 = RectangularWindow(0, 0, 50_000, 50_000, np.deg2rad(0)) + w0 = RectangularWindow(0, 0, 50_000, 50_000, np.deg2rad(10)) g.windows[0] = w0 - for i in range(20): + for i in range(200): p = g.predict_window(i) if np.linalg.norm(p.center - w0.center) < 400_000: g.windows[i] = p g.plot() + + h = HexagonalGrid() + v0 = HexagonalWindow(0, 0, 50_000, np.deg2rad(10)) + h.windows[0] = v0 + + for i in range(200): + q = h.predict_window(i) + if np.linalg.norm(q.center - v0.center) < 400_000: + h.windows[i] = q + + h.plot() diff --git a/src/instamatic/grid/window.py b/src/instamatic/grid/window.py index 63dae3c1..795b145a 100644 --- a/src/instamatic/grid/window.py +++ b/src/instamatic/grid/window.py @@ -161,7 +161,7 @@ def from_edge_xys(cls, edge_xys: np.ndarray) -> Self: xys_com = np.mean(edge_xys, axis=0) xys_deltas = edge_xys - xys_com xys_cov = np.cov(xys_deltas.T) - eigenvalues, eigenvectors = np.linalg.eigh(xys_cov) + _, eigenvectors = np.linalg.eigh(xys_cov) eigenvector_proj = xys_deltas @ eigenvectors width0 = eigenvector_proj[:, 1].max() - eigenvector_proj[:, 1].min() height0 = eigenvector_proj[:, 0].max() - eigenvector_proj[:, 0].min() @@ -202,4 +202,97 @@ def translated(self, delta: np.ndarray) -> Self: class HexagonalWindow(ConvexPolygonWindow): - """TODO: To be completed later to support a hexagonal lattice""" + """Describes a regular hexagonal window without assumptions about the grid. + + Geometry is described using four immutable float scalars (nm / radian): + + - center_x: coordinate of the window center on the X axis; + - center_y: coordinate of the window center on the Y axis; + - width: distance between two opposite sides ("flat-to-flat"); + - theta: signed angle from world X axis towards the +w_axis direction. + """ + + ROT60MAT = np.array([[1, -np.sqrt(3)], [np.sqrt(3), 1]], dtype=float) / 2 + + def __init__(self, x: float, y: float, w: float, t: float): + t = (t + (np.pi / 6)) % (np.pi / 3) - (np.pi / 6) # cast to [-pi/6, pi/6] + self.center_x: float_nm = x + self.center_y: float_nm = y + self.width = w = abs(w) + self.theta: float = t + + self.center = c = np.array([x, y], dtype=float) + self.w_axis = wa = 0.5 * w * np.array([np.cos(t), np.sin(t)], dtype=float) + self.h_axis = self.ROT60MAT @ (self.ROT60MAT @ wa) + + r_circum = w / np.sqrt(3.0) + corners = [] + for angle in np.linspace(t + np.pi / 6, t + 13 * np.pi / 6, num=6, endpoint=False): + corners.append(r_circum * np.array([np.cos(angle), np.sin(angle)], dtype=float)) + self.corners = c + np.vstack(corners) + + @classmethod + def from_edge_xys(cls, edge_xys: np.ndarray) -> Self: + """Return new by fitting a regular hexagon to a Nx2 list of edge + positions. + + Uses a simple initial guess from PCA and refines with Powell. + """ + edge_xys = np.asarray(edge_xys, dtype=float) + xys_com = np.mean(edge_xys, axis=0) + + # PCA for an initial orientation guess + xys_deltas = edge_xys - xys_com + xys_cov = np.cov(xys_deltas.T) + _, eigenvectors = np.linalg.eigh(xys_cov) + + # Use principal axis as a crude guess for a vertex direction; convert to theta for w_axis + theta0 = float(np.arctan2(eigenvectors[1, 1], eigenvectors[0, 1]) - np.pi / 6.0) + + # Guess width from projected spread onto w_axis direction (apothem approx) + w_hat0 = np.array([np.cos(theta0), np.sin(theta0)], dtype=float) + proj = xys_deltas @ w_hat0 + # apothem ~ median absolute projection to a side midpoint direction + a0 = float(np.median(np.abs(proj))) + width0 = max(1.0, 2.0 * a0) + + guess = np.array([xys_com[0], xys_com[1], width0, theta0], dtype=float) + res = minimize(cls.edge_d2_sum, guess, args=(edge_xys,), method='Powell') + new = cls(*res.x) + new._edge_xys = edge_xys + return new + + @staticmethod + def edge_d2_sum(geom: tuple[float, float, float, float], xys: np.ndarray) -> float: + """Objective: squared distance of points to nearest hexagon side (regular).""" + cx, cy, width, theta = geom + width = abs(width) + if width <= 0: + return np.inf + + c = np.array([cx, cy], dtype=float) + pts = np.asarray(xys, dtype=float) - c + + # Unit normals to the 6 sides (pointing outward). + # If w_axis points to a side midpoint at angle theta, then that side's outward normal is along theta. + # Other side normals are spaced by 60 degrees. + angles = theta + np.arange(6) * (np.pi / 3.0) + normals = np.stack([np.cos(angles), np.sin(angles)], axis=1) # (6,2) + + # Signed distances to each supporting line: (n·p - a) + # Point is inside if all <= 0. We want distance to boundary: max(n·p - a) clipped at 0. + signed = pts @ normals.T - 0.5 * width # (N,6) + outside = np.maximum(signed.max(axis=1), 0.0) # (N,) + return float(np.sum(outside**2)) + + def translated(self, delta: np.ndarray) -> Self: + """Return a new window translated by (dx, dy) in nm.""" + d = np.asarray(delta, dtype=float).reshape( + 2, + ) + return type(self)( + float(self.center_x + d[0]), + float(self.center_y + d[1]), + float(self.width), + float(self.theta), + ) From 860896ecb48afdf1bb8ee4dcb57f697bb49b089b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Mon, 26 Jan 2026 13:44:30 +0100 Subject: [PATCH 022/118] Remove unused imports --- src/instamatic/grid/grid.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/instamatic/grid/grid.py b/src/instamatic/grid/grid.py index d01b954b..913663a4 100644 --- a/src/instamatic/grid/grid.py +++ b/src/instamatic/grid/grid.py @@ -1,10 +1,7 @@ from __future__ import annotations -import copy -from typing import Annotated, Generic, Tuple, TypeVar, Union, cast +from typing import Annotated, Generic, TypeVar, Union, cast -import numpy as np -from fontTools.misc.cython import returns from matplotlib import pyplot as plt from matplotlib.axes import Axes from matplotlib.figure import Figure @@ -15,7 +12,6 @@ from instamatic._typing import float_nm, int_nm from instamatic.grid.pairing import * from instamatic.grid.window import ConvexPolygonWindow, HexagonalWindow, RectangularWindow -from instamatic.utils.iterating import pairwise DualIndex = tuple[int, int] UlamIndex = Annotated[int, 'positive'] From e6faefe69f6d3f18d1817af7d9979ec902b6e39a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Mon, 26 Jan 2026 16:12:14 +0100 Subject: [PATCH 023/118] Adapt SPEDState not to use grid, to register scans --- src/instamatic/experiments/sped/state.py | 55 +++++++++++++++++------- 1 file changed, 39 insertions(+), 16 deletions(-) diff --git a/src/instamatic/experiments/sped/state.py b/src/instamatic/experiments/sped/state.py index a3091fa4..166f3bf0 100644 --- a/src/instamatic/experiments/sped/state.py +++ b/src/instamatic/experiments/sped/state.py @@ -10,8 +10,7 @@ class SPEDState: """Stores the current state of the SPED experiment in history dataframe.""" def __init__(self) -> None: - self.grids: list[int] = [] - self.windows: dict[tuple[int, int], ConvexPolygonWindow] = {} + self.windows: dict[int, ConvexPolygonWindow] = {} self.scans: pd.DataFrame = pd.DataFrame() self.steps: pd.DataFrame = pd.DataFrame() self.init_dataframes() @@ -20,7 +19,6 @@ def init_dataframes(self) -> None: """Create a new empty history with required index and columns.""" self.scans = pd.DataFrame( { - 'grid': pd.Series(dtype=np.uint8), 'window': pd.Series(dtype=np.uint16), 'scan': pd.Series(dtype=np.uint16), 'x0': pd.Series(dtype=np.int32), @@ -29,10 +27,9 @@ def init_dataframes(self) -> None: 'span': pd.Series(dtype=np.uint32), } ) - self.scans.set_index(['grid', 'window', 'scan'], inplace=True) + self.scans.set_index(['window', 'scan'], inplace=True) self.steps = pd.DataFrame( { - 'grid': pd.Series(dtype=np.uint8), 'window': pd.Series(dtype=np.uint16), 'scan': pd.Series(dtype=np.uint16), 'step': pd.Series(dtype=np.uint16), @@ -40,13 +37,27 @@ def init_dataframes(self) -> None: 'n_peaks': pd.Series(dtype=np.uint16), } ) - self.steps.set_index(['grid', 'window', 'scan', 'step'], inplace=True) + self.steps.set_index(['window', 'scan', 'step'], inplace=True) - def add_scan(self, grid: int, window: int, scan: int, n_frames: int) -> None: - """Pre-allocate scan space in the history dataframe for performance.""" - new = pd.DataFrame( + def add_window(self, idx: int, window: ConvexPolygonWindow) -> None: + self.windows[idx] = window + + def add_scan( + self, + window: int, + scan: int, + x0: int, + y0: int, + direction: str, + span: int, + n_frames: int, + ) -> None: + """Append to scans and pre-allocate space in the steps dataframe.""" + new_scan = {'x0': x0, 'y0': y0, 'direction': direction, 'span': span} + self.scans.loc[window, scan] = new_scan + + new_steps = pd.DataFrame( { - 'grid': np.full(n_frames, grid, dtype=np.uint8), 'window': np.full(n_frames, window, dtype=np.uint16), 'scan': np.full(n_frames, scan, dtype=np.uint16), 'step': np.arange(n_frames, dtype=np.uint16), @@ -54,10 +65,22 @@ def add_scan(self, grid: int, window: int, scan: int, n_frames: int) -> None: 'n_peaks': np.zeros(n_frames, dtype=np.uint16), } ) - new.set_index(['grid', 'window', 'scan', 'step'], inplace=True) - self.steps = pd.concat([self.steps, new], copy=False) + new_steps.set_index(['window', 'scan', 'step'], inplace=True) + self.steps = pd.concat([self.steps, new_steps], copy=False) + + def fill_scan( + self, + window: int, + scan: int, + success: np.ndarray, + n_peaks: np.ndarray, + ) -> None: + """Fill a previously-added scan with success/n_peaks in one update.""" + idx = pd.IndexSlice[window, scan, :] + + n_rows = self.steps.loc[idx].shape[0] + if len(success) != n_rows or len(n_peaks) != n_rows: + raise ValueError(f'Expected {n_rows} steps, got {len(success)=}, {len(n_peaks)=}') - def add_step(self, idx, *, success: bool, n_peaks: int) -> None: - """Add a single result line to the history dataframe.""" - self.steps.at[idx, 'success'] = bool(success) - self.steps.at[idx, 'n_peaks'] = int(n_peaks) + self.steps.loc[idx, 'success'] = pd.array(success, dtype='boolean') + self.steps.loc[idx, 'n_peaks'] = np.asarray(n_peaks, dtype=np.uint16) From 8793404ebcb8aea55c6b31b9074b87162e2c0061 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Mon, 26 Jan 2026 18:45:55 +0100 Subject: [PATCH 024/118] Improve readability, bind closer SPED Journal and State --- src/instamatic/experiments/sped/journal.py | 90 ++++++++++++++++++++++ src/instamatic/experiments/sped/state.py | 43 +++++++++-- src/instamatic/grid/window.py | 11 +++ 3 files changed, 138 insertions(+), 6 deletions(-) create mode 100644 src/instamatic/experiments/sped/journal.py diff --git a/src/instamatic/experiments/sped/journal.py b/src/instamatic/experiments/sped/journal.py new file mode 100644 index 00000000..02394a96 --- /dev/null +++ b/src/instamatic/experiments/sped/journal.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import inspect +import json +import time +from contextlib import contextmanager +from functools import wraps +from pathlib import Path +from typing import Any, Callable, Iterator + +import numpy as np + +from instamatic._typing import AnyPath +from instamatic.grid.window import ConvexPolygonWindow + + +class Journal: + """Stores, retrieves and parses SPED State progress in a json file.""" + + def __init__(self, path: AnyPath) -> None: + self.path: Path = Path(path) + self.writing: bool = True + self._seq: int = 0 + + def write(self, method: str, kwargs: dict[str, Any]) -> None: + """Write the new event record directly to the journal.""" + if not self.writing: + return + + self._seq += 1 + record = {'seq': self._seq, 'ts': time.time(), 'method': method, 'kwargs': kwargs} + line = json.dumps(record, separators=(',', ':')) + '\n' + self.path.parent.mkdir(parents=True, exist_ok=True) + with self.path.open('a', encoding='utf-8') as f: + f.write(line) + f.flush() + + def events(self) -> Iterator[dict]: + """Yield JSONL records of event, stops on a (truncated) final line.""" + if not self.path.exists(): + return + with self.path.open('r', encoding='utf-8') as f: + for line in f: + line = line.strip() + if not line: + continue + try: + yield json.loads(line) + except json.JSONDecodeError: + break + + @contextmanager + def writing_off(self): + was_writing_before = self.writing + self.writing = False + try: + yield + finally: + self.writing = was_writing_before + + +def serialize(obj): + """Serialize complex numpy objects to JSON-readable ones.""" + + if isinstance(obj, np.ndarray): + return obj.tolist() + elif isinstance(obj, (np.integer,)): + return int(obj) + elif isinstance(obj, (np.floating,)): + return float(obj) + elif isinstance(obj, (ConvexPolygonWindow,)): + return repr(obj) + return obj + + +def journaled(method: Callable) -> Callable: + """Method decorator that logs its calls to object's journal attribute.""" + method_signature = inspect.signature(method) + + @wraps(method) + def wrapper(self, *args, **kwargs): + out = method(self, *args, **kwargs) + if (journal := getattr(self, 'journal', None)) is not None: + bound = method_signature.bind(self, *args, **kwargs) + bound.apply_defaults() + payload = {k: serialize(v) for k, v in bound.arguments.items() if k != 'self'} + journal.write(method.__name__, payload) + return out + + return wrapper diff --git a/src/instamatic/experiments/sped/state.py b/src/instamatic/experiments/sped/state.py index 166f3bf0..04b53719 100644 --- a/src/instamatic/experiments/sped/state.py +++ b/src/instamatic/experiments/sped/state.py @@ -1,21 +1,29 @@ from __future__ import annotations +import ast +import importlib +from typing import Callable, Optional, Sequence, Union + import numpy as np import pandas as pd +from instamatic.experiments.sped.journal import Journal, journaled from instamatic.grid.window import ConvexPolygonWindow +WindowFactory: Callable[[float, float, float, ...], type[ConvexPolygonWindow]] + class SPEDState: """Stores the current state of the SPED experiment in history dataframe.""" - def __init__(self) -> None: + def __init__(self, journal: Journal) -> None: + self.journal: Journal = journal self.windows: dict[int, ConvexPolygonWindow] = {} self.scans: pd.DataFrame = pd.DataFrame() self.steps: pd.DataFrame = pd.DataFrame() - self.init_dataframes() + self._init_dataframes() - def init_dataframes(self) -> None: + def _init_dataframes(self) -> None: """Create a new empty history with required index and columns.""" self.scans = pd.DataFrame( { @@ -39,9 +47,31 @@ def init_dataframes(self) -> None: ) self.steps.set_index(['window', 'scan', 'step'], inplace=True) - def add_window(self, idx: int, window: ConvexPolygonWindow) -> None: + @classmethod + def from_journal(cls, journal: Journal) -> SPEDState: + state = cls(journal=journal) + with journal.writing_off(): + for event in journal.events(): + method = getattr(state, event['method']) + kwargs = event.get('kwargs', {}) + method(**kwargs) + return state + + @journaled + def add_window(self, idx: int, window: Union[ConvexPolygonWindow, str]) -> None: + """For journaling purposes, can be added via instance or __repr__.""" + if isinstance(window, str): + body = ast.parse(window, mode='eval').body + assert isinstance(body, ast.Call), f'Failed to eval "{window}"' + assert isinstance(body.func, ast.Name), f'Failed to eval "{window}"' + window_class_name = body.func.id + kwargs = {kw.arg: ast.literal_eval(kw.value) for kw in body.keywords} + window_module = importlib.import_module('instamatic.grid.window') + window_class = getattr(window_module, window_class_name) + window = window_class(**kwargs) self.windows[idx] = window + @journaled def add_scan( self, window: int, @@ -68,12 +98,13 @@ def add_scan( new_steps.set_index(['window', 'scan', 'step'], inplace=True) self.steps = pd.concat([self.steps, new_steps], copy=False) + @journaled def fill_scan( self, window: int, scan: int, - success: np.ndarray, - n_peaks: np.ndarray, + success: Union[np.ndarray, Sequence[Union[bool, None]]], + n_peaks: Union[np.ndarray, Sequence[int]], ) -> None: """Fill a previously-added scan with success/n_peaks in one update.""" idx = pd.IndexSlice[window, scan, :] diff --git a/src/instamatic/grid/window.py b/src/instamatic/grid/window.py index 795b145a..6a3cbb26 100644 --- a/src/instamatic/grid/window.py +++ b/src/instamatic/grid/window.py @@ -31,6 +31,9 @@ class ConvexPolygonWindow(ABC): h_axis: np.ndarray = ... # from center towards the center of side in Y dir corners: Sequence[np.ndarray] = ... # a Nx2 list of center coordinates + @abstractmethod + def __repr__(self) -> str: ... + @classmethod def from_sweeping(cls, order: Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10] = 3) -> Self: """Return new using `EdgeSweeper`s scanning around current position.""" @@ -155,6 +158,10 @@ def __init__(self, x: float, y: float, w: float, h: float, t: float): self.h_axis = ha = 0.5 * h * np.array([-np.sin(t), np.cos(t)], dtype=float) self.corners = np.vstack([c + wa + ha, c + wa - ha, c - wa - ha, c - wa + ha]) + def __repr__(self) -> str: + args = [self.center_x, self.center_y, self.width, self.height, self.theta] + return self.__class__.__name__ + '(x={}, y={}, w={}, h={}, t={})'.format(*args) + @classmethod def from_edge_xys(cls, edge_xys: np.ndarray) -> Self: """Return new by fitting the edge to a Nx2 list of edge positions.""" @@ -231,6 +238,10 @@ def __init__(self, x: float, y: float, w: float, t: float): corners.append(r_circum * np.array([np.cos(angle), np.sin(angle)], dtype=float)) self.corners = c + np.vstack(corners) + def __repr__(self) -> str: + args = [self.center_x, self.center_y, self.width, self.theta] + return self.__class__.__name__ + '(x={}, y={}, w={}, t={})'.format(*args) + @classmethod def from_edge_xys(cls, edge_xys: np.ndarray) -> Self: """Return new by fitting a regular hexagon to a Nx2 list of edge From f4e205cd41958549bb1cd2382db8a8aa231764d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Tue, 27 Jan 2026 16:56:29 +0100 Subject: [PATCH 025/118] Add a ProgressListbox Frame that displays all successful experiments --- src/instamatic/experiments/sped/gui.py | 155 +++++++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 src/instamatic/experiments/sped/gui.py diff --git a/src/instamatic/experiments/sped/gui.py b/src/instamatic/experiments/sped/gui.py new file mode 100644 index 00000000..5b7e6c15 --- /dev/null +++ b/src/instamatic/experiments/sped/gui.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +import tkinter as tk +import tkinter.ttk as ttk +from typing import Protocol, Sequence, Union + +import numpy as np + + +class GridWindowProtocol(Protocol): + def __repr__(self) -> str: ... + + +class ProgressListbox(ttk.Frame): + """Use a ttk.TreeView to display the progress of scanning experiment.""" + + COLUMNS = 'Geometry hits refls steps hits/step refls/step'.split() + + def __init__(self) -> None: + super().__init__() + self.tree = None + self._build_tree() + self._scan_geom: list[Union[int, str]] = [] + self._scan_totals: tuple[int, int, int] = (0, 0, 0) # hits, refls, steps + self._window_totals: tuple[int, int, int] = (0, 0, 0) # hits, refls, steps + + def _build_tree(self) -> None: + self.pack(side=tk.TOP, fill=tk.BOTH, expand=True) + self.tree = ttk.Treeview(self, columns=self.COLUMNS, show='tree headings') + + for column in self.COLUMNS: + self.tree.heading(column, text=column) + + self.tree.column('#0', width=30, stretch=True) + self.tree.column('Geometry', anchor=tk.CENTER, width=60) + self.tree.column('hits', anchor=tk.E, width=20) + self.tree.column('refls', anchor=tk.E, width=20) + self.tree.column('steps', anchor=tk.E, width=20) + self.tree.column('hits/step', anchor=tk.E, width=20) + self.tree.column('refls/step', anchor=tk.E, width=20) + + vsb = ttk.Scrollbar(orient='vertical', command=self.tree.yview) + hsb = ttk.Scrollbar(orient='horizontal', command=self.tree.xview) + self.tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set) + self.tree.grid(column=0, row=0, sticky='nsew', in_=self) + vsb.grid(column=1, row=0, sticky='ns', in_=self) + hsb.grid(column=0, row=1, sticky='ew', in_=self) + self.grid_columnconfigure(0, weight=1) + self.grid_rowconfigure(0, weight=1) + + @staticmethod + def _window_iid(window: int) -> str: + return f'w:{window}' + + @staticmethod + def _scan_iid(window: int, scan: int) -> str: + return f'w:{window}/s:{scan}' + + @staticmethod + def _step_iid(window: int, scan: int, step: int) -> str: + return f'w:{window}/s:{scan}/p:{step}' + + def add_window(self, idx: int, window: GridWindowProtocol) -> None: + """Add a new parent line to the tree called Window #.""" + window_iid = self._window_iid(idx) + window_name = f'Window {idx:d}' + geom = repr(window) + values = (geom, '-', '-', '-', '-', '-') + self.tree.insert('', tk.END, iid=window_iid, text=window_name, values=values) + self._window_totals = (0, 0, 0) + + def add_scan( + self, + window: int, + scan: int, + x0: int, + y0: int, + direction: str, + span: int, + n_frames: int, + ): + """Add a new child scan line to the tree called Scan #.""" + window_iid = self._window_iid(window) + scan_iid = self._scan_iid(window, scan) + scan_name = f'Scan {scan:d}' + if direction.endswith('x'): + geom = f'y: {y0}, x: {x0} -> {x0 + span}' + else: + geom = f'x: {x0}, y: {y0} -> {y0 + span}' + values = (geom, '-', '-', n_frames, '-', '-') + self.tree.insert(window_iid, tk.END, iid=scan_iid, text=scan_name, values=values) + self._scan_geom = [x0, y0, direction, span] + self._scan_totals: tuple[int, int, int] = (0, 0, 0) + + def fill_scan( + self, + window: int, + scan: int, + success: Union[np.ndarray, Sequence[Union[bool, None]]], + n_peaks: Union[np.ndarray, Sequence[int]], + ) -> None: + """Add lines for successful experiments, update scan & column lines.""" + + scan_iid = self._scan_iid(window, scan) + window_iid = self._window_iid(window) + x0, y0, direction, span = self._scan_geom + step = span / len(success) * (-1 if direction.startswith('-') else 1) + + s_hits = sum(bool(s) for s in success) + s_refls = sum(int(n) for ok, n in zip(success, n_peaks) if ok is True) + s_steps = len(success) + s_hits_per_step = s_hits / s_steps if s_steps else 0.0 + s_refls_per_step = s_refls / s_steps if s_steps else 0.0 + + self.tree.set(scan_iid, 'hits', str(s_hits)) + self.tree.set(scan_iid, 'refls', str(s_refls)) + self.tree.set(scan_iid, 'steps', str(s_steps)) + self.tree.set(scan_iid, 'hits/step', f'{s_hits_per_step:.3g}') + self.tree.set(scan_iid, 'refls/step', f'{s_refls_per_step:.3g}') + + w_hits = self._window_totals[0] + s_hits + w_refls = self._window_totals[1] + s_refls + w_steps = self._window_totals[2] + s_steps + w_hits_per_step = w_hits / w_steps if w_steps else 0.0 + w_refls_per_step = w_refls / w_steps if w_steps else 0.0 + self._window_totals = (w_hits, w_refls, w_steps) + + self.tree.set(window_iid, 'hits', str(w_hits)) + self.tree.set(window_iid, 'refls', str(w_refls)) + self.tree.set(window_iid, 'steps', str(w_steps)) + self.tree.set(window_iid, 'hits/step', f'{w_hits_per_step:.3g}') + self.tree.set(window_iid, 'refls/step', f'{w_refls_per_step:.3g}') + + for i, (ok, n) in enumerate(zip(success, n_peaks)): + if not ok: + continue + step_name = f'Step {i:d}' + step_iid = self._step_iid(window, scan, i) + axis = direction[-1] + geom = f'{axis}: {int((x0 if axis == "x" else y0) + i * step)}' + values = (geom, '', int(n), '', '', '') + self.tree.insert(scan_iid, tk.END, iid=step_iid, text=step_name, values=values) + + +if __name__ == '__main__': + root = tk.Tk() + root.title('Test progress listbox') + listbox = ProgressListbox() + listbox.add_window(0, 'Some geometry') + listbox.add_scan(0, 0, 100, 200, '+x', 1000, 50) + listbox.fill_scan(0, 0, success=[True, False, True, None, True], n_peaks=[12, 3, 8, 0, 21]) + listbox.add_scan(0, 1, 90, 210, '+x', 1020, 50) + listbox.fill_scan(0, 1, success=[1, 0, 1, 0, 1, 1], n_peaks=[17, 3, 28, 0, 21, 19]) + + root.mainloop() From 9dcc2dc594a2cce656fb076283fcbd398c137482 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Tue, 27 Jan 2026 18:02:38 +0100 Subject: [PATCH 026/118] Attach progress table to state class --- src/instamatic/experiments/sped/journal.py | 2 +- .../experiments/sped/{gui.py => progress.py} | 29 +++++++++++++++---- src/instamatic/experiments/sped/state.py | 27 +++++++++-------- 3 files changed, 39 insertions(+), 19 deletions(-) rename src/instamatic/experiments/sped/{gui.py => progress.py} (88%) diff --git a/src/instamatic/experiments/sped/journal.py b/src/instamatic/experiments/sped/journal.py index 02394a96..fe86a22d 100644 --- a/src/instamatic/experiments/sped/journal.py +++ b/src/instamatic/experiments/sped/journal.py @@ -73,7 +73,7 @@ def serialize(obj): return obj -def journaled(method: Callable) -> Callable: +def edits_journal(method: Callable) -> Callable: """Method decorator that logs its calls to object's journal attribute.""" method_signature = inspect.signature(method) diff --git a/src/instamatic/experiments/sped/gui.py b/src/instamatic/experiments/sped/progress.py similarity index 88% rename from src/instamatic/experiments/sped/gui.py rename to src/instamatic/experiments/sped/progress.py index 5b7e6c15..85eb8e11 100644 --- a/src/instamatic/experiments/sped/gui.py +++ b/src/instamatic/experiments/sped/progress.py @@ -1,8 +1,10 @@ from __future__ import annotations +import inspect import tkinter as tk import tkinter.ttk as ttk -from typing import Protocol, Sequence, Union +from functools import wraps +from typing import Callable, Protocol, Sequence, Union import numpy as np @@ -11,7 +13,7 @@ class GridWindowProtocol(Protocol): def __repr__(self) -> str: ... -class ProgressListbox(ttk.Frame): +class ProgressTable(ttk.Frame): """Use a ttk.TreeView to display the progress of scanning experiment.""" COLUMNS = 'Geometry hits refls steps hits/step refls/step'.split() @@ -21,7 +23,6 @@ def __init__(self) -> None: self.tree = None self._build_tree() self._scan_geom: list[Union[int, str]] = [] - self._scan_totals: tuple[int, int, int] = (0, 0, 0) # hits, refls, steps self._window_totals: tuple[int, int, int] = (0, 0, 0) # hits, refls, steps def _build_tree(self) -> None: @@ -90,7 +91,6 @@ def add_scan( values = (geom, '-', '-', n_frames, '-', '-') self.tree.insert(window_iid, tk.END, iid=scan_iid, text=scan_name, values=values) self._scan_geom = [x0, y0, direction, span] - self._scan_totals: tuple[int, int, int] = (0, 0, 0) def fill_scan( self, @@ -107,7 +107,7 @@ def fill_scan( step = span / len(success) * (-1 if direction.startswith('-') else 1) s_hits = sum(bool(s) for s in success) - s_refls = sum(int(n) for ok, n in zip(success, n_peaks) if ok is True) + s_refls = sum(int(n) for ok, n in zip(success, n_peaks) if ok) s_steps = len(success) s_hits_per_step = s_hits / s_steps if s_steps else 0.0 s_refls_per_step = s_refls / s_steps if s_steps else 0.0 @@ -142,10 +142,27 @@ def fill_scan( self.tree.insert(scan_iid, tk.END, iid=step_iid, text=step_name, values=values) +def edits_progress(method: Callable) -> Callable: + """Method decorator, captures calls to modify object's progress attr.""" + method_signature = inspect.signature(method) + + @wraps(method) + def wrapper(self, *args, **kwargs): + out = method(self, *args, **kwargs) + if (progress := getattr(self, 'progress', None)) is not None: + bound = method_signature.bind(self, *args, **kwargs) + bound.apply_defaults() + kwargs = {k: v for k, v in bound.arguments.items() if k != 'self'} + getattr(progress, method.__name__)(**kwargs) + return out + + return wrapper + + if __name__ == '__main__': root = tk.Tk() root.title('Test progress listbox') - listbox = ProgressListbox() + listbox = ProgressTable() listbox.add_window(0, 'Some geometry') listbox.add_scan(0, 0, 100, 200, '+x', 1000, 50) listbox.fill_scan(0, 0, success=[True, False, True, None, True], n_peaks=[12, 3, 8, 0, 21]) diff --git a/src/instamatic/experiments/sped/state.py b/src/instamatic/experiments/sped/state.py index 04b53719..8f5bdb0e 100644 --- a/src/instamatic/experiments/sped/state.py +++ b/src/instamatic/experiments/sped/state.py @@ -7,7 +7,8 @@ import numpy as np import pandas as pd -from instamatic.experiments.sped.journal import Journal, journaled +from instamatic.experiments.sped.journal import Journal, edits_journal +from instamatic.experiments.sped.progress import ProgressTable, edits_progress from instamatic.grid.window import ConvexPolygonWindow WindowFactory: Callable[[float, float, float, ...], type[ConvexPolygonWindow]] @@ -16,8 +17,10 @@ class SPEDState: """Stores the current state of the SPED experiment in history dataframe.""" - def __init__(self, journal: Journal) -> None: + def __init__(self, journal: Journal, progress: Optional[ProgressTable] = None) -> None: self.journal: Journal = journal + self.progress: ProgressTable = progress + self.windows: dict[int, ConvexPolygonWindow] = {} self.scans: pd.DataFrame = pd.DataFrame() self.steps: pd.DataFrame = pd.DataFrame() @@ -47,17 +50,15 @@ def _init_dataframes(self) -> None: ) self.steps.set_index(['window', 'scan', 'step'], inplace=True) - @classmethod - def from_journal(cls, journal: Journal) -> SPEDState: - state = cls(journal=journal) - with journal.writing_off(): - for event in journal.events(): - method = getattr(state, event['method']) + def load_from_journal(self) -> None: + with self.journal.writing_off(): + for event in self.journal.events(): + method = getattr(self, event['method']) kwargs = event.get('kwargs', {}) method(**kwargs) - return state - @journaled + @edits_journal + @edits_progress def add_window(self, idx: int, window: Union[ConvexPolygonWindow, str]) -> None: """For journaling purposes, can be added via instance or __repr__.""" if isinstance(window, str): @@ -71,7 +72,8 @@ def add_window(self, idx: int, window: Union[ConvexPolygonWindow, str]) -> None: window = window_class(**kwargs) self.windows[idx] = window - @journaled + @edits_journal + @edits_progress def add_scan( self, window: int, @@ -98,7 +100,8 @@ def add_scan( new_steps.set_index(['window', 'scan', 'step'], inplace=True) self.steps = pd.concat([self.steps, new_steps], copy=False) - @journaled + @edits_journal + @edits_progress def fill_scan( self, window: int, From af071f5d909449bd36c1f8f861ef4e7ffbc32090 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Tue, 27 Jan 2026 19:56:03 +0100 Subject: [PATCH 027/118] Added GUI. With grid_columnconfigure, it is a bit too wide, but fine for now. --- .../experiments/{sped => scan_ed}/__init__.py | 0 .../{sped => scan_ed}/detection.py | 0 .../experiments/{sped => scan_ed}/dispatch.py | 0 .../{sped => scan_ed}/experiment.py | 0 .../experiments/{sped => scan_ed}/journal.py | 0 .../experiments/{sped => scan_ed}/progress.py | 8 +- .../experiments/{sped => scan_ed}/state.py | 4 +- .../experiments/{sped => scan_ed}/util.py | 0 src/instamatic/gui/modules.py | 1 + src/instamatic/gui/scan_ed_frame.py | 197 ++++++++++++++++++ 10 files changed, 204 insertions(+), 6 deletions(-) rename src/instamatic/experiments/{sped => scan_ed}/__init__.py (100%) rename src/instamatic/experiments/{sped => scan_ed}/detection.py (100%) rename src/instamatic/experiments/{sped => scan_ed}/dispatch.py (100%) rename src/instamatic/experiments/{sped => scan_ed}/experiment.py (100%) rename src/instamatic/experiments/{sped => scan_ed}/journal.py (100%) rename src/instamatic/experiments/{sped => scan_ed}/progress.py (97%) rename src/instamatic/experiments/{sped => scan_ed}/state.py (96%) rename src/instamatic/experiments/{sped => scan_ed}/util.py (100%) create mode 100644 src/instamatic/gui/scan_ed_frame.py diff --git a/src/instamatic/experiments/sped/__init__.py b/src/instamatic/experiments/scan_ed/__init__.py similarity index 100% rename from src/instamatic/experiments/sped/__init__.py rename to src/instamatic/experiments/scan_ed/__init__.py diff --git a/src/instamatic/experiments/sped/detection.py b/src/instamatic/experiments/scan_ed/detection.py similarity index 100% rename from src/instamatic/experiments/sped/detection.py rename to src/instamatic/experiments/scan_ed/detection.py diff --git a/src/instamatic/experiments/sped/dispatch.py b/src/instamatic/experiments/scan_ed/dispatch.py similarity index 100% rename from src/instamatic/experiments/sped/dispatch.py rename to src/instamatic/experiments/scan_ed/dispatch.py diff --git a/src/instamatic/experiments/sped/experiment.py b/src/instamatic/experiments/scan_ed/experiment.py similarity index 100% rename from src/instamatic/experiments/sped/experiment.py rename to src/instamatic/experiments/scan_ed/experiment.py diff --git a/src/instamatic/experiments/sped/journal.py b/src/instamatic/experiments/scan_ed/journal.py similarity index 100% rename from src/instamatic/experiments/sped/journal.py rename to src/instamatic/experiments/scan_ed/journal.py diff --git a/src/instamatic/experiments/sped/progress.py b/src/instamatic/experiments/scan_ed/progress.py similarity index 97% rename from src/instamatic/experiments/sped/progress.py rename to src/instamatic/experiments/scan_ed/progress.py index 85eb8e11..3a3d9317 100644 --- a/src/instamatic/experiments/sped/progress.py +++ b/src/instamatic/experiments/scan_ed/progress.py @@ -18,15 +18,14 @@ class ProgressTable(ttk.Frame): COLUMNS = 'Geometry hits refls steps hits/step refls/step'.split() - def __init__(self) -> None: - super().__init__() + def __init__(self, parent: tk.Misc, **kwargs) -> None: + super().__init__(parent, **kwargs) self.tree = None self._build_tree() self._scan_geom: list[Union[int, str]] = [] self._window_totals: tuple[int, int, int] = (0, 0, 0) # hits, refls, steps def _build_tree(self) -> None: - self.pack(side=tk.TOP, fill=tk.BOTH, expand=True) self.tree = ttk.Treeview(self, columns=self.COLUMNS, show='tree headings') for column in self.COLUMNS: @@ -162,7 +161,8 @@ def wrapper(self, *args, **kwargs): if __name__ == '__main__': root = tk.Tk() root.title('Test progress listbox') - listbox = ProgressTable() + listbox = ProgressTable(root) + listbox.pack(side=tk.TOP, fill=tk.BOTH, expand=True) listbox.add_window(0, 'Some geometry') listbox.add_scan(0, 0, 100, 200, '+x', 1000, 50) listbox.fill_scan(0, 0, success=[True, False, True, None, True], n_peaks=[12, 3, 8, 0, 21]) diff --git a/src/instamatic/experiments/sped/state.py b/src/instamatic/experiments/scan_ed/state.py similarity index 96% rename from src/instamatic/experiments/sped/state.py rename to src/instamatic/experiments/scan_ed/state.py index 8f5bdb0e..2d72dee1 100644 --- a/src/instamatic/experiments/sped/state.py +++ b/src/instamatic/experiments/scan_ed/state.py @@ -7,8 +7,8 @@ import numpy as np import pandas as pd -from instamatic.experiments.sped.journal import Journal, edits_journal -from instamatic.experiments.sped.progress import ProgressTable, edits_progress +from instamatic.experiments.scan_ed.journal import Journal, edits_journal +from instamatic.experiments.scan_ed.progress import ProgressTable, edits_progress from instamatic.grid.window import ConvexPolygonWindow WindowFactory: Callable[[float, float, float, ...], type[ConvexPolygonWindow]] diff --git a/src/instamatic/experiments/sped/util.py b/src/instamatic/experiments/scan_ed/util.py similarity index 100% rename from src/instamatic/experiments/sped/util.py rename to src/instamatic/experiments/scan_ed/util.py diff --git a/src/instamatic/gui/modules.py b/src/instamatic/gui/modules.py index da57934e..61b14070 100644 --- a/src/instamatic/gui/modules.py +++ b/src/instamatic/gui/modules.py @@ -14,6 +14,7 @@ 'cred_tvips', 'cred_fei', 'fast_adt', + 'scan_ed', 'sed', 'autocred', 'red', diff --git a/src/instamatic/gui/scan_ed_frame.py b/src/instamatic/gui/scan_ed_frame.py new file mode 100644 index 00000000..d552320d --- /dev/null +++ b/src/instamatic/gui/scan_ed_frame.py @@ -0,0 +1,197 @@ +from __future__ import annotations + +from functools import wraps +from tkinter import * +from tkinter.ttk import * +from typing import Any, Callable, Optional + +from instamatic import controller +from instamatic.experiments.scan_ed.progress import ProgressTable +from instamatic.utils.spinbox import Spinbox + +from .base_module import BaseModule, ModuleFrameMixin + +pad0 = {'sticky': 'EW', 'padx': 0, 'pady': 1} +pad10 = {'sticky': 'EW', 'padx': 10, 'pady': 1} +scan_step = {'from_': 0, 'to': 100_000, 'increment': 100, 'width': 20} +scan_exposure = {'from_': 0, 'to': 10, 'increment': 0.01, 'width': 20} +target_hits = {'from_': 0, 'to': 1_000_000, 'increment': 100, 'width': 20} +target_time = {'from_': 0, 'to': 43_200, 'increment': 60, 'width': 20} +target_xy = {'from_': 0, 'to': 1_000_000, 'increment': 1000, 'width': 20} +angle_delta = {'from_': 0, 'to': 180, 'increment': 0.1, 'width': 20} +duration = {'from_': 0, 'to': 60, 'increment': 0.1} + + +class ExperimentalScanEDVariables: + """A collection of tkinter Variable instances passed to the experiment.""" + + def __init__(self, on_change: Optional[Callable[[], None]] = None) -> None: + self.grid_geometry = StringVar() + self.scan_geometry = StringVar() + self.scan_x_step = IntVar(value=500) + self.scan_y_step = IntVar(value=500) + self.scan_exposure = DoubleVar(value=0.1) + + self.target_hits = IntVar(value=1000) + self.target_x = IntVar(value=500_000) + self.target_y = IntVar(value=500_000) + self.target_time = IntVar(value=480) + + self.target_hits_b = BooleanVar(value=False) + self.target_steps_b = BooleanVar(value=False) + self.target_x_b = BooleanVar(value=False) + self.target_y_b = BooleanVar(value=False) + self.target_time_b = BooleanVar(value=False) + + if on_change: + self._add_callback(on_change) + + def _add_callback(self, callback: Callable[[], None]) -> None: + """Add a safe trace callback to all `Variable` instances in self.""" + + @wraps(callback) + def safe_callback(*_): + try: + callback() + except TclError as e: # Ignore invalid/incomplete GUI edits + if 'expected floating-point number' not in str(e): + raise + except AttributeError as e: # Ignore incomplete initialization + if 'object has no attribute' not in str(e): + raise + + for name, var in vars(self).items(): + if isinstance(var, Variable): + var.trace_add('write', safe_callback) + + def as_dict(self): + return {n: v.get() for n, v in vars(self).items() if isinstance(v, Variable)} + + +class ExperimentalScanED(LabelFrame, ModuleFrameMixin): + """GUI panel to control Scanning (precession-assisted) ED experiments.""" + + def __init__(self, parent): + text = 'Automatically scan entire grid until any finish condition is met' + super().__init__(parent, text=text) + self.parent = parent + self.var = ExperimentalScanEDVariables() + self.busy: bool = False + self.ctrl = controller.get_instance() + + # Top-aligned part of the frame with experiment parameters + f = Frame(self) + for column in range(4): + f.grid_columnconfigure(column, weight=1, uniform='buttons') + f.grid_rowconfigure(10, weight=1) + + Label(f, text='Grid geometry:').grid(row=3, column=0, **pad10) + m = ['hexagonal', 'rectangular'] + self.grid_geometry = OptionMenu(f, self.var.grid_geometry, m[1], *m) + self.grid_geometry.grid(row=3, column=1, **pad10) + + Label(f, text='Scan geometry:').grid(row=4, column=0, **pad10) + m = ['X-raster', 'X-serpentine', 'Y-raster', 'Y-serpentine'] + self.scan_geometry = OptionMenu(f, self.var.scan_geometry, m[1], *m) + self.scan_geometry.grid(row=4, column=1, **pad10) + + Label(f, text='Scan X step (nm):').grid(row=5, column=0, **pad10) + var = self.var.scan_x_step + self.scan_x_step = Spinbox(f, textvariable=var, **scan_step) + self.scan_x_step.grid(row=5, column=1, **pad10) + + Label(f, text='Scan Y step (nm):').grid(row=6, column=0, **pad10) + var = self.var.scan_y_step + self.scan_y_step = Spinbox(f, textvariable=var, **scan_step) + self.scan_y_step.grid(row=6, column=1, **pad10) + + Label(f, text='Scan exposure (s):').grid(row=7, column=0, **pad10) + var = self.var.scan_exposure + self.scan_exposure = Spinbox(f, textvariable=var, **scan_exposure) + self.scan_exposure.grid(row=7, column=1, **pad10) + + # Finish conditions area with tick marks + + text = 'Finish conditions – experiment ends once:' + Label(f, text=text).grid(row=3, column=2, columnspan=2, **pad10) + + text = 'Hits exceed:' + self.target_hits_b = Checkbutton(f, variable=self.var.target_hits_b, text=text) + self.target_hits_b.grid(row=4, column=2, **pad10) + self.target_hits = Spinbox(f, textvariable=self.var.target_hits, **target_hits) + self.target_hits.grid(row=4, column=3, **pad10) + + text = '±X exceeds (nm):' + self.target_x_b = Checkbutton(f, variable=self.var.target_x_b, text=text) + self.target_x_b.grid(row=5, column=2, **pad10) + self.target_x = Spinbox(f, textvariable=self.var.target_x, **target_xy) + self.target_x.grid(row=5, column=3, **pad10) + + text = '±Y exceeds (nm):' + self.target_y_b = Checkbutton(f, variable=self.var.target_y_b, text=text) + self.target_y_b.grid(row=6, column=2, **pad10) + self.target_y = Spinbox(f, textvariable=self.var.target_y, **target_xy) + self.target_y.grid(row=6, column=3, **pad10) + + text = 'Time exceeds (h):' + self.target_time_b = Checkbutton(f, variable=self.var.target_time_b, text=text) + self.target_time_b.grid(row=7, column=2, **pad10) + self.target_time = Spinbox(f, textvariable=self.var.target_time, **target_time) + self.target_time.grid(row=7, column=3, **pad10) + + # Bottom area for progress and experiment flow control buttons + + self.progress = ProgressTable(f) + self.progress.grid(row=10, columnspan=4, sticky=NSEW, padx=10, pady=10) + + self.start_button = Button(f, text='Start', command=self.start_collection) + self.start_button.grid(row=20, column=0, sticky=EW, padx=(10, 0)) + + self.restore_button = Button(f, text='Restore', command=self.start_collection) + self.restore_button.grid(row=20, column=1, sticky=EW) + + self.stop_button = Button(f, text='Stop', command=self.start_collection) + self.stop_button.grid(row=20, column=2, sticky=EW) + + self.finalize_button = Button(f, text='Finalize', command=self.start_collection) + self.finalize_button.grid(row=20, column=3, sticky=EW, padx=(0, 10)) + + f.pack(side='bottom', fill=BOTH, expand=True, pady=10) + + def start_collection(self) -> None: + self.q.put(('fast_adt', {'frame': self, **self.var.as_dict()})) + + +def sced_interface_command(controller, **params: Any) -> None: + from instamatic.experiments import scan_ed as sped_module + + scan_ed_frame: ExperimentalScanED = params['frame'] + flat_field = controller.module_io.get_flatfield() + exp_dir = controller.module_io.get_new_experiment_directory() + exp_dir.mkdir(exist_ok=True, parents=True) + + controller.fast_adt = sped_module.Experiment( + ctrl=controller.ctrl, + path=exp_dir, + log=controller.log, + flatfield=flat_field, + scan_ed_frame=scan_ed_frame, + ) + try: + controller.fast_adt.start_collection(**params) + except RuntimeError: + pass # RuntimeError is raised if experiment is terminated early + finally: + del controller.fast_adt + + +module = BaseModule( + name='scan_ed', display_name='ScanED', tk_frame=ExperimentalScanED, location='bottom' +) +commands = {'scan_ed': sced_interface_command} + + +if __name__ == '__main__': + root = Tk() + ExperimentalScanED(root).pack(side='top', fill='both', expand=True) + root.mainloop() From 56f221bb9ca3552a798a2b6d94941cb39b30f239 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Wed, 28 Jan 2026 20:08:28 +0100 Subject: [PATCH 028/118] Added initial experiment logic and A LOT of TODOs since it sucks! --- .../experiments/scan_ed/experiment.py | 193 +++++++++++++++--- .../experiments/scan_ed/progress.py | 4 +- src/instamatic/experiments/scan_ed/state.py | 17 +- src/instamatic/grid/window.py | 16 +- src/instamatic/gui/scan_ed_frame.py | 90 ++++---- 5 files changed, 236 insertions(+), 84 deletions(-) diff --git a/src/instamatic/experiments/scan_ed/experiment.py b/src/instamatic/experiments/scan_ed/experiment.py index 46a23096..933b6976 100644 --- a/src/instamatic/experiments/scan_ed/experiment.py +++ b/src/instamatic/experiments/scan_ed/experiment.py @@ -1,26 +1,48 @@ from __future__ import annotations -from typing import Union +import logging +from itertools import cycle +from pathlib import Path +from typing import Any, Optional import numpy as np +import pandas as pd +from instamatic._typing import AnyPath, int_nm from instamatic.calibrate import CalibMovieDelays from instamatic.calibrate.calibrate_stage_translation import CalibStageTranslationX from instamatic.experiments.experiment_base import ExperimentBase from instamatic.experiments.fast_adt.experiment import FastADTMissingCalibError -from instamatic.grid.window import RectangularWindow +from instamatic.experiments.scan_ed.dispatch import DiffHuntDispatcher +from instamatic.experiments.scan_ed.journal import Journal +from instamatic.experiments.scan_ed.progress import ProgressTable +from instamatic.experiments.scan_ed.state import State +from instamatic.grid.grid import ConvexPolygonGrid +from instamatic.grid.window import ConvexPolygonWindow, RectangularWindow class Experiment(ExperimentBase): name = 'SPED' - def __init__(self, ctrl, **kwargs): + def __init__( + self, + ctrl, + path: AnyPath, + log: logging.Logger, + flatfield: Optional[np.ndarray] = None, + progress: Optional[ProgressTable] = None, + load: bool = False, + ): super().__init__() self.ctrl = ctrl + self.path: Path = Path(path) + self.log: logging.Logger = log + self.flatfield: Optional[np.ndarray] = flatfield + self.state = self.get_state(load=load, progress=progress) - self.exposure = 0.1 - self.speed: Union[float, int] = 1.0 - self.xy_resolution = 2 + self.dispatcher = DiffHuntDispatcher(shape=(514, 514), dtype=np.uint16) + self.dispatcher.initialize_workers() + # TODO init the dispatcher correctly once actual camera size is known def get_dead_time( self, @@ -50,37 +72,144 @@ def get_stage_translation(self) -> CalibStageTranslationX: print(m2 := 'Please run `instamatic.calibrate_stage_rotation` first.') raise FastADTMissingCalibError(m1 + ' ' + m2) - def determine_translation_speed(self) -> None: - detector_dead_time = self.get_dead_time(self.exposure) - time_for_one_frame = self.exposure + detector_dead_time + def get_state(self, load: bool, progress: Optional[ProgressTable] = None) -> State: + """Initialize a state, fill it from journal; raise at load issues.""" + journal_path = self.path / 'journal.jsonl' + journal = Journal(path=journal_path) + state = State(journal=journal, progress=progress) + if load: + if not journal_path.exists() or not journal_path.is_file(): + raise FileNotFoundError(f'No journal file found at {journal_path=}') + state.load_from_journal() + return state + + def get_grid(self, params: dict[str, Any]) -> ConvexPolygonGrid: + """Reconstruct the grid from current params and state.""" + from instamatic.grid.grid import HexagonalGrid, RectangularGrid + + if params.get('grid_geometry', '').lower().startswith('hex'): + grid = HexagonalGrid() + else: + grid = RectangularGrid() + if self.state.windows: + for wid, w in self.state.windows.items(): + assert isinstance(w, grid.window_type) + grid.windows[wid] = w + return grid + + def determine_exposure_and_speed( + self, + exposure: float, + step_size: int_nm, + ) -> tuple[float, float]: + detector_dead_time = self.get_dead_time(exposure) + time_for_one_frame = exposure + detector_dead_time trans_calib = self.get_stage_translation() - mot_plan = trans_calib.plan_motion(time_for_one_frame / self.xy_resolution) - self.exposure = abs(mot_plan.pace * self.xy_resolution) - detector_dead_time - self.speed = mot_plan.speed + mot_plan = trans_calib.plan_motion(1e9 * time_for_one_frame / step_size) + exposure = abs(mot_plan.pace * step_size) - detector_dead_time + speed = mot_plan.speed + return exposure, speed def start_collection(self, **params) -> None: # precalculate sliding speeds - self.determine_translation_speed() - - # plan the scanning of current grid window - win = RectangularWindow.from_sweeping(order=3) - y = np.min(win.corners[:, 1]) + (0.5 * self.xy_resolution) - scans: dict[int, tuple[float, float]] = {} - for i, x in enumerate(win.x_intersections(y)): - if x is None: + exposure, speed = self.determine_exposure_and_speed( + params['exposure'], params['step_size'] + ) + grid = self.get_grid(params=params) + stop_event = params['stop_event'] + + while not stop_event.is_set(): + try: + window_id, window = self.locate_next_window(grid=grid, params=params) + except IndexError: break - scans[y] = (x[0], x[1]) if i % 2 else (x[1], x[0]) - y += self.xy_resolution - - # for each scan, collect a movie - for y, (x0, x1) in scans.items(): - self.ctrl.stage.set(x=x0, y=y) - x_n = int(np.ceil(abs(x1 - x0) / self.xy_resolution)) - movie = self.ctrl.get_movie(n_frames=x_n, exposure=self.exposure) - self.ctrl.stage.set_with_speed(x=x1, speed=self.speed) - for x_i, (image, meta) in enumerate(movie): - ... # send image to multiprocessor analyzer + grid.windows['window_id'] = window + self.state.add_window(idx=window_id, window=window) + + if params['scan_geometry'].lower().startswith('x'): + fast_axis, scan_factory = 'x', window.x_intersections + fast_step, slow_step = params['scan_x_step'], params['scan_y_step'] + slow_axis_idx = 1 + else: # params['scan_geometry'].lower().startswith('y'): + fast_axis, scan_factory = 'y', window.y_intersections + fast_step, slow_step = params['scan_y_step'], params['scan_x_step'] + slow_axis_idx = 0 + + if params['scan_geometry'].lower().endswith('raster'): + scan_signs = cycle([1, -1]) + else: # params['scan_geometry'].lower().endswith('raster'): + scan_signs = [ + 1, + ] + + slow_min = np.min(window.corners[:, slow_axis_idx]) + slow_max = np.max(window.corners[:, slow_axis_idx]) + for scan_id, slow in enumerate( + np.arange(slow_min + slow_step, slow_max, slow_step) + ): + fast_min, fast_max = scan_factory(float(slow)) + self.state.add_scan( + window=window_id, + scan_id=scan_id, + x0=fast_min if fast_axis == 'x' else slow_min, + y0=fast_min if fast_axis == 'y' else slow_min, + direction=('+' if next(scan_signs) >= 0 else '-') + fast_axis, + span=abs(fast_max - fast_min), + step=fast_step, + ) + + for scan_id in self.state.scans.loc[window_id].index: + idx = pd.IndexSlice[window_id, scan_id, :] + if self.state.steps.loc[idx, 'success'].notna().any(): + continue # this scan has been already done + x0 = self.state.scans.at[(window_id, scan_id), 'x0'] + y0 = self.state.scans.at[(window_id, scan_id), 'y0'] + self.ctrl.stage.set(x0=x0, y0=y0) + + movie = self.ctrl.get_movie( + n_frames=len(idx), exposure=exposure, header_keys=None + ) + self.dispatcher.switch_buffer(len(idx), name=f'w:{window_id}/s:{scan_id}') + span = self.state.scans.at[(window_id, scan_id), 'span'] + direction = self.state.scans.at[(window_id, scan_id), 'direction'] + sign = +1 if direction.startswith('+') else -1 + fast1 = (x0 if direction.endswith('X') else y0) + sign * span + setter_kwargs = {fast_axis: fast1, 'speed': speed} + + self.ctrl.stage.set_with_speed(**setter_kwargs) + for frame, header in movie: + self.dispatcher.process(frame, header) + # TODO: receive all dispatch feedback + # TODO somehow wait until entire buffer is filled + self.dispatcher.write_buffer(self.path) + # TODO: write from history to state + # TODO: new writing path for every scan + + # TODO: add missing logic, repeated scans + # TODO: state: replace windows list with grid to avoid duplication + # TODO: simplify scans logic because right now it is difficult return - def finalize(self) -> None: ... + def locate_next_window( + self, + grid: ConvexPolygonGrid, + params: dict, + ) -> tuple[int, ConvexPolygonWindow]: + """Find a next window on the grid, or raise if none can be found.""" + last_window_id = max(grid.windows) + for window_id in range(last_window_id + 1, 2 * last_window_id + 10): + predicted = grid.predict_window(window_id) + x_lim = tx if (tx := params['target_x']) is not None else float('inf') + y_lim = ty if (ty := params['target_x']) is not None else float('inf') + x_fits = np.all(np.abs(predicted.corners[:, 0]) < x_lim) + y_fits = np.all(np.abs(predicted.corners[:, 0]) < y_lim) + if not (x_fits and y_fits): + continue + self.ctrl.stage.set(*[int(xy) for xy in predicted.center]) + return window_id, grid.window_type.from_sweeping() + raise IndexError('Could not locate next window within limits') + + def finalize(self) -> None: + ... + # TODO diff --git a/src/instamatic/experiments/scan_ed/progress.py b/src/instamatic/experiments/scan_ed/progress.py index 3a3d9317..19442014 100644 --- a/src/instamatic/experiments/scan_ed/progress.py +++ b/src/instamatic/experiments/scan_ed/progress.py @@ -77,7 +77,7 @@ def add_scan( y0: int, direction: str, span: int, - n_frames: int, + step: int, ): """Add a new child scan line to the tree called Scan #.""" window_iid = self._window_iid(window) @@ -87,7 +87,7 @@ def add_scan( geom = f'y: {y0}, x: {x0} -> {x0 + span}' else: geom = f'x: {x0}, y: {y0} -> {y0 + span}' - values = (geom, '-', '-', n_frames, '-', '-') + values = (geom, '-', '-', -(-span // step), '-', '-') self.tree.insert(window_iid, tk.END, iid=scan_iid, text=scan_name, values=values) self._scan_geom = [x0, y0, direction, span] diff --git a/src/instamatic/experiments/scan_ed/state.py b/src/instamatic/experiments/scan_ed/state.py index 2d72dee1..6758cfa6 100644 --- a/src/instamatic/experiments/scan_ed/state.py +++ b/src/instamatic/experiments/scan_ed/state.py @@ -14,7 +14,7 @@ WindowFactory: Callable[[float, float, float, ...], type[ConvexPolygonWindow]] -class SPEDState: +class State: """Stores the current state of the SPED experiment in history dataframe.""" def __init__(self, journal: Journal, progress: Optional[ProgressTable] = None) -> None: @@ -82,19 +82,20 @@ def add_scan( y0: int, direction: str, span: int, - n_frames: int, + step: int, ) -> None: """Append to scans and pre-allocate space in the steps dataframe.""" - new_scan = {'x0': x0, 'y0': y0, 'direction': direction, 'span': span} + new_scan = {'x0': x0, 'y0': y0, 'direction': direction, 'span': span, 'step': step} self.scans.loc[window, scan] = new_scan + n_steps = -(-span // step) new_steps = pd.DataFrame( { - 'window': np.full(n_frames, window, dtype=np.uint16), - 'scan': np.full(n_frames, scan, dtype=np.uint16), - 'step': np.arange(n_frames, dtype=np.uint16), - 'success': pd.array([pd.NA] * n_frames, dtype='boolean'), - 'n_peaks': np.zeros(n_frames, dtype=np.uint16), + 'window': np.full(n_steps, window, dtype=np.uint16), + 'scan': np.full(n_steps, scan, dtype=np.uint16), + 'step': np.arange(n_steps, dtype=np.uint16), + 'success': pd.array([pd.NA] * n_steps, dtype='boolean'), + 'n_peaks': np.zeros(n_steps, dtype=np.uint16), } ) new_steps.set_index(['window', 'scan', 'step'], inplace=True) diff --git a/src/instamatic/grid/window.py b/src/instamatic/grid/window.py index 6a3cbb26..8664f7ac 100644 --- a/src/instamatic/grid/window.py +++ b/src/instamatic/grid/window.py @@ -119,7 +119,7 @@ def x_intersections(self, y: float_nm) -> Optional[tuple[float, float]]: """Return (x_min, x_max) for a horizontal line intersecting at y.""" intersection_xs: list[float] = [] for x1, y1, x2, y2 in pairwise(self.corners, closed=True): - if y1 == y2: # work with edge case , close to zero + if y1 == y2: # edge case (degeneracy / double counting) continue intersection_fraction = (y - y1) / (y2 - y1) if not 0 < intersection_fraction < 1: @@ -129,6 +129,20 @@ def x_intersections(self, y: float_nm) -> Optional[tuple[float, float]]: return None return min(intersection_xs), max(intersection_xs) + def y_intersections(self, x: float_nm) -> Optional[tuple[float, float]]: + """Return (y_min, y_max) for a vertical line intersecting at x.""" + intersection_ys: list[float] = [] + for x1, y1, x2, y2 in pairwise(self.corners, closed=True): + if x1 == x2: # edge case (degeneracy / double counting) + continue + intersection_fraction = (x - x1) / (x2 - x1) + if not 0 < intersection_fraction < 1: + continue # does not intersect + intersection_ys.append(y1 + (y2 - y1) * intersection_fraction) + if len(intersection_ys) < 2: + return None + return min(intersection_ys), max(intersection_ys) + class RectangularWindow(ConvexPolygonWindow): """Describes one rectangular window without assumptions about the grid. diff --git a/src/instamatic/gui/scan_ed_frame.py b/src/instamatic/gui/scan_ed_frame.py index d552320d..46d74758 100644 --- a/src/instamatic/gui/scan_ed_frame.py +++ b/src/instamatic/gui/scan_ed_frame.py @@ -1,11 +1,13 @@ from __future__ import annotations -from functools import wraps +from pathlib import Path +from threading import Event as ThreadingEvent from tkinter import * from tkinter.ttk import * -from typing import Any, Callable, Optional +from typing import Any, Callable, Optional, Union from instamatic import controller +from instamatic._typing import AnyPath from instamatic.experiments.scan_ed.progress import ProgressTable from instamatic.utils.spinbox import Spinbox @@ -25,7 +27,7 @@ class ExperimentalScanEDVariables: """A collection of tkinter Variable instances passed to the experiment.""" - def __init__(self, on_change: Optional[Callable[[], None]] = None) -> None: + def __init__(self) -> None: self.grid_geometry = StringVar() self.scan_geometry = StringVar() self.scan_x_step = IntVar(value=500) @@ -38,34 +40,20 @@ def __init__(self, on_change: Optional[Callable[[], None]] = None) -> None: self.target_time = IntVar(value=480) self.target_hits_b = BooleanVar(value=False) - self.target_steps_b = BooleanVar(value=False) self.target_x_b = BooleanVar(value=False) self.target_y_b = BooleanVar(value=False) self.target_time_b = BooleanVar(value=False) - if on_change: - self._add_callback(on_change) + self.stop_event = ThreadingEvent() - def _add_callback(self, callback: Callable[[], None]) -> None: - """Add a safe trace callback to all `Variable` instances in self.""" - - @wraps(callback) - def safe_callback(*_): - try: - callback() - except TclError as e: # Ignore invalid/incomplete GUI edits - if 'expected floating-point number' not in str(e): - raise - except AttributeError as e: # Ignore incomplete initialization - if 'object has no attribute' not in str(e): - raise - - for name, var in vars(self).items(): - if isinstance(var, Variable): - var.trace_add('write', safe_callback) - - def as_dict(self): - return {n: v.get() for n, v in vars(self).items() if isinstance(v, Variable)} + def as_dict(self) -> dict[str, Union[float, int, str]]: + """Return self as dict, replace values with None if key_b is False.""" + d = {n: v.get() for n, v in vars(self).items() if isinstance(v, Variable)} + for key in d.copy().keys(): + if (key_b := key + '_b') in d: + if d.pop(key_b) is False: + d[key] = None + return d class ExperimentalScanED(LabelFrame, ModuleFrameMixin): @@ -144,38 +132,58 @@ def __init__(self, parent): self.progress = ProgressTable(f) self.progress.grid(row=10, columnspan=4, sticky=NSEW, padx=10, pady=10) - self.start_button = Button(f, text='Start', command=self.start_collection) - self.start_button.grid(row=20, column=0, sticky=EW, padx=(10, 0)) + g = Frame(self) + for column in range(3): + g.grid_columnconfigure(column, weight=1, uniform='buttons') - self.restore_button = Button(f, text='Restore', command=self.start_collection) - self.restore_button.grid(row=20, column=1, sticky=EW) + self.start_button = Button(g, text='Start collection', command=self.start_collection) + self.start_button.grid(row=20, column=0, sticky=EW) - self.stop_button = Button(f, text='Stop', command=self.start_collection) - self.stop_button.grid(row=20, column=2, sticky=EW) + self.load_button = Button(g, text='Load and continue', command=self.load_collection) + self.load_button.grid(row=20, column=1, sticky=EW) - self.finalize_button = Button(f, text='Finalize', command=self.start_collection) - self.finalize_button.grid(row=20, column=3, sticky=EW, padx=(0, 10)) + self.stop_button = Button(g, text='Stop collection', command=self.var.stop_event.set) + self.stop_button.grid(row=20, column=2, sticky=EW) + g.pack(side='bottom', fill=BOTH, expand=True, padx=10) f.pack(side='bottom', fill=BOTH, expand=True, pady=10) def start_collection(self) -> None: - self.q.put(('fast_adt', {'frame': self, **self.var.as_dict()})) + kwargs = {'load': True, 'progress': self.progress} + self.q.put(('scan_ed', {**kwargs, **self.var.as_dict()})) + + def load_collection(self) -> None: + kwargs = {'progress': self.progress} + self.q.put(('scan_ed', {**kwargs, **self.var.as_dict()})) def sced_interface_command(controller, **params: Any) -> None: - from instamatic.experiments import scan_ed as sped_module + from instamatic.experiments.scan_ed.experiment import Experiment + from instamatic.experiments.scan_ed.journal import Journal + from instamatic.experiments.scan_ed.state import State - scan_ed_frame: ExperimentalScanED = params['frame'] + load: bool = params.get('load', False) + progress: Optional[ProgressTable] = params.get('progress', None) flat_field = controller.module_io.get_flatfield() - exp_dir = controller.module_io.get_new_experiment_directory() - exp_dir.mkdir(exist_ok=True, parents=True) - controller.fast_adt = sped_module.Experiment( + if load: + exp_dir = controller.module_io.get_experiment_directory() + journal_path = Path(exp_dir) / 'journal.jsonl' + assert journal_path.is_file(), f'No journal file found at {journal_path}' + journal = Journal(path=journal_path) + state = State(journal=journal, progress=progress) + state.load_from_journal() + else: + exp_dir = controller.module_io.get_new_experiment_directory() + exp_dir.mkdir(exist_ok=True, parents=True) + + controller.fast_adt = Experiment( ctrl=controller.ctrl, path=exp_dir, log=controller.log, flatfield=flat_field, - scan_ed_frame=scan_ed_frame, + progress=progress, + state=state, ) try: controller.fast_adt.start_collection(**params) From aaad5d15b8d62d50c326186553793463bf16e204 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Thu, 29 Jan 2026 15:58:24 +0100 Subject: [PATCH 029/118] WIP; rewrite state, progress, experiment using new window and step syntax --- .../experiments/scan_ed/experiment.py | 94 ++++++++-------- .../experiments/scan_ed/progress.py | 86 ++++++++------- src/instamatic/experiments/scan_ed/state.py | 101 ++++++++---------- 3 files changed, 139 insertions(+), 142 deletions(-) diff --git a/src/instamatic/experiments/scan_ed/experiment.py b/src/instamatic/experiments/scan_ed/experiment.py index 933b6976..7ffd75d2 100644 --- a/src/instamatic/experiments/scan_ed/experiment.py +++ b/src/instamatic/experiments/scan_ed/experiment.py @@ -120,61 +120,30 @@ def start_collection(self, **params) -> None: while not stop_event.is_set(): try: - window_id, window = self.locate_next_window(grid=grid, params=params) + window_idx, window = self.locate_next_window(grid=grid, params=params) except IndexError: break grid.windows['window_id'] = window - self.state.add_window(idx=window_id, window=window) - - if params['scan_geometry'].lower().startswith('x'): - fast_axis, scan_factory = 'x', window.x_intersections - fast_step, slow_step = params['scan_x_step'], params['scan_y_step'] - slow_axis_idx = 1 - else: # params['scan_geometry'].lower().startswith('y'): - fast_axis, scan_factory = 'y', window.y_intersections - fast_step, slow_step = params['scan_y_step'], params['scan_x_step'] - slow_axis_idx = 0 - - if params['scan_geometry'].lower().endswith('raster'): - scan_signs = cycle([1, -1]) - else: # params['scan_geometry'].lower().endswith('raster'): - scan_signs = [ - 1, - ] - - slow_min = np.min(window.corners[:, slow_axis_idx]) - slow_max = np.max(window.corners[:, slow_axis_idx]) - for scan_id, slow in enumerate( - np.arange(slow_min + slow_step, slow_max, slow_step) - ): - fast_min, fast_max = scan_factory(float(slow)) - self.state.add_scan( - window=window_id, - scan_id=scan_id, - x0=fast_min if fast_axis == 'x' else slow_min, - y0=fast_min if fast_axis == 'y' else slow_min, - direction=('+' if next(scan_signs) >= 0 else '-') + fast_axis, - span=abs(fast_max - fast_min), - step=fast_step, - ) + self.state.add_window(idx=window_idx, window=window) + + self.add_scans(window_idx=window_idx, params=params) - for scan_id in self.state.scans.loc[window_id].index: - idx = pd.IndexSlice[window_id, scan_id, :] - if self.state.steps.loc[idx, 'success'].notna().any(): + for scan_id in self.state.scans.loc[window_idx].index: + idx = pd.IndexSlice[window_idx, scan_id, :] + if np.any(self.state.steps.loc[idx, 'n_peaks'] != -1): continue # this scan has been already done - x0 = self.state.scans.at[(window_id, scan_id), 'x0'] - y0 = self.state.scans.at[(window_id, scan_id), 'y0'] + scan = self.state.scans.loc[(window_idx, scan_id)] + x0 = scan['x0'] + y0 = scan['y0'] self.ctrl.stage.set(x0=x0, y0=y0) movie = self.ctrl.get_movie( n_frames=len(idx), exposure=exposure, header_keys=None ) - self.dispatcher.switch_buffer(len(idx), name=f'w:{window_id}/s:{scan_id}') - span = self.state.scans.at[(window_id, scan_id), 'span'] - direction = self.state.scans.at[(window_id, scan_id), 'direction'] - sign = +1 if direction.startswith('+') else -1 - fast1 = (x0 if direction.endswith('X') else y0) + sign * span - setter_kwargs = {fast_axis: fast1, 'speed': speed} + self.dispatcher.switch_buffer(len(idx), name=f'w:{window_idx}/s:{scan_id}') + axis = scan['axis'] + fast1 = (x0, y0)[axis] + scan['step'] * scan['n_steps'] + setter_kwargs = {'xy'[axis]: fast1, 'speed': speed} self.ctrl.stage.set_with_speed(**setter_kwargs) for frame, header in movie: @@ -210,6 +179,41 @@ def locate_next_window( return window_id, grid.window_type.from_sweeping() raise IndexError('Could not locate next window within limits') + def add_scans(self, window_idx: int, params: dict[str, Any]) -> None: + """Add scans for window, asserting it does not have scans yet.""" + + window = self.state.grid.windows[window_idx] + if params['scan_geometry'].lower().startswith('x'): + axis = 0 + scan_factory = window.x_intersections + step = params['scan_x_step'] + spacing = params['scan_y_step'] + else: # params['scan_geometry'].lower().startswith('y'): + axis = 1 + scan_factory = window.y_intersections + step = params['scan_y_step'] + spacing = params['scan_x_step'] + + if params['scan_geometry'].lower().endswith('raster'): + scan_signs = cycle([1, -1]) + else: # params['scan_geometry'].lower().endswith('raster'): + scan_signs = cycle([1]) + + slow_min = np.min(window.corners[:, 1 - axis]) + slow_max = np.max(window.corners[:, 1 - axis]) + slows = np.arange(slow_min + spacing, slow_max, spacing, dtype=int) + for scan_id, slow in enumerate(slows): + fast_min, fast_max = scan_factory(slow)[:: next(scan_signs)] + self.state.add_scan( + window=window_idx, + scan_id=scan_id, + x0=slow_min if axis else fast_min, + y0=fast_min if axis else slow_min, + axis=axis, + step=step, + n_steps=-(-abs(fast_max - fast_min) % step), + ) + def finalize(self) -> None: ... # TODO diff --git a/src/instamatic/experiments/scan_ed/progress.py b/src/instamatic/experiments/scan_ed/progress.py index 19442014..6b1761e9 100644 --- a/src/instamatic/experiments/scan_ed/progress.py +++ b/src/instamatic/experiments/scan_ed/progress.py @@ -16,14 +16,14 @@ def __repr__(self) -> str: ... class ProgressTable(ttk.Frame): """Use a ttk.TreeView to display the progress of scanning experiment.""" - COLUMNS = 'Geometry hits refls steps hits/step refls/step'.split() + COLUMNS = 'geometry hits peaks steps hits/step peaks/step'.split() def __init__(self, parent: tk.Misc, **kwargs) -> None: super().__init__(parent, **kwargs) self.tree = None self._build_tree() - self._scan_geom: list[Union[int, str]] = [] - self._window_totals: tuple[int, int, int] = (0, 0, 0) # hits, refls, steps + self._scan_geom: dict[tuple[int, int], tuple[int, int, int, int, int]] = {} + self._window_totals: tuple[int, int, int] = (0, 0, 0) # hits, peaks, steps def _build_tree(self) -> None: self.tree = ttk.Treeview(self, columns=self.COLUMNS, show='tree headings') @@ -32,12 +32,12 @@ def _build_tree(self) -> None: self.tree.heading(column, text=column) self.tree.column('#0', width=30, stretch=True) - self.tree.column('Geometry', anchor=tk.CENTER, width=60) + self.tree.column('geometry', anchor=tk.CENTER, width=120) self.tree.column('hits', anchor=tk.E, width=20) - self.tree.column('refls', anchor=tk.E, width=20) + self.tree.column('peaks', anchor=tk.E, width=20) self.tree.column('steps', anchor=tk.E, width=20) self.tree.column('hits/step', anchor=tk.E, width=20) - self.tree.column('refls/step', anchor=tk.E, width=20) + self.tree.column('peaks/step', anchor=tk.E, width=20) vsb = ttk.Scrollbar(orient='vertical', command=self.tree.yview) hsb = ttk.Scrollbar(orient='horizontal', command=self.tree.xview) @@ -75,68 +75,72 @@ def add_scan( scan: int, x0: int, y0: int, - direction: str, - span: int, + axis: int, step: int, - ): - """Add a new child scan line to the tree called Scan #.""" + n_steps: int, + ) -> None: + """Add a new child scan line to the tree called Scan # (planned).""" window_iid = self._window_iid(window) scan_iid = self._scan_iid(window, scan) scan_name = f'Scan {scan:d}' - if direction.endswith('x'): - geom = f'y: {y0}, x: {x0} -> {x0 + span}' + + start = (x0, y0)[axis] + end = start + step * n_steps + + if axis == 0: # x + geom = f'y={y0}, x: {start} -> {end}' else: - geom = f'x: {x0}, y: {y0} -> {y0 + span}' - values = (geom, '-', '-', -(-span // step), '-', '-') + geom = f'x={x0}, y: {start} -> {end}' + + values = (geom, '-', '-', str(int(n_steps)), '-', '-') self.tree.insert(window_iid, tk.END, iid=scan_iid, text=scan_name, values=values) - self._scan_geom = [x0, y0, direction, span] + + self._scan_geom[(window, scan)] = (x0, y0, axis, step, n_steps) def fill_scan( self, window: int, scan: int, - success: Union[np.ndarray, Sequence[Union[bool, None]]], + hits: Union[np.ndarray, Sequence[bool]], n_peaks: Union[np.ndarray, Sequence[int]], ) -> None: """Add lines for successful experiments, update scan & column lines.""" scan_iid = self._scan_iid(window, scan) window_iid = self._window_iid(window) - x0, y0, direction, span = self._scan_geom - step = span / len(success) * (-1 if direction.startswith('-') else 1) + x0, y0, axis, step, n_steps = self._scan_geom[(int(window), int(scan))] - s_hits = sum(bool(s) for s in success) - s_refls = sum(int(n) for ok, n in zip(success, n_peaks) if ok) - s_steps = len(success) + s_hits = sum(hits) + s_peaks = sum(int(n) for ok, n in zip(hits, n_peaks) if ok) + s_steps = len(hits) s_hits_per_step = s_hits / s_steps if s_steps else 0.0 - s_refls_per_step = s_refls / s_steps if s_steps else 0.0 + s_peaks_per_step = s_peaks / s_steps if s_steps else 0.0 self.tree.set(scan_iid, 'hits', str(s_hits)) - self.tree.set(scan_iid, 'refls', str(s_refls)) + self.tree.set(scan_iid, 'peaks', str(s_peaks)) self.tree.set(scan_iid, 'steps', str(s_steps)) self.tree.set(scan_iid, 'hits/step', f'{s_hits_per_step:.3g}') - self.tree.set(scan_iid, 'refls/step', f'{s_refls_per_step:.3g}') + self.tree.set(scan_iid, 'peaks/step', f'{s_peaks_per_step:.3g}') w_hits = self._window_totals[0] + s_hits - w_refls = self._window_totals[1] + s_refls + w_peaks = self._window_totals[1] + s_peaks w_steps = self._window_totals[2] + s_steps w_hits_per_step = w_hits / w_steps if w_steps else 0.0 - w_refls_per_step = w_refls / w_steps if w_steps else 0.0 - self._window_totals = (w_hits, w_refls, w_steps) + w_peaks_per_step = w_peaks / w_steps if w_steps else 0.0 + self._window_totals = (w_hits, w_peaks, w_steps) self.tree.set(window_iid, 'hits', str(w_hits)) - self.tree.set(window_iid, 'refls', str(w_refls)) + self.tree.set(window_iid, 'peaks', str(w_peaks)) self.tree.set(window_iid, 'steps', str(w_steps)) self.tree.set(window_iid, 'hits/step', f'{w_hits_per_step:.3g}') - self.tree.set(window_iid, 'refls/step', f'{w_refls_per_step:.3g}') + self.tree.set(window_iid, 'peaks/step', f'{w_peaks_per_step:.3g}') - for i, (ok, n) in enumerate(zip(success, n_peaks)): + for i, (ok, n) in enumerate(zip(hits, n_peaks)): if not ok: continue step_name = f'Step {i:d}' step_iid = self._step_iid(window, scan, i) - axis = direction[-1] - geom = f'{axis}: {int((x0 if axis == "x" else y0) + i * step)}' + geom = f'{"xy"[axis]}: {(x0, y0)[axis] + i * step}' values = (geom, '', int(n), '', '', '') self.tree.insert(scan_iid, tk.END, iid=step_iid, text=step_name, values=values) @@ -151,8 +155,8 @@ def wrapper(self, *args, **kwargs): if (progress := getattr(self, 'progress', None)) is not None: bound = method_signature.bind(self, *args, **kwargs) bound.apply_defaults() - kwargs = {k: v for k, v in bound.arguments.items() if k != 'self'} - getattr(progress, method.__name__)(**kwargs) + kwargs2 = {k: v for k, v in bound.arguments.items() if k != 'self'} + getattr(progress, method.__name__)(**kwargs2) return out return wrapper @@ -163,10 +167,16 @@ def wrapper(self, *args, **kwargs): root.title('Test progress listbox') listbox = ProgressTable(root) listbox.pack(side=tk.TOP, fill=tk.BOTH, expand=True) + listbox.add_window(0, 'Some geometry') - listbox.add_scan(0, 0, 100, 200, '+x', 1000, 50) - listbox.fill_scan(0, 0, success=[True, False, True, None, True], n_peaks=[12, 3, 8, 0, 21]) - listbox.add_scan(0, 1, 90, 210, '+x', 1020, 50) - listbox.fill_scan(0, 1, success=[1, 0, 1, 0, 1, 1], n_peaks=[17, 3, 28, 0, 21, 19]) + + # axis=0 => x scan, step sign gives direction + listbox.add_scan(0, 0, x0=100, y0=200, axis=0, step=50, n_steps=6) + listbox.fill_scan( + 0, 0, hits=[True, False, True, False, True, False], n_peaks=[12, 3, 8, 0, 21, 0] + ) + + listbox.add_scan(0, 1, x0=400, y0=210, axis=1, step=-25, n_steps=5) + listbox.fill_scan(0, 1, hits=[1, 0, 1, 0, 1], n_peaks=[17, 3, 28, 0, 21]) root.mainloop() diff --git a/src/instamatic/experiments/scan_ed/state.py b/src/instamatic/experiments/scan_ed/state.py index 6758cfa6..b2719c24 100644 --- a/src/instamatic/experiments/scan_ed/state.py +++ b/src/instamatic/experiments/scan_ed/state.py @@ -1,7 +1,5 @@ from __future__ import annotations -import ast -import importlib from typing import Callable, Optional, Sequence, Union import numpy as np @@ -9,6 +7,7 @@ from instamatic.experiments.scan_ed.journal import Journal, edits_journal from instamatic.experiments.scan_ed.progress import ProgressTable, edits_progress +from instamatic.grid.grid import ConvexPolygonGrid from instamatic.grid.window import ConvexPolygonWindow WindowFactory: Callable[[float, float, float, ...], type[ConvexPolygonWindow]] @@ -17,37 +16,41 @@ class State: """Stores the current state of the SPED experiment in history dataframe.""" - def __init__(self, journal: Journal, progress: Optional[ProgressTable] = None) -> None: + def __init__( + self, + journal: Journal, + grid: ConvexPolygonGrid, + progress: Optional[ProgressTable] = None, + ) -> None: self.journal: Journal = journal - self.progress: ProgressTable = progress + self.grid: ConvexPolygonGrid = grid + self.progress: Optional[ProgressTable] = progress - self.windows: dict[int, ConvexPolygonWindow] = {} self.scans: pd.DataFrame = pd.DataFrame() self.steps: pd.DataFrame = pd.DataFrame() self._init_dataframes() def _init_dataframes(self) -> None: """Create a new empty history with required index and columns.""" - self.scans = pd.DataFrame( - { - 'window': pd.Series(dtype=np.uint16), - 'scan': pd.Series(dtype=np.uint16), - 'x0': pd.Series(dtype=np.int32), - 'y0': pd.Series(dtype=np.int32), - 'direction': pd.Series(dtype=np.str_), - 'span': pd.Series(dtype=np.uint32), - } - ) + scan_columns = { + 'window': pd.Series(dtype=np.uint16), + 'scan': pd.Series(dtype=np.uint16), + 'x0': pd.Series(dtype=np.int32), + 'y0': pd.Series(dtype=np.int32), + 'axis': pd.Series(dtype=np.uint8), + 'step': pd.Series(dtype=np.int32), + 'n_steps': pd.Series(dtype=np.uint16), + } + steps_columns = { + 'window': pd.Series(dtype=np.uint16), + 'scan': pd.Series(dtype=np.uint16), + 'step': pd.Series(dtype=np.uint16), + 'hits': pd.Series(dtype=np.bool), + 'n_peaks': pd.Series(dtype=np.int16), + } + self.scans = pd.DataFrame(scan_columns) self.scans.set_index(['window', 'scan'], inplace=True) - self.steps = pd.DataFrame( - { - 'window': pd.Series(dtype=np.uint16), - 'scan': pd.Series(dtype=np.uint16), - 'step': pd.Series(dtype=np.uint16), - 'success': pd.Series(dtype=pd.BooleanDtype), - 'n_peaks': pd.Series(dtype=np.uint16), - } - ) + self.steps = pd.DataFrame(steps_columns) self.steps.set_index(['window', 'scan', 'step'], inplace=True) def load_from_journal(self) -> None: @@ -59,18 +62,9 @@ def load_from_journal(self) -> None: @edits_journal @edits_progress - def add_window(self, idx: int, window: Union[ConvexPolygonWindow, str]) -> None: + def add_window(self, idx: int, window: ConvexPolygonWindow) -> None: """For journaling purposes, can be added via instance or __repr__.""" - if isinstance(window, str): - body = ast.parse(window, mode='eval').body - assert isinstance(body, ast.Call), f'Failed to eval "{window}"' - assert isinstance(body.func, ast.Name), f'Failed to eval "{window}"' - window_class_name = body.func.id - kwargs = {kw.arg: ast.literal_eval(kw.value) for kw in body.keywords} - window_module = importlib.import_module('instamatic.grid.window') - window_class = getattr(window_module, window_class_name) - window = window_class(**kwargs) - self.windows[idx] = window + self.grid.windows[idx] = window @edits_journal @edits_progress @@ -80,26 +74,17 @@ def add_scan( scan: int, x0: int, y0: int, - direction: str, - span: int, + axis: int, step: int, + n_steps: int, ) -> None: """Append to scans and pre-allocate space in the steps dataframe.""" - new_scan = {'x0': x0, 'y0': y0, 'direction': direction, 'span': span, 'step': step} - self.scans.loc[window, scan] = new_scan - - n_steps = -(-span // step) - new_steps = pd.DataFrame( - { - 'window': np.full(n_steps, window, dtype=np.uint16), - 'scan': np.full(n_steps, scan, dtype=np.uint16), - 'step': np.arange(n_steps, dtype=np.uint16), - 'success': pd.array([pd.NA] * n_steps, dtype='boolean'), - 'n_peaks': np.zeros(n_steps, dtype=np.uint16), - } - ) - new_steps.set_index(['window', 'scan', 'step'], inplace=True) - self.steps = pd.concat([self.steps, new_steps], copy=False) + scan_cols = ['x0', 'y0', 'axis', 'step', 'n_steps'] + self.scans.loc[(window, scan), scan_cols] = (x0, y0, axis, step, n_steps) + idx_names = ['window', 'scan', 'step'] + idx = pd.MultiIndex.from_product([[window], [scan], range(n_steps)], names=idx_names) + self.steps.loc[idx, 'hits'] = np.full(n_steps, False, dtype=np.bool) + self.steps.loc[idx, 'n_peaks'] = np.full(n_steps, -1, dtype=np.int16) @edits_journal @edits_progress @@ -107,15 +92,13 @@ def fill_scan( self, window: int, scan: int, - success: Union[np.ndarray, Sequence[Union[bool, None]]], + hits: Union[np.ndarray, Sequence[bool]], n_peaks: Union[np.ndarray, Sequence[int]], ) -> None: """Fill a previously-added scan with success/n_peaks in one update.""" idx = pd.IndexSlice[window, scan, :] - - n_rows = self.steps.loc[idx].shape[0] - if len(success) != n_rows or len(n_peaks) != n_rows: - raise ValueError(f'Expected {n_rows} steps, got {len(success)=}, {len(n_peaks)=}') - - self.steps.loc[idx, 'success'] = pd.array(success, dtype='boolean') + n_rows = self.scans.loc[(window, scan), 'n_steps'] + if len(hits) != n_rows or len(n_peaks) != n_rows: + raise ValueError(f'Expected {n_rows} steps, got {len(hits)=}, {len(n_peaks)=}') + self.steps.loc[idx, 'hits'] = np.array(hits, dtype=np.bool) self.steps.loc[idx, 'n_peaks'] = np.asarray(n_peaks, dtype=np.uint16) From 0d2578c971f29a9637ee20a04e8a5d7d8a766382 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Fri, 30 Jan 2026 20:57:41 +0100 Subject: [PATCH 030/118] Its friday evening, God only remembers what fixes are here TBH --- .../experiments/scan_ed/detection.py | 4 +- .../experiments/scan_ed/dispatch.py | 325 ++++++++---------- .../experiments/scan_ed/encoding.py | 26 ++ .../experiments/scan_ed/experiment.py | 96 +++--- src/instamatic/experiments/scan_ed/journal.py | 22 ++ .../experiments/scan_ed/progress.py | 160 +++++++-- src/instamatic/experiments/scan_ed/state.py | 64 +++- src/instamatic/gui/proxy.py | 77 +++++ src/instamatic/gui/scan_ed_frame.py | 13 +- 9 files changed, 495 insertions(+), 292 deletions(-) create mode 100644 src/instamatic/experiments/scan_ed/encoding.py create mode 100644 src/instamatic/gui/proxy.py diff --git a/src/instamatic/experiments/scan_ed/detection.py b/src/instamatic/experiments/scan_ed/detection.py index 80855e9f..57c4b072 100644 --- a/src/instamatic/experiments/scan_ed/detection.py +++ b/src/instamatic/experiments/scan_ed/detection.py @@ -22,7 +22,7 @@ class DiffHuntResults: mask: Optional[np.ndarray] = None -def ring_quartile_detection( +def ring_percentile_detection( frame: np.ndarray, min_radius: int = 40, percentile: float = 99.0, @@ -208,5 +208,5 @@ def make_cross_mask(): path = rf'C:\Users\tchon\x\Instamatic_RATS_cRED_benchmark\instamatic_19\tiff\00{i:03d}.tiff' tiff = Image.open(path) image = np.array(tiff) - results = ring_quartile_detection(image, mask=mask) + results = ring_percentile_detection(image, mask=mask) plot_diffraction_debug(image, results) diff --git a/src/instamatic/experiments/scan_ed/dispatch.py b/src/instamatic/experiments/scan_ed/dispatch.py index 1f506d6b..ed813287 100644 --- a/src/instamatic/experiments/scan_ed/dispatch.py +++ b/src/instamatic/experiments/scan_ed/dispatch.py @@ -1,28 +1,27 @@ from __future__ import annotations import multiprocessing as mp -import multiprocessing.shared_memory import queue -import threading import uuid -from dataclasses import dataclass, field +from dataclasses import dataclass +from multiprocessing.shared_memory import SharedMemory from pathlib import Path -from typing import Optional +from typing import TYPE_CHECKING, Optional import numpy as np -import pandas as pd from typing_extensions import Literal from instamatic._typing import AnyPath +from instamatic.experiments.scan_ed.detection import DiffHuntResults, ring_percentile_detection from instamatic.formats import write_tiff -N_PROCESSORS = 4 - +if TYPE_CHECKING: + from instamatic.experiments.scan_ed.state import State -mp.set_start_method('spawn', force=True) +N_PROCESSORS = 4 -Task = Literal['PROCESS', 'WRITE', 'TERMINATE'] -Event = Literal['PROCESSING', 'PROCESSED', 'SWITCHED', 'TERMINATED'] +Task = Literal['INIT', 'PROCESS', 'WRITE', 'TERMINATE'] +Event = Literal['PROCESSING', 'PROCESSED'] @dataclass(frozen=True) @@ -42,220 +41,174 @@ class Feedback: event: Event worker_id: int - buffer_name: Optional[str] = None buffer_pointer: Optional[int] = None - details: Optional[dict] = None + details: Optional[DiffHuntResults] = None class DiffHuntDispatcher: """Proxy class: ask workers on other processes if image has diffraction""" - @dataclass - class Worker: - buffer: str = '' - busy: bool = False - pointer: Optional[int] = None - process: mp.Process = None - - @dataclass - class Buffer: - frames: np.ndarray - name: str - shm: mp.shared_memory.SharedMemory - pointer: int = 0 - pointers: set[int] = field(default_factory=set) # currently processed - workers: set[int] = field(default_factory=set) # attached to buffer - - def __init__(self, shape, dtype): + def __init__(self, shape: tuple[int, int], dtype: np.dtype) -> None: self.shape: tuple[int, int] = shape - self.dtype: np.dtype = dtype - + self.dtype: np.dtype = np.dtype(dtype) self.commands: mp.Queue[Command] = mp.Queue() self.feedback: mp.Queue[Feedback] = mp.Queue() - self.workers: dict[int, DiffHuntDispatcher.Worker] = self.initialize_workers() - self.buffers: dict[str, DiffHuntDispatcher.Buffer] = {} - self.history = pd.DataFrame(columns=['buffer', 'pointer', 'has_diffraction', 'header']) - self.history.set_index(['buffer', 'pointer'], inplace=True) - def initialize_workers(self) -> dict[int, DiffHuntDispatcher.Worker]: + self._workers: list[mp.Process] = [] + self._spawn_workers() + + self._buffer_name: str = '' + self._shm: Optional[SharedMemory] = None + self._frames: Optional[np.ndarray] = None + + self._n_frames: int = 0 + self._next_ptr: int = 0 + self._in_flight: set[int] = set() + + self.hits: Optional[np.ndarray] = None + self.headers: list[Optional[dict]] = [] + + def _spawn_workers(self) -> None: """Run once at the start of experiment to spawn eval processes.""" - workers = {} - for i in range(N_PROCESSORS): - worker = DiffHuntWorker(i, self.commands, self.feedback, self.dtype) + for wid in range(N_PROCESSORS): + worker = DiffHuntWorker(wid, self.commands, self.feedback, self.dtype) worker.start() - workers[i] = self.Worker(process=worker) - return workers - - def switch_buffer(self, n_frames: int = 100, name: str = None) -> None: - """Configure a new mp shared memory space to buffer a frame stack.""" - name = name if name is not None else uuid.uuid4().hex - shape = (n_frames, self.shape[0], self.shape[1]) - size = np.prod(shape) * np.dtype(self.dtype).itemsize - shm = mp.shared_memory.SharedMemory(name=name, create=True, size=size) - frames = np.ndarray(shape, dtype=self.dtype, buffer=shm.buf) - self.buffers[name] = self.Buffer(frames=frames, name=name, shm=shm) - - def write_buffer(self, path: AnyPath) -> None: - """Save all the frames with diffraction in an active buffer.""" - ab = list(self.buffers.values())[-1] # last i.e. active buffer - h = self.history - to_save = h[(h.index.get_level_values('buffer') == ab.name) & h['has_diffraction']] - for t in to_save.itertuples(): - buffer_name, pointer = t.Index - h = t.header - self.emit('WRITE', buffer_name, pointer, kwargs={'path': path, 'header': h}) - - # COMMANDING METHODS THAT DISPATCH COMMANDS TO WORKERS + self._workers.append(worker) def emit(self, task: Task, *args, **kwargs) -> None: """Shorthand to create and put Command in the self.commands queue.""" self.commands.put(Command(task, *args, **kwargs)) - def process(self, frame: np.ndarray, header: Optional[dict]) -> None: - """Request 'PROCESS' from buffer stored on some shared memory.""" - ab = list(self.buffers.values())[-1] # last i.e. active buffer - if ab.pointer >= ab.frames.shape[0]: - raise RuntimeError(f'{ab.name} buffer overflow') - ab.frames[ab.pointer, :, :] = frame - self.emit('PROCESS', ab.name, buffer_pointer=ab.pointer, buffer_shape=ab.frames.shape) - self.history.loc[(ab.name, ab.pointer), 'header'] = header - self.history.loc[(ab.name, ab.pointer), 'has_diffraction'] = None - ab.pointers.add(ab.pointer) - ab.pointer += 1 - - def terminate_workers(self) -> None: - """Command all workers to 'TERMINATE' and report the success.""" - for _ in self.workers: - self.emit('TERMINATE') + def begin_scan(self, n_frames: int, name: Optional[str] = None) -> None: + """Allocate a new shared buffer and reset all tracking for one scan.""" + self._buffer_name = name or uuid.uuid4().hex + self._n_frames = int(n_frames) + self._next_ptr = 0 + self._in_flight.clear() + shape3 = (self._n_frames, self.shape[0], self.shape[1]) + size = int(np.prod(shape3) * self.dtype.itemsize) + self._shm = SharedMemory(name=self._buffer_name, create=True, size=size) + self._frames = np.ndarray(shape3, dtype=self.dtype, buffer=self._shm.buf) + self.hits = np.zeros(self._n_frames, dtype=bool) + self.headers = [None] * self._n_frames + for _ in self._workers: + self.emit('INIT', buffer_name=self._buffer_name, buffer_shape=shape3) + + def end_scan(self) -> None: + """Release shared memory for the active scan.""" + if self._shm is None: + return + try: + self._shm.close() + self._shm.unlink() + finally: + self._shm = None + self._frames = None + self._buffer_name = '' + self._n_frames = 0 + self._next_ptr = 0 + self._in_flight.clear() + self.hits = None + self.headers = [] + + def submit(self, frame: np.ndarray, header: Optional[dict]) -> int: + """Copy a frame into the shared buffer and enqueue processing.""" + if self._frames is None: + raise RuntimeError('Call begin_scan() first.') + if self._next_ptr >= self._n_frames: + raise RuntimeError('Buffer overflow for active scan.') + + ptr = self._next_ptr + self._frames[ptr, :, :] = frame + self.headers[ptr] = header + self._in_flight.add(ptr) + self._next_ptr += 1 + + self.commands.put(Command('PROCESS', buffer_pointer=ptr)) + return ptr + + def all_frames_processed(self) -> bool: + """All submitted frames have been processed and scan is complete.""" + return (self._next_ptr == self._n_frames) and (not self._in_flight) + + def write_scan(self, path: AnyPath) -> None: + """Request workers to write all hit frames from the active scan.""" + for pointer, hit in enumerate(self.hits): + if hit: + bn = self._buffer_name + kwargs = {'path': path, 'header': self.headers[pointer]} + self.emit('WRITE', buffer_name=bn, buffer_pointer=pointer, kwargs=kwargs) # HANDLE FEEDBACK INCOMING FROM THE WORKERS - def handle_feedback(self, stop_event: threading.Event) -> None: - """To be called in a separate thread to handle incoming feedback.""" - while not stop_event.is_set(): + def drain_feedback(self, state: State, window: int, scan: int) -> None: + """Drains feedback queue; If using tk, call from main thread only!""" + for _ in range(2 * self._n_frames): try: - fb: Feedback = self.feedback.get(timeout=0.05) - except queue.Empty: - continue + fb: Feedback = self.feedback.get(timeout=15) + except queue.Empty as e: + raise RuntimeError('Did not receive Feedback within 15s') from e - worker = self.workers.get(fb.worker_id) + pointer = int(fb.buffer_pointer) if fb.event == 'PROCESSING': - worker.busy = True - worker.buffer = fb.buffer_name - worker.pointer = fb.buffer_pointer - buffer = self.buffers.get(fb.buffer_name) - buffer.workers.add(fb.worker_id) - buffer.pointers.add(fb.buffer_pointer) + state.mark_processing(window, scan, pointer) elif fb.event == 'PROCESSED': - idx = (fb.buffer_name, fb.buffer_pointer) - has = fb.details.get('has_diffraction', False) - self.history.at[idx, 'has_diffraction'] = has - worker.busy = False - worker.pointer = None - buffer = self.buffers.get(fb.buffer_name) - buffer.pointers.discard(fb.buffer_pointer) - - elif fb.event == 'SWITCHED': - if old_buffer := worker.buffer: - self.buffers.get(old_buffer).workers.discard(fb.worker_id) - worker.buffer = fb.buffer_name - self.buffers.get(fb.buffer_name).workers.add(fb.worker_id) - self._maybe_release_buffer(self.buffers.get(fb.buffer_name)) - - elif fb.event == 'TERMINATED': - self._terminate_worker(fb.worker_id) - - else: - raise ValueError(f'Unknown feedback event {fb.event}') - - def _maybe_release_buffer(self, buffer: DiffHuntDispatcher.Buffer): - """If the buffer has no workers and no plans, release its memory.""" - if not buffer.workers and not buffer.pointers: - try: - buffer.shm.close() - buffer.shm.unlink() - except Exception as e: - print(f'Warning: could not release buffer {buffer.name}: {e}') - finally: - self.buffers.pop(buffer.name, None) - - def _terminate_worker(self, worker_id: int) -> None: - """Once the worker is ready to terminate, join and close it.""" - worker = self.workers.pop(worker_id) - if worker.buffer: - buffer = self.buffers.get(worker.buffer) - if buffer: - buffer.workers.discard(worker_id) - self._maybe_release_buffer(buffer) - worker.process.join() - worker.process.close() + d: DiffHuntResults = fb.details + state.fill_step(window, scan, pointer, d.success, len(d.peaks)) + if self.hits is not None: + self.hits[pointer] = d.success + self._in_flight.discard(pointer) + + def terminate_workers(self) -> None: + """Command all workers to 'TERMINATE' and report the success.""" + for _ in self._workers: + self.emit('TERMINATE') + for p in self._workers: + p.join() + p.close() + self._workers.clear() class DiffHuntWorker(mp.Process): - """Stateful diffraction-hunting work process handled by the dispatcher.""" - - def __init__( - self, - worker_id: int, - commands: mp.Queue, - feedback: mp.Queue, - dtype: np.dtype, - ): + def __init__(self, worker_id: int, commands: mp.Queue, feedback: mp.Queue, dtype: np.dtype): super().__init__(daemon=True) self.worker_id = worker_id self.commands = commands self.feedback = feedback - self.dtype = dtype - - self.frames: np.ndarray = np.array([], dtype=dtype) - self.buffer_name: str = '' - - def emit(self, event: Event, *args, **kwargs) -> None: - """Put worker_id followed by all args in the self.feedback queue.""" - self.feedback.put(Feedback(event, self.worker_id, *args, **kwargs)) + self.dtype = np.dtype(dtype) + self._frames: Optional[np.ndarray] = None + self._shm: Optional[SharedMemory] = None def run(self) -> None: - """Main loop passing incoming commands to respective methods.""" while True: - cmd: Command = self.commands.get(block=True) - - if cmd.task == 'PROCESS': - self._process(cmd.buffer_name, cmd.buffer_shape, cmd.buffer_pointer) + cmd: Command = self.commands.get() + + if cmd.task == 'INIT': + if self._shm is not None: + self._shm.close() + self._shm = SharedMemory(name=cmd.buffer_name) + self._frames = np.ndarray( + cmd.buffer_shape, dtype=self.dtype, buffer=self._shm.buf + ) + + elif cmd.task == 'PROCESS': + ptr = int(cmd.buffer_pointer) + frame = self._frames[ptr] + d = ring_percentile_detection(frame=frame) + self.feedback.put( + Feedback('PROCESSED', self.worker_id, buffer_pointer=ptr, details=d) + ) elif cmd.task == 'WRITE': - self._write(cmd.buffer_name, cmd.buffer_pointer, **cmd.kwargs) + path = Path(cmd.kwargs['path']).resolve() + filename = f'{cmd.buffer_name}_{cmd.buffer_pointer:06d}.tiff' + frame = self._frames[cmd.buffer_pointer] + header = cmd.kwargs.get('header', {}) + write_tiff(fname=str(path / filename), data=frame, header=header) elif cmd.task == 'TERMINATE': - self.emit('TERMINATED') + if self._shm is not None: + self._shm.close() return - - else: - raise ValueError(f'Unknown command: {cmd}') - - def _process( - self, - buffer_name: str, - buffer_shape: tuple, - frame_index: int, - ) -> None: - """Handles the 'PROCESS' frame command.""" - - if buffer_name != self.buffer_name: - self.buffer_name = buffer_name - shm = mp.shared_memory.SharedMemory(name=buffer_name) - self.frames = np.ndarray(buffer_shape, dtype=self.dtype, buffer=shm.buf) - self.emit('SWITCHED', buffer_name=buffer_name) - - self.emit('PROCESSING', buffer_name=buffer_name, buffer_pointer=frame_index) - frame = self.frames[frame_index] - d = {'has_diffraction': detect_diffraction(frame)} - self.emit('PROCESSED', buffer_name=buffer_name, buffer_pointer=frame_index, details=d) - - def _write(self, buffer_name: str, frame_index: int, **kwargs) -> None: - """Handles the 'WRITE' command or runs after _process if auto-write.""" - path = kwargs.get('path', '') - header = kwargs.get('header', None) - fn = str(Path(path).resolve() / f'{buffer_name}_{frame_index:04d}.tiff') - write_tiff(fname=fn, data=self.frames[frame_index], header=header) diff --git a/src/instamatic/experiments/scan_ed/encoding.py b/src/instamatic/experiments/scan_ed/encoding.py new file mode 100644 index 00000000..172a91bb --- /dev/null +++ b/src/instamatic/experiments/scan_ed/encoding.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +import base64 + +import numpy as np + + +def encode_hits(h: np.ndarray) -> str: + packed = np.packbits(np.asarray(h, np.bool_), bitorder='little').tobytes() + return base64.b64encode(packed).decode('ascii') + + +def decode_hits(b64: str, n: int) -> np.ndarray: + raw = base64.b64decode(b64.encode('ascii')) + bits = np.unpackbits(np.frombuffer(raw, np.uint8), bitorder='little') + return bits[:n].astype(np.bool_) + + +def encode_i16(a: np.ndarray) -> str: + a = np.asarray(a, dtype=np.int16) + return base64.b64encode(a.tobytes()).decode('ascii') + + +def decode_i16(s: str) -> np.ndarray: + raw = base64.b64decode(s.encode('ascii')) + return np.frombuffer(raw, dtype=np.int16) diff --git a/src/instamatic/experiments/scan_ed/experiment.py b/src/instamatic/experiments/scan_ed/experiment.py index 7ffd75d2..7210961f 100644 --- a/src/instamatic/experiments/scan_ed/experiment.py +++ b/src/instamatic/experiments/scan_ed/experiment.py @@ -40,9 +40,9 @@ def __init__( self.flatfield: Optional[np.ndarray] = flatfield self.state = self.get_state(load=load, progress=progress) - self.dispatcher = DiffHuntDispatcher(shape=(514, 514), dtype=np.uint16) - self.dispatcher.initialize_workers() - # TODO init the dispatcher correctly once actual camera size is known + # attributes initialized once an experiment starts + self.params: dict[str, Any] = {} + self.dispatcher: Optional[DiffHuntDispatcher] = None def get_dead_time( self, @@ -63,6 +63,11 @@ def get_dead_time( else: return c.dead_time + def get_dispatcher(self) -> DiffHuntDispatcher: + """Start a multiprocessing helper once you have full access to cam.""" + image, h = self.ctrl.get_image() + return DiffHuntDispatcher(shape=image.shape, dtype=image.dtype) + def get_stage_translation(self) -> CalibStageTranslationX: """Get rotation calibration if present; otherwise warn & terminate.""" try: @@ -97,24 +102,20 @@ def get_grid(self, params: dict[str, Any]) -> ConvexPolygonGrid: grid.windows[wid] = w return grid - def determine_exposure_and_speed( - self, - exposure: float, - step_size: int_nm, - ) -> tuple[float, float]: - detector_dead_time = self.get_dead_time(exposure) - time_for_one_frame = exposure + detector_dead_time + def determine_exposure_and_speed(self, step_size: int_nm) -> tuple[float, float]: + """Determine exposure/speed reachable by TEM close to requested.""" + detector_dead_time = self.get_dead_time(self.params['exposure']) + time_for_one_frame = self.params['exposure'] + detector_dead_time trans_calib = self.get_stage_translation() - mot_plan = trans_calib.plan_motion(1e9 * time_for_one_frame / step_size) - exposure = abs(mot_plan.pace * step_size) - detector_dead_time - speed = mot_plan.speed - return exposure, speed + motion_plan = trans_calib.plan_motion(time_for_one_frame / step_size) + exposure = abs(motion_plan.pace * step_size) - detector_dead_time + return exposure, motion_plan.speed def start_collection(self, **params) -> None: - # precalculate sliding speeds - exposure, speed = self.determine_exposure_and_speed( - params['exposure'], params['step_size'] - ) + """Method that governs the entirety of scan ED experiment work flow.""" + + self.params = params + grid = self.get_grid(params=params) stop_event = params['stop_event'] @@ -128,31 +129,10 @@ def start_collection(self, **params) -> None: self.add_scans(window_idx=window_idx, params=params) - for scan_id in self.state.scans.loc[window_idx].index: - idx = pd.IndexSlice[window_idx, scan_id, :] - if np.any(self.state.steps.loc[idx, 'n_peaks'] != -1): - continue # this scan has been already done - scan = self.state.scans.loc[(window_idx, scan_id)] - x0 = scan['x0'] - y0 = scan['y0'] - self.ctrl.stage.set(x0=x0, y0=y0) - - movie = self.ctrl.get_movie( - n_frames=len(idx), exposure=exposure, header_keys=None - ) - self.dispatcher.switch_buffer(len(idx), name=f'w:{window_idx}/s:{scan_id}') - axis = scan['axis'] - fast1 = (x0, y0)[axis] + scan['step'] * scan['n_steps'] - setter_kwargs = {'xy'[axis]: fast1, 'speed': speed} - - self.ctrl.stage.set_with_speed(**setter_kwargs) - for frame, header in movie: - self.dispatcher.process(frame, header) - # TODO: receive all dispatch feedback - # TODO somehow wait until entire buffer is filled - self.dispatcher.write_buffer(self.path) - # TODO: write from history to state - # TODO: new writing path for every scan + for scan_idx in self.state.scans.loc[window_idx].index: + if self.dispatcher is None: + self.dispatcher = self.get_dispatcher() + self.run_scan(window_idx, scan_idx) # TODO: add missing logic, repeated scans # TODO: state: replace windows list with grid to avoid duplication @@ -214,6 +194,36 @@ def add_scans(self, window_idx: int, params: dict[str, Any]) -> None: n_steps=-(-abs(fast_max - fast_min) % step), ) + def run_scan(self, window_idx: int, scan_idx: int) -> None: + """Run a single scan previously added to state on the grid.""" + + idx = pd.IndexSlice[window_idx, scan_idx, :] + if np.any(self.state.steps.loc[idx, 'n_peaks'] != -1): + return # none-op for all scans that have been already done + + scan = self.state.scans.loc[(window_idx, scan_idx)] + self.ctrl.stage.set(x0=scan['x0'], y0=scan['y0']) + + exposure, speed = self.determine_exposure_and_speed(scan['step']) + movie = self.ctrl.get_movie(n_frames=len(idx), exposure=exposure, header_keys=None) + self.dispatcher.begin_scan(len(idx)) + axis = scan['axis'] # x: 0, y: 1 + fast0 = scan['y0' if axis else 'x0'] + fast1 = fast0 + scan['step'] * scan['n_steps'] + setter_kwargs = {'xy'[axis]: fast1, 'speed': speed} + + self.ctrl.stage.set_with_speed(**setter_kwargs) + for frame, header in movie: + self.dispatcher.process(frame, header) + # TODO: receive all dispatch feedback + # TODO somehow wait until entire buffer is filled + self.dispatcher.write_buffer(self.path) + # TODO: write from history to state + # TODO: new writing path for every scan + + # TODO: this code still needs to be modified but remember this: + self.state.finalize_scan(window_idx, scan_idx) + def finalize(self) -> None: ... # TODO diff --git a/src/instamatic/experiments/scan_ed/journal.py b/src/instamatic/experiments/scan_ed/journal.py index fe86a22d..227a40cd 100644 --- a/src/instamatic/experiments/scan_ed/journal.py +++ b/src/instamatic/experiments/scan_ed/journal.py @@ -21,6 +21,28 @@ def __init__(self, path: AnyPath) -> None: self.path: Path = Path(path) self.writing: bool = True self._seq: int = 0 + self._init_seq_from_file() + + def _init_seq_from_file(self) -> None: + """Initialize sequence counter from existing journal file (if any).""" + if not self.path.exists(): + return + + last = 0 + with self.path.open('r', encoding='utf-8') as f: + for line in f: + line = line.strip() + if not line: + continue + try: + rec = json.loads(line) + except json.JSONDecodeError: + break # tolerate truncated last line + seq = rec.get('seq') + if isinstance(seq, int) and seq > last: + last = seq + + self._seq = last def write(self, method: str, kwargs: dict[str, Any]) -> None: """Write the new event record directly to the journal.""" diff --git a/src/instamatic/experiments/scan_ed/progress.py b/src/instamatic/experiments/scan_ed/progress.py index 6b1761e9..9881b603 100644 --- a/src/instamatic/experiments/scan_ed/progress.py +++ b/src/instamatic/experiments/scan_ed/progress.py @@ -1,10 +1,12 @@ from __future__ import annotations import inspect +import queue import tkinter as tk import tkinter.ttk as ttk +from collections import Counter from functools import wraps -from typing import Callable, Protocol, Sequence, Union +from typing import Any, Callable, Protocol, Sequence, Union import numpy as np @@ -13,6 +15,11 @@ class GridWindowProtocol(Protocol): def __repr__(self) -> str: ... +def safe_ratio(d: dict, k1: str, k2: str, alt: str = '0.0') -> str: + """Return a formatted d1-to-d2 ratio if defined, else hyphen.""" + return f'{d[k1] / v2:.3g}' if (v2 := d[k2]) else alt + + class ProgressTable(ttk.Frame): """Use a ttk.TreeView to display the progress of scanning experiment.""" @@ -22,8 +29,9 @@ def __init__(self, parent: tk.Misc, **kwargs) -> None: super().__init__(parent, **kwargs) self.tree = None self._build_tree() - self._scan_geom: dict[tuple[int, int], tuple[int, int, int, int, int]] = {} - self._window_totals: tuple[int, int, int] = (0, 0, 0) # hits, peaks, steps + self._scan_geom: dict[tuple[int, int], tuple[int, int, int, int]] = {} + self._scan_totals: dict[tuple[int, int], Counter] = {} # hits, peaks, done, n_steps + self._window_totals: dict[int, Counter] = {} # hits, peaks, steps def _build_tree(self) -> None: self.tree = ttk.Treeview(self, columns=self.COLUMNS, show='tree headings') @@ -40,11 +48,9 @@ def _build_tree(self) -> None: self.tree.column('peaks/step', anchor=tk.E, width=20) vsb = ttk.Scrollbar(orient='vertical', command=self.tree.yview) - hsb = ttk.Scrollbar(orient='horizontal', command=self.tree.xview) - self.tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set) + self.tree.configure(yscrollcommand=vsb.set) self.tree.grid(column=0, row=0, sticky='nsew', in_=self) vsb.grid(column=1, row=0, sticky='ns', in_=self) - hsb.grid(column=0, row=1, sticky='ew', in_=self) self.grid_columnconfigure(0, weight=1) self.grid_rowconfigure(0, weight=1) @@ -67,7 +73,7 @@ def add_window(self, idx: int, window: GridWindowProtocol) -> None: geom = repr(window) values = (geom, '-', '-', '-', '-', '-') self.tree.insert('', tk.END, iid=window_iid, text=window_name, values=values) - self._window_totals = (0, 0, 0) + self._window_totals[idx] = Counter() def add_scan( self, @@ -95,7 +101,50 @@ def add_scan( values = (geom, '-', '-', str(int(n_steps)), '-', '-') self.tree.insert(window_iid, tk.END, iid=scan_iid, text=scan_name, values=values) - self._scan_geom[(window, scan)] = (x0, y0, axis, step, n_steps) + self._scan_geom[(window, scan)] = (x0, y0, axis, step) + self._scan_totals[(window, scan)] = Counter(n_steps=int(n_steps)) + self.tree.set(scan_iid, 'steps', f'0/{int(n_steps)}') + + def mark_processing(self, window: int, scan: int, step: int) -> None: + scan_iid = self._scan_iid(window, scan) + for column in 'hits peaks hits/step peaks/step'.split(): + if not self.tree.set(scan_iid, column).isnumeric(): # don't overwrite numbers + self.tree.set(scan_iid, column, '...') + + def fill_step(self, window: int, scan: int, step: int, hit: bool, n_peaks: int) -> None: + scan_iid = self._scan_iid(window, scan) + window_iid = self._window_iid(window) + + st = self._scan_totals[(window, scan)] + st['done'] += 1 + if hit: + st['hits'] += 1 + st['peaks'] += int(n_peaks) + + self.tree.set(scan_iid, 'hits', str(st['hits'])) + self.tree.set(scan_iid, 'peaks', str(st['peaks'])) + self.tree.set(scan_iid, 'steps', f'{st["done"]}/{st["n_steps"]}') + self.tree.set(scan_iid, 'hits/step', safe_ratio(st, 'hits', 'done')) + self.tree.set(scan_iid, 'peaks/step', safe_ratio(st, 'peaks', 'done')) + + wt = self._window_totals[window] + wt['steps'] += 1 + if hit: + wt['hits'] += 1 + wt['peaks'] += int(n_peaks) + + self.tree.set(window_iid, 'hits', str(wt['hits'])) + self.tree.set(window_iid, 'peaks', str(wt['peaks'])) + self.tree.set(window_iid, 'steps', str(wt['steps'])) + self.tree.set(window_iid, 'hits/step', safe_ratio(wt, 'hits', 'steps')) + self.tree.set(window_iid, 'peaks/step', safe_ratio(wt, 'peaks', 'steps')) + + if hit: + x0, y0, axis, step_size = self._scan_geom[(window, scan)] + step_iid = self._step_iid(window, scan, step) + geom = f'{"xy"[axis]}: {(x0, y0)[axis] + step * step_size}' + v = (geom, '', int(n_peaks), '', '', '') + self.tree.insert(scan_iid, tk.END, iid=step_iid, text=f'Step {step}', values=v) def fill_scan( self, @@ -108,41 +157,76 @@ def fill_scan( scan_iid = self._scan_iid(window, scan) window_iid = self._window_iid(window) - x0, y0, axis, step, n_steps = self._scan_geom[(int(window), int(scan))] + hits_arr = np.asarray(hits, dtype=bool) + peaks_arr = np.asarray(n_peaks, dtype=int) - s_hits = sum(hits) - s_peaks = sum(int(n) for ok, n in zip(hits, n_peaks) if ok) - s_steps = len(hits) - s_hits_per_step = s_hits / s_steps if s_steps else 0.0 - s_peaks_per_step = s_peaks / s_steps if s_steps else 0.0 + s_steps = int(hits_arr.size) + s_hits = int(hits_arr.sum()) + s_peaks = int(peaks_arr[hits_arr].sum()) if s_hits else 0 self.tree.set(scan_iid, 'hits', str(s_hits)) self.tree.set(scan_iid, 'peaks', str(s_peaks)) self.tree.set(scan_iid, 'steps', str(s_steps)) - self.tree.set(scan_iid, 'hits/step', f'{s_hits_per_step:.3g}') - self.tree.set(scan_iid, 'peaks/step', f'{s_peaks_per_step:.3g}') - - w_hits = self._window_totals[0] + s_hits - w_peaks = self._window_totals[1] + s_peaks - w_steps = self._window_totals[2] + s_steps - w_hits_per_step = w_hits / w_steps if w_steps else 0.0 - w_peaks_per_step = w_peaks / w_steps if w_steps else 0.0 - self._window_totals = (w_hits, w_peaks, w_steps) - - self.tree.set(window_iid, 'hits', str(w_hits)) - self.tree.set(window_iid, 'peaks', str(w_peaks)) - self.tree.set(window_iid, 'steps', str(w_steps)) - self.tree.set(window_iid, 'hits/step', f'{w_hits_per_step:.3g}') - self.tree.set(window_iid, 'peaks/step', f'{w_peaks_per_step:.3g}') - - for i, (ok, n) in enumerate(zip(hits, n_peaks)): - if not ok: - continue - step_name = f'Step {i:d}' - step_iid = self._step_iid(window, scan, i) - geom = f'{"xy"[axis]}: {(x0, y0)[axis] + i * step}' - values = (geom, '', int(n), '', '', '') - self.tree.insert(scan_iid, tk.END, iid=step_iid, text=step_name, values=values) + self.tree.set(scan_iid, 'hits/step', f'{s_hits / s_steps if s_steps else 0.0:.3g}') + self.tree.set(scan_iid, 'peaks/step', f'{s_peaks / s_steps if s_steps else 0.0:.3g}') + + wt = self._window_totals[window] + wt['hits'] += s_hits + wt['peaks'] += s_peaks + wt['steps'] += s_steps + + self.tree.set(window_iid, 'hits', str(wt['hits'])) + self.tree.set(window_iid, 'peaks', str(wt['peaks'])) + self.tree.set(window_iid, 'steps', str(wt['steps'])) + self.tree.set(window_iid, 'hits/step', safe_ratio(wt, 'hits', 'steps')) + self.tree.set(window_iid, 'peaks/step', safe_ratio(wt, 'peaks', 'steps')) + + +class ThreadSafeProgressTableProxy: + """Thread-safe proxy: same API as ProgressTable, executed on Tk thread.""" + + def __init__(self, parent: tk.Misc, target) -> None: + self._parent = parent + self._target = target + self._q: queue.Queue[tuple[str, tuple[Any, ...], dict[str, Any]]] = queue.Queue() + self._scheduled = False + + def _schedule(self) -> None: + """Lets the main Tk thread know to drain and run commands from _q.""" + if not self._scheduled: + self._scheduled = True + self._parent.after(0, self._drain) + + def _drain(self) -> None: + """Run at the main Tk thread, calls all scheduled commands from _q.""" + self._scheduled = False + while True: + try: + name, args, kwargs = self._q.get_nowait() + except queue.Empty: + break + getattr(self._target, name)(*args, **kwargs) + + def _post(self, name: str, *args, **kwargs) -> None: + """Instead of running command, schedule it to be run on main thread.""" + self._q.put((name, args, kwargs)) + self._schedule() + + # Keep the API fixed and consistent, generalizing this is annoying + def add_window(self, **kwargs): + self._post('add_window', **kwargs) + + def add_scan(self, **kwargs): + self._post('add_scan', **kwargs) + + def mark_processing(self, **kwargs): + self._post('mark_processing', **kwargs) + + def fill_step(self, **kwargs): + self._post('fill_step', **kwargs) + + def fill_scan(self, **kwargs): + self._post('fill_scan', **kwargs) def edits_progress(method: Callable) -> Callable: diff --git a/src/instamatic/experiments/scan_ed/state.py b/src/instamatic/experiments/scan_ed/state.py index b2719c24..0ed6b18c 100644 --- a/src/instamatic/experiments/scan_ed/state.py +++ b/src/instamatic/experiments/scan_ed/state.py @@ -1,10 +1,11 @@ from __future__ import annotations -from typing import Callable, Optional, Sequence, Union +from typing import Callable, Optional import numpy as np import pandas as pd +from instamatic.experiments.scan_ed.encoding import * from instamatic.experiments.scan_ed.journal import Journal, edits_journal from instamatic.experiments.scan_ed.progress import ProgressTable, edits_progress from instamatic.grid.grid import ConvexPolygonGrid @@ -45,7 +46,7 @@ def _init_dataframes(self) -> None: 'window': pd.Series(dtype=np.uint16), 'scan': pd.Series(dtype=np.uint16), 'step': pd.Series(dtype=np.uint16), - 'hits': pd.Series(dtype=np.bool), + 'hits': pd.Series(dtype=np.bool_), 'n_peaks': pd.Series(dtype=np.int16), } self.scans = pd.DataFrame(scan_columns) @@ -83,22 +84,51 @@ def add_scan( self.scans.loc[(window, scan), scan_cols] = (x0, y0, axis, step, n_steps) idx_names = ['window', 'scan', 'step'] idx = pd.MultiIndex.from_product([[window], [scan], range(n_steps)], names=idx_names) - self.steps.loc[idx, 'hits'] = np.full(n_steps, False, dtype=np.bool) + self.steps.loc[idx, 'hits'] = np.full(n_steps, False, dtype=np.bool_) self.steps.loc[idx, 'n_peaks'] = np.full(n_steps, -1, dtype=np.int16) - @edits_journal + def finalize_scan(self, window: int, scan: int) -> None: + idx = pd.IndexSlice[window, scan, :] + n_peaks = self.steps.loc[idx, 'n_peaks'].to_numpy(np.int16, copy=False) + if (n_peaks < 0).any(): + raise RuntimeError('Scan not complete.') + + hits = self.steps.loc[idx, 'hits'].to_numpy(np.bool_, copy=False) + + payload = { + 'window': int(window), + 'scan': int(scan), + 'hits': encode_hits(hits), + 'n_peaks': encode_i16(n_peaks), + } + self.journal.write('fill_encoded_scan', payload) + @edits_progress - def fill_scan( - self, - window: int, - scan: int, - hits: Union[np.ndarray, Sequence[bool]], - n_peaks: Union[np.ndarray, Sequence[int]], - ) -> None: - """Fill a previously-added scan with success/n_peaks in one update.""" + def mark_processing(self, window: int, scan: int, step: int) -> None: + """Mark a step as currently processed by setting n_peaks to -2.""" + idx = (window, scan, step) + if int(self.steps.at[idx, 'n_peaks']) == -1: + self.steps.at[idx, 'n_peaks'] = np.int16(-2) + + @edits_progress + def fill_step(self, window: int, scan: int, step: int, hit: bool, n_peaks: int) -> None: + """Once the step is processed, set correct hit bool and n_peaks.""" + idx = (window, scan, step) + self.steps.at[idx, 'hits'] = bool(hit) + self.steps.at[idx, 'n_peaks'] = np.int16(n_peaks) + + @edits_progress + def fill_scan(self, window: int, scan: int, hits, n_peaks) -> None: + """An alternative to repeated fill_step, fills whole scan at once.""" idx = pd.IndexSlice[window, scan, :] - n_rows = self.scans.loc[(window, scan), 'n_steps'] - if len(hits) != n_rows or len(n_peaks) != n_rows: - raise ValueError(f'Expected {n_rows} steps, got {len(hits)=}, {len(n_peaks)=}') - self.steps.loc[idx, 'hits'] = np.array(hits, dtype=np.bool) - self.steps.loc[idx, 'n_peaks'] = np.asarray(n_peaks, dtype=np.uint16) + self.steps.loc[idx, 'hits'] = np.asarray(hits, dtype=np.bool_) + self.steps.loc[idx, 'n_peaks'] = np.asarray(n_peaks, dtype=np.int16) + + def fill_encoded_scan(self, window: int, scan: int, hits: str, n_peaks: str) -> None: + """To be called ONLY during replay when recreating from journal.""" + n_steps = int(self.scans.loc[(window, scan), 'n_steps']) + hits_arr = decode_hits(hits, n_steps) + peaks_arr = decode_i16(n_peaks) + if peaks_arr.size != n_steps: + raise ValueError(f'Corrupt scan payload: {peaks_arr.size=} != {n_steps=}') + self.fill_scan(window, scan, hits_arr, peaks_arr) diff --git a/src/instamatic/gui/proxy.py b/src/instamatic/gui/proxy.py new file mode 100644 index 00000000..c4770494 --- /dev/null +++ b/src/instamatic/gui/proxy.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import queue +import tkinter as tk +from dataclasses import dataclass +from functools import wraps +from typing import Any, Callable, Optional + + +@dataclass +class _Call: + name: str + args: tuple + kwargs: dict + done: Optional[queue.Queue] = None # for sync calls + + +class TkProxy: + """Thread-safe proxy that runs target.methods in the main Tk thread.""" + + def __init__(self, parent: tk.Misc, target: Any) -> None: + self._tk = parent + self._target = target + self._q: queue.Queue[_Call] = queue.Queue() + self._scheduled = False + + def _schedule(self) -> None: + if not self._scheduled: + self._scheduled = True + self._tk.after(0, self._drain) + + def _drain(self) -> None: + self._scheduled = False + while True: + try: + c = self._q.get_nowait() + except queue.Empty: + break + + try: + fn = getattr(self._target, c.name) + res = fn(*c.args, **c.kwargs) + except Exception as e: + res = e + + if c.done is not None: + c.done.put(res) + + def _post( + self, name: str, *args: Any, done: Optional[queue.Queue] = None, **kwargs: Any + ) -> None: + self._q.put(_Call(name=name, args=args, kwargs=kwargs, done=done)) + self._schedule() + + def __getattr__(self, name: str) -> Callable[..., None]: + """Get attribute (incl. + + methods) from proxy if unavailable in self. + """ + + try: + return object.__getattribute__(self, name) + except AttributeError as e: + reraise_on_fail = e + try: + attr = getattr(self._target, name) + except AttributeError: + raise reraise_on_fail + + if not callable(attr): + return attr + + @wraps(attr) + def async_method(*args: Any, **kwargs: Any) -> None: + self._post(name, *args, **kwargs) + + return async_method diff --git a/src/instamatic/gui/scan_ed_frame.py b/src/instamatic/gui/scan_ed_frame.py index 46d74758..a3ac2775 100644 --- a/src/instamatic/gui/scan_ed_frame.py +++ b/src/instamatic/gui/scan_ed_frame.py @@ -4,11 +4,10 @@ from threading import Event as ThreadingEvent from tkinter import * from tkinter.ttk import * -from typing import Any, Callable, Optional, Union +from typing import Any, Optional, Union from instamatic import controller -from instamatic._typing import AnyPath -from instamatic.experiments.scan_ed.progress import ProgressTable +from instamatic.experiments.scan_ed.progress import ProgressTable, ThreadSafeProgressTableProxy from instamatic.utils.spinbox import Spinbox from .base_module import BaseModule, ModuleFrameMixin @@ -62,6 +61,7 @@ class ExperimentalScanED(LabelFrame, ModuleFrameMixin): def __init__(self, parent): text = 'Automatically scan entire grid until any finish condition is met' super().__init__(parent, text=text) + self.pack_propagate(False) # keep the width fixed self.parent = parent self.var = ExperimentalScanEDVariables() self.busy: bool = False @@ -149,12 +149,13 @@ def __init__(self, parent): f.pack(side='bottom', fill=BOTH, expand=True, pady=10) def start_collection(self) -> None: - kwargs = {'load': True, 'progress': self.progress} + progress = ThreadSafeProgressTableProxy(self, self.progress) + kwargs = {'load': True, 'progress': progress} self.q.put(('scan_ed', {**kwargs, **self.var.as_dict()})) def load_collection(self) -> None: - kwargs = {'progress': self.progress} - self.q.put(('scan_ed', {**kwargs, **self.var.as_dict()})) + progress = ThreadSafeProgressTableProxy(self, self.progress) + self.q.put(('scan_ed', {'progress': progress, **self.var.as_dict()})) def sced_interface_command(controller, **params: Any) -> None: From 597a0752302ae36b7d8803c393493a8438df992b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Mon, 2 Feb 2026 13:30:34 +0100 Subject: [PATCH 031/118] Finish implementing `run_scan` w/ new simplified dispatcher structure --- .../experiments/scan_ed/dispatch.py | 73 ++++++++++--------- .../experiments/scan_ed/experiment.py | 28 ++++--- 2 files changed, 56 insertions(+), 45 deletions(-) diff --git a/src/instamatic/experiments/scan_ed/dispatch.py b/src/instamatic/experiments/scan_ed/dispatch.py index ed813287..9e14dcce 100644 --- a/src/instamatic/experiments/scan_ed/dispatch.py +++ b/src/instamatic/experiments/scan_ed/dispatch.py @@ -6,6 +6,7 @@ from dataclasses import dataclass from multiprocessing.shared_memory import SharedMemory from pathlib import Path +from threading import Event from typing import TYPE_CHECKING, Optional import numpy as np @@ -20,15 +21,15 @@ N_PROCESSORS = 4 -Task = Literal['INIT', 'PROCESS', 'WRITE', 'TERMINATE'] -Event = Literal['PROCESSING', 'PROCESSED'] +CommandKind = Literal['INIT', 'PROCESS', 'WRITE', 'TERMINATE'] +FeedbackKind = Literal['PROCESSING', 'PROCESSED'] @dataclass(frozen=True) class Command: """Schema used to communicate commands from dispatcher to any worker.""" - task: Task + kind: CommandKind buffer_name: Optional[str] = None buffer_pointer: Optional[int] = None buffer_shape: Optional[tuple[int, int, int]] = None @@ -39,7 +40,7 @@ class Command: class Feedback: """Schema used to communicate feedback from any worker to dispatcher.""" - event: Event + kind: FeedbackKind worker_id: int buffer_pointer: Optional[int] = None details: Optional[DiffHuntResults] = None @@ -65,6 +66,7 @@ def __init__(self, shape: tuple[int, int], dtype: np.dtype) -> None: self._next_ptr: int = 0 self._in_flight: set[int] = set() + self.scan_processed: Event = Event() self.hits: Optional[np.ndarray] = None self.headers: list[Optional[dict]] = [] @@ -75,7 +77,7 @@ def _spawn_workers(self) -> None: worker.start() self._workers.append(worker) - def emit(self, task: Task, *args, **kwargs) -> None: + def emit(self, task: CommandKind, *args, **kwargs) -> None: """Shorthand to create and put Command in the self.commands queue.""" self.commands.put(Command(task, *args, **kwargs)) @@ -89,6 +91,7 @@ def begin_scan(self, n_frames: int, name: Optional[str] = None) -> None: size = int(np.prod(shape3) * self.dtype.itemsize) self._shm = SharedMemory(name=self._buffer_name, create=True, size=size) self._frames = np.ndarray(shape3, dtype=self.dtype, buffer=self._shm.buf) + self.scan_processed.clear() self.hits = np.zeros(self._n_frames, dtype=bool) self.headers = [None] * self._n_frames for _ in self._workers: @@ -111,7 +114,7 @@ def end_scan(self) -> None: self.hits = None self.headers = [] - def submit(self, frame: np.ndarray, header: Optional[dict]) -> int: + def process(self, frame: np.ndarray, header: Optional[dict]) -> int: """Copy a frame into the shared buffer and enqueue processing.""" if self._frames is None: raise RuntimeError('Call begin_scan() first.') @@ -127,10 +130,6 @@ def submit(self, frame: np.ndarray, header: Optional[dict]) -> int: self.commands.put(Command('PROCESS', buffer_pointer=ptr)) return ptr - def all_frames_processed(self) -> bool: - """All submitted frames have been processed and scan is complete.""" - return (self._next_ptr == self._n_frames) and (not self._in_flight) - def write_scan(self, path: AnyPath) -> None: """Request workers to write all hit frames from the active scan.""" for pointer, hit in enumerate(self.hits): @@ -139,10 +138,13 @@ def write_scan(self, path: AnyPath) -> None: kwargs = {'path': path, 'header': self.headers[pointer]} self.emit('WRITE', buffer_name=bn, buffer_pointer=pointer, kwargs=kwargs) - # HANDLE FEEDBACK INCOMING FROM THE WORKERS + def handle_feedback(self, state: State, window: int, scan: int) -> None: + """Continuously drain the feedback queue until scan is fully processed. - def drain_feedback(self, state: State, window: int, scan: int) -> None: - """Drains feedback queue; If using tk, call from main thread only!""" + This call modifies a decorated State table. Therefore, either it + must be run from the main thread, or a proxy Progress table must + be used. + """ for _ in range(2 * self._n_frames): try: fb: Feedback = self.feedback.get(timeout=15) @@ -151,15 +153,16 @@ def drain_feedback(self, state: State, window: int, scan: int) -> None: pointer = int(fb.buffer_pointer) - if fb.event == 'PROCESSING': + if fb.kind == 'PROCESSING': state.mark_processing(window, scan, pointer) - elif fb.event == 'PROCESSED': + elif fb.kind == 'PROCESSED': d: DiffHuntResults = fb.details state.fill_step(window, scan, pointer, d.success, len(d.peaks)) if self.hits is not None: self.hits[pointer] = d.success self._in_flight.discard(pointer) + self.scan_processed.set() def terminate_workers(self) -> None: """Command all workers to 'TERMINATE' and report the success.""" @@ -178,37 +181,37 @@ def __init__(self, worker_id: int, commands: mp.Queue, feedback: mp.Queue, dtype self.commands = commands self.feedback = feedback self.dtype = np.dtype(dtype) - self._frames: Optional[np.ndarray] = None - self._shm: Optional[SharedMemory] = None + self.frames: Optional[np.ndarray] = None + self.shm: Optional[SharedMemory] = None + + def emit(self, kind: FeedbackKind, **kwargs) -> None: + self.feedback.put(Feedback(kind=kind, worker_id=self.worker_id, **kwargs)) def run(self) -> None: while True: cmd: Command = self.commands.get() - if cmd.task == 'INIT': - if self._shm is not None: - self._shm.close() - self._shm = SharedMemory(name=cmd.buffer_name) - self._frames = np.ndarray( - cmd.buffer_shape, dtype=self.dtype, buffer=self._shm.buf - ) + if cmd.kind == 'INIT': + if self.shm is not None: + self.shm.close() + self.shm = SharedMemory(name=cmd.buffer_name) + shape = cmd.buffer_shape + self.frames = np.ndarray(shape, dtype=self.dtype, buffer=self.shm.buf) - elif cmd.task == 'PROCESS': + elif cmd.kind == 'PROCESS': ptr = int(cmd.buffer_pointer) - frame = self._frames[ptr] - d = ring_percentile_detection(frame=frame) - self.feedback.put( - Feedback('PROCESSED', self.worker_id, buffer_pointer=ptr, details=d) - ) + self.emit('PROCESSING', buffer_pointer=ptr) + d = ring_percentile_detection(frame=self.frames[ptr]) + self.emit('PROCESSED', buffer_pointer=ptr, details=d) - elif cmd.task == 'WRITE': + elif cmd.kind == 'WRITE': path = Path(cmd.kwargs['path']).resolve() filename = f'{cmd.buffer_name}_{cmd.buffer_pointer:06d}.tiff' - frame = self._frames[cmd.buffer_pointer] + frame = self.frames[cmd.buffer_pointer] header = cmd.kwargs.get('header', {}) write_tiff(fname=str(path / filename), data=frame, header=header) - elif cmd.task == 'TERMINATE': - if self._shm is not None: - self._shm.close() + elif cmd.kind == 'TERMINATE': + if self.shm is not None: + self.shm.close() return diff --git a/src/instamatic/experiments/scan_ed/experiment.py b/src/instamatic/experiments/scan_ed/experiment.py index 7210961f..b9abdc40 100644 --- a/src/instamatic/experiments/scan_ed/experiment.py +++ b/src/instamatic/experiments/scan_ed/experiment.py @@ -3,6 +3,7 @@ import logging from itertools import cycle from pathlib import Path +from threading import Thread from typing import Any, Optional import numpy as np @@ -19,6 +20,7 @@ from instamatic.experiments.scan_ed.state import State from instamatic.grid.grid import ConvexPolygonGrid from instamatic.grid.window import ConvexPolygonWindow, RectangularWindow +from instamatic.utils.beamstop import find_beamstop_rect class Experiment(ExperimentBase): @@ -199,31 +201,37 @@ def run_scan(self, window_idx: int, scan_idx: int) -> None: idx = pd.IndexSlice[window_idx, scan_idx, :] if np.any(self.state.steps.loc[idx, 'n_peaks'] != -1): - return # none-op for all scans that have been already done + return # none-op for a scans that has been already done scan = self.state.scans.loc[(window_idx, scan_idx)] self.ctrl.stage.set(x0=scan['x0'], y0=scan['y0']) - exposure, speed = self.determine_exposure_and_speed(scan['step']) - movie = self.ctrl.get_movie(n_frames=len(idx), exposure=exposure, header_keys=None) self.dispatcher.begin_scan(len(idx)) + fb_kwargs = {'state': self.state, 'window': window_idx, 'scan': scan_idx} + fb_thread = Thread(target=self.dispatcher.handle_feedback, kwargs=fb_kwargs) + fb_thread.start() + + exposure, speed = self.determine_exposure_and_speed(scan['step']) axis = scan['axis'] # x: 0, y: 1 fast0 = scan['y0' if axis else 'x0'] fast1 = fast0 + scan['step'] * scan['n_steps'] setter_kwargs = {'xy'[axis]: fast1, 'speed': speed} - self.ctrl.stage.set_with_speed(**setter_kwargs) + + movie = self.ctrl.get_movie(n_frames=len(idx), exposure=exposure, header_keys=None) for frame, header in movie: self.dispatcher.process(frame, header) - # TODO: receive all dispatch feedback - # TODO somehow wait until entire buffer is filled - self.dispatcher.write_buffer(self.path) - # TODO: write from history to state - # TODO: new writing path for every scan + self.dispatcher.scan_processed.wait(timeout=60) # should process live - # TODO: this code still needs to be modified but remember this: + self.dispatcher.write_scan(path=self.path / 'tiff') + self.dispatcher.end_scan() + fb_thread.join() self.state.finalize_scan(window_idx, scan_idx) + def teardown(self) -> None: + """Close all threads and safely shut down when requested.""" + self.dispatcher.terminate_workers() + def finalize(self) -> None: ... # TODO From f7e67d029ffdda1296c88cebc7f83bc622e4999f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Mon, 2 Feb 2026 14:30:48 +0100 Subject: [PATCH 032/118] Move grid.grid to grid.polygon to make ground for generalization --- src/instamatic/experiments/scan_ed/experiment.py | 12 ++++-------- src/instamatic/experiments/scan_ed/state.py | 9 +++++---- src/instamatic/grid/{grid.py => polygon.py} | 0 3 files changed, 9 insertions(+), 12 deletions(-) rename src/instamatic/grid/{grid.py => polygon.py} (100%) diff --git a/src/instamatic/experiments/scan_ed/experiment.py b/src/instamatic/experiments/scan_ed/experiment.py index b9abdc40..52c664f2 100644 --- a/src/instamatic/experiments/scan_ed/experiment.py +++ b/src/instamatic/experiments/scan_ed/experiment.py @@ -18,7 +18,7 @@ from instamatic.experiments.scan_ed.journal import Journal from instamatic.experiments.scan_ed.progress import ProgressTable from instamatic.experiments.scan_ed.state import State -from instamatic.grid.grid import ConvexPolygonGrid +from instamatic.grid.polygon import ConvexPolygonGrid from instamatic.grid.window import ConvexPolygonWindow, RectangularWindow from instamatic.utils.beamstop import find_beamstop_rect @@ -92,14 +92,14 @@ def get_state(self, load: bool, progress: Optional[ProgressTable] = None) -> Sta def get_grid(self, params: dict[str, Any]) -> ConvexPolygonGrid: """Reconstruct the grid from current params and state.""" - from instamatic.grid.grid import HexagonalGrid, RectangularGrid + from instamatic.grid.polygon import HexagonalGrid, RectangularGrid if params.get('grid_geometry', '').lower().startswith('hex'): grid = HexagonalGrid() else: grid = RectangularGrid() - if self.state.windows: - for wid, w in self.state.windows.items(): + if self.state.grid.windows: + for wid, w in self.state.grid.windows.items(): assert isinstance(w, grid.window_type) grid.windows[wid] = w return grid @@ -136,10 +136,6 @@ def start_collection(self, **params) -> None: self.dispatcher = self.get_dispatcher() self.run_scan(window_idx, scan_idx) - # TODO: add missing logic, repeated scans - # TODO: state: replace windows list with grid to avoid duplication - # TODO: simplify scans logic because right now it is difficult - return def locate_next_window( diff --git a/src/instamatic/experiments/scan_ed/state.py b/src/instamatic/experiments/scan_ed/state.py index 0ed6b18c..9dda6631 100644 --- a/src/instamatic/experiments/scan_ed/state.py +++ b/src/instamatic/experiments/scan_ed/state.py @@ -2,13 +2,12 @@ from typing import Callable, Optional -import numpy as np import pandas as pd from instamatic.experiments.scan_ed.encoding import * from instamatic.experiments.scan_ed.journal import Journal, edits_journal from instamatic.experiments.scan_ed.progress import ProgressTable, edits_progress -from instamatic.grid.grid import ConvexPolygonGrid +from instamatic.grid.polygon import ConvexPolygonGrid from instamatic.grid.window import ConvexPolygonWindow WindowFactory: Callable[[float, float, float, ...], type[ConvexPolygonWindow]] @@ -57,9 +56,11 @@ def _init_dataframes(self) -> None: def load_from_journal(self) -> None: with self.journal.writing_off(): for event in self.journal.events(): - method = getattr(self, event['method']) + method_name = event['method'] kwargs = event.get('kwargs', {}) - method(**kwargs) + if method_name == 'add_window': + kwargs['window'] = GridWindow.from_repr(kwargs['window']) + getattr(self, method_name)(**kwargs) @edits_journal @edits_progress diff --git a/src/instamatic/grid/grid.py b/src/instamatic/grid/polygon.py similarity index 100% rename from src/instamatic/grid/grid.py rename to src/instamatic/grid/polygon.py From eca8b3d458618e3ca36fbba25d55f22e4c0c722d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Mon, 2 Feb 2026 16:34:20 +0100 Subject: [PATCH 033/118] Reorganize the grid code to allow for more kinds of grid, WIP --- src/instamatic/_collections.py | 4 +- .../experiments/scan_ed/experiment.py | 2 +- src/instamatic/grid/grid.py | 148 +++++++++++++ src/instamatic/grid/polygon.py | 201 +++--------------- src/instamatic/grid/registry.py | 66 ++++++ src/instamatic/grid/sweepers.py | 2 +- src/instamatic/grid/utils.py | 0 src/instamatic/grid/window.py | 2 +- 8 files changed, 249 insertions(+), 176 deletions(-) create mode 100644 src/instamatic/grid/grid.py create mode 100644 src/instamatic/grid/registry.py create mode 100644 src/instamatic/grid/utils.py diff --git a/src/instamatic/_collections.py b/src/instamatic/_collections.py index 3d1d1ba7..81d97e0b 100644 --- a/src/instamatic/_collections.py +++ b/src/instamatic/_collections.py @@ -62,8 +62,8 @@ def format_field(self, value: Any, format_spec: str) -> str: class VersionedDict(MutableMapping[T1, T2]): """A dict whose version changes with every mutation; useful for caching.""" - def __init__(self) -> None: - self._d: dict[T1, T2] = {} + def __init__(self, d: dict = None) -> None: + self._d: dict[T1, T2] = d or {} self.version = 0 def __getitem__(self, k: T1) -> T2: diff --git a/src/instamatic/experiments/scan_ed/experiment.py b/src/instamatic/experiments/scan_ed/experiment.py index 52c664f2..2d1e7281 100644 --- a/src/instamatic/experiments/scan_ed/experiment.py +++ b/src/instamatic/experiments/scan_ed/experiment.py @@ -19,7 +19,7 @@ from instamatic.experiments.scan_ed.progress import ProgressTable from instamatic.experiments.scan_ed.state import State from instamatic.grid.polygon import ConvexPolygonGrid -from instamatic.grid.window import ConvexPolygonWindow, RectangularWindow +from instamatic.grid.window import ConvexPolygonWindow, SquareWindow from instamatic.utils.beamstop import find_beamstop_rect diff --git a/src/instamatic/grid/grid.py b/src/instamatic/grid/grid.py new file mode 100644 index 00000000..fedb84e7 --- /dev/null +++ b/src/instamatic/grid/grid.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +from typing import Annotated, Generic, Protocol, TypeVar, Union, cast + +import numpy as np + +from instamatic._collections import VersionedDict +from instamatic._typing import float_nm, int_nm +from instamatic.grid.window import ConvexPolygonWindow + +DualIndex = tuple[int, int] +SpiralIndex = Annotated[int, 'positive'] +WindowIndex = Union[DualIndex, SpiralIndex] +WindowType = TypeVar('WindowType', bound=ConvexPolygonWindow) + + +class PairingFunction(Protocol): + def __call__(self, i: int, j: int, /) -> int: ... + + +class PairingInverse(Protocol): + def __call__(self, n: int, /) -> tuple[int, int]: ... + + +class Grid(Generic[WindowType]): + """Abstract base class for any TEM grid. + + Container for grid windows. Lists and documents class methods and + attributes that must be implemented or work when inherited by every + grid. + """ + + window_type: type[WindowType] + + def __init__(self, windows: dict[int, WindowType] = None) -> None: + self.windows: VersionedDict[int, WindowType] = VersionedDict(windows or {}) + + +class ConvexPolygonGrid(Grid[WindowType]): + """A grid where all windows are convex polygons.""" + + +class PeriodicConvexPolygonGrid(ConvexPolygonGrid[WindowType]): + """A ConvexPolygonGrid with identical windows and on a 2D ab-lattice. + + The conventional, most-expected lattice kind for ED experiments. + Every window is an identical convex polygon placed in the same + distance from other windows, as determined by the grid support + thickness. Utilizes internal coordinate system of its "central" + window 0, with two axes, "a" & "b", selected in such a way that the + angle between axes "a" and coordinate X is minimal, and the angle + from "a" to "b" is positive (clockwise) and minimal. The length of + "a" and "b" should match expected distance to next windows. + """ + + pairing_function: PairingFunction + pairing_inverse: PairingInverse + + def __init__(self, windows: dict[int, WindowType] = None, spacing: int = 10_000) -> None: + super().__init__(windows) + self.default_spacing: int_nm = spacing + self._spacing_cache_version = 0 + self._spacing = spacing + + @property + def a(self) -> np.ndarray: + """Grid coordinate vector aligned with X pointing to next window.""" + w0 = self.windows[0] + return w0.w_axis * (2.0 + float(self.spacing) / np.linalg.norm(w0.w_axis)) + + @property + def b(self) -> np.ndarray: + """Second grid coordinate vector (not ~X) pointing to next window.""" + w0 = self.windows[0] + return w0.h_axis * (2.0 + float(self.spacing) / np.linalg.norm(w0.h_axis)) + + @property + def spacing(self) -> float_nm: + """Cached property of self.windows: stores spacing between windows.""" + if self._spacing_cache_version < self.windows.version: + self._spacing = self._estimate_spacing() + self._spacing_cache_version = self.windows.version + return self._spacing + + def _estimate_spacing(self) -> float_nm: + """Estimate actual spacing found between all defined grid windows.""" + if 0 not in self.windows or len(self.windows) < 2: + return float(self.default_spacing) + + w0 = self.windows[0] + a_axis = np.asarray(w0.w_axis, dtype=float) + b_axis = np.asarray(w0.h_axis, dtype=float) + a_hat = a_axis / np.linalg.norm(a_axis) + b_hat = b_axis / np.linalg.norm(b_axis) + + ijs = self.windows_ij.astype(float) # (N,2) + centers = self.windows_xy.astype(float) # (N,2) + deltas = centers - np.asarray(w0.center, dtype=float) + + mask = ~((ijs[:, 0] == 0) & (ijs[:, 1] == 0)) + ijs = ijs[mask] + deltas = deltas[mask] + + # Solve deltas ≈ [i j] @ [a_step; b_step] + # i.e. two independent least squares, one per coordinate component. + m, *_ = np.linalg.lstsq(ijs, deltas, rcond=None) + step_a, step_b = m[0], m[1] + + # Only use estimates along a/b axis if i/j coordinate changes + spacing_candidates: list[float] = [] + if np.any(ijs[:, 0] != 0): + if np.isfinite(s_w := float(np.dot(step_a - 2.0 * a_axis, a_hat))): + spacing_candidates.append(s_w) + if np.any(ijs[:, 1] != 0): + if np.isfinite(s_h := float(np.dot(step_b - 2.0 * b_axis, b_hat))): + spacing_candidates.append(s_h) + + if not spacing_candidates: + return float(self.default_spacing) + return float(max(0.0, float(np.mean(spacing_candidates)))) + + @property + def windows_ij(self) -> np.ndarray: + """A Nx2 array of all existing window dual indices in windows order.""" + ulam_indices = list(self.windows.keys()) + return np.array([self.pairing_inverse(u) for u in ulam_indices], dtype=int) + + @property + def windows_xy(self) -> np.ndarray: + """A Nx2 array of all existing window centers in windows order.""" + return np.array([w.center for w in self.windows.values()], dtype=float) + + def nearest_window(self, idx: WindowIndex) -> SpiralIndex: + """Return Ulam index of existing window nearest to the one with idx.""" + predicted_center = self.predict_center(idx) + offsets2 = np.sum((self.windows_xy - predicted_center) ** 2, axis=1) + nearest = int(np.argmin(offsets2)) + return list(self.windows.keys())[nearest] + + def predict_center(self, idx: WindowIndex) -> np.ndarray: + """Predict center position of window idx given the rest of the grid.""" + ij: DualIndex = self.pairing_inverse(idx) if isinstance(idx, int) else idx + return self.windows[0].center + self.a * ij[0] + self.b * ij[1] + + def predict_window(self, idx: WindowIndex) -> WindowType: + """Predict the window of index idx given the rest of the grid.""" + w0_delta = self.predict_center(idx) - self.windows[0].center + return cast(WindowType, self.windows[0].translated(w0_delta)) diff --git a/src/instamatic/grid/polygon.py b/src/instamatic/grid/polygon.py index 913663a4..cbf16350 100644 --- a/src/instamatic/grid/polygon.py +++ b/src/instamatic/grid/polygon.py @@ -1,188 +1,47 @@ from __future__ import annotations -from typing import Annotated, Generic, TypeVar, Union, cast - from matplotlib import pyplot as plt from matplotlib.axes import Axes from matplotlib.figure import Figure from matplotlib.patches import Polygon from matplotlib.ticker import FuncFormatter -from instamatic._collections import VersionedDict -from instamatic._typing import float_nm, int_nm -from instamatic.grid.pairing import * -from instamatic.grid.window import ConvexPolygonWindow, HexagonalWindow, RectangularWindow - -DualIndex = tuple[int, int] -UlamIndex = Annotated[int, 'positive'] -WindowIndex = Union[DualIndex, UlamIndex] -WindowType = TypeVar('WindowType', bound=ConvexPolygonWindow) - - -class ConvexPolygonGrid(Generic[WindowType]): - window_type: type[WindowType] - pairing_function: PairingFunction - pairing_inverse: PairingInverse - - def __init__(self) -> None: - self.windows: VersionedDict[int, WindowType] = VersionedDict() - self.default_spacing: int_nm = 10_000 - self._spacing_cache_version = 0 - self._spacing = 10_000 - - def _estimate_spacing(self) -> float_nm: - """Estimate actual spacing between all defined windows.""" - if 0 not in self.windows or len(self.windows) < 2: - return float(self.default_spacing) - - w0 = self.windows[0] - w_axis = np.asarray(w0.w_axis, dtype=float) - h_axis = np.asarray(w0.h_axis, dtype=float) - w_hat = w_axis / np.linalg.norm(w_axis) - h_hat = h_axis / np.linalg.norm(h_axis) - - ijs = self.windows_ij.astype(float) # (N,2) - centers = self.windows_xy.astype(float) # (N,2) - deltas = centers - np.asarray(w0.center, dtype=float) - - mask = ~((ijs[:, 0] == 0) & (ijs[:, 1] == 0)) - ijs = ijs[mask] - deltas = deltas[mask] - - # Solve deltas ≈ [i j] @ [w_step; h_step] - # i.e. two independent least squares, one per coordinate component. - m, *_ = np.linalg.lstsq(ijs, deltas, rcond=None) - step_w, step_h = m[0], m[1] - - # Only use estimates along w/h axis if i/j coordinate changes - s_candidates: list[float] = [] - if np.any(ijs[:, 0] != 0): - if np.isfinite(s_w := float(np.dot(step_w - 2.0 * w_axis, w_hat))): - s_candidates.append(s_w) - if np.any(ijs[:, 1] != 0): - if np.isfinite(s_h := float(np.dot(step_h - 2.0 * h_axis, h_hat))): - s_candidates.append(s_h) - - if not s_candidates: - return float(self.default_spacing) - return float(max(0.0, float(np.mean(s_candidates)))) - - @property - def coords(self) -> tuple[np.ndarray, np.ndarray]: - """Coordinate vectors along "w" and "h" dirs derived from window 0.""" - s = float(self.spacing) - w0 = self.windows[0] - step_w = 2.0 * w0.w_axis + s * w0.w_axis / np.linalg.norm(w0.w_axis) - step_h = 2.0 * w0.h_axis + s * w0.h_axis / np.linalg.norm(w0.h_axis) - return step_w, step_h - - @property - def spacing(self) -> float_nm: - """Cached property of self.windows: stores spacing between windows.""" - if self._spacing_cache_version < self.windows.version: - self._spacing = self._estimate_spacing() - self._spacing_cache_version = self.windows.version - return self._spacing - - @property - def windows_ij(self) -> np.ndarray: - """A Nx2 array of all existing window dual indices in windows order.""" - ulam_indices = list(self.windows.keys()) - return np.array([self.pairing_inverse(u) for u in ulam_indices], dtype=int) - - @property - def windows_xy(self) -> np.ndarray: - """A Nx2 array of all existing window centers in windows order.""" - return np.array([w.center for w in self.windows.values()], dtype=float) - - def nearest_window(self, idx: WindowIndex) -> UlamIndex: - """Return Ulam index of existing window nearest to the one with idx.""" - predicted_center = self.predict_center(idx) - offsets2 = np.sum((self.windows_xy - predicted_center) ** 2, axis=1) - nearest = int(np.argmin(offsets2)) - return list(self.windows.keys())[nearest] - - def plot(self, show: bool = True) -> tuple[Figure, Axes]: - """Plot grid windows as white polygons on black bg with Ulam labels.""" - - fig, ax = plt.subplots(figsize=(5, 5), dpi=100) - fig.patch.set_facecolor('black') - ax.set_facecolor('black') - - ax.set_aspect('equal', adjustable='box') - ax.set_xlabel('x / um', color='white') - ax.set_ylabel('y / um', color='white') - - ax.tick_params(colors='white', direction='out') - for spine in ax.spines.values(): - spine.set_color('white') - - if not self.windows: - plt.show() - return - - patch_kw = {'facecolor': 'white', 'edgecolor': 'white', 'closed': True} - text_kw = {'color': 'black', 'ha': 'center', 'va': 'center', 'fontsize': 10} - for ulam_idx, w in self.windows.items(): - corners = np.asarray(w.corners, dtype=float) - ax.add_patch(Polygon(corners, **patch_kw)) - cx, cy = w.center - ax.text(cx, cy, str(ulam_idx), **text_kw) - - ax.autoscale() - ax.xaxis.set_major_formatter(FuncFormatter(lambda x, _: f'{x * 1e-3:g}')) - ax.yaxis.set_major_formatter(FuncFormatter(lambda y, _: f'{y * 1e-3:g}')) - - # draw explicit x/y axes through origin for orientation - ax.axhline(0, color='white', linewidth=1.0, alpha=0.6, zorder=0) - ax.axvline(0, color='white', linewidth=1.0, alpha=0.6, zorder=0) - - if show: - plt.show() - return fig, ax - - def predict_center(self, idx: WindowIndex) -> np.ndarray: - """Predict center position of window idx given the rest of the grid.""" - ij: DualIndex = self.pairing_inverse(idx) if isinstance(idx, int) else idx - w, h = self.coords - return self.windows[0].center + w * ij[0] + h * ij[1] - - def predict_window(self, idx: WindowIndex) -> WindowType: - """Predict the window of index idx given the rest of the grid.""" - w0_delta = self.predict_center(idx) - self.windows[0].center - return cast(WindowType, self.windows[0].translated(w0_delta)) - -class HexagonalGrid(ConvexPolygonGrid[HexagonalWindow]): - window_type = HexagonalWindow - pairing_function = staticmethod(uv2spiral) - pairing_inverse = staticmethod(spiral2uv) +def plot(self, show: bool = True) -> tuple[Figure, Axes]: + """Plot grid windows as white polygons on black bg with Ulam labels.""" + fig, ax = plt.subplots(figsize=(5, 5), dpi=100) + fig.patch.set_facecolor('black') + ax.set_facecolor('black') -class RectangularGrid(ConvexPolygonGrid[RectangularWindow]): - window_type = RectangularWindow - pairing_function = staticmethod(ij2ulam) - pairing_inverse = staticmethod(ulam2ij) + ax.set_aspect('equal', adjustable='box') + ax.set_xlabel('x / um', color='white') + ax.set_ylabel('y / um', color='white') + ax.tick_params(colors='white', direction='out') + for spine in ax.spines.values(): + spine.set_color('white') -if __name__ == '__main__': - g = RectangularGrid() - w0 = RectangularWindow(0, 0, 50_000, 50_000, np.deg2rad(10)) - g.windows[0] = w0 - for i in range(200): - p = g.predict_window(i) - if np.linalg.norm(p.center - w0.center) < 400_000: - g.windows[i] = p + if not self.windows: + plt.show() + return - g.plot() + patch_kw = {'facecolor': 'white', 'edgecolor': 'white', 'closed': True} + text_kw = {'color': 'black', 'ha': 'center', 'va': 'center', 'fontsize': 10} + for ulam_idx, w in self.windows.items(): + corners = np.asarray(w.corners, dtype=float) + ax.add_patch(Polygon(corners, **patch_kw)) + cx, cy = w.center + ax.text(cx, cy, str(ulam_idx), **text_kw) - h = HexagonalGrid() - v0 = HexagonalWindow(0, 0, 50_000, np.deg2rad(10)) - h.windows[0] = v0 + ax.autoscale() + ax.xaxis.set_major_formatter(FuncFormatter(lambda x, _: f'{x * 1e-3:g}')) + ax.yaxis.set_major_formatter(FuncFormatter(lambda y, _: f'{y * 1e-3:g}')) - for i in range(200): - q = h.predict_window(i) - if np.linalg.norm(q.center - v0.center) < 400_000: - h.windows[i] = q + # draw explicit x/y axes through origin for orientation + ax.axhline(0, color='white', linewidth=1.0, alpha=0.6, zorder=0) + ax.axvline(0, color='white', linewidth=1.0, alpha=0.6, zorder=0) - h.plot() + if show: + plt.show() + return fig, ax diff --git a/src/instamatic/grid/registry.py b/src/instamatic/grid/registry.py new file mode 100644 index 00000000..4d0300b5 --- /dev/null +++ b/src/instamatic/grid/registry.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from instamatic._collections import NoOverwriteDict +from instamatic.grid.grid import PeriodicConvexPolygonGrid, WindowType +from instamatic.grid.pairing import ij2ulam, spiral2uv, ulam2ij, uv2spiral +from instamatic.grid.window import HexagonalWindow, SquareWindow + +GRID_REGISTRY = NoOverwriteDict[str, WindowType]() + + +def register_grid(name: str): + """A decorator to cleanly puts grid class in GRID_REGISTRY under name.""" + + def decorator(cls): + GRID_REGISTRY[name] = cls + return cls + + return decorator + + +@register_grid(name='Hexagonal') +class HexagonalGrid(PeriodicConvexPolygonGrid[HexagonalWindow]): + window_type = HexagonalWindow + pairing_function = staticmethod(uv2spiral) + pairing_inverse = staticmethod(spiral2uv) + + +@register_grid(name='Rectangular') +class RectangularGrid(PeriodicConvexPolygonGrid[SquareWindow]): + window_type = SquareWindow + pairing_function = staticmethod(ij2ulam) + pairing_inverse = staticmethod(ulam2ij) + + +@register_grid(name='Square') +class SquareGrid(PeriodicConvexPolygonGrid[SquareWindow]): + window_type = SquareWindow + pairing_function = staticmethod(ij2ulam) + pairing_inverse = staticmethod(ulam2ij) + + +# development test code; to be moved to artist/tests + +if __name__ == '__main__': + import numpy as np + + g = RectangularGrid() + w0 = SquareWindow(0, 0, 50_000, 50_000, np.deg2rad(10)) + g.windows[0] = w0 + for i in range(200): + p = g.predict_window(i) + if np.linalg.norm(p.center - w0.center) < 400_000: + g.windows[i] = p + + g.plot() + + h = HexagonalGrid() + v0 = HexagonalWindow(0, 0, 50_000, np.deg2rad(10)) + h.windows[0] = v0 + + for i in range(200): + q = h.predict_window(i) + if np.linalg.norm(q.center - v0.center) < 400_000: + h.windows[i] = q + + h.plot() diff --git a/src/instamatic/grid/sweepers.py b/src/instamatic/grid/sweepers.py index e5cd4089..7f64f362 100644 --- a/src/instamatic/grid/sweepers.py +++ b/src/instamatic/grid/sweepers.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any, ClassVar, Literal, Sequence +from typing import Any, Literal, Sequence import numpy as np from typing_extensions import Self diff --git a/src/instamatic/grid/utils.py b/src/instamatic/grid/utils.py new file mode 100644 index 00000000..e69de29b diff --git a/src/instamatic/grid/window.py b/src/instamatic/grid/window.py index 8664f7ac..d5a6d377 100644 --- a/src/instamatic/grid/window.py +++ b/src/instamatic/grid/window.py @@ -144,7 +144,7 @@ def y_intersections(self, x: float_nm) -> Optional[tuple[float, float]]: return min(intersection_ys), max(intersection_ys) -class RectangularWindow(ConvexPolygonWindow): +class SquareWindow(ConvexPolygonWindow): """Describes one rectangular window without assumptions about the grid. Geometry is described using five immutable float scalars (nm / radian): From 2a88a46dd63a715b405dd0ac2a7844ddd7b3247a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Mon, 2 Feb 2026 17:19:44 +0100 Subject: [PATCH 034/118] Reorganize the window code to allow for more kinds of grid, WIP --- .../experiments/scan_ed/experiment.py | 4 +- src/instamatic/experiments/scan_ed/journal.py | 4 +- src/instamatic/experiments/scan_ed/state.py | 6 +- src/instamatic/grid/grid.py | 12 +- src/instamatic/grid/registry.py | 10 +- src/instamatic/grid/window.py | 334 +++++++++++------- 6 files changed, 228 insertions(+), 142 deletions(-) diff --git a/src/instamatic/experiments/scan_ed/experiment.py b/src/instamatic/experiments/scan_ed/experiment.py index 2d1e7281..827de7f4 100644 --- a/src/instamatic/experiments/scan_ed/experiment.py +++ b/src/instamatic/experiments/scan_ed/experiment.py @@ -19,7 +19,7 @@ from instamatic.experiments.scan_ed.progress import ProgressTable from instamatic.experiments.scan_ed.state import State from instamatic.grid.polygon import ConvexPolygonGrid -from instamatic.grid.window import ConvexPolygonWindow, SquareWindow +from instamatic.grid.window import RectangularWindow, RegularPolygonWindow from instamatic.utils.beamstop import find_beamstop_rect @@ -142,7 +142,7 @@ def locate_next_window( self, grid: ConvexPolygonGrid, params: dict, - ) -> tuple[int, ConvexPolygonWindow]: + ) -> tuple[int, RegularPolygonWindow]: """Find a next window on the grid, or raise if none can be found.""" last_window_id = max(grid.windows) for window_id in range(last_window_id + 1, 2 * last_window_id + 10): diff --git a/src/instamatic/experiments/scan_ed/journal.py b/src/instamatic/experiments/scan_ed/journal.py index 227a40cd..ca344d70 100644 --- a/src/instamatic/experiments/scan_ed/journal.py +++ b/src/instamatic/experiments/scan_ed/journal.py @@ -11,7 +11,7 @@ import numpy as np from instamatic._typing import AnyPath -from instamatic.grid.window import ConvexPolygonWindow +from instamatic.grid.window import RegularPolygonWindow class Journal: @@ -90,7 +90,7 @@ def serialize(obj): return int(obj) elif isinstance(obj, (np.floating,)): return float(obj) - elif isinstance(obj, (ConvexPolygonWindow,)): + elif isinstance(obj, (RegularPolygonWindow,)): return repr(obj) return obj diff --git a/src/instamatic/experiments/scan_ed/state.py b/src/instamatic/experiments/scan_ed/state.py index 9dda6631..63793074 100644 --- a/src/instamatic/experiments/scan_ed/state.py +++ b/src/instamatic/experiments/scan_ed/state.py @@ -8,9 +8,9 @@ from instamatic.experiments.scan_ed.journal import Journal, edits_journal from instamatic.experiments.scan_ed.progress import ProgressTable, edits_progress from instamatic.grid.polygon import ConvexPolygonGrid -from instamatic.grid.window import ConvexPolygonWindow +from instamatic.grid.window import RegularPolygonWindow -WindowFactory: Callable[[float, float, float, ...], type[ConvexPolygonWindow]] +WindowFactory: Callable[[float, float, float, ...], type[RegularPolygonWindow]] class State: @@ -64,7 +64,7 @@ def load_from_journal(self) -> None: @edits_journal @edits_progress - def add_window(self, idx: int, window: ConvexPolygonWindow) -> None: + def add_window(self, idx: int, window: RegularPolygonWindow) -> None: """For journaling purposes, can be added via instance or __repr__.""" self.grid.windows[idx] = window diff --git a/src/instamatic/grid/grid.py b/src/instamatic/grid/grid.py index fedb84e7..34521524 100644 --- a/src/instamatic/grid/grid.py +++ b/src/instamatic/grid/grid.py @@ -6,12 +6,12 @@ from instamatic._collections import VersionedDict from instamatic._typing import float_nm, int_nm -from instamatic.grid.window import ConvexPolygonWindow +from instamatic.grid.window import RegularPolygonWindow DualIndex = tuple[int, int] SpiralIndex = Annotated[int, 'positive'] WindowIndex = Union[DualIndex, SpiralIndex] -WindowType = TypeVar('WindowType', bound=ConvexPolygonWindow) +WindowType = TypeVar('WindowType', bound=RegularPolygonWindow) class PairingFunction(Protocol): @@ -66,13 +66,13 @@ def __init__(self, windows: dict[int, WindowType] = None, spacing: int = 10_000) def a(self) -> np.ndarray: """Grid coordinate vector aligned with X pointing to next window.""" w0 = self.windows[0] - return w0.w_axis * (2.0 + float(self.spacing) / np.linalg.norm(w0.w_axis)) + return w0.a * (2.0 + float(self.spacing) / np.linalg.norm(w0.a)) @property def b(self) -> np.ndarray: """Second grid coordinate vector (not ~X) pointing to next window.""" w0 = self.windows[0] - return w0.h_axis * (2.0 + float(self.spacing) / np.linalg.norm(w0.h_axis)) + return w0.b * (2.0 + float(self.spacing) / np.linalg.norm(w0.b)) @property def spacing(self) -> float_nm: @@ -88,8 +88,8 @@ def _estimate_spacing(self) -> float_nm: return float(self.default_spacing) w0 = self.windows[0] - a_axis = np.asarray(w0.w_axis, dtype=float) - b_axis = np.asarray(w0.h_axis, dtype=float) + a_axis = np.asarray(w0.a, dtype=float) + b_axis = np.asarray(w0.b, dtype=float) a_hat = a_axis / np.linalg.norm(a_axis) b_hat = b_axis / np.linalg.norm(b_axis) diff --git a/src/instamatic/grid/registry.py b/src/instamatic/grid/registry.py index 4d0300b5..13430538 100644 --- a/src/instamatic/grid/registry.py +++ b/src/instamatic/grid/registry.py @@ -3,7 +3,7 @@ from instamatic._collections import NoOverwriteDict from instamatic.grid.grid import PeriodicConvexPolygonGrid, WindowType from instamatic.grid.pairing import ij2ulam, spiral2uv, ulam2ij, uv2spiral -from instamatic.grid.window import HexagonalWindow, SquareWindow +from instamatic.grid.window import HexagonalWindow, RectangularWindow, SquareWindow GRID_REGISTRY = NoOverwriteDict[str, WindowType]() @@ -26,14 +26,14 @@ class HexagonalGrid(PeriodicConvexPolygonGrid[HexagonalWindow]): @register_grid(name='Rectangular') -class RectangularGrid(PeriodicConvexPolygonGrid[SquareWindow]): - window_type = SquareWindow +class RectangularGrid(PeriodicConvexPolygonGrid[RectangularWindow]): + window_type = RectangularWindow pairing_function = staticmethod(ij2ulam) pairing_inverse = staticmethod(ulam2ij) @register_grid(name='Square') -class SquareGrid(PeriodicConvexPolygonGrid[SquareWindow]): +class SquareGrid(PeriodicConvexPolygonGrid[RectangularWindow]): window_type = SquareWindow pairing_function = staticmethod(ij2ulam) pairing_inverse = staticmethod(ulam2ij) @@ -45,7 +45,7 @@ class SquareGrid(PeriodicConvexPolygonGrid[SquareWindow]): import numpy as np g = RectangularGrid() - w0 = SquareWindow(0, 0, 50_000, 50_000, np.deg2rad(10)) + w0 = RectangularWindow(0, 0, 50_000, 50_000, np.deg2rad(10)) g.windows[0] = w0 for i in range(200): p = g.predict_window(i) diff --git a/src/instamatic/grid/window.py b/src/instamatic/grid/window.py index d5a6d377..1382160f 100644 --- a/src/instamatic/grid/window.py +++ b/src/instamatic/grid/window.py @@ -23,59 +23,16 @@ Y = np.array([0, 1], dtype=float) -class ConvexPolygonWindow(ABC): - """Describes one convex polygon window without assumptions about grid.""" +class Window(ABC): + """Describes an arbitrary single window on a TEM grid.""" center: np.ndarray = ... # 2-element array describing the center of window - w_axis: np.ndarray = ... # from center towards the center of side in X dir - h_axis: np.ndarray = ... # from center towards the center of side in Y dir - corners: Sequence[np.ndarray] = ... # a Nx2 list of center coordinates - @abstractmethod - def __repr__(self) -> str: ... - @classmethod - def from_sweeping(cls, order: Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10] = 3) -> Self: - """Return new using `EdgeSweeper`s scanning around current position.""" - origin = np.array(_ctrl.stage.xy, dtype=int) - team = str(origin) - _ = EdgeSweeperTeam(name=team) - - # define and sweep with initial marching sweepers to approx. grid center - dirs = [+X, -X, +Y, -Y] - mess = [MarchingEdgeSweeper(origin=origin, heading=d, team=team) for d in dirs] - for mes in mess: - mes.sweep() - center_x = (mess[0].position[0] + mess[1].position[0]) / 2 - center_y = (mess[2].position[1] + mess[3].position[1]) / 2 - center = np.array([center_x, center_y], dtype=float) +class ConvexPolygonWindow(Window): + """Describes any convex polygon TEM grid window with known corners.""" - # define binary sweepers, step to edge of marchers-probed region & sweep - mess_position_pairs = list(pairwise([mes.position for mes in mess], closed=True)) - bess = [BinaryEdgeSweeper(origin=center, heading=d) for d in (X, Y, -X, -Y)] - for bes in bess: - dists = [bes.dist2segment(*p1, *p2) for p1, p2 in mess_position_pairs] - safe_dist = min(dists) - bes.team.step_size - if np.isfinite(safe_dist) and safe_dist > 0: - bes.step(safe_dist) - bes.sweep() - - # for each order, create a new generation of beam sweepers and sweep - def bisectors(sweepers: list[BinaryEdgeSweeper]) -> list[BinaryEdgeSweeper]: - new = [a.breed(b) for a, b in pairwise(sweepers, closed=True)] - for ns in new: - ns.sweep() - return new - - for _ in range(1, order): - bess = list(chain.from_iterable(zip(bess, bisectors(bess)))) - - edge_xy = np.vstack([bes.position for bes in bess]) # Nx2 - return cls.from_edge_xys(edge_xy) # TODO continue refactoring - - @classmethod - @abstractmethod - def from_edge_xys(cls, edge_xy: np.ndarray) -> Self: ... + corners: np.ndarray = ... # a Nx2 ordered array of xy corner coordinates def plot(self, ax=None, pad: float = 0.1) -> None: """Plot a simple visual representation of the window geometry.""" @@ -144,8 +101,169 @@ def y_intersections(self, x: float_nm) -> Optional[tuple[float, float]]: return min(intersection_ys), max(intersection_ys) -class SquareWindow(ConvexPolygonWindow): - """Describes one rectangular window without assumptions about the grid. +class RegularPolygonWindow(Window): + """Describes regular polygon window with a 2D ab coordinate system. + + This kind of window is expected to exist in a periodic grid, + therefore it should include an internal coordinate system with two + axes "a" and "b". They should be selected in such a way that the + angle between axes "a" and X is minimal and the angle from "a" to + "b" is positive and minimal. The length of "a" and "b" should match + the distance between window center and its edge. + """ + + a: np.ndarray = ... # from center towards the side, aligned in ~X direction + b: np.ndarray = ... # from center towards the side, not aligned with ~X + + @abstractmethod + def __repr__(self) -> str: ... + + @classmethod + def from_sweeping(cls, order: Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10] = 3) -> Self: + """Return new using `EdgeSweeper`s scanning around current position.""" + origin = np.array(_ctrl.stage.xy, dtype=int) + team = str(origin) + _ = EdgeSweeperTeam(name=team) + + # define and sweep with initial marching sweepers to approx. grid center + dirs = [+X, -X, +Y, -Y] + mess = [MarchingEdgeSweeper(origin=origin, heading=d, team=team) for d in dirs] + for mes in mess: + mes.sweep() + center_x = (mess[0].position[0] + mess[1].position[0]) / 2 + center_y = (mess[2].position[1] + mess[3].position[1]) / 2 + center = np.array([center_x, center_y], dtype=float) + + # define binary sweepers, step to edge of marchers-probed region & sweep + mess_position_pairs = list(pairwise([mes.position for mes in mess], closed=True)) + bess = [BinaryEdgeSweeper(origin=center, heading=d) for d in (X, Y, -X, -Y)] + for bes in bess: + dists = [bes.dist2segment(*p1, *p2) for p1, p2 in mess_position_pairs] + safe_dist = min(dists) - bes.team.step_size + if np.isfinite(safe_dist) and safe_dist > 0: + bes.step(safe_dist) + bes.sweep() + + # for each order, create a new generation of beam sweepers and sweep + def bisectors(sweepers: list[BinaryEdgeSweeper]) -> list[BinaryEdgeSweeper]: + new = [a.breed(b) for a, b in pairwise(sweepers, closed=True)] + for ns in new: + ns.sweep() + return new + + for _ in range(1, order): + bess = list(chain.from_iterable(zip(bess, bisectors(bess)))) + + edge_xy = np.vstack([bes.position for bes in bess]) # Nx2 + return cls.from_edge_xys(edge_xy) # TODO continue refactoring + + @classmethod + @abstractmethod + def from_edge_xys(cls, edge_xy: np.ndarray) -> Self: ... + + +class HexagonalWindow(RegularPolygonWindow): + """Describes a regular hexagonal window with a 2D ab coordinate system. + + Geometry is described using four immutable float scalars (nm / radian): + + - center_x: coordinate of the window center on the X axis; + - center_y: coordinate of the window center on the Y axis; + - width: distance between two opposite sides ("flat-to-flat"); + - theta: signed angle from world X axis towards the +a axis direction. + """ + + ROT60MAT = np.array([[1, -np.sqrt(3)], [np.sqrt(3), 1]], dtype=float) / 2 + + def __init__(self, x: float, y: float, w: float, t: float): + t = (t + (np.pi / 6)) % (np.pi / 3) - (np.pi / 6) # cast to [-pi/6, pi/6] + self.center_x: float_nm = x + self.center_y: float_nm = y + self.width = w = abs(w) + self.theta: float = t + + self.center = c = np.array([x, y], dtype=float) + self.a = 0.5 * w * np.array([np.cos(t), np.sin(t)], dtype=float) + self.b = self.ROT60MAT @ (self.ROT60MAT @ self.a) + + r_circum = w / np.sqrt(3.0) + corners = [] + for angle in np.linspace(t + np.pi / 6, t + 13 * np.pi / 6, num=6, endpoint=False): + corners.append(r_circum * np.array([np.cos(angle), np.sin(angle)], dtype=float)) + self.corners = c + np.vstack(corners) + + def __repr__(self) -> str: + args = [self.center_x, self.center_y, self.width, self.theta] + return self.__class__.__name__ + '(x={}, y={}, w={}, t={})'.format(*args) + + @classmethod + def from_edge_xys(cls, edge_xys: np.ndarray) -> Self: + """Return new by fitting a regular hexagon to a Nx2 list of edge + positions. + + Uses a simple initial guess from PCA and refines with Powell. + """ + edge_xys = np.asarray(edge_xys, dtype=float) + xys_com = np.mean(edge_xys, axis=0) + + # PCA for an initial orientation guess + xys_deltas = edge_xys - xys_com + xys_cov = np.cov(xys_deltas.T) + _, eigenvectors = np.linalg.eigh(xys_cov) + + # Use principal axis as a crude guess for a vertex direction; convert to theta for a axis + theta0 = float(np.arctan2(eigenvectors[1, 1], eigenvectors[0, 1]) - np.pi / 6.0) + + # Guess width from projected spread onto a axis direction (apothem approx) + w_hat0 = np.array([np.cos(theta0), np.sin(theta0)], dtype=float) + proj = xys_deltas @ w_hat0 + # apothem ~ median absolute projection to a side midpoint direction + a0 = float(np.median(np.abs(proj))) + width0 = max(1.0, 2.0 * a0) + + guess = np.array([xys_com[0], xys_com[1], width0, theta0], dtype=float) + res = minimize(cls.edge_d2_sum, guess, args=(edge_xys,), method='Powell') + new = cls(*res.x) + new._edge_xys = edge_xys + return new + + @staticmethod + def edge_d2_sum(geom: tuple[float, float, float, float], xys: np.ndarray) -> float: + """Objective: squared distance of points to nearest hexagon side (regular).""" + center_x, center_y, width, theta = geom + if width <= 0: + return np.inf + + center = np.array([center_x, center_y], dtype=float) + deltas = np.asarray(xys, dtype=float) - center + + # Unit normals to the 6 sides (pointing outward). + # If an axis points to a side midpoint at angle theta, then that side's outward normal is along theta. + # Other side normals are spaced by 60 degrees. + angles = theta + np.arange(6) * (np.pi / 3.0) + normals = np.stack([np.cos(angles), np.sin(angles)], axis=1) # (6,2) + + # Signed distances to each supporting line: (n·p - a) + # Point is inside if all <= 0. We want distance to boundary: max(n·p - a) clipped at 0. + distances = deltas @ normals.T - 0.5 * width # (N,6) + outside = np.maximum(distances.max(axis=1), 0.0) # (N,) + return float(np.sum(outside**2)) + + def translated(self, delta: np.ndarray) -> Self: + """Return a new window translated by (dx, dy) in nm.""" + d = np.asarray(delta, dtype=float).reshape( + 2, + ) + return type(self)( + float(self.center_x + d[0]), + float(self.center_y + d[1]), + float(self.width), + float(self.theta), + ) + + +class RectangularWindow(RegularPolygonWindow): + """Describes one rectangular window with a 2D ab coordinate system. Geometry is described using five immutable float scalars (nm / radian): @@ -153,7 +271,7 @@ class SquareWindow(ConvexPolygonWindow): - center_y: coordinate of the window center on the Y axis; - width: length of window side aligned with the direction of X axis; - height: length of window side aligned with the direction or Y axis; - - theta: signed angle from X towards X-aligned edge (positive towards Y); + - theta: signed angle from X axis towards A axis and the X-aligned edge. """ def __init__(self, x: float, y: float, w: float, h: float, t: float): @@ -168,9 +286,9 @@ def __init__(self, x: float, y: float, w: float, h: float, t: float): self.theta: float = t # expressed in radian self.center = c = np.array([x, y], dtype=float) - self.w_axis = wa = 0.5 * w * np.array([np.cos(t), np.sin(t)], dtype=float) - self.h_axis = ha = 0.5 * h * np.array([-np.sin(t), np.cos(t)], dtype=float) - self.corners = np.vstack([c + wa + ha, c + wa - ha, c - wa - ha, c - wa + ha]) + self.a = a = 0.5 * w * np.array([np.cos(t), np.sin(t)], dtype=float) + self.b = b = 0.5 * h * np.array([-np.sin(t), np.cos(t)], dtype=float) + self.corners = np.vstack([c + a + b, c + a - b, c - a - b, c - a + b]) def __repr__(self) -> str: args = [self.center_x, self.center_y, self.width, self.height, self.theta] @@ -188,7 +306,7 @@ def from_edge_xys(cls, edge_xys: np.ndarray) -> Self: height0 = eigenvector_proj[:, 0].max() - eigenvector_proj[:, 0].min() theta0 = np.arctan2(eigenvectors[1, 1], eigenvectors[0, 1]) guess = np.array([xys_com[0], xys_com[1], width0, height0, theta0]) - res = minimize(cls.edge_dist2_sum, guess, args=(edge_xys,), method='Powell') + res = minimize(cls.edge_d2_sum, guess, args=(edge_xys,), method='Powell') new = cls(*res.x) new._edge_xys = edge_xys return new @@ -198,21 +316,19 @@ def edge_d2_sum(geom: tuple[float, float, float, float, float], xys: np.ndarray) """scipy.optimize.minimize fitting func; for geometry see cls docs.""" center_x, center_y, width, height, theta = geom center = np.array([center_x, center_y], dtype=float) - w_axis = 0.5 * width * np.array([np.cos(theta), np.sin(theta)]) - h_axis = 0.5 * height * np.array([-np.sin(theta), np.cos(theta)]) - w_axis_n = w_axis / np.linalg.norm(w_axis) - h_axis_n = h_axis / np.linalg.norm(h_axis) - d1 = np.abs(np.dot(xys - (center + w_axis), w_axis_n)) - d2 = np.abs(np.dot(xys - (center - w_axis), w_axis_n)) - d3 = np.abs(np.dot(xys - (center + h_axis), h_axis_n)) - d4 = np.abs(np.dot(xys - (center - h_axis), h_axis_n)) + a = 0.5 * width * np.array([np.cos(theta), np.sin(theta)]) + b = 0.5 * height * np.array([-np.sin(theta), np.cos(theta)]) + a_hat = a / np.linalg.norm(a) + b_hat = b / np.linalg.norm(b) + d1 = np.abs(np.dot(xys - (center + a), a_hat)) + d2 = np.abs(np.dot(xys - (center - a), a_hat)) + d3 = np.abs(np.dot(xys - (center + b), b_hat)) + d4 = np.abs(np.dot(xys - (center - b), b_hat)) return np.sum(np.min([d1, d2, d3, d4], axis=0) ** 2) def translated(self, delta: np.ndarray) -> Self: """Return a new window translated by (dx, dy) in nm.""" - d = np.asarray(delta, dtype=float).reshape( - 2, - ) + d = np.asarray(delta, dtype=float).reshape(2) return type(self)( float(self.center_x + d[0]), float(self.center_y + d[1]), @@ -222,35 +338,29 @@ def translated(self, delta: np.ndarray) -> Self: ) -class HexagonalWindow(ConvexPolygonWindow): - """Describes a regular hexagonal window without assumptions about the grid. +class SquareWindow(RegularPolygonWindow): + """Describes one square window with a 2D ab coordinate system. Geometry is described using four immutable float scalars (nm / radian): - center_x: coordinate of the window center on the X axis; - center_y: coordinate of the window center on the Y axis; - - width: distance between two opposite sides ("flat-to-flat"); - - theta: signed angle from world X axis towards the +w_axis direction. + - width: length of the square side (>= 0) + - theta: signed angle from X axis towards A axis and the X-aligned edge. """ - ROT60MAT = np.array([[1, -np.sqrt(3)], [np.sqrt(3), 1]], dtype=float) / 2 + def __init__(self, x: float, y: float, s: float, t: float): + t = (t + (np.pi / 4)) % (np.pi / 2) - (np.pi / 4) # cast to [-pi/4, pi/4] - def __init__(self, x: float, y: float, w: float, t: float): - t = (t + (np.pi / 6)) % (np.pi / 3) - (np.pi / 6) # cast to [-pi/6, pi/6] self.center_x: float_nm = x self.center_y: float_nm = y - self.width = w = abs(w) - self.theta: float = t + self.width = s = abs(s) + self.theta: float = float(t) self.center = c = np.array([x, y], dtype=float) - self.w_axis = wa = 0.5 * w * np.array([np.cos(t), np.sin(t)], dtype=float) - self.h_axis = self.ROT60MAT @ (self.ROT60MAT @ wa) - - r_circum = w / np.sqrt(3.0) - corners = [] - for angle in np.linspace(t + np.pi / 6, t + 13 * np.pi / 6, num=6, endpoint=False): - corners.append(r_circum * np.array([np.cos(angle), np.sin(angle)], dtype=float)) - self.corners = c + np.vstack(corners) + self.a = a = 0.5 * s * np.array([np.cos(t), np.sin(t)], dtype=float) + self.b = b = 0.5 * s * np.array([-np.sin(t), np.cos(t)], dtype=float) + self.corners = np.vstack([c + a + b, c + a - b, c - a - b, c - a + b]) def __repr__(self) -> str: args = [self.center_x, self.center_y, self.width, self.theta] @@ -258,30 +368,17 @@ def __repr__(self) -> str: @classmethod def from_edge_xys(cls, edge_xys: np.ndarray) -> Self: - """Return new by fitting a regular hexagon to a Nx2 list of edge - positions. - - Uses a simple initial guess from PCA and refines with Powell. - """ - edge_xys = np.asarray(edge_xys, dtype=float) + """Return new by fitting the edge to a Nx2 list of edge positions.""" xys_com = np.mean(edge_xys, axis=0) - - # PCA for an initial orientation guess xys_deltas = edge_xys - xys_com xys_cov = np.cov(xys_deltas.T) _, eigenvectors = np.linalg.eigh(xys_cov) - - # Use principal axis as a crude guess for a vertex direction; convert to theta for w_axis - theta0 = float(np.arctan2(eigenvectors[1, 1], eigenvectors[0, 1]) - np.pi / 6.0) - - # Guess width from projected spread onto w_axis direction (apothem approx) - w_hat0 = np.array([np.cos(theta0), np.sin(theta0)], dtype=float) - proj = xys_deltas @ w_hat0 - # apothem ~ median absolute projection to a side midpoint direction - a0 = float(np.median(np.abs(proj))) - width0 = max(1.0, 2.0 * a0) - - guess = np.array([xys_com[0], xys_com[1], width0, theta0], dtype=float) + eigenvector_proj = xys_deltas @ eigenvectors + width0 = float(eigenvector_proj[:, 1].max() - eigenvector_proj[:, 1].min()) + height0 = float(eigenvector_proj[:, 0].max() - eigenvector_proj[:, 0].min()) + theta0 = float(np.arctan2(eigenvectors[1, 1], eigenvectors[0, 1])) + side0 = max(1.0, 0.5 * (height0 + width0)) + guess = np.array([xys_com[0], xys_com[1], side0, theta0], dtype=float) res = minimize(cls.edge_d2_sum, guess, args=(edge_xys,), method='Powell') new = cls(*res.x) new._edge_xys = edge_xys @@ -289,32 +386,21 @@ def from_edge_xys(cls, edge_xys: np.ndarray) -> Self: @staticmethod def edge_d2_sum(geom: tuple[float, float, float, float], xys: np.ndarray) -> float: - """Objective: squared distance of points to nearest hexagon side (regular).""" - cx, cy, width, theta = geom - width = abs(width) + """Objective: squared distance of points to nearest of 4 square sides.""" + center_x, center_y, width, theta = geom if width <= 0: return np.inf - - c = np.array([cx, cy], dtype=float) - pts = np.asarray(xys, dtype=float) - c - - # Unit normals to the 6 sides (pointing outward). - # If w_axis points to a side midpoint at angle theta, then that side's outward normal is along theta. - # Other side normals are spaced by 60 degrees. - angles = theta + np.arange(6) * (np.pi / 3.0) - normals = np.stack([np.cos(angles), np.sin(angles)], axis=1) # (6,2) - - # Signed distances to each supporting line: (n·p - a) - # Point is inside if all <= 0. We want distance to boundary: max(n·p - a) clipped at 0. - signed = pts @ normals.T - 0.5 * width # (N,6) - outside = np.maximum(signed.max(axis=1), 0.0) # (N,) + center = np.array([center_x, center_y], dtype=float) + deltas = np.asarray(xys, dtype=float) - center + angles = theta + np.arange(4) * (np.pi / 2.0) + normals = np.stack([np.cos(angles), np.sin(angles)], axis=1) # (4,2) + # Signed distances to supporting lines: n·p - a, where a = side/2 + distances = deltas @ normals.T - 0.5 * width # (N,4) + outside = np.maximum(distances.max(axis=1), 0.0) # (N,) return float(np.sum(outside**2)) def translated(self, delta: np.ndarray) -> Self: - """Return a new window translated by (dx, dy) in nm.""" - d = np.asarray(delta, dtype=float).reshape( - 2, - ) + d = np.asarray(delta, dtype=float).reshape(2) return type(self)( float(self.center_x + d[0]), float(self.center_y + d[1]), From 7f4e24457e3a55c3a1384229915dab8b13b93db4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Mon, 2 Feb 2026 17:20:05 +0100 Subject: [PATCH 035/118] Reorganize the window code to allow for more kinds of grid, WIP --- src/instamatic/grid/window.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/instamatic/grid/window.py b/src/instamatic/grid/window.py index 1382160f..83e6eaa6 100644 --- a/src/instamatic/grid/window.py +++ b/src/instamatic/grid/window.py @@ -155,7 +155,7 @@ def bisectors(sweepers: list[BinaryEdgeSweeper]) -> list[BinaryEdgeSweeper]: bess = list(chain.from_iterable(zip(bess, bisectors(bess)))) edge_xy = np.vstack([bes.position for bes in bess]) # Nx2 - return cls.from_edge_xys(edge_xy) # TODO continue refactoring + return cls.from_edge_xys(edge_xy) @classmethod @abstractmethod From 9ec29585d210c1d0fb566ca52d79871b9225b833 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Mon, 2 Feb 2026 18:33:25 +0100 Subject: [PATCH 036/118] Fix window serialization for journaling (converted to dict, type inferred) --- .../experiments/scan_ed/experiment.py | 4 +- src/instamatic/experiments/scan_ed/journal.py | 6 +- src/instamatic/experiments/scan_ed/state.py | 15 ++--- src/instamatic/grid/grid.py | 4 +- src/instamatic/grid/registry.py | 21 ++----- src/instamatic/grid/window.py | 62 ++++++++++++------- 6 files changed, 61 insertions(+), 51 deletions(-) diff --git a/src/instamatic/experiments/scan_ed/experiment.py b/src/instamatic/experiments/scan_ed/experiment.py index 827de7f4..bd0016bb 100644 --- a/src/instamatic/experiments/scan_ed/experiment.py +++ b/src/instamatic/experiments/scan_ed/experiment.py @@ -19,7 +19,7 @@ from instamatic.experiments.scan_ed.progress import ProgressTable from instamatic.experiments.scan_ed.state import State from instamatic.grid.polygon import ConvexPolygonGrid -from instamatic.grid.window import RectangularWindow, RegularPolygonWindow +from instamatic.grid.window import GridablePolygonWindow, RectangularWindow from instamatic.utils.beamstop import find_beamstop_rect @@ -142,7 +142,7 @@ def locate_next_window( self, grid: ConvexPolygonGrid, params: dict, - ) -> tuple[int, RegularPolygonWindow]: + ) -> tuple[int, GridablePolygonWindow]: """Find a next window on the grid, or raise if none can be found.""" last_window_id = max(grid.windows) for window_id in range(last_window_id + 1, 2 * last_window_id + 10): diff --git a/src/instamatic/experiments/scan_ed/journal.py b/src/instamatic/experiments/scan_ed/journal.py index ca344d70..b69e161a 100644 --- a/src/instamatic/experiments/scan_ed/journal.py +++ b/src/instamatic/experiments/scan_ed/journal.py @@ -11,7 +11,7 @@ import numpy as np from instamatic._typing import AnyPath -from instamatic.grid.window import RegularPolygonWindow +from instamatic.grid.window import GridablePolygonWindow class Journal: @@ -90,8 +90,8 @@ def serialize(obj): return int(obj) elif isinstance(obj, (np.floating,)): return float(obj) - elif isinstance(obj, (RegularPolygonWindow,)): - return repr(obj) + elif isinstance(obj, (GridablePolygonWindow,)): + return obj.to_params() return obj diff --git a/src/instamatic/experiments/scan_ed/state.py b/src/instamatic/experiments/scan_ed/state.py index 63793074..21323fb1 100644 --- a/src/instamatic/experiments/scan_ed/state.py +++ b/src/instamatic/experiments/scan_ed/state.py @@ -7,10 +7,10 @@ from instamatic.experiments.scan_ed.encoding import * from instamatic.experiments.scan_ed.journal import Journal, edits_journal from instamatic.experiments.scan_ed.progress import ProgressTable, edits_progress -from instamatic.grid.polygon import ConvexPolygonGrid -from instamatic.grid.window import RegularPolygonWindow +from instamatic.grid.grid import PeriodicConvexPolygonGrid +from instamatic.grid.window import GridablePolygonWindow -WindowFactory: Callable[[float, float, float, ...], type[RegularPolygonWindow]] +WindowFactory: Callable[[float, float, float, ...], type[GridablePolygonWindow]] class State: @@ -19,11 +19,11 @@ class State: def __init__( self, journal: Journal, - grid: ConvexPolygonGrid, + grid: PeriodicConvexPolygonGrid, progress: Optional[ProgressTable] = None, ) -> None: self.journal: Journal = journal - self.grid: ConvexPolygonGrid = grid + self.grid: PeriodicConvexPolygonGrid = grid self.progress: Optional[ProgressTable] = progress self.scans: pd.DataFrame = pd.DataFrame() @@ -59,12 +59,13 @@ def load_from_journal(self) -> None: method_name = event['method'] kwargs = event.get('kwargs', {}) if method_name == 'add_window': - kwargs['window'] = GridWindow.from_repr(kwargs['window']) + wkw = {k: float(v) for k, v in kwargs.pop('window').items()} + kwargs['window'] = self.grid.window_type(**wkw) getattr(self, method_name)(**kwargs) @edits_journal @edits_progress - def add_window(self, idx: int, window: RegularPolygonWindow) -> None: + def add_window(self, idx: int, window: GridablePolygonWindow) -> None: """For journaling purposes, can be added via instance or __repr__.""" self.grid.windows[idx] = window diff --git a/src/instamatic/grid/grid.py b/src/instamatic/grid/grid.py index 34521524..4ab36f25 100644 --- a/src/instamatic/grid/grid.py +++ b/src/instamatic/grid/grid.py @@ -6,12 +6,12 @@ from instamatic._collections import VersionedDict from instamatic._typing import float_nm, int_nm -from instamatic.grid.window import RegularPolygonWindow +from instamatic.grid.window import GridablePolygonWindow DualIndex = tuple[int, int] SpiralIndex = Annotated[int, 'positive'] WindowIndex = Union[DualIndex, SpiralIndex] -WindowType = TypeVar('WindowType', bound=RegularPolygonWindow) +WindowType = TypeVar('WindowType', bound=GridablePolygonWindow) class PairingFunction(Protocol): diff --git a/src/instamatic/grid/registry.py b/src/instamatic/grid/registry.py index 13430538..0d0b6634 100644 --- a/src/instamatic/grid/registry.py +++ b/src/instamatic/grid/registry.py @@ -5,40 +5,31 @@ from instamatic.grid.pairing import ij2ulam, spiral2uv, ulam2ij, uv2spiral from instamatic.grid.window import HexagonalWindow, RectangularWindow, SquareWindow -GRID_REGISTRY = NoOverwriteDict[str, WindowType]() - -def register_grid(name: str): - """A decorator to cleanly puts grid class in GRID_REGISTRY under name.""" - - def decorator(cls): - GRID_REGISTRY[name] = cls - return cls - - return decorator - - -@register_grid(name='Hexagonal') class HexagonalGrid(PeriodicConvexPolygonGrid[HexagonalWindow]): window_type = HexagonalWindow pairing_function = staticmethod(uv2spiral) pairing_inverse = staticmethod(spiral2uv) -@register_grid(name='Rectangular') class RectangularGrid(PeriodicConvexPolygonGrid[RectangularWindow]): window_type = RectangularWindow pairing_function = staticmethod(ij2ulam) pairing_inverse = staticmethod(ulam2ij) -@register_grid(name='Square') class SquareGrid(PeriodicConvexPolygonGrid[RectangularWindow]): window_type = SquareWindow pairing_function = staticmethod(ij2ulam) pairing_inverse = staticmethod(ulam2ij) +GRID_REGISTRY = NoOverwriteDict[str, type[PeriodicConvexPolygonGrid]]() +GRID_REGISTRY['hexagonal'] = HexagonalGrid +GRID_REGISTRY['rectangular'] = RectangularGrid +GRID_REGISTRY['square'] = SquareGrid + + # development test code; to be moved to artist/tests if __name__ == '__main__': diff --git a/src/instamatic/grid/window.py b/src/instamatic/grid/window.py index 83e6eaa6..7f21accd 100644 --- a/src/instamatic/grid/window.py +++ b/src/instamatic/grid/window.py @@ -2,7 +2,7 @@ from abc import ABC, abstractmethod from itertools import chain -from typing import Literal, Optional, Sequence +from typing import Literal, Optional import numpy as np from matplotlib import pyplot as plt @@ -101,8 +101,8 @@ def y_intersections(self, x: float_nm) -> Optional[tuple[float, float]]: return min(intersection_ys), max(intersection_ys) -class RegularPolygonWindow(Window): - """Describes regular polygon window with a 2D ab coordinate system. +class GridablePolygonWindow(ConvexPolygonWindow): + """Describes a polygon window with a 2D (a, b) grid coordinate system. This kind of window is expected to exist in a periodic grid, therefore it should include an internal coordinate system with two @@ -161,8 +161,11 @@ def bisectors(sweepers: list[BinaryEdgeSweeper]) -> list[BinaryEdgeSweeper]: @abstractmethod def from_edge_xys(cls, edge_xy: np.ndarray) -> Self: ... + @abstractmethod + def to_params(self) -> dict[str, float]: ... + -class HexagonalWindow(RegularPolygonWindow): +class HexagonalWindow(GridablePolygonWindow): """Describes a regular hexagonal window with a 2D ab coordinate system. Geometry is described using four immutable float scalars (nm / radian): @@ -176,11 +179,11 @@ class HexagonalWindow(RegularPolygonWindow): ROT60MAT = np.array([[1, -np.sqrt(3)], [np.sqrt(3), 1]], dtype=float) / 2 def __init__(self, x: float, y: float, w: float, t: float): - t = (t + (np.pi / 6)) % (np.pi / 3) - (np.pi / 6) # cast to [-pi/6, pi/6] - self.center_x: float_nm = x - self.center_y: float_nm = y - self.width = w = abs(w) - self.theta: float = t + t = (float(t) + (np.pi / 6)) % (np.pi / 3) - (np.pi / 6) # cast to [-pi/6, pi/6] + self.center_x: float_nm = float(x) + self.center_y: float_nm = float(y) + self.width = w = abs(float(w)) + self.theta: float = float(t) # expressed in radian self.center = c = np.array([x, y], dtype=float) self.a = 0.5 * w * np.array([np.cos(t), np.sin(t)], dtype=float) @@ -249,6 +252,9 @@ def edge_d2_sum(geom: tuple[float, float, float, float], xys: np.ndarray) -> flo outside = np.maximum(distances.max(axis=1), 0.0) # (N,) return float(np.sum(outside**2)) + def to_params(self) -> dict[str, float]: + return {'x': self.center_x, 'y': self.center_y, 'w': self.width, 't': self.theta} + def translated(self, delta: np.ndarray) -> Self: """Return a new window translated by (dx, dy) in nm.""" d = np.asarray(delta, dtype=float).reshape( @@ -262,7 +268,7 @@ def translated(self, delta: np.ndarray) -> Self: ) -class RectangularWindow(RegularPolygonWindow): +class RectangularWindow(GridablePolygonWindow): """Describes one rectangular window with a 2D ab coordinate system. Geometry is described using five immutable float scalars (nm / radian): @@ -275,15 +281,15 @@ class RectangularWindow(RegularPolygonWindow): """ def __init__(self, x: float, y: float, w: float, h: float, t: float): - t = (t + (np.pi / 2)) % np.pi - (np.pi / 2) # cast to [-pi/2, pi/2] + t = (float(t) + (np.pi / 2)) % np.pi - (np.pi / 2) # cast to [-pi/2, pi/2] if not -np.pi / 4 < t < np.pi / 4: # cast to [-pi/4, pi/4] w, h, t = h, w, (np.pi - t) % np.pi - np.pi / 2 - self.center_x: float_nm = x - self.center_y: float_nm = y - self.width = w = abs(w) - self.height = h = abs(h) - self.theta: float = t # expressed in radian + self.center_x: float_nm = float(x) + self.center_y: float_nm = float(y) + self.width = w = abs(float(w)) + self.height = h = abs(float(h)) + self.theta: float = float(t) # expressed in radian self.center = c = np.array([x, y], dtype=float) self.a = a = 0.5 * w * np.array([np.cos(t), np.sin(t)], dtype=float) @@ -326,6 +332,15 @@ def edge_d2_sum(geom: tuple[float, float, float, float, float], xys: np.ndarray) d4 = np.abs(np.dot(xys - (center - b), b_hat)) return np.sum(np.min([d1, d2, d3, d4], axis=0) ** 2) + def to_params(self) -> dict[str, float]: + return { + 'x': self.center_x, + 'y': self.center_y, + 'w': self.width, + 'h': self.height, + 't': self.theta, + } + def translated(self, delta: np.ndarray) -> Self: """Return a new window translated by (dx, dy) in nm.""" d = np.asarray(delta, dtype=float).reshape(2) @@ -338,7 +353,7 @@ def translated(self, delta: np.ndarray) -> Self: ) -class SquareWindow(RegularPolygonWindow): +class SquareWindow(GridablePolygonWindow): """Describes one square window with a 2D ab coordinate system. Geometry is described using four immutable float scalars (nm / radian): @@ -349,17 +364,17 @@ class SquareWindow(RegularPolygonWindow): - theta: signed angle from X axis towards A axis and the X-aligned edge. """ - def __init__(self, x: float, y: float, s: float, t: float): - t = (t + (np.pi / 4)) % (np.pi / 2) - (np.pi / 4) # cast to [-pi/4, pi/4] + def __init__(self, x: float, y: float, w: float, t: float): + t = (float(t) + (np.pi / 4)) % (np.pi / 2) - (np.pi / 4) # cast to [-pi/4, pi/4] self.center_x: float_nm = x self.center_y: float_nm = y - self.width = s = abs(s) + self.width = w = abs(w) self.theta: float = float(t) self.center = c = np.array([x, y], dtype=float) - self.a = a = 0.5 * s * np.array([np.cos(t), np.sin(t)], dtype=float) - self.b = b = 0.5 * s * np.array([-np.sin(t), np.cos(t)], dtype=float) + self.a = a = 0.5 * w * np.array([np.cos(t), np.sin(t)], dtype=float) + self.b = b = 0.5 * w * np.array([-np.sin(t), np.cos(t)], dtype=float) self.corners = np.vstack([c + a + b, c + a - b, c - a - b, c - a + b]) def __repr__(self) -> str: @@ -399,6 +414,9 @@ def edge_d2_sum(geom: tuple[float, float, float, float], xys: np.ndarray) -> flo outside = np.maximum(distances.max(axis=1), 0.0) # (N,) return float(np.sum(outside**2)) + def to_params(self) -> dict[str, float]: + return {'x': self.center_x, 'y': self.center_y, 'w': self.width, 't': self.theta} + def translated(self, delta: np.ndarray) -> Self: d = np.asarray(delta, dtype=float).reshape(2) return type(self)( From 62ea890d86d0a2e5fea070111e8b59fba95da220 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Tue, 3 Feb 2026 13:42:44 +0100 Subject: [PATCH 037/118] Move plotting logic into a separate artist module, rm unused files --- .../experiments/scan_ed/experiment.py | 5 +- src/instamatic/grid/artist.py | 60 +++++++++++++++++++ src/instamatic/grid/polygon.py | 47 --------------- src/instamatic/grid/registry.py | 52 +++++++++------- src/instamatic/grid/utils.py | 0 src/instamatic/grid/window.py | 38 ------------ 6 files changed, 95 insertions(+), 107 deletions(-) create mode 100644 src/instamatic/grid/artist.py delete mode 100644 src/instamatic/grid/polygon.py delete mode 100644 src/instamatic/grid/utils.py diff --git a/src/instamatic/experiments/scan_ed/experiment.py b/src/instamatic/experiments/scan_ed/experiment.py index bd0016bb..187b98be 100644 --- a/src/instamatic/experiments/scan_ed/experiment.py +++ b/src/instamatic/experiments/scan_ed/experiment.py @@ -18,7 +18,8 @@ from instamatic.experiments.scan_ed.journal import Journal from instamatic.experiments.scan_ed.progress import ProgressTable from instamatic.experiments.scan_ed.state import State -from instamatic.grid.polygon import ConvexPolygonGrid + +# TODO from instamatic.grid.polygon import ConvexPolygonGrid from instamatic.grid.window import GridablePolygonWindow, RectangularWindow from instamatic.utils.beamstop import find_beamstop_rect @@ -92,7 +93,7 @@ def get_state(self, load: bool, progress: Optional[ProgressTable] = None) -> Sta def get_grid(self, params: dict[str, Any]) -> ConvexPolygonGrid: """Reconstruct the grid from current params and state.""" - from instamatic.grid.polygon import HexagonalGrid, RectangularGrid + # TODO from instamatic.grid import HexagonalGrid, RectangularGrid if params.get('grid_geometry', '').lower().startswith('hex'): grid = HexagonalGrid() diff --git a/src/instamatic/grid/artist.py b/src/instamatic/grid/artist.py new file mode 100644 index 00000000..940f5265 --- /dev/null +++ b/src/instamatic/grid/artist.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Dict, Optional + +import numpy as np +from matplotlib import pyplot as plt +from matplotlib.axes import Axes +from matplotlib.figure import Figure +from matplotlib.patches import Polygon +from matplotlib.ticker import FuncFormatter + +if TYPE_CHECKING: + from instamatic.grid.window import ConvexPolygonWindow + + +def plot( + windows: Dict[int, ConvexPolygonWindow], + ax: Optional[Axes] = None, + show_indices: bool = True, + show_axes: bool = True, + debug_edges: bool = False, + figsize: tuple[float, float] = (5, 5), + dpi: int = 100, +) -> tuple[Figure, Axes]: + fig, ax = (ax.figure, ax) if ax else plt.subplots(figsize=figsize, dpi=dpi) + + fig.patch.set_facecolor('black') + ax.set_facecolor('black') + ax.set_aspect('equal', adjustable='box') + ax.tick_params(colors='white', direction='out') + for spine in ax.spines.values(): + spine.set_color('white') + ax.set_xlabel('x / um', color='white') + ax.set_ylabel('y / um', color='white') + ax.xaxis.set_major_formatter(FuncFormatter(lambda x, _: f'{x * 1e-3:g}')) + ax.yaxis.set_major_formatter(FuncFormatter(lambda y, _: f'{y * 1e-3:g}')) + ax.grid(True, which='major', color='white', linewidth=0.8, alpha=0.25, zorder=0) + ax.set_axisbelow(True) + patch_kw = {'facecolor': 'white', 'edgecolor': 'white', 'closed': True, 'zorder': 1} + text_kw = {'color': 'black', 'ha': 'center', 'va': 'center', 'fontsize': 10, 'zorder': 2} + + for idx, window in windows.items(): + corners = np.asarray(window.corners, dtype=float) + ax.add_patch(Polygon(corners, **patch_kw)) + + if show_indices: + cx, cy = map(float, window.center) + ax.text(cx, cy, str(idx), **text_kw) + + if debug_edges and hasattr(window, '_edge_xys'): + xys = np.asarray(window._edge_xys, dtype=float) + ax.plot(xys[:, 0], xys[:, 1], 'bx', markersize=6, zorder=5) + + ax.autoscale() + + if show_axes: + ax.axhline(0, color='white', linewidth=1.0, alpha=0.6, zorder=0) + ax.axvline(0, color='white', linewidth=1.0, alpha=0.6, zorder=0) + + return fig, ax diff --git a/src/instamatic/grid/polygon.py b/src/instamatic/grid/polygon.py deleted file mode 100644 index cbf16350..00000000 --- a/src/instamatic/grid/polygon.py +++ /dev/null @@ -1,47 +0,0 @@ -from __future__ import annotations - -from matplotlib import pyplot as plt -from matplotlib.axes import Axes -from matplotlib.figure import Figure -from matplotlib.patches import Polygon -from matplotlib.ticker import FuncFormatter - - -def plot(self, show: bool = True) -> tuple[Figure, Axes]: - """Plot grid windows as white polygons on black bg with Ulam labels.""" - - fig, ax = plt.subplots(figsize=(5, 5), dpi=100) - fig.patch.set_facecolor('black') - ax.set_facecolor('black') - - ax.set_aspect('equal', adjustable='box') - ax.set_xlabel('x / um', color='white') - ax.set_ylabel('y / um', color='white') - - ax.tick_params(colors='white', direction='out') - for spine in ax.spines.values(): - spine.set_color('white') - - if not self.windows: - plt.show() - return - - patch_kw = {'facecolor': 'white', 'edgecolor': 'white', 'closed': True} - text_kw = {'color': 'black', 'ha': 'center', 'va': 'center', 'fontsize': 10} - for ulam_idx, w in self.windows.items(): - corners = np.asarray(w.corners, dtype=float) - ax.add_patch(Polygon(corners, **patch_kw)) - cx, cy = w.center - ax.text(cx, cy, str(ulam_idx), **text_kw) - - ax.autoscale() - ax.xaxis.set_major_formatter(FuncFormatter(lambda x, _: f'{x * 1e-3:g}')) - ax.yaxis.set_major_formatter(FuncFormatter(lambda y, _: f'{y * 1e-3:g}')) - - # draw explicit x/y axes through origin for orientation - ax.axhline(0, color='white', linewidth=1.0, alpha=0.6, zorder=0) - ax.axvline(0, color='white', linewidth=1.0, alpha=0.6, zorder=0) - - if show: - plt.show() - return fig, ax diff --git a/src/instamatic/grid/registry.py b/src/instamatic/grid/registry.py index 0d0b6634..377840a7 100644 --- a/src/instamatic/grid/registry.py +++ b/src/instamatic/grid/registry.py @@ -33,25 +33,37 @@ class SquareGrid(PeriodicConvexPolygonGrid[RectangularWindow]): # development test code; to be moved to artist/tests if __name__ == '__main__': + import matplotlib.pyplot as plt import numpy as np - g = RectangularGrid() - w0 = RectangularWindow(0, 0, 50_000, 50_000, np.deg2rad(10)) - g.windows[0] = w0 - for i in range(200): - p = g.predict_window(i) - if np.linalg.norm(p.center - w0.center) < 400_000: - g.windows[i] = p - - g.plot() - - h = HexagonalGrid() - v0 = HexagonalWindow(0, 0, 50_000, np.deg2rad(10)) - h.windows[0] = v0 - - for i in range(200): - q = h.predict_window(i) - if np.linalg.norm(q.center - v0.center) < 400_000: - h.windows[i] = q - - h.plot() + from instamatic.grid.artist import plot + + g1 = HexagonalGrid() + w1 = HexagonalWindow(0, 0, 50_000, np.deg2rad(10)) + g1.windows[0] = w1 + + g2 = RectangularGrid() + w2 = RectangularWindow(0, 0, 40_000, 60_000, np.deg2rad(10)) + g2.windows[0] = w2 + + g3 = RectangularGrid() + w3 = RectangularWindow(0, 0, 20_000, 200_000, np.deg2rad(10)) + g3.windows[0] = w3 + + g4 = SquareGrid() + w4 = SquareWindow(0, 0, 50_000, np.deg2rad(10)) + g4.windows[0] = w4 + + for grid in [g1, g2, g3, g4]: + for i in range(120): + w = grid.predict_window(i) + if np.linalg.norm(w.center - grid.windows[0].center) < 200_000: + grid.windows[i] = w + + fig, axs = plt.subplots(2, 2) + fig.tight_layout() + plot(g1.windows, ax=axs[0, 0]) + plot(g2.windows, ax=axs[0, 1]) + plot(g3.windows, ax=axs[1, 0]) + plot(g4.windows, ax=axs[1, 1]) + plt.show() diff --git a/src/instamatic/grid/utils.py b/src/instamatic/grid/utils.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/instamatic/grid/window.py b/src/instamatic/grid/window.py index 7f21accd..e2a12917 100644 --- a/src/instamatic/grid/window.py +++ b/src/instamatic/grid/window.py @@ -34,44 +34,6 @@ class ConvexPolygonWindow(Window): corners: np.ndarray = ... # a Nx2 ordered array of xy corner coordinates - def plot(self, ax=None, pad: float = 0.1) -> None: - """Plot a simple visual representation of the window geometry.""" - if ax is None: - _, ax = plt.subplots() - - corners = self.corners - cx, cy = self.center - xmin, ymin = corners.min(axis=0) - xmax, ymax = corners.max(axis=0) - dx, dy = xmax - xmin, ymax - ymin - - ax.set_facecolor('0.85') - ax.add_patch( - Polygon( - corners, - closed=True, - facecolor='white', - edgecolor='black', - linewidth=1.5, - zorder=1, - ) - ) - - ax.plot(corners[:, 0], corners[:, 1], 'ro', zorder=2) - ax.plot(cx, cy, 'r+', markersize=10, markeredgewidth=2, zorder=3) - - if hasattr(self, '_edge_xys'): - xys = self._edge_xys - ax.plot(xys[:, 0], xys[:, 1], 'bx', markersize=6, zorder=5) - - ax.set_aspect('equal', adjustable='box') - ax.set_xlim(xmin - pad * dx, xmax + pad * dx) - ax.set_ylim(ymin - pad * dy, ymax + pad * dy) - ax.set_xlabel('x / nm') - ax.set_ylabel('y / nm') - - plt.show() - def x_intersections(self, y: float_nm) -> Optional[tuple[float, float]]: """Return (x_min, x_max) for a horizontal line intersecting at y.""" intersection_xs: list[float] = [] From 1b478064ecd6cd1cd2a7b64c0c014b00afe0313b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Tue, 3 Feb 2026 14:02:17 +0100 Subject: [PATCH 038/118] Update experiment logic to the grid & windows changes --- .../experiments/scan_ed/experiment.py | 46 ++++--------------- src/instamatic/gui/scan_ed_frame.py | 2 +- 2 files changed, 11 insertions(+), 37 deletions(-) diff --git a/src/instamatic/experiments/scan_ed/experiment.py b/src/instamatic/experiments/scan_ed/experiment.py index 187b98be..ed59f628 100644 --- a/src/instamatic/experiments/scan_ed/experiment.py +++ b/src/instamatic/experiments/scan_ed/experiment.py @@ -18,10 +18,8 @@ from instamatic.experiments.scan_ed.journal import Journal from instamatic.experiments.scan_ed.progress import ProgressTable from instamatic.experiments.scan_ed.state import State - -# TODO from instamatic.grid.polygon import ConvexPolygonGrid -from instamatic.grid.window import GridablePolygonWindow, RectangularWindow -from instamatic.utils.beamstop import find_beamstop_rect +from instamatic.grid.registry import GRID_REGISTRY, PeriodicConvexPolygonGrid +from instamatic.grid.window import GridablePolygonWindow class Experiment(ExperimentBase): @@ -84,27 +82,14 @@ def get_state(self, load: bool, progress: Optional[ProgressTable] = None) -> Sta """Initialize a state, fill it from journal; raise at load issues.""" journal_path = self.path / 'journal.jsonl' journal = Journal(path=journal_path) - state = State(journal=journal, progress=progress) + grid = GRID_REGISTRY[self.params['grid_geometry']]() + state = State(journal=journal, grid=grid, progress=progress) if load: if not journal_path.exists() or not journal_path.is_file(): raise FileNotFoundError(f'No journal file found at {journal_path=}') state.load_from_journal() return state - def get_grid(self, params: dict[str, Any]) -> ConvexPolygonGrid: - """Reconstruct the grid from current params and state.""" - # TODO from instamatic.grid import HexagonalGrid, RectangularGrid - - if params.get('grid_geometry', '').lower().startswith('hex'): - grid = HexagonalGrid() - else: - grid = RectangularGrid() - if self.state.grid.windows: - for wid, w in self.state.grid.windows.items(): - assert isinstance(w, grid.window_type) - grid.windows[wid] = w - return grid - def determine_exposure_and_speed(self, step_size: int_nm) -> tuple[float, float]: """Determine exposure/speed reachable by TEM close to requested.""" detector_dead_time = self.get_dead_time(self.params['exposure']) @@ -119,37 +104,26 @@ def start_collection(self, **params) -> None: self.params = params - grid = self.get_grid(params=params) - stop_event = params['stop_event'] - - while not stop_event.is_set(): + while not params['stop_event'].is_set(): try: - window_idx, window = self.locate_next_window(grid=grid, params=params) + window_idx, window = self.locate_next_window() except IndexError: break - grid.windows['window_id'] = window self.state.add_window(idx=window_idx, window=window) - self.add_scans(window_idx=window_idx, params=params) - for scan_idx in self.state.scans.loc[window_idx].index: if self.dispatcher is None: self.dispatcher = self.get_dispatcher() self.run_scan(window_idx, scan_idx) - return - - def locate_next_window( - self, - grid: ConvexPolygonGrid, - params: dict, - ) -> tuple[int, GridablePolygonWindow]: + def locate_next_window(self) -> tuple[int, GridablePolygonWindow]: """Find a next window on the grid, or raise if none can be found.""" + grid: PeriodicConvexPolygonGrid[GridablePolygonWindow] = self.state.grid last_window_id = max(grid.windows) for window_id in range(last_window_id + 1, 2 * last_window_id + 10): predicted = grid.predict_window(window_id) - x_lim = tx if (tx := params['target_x']) is not None else float('inf') - y_lim = ty if (ty := params['target_x']) is not None else float('inf') + x_lim = tx if (tx := self.params['target_x']) is not None else float('inf') + y_lim = ty if (ty := self.params['target_x']) is not None else float('inf') x_fits = np.all(np.abs(predicted.corners[:, 0]) < x_lim) y_fits = np.all(np.abs(predicted.corners[:, 0]) < y_lim) if not (x_fits and y_fits): diff --git a/src/instamatic/gui/scan_ed_frame.py b/src/instamatic/gui/scan_ed_frame.py index a3ac2775..ca4aff85 100644 --- a/src/instamatic/gui/scan_ed_frame.py +++ b/src/instamatic/gui/scan_ed_frame.py @@ -74,7 +74,7 @@ def __init__(self, parent): f.grid_rowconfigure(10, weight=1) Label(f, text='Grid geometry:').grid(row=3, column=0, **pad10) - m = ['hexagonal', 'rectangular'] + m = ['hexagonal', 'rectangular', 'square'] self.grid_geometry = OptionMenu(f, self.var.grid_geometry, m[1], *m) self.grid_geometry.grid(row=3, column=1, **pad10) From a30e30028fad0dc7e82ecc4933623592ba0d45d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Tue, 3 Feb 2026 16:02:39 +0100 Subject: [PATCH 039/118] Add GUI options to change alpha with progress and determine windows manually --- .../experiments/scan_ed/experiment.py | 14 +++++-- src/instamatic/experiments/scan_ed/state.py | 8 ++++ src/instamatic/gui/gui.py | 1 - src/instamatic/gui/scan_ed_frame.py | 42 ++++++++++++------- 4 files changed, 45 insertions(+), 20 deletions(-) diff --git a/src/instamatic/experiments/scan_ed/experiment.py b/src/instamatic/experiments/scan_ed/experiment.py index ed59f628..6cd3d331 100644 --- a/src/instamatic/experiments/scan_ed/experiment.py +++ b/src/instamatic/experiments/scan_ed/experiment.py @@ -103,18 +103,24 @@ def start_collection(self, **params) -> None: """Method that governs the entirety of scan ED experiment work flow.""" self.params = params + if self.dispatcher is None: + self.dispatcher = self.get_dispatcher() while not params['stop_event'].is_set(): + window_idx: int = max(self.state.grid.windows.keys()) + for _, scan_idx in self.state.untouched_scans(window=window_idx): + self.run_scan(window_idx, scan_idx) + if params['stop_event'].is_set(): + break try: window_idx, window = self.locate_next_window() except IndexError: + params['stop_event'].set() break self.state.add_window(idx=window_idx, window=window) self.add_scans(window_idx=window_idx, params=params) - for scan_idx in self.state.scans.loc[window_idx].index: - if self.dispatcher is None: - self.dispatcher = self.get_dispatcher() - self.run_scan(window_idx, scan_idx) + + self.teardown() def locate_next_window(self) -> tuple[int, GridablePolygonWindow]: """Find a next window on the grid, or raise if none can be found.""" diff --git a/src/instamatic/experiments/scan_ed/state.py b/src/instamatic/experiments/scan_ed/state.py index 21323fb1..34fdfe35 100644 --- a/src/instamatic/experiments/scan_ed/state.py +++ b/src/instamatic/experiments/scan_ed/state.py @@ -134,3 +134,11 @@ def fill_encoded_scan(self, window: int, scan: int, hits: str, n_peaks: str) -> if peaks_arr.size != n_steps: raise ValueError(f'Corrupt scan payload: {peaks_arr.size=} != {n_steps=}') self.fill_scan(window, scan, hits_arr, peaks_arr) + + def untouched_scans(self, window: Optional[int] = None) -> pd.MultiIndex: + """An iterable of (window, scan)-idx of planned-but-untouched scans.""" + n_peaks = self.steps['n_peaks'] + if window is not None: + n_peaks = n_peaks.xs(window, level='window', drop_level=False) + untouched = n_peaks.eq(-1).groupby(level=['window', 'scan']).all() + return untouched[untouched].index diff --git a/src/instamatic/gui/gui.py b/src/instamatic/gui/gui.py index 40d19489..fde8f447 100644 --- a/src/instamatic/gui/gui.py +++ b/src/instamatic/gui/gui.py @@ -98,7 +98,6 @@ def load(self, modules, master): for location in self.locations: selected_modules = [module for module in modules if module.location == location] - is_group = len(selected_modules) > 1 if is_group: diff --git a/src/instamatic/gui/scan_ed_frame.py b/src/instamatic/gui/scan_ed_frame.py index ca4aff85..5a4c6ad7 100644 --- a/src/instamatic/gui/scan_ed_frame.py +++ b/src/instamatic/gui/scan_ed_frame.py @@ -14,12 +14,12 @@ pad0 = {'sticky': 'EW', 'padx': 0, 'pady': 1} pad10 = {'sticky': 'EW', 'padx': 10, 'pady': 1} -scan_step = {'from_': 0, 'to': 100_000, 'increment': 100, 'width': 20} -scan_exposure = {'from_': 0, 'to': 10, 'increment': 0.01, 'width': 20} -target_hits = {'from_': 0, 'to': 1_000_000, 'increment': 100, 'width': 20} -target_time = {'from_': 0, 'to': 43_200, 'increment': 60, 'width': 20} -target_xy = {'from_': 0, 'to': 1_000_000, 'increment': 1000, 'width': 20} -angle_delta = {'from_': 0, 'to': 180, 'increment': 0.1, 'width': 20} +scan_step = {'from_': 0, 'to': 100_000, 'increment': 100} +scan_exposure = {'from_': 0, 'to': 10, 'increment': 0.01} +target_hits = {'from_': 0, 'to': 1_000_000, 'increment': 100} +target_time = {'from_': 0, 'to': 43_200, 'increment': 60} +target_xy = {'from_': 0, 'to': 1_000_000, 'increment': 1000} +angle_delta = {'from_': 0, 'to': 180, 'increment': 0.1} duration = {'from_': 0, 'to': 60, 'increment': 0.1} @@ -32,16 +32,19 @@ def __init__(self) -> None: self.scan_x_step = IntVar(value=500) self.scan_y_step = IntVar(value=500) self.scan_exposure = DoubleVar(value=0.1) + self.grid_finder = StringVar() self.target_hits = IntVar(value=1000) self.target_x = IntVar(value=500_000) self.target_y = IntVar(value=500_000) self.target_time = IntVar(value=480) + self.target_alpha = IntVar(value=0) self.target_hits_b = BooleanVar(value=False) self.target_x_b = BooleanVar(value=False) self.target_y_b = BooleanVar(value=False) self.target_time_b = BooleanVar(value=False) + self.target_alpha_b = BooleanVar(value=False) self.stop_event = ThreadingEvent() @@ -98,34 +101,43 @@ def __init__(self, parent): self.scan_exposure = Spinbox(f, textvariable=var, **scan_exposure) self.scan_exposure.grid(row=7, column=1, **pad10) + Label(f, text='Increment tilt to (deg):').grid(row=8, column=0, **pad10) + self.target_alpha = Spinbox(f, textvariable=self.var.target_alpha, **angle_delta) + self.target_alpha.grid(row=8, column=1, **pad10) + # Finish conditions area with tick marks + Label(f, text='Find grid windows:').grid(row=3, column=2, **pad10) + m = ['All manually', 'First manually', 'All automatically'] + self.grid_finder = OptionMenu(f, self.var.grid_finder, m[1], *m) + self.grid_finder.grid(row=3, column=3, **pad10) + text = 'Finish conditions – experiment ends once:' - Label(f, text=text).grid(row=3, column=2, columnspan=2, **pad10) + Label(f, text=text).grid(row=4, column=2, columnspan=2, **pad10) text = 'Hits exceed:' self.target_hits_b = Checkbutton(f, variable=self.var.target_hits_b, text=text) - self.target_hits_b.grid(row=4, column=2, **pad10) + self.target_hits_b.grid(row=5, column=2, **pad10) self.target_hits = Spinbox(f, textvariable=self.var.target_hits, **target_hits) - self.target_hits.grid(row=4, column=3, **pad10) + self.target_hits.grid(row=5, column=3, **pad10) text = '±X exceeds (nm):' self.target_x_b = Checkbutton(f, variable=self.var.target_x_b, text=text) - self.target_x_b.grid(row=5, column=2, **pad10) + self.target_x_b.grid(row=6, column=2, **pad10) self.target_x = Spinbox(f, textvariable=self.var.target_x, **target_xy) - self.target_x.grid(row=5, column=3, **pad10) + self.target_x.grid(row=6, column=3, **pad10) text = '±Y exceeds (nm):' self.target_y_b = Checkbutton(f, variable=self.var.target_y_b, text=text) - self.target_y_b.grid(row=6, column=2, **pad10) + self.target_y_b.grid(row=7, column=2, **pad10) self.target_y = Spinbox(f, textvariable=self.var.target_y, **target_xy) - self.target_y.grid(row=6, column=3, **pad10) + self.target_y.grid(row=7, column=3, **pad10) text = 'Time exceeds (h):' self.target_time_b = Checkbutton(f, variable=self.var.target_time_b, text=text) - self.target_time_b.grid(row=7, column=2, **pad10) + self.target_time_b.grid(row=8, column=2, **pad10) self.target_time = Spinbox(f, textvariable=self.var.target_time, **target_time) - self.target_time.grid(row=7, column=3, **pad10) + self.target_time.grid(row=8, column=3, **pad10) # Bottom area for progress and experiment flow control buttons From 06712d9b3a1d597e93d9ba80f427173f1f9e5e14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Tue, 3 Feb 2026 17:14:24 +0100 Subject: [PATCH 040/118] Add option to change alpha during scan (0 to max, then -max to 0) --- src/instamatic/experiments/scan_ed/experiment.py | 8 ++++++++ src/instamatic/experiments/scan_ed/state.py | 13 +++++++++++++ src/instamatic/gui/scan_ed_frame.py | 14 +++++--------- 3 files changed, 26 insertions(+), 9 deletions(-) diff --git a/src/instamatic/experiments/scan_ed/experiment.py b/src/instamatic/experiments/scan_ed/experiment.py index 6cd3d331..5072c01e 100644 --- a/src/instamatic/experiments/scan_ed/experiment.py +++ b/src/instamatic/experiments/scan_ed/experiment.py @@ -109,6 +109,7 @@ def start_collection(self, **params) -> None: while not params['stop_event'].is_set(): window_idx: int = max(self.state.grid.windows.keys()) for _, scan_idx in self.state.untouched_scans(window=window_idx): + self.set_tilt(window_idx) self.run_scan(window_idx, scan_idx) if params['stop_event'].is_set(): break @@ -173,6 +174,13 @@ def add_scans(self, window_idx: int, params: dict[str, Any]) -> None: n_steps=-(-abs(fast_max - fast_min) % step), ) + def set_tilt(self, window_idx: int) -> None: + """Set alpha (0 to +/-max to 0) as a function of window progress.""" + p = self.state.window_progress(window=window_idx) + m = self.params['max_alpha'] + a = m * 2 * p if p <= 0.5 else m * (2 * p - 1) # 0 to m, then -m to 0 + self.ctrl.stage.set(a=a) + def run_scan(self, window_idx: int, scan_idx: int) -> None: """Run a single scan previously added to state on the grid.""" diff --git a/src/instamatic/experiments/scan_ed/state.py b/src/instamatic/experiments/scan_ed/state.py index 34fdfe35..47210bde 100644 --- a/src/instamatic/experiments/scan_ed/state.py +++ b/src/instamatic/experiments/scan_ed/state.py @@ -142,3 +142,16 @@ def untouched_scans(self, window: Optional[int] = None) -> pd.MultiIndex: n_peaks = n_peaks.xs(window, level='window', drop_level=False) untouched = n_peaks.eq(-1).groupby(level=['window', 'scan']).all() return untouched[untouched].index + + def window_progress(self, window: int) -> float: + """Return measured fraction of the window scans (length-weighted).""" + if self.scans.empty or window not in self.scans.index.get_level_values('window'): + return 0.0 + scans = self.scans.xs(window, level='window', drop_level=False) + total_steps = int(scans['n_steps'].sum()) + if total_steps == 0: + return 0.0 + n_peaks = self.steps['n_peaks'].xs(window, level='window', drop_level=False) + touched = n_peaks.ge(0).groupby(level=['window', 'scan']).any() + touched = touched.reindex(scans.index, fill_value=False) + return scans.loc[touched, 'n_steps'].sum() / total_steps diff --git a/src/instamatic/gui/scan_ed_frame.py b/src/instamatic/gui/scan_ed_frame.py index 5a4c6ad7..936264c5 100644 --- a/src/instamatic/gui/scan_ed_frame.py +++ b/src/instamatic/gui/scan_ed_frame.py @@ -38,13 +38,12 @@ def __init__(self) -> None: self.target_x = IntVar(value=500_000) self.target_y = IntVar(value=500_000) self.target_time = IntVar(value=480) - self.target_alpha = IntVar(value=0) + self.max_alpha = DoubleVar(value=0) self.target_hits_b = BooleanVar(value=False) self.target_x_b = BooleanVar(value=False) self.target_y_b = BooleanVar(value=False) self.target_time_b = BooleanVar(value=False) - self.target_alpha_b = BooleanVar(value=False) self.stop_event = ThreadingEvent() @@ -101,9 +100,9 @@ def __init__(self, parent): self.scan_exposure = Spinbox(f, textvariable=var, **scan_exposure) self.scan_exposure.grid(row=7, column=1, **pad10) - Label(f, text='Increment tilt to (deg):').grid(row=8, column=0, **pad10) - self.target_alpha = Spinbox(f, textvariable=self.var.target_alpha, **angle_delta) - self.target_alpha.grid(row=8, column=1, **pad10) + Label(f, text='Max alpha tilt (deg):').grid(row=8, column=0, **pad10) + self.max_alpha = Spinbox(f, textvariable=self.var.max_alpha, **angle_delta) + self.max_alpha.grid(row=8, column=1, **pad10) # Finish conditions area with tick marks @@ -183,9 +182,6 @@ def sced_interface_command(controller, **params: Any) -> None: exp_dir = controller.module_io.get_experiment_directory() journal_path = Path(exp_dir) / 'journal.jsonl' assert journal_path.is_file(), f'No journal file found at {journal_path}' - journal = Journal(path=journal_path) - state = State(journal=journal, progress=progress) - state.load_from_journal() else: exp_dir = controller.module_io.get_new_experiment_directory() exp_dir.mkdir(exist_ok=True, parents=True) @@ -196,7 +192,7 @@ def sced_interface_command(controller, **params: Any) -> None: log=controller.log, flatfield=flat_field, progress=progress, - state=state, + load=load, ) try: controller.fast_adt.start_collection(**params) From 841aec0b8bf21a8c530f44facc8152d1ffa19295 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Tue, 3 Feb 2026 17:36:14 +0100 Subject: [PATCH 041/118] Add `Experiment.set_stop_event_if_target_met` --- src/instamatic/experiments/scan_ed/experiment.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/instamatic/experiments/scan_ed/experiment.py b/src/instamatic/experiments/scan_ed/experiment.py index 5072c01e..4bee30f2 100644 --- a/src/instamatic/experiments/scan_ed/experiment.py +++ b/src/instamatic/experiments/scan_ed/experiment.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +from datetime import datetime, timedelta from itertools import cycle from pathlib import Path from threading import Thread @@ -40,6 +41,7 @@ def __init__( self.log: logging.Logger = log self.flatfield: Optional[np.ndarray] = flatfield self.state = self.get_state(load=load, progress=progress) + self.start_time = datetime.now() # attributes initialized once an experiment starts self.params: dict[str, Any] = {} @@ -111,6 +113,7 @@ def start_collection(self, **params) -> None: for _, scan_idx in self.state.untouched_scans(window=window_idx): self.set_tilt(window_idx) self.run_scan(window_idx, scan_idx) + self.set_stop_event_if_target_met() if params['stop_event'].is_set(): break try: @@ -213,6 +216,14 @@ def run_scan(self, window_idx: int, scan_idx: int) -> None: fb_thread.join() self.state.finalize_scan(window_idx, scan_idx) + def set_stop_event_if_target_met(self) -> None: + time_passed = datetime.now() - self.start_time + time_target = timedelta(hours=self.params['target_time']) + hits_found = self.state.steps['hits'].sum() + hits_target = self.params['target_hits'] + if time_passed > time_target or hits_found > hits_target: + self.params['stop_event'].set() + def teardown(self) -> None: """Close all threads and safely shut down when requested.""" self.dispatcher.terminate_workers() From 662845f04a0d6bd81a61e1ce121e7ea3fe23da17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Tue, 3 Feb 2026 19:19:50 +0100 Subject: [PATCH 042/118] Implement a way to add at start new windows manually or disabling adding automatically --- .../experiments/scan_ed/experiment.py | 127 +++++++++++++----- src/instamatic/grid/artist.py | 7 +- src/instamatic/grid/grid.py | 12 +- src/instamatic/gui/scan_ed_frame.py | 9 +- 4 files changed, 113 insertions(+), 42 deletions(-) diff --git a/src/instamatic/experiments/scan_ed/experiment.py b/src/instamatic/experiments/scan_ed/experiment.py index 4bee30f2..b024be24 100644 --- a/src/instamatic/experiments/scan_ed/experiment.py +++ b/src/instamatic/experiments/scan_ed/experiment.py @@ -2,10 +2,10 @@ import logging from datetime import datetime, timedelta -from itertools import cycle +from itertools import count, cycle from pathlib import Path from threading import Thread -from typing import Any, Optional +from typing import TYPE_CHECKING, Any, Optional import numpy as np import pandas as pd @@ -19,8 +19,13 @@ from instamatic.experiments.scan_ed.journal import Journal from instamatic.experiments.scan_ed.progress import ProgressTable from instamatic.experiments.scan_ed.state import State +from instamatic.grid.artist import plot from instamatic.grid.registry import GRID_REGISTRY, PeriodicConvexPolygonGrid from instamatic.grid.window import GridablePolygonWindow +from instamatic.gui.click_dispatcher import ClickListener, MouseButton + +if TYPE_CHECKING: + from instamatic.gui import videostream_frame as vsf_type class Experiment(ExperimentBase): @@ -34,6 +39,7 @@ def __init__( flatfield: Optional[np.ndarray] = None, progress: Optional[ProgressTable] = None, load: bool = False, + videostream_frame: Optional[vsf_type] = None, ): super().__init__() self.ctrl = ctrl @@ -42,6 +48,7 @@ def __init__( self.flatfield: Optional[np.ndarray] = flatfield self.state = self.get_state(load=load, progress=progress) self.start_time = datetime.now() + self.videostream_frame: Optional[vsf_type] = videostream_frame # attributes initialized once an experiment starts self.params: dict[str, Any] = {} @@ -108,14 +115,17 @@ def start_collection(self, **params) -> None: if self.dispatcher is None: self.dispatcher = self.get_dispatcher() + windows = self.determine_manual_windows() + self.order_and_add_manual_windows(windows) + while not params['stop_event'].is_set(): - window_idx: int = max(self.state.grid.windows.keys()) - for _, scan_idx in self.state.untouched_scans(window=window_idx): - self.set_tilt(window_idx) - self.run_scan(window_idx, scan_idx) - self.set_stop_event_if_target_met() - if params['stop_event'].is_set(): - break + for window_idx in self.state.grid.windows.keys(): + for _, scan_idx in self.state.untouched_scans(window=window_idx): + self.set_tilt(window_idx) + self.run_scan(window_idx, scan_idx) + self.set_stop_event_if_target_met() + if params['stop_event'].is_set(): + break try: window_idx, window = self.locate_next_window() except IndexError: @@ -126,22 +136,6 @@ def start_collection(self, **params) -> None: self.teardown() - def locate_next_window(self) -> tuple[int, GridablePolygonWindow]: - """Find a next window on the grid, or raise if none can be found.""" - grid: PeriodicConvexPolygonGrid[GridablePolygonWindow] = self.state.grid - last_window_id = max(grid.windows) - for window_id in range(last_window_id + 1, 2 * last_window_id + 10): - predicted = grid.predict_window(window_id) - x_lim = tx if (tx := self.params['target_x']) is not None else float('inf') - y_lim = ty if (ty := self.params['target_x']) is not None else float('inf') - x_fits = np.all(np.abs(predicted.corners[:, 0]) < x_lim) - y_fits = np.all(np.abs(predicted.corners[:, 0]) < y_lim) - if not (x_fits and y_fits): - continue - self.ctrl.stage.set(*[int(xy) for xy in predicted.center]) - return window_id, grid.window_type.from_sweeping() - raise IndexError('Could not locate next window within limits') - def add_scans(self, window_idx: int, params: dict[str, Any]) -> None: """Add scans for window, asserting it does not have scans yet.""" @@ -177,6 +171,81 @@ def add_scans(self, window_idx: int, params: dict[str, Any]) -> None: n_steps=-(-abs(fast_max - fast_min) % step), ) + def determine_manual_windows(self) -> list[GridablePolygonWindow]: + method = self.params.get('grid_finder', 'All automatically') + if method == 'All automatically': + return [] + + d = self.videostream_frame.click_dispatcher + n = self.name + cl: ClickListener = c if (c := d.listeners.get(n)) else d.add_listener(n) + + print('Please navigate the stage as many points on the edge as possible') + print('(at least the corners and approximate midpoints). At each point,') + print('position the edge at the center of the screen.') + print('Left-click the screen to add the point, right-click to finish.') + print('') + + windows = {} + for window_idx in count(): + edge_xys = [] + with cl: + while True: + c = cl.get_click() + if c.button == MouseButton.RIGHT: + break + edge_xys.append(self.ctrl.stage.xy) + edge_xys = np.asarray(edge_xys, dtype=float) + window = self.state.grid.window_type.from_edge_xys(edge_xy=edge_xys) + fig, ax = plot({**windows, window_idx: window}, debug_edges=True) + with self.videostream_frame.processor.temporary(figure=fig): + print('LMB to accept and finish, RMB to retry, MMB to accept and add new') + c = cl.get_click() + if c.button == MouseButton.LEFT: + windows[window_idx] = window + return list(windows.values()) + elif c.button == MouseButton.RIGHT: + continue + else: # middle or any other + windows[window_idx] = window + continue + + def order_and_add_manual_windows(self, windows: list[GridablePolygonWindow]) -> None: + """Based on the first, correctly reindex+add the following windows.""" + if not windows: + return + self.state.add_window(idx=0, window=windows.pop(0)) + for window in windows: + idx = self.state.grid.predict_index(window.center) + self.state.add_window(idx=idx, window=window) + + def locate_next_window(self) -> tuple[int, GridablePolygonWindow]: + """Find a next window on the grid, or raise if none can be found.""" + if self.params.get('grid_finder') == 'All manually': + raise IndexError('Experiment params disallow locating new windows') + grid: PeriodicConvexPolygonGrid[GridablePolygonWindow] = self.state.grid + for window_id in range(1, 2 * max(grid.windows) + 10): + if window_id in grid.windows: + continue + predicted = grid.predict_window(window_id) + x_lim = tx if (tx := self.params['target_x']) is not None else float('inf') + y_lim = ty if (ty := self.params['target_x']) is not None else float('inf') + x_fits = np.all(np.abs(predicted.corners[:, 0]) < x_lim) + y_fits = np.all(np.abs(predicted.corners[:, 0]) < y_lim) + if not (x_fits and y_fits): + continue + self.ctrl.stage.set(*[int(xy) for xy in predicted.center]) + return window_id, grid.window_type.from_sweeping() + raise IndexError('Could not locate next window within limits') + + def set_stop_event_if_target_met(self) -> None: + time_passed = datetime.now() - self.start_time + time_target = timedelta(hours=self.params['target_time']) + hits_found = self.state.steps['hits'].sum() + hits_target = self.params['target_hits'] + if time_passed > time_target or hits_found > hits_target: + self.params['stop_event'].set() + def set_tilt(self, window_idx: int) -> None: """Set alpha (0 to +/-max to 0) as a function of window progress.""" p = self.state.window_progress(window=window_idx) @@ -216,14 +285,6 @@ def run_scan(self, window_idx: int, scan_idx: int) -> None: fb_thread.join() self.state.finalize_scan(window_idx, scan_idx) - def set_stop_event_if_target_met(self) -> None: - time_passed = datetime.now() - self.start_time - time_target = timedelta(hours=self.params['target_time']) - hits_found = self.state.steps['hits'].sum() - hits_target = self.params['target_hits'] - if time_passed > time_target or hits_found > hits_target: - self.params['stop_event'].set() - def teardown(self) -> None: """Close all threads and safely shut down when requested.""" self.dispatcher.terminate_workers() diff --git a/src/instamatic/grid/artist.py b/src/instamatic/grid/artist.py index 940f5265..90581f2e 100644 --- a/src/instamatic/grid/artist.py +++ b/src/instamatic/grid/artist.py @@ -17,7 +17,6 @@ def plot( windows: Dict[int, ConvexPolygonWindow], ax: Optional[Axes] = None, show_indices: bool = True, - show_axes: bool = True, debug_edges: bool = False, figsize: tuple[float, float] = (5, 5), dpi: int = 100, @@ -52,9 +51,7 @@ def plot( ax.plot(xys[:, 0], xys[:, 1], 'bx', markersize=6, zorder=5) ax.autoscale() - - if show_axes: - ax.axhline(0, color='white', linewidth=1.0, alpha=0.6, zorder=0) - ax.axvline(0, color='white', linewidth=1.0, alpha=0.6, zorder=0) + ax.axhline(0, color='white', linewidth=1.0, alpha=0.6, zorder=0) + ax.axvline(0, color='white', linewidth=1.0, alpha=0.6, zorder=0) return fig, ax diff --git a/src/instamatic/grid/grid.py b/src/instamatic/grid/grid.py index 4ab36f25..dc40d95c 100644 --- a/src/instamatic/grid/grid.py +++ b/src/instamatic/grid/grid.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Annotated, Generic, Protocol, TypeVar, Union, cast +from typing import Annotated, Generic, Protocol, Sequence, TypeVar, Union, cast import numpy as np @@ -131,7 +131,7 @@ def windows_xy(self) -> np.ndarray: return np.array([w.center for w in self.windows.values()], dtype=float) def nearest_window(self, idx: WindowIndex) -> SpiralIndex: - """Return Ulam index of existing window nearest to the one with idx.""" + """Return spiral index of existing window nearest to the one w/ idx.""" predicted_center = self.predict_center(idx) offsets2 = np.sum((self.windows_xy - predicted_center) ** 2, axis=1) nearest = int(np.argmin(offsets2)) @@ -142,6 +142,14 @@ def predict_center(self, idx: WindowIndex) -> np.ndarray: ij: DualIndex = self.pairing_inverse(idx) if isinstance(idx, int) else idx return self.windows[0].center + self.a * ij[0] + self.b * ij[1] + def predict_index(self, center: Sequence[float]) -> int: + """Return spiral index of predicted window nearest to the center.""" + delta = np.asarray(center, dtype=float) - self.windows[0].center + metric = np.column_stack([self.a, self.b]) + ij, *_ = np.linalg.lstsq(metric, delta, rcond=None) + i, j = (int(np.rint(v)) for v in ij) + return int(self.pairing_function(i, j)) + def predict_window(self, idx: WindowIndex) -> WindowType: """Predict the window of index idx given the rest of the grid.""" w0_delta = self.predict_center(idx) - self.windows[0].center diff --git a/src/instamatic/gui/scan_ed_frame.py b/src/instamatic/gui/scan_ed_frame.py index 936264c5..ee3094e7 100644 --- a/src/instamatic/gui/scan_ed_frame.py +++ b/src/instamatic/gui/scan_ed_frame.py @@ -171,8 +171,6 @@ def load_collection(self) -> None: def sced_interface_command(controller, **params: Any) -> None: from instamatic.experiments.scan_ed.experiment import Experiment - from instamatic.experiments.scan_ed.journal import Journal - from instamatic.experiments.scan_ed.state import State load: bool = params.get('load', False) progress: Optional[ProgressTable] = params.get('progress', None) @@ -186,6 +184,12 @@ def sced_interface_command(controller, **params: Any) -> None: exp_dir = controller.module_io.get_new_experiment_directory() exp_dir.mkdir(exist_ok=True, parents=True) + # get the videostreaming frame only if needed for manual window determination + if params.get('grid_finder') == 'All automatically': + vsf = None + else: + vsf = controller.app.get_module('stream') + controller.fast_adt = Experiment( ctrl=controller.ctrl, path=exp_dir, @@ -193,6 +197,7 @@ def sced_interface_command(controller, **params: Any) -> None: flatfield=flat_field, progress=progress, load=load, + videostream_frame=vsf, ) try: controller.fast_adt.start_collection(**params) From ad15fe85f0345a53e1883d090dcfb8c049752f9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Wed, 4 Feb 2026 16:16:01 +0100 Subject: [PATCH 043/118] These changes made SPED work mostly fine, only minor issues remain --- .../experiments/scan_ed/dispatch.py | 42 +++++--- .../experiments/scan_ed/experiment.py | 97 +++++++++++-------- src/instamatic/experiments/scan_ed/state.py | 36 ++++--- src/instamatic/grid/window.py | 6 +- src/instamatic/gui/scan_ed_frame.py | 9 +- 5 files changed, 118 insertions(+), 72 deletions(-) diff --git a/src/instamatic/experiments/scan_ed/dispatch.py b/src/instamatic/experiments/scan_ed/dispatch.py index 9e14dcce..a64b054b 100644 --- a/src/instamatic/experiments/scan_ed/dispatch.py +++ b/src/instamatic/experiments/scan_ed/dispatch.py @@ -4,6 +4,7 @@ import queue import uuid from dataclasses import dataclass +from itertools import count from multiprocessing.shared_memory import SharedMemory from pathlib import Path from threading import Event @@ -22,7 +23,7 @@ N_PROCESSORS = 4 CommandKind = Literal['INIT', 'PROCESS', 'WRITE', 'TERMINATE'] -FeedbackKind = Literal['PROCESSING', 'PROCESSED'] +FeedbackKind = Literal['PROCESSING', 'PROCESSED', 'WRITTEN'] @dataclass(frozen=True) @@ -52,9 +53,10 @@ class DiffHuntDispatcher: def __init__(self, shape: tuple[int, int], dtype: np.dtype) -> None: self.shape: tuple[int, int] = shape self.dtype: np.dtype = np.dtype(dtype) - self.commands: mp.Queue[Command] = mp.Queue() + self.commands: list[mp.Queue[Command]] = [] self.feedback: mp.Queue[Feedback] = mp.Queue() + self._round_robin = count() self._workers: list[mp.Process] = [] self._spawn_workers() @@ -65,7 +67,9 @@ def __init__(self, shape: tuple[int, int], dtype: np.dtype) -> None: self._n_frames: int = 0 self._next_ptr: int = 0 self._in_flight: set[int] = set() + self._write_pending: set[int] = set() + self.scan_finished: Event = Event() self.scan_processed: Event = Event() self.hits: Optional[np.ndarray] = None self.headers: list[Optional[dict]] = [] @@ -73,13 +77,16 @@ def __init__(self, shape: tuple[int, int], dtype: np.dtype) -> None: def _spawn_workers(self) -> None: """Run once at the start of experiment to spawn eval processes.""" for wid in range(N_PROCESSORS): - worker = DiffHuntWorker(wid, self.commands, self.feedback, self.dtype) + command_queue = mp.Queue() + worker = DiffHuntWorker(wid, command_queue, self.feedback, self.dtype) worker.start() + self.commands.append(command_queue) self._workers.append(worker) def emit(self, task: CommandKind, *args, **kwargs) -> None: - """Shorthand to create and put Command in the self.commands queue.""" - self.commands.put(Command(task, *args, **kwargs)) + """Shorthand to create and put Command in next self.commands queue.""" + q = self.commands[next(self._round_robin) % N_PROCESSORS] + q.put(Command(task, *args, **kwargs)) def begin_scan(self, n_frames: int, name: Optional[str] = None) -> None: """Allocate a new shared buffer and reset all tracking for one scan.""" @@ -87,10 +94,12 @@ def begin_scan(self, n_frames: int, name: Optional[str] = None) -> None: self._n_frames = int(n_frames) self._next_ptr = 0 self._in_flight.clear() + self._write_pending.clear() shape3 = (self._n_frames, self.shape[0], self.shape[1]) size = int(np.prod(shape3) * self.dtype.itemsize) self._shm = SharedMemory(name=self._buffer_name, create=True, size=size) self._frames = np.ndarray(shape3, dtype=self.dtype, buffer=self._shm.buf) + self.scan_finished.clear() self.scan_processed.clear() self.hits = np.zeros(self._n_frames, dtype=bool) self.headers = [None] * self._n_frames @@ -111,6 +120,7 @@ def end_scan(self) -> None: self._n_frames = 0 self._next_ptr = 0 self._in_flight.clear() + self._write_pending.clear() self.hits = None self.headers = [] @@ -127,13 +137,14 @@ def process(self, frame: np.ndarray, header: Optional[dict]) -> int: self._in_flight.add(ptr) self._next_ptr += 1 - self.commands.put(Command('PROCESS', buffer_pointer=ptr)) + self.emit('PROCESS', buffer_pointer=ptr) return ptr def write_scan(self, path: AnyPath) -> None: """Request workers to write all hit frames from the active scan.""" for pointer, hit in enumerate(self.hits): if hit: + self._write_pending.add(pointer) bn = self._buffer_name kwargs = {'path': path, 'header': self.headers[pointer]} self.emit('WRITE', buffer_name=bn, buffer_pointer=pointer, kwargs=kwargs) @@ -145,7 +156,7 @@ def handle_feedback(self, state: State, window: int, scan: int) -> None: must be run from the main thread, or a proxy Progress table must be used. """ - for _ in range(2 * self._n_frames): + while (not self.scan_finished.is_set()) or self._in_flight or self._write_pending: try: fb: Feedback = self.feedback.get(timeout=15) except queue.Empty as e: @@ -162,6 +173,10 @@ def handle_feedback(self, state: State, window: int, scan: int) -> None: if self.hits is not None: self.hits[pointer] = d.success self._in_flight.discard(pointer) + + elif fb.kind == 'WRITTEN': + self._write_pending.discard(pointer) + self.scan_processed.set() def terminate_workers(self) -> None: @@ -205,11 +220,14 @@ def run(self) -> None: self.emit('PROCESSED', buffer_pointer=ptr, details=d) elif cmd.kind == 'WRITE': - path = Path(cmd.kwargs['path']).resolve() - filename = f'{cmd.buffer_name}_{cmd.buffer_pointer:06d}.tiff' - frame = self.frames[cmd.buffer_pointer] - header = cmd.kwargs.get('header', {}) - write_tiff(fname=str(path / filename), data=frame, header=header) + try: + path = Path(cmd.kwargs['path']).resolve() + filename = f'{cmd.buffer_name}_{cmd.buffer_pointer:06d}.tiff' + frame = self.frames[cmd.buffer_pointer] + header = cmd.kwargs.get('header', {}) + write_tiff(fname=str(path / filename), data=frame, header=header) + finally: + self.emit('WRITTEN', buffer_pointer=cmd.buffer_pointer) elif cmd.kind == 'TERMINATE': if self.shm is not None: diff --git a/src/instamatic/experiments/scan_ed/experiment.py b/src/instamatic/experiments/scan_ed/experiment.py index b024be24..b251c5ba 100644 --- a/src/instamatic/experiments/scan_ed/experiment.py +++ b/src/instamatic/experiments/scan_ed/experiment.py @@ -46,7 +46,9 @@ def __init__( self.path: Path = Path(path) self.log: logging.Logger = log self.flatfield: Optional[np.ndarray] = flatfield - self.state = self.get_state(load=load, progress=progress) + self.progress: Optional[ProgressTable] = progress + self.load: bool = load + self._state: Optional[State] = None self.start_time = datetime.now() self.videostream_frame: Optional[vsf_type] = videostream_frame @@ -54,6 +56,22 @@ def __init__( self.params: dict[str, Any] = {} self.dispatcher: Optional[DiffHuntDispatcher] = None + @property + def state(self) -> State: + """Initialize, fill a state if first access; raise at load issues.""" + if self._state is not None: + return self._state + journal_path = self.path / 'journal.jsonl' + journal = Journal(path=journal_path) + grid = GRID_REGISTRY[self.params['grid_geometry']]() + state = State(journal=journal, grid=grid, progress=self.progress) + if self.load: + if not journal_path.exists() or not journal_path.is_file(): + raise FileNotFoundError(f'No journal file found at {journal_path=}') + state.load_from_journal() + self._state = state + return state + def get_dead_time( self, exposure: float = 0.0, @@ -87,22 +105,10 @@ def get_stage_translation(self) -> CalibStageTranslationX: print(m2 := 'Please run `instamatic.calibrate_stage_rotation` first.') raise FastADTMissingCalibError(m1 + ' ' + m2) - def get_state(self, load: bool, progress: Optional[ProgressTable] = None) -> State: - """Initialize a state, fill it from journal; raise at load issues.""" - journal_path = self.path / 'journal.jsonl' - journal = Journal(path=journal_path) - grid = GRID_REGISTRY[self.params['grid_geometry']]() - state = State(journal=journal, grid=grid, progress=progress) - if load: - if not journal_path.exists() or not journal_path.is_file(): - raise FileNotFoundError(f'No journal file found at {journal_path=}') - state.load_from_journal() - return state - def determine_exposure_and_speed(self, step_size: int_nm) -> tuple[float, float]: """Determine exposure/speed reachable by TEM close to requested.""" - detector_dead_time = self.get_dead_time(self.params['exposure']) - time_for_one_frame = self.params['exposure'] + detector_dead_time + detector_dead_time = self.get_dead_time(self.params['scan_exposure']) + time_for_one_frame = self.params['scan_exposure'] + detector_dead_time trans_calib = self.get_stage_translation() motion_plan = trans_calib.plan_motion(time_for_one_frame / step_size) exposure = abs(motion_plan.pace * step_size) - detector_dead_time @@ -112,27 +118,35 @@ def start_collection(self, **params) -> None: """Method that governs the entirety of scan ED experiment work flow.""" self.params = params + _ = self.state # loads the journal if self.dispatcher is None: self.dispatcher = self.get_dispatcher() - windows = self.determine_manual_windows() - self.order_and_add_manual_windows(windows) + # windows are only added if no defined; TODO: allow adding after loading + self.ctrl.stage.set(a=0) + if not self.state.grid.windows: + windows = self.determine_manual_windows() + self.order_and_add_manual_windows(windows) while not params['stop_event'].is_set(): - for window_idx in self.state.grid.windows.keys(): - for _, scan_idx in self.state.untouched_scans(window=window_idx): - self.set_tilt(window_idx) - self.run_scan(window_idx, scan_idx) - self.set_stop_event_if_target_met() - if params['stop_event'].is_set(): - break + try: + for window_idx in self.state.grid.windows.keys(): + if not self.state.has_any_scans(window_idx): + self.add_scans(window_idx=window_idx, params=params) + for _, scan_idx in self.state.untouched_scans(window=window_idx): + self.set_tilt(window_idx) + self.run_scan(window_idx, scan_idx) + self.set_stop_event_if_target_met() + if params['stop_event'].is_set(): + break + finally: + self.ctrl.stage.set(a=0) try: window_idx, window = self.locate_next_window() except IndexError: params['stop_event'].set() break self.state.add_window(idx=window_idx, window=window) - self.add_scans(window_idx=window_idx, params=params) self.teardown() @@ -162,13 +176,13 @@ def add_scans(self, window_idx: int, params: dict[str, Any]) -> None: for scan_id, slow in enumerate(slows): fast_min, fast_max = scan_factory(slow)[:: next(scan_signs)] self.state.add_scan( - window=window_idx, - scan_id=scan_id, - x0=slow_min if axis else fast_min, - y0=fast_min if axis else slow_min, - axis=axis, - step=step, - n_steps=-(-abs(fast_max - fast_min) % step), + window=int(window_idx), + scan=int(scan_id), + x0=int(slow if axis else fast_min), + y0=int(fast_min if axis else slow), + axis=int(axis), + step=int(step), + n_steps=-int(-abs(fast_max - fast_min) // step), ) def determine_manual_windows(self) -> list[GridablePolygonWindow]: @@ -239,10 +253,12 @@ def locate_next_window(self) -> tuple[int, GridablePolygonWindow]: raise IndexError('Could not locate next window within limits') def set_stop_event_if_target_met(self) -> None: - time_passed = datetime.now() - self.start_time - time_target = timedelta(hours=self.params['target_time']) + th: Optional[int] = self.params.get('target_hits', None) + tt: Optional[int] = self.params.get('target_time', None) hits_found = self.state.steps['hits'].sum() - hits_target = self.params['target_hits'] + hits_target = th if th else float('inf') + time_passed = datetime.now() - self.start_time + time_target = timedelta(hours=tt) if tt else timedelta.max if time_passed > time_target or hits_found > hits_target: self.params['stop_event'].set() @@ -259,11 +275,12 @@ def run_scan(self, window_idx: int, scan_idx: int) -> None: idx = pd.IndexSlice[window_idx, scan_idx, :] if np.any(self.state.steps.loc[idx, 'n_peaks'] != -1): return # none-op for a scans that has been already done + n_frames = int(self.state.scans.loc[(window_idx, scan_idx), 'n_steps']) scan = self.state.scans.loc[(window_idx, scan_idx)] - self.ctrl.stage.set(x0=scan['x0'], y0=scan['y0']) + self.ctrl.stage.set(x=scan['x0'], y=scan['y0']) - self.dispatcher.begin_scan(len(idx)) + self.dispatcher.begin_scan(n_frames) fb_kwargs = {'state': self.state, 'window': window_idx, 'scan': scan_idx} fb_thread = Thread(target=self.dispatcher.handle_feedback, kwargs=fb_kwargs) fb_thread.start() @@ -275,14 +292,16 @@ def run_scan(self, window_idx: int, scan_idx: int) -> None: setter_kwargs = {'xy'[axis]: fast1, 'speed': speed} self.ctrl.stage.set_with_speed(**setter_kwargs) - movie = self.ctrl.get_movie(n_frames=len(idx), exposure=exposure, header_keys=None) + movie = self.ctrl.get_movie(n_frames=n_frames, exposure=exposure, header_keys=None) for frame, header in movie: self.dispatcher.process(frame, header) + self.dispatcher.scan_finished.set() # signals no more data is coming self.dispatcher.scan_processed.wait(timeout=60) # should process live + fb_thread.join() self.dispatcher.write_scan(path=self.path / 'tiff') + self.dispatcher.handle_feedback(self.state, window_idx, scan_idx) self.dispatcher.end_scan() - fb_thread.join() self.state.finalize_scan(window_idx, scan_idx) def teardown(self) -> None: diff --git a/src/instamatic/experiments/scan_ed/state.py b/src/instamatic/experiments/scan_ed/state.py index 47210bde..0f672669 100644 --- a/src/instamatic/experiments/scan_ed/state.py +++ b/src/instamatic/experiments/scan_ed/state.py @@ -33,20 +33,20 @@ def __init__( def _init_dataframes(self) -> None: """Create a new empty history with required index and columns.""" scan_columns = { - 'window': pd.Series(dtype=np.uint16), - 'scan': pd.Series(dtype=np.uint16), - 'x0': pd.Series(dtype=np.int32), - 'y0': pd.Series(dtype=np.int32), - 'axis': pd.Series(dtype=np.uint8), - 'step': pd.Series(dtype=np.int32), - 'n_steps': pd.Series(dtype=np.uint16), + 'window': pd.Series(dtype='UInt16'), + 'scan': pd.Series(dtype='UInt16'), + 'x0': pd.Series(dtype='Int32'), + 'y0': pd.Series(dtype='Int32'), + 'axis': pd.Series(dtype='UInt8'), + 'step': pd.Series(dtype='Int32'), + 'n_steps': pd.Series(dtype='UInt16'), } steps_columns = { - 'window': pd.Series(dtype=np.uint16), - 'scan': pd.Series(dtype=np.uint16), - 'step': pd.Series(dtype=np.uint16), - 'hits': pd.Series(dtype=np.bool_), - 'n_peaks': pd.Series(dtype=np.int16), + 'window': pd.Series(dtype='UInt16'), + 'scan': pd.Series(dtype='UInt16'), + 'step': pd.Series(dtype='UInt16'), + 'hits': pd.Series(dtype='boolean'), + 'n_peaks': pd.Series(dtype='Int16'), } self.scans = pd.DataFrame(scan_columns) self.scans.set_index(['window', 'scan'], inplace=True) @@ -86,8 +86,11 @@ def add_scan( self.scans.loc[(window, scan), scan_cols] = (x0, y0, axis, step, n_steps) idx_names = ['window', 'scan', 'step'] idx = pd.MultiIndex.from_product([[window], [scan], range(n_steps)], names=idx_names) - self.steps.loc[idx, 'hits'] = np.full(n_steps, False, dtype=np.bool_) - self.steps.loc[idx, 'n_peaks'] = np.full(n_steps, -1, dtype=np.int16) + new_scans = { + 'hits': np.zeros(n_steps, dtype=np.bool_), + 'n_peaks': np.full(n_steps, -1, dtype=np.int16), + } + self.steps = pd.concat([self.steps, pd.DataFrame(new_scans, index=idx)]) def finalize_scan(self, window: int, scan: int) -> None: idx = pd.IndexSlice[window, scan, :] @@ -135,6 +138,11 @@ def fill_encoded_scan(self, window: int, scan: int, hits: str, n_peaks: str) -> raise ValueError(f'Corrupt scan payload: {peaks_arr.size=} != {n_steps=}') self.fill_scan(window, scan, hits_arr, peaks_arr) + def has_any_scans(self, window: int) -> bool: + """Returns True if window has any defined scans with any status.""" + i, k = self.scans.index, 'window' + return len(self.scans) > 0 and k in i.names and (i.get_level_values(k) == window).any() + def untouched_scans(self, window: Optional[int] = None) -> pd.MultiIndex: """An iterable of (window, scan)-idx of planned-but-untouched scans.""" n_peaks = self.steps['n_peaks'] diff --git a/src/instamatic/grid/window.py b/src/instamatic/grid/window.py index e2a12917..c51881a3 100644 --- a/src/instamatic/grid/window.py +++ b/src/instamatic/grid/window.py @@ -37,7 +37,7 @@ class ConvexPolygonWindow(Window): def x_intersections(self, y: float_nm) -> Optional[tuple[float, float]]: """Return (x_min, x_max) for a horizontal line intersecting at y.""" intersection_xs: list[float] = [] - for x1, y1, x2, y2 in pairwise(self.corners, closed=True): + for (x1, y1), (x2, y2) in pairwise(self.corners, closed=True): if y1 == y2: # edge case (degeneracy / double counting) continue intersection_fraction = (y - y1) / (y2 - y1) @@ -51,7 +51,7 @@ def x_intersections(self, y: float_nm) -> Optional[tuple[float, float]]: def y_intersections(self, x: float_nm) -> Optional[tuple[float, float]]: """Return (y_min, y_max) for a vertical line intersecting at x.""" intersection_ys: list[float] = [] - for x1, y1, x2, y2 in pairwise(self.corners, closed=True): + for (x1, y1), (x2, y2) in pairwise(self.corners, closed=True): if x1 == x2: # edge case (degeneracy / double counting) continue intersection_fraction = (x - x1) / (x2 - x1) @@ -121,7 +121,7 @@ def bisectors(sweepers: list[BinaryEdgeSweeper]) -> list[BinaryEdgeSweeper]: @classmethod @abstractmethod - def from_edge_xys(cls, edge_xy: np.ndarray) -> Self: ... + def from_edge_xys(cls, edge_xys: np.ndarray) -> Self: ... @abstractmethod def to_params(self) -> dict[str, float]: ... diff --git a/src/instamatic/gui/scan_ed_frame.py b/src/instamatic/gui/scan_ed_frame.py index ee3094e7..d7bd3e96 100644 --- a/src/instamatic/gui/scan_ed_frame.py +++ b/src/instamatic/gui/scan_ed_frame.py @@ -50,6 +50,7 @@ def __init__(self) -> None: def as_dict(self) -> dict[str, Union[float, int, str]]: """Return self as dict, replace values with None if key_b is False.""" d = {n: v.get() for n, v in vars(self).items() if isinstance(v, Variable)} + d['stop_event'] = self.stop_event for key in d.copy().keys(): if (key_b := key + '_b') in d: if d.pop(key_b) is False: @@ -106,7 +107,7 @@ def __init__(self, parent): # Finish conditions area with tick marks - Label(f, text='Find grid windows:').grid(row=3, column=2, **pad10) + Label(f, text='Find new grid windows:').grid(row=3, column=2, **pad10) m = ['All manually', 'First manually', 'All automatically'] self.grid_finder = OptionMenu(f, self.var.grid_finder, m[1], *m) self.grid_finder.grid(row=3, column=3, **pad10) @@ -161,12 +162,12 @@ def __init__(self, parent): def start_collection(self) -> None: progress = ThreadSafeProgressTableProxy(self, self.progress) - kwargs = {'load': True, 'progress': progress} - self.q.put(('scan_ed', {**kwargs, **self.var.as_dict()})) + self.q.put(('scan_ed', {'progress': progress, **self.var.as_dict()})) def load_collection(self) -> None: progress = ThreadSafeProgressTableProxy(self, self.progress) - self.q.put(('scan_ed', {'progress': progress, **self.var.as_dict()})) + kwargs = {'load': True, 'progress': progress} + self.q.put(('scan_ed', {**kwargs, **self.var.as_dict()})) def sced_interface_command(controller, **params: Any) -> None: From df1c5fe0336983ae41cb4f6860cf2459ae79ab33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Wed, 4 Feb 2026 17:23:15 +0100 Subject: [PATCH 044/118] Give proper feedback whenever buttons are pressed, experiment stopped etc. --- .../experiments/scan_ed/experiment.py | 3 ++ .../experiments/scan_ed/progress.py | 15 +++++- src/instamatic/gui/scan_ed_frame.py | 48 +++++++++++++++++-- 3 files changed, 59 insertions(+), 7 deletions(-) diff --git a/src/instamatic/experiments/scan_ed/experiment.py b/src/instamatic/experiments/scan_ed/experiment.py index b251c5ba..b8c72300 100644 --- a/src/instamatic/experiments/scan_ed/experiment.py +++ b/src/instamatic/experiments/scan_ed/experiment.py @@ -141,6 +141,8 @@ def start_collection(self, **params) -> None: break finally: self.ctrl.stage.set(a=0) + if params['stop_event'].is_set(): + break try: window_idx, window = self.locate_next_window() except IndexError: @@ -307,6 +309,7 @@ def run_scan(self, window_idx: int, scan_idx: int) -> None: def teardown(self) -> None: """Close all threads and safely shut down when requested.""" self.dispatcher.terminate_workers() + self.params['stop_event'].clear() def finalize(self) -> None: ... diff --git a/src/instamatic/experiments/scan_ed/progress.py b/src/instamatic/experiments/scan_ed/progress.py index 9881b603..9e04ed67 100644 --- a/src/instamatic/experiments/scan_ed/progress.py +++ b/src/instamatic/experiments/scan_ed/progress.py @@ -39,8 +39,8 @@ def _build_tree(self) -> None: for column in self.COLUMNS: self.tree.heading(column, text=column) - self.tree.column('#0', width=30, stretch=True) - self.tree.column('geometry', anchor=tk.CENTER, width=120) + self.tree.column('#0', width=40, stretch=True) + self.tree.column('geometry', anchor=tk.CENTER, width=160) self.tree.column('hits', anchor=tk.E, width=20) self.tree.column('peaks', anchor=tk.E, width=20) self.tree.column('steps', anchor=tk.E, width=20) @@ -181,6 +181,14 @@ def fill_scan( self.tree.set(window_iid, 'hits/step', safe_ratio(wt, 'hits', 'steps')) self.tree.set(window_iid, 'peaks/step', safe_ratio(wt, 'peaks', 'steps')) + def clear(self) -> None: + """Remove all rows and reset cached totals (e.g. before loading).""" + for iid in self.tree.get_children(''): + self.tree.delete(iid) + self._scan_geom.clear() + self._scan_totals.clear() + self._window_totals.clear() + class ThreadSafeProgressTableProxy: """Thread-safe proxy: same API as ProgressTable, executed on Tk thread.""" @@ -228,6 +236,9 @@ def fill_step(self, **kwargs): def fill_scan(self, **kwargs): self._post('fill_scan', **kwargs) + def clear(self, **kwargs): + self._post('clear', **kwargs) + def edits_progress(method: Callable) -> Callable: """Method decorator, captures calls to modify object's progress attr.""" diff --git a/src/instamatic/gui/scan_ed_frame.py b/src/instamatic/gui/scan_ed_frame.py index d7bd3e96..1f99fdcb 100644 --- a/src/instamatic/gui/scan_ed_frame.py +++ b/src/instamatic/gui/scan_ed_frame.py @@ -1,5 +1,6 @@ from __future__ import annotations +from enum import Enum from pathlib import Path from threading import Event as ThreadingEvent from tkinter import * @@ -23,6 +24,23 @@ duration = {'from_': 0, 'to': 60, 'increment': 0.1} +class WidgetState(Enum): + IDLE = 0 + BUSY = 1 + STOPPING = 2 + + +class ThreadSafeTkCallback: + """Run callback(*args, **kwargs) on the Tk thread.""" + + def __init__(self, parent, callback): + self._parent = parent + self._callback = callback + + def __call__(self, *args, **kwargs): + self._parent.after(0, lambda: self._callback(*args, **kwargs)) + + class ExperimentalScanEDVariables: """A collection of tkinter Variable instances passed to the experiment.""" @@ -150,32 +168,51 @@ def __init__(self, parent): self.start_button = Button(g, text='Start collection', command=self.start_collection) self.start_button.grid(row=20, column=0, sticky=EW) - self.load_button = Button(g, text='Load and continue', command=self.load_collection) self.load_button.grid(row=20, column=1, sticky=EW) - - self.stop_button = Button(g, text='Stop collection', command=self.var.stop_event.set) + self.stop_button = Button(g, text='Stop collection', command=self.stop_collection) self.stop_button.grid(row=20, column=2, sticky=EW) + self.update_widget() g.pack(side='bottom', fill=BOTH, expand=True, padx=10) f.pack(side='bottom', fill=BOTH, expand=True, pady=10) def start_collection(self) -> None: + self.progress.clear() + callback = ThreadSafeTkCallback(self, self.update_widget) progress = ThreadSafeProgressTableProxy(self, self.progress) - self.q.put(('scan_ed', {'progress': progress, **self.var.as_dict()})) + kwargs = {'callback': callback, 'load': False, 'progress': progress} + self.q.put(('scan_ed', {**kwargs, **self.var.as_dict()})) + self.update_widget(state=WidgetState.BUSY) def load_collection(self) -> None: + self.progress.clear() + callback = ThreadSafeTkCallback(self, self.update_widget) progress = ThreadSafeProgressTableProxy(self, self.progress) - kwargs = {'load': True, 'progress': progress} + kwargs = {'callback': callback, 'load': True, 'progress': progress} self.q.put(('scan_ed', {**kwargs, **self.var.as_dict()})) + self.update_widget(state=WidgetState.BUSY) + + def stop_collection(self) -> None: + self.var.stop_event.set() + self.update_widget(state=WidgetState.STOPPING) + + def update_widget(self, state: WidgetState = WidgetState.IDLE) -> None: + """Update the buttons to reflect the current state of the widget.""" + self.start_button.config(state=NORMAL if state is WidgetState.IDLE else DISABLED) + self.load_button.config(state=NORMAL if state is WidgetState.IDLE else DISABLED) + self.stop_button.config(state=NORMAL if state is WidgetState.BUSY else DISABLED) def sced_interface_command(controller, **params: Any) -> None: from instamatic.experiments.scan_ed.experiment import Experiment + callback = params.pop('callback', lambda: None) load: bool = params.get('load', False) progress: Optional[ProgressTable] = params.get('progress', None) flat_field = controller.module_io.get_flatfield() + if params.get('stop_event', None) is not None: + params['stop_event'].clear() if load: exp_dir = controller.module_io.get_experiment_directory() @@ -205,6 +242,7 @@ def sced_interface_command(controller, **params: Any) -> None: except RuntimeError: pass # RuntimeError is raised if experiment is terminated early finally: + callback() del controller.fast_adt From 30607c554e36ee896debb1cfa58c065c1379e0a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Wed, 4 Feb 2026 17:46:33 +0100 Subject: [PATCH 045/118] Fix rastering/serpentine behaviour --- src/instamatic/experiments/scan_ed/experiment.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/instamatic/experiments/scan_ed/experiment.py b/src/instamatic/experiments/scan_ed/experiment.py index b8c72300..4fb9eec3 100644 --- a/src/instamatic/experiments/scan_ed/experiment.py +++ b/src/instamatic/experiments/scan_ed/experiment.py @@ -167,11 +167,7 @@ def add_scans(self, window_idx: int, params: dict[str, Any]) -> None: step = params['scan_y_step'] spacing = params['scan_x_step'] - if params['scan_geometry'].lower().endswith('raster'): - scan_signs = cycle([1, -1]) - else: # params['scan_geometry'].lower().endswith('raster'): - scan_signs = cycle([1]) - + scan_signs = cycle([1] if 'raster' in params['scan_geometry'] else [1, -1]) slow_min = np.min(window.corners[:, 1 - axis]) slow_max = np.max(window.corners[:, 1 - axis]) slows = np.arange(slow_min + spacing, slow_max, spacing, dtype=int) From 508ac1992326324e01e46a93369b2cfa6420fabf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Wed, 4 Feb 2026 18:25:35 +0100 Subject: [PATCH 046/118] When adding a scan, add also an error_margin to both sides, covering which takes (total stage delay) time --- .../experiments/scan_ed/experiment.py | 34 ++++++++++++------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/src/instamatic/experiments/scan_ed/experiment.py b/src/instamatic/experiments/scan_ed/experiment.py index 4fb9eec3..315ccc77 100644 --- a/src/instamatic/experiments/scan_ed/experiment.py +++ b/src/instamatic/experiments/scan_ed/experiment.py @@ -5,14 +5,14 @@ from itertools import count, cycle from pathlib import Path from threading import Thread -from typing import TYPE_CHECKING, Any, Optional +from typing import TYPE_CHECKING, Any, Literal, Optional import numpy as np import pandas as pd from instamatic._typing import AnyPath, int_nm from instamatic.calibrate import CalibMovieDelays -from instamatic.calibrate.calibrate_stage_translation import CalibStageTranslationX +from instamatic.calibrate.calibrate_stage_translation import * from instamatic.experiments.experiment_base import ExperimentBase from instamatic.experiments.fast_adt.experiment import FastADTMissingCalibError from instamatic.experiments.scan_ed.dispatch import DiffHuntDispatcher @@ -96,23 +96,25 @@ def get_dispatcher(self) -> DiffHuntDispatcher: image, h = self.ctrl.get_image() return DiffHuntDispatcher(shape=image.shape, dtype=image.dtype) - def get_stage_translation(self) -> CalibStageTranslationX: + def get_stage_translation(self) -> CalibStageMotion: """Get rotation calibration if present; otherwise warn & terminate.""" try: - return CalibStageTranslationX.from_file() + if self.params['scan_geometry'].lower().startswith('x'): + return CalibStageTranslationX.from_file() + return CalibStageTranslationY.from_file() except OSError: print(m1 := 'This script requires stage rotation to be calibrated.') print(m2 := 'Please run `instamatic.calibrate_stage_rotation` first.') raise FastADTMissingCalibError(m1 + ' ' + m2) - def determine_exposure_and_speed(self, step_size: int_nm) -> tuple[float, float]: - """Determine exposure/speed reachable by TEM close to requested.""" + def determine_timing(self, step_size: int_nm) -> tuple[float, float, float]: + """Determine exposure/reachable speed/total delay expected from TEM.""" detector_dead_time = self.get_dead_time(self.params['scan_exposure']) time_for_one_frame = self.params['scan_exposure'] + detector_dead_time trans_calib = self.get_stage_translation() motion_plan = trans_calib.plan_motion(time_for_one_frame / step_size) exposure = abs(motion_plan.pace * step_size) - detector_dead_time - return exposure, motion_plan.speed + return exposure, motion_plan.speed, motion_plan.total_delay def start_collection(self, **params) -> None: """Method that governs the entirety of scan ED experiment work flow.""" @@ -167,20 +169,26 @@ def add_scans(self, window_idx: int, params: dict[str, Any]) -> None: step = params['scan_y_step'] spacing = params['scan_x_step'] - scan_signs = cycle([1] if 'raster' in params['scan_geometry'] else [1, -1]) + _, _, total_delay = self.determine_timing(step) + error_margin = max(step * total_delay / self.params['scan_exposure'], 0) + + scan_dirs = cycle([1] if 'raster' in params['scan_geometry'] else [1, -1]) slow_min = np.min(window.corners[:, 1 - axis]) slow_max = np.max(window.corners[:, 1 - axis]) slows = np.arange(slow_min + spacing, slow_max, spacing, dtype=int) for scan_id, slow in enumerate(slows): - fast_min, fast_max = scan_factory(slow)[:: next(scan_signs)] + fast_min, fast_max = scan_factory(slow) + fast_min -= error_margin + fast_max += error_margin + fast_start, fast_stop = [fast_min, fast_max][:: next(scan_dirs)] self.state.add_scan( window=int(window_idx), scan=int(scan_id), - x0=int(slow if axis else fast_min), - y0=int(fast_min if axis else slow), + x0=int(slow if axis else fast_start), + y0=int(fast_start if axis else slow), axis=int(axis), step=int(step), - n_steps=-int(-abs(fast_max - fast_min) // step), + n_steps=-int(-abs(fast_stop - fast_start) // step), ) def determine_manual_windows(self) -> list[GridablePolygonWindow]: @@ -283,7 +291,7 @@ def run_scan(self, window_idx: int, scan_idx: int) -> None: fb_thread = Thread(target=self.dispatcher.handle_feedback, kwargs=fb_kwargs) fb_thread.start() - exposure, speed = self.determine_exposure_and_speed(scan['step']) + exposure, speed, _ = self.determine_timing(scan['step']) axis = scan['axis'] # x: 0, y: 1 fast0 = scan['y0' if axis else 'x0'] fast1 = fast0 + scan['step'] * scan['n_steps'] From 0d65370d2be0a47f6c73d8f45bb929565389eb8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Fri, 6 Feb 2026 13:30:07 +0100 Subject: [PATCH 047/118] Address critical issues found by GPT --- .../experiments/scan_ed/detection.py | 27 ++++++++++--------- .../experiments/scan_ed/dispatch.py | 8 ++++-- .../experiments/scan_ed/experiment.py | 16 +++++------ src/instamatic/experiments/scan_ed/util.py | 18 ------------- src/instamatic/gui/scan_ed_frame.py | 8 +++--- 5 files changed, 32 insertions(+), 45 deletions(-) delete mode 100644 src/instamatic/experiments/scan_ed/util.py diff --git a/src/instamatic/experiments/scan_ed/detection.py b/src/instamatic/experiments/scan_ed/detection.py index 57c4b072..84558ea1 100644 --- a/src/instamatic/experiments/scan_ed/detection.py +++ b/src/instamatic/experiments/scan_ed/detection.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Optional, Sequence +from typing import Optional, Sequence, Union import matplotlib.pyplot as plt import numpy as np @@ -11,6 +11,18 @@ from scipy import ndimage as ndi +def make_cross_mask(): + c = 511 / 2 + yy, xx = np.indices((512, 512)) + vertical = np.abs(xx - c) <= 1.9 + horizontal = np.abs(yy - c) <= 1.9 + cross = vertical | horizontal + return ~cross + + +HARD_CODED_MASK = make_cross_mask() + + @dataclass class DiffHuntResults: """Stores and normalizes basic results of diffraction detection.""" @@ -29,7 +41,7 @@ def ring_percentile_detection( threshold_mult: float = 3.0, min_peak_count: int = 10, min_peak_sep: int = 5, - mask: np.ndarray | None = None, + mask: Union[np.ndarray, None] = HARD_CODED_MASK, n_bins: int = 10, ): """Fast diffraction detector with radial-binned background subtraction. @@ -49,7 +61,7 @@ def ring_percentile_detection( valid_idx = np.flatnonzero(valid) if valid_idx.size == 0: - DiffHuntResults(success=False, bin_center=(cy, cx), mask=valid) + return DiffHuntResults(success=False, bin_center=(cy, cx), mask=valid) vals = frame.flat[valid_idx].astype(np.float32, copy=False) rr_vals = rr.flat[valid_idx].astype(np.float32, copy=False) @@ -191,15 +203,6 @@ def plot_diffraction_debug( plt.show() -def make_cross_mask(): - c = 511 / 2 - yy, xx = np.indices((512, 512)) - vertical = np.abs(xx - c) <= 1.9 - horizontal = np.abs(yy - c) <= 1.9 - cross = vertical | horizontal - return ~cross - - if __name__ == '__main__': from PIL import Image diff --git a/src/instamatic/experiments/scan_ed/dispatch.py b/src/instamatic/experiments/scan_ed/dispatch.py index a64b054b..c807ba59 100644 --- a/src/instamatic/experiments/scan_ed/dispatch.py +++ b/src/instamatic/experiments/scan_ed/dispatch.py @@ -216,8 +216,12 @@ def run(self) -> None: elif cmd.kind == 'PROCESS': ptr = int(cmd.buffer_pointer) self.emit('PROCESSING', buffer_pointer=ptr) - d = ring_percentile_detection(frame=self.frames[ptr]) - self.emit('PROCESSED', buffer_pointer=ptr, details=d) + try: + d = ring_percentile_detection(frame=self.frames[ptr]) + except Exception as e: + d = DiffHuntResults(success=False) + finally: + self.emit('PROCESSED', buffer_pointer=ptr, details=d) elif cmd.kind == 'WRITE': try: diff --git a/src/instamatic/experiments/scan_ed/experiment.py b/src/instamatic/experiments/scan_ed/experiment.py index 315ccc77..e2a74b49 100644 --- a/src/instamatic/experiments/scan_ed/experiment.py +++ b/src/instamatic/experiments/scan_ed/experiment.py @@ -1,16 +1,14 @@ from __future__ import annotations -import logging from datetime import datetime, timedelta from itertools import count, cycle from pathlib import Path from threading import Thread -from typing import TYPE_CHECKING, Any, Literal, Optional +from typing import TYPE_CHECKING, Any import numpy as np import pandas as pd -from instamatic._typing import AnyPath, int_nm from instamatic.calibrate import CalibMovieDelays from instamatic.calibrate.calibrate_stage_translation import * from instamatic.experiments.experiment_base import ExperimentBase @@ -181,6 +179,7 @@ def add_scans(self, window_idx: int, params: dict[str, Any]) -> None: fast_min -= error_margin fast_max += error_margin fast_start, fast_stop = [fast_min, fast_max][:: next(scan_dirs)] + step = step if fast_stop > fast_start else -step self.state.add_scan( window=int(window_idx), scan=int(scan_id), @@ -188,7 +187,7 @@ def add_scans(self, window_idx: int, params: dict[str, Any]) -> None: y0=int(fast_start if axis else slow), axis=int(axis), step=int(step), - n_steps=-int(-abs(fast_stop - fast_start) // step), + n_steps=int(np.ceil(abs((fast_stop - fast_start) / step))), ) def determine_manual_windows(self) -> list[GridablePolygonWindow]: @@ -244,14 +243,15 @@ def locate_next_window(self) -> tuple[int, GridablePolygonWindow]: if self.params.get('grid_finder') == 'All manually': raise IndexError('Experiment params disallow locating new windows') grid: PeriodicConvexPolygonGrid[GridablePolygonWindow] = self.state.grid - for window_id in range(1, 2 * max(grid.windows) + 10): + max_index = 10 + 2 * (max(grid.windows) if grid.windows else 0) + for window_id in range(0, max_index): if window_id in grid.windows: continue predicted = grid.predict_window(window_id) x_lim = tx if (tx := self.params['target_x']) is not None else float('inf') - y_lim = ty if (ty := self.params['target_x']) is not None else float('inf') + y_lim = ty if (ty := self.params['target_y']) is not None else float('inf') x_fits = np.all(np.abs(predicted.corners[:, 0]) < x_lim) - y_fits = np.all(np.abs(predicted.corners[:, 0]) < y_lim) + y_fits = np.all(np.abs(predicted.corners[:, 1]) < y_lim) if not (x_fits and y_fits): continue self.ctrl.stage.set(*[int(xy) for xy in predicted.center]) @@ -272,7 +272,7 @@ def set_tilt(self, window_idx: int) -> None: """Set alpha (0 to +/-max to 0) as a function of window progress.""" p = self.state.window_progress(window=window_idx) m = self.params['max_alpha'] - a = m * 2 * p if p <= 0.5 else m * (2 * p - 1) # 0 to m, then -m to 0 + a = m * 2 * p if p <= 0.5 else m * (2 * p - 2) # 0 to m, then -m to 0 self.ctrl.stage.set(a=a) def run_scan(self, window_idx: int, scan_idx: int) -> None: diff --git a/src/instamatic/experiments/scan_ed/util.py b/src/instamatic/experiments/scan_ed/util.py deleted file mode 100644 index af795828..00000000 --- a/src/instamatic/experiments/scan_ed/util.py +++ /dev/null @@ -1,18 +0,0 @@ -from __future__ import annotations - -from typing import NamedTuple, Optional - -from fontTools.misc.cython import returns - - -class SPEDLoc(NamedTuple): - """Universal SPED indexing/locating format for grid, scans, frames etc.""" - - grid_i: int - grid_j: int - scan: Optional[int] = None - step: Optional[int] = None - - @property - def name(self) -> str: - return 'SPED_' + '_'.join(str(i) for i in self if i is not None) diff --git a/src/instamatic/gui/scan_ed_frame.py b/src/instamatic/gui/scan_ed_frame.py index 1f99fdcb..31d81a6a 100644 --- a/src/instamatic/gui/scan_ed_frame.py +++ b/src/instamatic/gui/scan_ed_frame.py @@ -13,15 +13,13 @@ from .base_module import BaseModule, ModuleFrameMixin -pad0 = {'sticky': 'EW', 'padx': 0, 'pady': 1} pad10 = {'sticky': 'EW', 'padx': 10, 'pady': 1} -scan_step = {'from_': 0, 'to': 100_000, 'increment': 100} -scan_exposure = {'from_': 0, 'to': 10, 'increment': 0.01} +scan_step = {'from_': 100, 'to': 100_000, 'increment': 100} +scan_exposure = {'from_': 0.01, 'to': 10, 'increment': 0.01} target_hits = {'from_': 0, 'to': 1_000_000, 'increment': 100} target_time = {'from_': 0, 'to': 43_200, 'increment': 60} target_xy = {'from_': 0, 'to': 1_000_000, 'increment': 1000} -angle_delta = {'from_': 0, 'to': 180, 'increment': 0.1} -duration = {'from_': 0, 'to': 60, 'increment': 0.1} +angle_delta = {'from_': 0, 'to': 30, 'increment': 1} class WidgetState(Enum): From a478e0d75dedcb753d3d2560df01cdc5cc10a8ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Fri, 6 Feb 2026 14:52:57 +0100 Subject: [PATCH 048/118] Address issues with windows/grid found by GPT --- src/instamatic/grid/registry.py | 2 +- src/instamatic/grid/sweepers.py | 4 ++-- src/instamatic/grid/window.py | 14 ++++++-------- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/src/instamatic/grid/registry.py b/src/instamatic/grid/registry.py index 377840a7..7d2d49d5 100644 --- a/src/instamatic/grid/registry.py +++ b/src/instamatic/grid/registry.py @@ -18,7 +18,7 @@ class RectangularGrid(PeriodicConvexPolygonGrid[RectangularWindow]): pairing_inverse = staticmethod(ulam2ij) -class SquareGrid(PeriodicConvexPolygonGrid[RectangularWindow]): +class SquareGrid(PeriodicConvexPolygonGrid[SquareWindow]): window_type = SquareWindow pairing_function = staticmethod(ij2ulam) pairing_inverse = staticmethod(ulam2ij) diff --git a/src/instamatic/grid/sweepers.py b/src/instamatic/grid/sweepers.py index 7f64f362..6ae76943 100644 --- a/src/instamatic/grid/sweepers.py +++ b/src/instamatic/grid/sweepers.py @@ -95,7 +95,7 @@ def step(self, length: float_nm) -> None: """Change sweeper position by `length` in `heading` direction.""" x0, y0 = _ctrl.stage.xy x1 = int(x0 + self.heading[0].item() * length) - y1 = int(x0 + self.heading[1].item() * length) + y1 = int(y0 + self.heading[1].item() * length) self.goto(x1, y1) @@ -120,7 +120,7 @@ def breed(self, other: Self) -> Self: n = np.linalg.norm(s := self.heading + other.heading) if n == 0: raise ValueError('Cannot breed sweepers with parallel heading') - return self.__class__(origin=o, heading=s / n) + return self.__class__(origin=o, heading=s / n, team=self.team) def sweep(self) -> None: """Bin-search the edge based on peaked light vs max * threshold.""" diff --git a/src/instamatic/grid/window.py b/src/instamatic/grid/window.py index c51881a3..12e669a9 100644 --- a/src/instamatic/grid/window.py +++ b/src/instamatic/grid/window.py @@ -5,12 +5,10 @@ from typing import Literal, Optional import numpy as np -from matplotlib import pyplot as plt -from matplotlib.patches import Polygon from scipy.optimize import minimize from typing_extensions import Self -from instamatic._typing import float_nm, int_nm +from instamatic._typing import float_nm from instamatic.controller import TEMController, _ctrl, initialize from instamatic.grid.sweepers import BinaryEdgeSweeper, EdgeSweeperTeam, MarchingEdgeSweeper from instamatic.utils.iterating import pairwise @@ -81,24 +79,24 @@ class GridablePolygonWindow(ConvexPolygonWindow): def __repr__(self) -> str: ... @classmethod - def from_sweeping(cls, order: Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10] = 3) -> Self: + def from_sweeping(cls, order: Literal[1, 2, 3, 4, 5] = 3) -> Self: """Return new using `EdgeSweeper`s scanning around current position.""" origin = np.array(_ctrl.stage.xy, dtype=int) team = str(origin) _ = EdgeSweeperTeam(name=team) # define and sweep with initial marching sweepers to approx. grid center - dirs = [+X, -X, +Y, -Y] + dirs = [+X, +Y, -X, -Y] mess = [MarchingEdgeSweeper(origin=origin, heading=d, team=team) for d in dirs] for mes in mess: mes.sweep() - center_x = (mess[0].position[0] + mess[1].position[0]) / 2 - center_y = (mess[2].position[1] + mess[3].position[1]) / 2 + center_x = (mess[0].position[0] + mess[2].position[0]) / 2 + center_y = (mess[1].position[1] + mess[3].position[1]) / 2 center = np.array([center_x, center_y], dtype=float) # define binary sweepers, step to edge of marchers-probed region & sweep mess_position_pairs = list(pairwise([mes.position for mes in mess], closed=True)) - bess = [BinaryEdgeSweeper(origin=center, heading=d) for d in (X, Y, -X, -Y)] + bess = [BinaryEdgeSweeper(origin=center, heading=d, team=team) for d in (X, Y, -X, -Y)] for bes in bess: dists = [bes.dist2segment(*p1, *p2) for p1, p2 in mess_position_pairs] safe_dist = min(dists) - bes.team.step_size From 6f6a4183b7079937a25540c39caaf020e324ae15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Fri, 6 Feb 2026 16:41:31 +0100 Subject: [PATCH 049/118] Auto-save figures of windows whenever they are added --- .../experiments/scan_ed/experiment.py | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/instamatic/experiments/scan_ed/experiment.py b/src/instamatic/experiments/scan_ed/experiment.py index e2a74b49..9f12ac4c 100644 --- a/src/instamatic/experiments/scan_ed/experiment.py +++ b/src/instamatic/experiments/scan_ed/experiment.py @@ -127,6 +127,8 @@ def start_collection(self, **params) -> None: if not self.state.grid.windows: windows = self.determine_manual_windows() self.order_and_add_manual_windows(windows) + for window_idx, window in self.state.grid.windows.items(): + self.draw_windows_to_file(window_idx=window_idx, window=window) while not params['stop_event'].is_set(): try: @@ -149,6 +151,7 @@ def start_collection(self, **params) -> None: params['stop_event'].set() break self.state.add_window(idx=window_idx, window=window) + self.draw_windows_to_file(window_idx=window_idx, window=window) self.teardown() @@ -199,11 +202,9 @@ def determine_manual_windows(self) -> list[GridablePolygonWindow]: n = self.name cl: ClickListener = c if (c := d.listeners.get(n)) else d.add_listener(n) - print('Please navigate the stage as many points on the edge as possible') - print('(at least the corners and approximate midpoints). At each point,') - print('position the edge at the center of the screen.') - print('Left-click the screen to add the point, right-click to finish.') - print('') + print('Please navigate the stage to as many points on the windows edge as possible') + print('(at least the corners and midpoints). At each point, position the edge at') + print('the center of the screen and LMB to add the point. RMB to finish.') windows = {} for window_idx in count(): @@ -215,7 +216,7 @@ def determine_manual_windows(self) -> list[GridablePolygonWindow]: break edge_xys.append(self.ctrl.stage.xy) edge_xys = np.asarray(edge_xys, dtype=float) - window = self.state.grid.window_type.from_edge_xys(edge_xy=edge_xys) + window = self.state.grid.window_type.from_edge_xys(edge_xys=edge_xys) fig, ax = plot({**windows, window_idx: window}, debug_edges=True) with self.videostream_frame.processor.temporary(figure=fig): print('LMB to accept and finish, RMB to retry, MMB to accept and add new') @@ -258,6 +259,13 @@ def locate_next_window(self) -> tuple[int, GridablePolygonWindow]: return window_id, grid.window_type.from_sweeping() raise IndexError('Could not locate next window within limits') + def draw_windows_to_file(self, window_idx: int, window: GridablePolygonWindow) -> None: + """Use grid.artist.plot to draw window into its own file for debug.""" + file_path = self.path / 'windows' / f'window_{window_idx:04d}.png' + file_path.parent.mkdir(exist_ok=True, parents=True) + fig, ax = plot({window_idx: window}, debug_edges=True) + fig.savefig(file_path) + def set_stop_event_if_target_met(self) -> None: th: Optional[int] = self.params.get('target_hits', None) tt: Optional[int] = self.params.get('target_time', None) From 263f1c134eb3ffa65a9de1a2a52c1f7b97bad6d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Fri, 6 Feb 2026 16:42:05 +0100 Subject: [PATCH 050/118] Auto-save figures of windows whenever they are added (fix typo) --- src/instamatic/experiments/scan_ed/experiment.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/instamatic/experiments/scan_ed/experiment.py b/src/instamatic/experiments/scan_ed/experiment.py index 9f12ac4c..b51c6ded 100644 --- a/src/instamatic/experiments/scan_ed/experiment.py +++ b/src/instamatic/experiments/scan_ed/experiment.py @@ -128,7 +128,7 @@ def start_collection(self, **params) -> None: windows = self.determine_manual_windows() self.order_and_add_manual_windows(windows) for window_idx, window in self.state.grid.windows.items(): - self.draw_windows_to_file(window_idx=window_idx, window=window) + self.draw_window_to_file(window_idx=window_idx, window=window) while not params['stop_event'].is_set(): try: @@ -151,7 +151,7 @@ def start_collection(self, **params) -> None: params['stop_event'].set() break self.state.add_window(idx=window_idx, window=window) - self.draw_windows_to_file(window_idx=window_idx, window=window) + self.draw_window_to_file(window_idx=window_idx, window=window) self.teardown() @@ -259,7 +259,7 @@ def locate_next_window(self) -> tuple[int, GridablePolygonWindow]: return window_id, grid.window_type.from_sweeping() raise IndexError('Could not locate next window within limits') - def draw_windows_to_file(self, window_idx: int, window: GridablePolygonWindow) -> None: + def draw_window_to_file(self, window_idx: int, window: GridablePolygonWindow) -> None: """Use grid.artist.plot to draw window into its own file for debug.""" file_path = self.path / 'windows' / f'window_{window_idx:04d}.png' file_path.parent.mkdir(exist_ok=True, parents=True) From 92b61c245330638be1e9e29d3b84fa7b325fecce Mon Sep 17 00:00:00 2001 From: Daniel Tchon Date: Fri, 6 Feb 2026 19:10:01 +0100 Subject: [PATCH 051/118] Fix movie: auto-determine what IP serval should connect to (+ minor bugs) --- src/instamatic/camera/camera_serval.py | 12 +++++++++++- src/instamatic/experiments/scan_ed/experiment.py | 2 ++ src/instamatic/grid/sweepers.py | 4 ++-- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/instamatic/camera/camera_serval.py b/src/instamatic/camera/camera_serval.py index bfbecfab..254b843d 100644 --- a/src/instamatic/camera/camera_serval.py +++ b/src/instamatic/camera/camera_serval.py @@ -1,6 +1,7 @@ from __future__ import annotations import atexit +import contextlib import json import logging import math @@ -47,6 +48,14 @@ def __init__(self, name='serval'): logger.info(f'Camera {self.get_name()} initialized') atexit.register(self.release_connection) + @staticmethod + def _local_ip_for(remote_host: str, remote_port: int) -> str: + """Return the local IP used to reach (remote_host, remote_port).""" + # UDP "connect" does not send packets, but lets the OS choose interface/IP. + with contextlib.closing(socket.socket(socket.AF_INET, socket.SOCK_DGRAM)) as s: + s.connect((remote_host, remote_port)) + return s.getsockname()[0] + def get_image(self, exposure: Optional[float] = None, **kwargs) -> np.ndarray: """Image acquisition interface. If the exposure is not given, the default value is read from the config file. Binning is ignored. @@ -136,7 +145,8 @@ def get_movie( http_url = urlparse(self.conn.url) tcp_port = (http_url.port or 8080) + 1 - tcp_base = f'tcp://connect@{http_url.hostname}:{tcp_port}' + local_ip = self._local_ip_for(http_url.hostname, tcp_port) + tcp_base = f'tcp://connect@{local_ip}:{tcp_port}' tcp_dest = {'Base': tcp_base, 'Format': 'jsonimage', 'Mode': 'count'} self.conn.measurement_stop() diff --git a/src/instamatic/experiments/scan_ed/experiment.py b/src/instamatic/experiments/scan_ed/experiment.py index b51c6ded..54534cb6 100644 --- a/src/instamatic/experiments/scan_ed/experiment.py +++ b/src/instamatic/experiments/scan_ed/experiment.py @@ -244,6 +244,8 @@ def locate_next_window(self) -> tuple[int, GridablePolygonWindow]: if self.params.get('grid_finder') == 'All manually': raise IndexError('Experiment params disallow locating new windows') grid: PeriodicConvexPolygonGrid[GridablePolygonWindow] = self.state.grid + if not self.state.grid.windows: + return 0, grid.window_type.from_sweeping() max_index = 10 + 2 * (max(grid.windows) if grid.windows else 0) for window_id in range(0, max_index): if window_id in grid.windows: diff --git a/src/instamatic/grid/sweepers.py b/src/instamatic/grid/sweepers.py index 6ae76943..9d92f742 100644 --- a/src/instamatic/grid/sweepers.py +++ b/src/instamatic/grid/sweepers.py @@ -64,7 +64,7 @@ class EdgeSweeperTeam(InstanceAutoNameRegistry): name: str = '' # identifier used for registration in INSTANCES step_size: int_nm = 10_000 # largest step size allowed precision: int_nm = 1 # smallest step size allowed - threshold: float = 0.01 # fraction of light_max that signals the edge + threshold: float = 0.05 # fraction of light_max that signals the edge light_max: int = -1 # maximum light observed at any point by any sweeper @@ -120,7 +120,7 @@ def breed(self, other: Self) -> Self: n = np.linalg.norm(s := self.heading + other.heading) if n == 0: raise ValueError('Cannot breed sweepers with parallel heading') - return self.__class__(origin=o, heading=s / n, team=self.team) + return self.__class__(origin=o, heading=s / n, team=self.team.name) def sweep(self) -> None: """Bin-search the edge based on peaked light vs max * threshold.""" From 4aa614279618639ff22ee4534bea59680dfe5049 Mon Sep 17 00:00:00 2001 From: Daniel Tchon Date: Fri, 6 Feb 2026 20:16:59 +0100 Subject: [PATCH 052/118] Fix issue where points in windows were treated as in distance 0 --- .../experiments/scan_ed/experiment.py | 10 +-- src/instamatic/grid/window.py | 70 +++++++++++-------- 2 files changed, 45 insertions(+), 35 deletions(-) diff --git a/src/instamatic/experiments/scan_ed/experiment.py b/src/instamatic/experiments/scan_ed/experiment.py index 54534cb6..822e572a 100644 --- a/src/instamatic/experiments/scan_ed/experiment.py +++ b/src/instamatic/experiments/scan_ed/experiment.py @@ -181,15 +181,15 @@ def add_scans(self, window_idx: int, params: dict[str, Any]) -> None: fast_min, fast_max = scan_factory(slow) fast_min -= error_margin fast_max += error_margin - fast_start, fast_stop = [fast_min, fast_max][:: next(scan_dirs)] - step = step if fast_stop > fast_start else -step + direction = next(scan_dirs) + fast_start, fast_stop = [fast_min, fast_max][:: direction] self.state.add_scan( window=int(window_idx), scan=int(scan_id), x0=int(slow if axis else fast_start), y0=int(fast_start if axis else slow), axis=int(axis), - step=int(step), + step=int(step * direction), n_steps=int(np.ceil(abs((fast_stop - fast_start) / step))), ) @@ -245,7 +245,7 @@ def locate_next_window(self) -> tuple[int, GridablePolygonWindow]: raise IndexError('Experiment params disallow locating new windows') grid: PeriodicConvexPolygonGrid[GridablePolygonWindow] = self.state.grid if not self.state.grid.windows: - return 0, grid.window_type.from_sweeping() + return 0, grid.window_type.from_sweeping(order=4) max_index = 10 + 2 * (max(grid.windows) if grid.windows else 0) for window_id in range(0, max_index): if window_id in grid.windows: @@ -258,7 +258,7 @@ def locate_next_window(self) -> tuple[int, GridablePolygonWindow]: if not (x_fits and y_fits): continue self.ctrl.stage.set(*[int(xy) for xy in predicted.center]) - return window_id, grid.window_type.from_sweeping() + return window_id, grid.window_type.from_sweeping(order=3) raise IndexError('Could not locate next window within limits') def draw_window_to_file(self, window_idx: int, window: GridablePolygonWindow) -> None: diff --git a/src/instamatic/grid/window.py b/src/instamatic/grid/window.py index 12e669a9..2db25bfa 100644 --- a/src/instamatic/grid/window.py +++ b/src/instamatic/grid/window.py @@ -150,10 +150,9 @@ def __init__(self, x: float, y: float, w: float, t: float): self.b = self.ROT60MAT @ (self.ROT60MAT @ self.a) r_circum = w / np.sqrt(3.0) - corners = [] - for angle in np.linspace(t + np.pi / 6, t + 13 * np.pi / 6, num=6, endpoint=False): - corners.append(r_circum * np.array([np.cos(angle), np.sin(angle)], dtype=float)) - self.corners = c + np.vstack(corners) + angles = t + np.pi / 6 + np.arange(6) * (np.pi / 3) + corners = r_circum * np.stack([np.cos(angles), np.sin(angles)], axis=1) + self.corners = c + corners def __repr__(self) -> str: args = [self.center_x, self.center_y, self.width, self.theta] @@ -195,22 +194,22 @@ def edge_d2_sum(geom: tuple[float, float, float, float], xys: np.ndarray) -> flo """Objective: squared distance of points to nearest hexagon side (regular).""" center_x, center_y, width, theta = geom if width <= 0: - return np.inf + return float("inf") center = np.array([center_x, center_y], dtype=float) deltas = np.asarray(xys, dtype=float) - center - # Unit normals to the 6 sides (pointing outward). - # If an axis points to a side midpoint at angle theta, then that side's outward normal is along theta. - # Other side normals are spaced by 60 degrees. + # 6 outward normals, rotated by theta angles = theta + np.arange(6) * (np.pi / 3.0) normals = np.stack([np.cos(angles), np.sin(angles)], axis=1) # (6,2) - # Signed distances to each supporting line: (n·p - a) - # Point is inside if all <= 0. We want distance to boundary: max(n·p - a) clipped at 0. - distances = deltas @ normals.T - 0.5 * width # (N,6) - outside = np.maximum(distances.max(axis=1), 0.0) # (N,) - return float(np.sum(outside**2)) + apothem = 0.5 * width # if width is flat-to-flat + # signed distances to the six supporting lines + signed = deltas @ normals.T - apothem # (N,6) + + # edge distance: nearest line in absolute value + d = np.min(np.abs(signed), axis=1) # (N,) + return float(np.sum(d ** 2)) def to_params(self) -> dict[str, float]: return {'x': self.center_x, 'y': self.center_y, 'w': self.width, 't': self.theta} @@ -242,8 +241,9 @@ class RectangularWindow(GridablePolygonWindow): def __init__(self, x: float, y: float, w: float, h: float, t: float): t = (float(t) + (np.pi / 2)) % np.pi - (np.pi / 2) # cast to [-pi/2, pi/2] - if not -np.pi / 4 < t < np.pi / 4: # cast to [-pi/4, pi/4] - w, h, t = h, w, (np.pi - t) % np.pi - np.pi / 2 + if abs(t) > (np.pi / 4): # cast to [-pi/4, pi/4] + w, h = h, w + t = t - np.copysign(np.pi / 2, t) self.center_x: float_nm = float(x) self.center_y: float_nm = float(y) @@ -281,16 +281,24 @@ def from_edge_xys(cls, edge_xys: np.ndarray) -> Self: def edge_d2_sum(geom: tuple[float, float, float, float, float], xys: np.ndarray) -> float: """scipy.optimize.minimize fitting func; for geometry see cls docs.""" center_x, center_y, width, height, theta = geom + if width <= 0 or height <= 0: + return float("inf") + center = np.array([center_x, center_y], dtype=float) - a = 0.5 * width * np.array([np.cos(theta), np.sin(theta)]) - b = 0.5 * height * np.array([-np.sin(theta), np.cos(theta)]) - a_hat = a / np.linalg.norm(a) - b_hat = b / np.linalg.norm(b) - d1 = np.abs(np.dot(xys - (center + a), a_hat)) - d2 = np.abs(np.dot(xys - (center - a), a_hat)) - d3 = np.abs(np.dot(xys - (center + b), b_hat)) - d4 = np.abs(np.dot(xys - (center - b), b_hat)) - return np.sum(np.min([d1, d2, d3, d4], axis=0) ** 2) + deltas = np.asarray(xys, dtype=float) - center + + a_hat = np.array([np.cos(theta), np.sin(theta)], dtype=float) + b_hat = np.array([-np.sin(theta), np.cos(theta)], dtype=float) + + # local coordinates + u = deltas @ a_hat + v = deltas @ b_hat + + # distance to nearest supporting line among the 4 edges + du = np.abs(np.abs(u) - 0.5 * width) + dv = np.abs(np.abs(v) - 0.5 * height) + d = np.minimum(du, dv) + return float(np.sum(d ** 2)) def to_params(self) -> dict[str, float]: return { @@ -367,12 +375,14 @@ def edge_d2_sum(geom: tuple[float, float, float, float], xys: np.ndarray) -> flo return np.inf center = np.array([center_x, center_y], dtype=float) deltas = np.asarray(xys, dtype=float) - center - angles = theta + np.arange(4) * (np.pi / 2.0) - normals = np.stack([np.cos(angles), np.sin(angles)], axis=1) # (4,2) - # Signed distances to supporting lines: n·p - a, where a = side/2 - distances = deltas @ normals.T - 0.5 * width # (N,4) - outside = np.maximum(distances.max(axis=1), 0.0) # (N,) - return float(np.sum(outside**2)) + a_hat = np.array([np.cos(theta), np.sin(theta)], dtype=float) + b_hat = np.array([-np.sin(theta), np.cos(theta)], dtype=float) + u = deltas @ a_hat # signed coordinates in the square frame + v = deltas @ b_hat + du = np.abs(np.abs(u) - 0.5 * width) + dv = np.abs(np.abs(v) - 0.5 * width) + d = np.minimum(du, dv) + return float(np.sum(d ** 2)) def to_params(self) -> dict[str, float]: return {'x': self.center_x, 'y': self.center_y, 'w': self.width, 't': self.theta} From b098f294d0c26a372da8110791de9421f497908a Mon Sep 17 00:00:00 2001 From: Daniel Tchon Date: Fri, 6 Feb 2026 20:41:44 +0100 Subject: [PATCH 053/118] `self.ctrl.stage.set_with_speed(**setter_kwargs, wait=False)` LOL --- src/instamatic/experiments/scan_ed/experiment.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/instamatic/experiments/scan_ed/experiment.py b/src/instamatic/experiments/scan_ed/experiment.py index 822e572a..cd34f36a 100644 --- a/src/instamatic/experiments/scan_ed/experiment.py +++ b/src/instamatic/experiments/scan_ed/experiment.py @@ -306,7 +306,7 @@ def run_scan(self, window_idx: int, scan_idx: int) -> None: fast0 = scan['y0' if axis else 'x0'] fast1 = fast0 + scan['step'] * scan['n_steps'] setter_kwargs = {'xy'[axis]: fast1, 'speed': speed} - self.ctrl.stage.set_with_speed(**setter_kwargs) + self.ctrl.stage.set_with_speed(**setter_kwargs, wait=False) movie = self.ctrl.get_movie(n_frames=n_frames, exposure=exposure, header_keys=None) for frame, header in movie: From 9b4b32147fac69f04a46a3073a82d438c6889a34 Mon Sep 17 00:00:00 2001 From: Daniel Tchon Date: Fri, 6 Feb 2026 21:10:45 +0100 Subject: [PATCH 054/118] Add buffer name for better tiff names --- src/instamatic/experiments/scan_ed/experiment.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/instamatic/experiments/scan_ed/experiment.py b/src/instamatic/experiments/scan_ed/experiment.py index cd34f36a..9d6df11f 100644 --- a/src/instamatic/experiments/scan_ed/experiment.py +++ b/src/instamatic/experiments/scan_ed/experiment.py @@ -296,7 +296,7 @@ def run_scan(self, window_idx: int, scan_idx: int) -> None: scan = self.state.scans.loc[(window_idx, scan_idx)] self.ctrl.stage.set(x=scan['x0'], y=scan['y0']) - self.dispatcher.begin_scan(n_frames) + self.dispatcher.begin_scan(n_frames, name=f'w{window_idx:03d}_s{scan_idx:06d}') fb_kwargs = {'state': self.state, 'window': window_idx, 'scan': scan_idx} fb_thread = Thread(target=self.dispatcher.handle_feedback, kwargs=fb_kwargs) fb_thread.start() @@ -319,6 +319,7 @@ def run_scan(self, window_idx: int, scan_idx: int) -> None: self.dispatcher.handle_feedback(self.state, window_idx, scan_idx) self.dispatcher.end_scan() self.state.finalize_scan(window_idx, scan_idx) + self.ctrl.stage.wait() def teardown(self) -> None: """Close all threads and safely shut down when requested.""" From d5c169d2e8b77e9ed7ec6dd63b8111b5e30633e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Sat, 7 Feb 2026 16:18:49 +0100 Subject: [PATCH 055/118] Avoid counting single pixels on dark background by using small Gaussian blur. --- .../experiments/scan_ed/detection.py | 54 +++++++++++-------- 1 file changed, 32 insertions(+), 22 deletions(-) diff --git a/src/instamatic/experiments/scan_ed/detection.py b/src/instamatic/experiments/scan_ed/detection.py index 84558ea1..a492489f 100644 --- a/src/instamatic/experiments/scan_ed/detection.py +++ b/src/instamatic/experiments/scan_ed/detection.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass +from pathlib import Path from typing import Optional, Sequence, Union import matplotlib.pyplot as plt @@ -16,7 +17,8 @@ def make_cross_mask(): yy, xx = np.indices((512, 512)) vertical = np.abs(xx - c) <= 1.9 horizontal = np.abs(yy - c) <= 1.9 - cross = vertical | horizontal + zero_zero = (np.abs(xx) < 2) & (np.abs(yy) < 2) + cross = vertical | horizontal | zero_zero return ~cross @@ -38,21 +40,25 @@ def ring_percentile_detection( frame: np.ndarray, min_radius: int = 40, percentile: float = 99.0, - threshold_mult: float = 3.0, + threshold_mult: float = 2.0, min_peak_count: int = 10, min_peak_sep: int = 5, - mask: Union[np.ndarray, None] = HARD_CODED_MASK, + mask: np.ndarray | None = None, n_bins: int = 10, -): - """Fast diffraction detector with radial-binned background subtraction. - - - estimate center of incident beam based on a blurred central ROI - - excludes (mask == False) and excludes rr <= beam_radius_px regions - - estimates background and reflection threshold in `n_bins` radial shells - - reflections must exceed `percentile` * `threshold_mult` of their ring - - the algorithm is good at finding a small number of strongest reflections + gaussian_sigma: float = 1.2, +) -> DiffHuntResults: + """Radial-binned detector with thresholds computed on a *locally averaged* + image. + + This suppresses single-pixel spikes (stray electrons / hot pixels), + while keeping multi-pixel reflection profiles detectable. """ + # Build a locally-averaged "score" image for candidate selection. + score = frame.astype(np.float32, copy=False) + if gaussian_sigma and gaussian_sigma > 0: + score = ndi.gaussian_filter(score, sigma=float(gaussian_sigma), mode='nearest') + cy, cx = estimate_beam_center(frame, sigma=3.0) ys, xs = np.indices(frame.shape) rr = np.sqrt((ys - cy) ** 2 + (xs - cx) ** 2) @@ -63,7 +69,7 @@ def ring_percentile_detection( if valid_idx.size == 0: return DiffHuntResults(success=False, bin_center=(cy, cx), mask=valid) - vals = frame.flat[valid_idx].astype(np.float32, copy=False) + vals = score.flat[valid_idx] # (N,) float32 rr_vals = rr.flat[valid_idx].astype(np.float32, copy=False) r_max = float(rr_vals.max()) @@ -72,7 +78,6 @@ def ring_percentile_detection( bin_ids = np.digitize(rr_vals, bin_edges) - 1 np.clip(bin_ids, 0, n_bins - 1, out=bin_ids) - # Per-bin bg and thresholds (still a small loop) backgrounds = np.zeros(n_bins, dtype=np.float32) thresholds = np.full(n_bins, np.inf, dtype=np.float32) @@ -81,19 +86,20 @@ def ring_percentile_detection( if not np.any(sel): continue v = vals[sel] - bg = np.median(v) + bg = float(np.median(v)) backgrounds[b] = bg - threshold_perc = max(1.0, np.percentile(v, percentile) - bg) - thresholds[b] = threshold_mult * threshold_perc if v.size else np.inf + # Threshold in "score" units: (percentile - bg) times multiplier, with a small floor. + threshold_perc = max(1.0, float(np.percentile(v, percentile) - bg)) + thresholds[b] = threshold_mult * threshold_perc - # Candidate selection purely in 1D + # Candidate selection purely in 1D on the locally averaged image keep = vals >= (thresholds[bin_ids] + backgrounds[bin_ids]) - # Scatter to 2D only once (needed for clustering / argmax) peak_mask = np.zeros(frame.shape, dtype=bool) peak_mask.flat[valid_idx[keep]] = True + # Pick peak positions using the *raw* frame intensities (not the averaged score) peaks = cluster_peak_mask(peak_mask, frame, min_dist=min_peak_sep) n_peaks = int(peaks.shape[0]) @@ -106,7 +112,7 @@ def ring_percentile_detection( ) -def estimate_beam_center(frame: np.ndarray, sigma: float = 3.0) -> tuple[int, int]: +def estimate_beam_center(frame: np.ndarray, sigma: float = 10.0) -> tuple[int, int]: """Estimate beam center by Gaussian-blurring a small ROI and taking max.""" h, w = frame.shape cy0, cx0 = np.unravel_index(np.argmax(frame), frame.shape) @@ -179,7 +185,7 @@ def plot_diffraction_debug( ax.set_title('Diffraction detection debug') ax.axis('off') - img_log = np.log10(frame.astype(np.float32) + 1.0) + img_log = np.log10(np.maximum(frame.astype(np.float32), 0) + 1.0) ax.imshow(img_log, cmap='gray') if (mask := results.mask) is not None: # False == excluded areas = red tint @@ -204,12 +210,16 @@ def plot_diffraction_debug( if __name__ == '__main__': + from glob import glob + from PIL import Image mask = make_cross_mask() - for i in range(0, 50): - path = rf'C:\Users\tchon\x\Instamatic_RATS_cRED_benchmark\instamatic_19\tiff\00{i:03d}.tiff' + # paths = glob(r"C:\Users\tchon\x\2026-02-06-SPED_test\experiment_5\tiff\w000000_s000031_0000*.tiff") + paths = glob(r'C:\Users\tchon\x\Instamatic_RATS_cRED_benchmark\instamatic_19\tiff\0000*') + for path in paths: tiff = Image.open(path) image = np.array(tiff) results = ring_percentile_detection(image, mask=mask) + print(Path(path).stem, results.success, len(results.peaks)) plot_diffraction_debug(image, results) From 50abfd8c6302bcaf3347013c66cd644f1d19894e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Sat, 7 Feb 2026 16:58:53 +0100 Subject: [PATCH 056/118] Avoid soft bleed from under mask and favouring edge pixel due to gaussian blur mode nearest -> mirror --- .../experiments/scan_ed/detection.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/instamatic/experiments/scan_ed/detection.py b/src/instamatic/experiments/scan_ed/detection.py index a492489f..79a8e5e4 100644 --- a/src/instamatic/experiments/scan_ed/detection.py +++ b/src/instamatic/experiments/scan_ed/detection.py @@ -17,8 +17,7 @@ def make_cross_mask(): yy, xx = np.indices((512, 512)) vertical = np.abs(xx - c) <= 1.9 horizontal = np.abs(yy - c) <= 1.9 - zero_zero = (np.abs(xx) < 2) & (np.abs(yy) < 2) - cross = vertical | horizontal | zero_zero + cross = vertical | horizontal return ~cross @@ -56,8 +55,15 @@ def ring_percentile_detection( # Build a locally-averaged "score" image for candidate selection. score = frame.astype(np.float32, copy=False) + if mask is not None: + m = mask.astype(np.float32, copy=False) + numer = ndi.gaussian_filter(score * m, sigma=gaussian_sigma, mode='mirror') + denom = ndi.gaussian_filter(m, sigma=gaussian_sigma, mode='mirror') + score = np.divide(numer, denom, out=np.zeros_like(numer), where=denom > 0) + else: + score = ndi.gaussian_filter(score, sigma=gaussian_sigma, mode='mirror') if gaussian_sigma and gaussian_sigma > 0: - score = ndi.gaussian_filter(score, sigma=float(gaussian_sigma), mode='nearest') + score = ndi.gaussian_filter(score, sigma=float(gaussian_sigma), mode='mirror') cy, cx = estimate_beam_center(frame, sigma=3.0) ys, xs = np.indices(frame.shape) @@ -215,8 +221,10 @@ def plot_diffraction_debug( from PIL import Image mask = make_cross_mask() - # paths = glob(r"C:\Users\tchon\x\2026-02-06-SPED_test\experiment_5\tiff\w000000_s000031_0000*.tiff") - paths = glob(r'C:\Users\tchon\x\Instamatic_RATS_cRED_benchmark\instamatic_19\tiff\0000*') + paths = glob( + r'C:\Users\tchon\x\2026-02-06-SPED_test\experiment_5\tiff\w000000_s000031_0000*.tiff' + ) + # paths = glob(r'C:\Users\tchon\x\Instamatic_RATS_cRED_benchmark\instamatic_19\tiff\0000*') for path in paths: tiff = Image.open(path) image = np.array(tiff) From abf4dfb4547af248a819389fab9f0a8061be94f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Mon, 9 Feb 2026 15:16:16 +0100 Subject: [PATCH 057/118] Replace repr with shorter str when representing Window in ProgressTable --- .../experiments/scan_ed/progress.py | 3 +- src/instamatic/grid/window.py | 35 +++++++++---------- 2 files changed, 17 insertions(+), 21 deletions(-) diff --git a/src/instamatic/experiments/scan_ed/progress.py b/src/instamatic/experiments/scan_ed/progress.py index 9e04ed67..ac4d0261 100644 --- a/src/instamatic/experiments/scan_ed/progress.py +++ b/src/instamatic/experiments/scan_ed/progress.py @@ -70,8 +70,7 @@ def add_window(self, idx: int, window: GridWindowProtocol) -> None: """Add a new parent line to the tree called Window #.""" window_iid = self._window_iid(idx) window_name = f'Window {idx:d}' - geom = repr(window) - values = (geom, '-', '-', '-', '-', '-') + values = (str(window), '-', '-', '-', '-', '-') self.tree.insert('', tk.END, iid=window_iid, text=window_name, values=values) self._window_totals[idx] = Counter() diff --git a/src/instamatic/grid/window.py b/src/instamatic/grid/window.py index 2db25bfa..1fffd053 100644 --- a/src/instamatic/grid/window.py +++ b/src/instamatic/grid/window.py @@ -75,8 +75,17 @@ class GridablePolygonWindow(ConvexPolygonWindow): a: np.ndarray = ... # from center towards the side, aligned in ~X direction b: np.ndarray = ... # from center towards the side, not aligned with ~X - @abstractmethod - def __repr__(self) -> str: ... + def __repr__(self) -> str: + """Accurate representation, show params as floats (from to_params).""" + p = self.to_params() + parts = [f'{k}={float(v)}' for k, v in p.items()] + return f'{type(self).__name__}(' + ', '.join(parts) + ')' + + def __str__(self) -> str: + """Nicely display self, show params as integers (from to_params).""" + p = self.to_params() + parts = [f'{k}={int(np.rint(float(v)))}' for k, v in p.items()] + return f'{type(self).__name__}(' + ', '.join(parts) + ')' @classmethod def from_sweeping(cls, order: Literal[1, 2, 3, 4, 5] = 3) -> Self: @@ -154,10 +163,6 @@ def __init__(self, x: float, y: float, w: float, t: float): corners = r_circum * np.stack([np.cos(angles), np.sin(angles)], axis=1) self.corners = c + corners - def __repr__(self) -> str: - args = [self.center_x, self.center_y, self.width, self.theta] - return self.__class__.__name__ + '(x={}, y={}, w={}, t={})'.format(*args) - @classmethod def from_edge_xys(cls, edge_xys: np.ndarray) -> Self: """Return new by fitting a regular hexagon to a Nx2 list of edge @@ -194,7 +199,7 @@ def edge_d2_sum(geom: tuple[float, float, float, float], xys: np.ndarray) -> flo """Objective: squared distance of points to nearest hexagon side (regular).""" center_x, center_y, width, theta = geom if width <= 0: - return float("inf") + return float('inf') center = np.array([center_x, center_y], dtype=float) deltas = np.asarray(xys, dtype=float) - center @@ -209,7 +214,7 @@ def edge_d2_sum(geom: tuple[float, float, float, float], xys: np.ndarray) -> flo # edge distance: nearest line in absolute value d = np.min(np.abs(signed), axis=1) # (N,) - return float(np.sum(d ** 2)) + return float(np.sum(d**2)) def to_params(self) -> dict[str, float]: return {'x': self.center_x, 'y': self.center_y, 'w': self.width, 't': self.theta} @@ -256,10 +261,6 @@ def __init__(self, x: float, y: float, w: float, h: float, t: float): self.b = b = 0.5 * h * np.array([-np.sin(t), np.cos(t)], dtype=float) self.corners = np.vstack([c + a + b, c + a - b, c - a - b, c - a + b]) - def __repr__(self) -> str: - args = [self.center_x, self.center_y, self.width, self.height, self.theta] - return self.__class__.__name__ + '(x={}, y={}, w={}, h={}, t={})'.format(*args) - @classmethod def from_edge_xys(cls, edge_xys: np.ndarray) -> Self: """Return new by fitting the edge to a Nx2 list of edge positions.""" @@ -282,7 +283,7 @@ def edge_d2_sum(geom: tuple[float, float, float, float, float], xys: np.ndarray) """scipy.optimize.minimize fitting func; for geometry see cls docs.""" center_x, center_y, width, height, theta = geom if width <= 0 or height <= 0: - return float("inf") + return float('inf') center = np.array([center_x, center_y], dtype=float) deltas = np.asarray(xys, dtype=float) - center @@ -298,7 +299,7 @@ def edge_d2_sum(geom: tuple[float, float, float, float, float], xys: np.ndarray) du = np.abs(np.abs(u) - 0.5 * width) dv = np.abs(np.abs(v) - 0.5 * height) d = np.minimum(du, dv) - return float(np.sum(d ** 2)) + return float(np.sum(d**2)) def to_params(self) -> dict[str, float]: return { @@ -345,10 +346,6 @@ def __init__(self, x: float, y: float, w: float, t: float): self.b = b = 0.5 * w * np.array([-np.sin(t), np.cos(t)], dtype=float) self.corners = np.vstack([c + a + b, c + a - b, c - a - b, c - a + b]) - def __repr__(self) -> str: - args = [self.center_x, self.center_y, self.width, self.theta] - return self.__class__.__name__ + '(x={}, y={}, w={}, t={})'.format(*args) - @classmethod def from_edge_xys(cls, edge_xys: np.ndarray) -> Self: """Return new by fitting the edge to a Nx2 list of edge positions.""" @@ -382,7 +379,7 @@ def edge_d2_sum(geom: tuple[float, float, float, float], xys: np.ndarray) -> flo du = np.abs(np.abs(u) - 0.5 * width) dv = np.abs(np.abs(v) - 0.5 * width) d = np.minimum(du, dv) - return float(np.sum(d ** 2)) + return float(np.sum(d**2)) def to_params(self) -> dict[str, float]: return {'x': self.center_x, 'y': self.center_y, 'w': self.width, 't': self.theta} From c25dc9891e4361d4d6f02216464d1fe3c8bc7541 Mon Sep 17 00:00:00 2001 From: Daniel Tchon Date: Mon, 9 Feb 2026 18:20:27 +0100 Subject: [PATCH 058/118] Gently unlock GUI if journal does not exist --- src/instamatic/gui/scan_ed_frame.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/instamatic/gui/scan_ed_frame.py b/src/instamatic/gui/scan_ed_frame.py index 31d81a6a..d8ff6c61 100644 --- a/src/instamatic/gui/scan_ed_frame.py +++ b/src/instamatic/gui/scan_ed_frame.py @@ -215,7 +215,11 @@ def sced_interface_command(controller, **params: Any) -> None: if load: exp_dir = controller.module_io.get_experiment_directory() journal_path = Path(exp_dir) / 'journal.jsonl' - assert journal_path.is_file(), f'No journal file found at {journal_path}' + try: + if not journal_path.is_file(): + raise FileNotFoundError(f'No journal file found at {journal_path}') + finally: + callback() else: exp_dir = controller.module_io.get_new_experiment_directory() exp_dir.mkdir(exist_ok=True, parents=True) From 3e54b899663fd19bd4ff90c7b926606abb776db8 Mon Sep 17 00:00:00 2001 From: Daniel Tchon Date: Mon, 9 Feb 2026 18:21:21 +0100 Subject: [PATCH 059/118] When plotting, make figures square, dont force include 0 axes --- src/instamatic/grid/artist.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/instamatic/grid/artist.py b/src/instamatic/grid/artist.py index 90581f2e..16e658c8 100644 --- a/src/instamatic/grid/artist.py +++ b/src/instamatic/grid/artist.py @@ -50,7 +50,18 @@ def plot( xys = np.asarray(window._edge_xys, dtype=float) ax.plot(xys[:, 0], xys[:, 1], 'bx', markersize=6, zorder=5) - ax.autoscale() + ax.relim() + ax.autoscale_view() + + x0, x1 = ax.get_xlim() + y0, y1 = ax.get_ylim() + cx, cy = 0.5 * (x0 + x1), 0.5 * (y0 + y1) + r = 0.5 * max(x1 - x0, y1 - y0) + ax.set_xlim(cx - r, cx + r) + ax.set_ylim(cy - r, cy + r) + + + ax.set_autoscale_on(False) # Freeze limits so lines don't affect view ax.axhline(0, color='white', linewidth=1.0, alpha=0.6, zorder=0) ax.axvline(0, color='white', linewidth=1.0, alpha=0.6, zorder=0) From bb4520ce164553af4d23d0d8900102aa4728d355 Mon Sep 17 00:00:00 2001 From: Daniel Tchon Date: Mon, 9 Feb 2026 18:43:10 +0100 Subject: [PATCH 060/118] Add grid plotting, fix adding grid manually soft-locked --- src/instamatic/experiments/scan_ed/experiment.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/instamatic/experiments/scan_ed/experiment.py b/src/instamatic/experiments/scan_ed/experiment.py index 9d6df11f..9cac4458 100644 --- a/src/instamatic/experiments/scan_ed/experiment.py +++ b/src/instamatic/experiments/scan_ed/experiment.py @@ -129,6 +129,7 @@ def start_collection(self, **params) -> None: self.order_and_add_manual_windows(windows) for window_idx, window in self.state.grid.windows.items(): self.draw_window_to_file(window_idx=window_idx, window=window) + self.draw_grid_to_file() while not params['stop_event'].is_set(): try: @@ -152,6 +153,7 @@ def start_collection(self, **params) -> None: break self.state.add_window(idx=window_idx, window=window) self.draw_window_to_file(window_idx=window_idx, window=window) + self.draw_grid_to_file() self.teardown() @@ -218,7 +220,7 @@ def determine_manual_windows(self) -> list[GridablePolygonWindow]: edge_xys = np.asarray(edge_xys, dtype=float) window = self.state.grid.window_type.from_edge_xys(edge_xys=edge_xys) fig, ax = plot({**windows, window_idx: window}, debug_edges=True) - with self.videostream_frame.processor.temporary(figure=fig): + with self.videostream_frame.processor.temporary(figure=fig), cl: print('LMB to accept and finish, RMB to retry, MMB to accept and add new') c = cl.get_click() if c.button == MouseButton.LEFT: @@ -268,6 +270,13 @@ def draw_window_to_file(self, window_idx: int, window: GridablePolygonWindow) -> fig, ax = plot({window_idx: window}, debug_edges=True) fig.savefig(file_path) + def draw_grid_to_file(self): + """Use grid.artist.plot to draw grid into its own file for debug.""" + file_path = self.path / 'windows' / f'windows_all.png' + file_path.parent.mkdir(exist_ok=True, parents=True) + fig, ax = plot(self.state.grid.windows, debug_edges=False) + fig.savefig(file_path) + def set_stop_event_if_target_met(self) -> None: th: Optional[int] = self.params.get('target_hits', None) tt: Optional[int] = self.params.get('target_time', None) From 17c0cc33143e96c5b7f98a8eaffc5c98c489ea56 Mon Sep 17 00:00:00 2001 From: Daniel Tchon Date: Mon, 9 Feb 2026 18:43:43 +0100 Subject: [PATCH 061/118] Release stop button only if error happens, not always --- src/instamatic/gui/scan_ed_frame.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/instamatic/gui/scan_ed_frame.py b/src/instamatic/gui/scan_ed_frame.py index d8ff6c61..42a9ec66 100644 --- a/src/instamatic/gui/scan_ed_frame.py +++ b/src/instamatic/gui/scan_ed_frame.py @@ -218,7 +218,7 @@ def sced_interface_command(controller, **params: Any) -> None: try: if not journal_path.is_file(): raise FileNotFoundError(f'No journal file found at {journal_path}') - finally: + except FileNotFoundError: callback() else: exp_dir = controller.module_io.get_new_experiment_directory() From 98722f877ce50376bbd4d99c78c0e388c36783f7 Mon Sep 17 00:00:00 2001 From: Daniel Tchon Date: Mon, 9 Feb 2026 18:44:16 +0100 Subject: [PATCH 062/118] 0.1 sweeper threshold works fine with diffraction mode --- src/instamatic/grid/sweepers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/instamatic/grid/sweepers.py b/src/instamatic/grid/sweepers.py index 9d92f742..5b9518f2 100644 --- a/src/instamatic/grid/sweepers.py +++ b/src/instamatic/grid/sweepers.py @@ -64,7 +64,7 @@ class EdgeSweeperTeam(InstanceAutoNameRegistry): name: str = '' # identifier used for registration in INSTANCES step_size: int_nm = 10_000 # largest step size allowed precision: int_nm = 1 # smallest step size allowed - threshold: float = 0.05 # fraction of light_max that signals the edge + threshold: float = 0.1 # fraction of light_max that signals the edge light_max: int = -1 # maximum light observed at any point by any sweeper From 6fe21385f5c9a9ccbf4b33733991120116b0cf05 Mon Sep 17 00:00:00 2001 From: Daniel Tchon Date: Thu, 12 Feb 2026 15:18:58 +0100 Subject: [PATCH 063/118] TODO: ask Serval why the hell they swapped bytes 1 and 3 --- src/instamatic/camera/camera_serval.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/instamatic/camera/camera_serval.py b/src/instamatic/camera/camera_serval.py index 254b843d..92dbd0ee 100644 --- a/src/instamatic/camera/camera_serval.py +++ b/src/instamatic/camera/camera_serval.py @@ -262,6 +262,17 @@ def __next__(self) -> np.ndarray: self._recv_more() i, j = header_end, header_end + self.size frame = np.frombuffer(self.buffer[i:j], dtype=self.dtype).reshape(self.shape).copy() + + # Decode Serval-packed 32-bit jsonimage payload into the physical 24-bit count: + # observed packing (byte lanes): [b0, b1, b2, b3] with b1 unused/zero, + # count = b0 | (b3<<8) | (b2<<16). Contact Daniel Tchon, tchon@fzu.cz, for details. + if __debug__ and np.any((frame & np.uint32(0x0000FF00)) != 0): + raise ValueError('Unexpected nonzero byte1 in Serval packed uint32 payload.') + if frame.dtype == np.uint32: + bytes02 = frame & np.uint32(0x00F00FF) + bytes3 = frame & np.uint32(0xFF000000) + frame = bytes02 | (bytes3 >> np.uint32(16)) + self.buffer[: self.used - j] = self.buffer[j : self.used] self.used -= j self.i_frame += 1 From 2c68d3c0e62ce869898d11c3569b56a7a317bce9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Thu, 12 Feb 2026 19:34:09 +0100 Subject: [PATCH 064/118] Don't overwrite previous images with _edge_xys --- src/instamatic/experiments/scan_ed/experiment.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/instamatic/experiments/scan_ed/experiment.py b/src/instamatic/experiments/scan_ed/experiment.py index 9cac4458..fa1173b0 100644 --- a/src/instamatic/experiments/scan_ed/experiment.py +++ b/src/instamatic/experiments/scan_ed/experiment.py @@ -184,7 +184,7 @@ def add_scans(self, window_idx: int, params: dict[str, Any]) -> None: fast_min -= error_margin fast_max += error_margin direction = next(scan_dirs) - fast_start, fast_stop = [fast_min, fast_max][:: direction] + fast_start, fast_stop = [fast_min, fast_max][::direction] self.state.add_scan( window=int(window_idx), scan=int(scan_id), @@ -268,11 +268,12 @@ def draw_window_to_file(self, window_idx: int, window: GridablePolygonWindow) -> file_path = self.path / 'windows' / f'window_{window_idx:04d}.png' file_path.parent.mkdir(exist_ok=True, parents=True) fig, ax = plot({window_idx: window}, debug_edges=True) - fig.savefig(file_path) + if not file_path.exists(): # don't overwrite previous img with _edge_xys + fig.savefig(file_path) def draw_grid_to_file(self): """Use grid.artist.plot to draw grid into its own file for debug.""" - file_path = self.path / 'windows' / f'windows_all.png' + file_path = self.path / 'windows' / 'windows_all.png' file_path.parent.mkdir(exist_ok=True, parents=True) fig, ax = plot(self.state.grid.windows, debug_edges=False) fig.savefig(file_path) From 47267c4c61503b02a392ce5c2fc06dab71d912d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Tue, 17 Feb 2026 12:12:42 +0100 Subject: [PATCH 065/118] Fix typo in movie byte-shifting mechanism --- src/instamatic/camera/camera_serval.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/instamatic/camera/camera_serval.py b/src/instamatic/camera/camera_serval.py index 92dbd0ee..4b7345bb 100644 --- a/src/instamatic/camera/camera_serval.py +++ b/src/instamatic/camera/camera_serval.py @@ -269,7 +269,7 @@ def __next__(self) -> np.ndarray: if __debug__ and np.any((frame & np.uint32(0x0000FF00)) != 0): raise ValueError('Unexpected nonzero byte1 in Serval packed uint32 payload.') if frame.dtype == np.uint32: - bytes02 = frame & np.uint32(0x00F00FF) + bytes02 = frame & np.uint32(0x00FF00FF) bytes3 = frame & np.uint32(0xFF000000) frame = bytes02 | (bytes3 >> np.uint32(16)) From 3d1dc1a463d72e65243ab41c9074e84b0e5445c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Fri, 13 Mar 2026 18:57:32 +0100 Subject: [PATCH 066/118] WIP: large refactor of the grid logic to allow multi-window fitting --- src/instamatic/_collections.py | 25 -- .../experiments/scan_ed/experiment.py | 6 +- src/instamatic/experiments/scan_ed/state.py | 6 +- src/instamatic/grid/geometry.py | 258 ++++++++++++++++ src/instamatic/grid/grid.py | 156 ---------- src/instamatic/grid/pairing.py | 28 +- src/instamatic/grid/registry.py | 24 +- src/instamatic/grid/window.py | 280 +++++++----------- 8 files changed, 401 insertions(+), 382 deletions(-) create mode 100644 src/instamatic/grid/geometry.py delete mode 100644 src/instamatic/grid/grid.py diff --git a/src/instamatic/_collections.py b/src/instamatic/_collections.py index 81d97e0b..c4fd502a 100644 --- a/src/instamatic/_collections.py +++ b/src/instamatic/_collections.py @@ -57,28 +57,3 @@ def format_field(self, value: Any, format_spec: str) -> str: partial_formatter = PartialFormatter() - - -class VersionedDict(MutableMapping[T1, T2]): - """A dict whose version changes with every mutation; useful for caching.""" - - def __init__(self, d: dict = None) -> None: - self._d: dict[T1, T2] = d or {} - self.version = 0 - - def __getitem__(self, k: T1) -> T2: - return self._d[k] - - def __iter__(self) -> Iterator[T1]: - return iter(self._d) - - def __len__(self) -> int: - return len(self._d) - - def __setitem__(self, k, v) -> None: - self._d[k] = v - self.version += 1 - - def __delitem__(self, k) -> None: - del self._d[k] - self.version += 1 diff --git a/src/instamatic/experiments/scan_ed/experiment.py b/src/instamatic/experiments/scan_ed/experiment.py index fa1173b0..64a4995b 100644 --- a/src/instamatic/experiments/scan_ed/experiment.py +++ b/src/instamatic/experiments/scan_ed/experiment.py @@ -18,7 +18,7 @@ from instamatic.experiments.scan_ed.progress import ProgressTable from instamatic.experiments.scan_ed.state import State from instamatic.grid.artist import plot -from instamatic.grid.registry import GRID_REGISTRY, PeriodicConvexPolygonGrid +from instamatic.grid.registry import GRID_REGISTRY, PeriodicConvexPolygonGridGeometry from instamatic.grid.window import GridablePolygonWindow from instamatic.gui.click_dispatcher import ClickListener, MouseButton @@ -238,14 +238,14 @@ def order_and_add_manual_windows(self, windows: list[GridablePolygonWindow]) -> return self.state.add_window(idx=0, window=windows.pop(0)) for window in windows: - idx = self.state.grid.predict_index(window.center) + idx = self.state.grid.nearest_index(*window.center) self.state.add_window(idx=idx, window=window) def locate_next_window(self) -> tuple[int, GridablePolygonWindow]: """Find a next window on the grid, or raise if none can be found.""" if self.params.get('grid_finder') == 'All manually': raise IndexError('Experiment params disallow locating new windows') - grid: PeriodicConvexPolygonGrid[GridablePolygonWindow] = self.state.grid + grid: PeriodicConvexPolygonGridGeometry[GridablePolygonWindow] = self.state.grid if not self.state.grid.windows: return 0, grid.window_type.from_sweeping(order=4) max_index = 10 + 2 * (max(grid.windows) if grid.windows else 0) diff --git a/src/instamatic/experiments/scan_ed/state.py b/src/instamatic/experiments/scan_ed/state.py index 0f672669..02da716c 100644 --- a/src/instamatic/experiments/scan_ed/state.py +++ b/src/instamatic/experiments/scan_ed/state.py @@ -7,7 +7,7 @@ from instamatic.experiments.scan_ed.encoding import * from instamatic.experiments.scan_ed.journal import Journal, edits_journal from instamatic.experiments.scan_ed.progress import ProgressTable, edits_progress -from instamatic.grid.grid import PeriodicConvexPolygonGrid +from instamatic.grid.geometry import PeriodicConvexPolygonGridGeometry from instamatic.grid.window import GridablePolygonWindow WindowFactory: Callable[[float, float, float, ...], type[GridablePolygonWindow]] @@ -19,11 +19,11 @@ class State: def __init__( self, journal: Journal, - grid: PeriodicConvexPolygonGrid, + grid: PeriodicConvexPolygonGridGeometry, progress: Optional[ProgressTable] = None, ) -> None: self.journal: Journal = journal - self.grid: PeriodicConvexPolygonGrid = grid + self.grid: PeriodicConvexPolygonGridGeometry = grid self.progress: Optional[ProgressTable] = progress self.scans: pd.DataFrame = pd.DataFrame() diff --git a/src/instamatic/grid/geometry.py b/src/instamatic/grid/geometry.py new file mode 100644 index 00000000..d1ae152c --- /dev/null +++ b/src/instamatic/grid/geometry.py @@ -0,0 +1,258 @@ +from __future__ import annotations + +from typing import Annotated, Generic, Optional, Protocol, Self, Sequence, TypeVar, Union, cast + +import numpy as np +from scipy.optimize import least_squares + +from instamatic._typing import float_nm, int_nm +from instamatic.grid.window import GridablePolygonWindow + +DualIndex = tuple[int, int] +SpiralIndex = Annotated[int, 'positive'] +WindowIndex = Union[DualIndex, SpiralIndex] +WindowType = TypeVar('WindowType', bound=GridablePolygonWindow) + + +def versor( + *, + deg: Optional[Union[float, np.ndarray]] = None, + rad: Optional[Union[float, np.ndarray]] = None, +) -> np.ndarray: + """A versor in the direction of angle expressed in radians or degrees.""" + radians = np.deg2rad(deg) if rad is None else rad + return np.array([np.cos(radians), np.sin(radians)], dtype=float) + + +class PairingFunction(Protocol): + def __call__(self, i: int, j: int, /) -> int: ... + + +class PairingInverse(Protocol): + def __call__(self, n: int, /) -> tuple[int, int]: ... + + +WindowGeometryTuple = tuple[float_nm, float_nm, float, float_nm, Optional[float_nm]] +WindowShapeTuple = tuple[float, float_nm, Optional[float_nm]] + + +class PeriodicConvexPolygonGridGeometry(Generic[WindowType]): + """A ConvexPolygonGrid with identical windows and on a 2D ab-lattice. + + The conventional, most-expected lattice kind for ED experiments. + Every window is an identical convex polygon placed in the same + distance from other windows, as determined by the grid support + thickness. Utilizes internal coordinate system of its "central" + window 0, with two axes, "a" & "b", selected in such a way that the + angle between axes "a" and coordinate X is minimal, and the angle + from "a" to "b" is positive (clockwise) and minimal. The length of + "a" and "b" should match expected distance to next windows. + """ + + pairing_function: PairingFunction + pairing_inverse: PairingInverse + window_type: type[WindowType] + + DEFAULT_SPACING: float_nm = 10_000 + + def __init__( + self, + x: float_nm, # x coordinate of the grid origin in stage coordinates + y: float_nm, # y coordinate of the grid origin in stage coordinates + t: float, # signed angle from the X-axis towards a-vector in degrees + w: float_nm, # length of X-aligned axis: edge to edge center-points + h: Optional[float_nm] = None, # length of the other axis, if relevant + s: Optional[float_nm] = None, # spacing between neighbor grid windows + ): + self.x = x + self.y = y + self.t = t + self.w = w + self._h = h + self._s = s + + def normalized(self) -> Self: + """Align w with X axis by casting theta to [+,- interior angle / 2]""" + a = float(self.window_type.INTERIOR_ANGLE) + n = int(np.floor((self.t + 0.5 * a) / a)) # rotates needed to min theta + t = float(self.t - n * a) + w = abs(float(self.w)) + h = None if self._h is None else abs(float(self._h)) + if self._h is not None and (n % 2): + w, h = h, w + return self.__class__(self.x, self.y, t, w, h, self._s) + + @property + def origin(self) -> np.ndarray: + """Origin of the grid i.e. its window 0 in stage coordinates (nm).""" + return np.array([self.x, self.y], dtype=float) + + @property + def h(self): + """Value of "h" if it is applicable or "w" in square/hex cases.""" + return self._h if self.window_type.USES_HEIGHT else self.w + + @h.setter + def h(self, value: float_nm) -> None: + self._h = value if self.window_type.USES_HEIGHT else None + + @property + def s(self): + """Uniform spacing between two neighbor windows i.e. grid thickness.""" + return self.DEFAULT_SPACING if self._s is None else self._s + + @s.setter + def s(self, value: float_nm) -> None: + self._s = value + + @property + def a_dir(self) -> np.ndarray: + """A versor oriented along grid space axis "a" in stage coords.""" + t = float(np.radians(self.t)) + return np.array([np.cos(t), np.sin(t)], dtype=float) + + @property + def a_edge(self) -> np.ndarray: + """Half-window vector from center to edge midpoint along axis "a".""" + return (self.w / 2) * self.a_dir + + @property + def a_grid(self) -> np.ndarray: + """Center-to-center lattice vector to neighboring window along "a".""" + return (self.w + self.s) * self.a_dir + + @property + def b_dir(self) -> np.ndarray: + """A versor oriented along grid space axis "b" in stage coords.""" + t = float(np.radians(self.t + self.window_type.INTERIOR_ANGLE)) + return np.array([np.cos(t), np.sin(t)], dtype=float) + + @property + def b_edge(self) -> np.ndarray: + """Half-window vector from center to edge midpoint along axis "b".""" + return (self.h / 2) * self.b_dir + + @property + def b_grid(self) -> np.ndarray: + """Center-to-center lattice vector to neighboring window along "b".""" + return (self.h + self.s) * self.b_dir + + def window_geometry(self, idx: WindowIndex) -> WindowGeometryTuple: + """Return the current geom: origin + shape params of window "idx".""" + ij: DualIndex = self.pairing_inverse(idx) if isinstance(idx, int) else idx + x, y = self.origin + ij[0] * self.a_grid + ij[1] * self.b_grid + return x, y, self.t, self.w, self.h + + def window(self, idx: WindowIndex) -> WindowType: + """Convenience method that makes a window located at requested idx.""" + return self.window_type(*self.window_geometry(idx)) + + def nearest_index(self, x: float_nm, y: float_nm) -> int: + """Return spiral index of predicted window nearest to the center.""" + delta = np.asarray([x, y], dtype=float) - self.origin + metric = np.column_stack([self.a_grid, self.b_grid]) + ij, *_ = np.linalg.lstsq(metric, delta, rcond=None) + i, j = (int(np.rint(v)) for v in ij) + return int(self.pairing_function(i, j)) + + @classmethod + def guess(cls, intercepts: dict[int, np.ndarray]) -> Self: + """Guess the geometry of window 0 given points on its edges.""" + if 0 not in intercepts: + raise ValueError('No intercepts for window 0 provided') + + xys0 = np.asarray(intercepts[0], dtype=float) + c0 = np.mean(xys0, axis=0) + deltas = xys0 - c0 + half_span = 0.5 * cls.window_type.INTERIOR_ANGLE + thetas = np.linspace(-half_span, +half_span, 91) + + best_guess = None + best_score = np.inf + + for t in thetas: + a_dir = versor(deg=t) + b_dir = versor(deg=t + cls.window_type.INTERIOR_ANGLE) + qa = deltas @ a_dir + qb = deltas @ b_dir + + if cls.window_type.USES_HEIGHT: + w = 2.0 * np.quantile(np.abs(qa), 0.9) + h = 2.0 * np.quantile(np.abs(qb), 0.9) + else: + w = 2.0 * np.quantile(np.hstack([np.abs(qa), np.abs(qb)]), 0.9) + h = None + + g = cls(x=c0[0], y=c0[1], t=t, w=w, h=h, s=None) + score = float(np.sum(g.window(0).edge_residuals(xys0) ** 2)) + if score < best_score: + best_guess = g + best_score = score + + assert best_guess is not None + return best_guess + + def refine(self, intercepts: dict[int, np.ndarray]) -> None: + """Refine self to match the window_id: intercepts dictionary.""" + + windows = sorted(intercepts.keys()) + refine_spacing = len(windows) > 1 + fit_h = self.window_type.USES_HEIGHT + fixed_s = self._s # preserve "unknown spacing" when not refined + + def serialize(g: PeriodicConvexPolygonGridGeometry) -> np.ndarray: + """Express the geometry instance as a series of refined vars.""" + vals = [float(g.x), float(g.y), float(g.t), float(g.w)] + if fit_h: + vals.append(float(g.h)) + if refine_spacing: + vals.append(float(g.s)) + return np.asarray(vals, dtype=float) + + def deserialize(p: np.ndarray) -> PeriodicConvexPolygonGridGeometry: + """Convert a series of refined vars into a periodic geometry.""" + vals = iter(p) + x = float(next(vals)) + y = float(next(vals)) + t = float(next(vals)) + w = float(next(vals)) + h = float(next(vals)) if fit_h else None + s = float(next(vals)) if refine_spacing else fixed_s + return self.__class__(x=x, y=y, t=t, w=w, h=h, s=s) + + def residuals(p: np.ndarray) -> np.ndarray: + """Calculate residual for each window in refined geometry.""" + geom = deserialize(p) + res: list[np.ndarray] = [] + for idx, xys in intercepts.items(): + tmp_window = geom.window(idx) + res.append(tmp_window.edge_residuals(xys)) + return np.concatenate(res) if res else np.empty(0, dtype=float) + + lower = [-np.inf, -np.inf, -np.inf, 1e-9] + upper = [np.inf, np.inf, np.inf, np.inf] + if fit_h: + lower.append(1e-9) + upper.append(np.inf) + if refine_spacing: + lower.append(0.0) + upper.append(np.inf) + + res = least_squares( + residuals, + x0=serialize(self), + bounds=(np.asarray(lower, dtype=float), np.asarray(upper, dtype=float)), + method='trf', + loss='soft_l1', + ) + + geometry = deserialize(res.x) + if not refine_spacing: + geometry._s = fixed_s # keep spacing unknown/fixed in the 1-window case + + self.x = geometry.x + self.y = geometry.y + self.t = geometry.t + self.w = geometry.w + self.h = geometry.h + self.s = geometry._s diff --git a/src/instamatic/grid/grid.py b/src/instamatic/grid/grid.py deleted file mode 100644 index dc40d95c..00000000 --- a/src/instamatic/grid/grid.py +++ /dev/null @@ -1,156 +0,0 @@ -from __future__ import annotations - -from typing import Annotated, Generic, Protocol, Sequence, TypeVar, Union, cast - -import numpy as np - -from instamatic._collections import VersionedDict -from instamatic._typing import float_nm, int_nm -from instamatic.grid.window import GridablePolygonWindow - -DualIndex = tuple[int, int] -SpiralIndex = Annotated[int, 'positive'] -WindowIndex = Union[DualIndex, SpiralIndex] -WindowType = TypeVar('WindowType', bound=GridablePolygonWindow) - - -class PairingFunction(Protocol): - def __call__(self, i: int, j: int, /) -> int: ... - - -class PairingInverse(Protocol): - def __call__(self, n: int, /) -> tuple[int, int]: ... - - -class Grid(Generic[WindowType]): - """Abstract base class for any TEM grid. - - Container for grid windows. Lists and documents class methods and - attributes that must be implemented or work when inherited by every - grid. - """ - - window_type: type[WindowType] - - def __init__(self, windows: dict[int, WindowType] = None) -> None: - self.windows: VersionedDict[int, WindowType] = VersionedDict(windows or {}) - - -class ConvexPolygonGrid(Grid[WindowType]): - """A grid where all windows are convex polygons.""" - - -class PeriodicConvexPolygonGrid(ConvexPolygonGrid[WindowType]): - """A ConvexPolygonGrid with identical windows and on a 2D ab-lattice. - - The conventional, most-expected lattice kind for ED experiments. - Every window is an identical convex polygon placed in the same - distance from other windows, as determined by the grid support - thickness. Utilizes internal coordinate system of its "central" - window 0, with two axes, "a" & "b", selected in such a way that the - angle between axes "a" and coordinate X is minimal, and the angle - from "a" to "b" is positive (clockwise) and minimal. The length of - "a" and "b" should match expected distance to next windows. - """ - - pairing_function: PairingFunction - pairing_inverse: PairingInverse - - def __init__(self, windows: dict[int, WindowType] = None, spacing: int = 10_000) -> None: - super().__init__(windows) - self.default_spacing: int_nm = spacing - self._spacing_cache_version = 0 - self._spacing = spacing - - @property - def a(self) -> np.ndarray: - """Grid coordinate vector aligned with X pointing to next window.""" - w0 = self.windows[0] - return w0.a * (2.0 + float(self.spacing) / np.linalg.norm(w0.a)) - - @property - def b(self) -> np.ndarray: - """Second grid coordinate vector (not ~X) pointing to next window.""" - w0 = self.windows[0] - return w0.b * (2.0 + float(self.spacing) / np.linalg.norm(w0.b)) - - @property - def spacing(self) -> float_nm: - """Cached property of self.windows: stores spacing between windows.""" - if self._spacing_cache_version < self.windows.version: - self._spacing = self._estimate_spacing() - self._spacing_cache_version = self.windows.version - return self._spacing - - def _estimate_spacing(self) -> float_nm: - """Estimate actual spacing found between all defined grid windows.""" - if 0 not in self.windows or len(self.windows) < 2: - return float(self.default_spacing) - - w0 = self.windows[0] - a_axis = np.asarray(w0.a, dtype=float) - b_axis = np.asarray(w0.b, dtype=float) - a_hat = a_axis / np.linalg.norm(a_axis) - b_hat = b_axis / np.linalg.norm(b_axis) - - ijs = self.windows_ij.astype(float) # (N,2) - centers = self.windows_xy.astype(float) # (N,2) - deltas = centers - np.asarray(w0.center, dtype=float) - - mask = ~((ijs[:, 0] == 0) & (ijs[:, 1] == 0)) - ijs = ijs[mask] - deltas = deltas[mask] - - # Solve deltas ≈ [i j] @ [a_step; b_step] - # i.e. two independent least squares, one per coordinate component. - m, *_ = np.linalg.lstsq(ijs, deltas, rcond=None) - step_a, step_b = m[0], m[1] - - # Only use estimates along a/b axis if i/j coordinate changes - spacing_candidates: list[float] = [] - if np.any(ijs[:, 0] != 0): - if np.isfinite(s_w := float(np.dot(step_a - 2.0 * a_axis, a_hat))): - spacing_candidates.append(s_w) - if np.any(ijs[:, 1] != 0): - if np.isfinite(s_h := float(np.dot(step_b - 2.0 * b_axis, b_hat))): - spacing_candidates.append(s_h) - - if not spacing_candidates: - return float(self.default_spacing) - return float(max(0.0, float(np.mean(spacing_candidates)))) - - @property - def windows_ij(self) -> np.ndarray: - """A Nx2 array of all existing window dual indices in windows order.""" - ulam_indices = list(self.windows.keys()) - return np.array([self.pairing_inverse(u) for u in ulam_indices], dtype=int) - - @property - def windows_xy(self) -> np.ndarray: - """A Nx2 array of all existing window centers in windows order.""" - return np.array([w.center for w in self.windows.values()], dtype=float) - - def nearest_window(self, idx: WindowIndex) -> SpiralIndex: - """Return spiral index of existing window nearest to the one w/ idx.""" - predicted_center = self.predict_center(idx) - offsets2 = np.sum((self.windows_xy - predicted_center) ** 2, axis=1) - nearest = int(np.argmin(offsets2)) - return list(self.windows.keys())[nearest] - - def predict_center(self, idx: WindowIndex) -> np.ndarray: - """Predict center position of window idx given the rest of the grid.""" - ij: DualIndex = self.pairing_inverse(idx) if isinstance(idx, int) else idx - return self.windows[0].center + self.a * ij[0] + self.b * ij[1] - - def predict_index(self, center: Sequence[float]) -> int: - """Return spiral index of predicted window nearest to the center.""" - delta = np.asarray(center, dtype=float) - self.windows[0].center - metric = np.column_stack([self.a, self.b]) - ij, *_ = np.linalg.lstsq(metric, delta, rcond=None) - i, j = (int(np.rint(v)) for v in ij) - return int(self.pairing_function(i, j)) - - def predict_window(self, idx: WindowIndex) -> WindowType: - """Predict the window of index idx given the rest of the grid.""" - w0_delta = self.predict_center(idx) - self.windows[0].center - return cast(WindowType, self.windows[0].translated(w0_delta)) diff --git a/src/instamatic/grid/pairing.py b/src/instamatic/grid/pairing.py index 02ea231c..7b7e3171 100644 --- a/src/instamatic/grid/pairing.py +++ b/src/instamatic/grid/pairing.py @@ -107,36 +107,27 @@ def spiral2uv(n: int) -> tuple[int, int]: if t < k: u = t + 1 v = u - k - return (u, v) - # Segment 2: up the right-up edge (u=k), v = 1..k - if t < 2 * k: + elif t < 2 * k: u = k v = (t - k) + 1 - return (u, v) - # Segment 3: along the top edge (v=k), u = k-1 .. 0 - if t < 3 * k: + elif t < 3 * k: v = k u = (3 * k - 1) - t - return (u, v) - # Segment 4: down the upper-left edge (v-u=k), u = -1 .. -k - if t < 4 * k: + elif t < 4 * k: u = (3 * k) - t - 1 v = u + k - return (u, v) - # Segment 5: down the left edge (u=-k), v = -1 .. -k - if t < 5 * k: + elif t < 5 * k: u = -k v = (4 * k) - t - 1 - return (u, v) - # Segment 6: along the bottom edge (v=-k), u = -k+1 .. 0 - v = -k - u = t - 6 * k + 1 - return (u, v) + else: + v = -k + u = t - 6 * k + 1 + return u - v, v # conversion from previously used 120-deg system to 60-deg def uv2spiral(u: int, v: int) -> int: @@ -145,6 +136,7 @@ def uv2spiral(u: int, v: int) -> int: if u == 0 and v == 0: return 0 + u = u + v # conversion from previously used 120-deg system to 60-deg k = max(abs(u), abs(v), abs(u - v)) # Hex "radius" in (u,v) system s0 = 1 + 3 * (k - 1) * k # first index on ring k # point with lowest s0 lies right above bottom right corner of the hexagon @@ -208,7 +200,7 @@ def uv2spiral(u: int, v: int) -> int: # pretty-print spiral indices print('Spiral index grid:') for i, row in enumerate(spiral_grid): - print(' ' * i + ' '.join(f'{n:3d}' for n in row)) + print(' ' * (8 - i) + ' '.join(f'{n:3d}' for n in row)) for i in range(100): assert ij2ulam(*ulam2ij(i)) == i diff --git a/src/instamatic/grid/registry.py b/src/instamatic/grid/registry.py index 7d2d49d5..bf768210 100644 --- a/src/instamatic/grid/registry.py +++ b/src/instamatic/grid/registry.py @@ -1,33 +1,33 @@ from __future__ import annotations from instamatic._collections import NoOverwriteDict -from instamatic.grid.grid import PeriodicConvexPolygonGrid, WindowType +from instamatic.grid.geometry import PeriodicConvexPolygonGridGeometry, WindowType from instamatic.grid.pairing import ij2ulam, spiral2uv, ulam2ij, uv2spiral from instamatic.grid.window import HexagonalWindow, RectangularWindow, SquareWindow -class HexagonalGrid(PeriodicConvexPolygonGrid[HexagonalWindow]): +class HexagonalGridGeometry(PeriodicConvexPolygonGridGeometry[HexagonalWindow]): window_type = HexagonalWindow pairing_function = staticmethod(uv2spiral) pairing_inverse = staticmethod(spiral2uv) -class RectangularGrid(PeriodicConvexPolygonGrid[RectangularWindow]): +class RectangularGridGeometry(PeriodicConvexPolygonGridGeometry[RectangularWindow]): window_type = RectangularWindow pairing_function = staticmethod(ij2ulam) pairing_inverse = staticmethod(ulam2ij) -class SquareGrid(PeriodicConvexPolygonGrid[SquareWindow]): +class SquareGridGeometry(PeriodicConvexPolygonGridGeometry[SquareWindow]): window_type = SquareWindow pairing_function = staticmethod(ij2ulam) pairing_inverse = staticmethod(ulam2ij) -GRID_REGISTRY = NoOverwriteDict[str, type[PeriodicConvexPolygonGrid]]() -GRID_REGISTRY['hexagonal'] = HexagonalGrid -GRID_REGISTRY['rectangular'] = RectangularGrid -GRID_REGISTRY['square'] = SquareGrid +GRID_REGISTRY = NoOverwriteDict[str, type[PeriodicConvexPolygonGridGeometry]]() +GRID_REGISTRY['hexagonal'] = HexagonalGridGeometry +GRID_REGISTRY['rectangular'] = RectangularGridGeometry +GRID_REGISTRY['square'] = SquareGridGeometry # development test code; to be moved to artist/tests @@ -38,19 +38,19 @@ class SquareGrid(PeriodicConvexPolygonGrid[SquareWindow]): from instamatic.grid.artist import plot - g1 = HexagonalGrid() + g1 = HexagonalGridGeometry() w1 = HexagonalWindow(0, 0, 50_000, np.deg2rad(10)) g1.windows[0] = w1 - g2 = RectangularGrid() + g2 = RectangularGridGeometry() w2 = RectangularWindow(0, 0, 40_000, 60_000, np.deg2rad(10)) g2.windows[0] = w2 - g3 = RectangularGrid() + g3 = RectangularGridGeometry() w3 = RectangularWindow(0, 0, 20_000, 200_000, np.deg2rad(10)) g3.windows[0] = w3 - g4 = SquareGrid() + g4 = SquareGridGeometry() w4 = SquareWindow(0, 0, 50_000, np.deg2rad(10)) g4.windows[0] = w4 diff --git a/src/instamatic/grid/window.py b/src/instamatic/grid/window.py index 1fffd053..b77a8d59 100644 --- a/src/instamatic/grid/window.py +++ b/src/instamatic/grid/window.py @@ -2,7 +2,7 @@ from abc import ABC, abstractmethod from itertools import chain -from typing import Literal, Optional +from typing import Literal, Optional, Union import numpy as np from scipy.optimize import minimize @@ -21,6 +21,19 @@ Y = np.array([0, 1], dtype=float) +WindowGeometryTuple = tuple[float_nm, float_nm, float, float_nm, Optional[float_nm]] + + +def versor( + *, + deg: Optional[Union[float, np.ndarray]] = None, + rad: Optional[Union[float, np.ndarray]] = None, +) -> np.ndarray: + """A versor in the direction of angle expressed in radians or degrees.""" + radians = np.deg2rad(deg) if rad is None else rad + return np.array([np.cos(radians), np.sin(radians)], dtype=float) + + class Window(ABC): """Describes an arbitrary single window on a TEM grid.""" @@ -70,11 +83,40 @@ class GridablePolygonWindow(ConvexPolygonWindow): angle between axes "a" and X is minimal and the angle from "a" to "b" is positive and minimal. The length of "a" and "b" should match the distance between window center and its edge. + + Any subclass of GridablePolygonWindow should initialize using at least + four following parameters in this order, and other as needed: + + - x: x coordinate of the window center in the stage XY coordinate system; + - y: y coordinate of the window center in the stage XY coordinate system; + - t: smallest signed angle from stage +X axis towards "a" axis in degrees; + - w: double the distance between window center and its' edge midpoint; """ - a: np.ndarray = ... # from center towards the side, aligned in ~X direction + INTERIOR_ANGLE: float = ... # class attribute: angle between a and b axes + USES_HEIGHT: bool = ... # True if a secondary metric i.e. height is needed + a: np.ndarray = ... b: np.ndarray = ... # from center towards the side, not aligned with ~X + def __init__( + self, + x: float_nm, + y: float_nm, + t: float, + w: float_nm, + h: Optional[float_nm] = None, + ) -> None: + """A uniform abstract constructor for all subclasses (nm/degrees).""" + self.x: float_nm = float(x) + self.y: float_nm = float(y) + self.t: float = float(t) + self.w: float_nm = float(w) + self.h: float_nm = self.w if h is None else float(h) + + self.a: np.ndarray = ... # vector aligned with ~X direction + self.b: np.ndarray = ... # "a" rotated by INTERIOR_ANGLE anti-clockwise + self.corners: np.ndarray = ... # ordered anti-clockwise, start from "a" + def __repr__(self) -> str: """Accurate representation, show params as floats (from to_params).""" p = self.to_params() @@ -87,6 +129,38 @@ def __str__(self) -> str: parts = [f'{k}={int(np.rint(float(v)))}' for k, v in p.items()] return f'{type(self).__name__}(' + ', '.join(parts) + ')' + @property + def center(self) -> np.ndarray: + return np.array([self.x, self.y], dtype=float) + + def edge_residuals(self, xys: np.ndarray) -> np.ndarray: + """Return residual distance to the nearest edge per point.""" + xys = np.asarray(xys, dtype=float) + if len(xys) == 0: + return np.empty(0, dtype=float) + + p1 = np.asarray(self.corners, dtype=float) # (M, 2) + edge_vecs = np.roll(p1, -1, axis=0) - p1 # (M, 2) + edge_l2 = np.sum(edge_vecs * edge_vecs, axis=1) # (M,) + + if np.any(edge_l2 == 0): + raise ValueError('Degenerate polygon edge: consecutive corners coincide') + + # Vector from each segment start to each point + rel = xys[:, None, :] - p1[None, :, :] # (N, M, 2) + + # Projection parameter onto each edge, then clamp to the finite segment + t = np.sum(rel * edge_vecs[None, :, :], axis=2) / edge_l2[None, :] # (N, M) + t = np.clip(t, 0.0, 1.0) + + # Closest point on each segment + closest = p1[None, :, :] + t[:, :, None] * edge_vecs[None, :, :] # (N, M, 2) + + # Distance from each point to each segment + dists = np.linalg.norm(xys[:, None, :] - closest, axis=2) # (N, M) + + return np.min(dists, axis=1) + @classmethod def from_sweeping(cls, order: Literal[1, 2, 3, 4, 5] = 3) -> Self: """Return new using `EdgeSweeper`s scanning around current position.""" @@ -135,33 +209,20 @@ def to_params(self) -> dict[str, float]: ... class HexagonalWindow(GridablePolygonWindow): - """Describes a regular hexagonal window with a 2D ab coordinate system. - - Geometry is described using four immutable float scalars (nm / radian): - - - center_x: coordinate of the window center on the X axis; - - center_y: coordinate of the window center on the Y axis; - - width: distance between two opposite sides ("flat-to-flat"); - - theta: signed angle from world X axis towards the +a axis direction. - """ + """A regular hexagonal window with a 2D "ab" coordinate system.""" + INTERIOR_ANGLE: float = 60.0 ROT60MAT = np.array([[1, -np.sqrt(3)], [np.sqrt(3), 1]], dtype=float) / 2 + USES_HEIGHT = False - def __init__(self, x: float, y: float, w: float, t: float): - t = (float(t) + (np.pi / 6)) % (np.pi / 3) - (np.pi / 6) # cast to [-pi/6, pi/6] - self.center_x: float_nm = float(x) - self.center_y: float_nm = float(y) - self.width = w = abs(float(w)) - self.theta: float = float(t) # expressed in radian + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) - self.center = c = np.array([x, y], dtype=float) - self.a = 0.5 * w * np.array([np.cos(t), np.sin(t)], dtype=float) - self.b = self.ROT60MAT @ (self.ROT60MAT @ self.a) + self.a = 0.5 * self.w * versor(deg=self.t).T + self.b = self.ROT60MAT @ self.a - r_circum = w / np.sqrt(3.0) - angles = t + np.pi / 6 + np.arange(6) * (np.pi / 3) - corners = r_circum * np.stack([np.cos(angles), np.sin(angles)], axis=1) - self.corners = c + corners + angles = self.t + np.array([0, 60, 120, 180, 240, 300], dtype=float) + self.corners = self.center + self.w / np.sqrt(3.0) * versor(deg=angles).T @classmethod def from_edge_xys(cls, edge_xys: np.ndarray) -> Self: @@ -180,62 +241,32 @@ def from_edge_xys(cls, edge_xys: np.ndarray) -> Self: # Use principal axis as a crude guess for a vertex direction; convert to theta for a axis theta0 = float(np.arctan2(eigenvectors[1, 1], eigenvectors[0, 1]) - np.pi / 6.0) + theta0_deg = np.rad2deg(theta0) # Guess width from projected spread onto a axis direction (apothem approx) - w_hat0 = np.array([np.cos(theta0), np.sin(theta0)], dtype=float) - proj = xys_deltas @ w_hat0 + proj = xys_deltas @ versor(rad=theta0) # apothem ~ median absolute projection to a side midpoint direction a0 = float(np.median(np.abs(proj))) - width0 = max(1.0, 2.0 * a0) - guess = np.array([xys_com[0], xys_com[1], width0, theta0], dtype=float) + guess = np.array([xys_com[0], xys_com[1], theta0_deg, 2 * a0, None], dtype=float) res = minimize(cls.edge_d2_sum, guess, args=(edge_xys,), method='Powell') new = cls(*res.x) new._edge_xys = edge_xys return new - @staticmethod - def edge_d2_sum(geom: tuple[float, float, float, float], xys: np.ndarray) -> float: - """Objective: squared distance of points to nearest hexagon side (regular).""" - center_x, center_y, width, theta = geom - if width <= 0: - return float('inf') - - center = np.array([center_x, center_y], dtype=float) - deltas = np.asarray(xys, dtype=float) - center - - # 6 outward normals, rotated by theta - angles = theta + np.arange(6) * (np.pi / 3.0) - normals = np.stack([np.cos(angles), np.sin(angles)], axis=1) # (6,2) - - apothem = 0.5 * width # if width is flat-to-flat - # signed distances to the six supporting lines - signed = deltas @ normals.T - apothem # (N,6) - - # edge distance: nearest line in absolute value - d = np.min(np.abs(signed), axis=1) # (N,) - return float(np.sum(d**2)) - def to_params(self) -> dict[str, float]: - return {'x': self.center_x, 'y': self.center_y, 'w': self.width, 't': self.theta} + return {'x': self.x, 'y': self.y, 't': self.t, 'w': self.w} def translated(self, delta: np.ndarray) -> Self: """Return a new window translated by (dx, dy) in nm.""" - d = np.asarray(delta, dtype=float).reshape( - 2, - ) - return type(self)( - float(self.center_x + d[0]), - float(self.center_y + d[1]), - float(self.width), - float(self.theta), - ) + d = np.asarray(delta, dtype=float) + return type(self)(self.x + d[0], self.y + d[1], self.t, self.w) class RectangularWindow(GridablePolygonWindow): """Describes one rectangular window with a 2D ab coordinate system. - Geometry is described using five immutable float scalars (nm / radian): + Geometry is described using five immutable float scalars (nm / degree): - center_x: coordinate of the window center on the X axis; - center_y: coordinate of the window center on the Y axis; @@ -244,21 +275,15 @@ class RectangularWindow(GridablePolygonWindow): - theta: signed angle from X axis towards A axis and the X-aligned edge. """ - def __init__(self, x: float, y: float, w: float, h: float, t: float): - t = (float(t) + (np.pi / 2)) % np.pi - (np.pi / 2) # cast to [-pi/2, pi/2] - if abs(t) > (np.pi / 4): # cast to [-pi/4, pi/4] - w, h = h, w - t = t - np.copysign(np.pi / 2, t) - - self.center_x: float_nm = float(x) - self.center_y: float_nm = float(y) - self.width = w = abs(float(w)) - self.height = h = abs(float(h)) - self.theta: float = float(t) # expressed in radian - - self.center = c = np.array([x, y], dtype=float) - self.a = a = 0.5 * w * np.array([np.cos(t), np.sin(t)], dtype=float) - self.b = b = 0.5 * h * np.array([-np.sin(t), np.cos(t)], dtype=float) + INTERIOR_ANGLE: float = 90.0 + USES_HEIGHT = True + + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + + c = self.center + self.a = a = 0.5 * self.w * versor(deg=self.t) + self.b = b = 0.5 * self.h * versor(deg=self.t + 90) self.corners = np.vstack([c + a + b, c + a - b, c - a - b, c - a + b]) @classmethod @@ -271,80 +296,26 @@ def from_edge_xys(cls, edge_xys: np.ndarray) -> Self: eigenvector_proj = xys_deltas @ eigenvectors width0 = eigenvector_proj[:, 1].max() - eigenvector_proj[:, 1].min() height0 = eigenvector_proj[:, 0].max() - eigenvector_proj[:, 0].min() - theta0 = np.arctan2(eigenvectors[1, 1], eigenvectors[0, 1]) - guess = np.array([xys_com[0], xys_com[1], width0, height0, theta0]) + theta0 = np.rad2deg(np.arctan2(eigenvectors[1, 1], eigenvectors[0, 1])) + guess = np.array([xys_com[0], xys_com[1], theta0, width0, height0]) res = minimize(cls.edge_d2_sum, guess, args=(edge_xys,), method='Powell') new = cls(*res.x) new._edge_xys = edge_xys return new - @staticmethod - def edge_d2_sum(geom: tuple[float, float, float, float, float], xys: np.ndarray) -> float: - """scipy.optimize.minimize fitting func; for geometry see cls docs.""" - center_x, center_y, width, height, theta = geom - if width <= 0 or height <= 0: - return float('inf') - - center = np.array([center_x, center_y], dtype=float) - deltas = np.asarray(xys, dtype=float) - center - - a_hat = np.array([np.cos(theta), np.sin(theta)], dtype=float) - b_hat = np.array([-np.sin(theta), np.cos(theta)], dtype=float) - - # local coordinates - u = deltas @ a_hat - v = deltas @ b_hat - - # distance to nearest supporting line among the 4 edges - du = np.abs(np.abs(u) - 0.5 * width) - dv = np.abs(np.abs(v) - 0.5 * height) - d = np.minimum(du, dv) - return float(np.sum(d**2)) - def to_params(self) -> dict[str, float]: - return { - 'x': self.center_x, - 'y': self.center_y, - 'w': self.width, - 'h': self.height, - 't': self.theta, - } + return {'x': self.x, 'y': self.y, 't': self.t, 'w': self.w, 'h': self.h} def translated(self, delta: np.ndarray) -> Self: """Return a new window translated by (dx, dy) in nm.""" - d = np.asarray(delta, dtype=float).reshape(2) - return type(self)( - float(self.center_x + d[0]), - float(self.center_y + d[1]), - float(self.width), - float(self.height), - float(self.theta), - ) + d = np.asarray(delta, dtype=float) + return type(self)(self.x + d[0], self.y + d[1], self.t, self.w, self.h) -class SquareWindow(GridablePolygonWindow): - """Describes one square window with a 2D ab coordinate system. +class SquareWindow(RectangularWindow): + """A regular square window with a 2D "ab" coordinate system.""" - Geometry is described using four immutable float scalars (nm / radian): - - - center_x: coordinate of the window center on the X axis; - - center_y: coordinate of the window center on the Y axis; - - width: length of the square side (>= 0) - - theta: signed angle from X axis towards A axis and the X-aligned edge. - """ - - def __init__(self, x: float, y: float, w: float, t: float): - t = (float(t) + (np.pi / 4)) % (np.pi / 2) - (np.pi / 4) # cast to [-pi/4, pi/4] - - self.center_x: float_nm = x - self.center_y: float_nm = y - self.width = w = abs(w) - self.theta: float = float(t) - - self.center = c = np.array([x, y], dtype=float) - self.a = a = 0.5 * w * np.array([np.cos(t), np.sin(t)], dtype=float) - self.b = b = 0.5 * w * np.array([-np.sin(t), np.cos(t)], dtype=float) - self.corners = np.vstack([c + a + b, c + a - b, c - a - b, c - a + b]) + USES_HEIGHT = True @classmethod def from_edge_xys(cls, edge_xys: np.ndarray) -> Self: @@ -356,39 +327,18 @@ def from_edge_xys(cls, edge_xys: np.ndarray) -> Self: eigenvector_proj = xys_deltas @ eigenvectors width0 = float(eigenvector_proj[:, 1].max() - eigenvector_proj[:, 1].min()) height0 = float(eigenvector_proj[:, 0].max() - eigenvector_proj[:, 0].min()) - theta0 = float(np.arctan2(eigenvectors[1, 1], eigenvectors[0, 1])) + theta0 = np.rad2deg(np.arctan2(eigenvectors[1, 1], eigenvectors[0, 1])) side0 = max(1.0, 0.5 * (height0 + width0)) - guess = np.array([xys_com[0], xys_com[1], side0, theta0], dtype=float) + guess = np.array([xys_com[0], xys_com[1], theta0, side0, None], dtype=float) res = minimize(cls.edge_d2_sum, guess, args=(edge_xys,), method='Powell') new = cls(*res.x) new._edge_xys = edge_xys return new - @staticmethod - def edge_d2_sum(geom: tuple[float, float, float, float], xys: np.ndarray) -> float: - """Objective: squared distance of points to nearest of 4 square sides.""" - center_x, center_y, width, theta = geom - if width <= 0: - return np.inf - center = np.array([center_x, center_y], dtype=float) - deltas = np.asarray(xys, dtype=float) - center - a_hat = np.array([np.cos(theta), np.sin(theta)], dtype=float) - b_hat = np.array([-np.sin(theta), np.cos(theta)], dtype=float) - u = deltas @ a_hat # signed coordinates in the square frame - v = deltas @ b_hat - du = np.abs(np.abs(u) - 0.5 * width) - dv = np.abs(np.abs(v) - 0.5 * width) - d = np.minimum(du, dv) - return float(np.sum(d**2)) - def to_params(self) -> dict[str, float]: - return {'x': self.center_x, 'y': self.center_y, 'w': self.width, 't': self.theta} + return {'x': self.x, 'y': self.y, 't': self.t, 'w': self.w} def translated(self, delta: np.ndarray) -> Self: - d = np.asarray(delta, dtype=float).reshape(2) - return type(self)( - float(self.center_x + d[0]), - float(self.center_y + d[1]), - float(self.width), - float(self.theta), - ) + """Return a new window translated by (dx, dy) in nm.""" + d = np.asarray(delta, dtype=float) + return type(self)(self.x + d[0], self.y + d[1], self.t, self.w) From d3b13c22024f349541d7fe63bbd5b8628f4cc87e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Wed, 18 Mar 2026 14:38:01 +0100 Subject: [PATCH 067/118] WIP: move registry to geom, auto-find windows in limits, adapt artist, bugfixes --- .../experiments/scan_ed/experiment.py | 6 +- src/instamatic/grid/artist.py | 58 +++++++-- src/instamatic/grid/geometry.py | 59 ++++++++- src/instamatic/grid/registry.py | 69 ----------- src/instamatic/grid/window.py | 114 +++++++----------- 5 files changed, 152 insertions(+), 154 deletions(-) delete mode 100644 src/instamatic/grid/registry.py diff --git a/src/instamatic/experiments/scan_ed/experiment.py b/src/instamatic/experiments/scan_ed/experiment.py index 64a4995b..f3961d3f 100644 --- a/src/instamatic/experiments/scan_ed/experiment.py +++ b/src/instamatic/experiments/scan_ed/experiment.py @@ -219,7 +219,7 @@ def determine_manual_windows(self) -> list[GridablePolygonWindow]: edge_xys.append(self.ctrl.stage.xy) edge_xys = np.asarray(edge_xys, dtype=float) window = self.state.grid.window_type.from_edge_xys(edge_xys=edge_xys) - fig, ax = plot({**windows, window_idx: window}, debug_edges=True) + fig, ax = plot({**windows, window_idx: window}, show_intercepts=True) with self.videostream_frame.processor.temporary(figure=fig), cl: print('LMB to accept and finish, RMB to retry, MMB to accept and add new') c = cl.get_click() @@ -267,7 +267,7 @@ def draw_window_to_file(self, window_idx: int, window: GridablePolygonWindow) -> """Use grid.artist.plot to draw window into its own file for debug.""" file_path = self.path / 'windows' / f'window_{window_idx:04d}.png' file_path.parent.mkdir(exist_ok=True, parents=True) - fig, ax = plot({window_idx: window}, debug_edges=True) + fig, ax = plot({window_idx: window}, show_intercepts=True) if not file_path.exists(): # don't overwrite previous img with _edge_xys fig.savefig(file_path) @@ -275,7 +275,7 @@ def draw_grid_to_file(self): """Use grid.artist.plot to draw grid into its own file for debug.""" file_path = self.path / 'windows' / 'windows_all.png' file_path.parent.mkdir(exist_ok=True, parents=True) - fig, ax = plot(self.state.grid.windows, debug_edges=False) + fig, ax = plot(self.state.grid.windows, show_intercepts=False) fig.savefig(file_path) def set_stop_event_if_target_met(self) -> None: diff --git a/src/instamatic/grid/artist.py b/src/instamatic/grid/artist.py index 16e658c8..9b338b85 100644 --- a/src/instamatic/grid/artist.py +++ b/src/instamatic/grid/artist.py @@ -1,26 +1,31 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Dict, Optional +from typing import Optional import numpy as np -from matplotlib import pyplot as plt from matplotlib.axes import Axes from matplotlib.figure import Figure from matplotlib.patches import Polygon from matplotlib.ticker import FuncFormatter -if TYPE_CHECKING: - from instamatic.grid.window import ConvexPolygonWindow +from instamatic._typing import float_nm +from instamatic.grid.geometry import PeriodicConvexPolygonGridGeometry def plot( - windows: Dict[int, ConvexPolygonWindow], + geometry: PeriodicConvexPolygonGridGeometry, + *, + intercepts: Optional[dict[int, np.ndarray]] = None, + limit_x: Optional[float_nm] = None, + limit_y: Optional[float_nm] = None, ax: Optional[Axes] = None, show_indices: bool = True, - debug_edges: bool = False, + show_intercepts: bool = False, figsize: tuple[float, float] = (5, 5), dpi: int = 100, ) -> tuple[Figure, Axes]: + """Draw geometry with windows based on intercepts dict or limit_x/y.""" + fig, ax = (ax.figure, ax) if ax else plt.subplots(figsize=figsize, dpi=dpi) fig.patch.set_facecolor('black') @@ -38,7 +43,12 @@ def plot( patch_kw = {'facecolor': 'white', 'edgecolor': 'white', 'closed': True, 'zorder': 1} text_kw = {'color': 'black', 'ha': 'center', 'va': 'center', 'fontsize': 10, 'zorder': 2} - for idx, window in windows.items(): + indices = sorted(intercepts) if intercepts else list(range(25)) + if limit_x is not None and limit_y is not None: + indices = geometry.windows_in_limits(x=limit_x, y=limit_y) + + for idx in indices: + window = geometry.window(idx) corners = np.asarray(window.corners, dtype=float) ax.add_patch(Polygon(corners, **patch_kw)) @@ -46,8 +56,8 @@ def plot( cx, cy = map(float, window.center) ax.text(cx, cy, str(idx), **text_kw) - if debug_edges and hasattr(window, '_edge_xys'): - xys = np.asarray(window._edge_xys, dtype=float) + if show_intercepts and intercepts and idx in intercepts: + xys = np.asarray(intercepts[idx], dtype=float) ax.plot(xys[:, 0], xys[:, 1], 'bx', markersize=6, zorder=5) ax.relim() @@ -60,9 +70,37 @@ def plot( ax.set_xlim(cx - r, cx + r) ax.set_ylim(cy - r, cy + r) - ax.set_autoscale_on(False) # Freeze limits so lines don't affect view ax.axhline(0, color='white', linewidth=1.0, alpha=0.6, zorder=0) ax.axvline(0, color='white', linewidth=1.0, alpha=0.6, zorder=0) + if limit_x is not None: + ax.axvline(-limit_x, color='red', linewidth=1.0, zorder=4) + ax.axvline(limit_x, color='red', linewidth=1.0, zorder=4) + if limit_y is not None: + ax.axhline(-limit_y, color='red', linewidth=1.0, zorder=4) + ax.axhline(limit_y, color='red', linewidth=1.0, zorder=4) + return fig, ax + + +if __name__ == '__main__': + import matplotlib.pyplot as plt + import numpy as np + + from instamatic.grid.geometry import * + + common = {'x': 0, 'y': 0, 't': 0} + g1 = HexagonalGridGeometry(w=50_000, **common) + g2 = RectangularGridGeometry(w=40_000, h=60_000, **common) + g3 = RectangularGridGeometry(w=40_000, h=200_000, **common) + g4 = SquareGridGeometry(w=50_000, **common) + + fig, axs = plt.subplots(2, 2) + fig.tight_layout() + plot(g1, ax=axs[0, 0], limit_x=200_000, limit_y=200_000) + plot(g2, ax=axs[0, 1], limit_x=200_000, limit_y=200_000) + plot(g3, ax=axs[1, 0], limit_x=200_000, limit_y=200_000) + plot(g4, ax=axs[1, 1], limit_x=200_000, limit_y=200_000) + + plt.show() diff --git a/src/instamatic/grid/geometry.py b/src/instamatic/grid/geometry.py index d1ae152c..cd09371b 100644 --- a/src/instamatic/grid/geometry.py +++ b/src/instamatic/grid/geometry.py @@ -1,12 +1,21 @@ from __future__ import annotations +from itertools import count from typing import Annotated, Generic, Optional, Protocol, Self, Sequence, TypeVar, Union, cast import numpy as np +from pywinauto.sysinfo import is_x64_OS from scipy.optimize import least_squares +from instamatic._collections import NoOverwriteDict from instamatic._typing import float_nm, int_nm -from instamatic.grid.window import GridablePolygonWindow +from instamatic.grid.pairing import ij2ulam, spiral2uv, ulam2ij, uv2spiral +from instamatic.grid.window import ( + GridablePolygonWindow, + HexagonalWindow, + RectangularWindow, + SquareWindow, +) DualIndex = tuple[int, int] SpiralIndex = Annotated[int, 'positive'] @@ -49,6 +58,7 @@ class PeriodicConvexPolygonGridGeometry(Generic[WindowType]): "a" and "b" should match expected distance to next windows. """ + neighborhood: np.ndarray[int] pairing_function: PairingFunction pairing_inverse: PairingInverse window_type: type[WindowType] @@ -139,7 +149,7 @@ def b_grid(self) -> np.ndarray: def window_geometry(self, idx: WindowIndex) -> WindowGeometryTuple: """Return the current geom: origin + shape params of window "idx".""" - ij: DualIndex = self.pairing_inverse(idx) if isinstance(idx, int) else idx + ij: DualIndex = idx if isinstance(idx, tuple) else self.pairing_inverse(idx) x, y = self.origin + ij[0] * self.a_grid + ij[1] * self.b_grid return x, y, self.t, self.w, self.h @@ -147,6 +157,24 @@ def window(self, idx: WindowIndex) -> WindowType: """Convenience method that makes a window located at requested idx.""" return self.window_type(*self.window_geometry(idx)) + def windows_in_limits(self, x: float_nm, y: float_nm) -> list[int]: + """List indices of windows intersecting the box [-x, x] x [-y, y].""" + candidates_idx: set[int] = {0, self.nearest_index(0.0, 0.0)} + idx_in_limits: list[int] = [] + + while candidates_idx: + idx = min(candidates_idx) + candidates_idx.remove(idx) + + if self.window(idx=idx).intersects_limits(x, y): + idx_in_limits.append(idx) + for nb in np.array(self.pairing_inverse(idx)) + self.neighborhood: + nb_idx = self.pairing_function(nb[0], nb[1]) + if nb_idx > idx: + candidates_idx.add(nb_idx) + + return idx_in_limits + def nearest_index(self, x: float_nm, y: float_nm) -> int: """Return spiral index of predicted window nearest to the center.""" delta = np.asarray([x, y], dtype=float) - self.origin @@ -256,3 +284,30 @@ def residuals(p: np.ndarray) -> np.ndarray: self.w = geometry.w self.h = geometry.h self.s = geometry._s + + +class HexagonalGridGeometry(PeriodicConvexPolygonGridGeometry): + neighborhood = np.array([(1, 0), (0, 1), (-1, 1), (-1, 0), (0, -1), (1, -1)], dtype=int) + pairing_function: PairingFunction = staticmethod(uv2spiral) + pairing_inverse: PairingInverse = staticmethod(spiral2uv) + window_type: type[WindowType] = HexagonalWindow + + +class RectangularGridGeometry(PeriodicConvexPolygonGridGeometry[RectangularWindow]): + neighborhood = np.array([(1, 0), (0, 1), (-1, 0), (0, -1)], dtype=int) + pairing_function: PairingFunction = staticmethod(ij2ulam) + pairing_inverse: PairingInverse = staticmethod(ulam2ij) + window_type: type[WindowType] = RectangularWindow + + +class SquareGridGeometry(PeriodicConvexPolygonGridGeometry[SquareWindow]): + neighborhood = np.array([(1, 0), (0, 1), (-1, 0), (0, -1)], dtype=int) + pairing_function: PairingFunction = staticmethod(ij2ulam) + pairing_inverse: PairingInverse = staticmethod(ulam2ij) + window_type: type[WindowType] = SquareWindow + + +GRID_REGISTRY = NoOverwriteDict[str, type[PeriodicConvexPolygonGridGeometry]]() +GRID_REGISTRY['hexagonal'] = HexagonalGridGeometry +GRID_REGISTRY['rectangular'] = RectangularGridGeometry +GRID_REGISTRY['square'] = SquareGridGeometry diff --git a/src/instamatic/grid/registry.py b/src/instamatic/grid/registry.py deleted file mode 100644 index bf768210..00000000 --- a/src/instamatic/grid/registry.py +++ /dev/null @@ -1,69 +0,0 @@ -from __future__ import annotations - -from instamatic._collections import NoOverwriteDict -from instamatic.grid.geometry import PeriodicConvexPolygonGridGeometry, WindowType -from instamatic.grid.pairing import ij2ulam, spiral2uv, ulam2ij, uv2spiral -from instamatic.grid.window import HexagonalWindow, RectangularWindow, SquareWindow - - -class HexagonalGridGeometry(PeriodicConvexPolygonGridGeometry[HexagonalWindow]): - window_type = HexagonalWindow - pairing_function = staticmethod(uv2spiral) - pairing_inverse = staticmethod(spiral2uv) - - -class RectangularGridGeometry(PeriodicConvexPolygonGridGeometry[RectangularWindow]): - window_type = RectangularWindow - pairing_function = staticmethod(ij2ulam) - pairing_inverse = staticmethod(ulam2ij) - - -class SquareGridGeometry(PeriodicConvexPolygonGridGeometry[SquareWindow]): - window_type = SquareWindow - pairing_function = staticmethod(ij2ulam) - pairing_inverse = staticmethod(ulam2ij) - - -GRID_REGISTRY = NoOverwriteDict[str, type[PeriodicConvexPolygonGridGeometry]]() -GRID_REGISTRY['hexagonal'] = HexagonalGridGeometry -GRID_REGISTRY['rectangular'] = RectangularGridGeometry -GRID_REGISTRY['square'] = SquareGridGeometry - - -# development test code; to be moved to artist/tests - -if __name__ == '__main__': - import matplotlib.pyplot as plt - import numpy as np - - from instamatic.grid.artist import plot - - g1 = HexagonalGridGeometry() - w1 = HexagonalWindow(0, 0, 50_000, np.deg2rad(10)) - g1.windows[0] = w1 - - g2 = RectangularGridGeometry() - w2 = RectangularWindow(0, 0, 40_000, 60_000, np.deg2rad(10)) - g2.windows[0] = w2 - - g3 = RectangularGridGeometry() - w3 = RectangularWindow(0, 0, 20_000, 200_000, np.deg2rad(10)) - g3.windows[0] = w3 - - g4 = SquareGridGeometry() - w4 = SquareWindow(0, 0, 50_000, np.deg2rad(10)) - g4.windows[0] = w4 - - for grid in [g1, g2, g3, g4]: - for i in range(120): - w = grid.predict_window(i) - if np.linalg.norm(w.center - grid.windows[0].center) < 200_000: - grid.windows[i] = w - - fig, axs = plt.subplots(2, 2) - fig.tight_layout() - plot(g1.windows, ax=axs[0, 0]) - plot(g2.windows, ax=axs[0, 1]) - plot(g3.windows, ax=axs[1, 0]) - plot(g4.windows, ax=axs[1, 1]) - plt.show() diff --git a/src/instamatic/grid/window.py b/src/instamatic/grid/window.py index b77a8d59..e111158e 100644 --- a/src/instamatic/grid/window.py +++ b/src/instamatic/grid/window.py @@ -200,9 +200,48 @@ def bisectors(sweepers: list[BinaryEdgeSweeper]) -> list[BinaryEdgeSweeper]: edge_xy = np.vstack([bes.position for bes in bess]) # Nx2 return cls.from_edge_xys(edge_xy) - @classmethod - @abstractmethod - def from_edge_xys(cls, edge_xys: np.ndarray) -> Self: ... + # @classmethod + # @abstractmethod + # def from_edge_xys(cls, edge_xys: np.ndarray) -> Self: ... + # TODO: implement non-abstract based on the geometry implementation w/ no spacing + + def intersects_limits(self, x: float_nm, y: float_nm) -> bool: + """Test whether the window intersects the box [-x, x] x [-y, y]. To + this aim, in seven consecutive blocks: + + 1) Alias the corners and their coordinates for further + convenience; 2) Test whether the window bounding box (min/max) + is beyond limits; 3) Test whether any window corner is inside + the limits; 4) to 7) Test if any limit line intersects the + window within limits. + """ + c = np.asarray(self.corners, dtype=float) # window corners + cx = c[:, 0] # view of window corners' x coordinates + cy = c[:, 1] # view of window corners' x coordinates + + if cx.max() < -x or cx.min() > x or cy.max() < -y or cy.min() > y: + return False + + if np.any((cx > -x) & (cx < x) & (cy > -y) & (cy < y)): + return True + + xs = self.x_intersections(y=y) + if xs is not None and xs[0] < x and xs[1] > -x: + return True + + xs = self.x_intersections(y=-y) + if xs is not None and xs[0] < x and xs[1] > -x: + return True + + ys = self.y_intersections(x=x) + if ys is not None and ys[0] < y and ys[1] > -y: + return True + + ys = self.y_intersections(x=-x) + if ys is not None and ys[0] < y and ys[1] > -y: + return True + + return False @abstractmethod def to_params(self) -> dict[str, float]: ... @@ -221,39 +260,9 @@ def __init__(self, *args, **kwargs) -> None: self.a = 0.5 * self.w * versor(deg=self.t).T self.b = self.ROT60MAT @ self.a - angles = self.t + np.array([0, 60, 120, 180, 240, 300], dtype=float) + angles = self.t + np.array([30, 90, 150, 210, 270, 330], dtype=float) self.corners = self.center + self.w / np.sqrt(3.0) * versor(deg=angles).T - @classmethod - def from_edge_xys(cls, edge_xys: np.ndarray) -> Self: - """Return new by fitting a regular hexagon to a Nx2 list of edge - positions. - - Uses a simple initial guess from PCA and refines with Powell. - """ - edge_xys = np.asarray(edge_xys, dtype=float) - xys_com = np.mean(edge_xys, axis=0) - - # PCA for an initial orientation guess - xys_deltas = edge_xys - xys_com - xys_cov = np.cov(xys_deltas.T) - _, eigenvectors = np.linalg.eigh(xys_cov) - - # Use principal axis as a crude guess for a vertex direction; convert to theta for a axis - theta0 = float(np.arctan2(eigenvectors[1, 1], eigenvectors[0, 1]) - np.pi / 6.0) - theta0_deg = np.rad2deg(theta0) - - # Guess width from projected spread onto a axis direction (apothem approx) - proj = xys_deltas @ versor(rad=theta0) - # apothem ~ median absolute projection to a side midpoint direction - a0 = float(np.median(np.abs(proj))) - - guess = np.array([xys_com[0], xys_com[1], theta0_deg, 2 * a0, None], dtype=float) - res = minimize(cls.edge_d2_sum, guess, args=(edge_xys,), method='Powell') - new = cls(*res.x) - new._edge_xys = edge_xys - return new - def to_params(self) -> dict[str, float]: return {'x': self.x, 'y': self.y, 't': self.t, 'w': self.w} @@ -286,23 +295,6 @@ def __init__(self, *args, **kwargs) -> None: self.b = b = 0.5 * self.h * versor(deg=self.t + 90) self.corners = np.vstack([c + a + b, c + a - b, c - a - b, c - a + b]) - @classmethod - def from_edge_xys(cls, edge_xys: np.ndarray) -> Self: - """Return new by fitting the edge to a Nx2 list of edge positions.""" - xys_com = np.mean(edge_xys, axis=0) - xys_deltas = edge_xys - xys_com - xys_cov = np.cov(xys_deltas.T) - _, eigenvectors = np.linalg.eigh(xys_cov) - eigenvector_proj = xys_deltas @ eigenvectors - width0 = eigenvector_proj[:, 1].max() - eigenvector_proj[:, 1].min() - height0 = eigenvector_proj[:, 0].max() - eigenvector_proj[:, 0].min() - theta0 = np.rad2deg(np.arctan2(eigenvectors[1, 1], eigenvectors[0, 1])) - guess = np.array([xys_com[0], xys_com[1], theta0, width0, height0]) - res = minimize(cls.edge_d2_sum, guess, args=(edge_xys,), method='Powell') - new = cls(*res.x) - new._edge_xys = edge_xys - return new - def to_params(self) -> dict[str, float]: return {'x': self.x, 'y': self.y, 't': self.t, 'w': self.w, 'h': self.h} @@ -315,25 +307,7 @@ def translated(self, delta: np.ndarray) -> Self: class SquareWindow(RectangularWindow): """A regular square window with a 2D "ab" coordinate system.""" - USES_HEIGHT = True - - @classmethod - def from_edge_xys(cls, edge_xys: np.ndarray) -> Self: - """Return new by fitting the edge to a Nx2 list of edge positions.""" - xys_com = np.mean(edge_xys, axis=0) - xys_deltas = edge_xys - xys_com - xys_cov = np.cov(xys_deltas.T) - _, eigenvectors = np.linalg.eigh(xys_cov) - eigenvector_proj = xys_deltas @ eigenvectors - width0 = float(eigenvector_proj[:, 1].max() - eigenvector_proj[:, 1].min()) - height0 = float(eigenvector_proj[:, 0].max() - eigenvector_proj[:, 0].min()) - theta0 = np.rad2deg(np.arctan2(eigenvectors[1, 1], eigenvectors[0, 1])) - side0 = max(1.0, 0.5 * (height0 + width0)) - guess = np.array([xys_com[0], xys_com[1], theta0, side0, None], dtype=float) - res = minimize(cls.edge_d2_sum, guess, args=(edge_xys,), method='Powell') - new = cls(*res.x) - new._edge_xys = edge_xys - return new + USES_HEIGHT = False def to_params(self) -> dict[str, float]: return {'x': self.x, 'y': self.y, 't': self.t, 'w': self.w} From a412ccadd45f739e6f136edac857318e3a770b5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Thu, 19 Mar 2026 18:24:55 +0100 Subject: [PATCH 068/118] Incorporate changes to the logic of new grid geometry into serial experiment (untested) --- .../experiments/scan_ed/dispatch.py | 6 +- .../experiments/scan_ed/experiment.py | 247 ++++++++++-------- .../experiments/scan_ed/progress.py | 2 +- src/instamatic/experiments/scan_ed/state.py | 92 +++---- src/instamatic/grid/geometry.py | 3 + .../grid/{sweepers.py => sweeping.py} | 45 +++- src/instamatic/grid/window.py | 49 +--- 7 files changed, 233 insertions(+), 211 deletions(-) rename src/instamatic/grid/{sweepers.py => sweeping.py} (75%) diff --git a/src/instamatic/experiments/scan_ed/dispatch.py b/src/instamatic/experiments/scan_ed/dispatch.py index c807ba59..de80bc4a 100644 --- a/src/instamatic/experiments/scan_ed/dispatch.py +++ b/src/instamatic/experiments/scan_ed/dispatch.py @@ -149,7 +149,7 @@ def write_scan(self, path: AnyPath) -> None: kwargs = {'path': path, 'header': self.headers[pointer]} self.emit('WRITE', buffer_name=bn, buffer_pointer=pointer, kwargs=kwargs) - def handle_feedback(self, state: State, window: int, scan: int) -> None: + def handle_feedback(self, state: State, region: int, scan: int) -> None: """Continuously drain the feedback queue until scan is fully processed. This call modifies a decorated State table. Therefore, either it @@ -165,11 +165,11 @@ def handle_feedback(self, state: State, window: int, scan: int) -> None: pointer = int(fb.buffer_pointer) if fb.kind == 'PROCESSING': - state.mark_processing(window, scan, pointer) + state.mark_processing(region, scan, pointer) elif fb.kind == 'PROCESSED': d: DiffHuntResults = fb.details - state.fill_step(window, scan, pointer, d.success, len(d.peaks)) + state.fill_step(region, scan, pointer, d.success, len(d.peaks)) if self.hits is not None: self.hits[pointer] = d.success self._in_flight.discard(pointer) diff --git a/src/instamatic/experiments/scan_ed/experiment.py b/src/instamatic/experiments/scan_ed/experiment.py index f3961d3f..d123f486 100644 --- a/src/instamatic/experiments/scan_ed/experiment.py +++ b/src/instamatic/experiments/scan_ed/experiment.py @@ -1,12 +1,11 @@ from __future__ import annotations from datetime import datetime, timedelta -from itertools import count, cycle +from itertools import count, cycle, product from pathlib import Path from threading import Thread -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Iterator -import numpy as np import pandas as pd from instamatic.calibrate import CalibMovieDelays @@ -18,8 +17,8 @@ from instamatic.experiments.scan_ed.progress import ProgressTable from instamatic.experiments.scan_ed.state import State from instamatic.grid.artist import plot -from instamatic.grid.registry import GRID_REGISTRY, PeriodicConvexPolygonGridGeometry -from instamatic.grid.window import GridablePolygonWindow +from instamatic.grid.geometry import GRID_REGISTRY, PeriodicConvexPolygonGridGeometry +from instamatic.grid.sweeping import star_sweep from instamatic.gui.click_dispatcher import ClickListener, MouseButton if TYPE_CHECKING: @@ -61,7 +60,7 @@ def state(self) -> State: return self._state journal_path = self.path / 'journal.jsonl' journal = Journal(path=journal_path) - grid = GRID_REGISTRY[self.params['grid_geometry']]() + grid = GRID_REGISTRY[self.params['grid_geometry']](0, 0, 0, 50_000, 50_000) state = State(journal=journal, grid=grid, progress=self.progress) if self.load: if not journal_path.exists() or not journal_path.is_file(): @@ -117,76 +116,107 @@ def determine_timing(self, step_size: int_nm) -> tuple[float, float, float]: def start_collection(self, **params) -> None: """Method that governs the entirety of scan ED experiment work flow.""" + # Save parameters to a variable, load the journal and dispatcher self.params = params _ = self.state # loads the journal if self.dispatcher is None: self.dispatcher = self.get_dispatcher() - # windows are only added if no defined; TODO: allow adding after loading + # if allowed, add manually as many windows as the user desires. self.ctrl.stage.set(a=0) - if not self.state.grid.windows: - windows = self.determine_manual_windows() - self.order_and_add_manual_windows(windows) - for window_idx, window in self.state.grid.windows.items(): - self.draw_window_to_file(window_idx=window_idx, window=window) + if not self.state.intercepts: + grid, intercepts = self.determine_grid_manually() + self.state.update_grid(grid.to_params()) + for idx, idx_intercepts in intercepts.values(): + self.state.add_intercepts(idx, idx_intercepts) + + # Whenever any new window is added manually, draw it and then whole grid + for window_idx in self.state.intercepts: + self.draw_window_to_file(window_idx=window_idx) self.draw_grid_to_file() - while not params['stop_event'].is_set(): - try: - for window_idx in self.state.grid.windows.keys(): - if not self.state.has_any_scans(window_idx): - self.add_scans(window_idx=window_idx, params=params) - for _, scan_idx in self.state.untouched_scans(window=window_idx): - self.set_tilt(window_idx) - self.run_scan(window_idx, scan_idx) - self.set_stop_event_if_target_met() + # MAIN LOOP: define new region and request locating all windows in it + try: + for region_idx in count(): + windows_idx = self.region_members(cluster_idx=region_idx) + for window_idx in windows_idx: + if window_idx not in self.state.intercepts: + try: + _, intercepts = self.locate_window(window_idx) + except IndexError: + intercepts = np.zeros(shape=(0, 2), dtype=float) + self.state.add_intercepts(window_idx, intercepts) + self.draw_window_to_file(window_idx=window_idx) + self.draw_grid_to_file() if params['stop_event'].is_set(): break - finally: - self.ctrl.stage.set(a=0) - if params['stop_event'].is_set(): - break - try: - window_idx, window = self.locate_next_window() - except IndexError: - params['stop_event'].set() - break - self.state.add_window(idx=window_idx, window=window) - self.draw_window_to_file(window_idx=window_idx, window=window) - self.draw_grid_to_file() + # sanitation step: assert the current region is in limits + if sum(self.state.intercepts[i].shape[0] for i in windows_idx) == 0: + continue # should break if no more windows are in limits + + # once region is located, add and run the scans over it + if not self.state.has_any_scans(region_idx): + self.add_scans(region_idx=region_idx) + for _, scan_idx in self.state.untouched_scans(region=region_idx): + self.run_scan(region_idx, scan_idx) + self.set_stop_event_if_target_met() + if params['stop_event'].is_set(): + break + finally: + self.ctrl.stage.set(a=0) self.teardown() - def add_scans(self, window_idx: int, params: dict[str, Any]) -> None: + def region_members(self, cluster_idx: int) -> Iterator[int]: + """Find windows idx of all windows that belong to region idx.""" + region_size: str = self.params.get('region_size', '1x1') + region_shape = np.array([int(i.strip()) for i in region_size.split('x')], dtype=int) + region_ij = self.state.grid.pairing_inverse(cluster_idx) + + i_span = np.arange(region_shape[0]) - (region_shape[0] - 1) // 2 + j_span = np.arange(region_shape[1]) - (region_shape[1] - 1) // 2 + + window_ij = region_ij * region_shape + for i, j in product(i_span, j_span): + yield self.state.grid.pairing_function(window_ij[0] + i, window_ij[1] + j) + + def add_scans(self, region_idx: int) -> None: """Add scans for window, asserting it does not have scans yet.""" - window = self.state.grid.windows[window_idx] - if params['scan_geometry'].lower().startswith('x'): + p = self.params + windows_idx = self.region_members(cluster_idx=region_idx) + windows = [self.state.grid.window(idx) for idx in windows_idx] + + if p['scan_geometry'].lower().startswith('x'): axis = 0 - scan_factory = window.x_intersections - step = params['scan_x_step'] - spacing = params['scan_y_step'] + scan_factory = 'x_intersections' + step = p['scan_x_step'] + spacing = p['scan_y_step'] else: # params['scan_geometry'].lower().startswith('y'): axis = 1 - scan_factory = window.y_intersections - step = params['scan_y_step'] - spacing = params['scan_x_step'] + scan_factory = 'y_intersections' + step = p['scan_y_step'] + spacing = p['scan_x_step'] _, _, total_delay = self.determine_timing(step) - error_margin = max(step * total_delay / self.params['scan_exposure'], 0) + error_margin = max(step * total_delay / p['scan_exposure'], 0) - scan_dirs = cycle([1] if 'raster' in params['scan_geometry'] else [1, -1]) - slow_min = np.min(window.corners[:, 1 - axis]) - slow_max = np.max(window.corners[:, 1 - axis]) + scan_dirs = cycle([1] if 'raster' in p['scan_geometry'] else [1, -1]) + slow_min = np.min(w.corners[:, 1 - axis] for w in windows) + slow_max = np.max(w.corners[:, 1 - axis] for w in windows) slows = np.arange(slow_min + spacing, slow_max, spacing, dtype=int) for scan_id, slow in enumerate(slows): - fast_min, fast_max = scan_factory(slow) + # TODO: incorporate variable tilt (as different scans or new index) + # since it's float, likely better as variable, then series = local + fast_scans = np.array([getattr(w, scan_factory)(slow) for w in windows]) + fast_min = np.min(fast_scans) + fast_max = np.max(fast_scans) fast_min -= error_margin fast_max += error_margin direction = next(scan_dirs) fast_start, fast_stop = [fast_min, fast_max][::direction] self.state.add_scan( - window=int(window_idx), + region=int(region_idx), scan=int(scan_id), x0=int(slow if axis else fast_start), y0=int(fast_start if axis else slow), @@ -195,21 +225,24 @@ def add_scans(self, window_idx: int, params: dict[str, Any]) -> None: n_steps=int(np.ceil(abs((fast_stop - fast_start) / step))), ) - def determine_manual_windows(self) -> list[GridablePolygonWindow]: + def determine_grid_manually(self) -> tuple[PeriodicConvexPolygonGridGeometry, dict]: + grid = self.state.grid method = self.params.get('grid_finder', 'All automatically') if method == 'All automatically': - return [] + return grid, {} d = self.videostream_frame.click_dispatcher n = self.name cl: ClickListener = c if (c := d.listeners.get(n)) else d.add_listener(n) - print('Please navigate the stage to as many points on the windows edge as possible') + print('Please navigate the stage to as many points on one windows edge as possible') print('(at least the corners and midpoints). At each point, position the edge at') print('the center of the screen and LMB to add the point. RMB to finish.') - windows = {} - for window_idx in count(): + candidates: dict[int, np.ndarray] = {} + intercepts: dict[int, np.ndarray] = {} + window_idx: int = 0 + while True: edge_xys = [] with cl: while True: @@ -218,56 +251,58 @@ def determine_manual_windows(self) -> list[GridablePolygonWindow]: break edge_xys.append(self.ctrl.stage.xy) edge_xys = np.asarray(edge_xys, dtype=float) - window = self.state.grid.window_type.from_edge_xys(edge_xys=edge_xys) - fig, ax = plot({**windows, window_idx: window}, show_intercepts=True) + + if 0 in intercepts: + new_center = (np.max(edge_xys, axis=0) - np.min(edge_xys, axis=0)) / 2 + window_idx = grid.nearest_index(*new_center) + print(f'Adding another window: estimated index {window_idx}') + if window_idx in candidates: + print(f'Warning: window {window_idx} was already added! Overwriting...') + candidates[window_idx] = np.asarray(edge_xys, dtype=float) + + grid.refine(candidates) + fig, ax = plot(grid, show_intercepts=True) with self.videostream_frame.processor.temporary(figure=fig), cl: - print('LMB to accept and finish, RMB to retry, MMB to accept and add new') + print('LMB to accept and finish, RMB to retry, MMB to accept and new window') c = cl.get_click() if c.button == MouseButton.LEFT: - windows[window_idx] = window - return list(windows.values()) + intercepts[window_idx] = candidates[window_idx] + return grid, intercepts elif c.button == MouseButton.RIGHT: continue else: # middle or any other - windows[window_idx] = window + intercepts[window_idx] = candidates[window_idx] continue - def order_and_add_manual_windows(self, windows: list[GridablePolygonWindow]) -> None: - """Based on the first, correctly reindex+add the following windows.""" - if not windows: - return - self.state.add_window(idx=0, window=windows.pop(0)) - for window in windows: - idx = self.state.grid.nearest_index(*window.center) - self.state.add_window(idx=idx, window=window) - - def locate_next_window(self) -> tuple[int, GridablePolygonWindow]: - """Find a next window on the grid, or raise if none can be found.""" + def locate_window(self, idx: int = -1) -> tuple[int, np.ndarray]: + """Find intersects with a next window, raise if no window be found.""" if self.params.get('grid_finder') == 'All manually': raise IndexError('Experiment params disallow locating new windows') - grid: PeriodicConvexPolygonGridGeometry[GridablePolygonWindow] = self.state.grid - if not self.state.grid.windows: - return 0, grid.window_type.from_sweeping(order=4) - max_index = 10 + 2 * (max(grid.windows) if grid.windows else 0) - for window_id in range(0, max_index): - if window_id in grid.windows: - continue - predicted = grid.predict_window(window_id) - x_lim = tx if (tx := self.params['target_x']) is not None else float('inf') - y_lim = ty if (ty := self.params['target_y']) is not None else float('inf') - x_fits = np.all(np.abs(predicted.corners[:, 0]) < x_lim) - y_fits = np.all(np.abs(predicted.corners[:, 1]) < y_lim) - if not (x_fits and y_fits): - continue - self.ctrl.stage.set(*[int(xy) for xy in predicted.center]) - return window_id, grid.window_type.from_sweeping(order=3) - raise IndexError('Could not locate next window within limits') - - def draw_window_to_file(self, window_idx: int, window: GridablePolygonWindow) -> None: + if not self.state.intercepts: + return 0, star_sweep(arms=3, order=4) + + x_lim = tx if (tx := self.params['target_x']) is not None else 1_000_000 + y_lim = ty if (ty := self.params['target_y']) is not None else 1_000_000 + + in_limits = self.state.grid.windows_in_limits(x=x_lim, y=y_lim) + if idx == -1: + try: + idx = min([i for i in in_limits if i not in self.state.intercepts]) + except ValueError: + raise IndexError('Could not locate next window within limits') + else: + if idx not in in_limits: + raise IndexError(f'Requested window {idx} is not within limits') + + self.ctrl.stage.set(*[int(xy) for xy in self.state.grid.window(idx).center]) + return idx, star_sweep(arms=3, order=2, offset=11 * idx) + + def draw_window_to_file(self, window_idx: int) -> None: """Use grid.artist.plot to draw window into its own file for debug.""" file_path = self.path / 'windows' / f'window_{window_idx:04d}.png' file_path.parent.mkdir(exist_ok=True, parents=True) - fig, ax = plot({window_idx: window}, show_intercepts=True) + intercepts = {window_idx: self.state.intercepts[window_idx]} + fig, ax = plot(self.state.grid, intercepts=intercepts, show_intercepts=True) if not file_path.exists(): # don't overwrite previous img with _edge_xys fig.savefig(file_path) @@ -275,7 +310,7 @@ def draw_grid_to_file(self): """Use grid.artist.plot to draw grid into its own file for debug.""" file_path = self.path / 'windows' / 'windows_all.png' file_path.parent.mkdir(exist_ok=True, parents=True) - fig, ax = plot(self.state.grid.windows, show_intercepts=False) + fig, ax = plot(self.state.grid, intercepts=self.state.intercepts) fig.savefig(file_path) def set_stop_event_if_target_met(self) -> None: @@ -288,26 +323,19 @@ def set_stop_event_if_target_met(self) -> None: if time_passed > time_target or hits_found > hits_target: self.params['stop_event'].set() - def set_tilt(self, window_idx: int) -> None: - """Set alpha (0 to +/-max to 0) as a function of window progress.""" - p = self.state.window_progress(window=window_idx) - m = self.params['max_alpha'] - a = m * 2 * p if p <= 0.5 else m * (2 * p - 2) # 0 to m, then -m to 0 - self.ctrl.stage.set(a=a) - - def run_scan(self, window_idx: int, scan_idx: int) -> None: + def run_scan(self, region_idx: int, scan_idx: int) -> None: """Run a single scan previously added to state on the grid.""" - idx = pd.IndexSlice[window_idx, scan_idx, :] + idx = pd.IndexSlice[region_idx, scan_idx, :] if np.any(self.state.steps.loc[idx, 'n_peaks'] != -1): return # none-op for a scans that has been already done - n_frames = int(self.state.scans.loc[(window_idx, scan_idx), 'n_steps']) + n_frames = int(self.state.scans.loc[(region_idx, scan_idx), 'n_steps']) - scan = self.state.scans.loc[(window_idx, scan_idx)] + scan = self.state.scans.loc[(region_idx, scan_idx)] self.ctrl.stage.set(x=scan['x0'], y=scan['y0']) - self.dispatcher.begin_scan(n_frames, name=f'w{window_idx:03d}_s{scan_idx:06d}') - fb_kwargs = {'state': self.state, 'window': window_idx, 'scan': scan_idx} + self.dispatcher.begin_scan(n_frames, name=f'r{region_idx:03d}_s{scan_idx:06d}') + fb_kwargs = {'state': self.state, 'region': region_idx, 'scan': scan_idx} fb_thread = Thread(target=self.dispatcher.handle_feedback, kwargs=fb_kwargs) fb_thread.start() @@ -326,9 +354,9 @@ def run_scan(self, window_idx: int, scan_idx: int) -> None: fb_thread.join() self.dispatcher.write_scan(path=self.path / 'tiff') - self.dispatcher.handle_feedback(self.state, window_idx, scan_idx) + self.dispatcher.handle_feedback(self.state, region_idx, scan_idx) self.dispatcher.end_scan() - self.state.finalize_scan(window_idx, scan_idx) + self.state.finalize_scan(region_idx, scan_idx) self.ctrl.stage.wait() def teardown(self) -> None: @@ -336,6 +364,13 @@ def teardown(self) -> None: self.dispatcher.terminate_workers() self.params['stop_event'].clear() + def tilt_list(self) -> Sequence[float]: + """Return a list of tilts from - to + params[tilt_range] for scans.""" + tilt_extent = self.params.get('tilt_extent', 0) + tilt_step = self.params.get('tilt_step', 0) + tilt_count = np.round(2 * tilt_extent / tilt_step) + 1 + return np.linspace(-tilt_extent, tilt_extent, num=tilt_count, endpoint=True) + def finalize(self) -> None: ... # TODO diff --git a/src/instamatic/experiments/scan_ed/progress.py b/src/instamatic/experiments/scan_ed/progress.py index ac4d0261..2f3a1366 100644 --- a/src/instamatic/experiments/scan_ed/progress.py +++ b/src/instamatic/experiments/scan_ed/progress.py @@ -220,7 +220,7 @@ def _post(self, name: str, *args, **kwargs) -> None: self._schedule() # Keep the API fixed and consistent, generalizing this is annoying - def add_window(self, **kwargs): + def add_intercepts(self, **kwargs): self._post('add_window', **kwargs) def add_scan(self, **kwargs): diff --git a/src/instamatic/experiments/scan_ed/state.py b/src/instamatic/experiments/scan_ed/state.py index 02da716c..d6ed3073 100644 --- a/src/instamatic/experiments/scan_ed/state.py +++ b/src/instamatic/experiments/scan_ed/state.py @@ -4,14 +4,13 @@ import pandas as pd +from instamatic._collections import NoOverwriteDict from instamatic.experiments.scan_ed.encoding import * from instamatic.experiments.scan_ed.journal import Journal, edits_journal from instamatic.experiments.scan_ed.progress import ProgressTable, edits_progress from instamatic.grid.geometry import PeriodicConvexPolygonGridGeometry from instamatic.grid.window import GridablePolygonWindow -WindowFactory: Callable[[float, float, float, ...], type[GridablePolygonWindow]] - class State: """Stores the current state of the SPED experiment in history dataframe.""" @@ -21,10 +20,12 @@ def __init__( journal: Journal, grid: PeriodicConvexPolygonGridGeometry, progress: Optional[ProgressTable] = None, + intercepts: Optional[dict[int, np.ndarray]] = None, ) -> None: self.journal: Journal = journal self.grid: PeriodicConvexPolygonGridGeometry = grid self.progress: Optional[ProgressTable] = progress + self.intercepts: NoOverwriteDict[int, np.ndarray] = NoOverwriteDict(intercepts or {}) self.scans: pd.DataFrame = pd.DataFrame() self.steps: pd.DataFrame = pd.DataFrame() @@ -33,7 +34,7 @@ def __init__( def _init_dataframes(self) -> None: """Create a new empty history with required index and columns.""" scan_columns = { - 'window': pd.Series(dtype='UInt16'), + 'region': pd.Series(dtype='UInt16'), 'scan': pd.Series(dtype='UInt16'), 'x0': pd.Series(dtype='Int32'), 'y0': pd.Series(dtype='Int32'), @@ -42,38 +43,35 @@ def _init_dataframes(self) -> None: 'n_steps': pd.Series(dtype='UInt16'), } steps_columns = { - 'window': pd.Series(dtype='UInt16'), + 'region': pd.Series(dtype='UInt16'), 'scan': pd.Series(dtype='UInt16'), 'step': pd.Series(dtype='UInt16'), 'hits': pd.Series(dtype='boolean'), 'n_peaks': pd.Series(dtype='Int16'), } self.scans = pd.DataFrame(scan_columns) - self.scans.set_index(['window', 'scan'], inplace=True) + self.scans.set_index(['region', 'scan'], inplace=True) self.steps = pd.DataFrame(steps_columns) - self.steps.set_index(['window', 'scan', 'step'], inplace=True) + self.steps.set_index(['region', 'scan', 'step'], inplace=True) def load_from_journal(self) -> None: + """Recreate an instance of experiment state from journal file.""" with self.journal.writing_off(): for event in self.journal.events(): method_name = event['method'] kwargs = event.get('kwargs', {}) - if method_name == 'add_window': - wkw = {k: float(v) for k, v in kwargs.pop('window').items()} - kwargs['window'] = self.grid.window_type(**wkw) getattr(self, method_name)(**kwargs) @edits_journal - @edits_progress - def add_window(self, idx: int, window: GridablePolygonWindow) -> None: - """For journaling purposes, can be added via instance or __repr__.""" - self.grid.windows[idx] = window + def add_intercepts(self, idx: int, intercepts: np.ndarray) -> None: + """Add a Nx2 matrix of intercepts of window idx.""" + self.intercepts[idx] = np.asarray(intercepts, dtype=float) @edits_journal @edits_progress def add_scan( self, - window: int, + region: int, scan: int, x0: int, y0: int, @@ -83,17 +81,18 @@ def add_scan( ) -> None: """Append to scans and pre-allocate space in the steps dataframe.""" scan_cols = ['x0', 'y0', 'axis', 'step', 'n_steps'] - self.scans.loc[(window, scan), scan_cols] = (x0, y0, axis, step, n_steps) - idx_names = ['window', 'scan', 'step'] - idx = pd.MultiIndex.from_product([[window], [scan], range(n_steps)], names=idx_names) + self.scans.loc[(region, scan), scan_cols] = (x0, y0, axis, step, n_steps) + idx_names = ['region', 'scan', 'step'] + idx = pd.MultiIndex.from_product([[region], [scan], range(n_steps)], names=idx_names) new_scans = { 'hits': np.zeros(n_steps, dtype=np.bool_), 'n_peaks': np.full(n_steps, -1, dtype=np.int16), } self.steps = pd.concat([self.steps, pd.DataFrame(new_scans, index=idx)]) - def finalize_scan(self, window: int, scan: int) -> None: - idx = pd.IndexSlice[window, scan, :] + def finalize_scan(self, region: int, scan: int) -> None: + """Converts scan results to an encoded scan, writes it to journal.""" + idx = pd.IndexSlice[region, scan, :] n_peaks = self.steps.loc[idx, 'n_peaks'].to_numpy(np.int16, copy=False) if (n_peaks < 0).any(): raise RuntimeError('Scan not complete.') @@ -101,7 +100,7 @@ def finalize_scan(self, window: int, scan: int) -> None: hits = self.steps.loc[idx, 'hits'].to_numpy(np.bool_, copy=False) payload = { - 'window': int(window), + 'region': int(region), 'scan': int(scan), 'hits': encode_hits(hits), 'n_peaks': encode_i16(n_peaks), @@ -109,57 +108,48 @@ def finalize_scan(self, window: int, scan: int) -> None: self.journal.write('fill_encoded_scan', payload) @edits_progress - def mark_processing(self, window: int, scan: int, step: int) -> None: + def mark_processing(self, region: int, scan: int, step: int) -> None: """Mark a step as currently processed by setting n_peaks to -2.""" - idx = (window, scan, step) + idx = (region, scan, step) if int(self.steps.at[idx, 'n_peaks']) == -1: self.steps.at[idx, 'n_peaks'] = np.int16(-2) @edits_progress - def fill_step(self, window: int, scan: int, step: int, hit: bool, n_peaks: int) -> None: + def fill_step(self, region: int, scan: int, step: int, hit: bool, n_peaks: int) -> None: """Once the step is processed, set correct hit bool and n_peaks.""" - idx = (window, scan, step) + idx = (region, scan, step) self.steps.at[idx, 'hits'] = bool(hit) self.steps.at[idx, 'n_peaks'] = np.int16(n_peaks) @edits_progress - def fill_scan(self, window: int, scan: int, hits, n_peaks) -> None: + def fill_scan(self, region: int, scan: int, hits, n_peaks) -> None: """An alternative to repeated fill_step, fills whole scan at once.""" - idx = pd.IndexSlice[window, scan, :] + idx = pd.IndexSlice[region, scan, :] self.steps.loc[idx, 'hits'] = np.asarray(hits, dtype=np.bool_) self.steps.loc[idx, 'n_peaks'] = np.asarray(n_peaks, dtype=np.int16) - def fill_encoded_scan(self, window: int, scan: int, hits: str, n_peaks: str) -> None: - """To be called ONLY during replay when recreating from journal.""" - n_steps = int(self.scans.loc[(window, scan), 'n_steps']) + def fill_encoded_scan(self, region: int, scan: int, hits: str, n_peaks: str) -> None: + """Called directly ONLY during replay when recreating from journal.""" + n_steps = int(self.scans.loc[(region, scan), 'n_steps']) hits_arr = decode_hits(hits, n_steps) peaks_arr = decode_i16(n_peaks) if peaks_arr.size != n_steps: raise ValueError(f'Corrupt scan payload: {peaks_arr.size=} != {n_steps=}') - self.fill_scan(window, scan, hits_arr, peaks_arr) + self.fill_scan(region, scan, hits_arr, peaks_arr) - def has_any_scans(self, window: int) -> bool: - """Returns True if window has any defined scans with any status.""" - i, k = self.scans.index, 'window' - return len(self.scans) > 0 and k in i.names and (i.get_level_values(k) == window).any() + def has_any_scans(self, region: int) -> bool: + """Returns True if region has any defined scans with any status.""" + i, k = self.scans.index, 'region' + return len(self.scans) > 0 and k in i.names and (i.get_level_values(k) == region).any() - def untouched_scans(self, window: Optional[int] = None) -> pd.MultiIndex: - """An iterable of (window, scan)-idx of planned-but-untouched scans.""" + def untouched_scans(self, region: Optional[int] = None) -> pd.MultiIndex: + """An iterable of (region, scan)-idx of planned-but-untouched scans.""" n_peaks = self.steps['n_peaks'] - if window is not None: - n_peaks = n_peaks.xs(window, level='window', drop_level=False) - untouched = n_peaks.eq(-1).groupby(level=['window', 'scan']).all() + if region is not None: + n_peaks = n_peaks.xs(region, level='region', drop_level=False) + untouched = n_peaks.eq(-1).groupby(level=['region', 'scan']).all() return untouched[untouched].index - def window_progress(self, window: int) -> float: - """Return measured fraction of the window scans (length-weighted).""" - if self.scans.empty or window not in self.scans.index.get_level_values('window'): - return 0.0 - scans = self.scans.xs(window, level='window', drop_level=False) - total_steps = int(scans['n_steps'].sum()) - if total_steps == 0: - return 0.0 - n_peaks = self.steps['n_peaks'].xs(window, level='window', drop_level=False) - touched = n_peaks.ge(0).groupby(level=['window', 'scan']).any() - touched = touched.reindex(scans.index, fill_value=False) - return scans.loc[touched, 'n_steps'].sum() / total_steps + @edits_journal + def update_grid(self, params: dict[str, float]) -> None: + self.grid = self.grid.__class__(**params) diff --git a/src/instamatic/grid/geometry.py b/src/instamatic/grid/geometry.py index cd09371b..ceb77106 100644 --- a/src/instamatic/grid/geometry.py +++ b/src/instamatic/grid/geometry.py @@ -285,6 +285,9 @@ def residuals(p: np.ndarray) -> np.ndarray: self.h = geometry.h self.s = geometry._s + def to_params(self): + return {'x': self.x, 'y': self.y, 't': self.t, 'w': self.w, 'h': self._h, 's': self._s} + class HexagonalGridGeometry(PeriodicConvexPolygonGridGeometry): neighborhood = np.array([(1, 0), (0, 1), (-1, 1), (-1, 0), (0, -1), (1, -1)], dtype=int) diff --git a/src/instamatic/grid/sweepers.py b/src/instamatic/grid/sweeping.py similarity index 75% rename from src/instamatic/grid/sweepers.py rename to src/instamatic/grid/sweeping.py index 5b9518f2..26756f07 100644 --- a/src/instamatic/grid/sweepers.py +++ b/src/instamatic/grid/sweeping.py @@ -1,13 +1,15 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any, Literal, Sequence +from itertools import chain +from typing import Any, Literal, Optional, Sequence, Union import numpy as np from typing_extensions import Self -from instamatic._typing import float_nm, int_nm +from instamatic._typing import float_deg, float_nm, int_nm from instamatic.controller import TEMController, _ctrl, initialize +from instamatic.utils.iterating import pairwise if not _ctrl: _ctrl: TEMController = initialize() @@ -21,6 +23,16 @@ def cross2d(a: np.ndarray, b: np.ndarray) -> float: return (a[0] * b[1] - a[1] * b[0]).item() +def versor( + *, + deg: Optional[Union[float, np.ndarray]] = None, + rad: Optional[Union[float, np.ndarray]] = None, +) -> np.ndarray: + """A versor in the direction of angle expressed in radians or degrees.""" + radians = np.deg2rad(deg) if rad is None else rad + return np.array([np.cos(radians), np.sin(radians)], dtype=float) + + class InstanceAutoNameRegistry: """Autosave each subclass instance in `cls.INSTANCES` dict under `name`""" @@ -138,3 +150,32 @@ def sweep(self) -> None: if refining: step_size *= 0.5 self.step(length=direction * step_size) + + +def star_sweep( + arms: Literal[3, 4, 5, 6, 7] = 5, + order: Literal[1, 2, 3, 4, 5] = 5, + offset: float_deg = 0, +) -> np.ndarray: + """Sweep window, return (arms*2**order)x2 list of points on its edge.""" + center: Vector2 = np.array(_ctrl.stage.xy, dtype=int) + team = str(center) + _ = EdgeSweeperTeam(name=team) + + # define and sweep with initial marching sweepers to approx. grid center + headings = offset + np.linspace(0, 360, num=arms, endpoint=False, dtype=float) + directions = [versor(deg=h) for h in headings] + bess = [BinaryEdgeSweeper(origin=center, heading=d, team=team) for d in directions] + for bes in bess: + bes.sweep() + + # for each order, create a new generation of beam sweepers and sweep + def bisectors(sweepers: list[BinaryEdgeSweeper]) -> list[BinaryEdgeSweeper]: + new = [a.breed(b) for a, b in pairwise(sweepers, closed=True)] + for ns in new: + ns.sweep() + return new + + for _ in range(1, order): + bess = list(chain.from_iterable(zip(bess, bisectors(bess)))) + return np.vstack([bes.position for bes in bess]) # Nx2 diff --git a/src/instamatic/grid/window.py b/src/instamatic/grid/window.py index e111158e..13061216 100644 --- a/src/instamatic/grid/window.py +++ b/src/instamatic/grid/window.py @@ -1,16 +1,13 @@ from __future__ import annotations from abc import ABC, abstractmethod -from itertools import chain -from typing import Literal, Optional, Union +from typing import Optional, Union import numpy as np -from scipy.optimize import minimize from typing_extensions import Self from instamatic._typing import float_nm from instamatic.controller import TEMController, _ctrl, initialize -from instamatic.grid.sweepers import BinaryEdgeSweeper, EdgeSweeperTeam, MarchingEdgeSweeper from instamatic.utils.iterating import pairwise if not _ctrl: @@ -161,50 +158,6 @@ def edge_residuals(self, xys: np.ndarray) -> np.ndarray: return np.min(dists, axis=1) - @classmethod - def from_sweeping(cls, order: Literal[1, 2, 3, 4, 5] = 3) -> Self: - """Return new using `EdgeSweeper`s scanning around current position.""" - origin = np.array(_ctrl.stage.xy, dtype=int) - team = str(origin) - _ = EdgeSweeperTeam(name=team) - - # define and sweep with initial marching sweepers to approx. grid center - dirs = [+X, +Y, -X, -Y] - mess = [MarchingEdgeSweeper(origin=origin, heading=d, team=team) for d in dirs] - for mes in mess: - mes.sweep() - center_x = (mess[0].position[0] + mess[2].position[0]) / 2 - center_y = (mess[1].position[1] + mess[3].position[1]) / 2 - center = np.array([center_x, center_y], dtype=float) - - # define binary sweepers, step to edge of marchers-probed region & sweep - mess_position_pairs = list(pairwise([mes.position for mes in mess], closed=True)) - bess = [BinaryEdgeSweeper(origin=center, heading=d, team=team) for d in (X, Y, -X, -Y)] - for bes in bess: - dists = [bes.dist2segment(*p1, *p2) for p1, p2 in mess_position_pairs] - safe_dist = min(dists) - bes.team.step_size - if np.isfinite(safe_dist) and safe_dist > 0: - bes.step(safe_dist) - bes.sweep() - - # for each order, create a new generation of beam sweepers and sweep - def bisectors(sweepers: list[BinaryEdgeSweeper]) -> list[BinaryEdgeSweeper]: - new = [a.breed(b) for a, b in pairwise(sweepers, closed=True)] - for ns in new: - ns.sweep() - return new - - for _ in range(1, order): - bess = list(chain.from_iterable(zip(bess, bisectors(bess)))) - - edge_xy = np.vstack([bes.position for bes in bess]) # Nx2 - return cls.from_edge_xys(edge_xy) - - # @classmethod - # @abstractmethod - # def from_edge_xys(cls, edge_xys: np.ndarray) -> Self: ... - # TODO: implement non-abstract based on the geometry implementation w/ no spacing - def intersects_limits(self, x: float_nm, y: float_nm) -> bool: """Test whether the window intersects the box [-x, x] x [-y, y]. To this aim, in seven consecutive blocks: From 7f01f23153c913cd6a58fe2a6ac12ba46ba04864 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Fri, 20 Mar 2026 18:42:40 +0100 Subject: [PATCH 069/118] Add abstraction logic to the state: region and line for multiple scans --- .../experiments/scan_ed/encoding.py | 10 + .../experiments/scan_ed/experiment.py | 51 ++-- .../experiments/scan_ed/progress.py | 248 +++++++++++------- src/instamatic/experiments/scan_ed/state.py | 128 ++++++--- 4 files changed, 282 insertions(+), 155 deletions(-) diff --git a/src/instamatic/experiments/scan_ed/encoding.py b/src/instamatic/experiments/scan_ed/encoding.py index 172a91bb..f7971ed4 100644 --- a/src/instamatic/experiments/scan_ed/encoding.py +++ b/src/instamatic/experiments/scan_ed/encoding.py @@ -24,3 +24,13 @@ def encode_i16(a: np.ndarray) -> str: def decode_i16(s: str) -> np.ndarray: raw = base64.b64decode(s.encode('ascii')) return np.frombuffer(raw, dtype=np.int16) + + +def encode_u32(a: np.ndarray) -> str: + a = np.asarray(a, dtype=np.uint32) + return base64.b64encode(a.tobytes()).decode('ascii') + + +def decode_u32(s: str) -> np.ndarray: + raw = base64.b64decode(s.encode('ascii')) + return np.frombuffer(raw, dtype=np.uint32) diff --git a/src/instamatic/experiments/scan_ed/experiment.py b/src/instamatic/experiments/scan_ed/experiment.py index d123f486..a3a4e5dd 100644 --- a/src/instamatic/experiments/scan_ed/experiment.py +++ b/src/instamatic/experiments/scan_ed/experiment.py @@ -139,6 +139,7 @@ def start_collection(self, **params) -> None: try: for region_idx in count(): windows_idx = self.region_members(cluster_idx=region_idx) + self.state.add_region(region_idx, windows_idx) for window_idx in windows_idx: if window_idx not in self.state.intercepts: try: @@ -201,29 +202,35 @@ def add_scans(self, region_idx: int) -> None: _, _, total_delay = self.determine_timing(step) error_margin = max(step * total_delay / p['scan_exposure'], 0) - scan_dirs = cycle([1] if 'raster' in p['scan_geometry'] else [1, -1]) + # prepare the limits to be scanned over the slow axis slow_min = np.min(w.corners[:, 1 - axis] for w in windows) slow_max = np.max(w.corners[:, 1 - axis] for w in windows) slows = np.arange(slow_min + spacing, slow_max, spacing, dtype=int) - for scan_id, slow in enumerate(slows): - # TODO: incorporate variable tilt (as different scans or new index) - # since it's float, likely better as variable, then series = local - fast_scans = np.array([getattr(w, scan_factory)(slow) for w in windows]) - fast_min = np.min(fast_scans) - fast_max = np.max(fast_scans) - fast_min -= error_margin - fast_max += error_margin - direction = next(scan_dirs) - fast_start, fast_stop = [fast_min, fast_max][::direction] - self.state.add_scan( - region=int(region_idx), - scan=int(scan_id), - x0=int(slow if axis else fast_start), - y0=int(fast_start if axis else slow), - axis=int(axis), - step=int(step * direction), - n_steps=int(np.ceil(abs((fast_stop - fast_start) / step))), - ) + + # In raster mode, scans are added line after line, tilt after tilt: + # l# - line, t# - tilt, > - direction: l1t1> l1t2> l1t3> l2t1> l2t2> ... + # if serpentine, lines are paired, so all scans along a line share dir: + # l1t1> l2t1< l1t2> l2t2< l1t3> l2t3< ... l3t1> l4t1< l3t2> l4t2< ... + + for scan_id, tilt in enumerate(self.tilt_list()): + scan_dirs = cycle([1] if 'raster' in p['scan_geometry'] else [1, -1]) + for line_id, slow in enumerate(slows): + shared_id = {'region': int(region_idx), 'line': int(line_id)} + fasts = np.array([getattr(w, scan_factory)(slow) for w in windows]) + fast_min = np.min(fasts) - error_margin + fast_max = np.max(fasts) + error_margin + direction = next(scan_dirs) + fast_start, fast_stop = [fast_min, fast_max][::direction] + if tuple(shared_id.values()) not in self.state.lines.index: + self.state.add_line( + x0=int(slow if axis else fast_start), + y0=int(fast_start if axis else slow), + axis=int(axis), + step=int(step * direction), + n_steps=int(np.ceil(abs((fast_stop - fast_start) / step))), + **shared_id, + ) + self.state.add_scan(scan=int(scan_id), tilt=tilt, **shared_id) def determine_grid_manually(self) -> tuple[PeriodicConvexPolygonGridGeometry, dict]: grid = self.state.grid @@ -329,9 +336,9 @@ def run_scan(self, region_idx: int, scan_idx: int) -> None: idx = pd.IndexSlice[region_idx, scan_idx, :] if np.any(self.state.steps.loc[idx, 'n_peaks'] != -1): return # none-op for a scans that has been already done - n_frames = int(self.state.scans.loc[(region_idx, scan_idx), 'n_steps']) + n_frames = int(self.state.lines.loc[(region_idx, scan_idx), 'n_steps']) - scan = self.state.scans.loc[(region_idx, scan_idx)] + scan = self.state.lines.loc[(region_idx, scan_idx)] self.ctrl.stage.set(x=scan['x0'], y=scan['y0']) self.dispatcher.begin_scan(n_frames, name=f'r{region_idx:03d}_s{scan_idx:06d}') diff --git a/src/instamatic/experiments/scan_ed/progress.py b/src/instamatic/experiments/scan_ed/progress.py index 2f3a1366..cdf13ca3 100644 --- a/src/instamatic/experiments/scan_ed/progress.py +++ b/src/instamatic/experiments/scan_ed/progress.py @@ -6,7 +6,7 @@ import tkinter.ttk as ttk from collections import Counter from functools import wraps -from typing import Any, Callable, Protocol, Sequence, Union +from typing import Any, Callable, Optional, Protocol, Sequence, Union import numpy as np @@ -15,6 +15,12 @@ class GridWindowProtocol(Protocol): def __repr__(self) -> str: ... +def new_counter(**kwargs): + """A new counter to sum current hit, peak, step, and total step count.""" + starting_dict = {'hits': 0, 'peaks': 0, 'steps': 0, 'n_steps': 0} | kwargs + return Counter(**starting_dict) + + def safe_ratio(d: dict, k1: str, k2: str, alt: str = '0.0') -> str: """Return a formatted d1-to-d2 ratio if defined, else hyphen.""" return f'{d[k1] / v2:.3g}' if (v2 := d[k2]) else alt @@ -23,15 +29,17 @@ def safe_ratio(d: dict, k1: str, k2: str, alt: str = '0.0') -> str: class ProgressTable(ttk.Frame): """Use a ttk.TreeView to display the progress of scanning experiment.""" - COLUMNS = 'geometry hits peaks steps hits/step peaks/step'.split() + COLUMNS = ('geometry', 'hits', 'peaks', 'steps', 'hit rate') def __init__(self, parent: tk.Misc, **kwargs) -> None: super().__init__(parent, **kwargs) - self.tree = None + self.tree: Optional[ttk.Treeview] = None self._build_tree() - self._scan_geom: dict[tuple[int, int], tuple[int, int, int, int]] = {} - self._scan_totals: dict[tuple[int, int], Counter] = {} # hits, peaks, done, n_steps - self._window_totals: dict[int, Counter] = {} # hits, peaks, steps + + self._line_geom: dict[tuple[int, int], tuple[int, int, int, int, int]] = {} + self._region_totals: dict[int, Counter] = {} + self._line_totals: dict[tuple[int, int], Counter] = {} + self._scan_totals: dict[tuple[int, int, int], Counter] = {} def _build_tree(self) -> None: self.tree = ttk.Treeview(self, columns=self.COLUMNS, show='tree headings') @@ -44,8 +52,7 @@ def _build_tree(self) -> None: self.tree.column('hits', anchor=tk.E, width=20) self.tree.column('peaks', anchor=tk.E, width=20) self.tree.column('steps', anchor=tk.E, width=20) - self.tree.column('hits/step', anchor=tk.E, width=20) - self.tree.column('peaks/step', anchor=tk.E, width=20) + self.tree.column('hit rate', anchor=tk.E, width=20) vsb = ttk.Scrollbar(orient='vertical', command=self.tree.yview) self.tree.configure(yscrollcommand=vsb.set) @@ -55,138 +62,175 @@ def _build_tree(self) -> None: self.grid_rowconfigure(0, weight=1) @staticmethod - def _window_iid(window: int) -> str: - return f'w:{window}' + def _region_iid(region: int) -> str: + return f'r:{region}' @staticmethod - def _scan_iid(window: int, scan: int) -> str: - return f'w:{window}/s:{scan}' + def _line_iid(region: int, line: int) -> str: + return f'r:{region}/l:{line}' @staticmethod - def _step_iid(window: int, scan: int, step: int) -> str: - return f'w:{window}/s:{scan}/p:{step}' + def _scan_iid(region: int, line: int, scan: int) -> str: + return f'r:{region}/l:{line}/s:{scan}/' - def add_window(self, idx: int, window: GridWindowProtocol) -> None: - """Add a new parent line to the tree called Window #.""" - window_iid = self._window_iid(idx) - window_name = f'Window {idx:d}' - values = (str(window), '-', '-', '-', '-', '-') - self.tree.insert('', tk.END, iid=window_iid, text=window_name, values=values) - self._window_totals[idx] = Counter() - - def add_scan( + @staticmethod + def _step_iid(region: int, line: int, scan: int, step: int) -> str: + return f'r:{region}/l:{line}/s:{scan}/p:{step}' + + def add_region(self, region: int, windows: Sequence[int]) -> None: + """Add a new parent line called Region # with window information.""" + region_iid = self._region_iid(region) + region_name = f'Region {region:d}' + geometry = 'Windows: ' + ' '.join(str(w) for w in windows) + values = (geometry, '-', '-', '-', '-', '-') + self.tree.insert('', tk.END, iid=region_iid, text=region_name, values=values) + self._region_totals[region] = new_counter() + + def add_line( self, - window: int, - scan: int, + region: int, + line: int, x0: int, y0: int, axis: int, step: int, n_steps: int, ) -> None: - """Add a new child scan line to the tree called Scan # (planned).""" - window_iid = self._window_iid(window) - scan_iid = self._scan_iid(window, scan) - scan_name = f'Scan {scan:d}' + """Add a new line under region for "line" geometry called Line #.""" + region_iid = self._region_iid(region) + line_iid = self._line_iid(region, line) + line_name = f'Line {line}' start = (x0, y0)[axis] end = start + step * n_steps - - if axis == 0: # x + if axis == 0: geom = f'y={y0}, x: {start} -> {end}' else: geom = f'x={x0}, y: {start} -> {end}' - values = (geom, '-', '-', str(int(n_steps)), '-', '-') - self.tree.insert(window_iid, tk.END, iid=scan_iid, text=scan_name, values=values) + v = (geom, '-', '-', f'0/{n_steps}', '-') + self.tree.insert(region_iid, tk.END, iid=line_iid, text=line_name, values=v) + self._line_geom[(region, line)] = (x0, y0, axis, step, n_steps) + self._line_totals[(region, line)] = new_counter() - self._scan_geom[(window, scan)] = (x0, y0, axis, step) - self._scan_totals[(window, scan)] = Counter(n_steps=int(n_steps)) - self.tree.set(scan_iid, 'steps', f'0/{int(n_steps)}') - - def mark_processing(self, window: int, scan: int, step: int) -> None: - scan_iid = self._scan_iid(window, scan) - for column in 'hits peaks hits/step peaks/step'.split(): + def add_scan( + self, + region: int, + line: int, + scan: int, + tilt: float, + ) -> None: + """Add a new child scan line to the tree called Scan # (planned).""" + line_iid = self._line_iid(region, line) + scan_iid = self._scan_iid(region, line, scan) + scan_name = f'Scan {scan}' + + x0, y0, axis, step, n_steps = self._line_geom[(region, line)] + v = (f'tilt: {tilt:+6.3f} deg', '0', '0', f'0/{n_steps}', '0.0') + self.tree.insert(line_iid, tk.END, iid=scan_iid, text=scan_name, values=v) + + self._region_totals[region]['n_steps'] += n_steps + self._line_totals[(region, line)]['n_steps'] += n_steps + self._scan_totals[(region, line, scan)] = new_counter(n_steps=n_steps) + self._update_totals_display(region, line, scan) + + def mark_processing(self, region: int, line: int, scan: int, *_) -> None: + scan_iid = self._scan_iid(region, line, scan) + for column in ['hits', 'peaks', 'hit rate']: if not self.tree.set(scan_iid, column).isnumeric(): # don't overwrite numbers self.tree.set(scan_iid, column, '...') - def fill_step(self, window: int, scan: int, step: int, hit: bool, n_peaks: int) -> None: - scan_iid = self._scan_iid(window, scan) - window_iid = self._window_iid(window) - - st = self._scan_totals[(window, scan)] - st['done'] += 1 - if hit: - st['hits'] += 1 - st['peaks'] += int(n_peaks) - - self.tree.set(scan_iid, 'hits', str(st['hits'])) - self.tree.set(scan_iid, 'peaks', str(st['peaks'])) - self.tree.set(scan_iid, 'steps', f'{st["done"]}/{st["n_steps"]}') - self.tree.set(scan_iid, 'hits/step', safe_ratio(st, 'hits', 'done')) - self.tree.set(scan_iid, 'peaks/step', safe_ratio(st, 'peaks', 'done')) - - wt = self._window_totals[window] - wt['steps'] += 1 - if hit: - wt['hits'] += 1 - wt['peaks'] += int(n_peaks) - - self.tree.set(window_iid, 'hits', str(wt['hits'])) - self.tree.set(window_iid, 'peaks', str(wt['peaks'])) - self.tree.set(window_iid, 'steps', str(wt['steps'])) - self.tree.set(window_iid, 'hits/step', safe_ratio(wt, 'hits', 'steps')) - self.tree.set(window_iid, 'peaks/step', safe_ratio(wt, 'peaks', 'steps')) - - if hit: - x0, y0, axis, step_size = self._scan_geom[(window, scan)] - step_iid = self._step_iid(window, scan, step) - geom = f'{"xy"[axis]}: {(x0, y0)[axis] + step * step_size}' - v = (geom, '', int(n_peaks), '', '', '') - self.tree.insert(scan_iid, tk.END, iid=step_iid, text=f'Step {step}', values=v) + def _update_totals_display(self, region: int, line: int, scan: int) -> None: + self._update_region_totals_display(region=region) + self._update_line_totals_display(region=region, line=line) + self._update_scan_totals_display(region=region, line=line, scan=scan) + + def _update_region_totals_display(self, region: int) -> None: + region_iid = self._region_iid(region) + region_totals = self._region_totals[region] + self._update_row_display(row_iid=region_iid, totals=region_totals) + + def _update_line_totals_display(self, region: int, line: int) -> None: + line_iid = self._line_iid(region, line) + line_totals = self._line_totals[(region, line)] + self._update_row_display(row_iid=line_iid, totals=line_totals) + + def _update_scan_totals_display(self, region: int, line: int, scan: int) -> None: + scan_iid = self._scan_iid(region, line, scan) + scan_totals = self._scan_totals[(region, line, scan)] + self._update_row_display(row_iid=scan_iid, totals=scan_totals) + + def _update_row_display(self, row_iid: str, totals: Counter) -> None: + self.tree.set(row_iid, 'hits', str(totals['hits'])) + self.tree.set(row_iid, 'peaks', str(totals['peaks'])) + self.tree.set(row_iid, 'steps', f'{totals["steps"]}/{totals["n_steps"]}') + self.tree.set(row_iid, 'hit rate', safe_ratio(totals, 'hits', 'steps')) + + def fill_step( + self, + region: int, + line: int, + scan: int, + step: int, + hit: bool, + light: int, + n_peaks: int, + ) -> None: + rt = self._region_totals[region] + lt = self._line_totals[(region, line)] + st = self._scan_totals[(region, line, scan)] + for totals_counter in rt, lt, st: + totals_counter['steps'] += 1 + if hit: + totals_counter['hits'] += 1 + totals_counter['peaks'] += int(n_peaks) + self._update_totals_display(region, line, scan) def fill_scan( self, - window: int, + region: int, + line: int, scan: int, - hits: Union[np.ndarray, Sequence[bool]], - n_peaks: Union[np.ndarray, Sequence[int]], + step: int, + hits: bool, + light: int, + n_peaks: int, ) -> None: """Add lines for successful experiments, update scan & column lines.""" - scan_iid = self._scan_iid(window, scan) - window_iid = self._window_iid(window) hits_arr = np.asarray(hits, dtype=bool) peaks_arr = np.asarray(n_peaks, dtype=int) - - s_steps = int(hits_arr.size) - s_hits = int(hits_arr.sum()) - s_peaks = int(peaks_arr[hits_arr].sum()) if s_hits else 0 - - self.tree.set(scan_iid, 'hits', str(s_hits)) - self.tree.set(scan_iid, 'peaks', str(s_peaks)) - self.tree.set(scan_iid, 'steps', str(s_steps)) - self.tree.set(scan_iid, 'hits/step', f'{s_hits / s_steps if s_steps else 0.0:.3g}') - self.tree.set(scan_iid, 'peaks/step', f'{s_peaks / s_steps if s_steps else 0.0:.3g}') - - wt = self._window_totals[window] - wt['hits'] += s_hits - wt['peaks'] += s_peaks - wt['steps'] += s_steps - - self.tree.set(window_iid, 'hits', str(wt['hits'])) - self.tree.set(window_iid, 'peaks', str(wt['peaks'])) - self.tree.set(window_iid, 'steps', str(wt['steps'])) - self.tree.set(window_iid, 'hits/step', safe_ratio(wt, 'hits', 'steps')) - self.tree.set(window_iid, 'peaks/step', safe_ratio(wt, 'peaks', 'steps')) + sum_steps = int(hits_arr.size) + sum_hits = int(hits_arr.sum()) + sum_peaks = int(peaks_arr[hits_arr].sum()) if sum_hits else 0 + + st = self._scan_totals[(region, line, scan)] + old_hits = int(st['hits']) + old_peaks = int(st['peaks']) + old_steps = int(st['steps']) + st['hits'] = sum_hits + st['peaks'] = sum_peaks + st['steps'] = sum_steps + + lt = self._line_totals[(region, line)] + lt['hits'] += sum_hits - old_hits + lt['peaks'] += sum_peaks - old_peaks + lt['steps'] += sum_steps - old_steps + + rt = self._region_totals[region] + rt['hits'] += sum_hits - old_hits + rt['peaks'] += sum_peaks - old_peaks + rt['steps'] += sum_steps - old_steps + + self._update_totals_display(region, line, scan) def clear(self) -> None: """Remove all rows and reset cached totals (e.g. before loading).""" for iid in self.tree.get_children(''): self.tree.delete(iid) - self._scan_geom.clear() + self._line_geom.clear() self._scan_totals.clear() - self._window_totals.clear() + self._region_totals.clear() class ThreadSafeProgressTableProxy: diff --git a/src/instamatic/experiments/scan_ed/state.py b/src/instamatic/experiments/scan_ed/state.py index d6ed3073..bc7a5ccd 100644 --- a/src/instamatic/experiments/scan_ed/state.py +++ b/src/instamatic/experiments/scan_ed/state.py @@ -1,7 +1,8 @@ from __future__ import annotations -from typing import Callable, Optional +from typing import Callable, Optional, Sequence +import numpy as np import pandas as pd from instamatic._collections import NoOverwriteDict @@ -9,7 +10,6 @@ from instamatic.experiments.scan_ed.journal import Journal, edits_journal from instamatic.experiments.scan_ed.progress import ProgressTable, edits_progress from instamatic.grid.geometry import PeriodicConvexPolygonGridGeometry -from instamatic.grid.window import GridablePolygonWindow class State: @@ -27,32 +27,45 @@ def __init__( self.progress: Optional[ProgressTable] = progress self.intercepts: NoOverwriteDict[int, np.ndarray] = NoOverwriteDict(intercepts or {}) + self.lines: pd.DataFrame = pd.DataFrame() self.scans: pd.DataFrame = pd.DataFrame() self.steps: pd.DataFrame = pd.DataFrame() self._init_dataframes() def _init_dataframes(self) -> None: - """Create a new empty history with required index and columns.""" - scan_columns = { + """Create a new empty history with required indices and columns.""" + lines_columns = { 'region': pd.Series(dtype='UInt16'), - 'scan': pd.Series(dtype='UInt16'), + 'line': pd.Series(dtype='UInt16'), 'x0': pd.Series(dtype='Int32'), 'y0': pd.Series(dtype='Int32'), 'axis': pd.Series(dtype='UInt8'), 'step': pd.Series(dtype='Int32'), 'n_steps': pd.Series(dtype='UInt16'), } + self.lines = pd.DataFrame(lines_columns) + self.lines.set_index(['region', 'line'], inplace=True) + + scans_columns = { + 'region': pd.Series(dtype='UInt16'), + 'line': pd.Series(dtype='UInt16'), + 'scan': pd.Series(dtype='UInt8'), + 'tilt': pd.Series(dtype='Float32'), + } + self.scans = pd.DataFrame(scans_columns) + self.scans.set_index(['region', 'line', 'scan'], inplace=True) + steps_columns = { 'region': pd.Series(dtype='UInt16'), - 'scan': pd.Series(dtype='UInt16'), + 'line': pd.Series(dtype='UInt16'), + 'scan': pd.Series(dtype='UInt8'), 'step': pd.Series(dtype='UInt16'), 'hits': pd.Series(dtype='boolean'), + 'light': pd.Series(dtype='UInt32'), 'n_peaks': pd.Series(dtype='Int16'), } - self.scans = pd.DataFrame(scan_columns) - self.scans.set_index(['region', 'scan'], inplace=True) self.steps = pd.DataFrame(steps_columns) - self.steps.set_index(['region', 'scan', 'step'], inplace=True) + self.steps.set_index(['region', 'line', 'scan', 'step'], inplace=True) def load_from_journal(self) -> None: """Recreate an instance of experiment state from journal file.""" @@ -63,16 +76,21 @@ def load_from_journal(self) -> None: getattr(self, method_name)(**kwargs) @edits_journal - def add_intercepts(self, idx: int, intercepts: np.ndarray) -> None: + def add_intercepts(self, window: int, intercepts: np.ndarray) -> None: """Add a Nx2 matrix of intercepts of window idx.""" - self.intercepts[idx] = np.asarray(intercepts, dtype=float) + self.intercepts[window] = np.asarray(intercepts, dtype=float) @edits_journal @edits_progress - def add_scan( + def add_region(self, region: int, windows: Sequence[int]) -> None: + pass + + @edits_journal + @edits_progress + def add_line( self, region: int, - scan: int, + line: int, x0: int, y0: int, axis: int, @@ -80,62 +98,110 @@ def add_scan( n_steps: int, ) -> None: """Append to scans and pre-allocate space in the steps dataframe.""" - scan_cols = ['x0', 'y0', 'axis', 'step', 'n_steps'] - self.scans.loc[(region, scan), scan_cols] = (x0, y0, axis, step, n_steps) - idx_names = ['region', 'scan', 'step'] - idx = pd.MultiIndex.from_product([[region], [scan], range(n_steps)], names=idx_names) + lines_cols = ['x0', 'y0', 'axis', 'step', 'n_steps'] + self.lines.loc[(region, line), lines_cols] = (x0, y0, axis, step, n_steps) + + @edits_journal + @edits_progress + def add_scan( + self, + region: int, + line: int, + scan: int, + tilt: float, + ) -> None: + """Append to scans and pre-allocate space in the steps dataframe.""" + n_steps = self.lines.loc[(region, line)]['n_steps'] + + self.scans.loc[(region, line, scan), 'tilt'] = tilt + + names = ['region', 'line', 'scan', 'step'] + product = [[region], [line], [scan], range(n_steps)] + idx = pd.MultiIndex.from_product(product, names=names) new_scans = { 'hits': np.zeros(n_steps, dtype=np.bool_), + 'light': np.zeros(n_steps, dtype=np.uint32), 'n_peaks': np.full(n_steps, -1, dtype=np.int16), } self.steps = pd.concat([self.steps, pd.DataFrame(new_scans, index=idx)]) - def finalize_scan(self, region: int, scan: int) -> None: + def finalize_scan(self, region: int, line: int, scan: int) -> None: """Converts scan results to an encoded scan, writes it to journal.""" - idx = pd.IndexSlice[region, scan, :] + idx = pd.IndexSlice[region, line, scan, :] n_peaks = self.steps.loc[idx, 'n_peaks'].to_numpy(np.int16, copy=False) if (n_peaks < 0).any(): raise RuntimeError('Scan not complete.') hits = self.steps.loc[idx, 'hits'].to_numpy(np.bool_, copy=False) + light = self.steps.loc[idx, 'light'].to_numpy(np.uint32, copy=False) payload = { 'region': int(region), + 'line': int(line), 'scan': int(scan), 'hits': encode_hits(hits), + 'light': encode_u32(light), 'n_peaks': encode_i16(n_peaks), } self.journal.write('fill_encoded_scan', payload) @edits_progress - def mark_processing(self, region: int, scan: int, step: int) -> None: + def mark_processing(self, region: int, line: int, scan: int, step: int) -> None: """Mark a step as currently processed by setting n_peaks to -2.""" - idx = (region, scan, step) + idx = (region, line, scan, step) if int(self.steps.at[idx, 'n_peaks']) == -1: self.steps.at[idx, 'n_peaks'] = np.int16(-2) @edits_progress - def fill_step(self, region: int, scan: int, step: int, hit: bool, n_peaks: int) -> None: + def fill_step( + self, + region: int, + line: int, + scan: int, + step: int, + hits: bool, + light: int, + n_peaks: int, + ) -> None: """Once the step is processed, set correct hit bool and n_peaks.""" - idx = (region, scan, step) - self.steps.at[idx, 'hits'] = bool(hit) + idx = (region, line, scan, step) + self.steps.at[idx, 'hits'] = bool(hits) + self.steps.at[idx, 'light'] = np.uint32(light) self.steps.at[idx, 'n_peaks'] = np.int16(n_peaks) @edits_progress - def fill_scan(self, region: int, scan: int, hits, n_peaks) -> None: + def fill_scan( + self, + region: int, + line: int, + scan: int, + hits: Sequence[bool], + light: Sequence[int], + n_peaks: Sequence[int], + ) -> None: """An alternative to repeated fill_step, fills whole scan at once.""" - idx = pd.IndexSlice[region, scan, :] + idx = pd.IndexSlice[region, line, scan, :] self.steps.loc[idx, 'hits'] = np.asarray(hits, dtype=np.bool_) + self.steps.loc[idx, 'light'] = np.asarray(light, dtype=np.uint32) self.steps.loc[idx, 'n_peaks'] = np.asarray(n_peaks, dtype=np.int16) - def fill_encoded_scan(self, region: int, scan: int, hits: str, n_peaks: str) -> None: + def fill_encoded_scan( + self, + region: int, + line: int, + scan: int, + hits: str, + light: str, + n_peaks: str, + ) -> None: """Called directly ONLY during replay when recreating from journal.""" - n_steps = int(self.scans.loc[(region, scan), 'n_steps']) + n_steps = int(self.lines.loc[(region, line), 'n_steps']) hits_arr = decode_hits(hits, n_steps) + light_arr = decode_u32(light) peaks_arr = decode_i16(n_peaks) if peaks_arr.size != n_steps: raise ValueError(f'Corrupt scan payload: {peaks_arr.size=} != {n_steps=}') - self.fill_scan(region, scan, hits_arr, peaks_arr) + self.fill_scan(region, line, scan, hits_arr, light_arr, peaks_arr) def has_any_scans(self, region: int) -> bool: """Returns True if region has any defined scans with any status.""" @@ -143,11 +209,11 @@ def has_any_scans(self, region: int) -> bool: return len(self.scans) > 0 and k in i.names and (i.get_level_values(k) == region).any() def untouched_scans(self, region: Optional[int] = None) -> pd.MultiIndex: - """An iterable of (region, scan)-idx of planned-but-untouched scans.""" + """Iterable of (region, line, scan) of planned-but-untouched scans.""" n_peaks = self.steps['n_peaks'] if region is not None: n_peaks = n_peaks.xs(region, level='region', drop_level=False) - untouched = n_peaks.eq(-1).groupby(level=['region', 'scan']).all() + untouched = n_peaks.eq(-1).groupby(level=['region', 'line', 'scan']).all() return untouched[untouched].index @edits_journal From ad5e9b710de089ff6aa39661e4c24cbdfdd38883 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Mon, 23 Mar 2026 12:34:16 +0100 Subject: [PATCH 070/118] Fix issues, integrate dispatch and detection, add light handling (no correction yet) --- .../experiments/scan_ed/detection.py | 2 ++ .../experiments/scan_ed/dispatch.py | 6 ++-- .../experiments/scan_ed/experiment.py | 33 ++++++++++--------- .../experiments/scan_ed/progress.py | 4 +-- 4 files changed, 24 insertions(+), 21 deletions(-) diff --git a/src/instamatic/experiments/scan_ed/detection.py b/src/instamatic/experiments/scan_ed/detection.py index 79a8e5e4..1730e0f1 100644 --- a/src/instamatic/experiments/scan_ed/detection.py +++ b/src/instamatic/experiments/scan_ed/detection.py @@ -33,6 +33,7 @@ class DiffHuntResults: bin_edges: Optional[Sequence[float]] = None peaks: Optional[np.ndarray] = None mask: Optional[np.ndarray] = None + light: int = 0 def ring_percentile_detection( @@ -115,6 +116,7 @@ def ring_percentile_detection( bin_edges=bin_edges, peaks=peaks, mask=valid, + light=int(np.sum(frame, axis=None)), ) diff --git a/src/instamatic/experiments/scan_ed/dispatch.py b/src/instamatic/experiments/scan_ed/dispatch.py index de80bc4a..c050d749 100644 --- a/src/instamatic/experiments/scan_ed/dispatch.py +++ b/src/instamatic/experiments/scan_ed/dispatch.py @@ -149,7 +149,7 @@ def write_scan(self, path: AnyPath) -> None: kwargs = {'path': path, 'header': self.headers[pointer]} self.emit('WRITE', buffer_name=bn, buffer_pointer=pointer, kwargs=kwargs) - def handle_feedback(self, state: State, region: int, scan: int) -> None: + def handle_feedback(self, state: State, region: int, line: int, scan: int) -> None: """Continuously drain the feedback queue until scan is fully processed. This call modifies a decorated State table. Therefore, either it @@ -165,11 +165,11 @@ def handle_feedback(self, state: State, region: int, scan: int) -> None: pointer = int(fb.buffer_pointer) if fb.kind == 'PROCESSING': - state.mark_processing(region, scan, pointer) + state.mark_processing(region, line, scan, pointer) elif fb.kind == 'PROCESSED': d: DiffHuntResults = fb.details - state.fill_step(region, scan, pointer, d.success, len(d.peaks)) + state.fill_step(region, line, scan, pointer, d.success, d.light, len(d.peaks)) if self.hits is not None: self.hits[pointer] = d.success self._in_flight.discard(pointer) diff --git a/src/instamatic/experiments/scan_ed/experiment.py b/src/instamatic/experiments/scan_ed/experiment.py index a3a4e5dd..115e0d28 100644 --- a/src/instamatic/experiments/scan_ed/experiment.py +++ b/src/instamatic/experiments/scan_ed/experiment.py @@ -159,8 +159,8 @@ def start_collection(self, **params) -> None: # once region is located, add and run the scans over it if not self.state.has_any_scans(region_idx): self.add_scans(region_idx=region_idx) - for _, scan_idx in self.state.untouched_scans(region=region_idx): - self.run_scan(region_idx, scan_idx) + for _, line_idx, scan_idx in self.state.untouched_scans(region=region_idx): + self.run_scan(region_idx, line_idx, scan_idx) self.set_stop_event_if_target_met() if params['stop_event'].is_set(): break @@ -207,11 +207,7 @@ def add_scans(self, region_idx: int) -> None: slow_max = np.max(w.corners[:, 1 - axis] for w in windows) slows = np.arange(slow_min + spacing, slow_max, spacing, dtype=int) - # In raster mode, scans are added line after line, tilt after tilt: - # l# - line, t# - tilt, > - direction: l1t1> l1t2> l1t3> l2t1> l2t2> ... - # if serpentine, lines are paired, so all scans along a line share dir: - # l1t1> l2t1< l1t2> l2t2< l1t3> l2t3< ... l3t1> l4t1< l3t2> l4t2< ... - + # Scan the region at every tilt, going along the same line each time for scan_id, tilt in enumerate(self.tilt_list()): scan_dirs = cycle([1] if 'raster' in p['scan_geometry'] else [1, -1]) for line_id, slow in enumerate(slows): @@ -330,20 +326,25 @@ def set_stop_event_if_target_met(self) -> None: if time_passed > time_target or hits_found > hits_target: self.params['stop_event'].set() - def run_scan(self, region_idx: int, scan_idx: int) -> None: + def run_scan(self, region_idx: int, line_idx: int, scan_idx: int) -> None: """Run a single scan previously added to state on the grid.""" - idx = pd.IndexSlice[region_idx, scan_idx, :] + idx = pd.IndexSlice[region_idx, line_idx, scan_idx, :] if np.any(self.state.steps.loc[idx, 'n_peaks'] != -1): return # none-op for a scans that has been already done n_frames = int(self.state.lines.loc[(region_idx, scan_idx), 'n_steps']) - scan = self.state.lines.loc[(region_idx, scan_idx)] - self.ctrl.stage.set(x=scan['x0'], y=scan['y0']) + line = self.state.lines.loc[(region_idx, line_idx)] + self.ctrl.stage.set(x=line['x0'], y=line['y0']) + + scan = self.state.scans.loc[(region_idx, line_idx, scan_idx)] + if abs(self.ctrl.stage.a - scan['tilt']) > 0.05: # epsilon: + self.ctrl.stage.a = scan['tilt'] - self.dispatcher.begin_scan(n_frames, name=f'r{region_idx:03d}_s{scan_idx:06d}') - fb_kwargs = {'state': self.state, 'region': region_idx, 'scan': scan_idx} - fb_thread = Thread(target=self.dispatcher.handle_feedback, kwargs=fb_kwargs) + name = f'r{region_idx:03d}_l{line_idx:06d}_s{scan_idx:03d}' + self.dispatcher.begin_scan(n_frames, name=name) + kw = {'state': self.state, 'region': region_idx, 'line': line_idx, 'scan': scan_idx} + fb_thread = Thread(target=self.dispatcher.handle_feedback, kwargs=kw) fb_thread.start() exposure, speed, _ = self.determine_timing(scan['step']) @@ -361,9 +362,9 @@ def run_scan(self, region_idx: int, scan_idx: int) -> None: fb_thread.join() self.dispatcher.write_scan(path=self.path / 'tiff') - self.dispatcher.handle_feedback(self.state, region_idx, scan_idx) + self.dispatcher.handle_feedback(self.state, region_idx, line_idx, scan_idx) self.dispatcher.end_scan() - self.state.finalize_scan(region_idx, scan_idx) + self.state.finalize_scan(region_idx, line_idx, scan_idx) self.ctrl.stage.wait() def teardown(self) -> None: diff --git a/src/instamatic/experiments/scan_ed/progress.py b/src/instamatic/experiments/scan_ed/progress.py index cdf13ca3..fd0b0fab 100644 --- a/src/instamatic/experiments/scan_ed/progress.py +++ b/src/instamatic/experiments/scan_ed/progress.py @@ -82,7 +82,7 @@ def add_region(self, region: int, windows: Sequence[int]) -> None: region_iid = self._region_iid(region) region_name = f'Region {region:d}' geometry = 'Windows: ' + ' '.join(str(w) for w in windows) - values = (geometry, '-', '-', '-', '-', '-') + values = (geometry, '-', '-', '-', '-') self.tree.insert('', tk.END, iid=region_iid, text=region_name, values=values) self._region_totals[region] = new_counter() @@ -125,7 +125,7 @@ def add_scan( scan_iid = self._scan_iid(region, line, scan) scan_name = f'Scan {scan}' - x0, y0, axis, step, n_steps = self._line_geom[(region, line)] + _, _, _, _, n_steps = self._line_geom[(region, line)] v = (f'tilt: {tilt:+6.3f} deg', '0', '0', f'0/{n_steps}', '0.0') self.tree.insert(line_iid, tk.END, iid=scan_iid, text=scan_name, values=v) From 05dcd2f465d1accab5400dd7fd5b8a82cda551d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Wed, 25 Mar 2026 15:01:50 +0100 Subject: [PATCH 071/118] Implement, extract defining, fitting to scan profile as a function of grid geometry --- .../experiments/scan_ed/experiment.py | 33 ++++++++-- src/instamatic/experiments/scan_ed/profile.py | 64 +++++++++++++++++++ src/instamatic/experiments/scan_ed/state.py | 12 +++- 3 files changed, 100 insertions(+), 9 deletions(-) create mode 100644 src/instamatic/experiments/scan_ed/profile.py diff --git a/src/instamatic/experiments/scan_ed/experiment.py b/src/instamatic/experiments/scan_ed/experiment.py index 115e0d28..dfed5761 100644 --- a/src/instamatic/experiments/scan_ed/experiment.py +++ b/src/instamatic/experiments/scan_ed/experiment.py @@ -6,6 +6,7 @@ from threading import Thread from typing import TYPE_CHECKING, Any, Iterator +import numpy as np import pandas as pd from instamatic.calibrate import CalibMovieDelays @@ -14,10 +15,15 @@ from instamatic.experiments.fast_adt.experiment import FastADTMissingCalibError from instamatic.experiments.scan_ed.dispatch import DiffHuntDispatcher from instamatic.experiments.scan_ed.journal import Journal +from instamatic.experiments.scan_ed.profile import ScanProfile from instamatic.experiments.scan_ed.progress import ProgressTable from instamatic.experiments.scan_ed.state import State from instamatic.grid.artist import plot -from instamatic.grid.geometry import GRID_REGISTRY, PeriodicConvexPolygonGridGeometry +from instamatic.grid.geometry import ( + GRID_REGISTRY, + PeriodicConvexPolygonGridGeometry, + WindowType, +) from instamatic.grid.sweeping import star_sweep from instamatic.gui.click_dispatcher import ClickListener, MouseButton @@ -190,12 +196,10 @@ def add_scans(self, region_idx: int) -> None: if p['scan_geometry'].lower().startswith('x'): axis = 0 - scan_factory = 'x_intersections' step = p['scan_x_step'] spacing = p['scan_y_step'] else: # params['scan_geometry'].lower().startswith('y'): axis = 1 - scan_factory = 'y_intersections' step = p['scan_y_step'] spacing = p['scan_x_step'] @@ -212,9 +216,8 @@ def add_scans(self, region_idx: int) -> None: scan_dirs = cycle([1] if 'raster' in p['scan_geometry'] else [1, -1]) for line_id, slow in enumerate(slows): shared_id = {'region': int(region_idx), 'line': int(line_id)} - fasts = np.array([getattr(w, scan_factory)(slow) for w in windows]) - fast_min = np.min(fasts) - error_margin - fast_max = np.max(fasts) + error_margin + scan_profile = ScanProfile(windows=windows, **{'xy'[axis]: slow}) + fast_min, fast_max = scan_profile.envelope(margin=error_margin) direction = next(scan_dirs) fast_start, fast_stop = [fast_min, fast_max][::direction] if tuple(shared_id.values()) not in self.state.lines.index: @@ -364,7 +367,23 @@ def run_scan(self, region_idx: int, line_idx: int, scan_idx: int) -> None: self.dispatcher.write_scan(path=self.path / 'tiff') self.dispatcher.handle_feedback(self.state, region_idx, line_idx, scan_idx) self.dispatcher.end_scan() - self.state.finalize_scan(region_idx, line_idx, scan_idx) + + def finalize_scan(self, region_idx: int, line_idx: int, scan_idx: int) -> None: + """Calculate scan offset, finalize it, save state to journal etc.""" + + windows_idx = self.region_members(cluster_idx=region_idx) + windows = [self.state.grid.window(idx) for idx in windows_idx] + line = self.state.lines.loc[(region_idx, line_idx)] + axis = 'xy'[line['axis']] + fast0 = line['x0'] if axis == 'x' else line['y0'] + slow0 = line['y0'] if axis == 'x' else line['x0'] + scan_profile = ScanProfile(windows=windows, **{axis: slow0}) + + fast = fast0 + (0.5 + np.arange(line['n_steps'])) * line['step'] + light = self.state.steps.loc[(region_idx, line_idx, scan_idx), 'light'] + offset, _ = scan_profile.fit(x=fast, light=light) + + self.state.finalize_scan(region_idx, line_idx, scan_idx, offset=offset) self.ctrl.stage.wait() def teardown(self) -> None: diff --git a/src/instamatic/experiments/scan_ed/profile.py b/src/instamatic/experiments/scan_ed/profile.py new file mode 100644 index 00000000..9d75d6a4 --- /dev/null +++ b/src/instamatic/experiments/scan_ed/profile.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from collections import Counter +from typing import Optional, Sequence, Union + +import numpy as np +from scipy.optimize import curve_fit + +from instamatic._typing import float_nm +from instamatic.grid.geometry import WindowType + + +class ScanProfile: + """Find x or y intersections of windows and yield their properties.""" + + def __init__( + self, + windows: Sequence[WindowType], + *, + x: Optional[float_nm] = None, + y: Optional[float_nm] = None, + ) -> None: + assert Counter([x, y])[None] == 1, 'Exactly one of x or y must be given' + self.var = x if y is None else y + self.method = 'x_intersection' if y is None else 'y_intersection' + self.windows = windows + + self.intersections = [getattr(w, self.method)(self.var) for w in windows] + self.minimum = min(i[0] for i in self.intersections if i is not None) + self.maximum = max(i[1] for i in self.intersections if i is not None) + + def envelope(self, margin: float_nm = 0) -> tuple[float, float]: + return self.minimum - margin, self.maximum + margin + + @staticmethod + def sigmoid( + x: Union[float_nm, np.ndarray], + x0: Union[float_nm, np.ndarray], + width: float = 10.0, + ) -> float: + """A sigmoid that grows from 0 to 1 across ~1 unit (99%) around x0.""" + return 1 / (1 + np.exp(-(x - x0) / width)) + + def window_model( + self, + x: Union[float_nm, np.ndarray], + offset: float_nm, + scale: float, + ) -> Union[float_nm, np.ndarray]: + """Return a model of light at x given y-scaling and x-offset in nm.""" + x_arr = np.atleast_1d(np.asarray(x, dtype=float)) + starts = np.array([i[0] for i in self.intersections if i is not None]) + ends = np.array([i[1] for i in self.intersections if i is not None]) + + s1 = self.sigmoid(x_arr[None, :] - offset, starts[:, None]) + s2 = self.sigmoid(x_arr[None, :] - offset, ends[:, None]) + result = scale * np.sum(s1 - s2, axis=0) + return result.item() if np.isscalar(x) else result + + def fit(self, x: np.ndarray, light: np.ndarray) -> tuple[float_nm, float]: + """X-offset and y-scale that best fit (x, y) data to scan profile.""" + p0 = [0.0, np.percentile(light, 99)] + popt, _ = curve_fit(self.window_model, x, light, p0=p0) # noqa + return popt[0], popt[1] diff --git a/src/instamatic/experiments/scan_ed/state.py b/src/instamatic/experiments/scan_ed/state.py index bc7a5ccd..a36e41c9 100644 --- a/src/instamatic/experiments/scan_ed/state.py +++ b/src/instamatic/experiments/scan_ed/state.py @@ -6,6 +6,7 @@ import pandas as pd from instamatic._collections import NoOverwriteDict +from instamatic._typing import float_nm from instamatic.experiments.scan_ed.encoding import * from instamatic.experiments.scan_ed.journal import Journal, edits_journal from instamatic.experiments.scan_ed.progress import ProgressTable, edits_progress @@ -51,6 +52,7 @@ def _init_dataframes(self) -> None: 'line': pd.Series(dtype='UInt16'), 'scan': pd.Series(dtype='UInt8'), 'tilt': pd.Series(dtype='Float32'), + 'offset': pd.Series(dtype='Float32'), } self.scans = pd.DataFrame(scans_columns) self.scans.set_index(['region', 'line', 'scan'], inplace=True) @@ -125,13 +127,15 @@ def add_scan( } self.steps = pd.concat([self.steps, pd.DataFrame(new_scans, index=idx)]) - def finalize_scan(self, region: int, line: int, scan: int) -> None: + def finalize_scan(self, region: int, line: int, scan: int, offset: float_nm = 0) -> None: """Converts scan results to an encoded scan, writes it to journal.""" idx = pd.IndexSlice[region, line, scan, :] n_peaks = self.steps.loc[idx, 'n_peaks'].to_numpy(np.int16, copy=False) if (n_peaks < 0).any(): raise RuntimeError('Scan not complete.') + self.scans.loc[(region, line, scan), 'offset'] = offset + hits = self.steps.loc[idx, 'hits'].to_numpy(np.bool_, copy=False) light = self.steps.loc[idx, 'light'].to_numpy(np.uint32, copy=False) @@ -139,6 +143,7 @@ def finalize_scan(self, region: int, line: int, scan: int) -> None: 'region': int(region), 'line': int(line), 'scan': int(scan), + 'offset': float(offset), 'hits': encode_hits(hits), 'light': encode_u32(light), 'n_peaks': encode_i16(n_peaks), @@ -175,11 +180,13 @@ def fill_scan( region: int, line: int, scan: int, + offset: float_nm, hits: Sequence[bool], light: Sequence[int], n_peaks: Sequence[int], ) -> None: """An alternative to repeated fill_step, fills whole scan at once.""" + self.scans.loc[(region, line, scan), 'offset'] = offset idx = pd.IndexSlice[region, line, scan, :] self.steps.loc[idx, 'hits'] = np.asarray(hits, dtype=np.bool_) self.steps.loc[idx, 'light'] = np.asarray(light, dtype=np.uint32) @@ -190,6 +197,7 @@ def fill_encoded_scan( region: int, line: int, scan: int, + offset: float_nm, hits: str, light: str, n_peaks: str, @@ -201,7 +209,7 @@ def fill_encoded_scan( peaks_arr = decode_i16(n_peaks) if peaks_arr.size != n_steps: raise ValueError(f'Corrupt scan payload: {peaks_arr.size=} != {n_steps=}') - self.fill_scan(region, line, scan, hits_arr, light_arr, peaks_arr) + self.fill_scan(region, line, scan, offset, hits_arr, light_arr, peaks_arr) def has_any_scans(self, region: int) -> bool: """Returns True if region has any defined scans with any status.""" From ac8863d5083d316235e0216b2dcea1fa97994c85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Thu, 26 Mar 2026 15:50:30 +0100 Subject: [PATCH 072/118] Add a potentially-faulty option to heatmap onto the grid image. --- .../experiments/scan_ed/experiment.py | 16 ++++++ src/instamatic/grid/artist.py | 57 +++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/src/instamatic/experiments/scan_ed/experiment.py b/src/instamatic/experiments/scan_ed/experiment.py index dfed5761..66251b02 100644 --- a/src/instamatic/experiments/scan_ed/experiment.py +++ b/src/instamatic/experiments/scan_ed/experiment.py @@ -167,11 +167,14 @@ def start_collection(self, **params) -> None: self.add_scans(region_idx=region_idx) for _, line_idx, scan_idx in self.state.untouched_scans(region=region_idx): self.run_scan(region_idx, line_idx, scan_idx) + self.finalize_scan(region_idx, line_idx, scan_idx) self.set_stop_event_if_target_met() if params['stop_event'].is_set(): break + self.draw_hits_to_file() finally: self.ctrl.stage.set(a=0) + self.draw_hits_to_file() self.teardown() def region_members(self, cluster_idx: int) -> Iterator[int]: @@ -319,6 +322,19 @@ def draw_grid_to_file(self): fig, ax = plot(self.state.grid, intercepts=self.state.intercepts) fig.savefig(file_path) + def draw_hits_to_file(self): + """Overlay, save a heatmap of hits onto the plot of grid geometry.""" + file_path = self.path / 'windows' / 'heat_all.png' + file_path.parent.mkdir(exist_ok=True, parents=True) + fig, ax = plot( + self.state.grid, + lines=self.state.lines, + scans=self.state.scans, + steps=self.state.steps, + figsize=(10, 10), + ) + fig.savefig(file_path) + def set_stop_event_if_target_met(self) -> None: th: Optional[int] = self.params.get('target_hits', None) tt: Optional[int] = self.params.get('target_time', None) diff --git a/src/instamatic/grid/artist.py b/src/instamatic/grid/artist.py index 9b338b85..5b2e11a0 100644 --- a/src/instamatic/grid/artist.py +++ b/src/instamatic/grid/artist.py @@ -3,6 +3,7 @@ from typing import Optional import numpy as np +import pandas as pd from matplotlib.axes import Axes from matplotlib.figure import Figure from matplotlib.patches import Polygon @@ -19,6 +20,9 @@ def plot( limit_x: Optional[float_nm] = None, limit_y: Optional[float_nm] = None, ax: Optional[Axes] = None, + lines: Optional[pd.DataFrame] = None, + scans: Optional[pd.DataFrame] = None, + steps: Optional[pd.DataFrame] = None, show_indices: bool = True, show_intercepts: bool = False, figsize: tuple[float, float] = (5, 5), @@ -74,6 +78,59 @@ def plot( ax.axhline(0, color='white', linewidth=1.0, alpha=0.6, zorder=0) ax.axvline(0, color='white', linewidth=1.0, alpha=0.6, zorder=0) + # draw imshow with light and hits, if lines, scans, and steps are given + if lines and scans and steps: + slow_idx = 'y0' if (lines['axis'] == 0).all() else 'x0' + slows = lines[slow_idx] + slow_step = (np.maximum(slows) - np.minimum(slows)) / (len(slows) - 1) + slow_min = np.minimum(slows) - 0.5 * slow_step + slow_max = np.maximum(slows) + 0.5 * slow_step + slow_count = len(slows) + + max_offset = scans['offset'].abs().max() + + fast_idx = 'x0' if (lines['axis'] == 0).all() else 'y0' + fast_start = lines[fast_idx] + fast_end = lines[fast_idx] + lines['step'] * lines['n_steps'] + fast_step = lines['step'].abs().mean() + fast_min = np.minimum(fast_start, fast_end).min() - max_offset * fast_step + fast_max = np.maximum(fast_start, fast_end).max() + max_offset * fast_step + fast_count = np.ceil((fast_max - fast_min) / fast_step).astype(int) + + level = ['region', 'line', 'scan'] + hits = {k: g['hits'].to_numpy(dtype=float) for k, g in steps.groupby(level=level)} + + patch = np.zeros(shape=(slow_count, fast_count), dtype=float) + for region, line, line_row in lines.iterrows(): + slow = line_row[slow_idx] + i = int((slow - slow_min) // slow_step) + + step = line_row['step'] + n_steps = line_row['n_steps'] + fast0 = line_row[fast_idx] + fast1 = fast0 + step * n_steps + fast0, fast1 = (fast0, fast1) if fast0 < fast1 else (fast1, fast0) + + sc = scans.loc[(region, line)] + offsets = sc['offset'].to_numpy() + j0s = np.floor((fast0 - fast_min + offsets) / fast_step).astype(int) + + hits_arr = np.stack([hits[(region, line, s)] for s in sc.index], axis=0) + if step < 0: + hits_arr = hits_arr[:, :-1] + for k in range(len(j0s)): + j0 = j0s[k] + patch[i, j0 : j0 + n_steps] += hits_arr[k] + + if fast_idx == 'x0': + x0, x1, y0, y1 = fast_min, fast_max, slow_min, slow_max + else: + x0, x1, y0, y1 = slow_min, slow_max, fast_min, fast_max + patch = patch.T + + a = patch / patch_max if (patch_max := patch.max()) > 0 else patch + plt.imshow(patch, cmap='reds', alpha=a, origin='lower', extent=(x0, x1, y0, y1)) + if limit_x is not None: ax.axvline(-limit_x, color='red', linewidth=1.0, zorder=4) ax.axvline(limit_x, color='red', linewidth=1.0, zorder=4) From a22b5a328862efd7e9a3201334a523ed9161e234 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Thu, 26 Mar 2026 16:00:13 +0100 Subject: [PATCH 073/118] Temporarily, plot heatmaps w alpha=0.5 for debug purposes --- src/instamatic/grid/artist.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/instamatic/grid/artist.py b/src/instamatic/grid/artist.py index 5b2e11a0..cbe47e47 100644 --- a/src/instamatic/grid/artist.py +++ b/src/instamatic/grid/artist.py @@ -128,7 +128,7 @@ def plot( x0, x1, y0, y1 = slow_min, slow_max, fast_min, fast_max patch = patch.T - a = patch / patch_max if (patch_max := patch.max()) > 0 else patch + a = 0.5 + 0.5 * (patch / patch_max) if (patch_max := patch.max()) > 0 else 0.5 plt.imshow(patch, cmap='reds', alpha=a, origin='lower', extent=(x0, x1, y0, y1)) if limit_x is not None: From 3c86c05cc15791995e3b5dee0b5369686d932d5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Thu, 26 Mar 2026 16:40:08 +0100 Subject: [PATCH 074/118] EXport the region-windows logic to separate region.py --- .../experiments/scan_ed/experiment.py | 25 +++++------- src/instamatic/experiments/scan_ed/region.py | 38 +++++++++++++++++++ src/instamatic/grid/artist.py | 1 + 3 files changed, 48 insertions(+), 16 deletions(-) create mode 100644 src/instamatic/experiments/scan_ed/region.py diff --git a/src/instamatic/experiments/scan_ed/experiment.py b/src/instamatic/experiments/scan_ed/experiment.py index 66251b02..793accc4 100644 --- a/src/instamatic/experiments/scan_ed/experiment.py +++ b/src/instamatic/experiments/scan_ed/experiment.py @@ -17,6 +17,7 @@ from instamatic.experiments.scan_ed.journal import Journal from instamatic.experiments.scan_ed.profile import ScanProfile from instamatic.experiments.scan_ed.progress import ProgressTable +from instamatic.experiments.scan_ed.region import Regionalization from instamatic.experiments.scan_ed.state import State from instamatic.grid.artist import plot from instamatic.grid.geometry import ( @@ -58,6 +59,7 @@ def __init__( # attributes initialized once an experiment starts self.params: dict[str, Any] = {} self.dispatcher: Optional[DiffHuntDispatcher] = None + self.regionalization: Optional[Regionalization] = None @property def state(self) -> State: @@ -141,10 +143,14 @@ def start_collection(self, **params) -> None: self.draw_window_to_file(window_idx=window_idx) self.draw_grid_to_file() + # Introduce the logic for grouping windows by regions + rs = params.get('region_shape', '1x1') + self.regionalization = Regionalization.from_str(grid=self.state.grid, shape=rs) + # MAIN LOOP: define new region and request locating all windows in it try: for region_idx in count(): - windows_idx = self.region_members(cluster_idx=region_idx) + windows_idx = self.regionalization.windows(region_idx=region_idx) self.state.add_region(region_idx, windows_idx) for window_idx in windows_idx: if window_idx not in self.state.intercepts: @@ -177,24 +183,11 @@ def start_collection(self, **params) -> None: self.draw_hits_to_file() self.teardown() - def region_members(self, cluster_idx: int) -> Iterator[int]: - """Find windows idx of all windows that belong to region idx.""" - region_size: str = self.params.get('region_size', '1x1') - region_shape = np.array([int(i.strip()) for i in region_size.split('x')], dtype=int) - region_ij = self.state.grid.pairing_inverse(cluster_idx) - - i_span = np.arange(region_shape[0]) - (region_shape[0] - 1) // 2 - j_span = np.arange(region_shape[1]) - (region_shape[1] - 1) // 2 - - window_ij = region_ij * region_shape - for i, j in product(i_span, j_span): - yield self.state.grid.pairing_function(window_ij[0] + i, window_ij[1] + j) - def add_scans(self, region_idx: int) -> None: """Add scans for window, asserting it does not have scans yet.""" p = self.params - windows_idx = self.region_members(cluster_idx=region_idx) + windows_idx = self.regionalization.windows(region_idx=region_idx) windows = [self.state.grid.window(idx) for idx in windows_idx] if p['scan_geometry'].lower().startswith('x'): @@ -387,7 +380,7 @@ def run_scan(self, region_idx: int, line_idx: int, scan_idx: int) -> None: def finalize_scan(self, region_idx: int, line_idx: int, scan_idx: int) -> None: """Calculate scan offset, finalize it, save state to journal etc.""" - windows_idx = self.region_members(cluster_idx=region_idx) + windows_idx = self.regionalization.windows(region_idx=region_idx) windows = [self.state.grid.window(idx) for idx in windows_idx] line = self.state.lines.loc[(region_idx, line_idx)] axis = 'xy'[line['axis']] diff --git a/src/instamatic/experiments/scan_ed/region.py b/src/instamatic/experiments/scan_ed/region.py new file mode 100644 index 00000000..a8a2c62d --- /dev/null +++ b/src/instamatic/experiments/scan_ed/region.py @@ -0,0 +1,38 @@ +"""Keeps the logic about dividing grid windows into separate regions.""" + +from __future__ import annotations + +import re +from itertools import product +from typing import Iterator, Self, Union + +import numpy as np + +from instamatic.grid.geometry import PeriodicConvexPolygonGridGeometry as GridGeometry + + +class Regionalization: + """Dictates the relation between grid windows & regions (their groups).""" + + def __init__( + self, + grid: Union[type[GridGeometry], GridGeometry], + shape: tuple[int, int], + ): + self.grid = grid + self.shape = shape + + @classmethod + def from_str(cls, grid: Union[type[GridGeometry], GridGeometry], shape: str) -> Self: + """Shorthand to convert shape string "MxN" into a tuple.""" + m = re.match(pattern=r'^\s*(\d+)\s*[Xx*,]\s*(\d+)\s*$', string=shape) + return cls(grid, (int(m[1]), int(m[2]))) + + def windows(self, region_idx: int) -> Iterator[int]: + """Yield idx of windows that lie in requested region.""" + region_ij = self.grid.pairing_inverse(region_idx) + i_span = np.arange(self.shape[0]) - (self.shape[0] - 1) // 2 + j_span = np.arange(self.shape[1]) - (self.shape[1] - 1) // 2 + window_ij = region_ij * np.array(self.shape) + for i, j in product(i_span, j_span): + yield self.grid.pairing_function(window_ij[0] + i, window_ij[1] + j) diff --git a/src/instamatic/grid/artist.py b/src/instamatic/grid/artist.py index cbe47e47..895b81df 100644 --- a/src/instamatic/grid/artist.py +++ b/src/instamatic/grid/artist.py @@ -79,6 +79,7 @@ def plot( ax.axvline(0, color='white', linewidth=1.0, alpha=0.6, zorder=0) # draw imshow with light and hits, if lines, scans, and steps are given + # TODO: lines, scans, steps belong strictly to ScanED - move it there if lines and scans and steps: slow_idx = 'y0' if (lines['axis'] == 0).all() else 'x0' slows = lines[slow_idx] From c300654af8cb57339c852a7f5ef9a22b4a88bdcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Thu, 16 Apr 2026 17:39:20 +0200 Subject: [PATCH 075/118] Assert no data is lost when summing light --- src/instamatic/grid/sweeping.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/instamatic/grid/sweeping.py b/src/instamatic/grid/sweeping.py index 26756f07..3acd0fa8 100644 --- a/src/instamatic/grid/sweeping.py +++ b/src/instamatic/grid/sweeping.py @@ -93,7 +93,7 @@ def __init__(self, origin: Vector2, heading: Vector2, team: str = '') -> None: def peak(self) -> int: """Return light (image sum) at current position, update light max.""" - light = int(_ctrl.get_image(header_keys=())[0].sum()) + light = int(_ctrl.get_image(header_keys=())[0].sum(np.int64)) self.team.light_max = max(light, self.team.light_max) return light From eeac1b4eb37109f3a8b510d79a3ebdf689fe4b48 Mon Sep 17 00:00:00 2001 From: Daniel Tchon Date: Thu, 23 Apr 2026 17:02:43 +0200 Subject: [PATCH 076/118] WIP: fixes needed to get SPED almost back to working, fitting does not for some reason --- .../experiments/scan_ed/experiment.py | 20 +++---- src/instamatic/experiments/scan_ed/profile.py | 2 +- .../experiments/scan_ed/progress.py | 13 ++-- src/instamatic/grid/artist.py | 16 ++--- src/instamatic/grid/geometry.py | 60 +++++++++++++++++-- src/instamatic/grid/sweeping.py | 2 +- 6 files changed, 83 insertions(+), 30 deletions(-) diff --git a/src/instamatic/experiments/scan_ed/experiment.py b/src/instamatic/experiments/scan_ed/experiment.py index 793accc4..3dd0d0cd 100644 --- a/src/instamatic/experiments/scan_ed/experiment.py +++ b/src/instamatic/experiments/scan_ed/experiment.py @@ -135,7 +135,7 @@ def start_collection(self, **params) -> None: if not self.state.intercepts: grid, intercepts = self.determine_grid_manually() self.state.update_grid(grid.to_params()) - for idx, idx_intercepts in intercepts.values(): + for idx, idx_intercepts in intercepts.items(): self.state.add_intercepts(idx, idx_intercepts) # Whenever any new window is added manually, draw it and then whole grid @@ -150,7 +150,7 @@ def start_collection(self, **params) -> None: # MAIN LOOP: define new region and request locating all windows in it try: for region_idx in count(): - windows_idx = self.regionalization.windows(region_idx=region_idx) + windows_idx = list(self.regionalization.windows(region_idx=region_idx)) self.state.add_region(region_idx, windows_idx) for window_idx in windows_idx: if window_idx not in self.state.intercepts: @@ -203,8 +203,8 @@ def add_scans(self, region_idx: int) -> None: error_margin = max(step * total_delay / p['scan_exposure'], 0) # prepare the limits to be scanned over the slow axis - slow_min = np.min(w.corners[:, 1 - axis] for w in windows) - slow_max = np.max(w.corners[:, 1 - axis] for w in windows) + slow_min = np.min([w.corners[:, 1 - axis] for w in windows]) + slow_max = np.max([w.corners[:, 1 - axis] for w in windows]) slows = np.arange(slow_min + spacing, slow_max, spacing, dtype=int) # Scan the region at every tilt, going along the same line each time @@ -359,10 +359,10 @@ def run_scan(self, region_idx: int, line_idx: int, scan_idx: int) -> None: fb_thread = Thread(target=self.dispatcher.handle_feedback, kwargs=kw) fb_thread.start() - exposure, speed, _ = self.determine_timing(scan['step']) - axis = scan['axis'] # x: 0, y: 1 - fast0 = scan['y0' if axis else 'x0'] - fast1 = fast0 + scan['step'] * scan['n_steps'] + exposure, speed, _ = self.determine_timing(line['step']) # loc of 'step' does not work + axis = line['axis'] # x: 0, y: 1 + fast0 = line['y0' if axis else 'x0'] + fast1 = fast0 + line['step'] * line['n_steps'] setter_kwargs = {'xy'[axis]: fast1, 'speed': speed} self.ctrl.stage.set_with_speed(**setter_kwargs, wait=False) @@ -403,8 +403,8 @@ def teardown(self) -> None: def tilt_list(self) -> Sequence[float]: """Return a list of tilts from - to + params[tilt_range] for scans.""" tilt_extent = self.params.get('tilt_extent', 0) - tilt_step = self.params.get('tilt_step', 0) - tilt_count = np.round(2 * tilt_extent / tilt_step) + 1 + tilt_step = self.params.get('tilt_step', 1) + tilt_count = np.round(2 * tilt_extent / tilt_step).astype(int) + 1 return np.linspace(-tilt_extent, tilt_extent, num=tilt_count, endpoint=True) def finalize(self) -> None: diff --git a/src/instamatic/experiments/scan_ed/profile.py b/src/instamatic/experiments/scan_ed/profile.py index 9d75d6a4..b366fc18 100644 --- a/src/instamatic/experiments/scan_ed/profile.py +++ b/src/instamatic/experiments/scan_ed/profile.py @@ -22,7 +22,7 @@ def __init__( ) -> None: assert Counter([x, y])[None] == 1, 'Exactly one of x or y must be given' self.var = x if y is None else y - self.method = 'x_intersection' if y is None else 'y_intersection' + self.method = 'x_intersections' if y is None else 'y_intersections' self.windows = windows self.intersections = [getattr(w, self.method)(self.var) for w in windows] diff --git a/src/instamatic/experiments/scan_ed/progress.py b/src/instamatic/experiments/scan_ed/progress.py index fd0b0fab..81824175 100644 --- a/src/instamatic/experiments/scan_ed/progress.py +++ b/src/instamatic/experiments/scan_ed/progress.py @@ -134,7 +134,7 @@ def add_scan( self._scan_totals[(region, line, scan)] = new_counter(n_steps=n_steps) self._update_totals_display(region, line, scan) - def mark_processing(self, region: int, line: int, scan: int, *_) -> None: + def mark_processing(self, region: int, line: int, scan: int, *_, **__) -> None: scan_iid = self._scan_iid(region, line, scan) for column in ['hits', 'peaks', 'hit rate']: if not self.tree.set(scan_iid, column).isnumeric(): # don't overwrite numbers @@ -172,7 +172,7 @@ def fill_step( line: int, scan: int, step: int, - hit: bool, + hits: bool, light: int, n_peaks: int, ) -> None: @@ -181,7 +181,7 @@ def fill_step( st = self._scan_totals[(region, line, scan)] for totals_counter in rt, lt, st: totals_counter['steps'] += 1 - if hit: + if hits: totals_counter['hits'] += 1 totals_counter['peaks'] += int(n_peaks) self._update_totals_display(region, line, scan) @@ -264,8 +264,11 @@ def _post(self, name: str, *args, **kwargs) -> None: self._schedule() # Keep the API fixed and consistent, generalizing this is annoying - def add_intercepts(self, **kwargs): - self._post('add_window', **kwargs) + def add_region(self, **kwargs): + self._post('add_region', **kwargs) + + def add_line(self, **kwargs): + self._post('add_line', **kwargs) def add_scan(self, **kwargs): self._post('add_scan', **kwargs) diff --git a/src/instamatic/grid/artist.py b/src/instamatic/grid/artist.py index 895b81df..befa2f8b 100644 --- a/src/instamatic/grid/artist.py +++ b/src/instamatic/grid/artist.py @@ -2,13 +2,13 @@ from typing import Optional +import matplotlib.pyplot as plt import numpy as np import pandas as pd from matplotlib.axes import Axes from matplotlib.figure import Figure from matplotlib.patches import Polygon from matplotlib.ticker import FuncFormatter - from instamatic._typing import float_nm from instamatic.grid.geometry import PeriodicConvexPolygonGridGeometry @@ -80,12 +80,12 @@ def plot( # draw imshow with light and hits, if lines, scans, and steps are given # TODO: lines, scans, steps belong strictly to ScanED - move it there - if lines and scans and steps: + if all(x is not None and not x.empty for x in [lines, scans, steps]): slow_idx = 'y0' if (lines['axis'] == 0).all() else 'x0' slows = lines[slow_idx] - slow_step = (np.maximum(slows) - np.minimum(slows)) / (len(slows) - 1) - slow_min = np.minimum(slows) - 0.5 * slow_step - slow_max = np.maximum(slows) + 0.5 * slow_step + slow_step = (np.max(slows) - np.min(slows)) / (len(slows) - 1) + slow_min = np.min(slows) - 0.5 * slow_step + slow_max = np.max(slows) + 0.5 * slow_step slow_count = len(slows) max_offset = scans['offset'].abs().max() @@ -93,7 +93,7 @@ def plot( fast_idx = 'x0' if (lines['axis'] == 0).all() else 'y0' fast_start = lines[fast_idx] fast_end = lines[fast_idx] + lines['step'] * lines['n_steps'] - fast_step = lines['step'].abs().mean() + fast_step = lines['step'].abs().mean() # TODO fails of zero steps fast_min = np.minimum(fast_start, fast_end).min() - max_offset * fast_step fast_max = np.maximum(fast_start, fast_end).max() + max_offset * fast_step fast_count = np.ceil((fast_max - fast_min) / fast_step).astype(int) @@ -102,7 +102,7 @@ def plot( hits = {k: g['hits'].to_numpy(dtype=float) for k, g in steps.groupby(level=level)} patch = np.zeros(shape=(slow_count, fast_count), dtype=float) - for region, line, line_row in lines.iterrows(): + for (region, line), line_row in lines.iterrows(): slow = line_row[slow_idx] i = int((slow - slow_min) // slow_step) @@ -130,7 +130,7 @@ def plot( patch = patch.T a = 0.5 + 0.5 * (patch / patch_max) if (patch_max := patch.max()) > 0 else 0.5 - plt.imshow(patch, cmap='reds', alpha=a, origin='lower', extent=(x0, x1, y0, y1)) + ax.imshow(patch, cmap='reds', alpha=a, origin='lower', extent=(x0, x1, y0, y1)) if limit_x is not None: ax.axvline(-limit_x, color='red', linewidth=1.0, zorder=4) diff --git a/src/instamatic/grid/geometry.py b/src/instamatic/grid/geometry.py index ceb77106..c54e49f9 100644 --- a/src/instamatic/grid/geometry.py +++ b/src/instamatic/grid/geometry.py @@ -1,10 +1,8 @@ from __future__ import annotations -from itertools import count -from typing import Annotated, Generic, Optional, Protocol, Self, Sequence, TypeVar, Union, cast +from typing import Annotated, Generic, Optional, Protocol, Self, TypeVar, Union import numpy as np -from pywinauto.sysinfo import is_x64_OS from scipy.optimize import least_squares from instamatic._collections import NoOverwriteDict @@ -223,6 +221,8 @@ def guess(cls, intercepts: dict[int, np.ndarray]) -> Self: def refine(self, intercepts: dict[int, np.ndarray]) -> None: """Refine self to match the window_id: intercepts dictionary.""" + print(intercepts) + windows = sorted(intercepts.keys()) refine_spacing = len(windows) > 1 fit_h = self.window_type.USES_HEIGHT @@ -265,7 +265,6 @@ def residuals(p: np.ndarray) -> np.ndarray: if refine_spacing: lower.append(0.0) upper.append(np.inf) - res = least_squares( residuals, x0=serialize(self), @@ -273,7 +272,7 @@ def residuals(p: np.ndarray) -> np.ndarray: method='trf', loss='soft_l1', ) - + print(res.x) geometry = deserialize(res.x) if not refine_spacing: geometry._s = fixed_s # keep spacing unknown/fixed in the 1-window case @@ -314,3 +313,54 @@ class SquareGridGeometry(PeriodicConvexPolygonGridGeometry[SquareWindow]): GRID_REGISTRY['hexagonal'] = HexagonalGridGeometry GRID_REGISTRY['rectangular'] = RectangularGridGeometry GRID_REGISTRY['square'] = SquareGridGeometry + + +if __name__ == '__main__': + import numpy as np + residuals = {0: np.array([[572543.92272, 458611.73068], + [564971.6688 , 458611.73068], + [564971.6688 , 458611.73068], + [564971.6688 , 458611.73068], + [552876.06528, 458609.70234], + [552876.06528, 458609.70234], + [538110.39744, 458608.68817], + [538110.39744, 458608.68817], + [538110.39744, 458608.68817], + [518853.20256, 458610.71651], + [518853.20256, 458610.71651], + [496719.8544 , 458613.75902], + [496719.8544 , 458613.75902], + [487814.08368, 434983.59802], + [484349.97072, 421938.32931], + [484349.97072, 421938.32931], + [483886.27056, 411164.8014 ], + [483886.27056, 411164.8014 ], + [486744.23952, 397380.20276], + [486744.23952, 397380.20276], + [486744.23952, 397380.20276], + [486183.55632, 380419.22368], + [486183.55632, 380419.22368], + [496566.80304, 365588.0016 ], + [496566.80304, 365588.0016 ], + [503120.73504, 365593.07245], + [503120.73504, 365593.07245], + [511500.67584, 365799.96313], + [511500.67584, 365799.96313], + [520806.5016 , 366044.3781 ], + [520806.5016 , 366044.3781 ], + [532121.69472, 366439.9044 ], + [543770.26704, 366439.9044 ], + [543770.26704, 366439.9044 ], + [555862.83984, 366764.4388 ], + [555862.83984, 366764.4388 ], + [568560.04128, 367445.96104], + [568560.04128, 367445.96104], + [572369.65632, 406690.28336], + [576968.77392, 419188.91444], + [575013.95952, 431882.26616], + [572419.6632 , 441778.53702], + [572422.69392, 454949.56281]])} + a = SquareGridGeometry(10000, 20000, 0, 50000) + a.refine(residuals) + print(a.to_params()) + # TODO this does not fit correctly at all \ No newline at end of file diff --git a/src/instamatic/grid/sweeping.py b/src/instamatic/grid/sweeping.py index 3acd0fa8..36fa689e 100644 --- a/src/instamatic/grid/sweeping.py +++ b/src/instamatic/grid/sweeping.py @@ -93,7 +93,7 @@ def __init__(self, origin: Vector2, heading: Vector2, team: str = '') -> None: def peak(self) -> int: """Return light (image sum) at current position, update light max.""" - light = int(_ctrl.get_image(header_keys=())[0].sum(np.int64)) + light = int(_ctrl.get_image(header_keys=())[0].sum(dtype=np.int64)) self.team.light_max = max(light, self.team.light_max) return light From 60902140992986ac12d37673fbd919eaa4cf8713 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Thu, 23 Apr 2026 18:24:36 +0200 Subject: [PATCH 077/118] Fix window locating, refinement --- .../experiments/scan_ed/experiment.py | 4 +- src/instamatic/grid/geometry.py | 104 ++++++++++-------- src/instamatic/grid/window.py | 5 - 3 files changed, 60 insertions(+), 53 deletions(-) diff --git a/src/instamatic/experiments/scan_ed/experiment.py b/src/instamatic/experiments/scan_ed/experiment.py index 3dd0d0cd..ee4d5680 100644 --- a/src/instamatic/experiments/scan_ed/experiment.py +++ b/src/instamatic/experiments/scan_ed/experiment.py @@ -159,6 +159,8 @@ def start_collection(self, **params) -> None: except IndexError: intercepts = np.zeros(shape=(0, 2), dtype=float) self.state.add_intercepts(window_idx, intercepts) + self.state.grid.refine(intercepts=self.state.intercepts) + self.state.update_grid(self.state.grid.to_params()) self.draw_window_to_file(window_idx=window_idx) self.draw_grid_to_file() if params['stop_event'].is_set(): @@ -262,7 +264,7 @@ def determine_grid_manually(self) -> tuple[PeriodicConvexPolygonGridGeometry, di print(f'Warning: window {window_idx} was already added! Overwriting...') candidates[window_idx] = np.asarray(edge_xys, dtype=float) - grid.refine(candidates) + grid = grid.guess(candidates).refine(candidates) fig, ax = plot(grid, show_intercepts=True) with self.videostream_frame.processor.temporary(figure=fig), cl: print('LMB to accept and finish, RMB to retry, MMB to accept and new window') diff --git a/src/instamatic/grid/geometry.py b/src/instamatic/grid/geometry.py index c54e49f9..204fd04c 100644 --- a/src/instamatic/grid/geometry.py +++ b/src/instamatic/grid/geometry.py @@ -221,8 +221,6 @@ def guess(cls, intercepts: dict[int, np.ndarray]) -> Self: def refine(self, intercepts: dict[int, np.ndarray]) -> None: """Refine self to match the window_id: intercepts dictionary.""" - print(intercepts) - windows = sorted(intercepts.keys()) refine_spacing = len(windows) > 1 fit_h = self.window_type.USES_HEIGHT @@ -271,8 +269,9 @@ def residuals(p: np.ndarray) -> np.ndarray: bounds=(np.asarray(lower, dtype=float), np.asarray(upper, dtype=float)), method='trf', loss='soft_l1', + f_scale=10_000, ) - print(res.x) + geometry = deserialize(res.x) if not refine_spacing: geometry._s = fixed_s # keep spacing unknown/fixed in the 1-window case @@ -317,50 +316,61 @@ class SquareGridGeometry(PeriodicConvexPolygonGridGeometry[SquareWindow]): if __name__ == '__main__': import numpy as np - residuals = {0: np.array([[572543.92272, 458611.73068], - [564971.6688 , 458611.73068], - [564971.6688 , 458611.73068], - [564971.6688 , 458611.73068], - [552876.06528, 458609.70234], - [552876.06528, 458609.70234], - [538110.39744, 458608.68817], - [538110.39744, 458608.68817], - [538110.39744, 458608.68817], - [518853.20256, 458610.71651], - [518853.20256, 458610.71651], - [496719.8544 , 458613.75902], - [496719.8544 , 458613.75902], - [487814.08368, 434983.59802], - [484349.97072, 421938.32931], - [484349.97072, 421938.32931], - [483886.27056, 411164.8014 ], - [483886.27056, 411164.8014 ], - [486744.23952, 397380.20276], - [486744.23952, 397380.20276], - [486744.23952, 397380.20276], - [486183.55632, 380419.22368], - [486183.55632, 380419.22368], - [496566.80304, 365588.0016 ], - [496566.80304, 365588.0016 ], - [503120.73504, 365593.07245], - [503120.73504, 365593.07245], - [511500.67584, 365799.96313], - [511500.67584, 365799.96313], - [520806.5016 , 366044.3781 ], - [520806.5016 , 366044.3781 ], - [532121.69472, 366439.9044 ], - [543770.26704, 366439.9044 ], - [543770.26704, 366439.9044 ], - [555862.83984, 366764.4388 ], - [555862.83984, 366764.4388 ], - [568560.04128, 367445.96104], - [568560.04128, 367445.96104], - [572369.65632, 406690.28336], - [576968.77392, 419188.91444], - [575013.95952, 431882.26616], - [572419.6632 , 441778.53702], - [572422.69392, 454949.56281]])} + + residuals = { + 0: np.array( + [ + [572543.92272, 458611.73068], + [564971.6688, 458611.73068], + [564971.6688, 458611.73068], + [564971.6688, 458611.73068], + [552876.06528, 458609.70234], + [552876.06528, 458609.70234], + [538110.39744, 458608.68817], + [538110.39744, 458608.68817], + [538110.39744, 458608.68817], + [518853.20256, 458610.71651], + [518853.20256, 458610.71651], + [496719.8544, 458613.75902], + [496719.8544, 458613.75902], + [487814.08368, 434983.59802], + [484349.97072, 421938.32931], + [484349.97072, 421938.32931], + [483886.27056, 411164.8014], + [483886.27056, 411164.8014], + [486744.23952, 397380.20276], + [486744.23952, 397380.20276], + [486744.23952, 397380.20276], + [486183.55632, 380419.22368], + [486183.55632, 380419.22368], + [496566.80304, 365588.0016], + [496566.80304, 365588.0016], + [503120.73504, 365593.07245], + [503120.73504, 365593.07245], + [511500.67584, 365799.96313], + [511500.67584, 365799.96313], + [520806.5016, 366044.3781], + [520806.5016, 366044.3781], + [532121.69472, 366439.9044], + [543770.26704, 366439.9044], + [543770.26704, 366439.9044], + [555862.83984, 366764.4388], + [555862.83984, 366764.4388], + [568560.04128, 367445.96104], + [568560.04128, 367445.96104], + [572369.65632, 406690.28336], + [576968.77392, 419188.91444], + [575013.95952, 431882.26616], + [572419.6632, 441778.53702], + [572422.69392, 454949.56281], + ] + ) + } a = SquareGridGeometry(10000, 20000, 0, 50000) + print(a.guess(residuals).to_params()) + print(a.to_params()) + a.refine(residuals) + print(a.to_params()) + a = a.guess(residuals) a.refine(residuals) print(a.to_params()) - # TODO this does not fit correctly at all \ No newline at end of file diff --git a/src/instamatic/grid/window.py b/src/instamatic/grid/window.py index 13061216..063002e6 100644 --- a/src/instamatic/grid/window.py +++ b/src/instamatic/grid/window.py @@ -7,13 +7,8 @@ from typing_extensions import Self from instamatic._typing import float_nm -from instamatic.controller import TEMController, _ctrl, initialize from instamatic.utils.iterating import pairwise -if not _ctrl: - _ctrl: TEMController = initialize() - - X = np.array([1, 0], dtype=float) Y = np.array([0, 1], dtype=float) From 5b98b2d8b55f9eefb8584af1c6011914dd2d7c07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Thu, 23 Apr 2026 18:50:54 +0200 Subject: [PATCH 078/118] Clamp theta to -90 to 90 after refinement, it shouldn't ever be away. --- src/instamatic/grid/geometry.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/instamatic/grid/geometry.py b/src/instamatic/grid/geometry.py index 204fd04c..3b04c435 100644 --- a/src/instamatic/grid/geometry.py +++ b/src/instamatic/grid/geometry.py @@ -278,7 +278,7 @@ def residuals(p: np.ndarray) -> np.ndarray: self.x = geometry.x self.y = geometry.y - self.t = geometry.t + self.t = (geometry.t + 90) % 180 - 90 self.w = geometry.w self.h = geometry.h self.s = geometry._s From be1934c86eeb7c8e2972fe369e12ff70608ed152 Mon Sep 17 00:00:00 2001 From: Daniel Tchon Date: Thu, 23 Apr 2026 19:47:54 +0200 Subject: [PATCH 079/118] Further bugfixes/ideas --- .../experiments/scan_ed/experiment.py | 20 +++- .../experiments/scan_ed/progress.py | 10 +- src/instamatic/grid/artist.py | 108 +++++++++--------- 3 files changed, 82 insertions(+), 56 deletions(-) diff --git a/src/instamatic/experiments/scan_ed/experiment.py b/src/instamatic/experiments/scan_ed/experiment.py index ee4d5680..e88ea683 100644 --- a/src/instamatic/experiments/scan_ed/experiment.py +++ b/src/instamatic/experiments/scan_ed/experiment.py @@ -264,7 +264,8 @@ def determine_grid_manually(self) -> tuple[PeriodicConvexPolygonGridGeometry, di print(f'Warning: window {window_idx} was already added! Overwriting...') candidates[window_idx] = np.asarray(edge_xys, dtype=float) - grid = grid.guess(candidates).refine(candidates) + grid = grid.guess(candidates) + grid.refine(candidates) fig, ax = plot(grid, show_intercepts=True) with self.videostream_frame.processor.temporary(figure=fig), cl: print('LMB to accept and finish, RMB to retry, MMB to accept and new window') @@ -412,3 +413,20 @@ def tilt_list(self) -> Sequence[float]: def finalize(self) -> None: ... # TODO + +# TODO: something tries adding a window at every load +# Exception in Tkinter callback +# Traceback (most recent call last): +# File "C:\Program Files\Instamatic\Python312\Lib\tkinter\__init__.py", line 1968, in __call__ +# return self.func(*args) +# ^^^^^^^^^^^^^^^^ +# File "C:\Program Files\Instamatic\Python312\Lib\tkinter\__init__.py", line 862, in callit +# func(*args) +# File "C:\Program Files\Instamatic\instamatic\src\instamatic\experiments\scan_ed\progress.py", line 261, in _drain +# getattr(self._target, name)(*args, **kwargs) +# File "C:\Program Files\Instamatic\instamatic\src\instamatic\experiments\scan_ed\progress.py", line 88, in add_region +# self.tree.insert('', tk.END, iid=region_iid, text=region_name, values=values) +# File "C:\Program Files\Instamatic\Python312\Lib\tkinter\ttk.py", line 1339, in insert +# res = self.tk.call(self._w, "insert", parent, index, +# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +# _tkinter.TclError: Item r:0 already exists diff --git a/src/instamatic/experiments/scan_ed/progress.py b/src/instamatic/experiments/scan_ed/progress.py index 81824175..f3260ec5 100644 --- a/src/instamatic/experiments/scan_ed/progress.py +++ b/src/instamatic/experiments/scan_ed/progress.py @@ -10,6 +10,8 @@ import numpy as np +from instamatic._typing import float_nm + class GridWindowProtocol(Protocol): def __repr__(self) -> str: ... @@ -191,10 +193,10 @@ def fill_scan( region: int, line: int, scan: int, - step: int, - hits: bool, - light: int, - n_peaks: int, + offset: float_nm, + hits: Sequence[bool], + light: Sequence[int], + n_peaks: Sequence[int], ) -> None: """Add lines for successful experiments, update scan & column lines.""" diff --git a/src/instamatic/grid/artist.py b/src/instamatic/grid/artist.py index befa2f8b..57fea8b6 100644 --- a/src/instamatic/grid/artist.py +++ b/src/instamatic/grid/artist.py @@ -80,57 +80,63 @@ def plot( # draw imshow with light and hits, if lines, scans, and steps are given # TODO: lines, scans, steps belong strictly to ScanED - move it there - if all(x is not None and not x.empty for x in [lines, scans, steps]): - slow_idx = 'y0' if (lines['axis'] == 0).all() else 'x0' - slows = lines[slow_idx] - slow_step = (np.max(slows) - np.min(slows)) / (len(slows) - 1) - slow_min = np.min(slows) - 0.5 * slow_step - slow_max = np.max(slows) + 0.5 * slow_step - slow_count = len(slows) - - max_offset = scans['offset'].abs().max() - - fast_idx = 'x0' if (lines['axis'] == 0).all() else 'y0' - fast_start = lines[fast_idx] - fast_end = lines[fast_idx] + lines['step'] * lines['n_steps'] - fast_step = lines['step'].abs().mean() # TODO fails of zero steps - fast_min = np.minimum(fast_start, fast_end).min() - max_offset * fast_step - fast_max = np.maximum(fast_start, fast_end).max() + max_offset * fast_step - fast_count = np.ceil((fast_max - fast_min) / fast_step).astype(int) - - level = ['region', 'line', 'scan'] - hits = {k: g['hits'].to_numpy(dtype=float) for k, g in steps.groupby(level=level)} - - patch = np.zeros(shape=(slow_count, fast_count), dtype=float) - for (region, line), line_row in lines.iterrows(): - slow = line_row[slow_idx] - i = int((slow - slow_min) // slow_step) - - step = line_row['step'] - n_steps = line_row['n_steps'] - fast0 = line_row[fast_idx] - fast1 = fast0 + step * n_steps - fast0, fast1 = (fast0, fast1) if fast0 < fast1 else (fast1, fast0) - - sc = scans.loc[(region, line)] - offsets = sc['offset'].to_numpy() - j0s = np.floor((fast0 - fast_min + offsets) / fast_step).astype(int) - - hits_arr = np.stack([hits[(region, line, s)] for s in sc.index], axis=0) - if step < 0: - hits_arr = hits_arr[:, :-1] - for k in range(len(j0s)): - j0 = j0s[k] - patch[i, j0 : j0 + n_steps] += hits_arr[k] - - if fast_idx == 'x0': - x0, x1, y0, y1 = fast_min, fast_max, slow_min, slow_max - else: - x0, x1, y0, y1 = slow_min, slow_max, fast_min, fast_max - patch = patch.T - - a = 0.5 + 0.5 * (patch / patch_max) if (patch_max := patch.max()) > 0 else 0.5 - ax.imshow(patch, cmap='reds', alpha=a, origin='lower', extent=(x0, x1, y0, y1)) + try: + if all(x is not None and not x.empty for x in [lines, scans, steps]): + slow_idx = 'y0' if (lines['axis'] == 0).all() else 'x0' + slows = lines[slow_idx] + slow_step = (np.max(slows) - np.min(slows)) / (len(slows) - 1) + slow_min = np.min(slows) - 0.5 * slow_step + slow_max = np.max(slows) + 0.5 * slow_step + slow_count = len(slows) + + max_offset = scans['offset'].abs().max() + + fast_idx = 'x0' if (lines['axis'] == 0).all() else 'y0' + fast_start = lines[fast_idx] + fast_end = lines[fast_idx] + lines['step'] * lines['n_steps'] + fast_step = lines['step'].abs().mean() # TODO fails of zero steps + fast_min = np.minimum(fast_start, fast_end).min() - max_offset * fast_step + fast_max = np.maximum(fast_start, fast_end).max() + max_offset * fast_step + fast_count = np.ceil((fast_max - fast_min) / fast_step).astype(int) + + level = ['region', 'line', 'scan'] + hits = {k: g['hits'].to_numpy(dtype=float) for k, g in steps.groupby(level=level)} + + patch = np.zeros(shape=(slow_count, fast_count), dtype=float) + for (region, line), line_row in lines.iterrows(): + slow = line_row[slow_idx] + i = int((slow - slow_min) // slow_step) + + step = line_row['step'] + n_steps = line_row['n_steps'] + fast0 = line_row[fast_idx] + fast1 = fast0 + step * n_steps + fast0, fast1 = (fast0, fast1) if fast0 < fast1 else (fast1, fast0) + + sc = scans.loc[(region, line)] + offsets = sc['offset'].to_numpy() + j0s = np.floor((fast0 - fast_min + offsets) / fast_step).astype(int) + + hits_arr = np.stack([hits[(region, line, s)] for s in sc.index], axis=0) + if step < 0: + hits_arr = hits_arr[:, ::-1] + for k in range(len(j0s)): + j0 = j0s[k] + patch[i, j0 : j0 + n_steps] += hits_arr[k] + # TODO + # patch[i, j0 : j0 + n_steps] += hits_arr[k] + # ValueError: operands could not be broadcast together with shapes (0,) (211,) (0,) + + if fast_idx == 'x0': + x0, x1, y0, y1 = fast_min, fast_max, slow_min, slow_max + else: + x0, x1, y0, y1 = slow_min, slow_max, fast_min, fast_max + patch = patch.T + + a = 0.5 + 0.5 * (patch / patch_max) if (patch_max := patch.max()) > 0 else 0.5 + ax.imshow(patch, cmap='reds', alpha=a, origin='lower', extent=(x0, x1, y0, y1)) + except ValueError: + pass # currently I don't know how to plot this, and this is not my largest concern if limit_x is not None: ax.axvline(-limit_x, color='red', linewidth=1.0, zorder=4) From d3b9491badcd662bd0b2a971a396e1aabc390323 Mon Sep 17 00:00:00 2001 From: Daniel Tchon Date: Fri, 29 May 2026 14:36:33 +0200 Subject: [PATCH 080/118] Fix wrong line being read when processing --- src/instamatic/experiments/scan_ed/experiment.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/instamatic/experiments/scan_ed/experiment.py b/src/instamatic/experiments/scan_ed/experiment.py index e88ea683..9e22da56 100644 --- a/src/instamatic/experiments/scan_ed/experiment.py +++ b/src/instamatic/experiments/scan_ed/experiment.py @@ -1,5 +1,6 @@ from __future__ import annotations +import time from datetime import datetime, timedelta from itertools import count, cycle, product from pathlib import Path @@ -347,7 +348,7 @@ def run_scan(self, region_idx: int, line_idx: int, scan_idx: int) -> None: idx = pd.IndexSlice[region_idx, line_idx, scan_idx, :] if np.any(self.state.steps.loc[idx, 'n_peaks'] != -1): return # none-op for a scans that has been already done - n_frames = int(self.state.lines.loc[(region_idx, scan_idx), 'n_steps']) + n_frames = int(self.state.lines.loc[(region_idx, line_idx), 'n_steps']) line = self.state.lines.loc[(region_idx, line_idx)] self.ctrl.stage.set(x=line['x0'], y=line['y0']) @@ -374,7 +375,7 @@ def run_scan(self, region_idx: int, line_idx: int, scan_idx: int) -> None: self.dispatcher.process(frame, header) self.dispatcher.scan_finished.set() # signals no more data is coming self.dispatcher.scan_processed.wait(timeout=60) # should process live - fb_thread.join() + self.ctrl.stage.wait() self.dispatcher.write_scan(path=self.path / 'tiff') self.dispatcher.handle_feedback(self.state, region_idx, line_idx, scan_idx) From 40f53655e0498c4bae104e310f63cb5b1a6e3f23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Sat, 30 May 2026 17:27:29 +0200 Subject: [PATCH 081/118] You cannot `get_data_from_shared_memory` if data is an error report --- src/instamatic/camera/camera_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/instamatic/camera/camera_client.py b/src/instamatic/camera/camera_client.py index 08536fbb..2825f05a 100644 --- a/src/instamatic/camera/camera_client.py +++ b/src/instamatic/camera/camera_client.py @@ -139,7 +139,7 @@ def _eval_dct(self, dct): else: raise RuntimeError(f'Received empty response when evaluating {dct=}') - if self.use_shared_memory and acquiring_image and data: + if status == 200 and self.use_shared_memory and acquiring_image and data: data = self.get_data_from_shared_memory(**data) if status == 200: From cdab30ea7331759e867e94a020fdaef796e19907 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Thu, 18 Jun 2026 16:33:46 +0200 Subject: [PATCH 082/118] Serval: do not raise if byte1 is not 0 --- src/instamatic/camera/camera_serval.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/instamatic/camera/camera_serval.py b/src/instamatic/camera/camera_serval.py index 4b7345bb..863f07e2 100644 --- a/src/instamatic/camera/camera_serval.py +++ b/src/instamatic/camera/camera_serval.py @@ -266,8 +266,8 @@ def __next__(self) -> np.ndarray: # Decode Serval-packed 32-bit jsonimage payload into the physical 24-bit count: # observed packing (byte lanes): [b0, b1, b2, b3] with b1 unused/zero, # count = b0 | (b3<<8) | (b2<<16). Contact Daniel Tchon, tchon@fzu.cz, for details. - if __debug__ and np.any((frame & np.uint32(0x0000FF00)) != 0): - raise ValueError('Unexpected nonzero byte1 in Serval packed uint32 payload.') + if np.any((frame & np.uint32(0x0000FF00)) != 0): # potentially check harder + logger.debug('Unexpected nonzero byte1 in Serval packed uint32 payload.') if frame.dtype == np.uint32: bytes02 = frame & np.uint32(0x00FF00FF) bytes3 = frame & np.uint32(0xFF000000) From 7f6d3a7f8e142694168a9322eb0b73d9b86a7d03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Thu, 18 Jun 2026 18:50:43 +0200 Subject: [PATCH 083/118] Add an option to save all images --- .../experiments/scan_ed/dispatch.py | 21 +++++++++++++------ .../experiments/scan_ed/experiment.py | 3 ++- src/instamatic/gui/scan_ed_frame.py | 12 +++++++---- 3 files changed, 25 insertions(+), 11 deletions(-) diff --git a/src/instamatic/experiments/scan_ed/dispatch.py b/src/instamatic/experiments/scan_ed/dispatch.py index c050d749..13c5a790 100644 --- a/src/instamatic/experiments/scan_ed/dispatch.py +++ b/src/instamatic/experiments/scan_ed/dispatch.py @@ -1,6 +1,7 @@ from __future__ import annotations import multiprocessing as mp +import os import queue import uuid from dataclasses import dataclass @@ -140,13 +141,18 @@ def process(self, frame: np.ndarray, header: Optional[dict]) -> int: self.emit('PROCESS', buffer_pointer=ptr) return ptr - def write_scan(self, path: AnyPath) -> None: + def write_scan(self, path: AnyPath, all_: False) -> None: """Request workers to write all hit frames from the active scan.""" + bn = self._buffer_name + paths = [] for pointer, hit in enumerate(self.hits): - if hit: + if all_ or hit: + if all_: + paths.append(str(Path(path) / 'all')) + if hit: + paths.append(str(Path(path) / 'tiff')) self._write_pending.add(pointer) - bn = self._buffer_name - kwargs = {'path': path, 'header': self.headers[pointer]} + kwargs = {'paths': paths, 'header': self.headers[pointer]} self.emit('WRITE', buffer_name=bn, buffer_pointer=pointer, kwargs=kwargs) def handle_feedback(self, state: State, region: int, line: int, scan: int) -> None: @@ -225,11 +231,14 @@ def run(self) -> None: elif cmd.kind == 'WRITE': try: - path = Path(cmd.kwargs['path']).resolve() + paths = [Path(path).resolve() for path in cmd.kwargs['paths']] filename = f'{cmd.buffer_name}_{cmd.buffer_pointer:06d}.tiff' frame = self.frames[cmd.buffer_pointer] header = cmd.kwargs.get('header', {}) - write_tiff(fname=str(path / filename), data=frame, header=header) + write_tiff(fname=str(paths[0] / filename), data=frame, header=header) + for path in paths[1:]: # I assume no cross-device and won't raise + path.parent.mkdir(parents=True, exist_ok=True) + os.link(paths[0], path) finally: self.emit('WRITTEN', buffer_pointer=cmd.buffer_pointer) diff --git a/src/instamatic/experiments/scan_ed/experiment.py b/src/instamatic/experiments/scan_ed/experiment.py index 9e22da56..608a9b82 100644 --- a/src/instamatic/experiments/scan_ed/experiment.py +++ b/src/instamatic/experiments/scan_ed/experiment.py @@ -377,7 +377,7 @@ def run_scan(self, region_idx: int, line_idx: int, scan_idx: int) -> None: self.dispatcher.scan_processed.wait(timeout=60) # should process live self.ctrl.stage.wait() - self.dispatcher.write_scan(path=self.path / 'tiff') + self.dispatcher.write_scan(path=self.path, all_=self.params.get('save_all', False)) self.dispatcher.handle_feedback(self.state, region_idx, line_idx, scan_idx) self.dispatcher.end_scan() @@ -415,6 +415,7 @@ def finalize(self) -> None: ... # TODO + # TODO: something tries adding a window at every load # Exception in Tkinter callback # Traceback (most recent call last): diff --git a/src/instamatic/gui/scan_ed_frame.py b/src/instamatic/gui/scan_ed_frame.py index 42a9ec66..aa6090ff 100644 --- a/src/instamatic/gui/scan_ed_frame.py +++ b/src/instamatic/gui/scan_ed_frame.py @@ -55,6 +55,7 @@ def __init__(self) -> None: self.target_y = IntVar(value=500_000) self.target_time = IntVar(value=480) self.max_alpha = DoubleVar(value=0) + self.save_all = BooleanVar(value=False) self.target_hits_b = BooleanVar(value=False) self.target_x_b = BooleanVar(value=False) @@ -155,10 +156,15 @@ def __init__(self, parent): self.target_time = Spinbox(f, textvariable=self.var.target_time, **target_time) self.target_time.grid(row=8, column=3, **pad10) + text = 'Save all images in ./all:' + self.save_all_b = Checkbutton(f, variable=self.var.save_all, text=text) + self.save_all_b.grid(row=9, column=2, columnspan=2, **pad10) + # Bottom area for progress and experiment flow control buttons self.progress = ProgressTable(f) - self.progress.grid(row=10, columnspan=4, sticky=NSEW, padx=10, pady=10) + self.progress.grid(row=10, columnspan=4, sticky=NSEW, padx=10, pady=0) + f.pack(side='top', fill=BOTH, expand=True, pady=10) g = Frame(self) for column in range(3): @@ -171,9 +177,7 @@ def __init__(self, parent): self.stop_button = Button(g, text='Stop collection', command=self.stop_collection) self.stop_button.grid(row=20, column=2, sticky=EW) self.update_widget() - - g.pack(side='bottom', fill=BOTH, expand=True, padx=10) - f.pack(side='bottom', fill=BOTH, expand=True, pady=10) + g.pack(side='bottom', fill=X, padx=10, pady=(0, 10)) # pad from the bottom only def start_collection(self) -> None: self.progress.clear() From 238bbee869d822500871863561e1f2f42001ef94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Tue, 30 Jun 2026 13:16:38 +0200 Subject: [PATCH 084/118] Allow changing diffhunt configuration during experiment --- .../experiments/scan_ed/detection.py | 10 ++++++---- .../experiments/scan_ed/dispatch.py | 15 +++++++++++--- .../experiments/scan_ed/experiment.py | 1 + src/instamatic/experiments/scan_ed/state.py | 11 +++++++++- src/instamatic/gui/scan_ed_frame.py | 20 ++++++++++++++++--- 5 files changed, 46 insertions(+), 11 deletions(-) diff --git a/src/instamatic/experiments/scan_ed/detection.py b/src/instamatic/experiments/scan_ed/detection.py index 1730e0f1..6ac6acd1 100644 --- a/src/instamatic/experiments/scan_ed/detection.py +++ b/src/instamatic/experiments/scan_ed/detection.py @@ -43,9 +43,10 @@ def ring_percentile_detection( threshold_mult: float = 2.0, min_peak_count: int = 10, min_peak_sep: int = 5, - mask: np.ndarray | None = None, + mask: np.ndarray | None = HARD_CODED_MASK, n_bins: int = 10, gaussian_sigma: float = 1.2, + return_mask: bool = False, ) -> DiffHuntResults: """Radial-binned detector with thresholds computed on a *locally averaged* image. @@ -73,8 +74,9 @@ def ring_percentile_detection( valid &= rr > min_radius valid_idx = np.flatnonzero(valid) + returned_mask = valid if return_mask else None if valid_idx.size == 0: - return DiffHuntResults(success=False, bin_center=(cy, cx), mask=valid) + return DiffHuntResults(success=False, bin_center=(cy, cx), mask=returned_mask) vals = score.flat[valid_idx] # (N,) float32 rr_vals = rr.flat[valid_idx].astype(np.float32, copy=False) @@ -115,7 +117,7 @@ def ring_percentile_detection( bin_center=(cy, cx), bin_edges=bin_edges, peaks=peaks, - mask=valid, + mask=returned_mask, light=int(np.sum(frame, axis=None)), ) @@ -230,6 +232,6 @@ def plot_diffraction_debug( for path in paths: tiff = Image.open(path) image = np.array(tiff) - results = ring_percentile_detection(image, mask=mask) + results = ring_percentile_detection(image, mask=mask, return_mask=True) print(Path(path).stem, results.success, len(results.peaks)) plot_diffraction_debug(image, results) diff --git a/src/instamatic/experiments/scan_ed/dispatch.py b/src/instamatic/experiments/scan_ed/dispatch.py index 13c5a790..095e6178 100644 --- a/src/instamatic/experiments/scan_ed/dispatch.py +++ b/src/instamatic/experiments/scan_ed/dispatch.py @@ -9,7 +9,7 @@ from multiprocessing.shared_memory import SharedMemory from pathlib import Path from threading import Event -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Any, Optional import numpy as np from typing_extensions import Literal @@ -23,7 +23,7 @@ N_PROCESSORS = 4 -CommandKind = Literal['INIT', 'PROCESS', 'WRITE', 'TERMINATE'] +CommandKind = Literal['CONFIGURE', 'INIT', 'PROCESS', 'WRITE', 'TERMINATE'] FeedbackKind = Literal['PROCESSING', 'PROCESSED', 'WRITTEN'] @@ -89,6 +89,11 @@ def emit(self, task: CommandKind, *args, **kwargs) -> None: q = self.commands[next(self._round_robin) % N_PROCESSORS] q.put(Command(task, *args, **kwargs)) + def configure(self, **params: dict[str, Any]) -> None: + """Update worker config with provided params dictionary.""" + for _ in self._workers: + self.emit('CONFIGURE', **params) + def begin_scan(self, n_frames: int, name: Optional[str] = None) -> None: """Allocate a new shared buffer and reset all tracking for one scan.""" self._buffer_name = name or uuid.uuid4().hex @@ -202,6 +207,7 @@ def __init__(self, worker_id: int, commands: mp.Queue, feedback: mp.Queue, dtype self.commands = commands self.feedback = feedback self.dtype = np.dtype(dtype) + self.config: dict[str, Any] = {} self.frames: Optional[np.ndarray] = None self.shm: Optional[SharedMemory] = None @@ -219,11 +225,14 @@ def run(self) -> None: shape = cmd.buffer_shape self.frames = np.ndarray(shape, dtype=self.dtype, buffer=self.shm.buf) + elif cmd.kind == 'CONFIGURE': + self.config.update(cmd.kwargs) + elif cmd.kind == 'PROCESS': ptr = int(cmd.buffer_pointer) self.emit('PROCESSING', buffer_pointer=ptr) try: - d = ring_percentile_detection(frame=self.frames[ptr]) + d = ring_percentile_detection(frame=self.frames[ptr], **self.config) except Exception as e: d = DiffHuntResults(success=False) finally: diff --git a/src/instamatic/experiments/scan_ed/experiment.py b/src/instamatic/experiments/scan_ed/experiment.py index 608a9b82..53a439e8 100644 --- a/src/instamatic/experiments/scan_ed/experiment.py +++ b/src/instamatic/experiments/scan_ed/experiment.py @@ -130,6 +130,7 @@ def start_collection(self, **params) -> None: _ = self.state # loads the journal if self.dispatcher is None: self.dispatcher = self.get_dispatcher() + self.state.configure_dispatcher(params=params) # if allowed, add manually as many windows as the user desires. self.ctrl.stage.set(a=0) diff --git a/src/instamatic/experiments/scan_ed/state.py b/src/instamatic/experiments/scan_ed/state.py index a36e41c9..4ee3552a 100644 --- a/src/instamatic/experiments/scan_ed/state.py +++ b/src/instamatic/experiments/scan_ed/state.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Callable, Optional, Sequence +from typing import TYPE_CHECKING, Callable, Optional, Sequence import numpy as np import pandas as pd @@ -12,6 +12,9 @@ from instamatic.experiments.scan_ed.progress import ProgressTable, edits_progress from instamatic.grid.geometry import PeriodicConvexPolygonGridGeometry +if TYPE_CHECKING: + from instamatic.experiments.scan_ed.dispatch import DiffHuntDispatcher + class State: """Stores the current state of the SPED experiment in history dataframe.""" @@ -27,6 +30,7 @@ def __init__( self.grid: PeriodicConvexPolygonGridGeometry = grid self.progress: Optional[ProgressTable] = progress self.intercepts: NoOverwriteDict[int, np.ndarray] = NoOverwriteDict(intercepts or {}) + self.dispatcher: Optional[DiffHuntDispatcher] = None self.lines: pd.DataFrame = pd.DataFrame() self.scans: pd.DataFrame = pd.DataFrame() @@ -103,6 +107,11 @@ def add_line( lines_cols = ['x0', 'y0', 'axis', 'step', 'n_steps'] self.lines.loc[(region, line), lines_cols] = (x0, y0, axis, step, n_steps) + @edits_journal + def configure_dispatcher(self, **params) -> None: + assert self.dispatcher is not None + self.dispatcher.configure(**params) + @edits_journal @edits_progress def add_scan( diff --git a/src/instamatic/gui/scan_ed_frame.py b/src/instamatic/gui/scan_ed_frame.py index aa6090ff..4ac8a2e2 100644 --- a/src/instamatic/gui/scan_ed_frame.py +++ b/src/instamatic/gui/scan_ed_frame.py @@ -20,6 +20,7 @@ target_time = {'from_': 0, 'to': 43_200, 'increment': 60} target_xy = {'from_': 0, 'to': 1_000_000, 'increment': 1000} angle_delta = {'from_': 0, 'to': 30, 'increment': 1} +radius_range = {'from_': 40, 'to': 1000, 'increment': 1} class WidgetState(Enum): @@ -62,6 +63,9 @@ def __init__(self) -> None: self.target_y_b = BooleanVar(value=False) self.target_time_b = BooleanVar(value=False) + self.min_peak_count = IntVar(value=10) + self.min_radius = DoubleVar(value=0.5) + self.stop_event = ThreadingEvent() def as_dict(self) -> dict[str, Union[float, int, str]]: @@ -89,7 +93,7 @@ def __init__(self, parent): # Top-aligned part of the frame with experiment parameters f = Frame(self) - for column in range(4): + for column in range(6): f.grid_columnconfigure(column, weight=1, uniform='buttons') f.grid_rowconfigure(10, weight=1) @@ -156,14 +160,24 @@ def __init__(self, parent): self.target_time = Spinbox(f, textvariable=self.var.target_time, **target_time) self.target_time.grid(row=8, column=3, **pad10) + Label(f, text='Min peak count:').grid(row=3, column=4, **pad10) + var = self.var.min_peak_count + self.min_peak_count = Spinbox(f, textvariable=var, **angle_delta) + self.min_peak_count.grid(row=3, column=5, **pad10) + + Label(f, text='Min radius (px):').grid(row=4, column=4, **pad10) + var = self.var.min_radius + self.min_resolution = Spinbox(f, textvariable=var, **radius_range) + self.min_resolution.grid(row=4, column=5, **pad10) + text = 'Save all images in ./all:' self.save_all_b = Checkbutton(f, variable=self.var.save_all, text=text) - self.save_all_b.grid(row=9, column=2, columnspan=2, **pad10) + self.save_all_b.grid(row=8, column=4, columnspan=2, **pad10) # Bottom area for progress and experiment flow control buttons self.progress = ProgressTable(f) - self.progress.grid(row=10, columnspan=4, sticky=NSEW, padx=10, pady=0) + self.progress.grid(row=10, columnspan=6, sticky=NSEW, padx=10, pady=0) f.pack(side='top', fill=BOTH, expand=True, pady=10) g = Frame(self) From fade29a1e8aeab2501f71045c464945b9f5803ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Tue, 30 Jun 2026 14:12:43 +0200 Subject: [PATCH 085/118] Add new mode mechanism, explicitly initialize state --- .../experiments/scan_ed/experiment.py | 20 ++++---- src/instamatic/gui/scan_ed_frame.py | 49 +++++++++++-------- 2 files changed, 39 insertions(+), 30 deletions(-) diff --git a/src/instamatic/experiments/scan_ed/experiment.py b/src/instamatic/experiments/scan_ed/experiment.py index 53a439e8..099391b5 100644 --- a/src/instamatic/experiments/scan_ed/experiment.py +++ b/src/instamatic/experiments/scan_ed/experiment.py @@ -32,6 +32,8 @@ if TYPE_CHECKING: from instamatic.gui import videostream_frame as vsf_type +SCAN_ED_MODE = Literal['start', 'continue', 'reprocess'] + class Experiment(ExperimentBase): name = 'SPED' @@ -43,7 +45,7 @@ def __init__( log: logging.Logger, flatfield: Optional[np.ndarray] = None, progress: Optional[ProgressTable] = None, - load: bool = False, + mode: SCAN_ED_MODE = 'start', videostream_frame: Optional[vsf_type] = None, ): super().__init__() @@ -52,7 +54,7 @@ def __init__( self.log: logging.Logger = log self.flatfield: Optional[np.ndarray] = flatfield self.progress: Optional[ProgressTable] = progress - self.load: bool = load + self.mode: SCAN_ED_MODE = mode self._state: Optional[State] = None self.start_time = datetime.now() self.videostream_frame: Optional[vsf_type] = videostream_frame @@ -62,21 +64,21 @@ def __init__( self.dispatcher: Optional[DiffHuntDispatcher] = None self.regionalization: Optional[Regionalization] = None - @property - def state(self) -> State: + def initialize_state(self) -> None: """Initialize, fill a state if first access; raise at load issues.""" - if self._state is not None: - return self._state journal_path = self.path / 'journal.jsonl' journal = Journal(path=journal_path) grid = GRID_REGISTRY[self.params['grid_geometry']](0, 0, 0, 50_000, 50_000) state = State(journal=journal, grid=grid, progress=self.progress) - if self.load: + if self.mode == 'continue': if not journal_path.exists() or not journal_path.is_file(): raise FileNotFoundError(f'No journal file found at {journal_path=}') state.load_from_journal() self._state = state - return state + + @property + def state(self) -> State: + return self._state def get_dead_time( self, @@ -127,7 +129,7 @@ def start_collection(self, **params) -> None: # Save parameters to a variable, load the journal and dispatcher self.params = params - _ = self.state # loads the journal + self.initialize_state() if self.dispatcher is None: self.dispatcher = self.get_dispatcher() self.state.configure_dispatcher(params=params) diff --git a/src/instamatic/gui/scan_ed_frame.py b/src/instamatic/gui/scan_ed_frame.py index 4ac8a2e2..52907995 100644 --- a/src/instamatic/gui/scan_ed_frame.py +++ b/src/instamatic/gui/scan_ed_frame.py @@ -5,7 +5,7 @@ from threading import Event as ThreadingEvent from tkinter import * from tkinter.ttk import * -from typing import Any, Optional, Union +from typing import Any, Literal, Optional, Union from instamatic import controller from instamatic.experiments.scan_ed.progress import ProgressTable, ThreadSafeProgressTableProxy @@ -13,6 +13,8 @@ from .base_module import BaseModule, ModuleFrameMixin +SCAN_ED_MODE = Literal['start', 'continue', 'reprocess'] + pad10 = {'sticky': 'EW', 'padx': 10, 'pady': 1} scan_step = {'from_': 100, 'to': 100_000, 'increment': 100} scan_exposure = {'from_': 0.01, 'to': 10, 'increment': 0.01} @@ -184,32 +186,37 @@ def __init__(self, parent): for column in range(3): g.grid_columnconfigure(column, weight=1, uniform='buttons') - self.start_button = Button(g, text='Start collection', command=self.start_collection) + self.start_button = Button(g, text='Start collection', command=self.run_start) self.start_button.grid(row=20, column=0, sticky=EW) - self.load_button = Button(g, text='Load and continue', command=self.load_collection) + self.load_button = Button(g, text='Load and continue', command=self.run_continue) self.load_button.grid(row=20, column=1, sticky=EW) - self.stop_button = Button(g, text='Stop collection', command=self.stop_collection) - self.stop_button.grid(row=20, column=2, sticky=EW) + self.load_button = Button(g, text='Load and reprocess', command=self.run_reprocess) + self.load_button.grid(row=20, column=2, sticky=EW) + self.stop_button = Button(g, text='Stop collection', command=self.run_stop) + self.stop_button.grid(row=20, column=3, sticky=EW) self.update_widget() g.pack(side='bottom', fill=X, padx=10, pady=(0, 10)) # pad from the bottom only - def start_collection(self) -> None: + def _run(self, mode: SCAN_ED_MODE) -> None: + """Schedule the scan_ed job on the experiment thread in appropriate + mode.""" self.progress.clear() callback = ThreadSafeTkCallback(self, self.update_widget) progress = ThreadSafeProgressTableProxy(self, self.progress) - kwargs = {'callback': callback, 'load': False, 'progress': progress} + kwargs = {'callback': callback, 'mode': mode, 'progress': progress} self.q.put(('scan_ed', {**kwargs, **self.var.as_dict()})) self.update_widget(state=WidgetState.BUSY) - def load_collection(self) -> None: - self.progress.clear() - callback = ThreadSafeTkCallback(self, self.update_widget) - progress = ThreadSafeProgressTableProxy(self, self.progress) - kwargs = {'callback': callback, 'load': True, 'progress': progress} - self.q.put(('scan_ed', {**kwargs, **self.var.as_dict()})) - self.update_widget(state=WidgetState.BUSY) + def run_start(self) -> None: + self._run(mode='start') + + def run_continue(self) -> None: + self._run(mode='continue') - def stop_collection(self) -> None: + def run_reprocess(self) -> None: + self._run(mode='reprocess') + + def run_stop(self) -> None: self.var.stop_event.set() self.update_widget(state=WidgetState.STOPPING) @@ -224,13 +231,16 @@ def sced_interface_command(controller, **params: Any) -> None: from instamatic.experiments.scan_ed.experiment import Experiment callback = params.pop('callback', lambda: None) - load: bool = params.get('load', False) + mode: SCAN_ED_MODE = params.get('mode', 'start') # noqa type progress: Optional[ProgressTable] = params.get('progress', None) flat_field = controller.module_io.get_flatfield() if params.get('stop_event', None) is not None: params['stop_event'].clear() - if load: + if mode == 'start': + exp_dir = controller.module_io.get_new_experiment_directory() + exp_dir.mkdir(exist_ok=True, parents=True) + else: exp_dir = controller.module_io.get_experiment_directory() journal_path = Path(exp_dir) / 'journal.jsonl' try: @@ -238,9 +248,6 @@ def sced_interface_command(controller, **params: Any) -> None: raise FileNotFoundError(f'No journal file found at {journal_path}') except FileNotFoundError: callback() - else: - exp_dir = controller.module_io.get_new_experiment_directory() - exp_dir.mkdir(exist_ok=True, parents=True) # get the videostreaming frame only if needed for manual window determination if params.get('grid_finder') == 'All automatically': @@ -254,7 +261,7 @@ def sced_interface_command(controller, **params: Any) -> None: log=controller.log, flatfield=flat_field, progress=progress, - load=load, + mode=mode, videostream_frame=vsf, ) try: From b3ce20a844883bdb565c5841db6fe9b81578645d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Tue, 30 Jun 2026 15:50:22 +0200 Subject: [PATCH 086/118] Implement option to reprocess old data --- .../experiments/scan_ed/dispatch.py | 36 ++++++---- .../experiments/scan_ed/experiment.py | 70 ++++++++++++++++++- src/instamatic/experiments/scan_ed/state.py | 27 +++++-- 3 files changed, 111 insertions(+), 22 deletions(-) diff --git a/src/instamatic/experiments/scan_ed/dispatch.py b/src/instamatic/experiments/scan_ed/dispatch.py index 095e6178..2096e84f 100644 --- a/src/instamatic/experiments/scan_ed/dispatch.py +++ b/src/instamatic/experiments/scan_ed/dispatch.py @@ -146,19 +146,20 @@ def process(self, frame: np.ndarray, header: Optional[dict]) -> int: self.emit('PROCESS', buffer_pointer=ptr) return ptr - def write_scan(self, path: AnyPath, all_: False) -> None: + def write_scan(self, path: AnyPath, all_: bool = False) -> None: """Request workers to write all hit frames from the active scan.""" bn = self._buffer_name - paths = [] for pointer, hit in enumerate(self.hits): - if all_ or hit: - if all_: - paths.append(str(Path(path) / 'all')) - if hit: - paths.append(str(Path(path) / 'tiff')) - self._write_pending.add(pointer) - kwargs = {'paths': paths, 'header': self.headers[pointer]} - self.emit('WRITE', buffer_name=bn, buffer_pointer=pointer, kwargs=kwargs) + paths = [] + if all_: + paths.append(str(Path(path) / 'all')) + if hit: + paths.append(str(Path(path) / 'tiff')) + if not paths: + continue + self._write_pending.add(pointer) + kwargs = {'paths': paths, 'header': self.headers[pointer]} + self.emit('WRITE', buffer_name=bn, buffer_pointer=pointer, kwargs=kwargs) def handle_feedback(self, state: State, region: int, line: int, scan: int) -> None: """Continuously drain the feedback queue until scan is fully processed. @@ -240,14 +241,19 @@ def run(self) -> None: elif cmd.kind == 'WRITE': try: - paths = [Path(path).resolve() for path in cmd.kwargs['paths']] + dirs = [Path(p).resolve() for p in cmd.kwargs['paths']] filename = f'{cmd.buffer_name}_{cmd.buffer_pointer:06d}.tiff' frame = self.frames[cmd.buffer_pointer] header = cmd.kwargs.get('header', {}) - write_tiff(fname=str(paths[0] / filename), data=frame, header=header) - for path in paths[1:]: # I assume no cross-device and won't raise - path.parent.mkdir(parents=True, exist_ok=True) - os.link(paths[0], path) + first = dirs[0] / filename + first.parent.mkdir(parents=True, exist_ok=True) + write_tiff(fname=str(first), data=frame, header=header) + for d in dirs[1:]: # I assume no cross-device and won't raise + d.mkdir(parents=True, exist_ok=True) + target = d / filename + if target.exists() or target.is_symlink(): + target.unlink() + os.link(first, target) finally: self.emit('WRITTEN', buffer_pointer=cmd.buffer_pointer) diff --git a/src/instamatic/experiments/scan_ed/experiment.py b/src/instamatic/experiments/scan_ed/experiment.py index 099391b5..b2a6006d 100644 --- a/src/instamatic/experiments/scan_ed/experiment.py +++ b/src/instamatic/experiments/scan_ed/experiment.py @@ -1,5 +1,6 @@ from __future__ import annotations +import shutil import time from datetime import datetime, timedelta from itertools import count, cycle, product @@ -20,6 +21,7 @@ from instamatic.experiments.scan_ed.progress import ProgressTable from instamatic.experiments.scan_ed.region import Regionalization from instamatic.experiments.scan_ed.state import State +from instamatic.formats import read_tiff from instamatic.grid.artist import plot from instamatic.grid.geometry import ( GRID_REGISTRY, @@ -70,7 +72,7 @@ def initialize_state(self) -> None: journal = Journal(path=journal_path) grid = GRID_REGISTRY[self.params['grid_geometry']](0, 0, 0, 50_000, 50_000) state = State(journal=journal, grid=grid, progress=self.progress) - if self.mode == 'continue': + if self.mode in ('continue', 'reprocess'): if not journal_path.exists() or not journal_path.is_file(): raise FileNotFoundError(f'No journal file found at {journal_path=}') state.load_from_journal() @@ -99,11 +101,17 @@ def get_dead_time( else: return c.dead_time - def get_dispatcher(self) -> DiffHuntDispatcher: + def get_dispatcher_live(self) -> DiffHuntDispatcher: """Start a multiprocessing helper once you have full access to cam.""" image, h = self.ctrl.get_image() return DiffHuntDispatcher(shape=image.shape, dtype=image.dtype) + def get_dispatcher_from_file(self) -> DiffHuntDispatcher: + """Start a multiprocessing helper using a sample frame on the disk.""" + sample_path = next((self.path / 'all').glob('*.tiff')) + sample, _ = read_tiff(str(sample_path)) + return DiffHuntDispatcher(shape=sample.shape, dtype=sample.dtype) + def get_stage_translation(self) -> CalibStageMotion: """Get rotation calibration if present; otherwise warn & terminate.""" try: @@ -130,8 +138,12 @@ def start_collection(self, **params) -> None: # Save parameters to a variable, load the journal and dispatcher self.params = params self.initialize_state() + if self.mode == 'reprocess': + self.reprocess_collection() + return if self.dispatcher is None: - self.dispatcher = self.get_dispatcher() + self.dispatcher = self.get_dispatcher_live() + self.state.dispatcher = self.dispatcher self.state.configure_dispatcher(params=params) # if allowed, add manually as many windows as the user desires. @@ -402,6 +414,58 @@ def finalize_scan(self, region_idx: int, line_idx: int, scan_idx: int) -> None: self.state.finalize_scan(region_idx, line_idx, scan_idx, offset=offset) self.ctrl.stage.wait() + def reprocess_collection(self) -> None: + """Re-evaluate frames already saved in `all/` with current detection + params, rewriting the journal's hit data and `tiff/` from scratch. + + Never drives the microscope and never resumes collection + afterward. + """ + + if self.dispatcher is None: + self.dispatcher = self.get_dispatcher_from_file() + self.state.dispatcher = self.dispatcher + self.state.configure_dispatcher(params=self.params) + + shutil.rmtree(self.path / 'tiff', ignore_errors=True) + + for region_idx, line_idx, scan_idx in self.state.scans.index: + self.reprocess_scan(region_idx, line_idx, scan_idx) + if self.params['stop_event'].is_set(): + break + + self.draw_hits_to_file() + self.teardown() + + def reprocess_scan(self, region_idx, line_idx, scan_idx) -> None: + """Re-run detection on one previously collected scan's saved frames.""" + + n_frames = int(self.state.lines.loc[(region_idx, line_idx), 'n_steps']) + name = f'r{region_idx:03d}_l{line_idx:06d}_s{scan_idx:03d}' + frame_paths = [self.path / 'all' / f'{name}_{p:06d}.tiff' for p in range(n_frames)] + if not all(p.is_file() for p in frame_paths): + self.log.warning(f'Skipping reprocess of {name}: missing frame(s) in all/') + return + + self.dispatcher.begin_scan(n_frames, name=name) + kw = {'state': self.state, 'region': region_idx, 'line': line_idx, 'scan': scan_idx} + fb_thread = Thread(target=self.dispatcher.handle_feedback, kwargs=kw) + fb_thread.start() + + for frame_path in frame_paths: + frame, header = read_tiff(str(frame_path)) + self.dispatcher.process(frame, header=header) + + self.dispatcher.scan_finished.set() + self.dispatcher.scan_processed.wait(timeout=60) + self.dispatcher.write_scan( + path=self.path, all_=False + ) # tiff/ hits only, all/ untouched + self.dispatcher.handle_feedback(self.state, region_idx, line_idx, scan_idx) + self.dispatcher.end_scan() + + self.finalize_scan(region_idx, line_idx, scan_idx) + def teardown(self) -> None: """Close all threads and safely shut down when requested.""" self.dispatcher.terminate_workers() diff --git a/src/instamatic/experiments/scan_ed/state.py b/src/instamatic/experiments/scan_ed/state.py index 4ee3552a..35fd2c3f 100644 --- a/src/instamatic/experiments/scan_ed/state.py +++ b/src/instamatic/experiments/scan_ed/state.py @@ -74,11 +74,30 @@ def _init_dataframes(self) -> None: self.steps.set_index(['region', 'line', 'scan', 'step'], inplace=True) def load_from_journal(self) -> None: - """Recreate an instance of experiment state from journal file.""" + """Recreate an instance of experiment state from journal file. + + First, get the list of events. Then, specifically look at fill + events. Only apply the latest fill event to save on display + time. + """ + + events = list(self.journal.events()) + + latest_fill_event: dict[tuple[int, int, int], int] = {} + for event in events: + if event['method'] == 'fill_encoded_scan': + k = event['kwargs'] + latest_fill_event[(k['region'], k['line'], k['scan'])] = event['seq'] + with self.journal.writing_off(): - for event in self.journal.events(): - method_name = event['method'] - kwargs = event.get('kwargs', {}) + for event in events: + method_name, kwargs = event['method'], event.get('kwargs', {}) + if method_name == 'configure_dispatcher': + continue # reapplied live, not part of structural state + if method_name == 'fill_encoded_scan': + key = (kwargs['region'], kwargs['line'], kwargs['scan']) + if event['seq'] != latest_fill_event[key]: + continue # superseded by a later reprocess pass getattr(self, method_name)(**kwargs) @edits_journal From 22545f6d60359a43c205be4a76757499d9f04ce9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Tue, 30 Jun 2026 15:58:33 +0200 Subject: [PATCH 087/118] Fix radius min should be 40, not 0.5 by default (px not A-1) --- src/instamatic/gui/scan_ed_frame.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/instamatic/gui/scan_ed_frame.py b/src/instamatic/gui/scan_ed_frame.py index 52907995..34c0ab52 100644 --- a/src/instamatic/gui/scan_ed_frame.py +++ b/src/instamatic/gui/scan_ed_frame.py @@ -22,7 +22,7 @@ target_time = {'from_': 0, 'to': 43_200, 'increment': 60} target_xy = {'from_': 0, 'to': 1_000_000, 'increment': 1000} angle_delta = {'from_': 0, 'to': 30, 'increment': 1} -radius_range = {'from_': 40, 'to': 1000, 'increment': 1} +radius_range = {'from_': 0, 'to': 1000, 'increment': 1} class WidgetState(Enum): @@ -66,7 +66,7 @@ def __init__(self) -> None: self.target_time_b = BooleanVar(value=False) self.min_peak_count = IntVar(value=10) - self.min_radius = DoubleVar(value=0.5) + self.min_radius = DoubleVar(value=40) self.stop_event = ThreadingEvent() From 9b62979457b20f8ddbbf4ac01bef8318ec15cd37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Wed, 1 Jul 2026 13:37:46 +0200 Subject: [PATCH 088/118] Fix some issues found by "coworker" --- .../experiments/scan_ed/detection.py | 8 ++- .../experiments/scan_ed/dispatch.py | 8 +-- .../experiments/scan_ed/progress.py | 7 +-- src/instamatic/experiments/scan_ed/state.py | 2 +- src/instamatic/grid/artist.py | 49 +++++++++++-------- 5 files changed, 39 insertions(+), 35 deletions(-) diff --git a/src/instamatic/experiments/scan_ed/detection.py b/src/instamatic/experiments/scan_ed/detection.py index 6ac6acd1..e5a2d845 100644 --- a/src/instamatic/experiments/scan_ed/detection.py +++ b/src/instamatic/experiments/scan_ed/detection.py @@ -58,20 +58,18 @@ def ring_percentile_detection( # Build a locally-averaged "score" image for candidate selection. score = frame.astype(np.float32, copy=False) if mask is not None: - m = mask.astype(np.float32, copy=False) + m = mask.astype(np.float32, copy=True) numer = ndi.gaussian_filter(score * m, sigma=gaussian_sigma, mode='mirror') denom = ndi.gaussian_filter(m, sigma=gaussian_sigma, mode='mirror') score = np.divide(numer, denom, out=np.zeros_like(numer), where=denom > 0) else: - score = ndi.gaussian_filter(score, sigma=gaussian_sigma, mode='mirror') - if gaussian_sigma and gaussian_sigma > 0: + mask = np.ones(frame.shape, dtype=bool) score = ndi.gaussian_filter(score, sigma=float(gaussian_sigma), mode='mirror') cy, cx = estimate_beam_center(frame, sigma=3.0) ys, xs = np.indices(frame.shape) rr = np.sqrt((ys - cy) ** 2 + (xs - cx) ** 2) - valid = mask.astype(bool) if mask is not None else np.ones(frame.shape, dtype=bool) - valid &= rr > min_radius + valid = mask & (rr > min_radius) valid_idx = np.flatnonzero(valid) returned_mask = valid if return_mask else None diff --git a/src/instamatic/experiments/scan_ed/dispatch.py b/src/instamatic/experiments/scan_ed/dispatch.py index 2096e84f..e234e96b 100644 --- a/src/instamatic/experiments/scan_ed/dispatch.py +++ b/src/instamatic/experiments/scan_ed/dispatch.py @@ -148,6 +148,8 @@ def process(self, frame: np.ndarray, header: Optional[dict]) -> int: def write_scan(self, path: AnyPath, all_: bool = False) -> None: """Request workers to write all hit frames from the active scan.""" + if not self.scan_processed.is_set(): + raise RuntimeError('Call handle_feedback() to completion before write_scan().') bn = self._buffer_name for pointer, hit in enumerate(self.hits): paths = [] @@ -164,9 +166,9 @@ def write_scan(self, path: AnyPath, all_: bool = False) -> None: def handle_feedback(self, state: State, region: int, line: int, scan: int) -> None: """Continuously drain the feedback queue until scan is fully processed. - This call modifies a decorated State table. Therefore, either it - must be run from the main thread, or a proxy Progress table must - be used. + This call modifies the decorated State table. Therefore, either + it must be run from the main thread, or a proxy Progress table + must be used. """ while (not self.scan_finished.is_set()) or self._in_flight or self._write_pending: try: diff --git a/src/instamatic/experiments/scan_ed/progress.py b/src/instamatic/experiments/scan_ed/progress.py index f3260ec5..ef033479 100644 --- a/src/instamatic/experiments/scan_ed/progress.py +++ b/src/instamatic/experiments/scan_ed/progress.py @@ -6,17 +6,13 @@ import tkinter.ttk as ttk from collections import Counter from functools import wraps -from typing import Any, Callable, Optional, Protocol, Sequence, Union +from typing import Any, Callable, Optional, Sequence import numpy as np from instamatic._typing import float_nm -class GridWindowProtocol(Protocol): - def __repr__(self) -> str: ... - - def new_counter(**kwargs): """A new counter to sum current hit, peak, step, and total step count.""" starting_dict = {'hits': 0, 'peaks': 0, 'steps': 0, 'n_steps': 0} | kwargs @@ -233,6 +229,7 @@ def clear(self) -> None: self._line_geom.clear() self._scan_totals.clear() self._region_totals.clear() + self._line_totals.clear() class ThreadSafeProgressTableProxy: diff --git a/src/instamatic/experiments/scan_ed/state.py b/src/instamatic/experiments/scan_ed/state.py index 35fd2c3f..398b4abf 100644 --- a/src/instamatic/experiments/scan_ed/state.py +++ b/src/instamatic/experiments/scan_ed/state.py @@ -160,7 +160,7 @@ def finalize_scan(self, region: int, line: int, scan: int, offset: float_nm = 0) idx = pd.IndexSlice[region, line, scan, :] n_peaks = self.steps.loc[idx, 'n_peaks'].to_numpy(np.int16, copy=False) if (n_peaks < 0).any(): - raise RuntimeError('Scan not complete.') + raise RuntimeError(f'Scan incomplete or still in process: {n_peaks}') self.scans.loc[(region, line, scan), 'offset'] = offset diff --git a/src/instamatic/grid/artist.py b/src/instamatic/grid/artist.py index 57fea8b6..58b01b65 100644 --- a/src/instamatic/grid/artist.py +++ b/src/instamatic/grid/artist.py @@ -9,6 +9,7 @@ from matplotlib.figure import Figure from matplotlib.patches import Polygon from matplotlib.ticker import FuncFormatter + from instamatic._typing import float_nm from instamatic.grid.geometry import PeriodicConvexPolygonGridGeometry @@ -83,60 +84,66 @@ def plot( try: if all(x is not None and not x.empty for x in [lines, scans, steps]): slow_idx = 'y0' if (lines['axis'] == 0).all() else 'x0' + fast_idx = 'x0' if (lines['axis'] == 0).all() else 'y0' + slows = lines[slow_idx] - slow_step = (np.max(slows) - np.min(slows)) / (len(slows) - 1) + try: + slow_step = (np.max(slows) - np.min(slows)) / (len(slows) - 1) + except ZeroDivisionError: + slow_step = abs(lines['step']) # fallback: assume same as fast slow_min = np.min(slows) - 0.5 * slow_step slow_max = np.max(slows) + 0.5 * slow_step slow_count = len(slows) max_offset = scans['offset'].abs().max() + fast_step = lines['step'].abs().mean() # TODO fails if zero steps - fast_idx = 'x0' if (lines['axis'] == 0).all() else 'y0' fast_start = lines[fast_idx] fast_end = lines[fast_idx] + lines['step'] * lines['n_steps'] - fast_step = lines['step'].abs().mean() # TODO fails of zero steps - fast_min = np.minimum(fast_start, fast_end).min() - max_offset * fast_step - fast_max = np.maximum(fast_start, fast_end).max() + max_offset * fast_step + fast_min = np.minimum(fast_start, fast_end).min() - max_offset + fast_max = np.maximum(fast_start, fast_end).max() + max_offset fast_count = np.ceil((fast_max - fast_min) / fast_step).astype(int) level = ['region', 'line', 'scan'] hits = {k: g['hits'].to_numpy(dtype=float) for k, g in steps.groupby(level=level)} - patch = np.zeros(shape=(slow_count, fast_count), dtype=float) + hits_matrix = np.zeros(shape=(slow_count, fast_count), dtype=float) for (region, line), line_row in lines.iterrows(): slow = line_row[slow_idx] + step = int(line_row['step']) + n_steps = int(line_row['n_steps']) + fast0 = float(line_row[fast_idx]) i = int((slow - slow_min) // slow_step) - step = line_row['step'] - n_steps = line_row['n_steps'] - fast0 = line_row[fast_idx] - fast1 = fast0 + step * n_steps - fast0, fast1 = (fast0, fast1) if fast0 < fast1 else (fast1, fast0) - sc = scans.loc[(region, line)] offsets = sc['offset'].to_numpy() + hits_array = np.stack([hits[(region, line, s)] for s in sc.index], axis=0) + if step < 0: # reverse dir: flip hit matrix and recalculate fast0 + hits_array = hits_array[:, ::-1] + fast0 = fast0 + step * (n_steps - 1) j0s = np.floor((fast0 - fast_min + offsets) / fast_step).astype(int) - hits_arr = np.stack([hits[(region, line, s)] for s in sc.index], axis=0) - if step < 0: - hits_arr = hits_arr[:, ::-1] for k in range(len(j0s)): j0 = j0s[k] - patch[i, j0 : j0 + n_steps] += hits_arr[k] + hits_matrix[i, j0 : j0 + n_steps] += hits_array[k] # TODO - # patch[i, j0 : j0 + n_steps] += hits_arr[k] # ValueError: operands could not be broadcast together with shapes (0,) (211,) (0,) if fast_idx == 'x0': x0, x1, y0, y1 = fast_min, fast_max, slow_min, slow_max else: x0, x1, y0, y1 = slow_min, slow_max, fast_min, fast_max - patch = patch.T + hits_matrix = hits_matrix.T - a = 0.5 + 0.5 * (patch / patch_max) if (patch_max := patch.max()) > 0 else 0.5 - ax.imshow(patch, cmap='reds', alpha=a, origin='lower', extent=(x0, x1, y0, y1)) + rgba = np.zeros((*hits_matrix.shape, 4), dtype=np.float32) + if (hits_max := hits_matrix.max()) > 0: + rgba[..., 0] = 1.0 # red square with opacity ~ hit density + rgba[..., 3] = hits_matrix / hits_max + ax.imshow(rgba, origin='lower', extent=(x0, x1, y0, y1), aspect='auto', zorder=3) except ValueError: - pass # currently I don't know how to plot this, and this is not my largest concern + import traceback + + traceback.print_exc() # if fails, not my largest concern if limit_x is not None: ax.axvline(-limit_x, color='red', linewidth=1.0, zorder=4) From bcb8fc921842bdbadb649ad2df60295140b983fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Wed, 1 Jul 2026 14:09:47 +0200 Subject: [PATCH 089/118] Clear unused imports --- src/instamatic/experiments/scan_ed/experiment.py | 6 ++---- src/instamatic/experiments/scan_ed/state.py | 3 +-- src/instamatic/gui/scan_ed_frame.py | 7 +++---- 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/src/instamatic/experiments/scan_ed/experiment.py b/src/instamatic/experiments/scan_ed/experiment.py index b2a6006d..f19efd85 100644 --- a/src/instamatic/experiments/scan_ed/experiment.py +++ b/src/instamatic/experiments/scan_ed/experiment.py @@ -1,14 +1,12 @@ from __future__ import annotations import shutil -import time from datetime import datetime, timedelta -from itertools import count, cycle, product +from itertools import count, cycle from pathlib import Path from threading import Thread -from typing import TYPE_CHECKING, Any, Iterator +from typing import TYPE_CHECKING, Any -import numpy as np import pandas as pd from instamatic.calibrate import CalibMovieDelays diff --git a/src/instamatic/experiments/scan_ed/state.py b/src/instamatic/experiments/scan_ed/state.py index 398b4abf..273ebe47 100644 --- a/src/instamatic/experiments/scan_ed/state.py +++ b/src/instamatic/experiments/scan_ed/state.py @@ -1,8 +1,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Callable, Optional, Sequence +from typing import TYPE_CHECKING, Optional, Sequence -import numpy as np import pandas as pd from instamatic._collections import NoOverwriteDict diff --git a/src/instamatic/gui/scan_ed_frame.py b/src/instamatic/gui/scan_ed_frame.py index 34c0ab52..f47384ac 100644 --- a/src/instamatic/gui/scan_ed_frame.py +++ b/src/instamatic/gui/scan_ed_frame.py @@ -135,7 +135,7 @@ def __init__(self, parent): self.grid_finder = OptionMenu(f, self.var.grid_finder, m[1], *m) self.grid_finder.grid(row=3, column=3, **pad10) - text = 'Finish conditions – experiment ends once:' + text = 'Finish experiment once:' Label(f, text=text).grid(row=4, column=2, columnspan=2, **pad10) text = 'Hits exceed:' @@ -183,7 +183,7 @@ def __init__(self, parent): f.pack(side='top', fill=BOTH, expand=True, pady=10) g = Frame(self) - for column in range(3): + for column in range(4): g.grid_columnconfigure(column, weight=1, uniform='buttons') self.start_button = Button(g, text='Start collection', command=self.run_start) @@ -198,8 +198,7 @@ def __init__(self, parent): g.pack(side='bottom', fill=X, padx=10, pady=(0, 10)) # pad from the bottom only def _run(self, mode: SCAN_ED_MODE) -> None: - """Schedule the scan_ed job on the experiment thread in appropriate - mode.""" + """Schedule the scan_ed job on the experiment thread in given mode.""" self.progress.clear() callback = ThreadSafeTkCallback(self, self.update_widget) progress = ThreadSafeProgressTableProxy(self, self.progress) From b789c38071e1dceeb5832bcc73bef42a5ec0a062 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Thu, 2 Jul 2026 18:07:23 +0200 Subject: [PATCH 090/118] Simplify, replace dispatch dataclasses with dicts --- .../experiments/scan_ed/dispatch.py | 206 +++++++++--------- src/instamatic/experiments/scan_ed/state.py | 2 +- 2 files changed, 101 insertions(+), 107 deletions(-) diff --git a/src/instamatic/experiments/scan_ed/dispatch.py b/src/instamatic/experiments/scan_ed/dispatch.py index e234e96b..61a4c261 100644 --- a/src/instamatic/experiments/scan_ed/dispatch.py +++ b/src/instamatic/experiments/scan_ed/dispatch.py @@ -4,15 +4,14 @@ import os import queue import uuid -from dataclasses import dataclass from itertools import count from multiprocessing.shared_memory import SharedMemory from pathlib import Path from threading import Event -from typing import TYPE_CHECKING, Any, Optional +from typing import TYPE_CHECKING, Any, Optional, Sequence import numpy as np -from typing_extensions import Literal +from typing_extensions import Literal, TypeAlias from instamatic._typing import AnyPath from instamatic.experiments.scan_ed.detection import DiffHuntResults, ring_percentile_detection @@ -26,26 +25,8 @@ CommandKind = Literal['CONFIGURE', 'INIT', 'PROCESS', 'WRITE', 'TERMINATE'] FeedbackKind = Literal['PROCESSING', 'PROCESSED', 'WRITTEN'] - -@dataclass(frozen=True) -class Command: - """Schema used to communicate commands from dispatcher to any worker.""" - - kind: CommandKind - buffer_name: Optional[str] = None - buffer_pointer: Optional[int] = None - buffer_shape: Optional[tuple[int, int, int]] = None - kwargs: Optional[dict] = None - - -@dataclass(frozen=True) -class Feedback: - """Schema used to communicate feedback from any worker to dispatcher.""" - - kind: FeedbackKind - worker_id: int - buffer_pointer: Optional[int] = None - details: Optional[DiffHuntResults] = None +Command: TypeAlias = tuple[CommandKind, dict[str, Any]] +Feedback: TypeAlias = tuple[FeedbackKind, dict[str, Any]] class DiffHuntDispatcher: @@ -54,11 +35,11 @@ class DiffHuntDispatcher: def __init__(self, shape: tuple[int, int], dtype: np.dtype) -> None: self.shape: tuple[int, int] = shape self.dtype: np.dtype = np.dtype(dtype) - self.commands: list[mp.Queue[Command]] = [] - self.feedback: mp.Queue[Feedback] = mp.Queue() + self.command_queues: list[mp.Queue[Command]] = [] + self.feedback_queue: mp.Queue[Feedback] = mp.Queue() self._round_robin = count() - self._workers: list[mp.Process] = [] + self._workers: list[DiffHuntWorker] = [] self._spawn_workers() self._buffer_name: str = '' @@ -78,21 +59,20 @@ def __init__(self, shape: tuple[int, int], dtype: np.dtype) -> None: def _spawn_workers(self) -> None: """Run once at the start of experiment to spawn eval processes.""" for wid in range(N_PROCESSORS): - command_queue = mp.Queue() - worker = DiffHuntWorker(wid, command_queue, self.feedback, self.dtype) - worker.start() - self.commands.append(command_queue) - self._workers.append(worker) + w = DiffHuntWorker(wid, (q := mp.Queue()), self.feedback_queue, self.dtype) + w.start() + self.command_queues.append(q) + self._workers.append(w) - def emit(self, task: CommandKind, *args, **kwargs) -> None: + def emit(self, task: CommandKind, **kwargs) -> None: """Shorthand to create and put Command in next self.commands queue.""" - q = self.commands[next(self._round_robin) % N_PROCESSORS] - q.put(Command(task, *args, **kwargs)) + q = self.command_queues[next(self._round_robin) % N_PROCESSORS] + q.put((task, kwargs)) - def configure(self, **params: dict[str, Any]) -> None: - """Update worker config with provided params dictionary.""" - for _ in self._workers: - self.emit('CONFIGURE', **params) + def emit_all(self, task: CommandKind, **kwargs) -> None: + """Shorthand to create and put Command in ALL self.commands queues.""" + for q in self.command_queues: + q.put((task, kwargs)) def begin_scan(self, n_frames: int, name: Optional[str] = None) -> None: """Allocate a new shared buffer and reset all tracking for one scan.""" @@ -109,8 +89,7 @@ def begin_scan(self, n_frames: int, name: Optional[str] = None) -> None: self.scan_processed.clear() self.hits = np.zeros(self._n_frames, dtype=bool) self.headers = [None] * self._n_frames - for _ in self._workers: - self.emit('INIT', buffer_name=self._buffer_name, buffer_shape=shape3) + self.emit_all('INIT', buffer_name=self._buffer_name, buffer_shape=shape3) def end_scan(self) -> None: """Release shared memory for the active scan.""" @@ -151,7 +130,7 @@ def write_scan(self, path: AnyPath, all_: bool = False) -> None: if not self.scan_processed.is_set(): raise RuntimeError('Call handle_feedback() to completion before write_scan().') bn = self._buffer_name - for pointer, hit in enumerate(self.hits): + for ptr, hit in enumerate(self.hits): paths = [] if all_: paths.append(str(Path(path) / 'all')) @@ -159,44 +138,43 @@ def write_scan(self, path: AnyPath, all_: bool = False) -> None: paths.append(str(Path(path) / 'tiff')) if not paths: continue - self._write_pending.add(pointer) - kwargs = {'paths': paths, 'header': self.headers[pointer]} - self.emit('WRITE', buffer_name=bn, buffer_pointer=pointer, kwargs=kwargs) + self._write_pending.add(ptr) + kwargs = {'paths': paths, 'header': self.headers[ptr]} + self.emit('WRITE', buffer_name=bn, buffer_pointer=ptr, **kwargs) def handle_feedback(self, state: State, region: int, line: int, scan: int) -> None: """Continuously drain the feedback queue until scan is fully processed. - This call modifies the decorated State table. Therefore, either - it must be run from the main thread, or a proxy Progress table - must be used. + This call modifies the decorated State table. Thus, either it + must be run from the main thread, or a proxy Progress table must + be used. """ while (not self.scan_finished.is_set()) or self._in_flight or self._write_pending: try: - fb: Feedback = self.feedback.get(timeout=15) + fb_name, fb_kwargs = self.feedback_queue.get(timeout=15) except queue.Empty as e: raise RuntimeError('Did not receive Feedback within 15s') from e - pointer = int(fb.buffer_pointer) + ptr = int(fb_kwargs['buffer_pointer']) - if fb.kind == 'PROCESSING': - state.mark_processing(region, line, scan, pointer) + if fb_name == 'PROCESSING': + state.mark_processing(region, line, scan, ptr) - elif fb.kind == 'PROCESSED': - d: DiffHuntResults = fb.details - state.fill_step(region, line, scan, pointer, d.success, d.light, len(d.peaks)) + elif fb_name == 'PROCESSED': + d: DiffHuntResults = fb_kwargs['details'] + state.fill_step(region, line, scan, ptr, d.success, d.light, len(d.peaks)) if self.hits is not None: - self.hits[pointer] = d.success - self._in_flight.discard(pointer) + self.hits[ptr] = d.success + self._in_flight.discard(ptr) - elif fb.kind == 'WRITTEN': - self._write_pending.discard(pointer) + elif fb_name == 'WRITTEN': + self._write_pending.discard(ptr) self.scan_processed.set() def terminate_workers(self) -> None: """Command all workers to 'TERMINATE' and report the success.""" - for _ in self._workers: - self.emit('TERMINATE') + self.emit_all('TERMINATE') for p in self._workers: p.join() p.close() @@ -213,53 +191,69 @@ def __init__(self, worker_id: int, commands: mp.Queue, feedback: mp.Queue, dtype self.config: dict[str, Any] = {} self.frames: Optional[np.ndarray] = None self.shm: Optional[SharedMemory] = None + self.terminating: bool = False def emit(self, kind: FeedbackKind, **kwargs) -> None: - self.feedback.put(Feedback(kind=kind, worker_id=self.worker_id, **kwargs)) + kwargs['worker_id'] = self.worker_id + self.feedback.put((kind, kwargs)) def run(self) -> None: - while True: - cmd: Command = self.commands.get() - - if cmd.kind == 'INIT': - if self.shm is not None: - self.shm.close() - self.shm = SharedMemory(name=cmd.buffer_name) - shape = cmd.buffer_shape - self.frames = np.ndarray(shape, dtype=self.dtype, buffer=self.shm.buf) - - elif cmd.kind == 'CONFIGURE': - self.config.update(cmd.kwargs) - - elif cmd.kind == 'PROCESS': - ptr = int(cmd.buffer_pointer) - self.emit('PROCESSING', buffer_pointer=ptr) - try: - d = ring_percentile_detection(frame=self.frames[ptr], **self.config) - except Exception as e: - d = DiffHuntResults(success=False) - finally: - self.emit('PROCESSED', buffer_pointer=ptr, details=d) - - elif cmd.kind == 'WRITE': - try: - dirs = [Path(p).resolve() for p in cmd.kwargs['paths']] - filename = f'{cmd.buffer_name}_{cmd.buffer_pointer:06d}.tiff' - frame = self.frames[cmd.buffer_pointer] - header = cmd.kwargs.get('header', {}) - first = dirs[0] / filename - first.parent.mkdir(parents=True, exist_ok=True) - write_tiff(fname=str(first), data=frame, header=header) - for d in dirs[1:]: # I assume no cross-device and won't raise - d.mkdir(parents=True, exist_ok=True) - target = d / filename - if target.exists() or target.is_symlink(): - target.unlink() - os.link(first, target) - finally: - self.emit('WRITTEN', buffer_pointer=cmd.buffer_pointer) - - elif cmd.kind == 'TERMINATE': - if self.shm is not None: - self.shm.close() - return + """Run the worker, continuously await and run `self.cmd_*` commands.""" + while not self.terminating: + cmd_name, cmd_kwargs = self.commands.get() + cmd_method = getattr(self, f'cmd_{cmd_name.lower()}') + cmd_method(**cmd_kwargs) + + # ~~~~~~~~~~~~~~~~~~~~~~~~~~ self.cmd_COMMANDS ~~~~~~~~~~~~~~~~~~~~~~~~~~~ # + + def cmd_init(self, *, buffer_name: str, buffer_shape: tuple[int, ...]) -> None: + """INIT: Close previous buffer if exists and reattach to a new one.""" + if self.shm is not None: + self.shm.close() + self.shm = SharedMemory(name=buffer_name) + self.frames = np.ndarray(buffer_shape, dtype=self.dtype, buffer=self.shm.buf) + + def cmd_configure(self, **diffhunt_kwargs) -> None: + """CONFIGURE: Pass kwargs to self.config to be used at peak finding""" + self.config.update(**diffhunt_kwargs) + + def cmd_process(self, *, buffer_pointer: int) -> None: + """PROCESS: Eval diffraction results for image at assigned pointer""" + ptr = int(buffer_pointer) + self.emit('PROCESSING', buffer_pointer=ptr) + try: + d = ring_percentile_detection(frame=self.frames[ptr], **self.config) + except Exception as e: + d = DiffHuntResults(success=False) + finally: + self.emit('PROCESSED', buffer_pointer=ptr, details=d) + + def cmd_write( + self, + *, + paths: Sequence[AnyPath], + buffer_name: str, + buffer_pointer: int, + header: dict[str, Any], + ) -> None: + """WRITE: save image at assigned pointer on drive under buffer name""" + try: + dirs = [Path(p).resolve() for p in paths] + filename = f'{buffer_name}_{buffer_pointer:06d}.tiff' + frame = self.frames[buffer_pointer] + first = dirs[0] / filename + first.parent.mkdir(parents=True, exist_ok=True) + write_tiff(fname=str(first), data=frame, header=header) + for d in dirs[1:]: # I assume no cross-device and won't raise + d.mkdir(parents=True, exist_ok=True) + target = d / filename + if target.exists() or target.is_symlink(): + target.unlink() + os.link(first, target) + finally: + self.emit('WRITTEN', buffer_pointer=buffer_pointer) + + def cmd_terminate(self) -> None: + self.terminating = True + if self.shm is not None: + self.shm.close() diff --git a/src/instamatic/experiments/scan_ed/state.py b/src/instamatic/experiments/scan_ed/state.py index 273ebe47..803580f7 100644 --- a/src/instamatic/experiments/scan_ed/state.py +++ b/src/instamatic/experiments/scan_ed/state.py @@ -128,7 +128,7 @@ def add_line( @edits_journal def configure_dispatcher(self, **params) -> None: assert self.dispatcher is not None - self.dispatcher.configure(**params) + self.dispatcher.emit_all('CONFIGURE', **params) @edits_journal @edits_progress From 140271da81418e7a84b55f7473f30caaf2b8ae97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Thu, 2 Jul 2026 19:23:53 +0200 Subject: [PATCH 091/118] Fix controller typing bug; TODO better re/processing --- src/instamatic/controller.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/instamatic/controller.py b/src/instamatic/controller.py index dea63ff2..36022631 100644 --- a/src/instamatic/controller.py +++ b/src/instamatic/controller.py @@ -709,7 +709,7 @@ def get_movie( comment: str = '', header_keys: Tuple[str] = MOVIE_HEADER_KEYS_VARIABLE, header_keys_common: Tuple[str] = MOVIE_HEADER_KEYS_COMMON, - ) -> Generator[np.ndarray, None, None]: + ) -> Generator[tuple[np.ndarray, dict], None, None]: """Generate (image, header) pairs using camera's movie mode. If the exposure and binsize are not given, the default values are read from the config file. Common header info is collected before the generator @@ -732,8 +732,8 @@ def get_movie( Yields ------- - image_header: Generator[(np.ndarray, collections.ChainMap), None, None] - Generator of (numpy arrays with image data, ChainMap with + image_header: Generator[(np.ndarray, dict), None, None] + Generator of (numpy arrays with image data, dict with all the tem parameters and image attributes) pairs. Usage: From 6d256b70892e0d5e4a503db2256e658c7770a2bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Tue, 7 Jul 2026 15:05:44 +0200 Subject: [PATCH 092/118] Implement optimized processing via free worker pool instead of round robin --- .../experiments/scan_ed/dispatch.py | 168 ++++++++++-------- .../experiments/scan_ed/experiment.py | 28 +-- 2 files changed, 102 insertions(+), 94 deletions(-) diff --git a/src/instamatic/experiments/scan_ed/dispatch.py b/src/instamatic/experiments/scan_ed/dispatch.py index 61a4c261..ba9658a3 100644 --- a/src/instamatic/experiments/scan_ed/dispatch.py +++ b/src/instamatic/experiments/scan_ed/dispatch.py @@ -4,11 +4,9 @@ import os import queue import uuid -from itertools import count from multiprocessing.shared_memory import SharedMemory from pathlib import Path -from threading import Event -from typing import TYPE_CHECKING, Any, Optional, Sequence +from typing import TYPE_CHECKING, Any, Iterable, Optional, Sequence import numpy as np from typing_extensions import Literal, TypeAlias @@ -30,7 +28,7 @@ class DiffHuntDispatcher: - """Proxy class: ask workers on other processes if image has diffraction""" + """Proxy class: ask workers on other processes if image has diffraction.""" def __init__(self, shape: tuple[int, int], dtype: np.dtype) -> None: self.shape: tuple[int, int] = shape @@ -38,7 +36,6 @@ def __init__(self, shape: tuple[int, int], dtype: np.dtype) -> None: self.command_queues: list[mp.Queue[Command]] = [] self.feedback_queue: mp.Queue[Feedback] = mp.Queue() - self._round_robin = count() self._workers: list[DiffHuntWorker] = [] self._spawn_workers() @@ -47,12 +44,9 @@ def __init__(self, shape: tuple[int, int], dtype: np.dtype) -> None: self._frames: Optional[np.ndarray] = None self._n_frames: int = 0 - self._next_ptr: int = 0 - self._in_flight: set[int] = set() - self._write_pending: set[int] = set() + self._busy_workers: dict[int, Optional[int]] = {} # worker ID: pointer + self._free_workers: set[int] = set() # IDs of worker not running a task - self.scan_finished: Event = Event() - self.scan_processed: Event = Event() self.hits: Optional[np.ndarray] = None self.headers: list[Optional[dict]] = [] @@ -65,9 +59,10 @@ def _spawn_workers(self) -> None: self._workers.append(w) def emit(self, task: CommandKind, **kwargs) -> None: - """Shorthand to create and put Command in next self.commands queue.""" - q = self.command_queues[next(self._round_robin) % N_PROCESSORS] - q.put((task, kwargs)) + """Shorthand to create and put Command in free self.commands queue.""" + wid = self._free_workers.pop() + self._busy_workers[wid] = kwargs.get('buffer_pointer', None) + self.command_queues[wid].put((task, kwargs)) def emit_all(self, task: CommandKind, **kwargs) -> None: """Shorthand to create and put Command in ALL self.commands queues.""" @@ -78,15 +73,12 @@ def begin_scan(self, n_frames: int, name: Optional[str] = None) -> None: """Allocate a new shared buffer and reset all tracking for one scan.""" self._buffer_name = name or uuid.uuid4().hex self._n_frames = int(n_frames) - self._next_ptr = 0 - self._in_flight.clear() - self._write_pending.clear() + self._free_workers = set(range(N_PROCESSORS)) + self._busy_workers = {} shape3 = (self._n_frames, self.shape[0], self.shape[1]) size = int(np.prod(shape3) * self.dtype.itemsize) self._shm = SharedMemory(name=self._buffer_name, create=True, size=size) self._frames = np.ndarray(shape3, dtype=self.dtype, buffer=self._shm.buf) - self.scan_finished.clear() - self.scan_processed.clear() self.hits = np.zeros(self._n_frames, dtype=bool) self.headers = [None] * self._n_frames self.emit_all('INIT', buffer_name=self._buffer_name, buffer_shape=shape3) @@ -103,77 +95,103 @@ def end_scan(self) -> None: self._frames = None self._buffer_name = '' self._n_frames = 0 - self._next_ptr = 0 - self._in_flight.clear() - self._write_pending.clear() self.hits = None self.headers = [] - def process(self, frame: np.ndarray, header: Optional[dict]) -> int: - """Copy a frame into the shared buffer and enqueue processing.""" + def _handle_one_fb(self, state: State, region: int, line: int, scan: int) -> None: + """Receive one feedback item and apply it to state and bookkeeping. + + PROCESSING: update the state table (no worker freed yet — still running). + PROCESSED: record result, discard from in-flight, mark worker free. + WRITTEN: discard from write-pending, mark worker free. + """ + try: + fb_name, fb_kwargs = self.feedback_queue.get(timeout=15) + except queue.Empty as e: + raise RuntimeError('Did not receive feedback within 15 s') from e + + wid = int(fb_kwargs['worker_id']) + ptr = int(fb_kwargs['buffer_pointer']) + + if fb_name == 'PROCESSING': + state.mark_processing(region, line, scan, ptr) + + elif fb_name == 'PROCESSED': + d: DiffHuntResults = fb_kwargs['details'] + state.fill_step(region, line, scan, ptr, d.success, d.light, len(d.peaks)) + if self.hits is not None: + self.hits[ptr] = d.success + + if fb_name in {'PROCESSED', 'WRITTEN'}: + self._busy_workers.pop(wid, None) + self._free_workers.add(wid) + + def process_scan( + self, + movie: Iterable[tuple[np.ndarray, Optional[dict]]], + state: State, + region: int, + line: int, + scan: int, + ) -> None: + """Write `movie` frames into shared buffer, dispatch PROCESS tasks.""" if self._frames is None: raise RuntimeError('Call begin_scan() first.') - if self._next_ptr >= self._n_frames: - raise RuntimeError('Buffer overflow for active scan.') - - ptr = self._next_ptr - self._frames[ptr, :, :] = frame - self.headers[ptr] = header - self._in_flight.add(ptr) - self._next_ptr += 1 - - self.emit('PROCESS', buffer_pointer=ptr) - return ptr - - def write_scan(self, path: AnyPath, all_: bool = False) -> None: - """Request workers to write all hit frames from the active scan.""" - if not self.scan_processed.is_set(): - raise RuntimeError('Call handle_feedback() to completion before write_scan().') - bn = self._buffer_name - for ptr, hit in enumerate(self.hits): - paths = [] - if all_: - paths.append(str(Path(path) / 'all')) - if hit: - paths.append(str(Path(path) / 'tiff')) - if not paths: - continue - self._write_pending.add(ptr) - kwargs = {'paths': paths, 'header': self.headers[ptr]} - self.emit('WRITE', buffer_name=bn, buffer_pointer=ptr, **kwargs) - def handle_feedback(self, state: State, region: int, line: int, scan: int) -> None: - """Continuously drain the feedback queue until scan is fully processed. + for ptr, (frame, header) in enumerate(movie): + if ptr >= self._n_frames: + raise RuntimeError('Buffer overflow for active scan.') - This call modifies the decorated State table. Thus, either it - must be run from the main thread, or a proxy Progress table must - be used. - """ - while (not self.scan_finished.is_set()) or self._in_flight or self._write_pending: - try: - fb_name, fb_kwargs = self.feedback_queue.get(timeout=15) - except queue.Empty as e: - raise RuntimeError('Did not receive Feedback within 15s') from e + while not self._free_workers: # Block until some worker finishes. + self._handle_one_fb(state, region, line, scan) - ptr = int(fb_kwargs['buffer_pointer']) + self._frames[ptr] = frame + self.headers[ptr] = header + self.emit('PROCESS', buffer_pointer=ptr) - if fb_name == 'PROCESSING': - state.mark_processing(region, line, scan, ptr) + # Movie exhausted — drain until every dispatched frame is accounted for. + while self._busy_workers: + self._handle_one_fb(state, region, line, scan) - elif fb_name == 'PROCESSED': - d: DiffHuntResults = fb_kwargs['details'] - state.fill_step(region, line, scan, ptr, d.success, d.light, len(d.peaks)) - if self.hits is not None: - self.hits[ptr] = d.success - self._in_flight.discard(ptr) + def write_scan( + self, + path: AnyPath, + state: State, + region: int, + line: int, + scan: int, + all_: bool = False, + ) -> None: + """Send WRITE tasks for all hit frames and block until every write + completes. + + Identical flow to process(): dispatch to a free worker, drain + feedback whenever all workers are busy, and finish with a final + drain loop. + """ + if self.hits is None: + raise RuntimeError('Call begin_scan() first.') + + bn = self._buffer_name + for ptr, hit in enumerate(self.hits): + h: dict = self.headers[ptr] + p: list[str] = [] + if all_: + p.append(str(Path(path) / 'all')) + if hit: + p.append(str(Path(path) / 'tiff')) + if not p: + continue - elif fb_name == 'WRITTEN': - self._write_pending.discard(ptr) + while not self._free_workers: + self._handle_one_fb(state, region, line, scan) + self.emit('WRITE', paths=p, header=h, buffer_name=bn, buffer_pointer=ptr) - self.scan_processed.set() + while self._busy_workers: + self._handle_one_fb(state, region, line, scan) def terminate_workers(self) -> None: - """Command all workers to 'TERMINATE' and report the success.""" + """Command all workers to terminate and join them.""" self.emit_all('TERMINATE') for p in self._workers: p.join() diff --git a/src/instamatic/experiments/scan_ed/experiment.py b/src/instamatic/experiments/scan_ed/experiment.py index f19efd85..2350dd47 100644 --- a/src/instamatic/experiments/scan_ed/experiment.py +++ b/src/instamatic/experiments/scan_ed/experiment.py @@ -21,11 +21,7 @@ from instamatic.experiments.scan_ed.state import State from instamatic.formats import read_tiff from instamatic.grid.artist import plot -from instamatic.grid.geometry import ( - GRID_REGISTRY, - PeriodicConvexPolygonGridGeometry, - WindowType, -) +from instamatic.grid.geometry import GRID_REGISTRY, PeriodicConvexPolygonGridGeometry from instamatic.grid.sweeping import star_sweep from instamatic.gui.click_dispatcher import ClickListener, MouseButton @@ -357,12 +353,10 @@ def set_stop_event_if_target_met(self) -> None: def run_scan(self, region_idx: int, line_idx: int, scan_idx: int) -> None: """Run a single scan previously added to state on the grid.""" - idx = pd.IndexSlice[region_idx, line_idx, scan_idx, :] if np.any(self.state.steps.loc[idx, 'n_peaks'] != -1): - return # none-op for a scans that has been already done + return # non-op for a scans that has been already done n_frames = int(self.state.lines.loc[(region_idx, line_idx), 'n_steps']) - line = self.state.lines.loc[(region_idx, line_idx)] self.ctrl.stage.set(x=line['x0'], y=line['y0']) @@ -372,11 +366,8 @@ def run_scan(self, region_idx: int, line_idx: int, scan_idx: int) -> None: name = f'r{region_idx:03d}_l{line_idx:06d}_s{scan_idx:03d}' self.dispatcher.begin_scan(n_frames, name=name) - kw = {'state': self.state, 'region': region_idx, 'line': line_idx, 'scan': scan_idx} - fb_thread = Thread(target=self.dispatcher.handle_feedback, kwargs=kw) - fb_thread.start() - exposure, speed, _ = self.determine_timing(line['step']) # loc of 'step' does not work + exposure, speed, _ = self.determine_timing(line['step']) axis = line['axis'] # x: 0, y: 1 fast0 = line['y0' if axis else 'x0'] fast1 = fast0 + line['step'] * line['n_steps'] @@ -384,14 +375,13 @@ def run_scan(self, region_idx: int, line_idx: int, scan_idx: int) -> None: self.ctrl.stage.set_with_speed(**setter_kwargs, wait=False) movie = self.ctrl.get_movie(n_frames=n_frames, exposure=exposure, header_keys=None) - for frame, header in movie: - self.dispatcher.process(frame, header) - self.dispatcher.scan_finished.set() # signals no more data is coming - self.dispatcher.scan_processed.wait(timeout=60) # should process live + kw = dict(state=self.state, region=region_idx, line=line_idx, scan=scan_idx) + self.dispatcher.process_scan(movie, **kw) self.ctrl.stage.wait() - self.dispatcher.write_scan(path=self.path, all_=self.params.get('save_all', False)) - self.dispatcher.handle_feedback(self.state, region_idx, line_idx, scan_idx) + self.dispatcher.write_scan( + path=self.path, **kw, all_=self.params.get('save_all', False) + ) self.dispatcher.end_scan() def finalize_scan(self, region_idx: int, line_idx: int, scan_idx: int) -> None: @@ -452,7 +442,7 @@ def reprocess_scan(self, region_idx, line_idx, scan_idx) -> None: for frame_path in frame_paths: frame, header = read_tiff(str(frame_path)) - self.dispatcher.process(frame, header=header) + self.dispatcher.process_scan(frame, header=header) self.dispatcher.scan_finished.set() self.dispatcher.scan_processed.wait(timeout=60) From 1fd4a3e27ce993ca1af376b515d2b8b0372163f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Tue, 7 Jul 2026 17:23:59 +0200 Subject: [PATCH 093/118] Further simplify. link state in dispatcher, add faster reprocessing, do not overwrite tiff --- .../experiments/scan_ed/dispatch.py | 70 ++++++++----------- .../experiments/scan_ed/experiment.py | 39 ++++------- 2 files changed, 42 insertions(+), 67 deletions(-) diff --git a/src/instamatic/experiments/scan_ed/dispatch.py b/src/instamatic/experiments/scan_ed/dispatch.py index ba9658a3..cfd003f8 100644 --- a/src/instamatic/experiments/scan_ed/dispatch.py +++ b/src/instamatic/experiments/scan_ed/dispatch.py @@ -6,14 +6,14 @@ import uuid from multiprocessing.shared_memory import SharedMemory from pathlib import Path -from typing import TYPE_CHECKING, Any, Iterable, Optional, Sequence +from typing import TYPE_CHECKING, Any, Iterable, Optional, Sequence, Union import numpy as np from typing_extensions import Literal, TypeAlias from instamatic._typing import AnyPath from instamatic.experiments.scan_ed.detection import DiffHuntResults, ring_percentile_detection -from instamatic.formats import write_tiff +from instamatic.formats import read_tiff, write_tiff if TYPE_CHECKING: from instamatic.experiments.scan_ed.state import State @@ -30,22 +30,23 @@ class DiffHuntDispatcher: """Proxy class: ask workers on other processes if image has diffraction.""" - def __init__(self, shape: tuple[int, int], dtype: np.dtype) -> None: + def __init__(self, state: State, shape: tuple[int, int], dtype: np.dtype) -> None: + self.state: State = state # directly affect the state: fill scans, steps self.shape: tuple[int, int] = shape self.dtype: np.dtype = np.dtype(dtype) + self.command_queues: list[mp.Queue[Command]] = [] self.feedback_queue: mp.Queue[Feedback] = mp.Queue() self._workers: list[DiffHuntWorker] = [] self._spawn_workers() + self._busy_workers: dict[int, Optional[int]] = {} # worker ID: pointer + self._free_workers: set[int] = set() # IDs of worker not running a task self._buffer_name: str = '' self._shm: Optional[SharedMemory] = None self._frames: Optional[np.ndarray] = None - self._n_frames: int = 0 - self._busy_workers: dict[int, Optional[int]] = {} # worker ID: pointer - self._free_workers: set[int] = set() # IDs of worker not running a task self.hits: Optional[np.ndarray] = None self.headers: list[Optional[dict]] = [] @@ -98,7 +99,7 @@ def end_scan(self) -> None: self.hits = None self.headers = [] - def _handle_one_fb(self, state: State, region: int, line: int, scan: int) -> None: + def _handle_feedback(self, region: int, line: int, scan: int) -> None: """Receive one feedback item and apply it to state and bookkeeping. PROCESSING: update the state table (no worker freed yet — still running). @@ -111,14 +112,15 @@ def _handle_one_fb(self, state: State, region: int, line: int, scan: int) -> Non raise RuntimeError('Did not receive feedback within 15 s') from e wid = int(fb_kwargs['worker_id']) - ptr = int(fb_kwargs['buffer_pointer']) + ptr = int(self._busy_workers.get(wid, -1)) if fb_name == 'PROCESSING': - state.mark_processing(region, line, scan, ptr) + self.state.mark_processing(region, line, scan, ptr) elif fb_name == 'PROCESSED': d: DiffHuntResults = fb_kwargs['details'] - state.fill_step(region, line, scan, ptr, d.success, d.light, len(d.peaks)) + p = len(d.peaks) + self.state.fill_step(region, line, scan, ptr, d.success, d.light, p) if self.hits is not None: self.hits[ptr] = d.success @@ -128,8 +130,7 @@ def _handle_one_fb(self, state: State, region: int, line: int, scan: int) -> Non def process_scan( self, - movie: Iterable[tuple[np.ndarray, Optional[dict]]], - state: State, + movie: Iterable[Union[tuple[np.ndarray, Optional[dict]], AnyPath]], region: int, line: int, scan: int, @@ -138,37 +139,23 @@ def process_scan( if self._frames is None: raise RuntimeError('Call begin_scan() first.') - for ptr, (frame, header) in enumerate(movie): + for ptr, src in enumerate(movie): + frame, header = src if isinstance(src, tuple) else read_tiff(src) if ptr >= self._n_frames: raise RuntimeError('Buffer overflow for active scan.') while not self._free_workers: # Block until some worker finishes. - self._handle_one_fb(state, region, line, scan) + self._handle_feedback(region, line, scan) self._frames[ptr] = frame self.headers[ptr] = header self.emit('PROCESS', buffer_pointer=ptr) - # Movie exhausted — drain until every dispatched frame is accounted for. - while self._busy_workers: - self._handle_one_fb(state, region, line, scan) + while self._busy_workers: # drain until every worker is accounted for + self._handle_feedback(region, line, scan) - def write_scan( - self, - path: AnyPath, - state: State, - region: int, - line: int, - scan: int, - all_: bool = False, - ) -> None: - """Send WRITE tasks for all hit frames and block until every write - completes. - - Identical flow to process(): dispatch to a free worker, drain - feedback whenever all workers are busy, and finish with a final - drain loop. - """ + def write_scan(self, path: AnyPath, all_: bool = False) -> None: + """Send WRITE for all hit frames, block until every write completes.""" if self.hits is None: raise RuntimeError('Call begin_scan() first.') @@ -184,11 +171,11 @@ def write_scan( continue while not self._free_workers: - self._handle_one_fb(state, region, line, scan) + self._handle_feedback(-1, -1, -1) self.emit('WRITE', paths=p, header=h, buffer_name=bn, buffer_pointer=ptr) - while self._busy_workers: - self._handle_one_fb(state, region, line, scan) + while self._busy_workers: # drain until every worker is accounted for + self._handle_feedback(-1, -1, -1) def terminate_workers(self) -> None: """Command all workers to terminate and join them.""" @@ -238,13 +225,13 @@ def cmd_configure(self, **diffhunt_kwargs) -> None: def cmd_process(self, *, buffer_pointer: int) -> None: """PROCESS: Eval diffraction results for image at assigned pointer""" ptr = int(buffer_pointer) - self.emit('PROCESSING', buffer_pointer=ptr) + self.emit('PROCESSING') try: d = ring_percentile_detection(frame=self.frames[ptr], **self.config) - except Exception as e: + except Exception as _: d = DiffHuntResults(success=False) finally: - self.emit('PROCESSED', buffer_pointer=ptr, details=d) + self.emit('PROCESSED', details=d) def cmd_write( self, @@ -261,7 +248,8 @@ def cmd_write( frame = self.frames[buffer_pointer] first = dirs[0] / filename first.parent.mkdir(parents=True, exist_ok=True) - write_tiff(fname=str(first), data=frame, header=header) + if not first.exists(): + write_tiff(fname=str(first), data=frame, header=header) for d in dirs[1:]: # I assume no cross-device and won't raise d.mkdir(parents=True, exist_ok=True) target = d / filename @@ -269,7 +257,7 @@ def cmd_write( target.unlink() os.link(first, target) finally: - self.emit('WRITTEN', buffer_pointer=buffer_pointer) + self.emit('WRITTEN') def cmd_terminate(self) -> None: self.terminating = True diff --git a/src/instamatic/experiments/scan_ed/experiment.py b/src/instamatic/experiments/scan_ed/experiment.py index 2350dd47..6c252ff7 100644 --- a/src/instamatic/experiments/scan_ed/experiment.py +++ b/src/instamatic/experiments/scan_ed/experiment.py @@ -4,7 +4,6 @@ from datetime import datetime, timedelta from itertools import count, cycle from pathlib import Path -from threading import Thread from typing import TYPE_CHECKING, Any import pandas as pd @@ -97,14 +96,14 @@ def get_dead_time( def get_dispatcher_live(self) -> DiffHuntDispatcher: """Start a multiprocessing helper once you have full access to cam.""" - image, h = self.ctrl.get_image() - return DiffHuntDispatcher(shape=image.shape, dtype=image.dtype) + i, _ = self.ctrl.get_image() + return DiffHuntDispatcher(state=self.state, shape=i.shape, dtype=i.dtype) def get_dispatcher_from_file(self) -> DiffHuntDispatcher: """Start a multiprocessing helper using a sample frame on the disk.""" sample_path = next((self.path / 'all').glob('*.tiff')) - sample, _ = read_tiff(str(sample_path)) - return DiffHuntDispatcher(shape=sample.shape, dtype=sample.dtype) + i, _ = read_tiff(str(sample_path)) + return DiffHuntDispatcher(state=self.state, shape=i.shape, dtype=i.dtype) def get_stage_translation(self) -> CalibStageMotion: """Get rotation calibration if present; otherwise warn & terminate.""" @@ -374,14 +373,13 @@ def run_scan(self, region_idx: int, line_idx: int, scan_idx: int) -> None: setter_kwargs = {'xy'[axis]: fast1, 'speed': speed} self.ctrl.stage.set_with_speed(**setter_kwargs, wait=False) - movie = self.ctrl.get_movie(n_frames=n_frames, exposure=exposure, header_keys=None) - kw = dict(state=self.state, region=region_idx, line=line_idx, scan=scan_idx) - self.dispatcher.process_scan(movie, **kw) + m = self.ctrl.get_movie(n_frames=n_frames, exposure=exposure, header_keys=None) + kw = {'region': region_idx, 'line': line_idx, 'scan': scan_idx} + self.dispatcher.process_scan(m, **kw) self.ctrl.stage.wait() - self.dispatcher.write_scan( - path=self.path, **kw, all_=self.params.get('save_all', False) - ) + all_ = self.params.get('save_all', False) + self.dispatcher.write_scan(path=self.path, all_=all_) self.dispatcher.end_scan() def finalize_scan(self, region_idx: int, line_idx: int, scan_idx: int) -> None: @@ -404,7 +402,7 @@ def finalize_scan(self, region_idx: int, line_idx: int, scan_idx: int) -> None: def reprocess_collection(self) -> None: """Re-evaluate frames already saved in `all/` with current detection - params, rewriting the journal's hit data and `tiff/` from scratch. + params, rewriting the journal's hit data and `tiff/`. Never drives the microscope and never resumes collection afterward. @@ -436,20 +434,9 @@ def reprocess_scan(self, region_idx, line_idx, scan_idx) -> None: return self.dispatcher.begin_scan(n_frames, name=name) - kw = {'state': self.state, 'region': region_idx, 'line': line_idx, 'scan': scan_idx} - fb_thread = Thread(target=self.dispatcher.handle_feedback, kwargs=kw) - fb_thread.start() - - for frame_path in frame_paths: - frame, header = read_tiff(str(frame_path)) - self.dispatcher.process_scan(frame, header=header) - - self.dispatcher.scan_finished.set() - self.dispatcher.scan_processed.wait(timeout=60) - self.dispatcher.write_scan( - path=self.path, all_=False - ) # tiff/ hits only, all/ untouched - self.dispatcher.handle_feedback(self.state, region_idx, line_idx, scan_idx) + kw = {'region': region_idx, 'line': line_idx, 'scan': scan_idx} + self.dispatcher.process_scan(frame_paths, **kw) + self.dispatcher.write_scan(path=self.path, all_=True) self.dispatcher.end_scan() self.finalize_scan(region_idx, line_idx, scan_idx) From 5a0560f309e39a7e1975eaf1a0107c5a58b4b422 Mon Sep 17 00:00:00 2001 From: Daniel Tchon Date: Wed, 8 Jul 2026 18:11:37 +0200 Subject: [PATCH 094/118] Baseline changes needed to get (re)processing to work (quite nicely!) --- .../experiments/scan_ed/detection.py | 8 ++++--- .../experiments/scan_ed/dispatch.py | 2 ++ .../experiments/scan_ed/experiment.py | 21 ++++++++++++------- src/instamatic/gui/scan_ed_frame.py | 10 +++++---- 4 files changed, 27 insertions(+), 14 deletions(-) diff --git a/src/instamatic/experiments/scan_ed/detection.py b/src/instamatic/experiments/scan_ed/detection.py index e5a2d845..d428add6 100644 --- a/src/instamatic/experiments/scan_ed/detection.py +++ b/src/instamatic/experiments/scan_ed/detection.py @@ -2,7 +2,7 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path from typing import Optional, Sequence, Union @@ -31,7 +31,7 @@ class DiffHuntResults: success: bool bin_center: Optional[tuple[float, float]] = None bin_edges: Optional[Sequence[float]] = None - peaks: Optional[np.ndarray] = None + peaks: np.ndarray = field(default_factory=lambda: np.empty((0, 2), dtype=int)) mask: Optional[np.ndarray] = None light: int = 0 @@ -47,6 +47,7 @@ def ring_percentile_detection( n_bins: int = 10, gaussian_sigma: float = 1.2, return_mask: bool = False, + **_, ) -> DiffHuntResults: """Radial-binned detector with thresholds computed on a *locally averaged* image. @@ -54,7 +55,7 @@ def ring_percentile_detection( This suppresses single-pixel spikes (stray electrons / hot pixels), while keeping multi-pixel reflection profiles detectable. """ - + print(f'{min_peak_count=}, {min_radius=}') # Build a locally-averaged "score" image for candidate selection. score = frame.astype(np.float32, copy=False) if mask is not None: @@ -227,6 +228,7 @@ def plot_diffraction_debug( r'C:\Users\tchon\x\2026-02-06-SPED_test\experiment_5\tiff\w000000_s000031_0000*.tiff' ) # paths = glob(r'C:\Users\tchon\x\Instamatic_RATS_cRED_benchmark\instamatic_19\tiff\0000*') + paths = [r"G:\USERS\instamatic\2026-07-08\experiment_2\all\r000_l000003_s000_000044.tiff"] for path in paths: tiff = Image.open(path) image = np.array(tiff) diff --git a/src/instamatic/experiments/scan_ed/dispatch.py b/src/instamatic/experiments/scan_ed/dispatch.py index cfd003f8..74000911 100644 --- a/src/instamatic/experiments/scan_ed/dispatch.py +++ b/src/instamatic/experiments/scan_ed/dispatch.py @@ -221,9 +221,11 @@ def cmd_init(self, *, buffer_name: str, buffer_shape: tuple[int, ...]) -> None: def cmd_configure(self, **diffhunt_kwargs) -> None: """CONFIGURE: Pass kwargs to self.config to be used at peak finding""" self.config.update(**diffhunt_kwargs) + print(self.config) def cmd_process(self, *, buffer_pointer: int) -> None: """PROCESS: Eval diffraction results for image at assigned pointer""" + print(self.config) ptr = int(buffer_pointer) self.emit('PROCESSING') try: diff --git a/src/instamatic/experiments/scan_ed/experiment.py b/src/instamatic/experiments/scan_ed/experiment.py index 6c252ff7..6b3c49e5 100644 --- a/src/instamatic/experiments/scan_ed/experiment.py +++ b/src/instamatic/experiments/scan_ed/experiment.py @@ -4,6 +4,7 @@ from datetime import datetime, timedelta from itertools import count, cycle from pathlib import Path +from threading import Event from typing import TYPE_CHECKING, Any import pandas as pd @@ -42,6 +43,7 @@ def __init__( progress: Optional[ProgressTable] = None, mode: SCAN_ED_MODE = 'start', videostream_frame: Optional[vsf_type] = None, + stop_event: Optional[Event] = None, ): super().__init__() self.ctrl = ctrl @@ -53,6 +55,7 @@ def __init__( self._state: Optional[State] = None self.start_time = datetime.now() self.videostream_frame: Optional[vsf_type] = videostream_frame + self.stop_event: Optional[Event] = stop_event # attributes initialized once an experiment starts self.params: dict[str, Any] = {} @@ -137,7 +140,7 @@ def start_collection(self, **params) -> None: if self.dispatcher is None: self.dispatcher = self.get_dispatcher_live() self.state.dispatcher = self.dispatcher - self.state.configure_dispatcher(params=params) + self.state.configure_dispatcher(**params) # if allowed, add manually as many windows as the user desires. self.ctrl.stage.set(a=0) @@ -172,7 +175,7 @@ def start_collection(self, **params) -> None: self.state.update_grid(self.state.grid.to_params()) self.draw_window_to_file(window_idx=window_idx) self.draw_grid_to_file() - if params['stop_event'].is_set(): + if self.stop_event.is_set(): break # sanitation step: assert the current region is in limits @@ -186,10 +189,11 @@ def start_collection(self, **params) -> None: self.run_scan(region_idx, line_idx, scan_idx) self.finalize_scan(region_idx, line_idx, scan_idx) self.set_stop_event_if_target_met() - if params['stop_event'].is_set(): + if self.stop_event.is_set(): break self.draw_hits_to_file() finally: + self.ctrl.stage.wait() self.ctrl.stage.set(a=0) self.draw_hits_to_file() self.teardown() @@ -348,7 +352,7 @@ def set_stop_event_if_target_met(self) -> None: time_passed = datetime.now() - self.start_time time_target = timedelta(hours=tt) if tt else timedelta.max if time_passed > time_target or hits_found > hits_target: - self.params['stop_event'].set() + self.stop_event.set() def run_scan(self, region_idx: int, line_idx: int, scan_idx: int) -> None: """Run a single scan previously added to state on the grid.""" @@ -411,13 +415,16 @@ def reprocess_collection(self) -> None: if self.dispatcher is None: self.dispatcher = self.get_dispatcher_from_file() self.state.dispatcher = self.dispatcher - self.state.configure_dispatcher(params=self.params) + self.state.configure_dispatcher(**self.params) + + rs = self.params.get('region_shape', '1x1') + self.regionalization = Regionalization.from_str(grid=self.state.grid, shape=rs) shutil.rmtree(self.path / 'tiff', ignore_errors=True) for region_idx, line_idx, scan_idx in self.state.scans.index: self.reprocess_scan(region_idx, line_idx, scan_idx) - if self.params['stop_event'].is_set(): + if self.stop_event.is_set(): break self.draw_hits_to_file() @@ -444,7 +451,7 @@ def reprocess_scan(self, region_idx, line_idx, scan_idx) -> None: def teardown(self) -> None: """Close all threads and safely shut down when requested.""" self.dispatcher.terminate_workers() - self.params['stop_event'].clear() + self.stop_event.clear() def tilt_list(self) -> Sequence[float]: """Return a list of tilts from - to + params[tilt_range] for scans.""" diff --git a/src/instamatic/gui/scan_ed_frame.py b/src/instamatic/gui/scan_ed_frame.py index f47384ac..ed40fc54 100644 --- a/src/instamatic/gui/scan_ed_frame.py +++ b/src/instamatic/gui/scan_ed_frame.py @@ -230,11 +230,12 @@ def sced_interface_command(controller, **params: Any) -> None: from instamatic.experiments.scan_ed.experiment import Experiment callback = params.pop('callback', lambda: None) - mode: SCAN_ED_MODE = params.get('mode', 'start') # noqa type - progress: Optional[ProgressTable] = params.get('progress', None) + mode: SCAN_ED_MODE = params.pop('mode', 'start') # noqa type + progress: Optional[ProgressTable] = params.pop('progress', None) flat_field = controller.module_io.get_flatfield() - if params.get('stop_event', None) is not None: - params['stop_event'].clear() + stop_event: Optional[ThreadingEvent] = params.pop('stop_event', None) + if stop_event is not None: + stop_event.clear() if mode == 'start': exp_dir = controller.module_io.get_new_experiment_directory() @@ -262,6 +263,7 @@ def sced_interface_command(controller, **params: Any) -> None: progress=progress, mode=mode, videostream_frame=vsf, + stop_event=stop_event, ) try: controller.fast_adt.start_collection(**params) From e65f2bebe77f938fe2604722d3b788a84aaf19eb Mon Sep 17 00:00:00 2001 From: Daniel Tchon Date: Wed, 8 Jul 2026 20:07:32 +0200 Subject: [PATCH 095/118] Some fixes, remove debug statements; TODO: check movie encoding again... --- .../experiments/scan_ed/detection.py | 1 - .../experiments/scan_ed/dispatch.py | 21 ++++++++++++++++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/instamatic/experiments/scan_ed/detection.py b/src/instamatic/experiments/scan_ed/detection.py index d428add6..1f224f32 100644 --- a/src/instamatic/experiments/scan_ed/detection.py +++ b/src/instamatic/experiments/scan_ed/detection.py @@ -55,7 +55,6 @@ def ring_percentile_detection( This suppresses single-pixel spikes (stray electrons / hot pixels), while keeping multi-pixel reflection profiles detectable. """ - print(f'{min_peak_count=}, {min_radius=}') # Build a locally-averaged "score" image for candidate selection. score = frame.astype(np.float32, copy=False) if mask is not None: diff --git a/src/instamatic/experiments/scan_ed/dispatch.py b/src/instamatic/experiments/scan_ed/dispatch.py index 74000911..c7fa28d8 100644 --- a/src/instamatic/experiments/scan_ed/dispatch.py +++ b/src/instamatic/experiments/scan_ed/dispatch.py @@ -6,6 +6,7 @@ import uuid from multiprocessing.shared_memory import SharedMemory from pathlib import Path +from time import sleep from typing import TYPE_CHECKING, Any, Iterable, Optional, Sequence, Union import numpy as np @@ -51,6 +52,22 @@ def __init__(self, state: State, shape: tuple[int, int], dtype: np.dtype) -> Non self.hits: Optional[np.ndarray] = None self.headers: list[Optional[dict]] = [] + @staticmethod + def _create_shm(name: str, size: int) -> SharedMemory: + """Initialize shared memory, try to close previous one if needed.""" + exc = None + for _ in range(500): + try: + return SharedMemory(name=name, create=True, size=size) + except FileExistsError as e: + old = SharedMemory(name=name, create=False) + old.close() + old.unlink() + exc = e + sleep(0.01) + else: + raise FileExistsError(f'Could not init shared memory {name}') from exc + def _spawn_workers(self) -> None: """Run once at the start of experiment to spawn eval processes.""" for wid in range(N_PROCESSORS): @@ -78,7 +95,7 @@ def begin_scan(self, n_frames: int, name: Optional[str] = None) -> None: self._busy_workers = {} shape3 = (self._n_frames, self.shape[0], self.shape[1]) size = int(np.prod(shape3) * self.dtype.itemsize) - self._shm = SharedMemory(name=self._buffer_name, create=True, size=size) + self._shm = self._create_shm(name=self._buffer_name, size=size) self._frames = np.ndarray(shape3, dtype=self.dtype, buffer=self._shm.buf) self.hits = np.zeros(self._n_frames, dtype=bool) self.headers = [None] * self._n_frames @@ -221,11 +238,9 @@ def cmd_init(self, *, buffer_name: str, buffer_shape: tuple[int, ...]) -> None: def cmd_configure(self, **diffhunt_kwargs) -> None: """CONFIGURE: Pass kwargs to self.config to be used at peak finding""" self.config.update(**diffhunt_kwargs) - print(self.config) def cmd_process(self, *, buffer_pointer: int) -> None: """PROCESS: Eval diffraction results for image at assigned pointer""" - print(self.config) ptr = int(buffer_pointer) self.emit('PROCESSING') try: From b5cb749ee932831c1667eed7895b2448e1a5f9c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Thu, 9 Jul 2026 18:54:04 +0200 Subject: [PATCH 096/118] Temporary change to print/log raw image data --- src/instamatic/camera/camera_serval.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/instamatic/camera/camera_serval.py b/src/instamatic/camera/camera_serval.py index 863f07e2..7df2a02e 100644 --- a/src/instamatic/camera/camera_serval.py +++ b/src/instamatic/camera/camera_serval.py @@ -176,7 +176,17 @@ def get_movie( except socket.timeout: raise TimeoutError('Serval failed to connect back within 5 seconds.') with sock: - yield from ServalMovieDeserializer(sock, n_frames, self.movie_bufsize) + y = ServalMovieDeserializer(sock, n_frames, self.movie_bufsize) + yield from y + + # debugging serval connection + hex_str = y.buffer.hex(' ') + print(f'HEX PRINT: {hex_str}') + logger.info('HEX LOG: %s', hex_str) + + repr_str = repr(y.buffer) + print(f'REPR PRINT: {repr_str}') + logger.info('REPR LOG: %s', repr_str) finally: listener.close() From 5e658e2730bc02e078b78b619cda675d1ff3a30a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Fri, 10 Jul 2026 16:43:47 +0200 Subject: [PATCH 097/118] Potential fixes to the images to be tested --- src/instamatic/camera/camera_serval.py | 44 +++++++++++++++++++------- 1 file changed, 33 insertions(+), 11 deletions(-) diff --git a/src/instamatic/camera/camera_serval.py b/src/instamatic/camera/camera_serval.py index 7df2a02e..f7ae0393 100644 --- a/src/instamatic/camera/camera_serval.py +++ b/src/instamatic/camera/camera_serval.py @@ -176,17 +176,7 @@ def get_movie( except socket.timeout: raise TimeoutError('Serval failed to connect back within 5 seconds.') with sock: - y = ServalMovieDeserializer(sock, n_frames, self.movie_bufsize) - yield from y - - # debugging serval connection - hex_str = y.buffer.hex(' ') - print(f'HEX PRINT: {hex_str}') - logger.info('HEX LOG: %s', hex_str) - - repr_str = repr(y.buffer) - print(f'REPR PRINT: {repr_str}') - logger.info('REPR LOG: %s', repr_str) + yield from ServalMovieDeserializer(sock, n_frames, self.movie_bufsize) finally: listener.close() @@ -273,6 +263,9 @@ def __next__(self) -> np.ndarray: i, j = header_end, header_end + self.size frame = np.frombuffer(self.buffer[i:j], dtype=self.dtype).reshape(self.shape).copy() + # TESTING APPROACHES TO DECODING IMAGES + + # APPROACH 1: MOSTLY FINE BUT INTRODUCES TEARS ABOVE 256 # Decode Serval-packed 32-bit jsonimage payload into the physical 24-bit count: # observed packing (byte lanes): [b0, b1, b2, b3] with b1 unused/zero, # count = b0 | (b3<<8) | (b2<<16). Contact Daniel Tchon, tchon@fzu.cz, for details. @@ -283,11 +276,40 @@ def __next__(self) -> np.ndarray: bytes3 = frame & np.uint32(0xFF000000) frame = bytes02 | (bytes3 >> np.uint32(16)) + # APPROACH 2: APPROACH 1 WITH A POST-FIX + # if frame.dtype == np.uint32: + # bytes02 = frame & np.uint32(0x00FF00FF) + # bytes3 = frame & np.uint32(0xFF000000) + # frame = bytes02 | (bytes3 >> np.uint32(16)) + # array = frame.ravel() + # bit8_mask = array & np.uint32(0xFFFF00) + # array &= ~np.uint32(0xFFFF00) + # array[1:] |= bit8_mask[:-1] + # frame = array.reshape(self.shape) + self.buffer[: self.used - j] = self.buffer[j : self.used] self.used -= j self.i_frame += 1 return frame + # APPROACH 3: THEORETICALLY CORRECT READ THAT TAKES \n INTO CONSIDERATION + # def __next__(self) -> np.ndarray: + # """Recv as much data as needed and use it to yield next frame ASAP.""" + # if self.i_frame >= self.n_frames: + # raise StopIteration + # header_end = self._read_until(b'}\n') + # if self.i_frame == 0: + # self.read_image_shape_from_header(header_end) + # while self.used < header_end + self.size: + # self._recv_more() + # i, j = header_end, header_end + self.size + # frame = np.frombuffer(self.buffer[i:j], dtype=self.dtype).reshape(self.shape).copy() + # + # self.buffer[: self.used - j] = self.buffer[j : self.used] + # self.used -= j + # self.i_frame += 1 + # return frame + if __name__ == '__main__': cam = CameraServal() From 58fe09702df326ae34f267fe69ecfab288e07a17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Tue, 14 Jul 2026 12:53:00 +0200 Subject: [PATCH 098/118] Fix the TCP data stream reading --- src/instamatic/camera/camera_serval.py | 83 +++++++------------------- 1 file changed, 22 insertions(+), 61 deletions(-) diff --git a/src/instamatic/camera/camera_serval.py b/src/instamatic/camera/camera_serval.py index f7ae0393..04a05242 100644 --- a/src/instamatic/camera/camera_serval.py +++ b/src/instamatic/camera/camera_serval.py @@ -228,88 +228,49 @@ def __init__(self, sock: socket.socket, n_frames: int, bufsize: int) -> None: self.n_frames: int = n_frames self.shape: tuple[int, int] = (0, 0) self.size: int = 0 - self.dtype: np.dtype = np.uint32 + self.dtype: np.dtype = np.dtype(np.uint32) - def _recv_more(self) -> None: - if not (n := self.sock.recv_into(self.view[self.used :])): + def _receive_more(self) -> None: + """Attempt to receive bytes from the socket into free buffer space.""" + recv_len = self.sock.recv_into(self.view[self.used :]) + if not recv_len: raise EOFError - self.used += n + self.used += recv_len - def _read_until(self, token: bytes) -> int: + def _receive_until(self, token: bytes) -> int: + """Recv data until `token` is found, return index after the token.""" + token_idx = self.buffer.find(token, 0, self.used) while True: - idx = self.buffer.find(token, 0, self.used) - if idx >= 0: - return idx + len(token) - self._recv_more() + if token_idx >= 0: + return token_idx + len(token) + self._receive_more() - def read_image_shape_from_header(self, header_size: int) -> None: - """Read shape, size, dtype of all images from the first header.""" + def _parse_header(self, header_size: int) -> None: + """Read shape, size, dtype of all images from the 1st frame header.""" header_str = self.buffer[:header_size].decode('utf-8') header_dict = json.loads(header_str) - d = header_dict['bitDepth'] // 8 - self.shape = (header_dict['height'], header_dict['width']) - self.size = header_dict.get('dataSize', prod(self.shape) * d) - self.dtype = np.uint32 if d > 2 else np.uint16 if d > 1 else np.uint8 + bit_depth = header_dict['bitDepth'] + self.shape = header_dict['height'], header_dict['width'] + self.dtype = np.dtype(f'uint{bit_depth}').newbyteorder('>') + self.size = header_dict.get('dataSize', prod(self.shape) * self.dtype.itemsize) def __next__(self) -> np.ndarray: - """Recv as much data as needed and use it to yield next frame ASAP.""" + """Recv as much data as needed, return next frame from TCP stream.""" if self.i_frame >= self.n_frames: raise StopIteration - header_end = self._read_until(b'}') + header_end = self._receive_until(b'}\n') if self.i_frame == 0: - self.read_image_shape_from_header(header_end) + self._parse_header(header_end) while self.used < header_end + self.size: - self._recv_more() + self._receive_more() i, j = header_end, header_end + self.size frame = np.frombuffer(self.buffer[i:j], dtype=self.dtype).reshape(self.shape).copy() - # TESTING APPROACHES TO DECODING IMAGES - - # APPROACH 1: MOSTLY FINE BUT INTRODUCES TEARS ABOVE 256 - # Decode Serval-packed 32-bit jsonimage payload into the physical 24-bit count: - # observed packing (byte lanes): [b0, b1, b2, b3] with b1 unused/zero, - # count = b0 | (b3<<8) | (b2<<16). Contact Daniel Tchon, tchon@fzu.cz, for details. - if np.any((frame & np.uint32(0x0000FF00)) != 0): # potentially check harder - logger.debug('Unexpected nonzero byte1 in Serval packed uint32 payload.') - if frame.dtype == np.uint32: - bytes02 = frame & np.uint32(0x00FF00FF) - bytes3 = frame & np.uint32(0xFF000000) - frame = bytes02 | (bytes3 >> np.uint32(16)) - - # APPROACH 2: APPROACH 1 WITH A POST-FIX - # if frame.dtype == np.uint32: - # bytes02 = frame & np.uint32(0x00FF00FF) - # bytes3 = frame & np.uint32(0xFF000000) - # frame = bytes02 | (bytes3 >> np.uint32(16)) - # array = frame.ravel() - # bit8_mask = array & np.uint32(0xFFFF00) - # array &= ~np.uint32(0xFFFF00) - # array[1:] |= bit8_mask[:-1] - # frame = array.reshape(self.shape) - self.buffer[: self.used - j] = self.buffer[j : self.used] self.used -= j self.i_frame += 1 return frame - # APPROACH 3: THEORETICALLY CORRECT READ THAT TAKES \n INTO CONSIDERATION - # def __next__(self) -> np.ndarray: - # """Recv as much data as needed and use it to yield next frame ASAP.""" - # if self.i_frame >= self.n_frames: - # raise StopIteration - # header_end = self._read_until(b'}\n') - # if self.i_frame == 0: - # self.read_image_shape_from_header(header_end) - # while self.used < header_end + self.size: - # self._recv_more() - # i, j = header_end, header_end + self.size - # frame = np.frombuffer(self.buffer[i:j], dtype=self.dtype).reshape(self.shape).copy() - # - # self.buffer[: self.used - j] = self.buffer[j : self.used] - # self.used -= j - # self.i_frame += 1 - # return frame - if __name__ == '__main__': cam = CameraServal() From 3f49f63945a5bb5b5cdd37fd8f12fb06c6c8547a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Tue, 14 Jul 2026 18:06:32 +0200 Subject: [PATCH 099/118] Establish a strong bijection between buffer name and region/scan/line in new utils.py --- .../experiments/scan_ed/dispatch.py | 63 ++++++++++--------- .../experiments/scan_ed/experiment.py | 16 ++--- src/instamatic/experiments/scan_ed/utils.py | 63 +++++++++++++++++++ 3 files changed, 103 insertions(+), 39 deletions(-) create mode 100644 src/instamatic/experiments/scan_ed/utils.py diff --git a/src/instamatic/experiments/scan_ed/dispatch.py b/src/instamatic/experiments/scan_ed/dispatch.py index c7fa28d8..b3310e12 100644 --- a/src/instamatic/experiments/scan_ed/dispatch.py +++ b/src/instamatic/experiments/scan_ed/dispatch.py @@ -3,7 +3,6 @@ import multiprocessing as mp import os import queue -import uuid from multiprocessing.shared_memory import SharedMemory from pathlib import Path from time import sleep @@ -14,6 +13,7 @@ from instamatic._typing import AnyPath from instamatic.experiments.scan_ed.detection import DiffHuntResults, ring_percentile_detection +from instamatic.experiments.scan_ed.utils import SaveName from instamatic.formats import read_tiff, write_tiff if TYPE_CHECKING: @@ -26,6 +26,7 @@ Command: TypeAlias = tuple[CommandKind, dict[str, Any]] Feedback: TypeAlias = tuple[FeedbackKind, dict[str, Any]] +MovieOrPaths: TypeAlias = Iterable[Union[tuple[np.ndarray, Optional[dict]], AnyPath]] class DiffHuntDispatcher: @@ -44,7 +45,7 @@ def __init__(self, state: State, shape: tuple[int, int], dtype: np.dtype) -> Non self._busy_workers: dict[int, Optional[int]] = {} # worker ID: pointer self._free_workers: set[int] = set() # IDs of worker not running a task - self._buffer_name: str = '' + self.region_line_scan: Optional[tuple[int, int, int]] = None self._shm: Optional[SharedMemory] = None self._frames: Optional[np.ndarray] = None self._n_frames: int = 0 @@ -76,6 +77,10 @@ def _spawn_workers(self) -> None: self.command_queues.append(q) self._workers.append(w) + @property + def buffer_name(self) -> str: + return str(SaveName(*self.region_line_scan)) + def emit(self, task: CommandKind, **kwargs) -> None: """Shorthand to create and put Command in free self.commands queue.""" wid = self._free_workers.pop() @@ -87,19 +92,19 @@ def emit_all(self, task: CommandKind, **kwargs) -> None: for q in self.command_queues: q.put((task, kwargs)) - def begin_scan(self, n_frames: int, name: Optional[str] = None) -> None: + def begin_scan(self, region: int, line: int, scan: int, n_frames: int) -> None: """Allocate a new shared buffer and reset all tracking for one scan.""" - self._buffer_name = name or uuid.uuid4().hex + self.region_line_scan = region, line, scan self._n_frames = int(n_frames) self._free_workers = set(range(N_PROCESSORS)) self._busy_workers = {} shape3 = (self._n_frames, self.shape[0], self.shape[1]) size = int(np.prod(shape3) * self.dtype.itemsize) - self._shm = self._create_shm(name=self._buffer_name, size=size) + self._shm = self._create_shm(name=self.buffer_name, size=size) self._frames = np.ndarray(shape3, dtype=self.dtype, buffer=self._shm.buf) self.hits = np.zeros(self._n_frames, dtype=bool) self.headers = [None] * self._n_frames - self.emit_all('INIT', buffer_name=self._buffer_name, buffer_shape=shape3) + self.emit_all('INIT', buffer_name=self.buffer_name, buffer_shape=shape3) def end_scan(self) -> None: """Release shared memory for the active scan.""" @@ -111,12 +116,12 @@ def end_scan(self) -> None: finally: self._shm = None self._frames = None - self._buffer_name = '' + self.region_line_scan = None self._n_frames = 0 self.hits = None self.headers = [] - def _handle_feedback(self, region: int, line: int, scan: int) -> None: + def _handle_feedback(self) -> None: """Receive one feedback item and apply it to state and bookkeeping. PROCESSING: update the state table (no worker freed yet — still running). @@ -132,12 +137,12 @@ def _handle_feedback(self, region: int, line: int, scan: int) -> None: ptr = int(self._busy_workers.get(wid, -1)) if fb_name == 'PROCESSING': - self.state.mark_processing(region, line, scan, ptr) + self.state.mark_processing(*self.region_line_scan, ptr) elif fb_name == 'PROCESSED': d: DiffHuntResults = fb_kwargs['details'] p = len(d.peaks) - self.state.fill_step(region, line, scan, ptr, d.success, d.light, p) + self.state.fill_step(*self.region_line_scan, ptr, d.success, d.light, p) if self.hits is not None: self.hits[ptr] = d.success @@ -145,38 +150,35 @@ def _handle_feedback(self, region: int, line: int, scan: int) -> None: self._busy_workers.pop(wid, None) self._free_workers.add(wid) - def process_scan( - self, - movie: Iterable[Union[tuple[np.ndarray, Optional[dict]], AnyPath]], - region: int, - line: int, - scan: int, - ) -> None: + def process_scan(self, movie: MovieOrPaths) -> None: """Write `movie` frames into shared buffer, dispatch PROCESS tasks.""" if self._frames is None: raise RuntimeError('Call begin_scan() first.') - for ptr, src in enumerate(movie): - frame, header = src if isinstance(src, tuple) else read_tiff(src) + for ptr, src in enumerate(movie): # if given movie, iterate one-by-one + if isinstance(src, tuple): + frame, header = src + else: # if given a path list, inherit pointer from the path name + frame, header = read_tiff(src) + ptr = SaveName(Path(src).stem).as_dict()['frame'] if ptr >= self._n_frames: raise RuntimeError('Buffer overflow for active scan.') while not self._free_workers: # Block until some worker finishes. - self._handle_feedback(region, line, scan) + self._handle_feedback() self._frames[ptr] = frame self.headers[ptr] = header self.emit('PROCESS', buffer_pointer=ptr) while self._busy_workers: # drain until every worker is accounted for - self._handle_feedback(region, line, scan) + self._handle_feedback() def write_scan(self, path: AnyPath, all_: bool = False) -> None: """Send WRITE for all hit frames, block until every write completes.""" if self.hits is None: raise RuntimeError('Call begin_scan() first.') - bn = self._buffer_name for ptr, hit in enumerate(self.hits): h: dict = self.headers[ptr] p: list[str] = [] @@ -188,11 +190,11 @@ def write_scan(self, path: AnyPath, all_: bool = False) -> None: continue while not self._free_workers: - self._handle_feedback(-1, -1, -1) - self.emit('WRITE', paths=p, header=h, buffer_name=bn, buffer_pointer=ptr) + self._handle_feedback() + self.emit('WRITE', paths=p, header=h, buffer_pointer=ptr) while self._busy_workers: # drain until every worker is accounted for - self._handle_feedback(-1, -1, -1) + self._handle_feedback() def terminate_workers(self) -> None: """Command all workers to terminate and join them.""" @@ -211,8 +213,9 @@ def __init__(self, worker_id: int, commands: mp.Queue, feedback: mp.Queue, dtype self.feedback = feedback self.dtype = np.dtype(dtype) self.config: dict[str, Any] = {} - self.frames: Optional[np.ndarray] = None + self.buffer_name: Optional[str] = None self.shm: Optional[SharedMemory] = None + self.frames: Optional[np.ndarray] = None self.terminating: bool = False def emit(self, kind: FeedbackKind, **kwargs) -> None: @@ -230,8 +233,7 @@ def run(self) -> None: def cmd_init(self, *, buffer_name: str, buffer_shape: tuple[int, ...]) -> None: """INIT: Close previous buffer if exists and reattach to a new one.""" - if self.shm is not None: - self.shm.close() + self.buffer_name = buffer_name self.shm = SharedMemory(name=buffer_name) self.frames = np.ndarray(buffer_shape, dtype=self.dtype, buffer=self.shm.buf) @@ -254,14 +256,13 @@ def cmd_write( self, *, paths: Sequence[AnyPath], - buffer_name: str, buffer_pointer: int, - header: dict[str, Any], + header: Optional[dict[str, Any]], ) -> None: """WRITE: save image at assigned pointer on drive under buffer name""" try: dirs = [Path(p).resolve() for p in paths] - filename = f'{buffer_name}_{buffer_pointer:06d}.tiff' + filename = f'{self.buffer_name}_{buffer_pointer:06d}.tiff' frame = self.frames[buffer_pointer] first = dirs[0] / filename first.parent.mkdir(parents=True, exist_ok=True) diff --git a/src/instamatic/experiments/scan_ed/experiment.py b/src/instamatic/experiments/scan_ed/experiment.py index 6b3c49e5..0f2f6622 100644 --- a/src/instamatic/experiments/scan_ed/experiment.py +++ b/src/instamatic/experiments/scan_ed/experiment.py @@ -2,6 +2,7 @@ import shutil from datetime import datetime, timedelta +from glob import glob from itertools import count, cycle from pathlib import Path from threading import Event @@ -19,6 +20,7 @@ from instamatic.experiments.scan_ed.progress import ProgressTable from instamatic.experiments.scan_ed.region import Regionalization from instamatic.experiments.scan_ed.state import State +from instamatic.experiments.scan_ed.utils import SaveName from instamatic.formats import read_tiff from instamatic.grid.artist import plot from instamatic.grid.geometry import GRID_REGISTRY, PeriodicConvexPolygonGridGeometry @@ -367,7 +369,7 @@ def run_scan(self, region_idx: int, line_idx: int, scan_idx: int) -> None: if abs(self.ctrl.stage.a - scan['tilt']) > 0.05: # epsilon: self.ctrl.stage.a = scan['tilt'] - name = f'r{region_idx:03d}_l{line_idx:06d}_s{scan_idx:03d}' + name = str(SaveName().append(region_idx, line_idx, scan_idx)) self.dispatcher.begin_scan(n_frames, name=name) exposure, speed, _ = self.determine_timing(line['step']) @@ -434,15 +436,13 @@ def reprocess_scan(self, region_idx, line_idx, scan_idx) -> None: """Re-run detection on one previously collected scan's saved frames.""" n_frames = int(self.state.lines.loc[(region_idx, line_idx), 'n_steps']) - name = f'r{region_idx:03d}_l{line_idx:06d}_s{scan_idx:03d}' - frame_paths = [self.path / 'all' / f'{name}_{p:06d}.tiff' for p in range(n_frames)] - if not all(p.is_file() for p in frame_paths): - self.log.warning(f'Skipping reprocess of {name}: missing frame(s) in all/') + name = str(SaveName().append(region_idx, line_idx, scan_idx)) + frame_paths = glob(str(self.path / 'all' / f'{name}*.tiff')) + if not frame_paths: return - self.dispatcher.begin_scan(n_frames, name=name) - kw = {'region': region_idx, 'line': line_idx, 'scan': scan_idx} - self.dispatcher.process_scan(frame_paths, **kw) + self.dispatcher.begin_scan(region_idx, line_idx, scan_idx, n_frames) + self.dispatcher.process_scan(frame_paths) self.dispatcher.write_scan(path=self.path, all_=True) self.dispatcher.end_scan() diff --git a/src/instamatic/experiments/scan_ed/utils.py b/src/instamatic/experiments/scan_ed/utils.py new file mode 100644 index 00000000..bfbd2f0d --- /dev/null +++ b/src/instamatic/experiments/scan_ed/utils.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import re +from collections import UserString +from dataclasses import dataclass +from typing import Any, Literal + +from typing_extensions import Self, TypeAlias + +FieldKind: TypeAlias = Literal['region', 'line', 'scan', 'frame'] + + +class SaveName(UserString): + """Handles frame/buffer naming conventions throughout the ScanED module.""" + + @dataclass + class Field: + name: FieldKind + prefix: str + format: str + typ: type + + fields = [ + Field(name='region', prefix='r', format=':02d', typ=int), + Field(name='line', prefix='l', format=':04d', typ=int), + Field(name='scan', prefix='s', format=':02d', typ=int), + Field(name='frame', prefix='f', format=':04d', typ=int), + ] + + def __init__(self, seq: Any = ''): + super().__init__(seq) + + def append(self, *args, **kwargs) -> Self: + """Append new '_{prefix}{format} fields from args & kwargs to self.""" + if not args and not kwargs: + return self + fields = {f.name: f for f in self.fields if f.name not in self.as_dict()} + if kwargs: + key, value = kwargs.popitem() + field = fields[key] # noqa - field names must be FieldKind literals + else: # if args: + field = list(fields.values())[0] # first unused field + value, args = args[0], args[1:] + return self._append(field, value).append(*args, **kwargs) + + def _append(self, field: Field, value: Any) -> Self: + """Append.""" + suffix = '_' + field.prefix + '{' + field.format + '}' + return self.__class__(self + suffix.format(field.typ(value))) + + def as_dict(self) -> dict[FieldKind, Any]: + """Parse self and return as a {field.name: field.value} dictionary.""" + fields = {f.prefix: f for f in self.fields} + d = {} + for g1, g2 in re.findall(r'([a-z])(\d+)', self.data): + field = fields[g1] + d[field.name] = field.typ(g2) + return d + + def as_list(self) -> Any: + """Parse self and return as a list of present field values in order.""" + d = self.as_dict() + return [d[f.name] for f in self.fields if f.name in d] From db60b2fe19ec65a9bdf2e87647faf8253139e19c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Tue, 14 Jul 2026 18:32:56 +0200 Subject: [PATCH 100/118] `load_from_journal`: don't apply `fill_encoded_scan` if `self.mode == 'reprocess'` --- src/instamatic/experiments/scan_ed/experiment.py | 9 +++------ src/instamatic/experiments/scan_ed/state.py | 14 +++++--------- 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/src/instamatic/experiments/scan_ed/experiment.py b/src/instamatic/experiments/scan_ed/experiment.py index 0f2f6622..81c74180 100644 --- a/src/instamatic/experiments/scan_ed/experiment.py +++ b/src/instamatic/experiments/scan_ed/experiment.py @@ -73,7 +73,7 @@ def initialize_state(self) -> None: if self.mode in ('continue', 'reprocess'): if not journal_path.exists() or not journal_path.is_file(): raise FileNotFoundError(f'No journal file found at {journal_path=}') - state.load_from_journal() + state.load_from_journal(fill=self.mode == 'reprocess') self._state = state @property @@ -408,11 +408,8 @@ def finalize_scan(self, region_idx: int, line_idx: int, scan_idx: int) -> None: def reprocess_collection(self) -> None: """Re-evaluate frames already saved in `all/` with current detection - params, rewriting the journal's hit data and `tiff/`. - - Never drives the microscope and never resumes collection - afterward. - """ + params, rewriting the journal's hit data and `tiff/`; Never drives the + microscope and never resumes collection afterward.""" if self.dispatcher is None: self.dispatcher = self.get_dispatcher_from_file() diff --git a/src/instamatic/experiments/scan_ed/state.py b/src/instamatic/experiments/scan_ed/state.py index 803580f7..8b75a6f7 100644 --- a/src/instamatic/experiments/scan_ed/state.py +++ b/src/instamatic/experiments/scan_ed/state.py @@ -72,19 +72,15 @@ def _init_dataframes(self) -> None: self.steps = pd.DataFrame(steps_columns) self.steps.set_index(['region', 'line', 'scan', 'step'], inplace=True) - def load_from_journal(self) -> None: - """Recreate an instance of experiment state from journal file. - - First, get the list of events. Then, specifically look at fill - events. Only apply the latest fill event to save on display - time. - """ + def load_from_journal(self, fill: bool = True) -> None: + """Recreate experiment state from journal file: get the list of events + and, if fill=True, apply only the latest fill event to save time.""" events = list(self.journal.events()) latest_fill_event: dict[tuple[int, int, int], int] = {} for event in events: - if event['method'] == 'fill_encoded_scan': + if fill and event['method'] == 'fill_encoded_scan': k = event['kwargs'] latest_fill_event[(k['region'], k['line'], k['scan'])] = event['seq'] @@ -93,7 +89,7 @@ def load_from_journal(self) -> None: method_name, kwargs = event['method'], event.get('kwargs', {}) if method_name == 'configure_dispatcher': continue # reapplied live, not part of structural state - if method_name == 'fill_encoded_scan': + if fill and method_name == 'fill_encoded_scan': key = (kwargs['region'], kwargs['line'], kwargs['scan']) if event['seq'] != latest_fill_event[key]: continue # superseded by a later reprocess pass From fc47012a68af8948e9c0c2d7d16d85776b03fc5e Mon Sep 17 00:00:00 2001 From: Daniel Tchon Date: Wed, 15 Jul 2026 16:44:47 +0200 Subject: [PATCH 101/118] These changes allow streaming images at 10 fps (but not much faster...) --- src/instamatic/camera/camera_serval.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/instamatic/camera/camera_serval.py b/src/instamatic/camera/camera_serval.py index 04a05242..7c71d9c7 100644 --- a/src/instamatic/camera/camera_serval.py +++ b/src/instamatic/camera/camera_serval.py @@ -239,8 +239,8 @@ def _receive_more(self) -> None: def _receive_until(self, token: bytes) -> int: """Recv data until `token` is found, return index after the token.""" - token_idx = self.buffer.find(token, 0, self.used) while True: + token_idx = self.buffer.find(token, 0, self.used) if token_idx >= 0: return token_idx + len(token) self._receive_more() @@ -258,7 +258,7 @@ def __next__(self) -> np.ndarray: """Recv as much data as needed, return next frame from TCP stream.""" if self.i_frame >= self.n_frames: raise StopIteration - header_end = self._receive_until(b'}\n') + header_end = self._receive_until(b'}') + 1 if self.i_frame == 0: self._parse_header(header_end) while self.used < header_end + self.size: @@ -273,6 +273,12 @@ def __next__(self) -> np.ndarray: if __name__ == '__main__': + + # debugging block + cam = CameraServal() + cam.get_movie(10, 1.0) + exit() + cam = CameraServal() from IPython import embed From 8c984cdef6fbd9684aa3dd721136ce8ab0f3fee7 Mon Sep 17 00:00:00 2001 From: Daniel Tchon Date: Wed, 15 Jul 2026 18:02:12 +0200 Subject: [PATCH 102/118] Revert changes to __main__, fix IGNORE type/instance typing --- src/instamatic/camera/camera_serval.py | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/src/instamatic/camera/camera_serval.py b/src/instamatic/camera/camera_serval.py index 7c71d9c7..5cd61a17 100644 --- a/src/instamatic/camera/camera_serval.py +++ b/src/instamatic/camera/camera_serval.py @@ -26,7 +26,10 @@ # 2. `java -jar .\server\serv-2.1.3.jar` # 3. launch `instamatic` -Ignore = object() # sentinel object: informs `_get_images` to get a single image +class Ignore: # sentinel object: informs `_get_images` to get a single image + pass + +IGNORE = Ignore() class CameraServal(CameraBase): @@ -63,7 +66,7 @@ def get_image(self, exposure: Optional[float] = None, **kwargs) -> np.ndarray: exposure: `float` or `None` Exposure time in seconds. """ - return self._get_images(n_frames=Ignore, exposure=exposure, **kwargs) + return self._get_images(n_frames=IGNORE, exposure=exposure, **kwargs) def _get_images( self, @@ -72,15 +75,15 @@ def _get_images( **kwargs, ) -> Union[np.ndarray, List[np.ndarray]]: """General media acquisition dispatcher for other protected methods.""" - n: int = 1 if n_frames is Ignore else n_frames + n: int = 1 if n_frames is IGNORE else n_frames e: float = self.default_exposure if exposure is None else exposure - if n_frames == 0: # single image is communicated via n_frames = Ignore + if n_frames == 0: # single image is communicated via n_frames = IGNORE return [] elif e < self.MIN_EXPOSURE: logger.warning('%s: %d', self.BAD_EXPOSURE_MSG, e) - if n_frames is Ignore: + if n_frames is IGNORE: return self._get_image_null(exposure=e, **kwargs) return [self._get_image_null(exposure=e, **kwargs) for _ in range(n)] @@ -89,12 +92,12 @@ def _get_images( n1 = math.ceil(e / self.MAX_EXPOSURE) e = (e + self.dead_time) / n1 - self.dead_time images = self._get_image_stack(n_frames=n * n1, exposure=e, **kwargs) - if n_frames is Ignore: + if n_frames is IGNORE: return self._spliced_sum(images, exposure=e) return [self._spliced_sum(i, exposure=e) for i in batched(images, n1)] else: # if exposure is within limits - if n_frames is Ignore: + if n_frames is IGNORE: return self._get_image_single(exposure=e, **kwargs) return self._get_image_stack(n_frames=n, exposure=e, **kwargs) @@ -274,11 +277,6 @@ def __next__(self) -> np.ndarray: if __name__ == '__main__': - # debugging block - cam = CameraServal() - cam.get_movie(10, 1.0) - exit() - cam = CameraServal() from IPython import embed From 05a55ced86cfe58e33f6b7ea308c8209f5c0e4a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Mon, 20 Jul 2026 18:27:43 +0200 Subject: [PATCH 103/118] Today's approach to camera rework, continue --- src/instamatic/camera/camera_serval.py | 228 +++++++++++-------------- 1 file changed, 96 insertions(+), 132 deletions(-) diff --git a/src/instamatic/camera/camera_serval.py b/src/instamatic/camera/camera_serval.py index 5cd61a17..66bcd3ae 100644 --- a/src/instamatic/camera/camera_serval.py +++ b/src/instamatic/camera/camera_serval.py @@ -6,11 +6,10 @@ import logging import math import socket -import threading from io import BytesIO -from itertools import batched from math import prod -from typing import Generator, Iterator, List, Optional, Sequence, Tuple, Union +from threading import Thread +from typing import Iterator, Optional, Sequence, Tuple, Union from urllib.parse import urlparse import numpy as np @@ -26,10 +25,12 @@ # 2. `java -jar .\server\serv-2.1.3.jar` # 3. launch `instamatic` -class Ignore: # sentinel object: informs `_get_images` to get a single image - pass -IGNORE = Ignore() +def _local_ip_for(remote_host: str, remote_port: int) -> str: + """Return the local IP used to reach (remote_host, remote_port).""" + with contextlib.closing(socket.socket(socket.AF_INET, socket.SOCK_DGRAM)) as s: + s.connect((remote_host, remote_port)) + return s.getsockname()[0] class CameraServal(CameraBase): @@ -40,24 +41,60 @@ class CameraServal(CameraBase): MAX_EXPOSURE = 10.0 BAD_EXPOSURE_MSG = 'Requested exposure exceeds native Serval support (>0-10s)' - def __init__(self, name='serval'): + def __init__(self, name='serval') -> None: """Initialize camera module.""" super().__init__(name) - self.establish_connection() - self.dead_time = ( - self.detector_config['TriggerPeriod'] - self.detector_config['ExposureTime'] - ) + + self.tcp_dest: dict[str, str] = {} # destination for serial movies + self.conn, self.tcp_listener = self.establish_connection() + dc = self.detector_config # noqa: loaded from a camera/file.yaml + self.dead_time = dc['TriggerPeriod'] - dc['ExposureTime'] self.movie_bufsize = 2 * 4 * prod(self.dimensions) + self.null_image = np.zeros(shape=self.dimensions, dtype=np.int32) + logger.info(f'Camera {self.get_name()} initialized') atexit.register(self.release_connection) - @staticmethod - def _local_ip_for(remote_host: str, remote_port: int) -> str: - """Return the local IP used to reach (remote_host, remote_port).""" - # UDP "connect" does not send packets, but lets the OS choose interface/IP. - with contextlib.closing(socket.socket(socket.AF_INET, socket.SOCK_DGRAM)) as s: - s.connect((remote_host, remote_port)) - return s.getsockname()[0] + def establish_connection(self) -> tuple[ServalCamera, socket.socket]: + """Establish connection to the camera.""" + + http_url = urlparse(self.url) # noqa - loaded from a camera/file.yaml + tcp_port = (http_url.port or 8080) + 1 + local_ip = _local_ip_for(http_url.hostname, tcp_port) + tcp_base = f'tcp://connect@{local_ip}:{tcp_port}' + http_dest = {'Base': 'http://localhost', 'Format': 'tiff', 'Mode': 'count'} + self.tcp_dest = {'Base': tcp_base, 'Format': 'jsonimage', 'Mode': 'count'} + + f = dict(bpc_file_path=self.bpc_file_path, dacs_file_path=self.dacs_file_path) + conn = ServalCamera() + conn.connect(http_url) + conn.set_chip_config_files(**f) + conn.set_detector_config(**self.detector_config) + + tcp_listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + tcp_listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + tcp_listener.settimeout(5.0) + tcp_listener.bind(('0.0.0.0', tcp_port)) + tcp_listener.listen(1) + + self.conn.destination = {'Image': [http_dest]} + return conn, tcp_listener + + def release_connection(self) -> None: + """Release the connection to the camera.""" + self.conn.measurement_stop() + self.tcp_listener.close() + msg = f"Connection to camera '{self.get_name()}' released" + logger.info(msg) + + def set_detector_config(self, **kwargs) -> None: + """Set detector config while infering about missing config params.""" + if 'TriggerMode' not in kwargs: + tm = 'AUTOTRIGSTART_TIMERSTOP' if self.dead_time else 'CONTINUOUS' + kwargs['TriggerMode'] = tm + if 'TriggerPeriod' not in kwargs and 'ExposureTime' in kwargs: + kwargs['TriggerPeriod'] = kwargs['ExposureTime'] + self.dead_time + self.conn.set_detector_config(**kwargs) def get_image(self, exposure: Optional[float] = None, **kwargs) -> np.ndarray: """Image acquisition interface. If the exposure is not given, the @@ -66,73 +103,38 @@ def get_image(self, exposure: Optional[float] = None, **kwargs) -> np.ndarray: exposure: `float` or `None` Exposure time in seconds. """ - return self._get_images(n_frames=IGNORE, exposure=exposure, **kwargs) - - def _get_images( - self, - n_frames: Union[int, Ignore], - exposure: Optional[float] = None, - **kwargs, - ) -> Union[np.ndarray, List[np.ndarray]]: - """General media acquisition dispatcher for other protected methods.""" - n: int = 1 if n_frames is IGNORE else n_frames e: float = self.default_exposure if exposure is None else exposure - if n_frames == 0: # single image is communicated via n_frames = IGNORE - return [] - - elif e < self.MIN_EXPOSURE: + if e < self.MIN_EXPOSURE: logger.warning('%s: %d', self.BAD_EXPOSURE_MSG, e) - if n_frames is IGNORE: - return self._get_image_null(exposure=e, **kwargs) - return [self._get_image_null(exposure=e, **kwargs) for _ in range(n)] + return self.null_image elif e > self.MAX_EXPOSURE: logger.warning('%s: %d', self.BAD_EXPOSURE_MSG, e) - n1 = math.ceil(e / self.MAX_EXPOSURE) - e = (e + self.dead_time) / n1 - self.dead_time - images = self._get_image_stack(n_frames=n * n1, exposure=e, **kwargs) - if n_frames is IGNORE: - return self._spliced_sum(images, exposure=e) - return [self._spliced_sum(i, exposure=e) for i in batched(images, n1)] - - else: # if exposure is within limits - if n_frames is IGNORE: - return self._get_image_single(exposure=e, **kwargs) - return self._get_image_stack(n_frames=n, exposure=e, **kwargs) - - def _spliced_sum(self, arrays: Sequence[np.ndarray], exposure: float) -> np.ndarray: - """Sum a series of arrays while applying a dead time correction.""" - array_sum = sum(arrays, np.zeros_like(arrays[0])) - total_exposure = len(arrays) * exposure + (len(arrays) - 1) * self.dead_time - live_fraction = len(arrays) * exposure / total_exposure - return (array_sum / live_fraction).astype(arrays[0].dtype) + n = math.ceil(e / self.MAX_EXPOSURE) + e = (e + self.dead_time) / n - self.dead_time + images = list(self.get_movie(n_frames=n, exposure=e)) + return self._spliced_sum(images, exposure=e) - def _get_image_null(self, **_) -> np.ndarray: - logger.debug('Creating a synthetic image with zero counts') - return np.zeros(shape=self.get_image_dimensions(), dtype=np.int32) - - def _get_image_single(self, exposure: float, **_) -> np.ndarray: - """Request a single frame in the mode in a trigger collection mode.""" logger.debug(f'Collecting a single image with exposure {exposure} s') - self.conn.set_detector_config( - ExposureTime=exposure, - TriggerPeriod=exposure + self.dead_time, - ) + self.conn.set_detector_config(ExposureTime=exposure) db = self.conn.dashboard if db['Measurement'] is None or db['Measurement']['Status'] != 'DA_RECORDING': self.conn.measurement_start() - self.conn.trigger_start() + response = self.conn.get_request('/measurement/image') return tifffile.imread(BytesIO(response.content)) - def _get_image_stack(self, n_frames: int, exposure: float, **_) -> list[np.ndarray]: - """Get a series of images in a mode with minimal dead time.""" - return list(self.get_movie(n_frames=n_frames, exposure=exposure)) + def _spliced_sum(self, arrays: Sequence[np.ndarray], exposure: float) -> np.ndarray: + """Sum a series of arrays while applying a dead time correction.""" + array_sum = sum(arrays, np.zeros_like(arrays[0])) + total_exposure = len(arrays) * exposure + (len(arrays) - 1) * self.dead_time + live_fraction = len(arrays) * exposure / total_exposure + return (array_sum / live_fraction).astype(arrays[0].dtype) def get_movie( self, n_frames: int, exposure: Optional[float] = None, **kwargs - ) -> Generator[np.ndarray, None, None]: + ) -> Iterator[np.ndarray]: """Yield `n_frames` images received via a TCP stream with minimal dead time. If the exposure is not given, the default value is read from the config file. Binning is ignored. @@ -142,56 +144,38 @@ def get_movie( exposure: `float` or `None` Exposure time in seconds. """ - logger.debug(f'Collecting {n_frames}-frame movie with exposure {exposure} s via TCP') - mode: str = 'AUTOTRIGSTART_TIMERSTOP' if self.dead_time else 'CONTINUOUS' - exposure: float = self.default_exposure if exposure is None else exposure - - http_url = urlparse(self.conn.url) - tcp_port = (http_url.port or 8080) + 1 - local_ip = self._local_ip_for(http_url.hostname, tcp_port) - tcp_base = f'tcp://connect@{local_ip}:{tcp_port}' - tcp_dest = {'Base': tcp_base, 'Format': 'jsonimage', 'Mode': 'count'} + logger.debug(f'Collecting {n_frames}-frame movie with {exposure=} s via TCP') + e: float = self.default_exposure if exposure is None else exposure self.conn.measurement_stop() previous_config = self.conn.detector_config previous_destination = self.conn.destination + self.conn.destination = {'Image': [self.tcp_dest]} + self.set_detector_config(ExposureTime=e, nTriggers=n_frames) - listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - listener.settimeout(5.0) - try: - listener.bind(('0.0.0.0', tcp_port)) - listener.listen(1) - self.conn.destination = { - 'Image': [ - tcp_dest, - ] - } - self.conn.set_detector_config( - TriggerMode=mode, - ExposureTime=exposure, - TriggerPeriod=exposure + self.dead_time, - nTriggers=n_frames, - ) - threading.Thread(target=self.conn.measurement_start, daemon=True).start() + def _get_movie_inner() -> Iterator[np.ndarray]: # this runs on next(): try: - sock, addr = listener.accept() - except socket.timeout: - raise TimeoutError('Serval failed to connect back within 5 seconds.') - with sock: - yield from ServalMovieDeserializer(sock, n_frames, self.movie_bufsize) - - finally: - listener.close() - try: - self.conn.measurement_stop() - except Exception as e: - logger.error(f'Error stopping measurement: {e}') - try: - self.conn.destination = previous_destination - self.conn.set_detector_config(**previous_config) - except Exception as e: - logger.error(f'Error restoring config: {e}') + Thread(target=self.conn.measurement_start, daemon=True).start() + try: + sock, _ = self.tcp_listener.accept() + except socket.timeout: + raise TimeoutError('Serval failed to connect back within 5 seconds.') + with sock: + bs = self.movie_bufsize + yield from ServalMovieDeserializer(sock, n_frames, bs) + + finally: + try: + self.conn.measurement_stop() + except Exception as ex: + logger.error(f'Error stopping measurement: {ex}') + try: + self.conn.destination = previous_destination + self.conn.set_detector_config(**previous_config) + except Exception as ex: + logger.error(f'Error restoring config: {ex}') + + return _get_movie_inner() def get_image_dimensions(self) -> Tuple[int, int]: """Get the binned dimensions reported by the camera.""" @@ -199,25 +183,6 @@ def get_image_dimensions(self) -> Tuple[int, int]: dim_x, dim_y = self.get_camera_dimensions() return int(dim_x / binning), int(dim_y / binning) - def establish_connection(self) -> None: - """Establish connection to the camera.""" - self.conn = ServalCamera() - self.conn.connect(self.url) - self.conn.set_chip_config_files( - bpc_file_path=self.bpc_file_path, dacs_file_path=self.dacs_file_path - ) - self.conn.set_detector_config(**self.detector_config) - - img_dest = {'Base': 'http://localhost', 'Format': 'tiff', 'Mode': 'count'} - self.conn.destination = {'Image': [img_dest]} - - def release_connection(self) -> None: - """Release the connection to the camera.""" - self.conn.measurement_stop() - name = self.get_name() - msg = f"Connection to camera '{name}' released" - logger.info(msg) - class ServalMovieDeserializer(Iterator[np.ndarray]): """Deserializes Serval camera TCP byte stream from socket into images.""" @@ -276,7 +241,6 @@ def __next__(self) -> np.ndarray: if __name__ == '__main__': - cam = CameraServal() from IPython import embed From b366ee079b92d5a41f91d59dcc40dea960ccd9d8 Mon Sep 17 00:00:00 2001 From: Daniel Tchon Date: Wed, 22 Jul 2026 18:49:38 +0200 Subject: [PATCH 104/118] Temp changes, trying to debug new connection --- src/instamatic/camera/camera_serval.py | 29 +++++++++++++++++--------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/src/instamatic/camera/camera_serval.py b/src/instamatic/camera/camera_serval.py index 66bcd3ae..6565b183 100644 --- a/src/instamatic/camera/camera_serval.py +++ b/src/instamatic/camera/camera_serval.py @@ -67,23 +67,25 @@ def establish_connection(self) -> tuple[ServalCamera, socket.socket]: f = dict(bpc_file_path=self.bpc_file_path, dacs_file_path=self.dacs_file_path) conn = ServalCamera() - conn.connect(http_url) + conn.connect(http_url.geturl()) + print(http_url.geturl()) + print(self.url) conn.set_chip_config_files(**f) conn.set_detector_config(**self.detector_config) - tcp_listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - tcp_listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - tcp_listener.settimeout(5.0) - tcp_listener.bind(('0.0.0.0', tcp_port)) - tcp_listener.listen(1) + # tcp_listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + # tcp_listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + # tcp_listener.settimeout(5.0) + # tcp_listener.bind(('0.0.0.0', tcp_port)) + # tcp_listener.listen(1) - self.conn.destination = {'Image': [http_dest]} - return conn, tcp_listener + conn.destination = {'Image': [http_dest]} + return conn, None # tcp_listener def release_connection(self) -> None: """Release the connection to the camera.""" self.conn.measurement_stop() - self.tcp_listener.close() + # self.tcp_listener.close() msg = f"Connection to camera '{self.get_name()}' released" logger.info(msg) @@ -117,12 +119,19 @@ def get_image(self, exposure: Optional[float] = None, **kwargs) -> np.ndarray: return self._spliced_sum(images, exposure=e) logger.debug(f'Collecting a single image with exposure {exposure} s') - self.conn.set_detector_config(ExposureTime=exposure) + print('Setting detector config') + self.conn.set_detector_config(ExposureTime=e, TriggerPeriod=e+self.dead_time) + print(f'Set detector config to: {self.conn.get_request('/detector/config').json()}') db = self.conn.dashboard if db['Measurement'] is None or db['Measurement']['Status'] != 'DA_RECORDING': + print(f'Starting measurement') self.conn.measurement_start() + print(f'Started measurement, getting request') + self.conn.trigger_start() + print(f'Started trigger, getting request') response = self.conn.get_request('/measurement/image') + print(f'Got response: {response}') return tifffile.imread(BytesIO(response.content)) def _spliced_sum(self, arrays: Sequence[np.ndarray], exposure: float) -> np.ndarray: From c09ee2bfd9938578aae33e9fcac1293dcfc66427 Mon Sep 17 00:00:00 2001 From: Daniel Tchon Date: Wed, 22 Jul 2026 19:28:35 +0200 Subject: [PATCH 105/118] These changes are needed for fast-lazy movie --- src/instamatic/camera/camera_serval.py | 22 ++++++++-------------- src/instamatic/camera/videostream.py | 25 +++++++++++++++++++------ 2 files changed, 27 insertions(+), 20 deletions(-) diff --git a/src/instamatic/camera/camera_serval.py b/src/instamatic/camera/camera_serval.py index 6565b183..f8cb4570 100644 --- a/src/instamatic/camera/camera_serval.py +++ b/src/instamatic/camera/camera_serval.py @@ -68,19 +68,17 @@ def establish_connection(self) -> tuple[ServalCamera, socket.socket]: f = dict(bpc_file_path=self.bpc_file_path, dacs_file_path=self.dacs_file_path) conn = ServalCamera() conn.connect(http_url.geturl()) - print(http_url.geturl()) - print(self.url) conn.set_chip_config_files(**f) conn.set_detector_config(**self.detector_config) - # tcp_listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - # tcp_listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - # tcp_listener.settimeout(5.0) - # tcp_listener.bind(('0.0.0.0', tcp_port)) - # tcp_listener.listen(1) + tcp_listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + tcp_listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + tcp_listener.settimeout(5.0) + tcp_listener.bind(('0.0.0.0', tcp_port)) + tcp_listener.listen(1) conn.destination = {'Image': [http_dest]} - return conn, None # tcp_listener + return conn, tcp_listener def release_connection(self) -> None: """Release the connection to the camera.""" @@ -119,19 +117,13 @@ def get_image(self, exposure: Optional[float] = None, **kwargs) -> np.ndarray: return self._spliced_sum(images, exposure=e) logger.debug(f'Collecting a single image with exposure {exposure} s') - print('Setting detector config') self.conn.set_detector_config(ExposureTime=e, TriggerPeriod=e+self.dead_time) - print(f'Set detector config to: {self.conn.get_request('/detector/config').json()}') db = self.conn.dashboard if db['Measurement'] is None or db['Measurement']['Status'] != 'DA_RECORDING': - print(f'Starting measurement') self.conn.measurement_start() - print(f'Started measurement, getting request') self.conn.trigger_start() - print(f'Started trigger, getting request') response = self.conn.get_request('/measurement/image') - print(f'Got response: {response}') return tifffile.imread(BytesIO(response.content)) def _spliced_sum(self, arrays: Sequence[np.ndarray], exposure: float) -> np.ndarray: @@ -161,10 +153,12 @@ def get_movie( previous_destination = self.conn.destination self.conn.destination = {'Image': [self.tcp_dest]} self.set_detector_config(ExposureTime=e, nTriggers=n_frames) + print('SETUP PERFORMED') def _get_movie_inner() -> Iterator[np.ndarray]: # this runs on next(): try: Thread(target=self.conn.measurement_start, daemon=True).start() + print('MEASUREMENT STARTED') try: sock, _ = self.tcp_listener.accept() except socket.timeout: diff --git a/src/instamatic/camera/videostream.py b/src/instamatic/camera/videostream.py index 43c97b6e..38c168a8 100644 --- a/src/instamatic/camera/videostream.py +++ b/src/instamatic/camera/videostream.py @@ -65,6 +65,7 @@ def __init__(self, cam: CameraBase, callback, frametime: float = 0.05): self.stopEvent = threading.Event() self.acquireInitiateEvent = threading.Event() + self.acquireInitiateEvent2 = threading.Event() self.continuousCollectionEvent = threading.Event() def run(self): @@ -79,7 +80,9 @@ def run(self): self.callback(media, request=r) else: # isinstance(r, MovieRequest): n = r.n_frames if r.n_frames else 1 - for media in self.cam.get_movie(n_frames=n, exposure=e, binsize=b): + m = self.cam.get_movie(n_frames=n, exposure=e, binsize=b) + self.acquireInitiateEvent2.wait() + for media in m: self.callback(media, request=r) time.sleep(0) # yields thread priority to VideoStream @@ -206,16 +209,26 @@ def get_image(self, exposure=None, binsize=None) -> np.ndarray: def get_movie( self, n_frames: int, exposure=None, binsize=None ) -> Generator[np.ndarray, None, None]: + + self.blocked().__enter__() # Stop the passive collection during request acquisition try: - with self.blocked(): # Stop the passive collection during request acquisition - self.grabber.request = MovieRequest(n_frames, exposure, binsize) - self.grabber.acquireInitiateEvent.set() + self.grabber.request = MovieRequest(n_frames, exposure, binsize) + self.grabber.acquireInitiateEvent.set() + except Exception: + self.blocked().__exit__(None, None, None) + + def _movie_generator() -> Generator[np.ndarray, None, None]: + try: + self.grabber.acquireInitiateEvent2.set() for _ in range(n_frames): while not self.requested: time.sleep(0) # yields thread priority to MediaGrabber yield self.requested.popleft() - finally: - self.grabber.request = None + finally: + self.grabber.request = None + self.grabber.acquireInitiateEvent2.clear() + + return _movie_generator() def update_frametime(self, frametime): self.frametime = frametime From 98b840c65bc32e148e1bbeb487be1a22f6f9be40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Fri, 24 Jul 2026 16:02:02 +0200 Subject: [PATCH 106/118] Prevent ScanProfile.sigmoid from diverging when fitting --- src/instamatic/experiments/scan_ed/profile.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/instamatic/experiments/scan_ed/profile.py b/src/instamatic/experiments/scan_ed/profile.py index b366fc18..8cb111b4 100644 --- a/src/instamatic/experiments/scan_ed/profile.py +++ b/src/instamatic/experiments/scan_ed/profile.py @@ -5,6 +5,7 @@ import numpy as np from scipy.optimize import curve_fit +from scipy.special import expit from instamatic._typing import float_nm from instamatic.grid.geometry import WindowType @@ -36,10 +37,10 @@ def envelope(self, margin: float_nm = 0) -> tuple[float, float]: def sigmoid( x: Union[float_nm, np.ndarray], x0: Union[float_nm, np.ndarray], - width: float = 10.0, + width: float = 100.0, ) -> float: - """A sigmoid that grows from 0 to 1 across ~1 unit (99%) around x0.""" - return 1 / (1 + np.exp(-(x - x0) / width)) + """Grows (from 0 to 1) by .24/46/99 across 1/2/10 widths around x0.""" + return expit((x - x0) / width) def window_model( self, @@ -59,6 +60,6 @@ def window_model( def fit(self, x: np.ndarray, light: np.ndarray) -> tuple[float_nm, float]: """X-offset and y-scale that best fit (x, y) data to scan profile.""" - p0 = [0.0, np.percentile(light, 99)] - popt, _ = curve_fit(self.window_model, x, light, p0=p0) # noqa + p0 = [x[np.argmax(light)] - self.var, np.percentile(light, 99)] + popt, _ = curve_fit(self.window_model, x, light, p0=p0) # noqa unpacking return popt[0], popt[1] From 31cf3523989e2ad1f6c5ea3690efb24be5bdebe5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Fri, 24 Jul 2026 16:30:39 +0200 Subject: [PATCH 107/118] Next round of preemptive fixes for artist heatmap plotting --- src/instamatic/grid/artist.py | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/src/instamatic/grid/artist.py b/src/instamatic/grid/artist.py index 58b01b65..35de513c 100644 --- a/src/instamatic/grid/artist.py +++ b/src/instamatic/grid/artist.py @@ -86,24 +86,25 @@ def plot( slow_idx = 'y0' if (lines['axis'] == 0).all() else 'x0' fast_idx = 'x0' if (lines['axis'] == 0).all() else 'y0' - slows = lines[slow_idx] - try: - slow_step = (np.max(slows) - np.min(slows)) / (len(slows) - 1) - except ZeroDivisionError: - slow_step = abs(lines['step']) # fallback: assume same as fast - slow_min = np.min(slows) - 0.5 * slow_step - slow_max = np.max(slows) + 0.5 * slow_step - slow_count = len(slows) - max_offset = scans['offset'].abs().max() - fast_step = lines['step'].abs().mean() # TODO fails if zero steps + if (fast_step := lines['step'].abs().mean()) == 0: + raise ValueError(f'{fast_step=}: scan data missing or corrupt') fast_start = lines[fast_idx] fast_end = lines[fast_idx] + lines['step'] * lines['n_steps'] fast_min = np.minimum(fast_start, fast_end).min() - max_offset fast_max = np.maximum(fast_start, fast_end).max() + max_offset fast_count = np.ceil((fast_max - fast_min) / fast_step).astype(int) + slows = lines[slow_idx] + try: + slow_step = (np.max(slows) - np.min(slows)) / (len(slows) - 1) + except ZeroDivisionError: + slow_step = fast_step # fallback in case of a single scan + slow_min = np.min(slows) - 0.5 * slow_step + slow_max = np.max(slows) + 0.5 * slow_step + slow_count = len(slows) + level = ['region', 'line', 'scan'] hits = {k: g['hits'].to_numpy(dtype=float) for k, g in steps.groupby(level=level)} @@ -125,9 +126,10 @@ def plot( for k in range(len(j0s)): j0 = j0s[k] - hits_matrix[i, j0 : j0 + n_steps] += hits_array[k] - # TODO - # ValueError: operands could not be broadcast together with shapes (0,) (211,) (0,) + j0c = max(0, j0) + j1c = min(fast_count, j0 + n_steps) + if j0c < j1c: + hits_matrix[i, j0c:j1c] += hits_array[k][j0c - j0 : j1c - j0] if fast_idx == 'x0': x0, x1, y0, y1 = fast_min, fast_max, slow_min, slow_max @@ -140,7 +142,8 @@ def plot( rgba[..., 0] = 1.0 # red square with opacity ~ hit density rgba[..., 3] = hits_matrix / hits_max ax.imshow(rgba, origin='lower', extent=(x0, x1, y0, y1), aspect='auto', zorder=3) - except ValueError: + ax.set_aspect('equal', adjustable='box') + except (KeyError, ValueError): import traceback traceback.print_exc() # if fails, not my largest concern From db835919368a14c2ac134469d1df44566e63bf71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Fri, 24 Jul 2026 16:34:09 +0200 Subject: [PATCH 108/118] Improve imports --- src/instamatic/grid/artist.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/instamatic/grid/artist.py b/src/instamatic/grid/artist.py index 35de513c..ba035c45 100644 --- a/src/instamatic/grid/artist.py +++ b/src/instamatic/grid/artist.py @@ -1,8 +1,8 @@ from __future__ import annotations +import traceback from typing import Optional -import matplotlib.pyplot as plt import numpy as np import pandas as pd from matplotlib.axes import Axes @@ -144,8 +144,6 @@ def plot( ax.imshow(rgba, origin='lower', extent=(x0, x1, y0, y1), aspect='auto', zorder=3) ax.set_aspect('equal', adjustable='box') except (KeyError, ValueError): - import traceback - traceback.print_exc() # if fails, not my largest concern if limit_x is not None: From 601b9d0fa50891247108af6684ff860a4821f40a Mon Sep 17 00:00:00 2001 From: Daniel Tchon Date: Fri, 24 Jul 2026 18:48:49 +0200 Subject: [PATCH 109/118] THIS PLT IS NECESSARY! --- src/instamatic/grid/artist.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/instamatic/grid/artist.py b/src/instamatic/grid/artist.py index ba035c45..5d67674c 100644 --- a/src/instamatic/grid/artist.py +++ b/src/instamatic/grid/artist.py @@ -5,6 +5,7 @@ import numpy as np import pandas as pd +from matplotlib import pyplot as plt from matplotlib.axes import Axes from matplotlib.figure import Figure from matplotlib.patches import Polygon From 8b274a27d604a669c086d055405c628de9b0a8b0 Mon Sep 17 00:00:00 2001 From: Daniel Tchon Date: Fri, 24 Jul 2026 19:12:29 +0200 Subject: [PATCH 110/118] Remove comments, fixes post-refactor at microscope --- src/instamatic/camera/camera_serval.py | 2 -- src/instamatic/experiments/scan_ed/dispatch.py | 2 +- src/instamatic/experiments/scan_ed/experiment.py | 6 +++--- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/instamatic/camera/camera_serval.py b/src/instamatic/camera/camera_serval.py index f8cb4570..c3b56bb8 100644 --- a/src/instamatic/camera/camera_serval.py +++ b/src/instamatic/camera/camera_serval.py @@ -153,12 +153,10 @@ def get_movie( previous_destination = self.conn.destination self.conn.destination = {'Image': [self.tcp_dest]} self.set_detector_config(ExposureTime=e, nTriggers=n_frames) - print('SETUP PERFORMED') def _get_movie_inner() -> Iterator[np.ndarray]: # this runs on next(): try: Thread(target=self.conn.measurement_start, daemon=True).start() - print('MEASUREMENT STARTED') try: sock, _ = self.tcp_listener.accept() except socket.timeout: diff --git a/src/instamatic/experiments/scan_ed/dispatch.py b/src/instamatic/experiments/scan_ed/dispatch.py index b3310e12..ded7d2bb 100644 --- a/src/instamatic/experiments/scan_ed/dispatch.py +++ b/src/instamatic/experiments/scan_ed/dispatch.py @@ -79,7 +79,7 @@ def _spawn_workers(self) -> None: @property def buffer_name(self) -> str: - return str(SaveName(*self.region_line_scan)) + return str(SaveName().append(*self.region_line_scan)) def emit(self, task: CommandKind, **kwargs) -> None: """Shorthand to create and put Command in free self.commands queue.""" diff --git a/src/instamatic/experiments/scan_ed/experiment.py b/src/instamatic/experiments/scan_ed/experiment.py index 81c74180..91286364 100644 --- a/src/instamatic/experiments/scan_ed/experiment.py +++ b/src/instamatic/experiments/scan_ed/experiment.py @@ -370,7 +370,7 @@ def run_scan(self, region_idx: int, line_idx: int, scan_idx: int) -> None: self.ctrl.stage.a = scan['tilt'] name = str(SaveName().append(region_idx, line_idx, scan_idx)) - self.dispatcher.begin_scan(n_frames, name=name) + self.dispatcher.begin_scan(region_idx, line_idx, scan_idx, n_frames) exposure, speed, _ = self.determine_timing(line['step']) axis = line['axis'] # x: 0, y: 1 @@ -380,8 +380,8 @@ def run_scan(self, region_idx: int, line_idx: int, scan_idx: int) -> None: self.ctrl.stage.set_with_speed(**setter_kwargs, wait=False) m = self.ctrl.get_movie(n_frames=n_frames, exposure=exposure, header_keys=None) - kw = {'region': region_idx, 'line': line_idx, 'scan': scan_idx} - self.dispatcher.process_scan(m, **kw) + # kw = {'region': region_idx, 'line': line_idx, 'scan': scan_idx} + self.dispatcher.process_scan(m) self.ctrl.stage.wait() all_ = self.params.get('save_all', False) From 8c1bd0b4c3262d5b97846337717f69ec6f03e2a2 Mon Sep 17 00:00:00 2001 From: Daniel Tchon Date: Fri, 24 Jul 2026 20:26:57 +0200 Subject: [PATCH 111/118] Serialize lists of integers when creating new windows --- src/instamatic/experiments/scan_ed/journal.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/instamatic/experiments/scan_ed/journal.py b/src/instamatic/experiments/scan_ed/journal.py index b69e161a..1875d3f5 100644 --- a/src/instamatic/experiments/scan_ed/journal.py +++ b/src/instamatic/experiments/scan_ed/journal.py @@ -51,7 +51,7 @@ def write(self, method: str, kwargs: dict[str, Any]) -> None: self._seq += 1 record = {'seq': self._seq, 'ts': time.time(), 'method': method, 'kwargs': kwargs} - line = json.dumps(record, separators=(',', ':')) + '\n' + line = json.dumps(record, separators=(',', ':'), default=serialize) + '\n' self.path.parent.mkdir(parents=True, exist_ok=True) with self.path.open('a', encoding='utf-8') as f: f.write(line) From 911019d3d2968f34c34d054319243281ad7a599f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Fri, 7 Aug 2026 13:43:41 +0200 Subject: [PATCH 112/118] Remove debug prints, correctly close on exit --- src/instamatic/camera/camera_serval.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/instamatic/camera/camera_serval.py b/src/instamatic/camera/camera_serval.py index f8cb4570..e56181c8 100644 --- a/src/instamatic/camera/camera_serval.py +++ b/src/instamatic/camera/camera_serval.py @@ -50,7 +50,7 @@ def __init__(self, name='serval') -> None: dc = self.detector_config # noqa: loaded from a camera/file.yaml self.dead_time = dc['TriggerPeriod'] - dc['ExposureTime'] self.movie_bufsize = 2 * 4 * prod(self.dimensions) - self.null_image = np.zeros(shape=self.dimensions, dtype=np.int32) + self.null_image = np.zeros(shape=self.dimensions, dtype=np.uint32) logger.info(f'Camera {self.get_name()} initialized') atexit.register(self.release_connection) @@ -83,7 +83,7 @@ def establish_connection(self) -> tuple[ServalCamera, socket.socket]: def release_connection(self) -> None: """Release the connection to the camera.""" self.conn.measurement_stop() - # self.tcp_listener.close() + self.tcp_listener.close() msg = f"Connection to camera '{self.get_name()}' released" logger.info(msg) @@ -106,18 +106,18 @@ def get_image(self, exposure: Optional[float] = None, **kwargs) -> np.ndarray: e: float = self.default_exposure if exposure is None else exposure if e < self.MIN_EXPOSURE: - logger.warning('%s: %d', self.BAD_EXPOSURE_MSG, e) + logger.warning('%s: %g', self.BAD_EXPOSURE_MSG, e) return self.null_image elif e > self.MAX_EXPOSURE: - logger.warning('%s: %d', self.BAD_EXPOSURE_MSG, e) + logger.warning('%s: %g', self.BAD_EXPOSURE_MSG, e) n = math.ceil(e / self.MAX_EXPOSURE) e = (e + self.dead_time) / n - self.dead_time images = list(self.get_movie(n_frames=n, exposure=e)) return self._spliced_sum(images, exposure=e) logger.debug(f'Collecting a single image with exposure {exposure} s') - self.conn.set_detector_config(ExposureTime=e, TriggerPeriod=e+self.dead_time) + self.conn.set_detector_config(ExposureTime=e, TriggerPeriod=e + self.dead_time) db = self.conn.dashboard if db['Measurement'] is None or db['Measurement']['Status'] != 'DA_RECORDING': self.conn.measurement_start() @@ -153,12 +153,10 @@ def get_movie( previous_destination = self.conn.destination self.conn.destination = {'Image': [self.tcp_dest]} self.set_detector_config(ExposureTime=e, nTriggers=n_frames) - print('SETUP PERFORMED') def _get_movie_inner() -> Iterator[np.ndarray]: # this runs on next(): try: Thread(target=self.conn.measurement_start, daemon=True).start() - print('MEASUREMENT STARTED') try: sock, _ = self.tcp_listener.accept() except socket.timeout: From bd7f03e988048620ca92950975b86acd6dc6b304 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Fri, 7 Aug 2026 15:10:54 +0200 Subject: [PATCH 113/118] Try adding the http-fallback mechanism --- src/instamatic/camera/camera_serval.py | 116 ++++++++++++++++--------- 1 file changed, 74 insertions(+), 42 deletions(-) diff --git a/src/instamatic/camera/camera_serval.py b/src/instamatic/camera/camera_serval.py index e56181c8..facfe68c 100644 --- a/src/instamatic/camera/camera_serval.py +++ b/src/instamatic/camera/camera_serval.py @@ -46,44 +46,60 @@ def __init__(self, name='serval') -> None: super().__init__(name) self.tcp_dest: dict[str, str] = {} # destination for serial movies - self.conn, self.tcp_listener = self.establish_connection() + self.tcp_listener: Optional[socket.socket] = None # use tcp if not None + dc = self.detector_config # noqa: loaded from a camera/file.yaml self.dead_time = dc['TriggerPeriod'] - dc['ExposureTime'] self.movie_bufsize = 2 * 4 * prod(self.dimensions) self.null_image = np.zeros(shape=self.dimensions, dtype=np.uint32) + self.previous_config: dict = {} # used to revert to default after movie + self.previous_destination: dict = {} + self.conn: ServalCamera = self.establish_connection() + logger.info(f'Camera {self.get_name()} initialized') atexit.register(self.release_connection) - def establish_connection(self) -> tuple[ServalCamera, socket.socket]: - """Establish connection to the camera.""" - - http_url = urlparse(self.url) # noqa - loaded from a camera/file.yaml - tcp_port = (http_url.port or 8080) + 1 - local_ip = _local_ip_for(http_url.hostname, tcp_port) - tcp_base = f'tcp://connect@{local_ip}:{tcp_port}' - http_dest = {'Base': 'http://localhost', 'Format': 'tiff', 'Mode': 'count'} - self.tcp_dest = {'Base': tcp_base, 'Format': 'jsonimage', 'Mode': 'count'} - + def establish_connection(self) -> ServalCamera: + """Establish cam connection; "Missing" attrs are read from config.""" + http_url = urlparse(self.url) f = dict(bpc_file_path=self.bpc_file_path, dacs_file_path=self.dacs_file_path) + conn = ServalCamera() conn.connect(http_url.geturl()) conn.set_chip_config_files(**f) conn.set_detector_config(**self.detector_config) - tcp_listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - tcp_listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - tcp_listener.settimeout(5.0) - tcp_listener.bind(('0.0.0.0', tcp_port)) - tcp_listener.listen(1) + try: + tcp_listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + tcp_listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + tcp_listener.settimeout(1.0) + tcp_listener.bind(('0.0.0.0', 0)) + tcp_listener.listen(1) + tcp_port = tcp_listener.getsockname()[1] + local_ip = _local_ip_for(http_url.hostname, tcp_port) + tcp_base = f'tcp://connect@{local_ip}:{tcp_port}' + self.tcp_dest = {'Base': tcp_base, 'Format': 'jsonimage', 'Mode': 'count'} + self.conn, self.tcp_listener = conn, tcp_listener + _ = list(self.get_movie(n_frames=1, exposure=self.MIN_EXPOSURE)) + logger.info(f'TCP movie streaming ready on {tcp_port=}') + except OSError as exception: + try: + tcp_listener.close() # noqa: NameError excepted + except (NameError, OSError): + pass + self.tcp_listener = None + logger.info(f'TCP movie streaming {exception=}, falling back to HTTP') + http_dest = {'Base': 'http://localhost', 'Format': 'tiff', 'Mode': 'count'} conn.destination = {'Image': [http_dest]} - return conn, tcp_listener + return conn def release_connection(self) -> None: """Release the connection to the camera.""" self.conn.measurement_stop() - self.tcp_listener.close() + if self.tcp_listener is not None: + self.tcp_listener.close() msg = f"Connection to camera '{self.get_name()}' released" logger.info(msg) @@ -138,45 +154,61 @@ def get_movie( ) -> Iterator[np.ndarray]: """Yield `n_frames` images received via a TCP stream with minimal dead time. If the exposure is not given, the default value is read from the - config file. Binning is ignored. + config file. Binning is ignored. Setup is eager, iteration is delayed. n_frames: `int` Number of frames to collect exposure: `float` or `None` Exposure time in seconds. """ - logger.debug(f'Collecting {n_frames}-frame movie with {exposure=} s via TCP') + logger.debug(f'Collecting {n_frames}-frame movie with {exposure=} s') e: float = self.default_exposure if exposure is None else exposure - self.conn.measurement_stop() - previous_config = self.conn.detector_config - previous_destination = self.conn.destination - self.conn.destination = {'Image': [self.tcp_dest]} - self.set_detector_config(ExposureTime=e, nTriggers=n_frames) - def _get_movie_inner() -> Iterator[np.ndarray]: # this runs on next(): - try: - Thread(target=self.conn.measurement_start, daemon=True).start() + get_movie = self._get_movie_tcp if self.tcp_listener else self._get_movie_http + return get_movie(n_frames=n_frames, exposure=e) + + def _get_movie_http(self, n_frames: int, exposure: float) -> Iterator[np.ndarray]: + """Fallback method, polls frames from HTTP if TCP start-up failed.""" + self.previous_config = self.conn.detector_config + self.set_detector_config(ExposureTime=exposure, nTriggers=n_frames) + return self._get_movie_inner(n_frames=n_frames, use_tcp=False) + + def _get_movie_tcp(self, n_frames: int, exposure: float) -> Iterator[np.ndarray]: + """Fast method, reads frames directly from TCP stream if available.""" + self.previous_config = self.conn.detector_config + self.previous_destination = self.conn.destination + self.conn.destination = {'Image': [self.tcp_dest]} + self.set_detector_config(ExposureTime=exposure, nTriggers=n_frames) + return self._get_movie_inner(n_frames=n_frames, use_tcp=True) + + def _get_movie_inner(self, n_frames: int, use_tcp: bool) -> Iterator[np.ndarray]: + """Movie frame iterator, isolated from config for max performance.""" + try: + Thread(target=self.conn.measurement_start, daemon=True).start() + if use_tcp: try: sock, _ = self.tcp_listener.accept() except socket.timeout: - raise TimeoutError('Serval failed to connect back within 5 seconds.') + raise TimeoutError('Serval failed to connect back within 1s.') with sock: bs = self.movie_bufsize yield from ServalMovieDeserializer(sock, n_frames, bs) - - finally: - try: - self.conn.measurement_stop() - except Exception as ex: - logger.error(f'Error stopping measurement: {ex}') - try: - self.conn.destination = previous_destination - self.conn.set_detector_config(**previous_config) - except Exception as ex: - logger.error(f'Error restoring config: {ex}') - - return _get_movie_inner() + else: + for _ in range(n_frames): + response = self.conn.get_request('/measurement/image') + yield tifffile.imread(BytesIO(response.content)) + finally: + try: + self.conn.measurement_stop() + except Exception as ex: + logger.error(f'Error stopping measurement: {ex}') + try: + self.conn.set_detector_config(**self.previous_config) + if use_tcp: + self.conn.destination = self.previous_destination + except Exception as ex: + logger.error(f'Error restoring config and destination: {ex}') def get_image_dimensions(self) -> Tuple[int, int]: """Get the binned dimensions reported by the camera.""" From dfe8e22f335c48cd51c3e87fc4e95c8090d0543d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Fri, 7 Aug 2026 15:57:33 +0200 Subject: [PATCH 114/118] Try fixing establish_connection before testing movie --- src/instamatic/camera/camera_serval.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/instamatic/camera/camera_serval.py b/src/instamatic/camera/camera_serval.py index facfe68c..2269d0b8 100644 --- a/src/instamatic/camera/camera_serval.py +++ b/src/instamatic/camera/camera_serval.py @@ -63,6 +63,7 @@ def __init__(self, name='serval') -> None: def establish_connection(self) -> ServalCamera: """Establish cam connection; "Missing" attrs are read from config.""" http_url = urlparse(self.url) + hostname = http_url.hostname or 'localhost' f = dict(bpc_file_path=self.bpc_file_path, dacs_file_path=self.dacs_file_path) conn = ServalCamera() @@ -70,6 +71,9 @@ def establish_connection(self) -> ServalCamera: conn.set_chip_config_files(**f) conn.set_detector_config(**self.detector_config) + http_dest = {'Base': 'http://localhost', 'Format': 'tiff', 'Mode': 'count'} + conn.destination = {'Image': [http_dest]} + try: tcp_listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) tcp_listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) @@ -77,22 +81,19 @@ def establish_connection(self) -> ServalCamera: tcp_listener.bind(('0.0.0.0', 0)) tcp_listener.listen(1) tcp_port = tcp_listener.getsockname()[1] - local_ip = _local_ip_for(http_url.hostname, tcp_port) + local_ip = _local_ip_for(hostname, tcp_port) tcp_base = f'tcp://connect@{local_ip}:{tcp_port}' self.tcp_dest = {'Base': tcp_base, 'Format': 'jsonimage', 'Mode': 'count'} self.conn, self.tcp_listener = conn, tcp_listener _ = list(self.get_movie(n_frames=1, exposure=self.MIN_EXPOSURE)) logger.info(f'TCP movie streaming ready on {tcp_port=}') - except OSError as exception: - try: - tcp_listener.close() # noqa: NameError excepted - except (NameError, OSError): - pass - self.tcp_listener = None + except Exception as exception: + if self.tcp_listener is not None: + self.tcp_listener.close() + self.tcp_listener = None + conn.destination = {'Image': [http_dest]} logger.info(f'TCP movie streaming {exception=}, falling back to HTTP') - http_dest = {'Base': 'http://localhost', 'Format': 'tiff', 'Mode': 'count'} - conn.destination = {'Image': [http_dest]} return conn def release_connection(self) -> None: @@ -189,8 +190,9 @@ def _get_movie_inner(self, n_frames: int, use_tcp: bool) -> Iterator[np.ndarray] if use_tcp: try: sock, _ = self.tcp_listener.accept() + sock.settimeout(self.MAX_EXPOSURE) except socket.timeout: - raise TimeoutError('Serval failed to connect back within 1s.') + raise TimeoutError('Serval failed to connect back within 5s.') with sock: bs = self.movie_bufsize yield from ServalMovieDeserializer(sock, n_frames, bs) From 57921fc944a34971f5eef9468232646e383e9963 Mon Sep 17 00:00:00 2001 From: Daniel Tchon Date: Fri, 7 Aug 2026 17:48:04 +0200 Subject: [PATCH 115/118] Timeout-based TCP control is impossible: add env use_tcp_for_movie (bool) --- src/instamatic/camera/camera_serval.py | 58 ++++++++++++++------------ 1 file changed, 32 insertions(+), 26 deletions(-) diff --git a/src/instamatic/camera/camera_serval.py b/src/instamatic/camera/camera_serval.py index 2269d0b8..eccf33ae 100644 --- a/src/instamatic/camera/camera_serval.py +++ b/src/instamatic/camera/camera_serval.py @@ -74,26 +74,33 @@ def establish_connection(self) -> ServalCamera: http_dest = {'Base': 'http://localhost', 'Format': 'tiff', 'Mode': 'count'} conn.destination = {'Image': [http_dest]} - try: - tcp_listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - tcp_listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - tcp_listener.settimeout(1.0) - tcp_listener.bind(('0.0.0.0', 0)) - tcp_listener.listen(1) - tcp_port = tcp_listener.getsockname()[1] - local_ip = _local_ip_for(hostname, tcp_port) - tcp_base = f'tcp://connect@{local_ip}:{tcp_port}' - self.tcp_dest = {'Base': tcp_base, 'Format': 'jsonimage', 'Mode': 'count'} - self.conn, self.tcp_listener = conn, tcp_listener - _ = list(self.get_movie(n_frames=1, exposure=self.MIN_EXPOSURE)) - logger.info(f'TCP movie streaming ready on {tcp_port=}') - except Exception as exception: - if self.tcp_listener is not None: - self.tcp_listener.close() - self.tcp_listener = None - conn.destination = {'Image': [http_dest]} - logger.info(f'TCP movie streaming {exception=}, falling back to HTTP') - + # try: + # tcp_listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + # tcp_listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + # tcp_listener.settimeout(1.0) + # tcp_listener.bind(('0.0.0.0', 0)) + # tcp_listener.listen(1) + # tcp_port = tcp_listener.getsockname()[1] + # + # local_ip = _local_ip_for(hostname, tcp_port) + # tcp_base = f'tcp://connect@{local_ip}:{tcp_port}' + # print(f'{tcp_base=}') + # + # self.tcp_dest = {'Base': tcp_base, 'Format': 'jsonimage', 'Mode': 'count'} + # self.conn, self.tcp_listener = conn, tcp_listener + # + # # Test movie: will raise TimeoutError or RuntimeError if TCP fails + # _ = list(self.get_movie(n_frames=1, exposure=self.MIN_EXPOSURE)) + # logger.info(f'TCP movie streaming ready on {tcp_port=}') + # + # except Exception as exception: + # if self.tcp_listener is not None: + # self.tcp_listener.close() + # self.tcp_listener = None + # + # conn.destination = {'Image': [http_dest]} + # logger.warning(f'TCP movie streaming {exception=}, falling back to HTTP') + self.tcp_listener = None return conn def release_connection(self) -> None: @@ -186,17 +193,16 @@ def _get_movie_tcp(self, n_frames: int, exposure: float) -> Iterator[np.ndarray] def _get_movie_inner(self, n_frames: int, use_tcp: bool) -> Iterator[np.ndarray]: """Movie frame iterator, isolated from config for max performance.""" try: - Thread(target=self.conn.measurement_start, daemon=True).start() if use_tcp: - try: - sock, _ = self.tcp_listener.accept() - sock.settimeout(self.MAX_EXPOSURE) - except socket.timeout: - raise TimeoutError('Serval failed to connect back within 5s.') + Thread(target=self.conn.measurement_start, daemon=True).start() + self.tcp_listener.settimeout(1.0) + sock, _ = self.tcp_listener.accept() + sock.settimeout(1.1 * self.MAX_EXPOSURE) with sock: bs = self.movie_bufsize yield from ServalMovieDeserializer(sock, n_frames, bs) else: + self.conn.measurement_start() for _ in range(n_frames): response = self.conn.get_request('/measurement/image') yield tifffile.imread(BytesIO(response.content)) From 4084602c4ed6d34dafe3397c7b8c91bb7a7f1614 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Fri, 7 Aug 2026 19:13:31 +0200 Subject: [PATCH 116/118] Give up, define new STREAM_MOVIES_VIA_TCP to control TCP streaming --- src/instamatic/camera/camera_serval.py | 112 +++++++++++------------ src/instamatic/config/camera/serval.yaml | 1 + 2 files changed, 53 insertions(+), 60 deletions(-) diff --git a/src/instamatic/camera/camera_serval.py b/src/instamatic/camera/camera_serval.py index eccf33ae..cf7fdc4f 100644 --- a/src/instamatic/camera/camera_serval.py +++ b/src/instamatic/camera/camera_serval.py @@ -9,12 +9,13 @@ from io import BytesIO from math import prod from threading import Thread -from typing import Iterator, Optional, Sequence, Tuple, Union +from typing import Iterator, Optional, Sequence, Tuple from urllib.parse import urlparse import numpy as np import tifffile from serval_toolkit.camera import Camera as ServalCamera +from typing_extensions import TypeAlias from instamatic.camera.camera_base import CameraBase @@ -26,6 +27,14 @@ # 3. launch `instamatic` +# By default, movies are requested image-by-image client-side which can be slow. +# Setting this var or using `camera.yaml` equivalent causes movies to be streamed +# by server via TCP which is faster, but may require tweaking firewall settings. +STREAM_MOVIES_VIA_TCP: bool = False + +Movie: TypeAlias = Iterator[np.ndarray] + + def _local_ip_for(remote_host: str, remote_port: int) -> str: """Return the local IP used to reach (remote_host, remote_port).""" with contextlib.closing(socket.socket(socket.AF_INET, socket.SOCK_DGRAM)) as s: @@ -42,10 +51,10 @@ class CameraServal(CameraBase): BAD_EXPOSURE_MSG = 'Requested exposure exceeds native Serval support (>0-10s)' def __init__(self, name='serval') -> None: - """Initialize camera module.""" + """Initialize camera module, vars, establish connection & cleanup.""" super().__init__(name) - self.tcp_dest: dict[str, str] = {} # destination for serial movies + self.tcp_dest: dict[str, str] = {} # used if STREAM_MOVIES_VIA_TCP=True self.tcp_listener: Optional[socket.socket] = None # use tcp if not None dc = self.detector_config # noqa: loaded from a camera/file.yaml @@ -63,56 +72,37 @@ def __init__(self, name='serval') -> None: def establish_connection(self) -> ServalCamera: """Establish cam connection; "Missing" attrs are read from config.""" http_url = urlparse(self.url) - hostname = http_url.hostname or 'localhost' + http_dest = {'Base': 'http://localhost', 'Format': 'tiff', 'Mode': 'count'} f = dict(bpc_file_path=self.bpc_file_path, dacs_file_path=self.dacs_file_path) conn = ServalCamera() conn.connect(http_url.geturl()) conn.set_chip_config_files(**f) conn.set_detector_config(**self.detector_config) - - http_dest = {'Base': 'http://localhost', 'Format': 'tiff', 'Mode': 'count'} conn.destination = {'Image': [http_dest]} - # try: - # tcp_listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - # tcp_listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - # tcp_listener.settimeout(1.0) - # tcp_listener.bind(('0.0.0.0', 0)) - # tcp_listener.listen(1) - # tcp_port = tcp_listener.getsockname()[1] - # - # local_ip = _local_ip_for(hostname, tcp_port) - # tcp_base = f'tcp://connect@{local_ip}:{tcp_port}' - # print(f'{tcp_base=}') - # - # self.tcp_dest = {'Base': tcp_base, 'Format': 'jsonimage', 'Mode': 'count'} - # self.conn, self.tcp_listener = conn, tcp_listener - # - # # Test movie: will raise TimeoutError or RuntimeError if TCP fails - # _ = list(self.get_movie(n_frames=1, exposure=self.MIN_EXPOSURE)) - # logger.info(f'TCP movie streaming ready on {tcp_port=}') - # - # except Exception as exception: - # if self.tcp_listener is not None: - # self.tcp_listener.close() - # self.tcp_listener = None - # - # conn.destination = {'Image': [http_dest]} - # logger.warning(f'TCP movie streaming {exception=}, falling back to HTTP') - self.tcp_listener = None + if getattr(self, 'stream_movies_via_tcp') or STREAM_MOVIES_VIA_TCP: + self.tcp_listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.tcp_listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.tcp_listener.settimeout(1.0) + self.tcp_listener.bind(('0.0.0.0', 0)) + self.tcp_listener.listen(1) + tcp_port = self.tcp_listener.getsockname()[1] + local_ip = _local_ip_for(http_url.hostname or 'localhost', tcp_port) + tcp_base = f'tcp://connect@{local_ip}:{tcp_port}' + self.tcp_dest = {'Base': tcp_base, 'Format': 'jsonimage', 'Mode': 'count'} + return conn def release_connection(self) -> None: - """Release the connection to the camera.""" + """Release the connection to the camera (HTTP & TCP if applicable).""" self.conn.measurement_stop() if self.tcp_listener is not None: self.tcp_listener.close() - msg = f"Connection to camera '{self.get_name()}' released" - logger.info(msg) + logger.info(f"Connection to camera '{self.get_name()}' released") def set_detector_config(self, **kwargs) -> None: - """Set detector config while infering about missing config params.""" + """Set detector config while inferring about missing config params.""" if 'TriggerMode' not in kwargs: tm = 'AUTOTRIGSTART_TIMERSTOP' if self.dead_time else 'CONTINUOUS' kwargs['TriggerMode'] = tm @@ -157,12 +147,11 @@ def _spliced_sum(self, arrays: Sequence[np.ndarray], exposure: float) -> np.ndar live_fraction = len(arrays) * exposure / total_exposure return (array_sum / live_fraction).astype(arrays[0].dtype) - def get_movie( - self, n_frames: int, exposure: Optional[float] = None, **kwargs - ) -> Iterator[np.ndarray]: - """Yield `n_frames` images received via a TCP stream with minimal dead - time. If the exposure is not given, the default value is read from the - config file. Binning is ignored. Setup is eager, iteration is delayed. + def get_movie(self, n_frames: int, exposure: Optional[float] = None, **_) -> Movie: + """Yield `n_frames` images received via HTTP (convenient) or TCP + (fast). If the exposure is not given, the default is read from the + config file. Binning is ignored. Setup is eager, start is delayed until + iteration. n_frames: `int` Number of frames to collect @@ -172,35 +161,38 @@ def get_movie( logger.debug(f'Collecting {n_frames}-frame movie with {exposure=} s') e: float = self.default_exposure if exposure is None else exposure self.conn.measurement_stop() - get_movie = self._get_movie_tcp if self.tcp_listener else self._get_movie_http return get_movie(n_frames=n_frames, exposure=e) - def _get_movie_http(self, n_frames: int, exposure: float) -> Iterator[np.ndarray]: - """Fallback method, polls frames from HTTP if TCP start-up failed.""" + def _get_movie_http(self, n_frames: int, exposure: float) -> Movie: + """Convenient method: poll frames via HTTP using the client socket.""" self.previous_config = self.conn.detector_config self.set_detector_config(ExposureTime=exposure, nTriggers=n_frames) - return self._get_movie_inner(n_frames=n_frames, use_tcp=False) + return self._get_movie_inner(n_frames=n_frames) - def _get_movie_tcp(self, n_frames: int, exposure: float) -> Iterator[np.ndarray]: - """Fast method, reads frames directly from TCP stream if available.""" + def _get_movie_tcp(self, n_frames: int, exposure: float) -> Movie: + """Fast: stream frames directly via TCP if STREAM_MOVIES_VIA_TCP.""" self.previous_config = self.conn.detector_config self.previous_destination = self.conn.destination self.conn.destination = {'Image': [self.tcp_dest]} self.set_detector_config(ExposureTime=exposure, nTriggers=n_frames) - return self._get_movie_inner(n_frames=n_frames, use_tcp=True) + return self._get_movie_inner(n_frames=n_frames) - def _get_movie_inner(self, n_frames: int, use_tcp: bool) -> Iterator[np.ndarray]: - """Movie frame iterator, isolated from config for max performance.""" + def _get_movie_inner(self, n_frames: int) -> Movie: + """Frame generator. + + Separate method because `yield` keyword makes this + code execution delayed. In other words, setup runs when `get_movie` is + called, but this method only when returned iterator is first iterated. + """ try: - if use_tcp: + if self.tcp_listener: Thread(target=self.conn.measurement_start, daemon=True).start() - self.tcp_listener.settimeout(1.0) + self.tcp_listener.settimeout(5.0) sock, _ = self.tcp_listener.accept() sock.settimeout(1.1 * self.MAX_EXPOSURE) with sock: - bs = self.movie_bufsize - yield from ServalMovieDeserializer(sock, n_frames, bs) + yield from ServalMovieDeserializer(sock, n_frames, self.movie_bufsize) else: self.conn.measurement_start() for _ in range(n_frames): @@ -213,7 +205,7 @@ def _get_movie_inner(self, n_frames: int, use_tcp: bool) -> Iterator[np.ndarray] logger.error(f'Error stopping measurement: {ex}') try: self.conn.set_detector_config(**self.previous_config) - if use_tcp: + if self.tcp_listener: self.conn.destination = self.previous_destination except Exception as ex: logger.error(f'Error restoring config and destination: {ex}') @@ -225,7 +217,7 @@ def get_image_dimensions(self) -> Tuple[int, int]: return int(dim_x / binning), int(dim_y / binning) -class ServalMovieDeserializer(Iterator[np.ndarray]): +class ServalMovieDeserializer(Movie): """Deserializes Serval camera TCP byte stream from socket into images.""" def __init__(self, sock: socket.socket, n_frames: int, bufsize: int) -> None: @@ -259,7 +251,7 @@ def _parse_header(self, header_size: int) -> None: header_str = self.buffer[:header_size].decode('utf-8') header_dict = json.loads(header_str) bit_depth = header_dict['bitDepth'] - self.shape = header_dict['height'], header_dict['width'] + self.shape = (header_dict['height'], header_dict['width']) self.dtype = np.dtype(f'uint{bit_depth}').newbyteorder('>') self.size = header_dict.get('dataSize', prod(self.shape) * self.dtype.itemsize) @@ -267,7 +259,7 @@ def __next__(self) -> np.ndarray: """Recv as much data as needed, return next frame from TCP stream.""" if self.i_frame >= self.n_frames: raise StopIteration - header_end = self._receive_until(b'}') + 1 + header_end = self._receive_until(b'}') + 1 # json image never nests "}" if self.i_frame == 0: self._parse_header(header_end) while self.used < header_end + self.size: diff --git a/src/instamatic/config/camera/serval.yaml b/src/instamatic/config/camera/serval.yaml index 13b573c8..5cd92273 100644 --- a/src/instamatic/config/camera/serval.yaml +++ b/src/instamatic/config/camera/serval.yaml @@ -37,3 +37,4 @@ detector_config: bpc_file_path: '/home/asi/Desktop/Factory_settings/SPM-HGM/config.bpc' dacs_file_path: '/home/asi/Desktop/Factory_settings/SPM-HGM/config.dacs' url: 'http://localhost:8080' +stream_movies_via_tcp: false From 68699ad05be59cd1c581aa1b7ac07361228090ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Fri, 7 Aug 2026 19:15:53 +0200 Subject: [PATCH 117/118] Minor docstring improvement --- src/instamatic/camera/camera_serval.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/instamatic/camera/camera_serval.py b/src/instamatic/camera/camera_serval.py index cf7fdc4f..ea11e622 100644 --- a/src/instamatic/camera/camera_serval.py +++ b/src/instamatic/camera/camera_serval.py @@ -149,9 +149,8 @@ def _spliced_sum(self, arrays: Sequence[np.ndarray], exposure: float) -> np.ndar def get_movie(self, n_frames: int, exposure: Optional[float] = None, **_) -> Movie: """Yield `n_frames` images received via HTTP (convenient) or TCP - (fast). If the exposure is not given, the default is read from the - config file. Binning is ignored. Setup is eager, start is delayed until - iteration. + (fast); If the exposure is None, the default is read from the config. + Binning is ignored. Setup is eager, start is delayed until iteration. n_frames: `int` Number of frames to collect @@ -179,12 +178,10 @@ def _get_movie_tcp(self, n_frames: int, exposure: float) -> Movie: return self._get_movie_inner(n_frames=n_frames) def _get_movie_inner(self, n_frames: int) -> Movie: - """Frame generator. - - Separate method because `yield` keyword makes this - code execution delayed. In other words, setup runs when `get_movie` is - called, but this method only when returned iterator is first iterated. - """ + """Frame generator; Separate method because `yield` keyword makes this + code execution delayed; In other words, setup runs when `get_movie` is + called, but this method only when returned iterator is first + iterated.""" try: if self.tcp_listener: Thread(target=self.conn.measurement_start, daemon=True).start() From 06d2ca7c2762a2d2fba612d7b1b4a86dfb2bba35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Fri, 7 Aug 2026 19:16:28 +0200 Subject: [PATCH 118/118] Minor docstring improvement --- src/instamatic/camera/camera_serval.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/instamatic/camera/camera_serval.py b/src/instamatic/camera/camera_serval.py index ea11e622..8aa775b5 100644 --- a/src/instamatic/camera/camera_serval.py +++ b/src/instamatic/camera/camera_serval.py @@ -180,8 +180,7 @@ def _get_movie_tcp(self, n_frames: int, exposure: float) -> Movie: def _get_movie_inner(self, n_frames: int) -> Movie: """Frame generator; Separate method because `yield` keyword makes this code execution delayed; In other words, setup runs when `get_movie` is - called, but this method only when returned iterator is first - iterated.""" + called, but this method only when returned iterator is iterated.""" try: if self.tcp_listener: Thread(target=self.conn.measurement_start, daemon=True).start()