V 2.0.0 - Rasterix - Posterize

This commit is contained in:
DESKTOP-TVBJISQ\Primere
2026-03-29 11:28:33 +02:00
parent 65a88eb744
commit 1eaf8b1ff1
15 changed files with 177 additions and 565 deletions
+35
View File
@@ -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",)
+2
View File
@@ -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)",
-99
View File
@@ -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)
+1 -9
View File
@@ -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
-42
View File
@@ -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)
-2
View File
@@ -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]
-1
View File
@@ -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",
+4 -19
View File
@@ -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")
-186
View File
@@ -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)
-87
View File
@@ -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:
+35
View File
@@ -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")
+2 -45
View File
@@ -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:
-7
View File
@@ -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
+96 -66
View File
@@ -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
View File
@@ -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