huge changes
This commit is contained in:
242
filmscanv3.py
Executable file
242
filmscanv3.py
Executable file
@ -0,0 +1,242 @@
|
||||
#!/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()
|
Reference in New Issue
Block a user