File size: 9,909 Bytes
0598469
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
"""Calibration and task-aware RKNN export helpers."""

import ast
import math
from pathlib import Path

import cv2
import onnx
import yaml


IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".bmp", ".webp"}


def calibration_images(path, limit):
    """Read image directories, image lists, or a dataset YAML without downloading data."""
    path = Path(path).resolve()
    if path.is_dir():
        images = sorted(p for p in path.rglob("*") if p.suffix.lower() in IMAGE_SUFFIXES)
    elif path.suffix.lower() in {".yaml", ".yml"}:
        document = yaml.safe_load(path.read_text())
        root = Path(document.get("path", "."))
        if not root.is_absolute():
            root = path.parent / root
        split = document.get("val") or document.get("train")
        if not split:
            raise ValueError("Calibration YAML needs val or train image paths")
        sources = split if isinstance(split, list) else [split]
        images = []
        for source in sources:
            images.extend(calibration_images(root / source, limit))
    elif path.suffix.lower() == ".txt":
        images = []
        for line in path.read_text().splitlines():
            line = line.strip()
            if line and not line.startswith("#"):
                image = Path(line)
                images.append(image if image.is_absolute() else path.parent / image)
    elif path.suffix.lower() in IMAGE_SUFFIXES:
        images = [path]
    else:
        raise ValueError("Calibration data must be an image directory, list, or dataset YAML")
    images = list(dict.fromkeys(p.resolve() for p in images))[:limit]
    if not images or any(not p.is_file() for p in images):
        raise ValueError("Calibration images are missing or the dataset is empty")
    return images


