Files
filmsim/filmscan
2025-06-19 15:31:45 -04:00

485 lines
25 KiB
Plaintext
Executable File

#!/usr/bin/env -S uv run --script
# /// script
# dependencies = [
# "numpy",
# "scipy",
# "Pillow",
# "imageio",
# "colour-science",
# ]
# ///
import numpy as np
import imageio.v3 as iio
import colour
from scipy.ndimage import uniform_filter, maximum_filter, minimum_filter
import argparse
import os
from pathlib import Path
# --- Constants from negadoctor.c ---
THRESHOLD = 2.3283064365386963e-10
# LOG2_TO_LOG10 = 0.3010299956 # This is log10(2)
LOG10_2 = np.log10(2.0) # More precise
# --- Default parameters (from dt_iop_negadoctor_params_t defaults) ---
# These are for parameters NOT being auto-detected
DEFAULT_WB_HIGH = np.array([1.0, 1.0, 1.0], dtype=np.float32)
DEFAULT_WB_LOW = np.array([1.0, 1.0, 1.0], dtype=np.float32)
DEFAULT_GAMMA = 4.0
DEFAULT_SOFT_CLIP = 0.75
# Film stock is implicitly color by using 3-channel Dmin etc.
# --- Utility Functions ---
def find_patch_average(image: np.ndarray, center_y: int, center_x: int, patch_size: int) -> np.ndarray:
"""Averages pixel values in a square patch around a center point."""
half_patch = patch_size // 2
y_start = np.clip(center_y - half_patch, 0, image.shape[0] - 1)
y_end = np.clip(center_y + half_patch + 1, 0, image.shape[0]) # +1 for slice
x_start = np.clip(center_x - half_patch, 0, image.shape[1] - 1)
x_end = np.clip(center_x + half_patch + 1, 0, image.shape[1])
if y_start >= y_end or x_start >= x_end: # Should not happen with proper clipping
return image[center_y, center_x]
patch = image[y_start:y_end, x_start:x_end]
return np.mean(patch, axis=(0, 1))
def get_representative_patch_value(image: np.ndarray, mode: str = 'brightest',
patch_size_ratio: float = 1/64, min_patch_size: int = 8,
max_patch_size: int = 64) -> np.ndarray:
"""
Finds the brightest or darkest small patch in an image.
'Brightest' on a negative is the film base.
'Darkest' on a negative is a highlight in the scene.
The mode refers to the luma/intensity.
"""
if image.ndim != 3 or image.shape[2] != 3:
raise ValueError("Image must be an RGB image (H, W, 3)")
patch_size = int(min(image.shape[0], image.shape[1]) * patch_size_ratio)
patch_size = np.clip(patch_size, min_patch_size, max_patch_size)
patch_size = max(1, patch_size // 2 * 2 + 1) # Ensure odd for easier centering if needed
# Use a uniform filter to get local averages, speeds up finding good candidates
# Consider image intensity for finding min/max spot
if image.shape[0] < patch_size or image.shape[1] < patch_size:
# Image too small for filtering, find single pixel and sample around it
intensity_map = np.mean(image, axis=2)
if mode == 'brightest':
center_y, center_x = np.unravel_index(np.argmax(intensity_map), intensity_map.shape)
else: # darkest
center_y, center_x = np.unravel_index(np.argmin(intensity_map), intensity_map.shape)
return find_patch_average(image, center_y, center_x, patch_size)
# For larger images, we can use filters
# Calculate a proxy for patch brightness/darkness (e.g. sum of RGB in patch)
# A simple way is to find min/max of filtered image
# For more robustness, one might sample multiple candidate patches
# For simplicity here, find global min/max pixel and sample patch around it.
intensity_map = np.mean(image, axis=2) # Luminance proxy
if mode == 'brightest':
# Find the brightest pixel
flat_idx = np.argmax(intensity_map)
else: # darkest
# Find the darkest pixel
flat_idx = np.argmin(intensity_map)
center_y, center_x = np.unravel_index(flat_idx, intensity_map.shape)
# Refine patch location: average a small area around the found extreme pixel
# to reduce noise sensitivity.
# The patch size for averaging:
avg_patch_size = max(3, patch_size // 4) # A smaller patch for local averaging
return find_patch_average(image, center_y, center_x, avg_patch_size)
# --- Automated Parameter Calculation Functions ---
# These functions will use the original input image (img_aces_negative) for sampling,
# as implied by the C GUI code `self->picked_color...`
def auto_calculate_dmin(img_aces_negative: np.ndarray, **kwargs) -> np.ndarray:
"""Dmin is the color of the film base (brightest part of the negative)."""
# In the C code, Dmin values are typically > 0, often around [1.0, 0.45, 0.25] for color.
# These are divisors, so they should not be zero.
# The input image is 0-1. A very bright film base might be e.g. 0.8, 0.7, 0.6
# The C code Dmin seems to be in a different scale or used differently.
# Let's assume picked_color is directly used as Dmin.
# "Dmin[0] = 1.00f; Dmin[1] = 0.45f; Dmin[2] = 0.25f;" default
# However, `apply_auto_Dmin` sets `p->Dmin[k] = self->picked_color[k]`.
# If `picked_color` is from a 0-1 range image, Dmin will also be 0-1.
# This implies the input image is used directly.
dmin = get_representative_patch_value(img_aces_negative, mode='brightest', **kwargs)
return np.maximum(dmin, THRESHOLD) # Ensure Dmin is not too small
def auto_calculate_dmax(img_aces_negative: np.ndarray, dmin_param: np.ndarray, **kwargs) -> float:
"""
D_max = v_maxf(log10f(p->Dmin[c] / fmaxf(self->picked_color_min[c], THRESHOLD)))
picked_color_min is the darkest patch on the negative (scene highlight).
"""
darkest_negative_patch = get_representative_patch_value(img_aces_negative, mode='darkest', **kwargs)
darkest_negative_patch = np.maximum(darkest_negative_patch, THRESHOLD)
# Ensure dmin_param and darkest_negative_patch are broadcastable if dmin_param is scalar
dmin_param_rgb = np.array(dmin_param, dtype=np.float32)
if dmin_param_rgb.ndim == 0:
dmin_param_rgb = np.full(3, dmin_param_rgb, dtype=np.float32)
log_arg = dmin_param_rgb / darkest_negative_patch
rgb_dmax_contrib = np.log10(log_arg)
d_max = np.max(rgb_dmax_contrib)
return max(d_max, 0.1) # D_max must be positive
def auto_calculate_offset(img_aces_negative: np.ndarray, dmin_param: np.ndarray, d_max_param: float, **kwargs) -> float:
"""
p->offset = v_minf(log10f(p->Dmin[c] / fmaxf(self->picked_color_max[c], THRESHOLD)) / p->D_max)
picked_color_max is the brightest patch on the negative (scene shadow if Dmin is elsewhere, or Dmin itself).
For this context, it should be the part of the image that will become the deepest black after inversion,
which is *not* Dmin. So it's the brightest part of the *exposed* negative area.
This is tricky. If Dmin is picked from unexposed film edge, then picked_color_max is the brightest *image* area.
If Dmin is picked from brightest *image* area (no unexposed edge), then this becomes circular.
Let's assume Dmin was found correctly from the film base (unexposed or lightest part).
Then `picked_color_max` here refers to the brightest *actual image content* on the negative,
which will become the *darkest shadows* in the positive.
So we still use 'brightest' mode for `get_representative_patch_value` on the negative.
"""
brightest_negative_patch = get_representative_patch_value(img_aces_negative, mode='brightest', **kwargs)
brightest_negative_patch = np.maximum(brightest_negative_patch, THRESHOLD)
dmin_param_rgb = np.array(dmin_param, dtype=np.float32)
if dmin_param_rgb.ndim == 0:
dmin_param_rgb = np.full(3, dmin_param_rgb, dtype=np.float32)
log_arg = dmin_param_rgb / brightest_negative_patch
rgb_offset_contrib = np.log10(log_arg) / d_max_param
offset = np.min(rgb_offset_contrib)
return offset # Can be negative
def auto_calculate_paper_black(img_aces_negative: np.ndarray, dmin_param: np.ndarray, d_max_param: float,
offset_param: float, wb_high_param: np.ndarray, wb_low_param: np.ndarray,
**kwargs) -> float:
"""
p->black = v_maxf(RGB)
RGB[c] = -log10f(p->Dmin[c] / fmaxf(self->picked_color_max[c], THRESHOLD));
RGB[c] *= p->wb_high[c] / p->D_max;
RGB[c] += p->wb_low[c] * p->offset * p->wb_high[c]; # Error in my C-analysis: This wb_high is p->wb_high, not d->wb_high
# d->offset already has p->wb_high.
# Corrected: RGB[c] += p->wb_low[c] * p->offset * (p->wb_high[c] / p->D_max); -- NO
# C code: d->offset[c] = p->wb_high[c] * p->offset * p->wb_low[c];
# corrected_de[c] = (p->wb_high[c]/p->D_max) * log_density[c] + d->offset[c]
# This means the auto_black formula needs to use the same terms for consistency.
Let's re-evaluate the C `apply_auto_black`:
RGB_log_density_term[c] = -log10f(p->Dmin[c] / fmaxf(self->picked_color_max[c], THRESHOLD))
RGB_density_corrected_term[c] = (p->wb_high[c] / p->D_max) * RGB_log_density_term[c] + \
(p->wb_high[c] * p->offset * p->wb_low[c])
RGB[c] = 0.1f - (1.0f - fast_exp10f(RGB_density_corrected_term[c]));
p->black = v_maxf(RGB);
`picked_color_max` is the brightest patch on the negative.
"""
brightest_negative_patch = get_representative_patch_value(img_aces_negative, mode='brightest', **kwargs)
brightest_negative_patch = np.maximum(brightest_negative_patch, THRESHOLD)
dmin_param_rgb = np.array(dmin_param, dtype=np.float32)
if dmin_param_rgb.ndim == 0:
dmin_param_rgb = np.full(3, dmin_param_rgb, dtype=np.float32)
log_density_term = -np.log10(dmin_param_rgb / brightest_negative_patch)
# This is `corrected_de` for the brightest_negative_patch
density_corrected_term = (wb_high_param / d_max_param) * log_density_term + \
(wb_high_param * offset_param * wb_low_param) # This matches d->offset structure
val_for_black_calc = 0.1 - (1.0 - np.power(10.0, density_corrected_term))
paper_black = np.max(val_for_black_calc)
return paper_black
def auto_calculate_print_exposure(img_aces_negative: np.ndarray, dmin_param: np.ndarray, d_max_param: float,
offset_param: float, paper_black_param: float,
wb_high_param: np.ndarray, wb_low_param: np.ndarray,
**kwargs) -> float:
"""
p->exposure = v_minf(RGB)
RGB[c] = -log10f(p->Dmin[c] / fmaxf(self->picked_color_min[c], THRESHOLD));
RGB[c] *= p->wb_high[c] / p->D_max;
RGB[c] += p->wb_low[c] * p->offset * p->wb_high_param[c]; // Similar correction as paper_black
// This should be p->wb_low[c] * d->offset[c] using the effective offset
// which is p->wb_low[c] * (p->offset * p->wb_high[c])
Let's re-evaluate C `apply_auto_exposure`:
RGB_log_density_term[c] = -log10f(p->Dmin[c] / fmaxf(self->picked_color_min[c], THRESHOLD))
RGB_density_corrected_term[c] = (p->wb_high[c] / p->D_max) * RGB_log_density_term[c] + \
(p->wb_high[c] * p->offset * p->wb_low[c])
RGB[c] = 0.96f / ( (1.0f - fast_exp10f(RGB_density_corrected_term[c])) + p->black );
p->exposure = v_minf(RGB);
`picked_color_min` is the darkest patch on the negative.
"""
darkest_negative_patch = get_representative_patch_value(img_aces_negative, mode='darkest', **kwargs)
darkest_negative_patch = np.maximum(darkest_negative_patch, THRESHOLD)
dmin_param_rgb = np.array(dmin_param, dtype=np.float32)
if dmin_param_rgb.ndim == 0:
dmin_param_rgb = np.full(3, dmin_param_rgb, dtype=np.float32)
log_density_term = -np.log10(dmin_param_rgb / darkest_negative_patch)
density_corrected_term = (wb_high_param / d_max_param) * log_density_term + \
(wb_high_param * offset_param * wb_low_param)
denominator = (1.0 - np.power(10.0, density_corrected_term)) + paper_black_param
# Avoid division by zero or very small numbers if denominator is problematic
denominator = np.where(np.abs(denominator) < THRESHOLD, np.sign(denominator + THRESHOLD) * THRESHOLD, denominator)
val_for_exposure_calc = 0.96 / denominator
print_exposure = np.min(val_for_exposure_calc)
return max(print_exposure, 0.01) # Ensure exposure is positive
# --- Core Negadoctor Process ---
def negadoctor_process(img_aces_negative: np.ndarray,
dmin_param: np.ndarray,
wb_high_param: np.ndarray,
wb_low_param: np.ndarray,
d_max_param: float,
offset_param: float, # scan exposure bias
paper_black_param: float, # paper black (density correction)
gamma_param: float, # paper grade (gamma)
soft_clip_param: float, # paper gloss (specular highlights)
print_exposure_param: float # print exposure adjustment
) -> np.ndarray:
"""
Applies the negadoctor calculations based on `_process_pixel` and `commit_params`.
Input image and Dmin are expected to be in a compatible range (e.g., 0-1).
"""
# Ensure params are numpy arrays for broadcasting
dmin_param = np.array(dmin_param, dtype=np.float32).reshape(1, 1, 3)
wb_high_param = np.array(wb_high_param, dtype=np.float32).reshape(1, 1, 3)
wb_low_param = np.array(wb_low_param, dtype=np.float32).reshape(1, 1, 3)
# From commit_params:
# d->wb_high[c] = p->wb_high[c] / p->D_max;
effective_wb_high = wb_high_param / d_max_param
# d->offset[c] = p->wb_high[c] * p->offset * p->wb_low[c];
# Note: p->offset is scalar offset_param
effective_offset = wb_high_param * offset_param * wb_low_param
# d->black = -p->exposure * (1.0f + p->black);
# Note: p->exposure is scalar print_exposure_param, p->black is scalar paper_black_param
effective_paper_black = -print_exposure_param * (1.0 + paper_black_param)
# d->soft_clip_comp = 1.0f - p->soft_clip;
soft_clip_comp = 1.0 - soft_clip_param
# --- _process_pixel logic ---
# 1. Convert transmission to density using Dmin as a fulcrum
# density[c] = Dmin[c] / clamped[c];
# log_density[c] = log2(density[c]) * -LOG2_to_LOG10 = -log10(density[c])
clamped_input = np.maximum(img_aces_negative, THRESHOLD)
density = dmin_param / clamped_input
log_density = -np.log10(density) # This is log10(clamped_input / dmin_param)
# 2. Correct density in log space
# corrected_de[c] = effective_wb_high[c] * log_density[c] + effective_offset[c];
corrected_density = effective_wb_high * log_density + effective_offset
# 3. Print density on paper
# print_linear[c] = -(exposure[c] * ten_to_x[c] + black[c]);
# exposure[c] is print_exposure_param, black[c] is effective_paper_black
# ten_to_x is 10^corrected_density
ten_to_corrected_density = np.power(10.0, corrected_density)
print_linear = -(print_exposure_param * ten_to_corrected_density + effective_paper_black)
print_linear = np.maximum(print_linear, 0.0)
# 4. Apply paper grade (gamma)
# print_gamma = print_linear ^ gamma_param
print_gamma = np.power(print_linear, gamma_param)
# 5. Compress highlights (soft clip)
# pix_out[c] = (print_gamma[c] > soft_clip[c])
# ? soft_clip[c] + (1.0f - e_to_gamma[c]) * soft_clip_comp[c]
# : print_gamma[c];
# where e_to_gamma[c] = exp(-(print_gamma[c] - soft_clip[c]) / soft_clip_comp[c])
# Avoid issues with soft_clip_comp being zero if soft_clip_param is 1.0
if np.isclose(soft_clip_comp, 0.0):
output_pixels = np.where(print_gamma > soft_clip_param, soft_clip_param, print_gamma)
else:
exponent = -(print_gamma - soft_clip_param) / soft_clip_comp
# Clip exponent to avoid overflow in np.exp for very large negative print_gamma values
exponent = np.clip(exponent, -700, 700) # exp(-709) is ~0, exp(709) is ~inf
e_to_gamma = np.exp(exponent)
compressed_highlights = soft_clip_param + (1.0 - e_to_gamma) * soft_clip_comp
output_pixels = np.where(print_gamma > soft_clip_param, compressed_highlights, print_gamma)
return np.clip(output_pixels, 0.0, None) # Final clip to 0-Inf range
# --- Main Execution ---
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Python implementation of Darktable's Negadoctor module.")
parser.add_argument("input_file", help="Path to the input negative image (TIFF).")
parser.add_argument("output_file", help="Path to save the processed positive image.")
parser.add_argument("--patch_size_ratio", type=float, default=1/128, help="Ratio of image min dim for patch size in auto-detection.") # smaller default
parser.add_argument("--min_patch_size", type=int, default=8, help="Minimum patch size in pixels.")
parser.add_argument("--max_patch_size", type=int, default=32, help="Maximum patch size in pixels.") # smaller default
parser.add_argument("--aces_transform", default="ACEScg", help="ACES working space (e.g., ACEScg, ACEScc, ACEScct).")
parser.add_argument("--output_colorspace", default="sRGB", help="Colorspace for output image (e.g., sRGB, Display P3).")
args = parser.parse_args()
print(f"Loading image: {args.input_file}")
try:
img_raw = iio.imread(args.input_file)
except Exception as e:
print(f"Error loading image: {e}")
exit(1)
print(f"Original image dtype: {img_raw.dtype}, shape: {img_raw.shape}, min: {img_raw.min()}, max: {img_raw.max()}")
if img_raw.dtype == np.uint16:
img_float = img_raw.astype(np.float32) / 65535.0
elif img_raw.dtype == np.uint8:
img_float = img_raw.astype(np.float32) / 255.0
elif img_raw.dtype == np.float32 or img_raw.dtype == np.float64:
if img_raw.max() > 1.01 : # Check if it's not already 0-1
print("Warning: Input float image max > 1.0. Assuming it's not normalized. Clamping and scaling may occur.")
img_float = np.clip(img_raw, 0, None).astype(np.float32) # Needs proper scaling if not 0-1
if img_float.max() > 1.0: # if it's still high, e.g. integer range float
if np.percentile(img_float, 99.9) < 256: # likely 8-bit range
img_float /= 255.0
elif np.percentile(img_float, 99.9) < 65536: # likely 16-bit range
img_float /= 65535.0
else: # unknown large float range
print("Warning: Unknown float range. Trying to normalize by max value.")
img_float /= img_float.max()
else:
img_float = img_raw.astype(np.float32)
else:
raise ValueError(f"Unsupported image dtype: {img_raw.dtype}")
img_float = np.clip(img_float, 0.0, 1.0)
if img_float.ndim == 2: # Grayscale
img_float = np.stack([img_float]*3, axis=-1) # make 3-channel
# Assuming input TIFF is sRGB encoded (common for scans unless specified)
# Convert to linear sRGB first, then to ACEScg
print("Converting to ACEScg...")
# img_linear_srgb = colour.gamma_correct(img_float, 1/2.2, 'ITU-R BT.709') # Approximate sRGB EOTF decoding
img_linear_srgb = colour.models.eotf_sRGB(img_float) # More accurate sRGB EOTF decoding
# Calculate the full transformation matrix from linear sRGB to ACEScg.
# This includes chromatic adaptation from sRGB's D65 whitepoint
# to ACEScg's D60 whitepoint, which is crucial for accuracy.
sRGB_cs = colour.models.RGB_COLOURSPACE_sRGB
ACEScg_cs = colour.models.RGB_COLOURSPACE_ACESCG
# colour.matrix_RGB_to_RGB computes the combined matrix: M_XYZ_to_ACEScg @ M_CAT @ M_sRGB_to_XYZ
# This matrix is cached by colour-science after the first call for efficiency.
srgb_to_acescg_matrix = colour.matrix_RGB_to_RGB(sRGB_cs, ACEScg_cs) # Shape: (3, 3)
# Apply the transformation using NumPy's matrix multiplication operator @.
# img_linear_srgb has shape (H, W, 3).
# srgb_to_acescg_matrix.T also has shape (3, 3).
# The @ operator performs (H, W, 3) @ (3, 3) -> (H, W, 3),
# effectively applying the 3x3 matrix to each 3-element RGB vector.
# This is generally highly optimized and avoids explicit reshape calls.
img_acescg = img_linear_srgb @ srgb_to_acescg_matrix.T
# ACEScg space can legitimately have values outside [0,1] for bright, saturated colors.
img_acescg = np.clip(img_acescg, 0.0, None)
print(f"Image in ACEScg: shape: {img_acescg.shape}, min: {img_acescg.min():.4f}, max: {img_acescg.max():.4f}, mean: {img_acescg.mean():.4f}")
# Automated parameter detection
patch_kwargs = {
"patch_size_ratio": args.patch_size_ratio,
"min_patch_size": args.min_patch_size,
"max_patch_size": args.max_patch_size
}
print("Auto-detecting parameters...")
param_dmin = auto_calculate_dmin(img_acescg, **patch_kwargs)
print(f" Dmin: {param_dmin}")
param_d_max = auto_calculate_dmax(img_acescg, param_dmin, **patch_kwargs)
print(f" D_max: {param_d_max:.4f}")
param_offset = auto_calculate_offset(img_acescg, param_dmin, param_d_max, **patch_kwargs)
print(f" Offset (Scan Bias): {param_offset:.4f}")
param_paper_black = auto_calculate_paper_black(img_acescg, param_dmin, param_d_max, param_offset,
DEFAULT_WB_HIGH, DEFAULT_WB_LOW, **patch_kwargs)
print(f" Paper Black: {param_paper_black:.4f}")
param_print_exposure = auto_calculate_print_exposure(img_acescg, param_dmin, param_d_max, param_offset,
param_paper_black, DEFAULT_WB_HIGH, DEFAULT_WB_LOW,
**patch_kwargs)
print(f" Print Exposure: {param_print_exposure:.4f}")
# Perform Negadoctor processing
print("Applying Negadoctor process...")
img_processed_acescg = negadoctor_process(
img_acescg,
dmin_param=param_dmin,
wb_high_param=DEFAULT_WB_HIGH,
wb_low_param=DEFAULT_WB_LOW,
d_max_param=param_d_max,
offset_param=param_offset,
paper_black_param=param_paper_black,
gamma_param=DEFAULT_GAMMA,
soft_clip_param=DEFAULT_SOFT_CLIP,
print_exposure_param=param_print_exposure
)
print(f"Processed (ACEScg): min: {img_processed_acescg.min():.4f}, max: {img_processed_acescg.max():.4f}, mean: {img_processed_acescg.mean():.4f}")
# Convert back to output colorspace (e.g., sRGB)
print(f"Converting from ACEScg to {args.output_colorspace}...")
if args.output_colorspace.upper() == 'SRGB':
output_cs = colour.models.RGB_COLOURSPACE_sRGB
img_out_linear = colour.RGB_to_RGB(img_processed_acescg,
colour.models.RGB_COLOURSPACE_ACESCG,
output_cs)
img_out_linear = np.clip(img_out_linear, 0.0, 1.0) # Clip before gamma correction
# img_out_gamma = colour.models.oetf_sRGB(img_out_linear) # Accurate sRGB OETF
img_out_gamma = colour.models.eotf_inverse_sRGB(img_out_linear) # Accurate sRGB OETF
elif args.output_colorspace.upper() == 'DISPLAY P3':
output_cs = colour.models.RGB_COLOURSPACE_DISPLAY_P3
img_out_linear = colour.RGB_to_RGB(img_processed_acescg,
colour.models.RGB_COLOURSPACE_ACESCG,
output_cs)
img_out_linear = np.clip(img_out_linear, 0.0, 1.0)
img_out_gamma = colour.models.eotf_inverse_sRGB(img_out_linear) # Display P3 uses sRGB transfer function
else:
print(f"Warning: Unsupported output colorspace {args.output_colorspace}. Defaulting to sRGB.")
output_cs = colour.models.RGB_COLOURSPACE_sRGB
img_out_linear = colour.RGB_to_RGB(img_processed_acescg,
colour.models.RGB_COLOURSPACE_ACESCG,
output_cs)
img_out_linear = np.clip(img_out_linear, 0.0, 1.0)
img_out_gamma = colour.models.eotf_inverse_sRGB(img_out_linear)
img_out_final = np.clip(img_out_gamma, 0.0, 1.0)
print(f"Final output image: min: {img_out_final.min():.4f}, max: {img_out_final.max():.4f}, mean: {img_out_final.mean():.4f}")
# Save the image
output_path = Path(args.output_file)
if output_path.suffix.lower() in ['.tif', '.tiff']:
img_to_save = (img_out_final * 65535.0).astype(np.uint16)
print(f"Saving 16-bit TIFF: {args.output_file}")
else:
img_to_save = (img_out_final * 255.0).astype(np.uint8)
print(f"Saving 8-bit image (e.g. PNG/JPG): {args.output_file}")
try:
iio.imwrite(args.output_file, img_to_save)
print("Processing complete.")
except Exception as e:
print(f"Error saving image: {e}")
exit(1)