#!/usr/bin/env -S uv run --script # /// script # dependencies = [ # "numpy", # "scipy", # "Pillow", # "imageio", # "colour-science", # "tifffile", # ] # /// import argparse import time import sys import imageio.v2 as imageio import numpy as np import colour # --- Helper Functions --- def find_film_base_from_border(linear_image: np.ndarray) -> np.ndarray: """ Finds the film base color by sampling the outer 2% border of the image. This function implements the --border logic. It randomly samples 64 patches of 8x8 pixels from the defined border area, calculates the mean color of each patch, and returns the median of these mean colors. Median is used as it's more robust to outliers (like dust or scratches) than the mean or mode. Args: linear_image: The image data in a linear RGB color space (float32). Returns: A 1D NumPy array representing the film base color [R, G, B]. """ print("Finding film base color from image border...") h, w, _ = linear_image.shape border_px_h = int(h * 0.02) border_px_w = int(w * 0.02) # Define four rectangular regions for the border regions = [ (0, 0, h, border_px_w), # Left (0, w - border_px_w, h, w), # Right (0, 0, border_px_h, w), # Top (h - border_px_h, 0, h, w), # Bottom ] patch_size = 8 num_patches = 64 patch_means = [] for _ in range(num_patches): # Pick a random region and sample a patch from it top, left, bottom, right = regions[np.random.randint(4)] # Ensure we don't sample outside the bounds if (bottom - top <= patch_size) or (right - left <= patch_size): continue rand_y = np.random.randint(top, bottom - patch_size) rand_x = np.random.randint(left, right - patch_size) patch = linear_image[rand_y:rand_y + patch_size, rand_x:rand_x + patch_size] patch_means.append(np.mean(patch, axis=(0, 1))) if not patch_means: raise ValueError("Could not sample any patches from the border. Check image dimensions and border size.") # Median is more robust to outliers (dust, scratches) than mean film_base_color = np.median(patch_means, axis=0) return film_base_color def find_film_base_from_darkest(linear_image: np.ndarray) -> np.ndarray: """ Finds the film base color by identifying the darkest pixels in the negative. This function implements the default logic. It calculates the luminance of the image, identifies the 0.1% darkest pixels, and returns their average color. Args: linear_image: The image data in a linear RGB color space (float32). Returns: A 1D NumPy array representing the film base color [R, G, B]. """ print("Finding film base color from darkest part of the image...") # Using Rec.709 coefficients for luminance calculation is a standard approach luminance = colour.RGB_luminance(linear_image, primaries='ITU-R BT.709', whitepoint='D65') # Find the threshold for the darkest 0.1% of pixels darkest_threshold = np.percentile(luminance, 0.1) darkest_pixels_mask = luminance <= darkest_threshold # If no pixels are found (unlikely), relax the threshold if not np.any(darkest_pixels_mask): darkest_pixels_mask = luminance <= np.percentile(luminance, 1) darkest_pixels = linear_image[darkest_pixels_mask] film_base_color = np.mean(darkest_pixels, axis=0) return film_base_color # --- Main Processing Function --- def main(): parser = argparse.ArgumentParser( description="A high-performance tool for inverting color negative film scans.", formatter_class=argparse.RawTextHelpFormatter ) parser.add_argument("input_file", help="Path to the 16-bit input TIFF file (sRGB).") parser.add_argument("output_file", help="Path to save the 16-bit output TIFF file.") method_group = parser.add_mutually_exclusive_group() method_group.add_argument( "--border", action="store_true", help="Find film base color by sampling the outer 2%% of the image border." ) method_group.add_argument( "--color", type=str, help="Specify film base color as a CIEXYZ value (e.g., '0.41,0.35,0.18').\nAssumes D65 standard illuminant." ) parser.add_argument( "--wide", action="store_true", help="Output in a wide gamut color space (Rec.2020) instead of sRGB." ) if len(sys.argv) == 1: parser.print_help(sys.stderr) sys.exit(1) args = parser.parse_args() start_time = time.time() # 1. Read Image and Normalize print(f"Reading image: {args.input_file}") try: # ImageIO reads TIFFs as (height, width, channels) img_int16 = imageio.imread(args.input_file) except FileNotFoundError: print(f"Error: Input file not found at {args.input_file}") sys.exit(1) if img_int16.dtype != np.uint16: print("Warning: Input image is not 16-bit. Results may have reduced quality.") # Convert to float (0.0 - 1.0) for processing img_float = img_int16.astype(np.float32) / 65535.0 # 2. Color Space Conversion (to Linear) # The input is assumed to be in standard (non-linear) sRGB space. # All our math must happen in a linear space. print("Converting from sRGB to linear sRGB...") srgb_colourspace = colour.models.RGB_COLOURSPACE_sRGB linear_image = colour.cctf_decoding(img_float, function='sRGB') # 3. Determine Film Base Color film_base_color = None if args.color: print(f"Using provided CIEXYZ color: {args.color}") try: xyz_values = np.array([float(x.strip()) for x in args.color.split(',')]) print(f"Parsed XYZ values: {xyz_values}") if xyz_values.shape != (3,): print("Error: --color must be in the format 'X,Y,Z' with three values.") raise ValueError # Convert the provided XYZ color to our working linear sRGB space film_base_color = colour.XYZ_to_RGB(xyz_values, 'sRGB') except (ValueError, IndexError) as e: print("Error: Invalid --color format. Please use 'X,Y,Z', e.g., '0.41,0.35,0.18'") print(e) sys.exit(1) elif args.border: film_base_color = find_film_base_from_border(linear_image) else: # Default method if neither --border nor --color is specified film_base_color = find_film_base_from_darkest(linear_image) print(f"Determined Film Base Color (Linear RGB): {np.round(film_base_color, 4)}") # Ensure base color is not black to avoid division by zero if np.any(film_base_color <= 1e-8): print("Error: Determined film base color is too dark or black, cannot proceed.") sys.exit(1) # 4. Core Inversion Process print("Performing inversion...") # Step A: Remove the film mask by dividing by the base color. # This is equivalent to Photoshop's 'Divide' blend mode. # It "white balances" the image against the orange mask. masked_removed = linear_image / film_base_color # Step B: Invert the image. Based on the principle that exposure = 1 / transmittance. # Add a small epsilon to prevent division by zero in pure black areas. epsilon = 1e-8 inverted_image = 1.0 / (masked_removed + epsilon) # 5. Normalize for Output # The inverted values are unbounded. We must normalize them into the 0-1 range. # This is a technical step, not an artistic one like levels. It preserves tonal relationships. max_val = np.percentile(inverted_image, 99.95) # Avoid blowing out specular highlights if max_val <= epsilon: print("Warning: Inverted image is black. Result will be black.") normalized_linear_positive = np.zeros_like(inverted_image) else: normalized_linear_positive = inverted_image / max_val # Clip any remaining >1 values normalized_linear_positive = np.clip(normalized_linear_positive, 0.0, 1.0) # 6. Color Space Conversion (to Output Space) output_space_name = "Rec.2020" if args.wide else "sRGB" print(f"Converting linear positive to target space ({output_space_name})...") if args.wide: # Convert from linear sRGB primaries to linear Rec.2020 primaries wide_linear = colour.RGB_to_RGB( normalized_linear_positive, srgb_colourspace, colour.models.RGB_COLOURSPACE_BT2020 ) # Apply the Rec.2020 gamma curve final_image_float = colour.cctf_encoding(wide_linear, function='ITU-R BT.2020') else: # Just apply the sRGB gamma curve final_image_float = colour.cctf_encoding(normalized_linear_positive, function='sRGB') # 7. De-normalize and Save print(f"Saving to {args.output_file}") # Convert from float (0-1) back to 16-bit integer (0-65535) output_image = (np.clip(final_image_float, 0.0, 1.0) * 65535).astype(np.uint16) imageio.imwrite(args.output_file, output_image) end_time = time.time() print(f"\nProcessing complete in {end_time - start_time:.2f} seconds.") if __name__ == "__main__": main()