Files
DarkRoom/tools/quantise-models.py
T
dtourolle 06422a07db Offer three denoise networks and a method to choose between them
AI Denoise's Apply switch becomes Method: Bilinear, Fast, Medium, Best,
default Best, so an untouched raw writes nothing and develops through the
mixture. `apply` is still read and never written: 0 is Bilinear, 1 keeps
a network already chosen.

- Best is the mixture of a flat and an edge expert with a learned gate;
  Medium and Fast are students distilled from it. 2.48 s, 0.79 s and
  0.57 s for a 20 MP frame on TensorRT fp16.
- Each network carries its own tile border (256 for the mixture, 192 for
  the students) through `dr_denoise::Shipped` and `TileNet::halo`.
- The file is hashed once at open and each network keys its own cached
  result; Bilinear keeps the result in memory for the way back.
- Each has an .a16w16 sibling for the Hexagon: 0.00 dB on the 6D gate,
  at most 0.11 dB with the noise scaled x0.5 to x4.
- APK BUNDLED 19 -> 23; the PKGBUILD installs all three.
2026-10-04 08:02:25 -04:00

266 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""The body of quantise-models.sh; see there. Run through it, not directly."""
import glob
import os
import sys
from pathlib import Path
import numpy as np
import onnx
import onnxruntime as ort
from onnx import version_converter
from onnxruntime.quantization import CalibrationDataReader, CalibrationMethod, QuantType, quantize_static
from onnxruntime.quantization.calibrate import create_calibrator, save_tensors_data
from onnxruntime.quantization.execution_providers.qnn import get_qnn_qdq_config
from PIL import Image
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import htp_graph # noqa: E402
MODELS = Path(__file__).resolve().parents[1] / "models"
PHOTOS = 300 # calibration photographs; face crops and keypoints come from fewer
CHUNK = 4 # inputs whose activations are held at once (scrfd_10g: ~1 GB each)
Q = QuantType
FORMS = {"int8": (Q.QUInt8, Q.QInt8), "a16w8": (Q.QUInt16, Q.QInt8), "a16w16": (Q.QUInt16, Q.QInt16)}
# Per model: where it lives, the form `Rung::form` gives its role, the exact
# rewrites its graph needs, and nodes that stay float on the CPU because one
# scale cannot serve the tensor (the segmenter's rows: boxes in pixels beside
# scores in 0..1) or because the HTP's 16-bit arithmetic drifts there (the
# scene model's attention). Measured, inference.md §1.5.
TABLE = {
"scrfd_500m_640": dict(dir="face", form="a16w8", feed="scrfd"),
"scrfd_2.5g_640": dict(dir="face", form="a16w8", feed="scrfd"),
"scrfd_10g_640": dict(dir="face", form="a16w8", feed="scrfd"),
"2d106det_b1": dict(dir="face", form="a16w8", feed="landmarks"),
"yolo26n-seg": dict(dir="segment", form="a16w16", feed="yolo", float_from="/model.23/Concat_4"),
"yolo26s-sem-ade20k": dict(dir="scene", form="a16w16", feed="yolo",
float_nodes=["/model.10/m/m.0/attn/MatMul", "/model.10/m/m.0/attn/Softmax",
"/model.10/m/m.0/attn/MatMul_1"]),
"migan-512": dict(dir="inpaint", form="a16w16", feed="migan"),
"xfeat-1024": dict(dir="keypoints", form="int8", feed="xfeat", rewrites=["unfold", "resize"]),
"xfeat-768": dict(dir="keypoints", form="int8", feed="xfeat", rewrites=["unfold", "resize"]),
"mosaic-fast-1408": dict(dir="denoise", form="a16w16", feed=None, rewrites=["bayer"]),
"mosaic-medium-1408": dict(dir="denoise", form="a16w16", feed=None, rewrites=["bayer"]),
# Exported with its packs as SpaceToDepth already; the rewrite finds
# nothing to do.
"mosaic-best-1408": dict(dir="denoise", form="a16w16", feed=None, rewrites=["bayer"]),
}
# ---- the app's samplers (dr-face Letterbox, dr-segment Letterbox::sample, align.rs) ----
def load(p):
return np.asarray(Image.open(p).convert("RGB"), np.float32) / 255.0
def bilinear(img, sx, sy):
h, w = img.shape[:2]
x0, y0 = np.floor(sx).astype(np.int64), np.floor(sy).astype(np.int64)
fx, fy = (sx - x0)[..., None], (sy - y0)[..., None]
c = lambda a, n: np.clip(a, 0, n - 1) # noqa: E731 - neighbours clamp at the edge
top = img[c(y0, h), c(x0, w)] * (1 - fx) + img[c(y0, h), c(x0 + 1, w)] * fx
bot = img[c(y0 + 1, h), c(x0, w)] * (1 - fx) + img[c(y0 + 1, h), c(x0 + 1, w)] * fx
return top * (1 - fy) + bot * fy
def chw(x):
return np.ascontiguousarray(x.transpose(2, 0, 1))[None].astype(np.float32)
def letterbox(img, edge, yolo):
h, w = img.shape[:2]
s = min(edge / w, edge / h)
px, py = (edge - w * s) / 2, (edge - h * s) / 2
ix, iy = np.meshgrid(np.arange(edge) + 0.5, np.arange(edge) + 0.5)
if yolo: # semantic.rs: no -0.5, pad 0.5, 0..1
sx, sy = (ix - px) / s, (iy - py) / s
out = bilinear(img, sx, sy)
out[(sx < 0) | (sx >= w) | (sy < 0) | (sy >= h)] = 0.5
else: # detect.rs: -0.5, pad 114, (v·255 − 127.5)/128
sx, sy = (ix - px) / s - 0.5, (iy - py) / s - 0.5
out = (bilinear(img, sx, sy) * 255 - 127.5) / 128
out[(sx < -0.5) | (sx > w - 0.5) | (sy < -0.5) | (sy > h - 0.5)] = (114 - 127.5) / 128
return chw(out), (s, px, py)
def crop_box(img, x0, y0, bw, bh, ow, oh):
"""align.rs crop_box: output (u+.5) → source, −0.5, bilinear, outside black."""
u, v = np.meshgrid(np.arange(ow) + 0.5, np.arange(oh) + 0.5)
sx, sy = x0 + u * bw / ow - 0.5, y0 + v * bh / oh - 0.5
out = bilinear(img, sx, sy)
h, w = img.shape[:2]
out[(sx < -1) | (sx > w) | (sy < -1) | (sy > h)] = 0
return out
def scrfd_boxes(outs, s, px, py):
"""detect.rs decode: score ≥ 0.5, greedy NMS at 0.4, min side 24 px."""
fmc = len(outs) // 3
boxes, scores = [], []
for i, st in enumerate([8, 16, 32, 64][:fmc]):
sc, bx = outs[i].reshape(-1), outs[fmc + i].reshape(-1, 4)
idx = np.nonzero(sc >= 0.5)[0]
cell = idx // 2
cx, cy = (cell % (640 // st)) * st, (cell // (640 // st)) * st
boxes.append(np.stack([cx - bx[idx, 0] * st, cy - bx[idx, 1] * st, cx + bx[idx, 2] * st, cy + bx[idx, 3] * st], 1))
scores.append(sc[idx])
b, sc = np.concatenate(boxes), np.concatenate(scores)
keep = []
for i in np.argsort(-sc):
x0 = np.maximum(b[i, 0], b[keep, 0]); y0 = np.maximum(b[i, 1], b[keep, 1])
x1 = np.minimum(b[i, 2], b[keep, 2]); y1 = np.minimum(b[i, 3], b[keep, 3])
inter = np.clip(x1 - x0, 0, None) * np.clip(y1 - y0, 0, None)
area = lambda r: (r[..., 2] - r[..., 0]) * (r[..., 3] - r[..., 1]) # noqa: E731
if not keep or (inter / (area(b[i]) + area(b[keep]) - inter)).max() <= 0.4:
keep.append(i)
b = (b[keep] - [px, py, px, py]) / s
return b[np.minimum(b[:, 2] - b[:, 0], b[:, 3] - b[:, 1]) >= 32]
# ---- one calibration input per photograph (or per face), as the app makes it ----
def feeds(kind, photos, model):
name = model.get_inputs()[0].name
if kind == "scrfd":
for p in photos:
yield {name: letterbox(load(p), 640, False)[0]}
elif kind == "landmarks": # landmarks.rs: 1.5× the box, square, 0..255
det = ort.InferenceSession(str(MODELS / "face/scrfd_10g_640.onnx"), providers=["CPUExecutionProvider"])
for p in photos:
img = load(p)
x, ctx = letterbox(img, 640, False)
for b in scrfd_boxes(det.run(None, {"input.1": x}), *ctx):
cx, cy, side = (b[0] + b[2]) / 2, (b[1] + b[3]) / 2, 1.5 * max(b[2] - b[0], b[3] - b[1])
yield {name: chw(crop_box(img, cx - side / 2, cy - side / 2, side, side, 192, 192) * 255)}
elif kind == "yolo":
for p in photos:
yield {name: letterbox(load(p), 640, True)[0]}
elif kind == "migan": # migan.rs: ch0 = known − 0.5, ch1–3 = (rgb·2 − 1)·known
rng = np.random.default_rng(7)
for p in photos:
img = load(p)
h, w = img.shape[:2]
e = min(h, w)
sq = Image.fromarray((img[(h - e) // 2:(h + e) // 2, (w - e) // 2:(w + e) // 2] * 255).astype(np.uint8))
img = np.asarray(sq.resize((512, 512), Image.BILINEAR), np.float32) / 255
known = np.ones((512, 512), np.float32)
for _ in range(rng.integers(1, 3)): # a panorama's unknown border: a wedge along one edge
side = rng.integers(4)
depth = np.linspace(rng.integers(20, 110), rng.integers(20, 110), 512).astype(int)
edge = np.arange(512)[:, None] < depth[None, :] # [depth, along]: inside the wedge
wedge = edge if side % 2 == 0 else edge[::-1] # top / bottom of a column
known[wedge if side < 2 else wedge.T] = 0 # or left / right of a row
x = np.concatenate([(known - 0.5)[None], ((img * 2 - 1) * known[..., None]).transpose(2, 0, 1)])[None]
yield {name: x.astype(np.float32)}
elif kind == "xfeat": # xfeat.rs: grey 0..1, shrink to fit, top-left, zero pad
_, _, H, W = [d if isinstance(d, int) else 1 for d in model.get_inputs()[0].shape]
for p in photos:
g = load(p).mean(2) # a display-rendered photograph is already the app's (R+G+B)/3 ^ 1/2.2
h, w = g.shape
s = min(W / w, H / h, 1.0)
if s < 1:
g = np.asarray(Image.fromarray(g).resize((round(w * s), round(h * s)), Image.BOX))
pad = np.zeros((H, W), np.float32)
pad[:g.shape[0], :g.shape[1]] = g
yield {name: pad[None, None]}
class Items(CalibrationDataReader):
def __init__(self, items):
self.it = iter(items)
def get_next(self):
return next(self.it, None)
def calibrate(path, items, cache):
"""Min/max ranges in chunks — every ORT calibrator holds all activations
until it folds them, and the others measurably degrade the result."""
cal = create_calibrator(Path(path), None, augmented_model_path=f"{path}.aug.onnx",
calibrate_method=CalibrationMethod.MinMax)
batch, n = [], 0
for item in items:
batch.append(item)
n += 1
if len(batch) == CHUNK:
cal.collect_data(Items(batch))
batch = []
if batch:
cal.collect_data(Items(batch))
save_tensors_data(cal.compute_data(), cache)
os.remove(f"{path}.aug.onnx")
return n
def downstream(m, start):
names, live = set(), set()
for n in m.graph.node:
if n.name == start or any(i in live for i in n.input):
names.add(n.name)
live.update(n.output)
return sorted(names)
def main():
args = sys.argv[1:]
ranges = None
if args[:1] == ["--ranges"]:
ranges, args = args[1], args[2:]
photo_dir = None
else:
photo_dir, args = args[0], args[1:]
names = args or [n for n in TABLE if TABLE[n]["feed"]]
photos = []
if photo_dir:
photos = sorted(p for e in ("jpg", "jpeg", "JPG", "JPEG", "png")
for p in glob.glob(os.path.join(photo_dir, "**", f"*.{e}"), recursive=True))[:PHOTOS]
if len(photos) < 50:
sys.exit(f"only {len(photos)} photographs under {photo_dir}; calibration wants hundreds")
print(f"==> calibrating on {len(photos)} photographs")
for stem in names:
spec = TABLE[stem]
src = MODELS / spec["dir"] / f"{stem}.onnx"
out = src.with_name(f"{stem}.{spec['form']}.onnx")
work = src.with_name(f"{stem}.quant-work.onnx")
print(f" {stem} -> {out.name}")
m = onnx.load(src)
if next(o.version for o in m.opset_import if o.domain in ("", "ai.onnx")) < 13:
m = version_converter.convert_version(m, 17) # per-channel QDQ needs 13
m.ir_version = 8
onnx.save(m, work)
if spec.get("rewrites"):
onnx.save(htp_graph.rewrite(str(work), spec["rewrites"]), work)
model = ort.InferenceSession(str(work), providers=["CPUExecutionProvider"])
cache = str(work) + ".ranges"
if spec["feed"] is None:
if not ranges:
sys.exit(f"{stem} is calibrated on mosaics, not photographs: pass --ranges")
cache = ranges
else:
n = calibrate(str(work), feeds(spec["feed"], photos, model), cache)
print(f" {n} calibration inputs")
act, wt = FORMS[spec["form"]]
# The config needs a reader only to exist; the ranges come from `cache`.
zeros = {i.name: np.zeros([d if isinstance(d, int) else 1 for d in i.shape], np.float32)
for i in model.get_inputs()}
cfg = get_qnn_qdq_config(str(work), Items([zeros]), activation_type=act, weight_type=wt, per_channel=True)
exclude = list(cfg.nodes_to_exclude or []) + spec.get("float_nodes", [])
if spec.get("float_from"):
exclude += downstream(onnx.load(work), spec["float_from"])
quantize_static(str(work), str(out), None, quant_format=cfg.quant_format,
op_types_to_quantize=cfg.op_types_to_quantize, per_channel=True,
activation_type=act, weight_type=wt, nodes_to_exclude=exclude,
calibrate_method=CalibrationMethod.MinMax, extra_options=cfg.extra_options,
calibration_cache_path=cache)
os.remove(work)
if cache != ranges:
os.remove(cache)
print(f" {out.name}: {out.stat().st_size // 1024} KB")
if __name__ == "__main__":
main()