def prepare_calibration(images, directory, width, height, center_crop):
    """Apply the application's crop/letterbox policy before RKNN calibration."""
    import numpy as np

    directory.mkdir()
    paths = []
    for index, path in enumerate(images):
        image = cv2.imread(str(path), cv2.IMREAD_COLOR)
        if image is None:
            raise ValueError(f"Cannot read calibration image: {path}")
        h, w = image.shape[:2]
        if center_crop:
            ratio = width / height
            crop_w = min(w, int(h * ratio)) & ~1
            crop_h = min(h, int(w / ratio)) & ~1
            if min(crop_w, crop_h) < 2:
                raise ValueError(f"Calibration image is too small: {path}")
            x = ((w - crop_w) // 2) & ~1
            y = ((h - crop_h) // 2) & ~1
            output = cv2.resize(image[y:y + crop_h, x:x + crop_w], (width, height))
        else:
            scale = min(width / w, height / h)
            rw = max(2, int(math.floor(w * scale + 0.5)) & ~1)
            rh = max(2, int(math.floor(h * scale + 0.5)) & ~1)
            x = ((width - rw) // 2) & ~1
            y = ((height - rh) // 2) & ~1
            output = np.full((height, width, 3), 114, dtype=np.uint8)
            output[y:y + rh, x:x + rw] = cv2.resize(image, (rw, rh))
        target = directory / f"{index:05d}.png"
        if not cv2.imwrite(str(target), output):
            raise RuntimeError(f"Cannot write calibration image: {target}")
        paths.append(str(target))
    dataset = directory / "images.txt"
    dataset.write_text("\n".join(paths) + "\n")
    return dataset


def floating_head_tensors(graph, head_index):
    """Keep the complete prediction head and decoding in FP16."""
    prefix = f"/model.{head_index}/"
    tensors = {value for node in graph.node if node.name.startswith(prefix)
               for value in node.output}
    if not tensors:
        raise ValueError("Cannot identify the prediction-head scope in this ONNX export")
    return tensors


def floating_decode_tensors(graph):
    """Keep terminal decoding separate from quantized learned predictions."""
    producers = {name: node for node in graph.node for name in node.output}
    pending = [value.name for value in graph.output]
    protected = set()
    learned = {"Conv", "ConvTranspose", "Gemm", "MatMul"}
    while pending:
        name = pending.pop()
        node = producers.get(name)
        if name in protected or node is None or node.op_type in learned:
            continue
        protected.add(name)
        pending.extend(node.input)
    return protected


def dense_output(graph, metadata, task):
    """Return low-resolution dense predictions; reconstruct sampled pixels in C++."""
    if task not in {"semantic", "depth"}:
        return graph
    producers = {name: node for node in graph.graph.node for name in node.output}
    node = producers[graph.graph.output[0].name]
    while node.op_type in {"Cast", "ArgMax", "Relu"}:
        node = producers[node.input[0]]
    if node.op_type != "Resize":
        raise ValueError("Dense export needs a terminal bilinear Resize")
    attributes = {a.name: onnx.helper.get_attribute_value(a) for a in node.attribute}
    mode = attributes.get("coordinate_transformation_mode", b"half_pixel").decode()
    if attributes.get("mode") != b"linear" or mode not in {"half_pixel", "align_corners"}:
        raise ValueError("Unsupported dense resize convention")
    inferred = onnx.shape_inference.infer_shapes(graph)
    tensor = next((v for v in inferred.graph.value_info if v.name == node.input[0]), None)
    if tensor is None or len(tensor.type.tensor_type.shape.dim) != 4:
        raise ValueError("Cannot determine the dense prediction shape")
    inputs = [value.name for value in inferred.graph.input]
    result = onnx.utils.Extractor(inferred).extract_model(inputs, [node.input[0]])
    metadata["dense_resize"] = mode
    return result


def export_optimized(model, chip, size, data, limit, precision):
    from rknn.api import RKNN

    exported = Path(model.export(format="onnx", imgsz=size, batch=1, device="cpu",
                                 opset=19, simplify=True, end2end=False)).resolve()
    graph = onnx.load(str(exported))
    metadata = {}
    for entry in graph.metadata_props:
        try:
            metadata[entry.key] = ast.literal_eval(entry.value)
        except (ValueError, SyntaxError):
            metadata[entry.key] = entry.value
    graph = dense_output(graph, metadata, model.task)
    onnx.save(graph, str(exported))
    result = exported.with_suffix(".rknn")

    if precision != "fp16":
        images = calibration_images(data, limit)
        dataset = prepare_calibration(images, exported.parent / "calibration", size, size,
                                      model.task == "classify")
        head_index = model.model.model[-1].i
        protected = (floating_head_tensors(graph.graph, head_index)
                     if precision == "mixed" else floating_decode_tensors(graph.graph))
        head_prefix = f"/model.{head_index}/"
        toolkit = RKNN(verbose=False)
        try:
            check(toolkit.config(mean_values=[[0, 0, 0]], std_values=[[255, 255, 255]],
                                 target_platform=chip), "configure")
            check(toolkit.load_onnx(model=str(exported)), "load ONNX")
            check(toolkit.hybrid_quantization_step1(dataset=str(dataset), proposal=False),
                  "prepare mixed precision")
        finally:
            toolkit.release()
        config_path = exported.with_suffix(".quantization.cfg")
        config = yaml.safe_load(config_path.read_text())
        # RKNN adds layout/conversion aliases such as output0-rs and output0_int8.
        # Protecting only the public output would leave its packed source quantized.
        matched = [key for key, parameter in config["quantize_parameters"].items()
                   if parameter.get("dtype") in {"float32", "float16", "int8", "uint8"}
                   and ((precision == "mixed" and key.startswith(head_prefix)) or any(
                       key == tensor or key.startswith(tensor + suffix)
                       for tensor in protected for suffix in ("_", "-", "#")))]
        if not matched and precision == "mixed":
            raise RuntimeError("Cannot identify floating-point prediction heads in this model")
        config["custom_quantize_layers"] = {key: "float16" for key in matched}
        config_path.write_text(yaml.safe_dump(config, sort_keys=False))

        toolkit = RKNN(verbose=False)
        try:
            check(toolkit.hybrid_quantization_step2(
                  model_input=str(exported.with_suffix(".model")),
                  data_input=str(exported.with_suffix(".data")),
                  model_quantization_cfg=str(config_path)), "build mixed precision")
            check(toolkit.export_rknn(str(result)), "save RKNN")
        finally:
            toolkit.release()
        metadata.update(precision=precision, head_precision="fp16" if precision == "mixed" else "int8",
                        decode_precision="fp16" if matched else "none",
                        fp16_decode_tensors=len(matched), calibration_images=len(images))
        print(f"{precision}: {len(matched)} FP16 head/decode tensors, {len(images)} calibration images")
    else:
        toolkit = RKNN(verbose=False)
        try:
            check(toolkit.config(mean_values=[[0, 0, 0]], std_values=[[255, 255, 255]],
                                 target_platform=chip), "configure")
            check(toolkit.load_onnx(model=str(exported)), "load ONNX")
            check(toolkit.build(do_quantization=False), "build FP16")
            check(toolkit.export_rknn(str(result)), "save RKNN")
        finally:
            toolkit.release()
        metadata["precision"] = "fp16"
    metadata["target_platform"] = chip
    metadata["args"] = {**metadata.get("args", {}), "format": "rknn",
                        "precision": metadata["precision"]}
    return result, metadata


def check(result, operation):
    if result != 0:
        raise RuntimeError(f"RKNN {operation} failed ({result})")