#!/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, 1.0) # Final clip to 0-1 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 img_acescg = colour.RGB_to_RGB(img_linear_srgb, colour.models.RGB_COLOURSPACE_sRGB, colour.models.RGB_COLOURSPACE_ACESCG) img_acescg = np.clip(img_acescg, 0.0, None) # ACEScg can have values > 1.0 for very bright sources 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)