V 2.0.0 - Rasterix - new dithering

This commit is contained in:
DESKTOP-TVBJISQ\Primere
2026-03-24 19:40:27 +01:00
parent 5051565d2b
commit 5db0b1aefc
3 changed files with 103 additions and 79 deletions
+5 -8
View File
@@ -2250,11 +2250,11 @@ class PrimereRasterix:
"skip_if_no_clip": ("BOOLEAN", {"default": False, "label_off": "Offset all values", "label_on": "Skip if no clips"}),
"normalize_gaps": ("BOOLEAN", {"default": False, "label_on": "Anti-comb filter: ON", "label_off": "Anti-comb filter: OFF"}),
"dither_quantization": ("BOOLEAN", {"default": False, "label_off": "Dither quantization OFF", "label_on": "Dither quantization ON"}),
"adaptive_dither_strength": ("BOOLEAN", {"default": False, "label_off": "Keep dither strength", "label_on": "Increase dither strength"}),
"error_diffusion": ("BOOLEAN", {"default": False, "label_off": "Ignore endpoint offset", "label_on": "Apply endpoint offset"}),
"normalize_midpeaks": ("BOOLEAN", {"default": False, "label_on": "Anti-spike filter: ON", "label_off": "Anti-spike filter: OFF"}),
"peak_width": ("INT", {"default": 3, "min": 1, "max": 10, "step": 1}),
"dither_quantization": ("BOOLEAN", {"default": False, "label_off": "Dither quantization OFF", "label_on": "Dither quantization ON"}),
"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_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}),
@@ -2279,12 +2279,9 @@ class PrimereRasterix:
rasterix_json_path = os.path.join(PRIMERE_ROOT, 'front_end', 'rasterix.json')
rasterix_data = utility.json2tuple(rasterix_json_path) or {}
stretched_gaps_spike = []
scale_spike = []
rng_gap_spike = []
if auto_normalize:
pil_img = img_levels_auto.img_levels_auto(image=pil_img, auto_normalize=auto_normalize, threshold=auto_levels_threshold, auto_gamma=auto_gamma, gamma_target=gamma_target, precision=precision)
pil_img = img_levels_auto.img_levels_auto(image=pil_img, auto_normalize=auto_normalize, threshold=auto_levels_threshold, normalize_gaps=normalize_gaps, normalize_midpeaks=False, peak_width=peak_width, auto_gamma=auto_gamma, gamma_target=gamma_target, precision=precision)
if use_white_balance and (wb_temperature != 6500 or wb_tint != 0):
pil_img = img_white_balance.img_white_balance(image=pil_img, temperature=wb_temperature, tint=wb_tint)
@@ -2325,7 +2322,7 @@ class PrimereRasterix:
pil_img = img_levels_compress.img_levels_compress(image=pil_img, black_offset=black_offset, white_offset=white_offset, skip_if_no_clip=skip_if_no_clip, high_precision=precision)
if dither_quantization or error_diffusion or normalize_midpeaks:
pil_img = img_dithering.img_dithering(image=pil_img, normalize_gaps_legacy=normalize_gaps, stretched_gaps_spike=stretched_gaps_spike, scale_spike=scale_spike, rng_gap_spike=rng_gap_spike, 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)
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)
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)
+19 -52
View File
@@ -2,6 +2,7 @@ import numpy as np
from PIL import Image
from numpy.lib.stride_tricks import sliding_window_view
def _adaptive_dither_amplitude(scale: float, adaptive: bool, max_val: float) -> float:
"""
Return dither amplitude in output-code units (LSB of 8-bit domain).
@@ -94,31 +95,8 @@ def _normalize_midpeaks_channel(
return np.where(qualify_mask, np.clip(result + noise, 0.0, max_val), result)
def _normalize_gaps_legacy(
stretched: np.ndarray,
scale: float,
rng_gap: np.random.Generator,
max_val: float = 255.0,
) -> np.ndarray:
"""
Anti-comb filter — TPDF gap dithering (moved from img_levels_auto).
Fills quantization gaps from non-integer stretch scale factors.
"""
print('------------ 5 -------------------')
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))
return np.clip(stretched + noise, 0.0, max_val)
def img_dithering(
image: Image.Image,
normalize_gaps_legacy: bool = False,
stretched_gaps_spike: list = [],
scale_spike: list = [],
rng_gap_spike: list = [],
dither_quantization: bool = True,
adaptive_dither_strength: bool = True,
error_diffusion: bool = False,
@@ -126,56 +104,45 @@ def img_dithering(
peak_width: int = 3,
high_precision: bool = False,
) -> Image.Image:
"""
Standalone quantization dither stage for post-processing.
Args:
image: PIL Image (RGB)
dither_quantization: Apply TPDF dither before rounding.
adaptive_dither_strength: Adapt dither amount to current tonal span.
error_diffusion: Use Floyd-Steinberg quantization path.
high_precision: False = process in 8-bit domain (0..255),
True = process in 16-bit domain (0..65535),
then convert back to 8-bit RGB output.
normalize_midpeaks: Alternative/extra anti-spike smoothing near
histogram gaps before final quantization.
peak_width: 1..10 neighborhood used by normalize_midpeaks.
"""
if not (1 <= peak_width <= 10):
raise ValueError(f"peak_width must be 1–10, got {peak_width}")
print('------------ 2 -------------------')
arr_8f = np.array(image.convert("RGB"), dtype=np.float32)
max_val = 65535.0 if high_precision else 255.0
scale_factor = max_val / 255.0
arr = arr_8f * scale_factor if high_precision else arr_8f
# 1) Legacy anti-comb (TPDF gap dither from auto-levels stretch)
# - If lists were provided by img_levels_auto → use exact per-channel scale + RNG
# - Otherwise (auto-levels was off) → fallback to a useful non-specific scale
# estimated from the current image tonal span (exactly as you requested).
if normalize_gaps_legacy:
print('------------ 3 -------------------')
if len(stretched_gaps_spike) > 0 and len(scale_spike) > 0 and len(rng_gap_spike) > 0:
print('------------ 4a -------------------')
# Auto-levels was used → use its exact stretch parameters
for ch in range(3):
scale = scale_spike[ch]
rng_gap = rng_gap_spike[ch]
# Apply to the current image (post-gamma if auto_gamma was enabled).
# This is the cleanest logical placement now that the function lives here.
arr[:, :, ch] = _normalize_gaps_legacy(arr[:, :, ch], scale, rng_gap, max_val)
else:
print('------------ 4b -------------------')
# Auto-levels was OFF → run with useful non-specific parameters
local_scale = _estimate_global_scale(arr, max_val)
for ch in range(3):
rng_gap = np.random.default_rng(ch) # same seeding style as levels_auto
arr[:, :, ch] = _normalize_gaps_legacy(arr[:, :, ch], local_scale, rng_gap, max_val)
# 2) Mid-peak smoothing
if normalize_midpeaks:
for ch in range(3):
rng = np.random.default_rng(100 + ch)
arr[:, :, ch] = _normalize_midpeaks_channel(arr[:, :, ch], peak_width, max_val, rng)
# 3) Quantization path
if error_diffusion:
quantized = _floyd_steinberg_quantize(arr, max_val)
else:
quant_input = arr
if dither_quantization:
local_scale = _estimate_global_scale(quant_input, max_val)
amp = _adaptive_dither_amplitude(local_scale, adaptive_dither_strength, max_val)
scale = _estimate_global_scale(quant_input, max_val)
amp = _adaptive_dither_amplitude(scale, adaptive_dither_strength, max_val)
quant_input = quant_input + _tpdf_noise(quant_input.shape, amp)
quantized = np.clip(np.rint(quant_input), 0, max_val)
out_8f = quantized * (255.0 / max_val) if high_precision else quantized
out_8 = np.clip(np.rint(out_8f), 0, 255).astype(np.uint8)
return Image.fromarray(out_8, mode="RGB")
return Image.fromarray(out_8, mode="RGB")
+79 -19
View File
@@ -130,6 +130,73 @@ def levels_edge_spread(
return result
def levels_normalize_midpeaks(
stretched: np.ndarray,
peak_width: int,
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
gap_bins = set(int(b) for b in range(lo, hi) if s_hist[b] == 0)
if not gap_bins:
return result
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
from numpy.lib.stride_tricks import sliding_window_view
pad = peak_width
padded = np.pad(gap_arr, pad, mode='constant', constant_values=False)
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]:
continue
qualify_mask |= (s_int == b)
result = np.where(qualify_mask, np.clip(result + noise, 0.0, max_val), result)
return result
def levels_normalize_gaps(
stretched: np.ndarray,
scale: float,
@@ -157,8 +224,7 @@ def levels_normalize_gaps(
"""
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))
noise = (rng_gap.uniform(-half, half, stretched.shape).astype(np.float32) + rng_gap.uniform(-half, half, stretched.shape).astype(np.float32))
return np.clip(stretched + noise, 0.0, max_val)
@@ -208,12 +274,12 @@ def levels_auto_gamma(
def img_levels_auto(
image: Image.Image,
auto_normalize: bool = False,
auto_normalize: bool = True,
threshold: float = 2.0,
# normalize_gaps: bool = False,
# normalize_midpeaks: bool = False,
# peak_width: int = 3,
auto_gamma: bool = False,
normalize_gaps: bool = True,
normalize_midpeaks: bool = False,
peak_width: int = 3,
auto_gamma: bool = True,
gamma_target: float = 128.0,
precision: bool = False,
) -> Image.Image:
@@ -273,9 +339,6 @@ def img_levels_auto(
6. levels_auto_gamma — gamma correction (if auto_gamma)
"""
img = image.convert("RGB")
stretched_gaps_spike = []
scale_spike = []
rng_gap_spike = []
if not auto_normalize:
return img
@@ -284,8 +347,8 @@ def img_levels_auto(
raise ValueError(f"threshold must be 0.0–100.0, got {threshold}")
if not (0.0 <= gamma_target <= 255.0):
raise ValueError(f"gamma_target must be 0–255, got {gamma_target}")
# if not (1 <= peak_width <= 10):
# raise ValueError(f"peak_width must be 1–10, got {peak_width}")
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
@@ -302,7 +365,7 @@ def img_levels_auto(
for ch in range(3):
rng_gap = np.random.default_rng(ch)
# rng_spike = np.random.default_rng(ch + 100)
rng_spike = np.random.default_rng(ch + 100)
channel = arr[:, :, ch]
@@ -316,15 +379,12 @@ def img_levels_auto(
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)
if normalize_midpeaks:
stretched = levels_normalize_midpeaks(stretched, peak_width, rng_spike, max_val)
# 5. Gap dithering
''' if normalize_gaps:
if normalize_gaps:
stretched = levels_normalize_gaps(stretched, scale, rng_gap, max_val)
stretched_gaps_spike.append(stretched)
scale_spike.append(scale)
rng_gap_spike.append(rng_gap) '''
# 6. Auto gamma
if auto_gamma: