From 5c2dad5bb54271b15184010c27215a035d425278 Mon Sep 17 00:00:00 2001 From: Nicola Vigano Date: Mon, 7 Feb 2022 17:12:09 +0100 Subject: [PATCH 01/26] Implemented cone-beam geometry calibration from rotating spheres. Signed-off-by: Nicola VIGANO --- src/corrct/alignment/__init__.py | 2 + src/corrct/alignment/cone_beam.py | 625 ++++++++++++++++++++++++++++++ src/corrct/alignment/fitting.py | 308 +++++++++++++++ src/corrct/alignment/markers.py | 81 ++++ 4 files changed, 1016 insertions(+) create mode 100644 src/corrct/alignment/cone_beam.py create mode 100644 src/corrct/alignment/markers.py diff --git a/src/corrct/alignment/__init__.py b/src/corrct/alignment/__init__.py index 0b1f86f..116b79b 100644 --- a/src/corrct/alignment/__init__.py +++ b/src/corrct/alignment/__init__.py @@ -6,6 +6,8 @@ from . import centering # noqa: F401, F402 from . import fitting # noqa: F401, F402 from . import shifts # noqa: F401, F402 +from . import cone_beam # noqa: F401, F402 +from . import markers # noqa: F401, F402 RecenterVolume = centering.RecenterVolume DetectorShiftsPRE = shifts.DetectorShiftsPRE diff --git a/src/corrct/alignment/cone_beam.py b/src/corrct/alignment/cone_beam.py new file mode 100644 index 0000000..0e8aa24 --- /dev/null +++ b/src/corrct/alignment/cone_beam.py @@ -0,0 +1,625 @@ +#!/usr/bin/env python3 +""" +Calibrate cone-beam reconstruction geometry. + +@author: Nicola VIGANÒ, ESRF - The European Synchrotron, Grenoble, France, +and CEA-IRIG, Grenoble, France +""" + +import json +from dataclasses import dataclass +from dataclasses import replace as dc_replace +from typing import Sequence, Union + +import matplotlib.pyplot as plt +import numpy as np +from scipy.spatial.transform import Rotation +from numpy.typing import ArrayLike, NDArray +from tqdm import tqdm + +from . import fitting + +from .. import models, projectors, solvers + + +def _class_to_json(obj: object) -> str: + return json.dumps(obj, default=lambda o: {o.__class__.__name__: o.__dict__}, sort_keys=True, indent=4) + + +def _diff_axis_angle_rad(center_1_vu: Union[ArrayLike, NDArray], center_2_vu: Union[ArrayLike, NDArray]) -> float: + diffs_vu = np.array(center_1_vu) - np.array(center_2_vu) + angle_rad = np.arctan2(diffs_vu[-1], diffs_vu[-2]) + angle_rad = np.mod(angle_rad, 2 * np.pi) + return angle_rad - np.pi + + +@dataclass +class ConeBeamGeometry: + """Store the acquisition geometry parameters, used for creating reconstruction geometries. + + A description of the geometry / meaning of the fields can be found here: + - Noo, F., Clackdoyle, R., Mennessier, C., White, T. A. & Roney, T. J. (2000). Phys. Med. Biol. 45, 3489–3508. + doi: 10.1088/0031-9155/45/11/327 + """ + + theta_deg: float = 0.0 + phi_deg: float = 0.0 + eta_deg: float = 0.0 + D: float = 0.0 + R: float = 0.0 + v0: float = 0.0 + u0: float = 0.0 + det_pix_v: int = 0 + det_pix_u: int = 0 + + def __str__(self) -> str: + """ + Return a human readable representation of the object. + + Returns + ------- + str + The human readable representation of the object. + """ + descr = "AcquisitionGeometry(\n" + for f, v in self.__dict__.items(): + descr += f" {f} = {v}" + if f.lower()[-3:] == "rad": + descr += f" ({np.rad2deg(v)} deg)" + descr += ",\n" + return descr + ")" + + def get_prj_geom(self, translate_z_to_center: bool = True) -> models.ProjectionGeometry: + """ + Create the geometry for reconstruction. + + Returns + ------- + Dict + The geometry to be used for reconstruction. + """ + # Sample base vectors + e_x_xyz = np.array([1, 0, 0]) + + # NOTE: Y coordinate is flipped, with respect to the input!!! + + theta_rad = np.deg2rad(self.theta_deg) + phi_rad = np.deg2rad(self.phi_deg) + + # Rotated detector base vectors + alpha_xyz = np.array([-np.sin(phi_rad), -np.cos(phi_rad), 0]) + beta_xyz = np.array( + [ + -np.sin(theta_rad) * np.cos(phi_rad), + np.sin(theta_rad) * np.sin(phi_rad), + np.cos(theta_rad), + ] + ) + + # Detector normal + e_n_xyz = np.array( + [ + np.cos(theta_rad) * np.cos(phi_rad), + -np.cos(theta_rad) * np.sin(phi_rad), + np.sin(theta_rad), + ] + ) + + det_pos_xyz = -e_n_xyz * self.D + e_x_xyz * self.R - alpha_xyz * self.u0 - beta_xyz * self.v0 + src_pos_xyz = e_x_xyz * self.R + + pix2vox_ratio = self.R / self.D * np.abs(np.dot(e_n_xyz, e_x_xyz)) + + if translate_z_to_center: + det_center_xyz = e_n_xyz * self.D + alpha_xyz * self.u0 + beta_xyz * self.v0 + + translation_z = det_center_xyz[2] / np.abs(det_center_xyz[0]) * self.R + src_pos_xyz[2] += translation_z + det_pos_xyz[2] += translation_z + + rotation = Rotation.from_rotvec(-e_n_xyz * np.deg2rad(self.eta_deg)) + e_u_xyz = rotation.apply(alpha_xyz) + e_v_xyz = rotation.apply(beta_xyz) + + return models.ProjectionGeometry( + geom_type="cone", + src_pos_xyz=src_pos_xyz, + det_pos_xyz=det_pos_xyz, + det_u_xyz=e_u_xyz, + det_v_xyz=e_v_xyz, + rot_dir_xyz=np.array([0, 0, 1]), + pix2vox_ratio=pix2vox_ratio, + ) + + def get_vol_geom(self, up_sampling: int = 1) -> models.VolumeGeometry: + """ + Generate volume geometry. + + Returns + ------- + VolumeGeometry + The volume geometry. + """ + return models.VolumeGeometry( + _vol_shape_xyz=np.array([self.det_pix_u, self.det_pix_u, self.det_pix_v], dtype=int) * up_sampling, + vox_size=1 / up_sampling, + ) + + def update(self, field: str, val: float, is_relative: bool = True, decimals: Union[int, None] = 3) -> "ConeBeamGeometry": + """ + Return a copy of the original data, with a replaced field. + + Parameters + ---------- + field : str + The field to replace. + val : float + The new value of the field. + is_relative : bool, optional + Whether the value is relative to the previous. The default is True. + decimals : int | None, optional + The number of decimals (precision) to use for the updated values, by default 3 decimals. + + Returns + ------- + AcquisitionGeometry + The updated geometry. + """ + new_val = getattr(self, field) + val if is_relative else val + if decimals is not None: + new_val = np.around(new_val, decimals=decimals) + return dc_replace(self, **{field: new_val}) + + def get_tuning_params( + self, field: str, val_range: Union[ArrayLike, NDArray], is_relative: bool = True + ) -> Sequence["ConeBeamGeometry"]: + """ + Generate sequences of acquisition geometries, with a slight variation over a field's value. + + Parameters + ---------- + field : str + The field to tune. + val_range : ArrayLike + The value range. + is_relative : bool, optional + Whether the values are relative. The default is True. + + Returns + ------- + Sequence[AcquisitionGeometry] + The list of new acquisition geometries. + """ + return [self.update(field, val, is_relative) for val in np.array(val_range, ndmin=1)] + + def to_json(self) -> str: + """ + Save instance to JSON. + + Returns + ------- + str + The JSON representation. + """ + return _class_to_json(self) + + def from_json(self, data: str) -> None: + """ + Load instance from JSON. + + Parameters + ---------- + data : str + The JSON data to load. + + Raises + ------ + ValueError + In case we were to load more than one instance, or different classes. + """ + d = json.loads(data) + if len(d.keys()) > 1: + raise ValueError("Initialization from JSON: More than one class instance passed.") + class_name = list(d.keys())[0] + if list(d.keys())[0] != self.__class__.__name__: + raise ValueError( + f"Initialization from JSON: expecting {self.__class__.__name__} class instance, but {d.keys()[0]} passed." + ) + d = d[class_name] + for k in self.__dict__.keys(): + self.__dict__[k] = d[k] + + +class FitConeBeamGeometry: + """Cone-beam geometry calibration object. + + This method is based on the following article: + - Noo, F., Clackdoyle, R., Mennessier, C., White, T. A. & Roney, T. J. (2000). Phys. Med. Biol. 45, 3489–3508. + doi: 10.1088/0031-9155/45/11/327 + """ + + def __init__( + self, + prj_size_vu: Union[ArrayLike, NDArray], + points_ell1: Union[ArrayLike, NDArray], + points_ell2: Union[ArrayLike, NDArray], + points_axis: Union[ArrayLike, NDArray, None] = None, + verbose: bool = True, + ): + """Initialize a cone-beam geometry calibration object. + + Parameters + ---------- + prj_size_vu : Union[ArrayLike, NDArray] + Size of the projections. + points_ell1 : Union[ArrayLike, NDArray] + Points of first ellipse. + points_ell2 : Union[ArrayLike, NDArray] + Points of second ellipse. + points_axis : Union[ArrayLike, NDArray, None], optional + Points of the rotation axis, by default None + verbose : bool, optional + Whether to produce verbose output, by default True + """ + self.prj_size_vu = np.array(prj_size_vu) + self.center_vu = self.prj_size_vu / 2 + + self.points_ell1 = np.array(points_ell1) - self.center_vu + self.points_ell2 = np.array(points_ell2) - self.center_vu + + if points_axis is not None: + points_axis = np.array(points_axis) - self.center_vu + self.points_axis = points_axis + + self.acq_geom = ConeBeamGeometry(det_pix_v=int(self.prj_size_vu[0]), det_pix_u=int(self.prj_size_vu[1])) + + self.verbose = verbose + + self._pre_fit() + + def _pre_fit(self, use_least_squares: bool = False) -> None: + ell1 = fitting.Ellipse(self.points_ell1) + ell2 = fitting.Ellipse(self.points_ell2) + + if self.points_axis is not None: + # Using measured projected center, whenever available + self.ell1_prj_center_vu = self.points_axis[0, :] + self.ell2_prj_center_vu = self.points_axis[-1, :] + + self.prj_origin_vu = self.points_axis[1, :] + else: + self.ell1_prj_center_vu = ell1.fit_prj_center(least_squares=use_least_squares) + self.ell2_prj_center_vu = ell2.fit_prj_center(least_squares=use_least_squares) + + self.prj_origin_vu = None + + self.acq_geom.eta_deg = np.rad2deg(_diff_axis_angle_rad(self.ell1_prj_center_vu, self.ell2_prj_center_vu)) + + if self.verbose: + print(f"Projected origin on the detector (pix): {self.prj_origin_vu}") + print(f"Detector tilt around its normal (eta), fitted (deg): {self.acq_geom.eta_deg}") + + if np.abs(self.acq_geom.eta_deg) > 0.1: + rot = Rotation.from_rotvec(-np.deg2rad(self.acq_geom.eta_deg) * np.array([0, 0, 1])) + rot_mat = rot.as_matrix()[:2, :2] + + self.points_ell1_rot = rot_mat.dot(self.points_ell1.T).T + self.points_ell2_rot = rot_mat.dot(self.points_ell2.T).T + else: + self.points_ell1_rot = self.points_ell1.copy() + self.points_ell2_rot = self.points_ell2.copy() + + # Re-instatiate ellipse class, after rotation + ell1 = fitting.Ellipse(self.points_ell1_rot) + ell2 = fitting.Ellipse(self.points_ell2_rot) + + self.ell1_params = ell1.fit_parameters(least_squares=use_least_squares) + self.ell2_params = ell2.fit_parameters(least_squares=use_least_squares) + + self.acq_geom.D = self._fit_distance_det2src(self.ell1_params, self.ell2_params) + + if self.verbose: + print(f"Fitted detector distance from source (pix): {self.acq_geom.D}") + + def fit(self, r: float, e: float = 1) -> ConeBeamGeometry: + """ + Fit the cone-beam geometry parameters, that will be used for producing the projection geometry. + + Parameters + ---------- + r : float + The radius of the circle performed by the spheres. + e : float, optional + Either 1 or -1, indicating whether the source is between the circles or not. The default is 1. + + Raises + ------ + ValueError + In case of flipped ellipses. + """ + b1, a1, c1, v1, u1 = self.ell1_params + b2, a2, c2, v2, u2 = self.ell2_params + + sign_z1 = -1 + sign_z2 = sign_z1 * -e + + def get_v0(vk, bk, ak, ck, D, sign_zk) -> float: + return vk - sign_zk * np.sqrt(ak + (ak**2) * (D**2)) / np.sqrt(ak * bk - (ck**2)) + + def get_denom(bk, ak, ck, D) -> float: + return np.sqrt(ak * bk + (ak**2) * bk * (D**2) - (ck**2)) + + def get_rho(bk, ak, ck, D) -> float: + return np.sqrt(ak * bk - (ck**2)) / get_denom(bk, ak, ck, D) + + def get_zeta(bk, ak, ck, D, sign_zk) -> float: + return D * sign_zk * ak * np.sqrt(ak) / get_denom(bk, ak, ck, D) + + v01 = get_v0(v1, b1, a1, c1, self.acq_geom.D, sign_z1) + v02 = get_v0(v2, b2, a2, c2, self.acq_geom.D, sign_z2) + + self.acq_geom.v0 = np.array([v01, v02]).mean() + self.acq_geom.u0 = ( + np.mean([u1, u2]) + c1 / (2 * a1) * (v1 - self.acq_geom.v0) + c2 / (2 * a2) * (v2 - self.acq_geom.v0) + ) + + if self.verbose: + print(f"Ellipses' positions:\n- upper (v1={v1}, u1={u1})\n- lower (v2={v2}, u2={u2})") + print(f"Fitted source position over detector: v0={self.acq_geom.v0}, u0={self.acq_geom.u0}") + print(f"- Separately fitted v: v01={v01}, v02={v02}") + + if np.linalg.norm(v01 - v02) > np.linalg.norm(v1 - v2): + raise ValueError( + f"Obtained: v01={v01}, v02={v02}, while v1={v1}, v2={v2}. Probably wrong order of ellipses (please flip them!)" + ) + + rho1 = get_rho(b1, a1, c1, self.acq_geom.D) + rho2 = get_rho(b2, a2, c2, self.acq_geom.D) + + zeta1 = get_zeta(b1, a1, c1, self.acq_geom.D, sign_z1) + zeta2 = get_zeta(b2, a2, c2, self.acq_geom.D, sign_z2) + + self.acq_geom.phi_deg = np.rad2deg(np.arcsin(-c1 / (2 * a1) * zeta1 - c2 / (2 * a2) * zeta2)) + + R1 = r / rho1 + R2 = r / rho2 + + self.z1 = R1 * zeta1 + self.z2 = R2 * zeta2 + + z_full = self.z1 - self.z2 + + self.acq_geom.theta_deg = 0.0 + # self.acq_geom.theta_rad = np.arcsin((R1 - R2) / z_full) / np.mean(self.prj_size_vu) + + self.acq_geom.R = (-self.z2 * R1 + self.z1 * R2) / z_full + + if self.prj_origin_vu is None: + self.prj_origin_vu = (-self.z2 * self.ell1_prj_center_vu + self.z1 * self.ell2_prj_center_vu) / z_full + if self.verbose: + print(f"Projected origin on the detector (pix): {self.prj_origin_vu}") + + if self.verbose: + print(f"Fitted distances between source and rotation axis (pix):\n- R1={R1}, R={self.acq_geom.R}, R2={R2}") + print(f"Fitted heights of the two ellipses, with respect to the source (pix): z1={self.z1}, z2={self.z2}") + print(f"Fitted polar angle of the detector (phi deg): {self.acq_geom.phi_deg}") + print(f"Fitted azimuthal angle of the detector (theta deg): {self.acq_geom.theta_deg}") + + return self.acq_geom + + @staticmethod + def _fit_distance_det2src( + ellipse_1: Union[ArrayLike, NDArray], ellipse_2: Union[ArrayLike, NDArray], e: float = 1 + ) -> float: + b1, a1, c1, v1, _ = np.array(ellipse_1) + b2, a2, c2, v2, _ = np.array(ellipse_2) + + ecc2 = np.sqrt(b2 - c2**2 / a2) + ecc1 = np.sqrt(b1 - c1**2 / a1) + + m0 = (v2 - v1) * ecc2 + m1 = ecc2 / ecc1 + + m0m1 = 2 * m0 * m1 + m1_2 = m1**2 + + n0 = (1 - m0**2 - m1_2) / m0m1 + n1 = (a2 - a1 * m1_2) / m0m1 + + n0n1 = n0 * n1 + n1_2 = n1**2 + + s2d_2 = ((a1 - 2 * n0n1) - e * np.sqrt(a1**2 + 4 * n1_2 - 4 * n0n1 * a1)) / (2 * n1_2) + return np.sqrt(s2d_2) + + +def tune_acquisition_geometry( + acq_geom_init: ConeBeamGeometry, + data: Union[ArrayLike, NDArray], + angles_rot_rad: Union[ArrayLike, NDArray], + params: dict[str, Union[ArrayLike, NDArray]], + data_mask: Union[ArrayLike, NDArray, None] = None, + verbose: bool = True, +) -> ConeBeamGeometry: + """ + Tune the acquisition geometry, based on calibration data self-consistency. + + Parameters + ---------- + acq_geom : ConeBeamGeometry + The cone-beam geometry to refine. + data : ArrayLike | NDArray + The calibration projection data. + angles : ArrayLike | NDArray + Angles of the projections. + params : dict[str, ArrayLike | NDArray] + Parameters to tune as a dictionary. + The acquisition parameters to tune are the keys, and their test values are the dictionary values. + data_mask : ArrayLike | NDArray | None, optional + Pixel mask of the data, to mask out dead or hot pixels. The default is None. + verbose : bool, optional + Whether to output verbose information or not, by default False. + """ + data = np.array(data) + angles_rot_rad = np.array(angles_rot_rad) + if data_mask is not None: + data_mask = np.array(data_mask) + + solver = solvers.SIRT(tolerance=0) + + acq_geom_tuned = acq_geom_init + + for par_name, par_vals in params.items(): + par_vals = np.array(par_vals) + desc = f"Tuning '{par_name}': " + residuals = np.atleast_1d(np.empty(len(par_vals))) + for par_ind, acq_geom in enumerate(tqdm(acq_geom_tuned.get_tuning_params(par_name, par_vals), desc=desc)): + vol_geom = acq_geom.get_vol_geom() + prj_geom = acq_geom.get_prj_geom() + with projectors.ProjectorUncorrected(vol_geom, angles_rot_rad, prj_geom=prj_geom) as prj: + _, info = solver(prj, data, iterations=100, b_mask=data_mask) + residuals[par_ind] = info.residuals[-1] + + min_par, min_res, fit_info = fitting.fit_parabola_min(par_vals, residuals, decimals=6) + + if verbose: + old_par_val = getattr(acq_geom_tuned, par_name) + print(f"Min of {par_name}: {old_par_val}" + f" -> {old_par_val + min_par} (diff: {min_par})\n") + fig, axs = plt.subplots() + axs.plot(par_vals, residuals) + if fit_info is not None: + x = np.linspace(fit_info[1][0], fit_info[1][2]) + y = fit_info[0][0] + x * (fit_info[0][1] + x * fit_info[0][2]) + axs.plot(x, y) + axs.scatter(min_par, min_res, 10, "r") + axs.set_title(par_name) + axs.grid() + fig.tight_layout() + plt.show(block=False) + + acq_geom_tuned = acq_geom_tuned.update(par_name, min_par) + + return acq_geom_tuned + + +def cm2inch(dims: Union[ArrayLike, NDArray]) -> tuple[float]: + """Convert cm into inch. + + Parameters + ---------- + dims : Union[ArrayLike, NDArray] + The dimentions of the object in cm + + Returns + ------- + tuple[float] + The output dimensions in inch + """ + return tuple(np.array(dims) / 2.54) + + +class MarkerVisualizer: + """Plotting class to assess the calibration quality.""" + + def __init__( + self, + fitted_positions_vu: Union[ArrayLike, NDArray], + imgs: NDArray, + disk: NDArray, + ell_params: Union[ArrayLike, NDArray, None] = None, + ) -> None: + self.positions_vu = np.array(fitted_positions_vu) + self.imgs = imgs + self.disk = disk + self.global_lims = False + + if ell_params is not None: + ell_params = np.array(ell_params) + self.ell_params = ell_params + + self.curr_pos = 0 + + if self.ell_params is not None: + us = np.sort(self.positions_vu[:, 1]) + self.v_1, self.v_2 = fitting.Ellipse.predict_v(self.ell_params, us) + + self.fig, self.axs = plt.subplots(1, 3, figsize=cm2inch([36, 12])) # , sharex=True, sharey=True + self.axs[2].imshow(self.disk) + self.axs[0].set_xlim(0, self.imgs.shape[-1]) + self.axs[0].set_ylim(self.imgs.shape[-3], 0) + self.fig.tight_layout() + self.update() + + self.fig.canvas.mpl_connect("key_press_event", self._key_event) + self.fig.canvas.mpl_connect("scroll_event", self._scroll_event) + + def update(self) -> None: + self.curr_pos = self.curr_pos % self.imgs.shape[-2] + + for img in self.axs[0].get_images(): + img.remove() + x_lims = self.axs[0].get_xlim() + y_lims = self.axs[0].get_ylim() + self.axs[0].cla() + self.axs[0].set_xlim(x_lims[0], x_lims[1]) + self.axs[0].set_ylim(y_lims[0], y_lims[1]) + + for img in self.axs[1].get_images(): + img.remove() + self.axs[1].cla() + + self.axs[0].plot(self.positions_vu[:, 1], self.positions_vu[:, 0], "bo-", markersize=4) + self.axs[0].scatter(self.positions_vu[self.curr_pos, 1], self.positions_vu[self.curr_pos, 0], c="r") + + if self.ell_params is not None: + us = np.sort(self.positions_vu[:, 1]) + self.axs[0].plot(us, self.v_1, "g") + self.axs[0].plot(us, self.v_2, "g") + self.axs[0].grid() + + if self.global_lims: + vmin = self.imgs.min() + vmax = self.imgs.max() + else: + vmin = self.imgs[:, self.curr_pos, :].min() + vmax = self.imgs[:, self.curr_pos, :].max() + + img = self.axs[1].imshow(self.imgs[:, self.curr_pos, :], vmin=vmin, vmax=vmax) + self.axs[1].scatter(self.positions_vu[self.curr_pos, 1], self.positions_vu[self.curr_pos, 0], c="r") + self.axs[1].set_title(f"Range: [{vmin}, {vmax}]") + # plt.colorbar(im, ax=self.axs[1]) + self.fig.canvas.draw() + + def _key_event(self, evnt) -> None: + if evnt.key == "right": + self.curr_pos += 1 + elif evnt.key == "left": + self.curr_pos -= 1 + elif evnt.key == "up": + self.curr_pos += 1 + elif evnt.key == "down": + self.curr_pos -= 1 + elif evnt.key == "pageup": + self.curr_pos += 10 + elif evnt.key == "pagedown": + self.curr_pos -= 10 + elif evnt.key == "escape": + plt.close(self.fig) + elif evnt.key == "ctrl+l": + self.global_lims = not self.global_lims + else: + print(evnt.key) + return + + self.update() + + def _scroll_event(self, evnt) -> None: + if evnt.button == "up": + self.curr_pos += 1 + elif evnt.button == "down": + self.curr_pos -= 1 + else: + print(evnt.key) + return + + self.update() diff --git a/src/corrct/alignment/fitting.py b/src/corrct/alignment/fitting.py index 1afd04c..4b6aebf 100644 --- a/src/corrct/alignment/fitting.py +++ b/src/corrct/alignment/fitting.py @@ -719,3 +719,311 @@ def refine_max_position_2d( + f" Input values: {f_vals}" ) return vertex_yx + + +def fit_parabola_min( + fun_x: Union[ArrayLike, NDArray], + fun_vals: Union[ArrayLike, NDArray], + scale: Literal["linear", "log"] = "linear", + decimals: int = 2, +) -> tuple[float, float, Optional[tuple[NDArray, NDArray]]]: + """Parabolic fit local function stationary point. + + Parameters + ---------- + fx : ArrayLike + Parameter values. + f_vals : ArrayLike + Objective function costs of each parameter value. + scale : str, optional + Scale of the fit. Options are: "log" | "linear". The default is "log". + + Returns + ------- + min_fx : float + Expected parameter value of the fitted minimum. + min_f_val : float + Expected objective function cost of the fitted minimum. + """ + fun_x = np.array(fun_x) + fun_vals = np.array(fun_vals) + + if len(fun_x) < 3 or len(fun_vals) < 3 or len(fun_x) != len(fun_vals): + raise ValueError( + "Lengths of the parameter values and function values should be identical and >= 3." + + f"Given: fx={len(fun_x)}, f_vals={len(fun_vals)}" + ) + + if scale.lower() == "log": + to_fit = np.log10 + + def from_fit(vals): + return 10**vals + + elif scale.lower() == "linear": + + def to_fit(vals): + return vals + + from_fit = to_fit + else: + raise ValueError(f"Parameter 'scale' should be either 'log' or 'linear', given '{scale}' instead") + + min_pos = np.argmin(fun_vals) + if min_pos == 0: + print("WARNING: minimum value at the beginning of the lambda range.") + fx_fit = to_fit(fun_x[:3]) + f_vals_fit = fun_vals[:3] + elif min_pos == (len(fun_vals) - 1): + print("WARNING: minimum value at the end of the lambda range.") + fx_fit = to_fit(fun_x[-3:]) + f_vals_fit = fun_vals[-3:] + else: + fx_fit = to_fit(fun_x[min_pos - 1 : min_pos + 2]) + f_vals_fit = fun_vals[min_pos - 1 : min_pos + 2] + + # using Polynomial.fit, because it is supposed to be more numerically + # stable than previous solutions (according to numpy). + poly = Polynomial.fit(fx_fit, f_vals_fit, deg=2) + coeffs = poly.convert().coef + if coeffs[2] <= 0: + print("WARNING: fitted curve is concave. Returning minimum measured point.") + return fun_x[min_pos], fun_vals[min_pos], None + + # For a 1D parabola `f(x) = c + bx + ax^2`, the vertex position is: + # x_v = -b / 2a. + vertex_pos = -coeffs[1] / (2 * coeffs[2]) + vertex_val = coeffs[0] + vertex_pos * coeffs[1] / 2 + + vertex_pos = np.around(vertex_pos, decimals=decimals) + vertex_val = np.around(vertex_val, decimals=decimals) + + min_fx, min_f_val = from_fit(vertex_pos), vertex_val + if min_fx < fun_x[0] or min_fx > fun_x[-1]: + print( + f"WARNING: fitted stationary point {min_fx} is outside input range [{fun_x[0]}, {fun_x[-1]}]." + + " Returning minimum measured point." + ) + return fun_x[min_pos], fun_vals[min_pos], None + + return min_fx, min_f_val, (coeffs, fx_fit) + + +class Ellipse: + """ + Initialize ellipse class, used for fitting acquisition geometry parameters. + + Parameters + ---------- + prj_points_vu : ArrayLike + List ofprojected positions over the detector of a test object. + prj_center_vu : Optional[ArrayLike], optional + Projected position of the rotation center. The default is None. + """ + + def __init__(self, prj_points_vu: Union[ArrayLike, NDArray], prj_center_vu: Union[ArrayLike, NDArray, None] = None): + self.prj_points_vu = np.array(prj_points_vu) + if prj_center_vu is not None: + prj_center_vu = np.array(prj_center_vu) + self.prj_center_vu = prj_center_vu + + def fit_prj_center(self, rescale: bool = True, least_squares: bool = True) -> NDArray: + """ + Fit the projected circle position. + + Parameters + ---------- + rescale : bool, optional + Whether to rescale the data within the interval [-1, 1]. The default is True. + least_squares : bool, optional + Whether to use the least-squares (l2-norm) fit or l1-norm. The default is True. + + Returns + ------- + ArrayLike + The fitted center position. + """ + c_vu = np.mean(self.prj_points_vu, axis=0) + pos_vu = self.prj_points_vu - c_vu + + if rescale: + scale_vu = np.max(pos_vu, axis=0) - np.min(pos_vu, axis=0) + pos_vu /= scale_vu + else: + scale_vu = 1.0 + + num_lines = pos_vu.shape[-2] // 2 + pos1_vu = pos_vu[:num_lines, :] + pos2_vu = pos_vu[num_lines : num_lines * 2, :] + + diffs_vu = pos2_vu - pos1_vu + b = np.cross(pos1_vu, pos2_vu) + A = np.stack([diffs_vu[:, -1], -diffs_vu[:, -2]], axis=-1) + + p_vu = np.linalg.lstsq(A, b, rcond=None)[0] + if not least_squares: + + def _func(params: NDArrayFloat) -> float: + predicted_b = A.dot(params) + l1_diff = np.linalg.norm(predicted_b - b, ord=1) + return float(l1_diff) + + opt_p_vu = spopt.minimize(_func, p_vu) + p_vu = opt_p_vu.x + + return p_vu * scale_vu + c_vu + + def fit_parameters(self, rescale: bool = True, least_squares: bool = True) -> NDArray: + """ + Fit the ellipse parameters. + + Parameters + ---------- + rescale : bool, optional + Whether to rescale the data within the interval [-1, 1]. The default is True. + least_squares : bool, optional + Whether to use the least-squares (l2-norm) fit or l1-norm. The default is True. + + Returns + ------- + ArrayLike + The fitted ellipse parameters. + """ + # First we fit 5 intermediate variables + p_u: NDArray = self.prj_points_vu[:, -1] + p_v: NDArray = self.prj_points_vu[:, -2] + + if rescale: + c_h = np.mean(p_u) + c_v = np.mean(p_v) + p_u = p_u - c_h + p_v = p_v - c_v + + p_u_scaling = np.abs(p_u).max() + p_v_scaling = np.abs(p_v).max() + p_u /= p_u_scaling + p_v /= p_v_scaling + else: + c_h = 0.0 + c_v = 0.0 + p_u_scaling = 1.0 + p_v_scaling = 1.0 + + A = np.stack([p_u**2, -2 * p_u, -2 * p_v, 2 * p_u * p_v, np.ones_like(p_u)], axis=-1) + b = -(p_v**2) + + params = np.linalg.lstsq(A, b, rcond=None)[0] + if not least_squares: + + def _func(pars: NDArrayFloat) -> float: + predicted_b = A.dot(pars) + l1_diff = np.linalg.norm(predicted_b - b, ord=1) + return float(l1_diff) + + opt_params = spopt.minimize(_func, params) + params = opt_params.x + + if rescale: + params[0] *= (p_v_scaling**2) / (p_u_scaling**2) + params[1] *= (p_v_scaling**2) / p_u_scaling + params[2] *= p_v_scaling + params[3] *= p_v_scaling / p_u_scaling + params[4] *= p_v_scaling**2 + + u = (params[1] - params[2] * params[3]) / (params[0] - params[3] ** 2) + v = (params[0] * params[2] - params[1] * params[3]) / (params[0] - params[2] * params[3]) + + a = params[0] / (params[0] * u**2 + v**2 + 2 * params[3] * u * v - params[4]) + b = a / params[0] + c = params[3] * b + + return np.array([b, a, c, v + c_v, u + c_h]) + + def fit_ellipse_centroid(self, rescale: bool = True, least_squares: bool = True) -> NDArray: + """ + Fit the ellipse parameters, when assuming the center of mass of the points as center of the ellipse. + + Parameters + ---------- + rescale : bool, optional + Whether to rescale the data within the interval [-1, 1]. The default is True. + least_squares : bool, optional + Whether to use the least-squares (l2-norm) fit or l1-norm. The default is True. + + Returns + ------- + ArrayLike + The fitted ellipse parameters. + """ + # First we fit 3 intermediate variables + num_to_keep = (self.prj_points_vu.shape[0] // 2) * 2 + p_u = self.prj_points_vu[:num_to_keep, -1] + p_v = self.prj_points_vu[:num_to_keep, -2] + + u = np.mean(p_u) + v = np.mean(p_v) + + p_u = p_u - u + p_v = p_v - v + + if rescale: + p_u_scaling = np.abs(p_u).max() + p_v_scaling = np.abs(p_v).max() + p_u /= p_u_scaling + p_v /= p_v_scaling + else: + p_u_scaling = 1.0 + p_v_scaling = 1.0 + + A = np.stack([p_u**2, p_u * p_v, np.ones_like(p_u)], axis=-1) + b = -(p_v**2) + + params = np.linalg.lstsq(A, b, rcond=None)[0] + if not least_squares: + + def _func(pars: NDArrayFloat) -> float: + predicted_b = A.dot(pars) + l1_diff = np.linalg.norm(predicted_b - b, ord=1) + return float(l1_diff) + + opt_params = spopt.minimize(_func, params) + params = opt_params.x + + if rescale: + params[0] *= (p_v_scaling**2) / (p_u_scaling**2) + params[1] *= p_v_scaling / p_u_scaling + params[2] *= p_v_scaling**2 + + a = -params[0] / params[2] + b = -1 / params[2] + c = -params[1] / params[2] + + return np.array([b, a, c, v, u]) + + @staticmethod + def predict_v(ell_params: Union[ArrayLike, NDArray], uus: Union[ArrayLike, NDArray]) -> tuple[NDArray, NDArray]: + """Predict V coordinates of ellipse from its parameters, and U coordinates. + + Parameters + ---------- + ell_params : Union[ArrayLike, NDArray] + The ellipse parameters + uus : Union[ArrayLike, NDArray] + The U coordinates + + Returns + ------- + tuple[NDArray, NDArray] + The corresponding top and bottom V coordinates + """ + b, a, c, v, u = np.array(ell_params) + uus = np.array(uus) + + a_tilde = b + b_tilde = 2 * (-b * v + c * uus - c * u) + c_tilde = -(1 - a * (uus - u) ** 2 - b * v**2 + 2 * c * v * (uus - u)) + delta_tilde = np.sqrt(b_tilde**2 - 4 * a_tilde * c_tilde) + v_1 = (-b_tilde + delta_tilde) / (2 * a_tilde) + v_2 = (-b_tilde - delta_tilde) / (2 * a_tilde) + + return v_1, v_2 diff --git a/src/corrct/alignment/markers.py b/src/corrct/alignment/markers.py new file mode 100644 index 0000000..c500826 --- /dev/null +++ b/src/corrct/alignment/markers.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +""" +Fiducial marker tracking routines. + +@author: Nicola VIGANÒ, ESRF - The European Synchrotron, Grenoble, France, +and CEA-IRIG, Grenoble, France +""" + +from typing import Union + +import numpy as np +import scipy.ndimage as spimg +from numpy.typing import ArrayLike, NDArray + +from . import fitting + + +def track_marker(prj_data: NDArray, marker_vu: NDArray, stack_axis: int = -2) -> NDArray: + """Track marker position in a stack of images. + + Parameters + ---------- + prj_data_vwu : NDArray + The projection data. + marker_vu : NDArray + The fiducial marker to track in VU. + stack_axis : int, optional + The axis along which the images are stacked. The default is -2. + + Returns + ------- + NDArray + List of positions for each image. + """ + marker_v1u = np.expand_dims(marker_vu, stack_axis).astype(np.float32) + marker_pos = fitting.fit_shifts_vu_xc(prj_data, marker_v1u, stack_axis=stack_axis, normalize_fourier=False) + marker_pos = marker_pos.swapaxes(-2, -1) + np.array(marker_vu.shape) / 2 + return marker_pos + + +def create_marker_disk( + data_shape_vu: Union[ArrayLike, NDArray], radius: float, super_sampling: int = 5, conv: bool = True +) -> NDArray: + """ + Create a Disk probe object, that will be used for tracking a calibration object's movement. + + Parameters + ---------- + data_shape_vu : ArrayLike + Shape of the images (vertical, horizontal). + radius : float + Radius of the probe. + super_sampling : int, optional + Super sampling of the coordinates used for creation. The default is 5. + conv : bool, optional + Whether to convolve the initial probe with itself. The default is True. + + Returns + ------- + NDArray + An image of the same size as the projections, that contains the marker in the center. + """ + data_shape_vu = np.array(data_shape_vu, dtype=int) * super_sampling + + # coords = [np.linspace(-(s - 1) / 2, (s - 1) / 2, s, dtype=np.float32) for s in data_shape_vu] + coords = [np.fft.fftfreq(d, 1 / d) for d in data_shape_vu] + coords = np.stack(np.meshgrid(*coords, indexing="ij"), axis=0) + pix_rr = np.sqrt(np.sum(coords**2, axis=0)) + + probe = pix_rr < radius * super_sampling + probe = np.roll(probe, super_sampling // 2, axis=tuple(np.arange(len(data_shape_vu)))) + new_shape = np.stack([data_shape_vu // super_sampling, np.ones_like(data_shape_vu) * super_sampling], axis=1).flatten() + probe = probe.reshape(new_shape) + probe = np.mean(probe, axis=tuple(np.arange(1, len(data_shape_vu) * 2, 2, dtype=int))) + + probe = np.fft.fftshift(probe) + + if conv: + probe = spimg.convolve(probe, probe) + + return probe From 90aab9358f1181ce6b9ec06d1c49a4c1bfa71653 Mon Sep 17 00:00:00 2001 From: Nicola VIGANO Date: Mon, 28 Aug 2023 13:17:38 +0200 Subject: [PATCH 02/26] Switched dims convention to be coherent with other alignment functions Signed-off-by: Nicola VIGANO --- src/corrct/alignment/cone_beam.py | 64 +++++++++++++++++-------- src/corrct/alignment/fitting.py | 79 ++++--------------------------- src/corrct/alignment/markers.py | 3 +- 3 files changed, 53 insertions(+), 93 deletions(-) diff --git a/src/corrct/alignment/cone_beam.py b/src/corrct/alignment/cone_beam.py index 0e8aa24..2019a99 100644 --- a/src/corrct/alignment/cone_beam.py +++ b/src/corrct/alignment/cone_beam.py @@ -26,7 +26,7 @@ def _class_to_json(obj: object) -> str: return json.dumps(obj, default=lambda o: {o.__class__.__name__: o.__dict__}, sort_keys=True, indent=4) -def _diff_axis_angle_rad(center_1_vu: Union[ArrayLike, NDArray], center_2_vu: Union[ArrayLike, NDArray]) -> float: +def _get_rot_axis_angle_rad(center_1_vu: Union[ArrayLike, NDArray], center_2_vu: Union[ArrayLike, NDArray]) -> float: diffs_vu = np.array(center_1_vu) - np.array(center_2_vu) angle_rad = np.arctan2(diffs_vu[-1], diffs_vu[-2]) angle_rad = np.mod(angle_rad, 2 * np.pi) @@ -245,6 +245,7 @@ def __init__( points_ell2: Union[ArrayLike, NDArray], points_axis: Union[ArrayLike, NDArray, None] = None, verbose: bool = True, + plot_result: bool = False, ): """Initialize a cone-beam geometry calibration object. @@ -260,9 +261,12 @@ def __init__( Points of the rotation axis, by default None verbose : bool, optional Whether to produce verbose output, by default True + plot_result : bool, optional + Whether to plot the results of the geometry, by default False + It requires verbose to be True. """ self.prj_size_vu = np.array(prj_size_vu) - self.center_vu = self.prj_size_vu / 2 + self.center_vu = self.prj_size_vu[:, None] / 2 self.points_ell1 = np.array(points_ell1) - self.center_vu self.points_ell2 = np.array(points_ell2) - self.center_vu @@ -274,26 +278,27 @@ def __init__( self.acq_geom = ConeBeamGeometry(det_pix_v=int(self.prj_size_vu[0]), det_pix_u=int(self.prj_size_vu[1])) self.verbose = verbose + self.plot_result = plot_result and verbose self._pre_fit() def _pre_fit(self, use_least_squares: bool = False) -> None: - ell1 = fitting.Ellipse(self.points_ell1) - ell2 = fitting.Ellipse(self.points_ell2) + ell1_acq = fitting.Ellipse(self.points_ell1) + ell2_acq = fitting.Ellipse(self.points_ell2) if self.points_axis is not None: # Using measured projected center, whenever available - self.ell1_prj_center_vu = self.points_axis[0, :] - self.ell2_prj_center_vu = self.points_axis[-1, :] + self.ell1_prj_center_vu = self.points_axis[:, 0] + self.ell2_prj_center_vu = self.points_axis[:, 2] - self.prj_origin_vu = self.points_axis[1, :] + self.prj_origin_vu = self.points_axis[:, 1] else: - self.ell1_prj_center_vu = ell1.fit_prj_center(least_squares=use_least_squares) - self.ell2_prj_center_vu = ell2.fit_prj_center(least_squares=use_least_squares) + self.ell1_prj_center_vu = ell1_acq.fit_prj_center(least_squares=use_least_squares) + self.ell2_prj_center_vu = ell2_acq.fit_prj_center(least_squares=use_least_squares) self.prj_origin_vu = None - self.acq_geom.eta_deg = np.rad2deg(_diff_axis_angle_rad(self.ell1_prj_center_vu, self.ell2_prj_center_vu)) + self.acq_geom.eta_deg = np.rad2deg(_get_rot_axis_angle_rad(self.ell1_prj_center_vu, self.ell2_prj_center_vu)) if self.verbose: print(f"Projected origin on the detector (pix): {self.prj_origin_vu}") @@ -303,18 +308,35 @@ def _pre_fit(self, use_least_squares: bool = False) -> None: rot = Rotation.from_rotvec(-np.deg2rad(self.acq_geom.eta_deg) * np.array([0, 0, 1])) rot_mat = rot.as_matrix()[:2, :2] - self.points_ell1_rot = rot_mat.dot(self.points_ell1.T).T - self.points_ell2_rot = rot_mat.dot(self.points_ell2.T).T + self.points_ell1_rot = rot_mat.dot(self.points_ell1) + self.points_ell2_rot = rot_mat.dot(self.points_ell2) else: self.points_ell1_rot = self.points_ell1.copy() self.points_ell2_rot = self.points_ell2.copy() # Re-instatiate ellipse class, after rotation - ell1 = fitting.Ellipse(self.points_ell1_rot) - ell2 = fitting.Ellipse(self.points_ell2_rot) + ell1_rot = fitting.Ellipse(self.points_ell1_rot) + ell2_rot = fitting.Ellipse(self.points_ell2_rot) - self.ell1_params = ell1.fit_parameters(least_squares=use_least_squares) - self.ell2_params = ell2.fit_parameters(least_squares=use_least_squares) + self.ell1_params = ell1_rot.fit_parameters(least_squares=use_least_squares) + self.ell2_params = ell2_rot.fit_parameters(least_squares=use_least_squares) + + if self.plot_result: + fig, axs = plt.subplots() + axs.plot(self.points_ell1[1, :], self.points_ell1[0, :], "C0--", label="Ellipse 1 - Acquired") + axs.plot(self.points_ell2[1, :], self.points_ell2[0, :], "C1--", label="Ellipse 2 - Acquired") + axs.plot(self.points_ell1_rot[1, :], self.points_ell1_rot[0, :], "C0", label="Ellipse 1 - Rotated") + axs.plot(self.points_ell2_rot[1, :], self.points_ell2_rot[0, :], "C1", label="Ellipse 2 - Rotated") + ell1_acq_params = ell1_acq.fit_parameters(least_squares=use_least_squares) + ell2_acq_params = ell2_acq.fit_parameters(least_squares=use_least_squares) + axs.plot([ell1_acq_params[-1], ell2_acq_params[-1]], [ell1_acq_params[-2], ell2_acq_params[-2]], "C2--") + axs.plot([self.ell1_params[-1], self.ell2_params[-1]], [self.ell1_params[-2], self.ell2_params[-2]], "C2") + if self.points_axis is not None: + axs.scatter(self.points_axis[1], self.points_axis[0], c="C2", marker="*", label="Centers - Acquired") + axs.legend() + axs.grid() + fig.tight_layout() + plt.show(block=False) self.acq_geom.D = self._fit_distance_det2src(self.ell1_params, self.ell2_params) @@ -540,7 +562,7 @@ def __init__( self.curr_pos = 0 if self.ell_params is not None: - us = np.sort(self.positions_vu[:, 1]) + us = np.sort(self.positions_vu[1, :]) self.v_1, self.v_2 = fitting.Ellipse.predict_v(self.ell_params, us) self.fig, self.axs = plt.subplots(1, 3, figsize=cm2inch([36, 12])) # , sharex=True, sharey=True @@ -568,11 +590,11 @@ def update(self) -> None: img.remove() self.axs[1].cla() - self.axs[0].plot(self.positions_vu[:, 1], self.positions_vu[:, 0], "bo-", markersize=4) - self.axs[0].scatter(self.positions_vu[self.curr_pos, 1], self.positions_vu[self.curr_pos, 0], c="r") + self.axs[0].plot(self.positions_vu[1, :], self.positions_vu[0, :], "bo-", markersize=4) + self.axs[0].scatter(self.positions_vu[1, self.curr_pos], self.positions_vu[0, self.curr_pos], c="r") if self.ell_params is not None: - us = np.sort(self.positions_vu[:, 1]) + us = np.sort(self.positions_vu[1, :]) self.axs[0].plot(us, self.v_1, "g") self.axs[0].plot(us, self.v_2, "g") self.axs[0].grid() @@ -585,7 +607,7 @@ def update(self) -> None: vmax = self.imgs[:, self.curr_pos, :].max() img = self.axs[1].imshow(self.imgs[:, self.curr_pos, :], vmin=vmin, vmax=vmax) - self.axs[1].scatter(self.positions_vu[self.curr_pos, 1], self.positions_vu[self.curr_pos, 0], c="r") + self.axs[1].scatter(self.positions_vu[1, self.curr_pos], self.positions_vu[0, self.curr_pos], c="r") self.axs[1].set_title(f"Range: [{vmin}, {vmax}]") # plt.colorbar(im, ax=self.axs[1]) self.fig.canvas.draw() diff --git a/src/corrct/alignment/fitting.py b/src/corrct/alignment/fitting.py index 4b6aebf..3ce14f1 100644 --- a/src/corrct/alignment/fitting.py +++ b/src/corrct/alignment/fitting.py @@ -843,22 +843,22 @@ def fit_prj_center(self, rescale: bool = True, least_squares: bool = True) -> ND ArrayLike The fitted center position. """ - c_vu = np.mean(self.prj_points_vu, axis=0) + c_vu = np.mean(self.prj_points_vu, axis=-1, keepdims=True) pos_vu = self.prj_points_vu - c_vu if rescale: - scale_vu = np.max(pos_vu, axis=0) - np.min(pos_vu, axis=0) + scale_vu = np.max(pos_vu, axis=-1, keepdims=True) - np.min(pos_vu, axis=-1, keepdims=True) pos_vu /= scale_vu else: scale_vu = 1.0 - num_lines = pos_vu.shape[-2] // 2 - pos1_vu = pos_vu[:num_lines, :] - pos2_vu = pos_vu[num_lines : num_lines * 2, :] + num_lines = pos_vu.shape[-1] // 2 + pos1_vu = pos_vu[:, :num_lines] + pos2_vu = pos_vu[:, num_lines : num_lines * 2] diffs_vu = pos2_vu - pos1_vu - b = np.cross(pos1_vu, pos2_vu) - A = np.stack([diffs_vu[:, -1], -diffs_vu[:, -2]], axis=-1) + b = np.cross(pos1_vu, pos2_vu, axis=0) + A = np.stack([diffs_vu[-1, :], -diffs_vu[-2, :]], axis=0) p_vu = np.linalg.lstsq(A, b, rcond=None)[0] if not least_squares: @@ -890,8 +890,8 @@ def fit_parameters(self, rescale: bool = True, least_squares: bool = True) -> ND The fitted ellipse parameters. """ # First we fit 5 intermediate variables - p_u: NDArray = self.prj_points_vu[:, -1] - p_v: NDArray = self.prj_points_vu[:, -2] + p_u: NDArray = self.prj_points_vu[-1, :] + p_v: NDArray = self.prj_points_vu[-2, :] if rescale: c_h = np.mean(p_u) @@ -939,67 +939,6 @@ def _func(pars: NDArrayFloat) -> float: return np.array([b, a, c, v + c_v, u + c_h]) - def fit_ellipse_centroid(self, rescale: bool = True, least_squares: bool = True) -> NDArray: - """ - Fit the ellipse parameters, when assuming the center of mass of the points as center of the ellipse. - - Parameters - ---------- - rescale : bool, optional - Whether to rescale the data within the interval [-1, 1]. The default is True. - least_squares : bool, optional - Whether to use the least-squares (l2-norm) fit or l1-norm. The default is True. - - Returns - ------- - ArrayLike - The fitted ellipse parameters. - """ - # First we fit 3 intermediate variables - num_to_keep = (self.prj_points_vu.shape[0] // 2) * 2 - p_u = self.prj_points_vu[:num_to_keep, -1] - p_v = self.prj_points_vu[:num_to_keep, -2] - - u = np.mean(p_u) - v = np.mean(p_v) - - p_u = p_u - u - p_v = p_v - v - - if rescale: - p_u_scaling = np.abs(p_u).max() - p_v_scaling = np.abs(p_v).max() - p_u /= p_u_scaling - p_v /= p_v_scaling - else: - p_u_scaling = 1.0 - p_v_scaling = 1.0 - - A = np.stack([p_u**2, p_u * p_v, np.ones_like(p_u)], axis=-1) - b = -(p_v**2) - - params = np.linalg.lstsq(A, b, rcond=None)[0] - if not least_squares: - - def _func(pars: NDArrayFloat) -> float: - predicted_b = A.dot(pars) - l1_diff = np.linalg.norm(predicted_b - b, ord=1) - return float(l1_diff) - - opt_params = spopt.minimize(_func, params) - params = opt_params.x - - if rescale: - params[0] *= (p_v_scaling**2) / (p_u_scaling**2) - params[1] *= p_v_scaling / p_u_scaling - params[2] *= p_v_scaling**2 - - a = -params[0] / params[2] - b = -1 / params[2] - c = -params[1] / params[2] - - return np.array([b, a, c, v, u]) - @staticmethod def predict_v(ell_params: Union[ArrayLike, NDArray], uus: Union[ArrayLike, NDArray]) -> tuple[NDArray, NDArray]: """Predict V coordinates of ellipse from its parameters, and U coordinates. diff --git a/src/corrct/alignment/markers.py b/src/corrct/alignment/markers.py index c500826..7d5ab81 100644 --- a/src/corrct/alignment/markers.py +++ b/src/corrct/alignment/markers.py @@ -34,8 +34,7 @@ def track_marker(prj_data: NDArray, marker_vu: NDArray, stack_axis: int = -2) -> """ marker_v1u = np.expand_dims(marker_vu, stack_axis).astype(np.float32) marker_pos = fitting.fit_shifts_vu_xc(prj_data, marker_v1u, stack_axis=stack_axis, normalize_fourier=False) - marker_pos = marker_pos.swapaxes(-2, -1) + np.array(marker_vu.shape) / 2 - return marker_pos + return marker_pos + np.array(marker_vu.shape)[:, None] / 2 def create_marker_disk( From 092924fa2f8500d37f469815e79b44ccaaadc6c6 Mon Sep 17 00:00:00 2001 From: Nicola VIGANO Date: Mon, 28 Aug 2023 15:32:10 +0200 Subject: [PATCH 03/26] Cone-beam: refactored Ellipse class, and reorganized visualizer Signed-off-by: Nicola VIGANO --- src/corrct/alignment/cone_beam.py | 158 ++-------------------- src/corrct/alignment/fitting.py | 213 +++++++++++++++++------------- src/corrct/alignment/markers.py | 136 +++++++++++++++++++ 3 files changed, 268 insertions(+), 239 deletions(-) diff --git a/src/corrct/alignment/cone_beam.py b/src/corrct/alignment/cone_beam.py index 2019a99..4e70ab3 100644 --- a/src/corrct/alignment/cone_beam.py +++ b/src/corrct/alignment/cone_beam.py @@ -283,8 +283,8 @@ def __init__( self._pre_fit() def _pre_fit(self, use_least_squares: bool = False) -> None: - ell1_acq = fitting.Ellipse(self.points_ell1) - ell2_acq = fitting.Ellipse(self.points_ell2) + self.ell1_acq = fitting.Ellipse(self.points_ell1, least_squares=use_least_squares) + self.ell2_acq = fitting.Ellipse(self.points_ell2, least_squares=use_least_squares) if self.points_axis is not None: # Using measured projected center, whenever available @@ -293,8 +293,8 @@ def _pre_fit(self, use_least_squares: bool = False) -> None: self.prj_origin_vu = self.points_axis[:, 1] else: - self.ell1_prj_center_vu = ell1_acq.fit_prj_center(least_squares=use_least_squares) - self.ell2_prj_center_vu = ell2_acq.fit_prj_center(least_squares=use_least_squares) + self.ell1_prj_center_vu = self.ell1_acq.center_vu + self.ell2_prj_center_vu = self.ell2_acq.center_vu self.prj_origin_vu = None @@ -315,11 +315,8 @@ def _pre_fit(self, use_least_squares: bool = False) -> None: self.points_ell2_rot = self.points_ell2.copy() # Re-instatiate ellipse class, after rotation - ell1_rot = fitting.Ellipse(self.points_ell1_rot) - ell2_rot = fitting.Ellipse(self.points_ell2_rot) - - self.ell1_params = ell1_rot.fit_parameters(least_squares=use_least_squares) - self.ell2_params = ell2_rot.fit_parameters(least_squares=use_least_squares) + self.ell1_rot = fitting.Ellipse(self.points_ell1_rot, least_squares=use_least_squares) + self.ell2_rot = fitting.Ellipse(self.points_ell2_rot, least_squares=use_least_squares) if self.plot_result: fig, axs = plt.subplots() @@ -327,10 +324,8 @@ def _pre_fit(self, use_least_squares: bool = False) -> None: axs.plot(self.points_ell2[1, :], self.points_ell2[0, :], "C1--", label="Ellipse 2 - Acquired") axs.plot(self.points_ell1_rot[1, :], self.points_ell1_rot[0, :], "C0", label="Ellipse 1 - Rotated") axs.plot(self.points_ell2_rot[1, :], self.points_ell2_rot[0, :], "C1", label="Ellipse 2 - Rotated") - ell1_acq_params = ell1_acq.fit_parameters(least_squares=use_least_squares) - ell2_acq_params = ell2_acq.fit_parameters(least_squares=use_least_squares) - axs.plot([ell1_acq_params[-1], ell2_acq_params[-1]], [ell1_acq_params[-2], ell2_acq_params[-2]], "C2--") - axs.plot([self.ell1_params[-1], self.ell2_params[-1]], [self.ell1_params[-2], self.ell2_params[-2]], "C2") + axs.plot([self.ell1_acq.u, self.ell2_acq.u], [self.ell1_acq.v, self.ell2_acq.v], "C2--") + axs.plot([self.ell1_rot.u, self.ell2_rot.u], [self.ell1_rot.v, self.ell2_rot.v], "C2") if self.points_axis is not None: axs.scatter(self.points_axis[1], self.points_axis[0], c="C2", marker="*", label="Centers - Acquired") axs.legend() @@ -338,7 +333,7 @@ def _pre_fit(self, use_least_squares: bool = False) -> None: fig.tight_layout() plt.show(block=False) - self.acq_geom.D = self._fit_distance_det2src(self.ell1_params, self.ell2_params) + self.acq_geom.D = self._fit_distance_det2src(self.ell1_rot, self.ell2_rot) if self.verbose: print(f"Fitted detector distance from source (pix): {self.acq_geom.D}") @@ -359,8 +354,8 @@ def fit(self, r: float, e: float = 1) -> ConeBeamGeometry: ValueError In case of flipped ellipses. """ - b1, a1, c1, v1, u1 = self.ell1_params - b2, a2, c2, v2, u2 = self.ell2_params + b1, a1, c1, v1, u1 = self.ell1_rot.parameters + b2, a2, c2, v2, u2 = self.ell2_rot.parameters sign_z1 = -1 sign_z2 = sign_z1 * -e @@ -430,11 +425,9 @@ def get_zeta(bk, ak, ck, D, sign_zk) -> float: return self.acq_geom @staticmethod - def _fit_distance_det2src( - ellipse_1: Union[ArrayLike, NDArray], ellipse_2: Union[ArrayLike, NDArray], e: float = 1 - ) -> float: - b1, a1, c1, v1, _ = np.array(ellipse_1) - b2, a2, c2, v2, _ = np.array(ellipse_2) + def _fit_distance_det2src(ellipse_1: fitting.Ellipse, ellipse_2: fitting.Ellipse, e: float = 1) -> float: + b1, a1, c1, v1, _ = ellipse_1.parameters + b2, a2, c2, v2, _ = ellipse_2.parameters ecc2 = np.sqrt(b2 - c2**2 / a2) ecc1 = np.sqrt(b1 - c1**2 / a1) @@ -522,126 +515,3 @@ def tune_acquisition_geometry( acq_geom_tuned = acq_geom_tuned.update(par_name, min_par) return acq_geom_tuned - - -def cm2inch(dims: Union[ArrayLike, NDArray]) -> tuple[float]: - """Convert cm into inch. - - Parameters - ---------- - dims : Union[ArrayLike, NDArray] - The dimentions of the object in cm - - Returns - ------- - tuple[float] - The output dimensions in inch - """ - return tuple(np.array(dims) / 2.54) - - -class MarkerVisualizer: - """Plotting class to assess the calibration quality.""" - - def __init__( - self, - fitted_positions_vu: Union[ArrayLike, NDArray], - imgs: NDArray, - disk: NDArray, - ell_params: Union[ArrayLike, NDArray, None] = None, - ) -> None: - self.positions_vu = np.array(fitted_positions_vu) - self.imgs = imgs - self.disk = disk - self.global_lims = False - - if ell_params is not None: - ell_params = np.array(ell_params) - self.ell_params = ell_params - - self.curr_pos = 0 - - if self.ell_params is not None: - us = np.sort(self.positions_vu[1, :]) - self.v_1, self.v_2 = fitting.Ellipse.predict_v(self.ell_params, us) - - self.fig, self.axs = plt.subplots(1, 3, figsize=cm2inch([36, 12])) # , sharex=True, sharey=True - self.axs[2].imshow(self.disk) - self.axs[0].set_xlim(0, self.imgs.shape[-1]) - self.axs[0].set_ylim(self.imgs.shape[-3], 0) - self.fig.tight_layout() - self.update() - - self.fig.canvas.mpl_connect("key_press_event", self._key_event) - self.fig.canvas.mpl_connect("scroll_event", self._scroll_event) - - def update(self) -> None: - self.curr_pos = self.curr_pos % self.imgs.shape[-2] - - for img in self.axs[0].get_images(): - img.remove() - x_lims = self.axs[0].get_xlim() - y_lims = self.axs[0].get_ylim() - self.axs[0].cla() - self.axs[0].set_xlim(x_lims[0], x_lims[1]) - self.axs[0].set_ylim(y_lims[0], y_lims[1]) - - for img in self.axs[1].get_images(): - img.remove() - self.axs[1].cla() - - self.axs[0].plot(self.positions_vu[1, :], self.positions_vu[0, :], "bo-", markersize=4) - self.axs[0].scatter(self.positions_vu[1, self.curr_pos], self.positions_vu[0, self.curr_pos], c="r") - - if self.ell_params is not None: - us = np.sort(self.positions_vu[1, :]) - self.axs[0].plot(us, self.v_1, "g") - self.axs[0].plot(us, self.v_2, "g") - self.axs[0].grid() - - if self.global_lims: - vmin = self.imgs.min() - vmax = self.imgs.max() - else: - vmin = self.imgs[:, self.curr_pos, :].min() - vmax = self.imgs[:, self.curr_pos, :].max() - - img = self.axs[1].imshow(self.imgs[:, self.curr_pos, :], vmin=vmin, vmax=vmax) - self.axs[1].scatter(self.positions_vu[1, self.curr_pos], self.positions_vu[0, self.curr_pos], c="r") - self.axs[1].set_title(f"Range: [{vmin}, {vmax}]") - # plt.colorbar(im, ax=self.axs[1]) - self.fig.canvas.draw() - - def _key_event(self, evnt) -> None: - if evnt.key == "right": - self.curr_pos += 1 - elif evnt.key == "left": - self.curr_pos -= 1 - elif evnt.key == "up": - self.curr_pos += 1 - elif evnt.key == "down": - self.curr_pos -= 1 - elif evnt.key == "pageup": - self.curr_pos += 10 - elif evnt.key == "pagedown": - self.curr_pos -= 10 - elif evnt.key == "escape": - plt.close(self.fig) - elif evnt.key == "ctrl+l": - self.global_lims = not self.global_lims - else: - print(evnt.key) - return - - self.update() - - def _scroll_event(self, evnt) -> None: - if evnt.button == "up": - self.curr_pos += 1 - elif evnt.button == "down": - self.curr_pos -= 1 - else: - print(evnt.key) - return - - self.update() diff --git a/src/corrct/alignment/fitting.py b/src/corrct/alignment/fitting.py index 3ce14f1..5390f53 100644 --- a/src/corrct/alignment/fitting.py +++ b/src/corrct/alignment/fitting.py @@ -8,6 +8,7 @@ and ESRF - The European Synchrotron, Grenoble, France """ +from abc import ABC, abstractmethod from collections.abc import Sequence import matplotlib.pyplot as plt @@ -458,7 +459,7 @@ def f(apb: NDArrayFloat) -> float: return float(l1_diff) apb = spopt.minimize(f, np.array([a, p, b])) - (a, p, b) = apb.x + a, p, b = apb.x return a, p, b @@ -809,44 +810,108 @@ def to_fit(vals): return min_fx, min_f_val, (coeffs, fx_fit) -class Ellipse: - """ - Initialize ellipse class, used for fitting acquisition geometry parameters. +class Trajectory(ABC): + """Base trajectory class.""" - Parameters - ---------- - prj_points_vu : ArrayLike - List ofprojected positions over the detector of a test object. - prj_center_vu : Optional[ArrayLike], optional - Projected position of the rotation center. The default is None. - """ + @abstractmethod + def __call__(self, uus: Union[ArrayLike, NDArray]) -> Sequence[NDArray]: + """Compute V coordinates, given V coordinates. - def __init__(self, prj_points_vu: Union[ArrayLike, NDArray], prj_center_vu: Union[ArrayLike, NDArray, None] = None): - self.prj_points_vu = np.array(prj_points_vu) - if prj_center_vu is not None: - prj_center_vu = np.array(prj_center_vu) - self.prj_center_vu = prj_center_vu + Parameters + ---------- + uus : Union[ArrayLike, NDArray] + The U coordinates - def fit_prj_center(self, rescale: bool = True, least_squares: bool = True) -> NDArray: + Returns + ------- + Sequence[NDArray] + Corresponding V coordiantes, given the multiplicity of the trajectory """ - Fit the projected circle position. + + +class Ellipse(Trajectory): + """Elliptic trajectory class.""" + + a: float + b: float + c: float + u: float + v: float + + c_vu: NDArrayFloat + + def __init__(self, prj_points_vu: Union[ArrayLike, NDArray], rescale: bool = True, least_squares: bool = True): + """Initialize ellipse class. Parameters ---------- + prj_points_vu : ArrayLike | NDArray + List of sampled points over the trajectory. rescale : bool, optional Whether to rescale the data within the interval [-1, 1]. The default is True. least_squares : bool, optional Whether to use the least-squares (l2-norm) fit or l1-norm. The default is True. + """ + self.prj_points_vu = np.array(prj_points_vu) + + self.rescale = rescale + self.least_squares = least_squares + + self._fit_center() + self._fit_parameters() + + @property + def center_vu(self) -> NDArray: + """Return the fitted ellipse center. Returns ------- - ArrayLike + NDArray The fitted center position. """ + return self.c_vu + + @property + def parameters(self) -> NDArray: + """Return the fitted ellipse parameters. + + Returns + ------- + NDArray + The fitted ellipse parameters. + """ + return np.array([self.b, self.a, self.c, self.v, self.u]) + + def __call__(self, uus: Union[ArrayLike, NDArray]) -> Sequence[NDArray]: + """Predict V coordinates of ellipse from its parameters, and U coordinates. + + Parameters + ---------- + uus : Union[ArrayLike, NDArray] + The U coordinates + + Returns + ------- + tuple[NDArray, NDArray] + The corresponding top and bottom V coordinates + """ + b, a, c, v, u = np.array(self.parameters) + uus = np.array(uus) + + a_tilde = b + b_tilde = 2 * (-b * v + c * uus - c * u) + c_tilde = -(1 - a * (uus - u) ** 2 - b * v**2 + 2 * c * v * (uus - u)) + delta_tilde = np.sqrt(b_tilde**2 - 4 * a_tilde * c_tilde) + v_1 = (-b_tilde + delta_tilde) / (2 * a_tilde) + v_2 = (-b_tilde - delta_tilde) / (2 * a_tilde) + + return v_1, v_2 + + def _fit_center(self) -> None: c_vu = np.mean(self.prj_points_vu, axis=-1, keepdims=True) pos_vu = self.prj_points_vu - c_vu - if rescale: + if self.rescale: scale_vu = np.max(pos_vu, axis=-1, keepdims=True) - np.min(pos_vu, axis=-1, keepdims=True) pos_vu /= scale_vu else: @@ -857,46 +922,31 @@ def fit_prj_center(self, rescale: bool = True, least_squares: bool = True) -> ND pos2_vu = pos_vu[:, num_lines : num_lines * 2] diffs_vu = pos2_vu - pos1_vu - b = np.cross(pos1_vu, pos2_vu, axis=0) - A = np.stack([diffs_vu[-1, :], -diffs_vu[-2, :]], axis=0) + vandermonde = np.stack([diffs_vu[-1, :], -diffs_vu[-2, :]], axis=-1) + values = np.cross(pos1_vu, pos2_vu, axis=0) - p_vu = np.linalg.lstsq(A, b, rcond=None)[0] - if not least_squares: + p_vu = np.linalg.lstsq(vandermonde, values, rcond=None)[0] + if not self.least_squares: def _func(params: NDArrayFloat) -> float: - predicted_b = A.dot(params) - l1_diff = np.linalg.norm(predicted_b - b, ord=1) + predicted_values = vandermonde.dot(params) + l1_diff = np.linalg.norm(predicted_values - values, ord=1) return float(l1_diff) opt_p_vu = spopt.minimize(_func, p_vu) p_vu = opt_p_vu.x - return p_vu * scale_vu + c_vu - - def fit_parameters(self, rescale: bool = True, least_squares: bool = True) -> NDArray: - """ - Fit the ellipse parameters. - - Parameters - ---------- - rescale : bool, optional - Whether to rescale the data within the interval [-1, 1]. The default is True. - least_squares : bool, optional - Whether to use the least-squares (l2-norm) fit or l1-norm. The default is True. + self.c_vu = p_vu * scale_vu + c_vu - Returns - ------- - ArrayLike - The fitted ellipse parameters. - """ + def _fit_parameters(self) -> None: # First we fit 5 intermediate variables p_u: NDArray = self.prj_points_vu[-1, :] p_v: NDArray = self.prj_points_vu[-2, :] - if rescale: - c_h = np.mean(p_u) + if self.rescale: + c_u = np.mean(p_u) c_v = np.mean(p_v) - p_u = p_u - c_h + p_u = p_u - c_u p_v = p_v - c_v p_u_scaling = np.abs(p_u).max() @@ -904,65 +954,38 @@ def fit_parameters(self, rescale: bool = True, least_squares: bool = True) -> ND p_u /= p_u_scaling p_v /= p_v_scaling else: - c_h = 0.0 + c_u = 0.0 c_v = 0.0 p_u_scaling = 1.0 p_v_scaling = 1.0 - A = np.stack([p_u**2, -2 * p_u, -2 * p_v, 2 * p_u * p_v, np.ones_like(p_u)], axis=-1) - b = -(p_v**2) + vandermonde = np.stack([p_u**2, -2 * p_u, -2 * p_v, 2 * p_u * p_v, np.ones_like(p_u)], axis=-1) + values = -(p_v**2) - params = np.linalg.lstsq(A, b, rcond=None)[0] - if not least_squares: + coeffs = np.linalg.lstsq(vandermonde, values, rcond=None)[0] + if not self.least_squares: def _func(pars: NDArrayFloat) -> float: - predicted_b = A.dot(pars) - l1_diff = np.linalg.norm(predicted_b - b, ord=1) + predicted_b = vandermonde.dot(pars) + l1_diff = np.linalg.norm(predicted_b - values, ord=1) return float(l1_diff) - opt_params = spopt.minimize(_func, params) - params = opt_params.x + opt_params = spopt.minimize(_func, coeffs) + coeffs = opt_params.x - if rescale: - params[0] *= (p_v_scaling**2) / (p_u_scaling**2) - params[1] *= (p_v_scaling**2) / p_u_scaling - params[2] *= p_v_scaling - params[3] *= p_v_scaling / p_u_scaling - params[4] *= p_v_scaling**2 + if self.rescale: + coeffs[0] *= (p_v_scaling**2) / (p_u_scaling**2) + coeffs[1] *= (p_v_scaling**2) / p_u_scaling + coeffs[2] *= p_v_scaling + coeffs[3] *= p_v_scaling / p_u_scaling + coeffs[4] *= p_v_scaling**2 - u = (params[1] - params[2] * params[3]) / (params[0] - params[3] ** 2) - v = (params[0] * params[2] - params[1] * params[3]) / (params[0] - params[2] * params[3]) + self.u = (coeffs[1] - coeffs[2] * coeffs[3]) / (coeffs[0] - coeffs[3] ** 2) + self.v = (coeffs[0] * coeffs[2] - coeffs[1] * coeffs[3]) / (coeffs[0] - coeffs[2] * coeffs[3]) - a = params[0] / (params[0] * u**2 + v**2 + 2 * params[3] * u * v - params[4]) - b = a / params[0] - c = params[3] * b + self.a = coeffs[0] / (coeffs[0] * self.u**2 + self.v**2 + 2 * coeffs[3] * self.u * self.v - coeffs[4]) + self.b = self.a / coeffs[0] + self.c = coeffs[3] * self.b - return np.array([b, a, c, v + c_v, u + c_h]) - - @staticmethod - def predict_v(ell_params: Union[ArrayLike, NDArray], uus: Union[ArrayLike, NDArray]) -> tuple[NDArray, NDArray]: - """Predict V coordinates of ellipse from its parameters, and U coordinates. - - Parameters - ---------- - ell_params : Union[ArrayLike, NDArray] - The ellipse parameters - uus : Union[ArrayLike, NDArray] - The U coordinates - - Returns - ------- - tuple[NDArray, NDArray] - The corresponding top and bottom V coordinates - """ - b, a, c, v, u = np.array(ell_params) - uus = np.array(uus) - - a_tilde = b - b_tilde = 2 * (-b * v + c * uus - c * u) - c_tilde = -(1 - a * (uus - u) ** 2 - b * v**2 + 2 * c * v * (uus - u)) - delta_tilde = np.sqrt(b_tilde**2 - 4 * a_tilde * c_tilde) - v_1 = (-b_tilde + delta_tilde) / (2 * a_tilde) - v_2 = (-b_tilde - delta_tilde) / (2 * a_tilde) - - return v_1, v_2 + self.u += c_u + self.v += c_v diff --git a/src/corrct/alignment/markers.py b/src/corrct/alignment/markers.py index 7d5ab81..1aff9d6 100644 --- a/src/corrct/alignment/markers.py +++ b/src/corrct/alignment/markers.py @@ -12,9 +12,27 @@ import scipy.ndimage as spimg from numpy.typing import ArrayLike, NDArray +import matplotlib.pyplot as plt + from . import fitting +def cm2inch(dims: Union[ArrayLike, NDArray]) -> tuple[float]: + """Convert cm into inch. + + Parameters + ---------- + dims : Union[ArrayLike, NDArray] + The dimentions of the object in cm + + Returns + ------- + tuple[float] + The output dimensions in inch + """ + return tuple(np.array(dims) / 2.54) + + def track_marker(prj_data: NDArray, marker_vu: NDArray, stack_axis: int = -2) -> NDArray: """Track marker position in a stack of images. @@ -78,3 +96,121 @@ def create_marker_disk( probe = spimg.convolve(probe, probe) return probe + + +class MarkerTrackingVisualizer: + """Plotting class to assess the marker tracking quality.""" + + def __init__( + self, + fitted_positions_vu: Union[ArrayLike, NDArray], + images: NDArray, + marker: NDArray, + trajectory: Union[fitting.Trajectory, None] = None, + ) -> None: + """Initialize the visualization utility for checking the marker position fitting. + + Parameters + ---------- + fitted_positions_vu : Union[ArrayLike, NDArray] + The fitted positions of the marker + imgs : NDArray + The original images + disk : NDArray + The marker image + trajectory : Union[fitting.Trajectory, None], optional + The trajectory object that the points are supposed to follow, by default None + """ + self.positions_vu = np.array(fitted_positions_vu) + self.images = images + self.marker = marker + self.global_lims = False + + self.trajectory = trajectory + + self.curr_pos = 0 + + if self.trajectory is not None: + uus = np.sort(self.positions_vu[1, :]) + self.vvs = self.trajectory(uus) + + self.fig, self.axs = plt.subplots(1, 3, figsize=cm2inch([36, 12])) # , sharex=True, sharey=True + self.axs[2].imshow(self.marker) + self.axs[0].set_xlim(0, self.images.shape[-1]) + self.axs[0].set_ylim(self.images.shape[-3], 0) + self.fig.tight_layout() + self._update() + + self.fig.canvas.mpl_connect("key_press_event", self._key_event) + self.fig.canvas.mpl_connect("scroll_event", self._scroll_event) + + def _update(self) -> None: + self.curr_pos = self.curr_pos % self.images.shape[-2] + + for img in self.axs[0].get_images(): + img.remove() + x_lims = self.axs[0].get_xlim() + y_lims = self.axs[0].get_ylim() + self.axs[0].cla() + self.axs[0].set_xlim(x_lims[0], x_lims[1]) + self.axs[0].set_ylim(y_lims[0], y_lims[1]) + + for img in self.axs[1].get_images(): + img.remove() + self.axs[1].cla() + + self.axs[0].plot(self.positions_vu[1, :], self.positions_vu[0, :], "bo-", markersize=4) + self.axs[0].scatter(self.positions_vu[1, self.curr_pos], self.positions_vu[0, self.curr_pos], c="r") + + if self.trajectory is not None: + uus = np.sort(self.positions_vu[1, :]) + for vvs in self.vvs: + self.axs[0].plot(uus, vvs, "g") + self.axs[0].grid() + + if self.global_lims: + vmin = self.images.min() + vmax = self.images.max() + else: + vmin = self.images[:, self.curr_pos, :].min() + vmax = self.images[:, self.curr_pos, :].max() + + img = self.axs[1].imshow(self.images[:, self.curr_pos, :], vmin=vmin, vmax=vmax) + self.axs[1].scatter(self.positions_vu[1, self.curr_pos], self.positions_vu[0, self.curr_pos], c="r") + self.axs[1].set_title(f"Range: [{vmin}, {vmax}]") + # plt.colorbar(im, ax=self.axs[1]) + self.fig.canvas.draw() + + def _key_event(self, evnt) -> None: + if evnt.key == "right": + self.curr_pos += 1 + elif evnt.key == "left": + self.curr_pos -= 1 + elif evnt.key == "up": + self.curr_pos += 1 + elif evnt.key == "down": + self.curr_pos -= 1 + elif evnt.key == "pageup": + self.curr_pos += 10 + elif evnt.key == "pagedown": + self.curr_pos -= 10 + elif evnt.key == "escape": + plt.close(self.fig) + elif evnt.key == "ctrl+l": + self.global_lims = not self.global_lims + else: + print(evnt.key) + return + + self._update() + + def _scroll_event(self, evnt) -> None: + if evnt.button == "up": + self.curr_pos += 1 + elif evnt.button == "down": + self.curr_pos -= 1 + else: + print(evnt.key) + return + + self._update() From 1e0ade4485f3aade4f46c679af8107f9d7a9880f Mon Sep 17 00:00:00 2001 From: Nicola VIGANO Date: Sun, 3 Sep 2023 23:44:08 +0200 Subject: [PATCH 04/26] Linting fixes Signed-off-by: Nicola VIGANO --- src/corrct/alignment/cone_beam.py | 51 ++++++++++++++++++------------- 1 file changed, 30 insertions(+), 21 deletions(-) diff --git a/src/corrct/alignment/cone_beam.py b/src/corrct/alignment/cone_beam.py index 4e70ab3..3e9e321 100644 --- a/src/corrct/alignment/cone_beam.py +++ b/src/corrct/alignment/cone_beam.py @@ -62,10 +62,10 @@ def __str__(self) -> str: The human readable representation of the object. """ descr = "AcquisitionGeometry(\n" - for f, v in self.__dict__.items(): - descr += f" {f} = {v}" - if f.lower()[-3:] == "rad": - descr += f" ({np.rad2deg(v)} deg)" + for field, value in self.__dict__.items(): + descr += f" {field} = {value}" + if field.lower()[-3:] == "deg": + descr += f" ({value} deg)" descr += ",\n" return descr + ")" @@ -203,7 +203,7 @@ def to_json(self) -> str: """ return _class_to_json(self) - def from_json(self, data: str) -> None: + def from_json(self, data_json: str) -> None: """ Load instance from JSON. @@ -217,17 +217,20 @@ def from_json(self, data: str) -> None: ValueError In case we were to load more than one instance, or different classes. """ - d = json.loads(data) - if len(d.keys()) > 1: + data_tree = json.loads(data_json) + if len(data_tree.keys()) > 1: raise ValueError("Initialization from JSON: More than one class instance passed.") - class_name = list(d.keys())[0] - if list(d.keys())[0] != self.__class__.__name__: + + class_name = list(data_tree.keys())[0] + if list(data_tree.keys())[0] != self.__class__.__name__: raise ValueError( - f"Initialization from JSON: expecting {self.__class__.__name__} class instance, but {d.keys()[0]} passed." + f"Initialization from JSON: expecting {self.__class__.__name__} class instance," + f" but {data_tree.keys()[0]} passed." ) - d = d[class_name] - for k in self.__dict__.keys(): - self.__dict__[k] = d[k] + + data_dict = data_tree[class_name] + for key in self.__dict__.keys(): + self.__dict__[key] = data_dict[key] class FitConeBeamGeometry: @@ -238,6 +241,8 @@ class FitConeBeamGeometry: doi: 10.1088/0031-9155/45/11/327 """ + acq_geom: ConeBeamGeometry + def __init__( self, prj_size_vu: Union[ArrayLike, NDArray], @@ -267,6 +272,7 @@ def __init__( """ self.prj_size_vu = np.array(prj_size_vu) self.center_vu = self.prj_size_vu[:, None] / 2 + self.prj_origin_vu = None self.points_ell1 = np.array(points_ell1) - self.center_vu self.points_ell2 = np.array(points_ell2) - self.center_vu @@ -280,9 +286,12 @@ def __init__( self.verbose = verbose self.plot_result = plot_result and verbose - self._pre_fit() + self.z1 = np.array([]) + self.z2 = np.array([]) + + self._initialize() - def _pre_fit(self, use_least_squares: bool = False) -> None: + def _initialize(self, use_least_squares: bool = False) -> None: self.ell1_acq = fitting.Ellipse(self.points_ell1, least_squares=use_least_squares) self.ell2_acq = fitting.Ellipse(self.points_ell2, least_squares=use_least_squares) @@ -398,18 +407,18 @@ def get_zeta(bk, ak, ck, D, sign_zk) -> float: self.acq_geom.phi_deg = np.rad2deg(np.arcsin(-c1 / (2 * a1) * zeta1 - c2 / (2 * a2) * zeta2)) - R1 = r / rho1 - R2 = r / rho2 + R_e1 = r / rho1 + R_e2 = r / rho2 - self.z1 = R1 * zeta1 - self.z2 = R2 * zeta2 + self.z1 = R_e1 * zeta1 + self.z2 = R_e2 * zeta2 z_full = self.z1 - self.z2 self.acq_geom.theta_deg = 0.0 # self.acq_geom.theta_rad = np.arcsin((R1 - R2) / z_full) / np.mean(self.prj_size_vu) - self.acq_geom.R = (-self.z2 * R1 + self.z1 * R2) / z_full + self.acq_geom.R = (-self.z2 * R_e1 + self.z1 * R_e2) / z_full if self.prj_origin_vu is None: self.prj_origin_vu = (-self.z2 * self.ell1_prj_center_vu + self.z1 * self.ell2_prj_center_vu) / z_full @@ -417,7 +426,7 @@ def get_zeta(bk, ak, ck, D, sign_zk) -> float: print(f"Projected origin on the detector (pix): {self.prj_origin_vu}") if self.verbose: - print(f"Fitted distances between source and rotation axis (pix):\n- R1={R1}, R={self.acq_geom.R}, R2={R2}") + print(f"Fitted distances between source and rotation axis (pix):\n- R1={R_e1}, R={self.acq_geom.R}, R2={R_e2}") print(f"Fitted heights of the two ellipses, with respect to the source (pix): z1={self.z1}, z2={self.z2}") print(f"Fitted polar angle of the detector (phi deg): {self.acq_geom.phi_deg}") print(f"Fitted azimuthal angle of the detector (theta deg): {self.acq_geom.theta_deg}") From 057064be39388e8d0d109533c6f6bf0939d943fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicola=20VIGAN=C3=92?= Date: Mon, 18 May 2026 01:35:08 +0200 Subject: [PATCH 05/26] Modernized code to Python 3.10 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Nicola VIGANÒ --- src/corrct/alignment/cone_beam.py | 59 ++++---- src/corrct/alignment/fitting.py | 241 ++++++++++++++++++------------ src/corrct/alignment/markers.py | 37 +++-- 3 files changed, 193 insertions(+), 144 deletions(-) diff --git a/src/corrct/alignment/cone_beam.py b/src/corrct/alignment/cone_beam.py index 3e9e321..6f3bf74 100644 --- a/src/corrct/alignment/cone_beam.py +++ b/src/corrct/alignment/cone_beam.py @@ -9,24 +9,23 @@ import json from dataclasses import dataclass from dataclasses import replace as dc_replace -from typing import Sequence, Union +from collections.abc import Sequence import matplotlib.pyplot as plt import numpy as np +from numpy.typing import NDArray from scipy.spatial.transform import Rotation -from numpy.typing import ArrayLike, NDArray from tqdm import tqdm -from . import fitting - from .. import models, projectors, solvers +from . import fitting def _class_to_json(obj: object) -> str: return json.dumps(obj, default=lambda o: {o.__class__.__name__: o.__dict__}, sort_keys=True, indent=4) -def _get_rot_axis_angle_rad(center_1_vu: Union[ArrayLike, NDArray], center_2_vu: Union[ArrayLike, NDArray]) -> float: +def _get_rot_axis_angle_rad(center_1_vu: Sequence[float] | NDArray, center_2_vu: Sequence[float] | NDArray) -> float: diffs_vu = np.array(center_1_vu) - np.array(center_2_vu) angle_rad = np.arctan2(diffs_vu[-1], diffs_vu[-2]) angle_rad = np.mod(angle_rad, 2 * np.pi) @@ -145,7 +144,7 @@ def get_vol_geom(self, up_sampling: int = 1) -> models.VolumeGeometry: vox_size=1 / up_sampling, ) - def update(self, field: str, val: float, is_relative: bool = True, decimals: Union[int, None] = 3) -> "ConeBeamGeometry": + def update(self, field: str, val: float, is_relative: bool = True, decimals: int | None = 3) -> "ConeBeamGeometry": """ Return a copy of the original data, with a replaced field. @@ -171,7 +170,7 @@ def update(self, field: str, val: float, is_relative: bool = True, decimals: Uni return dc_replace(self, **{field: new_val}) def get_tuning_params( - self, field: str, val_range: Union[ArrayLike, NDArray], is_relative: bool = True + self, field: str, val_range: Sequence[float] | NDArray, is_relative: bool = True ) -> Sequence["ConeBeamGeometry"]: """ Generate sequences of acquisition geometries, with a slight variation over a field's value. @@ -180,7 +179,7 @@ def get_tuning_params( ---------- field : str The field to tune. - val_range : ArrayLike + val_range : Sequence[float] | NDArray The value range. is_relative : bool, optional Whether the values are relative. The default is True. @@ -245,10 +244,10 @@ class FitConeBeamGeometry: def __init__( self, - prj_size_vu: Union[ArrayLike, NDArray], - points_ell1: Union[ArrayLike, NDArray], - points_ell2: Union[ArrayLike, NDArray], - points_axis: Union[ArrayLike, NDArray, None] = None, + prj_size_vu: Sequence[int] | NDArray, + points_ell1: Sequence[Sequence[float]] | NDArray, + points_ell2: Sequence[Sequence[float]] | NDArray, + points_axis: Sequence[Sequence[float]] | NDArray | None = None, verbose: bool = True, plot_result: bool = False, ): @@ -256,13 +255,13 @@ def __init__( Parameters ---------- - prj_size_vu : Union[ArrayLike, NDArray] + prj_size_vu : Sequence[int] | NDArray Size of the projections. - points_ell1 : Union[ArrayLike, NDArray] + points_ell1 : Sequence[Sequence[float]] | NDArray Points of first ellipse. - points_ell2 : Union[ArrayLike, NDArray] + points_ell2 : Sequence[Sequence[float]] | NDArray Points of second ellipse. - points_axis : Union[ArrayLike, NDArray, None], optional + points_axis : Sequence[Sequence[float]] | NDArray | None, optional Points of the rotation axis, by default None verbose : bool, optional Whether to produce verbose output, by default True @@ -292,8 +291,8 @@ def __init__( self._initialize() def _initialize(self, use_least_squares: bool = False) -> None: - self.ell1_acq = fitting.Ellipse(self.points_ell1, least_squares=use_least_squares) - self.ell2_acq = fitting.Ellipse(self.points_ell2, least_squares=use_least_squares) + self.ell1_acq = fitting.Ellipse(self.points_ell1, use_least_squares=use_least_squares) + self.ell2_acq = fitting.Ellipse(self.points_ell2, use_least_squares=use_least_squares) if self.points_axis is not None: # Using measured projected center, whenever available @@ -323,9 +322,9 @@ def _initialize(self, use_least_squares: bool = False) -> None: self.points_ell1_rot = self.points_ell1.copy() self.points_ell2_rot = self.points_ell2.copy() - # Re-instatiate ellipse class, after rotation - self.ell1_rot = fitting.Ellipse(self.points_ell1_rot, least_squares=use_least_squares) - self.ell2_rot = fitting.Ellipse(self.points_ell2_rot, least_squares=use_least_squares) + # Re-instantiate ellipse class, after rotation + self.ell1_rot = fitting.Ellipse(self.points_ell1_rot, use_least_squares=use_least_squares) + self.ell2_rot = fitting.Ellipse(self.points_ell2_rot, use_least_squares=use_least_squares) if self.plot_result: fig, axs = plt.subplots() @@ -459,10 +458,10 @@ def _fit_distance_det2src(ellipse_1: fitting.Ellipse, ellipse_2: fitting.Ellipse def tune_acquisition_geometry( acq_geom_init: ConeBeamGeometry, - data: Union[ArrayLike, NDArray], - angles_rot_rad: Union[ArrayLike, NDArray], - params: dict[str, Union[ArrayLike, NDArray]], - data_mask: Union[ArrayLike, NDArray, None] = None, + data: NDArray, + angles_rot_rad: Sequence[float] | NDArray, + params: dict[str, Sequence[float] | NDArray], + data_mask: NDArray | None = None, verbose: bool = True, ) -> ConeBeamGeometry: """ @@ -472,14 +471,14 @@ def tune_acquisition_geometry( ---------- acq_geom : ConeBeamGeometry The cone-beam geometry to refine. - data : ArrayLike | NDArray + data : NDArray The calibration projection data. - angles : ArrayLike | NDArray + angles : Sequence[float] | NDArray Angles of the projections. - params : dict[str, ArrayLike | NDArray] + params : dict[str, Sequence[float] | NDArray] Parameters to tune as a dictionary. The acquisition parameters to tune are the keys, and their test values are the dictionary values. - data_mask : ArrayLike | NDArray | None, optional + data_mask : NDArray | None, optional Pixel mask of the data, to mask out dead or hot pixels. The default is None. verbose : bool, optional Whether to output verbose information or not, by default False. @@ -489,7 +488,7 @@ def tune_acquisition_geometry( if data_mask is not None: data_mask = np.array(data_mask) - solver = solvers.SIRT(tolerance=0) + solver = solvers.SIRT(tolerance=0.0) acq_geom_tuned = acq_geom_init diff --git a/src/corrct/alignment/fitting.py b/src/corrct/alignment/fitting.py index 5390f53..a79c013 100644 --- a/src/corrct/alignment/fitting.py +++ b/src/corrct/alignment/fitting.py @@ -723,11 +723,11 @@ def refine_max_position_2d( def fit_parabola_min( - fun_x: Union[ArrayLike, NDArray], - fun_vals: Union[ArrayLike, NDArray], + fun_x: ArrayLike | NDArray, + fun_vals: ArrayLike | NDArray, scale: Literal["linear", "log"] = "linear", decimals: int = 2, -) -> tuple[float, float, Optional[tuple[NDArray, NDArray]]]: +) -> tuple[float, float, tuple[NDArray, NDArray] | None]: """Parabolic fit local function stationary point. Parameters @@ -810,22 +810,149 @@ def to_fit(vals): return min_fx, min_f_val, (coeffs, fx_fit) +def fit_ellipse_center(prj_points_vu: NDArray, rescale: bool = True, use_l1_norm: bool = False) -> NDArray: + """ + Fit an ellipse center to a set of projected points in VU coordinates. + + Parameters + ---------- + prj_points_vu : NDArray + Projected points in VU coordinates. The expected organization is: + - Last dimension: List of points (each point is a 2D coordinate). + - First dimension: Coordinates (V and U). + rescale : bool, optional + If True, rescale the points to have a maximum range of 1. Default is True. + use_l1_norm : bool, optional + If True, use L1 norm for fitting instead of the default L2 norm. Default is False. + + Returns + ------- + NDArray + The fitted ellipse center in VU coordinates. + """ + c_vu = np.mean(prj_points_vu, axis=-1, keepdims=True) + pos_vu = prj_points_vu - c_vu + + if rescale: + scale_vu = np.max(pos_vu, axis=-1, keepdims=True) - np.min(pos_vu, axis=-1, keepdims=True) + pos_vu /= scale_vu + else: + scale_vu = 1.0 + + num_lines = pos_vu.shape[-1] // 2 + pos1_vu = pos_vu[:, :num_lines] + pos2_vu = pos_vu[:, num_lines : num_lines * 2] + + diffs_vu = pos2_vu - pos1_vu + vandermonde = np.stack([diffs_vu[-1, :], -diffs_vu[-2, :]], axis=-1) + values = np.cross(pos1_vu, pos2_vu, axis=0) + + p_vu = np.linalg.lstsq(vandermonde, values, rcond=None)[0] + if use_l1_norm: + + def _func(params: NDArrayFloat) -> float: + predicted_values = vandermonde.dot(params) + l1_diff = np.linalg.norm(predicted_values - values, ord=1) + return float(l1_diff) + + opt_p_vu = spopt.minimize(_func, p_vu) + p_vu = opt_p_vu.x + + return p_vu * scale_vu + c_vu + + +def fit_ellipse_parameters( + prj_points_vu: NDArray, rescale: bool = True, use_l1_norm: bool = False +) -> tuple[float, float, float, float, float]: + """ + Fit ellipse parameters to a set of projected points in VU coordinates. + + Parameters + ---------- + prj_points_vu : NDArray + Projected points in VU coordinates. The expected organization is: + - Last dimension: List of points (each point is a 2D coordinate). + - First dimension: Coordinates (V and U). + rescale : bool, optional + If True, rescale the points to have a maximum range of 1. Default is True. + use_l1_norm : bool, optional + If True, use L1 norm for fitting instead of the default L2 norm. Default is False. + + Returns + ------- + tuple[float, float, float, float, float] + The fitted ellipse parameters: a, b, c, u, v. + """ + # First we fit 5 intermediate variables + p_u: NDArray = prj_points_vu[-1, :] + p_v: NDArray = prj_points_vu[-2, :] + + if rescale: + c_u = float(np.mean(p_u)) + c_v = float(np.mean(p_v)) + p_u = p_u - c_u + p_v = p_v - c_v + + p_u_scaling = float(np.abs(p_u).max()) + p_v_scaling = float(np.abs(p_v).max()) + p_u /= p_u_scaling + p_v /= p_v_scaling + else: + c_u = 0.0 + c_v = 0.0 + p_u_scaling = 1.0 + p_v_scaling = 1.0 + + vandermonde = np.stack([p_u**2, -2 * p_u, -2 * p_v, 2 * p_u * p_v, np.ones_like(p_u)], axis=-1) + values = -(p_v**2) + + coeffs = np.linalg.lstsq(vandermonde, values, rcond=None)[0] + if use_l1_norm: + + def _func(pars: NDArrayFloat) -> float: + predicted_b = vandermonde.dot(pars) + l1_diff = np.linalg.norm(predicted_b - values, ord=1) + return float(l1_diff) + + opt_params = spopt.minimize(_func, coeffs) + coeffs = opt_params.x + + if rescale: + coeffs[0] *= (p_v_scaling**2) / (p_u_scaling**2) + coeffs[1] *= (p_v_scaling**2) / p_u_scaling + coeffs[2] *= p_v_scaling + coeffs[3] *= p_v_scaling / p_u_scaling + coeffs[4] *= p_v_scaling**2 + + u = (coeffs[1] - coeffs[2] * coeffs[3]) / (coeffs[0] - coeffs[3] ** 2) + v = (coeffs[0] * coeffs[2] - coeffs[1] * coeffs[3]) / (coeffs[0] - coeffs[2] * coeffs[3]) + + a = coeffs[0] / (coeffs[0] * u**2 + v**2 + 2 * coeffs[3] * u * v - coeffs[4]) + b = a / coeffs[0] + c = coeffs[3] * b + + u += c_u + v += c_v + + return a, b, c, u, v + + class Trajectory(ABC): """Base trajectory class.""" @abstractmethod - def __call__(self, uus: Union[ArrayLike, NDArray]) -> Sequence[NDArray]: + def __call__(self, uus: Sequence[float] | NDArray) -> Sequence[NDArray]: """Compute V coordinates, given V coordinates. Parameters ---------- - uus : Union[ArrayLike, NDArray] + uus : Sequence[float] | NDArray The U coordinates Returns ------- Sequence[NDArray] - Corresponding V coordiantes, given the multiplicity of the trajectory + Corresponding V coordinates, given the multiplicity of the trajectory """ @@ -840,7 +967,12 @@ class Ellipse(Trajectory): c_vu: NDArrayFloat - def __init__(self, prj_points_vu: Union[ArrayLike, NDArray], rescale: bool = True, least_squares: bool = True): + prj_points_vu: NDArray + + rescale: bool + use_least_squares: bool + + def __init__(self, prj_points_vu: ArrayLike | NDArray, rescale: bool = True, use_least_squares: bool = True): """Initialize ellipse class. Parameters @@ -849,16 +981,18 @@ def __init__(self, prj_points_vu: Union[ArrayLike, NDArray], rescale: bool = Tru List of sampled points over the trajectory. rescale : bool, optional Whether to rescale the data within the interval [-1, 1]. The default is True. - least_squares : bool, optional + use_least_squares : bool, optional Whether to use the least-squares (l2-norm) fit or l1-norm. The default is True. """ self.prj_points_vu = np.array(prj_points_vu) self.rescale = rescale - self.least_squares = least_squares + self.use_least_squares = use_least_squares - self._fit_center() - self._fit_parameters() + self.c_vu = fit_ellipse_center(self.prj_points_vu, self.rescale, not self.use_least_squares) + self.a, self.b, self.c, self.u, self.v = fit_ellipse_parameters( + self.prj_points_vu, self.rescale, not self.use_least_squares + ) @property def center_vu(self) -> NDArray: @@ -882,7 +1016,7 @@ def parameters(self) -> NDArray: """ return np.array([self.b, self.a, self.c, self.v, self.u]) - def __call__(self, uus: Union[ArrayLike, NDArray]) -> Sequence[NDArray]: + def __call__(self, uus: ArrayLike | NDArray) -> Sequence[NDArray]: """Predict V coordinates of ellipse from its parameters, and U coordinates. Parameters @@ -906,86 +1040,3 @@ def __call__(self, uus: Union[ArrayLike, NDArray]) -> Sequence[NDArray]: v_2 = (-b_tilde - delta_tilde) / (2 * a_tilde) return v_1, v_2 - - def _fit_center(self) -> None: - c_vu = np.mean(self.prj_points_vu, axis=-1, keepdims=True) - pos_vu = self.prj_points_vu - c_vu - - if self.rescale: - scale_vu = np.max(pos_vu, axis=-1, keepdims=True) - np.min(pos_vu, axis=-1, keepdims=True) - pos_vu /= scale_vu - else: - scale_vu = 1.0 - - num_lines = pos_vu.shape[-1] // 2 - pos1_vu = pos_vu[:, :num_lines] - pos2_vu = pos_vu[:, num_lines : num_lines * 2] - - diffs_vu = pos2_vu - pos1_vu - vandermonde = np.stack([diffs_vu[-1, :], -diffs_vu[-2, :]], axis=-1) - values = np.cross(pos1_vu, pos2_vu, axis=0) - - p_vu = np.linalg.lstsq(vandermonde, values, rcond=None)[0] - if not self.least_squares: - - def _func(params: NDArrayFloat) -> float: - predicted_values = vandermonde.dot(params) - l1_diff = np.linalg.norm(predicted_values - values, ord=1) - return float(l1_diff) - - opt_p_vu = spopt.minimize(_func, p_vu) - p_vu = opt_p_vu.x - - self.c_vu = p_vu * scale_vu + c_vu - - def _fit_parameters(self) -> None: - # First we fit 5 intermediate variables - p_u: NDArray = self.prj_points_vu[-1, :] - p_v: NDArray = self.prj_points_vu[-2, :] - - if self.rescale: - c_u = np.mean(p_u) - c_v = np.mean(p_v) - p_u = p_u - c_u - p_v = p_v - c_v - - p_u_scaling = np.abs(p_u).max() - p_v_scaling = np.abs(p_v).max() - p_u /= p_u_scaling - p_v /= p_v_scaling - else: - c_u = 0.0 - c_v = 0.0 - p_u_scaling = 1.0 - p_v_scaling = 1.0 - - vandermonde = np.stack([p_u**2, -2 * p_u, -2 * p_v, 2 * p_u * p_v, np.ones_like(p_u)], axis=-1) - values = -(p_v**2) - - coeffs = np.linalg.lstsq(vandermonde, values, rcond=None)[0] - if not self.least_squares: - - def _func(pars: NDArrayFloat) -> float: - predicted_b = vandermonde.dot(pars) - l1_diff = np.linalg.norm(predicted_b - values, ord=1) - return float(l1_diff) - - opt_params = spopt.minimize(_func, coeffs) - coeffs = opt_params.x - - if self.rescale: - coeffs[0] *= (p_v_scaling**2) / (p_u_scaling**2) - coeffs[1] *= (p_v_scaling**2) / p_u_scaling - coeffs[2] *= p_v_scaling - coeffs[3] *= p_v_scaling / p_u_scaling - coeffs[4] *= p_v_scaling**2 - - self.u = (coeffs[1] - coeffs[2] * coeffs[3]) / (coeffs[0] - coeffs[3] ** 2) - self.v = (coeffs[0] * coeffs[2] - coeffs[1] * coeffs[3]) / (coeffs[0] - coeffs[2] * coeffs[3]) - - self.a = coeffs[0] / (coeffs[0] * self.u**2 + self.v**2 + 2 * coeffs[3] * self.u * self.v - coeffs[4]) - self.b = self.a / coeffs[0] - self.c = coeffs[3] * self.b - - self.u += c_u - self.v += c_v diff --git a/src/corrct/alignment/markers.py b/src/corrct/alignment/markers.py index 1aff9d6..43de374 100644 --- a/src/corrct/alignment/markers.py +++ b/src/corrct/alignment/markers.py @@ -6,29 +6,29 @@ and CEA-IRIG, Grenoble, France """ -from typing import Union +from collections.abc import Sequence import numpy as np import scipy.ndimage as spimg -from numpy.typing import ArrayLike, NDArray +from numpy.typing import NDArray import matplotlib.pyplot as plt from . import fitting -def cm2inch(dims: Union[ArrayLike, NDArray]) -> tuple[float]: - """Convert cm into inch. +def cm2inch(dims: Sequence[float] | NDArray) -> tuple[float]: + """Convert dimensions from centimeters to inches. Parameters ---------- - dims : Union[ArrayLike, NDArray] - The dimentions of the object in cm + dims : Sequence[float] | NDArray + The dimensions of the object in centimeters. Can be a sequence of floats or a NumPy array. Returns ------- tuple[float] - The output dimensions in inch + The converted dimensions in inches, as a tuple of floats. """ return tuple(np.array(dims) / 2.54) @@ -56,26 +56,25 @@ def track_marker(prj_data: NDArray, marker_vu: NDArray, stack_axis: int = -2) -> def create_marker_disk( - data_shape_vu: Union[ArrayLike, NDArray], radius: float, super_sampling: int = 5, conv: bool = True + data_shape_vu: Sequence[int] | NDArray, radius: float, super_sampling: int = 5, conv: bool = True ) -> NDArray: - """ - Create a Disk probe object, that will be used for tracking a calibration object's movement. + """Create a disk-shaped marker for tracking a calibration object's movement. Parameters ---------- - data_shape_vu : ArrayLike - Shape of the images (vertical, horizontal). + data_shape_vu : Sequence[int] | NDArray + Shape of the images (vertical, horizontal). Can be a sequence of integers or a NumPy array. radius : float - Radius of the probe. + Radius of the marker in pixels. super_sampling : int, optional - Super sampling of the coordinates used for creation. The default is 5. + Super-sampling factor for the coordinates used in marker creation. Default is 5. conv : bool, optional - Whether to convolve the initial probe with itself. The default is True. + Whether to convolve the initial marker with itself. Default is True. Returns ------- NDArray - An image of the same size as the projections, that contains the marker in the center. + An image of the same size as the input projections, with the marker centered. """ data_shape_vu = np.array(data_shape_vu, dtype=int) * super_sampling @@ -103,16 +102,16 @@ class MarkerTrackingVisualizer: def __init__( self, - fitted_positions_vu: Union[ArrayLike, NDArray], + fitted_positions_vu: NDArray, images: NDArray, marker: NDArray, - trajectory: Union[fitting.Trajectory, None] = None, + trajectory: fitting.Trajectory | None = None, ) -> None: """Initialize the visualization utility for checking the marker position fitting. Parameters ---------- - fitted_positions_vu : Union[ArrayLike, NDArray] + fitted_positions_vu : NDArray The fitted positions of the marker imgs : NDArray The original images From 784bc0918ef436543aaf23d69f6f52f4371611ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicola=20VIGAN=C3=92?= Date: Tue, 19 May 2026 00:28:20 +0200 Subject: [PATCH 06/26] Alignment: fix type hinting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Nicola VIGANÒ --- src/corrct/alignment/fitting.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/corrct/alignment/fitting.py b/src/corrct/alignment/fitting.py index a79c013..0d5ea63 100644 --- a/src/corrct/alignment/fitting.py +++ b/src/corrct/alignment/fitting.py @@ -10,6 +10,7 @@ from abc import ABC, abstractmethod from collections.abc import Sequence +from typing import Literal import matplotlib.pyplot as plt import numpy as np @@ -756,14 +757,16 @@ def fit_parabola_min( ) if scale.lower() == "log": - to_fit = np.log10 - def from_fit(vals): + def to_fit(vals: NDArray) -> NDArray: + return np.log10(vals) + + def from_fit(vals: NDArray) -> NDArray: return 10**vals elif scale.lower() == "linear": - def to_fit(vals): + def to_fit(vals: NDArray) -> NDArray: return vals from_fit = to_fit From cecd865af54ad7418b22fd5df4ea06f3337e43d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicola=20VIGAN=C3=92?= Date: Tue, 19 May 2026 00:28:40 +0200 Subject: [PATCH 07/26] Fixed some imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Nicola VIGANÒ --- src/corrct/alignment/cone_beam.py | 35 ++++++++++++++++--------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/src/corrct/alignment/cone_beam.py b/src/corrct/alignment/cone_beam.py index 6f3bf74..9f4775c 100644 --- a/src/corrct/alignment/cone_beam.py +++ b/src/corrct/alignment/cone_beam.py @@ -7,18 +7,19 @@ """ import json -from dataclasses import dataclass -from dataclasses import replace as dc_replace +from dataclasses import dataclass, replace as dc_replace from collections.abc import Sequence import matplotlib.pyplot as plt import numpy as np from numpy.typing import NDArray from scipy.spatial.transform import Rotation -from tqdm import tqdm +from tqdm.auto import tqdm -from .. import models, projectors, solvers -from . import fitting +from corrct.models import ProjectionGeometry, VolumeGeometry +from corrct.projectors import ProjectorUncorrected +from corrct.solvers import SIRT +from corrct.alignment.fitting import Ellipse, fit_parabola_min def _class_to_json(obj: object) -> str: @@ -68,7 +69,7 @@ def __str__(self) -> str: descr += ",\n" return descr + ")" - def get_prj_geom(self, translate_z_to_center: bool = True) -> models.ProjectionGeometry: + def get_prj_geom(self, translate_z_to_center: bool = True) -> ProjectionGeometry: """ Create the geometry for reconstruction. @@ -120,7 +121,7 @@ def get_prj_geom(self, translate_z_to_center: bool = True) -> models.ProjectionG e_u_xyz = rotation.apply(alpha_xyz) e_v_xyz = rotation.apply(beta_xyz) - return models.ProjectionGeometry( + return ProjectionGeometry( geom_type="cone", src_pos_xyz=src_pos_xyz, det_pos_xyz=det_pos_xyz, @@ -130,7 +131,7 @@ def get_prj_geom(self, translate_z_to_center: bool = True) -> models.ProjectionG pix2vox_ratio=pix2vox_ratio, ) - def get_vol_geom(self, up_sampling: int = 1) -> models.VolumeGeometry: + def get_vol_geom(self, up_sampling: int = 1) -> VolumeGeometry: """ Generate volume geometry. @@ -139,7 +140,7 @@ def get_vol_geom(self, up_sampling: int = 1) -> models.VolumeGeometry: VolumeGeometry The volume geometry. """ - return models.VolumeGeometry( + return VolumeGeometry( _vol_shape_xyz=np.array([self.det_pix_u, self.det_pix_u, self.det_pix_v], dtype=int) * up_sampling, vox_size=1 / up_sampling, ) @@ -291,8 +292,8 @@ def __init__( self._initialize() def _initialize(self, use_least_squares: bool = False) -> None: - self.ell1_acq = fitting.Ellipse(self.points_ell1, use_least_squares=use_least_squares) - self.ell2_acq = fitting.Ellipse(self.points_ell2, use_least_squares=use_least_squares) + self.ell1_acq = Ellipse(self.points_ell1, use_least_squares=use_least_squares) + self.ell2_acq = Ellipse(self.points_ell2, use_least_squares=use_least_squares) if self.points_axis is not None: # Using measured projected center, whenever available @@ -323,8 +324,8 @@ def _initialize(self, use_least_squares: bool = False) -> None: self.points_ell2_rot = self.points_ell2.copy() # Re-instantiate ellipse class, after rotation - self.ell1_rot = fitting.Ellipse(self.points_ell1_rot, use_least_squares=use_least_squares) - self.ell2_rot = fitting.Ellipse(self.points_ell2_rot, use_least_squares=use_least_squares) + self.ell1_rot = Ellipse(self.points_ell1_rot, use_least_squares=use_least_squares) + self.ell2_rot = Ellipse(self.points_ell2_rot, use_least_squares=use_least_squares) if self.plot_result: fig, axs = plt.subplots() @@ -433,7 +434,7 @@ def get_zeta(bk, ak, ck, D, sign_zk) -> float: return self.acq_geom @staticmethod - def _fit_distance_det2src(ellipse_1: fitting.Ellipse, ellipse_2: fitting.Ellipse, e: float = 1) -> float: + def _fit_distance_det2src(ellipse_1: Ellipse, ellipse_2: Ellipse, e: float = 1) -> float: b1, a1, c1, v1, _ = ellipse_1.parameters b2, a2, c2, v2, _ = ellipse_2.parameters @@ -488,7 +489,7 @@ def tune_acquisition_geometry( if data_mask is not None: data_mask = np.array(data_mask) - solver = solvers.SIRT(tolerance=0.0) + solver = SIRT(tolerance=0.0) acq_geom_tuned = acq_geom_init @@ -499,11 +500,11 @@ def tune_acquisition_geometry( for par_ind, acq_geom in enumerate(tqdm(acq_geom_tuned.get_tuning_params(par_name, par_vals), desc=desc)): vol_geom = acq_geom.get_vol_geom() prj_geom = acq_geom.get_prj_geom() - with projectors.ProjectorUncorrected(vol_geom, angles_rot_rad, prj_geom=prj_geom) as prj: + with ProjectorUncorrected(vol_geom, angles_rot_rad, prj_geom=prj_geom) as prj: _, info = solver(prj, data, iterations=100, b_mask=data_mask) residuals[par_ind] = info.residuals[-1] - min_par, min_res, fit_info = fitting.fit_parabola_min(par_vals, residuals, decimals=6) + min_par, min_res, fit_info = fit_parabola_min(par_vals, residuals, decimals=6) if verbose: old_par_val = getattr(acq_geom_tuned, par_name) From 31689121e9f6e9761cda0774f6ab9118d14d29fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicola=20VIGAN=C3=92?= Date: Fri, 29 May 2026 10:56:31 +0200 Subject: [PATCH 08/26] Changed Ellipse class initialization interface and fixed center fitting function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Nicola VIGANÒ --- src/corrct/alignment/cone_beam.py | 45 +++++++---- src/corrct/alignment/fitting.py | 127 +++++++++++++++++++++++------- 2 files changed, 130 insertions(+), 42 deletions(-) diff --git a/src/corrct/alignment/cone_beam.py b/src/corrct/alignment/cone_beam.py index 9f4775c..323188b 100644 --- a/src/corrct/alignment/cone_beam.py +++ b/src/corrct/alignment/cone_beam.py @@ -19,7 +19,7 @@ from corrct.models import ProjectionGeometry, VolumeGeometry from corrct.projectors import ProjectorUncorrected from corrct.solvers import SIRT -from corrct.alignment.fitting import Ellipse, fit_parabola_min +from corrct.alignment.fitting import Ellipse, fit_parabola_min, fit_ellipse, fit_ellipse_center def _class_to_json(obj: object) -> str: @@ -292,8 +292,8 @@ def __init__( self._initialize() def _initialize(self, use_least_squares: bool = False) -> None: - self.ell1_acq = Ellipse(self.points_ell1, use_least_squares=use_least_squares) - self.ell2_acq = Ellipse(self.points_ell2, use_least_squares=use_least_squares) + ell1_acq_cvu = fit_ellipse_center(self.points_ell1, use_l1_norm=not use_least_squares) + ell2_acq_cvu = fit_ellipse_center(self.points_ell2, use_l1_norm=not use_least_squares) if self.points_axis is not None: # Using measured projected center, whenever available @@ -301,9 +301,26 @@ def _initialize(self, use_least_squares: bool = False) -> None: self.ell2_prj_center_vu = self.points_axis[:, 2] self.prj_origin_vu = self.points_axis[:, 1] + + if self.verbose: + # Calculate the difference in pixels between the computed and measured ellipse centers + diff_ell1 = ell1_acq_cvu - self.ell1_prj_center_vu + diff_ell2 = ell2_acq_cvu - self.ell2_prj_center_vu + + print("Difference between computed and measured ellipse centers:") + print("- Ellipse 1:") + print(f" Positions: acquired={ell1_acq_cvu} vs computed={self.ell1_prj_center_vu}") + print( + f" Norm: {np.linalg.norm(diff_ell1):.2f} pixels, Coordinates: x={diff_ell1[0]:.2f} pixels, y={diff_ell1[1]:.2f} pixels" + ) + print("- Ellipse 2:") + print(f" Positions: acquired={ell2_acq_cvu} vs computed={self.ell2_prj_center_vu}") + print( + f" Norm: {np.linalg.norm(diff_ell2):.2f} pixels, Coordinates: x={diff_ell2[0]:.2f} pixels, y={diff_ell2[1]:.2f} pixels" + ) else: - self.ell1_prj_center_vu = self.ell1_acq.center_vu - self.ell2_prj_center_vu = self.ell2_acq.center_vu + self.ell1_prj_center_vu = ell1_acq_cvu + self.ell2_prj_center_vu = ell2_acq_cvu self.prj_origin_vu = None @@ -317,23 +334,23 @@ def _initialize(self, use_least_squares: bool = False) -> None: rot = Rotation.from_rotvec(-np.deg2rad(self.acq_geom.eta_deg) * np.array([0, 0, 1])) rot_mat = rot.as_matrix()[:2, :2] - self.points_ell1_rot = rot_mat.dot(self.points_ell1) - self.points_ell2_rot = rot_mat.dot(self.points_ell2) + points_ell1_rot = rot_mat.dot(self.points_ell1) + points_ell2_rot = rot_mat.dot(self.points_ell2) else: - self.points_ell1_rot = self.points_ell1.copy() - self.points_ell2_rot = self.points_ell2.copy() + points_ell1_rot = self.points_ell1.copy() + points_ell2_rot = self.points_ell2.copy() # Re-instantiate ellipse class, after rotation - self.ell1_rot = Ellipse(self.points_ell1_rot, use_least_squares=use_least_squares) - self.ell2_rot = Ellipse(self.points_ell2_rot, use_least_squares=use_least_squares) + self.ell1_rot: Ellipse = fit_ellipse(points_ell1_rot, use_least_squares=use_least_squares) + self.ell2_rot: Ellipse = fit_ellipse(points_ell2_rot, use_least_squares=use_least_squares) if self.plot_result: fig, axs = plt.subplots() axs.plot(self.points_ell1[1, :], self.points_ell1[0, :], "C0--", label="Ellipse 1 - Acquired") axs.plot(self.points_ell2[1, :], self.points_ell2[0, :], "C1--", label="Ellipse 2 - Acquired") - axs.plot(self.points_ell1_rot[1, :], self.points_ell1_rot[0, :], "C0", label="Ellipse 1 - Rotated") - axs.plot(self.points_ell2_rot[1, :], self.points_ell2_rot[0, :], "C1", label="Ellipse 2 - Rotated") - axs.plot([self.ell1_acq.u, self.ell2_acq.u], [self.ell1_acq.v, self.ell2_acq.v], "C2--") + axs.plot(points_ell1_rot[1, :], points_ell1_rot[0, :], "C0", label="Ellipse 1 - Rotated") + axs.plot(points_ell2_rot[1, :], points_ell2_rot[0, :], "C1", label="Ellipse 2 - Rotated") + axs.plot([ell1_acq_cvu[1], ell2_acq_cvu[1]], [ell1_acq_cvu[0], ell2_acq_cvu[0]], "C2--") axs.plot([self.ell1_rot.u, self.ell2_rot.u], [self.ell1_rot.v, self.ell2_rot.v], "C2") if self.points_axis is not None: axs.scatter(self.points_axis[1], self.points_axis[0], c="C2", marker="*", label="Centers - Acquired") diff --git a/src/corrct/alignment/fitting.py b/src/corrct/alignment/fitting.py index 0d5ea63..9bff10f 100644 --- a/src/corrct/alignment/fitting.py +++ b/src/corrct/alignment/fitting.py @@ -813,10 +813,16 @@ def to_fit(vals: NDArray) -> NDArray: return min_fx, min_f_val, (coeffs, fx_fit) -def fit_ellipse_center(prj_points_vu: NDArray, rescale: bool = True, use_l1_norm: bool = False) -> NDArray: +def fit_ellipse_center( + prj_points_vu: NDArray, rescale: bool = True, use_l1_norm: bool = False, decimals: int | None = 2 +) -> NDArray: """ Fit an ellipse center to a set of projected points in VU coordinates. + The function uses a least-squares approach to fit an ellipse center to the given points. + Optionally, it can use L1 norm for fitting instead of the default L2 norm, and rescale + the points during fitting to have a maximum range of 1 (improve numerical stability). + Parameters ---------- prj_points_vu : NDArray @@ -827,20 +833,23 @@ def fit_ellipse_center(prj_points_vu: NDArray, rescale: bool = True, use_l1_norm If True, rescale the points to have a maximum range of 1. Default is True. use_l1_norm : bool, optional If True, use L1 norm for fitting instead of the default L2 norm. Default is False. + decimals : int | None, optional + The number of decimal places to round the result to. If None, no rounding is performed. + Default is 2. Returns ------- NDArray The fitted ellipse center in VU coordinates. """ - c_vu = np.mean(prj_points_vu, axis=-1, keepdims=True) - pos_vu = prj_points_vu - c_vu + c_vu = np.mean(prj_points_vu, axis=-1) + pos_vu = prj_points_vu - c_vu[:, None] if rescale: - scale_vu = np.max(pos_vu, axis=-1, keepdims=True) - np.min(pos_vu, axis=-1, keepdims=True) - pos_vu /= scale_vu + scale_vu = np.max(pos_vu, axis=-1) - np.min(pos_vu, axis=-1) + pos_vu /= scale_vu[:, None] else: - scale_vu = 1.0 + scale_vu = np.ones(2, dtype=pos_vu.dtype) num_lines = pos_vu.shape[-1] // 2 pos1_vu = pos_vu[:, :num_lines] @@ -861,7 +870,12 @@ def _func(params: NDArrayFloat) -> float: opt_p_vu = spopt.minimize(_func, p_vu) p_vu = opt_p_vu.x - return p_vu * scale_vu + c_vu + pred_c_vu = p_vu * scale_vu + c_vu + + if decimals is not None: + pred_c_vu = np.around(pred_c_vu, decimals=decimals) + + return pred_c_vu def fit_ellipse_parameters( @@ -970,32 +984,52 @@ class Ellipse(Trajectory): c_vu: NDArrayFloat - prj_points_vu: NDArray - - rescale: bool - use_least_squares: bool - - def __init__(self, prj_points_vu: ArrayLike | NDArray, rescale: bool = True, use_least_squares: bool = True): + def __init__(self, a: float, b: float, c: float, u: float, v: float, c_vu: NDArrayFloat) -> None: """Initialize ellipse class. + Ellipse corresponding to the equation: a*(x - u)**2 + b*(y - v)**2 + 2*c*(x - u)*(y - v) = 1 + Parameters ---------- - prj_points_vu : ArrayLike | NDArray - List of sampled points over the trajectory. - rescale : bool, optional - Whether to rescale the data within the interval [-1, 1]. The default is True. - use_least_squares : bool, optional - Whether to use the least-squares (l2-norm) fit or l1-norm. The default is True. + a : float + The semi-major axis of the ellipse. + b : float + The semi-minor axis of the ellipse. + c : float + The rotation angle of the ellipse in radians. + u : float + The center of the ellipse along the x-axis. + v : float + The center of the ellipse along the y-axis. + c_vu : NDArrayFloat + The covariance matrix of the ellipse parameters. """ - self.prj_points_vu = np.array(prj_points_vu) + self.a = a + self.b = b + self.c = c + self.u = u + self.v = v + self.c_vu = c_vu - self.rescale = rescale - self.use_least_squares = use_least_squares + def __repr__(self) -> str: + """Return a string representation of the Ellipse instance.""" + return f"Ellipse(a={self.a}, b={self.b}, c={self.c}, u={self.u}, v={self.v}, c_vu={self.c_vu})" - self.c_vu = fit_ellipse_center(self.prj_points_vu, self.rescale, not self.use_least_squares) - self.a, self.b, self.c, self.u, self.v = fit_ellipse_parameters( - self.prj_points_vu, self.rescale, not self.use_least_squares - ) + @property + def extremes_u(self) -> tuple[float, float]: + """ + Find the most extreme x coordinates (U coordinates) that are still valid for the given ellipse equation. + + Returns + ------- + tuple[float, float] + A tuple containing the most extreme U coordinates (u_min, u_max). + """ + # Calculate the extreme U coordinates + u_min = self.u - np.sqrt(1 / self.a) + u_max = self.u + np.sqrt(1 / self.a) + + return u_min, u_max @property def center_vu(self) -> NDArray: @@ -1035,11 +1069,48 @@ def __call__(self, uus: ArrayLike | NDArray) -> Sequence[NDArray]: b, a, c, v, u = np.array(self.parameters) uus = np.array(uus) + uus_u = uus - u + a_tilde = b - b_tilde = 2 * (-b * v + c * uus - c * u) - c_tilde = -(1 - a * (uus - u) ** 2 - b * v**2 + 2 * c * v * (uus - u)) + # b_tilde = 2 * (-b * v + c * uus - c * u) + # c_tilde = -(1 - a * (uus - u) ** 2 - b * v**2 + 2 * c * v * (uus - u)) + b_tilde = 2 * (-b * v + c * uus_u) + c_tilde = -(1 - a * uus_u**2 - b * v**2 + 2 * c * v * uus_u) delta_tilde = np.sqrt(b_tilde**2 - 4 * a_tilde * c_tilde) v_1 = (-b_tilde + delta_tilde) / (2 * a_tilde) v_2 = (-b_tilde - delta_tilde) / (2 * a_tilde) return v_1, v_2 + + +def fit_ellipse(prj_points_vu: ArrayLike | NDArray, rescale: bool = True, use_least_squares: bool = True) -> Ellipse: + """Fit an ellipse to a set of 2D points using either least-squares or l1-norm optimization. + + Parameters + ---------- + prj_points_vu : ArrayLike | NDArray + A list or array of 2D points (shape: Nx2) representing the trajectory to fit. + rescale : bool, optional + Whether to rescale the data within the interval [-1, 1] to improve numerical stability. + Default is True. + use_least_squares : bool, optional + Whether to use the least-squares (l2-norm) fit for optimization. If False, uses l1-norm. + Default is True. + + Returns + ------- + Ellipse + An Ellipse object containing the fitted ellipse parameters (center, axes, and rotation angle). + + Notes + ----- + The function first fits the ellipse parameters (axes and rotation angle) and then fits the center + separately. The optimization method can be switched between least-squares and l1-norm for robustness + against outliers. + """ + prj_points_vu = np.array(prj_points_vu) + + return Ellipse( + *fit_ellipse_parameters(prj_points_vu, rescale, not use_least_squares), + fit_ellipse_center(prj_points_vu, rescale, not use_least_squares), + ) From 43bf4554619b51bda623bb9848b2fbf00b798c90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicola=20VIGAN=C3=92?= Date: Fri, 29 May 2026 10:57:41 +0200 Subject: [PATCH 09/26] Alignment/cone-beam: added ellipse fitting tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Nicola VIGANÒ --- src/corrct/alignment/cone_beam.py | 4 +- src/corrct/alignment/fitting.py | 2 +- tests/alignment/__init__.py | 0 tests/alignment/test_fitting.py | 123 ++++++++++++++++++++++++++++++ 4 files changed, 126 insertions(+), 3 deletions(-) create mode 100644 tests/alignment/__init__.py create mode 100644 tests/alignment/test_fitting.py diff --git a/src/corrct/alignment/cone_beam.py b/src/corrct/alignment/cone_beam.py index 323188b..9c629c7 100644 --- a/src/corrct/alignment/cone_beam.py +++ b/src/corrct/alignment/cone_beam.py @@ -309,12 +309,12 @@ def _initialize(self, use_least_squares: bool = False) -> None: print("Difference between computed and measured ellipse centers:") print("- Ellipse 1:") - print(f" Positions: acquired={ell1_acq_cvu} vs computed={self.ell1_prj_center_vu}") + print(f" Positions: computed={ell1_acq_cvu} vs acquired={self.ell1_prj_center_vu}") print( f" Norm: {np.linalg.norm(diff_ell1):.2f} pixels, Coordinates: x={diff_ell1[0]:.2f} pixels, y={diff_ell1[1]:.2f} pixels" ) print("- Ellipse 2:") - print(f" Positions: acquired={ell2_acq_cvu} vs computed={self.ell2_prj_center_vu}") + print(f" Positions: computed={ell2_acq_cvu} vs acquired={self.ell2_prj_center_vu}") print( f" Norm: {np.linalg.norm(diff_ell2):.2f} pixels, Coordinates: x={diff_ell2[0]:.2f} pixels, y={diff_ell2[1]:.2f} pixels" ) diff --git a/src/corrct/alignment/fitting.py b/src/corrct/alignment/fitting.py index 9bff10f..02cb7f1 100644 --- a/src/corrct/alignment/fitting.py +++ b/src/corrct/alignment/fitting.py @@ -1002,7 +1002,7 @@ def __init__(self, a: float, b: float, c: float, u: float, v: float, c_vu: NDArr v : float The center of the ellipse along the y-axis. c_vu : NDArrayFloat - The covariance matrix of the ellipse parameters. + The projected center of the circular orbit generating the ellipse. """ self.a = a self.b = b diff --git a/tests/alignment/__init__.py b/tests/alignment/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/alignment/test_fitting.py b/tests/alignment/test_fitting.py new file mode 100644 index 0000000..5d88483 --- /dev/null +++ b/tests/alignment/test_fitting.py @@ -0,0 +1,123 @@ +import numpy as np +import pytest +from numpy.typing import NDArray + +from corrct.alignment.fitting import Ellipse, fit_ellipse_center, fit_ellipse_parameters + + +def generate_points(ellipse: Ellipse, num_points: int, noise_std: float = 0.0, outliers: int = 0) -> NDArray: + """Generate points on the ellipse with optional noise and outliers. + + Parameters + ---------- + ellipse : Ellipse + The ellipse to generate points from. + num_points : int + Number of points to generate. + noise : float, optional + Standard deviation of the Gaussian noise to add to the points. Default is 0.0. + outliers : int, optional + Number of outliers to add to the points. Default is 0. + + Returns + ------- + NDArray + Generated points in VU coordinates. + """ + angles = np.linspace(0, np.pi, num_points) + x = np.cos(angles) / np.sqrt(ellipse.a) + ellipse.u + y = ellipse(x) + + points = np.stack([[*y[0], *np.flip(y[1])], [*x, *np.flip(x)]], axis=0) + + if noise_std > 0.0: + points += np.random.normal(scale=noise_std, size=points.shape) + + if outliers > 0: + outlier_indices = np.random.choice(num_points, size=outliers, replace=False) + points[:, outlier_indices] += np.random.normal(scale=5 * noise_std, size=(2, outliers)) + + # import matplotlib.pyplot as plt + + # print(ellipse) + # print(points) + # fig, axs = plt.subplots(1, 1) + # axs.plot(x, y[0]) + # axs.plot(x, y[1]) + # axs.scatter(points[1], points[0], color="C2") + # fig.tight_layout() + # plt.show() + + return points + + +def test_fit_ellipse_center_simple() -> None: + """Test fit_ellipse_center with no noise and 6 points.""" + ellipse = Ellipse(a=2.0, b=1.5, c=0.5, u=1.0, v=1.0, c_vu=np.ones(2)) + points = generate_points(ellipse, num_points=6) + + center = fit_ellipse_center(points, rescale=True, use_l1_norm=False) + + assert np.allclose(center, np.array([[1.0], [1.0]]), atol=1e-2), "Center should be close to [1.0, 1.0]" + + +def test_fit_ellipse_center_noisy() -> None: + """Test fit_ellipse_center with small Gaussian noise and 60 points.""" + ellipse = Ellipse(a=2.0, b=1.5, c=0.5, u=1.0, v=1.0, c_vu=np.ones(2)) + points = generate_points(ellipse, num_points=60, noise_std=0.1) + + center = fit_ellipse_center(points, rescale=True, use_l1_norm=False) + + assert np.allclose(center, np.array([[1.0], [1.0]]), atol=1e-1), "Center should be close to [1.0, 1.0]" + + +def test_fit_ellipse_center_noisy_with_outliers() -> None: + """Test fit_ellipse_center with 60 points and 2 outliers using L1 norm.""" + ellipse = Ellipse(a=2.0, b=1.5, c=0.5, u=1.0, v=1.0, c_vu=np.ones(2)) + points = generate_points(ellipse, num_points=60, noise_std=0.1, outliers=2) + + center = fit_ellipse_center(points, rescale=True, use_l1_norm=True) + + assert np.allclose(center, np.array([[1.0], [1.0]]), atol=1e-1), "Center should be close to [1.0, 1.0]" + + +def test_fit_ellipse_parameters_simple() -> None: + """Test fit_ellipse_parameters with no noise and 6 points.""" + ellipse = Ellipse(a=2.0, b=1.5, c=0.5, u=1.0, v=1.0, c_vu=np.ones(2)) + points = generate_points(ellipse, num_points=6) + + a, b, c, u, v = fit_ellipse_parameters(points, rescale=True, use_l1_norm=False) + + assert np.isclose(a, 2.0, atol=1e-2), "Semi-major axis (a) should be close to 2.0" + assert np.isclose(b, 1.5, atol=1e-2), "Semi-minor axis (b) should be close to 1.5" + assert np.isclose(c, 0.5, atol=1e-2), "Rotation parameter (c) should be close to 0.5" + assert np.isclose(u, 1.0, atol=1e-2), "Center along the x-axis (u) should be close to 1.0" + assert np.isclose(v, 1.0, atol=1e-2), "Center along the y-axis (v) should be close to 1.0" + + +def test_fit_ellipse_parameters_noisy() -> None: + """Test fit_ellipse_parameters with small Gaussian noise and 60 points.""" + ellipse = Ellipse(a=2.0, b=1.5, c=0.5, u=1.0, v=1.0, c_vu=np.ones(2)) + points = generate_points(ellipse, num_points=60, noise_std=0.02) + + a, b, c, u, v = fit_ellipse_parameters(points, rescale=True, use_l1_norm=False) + + assert np.isclose(a, 2.0, atol=1e-1), "Semi-major axis (a) should be close to 2.0" + assert np.isclose(b, 1.5, atol=1e-1), "Semi-minor axis (b) should be close to 1.5" + assert np.isclose(c, 0.5, atol=1e-1), "Rotation parameter (c) should be close to 0.5" + assert np.isclose(u, 1.0, atol=1e-1), "Center along the x-axis (u) should be close to 1.0" + assert np.isclose(v, 1.0, atol=1e-1), "Center along the y-axis (v) should be close to 1.0" + + +def test_fit_ellipse_parameters_noisy_with_outliers() -> None: + """Test fit_ellipse_parameters with 60 points and 2 outliers using L1 norm.""" + ellipse = Ellipse(a=2.0, b=1.5, c=0.5, u=1.0, v=1.0, c_vu=np.ones(2)) + points = generate_points(ellipse, num_points=60, noise_std=0.02, outliers=2) + + a, b, c, u, v = fit_ellipse_parameters(points, rescale=True, use_l1_norm=True) + + assert np.isclose(a, 2.0, atol=1e-1), "Semi-major axis (a) should be close to 2.0" + assert np.isclose(b, 1.5, atol=1e-1), "Semi-minor axis (b) should be close to 1.5" + assert np.isclose(c, 0.5, atol=1e-1), "Rotation parameter (c) should be close to 0.5" + assert np.isclose(u, 1.0, atol=1e-1), "Center along the x-axis (u) should be close to 1.0" + assert np.isclose(v, 1.0, atol=1e-1), "Center along the y-axis (v) should be close to 1.0" From 23405ca9dc9d3c62225f3700d39c38149ab0e9e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicola=20VIGAN=C3=92?= Date: Fri, 29 May 2026 11:37:32 +0200 Subject: [PATCH 10/26] More fixes for the eta fitting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Nicola VIGANÒ --- src/corrct/alignment/cone_beam.py | 78 +++++++++++++++++++------------ 1 file changed, 47 insertions(+), 31 deletions(-) diff --git a/src/corrct/alignment/cone_beam.py b/src/corrct/alignment/cone_beam.py index 9c629c7..0ffdd95 100644 --- a/src/corrct/alignment/cone_beam.py +++ b/src/corrct/alignment/cone_beam.py @@ -12,7 +12,7 @@ import matplotlib.pyplot as plt import numpy as np -from numpy.typing import NDArray +from numpy.typing import DTypeLike, NDArray from scipy.spatial.transform import Rotation from tqdm.auto import tqdm @@ -26,11 +26,33 @@ def _class_to_json(obj: object) -> str: return json.dumps(obj, default=lambda o: {o.__class__.__name__: o.__dict__}, sort_keys=True, indent=4) -def _get_rot_axis_angle_rad(center_1_vu: Sequence[float] | NDArray, center_2_vu: Sequence[float] | NDArray) -> float: - diffs_vu = np.array(center_1_vu) - np.array(center_2_vu) +def _get_rot_axis_angle_deg( + center_1_vu: Sequence[float] | NDArray, + center_2_vu: Sequence[float] | NDArray, + decimals: int | None = 4, + dtype: DTypeLike = np.float32, +) -> float: + center_1_vu = np.squeeze(np.array(center_1_vu, dtype=dtype)) + center_2_vu = np.squeeze(np.array(center_2_vu, dtype=dtype)) + + # Check if the arrays are 2D + if center_1_vu.ndim != 1 or center_2_vu.ndim != 1: + raise ValueError("Input arrays must be 1D") + + # Check if the first dimension has length 2 + if center_1_vu.shape[0] != 2 or center_2_vu.shape[0] != 2: + raise ValueError("Input arrays must have length 2") + + diffs_vu = center_1_vu - center_2_vu angle_rad = np.arctan2(diffs_vu[-1], diffs_vu[-2]) angle_rad = np.mod(angle_rad, 2 * np.pi) - return angle_rad - np.pi + + angle_deg = np.rad2deg(angle_rad - np.pi) + + if decimals is not None: + angle_deg = np.around(angle_deg, decimals=decimals) + + return float(angle_deg) @dataclass @@ -292,43 +314,37 @@ def __init__( self._initialize() def _initialize(self, use_least_squares: bool = False) -> None: - ell1_acq_cvu = fit_ellipse_center(self.points_ell1, use_l1_norm=not use_least_squares) - ell2_acq_cvu = fit_ellipse_center(self.points_ell2, use_l1_norm=not use_least_squares) + ell1_fit_prj_c_vu = fit_ellipse_center(self.points_ell1, use_l1_norm=not use_least_squares) + ell2_fit_prj_c_vu = fit_ellipse_center(self.points_ell2, use_l1_norm=not use_least_squares) if self.points_axis is not None: # Using measured projected center, whenever available - self.ell1_prj_center_vu = self.points_axis[:, 0] - self.ell2_prj_center_vu = self.points_axis[:, 2] - - self.prj_origin_vu = self.points_axis[:, 1] + ell1_acq_prj_c_vu = self.points_axis[:, 0] + ell2_acq_prj_c_vu = self.points_axis[:, 2] if self.verbose: - # Calculate the difference in pixels between the computed and measured ellipse centers - diff_ell1 = ell1_acq_cvu - self.ell1_prj_center_vu - diff_ell2 = ell2_acq_cvu - self.ell2_prj_center_vu - - print("Difference between computed and measured ellipse centers:") - print("- Ellipse 1:") - print(f" Positions: computed={ell1_acq_cvu} vs acquired={self.ell1_prj_center_vu}") - print( - f" Norm: {np.linalg.norm(diff_ell1):.2f} pixels, Coordinates: x={diff_ell1[0]:.2f} pixels, y={diff_ell1[1]:.2f} pixels" - ) - print("- Ellipse 2:") - print(f" Positions: computed={ell2_acq_cvu} vs acquired={self.ell2_prj_center_vu}") - print( - f" Norm: {np.linalg.norm(diff_ell2):.2f} pixels, Coordinates: x={diff_ell2[0]:.2f} pixels, y={diff_ell2[1]:.2f} pixels" - ) + print("Difference between fitted and measured ellipse centers:") + print(f"- Ellipse 1: fitted={ell1_fit_prj_c_vu} vs acquired={ell1_acq_prj_c_vu}") + print(f"- Ellipse 2: fitted={ell2_fit_prj_c_vu} vs acquired={ell2_acq_prj_c_vu}") + fit_eta_deg = _get_rot_axis_angle_deg(ell1_fit_prj_c_vu, ell2_fit_prj_c_vu) + acq_eta_deg = _get_rot_axis_angle_deg(ell1_acq_prj_c_vu, ell2_acq_prj_c_vu) + print(f"- Eta differences: fitted={fit_eta_deg:.4} vs acq={acq_eta_deg:.4}") + + self.ell1_prj_c_vu = ell1_acq_prj_c_vu + self.ell2_prj_c_vu = ell2_acq_prj_c_vu + + self.prj_origin_vu = self.points_axis[:, 1] else: - self.ell1_prj_center_vu = ell1_acq_cvu - self.ell2_prj_center_vu = ell2_acq_cvu + self.ell1_prj_c_vu = ell1_fit_prj_c_vu + self.ell2_prj_c_vu = ell2_fit_prj_c_vu self.prj_origin_vu = None - self.acq_geom.eta_deg = np.rad2deg(_get_rot_axis_angle_rad(self.ell1_prj_center_vu, self.ell2_prj_center_vu)) + self.acq_geom.eta_deg = _get_rot_axis_angle_deg(self.ell1_prj_c_vu, self.ell2_prj_c_vu) if self.verbose: print(f"Projected origin on the detector (pix): {self.prj_origin_vu}") - print(f"Detector tilt around its normal (eta), fitted (deg): {self.acq_geom.eta_deg}") + print(f"Detector tilt around its normal (eta), fitted (deg): {self.acq_geom.eta_deg:.4}") if np.abs(self.acq_geom.eta_deg) > 0.1: rot = Rotation.from_rotvec(-np.deg2rad(self.acq_geom.eta_deg) * np.array([0, 0, 1])) @@ -350,7 +366,7 @@ def _initialize(self, use_least_squares: bool = False) -> None: axs.plot(self.points_ell2[1, :], self.points_ell2[0, :], "C1--", label="Ellipse 2 - Acquired") axs.plot(points_ell1_rot[1, :], points_ell1_rot[0, :], "C0", label="Ellipse 1 - Rotated") axs.plot(points_ell2_rot[1, :], points_ell2_rot[0, :], "C1", label="Ellipse 2 - Rotated") - axs.plot([ell1_acq_cvu[1], ell2_acq_cvu[1]], [ell1_acq_cvu[0], ell2_acq_cvu[0]], "C2--") + axs.plot([ell1_fit_prj_c_vu[1], ell2_fit_prj_c_vu[1]], [ell1_fit_prj_c_vu[0], ell2_fit_prj_c_vu[0]], "C2--") axs.plot([self.ell1_rot.u, self.ell2_rot.u], [self.ell1_rot.v, self.ell2_rot.v], "C2") if self.points_axis is not None: axs.scatter(self.points_axis[1], self.points_axis[0], c="C2", marker="*", label="Centers - Acquired") @@ -438,7 +454,7 @@ def get_zeta(bk, ak, ck, D, sign_zk) -> float: self.acq_geom.R = (-self.z2 * R_e1 + self.z1 * R_e2) / z_full if self.prj_origin_vu is None: - self.prj_origin_vu = (-self.z2 * self.ell1_prj_center_vu + self.z1 * self.ell2_prj_center_vu) / z_full + self.prj_origin_vu = (-self.z2 * self.ell1_prj_c_vu + self.z1 * self.ell2_prj_c_vu) / z_full if self.verbose: print(f"Projected origin on the detector (pix): {self.prj_origin_vu}") From 3aee57d6c81c494b42f563824a1a23dbc2762f1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicola=20VIGAN=C3=92?= Date: Mon, 1 Jun 2026 01:43:42 +0200 Subject: [PATCH 11/26] First round of geometry overhaul, including a fix for specific ellipses positions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Nicola VIGANÒ --- src/corrct/alignment/cone_beam.py | 211 ++++++++++++++++++++---------- 1 file changed, 144 insertions(+), 67 deletions(-) diff --git a/src/corrct/alignment/cone_beam.py b/src/corrct/alignment/cone_beam.py index 0ffdd95..695046a 100644 --- a/src/corrct/alignment/cone_beam.py +++ b/src/corrct/alignment/cone_beam.py @@ -67,12 +67,13 @@ class ConeBeamGeometry: theta_deg: float = 0.0 phi_deg: float = 0.0 eta_deg: float = 0.0 - D: float = 0.0 - R: float = 0.0 - v0: float = 0.0 - u0: float = 0.0 - det_pix_v: int = 0 - det_pix_u: int = 0 + D_pix: float = 0.0 + R_pix: float = 0.0 + v0_pix: float = 0.0 + u0_pix: float = 0.0 + det_size_v_pix: int = 0 + det_size_u_pix: int = 0 + pix_size_um: float = 0.0 def __str__(self) -> str: """ @@ -87,7 +88,13 @@ def __str__(self) -> str: for field, value in self.__dict__.items(): descr += f" {field} = {value}" if field.lower()[-3:] == "deg": - descr += f" ({value} deg)" + descr += " [deg]" + elif field.lower()[-3:] == "pix": + descr += " [pix]" + if self.pix_size_um > 0.0: + descr += f" ({value * self.pix_size_um} [um])" + elif field.lower()[-2:] == "um": + descr += " [um]" descr += ",\n" return descr + ")" @@ -127,15 +134,15 @@ def get_prj_geom(self, translate_z_to_center: bool = True) -> ProjectionGeometry ] ) - det_pos_xyz = -e_n_xyz * self.D + e_x_xyz * self.R - alpha_xyz * self.u0 - beta_xyz * self.v0 - src_pos_xyz = e_x_xyz * self.R + det_pos_xyz = -e_n_xyz * self.D_pix + e_x_xyz * self.R_pix - alpha_xyz * self.u0_pix - beta_xyz * self.v0_pix + src_pos_xyz = e_x_xyz * self.R_pix - pix2vox_ratio = self.R / self.D * np.abs(np.dot(e_n_xyz, e_x_xyz)) + pix2vox_ratio = self.R_pix / self.D_pix * np.abs(np.dot(e_n_xyz, e_x_xyz)) if translate_z_to_center: - det_center_xyz = e_n_xyz * self.D + alpha_xyz * self.u0 + beta_xyz * self.v0 + det_center_xyz = e_n_xyz * self.D_pix + alpha_xyz * self.u0_pix + beta_xyz * self.v0_pix - translation_z = det_center_xyz[2] / np.abs(det_center_xyz[0]) * self.R + translation_z = det_center_xyz[2] / np.abs(det_center_xyz[0]) * self.R_pix src_pos_xyz[2] += translation_z det_pos_xyz[2] += translation_z @@ -163,7 +170,7 @@ def get_vol_geom(self, up_sampling: int = 1) -> VolumeGeometry: The volume geometry. """ return VolumeGeometry( - _vol_shape_xyz=np.array([self.det_pix_u, self.det_pix_u, self.det_pix_v], dtype=int) * up_sampling, + _vol_shape_xyz=np.array([self.det_size_u_pix, self.det_size_u_pix, self.det_size_v_pix], dtype=int) * up_sampling, vox_size=1 / up_sampling, ) @@ -271,6 +278,7 @@ def __init__( points_ell1: Sequence[Sequence[float]] | NDArray, points_ell2: Sequence[Sequence[float]] | NDArray, points_axis: Sequence[Sequence[float]] | NDArray | None = None, + pix_size_um: float | None = None, verbose: bool = True, plot_result: bool = False, ): @@ -292,18 +300,38 @@ def __init__( Whether to plot the results of the geometry, by default False It requires verbose to be True. """ - self.prj_size_vu = np.array(prj_size_vu) - self.center_vu = self.prj_size_vu[:, None] / 2 + prj_size_vu = np.array(prj_size_vu) + if prj_size_vu.ndim != 1 or len(prj_size_vu) != 2: + if len(prj_size_vu) == 1: + prj_size_vu = np.tile(prj_size_vu, 2) + else: + raise ValueError("prj_size_vu must be a 1D array with 2 elements") + self.prj_size_vu = prj_size_vu + + self.center_vu = np.squeeze(self.prj_size_vu)[:, None] / 2 self.prj_origin_vu = None - self.points_ell1 = np.array(points_ell1) - self.center_vu - self.points_ell2 = np.array(points_ell2) - self.center_vu + points_ell1 = np.array(points_ell1) + if points_ell1.ndim != 2: + raise ValueError("points_ell1 must be a 2D array") + if points_ell1.shape[0] != 2: + raise ValueError("points_ell1 must have a first dimension equal to 2") + self.points_ell1 = points_ell1 - self.center_vu + + points_ell2 = np.array(points_ell2) + if points_ell2.ndim != 2: + raise ValueError("points_ell2 must be a 2D array") + if points_ell2.shape[0] != 2: + raise ValueError("points_ell2 must have a first dimension equal to 2") + self.points_ell2 = points_ell2 - self.center_vu if points_axis is not None: points_axis = np.array(points_axis) - self.center_vu self.points_axis = points_axis - self.acq_geom = ConeBeamGeometry(det_pix_v=int(self.prj_size_vu[0]), det_pix_u=int(self.prj_size_vu[1])) + self.acq_geom = ConeBeamGeometry(det_size_v_pix=int(self.prj_size_vu[0]), det_size_u_pix=int(self.prj_size_vu[1])) + if pix_size_um is not None: + self.acq_geom.pix_size_um = pix_size_um self.verbose = verbose self.plot_result = plot_result and verbose @@ -316,35 +344,43 @@ def __init__( def _initialize(self, use_least_squares: bool = False) -> None: ell1_fit_prj_c_vu = fit_ellipse_center(self.points_ell1, use_l1_norm=not use_least_squares) ell2_fit_prj_c_vu = fit_ellipse_center(self.points_ell2, use_l1_norm=not use_least_squares) + fit_eta_deg = _get_rot_axis_angle_deg(ell1_fit_prj_c_vu, ell2_fit_prj_c_vu) + + if self.verbose: + print("Fitted / measured values:") if self.points_axis is not None: # Using measured projected center, whenever available ell1_acq_prj_c_vu = self.points_axis[:, 0] ell2_acq_prj_c_vu = self.points_axis[:, 2] + acq_eta_deg = _get_rot_axis_angle_deg(ell1_acq_prj_c_vu, ell2_acq_prj_c_vu) if self.verbose: - print("Difference between fitted and measured ellipse centers:") - print(f"- Ellipse 1: fitted={ell1_fit_prj_c_vu} vs acquired={ell1_acq_prj_c_vu}") - print(f"- Ellipse 2: fitted={ell2_fit_prj_c_vu} vs acquired={ell2_acq_prj_c_vu}") - fit_eta_deg = _get_rot_axis_angle_deg(ell1_fit_prj_c_vu, ell2_fit_prj_c_vu) - acq_eta_deg = _get_rot_axis_angle_deg(ell1_acq_prj_c_vu, ell2_acq_prj_c_vu) - print(f"- Eta differences: fitted={fit_eta_deg:.4} vs acq={acq_eta_deg:.4}") + print(f"- Ellipse 1 center: fitted = {ell1_fit_prj_c_vu} vs acquired = {ell1_acq_prj_c_vu} [pix]") + print(f"- Ellipse 2 center: fitted = {ell2_fit_prj_c_vu} vs acquired = {ell2_acq_prj_c_vu} [pix]") + print("- Detector tilt around its normal (eta):") + print(f" * fitted = {fit_eta_deg:.4} vs acq = {acq_eta_deg:.4} [deg] <= Using acquired!") self.ell1_prj_c_vu = ell1_acq_prj_c_vu self.ell2_prj_c_vu = ell2_acq_prj_c_vu self.prj_origin_vu = self.points_axis[:, 1] + self.acq_geom.eta_deg = acq_eta_deg else: self.ell1_prj_c_vu = ell1_fit_prj_c_vu self.ell2_prj_c_vu = ell2_fit_prj_c_vu self.prj_origin_vu = None + self.acq_geom.eta_deg = fit_eta_deg + if self.verbose: + print(f"- Detector tilt around its normal (eta), fitted: {self.acq_geom.eta_deg:.4} [deg]") - self.acq_geom.eta_deg = _get_rot_axis_angle_deg(self.ell1_prj_c_vu, self.ell2_prj_c_vu) - - if self.verbose: - print(f"Projected origin on the detector (pix): {self.prj_origin_vu}") - print(f"Detector tilt around its normal (eta), fitted (deg): {self.acq_geom.eta_deg:.4}") + pix_size_um = self.acq_geom.pix_size_um + if self.verbose and self.prj_origin_vu is not None: + print( + f"- Projected origin on the detector: {self.prj_origin_vu} [pix]", + f"({self.prj_origin_vu * pix_size_um} [um])" if pix_size_um > 0.0 else "", + ) if np.abs(self.acq_geom.eta_deg) > 0.1: rot = Rotation.from_rotvec(-np.deg2rad(self.acq_geom.eta_deg) * np.array([0, 0, 1])) @@ -375,68 +411,96 @@ def _initialize(self, use_least_squares: bool = False) -> None: fig.tight_layout() plt.show(block=False) - self.acq_geom.D = self._fit_distance_det2src(self.ell1_rot, self.ell2_rot) - - if self.verbose: - print(f"Fitted detector distance from source (pix): {self.acq_geom.D}") - - def fit(self, r: float, e: float = 1) -> ConeBeamGeometry: + def fit(self, r: float, e: float = 1, meas_D_pix: float | None = None) -> ConeBeamGeometry: """ Fit the cone-beam geometry parameters, that will be used for producing the projection geometry. Parameters ---------- r : float - The radius of the circle performed by the spheres. + The radius of the circle performed by the spheres in pixels. e : float, optional Either 1 or -1, indicating whether the source is between the circles or not. The default is 1. + meas_D_pix : float, optional + The measured source-detector distance in pixels. This parameter is only necessary when the + computed source-detector distance is invalid or zero. Raises ------ ValueError - In case of flipped ellipses. + In case of flipped ellipses or invalid computed source-detector distance. """ + + def get_v0(v: float, b: float, a: float, c: float, D: float, sign_z: float) -> float: + return v - sign_z * np.sqrt(a + (a**2) * (D**2)) / np.sqrt(a * b - (c**2)) + + def get_denom(b: float, a: float, c: float, D: float) -> float: + return np.sqrt(a * b + (a**2) * b * (D**2) - (c**2)) + + def get_rho(b: float, a: float, c: float, D: float) -> float: + return np.sqrt(a * b - (c**2)) / get_denom(b, a, c, D) + + def get_zeta(b: float, a: float, c: float, D: float, sign_zk: float) -> float: + return D * sign_zk * a * np.sqrt(a) / get_denom(b, a, c, D) + b1, a1, c1, v1, u1 = self.ell1_rot.parameters b2, a2, c2, v2, u2 = self.ell2_rot.parameters - sign_z1 = -1 - sign_z2 = sign_z1 * -e + if self.verbose: + print("Fitted values from the calibration scan parameters:") + print("- Ellipses' parameters:") + print(f" * upper ({a1 = :.6}, {b1 = :.6}, {c1 = :.6}, {v1 = :.6}, {u1 = :.6})") + print(f" * lower ({a2 = :.6}, {b2 = :.6}, {c2 = :.6}, {v2 = :.6}, {u2 = :.6})") - def get_v0(vk, bk, ak, ck, D, sign_zk) -> float: - return vk - sign_zk * np.sqrt(ak + (ak**2) * (D**2)) / np.sqrt(ak * bk - (ck**2)) + pix_size_um = self.acq_geom.pix_size_um - def get_denom(bk, ak, ck, D) -> float: - return np.sqrt(ak * bk + (ak**2) * bk * (D**2) - (ck**2)) + comp_D_pix = self._fit_distance_det2src(self.ell1_rot, self.ell2_rot, e=e) + if meas_D_pix is None: + if np.isnan(comp_D_pix) or np.isclose(comp_D_pix, 0.0): + raise ValueError( + f"The computed source-detector distance is invalid ({comp_D_pix}), please enter a measured value" + ) - def get_rho(bk, ak, ck, D) -> float: - return np.sqrt(ak * bk - (ck**2)) / get_denom(bk, ak, ck, D) + self.acq_geom.D_pix = comp_D_pix - def get_zeta(bk, ak, ck, D, sign_zk) -> float: - return D * sign_zk * ak * np.sqrt(ak) / get_denom(bk, ak, ck, D) + if self.verbose: + print( + f"- Computed source-detector distance: {self.acq_geom.D_pix:.6} [pix]", + f"({self.acq_geom.D_pix * pix_size_um:.4e} [um])" if pix_size_um > 0.0 else "", + ) + else: + self.acq_geom.D_pix = meas_D_pix - v01 = get_v0(v1, b1, a1, c1, self.acq_geom.D, sign_z1) - v02 = get_v0(v2, b2, a2, c2, self.acq_geom.D, sign_z2) + if self.verbose: + print("- Source-detector distance (using measured):") + print(f" * Measured = {meas_D_pix:.6} vs computed = {comp_D_pix:.6} [pix]") + print(f" * Measured = {meas_D_pix*pix_size_um:.6} vs computed = {comp_D_pix*pix_size_um:.6} [um]") - self.acq_geom.v0 = np.array([v01, v02]).mean() - self.acq_geom.u0 = ( - np.mean([u1, u2]) + c1 / (2 * a1) * (v1 - self.acq_geom.v0) + c2 / (2 * a2) * (v2 - self.acq_geom.v0) + sign_z1 = -1 + sign_z2 = sign_z1 * -e + + v01 = get_v0(v1, b1, a1, c1, self.acq_geom.D_pix, sign_z1) + v02 = get_v0(v2, b2, a2, c2, self.acq_geom.D_pix, sign_z2) + + self.acq_geom.v0_pix = np.array([v01, v02]).mean() + self.acq_geom.u0_pix = ( + np.mean([u1, u2]) + c1 / (2 * a1) * (v1 - self.acq_geom.v0_pix) + c2 / (2 * a2) * (v2 - self.acq_geom.v0_pix) ) if self.verbose: - print(f"Ellipses' positions:\n- upper (v1={v1}, u1={u1})\n- lower (v2={v2}, u2={u2})") - print(f"Fitted source position over detector: v0={self.acq_geom.v0}, u0={self.acq_geom.u0}") - print(f"- Separately fitted v: v01={v01}, v02={v02}") + print(f"- Source position over detector: v0 = {self.acq_geom.v0_pix:.6}, u0 = {self.acq_geom.u0_pix:.6}") + print(f" * Separately fitted v (from the two ellipses): {v01 = :.6}, {v02 = :.6}") if np.linalg.norm(v01 - v02) > np.linalg.norm(v1 - v2): raise ValueError( f"Obtained: v01={v01}, v02={v02}, while v1={v1}, v2={v2}. Probably wrong order of ellipses (please flip them!)" ) - rho1 = get_rho(b1, a1, c1, self.acq_geom.D) - rho2 = get_rho(b2, a2, c2, self.acq_geom.D) + rho1 = get_rho(b1, a1, c1, self.acq_geom.D_pix) + rho2 = get_rho(b2, a2, c2, self.acq_geom.D_pix) - zeta1 = get_zeta(b1, a1, c1, self.acq_geom.D, sign_z1) - zeta2 = get_zeta(b2, a2, c2, self.acq_geom.D, sign_z2) + zeta1 = get_zeta(b1, a1, c1, self.acq_geom.D_pix, sign_z1) + zeta2 = get_zeta(b2, a2, c2, self.acq_geom.D_pix, sign_z2) self.acq_geom.phi_deg = np.rad2deg(np.arcsin(-c1 / (2 * a1) * zeta1 - c2 / (2 * a2) * zeta2)) @@ -448,21 +512,34 @@ def get_zeta(bk, ak, ck, D, sign_zk) -> float: z_full = self.z1 - self.z2 - self.acq_geom.theta_deg = 0.0 - # self.acq_geom.theta_rad = np.arcsin((R1 - R2) / z_full) / np.mean(self.prj_size_vu) + self.acq_geom.R_pix = (-self.z2 * R_e1 + self.z1 * R_e2) / z_full - self.acq_geom.R = (-self.z2 * R_e1 + self.z1 * R_e2) / z_full + if np.isnan(self.acq_geom.R_pix) or np.isclose(self.acq_geom.R_pix, 0.0): + raise ValueError(f"The computed source-origin distance is invalid ({self.acq_geom.R_pix})") if self.prj_origin_vu is None: self.prj_origin_vu = (-self.z2 * self.ell1_prj_c_vu + self.z1 * self.ell2_prj_c_vu) / z_full if self.verbose: - print(f"Projected origin on the detector (pix): {self.prj_origin_vu}") + print(f"- Projected origin on the detector: {self.prj_origin_vu} [pix]") + + self.acq_geom.theta_deg = 0.0 + # self.acq_geom.theta_rad = np.arcsin((R1 - R2) / z_full) / np.mean(self.prj_size_vu) if self.verbose: - print(f"Fitted distances between source and rotation axis (pix):\n- R1={R_e1}, R={self.acq_geom.R}, R2={R_e2}") - print(f"Fitted heights of the two ellipses, with respect to the source (pix): z1={self.z1}, z2={self.z2}") - print(f"Fitted polar angle of the detector (phi deg): {self.acq_geom.phi_deg}") - print(f"Fitted azimuthal angle of the detector (theta deg): {self.acq_geom.theta_deg}") + print("- Distances between source and rotation axis:") + print(f" * R1 = {R_e1:.6}, R = {self.acq_geom.R_pix:.6}, R2 = {R_e2:.6} [pix]") + if pix_size_um > 0.0: + print( + f" * R1 = {R_e1 * pix_size_um:.4e},", + f"R = {self.acq_geom.R_pix * pix_size_um:.4e},", + f"R2 = {R_e2 * pix_size_um:.4e} [um]", + ) + print("- Heights of the two ellipses, with respect to the source:") + print(f" * z1 = {self.z1:.6}, z2 = {self.z2:.6} [pix]") + if pix_size_um > 0.0: + print(f" * z1 = {self.z1 * pix_size_um:.4e}, z2 = {self.z2 * pix_size_um:.4e} [um]") + print(f"- Polar angle of the detector (phi deg): {self.acq_geom.phi_deg}") + print(f"- Azimuthal angle of the detector (theta deg): {self.acq_geom.theta_deg}") return self.acq_geom From 9399822bfdac93e241b79828958141560eae3a08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicola=20VIGAN=C3=92?= Date: Mon, 1 Jun 2026 12:29:40 +0200 Subject: [PATCH 12/26] Tests: added one simple unit test for the geometry calibration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Nicola VIGANÒ --- tests/alignment/test_cone_beam.py | 358 ++++++++++++++++++++++++++++++ 1 file changed, 358 insertions(+) create mode 100644 tests/alignment/test_cone_beam.py diff --git a/tests/alignment/test_cone_beam.py b/tests/alignment/test_cone_beam.py new file mode 100644 index 0000000..8199094 --- /dev/null +++ b/tests/alignment/test_cone_beam.py @@ -0,0 +1,358 @@ +from collections.abc import Sequence +from dataclasses import dataclass + +import matplotlib.pyplot as plt +import numpy as np +from mpl_toolkits.mplot3d.art3d import Poly3DCollection +from numpy.typing import NDArray +from scipy.spatial.transform import Rotation + +from corrct.alignment.cone_beam import FitConeBeamGeometry + + +@dataclass +class DetectorGeometry: + det_u_xyz: NDArray + det_v_xyz: NDArray + det_center_xyz: NDArray + src_pos_xyz: NDArray + u0: float + v0: float + pu: float + pv: float + + +def compute_detector_geometry( + R: float, + d: Sequence[float] | NDArray, + p: float, + t: float, + n: float, + pu: float, + pv: float, + u_sign: int = 1, + v_sign: int = -1, +) -> DetectorGeometry: + """ + Compute the detector geometry (u, v, center, u0, v0) from the given parameters. + + Parameters: + - R: Distance from source to volume center. + - d: Vector (dx, dy, dz) from volume center to detector center in XYZ. + - p, t, n: Detector tilts (phi, theta, eta) in radians. + - pu, pv: Pixel sizes in U and V directions. + - u_sign, v_sign: Sign convention for U and V axes (default: 1, -1). + + Returns: + - DetectorGeometry dataclass with computed properties. + """ + d = np.array(d) + src_pos = np.array([-R, 0, 0]) + det_center = d + + # Apply tilts in order: p (phi), t (theta), n (eta) + # p (phi) around Z: + # [cos(p), -sin(p), 0] + # [sin(p), cos(p), 0] + # [0, 0, 1] + rot_p = Rotation.from_rotvec([0, 0, p]) + # t (theta) around Y: + # [cos(t), 0, sin(t)] + # [0, 1, 0 ] + # [-sin(t), 0, cos(t)] + rot_t = Rotation.from_rotvec([0, t, 0]) + # n (eta) around X: + # [1, 0, 0] + # [0, cos(n), -sin(n)] + # [0, sin(n), cos(n)] + rot_n = Rotation.from_rotvec([n, 0, 0]) + + # Compute the u and v vectors + det_u = np.array([0, u_sign * 1, 0]) + det_v = np.array([0, 0, v_sign * 1]) + + det_u = rot_p.apply(det_u) + det_u = rot_t.apply(det_u) + det_u = rot_n.apply(det_u) + + det_v = rot_p.apply(det_v) + det_v = rot_t.apply(det_v) + det_v = rot_n.apply(det_v) + + det_normal = np.cross(det_u, det_v) + + # Compute (u0, v0) as the projection of the source on the detector + vec_source_to_detector = det_center - src_pos + t = np.dot(det_normal, vec_source_to_detector) / np.dot(det_normal, det_normal) + projection_source = src_pos + t * vec_source_to_detector + vec_detector_to_projection = projection_source - det_center + u0 = np.dot(vec_detector_to_projection, det_u) / pu + v0 = np.dot(vec_detector_to_projection, det_v) / pv + + return DetectorGeometry( + det_u_xyz=det_u, + det_v_xyz=det_v, + det_center_xyz=det_center, + src_pos_xyz=src_pos, + u0=u0, + v0=v0, + pu=pu, + pv=pv, + ) + + +def project_to_uv(points_xyz: NDArray, geo: DetectorGeometry) -> NDArray: + """ + Project a list of XYZ points to UV coordinates using precomputed geometry. + + Parameters: + - xyz_points: Array of (x, y, z) points in the sample volume (XYZ coordinates). + - geo: DetectorGeometry dataclass with precomputed properties. + + Returns: + - Array of (u, v) coordinates for each input point. + """ + points_xyz = np.array(points_xyz, dtype=np.float32) + + det_normal_xyz = np.cross(geo.det_u_xyz, geo.det_v_xyz) + + vecs_src_2_pnts = points_xyz - geo.src_pos_xyz + denoms = np.dot(det_normal_xyz, vecs_src_2_pnts.T) + + t0 = np.dot(det_normal_xyz, geo.det_center_xyz - geo.src_pos_xyz) + + # Project each rotated point onto the tilted detector + uv_coords = np.zeros((len(points_xyz), 2), dtype=np.float32) + for ii_pnt, (vec_src_2_pnt, denom) in enumerate(zip(vecs_src_2_pnts, denoms)): + + if np.isclose(denom, 0.0): + uv_coords[ii_pnt] = (np.nan, np.nan) + continue + + intersection = geo.src_pos_xyz + t0 / denom * vec_src_2_pnt + vec_detector_to_intersection = intersection - geo.det_center_xyz + + # Project onto U and V axes + u = np.dot(vec_detector_to_intersection, geo.det_u_xyz) / geo.pu + geo.u0 + v = np.dot(vec_detector_to_intersection, geo.det_v_xyz) / geo.pv + geo.v0 + + uv_coords[ii_pnt] = (u, v) + + return uv_coords + + +def compute_xyz_rotated(r: float, z: float, ws: NDArray, sx: float = 1.0, sy: float = 1.0, sz: float = 1.0) -> NDArray: + """ + Compute XYZ coordinates for an array of (r, z, w) inputs in the rotated volume. + + Parameters: + - r: Radial distance from volume center. + - z: Elevation (Z coordinates). + - w: Array of rotation angles (omega) in radians. + - sx, sy, sz: Voxel sizes in X, Y, Z directions. + + Returns: + - Array of (x, y, z) coordinates in the rotated volume. + """ + ws = np.squeeze(np.array(ws)) + + # Check if ws is one-dimensional + if ws.ndim != 1: + raise ValueError("Input array ws must be one-dimensional.") + + # At w=0, the point is along Y: (0, r, z) + # Rotation matrix for w (omega) around Z: + # [cos(w), -sin(w), 0] + # [sin(w), cos(w), 0] + # [0, 0, 1] + rot_w = Rotation.from_rotvec(np.array([np.zeros_like(ws), np.zeros_like(ws), ws]).T) + + rot_xyz = rot_w.apply(np.array([0, r, z]).T) + + # Scale by voxel sizes + return rot_xyz * np.array((sx, sy, sz)) + + +def plot_projection_geometry( + geo: DetectorGeometry, + det_size_uv: tuple[int, int], + vol_size_xyz: tuple[int, int, int], + voxel_size_xyz: tuple[float, float, float] = (1.0, 1.0, 1.0), +): + """ + Plot the projection geometry using matplotlib. + + Parameters: + - geo: DetectorGeometry dataclass with precomputed properties. + - det_size_uv: Detector size in number of pixels in U and V directions. + - vol_size_xyz: Volume size in number of voxels in X, Y, and Z directions. + - voxel_size_xyz: Voxel sizes in X, Y, Z directions. + """ + fig = plt.figure(figsize=(10, 8)) + ax = fig.add_subplot(111, projection='3d') + + # Plot the source + ax.scatter(*geo.src_pos_xyz, color='r', s=100, label='Source') + # print(geo.src_pos_xyz) + + # Plot the detector + det_corners = np.array( + [ + geo.det_center_xyz + geo.det_u_xyz * det_size_uv[0] * geo.pu / 2 + geo.det_v_xyz * det_size_uv[1] * geo.pv / 2, + geo.det_center_xyz + geo.det_u_xyz * det_size_uv[0] * geo.pu / 2 - geo.det_v_xyz * det_size_uv[1] * geo.pv / 2, + geo.det_center_xyz - geo.det_u_xyz * det_size_uv[0] * geo.pu / 2 - geo.det_v_xyz * det_size_uv[1] * geo.pv / 2, + geo.det_center_xyz - geo.det_u_xyz * det_size_uv[0] * geo.pu / 2 + geo.det_v_xyz * det_size_uv[1] * geo.pv / 2, + ] + ) + # print(det_corners) + + detector = Poly3DCollection([det_corners], alpha=0.5, linewidths=1, edgecolors='k') + detector.set_facecolor('b') + ax.add_collection3d(detector) + + # Plot the volume + x, y, z = [size * voxel_size / 2 for size, voxel_size in zip(vol_size_xyz, voxel_size_xyz)] + + # Create a cube + cube_vertices = np.array( + [ + [-x, -y, -z], + [x, -y, -z], + [x, y, -z], + [-x, y, -z], + [-x, -y, z], + [x, -y, z], + [x, y, z], + [-x, y, z], + ] + ) + # print(cube_vertices) + + # Create the 8 faces of the cube + cube_faces = [ + [cube_vertices[0], cube_vertices[1], cube_vertices[2], cube_vertices[3]], # Bottom face + [cube_vertices[4], cube_vertices[5], cube_vertices[6], cube_vertices[7]], # Top face + [cube_vertices[0], cube_vertices[1], cube_vertices[5], cube_vertices[4]], # Front face + [cube_vertices[2], cube_vertices[3], cube_vertices[7], cube_vertices[6]], # Back face + [cube_vertices[1], cube_vertices[2], cube_vertices[6], cube_vertices[5]], # Right face + [cube_vertices[0], cube_vertices[3], cube_vertices[7], cube_vertices[4]], # Left face + ] + + volume = Poly3DCollection(cube_faces, alpha=0.1, linewidths=1, edgecolors='k') + volume.set_facecolor('g') + ax.add_collection3d(volume) + + # Plot vectors from the origin to the source and detector center + ax.quiver( + [0, 0], + [0, 0], + [0, 0], + [geo.src_pos_xyz[0], geo.det_center_xyz[0]], + [geo.src_pos_xyz[1], geo.det_center_xyz[1]], + [geo.src_pos_xyz[2], geo.det_center_xyz[2]], + color=['r', 'b'], + arrow_length_ratio=0.1, + label='Source and Detector Center', + ) + + # Plot vectors from the detector center to the u and v unit vectors + ax.quiver( + [geo.det_center_xyz[0], geo.det_center_xyz[0]], + [geo.det_center_xyz[1], geo.det_center_xyz[1]], + [geo.det_center_xyz[2], geo.det_center_xyz[2]], + [geo.det_u_xyz[0] * det_size_uv[0] * geo.pu / 4, geo.det_v_xyz[0] * det_size_uv[1] * geo.pv / 4], + [geo.det_u_xyz[1] * det_size_uv[0] * geo.pu / 4, geo.det_v_xyz[1] * det_size_uv[1] * geo.pv / 4], + [geo.det_u_xyz[2] * det_size_uv[0] * geo.pu / 4, geo.det_v_xyz[2] * det_size_uv[1] * geo.pv / 4], + color=['m', 'c'], + arrow_length_ratio=0.1, + label='U and V Vectors', + ) + + # Set labels and legend + ax.set_xlabel('X') + ax.set_ylabel('Y') + ax.set_zlabel('Z') + ax.legend() + ax.set_aspect("equal") + + plt.show() + + +def test_cone_beam_ellipse(): + debug = False + + R = 30.0 + D = 20.0 + det_size_vu = 12 + + geom = compute_detector_geometry(R=R, d=[D, 0.0, 0.0], p=0.0, t=0.0, n=0.0, pu=1.0, pv=1.0) + if debug: + print(f"{geom = }") + vol_size_xyz = [det_size_vu - 2] * 3 + plot_projection_geometry(geom, det_size_uv=(det_size_vu,) * 2, vol_size_xyz=tuple(vol_size_xyz)) + + r = det_size_vu / 2 - 1 + z_u = 2.5 + z_l = -1.5 + ws = np.deg2rad(np.arange(0, 360, 6)) + points_xyz_u = compute_xyz_rotated(r=r, z=z_u, ws=ws) + points_xyz_l = compute_xyz_rotated(r=r, z=z_l, ws=ws) + + # fig, axs = plt.subplots(1, 1) + # axs.plot(points_xyz[:, 0], points_xyz[:, 1]) + # axs.grid() + # fig.tight_layout() + # plt.show() + + prj_uv_coords_u = project_to_uv(points_xyz_u, geom) + prj_uv_coords_l = project_to_uv(points_xyz_l, geom) + + # fig, axs = plt.subplots(1, 1) + # axs.plot(prj_uv_coords_u[:, 0], prj_uv_coords_u[:, 1]) + # axs.plot(prj_uv_coords_l[:, 0], prj_uv_coords_l[:, 1]) + # axs.grid() + # fig.tight_layout() + # plt.show() + + prj_size_vu = np.array((det_size_vu, det_size_vu)) + + points_ell1 = np.flip(prj_uv_coords_u.T, axis=0) + prj_size_vu[:, None] / 2 + points_ell2 = np.flip(prj_uv_coords_l.T, axis=0) + prj_size_vu[:, None] / 2 + + if debug: + print(f"{points_ell1 = }") + print(f"{points_ell2 = }") + + geom_fit = FitConeBeamGeometry(prj_size_vu=prj_size_vu, points_ell1=points_ell1, points_ell2=points_ell2) + geom_fit.fit(r=r, e=1.0) + + if debug: + print(f"{geom_fit.acq_geom = }") + + assert np.isclose( + D + R, geom_fit.acq_geom.D_pix + ), f"The fitted source-detector distance is wrong ({geom_fit.acq_geom.D_pix:.6} vs expected: {D+R:.6})" + assert np.isclose( + R, geom_fit.acq_geom.R_pix + ), f"The fitted source-sample distance is wrong ({geom_fit.acq_geom.R_pix:.6} vs expected: {R:.6})" + + +if __name__ == "__main__": + # geometry = compute_detector_geometry(R=1e10, d=[1.0, 0.0, 0.0], p=0.0, t=0.0, n=0.0, pu=1.0, pv=1.0) + geometry = compute_detector_geometry(R=160.0, d=[100.0, 0.0, 0.0], p=0.0, t=0.0, n=0.0, pu=1.0, pv=1.0) + geometry = compute_detector_geometry(R=160.0, d=[100.0, 0.0, 0.0], p=0.0, t=0.0, n=np.pi / 4, pu=1.0, pv=1.0) + # geometry = compute_detector_geometry(R=10.0, d=[1.0, 0.0, 0.0], p=0.0, t=0.0, n=np.pi / 2, pu=1.0, pv=1.0) + # geometry = compute_detector_geometry(R=10.0, d=[1.0, 0.0, 0.0], p=0.0, t=0.0, n=-np.pi, pu=1.0, pv=2.0) + print(geometry) + + plot_projection_geometry(geometry, (100, 100), (80, 80, 80)) + + # Project points + xyz_points_tst = np.array([[0, 1, 2], [3, 4, 5]]) + prj_uv_coords = project_to_uv(xyz_points_tst, geometry) + print(prj_uv_coords) + + # Compute rotated XYZ for arrays of (r, z, w) + ws = np.array([0, np.pi / 4, np.pi / 2]) + xyz_rotated = compute_xyz_rotated(r=2.0, z=1.0, ws=ws) + print(xyz_rotated) From 081357432790221eeaecdfbf9955b1080f6677c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicola=20VIGAN=C3=92?= Date: Tue, 2 Jun 2026 12:10:57 +0200 Subject: [PATCH 13/26] Further simplified function arguments and improved presentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Nicola VIGANÒ --- src/corrct/alignment/cone_beam.py | 37 +++++++++++++++++-------------- src/corrct/alignment/fitting.py | 12 +++++----- 2 files changed, 26 insertions(+), 23 deletions(-) diff --git a/src/corrct/alignment/cone_beam.py b/src/corrct/alignment/cone_beam.py index 695046a..4319ebb 100644 --- a/src/corrct/alignment/cone_beam.py +++ b/src/corrct/alignment/cone_beam.py @@ -279,6 +279,7 @@ def __init__( points_ell2: Sequence[Sequence[float]] | NDArray, points_axis: Sequence[Sequence[float]] | NDArray | None = None, pix_size_um: float | None = None, + use_l1_norm: bool = False, verbose: bool = True, plot_result: bool = False, ): @@ -294,6 +295,11 @@ def __init__( Points of second ellipse. points_axis : Sequence[Sequence[float]] | NDArray | None, optional Points of the rotation axis, by default None + pix_size_um : float | None, optional + The size of the pixel edge in micrometers. Default is None. + use_l1_norm : bool, optional + Whether to use the l1-norm or the least-squares (l2-norm) fit for optimization. + Default is False. verbose : bool, optional Whether to produce verbose output, by default True plot_result : bool, optional @@ -336,14 +342,11 @@ def __init__( self.verbose = verbose self.plot_result = plot_result and verbose - self.z1 = np.array([]) - self.z2 = np.array([]) + self._initialize(use_l1_norm=use_l1_norm) - self._initialize() - - def _initialize(self, use_least_squares: bool = False) -> None: - ell1_fit_prj_c_vu = fit_ellipse_center(self.points_ell1, use_l1_norm=not use_least_squares) - ell2_fit_prj_c_vu = fit_ellipse_center(self.points_ell2, use_l1_norm=not use_least_squares) + def _initialize(self, use_l1_norm: bool) -> None: + ell1_fit_prj_c_vu = fit_ellipse_center(self.points_ell1, use_l1_norm=use_l1_norm) + ell2_fit_prj_c_vu = fit_ellipse_center(self.points_ell2, use_l1_norm=use_l1_norm) fit_eta_deg = _get_rot_axis_angle_deg(ell1_fit_prj_c_vu, ell2_fit_prj_c_vu) if self.verbose: @@ -393,8 +396,8 @@ def _initialize(self, use_least_squares: bool = False) -> None: points_ell2_rot = self.points_ell2.copy() # Re-instantiate ellipse class, after rotation - self.ell1_rot: Ellipse = fit_ellipse(points_ell1_rot, use_least_squares=use_least_squares) - self.ell2_rot: Ellipse = fit_ellipse(points_ell2_rot, use_least_squares=use_least_squares) + self.ell1_rot: Ellipse = fit_ellipse(points_ell1_rot, use_l1_norm=use_l1_norm) + self.ell2_rot: Ellipse = fit_ellipse(points_ell2_rot, use_l1_norm=use_l1_norm) if self.plot_result: fig, axs = plt.subplots() @@ -493,7 +496,7 @@ def get_zeta(b: float, a: float, c: float, D: float, sign_zk: float) -> float: if np.linalg.norm(v01 - v02) > np.linalg.norm(v1 - v2): raise ValueError( - f"Obtained: v01={v01}, v02={v02}, while v1={v1}, v2={v2}. Probably wrong order of ellipses (please flip them!)" + f"Obtained: {v01 = }, {v02 = }, while {v1 = }, {v2 = }. Probably wrong order of ellipses (please flip them!)" ) rho1 = get_rho(b1, a1, c1, self.acq_geom.D_pix) @@ -507,18 +510,18 @@ def get_zeta(b: float, a: float, c: float, D: float, sign_zk: float) -> float: R_e1 = r / rho1 R_e2 = r / rho2 - self.z1 = R_e1 * zeta1 - self.z2 = R_e2 * zeta2 + z1 = R_e1 * zeta1 + z2 = R_e2 * zeta2 - z_full = self.z1 - self.z2 + z_full = z1 - z2 - self.acq_geom.R_pix = (-self.z2 * R_e1 + self.z1 * R_e2) / z_full + self.acq_geom.R_pix = (-z2 * R_e1 + z1 * R_e2) / z_full if np.isnan(self.acq_geom.R_pix) or np.isclose(self.acq_geom.R_pix, 0.0): raise ValueError(f"The computed source-origin distance is invalid ({self.acq_geom.R_pix})") if self.prj_origin_vu is None: - self.prj_origin_vu = (-self.z2 * self.ell1_prj_c_vu + self.z1 * self.ell2_prj_c_vu) / z_full + self.prj_origin_vu = (-z2 * self.ell1_prj_c_vu + z1 * self.ell2_prj_c_vu) / z_full if self.verbose: print(f"- Projected origin on the detector: {self.prj_origin_vu} [pix]") @@ -535,9 +538,9 @@ def get_zeta(b: float, a: float, c: float, D: float, sign_zk: float) -> float: f"R2 = {R_e2 * pix_size_um:.4e} [um]", ) print("- Heights of the two ellipses, with respect to the source:") - print(f" * z1 = {self.z1:.6}, z2 = {self.z2:.6} [pix]") + print(f" * {z1 = :.6}, {z2 = :.6} [pix]") if pix_size_um > 0.0: - print(f" * z1 = {self.z1 * pix_size_um:.4e}, z2 = {self.z2 * pix_size_um:.4e} [um]") + print(f" * z1 = {z1 * pix_size_um:.4e}, z2 = {z2 * pix_size_um:.4e} [um]") print(f"- Polar angle of the detector (phi deg): {self.acq_geom.phi_deg}") print(f"- Azimuthal angle of the detector (theta deg): {self.acq_geom.theta_deg}") diff --git a/src/corrct/alignment/fitting.py b/src/corrct/alignment/fitting.py index 02cb7f1..38824fd 100644 --- a/src/corrct/alignment/fitting.py +++ b/src/corrct/alignment/fitting.py @@ -1083,7 +1083,7 @@ def __call__(self, uus: ArrayLike | NDArray) -> Sequence[NDArray]: return v_1, v_2 -def fit_ellipse(prj_points_vu: ArrayLike | NDArray, rescale: bool = True, use_least_squares: bool = True) -> Ellipse: +def fit_ellipse(prj_points_vu: ArrayLike | NDArray, rescale: bool = True, use_l1_norm: bool = False) -> Ellipse: """Fit an ellipse to a set of 2D points using either least-squares or l1-norm optimization. Parameters @@ -1093,9 +1093,9 @@ def fit_ellipse(prj_points_vu: ArrayLike | NDArray, rescale: bool = True, use_le rescale : bool, optional Whether to rescale the data within the interval [-1, 1] to improve numerical stability. Default is True. - use_least_squares : bool, optional - Whether to use the least-squares (l2-norm) fit for optimization. If False, uses l1-norm. - Default is True. + use_l1_norm : bool, optional + Whether to use the l1-norm or the least-squares (l2-norm) fit for optimization. + Default is False. Returns ------- @@ -1111,6 +1111,6 @@ def fit_ellipse(prj_points_vu: ArrayLike | NDArray, rescale: bool = True, use_le prj_points_vu = np.array(prj_points_vu) return Ellipse( - *fit_ellipse_parameters(prj_points_vu, rescale, not use_least_squares), - fit_ellipse_center(prj_points_vu, rescale, not use_least_squares), + *fit_ellipse_parameters(prj_points_vu, rescale, use_l1_norm=use_l1_norm), + fit_ellipse_center(prj_points_vu, rescale, use_l1_norm=use_l1_norm), ) From 6af86c90c8d1d368db19ae62f3a99f398da26b83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicola=20VIGAN=C3=92?= Date: Wed, 3 Jun 2026 18:51:44 +0200 Subject: [PATCH 14/26] Fixed tests and improved geometry plotting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Nicola VIGANÒ --- src/corrct/alignment/cone_beam.py | 1 + src/corrct/models.py | 130 ++++++++++ tests/alignment/test_cone_beam.py | 390 ++++++++++-------------------- 3 files changed, 264 insertions(+), 257 deletions(-) diff --git a/src/corrct/alignment/cone_beam.py b/src/corrct/alignment/cone_beam.py index 4319ebb..0f6d997 100644 --- a/src/corrct/alignment/cone_beam.py +++ b/src/corrct/alignment/cone_beam.py @@ -158,6 +158,7 @@ def get_prj_geom(self, translate_z_to_center: bool = True) -> ProjectionGeometry det_v_xyz=e_v_xyz, rot_dir_xyz=np.array([0, 0, 1]), pix2vox_ratio=pix2vox_ratio, + det_shape_vu=np.array((self.det_size_v_pix, self.det_size_u_pix)), ) def get_vol_geom(self, up_sampling: int = 1) -> VolumeGeometry: diff --git a/src/corrct/models.py b/src/corrct/models.py index a203cb5..314d558 100644 --- a/src/corrct/models.py +++ b/src/corrct/models.py @@ -13,8 +13,10 @@ from dataclasses import replace as dc_replace from typing import Any +import matplotlib.pyplot as plt import numpy as np import scipy.spatial.transform as spt +from mpl_toolkits.mplot3d.art3d import Poly3DCollection from numpy.typing import ArrayLike, NDArray ROT_DIRS_VALID = ("clockwise", "counter-clockwise") @@ -788,3 +790,131 @@ def get_vol_geom_from_volume(volume: NDArray) -> VolumeGeometry: if len(vol_shape_zxy) < 2: raise ValueError(f"The volume should be at least 2-dimensional, but the following shape was passed: {vol_shape_zxy}") return VolumeGeometry(np.array([vol_shape_zxy[-2], vol_shape_zxy[-1], *np.flip(vol_shape_zxy[:-2])])) + + +def plot_projection_geometry(prj_geom: ProjectionGeometry, vol_geom: VolumeGeometry): + """ + Plot the projection geometry using matplotlib. + + Parameters + ---------- + prj_geom : ProjectionGeometry + ProjectionGeometry class with precomputed geometric properties. + vol_size_xyz : VolumeGeometry + VolumeGeometry class reporting the volume size in number of voxels in X, Y, and Z directions, and their size. + """ + if prj_geom.det_shape_vu is None or len(prj_geom.det_shape_vu) != 2: + raise ValueError(f"prj_geom.det_shape_vu must be a 2-element sequence, got {prj_geom = } instead.") + # Check that the fields have exactly 3 elements + for field in ['det_pos_xyz', 'det_u_xyz', 'det_v_xyz', 'src_pos_xyz']: + field_value = getattr(prj_geom, field) + if field_value.size != 3: + raise ValueError(f"{field} must have exactly 3 elements, got {field_value.size} elements instead.") + if len(vol_geom.shape_xyz) != 3: + raise ValueError(f"vol_geom.shape_xyz must be a 3-element sequence, got {vol_geom = } instead.") + + fig = plt.figure(figsize=(10, 8)) + ax = fig.add_subplot(111, projection='3d') + + src_pos = prj_geom.src_pos_xyz.flatten() + det_pos = prj_geom.det_pos_xyz.flatten() + det_u = prj_geom.det_u_xyz.flatten() + det_v = prj_geom.det_v_xyz.flatten() + + pix_size = vol_geom.vox_size / prj_geom.pix2vox_ratio + + # Plot the source + ax.scatter(*src_pos, color='r', s=100, label='Source') + print(f"{prj_geom.det_shape_vu = }, {vol_geom = }") + + # Plot the detector + det_corners = np.array( + [ + det_pos + det_u * prj_geom.det_shape_vu[1] * pix_size / 2 + det_v * prj_geom.det_shape_vu[0] * pix_size / 2, + det_pos + det_u * prj_geom.det_shape_vu[1] * pix_size / 2 - det_v * prj_geom.det_shape_vu[0] * pix_size / 2, + det_pos - det_u * prj_geom.det_shape_vu[1] * pix_size / 2 - det_v * prj_geom.det_shape_vu[0] * pix_size / 2, + det_pos - det_u * prj_geom.det_shape_vu[1] * pix_size / 2 + det_v * prj_geom.det_shape_vu[0] * pix_size / 2, + ] + ) + # print(det_corners) + + detector = Poly3DCollection([det_corners], alpha=0.5, linewidths=1, edgecolors='k') + detector.set_facecolor('b') + ax.add_collection3d(detector) + + # Plot the volume + x, y, z = vol_geom.shape_xyz * vol_geom.vox_size / 2 + + # Create a cube + cube_vertices = np.array( + [ + [-x, -y, -z], + [x, -y, -z], + [x, y, -z], + [-x, y, -z], + [-x, -y, z], + [x, -y, z], + [x, y, z], + [-x, y, z], + ] + ) + # print(cube_vertices) + + # Create the 8 faces of the cube + cube_faces = [ + [cube_vertices[0], cube_vertices[1], cube_vertices[2], cube_vertices[3]], # Bottom face + [cube_vertices[4], cube_vertices[5], cube_vertices[6], cube_vertices[7]], # Top face + [cube_vertices[0], cube_vertices[1], cube_vertices[5], cube_vertices[4]], # Front face + [cube_vertices[2], cube_vertices[3], cube_vertices[7], cube_vertices[6]], # Back face + [cube_vertices[1], cube_vertices[2], cube_vertices[6], cube_vertices[5]], # Right face + [cube_vertices[0], cube_vertices[3], cube_vertices[7], cube_vertices[4]], # Left face + ] + + volume = Poly3DCollection(cube_faces, alpha=0.1, linewidths=1, edgecolors='k') + volume.set_facecolor('g') + ax.add_collection3d(volume) + + # Plot vectors from the origin to the source and detector center + ax.quiver( + [0, 0], + [0, 0], + [0, 0], + [src_pos[0], det_pos[0]], + [src_pos[1], det_pos[1]], + [src_pos[2], det_pos[2]], + color=['r', 'b'], + arrow_length_ratio=0.1, + label='Source and Detector Center', + ) + + # Plot vectors from the detector center to the u and v unit vectors + ax.quiver( + [det_pos[0], det_pos[0]], + [det_pos[1], det_pos[1]], + [det_pos[2], det_pos[2]], + [ + det_u[0] * prj_geom.det_shape_vu[1] * pix_size / 4, + det_v[0] * prj_geom.det_shape_vu[0] * pix_size / 4, + ], + [ + det_u[1] * prj_geom.det_shape_vu[1] * pix_size / 4, + det_v[1] * prj_geom.det_shape_vu[0] * pix_size / 4, + ], + [ + det_u[2] * prj_geom.det_shape_vu[1] * pix_size / 4, + det_v[2] * prj_geom.det_shape_vu[0] * pix_size / 4, + ], + color=['m', 'c'], + arrow_length_ratio=0.1, + label='U and V Vectors', + ) + + # Set labels and legend + ax.set_xlabel('X') + ax.set_ylabel('Y') + ax.set_zlabel('Z') + ax.legend() + ax.set_aspect("equal") + fig.tight_layout() + + plt.show() diff --git a/tests/alignment/test_cone_beam.py b/tests/alignment/test_cone_beam.py index 8199094..142b0b3 100644 --- a/tests/alignment/test_cone_beam.py +++ b/tests/alignment/test_cone_beam.py @@ -1,158 +1,75 @@ -from collections.abc import Sequence -from dataclasses import dataclass - -import matplotlib.pyplot as plt import numpy as np -from mpl_toolkits.mplot3d.art3d import Poly3DCollection +import pytest from numpy.typing import NDArray from scipy.spatial.transform import Rotation -from corrct.alignment.cone_beam import FitConeBeamGeometry - - -@dataclass -class DetectorGeometry: - det_u_xyz: NDArray - det_v_xyz: NDArray - det_center_xyz: NDArray - src_pos_xyz: NDArray - u0: float - v0: float - pu: float - pv: float - - -def compute_detector_geometry( - R: float, - d: Sequence[float] | NDArray, - p: float, - t: float, - n: float, - pu: float, - pv: float, - u_sign: int = 1, - v_sign: int = -1, -) -> DetectorGeometry: - """ - Compute the detector geometry (u, v, center, u0, v0) from the given parameters. - - Parameters: - - R: Distance from source to volume center. - - d: Vector (dx, dy, dz) from volume center to detector center in XYZ. - - p, t, n: Detector tilts (phi, theta, eta) in radians. - - pu, pv: Pixel sizes in U and V directions. - - u_sign, v_sign: Sign convention for U and V axes (default: 1, -1). - - Returns: - - DetectorGeometry dataclass with computed properties. - """ - d = np.array(d) - src_pos = np.array([-R, 0, 0]) - det_center = d - - # Apply tilts in order: p (phi), t (theta), n (eta) - # p (phi) around Z: - # [cos(p), -sin(p), 0] - # [sin(p), cos(p), 0] - # [0, 0, 1] - rot_p = Rotation.from_rotvec([0, 0, p]) - # t (theta) around Y: - # [cos(t), 0, sin(t)] - # [0, 1, 0 ] - # [-sin(t), 0, cos(t)] - rot_t = Rotation.from_rotvec([0, t, 0]) - # n (eta) around X: - # [1, 0, 0] - # [0, cos(n), -sin(n)] - # [0, sin(n), cos(n)] - rot_n = Rotation.from_rotvec([n, 0, 0]) - - # Compute the u and v vectors - det_u = np.array([0, u_sign * 1, 0]) - det_v = np.array([0, 0, v_sign * 1]) - - det_u = rot_p.apply(det_u) - det_u = rot_t.apply(det_u) - det_u = rot_n.apply(det_u) - - det_v = rot_p.apply(det_v) - det_v = rot_t.apply(det_v) - det_v = rot_n.apply(det_v) - - det_normal = np.cross(det_u, det_v) - - # Compute (u0, v0) as the projection of the source on the detector - vec_source_to_detector = det_center - src_pos - t = np.dot(det_normal, vec_source_to_detector) / np.dot(det_normal, det_normal) - projection_source = src_pos + t * vec_source_to_detector - vec_detector_to_projection = projection_source - det_center - u0 = np.dot(vec_detector_to_projection, det_u) / pu - v0 = np.dot(vec_detector_to_projection, det_v) / pv - - return DetectorGeometry( - det_u_xyz=det_u, - det_v_xyz=det_v, - det_center_xyz=det_center, - src_pos_xyz=src_pos, - u0=u0, - v0=v0, - pu=pu, - pv=pv, - ) +from corrct.alignment.cone_beam import FitConeBeamGeometry, ConeBeamGeometry +from corrct.models import ProjectionGeometry, plot_projection_geometry -def project_to_uv(points_xyz: NDArray, geo: DetectorGeometry) -> NDArray: +def project_to_uv(points_xyz: NDArray, proj_geom: ProjectionGeometry) -> NDArray: """ Project a list of XYZ points to UV coordinates using precomputed geometry. - Parameters: - - xyz_points: Array of (x, y, z) points in the sample volume (XYZ coordinates). - - geo: DetectorGeometry dataclass with precomputed properties. - - Returns: - - Array of (u, v) coordinates for each input point. + Parameters + ---------- + points_xyz : NDArray + Array of (x, y, z) points in the sample volume (XYZ coordinates). + geo : ProjectionGeometry + ProjectionGeometry class with precomputed geometry properties. + + Returns + ------- + NDArray + Array of (u, v) coordinates for each input point. """ points_xyz = np.array(points_xyz, dtype=np.float32) - det_normal_xyz = np.cross(geo.det_u_xyz, geo.det_v_xyz) + det_u_xyz = proj_geom.det_u_xyz.flatten() + det_v_xyz = proj_geom.det_v_xyz.flatten() - vecs_src_2_pnts = points_xyz - geo.src_pos_xyz - denoms = np.dot(det_normal_xyz, vecs_src_2_pnts.T) + det_normal_xyz = np.cross(det_u_xyz, det_v_xyz) - t0 = np.dot(det_normal_xyz, geo.det_center_xyz - geo.src_pos_xyz) + vecs_src_2_pnts = points_xyz - proj_geom.src_pos_xyz + vecs_src_2_pnts = vecs_src_2_pnts / np.linalg.norm(vecs_src_2_pnts, axis=-1, keepdims=True) - # Project each rotated point onto the tilted detector - uv_coords = np.zeros((len(points_xyz), 2), dtype=np.float32) - for ii_pnt, (vec_src_2_pnt, denom) in enumerate(zip(vecs_src_2_pnts, denoms)): + denominators = np.dot(det_normal_xyz, vecs_src_2_pnts.T) + numerators = (proj_geom.det_pos_xyz - points_xyz).dot(det_normal_xyz) - if np.isclose(denom, 0.0): - uv_coords[ii_pnt] = (np.nan, np.nan) - continue + invalid_points = np.isclose(denominators, 0.0) + valid_denoms = np.logical_not(invalid_points) - intersection = geo.src_pos_xyz + t0 / denom * vec_src_2_pnt - vec_detector_to_intersection = intersection - geo.det_center_xyz + lams = numerators[list(valid_denoms)] / denominators[list(valid_denoms)] + prj_pnts_xyz = vecs_src_2_pnts[list(valid_denoms), :] * lams[:, None] + points_xyz[list(valid_denoms), :] - # Project onto U and V axes - u = np.dot(vec_detector_to_intersection, geo.det_u_xyz) / geo.pu + geo.u0 - v = np.dot(vec_detector_to_intersection, geo.det_v_xyz) / geo.pv + geo.v0 - - uv_coords[ii_pnt] = (u, v) + uv_coords = np.empty((len(points_xyz), 2), dtype=np.float32) + uv_coords.fill(np.nan) + uv_coords[list(valid_denoms), :] = np.stack((prj_pnts_xyz.dot(det_u_xyz), prj_pnts_xyz.dot(det_v_xyz)), axis=1) return uv_coords -def compute_xyz_rotated(r: float, z: float, ws: NDArray, sx: float = 1.0, sy: float = 1.0, sz: float = 1.0) -> NDArray: +def compute_xyz_rotated( + r: float, z: float, ws: NDArray, voxel_size_xyz: tuple[float, float, float] = (1.0, 1.0, 1.0) +) -> NDArray: """ Compute XYZ coordinates for an array of (r, z, w) inputs in the rotated volume. - Parameters: - - r: Radial distance from volume center. - - z: Elevation (Z coordinates). - - w: Array of rotation angles (omega) in radians. - - sx, sy, sz: Voxel sizes in X, Y, Z directions. - - Returns: - - Array of (x, y, z) coordinates in the rotated volume. + Parameters + ---------- + r : float + Radial distance from volume center. + z : float + Elevation (Z coordinates). + ws : NDArray + Array of rotation angles (omega) in radians. + voxel_size_xyz : tuple[float, float, float], optional + Voxel sizes in X, Y, Z directions (default: (1.0, 1.0, 1.0)). + + Returns + ------- + NDArray + Array of (x, y, z) coordinates in the rotated volume. """ ws = np.squeeze(np.array(ws)) @@ -170,130 +87,84 @@ def compute_xyz_rotated(r: float, z: float, ws: NDArray, sx: float = 1.0, sy: fl rot_xyz = rot_w.apply(np.array([0, r, z]).T) # Scale by voxel sizes - return rot_xyz * np.array((sx, sy, sz)) + return rot_xyz * np.array(voxel_size_xyz) -def plot_projection_geometry( - geo: DetectorGeometry, - det_size_uv: tuple[int, int], - vol_size_xyz: tuple[int, int, int], - voxel_size_xyz: tuple[float, float, float] = (1.0, 1.0, 1.0), -): - """ - Plot the projection geometry using matplotlib. +@pytest.mark.parametrize("R", [30.0, 50.0]) +@pytest.mark.parametrize("D", [70.0, 80.0]) +def test_cone_beam_ellipse_distances(R: float, D: float): + debug = False - Parameters: - - geo: DetectorGeometry dataclass with precomputed properties. - - det_size_uv: Detector size in number of pixels in U and V directions. - - vol_size_xyz: Volume size in number of voxels in X, Y, and Z directions. - - voxel_size_xyz: Voxel sizes in X, Y, Z directions. - """ - fig = plt.figure(figsize=(10, 8)) - ax = fig.add_subplot(111, projection='3d') - - # Plot the source - ax.scatter(*geo.src_pos_xyz, color='r', s=100, label='Source') - # print(geo.src_pos_xyz) - - # Plot the detector - det_corners = np.array( - [ - geo.det_center_xyz + geo.det_u_xyz * det_size_uv[0] * geo.pu / 2 + geo.det_v_xyz * det_size_uv[1] * geo.pv / 2, - geo.det_center_xyz + geo.det_u_xyz * det_size_uv[0] * geo.pu / 2 - geo.det_v_xyz * det_size_uv[1] * geo.pv / 2, - geo.det_center_xyz - geo.det_u_xyz * det_size_uv[0] * geo.pu / 2 - geo.det_v_xyz * det_size_uv[1] * geo.pv / 2, - geo.det_center_xyz - geo.det_u_xyz * det_size_uv[0] * geo.pu / 2 + geo.det_v_xyz * det_size_uv[1] * geo.pv / 2, - ] - ) - # print(det_corners) - - detector = Poly3DCollection([det_corners], alpha=0.5, linewidths=1, edgecolors='k') - detector.set_facecolor('b') - ax.add_collection3d(detector) - - # Plot the volume - x, y, z = [size * voxel_size / 2 for size, voxel_size in zip(vol_size_xyz, voxel_size_xyz)] - - # Create a cube - cube_vertices = np.array( - [ - [-x, -y, -z], - [x, -y, -z], - [x, y, -z], - [-x, y, -z], - [-x, -y, z], - [x, -y, z], - [x, y, z], - [-x, y, z], - ] - ) - # print(cube_vertices) - - # Create the 8 faces of the cube - cube_faces = [ - [cube_vertices[0], cube_vertices[1], cube_vertices[2], cube_vertices[3]], # Bottom face - [cube_vertices[4], cube_vertices[5], cube_vertices[6], cube_vertices[7]], # Top face - [cube_vertices[0], cube_vertices[1], cube_vertices[5], cube_vertices[4]], # Front face - [cube_vertices[2], cube_vertices[3], cube_vertices[7], cube_vertices[6]], # Back face - [cube_vertices[1], cube_vertices[2], cube_vertices[6], cube_vertices[5]], # Right face - [cube_vertices[0], cube_vertices[3], cube_vertices[7], cube_vertices[4]], # Left face - ] - - volume = Poly3DCollection(cube_faces, alpha=0.1, linewidths=1, edgecolors='k') - volume.set_facecolor('g') - ax.add_collection3d(volume) - - # Plot vectors from the origin to the source and detector center - ax.quiver( - [0, 0], - [0, 0], - [0, 0], - [geo.src_pos_xyz[0], geo.det_center_xyz[0]], - [geo.src_pos_xyz[1], geo.det_center_xyz[1]], - [geo.src_pos_xyz[2], geo.det_center_xyz[2]], - color=['r', 'b'], - arrow_length_ratio=0.1, - label='Source and Detector Center', - ) + det_size_vu = 12 - # Plot vectors from the detector center to the u and v unit vectors - ax.quiver( - [geo.det_center_xyz[0], geo.det_center_xyz[0]], - [geo.det_center_xyz[1], geo.det_center_xyz[1]], - [geo.det_center_xyz[2], geo.det_center_xyz[2]], - [geo.det_u_xyz[0] * det_size_uv[0] * geo.pu / 4, geo.det_v_xyz[0] * det_size_uv[1] * geo.pv / 4], - [geo.det_u_xyz[1] * det_size_uv[0] * geo.pu / 4, geo.det_v_xyz[1] * det_size_uv[1] * geo.pv / 4], - [geo.det_u_xyz[2] * det_size_uv[0] * geo.pu / 4, geo.det_v_xyz[2] * det_size_uv[1] * geo.pv / 4], - color=['m', 'c'], - arrow_length_ratio=0.1, - label='U and V Vectors', - ) + acq_geom_tst = ConeBeamGeometry(theta_deg=0.0, phi_deg=0.0, eta_deg=0.0, D_pix=D, R_pix=R) + proj_geom_tst = acq_geom_tst.get_prj_geom() + if debug: + print(f"{acq_geom_tst = }") + plot_projection_geometry(proj_geom_tst, acq_geom_tst.get_vol_geom()) + + r = det_size_vu / 2 - 1 + z_u = -2.5 + z_l = 1.5 + ws = np.deg2rad(np.arange(0, 360, 6)) + points_xyz_u = compute_xyz_rotated(r=r, z=z_u, ws=ws) + points_xyz_l = compute_xyz_rotated(r=r, z=z_l, ws=ws) + + # fig, axs = plt.subplots(1, 1) + # axs.plot(points_xyz[:, 0], points_xyz[:, 1]) + # axs.grid() + # fig.tight_layout() + # plt.show() - # Set labels and legend - ax.set_xlabel('X') - ax.set_ylabel('Y') - ax.set_zlabel('Z') - ax.legend() - ax.set_aspect("equal") + prj_uv_coords_u = project_to_uv(points_xyz_u, proj_geom_tst) + prj_uv_coords_l = project_to_uv(points_xyz_l, proj_geom_tst) - plt.show() + # fig, axs = plt.subplots(1, 1) + # axs.plot(prj_uv_coords_u[:, 0], prj_uv_coords_u[:, 1]) + # axs.plot(prj_uv_coords_l[:, 0], prj_uv_coords_l[:, 1]) + # axs.grid() + # fig.tight_layout() + # plt.show() + prj_size_vu = np.array((det_size_vu, det_size_vu)) + + points_ell1 = np.flip(prj_uv_coords_u.T, axis=0) + prj_size_vu[:, None] / 2 + points_ell2 = np.flip(prj_uv_coords_l.T, axis=0) + prj_size_vu[:, None] / 2 + + if debug: + print(f"{points_ell1 = }") + print(f"{points_ell2 = }") -def test_cone_beam_ellipse(): + geom_fit = FitConeBeamGeometry(prj_size_vu=prj_size_vu, points_ell1=points_ell1, points_ell2=points_ell2) + geom_fit.fit(r=r, e=1.0) + + if debug: + print(f"{geom_fit.acq_geom = }") + + assert np.isclose( + D, geom_fit.acq_geom.D_pix, rtol=1e-5, atol=1e-3 + ), f"The fitted source-detector distance is wrong ({geom_fit.acq_geom.D_pix:.6} vs expected: {D:.6})" + assert np.isclose( + R, geom_fit.acq_geom.R_pix, rtol=1e-5, atol=1e-3 + ), f"The fitted source-sample distance is wrong ({geom_fit.acq_geom.R_pix:.6} vs expected: {R:.6})" + + +@pytest.mark.parametrize(("p", "n"), [(30.0, 0.0), (5.0, 1.0), (0.0, 15.0)]) +def test_cone_beam_ellipse_angles(p: float, n: float): debug = False - R = 30.0 - D = 20.0 det_size_vu = 12 - geom = compute_detector_geometry(R=R, d=[D, 0.0, 0.0], p=0.0, t=0.0, n=0.0, pu=1.0, pv=1.0) + acq_geom_tst = ConeBeamGeometry(theta_deg=0.0, phi_deg=p, eta_deg=n, D_pix=80.0, R_pix=50.0) + proj_geom_tst = acq_geom_tst.get_prj_geom() if debug: - print(f"{geom = }") - vol_size_xyz = [det_size_vu - 2] * 3 - plot_projection_geometry(geom, det_size_uv=(det_size_vu,) * 2, vol_size_xyz=tuple(vol_size_xyz)) + print(f"{acq_geom_tst = }") + print(f"{proj_geom_tst = }") + plot_projection_geometry(proj_geom_tst, acq_geom_tst.get_vol_geom()) r = det_size_vu / 2 - 1 - z_u = 2.5 - z_l = -1.5 + z_u = -2.5 + z_l = 1.5 ws = np.deg2rad(np.arange(0, 360, 6)) points_xyz_u = compute_xyz_rotated(r=r, z=z_u, ws=ws) points_xyz_l = compute_xyz_rotated(r=r, z=z_l, ws=ws) @@ -304,8 +175,8 @@ def test_cone_beam_ellipse(): # fig.tight_layout() # plt.show() - prj_uv_coords_u = project_to_uv(points_xyz_u, geom) - prj_uv_coords_l = project_to_uv(points_xyz_l, geom) + prj_uv_coords_u = project_to_uv(points_xyz_u, proj_geom_tst) + prj_uv_coords_l = project_to_uv(points_xyz_l, proj_geom_tst) # fig, axs = plt.subplots(1, 1) # axs.plot(prj_uv_coords_u[:, 0], prj_uv_coords_u[:, 1]) @@ -330,29 +201,34 @@ def test_cone_beam_ellipse(): print(f"{geom_fit.acq_geom = }") assert np.isclose( - D + R, geom_fit.acq_geom.D_pix - ), f"The fitted source-detector distance is wrong ({geom_fit.acq_geom.D_pix:.6} vs expected: {D+R:.6})" + p, geom_fit.acq_geom.phi_deg, rtol=5e-2, atol=5e-1 + ), f"The fitted phi angle is wrong ({geom_fit.acq_geom.phi_deg:.6} vs expected: {p:.6})" assert np.isclose( - R, geom_fit.acq_geom.R_pix - ), f"The fitted source-sample distance is wrong ({geom_fit.acq_geom.R_pix:.6} vs expected: {R:.6})" + n, geom_fit.acq_geom.eta_deg, rtol=1e-2, atol=1e-1 + ), f"The fitted yaw angle is wrong ({geom_fit.acq_geom.eta_deg:.6} vs expected: {n:.6})" if __name__ == "__main__": - # geometry = compute_detector_geometry(R=1e10, d=[1.0, 0.0, 0.0], p=0.0, t=0.0, n=0.0, pu=1.0, pv=1.0) - geometry = compute_detector_geometry(R=160.0, d=[100.0, 0.0, 0.0], p=0.0, t=0.0, n=0.0, pu=1.0, pv=1.0) - geometry = compute_detector_geometry(R=160.0, d=[100.0, 0.0, 0.0], p=0.0, t=0.0, n=np.pi / 4, pu=1.0, pv=1.0) - # geometry = compute_detector_geometry(R=10.0, d=[1.0, 0.0, 0.0], p=0.0, t=0.0, n=np.pi / 2, pu=1.0, pv=1.0) - # geometry = compute_detector_geometry(R=10.0, d=[1.0, 0.0, 0.0], p=0.0, t=0.0, n=-np.pi, pu=1.0, pv=2.0) - print(geometry) - - plot_projection_geometry(geometry, (100, 100), (80, 80, 80)) - - # Project points - xyz_points_tst = np.array([[0, 1, 2], [3, 4, 5]]) - prj_uv_coords = project_to_uv(xyz_points_tst, geometry) + points_xyz_tst = np.array([[0, 1, 2], [3, 4, 5]]) + + # acq_geom = ConeBeamGeometry(theta_deg=0.0, phi_deg=0.0, eta_deg=0.0, D_pix=260.0, R_pix=160.0) + acq_geom = ConeBeamGeometry( + theta_deg=0.0, + phi_deg=0.0, + eta_deg=np.rad2deg(np.pi / 4), + D_pix=260.0, + R_pix=160.0, + det_size_u_pix=100, + det_size_v_pix=100, + ) + print(acq_geom) + prj_geom = acq_geom.get_prj_geom() + prj_uv_coords = project_to_uv(points_xyz=points_xyz_tst, proj_geom=prj_geom) print(prj_uv_coords) # Compute rotated XYZ for arrays of (r, z, w) ws = np.array([0, np.pi / 4, np.pi / 2]) xyz_rotated = compute_xyz_rotated(r=2.0, z=1.0, ws=ws) print(xyz_rotated) + + plot_projection_geometry(acq_geom.get_prj_geom(), acq_geom.get_vol_geom()) From 0ab9c536695b6dc9d2b6ab6e1371a00adc8d5432 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicola=20VIGAN=C3=92?= Date: Wed, 3 Jun 2026 18:52:30 +0200 Subject: [PATCH 15/26] More fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Nicola VIGANÒ --- src/corrct/alignment/cone_beam.py | 13 +++++++++++-- src/corrct/alignment/fitting.py | 8 ++++---- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/corrct/alignment/cone_beam.py b/src/corrct/alignment/cone_beam.py index 0f6d997..9648ad6 100644 --- a/src/corrct/alignment/cone_beam.py +++ b/src/corrct/alignment/cone_beam.py @@ -371,6 +371,10 @@ def _initialize(self, use_l1_norm: bool) -> None: self.prj_origin_vu = self.points_axis[:, 1] self.acq_geom.eta_deg = acq_eta_deg else: + if self.verbose: + print(f"- Ellipse 1 center: fitted = {ell1_fit_prj_c_vu} [pix]") + print(f"- Ellipse 2 center: fitted = {ell2_fit_prj_c_vu} [pix]") + self.ell1_prj_c_vu = ell1_fit_prj_c_vu self.ell2_prj_c_vu = ell2_fit_prj_c_vu @@ -486,9 +490,12 @@ def get_zeta(b: float, a: float, c: float, D: float, sign_zk: float) -> float: v01 = get_v0(v1, b1, a1, c1, self.acq_geom.D_pix, sign_z1) v02 = get_v0(v2, b2, a2, c2, self.acq_geom.D_pix, sign_z2) + tilt_ratio1 = c1 / (2 * a1) + tilt_ratio2 = c2 / (2 * a2) + self.acq_geom.v0_pix = np.array([v01, v02]).mean() self.acq_geom.u0_pix = ( - np.mean([u1, u2]) + c1 / (2 * a1) * (v1 - self.acq_geom.v0_pix) + c2 / (2 * a2) * (v2 - self.acq_geom.v0_pix) + (u1 + u2) / 2 + tilt_ratio1 * (v1 - self.acq_geom.v0_pix) + tilt_ratio2 * (v2 - self.acq_geom.v0_pix) ) if self.verbose: @@ -506,7 +513,9 @@ def get_zeta(b: float, a: float, c: float, D: float, sign_zk: float) -> float: zeta1 = get_zeta(b1, a1, c1, self.acq_geom.D_pix, sign_z1) zeta2 = get_zeta(b2, a2, c2, self.acq_geom.D_pix, sign_z2) - self.acq_geom.phi_deg = np.rad2deg(np.arcsin(-c1 / (2 * a1) * zeta1 - c2 / (2 * a2) * zeta2)) + sin_phi1 = -tilt_ratio1 * zeta1 + sin_phi2 = -tilt_ratio2 * zeta2 + self.acq_geom.phi_deg = np.rad2deg(np.arcsin(sin_phi1 + sin_phi2)) R_e1 = r / rho1 R_e2 = r / rho2 diff --git a/src/corrct/alignment/fitting.py b/src/corrct/alignment/fitting.py index 38824fd..74bd2c3 100644 --- a/src/corrct/alignment/fitting.py +++ b/src/corrct/alignment/fitting.py @@ -1043,15 +1043,15 @@ def center_vu(self) -> NDArray: return self.c_vu @property - def parameters(self) -> NDArray: + def parameters(self) -> tuple[float, float, float, float, float]: """Return the fitted ellipse parameters. Returns ------- - NDArray - The fitted ellipse parameters. + tuple[float, float, float, float, float] + The fitted ellipse parameters: b, a, c, v, u. """ - return np.array([self.b, self.a, self.c, self.v, self.u]) + return self.b, self.a, self.c, self.v, self.u def __call__(self, uus: ArrayLike | NDArray) -> Sequence[NDArray]: """Predict V coordinates of ellipse from its parameters, and U coordinates. From b3a54d3bad0552feb639f0389a0c14dbed9533c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicola=20VIGAN=C3=92?= Date: Thu, 4 Jun 2026 13:59:33 +0200 Subject: [PATCH 16/26] Fixed geometry plotting to work with parallel geometry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Nicola VIGANÒ --- src/corrct/models.py | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/src/corrct/models.py b/src/corrct/models.py index 314d558..ff5d6f6 100644 --- a/src/corrct/models.py +++ b/src/corrct/models.py @@ -823,10 +823,6 @@ def plot_projection_geometry(prj_geom: ProjectionGeometry, vol_geom: VolumeGeome pix_size = vol_geom.vox_size / prj_geom.pix2vox_ratio - # Plot the source - ax.scatter(*src_pos, color='r', s=100, label='Source') - print(f"{prj_geom.det_shape_vu = }, {vol_geom = }") - # Plot the detector det_corners = np.array( [ @@ -874,18 +870,19 @@ def plot_projection_geometry(prj_geom: ProjectionGeometry, vol_geom: VolumeGeome volume.set_facecolor('g') ax.add_collection3d(volume) + if prj_geom.geom_type.lower() == "cone": + # Plot the source + ax.scatter(*src_pos, color='r', s=100) # , label='Source' + + # Plot vectors from the origin to the source and detector center + ax.quiver(*([0.0] * 3), *src_pos, color='r', arrow_length_ratio=0.1, label='Source Position') + else: + # plot the projection direction + prj_dir = src_pos / np.linalg.norm(src_pos) * np.linalg.norm(det_pos) + ax.quiver(*([0.0] * 3), *prj_dir, color='r', arrow_length_ratio=0.1, label='Projection direction') + # Plot vectors from the origin to the source and detector center - ax.quiver( - [0, 0], - [0, 0], - [0, 0], - [src_pos[0], det_pos[0]], - [src_pos[1], det_pos[1]], - [src_pos[2], det_pos[2]], - color=['r', 'b'], - arrow_length_ratio=0.1, - label='Source and Detector Center', - ) + ax.quiver(*([0.0] * 3), *det_pos, color=['b'], arrow_length_ratio=0.1, label='Detector Center') # Plot vectors from the detector center to the u and v unit vectors ax.quiver( From 77ce927e025be5ac644c57efd8ec415e4cdaa1ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicola=20VIGAN=C3=92?= Date: Thu, 4 Jun 2026 13:59:52 +0200 Subject: [PATCH 17/26] Small formatting changes to tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Nicola VIGANÒ --- tests/alignment/test_cone_beam.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/alignment/test_cone_beam.py b/tests/alignment/test_cone_beam.py index 142b0b3..f4f570f 100644 --- a/tests/alignment/test_cone_beam.py +++ b/tests/alignment/test_cone_beam.py @@ -93,6 +93,7 @@ def compute_xyz_rotated( @pytest.mark.parametrize("R", [30.0, 50.0]) @pytest.mark.parametrize("D", [70.0, 80.0]) def test_cone_beam_ellipse_distances(R: float, D: float): + """Test the cone beam geometry determination for different distance combinations.""" debug = False det_size_vu = 12 @@ -151,6 +152,7 @@ def test_cone_beam_ellipse_distances(R: float, D: float): @pytest.mark.parametrize(("p", "n"), [(30.0, 0.0), (5.0, 1.0), (0.0, 15.0)]) def test_cone_beam_ellipse_angles(p: float, n: float): + """Test the cone beam geometry determination for different angles combinations.""" debug = False det_size_vu = 12 @@ -211,7 +213,6 @@ def test_cone_beam_ellipse_angles(p: float, n: float): if __name__ == "__main__": points_xyz_tst = np.array([[0, 1, 2], [3, 4, 5]]) - # acq_geom = ConeBeamGeometry(theta_deg=0.0, phi_deg=0.0, eta_deg=0.0, D_pix=260.0, R_pix=160.0) acq_geom = ConeBeamGeometry( theta_deg=0.0, phi_deg=0.0, @@ -222,13 +223,14 @@ def test_cone_beam_ellipse_angles(p: float, n: float): det_size_v_pix=100, ) print(acq_geom) + prj_geom = acq_geom.get_prj_geom() prj_uv_coords = project_to_uv(points_xyz=points_xyz_tst, proj_geom=prj_geom) print(prj_uv_coords) # Compute rotated XYZ for arrays of (r, z, w) - ws = np.array([0, np.pi / 4, np.pi / 2]) - xyz_rotated = compute_xyz_rotated(r=2.0, z=1.0, ws=ws) + ws_test = np.array([0, np.pi / 4, np.pi / 2]) + xyz_rotated = compute_xyz_rotated(r=2.0, z=1.0, ws=ws_test) print(xyz_rotated) plot_projection_geometry(acq_geom.get_prj_geom(), acq_geom.get_vol_geom()) From 1c207575c0d85bfa9899f2c4466587fae5844512 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicola=20VIGAN=C3=92?= Date: Fri, 5 Jun 2026 17:42:56 +0200 Subject: [PATCH 18/26] Removed unnnecessary newlines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Nicola VIGANÒ --- src/corrct/alignment/cone_beam.py | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/src/corrct/alignment/cone_beam.py b/src/corrct/alignment/cone_beam.py index 9648ad6..85c6d1c 100644 --- a/src/corrct/alignment/cone_beam.py +++ b/src/corrct/alignment/cone_beam.py @@ -117,22 +117,10 @@ def get_prj_geom(self, translate_z_to_center: bool = True) -> ProjectionGeometry # Rotated detector base vectors alpha_xyz = np.array([-np.sin(phi_rad), -np.cos(phi_rad), 0]) - beta_xyz = np.array( - [ - -np.sin(theta_rad) * np.cos(phi_rad), - np.sin(theta_rad) * np.sin(phi_rad), - np.cos(theta_rad), - ] - ) + beta_xyz = np.array([-np.sin(theta_rad) * np.cos(phi_rad), np.sin(theta_rad) * np.sin(phi_rad), np.cos(theta_rad)]) # Detector normal - e_n_xyz = np.array( - [ - np.cos(theta_rad) * np.cos(phi_rad), - -np.cos(theta_rad) * np.sin(phi_rad), - np.sin(theta_rad), - ] - ) + e_n_xyz = np.array([np.cos(theta_rad) * np.cos(phi_rad), -np.cos(theta_rad) * np.sin(phi_rad), np.sin(theta_rad)]) det_pos_xyz = -e_n_xyz * self.D_pix + e_x_xyz * self.R_pix - alpha_xyz * self.u0_pix - beta_xyz * self.v0_pix src_pos_xyz = e_x_xyz * self.R_pix From a8763ee6671694fa554861c21bcd217a963243b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicola=20VIGAN=C3=92?= Date: Fri, 5 Jun 2026 17:45:04 +0200 Subject: [PATCH 19/26] Added checkbox for selecting the objects to set as visible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Nicola VIGANÒ --- src/corrct/models.py | 78 +++++++++++++++++++++++--------------------- 1 file changed, 41 insertions(+), 37 deletions(-) diff --git a/src/corrct/models.py b/src/corrct/models.py index ff5d6f6..7c7df93 100644 --- a/src/corrct/models.py +++ b/src/corrct/models.py @@ -16,6 +16,7 @@ import matplotlib.pyplot as plt import numpy as np import scipy.spatial.transform as spt +from matplotlib.widgets import CheckButtons from mpl_toolkits.mplot3d.art3d import Poly3DCollection from numpy.typing import ArrayLike, NDArray @@ -832,10 +833,10 @@ def plot_projection_geometry(prj_geom: ProjectionGeometry, vol_geom: VolumeGeome det_pos - det_u * prj_geom.det_shape_vu[1] * pix_size / 2 + det_v * prj_geom.det_shape_vu[0] * pix_size / 2, ] ) - # print(det_corners) detector = Poly3DCollection([det_corners], alpha=0.5, linewidths=1, edgecolors='k') detector.set_facecolor('b') + detector.set_label("Detector") ax.add_collection3d(detector) # Plot the volume @@ -843,18 +844,8 @@ def plot_projection_geometry(prj_geom: ProjectionGeometry, vol_geom: VolumeGeome # Create a cube cube_vertices = np.array( - [ - [-x, -y, -z], - [x, -y, -z], - [x, y, -z], - [-x, y, -z], - [-x, -y, z], - [x, -y, z], - [x, y, z], - [-x, y, z], - ] + [[-x, -y, -z], [x, -y, -z], [x, y, -z], [-x, y, -z], [-x, -y, z], [x, -y, z], [x, y, z], [-x, y, z]] ) - # print(cube_vertices) # Create the 8 faces of the cube cube_faces = [ @@ -868,49 +859,62 @@ def plot_projection_geometry(prj_geom: ProjectionGeometry, vol_geom: VolumeGeome volume = Poly3DCollection(cube_faces, alpha=0.1, linewidths=1, edgecolors='k') volume.set_facecolor('g') + volume.set_label("Volume") ax.add_collection3d(volume) + # Organize the objects and their labels in a list + objects: list = [detector, volume] + if prj_geom.geom_type.lower() == "cone": # Plot the source - ax.scatter(*src_pos, color='r', s=100) # , label='Source' + src_scatter = ax.scatter(*src_pos, color='r', s=100, label='Source Spot') + objects.append(src_scatter) # Plot vectors from the origin to the source and detector center - ax.quiver(*([0.0] * 3), *src_pos, color='r', arrow_length_ratio=0.1, label='Source Position') + src_quiver = ax.quiver(*([0.0] * 3), *src_pos, color='r', arrow_length_ratio=0.1, label='Source Position') + objects.append(src_quiver) else: # plot the projection direction prj_dir = src_pos / np.linalg.norm(src_pos) * np.linalg.norm(det_pos) - ax.quiver(*([0.0] * 3), *prj_dir, color='r', arrow_length_ratio=0.1, label='Projection direction') + prj_quiver = ax.quiver(*([0.0] * 3), *prj_dir, color='r', arrow_length_ratio=0.1, label='Projection direction') + objects.append(prj_quiver) # Plot vectors from the origin to the source and detector center - ax.quiver(*([0.0] * 3), *det_pos, color=['b'], arrow_length_ratio=0.1, label='Detector Center') + det_quiver = ax.quiver(*([0.0] * 3), *det_pos, color='b', arrow_length_ratio=0.1, label='Detector Center') + objects.append(det_quiver) # Plot vectors from the detector center to the u and v unit vectors - ax.quiver( - [det_pos[0], det_pos[0]], - [det_pos[1], det_pos[1]], - [det_pos[2], det_pos[2]], - [ - det_u[0] * prj_geom.det_shape_vu[1] * pix_size / 4, - det_v[0] * prj_geom.det_shape_vu[0] * pix_size / 4, - ], - [ - det_u[1] * prj_geom.det_shape_vu[1] * pix_size / 4, - det_v[1] * prj_geom.det_shape_vu[0] * pix_size / 4, - ], - [ - det_u[2] * prj_geom.det_shape_vu[1] * pix_size / 4, - det_v[2] * prj_geom.det_shape_vu[0] * pix_size / 4, - ], - color=['m', 'c'], - arrow_length_ratio=0.1, - label='U and V Vectors', + u_quiver = ax.quiver( + *det_pos, *(det_u * prj_geom.det_shape_vu[1] * pix_size / 4), color='m', arrow_length_ratio=0.1, label='U vector' ) + objects.append(u_quiver) + v_quiver = ax.quiver( + *det_pos, *(det_v * prj_geom.det_shape_vu[0] * pix_size / 4), color='c', arrow_length_ratio=0.1, label='V vector' + ) + objects.append(v_quiver) + + labels = [o.get_label() for o in objects] + colors = [o.get_color() if hasattr(o, "get_color") else o.get_facecolor()[0] for o in objects] + visibility = [o.get_visible() for o in objects] + + # Create a rectangle for the CheckButtons + rax = ax.inset_axes([0.0, 0.0, 0.3, 0.25]) + visibility = [True] * len(objects) + check = CheckButtons(rax, labels, visibility, label_props={'color': colors, "fontsize": [14] * len(objects)}) + + # Define the function to update the visibility of the elements + def func(label): + index = labels.index(label) + objects[index].set_visible(not objects[index].get_visible()) + plt.draw() + + # Register the function with the CheckButtons + check.on_clicked(func) - # Set labels and legend + # Set labels ax.set_xlabel('X') ax.set_ylabel('Y') ax.set_zlabel('Z') - ax.legend() ax.set_aspect("equal") fig.tight_layout() From d3954011d27581acc5cc1549a1360016fcbbbd78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicola=20VIGAN=C3=92?= Date: Mon, 8 Jun 2026 15:17:57 +0200 Subject: [PATCH 20/26] Added example on how to use the calibration data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Nicola VIGANÒ --- .../example_02_cone-beam_calibration.py | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 examples/alignment/example_02_cone-beam_calibration.py diff --git a/examples/alignment/example_02_cone-beam_calibration.py b/examples/alignment/example_02_cone-beam_calibration.py new file mode 100644 index 0000000..7128f10 --- /dev/null +++ b/examples/alignment/example_02_cone-beam_calibration.py @@ -0,0 +1,45 @@ +""" +Example demonstrating the use of cone-beam geometry calibration routines. + +@author: Nicola VIGANÒ, CEA-IRIG, Grenoble, France +""" + +from pathlib import Path + +import h5py +import numpy as np +from numpy.typing import NDArray + +import corrct as cct + + +def _get_data(fid: h5py.File, data_path: str) -> NDArray: + dataset = fid[data_path] + if isinstance(dataset, h5py.Dataset): + return np.array(dataset[()]) + else: + raise ValueError(f"Path: {data_path}, is not a h5py.Dataset, but a {dataset} instead") + + +def _load_data(fname: str | Path) -> dict[str, NDArray]: + with h5py.File(fname) as fid: + return {k: _get_data(fid, f"/{k}") for k in fid.keys()} + + +data = _load_data("./data/calib/calibration_scans.h5") + +prj_size_vu = (data["scan_1"].shape[0], data["scan_1"].shape[2]) + +probe = cct.alignment.markers.create_marker_disk(prj_size_vu, 3.5) +pos_1, pos_2 = (cct.alignment.markers.track_marker(imgs, probe) for imgs in (data["scan_1"], data["scan_2"])) + +pixel_size_um = float(data["pixel_size_um"]) +orbit_radius_pix = float(data["orbit_radius_um"]) / pixel_size_um + +fit_cb_geom = cct.alignment.cone_beam.FitConeBeamGeometry( + prj_size_vu, points_ell1=pos_1, points_ell2=pos_2, pix_size_um=pixel_size_um, plot_result=True +) +acq_geom = fit_cb_geom.fit(r=orbit_radius_pix) + +print(acq_geom) +cct.models.plot_projection_geometry(acq_geom.get_prj_geom(), acq_geom.get_vol_geom()) From 6e53e4e51c50813ec7fdb0f2ef4fe80f21a6ffc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicola=20VIGAN=C3=92?= Date: Mon, 8 Jun 2026 15:18:24 +0200 Subject: [PATCH 21/26] Models: Added projection lines to geometry visualization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Nicola VIGANÒ --- src/corrct/models.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/corrct/models.py b/src/corrct/models.py index 7c7df93..377cca1 100644 --- a/src/corrct/models.py +++ b/src/corrct/models.py @@ -17,7 +17,7 @@ import numpy as np import scipy.spatial.transform as spt from matplotlib.widgets import CheckButtons -from mpl_toolkits.mplot3d.art3d import Poly3DCollection +from mpl_toolkits.mplot3d.art3d import Line3DCollection, Poly3DCollection from numpy.typing import ArrayLike, NDArray ROT_DIRS_VALID = ("clockwise", "counter-clockwise") @@ -866,6 +866,11 @@ def plot_projection_geometry(prj_geom: ProjectionGeometry, vol_geom: VolumeGeome objects: list = [detector, volume] if prj_geom.geom_type.lower() == "cone": + prj_lines = [np.stack((src_pos, d_c), axis=0) for d_c in det_corners] + prj_lines_c = Line3DCollection(prj_lines, colors="g", linestyles="-.", linewidths=1.0, label="Projection lines") + ax.add_collection(prj_lines_c) + objects.append(prj_lines_c) + # Plot the source src_scatter = ax.scatter(*src_pos, color='r', s=100, label='Source Spot') objects.append(src_scatter) From b69046c072c4e2a0a2a2d91c2c4fbe85768941e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicola=20VIGAN=C3=92?= Date: Tue, 9 Jun 2026 15:45:35 +0200 Subject: [PATCH 22/26] Added fine-tuning to the example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Nicola VIGANÒ --- .../example_02_cone-beam_calibration.py | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/examples/alignment/example_02_cone-beam_calibration.py b/examples/alignment/example_02_cone-beam_calibration.py index 7128f10..bbc5466 100644 --- a/examples/alignment/example_02_cone-beam_calibration.py +++ b/examples/alignment/example_02_cone-beam_calibration.py @@ -26,7 +26,7 @@ def _load_data(fname: str | Path) -> dict[str, NDArray]: return {k: _get_data(fid, f"/{k}") for k in fid.keys()} -data = _load_data("./data/calib/calibration_scans.h5") +data = _load_data("./data/calibration_scans.h5") prj_size_vu = (data["scan_1"].shape[0], data["scan_1"].shape[2]) @@ -43,3 +43,22 @@ def _load_data(fname: str | Path) -> dict[str, NDArray]: print(acq_geom) cct.models.plot_projection_geometry(acq_geom.get_prj_geom(), acq_geom.get_vol_geom()) + +imgs_t = (data["scan_1"] + data["scan_2"]).astype(np.float32) + +acq_geom = cct.alignment.cone_beam.tune_acquisition_geometry( + acq_geom, + data=imgs_t, + angles_rot_rad=np.deg2rad(data["angles_deg"]), + params=dict( + D_pix=np.linspace(-24 * 2, 0, 9), + theta_deg=np.linspace(-1, 1, 5), + eta_deg=np.linspace(-1, 1, 5), + phi_deg=np.linspace(-0.25, 0.25, 5), + u0_pix=np.linspace(-1, 1, 9), + v0_pix=np.linspace(-1, 1, 9), + ), + verbose=True, +) + +print(acq_geom) From a42fc545761d5e3e63c417932e49a7dad200f105 Mon Sep 17 00:00:00 2001 From: Nicola Vigano Date: Wed, 10 Jun 2026 13:58:32 +0200 Subject: [PATCH 23/26] Added tutorial Signed-off-by: Nicola Vigano --- .../corrct/corrct.alignment.cone_beam.md | 293 ++++++++++++++++++ .../corrct/corrct.alignment.fitting.md | 204 ++++++++++++ .../corrct/corrct.alignment.markers.md | 103 ++++++ .../apidocs/corrct/corrct.alignment.md | 2 + doc_sources/apidocs/corrct/corrct.models.md | 11 + doc_sources/cone_beam_calibration_tutorial.md | 147 +++++++++ .../alignment_cone-beam_ellipse-eta-fit.png | Bin 0 -> 32841 bytes .../images/alignment_cone-beam_fit-phi.png | Bin 0 -> 26708 bytes .../images/alignment_cone-beam_fit-theta.png | Bin 0 -> 31798 bytes .../images/alignment_cone-beam_fit-u0.png | Bin 0 -> 27742 bytes .../images/alignment_cone-beam_fit-v0.png | Bin 0 -> 31168 bytes .../images/alignment_cone-beam_plot-geom.png | Bin 0 -> 160642 bytes doc_sources/index.rst | 1 + 13 files changed, 761 insertions(+) create mode 100644 doc_sources/apidocs/corrct/corrct.alignment.cone_beam.md create mode 100644 doc_sources/apidocs/corrct/corrct.alignment.markers.md create mode 100644 doc_sources/cone_beam_calibration_tutorial.md create mode 100644 doc_sources/images/alignment_cone-beam_ellipse-eta-fit.png create mode 100644 doc_sources/images/alignment_cone-beam_fit-phi.png create mode 100644 doc_sources/images/alignment_cone-beam_fit-theta.png create mode 100644 doc_sources/images/alignment_cone-beam_fit-u0.png create mode 100644 doc_sources/images/alignment_cone-beam_fit-v0.png create mode 100644 doc_sources/images/alignment_cone-beam_plot-geom.png diff --git a/doc_sources/apidocs/corrct/corrct.alignment.cone_beam.md b/doc_sources/apidocs/corrct/corrct.alignment.cone_beam.md new file mode 100644 index 0000000..101264a --- /dev/null +++ b/doc_sources/apidocs/corrct/corrct.alignment.cone_beam.md @@ -0,0 +1,293 @@ +# {py:mod}`corrct.alignment.cone_beam` + +```{py:module} corrct.alignment.cone_beam +``` + +```{autodoc2-docstring} corrct.alignment.cone_beam +:allowtitles: +``` + +## Module Contents + +### Classes + +````{list-table} +:class: autosummary longtable +:align: left + +* - {py:obj}`ConeBeamGeometry ` + - ```{autodoc2-docstring} corrct.alignment.cone_beam.ConeBeamGeometry + :summary: + ``` +* - {py:obj}`FitConeBeamGeometry ` + - ```{autodoc2-docstring} corrct.alignment.cone_beam.FitConeBeamGeometry + :summary: + ``` +```` + +### Functions + +````{list-table} +:class: autosummary longtable +:align: left + +* - {py:obj}`_class_to_json ` + - ```{autodoc2-docstring} corrct.alignment.cone_beam._class_to_json + :summary: + ``` +* - {py:obj}`_get_rot_axis_angle_deg ` + - ```{autodoc2-docstring} corrct.alignment.cone_beam._get_rot_axis_angle_deg + :summary: + ``` +* - {py:obj}`tune_acquisition_geometry ` + - ```{autodoc2-docstring} corrct.alignment.cone_beam.tune_acquisition_geometry + :summary: + ``` +```` + +### API + +````{py:function} _class_to_json(obj: object) -> str +:canonical: corrct.alignment.cone_beam._class_to_json + +```{autodoc2-docstring} corrct.alignment.cone_beam._class_to_json +``` +```` + +````{py:function} _get_rot_axis_angle_deg(center_1_vu: collections.abc.Sequence[float] | numpy.typing.NDArray, center_2_vu: collections.abc.Sequence[float] | numpy.typing.NDArray, decimals: int | None = 4, dtype: numpy.typing.DTypeLike = np.float32) -> float +:canonical: corrct.alignment.cone_beam._get_rot_axis_angle_deg + +```{autodoc2-docstring} corrct.alignment.cone_beam._get_rot_axis_angle_deg +``` +```` + +`````{py:class} ConeBeamGeometry +:canonical: corrct.alignment.cone_beam.ConeBeamGeometry + +```{autodoc2-docstring} corrct.alignment.cone_beam.ConeBeamGeometry +``` + +````{py:attribute} theta_deg +:canonical: corrct.alignment.cone_beam.ConeBeamGeometry.theta_deg +:type: float +:value: > + 0.0 + +```{autodoc2-docstring} corrct.alignment.cone_beam.ConeBeamGeometry.theta_deg +``` + +```` + +````{py:attribute} phi_deg +:canonical: corrct.alignment.cone_beam.ConeBeamGeometry.phi_deg +:type: float +:value: > + 0.0 + +```{autodoc2-docstring} corrct.alignment.cone_beam.ConeBeamGeometry.phi_deg +``` + +```` + +````{py:attribute} eta_deg +:canonical: corrct.alignment.cone_beam.ConeBeamGeometry.eta_deg +:type: float +:value: > + 0.0 + +```{autodoc2-docstring} corrct.alignment.cone_beam.ConeBeamGeometry.eta_deg +``` + +```` + +````{py:attribute} D_pix +:canonical: corrct.alignment.cone_beam.ConeBeamGeometry.D_pix +:type: float +:value: > + 0.0 + +```{autodoc2-docstring} corrct.alignment.cone_beam.ConeBeamGeometry.D_pix +``` + +```` + +````{py:attribute} R_pix +:canonical: corrct.alignment.cone_beam.ConeBeamGeometry.R_pix +:type: float +:value: > + 0.0 + +```{autodoc2-docstring} corrct.alignment.cone_beam.ConeBeamGeometry.R_pix +``` + +```` + +````{py:attribute} v0_pix +:canonical: corrct.alignment.cone_beam.ConeBeamGeometry.v0_pix +:type: float +:value: > + 0.0 + +```{autodoc2-docstring} corrct.alignment.cone_beam.ConeBeamGeometry.v0_pix +``` + +```` + +````{py:attribute} u0_pix +:canonical: corrct.alignment.cone_beam.ConeBeamGeometry.u0_pix +:type: float +:value: > + 0.0 + +```{autodoc2-docstring} corrct.alignment.cone_beam.ConeBeamGeometry.u0_pix +``` + +```` + +````{py:attribute} det_size_v_pix +:canonical: corrct.alignment.cone_beam.ConeBeamGeometry.det_size_v_pix +:type: int +:value: > + 0 + +```{autodoc2-docstring} corrct.alignment.cone_beam.ConeBeamGeometry.det_size_v_pix +``` + +```` + +````{py:attribute} det_size_u_pix +:canonical: corrct.alignment.cone_beam.ConeBeamGeometry.det_size_u_pix +:type: int +:value: > + 0 + +```{autodoc2-docstring} corrct.alignment.cone_beam.ConeBeamGeometry.det_size_u_pix +``` + +```` + +````{py:attribute} pix_size_um +:canonical: corrct.alignment.cone_beam.ConeBeamGeometry.pix_size_um +:type: float +:value: > + 0.0 + +```{autodoc2-docstring} corrct.alignment.cone_beam.ConeBeamGeometry.pix_size_um +``` + +```` + +````{py:method} __str__() -> str +:canonical: corrct.alignment.cone_beam.ConeBeamGeometry.__str__ + +```{autodoc2-docstring} corrct.alignment.cone_beam.ConeBeamGeometry.__str__ +``` + +```` + +````{py:method} get_prj_geom(translate_z_to_center: bool = True) -> corrct.models.ProjectionGeometry +:canonical: corrct.alignment.cone_beam.ConeBeamGeometry.get_prj_geom + +```{autodoc2-docstring} corrct.alignment.cone_beam.ConeBeamGeometry.get_prj_geom +``` + +```` + +````{py:method} get_vol_geom(up_sampling: int = 1) -> corrct.models.VolumeGeometry +:canonical: corrct.alignment.cone_beam.ConeBeamGeometry.get_vol_geom + +```{autodoc2-docstring} corrct.alignment.cone_beam.ConeBeamGeometry.get_vol_geom +``` + +```` + +````{py:method} update(field: str, val: float, is_relative: bool = True, decimals: int | None = 3) -> corrct.alignment.cone_beam.ConeBeamGeometry +:canonical: corrct.alignment.cone_beam.ConeBeamGeometry.update + +```{autodoc2-docstring} corrct.alignment.cone_beam.ConeBeamGeometry.update +``` + +```` + +````{py:method} get_tuning_params(field: str, val_range: collections.abc.Sequence[float] | numpy.typing.NDArray, is_relative: bool = True) -> collections.abc.Sequence[corrct.alignment.cone_beam.ConeBeamGeometry] +:canonical: corrct.alignment.cone_beam.ConeBeamGeometry.get_tuning_params + +```{autodoc2-docstring} corrct.alignment.cone_beam.ConeBeamGeometry.get_tuning_params +``` + +```` + +````{py:method} to_json() -> str +:canonical: corrct.alignment.cone_beam.ConeBeamGeometry.to_json + +```{autodoc2-docstring} corrct.alignment.cone_beam.ConeBeamGeometry.to_json +``` + +```` + +````{py:method} from_json(data_json: str) -> None +:canonical: corrct.alignment.cone_beam.ConeBeamGeometry.from_json + +```{autodoc2-docstring} corrct.alignment.cone_beam.ConeBeamGeometry.from_json +``` + +```` + +````` + +`````{py:class} FitConeBeamGeometry(prj_size_vu: collections.abc.Sequence[int] | numpy.typing.NDArray, points_ell1: collections.abc.Sequence[collections.abc.Sequence[float]] | numpy.typing.NDArray, points_ell2: collections.abc.Sequence[collections.abc.Sequence[float]] | numpy.typing.NDArray, points_axis: collections.abc.Sequence[collections.abc.Sequence[float]] | numpy.typing.NDArray | None = None, pix_size_um: float | None = None, use_l1_norm: bool = False, verbose: bool = True, plot_result: bool = False) +:canonical: corrct.alignment.cone_beam.FitConeBeamGeometry + +```{autodoc2-docstring} corrct.alignment.cone_beam.FitConeBeamGeometry +``` + +```{rubric} Initialization +``` + +```{autodoc2-docstring} corrct.alignment.cone_beam.FitConeBeamGeometry.__init__ +``` + +````{py:attribute} acq_geom +:canonical: corrct.alignment.cone_beam.FitConeBeamGeometry.acq_geom +:type: corrct.alignment.cone_beam.ConeBeamGeometry +:value: > + None + +```{autodoc2-docstring} corrct.alignment.cone_beam.FitConeBeamGeometry.acq_geom +``` + +```` + +````{py:method} _initialize(use_l1_norm: bool) -> None +:canonical: corrct.alignment.cone_beam.FitConeBeamGeometry._initialize + +```{autodoc2-docstring} corrct.alignment.cone_beam.FitConeBeamGeometry._initialize +``` + +```` + +````{py:method} fit(r: float, e: float = 1, meas_D_pix: float | None = None) -> corrct.alignment.cone_beam.ConeBeamGeometry +:canonical: corrct.alignment.cone_beam.FitConeBeamGeometry.fit + +```{autodoc2-docstring} corrct.alignment.cone_beam.FitConeBeamGeometry.fit +``` + +```` + +````{py:method} _fit_distance_det2src(ellipse_1: corrct.alignment.fitting.Ellipse, ellipse_2: corrct.alignment.fitting.Ellipse, e: float = 1) -> float +:canonical: corrct.alignment.cone_beam.FitConeBeamGeometry._fit_distance_det2src +:staticmethod: + +```{autodoc2-docstring} corrct.alignment.cone_beam.FitConeBeamGeometry._fit_distance_det2src +``` + +```` + +````` + +````{py:function} tune_acquisition_geometry(acq_geom_init: corrct.alignment.cone_beam.ConeBeamGeometry, data: numpy.typing.NDArray, angles_rot_rad: collections.abc.Sequence[float] | numpy.typing.NDArray, params: dict[str, collections.abc.Sequence[float] | numpy.typing.NDArray], data_mask: numpy.typing.NDArray | None = None, verbose: bool = True) -> corrct.alignment.cone_beam.ConeBeamGeometry +:canonical: corrct.alignment.cone_beam.tune_acquisition_geometry + +```{autodoc2-docstring} corrct.alignment.cone_beam.tune_acquisition_geometry +``` +```` diff --git a/doc_sources/apidocs/corrct/corrct.alignment.fitting.md b/doc_sources/apidocs/corrct/corrct.alignment.fitting.md index 7471d49..de7ae21 100644 --- a/doc_sources/apidocs/corrct/corrct.alignment.fitting.md +++ b/doc_sources/apidocs/corrct/corrct.alignment.fitting.md @@ -9,6 +9,22 @@ ## Module Contents +### Classes + +````{list-table} +:class: autosummary longtable +:align: left + +* - {py:obj}`Trajectory ` + - ```{autodoc2-docstring} corrct.alignment.fitting.Trajectory + :summary: + ``` +* - {py:obj}`Ellipse ` + - ```{autodoc2-docstring} corrct.alignment.fitting.Ellipse + :summary: + ``` +```` + ### Functions ````{list-table} @@ -59,6 +75,22 @@ - ```{autodoc2-docstring} corrct.alignment.fitting.refine_max_position_2d :summary: ``` +* - {py:obj}`fit_parabola_min ` + - ```{autodoc2-docstring} corrct.alignment.fitting.fit_parabola_min + :summary: + ``` +* - {py:obj}`fit_ellipse_center ` + - ```{autodoc2-docstring} corrct.alignment.fitting.fit_ellipse_center + :summary: + ``` +* - {py:obj}`fit_ellipse_parameters ` + - ```{autodoc2-docstring} corrct.alignment.fitting.fit_ellipse_parameters + :summary: + ``` +* - {py:obj}`fit_ellipse ` + - ```{autodoc2-docstring} corrct.alignment.fitting.fit_ellipse + :summary: + ``` ```` ### Data @@ -175,3 +207,175 @@ ```{autodoc2-docstring} corrct.alignment.fitting.refine_max_position_2d ``` ```` + +````{py:function} fit_parabola_min(fun_x: numpy.typing.ArrayLike | numpy.typing.NDArray, fun_vals: numpy.typing.ArrayLike | numpy.typing.NDArray, scale: typing.Literal[linear, log] = 'linear', decimals: int = 2) -> tuple[float, float, tuple[numpy.typing.NDArray, numpy.typing.NDArray] | None] +:canonical: corrct.alignment.fitting.fit_parabola_min + +```{autodoc2-docstring} corrct.alignment.fitting.fit_parabola_min +``` +```` + +````{py:function} fit_ellipse_center(prj_points_vu: numpy.typing.NDArray, rescale: bool = True, use_l1_norm: bool = False, decimals: int | None = 2) -> numpy.typing.NDArray +:canonical: corrct.alignment.fitting.fit_ellipse_center + +```{autodoc2-docstring} corrct.alignment.fitting.fit_ellipse_center +``` +```` + +````{py:function} fit_ellipse_parameters(prj_points_vu: numpy.typing.NDArray, rescale: bool = True, use_l1_norm: bool = False) -> tuple[float, float, float, float, float] +:canonical: corrct.alignment.fitting.fit_ellipse_parameters + +```{autodoc2-docstring} corrct.alignment.fitting.fit_ellipse_parameters +``` +```` + +`````{py:class} Trajectory +:canonical: corrct.alignment.fitting.Trajectory + +Bases: {py:obj}`abc.ABC` + +```{autodoc2-docstring} corrct.alignment.fitting.Trajectory +``` + +````{py:method} __call__(uus: collections.abc.Sequence[float] | numpy.typing.NDArray) -> collections.abc.Sequence[numpy.typing.NDArray] +:canonical: corrct.alignment.fitting.Trajectory.__call__ +:abstractmethod: + +```{autodoc2-docstring} corrct.alignment.fitting.Trajectory.__call__ +``` + +```` + +````` + +`````{py:class} Ellipse(a: float, b: float, c: float, u: float, v: float, c_vu: corrct.alignment.fitting.NDArrayFloat) +:canonical: corrct.alignment.fitting.Ellipse + +Bases: {py:obj}`corrct.alignment.fitting.Trajectory` + +```{autodoc2-docstring} corrct.alignment.fitting.Ellipse +``` + +```{rubric} Initialization +``` + +```{autodoc2-docstring} corrct.alignment.fitting.Ellipse.__init__ +``` + +````{py:attribute} a +:canonical: corrct.alignment.fitting.Ellipse.a +:type: float +:value: > + None + +```{autodoc2-docstring} corrct.alignment.fitting.Ellipse.a +``` + +```` + +````{py:attribute} b +:canonical: corrct.alignment.fitting.Ellipse.b +:type: float +:value: > + None + +```{autodoc2-docstring} corrct.alignment.fitting.Ellipse.b +``` + +```` + +````{py:attribute} c +:canonical: corrct.alignment.fitting.Ellipse.c +:type: float +:value: > + None + +```{autodoc2-docstring} corrct.alignment.fitting.Ellipse.c +``` + +```` + +````{py:attribute} u +:canonical: corrct.alignment.fitting.Ellipse.u +:type: float +:value: > + None + +```{autodoc2-docstring} corrct.alignment.fitting.Ellipse.u +``` + +```` + +````{py:attribute} v +:canonical: corrct.alignment.fitting.Ellipse.v +:type: float +:value: > + None + +```{autodoc2-docstring} corrct.alignment.fitting.Ellipse.v +``` + +```` + +````{py:attribute} c_vu +:canonical: corrct.alignment.fitting.Ellipse.c_vu +:type: corrct.alignment.fitting.NDArrayFloat +:value: > + None + +```{autodoc2-docstring} corrct.alignment.fitting.Ellipse.c_vu +``` + +```` + +````{py:method} __repr__() -> str +:canonical: corrct.alignment.fitting.Ellipse.__repr__ + +```{autodoc2-docstring} corrct.alignment.fitting.Ellipse.__repr__ +``` + +```` + +````{py:property} extremes_u +:canonical: corrct.alignment.fitting.Ellipse.extremes_u +:type: tuple[float, float] + +```{autodoc2-docstring} corrct.alignment.fitting.Ellipse.extremes_u +``` + +```` + +````{py:property} center_vu +:canonical: corrct.alignment.fitting.Ellipse.center_vu +:type: numpy.typing.NDArray + +```{autodoc2-docstring} corrct.alignment.fitting.Ellipse.center_vu +``` + +```` + +````{py:property} parameters +:canonical: corrct.alignment.fitting.Ellipse.parameters +:type: tuple[float, float, float, float, float] + +```{autodoc2-docstring} corrct.alignment.fitting.Ellipse.parameters +``` + +```` + +````{py:method} __call__(uus: numpy.typing.ArrayLike | numpy.typing.NDArray) -> collections.abc.Sequence[numpy.typing.NDArray] +:canonical: corrct.alignment.fitting.Ellipse.__call__ + +```{autodoc2-docstring} corrct.alignment.fitting.Ellipse.__call__ +``` + +```` + +````` + +````{py:function} fit_ellipse(prj_points_vu: numpy.typing.ArrayLike | numpy.typing.NDArray, rescale: bool = True, use_l1_norm: bool = False) -> corrct.alignment.fitting.Ellipse +:canonical: corrct.alignment.fitting.fit_ellipse + +```{autodoc2-docstring} corrct.alignment.fitting.fit_ellipse +``` +```` diff --git a/doc_sources/apidocs/corrct/corrct.alignment.markers.md b/doc_sources/apidocs/corrct/corrct.alignment.markers.md new file mode 100644 index 0000000..a388aa5 --- /dev/null +++ b/doc_sources/apidocs/corrct/corrct.alignment.markers.md @@ -0,0 +1,103 @@ +# {py:mod}`corrct.alignment.markers` + +```{py:module} corrct.alignment.markers +``` + +```{autodoc2-docstring} corrct.alignment.markers +:allowtitles: +``` + +## Module Contents + +### Classes + +````{list-table} +:class: autosummary longtable +:align: left + +* - {py:obj}`MarkerTrackingVisualizer ` + - ```{autodoc2-docstring} corrct.alignment.markers.MarkerTrackingVisualizer + :summary: + ``` +```` + +### Functions + +````{list-table} +:class: autosummary longtable +:align: left + +* - {py:obj}`cm2inch ` + - ```{autodoc2-docstring} corrct.alignment.markers.cm2inch + :summary: + ``` +* - {py:obj}`track_marker ` + - ```{autodoc2-docstring} corrct.alignment.markers.track_marker + :summary: + ``` +* - {py:obj}`create_marker_disk ` + - ```{autodoc2-docstring} corrct.alignment.markers.create_marker_disk + :summary: + ``` +```` + +### API + +````{py:function} cm2inch(dims: collections.abc.Sequence[float] | numpy.typing.NDArray) -> tuple[float] +:canonical: corrct.alignment.markers.cm2inch + +```{autodoc2-docstring} corrct.alignment.markers.cm2inch +``` +```` + +````{py:function} track_marker(prj_data: numpy.typing.NDArray, marker_vu: numpy.typing.NDArray, stack_axis: int = -2) -> numpy.typing.NDArray +:canonical: corrct.alignment.markers.track_marker + +```{autodoc2-docstring} corrct.alignment.markers.track_marker +``` +```` + +````{py:function} create_marker_disk(data_shape_vu: collections.abc.Sequence[int] | numpy.typing.NDArray, radius: float, super_sampling: int = 5, conv: bool = True) -> numpy.typing.NDArray +:canonical: corrct.alignment.markers.create_marker_disk + +```{autodoc2-docstring} corrct.alignment.markers.create_marker_disk +``` +```` + +`````{py:class} MarkerTrackingVisualizer(fitted_positions_vu: numpy.typing.NDArray, images: numpy.typing.NDArray, marker: numpy.typing.NDArray, trajectory: corrct.alignment.fitting.Trajectory | None = None) +:canonical: corrct.alignment.markers.MarkerTrackingVisualizer + +```{autodoc2-docstring} corrct.alignment.markers.MarkerTrackingVisualizer +``` + +```{rubric} Initialization +``` + +```{autodoc2-docstring} corrct.alignment.markers.MarkerTrackingVisualizer.__init__ +``` + +````{py:method} _update() -> None +:canonical: corrct.alignment.markers.MarkerTrackingVisualizer._update + +```{autodoc2-docstring} corrct.alignment.markers.MarkerTrackingVisualizer._update +``` + +```` + +````{py:method} _key_event(evnt) -> None +:canonical: corrct.alignment.markers.MarkerTrackingVisualizer._key_event + +```{autodoc2-docstring} corrct.alignment.markers.MarkerTrackingVisualizer._key_event +``` + +```` + +````{py:method} _scroll_event(evnt) -> None +:canonical: corrct.alignment.markers.MarkerTrackingVisualizer._scroll_event + +```{autodoc2-docstring} corrct.alignment.markers.MarkerTrackingVisualizer._scroll_event +``` + +```` + +````` diff --git a/doc_sources/apidocs/corrct/corrct.alignment.md b/doc_sources/apidocs/corrct/corrct.alignment.md index e6ac67b..9865b01 100644 --- a/doc_sources/apidocs/corrct/corrct.alignment.md +++ b/doc_sources/apidocs/corrct/corrct.alignment.md @@ -14,7 +14,9 @@ :maxdepth: 1 corrct.alignment.centering +corrct.alignment.cone_beam corrct.alignment.fitting +corrct.alignment.markers corrct.alignment.shifts ``` diff --git a/doc_sources/apidocs/corrct/corrct.models.md b/doc_sources/apidocs/corrct/corrct.models.md index d4e1a41..8e19a3c 100644 --- a/doc_sources/apidocs/corrct/corrct.models.md +++ b/doc_sources/apidocs/corrct/corrct.models.md @@ -63,6 +63,10 @@ - ```{autodoc2-docstring} corrct.models.get_vol_geom_from_volume :summary: ``` +* - {py:obj}`plot_projection_geometry ` + - ```{autodoc2-docstring} corrct.models.plot_projection_geometry + :summary: + ``` ```` ### Data @@ -485,3 +489,10 @@ Bases: {py:obj}`corrct.models.Geometry` ```{autodoc2-docstring} corrct.models.get_vol_geom_from_volume ``` ```` + +````{py:function} plot_projection_geometry(prj_geom: corrct.models.ProjectionGeometry, vol_geom: corrct.models.VolumeGeometry) +:canonical: corrct.models.plot_projection_geometry + +```{autodoc2-docstring} corrct.models.plot_projection_geometry +``` +```` diff --git a/doc_sources/cone_beam_calibration_tutorial.md b/doc_sources/cone_beam_calibration_tutorial.md new file mode 100644 index 0000000..f6bc2c3 --- /dev/null +++ b/doc_sources/cone_beam_calibration_tutorial.md @@ -0,0 +1,147 @@ +# Cone-Beam Geometry Calibration + +In this tutorial, we'll demonstrate how to use `corrct`'s cone-beam geometry calibration routines. This process is crucial for accurate X-ray tomography reconstructions, especially when dealing with cone-beam geometry setups. +To follow this tutorial, you will need to download the demonstration dataset from [Zenodo](https://doi.org/10.5281/zenodo.20559974), and place it a sub-directory `./data/` of the current working directory. + +## Setup and Data Loading + +First, let's import the necessary libraries and load our example dataset: + +```python +from pathlib import Path +import h5py +import numpy as np +from numpy.typing import NDArray + +from corrct.alignment.cone_beam import FitConeBeamGeometry, tune_acquisition_geometry +from corrct.alignment.markers import create_marker_disk, track_marker +from corrct.models import plot_projection_geometry + +def _get_data(fid: h5py.File, data_path: str) -> NDArray: + dataset = fid[data_path] + if isinstance(dataset, h5py.Dataset): + return np.array(dataset[()]) + else: + raise ValueError(f"Path: {data_path}, is not a h5py.Dataset, but a {dataset} instead") + +def _load_data(fname: str | Path) -> dict[str, NDArray]: + with h5py.File(fname) as fid: + return {k: _get_data(fid, f"/{k}") for k in fid.keys()} + +try: + data = _load_data("./data/calibration_scans.h5") +except FileNotFoundError as exc: + raise ValueError("Please download the example dataset from https://doi.org/10.5281/zenodo.20559974") from exc +``` + +This dataset is composed of two different tomographic scans, with 60 angles each. In each scan, we record the motion of a +sphere in a circular orbit around the rotation axis of the sample rotation stage. The difference between the two scans is the +height of the orbit with respect to the origin of the sample coordinate system. + +## Sphere Trajectory Tracking + +Before we can proceed with the geometry calibration, we need to identify the sphere position over the detector at each angle. +The sphere trajectory over the detector will be an ellipse, corresponding to the projection of each circular trajectory in the +sample space. + +We'll start by creating a marker disk: + +```python +prj_size_vu = (data["scan_1"].shape[0], data["scan_1"].shape[2]) + +probe = create_marker_disk(prj_size_vu, 3.5) +``` + +:::{note} + The `create_marker_disk` function is a convenience function for objects that look like projected solid spheres. + For other types of objects, you might want use one real image of the object (or an average of them). +::: + +We can then track its position in our projection images: +```python +pos_l, pos_u = (track_marker(imgs, probe) for imgs in (data["scan_1"], data["scan_2"])) +``` + +## Initial Geometry Estimation + +Next, we'll estimate the initial cone-beam geometry parameters: + +```python +pixel_size_um = float(data["pixel_size_um"]) +orbit_radius_pix = float(data["orbit_radius_um"]) / pixel_size_um + +fit_cb_geom = FitConeBeamGeometry( + prj_size_vu, points_ell1=pos_u, points_ell2=pos_l, pix_size_um=pixel_size_um, plot_result=True +) +acq_geom = fit_cb_geom.fit(r=orbit_radius_pix) + +print(acq_geom) +``` + +The radius of the circular trajectory should be known and provided in pixels. +This calibration procedure is based on: + +- Noo, F., Clackdoyle, R., Mennessier, C., White, T. A. & Roney, T. J. (2000). Phys. Med. Biol. 45, 3489–3508. + doi: 10.1088/0031-9155/45/11/327 + +:::{note} + While the attribute `pix_size_um` is optional, we suggest passing it, as it will, as it will enable the fitting routines + to automatically present the fitted distances in both pixels and micrometers. +::: + +If the switch `plot_result` is `True`, this procedure will display the trajectory of the ellipses, both before `eta` fitting and after. + +![eta-det](images/alignment_cone-beam_ellipse-eta-fit.png) + +## Geometry Refinement + +For more accurate results, it is possible to refine our geometry estimation, by minimizing the residual of the reconstructions: + +```python +imgs_t = (data["scan_1"] + data["scan_2"]).astype(np.float32) +angles_rot_rad = np.deg2rad(data["angles_deg"]) + +acq_geom = tune_acquisition_geometry( + acq_geom, + data=imgs_t, + angles_rot_rad=angles_rot_rad, + params=dict( + theta_deg=np.linspace(-1, 1, 5), + phi_deg=np.linspace(-0.25, 0.25, 5), + u0_pix=np.linspace(-1, 1, 9), + v0_pix=np.linspace(-1, 1, 9), + ), + verbose=True, +) + +print(acq_geom) +``` + +The `tune_acquisition_geometry` expects an initial geometry, projection data (with corresponding rotation angles in radians), and a dictionary listing for each parameter to be optimized the list of values to try. +It will then return the geometry that has the lowest reconstruction residual for the given values. + +::::{grid} +:::{grid-item} +![Phi (detector tilt)](images/alignment_cone-beam_fit-phi.png) +::: +:::{grid-item} +![Theta (detector tilt)](images/alignment_cone-beam_fit-theta.png) +::: +:::: +::::{grid} +:::{grid-item} +![u0 (Horizontal source position)](images/alignment_cone-beam_fit-u0.png) +::: +:::{grid-item} +![v0 (Vertical source position)](images/alignment_cone-beam_fit-v0.png) +::: +:::: + +## Visualization + +After completing the calibration, you can visualize the projection geometry: + +```python +plot_projection_geometry(acq_geom.get_prj_geom(), acq_geom.get_vol_geom()) +``` +![plot-geom](images/alignment_cone-beam_plot-geom.png) diff --git a/doc_sources/images/alignment_cone-beam_ellipse-eta-fit.png b/doc_sources/images/alignment_cone-beam_ellipse-eta-fit.png new file mode 100644 index 0000000000000000000000000000000000000000..afd3a10ca40d914f79460d3400a89525066a0194 GIT binary patch literal 32841 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 beLun~q`vI1TJSL#f&Y{hROO3g&4T|IFOeE% literal 0 HcmV?d00001 diff --git a/doc_sources/images/alignment_cone-beam_fit-phi.png b/doc_sources/images/alignment_cone-beam_fit-phi.png new file mode 100644 index 0000000000000000000000000000000000000000..3086a9e9f22412c5a9d4a16332d2e16d9986b0a2 GIT binary patch literal 26708 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 literal 0 HcmV?d00001 diff --git a/doc_sources/images/alignment_cone-beam_fit-theta.png b/doc_sources/images/alignment_cone-beam_fit-theta.png new file mode 100644 index 0000000000000000000000000000000000000000..8741c0b1b7e2056b0e07b25a9f0b8c7de9c465a8 GIT binary patch literal 31798 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
L<^S_n+0$QobSOQ(c z#PoEeRV;{DVA`r=UNAQHBz}X1mKL*7B+%b@Y;r4jo36MW5Kk zXtkOB{M0^uvi69ASQl3+J~Q-BEB~Q2E(M;C;?XfDVUP%!0Yko@aeJ2)>~R1W7s!(5_8|%g$+`d~oj&lmKTrm8f9UnKM&KGE>_qdo9G)PFsl z!#=$<>Me6T@__@Rw3kmvE2B&Fq2vFP)OTJ9jg*L}78qN}2M@47rh1K=I|4q9p#ZbB zUeAOPB)!R;GxTprnr5>~*;|P4pm1#Ek-E!D^xdKEQr_UFdr^JcOy5Z(cvYy8`D7E^ zt4_E^NA53SqH$h%XZ#)cm5F5|E+OUEAEFQULBd^_D2f)9loZk(=f(9$lR@Mg9eo+p zGjt&1M=EeA9-|r^>m0UBiSK}BG42NC%Apj8zuFK-ll{{RHP_VBHeq;g&u$kT^Yvey$75O58K_CZBqH~)(P zgfob^eWar|Te}0AUi|jIFIFAzGl8HTW(QVVxjOjC)ie^2(z{IkVRu*X7aK|Ua^4;& zOck2Sb8V5XyBGEfT4O$u^PUzGUa<&YYb@d>l_?mot+O6$wd%bWz64_3-^Tr zi$UI%6F#evR**J-RLQ2w&CNv_7#Ofa$iSl%yx0BdaS6;8>_JoM+}X3%ym@D@9gn;! z%V+3d8*chad|HJA7yjY(OMW(9;>wX&@4vuXiaQYVn|~A-D!_#R!pDr#T$q`Rw=88C zCAb~-hWDesW@VtjxBVJp7t1+3QL=tX?O*WOfEsqyEAeinMP6lsUq5QvcF6`r+>m{K zjkV=z!77AC7{T3hI5CCU%U0ywzj@?ef7Z7_0?Io;a)9h!YCwb9#uUb|Rt*gFQAiIE z#B`7)!UE^-DC1cj=1_ZAjXX>ST-U35w^o=DRW~gHj6RFmK{)i4rCS^ki7n$*n2bFM z8u$+5(CEUrRHck*Rrk|;@+v^>upAGqQMxK;MEFQ%%l z_(_)-kkM~e*NL756H+B##f(@PwJf2|`%?oWC(~C?)a*#TO^?)V%)+k%U?2*?xfK=! zN;iZK48jy0w135OARiB0TyyTn3pVA)M=L1E)UVtIyCJGejKfgco)6x5H(3u4g?FxW zVR%xch2>QOhUm&svW^XDC)DaL%LSKRKP)g812X#TwZ6F|`;l_2E!Xa{vwoc@m{&p| zf4kcBn2Ok^#`|{S{JN5S+YUW3kZfUMh}4#8r|^tU`#_C(fRHgHRtC00YUznEdET(d zNd!R2RF?TBUA;m42Ci=@&ak<7G|>HxeXPF&*pNhhFeFF+H^o^pQRC|XJYWFLhYVv- zC;!uKc%UTa+eYGYYZWe-#>o*|6J9xX?}T)$_kMTkJnoSR1`Ls#JZ?z#!e8=EkDu8O z4}@vGM&dKO&g^S!EGTEMZZv2_#{jHuwPs}r!WJ8-UQ0R8efHD$gmKD@`8gv;W&&g? zx+D+^9)!4F6u|26tW2ah#a|3}fwz1WQx*oK;5iso@A4M}&1QOk?!AOG^oLW7%p~X8 zMu*Y0+4zudRlAjc8}N&UH<$*$ec0e7wBrsyQ9Frxd?W;7vn_;Hfd+;YHSV&U@>y8b zNWIM^(PE1JJy`Ehy+{V=ghHVOp(_BesxME z3&7)F@Q5eg zHMqEIm2&YQ^Jf@An-;o+5U{j5-75iy8~GL}+!d1cQLD0c2jm$j@J*UD@qSLFWVx$0Mb7Arr7l3`-n$_c4~ z15HPplpeGXO8|){8|)L)Q^lh!GOD5h@|-ZeRr`DnsLq&4RP(0!Gk21C0qPU|fbM|s z?hjXlNmSwU-k~<}_m4k*Le%?KL^CA@Aq8GqZlO8r_FHn=%HZ9g@pasHr^EOB3X^QH z+*yw!`)hi(1n7_n@g^;(en5cgP8qG_xW|9$@z zr}jlv8dlxmcU-~*1(8q$f|i7RJ0uM{I}9ptmLlGR&_a~FJCauy?MCV#*?RKU=Mkr7 z_@&5!_Q@vU^)#yag|&J=HazYhL%k!xkf~hfvxo0A)<)CostyvfjQjUv7kRY2LuThc0pZRTGjkL_zuEo;QykaXemU#%7zpe@FfNq)dD8c z>vdSzxVb?Xr%GK{aHyZdRF?8JV6ToVt(lS^z&wX^ih=-85kAykW;gekPfJEA5;Y$&Gs|IA# zi1Zei^sX=JeDE2B2Ka6hT2^t0gv$)ng-g#YGG->fLC{(Ko~M~0*`l{;5p3@&RV&#v z=jiA8^%G3cYyu}>9!nvz7P4(3kp62RypAapKEZ%!lz0o zY6o!&=#G8z!|uz#Ku63kzUT9gg`E%5k6NM1o&A~ToFRWkIWzE9*aS?C)-;CltsgWg zf!u9MFr`SkTgJ06qFKa1Obum zMT&?Z2&gF1NGK>ucQ?|VYr&!s>5y8$+V|i0-TQoJ&i-c4IdhzGbW{-j@jUl)-`DlK zgxx1+_jWvo-O;;-a$$0{(HqgXD~d?iZRxgkFWkpb!#^_aUi2 z9W2=)#IF0;0fZ?f>G05R25=44O-*TC-hXi;Cy*$&>fpNUF23rTphhYgm*%(1Ns<)s zhb^%K?9y6Yh{xLAu0&ljmgmS>0AM?7)U!1 z5l&ZER|fm{vPkgy3xie!e2=nHZp&@p_hXdxxCE%4^3_`M4+a)6_(2?q=oJlh_2cB^ zDHfdx4a{-1xtOJI`Xz5+M1~zTSc##9!v3@`#qu%R5#JcDy5^mlPF@O1EF+&&)Pn{j?VyF!9g7u%-CV20tP!g5!pW{rD9k z-9qbT5M{gn?!O<;Lu7a!i-?+uSacXGn|{VHCyO5d(k%bgX^3$?c$2oEpC+$|J=%0;i%nSAGrq% zWYsL&1)L%r`I_=s>H6OCKtZ}b|F;wIS<2?-=Fx_zcJs1dmxMa32Zbo&WS;P=FT+$d zv?1K}Q)D6+UNs>NhktSz8mX}EwR}RuTQ=L`O;p~OcG<1yyNx5qn_zGzXM_FscC<(U zyg89WLpp%HLqJMMD`0&DnYV#NXI4)T&LjZt=tz|Bge4&8G5=YxNBcGkRGtd_)tf&>QB+ z^8`nspwLhfR_GB!LDnrQ(Uao%~Tob#OU^*%Pf-4JfU}e z1)+Ww?ay$Jf~^Bl45?q0aWo|!D;C@yNrSn>R5v=dIeQNI+j=l!3jON618%O{AbyoY zr;9>Y*u3d4#L^Y4dFB8!IN?t=GBs~L(=xg)Z*~}@9Zs?^m91mkSS{{s zmwKX0@m2_2%#&Zy=_sT9@4U!h$B{GMCvPB^ORfQ)=LQkac9XH)T_PflW!vU>{_CJ% zRWE_kdXQgOc{EXy`)gcK1At?%v0__Vq}DeXWBcZ}zV zubmqlmZSAz2aur%REOrxk*4r5C13C4SfH&B>ESYh>a`sd5Sv~`Z$v;-llpdbD|@ZR zf2jn3QSjGlK82y-1FYiGkv|?Sy;L)q#)g^=v0+E@IkUFy0<=1}>9|Fa|BD8isuFue zvq{TP_wL4y-qy>x-$@5ouTa{c@{@dHvCBCpJ?<*>-J|6PGv z9{?4!`@v(qhPmFB&fwGz$|k_&&&C zJO!!@GSJ1nOpAR_pjfz~kY0s+TmtfO-5@y&K`q@{BC>;7T?)?g$*?al7If5K0?Z`? zw=@yL-vp+ea?xV8c$Pk->IJw={ilD5)fN!2y?y<*#v7%OC$=0noRj)C=6CB^xqUG= zffi^;&dWF$gw#W_)F=p~JoWtJf z*_xA`qS<)BM}J5&90m6)YW24OQh9+$&kj4tOf7M6L4f2!8U`7jMSjOL{FruQvehG} zAF_E||GbhPCUj87w*Z-iUirPsHYOUs|~6-dvSQ#ggLc>XEY1`g{)c({Mkha}hb(q*B@XxYEL~+q%rbm{DsQwM+8V zyIOfEH@REdI!xeMXw!3h4%5|HemgDMSc!f5ZpjI~AEaLc>Ws$x_9UVrwG@I8{+|B5 zDcwr1LraMl9b?ttvA|03#=xtTj2tUdZk~|#5RWHencM+kYDem$BPDJ@+m_7r5e0=^ zlCxrbCoGPTJEJLVz1d~HMzQEB1cT-;NMB3G1%;gTCY;2xH{4GW6Ds~?m*<+1q(BIU z<-e770M8_FhoA#;cBDsj<-qyqeR=BcCjy5bNw8wXwNf#W4*)a@&tu7F@VzL=_5@V< zw@ht{9^9EfDV#kk*3ReWZ zyOd_(-rmt+4)sr8mJteAq|BErs{vGN56)eEF3>Lnj;%NKCha$EUZ`ofyJq1K(D*Rt zwEOSnQOxiU4)xoH%i?2f*f6~G^9g9W3A(Nxe)aco*1sGXXFCVfo)ViqL5f3rS=@3E z;CH$z4aLSvtHipeY^O@3V`&m{PaJxSdj0k-X)p?1F#a(oWM3I950)OFdVz`r;m~-2 zx*D9x*^hfUfi&y~+;5;qR4&3;CK_mfYe4bc0P3YdVLNoVf3&o;c>2z^G3gfBwtH`n z&H;_n;aO~Z3rrH#E54GgWJ&!1g{Gr8e?;23EK%tgeIU7lqiIfcsC?;I^l%B`OxC2y zRDx>(Q%?zd`?Y;XmP9Oxyz27&&VSYj}D@TjJ;H zDeNYnW_MzW@*^Pn{@dea%CX6VP9>CgJxJics4MnT0hJyW+^1GrGCLm4GVI}=?+uh{ zqa8L+4TpNbb|R+Rzm}#dX2(Y#GJKD>z?(}W8khmlQsxp9b*nW8<%?SZg^BV950DV7 zR>}K2DH;F1G(`X2)1U0hM%lyw!=W=|-Q!8-R?3zJA+aA5fW6Rurw@aNd=`jU6S`wWyb)MaY9#&!3^*>1Mz8D^DI z!`41AR+yw&wzE?S9T7>-%W!)$QjmQdVw$`p^t%_XW$rrJ5eqk#$X0c2QZHWKJSSB@1TkFdnr0)uKd!j zi^aeG4`cFR8wW>&MPAxpj+36cae6P)-bfex@KL&mn%*>d(D8eSu)5MtMD1UyMOpim zvG4mymkj0|OVwxBE=L@xiqFrL>#p5h#HM&?Q|M|+6jw%GHT5N4Kv5O%r8PN@HqZ2_ z>V^kkv+xQy`bEmhKqoDh=x!1k?jvA3qF|Sanw~a;%z@x?(9IbG9ow`iYz8PwoqFOx zP635z6EK*vN)8K7B9uX3wt%p-ht7_tWZF@QsIInF5@t_1q-ojzn>sG`Tavc+0h!7T5u$l^zx_pDYSbfa{Xnm_!1i|yxC{J(>=&#bU=oAw8hOd^sA}oN z;ID+$LHjl>XvFKgb(?1ghpTbsb$&DBYC3~efnr;OQ_?nw=Ur>?8*KS-z6>ntAE9}>~mV0X~AsboyI@@m8fLI?*5~#ZQ75Fz1Upu?~`_d zF8HAl#LqXmv~tHktl;WVc}Bf(h9o@W&?T0E&RQ<9gt0Wa)FO<`V*FlS+VU0g)IbEm zfC<0g0QI9%$82Tg@EfA1{YOKs_IkdC5gP6j-MQ`sQ=4c^&6A7G-e-g__$#sq%A0He z@5({1H>P;_aP=Ve4TUhdab4BKViNWdJxl&ive858C(68YbB8>~4%q+rum2e-3J~Ep z=Mfobx?Yh0n3Vvz^?trLO%zy9f98Kv;C1r?)FrwVrhY_{zK1ets_`70oTLDTkCCuT zu*0LgH)-5(-V$O~KQk}AISD@LqT`F}jEnn6lW_qN()PG5?(c=(w=$&SKOWfsbfdmG zYzq>{HmDe^p%U3LZ9boBwhEmsANlB($@V59`=SSy^phX-P7P3f)QXaaZ~S4Nc0%lJ zWFs190@+9{b(!jHY&IHnuR!TgsbJPREI!JLI~r?I*KxsEtc}KVvyF4;o zJZcYd2}svQW^2gwJSXR0%*-wD{y}H#N~RFnqA9KoF`21b9LpfV&^OXXnJl z#qD-sqN z#qLE`4{srhf8Lai`q-OVr+HVHE184*_{_F+17`F_$cRt=SK8NZsY2y1)B?&jB)Mfn zIwer1HG7g&@v5zQV_-i325>lR>uZjLy6(=ti0moRAt{vV(k^cOqj}dnjirmcyEcCv zdyul?r1O!V3k$8#y2|vRZ33h_IUjG^r$A7i4$zV+@Q{a){Bmja{*>@M3Z1=csw{O> zPp-pTi#(A2AnB2>6{*;TTr@vN?BG$yD$%g`vg$(9n~Rc>ZOAV&2?*LxyfMMlbg`s7 ze0{?afBkBd-su-Rp6|<&yM!pfz94L8z>2db;GVZ@$&MDM+1R{r{-V-5F0YSqI(uTv zCsx`GH(^v=?EWP*_WrV_0Naf#I&CkJK?iwYN1 zBpJx7D&OG0b{~(~4)|PgHkFb-<2^J?K%G93shvb5$x0finrMOP4oM3SzmT(_aFUy8 z_}gXs7%_OWz0Kg@tUI`t23$T}BR;Apnqn4GF~E{UGEu^l$Oam4 zsSi2uZIedWG`*E{mI{UDjU;G9XBbvwND=AKfXh+X022pH4i%p7%~fq>27xUBpLcL9a@M;F@$SPX2gK$XO4u`b@qbFO_qp5A-n8s?g1EH=fgkigaq?5S?3 z@1Wj~EaDTZ>wu+WvDjUs86HwrX*a*?X+22W=aXJIv2mWRrGBdfZhi{LGR2roxujdz zXy;iw$=b;~O_H&ZE)IJ3My4!vzb5EzXls+CF^w!PI^Sr5gwl^-lJD~I_%XDi84v1UIt#Aqend0-d%q9p67 z2Rbd>y8=5uAoSXnl6XT~+DR^A39V?dJlX_3mSNh&TEL3Z+sd4rPAIftpLEN)Nb%8y z&A0ZbOtbJfG?)0s7+aHwJx}DF?`ir=O(jvGbcge=7&POP#RowL8mQ5XD>_XfN@8|T zN0w?nXIap2%kI4Z3fF~?0{Luj@kfGrgL%!%4o8Y; z8G>-`3AfcRKUA5J>i8@FH*1j-RmL1D6XQhG%s{|BY?lQ;ocK1~zzM4x>EpC-L_XQE zf}3mqn&S$+?U=-Ng~>Ggh1*y-D>$9%VEu7mw>OXN{hp@2-Wl&kLa%n5^_X9MPqdEl zon>m7Ot#jE*?g^Ir?(ek7ivI?q$!^v^LGjIU!X&TN&QHI^n+EbN`dMH!bC120vKLF z@5=l7Gy%XIQl8Lo(RN^khMp2_wS&hOZZZs3ky_0sH>_N^v+8?&AmtC_g_x%`fT!Rr@o7 zkW2CHw8way(_9B)I+|Qr35PQz5DCw~HNS7r^e{(%OAH{X<>Fv$aPmu}W|ho-=cysn zz~m3XDGWc2J_s1B7ks&hCpzs-0rhUD9G(x%C8@XG^B@BT=bv}sa=*zkK#x%)LnwrQ z1!^zI*_i_eeuWhtaU4MpYHDUi5iw|qJ<;|PH}YB|0fSUiZ*K%tGI>D7=?PxRPu}$X z?@3wU0^ZKWkwZAsr_uKhd0QC2=@$ITJ-`5PzS5QMG3se@Xw6LJHk>TsYNBm;dVI1W zUIl#Y2qzuX>%i7tk6F-+S~+q(bH0JW=n@cmY>}zhi>fN$qyYqcK4G`Q1XCZ@0%xW;x2IgfwL%EMa9KhDa2*62kus*AN;cDp3xG$IjGrC4d zQ3?f>8#BLdzR)rdH8V14!56xto7>rv&Ce#*ZoFz1(ni&hLiMsT3e@?T)-3 zJTQi1^^3ByvbBjo6f-!p#KDJR1NXf_63XI77?hOrkJYs`VdPI2$0{&8U_7FP1R?}A z+4%&CRjV`4V}hWT;2hvKOaWe27>mWe0)6Xyt)i3P(+^NU9T$ci8&f!}HbRN{3Y3jZ z5F^$Ie1-K9aH=$cxsG@B;I1K^3x&_&t_87>58+_j>i|Es;-ol2Mv?N<^!2-U@3K|^ znH^^((aSGJ55|ulXo1=S#5_NBg(!XXsH52&-qb%&+jC2L?FfOb9=uqhcF}+ukQjBQ1uq?mF??DG z7W1;XcK!NefVmpRt^*H)+1J+>2EcJe-yoL-NwATc0}kjeguS658R6u?LFR6Uz&L>Fu+l@?= z_f3G`+)GnsgLgszypXyNOH|&9nsyc1=)u9dc&QMBkmxoJz9lab-xa~vz%^G0XO{)# zq+#U@-6Q#nUxPPg$F)Bv&T;_dfG8a%-jy>D`7#OiHna^yais&$&QJWqfFL#eJ$x_b zeltJ@=5y|Vh(Shpua^OL<<%NU!E_CjgCz)2WI$7J&hv@~8cPk~b9aE`^~g6c zs}Bc+t=7WMj*eLv0mJY0bMBz=8v?2X!eYAw?mRz#=n-PlY5pgG{(!~!6Y$$$2V8|a z_`$8qgj_af4x$W>o z@BqQNa0Ajma*S)9dA0KP_}tZmm#F#?dG$Y$6B&iW#SRFL?c$b&4qv3#3T55J?8VNL zy`RsYE+pK|M%~SigS%-CfgB*4pV2$~aV~uNz!%VNmVN0o@NveF?vM|U-Cd09jmP0N z5Q~+#O@I~du661SC571hnH2bkFp}#2TN&5p5D%MBh zBps5mqeS-elLRDgj9;(F)*wmn5C!pger0l5W=&yiq{d)36m00wV~%6<2ke`i4U{v? zDBt}Y6Gl11(5Y-PopwiFD27zP)|u01Kpi0hNq~9!>!L=178L+EQ?|zu*xm(*)b}j{Gm>rN+j&3<-o>^tT2t8@&s!n{V*M# z?f~qc-4fzzLXo2F23*Nkkgey{D{E$M&ivwD<+i^{uaOtnOd7#%5()?&My;=!&1JC; zfHi9LrOB4}MAF&n92774v74FBmlBEz)>;x_|%* zB_U+Hho0DWu*M!#lQlc=pofC`h#8&V3lQAW3@#s(WT&+l#s` zV;{phI6(9Yd+-SnZ!(&$tNt-v|+4vJW3HNR{>=qSNaK-bVW7i9e3as9D!%eS) zAIGVws}|*bASKQk+Q;?w5WWe1k(eyWi*{i72G#z2uI~VRF|AJk@DCQm(NZM$lIHYj zGuZTdp>b}*1fg-kBBZbyi7|^?Tf!2}1oi>ZQnwXb1UN}j_~6fbvA1$nUH$FE(vr8~ex1a>JpI4rJzqJs&FKaCPD&#&F60sDd(k1i%I}iB(K5TfFa72B-(&qjm^W PCc3Gps*rij_}PB|8b86d literal 0 HcmV?d00001 diff --git a/doc_sources/images/alignment_cone-beam_plot-geom.png b/doc_sources/images/alignment_cone-beam_plot-geom.png new file mode 100644 index 0000000000000000000000000000000000000000..d32d2f8d2933e637aa77240a2ab839c6be9ccaff GIT binary patch literal 160642 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 U8h5s Date: Wed, 10 Jun 2026 13:59:27 +0200 Subject: [PATCH 24/26] Fixed example script Signed-off-by: Nicola Vigano --- .../example_02_cone-beam_calibration.py | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/examples/alignment/example_02_cone-beam_calibration.py b/examples/alignment/example_02_cone-beam_calibration.py index bbc5466..86fefc9 100644 --- a/examples/alignment/example_02_cone-beam_calibration.py +++ b/examples/alignment/example_02_cone-beam_calibration.py @@ -10,7 +10,9 @@ import numpy as np from numpy.typing import NDArray -import corrct as cct +from corrct.alignment.cone_beam import FitConeBeamGeometry, tune_acquisition_geometry +from corrct.alignment.markers import create_marker_disk, track_marker +from corrct.models import plot_projection_geometry def _get_data(fid: h5py.File, data_path: str) -> NDArray: @@ -26,34 +28,35 @@ def _load_data(fname: str | Path) -> dict[str, NDArray]: return {k: _get_data(fid, f"/{k}") for k in fid.keys()} -data = _load_data("./data/calibration_scans.h5") +try: + data = _load_data("./data/calibration_scans.h5") +except FileNotFoundError as exc: + raise ValueError("Please download the example dataset from https://doi.org/10.5281/zenodo.20559974") from exc prj_size_vu = (data["scan_1"].shape[0], data["scan_1"].shape[2]) -probe = cct.alignment.markers.create_marker_disk(prj_size_vu, 3.5) -pos_1, pos_2 = (cct.alignment.markers.track_marker(imgs, probe) for imgs in (data["scan_1"], data["scan_2"])) +probe = create_marker_disk(prj_size_vu, 3.5) +pos_l, pos_u = (track_marker(imgs, probe) for imgs in (data["scan_1"], data["scan_2"])) pixel_size_um = float(data["pixel_size_um"]) orbit_radius_pix = float(data["orbit_radius_um"]) / pixel_size_um -fit_cb_geom = cct.alignment.cone_beam.FitConeBeamGeometry( - prj_size_vu, points_ell1=pos_1, points_ell2=pos_2, pix_size_um=pixel_size_um, plot_result=True +fit_cb_geom = FitConeBeamGeometry( + prj_size_vu, points_ell1=pos_u, points_ell2=pos_l, pix_size_um=pixel_size_um, plot_result=True ) acq_geom = fit_cb_geom.fit(r=orbit_radius_pix) print(acq_geom) -cct.models.plot_projection_geometry(acq_geom.get_prj_geom(), acq_geom.get_vol_geom()) imgs_t = (data["scan_1"] + data["scan_2"]).astype(np.float32) +angles_rot_rad = np.deg2rad(data["angles_deg"]) -acq_geom = cct.alignment.cone_beam.tune_acquisition_geometry( +acq_geom = tune_acquisition_geometry( acq_geom, data=imgs_t, - angles_rot_rad=np.deg2rad(data["angles_deg"]), + angles_rot_rad=angles_rot_rad, params=dict( - D_pix=np.linspace(-24 * 2, 0, 9), theta_deg=np.linspace(-1, 1, 5), - eta_deg=np.linspace(-1, 1, 5), phi_deg=np.linspace(-0.25, 0.25, 5), u0_pix=np.linspace(-1, 1, 9), v0_pix=np.linspace(-1, 1, 9), @@ -62,3 +65,4 @@ def _load_data(fname: str | Path) -> dict[str, NDArray]: ) print(acq_geom) +plot_projection_geometry(acq_geom.get_prj_geom(), acq_geom.get_vol_geom()) From a0a8ec74126d3178de4666355ce212f748040b5f Mon Sep 17 00:00:00 2001 From: Nicola Vigano Date: Wed, 10 Jun 2026 15:11:00 +0200 Subject: [PATCH 25/26] Final tweaks to the tutorial Signed-off-by: Nicola Vigano --- doc_sources/cone_beam_calibration_tutorial.md | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/doc_sources/cone_beam_calibration_tutorial.md b/doc_sources/cone_beam_calibration_tutorial.md index f6bc2c3..924b785 100644 --- a/doc_sources/cone_beam_calibration_tutorial.md +++ b/doc_sources/cone_beam_calibration_tutorial.md @@ -1,6 +1,23 @@ # Cone-Beam Geometry Calibration -In this tutorial, we'll demonstrate how to use `corrct`'s cone-beam geometry calibration routines. This process is crucial for accurate X-ray tomography reconstructions, especially when dealing with cone-beam geometry setups. +In this tutorial, we'll demonstrate how to use `corrct`'s cone-beam geometry calibration routines. +This process is crucial for accurate X-ray tomography reconstructions, especially when dealing with cone-beam geometry setups. +This calibration procedure is based on: + +- Noo, F., Clackdoyle, R., Mennessier, C., White, T. A. & Roney, T. J. (2000). Phys. Med. Biol. 45, 3489–3508. + doi: 10.1088/0031-9155/45/11/327 + +This procedure relies on certain assumptions of the geometry (e.g., non-degeneracy of certain parameters), and it has +limitations with respect to its accuracy or ability to determine certain parameters (i.e., one of the detector tilts). +More advanced procedures have been published in the literature more recently, which address its pitfalls. +However, it is quite straight-forward to be carried out, and it requires minimal data to work. +The remaining uncertainties can be fine tuned with the data-driven approach presented here below. + +In particular, this procedure assumes that you provide two different tomography scans of a rotating marker, +on circular orbits around the rotation axis of the sample coordinates. +Ideally, these two trajectories should be on opposite sides of the origin along the rotation axis (not necessarily at the exact same distance). +The projections of these two orbits will form two distinct ellipses over the detector. + To follow this tutorial, you will need to download the demonstration dataset from [Zenodo](https://doi.org/10.5281/zenodo.20559974), and place it a sub-directory `./data/` of the current working directory. ## Setup and Data Loading @@ -35,13 +52,12 @@ except FileNotFoundError as exc: ``` This dataset is composed of two different tomographic scans, with 60 angles each. In each scan, we record the motion of a -sphere in a circular orbit around the rotation axis of the sample rotation stage. The difference between the two scans is the -height of the orbit with respect to the origin of the sample coordinate system. +sphere in a circular orbit at radius of 3 millimeters around the rotation axis of the sample rotation stage. ## Sphere Trajectory Tracking Before we can proceed with the geometry calibration, we need to identify the sphere position over the detector at each angle. -The sphere trajectory over the detector will be an ellipse, corresponding to the projection of each circular trajectory in the +The sphere trajectory over the detector will be an ellipse, corresponding to the projection of each circular trajectory from the sample space. We'll start by creating a marker disk: @@ -79,10 +95,6 @@ print(acq_geom) ``` The radius of the circular trajectory should be known and provided in pixels. -This calibration procedure is based on: - -- Noo, F., Clackdoyle, R., Mennessier, C., White, T. A. & Roney, T. J. (2000). Phys. Med. Biol. 45, 3489–3508. - doi: 10.1088/0031-9155/45/11/327 :::{note} While the attribute `pix_size_um` is optional, we suggest passing it, as it will, as it will enable the fitting routines From 745f6f2a8fec27670d5b5379c40b37220a1a70df Mon Sep 17 00:00:00 2001 From: Nicola Vigano Date: Wed, 10 Jun 2026 15:18:40 +0200 Subject: [PATCH 26/26] Added one last check that should avoid mixing the order of the ellipses Signed-off-by: Nicola Vigano --- src/corrct/alignment/cone_beam.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/corrct/alignment/cone_beam.py b/src/corrct/alignment/cone_beam.py index 85c6d1c..7ebbded 100644 --- a/src/corrct/alignment/cone_beam.py +++ b/src/corrct/alignment/cone_beam.py @@ -371,6 +371,12 @@ def _initialize(self, use_l1_norm: bool) -> None: if self.verbose: print(f"- Detector tilt around its normal (eta), fitted: {self.acq_geom.eta_deg:.4} [deg]") + if np.abs(self.acq_geom.eta_deg) > 120: + raise ValueError( + "The order of the ellipses seems to have been inverted." + f" (it suggests an eta of {self.acq_geom.eta_deg}). Please swap them." + ) + pix_size_um = self.acq_geom.pix_size_um if self.verbose and self.prj_origin_vu is not None: print(