pbc3.0 first update
Browse files- .gitignore +2 -0
- PBC3.py +1003 -0
- PBC3_animation.py +298 -0
- pbc3_kernels.py +172 -0
- pbc3_types.py +201 -0
- server.py +86 -104
- static/app.js +157 -289
- static/index.html +2 -2
.gitignore
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# macos
|
| 2 |
+
.DS_Store
|
PBC3.py
ADDED
|
@@ -0,0 +1,1003 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
# ====================================================================================================
|
| 3 |
+
#
|
| 4 |
+
# PBC v3.0 - Probabilistic Brush Compression
|
| 5 |
+
# Lossy Image Compression Algorithm by EgeEken (github.com/EgeEken)
|
| 6 |
+
# 3.0 Update - 2026-06 - Whole algorithm overhaul
|
| 7 |
+
#
|
| 8 |
+
# ====================================================================================================
|
| 9 |
+
|
| 10 |
+
import time
|
| 11 |
+
import math
|
| 12 |
+
import lzma
|
| 13 |
+
import numpy as np
|
| 14 |
+
from PIL import Image
|
| 15 |
+
|
| 16 |
+
from pbc3_types import BitWriter, BitReader, PBC3Config, PBC3Result # noqa: F401 (re-exported)
|
| 17 |
+
from pbc3_kernels import (
|
| 18 |
+
NUMBA_AVAILABLE as _NUMBA,
|
| 19 |
+
box_cell_bound as _nb_box_cell_bound,
|
| 20 |
+
base_cell_size as _nb_base_cell_size,
|
| 21 |
+
anchor_block_scores as _nb_anchor_block_scores,
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class PBC3:
|
| 26 |
+
MAGIC = b"PBC3"
|
| 27 |
+
VERSION = 0
|
| 28 |
+
PALETTE_GENERATED = 0
|
| 29 |
+
PALETTE_EXPLICIT = 1
|
| 30 |
+
ENTROPY_STORE = 0
|
| 31 |
+
ENTROPY_LZMA = 2
|
| 32 |
+
_LZMA_FILTERS = [{"id": lzma.FILTER_LZMA2, "preset": lzma.PRESET_EXTREME}]
|
| 33 |
+
COLOR_SPACES = {"RGB": 0, "YCbCr": 1}
|
| 34 |
+
COLOR_SPACE_NAMES = {0: "RGB", 1: "YCbCr"}
|
| 35 |
+
RESAMPLE_FILTER = Image.Resampling.BICUBIC
|
| 36 |
+
RESAMPLE_REDUCING_GAP = None
|
| 37 |
+
USE_NUMBA_RESAMPLE = False # opt-in numba bicubic (pbc3_resample.py); PIL by default
|
| 38 |
+
|
| 39 |
+
@staticmethod
|
| 40 |
+
def _to_image(image):
|
| 41 |
+
if isinstance(image, Image.Image):
|
| 42 |
+
return image
|
| 43 |
+
if isinstance(image, str):
|
| 44 |
+
return Image.open(image)
|
| 45 |
+
arr = np.asarray(image)
|
| 46 |
+
if arr.dtype != np.uint8:
|
| 47 |
+
arr = np.clip(arr, 0, 255).astype(np.uint8)
|
| 48 |
+
mode = "RGBA" if arr.ndim == 3 and arr.shape[-1] == 4 else "RGB"
|
| 49 |
+
return Image.fromarray(arr, mode)
|
| 50 |
+
|
| 51 |
+
@staticmethod
|
| 52 |
+
def _has_alpha(img):
|
| 53 |
+
return img.mode in ("RGBA", "LA", "PA") or (img.mode == "P" and "transparency" in img.info)
|
| 54 |
+
|
| 55 |
+
@staticmethod
|
| 56 |
+
def _ceil_div(a, b):
|
| 57 |
+
return (a + b - 1) // b
|
| 58 |
+
|
| 59 |
+
@staticmethod
|
| 60 |
+
def _add_time(timings, key, seconds):
|
| 61 |
+
timings[key] = timings.get(key, 0.0) + seconds
|
| 62 |
+
|
| 63 |
+
@staticmethod
|
| 64 |
+
def _norm(values):
|
| 65 |
+
arr = np.asarray(values, dtype=np.float64)
|
| 66 |
+
rng = arr.max() - arr.min()
|
| 67 |
+
if rng <= 0:
|
| 68 |
+
return np.ones_like(arr)
|
| 69 |
+
return (arr - arr.min()) / rng
|
| 70 |
+
|
| 71 |
+
@staticmethod
|
| 72 |
+
def _interp(start, end, step, count):
|
| 73 |
+
if count <= 1:
|
| 74 |
+
return float(end)
|
| 75 |
+
p = (step - 1) / max(1, count - 1)
|
| 76 |
+
return float(start) * (1 - p) + float(end) * p
|
| 77 |
+
|
| 78 |
+
@classmethod
|
| 79 |
+
def _entropy_pack(cls, body, use_lzma=True):
|
| 80 |
+
if not use_lzma:
|
| 81 |
+
return cls.ENTROPY_STORE, body
|
| 82 |
+
x = lzma.compress(body, format=lzma.FORMAT_RAW, filters=cls._LZMA_FILTERS)
|
| 83 |
+
if len(x) < len(body):
|
| 84 |
+
return cls.ENTROPY_LZMA, x
|
| 85 |
+
return cls.ENTROPY_STORE, body
|
| 86 |
+
|
| 87 |
+
@classmethod
|
| 88 |
+
def _entropy_unpack(cls, method, body):
|
| 89 |
+
if method == cls.ENTROPY_STORE:
|
| 90 |
+
return body
|
| 91 |
+
if method == cls.ENTROPY_LZMA:
|
| 92 |
+
return lzma.decompress(body, format=lzma.FORMAT_RAW, filters=cls._LZMA_FILTERS)
|
| 93 |
+
raise ValueError(f"unknown entropy method {method}")
|
| 94 |
+
|
| 95 |
+
@classmethod
|
| 96 |
+
def _open_body(cls, data):
|
| 97 |
+
if data[:4] != cls.MAGIC:
|
| 98 |
+
raise ValueError("not a PBC3 file")
|
| 99 |
+
version = data[4]
|
| 100 |
+
if version != cls.VERSION:
|
| 101 |
+
raise ValueError(f"unsupported PBC3 version {version}")
|
| 102 |
+
return version, cls._entropy_unpack(data[5], data[6:])
|
| 103 |
+
|
| 104 |
+
@classmethod
|
| 105 |
+
def _auto_downsample_rate(cls, image_size, downsample_rate, max_pixels):
|
| 106 |
+
if downsample_rate != -1:
|
| 107 |
+
return float(downsample_rate)
|
| 108 |
+
w, h = image_size
|
| 109 |
+
pixels = w * h
|
| 110 |
+
max_pixels = max(1, int(max_pixels))
|
| 111 |
+
if pixels <= max_pixels:
|
| 112 |
+
return 1.0
|
| 113 |
+
return math.sqrt(pixels / max_pixels)
|
| 114 |
+
|
| 115 |
+
@classmethod
|
| 116 |
+
def _downsample_image(cls, img, rate):
|
| 117 |
+
if rate <= 1:
|
| 118 |
+
return img.copy()
|
| 119 |
+
w = max(1, int(round(img.size[0] / rate)))
|
| 120 |
+
h = max(1, int(round(img.size[1] / rate)))
|
| 121 |
+
return img.resize((w, h), cls.RESAMPLE_FILTER, reducing_gap=cls.RESAMPLE_REDUCING_GAP)
|
| 122 |
+
|
| 123 |
+
@staticmethod
|
| 124 |
+
def _palette_bounds(values):
|
| 125 |
+
min_value = int(np.min(values))
|
| 126 |
+
max_value = int(np.max(values))
|
| 127 |
+
return min(255, max(0, -min_value)), min(255, max(0, max_value))
|
| 128 |
+
|
| 129 |
+
@classmethod
|
| 130 |
+
def _range_counts(cls, mask_size, negative_max=255, positive_max=255, positive_bias=True):
|
| 131 |
+
side_bits = max(0, mask_size - 1)
|
| 132 |
+
negative_max = max(0, int(negative_max))
|
| 133 |
+
positive_max = max(0, int(positive_max))
|
| 134 |
+
if side_bits == 0 or (negative_max == 0 and positive_max == 0):
|
| 135 |
+
return 0, 0
|
| 136 |
+
if negative_max == 0:
|
| 137 |
+
return min(side_bits, positive_max), 0
|
| 138 |
+
if positive_max == 0:
|
| 139 |
+
return 0, min(side_bits, negative_max)
|
| 140 |
+
raw_pos = side_bits * positive_max / (positive_max + negative_max)
|
| 141 |
+
pos_count = math.ceil(raw_pos) if positive_bias else math.floor(raw_pos)
|
| 142 |
+
pos_count = min(side_bits - 1, max(1, pos_count), positive_max)
|
| 143 |
+
neg_count = min(side_bits - pos_count, negative_max)
|
| 144 |
+
if neg_count == 0 and negative_max > 0 and side_bits > pos_count:
|
| 145 |
+
neg_count = 1
|
| 146 |
+
pos_count = max(1, pos_count - 1)
|
| 147 |
+
return pos_count, neg_count
|
| 148 |
+
|
| 149 |
+
@classmethod
|
| 150 |
+
def _mask_index_for_value(cls, value, mask_size, negative_max=255, positive_max=255, positive_bias=True):
|
| 151 |
+
if value == 0:
|
| 152 |
+
return 0
|
| 153 |
+
pos_count, neg_count = cls._range_counts(mask_size, negative_max, positive_max, positive_bias)
|
| 154 |
+
if value > 0:
|
| 155 |
+
if pos_count == 0 or positive_max <= 0:
|
| 156 |
+
return None
|
| 157 |
+
mag = min(int(value), positive_max)
|
| 158 |
+
bin_i = min((mag - 1) * pos_count // positive_max, pos_count - 1)
|
| 159 |
+
return 1 + bin_i
|
| 160 |
+
if neg_count == 0 or negative_max <= 0:
|
| 161 |
+
return None
|
| 162 |
+
mag = min(int(-value), negative_max)
|
| 163 |
+
bin_i = min((mag - 1) * neg_count // negative_max, neg_count - 1)
|
| 164 |
+
return 1 + pos_count + bin_i
|
| 165 |
+
|
| 166 |
+
@classmethod
|
| 167 |
+
def _range_for_mask_index(cls, index, mask_size, negative_max=255, positive_max=255, positive_bias=True):
|
| 168 |
+
pos_count, neg_count = cls._range_counts(mask_size, negative_max, positive_max, positive_bias)
|
| 169 |
+
if index == 0:
|
| 170 |
+
return 0, 0
|
| 171 |
+
if 1 <= index <= pos_count:
|
| 172 |
+
bin_i = index - 1
|
| 173 |
+
start = 1 + (bin_i * positive_max) // pos_count
|
| 174 |
+
end = ((bin_i + 1) * positive_max) // pos_count
|
| 175 |
+
return (start, end) if start <= end else None
|
| 176 |
+
bin_i = index - 1 - pos_count
|
| 177 |
+
if 0 <= bin_i < neg_count:
|
| 178 |
+
low_mag = 1 + (bin_i * negative_max) // neg_count
|
| 179 |
+
high_mag = ((bin_i + 1) * negative_max) // neg_count
|
| 180 |
+
return (-high_mag, -low_mag) if high_mag >= low_mag else None
|
| 181 |
+
return None
|
| 182 |
+
|
| 183 |
+
@classmethod
|
| 184 |
+
def _mask_from_values(cls, values, mask_size, negative_max=255, positive_max=255, positive_bias=True):
|
| 185 |
+
mask = [0] * mask_size
|
| 186 |
+
mask[0] = 1
|
| 187 |
+
pos_count, neg_count = cls._range_counts(mask_size, negative_max, positive_max, positive_bias)
|
| 188 |
+
flat = np.clip(np.rint(np.asarray(values)).astype(np.int32).ravel(), -negative_max, positive_max)
|
| 189 |
+
if pos_count > 0 and positive_max > 0:
|
| 190 |
+
pos = flat[flat > 0]
|
| 191 |
+
if pos.size:
|
| 192 |
+
bins = 1 + np.minimum((np.minimum(pos, positive_max) - 1) * pos_count // positive_max, pos_count - 1)
|
| 193 |
+
for b in np.unique(bins):
|
| 194 |
+
if b < mask_size:
|
| 195 |
+
mask[int(b)] = 1
|
| 196 |
+
if neg_count > 0 and negative_max > 0:
|
| 197 |
+
neg = flat[flat < 0]
|
| 198 |
+
if neg.size:
|
| 199 |
+
mag = np.minimum(-neg, negative_max)
|
| 200 |
+
bins = 1 + pos_count + np.minimum((mag - 1) * neg_count // negative_max, neg_count - 1)
|
| 201 |
+
for b in np.unique(bins):
|
| 202 |
+
if b < mask_size:
|
| 203 |
+
mask[int(b)] = 1
|
| 204 |
+
return mask
|
| 205 |
+
|
| 206 |
+
@classmethod
|
| 207 |
+
def _active_value_count(cls, mask, negative_max=255, positive_max=255, positive_bias=True):
|
| 208 |
+
count = 0
|
| 209 |
+
for i, bit in enumerate(mask):
|
| 210 |
+
if bit:
|
| 211 |
+
r = cls._range_for_mask_index(i, len(mask), negative_max, positive_max, positive_bias)
|
| 212 |
+
if r is not None:
|
| 213 |
+
start, end = r
|
| 214 |
+
count += end - start + 1
|
| 215 |
+
return max(1, count)
|
| 216 |
+
|
| 217 |
+
@classmethod
|
| 218 |
+
def resolve_palette_bitcount(cls, mask, max_bitcount, negative_max=255, positive_max=255, positive_bias=True):
|
| 219 |
+
value_count = cls._active_value_count(mask, negative_max, positive_max, positive_bias)
|
| 220 |
+
needed = max(1, math.ceil(math.log2(value_count)))
|
| 221 |
+
return min(int(max_bitcount), needed)
|
| 222 |
+
|
| 223 |
+
@classmethod
|
| 224 |
+
def palette_generator(cls, mask, max_bitcount, negative_max=255, positive_max=255, positive_bias=True):
|
| 225 |
+
bitcount = cls.resolve_palette_bitcount(mask, max_bitcount, negative_max, positive_max, positive_bias)
|
| 226 |
+
size = 1 << bitcount
|
| 227 |
+
active_ranges = []
|
| 228 |
+
for i, bit in enumerate(mask):
|
| 229 |
+
if bit:
|
| 230 |
+
r = cls._range_for_mask_index(i, len(mask), negative_max, positive_max, positive_bias)
|
| 231 |
+
if r is not None:
|
| 232 |
+
active_ranges.append(r)
|
| 233 |
+
palette = []
|
| 234 |
+
if mask and mask[0]:
|
| 235 |
+
palette.append(0)
|
| 236 |
+
active_ranges = [r for r in active_ranges if r != (0, 0)]
|
| 237 |
+
value_count = cls._active_value_count(mask, negative_max, positive_max, positive_bias)
|
| 238 |
+
if size >= value_count:
|
| 239 |
+
for start, end in active_ranges:
|
| 240 |
+
palette.extend(range(start, end + 1))
|
| 241 |
+
if len(palette) < size:
|
| 242 |
+
palette.extend([palette[-1] if palette else 0] * (size - len(palette)))
|
| 243 |
+
return np.array(palette[:size], dtype=np.int16)
|
| 244 |
+
if not active_ranges:
|
| 245 |
+
return np.zeros(size, dtype=np.int16)
|
| 246 |
+
remaining = size - len(palette)
|
| 247 |
+
counts = [0] * len(active_ranges)
|
| 248 |
+
for i in range(remaining):
|
| 249 |
+
counts[i % len(active_ranges)] += 1
|
| 250 |
+
for (start, end), count in zip(active_ranges, counts):
|
| 251 |
+
if count == 1:
|
| 252 |
+
palette.append(int(round((start + end) / 2)))
|
| 253 |
+
elif count > 1:
|
| 254 |
+
for j in range(count):
|
| 255 |
+
t = (j + 1) / (count + 1)
|
| 256 |
+
palette.append(int(round(start + (end - start) * t)))
|
| 257 |
+
if len(palette) < size:
|
| 258 |
+
palette.extend([palette[-1] if palette else 0] * (size - len(palette)))
|
| 259 |
+
return np.array(palette[:size], dtype=np.int16)
|
| 260 |
+
|
| 261 |
+
@classmethod
|
| 262 |
+
def _top_values_palette(cls, small, bitcount, threshold):
|
| 263 |
+
size = 1 << bitcount
|
| 264 |
+
flat = np.clip(np.rint(np.asarray(small)).astype(np.int32).ravel(), -255, 255)
|
| 265 |
+
vals, counts = np.unique(flat, return_counts=True)
|
| 266 |
+
centroids = [0.0]
|
| 267 |
+
binw = max(1, int(threshold))
|
| 268 |
+
agg = {}
|
| 269 |
+
for v, ct in zip(np.round(vals / binw) * binw, counts):
|
| 270 |
+
agg[float(v)] = agg.get(float(v), 0) + int(ct)
|
| 271 |
+
for v in sorted((k for k in agg if k != 0.0), key=lambda k: agg[k], reverse=True):
|
| 272 |
+
centroids.append(v)
|
| 273 |
+
if len(centroids) >= size:
|
| 274 |
+
break
|
| 275 |
+
centroids = np.array(centroids, dtype=np.float64)
|
| 276 |
+
if centroids.size > 1 and vals.size:
|
| 277 |
+
w = counts.astype(np.float64)
|
| 278 |
+
for _ in range(8):
|
| 279 |
+
assign = np.argmin(np.abs(vals[:, None] - centroids[None, :]), axis=1)
|
| 280 |
+
new = centroids.copy()
|
| 281 |
+
for k in range(centroids.size):
|
| 282 |
+
sel = assign == k
|
| 283 |
+
wk = w[sel].sum()
|
| 284 |
+
if wk > 0:
|
| 285 |
+
new[k] = float((vals[sel] * w[sel]).sum() / wk)
|
| 286 |
+
new[0] = 0.0
|
| 287 |
+
if np.array_equal(np.rint(new), np.rint(centroids)):
|
| 288 |
+
centroids = new
|
| 289 |
+
break
|
| 290 |
+
centroids = new
|
| 291 |
+
pal = np.rint(centroids).astype(np.int16)
|
| 292 |
+
if pal.size < size:
|
| 293 |
+
pal = np.concatenate([pal, np.zeros(size - pal.size, dtype=np.int16)])
|
| 294 |
+
return pal[:size]
|
| 295 |
+
|
| 296 |
+
@staticmethod
|
| 297 |
+
def quantize_signed(values, palette):
|
| 298 |
+
vals = np.asarray(values, dtype=np.int16)
|
| 299 |
+
pal = np.asarray(palette, dtype=np.int16)
|
| 300 |
+
dist = np.abs(vals[..., None].astype(np.int32) - pal[None, None, :].astype(np.int32))
|
| 301 |
+
return np.argmin(dist, axis=-1).astype(np.uint16)
|
| 302 |
+
|
| 303 |
+
@classmethod
|
| 304 |
+
def signed_resample(cls, values, out_h, out_w):
|
| 305 |
+
values = np.asarray(values, dtype=np.float32)
|
| 306 |
+
out_h, out_w = int(out_h), int(out_w)
|
| 307 |
+
if values.shape == (out_h, out_w):
|
| 308 |
+
return np.rint(values).astype(np.int16)
|
| 309 |
+
if cls.USE_NUMBA_RESAMPLE:
|
| 310 |
+
from pbc3_resample import resample_bicubic
|
| 311 |
+
out = resample_bicubic(values, out_h, out_w)
|
| 312 |
+
else:
|
| 313 |
+
resized = Image.fromarray(values).resize((out_w, out_h), cls.RESAMPLE_FILTER, reducing_gap=cls.RESAMPLE_REDUCING_GAP)
|
| 314 |
+
out = np.asarray(resized, dtype=np.float32)
|
| 315 |
+
return np.rint(out).astype(np.int16)
|
| 316 |
+
|
| 317 |
+
@classmethod
|
| 318 |
+
def signed_resample_cells(cls, values, cell_size):
|
| 319 |
+
h, w = values.shape
|
| 320 |
+
return cls.signed_resample(values, cls._ceil_div(h, cell_size), cls._ceil_div(w, cell_size))
|
| 321 |
+
|
| 322 |
+
@classmethod
|
| 323 |
+
def apply_grid(cls, canvas_layer, x, y, w, h, cell_size, values):
|
| 324 |
+
patch = cls.signed_resample(values, h, w).astype(np.int32)
|
| 325 |
+
canvas_layer[y:y + h, x:x + w] += patch
|
| 326 |
+
|
| 327 |
+
@staticmethod
|
| 328 |
+
def _integral(a):
|
| 329 |
+
return np.pad(a.astype(np.int64).cumsum(0).cumsum(1), ((1, 0), (1, 0)))
|
| 330 |
+
|
| 331 |
+
@classmethod
|
| 332 |
+
def _cell_edges(cls, start, length, cell_size):
|
| 333 |
+
n = cls._ceil_div(length, cell_size)
|
| 334 |
+
edges = start + np.arange(n + 1) * cell_size
|
| 335 |
+
edges[n] = start + length
|
| 336 |
+
return edges
|
| 337 |
+
|
| 338 |
+
@classmethod
|
| 339 |
+
def _box_cell_bound(cls, integral, x, y, bw, bh, cell_size):
|
| 340 |
+
if _NUMBA:
|
| 341 |
+
return _nb_box_cell_bound(np.ascontiguousarray(integral, dtype=np.int64),
|
| 342 |
+
int(x), int(y), int(bw), int(bh), int(cell_size))
|
| 343 |
+
xe = cls._cell_edges(x, bw, cell_size)
|
| 344 |
+
ye = cls._cell_edges(y, bh, cell_size)
|
| 345 |
+
corners = integral[np.ix_(ye, xe)].astype(np.float64)
|
| 346 |
+
cell_sum = corners[1:, 1:] - corners[:-1, 1:] - corners[1:, :-1] + corners[:-1, :-1]
|
| 347 |
+
counts = (np.diff(ye)[:, None] * np.diff(xe)[None, :]).astype(np.float64)
|
| 348 |
+
return float(np.sum(cell_sum * cell_sum / counts))
|
| 349 |
+
|
| 350 |
+
@classmethod
|
| 351 |
+
def _write_grid(cls, bw, flat, bitcount):
|
| 352 |
+
for value in flat:
|
| 353 |
+
bw.write(int(value), bitcount)
|
| 354 |
+
|
| 355 |
+
@classmethod
|
| 356 |
+
def _read_grid(cls, br, n, bitcount):
|
| 357 |
+
flat = np.zeros(n, dtype=np.uint16)
|
| 358 |
+
for k in range(n):
|
| 359 |
+
flat[k] = br.read(bitcount)
|
| 360 |
+
return flat
|
| 361 |
+
|
| 362 |
+
@classmethod
|
| 363 |
+
def _patch_bits_for(cls, patch, channel_bits):
|
| 364 |
+
w, h, cell = patch["w"], patch["h"], patch["cell_size"]
|
| 365 |
+
bitcount = patch["bitcount"]
|
| 366 |
+
grid_bits = cls._ceil_div(w, cell) * cls._ceil_div(h, cell) * bitcount
|
| 367 |
+
base = channel_bits + 64 + 16 + 1
|
| 368 |
+
if patch["palette_mode"] == cls.PALETTE_EXPLICIT:
|
| 369 |
+
header = base + 4 + (1 << bitcount) * 9
|
| 370 |
+
else:
|
| 371 |
+
header = base + 10 + len(patch["mask"]) + 8 + 8 + 4
|
| 372 |
+
return header + grid_bits
|
| 373 |
+
|
| 374 |
+
@classmethod
|
| 375 |
+
def _patch_header_bits(cls, channel_bits, mask_size):
|
| 376 |
+
return channel_bits + 64 + 10 + mask_size + 8 + 8 + 4 + 16 + 1
|
| 377 |
+
|
| 378 |
+
@classmethod
|
| 379 |
+
def _palette_threshold(cls, config, step):
|
| 380 |
+
base = int(config.palette_difference_threshold)
|
| 381 |
+
if base <= 0:
|
| 382 |
+
return 0
|
| 383 |
+
if str(config.palette_difference_threshold_mode).lower() != "linear" or config.patch_count <= 1:
|
| 384 |
+
return base
|
| 385 |
+
progress = (step - 1) / max(1, config.patch_count - 1)
|
| 386 |
+
if progress >= 0.9:
|
| 387 |
+
return 0
|
| 388 |
+
return int(round(base * (1 - progress / 0.9)))
|
| 389 |
+
|
| 390 |
+
@classmethod
|
| 391 |
+
def _palette_mode_options(cls, config, bitcount):
|
| 392 |
+
mode = str(config.palette_mode).lower()
|
| 393 |
+
if mode == "generated":
|
| 394 |
+
return [cls.PALETTE_GENERATED]
|
| 395 |
+
if mode == "explicit":
|
| 396 |
+
return [cls.PALETTE_EXPLICIT]
|
| 397 |
+
opts = [cls.PALETTE_GENERATED]
|
| 398 |
+
if bitcount <= int(config.explicit_palette_max_bitcount):
|
| 399 |
+
opts.append(cls.PALETTE_EXPLICIT)
|
| 400 |
+
return opts
|
| 401 |
+
|
| 402 |
+
@classmethod
|
| 403 |
+
def _channel_error_score(cls, target, canvas, channel, mode):
|
| 404 |
+
err = np.abs(target[:, :, channel] - np.clip(canvas[:, :, channel], 0, 255))
|
| 405 |
+
if str(mode).lower() == "max":
|
| 406 |
+
return float(np.max(err))
|
| 407 |
+
return float(np.sum(err))
|
| 408 |
+
|
| 409 |
+
@classmethod
|
| 410 |
+
def _choose_channel(cls, scores, step, channels, mode):
|
| 411 |
+
mode = str(mode).lower()
|
| 412 |
+
if mode in {"sum", "max"}:
|
| 413 |
+
return int(max(range(channels), key=lambda c: scores[c]))
|
| 414 |
+
return (step - 1) % channels
|
| 415 |
+
|
| 416 |
+
@classmethod
|
| 417 |
+
def _write_patch(cls, bw, patch, channel_bits):
|
| 418 |
+
bw.write(patch["channel"], channel_bits)
|
| 419 |
+
bw.write(patch["x"], 16)
|
| 420 |
+
bw.write(patch["y"], 16)
|
| 421 |
+
bw.write(patch["w"], 16)
|
| 422 |
+
bw.write(patch["h"], 16)
|
| 423 |
+
pm = patch["palette_mode"]
|
| 424 |
+
bw.write(pm, 1)
|
| 425 |
+
bitcount = patch["bitcount"]
|
| 426 |
+
if pm == cls.PALETTE_EXPLICIT:
|
| 427 |
+
bw.write(bitcount, 4)
|
| 428 |
+
for v in patch["palette"]:
|
| 429 |
+
bw.write(int(v) & 0x1FF, 9)
|
| 430 |
+
else:
|
| 431 |
+
mask = patch["mask"]
|
| 432 |
+
bw.write(len(mask), 10)
|
| 433 |
+
for bit in mask:
|
| 434 |
+
bw.write(bit, 1)
|
| 435 |
+
bw.write(patch["neg"], 8)
|
| 436 |
+
bw.write(patch["pos"], 8)
|
| 437 |
+
bw.write(patch["max_bitcount"], 4)
|
| 438 |
+
flat = patch["indices"].ravel().astype(np.int64)
|
| 439 |
+
bw.write(patch["cell_size"], 16)
|
| 440 |
+
cls._write_grid(bw, flat, bitcount)
|
| 441 |
+
|
| 442 |
+
@classmethod
|
| 443 |
+
def _read_patch(cls, br, channel_bits, positive_bias=True):
|
| 444 |
+
channel = br.read(channel_bits)
|
| 445 |
+
x = br.read(16)
|
| 446 |
+
y = br.read(16)
|
| 447 |
+
w = br.read(16)
|
| 448 |
+
h = br.read(16)
|
| 449 |
+
pm = br.read(1)
|
| 450 |
+
if pm == cls.PALETTE_EXPLICIT:
|
| 451 |
+
bitcount = br.read(4)
|
| 452 |
+
size = 1 << bitcount
|
| 453 |
+
palette = np.empty(size, dtype=np.int16)
|
| 454 |
+
for i in range(size):
|
| 455 |
+
raw = br.read(9)
|
| 456 |
+
palette[i] = raw - 512 if raw >= 256 else raw
|
| 457 |
+
else:
|
| 458 |
+
mask_size = br.read(10)
|
| 459 |
+
mask = [br.read(1) for _ in range(mask_size)]
|
| 460 |
+
negative_max = br.read(8)
|
| 461 |
+
positive_max = br.read(8)
|
| 462 |
+
max_bitcount = br.read(4)
|
| 463 |
+
bitcount = cls.resolve_palette_bitcount(mask, max_bitcount, negative_max, positive_max, positive_bias)
|
| 464 |
+
palette = cls.palette_generator(mask, max_bitcount, negative_max, positive_max, positive_bias)
|
| 465 |
+
cell_size = br.read(16)
|
| 466 |
+
gw = cls._ceil_div(w, cell_size)
|
| 467 |
+
gh = cls._ceil_div(h, cell_size)
|
| 468 |
+
flat = cls._read_grid(br, gh * gw, bitcount)
|
| 469 |
+
indices = flat.reshape(gh, gw)
|
| 470 |
+
values = palette[indices]
|
| 471 |
+
return channel, x, y, w, h, cell_size, values
|
| 472 |
+
|
| 473 |
+
@classmethod
|
| 474 |
+
def _make_patch(cls, channel, x, y, w, h, cell_size, residual, config, max_bitcount, palette_mode, threshold):
|
| 475 |
+
small = cls.signed_resample_cells(residual, cell_size)
|
| 476 |
+
if palette_mode == cls.PALETTE_EXPLICIT:
|
| 477 |
+
bitcount = int(max_bitcount)
|
| 478 |
+
palette = cls._top_values_palette(small, bitcount, threshold)
|
| 479 |
+
indices = cls.quantize_signed(np.clip(small, -255, 255), palette)
|
| 480 |
+
values = palette[indices]
|
| 481 |
+
return {
|
| 482 |
+
"channel": channel, "x": x, "y": y, "w": w, "h": h, "cell_size": cell_size,
|
| 483 |
+
"indices": indices, "palette_mode": cls.PALETTE_EXPLICIT,
|
| 484 |
+
"palette": palette, "bitcount": bitcount,
|
| 485 |
+
"mask": None, "neg": 0, "pos": 0, "max_bitcount": bitcount,
|
| 486 |
+
}, values
|
| 487 |
+
negative_max, positive_max = cls._palette_bounds(small)
|
| 488 |
+
mask = cls._mask_from_values(small, config.mask_size, negative_max, positive_max, config.positive_bias)
|
| 489 |
+
palette = cls.palette_generator(mask, max_bitcount, negative_max, positive_max, config.positive_bias)
|
| 490 |
+
indices = cls.quantize_signed(np.clip(small, -negative_max, positive_max), palette)
|
| 491 |
+
values = palette[indices]
|
| 492 |
+
bitcount = cls.resolve_palette_bitcount(mask, max_bitcount, negative_max, positive_max, config.positive_bias)
|
| 493 |
+
return {
|
| 494 |
+
"channel": channel, "x": x, "y": y, "w": w, "h": h, "cell_size": cell_size,
|
| 495 |
+
"indices": indices, "palette_mode": cls.PALETTE_GENERATED,
|
| 496 |
+
"palette": None, "bitcount": bitcount,
|
| 497 |
+
"mask": mask, "neg": negative_max, "pos": positive_max, "max_bitcount": max_bitcount,
|
| 498 |
+
}, values
|
| 499 |
+
|
| 500 |
+
@classmethod
|
| 501 |
+
def _top_anchors(cls, visible_error_channel, top_k, block_size, channel):
|
| 502 |
+
h, w = visible_error_channel.shape
|
| 503 |
+
block_size = max(1, int(block_size))
|
| 504 |
+
if block_size == 1:
|
| 505 |
+
flat = visible_error_channel.reshape(-1)
|
| 506 |
+
k = min(int(top_k), flat.size)
|
| 507 |
+
idx = np.argpartition(flat, -k)[-k:]
|
| 508 |
+
idx = idx[np.argsort(flat[idx])[::-1]]
|
| 509 |
+
return [(channel, int(i) // w, int(i) % w) for i in idx]
|
| 510 |
+
if _NUMBA:
|
| 511 |
+
scores, ys, xs = _nb_anchor_block_scores(np.ascontiguousarray(visible_error_channel, dtype=np.float64), block_size)
|
| 512 |
+
if scores.size == 0:
|
| 513 |
+
return []
|
| 514 |
+
k = min(int(top_k), scores.size)
|
| 515 |
+
idx = np.argpartition(scores, -k)[-k:]
|
| 516 |
+
order = idx[np.argsort(scores[idx])[::-1]]
|
| 517 |
+
return [(channel, int(ys[i]), int(xs[i])) for i in order]
|
| 518 |
+
anchors = []
|
| 519 |
+
ii = np.pad(visible_error_channel.cumsum(axis=0).cumsum(axis=1), ((1, 0), (1, 0)))
|
| 520 |
+
for y0 in range(0, h, block_size):
|
| 521 |
+
y1 = min(h, y0 + block_size)
|
| 522 |
+
for x0 in range(0, w, block_size):
|
| 523 |
+
x1 = min(w, x0 + block_size)
|
| 524 |
+
s = ii[y1, x1] - ii[y0, x1] - ii[y1, x0] + ii[y0, x0]
|
| 525 |
+
anchors.append((float(s / ((y1 - y0) * (x1 - x0))), channel, (y0 + y1 - 1) // 2, (x0 + x1 - 1) // 2))
|
| 526 |
+
if not anchors:
|
| 527 |
+
return []
|
| 528 |
+
k = min(int(top_k), len(anchors))
|
| 529 |
+
idx = np.argpartition(np.array([a[0] for a in anchors]), -k)[-k:]
|
| 530 |
+
selected = [anchors[i] for i in idx]
|
| 531 |
+
selected.sort(key=lambda a: a[0], reverse=True)
|
| 532 |
+
return [(c, y, x) for _, c, y, x in selected]
|
| 533 |
+
|
| 534 |
+
@classmethod
|
| 535 |
+
def _sample_box(cls, rng, anchor, image_w, image_h, config):
|
| 536 |
+
c, ay, ax = anchor
|
| 537 |
+
min_size = max(1, int(config.min_patch_size))
|
| 538 |
+
max_w = max(min_size, min(int(config.max_patch_size), image_w))
|
| 539 |
+
max_h = max(min_size, min(int(config.max_patch_size), image_h))
|
| 540 |
+
w = int(round(2 ** rng.uniform(math.log2(min_size), math.log2(max_w))))
|
| 541 |
+
h = int(round(2 ** rng.uniform(math.log2(min_size), math.log2(max_h))))
|
| 542 |
+
w = min(max(1, w), image_w)
|
| 543 |
+
h = min(max(1, h), image_h)
|
| 544 |
+
x_min = max(0, ax - w + 1)
|
| 545 |
+
x_max = min(ax, image_w - w)
|
| 546 |
+
y_min = max(0, ay - h + 1)
|
| 547 |
+
y_max = min(ay, image_h - h)
|
| 548 |
+
x = int(rng.integers(x_min, x_max + 1)) if x_min <= x_max else max(0, min(ax, image_w - w))
|
| 549 |
+
y = int(rng.integers(y_min, y_max + 1)) if y_min <= y_max else max(0, min(ay, image_h - h))
|
| 550 |
+
return c, x, y, w, h, ax, ay
|
| 551 |
+
|
| 552 |
+
@classmethod
|
| 553 |
+
def _base_cell_size(cls, residual_patch, config):
|
| 554 |
+
if _NUMBA and residual_patch.size:
|
| 555 |
+
return int(_nb_base_cell_size(np.ascontiguousarray(residual_patch, dtype=np.float64), int(config.max_cell_size)))
|
| 556 |
+
mean_abs = float(np.mean(np.abs(residual_patch))) if residual_patch.size else 0.0
|
| 557 |
+
if mean_abs <= 0:
|
| 558 |
+
return int(config.max_cell_size)
|
| 559 |
+
gx = float(np.mean(np.abs(np.diff(residual_patch, axis=1)))) if residual_patch.shape[1] > 1 else 0.0
|
| 560 |
+
gy = float(np.mean(np.abs(np.diff(residual_patch, axis=0)))) if residual_patch.shape[0] > 1 else 0.0
|
| 561 |
+
ratio = (gx + gy) / (mean_abs + 1.0)
|
| 562 |
+
if ratio < 0.25:
|
| 563 |
+
return 32
|
| 564 |
+
if ratio < 0.5:
|
| 565 |
+
return 16
|
| 566 |
+
if ratio < 1.0:
|
| 567 |
+
return 8
|
| 568 |
+
return 4
|
| 569 |
+
|
| 570 |
+
@classmethod
|
| 571 |
+
def _candidate_cell_sizes(cls, base, config):
|
| 572 |
+
offsets = [0, 1, -1, 2, -2, 3, -3]
|
| 573 |
+
cells = []
|
| 574 |
+
for off in offsets:
|
| 575 |
+
if len(cells) >= max(1, int(config.cell_sizes_per_candidate)):
|
| 576 |
+
break
|
| 577 |
+
cell = int(round(base * (2 ** off)))
|
| 578 |
+
cell = max(int(config.min_cell_size), min(int(config.max_cell_size), cell))
|
| 579 |
+
if cell not in cells:
|
| 580 |
+
cells.append(cell)
|
| 581 |
+
return cells
|
| 582 |
+
|
| 583 |
+
@classmethod
|
| 584 |
+
def _patch_bitcounts(cls, config):
|
| 585 |
+
if str(config.patch_bitcount_mode).lower() != "dynamic":
|
| 586 |
+
return [int(config.patch_palette_bitcount)]
|
| 587 |
+
lo = max(1, min(9, int(config.dynamic_patch_bitcount_min)))
|
| 588 |
+
hi = max(lo, min(9, int(config.dynamic_patch_bitcount_max)))
|
| 589 |
+
return list(range(lo, hi + 1))
|
| 590 |
+
|
| 591 |
+
@classmethod
|
| 592 |
+
def _auto_init_candidates(cls, residual, w, h, config):
|
| 593 |
+
mean_abs = float(np.mean(np.abs(residual))) + 1.0
|
| 594 |
+
gx = float(np.mean(np.abs(np.diff(residual, axis=1)))) if residual.shape[1] > 1 else 0.0
|
| 595 |
+
gy = float(np.mean(np.abs(np.diff(residual, axis=0)))) if residual.shape[0] > 1 else 0.0
|
| 596 |
+
freq = (gx + gy) / mean_abs
|
| 597 |
+
std = float(np.std(residual))
|
| 598 |
+
if freq >= 1.0:
|
| 599 |
+
cell0 = 4
|
| 600 |
+
elif freq >= 0.5:
|
| 601 |
+
cell0 = 8
|
| 602 |
+
elif freq >= 0.25:
|
| 603 |
+
cell0 = 12
|
| 604 |
+
elif freq >= 0.12:
|
| 605 |
+
cell0 = 16
|
| 606 |
+
else:
|
| 607 |
+
cell0 = 24
|
| 608 |
+
if std < 6:
|
| 609 |
+
bits0 = 3
|
| 610 |
+
elif std < 12:
|
| 611 |
+
bits0 = 4
|
| 612 |
+
elif std < 24:
|
| 613 |
+
bits0 = 5
|
| 614 |
+
else:
|
| 615 |
+
bits0 = 6
|
| 616 |
+
lo_c, hi_c = max(1, int(config.min_cell_size)), min(int(config.max_cell_size), max(w, h))
|
| 617 |
+
max_b = int(config.downsample_palette_bitcount)
|
| 618 |
+
clampc = lambda v: max(lo_c, min(hi_c, int(v)))
|
| 619 |
+
clampb = lambda v: max(1, min(max_b, int(v)))
|
| 620 |
+
raw = [(cell0, bits0), (cell0, bits0 - 1), (cell0, bits0 + 1),
|
| 621 |
+
(cell0 // 2, bits0), (cell0 * 2, bits0), (cell0 // 2, bits0 - 1),
|
| 622 |
+
(cell0 * 2, bits0 + 1), (cell0 // 4, bits0), (cell0 * 4, bits0),
|
| 623 |
+
(cell0 // 2, bits0 + 1), (cell0 * 2, bits0 - 1), (cell0, bits0 + 2),
|
| 624 |
+
(cell0, bits0 - 2), (cell0 // 4, bits0 + 1), (cell0 * 4, bits0 - 1)]
|
| 625 |
+
out = []
|
| 626 |
+
for cell, bits in raw:
|
| 627 |
+
pair = (clampc(cell), clampb(bits))
|
| 628 |
+
if pair not in out:
|
| 629 |
+
out.append(pair)
|
| 630 |
+
return out
|
| 631 |
+
|
| 632 |
+
@classmethod
|
| 633 |
+
def _select_init(cls, c, target, canvas, w, h, config, channel_bits):
|
| 634 |
+
base_layer = canvas[:, :, c]
|
| 635 |
+
residual = target[:, :, c] - base_layer
|
| 636 |
+
before = target[:, :, c] - np.clip(base_layer, 0, 255)
|
| 637 |
+
before_sse = float(np.sum(before.astype(np.int64) ** 2))
|
| 638 |
+
cands = cls._auto_init_candidates(residual, w, h, config)[:max(1, int(config.init_search_depth))]
|
| 639 |
+
reductions, bit_costs, built = [], [], []
|
| 640 |
+
for cell, bits in cands:
|
| 641 |
+
patch, values = cls._make_patch(c, 0, 0, w, h, cell, residual, config, bits, cls.PALETTE_GENERATED, 0)
|
| 642 |
+
delta = cls.signed_resample(values, h, w).astype(np.int32)
|
| 643 |
+
after = target[:, :, c] - np.clip(base_layer + delta, 0, 255)
|
| 644 |
+
reductions.append(before_sse - float(np.sum(after.astype(np.int64) ** 2)))
|
| 645 |
+
bit_costs.append(cls._patch_bits_for(patch, channel_bits))
|
| 646 |
+
built.append((patch, values, cell, bits))
|
| 647 |
+
q = float(config.q_init)
|
| 648 |
+
scores = q * cls._norm(reductions) - (1.0 - q) * cls._norm(bit_costs)
|
| 649 |
+
return built[int(np.argmax(scores))]
|
| 650 |
+
|
| 651 |
+
@classmethod
|
| 652 |
+
def _debug_line(cls, kind, **items):
|
| 653 |
+
return kind + " " + " ".join(f"{k}={v}" for k, v in items.items())
|
| 654 |
+
|
| 655 |
+
@classmethod
|
| 656 |
+
def _select_patch(cls, target, canvas, config, rng, channel_bits, step, canvas_patches, debug_lines, timings, current_channel):
|
| 657 |
+
t = time.perf_counter()
|
| 658 |
+
visible_canvas_channel = np.clip(canvas[:, :, current_channel], 0, 255).astype(np.int32)
|
| 659 |
+
visible_error = (target[:, :, current_channel] - visible_canvas_channel).astype(np.int64)
|
| 660 |
+
abs_error = np.abs(visible_error)
|
| 661 |
+
integral_signed = cls._integral(visible_error)
|
| 662 |
+
integral_abs = cls._integral(abs_error)
|
| 663 |
+
cls._add_time(timings, "visible_error", time.perf_counter() - t)
|
| 664 |
+
|
| 665 |
+
q = cls._interp(config.q_start, config.q_end, step, config.patch_count)
|
| 666 |
+
search_q = cls._interp(config.search_q_start, config.search_q_end, step, config.patch_count)
|
| 667 |
+
threshold = cls._palette_threshold(config, step)
|
| 668 |
+
|
| 669 |
+
t = time.perf_counter()
|
| 670 |
+
anchors = cls._top_anchors(abs_error.astype(np.float32), config.top_k, config.anchor_block_size, current_channel)
|
| 671 |
+
cls._add_time(timings, "anchors", time.perf_counter() - t)
|
| 672 |
+
if not anchors:
|
| 673 |
+
return None, None
|
| 674 |
+
|
| 675 |
+
h, w, _ = target.shape
|
| 676 |
+
box_sums, box_areas, box_specs = [], [], []
|
| 677 |
+
t = time.perf_counter()
|
| 678 |
+
for i in range(max(1, int(config.search_depth))):
|
| 679 |
+
c, x, y, bw, bh, ax, ay = cls._sample_box(rng, anchors[i % len(anchors)], w, h, config)
|
| 680 |
+
box_sum = integral_abs[y + bh, x + bw] - integral_abs[y, x + bw] - integral_abs[y + bh, x] + integral_abs[y, x]
|
| 681 |
+
if box_sum <= 0:
|
| 682 |
+
continue
|
| 683 |
+
box_sums.append(float(box_sum))
|
| 684 |
+
box_areas.append(float(bw * bh))
|
| 685 |
+
box_specs.append((c, x, y, bw, bh))
|
| 686 |
+
cls._add_time(timings, "search_prescore", time.perf_counter() - t)
|
| 687 |
+
if not box_specs:
|
| 688 |
+
return None, None
|
| 689 |
+
pre_scores = search_q * cls._norm(box_sums) - (1.0 - search_q) * cls._norm(box_areas)
|
| 690 |
+
keep = np.argsort(pre_scores)[::-1][:max(1, int(config.proposal_depth))]
|
| 691 |
+
boxes = [box_specs[i] for i in keep]
|
| 692 |
+
|
| 693 |
+
header_bits = cls._patch_header_bits(channel_bits, config.mask_size)
|
| 694 |
+
mid_bitcount = int(config.patch_palette_bitcount)
|
| 695 |
+
|
| 696 |
+
t = time.perf_counter()
|
| 697 |
+
mid_bounds, mid_bits, mid_specs = [], [], []
|
| 698 |
+
for (c, x, y, bw, bh) in boxes:
|
| 699 |
+
hidden_residual = target[y:y + bh, x:x + bw, c] - canvas[y:y + bh, x:x + bw, c]
|
| 700 |
+
base_cell = cls._base_cell_size(hidden_residual, config)
|
| 701 |
+
for cell_size in cls._candidate_cell_sizes(base_cell, config):
|
| 702 |
+
cell_size = max(1, min(cell_size, bw, bh))
|
| 703 |
+
bound = cls._box_cell_bound(integral_signed, x, y, bw, bh, cell_size)
|
| 704 |
+
if bound <= 0:
|
| 705 |
+
continue
|
| 706 |
+
grid_cells = cls._ceil_div(bw, cell_size) * cls._ceil_div(bh, cell_size)
|
| 707 |
+
mid_bounds.append(bound)
|
| 708 |
+
mid_bits.append(header_bits + grid_cells * mid_bitcount)
|
| 709 |
+
mid_specs.append((c, x, y, bw, bh, cell_size))
|
| 710 |
+
cls._add_time(timings, "mid_score", time.perf_counter() - t)
|
| 711 |
+
if not mid_specs:
|
| 712 |
+
return None, None
|
| 713 |
+
mid_scores = q * cls._norm(mid_bounds) - (1.0 - q) * cls._norm(mid_bits)
|
| 714 |
+
keep = np.argsort(mid_scores)[::-1][:max(1, int(config.exact_depth))]
|
| 715 |
+
mid_specs = [mid_specs[i] for i in keep]
|
| 716 |
+
|
| 717 |
+
bitcounts = cls._patch_bitcounts(config)
|
| 718 |
+
reductions, bit_costs, built = [], [], []
|
| 719 |
+
t = time.perf_counter()
|
| 720 |
+
for proposal_i, (c, x, y, bw, bh, cell_size) in enumerate(mid_specs):
|
| 721 |
+
hidden_residual = target[y:y + bh, x:x + bw, c] - canvas[y:y + bh, x:x + bw, c]
|
| 722 |
+
before = target[y:y + bh, x:x + bw, c] - np.clip(canvas[y:y + bh, x:x + bw, c], 0, 255)
|
| 723 |
+
before_sse = float(np.sum(before.astype(np.int64) ** 2))
|
| 724 |
+
for bitcount in bitcounts:
|
| 725 |
+
for palette_mode in cls._palette_mode_options(config, bitcount):
|
| 726 |
+
patch, values = cls._make_patch(c, x, y, bw, bh, cell_size, hidden_residual, config, bitcount, palette_mode, threshold)
|
| 727 |
+
delta = cls.signed_resample(values, bh, bw).astype(np.int32)
|
| 728 |
+
after = target[y:y + bh, x:x + bw, c] - np.clip(canvas[y:y + bh, x:x + bw, c] + delta, 0, 255)
|
| 729 |
+
reduction = before_sse - float(np.sum(after.astype(np.int64) ** 2))
|
| 730 |
+
if reduction <= 0:
|
| 731 |
+
continue
|
| 732 |
+
reductions.append(reduction)
|
| 733 |
+
bit_costs.append(cls._patch_bits_for(patch, channel_bits))
|
| 734 |
+
built.append((patch, values))
|
| 735 |
+
if config.debug_mode:
|
| 736 |
+
debug_lines.append(cls._debug_line(
|
| 737 |
+
"CANDIDATE", patch_step=step, canvas_patches=canvas_patches, proposal=proposal_i,
|
| 738 |
+
channel=c, x=x, y=y, w=bw, h=bh, cell_size=cell_size, bitcount=bitcount,
|
| 739 |
+
palette_mode=palette_mode, reduction=f"{reduction:.4f}"))
|
| 740 |
+
cls._add_time(timings, "fill_score", time.perf_counter() - t)
|
| 741 |
+
if not built:
|
| 742 |
+
return None, None
|
| 743 |
+
|
| 744 |
+
scores = q * cls._norm(reductions) - (1.0 - q) * cls._norm(bit_costs)
|
| 745 |
+
best_i = int(np.argmax(scores))
|
| 746 |
+
best_patch, best_values = built[best_i]
|
| 747 |
+
if config.debug_mode:
|
| 748 |
+
debug_lines.append(cls._debug_line(
|
| 749 |
+
"SELECTED", patch_step=step, canvas_patches=canvas_patches, channel=best_patch["channel"],
|
| 750 |
+
x=best_patch["x"], y=best_patch["y"], w=best_patch["w"], h=best_patch["h"],
|
| 751 |
+
cell_size=best_patch["cell_size"], bitcount=best_patch["bitcount"],
|
| 752 |
+
palette_mode=best_patch["palette_mode"], score=f"{float(scores[best_i]):.6f}"))
|
| 753 |
+
return best_patch, best_values
|
| 754 |
+
|
| 755 |
+
@classmethod
|
| 756 |
+
def _write_header(cls, bw, w, h, original_w, original_h, downsampled, color_id, channels, channel_bits, positive_bias, has_alpha, patch_count, base_values):
|
| 757 |
+
bw.write(int(downsampled), 1)
|
| 758 |
+
if downsampled:
|
| 759 |
+
bw.write(original_w, 16)
|
| 760 |
+
bw.write(original_h, 16)
|
| 761 |
+
bw.write(w, 16)
|
| 762 |
+
bw.write(h, 16)
|
| 763 |
+
bw.write(color_id, 2)
|
| 764 |
+
bw.write(channels, 8)
|
| 765 |
+
bw.write(channel_bits, 4)
|
| 766 |
+
bw.write(int(positive_bias), 1)
|
| 767 |
+
bw.write(int(has_alpha), 1)
|
| 768 |
+
bw.write(patch_count, 32)
|
| 769 |
+
for base in base_values:
|
| 770 |
+
bw.write(base, 8)
|
| 771 |
+
|
| 772 |
+
@classmethod
|
| 773 |
+
def _read_header(cls, br):
|
| 774 |
+
downsampled = bool(br.read(1))
|
| 775 |
+
original_w = br.read(16) if downsampled else None
|
| 776 |
+
original_h = br.read(16) if downsampled else None
|
| 777 |
+
w = br.read(16)
|
| 778 |
+
h = br.read(16)
|
| 779 |
+
color_id = br.read(2)
|
| 780 |
+
channels = br.read(8)
|
| 781 |
+
channel_bits = br.read(4)
|
| 782 |
+
positive_bias = bool(br.read(1))
|
| 783 |
+
has_alpha = bool(br.read(1))
|
| 784 |
+
patch_count = br.read(32)
|
| 785 |
+
color_space = cls.COLOR_SPACE_NAMES[color_id]
|
| 786 |
+
base_values = [br.read(8) for _ in range(channels)]
|
| 787 |
+
return downsampled, original_w, original_h, w, h, color_space, channels, channel_bits, positive_bias, has_alpha, patch_count, base_values
|
| 788 |
+
|
| 789 |
+
@classmethod
|
| 790 |
+
def prepare(cls, image, config=None, **kwargs):
|
| 791 |
+
"""Build the working-resolution target once so it can be reused across many
|
| 792 |
+
compress() runs on the same image via compress(..., reuse=prep) (skips the
|
| 793 |
+
setup_downsample step per run). Reuse is valid only while color_space /
|
| 794 |
+
downsample settings stay the same."""
|
| 795 |
+
if config is None:
|
| 796 |
+
config = PBC3Config(**kwargs)
|
| 797 |
+
elif kwargs:
|
| 798 |
+
config = PBC3Config(**{**config.__dict__, **kwargs})
|
| 799 |
+
src = cls._to_image(image)
|
| 800 |
+
has_alpha = cls._has_alpha(src)
|
| 801 |
+
if has_alpha:
|
| 802 |
+
rgba = src.convert("RGBA")
|
| 803 |
+
color_img = rgba.convert("RGB").convert(config.color_space)
|
| 804 |
+
alpha_img = rgba.getchannel("A")
|
| 805 |
+
orig_compare = rgba
|
| 806 |
+
else:
|
| 807 |
+
color_img = src.convert(config.color_space)
|
| 808 |
+
alpha_img = None
|
| 809 |
+
orig_compare = src.convert("RGB")
|
| 810 |
+
original_w, original_h = color_img.size
|
| 811 |
+
rate = cls._auto_downsample_rate(color_img.size, config.downsample_rate, config.auto_downsample_max_pixels)
|
| 812 |
+
color_ds = cls._downsample_image(color_img, rate)
|
| 813 |
+
downsampled = color_ds.size != color_img.size
|
| 814 |
+
arr = np.asarray(color_ds, dtype=np.uint8)
|
| 815 |
+
if has_alpha:
|
| 816 |
+
alpha_ds = alpha_img.resize(color_ds.size, cls.RESAMPLE_FILTER, reducing_gap=cls.RESAMPLE_REDUCING_GAP) if downsampled else alpha_img
|
| 817 |
+
arr = np.dstack([arr, np.asarray(alpha_ds, dtype=np.uint8)])
|
| 818 |
+
h, w, channels = arr.shape
|
| 819 |
+
return {
|
| 820 |
+
"arr": arr, "target": arr.astype(np.int32), "h": h, "w": w, "channels": channels,
|
| 821 |
+
"original_w": original_w, "original_h": original_h, "downsampled": downsampled,
|
| 822 |
+
"has_alpha": has_alpha, "orig_compare": orig_compare, "rate": rate,
|
| 823 |
+
"color_id": cls.COLOR_SPACES[config.color_space], "color_space": config.color_space,
|
| 824 |
+
}
|
| 825 |
+
|
| 826 |
+
@classmethod
|
| 827 |
+
def compress(cls, image, config=None, *, reuse=None, **kwargs):
|
| 828 |
+
if config is None:
|
| 829 |
+
config = PBC3Config(**kwargs)
|
| 830 |
+
elif kwargs:
|
| 831 |
+
config = PBC3Config(**{**config.__dict__, **kwargs})
|
| 832 |
+
|
| 833 |
+
t0 = time.perf_counter()
|
| 834 |
+
timings = {}
|
| 835 |
+
debug_lines = []
|
| 836 |
+
|
| 837 |
+
t = time.perf_counter()
|
| 838 |
+
prep = reuse if reuse is not None else cls.prepare(image, config)
|
| 839 |
+
arr, target = prep["arr"], prep["target"]
|
| 840 |
+
h, w, channels = prep["h"], prep["w"], prep["channels"]
|
| 841 |
+
original_w, original_h = prep["original_w"], prep["original_h"]
|
| 842 |
+
downsampled, has_alpha = prep["downsampled"], prep["has_alpha"]
|
| 843 |
+
orig_compare, color_id, rate = prep["orig_compare"], prep["color_id"], prep["rate"]
|
| 844 |
+
cls._add_time(timings, "setup_downsample", time.perf_counter() - t)
|
| 845 |
+
|
| 846 |
+
if w > 65535 or h > 65535 or original_w > 65535 or original_h > 65535:
|
| 847 |
+
raise ValueError("this prototype stores dimensions as uint16")
|
| 848 |
+
if config.mask_size < 1 or config.mask_size > 1023:
|
| 849 |
+
raise ValueError("mask_size must be in 1..1023")
|
| 850 |
+
if config.auto_downsample_max_pixels < 1:
|
| 851 |
+
raise ValueError("auto_downsample_max_pixels must be >= 1")
|
| 852 |
+
if not (1 <= config.downsample_palette_bitcount <= 9 and 1 <= config.patch_palette_bitcount <= 9):
|
| 853 |
+
raise ValueError("palette bitcounts must be in 1..9")
|
| 854 |
+
if str(config.channel_cycle).lower() not in {"off", "sum", "max"}:
|
| 855 |
+
raise ValueError('channel_cycle must be "Off", "Sum", or "Max"')
|
| 856 |
+
if str(config.patch_bitcount_mode).lower() not in {"constant", "dynamic"}:
|
| 857 |
+
raise ValueError('patch_bitcount_mode must be "constant" or "dynamic"')
|
| 858 |
+
if str(config.palette_mode).lower() not in {"generated", "explicit", "auto"}:
|
| 859 |
+
raise ValueError('palette_mode must be "generated", "explicit", or "auto"')
|
| 860 |
+
|
| 861 |
+
channel_bits = max(1, math.ceil(math.log2(channels)))
|
| 862 |
+
base_values = [int(round(float(np.mean(arr[:, :, c])))) for c in range(channels)]
|
| 863 |
+
canvas = np.zeros((h, w, channels), dtype=np.int32)
|
| 864 |
+
for c, base in enumerate(base_values):
|
| 865 |
+
canvas[:, :, c] = base
|
| 866 |
+
|
| 867 |
+
patches = []
|
| 868 |
+
t = time.perf_counter()
|
| 869 |
+
for c in range(channels):
|
| 870 |
+
if config.auto_downsample_init:
|
| 871 |
+
patch, values, init_cell, init_bits = cls._select_init(c, target, canvas, w, h, config, channel_bits)
|
| 872 |
+
if config.debug_print:
|
| 873 |
+
print(f"[auto-init] channel {c}: cell={init_cell}, bitcount={init_bits}")
|
| 874 |
+
else:
|
| 875 |
+
init_cell, init_bits = config.downsample_init_cell_size, config.downsample_palette_bitcount
|
| 876 |
+
residual = target[:, :, c] - canvas[:, :, c]
|
| 877 |
+
patch, values = cls._make_patch(c, 0, 0, w, h, init_cell, residual, config, init_bits, cls.PALETTE_GENERATED, 0)
|
| 878 |
+
cls.apply_grid(canvas[:, :, c], 0, 0, w, h, init_cell, values)
|
| 879 |
+
patches.append(patch)
|
| 880 |
+
if config.debug_mode:
|
| 881 |
+
debug_lines.append(cls._debug_line("INIT", stream_patch=len(patches), channel=c, x=0, y=0, w=w, h=h, cell_size=init_cell, bitcount=init_bits))
|
| 882 |
+
cls._add_time(timings, "init_layer", time.perf_counter() - t)
|
| 883 |
+
|
| 884 |
+
channel_scores = [cls._channel_error_score(target, canvas, c, config.channel_cycle) for c in range(channels)]
|
| 885 |
+
quality_target = float(config.quality_target_mae)
|
| 886 |
+
rng = np.random.default_rng(config.random_seed)
|
| 887 |
+
t_patch_total = time.perf_counter()
|
| 888 |
+
for step in range(1, max(0, int(config.patch_count)) + 1):
|
| 889 |
+
current_channel = cls._choose_channel(channel_scores, step, channels, config.channel_cycle)
|
| 890 |
+
patch, values = cls._select_patch(target, canvas, config, rng, channel_bits, step, len(patches), debug_lines, timings, current_channel)
|
| 891 |
+
if patch is None:
|
| 892 |
+
break
|
| 893 |
+
t = time.perf_counter()
|
| 894 |
+
c = patch["channel"]
|
| 895 |
+
cls.apply_grid(canvas[:, :, c], patch["x"], patch["y"], patch["w"], patch["h"], patch["cell_size"], values)
|
| 896 |
+
patches.append(patch)
|
| 897 |
+
channel_scores[c] = cls._channel_error_score(target, canvas, c, config.channel_cycle)
|
| 898 |
+
cls._add_time(timings, "apply_selected", time.perf_counter() - t)
|
| 899 |
+
if config.debug_mode:
|
| 900 |
+
debug_lines.append(cls._debug_line("APPLIED", patch_step=step, stream_patch=len(patches), channel=c, channel_score=f"{channel_scores[c]:.4f}", x=patch["x"], y=patch["y"], w=patch["w"], h=patch["h"], cell_size=patch["cell_size"]))
|
| 901 |
+
if config.debug_print:
|
| 902 |
+
print("|", end="", flush=True)
|
| 903 |
+
if quality_target > 0 and float(np.mean(np.abs(target - np.clip(canvas, 0, 255)))) <= quality_target:
|
| 904 |
+
break
|
| 905 |
+
if config.debug_print:
|
| 906 |
+
print()
|
| 907 |
+
cls._add_time(timings, "patch_loop_total", time.perf_counter() - t_patch_total)
|
| 908 |
+
|
| 909 |
+
t = time.perf_counter()
|
| 910 |
+
bw = BitWriter()
|
| 911 |
+
cls._write_header(bw, w, h, original_w, original_h, downsampled, color_id, channels, channel_bits, config.positive_bias, has_alpha, len(patches), base_values)
|
| 912 |
+
for patch in patches:
|
| 913 |
+
cls._write_patch(bw, patch, channel_bits)
|
| 914 |
+
method, body = cls._entropy_pack(bw.finish(), config.use_lzma)
|
| 915 |
+
data = cls.MAGIC + bytes([cls.VERSION, method]) + body
|
| 916 |
+
cls._add_time(timings, "serialize", time.perf_counter() - t)
|
| 917 |
+
|
| 918 |
+
t = time.perf_counter()
|
| 919 |
+
out_img = cls._canvas_to_image(canvas, config.color_space, has_alpha)
|
| 920 |
+
if downsampled:
|
| 921 |
+
out_img = out_img.resize((original_w, original_h), cls.RESAMPLE_FILTER, reducing_gap=cls.RESAMPLE_REDUCING_GAP)
|
| 922 |
+
mse = float(np.mean((np.asarray(orig_compare, dtype=np.float32) - np.asarray(out_img, dtype=np.float32)) ** 2))
|
| 923 |
+
cls._add_time(timings, "finalize_mse", time.perf_counter() - t)
|
| 924 |
+
|
| 925 |
+
debug_path = None
|
| 926 |
+
total_seconds = time.perf_counter() - t0
|
| 927 |
+
timings["total"] = total_seconds
|
| 928 |
+
if config.debug_mode:
|
| 929 |
+
ts = time.strftime("%Y%m%d_%H%M%S")
|
| 930 |
+
debug_path = config.debug_path or f"debug_{ts}.txt"
|
| 931 |
+
with open(debug_path, "w", encoding="utf-8") as f:
|
| 932 |
+
f.write(cls._debug_line("CONFIG", **{k: v for k, v in config.__dict__.items() if k not in {"debug_path"}}) + "\n")
|
| 933 |
+
f.write(cls._debug_line("IMAGE", original_w=original_w, original_h=original_h, working_w=w, working_h=h,
|
| 934 |
+
original_pixels=original_w * original_h, working_pixels=w * h,
|
| 935 |
+
downsample_rate=f"{rate:.6f}", downsampled=int(downsampled), has_alpha=int(has_alpha)) + "\n")
|
| 936 |
+
for k, v in timings.items():
|
| 937 |
+
f.write(cls._debug_line("TIMER", phase=k, seconds=f"{v:.6f}") + "\n")
|
| 938 |
+
for line in debug_lines:
|
| 939 |
+
f.write(line + "\n")
|
| 940 |
+
|
| 941 |
+
return PBC3Result(out_img, data, config, mse, total_seconds, len(data) * 8, original_w, original_h, w, h, timings, debug_path, channels=channels)
|
| 942 |
+
|
| 943 |
+
@classmethod
|
| 944 |
+
def _canvas_to_image(cls, canvas, color_space, has_alpha):
|
| 945 |
+
arr = np.clip(canvas, 0, 255).astype(np.uint8)
|
| 946 |
+
if has_alpha:
|
| 947 |
+
color = Image.fromarray(arr[:, :, :3], color_space).convert("RGB").convert("RGBA")
|
| 948 |
+
color.putalpha(Image.fromarray(arr[:, :, 3], "L"))
|
| 949 |
+
return color
|
| 950 |
+
return Image.fromarray(arr, color_space).convert("RGB")
|
| 951 |
+
|
| 952 |
+
@classmethod
|
| 953 |
+
def _decode_to_canvas(cls, data, max_patches=None):
|
| 954 |
+
if isinstance(data, str):
|
| 955 |
+
with open(data, "rb") as f:
|
| 956 |
+
data = f.read()
|
| 957 |
+
version, body = cls._open_body(data)
|
| 958 |
+
br = BitReader(body)
|
| 959 |
+
downsampled, original_w, original_h, w, h, color_space, channels, channel_bits, positive_bias, has_alpha, patch_count, base_values = cls._read_header(br)
|
| 960 |
+
canvas = np.zeros((h, w, channels), dtype=np.int32)
|
| 961 |
+
for c, base in enumerate(base_values):
|
| 962 |
+
canvas[:, :, c] = base
|
| 963 |
+
patches_to_read = patch_count if max_patches is None else min(int(max_patches), patch_count)
|
| 964 |
+
for _ in range(patches_to_read):
|
| 965 |
+
channel, x, y, pw, ph, cell_size, values = cls._read_patch(br, channel_bits, positive_bias)
|
| 966 |
+
cls.apply_grid(canvas[:, :, channel], x, y, pw, ph, cell_size, values)
|
| 967 |
+
return canvas, color_space, downsampled, original_w, original_h, w, h, has_alpha, channels, patch_count
|
| 968 |
+
|
| 969 |
+
@classmethod
|
| 970 |
+
def decompress(cls, data, max_patches=None):
|
| 971 |
+
t0 = time.perf_counter()
|
| 972 |
+
if isinstance(data, str):
|
| 973 |
+
with open(data, "rb") as f:
|
| 974 |
+
data = f.read()
|
| 975 |
+
canvas, color_space, downsampled, original_w, original_h, w, h, has_alpha, channels, patch_count = cls._decode_to_canvas(data, max_patches=max_patches)
|
| 976 |
+
img = cls._canvas_to_image(canvas, color_space, has_alpha)
|
| 977 |
+
if downsampled:
|
| 978 |
+
img = img.resize((original_w, original_h), cls.RESAMPLE_FILTER, reducing_gap=cls.RESAMPLE_REDUCING_GAP)
|
| 979 |
+
cfg = PBC3Config(color_space=color_space)
|
| 980 |
+
return PBC3Result(img, data, cfg, None, time.perf_counter() - t0, len(data) * 8, original_w or w, original_h or h, w, h, channels=channels)
|
| 981 |
+
|
| 982 |
+
@classmethod
|
| 983 |
+
def encode_file(cls, input_path, output_path, config=None, **kwargs):
|
| 984 |
+
result = cls.compress(Image.open(input_path), config=config, **kwargs)
|
| 985 |
+
with open(output_path, "wb") as f:
|
| 986 |
+
f.write(result.data)
|
| 987 |
+
return result
|
| 988 |
+
|
| 989 |
+
@classmethod
|
| 990 |
+
def decode_file(cls, input_path, output_path=None):
|
| 991 |
+
image = cls.decompress(input_path).image
|
| 992 |
+
if output_path is not None:
|
| 993 |
+
image.save(output_path)
|
| 994 |
+
return image
|
| 995 |
+
|
| 996 |
+
|
| 997 |
+
if __name__ == "__main__":
|
| 998 |
+
import sys
|
| 999 |
+
if len(sys.argv) < 3:
|
| 1000 |
+
print("usage: python PBC3.py input_image output.pbc3")
|
| 1001 |
+
else:
|
| 1002 |
+
res = PBC3.encode_file(sys.argv[1], sys.argv[2])
|
| 1003 |
+
print(f"MSE: {res.mse:.2f} | Size: {len(res.data) / 1024:.2f} KB | Rate: {res.compression_rate:.2f}x | Time: {res.encode_seconds:.3f}s")
|
PBC3_animation.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import numpy as np
|
| 3 |
+
from PIL import Image, ImageDraw, ImageFont
|
| 4 |
+
from PBC3 import PBC3, BitReader
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def _font(size):
|
| 8 |
+
for name in ("DejaVuSans.ttf", "Arial.ttf"):
|
| 9 |
+
try:
|
| 10 |
+
return ImageFont.truetype(name, size)
|
| 11 |
+
except Exception:
|
| 12 |
+
pass
|
| 13 |
+
return ImageFont.load_default()
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _colorize_rgb_channel(channel, c):
|
| 17 |
+
arr = np.clip(channel, 0, 255).astype(np.uint8)
|
| 18 |
+
out = np.zeros((arr.shape[0], arr.shape[1], 3), dtype=np.uint8)
|
| 19 |
+
out[:, :, c % 3] = arr
|
| 20 |
+
return Image.fromarray(out, "RGB")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _colorize_ycbcr_channel(channel, c):
|
| 24 |
+
arr = np.clip(channel, 0, 255).astype(np.uint8)
|
| 25 |
+
if c == 0:
|
| 26 |
+
return Image.fromarray(np.stack([arr, arr, arr], axis=2), "RGB")
|
| 27 |
+
|
| 28 |
+
d = arr.astype(np.int16) - 128
|
| 29 |
+
mag = np.clip(np.abs(d) * 2, 0, 255).astype(np.uint8)
|
| 30 |
+
out = np.zeros((*arr.shape, 3), dtype=np.uint8)
|
| 31 |
+
if c == 1:
|
| 32 |
+
pos = d >= 0
|
| 33 |
+
out[pos, 2] = mag[pos]
|
| 34 |
+
out[~pos, 0] = mag[~pos]
|
| 35 |
+
out[~pos, 1] = mag[~pos]
|
| 36 |
+
else:
|
| 37 |
+
pos = d >= 0
|
| 38 |
+
out[pos, 0] = mag[pos]
|
| 39 |
+
out[~pos, 1] = mag[~pos]
|
| 40 |
+
out[~pos, 2] = mag[~pos]
|
| 41 |
+
return Image.fromarray(out, "RGB")
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _colorize_channel(channel, c, color_space):
|
| 45 |
+
if str(color_space).lower() == "ycbcr":
|
| 46 |
+
return _colorize_ycbcr_channel(channel, c)
|
| 47 |
+
return _colorize_rgb_channel(channel, c)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _error_image(error, color_space, channel=None):
|
| 51 |
+
err = np.asarray(error, dtype=np.float32)
|
| 52 |
+
if err.ndim == 3:
|
| 53 |
+
err = np.mean(np.abs(err), axis=2)
|
| 54 |
+
m = float(np.max(err))
|
| 55 |
+
arr = np.clip(err * (255.0 / m), 0, 255).astype(np.uint8) if m > 0 else np.zeros(err.shape, dtype=np.uint8)
|
| 56 |
+
return Image.fromarray(np.stack([arr, arr, arr], axis=2), "RGB")
|
| 57 |
+
|
| 58 |
+
signed = err
|
| 59 |
+
mag = np.abs(signed)
|
| 60 |
+
m = float(np.max(mag))
|
| 61 |
+
arr = np.clip(mag * (255.0 / m), 0, 255).astype(np.uint8) if m > 0 else np.zeros(mag.shape, dtype=np.uint8)
|
| 62 |
+
if str(color_space).lower() != "ycbcr" or channel == 0:
|
| 63 |
+
return _colorize_channel(arr, 0 if channel is None else channel, color_space)
|
| 64 |
+
|
| 65 |
+
d = np.sign(signed).astype(np.int16) * arr.astype(np.int16)
|
| 66 |
+
if channel == 1:
|
| 67 |
+
out = np.zeros((*arr.shape, 3), dtype=np.uint8)
|
| 68 |
+
pos = d >= 0
|
| 69 |
+
out[pos, 2] = arr[pos]
|
| 70 |
+
out[~pos, 0] = arr[~pos]
|
| 71 |
+
out[~pos, 1] = arr[~pos]
|
| 72 |
+
return Image.fromarray(out, "RGB")
|
| 73 |
+
|
| 74 |
+
out = np.zeros((*arr.shape, 3), dtype=np.uint8)
|
| 75 |
+
pos = d >= 0
|
| 76 |
+
out[pos, 0] = arr[pos]
|
| 77 |
+
out[~pos, 1] = arr[~pos]
|
| 78 |
+
out[~pos, 2] = arr[~pos]
|
| 79 |
+
return Image.fromarray(out, "RGB")
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def _draw_patch(draw, box, scale, offset, color="red", width=4):
|
| 83 |
+
if box is None:
|
| 84 |
+
return
|
| 85 |
+
x, y, w, h = box
|
| 86 |
+
ox, oy = offset
|
| 87 |
+
rect = [ox + x * scale, oy + y * scale, ox + (x + w) * scale, oy + (y + h) * scale]
|
| 88 |
+
for i in range(width):
|
| 89 |
+
draw.rectangle([rect[0] - i, rect[1] - i, rect[2] + i, rect[3] + i], outline=color)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def _fit(img, max_w, max_h):
|
| 93 |
+
scale = min(max_w / img.width, max_h / img.height)
|
| 94 |
+
out_w = max(1, int(img.width * scale))
|
| 95 |
+
out_h = max(1, int(img.height * scale))
|
| 96 |
+
return img.resize((out_w, out_h), Image.Resampling.NEAREST), scale
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def _make_frame(canvas, color_space, patch_info, separated_channels, title, target=None, show_errors=False, output_size=(3840, 2160)):
|
| 100 |
+
arr = np.clip(canvas, 0, 255).astype(np.uint8)
|
| 101 |
+
rgb = Image.fromarray(arr, color_space).convert("RGB")
|
| 102 |
+
error = None if target is None else target.astype(np.int32) - arr.astype(np.int32)
|
| 103 |
+
|
| 104 |
+
if not separated_channels:
|
| 105 |
+
panels = [(rgb, False)]
|
| 106 |
+
if show_errors:
|
| 107 |
+
if error is None:
|
| 108 |
+
raise ValueError("show_errors=True requires original_image")
|
| 109 |
+
panels.append((_error_image(error, color_space), True))
|
| 110 |
+
cols, rows = 1, len(panels)
|
| 111 |
+
else:
|
| 112 |
+
panels = [
|
| 113 |
+
(_colorize_channel(arr[:, :, 0], 0, color_space), False),
|
| 114 |
+
(_colorize_channel(arr[:, :, 1], 1, color_space), False),
|
| 115 |
+
(_colorize_channel(arr[:, :, 2], 2, color_space), False),
|
| 116 |
+
(rgb, False),
|
| 117 |
+
]
|
| 118 |
+
if show_errors:
|
| 119 |
+
if error is None:
|
| 120 |
+
raise ValueError("show_errors=True requires original_image")
|
| 121 |
+
panels.extend([
|
| 122 |
+
(_error_image(error[:, :, 0], color_space, 0), True),
|
| 123 |
+
(_error_image(error[:, :, 1], color_space, 1), True),
|
| 124 |
+
(_error_image(error[:, :, 2], color_space, 2), True),
|
| 125 |
+
(_error_image(error, color_space), True),
|
| 126 |
+
])
|
| 127 |
+
cols, rows = 4, 2 if show_errors else 1
|
| 128 |
+
|
| 129 |
+
frame_w, frame_h = output_size
|
| 130 |
+
frame = Image.new("RGB", output_size, "black")
|
| 131 |
+
draw = ImageDraw.Draw(frame)
|
| 132 |
+
title_h = 120
|
| 133 |
+
gap = 18
|
| 134 |
+
margin = 36
|
| 135 |
+
draw.text((margin, 34), title, fill="white", font=_font(46))
|
| 136 |
+
|
| 137 |
+
area_w = frame_w - margin * 2
|
| 138 |
+
area_h = frame_h - title_h - margin
|
| 139 |
+
cell_w = (area_w - gap * (cols - 1)) // cols
|
| 140 |
+
cell_h = (area_h - gap * (rows - 1)) // rows
|
| 141 |
+
active_channel = patch_info[0] if patch_info is not None else None
|
| 142 |
+
patch_box = patch_info[1:5] if patch_info is not None else None
|
| 143 |
+
|
| 144 |
+
for i, (panel, is_error) in enumerate(panels):
|
| 145 |
+
col = i % cols
|
| 146 |
+
row = i // cols
|
| 147 |
+
x0 = margin + col * (cell_w + gap)
|
| 148 |
+
y0 = title_h + row * (cell_h + gap)
|
| 149 |
+
fitted, scale = _fit(panel, cell_w, cell_h)
|
| 150 |
+
px = x0 + (cell_w - fitted.width) // 2
|
| 151 |
+
py = y0 + (cell_h - fitted.height) // 2
|
| 152 |
+
frame.paste(fitted, (px, py))
|
| 153 |
+
|
| 154 |
+
is_rgb_panel = (separated_channels and col == 3) or (not separated_channels and i == 0)
|
| 155 |
+
is_active_channel_panel = separated_channels and col == active_channel
|
| 156 |
+
if patch_box is not None and not is_error and (is_rgb_panel or is_active_channel_panel):
|
| 157 |
+
_draw_patch(draw, patch_box, scale, (px, py))
|
| 158 |
+
|
| 159 |
+
return frame
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def _even_rgb_array(frame):
|
| 163 |
+
arr = np.asarray(frame.convert("RGB"), dtype=np.uint8)
|
| 164 |
+
h, w = arr.shape[:2]
|
| 165 |
+
pad_h = h % 2
|
| 166 |
+
pad_w = w % 2
|
| 167 |
+
if pad_h or pad_w:
|
| 168 |
+
arr = np.pad(arr, ((0, pad_h), (0, pad_w), (0, 0)), mode="constant", constant_values=0)
|
| 169 |
+
return arr
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def _write_mp4(frames, output_path, fps):
|
| 173 |
+
try:
|
| 174 |
+
import imageio.v2 as imageio
|
| 175 |
+
except Exception as e:
|
| 176 |
+
raise RuntimeError("imageio is not importable. Try: pip install imageio imageio-ffmpeg") from e
|
| 177 |
+
try:
|
| 178 |
+
import imageio_ffmpeg # noqa: F401
|
| 179 |
+
except Exception as e:
|
| 180 |
+
raise RuntimeError("MP4 writing needs ffmpeg. Try: pip install imageio-ffmpeg") from e
|
| 181 |
+
|
| 182 |
+
writer = imageio.get_writer(
|
| 183 |
+
output_path,
|
| 184 |
+
fps=fps,
|
| 185 |
+
codec="libx264",
|
| 186 |
+
quality=8,
|
| 187 |
+
macro_block_size=16,
|
| 188 |
+
ffmpeg_params=["-pix_fmt", "yuv420p", "-movflags", "+faststart"],
|
| 189 |
+
)
|
| 190 |
+
try:
|
| 191 |
+
for frame in frames:
|
| 192 |
+
writer.append_data(_even_rgb_array(frame))
|
| 193 |
+
finally:
|
| 194 |
+
writer.close()
|
| 195 |
+
return output_path
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def _write_gif(frames, output_path, fps):
|
| 199 |
+
duration = int(1000 / fps)
|
| 200 |
+
frames[0].save(output_path, save_all=True, append_images=frames[1:], duration=duration, loop=0)
|
| 201 |
+
return output_path
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def _write_frames(frames, output_path, fps, fallback_to_gif=True):
|
| 205 |
+
ext = os.path.splitext(output_path)[1].lower()
|
| 206 |
+
if ext == ".gif":
|
| 207 |
+
return _write_gif(frames, output_path, fps)
|
| 208 |
+
if ext not in {".mp4", ".m4v", ".mov"}:
|
| 209 |
+
output_path = os.path.splitext(output_path)[0] + ".mp4"
|
| 210 |
+
try:
|
| 211 |
+
return _write_mp4(frames, output_path, fps)
|
| 212 |
+
except Exception as e:
|
| 213 |
+
if not fallback_to_gif:
|
| 214 |
+
raise
|
| 215 |
+
fallback = os.path.splitext(output_path)[0] + ".gif"
|
| 216 |
+
print(f"MP4 export failed: {e}")
|
| 217 |
+
print(f"Falling back to GIF: {fallback}")
|
| 218 |
+
return _write_gif(frames, fallback, fps)
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def _working_target(original_image, color_space, working_w, working_h):
|
| 222 |
+
img = PBC3._to_image(original_image).convert(color_space)
|
| 223 |
+
if img.width != working_w or img.height != working_h:
|
| 224 |
+
img = img.resize((working_w, working_h), PBC3.RESAMPLE_FILTER, reducing_gap=PBC3.RESAMPLE_REDUCING_GAP)
|
| 225 |
+
return np.asarray(img, dtype=np.uint8)
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def animate_pbc3(
|
| 229 |
+
data,
|
| 230 |
+
output_path="pbc3_animation.mp4",
|
| 231 |
+
fps=3,
|
| 232 |
+
separated_channels=True,
|
| 233 |
+
max_patches=None,
|
| 234 |
+
fallback_to_gif=True,
|
| 235 |
+
show_errors=False,
|
| 236 |
+
original_image=None,
|
| 237 |
+
output_size=(3840, 2160),
|
| 238 |
+
):
|
| 239 |
+
if isinstance(data, str):
|
| 240 |
+
with open(data, "rb") as f:
|
| 241 |
+
data = f.read()
|
| 242 |
+
version, body = PBC3._open_body(data)
|
| 243 |
+
br = BitReader(body)
|
| 244 |
+
downsampled, original_w, original_h, w, h, color_space, channels, channel_bits, positive_bias, patch_count, base_values = PBC3._read_header(br)
|
| 245 |
+
if channels != 3 and separated_channels:
|
| 246 |
+
separated_channels = False
|
| 247 |
+
if show_errors and original_image is None:
|
| 248 |
+
raise ValueError("show_errors=True requires original_image=...")
|
| 249 |
+
|
| 250 |
+
target = _working_target(original_image, color_space, w, h) if show_errors else None
|
| 251 |
+
canvas = np.zeros((h, w, channels), dtype=np.int32)
|
| 252 |
+
for c, base in enumerate(base_values):
|
| 253 |
+
canvas[:, :, c] = base
|
| 254 |
+
|
| 255 |
+
frames = []
|
| 256 |
+
limit = patch_count if max_patches is None else min(int(max_patches), patch_count)
|
| 257 |
+
|
| 258 |
+
# Note: entropy coding is global, so per-patch byte sizes are reported on the
|
| 259 |
+
# pre-entropy (uncompressed) stream; the final file is smaller after packing.
|
| 260 |
+
current_bytes = br.i
|
| 261 |
+
current_kb = current_bytes / 1024
|
| 262 |
+
frames.append(_make_frame(
|
| 263 |
+
canvas,
|
| 264 |
+
color_space,
|
| 265 |
+
None,
|
| 266 |
+
separated_channels,
|
| 267 |
+
f"Patch 0/{patch_count} | Stream Size: {current_kb:.2f} KB | (+0.00 KB)",
|
| 268 |
+
target,
|
| 269 |
+
show_errors,
|
| 270 |
+
output_size,
|
| 271 |
+
))
|
| 272 |
+
|
| 273 |
+
for i in range(1, limit + 1):
|
| 274 |
+
previous_bytes = current_bytes
|
| 275 |
+
|
| 276 |
+
channel, x, y, pw, ph, cell_size, values, mode = PBC3._read_patch(br, channel_bits, positive_bias)
|
| 277 |
+
|
| 278 |
+
current_bytes = br.i
|
| 279 |
+
current_kb = current_bytes / 1024
|
| 280 |
+
delta_kb = (current_bytes - previous_bytes) / 1024
|
| 281 |
+
|
| 282 |
+
PBC3.apply_grid(canvas[:, :, channel], x, y, pw, ph, cell_size, values)
|
| 283 |
+
title = (
|
| 284 |
+
f"Patch {i}/{patch_count} | Stream Size: {current_kb:.2f} KB "
|
| 285 |
+
f"| (+{delta_kb:.2f} KB) | ch={channel} box=({x},{y},{pw},{ph}) cell={cell_size}"
|
| 286 |
+
)
|
| 287 |
+
frames.append(_make_frame(
|
| 288 |
+
canvas,
|
| 289 |
+
color_space,
|
| 290 |
+
(channel, x, y, pw, ph, cell_size),
|
| 291 |
+
separated_channels,
|
| 292 |
+
title,
|
| 293 |
+
target,
|
| 294 |
+
show_errors,
|
| 295 |
+
output_size,
|
| 296 |
+
))
|
| 297 |
+
|
| 298 |
+
return _write_frames(frames, output_path, fps, fallback_to_gif=fallback_to_gif)
|
pbc3_kernels.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Numba-accelerated search kernels for PBC3.
|
| 2 |
+
#
|
| 3 |
+
# These are imported and used directly by PBC3 when numba is available; otherwise
|
| 4 |
+
# PBC3 falls back to equivalent numpy implementations. They are quality-equivalent
|
| 5 |
+
# but NOT bit-identical to the pure-numpy path (numba sums floats in a different
|
| 6 |
+
# order than numpy's pairwise summation, so argsort/argpartition ties may
|
| 7 |
+
# occasionally flip and select a different but equivalent patch).
|
| 8 |
+
#
|
| 9 |
+
# verify() checks the kernels against reference numpy formulas to tight tolerance.
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
|
| 13 |
+
try:
|
| 14 |
+
from numba import njit
|
| 15 |
+
NUMBA_AVAILABLE = True
|
| 16 |
+
except Exception:
|
| 17 |
+
NUMBA_AVAILABLE = False
|
| 18 |
+
|
| 19 |
+
def njit(*args, **kwargs):
|
| 20 |
+
def wrap(f):
|
| 21 |
+
return f
|
| 22 |
+
return wrap(args[0]) if args and callable(args[0]) else wrap
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@njit(cache=True)
|
| 26 |
+
def box_cell_bound(integral, x, y, bw, bh, cell_size):
|
| 27 |
+
nx = (bw + cell_size - 1) // cell_size
|
| 28 |
+
ny = (bh + cell_size - 1) // cell_size
|
| 29 |
+
total = 0.0
|
| 30 |
+
for iy in range(ny):
|
| 31 |
+
y0 = y + iy * cell_size
|
| 32 |
+
y1 = y + bh if iy == ny - 1 else y + (iy + 1) * cell_size
|
| 33 |
+
for ix in range(nx):
|
| 34 |
+
x0 = x + ix * cell_size
|
| 35 |
+
x1 = x + bw if ix == nx - 1 else x + (ix + 1) * cell_size
|
| 36 |
+
s = float(integral[y1, x1] - integral[y0, x1] - integral[y1, x0] + integral[y0, x0])
|
| 37 |
+
total += s * s / ((y1 - y0) * (x1 - x0))
|
| 38 |
+
return total
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
@njit(cache=True)
|
| 42 |
+
def base_cell_size(res, max_cell):
|
| 43 |
+
h, w = res.shape
|
| 44 |
+
s = 0.0
|
| 45 |
+
for i in range(h):
|
| 46 |
+
for j in range(w):
|
| 47 |
+
v = res[i, j]
|
| 48 |
+
s += v if v >= 0 else -v
|
| 49 |
+
mean_abs = s / (h * w)
|
| 50 |
+
if mean_abs <= 0.0:
|
| 51 |
+
return max_cell
|
| 52 |
+
gx = 0.0
|
| 53 |
+
if w > 1:
|
| 54 |
+
for i in range(h):
|
| 55 |
+
for j in range(w - 1):
|
| 56 |
+
d = res[i, j + 1] - res[i, j]
|
| 57 |
+
gx += d if d >= 0 else -d
|
| 58 |
+
gx /= h * (w - 1)
|
| 59 |
+
gy = 0.0
|
| 60 |
+
if h > 1:
|
| 61 |
+
for i in range(h - 1):
|
| 62 |
+
for j in range(w):
|
| 63 |
+
d = res[i + 1, j] - res[i, j]
|
| 64 |
+
gy += d if d >= 0 else -d
|
| 65 |
+
gy /= (h - 1) * w
|
| 66 |
+
ratio = (gx + gy) / (mean_abs + 1.0)
|
| 67 |
+
if ratio < 0.25:
|
| 68 |
+
return 32
|
| 69 |
+
if ratio < 0.5:
|
| 70 |
+
return 16
|
| 71 |
+
if ratio < 1.0:
|
| 72 |
+
return 8
|
| 73 |
+
return 4
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
@njit(cache=True)
|
| 77 |
+
def anchor_block_scores(err, block_size):
|
| 78 |
+
h, w = err.shape
|
| 79 |
+
ny = (h + block_size - 1) // block_size
|
| 80 |
+
nx = (w + block_size - 1) // block_size
|
| 81 |
+
n = ny * nx
|
| 82 |
+
scores = np.empty(n, dtype=np.float64)
|
| 83 |
+
ys = np.empty(n, dtype=np.int64)
|
| 84 |
+
xs = np.empty(n, dtype=np.int64)
|
| 85 |
+
k = 0
|
| 86 |
+
for by in range(ny):
|
| 87 |
+
y0 = by * block_size
|
| 88 |
+
y1 = min(h, y0 + block_size)
|
| 89 |
+
for bx in range(nx):
|
| 90 |
+
x0 = bx * block_size
|
| 91 |
+
x1 = min(w, x0 + block_size)
|
| 92 |
+
s = 0.0
|
| 93 |
+
for i in range(y0, y1):
|
| 94 |
+
for j in range(x0, x1):
|
| 95 |
+
s += err[i, j]
|
| 96 |
+
scores[k] = s / ((y1 - y0) * (x1 - x0))
|
| 97 |
+
ys[k] = (y0 + y1 - 1) // 2
|
| 98 |
+
xs[k] = (x0 + x1 - 1) // 2
|
| 99 |
+
k += 1
|
| 100 |
+
return scores, ys, xs
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
# Reference numpy formulas for tolerance-checking the kernels.
|
| 104 |
+
def _ref_box_cell_bound(integral, x, y, bw, bh, cell_size):
|
| 105 |
+
cd = lambda a, b: (a + b - 1) // b
|
| 106 |
+
def edges(start, length):
|
| 107 |
+
n = cd(length, cell_size)
|
| 108 |
+
e = start + np.arange(n + 1) * cell_size
|
| 109 |
+
e[n] = start + length
|
| 110 |
+
return e
|
| 111 |
+
xe, ye = edges(x, bw), edges(y, bh)
|
| 112 |
+
corners = integral[np.ix_(ye, xe)].astype(np.float64)
|
| 113 |
+
cell_sum = corners[1:, 1:] - corners[:-1, 1:] - corners[1:, :-1] + corners[:-1, :-1]
|
| 114 |
+
counts = (np.diff(ye)[:, None] * np.diff(xe)[None, :]).astype(np.float64)
|
| 115 |
+
return float(np.sum(cell_sum * cell_sum / counts))
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def _ref_base_cell_size(res, max_cell):
|
| 119 |
+
mean_abs = float(np.mean(np.abs(res)))
|
| 120 |
+
if mean_abs <= 0:
|
| 121 |
+
return int(max_cell)
|
| 122 |
+
gx = float(np.mean(np.abs(np.diff(res, axis=1)))) if res.shape[1] > 1 else 0.0
|
| 123 |
+
gy = float(np.mean(np.abs(np.diff(res, axis=0)))) if res.shape[0] > 1 else 0.0
|
| 124 |
+
ratio = (gx + gy) / (mean_abs + 1.0)
|
| 125 |
+
return 32 if ratio < 0.25 else 16 if ratio < 0.5 else 8 if ratio < 1.0 else 4
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def _ref_block_scores(err, block_size):
|
| 129 |
+
h, w = err.shape
|
| 130 |
+
out = []
|
| 131 |
+
for y0 in range(0, h, block_size):
|
| 132 |
+
y1 = min(h, y0 + block_size)
|
| 133 |
+
for x0 in range(0, w, block_size):
|
| 134 |
+
x1 = min(w, x0 + block_size)
|
| 135 |
+
out.append(float(err[y0:y1, x0:x1].astype(np.float64).sum() / ((y1 - y0) * (x1 - x0))))
|
| 136 |
+
return np.array(out)
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def verify(trials=400, seed=0):
|
| 140 |
+
rng = np.random.default_rng(seed)
|
| 141 |
+
worst_box = 0.0
|
| 142 |
+
bcs_mismatch = 0
|
| 143 |
+
worst_anchor = 0.0
|
| 144 |
+
for _ in range(trials):
|
| 145 |
+
H, W = int(rng.integers(8, 200)), int(rng.integers(8, 200))
|
| 146 |
+
e = rng.integers(-80, 80, size=(H, W)).astype(np.int64)
|
| 147 |
+
integral = np.pad(e.cumsum(0).cumsum(1), ((1, 0), (1, 0)))
|
| 148 |
+
bw, bh = int(rng.integers(4, W + 1)), int(rng.integers(4, H + 1))
|
| 149 |
+
x, y = int(rng.integers(0, W - bw + 1)), int(rng.integers(0, H - bh + 1))
|
| 150 |
+
cell = int(rng.integers(1, max(2, min(bw, bh))))
|
| 151 |
+
ref = _ref_box_cell_bound(integral, x, y, bw, bh, cell)
|
| 152 |
+
got = box_cell_bound(integral, x, y, bw, bh, cell)
|
| 153 |
+
worst_box = max(worst_box, abs(got - ref) / (abs(ref) + 1e-9))
|
| 154 |
+
|
| 155 |
+
res = rng.integers(-60, 60, size=(bh, bw)).astype(np.float64)
|
| 156 |
+
if _ref_base_cell_size(res, 64) != int(base_cell_size(res, 64)):
|
| 157 |
+
bcs_mismatch += 1
|
| 158 |
+
|
| 159 |
+
bs = int(rng.integers(2, 16))
|
| 160 |
+
err = np.abs(e).astype(np.float64)
|
| 161 |
+
ref_s = _ref_block_scores(err, bs)
|
| 162 |
+
got_s, _, _ = anchor_block_scores(err, bs)
|
| 163 |
+
worst_anchor = max(worst_anchor, float(np.max(np.abs(ref_s - got_s) / (np.abs(ref_s) + 1e-9))))
|
| 164 |
+
print(f"numba_available={NUMBA_AVAILABLE} trials={trials}")
|
| 165 |
+
print(f"box_cell_bound worst_relative_diff={worst_box:.3e}")
|
| 166 |
+
print(f"base_cell_size mismatches={bcs_mismatch}/{trials}")
|
| 167 |
+
print(f"anchor_block_scores worst_relative_diff={worst_anchor:.3e}")
|
| 168 |
+
return worst_box, bcs_mismatch, worst_anchor
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
if __name__ == "__main__":
|
| 172 |
+
verify()
|
pbc3_types.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# PBC3 supporting types: bit I/O, config, and result container.
|
| 2 |
+
# Split out of PBC3.py to keep that module under size limits and improve structure.
|
| 3 |
+
|
| 4 |
+
from dataclasses import dataclass, field
|
| 5 |
+
import os
|
| 6 |
+
import numpy as np
|
| 7 |
+
from PIL import Image
|
| 8 |
+
from matplotlib import pyplot as plt
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class BitWriter:
|
| 12 |
+
def __init__(self):
|
| 13 |
+
self.data = bytearray()
|
| 14 |
+
self.acc = 0
|
| 15 |
+
self.nbits = 0
|
| 16 |
+
|
| 17 |
+
def write(self, value, bitcount):
|
| 18 |
+
value = int(value)
|
| 19 |
+
if bitcount <= 0:
|
| 20 |
+
return
|
| 21 |
+
if value < 0 or value >= (1 << bitcount):
|
| 22 |
+
raise ValueError(f"value {value} does not fit in {bitcount} bits")
|
| 23 |
+
self.acc = (self.acc << bitcount) | value
|
| 24 |
+
self.nbits += bitcount
|
| 25 |
+
while self.nbits >= 8:
|
| 26 |
+
shift = self.nbits - 8
|
| 27 |
+
self.data.append((self.acc >> shift) & 255)
|
| 28 |
+
self.acc &= (1 << shift) - 1
|
| 29 |
+
self.nbits -= 8
|
| 30 |
+
|
| 31 |
+
def finish(self):
|
| 32 |
+
if self.nbits:
|
| 33 |
+
self.data.append((self.acc << (8 - self.nbits)) & 255)
|
| 34 |
+
self.acc = 0
|
| 35 |
+
self.nbits = 0
|
| 36 |
+
return bytes(self.data)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class BitReader:
|
| 40 |
+
def __init__(self, data):
|
| 41 |
+
self.data = data
|
| 42 |
+
self.i = 0
|
| 43 |
+
self.acc = 0
|
| 44 |
+
self.nbits = 0
|
| 45 |
+
|
| 46 |
+
def read(self, bitcount):
|
| 47 |
+
while self.nbits < bitcount:
|
| 48 |
+
if self.i >= len(self.data):
|
| 49 |
+
raise EOFError("bitstream ended early")
|
| 50 |
+
self.acc = (self.acc << 8) | self.data[self.i]
|
| 51 |
+
self.i += 1
|
| 52 |
+
self.nbits += 8
|
| 53 |
+
shift = self.nbits - bitcount
|
| 54 |
+
value = (self.acc >> shift) & ((1 << bitcount) - 1)
|
| 55 |
+
self.acc &= (1 << shift) - 1
|
| 56 |
+
self.nbits -= bitcount
|
| 57 |
+
return value
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
@dataclass
|
| 61 |
+
class PBC3Config:
|
| 62 |
+
patch_count: int = 20
|
| 63 |
+
search_depth: int = 200
|
| 64 |
+
proposal_depth: int = 50
|
| 65 |
+
exact_depth: int = 10
|
| 66 |
+
min_patch_size: int = 16
|
| 67 |
+
max_patch_size: int = 400
|
| 68 |
+
min_cell_size: int = 1
|
| 69 |
+
max_cell_size: int = 64
|
| 70 |
+
cell_sizes_per_candidate: int = 3
|
| 71 |
+
top_k: int = 20
|
| 72 |
+
search_q_start: float = 0.4
|
| 73 |
+
search_q_end: float = 0.1
|
| 74 |
+
q_init: float = 0.7
|
| 75 |
+
q_start: float = 0.9
|
| 76 |
+
q_end: float = 0.9
|
| 77 |
+
color_space: str = "YCbCr"
|
| 78 |
+
channel_cycle: str = "Sum"
|
| 79 |
+
auto_downsample_init: bool = True
|
| 80 |
+
init_search_depth: int = 7
|
| 81 |
+
downsample_init_cell_size: int = 12
|
| 82 |
+
downsample_palette_bitcount: int = 6
|
| 83 |
+
downsample_rate: float = -1
|
| 84 |
+
auto_downsample_max_pixels: int = 250_000
|
| 85 |
+
patch_palette_bitcount: int = 2
|
| 86 |
+
patch_bitcount_mode: str = "constant"
|
| 87 |
+
palette_mode: str = "generated"
|
| 88 |
+
palette_difference_threshold: int = 0
|
| 89 |
+
palette_difference_threshold_mode: str = "constant"
|
| 90 |
+
explicit_palette_max_bitcount: int = 3
|
| 91 |
+
quality_target_mae: float = 0.0
|
| 92 |
+
mask_size: int = 4
|
| 93 |
+
anchor_block_size: int = 8
|
| 94 |
+
dynamic_patch_bitcount_min: int = 2
|
| 95 |
+
dynamic_patch_bitcount_max: int = 3
|
| 96 |
+
positive_bias: bool = True
|
| 97 |
+
use_lzma: bool = True
|
| 98 |
+
random_seed: int = 2003
|
| 99 |
+
debug_mode: bool = False
|
| 100 |
+
debug_print: bool = False
|
| 101 |
+
debug_path: str = None
|
| 102 |
+
|
| 103 |
+
def __post_init__(self):
|
| 104 |
+
self.channel_cycle = str(self.channel_cycle)
|
| 105 |
+
self.patch_bitcount_mode = str(self.patch_bitcount_mode)
|
| 106 |
+
|
| 107 |
+
@classmethod
|
| 108 |
+
def speed(cls): # old "balanced"
|
| 109 |
+
return cls()
|
| 110 |
+
|
| 111 |
+
@classmethod
|
| 112 |
+
def balanced(cls):
|
| 113 |
+
return cls(patch_count=20, search_q_start=0.7, search_q_end=0.2,
|
| 114 |
+
init_search_depth=3, q_init=0.7, q_start=0.8, q_end=0.8)
|
| 115 |
+
|
| 116 |
+
@classmethod
|
| 117 |
+
def quality(cls):
|
| 118 |
+
return cls(patch_count=20, search_q_start=0.7, search_q_end=0.6,
|
| 119 |
+
init_search_depth=3, q_init=0.9, q_start=0.9, q_end=0.8)
|
| 120 |
+
|
| 121 |
+
@classmethod
|
| 122 |
+
def compression(cls):
|
| 123 |
+
return cls(patch_count=50, search_q_start=0.5, search_q_end=0.2,
|
| 124 |
+
init_search_depth=3, q_init=0.7, q_start=0.8, q_end=0.8)
|
| 125 |
+
|
| 126 |
+
@classmethod
|
| 127 |
+
def high_quality(cls): # old "quality"
|
| 128 |
+
return cls(patch_count=100, search_q_start=0.6, search_q_end=0.1)
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
@dataclass
|
| 132 |
+
class PBC3Result:
|
| 133 |
+
image: Image.Image
|
| 134 |
+
data: bytes
|
| 135 |
+
config: PBC3Config
|
| 136 |
+
mse: float
|
| 137 |
+
encode_seconds: float
|
| 138 |
+
total_bits: int
|
| 139 |
+
original_width: int = None
|
| 140 |
+
original_height: int = None
|
| 141 |
+
working_width: int = None
|
| 142 |
+
working_height: int = None
|
| 143 |
+
timings: dict = field(default_factory=dict)
|
| 144 |
+
debug_path: str = None
|
| 145 |
+
channels: int = 3
|
| 146 |
+
|
| 147 |
+
@property
|
| 148 |
+
def original_bits(self):
|
| 149 |
+
w = self.original_width or self.image.width
|
| 150 |
+
h = self.original_height or self.image.height
|
| 151 |
+
return w * h * self.channels * 8
|
| 152 |
+
|
| 153 |
+
@property
|
| 154 |
+
def compressed_kb(self):
|
| 155 |
+
return self.total_bits / 8 / 1024
|
| 156 |
+
|
| 157 |
+
@property
|
| 158 |
+
def original_kb(self):
|
| 159 |
+
return self.original_bits / 8 / 1024
|
| 160 |
+
|
| 161 |
+
@property
|
| 162 |
+
def compression_rate(self):
|
| 163 |
+
return self.original_bits / self.total_bits if self.total_bits else float("inf")
|
| 164 |
+
|
| 165 |
+
@property
|
| 166 |
+
def compressed_percent(self):
|
| 167 |
+
return self.total_bits / self.original_bits * 100 if self.original_bits else 0
|
| 168 |
+
|
| 169 |
+
def save(self, path):
|
| 170 |
+
if self.data is None:
|
| 171 |
+
raise ValueError("result has no compressed data to save")
|
| 172 |
+
with open(path, "wb") as f:
|
| 173 |
+
f.write(self.data)
|
| 174 |
+
|
| 175 |
+
def verify(self):
|
| 176 |
+
from PBC3 import PBC3
|
| 177 |
+
if self.data is None:
|
| 178 |
+
return False
|
| 179 |
+
decoded = PBC3.decompress(self.data).image
|
| 180 |
+
return np.array_equal(np.asarray(self.image), np.asarray(decoded))
|
| 181 |
+
|
| 182 |
+
def show(self, subtitle=None):
|
| 183 |
+
fig = plt.figure(figsize=(8, 7.4), dpi=130)
|
| 184 |
+
gs = fig.add_gridspec(3, 1, height_ratios=[0.09, 0.16, 1.0], hspace=0.04)
|
| 185 |
+
title_ax = fig.add_subplot(gs[0])
|
| 186 |
+
info_ax = fig.add_subplot(gs[1])
|
| 187 |
+
image_ax = fig.add_subplot(gs[2])
|
| 188 |
+
for ax in (title_ax, info_ax, image_ax):
|
| 189 |
+
ax.axis("off")
|
| 190 |
+
title_ax.text(0.5, 0.5, "PBC3 Result" if subtitle is None else f"PBC3 Result\n{subtitle}", ha="center", va="center", fontsize=16, fontweight="bold")
|
| 191 |
+
mse = "N/A" if self.mse is None else f"{self.mse:.2f}"
|
| 192 |
+
seconds = "N/A" if self.encode_seconds is None else f"{self.encode_seconds:.3f}s"
|
| 193 |
+
debug = f" | Debug: {os.path.basename(self.debug_path)}" if self.debug_path else ""
|
| 194 |
+
info = (
|
| 195 |
+
f"MSE: {mse} | Compressed: {self.compressed_kb:.2f} KB | Original: {self.original_kb:.2f} KB\n"
|
| 196 |
+
f"Compression: {self.compression_rate:.2f}x ({self.compressed_percent:.2f}%) | Time: {seconds}{debug}"
|
| 197 |
+
)
|
| 198 |
+
info_ax.text(0.5, 0.5, info, ha="center", va="center", color="white", fontsize=10, linespacing=1.35,
|
| 199 |
+
bbox=dict(boxstyle="round,pad=0.5", facecolor="black", alpha=0.72, edgecolor="none"))
|
| 200 |
+
image_ax.imshow(self.image)
|
| 201 |
+
plt.show()
|
server.py
CHANGED
|
@@ -8,7 +8,7 @@ import os
|
|
| 8 |
import time
|
| 9 |
from typing import List
|
| 10 |
|
| 11 |
-
from fastapi import FastAPI, File, Form, UploadFile
|
| 12 |
from fastapi.responses import JSONResponse, StreamingResponse
|
| 13 |
from fastapi.staticfiles import StaticFiles
|
| 14 |
from PIL import Image
|
|
@@ -21,6 +21,7 @@ except Exception:
|
|
| 21 |
pass
|
| 22 |
|
| 23 |
from PBC2_4 import PBC, PBC2Config, PBC2Result, preload_numba
|
|
|
|
| 24 |
|
| 25 |
app = FastAPI(title="PBC Compression Demo")
|
| 26 |
|
|
@@ -182,6 +183,30 @@ def _i(v, d):
|
|
| 182 |
def _truthy(v):
|
| 183 |
return str(v).lower() == "true"
|
| 184 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
|
| 186 |
@app.get("/api/multlist")
|
| 187 |
def multlist(bit_count: int = 2, min: int = -10, max: int = 20, mode: str = "Stable_Uniform"):
|
|
@@ -189,102 +214,44 @@ def multlist(bit_count: int = 2, min: int = -10, max: int = 20, mode: str = "Sta
|
|
| 189 |
|
| 190 |
|
| 191 |
@app.post("/api/compress")
|
| 192 |
-
async def compress(
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
downsample_initialize_bits: str = Form("8"),
|
| 198 |
-
downsample_initialize: str = Form("true"),
|
| 199 |
-
size_range_start: str = Form(""),
|
| 200 |
-
size_range_end: str = Form(""),
|
| 201 |
-
color_space: str = Form("RGB"),
|
| 202 |
-
downsample_rate: str = Form(""),
|
| 203 |
-
downsample_alg: str = Form("Bicubic"),
|
| 204 |
-
start_mode: str = Form("Average"),
|
| 205 |
-
decay_mode: str = Form("Auto"),
|
| 206 |
-
decay_cutoff: str = Form(""),
|
| 207 |
-
decay_softness: str = Form(""),
|
| 208 |
-
decay_progress: str = Form(""),
|
| 209 |
-
mult_list: str = Form(""),
|
| 210 |
-
strokes_per_quadrant: str = Form("100"),
|
| 211 |
-
quadrant_warmup_time: str = Form("0.5"),
|
| 212 |
-
quadrant_max_bits: str = Form("8"),
|
| 213 |
-
quadrant_padding: str = Form("4"),
|
| 214 |
-
quadrant_selection_criteria: str = Form("Sum"),
|
| 215 |
-
channel_cycle: str = Form("Smart"),
|
| 216 |
-
strokes_per_channel_cycle: str = Form("100"),
|
| 217 |
-
channel_cycle_warmup_time: str = Form("0.9"),
|
| 218 |
-
cycle_selection_criteria: str = Form("Min"),
|
| 219 |
-
):
|
| 220 |
try:
|
| 221 |
-
raw_bytes = await
|
| 222 |
img = Image.open(io.BytesIO(raw_bytes)).convert("RGB")
|
| 223 |
except Exception as exc:
|
| 224 |
return JSONResponse({"error": f"Could not read image: {exc}"}, status_code=400)
|
| 225 |
w, h = img.size
|
| 226 |
|
| 227 |
-
|
| 228 |
-
channel_cycle = None
|
| 229 |
-
elif channel_cycle == "123":
|
| 230 |
-
channel_cycle = "Default"
|
| 231 |
-
|
| 232 |
if mode == "Auto":
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
kwargs = {}
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
else:
|
| 245 |
-
decay = dict(
|
| 246 |
-
decay_cutoff=_f(decay_cutoff, 0.5),
|
| 247 |
-
decay_softness=_f(decay_softness, 0.5),
|
| 248 |
-
decay_progress=_f(decay_progress, 0.5),
|
| 249 |
-
)
|
| 250 |
-
try:
|
| 251 |
-
mlist = [int(x) for x in ast.literal_eval(mult_list)]
|
| 252 |
-
assert mlist
|
| 253 |
-
except (ValueError, SyntaxError, AssertionError, TypeError):
|
| 254 |
-
mlist = [-10, 0, 5, 20]
|
| 255 |
-
kwargs = dict(
|
| 256 |
-
stroke_count=_i(stroke_count, -1),
|
| 257 |
-
size_range=(_f(size_range_start, 0.3), _f(size_range_end, 0.01)),
|
| 258 |
-
mult_list=mlist,
|
| 259 |
-
start_mode=start_mode,
|
| 260 |
-
**decay,
|
| 261 |
-
focus_strokes=_i(strokes_per_quadrant, 100),
|
| 262 |
-
focus_warmup=_f(quadrant_warmup_time, 0.5),
|
| 263 |
-
focus_max_bits=_i(quadrant_max_bits, 8),
|
| 264 |
-
focus_padding=_i(quadrant_padding, 4),
|
| 265 |
-
focus_criteria=quadrant_selection_criteria,
|
| 266 |
-
channel_cycle=channel_cycle,
|
| 267 |
-
channel_cycle_strokes=_i(strokes_per_channel_cycle, 100),
|
| 268 |
-
channel_cycle_warmup=_f(channel_cycle_warmup_time, 0.9),
|
| 269 |
-
channel_cycle_criteria=cycle_selection_criteria,
|
| 270 |
-
color_space=color_space,
|
| 271 |
-
downsample_rate=_f(downsample_rate, -1),
|
| 272 |
-
downsample_initialize=_truthy(downsample_initialize),
|
| 273 |
-
downsample_initialize_rate=_f(downsample_initialize_rate, 16),
|
| 274 |
-
downsample_initialize_bits=_i(downsample_initialize_bits, 8),
|
| 275 |
-
resample=RESAMPLE.get(downsample_alg, "bicubic"),
|
| 276 |
-
)
|
| 277 |
|
| 278 |
try:
|
| 279 |
-
result =
|
| 280 |
except Exception as exc:
|
| 281 |
return JSONResponse({"error": f"Compression failed: {exc}"}, status_code=400)
|
| 282 |
reconstructed = result.image.convert("RGB")
|
| 283 |
|
| 284 |
-
# Full-resolution MSE/CQ: original vs the reconstruction (already at original size).
|
| 285 |
a = np.asarray(img, dtype=np.float32)
|
| 286 |
b = np.asarray(reconstructed.resize(img.size), dtype=np.float32)
|
| 287 |
-
|
| 288 |
mse = mse_metric(a, b)
|
| 289 |
cq = composite_quality(a, b)
|
| 290 |
|
|
@@ -305,7 +272,7 @@ async def compress(
|
|
| 305 |
"mse": round(mse, 2),
|
| 306 |
"composite_quality": round(cq, 2),
|
| 307 |
"time_seconds": round(result.encode_seconds, 2),
|
| 308 |
-
"params": {
|
| 309 |
})
|
| 310 |
|
| 311 |
|
|
@@ -332,9 +299,9 @@ async def decode(file: UploadFile = File(...)):
|
|
| 332 |
raw = await file.read()
|
| 333 |
|
| 334 |
try:
|
| 335 |
-
|
| 336 |
-
img =
|
| 337 |
-
elapsed =
|
| 338 |
except Exception as exc:
|
| 339 |
return JSONResponse({"error": f"Decode failed: {exc}"}, status_code=400)
|
| 340 |
|
|
@@ -391,11 +358,11 @@ def _guess_quality(fmt, target_bpp):
|
|
| 391 |
return qs[-1]
|
| 392 |
|
| 393 |
|
| 394 |
-
def _match_codec_gen(img, fmt, target_bpp, pixels):
|
| 395 |
"""Generator yielding {'q','bpp'} per attempt, then a final {'best': {...}}.
|
| 396 |
|
| 397 |
-
|
| 398 |
-
|
| 399 |
"""
|
| 400 |
qmin, qmax = _q_bounds(fmt)
|
| 401 |
tried = {}
|
|
@@ -412,26 +379,34 @@ def _match_codec_gen(img, fmt, target_bpp, pixels):
|
|
| 412 |
tried[q] = None
|
| 413 |
return None
|
| 414 |
data = buf.getvalue()
|
| 415 |
-
|
| 416 |
-
r = {"q": q, "bpp": bpp, "data": data}
|
| 417 |
tried[q] = r
|
| 418 |
-
if best["ref"] is None or abs(bpp - target_bpp) < abs(best["ref"]["bpp"] - target_bpp):
|
| 419 |
best["ref"] = r
|
| 420 |
return r
|
| 421 |
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
# If the observed bpp undershoots, the image compresses smaller than the guide -> raise quality.
|
| 428 |
-
adj = target_bpp * (target_bpp / cur["bpp"]) if cur["bpp"] > 0 else target_bpp
|
| 429 |
-
nxt = enc(_guess_quality(fmt, adj))
|
| 430 |
-
n += 1
|
| 431 |
-
if not nxt or nxt["q"] == cur["q"]:
|
| 432 |
break
|
| 433 |
-
yield {"q":
|
| 434 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 435 |
|
| 436 |
yield {"best": best["ref"]}
|
| 437 |
|
|
@@ -729,7 +704,14 @@ async def match_codec(image: UploadFile = File(...), codec: str = Form("jpeg"),
|
|
| 729 |
if not best:
|
| 730 |
return JSONResponse({"error": f"{fmt} encoding unavailable."}, status_code=400)
|
| 731 |
rec = Image.open(io.BytesIO(best["data"])).convert("RGB")
|
| 732 |
-
return {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 733 |
|
| 734 |
|
| 735 |
@app.get("/api/sweeps/artifact")
|
|
|
|
| 8 |
import time
|
| 9 |
from typing import List
|
| 10 |
|
| 11 |
+
from fastapi import FastAPI, File, Form, Request, UploadFile
|
| 12 |
from fastapi.responses import JSONResponse, StreamingResponse
|
| 13 |
from fastapi.staticfiles import StaticFiles
|
| 14 |
from PIL import Image
|
|
|
|
| 21 |
pass
|
| 22 |
|
| 23 |
from PBC2_4 import PBC, PBC2Config, PBC2Result, preload_numba
|
| 24 |
+
from PBC3 import PBC3, PBC3Config
|
| 25 |
|
| 26 |
app = FastAPI(title="PBC Compression Demo")
|
| 27 |
|
|
|
|
| 183 |
def _truthy(v):
|
| 184 |
return str(v).lower() == "true"
|
| 185 |
|
| 186 |
+
PBC3_PRESETS = {
|
| 187 |
+
"speed": PBC3Config.speed,
|
| 188 |
+
"balanced": PBC3Config.balanced,
|
| 189 |
+
"compression": PBC3Config.compression,
|
| 190 |
+
"quality": PBC3Config.quality,
|
| 191 |
+
"high_quality": PBC3Config.high_quality,
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
# Every PBC3Config field the UI can submit, with the caster used to parse its form value.
|
| 195 |
+
PBC3_FIELDS = {
|
| 196 |
+
"patch_count": int, "search_depth": int, "proposal_depth": int, "exact_depth": int,
|
| 197 |
+
"min_patch_size": int, "max_patch_size": int, "min_cell_size": int, "max_cell_size": int,
|
| 198 |
+
"cell_sizes_per_candidate": int, "top_k": int,
|
| 199 |
+
"search_q_start": float, "search_q_end": float, "q_init": float, "q_start": float, "q_end": float,
|
| 200 |
+
"color_space": str, "channel_cycle": str,
|
| 201 |
+
"auto_downsample_init": _truthy, "init_search_depth": int, "downsample_init_cell_size": int,
|
| 202 |
+
"downsample_palette_bitcount": int, "downsample_rate": float, "auto_downsample_max_pixels": int,
|
| 203 |
+
"patch_palette_bitcount": int, "patch_bitcount_mode": str, "palette_mode": str,
|
| 204 |
+
"palette_difference_threshold": int, "palette_difference_threshold_mode": str,
|
| 205 |
+
"explicit_palette_max_bitcount": int, "quality_target_mae": float, "mask_size": int,
|
| 206 |
+
"anchor_block_size": int, "dynamic_patch_bitcount_min": int, "dynamic_patch_bitcount_max": int,
|
| 207 |
+
"positive_bias": _truthy, "use_lzma": _truthy, "random_seed": int,
|
| 208 |
+
"debug_mode": _truthy, "debug_print": _truthy,
|
| 209 |
+
}
|
| 210 |
|
| 211 |
@app.get("/api/multlist")
|
| 212 |
def multlist(bit_count: int = 2, min: int = -10, max: int = 20, mode: str = "Stable_Uniform"):
|
|
|
|
| 214 |
|
| 215 |
|
| 216 |
@app.post("/api/compress")
|
| 217 |
+
async def compress(request: Request):
|
| 218 |
+
form = await request.form()
|
| 219 |
+
upload = form.get("image")
|
| 220 |
+
if upload is None:
|
| 221 |
+
return JSONResponse({"error": "No image provided"}, status_code=400)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
try:
|
| 223 |
+
raw_bytes = await upload.read()
|
| 224 |
img = Image.open(io.BytesIO(raw_bytes)).convert("RGB")
|
| 225 |
except Exception as exc:
|
| 226 |
return JSONResponse({"error": f"Could not read image: {exc}"}, status_code=400)
|
| 227 |
w, h = img.size
|
| 228 |
|
| 229 |
+
mode = form.get("mode", "Auto")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
if mode == "Auto":
|
| 231 |
+
preset = form.get("auto_config", "speed")
|
| 232 |
+
config = PBC3_PRESETS.get(preset, PBC3Config.speed)()
|
| 233 |
+
applied = {"mode": "Auto", "auto_config": preset}
|
| 234 |
+
else:
|
| 235 |
kwargs = {}
|
| 236 |
+
for k, caster in PBC3_FIELDS.items():
|
| 237 |
+
v = form.get(k)
|
| 238 |
+
if v in (None, ""):
|
| 239 |
+
continue
|
| 240 |
+
try:
|
| 241 |
+
kwargs[k] = caster(v)
|
| 242 |
+
except (ValueError, TypeError):
|
| 243 |
+
pass
|
| 244 |
+
config = PBC3Config(**kwargs)
|
| 245 |
+
applied = {"mode": mode, **kwargs}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
|
| 247 |
try:
|
| 248 |
+
result = PBC3.compress(img, config=config)
|
| 249 |
except Exception as exc:
|
| 250 |
return JSONResponse({"error": f"Compression failed: {exc}"}, status_code=400)
|
| 251 |
reconstructed = result.image.convert("RGB")
|
| 252 |
|
|
|
|
| 253 |
a = np.asarray(img, dtype=np.float32)
|
| 254 |
b = np.asarray(reconstructed.resize(img.size), dtype=np.float32)
|
|
|
|
| 255 |
mse = mse_metric(a, b)
|
| 256 |
cq = composite_quality(a, b)
|
| 257 |
|
|
|
|
| 272 |
"mse": round(mse, 2),
|
| 273 |
"composite_quality": round(cq, 2),
|
| 274 |
"time_seconds": round(result.encode_seconds, 2),
|
| 275 |
+
"params": {k: (list(v) if isinstance(v, tuple) else v) for k, v in applied.items()},
|
| 276 |
})
|
| 277 |
|
| 278 |
|
|
|
|
| 299 |
raw = await file.read()
|
| 300 |
|
| 301 |
try:
|
| 302 |
+
dec_res = PBC3.decompress(bytes(raw)).image.convert("RGB")
|
| 303 |
+
img = dec_res.image
|
| 304 |
+
elapsed = dec_res.encode_seconds
|
| 305 |
except Exception as exc:
|
| 306 |
return JSONResponse({"error": f"Decode failed: {exc}"}, status_code=400)
|
| 307 |
|
|
|
|
| 358 |
return qs[-1]
|
| 359 |
|
| 360 |
|
| 361 |
+
def _match_codec_gen(img, fmt, target_bpp, pixels, search_count=8):
|
| 362 |
"""Generator yielding {'q','bpp'} per attempt, then a final {'best': {...}}.
|
| 363 |
|
| 364 |
+
Binary-searches quality toward target_bpp (within 3%), then refines ±3 around the
|
| 365 |
+
best hit. `best['data']` holds the chosen encoded bytes. bpp is in bits-per-pixel.
|
| 366 |
"""
|
| 367 |
qmin, qmax = _q_bounds(fmt)
|
| 368 |
tried = {}
|
|
|
|
| 379 |
tried[q] = None
|
| 380 |
return None
|
| 381 |
data = buf.getvalue()
|
| 382 |
+
r = {"q": q, "bpp": len(data) * 8 / pixels, "data": data}
|
|
|
|
| 383 |
tried[q] = r
|
| 384 |
+
if best["ref"] is None or abs(r["bpp"] - target_bpp) < abs(best["ref"]["bpp"] - target_bpp):
|
| 385 |
best["ref"] = r
|
| 386 |
return r
|
| 387 |
|
| 388 |
+
lo, hi = qmin, qmax
|
| 389 |
+
for _ in range(search_count):
|
| 390 |
+
q = (lo + hi) // 2
|
| 391 |
+
r = enc(q)
|
| 392 |
+
if r is None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 393 |
break
|
| 394 |
+
yield {"q": r["q"], "bpp": r["bpp"]}
|
| 395 |
+
if target_bpp > 0 and abs(r["bpp"] - target_bpp) / target_bpp < 0.03:
|
| 396 |
+
break
|
| 397 |
+
if r["bpp"] > target_bpp:
|
| 398 |
+
hi = q - 1
|
| 399 |
+
else:
|
| 400 |
+
lo = q + 1
|
| 401 |
+
if lo > hi:
|
| 402 |
+
break
|
| 403 |
+
|
| 404 |
+
if best["ref"]:
|
| 405 |
+
center = best["ref"]["q"]
|
| 406 |
+
for q in range(max(qmin, center - 3), min(qmax, center + 3) + 1):
|
| 407 |
+
r = enc(q)
|
| 408 |
+
if r:
|
| 409 |
+
yield {"q": r["q"], "bpp": r["bpp"]}
|
| 410 |
|
| 411 |
yield {"best": best["ref"]}
|
| 412 |
|
|
|
|
| 704 |
if not best:
|
| 705 |
return JSONResponse({"error": f"{fmt} encoding unavailable."}, status_code=400)
|
| 706 |
rec = Image.open(io.BytesIO(best["data"])).convert("RGB")
|
| 707 |
+
return {
|
| 708 |
+
"image": _png_b64(rec),
|
| 709 |
+
"bpp": best["bpp"],
|
| 710 |
+
"q": best["q"],
|
| 711 |
+
"quality": composite_quality(img, rec),
|
| 712 |
+
"mse": mse_metric(img, rec),
|
| 713 |
+
"size_kb": round(len(best["data"]) / 1024, 2),
|
| 714 |
+
}
|
| 715 |
|
| 716 |
|
| 717 |
@app.get("/api/sweeps/artifact")
|
static/app.js
CHANGED
|
@@ -339,47 +339,78 @@ function gotoView(v) {
|
|
| 339 |
}
|
| 340 |
|
| 341 |
/* ============================================================
|
| 342 |
-
PARAMETERS
|
|
|
|
|
|
|
|
|
|
| 343 |
============================================================ */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 344 |
const PARAMS = [
|
| 345 |
-
|
| 346 |
-
{ id: "
|
| 347 |
-
{ id: "
|
| 348 |
-
|
| 349 |
-
{ id: "
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
{ id: "
|
| 354 |
-
{ id: "
|
| 355 |
-
{ id: "
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
{ id: "
|
| 360 |
-
{ id: "
|
| 361 |
-
|
| 362 |
-
{ id: "
|
| 363 |
-
{ id: "
|
| 364 |
-
{ id: "
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
{ id: "
|
| 369 |
-
{ id: "
|
| 370 |
-
{ id: "
|
| 371 |
-
{ id: "
|
| 372 |
-
{ id: "
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
{ id: "
|
| 376 |
-
{ id: "
|
| 377 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 378 |
];
|
| 379 |
|
| 380 |
const paramState = {};
|
| 381 |
PARAMS.forEach(p => paramState[p.id] = p.value);
|
| 382 |
-
paramState.
|
|
|
|
| 383 |
|
| 384 |
let paramMode = "Auto";
|
| 385 |
const paramsBody = document.getElementById("params-body");
|
|
@@ -393,10 +424,19 @@ document.querySelectorAll("#param-mode .seg-btn").forEach(b => b.addEventListene
|
|
| 393 |
|
| 394 |
function renderParams() {
|
| 395 |
if (paramMode === "Auto") {
|
| 396 |
-
paramsBody.innerHTML = `
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 397 |
return;
|
| 398 |
}
|
| 399 |
-
|
| 400 |
const items = PARAMS.filter(p => p.modes.includes(paramMode));
|
| 401 |
let html = `<div class="param-grid">`, group = null;
|
| 402 |
items.forEach(p => {
|
|
@@ -415,84 +455,40 @@ function renderParams() {
|
|
| 415 |
});
|
| 416 |
});
|
| 417 |
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
|
|
|
|
|
|
|
|
|
| 423 |
};
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
}
|
| 427 |
-
|
| 428 |
-
const gen = paramsBody.querySelector("#gen-mult_list");
|
| 429 |
-
if (gen) gen.addEventListener("click", async () => {
|
| 430 |
-
const q = new URLSearchParams({ bit_count: paramState.mult_bit_count, min: paramState.mult_min, max: paramState.mult_max, mode: paramState.multlist_mode });
|
| 431 |
-
try {
|
| 432 |
-
const { list } = await (await fetch("/api/multlist?" + q)).json();
|
| 433 |
-
const s = "[" + list.join(", ") + "]";
|
| 434 |
-
paramState.mult_list = s;
|
| 435 |
-
paramsBody.querySelector('[data-pid="mult_list"]').value = s;
|
| 436 |
-
} catch { toast("Could not generate list"); }
|
| 437 |
});
|
| 438 |
}
|
| 439 |
|
| 440 |
function control(p) {
|
| 441 |
const v = paramState[p.id];
|
|
|
|
| 442 |
if (p.type === "select")
|
| 443 |
-
return `<div class="param"><label>${p.label}</label><select data-pid="${p.id}">${p.options.map(o => `<option ${o == v ? "selected" : ""}>${o}</option>`).join("")}</select></div>`;
|
| 444 |
if (p.type === "check")
|
| 445 |
-
return `<div class="param"><label>${p.label}</label><label class="check-row"><input type="checkbox" data-pid="${p.id}" ${v ? "checked" : ""}> enabled</label></div>`;
|
| 446 |
-
if (p.type === "text")
|
| 447 |
-
return `<div class="param full"><label>${p.label}${p.hint ? ` <i>${p.hint}</i>` : ""}</label>
|
| 448 |
-
<div class="slider-row">
|
| 449 |
-
<input type="text" class="num" data-pid="${p.id}" value="${v}" style="flex:1;text-align:left">
|
| 450 |
-
${p.gen ? `<button type="button" class="gen-btn" id="gen-${p.id}">Generate</button>` : ""}
|
| 451 |
-
</div></div>`;
|
| 452 |
return `<div class="param ${p.full ? "full" : ""}">
|
| 453 |
-
<label>${p.label}${
|
| 454 |
<div class="slider-row">
|
| 455 |
<input type="range" data-pid="${p.id}" min="${p.min}" max="${p.max}" step="${p.step}" value="${v}">
|
| 456 |
<input type="number" class="num" data-pid="${p.id}" min="${p.min}" max="${p.max}" step="${p.step}" value="${v}">
|
| 457 |
-
${p.
|
| 458 |
</div></div>`;
|
| 459 |
}
|
| 460 |
|
| 461 |
-
/* ----
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
size_end: "size_range_end",
|
| 466 |
-
decay_cutoff: "decay_cutoff",
|
| 467 |
-
decay_softness: "decay_softness",
|
| 468 |
-
decay_progress: "decay_progress",
|
| 469 |
-
focus_strokes: "strokes_per_quadrant",
|
| 470 |
-
focus_warmup: "quadrant_warmup_time",
|
| 471 |
-
focus_max_bits: "quadrant_max_bits",
|
| 472 |
-
focus_padding: "quadrant_padding",
|
| 473 |
-
focus_criteria: "quadrant_selection_criteria",
|
| 474 |
-
color_space: "color_space",
|
| 475 |
-
downsample_rate: "downsample_rate",
|
| 476 |
-
start_mode: "start_mode",
|
| 477 |
-
channel_cycle_strokes: "strokes_per_channel_cycle",
|
| 478 |
-
channel_cycle_warmup: "channel_cycle_warmup_time",
|
| 479 |
-
channel_cycle_criteria: "cycle_selection_criteria",
|
| 480 |
-
downsample_initialize_rate: "downsample_initialize_rate",
|
| 481 |
-
downsample_initialize_bits: "downsample_initialize_bits",
|
| 482 |
-
mult_bit_count: "mult_bit_count",
|
| 483 |
-
mult_min: "mult_min",
|
| 484 |
-
mult_max: "mult_max",
|
| 485 |
-
mult_mode: "multlist_mode",
|
| 486 |
-
};
|
| 487 |
-
|
| 488 |
-
const RESAMPLE_CAP = {
|
| 489 |
-
bicubic: "Bicubic",
|
| 490 |
-
lanczos: "Lanczos",
|
| 491 |
-
bilinear: "Bilinear",
|
| 492 |
-
nearest: "Nearest",
|
| 493 |
-
box: "Box",
|
| 494 |
-
};
|
| 495 |
-
|
| 496 |
function parseOptuna(text) {
|
| 497 |
const out = {};
|
| 498 |
text.trim().split(/\r?\n/).forEach(line => {
|
|
@@ -501,173 +497,17 @@ function parseOptuna(text) {
|
|
| 501 |
});
|
| 502 |
return out;
|
| 503 |
}
|
| 504 |
-
|
| 505 |
-
// opts.keepMode: apply values to paramState without switching the mode, re-rendering
|
| 506 |
-
// the form, or toasting (used by the live suggestor as the user drags the sliders).
|
| 507 |
-
async function applyOptuna(text, opts = {}) {
|
| 508 |
-
const keepMode = !!opts.keepMode;
|
| 509 |
const p = parseOptuna(text);
|
| 510 |
-
if (!Object.keys(p).length) {
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
for (const [k, v] of Object.entries(p)) if (OPTUNA_MAP[k]) paramState[OPTUNA_MAP[k]] = v;
|
| 518 |
-
if (p.resample) paramState.downsample_alg = RESAMPLE_CAP[p.resample.toLowerCase()] || "Bicubic";
|
| 519 |
-
if (p.channel_cycle) paramState.channel_cycle = p.channel_cycle === "Default" ? "123" : p.channel_cycle;
|
| 520 |
-
if (p.decay_cutoff !== undefined) paramState.decay_mode = "Manual";
|
| 521 |
-
if (p.downsample_initialize !== undefined) paramState.downsample_initialize = p.downsample_initialize === "True";
|
| 522 |
-
|
| 523 |
-
if (p.mult_mode) {
|
| 524 |
-
const mode = p.mult_mode === "PBC_Default" ? "PBC Default" : p.mult_mode;
|
| 525 |
-
paramState.multlist_mode = mode;
|
| 526 |
-
|
| 527 |
-
if (mode === "PBC Default") {
|
| 528 |
-
paramState.mult_list = "[-10, 0, 5, 20]";
|
| 529 |
-
} else if (p.mult_bit_count) {
|
| 530 |
-
try {
|
| 531 |
-
const q = new URLSearchParams({
|
| 532 |
-
bit_count: p.mult_bit_count,
|
| 533 |
-
min: p.mult_min,
|
| 534 |
-
max: p.mult_max,
|
| 535 |
-
mode: p.mult_mode || "Stable_Uniform",
|
| 536 |
-
});
|
| 537 |
-
const { list } = await (await fetch("/api/multlist?" + q)).json();
|
| 538 |
-
paramState.mult_list = "[" + list.join(", ") + "]";
|
| 539 |
-
} catch {}
|
| 540 |
-
}
|
| 541 |
-
}
|
| 542 |
-
if (!keepMode) {
|
| 543 |
-
renderParams();
|
| 544 |
-
toast("Loaded Optuna config");
|
| 545 |
-
}
|
| 546 |
-
}
|
| 547 |
-
|
| 548 |
-
/* ============================================================
|
| 549 |
-
SEMI-AUTO SUGGESTOR (interpolation over a tuning sweep)
|
| 550 |
-
Appears as a "Suggest" parameter mode only when the backend
|
| 551 |
-
recognizes a tuning .db. Three priority sliders pick an
|
| 552 |
-
operating point interpolated along the study's stroke-count
|
| 553 |
-
response curves; the result is loaded as a Manual config.
|
| 554 |
-
============================================================ */
|
| 555 |
-
let sweepDb = null; // { db_path, studies:[...] } once a .db is recognized
|
| 556 |
-
const sweepTrialCache = {}; // study_name -> trials[]
|
| 557 |
-
|
| 558 |
-
async function initSuggestor() {
|
| 559 |
-
try {
|
| 560 |
-
const d = await (await fetch("/api/sweeps/studies?db_path=")).json();
|
| 561 |
-
if (d && Array.isArray(d.studies) && d.studies.length) { sweepDb = d; addSuggestMode(); }
|
| 562 |
-
} catch {}
|
| 563 |
-
}
|
| 564 |
-
|
| 565 |
-
function addSuggestMode() {
|
| 566 |
-
const bar = document.getElementById("param-mode");
|
| 567 |
-
if (!bar || bar.querySelector('[data-mode="Suggest"]')) return;
|
| 568 |
-
const btn = document.createElement("button");
|
| 569 |
-
btn.className = "seg-btn";
|
| 570 |
-
btn.dataset.mode = "Suggest";
|
| 571 |
-
btn.textContent = "Suggest";
|
| 572 |
-
btn.style.marginLeft = "auto";
|
| 573 |
-
btn.addEventListener("click", () => {
|
| 574 |
-
document.querySelectorAll("#param-mode .seg-btn").forEach(x => x.classList.remove("active"));
|
| 575 |
-
btn.classList.add("active");
|
| 576 |
-
// Compress submits Manual params; the suggestor just fills them in.
|
| 577 |
-
paramMode = "Suggest";
|
| 578 |
-
renderSuggest();
|
| 579 |
-
});
|
| 580 |
-
bar.appendChild(btn);
|
| 581 |
-
}
|
| 582 |
-
|
| 583 |
-
function sgSlider(id, label, val) {
|
| 584 |
-
return `<div class="param full"><label>${label} <i id="${id}-v">${val}</i></label>
|
| 585 |
-
<div class="slider-row"><input type="range" id="${id}" min="0" max="100" value="${val}"></div></div>`;
|
| 586 |
-
}
|
| 587 |
-
|
| 588 |
-
function renderSuggest() {
|
| 589 |
-
const studies = sweepDb ? sweepDb.studies : [];
|
| 590 |
-
paramsBody.innerHTML = `
|
| 591 |
-
<p class="card-date">Interpolation-based suggestion from a tuning sweep. Set your priorities and the closest stroke-count operating point on the study's Pareto response curves is interpolated, then loaded as a Manual config you can still tweak before compressing.</p>
|
| 592 |
-
<div class="param-grid">
|
| 593 |
-
<div class="param-group-title">Sweep study</div>
|
| 594 |
-
<div class="param full"><label>Study</label>
|
| 595 |
-
<select id="sg-study">${studies.map(s => `<option value="${s.study_name}">${s.study_name} (${s.n_trials})</option>`).join("")}</select></div>
|
| 596 |
-
<div class="param-group-title">Priorities</div>
|
| 597 |
-
${sgSlider("sg-speed", "Speed priority", 33)}
|
| 598 |
-
${sgSlider("sg-comp", "Compression priority", 33)}
|
| 599 |
-
${sgSlider("sg-qual", "Quality priority", 34)}
|
| 600 |
-
</div>
|
| 601 |
-
<div id="sg-out" class="card-date" style="margin-top:10px"></div>`;
|
| 602 |
-
|
| 603 |
-
document.getElementById("sg-study").addEventListener("change", recomputeSuggestion);
|
| 604 |
-
["sg-speed", "sg-comp", "sg-qual"].forEach(id => {
|
| 605 |
-
const el = document.getElementById(id);
|
| 606 |
-
el.addEventListener("input", () => { document.getElementById(id + "-v").textContent = el.value; recomputeSuggestion(); });
|
| 607 |
-
});
|
| 608 |
-
recomputeSuggestion();
|
| 609 |
-
}
|
| 610 |
-
|
| 611 |
-
async function loadSweepTrials(name) {
|
| 612 |
-
if (sweepTrialCache[name]) return sweepTrialCache[name];
|
| 613 |
-
try {
|
| 614 |
-
const d = await (await fetch(`/api/sweeps/study?db_path=${encodeURIComponent(sweepDb.db_path)}&study_name=${encodeURIComponent(name)}`)).json();
|
| 615 |
-
if (d.error) return null;
|
| 616 |
-
sweepTrialCache[name] = d.trials || [];
|
| 617 |
-
return sweepTrialCache[name];
|
| 618 |
-
} catch { return null; }
|
| 619 |
-
}
|
| 620 |
-
|
| 621 |
-
function computeSuggestion(trials, ws, wc, wq) {
|
| 622 |
-
let set = trials.filter(t => t.pareto);
|
| 623 |
-
if (!set.length) set = trials;
|
| 624 |
-
const pts = set.map(t => ({ s: +t.params.stroke_count, q: t.quality, bpp: t.bpp, sp: t.speed, t }))
|
| 625 |
-
.filter(p => !isNaN(p.s)).sort((a, b) => a.s - b.s);
|
| 626 |
-
if (!pts.length) return null;
|
| 627 |
-
|
| 628 |
-
const sum = (ws + wc + wq) || 1; wq /= sum;
|
| 629 |
-
const xs = pts.map(p => p.s);
|
| 630 |
-
const interpKey = (s, key) => {
|
| 631 |
-
if (s <= xs[0]) return pts[0][key];
|
| 632 |
-
if (s >= xs[xs.length - 1]) return pts[xs.length - 1][key];
|
| 633 |
-
for (let i = 1; i < xs.length; i++)
|
| 634 |
-
if (s <= xs[i]) { const f = (s - xs[i - 1]) / ((xs[i] - xs[i - 1]) || 1); return pts[i - 1][key] + f * (pts[i][key] - pts[i - 1][key]); }
|
| 635 |
-
return pts[pts.length - 1][key];
|
| 636 |
-
};
|
| 637 |
-
|
| 638 |
-
// Quality favours more strokes; speed and compression both favour fewer. After
|
| 639 |
-
// normalisation the quality share sets the position along the stroke axis, so moving
|
| 640 |
-
// any slider slides the operating point gradually between trials instead of snapping
|
| 641 |
-
// to an endpoint (which a weighted-sum maximum does on near-linear Pareto fronts).
|
| 642 |
-
const lo = xs[0], hi = xs[xs.length - 1];
|
| 643 |
-
const s = lo + (hi - lo) * Math.max(0, Math.min(1, wq));
|
| 644 |
-
const nearest = pts.reduce((a, b) => Math.abs(b.s - s) < Math.abs(a.s - s) ? b : a);
|
| 645 |
-
return { stroke: Math.round(s), trial: nearest.t, q: interpKey(s, "q"), bpp: interpKey(s, "bpp"), sp: interpKey(s, "sp") };
|
| 646 |
-
}
|
| 647 |
-
|
| 648 |
-
function suggestionToOptuna(t, stroke) {
|
| 649 |
-
const lines = Object.entries(t.params).map(([k, v]) =>
|
| 650 |
-
k === "stroke_count" ? `stroke_count ${stroke}` : `${k} ${v === true ? "True" : v === false ? "False" : v}`);
|
| 651 |
-
return lines.join("\n");
|
| 652 |
-
}
|
| 653 |
-
|
| 654 |
-
async function recomputeSuggestion() {
|
| 655 |
-
const out = document.getElementById("sg-out");
|
| 656 |
-
if (!out) return;
|
| 657 |
-
const name = document.getElementById("sg-study").value;
|
| 658 |
-
const trials = await loadSweepTrials(name);
|
| 659 |
-
if (!trials) { out.textContent = "Could not load study."; return; }
|
| 660 |
-
const ws = +document.getElementById("sg-speed").value,
|
| 661 |
-
wc = +document.getElementById("sg-comp").value,
|
| 662 |
-
wq = +document.getElementById("sg-qual").value;
|
| 663 |
-
const sug = computeSuggestion(trials, ws, wc, wq);
|
| 664 |
-
if (!sug) { out.textContent = "No usable trials in this study."; return; }
|
| 665 |
-
await applyOptuna(suggestionToOptuna(sug.trial, sug.stroke), { keepMode: true });
|
| 666 |
-
out.innerHTML = `→ suggested <b>stroke_count=${sug.stroke}</b> (interpolated; companion params from nearest trial #${sug.trial.number}) · predicted quality ${sug.q.toFixed(4)} · bpp ${sug.bpp.toFixed(4)} · ${sug.sp.toFixed(3)} sec/MP. Loaded as Manual params — press Compress.`;
|
| 667 |
}
|
| 668 |
|
| 669 |
renderParams();
|
| 670 |
-
initSuggestor();
|
| 671 |
|
| 672 |
/* ============================================================
|
| 673 |
IMAGE INPUT (compress)
|
|
@@ -725,17 +565,17 @@ compressBtn.addEventListener("click", async () => {
|
|
| 725 |
compressBtn.classList.add("busy");
|
| 726 |
orbStatus.textContent = "compressing…";
|
| 727 |
|
| 728 |
-
// The suggestor fills the Manual parameters, so it submits as a Manual run.
|
| 729 |
-
const fillMode = paramMode === "Suggest" ? "Manual" : paramMode;
|
| 730 |
const fd = new FormData();
|
| 731 |
fd.append("image", currentFile);
|
| 732 |
-
fd.append("mode",
|
| 733 |
-
if (
|
| 734 |
-
|
|
|
|
|
|
|
|
|
|
| 735 |
const v = paramState[p.id];
|
| 736 |
fd.append(p.id, p.type === "check" ? (v ? "true" : "false") : v);
|
| 737 |
});
|
| 738 |
-
fd.append("downsample_initialize", paramState.downsample_initialize ? "true" : "false");
|
| 739 |
}
|
| 740 |
|
| 741 |
try {
|
|
@@ -847,11 +687,14 @@ function card(d) {
|
|
| 847 |
const badge = d.decoded
|
| 848 |
? `<span class="fs-btn" style="left:8px;right:auto;width:auto;padding:0 8px;opacity:1;color:var(--red);font-size:9px;letter-spacing:.12em;text-transform:uppercase;cursor:default">decoded</span>`
|
| 849 |
: "";
|
| 850 |
-
|
|
|
|
|
|
|
|
|
|
| 851 |
? `<div class="foot-row" style="align-items:center;gap:8px">
|
| 852 |
<span class="k">Compare to:</span>
|
| 853 |
-
<button class="chip" data-act="cmp-jpeg" style="color:#7d8cff;border-color:#7d8cff">JPEG</button>
|
| 854 |
-
<button class="chip" data-act="cmp-avif" style="color:#34d39a;border-color:#34d39a">AVIF</button>
|
| 855 |
</div>`
|
| 856 |
: "";
|
| 857 |
el.innerHTML = `
|
|
@@ -888,12 +731,13 @@ function card(d) {
|
|
| 888 |
return el;
|
| 889 |
}
|
| 890 |
|
| 891 |
-
// Encode the registry entry's original image with JPEG/AVIF at
|
| 892 |
// it as a "Hold for …" button in the fullscreen viewer (same UX as hold-for-original).
|
|
|
|
|
|
|
| 893 |
async function compareCodec(d, codec, btn) {
|
| 894 |
-
if (!d.original_image) return;
|
| 895 |
const label = codec.toUpperCase();
|
| 896 |
-
const old = btn.textContent;
|
| 897 |
btn.disabled = true;
|
| 898 |
btn.textContent = label + "…";
|
| 899 |
try {
|
|
@@ -908,18 +752,22 @@ async function compareCodec(d, codec, btn) {
|
|
| 908 |
d[codec + "_image"] = r.image;
|
| 909 |
d[codec + "_bpp"] = r.bpp;
|
| 910 |
d[codec + "_q"] = r.q;
|
| 911 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 912 |
if (!viewer.hidden && registry[viewIndex] === d) renderViewer();
|
| 913 |
} catch (e) {
|
| 914 |
toast(`${label} comparison failed`);
|
| 915 |
console.error(e);
|
|
|
|
|
|
|
| 916 |
}
|
| 917 |
-
btn.disabled = false;
|
| 918 |
-
btn.textContent = old;
|
| 919 |
}
|
| 920 |
|
| 921 |
function escapeParams(p) {
|
| 922 |
-
if (!p || p.mode === "Auto") return "Auto";
|
| 923 |
return Object.entries(p).filter(([k]) => k !== "mode")
|
| 924 |
.map(([k, v]) => `${k}=${Array.isArray(v) ? v.join(",") : v}`).join(" · ") || p.mode;
|
| 925 |
}
|
|
@@ -954,6 +802,27 @@ function step(dir) {
|
|
| 954 |
if (i >= 0 && i < registry.length) { viewIndex = i; renderViewer(); }
|
| 955 |
}
|
| 956 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 957 |
function renderViewer() {
|
| 958 |
const d = registry[viewIndex];
|
| 959 |
if (!d) return;
|
|
@@ -962,37 +831,36 @@ function renderViewer() {
|
|
| 962 |
holdJpeg.hidden = !d.jpeg_image;
|
| 963 |
holdAvif.hidden = !d.avif_image;
|
| 964 |
[holdBtn, holdJpeg, holdAvif].forEach(b => b.classList.remove("holding"));
|
| 965 |
-
|
| 966 |
-
? `Decoded in ${d.time_seconds}s | ${d.compression_rate}× compression`
|
| 967 |
-
: `Compressed ${d.compression_rate}× in ${d.time_seconds}s | MSE: ${d.mse} | Composite Quality: ${d.composite_quality}`;
|
| 968 |
-
if (d.jpeg_image) cap += ` | JPEG q${d.jpeg_q} (${bppToRate(d.jpeg_bpp).toFixed(1)}×)`;
|
| 969 |
-
if (d.avif_image) cap += ` | AVIF q${d.avif_q} (${bppToRate(d.avif_bpp).toFixed(1)}×)`;
|
| 970 |
-
document.getElementById("viewer-caption").textContent = cap;
|
| 971 |
prevBtn.disabled = viewIndex <= 0;
|
| 972 |
nextBtn.disabled = viewIndex >= registry.length - 1;
|
| 973 |
}
|
| 974 |
|
| 975 |
-
function wireHold(btn, getSrc) {
|
|
|
|
| 976 |
const release = () => {
|
| 977 |
const d = registry[viewIndex];
|
| 978 |
const img = viewerStage.querySelector("img");
|
| 979 |
if (d && img) img.src = d.reconstructed_image;
|
|
|
|
| 980 |
btn.classList.remove("holding");
|
| 981 |
};
|
| 982 |
btn.addEventListener("pointerdown", e => {
|
| 983 |
e.preventDefault();
|
|
|
|
| 984 |
const img = viewerStage.querySelector("img");
|
| 985 |
const src = getSrc();
|
| 986 |
if (!img || !src) return;
|
| 987 |
img.src = src;
|
|
|
|
| 988 |
btn.classList.add("holding");
|
| 989 |
});
|
| 990 |
btn.addEventListener("pointerup", release);
|
| 991 |
btn.addEventListener("pointerleave", () => btn.classList.contains("holding") && release());
|
| 992 |
}
|
| 993 |
wireHold(holdBtn, () => { const d = registry[viewIndex]; return d && d.original_image; });
|
| 994 |
-
wireHold(holdJpeg, () => { const d = registry[viewIndex]; return d && d.jpeg_image; });
|
| 995 |
-
wireHold(holdAvif, () => { const d = registry[viewIndex]; return d && d.avif_image; });
|
| 996 |
|
| 997 |
/* ============================================================
|
| 998 |
DOWNLOAD
|
|
@@ -1032,4 +900,4 @@ function toast(msg) {
|
|
| 1032 |
/* ============================================================
|
| 1033 |
BOOT
|
| 1034 |
============================================================ */
|
| 1035 |
-
initRoster();
|
|
|
|
| 339 |
}
|
| 340 |
|
| 341 |
/* ============================================================
|
| 342 |
+
PARAMETERS (PBC3.0)
|
| 343 |
+
Auto → pick one of the PBC3Config presets.
|
| 344 |
+
Semi → the high-impact parameters as sliders/inputs.
|
| 345 |
+
Manual → every PBC3Config field.
|
| 346 |
============================================================ */
|
| 347 |
+
const AUTO_CONFIGS = [
|
| 348 |
+
{ id: "speed", label: "Speed" },
|
| 349 |
+
{ id: "balanced", label: "Balanced" },
|
| 350 |
+
{ id: "compression", label: "Compression" },
|
| 351 |
+
{ id: "quality", label: "Quality" },
|
| 352 |
+
{ id: "high_quality", label: "High Quality" },
|
| 353 |
+
];
|
| 354 |
+
|
| 355 |
const PARAMS = [
|
| 356 |
+
// ---- Search (Semi + Manual) ----
|
| 357 |
+
{ id: "patch_count", label: "Patch count", hint: "more patches = higher quality, larger files, slower", group: "Search", modes: ["Semi", "Manual"], type: "slider", min: 0, max: 500, step: 1, value: 20, full: true },
|
| 358 |
+
{ id: "search_depth", label: "Search depth", group: "Search", modes: ["Semi", "Manual"], type: "slider", min: 0, max: 2000, step: 10, value: 200 },
|
| 359 |
+
{ id: "proposal_depth", label: "Proposal depth", group: "Search", modes: ["Semi", "Manual"], type: "slider", min: 0, max: 500, step: 5, value: 50 },
|
| 360 |
+
{ id: "exact_depth", label: "Exact depth", group: "Search", modes: ["Semi", "Manual"], type: "slider", min: 0, max: 200, step: 1, value: 10 },
|
| 361 |
+
|
| 362 |
+
// ---- Quality schedule (Semi + Manual) ----
|
| 363 |
+
{ id: "search_q_start", label: "Search q start", group: "Quality schedule", modes: ["Semi", "Manual"], type: "slider", min: 0, max: 1, step: 0.01, value: 0.4 },
|
| 364 |
+
{ id: "search_q_end", label: "Search q end", group: "Quality schedule", modes: ["Semi", "Manual"], type: "slider", min: 0, max: 1, step: 0.01, value: 0.1 },
|
| 365 |
+
{ id: "q_init", label: "Q init", group: "Quality schedule", modes: ["Semi", "Manual"], type: "slider", min: 0, max: 1, step: 0.01, value: 0.7 },
|
| 366 |
+
{ id: "q_start", label: "Q start", group: "Quality schedule", modes: ["Semi", "Manual"], type: "slider", min: 0, max: 1, step: 0.01, value: 0.9 },
|
| 367 |
+
{ id: "q_end", label: "Q end", group: "Quality schedule", modes: ["Semi", "Manual"], type: "slider", min: 0, max: 1, step: 0.01, value: 0.9 },
|
| 368 |
+
|
| 369 |
+
// ---- Downsampling (Semi + Manual; Manual adds the init-layer knobs) ----
|
| 370 |
+
{ id: "downsample_rate", label: "Downsample rate", hint: "auto = derive from max pixels", group: "Downsampling", modes: ["Semi", "Manual"], type: "slider", min: 1, max: 16, step: 0.1, value: 2, autoToggle: { value: -1 } },
|
| 371 |
+
{ id: "auto_downsample_max_pixels", label: "Auto downsample max pixels", group: "Downsampling", modes: ["Semi", "Manual"], type: "slider", min: 10000, max: 4000000, step: 10000, value: 250000, full: true },
|
| 372 |
+
{ id: "auto_downsample_init", label: "Auto downsample init", group: "Downsampling", modes: ["Manual"], type: "check", value: true },
|
| 373 |
+
{ id: "init_search_depth", label: "Init search depth", group: "Downsampling", modes: ["Manual"], type: "slider", min: 0, max: 50, step: 1, value: 7 },
|
| 374 |
+
{ id: "downsample_init_cell_size", label: "Init cell size", group: "Downsampling", modes: ["Manual"], type: "slider", min: 1, max: 64, step: 1, value: 12 },
|
| 375 |
+
{ id: "downsample_palette_bitcount", label: "Init palette bitcount", group: "Downsampling", modes: ["Manual"], type: "slider", min: 1, max: 9, step: 1, value: 6 },
|
| 376 |
+
|
| 377 |
+
// ---- Patch & cell sizing (Manual) ----
|
| 378 |
+
{ id: "min_patch_size", label: "Min patch size", group: "Patch & cell sizing", modes: ["Manual"], type: "slider", min: 1, max: 1024, step: 1, value: 16 },
|
| 379 |
+
{ id: "max_patch_size", label: "Max patch size", group: "Patch & cell sizing", modes: ["Manual"], type: "slider", min: 1, max: 2000, step: 1, value: 400 },
|
| 380 |
+
{ id: "min_cell_size", label: "Min cell size", group: "Patch & cell sizing", modes: ["Manual"], type: "slider", min: 1, max: 64, step: 1, value: 1 },
|
| 381 |
+
{ id: "max_cell_size", label: "Max cell size", group: "Patch & cell sizing", modes: ["Manual"], type: "slider", min: 1, max: 256, step: 1, value: 64 },
|
| 382 |
+
{ id: "cell_sizes_per_candidate", label: "Cell sizes / candidate", group: "Patch & cell sizing", modes: ["Manual"], type: "slider", min: 1, max: 16, step: 1, value: 3 },
|
| 383 |
+
{ id: "top_k", label: "Top-k", group: "Patch & cell sizing", modes: ["Manual"], type: "slider", min: 1, max: 200, step: 1, value: 20 },
|
| 384 |
+
|
| 385 |
+
// ---- Color (Manual) ----
|
| 386 |
+
{ id: "color_space", label: "Color space", group: "Color", modes: ["Manual"], type: "select", options: ["RGB", "YCbCr"], value: "YCbCr" },
|
| 387 |
+
{ id: "channel_cycle", label: "Channel cycle", group: "Color", modes: ["Manual"], type: "select", options: ["Off", "Sum", "Max"], value: "Sum" },
|
| 388 |
+
|
| 389 |
+
// ---- Palette & bit allocation (Manual) ----
|
| 390 |
+
{ id: "patch_palette_bitcount", label: "Patch palette bitcount", group: "Palette & bits", modes: ["Manual"], type: "slider", min: 1, max: 9, step: 1, value: 2 },
|
| 391 |
+
{ id: "patch_bitcount_mode", label: "Patch bitcount mode", group: "Palette & bits", modes: ["Manual"], type: "select", options: ["constant", "dynamic"], value: "constant" },
|
| 392 |
+
{ id: "dynamic_patch_bitcount_min", label: "Dynamic bitcount min", group: "Palette & bits", modes: ["Manual"], type: "slider", min: 1, max: 9, step: 1, value: 2 },
|
| 393 |
+
{ id: "dynamic_patch_bitcount_max", label: "Dynamic bitcount max", group: "Palette & bits", modes: ["Manual"], type: "slider", min: 1, max: 9, step: 1, value: 3 },
|
| 394 |
+
{ id: "palette_mode", label: "Palette mode", group: "Palette & bits", modes: ["Manual"], type: "select", options: ["generated", "explicit", "auto"], value: "generated" },
|
| 395 |
+
{ id: "explicit_palette_max_bitcount", label: "Explicit palette max bitcount", group: "Palette & bits", modes: ["Manual"], type: "slider", min: 1, max: 9, step: 1, value: 3 },
|
| 396 |
+
{ id: "palette_difference_threshold", label: "Palette diff threshold", group: "Palette & bits", modes: ["Manual"], type: "slider", min: 0, max: 255, step: 1, value: 0 },
|
| 397 |
+
{ id: "palette_difference_threshold_mode", label: "Palette diff threshold mode", group: "Palette & bits", modes: ["Manual"], type: "select", options: ["constant", "linear"], value: "constant" },
|
| 398 |
+
{ id: "mask_size", label: "Mask size", group: "Palette & bits", modes: ["Manual"], type: "slider", min: 1, max: 1023, step: 1, value: 4 },
|
| 399 |
+
{ id: "anchor_block_size", label: "Anchor block size", group: "Palette & bits", modes: ["Manual"], type: "slider", min: 1, max: 64, step: 1, value: 8 },
|
| 400 |
+
{ id: "positive_bias", label: "Positive bias", group: "Palette & bits", modes: ["Manual"], type: "check", value: true },
|
| 401 |
+
|
| 402 |
+
// ---- Advanced (Manual) ----
|
| 403 |
+
{ id: "quality_target_mae", label: "Quality target MAE", hint: "0 = off (stop early once MAE drops below this)", group: "Advanced", modes: ["Manual"], type: "slider", min: 0, max: 50, step: 0.1, value: 0, full: true },
|
| 404 |
+
{ id: "use_lzma", label: "Use LZMA", group: "Advanced", modes: ["Manual"], type: "check", value: true },
|
| 405 |
+
{ id: "random_seed", label: "Random seed", group: "Advanced", modes: ["Manual"], type: "slider", min: 0, max: 1000000, step: 1, value: 2003 },
|
| 406 |
+
{ id: "debug_mode", label: "Debug mode", group: "Advanced", modes: ["Manual"], type: "check", value: false },
|
| 407 |
+
{ id: "debug_print", label: "Debug print", group: "Advanced", modes: ["Manual"], type: "check", value: false },
|
| 408 |
];
|
| 409 |
|
| 410 |
const paramState = {};
|
| 411 |
PARAMS.forEach(p => paramState[p.id] = p.value);
|
| 412 |
+
paramState.auto_config = "speed";
|
| 413 |
+
PARAMS.filter(p => p.autoToggle).forEach(p => paramState[p.id + "__auto"] = true);
|
| 414 |
|
| 415 |
let paramMode = "Auto";
|
| 416 |
const paramsBody = document.getElementById("params-body");
|
|
|
|
| 424 |
|
| 425 |
function renderParams() {
|
| 426 |
if (paramMode === "Auto") {
|
| 427 |
+
paramsBody.innerHTML = `
|
| 428 |
+
<p class="card-date">Auto mode — pick a preset configuration; every other parameter is derived from the image.</p>
|
| 429 |
+
<div class="param-grid">
|
| 430 |
+
<div class="param-group-title">Preset</div>
|
| 431 |
+
<div class="param full"><label>Configuration</label>
|
| 432 |
+
<select data-pid="auto_config">
|
| 433 |
+
${AUTO_CONFIGS.map(c => `<option value="${c.id}" ${c.id === paramState.auto_config ? "selected" : ""}>${c.label}</option>`).join("")}
|
| 434 |
+
</select></div>
|
| 435 |
+
</div>`;
|
| 436 |
+
paramsBody.querySelector('[data-pid="auto_config"]').addEventListener("change", e => { paramState.auto_config = e.target.value; });
|
| 437 |
return;
|
| 438 |
}
|
| 439 |
+
|
| 440 |
const items = PARAMS.filter(p => p.modes.includes(paramMode));
|
| 441 |
let html = `<div class="param-grid">`, group = null;
|
| 442 |
items.forEach(p => {
|
|
|
|
| 455 |
});
|
| 456 |
});
|
| 457 |
|
| 458 |
+
// "auto" inline toggles: when checked the submitted value is the param's auto sentinel
|
| 459 |
+
// (e.g. downsample_rate = -1) and the slider/number are disabled.
|
| 460 |
+
paramsBody.querySelectorAll("[data-auto]").forEach(cb => {
|
| 461 |
+
const id = cb.dataset.auto;
|
| 462 |
+
cb.checked = !!paramState[id + "__auto"];
|
| 463 |
+
const sync = () => {
|
| 464 |
+
paramState[id + "__auto"] = cb.checked;
|
| 465 |
+
paramsBody.querySelectorAll(`[data-pid="${id}"]`).forEach(el => el.disabled = cb.checked);
|
| 466 |
};
|
| 467 |
+
cb.addEventListener("change", sync);
|
| 468 |
+
sync();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 469 |
});
|
| 470 |
}
|
| 471 |
|
| 472 |
function control(p) {
|
| 473 |
const v = paramState[p.id];
|
| 474 |
+
const hint = p.hint ? ` <i>${p.hint}</i>` : "";
|
| 475 |
if (p.type === "select")
|
| 476 |
+
return `<div class="param"><label>${p.label}${hint}</label><select data-pid="${p.id}">${p.options.map(o => `<option ${o == v ? "selected" : ""}>${o}</option>`).join("")}</select></div>`;
|
| 477 |
if (p.type === "check")
|
| 478 |
+
return `<div class="param"><label>${p.label}${hint}</label><label class="check-row"><input type="checkbox" data-pid="${p.id}" ${v ? "checked" : ""}> enabled</label></div>`;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 479 |
return `<div class="param ${p.full ? "full" : ""}">
|
| 480 |
+
<label>${p.label}${hint}</label>
|
| 481 |
<div class="slider-row">
|
| 482 |
<input type="range" data-pid="${p.id}" min="${p.min}" max="${p.max}" step="${p.step}" value="${v}">
|
| 483 |
<input type="number" class="num" data-pid="${p.id}" min="${p.min}" max="${p.max}" step="${p.step}" value="${v}">
|
| 484 |
+
${p.autoToggle ? `<label class="check-inline"><input type="checkbox" data-auto="${p.id}"> auto</label>` : ""}
|
| 485 |
</div></div>`;
|
| 486 |
}
|
| 487 |
|
| 488 |
+
/* ---- Sweep Analyzer "Load into Demo" hook -------------------------------
|
| 489 |
+
The analyzer still emits PBC2.4-style param dumps; PBC3 sweep integration
|
| 490 |
+
is pending. For now this parses the dump and applies any key that matches a
|
| 491 |
+
current parameter id, switching to Manual without erroring on the rest. */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 492 |
function parseOptuna(text) {
|
| 493 |
const out = {};
|
| 494 |
text.trim().split(/\r?\n/).forEach(line => {
|
|
|
|
| 497 |
});
|
| 498 |
return out;
|
| 499 |
}
|
| 500 |
+
async function applyOptuna(text) {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 501 |
const p = parseOptuna(text);
|
| 502 |
+
if (!Object.keys(p).length) { toast("Couldn't parse anything"); return; }
|
| 503 |
+
for (const [k, v] of Object.entries(p)) if (k in paramState) paramState[k] = v;
|
| 504 |
+
paramMode = "Manual";
|
| 505 |
+
document.querySelectorAll("#param-mode .seg-btn").forEach(b => b.classList.toggle("active", b.dataset.mode === "Manual"));
|
| 506 |
+
renderParams();
|
| 507 |
+
toast("Loaded config");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 508 |
}
|
| 509 |
|
| 510 |
renderParams();
|
|
|
|
| 511 |
|
| 512 |
/* ============================================================
|
| 513 |
IMAGE INPUT (compress)
|
|
|
|
| 565 |
compressBtn.classList.add("busy");
|
| 566 |
orbStatus.textContent = "compressing…";
|
| 567 |
|
|
|
|
|
|
|
| 568 |
const fd = new FormData();
|
| 569 |
fd.append("image", currentFile);
|
| 570 |
+
fd.append("mode", paramMode);
|
| 571 |
+
if (paramMode === "Auto") {
|
| 572 |
+
fd.append("auto_config", paramState.auto_config);
|
| 573 |
+
} else {
|
| 574 |
+
PARAMS.filter(p => p.modes.includes(paramMode)).forEach(p => {
|
| 575 |
+
if (p.autoToggle && paramState[p.id + "__auto"]) { fd.append(p.id, p.autoToggle.value); return; }
|
| 576 |
const v = paramState[p.id];
|
| 577 |
fd.append(p.id, p.type === "check" ? (v ? "true" : "false") : v);
|
| 578 |
});
|
|
|
|
| 579 |
}
|
| 580 |
|
| 581 |
try {
|
|
|
|
| 687 |
const badge = d.decoded
|
| 688 |
? `<span class="fs-btn" style="left:8px;right:auto;width:auto;padding:0 8px;opacity:1;color:var(--red);font-size:9px;letter-spacing:.12em;text-transform:uppercase;cursor:default">decoded</span>`
|
| 689 |
: "";
|
| 690 |
+
|
| 691 |
+
// Only show codecs that haven't been generated yet; drop the whole row once both exist.
|
| 692 |
+
const haveJ = !!d.jpeg_image, haveA = !!d.avif_image;
|
| 693 |
+
const compareBlock = (!d.decoded && d.original_image && !(haveJ && haveA))
|
| 694 |
? `<div class="foot-row" style="align-items:center;gap:8px">
|
| 695 |
<span class="k">Compare to:</span>
|
| 696 |
+
${haveJ ? "" : `<button class="chip" data-act="cmp-jpeg" style="color:#7d8cff;border-color:#7d8cff">JPEG</button>`}
|
| 697 |
+
${haveA ? "" : `<button class="chip" data-act="cmp-avif" style="color:#34d39a;border-color:#34d39a">AVIF</button>`}
|
| 698 |
</div>`
|
| 699 |
: "";
|
| 700 |
el.innerHTML = `
|
|
|
|
| 731 |
return el;
|
| 732 |
}
|
| 733 |
|
| 734 |
+
// Encode the registry entry's original image with JPEG/AVIF at PBC3's bpp, then expose
|
| 735 |
// it as a "Hold for …" button in the fullscreen viewer (same UX as hold-for-original).
|
| 736 |
+
// Once generated the codec is cached on the entry, so the chip is removed and a repeat
|
| 737 |
+
// click is impossible (renderRegistry rebuilds the card without it).
|
| 738 |
async function compareCodec(d, codec, btn) {
|
| 739 |
+
if (!d.original_image || d[codec + "_image"]) return;
|
| 740 |
const label = codec.toUpperCase();
|
|
|
|
| 741 |
btn.disabled = true;
|
| 742 |
btn.textContent = label + "…";
|
| 743 |
try {
|
|
|
|
| 752 |
d[codec + "_image"] = r.image;
|
| 753 |
d[codec + "_bpp"] = r.bpp;
|
| 754 |
d[codec + "_q"] = r.q;
|
| 755 |
+
d[codec + "_mse"] = r.mse;
|
| 756 |
+
d[codec + "_quality"] = r.quality;
|
| 757 |
+
d[codec + "_size_kb"] = r.size_kb;
|
| 758 |
+
toast(`${label} q${r.q} ready — hold in fullscreen to compare`);
|
| 759 |
+
renderRegistry();
|
| 760 |
if (!viewer.hidden && registry[viewIndex] === d) renderViewer();
|
| 761 |
} catch (e) {
|
| 762 |
toast(`${label} comparison failed`);
|
| 763 |
console.error(e);
|
| 764 |
+
btn.disabled = false;
|
| 765 |
+
btn.textContent = label;
|
| 766 |
}
|
|
|
|
|
|
|
| 767 |
}
|
| 768 |
|
| 769 |
function escapeParams(p) {
|
| 770 |
+
if (!p || p.mode === "Auto") return p && p.auto_config ? `Auto · ${p.auto_config}` : "Auto";
|
| 771 |
return Object.entries(p).filter(([k]) => k !== "mode")
|
| 772 |
.map(([k, v]) => `${k}=${Array.isArray(v) ? v.join(",") : v}`).join(" · ") || p.mode;
|
| 773 |
}
|
|
|
|
| 802 |
if (i >= 0 && i < registry.length) { viewIndex = i; renderViewer(); }
|
| 803 |
}
|
| 804 |
|
| 805 |
+
function viewerCaption(d) {
|
| 806 |
+
if (d.decoded)
|
| 807 |
+
return `Decoded in ${d.time_seconds}s | ${d.compression_rate}× compression`;
|
| 808 |
+
let cap = `Compressed ${d.compression_rate}× in ${d.time_seconds}s | MSE: ${d.mse} | Composite Quality: ${d.composite_quality}`;
|
| 809 |
+
if (d.jpeg_image) cap += ` | JPEG q${d.jpeg_q} (${bppToRate(d.jpeg_bpp).toFixed(1)}×)`;
|
| 810 |
+
if (d.avif_image) cap += ` | AVIF q${d.avif_q} (${bppToRate(d.avif_bpp).toFixed(1)}×)`;
|
| 811 |
+
return cap;
|
| 812 |
+
}
|
| 813 |
+
|
| 814 |
+
// While holding for JPEG/AVIF, describe how that codec did against PBC3 at the same bpp.
|
| 815 |
+
function codecCompareCaption(d, codec) {
|
| 816 |
+
const label = codec.toUpperCase();
|
| 817 |
+
const fmtSize = d[codec + "_size_kb"], fmtMse = d[codec + "_mse"], fmtCq = d[codec + "_quality"], q = d[codec + "_q"];
|
| 818 |
+
const pct = (a, b) => Math.abs((a - b) / (b || 1e-9) * 100).toFixed(1);
|
| 819 |
+
const sizeWord = fmtSize >= d.compressed_kb ? "bigger" : "smaller"; // smaller file = good
|
| 820 |
+
const mseWord = fmtMse <= d.mse ? "better" : "worse"; // lower MSE = good
|
| 821 |
+
const cqWord = fmtCq >= d.composite_quality ? "better" : "worse"; // higher CQ = good
|
| 822 |
+
return `${label} at q${q} compressed this image ${d.width}×${d.height} with a ${pct(fmtSize, d.compressed_kb)}% ${sizeWord} file size, `
|
| 823 |
+
+ `${pct(fmtMse, d.mse)}% ${mseWord} MSE and ${pct(fmtCq, d.composite_quality)}% ${cqWord} Composite Quality Score.`;
|
| 824 |
+
}
|
| 825 |
+
|
| 826 |
function renderViewer() {
|
| 827 |
const d = registry[viewIndex];
|
| 828 |
if (!d) return;
|
|
|
|
| 831 |
holdJpeg.hidden = !d.jpeg_image;
|
| 832 |
holdAvif.hidden = !d.avif_image;
|
| 833 |
[holdBtn, holdJpeg, holdAvif].forEach(b => b.classList.remove("holding"));
|
| 834 |
+
document.getElementById("viewer-caption").textContent = viewerCaption(d);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 835 |
prevBtn.disabled = viewIndex <= 0;
|
| 836 |
nextBtn.disabled = viewIndex >= registry.length - 1;
|
| 837 |
}
|
| 838 |
|
| 839 |
+
function wireHold(btn, getSrc, getCaption) {
|
| 840 |
+
const capEl = document.getElementById("viewer-caption");
|
| 841 |
const release = () => {
|
| 842 |
const d = registry[viewIndex];
|
| 843 |
const img = viewerStage.querySelector("img");
|
| 844 |
if (d && img) img.src = d.reconstructed_image;
|
| 845 |
+
if (d) capEl.textContent = viewerCaption(d);
|
| 846 |
btn.classList.remove("holding");
|
| 847 |
};
|
| 848 |
btn.addEventListener("pointerdown", e => {
|
| 849 |
e.preventDefault();
|
| 850 |
+
const d = registry[viewIndex];
|
| 851 |
const img = viewerStage.querySelector("img");
|
| 852 |
const src = getSrc();
|
| 853 |
if (!img || !src) return;
|
| 854 |
img.src = src;
|
| 855 |
+
if (getCaption && d) capEl.textContent = getCaption(d);
|
| 856 |
btn.classList.add("holding");
|
| 857 |
});
|
| 858 |
btn.addEventListener("pointerup", release);
|
| 859 |
btn.addEventListener("pointerleave", () => btn.classList.contains("holding") && release());
|
| 860 |
}
|
| 861 |
wireHold(holdBtn, () => { const d = registry[viewIndex]; return d && d.original_image; });
|
| 862 |
+
wireHold(holdJpeg, () => { const d = registry[viewIndex]; return d && d.jpeg_image; }, d => codecCompareCaption(d, "jpeg"));
|
| 863 |
+
wireHold(holdAvif, () => { const d = registry[viewIndex]; return d && d.avif_image; }, d => codecCompareCaption(d, "avif"));
|
| 864 |
|
| 865 |
/* ============================================================
|
| 866 |
DOWNLOAD
|
|
|
|
| 900 |
/* ============================================================
|
| 901 |
BOOT
|
| 902 |
============================================================ */
|
| 903 |
+
initRoster();
|
static/index.html
CHANGED
|
@@ -16,7 +16,7 @@
|
|
| 16 |
#hold-jpeg, #hold-avif { border-color: var(--hc); color: var(--hc); }
|
| 17 |
#hold-jpeg.holding, #hold-avif.holding { background: var(--hc); border-color: var(--hc); color: #fff; }
|
| 18 |
</style>
|
| 19 |
-
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>
|
| 20 |
</head>
|
| 21 |
<body>
|
| 22 |
|
|
@@ -24,7 +24,7 @@
|
|
| 24 |
<section id="landing" class="section landing">
|
| 25 |
<div class="landing-grid">
|
| 26 |
<div class="landing-left">
|
| 27 |
-
<p class="eyebrow">PBC ·
|
| 28 |
<h1 class="title">Probabilistic <em>Brush</em> Compression.</h1>
|
| 29 |
<p class="lede">
|
| 30 |
An unconventional lossy image compression algorithm. It compresses image
|
|
|
|
| 16 |
#hold-jpeg, #hold-avif { border-color: var(--hc); color: var(--hc); }
|
| 17 |
#hold-jpeg.holding, #hold-avif.holding { background: var(--hc); border-color: var(--hc); color: #fff; }
|
| 18 |
</style>
|
| 19 |
+
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>👁️</text></svg>">
|
| 20 |
</head>
|
| 21 |
<body>
|
| 22 |
|
|
|
|
| 24 |
<section id="landing" class="section landing">
|
| 25 |
<div class="landing-grid">
|
| 26 |
<div class="landing-left">
|
| 27 |
+
<p class="eyebrow">PBC · v3.0</p>
|
| 28 |
<h1 class="title">Probabilistic <em>Brush</em> Compression.</h1>
|
| 29 |
<p class="lede">
|
| 30 |
An unconventional lossy image compression algorithm. It compresses image
|