V 2.0.0 - Rasterix 10

This commit is contained in:
DESKTOP-TVBJISQ\Primere
2026-03-19 19:36:58 +01:00
parent e31b62eb85
commit 9a3abb5a5b
3 changed files with 990 additions and 1 deletions
+18 -1
View File
@@ -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)
+874
View File
@@ -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
+98
View File
@@ -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")