242 lines
9.0 KiB
Python
Executable File
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() |