# ==================================================================================================== # # PBC v2.4 - Probabilistic Brush Compression # Lossy Image Compression Algorithm by EgeEken (github.com/EgeEken) # 2.4 Update - 2026-06 - Codebase Refactor and Optimization # # ==================================================================================================== # ======================================= IMPORTS ==================================================== from dataclasses import dataclass, replace from typing import Optional, Union from time import perf_counter import numpy as np from numba import njit, uint32, uint64 from PIL import Image import matplotlib.pyplot as plt # ==================================================================================================== # ================================== GLOBAL CONSTANTS / UTILS ======================================== MAGIC = b"PBC2" VERSION = 4 RESAMPLE = { "nearest": Image.Resampling.NEAREST, "box": Image.Resampling.BOX, "bilinear": Image.Resampling.BILINEAR, "hamming": Image.Resampling.HAMMING, "bicubic": Image.Resampling.BICUBIC, "lanczos": Image.Resampling.LANCZOS, } def _resample_id(name: str) -> int: return list(RESAMPLE).index(name) def _resample_name(idx: int) -> str: return list(RESAMPLE)[idx] def rgb_to_ycbcr(img: np.ndarray) -> np.ndarray: xform = np.array([[0.299, 0.587, 0.114], [-0.168736, -0.331264, 0.5], [0.5, -0.418688, -0.081312]]) ycbcr = img.astype(float).dot(xform.T) ycbcr[:, :, [1, 2]] += 128 return np.clip(ycbcr, 0, 255).astype(np.uint8) def ycbcr_to_rgb(img: np.ndarray) -> np.ndarray: xform = np.array([[1, 0, 1.402], [1, -0.344136, -0.714136], [1, 1.772, 0]]) rgb = img.astype(float) rgb[:, :, [1, 2]] -= 128 rgb = rgb.dot(xform.T) return np.clip(rgb, 0, 255).astype(np.uint8) def _resize(img_pil: Image.Image, size_wh, resample) -> Image.Image: return img_pil.resize((int(size_wh[0]), int(size_wh[1])), resample) def _downsample(img_pil: Image.Image, rate: float, resample) -> Image.Image: if rate <= 1: return img_pil return _resize(img_pil, (img_pil.width // rate, img_pil.height // rate), resample) def _focus_bitcount(height: int, width: int, size: int, max_bitcount: int = 8) -> int: """Maximum number of focus-split bits usable for the given dimensions and brush size.""" ch, cw = height, width bitcount = 0 split_height = True while bitcount < max_bitcount: if split_height: if ch // 2 < size: break ch //= 2 else: if cw // 2 < size: break cw //= 2 split_height = not split_height bitcount += 1 return bitcount def _focus_region(height: int, width: int, code: int, bitcount: int): """Full bounding box of a focus code (no brush-size offset, no padding).""" r_s, r_e = 0, height c_s, c_e = 0, width split_height = True for b in range(bitcount - 1, -1, -1): bit = (code >> b) & 1 if split_height: mid = (r_s + r_e) // 2 r_e, r_s = (mid, r_s) if bit == 0 else (r_e, mid) else: mid = (c_s + c_e) // 2 c_e, c_s = (mid, c_s) if bit == 0 else (c_e, mid) split_height = not split_height return r_s, c_s, r_e, c_e def _select_focus(error_layer: np.ndarray, bitcount: int, criteria: str) -> int: """Selects the focus code maximizing error under the given criteria.""" if bitcount == 0: return 0 h, w = error_layer.shape best_err = -1.0 best = 0 for code in range(1 << bitcount): r_s, c_s, r_e, c_e = _focus_region(h, w, code, bitcount) region = error_layer[r_s:r_e, c_s:c_e] if region.size == 0: continue if criteria == "Max": e = float(region.max()) elif criteria == "Min": e = float(region.min()) else: e = float(region.sum()) if e > best_err: best_err = e best = code return best def _channel_cycle(full_error_layer: np.ndarray, strategy: str, criteria: str): """Returns the next 3-channel selection order based on per-channel error.""" if strategy == "Default": return [0, 1, 2] errs = [] for ch in range(3): layer = full_error_layer[:, :, ch] if criteria == "Max": errs.append(float(layer.max())) elif criteria == "Min": errs.append(float(layer.min())) elif criteria == "Median": errs.append(float(np.median(layer))) else: errs.append(float(layer.sum())) order = sorted(range(3), key=lambda x: errs[x], reverse=True) if strategy == "Strict": return [order[0]] * 3 if strategy == "Balanced": return [order[0], order[0], order[1]] if strategy == "Smart": if errs[order[0]] > 2 * errs[order[1]]: return [order[0]] * 3 if errs[order[1]] > 2 * errs[order[2]]: return [order[0], order[0], order[1]] return [0, 1, 2] def _resolve_decay_value(val: float, length: int, kind: str): """Resolves an auto (-1) decay parameter; returns (was_auto, clamped_rounded_value).""" auto = (val == -1) if auto: val = 0.01 + (1 / 1.0000115) ** (length + 15000) if kind == "cutoff" else 0.5 hi = 3.0 if kind == "cutoff" else 1.0 return auto, round(float(np.clip(val, 0.0, hi)), 4) def _decay_curve(length: int, start: int, end: int, cutoff: float, softness: float, progress: float) -> np.ndarray: """Brush size for each stroke index (even integers).""" x = np.arange(length, dtype=float) if cutoff <= 0: return np.full(length, end, dtype=int) lencut = length * cutoff if lencut >= length * 3: return np.full(length, start, dtype=int) lin = start + (x / (length * cutoff)) * (end - start) if softness <= 0: sig = np.where(x >= lencut / 2, end, start) else: k = 1.0 / softness * (np.sqrt(abs(end - start)) / length) sig = start + (end - start) / (1 + np.exp(-k * (x - lencut / 2))) curve = progress * sig + (1 - progress) * lin curve[x >= lencut] = end return (curve // 2 * 2).astype(int) # ==================================================================================================== # ==================================== BIT WRITER/READERS ============================================ class BitWriter: """MSB-first bit writer backed by a growable byte buffer.""" def __init__(self): self.buf = bytearray() self.acc = 0 # pending bits not yet flushed to a full byte self.n = 0 # number of pending bits in acc self.bits = 0 # total bits written def write(self, value: int, count: int) -> None: if count <= 0: return self.acc = (self.acc << count) | (int(value) & ((1 << count) - 1)) self.n += count self.bits += count while self.n >= 8: self.n -= 8 self.buf.append((self.acc >> self.n) & 0xFF) self.acc &= (1 << self.n) - 1 def write_signed(self, value: int, count: int) -> None: self.write(0 if value < 0 else 1, 1) self.write(abs(int(value)), count - 1) def write_float(self, value: float, decimals: int = 4, count: int = 20) -> None: self.write(int(round(value * (10 ** decimals))), count) def write_array(self, arr: np.ndarray, bits: int) -> None: shift = 8 - bits for v in arr.reshape(-1): self.write(int(v) >> shift, bits) def finish(self): pad = (8 - self.n) % 8 if self.n: self.buf.append((self.acc << pad) & 0xFF) self.acc = 0 self.n = 0 return bytes(self.buf), pad class BitReader: """MSB-first bit reader over an immutable bytes payload.""" def __init__(self, data: bytes): self.data = data self.i = 0 self.bits = 0 def read(self, count: int) -> int: if count <= 0: return 0 v = 0 i = self.i data = self.data for _ in range(count): v = (v << 1) | ((data[i >> 3] >> (7 - (i & 7))) & 1) i += 1 self.i = i self.bits += count return v def read_signed(self, count: int) -> int: sign = self.read(1) v = self.read(count - 1) return v if sign else -v def read_float(self, decimals: int = 4, count: int = 20) -> float: return self.read(count) / (10 ** decimals) def read_array(self, shape, bits: int) -> np.ndarray: shift = 8 - bits arr = np.empty(int(np.prod(shape)), dtype=np.uint8) for i in range(arr.size): arr[i] = self.read(bits) << shift return arr.reshape(shape) # ==================================================================================================== # =========================================== CONFIG ================================================= @dataclass class PBC2Config: stroke_count: int = -1 # If -1, set from image size (20000 + 0.0015 * ((max_dim + 3200) ** 2)) size_range: tuple[float, float] = (-1.0, -1.0) # If (-1, -1), set from stroke_count mult_list: tuple[int, ...] = (-10, 0, 5, 20) # Value options per stroke, encoded as an index into this list. start_mode: str = "Average" # "Black", "White", "Custom", "Average", "Median" start_custom: tuple[int, int, int] = (128, 128, 128) # Used when start_mode is "Custom". # Decay Function Parameters - Control how stroke sizes decrease over time. decay_cutoff: float = -1.0 decay_softness: float = -1.0 decay_progress: float = -1.0 # Focus Area Parameters - Dynamic focus area prioritizing high-error regions for stroke placement. focus_strokes: int = 100 focus_warmup: float = -1.0 focus_max_bits: int = 8 focus_padding: int = 4 focus_criteria: str = "Sum" # "Sum", "Max", "Min" # Channel Cycling Parameters. channel_cycle: Union[str, bool, None] = "Smart" # "Smart", "Strict", "Balanced", "Default", False/None channel_cycle_strokes: int = 100 channel_cycle_warmup: float = 0.9 channel_cycle_criteria: str = "Min" # "Sum", "Max", "Min", "Median" color_space: str = "RGB" # "RGB" or "YCbCr" seed: int = 28042003 # Downsample Rate Parameters. downsample_rate: float = -1.0 # Downsample Initialization Parameters. downsample_initialize: bool = True downsample_initialize_rate: float = 16.0 downsample_initialize_bits: int = 8 # Bits per channel for the init layer (8 = lossless). resample: str = "bicubic" # EXPERIMENTS (kept as config placeholders, not yet wired into the encode path) placement_mode: str = "random" final_only_clipping: bool = False rgb_native: bool = False def _prepare_config(img_size, cfg: PBC2Config) -> PBC2Config: cfg = replace(cfg) if cfg.stroke_count == -1: cfg.stroke_count = int(20000 + 0.0015 * ((max(img_size) + 3200) ** 2)) if cfg.downsample_rate == -1: cfg.downsample_rate = 1 if min(img_size) < 600 else min(img_size) / 500 if cfg.downsample_initialize and cfg.downsample_initialize_rate < 32: if cfg.stroke_count > 20000: if cfg.decay_cutoff == -1: cfg.decay_cutoff = 0.3 if cfg.size_range == (-1.0, -1.0): cfg.size_range = (0.05, 0.01) if cfg.focus_warmup == -1: cfg.focus_warmup = 0.1 else: if cfg.decay_cutoff == -1: cfg.decay_cutoff = 0.7 if cfg.size_range == (-1.0, -1.0): cfg.size_range = (0.1, 0.03) if cfg.focus_warmup == -1: cfg.focus_warmup = 0.7 if cfg.focus_warmup == -1: v = 0.75 - (0.000014 * (cfg.stroke_count - 1000) ** 2) / 550000 cfg.focus_warmup = max(0.0, min(v, 1.0)) return cfg @dataclass class PBC2Result: image: Image.Image data: bytes config: PBC2Config losses: tuple[int, int, int] mse: float header_bits: int total_bits: int encode_seconds: float def save(self, path: str) -> None: with open(path, "wb") as f: f.write(self.data) def show(self) -> None: plt.imshow(self.image) plt.axis("off") plt.title(f"PBC2 Result - MSE: {self.mse:.2f}, Size: {self.total_bits / 8 / 1024:.2f} KB, " f"Encode Time: {self.encode_seconds:.2f}s") plt.show() # ==================================================================================================== # ========================================= NJIT FUNCTIONS =========================================== @njit(inline='always') def pcg_step(state): old_state = state state = uint64(old_state * 6364136223846793005 + 1442695040888963407) xorshifted = uint32(((old_state >> 18) ^ old_state) >> 27) rot = uint32(old_state >> 59) out = (xorshifted >> rot) | (xorshifted << ((-rot) & 31)) return state, out @njit(fastmath=True) def get_stroke_coords_rolling(state, r_start, c_start, r_end, c_end): if r_end <= r_start: r_end = r_start + 1 if c_end <= c_start: c_end = c_start + 1 range_h = uint32(r_end - r_start) range_w = uint32(c_end - c_start) state, rnd_row = pcg_step(state) state, rnd_col = pcg_step(state) row = r_start + int(rnd_row % range_h) col = c_start + int(rnd_col % range_w) return state, row, col @njit(fastmath=True) def process_quadrant_int(height, width, size, q_int, bitcount, padding): row_start, row_end = 0, height col_start, col_end = 0, width split_height = True for b in range(bitcount - 1, -1, -1): bit = (q_int >> b) & 1 if split_height: mid = (row_start + row_end) // 2 if bit == 0: row_end = mid else: row_start = mid else: mid = (col_start + col_end) // 2 if bit == 0: col_end = mid else: col_start = mid split_height = not split_height row_end -= size col_end -= size row_start = max(0, row_start - padding) col_start = max(0, col_start - padding) row_end = min(height - size, row_end + padding) col_end = min(width - size, col_end + padding) return row_start, col_start, row_end, col_end @njit(fastmath=True) def stroke_numba(target_layer, canvas_layer, h, w, row, col, size, mult_arr): half = size // 2 stroke_indices = np.zeros(4, dtype=np.int32) for k in range(4): if k == 0: r_s, r_e = row, row + half c_s, c_e = col, col + half elif k == 1: r_s, r_e = row, row + half c_s, c_e = col + half, col + size elif k == 2: r_s, r_e = row + half, row + size c_s, c_e = col, col + half else: r_s, r_e = row + half, row + size c_s, c_e = col + half, col + size if r_s >= h or c_s >= w: continue if r_s >= r_e or c_s >= c_e: continue t_slice = target_layer[r_s:r_e, c_s:c_e] c_slice = canvas_layer[r_s:r_e, c_s:c_e] if t_slice.size == 0: continue mean_diff = np.mean(t_slice - c_slice) best_idx = np.argmin(np.abs(mult_arr - mean_diff)) best_mult = mult_arr[best_idx] if best_mult != 0: for rr in range(c_slice.shape[0]): for cc in range(c_slice.shape[1]): val = c_slice[rr, cc] + best_mult if val > 255: val = 255 elif val < 0: val = 0 c_slice[rr, cc] = val stroke_indices[k] = best_idx return stroke_indices, canvas_layer @njit(fastmath=True) def stroke_numba_decompress(canvas_layer, h, w, row, col, size, mult_arr, stroke_indices): half = size // 2 for k in range(4): if k == 0: r_s, r_e = row, row + half c_s, c_e = col, col + half elif k == 1: r_s, r_e = row, row + half c_s, c_e = col + half, col + size elif k == 2: r_s, r_e = row + half, row + size c_s, c_e = col, col + half else: r_s, r_e = row + half, row + size c_s, c_e = col + half, col + size if r_s >= h or c_s >= w: continue if r_s >= r_e or c_s >= c_e: continue c_slice = canvas_layer[r_s:r_e, c_s:c_e] best_mult = mult_arr[stroke_indices[k]] if best_mult != 0: for rr in range(c_slice.shape[0]): for cc in range(c_slice.shape[1]): val = c_slice[rr, cc] + best_mult if val > 255: val = 255 elif val < 0: val = 0 c_slice[rr, cc] = val return canvas_layer # ==================================================================================================== # ======================================= CORE ENCODE/DECODE ========================================= class PBC: """# Probabilistic Brush Compression (PBC) \n --- ### Developed by **Ege Eken** (https://github.com/EgeEken/PBC) \n Current Version: **V2.4** (2026) \n\n --- This is a lossy image compression algorithm that compresses images into a series of brush stroke instructions.\n For more information, visit the [GitHub Repository](https://github.com/EgeEken/PBC) """ Config = PBC2Config Result = PBC2Result # ---------------------------------- public API ---------------------------------- @staticmethod def compress(img, config: Optional[PBC2Config] = None, **overrides) -> PBC2Result: result = None for out in PBC._encode(img, config, overrides, stream_interval=None): result = out return result @staticmethod def compress_stream(img, config: Optional[PBC2Config] = None, stream_interval: int = 100, **overrides): yield from PBC._encode(img, config, overrides, stream_interval=stream_interval) @staticmethod def decompress(data: Union[bytes, bytearray, str], return_result: bool = False): if isinstance(data, str): with open(data, "rb") as f: data = f.read() data = bytes(data) if data[:4] != MAGIC: raise ValueError("Invalid PBC2 file (bad magic).") br = BitReader(data[6:]) # skip MAGIC(4) + VERSION(1) + pad(1) downsample_flag = br.read(1) original_w = original_h = -1 if downsample_flag: original_w = br.read(16) original_h = br.read(16) ycbcr = br.read(1) resample_name = _resample_name(br.read(3)) resample = RESAMPLE[resample_name] seed = br.read(32) h = br.read(16) w = br.read(16) stroke_count = br.read(20) start_color = (br.read(8), br.read(8), br.read(8)) if br.read(1): # downsample-initialize flag init_bits = br.read(4) n_h = br.read(10) n_w = br.read(10) canvas_rgb = br.read_array((n_h, n_w, 3), init_bits) canvas = np.array(_resize(Image.fromarray(canvas_rgb), (w, h), resample), dtype=np.int16) if ycbcr: canvas = rgb_to_ycbcr(canvas).astype(np.int16) else: canvas = np.full((h, w, 3), start_color, dtype=np.int16) size_start = br.read(16) size_end = br.read(16) c_auto = br.read(1); cv = br.read_float(4, 20) s_auto = br.read(1); sv = br.read_float(4, 20) p_auto = br.read(1); pv = br.read_float(4, 20) _, cutoff = _resolve_decay_value(-1 if c_auto else cv, stroke_count, "cutoff") _, softness = _resolve_decay_value(-1 if s_auto else sv, stroke_count, "softness") _, progress = _resolve_decay_value(-1 if p_auto else pv, stroke_count, "progress") sizes = _decay_curve(stroke_count, size_start, size_end, cutoff, softness, progress) len_multlist = br.read(9) mult_list = [br.read_signed(9) for _ in range(len_multlist)] mult_arr = np.array(mult_list) mb = int(np.ceil(np.log2(len(mult_arr)))) if len(mult_arr) > 1 else 0 focus_warmup_strokes = br.read(20) focus_strokes = br.read(20) focus_max_bits = br.read(8) focus_padding = br.read(8) cycle = bool(br.read(1)) if cycle: channel_cycle_strokes = br.read(20) channel_cycle_warmup_strokes = br.read(20) else: channel_cycle_strokes = channel_cycle_warmup_strokes = 0 canvas = PBC._drain(PBC._run_loop( br, None, canvas, None, sizes, stroke_count, h, w, seed, mult_arr, mb, focus_warmup_strokes, focus_strokes, focus_max_bits, focus_padding, None, cycle, channel_cycle_strokes, channel_cycle_warmup_strokes, None, None, None, )) canvas = np.clip(canvas, 0, 255) final = ycbcr_to_rgb(canvas) if ycbcr else canvas.astype(np.uint8) final_pil = Image.fromarray(final) if downsample_flag: final_pil = _resize(final_pil, (original_w, original_h), resample) if not return_result: return final_pil cfg = PBC2Config(stroke_count=stroke_count, size_range=(size_start, size_end), mult_list=tuple(mult_list), start_custom=start_color, decay_cutoff=cutoff, decay_softness=softness, decay_progress=progress, focus_strokes=focus_strokes, focus_max_bits=focus_max_bits, focus_padding=focus_padding, channel_cycle=cycle, channel_cycle_strokes=channel_cycle_strokes, color_space="YCbCr" if ycbcr else "RGB", seed=seed, resample=resample_name) return PBC2Result(final_pil, data, cfg, (-1, -1, -1), -1.0, -1, br.bits, 0.0) # ---------------------------------- internals ---------------------------------- @staticmethod def _drain(gen): try: while True: next(gen) except StopIteration as e: return e.value @staticmethod def _encode(img, config, overrides, stream_interval): t0 = perf_counter() if isinstance(img, str): img = Image.open(img) elif isinstance(img, np.ndarray): img = Image.fromarray(img) img = img.convert("RGB") original_size = img.size # (w, h) cfg = config or PBC2Config() if overrides: cfg = replace(cfg, **overrides) cfg = _prepare_config(original_size, cfg) ycbcr = cfg.color_space == "YCbCr" resample = RESAMPLE[cfg.resample] work = Image.fromarray(np.array(img.convert("YCbCr"))) if ycbcr else img bw = BitWriter() if cfg.downsample_rate > 1: ds = _downsample(work, cfg.downsample_rate, resample) arr = np.array(ds, dtype=np.int16) bw.write(1, 1) bw.write(original_size[0], 16) bw.write(original_size[1], 16) else: ds = None arr = np.array(work, dtype=np.int16) bw.write(0, 1) bw.write(1 if ycbcr else 0, 1) bw.write(_resample_id(cfg.resample), 3) bw.write(cfg.seed, 32) h, w = arr.shape[:2] bw.write(h, 16) bw.write(w, 16) bw.write(cfg.stroke_count, 20) r, g, b = arr[:, :, 0], arr[:, :, 1], arr[:, :, 2] if cfg.start_mode == "Black": start_color = (0, 0, 0) if not ycbcr else (0, 128, 128) elif cfg.start_mode == "White": start_color = (255, 255, 255) if not ycbcr else (255, 128, 128) elif cfg.start_mode == "Custom": start_color = tuple(cfg.start_custom) elif cfg.start_mode in ("Average", "Mean"): start_color = (int(np.mean(r)), int(np.mean(g)), int(np.mean(b))) elif cfg.start_mode == "Median": start_color = (int(np.median(r)), int(np.median(g)), int(np.median(b))) elif cfg.start_mode == "True Median": start_color = tuple(int(v) for v in np.median(arr.reshape(-1, 3), axis=0)) else: start_color = (int(np.mean(r)), int(np.mean(g)), int(np.mean(b))) for c in start_color: bw.write(int(c), 8) if cfg.downsample_initialize: bw.write(1, 1) init_bits = cfg.downsample_initialize_bits n_h = int(h / cfg.downsample_initialize_rate) n_w = int(w / cfg.downsample_initialize_rate) bw.write(init_bits, 4) bw.write(n_h, 10) bw.write(n_w, 10) src = ds if cfg.downsample_rate > 1 else work canvas_rgb = np.array(_resize(src, (n_w, n_h), resample), dtype=np.uint8) if ycbcr: canvas_rgb = ycbcr_to_rgb(canvas_rgb) bw.write_array(canvas_rgb, init_bits) canvas = np.array(_resize(Image.fromarray(canvas_rgb), (w, h), resample), dtype=np.int16) if ycbcr: canvas = rgb_to_ycbcr(canvas).astype(np.int16) else: bw.write(0, 1) canvas = np.full((h, w, 3), start_color, dtype=np.int16) ss, se = cfg.size_range if ss == -1: ss = 0.3 + (1 / 1.00095) ** (cfg.stroke_count + 7000) if se == -1: se = 0.01 + (1 / 1.00015) ** (cfg.stroke_count + 10200) size_start = int(ss * (min(h, w) - 2)) + 2 size_end = int(se * (min(h, w) - 2)) + 2 bw.write(size_start, 16) bw.write(size_end, 16) c_auto, cutoff = _resolve_decay_value(cfg.decay_cutoff, cfg.stroke_count, "cutoff") s_auto, softness = _resolve_decay_value(cfg.decay_softness, cfg.stroke_count, "softness") p_auto, progress = _resolve_decay_value(cfg.decay_progress, cfg.stroke_count, "progress") bw.write(1 if c_auto else 0, 1); bw.write_float(cutoff, 4, 20) bw.write(1 if s_auto else 0, 1); bw.write_float(softness, 4, 20) bw.write(1 if p_auto else 0, 1); bw.write_float(progress, 4, 20) sizes = _decay_curve(cfg.stroke_count, size_start, size_end, cutoff, softness, progress) mult_arr = np.array(cfg.mult_list) mb = int(np.ceil(np.log2(len(mult_arr)))) if len(mult_arr) > 1 else 0 bw.write(len(mult_arr), 9) for m in mult_arr: bw.write_signed(int(m), 9) focus_warmup_strokes = int(cfg.focus_warmup * cfg.stroke_count) bw.write(focus_warmup_strokes, 20) bw.write(cfg.focus_strokes, 20) bw.write(cfg.focus_max_bits, 8) bw.write(cfg.focus_padding, 8) cycle_name = cfg.channel_cycle cycle = bool(cycle_name) channel_cycle_warmup_strokes = int(cfg.channel_cycle_warmup * cfg.stroke_count) bw.write(1 if cycle else 0, 1) if cycle: bw.write(cfg.channel_cycle_strokes, 20) bw.write(channel_cycle_warmup_strokes, 20) header_bits = bw.bits canvas = yield from PBC._run_loop( None, bw, canvas, arr, sizes, cfg.stroke_count, h, w, cfg.seed, mult_arr, mb, focus_warmup_strokes, cfg.focus_strokes, cfg.focus_max_bits, cfg.focus_padding, cfg.focus_criteria, cycle, cfg.channel_cycle_strokes, channel_cycle_warmup_strokes, cycle_name if isinstance(cycle_name, str) else "Default", cfg.channel_cycle_criteria, (stream_interval, ycbcr), ) canvas = np.clip(canvas, 0, 255) final = ycbcr_to_rgb(canvas) if ycbcr else canvas.astype(np.uint8) final_pil = Image.fromarray(final) if cfg.downsample_rate > 1: final_pil = _resize(final_pil, original_size, resample) payload, pad = bw.finish() data = MAGIC + bytes([VERSION, pad]) + payload final_arr = np.array(final_pil) orig_arr = np.array(img) losses = tuple(int(np.mean((orig_arr[:, :, c].astype(np.float32) - final_arr[:, :, c].astype(np.float32)) ** 2)) for c in range(3)) mse = float(np.mean(losses)) yield PBC2Result(final_pil, data, cfg, losses, mse, header_bits, bw.bits, perf_counter() - t0) @staticmethod def _run_loop(br, bw, canvas, arr, sizes, stroke_count, h, w, seed, mult_arr, mb, focus_warmup_strokes, focus_strokes, focus_max_bits, focus_padding, focus_criteria, cycle, channel_cycle_strokes, channel_cycle_warmup_strokes, cycle_strategy, cycle_criteria, stream_ctx): """Shared stroke loop, used by both encode (bw set) and decode (br set). Generator: yields stream updates during encode; returns the final canvas.""" encoding = bw is not None rng_state = np.uint64(seed) focus_codes = [None, None, None] focus_bits = [0, 0, 0] focus_counters = [focus_warmup_strokes // 3] * 3 channel_selector = [0, 1, 2] cycle_timer = channel_cycle_warmup_strokes stream_interval = stream_ctx[0] if stream_ctx else None ycbcr = stream_ctx[1] if stream_ctx else False stream_timer = 0 for i in range(stroke_count): ch = channel_selector[i % 3] canvas_layer = canvas[:, :, ch] size = int(sizes[i]) if focus_counters[ch] <= 0: bc = _focus_bitcount(h, w, size, focus_max_bits) if bc > 0: if encoding: err = np.abs(arr[:, :, ch] - canvas_layer) code = _select_focus(err, bc, focus_criteria) bw.write(code, bc) else: code = br.read(bc) focus_codes[ch] = code focus_bits[ch] = bc else: focus_codes[ch] = None focus_bits[ch] = 0 focus_counters[ch] = focus_strokes if cycle and cycle_timer <= 0: if encoding: channel_selector = _channel_cycle(np.abs(arr - canvas), cycle_strategy, cycle_criteria) for c in channel_selector: bw.write(c, 2) else: channel_selector = [br.read(2) for _ in range(3)] cycle_timer = channel_cycle_strokes if focus_codes[ch] is None: r_s, c_s = 0, 0 r_e, c_e = h - size, w - size else: r_s, c_s, r_e, c_e = process_quadrant_int(h, w, size, focus_codes[ch], focus_bits[ch], focus_padding) rng_state, row, col = get_stroke_coords_rolling(rng_state, r_s, c_s, r_e, c_e) rng_state = np.uint64(rng_state) if encoding: indices, _ = stroke_numba(arr[:, :, ch], canvas_layer, h, w, row, col, size, mult_arr) for idx in indices: bw.write(int(idx), mb) else: indices = np.array([br.read(mb) for _ in range(4)], dtype=np.int32) stroke_numba_decompress(canvas_layer, h, w, row, col, size, mult_arr, indices) focus_counters[ch] -= 1 if cycle: cycle_timer -= 1 if encoding and stream_interval: if stream_timer <= 0: stream_timer = stream_interval - 1 interim = np.clip(canvas, 0, 255).astype(np.uint8) if ycbcr: interim = ycbcr_to_rgb(interim) losses = [int(np.mean((interim[:, :, c].astype(np.float32) - arr[:, :, c].astype(np.float32)) ** 2)) for c in range(3)] yield (Image.fromarray(interim), f"Processed {i}/{stroke_count} strokes. {(i / stroke_count) * 100:.2f}%", bw.bits, losses) else: stream_timer -= 1 return canvas # ==================================================================================================== # ==================================================================================================== # Preloading numba when imported to prevent first run excess latency def preload_numba() -> None: img = Image.fromarray(np.random.randint(0, 256, (16, 16, 3), dtype=np.uint8)) PBC.compress(img, PBC2Config(stroke_count=10, downsample_initialize=False, focus_warmup=0.0)) preload_numba()