V 2.0.0 - Rasterix - new film rendering

This commit is contained in:
DESKTOP-TVBJISQ\Primere
2026-03-24 17:52:08 +01:00
parent 0b34cdba76
commit ad6bcc50fa
5 changed files with 507 additions and 273 deletions
+28 -18
View File
@@ -85,6 +85,7 @@ from ..components.images import img_film_rendering as img_film_rendering
from ..components.images.img_film_rendering import FILM_PRESETS
from ..components.images import img_lens_effects as img_lens_effects
from ..components.images import img_levels_compress as img_levels_compress
from ..components.images import img_dithering as img_dithering
from ..components.images import histogram as histogram
class PrimereSamplersSteps:
@@ -2182,9 +2183,9 @@ class PrimereRasterix:
"auto_normalize": ("BOOLEAN", {"default": False, "label_off": "No auto levels", "label_on": "Apply auto levels"}),
"auto_levels_threshold": ("FLOAT", {"default": 0.2, "min": 0.0, "max": 10.0, "step": 0.1}),
"normalize_gaps": ("BOOLEAN", {"default": False, "label_on": "Anti-comb filter: ON", "label_off": "Anti-comb filter: OFF"}),
"normalize_midpeaks": ("BOOLEAN", {"default": False, "label_on": "Anti-spike filter: ON", "label_off": "Anti-spike filter: OFF"}),
"peak_width": ("INT", {"default": 3, "min": 1, "max": 10, "step": 1}),
# "normalize_gaps": ("BOOLEAN", {"default": False, "label_on": "Anti-comb filter: ON", "label_off": "Anti-comb filter: OFF"}),
# "normalize_midpeaks": ("BOOLEAN", {"default": False, "label_on": "Anti-spike filter: ON", "label_off": "Anti-spike filter: OFF"}),
# "peak_width": ("INT", {"default": 3, "min": 1, "max": 10, "step": 1}),
"auto_gamma": ("BOOLEAN", {"default": False, "label_on": "Auto gamma: ON", "label_off": "Auto gamma:: OFF"}),
"gamma_target": ("FLOAT", {"default": 128.0, "min": 0.0, "max": 255.0, "step": 0.1}),
@@ -2212,6 +2213,7 @@ class PrimereRasterix:
"use_film_rendering": ("BOOLEAN", {"default": False, "label_off": "Ignore film rendering", "label_on": "Apply film rendering"}),
"film_rendering": (list(FILM_PRESETS.keys()), {"default": "kodak_kodachrome_64_CF"}),
"film_rendering_intensity": ("FLOAT", {"default": 100, "min": 0, "max": 200, "step": 1}),
"iso_grain": ("BOOLEAN", {"default": False, "label_off": "Ignore ISO grain", "label_on": "Add ISO grain"}),
"use_selective_tone": ("BOOLEAN", {"default": False, "label_off": "Ignore selective tone", "label_on": "Apply selective tone"}),
"selective_tone_value": ("FLOAT", {"default": 0, "min": -100, "max": 100, "step": 1}),
@@ -2242,19 +2244,24 @@ class PrimereRasterix:
"detail_mode": (["fine", "medium", "broad"], {"default": "medium"}),
"shade_strength": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}),
"use_level_endpoints": ("BOOLEAN", {"default": False, "label_off": "Ignore endpoint offset", "label_on": "Apply endpoint offset"}),
"black_offset": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 25.0, "step": 0.1}),
"white_offset": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 25.0, "step": 0.1}),
"skip_if_no_clip": ("BOOLEAN", {"default": False, "label_off": "Offset all values", "label_on": "Skip if no clips"}),
"normalize_gaps": ("BOOLEAN", {"default": False, "label_on": "Anti-comb filter: ON", "label_off": "Anti-comb filter: OFF"}),
"dither_quantization": ("BOOLEAN", {"default": False, "label_off": "Dither quantization OFF", "label_on": "Dither quantization ON"}),
"adaptive_dither_strength": ("BOOLEAN", {"default": False, "label_off": "Keep dither strength", "label_on": "Increase dither strength"}),
"error_diffusion": ("BOOLEAN", {"default": False, "label_off": "Ignore endpoint offset", "label_on": "Apply endpoint offset"}),
"normalize_midpeaks": ("BOOLEAN", {"default": False, "label_on": "Anti-spike filter: ON", "label_off": "Anti-spike filter: OFF"}),
"peak_width": ("INT", {"default": 3, "min": 1, "max": 10, "step": 1}),
"use_ai_detection_bypasser": ("BOOLEAN", {"default": False, "label_off": "AI detection bypass off", "label_on": "AI detection bypass on"}),
"adb_freq_strength": ("FLOAT", {"default": 0.019, "min": 0.0, "max": 0.1, "step": 0.001}),
"adb_variance_strength": ("FLOAT", {"default": 0.32, "min": 0.0, "max": 1.0, "step": 0.01}),
"adb_unsharp_percent": ("INT", {"default": 38, "min": 0, "max": 150, "step": 1}),
"adb_jpeg_cycles": ("INT", {"default": 4, "min": 0, "max": 6, "step": 1}),
# "final_peaks": ("BOOLEAN", {"default": False, "label_on": "End peak normalization: ON", "label_off": "End peak normalization: OFF"}),
"use_level_endpoints": ("BOOLEAN", {"default": False, "label_off": "Ignore endpoint offset", "label_on": "Apply endpoint offset"}),
"black_offset": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 25.0, "step": 0.1}),
"white_offset": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 25.0, "step": 0.1}),
"skip_if_no_clip": ("BOOLEAN", {"default": False, "label_off": "Offset all values", "label_on": "Skip if no clips"}),
"show_histogram": ("BOOLEAN", {"default": False, "label_off": "Ignore histogram", "label_on": "Create histogram"}),
"histogram_source": ("BOOLEAN", {"default": False, "label_off": "Show output histogram", "label_on": "Show input histogram"}),
"histogram_channel": (["RGB", "RED", "GREEN", "BLUE"], {"default": "RGB"}),
@@ -2266,15 +2273,18 @@ class PrimereRasterix:
}
}
def primere_rasterix(self, concepts, models, image, precision, auto_normalize, auto_levels_threshold, normalize_gaps, normalize_midpeaks, peak_width, auto_gamma, gamma_target, use_white_balance, wb_temperature, wb_tint, use_blur, blur_type, blur_intensity, blur_radius, angle, bilateral_edge_sensitivity, blur_edge_only, edge_threshold, use_smart_lighting, smart_lighting, use_brightness_contrast, brightness, contrast, use_legacy, use_film_rendering, film_rendering, film_rendering_intensity, use_selective_tone, selective_tone_value, selective_tone_zone, selective_tone_separation, selective_tone_strength, use_color_balance, color_balance_cyan_red, color_balance_magenta_green, color_balance_yellow_blue, color_balance_tone, color_balance_preserve_luminosity, color_balance_separation, use_hsl, hsl_hue, hsl_saturation, hsl_lightness, hsl_vibrance, hsl_channel, hsl_channel_width, hsl_skin_protection, use_shade_detailer, shade_level, shade_radius, detail_mode, shade_strength, use_ai_detection_bypasser, adb_freq_strength, adb_variance_strength, adb_unsharp_percent, adb_jpeg_cycles, use_level_endpoints, black_offset, white_offset, skip_if_no_clip, show_histogram=False, histogram_source=False, histogram_channel="RGB", histogram_style="gradient", model_concept=None, model_name=None):
def primere_rasterix(self, concepts, models, image, precision, auto_normalize, auto_levels_threshold, normalize_midpeaks, peak_width, auto_gamma, gamma_target, use_white_balance, wb_temperature, wb_tint, use_blur, blur_type, blur_intensity, blur_radius, angle, bilateral_edge_sensitivity, blur_edge_only, edge_threshold, use_smart_lighting, smart_lighting, use_brightness_contrast, brightness, contrast, use_legacy, use_film_rendering, film_rendering, film_rendering_intensity, iso_grain, use_selective_tone, selective_tone_value, selective_tone_zone, selective_tone_separation, selective_tone_strength, use_color_balance, color_balance_cyan_red, color_balance_magenta_green, color_balance_yellow_blue, color_balance_tone, color_balance_preserve_luminosity, color_balance_separation, use_hsl, hsl_hue, hsl_saturation, hsl_lightness, hsl_vibrance, hsl_channel, hsl_channel_width, hsl_skin_protection, use_shade_detailer, shade_level, shade_radius, detail_mode, shade_strength, use_ai_detection_bypasser, adb_freq_strength, adb_variance_strength, adb_unsharp_percent, adb_jpeg_cycles, use_level_endpoints, black_offset, white_offset, skip_if_no_clip, normalize_gaps, dither_quantization, adaptive_dither_strength, error_diffusion, show_histogram=False, histogram_source=False, histogram_channel="RGB", histogram_style="gradient", model_concept=None, model_name=None):
pil_img = utility.tensor_to_image(image)
pil_img_input = pil_img.copy()
rasterix_json_path = os.path.join(PRIMERE_ROOT, 'front_end', 'rasterix.json')
rasterix_data = utility.json2tuple(rasterix_json_path) or {}
stretched_gaps_spike = []
scale_spike = []
rng_gap_spike = []
if auto_normalize:
pil_img = img_levels_auto.img_levels_auto(image=pil_img, auto_normalize=auto_normalize, threshold=auto_levels_threshold, normalize_gaps=normalize_gaps, normalize_midpeaks=normalize_midpeaks, peak_width=peak_width, auto_gamma=auto_gamma, gamma_target=gamma_target, precision=precision)
pil_img = img_levels_auto.img_levels_auto(image=pil_img, auto_normalize=auto_normalize, threshold=auto_levels_threshold, auto_gamma=auto_gamma, gamma_target=gamma_target, precision=precision)
if use_white_balance and (wb_temperature != 6500 or wb_tint != 0):
pil_img = img_white_balance.img_white_balance(image=pil_img, temperature=wb_temperature, tint=wb_tint)
@@ -2289,7 +2299,7 @@ class PrimereRasterix:
pil_img = img_brightness_contrast.img_brightness_contrast(image=pil_img, brightness=brightness, contrast=contrast, use_legacy=use_legacy)
if use_film_rendering and film_rendering_intensity != 0:
pil_img = img_film_rendering.img_film_rendering(image=pil_img, rendering=film_rendering, intensity=film_rendering_intensity)
pil_img = img_film_rendering.img_film_rendering(image=pil_img, rendering=film_rendering, intensity=film_rendering_intensity, add_grain=iso_grain)
st_data = rasterix_data.get('selective_tone', {})
if use_selective_tone and st_data:
@@ -2311,15 +2321,15 @@ class PrimereRasterix:
rad = vals.get('shade_radius', 0)
pil_img = img_shade_level.img_shade_level(image=pil_img, shade_level=lvl, radius=rad, strength=shade_strength)
# if final_peaks:
# pil_img = img_levels_auto.img_levels_auto(image=pil_img, auto_normalize=True, threshold=0, normalize_gaps=False, normalize_midpeaks=True, peak_width=peak_width, auto_gamma=False, gamma_target=128)
if use_level_endpoints and (black_offset != 0 or white_offset != 0):
pil_img = img_levels_compress.img_levels_compress(image=pil_img, black_offset=black_offset, white_offset=white_offset, skip_if_no_clip=skip_if_no_clip, high_precision=precision)
if dither_quantization or error_diffusion or normalize_midpeaks:
pil_img = img_dithering.img_dithering(image=pil_img, normalize_gaps_legacy=normalize_gaps, stretched_gaps_spike=stretched_gaps_spike, scale_spike=scale_spike, rng_gap_spike=rng_gap_spike, dither_quantization=dither_quantization, adaptive_dither_strength=adaptive_dither_strength, error_diffusion=error_diffusion, normalize_midpeaks=normalize_midpeaks, peak_width=peak_width, high_precision=precision)
if use_ai_detection_bypasser:
pil_img = isgen_detect_ext_full.bypass_ai_detector(image=pil_img, freq_strength=adb_freq_strength, variance_strength=adb_variance_strength, unsharp_percent=adb_unsharp_percent, jpeg_cycles=adb_jpeg_cycles)
if use_level_endpoints and (black_offset != 0 or white_offset != 0):
pil_img = img_levels_compress.img_levels_compress(image=pil_img, black_offset=black_offset, white_offset=white_offset, skip_if_no_clip=skip_if_no_clip, high_precision=precision)
if show_histogram:
hist_dir = os.path.join(PRIMERE_ROOT, 'front_end', 'images')
rendered = {}
+177
View File
@@ -0,0 +1,177 @@
import numpy as np
from PIL import Image
from numpy.lib.stride_tricks import sliding_window_view
def _adaptive_dither_amplitude(scale: float, adaptive: bool, max_val: float) -> float:
"""
Return dither amplitude in output-code units (LSB of 8-bit domain).
scale=1.0 means wide tonal span, lower values mean tighter span.
"""
base_lsb = max_val / 255.0
if not adaptive:
return 1.0 * base_lsb
compression = float(np.clip(1.0 - scale, 0.0, 1.0))
return (0.75 + 1.25 * compression) * base_lsb
def _estimate_global_scale(arr: np.ndarray, max_val: float) -> float:
"""
Estimate effective tonal span (0..1) from channel min/max.
Used for adaptive dither strength when the operation is applied
as a standalone post-process.
"""
mins = arr.reshape(-1, 3).min(axis=0)
maxs = arr.reshape(-1, 3).max(axis=0)
spans = np.clip((maxs - mins) / max_val, 0.0, 1.0)
return float(np.mean(spans))
def _tpdf_noise(shape: tuple[int, int, int], amplitude: float) -> np.ndarray:
"""Triangular PDF noise in [-amplitude, +amplitude], float32."""
h, w, c = shape
rng = np.random.default_rng()
u1 = rng.random((h, w, c), dtype=np.float32)
u2 = rng.random((h, w, c), dtype=np.float32)
return (u1 - u2) * amplitude
def _floyd_steinberg_quantize(arr: np.ndarray, max_val: float) -> np.ndarray:
"""Floyd-Steinberg error-diffusion quantization in current precision domain."""
work = np.clip(arr, 0.0, max_val).astype(np.float32, copy=True)
h, w, c = work.shape
for ch in range(c):
plane = work[:, :, ch]
for y in range(h):
for x in range(w):
old = plane[y, x]
new = np.clip(np.rint(old), 0.0, max_val)
err = old - new
plane[y, x] = new
if x + 1 < w:
plane[y, x + 1] += err * (7.0 / 16.0)
if y + 1 < h:
if x > 0:
plane[y + 1, x - 1] += err * (3.0 / 16.0)
plane[y + 1, x] += err * (5.0 / 16.0)
if x + 1 < w:
plane[y + 1, x + 1] += err * (1.0 / 16.0)
return np.clip(work, 0.0, max_val)
def _normalize_midpeaks_channel(
channel: np.ndarray,
peak_width: int,
max_val: float,
rng: np.random.Generator,
) -> np.ndarray:
"""
Histogram-aware anti-spike smoothing near empty bins (gaps),
adapted from levels_auto for standalone post-process use.
"""
n_bins = int(max_val) + 1
result = channel.copy()
c_int = np.clip(np.round(result).astype(np.int64), 0, int(max_val))
c_hist = np.bincount(c_int.ravel(), minlength=n_bins).astype(np.float64)
gap_arr = (c_hist == 0)
if not gap_arr.any():
return result
pad = peak_width
padded = np.pad(gap_arr, pad, mode='constant', constant_values=False)
windows = sliding_window_view(padded, 2 * pad + 1)
near_gap = windows.any(axis=1)
# Use a bit-depth-scaled TPDF noise amplitude similar to levels_auto.
amp = (peak_width / 2.0) * (max_val / 255.0)
half = amp / 2.0
noise = (
rng.uniform(-half, half, result.shape).astype(np.float32) +
rng.uniform(-half, half, result.shape).astype(np.float32)
)
qualify_mask = near_gap[c_int] & (~gap_arr[c_int])
return np.where(qualify_mask, np.clip(result + noise, 0.0, max_val), result)
def _normalize_gaps_legacy(
stretched: np.ndarray,
scale: float,
rng_gap: np.random.Generator,
max_val: float = 255.0,
) -> np.ndarray:
"""
Anti-comb filter — TPDF gap dithering.
Fills quantization gaps from non-integer stretch scale factors.
At 16-bit the gaps are far smaller (1/65535 vs 1/255) and largely
invisible, but dithering is still applied for completeness.
Amplitude formula is ratio-based so it works at any bit depth:
amplitude = max(1.0, (scale / 1.275) ^ 2.2) × (max_val / 255)
Args:
stretched : 2D float32 array
scale : stretch scale factor
rng_gap : np.random.Generator
max_val : 255.0 for 8-bit, 65535.0 for 16-bit
Returns:
Float32 array with gaps filled
"""
amplitude = max(1.0, (scale / 1.275) ** 2.2) * (max_val / 255.0)
half = amplitude / 2.0
noise = (rng_gap.uniform(-half, half, stretched.shape).astype(np.float32) +
rng_gap.uniform(-half, half, stretched.shape).astype(np.float32))
return np.clip(stretched + noise, 0.0, max_val)
def img_dithering(
image: Image.Image,
normalize_gaps_legacy: bool = False,
stretched_gaps_spike: list = [],
scale_spike: list = [],
rng_gap_spike: list = [],
dither_quantization: bool = True,
adaptive_dither_strength: bool = True,
error_diffusion: bool = False,
normalize_midpeaks: bool = False,
peak_width: int = 3,
high_precision: bool = False,
) -> Image.Image:
if not (1 <= peak_width <= 10):
raise ValueError(f"peak_width must be 1–10, got {peak_width}")
arr_8f = np.array(image.convert("RGB"), dtype=np.float32)
max_val = 65535.0 if high_precision else 255.0
scale_factor = max_val / 255.0
arr = arr_8f * scale_factor if high_precision else arr_8f
# 1) Legacy anti-comb (must run first when enabled and scale is known)
if normalize_gaps_legacy and len(stretched_gaps_spike) > 0:
for ch in range(3):
# arr = _normalize_gaps_legacy(arr, float(scale), rng_gap, max_val)
arr[:, :, ch] = _normalize_gaps_legacy(stretched_gaps_spike[:, :, ch], scale_spike[:, :, ch], rng_gap_spike[:, :, ch], max_val)
# 2) Mid-peak smoothing
if normalize_midpeaks:
for ch in range(3):
rng = np.random.default_rng(100 + ch)
arr[:, :, ch] = _normalize_midpeaks_channel(arr[:, :, ch], peak_width, max_val, rng)
# 3) Quantization path
if error_diffusion:
quantized = _floyd_steinberg_quantize(arr, max_val)
else:
quant_input = arr
if dither_quantization:
local_scale = float(scale) if scale is not None else _estimate_global_scale(quant_input, max_val)
amp = _adaptive_dither_amplitude(local_scale, adaptive_dither_strength, max_val)
quant_input = quant_input + _tpdf_noise(quant_input.shape, amp)
quantized = np.clip(np.rint(quant_input), 0, max_val)
out_8f = quantized * (255.0 / max_val) if high_precision else quantized
out_8 = np.clip(np.rint(out_8f), 0, 255).astype(np.uint8)
return Image.fromarray(out_8, mode="RGB")
+282 -175
View File
@@ -1,39 +1,11 @@
import numpy as np
from PIL import Image
# ── Film / Sensor preset library ─────────────────────────────────────────────
#
# All presets now include layered film physics parameters:
#
# Colour film (_CF) and B&W film (_BWF):
# "bias" : (R, G, B) multiplicative colour bias
# "hd" : Hurter-Driffield characteristic curve parameters per channel
# Each channel: {"toe": float, "gamma": float, "shoulder": float}
# toe = steepness of shadow compression (0.3–0.8)
# higher = more shadow detail compression
# gamma = midtone contrast / straight-line slope (0.7–1.4)
# higher = more contrast in midtones
# shoulder = steepness of highlight rolloff (0.3–0.9)
# higher = harder highlight rolloff (less blooming)
# "rolloff" : float 0–1, where highlight shoulder begins (luminance)
# lower = shoulder starts earlier (softer highlights overall)
# "shadow_lift" : (R, G, B) black point colour cast — the colour of film base
#
# Digital sensor (_CCD): unchanged structure, gains layered rendering too
#
# The H&D sigmoid function applied per channel:
# f(x) = shoulder_out / (1 + exp(-k_mid * (x - x0)))
# where toe/gamma/shoulder parameters set the shape of each zone.
FILM_PRESETS = {
# ─────────────────────────────────────────────────────────────────────────
# COLOUR FILMS (_CF)
# ─────────────────────────────────────────────────────────────────────────
"fuji_astia_100_CF": {
"desc": "Fuji Astia 100 — soft, low contrast, neutral skin tones, subtle colours",
"iso": 100, "grain_type": "fine", "grain_color": "color",
"bw": False, "type": "CF",
"bias": (1.00, 1.00, 0.97),
"rolloff": 0.80,
@@ -47,6 +19,7 @@ FILM_PRESETS = {
"fuji_provia_100_CF": {
"desc": "Fuji Provia 100F — standard/neutral, accurate colour, moderate contrast",
"iso": 100, "grain_type": "fine", "grain_color": "color",
"bw": False, "type": "CF",
"bias": (1.00, 1.01, 1.02),
"rolloff": 0.82,
@@ -60,6 +33,7 @@ FILM_PRESETS = {
"fuji_velvia_100_CF": {
"desc": "Fuji Velvia 100 — punchy, very saturated, high contrast, vivid greens and blues",
"iso": 100, "grain_type": "fine", "grain_color": "color",
"bw": False, "type": "CF",
"bias": (1.00, 1.03, 1.06),
"rolloff": 0.78,
@@ -73,6 +47,7 @@ FILM_PRESETS = {
"fuji_superia_400_CF": {
"desc": "Fuji Superia 400 — consumer negative, warm greens, slight grain character",
"iso": 400, "grain_type": "gaussian", "grain_color": "color",
"bw": False, "type": "CF",
"bias": (1.02, 1.03, 0.96),
"rolloff": 0.78,
@@ -86,6 +61,7 @@ FILM_PRESETS = {
"fuji_400h_CF": {
"desc": "Fuji 400H — soft highlights, cool shadows, popular portrait film",
"iso": 400, "grain_type": "gaussian", "grain_color": "color",
"bw": False, "type": "CF",
"bias": (0.99, 1.01, 1.04),
"rolloff": 0.72,
@@ -99,19 +75,21 @@ FILM_PRESETS = {
"kodak_kodachrome_64_CF": {
"desc": "Kodak Kodachrome 64 — iconic warm reds, deep blues, high contrast, rich shadows",
"iso": 64, "grain_type": "fine", "grain_color": "color",
"bw": False, "type": "CF",
"bias": (1.06, 0.98, 0.94),
"rolloff": 0.80,
"shadow_lift": (0.00, 0.00, 0.00),
"hd": {
"r": {"toe": 0.60, "gamma": 1.10, "shoulder": 0.72}, # red: high contrast
"g": {"toe": 0.45, "gamma": 0.95, "shoulder": 0.58}, # green: moderate
"b": {"toe": 0.42, "gamma": 0.90, "shoulder": 0.55}, # blue: slightly less
"r": {"toe": 0.60, "gamma": 1.10, "shoulder": 0.72},
"g": {"toe": 0.45, "gamma": 0.95, "shoulder": 0.58},
"b": {"toe": 0.42, "gamma": 0.90, "shoulder": 0.55},
},
},
"kodak_ektachrome_100vs_CF": {
"desc": "Kodak Ektachrome 100VS — very saturated, cool blues, strong greens",
"iso": 100, "grain_type": "fine", "grain_color": "color",
"bw": False, "type": "CF",
"bias": (0.98, 1.02, 1.05),
"rolloff": 0.79,
@@ -125,19 +103,21 @@ FILM_PRESETS = {
"kodak_portra_160_CF": {
"desc": "Kodak Portra 160 — warm skin tones, soft highlights, low contrast, fine grain",
"iso": 160, "grain_type": "fine", "grain_color": "color",
"bw": False, "type": "CF",
"bias": (1.04, 1.01, 0.95),
"rolloff": 0.70,
"shadow_lift": (0.04, 0.03, 0.03),
"hd": {
"r": {"toe": 0.35, "gamma": 0.82, "shoulder": 0.38}, # red: very soft
"r": {"toe": 0.35, "gamma": 0.82, "shoulder": 0.38},
"g": {"toe": 0.33, "gamma": 0.80, "shoulder": 0.36},
"b": {"toe": 0.30, "gamma": 0.76, "shoulder": 0.33}, # blue: softest
"b": {"toe": 0.30, "gamma": 0.76, "shoulder": 0.33},
},
},
"kodak_portra_400_CF": {
"desc": "Kodak Portra 400 — versatile portrait film, warm, slightly lifted shadows",
"iso": 400, "grain_type": "gaussian", "grain_color": "color",
"bw": False, "type": "CF",
"bias": (1.03, 1.00, 0.96),
"rolloff": 0.73,
@@ -151,6 +131,7 @@ FILM_PRESETS = {
"kodak_gold_200_CF": {
"desc": "Kodak Gold 200 — consumer film, warm golden tone, boosted yellows and reds",
"iso": 200, "grain_type": "gaussian", "grain_color": "color",
"bw": False, "type": "CF",
"bias": (1.05, 1.02, 0.92),
"rolloff": 0.76,
@@ -158,12 +139,13 @@ FILM_PRESETS = {
"hd": {
"r": {"toe": 0.45, "gamma": 0.95, "shoulder": 0.55},
"g": {"toe": 0.43, "gamma": 0.92, "shoulder": 0.52},
"b": {"toe": 0.32, "gamma": 0.78, "shoulder": 0.38}, # blue: compressed
"b": {"toe": 0.32, "gamma": 0.78, "shoulder": 0.38},
},
},
"kodak_ultramax_400_CF": {
"desc": "Kodak Ultramax 400 — vivid warm colours, punchy contrast, popular street film",
"iso": 400, "grain_type": "gaussian", "grain_color": "color",
"bw": False, "type": "CF",
"bias": (1.04, 1.01, 0.93),
"rolloff": 0.77,
@@ -177,6 +159,7 @@ FILM_PRESETS = {
"kodak_tri_x_400_CF": {
"desc": "Kodak Tri-X 400 — B&W look in colour, strong contrast, warm shadow tint",
"iso": 400, "grain_type": "organic", "grain_color": "color",
"bw": False, "type": "CF",
"bias": (1.02, 1.00, 0.97),
"rolloff": 0.80,
@@ -190,6 +173,7 @@ FILM_PRESETS = {
"agfa_vista_200_CF": {
"desc": "Agfa Vista 200 — cool shadows, slight blue-green tint, soft contrast",
"iso": 200, "grain_type": "gaussian", "grain_color": "color",
"bw": False, "type": "CF",
"bias": (0.97, 1.00, 1.04),
"rolloff": 0.79,
@@ -203,6 +187,7 @@ FILM_PRESETS = {
"lomography_lomo_100_CF": {
"desc": "Lomography 100 — high contrast, cross-process look, boosted saturation",
"iso": 100, "grain_type": "organic", "grain_color": "color",
"bw": False, "type": "CF",
"bias": (1.05, 0.97, 1.02),
"rolloff": 0.75,
@@ -216,6 +201,7 @@ FILM_PRESETS = {
"ilford_xp2_400_CF": {
"desc": "Ilford XP2 Super 400 — chromogenic B&W, neutral, clean shadows",
"iso": 400, "grain_type": "gaussian", "grain_color": "monochrome",
"bw": False, "type": "CF",
"bias": (1.00, 1.00, 1.00),
"rolloff": 0.83,
@@ -229,6 +215,7 @@ FILM_PRESETS = {
"kodak_vision3_500t_CF": {
"desc": "Kodak Vision3 500T — cinema negative, tungsten balanced, warm shadows, teal highlights",
"iso": 500, "grain_type": "gaussian", "grain_color": "color",
"bw": False, "type": "CF",
"bias": (1.03, 0.99, 0.96),
"rolloff": 0.74,
@@ -242,6 +229,7 @@ FILM_PRESETS = {
"fuji_eterna_250d_CF": {
"desc": "Fuji Eterna 250D — cinema film, daylight balanced, soft contrast, desaturated highlights",
"iso": 250, "grain_type": "fine", "grain_color": "color",
"bw": False, "type": "CF",
"bias": (0.99, 1.01, 1.03),
"rolloff": 0.72,
@@ -253,12 +241,9 @@ FILM_PRESETS = {
},
},
# ─────────────────────────────────────────────────────────────────────────
# B&W FILMS (_BWF)
# ─────────────────────────────────────────────────────────────────────────
"ilford_hp5_400_BWF": {
"desc": "Ilford HP5 Plus 400 — classic panchromatic, neutral grey, forgiving latitude",
"iso": 400, "grain_type": "organic", "grain_color": "monochrome",
"bw": True, "type": "BWF",
"mix": (0.299, 0.587, 0.114),
"tint": (1.00, 1.00, 1.00),
@@ -272,6 +257,7 @@ FILM_PRESETS = {
"ilford_delta_100_BWF": {
"desc": "Ilford Delta 100 — fine grain, cool neutral tone, excellent shadow detail",
"iso": 100, "grain_type": "fine", "grain_color": "monochrome",
"bw": True, "type": "BWF",
"mix": (0.28, 0.60, 0.12),
"tint": (0.98, 0.99, 1.01),
@@ -285,6 +271,7 @@ FILM_PRESETS = {
"ilford_delta_3200_BWF": {
"desc": "Ilford Delta 3200 — very high ISO, lifted shadows, compressed highlights",
"iso": 3200, "grain_type": "organic", "grain_color": "monochrome",
"bw": True, "type": "BWF",
"mix": (0.30, 0.59, 0.11),
"tint": (1.00, 1.00, 1.00),
@@ -298,6 +285,7 @@ FILM_PRESETS = {
"kodak_tmax_100_BWF": {
"desc": "Kodak T-Max 100 — ultra-fine grain, high contrast, deep clean blacks",
"iso": 100, "grain_type": "fine", "grain_color": "monochrome",
"bw": True, "type": "BWF",
"mix": (0.27, 0.62, 0.11),
"tint": (1.00, 1.00, 1.00),
@@ -311,6 +299,7 @@ FILM_PRESETS = {
"kodak_tmax_400_BWF": {
"desc": "Kodak T-Max 400 — fine grain for ISO 400, excellent tonal range",
"iso": 400, "grain_type": "fine", "grain_color": "monochrome",
"bw": True, "type": "BWF",
"mix": (0.28, 0.61, 0.11),
"tint": (1.00, 1.00, 1.00),
@@ -324,6 +313,7 @@ FILM_PRESETS = {
"kodak_tri_x_400_BWF": {
"desc": "Kodak Tri-X 400 B&W — iconic, punchy, deep blacks, photojournalism classic",
"iso": 400, "grain_type": "organic", "grain_color": "monochrome",
"bw": True, "type": "BWF",
"mix": (0.32, 0.58, 0.10),
"tint": (1.01, 1.00, 0.99),
@@ -337,6 +327,7 @@ FILM_PRESETS = {
"agfa_apx_100_BWF": {
"desc": "Agfa APX 100 — smooth midtones, slightly warm neutral, soft shadow gradation",
"iso": 100, "grain_type": "fine", "grain_color": "monochrome",
"bw": True, "type": "BWF",
"mix": (0.30, 0.59, 0.11),
"tint": (1.01, 1.00, 0.99),
@@ -350,6 +341,7 @@ FILM_PRESETS = {
"agfa_apx_400_BWF": {
"desc": "Agfa APX 400 — medium grain, contrasty midtones, green-sensitive",
"iso": 400, "grain_type": "organic", "grain_color": "monochrome",
"bw": True, "type": "BWF",
"mix": (0.29, 0.61, 0.10),
"tint": (1.00, 1.00, 1.00),
@@ -363,6 +355,7 @@ FILM_PRESETS = {
"rollei_rpx_400_BWF": {
"desc": "Rollei RPX 400 — very deep blacks, punchy street photography look",
"iso": 400, "grain_type": "organic", "grain_color": "monochrome",
"bw": True, "type": "BWF",
"mix": (0.30, 0.59, 0.11),
"tint": (1.00, 1.00, 1.00),
@@ -376,6 +369,7 @@ FILM_PRESETS = {
"fomapan_100_BWF": {
"desc": "Fomapan 100 — orthochromatic character, blue-sensitive, soft contrast, vintage look",
"iso": 100, "grain_type": "gaussian", "grain_color": "monochrome",
"bw": True, "type": "BWF",
"mix": (0.22, 0.55, 0.23),
"tint": (1.00, 1.00, 1.00),
@@ -389,6 +383,7 @@ FILM_PRESETS = {
"selenium_tone_BWF": {
"desc": "Selenium toning — cool blue-purple shadow tone, archival darkroom process",
"iso": 400, "grain_type": "organic", "grain_color": "monochrome",
"bw": True, "type": "BWF",
"mix": (0.299, 0.587, 0.114),
"tint": (0.96, 0.97, 1.04),
@@ -402,6 +397,7 @@ FILM_PRESETS = {
"sepia_tone_BWF": {
"desc": "Sepia toning — warm brown throughout, classic Victorian / vintage look",
"iso": 400, "grain_type": "organic", "grain_color": "monochrome",
"bw": True, "type": "BWF",
"mix": (0.299, 0.587, 0.114),
"tint": (1.08, 1.00, 0.82),
@@ -415,6 +411,7 @@ FILM_PRESETS = {
"gold_tone_BWF": {
"desc": "Gold toning — warm golden highlights, cooler shadows, elegant darkroom effect",
"iso": 400, "grain_type": "organic", "grain_color": "monochrome",
"bw": True, "type": "BWF",
"mix": (0.299, 0.587, 0.114),
"tint": (1.05, 1.02, 0.88),
@@ -428,6 +425,7 @@ FILM_PRESETS = {
"cyanotype_BWF": {
"desc": "Cyanotype — deep cyan-blue alternative process print look",
"iso": 400, "grain_type": "gaussian", "grain_color": "monochrome",
"bw": True, "type": "BWF",
"mix": (0.299, 0.587, 0.114),
"tint": (0.78, 0.90, 1.15),
@@ -441,6 +439,7 @@ FILM_PRESETS = {
"platinum_palladium_BWF": {
"desc": "Platinum/Palladium print — long tonal scale, subtle warm neutral, rich shadow detail",
"iso": 400, "grain_type": "fine", "grain_color": "monochrome",
"bw": True, "type": "BWF",
"mix": (0.299, 0.587, 0.114),
"tint": (1.02, 1.01, 0.99),
@@ -452,12 +451,9 @@ FILM_PRESETS = {
"b": {"toe": 0.33, "gamma": 0.80, "shoulder": 0.42}},
},
# ─────────────────────────────────────────────────────────────────────────
# DIGITAL SENSORS (_CCD) — unchanged structure
# ─────────────────────────────────────────────────────────────────────────
"canon_5d_mark2_CCD": {
"desc": "Canon 5D Mark II — warm romantic colour, gentle highlight rolloff, smooth skin tones",
"iso": 100, "grain_type": "fine", "grain_color": "monochrome",
"bw": False, "type": "CCD",
"bias": (1.04, 1.00, 0.97),
"matrix": [[1.06,-0.04,-0.02],[-0.03,1.04,-0.01],[-0.02,-0.06,1.08]],
@@ -472,6 +468,7 @@ FILM_PRESETS = {
"canon_5d_mark1_CCD": {
"desc": "Canon 5D Mark I — original full-frame CCD, warm character, pleasing colour",
"iso": 100, "grain_type": "fine", "grain_color": "monochrome",
"bw": False, "type": "CCD",
"bias": (1.05, 1.00, 0.95),
"matrix": [[1.08,-0.05,-0.03],[-0.03,1.05,-0.02],[-0.03,-0.07,1.10]],
@@ -486,6 +483,7 @@ FILM_PRESETS = {
"canon_1dx_CCD": {
"desc": "Canon 1Dx — professional sports/press, accurate neutral colour, punchy contrast",
"iso": 100, "grain_type": "fine", "grain_color": "monochrome",
"bw": False, "type": "CCD",
"bias": (1.02, 1.00, 0.99),
"matrix": [[1.04,-0.02,-0.02],[-0.02,1.03,-0.01],[-0.01,-0.04,1.05]],
@@ -500,6 +498,7 @@ FILM_PRESETS = {
"sony_a7iii_CCD": {
"desc": "Sony A7 III — neutral accurate colour, cool shadow character, high dynamic range",
"iso": 100, "grain_type": "fine", "grain_color": "monochrome",
"bw": False, "type": "CCD",
"bias": (1.00, 1.01, 1.02),
"matrix": [[1.02,-0.01,-0.01],[-0.01,1.03,0.00],[0.00,-0.02,1.02]],
@@ -514,6 +513,7 @@ FILM_PRESETS = {
"sony_a7rii_CCD": {
"desc": "Sony A7R II — very high resolution, neutral-cool, extremely detailed",
"iso": 100, "grain_type": "fine", "grain_color": "monochrome",
"bw": False, "type": "CCD",
"bias": (0.99, 1.01, 1.03),
"matrix": [[1.01,-0.01,0.00],[-0.01,1.03,0.00],[0.00,-0.02,1.02]],
@@ -528,6 +528,7 @@ FILM_PRESETS = {
"nikon_d800_CCD": {
"desc": "Nikon D800 — neutral accurate, slightly cool shadows, excellent detail",
"iso": 100, "grain_type": "fine", "grain_color": "monochrome",
"bw": False, "type": "CCD",
"bias": (1.00, 1.01, 1.01),
"matrix": [[1.03,-0.02,-0.01],[-0.01,1.03,0.00],[0.00,-0.03,1.03]],
@@ -542,6 +543,7 @@ FILM_PRESETS = {
"nikon_d3_CCD": {
"desc": "Nikon D3 — warm classic DSLR rendering, photojournalism standard",
"iso": 200, "grain_type": "fine", "grain_color": "monochrome",
"bw": False, "type": "CCD",
"bias": (1.03, 1.01, 0.97),
"matrix": [[1.05,-0.03,-0.02],[-0.02,1.04,-0.01],[-0.01,-0.05,1.06]],
@@ -556,6 +558,7 @@ FILM_PRESETS = {
"fuji_xt3_CCD": {
"desc": "Fuji X-T3 — film-simulation-inspired colour science, warm midtones, X-Trans",
"iso": 160, "grain_type": "fine", "grain_color": "monochrome",
"bw": False, "type": "CCD",
"bias": (1.03, 1.01, 0.98),
"matrix": [[1.05,-0.03,-0.02],[-0.02,1.05,-0.02],[-0.01,-0.04,1.05]],
@@ -570,6 +573,7 @@ FILM_PRESETS = {
"fuji_gfx_CCD": {
"desc": "Fuji GFX 100 — medium format digital, very neutral, exceptional tonal gradation",
"iso": 100, "grain_type": "fine", "grain_color": "monochrome",
"bw": False, "type": "CCD",
"bias": (1.01, 1.01, 1.00),
"matrix": [[1.02,-0.01,-0.01],[-0.01,1.03,-0.01],[0.00,-0.02,1.02]],
@@ -584,6 +588,7 @@ FILM_PRESETS = {
"leica_m9_CCD": {
"desc": "Leica M9 CCD — iconic true CCD sensor, warm romantic colour, beautiful highlight glow",
"iso": 160, "grain_type": "fine", "grain_color": "monochrome",
"bw": False, "type": "CCD",
"bias": (1.05, 1.01, 0.94),
"matrix": [[1.07,-0.04,-0.03],[-0.03,1.05,-0.02],[-0.02,-0.08,1.10]],
@@ -598,6 +603,7 @@ FILM_PRESETS = {
"leica_m11_CCD": {
"desc": "Leica M11 CMOS — modern Leica, very neutral and clinical, faithful colour science",
"iso": 100, "grain_type": "fine", "grain_color": "monochrome",
"bw": False, "type": "CCD",
"bias": (1.01, 1.01, 1.00),
"matrix": [[1.02,-0.01,-0.01],[-0.01,1.02,0.00],[0.00,-0.02,1.02]],
@@ -612,6 +618,7 @@ FILM_PRESETS = {
"hasselblad_x2d_CCD": {
"desc": "Hasselblad X2D — 100MP medium format, clinical precision, very wide tonal range",
"iso": 100, "grain_type": "fine", "grain_color": "monochrome",
"bw": False, "type": "CCD",
"bias": (1.00, 1.01, 1.01),
"matrix": [[1.01,0.00,-0.01],[0.00,1.02,0.00],[0.00,-0.01,1.01]],
@@ -626,6 +633,7 @@ FILM_PRESETS = {
"olympus_omd_CCD": {
"desc": "Olympus OM-D E-M1 — punchy vivid colour, slightly cool, contrasty rendering",
"iso": 200, "grain_type": "fine", "grain_color": "monochrome",
"bw": False, "type": "CCD",
"bias": (1.01, 1.02, 1.02),
"matrix": [[1.03,-0.01,-0.02],[-0.01,1.04,-0.01],[-0.01,-0.02,1.03]],
@@ -639,41 +647,12 @@ FILM_PRESETS = {
},
}
# ─────────────────────────────────────────────────────────────────────────────
# Main function
# ─────────────────────────────────────────────────────────────────────────────
def img_film_rendering(
image: Image.Image,
rendering: str = "kodak_kodachrome_64_CF",
intensity: float = 100,
add_grain: bool = False,
) -> Image.Image:
"""
Film stock and digital sensor rendering simulation.
Colour film (_CF) and B&W film (_BWF) presets use a layered H&D pipeline:
1. Colour bias (sensor/emulsion spectral response)
2. Per-channel Hurter-Driffield sigmoid curve (toe / gamma / shoulder)
with differential channel response — each channel has its own
characteristic curve producing real colour separation across tones
3. Smooth highlight rolloff shoulder (not hard clip)
4. Shadow lift / film base colour cast
Digital sensor (_CCD) presets use the existing matrix pipeline.
Args:
image : PIL Image (RGB)
rendering : Preset name (see FILM_PRESETS keys)
intensity : 0 … 200.
0 = passthrough.
100 = full preset rendering.
101–200 = overdrive — extrapolates beyond the preset
for a more dramatic effect. 200 = double the difference
from original. User can dial back if too strong.
Returns:
PIL Image (RGB)
"""
if intensity == 0:
return image.convert("RGB")
@@ -682,57 +661,39 @@ def img_film_rendering(
raise ValueError(f"Unknown rendering '{rendering}'. Valid: {valid}")
if not (0 <= intensity <= 200):
raise ValueError(f"intensity must be 0–200, got {intensity}")
raise ValueError(f"intensity must be 0-200, got {intensity}")
preset = FILM_PRESETS[rendering]
img = image.convert("RGB")
arr = np.array(img, dtype=np.float32) / 255.0
orig = arr.copy()
preset_base = FILM_PRESETS[rendering]
img = image.convert("RGB")
arr = np.array(img, dtype=np.float32) / 255.0
orig = arr.copy()
H, W = arr.shape[:2]
if preset["type"] == "BWF":
analysis = _analyse_image(arr)
preset = _adapt_preset(preset_base, analysis)
if preset_base["type"] == "BWF":
arr_out = _apply_bw(arr, preset)
elif preset["type"] == "CCD":
elif preset_base["type"] == "CCD":
arr_out = _apply_sensor(arr, preset)
else:
arr_out = _apply_colour(arr, preset)
# ── Blend with overdrive support ──────────────────────────────────────────
# CF and CCD: intensity 0–200 extrapolates continuously.
# result = orig + blend * (arr_out - orig)
# blend=1.0 (100) = full preset, blend=2.0 (200) = double push
#
# BWF: intensity 0–100 = normal blend to greyscale.
# intensity 101–200 = push-processing simulation on the greyscale output.
# Stays fully greyscale — applies increasing contrast and shadow
# compression to arr_out, simulating darkroom push-processing of B&W film.
# No colour is reintroduced at any intensity value.
if preset["type"] == "BWF" and intensity > 100:
# First blend to full greyscale at intensity=100
if preset_base["type"] == "BWF" and intensity > 100:
result = arr_out.copy()
# Push amount: 0.0 at intensity=100, 1.0 at intensity=200
push = (intensity - 100) / 100.0
# Push-processing effect on greyscale:
# - Increase contrast (S-curve steepening)
# - Compress shadows further (deeper blacks)
# - Protect highlights (slight shoulder compression)
# Applied to the greyscale channel (all three are equal in BWF output)
grey = result[..., 0]
# Contrast boost: push midtones away from 0.5, steepen the S-curve
contrast_factor = 1.0 + push * 0.8
grey = np.clip((grey - 0.5) * contrast_factor + 0.5, 0.0, 1.0)
# Shadow compression: pull dark tones down further (deeper blacks)
shadow_power = 1.0 + push * 0.6 # >1 = shadow compression
shadow_power = 1.0 + push * 0.6
grey = np.power(np.clip(grey, 0.0, 1.0), shadow_power)
# Highlight rolloff: gentle compression to avoid blowout
hi_push = 0.85 - push * 0.10 # rolloff starts earlier as push increases
hi_push = 0.85 - push * 0.10
hi_mask = np.clip((grey - hi_push) / (1.0 - hi_push + 1e-6), 0.0, 1.0)
grey = np.where(grey > hi_push,
hi_push + (1.0 - hi_push) * (1.0 - (1.0 - hi_mask) ** 2),
grey)
grey = np.clip(grey, 0.0, 1.0)
# Apply toning tint if present (for selenium, sepia, etc.)
tint = np.array(preset.get("tint", (1.0, 1.0, 1.0)), dtype=np.float32)
tint = np.array(preset_base.get("tint", (1.0, 1.0, 1.0)), dtype=np.float32)
result = np.stack([grey * tint[0], grey * tint[1], grey * tint[2]], axis=-1)
result = np.clip(result, 0.0, 1.0)
else:
@@ -740,72 +701,246 @@ def img_film_rendering(
result = orig + blend * (arr_out - orig)
result = np.clip(result, 0.0, 1.0)
if add_grain:
result = _apply_grain(result, preset_base, H, W)
return Image.fromarray((result * 255).astype(np.uint8), mode="RGB")
def list_film_presets() -> dict:
"""Returns {preset_name: description} for all presets."""
return {k: v["desc"] for k, v in FILM_PRESETS.items()}
def list_presets_by_type() -> dict:
"""Returns presets grouped by type: {"CF": [...], "BWF": [...], "CCD": [...]}"""
result = {"CF": [], "BWF": [], "CCD": []}
for k, v in FILM_PRESETS.items():
result[v["type"]].append(k)
return result
def _analyse_image(arr: np.ndarray) -> dict:
# ─────────────────────────────────────────────────────────────────────────────
# H&D characteristic curve
# ─────────────────────────────────────────────────────────────────────────────
lum = 0.299 * arr[..., 0] + 0.587 * arr[..., 1] + 0.114 * arr[..., 2]
lum_flat = lum.ravel()
median_lum = float(np.median(lum_flat))
lum_std = float(lum_flat.std())
shadow_fraction = float((lum_flat < 0.2).mean())
highlight_fraction = float((lum_flat > 0.8).mean())
midtone_fraction = float(((lum_flat >= 0.2) & (lum_flat <= 0.8)).mean())
p05, p95 = float(np.percentile(lum_flat, 5)), float(np.percentile(lum_flat, 95))
dynamic_range = p95 - p05
ch_means = np.array([arr[..., c].mean() for c in range(3)], dtype=np.float32)
grey_mean = float(ch_means.mean())
dominant_cast = (ch_means - grey_mean) / (grey_mean + 1e-6)
ch_max = arr.max(axis=-1)
ch_min = arr.min(axis=-1)
mean_saturation = float(((ch_max - ch_min) / (ch_max + 1e-6)).mean())
return {
"median_lum": median_lum,
"lum_std": lum_std,
"shadow_fraction": shadow_fraction,
"highlight_fraction": highlight_fraction,
"midtone_fraction": midtone_fraction,
"mean_saturation": mean_saturation,
"dominant_cast": dominant_cast,
"dynamic_range": dynamic_range,
"is_lowkey": median_lum < 0.42,
"is_highkey": median_lum > 0.58,
"is_flat": lum_std < 0.12,
"is_desaturated": mean_saturation < 0.08,
}
def _adapt_preset(preset: dict, analysis: dict) -> dict:
import copy
p = copy.deepcopy(preset)
median = analysis["median_lum"]
std = analysis["lum_std"]
hi_frac = analysis["highlight_fraction"]
sh_frac = analysis["shadow_fraction"]
cast = analysis["dominant_cast"]
desat = analysis["is_desaturated"]
flat = analysis["is_flat"]
if "hd" in p:
if flat:
gamma_scale = 1.10
elif std > 0.28:
gamma_scale = 0.93
else:
gamma_scale = 1.0
if gamma_scale != 1.0:
for ch_key in p["hd"]:
p["hd"][ch_key]["gamma"] = float(
np.clip(p["hd"][ch_key]["gamma"] * gamma_scale, 0.5, 1.8))
if analysis["is_lowkey"] and "hd" in p:
toe_scale = 0.88
for ch_key in p["hd"]:
p["hd"][ch_key]["toe"] = float(
np.clip(p["hd"][ch_key]["toe"] * toe_scale, 0.2, 0.85))
if analysis["is_highkey"] and "hd" in p:
sh_scale = 1.08
for ch_key in p["hd"]:
p["hd"][ch_key]["shoulder"] = float(
np.clip(p["hd"][ch_key]["shoulder"] * sh_scale, 0.25, 0.95))
if "rolloff" in p:
if hi_frac < 0.05:
p["rolloff"] = float(max(p["rolloff"] - 0.06, 0.55))
if "shadow_lift" in p:
lift = np.array(p["shadow_lift"], dtype=np.float32)
if sh_frac < 0.05:
lift = np.clip(lift * 1.4, 0.0, 0.15)
elif sh_frac > 0.50:
lift = lift * 0.75
p["shadow_lift"] = tuple(float(v) for v in lift)
if "bias" in p and not p.get("bw", False):
bias = np.array(p["bias"], dtype=np.float32)
compensation = cast * 0.30
bias = np.clip(bias - compensation, 0.7, 1.4)
p["bias"] = tuple(float(v) for v in bias)
if desat and "bias" in p and not p.get("bw", False):
bias = np.array(p["bias"], dtype=np.float32)
deviation = bias - 1.0
bias = np.clip(1.0 + deviation * 1.5, 0.7, 1.4)
p["bias"] = tuple(float(v) for v in bias)
return p
_ISO_GRAIN = {
25: (5, 0.55),
50: (7, 0.60),
64: (9, 0.65),
100: (12, 0.70),
160: (15, 0.80),
200: (18, 0.90),
250: (20, 0.95),
400: (26, 1.10),
500: (30, 1.20),
800: (38, 1.50),
1600: (50, 2.00),
3200: (65, 2.80),
}
_ISO_REFERENCE_AREA = 1920 * 1280
def _make_grain_params(preset: dict, H: int, W: int) -> dict:
iso = preset.get("iso", 400)
grain_type = preset.get("grain_type", "gaussian")
grain_color = preset.get("grain_color", "monochrome")
iso_keys = sorted(_ISO_GRAIN.keys())
nearest = min(iso_keys, key=lambda k: abs(k - iso))
intensity, base_size = _ISO_GRAIN[nearest]
area_scale = np.sqrt((H * W) / _ISO_REFERENCE_AREA)
grain_size = float(np.clip(base_size * area_scale, 0.5, 8.0))
shadow_strength = 1.3 if preset.get("bw", False) else 1.0
highlight_strength = 0.2 if preset.get("bw", False) else 0.3
color_tint = "neutral"
if preset["type"] == "CCD":
color_tint = "cool"
intensity = max(3, intensity // 3)
grain_size = float(np.clip(grain_size * 0.5, 0.5, 3.0))
return {
"intensity": float(intensity),
"grain_size": grain_size,
"grain_type": grain_type,
"color_mode": grain_color,
"color_tint": color_tint,
"shadow_strength": shadow_strength,
"highlight_strength": highlight_strength,
"midtone_peak": 0.4,
}
def _apply_grain(arr: np.ndarray, preset: dict, H: int, W: int) -> np.ndarray:
from scipy.ndimage import gaussian_filter
params = _make_grain_params(preset, H, W)
iso = preset.get("iso", 400)
gt = params["grain_type"]
cm = params["color_mode"]
rng = np.random.default_rng(None)
sigma = (params["intensity"] / 255.0 * 40.0) / 255.0
gs = params["grain_size"]
mp = params["midtone_peak"]
lum = 0.299*arr[...,0] + 0.587*arr[...,1] + 0.114*arr[...,2]
bell = np.exp(-0.5 * ((lum - mp) / 0.28) ** 2)
shadow_mask = np.clip(1.0 - lum / (mp + 1e-6), 0, 1)
highlight_mask = np.clip((lum - mp) / (1.0 - mp + 1e-6), 0, 1)
lum_mask = bell * (1.0 + shadow_mask * (params["shadow_strength"] - 1.0) + highlight_mask * (params["highlight_strength"] - 1.0))
lum_mask = np.clip(lum_mask, 0, None)
def make_noise(shape):
raw = rng.standard_normal(shape).astype(np.float32)
if gt == "gaussian":
if gs > 0.6:
raw = gaussian_filter(raw, sigma=gs * 0.5)
elif gt == "organic":
coarse = gaussian_filter(
rng.standard_normal(shape).astype(np.float32), sigma=gs * 2.0)
fine = gaussian_filter(raw, sigma=gs * 0.3)
raw = coarse * 0.6 + fine * 0.4
elif gt == "fine":
raw = gaussian_filter(raw, sigma=max(0.3, gs * 0.2))
return raw
if cm == "monochrome":
base = make_noise((H, W))
nr = ng = nb = base
else:
nr = make_noise((H, W))
nb = make_noise((H, W))
if gs > 0.6 and gt == "gaussian":
ng = gaussian_filter(rng.standard_normal((H,W)).astype(np.float32),
sigma=gs * 0.35)
else:
ng = make_noise((H, W))
if params["color_tint"] == "cool":
tr, tg, tb = 0.80, 0.95, 1.25
else:
tr = tg = tb = 1.0
out = arr.copy()
out[..., 0] = np.clip(arr[..., 0] + nr * sigma * lum_mask * tr, 0, 1)
out[..., 1] = np.clip(arr[..., 1] + ng * sigma * lum_mask * tg, 0, 1)
out[..., 2] = np.clip(arr[..., 2] + nb * sigma * lum_mask * tb, 0, 1)
return out
def _hd_curve(x: np.ndarray, toe: float, gamma: float, shoulder: float) -> np.ndarray:
"""
Hurter-Driffield sigmoid characteristic curve for one emulsion layer.
Models the three zones of real film response:
Toe — shadow compression, low contrast, detail preservation
Straight — midtone linear region, main contrast zone
Shoulder — highlight compression, smooth rolloff, no hard clipping
Implementation: piecewise sigmoid blend
toe zone: sigmoid centred at 0, steepness = toe * 8
straight zone: linear ramp with slope gamma
shoulder zone: sigmoid centred at 1, steepness = shoulder * 8
Args:
x : input values 0–1
toe : shadow steepness / compression (0.3 = soft, 0.7 = hard)
gamma : midtone contrast slope (0.7 = low, 1.3 = high)
shoulder : highlight compression steepness (0.3 = gentle, 0.8 = abrupt)
Returns:
output values 0–1
"""
x = np.clip(x, 0.0, 1.0)
# ── Toe sigmoid (shadow zone) ─────────────────────────────────────────────
# Maps 0 → 0, pulls shadow tones upward gently
k_toe = toe * 10.0
toe_out = 1.0 / (1.0 + np.exp(-k_toe * (x - toe * 0.5)))
toe_out = toe_out - (1.0 / (1.0 + np.exp(-k_toe * (0.0 - toe * 0.5))))
toe_out = toe_out / (1.0 / (1.0 + np.exp(-k_toe * (1.0 - toe * 0.5))) -
1.0 / (1.0 + np.exp(-k_toe * (0.0 - toe * 0.5))) + 1e-8)
# ── Straight line (midtone zone) ──────────────────────────────────────────
# Linear with slope gamma, pivoted at midpoint (0.5, 0.5)
straight = np.clip(0.5 + gamma * (x - 0.5), 0.0, 1.0)
# ── Shoulder sigmoid (highlight zone) ────────────────────────────────────
# Maps 1 → 1, compresses highlights gently
k_sh = shoulder * 10.0
sh_offset = 1.0 - shoulder * 0.5
sh_out = 1.0 / (1.0 + np.exp(-k_sh * (x - sh_offset)))
sh_out = sh_out / (1.0 / (1.0 + np.exp(-k_sh * (1.0 - sh_offset))) + 1e-8)
# ── Blend zones by luminance ──────────────────────────────────────────────
# Smooth blend weights: toe dominates in shadows, shoulder in highlights,
# straight line in midtones
w_toe = np.clip(1.0 - x / 0.4, 0.0, 1.0) ** 2
w_sh = np.clip((x - 0.6) / 0.4, 0.0, 1.0) ** 2
w_mid = 1.0 - w_toe - w_sh
@@ -813,42 +948,24 @@ def _hd_curve(x: np.ndarray, toe: float, gamma: float, shoulder: float) -> np.nd
result = w_toe * toe_out + w_mid * straight + w_sh * sh_out
return np.clip(result, 0.0, 1.0)
# ─────────────────────────────────────────────────────────────────────────────
# Rendering pipelines
# ─────────────────────────────────────────────────────────────────────────────
def _apply_lut(channel: np.ndarray, pts: list) -> np.ndarray:
"""Legacy LUT for CCD presets."""
pts_arr = np.array(pts, dtype=np.float64)
lut = np.interp(np.linspace(0, 1, 256), pts_arr[:,0], pts_arr[:,1]).astype(np.float32)
indices = np.clip((channel * 255).astype(np.int32), 0, 255)
return lut[indices]
def _apply_colour(arr: np.ndarray, preset: dict) -> np.ndarray:
"""
Colour film H&D pipeline:
1. Colour bias
2. Per-channel H&D sigmoid with differential toe/shoulder
(channels respond differently across tonal zones → real colour separation)
3. Highlight shoulder rolloff
4. Shadow lift
"""
# ── 1. Colour bias ────────────────────────────────────────────────────────
bias = np.array(preset["bias"], dtype=np.float32)
arr = np.clip(arr * bias, 0.0, 1.0)
# ── 2. Per-channel H&D curve ──────────────────────────────────────────────
hd = preset["hd"]
R = _hd_curve(arr[..., 0], **hd["r"])
G = _hd_curve(arr[..., 1], **hd["g"])
B = _hd_curve(arr[..., 2], **hd["b"])
arr = np.stack([R, G, B], axis=-1)
# ── 3. Smooth highlight rolloff ───────────────────────────────────────────
# Applied after the H&D curve, in the output domain.
# Uses a smooth quadratic shoulder rather than hard clip.
rolloff = preset.get("rolloff", 0.80)
hi_start = rolloff
if hi_start < 1.0:
@@ -856,23 +973,14 @@ def _apply_colour(arr: np.ndarray, preset: dict) -> np.ndarray:
hi_compressed = hi_start + (1.0 - hi_start) * (1.0 - (1.0 - hi_mask) ** 2)
arr = np.where(arr > hi_start, hi_compressed, arr)
# ── 4. Shadow lift / film base colour ─────────────────────────────────────
lift = np.array(preset.get("shadow_lift", (0.0, 0.0, 0.0)), dtype=np.float32)
if lift.any():
arr = arr + lift * (1.0 - arr)
return np.clip(arr, 0.0, 1.0)
def _apply_bw(arr: np.ndarray, preset: dict) -> np.ndarray:
"""
B&W film H&D pipeline:
1. Spectral sensitivity channel mix
2. H&D curve on the grey channel
3. Highlight rolloff
4. Shadow lift
5. Chemical toning tint
"""
mix = preset["mix"]
grey = np.clip(arr[...,0]*mix[0] + arr[...,1]*mix[1] + arr[...,2]*mix[2], 0.0, 1.0)
@@ -896,9 +1004,8 @@ def _apply_bw(arr: np.ndarray, preset: dict) -> np.ndarray:
grey_lifted * tint[2],
], axis=-1), 0.0, 1.0)
def _apply_sensor(arr: np.ndarray, preset: dict) -> np.ndarray:
"""Digital sensor pipeline — unchanged from previous version."""
linear = np.where(arr <= 0.04045, arr/12.92, ((arr+0.055)/1.055)**2.4)
bias = np.array(preset["bias"], dtype=np.float32)
linear = np.clip(linear * bias, 0, 1)
+17 -78
View File
@@ -130,73 +130,6 @@ def levels_edge_spread(
return result
def levels_normalize_midpeaks(
stretched: np.ndarray,
peak_width: int,
rng_spike: np.random.Generator,
max_val: float = 255.0,
) -> np.ndarray:
"""
Anti-spike filter — smooths histogram bins near quantization gaps.
A bin qualifies as a peak if it has at least one zero bin within
peak_width positions. Targeted TPDF dithering is applied to qualifying
pixels using a single pre-generated noise field (not per-bin), making
the operation O(1) in the number of qualifying bins.
Args:
stretched : 2D float32 array after edge spread
peak_width : 1–10, distance from a gap that qualifies a bin
rng_spike : np.random.Generator (independent from gap dithering)
max_val : 255.0 for 8-bit, 65535.0 for 16-bit
Returns:
Float32 array with peak bins redistributed
"""
n_bins = int(max_val) + 1
result = stretched.copy()
s_int = np.clip(np.round(result).astype(np.int64), 0, int(max_val))
s_hist = np.bincount(s_int.ravel(), minlength=n_bins).astype(np.float64)
# Mid-range: exclude edge-spread zones (bins 0–edge and max-edge–max)
edge_bins = int(EDGE_SPREAD_RATIO * max_val) + 1
lo = edge_bins
hi = n_bins - edge_bins
gap_bins = set(int(b) for b in range(lo, hi) if s_hist[b] == 0)
if not gap_bins:
return result
amp = (peak_width / 2.0) * (max_val / 255.0) # scale amplitude with bit depth
half = amp / 2.0
# Generate noise once for the full channel
noise = (rng_spike.uniform(-half, half, result.shape).astype(np.float32) +
rng_spike.uniform(-half, half, result.shape).astype(np.float32))
# Vectorized near-gap detection via sliding window
gap_arr = np.zeros(n_bins, dtype=bool)
for g in gap_bins:
gap_arr[g] = True
from numpy.lib.stride_tricks import sliding_window_view
pad = peak_width
padded = np.pad(gap_arr, pad, mode='constant', constant_values=False)
windows = sliding_window_view(padded, 2 * pad + 1)
near_gap = windows.any(axis=1) # shape (n_bins,)
# Build mask: all pixels in qualifying non-gap bins
qualify_mask = np.zeros(result.shape, dtype=bool)
for b in range(lo, hi):
if s_hist[b] == 0 or not near_gap[b]:
continue
qualify_mask |= (s_int == b)
result = np.where(qualify_mask, np.clip(result + noise, 0.0, max_val), result)
return result
def levels_normalize_gaps(
stretched: np.ndarray,
scale: float,
@@ -275,12 +208,12 @@ def levels_auto_gamma(
def img_levels_auto(
image: Image.Image,
auto_normalize: bool = True,
auto_normalize: bool = False,
threshold: float = 2.0,
normalize_gaps: bool = True,
normalize_midpeaks: bool = False,
peak_width: int = 3,
auto_gamma: bool = True,
# normalize_gaps: bool = False,
# normalize_midpeaks: bool = False,
# peak_width: int = 3,
auto_gamma: bool = False,
gamma_target: float = 128.0,
precision: bool = False,
) -> Image.Image:
@@ -340,6 +273,9 @@ def img_levels_auto(
6. levels_auto_gamma — gamma correction (if auto_gamma)
"""
img = image.convert("RGB")
stretched_gaps_spike = []
scale_spike = []
rng_gap_spike = []
if not auto_normalize:
return img
@@ -348,8 +284,8 @@ def img_levels_auto(
raise ValueError(f"threshold must be 0.0–100.0, got {threshold}")
if not (0.0 <= gamma_target <= 255.0):
raise ValueError(f"gamma_target must be 0–255, got {gamma_target}")
if not (1 <= peak_width <= 10):
raise ValueError(f"peak_width must be 1–10, got {peak_width}")
# if not (1 <= peak_width <= 10):
# raise ValueError(f"peak_width must be 1–10, got {peak_width}")
# ── Bit depth configuration ───────────────────────────────────────────────
max_val = 65535.0 if precision else 255.0
@@ -366,7 +302,7 @@ def img_levels_auto(
for ch in range(3):
rng_gap = np.random.default_rng(ch)
rng_spike = np.random.default_rng(ch + 100)
# rng_spike = np.random.default_rng(ch + 100)
channel = arr[:, :, ch]
@@ -380,12 +316,15 @@ def img_levels_auto(
stretched = levels_edge_spread(channel, stretched, black_point, white_point, max_val)
# 4. Peak smoothing (before gap dithering)
if normalize_midpeaks:
stretched = levels_normalize_midpeaks(stretched, peak_width, rng_spike, max_val)
# if normalize_midpeaks:
# stretched = levels_normalize_midpeaks(stretched, peak_width, rng_spike, max_val)
# 5. Gap dithering
if normalize_gaps:
''' if normalize_gaps:
stretched = levels_normalize_gaps(stretched, scale, rng_gap, max_val)
stretched_gaps_spike.append(stretched)
scale_spike.append(scale)
rng_gap_spike.append(rng_gap) '''
# 6. Auto gamma
if auto_gamma:
+3 -2
View File
@@ -139,8 +139,9 @@ def img_levels_compress(
# Convert back to uint8
if high_precision:
out_8 = np.clip(out * (255.0 / max_val), 0, 255).astype(np.uint8)
out_8f = np.clip(out * (255.0 / max_val), 0, 255)
else:
out_8 = np.clip(out, 0, 255).astype(np.uint8)
out_8f = np.clip(out, 0, 255)
out_8 = np.clip(np.rint(out_8f), 0, 255).astype(np.uint8)
return Image.fromarray(out_8, mode="RGB")