from __future__ import annotations from dataclasses import dataclass import numpy as np @dataclass(frozen=True) class IntensityRange: minimum: float maximum: float @property def span(self) -> float: return max(self.maximum - self.minimum, 1e-6) def to_float32(image: np.ndarray) -> np.ndarray: return np.asarray(image, dtype=np.float32) def normalize_with_range(image: np.ndarray) -> tuple[np.ndarray, IntensityRange]: image = to_float32(image) finite_mask = np.isfinite(image) if not np.any(finite_mask): raise ValueError("Input image does not contain finite pixel values.") finite_values = image[finite_mask] value_range = IntensityRange( minimum=float(finite_values.min()), maximum=float(finite_values.max()), ) normalized = np.zeros_like(image, dtype=np.float32) normalized[finite_mask] = (image[finite_mask] - value_range.minimum) / value_range.span normalized = np.clip(normalized, 0.0, 1.0) return normalized, value_range def denormalize_image(normalized: np.ndarray, value_range: IntensityRange) -> np.ndarray: normalized = np.clip(to_float32(normalized), 0.0, 1.0) return normalized * value_range.span + value_range.minimum def quantize_normalized(normalized: np.ndarray, bit_depth: int) -> tuple[np.ndarray, np.ndarray]: if bit_depth < 1: raise ValueError("Bit depth must be at least 1.") normalized = np.clip(to_float32(normalized), 0.0, 1.0) levels = 2**bit_depth level_indices = np.rint(normalized * (levels - 1)).astype(np.int32) quantized = level_indices.astype(np.float32) / float(levels - 1) return quantized, level_indices def apply_window(image: np.ndarray, level: float, width: float) -> tuple[np.ndarray, float, float]: image = to_float32(image) width = max(float(width), 1e-6) low = float(level - width / 2.0) high = float(level + width / 2.0) windowed = np.clip((image - low) / (high - low), 0.0, 1.0) return windowed.astype(np.float32), low, high def bounds_to_level_width(low: float, high: float) -> tuple[float, float]: width = max(float(high) - float(low), 1e-6) level = float(low) + width / 2.0 return level, width def compute_default_window(image: np.ndarray) -> tuple[float, float]: image = to_float32(image) finite_values = image[np.isfinite(image)] if finite_values.size == 0: raise ValueError("Input image does not contain finite pixel values.") p5 = float(np.percentile(finite_values, 5)) p95 = float(np.percentile(finite_values, 95)) width = max(p95 - p5, 1e-3) level = (p5 + p95) / 2.0 return level, width def resize_for_display(image: np.ndarray, max_edge: int = 768) -> np.ndarray: image = np.asarray(image) if image.ndim != 2: raise ValueError("Expected a 2D grayscale image.") height, width = image.shape current_max = max(height, width) if current_max <= max_edge: return image scale = max_edge / float(current_max) new_height = max(1, int(round(height * scale))) new_width = max(1, int(round(width * scale))) y_idx = np.linspace(0, height - 1, new_height).astype(np.int32) x_idx = np.linspace(0, width - 1, new_width).astype(np.int32) return image[np.ix_(y_idx, x_idx)]