Files
CosmicLaca-ComfyUI_Primere_…/components/images/histogram.py
T

501 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import numpy as np
from PIL import Image, ImageFilter
# ─────────────────────────────────────────────────────────────────────────────
# 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))],
"GREEN": [(1, (0.22, 1.0, 0.22))],
"BLUE": [(2, (0.22, 0.44, 1.0))],
}
# All valid style names
VALID_STYLES = {
# "gradient", # original — filled area with top-to-bottom brightness fade
"bars", # original — flat filled area
"lines", # original — thin outline only (2px)
# "glow", # original — bars + gaussian bloom
"waveform", # center-line oscilloscope, mirrored above/below midpoint
"heatmap", # single-channel density map using perceptual colour ramp
"stacked", # R/G/B channels stacked (not overlapping)
# "dots", # vertical dot columns, density proportional to count
# "step", # raw unsmoothed step function — shows true comb pattern
"luma", # gradient + luminosity curve overlaid in white
# "log", # gradient with log-scale Y axis
"parade", # R | G | B panels side by side (ignores channel arg)
# "percentile", # gradient + vertical lines at 10/25/50/75/90 percentiles
# "inverse", # light background variant of gradient
}
# ─────────────────────────────────────────────────────────────────────────────
# Internal helpers
# ─────────────────────────────────────────────────────────────────────────────
def _get_raw(arr: np.ndarray, ch_idx: int, precision: bool) -> np.ndarray:
"""Return 256-bin histogram for one channel."""
if precision:
arr_16 = arr[:, :, ch_idx] * (65535.0 / 255.0)
raw_16, _ = np.histogram(arr_16, bins=65536, range=(0, 65536))
return raw_16.reshape(256, 256).sum(axis=1).astype(np.float32)
else:
raw, _ = np.histogram(arr[:, :, ch_idx], bins=256, range=(0, 256))
return raw.astype(np.float32)
def _make_smooth(sigma: float):
"""Return a closure that gaussian-smooths a 256-element array."""
size = max(int(sigma * 4) | 1, 3)
kx = np.arange(size) - size // 2
k = np.exp(-0.5 * (kx / sigma) ** 2); k /= k.sum()
def _smooth(h: np.ndarray) -> np.ndarray:
return np.convolve(h.astype(np.float32), k, mode='same')
return _smooth
def _normalise(h: np.ndarray, smooth_fn, sqrt: bool) -> np.ndarray:
sm = smooth_fn(h)
if sqrt:
sm = np.sqrt(np.maximum(sm, 0))
return sm / (sm.max() or 1.0)
def _log_normalise(h: np.ndarray, smooth_fn) -> np.ndarray:
sm = smooth_fn(h)
sm = np.log1p(np.maximum(sm, 0))
return sm / (sm.max() or 1.0)
def _dark_canvas(h: int, w: int) -> np.ndarray:
canvas = np.full((h, w, 3), 18.0 / 255.0, dtype=np.float32)
for frac in (0.25, 0.5, 0.75):
canvas[int((1.0 - frac) * (h - 1)), :] = 0.32
canvas[:, int(frac * (w - 1)), :] = 0.32
return canvas
def _light_canvas(h: int, w: int) -> np.ndarray:
canvas = np.full((h, w, 3), 0.92, dtype=np.float32)
for frac in (0.25, 0.5, 0.75):
canvas[int((1.0 - frac) * (h - 1)), :] = 0.72
canvas[:, int(frac * (w - 1)), :] = 0.72
return canvas
def _cols_heights(norm256: np.ndarray, hist_w: int, hist_h: int):
"""Interpolate normalised 256-bin curve to hist_w display columns."""
x_idx = np.linspace(0, 255, hist_w)
cols = np.interp(x_idx, np.arange(256), norm256)
heights = (cols * (hist_h - 1)).astype(int)
return cols, heights
def _draw_gradient(canvas, heights, color, hist_h, hist_w):
row_idx = np.arange(hist_h).reshape(-1, 1)
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)
grad = np.clip(0.28 + 0.72 * (dist_b / safe_h), 0.0, 1.0)
for ci, cv in enumerate(color):
canvas[:, :, ci] = np.where(
fill_mask, np.maximum(canvas[:, :, ci], grad * cv), canvas[:, :, ci])
def _draw_bars(canvas, heights, color, hist_h, hist_w, alpha=1.0):
row_idx = np.arange(hist_h).reshape(-1, 1)
fill_mask = row_idx >= (hist_h - heights)
for ci, cv in enumerate(color):
canvas[:, :, ci] = np.where(
fill_mask, np.maximum(canvas[:, :, ci], cv * alpha), canvas[:, :, ci])
def _draw_lines(canvas, heights, color, hist_h, hist_w):
for dy in range(2):
rs = np.clip(hist_h - heights - dy, 0, hist_h - 1)
xs = np.arange(hist_w)[heights > 0]
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 histogram (256 bins, sigma=1.0, linear norm)
True = 16-bit histogram (65536→256 bins, sigma=0.75,
sqrt normalisation)
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
sqrt_norm = precision
sigma = (0.5 if style in ("bars","step","dots") else 0.75) if precision else \
(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"]):
ox = p * panel_w
raw = _get_raw(arr, ch_idx, precision)
norm = _normalise(raw, smooth, sqrt_norm)
_, heights = _cols_heights(norm, panel_w, hist_h)
sub = canvas[:, ox:ox+panel_w, :]
_draw_gradient(sub, heights, color, hist_h, panel_w)
canvas[:, ox:ox+panel_w, :] = sub
result = Image.fromarray(
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])
if precision:
l16 = luma_arr * (65535.0/255.0)
r16, _ = np.histogram(l16, bins=65536, range=(0,65536))
luma_raw = r16.reshape(256,256).sum(axis=1).astype(np.float32)
else:
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])
ramp_b = np.array([0.0, 0.55, 0.85, 1.0])
cr = np.interp(cols, ramp_t, ramp_r)
cg = np.interp(cols, ramp_t, ramp_g)
cb = np.interp(cols, ramp_t, ramp_b)
row_idx = np.arange(hist_h).reshape(-1, 1)
heights = (cols * (hist_h - 1)).astype(int)
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)
grad = np.clip(0.15 + 0.85 * (dist_b / safe_h), 0.0, 1.0)
for ci, cramp in enumerate([cr, cg, cb]):
canvas[:, :, ci] = np.where(fill_mask,
np.maximum(canvas[:, :, ci], grad * cramp), canvas[:, :, ci])
result = Image.fromarray(
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)
floor_h = cum_h.astype(int)
top_h = (cum_h + layer_h).astype(int)
for x in range(hist_w):
if layer_h[x] > 0:
y_lo = hist_h - 1 - top_h[x]
y_hi = hist_h - 1 - floor_h[x]
y_lo = np.clip(y_lo, 0, hist_h-1)
y_hi = np.clip(y_hi, 0, hist_h-1)
if y_lo <= y_hi:
for ci, cv in enumerate(color):
canvas[y_lo:y_hi+1, x, ci] = np.maximum(
canvas[y_lo:y_hi+1, x, ci], cv * 0.85)
cum_h += layer_h
result = Image.fromarray(
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":
x_idx = np.linspace(0, 255, hist_w)
row_idx = np.arange(hist_h).reshape(-1, 1)
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:
norm256 = raw / (raw.max() or 1.0)
# Step: each of 256 bins gets hist_w/256 columns at same height
bin_w = hist_w / 256.0
heights = np.zeros(hist_w, dtype=int)
for b in range(256):
x_lo = int(b * bin_w)
x_hi = int((b + 1) * bin_w)
heights[x_lo:x_hi] = int(norm256[b] * (hist_h - 1))
fill_mask = row_idx >= (hist_h - heights)
for ci, cv in enumerate(color):
canvas[:, :, ci] = np.where(
fill_mask, np.maximum(canvas[:, :, ci], cv * 0.9), canvas[:, :, ci])
# Draw top edge in bright white for each bin
for b in range(256):
x_lo = int(b * bin_w); x_hi = int((b+1) * bin_w)
h_val = int(norm256[b] * (hist_h - 1))
y = np.clip(hist_h - 1 - h_val, 0, hist_h-1)
canvas[y, x_lo:x_hi, :] = np.maximum(canvas[y, x_lo:x_hi, :], 0.95)
result = Image.fromarray(
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
for ch_idx, color in draw_channels:
raw = _get_raw(arr, ch_idx, precision)
norm = _normalise(raw, smooth, sqrt_norm)
cols = np.interp(x_idx, np.arange(256), norm)
amp = (cols * (mid - 2)).astype(int) # half-amplitude
for x in range(hist_w):
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
for ch_idx, color in draw_channels:
raw = _get_raw(arr, ch_idx, precision)
norm = _normalise(raw, smooth, sqrt_norm)
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))
brightness = 0.5 + 0.5 * (1.0 - pos / hist_h)
for ci, cv in enumerate(color):
canvas[y, x, ci] = max(canvas[y, x, ci], cv * brightness)
result = Image.fromarray(
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:
raw = _get_raw(arr, ch_idx, precision)
norm = _log_normalise(raw, smooth)
_, heights = _cols_heights(norm, hist_w, hist_h)
_draw_gradient(canvas, heights, color, hist_h, hist_w)
result = Image.fromarray(
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)
white = (1.0, 1.0, 1.0)
_draw_lines(canvas, heights_luma, white, hist_h, hist_w)
result = Image.fromarray(
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:
raw = _get_raw(arr, ch_idx, precision)
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()
else:
flat = arr[:, :, draw_channels[0][0]].ravel()
pcts = [10, 25, 50, 75, 90]
pvals = np.percentile(flat, pcts)
pct_colors = [
(0.60, 0.60, 0.60), # 10th — grey
(0.85, 0.85, 0.30), # 25th — yellow
(1.00, 1.00, 1.00), # 50th — white (median)
(0.85, 0.85, 0.30), # 75th — yellow
(0.60, 0.60, 0.60), # 90th — grey
]
for pval, pcol in zip(pvals, pct_colors):
x_pos = int(np.interp(pval, [0, 255], [0, hist_w - 1]))
x_pos = np.clip(x_pos, 0, hist_w - 1)
for ci, cv in enumerate(pcol):
canvas[:, x_pos, ci] = cv
result = Image.fromarray(
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:
raw = _get_raw(arr, ch_idx, precision)
norm = _normalise(raw, smooth, sqrt_norm)
cols = np.interp(x_idx, np.arange(256), norm)
heights = (cols * (hist_h - 1)).astype(int)
row_idx = np.arange(hist_h).reshape(-1, 1)
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),
canvas[:, :, ci])
result = Image.fromarray(
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)
_, heights = _cols_heights(norm, hist_w, hist_h)
if style in ("gradient", "inverse"):
_draw_gradient(canvas, heights, color, hist_h, hist_w)
elif style == "lines":
_draw_lines(canvas, heights, color, hist_h, hist_w)
else: # bars, glow
_draw_bars(canvas, heights, color, hist_h, hist_w)
result = Image.fromarray(
np.clip(canvas * 255, 0, 255).astype(np.uint8), mode="RGB")
if style == "glow":
bloom = result.filter(ImageFilter.GaussianBlur(radius=5))
result = Image.fromarray(
np.clip(
np.array(result, dtype=np.float32) +
np.array(bloom, dtype=np.float32) * 0.55,
0, 255).astype(np.uint8), mode="RGB")
return result