#!/usr/bin/env -S uv run --script # /// script # dependencies = [ # "numpy", # "scipy", # "Pillow", # "imageio", # "rawpy", # ] # /// # -*- coding: utf-8 -*- """ Single-file Python script for Film Stock Color Emulation based on Datasheet CSV. Focuses on color transformation, applying effects derived from datasheet parameters. Assumes input image is in linear RGB format. Excludes film grain simulation. Dependencies: numpy, imageio, scipy Installation: pip install numpy imageio scipy Pillow """ import argparse import csv import numpy as np import imageio.v3 as iio from scipy.interpolate import interp1d from scipy.ndimage import gaussian_filter import rawpy import os import sys import json # --- Configuration --- # Small epsilon to prevent log(0) or division by zero errors EPSILON = 1e-10 from typing import Optional, List class Info: name: str description: str format_mm: int version: str def __init__( self, name: str, description: str, format_mm: int, version: str ) -> None: self.name = name self.description = description self.format_mm = format_mm self.version = version def __repr__(self) -> str: return ( f"Info(name={self.name}, description={self.description}, " f"format_mm={self.format_mm}, version={self.version})" ) class Balance: r_shift: float g_shift: float b_shift: float def __init__(self, r_shift: float, g_shift: float, b_shift: float) -> None: self.r_shift = r_shift self.g_shift = g_shift self.b_shift = b_shift def __repr__(self) -> str: return ( f"Balance(r_shift={self.r_shift:.3f}, g_shift={self.g_shift:.3f}, b_shift={self.b_shift:.3f})" ) class Gamma: r_factor: float g_factor: float b_factor: float def __init__(self, r_factor: float, g_factor: float, b_factor: float) -> None: self.r_factor = r_factor self.g_factor = g_factor self.b_factor = b_factor def __repr__(self) -> str: return ( f"Gamma(r_factor={self.r_factor:.3f}, g_factor={self.g_factor:.3f}, b_factor={self.b_factor:.3f})" ) class Processing: gamma: Gamma balance: Balance def __init__(self, gamma: Gamma, balance: Balance) -> None: self.gamma = gamma self.balance = balance def __repr__(self) -> str: return ( f"Processing(gamma=({self.gamma.r_factor:.3f}, {self.gamma.g_factor:.3f}, {self.gamma.b_factor:.3f}), " f"balance=({self.balance.r_shift:.3f}, {self.balance.g_shift:.3f}, {self.balance.b_shift:.3f}))" ) class Couplers: amount: float diffusion_um: float def __init__(self, amount: float, diffusion_um: float) -> None: self.amount = amount self.diffusion_um = diffusion_um def __repr__(self) -> str: return f"Couplers(amount={self.amount:.3f}, diffusion_um={self.diffusion_um:.1f})" class HDCurvePoint: d: Optional[float] r: float g: float b: float def __init__(self, d: Optional[float], r: float, g: float, b: float) -> None: self.d = d self.r = r self.g = g self.b = b def __repr__(self) -> str: return f"HDCurvePoint(d={self.d}, r={self.r:.3f}, g={self.g:.3f}, b={self.b:.3f})" class SpectralSensitivityCurvePoint: wavelength: float y: float m: float c: float def __init__(self, wavelength: float, y: float, m: float, c: float) -> None: self.wavelength = wavelength self.y = y self.m = m self.c = c def __repr__(self) -> str: return f"SpectralSensitivityCurvePoint(wavelength={self.wavelength:.1f}, y={self.y:.3f}, m={self.m:.3f}, c={self.c:.3f})" class RGBValue: r: float g: float b: float def __init__(self, r: float, g: float, b: float) -> None: self.r = r self.g = g self.b = b def __repr__(self) -> str: return f"RGBValue(r={self.r:.3f}, g={self.g:.3f}, b={self.b:.3f})" class Curves: hd: List[HDCurvePoint] spectral_sensitivity: List[SpectralSensitivityCurvePoint] def __init__(self, hd: List[HDCurvePoint], spectral_sensitivity: List[SpectralSensitivityCurvePoint]) -> None: self.hd = hd self.spectral_sensitivity = spectral_sensitivity def __repr__(self) -> str: return f"Curves(hd={',\n'.join(repr(point) for point in self.hd)}, spectral_sensitivity={',\n'.join(repr(point) for point in self.spectral_sensitivity)})" class Halation: strength: RGBValue size_um: RGBValue def __init__(self, strength: RGBValue, size_um: RGBValue) -> None: self.strength = strength self.size_um = size_um def __repr__(self) -> str: return f"Halation(strength={self.strength}, size_um={self.size_um})" class Interlayer: diffusion_um: float def __init__(self, diffusion_um: float) -> None: self.diffusion_um = diffusion_um def __repr__(self) -> str: return f"Interlayer(diffusion_um={self.diffusion_um:.1f})" class Calibration: iso: int middle_gray_logE: float def __init__(self, iso: int, middle_gray_logE: float) -> None: self.iso = iso self.middle_gray_logE = middle_gray_logE def __repr__(self) -> str: return ( f"Calibration(iso={self.iso}\nmiddle_gray_logE={self.middle_gray_logE:.3f})" ) class Properties: halation: Halation couplers: Couplers interlayer: Interlayer curves: Curves calibration: Calibration def __init__( self, halation: Halation, couplers: Couplers, interlayer: Interlayer, curves: Curves, calibration: Calibration, ) -> None: self.halation = halation self.couplers = couplers self.interlayer = interlayer self.curves = curves self.calibration = calibration def __repr__(self) -> str: return ( f"Properties(halation={self.halation}\ncouplers={self.couplers}\n" f"interlayer={self.interlayer}\ncurves={self.curves}\n" f"calibration={self.calibration})" ) class FilmDatasheet: info: Info processing: Processing properties: Properties def __init__( self, info: Info, processing: Processing, properties: Properties ) -> None: self.info = info self.processing = processing self.properties = properties def __repr__(self) -> str: return ( f"FilmDatasheet(info={self.info}\nprocessing={self.processing}\n" f"properties={self.properties})" ) import pprint def parse_datasheet_json(json_filepath) -> FilmDatasheet | None: # Parse JSON into FilmDatasheet object """Parses the film datasheet JSON file. Args: json_filepath (str): Path to the datasheet JSON file. Returns: FilmDatasheet: Parsed datasheet object. """ if not os.path.exists(json_filepath): print(f"Error: Datasheet file not found at {json_filepath}", file=sys.stderr) return None try: with open(json_filepath, "r") as jsonfile: data = json.load(jsonfile) # Parse the JSON data into the FilmDatasheet structure info = Info( name=data["info"]["name"], description=data["info"]["description"], format_mm=data["info"]["format_mm"], version=data["info"]["version"], ) gamma = Gamma( r_factor=data["processing"]["gamma"]["r_factor"], g_factor=data["processing"]["gamma"]["g_factor"], b_factor=data["processing"]["gamma"]["b_factor"], ) balance = Balance( r_shift=data["processing"]["balance"]["r_shift"], g_shift=data["processing"]["balance"]["g_shift"], b_shift=data["processing"]["balance"]["b_shift"], ) processing = Processing(gamma=gamma, balance=balance) halation = Halation( strength=RGBValue( r=data["properties"]["halation"]["strength"]["r"], g=data["properties"]["halation"]["strength"]["g"], b=data["properties"]["halation"]["strength"]["b"], ), size_um=RGBValue( r=data["properties"]["halation"]["size_um"]["r"], g=data["properties"]["halation"]["size_um"]["g"], b=data["properties"]["halation"]["size_um"]["b"], ), ) couplers = Couplers( amount=data["properties"]["couplers"]["amount"], diffusion_um=data["properties"]["couplers"]["diffusion_um"], ) interlayer = Interlayer( diffusion_um=data["properties"]["interlayer"]["diffusion_um"] ) calibration = Calibration( iso=data["properties"]["calibration"]["iso"], middle_gray_logE=data["properties"]["calibration"]["middle_gray_logh"], ) curves = Curves( hd=[ HDCurvePoint(d=point["d"], r=point["r"], g=point["g"], b=point["b"]) for point in data["properties"]["curves"]["hd"] ], spectral_sensitivity=[ SpectralSensitivityCurvePoint( wavelength=point["wavelength"], y=point["y"], m=point["m"], c=point["c"], ) for point in data["properties"]["curves"]["spectral_sensitivity"] ], ) print(f"Parsed {len(curves.hd)} H&D curve points.") properties = Properties( calibration=calibration, halation=halation, couplers=couplers, interlayer=interlayer, curves=curves, ) return FilmDatasheet( info=info, processing=processing, properties=properties ) except Exception as e: print(f"Error parsing datasheet JSON '{json_filepath}': {e}", file=sys.stderr) return None def um_to_pixels(sigma_um, image_width_px, film_format_mm): """Converts sigma from micrometers to pixels.""" if film_format_mm <= 0 or image_width_px <= 0: return 0 microns_per_pixel = (film_format_mm * 1000.0) / image_width_px sigma_pixels = sigma_um / microns_per_pixel return sigma_pixels def apply_hd_curves( log_exposure_rgb, processing: Processing, hd_curve: List[HDCurvePoint], middle_gray_logE: float, ) -> np.ndarray: """Applies H&D curves to log exposure values.""" density_rgb = np.zeros_like(log_exposure_rgb) gamma_factors = [ processing.gamma.r_factor, processing.gamma.g_factor, processing.gamma.b_factor, ] balance_shifts = [ processing.balance.r_shift, processing.balance.g_shift, processing.balance.b_shift, ] min_logE = hd_curve[0].d max_logE = hd_curve[-1].d min_densities = [hd_curve[0].g, hd_curve[0].g, hd_curve[0].g] max_densities = [hd_curve[-1].g, hd_curve[-1].g, hd_curve[-1].g] for i, channel in enumerate(["R", "G", "B"]): # Apply gamma factor (affects contrast by scaling log exposure input) # Handle potential division by zero if gamma factor is 0 gamma_factor = gamma_factors[i] if abs(gamma_factor) < EPSILON: print( f"Warning: Gamma factor for channel {channel} is near zero. Clamping to {EPSILON}.", file=sys.stderr, ) gamma_factor = EPSILON if gamma_factor >= 0 else -EPSILON # Adjust log exposure relative to middle gray before applying gamma log_exposure_adjusted = ( middle_gray_logE + (log_exposure_rgb[..., i] - middle_gray_logE) / gamma_factor ) # Clamp adjusted exposure to the range defined in the H&D data before interpolation log_exposure_clamped = np.clip(log_exposure_adjusted, min_logE, max_logE) # Create interpolation function for the current channel if channel == "R": interp_func = interp1d( [d.d for d in hd_curve], [d.r for d in hd_curve], kind="linear", # Linear interpolation is common, could be 'cubic' bounds_error=False, # Allows extrapolation, but we clamp manually below fill_value=(min_densities[i], max_densities[i]), # type: ignore ) elif channel == "G": interp_func = interp1d( [d.d for d in hd_curve], [d.g for d in hd_curve], kind="linear", # Linear interpolation is common, could be 'cubic' bounds_error=False, # Allows extrapolation, but we clamp manually below fill_value=(min_densities[i], max_densities[i]), # type: ignore ) else: interp_func = interp1d( [d.d for d in hd_curve], [d.b for d in hd_curve], kind="linear", # Linear interpolation is common, could be 'cubic' bounds_error=False, # Allows extrapolation, but we clamp manually below fill_value=(min_densities[i], max_densities[i]), # type: ignore ) # Apply interpolation density = interp_func(log_exposure_clamped) # Apply density balance shift (additive density offset) density += balance_shifts[i] density_rgb[..., i] = np.maximum(density, 0) # Ensure density is non-negative return density_rgb def apply_saturation_rgb(image_linear, saturation_factor): """Adjusts saturation directly in RGB space.""" if saturation_factor == 1.0: return image_linear # Luminance weights for sRGB primaries (Rec.709) luminance = ( 0.2126 * image_linear[..., 0] + 0.7152 * image_linear[..., 1] + 0.0722 * image_linear[..., 2] ) # Expand luminance to 3 channels for broadcasting luminance_rgb = np.expand_dims(luminance, axis=-1) # Apply saturation: Lerp between luminance (grayscale) and original color saturated_image = luminance_rgb + saturation_factor * (image_linear - luminance_rgb) # Clip results to valid range (important after saturation boost) return np.clip(saturated_image, 0.0, 1.0) def apply_spatial_effects( image, film_format_mm, couplerData: Couplers, interlayerData: Interlayer, halationData: Halation, image_width_px, ): """Applies diffusion blur and halation.""" # Combine diffusion effects (assuming they add quadratically in terms of sigma) total_diffusion_um = np.sqrt( couplerData.diffusion_um**2 + interlayerData.diffusion_um**2 ) if total_diffusion_um > EPSILON: sigma_pixels_diffusion = um_to_pixels( total_diffusion_um, image_width_px, film_format_mm ) if sigma_pixels_diffusion > EPSILON: print( f"Applying diffusion blur: sigma={sigma_pixels_diffusion:.2f} pixels ({total_diffusion_um:.1f} um)" ) # Apply blur to the linear image data image = gaussian_filter( image, sigma=[sigma_pixels_diffusion, sigma_pixels_diffusion, 0], mode="nearest", ) # Blur R, G, B independently image = np.clip(image, 0.0, 1.0) # Keep values in range # --- 2. Apply Halation --- # This simulates light scattering back through the emulsion halation_applied = False blurred_image_halation = np.copy( image ) # Start with potentially diffusion-blurred image strengths = [ halationData.strength.r, halationData.strength.g, halationData.strength.b, ] sizes_um = [ halationData.size_um.r, halationData.size_um.g, halationData.size_um.b, ] for i in range(3): strength = strengths[i] size_um = sizes_um[i] if strength > EPSILON and size_um > EPSILON: sigma_pixels_halation = um_to_pixels( size_um, image_width_px, film_format_mm ) if sigma_pixels_halation > EPSILON: halation_applied = True print( f"Applying halation blur (Channel {i}): sigma={sigma_pixels_halation:.2f} pixels ({size_um:.1f} um), strength={strength:.3f}" ) # Blur only the current channel for halation effect channel_blurred = gaussian_filter( image[..., i], sigma=sigma_pixels_halation, mode="nearest" ) # Add the blurred channel back, weighted by strength blurred_image_halation[..., i] = ( image[..., i] * (1.0 - strength) + channel_blurred * strength ) if halation_applied: # Clip final result after halation image = np.clip(blurred_image_halation, 0.0, 1.0) return image def main(): parser = argparse.ArgumentParser( description="Simulate film stock color characteristics using a datasheet JSON." ) parser.add_argument( "input_image", help="Path to the input RGB image (e.g., PNG, TIFF). Assumed linear RGB.", ) parser.add_argument("datasheet_json", help="Path to the film datasheet JSON file.") parser.add_argument("output_image", help="Path to save the output emulated image.") args = parser.parse_args() # --- Load Datasheet --- print(f"Loading datasheet: {args.datasheet_json}") datasheet: FilmDatasheet | None = parse_datasheet_json(args.datasheet_json) if datasheet is None: sys.exit(1) print( f"Simulating: {datasheet.info.name} ({datasheet.info.format_mm}mm) (v{datasheet.info.version})\n\t{datasheet.info.description}" ) import pprint pprint.pp(datasheet) # --- Load Input Image --- print(f"Loading input image: {args.input_image}") try: # For DNG files, force reading the raw image data, not the embedded thumbnail if ( args.input_image.lower().endswith(".dng") or args.input_image.lower().endswith(".raw") or args.input_image.lower().endswith(".arw") ): print("Detected Camera RAW file, reading raw image data...") with rawpy.imread(args.input_image) as raw: image_raw = raw.postprocess( demosaic_algorithm=rawpy.DemosaicAlgorithm.AHD, # type: ignore output_bps=16, # Use 16-bit output for better precision use_camera_wb=True, # Use camera white balance no_auto_bright=True, # Disable auto brightness adjustment output_color=rawpy.ColorSpace.sRGB, # type: ignore ) # If the image has more than 3 channels, try to select the first 3 (RGB) if image_raw.ndim == 3 and image_raw.shape[-1] > 3: image_raw = image_raw[..., :3] else: image_raw = iio.imread(args.input_image) except FileNotFoundError: print( f"Error: Input image file not found at {args.input_image}", file=sys.stderr ) sys.exit(1) except Exception as e: print(f"Error reading input image: {e}", file=sys.stderr) sys.exit(1) # Check if image is likely sRGB (8-bit or 16-bit integer types are usually sRGB) is_srgb = image_raw.dtype in (np.uint8, np.uint16) if is_srgb: print("Input image appears to be sRGB. Linearizing...") # Convert to float in [0,1] if image_raw.dtype == np.uint8: image_float = image_raw.astype(np.float64) / 255.0 elif image_raw.dtype == np.uint16: image_float = image_raw.astype(np.float64) / 65535.0 else: image_float = image_raw.astype(np.float64) # sRGB to linear conversion def srgb_to_linear(c): c = np.clip(c, 0.0, 1.0) return np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4) image_raw = srgb_to_linear(image_float) else: print("Input image is assumed to be linear RGB.") # --- Prepare Image Data --- # Convert to float64 for precision, handle different input types if image_raw.dtype == np.uint8: image_linear = image_raw.astype(np.float64) / 255.0 elif image_raw.dtype == np.uint16: image_linear = image_raw.astype(np.float64) / 65535.0 elif image_raw.dtype == np.float32: image_linear = image_raw.astype(np.float64) elif image_raw.dtype == np.float64: image_linear = image_raw else: print(f"Error: Unsupported image data type: {image_raw.dtype}", file=sys.stderr) sys.exit(1) # Discard alpha channel if present if image_linear.shape[-1] == 4: print("Discarding alpha channel.") image_linear = image_linear[..., :3] elif image_linear.shape[-1] != 3: print( f"Error: Input image must be RGB (shape {image_linear.shape} not supported).", file=sys.stderr, ) sys.exit(1) # Ensure input is non-negative image_linear = np.maximum(image_linear, 0.0) print(f"Input image dimensions: {image_linear.shape}") image_width_px = image_linear.shape[1] # --- Pipeline Steps --- # 1. Convert Linear RGB to Log Exposure (LogE) # Map linear 0.18 to the specified middle_gray_logE from the datasheet print("Converting linear RGB to Log Exposure...") middle_gray_logE = float(datasheet.properties.calibration.middle_gray_logE) # Add epsilon inside log10 to handle pure black pixels log_exposure_rgb = middle_gray_logE + np.log10(image_linear / 0.18 + EPSILON) # Note: Values below 0.18 * 10**(hd_data['LogE'][0] - middle_gray_logE) # or above 0.18 * 10**(hd_data['LogE'][-1] - middle_gray_logE) # will map outside the H&D curve's defined LogE range and rely on clamping/extrapolation. # 2. Apply H&D Curves (Tonal Mapping + Balance Shifts + Gamma/Contrast) print("Applying H&D curves...") density_rgb = apply_hd_curves( log_exposure_rgb, datasheet.processing, datasheet.properties.curves.hd, middle_gray_logE, ) # 3. Convert Density back to Linear Transmittance # Higher density means lower transmittance print("Converting density to linear transmittance...") # Add density epsilon? Usually density floor (Dmin) handles this. linear_transmittance = 10.0 ** (-density_rgb) # Normalize transmittance? Optional, assumes Dmin corresponds roughly to max transmittance 1.0 # Could normalize relative to Dmin from the curves if needed. # linear_transmittance = linear_transmittance / (10.0**(-np.array([hd_data['Density_R'][0], hd_data['Density_G'][0], hd_data['Density_B'][0]]))) linear_transmittance = np.clip(linear_transmittance, 0.0, 1.0) # 4. Apply Spatial Effects (Diffusion Blur, Halation) print("Applying spatial effects (diffusion, halation)...") # Apply these effects in the linear domain linear_post_spatial = apply_spatial_effects( linear_transmittance, datasheet.info.format_mm, datasheet.properties.couplers, datasheet.properties.interlayer, datasheet.properties.halation, image_width_px, ) # 5. Apply Saturation Adjustment (Approximating Coupler Effects) print("Applying saturation adjustment...") coupler_amount = datasheet.properties.couplers.amount # Assuming coupler_amount directly scales saturation factor. # Values > 1 increase saturation, < 1 decrease. linear_post_saturation = apply_saturation_rgb(linear_post_spatial, coupler_amount) # --- Final Output Conversion --- print("Converting to output format...") # Clip final result and convert to uint8 if args.output_image.lower().endswith(".tiff"): output_image_uint8 = ( np.clip(linear_post_saturation, 0.0, 1.0) * 65535.0 ).astype(np.uint16) else: output_image_uint8 = (np.clip(linear_post_saturation, 0.0, 1.0) * 255.0).astype( np.uint8 ) # --- Save Output Image --- print(f"Saving output image: {args.output_image}") try: if args.output_image.lower().endswith((".tiff", ".tif")): # Use imageio for standard formats iio.imwrite(args.output_image, output_image_uint8) elif args.output_image.lower().endswith(".png"): iio.imwrite(args.output_image, output_image_uint8, format="PNG") else: iio.imwrite(args.output_image, output_image_uint8, quality=95) print("Done.") except Exception as e: print(f"Error writing output image: {e}", file=sys.stderr) sys.exit(1) if __name__ == "__main__": main()