485 lines
25 KiB
Plaintext
Executable File
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) |