900 lines
35 KiB
Plaintext
Executable File
900 lines
35 KiB
Plaintext
Executable File
#!/usr/bin/env -S uv run --script
|
|
# /// script
|
|
# dependencies = [
|
|
# "numpy",
|
|
# "scipy",
|
|
# "Pillow",
|
|
# "imageio",
|
|
# "rawpy",
|
|
# "colour-science",
|
|
# ]
|
|
# ///
|
|
|
|
# -*- 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, gaussian_filter1d
|
|
import rawpy
|
|
import os
|
|
import sys
|
|
import colour
|
|
from colour.colorimetry import SDS_ILLUMINANTS
|
|
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:
|
|
saturation_amount: float
|
|
dir_amount_rgb: List[float]
|
|
dir_diffusion_um: float
|
|
dir_diffusion_interlayer: float
|
|
|
|
def __init__(self, saturation_amount: float, dir_amount_rgb: List[float], dir_diffusion_um: float, dir_diffusion_interlayer: float) -> None:
|
|
self.saturation_amount = saturation_amount
|
|
self.dir_amount_rgb = dir_amount_rgb
|
|
self.dir_diffusion_um = dir_diffusion_um
|
|
self.dir_diffusion_interlayer = dir_diffusion_interlayer
|
|
|
|
|
|
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(
|
|
saturation_amount=data["properties"]["couplers"]["saturation_amount"],
|
|
dir_amount_rgb=data["properties"]["couplers"]["dir_amount_rgb"],
|
|
dir_diffusion_um=data["properties"]["couplers"]["dir_diffusion_um"],
|
|
dir_diffusion_interlayer=data["properties"]["couplers"]["dir_diffusion_interlayer"],
|
|
)
|
|
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 compute_inhibitor_matrix(amount_rgb: List[float], diffusion_interlayer: float) -> np.ndarray:
|
|
"""
|
|
Computes the 3x3 DIR coupler inhibitor matrix.
|
|
Diagonal elements represent self-inhibition (intra-layer).
|
|
Off-diagonal elements represent cross-inhibition (inter-layer).
|
|
"""
|
|
# Start with an identity matrix representing the source of inhibitors
|
|
matrix = np.eye(3)
|
|
# Apply 1D blur across the layer axis to simulate diffusion between layers
|
|
if diffusion_interlayer > 0:
|
|
matrix = gaussian_filter1d(matrix, sigma=diffusion_interlayer, axis=0, mode='constant', cval=0)
|
|
matrix = gaussian_filter1d(matrix, sigma=diffusion_interlayer, axis=1, mode='constant', cval=0)
|
|
# Normalize rows to ensure diffusion doesn't create/destroy inhibitor
|
|
row_sums = matrix.sum(axis=1)
|
|
matrix = matrix / row_sums[:, np.newaxis]
|
|
# Scale by the amount of inhibitor released by each source layer
|
|
matrix = matrix * np.array(amount_rgb)[:, np.newaxis]
|
|
return matrix
|
|
|
|
|
|
def compute_uncoupled_hd_curves(hd_curve_data: List[HDCurvePoint], inhibitor_matrix: np.ndarray) -> List[HDCurvePoint]:
|
|
"""
|
|
Pre-calculates a new set of H&D curves that represent the film's
|
|
response *without* DIR couplers.
|
|
"""
|
|
log_E_values = np.array([p.d for p in hd_curve_data if p.d is not None])
|
|
density_r = np.array([p.r for p in hd_curve_data])
|
|
density_g = np.array([p.g for p in hd_curve_data])
|
|
density_b = np.array([p.b for p in hd_curve_data])
|
|
|
|
# For a neutral gray ramp, we assume the density forming in all three layers can be
|
|
# approximated by the green channel's response. This is our source of inhibitors.
|
|
neutral_density_curve = density_g
|
|
|
|
# Create a (num_points, 3) source density matrix for the neutral ramp.
|
|
# We tile the single neutral curve across all three source channels.
|
|
source_density_matrix = np.tile(neutral_density_curve[:, np.newaxis], (1, 3))
|
|
|
|
# Calculate inhibitor signal received by each destination layer.
|
|
# This is the matrix product of the source densities and the inhibitor matrix.
|
|
# The (i, j)-th element of inhibitor_matrix is the effect FROM source i ON destination j.
|
|
# Shape: (40, 3) @ (3, 3) -> (40, 3)
|
|
inhibitor_effect = source_density_matrix @ inhibitor_matrix
|
|
|
|
# For each channel, find the new "uncoupled" density curve by shifting the
|
|
# exposure axis by the calculated inhibitor effect for that channel.
|
|
uncoupled_r = np.interp(log_E_values, log_E_values - inhibitor_effect[:, 0], density_r)
|
|
uncoupled_g = np.interp(log_E_values, log_E_values - inhibitor_effect[:, 1], density_g)
|
|
uncoupled_b = np.interp(log_E_values, log_E_values - inhibitor_effect[:, 2], density_b)
|
|
|
|
# Reassemble into the HDCurvePoint list structure
|
|
uncoupled_curve = []
|
|
for i, log_e in enumerate(log_E_values):
|
|
uncoupled_curve.append(HDCurvePoint(d=log_e, r=uncoupled_r[i], g=uncoupled_g[i], b=uncoupled_b[i]))
|
|
|
|
return uncoupled_curve
|
|
|
|
|
|
def apply_dir_coupler_simulation(log_exposure_rgb, naive_density_rgb, inhibitor_matrix, diffusion_um, film_format_mm, image_width_px):
|
|
"""
|
|
Applies the DIR coupler effect to the log exposure image.
|
|
"""
|
|
# 1. Spatially diffuse the inhibitor signal
|
|
diffusion_pixels = um_to_pixels(diffusion_um, image_width_px, film_format_mm)
|
|
if diffusion_pixels > EPSILON:
|
|
# We blur the density signal, which is proportional to the amount of inhibitor released
|
|
inhibitor_signal_diffused = gaussian_filter(naive_density_rgb, sigma=(diffusion_pixels, diffusion_pixels, 0))
|
|
else:
|
|
inhibitor_signal_diffused = naive_density_rgb
|
|
|
|
# 2. Apply inter-layer crosstalk
|
|
# inhibitor_signal has shape (H, W, 3), inhibitor_matrix has shape (3, 3)
|
|
# einsum calculates the total inhibition for each destination layer from all source layers
|
|
inhibitor_effect = np.einsum('...s, sm -> ...m', inhibitor_signal_diffused, inhibitor_matrix)
|
|
|
|
# 3. Modify the original log exposure
|
|
modified_log_exposure = log_exposure_rgb - inhibitor_effect
|
|
return modified_log_exposure
|
|
|
|
|
|
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,
|
|
interlayerData: Interlayer,
|
|
halationData: Halation,
|
|
image_width_px,
|
|
):
|
|
"""Applies diffusion blur and halation."""
|
|
# Combine diffusion effects (assuming they add quadratically in terms of sigma)
|
|
softening_diffusion_um = interlayerData.diffusion_um
|
|
if softening_diffusion_um > EPSILON:
|
|
sigma_pixels_diffusion = um_to_pixels(softening_diffusion_um, image_width_px, film_format_mm)
|
|
if sigma_pixels_diffusion > EPSILON:
|
|
print(f"Applying interlayer diffusion blur: sigma={sigma_pixels_diffusion:.2f} pixels ({softening_diffusion_um:.1f} um)")
|
|
image = gaussian_filter(image, sigma=[sigma_pixels_diffusion, sigma_pixels_diffusion, 0], mode="nearest")
|
|
image = np.clip(image, 0.0, 1.0)
|
|
|
|
# --- 2. Apply Halation ---
|
|
# This simulates light scattering back through the emulsion
|
|
halation_applied = True
|
|
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 calculate_film_log_exposure(image_linear_srgb, # Assuming input is converted to linear sRGB
|
|
spectral_data: list[SpectralSensitivityCurvePoint],
|
|
ref_illuminant_spd, # e.g., colour.SDS_ILLUMINANTS['D65']
|
|
middle_gray_logE,
|
|
EPSILON=1e-10):
|
|
"""
|
|
Calculates the effective log exposure for each film layer based on spectral sensitivities.
|
|
This version correctly maps film layers (RGB) to dye sensitivities (CMY)
|
|
and calibrates exposure without distorting color balance.
|
|
"""
|
|
# --- 1. Define a common spectral shape and align all data ---
|
|
common_shape = colour.SpectralShape(380, 780, 5)
|
|
common_wavelengths = common_shape.wavelengths
|
|
|
|
# --- THE FIX IS HERE: Correct mapping of film layers to sensitivities ---
|
|
# The 'R' layer of our model corresponds to the Cyan dye ('c') sensitivity curve.
|
|
# The 'G' layer of our model corresponds to the Magenta dye ('m') sensitivity curve.
|
|
# The 'B' layer of our model corresponds to the Yellow dye ('y') sensitivity curve.
|
|
film_sens_R = interp1d([p.wavelength for p in spectral_data], [p.c for p in spectral_data], bounds_error=False, fill_value=0)(common_wavelengths)
|
|
film_sens_G = interp1d([p.wavelength for p in spectral_data], [p.m for p in spectral_data], bounds_error=False, fill_value=0)(common_wavelengths)
|
|
film_sens_B = interp1d([p.wavelength for p in spectral_data], [p.y for p in spectral_data], bounds_error=False, fill_value=0)(common_wavelengths)
|
|
|
|
film_spectral_sensitivities = np.stack([film_sens_R, film_sens_G, film_sens_B], axis=-1)
|
|
print(f"Film spectral sensitivities shape: {film_spectral_sensitivities.shape}")
|
|
|
|
# Align Reference Illuminant to our common shape
|
|
illuminant_aligned = ref_illuminant_spd.copy().align(common_shape)
|
|
|
|
# --- 2. Use Mallett (2019) to get spectral reflectance from sRGB input ---
|
|
# Get the three sRGB spectral primary basis functions
|
|
mallett_basis_sds = colour.recovery.MSDS_BASIS_FUNCTIONS_sRGB_MALLETT2019
|
|
mallett_basis_aligned = mallett_basis_sds.copy().align(common_shape)
|
|
print(f"Mallett basis shape: {mallett_basis_aligned.shape}")
|
|
|
|
# This is the core of Mallett's method: the reflectance spectrum of any sRGB color
|
|
# is a linear combination of these three basis spectra, weighted by the linear sRGB values.
|
|
# S(lambda) = R * Sr(lambda) + G * Sg(lambda) + B * Sb(lambda)
|
|
spectral_reflectance = np.einsum(
|
|
'...c, kc -> ...k', # c: sRGB channels, k: wavelengths
|
|
image_linear_srgb, mallett_basis_aligned.values
|
|
)
|
|
print(f"Spectral reflectance shape: {spectral_reflectance.shape}")
|
|
|
|
# --- 3. Calculate Scene Light and Film Exposure ---
|
|
# The light hitting the film is the spectral reflectance multiplied by the scene illuminant.
|
|
light_from_scene = spectral_reflectance * illuminant_aligned.values
|
|
print(f"Light from scene shape: {light_from_scene.shape}")
|
|
|
|
# The exposure on each film layer is the integral of scene light * layer sensitivity.
|
|
# The einsum here performs that multiplication and summation (integration) in one step.
|
|
film_exposure_values = np.einsum(
|
|
'...k, ks -> ...s', # k: wavelengths, s: film layers
|
|
light_from_scene, film_spectral_sensitivities
|
|
)
|
|
print(f"Film exposure values shape: {film_exposure_values.shape}")
|
|
|
|
# --- 4. Calibrate Exposure Level (The Right Way) ---
|
|
# We now need to anchor this exposure to the datasheet's middle_gray_logE.
|
|
# First, find the exposure produced by a perfect 18.4% gray card.
|
|
gray_srgb_linear = np.array([0.184, 0.184, 0.184])
|
|
gray_reflectance = np.einsum('c, kc -> k', gray_srgb_linear, mallett_basis_aligned.values)
|
|
gray_light = gray_reflectance * illuminant_aligned.values
|
|
exposure_18_gray_film = np.einsum('k, ks -> s', gray_light, film_spectral_sensitivities)
|
|
print(f"Exposure for 18.4% gray card shape: {exposure_18_gray_film.shape}")
|
|
|
|
# The datasheet says that a middle gray exposure should result in a log value of -1.44 (for Portra).
|
|
# Our "green" channel is the usual reference for overall brightness.
|
|
# Let's find out what log exposure our 18.4% gray card *actually* produced on the green layer.
|
|
log_exposure_of_gray_on_green_layer = np.log10(exposure_18_gray_film[1] + EPSILON)
|
|
|
|
# Now, calculate the difference (the "shift") needed to match the datasheet.
|
|
log_shift = middle_gray_logE - log_exposure_of_gray_on_green_layer
|
|
print(f"Log shift to match middle gray: {log_shift:.3f}")
|
|
|
|
# --- 5. Final Conversion to Log Exposure ---
|
|
# Apply this single brightness shift to all three channels. This preserves the
|
|
# film's inherent color balance while correctly setting the exposure level.
|
|
log_exposure_rgb = np.log10(film_exposure_values + EPSILON) + log_shift
|
|
print(f"Log exposure RGB shape: {log_exposure_rgb.shape}")
|
|
|
|
return log_exposure_rgb
|
|
|
|
|
|
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}"
|
|
)
|
|
|
|
print("Pre-calculating DIR coupler effects...")
|
|
inhibitor_matrix = compute_inhibitor_matrix(datasheet.properties.couplers.dir_amount_rgb, datasheet.properties.couplers.dir_diffusion_interlayer)
|
|
print("Inhibitor Matrix:\n", inhibitor_matrix)
|
|
uncoupled_hd_curve = compute_uncoupled_hd_curves(datasheet.properties.curves.hd, inhibitor_matrix)
|
|
print(f"Successfully computed {len(uncoupled_hd_curve)} points for the uncoupled H&D curve.")
|
|
|
|
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 = calculate_film_log_exposure(image_linear,
|
|
datasheet.properties.curves.spectral_sensitivity,
|
|
SDS_ILLUMINANTS['D65'], # Use D65 as reference illuminant
|
|
middle_gray_logE, EPSILON)
|
|
|
|
|
|
print("Applying DIR coupler simulation...")
|
|
naive_density_rgb = apply_hd_curves(log_exposure_rgb, datasheet.processing, datasheet.properties.curves.hd, middle_gray_logE)
|
|
# 2b. Use this density to calculate the inhibitor effect and modify the log exposure
|
|
modified_log_exposure_rgb = apply_dir_coupler_simulation(
|
|
log_exposure_rgb,
|
|
naive_density_rgb,
|
|
inhibitor_matrix,
|
|
datasheet.properties.couplers.dir_diffusion_um,
|
|
datasheet.info.format_mm,
|
|
image_width_px
|
|
)
|
|
|
|
|
|
# 2. Apply H&D Curves (Tonal Mapping + Balance Shifts + Gamma/Contrast)
|
|
print("Applying H&D curves...")
|
|
density_rgb = apply_hd_curves(
|
|
modified_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.interlayer,
|
|
datasheet.properties.halation,
|
|
image_width_px,
|
|
)
|
|
|
|
# 5. Apply Saturation Adjustment (Approximating Coupler Effects)
|
|
# Assuming coupler_amount directly scales saturation factor.
|
|
# Values > 1 increase saturation, < 1 decrease.
|
|
linear_post_saturation = apply_saturation_rgb(linear_post_spatial, datasheet.properties.couplers.saturation_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()
|