From ad6bcc50fa2fe401eda6647f2d9152a7aa00d013 Mon Sep 17 00:00:00 2001 From: "DESKTOP-TVBJISQ\\Primere" Date: Tue, 24 Mar 2026 17:52:08 +0100 Subject: [PATCH] V 2.0.0 - Rasterix - new film rendering --- Nodes/Dashboard.py | 46 ++- components/images/img_dithering.py | 177 +++++++++ components/images/img_film_rendering.py | 457 ++++++++++++++--------- components/images/img_levels_auto.py | 95 +---- components/images/img_levels_compress.py | 5 +- 5 files changed, 507 insertions(+), 273 deletions(-) create mode 100644 components/images/img_dithering.py diff --git a/Nodes/Dashboard.py b/Nodes/Dashboard.py index 16a7fda..e9e2453 100644 --- a/Nodes/Dashboard.py +++ b/Nodes/Dashboard.py @@ -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 = {} diff --git a/components/images/img_dithering.py b/components/images/img_dithering.py new file mode 100644 index 0000000..6dec580 --- /dev/null +++ b/components/images/img_dithering.py @@ -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") diff --git a/components/images/img_film_rendering.py b/components/images/img_film_rendering.py index 73e44a8..f47db1f 100644 --- a/components/images/img_film_rendering.py +++ b/components/images/img_film_rendering.py @@ -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) diff --git a/components/images/img_levels_auto.py b/components/images/img_levels_auto.py index 006a948..d6f7b2e 100644 --- a/components/images/img_levels_auto.py +++ b/components/images/img_levels_auto.py @@ -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: diff --git a/components/images/img_levels_compress.py b/components/images/img_levels_compress.py index ef61cf4..3c61034 100644 --- a/components/images/img_levels_compress.py +++ b/components/images/img_levels_compress.py @@ -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")