V 2.0.0 - Rasterix - new film rendering
This commit is contained in:
+28
-18
@@ -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 = {}
|
||||
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user