Files
filmsim/filmscanv3.py
2025-06-19 15:31:45 -04:00

242 lines
9.0 KiB
Python
Executable File

#!/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()