| 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)] |
|
|