# SCOPE-XR (Single-image Characterization Of PErformance in X-Ray systems)
# Copyright (C) 2026 Jacopo Altieri
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from pathlib import Path
from typing import Optional, Callable
import imageio.v3 as iio
import matplotlib.pyplot as plt
import numpy as np
[docs]
def eval_minimum_magnification(a: float, n: int, p: float) -> float:
"""
Evaluate the minimum magnification required to obtain a focal spot image involving a reasonable number n of pixels.
Parameters
----------
a
Focal spot size (dimension).
n
Number of pixels desired.
p
Pixel size/pitch.
Returns
-------
float
The calculated minimum magnification.
"""
m = (a + n * p) / a
return m
[docs]
def eval_minimum_radius(n: int, p: float, m: float) -> float:
"""
Evaluate the minimum disk radius required to obtain a focal spot image involving a reasonable number n of pixels.
Parameters
----------
n
Number of pixels desired.
p
Pixel size/pitch.
m
Magnification factor.
Returns
-------
float
The calculated minimum radius.
"""
r = (1 + n**2) * p / (2 * m)
return r
[docs]
def crop_square_roi(
img: np.ndarray,
center: tuple[float, float],
radius: float,
width_factor: float = 1.5,
output_path: Optional[str] = None,
) -> np.ndarray:
"""
Crop a square region of interest (ROI) around the specified center.
Parameters
----------
img
Input image array.
center
(x, y) coordinates of the center.
radius
Radius of the feature to crop around.
width_factor
Factor to determine the crop size relative to the radius.
output_path
If provided, saves the cropped image to this directory.
Returns
-------
np.ndarray
The cropped image array.
"""
cx, cy = center
half_w = int(radius * width_factor)
x0 = max(cx - half_w, 0)
x1 = min(cx + half_w, img.shape[1])
y0 = max(cy - half_w, 0)
y1 = min(cy + half_w, img.shape[0])
cropped = img[int(y0) : int(y1), int(x0) : int(x1)]
if output_path is not None:
plt.imsave(
Path(output_path) / "cropped.png",
cropped.astype(np.uint16),
cmap="gray",
)
return cropped
[docs]
def save_16bit_tiff(data: np.ndarray, path: str) -> None:
"""
Scales and saves a NumPy array as a 16-bit grayscale TIFF.
Parameters
----------
data
Input image data.
path
Output file path.
Returns
-------
None
This function saves a file and does not return a value.
"""
data_min = data.min()
data_max = data.max()
data_range = data_max - data_min
if data_range == 0:
# Handle constant images - use zeros for zero value, otherwise use max
scaled_data = (
np.zeros(data.shape, dtype=np.uint16)
if data_min == 0
else np.full(data.shape, 65535, dtype=np.uint16)
)
else:
# Normalize and scale in one operation to avoid intermediate array
# Use clip to handle any floating point edge cases
scaled_data = np.clip(
np.round((data - data_min) * (65535.0 / data_range)), 0, 65535
).astype(np.uint16)
# Save using imageio with lossless compression
iio.imwrite(path, scaled_data, compression="deflate")
[docs]
def interpolate_nans_1d(y: np.ndarray) -> np.ndarray:
"""
Linearly interpolate NaNs in a 1D array.
Parameters
----------
y
1D input array possibly containing NaNs.
Returns
-------
np.ndarray
Array with NaNs filled by linear interpolation.
"""
nans = np.isnan(y)
not_nans = ~nans
if np.all(nans):
return np.zeros_like(y)
return np.interp(np.arange(len(y)), np.flatnonzero(not_nans), y[not_nans])
[docs]
def suggest_os_angle(p: float, n: int, r: float) -> float:
"""
Suggest the optimal oversampling angle to ensure negligible cross-talk.
Parameters
----------
p
Pixel size.
n
Oversampling factor (or similar parameter depending on strategy).
r
Radius.
Returns
-------
float
Suggested oversampling angle in degrees.
"""
dtheta = 2 * np.arccos(1 - p / (n * r))
dtheta = np.degrees(dtheta)
return dtheta
[docs]
def save_and_plot(
name: str,
arr: np.ndarray,
out_dir: str,
plot_func: Optional[Callable] = None,
suffix: str = "",
show_plots: bool = False,
) -> str:
"""
Save a 2D array as a 16-bit TIFF and optionally plot it using a provided plotting function.
Parameters
----------
name
Base name for the file.
arr
Image array to save.
out_dir
Output directory.
plot_func
Optional function to generate a plot.
suffix
Suffix to append to the filename.
show_plots
If True, show the plot interactively.
Returns
-------
str
Path to the saved TIFF file.
"""
fname = f"{name}{suffix}.tiff" if not name.endswith(".tiff") else name
path = Path(out_dir) / fname
save_16bit_tiff(arr, str(path))
if plot_func:
plot_func(arr, out_dir, show_plots)
return str(path)
[docs]
def background_percentile(profile: np.ndarray, low_frac: float = 0.15) -> float:
"""
Estimate background intensity from the lower percentile of a profile.
Parameters
----------
profile
1D array representing a profile.
low_frac
Fraction (0-1) to use for percentile calculation. Default is 0.5 (50th percentile).
Returns
-------
float
Mean intensity of values below the percentile threshold.
"""
profile = np.asarray(profile)
threshold = np.percentile(profile, low_frac * 100)
return float(np.mean(profile[profile <= threshold]))