From 020058c8a17ccfa226c0a38aeea0d7761fd14bb4 Mon Sep 17 00:00:00 2001 From: mgiammar Date: Tue, 20 Oct 2026 13:25:18 -0700 Subject: [PATCH 1/3] feat: beam mask estimation with Sobel edge fitting --- .../decolace_pipeline_example_config.yaml | 6 +- src/ripple/config/beam_mask_config.py | 34 +- src/ripple/core/__init__.py | 8 +- src/ripple/core/beam_mask.py | 366 +++++++++++++++++- src/ripple/managers/beam_mask_manager.py | 2 + tests/test_beam_mask.py | 190 +++++++++ tests/test_beam_mask_manager.py | 66 +++- tests/test_movie_config.py | 92 +++++ 8 files changed, 734 insertions(+), 30 deletions(-) create mode 100644 tests/test_movie_config.py diff --git a/programs/decolace_pipeline/decolace_pipeline_example_config.yaml b/programs/decolace_pipeline/decolace_pipeline_example_config.yaml index 814f843..8c9a75c 100644 --- a/programs/decolace_pipeline/decolace_pipeline_example_config.yaml +++ b/programs/decolace_pipeline/decolace_pipeline_example_config.yaml @@ -27,9 +27,11 @@ movie_config: # --- Beam mask estimation parameters --- beam_mask_config: - threshold_method: "otsu" # (optional, default "otsu") Thresholding algorithm for binarising the low-pass-filtered frame sum. Only "otsu" is currently supported. + threshold_method: "sobel" # (optional, default "sobel") Ellipse fit method: "sobel" (outermost Sobel edge along rays from the beam centre; robust to dark sample at the rim) or "otsu" (older Otsu threshold + connected component). diameter_reduction: 0.0 # (optional, default 0.0) Fractional shrink applied to both ellipse radii; 0.1 shrinks each axis by 10% to exclude fringe artifacts at the beam edge. - low_pass_resolution: 100.0 # (optional, default 100.0) Low-pass filter cutoff (Angstroms) applied to the frame sum before thresholding. + working_pixel_size: 8.0 # (optional, default 8.0) "sobel" only. Approximate pixel size (Angstroms) the frame sum is binned to before edge detection. + edge_smoothing_sigma: 120.0 # (optional, default 120.0) "sobel" only. Gaussian smoothing sigma (Angstroms) applied before edge detection. + low_pass_resolution: 100.0 # (optional, default 100.0) "otsu" only. Low-pass filter cutoff (Angstroms) applied to the frame sum before thresholding. # --- Output file configuration --- output_config: diff --git a/src/ripple/config/beam_mask_config.py b/src/ripple/config/beam_mask_config.py index cca4d48..bdb57ba 100644 --- a/src/ripple/config/beam_mask_config.py +++ b/src/ripple/config/beam_mask_config.py @@ -3,6 +3,7 @@ from typing import Literal import torch +from pydantic import PositiveFloat from teamtomo_basemodel import BaseModelTeamTomo from ripple.config.crop_bounds_config import CropBoundsConfig @@ -15,24 +16,36 @@ class BeamMaskConfig(BaseModelTeamTomo): Parameters ---------- - threshold_method: Literal["otsu"] - Thresholding algorithm used to binarise the low-pass-filtered frame sum. - Currently only ``"otsu"`` is supported. Default is ``"otsu"``. + threshold_method: Literal["sobel", "otsu"] + Method used to fit the beam ellipse to the frame sum. ``"sobel"`` fits the + ellipse to the outermost Sobel edge along rays cast from the beam center, and is + robust to dark sample lying across the beam rim. ``"otsu"``thresholds the + low-pass-filtered frame sum with Otsu's method and fits the largest connected + component. diameter_reduction: float Fractional amount by which both ellipse radii are shrunk before creating the final mask. ``0.0`` applies no reduction; ``0.1`` shrinks each axis by 10% to exclude fringe artifacts at the beam edge. Default is 0.0. low_pass_resolution: float Low-pass filter cutoff, in Angstroms, applied to the frame sum before - thresholding. Default is 100.0. + thresholding. Only used by ``"otsu"``. Default is 100.0. + working_pixel_size: float + Approximate pixel size, in Angstroms, the frame sum is binned to before edge + detection. Only used by ``"sobel"``. Default is 8.0. + edge_smoothing_sigma: float + Standard deviation, in Angstroms, of the Gaussian smoothing applied to the + binned frame sum before edge detection. Only used by ``"sobel"``. Default is + 120.0. crop_bounds_config: CropBoundsConfig Size policy for cropping movie frames down to the region covered by the beam mask. Default disables cropping (``mode="none"``). """ - threshold_method: Literal["otsu"] = "otsu" + threshold_method: Literal["sobel", "otsu"] = "sobel" diameter_reduction: float = 0.0 low_pass_resolution: float = 100.0 + working_pixel_size: PositiveFloat = 8.0 + edge_smoothing_sigma: PositiveFloat = 120.0 crop_bounds_config: CropBoundsConfig = CropBoundsConfig() @@ -80,9 +93,16 @@ class BeamMaskResult(BaseModelTeamTomo): output_crop_max_x: int Right column of the output crop region, inclusive. threshold_method: str - Thresholding algorithm used to binarise the filtered frame sum. + Method used to fit the beam ellipse (``"sobel"`` or ``"otsu"``). pixel_size: float Pixel size in Angstroms per pixel. + n_edge_points: int | None + Number of beam-edge points found by the ``"sobel"`` method (at most one per ray + cast). None for ``"otsu"``. + n_inliers: int | None + Number of edge points kept as beam-rim inliers by the ``"sobel"`` method. + ``n_inliers / ripple.core.beam_mask.DEFAULT_N_RAYS`` is the fraction of the + rim that was found. None for ``"otsu"``. """ center_y: float @@ -103,6 +123,8 @@ class BeamMaskResult(BaseModelTeamTomo): output_crop_max_x: int threshold_method: str pixel_size: float + n_edge_points: int | None = None + n_inliers: int | None = None @property def crop_bounds(self) -> CropBounds: diff --git a/src/ripple/core/__init__.py b/src/ripple/core/__init__.py index 636e476..0bb5b3b 100644 --- a/src/ripple/core/__init__.py +++ b/src/ripple/core/__init__.py @@ -1,6 +1,11 @@ """Core functions for RIPPLE.""" -from .beam_mask import estimate_beam_mask, make_ellipse_mask, sum_movie_chunked +from .beam_mask import ( + estimate_beam_mask, + fit_beam_edge, + make_ellipse_mask, + sum_movie_chunked, +) from .core_optimize_sigmas import core_optimize_sigmas from .core_polish_particles import core_polish_particles from .crop_bounds import crop_movie, determine_crop_bounds, get_crop_bounds @@ -13,6 +18,7 @@ "crop_movie", "determine_crop_bounds", "estimate_beam_mask", + "fit_beam_edge", "fourier_crop_movie", "generate_dose_weighted_image", "get_crop_bounds", diff --git a/src/ripple/core/beam_mask.py b/src/ripple/core/beam_mask.py index 64b0b68..a7aeda3 100644 --- a/src/ripple/core/beam_mask.py +++ b/src/ripple/core/beam_mask.py @@ -7,12 +7,18 @@ import cv2 import numpy as np import torch +from scipy.ndimage import gaussian_filter, map_coordinates from skimage.measure import label, regionprops from torch_fourier_filter.bandpass import bandpass_filter from ripple.core.crop_bounds import get_crop_bounds from ripple.core.prepare_movie import DEFAULT_PREP_CHUNK_SIZE +Ellipse = tuple[float, float, float, float, float] # cy, cx, axis1, axis2, angle_deg + +# Number of rays cast from the beam center for the Sobel edge-ray fit +DEFAULT_N_RAYS = 720 + class BeamMaskParams(TypedDict): """Fitted ellipse and crop-bound parameters returned by `estimate_beam_mask`.""" @@ -31,6 +37,20 @@ class BeamMaskParams(TypedDict): crop_max_x: int threshold_method: str pixel_size: float + n_edge_points: int | None # NOTE: Only non-None for "sobel" method + n_inliers: int | None # NOTE: Only non-None for "sobel" method + + +class BeamEdgeFit(TypedDict): + """Ellipse and edge-point counts returned by `fit_beam_edge`.""" + + center_y: float + center_x: float + axis1: float + axis2: float + angle_deg: float + n_edge_points: int + n_inliers: int # --------------------------------------------------------------------------- @@ -308,6 +328,283 @@ def make_ellipse_mask( return inside +# --------------------------------------------------------------------------- +# Sobel edge-ray fit +# --------------------------------------------------------------------------- + + +def bin_image(image: np.ndarray, factor: int) -> np.ndarray: + """Block-mean a 2-D array by `factor`, cropping any remainder. + + Parameters + ---------- + image : np.ndarray + 2-D array to bin. + factor : int + Binning factor along both axes. Values ``<= 1`` return an unbinned copy. + + Returns + ------- + np.ndarray + float32 array of shape ``(height // factor, width // factor)``. + """ + if factor <= 1: + return image.astype(np.float32) + h = image.shape[0] // factor * factor + w = image.shape[1] // factor * factor + binned: np.ndarray = ( + image[:h, :w] + .reshape(h // factor, factor, w // factor, factor) + .mean((1, 3), dtype=np.float32) + ) + return binned + + +def _background_stats(image: np.ndarray) -> tuple[float, float, float]: + """Return ``(background, noise, beam_level)`` of a smoothed frame sum.""" + dark = image[image <= np.percentile(image, 10)] + bg = float(np.median(dark)) + noise = float(1.4826 * np.median(np.abs(dark - bg))) + 1e-6 + return bg, noise, float(np.percentile(image, 95)) + + +def _initial_center(image: np.ndarray, k: float = 8.0) -> tuple[float, float]: + """Centroid ``(y, x)`` of the image saturated at background + `k` x noise.""" + bg, noise, _ = _background_stats(image) + weight = np.clip((image - bg) / (k * noise), 0.0, 1.0) + total = float(weight.sum()) + if total <= 0.0: + return (image.shape[0] - 1) / 2.0, (image.shape[1] - 1) / 2.0 + yy, xx = np.indices(weight.shape) + return float((yy * weight).sum()) / total, float((xx * weight).sum()) / total + + +def radial_edge_points( + image: np.ndarray, + center: tuple[float, float], + n_rays: int = DEFAULT_N_RAYS, + snr: float = 6.0, + margin: int = 4, + exit_level: float = 0.15, +) -> np.ndarray: + """Find one beam-edge point per ray cast outward from `center`. + + Parameters + ---------- + image : np.ndarray + Smoothed 2-D frame sum, linear intensity. + center : tuple[float, float] + ``(y, x)`` origin of the rays, in pixels. + n_rays : int + Number of rays, evenly spaced in angle. + snr : float + Minimum edge strength, in units of the background gradient noise. + margin : int + Samples within `margin` pixels of the image border are ignored. + exit_level : float + Normalized intensity (0 = background, 1 = beam) above which a ray is considered + to leave the detector while still inside the beam. + + Returns + ------- + np.ndarray + ``(N, 2)`` array of edge points as ``(x, y)`` = ``(col, row)``, ``N <= + n_rays``. + """ + h, w = image.shape + image = image.astype(np.float32) + gx = cv2.Sobel(image, cv2.CV_32F, 1, 0, ksize=3) + gy = cv2.Sobel(image, cv2.CV_32F, 0, 1, ksize=3) + + bg, _, beam_level = _background_stats(image) + dark = image <= np.percentile(image, 10) + grad_noise = float(np.std(np.r_[gx[dark], gy[dark]])) + 1e-9 + + radii = np.arange(0.15 * min(h, w) / 2, np.hypot(h, w), 0.5) + theta = np.linspace(0, 2 * np.pi, n_rays, endpoint=False) + ux, uy = np.cos(theta)[:, None], np.sin(theta)[:, None] + xs, ys = center[1] + ux * radii, center[0] + uy * radii + inside = ( + (xs > margin) & (xs < w - 1 - margin) & (ys > margin) & (ys < h - 1 - margin) + ) + + # Bright-to-dark step along the outward direction, in noise units. + drop = -( + map_coordinates(gx, [ys, xs], order=1) * ux + + map_coordinates(gy, [ys, xs], order=1) * uy + ) + drop = np.where(inside, drop / grad_noise, 0.0) + + rows = np.arange(n_rays) + peak = (drop > snr) & (drop >= np.roll(drop, 1, 1)) & (drop >= np.roll(drop, -1, 1)) + idx = drop.shape[1] - 1 - np.argmax(peak[:, ::-1], axis=1) # outermost peak + + # In-image samples along a ray are contiguous since the frame is convex. + last = np.maximum(inside.sum(axis=1) - 1, 0) + exit_intensity = map_coordinates(image, [ys[rows, last], xs[rows, last]], order=1) + exit_level_norm = (exit_intensity - bg) / max(beam_level - bg, 1e-9) + + keep = (drop[rows, idx] > snr) & (exit_level_norm < exit_level) + points: np.ndarray = np.c_[xs[rows, idx], ys[rows, idx]][keep] + return points + + +def _cv2_fit_ellipse(points_xy: np.ndarray) -> Ellipse: + """``cv2.fitEllipse`` on ``(x, y)`` points, as ``(cy, cx, axis1, axis2, angle)``.""" + (cx, cy), (ax1, ax2), angle_deg = cv2.fitEllipse( + points_xy.astype(np.float32).reshape(-1, 1, 2) + ) + return float(cy), float(cx), float(ax1) / 2.0, float(ax2) / 2.0, float(angle_deg) + + +def _ellipse_residual(points_xy: np.ndarray, ellipse: Ellipse) -> np.ndarray: + """Approximate radial distance, in pixels, of each point from `ellipse`.""" + cy, cx, axis1, axis2, angle_deg = ellipse + theta = np.radians(angle_deg) + dx, dy = points_xy[:, 0] - cx, points_xy[:, 1] - cy + proj1 = dx * np.cos(theta) + dy * np.sin(theta) + proj2 = -dx * np.sin(theta) + dy * np.cos(theta) + rho = np.sqrt((proj1 / axis1) ** 2 + (proj2 / axis2) ** 2) + residual: np.ndarray = (rho - 1.0) * np.sqrt(axis1 * axis2) + return residual + + +def _circle_from_3_points(points_xy: np.ndarray) -> tuple[float, float, float] | None: + """Circle ``(x, y, r)`` through three points, or None if they are collinear.""" + (x1, y1), (x2, y2), (x3, y3) = points_xy + d = 2.0 * (x1 * (y2 - y3) + x2 * (y3 - y1) + x3 * (y1 - y2)) + if abs(d) < 1e-9: + return None + s1, s2, s3 = x1**2 + y1**2, x2**2 + y2**2, x3**2 + y3**2 + ux = (s1 * (y2 - y3) + s2 * (y3 - y1) + s3 * (y1 - y2)) / d + uy = (s1 * (x3 - x2) + s2 * (x1 - x3) + s3 * (x2 - x1)) / d + return float(ux), float(uy), float(np.hypot(x1 - ux, y1 - uy)) + + +def robust_ellipse_fit( + points_xy: np.ndarray, + tol_frac: float = 0.02, + n_iterations: int = 400, + seed: int = 0, +) -> tuple[Ellipse, np.ndarray]: + """Fit an ellipse to beam-edge points, rejecting non-rim outliers via RANSAC. + + Parameters + ---------- + points_xy : np.ndarray + ``(N, 2)`` candidate edge points as ``(x, y)``. + tol_frac : float + RANSAC inlier band as a fraction of the candidate circle radius. + n_iterations : int + Number of RANSAC iterations. + seed : int + Seed for the RANSAC sampler, so results are deterministic. + + Returns + ------- + tuple[Ellipse, np.ndarray] + ``(center_y, center_x, axis1, axis2, angle_deg)`` in the :func:`fit_ellipse` + convention, and the boolean inlier mask over `points_xy`. + + Raises + ------ + ValueError + If fewer than 5 points are given or no consistent rim is found. + """ + n_points = len(points_xy) + if n_points < 5: + raise ValueError(f"Only {n_points} beam-edge points found; need at least 5.") + + rng = np.random.default_rng(seed) + best: np.ndarray | None = None + for _ in range(n_iterations): + circle = _circle_from_3_points( + points_xy[rng.choice(n_points, 3, replace=False)] + ) + if circle is None: + continue + cx, cy, r = circle + dist = np.hypot(points_xy[:, 0] - cx, points_xy[:, 1] - cy) + inliers = np.abs(dist - r) < tol_frac * r + if best is None or inliers.sum() > best.sum(): + best = inliers + if best is None or best.sum() < 5: + raise ValueError("No consistent beam rim found among the beam-edge points.") + + # NOTE: Converting median of residual statistics to MAD (median absolute deviation) + # under assumption of Gaussian noise. Taking 75th percentile yields + # sigma = 1.4826 * MAD. + keep = best + for _ in range(3): + ellipse = _cv2_fit_ellipse(points_xy[keep]) + residual = _ellipse_residual(points_xy, ellipse) + mad = 1.4826 * float(np.median(np.abs(residual[keep]))) + new_keep = np.abs(residual) < max(3.0 * mad, 1.0) + if new_keep.sum() < 5: + break + keep = new_keep + + return ellipse, keep + + +def fit_beam_edge( + frame_sum: np.ndarray, + pixel_size: float, + working_pixel_size: float = 8.0, + smoothing_sigma: float = 120.0, + n_rays: int = DEFAULT_N_RAYS, +) -> BeamEdgeFit: + """Fit the beam ellipse to the outer Sobel edge of a raw frame sum. + + Parameters + ---------- + frame_sum : np.ndarray + 2-D sum of all raw movie frames. + pixel_size : float + Pixel size of `frame_sum` in Angstroms per pixel. + working_pixel_size : float + Approximate pixel size, in Angstroms, the frame sum is binned to before edge + detection. + smoothing_sigma : float + Standard deviation, in Angstroms, of the Gaussian applied after binning. + n_rays : int + Number of rays cast from the beam center. + + Returns + ------- + BeamEdgeFit + Fitted ellipse in `frame_sum` pixels (:func:`fit_ellipse` convention), plus + the number of edge points found and the number kept as rim inliers. + + Raises + ------ + ValueError + If no beam rim can be found (e.g. no beam in the frame). + """ + factor = max(1, round(working_pixel_size / pixel_size)) + image = gaussian_filter( + bin_image(frame_sum, factor), smoothing_sigma / (pixel_size * factor) + ) + + center = _initial_center(image) + for _ in range(2): # second pass re-casts rays from the fitted center + points = radial_edge_points(image, center, n_rays=n_rays) + ellipse, keep = robust_ellipse_fit(points) + center = (ellipse[0], ellipse[1]) + + cy, cx, axis1, axis2, angle_deg = ellipse + return { + # Pixel-center convention when undoing the binning. + "center_y": (cy + 0.5) * factor - 0.5, + "center_x": (cx + 0.5) * factor - 0.5, + "axis1": axis1 * factor, + "axis2": axis2 * factor, + "angle_deg": angle_deg, + "n_edge_points": len(points), + "n_inliers": int(keep.sum()), + } + + # --------------------------------------------------------------------------- # Orchestration # --------------------------------------------------------------------------- @@ -320,12 +617,19 @@ def estimate_beam_mask( diameter_reduction: float, low_pass_resolution: float, device: torch.device | str | None = None, + working_pixel_size: float = 8.0, + edge_smoothing_sigma: float = 120.0, ) -> BeamMaskParams: """Estimate a DeCo-LACE beam mask ellipse from a raw frame sum. - Low-pass filters `frame_sum` (on `device`), thresholds it with Otsu's method, fits - an ellipse to the largest connected component, and computes the tight crop bounds - for the (possibly shrunk) ellipse mask. + Fits an ellipse to the beam with one of two methods, then computes the tight crop + bounds for the (possibly shrunk) ellipse mask: + + - ``"sobel"``: bins and Gaussian-smooths `frame_sum`, then fits the ellipse to the + outermost Sobel edge along rays cast from the beam center (see + :func:`fit_beam_edge`). Robust to dark sample lying across the beam rim. + - ``"otsu"``: low-pass filters `frame_sum` (on `device`), thresholds it with + Otsu's method and fits an ellipse to the largest connected component. Parameters ---------- @@ -334,16 +638,22 @@ def estimate_beam_mask( pixel_size : float Pixel size in Angstroms per pixel. threshold_method : str - Thresholding algorithm; currently only ``"otsu"`` is supported. + Ellipse estimation method, ``"sobel"`` or ``"otsu"``. diameter_reduction : float Fractional shrink applied to both ellipse radii before computing crop bounds and the returned mask parameters. low_pass_resolution : float Low-pass filter cutoff resolution in Angstroms, applied to `frame_sum` - before thresholding. + before thresholding. Only used by ``"otsu"``. device : torch.device | str | None Device the low-pass filter runs on. If None, uses `frame_sum`'s current - device. + device. Only used by ``"otsu"``; ``"sobel"`` runs on the CPU. + working_pixel_size : float + Approximate pixel size, in Angstroms, `frame_sum` is binned to before edge + detection. Only used by ``"sobel"``. + edge_smoothing_sigma : float + Standard deviation, in Angstroms, of the Gaussian smoothing applied before edge + detection. Only used by ``"sobel"``. Returns ------- @@ -353,25 +663,39 @@ def estimate_beam_mask( Raises ------ ValueError - If `threshold_method` is not ``"otsu"``. + If `threshold_method` is not ``"sobel"`` or ``"otsu"``, or if no beam can be + found in `frame_sum`. """ - if threshold_method != "otsu": - raise ValueError( - f"Unsupported threshold_method '{threshold_method}'. Only 'otsu' is " - "currently supported." + n_edge_points: int | None = None + n_inliers: int | None = None + + if threshold_method == "sobel": + fit = fit_beam_edge( + frame_sum.detach().cpu().numpy(), + pixel_size, + working_pixel_size=working_pixel_size, + smoothing_sigma=edge_smoothing_sigma, ) + center_y, center_x = fit["center_y"], fit["center_x"] + axis1, axis2, angle_deg = fit["axis1"], fit["axis2"], fit["angle_deg"] + n_edge_points, n_inliers = fit["n_edge_points"], fit["n_inliers"] + elif threshold_method == "otsu": + filtered = low_pass_filter( + frame_sum, pixel_size, low_pass_resolution, device=device + ) + filtered_np = filtered.detach().cpu().numpy() - filtered = low_pass_filter( - frame_sum, pixel_size, low_pass_resolution, device=device - ) - filtered_np = filtered.detach().cpu().numpy() - - threshold = threshold_otsu(filtered_np) - binary = filtered_np > threshold + threshold = threshold_otsu(filtered_np) + binary = filtered_np > threshold - center_y, center_x, axis1, axis2, angle_deg = fit_ellipse(binary) + center_y, center_x, axis1, axis2, angle_deg = fit_ellipse(binary) + else: + raise ValueError( + f"Unsupported threshold_method '{threshold_method}'. Expected 'sobel' or " + "'otsu'." + ) - h, w = int(binary.shape[0]), int(binary.shape[1]) + h, w = int(frame_sum.shape[0]), int(frame_sum.shape[1]) ellipse_mask = make_ellipse_mask( shape=(h, w), @@ -400,4 +724,6 @@ def estimate_beam_mask( "crop_max_x": max_x, "threshold_method": threshold_method, "pixel_size": pixel_size, + "n_edge_points": n_edge_points, + "n_inliers": n_inliers, } diff --git a/src/ripple/managers/beam_mask_manager.py b/src/ripple/managers/beam_mask_manager.py index 65f4942..706d8b9 100644 --- a/src/ripple/managers/beam_mask_manager.py +++ b/src/ripple/managers/beam_mask_manager.py @@ -56,6 +56,8 @@ def estimate(self, movie: torch.Tensor | None = None) -> BeamMaskResult: self.beam_mask_config.diameter_reduction, self.beam_mask_config.low_pass_resolution, device=device, + working_pixel_size=self.beam_mask_config.working_pixel_size, + edge_smoothing_sigma=self.beam_mask_config.edge_smoothing_sigma, ) ellipse_mask = make_ellipse_mask( diff --git a/tests/test_beam_mask.py b/tests/test_beam_mask.py index 1c04413..62c92c6 100644 --- a/tests/test_beam_mask.py +++ b/tests/test_beam_mask.py @@ -7,7 +7,9 @@ import torch from ripple.core.beam_mask import ( + DEFAULT_N_RAYS, estimate_beam_mask, + fit_beam_edge, fit_ellipse, get_crop_bounds, low_pass_filter, @@ -19,6 +21,68 @@ HEIGHT = 128 WIDTH = 128 +# Synthetic DeCo-LACE beam, roughly a K3 super-resolution frame binned to ~8 A/px: +# ~4 % elliptical and clipped by the top and bottom of the detector. +BEAM_PIXEL_SIZE = 8.0 +BEAM_SHAPE = (536, 754) +BEAM = { + "center_y": 270.0, + "center_x": 380.0, + "axis1": 286.0, + "axis2": 275.0, + "angle_deg": 130.0, +} + + +def _synthetic_beam( + scale: int = 1, + dose: float = 50.0, + background: float = 0.5, + halo: float = 0.03, + gradient: float = 0.0, + dark_band: bool = False, + seed: int = 0, +) -> np.ndarray: + """Poisson frame sum of `BEAM` at ``BEAM_PIXEL_SIZE / scale`` A/px. + + `halo` is the amplitude (fraction of `dose`) of a faint exponential halo outside + the aperture edge. `gradient` tilts the in-beam intensity linearly across x by + +/- `gradient`. `dark_band` puts background-level sample across the right rim. + """ + h, w = BEAM_SHAPE[0] * scale, BEAM_SHAPE[1] * scale + cy = (BEAM["center_y"] + 0.5) * scale - 0.5 + cx = (BEAM["center_x"] + 0.5) * scale - 0.5 + a1, a2 = BEAM["axis1"] * scale, BEAM["axis2"] * scale + theta = np.radians(BEAM["angle_deg"]) + + yy, xx = np.indices((h, w), dtype=np.float64) + dx, dy = xx - cx, yy - cy + p1 = dx * np.cos(theta) + dy * np.sin(theta) + p2 = -dx * np.sin(theta) + dy * np.cos(theta) + rho = np.sqrt((p1 / a1) ** 2 + (p2 / a2) ** 2) + + beam = (rho <= 1.0) * (1.0 + gradient * dx / a1) + if dark_band: + beam[(dx > 0.6 * a1) & (np.abs(dy) < 0.15 * a1)] = 0.0 + decay = 65.0 * scale # ~1000 super-resolution pixels + halo_map = (rho > 1.0) * halo * np.exp(-(rho - 1.0) * np.sqrt(a1 * a2) / decay) + + rng = np.random.default_rng(seed) + expected = background + dose * (beam + halo_map) + return rng.poisson(expected).astype(np.float32) + + +def _assert_matches_beam(fit, scale: int = 1, rel_tol: float = 0.01) -> None: + """Fitted ellipse matches `BEAM` (axes and centre within `rel_tol` of R).""" + radius = np.sqrt(BEAM["axis1"] * BEAM["axis2"]) * scale + true_axes = sorted([BEAM["axis1"] * scale, BEAM["axis2"] * scale]) + fit_axes = sorted([fit["axis1"], fit["axis2"]]) + assert fit_axes == pytest.approx(true_axes, rel=rel_tol) + + cy = (BEAM["center_y"] + 0.5) * scale - 0.5 + cx = (BEAM["center_x"] + 0.5) * scale - 0.5 + assert np.hypot(fit["center_y"] - cy, fit["center_x"] - cx) < rel_tol * radius + def _iou(mask_a: np.ndarray, mask_b: np.ndarray) -> float: """Intersection-over-union of two boolean masks.""" @@ -231,6 +295,77 @@ def test_raises_when_diameter_reduction_collapses_axis(self, ellipse_params): make_ellipse_mask(**ellipse_params, diameter_reduction=1.0) +# --------------------------------------------------------------------------- +# fit_beam_edge +# --------------------------------------------------------------------------- + + +class TestFitBeamEdge: + """Tests for the Sobel edge-ray beam fit.""" + + def test_recovers_clipped_beam(self): + """Beam clipped top and bottom: clipped rays are dropped, centre unbiased.""" + frame_sum = _synthetic_beam() + + fit = fit_beam_edge(frame_sum, pixel_size=BEAM_PIXEL_SIZE) + + _assert_matches_beam(fit) + # Arcs clipped by the detector edge yield no edge points. + assert fit["n_edge_points"] < DEFAULT_N_RAYS + assert fit["n_inliers"] > 0.5 * DEFAULT_N_RAYS + + def test_strong_intensity_gradient_across_beam(self): + frame_sum = _synthetic_beam(gradient=0.8) + + fit = fit_beam_edge(frame_sum, pixel_size=BEAM_PIXEL_SIZE) + + _assert_matches_beam(fit) + + def test_dark_sample_across_rim(self): + """Background-level sample across the rim must not pull the fit inward.""" + frame_sum = _synthetic_beam(dark_band=True) + + fit = fit_beam_edge(frame_sum, pixel_size=BEAM_PIXEL_SIZE) + + _assert_matches_beam(fit) + + def test_bright_halo_does_not_bias_radius_outward(self): + frame_sum = _synthetic_beam(halo=0.15) + + fit = fit_beam_edge(frame_sum, pixel_size=BEAM_PIXEL_SIZE) + + _assert_matches_beam(fit) + + def test_low_dose_beam(self): + """100x lower dose: the ellipse is still recovered.""" + frame_sum = _synthetic_beam(dose=0.5, background=0.005) + + fit = fit_beam_edge(frame_sum, pixel_size=BEAM_PIXEL_SIZE) + + _assert_matches_beam(fit) + + def test_raises_on_pure_noise(self): + rng = np.random.default_rng(0) + frame_sum = rng.poisson(5.0, size=BEAM_SHAPE).astype(np.float32) + + with pytest.raises(ValueError, match="beam"): + fit_beam_edge(frame_sum, pixel_size=BEAM_PIXEL_SIZE) + + @pytest.mark.parametrize("working_pixel_size", [4.0, 8.0, 16.0]) + def test_result_in_input_pixels_for_any_binning(self, working_pixel_size): + """Fit on a 2 A/px frame sum is in input pixels whatever the binning.""" + scale = 4 + frame_sum = _synthetic_beam(scale=scale, dose=50.0 / scale**2) + + fit = fit_beam_edge( + frame_sum, + pixel_size=BEAM_PIXEL_SIZE / scale, + working_pixel_size=working_pixel_size, + ) + + _assert_matches_beam(fit, scale=scale) + + # --------------------------------------------------------------------------- # estimate_beam_mask # --------------------------------------------------------------------------- @@ -259,6 +394,61 @@ def test_full_pipeline_on_synthetic_frame_sum(self, ellipse_mask): assert 0 <= result["crop_min_y"] < result["crop_max_y"] < HEIGHT assert 0 <= result["crop_min_x"] < result["crop_max_x"] < WIDTH + def test_sobel_method_on_synthetic_frame_sum(self): + frame_sum = torch.from_numpy(_synthetic_beam()) + + result = estimate_beam_mask( + frame_sum, + pixel_size=BEAM_PIXEL_SIZE, + threshold_method="sobel", + diameter_reduction=0.0, + low_pass_resolution=100.0, + ) + + _assert_matches_beam(result) + assert result["threshold_method"] == "sobel" + assert (result["image_shape_y"], result["image_shape_x"]) == BEAM_SHAPE + assert result["n_inliers"] is not None + assert result["n_edge_points"] is not None + assert 0 < result["n_inliers"] <= result["n_edge_points"] <= DEFAULT_N_RAYS + # The beam is clipped top and bottom, but not left and right. + assert result["crop_min_y"] == 0 + assert result["crop_max_y"] == BEAM_SHAPE[0] - 1 + assert 0 < result["crop_min_x"] < result["crop_max_x"] < BEAM_SHAPE[1] - 1 + + def test_sobel_beats_otsu_with_dark_sample_at_rim(self): + """Dark sample at the rim notches Otsu's region; the Sobel fit stays round.""" + frame_sum = torch.from_numpy(_synthetic_beam(gradient=0.6, dark_band=True)) + + def axis_ratio(method: str) -> float: + result = estimate_beam_mask( + frame_sum, + pixel_size=BEAM_PIXEL_SIZE, + threshold_method=method, + diameter_reduction=0.0, + low_pass_resolution=100.0, + ) + axes = sorted([result["axis1"], result["axis2"]]) + return axes[0] / axes[1] + + true_ratio = BEAM["axis2"] / BEAM["axis1"] + assert axis_ratio("sobel") == pytest.approx(true_ratio, abs=0.01) + assert axis_ratio("otsu") < true_ratio - 0.02 + + def test_otsu_reports_no_edge_counts(self, ellipse_mask): + frame_sum = torch.from_numpy(ellipse_mask.astype(np.float32)) * 100.0 + + result = estimate_beam_mask( + frame_sum, + pixel_size=1.0, + threshold_method="otsu", + diameter_reduction=0.0, + low_pass_resolution=10.0, + ) + + assert result["n_edge_points"] is None + assert result["n_inliers"] is None + def test_raises_on_unsupported_threshold_method(self, ellipse_mask): frame_sum = torch.from_numpy(ellipse_mask.astype(np.float32)) * 100.0 diff --git a/tests/test_beam_mask_manager.py b/tests/test_beam_mask_manager.py index 7693f12..a499404 100644 --- a/tests/test_beam_mask_manager.py +++ b/tests/test_beam_mask_manager.py @@ -70,7 +70,9 @@ def computational_config() -> ComputationalConfig: @pytest.fixture def beam_mask_config() -> BeamMaskConfig: - return BeamMaskConfig(diameter_reduction=0.05, low_pass_resolution=10.0) + return BeamMaskConfig( + threshold_method="otsu", diameter_reduction=0.05, low_pass_resolution=10.0 + ) @pytest.fixture @@ -118,6 +120,7 @@ def test_output_crop_bounds_respect_divisible_by( # size for this ellipse -- crop_bounds_config.divisible_by must still # force both output crop side lengths to a multiple of 3. beam_mask_config = BeamMaskConfig( + threshold_method="otsu", diameter_reduction=0.05, low_pass_resolution=10.0, crop_bounds_config=CropBoundsConfig(mode="tight", divisible_by=3), @@ -136,6 +139,66 @@ def test_output_crop_bounds_respect_divisible_by( assert output_width % 3 == 0 +class TestEstimateSobel: + """BeamMaskManager.estimate with the default ``"sobel"`` method.""" + + @pytest.fixture + def beam_movie(self) -> tuple[np.ndarray, dict]: + """Poisson movie of a nearly round beam at 8 A/px, clipped at the top.""" + ellipse = { + "center_y": 100.0, + "center_x": 130.0, + "axis1": 104.0, + "axis2": 100.0, + "angle_deg": 40.0, + } + beam = make_ellipse_mask(shape=(192, 256), **ellipse).astype(np.float32) + rng = np.random.default_rng(0) + movie = rng.poisson(0.1 + 10.0 * beam, size=(N_FRAMES, *beam.shape)) + return movie.astype(np.float32), ellipse + + def test_default_config_uses_sobel(self, computational_config, beam_movie): + movie, ellipse = beam_movie + manager = BeamMaskManager( + computational_config=computational_config, + movie_config=MovieConfig( + pixel_size=8.0, fluence=0.0, fluence_per_frame=0.0 + ), + beam_mask_config=BeamMaskConfig(), + ) + + result = manager.estimate(movie=torch.from_numpy(movie)) + + assert result.threshold_method == "sobel" + assert result.n_inliers is not None + assert result.n_edge_points is not None + assert 0 < result.n_inliers <= result.n_edge_points + assert ( + np.hypot( + result.center_y - ellipse["center_y"], + result.center_x - ellipse["center_x"], + ) + < 2.0 + ) + assert sorted([result.axis1, result.axis2]) == pytest.approx( + sorted([ellipse["axis1"], ellipse["axis2"]]), rel=0.02 + ) + + def test_raises_when_no_beam(self, computational_config): + rng = np.random.default_rng(0) + movie = rng.poisson(1.0, size=(N_FRAMES, 192, 256)).astype(np.float32) + manager = BeamMaskManager( + computational_config=computational_config, + movie_config=MovieConfig( + pixel_size=8.0, fluence=0.0, fluence_per_frame=0.0 + ), + beam_mask_config=BeamMaskConfig(), + ) + + with pytest.raises(ValueError, match="beam"): + manager.estimate(movie=torch.from_numpy(movie)) + + class TestSharedTensorComposition: """Verify a single loaded movie tensor composes with AlignFramesManager.""" @@ -173,6 +236,7 @@ def test_output_crop_bounds_feed_align_frames_manager_crop( ): """The beam mask's output_crop_* bounds physically crop the movie.""" beam_mask_config = BeamMaskConfig( + threshold_method="otsu", diameter_reduction=0.05, low_pass_resolution=10.0, crop_bounds_config=CropBoundsConfig(mode="tight"), diff --git a/tests/test_movie_config.py b/tests/test_movie_config.py new file mode 100644 index 0000000..e8fa095 --- /dev/null +++ b/tests/test_movie_config.py @@ -0,0 +1,92 @@ +"""Tests for MovieConfig's crop-bounds auto-computation.""" + +# pylint: disable=redefined-outer-name + +import pytest +import torch + +from ripple.config import CropBoundsConfig, MovieConfig + +HEIGHT = 32 +WIDTH = 32 + + +@pytest.fixture +def sample_movie() -> torch.Tensor: + return torch.randn(5, HEIGHT, WIDTH, dtype=torch.float32) + + +@pytest.fixture +def sample_mask() -> torch.Tensor: + mask = torch.zeros(HEIGHT, WIDTH, dtype=torch.float32) + mask[8:20, 10:26] = 1.0 # tight bbox: (8, 19, 10, 25) -> shape (12, 16) + return mask + + +def _movie_config(**crop_kwargs) -> MovieConfig: + return MovieConfig( + pixel_size=1.0, + fluence=40.0, + fluence_per_frame=10.0, + crop_bounds_config=CropBoundsConfig(**crop_kwargs), + ) + + +class TestComputeCropBounds: + """Tests for MovieConfig.compute_crop_bounds.""" + + def test_mode_none_returns_none(self, sample_mask): + config = _movie_config() # default mode="none" + assert config.compute_crop_bounds(sample_mask) is None + + def test_mode_tight_matches_expected_bbox(self, sample_mask): + config = _movie_config(mode="tight") + bounds = config.compute_crop_bounds(sample_mask) + assert bounds == {"min_y": 8, "max_y": 19, "min_x": 10, "max_x": 25} + + def test_mode_nice_size_rounds_up(self, sample_mask): + config = _movie_config(mode="nice_size", round_to=8) + bounds = config.compute_crop_bounds(sample_mask) + height = bounds["max_y"] - bounds["min_y"] + 1 + width = bounds["max_x"] - bounds["min_x"] + 1 + assert height % 8 == 0 + assert width % 8 == 0 + + def test_works_regardless_of_mask_source(self, sample_mask): + # Path-based (loaded from disk) and in-memory masks are both plain + # torch.Tensor by the time they reach compute_crop_bounds, so an + # in-memory mask must produce identical bounds to an equivalent + # path-loaded one -- there is nothing that distinguishes the two here. + config = _movie_config(mode="tight") + in_memory_bounds = config.compute_crop_bounds(sample_mask) + loaded_equivalent_bounds = config.compute_crop_bounds(sample_mask.clone()) + assert in_memory_bounds == loaded_equivalent_bounds + + +class TestPrepareAutoCrop: + """Tests for MovieConfig.prepare's automatic crop_bounds computation.""" + + def test_no_mask_no_crop(self, sample_movie): + config = _movie_config(mode="tight") + result = config.prepare(sample_movie) + assert result.shape == sample_movie.shape + + def test_mask_with_mode_none_no_crop(self, sample_movie, sample_mask): + config = _movie_config() # default mode="none" + result = config.prepare(sample_movie, mask=sample_mask) + assert result.shape == sample_movie.shape + + def test_mask_with_mode_tight_auto_crops(self, sample_movie, sample_mask): + config = _movie_config(mode="tight") + result = config.prepare(sample_movie, mask=sample_mask) + assert result.shape == (sample_movie.shape[0], 12, 16) + + def test_explicit_crop_bounds_overrides_auto_compute( + self, sample_movie, sample_mask + ): + config = _movie_config(mode="tight") + explicit_bounds = {"min_y": 0, "max_y": 15, "min_x": 0, "max_x": 15} + result = config.prepare( + sample_movie, mask=sample_mask, crop_bounds=explicit_bounds + ) + assert result.shape == (sample_movie.shape[0], 16, 16) From e0def6e966d838affe526a47c84905c6a550fcef Mon Sep 17 00:00:00 2001 From: mgiammar Date: Tue, 20 Oct 2026 13:29:56 -0700 Subject: [PATCH 2/3] fix: low-pass filter cutoff correction --- src/ripple/core/beam_mask.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ripple/core/beam_mask.py b/src/ripple/core/beam_mask.py index a7aeda3..07d495e 100644 --- a/src/ripple/core/beam_mask.py +++ b/src/ripple/core/beam_mask.py @@ -121,7 +121,8 @@ def low_pass_filter( torch.Tensor Filtered image as a float32 2-D tensor, on `device`. """ - cutoff = 0.5 * pixel_size / low_pass_resolution + # bandpass_filter takes cycles/pixel (Nyquist = 0.5). + cutoff = pixel_size / low_pass_resolution if device is not None: image = image.to(device=device, dtype=torch.float32) dft = torch.fft.rfft2(image) From 813ebbc43ea0d937a36e8efa6d1cd71392f7459e Mon Sep 17 00:00:00 2001 From: mgiammar Date: Wed, 28 Oct 2026 07:47:21 -0700 Subject: [PATCH 3/3] refactor!: remove Otsu beam mask estimation, Sobel only The Sobel edge-ray fit replaces Otsu thresholding entirely. - Remove low_pass_filter, threshold_otsu and fit_ellipse from ripple.core.beam_mask, and the scikit-image dependency they needed. - BeamMaskConfig.threshold_method now only accepts "sobel"; the Otsu-only low_pass_resolution field is removed. - estimate_beam_mask drops its low_pass_resolution and device arguments. - BeamMaskResult.n_edge_points and n_inliers are now required ints. - BeamMaskResult.threshold_method is kept so downstream stores still get a value. - Manager tests use a Sobel-sized synthetic movie (8 A/px) instead of the 64x64 Otsu-only fixture. BREAKING CHANGE: configs that set threshold_method="otsu" or low_pass_resolution no longer validate. Co-Authored-By: Claude Opus 5.5 --- .../decolace_pipeline_example_config.yaml | 7 +- pyproject.toml | 1 - src/ripple/config/beam_mask_config.py | 35 +-- src/ripple/core/beam_mask.py | 263 +++--------------- src/ripple/managers/beam_mask_manager.py | 2 - tests/test_beam_mask.py | 196 ++----------- tests/test_beam_mask_manager.py | 102 ++----- uv.lock | 162 +---------- 8 files changed, 117 insertions(+), 651 deletions(-) diff --git a/programs/decolace_pipeline/decolace_pipeline_example_config.yaml b/programs/decolace_pipeline/decolace_pipeline_example_config.yaml index 8c9a75c..a7429be 100644 --- a/programs/decolace_pipeline/decolace_pipeline_example_config.yaml +++ b/programs/decolace_pipeline/decolace_pipeline_example_config.yaml @@ -27,11 +27,10 @@ movie_config: # --- Beam mask estimation parameters --- beam_mask_config: - threshold_method: "sobel" # (optional, default "sobel") Ellipse fit method: "sobel" (outermost Sobel edge along rays from the beam centre; robust to dark sample at the rim) or "otsu" (older Otsu threshold + connected component). + threshold_method: "sobel" # (optional, default "sobel") Ellipse fit method; only "sobel" (outermost Sobel edge along rays from the beam center; robust to dark sample at the rim) is currently supported. diameter_reduction: 0.0 # (optional, default 0.0) Fractional shrink applied to both ellipse radii; 0.1 shrinks each axis by 10% to exclude fringe artifacts at the beam edge. - working_pixel_size: 8.0 # (optional, default 8.0) "sobel" only. Approximate pixel size (Angstroms) the frame sum is binned to before edge detection. - edge_smoothing_sigma: 120.0 # (optional, default 120.0) "sobel" only. Gaussian smoothing sigma (Angstroms) applied before edge detection. - low_pass_resolution: 100.0 # (optional, default 100.0) "otsu" only. Low-pass filter cutoff (Angstroms) applied to the frame sum before thresholding. + working_pixel_size: 8.0 # (optional, default 8.0) Approximate pixel size (Angstroms) the frame sum is binned to before edge detection. + edge_smoothing_sigma: 120.0 # (optional, default 120.0) Gaussian smoothing sigma (Angstroms) applied before edge detection. # --- Output file configuration --- output_config: diff --git a/pyproject.toml b/pyproject.toml index 295a544..b91c71c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,7 +58,6 @@ dependencies = [ "h5py", "dm4", "opencv-python-headless", - "scikit-image", ] # https://peps.python.org/pep-0621/#dependencies-optional-dependencies diff --git a/src/ripple/config/beam_mask_config.py b/src/ripple/config/beam_mask_config.py index bdb57ba..be0e4f3 100644 --- a/src/ripple/config/beam_mask_config.py +++ b/src/ripple/config/beam_mask_config.py @@ -16,34 +16,28 @@ class BeamMaskConfig(BaseModelTeamTomo): Parameters ---------- - threshold_method: Literal["sobel", "otsu"] + threshold_method: Literal["sobel"] Method used to fit the beam ellipse to the frame sum. ``"sobel"`` fits the ellipse to the outermost Sobel edge along rays cast from the beam center, and is - robust to dark sample lying across the beam rim. ``"otsu"``thresholds the - low-pass-filtered frame sum with Otsu's method and fits the largest connected - component. + robust to dark sample lying across the beam rim. Currently only ``"sobel"`` is + supported. Default is ``"sobel"``. diameter_reduction: float Fractional amount by which both ellipse radii are shrunk before creating the final mask. ``0.0`` applies no reduction; ``0.1`` shrinks each axis by 10% to exclude fringe artifacts at the beam edge. Default is 0.0. - low_pass_resolution: float - Low-pass filter cutoff, in Angstroms, applied to the frame sum before - thresholding. Only used by ``"otsu"``. Default is 100.0. working_pixel_size: float Approximate pixel size, in Angstroms, the frame sum is binned to before edge - detection. Only used by ``"sobel"``. Default is 8.0. + detection. Default is 8.0. edge_smoothing_sigma: float Standard deviation, in Angstroms, of the Gaussian smoothing applied to the - binned frame sum before edge detection. Only used by ``"sobel"``. Default is - 120.0. + binned frame sum before edge detection. Default is 120.0. crop_bounds_config: CropBoundsConfig Size policy for cropping movie frames down to the region covered by the beam mask. Default disables cropping (``mode="none"``). """ - threshold_method: Literal["sobel", "otsu"] = "sobel" + threshold_method: Literal["sobel"] = "sobel" diameter_reduction: float = 0.0 - low_pass_resolution: float = 100.0 working_pixel_size: PositiveFloat = 8.0 edge_smoothing_sigma: PositiveFloat = 120.0 crop_bounds_config: CropBoundsConfig = CropBoundsConfig() @@ -93,16 +87,15 @@ class BeamMaskResult(BaseModelTeamTomo): output_crop_max_x: int Right column of the output crop region, inclusive. threshold_method: str - Method used to fit the beam ellipse (``"sobel"`` or ``"otsu"``). + Method used to fit the beam ellipse (``"sobel"``). pixel_size: float Pixel size in Angstroms per pixel. - n_edge_points: int | None - Number of beam-edge points found by the ``"sobel"`` method (at most one per ray - cast). None for ``"otsu"``. - n_inliers: int | None - Number of edge points kept as beam-rim inliers by the ``"sobel"`` method. + n_edge_points: int + Number of beam-edge points found (at most one per ray cast). + n_inliers: int + Number of edge points kept as beam-rim inliers. ``n_inliers / ripple.core.beam_mask.DEFAULT_N_RAYS`` is the fraction of the - rim that was found. None for ``"otsu"``. + rim that was found. """ center_y: float @@ -123,8 +116,8 @@ class BeamMaskResult(BaseModelTeamTomo): output_crop_max_x: int threshold_method: str pixel_size: float - n_edge_points: int | None = None - n_inliers: int | None = None + n_edge_points: int + n_inliers: int @property def crop_bounds(self) -> CropBounds: diff --git a/src/ripple/core/beam_mask.py b/src/ripple/core/beam_mask.py index 07d495e..77927d0 100644 --- a/src/ripple/core/beam_mask.py +++ b/src/ripple/core/beam_mask.py @@ -8,8 +8,6 @@ import numpy as np import torch from scipy.ndimage import gaussian_filter, map_coordinates -from skimage.measure import label, regionprops -from torch_fourier_filter.bandpass import bandpass_filter from ripple.core.crop_bounds import get_crop_bounds from ripple.core.prepare_movie import DEFAULT_PREP_CHUNK_SIZE @@ -37,8 +35,8 @@ class BeamMaskParams(TypedDict): crop_max_x: int threshold_method: str pixel_size: float - n_edge_points: int | None # NOTE: Only non-None for "sobel" method - n_inliers: int | None # NOTE: Only non-None for "sobel" method + n_edge_points: int + n_inliers: int class BeamEdgeFit(TypedDict): @@ -93,165 +91,10 @@ def sum_movie_chunked( # --------------------------------------------------------------------------- -# Algorithm steps +# Ellipse mask # --------------------------------------------------------------------------- -def low_pass_filter( - image: torch.Tensor, - pixel_size: float, - low_pass_resolution: float, - device: torch.device | str | None = None, -) -> torch.Tensor: - """Apply a Fourier low-pass filter to a 2-D image. - - Parameters - ---------- - image : torch.Tensor - 2-D float tensor to filter. - pixel_size : float - Pixel size in Angstroms per pixel. - low_pass_resolution : float - Low-pass cutoff resolution in Angstroms (larger = more blurring). - device : torch.device | str | None - Device the filtering runs on. If None, uses `image`'s current device. - - Returns - ------- - torch.Tensor - Filtered image as a float32 2-D tensor, on `device`. - """ - # bandpass_filter takes cycles/pixel (Nyquist = 0.5). - cutoff = pixel_size / low_pass_resolution - if device is not None: - image = image.to(device=device, dtype=torch.float32) - dft = torch.fft.rfft2(image) - bp = bandpass_filter( - low=0.0, - high=float(cutoff), - falloff=0.01, - image_shape=(image.shape[0], image.shape[1]), - rfft=True, - fftshift=False, - ).to(device=dft.device) - filtered: torch.Tensor = torch.fft.irfft2(bp * dft) - - return filtered - - -def threshold_otsu(image: np.ndarray) -> float: - """Compute a threshold using Otsu's method (minimizes intra-class variance). - - Parameters - ---------- - image : np.ndarray - 2-D or N-D float array. - - Returns - ------- - float - Optimal threshold value. - """ - hist, bin_edges = np.histogram(image.flatten(), bins=256, density=True) - bin_centers = (bin_edges[:-1] + bin_edges[1:]) / 2.0 - hist = hist / float(hist.sum()) - - best_threshold = float(bin_centers[0]) - min_variance = float("inf") - - for i in range(1, len(bin_centers) - 1): - w0 = float(hist[:i].sum()) - w1 = float(hist[i:].sum()) - if w0 == 0.0 or w1 == 0.0: - continue - mu0 = float((hist[:i] * bin_centers[:i]).sum()) / w0 - mu1 = float((hist[i:] * bin_centers[i:]).sum()) / w1 - var0 = float((hist[:i] * (bin_centers[:i] - mu0) ** 2).sum()) / w0 - var1 = float((hist[i:] * (bin_centers[i:] - mu1) ** 2).sum()) / w1 - variance = w0 * var0 + w1 * var1 - if variance < min_variance: - min_variance = variance - best_threshold = float(bin_centers[i]) - - return best_threshold - - -def fit_ellipse(binary: np.ndarray) -> tuple[float, float, float, float, float]: - """Fit an ellipse to the largest connected component of a binary image. - - Falls back to scikit-image region-moment fitting when fewer than 5 interior - contour points remain (beam too severely clipped to recover arc geometry). - - Parameters - ---------- - binary : np.ndarray - Boolean 2-D array. - - Returns - ------- - tuple[float, float, float, float, float] - ``(center_y, center_x, axis1, axis2, angle_deg)`` where ``axis1`` is - the semi-axis radius along ``angle_deg`` (cv2 ``axes[0]/2``), ``axis2`` - is the semi-axis perpendicular to it (cv2 ``axes[1]/2``), and - ``angle_deg`` is the angle in degrees (clockwise from horizontal on - screen). - - Raises - ------ - ValueError - If the binary image has no foreground pixels. - """ - labeled: np.ndarray = label(binary) - props = regionprops(labeled) - if not props: - raise ValueError("No connected components found in the binary image.") - - largest = max(props, key=lambda r: r.area) - - H, W = binary.shape - region_mask = (labeled == largest.label).astype(np.uint8) * 255 - - # Full-pixel contour (CHAIN_APPROX_NONE) gives a dense arc for a better fit. - contours, _ = cv2.findContours( - region_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE - ) - cnt = max(contours, key=len) - # cv2 contour points: (N, 1, 2) with last dim = (col, row) = (x, y) - pts = cnt.reshape(-1, 2).astype(np.float64) # shape (N, 2): (col, row) - - # Drop points on and near the image boundary -- they trace the straight clipped - # edge, not the real ellipse arc, and would pull the fit toward the border. - border_margin = 16 # pixels - x_col, y_row = pts[:, 0], pts[:, 1] - interior = ( - (x_col > border_margin) - & (x_col < W - border_margin) - & (y_row > border_margin) - & (y_row < H - border_margin) - ) - interior_pts = pts[interior] - - # Fallback: region moments from scikit-image (only for extreme clipping). - if len(interior_pts) < 5: - cy, cx = largest.centroid - # Convert regionprops orientation (CCW from row axis, radians) to cv2 angle - # convention (CW from horizontal on screen, degrees): angle = 90 - degrees(ori). - fallback_angle_deg = float(np.degrees(np.pi / 2.0 - largest.orientation)) - return ( - float(cy), - float(cx), - float(largest.axis_major_length) / 2.0, - float(largest.axis_minor_length) / 2.0, - fallback_angle_deg, - ) - - (cx, cy), (ax1, ax2), angle_deg = cv2.fitEllipse( - interior_pts.astype(np.float32).reshape(-1, 1, 2) - ) - - return float(cy), float(cx), float(ax1) / 2.0, float(ax2) / 2.0, float(angle_deg) - - def make_ellipse_mask( shape: tuple[int, int], center_y: float, @@ -504,8 +347,9 @@ def robust_ellipse_fit( Returns ------- tuple[Ellipse, np.ndarray] - ``(center_y, center_x, axis1, axis2, angle_deg)`` in the :func:`fit_ellipse` - convention, and the boolean inlier mask over `points_xy`. + ``(center_y, center_x, axis1, axis2, angle_deg)`` in the + :func:`make_ellipse_mask` convention, and the boolean inlier mask over + `points_xy`. Raises ------ @@ -574,8 +418,9 @@ def fit_beam_edge( Returns ------- BeamEdgeFit - Fitted ellipse in `frame_sum` pixels (:func:`fit_ellipse` convention), plus - the number of edge points found and the number kept as rim inliers. + Fitted ellipse in `frame_sum` pixels (:func:`make_ellipse_mask` + convention), plus the number of edge points found and the number kept as rim + inliers. Raises ------ @@ -616,45 +461,33 @@ def estimate_beam_mask( pixel_size: float, threshold_method: str, diameter_reduction: float, - low_pass_resolution: float, - device: torch.device | str | None = None, working_pixel_size: float = 8.0, edge_smoothing_sigma: float = 120.0, ) -> BeamMaskParams: """Estimate a DeCo-LACE beam mask ellipse from a raw frame sum. - Fits an ellipse to the beam with one of two methods, then computes the tight crop - bounds for the (possibly shrunk) ellipse mask: - - - ``"sobel"``: bins and Gaussian-smooths `frame_sum`, then fits the ellipse to the - outermost Sobel edge along rays cast from the beam center (see - :func:`fit_beam_edge`). Robust to dark sample lying across the beam rim. - - ``"otsu"``: low-pass filters `frame_sum` (on `device`), thresholds it with - Otsu's method and fits an ellipse to the largest connected component. + Bins and Gaussian-smooths `frame_sum`, fits the ellipse to the outermost Sobel + edge along rays cast from the beam center (see :func:`fit_beam_edge`), and + computes the tight crop bounds for the (possibly shrunk) ellipse mask. Parameters ---------- frame_sum : torch.Tensor 2-D tensor, sum of all raw movie frames (e.g. from :func:`sum_movie_chunked`). + The fit runs on the CPU. pixel_size : float Pixel size in Angstroms per pixel. threshold_method : str - Ellipse estimation method, ``"sobel"`` or ``"otsu"``. + Ellipse estimation method; currently only ``"sobel"`` is supported. diameter_reduction : float Fractional shrink applied to both ellipse radii before computing crop bounds and the returned mask parameters. - low_pass_resolution : float - Low-pass filter cutoff resolution in Angstroms, applied to `frame_sum` - before thresholding. Only used by ``"otsu"``. - device : torch.device | str | None - Device the low-pass filter runs on. If None, uses `frame_sum`'s current - device. Only used by ``"otsu"``; ``"sobel"`` runs on the CPU. working_pixel_size : float Approximate pixel size, in Angstroms, `frame_sum` is binned to before edge - detection. Only used by ``"sobel"``. + detection. edge_smoothing_sigma : float Standard deviation, in Angstroms, of the Gaussian smoothing applied before edge - detection. Only used by ``"sobel"``. + detection. Returns ------- @@ -664,58 +497,42 @@ def estimate_beam_mask( Raises ------ ValueError - If `threshold_method` is not ``"sobel"`` or ``"otsu"``, or if no beam can be - found in `frame_sum`. + If `threshold_method` is not ``"sobel"``, or if no beam can be found in + `frame_sum`. """ - n_edge_points: int | None = None - n_inliers: int | None = None - - if threshold_method == "sobel": - fit = fit_beam_edge( - frame_sum.detach().cpu().numpy(), - pixel_size, - working_pixel_size=working_pixel_size, - smoothing_sigma=edge_smoothing_sigma, - ) - center_y, center_x = fit["center_y"], fit["center_x"] - axis1, axis2, angle_deg = fit["axis1"], fit["axis2"], fit["angle_deg"] - n_edge_points, n_inliers = fit["n_edge_points"], fit["n_inliers"] - elif threshold_method == "otsu": - filtered = low_pass_filter( - frame_sum, pixel_size, low_pass_resolution, device=device - ) - filtered_np = filtered.detach().cpu().numpy() - - threshold = threshold_otsu(filtered_np) - binary = filtered_np > threshold - - center_y, center_x, axis1, axis2, angle_deg = fit_ellipse(binary) - else: + if threshold_method != "sobel": raise ValueError( - f"Unsupported threshold_method '{threshold_method}'. Expected 'sobel' or " - "'otsu'." + f"Unsupported threshold_method '{threshold_method}'. Only 'sobel' is " + "currently supported." ) + fit = fit_beam_edge( + frame_sum.detach().cpu().numpy(), + pixel_size, + working_pixel_size=working_pixel_size, + smoothing_sigma=edge_smoothing_sigma, + ) + h, w = int(frame_sum.shape[0]), int(frame_sum.shape[1]) ellipse_mask = make_ellipse_mask( shape=(h, w), - center_y=center_y, - center_x=center_x, - axis1=axis1, - axis2=axis2, - angle_deg=angle_deg, + center_y=fit["center_y"], + center_x=fit["center_x"], + axis1=fit["axis1"], + axis2=fit["axis2"], + angle_deg=fit["angle_deg"], diameter_reduction=diameter_reduction, ) min_y, max_y, min_x, max_x = get_crop_bounds(ellipse_mask) return { - "center_y": center_y, - "center_x": center_x, - "axis1": axis1, - "axis2": axis2, - "angle_deg": angle_deg, + "center_y": fit["center_y"], + "center_x": fit["center_x"], + "axis1": fit["axis1"], + "axis2": fit["axis2"], + "angle_deg": fit["angle_deg"], "diameter_reduction": diameter_reduction, "image_shape_y": h, "image_shape_x": w, @@ -725,6 +542,6 @@ def estimate_beam_mask( "crop_max_x": max_x, "threshold_method": threshold_method, "pixel_size": pixel_size, - "n_edge_points": n_edge_points, - "n_inliers": n_inliers, + "n_edge_points": fit["n_edge_points"], + "n_inliers": fit["n_inliers"], } diff --git a/src/ripple/managers/beam_mask_manager.py b/src/ripple/managers/beam_mask_manager.py index 706d8b9..52979f7 100644 --- a/src/ripple/managers/beam_mask_manager.py +++ b/src/ripple/managers/beam_mask_manager.py @@ -54,8 +54,6 @@ def estimate(self, movie: torch.Tensor | None = None) -> BeamMaskResult: self.movie_config.pixel_size, self.beam_mask_config.threshold_method, self.beam_mask_config.diameter_reduction, - self.beam_mask_config.low_pass_resolution, - device=device, working_pixel_size=self.beam_mask_config.working_pixel_size, edge_smoothing_sigma=self.beam_mask_config.edge_smoothing_sigma, ) diff --git a/tests/test_beam_mask.py b/tests/test_beam_mask.py index 62c92c6..db55505 100644 --- a/tests/test_beam_mask.py +++ b/tests/test_beam_mask.py @@ -10,12 +10,9 @@ DEFAULT_N_RAYS, estimate_beam_mask, fit_beam_edge, - fit_ellipse, get_crop_bounds, - low_pass_filter, make_ellipse_mask, sum_movie_chunked, - threshold_otsu, ) HEIGHT = 128 @@ -84,13 +81,6 @@ def _assert_matches_beam(fit, scale: int = 1, rel_tol: float = 0.01) -> None: assert np.hypot(fit["center_y"] - cy, fit["center_x"] - cx) < rel_tol * radius -def _iou(mask_a: np.ndarray, mask_b: np.ndarray) -> float: - """Intersection-over-union of two boolean masks.""" - intersection = np.logical_and(mask_a, mask_b).sum() - union = np.logical_or(mask_a, mask_b).sum() - return float(intersection) / float(union) - - @pytest.fixture def ellipse_params() -> dict: return { @@ -142,113 +132,6 @@ def test_result_on_requested_device(self): assert result.shape == (8, 8) -# --------------------------------------------------------------------------- -# low_pass_filter -# --------------------------------------------------------------------------- - - -class TestLowPassFilter: - """Tests for low_pass_filter.""" - - def test_preserves_uniform_image(self): - """A constant (DC-only) image should pass through with its mean unchanged.""" - image = torch.full((HEIGHT, WIDTH), 5.0) - - result = low_pass_filter(image, pixel_size=1.0, low_pass_resolution=20.0) - - assert torch.allclose(result.mean(), image.mean(), atol=1e-3) - - def test_attenuates_high_frequency_noise(self): - """Filtering should reduce the variance of pure high-frequency noise.""" - torch.manual_seed(0) - noise = torch.randn(HEIGHT, WIDTH) - - result = low_pass_filter(noise, pixel_size=1.0, low_pass_resolution=20.0) - - assert result.std() < noise.std() - - def test_moves_to_requested_device(self): - image = torch.randn(HEIGHT, WIDTH) - result = low_pass_filter( - image, pixel_size=1.0, low_pass_resolution=20.0, device=torch.device("cpu") - ) - assert result.device == torch.device("cpu") - - -# --------------------------------------------------------------------------- -# threshold_otsu -# --------------------------------------------------------------------------- - - -class TestThresholdOtsu: - """Tests for threshold_otsu.""" - - def test_separates_bimodal_clusters(self): - rng = np.random.default_rng(0) - low_cluster = rng.normal(loc=0.0, scale=0.5, size=5000) - high_cluster = rng.normal(loc=10.0, scale=0.5, size=5000) - image = np.concatenate([low_cluster, high_cluster]) - - threshold = threshold_otsu(image) - - # The two clusters are well-separated, so any threshold strictly between - # them correctly assigns every sample to its true cluster (Otsu may land - # anywhere within the near-zero-density gap between the clusters). - assert low_cluster.max() < threshold < high_cluster.min() - - -# --------------------------------------------------------------------------- -# fit_ellipse -# --------------------------------------------------------------------------- - - -class TestFitEllipse: - """Tests for fit_ellipse.""" - - def test_recovers_synthetic_ellipse(self, ellipse_params, ellipse_mask): - """Fitted ellipse should closely reconstruct the original mask. - - cv2.fitEllipse can report an equivalent ellipse with axis1/axis2 swapped - and angle offset by 90 degrees, so compare via mask IoU rather than raw - parameters directly. - """ - center_y, center_x, axis1, axis2, angle_deg = fit_ellipse(ellipse_mask) - - refit_mask = make_ellipse_mask( - shape=ellipse_params["shape"], - center_y=center_y, - center_x=center_x, - axis1=axis1, - axis2=axis2, - angle_deg=angle_deg, - ) - - assert _iou(ellipse_mask, refit_mask) > 0.95 - - def test_raises_on_empty_mask(self): - empty = np.zeros((HEIGHT, WIDTH), dtype=bool) - with pytest.raises(ValueError, match="No connected components"): - fit_ellipse(empty) - - def test_falls_back_for_severely_clipped_ellipse(self): - """A beam mostly cut off by the frame edge should still fit via the fallback.""" - mask = make_ellipse_mask( - shape=(HEIGHT, WIDTH), - center_y=0.0, - center_x=0.0, - axis1=60.0, - axis2=60.0, - angle_deg=0.0, - ) - - center_y, center_x, axis1, axis2, _ = fit_ellipse(mask) - - assert axis1 > 0.0 - assert axis2 > 0.0 - assert center_y < HEIGHT / 2 - assert center_x < WIDTH / 2 - - # --------------------------------------------------------------------------- # get_crop_bounds # --------------------------------------------------------------------------- @@ -374,27 +257,7 @@ def test_result_in_input_pixels_for_any_binning(self, working_pixel_size): class TestEstimateBeamMask: """Tests for the estimate_beam_mask orchestration function.""" - def test_full_pipeline_on_synthetic_frame_sum(self, ellipse_mask): - frame_sum = torch.from_numpy(ellipse_mask.astype(np.float32)) * 100.0 - - result = estimate_beam_mask( - frame_sum, - pixel_size=1.0, - threshold_method="otsu", - diameter_reduction=0.1, - low_pass_resolution=10.0, - device=torch.device("cpu"), - ) - - assert result["image_shape_y"] == HEIGHT - assert result["image_shape_x"] == WIDTH - assert result["threshold_method"] == "otsu" - assert result["pixel_size"] == 1.0 - assert result["diameter_reduction"] == 0.1 - assert 0 <= result["crop_min_y"] < result["crop_max_y"] < HEIGHT - assert 0 <= result["crop_min_x"] < result["crop_max_x"] < WIDTH - - def test_sobel_method_on_synthetic_frame_sum(self): + def test_full_pipeline_on_synthetic_frame_sum(self): frame_sum = torch.from_numpy(_synthetic_beam()) result = estimate_beam_mask( @@ -402,61 +265,48 @@ def test_sobel_method_on_synthetic_frame_sum(self): pixel_size=BEAM_PIXEL_SIZE, threshold_method="sobel", diameter_reduction=0.0, - low_pass_resolution=100.0, ) _assert_matches_beam(result) assert result["threshold_method"] == "sobel" + assert result["pixel_size"] == BEAM_PIXEL_SIZE assert (result["image_shape_y"], result["image_shape_x"]) == BEAM_SHAPE - assert result["n_inliers"] is not None - assert result["n_edge_points"] is not None assert 0 < result["n_inliers"] <= result["n_edge_points"] <= DEFAULT_N_RAYS # The beam is clipped top and bottom, but not left and right. assert result["crop_min_y"] == 0 assert result["crop_max_y"] == BEAM_SHAPE[0] - 1 assert 0 < result["crop_min_x"] < result["crop_max_x"] < BEAM_SHAPE[1] - 1 - def test_sobel_beats_otsu_with_dark_sample_at_rim(self): - """Dark sample at the rim notches Otsu's region; the Sobel fit stays round.""" - frame_sum = torch.from_numpy(_synthetic_beam(gradient=0.6, dark_band=True)) + def test_diameter_reduction_shrinks_crop_bounds(self): + frame_sum = torch.from_numpy(_synthetic_beam()) - def axis_ratio(method: str) -> float: - result = estimate_beam_mask( + full, shrunk = ( + estimate_beam_mask( frame_sum, pixel_size=BEAM_PIXEL_SIZE, - threshold_method=method, - diameter_reduction=0.0, - low_pass_resolution=100.0, + threshold_method="sobel", + diameter_reduction=reduction, ) - axes = sorted([result["axis1"], result["axis2"]]) - return axes[0] / axes[1] - - true_ratio = BEAM["axis2"] / BEAM["axis1"] - assert axis_ratio("sobel") == pytest.approx(true_ratio, abs=0.01) - assert axis_ratio("otsu") < true_ratio - 0.02 - - def test_otsu_reports_no_edge_counts(self, ellipse_mask): - frame_sum = torch.from_numpy(ellipse_mask.astype(np.float32)) * 100.0 - - result = estimate_beam_mask( - frame_sum, - pixel_size=1.0, - threshold_method="otsu", - diameter_reduction=0.0, - low_pass_resolution=10.0, + for reduction in (0.0, 0.1) ) - assert result["n_edge_points"] is None - assert result["n_inliers"] is None - - def test_raises_on_unsupported_threshold_method(self, ellipse_mask): - frame_sum = torch.from_numpy(ellipse_mask.astype(np.float32)) * 100.0 + assert shrunk["diameter_reduction"] == 0.1 + # Same fitted ellipse; only the mask and crop bounds shrink. + assert shrunk["axis1"] == full["axis1"] + assert shrunk["axis2"] == full["axis2"] + # A 10 % shrink pulls the ellipse inside the top and bottom of the frame. + assert full["crop_min_y"] < shrunk["crop_min_y"] + assert shrunk["crop_max_y"] < full["crop_max_y"] + assert full["crop_min_x"] < shrunk["crop_min_x"] + assert shrunk["crop_max_x"] < full["crop_max_x"] + + def test_raises_on_unsupported_threshold_method(self): + frame_sum = torch.from_numpy(_synthetic_beam()) with pytest.raises(ValueError, match="Unsupported threshold_method"): estimate_beam_mask( frame_sum, - pixel_size=1.0, - threshold_method="valley", + pixel_size=BEAM_PIXEL_SIZE, + threshold_method="otsu", diameter_reduction=0.0, - low_pass_resolution=10.0, ) diff --git a/tests/test_beam_mask_manager.py b/tests/test_beam_mask_manager.py index a499404..bf1c056 100644 --- a/tests/test_beam_mask_manager.py +++ b/tests/test_beam_mask_manager.py @@ -19,8 +19,18 @@ from ripple.managers import AlignFramesManager, BeamMaskManager N_FRAMES = 4 -HEIGHT = 64 -WIDTH = 64 +HEIGHT = 192 +WIDTH = 256 +PIXEL_SIZE = 8.0 + +# Nearly round beam, clipped by the top of the frame. +ELLIPSE = { + "center_y": 100.0, + "center_x": 130.0, + "axis1": 104.0, + "axis2": 100.0, + "angle_deg": 40.0, +} def _write_mrc(path, array: np.ndarray) -> str: @@ -31,33 +41,26 @@ def _write_mrc(path, array: np.ndarray) -> str: @pytest.fixture def ellipse_mask() -> np.ndarray: - return make_ellipse_mask( - shape=(HEIGHT, WIDTH), - center_y=34.0, - center_x=28.0, - axis1=20.0, - axis2=15.0, - angle_deg=10.0, - ) + return make_ellipse_mask(shape=(HEIGHT, WIDTH), **ELLIPSE) @pytest.fixture def movie_array(ellipse_mask) -> np.ndarray: + """Poisson movie of the beam at ``PIXEL_SIZE`` A/px.""" rng = np.random.default_rng(0) - noise = rng.standard_normal((N_FRAMES, HEIGHT, WIDTH)).astype(np.float32) * 0.1 - signal = ellipse_mask.astype(np.float32) * 5.0 - return noise + signal + expected = 0.1 + 10.0 * ellipse_mask.astype(np.float32) + return rng.poisson(expected, size=(N_FRAMES, HEIGHT, WIDTH)).astype(np.float32) @pytest.fixture def movie_config(tmp_path, movie_array) -> MovieConfig: - """A MovieConfig backed by a tiny on-disk MRC movie. + """A MovieConfig backed by a small on-disk MRC movie. fluence/fluence_per_frame are irrelevant to beam mask estimation and left at 0.0. """ return MovieConfig( movie_path=_write_mrc(tmp_path / "movie.mrc", movie_array), - pixel_size=1.0, + pixel_size=PIXEL_SIZE, fluence=0.0, fluence_per_frame=0.0, ) @@ -70,9 +73,7 @@ def computational_config() -> ComputationalConfig: @pytest.fixture def beam_mask_config() -> BeamMaskConfig: - return BeamMaskConfig( - threshold_method="otsu", diameter_reduction=0.05, low_pass_resolution=10.0 - ) + return BeamMaskConfig(diameter_reduction=0.05) @pytest.fixture @@ -92,9 +93,9 @@ def test_loads_from_config_when_no_movie_given(self, manager): assert result.image_shape_y == HEIGHT assert result.image_shape_x == WIDTH - assert result.pixel_size == 1.0 + assert result.pixel_size == PIXEL_SIZE assert result.diameter_reduction == 0.05 - assert result.threshold_method == "otsu" + assert result.threshold_method == "sobel" def test_explicit_movie_tensor_skips_disk_load(self, manager, movie_array): manager.movie_config.movie_path = "/nonexistent/path/should-never-be-read.mrc" @@ -120,9 +121,7 @@ def test_output_crop_bounds_respect_divisible_by( # size for this ellipse -- crop_bounds_config.divisible_by must still # force both output crop side lengths to a multiple of 3. beam_mask_config = BeamMaskConfig( - threshold_method="otsu", diameter_reduction=0.05, - low_pass_resolution=10.0, crop_bounds_config=CropBoundsConfig(mode="tight", divisible_by=3), ) manager = BeamMaskManager( @@ -138,62 +137,21 @@ def test_output_crop_bounds_respect_divisible_by( assert output_height % 3 == 0 assert output_width % 3 == 0 + def test_recovers_ellipse(self, manager): + result = manager.estimate() -class TestEstimateSobel: - """BeamMaskManager.estimate with the default ``"sobel"`` method.""" - - @pytest.fixture - def beam_movie(self) -> tuple[np.ndarray, dict]: - """Poisson movie of a nearly round beam at 8 A/px, clipped at the top.""" - ellipse = { - "center_y": 100.0, - "center_x": 130.0, - "axis1": 104.0, - "axis2": 100.0, - "angle_deg": 40.0, - } - beam = make_ellipse_mask(shape=(192, 256), **ellipse).astype(np.float32) - rng = np.random.default_rng(0) - movie = rng.poisson(0.1 + 10.0 * beam, size=(N_FRAMES, *beam.shape)) - return movie.astype(np.float32), ellipse - - def test_default_config_uses_sobel(self, computational_config, beam_movie): - movie, ellipse = beam_movie - manager = BeamMaskManager( - computational_config=computational_config, - movie_config=MovieConfig( - pixel_size=8.0, fluence=0.0, fluence_per_frame=0.0 - ), - beam_mask_config=BeamMaskConfig(), - ) - - result = manager.estimate(movie=torch.from_numpy(movie)) - - assert result.threshold_method == "sobel" - assert result.n_inliers is not None - assert result.n_edge_points is not None assert 0 < result.n_inliers <= result.n_edge_points - assert ( - np.hypot( - result.center_y - ellipse["center_y"], - result.center_x - ellipse["center_x"], - ) - < 2.0 + center_offset = np.hypot( + result.center_y - ELLIPSE["center_y"], result.center_x - ELLIPSE["center_x"] ) + assert center_offset < 2.0 assert sorted([result.axis1, result.axis2]) == pytest.approx( - sorted([ellipse["axis1"], ellipse["axis2"]]), rel=0.02 + sorted([ELLIPSE["axis1"], ELLIPSE["axis2"]]), rel=0.02 ) - def test_raises_when_no_beam(self, computational_config): + def test_raises_when_no_beam(self, manager): rng = np.random.default_rng(0) - movie = rng.poisson(1.0, size=(N_FRAMES, 192, 256)).astype(np.float32) - manager = BeamMaskManager( - computational_config=computational_config, - movie_config=MovieConfig( - pixel_size=8.0, fluence=0.0, fluence_per_frame=0.0 - ), - beam_mask_config=BeamMaskConfig(), - ) + movie = rng.poisson(1.0, size=(N_FRAMES, HEIGHT, WIDTH)).astype(np.float32) with pytest.raises(ValueError, match="beam"): manager.estimate(movie=torch.from_numpy(movie)) @@ -236,9 +194,7 @@ def test_output_crop_bounds_feed_align_frames_manager_crop( ): """The beam mask's output_crop_* bounds physically crop the movie.""" beam_mask_config = BeamMaskConfig( - threshold_method="otsu", diameter_reduction=0.05, - low_pass_resolution=10.0, crop_bounds_config=CropBoundsConfig(mode="tight"), ) beam_mask_manager = BeamMaskManager( diff --git a/uv.lock b/uv.lock index 95eaa36..e10a2e0 100644 --- a/uv.lock +++ b/uv.lock @@ -528,7 +528,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/65/8b/3669ad3b3f247a791b2b4aceb3aa5a31f5f6817bf547e4e1ff712338145a/greenlet-3.4.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:1a54a921561dd9518d31d2d3db4d7f80e589083063ab4d3e2e950756ef809e1a", size = 286902, upload-time = "2026-04-08T15:52:12.138Z" }, { url = "https://files.pythonhosted.org/packages/38/3e/3c0e19b82900873e2d8469b590a6c4b3dfd2b316d0591f1c26b38a4879a5/greenlet-3.4.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16dec271460a9a2b154e3b1c2fa1050ce6280878430320e85e08c166772e3f97", size = 606099, upload-time = "2026-04-08T16:24:38.408Z" }, { url = "https://files.pythonhosted.org/packages/b5/33/99fef65e7754fc76a4ed14794074c38c9ed3394a5bd129d7f61b705f3168/greenlet-3.4.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:90036ce224ed6fe75508c1907a77e4540176dcf0744473627785dd519c6f9996", size = 618837, upload-time = "2026-04-08T16:30:58.298Z" }, + { url = "https://files.pythonhosted.org/packages/44/57/eae2cac10421feae6c0987e3dc106c6d86262b1cb379e171b017aba893a6/greenlet-3.4.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6f0def07ec9a71d72315cf26c061aceee53b306c36ed38c35caba952ea1b319d", size = 624901, upload-time = "2026-04-08T16:40:38.981Z" }, { url = "https://files.pythonhosted.org/packages/36/f7/229f3aed6948faa20e0616a0b8568da22e365ede6a54d7d369058b128afd/greenlet-3.4.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1c4f6b453006efb8310affb2d132832e9bbb4fc01ce6df6b70d810d38f1f6dc", size = 615062, upload-time = "2026-04-08T15:56:33.766Z" }, + { url = "https://files.pythonhosted.org/packages/6a/8a/0e73c9b94f31d1cc257fe79a0eff621674141cdae7d6d00f40de378a1e42/greenlet-3.4.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:0e1254cf0cbaa17b04320c3a78575f29f3c161ef38f59c977108f19ffddaf077", size = 423927, upload-time = "2026-04-08T16:43:05.293Z" }, { url = "https://files.pythonhosted.org/packages/08/97/d988180011aa40135c46cd0d0cf01dd97f7162bae14139b4a3ef54889ba5/greenlet-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b2d9a138ffa0e306d0e2b72976d2fb10b97e690d40ab36a472acaab0838e2de", size = 1573511, upload-time = "2026-04-08T16:26:20.058Z" }, { url = "https://files.pythonhosted.org/packages/d4/0f/a5a26fe152fb3d12e6a474181f6e9848283504d0afd095f353d85726374b/greenlet-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8424683caf46eb0eb6f626cb95e008e8cc30d0cb675bdfa48200925c79b38a08", size = 1640396, upload-time = "2026-04-08T15:57:30.88Z" }, { url = "https://files.pythonhosted.org/packages/42/cf/bb2c32d9a100e36ee9f6e38fad6b1e082b8184010cb06259b49e1266ca01/greenlet-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0a53fb071531d003b075c444014ff8f8b1a9898d36bb88abd9ac7b3524648a2", size = 238892, upload-time = "2026-04-08T17:03:10.094Z" }, @@ -536,7 +538,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7a/75/7e9cd1126a1e1f0cd67b0eda02e5221b28488d352684704a78ed505bd719/greenlet-3.4.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:43748988b097f9c6f09364f260741aa73c80747f63389824435c7a50bfdfd5c1", size = 285856, upload-time = "2026-04-08T15:52:45.82Z" }, { url = "https://files.pythonhosted.org/packages/9d/c4/3e2df392e5cb199527c4d9dbcaa75c14edcc394b45040f0189f649631e3c/greenlet-3.4.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5566e4e2cd7a880e8c27618e3eab20f3494452d12fd5129edef7b2f7aa9a36d1", size = 610208, upload-time = "2026-04-08T16:24:39.674Z" }, { url = "https://files.pythonhosted.org/packages/da/af/750cdfda1d1bd30a6c28080245be8d0346e669a98fdbae7f4102aa95fff3/greenlet-3.4.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1054c5a3c78e2ab599d452f23f7adafef55062a783a8e241d24f3b633ba6ff82", size = 621269, upload-time = "2026-04-08T16:30:59.767Z" }, + { url = "https://files.pythonhosted.org/packages/e0/93/c8c508d68ba93232784bbc1b5474d92371f2897dfc6bc281b419f2e0d492/greenlet-3.4.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:98eedd1803353daf1cd9ef23eef23eda5a4d22f99b1f998d273a8b78b70dd47f", size = 628455, upload-time = "2026-04-08T16:40:40.698Z" }, { url = "https://files.pythonhosted.org/packages/54/78/0cbc693622cd54ebe25207efbb3a0eb07c2639cb8594f6e3aaaa0bb077a8/greenlet-3.4.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f82cb6cddc27dd81c96b1506f4aa7def15070c3b2a67d4e46fd19016aacce6cf", size = 617549, upload-time = "2026-04-08T15:56:34.893Z" }, + { url = "https://files.pythonhosted.org/packages/7f/46/cfaaa0ade435a60550fd83d07dfd5c41f873a01da17ede5c4cade0b9bab8/greenlet-3.4.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:b7857e2202aae67bc5725e0c1f6403c20a8ff46094ece015e7d474f5f7020b55", size = 426238, upload-time = "2026-04-08T16:43:06.865Z" }, { url = "https://files.pythonhosted.org/packages/ba/c0/8966767de01343c1ff47e8b855dc78e7d1a8ed2b7b9c83576a57e289f81d/greenlet-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:227a46251ecba4ff46ae742bc5ce95c91d5aceb4b02f885487aff269c127a729", size = 1575310, upload-time = "2026-04-08T16:26:21.671Z" }, { url = "https://files.pythonhosted.org/packages/b8/38/bcdc71ba05e9a5fda87f63ffc2abcd1f15693b659346df994a48c968003d/greenlet-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5b99e87be7eba788dd5b75ba1cde5639edffdec5f91fe0d734a249535ec3408c", size = 1640435, upload-time = "2026-04-08T15:57:32.572Z" }, { url = "https://files.pythonhosted.org/packages/a1/c2/19b664b7173b9e4ef5f77e8cef9f14c20ec7fce7920dc1ccd7afd955d093/greenlet-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:849f8bc17acd6295fcb5de8e46d55cc0e52381c56eaf50a2afd258e97bc65940", size = 238760, upload-time = "2026-04-08T17:04:03.878Z" }, @@ -544,7 +548,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/02/bde66806e8f169cf90b14d02c500c44cdbe02c8e224c9c67bafd1b8cadd1/greenlet-3.4.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:10a07aca6babdd18c16a3f4f8880acfffc2b88dfe431ad6aa5f5740759d7d75e", size = 286291, upload-time = "2026-04-08T17:09:34.307Z" }, { url = "https://files.pythonhosted.org/packages/05/1f/39da1c336a87d47c58352fb8a78541ce63d63ae57c5b9dae1fe02801bbc2/greenlet-3.4.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:076e21040b3a917d3ce4ad68fb5c3c6b32f1405616c4a57aa83120979649bd3d", size = 656749, upload-time = "2026-04-08T16:24:41.721Z" }, { url = "https://files.pythonhosted.org/packages/d3/6c/90ee29a4ee27af7aa2e2ec408799eeb69ee3fcc5abcecac6ddd07a5cd0f2/greenlet-3.4.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e82689eea4a237e530bb5cb41b180ef81fa2160e1f89422a67be7d90da67f615", size = 669084, upload-time = "2026-04-08T16:31:01.372Z" }, + { url = "https://files.pythonhosted.org/packages/d2/4a/74078d3936712cff6d3c91a930016f476ce4198d84e224fe6d81d3e02880/greenlet-3.4.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:06c2d3b89e0c62ba50bd7adf491b14f39da9e7e701647cb7b9ff4c99bee04b19", size = 673405, upload-time = "2026-04-08T16:40:42.527Z" }, { url = "https://files.pythonhosted.org/packages/07/49/d4cad6e5381a50947bb973d2f6cf6592621451b09368b8c20d9b8af49c5b/greenlet-3.4.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4df3b0b2289ec686d3c821a5fee44259c05cfe824dd5e6e12c8e5f5df23085cf", size = 665621, upload-time = "2026-04-08T15:56:35.995Z" }, + { url = "https://files.pythonhosted.org/packages/79/3e/df8a83ab894751bc31e1106fdfaa80ca9753222f106b04de93faaa55feb7/greenlet-3.4.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:070b8bac2ff3b4d9e0ff36a0d19e42103331d9737e8504747cd1e659f76297bd", size = 471670, upload-time = "2026-04-08T16:43:08.512Z" }, { url = "https://files.pythonhosted.org/packages/37/31/d1edd54f424761b5d47718822f506b435b6aab2f3f93b465441143ea5119/greenlet-3.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8bff29d586ea415688f4cec96a591fcc3bf762d046a796cdadc1fdb6e7f2d5bf", size = 1622259, upload-time = "2026-04-08T16:26:23.201Z" }, { url = "https://files.pythonhosted.org/packages/b0/c6/6d3f9cdcb21c4e12a79cb332579f1c6aa1af78eb68059c5a957c7812d95e/greenlet-3.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a569c2fb840c53c13a2b8967c63621fafbd1a0e015b9c82f408c33d626a2fda", size = 1686916, upload-time = "2026-04-08T15:57:34.282Z" }, { url = "https://files.pythonhosted.org/packages/63/45/c1ca4a1ad975de4727e52d3ffe641ae23e1d7a8ffaa8ff7a0477e1827b92/greenlet-3.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:207ba5b97ea8b0b60eb43ffcacf26969dd83726095161d676aac03ff913ee50d", size = 239821, upload-time = "2026-04-08T17:03:48.423Z" }, @@ -552,7 +558,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d4/8f/18d72b629783f5e8d045a76f5325c1e938e659a9e4da79c7dcd10169a48d/greenlet-3.4.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d70012e51df2dbbccfaf63a40aaf9b40c8bed37c3e3a38751c926301ce538ece", size = 294681, upload-time = "2026-04-08T15:52:35.778Z" }, { url = "https://files.pythonhosted.org/packages/9e/ad/5fa86ec46769c4153820d58a04062285b3b9e10ba3d461ee257b68dcbf53/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a58bec0751f43068cd40cff31bb3ca02ad6000b3a51ca81367af4eb5abc480c8", size = 658899, upload-time = "2026-04-08T16:24:43.32Z" }, { url = "https://files.pythonhosted.org/packages/43/f0/4e8174ca0e87ae748c409f055a1ba161038c43cc0a5a6f1433a26ac2e5bf/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05fa0803561028f4b2e3b490ee41216a842eaee11aed004cc343a996d9523aa2", size = 665284, upload-time = "2026-04-08T16:31:02.833Z" }, + { url = "https://files.pythonhosted.org/packages/ef/92/466b0d9afd44b8af623139a3599d651c7564fa4152f25f117e1ee5949ffb/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c4cd56a9eb7a6444edbc19062f7b6fbc8f287c663b946e3171d899693b1c19fa", size = 665872, upload-time = "2026-04-08T16:40:43.912Z" }, { url = "https://files.pythonhosted.org/packages/19/da/991cf7cd33662e2df92a1274b7eb4d61769294d38a1bba8a45f31364845e/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e60d38719cb80b3ab5e85f9f1aed4960acfde09868af6762ccb27b260d68f4ed", size = 661861, upload-time = "2026-04-08T15:56:37.269Z" }, + { url = "https://files.pythonhosted.org/packages/0d/14/3395a7ef3e260de0325152ddfe19dffb3e49fe10873b94654352b53ad48e/greenlet-3.4.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:1f85f204c4d54134ae850d401fa435c89cd667d5ce9dc567571776b45941af72", size = 489237, upload-time = "2026-04-08T16:43:09.993Z" }, { url = "https://files.pythonhosted.org/packages/36/c5/6c2c708e14db3d9caea4b459d8464f58c32047451142fe2cfd90e7458f41/greenlet-3.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f50c804733b43eded05ae694691c9aa68bca7d0a867d67d4a3f514742a2d53f", size = 1622182, upload-time = "2026-04-08T16:26:24.777Z" }, { url = "https://files.pythonhosted.org/packages/7a/4c/50c5fed19378e11a29fabab1f6be39ea95358f4a0a07e115a51ca93385d8/greenlet-3.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2d4f0635dc4aa638cda4b2f5a07ae9a2cff9280327b581a3fcb6f317b4fbc38a", size = 1685050, upload-time = "2026-04-08T15:57:36.453Z" }, { url = "https://files.pythonhosted.org/packages/db/72/85ae954d734703ab48e622c59d4ce35d77ce840c265814af9c078cacc7aa/greenlet-3.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1a4a48f24681300c640f143ba7c404270e1ebbbcf34331d7104a4ff40f8ea705", size = 245554, upload-time = "2026-04-08T17:03:50.044Z" }, @@ -728,19 +736,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/c7/94e930cef9e0a29a2df5e3ba3bacd2c2f1e34ca373fe48624b64af8ae91c/imagecodecs-2026.3.6-cp311-abi3-win_arm64.whl", hash = "sha256:fc4856913be6c8b3861223158920d934a0ae203149a435f585622dbbff8ed696", size = 15193605, upload-time = "2026-03-07T01:26:15.016Z" }, ] -[[package]] -name = "imageio" -version = "2.37.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, - { name = "pillow" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b1/84/93bcd1300216ea50811cee96873b84a1bebf8d0489ffaf7f2a3756bab866/imageio-2.37.3.tar.gz", hash = "sha256:bbb37efbfc4c400fcd534b367b91fcd66d5da639aaa138034431a1c5e0a41451", size = 389673, upload-time = "2026-03-09T11:31:12.573Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/49/fa/391e437a34e55095173dca5f24070d89cbc233ff85bf1c29c93248c6588d/imageio-2.37.3-py3-none-any.whl", hash = "sha256:46f5bb8522cd421c0f5ae104d8268f569d856b29eb1a13b92829d1970f32c9f0", size = 317646, upload-time = "2026-03-09T11:31:10.771Z" }, -] - [[package]] name = "iniconfig" version = "2.3.0" @@ -817,18 +812,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] -[[package]] -name = "lazy-loader" -version = "0.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "packaging" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/49/ac/21a1f8aa3777f5658576777ea76bfb124b702c520bbe90edf4ae9915eafa/lazy_loader-0.5.tar.gz", hash = "sha256:717f9179a0dbed357012ddad50a5ad3d5e4d9a0b8712680d4e687f5e6e6ed9b3", size = 15294, upload-time = "2026-03-06T15:45:09.054Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/a1/8d812e53a5da1687abb10445275d41a8b13adb781bbf7196ddbcf8d88505/lazy_loader-0.5-py3-none-any.whl", hash = "sha256:ab0ea149e9c554d4ffeeb21105ac60bed7f3b4fd69b1d2360a4add51b170b005", size = 8044, upload-time = "2026-03-06T15:45:07.668Z" }, -] - [[package]] name = "leopard-em" version = "1.2" @@ -1521,75 +1504,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, ] -[[package]] -name = "pillow" -version = "12.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, - { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, - { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, - { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, - { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, - { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, - { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, - { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, - { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, - { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, - { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, - { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, - { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, - { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, - { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, - { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, - { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, - { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, - { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, - { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, - { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, - { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, - { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, - { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, - { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, - { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, - { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, - { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, - { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, - { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, - { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, - { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, - { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, - { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, - { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, - { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, - { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, - { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, - { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, - { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, - { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, - { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, - { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, - { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, - { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, - { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, - { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, - { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, - { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, - { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, - { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, - { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, - { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, - { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, - { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, - { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, - { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, - { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, - { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, - { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, - { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, -] - [[package]] name = "platformdirs" version = "4.9.6" @@ -2034,7 +1948,6 @@ dependencies = [ { name = "pandas" }, { name = "pydantic" }, { name = "pyyaml" }, - { name = "scikit-image" }, { name = "scipy" }, { name = "teamtomo-basemodel" }, { name = "tifffile" }, @@ -2078,7 +1991,6 @@ requires-dist = [ { name = "pandas" }, { name = "pydantic" }, { name = "pyyaml" }, - { name = "scikit-image" }, { name = "scipy" }, { name = "teamtomo-basemodel" }, { name = "tifffile" }, @@ -2142,64 +2054,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, ] -[[package]] -name = "scikit-image" -version = "0.26.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "imageio" }, - { name = "lazy-loader" }, - { name = "networkx" }, - { name = "numpy" }, - { name = "packaging" }, - { name = "pillow" }, - { name = "scipy" }, - { name = "tifffile" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a1/b4/2528bb43c67d48053a7a649a9666432dc307d66ba02e3a6d5c40f46655df/scikit_image-0.26.0.tar.gz", hash = "sha256:f5f970ab04efad85c24714321fcc91613fcb64ef2a892a13167df2f3e59199fa", size = 22729739, upload-time = "2025-12-20T17:12:21.824Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/99/e8/e13757982264b33a1621628f86b587e9a73a13f5256dad49b19ba7dc9083/scikit_image-0.26.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d454b93a6fa770ac5ae2d33570f8e7a321bb80d29511ce4b6b78058ebe176e8c", size = 12376452, upload-time = "2025-12-20T17:10:52.796Z" }, - { url = "https://files.pythonhosted.org/packages/e3/be/f8dd17d0510f9911f9f17ba301f7455328bf13dae416560126d428de9568/scikit_image-0.26.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3409e89d66eff5734cd2b672d1c48d2759360057e714e1d92a11df82c87cba37", size = 12061567, upload-time = "2025-12-20T17:10:55.207Z" }, - { url = "https://files.pythonhosted.org/packages/b3/2b/c70120a6880579fb42b91567ad79feb4772f7be72e8d52fec403a3dde0c6/scikit_image-0.26.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c717490cec9e276afb0438dd165b7c3072d6c416709cc0f9f5a4c1070d23a44", size = 13084214, upload-time = "2025-12-20T17:10:57.468Z" }, - { url = "https://files.pythonhosted.org/packages/f4/a2/70401a107d6d7466d64b466927e6b96fcefa99d57494b972608e2f8be50f/scikit_image-0.26.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7df650e79031634ac90b11e64a9eedaf5a5e06fcd09bcd03a34be01745744466", size = 13561683, upload-time = "2025-12-20T17:10:59.49Z" }, - { url = "https://files.pythonhosted.org/packages/13/a5/48bdfd92794c5002d664e0910a349d0a1504671ef5ad358150f21643c79a/scikit_image-0.26.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cefd85033e66d4ea35b525bb0937d7f42d4cdcfed2d1888e1570d5ce450d3932", size = 14112147, upload-time = "2025-12-20T17:11:02.083Z" }, - { url = "https://files.pythonhosted.org/packages/ee/b5/ac71694da92f5def5953ca99f18a10fe98eac2dd0a34079389b70b4d0394/scikit_image-0.26.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3f5bf622d7c0435884e1e141ebbe4b2804e16b2dd23ae4c6183e2ea99233be70", size = 14661625, upload-time = "2025-12-20T17:11:04.528Z" }, - { url = "https://files.pythonhosted.org/packages/23/4d/a3cc1e96f080e253dad2251bfae7587cf2b7912bcd76fd43fd366ff35a87/scikit_image-0.26.0-cp312-cp312-win_amd64.whl", hash = "sha256:abed017474593cd3056ae0fe948d07d0747b27a085e92df5474f4955dd65aec0", size = 11911059, upload-time = "2025-12-20T17:11:06.61Z" }, - { url = "https://files.pythonhosted.org/packages/35/8a/d1b8055f584acc937478abf4550d122936f420352422a1a625eef2c605d8/scikit_image-0.26.0-cp312-cp312-win_arm64.whl", hash = "sha256:4d57e39ef67a95d26860c8caf9b14b8fb130f83b34c6656a77f191fa6d1d04d8", size = 11348740, upload-time = "2025-12-20T17:11:09.118Z" }, - { url = "https://files.pythonhosted.org/packages/4f/48/02357ffb2cca35640f33f2cfe054a4d6d5d7a229b88880a64f1e45c11f4e/scikit_image-0.26.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a2e852eccf41d2d322b8e60144e124802873a92b8d43a6f96331aa42888491c7", size = 12346329, upload-time = "2025-12-20T17:11:11.599Z" }, - { url = "https://files.pythonhosted.org/packages/67/b9/b792c577cea2c1e94cda83b135a656924fc57c428e8a6d302cd69aac1b60/scikit_image-0.26.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:98329aab3bc87db352b9887f64ce8cdb8e75f7c2daa19927f2e121b797b678d5", size = 12031726, upload-time = "2025-12-20T17:11:13.871Z" }, - { url = "https://files.pythonhosted.org/packages/07/a9/9564250dfd65cb20404a611016db52afc6268b2b371cd19c7538ea47580f/scikit_image-0.26.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:915bb3ba66455cf8adac00dc8fdf18a4cd29656aec7ddd38cb4dda90289a6f21", size = 13094910, upload-time = "2025-12-20T17:11:16.2Z" }, - { url = "https://files.pythonhosted.org/packages/a3/b8/0d8eeb5a9fd7d34ba84f8a55753a0a3e2b5b51b2a5a0ade648a8db4a62f7/scikit_image-0.26.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b36ab5e778bf50af5ff386c3ac508027dc3aaeccf2161bdf96bde6848f44d21b", size = 13660939, upload-time = "2025-12-20T17:11:18.464Z" }, - { url = "https://files.pythonhosted.org/packages/2f/d6/91d8973584d4793d4c1a847d388e34ef1218d835eeddecfc9108d735b467/scikit_image-0.26.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:09bad6a5d5949c7896c8347424c4cca899f1d11668030e5548813ab9c2865dcb", size = 14138938, upload-time = "2025-12-20T17:11:20.919Z" }, - { url = "https://files.pythonhosted.org/packages/39/9a/7e15d8dc10d6bbf212195fb39bdeb7f226c46dd53f9c63c312e111e2e175/scikit_image-0.26.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:aeb14db1ed09ad4bee4ceb9e635547a8d5f3549be67fc6c768c7f923e027e6cd", size = 14752243, upload-time = "2025-12-20T17:11:23.347Z" }, - { url = "https://files.pythonhosted.org/packages/8f/58/2b11b933097bc427e42b4a8b15f7de8f24f2bac1fd2779d2aea1431b2c31/scikit_image-0.26.0-cp313-cp313-win_amd64.whl", hash = "sha256:ac529eb9dbd5954f9aaa2e3fe9a3fd9661bfe24e134c688587d811a0233127f1", size = 11906770, upload-time = "2025-12-20T17:11:25.297Z" }, - { url = "https://files.pythonhosted.org/packages/ad/ec/96941474a18a04b69b6f6562a5bd79bd68049fa3728d3b350976eccb8b93/scikit_image-0.26.0-cp313-cp313-win_arm64.whl", hash = "sha256:a2d211bc355f59725efdcae699b93b30348a19416cc9e017f7b2fb599faf7219", size = 11342506, upload-time = "2025-12-20T17:11:27.399Z" }, - { url = "https://files.pythonhosted.org/packages/03/e5/c1a9962b0cf1952f42d32b4a2e48eed520320dbc4d2ff0b981c6fa508b6b/scikit_image-0.26.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:9eefb4adad066da408a7601c4c24b07af3b472d90e08c3e7483d4e9e829d8c49", size = 12663278, upload-time = "2025-12-20T17:11:29.358Z" }, - { url = "https://files.pythonhosted.org/packages/ae/97/c1a276a59ce8e4e24482d65c1a3940d69c6b3873279193b7ebd04e5ee56b/scikit_image-0.26.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:6caec76e16c970c528d15d1c757363334d5cb3069f9cea93d2bead31820511f3", size = 12405142, upload-time = "2025-12-20T17:11:31.282Z" }, - { url = "https://files.pythonhosted.org/packages/d4/4a/f1cbd1357caef6c7993f7efd514d6e53d8fd6f7fe01c4714d51614c53289/scikit_image-0.26.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a07200fe09b9d99fcdab959859fe0f7db8df6333d6204344425d476850ce3604", size = 12942086, upload-time = "2025-12-20T17:11:33.683Z" }, - { url = "https://files.pythonhosted.org/packages/5b/6f/74d9fb87c5655bd64cf00b0c44dc3d6206d9002e5f6ba1c9aeb13236f6bf/scikit_image-0.26.0-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92242351bccf391fc5df2d1529d15470019496d2498d615beb68da85fe7fdf37", size = 13265667, upload-time = "2025-12-20T17:11:36.11Z" }, - { url = "https://files.pythonhosted.org/packages/a7/73/faddc2413ae98d863f6fa2e3e14da4467dd38e788e1c23346cf1a2b06b97/scikit_image-0.26.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:52c496f75a7e45844d951557f13c08c81487c6a1da2e3c9c8a39fcde958e02cc", size = 14001966, upload-time = "2025-12-20T17:11:38.55Z" }, - { url = "https://files.pythonhosted.org/packages/02/94/9f46966fa042b5d57c8cd641045372b4e0df0047dd400e77ea9952674110/scikit_image-0.26.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:20ef4a155e2e78b8ab973998e04d8a361d49d719e65412405f4dadd9155a61d9", size = 14359526, upload-time = "2025-12-20T17:11:41.087Z" }, - { url = "https://files.pythonhosted.org/packages/5d/b4/2840fe38f10057f40b1c9f8fb98a187a370936bf144a4ac23452c5ef1baf/scikit_image-0.26.0-cp313-cp313t-win_amd64.whl", hash = "sha256:c9087cf7d0e7f33ab5c46d2068d86d785e70b05400a891f73a13400f1e1faf6a", size = 12287629, upload-time = "2025-12-20T17:11:43.11Z" }, - { url = "https://files.pythonhosted.org/packages/22/ba/73b6ca70796e71f83ab222690e35a79612f0117e5aaf167151b7d46f5f2c/scikit_image-0.26.0-cp313-cp313t-win_arm64.whl", hash = "sha256:27d58bc8b2acd351f972c6508c1b557cfed80299826080a4d803dd29c51b707e", size = 11647755, upload-time = "2025-12-20T17:11:45.279Z" }, - { url = "https://files.pythonhosted.org/packages/51/44/6b744f92b37ae2833fd423cce8f806d2368859ec325a699dc30389e090b9/scikit_image-0.26.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:63af3d3a26125f796f01052052f86806da5b5e54c6abef152edb752683075a9c", size = 12365810, upload-time = "2025-12-20T17:11:47.357Z" }, - { url = "https://files.pythonhosted.org/packages/40/f5/83590d9355191f86ac663420fec741b82cc547a4afe7c4c1d986bf46e4db/scikit_image-0.26.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ce00600cd70d4562ed59f80523e18cdcc1fae0e10676498a01f73c255774aefd", size = 12075717, upload-time = "2025-12-20T17:11:49.483Z" }, - { url = "https://files.pythonhosted.org/packages/72/48/253e7cf5aee6190459fe136c614e2cbccc562deceb4af96e0863f1b8ee29/scikit_image-0.26.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6381edf972b32e4f54085449afde64365a57316637496c1325a736987083e2ab", size = 13161520, upload-time = "2025-12-20T17:11:51.58Z" }, - { url = "https://files.pythonhosted.org/packages/73/c3/cec6a3cbaadfdcc02bd6ff02f3abfe09eaa7f4d4e0a525a1e3a3f4bce49c/scikit_image-0.26.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6624a76c6085218248154cc7e1500e6b488edcd9499004dd0d35040607d7505", size = 13684340, upload-time = "2025-12-20T17:11:53.708Z" }, - { url = "https://files.pythonhosted.org/packages/d4/0d/39a776f675d24164b3a267aa0db9f677a4cb20127660d8bf4fd7fef66817/scikit_image-0.26.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f775f0e420faac9c2aa6757135f4eb468fb7b70e0b67fa77a5e79be3c30ee331", size = 14203839, upload-time = "2025-12-20T17:11:55.89Z" }, - { url = "https://files.pythonhosted.org/packages/ee/25/2514df226bbcedfe9b2caafa1ba7bc87231a0c339066981b182b08340e06/scikit_image-0.26.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede4d6d255cc5da9faeb2f9ba7fedbc990abbc652db429f40a16b22e770bb578", size = 14770021, upload-time = "2025-12-20T17:11:58.014Z" }, - { url = "https://files.pythonhosted.org/packages/8d/5b/0671dc91c0c79340c3fe202f0549c7d3681eb7640fe34ab68a5f090a7c7f/scikit_image-0.26.0-cp314-cp314-win_amd64.whl", hash = "sha256:0660b83968c15293fd9135e8d860053ee19500d52bf55ca4fb09de595a1af650", size = 12023490, upload-time = "2025-12-20T17:12:00.013Z" }, - { url = "https://files.pythonhosted.org/packages/65/08/7c4cb59f91721f3de07719085212a0b3962e3e3f2d1818cbac4eeb1ea53e/scikit_image-0.26.0-cp314-cp314-win_arm64.whl", hash = "sha256:b8d14d3181c21c11170477a42542c1addc7072a90b986675a71266ad17abc37f", size = 11473782, upload-time = "2025-12-20T17:12:01.983Z" }, - { url = "https://files.pythonhosted.org/packages/49/41/65c4258137acef3d73cb561ac55512eacd7b30bb4f4a11474cad526bc5db/scikit_image-0.26.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:cde0bbd57e6795eba83cb10f71a677f7239271121dc950bc060482834a668ad1", size = 12686060, upload-time = "2025-12-20T17:12:03.886Z" }, - { url = "https://files.pythonhosted.org/packages/e7/32/76971f8727b87f1420a962406388a50e26667c31756126444baf6668f559/scikit_image-0.26.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:163e9afb5b879562b9aeda0dd45208a35316f26cc7a3aed54fd601604e5cf46f", size = 12422628, upload-time = "2025-12-20T17:12:05.921Z" }, - { url = "https://files.pythonhosted.org/packages/37/0d/996febd39f757c40ee7b01cdb861867327e5c8e5f595a634e8201462d958/scikit_image-0.26.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:724f79fd9b6cb6f4a37864fe09f81f9f5d5b9646b6868109e1b100d1a7019e59", size = 12962369, upload-time = "2025-12-20T17:12:07.912Z" }, - { url = "https://files.pythonhosted.org/packages/48/b4/612d354f946c9600e7dea012723c11d47e8d455384e530f6daaaeb9bf62c/scikit_image-0.26.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3268f13310e6857508bd87202620df996199a016a1d281b309441d227c822394", size = 13272431, upload-time = "2025-12-20T17:12:10.255Z" }, - { url = "https://files.pythonhosted.org/packages/0a/6e/26c00b466e06055a086de2c6e2145fe189ccdc9a1d11ccc7de020f2591ad/scikit_image-0.26.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fac96a1f9b06cd771cbbb3cd96c5332f36d4efd839b1d8b053f79e5887acde62", size = 14016362, upload-time = "2025-12-20T17:12:12.793Z" }, - { url = "https://files.pythonhosted.org/packages/47/88/00a90402e1775634043c2a0af8a3c76ad450866d9fa444efcc43b553ba2d/scikit_image-0.26.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c1e7bd342f43e7a97e571b3f03ba4c1293ea1a35c3f13f41efdc8a81c1dc8f2", size = 14364151, upload-time = "2025-12-20T17:12:14.909Z" }, - { url = "https://files.pythonhosted.org/packages/da/ca/918d8d306bd43beacff3b835c6d96fac0ae64c0857092f068b88db531a7c/scikit_image-0.26.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b702c3bb115e1dcf4abf5297429b5c90f2189655888cbed14921f3d26f81d3a4", size = 12413484, upload-time = "2025-12-20T17:12:17.046Z" }, - { url = "https://files.pythonhosted.org/packages/dc/cd/4da01329b5a8d47ff7ec3c99a2b02465a8017b186027590dc7425cee0b56/scikit_image-0.26.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0608aa4a9ec39e0843de10d60edb2785a30c1c47819b67866dd223ebd149acaf", size = 11769501, upload-time = "2025-12-20T17:12:19.339Z" }, -] - [[package]] name = "scipy" version = "1.17.1"