EgeEken commited on
Commit
8e60e86
·
1 Parent(s): 9a572fd

pbc3.0 first update

Browse files
Files changed (8) hide show
  1. .gitignore +2 -0
  2. PBC3.py +1003 -0
  3. PBC3_animation.py +298 -0
  4. pbc3_kernels.py +172 -0
  5. pbc3_types.py +201 -0
  6. server.py +86 -104
  7. static/app.js +157 -289
  8. 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
- image: UploadFile = File(...),
194
- mode: str = Form("Auto"),
195
- stroke_count: str = Form(""),
196
- downsample_initialize_rate: str = Form(""),
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 image.read()
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
- if channel_cycle == "Off":
228
- channel_cycle = None
229
- elif channel_cycle == "123":
230
- channel_cycle = "Default"
231
-
232
  if mode == "Auto":
 
 
 
 
233
  kwargs = {}
234
- elif mode == "Semi":
235
- kwargs = dict(
236
- stroke_count=_i(stroke_count, -1),
237
- downsample_initialize=_truthy(downsample_initialize),
238
- downsample_initialize_rate=_f(downsample_initialize_rate, 16),
239
- downsample_initialize_bits=_i(downsample_initialize_bits, 8),
240
- )
241
- else: # Manual
242
- if decay_mode == "Auto":
243
- decay = dict(decay_cutoff=-1.0, decay_softness=-1.0, decay_progress=-1.0)
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 = PBC.compress(img, **kwargs)
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": {"mode": mode, **{k: (list(v) if isinstance(v, tuple) else v) for k, v in kwargs.items()}},
309
  })
310
 
311
 
@@ -332,9 +299,9 @@ async def decode(file: UploadFile = File(...)):
332
  raw = await file.read()
333
 
334
  try:
335
- timer = time.time()
336
- img = PBC.decompress(bytes(raw)).convert("RGB")
337
- elapsed = time.time() - timer
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
- Seeds from the bpp guideline, then nudges quality up/down based on the observed
398
- bpp, capping at 3 total encodes. `best['data']` holds the chosen encoded bytes.
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
- bpp = len(data) * 8 / pixels
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
- cur = enc(_guess_quality(fmt, target_bpp))
423
- if cur:
424
- yield {"q": cur["q"], "bpp": cur["bpp"]}
425
- n = 0
426
- while cur and n < 2 and (not target_bpp or abs(cur["bpp"] - target_bpp) / target_bpp >= 0.08):
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": nxt["q"], "bpp": nxt["bpp"]}
434
- cur = nxt
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 {"image": _png_b64(rec), "bpp": best["bpp"], "q": best["q"], "quality": composite_quality(img, rec)}
 
 
 
 
 
 
 
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
- { id: "stroke_count", label: "Stroke count", hint: "higher = better quality but slower and less compression", group: "Core", modes: ["Semi", "Manual"], type: "slider", min: 0, max: 200000, step: 1000, value: 40000, full: true },
346
- { id: "downsample_initialize_rate", label: "Downsample init rate", group: "Core", modes: ["Semi", "Manual"], type: "slider", min: 2, max: 32, step: 0.1, value: 16, disableToggle: true },
347
- { id: "downsample_initialize_bits", label: "Downsample init bits", group: "Core", modes: ["Manual"], type: "slider", min: 1, max: 8, step: 1, value: 8 },
348
-
349
- { id: "size_range_start", label: "Size range start", group: "Strokes", modes: ["Manual"], type: "slider", min: 0.001, max: 1, step: 0.001, value: 0.3 },
350
- { id: "size_range_end", label: "Size range end", group: "Strokes", modes: ["Manual"], type: "slider", min: 0.001, max: 1, step: 0.001, value: 0.01 },
351
- { id: "start_mode", label: "Canvas start color", group: "Strokes", modes: ["Manual"], type: "select", options: ["Average", "Median", "Black", "White", "Custom"], value: "Average" },
352
-
353
- { id: "color_space", label: "Color space", group: "Color & downsampling", modes: ["Manual"], type: "select", options: ["RGB", "YCbCr"], value: "RGB" },
354
- { id: "downsample_rate", label: "Downsample rate", hint: "-1 = auto", group: "Color & downsampling", modes: ["Manual"], type: "slider", min: -1, max: 16, step: 0.1, value: -1 },
355
- { id: "downsample_alg", label: "Resample algorithm", group: "Color & downsampling", modes: ["Manual"], type: "select", options: ["Lanczos", "Bicubic", "Bilinear", "Nearest"], value: "Bicubic" },
356
-
357
- { id: "decay_mode", label: "Size decay function", group: "Decay", modes: ["Manual"], type: "select", options: ["Auto", "Manual"], value: "Auto" },
358
- { id: "decay_cutoff", label: "Decay cutoff", group: "Decay", modes: ["Manual"], type: "slider", min: 0, max: 1, step: 0.01, value: 0.5 },
359
- { id: "decay_softness", label: "Decay softness", group: "Decay", modes: ["Manual"], type: "slider", min: 0, max: 1, step: 0.01, value: 0.5 },
360
- { id: "decay_progress", label: "Decay progress", group: "Decay", modes: ["Manual"], type: "slider", min: 0, max: 1, step: 0.01, value: 0.5 },
361
-
362
- { id: "multlist_mode", label: "Generator mode", group: "Multiplier list", modes: ["Manual"], type: "select", options: ["PBC Default", "Stable_Uniform", "Uniform", "Random"], value: "PBC Default" },
363
- { id: "mult_bit_count", label: "Multiplier bit count", group: "Multiplier list", modes: ["Manual"], type: "slider", min: 1, max: 9, step: 1, value: 2 },
364
- { id: "mult_min", label: "Multiplier minimum", group: "Multiplier list", modes: ["Manual"], type: "slider", min: -255, max: 255, step: 1, value: -10 },
365
- { id: "mult_max", label: "Multiplier maximum", group: "Multiplier list", modes: ["Manual"], type: "slider", min: -255, max: 255, step: 1, value: 20 },
366
- { id: "mult_list", label: "Multiplier list", hint: "editable", group: "Multiplier list", modes: ["Manual"], type: "text", value: "[-10, 0, 5, 20]", gen: true },
367
-
368
- { id: "strokes_per_quadrant", label: "Focus strokes", group: "Focus", modes: ["Manual"], type: "slider", min: 10, max: 1000, step: 10, value: 100 },
369
- { id: "quadrant_warmup_time", label: "Focus warmup", group: "Focus", modes: ["Manual"], type: "slider", min: 0, max: 1, step: 0.01, value: 0.5 },
370
- { id: "quadrant_max_bits", label: "Focus max bits", group: "Focus", modes: ["Manual"], type: "slider", min: 0, max: 12, step: 1, value: 8 },
371
- { id: "quadrant_padding", label: "Focus padding", group: "Focus", modes: ["Manual"], type: "slider", min: 0, max: 32, step: 1, value: 4 },
372
- { id: "quadrant_selection_criteria", label: "Focus selection", group: "Focus", modes: ["Manual"], type: "select", options: ["Sum", "Max", "Min"], value: "Sum" },
373
-
374
- { id: "channel_cycle", label: "Channel cycle strategy", group: "Channel cycle", modes: ["Manual"], type: "select", options: ["Smart", "Strict", "Balanced", "123"], value: "Smart" },
375
- { id: "strokes_per_channel_cycle", label: "Strokes / cycle", group: "Channel cycle", modes: ["Manual"], type: "slider", min: 10, max: 1000, step: 10, value: 100 },
376
- { id: "channel_cycle_warmup_time", label: "Cycle warmup", group: "Channel cycle", modes: ["Manual"], type: "slider", min: 0, max: 1, step: 0.01, value: 0.9 },
377
- { id: "cycle_selection_criteria", label: "Cycle selection", group: "Channel cycle", modes: ["Manual"], type: "select", options: ["Min", "Max", "Sum"], value: "Min" },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
378
  ];
379
 
380
  const paramState = {};
381
  PARAMS.forEach(p => paramState[p.id] = p.value);
382
- paramState.downsample_initialize = true; // controlled by the inline "off" checkbox
 
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 = `<p class="card-date">Auto mode — every parameter is derived from the image. Switch to Semi-Auto or Manual to take control.</p>`;
 
 
 
 
 
 
 
 
 
397
  return;
398
  }
399
- if (paramMode === "Suggest") { renderSuggest(); return; }
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
- const off = paramsBody.querySelector("[data-dsioff]");
419
- if (off) {
420
- const syncDsi = () => {
421
- paramState.downsample_initialize = !off.checked;
422
- paramsBody.querySelectorAll('[data-pid="downsample_initialize_rate"]').forEach(el => el.disabled = off.checked);
 
 
 
423
  };
424
- off.addEventListener("change", syncDsi);
425
- syncDsi();
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}${p.hint ? ` <i>${p.hint}</i>` : ""}</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.disableToggle ? `<label class="check-inline"><input type="checkbox" data-dsioff> off</label>` : ""}
458
  </div></div>`;
459
  }
460
 
461
- /* ---- Optuna → form mapping (used by the Sweep Analyzer "Load into Demo" and the suggestor) ---- */
462
- const OPTUNA_MAP = {
463
- stroke_count: "stroke_count",
464
- size_start: "size_range_start",
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) { if (!keepMode) toast("Couldn't parse anything"); return; }
511
-
512
- if (!keepMode) {
513
- paramMode = "Manual";
514
- document.querySelectorAll("#param-mode .seg-btn").forEach(b => b.classList.toggle("active", b.dataset.mode === "Manual"));
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", fillMode);
733
- if (fillMode !== "Auto") {
734
- PARAMS.filter(p => p.modes.includes(fillMode)).forEach(p => {
 
 
 
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
- const compareBlock = (!d.decoded && d.original_image)
 
 
 
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 PBC's bpp, then expose
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
- toast(`${label} q${r.q} · ${bppToRate(r.bpp).toFixed(1)}× — hold in fullscreen to compare`);
 
 
 
 
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
- let cap = d.decoded
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>🧩</text></svg>">
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 · v2.4</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
 
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