diff --git a/Nodes/Dashboard.py b/Nodes/Dashboard.py index b57953b..2e83fe8 100644 --- a/Nodes/Dashboard.py +++ b/Nodes/Dashboard.py @@ -80,6 +80,9 @@ from ..components.images import img_film_grain as img_film_grain from ..components.images import img_blur as img_blur from ..components.images import img_selective_tone as img_selective_tone from ..components.images import img_smart_lighting as img_smart_lighting +from ..components.images import img_white_balance as img_white_balance +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 class PrimereSamplersSteps: @@ -2174,6 +2177,10 @@ class PrimereRasterix: "auto_normalize": ("BOOLEAN", {"default": False, "label_off": "No auto levels", "label_on": "Apply auto levels"}), "auto_levels_threshold": ("FLOAT", {"default": 2.0, "min": 0.0, "max": 10.0, "step": 0.1}), + "use_white_balance": ("BOOLEAN", {"default": False, "label_off": "Ignore white balance", "label_on": "Apply white balance"}), + "wb_temperature": ("FLOAT", {"default": 6500, "min": 2000, "max": 12000, "step": 100}), + "wb_tint": ("FLOAT", {"default": 0, "min": -100, "max": 100, "step": 1}), + "use_smart_lighting": ("BOOLEAN", {"default": False, "label_off": "Ignore smart lightning", "label_on": "Apply smart lightning"}), "smart_lighting": ("FLOAT", {"default": 0, "min": 0, "max": 100, "step": 1}), @@ -2191,6 +2198,10 @@ class PrimereRasterix: "contrast": ("FLOAT", {"default": 0, "min": -50, "max": 100, "step": 1}), "use_legacy": ("BOOLEAN", {"default": False, "label_off": "Use non-linear shift", "label_on": "Use adaptive offset"}), + "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": 100, "step": 1}), + "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}), "selective_tone_zone": (["highlights", "midtones", "shadows", "blacks"], {"default": "midtones"}), @@ -2232,7 +2243,7 @@ class PrimereRasterix: } } - def primere_rasterix(self, concepts, models, image, auto_normalize, auto_levels_threshold, 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_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, model_concept=None, model_name=None): + def primere_rasterix(self, concepts, models, image, auto_normalize, auto_levels_threshold, 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, model_concept=None, model_name=None): pil_img = utility.tensor_to_image(image) rasterix_json_path = os.path.join(PRIMERE_ROOT, 'front_end', 'rasterix.json') @@ -2241,6 +2252,9 @@ class PrimereRasterix: if auto_normalize: pil_img = img_levels_auto.img_levels_auto(image=pil_img, auto_normalize=auto_normalize, threshold=auto_levels_threshold) + 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) + if use_blur and blur_intensity != 0: pil_img = img_blur.img_blur(image=pil_img, blur_type=blur_type, intensity=blur_intensity, radius=blur_radius, angle=angle, edge_only=blur_edge_only, bilateral_edge_sensitivity=bilateral_edge_sensitivity, edge_threshold=edge_threshold) @@ -2250,6 +2264,9 @@ class PrimereRasterix: if use_brightness_contrast and (brightness != 0 or contrast != 0): 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) + st_data = rasterix_data.get('selective_tone', {}) if use_selective_tone and st_data: pil_img = img_selective_tone.img_selective_tone(image=pil_img, channels_data=st_data, separation=selective_tone_separation, strength=selective_tone_strength) diff --git a/components/images/img_film_rendering.py b/components/images/img_film_rendering.py new file mode 100644 index 0000000..572d377 --- /dev/null +++ b/components/images/img_film_rendering.py @@ -0,0 +1,874 @@ +import numpy as np +from PIL import Image + + +# ── Film / Sensor preset library ───────────────────────────────────────────── +# Colour film (_CF): +# "bw" : False +# "type" : "CF" +# "bias" : (R, G, B) multiplicative gain +# "curves" : {"r","g","b"} each list of (in, out) control points 0–1 +# +# B&W film (_BWF): +# "bw" : True +# "type" : "BWF" +# "mix" : (R, G, B) spectral sensitivity weights, sum to 1.0 +# "tint" : (R, G, B) chemical toning colour bias +# "bias" : (1,1,1) unused, kept for consistency +# "curves" : {"r","g","b"} identical — applied to the grey channel +# +# Digital sensor (_CCD): +# "bw" : False +# "type" : "CCD" +# "bias" : (R, G, B) sensor colour filter bias +# "matrix" : 3×3 colour science matrix (applied in linear light) +# "curves" : {"r","g","b"} camera tone curve / picture profile +# "shadow_lift" : (R, G, B) black point lift — sensor noise floor colour +# "highlight_rolloff" : float 0–1, controls highlight compression strength + +FILM_PRESETS = { + + # ───────────────────────────────────────────────────────────────────────── + # COLOUR FILMS (_CF) + # ───────────────────────────────────────────────────────────────────────── + + "fuji_astia_100_CF": { + "desc": "Fuji Astia 100 — soft, low contrast, neutral skin tones, subtle colours", + "bw": False, "type": "CF", + "bias": (1.00, 1.00, 0.97), + "curves": { + "r": [(0.0,0.05),(0.25,0.27),(0.5,0.52),(0.75,0.76),(1.0,0.97)], + "g": [(0.0,0.04),(0.25,0.27),(0.5,0.52),(0.75,0.76),(1.0,0.97)], + "b": [(0.0,0.04),(0.25,0.26),(0.5,0.51),(0.75,0.75),(1.0,0.96)], + }, + }, + + "fuji_provia_100_CF": { + "desc": "Fuji Provia 100F — standard/neutral, accurate colour, moderate contrast", + "bw": False, "type": "CF", + "bias": (1.00, 1.01, 1.02), + "curves": { + "r": [(0.0,0.02),(0.25,0.26),(0.5,0.51),(0.75,0.76),(1.0,0.99)], + "g": [(0.0,0.02),(0.25,0.27),(0.5,0.52),(0.75,0.77),(1.0,0.99)], + "b": [(0.0,0.03),(0.25,0.27),(0.5,0.53),(0.75,0.77),(1.0,0.99)], + }, + }, + + "fuji_velvia_100_CF": { + "desc": "Fuji Velvia 100 — punchy, very saturated, high contrast, vivid greens and blues", + "bw": False, "type": "CF", + "bias": (1.00, 1.03, 1.06), + "curves": { + "r": [(0.0,0.00),(0.25,0.22),(0.5,0.50),(0.75,0.78),(1.0,1.00)], + "g": [(0.0,0.00),(0.25,0.23),(0.5,0.52),(0.75,0.80),(1.0,1.00)], + "b": [(0.0,0.00),(0.25,0.24),(0.5,0.54),(0.75,0.82),(1.0,1.00)], + }, + }, + + "fuji_superia_400_CF": { + "desc": "Fuji Superia 400 — consumer negative, warm greens, slight grain character", + "bw": False, "type": "CF", + "bias": (1.02, 1.03, 0.96), + "curves": { + "r": [(0.0,0.04),(0.25,0.28),(0.5,0.53),(0.75,0.77),(1.0,0.97)], + "g": [(0.0,0.04),(0.25,0.29),(0.5,0.54),(0.75,0.78),(1.0,0.97)], + "b": [(0.0,0.03),(0.25,0.25),(0.5,0.49),(0.75,0.73),(1.0,0.94)], + }, + }, + + "fuji_400h_CF": { + "desc": "Fuji 400H — soft highlights, cool shadows, popular portrait film", + "bw": False, "type": "CF", + "bias": (0.99, 1.01, 1.04), + "curves": { + "r": [(0.0,0.05),(0.25,0.27),(0.5,0.51),(0.75,0.75),(1.0,0.96)], + "g": [(0.0,0.05),(0.25,0.28),(0.5,0.52),(0.75,0.76),(1.0,0.97)], + "b": [(0.0,0.06),(0.25,0.29),(0.5,0.53),(0.75,0.77),(1.0,0.97)], + }, + }, + + "kodak_kodachrome_64_CF": { + "desc": "Kodak Kodachrome 64 — iconic warm reds, deep blues, high contrast, rich shadows", + "bw": False, "type": "CF", + "bias": (1.06, 0.98, 0.94), + "curves": { + "r": [(0.0,0.00),(0.25,0.24),(0.5,0.52),(0.75,0.80),(1.0,1.00)], + "g": [(0.0,0.00),(0.25,0.22),(0.5,0.49),(0.75,0.76),(1.0,0.98)], + "b": [(0.0,0.00),(0.25,0.21),(0.5,0.48),(0.75,0.75),(1.0,0.97)], + }, + }, + + "kodak_ektachrome_100vs_CF": { + "desc": "Kodak Ektachrome 100VS — very saturated, cool blues, strong greens", + "bw": False, "type": "CF", + "bias": (0.98, 1.02, 1.05), + "curves": { + "r": [(0.0,0.00),(0.25,0.23),(0.5,0.50),(0.75,0.78),(1.0,1.00)], + "g": [(0.0,0.00),(0.25,0.24),(0.5,0.52),(0.75,0.80),(1.0,1.00)], + "b": [(0.0,0.00),(0.25,0.25),(0.5,0.54),(0.75,0.82),(1.0,1.00)], + }, + }, + + "kodak_portra_160_CF": { + "desc": "Kodak Portra 160 — warm skin tones, soft highlights, low contrast, fine grain", + "bw": False, "type": "CF", + "bias": (1.04, 1.01, 0.95), + "curves": { + "r": [(0.0,0.04),(0.25,0.28),(0.5,0.53),(0.75,0.77),(1.0,0.97)], + "g": [(0.0,0.03),(0.25,0.27),(0.5,0.52),(0.75,0.76),(1.0,0.97)], + "b": [(0.0,0.03),(0.25,0.25),(0.5,0.49),(0.75,0.73),(1.0,0.94)], + }, + }, + + "kodak_portra_400_CF": { + "desc": "Kodak Portra 400 — versatile portrait film, warm, slightly lifted shadows", + "bw": False, "type": "CF", + "bias": (1.03, 1.00, 0.96), + "curves": { + "r": [(0.0,0.05),(0.25,0.28),(0.5,0.53),(0.75,0.77),(1.0,0.97)], + "g": [(0.0,0.04),(0.25,0.27),(0.5,0.52),(0.75,0.76),(1.0,0.97)], + "b": [(0.0,0.04),(0.25,0.25),(0.5,0.50),(0.75,0.74),(1.0,0.95)], + }, + }, + + "kodak_gold_200_CF": { + "desc": "Kodak Gold 200 — consumer film, warm golden tone, boosted yellows and reds", + "bw": False, "type": "CF", + "bias": (1.05, 1.02, 0.92), + "curves": { + "r": [(0.0,0.03),(0.25,0.28),(0.5,0.54),(0.75,0.79),(1.0,0.98)], + "g": [(0.0,0.03),(0.25,0.27),(0.5,0.53),(0.75,0.78),(1.0,0.97)], + "b": [(0.0,0.02),(0.25,0.23),(0.5,0.47),(0.75,0.71),(1.0,0.92)], + }, + }, + + "kodak_ultramax_400_CF": { + "desc": "Kodak Ultramax 400 — vivid warm colours, punchy contrast, popular street film", + "bw": False, "type": "CF", + "bias": (1.04, 1.01, 0.93), + "curves": { + "r": [(0.0,0.02),(0.25,0.26),(0.5,0.52),(0.75,0.79),(1.0,0.99)], + "g": [(0.0,0.02),(0.25,0.26),(0.5,0.52),(0.75,0.78),(1.0,0.98)], + "b": [(0.0,0.01),(0.25,0.22),(0.5,0.47),(0.75,0.73),(1.0,0.94)], + }, + }, + + "kodak_tri_x_400_CF": { + "desc": "Kodak Tri-X 400 — B&W look in colour, strong contrast, warm shadow tint", + "bw": False, "type": "CF", + "bias": (1.02, 1.00, 0.97), + "curves": { + "r": [(0.0,0.00),(0.25,0.21),(0.5,0.50),(0.75,0.79),(1.0,1.00)], + "g": [(0.0,0.00),(0.25,0.21),(0.5,0.50),(0.75,0.79),(1.0,1.00)], + "b": [(0.0,0.00),(0.25,0.20),(0.5,0.49),(0.75,0.78),(1.0,0.99)], + }, + }, + + "agfa_vista_200_CF": { + "desc": "Agfa Vista 200 — cool shadows, slight blue-green tint, soft contrast", + "bw": False, "type": "CF", + "bias": (0.97, 1.00, 1.04), + "curves": { + "r": [(0.0,0.04),(0.25,0.26),(0.5,0.51),(0.75,0.75),(1.0,0.96)], + "g": [(0.0,0.04),(0.25,0.27),(0.5,0.52),(0.75,0.76),(1.0,0.97)], + "b": [(0.0,0.05),(0.25,0.28),(0.5,0.53),(0.75,0.77),(1.0,0.97)], + }, + }, + + "lomography_lomo_100_CF": { + "desc": "Lomography 100 — high contrast, cross-process look, boosted saturation", + "bw": False, "type": "CF", + "bias": (1.05, 0.97, 1.02), + "curves": { + "r": [(0.0,0.00),(0.25,0.24),(0.5,0.53),(0.75,0.82),(1.0,1.00)], + "g": [(0.0,0.00),(0.25,0.20),(0.5,0.48),(0.75,0.77),(1.0,0.98)], + "b": [(0.0,0.00),(0.25,0.23),(0.5,0.52),(0.75,0.80),(1.0,1.00)], + }, + }, + + "ilford_xp2_400_CF": { + "desc": "Ilford XP2 Super 400 — chromogenic B&W, neutral, clean shadows", + "bw": False, "type": "CF", + "bias": (1.00, 1.00, 1.00), + "curves": { + "r": [(0.0,0.01),(0.25,0.23),(0.5,0.50),(0.75,0.77),(1.0,0.99)], + "g": [(0.0,0.01),(0.25,0.23),(0.5,0.50),(0.75,0.77),(1.0,0.99)], + "b": [(0.0,0.01),(0.25,0.23),(0.5,0.50),(0.75,0.77),(1.0,0.99)], + }, + }, + + "kodak_vision3_500t_CF": { + "desc": "Kodak Vision3 500T — cinema negative, tungsten balanced, warm shadows, teal highlights", + "bw": False, "type": "CF", + "bias": (1.03, 0.99, 0.96), + "curves": { + "r": [(0.0,0.05),(0.25,0.28),(0.5,0.52),(0.75,0.76),(1.0,0.96)], + "g": [(0.0,0.04),(0.25,0.27),(0.5,0.51),(0.75,0.75),(1.0,0.96)], + "b": [(0.0,0.06),(0.25,0.27),(0.5,0.51),(0.75,0.76),(1.0,0.97)], + }, + }, + + "fuji_eterna_250d_CF": { + "desc": "Fuji Eterna 250D — cinema film, daylight balanced, soft contrast, desaturated highlights", + "bw": False, "type": "CF", + "bias": (0.99, 1.01, 1.03), + "curves": { + "r": [(0.0,0.05),(0.25,0.27),(0.5,0.51),(0.75,0.75),(1.0,0.95)], + "g": [(0.0,0.05),(0.25,0.28),(0.5,0.52),(0.75,0.76),(1.0,0.96)], + "b": [(0.0,0.05),(0.25,0.28),(0.5,0.52),(0.75,0.76),(1.0,0.96)], + }, + }, + + # ───────────────────────────────────────────────────────────────────────── + # B&W FILMS (_BWF) + # ───────────────────────────────────────────────────────────────────────── + + "ilford_hp5_400_BWF": { + "desc": "Ilford HP5 Plus 400 — classic panchromatic, neutral grey, forgiving latitude", + "bw": True, "type": "BWF", + "mix": (0.299, 0.587, 0.114), + "tint": (1.00, 1.00, 1.00), + "bias": (1.00, 1.00, 1.00), + "curves": { + "r": [(0.0,0.01),(0.25,0.24),(0.5,0.51),(0.75,0.77),(1.0,0.99)], + "g": [(0.0,0.01),(0.25,0.24),(0.5,0.51),(0.75,0.77),(1.0,0.99)], + "b": [(0.0,0.01),(0.25,0.24),(0.5,0.51),(0.75,0.77),(1.0,0.99)], + }, + }, + + "ilford_delta_100_BWF": { + "desc": "Ilford Delta 100 — fine grain, cool neutral tone, excellent shadow detail", + "bw": True, "type": "BWF", + "mix": (0.28, 0.60, 0.12), + "tint": (0.98, 0.99, 1.01), + "bias": (1.00, 1.00, 1.00), + "curves": { + "r": [(0.0,0.01),(0.25,0.25),(0.5,0.51),(0.75,0.76),(1.0,0.99)], + "g": [(0.0,0.01),(0.25,0.25),(0.5,0.51),(0.75,0.76),(1.0,0.99)], + "b": [(0.0,0.01),(0.25,0.25),(0.5,0.51),(0.75,0.76),(1.0,0.99)], + }, + }, + + "ilford_delta_3200_BWF": { + "desc": "Ilford Delta 3200 — very high ISO, lifted shadows, compressed highlights", + "bw": True, "type": "BWF", + "mix": (0.30, 0.59, 0.11), + "tint": (1.00, 1.00, 1.00), + "bias": (1.00, 1.00, 1.00), + "curves": { + "r": [(0.0,0.08),(0.25,0.28),(0.5,0.52),(0.75,0.75),(1.0,0.94)], + "g": [(0.0,0.08),(0.25,0.28),(0.5,0.52),(0.75,0.75),(1.0,0.94)], + "b": [(0.0,0.08),(0.25,0.28),(0.5,0.52),(0.75,0.75),(1.0,0.94)], + }, + }, + + "kodak_tmax_100_BWF": { + "desc": "Kodak T-Max 100 — ultra-fine grain, high contrast, deep clean blacks", + "bw": True, "type": "BWF", + "mix": (0.27, 0.62, 0.11), + "tint": (1.00, 1.00, 1.00), + "bias": (1.00, 1.00, 1.00), + "curves": { + "r": [(0.0,0.00),(0.25,0.22),(0.5,0.51),(0.75,0.80),(1.0,1.00)], + "g": [(0.0,0.00),(0.25,0.22),(0.5,0.51),(0.75,0.80),(1.0,1.00)], + "b": [(0.0,0.00),(0.25,0.22),(0.5,0.51),(0.75,0.80),(1.0,1.00)], + }, + }, + + "kodak_tmax_400_BWF": { + "desc": "Kodak T-Max 400 — fine grain for ISO 400, excellent tonal range", + "bw": True, "type": "BWF", + "mix": (0.28, 0.61, 0.11), + "tint": (1.00, 1.00, 1.00), + "bias": (1.00, 1.00, 1.00), + "curves": { + "r": [(0.0,0.01),(0.25,0.23),(0.5,0.51),(0.75,0.78),(1.0,0.99)], + "g": [(0.0,0.01),(0.25,0.23),(0.5,0.51),(0.75,0.78),(1.0,0.99)], + "b": [(0.0,0.01),(0.25,0.23),(0.5,0.51),(0.75,0.78),(1.0,0.99)], + }, + }, + + "kodak_tri_x_400_BWF": { + "desc": "Kodak Tri-X 400 B&W — iconic, punchy, deep blacks, photojournalism classic", + "bw": True, "type": "BWF", + "mix": (0.32, 0.58, 0.10), + "tint": (1.01, 1.00, 0.99), + "bias": (1.00, 1.00, 1.00), + "curves": { + "r": [(0.0,0.00),(0.25,0.21),(0.5,0.50),(0.75,0.80),(1.0,1.00)], + "g": [(0.0,0.00),(0.25,0.21),(0.5,0.50),(0.75,0.80),(1.0,1.00)], + "b": [(0.0,0.00),(0.25,0.21),(0.5,0.50),(0.75,0.80),(1.0,1.00)], + }, + }, + + "agfa_apx_100_BWF": { + "desc": "Agfa APX 100 — smooth midtones, slightly warm neutral, soft shadow gradation", + "bw": True, "type": "BWF", + "mix": (0.30, 0.59, 0.11), + "tint": (1.01, 1.00, 0.99), + "bias": (1.00, 1.00, 1.00), + "curves": { + "r": [(0.0,0.02),(0.25,0.25),(0.5,0.51),(0.75,0.76),(1.0,0.98)], + "g": [(0.0,0.02),(0.25,0.25),(0.5,0.51),(0.75,0.76),(1.0,0.98)], + "b": [(0.0,0.02),(0.25,0.25),(0.5,0.51),(0.75,0.76),(1.0,0.98)], + }, + }, + + "agfa_apx_400_BWF": { + "desc": "Agfa APX 400 — medium grain, contrasty midtones, green-sensitive", + "bw": True, "type": "BWF", + "mix": (0.29, 0.61, 0.10), + "tint": (1.00, 1.00, 1.00), + "bias": (1.00, 1.00, 1.00), + "curves": { + "r": [(0.0,0.01),(0.25,0.23),(0.5,0.51),(0.75,0.79),(1.0,0.99)], + "g": [(0.0,0.01),(0.25,0.23),(0.5,0.51),(0.75,0.79),(1.0,0.99)], + "b": [(0.0,0.01),(0.25,0.23),(0.5,0.51),(0.75,0.79),(1.0,0.99)], + }, + }, + + "rollei_rpx_400_BWF": { + "desc": "Rollei RPX 400 — very deep blacks, punchy street photography look", + "bw": True, "type": "BWF", + "mix": (0.30, 0.59, 0.11), + "tint": (1.00, 1.00, 1.00), + "bias": (1.00, 1.00, 1.00), + "curves": { + "r": [(0.0,0.00),(0.25,0.20),(0.5,0.50),(0.75,0.81),(1.0,1.00)], + "g": [(0.0,0.00),(0.25,0.20),(0.5,0.50),(0.75,0.81),(1.0,1.00)], + "b": [(0.0,0.00),(0.25,0.20),(0.5,0.50),(0.75,0.81),(1.0,1.00)], + }, + }, + + "fomapan_100_BWF": { + "desc": "Fomapan 100 — orthochromatic character, blue-sensitive, soft contrast, vintage look", + "bw": True, "type": "BWF", + "mix": (0.22, 0.55, 0.23), + "tint": (1.00, 1.00, 1.00), + "bias": (1.00, 1.00, 1.00), + "curves": { + "r": [(0.0,0.03),(0.25,0.26),(0.5,0.51),(0.75,0.75),(1.0,0.97)], + "g": [(0.0,0.03),(0.25,0.26),(0.5,0.51),(0.75,0.75),(1.0,0.97)], + "b": [(0.0,0.03),(0.25,0.26),(0.5,0.51),(0.75,0.75),(1.0,0.97)], + }, + }, + + "selenium_tone_BWF": { + "desc": "Selenium toning — cool blue-purple shadow tone, archival darkroom process", + "bw": True, "type": "BWF", + "mix": (0.299, 0.587, 0.114), + "tint": (0.96, 0.97, 1.04), + "bias": (1.00, 1.00, 1.00), + "curves": { + "r": [(0.0,0.01),(0.25,0.23),(0.5,0.50),(0.75,0.77),(1.0,0.99)], + "g": [(0.0,0.01),(0.25,0.23),(0.5,0.50),(0.75,0.77),(1.0,0.99)], + "b": [(0.0,0.01),(0.25,0.23),(0.5,0.50),(0.75,0.77),(1.0,0.99)], + }, + }, + + "sepia_tone_BWF": { + "desc": "Sepia toning — warm brown throughout, classic Victorian / vintage look", + "bw": True, "type": "BWF", + "mix": (0.299, 0.587, 0.114), + "tint": (1.08, 1.00, 0.82), + "bias": (1.00, 1.00, 1.00), + "curves": { + "r": [(0.0,0.04),(0.25,0.26),(0.5,0.52),(0.75,0.77),(1.0,0.97)], + "g": [(0.0,0.04),(0.25,0.26),(0.5,0.52),(0.75,0.77),(1.0,0.97)], + "b": [(0.0,0.04),(0.25,0.26),(0.5,0.52),(0.75,0.77),(1.0,0.97)], + }, + }, + + "gold_tone_BWF": { + "desc": "Gold toning — warm golden highlights, cooler shadows, elegant darkroom effect", + "bw": True, "type": "BWF", + "mix": (0.299, 0.587, 0.114), + "tint": (1.05, 1.02, 0.88), + "bias": (1.00, 1.00, 1.00), + "curves": { + "r": [(0.0,0.02),(0.25,0.24),(0.5,0.51),(0.75,0.78),(1.0,0.99)], + "g": [(0.0,0.02),(0.25,0.24),(0.5,0.51),(0.75,0.78),(1.0,0.99)], + "b": [(0.0,0.02),(0.25,0.24),(0.5,0.51),(0.75,0.78),(1.0,0.99)], + }, + }, + + "cyanotype_BWF": { + "desc": "Cyanotype — deep cyan-blue alternative process print look", + "bw": True, "type": "BWF", + "mix": (0.299, 0.587, 0.114), + "tint": (0.78, 0.90, 1.15), + "bias": (1.00, 1.00, 1.00), + "curves": { + "r": [(0.0,0.02),(0.25,0.23),(0.5,0.50),(0.75,0.76),(1.0,0.97)], + "g": [(0.0,0.02),(0.25,0.23),(0.5,0.50),(0.75,0.76),(1.0,0.97)], + "b": [(0.0,0.02),(0.25,0.23),(0.5,0.50),(0.75,0.76),(1.0,0.97)], + }, + }, + + "platinum_palladium_BWF": { + "desc": "Platinum/Palladium print — long tonal scale, warm neutral, rich shadow detail", + "bw": True, "type": "BWF", + "mix": (0.299, 0.587, 0.114), + "tint": (1.02, 1.01, 0.99), + "bias": (1.00, 1.00, 1.00), + "curves": { + "r": [(0.0,0.03),(0.25,0.26),(0.5,0.51),(0.75,0.75),(1.0,0.97)], + "g": [(0.0,0.03),(0.25,0.26),(0.5,0.51),(0.75,0.75),(1.0,0.97)], + "b": [(0.0,0.03),(0.25,0.26),(0.5,0.51),(0.75,0.75),(1.0,0.97)], + }, + }, + + # ───────────────────────────────────────────────────────────────────────── + # DIGITAL CAMERA SENSORS (_CCD) + # + # "matrix" : 3×3 colour science matrix applied in linear light. + # Rows = output R, G, B. Columns = input R, G, B. + # Identity = [[1,0,0],[0,1,0],[0,0,1]] + # "shadow_lift" : (R, G, B) black point offset — sensor noise floor colour + # "highlight_rolloff" : 0–1, how aggressively highlights are compressed + # ───────────────────────────────────────────────────────────────────────── + + "canon_5d_mark2_CCD": { + "desc": "Canon 5D Mark II — warm romantic colour, gentle highlight rolloff, " + "smooth skin tones, classic full-frame DSLR", + "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], + ], + "shadow_lift": (0.008, 0.006, 0.005), + "highlight_rolloff": 0.35, + "curves": { + "r": [(0.0,0.01),(0.25,0.26),(0.5,0.52),(0.75,0.77),(1.0,0.97)], + "g": [(0.0,0.01),(0.25,0.26),(0.5,0.51),(0.75,0.76),(1.0,0.97)], + "b": [(0.0,0.01),(0.25,0.25),(0.5,0.50),(0.75,0.75),(1.0,0.96)], + }, + }, + + "canon_5d_mark1_CCD": { + "desc": "Canon 5D Mark I — original full-frame, warm CCD character, " + "lower dynamic range but very pleasing colour rendering", + "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], + ], + "shadow_lift": (0.012, 0.009, 0.007), + "highlight_rolloff": 0.45, + "curves": { + "r": [(0.0,0.02),(0.25,0.27),(0.5,0.53),(0.75,0.77),(1.0,0.97)], + "g": [(0.0,0.01),(0.25,0.26),(0.5,0.52),(0.75,0.76),(1.0,0.96)], + "b": [(0.0,0.01),(0.25,0.24),(0.5,0.50),(0.75,0.74),(1.0,0.95)], + }, + }, + + "canon_1dx_CCD": { + "desc": "Canon 1Dx — professional sports/press, accurate neutral colour, " + "punchy contrast, excellent highlight detail", + "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], + ], + "shadow_lift": (0.006, 0.005, 0.005), + "highlight_rolloff": 0.25, + "curves": { + "r": [(0.0,0.00),(0.25,0.25),(0.5,0.51),(0.75,0.78),(1.0,0.99)], + "g": [(0.0,0.00),(0.25,0.25),(0.5,0.51),(0.75,0.78),(1.0,0.99)], + "b": [(0.0,0.00),(0.25,0.24),(0.5,0.50),(0.75,0.77),(1.0,0.98)], + }, + }, + + "sony_a7iii_CCD": { + "desc": "Sony A7 III — neutral accurate colour, cool shadow character, " + "high dynamic range, clinical modern look", + "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], + ], + "shadow_lift": (0.004, 0.004, 0.006), + "highlight_rolloff": 0.20, + "curves": { + "r": [(0.0,0.00),(0.25,0.25),(0.5,0.51),(0.75,0.76),(1.0,0.99)], + "g": [(0.0,0.00),(0.25,0.26),(0.5,0.52),(0.75,0.77),(1.0,1.00)], + "b": [(0.0,0.01),(0.25,0.26),(0.5,0.52),(0.75,0.77),(1.0,0.99)], + }, + }, + + "sony_a7rii_CCD": { + "desc": "Sony A7R II — very high resolution, neutral-cool, extremely detailed, " + "wide colour gamut rendering", + "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], + ], + "shadow_lift": (0.003, 0.003, 0.005), + "highlight_rolloff": 0.18, + "curves": { + "r": [(0.0,0.00),(0.25,0.25),(0.5,0.51),(0.75,0.76),(1.0,0.99)], + "g": [(0.0,0.00),(0.25,0.26),(0.5,0.52),(0.75,0.77),(1.0,1.00)], + "b": [(0.0,0.01),(0.25,0.26),(0.5,0.52),(0.75,0.78),(1.0,1.00)], + }, + }, + + "nikon_d800_CCD": { + "desc": "Nikon D800 — neutral accurate, slightly cool shadows, excellent detail, " + "professional landscape/studio camera", + "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], + ], + "shadow_lift": (0.005, 0.005, 0.007), + "highlight_rolloff": 0.22, + "curves": { + "r": [(0.0,0.00),(0.25,0.25),(0.5,0.51),(0.75,0.77),(1.0,0.99)], + "g": [(0.0,0.00),(0.25,0.26),(0.5,0.52),(0.75,0.77),(1.0,0.99)], + "b": [(0.0,0.01),(0.25,0.26),(0.5,0.52),(0.75,0.77),(1.0,0.99)], + }, + }, + + "nikon_d3_CCD": { + "desc": "Nikon D3 — warm classic DSLR rendering, photojournalism standard, " + "smooth pleasing colour, good shadow detail", + "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], + ], + "shadow_lift": (0.008, 0.007, 0.006), + "highlight_rolloff": 0.30, + "curves": { + "r": [(0.0,0.01),(0.25,0.26),(0.5,0.52),(0.75,0.77),(1.0,0.97)], + "g": [(0.0,0.01),(0.25,0.26),(0.5,0.51),(0.75,0.76),(1.0,0.97)], + "b": [(0.0,0.01),(0.25,0.25),(0.5,0.50),(0.75,0.75),(1.0,0.96)], + }, + }, + + "fuji_xt3_CCD": { + "desc": "Fuji X-T3 — film-simulation-inspired colour science, warm midtones, " + "distinctive X-Trans rendering, vivid but natural", + "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], + ], + "shadow_lift": (0.007, 0.006, 0.005), + "highlight_rolloff": 0.40, + "curves": { + "r": [(0.0,0.02),(0.25,0.27),(0.5,0.52),(0.75,0.77),(1.0,0.97)], + "g": [(0.0,0.02),(0.25,0.27),(0.5,0.52),(0.75,0.77),(1.0,0.97)], + "b": [(0.0,0.01),(0.25,0.25),(0.5,0.51),(0.75,0.75),(1.0,0.96)], + }, + }, + + "fuji_gfx_CCD": { + "desc": "Fuji GFX 100 — medium format digital, very neutral and clean, " + "exceptional tonal gradation, subtle warm-neutral", + "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], + ], + "shadow_lift": (0.005, 0.005, 0.004), + "highlight_rolloff": 0.28, + "curves": { + "r": [(0.0,0.01),(0.25,0.26),(0.5,0.51),(0.75,0.76),(1.0,0.98)], + "g": [(0.0,0.01),(0.25,0.26),(0.5,0.52),(0.75,0.77),(1.0,0.99)], + "b": [(0.0,0.01),(0.25,0.26),(0.5,0.51),(0.75,0.76),(1.0,0.98)], + }, + }, + + "leica_m9_CCD": { + "desc": "Leica M9 CCD — iconic true CCD sensor, warm romantic colour, " + "beautiful highlight glow, beloved by photographers for its " + "three-dimensional rendering", + "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], + ], + "shadow_lift": (0.010, 0.008, 0.007), + "highlight_rolloff": 0.50, + "curves": { + "r": [(0.0,0.02),(0.25,0.27),(0.5,0.53),(0.75,0.78),(1.0,0.97)], + "g": [(0.0,0.02),(0.25,0.27),(0.5,0.52),(0.75,0.77),(1.0,0.97)], + "b": [(0.0,0.01),(0.25,0.24),(0.5,0.50),(0.75,0.74),(1.0,0.95)], + }, + }, + + "leica_m11_CCD": { + "desc": "Leica M11 CMOS — modern Leica, very neutral and clinical, " + "60MP resolution character, faithful colour science", + "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], + ], + "shadow_lift": (0.004, 0.004, 0.004), + "highlight_rolloff": 0.22, + "curves": { + "r": [(0.0,0.01),(0.25,0.25),(0.5,0.51),(0.75,0.76),(1.0,0.98)], + "g": [(0.0,0.01),(0.25,0.26),(0.5,0.52),(0.75,0.77),(1.0,0.99)], + "b": [(0.0,0.01),(0.25,0.25),(0.5,0.51),(0.75,0.76),(1.0,0.98)], + }, + }, + + "hasselblad_x2d_CCD": { + "desc": "Hasselblad X2D — 100MP medium format, extremely neutral and clean, " + "very wide tonal range, clinical precision colour", + "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], + ], + "shadow_lift": (0.003, 0.003, 0.003), + "highlight_rolloff": 0.15, + "curves": { + "r": [(0.0,0.00),(0.25,0.25),(0.5,0.51),(0.75,0.76),(1.0,0.99)], + "g": [(0.0,0.00),(0.25,0.25),(0.5,0.51),(0.75,0.76),(1.0,0.99)], + "b": [(0.0,0.00),(0.25,0.25),(0.5,0.51),(0.75,0.76),(1.0,0.99)], + }, + }, + + "olympus_omd_CCD": { + "desc": "Olympus OM-D E-M1 — punchy vivid colour, slightly cool, " + "Micro Four Thirds sensor, sharp contrasty rendering", + "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], + ], + "shadow_lift": (0.005, 0.005, 0.006), + "highlight_rolloff": 0.20, + "curves": { + "r": [(0.0,0.00),(0.25,0.25),(0.5,0.52),(0.75,0.78),(1.0,1.00)], + "g": [(0.0,0.00),(0.25,0.26),(0.5,0.52),(0.75,0.79),(1.0,1.00)], + "b": [(0.0,0.01),(0.25,0.26),(0.5,0.52),(0.75,0.78),(1.0,0.99)], + }, + }, +} + + +# ───────────────────────────────────────────────────────────────────────────── +# Main function +# ───────────────────────────────────────────────────────────────────────────── + +def img_film_rendering( + image: Image.Image, + rendering: str = "kodak_kodachrome_64_CF", + intensity: float = 100, +) -> Image.Image: + """ + Film and digital sensor rendering simulation. + Supports colour film (_CF), B&W film (_BWF), and digital camera sensors (_CCD). + + Args: + image : PIL Image (RGB) + + rendering : Preset name — suffix indicates type: + _CF = colour film + _BWF = black and white film + _CCD = digital camera sensor + + ── Colour film (_CF) ───────────────────────────────────── + "fuji_astia_100_CF" Soft, neutral skin, low contrast + "fuji_provia_100_CF" Standard, accurate, moderate contrast + "fuji_velvia_100_CF" Vivid, high contrast, punchy colours + "fuji_superia_400_CF" Warm greens, consumer negative + "fuji_400h_CF" Soft highlights, cool shadows, portrait + "kodak_kodachrome_64_CF" Warm reds, deep blues, high contrast + "kodak_ektachrome_100vs_CF" Very saturated, cool blues + "kodak_portra_160_CF" Warm skin, soft highlights, fine grain + "kodak_portra_400_CF" Warm, lifted shadows, versatile portrait + "kodak_gold_200_CF" Warm golden, boosted yellows + "kodak_ultramax_400_CF" Vivid warm, punchy, street film + "kodak_tri_x_400_CF" Strong contrast, warm shadow tint + "agfa_vista_200_CF" Cool shadows, blue-green tint + "lomography_lomo_100_CF" High contrast, cross-process + "ilford_xp2_400_CF" Neutral, clean, minimal colour shift + "kodak_vision3_500t_CF" Cinema negative, warm shadows, teal hi + "fuji_eterna_250d_CF" Soft cinema, desaturated highlights + + ── B&W film (_BWF) ─────────────────────────────────────── + "ilford_hp5_400_BWF" Neutral panchromatic, forgiving + "ilford_delta_100_BWF" Fine grain, cool tone, sharp + "ilford_delta_3200_BWF" High ISO, lifted shadows, pushed look + "kodak_tmax_100_BWF" Ultra-fine grain, high contrast, sharp + "kodak_tmax_400_BWF" Fine grain, excellent tonal range + "kodak_tri_x_400_BWF" Iconic, punchy, photojournalism classic + "agfa_apx_100_BWF" Smooth midtones, slightly warm neutral + "agfa_apx_400_BWF" Medium grain, contrasty midtones + "rollei_rpx_400_BWF" Very deep blacks, punchy street look + "fomapan_100_BWF" Soft, orthochromatic character, vintage + "selenium_tone_BWF" Cool blue-purple shadows, archival + "sepia_tone_BWF" Warm brown throughout, Victorian vintage + "gold_tone_BWF" Warm golden highlights, cooler shadows + "cyanotype_BWF" Deep cyan-blue, alternative process + "platinum_palladium_BWF" Long tonal scale, subtle warm neutral + + ── Digital sensors (_CCD) ──────────────────────────────── + "canon_5d_mark2_CCD" Warm romantic, gentle rolloff, skin tones + "canon_5d_mark1_CCD" Original full-frame CCD, warm character + "canon_1dx_CCD" Neutral accurate, punchy, professional + "sony_a7iii_CCD" Neutral, cool shadows, high dynamic range + "sony_a7rii_CCD" Very neutral-cool, extremely detailed + "nikon_d800_CCD" Neutral accurate, slightly cool, landscape + "nikon_d3_CCD" Warm classic, photojournalism standard + "fuji_xt3_CCD" Film-simulation-inspired, vivid natural + "fuji_gfx_CCD" Medium format, neutral, exceptional tonal + "leica_m9_CCD" Iconic true CCD, warm, highlight glow + "leica_m11_CCD" Modern Leica, very neutral and clinical + "hasselblad_x2d_CCD" 100MP MF, clinical precision, wide tonal + "olympus_omd_CCD" Punchy vivid, slightly cool, contrasty + + intensity : 0 … 100. Blend between original and full rendering. + 0 = passthrough. + 50 = 50/50 blend. + 100 = full rendering (default). + + Returns: + PIL Image (RGB) + """ + if intensity == 0: + return image.convert("RGB") + + if rendering not in FILM_PRESETS: + valid = ", ".join(sorted(FILM_PRESETS.keys())) + raise ValueError(f"Unknown rendering '{rendering}'. Valid: {valid}") + + if not (0 <= intensity <= 100): + raise ValueError(f"intensity must be 0–100, got {intensity}") + + preset = FILM_PRESETS[rendering] + img = image.convert("RGB") + arr = np.array(img, dtype=np.float32) / 255.0 + orig = arr.copy() + + if preset["type"] == "BWF": + arr_out = _apply_bw(arr, preset) + elif preset["type"] == "CCD": + arr_out = _apply_sensor(arr, preset) + else: + arr_out = _apply_colour(arr, preset) + + blend = intensity / 100.0 + result = orig * (1.0 - blend) + arr_out * blend + return Image.fromarray(np.clip(result * 255, 0, 255).astype(np.uint8), mode="RGB") + + +def list_film_presets() -> dict: + """Returns {preset_name: description} for all presets. Use for ComfyUI combo.""" + 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 + + +# ───────────────────────────────────────────────────────────────────────────── +# Internal rendering paths +# ───────────────────────────────────────────────────────────────────────────── + +def _apply_lut(channel: np.ndarray, pts: list) -> np.ndarray: + 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: + bias = np.array(preset["bias"], dtype=np.float32) + arr = np.clip(arr * bias, 0, 1) + curves = preset["curves"] + return np.clip(np.stack([ + _apply_lut(arr[..., 0], curves["r"]), + _apply_lut(arr[..., 1], curves["g"]), + _apply_lut(arr[..., 2], curves["b"]), + ], axis=-1), 0, 1) + + +def _apply_bw(arr: np.ndarray, preset: dict) -> np.ndarray: + mix = preset["mix"] + grey = np.clip(arr[...,0]*mix[0] + arr[...,1]*mix[1] + arr[...,2]*mix[2], 0, 1) + grey = _apply_lut(grey, preset["curves"]["r"]) + tint = np.array(preset["tint"], dtype=np.float32) + return np.clip(np.stack([grey*tint[0], grey*tint[1], grey*tint[2]], axis=-1), 0, 1) + + +def _apply_sensor(arr: np.ndarray, preset: dict) -> np.ndarray: + # ── 1. Linearise sRGB (remove gamma) ────────────────────────────────────── + linear = np.where(arr <= 0.04045, arr/12.92, + ((arr + 0.055)/1.055)**2.4) + + # ── 2. Apply sensor colour bias ──────────────────────────────────────────── + bias = np.array(preset["bias"], dtype=np.float32) + linear = np.clip(linear * bias, 0, 1) + + # ── 3. Apply 3×3 colour science matrix ──────────────────────────────────── + M = np.array(preset["matrix"], dtype=np.float32) + H, W = linear.shape[:2] + flat = linear.reshape(-1, 3) + flat = np.clip(flat @ M.T, 0, 1) + linear = flat.reshape(H, W, 3) + + # ── 4. Apply highlight rolloff in linear light ───────────────────────────── + # Compress values above rolloff_start toward 1.0 with a smooth curve + rolloff = preset["highlight_rolloff"] + if rolloff > 0: + hi_start = 1.0 - rolloff + hi_mask = np.clip((linear - hi_start) / rolloff, 0, 1) + # Smooth S-curve compression in the highlight zone + hi_compressed = hi_start + rolloff * (1.0 - (1.0 - hi_mask)**2) + linear = np.where(linear > hi_start, hi_compressed, linear) + + # ── 5. Re-apply sRGB gamma ───────────────────────────────────────────────── + srgb = np.where(linear <= 0.0031308, linear*12.92, + 1.055 * np.power(np.clip(linear, 0, None), 1.0/2.4) - 0.055) + srgb = np.clip(srgb, 0, 1) + + # ── 6. Apply camera tone curve (picture profile) ─────────────────────────── + curves = preset["curves"] + srgb = np.clip(np.stack([ + _apply_lut(srgb[..., 0], curves["r"]), + _apply_lut(srgb[..., 1], curves["g"]), + _apply_lut(srgb[..., 2], curves["b"]), + ], axis=-1), 0, 1) + + # ── 7. Apply shadow lift / sensor noise floor colour ────────────────────── + lift = np.array(preset["shadow_lift"], dtype=np.float32) + srgb = np.clip(srgb + lift * (1.0 - srgb), 0, 1) + + return srgb diff --git a/components/images/img_white_balance.py b/components/images/img_white_balance.py new file mode 100644 index 0000000..78915c7 --- /dev/null +++ b/components/images/img_white_balance.py @@ -0,0 +1,98 @@ +import numpy as np +from PIL import Image + + +def img_white_balance( + image: Image.Image, + temperature: float = 6500, + tint: float = 0, +) -> Image.Image: + """ + RGB white balance adjustment using colour temperature in Kelvin. + Approximates the DxO-style cool/warm slider with real Kelvin values. + + Works on JPEG/PNG by applying per-channel RGB gain corrections derived + from the difference between the target temperature and neutral (6500K). + This is an approximation — true white balance requires RAW data — but + produces visually correct and useful results on rendered images. + + Args: + image : PIL Image (RGB) + temperature : 2000 … 12000. Colour temperature in Kelvin. + 2000K = very warm / candlelight (strong orange) + 3200K = tungsten / indoor lamp (warm) + 5500K = daylight / flash (neutral-warm) + 6500K = sRGB standard white point (no change) + 7500K = overcast / shade (cool-blue) + 10000K = deep shade / very blue sky (very cool) + 12000K = extreme cool (strong blue) + tint : -100 … +100. Green/magenta tint correction. + Negative = shift toward magenta. + Positive = shift toward green. + 0 = no tint (default). + + Returns: + PIL Image (RGB) + """ + if temperature == 6500 and tint == 0: + return image.convert("RGB") + + if not (2000 <= temperature <= 12000): + raise ValueError(f"temperature must be 2000–12000K, got {temperature}") + if not (-100 <= tint <= 100): + raise ValueError(f"tint must be -100 … +100, got {tint}") + + img = image.convert("RGB") + arr = np.array(img, dtype=np.float32) / 255.0 + + # ── Kelvin → RGB gain ───────────────────────────────────────────────────── + # Derived from Tanner Helland's empirical Kelvin→RGB formula, then + # normalised so 6500K = (1.0, 1.0, 1.0) — no change at neutral point. + # We compute gain for the target temp and divide by the 6500K gain to + # get the relative correction needed on a sRGB-origin image. + + def kelvin_to_rgb(K): + K = K / 100.0 + # Red + if K <= 66: + R = 255.0 + else: + R = 329.698727446 * ((K - 60) ** -0.1332047592) + R = np.clip(R, 0, 255) + + # Green + if K <= 66: + G = 99.4708025861 * np.log(K) - 161.1195681661 + else: + G = 288.1221695283 * ((K - 60) ** -0.0755148492) + G = np.clip(G, 0, 255) + + # Blue + if K >= 66: + B = 255.0 + elif K <= 19: + B = 0.0 + else: + B = 138.5177312231 * np.log(K - 10) - 305.0447927307 + B = np.clip(B, 0, 255) + + return np.array([R, G, B]) / 255.0 + + # RGB at target temperature and at neutral 6500K + rgb_target = kelvin_to_rgb(temperature) + rgb_neutral = kelvin_to_rgb(6500) + + # Per-channel gain relative to neutral + with np.errstate(divide='ignore', invalid='ignore'): + gain = np.where(rgb_neutral > 0, rgb_target / rgb_neutral, 1.0) + + # ── Apply temperature gain ───────────────────────────────────────────────── + arr = arr * gain + + # ── Apply tint (green ↔ magenta on green channel) ───────────────────────── + if tint != 0: + tint_gain = 1.0 + (tint / 100.0) * 0.3 + arr[..., 1] = arr[..., 1] * tint_gain + + arr = np.clip(arr, 0, 1) + return Image.fromarray((arr * 255).astype(np.uint8), mode="RGB")