V 2.0.0 - Rasterix - Posterize
This commit is contained in:
@@ -19,6 +19,7 @@ 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
|
||||
from ..components.images import img_posterize as img_posterize
|
||||
from ..components import utility
|
||||
from .Dashboard import PrimereModelConceptSelector as PrimereModelConceptSelector
|
||||
import os
|
||||
@@ -124,6 +125,10 @@ class PrimereRasterix:
|
||||
"adaptive_dither_strength": ("BOOLEAN", {"default": False, "label_off": "Keep dither strength", "label_on": "Increase dither strength"}),
|
||||
"error_diffusion": ("BOOLEAN", {"default": False, "label_off": "Error diffusion OFF", "label_on": "Error diffusion ON"}),
|
||||
|
||||
"use_posterize": ("BOOLEAN", {"default": False, "label_off": "Ignore posterize", "label_on": "Apply posterize"}),
|
||||
"shades": ("INT", {"default": 255, "min": 1, "max": 255, "step": 1}),
|
||||
"channels": (["Red", "Green", "Blue"], {"default": "Red"}),
|
||||
|
||||
"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}),
|
||||
@@ -228,6 +233,7 @@ class PrimereRasterix:
|
||||
dither_quantization = kwargs.get('dither_quantization', False)
|
||||
adaptive_dither_strength = kwargs.get('adaptive_dither_strength', False)
|
||||
error_diffusion = kwargs.get('error_diffusion', False)
|
||||
use_posterize = kwargs.get('use_level_endpoints', False)
|
||||
show_histogram = kwargs.get('show_histogram', False)
|
||||
histogram_source = kwargs.get('histogram_source', False)
|
||||
histogram_channel = kwargs.get('histogram_channel', "RGB")
|
||||
@@ -287,6 +293,10 @@ class PrimereRasterix:
|
||||
if dither_quantization or error_diffusion or normalize_midpeaks:
|
||||
pil_img = img_dithering.img_dithering(image=pil_img, 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, seed=seed)
|
||||
|
||||
poster_data = rasterix_data.get('posterize', {})
|
||||
if use_posterize and poster_data:
|
||||
pil_img = img_posterize.img_posterize(image=pil_img, channels_data=poster_data)
|
||||
|
||||
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)
|
||||
|
||||
@@ -633,6 +643,31 @@ class PrimereLevelEndpoints:
|
||||
|
||||
return (utility.image_to_tensor(pil_img),)
|
||||
|
||||
class PrimerePosterize:
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("IMAGE",)
|
||||
FUNCTION = "primere_posterize"
|
||||
CATEGORY = TREE_RASTERIX
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE", {"forceInput": True}),
|
||||
"use_posterize": ("BOOLEAN", {"default": False, "label_off": "Ignore posterize", "label_on": "Apply posterize"}),
|
||||
"shades": ("INT", {"default": 255, "min": 1, "max": 255, "step": 1}),
|
||||
"channels": (["Red", "Green", "Blue"], {"default": "Red"}),
|
||||
}
|
||||
}
|
||||
|
||||
def primere_posterize(self, image, use_posterize, shades, channels):
|
||||
pil_img = utility.tensor_to_image(image)
|
||||
rasterix_json_path = os.path.join(PRIMERE_ROOT, 'front_end', 'rasterix.json')
|
||||
rasterix_data = utility.json2tuple(rasterix_json_path) or {}
|
||||
poster_data = rasterix_data.get('posterize', {})
|
||||
if use_posterize and poster_data:
|
||||
pil_img = img_posterize.img_posterize(image=pil_img, channels_data=poster_data)
|
||||
return (utility.image_to_tensor(pil_img),)
|
||||
|
||||
class PrimereDithering:
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
|
||||
@@ -72,6 +72,7 @@ NODE_CLASS_MAPPINGS = {
|
||||
"PrimereHSL": Rasterix.PrimereHSL,
|
||||
"PrimereShadeDetailer": Rasterix.PrimereShadeDetailer,
|
||||
"PrimereLevelEndpoints": Rasterix.PrimereLevelEndpoints,
|
||||
"PrimerePosterize": Rasterix.PrimerePosterize,
|
||||
"PrimereDithering": Rasterix.PrimereDithering,
|
||||
"PrimereAIDetectionBypasser": Rasterix.PrimereAIDetectionBypasser,
|
||||
"PrimereRasterixGrain": Rasterix.PrimereRasterixGrain,
|
||||
@@ -169,6 +170,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"PrimereHSL": "Primere Rasterix (HSL)",
|
||||
"PrimereShadeDetailer": "Primere Rasterix (Shade Detailer)",
|
||||
"PrimereLevelEndpoints": "Primere Rasterix (Level Endpoints)",
|
||||
"PrimerePosterize": "Primere Rasterix (Posterize)",
|
||||
"PrimereDithering": "Primere Rasterix (Dithering)",
|
||||
"PrimereAIDetectionBypasser": "Primere Rasterix (AI Detection Bypasser)",
|
||||
"PrimereRasterixGrain": "Primere Rasterix (Grain)",
|
||||
|
||||
@@ -6,10 +6,6 @@ import json
|
||||
from ..tree import PRIMERE_ROOT
|
||||
import os
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Channel definitions
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
_HIST_CH_DEFS = {
|
||||
"RGB": [(0, (1.0, 0.22, 0.22)), (1, (0.22, 1.0, 0.22)), (2, (0.22, 0.44, 1.0))],
|
||||
"RED": [(0, (1.0, 0.22, 0.22))],
|
||||
@@ -36,10 +32,6 @@ VALID_STYLES = {
|
||||
}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Internal helpers
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_raw(arr: np.ndarray, ch_idx: int, precision: bool) -> np.ndarray:
|
||||
"""Return 256-bin histogram for one channel."""
|
||||
if precision:
|
||||
@@ -124,77 +116,34 @@ def _draw_lines(canvas, heights, color, hist_h, hist_w):
|
||||
for ci, cv in enumerate(color):
|
||||
canvas[rs[xs], xs, ci] = np.maximum(canvas[rs[xs], xs, ci], cv)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Main function
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def rasterix_histogram_render(
|
||||
pil_img: Image.Image,
|
||||
channel: str = "RGB",
|
||||
style: str = "bars",
|
||||
precision: bool = False,
|
||||
) -> Image.Image:
|
||||
"""
|
||||
Render a histogram visualisation of pil_img.
|
||||
|
||||
Args:
|
||||
pil_img : PIL Image (RGB)
|
||||
|
||||
channel : "RGB" | "RED" | "GREEN" | "BLUE"
|
||||
Ignored by "parade" style (always shows all three).
|
||||
Ignored by "heatmap" and "luma" (use fixed channel logic).
|
||||
|
||||
style : One of:
|
||||
# "gradient" — filled area with top fade (default)
|
||||
"bars" — flat filled area
|
||||
"lines" — thin outline only
|
||||
# "glow" — bars + gaussian bloom
|
||||
"waveform" — center-mirrored oscilloscope curve
|
||||
"heatmap" — luminosity density with perceptual colour ramp
|
||||
"stacked" — R/G/B stacked (non-overlapping areas)
|
||||
# "dots" — vertical dot columns proportional to count
|
||||
# "step" — raw unsmoothed step function (shows comb)
|
||||
"luma" — gradient + white luminosity overlay curve
|
||||
# "log" — log-scale Y axis gradient
|
||||
"parade" — R | G | B side-by-side panels
|
||||
# "percentile" — gradient + percentile marker lines
|
||||
# "inverse" — light-background gradient
|
||||
|
||||
precision : False = 8-bit raw histogram source
|
||||
True = 16-bit raw histogram source (downsampled to 256 bins)
|
||||
Note: rendering normalisation is intentionally identical for both
|
||||
so equal input gives equal visual histogram height.
|
||||
|
||||
Returns:
|
||||
PIL Image (RGB) — 1024 × 256 px (parade: 1536 × 256 px)
|
||||
"""
|
||||
if style not in VALID_STYLES:
|
||||
raise ValueError(f"style must be one of {sorted(VALID_STYLES)}, got '{style}'")
|
||||
|
||||
arr = np.array(pil_img.convert("RGB"), dtype=np.float32)
|
||||
hist_h = 192
|
||||
hist_w = 512
|
||||
# Keep visual scale identical between 8-bit and 16-bit rendering.
|
||||
# Precision only changes raw-bin acquisition, not display normalisation.
|
||||
sqrt_norm = False
|
||||
|
||||
sigma = 0.75 if style in ("bars", "step", "dots") else 1.0
|
||||
smooth = _make_smooth(sigma)
|
||||
channels = _HIST_CH_DEFS.get(channel, _HIST_CH_DEFS["RGB"])
|
||||
|
||||
# ── PARADE — special layout: three panels side by side ───────────────────
|
||||
if style == "parade":
|
||||
panel_w = hist_w // 3 # 341 px each; total = 1023 px
|
||||
parade_w = panel_w * 3
|
||||
canvas = np.full((hist_h, parade_w, 3), 18.0 / 255.0, dtype=np.float32)
|
||||
# grid per panel
|
||||
for p in range(3):
|
||||
ox = p * panel_w
|
||||
for frac in (0.25, 0.5, 0.75):
|
||||
canvas[int((1.0-frac)*(hist_h-1)), ox:ox+panel_w] = 0.32
|
||||
canvas[:, ox + int(frac*(panel_w-1)), :] = 0.32
|
||||
# separator
|
||||
if p > 0:
|
||||
canvas[:, ox, :] = 0.45
|
||||
for p, (ch_idx, color) in enumerate(_HIST_CH_DEFS["RGB"]):
|
||||
@@ -209,19 +158,15 @@ def rasterix_histogram_render(
|
||||
np.clip(canvas * 255, 0, 255).astype(np.uint8), mode="RGB")
|
||||
return result
|
||||
|
||||
# ── All other styles use hist_w × hist_h canvas ───────────────────────────
|
||||
if style == "inverse":
|
||||
canvas = _light_canvas(hist_h, hist_w)
|
||||
# Darker curve colors for light background
|
||||
inv_colors = {0: (0.75, 0.10, 0.10), 1: (0.10, 0.65, 0.10), 2: (0.10, 0.25, 0.85)}
|
||||
draw_channels = [(idx, inv_colors.get(idx, col)) for idx, col in channels]
|
||||
else:
|
||||
canvas = _dark_canvas(hist_h, hist_w)
|
||||
draw_channels = channels
|
||||
|
||||
# ── Compute raw histograms ────────────────────────────────────────────────
|
||||
raws = {ch_idx: _get_raw(arr, ch_idx, precision) for ch_idx, _ in channels}
|
||||
# Also compute luma for luma/heatmap styles
|
||||
luma_raw = None
|
||||
if style in ("luma", "heatmap"):
|
||||
luma_arr = (0.299 * arr[:,:,0] + 0.587 * arr[:,:,1] + 0.114 * arr[:,:,2])
|
||||
@@ -233,14 +178,10 @@ def rasterix_histogram_render(
|
||||
luma_raw, _ = np.histogram(luma_arr, bins=256, range=(0,256))
|
||||
luma_raw = luma_raw.astype(np.float32)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# HEATMAP — luminosity density with black→blue→cyan→white ramp
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
if style == "heatmap":
|
||||
norm = _normalise(luma_raw, smooth, sqrt_norm)
|
||||
x_idx = np.linspace(0, 255, hist_w)
|
||||
cols = np.interp(x_idx, np.arange(256), norm)
|
||||
# Ramp: 0→black, 0.33→deep blue, 0.66→cyan, 1.0→white
|
||||
ramp_t = np.array([0.0, 0.33, 0.66, 1.0])
|
||||
ramp_r = np.array([0.0, 0.05, 0.0, 1.0])
|
||||
ramp_g = np.array([0.0, 0.05, 0.85, 1.0])
|
||||
@@ -261,22 +202,16 @@ def rasterix_histogram_render(
|
||||
np.clip(canvas * 255, 0, 255).astype(np.uint8), mode="RGB")
|
||||
return result
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# STACKED — R bottom, G middle, B top (non-overlapping)
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
if style == "stacked":
|
||||
x_idx = np.linspace(0, 255, hist_w)
|
||||
row_idx = np.arange(hist_h).reshape(-1, 1)
|
||||
# Stack: at each x, allocate vertical space proportionally
|
||||
norms = []
|
||||
for ch_idx, _ in _HIST_CH_DEFS["RGB"]:
|
||||
raw = _get_raw(arr, ch_idx, precision)
|
||||
norms.append(np.interp(x_idx, np.arange(256), _normalise(raw, smooth, sqrt_norm)))
|
||||
norms = np.array(norms) # (3, hist_w)
|
||||
total = norms.sum(axis=0) + 1e-6
|
||||
# Fractional heights per channel
|
||||
fracs = norms / total # (3, hist_w), each col sums to 1
|
||||
# Bottom channel (R), then G on top, then B on top
|
||||
cum_h = np.zeros(hist_w, dtype=np.float32)
|
||||
for layer, (ch_idx, color) in enumerate(_HIST_CH_DEFS["RGB"]):
|
||||
layer_h = (fracs[layer] * (hist_h - 1) * norms.max(axis=0) / norms.max()).astype(int)
|
||||
@@ -297,15 +232,11 @@ def rasterix_histogram_render(
|
||||
np.clip(canvas * 255, 0, 255).astype(np.uint8), mode="RGB")
|
||||
return result
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# STEP — raw unsmoothed bins, shows true quantization comb pattern
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
if style == "step":
|
||||
row_idx = np.arange(hist_h).reshape(-1, 1)
|
||||
column_bin = np.minimum((np.arange(hist_w) * 256) // hist_w, 255).astype(np.int32)
|
||||
for ch_idx, color in draw_channels:
|
||||
raw = _get_raw(arr, ch_idx, precision)
|
||||
# No smoothing — raw bin values normalised only
|
||||
if sqrt_norm:
|
||||
norm256 = np.sqrt(np.maximum(raw, 0)); norm256 /= (norm256.max() or 1.0)
|
||||
else:
|
||||
@@ -322,9 +253,6 @@ def rasterix_histogram_render(
|
||||
np.clip(canvas * 255, 0, 255).astype(np.uint8), mode="RGB")
|
||||
return result
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# WAVEFORM — center-line oscilloscope, mirrored above/below midpoint
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
if style == "waveform":
|
||||
x_idx = np.linspace(0, 255, hist_w)
|
||||
mid = hist_h // 2
|
||||
@@ -337,21 +265,16 @@ def rasterix_histogram_render(
|
||||
if amp[x] == 0: continue
|
||||
y_lo = np.clip(mid - amp[x], 0, hist_h-1)
|
||||
y_hi = np.clip(mid + amp[x], 0, hist_h-1)
|
||||
# Gradient: bright at midline, fading to edges
|
||||
for y in range(y_lo, y_hi+1):
|
||||
dist = abs(y - mid) / max(amp[x], 1)
|
||||
brightness = max(0.25, 1.0 - dist * 0.7)
|
||||
for ci, cv in enumerate(color):
|
||||
canvas[y, x, ci] = max(canvas[y, x, ci], cv * brightness)
|
||||
# Draw center marker line
|
||||
canvas[mid, :, :] = np.maximum(canvas[mid, :, :], 0.28)
|
||||
result = Image.fromarray(
|
||||
np.clip(canvas * 255, 0, 255).astype(np.uint8), mode="RGB")
|
||||
return result
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# DOTS — vertical dot columns, spacing proportional to count
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
if style == "dots":
|
||||
x_idx = np.linspace(0, 255, hist_w)
|
||||
n_dots = 32 # max dots per column
|
||||
@@ -361,7 +284,6 @@ def rasterix_histogram_render(
|
||||
cols = np.interp(x_idx, np.arange(256), norm)
|
||||
for x in range(hist_w):
|
||||
n = max(1, int(cols[x] * n_dots))
|
||||
# Distribute n dots evenly across the column height
|
||||
positions = np.linspace(hist_h - 2, int((1.0 - cols[x]) * (hist_h - 1)), n)
|
||||
for pos in positions:
|
||||
y = int(np.clip(pos, 0, hist_h - 1))
|
||||
@@ -372,9 +294,6 @@ def rasterix_histogram_render(
|
||||
np.clip(canvas * 255, 0, 255).astype(np.uint8), mode="RGB")
|
||||
return result
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# LOG — log-scale Y axis
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
if style == "log":
|
||||
x_idx = np.linspace(0, 255, hist_w)
|
||||
for ch_idx, color in draw_channels:
|
||||
@@ -386,20 +305,14 @@ def rasterix_histogram_render(
|
||||
np.clip(canvas * 255, 0, 255).astype(np.uint8), mode="RGB")
|
||||
return result
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# LUMA — gradient base + white luminosity curve on top
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
if style == "luma":
|
||||
x_idx = np.linspace(0, 255, hist_w)
|
||||
# Draw RGB gradient base first (semi-transparent feel via lower alpha)
|
||||
for ch_idx, color in draw_channels:
|
||||
raw = _get_raw(arr, ch_idx, precision)
|
||||
norm = _normalise(raw, smooth, sqrt_norm)
|
||||
_, heights = _cols_heights(norm, hist_w, hist_h)
|
||||
# Draw at 55% brightness so luma curve stands out
|
||||
dimmed = tuple(v * 0.55 for v in color)
|
||||
_draw_gradient(canvas, heights, dimmed, hist_h, hist_w)
|
||||
# Draw luminosity curve in white
|
||||
norm_luma = _normalise(luma_raw, smooth, sqrt_norm)
|
||||
cols_luma = np.interp(x_idx, np.arange(256), norm_luma)
|
||||
heights_luma = (cols_luma * (hist_h - 1)).astype(int)
|
||||
@@ -409,9 +322,6 @@ def rasterix_histogram_render(
|
||||
np.clip(canvas * 255, 0, 255).astype(np.uint8), mode="RGB")
|
||||
return result
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# PERCENTILE — gradient + vertical marker lines
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
if style == "percentile":
|
||||
x_idx = np.linspace(0, 255, hist_w)
|
||||
for ch_idx, color in draw_channels:
|
||||
@@ -419,7 +329,6 @@ def rasterix_histogram_render(
|
||||
norm = _normalise(raw, smooth, sqrt_norm)
|
||||
_, heights = _cols_heights(norm, hist_w, hist_h)
|
||||
_draw_gradient(canvas, heights, color, hist_h, hist_w)
|
||||
# Compute percentiles from first channel (or luma if RGB)
|
||||
if len(draw_channels) == 3:
|
||||
lum = 0.299*arr[:,:,0] + 0.587*arr[:,:,1] + 0.114*arr[:,:,2]
|
||||
flat = lum.ravel()
|
||||
@@ -443,9 +352,6 @@ def rasterix_histogram_render(
|
||||
np.clip(canvas * 255, 0, 255).astype(np.uint8), mode="RGB")
|
||||
return result
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# INVERSE — light background gradient
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
if style == "inverse":
|
||||
x_idx = np.linspace(0, 255, hist_w)
|
||||
for ch_idx, color in draw_channels:
|
||||
@@ -457,10 +363,8 @@ def rasterix_histogram_render(
|
||||
fill_mask = row_idx >= (hist_h - heights)
|
||||
safe_h = np.maximum(heights, 1).astype(np.float32)
|
||||
dist_b = (hist_h - 1 - row_idx).astype(np.float32)
|
||||
# Inverse gradient: dark at bottom, lighter toward top of fill
|
||||
grad = np.clip(0.15 + 0.85 * (1.0 - dist_b / safe_h), 0.0, 1.0)
|
||||
for ci, cv in enumerate(color):
|
||||
# Subtract from white background
|
||||
canvas[:, :, ci] = np.where(
|
||||
fill_mask,
|
||||
np.minimum(canvas[:, :, ci], 1.0 - grad * cv * 0.7),
|
||||
@@ -469,9 +373,6 @@ def rasterix_histogram_render(
|
||||
np.clip(canvas * 255, 0, 255).astype(np.uint8), mode="RGB")
|
||||
return result
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Original styles: gradient, bars, lines, glow
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
for ch_idx, color in draw_channels:
|
||||
raw = _get_raw(arr, ch_idx, precision)
|
||||
norm = _normalise(raw, smooth, sqrt_norm)
|
||||
|
||||
@@ -33,7 +33,6 @@ def img_blur(
|
||||
|
||||
effective_radius = radius * intensity
|
||||
|
||||
# ── Blur types ────────────────────────────────────────────────────────────
|
||||
if blur_type == "gaussian":
|
||||
blurred = np.stack([
|
||||
gaussian_filter(arr[..., c], sigma=effective_radius)
|
||||
@@ -56,7 +55,6 @@ def img_blur(
|
||||
], axis=-1)
|
||||
|
||||
elif blur_type == "bilateral":
|
||||
# color_sigma: maps sensitivity 0.0 → 0.02 (very tight), 1.0 → 0.3 (loose)
|
||||
color_sigma = 0.02 + bilateral_edge_sensitivity * 0.28
|
||||
blurred = _bilateral_blur(arr, spatial_sigma=effective_radius,
|
||||
color_sigma=color_sigma)
|
||||
@@ -68,7 +66,6 @@ def img_blur(
|
||||
for c in range(3)
|
||||
], axis=-1)
|
||||
|
||||
# ── Edge-only mask ────────────────────────────────────────────────────────
|
||||
if edge_only:
|
||||
grey = 0.299 * arr[..., 0] + 0.587 * arr[..., 1] + 0.114 * arr[..., 2]
|
||||
sx = sobel(grey, axis=0)
|
||||
@@ -76,11 +73,8 @@ def img_blur(
|
||||
edge_mag = np.sqrt(sx**2 + sy**2)
|
||||
edge_mag = np.clip(edge_mag / (edge_mag.max() + 1e-6), 0, 1)
|
||||
|
||||
# Apply threshold: remap [threshold … 1] → [0 … 1] so pixels above
|
||||
# threshold are fully sharp, pixels below are progressively blurred.
|
||||
if edge_threshold > 0:
|
||||
edge_mag = np.clip((edge_mag - edge_threshold) /
|
||||
(1.0 - edge_threshold + 1e-6), 0, 1)
|
||||
edge_mag = np.clip((edge_mag - edge_threshold) / (1.0 - edge_threshold + 1e-6), 0, 1)
|
||||
|
||||
edge_mag = edge_mag[..., np.newaxis]
|
||||
blurred = arr * edge_mag + blurred * (1.0 - edge_mag)
|
||||
@@ -89,8 +83,6 @@ def img_blur(
|
||||
return Image.fromarray((result * 255).astype(np.uint8), mode="RGB")
|
||||
|
||||
|
||||
# ── Kernel helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
def _motion_kernel(length: int, angle_deg: float) -> np.ndarray:
|
||||
angle_rad = np.deg2rad(angle_deg)
|
||||
cx, cy = length // 2, length // 2
|
||||
|
||||
@@ -2,10 +2,6 @@ import numpy as np
|
||||
from PIL import Image
|
||||
from numpy.lib.stride_tricks import sliding_window_view
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Optional GPU / acceleration imports (graceful fallback)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
try:
|
||||
from numba import njit
|
||||
NUMBA_AVAILABLE = True
|
||||
@@ -15,10 +11,6 @@ except ImportError:
|
||||
|
||||
|
||||
def _adaptive_dither_amplitude(scale: float, adaptive: bool, max_val: float) -> float:
|
||||
"""
|
||||
Return dither amplitude in output-code units (LSB of 8-bit domain).
|
||||
(Already strengthened in previous version — unchanged)
|
||||
"""
|
||||
base_lsb = max_val / 255.0
|
||||
if not adaptive:
|
||||
return 1.5 * base_lsb
|
||||
@@ -27,9 +19,6 @@ def _adaptive_dither_amplitude(scale: float, adaptive: bool, max_val: float) ->
|
||||
|
||||
|
||||
def _estimate_global_scale(arr: np.ndarray, max_val: float) -> float:
|
||||
"""
|
||||
Estimate effective tonal span (0..1) from channel min/max.
|
||||
"""
|
||||
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)
|
||||
@@ -37,7 +26,6 @@ def _estimate_global_scale(arr: np.ndarray, max_val: float) -> float:
|
||||
|
||||
|
||||
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)
|
||||
@@ -46,7 +34,6 @@ def _tpdf_noise(shape: tuple[int, int, int], amplitude: float) -> np.ndarray:
|
||||
|
||||
|
||||
def _floyd_steinberg_quantize_python(arr: np.ndarray, max_val: float) -> np.ndarray:
|
||||
"""Original pure-Python Floyd-Steinberg (kept for when Numba is not used)."""
|
||||
work = np.clip(arr, 0.0, max_val).astype(np.float32, copy=True)
|
||||
h, w, c = work.shape
|
||||
for ch in range(c):
|
||||
@@ -93,7 +80,6 @@ def _floyd_steinberg_quantize_numba(arr: np.ndarray, max_val: float) -> np.ndarr
|
||||
|
||||
|
||||
def _get_spikiness_factor(c_hist: np.ndarray, total: float) -> float:
|
||||
"""Return 0.0–2.0 boost factor when histogram has tall spikes."""
|
||||
if total <= 0:
|
||||
return 0.0
|
||||
peak_ratio = c_hist.max() / (c_hist.mean() + 1e-8)
|
||||
@@ -106,23 +92,6 @@ def _normalize_midpeaks_channel(
|
||||
max_val: float,
|
||||
rng: np.random.Generator,
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Histogram-aware anti-spike smoothing near empty bins (gaps).
|
||||
|
||||
PRECISION-AWARE FIX (March 2026):
|
||||
• 16-bit (high_precision=True): amp = (peak_width * 1.0) * (max_val / 255.0)
|
||||
→ exactly as you requested and liked. At peak_width=1 it already
|
||||
smooths "every peaks" strongly — this is intentional and unchanged.
|
||||
• 8-bit (high_precision=False): amp = (peak_width * 12.0) * (max_val / 255.0)
|
||||
→ much stronger base multiplier so that peak_width=6 (or even 3–4)
|
||||
now produces a clearly visible histogram smoothing effect that
|
||||
matches the "logical" strength you see in 16-bit.
|
||||
• Automatic spikiness boost (up to 3×) is still applied on top for both
|
||||
bit depths.
|
||||
• No other logic was changed — the qualify_mask, gap detection, and
|
||||
clipping are identical. The only difference is the base amplitude
|
||||
per bit depth so the visual/histogram result feels consistent.
|
||||
"""
|
||||
n_bins = int(max_val) + 1
|
||||
result = channel.copy()
|
||||
c_int = np.clip(np.round(result).astype(np.int64), 0, int(max_val))
|
||||
@@ -132,7 +101,6 @@ def _normalize_midpeaks_channel(
|
||||
if not gap_arr.any():
|
||||
return result
|
||||
|
||||
# ── BIT-DEPTH-SPECIFIC STRENGTH (16-bit untouched, 8-bit now strong) ─────
|
||||
if max_val >= 65535.0: # 16-bit — exactly as you wanted
|
||||
amp = (peak_width * 0.5) * (max_val / 255.0)
|
||||
else: # 8-bit — fixed to give visible effect
|
||||
@@ -165,14 +133,6 @@ def img_dithering(
|
||||
numba_accelerated: bool = True,
|
||||
seed: int | None = None,
|
||||
) -> Image.Image:
|
||||
"""
|
||||
Standalone quantization dither stage for post-processing.
|
||||
|
||||
PRECISION HANDLING IS NOW LOGICALLY CONSISTENT:
|
||||
• 16-bit at peak_width=1 behaves exactly as before (strong smoothing).
|
||||
• 8-bit now gives a clearly visible histogram-smoothing effect at
|
||||
reasonable peak_width values (try 3–6). No more "do nothing".
|
||||
"""
|
||||
if not (1 <= peak_width <= 10):
|
||||
raise ValueError(f"peak_width must be 1–10, got {peak_width}")
|
||||
|
||||
@@ -186,14 +146,12 @@ def img_dithering(
|
||||
scale_factor = max_val / 255.0
|
||||
arr = arr_8f * scale_factor if high_precision else arr_8f
|
||||
|
||||
# ── 1. Mid-peak spike removal (now consistent across bit depths) ─────────
|
||||
if normalize_midpeaks:
|
||||
for ch in range(3):
|
||||
channel_seed = int(base_rng.integers(0, 2**31 - 1))
|
||||
rng = np.random.default_rng(channel_seed)
|
||||
arr[:, :, ch] = _normalize_midpeaks_channel(arr[:, :, ch], peak_width, max_val, rng)
|
||||
|
||||
# ── 2. Final quantization stage ──────────────────────────────────────────
|
||||
if error_diffusion:
|
||||
pre_amp = 0.5 * (max_val / 255.0)
|
||||
arr = arr + _tpdf_noise(arr.shape, pre_amp)
|
||||
|
||||
@@ -115,8 +115,6 @@ def img_film_grain(
|
||||
else:
|
||||
tint = TINTS[color_tint]
|
||||
|
||||
# ── Apply grain ───────────────────────────────────────────────────────────
|
||||
# Scale noise to sigma (pixel intensity units), apply lum_mask, apply tint
|
||||
grain_r = noise_r * sigma * lum_mask * tint[0]
|
||||
grain_g = noise_g * sigma * lum_mask * tint[1]
|
||||
grain_b = noise_b * sigma * lum_mask * tint[2]
|
||||
|
||||
@@ -2,7 +2,6 @@ import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
FILM_PRESETS = {
|
||||
|
||||
"fuji_astia_100_CF": {
|
||||
"desc": "Fuji Astia 100 — soft, low contrast, neutral skin tones, subtle colours",
|
||||
"iso": 100, "grain_type": "fine", "grain_color": "color",
|
||||
|
||||
@@ -14,7 +14,6 @@ def img_hue_saturation(
|
||||
if not (0 <= channel_width <= 100):
|
||||
raise ValueError(f"channel_width must be 0 … 100, got {channel_width}")
|
||||
|
||||
# ── Passthrough short-circuit ─────────────────────────────────────────────
|
||||
def _is_zero(v):
|
||||
return v == 0 or v is None
|
||||
|
||||
@@ -32,7 +31,6 @@ def img_hue_saturation(
|
||||
img = image.convert("RGB")
|
||||
arr = np.array(img, dtype=np.float32) / 255.0
|
||||
|
||||
# ── RGB → HSV ─────────────────────────────────────────────────────────────
|
||||
R, G, B = arr[:,:,0], arr[:,:,1], arr[:,:,2]
|
||||
Cmax = np.maximum(np.maximum(R, G), B)
|
||||
Cmin = np.minimum(np.minimum(R, G), B)
|
||||
@@ -54,7 +52,6 @@ def img_hue_saturation(
|
||||
feather_deg = 10 + t * 35 # 10° … 45°
|
||||
outer_deg = hard_deg + feather_deg
|
||||
|
||||
# ── Accumulate adjustments across all channels ────────────────────────────
|
||||
total_hue = np.zeros_like(h)
|
||||
total_sat = np.zeros_like(s)
|
||||
total_lightness = np.zeros_like(h)
|
||||
@@ -84,29 +81,19 @@ def img_hue_saturation(
|
||||
total_lightness += mask * (params.get('lightness', 0) / 100.0)
|
||||
total_vibrance += mask * (params.get('vibrance', 0) / 100.0)
|
||||
|
||||
# ── Apply hue ─────────────────────────────────────────────────────────────
|
||||
h_new = (h + total_hue) % 360.0
|
||||
|
||||
# ── Apply saturation ──────────────────────────────────────────────────────
|
||||
s_new = np.where(total_sat >= 0,
|
||||
s + total_sat * (1.0 - s),
|
||||
s + total_sat * s)
|
||||
s_new = np.where(total_sat >= 0, s + total_sat * (1.0 - s), s + total_sat * s)
|
||||
s_new = np.clip(s_new, 0.0, 1.0)
|
||||
|
||||
# ── Apply vibrance (with optional skin protection) ────────────────────────
|
||||
if np.any(total_vibrance != 0):
|
||||
if skin_protection:
|
||||
skin_diff = np.abs(((h_new - 25.0 + 180) % 360) - 180)
|
||||
skin_mask = np.where(skin_diff <= 35.0, 1.0,
|
||||
np.where(skin_diff <= 55.0,
|
||||
1.0 - (skin_diff - 35.0) / 20.0, 0.0))
|
||||
skin_mask = np.where(skin_diff <= 35.0, 1.0, np.where(skin_diff <= 55.0, 1.0 - (skin_diff - 35.0) / 20.0, 0.0))
|
||||
vib_mask = 1.0 - skin_mask # 0 on skin, 1 elsewhere
|
||||
else:
|
||||
vib_mask = np.ones_like(h_new)
|
||||
|
||||
s_new += np.where(total_vibrance >= 0,
|
||||
(1.0 - s_new) * vib_mask * total_vibrance,
|
||||
s_new * vib_mask * total_vibrance)
|
||||
s_new += np.where(total_vibrance >= 0, (1.0 - s_new) * vib_mask * total_vibrance, s_new * vib_mask * total_vibrance)
|
||||
s_new = np.clip(s_new, 0.0, 1.0)
|
||||
|
||||
# ── HSV → RGB ─────────────────────────────────────────────────────────────
|
||||
@@ -129,9 +116,7 @@ def img_hue_saturation(
|
||||
# ── Apply lightness ───────────────────────────────────────────────────────
|
||||
if np.any(total_lightness != 0):
|
||||
L3 = total_lightness[:, :, np.newaxis]
|
||||
rgb_sectors = np.where(L3 > 0,
|
||||
rgb_sectors + L3 * (1.0 - rgb_sectors),
|
||||
rgb_sectors + L3 * rgb_sectors)
|
||||
rgb_sectors = np.where(L3 > 0, rgb_sectors + L3 * (1.0 - rgb_sectors), rgb_sectors + L3 * rgb_sectors)
|
||||
|
||||
result = np.clip(rgb_sectors, 0.0, 1.0)
|
||||
return Image.fromarray((result * 255).astype(np.uint8), mode="RGB")
|
||||
@@ -1,38 +1,16 @@
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Constants
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
EDGE_SPREAD_RATIO = 8.0 / 255.0 # edge spread as fraction of max value
|
||||
# 8-bit: 8 bins, 16-bit: 2056 bins
|
||||
GAMMA_MIN = 0.25
|
||||
GAMMA_MAX = 4.0
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Step functions — callable independently
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def levels_detect_points(
|
||||
channel: np.ndarray,
|
||||
threshold: float,
|
||||
max_val: float = 255.0,
|
||||
) -> tuple:
|
||||
"""
|
||||
Detect black and white points from a single channel histogram.
|
||||
|
||||
Args:
|
||||
channel : 2D float32 array, values 0–max_val
|
||||
threshold : 0.0–100.0, percent of pixels to clip at each end
|
||||
max_val : 255.0 for 8-bit, 65535.0 for 16-bit
|
||||
|
||||
Returns:
|
||||
(black_point, white_point, scale)
|
||||
scale = max_val / (white_point - black_point)
|
||||
"""
|
||||
n_bins = int(max_val) + 1
|
||||
hist, _ = np.histogram(channel, bins=n_bins, range=(0, max_val + 1))
|
||||
cumulative = np.cumsum(hist)
|
||||
@@ -64,18 +42,6 @@ def levels_stretch(
|
||||
white_point: int,
|
||||
max_val: float = 255.0,
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Linear stretch of channel values to [0 … max_val].
|
||||
|
||||
Args:
|
||||
channel : 2D float32 array
|
||||
black_point : input value that maps to 0
|
||||
white_point : input value that maps to max_val
|
||||
max_val : 255.0 for 8-bit, 65535.0 for 16-bit
|
||||
|
||||
Returns:
|
||||
Stretched float32 array clipped to [0, max_val]
|
||||
"""
|
||||
scale = max_val / (white_point - black_point)
|
||||
stretched = (channel - black_point) * scale
|
||||
return np.clip(stretched, 0.0, max_val)
|
||||
@@ -88,23 +54,6 @@ def levels_edge_spread(
|
||||
white_point: int,
|
||||
max_val: float = 255.0,
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Rank-based edge spread — always applied, not gated by any boolean.
|
||||
|
||||
Spreads clipped pixels uniformly across [0 … edge_spread] and
|
||||
[max_val-edge_spread … max_val]. Edge spread width scales proportionally
|
||||
with max_val so the same fraction of the range is used at any bit depth.
|
||||
|
||||
Args:
|
||||
channel : original 2D float32 channel before stretch
|
||||
stretched : 2D float32 array after stretch
|
||||
black_point : black point used in stretch
|
||||
white_point : white point used in stretch
|
||||
max_val : 255.0 for 8-bit, 65535.0 for 16-bit
|
||||
|
||||
Returns:
|
||||
Float32 array with edge pixels redistributed
|
||||
"""
|
||||
edge_spread = EDGE_SPREAD_RATIO * max_val # ~8 at 8-bit, ~2056 at 16-bit
|
||||
result = stretched.copy()
|
||||
|
||||
@@ -137,29 +86,11 @@ def levels_normalize_midpeaks(
|
||||
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
|
||||
@@ -171,11 +102,9 @@ def levels_normalize_midpeaks(
|
||||
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
|
||||
@@ -186,7 +115,6 @@ def levels_normalize_midpeaks(
|
||||
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]:
|
||||
@@ -203,25 +131,6 @@ def levels_normalize_gaps(
|
||||
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))
|
||||
@@ -233,22 +142,6 @@ def levels_auto_gamma(
|
||||
gamma_target: float,
|
||||
max_val: float = 255.0,
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Auto gamma correction — pushes mean brightness toward gamma_target.
|
||||
|
||||
Formula: gamma = log(current_mean_norm) / log(target_norm)
|
||||
Applied: output = (input / max_val) ^ (1 / gamma) × max_val
|
||||
Black and white stay anchored. gamma_target is always on 0–255 scale
|
||||
regardless of bit depth — it is normalised internally.
|
||||
|
||||
Args:
|
||||
stretched : 2D float32 array, values 0–max_val
|
||||
gamma_target : target mean brightness 0–255 (normalised internally)
|
||||
max_val : 255.0 for 8-bit, 65535.0 for 16-bit
|
||||
|
||||
Returns:
|
||||
Float32 array with gamma correction applied
|
||||
"""
|
||||
current_mean = float(stretched.mean())
|
||||
low_guard = 0.5 * (max_val / 255.0)
|
||||
high_guard = max_val - low_guard
|
||||
@@ -268,10 +161,6 @@ def levels_auto_gamma(
|
||||
return np.clip(np.power(norm, 1.0 / gamma) * max_val, 0.0, max_val)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Main function
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def img_levels_auto(
|
||||
image: Image.Image,
|
||||
auto_normalize: bool = True,
|
||||
@@ -284,61 +173,6 @@ def img_levels_auto(
|
||||
precision: bool = False,
|
||||
seed: int | None = None,
|
||||
) -> Image.Image:
|
||||
"""
|
||||
Photoshop-style per-channel auto levels normalization.
|
||||
|
||||
Args:
|
||||
image : PIL Image (RGB)
|
||||
|
||||
auto_normalize : True = apply auto levels (default).
|
||||
False = passthrough, return image unchanged.
|
||||
|
||||
threshold : 0.0 … 100.0. Percent of total pixels per channel
|
||||
used to determine black and white points.
|
||||
~1–2 = subtle, ~5 = moderate, ~10+ = aggressive.
|
||||
|
||||
normalize_gaps : True = Anti-comb filter. TPDF dithering fills
|
||||
quantization gaps. Independent of normalize_midpeaks.
|
||||
Default: True.
|
||||
|
||||
normalize_midpeaks : True = Anti-spike filter. Smooths bins near gaps.
|
||||
False = completely skipped regardless of other flags.
|
||||
Default: False.
|
||||
|
||||
peak_width : 1 … 10. Distance from a gap that qualifies a bin
|
||||
as a peak for smoothing. Only used when
|
||||
normalize_midpeaks=True.
|
||||
1 = only directly adjacent bins (surgical)
|
||||
3 = within 3 bins of any gap (default)
|
||||
10 = wide smoothing
|
||||
|
||||
auto_gamma : True = auto per-channel gamma after stretch.
|
||||
Default: True.
|
||||
|
||||
gamma_target : 0 … 255. Target mean brightness for auto gamma.
|
||||
128 = neutral (default), 110 = moody, 150 = airy.
|
||||
Always specified on 0–255 scale regardless of
|
||||
precision setting.
|
||||
|
||||
precision : False = 8-bit pipeline, returns PIL Image RGB.
|
||||
True = 16-bit pipeline (65536 histogram bins),
|
||||
returns PIL Image RGB encoded at 16-bit precision
|
||||
scaled back to 8-bit output. Use for AI-generated
|
||||
tensors where higher internal precision reduces
|
||||
quantization artefacts before final 8-bit output.
|
||||
Default: False.
|
||||
|
||||
Returns:
|
||||
PIL Image (RGB)
|
||||
|
||||
Pipeline per channel:
|
||||
1. levels_detect_points — black / white point via threshold
|
||||
2. levels_stretch — linear stretch to [0 … max_val]
|
||||
3. levels_edge_spread — rank-based edge spread (always on)
|
||||
4. levels_normalize_midpeaks — peak smoothing (if normalize_midpeaks)
|
||||
5. levels_normalize_gaps — TPDF gap dithering (if normalize_gaps)
|
||||
6. levels_auto_gamma — gamma correction (if auto_gamma)
|
||||
"""
|
||||
img = image.convert("RGB")
|
||||
|
||||
if not auto_normalize:
|
||||
@@ -351,11 +185,7 @@ def img_levels_auto(
|
||||
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
|
||||
|
||||
# ── Load image into float array ───────────────────────────────────────────
|
||||
# Always read as 8-bit uint8 from PIL, then scale up to max_val if needed
|
||||
arr_8 = np.array(img, dtype=np.float32) # 0–255 always
|
||||
if precision:
|
||||
arr = arr_8 * (65535.0 / 255.0) # scale to 0–65535
|
||||
@@ -368,35 +198,19 @@ def img_levels_auto(
|
||||
channel_seed_spike = int(base_rng.integers(0, 2**31 - 1))
|
||||
rng_gap = np.random.default_rng(channel_seed_gap)
|
||||
rng_spike = np.random.default_rng(channel_seed_spike)
|
||||
|
||||
channel = arr[:, :, ch]
|
||||
|
||||
# 1. Detect black / white points
|
||||
black_point, white_point, scale = levels_detect_points(channel, threshold, max_val)
|
||||
|
||||
# 2. Stretch
|
||||
stretched = levels_stretch(channel, black_point, white_point, max_val)
|
||||
|
||||
# 3. Edge spread (always on)
|
||||
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)
|
||||
|
||||
# 5. Gap dithering
|
||||
if normalize_gaps:
|
||||
stretched = levels_normalize_gaps(stretched, scale, rng_gap, max_val)
|
||||
|
||||
# 6. Auto gamma
|
||||
if auto_gamma:
|
||||
stretched = levels_auto_gamma(stretched, gamma_target, max_val)
|
||||
|
||||
out[:, :, ch] = stretched
|
||||
|
||||
# ── Convert back to uint8 for PIL output ──────────────────────────────────
|
||||
if precision:
|
||||
# Scale 16-bit result back to 8-bit for PIL output
|
||||
out_8 = np.clip(out * (255.0 / 65535.0), 0, 255).astype(np.uint8)
|
||||
else:
|
||||
out_8 = np.clip(out, 0, 255).astype(np.uint8)
|
||||
|
||||
@@ -9,80 +9,6 @@ def img_levels_compress(
|
||||
skip_if_no_clip: bool = False,
|
||||
high_precision: bool = False,
|
||||
) -> Image.Image:
|
||||
"""
|
||||
Proportional histogram compression — the inverse of auto-levels stretch.
|
||||
|
||||
Shifts the black point upward and the white point downward by the specified
|
||||
offsets, compressing all pixel values proportionally into the narrower range
|
||||
without clipping any pixels. The original tonal relationships are preserved.
|
||||
|
||||
This is the manual counterpart to img_levels_auto: while auto-levels
|
||||
automatically STRETCHES the histogram to fill [0, 255], this function
|
||||
COMPRESSES the histogram to avoid pure black (0) and pure white (255),
|
||||
which is useful for preventing full-black / full-white areas in compositing
|
||||
or for adding a subtle lift before further processing.
|
||||
|
||||
Args:
|
||||
image : PIL Image (RGB)
|
||||
|
||||
black_offset : 0.0 … 25.0 (0–10% of 255).
|
||||
Lifts the output black point.
|
||||
0 = no change (pixels can still reach 0).
|
||||
10 = darkest pixel maps to 10 instead of 0.
|
||||
25 = darkest pixel maps to 25 (maximum lift).
|
||||
|
||||
white_offset : 0.0 … 25.0 (0–10% of 255).
|
||||
Lowers the output white point.
|
||||
0 = no change (pixels can still reach 255).
|
||||
5 = brightest pixel maps to 250 instead of 255.
|
||||
25 = brightest pixel maps to 230 (maximum pull-down).
|
||||
|
||||
skip_if_no_clip : Controls per-side, per-channel behaviour when the
|
||||
channel's data does not actually reach the extreme.
|
||||
|
||||
False (default) — Always apply:
|
||||
Both offsets are applied to every channel regardless
|
||||
of whether the channel data reaches 0 or 255.
|
||||
Use this for a uniform look across all channels.
|
||||
|
||||
True — Skip if no data reaches the extreme:
|
||||
Each offset is applied to a channel side only if
|
||||
the channel's data reaches that side threshold:
|
||||
Black side: apply only if channel_min <= black_offset
|
||||
White side: apply only if channel_max >= (255 - white_offset)
|
||||
If a channel's brightest pixel is 245 and white_offset=5
|
||||
(threshold = 250), the white compression is skipped for
|
||||
that channel — it has no pixels to protect.
|
||||
Each side is evaluated independently.
|
||||
Use this when channels have unequal tonal ranges and
|
||||
you only want to protect sides that actually clip.
|
||||
|
||||
high_precision : False = 8-bit pipeline (default, 0–255 range).
|
||||
True = 16-bit pipeline (0–65535 range, offsets
|
||||
scale proportionally with max_val).
|
||||
Both return PIL Image RGB (uint8). 16-bit precision
|
||||
is internal — more accurate intermediate computation.
|
||||
|
||||
Returns:
|
||||
PIL Image (RGB)
|
||||
|
||||
Formula (per active side):
|
||||
Both sides active:
|
||||
output = black_offset + input × (max_val − white_offset − black_offset) / max_val
|
||||
|
||||
Black side only:
|
||||
output = black_offset + input × (max_val − black_offset) / max_val
|
||||
|
||||
White side only:
|
||||
output = input × (max_val − white_offset) / max_val
|
||||
|
||||
Neither side active (skip_if_no_clip=True, no data at extremes):
|
||||
output = input (passthrough)
|
||||
|
||||
Passthrough conditions:
|
||||
- black_offset == 0 AND white_offset == 0
|
||||
- OR skip_if_no_clip=True and no channel side reaches its threshold
|
||||
"""
|
||||
if not (0.0 <= black_offset <= 25.0):
|
||||
raise ValueError(f"black_offset must be 0.0–25.0, got {black_offset}")
|
||||
if not (0.0 <= white_offset <= 25.0):
|
||||
@@ -94,12 +20,10 @@ def img_levels_compress(
|
||||
img = image.convert("RGB")
|
||||
max_val = 65535.0 if high_precision else 255.0
|
||||
|
||||
# Scale offsets from 8-bit units to internal precision
|
||||
scale_factor = max_val / 255.0
|
||||
bo = black_offset * scale_factor # e.g. 10 → 10 (8-bit) or 2570 (16-bit)
|
||||
wo = white_offset * scale_factor
|
||||
|
||||
# Load as float32, scale to internal range
|
||||
arr_8 = np.array(img, dtype=np.float32) # 0–255 always
|
||||
arr = arr_8 * scale_factor if high_precision else arr_8.copy()
|
||||
|
||||
@@ -107,13 +31,7 @@ def img_levels_compress(
|
||||
|
||||
for ch in range(3):
|
||||
channel = arr[:, :, ch]
|
||||
|
||||
if skip_if_no_clip:
|
||||
# Per-side threshold check:
|
||||
# Black side fires only if the channel has pixels at or below the
|
||||
# new black point threshold.
|
||||
# White side fires only if the channel has pixels at or above the
|
||||
# new white point threshold.
|
||||
ch_min = float(channel.min())
|
||||
ch_max = float(channel.max())
|
||||
black_active = ch_min <= bo
|
||||
@@ -122,22 +40,17 @@ def img_levels_compress(
|
||||
black_active = True
|
||||
white_active = True
|
||||
|
||||
# Determine effective offsets for this channel
|
||||
bo_eff = bo if black_active else 0.0
|
||||
wo_eff = wo if white_active else 0.0
|
||||
|
||||
if bo_eff == 0.0 and wo_eff == 0.0:
|
||||
# Neither side active — passthrough this channel
|
||||
out[:, :, ch] = channel
|
||||
continue
|
||||
|
||||
# Proportional compression formula
|
||||
# output = bo_eff + input × (max_val − wo_eff − bo_eff) / max_val
|
||||
compress_range = max_val - wo_eff - bo_eff
|
||||
scale = compress_range / max_val
|
||||
out[:, :, ch] = bo_eff + channel * scale
|
||||
|
||||
# Convert back to uint8
|
||||
if high_precision:
|
||||
out_8f = np.clip(out * (255.0 / max_val), 0, 255)
|
||||
else:
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def img_posterize(
|
||||
image: Image.Image,
|
||||
channels_data: dict,
|
||||
) -> Image.Image:
|
||||
levels_r = int(channels_data.get("Red", 255))
|
||||
levels_g = int(channels_data.get("Green", 255))
|
||||
levels_b = int(channels_data.get("Blue", 255))
|
||||
|
||||
levels_r = max(1, min(255, levels_r))
|
||||
levels_g = max(1, min(255, levels_g))
|
||||
levels_b = max(1, min(255, levels_b))
|
||||
|
||||
if levels_r == 255 and levels_g == 255 and levels_b == 255:
|
||||
return image.convert("RGB")
|
||||
|
||||
img = image.convert("RGB")
|
||||
arr = np.array(img, dtype=np.float32) / 255.0
|
||||
|
||||
def _posterize(ch_arr, levels):
|
||||
if levels >= 255:
|
||||
return ch_arr
|
||||
step = 1.0 / levels
|
||||
return np.floor(ch_arr / step) * step
|
||||
|
||||
out = np.stack([
|
||||
_posterize(arr[..., 0], levels_r),
|
||||
_posterize(arr[..., 1], levels_g),
|
||||
_posterize(arr[..., 2], levels_b),
|
||||
], axis=-1)
|
||||
|
||||
return Image.fromarray(np.clip(out * 255, 0, 255).astype(np.uint8), mode="RGB")
|
||||
@@ -9,38 +9,6 @@ def img_shade_level(
|
||||
radius: float = 0,
|
||||
strength: float = 0.5,
|
||||
) -> Image.Image:
|
||||
"""
|
||||
Micro-contrast / shade density adjustment on the L channel only.
|
||||
Hue and saturation are never modified.
|
||||
|
||||
Args:
|
||||
image : PIL Image (RGB)
|
||||
shade_level : -100 … +100. 0 = no change (passthrough).
|
||||
Positive = amplify local shade differences (more texture,
|
||||
less plastic/flat look).
|
||||
Negative = suppress local shade differences (smoother,
|
||||
more painterly).
|
||||
radius : 0.0 … 50.0. Gaussian blur radius in pixels.
|
||||
Defines the spatial scale of the effect:
|
||||
0 = auto: ~1% of the shorter image dimension.
|
||||
1–3 = pixel-level texture and fine grain only.
|
||||
4–10 = mid-level detail, good for portraits.
|
||||
15–50 = broad tonal area transitions.
|
||||
Tip: smaller radius = sharper/grainier effect.
|
||||
larger radius = smoother/broader contrast lift.
|
||||
strength : 0.0 … 1.0. Controls the ceiling of what shade_level
|
||||
±100 can do, and balances the positive/negative asymmetry.
|
||||
0.0 = very subtle — positive and negative equally gentle.
|
||||
0.5 = default — positive ~3× stronger than negative
|
||||
(amplifying detail is more dramatic
|
||||
than suppressing it).
|
||||
1.0 = maximum — positive very aggressive,
|
||||
negative also stronger than default.
|
||||
Positive multiplier: 1.0 + strength * 4.0 (1.0 … 5.0)
|
||||
Negative multiplier: 0.5 + strength * 1.0 (0.5 … 1.5)
|
||||
Returns:
|
||||
PIL Image (RGB)
|
||||
"""
|
||||
if shade_level == 0:
|
||||
return image.convert("RGB")
|
||||
|
||||
@@ -56,13 +24,8 @@ def img_shade_level(
|
||||
img = image.convert("RGB")
|
||||
arr = np.array(img, dtype=np.float32) / 255.0
|
||||
|
||||
# ── RGB → Lab ─────────────────────────────────────────────────────────────
|
||||
def rgb_to_lab(rgb):
|
||||
linear = np.where(
|
||||
rgb <= 0.04045,
|
||||
rgb / 12.92,
|
||||
((rgb + 0.055) / 1.055) ** 2.4
|
||||
)
|
||||
linear = np.where(rgb <= 0.04045, rgb / 12.92, ((rgb + 0.055) / 1.055) ** 2.4)
|
||||
M = np.array([
|
||||
[0.4124564, 0.3575761, 0.1804375],
|
||||
[0.2126729, 0.7151522, 0.0721750],
|
||||
@@ -85,8 +48,7 @@ def img_shade_level(
|
||||
fz = fy - b / 200.0
|
||||
def f_inv(t):
|
||||
delta = 6.0 / 29.0
|
||||
return np.where(t > delta, t ** 3,
|
||||
3 * delta**2 * (t - 4.0/29.0))
|
||||
return np.where(t > delta, t ** 3, 3 * delta**2 * (t - 4.0/29.0))
|
||||
xyz_n = np.array([0.95047, 1.00000, 1.08883], dtype=np.float32)
|
||||
xyz = np.stack([f_inv(fx), f_inv(fy), f_inv(fz)], axis=-1) * xyz_n
|
||||
M_inv = np.array([
|
||||
@@ -99,20 +61,15 @@ def img_shade_level(
|
||||
1.055 * np.power(np.clip(linear, 0, None), 1.0/2.4) - 0.055)
|
||||
return np.clip(srgb, 0.0, 1.0)
|
||||
|
||||
# ── Resolve radius ─────────────────────────────────────────────────────────
|
||||
lab = rgb_to_lab(arr)
|
||||
L = lab[..., 0]
|
||||
H, W = L.shape
|
||||
|
||||
r = radius if radius > 0.0 else max(1.0, min(H, W) * 0.01)
|
||||
|
||||
# ── Detail layer ──────────────────────────────────────────────────────────
|
||||
L_blurred = gaussian_filter(L, sigma=r)
|
||||
detail = L - L_blurred
|
||||
|
||||
# ── Strength-controlled asymmetric multiplier ─────────────────────────────
|
||||
# Positive ceiling: 1.0 … 5.0 (amplifying detail is inherently stronger)
|
||||
# Negative ceiling: 0.5 … 1.5 (suppression is gentler by nature)
|
||||
if shade_level > 0:
|
||||
multiplier = 1.0 + strength * 4.0
|
||||
else:
|
||||
|
||||
@@ -20,21 +20,18 @@ def img_white_balance(
|
||||
|
||||
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:
|
||||
@@ -45,18 +42,14 @@ def img_white_balance(
|
||||
|
||||
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
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
|
||||
const CB_TONES = ["highlights", "midtones", "shadows"];
|
||||
const HS_CHANNELS = ["master", "red", "green", "blue"];
|
||||
const ST_ZONES = ["highlights", "midtones", "shadows", "blacks"];
|
||||
const SH_MODES = ["fine", "medium", "broad"];
|
||||
const CB_TONES = ["highlights", "midtones", "shadows"];
|
||||
const HS_CHANNELS = ["master", "red", "green", "blue"];
|
||||
const ST_ZONES = ["highlights", "midtones", "shadows", "blacks"];
|
||||
const SH_MODES = ["fine", "medium", "broad"];
|
||||
const POST_CHANNELS = ["Red", "Green", "Blue"];
|
||||
|
||||
function buildFilmPresetMap(allPresets) {
|
||||
const byType = {};
|
||||
@@ -17,36 +18,32 @@ function buildFilmPresetMap(allPresets) {
|
||||
return byType;
|
||||
}
|
||||
|
||||
const cbDefault = () => ({
|
||||
const cbDefault = () => ({
|
||||
highlights: { cyan_red: 0, magenta_green: 0, yellow_blue: 0 },
|
||||
midtones: { cyan_red: 0, magenta_green: 0, yellow_blue: 0 },
|
||||
shadows: { cyan_red: 0, magenta_green: 0, yellow_blue: 0 },
|
||||
});
|
||||
|
||||
const hsDefault = () => ({
|
||||
const hsDefault = () => ({
|
||||
master: { hue: 0, saturation: 0, lightness: 0, vibrance: 0 },
|
||||
red: { hue: 0, saturation: 0, lightness: 0, vibrance: 0 },
|
||||
green: { hue: 0, saturation: 0, lightness: 0, vibrance: 0 },
|
||||
blue: { hue: 0, saturation: 0, lightness: 0, vibrance: 0 },
|
||||
});
|
||||
|
||||
const stDefault = () => ({
|
||||
highlights: 0,
|
||||
midtones: 0,
|
||||
shadows: 0,
|
||||
blacks: 0,
|
||||
});
|
||||
|
||||
const shDefault = () => ({
|
||||
const stDefault = () => ({ highlights: 0, midtones: 0, shadows: 0, blacks: 0 });
|
||||
const shDefault = () => ({
|
||||
fine: { shade_level: 0, shade_radius: 0 },
|
||||
medium: { shade_level: 0, shade_radius: 0 },
|
||||
broad: { shade_level: 0, shade_radius: 0 },
|
||||
});
|
||||
const postDefault = () => ({ Red: 255, Green: 255, Blue: 255 });
|
||||
|
||||
async function rasterixLoad() {
|
||||
try {
|
||||
const resp = await fetch('/primere_rasterix_read');
|
||||
if (!resp.ok) return { color_balance: cbDefault(), hue_saturation: hsDefault(), selective_tone: stDefault(), shade: shDefault() };
|
||||
if (!resp.ok) return {
|
||||
color_balance: cbDefault(), hue_saturation: hsDefault(),
|
||||
selective_tone: stDefault(), shade: shDefault(), posterize: postDefault(),
|
||||
};
|
||||
const data = await resp.json();
|
||||
|
||||
const cb = data.color_balance || {};
|
||||
@@ -65,9 +62,16 @@ async function rasterixLoad() {
|
||||
for (const m of SH_MODES)
|
||||
if (!sh[m]) sh[m] = { shade_level: 0, shade_radius: 0 };
|
||||
|
||||
return { color_balance: cb, hue_saturation: hs, selective_tone: st, shade: sh };
|
||||
const post = data.posterize || {};
|
||||
for (const ch of POST_CHANNELS)
|
||||
if (post[ch] === undefined) post[ch] = 255;
|
||||
|
||||
return { color_balance: cb, hue_saturation: hs, selective_tone: st, shade: sh, posterize: post };
|
||||
} catch {
|
||||
return { color_balance: cbDefault(), hue_saturation: hsDefault(), selective_tone: stDefault(), shade: shDefault() };
|
||||
return {
|
||||
color_balance: cbDefault(), hue_saturation: hsDefault(),
|
||||
selective_tone: stDefault(), shade: shDefault(), posterize: postDefault(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +91,11 @@ app.registerExtension({
|
||||
name: "Primere.Rasterix",
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
const rasterixNodes = ["PrimereRasterix", "PrimereSelectiveTone", "PrimereColorBalance", "PrimereHSL", "PrimereShadeDetailer", "PrimereHistogram", "PrimereFilmRendering"];
|
||||
const rasterixNodes = [
|
||||
"PrimereRasterix", "PrimereSelectiveTone", "PrimereColorBalance",
|
||||
"PrimereHSL", "PrimereShadeDetailer", "PrimereHistogram",
|
||||
"PrimereFilmRendering", "PrimerePosterize",
|
||||
];
|
||||
if (!rasterixNodes.includes(nodeData.name)) return;
|
||||
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
@@ -97,42 +105,43 @@ app.registerExtension({
|
||||
const node = this;
|
||||
const fw = (name) => node.widgets?.find(w => w.name === name);
|
||||
|
||||
// Color Balance
|
||||
const wTone = fw("color_balance_tone");
|
||||
const wCR = fw("color_balance_cyan_red");
|
||||
const wMG = fw("color_balance_magenta_green");
|
||||
const wYB = fw("color_balance_yellow_blue");
|
||||
|
||||
// Hue / Saturation
|
||||
const wHsCh = fw("hsl_channel");
|
||||
const wHsHue = fw("hsl_hue");
|
||||
const wHsSat = fw("hsl_saturation");
|
||||
const wHsLit = fw("hsl_lightness");
|
||||
const wHsVib = fw("hsl_vibrance");
|
||||
|
||||
// Selective Tone
|
||||
const wStZone = fw("selective_tone_zone");
|
||||
const wStVal = fw("selective_tone_value");
|
||||
|
||||
// Shade
|
||||
const wShMode = fw("detail_mode");
|
||||
const wShLvl = fw("shade_level");
|
||||
const wShRad = fw("shade_radius");
|
||||
|
||||
let cbStore = cbDefault();
|
||||
let hsStore = hsDefault();
|
||||
let stStore = stDefault();
|
||||
let shStore = shDefault();
|
||||
const wPostCh = fw("channels");
|
||||
const wPostSh = fw("shades");
|
||||
|
||||
let prevTone = wTone?.value ?? "midtones";
|
||||
let prevHsCh = wHsCh?.value ?? "master";
|
||||
let prevStZn = wStZone?.value ?? "midtones";
|
||||
let prevShMd = wShMode?.value ?? "medium";
|
||||
let updating = false;
|
||||
let cbStore = cbDefault();
|
||||
let hsStore = hsDefault();
|
||||
let stStore = stDefault();
|
||||
let shStore = shDefault();
|
||||
let postStore = postDefault();
|
||||
|
||||
let prevTone = wTone?.value ?? "midtones";
|
||||
let prevHsCh = wHsCh?.value ?? "master";
|
||||
let prevStZn = wStZone?.value ?? "midtones";
|
||||
let prevShMd = wShMode?.value ?? "medium";
|
||||
let prevPostCh = wPostCh?.value ?? "Red";
|
||||
let updating = false;
|
||||
let histogramDebounceTimer = null;
|
||||
|
||||
function applyFilmTypeFilter() {
|
||||
const filmTypeWidget = fw("film_type");
|
||||
const filmTypeWidget = fw("film_type");
|
||||
const filmRenderingWidget = fw("film_rendering");
|
||||
if (!filmTypeWidget || !filmRenderingWidget) return;
|
||||
|
||||
@@ -143,14 +152,14 @@ app.registerExtension({
|
||||
}
|
||||
|
||||
const selectedType = String(filmTypeWidget.value || "All");
|
||||
const allValues = node.__primereFilmAllPresets;
|
||||
const byType = node.__primereFilmByType || {};
|
||||
const nextValues = selectedType === "All"
|
||||
const allValues = node.__primereFilmAllPresets;
|
||||
const byType = node.__primereFilmByType || {};
|
||||
const nextValues = selectedType === "All"
|
||||
? allValues
|
||||
: (byType[selectedType] && byType[selectedType].length > 0 ? byType[selectedType] : allValues);
|
||||
: (byType[selectedType]?.length > 0 ? byType[selectedType] : allValues);
|
||||
|
||||
filmRenderingWidget.options = filmRenderingWidget.options || {};
|
||||
filmRenderingWidget.options.values = [...nextValues];
|
||||
filmRenderingWidget.options = filmRenderingWidget.options || {};
|
||||
filmRenderingWidget.options.values = [...nextValues];
|
||||
|
||||
if (!nextValues.includes(filmRenderingWidget.value)) {
|
||||
filmRenderingWidget.value = nextValues[0] || filmRenderingWidget.value;
|
||||
@@ -192,8 +201,8 @@ app.registerExtension({
|
||||
|
||||
function histogramFileUrl(showInput, channel, style) {
|
||||
const prefix = showInput ? "input" : "output";
|
||||
const ch = (channel || "RGB").toLowerCase();
|
||||
const st = style || "bars";
|
||||
const ch = (channel || "RGB").toLowerCase();
|
||||
const st = style || "bars";
|
||||
return `/extensions/ComfyUI_Primere_Nodes/images/${prefix}_histogram_${ch}_${st}.jpg`;
|
||||
}
|
||||
|
||||
@@ -214,13 +223,13 @@ app.registerExtension({
|
||||
async function generateHistogram(showInput, channel, style) {
|
||||
try {
|
||||
await fetch('/primere_rasterix_histogram_generate', {
|
||||
method: 'POST',
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
histogram_source: showInput,
|
||||
body: JSON.stringify({
|
||||
histogram_source: showInput,
|
||||
histogram_channel: channel || "RGB",
|
||||
histogram_style: style || "bars",
|
||||
precision: fw("precision")?.value ?? false,
|
||||
histogram_style: style || "bars",
|
||||
precision: fw("precision")?.value ?? false,
|
||||
}),
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -247,19 +256,16 @@ app.registerExtension({
|
||||
|
||||
function currentHistState() {
|
||||
return {
|
||||
enabled: fw("show_histogram")?.value ?? false,
|
||||
showInput: fw("histogram_source")?.value ?? false,
|
||||
channel: fw("histogram_channel")?.value ?? "RGB",
|
||||
style: fw("histogram_style")?.value ?? "bars",
|
||||
enabled: fw("show_histogram")?.value ?? false,
|
||||
showInput: fw("histogram_source")?.value ?? false,
|
||||
channel: fw("histogram_channel")?.value ?? "RGB",
|
||||
style: fw("histogram_style")?.value ?? "bars",
|
||||
};
|
||||
}
|
||||
|
||||
node.onExecuted = async function() {
|
||||
node.onExecuted = async function () {
|
||||
const { enabled, showInput, channel, style } = currentHistState();
|
||||
if (!enabled) {
|
||||
showHistogramOffImage();
|
||||
return;
|
||||
}
|
||||
if (!enabled) { showHistogramOffImage(); return; }
|
||||
if (await histogramFileExists(showInput, channel, style)) {
|
||||
updateHistogramDisplay(showInput, channel, style);
|
||||
return;
|
||||
@@ -268,7 +274,7 @@ app.registerExtension({
|
||||
updateHistogramDisplay(showInput, channel, style);
|
||||
};
|
||||
|
||||
// ── Color Balance ─────────────────────────────────────────────────
|
||||
// Color Balance
|
||||
function applyCbSliders(tone) {
|
||||
if (!wCR) return;
|
||||
updating = true;
|
||||
@@ -285,7 +291,7 @@ app.registerExtension({
|
||||
rasterixSave('color_balance', cbStore);
|
||||
}
|
||||
|
||||
// ── Hue / Saturation ──────────────────────────────────────────────
|
||||
// Hue / Saturation
|
||||
function applyHsSliders(ch) {
|
||||
if (!wHsHue) return;
|
||||
updating = true;
|
||||
@@ -303,7 +309,7 @@ app.registerExtension({
|
||||
rasterixSave('hue_saturation', hsStore);
|
||||
}
|
||||
|
||||
// ── Selective Tone ────────────────────────────────────────────────
|
||||
// Selective Tone
|
||||
function applyStSlider(zone) {
|
||||
if (!wStVal) return;
|
||||
updating = true;
|
||||
@@ -317,7 +323,7 @@ app.registerExtension({
|
||||
rasterixSave('selective_tone', stStore);
|
||||
}
|
||||
|
||||
// ── Shade ─────────────────────────────────────────────────────────
|
||||
// Shade
|
||||
function applyShSliders(mode) {
|
||||
if (!wShLvl) return;
|
||||
updating = true;
|
||||
@@ -333,24 +339,41 @@ app.registerExtension({
|
||||
rasterixSave('shade', shStore);
|
||||
}
|
||||
|
||||
// ── Initial load ──────────────────────────────────────────────────
|
||||
// Posterize
|
||||
function applyPostSlider(ch) {
|
||||
if (!wPostSh) return;
|
||||
updating = true;
|
||||
wPostSh.value = postStore[ch] ?? 255;
|
||||
updating = false;
|
||||
app.canvas?.setDirty(true);
|
||||
}
|
||||
function capturePostSlider(ch) {
|
||||
if (!wPostSh) return;
|
||||
postStore[ch] = wPostSh.value;
|
||||
rasterixSave('posterize', postStore);
|
||||
}
|
||||
|
||||
// Initial load
|
||||
rasterixLoad().then(loaded => {
|
||||
cbStore = loaded.color_balance;
|
||||
hsStore = loaded.hue_saturation;
|
||||
stStore = loaded.selective_tone;
|
||||
shStore = loaded.shade;
|
||||
cbStore = loaded.color_balance;
|
||||
hsStore = loaded.hue_saturation;
|
||||
stStore = loaded.selective_tone;
|
||||
shStore = loaded.shade;
|
||||
postStore = loaded.posterize;
|
||||
applyCbSliders(prevTone);
|
||||
applyHsSliders(prevHsCh);
|
||||
applyStSlider(prevStZn);
|
||||
applyShSliders(prevShMd);
|
||||
applyPostSlider(prevPostCh);
|
||||
});
|
||||
|
||||
// ── Widget change handler ─────────────────────────────────────────
|
||||
// Widget change handler
|
||||
node.onWidgetChanged = function (name, value) {
|
||||
if (updating) return;
|
||||
|
||||
if (name === "film_type") {
|
||||
applyFilmTypeFilter();
|
||||
|
||||
} else if (name === "color_balance_tone") {
|
||||
captureCbSliders(prevTone);
|
||||
applyCbSliders(value);
|
||||
@@ -391,6 +414,13 @@ app.registerExtension({
|
||||
) {
|
||||
captureShSliders(wShMode?.value ?? prevShMd);
|
||||
|
||||
} else if (name === "channels") {
|
||||
capturePostSlider(prevPostCh);
|
||||
applyPostSlider(value);
|
||||
prevPostCh = value;
|
||||
} else if (name === "shades") {
|
||||
capturePostSlider(wPostCh?.value ?? prevPostCh);
|
||||
|
||||
} else if (name === "histogram_source") {
|
||||
const { enabled, channel, style } = currentHistState();
|
||||
if (enabled) requestHistogramSwitch(value, channel, style);
|
||||
|
||||
+2
-2
@@ -1,4 +1,3 @@
|
||||
deepface
|
||||
Pillow
|
||||
PyYAML
|
||||
Requests
|
||||
@@ -52,4 +51,5 @@ openai
|
||||
google-genai>=1.65.0
|
||||
dotenv
|
||||
elevenlabs
|
||||
python-magic-bin
|
||||
python-magic-bin
|
||||
deepface
|
||||
Reference in New Issue
Block a user