a manifold extension to port fully onchain an existing collection minted with offchain content
clgsms/encode/encode_onchain_v2.py
Python · 280 lines · 14 KBRAWDOWNLOAD
#!/usr/bin/env python3
"""
CLGSMS -> on-chain AVIF encoder, v2.
Same output contract as v1 (native-resolution AVIF 4:4:4, ./onchain/<name>.avif,
report.csv, MANIFEST.sha256) - different quality policy, different search,
much lighter on CPU and memory.
What changed and why. Every codec claim below was measured at MATCHED BYTE
SIZE on 12 1024px tiles of CLGSM-13 / CLGSMS_63 (Pillow 12, libaom 3.13):
* CONSTANT QUALITY, CAPPED. v1 always filled the budget. v2 encodes every
piece at one quantiser (--q, default 40) so the collection sits on a single
visual tier, and fits *down* only when a heavy piece would exceed --budget.
Light pieces come in around 0.4-0.6 MB, heavy ones at the cap. Bytes are
only spent where the content needs them.
* ONE FINAL ENCODE INSTEAD OF SEVEN. A speed-7 encode of the full image
takes ~7 s against ~65 s at speed 4 and lands a steady 6% under the
speed-4 size, so the rate curve log(bytes) ~ quantiser is probed at
speed 7, the final speed-4 encode is placed on it, and the curve is
re-anchored on the real result if it overshoots (one extra final encode
at most in practice). Tile sampling was tried and rejected: +-10% error,
and slower. The search runs over the 64 real AV1 quantiser levels, not
the 101 quality values (pairs of which are byte-identical).
* MULTITHREADED libaom row-mt on every encode (v1 used the default).
* MEMORY-FLAT METRICS. v1 held three float64 copies of a 33 Mpx image
(~2.4 GB). v2 streams PSNR / edge / gradient-PSNR in float32 row bands.
* CODEC FLAGS: tune=ssim kept, CDEF turned back ON. libavif now defaults
still images to tune=iq, which costs +12% bytes at equal PSNR on this
material, so tune=ssim stays explicit. With the current libaom, CDEF on
is 2.2% smaller at equal PSNR, gradient-PSNR +0.1 dB and edge energy
unchanged (-0.2 pt) - v1 measured it at fixed q, where the filter only
ever looks like a loss. Everything else was flat or worse at equal
bytes: speed 2 / 3 (0.0%), speed 5 (+15%), 10-bit (+2%), enable-qm (+1%),
sharpness=7 (+6%), deltaq / aq modes (byte-identical), intra-edge-filter,
chroma-deltaq (0%), tune=psnr (+1%), RGB identity matrix (+9%), 4:2:0
(+7%), rav1e (+14%, 4x slower).
* PRE-SHARPEN kept exactly (unsharp r0.6 / 50%). At matched bytes it buys
+2.5 pts of edge energy for 1.7% bytes; 35% is byte-neutral for +1.1 pt
(--sharpen 35 if you prefer fidelity); 80% or radius 1.0 cost 14-21% more
bytes for halos. Closed-loop residual compensation
(x + (x - decode(encode(x)))) was measured and rejected: +115% bytes at
equal PSNR - it re-injects exactly the detail the quantiser cannot carry.
Only the encoder input is sharpened; metrics compare against the
untouched source.
* ENCODER BUILD MATTERS: libaom 3.8 (Ubuntu's avifenc) is 4-8% smaller at
equal PSNR than the libaom 3.13 bundled in current Pillow wheels, with no
flag found that closes the gap. Not exploited here - Pillow keeps the
script dependency-free - but pinning an older libavif/libaom is a free
5% if you want to chase it.
Usage:
python3 encode_onchain_v2.py # q40, cap 1,000,000 B
python3 encode_onchain_v2.py --q 44 --budget 1000000
python3 encode_onchain_v2.py --q max --budget 1000000 # v1 policy: fill the cap
python3 encode_onchain_v2.py --crops # + 1:1 QA crops in onchain/qa/
python3 encode_onchain_v2.py --only CLGSM-13.png # one file
Re-running skips files already in report.csv (use --force, or delete the .avif).
"""
import argparse, csv, io, os, sys, hashlib, time, math
from PIL import Image, ImageFilter
import numpy as np
Image.MAX_IMAGE_PIXELS = None
ADVANCED = {"enable-cdef": "1", "tune": "ssim"}
THREADS = os.cpu_count() or 4
BAND = 256 # rows per metrics band (float32) -> ~50 MB peak per band
PROBE_RATIO = 1.065 # measured: speed-4 bytes / speed-7 bytes at equal q
QMIN, QMAX = 5, 70
# ----------------------------------------------------------------- encoding --
# libavif maps quality 0-100 onto the AV1 quantiser index 0-63 with
# qidx = ((100 - quality) * 63 + 50) // 100, so pairs of neighbouring qualities
# (39/40, 36/37, ...) are byte-identical. v1's binary search over quality wasted
# steps on those; v2 searches the 64 real quantiser levels and reports the
# highest quality that reaches each one.
QIDX_OF = {q: ((100 - q) * 63 + 50) // 100 for q in range(101)}
QUALITY_OF = {}
for _q, _qi in QIDX_OF.items():
QUALITY_OF[_qi] = max(QUALITY_OF.get(_qi, 0), _q)
QI_BEST, QI_WORST = QIDX_OF[QMAX], QIDX_OF[QMIN] # 19 .. 60
def encode(im, qidx, speed):
buf = io.BytesIO()
im.save(buf, "AVIF", quality=QUALITY_OF[int(qidx)], speed=speed, subsampling="4:4:4",
advanced=ADVANCED, max_threads=THREADS)
return buf.getvalue()
class RateModel:
"""log(bytes) as a low-order polynomial in the quantiser index, fitted on
fast probes, then shifted to pass through the nearest final-speed encode."""
def __init__(self, src, probe_speed, ratio):
self.src, self.speed, self.ratio = src, probe_speed, ratio
self.probes, self.full, self.coef = {}, {}, None
def probe(self, qi, log):
qi = int(min(max(qi, QI_BEST), QI_WORST))
if qi not in self.probes:
t0 = time.time()
self.probes[qi] = len(encode(self.src, qi, self.speed)) * self.ratio
log(f" probe q={QUALITY_OF[qi]:<3} (qidx {qi}) ~{self.probes[qi]/1024:7.1f} KiB "
f"({time.time()-t0:.0f}s, speed {self.speed})")
self.fit()
return self.probes[qi]
def fit(self):
xs = np.array(sorted(self.probes), float)
ys = np.log([self.probes[x] for x in xs])
deg = min(2, len(xs) - 1)
self.coef = np.polyfit(xs, ys, deg) if deg > 0 else np.array([ys[0]])
def _m(self, qi):
return float(np.polyval(self.coef, qi))
def predict(self, qi):
if not self.full:
return math.exp(self._m(qi))
q0 = min(self.full, key=lambda k: abs(k - qi)) # nearest anchor
return self.full[q0] * math.exp(self._m(qi) - self._m(q0))
def qi_for(self, budget, lo):
"""Best (lowest) quantiser index >= lo predicted to fit."""
for qi in range(int(lo), QI_WORST + 1):
if self.predict(qi) <= budget:
return qi
return QI_WORST
def fit_encode(src, q_target, budget, speed, probe_speed, log):
"""Encode at q_target if it fits the budget, else at the best quality that
does. Returns (quality, qidx, bytes, n_final_encodes, n_probes)."""
rm = RateModel(src, probe_speed, PROBE_RATIO)
qi_t = QIDX_OF[q_target]
qi = qi_t
if rm.probe(qi, log) > budget * 0.97: # will (probably) not fit as-is
rm.probe(qi + 4, log); rm.probe(qi + 8, log)
qi = rm.qi_for(budget * 0.975, qi)
done = {}
while True:
t0 = time.time()
data = encode(src, qi, speed)
done[qi] = data; rm.full[qi] = len(data)
log(f" encode q={QUALITY_OF[qi]:<3} (qidx {qi}) {len(data)/1024:7.1f} KiB "
f"({time.time()-t0:.0f}s, speed {speed})")
fits = [k for k in done if len(done[k]) <= budget]
if fits:
best = min(fits)
# forced below the target quality: is one notch up still inside?
if best > qi_t and best - 1 not in done and len(done) < 4 \
and rm.predict(best - 1) <= budget * 0.99:
qi = best - 1; continue
return QUALITY_OF[best], best, done[best], len(done), len(rm.probes)
# overshoot: the model is now anchored on the real byte count
qi_new = rm.qi_for(budget * 0.975, qi + 1)
qi = qi + 1 if qi_new in done else qi_new
if qi >= QI_WORST and QI_WORST in done:
return QUALITY_OF[QI_WORST], QI_WORST, done[QI_WORST], len(done), len(rm.probes)
# ------------------------------------------------------------------ metrics --
def banded_metrics(ref_im, data):
"""PSNR, edge-energy retention (%) and gradient PSNR against the untouched
source, streamed in row bands so peak memory stays ~flat."""
dec = Image.open(io.BytesIO(data)).convert("RGB")
W, H = ref_im.size
se = gse = gr = gd = 0.0; npx = ng = 0
for y0 in range(0, H, BAND):
y1 = min(H, y0 + BAND + 1) # 1-row overlap for vertical diffs
a = np.asarray(ref_im.crop((0, y0, W, y1)), np.float32)
b = np.asarray(dec.crop((0, y0, W, y1)), np.float32)
n = a.shape[0] - (1 if y1 < H else 0) # rows owned by this band
d = a[:n] - b[:n]
se += float((d * d).sum()); npx += d.size
for ax in (0, 1):
ga, gb = np.diff(a, axis=ax), np.diff(b, axis=ax)
if ax == 1: ga, gb = ga[:n], gb[:n]
gr += float(np.abs(ga).sum()); gd += float(np.abs(gb).sum())
e = ga - gb; gse += float((e * e).sum()); ng += e.size
psnr = 99.0 if se == 0 else 10 * math.log10(65025 * npx / se)
gpsnr = 99.0 if gse == 0 else 10 * math.log10(65025 * ng / gse)
return psnr, gd / gr * 100, gpsnr, dec
def qa_crops(ref_im, dec, out_png, size=512):
"""Side-by-side 1:1 crops (source | encode) of the busiest region."""
small = np.asarray(ref_im.reduce(8).convert("L"), np.float32)
g = np.abs(np.diff(small, axis=1))[:-1] + np.abs(np.diff(small, axis=0))[:, :-1]
k = size // 8; best = (-1, 0, 0)
for y in range(0, g.shape[0] - k + 1, k // 2):
for x in range(0, g.shape[1] - k + 1, k // 2):
s = g[y:y + k, x:x + k].mean()
if s > best[0]: best = (s, x * 8, y * 8)
_, bx, by = best
box = (bx, by, bx + size, by + size)
sheet = Image.new("RGB", (size * 2 + 8, size), (24, 24, 24))
sheet.paste(ref_im.crop(box), (0, 0)); sheet.paste(dec.crop(box), (size + 8, 0))
sheet.save(out_png)
# --------------------------------------------------------------------- main --
FIELDS = ["file", "w", "h", "quality", "qidx", "bytes", "kib", "psnr_db", "edge_pct", "gpsnr_db",
"final_encodes", "probes", "seconds", "sha256"]
def write_report(report, rows, out):
rs = sorted(rows.values(), key=lambda r: r["file"])
with open(report, "w", newline="") as fh:
w = csv.DictWriter(fh, fieldnames=FIELDS, extrasaction="ignore"); w.writeheader(); w.writerows(rs)
with open(os.path.join(out, "MANIFEST.sha256"), "w") as fh:
fh.writelines(f"{r['sha256']} {r['file']}\n" for r in rs)
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--q", default="40", help="target quality 0-100, or 'max' to fill the budget (default 40)")
ap.add_argument("--budget", type=int, default=1_000_000, help="hard cap in bytes per file (default 1,000,000)")
ap.add_argument("--speed", type=int, default=4, help="libaom speed of the final encode (default 4; 2-3 measured as no gain)")
ap.add_argument("--probe-speed", type=int, default=7, help="speed of the rate probes (default 7)")
ap.add_argument("--sharpen", type=float, default=50, help="unsharp %% before encoding, radius 0.6 (default 50; 35 is byte-neutral; 0 disables)")
ap.add_argument("--out", default="onchain")
ap.add_argument("--src", default=".")
ap.add_argument("--only", nargs="*", help="only these PNG file names")
ap.add_argument("--crops", action="store_true", help="write <out>/qa/<name>.png 1:1 source|encode crops")
ap.add_argument("--force", action="store_true", help="re-encode files already in report.csv")
args = ap.parse_args()
q_target = QMAX if args.q == "max" else min(QMAX, max(QMIN, int(args.q)))
os.makedirs(args.out, exist_ok=True)
if args.crops: os.makedirs(os.path.join(args.out, "qa"), exist_ok=True)
pngs = sorted(f for f in os.listdir(args.src) if f.lower().endswith(".png"))
if args.only: pngs = [f for f in pngs if f in set(args.only)]
if not pngs: sys.exit("no PNGs found in " + os.path.abspath(args.src))
report = os.path.join(args.out, "report.csv")
rows = {}
if os.path.exists(report):
with open(report) as fh:
rows = {r["file"]: r for r in csv.DictReader(fh)}
log = lambda s: print(s, flush=True)
for i, f in enumerate(pngs, 1):
stem = os.path.splitext(f)[0]; name = stem + ".avif"; dst = os.path.join(args.out, name)
if name in rows and os.path.exists(dst) and not args.force:
log(f"[{i:>3}/{len(pngs)}] {name:<28} already done, skipping"); continue
t0 = time.time()
im = Image.open(os.path.join(args.src, f)).convert("RGB")
log(f"[{i:>3}/{len(pngs)}] {name:<28} {im.width}x{im.height}")
src = im.filter(ImageFilter.UnsharpMask(radius=0.6, percent=int(args.sharpen), threshold=0)) \
if args.sharpen > 0 else im
q, qi, data, nf, npb = fit_encode(src, q_target, args.budget, args.speed, args.probe_speed, log)
del src
with open(dst, "wb") as fh: fh.write(data)
p, edge, gp, dec = banded_metrics(im, data)
if args.crops: qa_crops(im, dec, os.path.join(args.out, "qa", stem + ".png"))
del dec
secs = time.time() - t0
rows[name] = dict(file=name, w=im.width, h=im.height, quality=q, qidx=qi, bytes=len(data),
kib=round(len(data) / 1024, 1), psnr_db=round(p, 2), edge_pct=round(edge, 1),
gpsnr_db=round(gp, 2), final_encodes=nf, probes=npb, seconds=round(secs, 1),
sha256=hashlib.sha256(data).hexdigest())
log(f" -> q={q} (qidx {qi}) {len(data)/1024:.1f} KiB PSNR {p:.2f} dB edge {edge:.1f}% "
f"gPSNR {gp:.2f} dB [{nf} final + {npb} probe encodes, {secs:.0f}s]")
write_report(report, rows, args.out)
rs = list(rows.values()); n = len(rs)
tot = sum(int(r["bytes"]) for r in rs)
gas = sum(int(r["bytes"]) * 216 + -(-int(r["bytes"]) // 24575) * 75000 for r in rs)
log(f"\n{n} files, {tot/1024/1024:.2f} MiB total, mean {tot/n/1024:.0f} KiB, "
f"mean PSNR {sum(float(r['psnr_db']) for r in rs)/n:.2f} dB, "
f"mean edge {sum(float(r['edge_pct']) for r in rs)/n:.1f}%")
log(f"Ethereum: {gas/1e6:,.0f}M gas total -> {gas*1e-9:,.3f} ETH per gwei of gas price")
if __name__ == "__main__":
main()