V 2.0.0 - Rasterix - film halation
This commit is contained in:
+3
-2
@@ -2214,6 +2214,7 @@ class PrimereRasterix:
|
||||
"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"}),
|
||||
"halation": ("BOOLEAN", {"default": False, "label_off": "Ignore halation", "label_on": "Add halation"}),
|
||||
|
||||
"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}),
|
||||
@@ -2273,7 +2274,7 @@ class PrimereRasterix:
|
||||
}
|
||||
}
|
||||
|
||||
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):
|
||||
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, halation, 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()
|
||||
|
||||
@@ -2296,7 +2297,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, add_grain=iso_grain)
|
||||
pil_img = img_film_rendering.img_film_rendering(image=pil_img, rendering=film_rendering, intensity=film_rendering_intensity, add_grain=iso_grain, add_halation=halation)
|
||||
|
||||
st_data = rasterix_data.get('selective_tone', {})
|
||||
if use_selective_tone and st_data:
|
||||
|
||||
@@ -652,6 +652,7 @@ def img_film_rendering(
|
||||
rendering: str = "kodak_kodachrome_64_CF",
|
||||
intensity: float = 100,
|
||||
add_grain: bool = False,
|
||||
add_halation: bool = False,
|
||||
) -> Image.Image:
|
||||
if intensity == 0:
|
||||
return image.convert("RGB")
|
||||
@@ -701,6 +702,11 @@ def img_film_rendering(
|
||||
result = orig + blend * (arr_out - orig)
|
||||
result = np.clip(result, 0.0, 1.0)
|
||||
|
||||
# Apply Halation first (before grain) because light scatters in the emulsion
|
||||
# before the physical grain structure is fully developed/perceived
|
||||
if add_halation:
|
||||
result = _apply_halation(result, preset_base, H, W)
|
||||
|
||||
if add_grain:
|
||||
result = _apply_grain(result, preset_base, H, W)
|
||||
|
||||
@@ -753,18 +759,18 @@ def _analyse_image(arr: np.ndarray) -> dict:
|
||||
"is_desaturated": mean_saturation < 0.08,
|
||||
}
|
||||
|
||||
def _adapt_preset(preset: dict, analysis: dict) -> dict:
|
||||
|
||||
def _adapt_preset(preset: dict, analysis: dict) -> dict:
|
||||
import copy
|
||||
p = copy.deepcopy(preset)
|
||||
|
||||
median = analysis["median_lum"]
|
||||
std = analysis["lum_std"]
|
||||
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"]
|
||||
cast = analysis["dominant_cast"]
|
||||
desat = analysis["is_desaturated"]
|
||||
flat = analysis["is_flat"]
|
||||
|
||||
if "hd" in p:
|
||||
if flat:
|
||||
@@ -805,15 +811,26 @@ def _adapt_preset(preset: dict, analysis: dict) -> dict:
|
||||
|
||||
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)
|
||||
# 1. Cast compensation
|
||||
compensation = cast * 0.30
|
||||
bias = bias - compensation
|
||||
|
||||
# 2. LAB SCANNER AUTO-EXPOSURE (Using the 'median' variable)
|
||||
# Gently push dark images and pull bright images
|
||||
if median < 0.35:
|
||||
push = (0.35 - median) * 0.75
|
||||
bias += push
|
||||
elif median > 0.65:
|
||||
pull = (median - 0.65) * 0.75
|
||||
bias -= pull
|
||||
|
||||
# 3. Desaturation compensation
|
||||
if desat:
|
||||
deviation = bias - 1.0
|
||||
bias = 1.0 + deviation * 1.5
|
||||
|
||||
p["bias"] = tuple(float(v) for v in np.clip(bias, 0.7, 1.4))
|
||||
|
||||
return p
|
||||
|
||||
@@ -866,24 +883,24 @@ def _make_grain_params(preset: dict, H: int, W: int) -> dict:
|
||||
"midtone_peak": 0.4,
|
||||
}
|
||||
|
||||
def _apply_grain(arr: np.ndarray, preset: dict, H: int, W: int) -> np.ndarray:
|
||||
|
||||
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"]
|
||||
params = _make_grain_params(preset, H, W)
|
||||
iso = preset.get("iso", 400) # Now actively used for chroma correlation
|
||||
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"]
|
||||
gs = params["grain_size"]
|
||||
mp = params["midtone_peak"]
|
||||
|
||||
lum = 0.299*arr[...,0] + 0.587*arr[...,1] + 0.114*arr[...,2]
|
||||
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)
|
||||
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)
|
||||
@@ -896,8 +913,8 @@ def _apply_grain(arr: np.ndarray, preset: dict, H: int, W: int) -> np.ndarray:
|
||||
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
|
||||
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
|
||||
@@ -906,13 +923,21 @@ def _apply_grain(arr: np.ndarray, preset: dict, H: int, W: int) -> np.ndarray:
|
||||
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))
|
||||
# ISO-BASED CHROMA CORRELATION (Using the 'iso' variable)
|
||||
# Low ISO = highly correlated channels (monochromatic-ish grain)
|
||||
# High ISO = uncorrelated channels (colorful, blotchy dye clouds)
|
||||
correlation = float(np.clip(1.0 - (iso / 1600.0), 0.3, 0.9))
|
||||
|
||||
base_lum = make_noise((H, W))
|
||||
nr = base_lum * correlation + make_noise((H, W)) * (1.0 - correlation)
|
||||
ng = base_lum * correlation + make_noise((H, W)) * (1.0 - correlation)
|
||||
nb = base_lum * correlation + make_noise((H, W)) * (1.0 - correlation)
|
||||
|
||||
# Normalize variance so the overall intensity matches the sigma requested
|
||||
norm_factor = 1.0 / np.sqrt(correlation ** 2 + (1.0 - correlation) ** 2)
|
||||
nr *= norm_factor
|
||||
ng *= norm_factor
|
||||
nb *= norm_factor
|
||||
|
||||
if params["color_tint"] == "cool":
|
||||
tr, tg, tb = 0.80, 0.95, 1.25
|
||||
@@ -925,6 +950,55 @@ def _apply_grain(arr: np.ndarray, preset: dict, H: int, W: int) -> np.ndarray:
|
||||
out[..., 2] = np.clip(arr[..., 2] + nb * sigma * lum_mask * tb, 0, 1)
|
||||
return out
|
||||
|
||||
|
||||
def _apply_halation(arr: np.ndarray, preset: dict, H: int, W: int) -> np.ndarray:
|
||||
from scipy.ndimage import gaussian_filter
|
||||
|
||||
# Dynamically scale the glow radius based on image size (crucial for ComfyUI upscales)
|
||||
area_scale = np.sqrt((H * W) / (1920 * 1280))
|
||||
base_radius = 8.0 * area_scale
|
||||
|
||||
# Check the preset to determine halation character
|
||||
desc = preset.get("desc", "").lower()
|
||||
is_cinema = "cinema" in desc or "vision3" in desc
|
||||
is_bw = preset.get("bw", False)
|
||||
|
||||
# Cinema films usually have removed rem-jet backings when cross-processed,
|
||||
# or naturally stronger halation. Standard film has less.
|
||||
strength = 0.55 if is_cinema else 0.25
|
||||
|
||||
# 1. Isolate the absolute brightest spots (luminance > 80%)
|
||||
lum = 0.299 * arr[..., 0] + 0.587 * arr[..., 1] + 0.114 * arr[..., 2]
|
||||
threshold = 0.80
|
||||
|
||||
# Soft mask to ensure smooth roll-off into the glow
|
||||
hi_mask = np.clip((lum - threshold) / (1.0 - threshold + 1e-6), 0.0, 1.0)
|
||||
|
||||
# Square the mask to tightly restrict the core of the halation
|
||||
bright_spots = arr * (hi_mask ** 2)[..., None]
|
||||
|
||||
out = arr.copy()
|
||||
|
||||
if is_bw:
|
||||
# B&W halation is just a diffuse white/luma glow in the silver halides
|
||||
blur = gaussian_filter(bright_spots[..., 0], sigma=base_radius)
|
||||
out[..., 0] = np.clip(out[..., 0] + blur * strength, 0.0, 1.0)
|
||||
out[..., 1] = np.clip(out[..., 1] + blur * strength, 0.0, 1.0)
|
||||
out[..., 2] = np.clip(out[..., 2] + blur * strength, 0.0, 1.0)
|
||||
else:
|
||||
# Color halation is predominantly red, with a tiny bit of green for a warm orange roll-off.
|
||||
# Blue scatters the least.
|
||||
r_blur = gaussian_filter(bright_spots[..., 0], sigma=base_radius)
|
||||
g_blur = gaussian_filter(bright_spots[..., 1], sigma=base_radius * 0.6)
|
||||
b_blur = gaussian_filter(bright_spots[..., 2], sigma=base_radius * 0.2)
|
||||
|
||||
# Additive blend back onto the original image
|
||||
out[..., 0] = np.clip(out[..., 0] + r_blur * strength * 1.2, 0.0, 1.0)
|
||||
out[..., 1] = np.clip(out[..., 1] + g_blur * strength * 0.3, 0.0, 1.0)
|
||||
out[..., 2] = np.clip(out[..., 2] + b_blur * strength * 0.0, 0.0, 1.0)
|
||||
|
||||
return out
|
||||
|
||||
def _hd_curve(x: np.ndarray, toe: float, gamma: float, shoulder: float) -> np.ndarray:
|
||||
x = np.clip(x, 0.0, 1.0)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user