501 lines
27 KiB
Python
501 lines
27 KiB
Python
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
|