File size: 12,094 Bytes
d31e060 afef12f d31e060 afef12f d31e060 afef12f | 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 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 | """Typed contracts between YOLO26n checkpoint intake and model conversion."""
from __future__ import annotations
import math
from dataclasses import dataclass
from enum import Enum
from .converter_io import JsonObject
# The native schedule accepts these square inputs and derives all intermediate
# tensor extents from them. Values outside this closed set are rejected by both
# the exporter and runtime before graph construction.
SUPPORTED_RESOLUTIONS = (320, 480, 640, 800, 960, 1280)
# YOLO26n emits one detection branch at each of these fixed reductions. The
# branch count and output-position calculation are derived from this tuple.
DETECTION_STRIDES = (8, 16, 32)
DETECTION_BRANCH_COUNT = len(DETECTION_STRIDES)
# Each detection position contains four box-coordinate channels followed by one
# confidence channel for each class.
BOX_COORDINATE_CHANNELS = 4
_CLASSIFICATION_CHANNEL_FLOOR = 64
# These are the 102 convolution outputs consumed by the fixed runtime graphs.
# Every convolution contributes a weight and a bias after BatchNorm folding,
# except that two weights are split at the input boundaries listed below. The
# tuple is lexicographically ordered to match validated checkpoint paths.
RUNTIME_CONVOLUTION_PATHS = (
"model.0.conv",
"model.1.conv",
"model.10.cv1.conv",
"model.10.cv2.conv",
"model.10.m.0.attn.pe.conv",
"model.10.m.0.attn.proj.conv",
"model.10.m.0.attn.qkv.conv",
"model.10.m.0.ffn.0.conv",
"model.10.m.0.ffn.1.conv",
"model.13.cv1.conv",
"model.13.cv2.conv",
"model.13.m.0.cv1.conv",
"model.13.m.0.cv2.conv",
"model.13.m.0.cv3.conv",
"model.13.m.0.m.0.cv1.conv",
"model.13.m.0.m.0.cv2.conv",
"model.13.m.0.m.1.cv1.conv",
"model.13.m.0.m.1.cv2.conv",
"model.16.cv1.conv",
"model.16.cv2.conv",
"model.16.m.0.cv1.conv",
"model.16.m.0.cv2.conv",
"model.16.m.0.cv3.conv",
"model.16.m.0.m.0.cv1.conv",
"model.16.m.0.m.0.cv2.conv",
"model.16.m.0.m.1.cv1.conv",
"model.16.m.0.m.1.cv2.conv",
"model.17.conv",
"model.19.cv1.conv",
"model.19.cv2.conv",
"model.19.m.0.cv1.conv",
"model.19.m.0.cv2.conv",
"model.19.m.0.cv3.conv",
"model.19.m.0.m.0.cv1.conv",
"model.19.m.0.m.0.cv2.conv",
"model.19.m.0.m.1.cv1.conv",
"model.19.m.0.m.1.cv2.conv",
"model.2.cv1.conv",
"model.2.cv2.conv",
"model.2.m.0.cv1.conv",
"model.2.m.0.cv2.conv",
"model.20.conv",
"model.22.cv1.conv",
"model.22.cv2.conv",
"model.22.m.0.0.cv1.conv",
"model.22.m.0.0.cv2.conv",
"model.22.m.0.1.attn.pe.conv",
"model.22.m.0.1.attn.proj.conv",
"model.22.m.0.1.attn.qkv.conv",
"model.22.m.0.1.ffn.0.conv",
"model.22.m.0.1.ffn.1.conv",
"model.23.cv2.0.0.conv",
"model.23.cv2.0.1.conv",
"model.23.cv2.0.2",
"model.23.cv2.1.0.conv",
"model.23.cv2.1.1.conv",
"model.23.cv2.1.2",
"model.23.cv2.2.0.conv",
"model.23.cv2.2.1.conv",
"model.23.cv2.2.2",
"model.23.cv3.0.0.0.conv",
"model.23.cv3.0.0.1.conv",
"model.23.cv3.0.1.0.conv",
"model.23.cv3.0.1.1.conv",
"model.23.cv3.0.2",
"model.23.cv3.1.0.0.conv",
"model.23.cv3.1.0.1.conv",
"model.23.cv3.1.1.0.conv",
"model.23.cv3.1.1.1.conv",
"model.23.cv3.1.2",
"model.23.cv3.2.0.0.conv",
"model.23.cv3.2.0.1.conv",
"model.23.cv3.2.1.0.conv",
"model.23.cv3.2.1.1.conv",
"model.23.cv3.2.2",
"model.3.conv",
"model.4.cv1.conv",
"model.4.cv2.conv",
"model.4.m.0.cv1.conv",
"model.4.m.0.cv2.conv",
"model.5.conv",
"model.6.cv1.conv",
"model.6.cv2.conv",
"model.6.m.0.cv1.conv",
"model.6.m.0.cv2.conv",
"model.6.m.0.cv3.conv",
"model.6.m.0.m.0.cv1.conv",
"model.6.m.0.m.0.cv2.conv",
"model.6.m.0.m.1.cv1.conv",
"model.6.m.0.m.1.cv2.conv",
"model.7.conv",
"model.8.cv1.conv",
"model.8.cv2.conv",
"model.8.m.0.cv1.conv",
"model.8.m.0.cv2.conv",
"model.8.m.0.cv3.conv",
"model.8.m.0.m.0.cv1.conv",
"model.8.m.0.m.0.cv2.conv",
"model.8.m.0.m.1.cv1.conv",
"model.8.m.0.m.1.cv2.conv",
"model.9.cv1.conv",
"model.9.cv2.conv",
)
SPLIT_PROJECTION_CHANNEL_RANGES = (
("model.13.cv1.conv.weight", 0, 256),
("model.13.cv1.conv.weight", 256, 384),
("model.16.cv1.conv.weight", 0, 128),
("model.16.cv1.conv.weight", 128, 256),
)
SPLIT_PROJECTION_ZERO_BIAS_CHANNELS = (128, 64)
def _runtime_weight_names() -> tuple[str, ...]:
"""Derive every ordered runtime slot name from the fixed model topology."""
split_sources = {source for source, _begin, _end in SPLIT_PROJECTION_CHANNEL_RANGES}
names = [f"{path}.bias" for path in RUNTIME_CONVOLUTION_PATHS]
names.extend(
f"{path}.weight"
for path in RUNTIME_CONVOLUTION_PATHS
if f"{path}.weight" not in split_sources
)
names.extend(
f"{source}[input:{begin}:{end}]" for source, begin, end in SPLIT_PROJECTION_CHANNEL_RANGES
)
names.extend(
f"projection.zero_bias.{channels}" for channels in SPLIT_PROJECTION_ZERO_BIAS_CHANNELS
)
return tuple(sorted(names))
RUNTIME_WEIGHT_NAMES = _runtime_weight_names()
# Stable slot numbers are assigned by semantic name. The fixed topology above
# therefore determines the slot count and order used by both exporter and runtime.
RUNTIME_WEIGHT_SLOT_COUNT = len(RUNTIME_WEIGHT_NAMES)
_RUNTIME_TARGET = "linux-aarch64-raspberry-pi-5"
_RUNTIME_THREAD_COUNT = 4
def classification_width_for_classes(class_count: int) -> int:
"""Return the classification-branch width implemented by YOLO26n."""
if type(class_count) is not int or class_count <= 0:
raise ValueError(f"unsupported YOLO26n class count: {class_count}")
return max(_CLASSIFICATION_CHANNEL_FLOOR, class_count)
def detection_output_channels(class_count: int) -> int:
"""Return four box coordinates plus one confidence channel per class."""
if type(class_count) is not int or class_count <= 0:
raise ValueError(f"unsupported YOLO26n class count: {class_count}")
return BOX_COORDINATE_CHANNELS + class_count
class ConvolutionActivation(Enum):
"""Activation fused into or scheduled immediately after a convolution."""
IDENTITY = "identity"
SILU = "silu"
@dataclass(frozen=True)
class LearnedTensor:
"""One named checkpoint tensor copied out of Torch-owned storage."""
name: str
shape: tuple[int, ...]
dtype: str
data: bytes
def __post_init__(self) -> None:
element_bytes = {"float16": 2, "int64": 8}.get(self.dtype)
elements = 1
for extent in self.shape:
if type(extent) is not int or extent <= 0:
raise ValueError("invalid learned tensor shape")
elements *= extent
if not self.name or element_bytes is None or len(self.data) != elements * element_bytes:
raise ValueError("invalid learned tensor storage")
@dataclass(frozen=True)
class ConvolutionSpec:
"""Convolution geometry needed to validate and lay out its learned weights."""
path: str
input_channels: int
output_channels: int
kernel: tuple[int, int]
stride: tuple[int, int]
padding: tuple[int, int]
dilation: tuple[int, int]
groups: int
activation: ConvolutionActivation
def __post_init__(self) -> None:
if (
not self.path
or min(self.input_channels, self.output_channels, self.groups) <= 0
or self.input_channels % self.groups
or self.output_channels % self.groups
or any(value <= 0 for value in self.kernel + self.stride + self.dilation)
or any(value < 0 for value in self.padding)
):
raise ValueError("invalid validated convolution")
@dataclass(frozen=True)
class BatchNormFusion:
"""A BatchNorm wrapper whose statistics are folded into its convolution."""
convolution_path: str
epsilon: float
def __post_init__(self) -> None:
if not self.convolution_path or not math.isfinite(self.epsilon) or self.epsilon <= 0.0:
raise ValueError("invalid BatchNorm fusion contract")
@dataclass(frozen=True)
class AttentionSpec:
"""Attention dimensions reproduced by one of the fixed runtime graphs."""
path: str
heads: int
head_channels: int
key_channels: int
query_scale: float
def __post_init__(self) -> None:
if (
not self.path
or min(self.heads, self.head_channels, self.key_channels) <= 0
or self.head_channels % 2
or self.key_channels != self.head_channels // 2
or self.query_scale != self.key_channels**-0.5
):
raise ValueError("invalid validated attention")
@dataclass(frozen=True)
class ValidatedYolo26n:
"""Checkpoint data copied into the exact facts needed by later stages."""
learned_tensors: tuple[LearnedTensor, ...]
convolutions: tuple[ConvolutionSpec, ...]
batch_norm_fusions: tuple[BatchNormFusion, ...]
attention_blocks: tuple[AttentionSpec, ...]
class_names: tuple[str, ...]
classification_width: int
detection_strides: tuple[int, int, int]
def __post_init__(self) -> None:
if not self.class_names or any(not name for name in self.class_names):
raise ValueError("validated YOLO26n class names must be non-empty")
if self.classification_width != classification_width_for_classes(self.class_count):
raise ValueError("validated YOLO26n classification width changed")
if self.detection_strides != DETECTION_STRIDES:
raise ValueError("validated YOLO26n detection strides changed")
inventories = (
[row.name for row in self.learned_tensors],
[row.path for row in self.convolutions],
[row.convolution_path for row in self.batch_norm_fusions],
[row.path for row in self.attention_blocks],
)
if any(len(rows) != len(set(rows)) for rows in inventories):
raise ValueError("validated YOLO26n inventory contains duplicates")
@property
def class_count(self) -> int:
return len(self.class_names)
def convolution(self, path: str) -> ConvolutionSpec:
matches = [row for row in self.convolutions if row.path == path]
if len(matches) != 1:
raise ValueError(f"unknown validated convolution: {path}")
return matches[0]
def attention(self, path: str) -> AttentionSpec:
matches = [row for row in self.attention_blocks if row.path == path]
if len(matches) != 1:
raise ValueError(f"unknown validated attention: {path}")
return matches[0]
@dataclass(frozen=True)
class ModelResolution:
"""One validated square input size and its derived detector dimensions."""
resolution: int
def __post_init__(self) -> None:
if (
not isinstance(self.resolution, int)
or isinstance(self.resolution, bool)
or self.resolution not in SUPPORTED_RESOLUTIONS
):
raise ValueError(f"unsupported YOLO26n resolution: {self.resolution}")
@property
def input_shape(self) -> tuple[int, int, int, int]:
return (1, 3, self.resolution, self.resolution)
@property
def detection_positions(self) -> int:
extents = (self.resolution // stride for stride in DETECTION_STRIDES)
return sum(extent * extent for extent in extents)
def output_shape(self, class_count: int) -> tuple[int, int, int]:
"""Return the runtime output shape for a model's class inventory."""
return (1, detection_output_channels(class_count), self.detection_positions)
def target_manifest(self) -> JsonObject:
"""Describe the fixed runtime target paired with this model artifact."""
return {
"version": 1,
"target": _RUNTIME_TARGET,
"input_shape": list(self.input_shape),
"input_dtype": "fp32",
"output_dtype": "fp32",
"thread_count": _RUNTIME_THREAD_COUNT,
}
|