apocaliss92 commited on
Commit
ebeed67
·
verified ·
1 Parent(s): d9e4572

camstack-train 0.1.0: YOLOv9 root detector fine-tune (job.json in, result.json + bundle.tar out)

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. Dockerfile +23 -0
  2. LICENSE +666 -0
  3. README.md +96 -0
  4. camstack_train/__init__.py +9 -0
  5. camstack_train/__main__.py +7 -0
  6. camstack_train/_steps.py +101 -0
  7. camstack_train/_vendor/__init__.py +31 -0
  8. camstack_train/_vendor/camstack_eval/__init__.py +6 -0
  9. camstack_train/_vendor/camstack_eval/bootstrap.py +107 -0
  10. camstack_train/_vendor/camstack_eval/classes.py +40 -0
  11. camstack_train/_vendor/camstack_eval/compare.py +102 -0
  12. camstack_train/_vendor/camstack_eval/dataset.py +181 -0
  13. camstack_train/_vendor/camstack_eval/floors.py +79 -0
  14. camstack_train/_vendor/camstack_eval/metrics.py +273 -0
  15. camstack_train/_vendor/camstack_eval/pipeline.py +170 -0
  16. camstack_train/_vendor/camstack_eval/pseudo_gt.py +68 -0
  17. camstack_train/_vendor/pipeline_python/inference_pool.py +0 -0
  18. camstack_train/_vendor/pipeline_python/postprocessors/__init__.py +38 -0
  19. camstack_train/_vendor/pipeline_python/postprocessors/arcface.py +31 -0
  20. camstack_train/_vendor/pipeline_python/postprocessors/clip.py +35 -0
  21. camstack_train/_vendor/pipeline_python/postprocessors/ctc.py +406 -0
  22. camstack_train/_vendor/pipeline_python/postprocessors/plate_slots.py +180 -0
  23. camstack_train/_vendor/pipeline_python/postprocessors/rfdetr.py +161 -0
  24. camstack_train/_vendor/pipeline_python/postprocessors/saliency.py +90 -0
  25. camstack_train/_vendor/pipeline_python/postprocessors/scrfd.py +235 -0
  26. camstack_train/_vendor/pipeline_python/postprocessors/softmax.py +56 -0
  27. camstack_train/_vendor/pipeline_python/postprocessors/ssd.py +184 -0
  28. camstack_train/_vendor/pipeline_python/postprocessors/yamnet.py +41 -0
  29. camstack_train/_vendor/pipeline_python/postprocessors/yolo.py +364 -0
  30. camstack_train/_vendor/pipeline_python/postprocessors/yolo_seg.py +173 -0
  31. camstack_train/_vendor/pipeline_python/postprocessors/yolo_split_dfl.py +255 -0
  32. camstack_train/_vendor/pipeline_python/postprocessors/yolonas.py +67 -0
  33. camstack_train/_vendor/pipeline_python/postprocessors/yunet.py +275 -0
  34. camstack_train/cli.py +70 -0
  35. camstack_train/dataset.py +228 -0
  36. camstack_train/errors.py +34 -0
  37. camstack_train/evaluate.py +117 -0
  38. camstack_train/events.py +87 -0
  39. camstack_train/export.py +142 -0
  40. camstack_train/floors.py +82 -0
  41. camstack_train/manifest.py +166 -0
  42. camstack_train/replay.py +102 -0
  43. camstack_train/result_model.py +138 -0
  44. camstack_train/run.py +167 -0
  45. camstack_train/spec.py +173 -0
  46. camstack_train/split.py +101 -0
  47. camstack_train/train_det.py +131 -0
  48. pyproject.toml +50 -0
  49. schemas/job.schema.json +380 -0
  50. schemas/result.schema.json +756 -0
Dockerfile ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Tracked in camstack/server as hf/camstack-trainer/Dockerfile; edit it there, not on the Hub.
2
+ #
3
+ # The CamStack trainer image. Two builders read this file:
4
+ # - `docker build --platform linux/amd64 --build-arg TRAINER_REVISION=<sha> .` (any registry);
5
+ # - the hub's Modal provider, which builds it WITHOUT a build context:
6
+ # FROM becomes `images.fromRegistry(...)` and every other line one
7
+ # `dockerfileCommands` entry.
8
+ # So, load-bearing:
9
+ # - only FROM, ARG, ENV, RUN and WORKDIR — never COPY or ADD (there is no context);
10
+ # - one instruction per line, no backslash continuations (the hub splits on lines);
11
+ # - every pip version pinned; the trainer itself installed from this repository
12
+ # at TRAINER_REVISION (the hub passes the exact commit it was released with).
13
+ FROM pytorch/pytorch:2.13.0-cuda12.6-cudnn9-runtime
14
+ ARG TRAINER_REVISION=main
15
+ ENV PYTHONUNBUFFERED=1
16
+ ENV PIP_NO_CACHE_DIR=1
17
+ ENV TRAINER_REVISION=${TRAINER_REVISION}
18
+ ENV YOLO_CONFIG_DIR=/tmp/ultralytics
19
+ RUN apt-get update && apt-get install -y --no-install-recommends git libgl1 libglib2.0-0 && rm -rf /var/lib/apt/lists/*
20
+ RUN pip install "camstack-train[train,export] @ git+https://huggingface.co/camstack/camstack-trainer@${TRAINER_REVISION}"
21
+ RUN python -c "import camstack_train, ultralytics, openvino, nncf, coremltools; print('trainer', camstack_train.__version__)"
22
+ WORKDIR /app
23
+ CMD ["python", "-m", "camstack_train", "--help"]
LICENSE ADDED
@@ -0,0 +1,666 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Source: https://www.gnu.org/licenses/agpl-3.0.txt
2
+ Fetched: 2026-09-26 (verbatim below the line; nothing edited)
3
+ Applies to: Ultralytics weights: objectDetection/yolo26, objectDetection/yolov9, segmentation/yolo26-seg
4
+ Note: byte-identical to https://raw.githubusercontent.com/ultralytics/ultralytics/main/LICENSE, fetched the same day.
5
+ --------------------------------------------------------------------------------
6
+ GNU AFFERO GENERAL PUBLIC LICENSE
7
+ Version 3, 19 November 2007
8
+
9
+ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
10
+ Everyone is permitted to copy and distribute verbatim copies
11
+ of this license document, but changing it is not allowed.
12
+
13
+ Preamble
14
+
15
+ The GNU Affero General Public License is a free, copyleft license for
16
+ software and other kinds of works, specifically designed to ensure
17
+ cooperation with the community in the case of network server software.
18
+
19
+ The licenses for most software and other practical works are designed
20
+ to take away your freedom to share and change the works. By contrast,
21
+ our General Public Licenses are intended to guarantee your freedom to
22
+ share and change all versions of a program--to make sure it remains free
23
+ software for all its users.
24
+
25
+ When we speak of free software, we are referring to freedom, not
26
+ price. Our General Public Licenses are designed to make sure that you
27
+ have the freedom to distribute copies of free software (and charge for
28
+ them if you wish), that you receive source code or can get it if you
29
+ want it, that you can change the software or use pieces of it in new
30
+ free programs, and that you know you can do these things.
31
+
32
+ Developers that use our General Public Licenses protect your rights
33
+ with two steps: (1) assert copyright on the software, and (2) offer
34
+ you this License which gives you legal permission to copy, distribute
35
+ and/or modify the software.
36
+
37
+ A secondary benefit of defending all users' freedom is that
38
+ improvements made in alternate versions of the program, if they
39
+ receive widespread use, become available for other developers to
40
+ incorporate. Many developers of free software are heartened and
41
+ encouraged by the resulting cooperation. However, in the case of
42
+ software used on network servers, this result may fail to come about.
43
+ The GNU General Public License permits making a modified version and
44
+ letting the public access it on a server without ever releasing its
45
+ source code to the public.
46
+
47
+ The GNU Affero General Public License is designed specifically to
48
+ ensure that, in such cases, the modified source code becomes available
49
+ to the community. It requires the operator of a network server to
50
+ provide the source code of the modified version running there to the
51
+ users of that server. Therefore, public use of a modified version, on
52
+ a publicly accessible server, gives the public access to the source
53
+ code of the modified version.
54
+
55
+ An older license, called the Affero General Public License and
56
+ published by Affero, was designed to accomplish similar goals. This is
57
+ a different license, not a version of the Affero GPL, but Affero has
58
+ released a new version of the Affero GPL which permits relicensing under
59
+ this license.
60
+
61
+ The precise terms and conditions for copying, distribution and
62
+ modification follow.
63
+
64
+ TERMS AND CONDITIONS
65
+
66
+ 0. Definitions.
67
+
68
+ "This License" refers to version 3 of the GNU Affero General Public License.
69
+
70
+ "Copyright" also means copyright-like laws that apply to other kinds of
71
+ works, such as semiconductor masks.
72
+
73
+ "The Program" refers to any copyrightable work licensed under this
74
+ License. Each licensee is addressed as "you". "Licensees" and
75
+ "recipients" may be individuals or organizations.
76
+
77
+ To "modify" a work means to copy from or adapt all or part of the work
78
+ in a fashion requiring copyright permission, other than the making of an
79
+ exact copy. The resulting work is called a "modified version" of the
80
+ earlier work or a work "based on" the earlier work.
81
+
82
+ A "covered work" means either the unmodified Program or a work based
83
+ on the Program.
84
+
85
+ To "propagate" a work means to do anything with it that, without
86
+ permission, would make you directly or secondarily liable for
87
+ infringement under applicable copyright law, except executing it on a
88
+ computer or modifying a private copy. Propagation includes copying,
89
+ distribution (with or without modification), making available to the
90
+ public, and in some countries other activities as well.
91
+
92
+ To "convey" a work means any kind of propagation that enables other
93
+ parties to make or receive copies. Mere interaction with a user through
94
+ a computer network, with no transfer of a copy, is not conveying.
95
+
96
+ An interactive user interface displays "Appropriate Legal Notices"
97
+ to the extent that it includes a convenient and prominently visible
98
+ feature that (1) displays an appropriate copyright notice, and (2)
99
+ tells the user that there is no warranty for the work (except to the
100
+ extent that warranties are provided), that licensees may convey the
101
+ work under this License, and how to view a copy of this License. If
102
+ the interface presents a list of user commands or options, such as a
103
+ menu, a prominent item in the list meets this criterion.
104
+
105
+ 1. Source Code.
106
+
107
+ The "source code" for a work means the preferred form of the work
108
+ for making modifications to it. "Object code" means any non-source
109
+ form of a work.
110
+
111
+ A "Standard Interface" means an interface that either is an official
112
+ standard defined by a recognized standards body, or, in the case of
113
+ interfaces specified for a particular programming language, one that
114
+ is widely used among developers working in that language.
115
+
116
+ The "System Libraries" of an executable work include anything, other
117
+ than the work as a whole, that (a) is included in the normal form of
118
+ packaging a Major Component, but which is not part of that Major
119
+ Component, and (b) serves only to enable use of the work with that
120
+ Major Component, or to implement a Standard Interface for which an
121
+ implementation is available to the public in source code form. A
122
+ "Major Component", in this context, means a major essential component
123
+ (kernel, window system, and so on) of the specific operating system
124
+ (if any) on which the executable work runs, or a compiler used to
125
+ produce the work, or an object code interpreter used to run it.
126
+
127
+ The "Corresponding Source" for a work in object code form means all
128
+ the source code needed to generate, install, and (for an executable
129
+ work) run the object code and to modify the work, including scripts to
130
+ control those activities. However, it does not include the work's
131
+ System Libraries, or general-purpose tools or generally available free
132
+ programs which are used unmodified in performing those activities but
133
+ which are not part of the work. For example, Corresponding Source
134
+ includes interface definition files associated with source files for
135
+ the work, and the source code for shared libraries and dynamically
136
+ linked subprograms that the work is specifically designed to require,
137
+ such as by intimate data communication or control flow between those
138
+ subprograms and other parts of the work.
139
+
140
+ The Corresponding Source need not include anything that users
141
+ can regenerate automatically from other parts of the Corresponding
142
+ Source.
143
+
144
+ The Corresponding Source for a work in source code form is that
145
+ same work.
146
+
147
+ 2. Basic Permissions.
148
+
149
+ All rights granted under this License are granted for the term of
150
+ copyright on the Program, and are irrevocable provided the stated
151
+ conditions are met. This License explicitly affirms your unlimited
152
+ permission to run the unmodified Program. The output from running a
153
+ covered work is covered by this License only if the output, given its
154
+ content, constitutes a covered work. This License acknowledges your
155
+ rights of fair use or other equivalent, as provided by copyright law.
156
+
157
+ You may make, run and propagate covered works that you do not
158
+ convey, without conditions so long as your license otherwise remains
159
+ in force. You may convey covered works to others for the sole purpose
160
+ of having them make modifications exclusively for you, or provide you
161
+ with facilities for running those works, provided that you comply with
162
+ the terms of this License in conveying all material for which you do
163
+ not control copyright. Those thus making or running the covered works
164
+ for you must do so exclusively on your behalf, under your direction
165
+ and control, on terms that prohibit them from making any copies of
166
+ your copyrighted material outside their relationship with you.
167
+
168
+ Conveying under any other circumstances is permitted solely under
169
+ the conditions stated below. Sublicensing is not allowed; section 10
170
+ makes it unnecessary.
171
+
172
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
173
+
174
+ No covered work shall be deemed part of an effective technological
175
+ measure under any applicable law fulfilling obligations under article
176
+ 11 of the WIPO copyright treaty adopted on 20 December 1996, or
177
+ similar laws prohibiting or restricting circumvention of such
178
+ measures.
179
+
180
+ When you convey a covered work, you waive any legal power to forbid
181
+ circumvention of technological measures to the extent such circumvention
182
+ is effected by exercising rights under this License with respect to
183
+ the covered work, and you disclaim any intention to limit operation or
184
+ modification of the work as a means of enforcing, against the work's
185
+ users, your or third parties' legal rights to forbid circumvention of
186
+ technological measures.
187
+
188
+ 4. Conveying Verbatim Copies.
189
+
190
+ You may convey verbatim copies of the Program's source code as you
191
+ receive it, in any medium, provided that you conspicuously and
192
+ appropriately publish on each copy an appropriate copyright notice;
193
+ keep intact all notices stating that this License and any
194
+ non-permissive terms added in accord with section 7 apply to the code;
195
+ keep intact all notices of the absence of any warranty; and give all
196
+ recipients a copy of this License along with the Program.
197
+
198
+ You may charge any price or no price for each copy that you convey,
199
+ and you may offer support or warranty protection for a fee.
200
+
201
+ 5. Conveying Modified Source Versions.
202
+
203
+ You may convey a work based on the Program, or the modifications to
204
+ produce it from the Program, in the form of source code under the
205
+ terms of section 4, provided that you also meet all of these conditions:
206
+
207
+ a) The work must carry prominent notices stating that you modified
208
+ it, and giving a relevant date.
209
+
210
+ b) The work must carry prominent notices stating that it is
211
+ released under this License and any conditions added under section
212
+ 7. This requirement modifies the requirement in section 4 to
213
+ "keep intact all notices".
214
+
215
+ c) You must license the entire work, as a whole, under this
216
+ License to anyone who comes into possession of a copy. This
217
+ License will therefore apply, along with any applicable section 7
218
+ additional terms, to the whole of the work, and all its parts,
219
+ regardless of how they are packaged. This License gives no
220
+ permission to license the work in any other way, but it does not
221
+ invalidate such permission if you have separately received it.
222
+
223
+ d) If the work has interactive user interfaces, each must display
224
+ Appropriate Legal Notices; however, if the Program has interactive
225
+ interfaces that do not display Appropriate Legal Notices, your
226
+ work need not make them do so.
227
+
228
+ A compilation of a covered work with other separate and independent
229
+ works, which are not by their nature extensions of the covered work,
230
+ and which are not combined with it such as to form a larger program,
231
+ in or on a volume of a storage or distribution medium, is called an
232
+ "aggregate" if the compilation and its resulting copyright are not
233
+ used to limit the access or legal rights of the compilation's users
234
+ beyond what the individual works permit. Inclusion of a covered work
235
+ in an aggregate does not cause this License to apply to the other
236
+ parts of the aggregate.
237
+
238
+ 6. Conveying Non-Source Forms.
239
+
240
+ You may convey a covered work in object code form under the terms
241
+ of sections 4 and 5, provided that you also convey the
242
+ machine-readable Corresponding Source under the terms of this License,
243
+ in one of these ways:
244
+
245
+ a) Convey the object code in, or embodied in, a physical product
246
+ (including a physical distribution medium), accompanied by the
247
+ Corresponding Source fixed on a durable physical medium
248
+ customarily used for software interchange.
249
+
250
+ b) Convey the object code in, or embodied in, a physical product
251
+ (including a physical distribution medium), accompanied by a
252
+ written offer, valid for at least three years and valid for as
253
+ long as you offer spare parts or customer support for that product
254
+ model, to give anyone who possesses the object code either (1) a
255
+ copy of the Corresponding Source for all the software in the
256
+ product that is covered by this License, on a durable physical
257
+ medium customarily used for software interchange, for a price no
258
+ more than your reasonable cost of physically performing this
259
+ conveying of source, or (2) access to copy the
260
+ Corresponding Source from a network server at no charge.
261
+
262
+ c) Convey individual copies of the object code with a copy of the
263
+ written offer to provide the Corresponding Source. This
264
+ alternative is allowed only occasionally and noncommercially, and
265
+ only if you received the object code with such an offer, in accord
266
+ with subsection 6b.
267
+
268
+ d) Convey the object code by offering access from a designated
269
+ place (gratis or for a charge), and offer equivalent access to the
270
+ Corresponding Source in the same way through the same place at no
271
+ further charge. You need not require recipients to copy the
272
+ Corresponding Source along with the object code. If the place to
273
+ copy the object code is a network server, the Corresponding Source
274
+ may be on a different server (operated by you or a third party)
275
+ that supports equivalent copying facilities, provided you maintain
276
+ clear directions next to the object code saying where to find the
277
+ Corresponding Source. Regardless of what server hosts the
278
+ Corresponding Source, you remain obligated to ensure that it is
279
+ available for as long as needed to satisfy these requirements.
280
+
281
+ e) Convey the object code using peer-to-peer transmission, provided
282
+ you inform other peers where the object code and Corresponding
283
+ Source of the work are being offered to the general public at no
284
+ charge under subsection 6d.
285
+
286
+ A separable portion of the object code, whose source code is excluded
287
+ from the Corresponding Source as a System Library, need not be
288
+ included in conveying the object code work.
289
+
290
+ A "User Product" is either (1) a "consumer product", which means any
291
+ tangible personal property which is normally used for personal, family,
292
+ or household purposes, or (2) anything designed or sold for incorporation
293
+ into a dwelling. In determining whether a product is a consumer product,
294
+ doubtful cases shall be resolved in favor of coverage. For a particular
295
+ product received by a particular user, "normally used" refers to a
296
+ typical or common use of that class of product, regardless of the status
297
+ of the particular user or of the way in which the particular user
298
+ actually uses, or expects or is expected to use, the product. A product
299
+ is a consumer product regardless of whether the product has substantial
300
+ commercial, industrial or non-consumer uses, unless such uses represent
301
+ the only significant mode of use of the product.
302
+
303
+ "Installation Information" for a User Product means any methods,
304
+ procedures, authorization keys, or other information required to install
305
+ and execute modified versions of a covered work in that User Product from
306
+ a modified version of its Corresponding Source. The information must
307
+ suffice to ensure that the continued functioning of the modified object
308
+ code is in no case prevented or interfered with solely because
309
+ modification has been made.
310
+
311
+ If you convey an object code work under this section in, or with, or
312
+ specifically for use in, a User Product, and the conveying occurs as
313
+ part of a transaction in which the right of possession and use of the
314
+ User Product is transferred to the recipient in perpetuity or for a
315
+ fixed term (regardless of how the transaction is characterized), the
316
+ Corresponding Source conveyed under this section must be accompanied
317
+ by the Installation Information. But this requirement does not apply
318
+ if neither you nor any third party retains the ability to install
319
+ modified object code on the User Product (for example, the work has
320
+ been installed in ROM).
321
+
322
+ The requirement to provide Installation Information does not include a
323
+ requirement to continue to provide support service, warranty, or updates
324
+ for a work that has been modified or installed by the recipient, or for
325
+ the User Product in which it has been modified or installed. Access to a
326
+ network may be denied when the modification itself materially and
327
+ adversely affects the operation of the network or violates the rules and
328
+ protocols for communication across the network.
329
+
330
+ Corresponding Source conveyed, and Installation Information provided,
331
+ in accord with this section must be in a format that is publicly
332
+ documented (and with an implementation available to the public in
333
+ source code form), and must require no special password or key for
334
+ unpacking, reading or copying.
335
+
336
+ 7. Additional Terms.
337
+
338
+ "Additional permissions" are terms that supplement the terms of this
339
+ License by making exceptions from one or more of its conditions.
340
+ Additional permissions that are applicable to the entire Program shall
341
+ be treated as though they were included in this License, to the extent
342
+ that they are valid under applicable law. If additional permissions
343
+ apply only to part of the Program, that part may be used separately
344
+ under those permissions, but the entire Program remains governed by
345
+ this License without regard to the additional permissions.
346
+
347
+ When you convey a copy of a covered work, you may at your option
348
+ remove any additional permissions from that copy, or from any part of
349
+ it. (Additional permissions may be written to require their own
350
+ removal in certain cases when you modify the work.) You may place
351
+ additional permissions on material, added by you to a covered work,
352
+ for which you have or can give appropriate copyright permission.
353
+
354
+ Notwithstanding any other provision of this License, for material you
355
+ add to a covered work, you may (if authorized by the copyright holders of
356
+ that material) supplement the terms of this License with terms:
357
+
358
+ a) Disclaiming warranty or limiting liability differently from the
359
+ terms of sections 15 and 16 of this License; or
360
+
361
+ b) Requiring preservation of specified reasonable legal notices or
362
+ author attributions in that material or in the Appropriate Legal
363
+ Notices displayed by works containing it; or
364
+
365
+ c) Prohibiting misrepresentation of the origin of that material, or
366
+ requiring that modified versions of such material be marked in
367
+ reasonable ways as different from the original version; or
368
+
369
+ d) Limiting the use for publicity purposes of names of licensors or
370
+ authors of the material; or
371
+
372
+ e) Declining to grant rights under trademark law for use of some
373
+ trade names, trademarks, or service marks; or
374
+
375
+ f) Requiring indemnification of licensors and authors of that
376
+ material by anyone who conveys the material (or modified versions of
377
+ it) with contractual assumptions of liability to the recipient, for
378
+ any liability that these contractual assumptions directly impose on
379
+ those licensors and authors.
380
+
381
+ All other non-permissive additional terms are considered "further
382
+ restrictions" within the meaning of section 10. If the Program as you
383
+ received it, or any part of it, contains a notice stating that it is
384
+ governed by this License along with a term that is a further
385
+ restriction, you may remove that term. If a license document contains
386
+ a further restriction but permits relicensing or conveying under this
387
+ License, you may add to a covered work material governed by the terms
388
+ of that license document, provided that the further restriction does
389
+ not survive such relicensing or conveying.
390
+
391
+ If you add terms to a covered work in accord with this section, you
392
+ must place, in the relevant source files, a statement of the
393
+ additional terms that apply to those files, or a notice indicating
394
+ where to find the applicable terms.
395
+
396
+ Additional terms, permissive or non-permissive, may be stated in the
397
+ form of a separately written license, or stated as exceptions;
398
+ the above requirements apply either way.
399
+
400
+ 8. Termination.
401
+
402
+ You may not propagate or modify a covered work except as expressly
403
+ provided under this License. Any attempt otherwise to propagate or
404
+ modify it is void, and will automatically terminate your rights under
405
+ this License (including any patent licenses granted under the third
406
+ paragraph of section 11).
407
+
408
+ However, if you cease all violation of this License, then your
409
+ license from a particular copyright holder is reinstated (a)
410
+ provisionally, unless and until the copyright holder explicitly and
411
+ finally terminates your license, and (b) permanently, if the copyright
412
+ holder fails to notify you of the violation by some reasonable means
413
+ prior to 60 days after the cessation.
414
+
415
+ Moreover, your license from a particular copyright holder is
416
+ reinstated permanently if the copyright holder notifies you of the
417
+ violation by some reasonable means, this is the first time you have
418
+ received notice of violation of this License (for any work) from that
419
+ copyright holder, and you cure the violation prior to 30 days after
420
+ your receipt of the notice.
421
+
422
+ Termination of your rights under this section does not terminate the
423
+ licenses of parties who have received copies or rights from you under
424
+ this License. If your rights have been terminated and not permanently
425
+ reinstated, you do not qualify to receive new licenses for the same
426
+ material under section 10.
427
+
428
+ 9. Acceptance Not Required for Having Copies.
429
+
430
+ You are not required to accept this License in order to receive or
431
+ run a copy of the Program. Ancillary propagation of a covered work
432
+ occurring solely as a consequence of using peer-to-peer transmission
433
+ to receive a copy likewise does not require acceptance. However,
434
+ nothing other than this License grants you permission to propagate or
435
+ modify any covered work. These actions infringe copyright if you do
436
+ not accept this License. Therefore, by modifying or propagating a
437
+ covered work, you indicate your acceptance of this License to do so.
438
+
439
+ 10. Automatic Licensing of Downstream Recipients.
440
+
441
+ Each time you convey a covered work, the recipient automatically
442
+ receives a license from the original licensors, to run, modify and
443
+ propagate that work, subject to this License. You are not responsible
444
+ for enforcing compliance by third parties with this License.
445
+
446
+ An "entity transaction" is a transaction transferring control of an
447
+ organization, or substantially all assets of one, or subdividing an
448
+ organization, or merging organizations. If propagation of a covered
449
+ work results from an entity transaction, each party to that
450
+ transaction who receives a copy of the work also receives whatever
451
+ licenses to the work the party's predecessor in interest had or could
452
+ give under the previous paragraph, plus a right to possession of the
453
+ Corresponding Source of the work from the predecessor in interest, if
454
+ the predecessor has it or can get it with reasonable efforts.
455
+
456
+ You may not impose any further restrictions on the exercise of the
457
+ rights granted or affirmed under this License. For example, you may
458
+ not impose a license fee, royalty, or other charge for exercise of
459
+ rights granted under this License, and you may not initiate litigation
460
+ (including a cross-claim or counterclaim in a lawsuit) alleging that
461
+ any patent claim is infringed by making, using, selling, offering for
462
+ sale, or importing the Program or any portion of it.
463
+
464
+ 11. Patents.
465
+
466
+ A "contributor" is a copyright holder who authorizes use under this
467
+ License of the Program or a work on which the Program is based. The
468
+ work thus licensed is called the contributor's "contributor version".
469
+
470
+ A contributor's "essential patent claims" are all patent claims
471
+ owned or controlled by the contributor, whether already acquired or
472
+ hereafter acquired, that would be infringed by some manner, permitted
473
+ by this License, of making, using, or selling its contributor version,
474
+ but do not include claims that would be infringed only as a
475
+ consequence of further modification of the contributor version. For
476
+ purposes of this definition, "control" includes the right to grant
477
+ patent sublicenses in a manner consistent with the requirements of
478
+ this License.
479
+
480
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
481
+ patent license under the contributor's essential patent claims, to
482
+ make, use, sell, offer for sale, import and otherwise run, modify and
483
+ propagate the contents of its contributor version.
484
+
485
+ In the following three paragraphs, a "patent license" is any express
486
+ agreement or commitment, however denominated, not to enforce a patent
487
+ (such as an express permission to practice a patent or covenant not to
488
+ sue for patent infringement). To "grant" such a patent license to a
489
+ party means to make such an agreement or commitment not to enforce a
490
+ patent against the party.
491
+
492
+ If you convey a covered work, knowingly relying on a patent license,
493
+ and the Corresponding Source of the work is not available for anyone
494
+ to copy, free of charge and under the terms of this License, through a
495
+ publicly available network server or other readily accessible means,
496
+ then you must either (1) cause the Corresponding Source to be so
497
+ available, or (2) arrange to deprive yourself of the benefit of the
498
+ patent license for this particular work, or (3) arrange, in a manner
499
+ consistent with the requirements of this License, to extend the patent
500
+ license to downstream recipients. "Knowingly relying" means you have
501
+ actual knowledge that, but for the patent license, your conveying the
502
+ covered work in a country, or your recipient's use of the covered work
503
+ in a country, would infringe one or more identifiable patents in that
504
+ country that you have reason to believe are valid.
505
+
506
+ If, pursuant to or in connection with a single transaction or
507
+ arrangement, you convey, or propagate by procuring conveyance of, a
508
+ covered work, and grant a patent license to some of the parties
509
+ receiving the covered work authorizing them to use, propagate, modify
510
+ or convey a specific copy of the covered work, then the patent license
511
+ you grant is automatically extended to all recipients of the covered
512
+ work and works based on it.
513
+
514
+ A patent license is "discriminatory" if it does not include within
515
+ the scope of its coverage, prohibits the exercise of, or is
516
+ conditioned on the non-exercise of one or more of the rights that are
517
+ specifically granted under this License. You may not convey a covered
518
+ work if you are a party to an arrangement with a third party that is
519
+ in the business of distributing software, under which you make payment
520
+ to the third party based on the extent of your activity of conveying
521
+ the work, and under which the third party grants, to any of the
522
+ parties who would receive the covered work from you, a discriminatory
523
+ patent license (a) in connection with copies of the covered work
524
+ conveyed by you (or copies made from those copies), or (b) primarily
525
+ for and in connection with specific products or compilations that
526
+ contain the covered work, unless you entered into that arrangement,
527
+ or that patent license was granted, prior to 28 March 2007.
528
+
529
+ Nothing in this License shall be construed as excluding or limiting
530
+ any implied license or other defenses to infringement that may
531
+ otherwise be available to you under applicable patent law.
532
+
533
+ 12. No Surrender of Others' Freedom.
534
+
535
+ If conditions are imposed on you (whether by court order, agreement or
536
+ otherwise) that contradict the conditions of this License, they do not
537
+ excuse you from the conditions of this License. If you cannot convey a
538
+ covered work so as to satisfy simultaneously your obligations under this
539
+ License and any other pertinent obligations, then as a consequence you may
540
+ not convey it at all. For example, if you agree to terms that obligate you
541
+ to collect a royalty for further conveying from those to whom you convey
542
+ the Program, the only way you could satisfy both those terms and this
543
+ License would be to refrain entirely from conveying the Program.
544
+
545
+ 13. Remote Network Interaction; Use with the GNU General Public License.
546
+
547
+ Notwithstanding any other provision of this License, if you modify the
548
+ Program, your modified version must prominently offer all users
549
+ interacting with it remotely through a computer network (if your version
550
+ supports such interaction) an opportunity to receive the Corresponding
551
+ Source of your version by providing access to the Corresponding Source
552
+ from a network server at no charge, through some standard or customary
553
+ means of facilitating copying of software. This Corresponding Source
554
+ shall include the Corresponding Source for any work covered by version 3
555
+ of the GNU General Public License that is incorporated pursuant to the
556
+ following paragraph.
557
+
558
+ Notwithstanding any other provision of this License, you have
559
+ permission to link or combine any covered work with a work licensed
560
+ under version 3 of the GNU General Public License into a single
561
+ combined work, and to convey the resulting work. The terms of this
562
+ License will continue to apply to the part which is the covered work,
563
+ but the work with which it is combined will remain governed by version
564
+ 3 of the GNU General Public License.
565
+
566
+ 14. Revised Versions of this License.
567
+
568
+ The Free Software Foundation may publish revised and/or new versions of
569
+ the GNU Affero General Public License from time to time. Such new versions
570
+ will be similar in spirit to the present version, but may differ in detail to
571
+ address new problems or concerns.
572
+
573
+ Each version is given a distinguishing version number. If the
574
+ Program specifies that a certain numbered version of the GNU Affero General
575
+ Public License "or any later version" applies to it, you have the
576
+ option of following the terms and conditions either of that numbered
577
+ version or of any later version published by the Free Software
578
+ Foundation. If the Program does not specify a version number of the
579
+ GNU Affero General Public License, you may choose any version ever published
580
+ by the Free Software Foundation.
581
+
582
+ If the Program specifies that a proxy can decide which future
583
+ versions of the GNU Affero General Public License can be used, that proxy's
584
+ public statement of acceptance of a version permanently authorizes you
585
+ to choose that version for the Program.
586
+
587
+ Later license versions may give you additional or different
588
+ permissions. However, no additional obligations are imposed on any
589
+ author or copyright holder as a result of your choosing to follow a
590
+ later version.
591
+
592
+ 15. Disclaimer of Warranty.
593
+
594
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
595
+ APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
596
+ HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
597
+ OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
598
+ THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
599
+ PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
600
+ IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
601
+ ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
602
+
603
+ 16. Limitation of Liability.
604
+
605
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
606
+ WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
607
+ THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
608
+ GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
609
+ USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
610
+ DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
611
+ PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
612
+ EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
613
+ SUCH DAMAGES.
614
+
615
+ 17. Interpretation of Sections 15 and 16.
616
+
617
+ If the disclaimer of warranty and limitation of liability provided
618
+ above cannot be given local legal effect according to their terms,
619
+ reviewing courts shall apply local law that most closely approximates
620
+ an absolute waiver of all civil liability in connection with the
621
+ Program, unless a warranty or assumption of liability accompanies a
622
+ copy of the Program in return for a fee.
623
+
624
+ END OF TERMS AND CONDITIONS
625
+
626
+ How to Apply These Terms to Your New Programs
627
+
628
+ If you develop a new program, and you want it to be of the greatest
629
+ possible use to the public, the best way to achieve this is to make it
630
+ free software which everyone can redistribute and change under these terms.
631
+
632
+ To do so, attach the following notices to the program. It is safest
633
+ to attach them to the start of each source file to most effectively
634
+ state the exclusion of warranty; and each file should have at least
635
+ the "copyright" line and a pointer to where the full notice is found.
636
+
637
+ <one line to give the program's name and a brief idea of what it does.>
638
+ Copyright (C) <year> <name of author>
639
+
640
+ This program is free software: you can redistribute it and/or modify
641
+ it under the terms of the GNU Affero General Public License as published by
642
+ the Free Software Foundation, either version 3 of the License, or
643
+ (at your option) any later version.
644
+
645
+ This program is distributed in the hope that it will be useful,
646
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
647
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
648
+ GNU Affero General Public License for more details.
649
+
650
+ You should have received a copy of the GNU Affero General Public License
651
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
652
+
653
+ Also add information on how to contact you by electronic and paper mail.
654
+
655
+ If your software can interact with users remotely through a computer
656
+ network, you should also make sure that it provides a way for users to
657
+ get its source. For example, if your program is a web application, its
658
+ interface could display a "Source" link that leads users to an archive
659
+ of the code. There are many ways you could offer source, and different
660
+ solutions will be better for different programs; see section 13 for the
661
+ specific requirements.
662
+
663
+ You should also get your employer (if you work as a programmer) or school,
664
+ if any, to sign a "copyright disclaimer" for the program, if necessary.
665
+ For more information on this, and how to apply and follow the GNU AGPL, see
666
+ <https://www.gnu.org/licenses/>.
README.md ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: agpl-3.0
3
+ tags:
4
+ - object-detection
5
+ - yolov9
6
+ - training
7
+ - camstack
8
+ library_name: ultralytics
9
+ ---
10
+
11
+ <!-- Tracked in camstack/server as hf/camstack-trainer/; edit it there, not on the Hub. The vendored code under camstack_train/_vendor/ is a byte-identical copy of camstack/server sources, guarded by scripts/check-trainer-vendor.ts. -->
12
+
13
+ # CamStack trainer
14
+
15
+ The container a [CamStack](https://camstack-docs.zentik.app) hub runs on a
16
+ cloud GPU (Modal first) to fine-tune its YOLOv9 root detector on the operator's
17
+ own annotated frames. It evaluates the result against the model the cameras
18
+ run today, proposes per-class confidence floors, and exports every format a
19
+ CamStack node runs (ONNX, OpenVINO FP16 and INT8, Core ML FP16).
20
+
21
+ **Licence: AGPL-3.0.** It imports Ultralytics (AGPL-3.0), and a model it
22
+ fine-tunes is an Ultralytics derivative. The hub never imports this package: it
23
+ talks to the container through files and a process boundary only. This
24
+ repository, Dockerfile included, is the Corresponding Source of every image
25
+ built from it.
26
+
27
+ ## The contract
28
+
29
+ In:
30
+
31
+ - `/data/job.json` — schema `camstack.training/v1`, every field filled by the
32
+ hub ([`schemas/job.schema.json`](schemas/job.schema.json)). An unknown schema
33
+ exits 64.
34
+ - `/data/dataset.tar` — the hub's annotated export
35
+ (`camstack.retrain-export/v2|v3`), verified against `dataset.sha256`.
36
+ - `/cache` — a persistent volume: base weights, the COCO replay slice, the
37
+ baseline ONNX.
38
+
39
+ Out, in `/data/out`:
40
+
41
+ - `bundle.tar` — every artefact under its flat catalog name:
42
+ `<id>.pt`, `<id>.onnx`, `<id>-fp16.xml/.bin`, `<id>-int8.xml/.bin`,
43
+ `<id>.mlpackage/`.
44
+ - `result.json` — schema `camstack.training-result/v1`
45
+ ([`schemas/result.schema.json`](schemas/result.schema.json)): the dataset
46
+ split, training summary, baseline vs candidate evaluation, proposed floors,
47
+ the INT8 gate, one row per bundled file with its sha256, and a
48
+ `catalogDraft` (a `ModelCatalogEntry` per variant, relative URLs, revisions).
49
+ Written on failure too.
50
+
51
+ Progress: one `CAMSTACK_EVENT {json}` line per event on stdout — `stage`,
52
+ `epoch`, `metric`, `artifact`, `error`, and a final `done` carrying the sha256
53
+ of `result.json`. Every event has `v: 1` and `ts` (epoch ms).
54
+
55
+ Exit codes: 0 ok · 64 bad job · 65 dataset unusable · 70 internal.
56
+
57
+ ## What it does
58
+
59
+ 1. **Prepare** — verify the archive, convert it to a YOLO layout (COCO-80 ids;
60
+ a vehicle/animal box needs a COCO `label` or its frame is left out of
61
+ training; `model_error` boxes are learned as background), and split it by
62
+ whole cameras or whole days, never by random frame.
63
+ 2. **Replay** — mix a pinned slice of COCO val2017 back in so the 80-class head
64
+ is not forgotten.
65
+ 3. **Train** — Ultralytics `YOLO(<base>.pt).train(...)` with the job's
66
+ epochs / batch / lr0 / freeze / patience / seed.
67
+ 4. **Export** — ONNX (static, opset 13), OpenVINO FP16, OpenVINO INT8
68
+ calibrated on the user's own train frames letterboxed exactly like the
69
+ runtime, Core ML FP16. A failing optional format is dropped with its
70
+ reason; ONNX failing fails the job.
71
+ 5. **Evaluate** — the CamStack evaluation harness (vendored from
72
+ `scripts/eval/`) runs the baseline and the candidate with the inference
73
+ pool's own preprocess and decode on the complete holdout frames, and the
74
+ D739 rule proposes floors. INT8 is dropped when its mAP50 falls more than
75
+ `int8Gate.maxMap50Drop` below FP16.
76
+ 6. **Package** — `bundle.tar` + `result.json`.
77
+
78
+ Logs carry counts only — never a frame path or a camera name.
79
+
80
+ ## Running it yourself
81
+
82
+ ```bash
83
+ docker build --platform linux/amd64 --build-arg TRAINER_REVISION=main -t camstack-trainer .
84
+ docker run --gpus all -v $PWD/data:/data -v $PWD/cache:/cache camstack-trainer \
85
+ python -m camstack_train run --job /data/job.json --dataset /data/dataset.tar --out /data/out --cache /cache
86
+ ```
87
+
88
+ Without Docker (`pip install ".[train,export]"` into an environment that has
89
+ torch), the same command runs as is. To only convert an export for training by
90
+ hand:
91
+
92
+ ```bash
93
+ python -m camstack_train dataset --export camstack-retrain.tar --out ./yolo-dataset
94
+ ```
95
+
96
+ Tests (no torch, Ultralytics or OpenVINO needed): `pip install ".[test]" && pytest`.
camstack_train/__init__.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ """CamStack trainer: fine-tune a root detector on a hub's annotated export.
2
+
3
+ The hub never imports this package. It talks to it through files only:
4
+ ``job.json`` + ``dataset.tar`` in, ``bundle.tar`` + ``result.json`` out, and
5
+ ``CAMSTACK_EVENT`` lines on stdout. Ultralytics (AGPL-3.0) is imported only by
6
+ ``train_det`` and the export steps, inside this image.
7
+ """
8
+
9
+ __version__ = "0.1.0"
camstack_train/__main__.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ sys.exit(main())
camstack_train/_steps.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The heavy export steps, each run in its own child process.
2
+
3
+ ``python -m camstack_train._steps <step> '<json args>'``. A crash inside
4
+ coremltools, NNCF or a converter takes down that child only; the parent
5
+ records the step as failed (``export.SubprocessToolchain``). The recipes mirror
6
+ ``scripts/build-camstack-models.py`` / ``convert_lib.py`` in camstack/server
7
+ (the yolov9 recipe: static ONNX at opset 13, ``nms=False``, batch 1; CoreML
8
+ ``half=True`` — FP16, D743; OpenVINO from the ONNX via ``ov.convert_model``,
9
+ never the Ultralytics OV exporter), and are not imported from them.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import shutil
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ #: Same bound as convert_lib / the inference pool (CAMSTACK_OV_COMPILATION_THREADS).
19
+ OV_COMPILATION_THREADS = 4
20
+ ONNX_OPSET = 13
21
+
22
+
23
+ def _move(src: Path, dest: Path) -> None:
24
+ if dest.exists():
25
+ shutil.rmtree(dest) if dest.is_dir() else dest.unlink()
26
+ dest.parent.mkdir(parents=True, exist_ok=True)
27
+ shutil.move(str(src), str(dest))
28
+
29
+
30
+ def step_onnx(pt: str, out: str, imgsz: int) -> None:
31
+ from ultralytics import YOLO
32
+
33
+ path = YOLO(pt).export(
34
+ format="onnx", imgsz=imgsz, dynamic=False, simplify=True,
35
+ opset=ONNX_OPSET, nms=False, batch=1, half=False,
36
+ )
37
+ _move(Path(path), Path(out))
38
+
39
+
40
+ def step_coreml(pt: str, out: str, imgsz: int) -> None:
41
+ from ultralytics import YOLO
42
+
43
+ path = YOLO(pt).export(format="coreml", imgsz=imgsz, dynamic=False, batch=1, half=True, nms=False)
44
+ _move(Path(path), Path(out))
45
+
46
+
47
+ def _validate_ir(xml: str) -> None:
48
+ import openvino as ov
49
+
50
+ core = ov.Core()
51
+ compiled = core.compile_model(core.read_model(xml), "CPU", {"COMPILATION_NUM_THREADS": OV_COMPILATION_THREADS})
52
+ for port in list(compiled.inputs) + list(compiled.outputs):
53
+ if not port.get_any_name():
54
+ raise ValueError("the IR has an unnamed tensor")
55
+
56
+
57
+ def step_openvino(onnx: str, xml: str, fp16: bool) -> None:
58
+ import openvino as ov
59
+
60
+ Path(xml).parent.mkdir(parents=True, exist_ok=True)
61
+ ov.save_model(ov.convert_model(onnx), xml, compress_to_fp16=fp16)
62
+ _validate_ir(xml)
63
+
64
+
65
+ def step_openvino_int8(onnx: str, xml: str, calib: list[str], imgsz: int) -> None:
66
+ import nncf
67
+ import numpy as np
68
+ import openvino as ov
69
+
70
+ from ._vendor import ensure_pipeline_on_path
71
+
72
+ ensure_pipeline_on_path()
73
+ import inference_pool # the pool's own decode + letterbox (grey 114, /255)
74
+
75
+ def preprocess(path: str):
76
+ arr, _scale, _pad = inference_pool.letterbox(inference_pool.decode_jpeg(Path(path).read_bytes()), imgsz)
77
+ return arr.transpose(2, 0, 1)[np.newaxis].astype(np.float32)
78
+
79
+ dataset = nncf.Dataset(calib, preprocess)
80
+ quantized = nncf.quantize(ov.convert_model(onnx), dataset, subset_size=len(calib))
81
+ Path(xml).parent.mkdir(parents=True, exist_ok=True)
82
+ ov.save_model(quantized, xml)
83
+ _validate_ir(xml)
84
+
85
+
86
+ STEPS = {
87
+ "onnx": lambda a: step_onnx(a["pt"], a["out"], a["imgsz"]),
88
+ "coreml": lambda a: step_coreml(a["pt"], a["out"], a["imgsz"]),
89
+ "openvino": lambda a: step_openvino(a["onnx"], a["xml"], a["fp16"]),
90
+ "openvino-int8": lambda a: step_openvino_int8(a["onnx"], a["xml"], a["calib"], a["imgsz"]),
91
+ }
92
+
93
+
94
+ def main(argv: list[str]) -> int:
95
+ step, payload = argv[0], json.loads(argv[1])
96
+ STEPS[step](payload)
97
+ return 0
98
+
99
+
100
+ if __name__ == "__main__":
101
+ sys.exit(main(sys.argv[1:]))
camstack_train/_vendor/__init__.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Byte-identical copies of CamStack code the trainer reuses, never edited here.
2
+
3
+ - ``camstack_eval/`` is ``scripts/eval/camstack_eval/`` (the P5 evaluation
4
+ harness): the letterbox + decode + metrics + D739 floor rule the hub's own
5
+ measurements use.
6
+ - ``pipeline_python/`` is ``packages/addon-pipeline/python/`` (the inference
7
+ pool and its postprocessors, tests excluded): the harness imports the pool's
8
+ preprocess and decode from it, so the trainer evaluates exactly as a node runs.
9
+
10
+ ``scripts/check-trainer-vendor.ts`` in camstack/server fails when either copy
11
+ drifts from its source. Change the source, then re-copy.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ #: The folder holding ``inference_pool.py`` and ``postprocessors/``.
19
+ PIPELINE_PYTHON = Path(__file__).resolve().parent / "pipeline_python"
20
+
21
+
22
+ def ensure_pipeline_on_path() -> None:
23
+ """Put the vendored pool first on ``sys.path``.
24
+
25
+ The harness's ``pipeline._pipeline()`` imports ``inference_pool`` and
26
+ ``postprocessors`` as top-level modules from the repository path; with this
27
+ folder already on the path those imports resolve to the vendored copy.
28
+ """
29
+ entry = str(PIPELINE_PYTHON)
30
+ if entry not in sys.path:
31
+ sys.path.insert(0, entry)
camstack_train/_vendor/camstack_eval/__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ """CamStack detector evaluation harness (cloud-training P5).
2
+
3
+ Pipeline-identical preprocess and decode for YOLO ONNX detectors, P/R/AP50 per
4
+ macro class, camera, day/night and small subjects, a baseline vs candidate
5
+ comparison on a holdout, and the D739 floor rule. See `scripts/eval/evaluate.py`.
6
+ """
camstack_train/_vendor/camstack_eval/bootstrap.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Paired bootstrap of an AP difference between two models on one set.
2
+
3
+ Both models are scored on the SAME resampled frames (frames drawn with
4
+ replacement, ``rounds`` times), so what varies is the set, not the pairing:
5
+ the 95 % interval of ``AP(candidate) - AP(baseline)`` is the noise band a
6
+ "no regression" verdict is read against. Matching is per frame, so it is done
7
+ once (`metrics.frame_matches`) and a resample only re-weights each frame's
8
+ detections by how often the frame was drawn — the same AP as concatenating the
9
+ drawn frames (`metrics.ap_of_sample`), computed for every round at once.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+ from typing import Optional, Sequence
15
+
16
+ import numpy as np
17
+
18
+ from .metrics import COCO_IOUS, FrameMatches
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class _Flat:
23
+ """One model's matches of one class at one IoU, score-sorted."""
24
+
25
+ frame: np.ndarray # frame index per detection, highest score first
26
+ tp: np.ndarray # bool per detection
27
+ positives: np.ndarray # ground-truth count per frame
28
+
29
+
30
+ def _flatten(matches: Sequence[FrameMatches]) -> _Flat:
31
+ scores, tps, frames = [], [], []
32
+ for i, m in enumerate(matches):
33
+ scores.extend(m.scores)
34
+ tps.extend(m.true_positive)
35
+ frames.extend([i] * len(m.scores))
36
+ order = np.argsort(-np.asarray(scores, dtype=np.float64), kind="stable")
37
+ return _Flat(
38
+ frame=np.asarray(frames, dtype=np.int64)[order],
39
+ tp=np.asarray(tps, dtype=bool)[order],
40
+ positives=np.asarray([m.positives for m in matches], dtype=np.float64),
41
+ )
42
+
43
+
44
+ def weighted_ap(flat: _Flat, weights: np.ndarray) -> np.ndarray:
45
+ """AP per round; ``weights`` is ``[rounds, frames]`` (draw counts)."""
46
+ total = weights @ flat.positives
47
+ if flat.frame.size == 0:
48
+ return np.where(total > 0, 0.0, np.nan)
49
+ w = weights[:, flat.frame]
50
+ ctp = np.cumsum(w * flat.tp, axis=1)
51
+ cfp = np.cumsum(w * ~flat.tp, axis=1)
52
+ denom = ctp + cfp
53
+ precision = np.divide(ctp, denom, out=np.zeros_like(ctp), where=denom > 0)
54
+ envelope = np.maximum.accumulate(precision[:, ::-1], axis=1)[:, ::-1]
55
+ gained = (w * flat.tp * envelope).sum(axis=1)
56
+ return np.divide(gained, total, out=np.full_like(total, np.nan), where=total > 0)
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class Delta:
61
+ """``candidate - baseline`` on the full set and its 95 % bootstrap interval."""
62
+
63
+ value: float
64
+ low: float
65
+ high: float
66
+
67
+ @property
68
+ def significant_drop(self) -> bool:
69
+ return self.high < 0
70
+
71
+ @property
72
+ def significant_gain(self) -> bool:
73
+ return self.low > 0
74
+
75
+ def as_dict(self) -> dict:
76
+ return {"delta": round(self.value, 4), "ci95": [round(self.low, 4), round(self.high, 4)]}
77
+
78
+
79
+ def draw_weights(frames: int, rounds: int, seed: int = 0) -> np.ndarray:
80
+ """``[rounds + 1, frames]`` draw counts; row 0 is the full set (all ones)."""
81
+ rng = np.random.default_rng(seed)
82
+ draws = rng.integers(0, frames, size=(rounds, frames))
83
+ counts = np.zeros((rounds, frames), dtype=np.float64)
84
+ np.add.at(counts, (np.arange(rounds)[:, None], draws), 1.0)
85
+ return np.vstack([np.ones((1, frames)), counts])
86
+
87
+
88
+ def ap_rounds(matches_by_iou: dict[float, Sequence[FrameMatches]], weights: np.ndarray,
89
+ ious: Sequence[float]) -> np.ndarray:
90
+ """Mean AP over ``ious`` per round."""
91
+ return np.nanmean(
92
+ np.stack([weighted_ap(_flatten(matches_by_iou[t]), weights) for t in ious]), axis=0
93
+ )
94
+
95
+
96
+ def paired_delta(baseline: np.ndarray, candidate: np.ndarray) -> Optional[Delta]:
97
+ """From per-round values (row 0 = full set) of two models."""
98
+ diff = candidate - baseline
99
+ if np.isnan(diff[0]):
100
+ return None
101
+ rounds = diff[1:][~np.isnan(diff[1:])]
102
+ low, high = (np.percentile(rounds, [2.5, 97.5]) if rounds.size else (np.nan, np.nan))
103
+ return Delta(float(diff[0]), float(low), float(high))
104
+
105
+
106
+ AP50 = (0.5,)
107
+ AP50_95 = COCO_IOUS
camstack_train/_vendor/camstack_eval/classes.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """COCO → CamStack macro classes, as the pipeline maps them
2
+ (`packages/types/src/catalogs/coco-classmap.ts` `COCO_TO_MACRO`). The floors
3
+ and the comparison are per macro class, because that is what the pipeline
4
+ gates on. `package` (COCO luggage proxies) is mapped but not evaluated by
5
+ default: it was too rare to calibrate (D739 keeps it at 0.50)."""
6
+ from __future__ import annotations
7
+
8
+ from typing import Optional
9
+
10
+ from .metrics import Detection
11
+
12
+ COCO_TO_MACRO: dict[str, str] = {
13
+ "person": "person",
14
+ **{k: "vehicle" for k in ("bicycle", "car", "motorcycle", "airplane", "bus", "train", "truck", "boat")},
15
+ **{
16
+ k: "animal"
17
+ for k in ("bird", "cat", "dog", "horse", "sheep", "cow", "elephant", "bear", "zebra", "giraffe")
18
+ },
19
+ **{k: "package" for k in ("suitcase", "backpack", "handbag")},
20
+ }
21
+
22
+ #: Macro classes a root detector is evaluated on.
23
+ DETECTOR_CLASSES: tuple[str, ...] = ("person", "vehicle", "animal")
24
+
25
+
26
+ def macro_of(label: str) -> Optional[str]:
27
+ """A label's macro class; a label that already IS a macro maps to itself."""
28
+ if label in DETECTOR_CLASSES or label == "package":
29
+ return label
30
+ return COCO_TO_MACRO.get(label)
31
+
32
+
33
+ def to_macro(detections: list[Detection]) -> list[Detection]:
34
+ """Map detections to macro classes; a class the pipeline drops is dropped."""
35
+ out = []
36
+ for d in detections:
37
+ macro = macro_of(d.cls)
38
+ if macro is not None:
39
+ out.append(Detection(macro, d.score, d.bbox))
40
+ return out
camstack_train/_vendor/camstack_eval/compare.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Baseline vs candidate on one holdout.
2
+
3
+ Both models are run on the same frames. The candidate's floors come from the
4
+ D739 rule against the baseline's shipped floors (`floors.propose_floor`), and
5
+ the candidate is reported AT those floors — "same false-alert rate, what more
6
+ does it find". Per-camera recall deltas are what a promotion gate reads
7
+ (analysis §6: no camera may lose more than 0.01 of person recall).
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from typing import Callable, Iterable, Optional, Sequence
12
+
13
+ from .classes import DETECTOR_CLASSES, to_macro
14
+ from .dataset import EvalFrame
15
+ from .floors import FloorPolicy, ScoredDetection, propose_floor
16
+ from .metrics import ClassResult, Detection, FrameTruth, average_precision, evaluate_class, scored_detections
17
+
18
+ Detector = Callable[[bytes], Sequence[Detection]]
19
+
20
+
21
+ def select_holdout(
22
+ frames: Iterable[EvalFrame],
23
+ cameras: Optional[Sequence[int]] = None,
24
+ since: Optional[int] = None,
25
+ ) -> list[EvalFrame]:
26
+ """Holdout by whole cameras and/or by capture time — never by random frame."""
27
+ wanted = set(cameras) if cameras else None
28
+ out = []
29
+ for f in frames:
30
+ if wanted is not None and f.truth.camera not in wanted:
31
+ continue
32
+ if since is not None and (f.captured_at is None or f.captured_at < since):
33
+ continue
34
+ out.append(f)
35
+ return out
36
+
37
+
38
+ def _run(frames: Sequence[EvalFrame], detector: Detector) -> list[tuple[FrameTruth, list[Detection]]]:
39
+ return [(f.truth, to_macro(list(detector(f.read())))) for f in frames]
40
+
41
+
42
+ def _summary(result: ClassResult, ap: Optional[float]) -> dict:
43
+ return {
44
+ "floor": result.floor,
45
+ "tp": result.tp,
46
+ "fp": result.fp,
47
+ "fn": result.fn,
48
+ "precision": result.precision,
49
+ "recall": result.recall,
50
+ "ap50": ap,
51
+ "recallByCamera": {str(k): v for k, v in sorted(result.recall_by_camera.items())},
52
+ "recallByLight": dict(sorted(result.recall_by_light.items())),
53
+ "fpByCamera": {str(k): v for k, v in sorted(result.fp_by_camera.items())},
54
+ "smallFound": result.small_found,
55
+ "smallTotal": result.small_total,
56
+ }
57
+
58
+
59
+ def compare_models(
60
+ frames: Sequence[EvalFrame],
61
+ baseline: Detector,
62
+ candidate: Detector,
63
+ baseline_floors: dict[str, float],
64
+ candidate_floors: Optional[dict[str, float]] = None,
65
+ policy: FloorPolicy = FloorPolicy(),
66
+ classes: Sequence[str] = DETECTOR_CLASSES,
67
+ ) -> dict:
68
+ base_runs = _run(frames, baseline)
69
+ cand_runs = _run(frames, candidate)
70
+ report: dict = {"frames": len(frames), "classes": {}}
71
+ for cls in classes:
72
+ base_floor = baseline_floors[cls]
73
+ base_scored, positives = scored_detections(cls, base_runs)
74
+ cand_scored, _ = scored_detections(cls, cand_runs)
75
+ proposed = propose_floor(
76
+ [ScoredDetection(d.score, d.true_positive) for d in base_scored],
77
+ base_floor,
78
+ [ScoredDetection(d.score, d.true_positive) for d in cand_scored],
79
+ positives,
80
+ policy,
81
+ )
82
+ cand_floor = (candidate_floors or {}).get(cls, proposed.value)
83
+ base = evaluate_class(cls, base_runs, base_floor)
84
+ cand = evaluate_class(cls, cand_runs, cand_floor)
85
+ deltas = {}
86
+ for cam, recall in cand.recall_by_camera.items():
87
+ before = base.recall_by_camera.get(cam)
88
+ if recall is not None and before is not None:
89
+ deltas[str(cam)] = round(recall - before, 4)
90
+ report["classes"][cls] = {
91
+ "positives": positives,
92
+ "baseline": _summary(base, average_precision(cls, base_runs)),
93
+ "candidate": _summary(cand, average_precision(cls, cand_runs)),
94
+ "proposedFloor": {
95
+ "value": proposed.value,
96
+ "reason": proposed.reason,
97
+ "fpBudget": proposed.fp_budget,
98
+ "fitted": proposed.fitted,
99
+ },
100
+ "recallDeltaByCamera": dict(sorted(deltas.items())),
101
+ }
102
+ return report
camstack_train/_vendor/camstack_eval/dataset.py ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The hub's annotated export (`annotations.json`, schema
2
+ `camstack.retrain-export/v2`) as an evaluation set.
3
+
4
+ Only frames the operator marked ``complete`` ("all subjects boxed") are ground
5
+ truth by default: on any other frame an unboxed person would be scored as a
6
+ false positive. ``subject`` annotations of a detector macro class are the
7
+ positives; ``model_error`` boxes mark a region the model got wrong and are not
8
+ positives (a detection there is a false positive, as it should be).
9
+
10
+ Works on the tar itself or on an extracted directory.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import tarfile
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+ from typing import Callable, Optional
19
+
20
+ from .classes import DETECTOR_CLASSES
21
+ from .metrics import FrameTruth, GroundTruthBox
22
+
23
+ MANIFEST = "annotations.json"
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class EvalFrame:
28
+ truth: FrameTruth
29
+ path: str
30
+ captured_at: Optional[int]
31
+ read: Callable[[], bytes]
32
+
33
+
34
+ def _night(entry: dict) -> Optional[bool]:
35
+ lighting = entry.get("lighting")
36
+ return None if not isinstance(lighting, dict) else bool(lighting.get("night"))
37
+
38
+
39
+ def _truth(entry: dict, classes: tuple[str, ...]) -> FrameTruth:
40
+ boxes = []
41
+ for a in entry.get("annotations", []):
42
+ if a.get("kind") != "subject" or a.get("macroClass") not in classes:
43
+ continue
44
+ b = a["bbox"]
45
+ boxes.append(GroundTruthBox(a["macroClass"], (b["x"], b["y"], b["w"], b["h"])))
46
+ return FrameTruth(
47
+ frame_id=entry["frameId"],
48
+ camera=int(entry["deviceId"]),
49
+ night=_night(entry),
50
+ boxes=tuple(boxes),
51
+ )
52
+
53
+
54
+ def load_export(
55
+ source: str,
56
+ complete_only: bool = True,
57
+ classes: tuple[str, ...] = DETECTOR_CLASSES,
58
+ ) -> list[EvalFrame]:
59
+ root = Path(source)
60
+ if root.is_dir():
61
+ manifest = json.loads((root / MANIFEST).read_text("utf-8"))
62
+
63
+ def reader(path: str) -> Callable[[], bytes]:
64
+ return lambda: (root / path).read_bytes()
65
+ else:
66
+ with tarfile.open(root) as tar:
67
+ member = tar.extractfile(MANIFEST)
68
+ if member is None:
69
+ raise ValueError(f"{source}: no {MANIFEST}")
70
+ manifest = json.loads(member.read().decode("utf-8"))
71
+
72
+ def reader(path: str) -> Callable[[], bytes]:
73
+ def read() -> bytes:
74
+ with tarfile.open(root) as tar:
75
+ f = tar.extractfile(path)
76
+ if f is None:
77
+ raise FileNotFoundError(path)
78
+ return f.read()
79
+
80
+ return read
81
+
82
+ out = []
83
+ for entry in manifest.get("frames", []):
84
+ if complete_only and entry.get("complete") is not True:
85
+ continue
86
+ out.append(
87
+ EvalFrame(
88
+ truth=_truth(entry, classes),
89
+ path=entry["path"],
90
+ captured_at=entry.get("capturedAt"),
91
+ read=reader(entry["path"]),
92
+ )
93
+ )
94
+ return out
95
+
96
+
97
+ def _file_reader(path: Path) -> Callable[[], bytes]:
98
+ return lambda: path.read_bytes()
99
+
100
+
101
+ def load_coco(
102
+ annotations: str,
103
+ images_dir: str,
104
+ skip: int = 0,
105
+ limit: Optional[int] = None,
106
+ classes: tuple[str, ...] = DETECTOR_CLASSES,
107
+ ) -> list[EvalFrame]:
108
+ """COCO ``instances_*.json`` as an evaluation set, in macro classes.
109
+
110
+ Images are taken in FILE-NAME order — the order the INT8 calibration reads
111
+ the same folder (`convert_lib.calibration_images`) — after the first
112
+ ``skip``, so ``skip`` = the calibration subset size keeps every calibration
113
+ image out of the holdout. Every image counts, including those with no
114
+ object of a macro class: a detection there is a false positive. A crowd
115
+ box (``iscrowd``) is "don't care", as in COCO's own evaluation.
116
+ """
117
+ from .classes import macro_of
118
+
119
+ data = json.loads(Path(annotations).read_text("utf-8"))
120
+ names = {c["id"]: c["name"] for c in data["categories"]}
121
+ by_image: dict[int, list[dict]] = {}
122
+ for a in data["annotations"]:
123
+ by_image.setdefault(a["image_id"], []).append(a)
124
+ images = sorted(data["images"], key=lambda im: im["file_name"])[skip:]
125
+ if limit is not None:
126
+ images = images[:limit]
127
+ root = Path(images_dir)
128
+ out = []
129
+ for im in images:
130
+ w, h = float(im["width"]), float(im["height"])
131
+ boxes, dont_care = [], []
132
+ for a in by_image.get(im["id"], []):
133
+ macro = macro_of(names[a["category_id"]])
134
+ if macro not in classes:
135
+ continue
136
+ x, y, bw, bh = a["bbox"]
137
+ gt = GroundTruthBox(macro, (x / w, y / h, bw / w, bh / h))
138
+ (dont_care if a.get("iscrowd") else boxes).append(gt)
139
+ out.append(
140
+ EvalFrame(
141
+ truth=FrameTruth(
142
+ frame_id=im["file_name"], camera=0, night=None,
143
+ boxes=tuple(boxes), dont_care=tuple(dont_care),
144
+ ),
145
+ path=im["file_name"],
146
+ captured_at=None,
147
+ read=_file_reader(root / im["file_name"]),
148
+ )
149
+ )
150
+ return out
151
+
152
+
153
+ def load_pseudo_gt(source: str, frames_dir: Optional[str] = None) -> list[EvalFrame]:
154
+ """A pseudo ground truth (schema ``camstack.pseudo-gt/v1``): per frame the
155
+ positives and the "don't care" boxes of `pseudo_gt.pseudo_ground_truth`,
156
+ normalised ``(x, y, w, h)``. Frames are read from ``frames_dir`` (default:
157
+ the folder holding the JSON)."""
158
+ path = Path(source)
159
+ data = json.loads(path.read_text("utf-8"))
160
+ if data.get("schema") != "camstack.pseudo-gt/v1":
161
+ raise ValueError(f"{source}: not a camstack.pseudo-gt/v1 file")
162
+ root = Path(frames_dir) if frames_dir else path.parent
163
+
164
+ def boxes(rows: list[dict]) -> tuple[GroundTruthBox, ...]:
165
+ return tuple(GroundTruthBox(r["cls"], tuple(r["bbox"])) for r in rows)
166
+
167
+ return [
168
+ EvalFrame(
169
+ truth=FrameTruth(
170
+ frame_id=f["file"],
171
+ camera=int(f.get("deviceId", 0)),
172
+ night=f.get("night"),
173
+ boxes=boxes(f["boxes"]),
174
+ dont_care=boxes(f.get("dontCare", [])),
175
+ ),
176
+ path=f["file"],
177
+ captured_at=f.get("capturedAt"),
178
+ read=_file_reader(root / f["file"]),
179
+ )
180
+ for f in data["frames"]
181
+ ]
camstack_train/_vendor/camstack_eval/floors.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The D739 floor rule (docs/decisions/adr-0739-*.md).
2
+
3
+ A model's floor for a class is the LOWEST confidence at which it raises no more
4
+ false positives on the evaluation set than the baseline does at the baseline's
5
+ shipped floor — so switching model keeps the false-alert rate and changes only
6
+ what is found. On a small or fine-tuned set two guards apply:
7
+
8
+ - **support**: a class with fewer than ``min_support`` ground-truth positives
9
+ inherits the baseline floor (a floor fitted on a handful of boxes is noise);
10
+ - **clamp**: the floor stays within ``±clamp`` of the baseline floor (single
11
+ digit false-positive counts carry ±0.05–0.10 of noise).
12
+
13
+ Floors are on a ``grid_step`` grid and never below ``minimum``, as in the
14
+ 2026-10-07 calibration.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass
19
+ from typing import Literal, Optional, Sequence
20
+
21
+ FloorReason = Literal["fp-budget", "low-support", "clamped"]
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class ScoredDetection:
26
+ score: float
27
+ true_positive: bool
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class FloorPolicy:
32
+ min_support: int = 20
33
+ clamp: float = 0.15
34
+ grid_step: float = 0.05
35
+ minimum: float = 0.25
36
+ maximum: float = 0.95
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class ProposedFloor:
41
+ value: float
42
+ reason: FloorReason
43
+ fp_budget: int
44
+ #: The unclamped fit, ``None`` when no grid floor meets the budget.
45
+ fitted: Optional[float]
46
+
47
+
48
+ def false_positives_at(detections: Sequence[ScoredDetection], floor: float) -> int:
49
+ return sum(1 for d in detections if not d.true_positive and d.score >= floor - 1e-9)
50
+
51
+
52
+ def _grid(policy: FloorPolicy) -> list[float]:
53
+ steps = int(round((policy.maximum - policy.minimum) / policy.grid_step))
54
+ return [round(policy.minimum + i * policy.grid_step, 4) for i in range(steps + 1)]
55
+
56
+
57
+ def propose_floor(
58
+ baseline: Sequence[ScoredDetection],
59
+ baseline_floor: float,
60
+ candidate: Sequence[ScoredDetection],
61
+ positives: int,
62
+ policy: FloorPolicy = FloorPolicy(),
63
+ ) -> ProposedFloor:
64
+ budget = false_positives_at(baseline, baseline_floor)
65
+ if positives < policy.min_support:
66
+ return ProposedFloor(baseline_floor, "low-support", budget, None)
67
+ fitted = next(
68
+ (t for t in _grid(policy) if false_positives_at(candidate, t) <= budget),
69
+ None,
70
+ )
71
+ low = round(max(policy.minimum, baseline_floor - policy.clamp), 4)
72
+ high = round(baseline_floor + policy.clamp, 4)
73
+ if fitted is None:
74
+ return ProposedFloor(high, "clamped", budget, None)
75
+ if fitted < low:
76
+ return ProposedFloor(low, "clamped", budget, fitted)
77
+ if fitted > high:
78
+ return ProposedFloor(high, "clamped", budget, fitted)
79
+ return ProposedFloor(fitted, "fp-budget", budget, fitted)
camstack_train/_vendor/camstack_eval/metrics.py ADDED
@@ -0,0 +1,273 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Detection metrics against a ground truth, the way the 2026-10-07 detector
2
+ comparison measured them (docs/benchmarks/2026-10-07-detector-comparison.md).
3
+
4
+ Boxes are normalised ``(x, y, w, h)`` against the full frame. A detection is a
5
+ true positive when it overlaps an unmatched ground-truth box of the same macro
6
+ class at IoU >= 0.5 (greedy, highest score first); a detection on a
7
+ "don't care" box is ignored; everything else is a false positive. Greedy in
8
+ score order makes the matching threshold-consistent: raising the floor only
9
+ truncates the list, so one pass serves every floor and AP50.
10
+
11
+ "Small" follows the comparison: a box under 100 px tall in a 960x540 frame,
12
+ i.e. under 100/540 of the frame height.
13
+
14
+ AP50-95 is the COCO average of AP over the IoU thresholds 0.50, 0.55 … 0.95,
15
+ each matched the same greedy way (a "don't care" box is still ignored at IoU
16
+ 0.5). It is computed per macro class, at the pool floor, so it is lower than an
17
+ Ultralytics `val` mAP (floor 0.001, 80 classes): compare it only with itself.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ from dataclasses import dataclass, field
22
+ from typing import Iterable, Optional, Sequence
23
+
24
+ Box = tuple[float, float, float, float]
25
+
26
+ MATCH_IOU = 0.5
27
+ SMALL_HEIGHT_FRACTION = 100 / 540
28
+ #: COCO's AP50-95 thresholds.
29
+ COCO_IOUS: tuple[float, ...] = tuple(round(0.5 + 0.05 * i, 2) for i in range(10))
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class GroundTruthBox:
34
+ cls: str
35
+ bbox: Box
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class Detection:
40
+ cls: str
41
+ score: float
42
+ bbox: Box
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class FrameTruth:
47
+ frame_id: str
48
+ camera: int
49
+ #: ``True`` night/IR, ``False`` day, ``None`` unknown.
50
+ night: Optional[bool]
51
+ boxes: tuple[GroundTruthBox, ...]
52
+ dont_care: tuple[GroundTruthBox, ...] = ()
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class MatchedDetection:
57
+ """One detection of the class, after matching: what floors and AP use."""
58
+
59
+ score: float
60
+ true_positive: bool
61
+
62
+
63
+ @dataclass
64
+ class ClassResult:
65
+ cls: str
66
+ floor: float
67
+ tp: int = 0
68
+ fp: int = 0
69
+ fn: int = 0
70
+ small_found: int = 0
71
+ small_total: int = 0
72
+ recall_by_camera: dict = field(default_factory=dict)
73
+ recall_by_light: dict = field(default_factory=dict)
74
+ fp_by_camera: dict = field(default_factory=dict)
75
+
76
+ @property
77
+ def precision(self) -> Optional[float]:
78
+ return None if self.tp + self.fp == 0 else self.tp / (self.tp + self.fp)
79
+
80
+ @property
81
+ def recall(self) -> Optional[float]:
82
+ return None if self.tp + self.fn == 0 else self.tp / (self.tp + self.fn)
83
+
84
+
85
+ def iou(a: Box, b: Box) -> float:
86
+ ax2, ay2 = a[0] + a[2], a[1] + a[3]
87
+ bx2, by2 = b[0] + b[2], b[1] + b[3]
88
+ iw = max(0.0, min(ax2, bx2) - max(a[0], b[0]))
89
+ ih = max(0.0, min(ay2, by2) - max(a[1], b[1]))
90
+ inter = iw * ih
91
+ union = a[2] * a[3] + b[2] * b[3] - inter
92
+ return inter / union if union > 0 else 0.0
93
+
94
+
95
+ def is_small(bbox: Box) -> bool:
96
+ return bbox[3] < SMALL_HEIGHT_FRACTION
97
+
98
+
99
+ def light_bucket(night: Optional[bool]) -> str:
100
+ return "unknown" if night is None else ("night" if night else "day")
101
+
102
+
103
+ @dataclass(frozen=True)
104
+ class _FrameMatch:
105
+ #: (score, verdict) per detection of the class, highest score first;
106
+ #: verdict is "tp", "fp" or "ignored".
107
+ detections: tuple[tuple[float, str], ...]
108
+ #: per ground-truth box: the score that matched it, or None.
109
+ gt_scores: tuple[Optional[float], ...]
110
+
111
+
112
+ def _match_frame(
113
+ cls: str, truth: FrameTruth, detections: Sequence[Detection], match_iou: float = MATCH_IOU
114
+ ) -> _FrameMatch:
115
+ gts = [g for g in truth.boxes if g.cls == cls]
116
+ cares = [g for g in truth.dont_care if g.cls == cls]
117
+ gt_scores: list[Optional[float]] = [None] * len(gts)
118
+ verdicts: list[tuple[float, str]] = []
119
+ for det in sorted((d for d in detections if d.cls == cls), key=lambda d: -d.score):
120
+ best, best_iou = -1, match_iou
121
+ for i, g in enumerate(gts):
122
+ if gt_scores[i] is not None:
123
+ continue
124
+ overlap = iou(det.bbox, g.bbox)
125
+ if overlap >= best_iou:
126
+ best, best_iou = i, overlap
127
+ if best >= 0:
128
+ gt_scores[best] = det.score
129
+ verdicts.append((det.score, "tp"))
130
+ elif any(iou(det.bbox, c.bbox) >= MATCH_IOU for c in cares):
131
+ verdicts.append((det.score, "ignored"))
132
+ else:
133
+ verdicts.append((det.score, "fp"))
134
+ return _FrameMatch(tuple(verdicts), tuple(gt_scores))
135
+
136
+
137
+ def _ratio(hit: int, total: int) -> Optional[float]:
138
+ return None if total == 0 else hit / total
139
+
140
+
141
+ def evaluate_class(
142
+ cls: str,
143
+ frames: Iterable[tuple[FrameTruth, Sequence[Detection]]],
144
+ floor: float,
145
+ ) -> ClassResult:
146
+ result = ClassResult(cls=cls, floor=floor)
147
+ by_camera: dict[int, list[int]] = {}
148
+ by_light: dict[str, list[int]] = {}
149
+ for truth, detections in frames:
150
+ match = _match_frame(cls, truth, detections)
151
+ gts = [g for g in truth.boxes if g.cls == cls]
152
+ for score, verdict in match.detections:
153
+ if score < floor or verdict == "ignored":
154
+ continue
155
+ if verdict == "tp":
156
+ result.tp += 1
157
+ else:
158
+ result.fp += 1
159
+ result.fp_by_camera[truth.camera] = result.fp_by_camera.get(truth.camera, 0) + 1
160
+ for g, score in zip(gts, match.gt_scores):
161
+ found = score is not None and score >= floor
162
+ if not found:
163
+ result.fn += 1
164
+ cam = by_camera.setdefault(truth.camera, [0, 0])
165
+ light = by_light.setdefault(light_bucket(truth.night), [0, 0])
166
+ cam[0] += int(found)
167
+ cam[1] += 1
168
+ light[0] += int(found)
169
+ light[1] += 1
170
+ if is_small(g.bbox):
171
+ result.small_total += 1
172
+ result.small_found += int(found)
173
+ result.recall_by_camera = {k: _ratio(*v) for k, v in by_camera.items()}
174
+ result.recall_by_light = {k: _ratio(*v) for k, v in by_light.items()}
175
+ return result
176
+
177
+
178
+ def scored_detections(
179
+ cls: str,
180
+ frames: Iterable[tuple[FrameTruth, Sequence[Detection]]],
181
+ match_iou: float = MATCH_IOU,
182
+ ) -> tuple[list[MatchedDetection], int]:
183
+ """Every detection of the class with its verdict (ignored ones dropped),
184
+ and the number of ground-truth positives. Feeds AP50 and the floor rule."""
185
+ out: list[MatchedDetection] = []
186
+ positives = 0
187
+ for truth, detections in frames:
188
+ match = _match_frame(cls, truth, detections, match_iou)
189
+ positives += sum(1 for g in truth.boxes if g.cls == cls)
190
+ out.extend(MatchedDetection(s, v == "tp") for s, v in match.detections if v != "ignored")
191
+ return out, positives
192
+
193
+
194
+ def ap_from_matches(scores: Sequence[float], true_positive: Sequence[bool], positives: int) -> Optional[float]:
195
+ """AP, all-point interpolated (VOC 2010+), from matched detections in any
196
+ order. ``None`` with no positives. Ties keep their given order."""
197
+ if positives == 0:
198
+ return None
199
+ order = sorted(range(len(scores)), key=lambda i: -scores[i])
200
+ tp = fp = 0
201
+ precisions: list[float] = []
202
+ hits: list[bool] = []
203
+ for i in order:
204
+ hit = bool(true_positive[i])
205
+ tp += int(hit)
206
+ fp += int(not hit)
207
+ precisions.append(tp / (tp + fp))
208
+ hits.append(hit)
209
+ # Precision envelope: the best precision at this recall or any higher one.
210
+ best = 0.0
211
+ for i in range(len(precisions) - 1, -1, -1):
212
+ best = max(best, precisions[i])
213
+ precisions[i] = best
214
+ return sum(p for p, hit in zip(precisions, hits) if hit) / positives
215
+
216
+
217
+ def average_precision(
218
+ cls: str,
219
+ frames: Iterable[tuple[FrameTruth, Sequence[Detection]]],
220
+ match_iou: float = MATCH_IOU,
221
+ ) -> Optional[float]:
222
+ """AP at IoU ``match_iou`` (0.5 by default), all-point interpolated (VOC 2010+).
223
+ ``None`` with no positives."""
224
+ scored, positives = scored_detections(cls, frames, match_iou)
225
+ return ap_from_matches([d.score for d in scored], [d.true_positive for d in scored], positives)
226
+
227
+
228
+ def average_precision_coco(
229
+ cls: str, frames: Sequence[tuple[FrameTruth, Sequence[Detection]]]
230
+ ) -> Optional[float]:
231
+ """AP50-95: the mean AP over ``COCO_IOUS``. ``None`` with no positives."""
232
+ aps = [average_precision(cls, frames, t) for t in COCO_IOUS]
233
+ return None if aps[0] is None else sum(a for a in aps if a is not None) / len(aps)
234
+
235
+
236
+ @dataclass(frozen=True)
237
+ class FrameMatches:
238
+ """One frame's matched detections of one class at one IoU: what a
239
+ bootstrap re-uses, since matching is per frame."""
240
+
241
+ scores: tuple[float, ...]
242
+ true_positive: tuple[bool, ...]
243
+ positives: int
244
+
245
+
246
+ def frame_matches(
247
+ cls: str, frames: Sequence[tuple[FrameTruth, Sequence[Detection]]], match_iou: float
248
+ ) -> list[FrameMatches]:
249
+ out = []
250
+ for truth, detections in frames:
251
+ match = _match_frame(cls, truth, detections, match_iou)
252
+ kept = [(s, v == "tp") for s, v in match.detections if v != "ignored"]
253
+ out.append(
254
+ FrameMatches(
255
+ tuple(s for s, _ in kept),
256
+ tuple(t for _, t in kept),
257
+ sum(1 for g in truth.boxes if g.cls == cls),
258
+ )
259
+ )
260
+ return out
261
+
262
+
263
+ def ap_of_sample(matches: Sequence[FrameMatches], sample: Iterable[int]) -> Optional[float]:
264
+ """AP over the frames ``sample`` (indices, repeats allowed) of ``matches``."""
265
+ scores: list[float] = []
266
+ hits: list[bool] = []
267
+ positives = 0
268
+ for i in sample:
269
+ m = matches[i]
270
+ scores.extend(m.scores)
271
+ hits.extend(m.true_positive)
272
+ positives += m.positives
273
+ return ap_from_matches(scores, hits, positives)
camstack_train/_vendor/camstack_eval/pipeline.py ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Run a YOLO detector (ONNX or OpenVINO IR) exactly as the inference pool does.
2
+
3
+ Nothing here re-implements the pipeline: the letterbox (grey 114 padding,
4
+ bilinear resize, /255) and the YOLO decode (sigmoid detection, confidence
5
+ floor, NMS) are IMPORTED from `packages/addon-pipeline/python`, so a change to
6
+ production preprocessing changes the evaluation with it. The pool floor (0.05)
7
+ and the NMS IoU (0.45) are the pool's.
8
+
9
+ An OpenVINO IR is compiled as the pool compiles it: a static 3-channel input
10
+ gets the pool's own PrePostProcessor (uint8 NHWC in, the /255 folded into the
11
+ graph — `inference_pool._build_ov_ppp_model`), a dynamic one is fed the float
12
+ letterbox. The device is the CPU at FP32 (`INFERENCE_PRECISION_HINT=f32`), the
13
+ x86 CPU default; an ARM CPU defaults to FP16, which is not what any node runs.
14
+
15
+ One difference stays and is stated: the live path first downscales a frame to
16
+ the model's content size on the Node side (sharp); here the PIL letterbox does
17
+ that resize itself. The comparison run of 2026-10-07 measured the same way.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import sys
22
+ from pathlib import Path
23
+ from typing import Optional, Sequence
24
+
25
+ import numpy as np
26
+
27
+ from .metrics import Detection
28
+
29
+ REPO_ROOT = Path(__file__).resolve().parents[3]
30
+ PIPELINE_PYTHON = REPO_ROOT / "packages" / "addon-pipeline" / "python"
31
+
32
+ #: The inference pool's confidence floor and NMS IoU.
33
+ POOL_FLOOR = 0.05
34
+ POOL_NMS_IOU = 0.45
35
+
36
+
37
+ def _pipeline():
38
+ if str(PIPELINE_PYTHON) not in sys.path:
39
+ sys.path.insert(0, str(PIPELINE_PYTHON))
40
+ import inference_pool # noqa: E402 (path set above)
41
+ from postprocessors import POSTPROCESSORS # noqa: E402
42
+
43
+ return inference_pool, POSTPROCESSORS
44
+
45
+
46
+ class OnnxDetector:
47
+ """A detector on ONNX Runtime (CPU unless ``providers`` says otherwise)."""
48
+
49
+ def __init__(
50
+ self,
51
+ model_path: str,
52
+ input_size: int,
53
+ labels: Optional[Sequence[str]] = None,
54
+ postprocessor: str = "yolo",
55
+ pool_floor: float = POOL_FLOOR,
56
+ nms_iou: float = POOL_NMS_IOU,
57
+ providers: Optional[Sequence[str]] = None,
58
+ ) -> None:
59
+ import onnxruntime as ort
60
+
61
+ self._pool, postprocessors = _pipeline()
62
+ if postprocessor not in postprocessors:
63
+ raise ValueError(f"unknown postprocessor {postprocessor!r}")
64
+ self._postprocess = postprocessors[postprocessor]
65
+ from postprocessors.yolo import COCO_80
66
+
67
+ self.input_size = input_size
68
+ self._config = {
69
+ "inputSize": input_size,
70
+ "labels": list(labels) if labels is not None else list(COCO_80),
71
+ "confidence": pool_floor,
72
+ "nmsIouThreshold": nms_iou,
73
+ "postprocessor": postprocessor,
74
+ }
75
+ self._session = ort.InferenceSession(
76
+ model_path, providers=list(providers) if providers else ["CPUExecutionProvider"]
77
+ )
78
+ self._input_name = self._session.get_inputs()[0].name
79
+ self._output_names = [o.name for o in self._session.get_outputs()]
80
+
81
+ def detect(self, jpeg: bytes) -> list[Detection]:
82
+ img = self._pool.decode_jpeg(jpeg)
83
+ orig_w, orig_h = img.size
84
+ arr, scale, pad = self._pool.letterbox(img, self.input_size)
85
+ tensor = arr.transpose(2, 0, 1)[np.newaxis].astype(np.float32)
86
+ outputs = self._session.run(self._output_names, {self._input_name: tensor})
87
+ predictions = dict(zip(self._output_names, outputs))
88
+ result = self._postprocess(predictions, self._config, orig_w, orig_h, scale, pad)
89
+ return _to_detections(result, orig_w, orig_h)
90
+
91
+
92
+ def _to_detections(result: dict, orig_w: int, orig_h: int) -> list[Detection]:
93
+ out: list[Detection] = []
94
+ for d in result.get("detections", []):
95
+ x1, y1, x2, y2 = d["bbox"]
96
+ out.append(
97
+ Detection(
98
+ str(d["class"]),
99
+ float(d["score"]),
100
+ (x1 / orig_w, y1 / orig_h, (x2 - x1) / orig_w, (y2 - y1) / orig_h),
101
+ )
102
+ )
103
+ return out
104
+
105
+
106
+ class OpenVinoDetector:
107
+ """A detector on an OpenVINO IR, on the CPU at FP32 (see the module doc)."""
108
+
109
+ def __init__(
110
+ self,
111
+ model_path: str,
112
+ input_size: int,
113
+ labels: Optional[Sequence[str]] = None,
114
+ postprocessor: str = "yolo",
115
+ pool_floor: float = POOL_FLOOR,
116
+ nms_iou: float = POOL_NMS_IOU,
117
+ device: str = "CPU",
118
+ precision: str = "f32",
119
+ ) -> None:
120
+ import openvino as ov
121
+
122
+ self._pool, postprocessors = _pipeline()
123
+ if postprocessor not in postprocessors:
124
+ raise ValueError(f"unknown postprocessor {postprocessor!r}")
125
+ self._postprocess = postprocessors[postprocessor]
126
+ from postprocessors.yolo import COCO_80
127
+
128
+ self.input_size = input_size
129
+ self._config = {
130
+ "inputSize": input_size,
131
+ "labels": list(labels) if labels is not None else list(COCO_80),
132
+ "confidence": pool_floor,
133
+ "nmsIouThreshold": nms_iou,
134
+ "postprocessor": postprocessor,
135
+ "preprocessMode": "letterbox",
136
+ }
137
+ core = ov.Core()
138
+ model, self._ppp = self._pool._build_ov_ppp_model(core, model_path, self._config)
139
+ properties = {"INFERENCE_PRECISION_HINT": precision, "PERFORMANCE_HINT": "LATENCY"}
140
+ self._compiled = core.compile_model(model if model is not None else model_path, device, properties)
141
+ self._request = self._compiled.create_infer_request()
142
+ self._output_names = [o.get_any_name() for o in self._compiled.outputs]
143
+
144
+ @property
145
+ def folded(self) -> bool:
146
+ """True when the pool's PrePostProcessor was attached (static input)."""
147
+ return self._ppp is not None
148
+
149
+ def detect(self, jpeg: bytes) -> list[Detection]:
150
+ img = self._pool.decode_jpeg(jpeg)
151
+ orig_w, orig_h = img.size
152
+ if self._ppp is not None:
153
+ tensor, scale, pad = self._pool._preprocess_ov_uint8(img, self._ppp)
154
+ else:
155
+ arr, scale, pad = self._pool.letterbox(img, self.input_size)
156
+ tensor = arr.transpose(2, 0, 1)[np.newaxis].astype(np.float32)
157
+ self._request.infer({0: tensor})
158
+ predictions = {
159
+ name: self._request.get_output_tensor(i).data.copy()
160
+ for i, name in enumerate(self._output_names)
161
+ }
162
+ result = self._postprocess(predictions, self._config, orig_w, orig_h, scale, pad)
163
+ return _to_detections(result, orig_w, orig_h)
164
+
165
+
166
+ def make_detector(model_path: str, input_size: int):
167
+ """An ``OpenVinoDetector`` for an ``.xml`` IR, an ``OnnxDetector`` otherwise."""
168
+ if model_path.endswith(".xml"):
169
+ return OpenVinoDetector(model_path, input_size)
170
+ return OnnxDetector(model_path, input_size)
camstack_train/_vendor/camstack_eval/pseudo_gt.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pseudo ground truth from two teacher detectors — the 2026-10-07 method.
2
+
3
+ "Two large reference detectors (YOLO26-X at 1280 px and RT-DETR-X at 640 px)
4
+ are run on every frame; an object counts when both agree (or one is very
5
+ confident). Uncertain boxes are don't care." The original scripts were lost
6
+ with a session scratchpad; the thresholds below are the documented SHAPE of
7
+ the rule with defaults chosen here, not the exact values of that run.
8
+ Detections must already be in macro classes (`classes.to_macro`).
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+ from typing import Sequence
14
+
15
+ from .metrics import MATCH_IOU, Detection, GroundTruthBox, iou
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class PseudoGtPolicy:
20
+ #: Both teachers at or above this, same class, IoU >= 0.5 ⇒ positive.
21
+ agree_score: float = 0.3
22
+ #: One teacher alone at or above this ⇒ positive.
23
+ solo_score: float = 0.8
24
+ #: A box a teacher holds at or above this that is not a positive ⇒ don't care.
25
+ care_score: float = 0.3
26
+
27
+
28
+ def pseudo_ground_truth(
29
+ teacher_a: Sequence[Detection],
30
+ teacher_b: Sequence[Detection],
31
+ policy: PseudoGtPolicy = PseudoGtPolicy(),
32
+ ) -> tuple[list[GroundTruthBox], list[GroundTruthBox]]:
33
+ positives: list[GroundTruthBox] = []
34
+ used_b: set[int] = set()
35
+ leftovers: list[Detection] = []
36
+ for a in sorted(teacher_a, key=lambda d: -d.score):
37
+ partner = -1
38
+ if a.score >= policy.agree_score:
39
+ for j, b in enumerate(teacher_b):
40
+ if j in used_b or b.cls != a.cls or b.score < policy.agree_score:
41
+ continue
42
+ if iou(a.bbox, b.bbox) >= MATCH_IOU:
43
+ partner = j
44
+ break
45
+ if partner >= 0:
46
+ used_b.add(partner)
47
+ positives.append(GroundTruthBox(a.cls, a.bbox))
48
+ elif a.score >= policy.solo_score:
49
+ positives.append(GroundTruthBox(a.cls, a.bbox))
50
+ else:
51
+ leftovers.append(a)
52
+ for j, b in enumerate(teacher_b):
53
+ if j in used_b:
54
+ continue
55
+ duplicate = any(p.cls == b.cls and iou(p.bbox, b.bbox) >= MATCH_IOU for p in positives)
56
+ if duplicate:
57
+ continue
58
+ if b.score >= policy.solo_score:
59
+ positives.append(GroundTruthBox(b.cls, b.bbox))
60
+ else:
61
+ leftovers.append(b)
62
+ dont_care = [
63
+ GroundTruthBox(d.cls, d.bbox)
64
+ for d in leftovers
65
+ if d.score >= policy.care_score
66
+ and not any(p.cls == d.cls and iou(p.bbox, d.bbox) >= MATCH_IOU for p in positives)
67
+ ]
68
+ return positives, dont_care
camstack_train/_vendor/pipeline_python/inference_pool.py ADDED
The diff for this file is too large to render. See raw diff
 
camstack_train/_vendor/pipeline_python/postprocessors/__init__.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Postprocessor dispatch table for inference_pool.py.
2
+
3
+ Each postprocessor takes raw model output (dict of numpy arrays) and model config,
4
+ returns a structured dict matching the StepOutput JSON format.
5
+ """
6
+ from .yolo import postprocess_yolo
7
+ from .yolo_seg import postprocess_yolo_seg
8
+ from .scrfd import postprocess_scrfd
9
+ from .arcface import postprocess_arcface
10
+ from .clip import postprocess_clip
11
+ from .softmax import postprocess_softmax
12
+ from .ctc import postprocess_ctc
13
+ from .plate_slots import postprocess_plate_slots
14
+ from .saliency import postprocess_saliency
15
+ from .yamnet import postprocess_yamnet
16
+ from .ssd import postprocess_ssd
17
+ from .rfdetr import postprocess_rfdetr
18
+ from .yolonas import postprocess_yolonas
19
+ from .yunet import postprocess_yunet
20
+ from .yolo_split_dfl import postprocess_yolo_split_dfl
21
+
22
+ POSTPROCESSORS = {
23
+ "yolo": postprocess_yolo,
24
+ "yolo-seg": postprocess_yolo_seg,
25
+ "scrfd": postprocess_scrfd,
26
+ "arcface": postprocess_arcface,
27
+ "clip": postprocess_clip,
28
+ "softmax": postprocess_softmax,
29
+ "ctc": postprocess_ctc,
30
+ "plate-slots": postprocess_plate_slots,
31
+ "saliency": postprocess_saliency,
32
+ "yamnet": postprocess_yamnet,
33
+ "ssd": postprocess_ssd,
34
+ "rfdetr": postprocess_rfdetr,
35
+ "yolonas": postprocess_yolonas,
36
+ "yunet": postprocess_yunet,
37
+ "yolo-split-dfl": postprocess_yolo_split_dfl,
38
+ }
camstack_train/_vendor/pipeline_python/postprocessors/arcface.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ArcFace face embedding postprocessor.
2
+
3
+ Input: raw embedding tensor from ArcFace model
4
+ Output: {"kind": "embedding", "embedding": [...], "embeddingNorm": [...]}
5
+ """
6
+ import numpy as np
7
+
8
+
9
+ def postprocess_arcface(
10
+ predictions: dict,
11
+ config: dict,
12
+ orig_w: int,
13
+ orig_h: int,
14
+ scale: float,
15
+ pad: tuple[int, int],
16
+ ) -> dict:
17
+ """L2-normalize the embedding vector."""
18
+ # Get the first output tensor (usually named "fc1" or similar)
19
+ raw = np.array(list(predictions.values())[0]).flatten().astype(np.float32)
20
+
21
+ norm = np.linalg.norm(raw)
22
+ if norm == 0:
23
+ normalized = np.zeros_like(raw)
24
+ else:
25
+ normalized = raw / norm
26
+
27
+ return {
28
+ "kind": "embedding",
29
+ "embedding": raw.tolist(),
30
+ "embeddingNorm": normalized.tolist(),
31
+ }
camstack_train/_vendor/pipeline_python/postprocessors/clip.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CLIP image embedding postprocessor.
2
+
3
+ Input: raw embedding tensor from a CLIP vision encoder (e.g. MobileCLIP S0/S1/S2)
4
+ Output: {"kind": "embedding", "embedding": [...], "embeddingNorm": [...]}
5
+
6
+ MobileCLIP uses resize 256×256 + rescale /255 only (no ImageNet mean/std).
7
+ The existing inference_pool resize_image path already produces this layout —
8
+ preprocessMode:'resize' + inputNormalization:'none' is the correct config.
9
+ """
10
+ import numpy as np
11
+
12
+
13
+ def postprocess_clip(
14
+ predictions: dict,
15
+ config: dict,
16
+ orig_w: int,
17
+ orig_h: int,
18
+ scale: float,
19
+ pad: tuple[int, int],
20
+ ) -> dict:
21
+ """L2-normalize the CLIP embedding vector."""
22
+ # Get the first output tensor (vision encoder emits a single embedding)
23
+ raw = np.array(list(predictions.values())[0]).flatten().astype(np.float32)
24
+
25
+ norm = np.linalg.norm(raw)
26
+ if norm == 0:
27
+ normalized = np.zeros_like(raw)
28
+ else:
29
+ normalized = raw / norm
30
+
31
+ return {
32
+ "kind": "embedding",
33
+ "embedding": raw.tolist(),
34
+ "embeddingNorm": normalized.tolist(),
35
+ }
camstack_train/_vendor/pipeline_python/postprocessors/ctc.py ADDED
@@ -0,0 +1,406 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CTC text recognition postprocessor.
2
+
3
+ Used for: vgg_english_g2 (plate OCR).
4
+
5
+ Input: [1, seqLen, numChars] logits
6
+ Output: {"kind": "text", "text": "ABC123", "confidence": 0.95, "formatValid": true}
7
+
8
+ Algorithm: argmax per timestep → collapse consecutive duplicates → remove blank (index 0) → join
9
+
10
+ Plausibility gate
11
+ -----------------
12
+ When the recognizer is fed a too-low-resolution / heavily-blurred plate crop
13
+ its logit map collapses toward the CTC blank and greedy decode emits a spurious
14
+ 1–2 character read (e.g. a lone "A") at whatever confidence the single surviving
15
+ timestep carries. That noise was being stored verbatim as a "plate" label. The
16
+ charset + greedy decode themselves are correct — this only rejects a result too
17
+ short to be a real plate (`minTextLength`, non-space chars) or below a confidence
18
+ floor (`minTextConfidence`), returning empty text so no garbage label is stored.
19
+ Real plates read at 5+ chars / conf ~0.86–1.0, so the conservative defaults never
20
+ suppress a genuine read; both are config-overridable for per-camera tuning.
21
+
22
+ Normalisation before the grammar
23
+ --------------------------------
24
+ The recognizer's charset carries lowercase letters and a space, and live reads
25
+ use them freely ("dn Ro 3o9" is the same plate as "DNRO309"). The grammar and
26
+ the confusable table are both defined over UPPERCASE alphanumerics, so a raw
27
+ read is upper-cased and stripped of non-alphanumerics BEFORE either runs —
28
+ otherwise a perfectly readable plate fails the grammar for its case, and the
29
+ 0↔O repair cannot even be attempted (lowercase 'o' is not a CONFUSABLES key).
30
+ The per-character scores and aligned timesteps are filtered alongside the
31
+ glyphs, so the rescorer keeps reading the softmax mass at the right timestep.
32
+
33
+ The RAW text still feeds the symbol gate below: normalisation must never be
34
+ able to launder hallucinated punctuation into a conforming plate. A read that
35
+ carries a symbol glyph is rejected before the grammar is ever consulted, so
36
+ repair only ever runs on a read that already passed the plausibility gate.
37
+
38
+ Format-aware rescoring (post-hoc)
39
+ ---------------------------------
40
+ After the plausibility gate, the NORMALISED read is validated against the active
41
+ region's plate grammar (`plateRegion`, default 'DE'; 'off' disables). If the
42
+ letter/digit sequence already matches, it is accepted as-is (`formatValid: true`).
43
+ If it does NOT match, candidate reads are generated by substituting classic OCR
44
+ confusables (0↔O, 1↔I, 8↔B, 5↔S, 2↔Z, 6↔G, 4↔A) position-by-position; each
45
+ candidate is scored by the mean per-character probability and the highest-scoring
46
+ GRAMMAR-CONFORMING candidate replaces the read (`formatValid: true`). If no
47
+ candidate matches, the original greedy read is kept verbatim (`formatValid:
48
+ false`) — validation NEVER drops a plausible read, it only fixes or annotates it.
49
+
50
+ Per-character probability approximation
51
+ ---------------------------------------
52
+ The greedy CTC path collapses consecutive-duplicate argmax timesteps; for each
53
+ emitted output character we retain the single timestep `t` at which its run
54
+ began. A candidate's per-character probability is then approximated by the
55
+ softmax at that ONE aligned timestep — for an unchanged position the original
56
+ argmax probability, and for a substituted position `probs[t, altIndex]` (the
57
+ real softmax mass the confusable already carried at that timestep). This is an
58
+ approximation: it does not re-run a full CTC forward alignment for the candidate
59
+ string (which could re-collapse differently). For single-character confusable
60
+ swaps it is a faithful, cheap proxy — the timestep that produced the original
61
+ glyph is exactly where its confusable competes for probability.
62
+ """
63
+ import itertools
64
+ import re
65
+
66
+ import numpy as np
67
+
68
+ # Shortest plausible plate (non-space chars). No real plate is 1–2 chars, so
69
+ # this only rejects the degenerate near-all-blank collapse; kept conservative to
70
+ # never drop a genuine short/vanity plate.
71
+ DEFAULT_MIN_TEXT_LENGTH = 3
72
+ # Confidence floor (0.0 = disabled by default — character count is the reliable
73
+ # discriminator for the degenerate collapse; the "A"@1.0 case is caught by
74
+ # length, not confidence). Available for per-camera tuning via config.
75
+ DEFAULT_MIN_TEXT_CONFIDENCE = 0.0
76
+ # Product-level default plate grammar region — supplied to this postprocessor by
77
+ # the TS step config (`plateRegion`, see step-definitions.ts). When the key is
78
+ # ABSENT from `config` the postprocessor falls back to 'off' (no rescoring) so a
79
+ # bare / pre-plumbing call is byte-identical to the historical decode behaviour;
80
+ # the DE default only takes effect once the config plumbing supplies it.
81
+ DEFAULT_PLATE_REGION = "DE"
82
+ # Fallback when `plateRegion` is absent from config (backward-compatible: no
83
+ # rescoring, no formatValid annotation).
84
+ ABSENT_PLATE_REGION = "off"
85
+ # Cap on the number of positions that may simultaneously carry a confusable
86
+ # alternative. Each substitutable position doubles the candidate count, so the
87
+ # worst case is 2**MAX_SUBST_POSITIONS candidates. Plates are ≤8 glyphs so this
88
+ # is rarely binding, but the cap guarantees a bounded search (≤256 candidates)
89
+ # even on a pathological long read: when more positions than this have a
90
+ # confusable, only the LOWEST-confidence positions (the most likely misreads)
91
+ # stay substitutable; the rest are locked to their greedy glyph.
92
+ MAX_SUBST_POSITIONS = 8
93
+
94
+ # Classic OCR confusable pairs, bidirectional. Maps a glyph to the alternates it
95
+ # is commonly mistaken for/against. Used ONLY to repair a read that fails the
96
+ # grammar — never to alter a read that already conforms.
97
+ CONFUSABLES: dict[str, tuple[str, ...]] = {
98
+ "0": ("O",),
99
+ "O": ("0",),
100
+ "1": ("I",),
101
+ "I": ("1",),
102
+ "8": ("B",),
103
+ "B": ("8",),
104
+ "5": ("S",),
105
+ "S": ("5",),
106
+ "2": ("Z",),
107
+ "Z": ("2",),
108
+ "6": ("G",),
109
+ "G": ("6",),
110
+ "4": ("A",),
111
+ "A": ("4",),
112
+ }
113
+
114
+ # Region plate grammars, keyed by ISO-3166 alpha-2 code. Each pattern validates
115
+ # the RAW letter/digit sequence the OCR emits (no separators — the recognizer
116
+ # never outputs the dash/space of the physical plate). Ready for future
117
+ # countries ('IT', 'FR', …); 'off' (handled before lookup) disables rescoring.
118
+ #
119
+ # GERMANY ('DE'): district code (1–3 letters) + 1–2 letters + 1–4 digits +
120
+ # optional E (electric) / H (historic) suffix; ≤8 glyphs excluding separators.
121
+ # Separators are optional in the pattern for robustness, though the OCR emits
122
+ # none. The letter-block split (district vs. series) is inherently ambiguous on
123
+ # the raw string, but that does not matter — the regex validates the whole
124
+ # sequence, not the split.
125
+ PLATE_GRAMMARS: dict[str, str] = {
126
+ "DE": r"^[A-ZÄÖÜ]{1,3}[- ]?[A-Z]{1,2}[- ]?[0-9]{1,4}[EH]?$",
127
+ }
128
+
129
+
130
+ def _matches_grammar(text: str, pattern) -> bool:
131
+ return pattern.match(text) is not None
132
+
133
+
134
+ def _normalize_read(
135
+ chars: list[str],
136
+ char_scores: list[float],
137
+ char_timesteps: list[int],
138
+ ) -> tuple[str, list[float], list[int]]:
139
+ """Upper-case + drop non-alphanumeric glyphs, keeping scores/timesteps aligned.
140
+
141
+ The grammar patterns and CONFUSABLES are defined over uppercase alnums; the
142
+ charset the recognizer decodes from is not. Returning the filtered score and
143
+ timestep lists alongside the text is what lets the rescorer keep reading a
144
+ candidate glyph's softmax mass at the timestep that produced its position.
145
+
146
+ A glyph whose upper-case form is not a single character (no such glyph in
147
+ this charset, but the fold is locale-dependent in general) is kept as-is
148
+ rather than silently changing the string length.
149
+ """
150
+ out_chars: list[str] = []
151
+ out_scores: list[float] = []
152
+ out_timesteps: list[int] = []
153
+ for ch, score, timestep in zip(chars, char_scores, char_timesteps):
154
+ if not ch.isalnum():
155
+ continue
156
+ upper = ch.upper()
157
+ out_chars.append(upper if len(upper) == 1 else ch)
158
+ out_scores.append(score)
159
+ out_timesteps.append(timestep)
160
+ return "".join(out_chars), out_scores, out_timesteps
161
+
162
+
163
+ def _rescore_for_grammar(
164
+ text: str,
165
+ char_scores: list[float],
166
+ char_timesteps: list[int],
167
+ probs: np.ndarray,
168
+ charset: list[str],
169
+ pattern,
170
+ ) -> tuple[str, bool]:
171
+ """Return (repaired_or_original_text, format_valid).
172
+
173
+ If `text` already matches `pattern`, return it unchanged with True.
174
+ Otherwise enumerate confusable substitutions (bounded), score each candidate
175
+ by mean per-character probability, and return the highest-scoring candidate
176
+ that matches the grammar. If none match, return the original text with False.
177
+ """
178
+ if _matches_grammar(text, pattern):
179
+ return text, True
180
+
181
+ chars = list(text)
182
+ n = len(chars)
183
+
184
+ # Reverse charset lookup (glyph → first index) so a substituted glyph's
185
+ # softmax mass can be read from the aligned timestep. Built once per call.
186
+ char_to_index: dict[str, int] = {}
187
+ for idx, glyph in enumerate(charset):
188
+ if glyph and glyph not in char_to_index:
189
+ char_to_index[glyph] = idx
190
+
191
+ # Which positions carry a usable confusable alternative (both the glyph has a
192
+ # mapping AND the alternate is representable in this charset so its timestep
193
+ # probability can be looked up).
194
+ subst_positions: list[int] = []
195
+ for i, ch in enumerate(chars):
196
+ alts = CONFUSABLES.get(ch)
197
+ if not alts:
198
+ continue
199
+ if any(alt in char_to_index for alt in alts):
200
+ subst_positions.append(i)
201
+
202
+ if not subst_positions:
203
+ return text, False
204
+
205
+ # Bound the search: keep only the MAX_SUBST_POSITIONS lowest-confidence
206
+ # positions substitutable (most likely misreads); lock the rest.
207
+ if len(subst_positions) > MAX_SUBST_POSITIONS:
208
+ subst_positions.sort(key=lambda i: char_scores[i])
209
+ subst_positions = sorted(subst_positions[:MAX_SUBST_POSITIONS])
210
+
211
+ subst_set = set(subst_positions)
212
+
213
+ # Per-position option list: original glyph, plus any representable confusables
214
+ # (only for substitutable positions). itertools.product enumerates the grid.
215
+ options: list[list[str]] = []
216
+ for i, ch in enumerate(chars):
217
+ if i in subst_set:
218
+ alts = [a for a in CONFUSABLES.get(ch, ()) if a in char_to_index]
219
+ options.append([ch, *alts])
220
+ else:
221
+ options.append([ch])
222
+
223
+ def _prob_at(i: int, glyph: str) -> float:
224
+ if glyph == chars[i]:
225
+ return char_scores[i]
226
+ idx = char_to_index.get(glyph)
227
+ if idx is None:
228
+ return 0.0
229
+ return float(probs[char_timesteps[i], idx])
230
+
231
+ best_text: str | None = None
232
+ best_score = -1.0
233
+ for combo in itertools.product(*options):
234
+ candidate = "".join(combo)
235
+ if candidate == text:
236
+ continue # already known non-matching (checked above)
237
+ if not _matches_grammar(candidate, pattern):
238
+ continue
239
+ score = sum(_prob_at(i, combo[i]) for i in range(n)) / n if n else 0.0
240
+ if score > best_score:
241
+ best_score = score
242
+ best_text = candidate
243
+
244
+ if best_text is not None:
245
+ return best_text, True
246
+ return text, False
247
+
248
+
249
+ # Glyphs the recognizer emits for a plate's physical registration seal.
250
+ SEAL_GLYPHS = frozenset(".:;,*-'`\"·")
251
+
252
+
253
+ def _strip_seal_glyph(text: str, region: str) -> "str | None":
254
+ """Return the normalised plate when `text` is a conforming read carrying ONE
255
+ interior seal glyph; ``None`` otherwise (caller applies the normal gate).
256
+
257
+ Narrow by construction: exactly one seal-like glyph, never at either end,
258
+ nothing else non-alphanumeric left over, and the normalised remainder must
259
+ match the region grammar. A read that does not become a legal plate is not
260
+ rescued.
261
+ """
262
+ pattern_src = PLATE_GRAMMARS.get(region)
263
+ if pattern_src is None:
264
+ return None
265
+ seals = [i for i, c in enumerate(text) if c in SEAL_GLYPHS]
266
+ if len(seals) != 1:
267
+ return None
268
+ pos = seals[0]
269
+ if pos == 0 or pos == len(text) - 1:
270
+ return None
271
+ stripped = text[:pos] + text[pos + 1 :]
272
+ if any(not (c.isalnum() or c.isspace()) for c in stripped):
273
+ return None
274
+ normalised = "".join(c for c in stripped.upper() if c.isalnum())
275
+ if re.match(pattern_src, normalised) is None:
276
+ return None
277
+ return normalised
278
+
279
+
280
+ def postprocess_ctc(
281
+ predictions: dict,
282
+ config: dict,
283
+ orig_w: int,
284
+ orig_h: int,
285
+ scale: float,
286
+ pad: tuple[int, int],
287
+ ) -> dict:
288
+ """CTC greedy decode."""
289
+ charset = config.get("charset", [])
290
+
291
+ # Get first output tensor
292
+ raw = np.array(list(predictions.values())[0]).astype(np.float32)
293
+
294
+ # Handle shapes: [1, T, C] or [T, C]
295
+ if raw.ndim == 3:
296
+ raw = raw[0] # remove batch
297
+ # raw is now [T, C] — logits (not probabilities)
298
+ seq_len, num_chars = raw.shape
299
+
300
+ # Apply softmax to get probabilities
301
+ raw_max = np.max(raw, axis=1, keepdims=True)
302
+ exp_raw = np.exp(raw - raw_max)
303
+ probs = exp_raw / np.sum(exp_raw, axis=1, keepdims=True)
304
+
305
+ # Argmax per timestep
306
+ best_indices = np.argmax(probs, axis=1) # [T]
307
+ best_scores = probs[np.arange(seq_len), best_indices] # [T]
308
+
309
+ # Collapse consecutive duplicates (retain the timestep each run began at so
310
+ # the rescorer can read a confusable's softmax mass at that aligned step).
311
+ collapsed = []
312
+ collapsed_scores = []
313
+ collapsed_timesteps = []
314
+ for i, idx in enumerate(best_indices):
315
+ if i == 0 or idx != best_indices[i - 1]:
316
+ collapsed.append(int(idx))
317
+ collapsed_scores.append(float(best_scores[i]))
318
+ collapsed_timesteps.append(i)
319
+
320
+ # Remove blank (index 0) and join
321
+ chars = []
322
+ scores = []
323
+ char_timesteps = []
324
+ for idx, score, t in zip(collapsed, collapsed_scores, collapsed_timesteps):
325
+ if idx != 0:
326
+ if idx < len(charset):
327
+ chars.append(charset[idx])
328
+ else:
329
+ chars.append(chr(idx))
330
+ scores.append(score)
331
+ char_timesteps.append(t)
332
+
333
+ text = "".join(chars)
334
+ confidence = float(np.mean(scores)) if scores else 0.0
335
+
336
+ # Plausibility gate — suppress the degenerate near-all-blank collapse so
337
+ # low-resolution noise is never stored as a plate label. Count ALPHANUMERIC
338
+ # characters only ("DW RO 309" → 7): the charset carries symbol glyphs
339
+ # ('[', ')', '.', …) that a blurred crop hallucinates, and they must not
340
+ # count toward "long enough to be a plate".
341
+ min_len = int(config.get("minTextLength", DEFAULT_MIN_TEXT_LENGTH))
342
+ min_conf = float(config.get("minTextConfidence", DEFAULT_MIN_TEXT_CONFIDENCE))
343
+ region = str(config.get("plateRegion", ABSENT_PLATE_REGION))
344
+ alnum_count = sum(1 for c in text if c.isalnum())
345
+ # Symbol-blind gate: when a plate grammar/region is ACTIVE, no real plate
346
+ # contains a non-alphanumeric glyph (spaces allowed — word gaps are real).
347
+ # Observed live labels "[miv4o)" / "6ou.2" (2026-07-22) sailed through the
348
+ # length-only gate; reject them exactly like the other implausible reads.
349
+ has_symbol_glyph = region != "off" and any(
350
+ not (c.isalnum() or c.isspace()) for c in text
351
+ )
352
+ # …except the SEAL. A German plate physically carries a registration seal
353
+ # between the district code and the letter group, and the recognizer
354
+ # transcribes that badge as a punctuation glyph ('.', ':', '-', …). Once the
355
+ # quad warp (2026-08-20) started delivering legible strips, the correct
356
+ # decodes arrived as "DN.D7578" / "DN-P 1091" / "DN:HF 974" and this gate
357
+ # threw away every one of them — the warp scored 1/21 instead of 4/21 on the
358
+ # cam-617 ground-truth set purely because of this line.
359
+ #
360
+ # The exemption is deliberately narrow: EXACTLY ONE seal-like glyph, strictly
361
+ # INTERIOR, and the remainder must CONFORM to the region grammar. Noise
362
+ # cannot be laundered by it — the 2026-07-22 junk ("6ou.2x", "[miv4o)") has
363
+ # either the wrong count, the wrong position, or no conforming remainder.
364
+ if has_symbol_glyph:
365
+ repaired = _strip_seal_glyph(text, region)
366
+ if repaired is not None:
367
+ return {
368
+ "kind": "text",
369
+ "text": repaired,
370
+ "confidence": round(confidence, 4),
371
+ "formatValid": True,
372
+ }
373
+ if alnum_count < min_len or confidence < min_conf or has_symbol_glyph:
374
+ return {
375
+ "kind": "text",
376
+ "text": "",
377
+ "confidence": round(confidence, 4),
378
+ }
379
+
380
+ result = {
381
+ "kind": "text",
382
+ "text": text,
383
+ "confidence": round(confidence, 4),
384
+ }
385
+
386
+ # Post-hoc format validation + confusable rescoring. 'off' disables it
387
+ # entirely (no grammar lookup, no formatValid annotation).
388
+ if region != "off":
389
+ pattern_src = PLATE_GRAMMARS.get(region)
390
+ if pattern_src is not None:
391
+ pattern = re.compile(pattern_src)
392
+ # Grammar + confusables run on the NORMALISED read (upper, alnum
393
+ # only). Reached only past the plausibility gate above, so a repair
394
+ # can never turn sub-legible noise into a more credible string: the
395
+ # symbol gate already rejected the reads that would qualify, and the
396
+ # normalised length equals the alnum count the length gate cleared.
397
+ norm_text, norm_scores, norm_timesteps = _normalize_read(
398
+ chars, scores, char_timesteps
399
+ )
400
+ fixed_text, format_valid = _rescore_for_grammar(
401
+ norm_text, norm_scores, norm_timesteps, probs, list(charset), pattern
402
+ )
403
+ result["text"] = fixed_text
404
+ result["formatValid"] = format_valid
405
+
406
+ return result
camstack_train/_vendor/pipeline_python/postprocessors/plate_slots.py ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Fixed-slot license-plate recognition postprocessor.
2
+
3
+ Used for: `cct-s-v2-global` (fast-plate-ocr), the plate-OCR default since
4
+ 2026-08-20.
5
+
6
+ Input: the `plate` head, ``[1, PLATE_SLOTS, VOCAB]`` (or the flattened
7
+ ``[1, PLATE_SLOTS * VOCAB]``) — an INDEPENDENT softmax per character position,
8
+ already normalised inside the graph.
9
+ Output: ``{"kind": "text", "text": "DNRO309", "confidence": 0.99,
10
+ "formatValid": true}`` — the same StepOutput shape the CTC path produces, so
11
+ nothing downstream (the label writer, the D186 per-track vote) changes.
12
+
13
+ Why this is not `ctc.py`
14
+ ------------------------
15
+ The two readers fail in opposite directions and that dictates opposite gates.
16
+
17
+ A CTC recognizer fed noise collapses toward the blank: the decode comes out one
18
+ or two characters long, which is why "at least `minTextLength` alphanumerics"
19
+ was a sound plausibility gate for `vgg_english_g2`. A fixed-slot reader has no
20
+ blank. It runs `PLATE_SLOTS` classification heads and every one of them picks a
21
+ winner, on a plate, on a bumper, on an OSD weather band. Length carries no
22
+ information here — `minTextLength` would admit literally every ROI the detector
23
+ ever hands over.
24
+
25
+ What DOES separate them is calibration, and only at the weakest position. On the
26
+ 39-crop cam-617 ground-truth set (21 plates, 9 vehicles, 18 junk ROIs) every
27
+ correct read has a minimum per-character probability of 0.80 or better, while no
28
+ junk ROI reaches it; the misreads that do get close are wrong in exactly one
29
+ slot, and that slot is the one that dips. The MEAN hides this — six confident
30
+ glyphs and one guess still average ~0.9 — so the gate reads the MINIMUM.
31
+
32
+ Second gate: the region grammar (`plateRegion`, shared with `ctc.py` so there is
33
+ one German plate pattern in this codebase, not two). Here a non-conforming read
34
+ is DROPPED rather than kept-and-annotated. That differs from the CTC path on
35
+ purpose: this alphabet is 36 alphanumerics plus a pad, so it cannot emit the
36
+ "obviously not a plate" shapes (`[miv4o)`, `6ou.2`) that made an annotated read
37
+ worth storing — a non-conforming read here is a confident misread, which is the
38
+ one thing that must never reach a label.
39
+
40
+ No confusable rescoring
41
+ -----------------------
42
+ `ctc.py` repairs a grammar failure by substituting 0↔O, 1↔I, 5↔S… That machinery
43
+ exists because `vgg_english_g2` decodes from a 97-glyph English charset where
44
+ case and punctuation are live classes. This alphabet has neither, so the only
45
+ surviving confusion is 0↔O — and, run over the 39-crop set at the shipped
46
+ operating point, the rescorer fires on zero crops. An unfired repair path is a
47
+ path nothing verifies, so it is not here. If a future set shows it firing, take
48
+ it from `ctc.py`: `_rescore_for_grammar` is already position/probability
49
+ indexed, and a slot index is exactly the "aligned timestep" it wants.
50
+ """
51
+ import re
52
+
53
+ import numpy as np
54
+
55
+ from .ctc import ABSENT_PLATE_REGION, PLATE_GRAMMARS
56
+
57
+ # The `cct_s_v2_global_plate_config.yaml` contract, published beside the weights.
58
+ # Class indices are positions in this string; a drift re-maps every glyph.
59
+ PLATE_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_"
60
+ PLATE_PAD_CHAR = "_"
61
+ PLATE_SLOTS = 10
62
+
63
+ # Minimum probability the WEAKEST emitted character must carry.
64
+ #
65
+ # 0.75 is not a round number picked for taste — it is the centre of the band
66
+ # that separates the two populations on the cam-617 set WITH THE INFERENCE
67
+ # PRECISION SPREAD INCLUDED. Taking, per crop, the worst case across ONNX fp32,
68
+ # OpenVINO fp16 and CoreML: the highest-scoring junk ROI that also conforms to
69
+ # the German grammar reaches 0.728, and the lowest-scoring correct plate that
70
+ # still has to be read sits at 0.779. Anything inside (0.728, 0.779) stores
71
+ # every one of those plates and none of that junk on every engine.
72
+ #
73
+ # The band matters because a plate recognizer does not get to choose its
74
+ # arithmetic: OpenVINO's CPU default inference precision is fp16, an Intel NPU
75
+ # has no other option, and a marginal crop's weakest character moves by as much
76
+ # as 0.16 between engines. A floor chosen from one engine's numbers would be a
77
+ # different filter on the next node.
78
+ #
79
+ # The two sides of the band are not symmetric, which is why 0.75 leans up. Too
80
+ # LOW stores a wrong plate, and nothing downstream can undo that. Too HIGH
81
+ # silences a marginal read — and the step runs up to `maxPerTrack` times per
82
+ # track with the reads voted (D186), so the next frame gets another go.
83
+ #
84
+ # Config-overridable (`minCharProbability`).
85
+ DEFAULT_MIN_CHAR_PROBABILITY = 0.75
86
+
87
+ # Name of the plate head in the published graph. The region head (`region`,
88
+ # [1,66]) rides along; it is NOT consulted — at this floor every junk ROI it
89
+ # would have caught is already below the confidence gate, and a second gate
90
+ # nothing removes is a second thing to keep true.
91
+ PLATE_OUTPUT_NAME = "plate"
92
+
93
+
94
+ def _select_plate_logits(predictions: dict) -> np.ndarray:
95
+ """Return the plate head as ``[PLATE_SLOTS, VOCAB]``.
96
+
97
+ By NAME first — the graph has two outputs and dict ordering is not a
98
+ contract. By SHAPE as the fallback, so an engine whose exporter dropped the
99
+ tensor names still decodes the right head rather than the region logits.
100
+ """
101
+ vocab = len(PLATE_ALPHABET)
102
+ expected = PLATE_SLOTS * vocab
103
+ candidate = predictions.get(PLATE_OUTPUT_NAME)
104
+ if candidate is None:
105
+ for value in predictions.values():
106
+ array = np.asarray(value)
107
+ if array.size == expected:
108
+ candidate = array
109
+ break
110
+ if candidate is None:
111
+ raise ValueError(
112
+ f"no plate head in predictions {sorted(predictions)}: "
113
+ f"expected an output of {expected} values"
114
+ )
115
+ array = np.asarray(candidate, dtype=np.float32)
116
+ if array.size != expected:
117
+ raise ValueError(
118
+ f"plate head has {array.size} values, expected "
119
+ f"{PLATE_SLOTS}x{vocab}={expected}"
120
+ )
121
+ return array.reshape(PLATE_SLOTS, vocab)
122
+
123
+
124
+ def postprocess_plate_slots(
125
+ predictions: dict,
126
+ config: dict,
127
+ orig_w: int,
128
+ orig_h: int,
129
+ scale: float,
130
+ pad: tuple[int, int],
131
+ ) -> dict:
132
+ """Per-slot argmax decode + confidence floor + region grammar."""
133
+ probs = _select_plate_logits(predictions)
134
+ alphabet = str(config.get("plateAlphabet") or PLATE_ALPHABET)
135
+ pad_char = str(config.get("platePadChar") or PLATE_PAD_CHAR)
136
+ min_char_prob = float(
137
+ config.get("minCharProbability", DEFAULT_MIN_CHAR_PROBABILITY)
138
+ )
139
+ region = str(config.get("plateRegion", ABSENT_PLATE_REGION))
140
+
141
+ winners = np.argmax(probs, axis=-1)
142
+ scores = probs[np.arange(len(winners)), winners]
143
+ decoded = "".join(alphabet[int(i)] for i in winners)
144
+
145
+ # Trailing pad is the model's length signal; strip it and keep the scores of
146
+ # the glyphs that survive aligned with them.
147
+ text = decoded.rstrip(pad_char)
148
+ kept = [float(s) for s in scores[: len(text)]]
149
+ confidence = round(float(np.mean(kept)), 4) if kept else 0.0
150
+
151
+ def silent() -> dict:
152
+ return {"kind": "text", "text": "", "confidence": confidence}
153
+
154
+ if not text:
155
+ return silent()
156
+ # A pad the model placed BETWEEN glyphs is a refusal at a position it
157
+ # believes exists — never a plate, and never something to paper over.
158
+ if pad_char in text:
159
+ return silent()
160
+ if min(kept) < min_char_prob:
161
+ return silent()
162
+
163
+ pattern_src = PLATE_GRAMMARS.get(region) if region != "off" else None
164
+ if pattern_src is None:
165
+ # No grammar for this region (or rescoring disabled): the read stands on
166
+ # the confidence gate alone, and says so.
167
+ return {
168
+ "kind": "text",
169
+ "text": text,
170
+ "confidence": confidence,
171
+ "formatValid": False,
172
+ }
173
+ if re.match(pattern_src, text) is None:
174
+ return silent()
175
+ return {
176
+ "kind": "text",
177
+ "text": text,
178
+ "confidence": confidence,
179
+ "formatValid": True,
180
+ }
camstack_train/_vendor/pipeline_python/postprocessors/rfdetr.py ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """RF-DETR postprocessor (DETR-style query decode).
2
+
3
+ Used by the `rfdetr-package` parcel detector on the `package-detection` step
4
+ (per-model `postprocessor: 'rfdetr'` override — the step default is `yolo`).
5
+
6
+ Model contract, verified on the shipped export (onnx / openvino / coreml all
7
+ agree to |d| <= 0.005 in sigmoid space):
8
+
9
+ input "input" [1, 3, 576, 576] float, square RESIZE (no letterbox),
10
+ ImageNet mean/std normalised
11
+ (catalog: preprocessMode 'resize' +
12
+ inputNormalization 'imagenet')
13
+ output "dets" [1, 300, 4] cxcywh, NORMALISED to the input image
14
+ output "labels" [1, 300, 2] RAW logits; sigmoid -> per-query prob.
15
+
16
+ Three things differ from the `yolo` postprocessor and every one of them is a
17
+ way to get zero detections while looking correct:
18
+
19
+ 1. **Column 0 is the no-object slot; the real classes start at 1.**
20
+ The upstream rfdetr helper slices `logits[..., :-1]` — it drops the LAST
21
+ column. On this checkpoint (`num_classes=1` ⇒ head width 2) that would throw
22
+ away the `package` column and keep the background one, so every frame
23
+ decodes as "nothing, forever". We slice `[..., 1:]`. Measured: the
24
+ background column never exceeded 0.003 on any test frame, parcel or not.
25
+
26
+ 2. **No letterbox, so `scale`/`pad` are unused.** Boxes map onto the original
27
+ crop by multiplying the normalised cxcywh by orig_w / orig_h directly. The
28
+ normalised coords are NOT sigmoid-bounded and DO exceed 1.0 (and go below 0)
29
+ on edge queries — they are clamped to the frame, and a box that clamps to
30
+ nothing is dropped.
31
+
32
+ 3. **300 independent queries, no objectness.** NMS still runs (near-duplicate
33
+ queries do occur) but the model is not anchor-based, so the default IoU is
34
+ looser (0.50) than the yolo default on purpose.
35
+
36
+ DEGENERATE-BOX GUARD (measured, not theoretical): on a parcel-free crop
37
+ dominated by one large dark rectangle (the doormat) the top query becomes a box
38
+ spanning ~the whole crop at 0.33. Every real parcel detection was <= 25% of the
39
+ crop area. Rejecting boxes covering more than `maxAreaFraction` of the crop
40
+ removes that failure mode without touching a single true positive.
41
+
42
+ Output matches the yolo postprocessor:
43
+ ``{"kind": "detections", "detections": [{"class", "score", "bbox":[x1,y1,x2,y2]}]}``
44
+ with pixel bboxes in ORIGINAL frame coordinates.
45
+ """
46
+ from __future__ import annotations
47
+
48
+ import numpy as np
49
+
50
+ # Fraction of the input crop above which a box is treated as the degenerate
51
+ # whole-crop query rather than a parcel. Config key: `maxAreaFraction`.
52
+ DEFAULT_MAX_AREA_FRACTION = 0.60
53
+ # DETR queries are not anchors, so duplicates overlap less tightly than a yolo
54
+ # grid does. Config key: `nmsIouThreshold` (shared with the yolo decoder).
55
+ DEFAULT_NMS_IOU = 0.50
56
+
57
+
58
+ def _iou(a: tuple, b: tuple) -> float:
59
+ """IoU between two boxes (x1, y1, x2, y2)."""
60
+ ix1 = max(a[0], b[0])
61
+ iy1 = max(a[1], b[1])
62
+ ix2 = min(a[2], b[2])
63
+ iy2 = min(a[3], b[3])
64
+ inter = max(0.0, ix2 - ix1) * max(0.0, iy2 - iy1)
65
+ if inter == 0:
66
+ return 0.0
67
+ union = (a[2] - a[0]) * (a[3] - a[1]) + (b[2] - b[0]) * (b[3] - b[1]) - inter
68
+ return inter / union if union > 0 else 0.0
69
+
70
+
71
+ def _nms(boxes: list, iou_threshold: float) -> list:
72
+ """Greedy non-maximum suppression over `_xyxy`, highest score first."""
73
+ kept: list = []
74
+ for box in sorted(boxes, key=lambda b: b["score"], reverse=True):
75
+ if all(_iou(box["_xyxy"], k["_xyxy"]) <= iou_threshold for k in kept):
76
+ kept.append(box)
77
+ return kept
78
+
79
+
80
+ def _resolve_outputs(predictions: dict) -> tuple:
81
+ """(dets, labels) by NAME, falling back to shape when the names are stripped.
82
+
83
+ The onnx / openvino / coreml exports all keep the `dets` + `labels` names;
84
+ the shape fallback covers a re-export that renames them (the boxes tensor is
85
+ the one whose last dim is 4).
86
+ """
87
+ if "dets" in predictions and "labels" in predictions:
88
+ return np.asarray(predictions["dets"]), np.asarray(predictions["labels"])
89
+ arrays = [np.asarray(v) for v in predictions.values()]
90
+ dets = next((a for a in arrays if a.shape[-1] == 4), None)
91
+ labels = next((a for a in arrays if a.shape[-1] != 4), None)
92
+ return dets, labels
93
+
94
+
95
+ def postprocess_rfdetr(
96
+ predictions: dict,
97
+ config: dict,
98
+ orig_w: int,
99
+ orig_h: int,
100
+ scale: float,
101
+ pad: tuple,
102
+ ) -> dict:
103
+ """Decode RF-DETR query output to original-frame detections.
104
+
105
+ `scale` / `pad` are accepted for signature parity with the other
106
+ postprocessors and deliberately UNUSED: this model is a square resize, not a
107
+ letterbox, so there is no padding to invert.
108
+ """
109
+ conf_threshold = float(config.get("confidence", 0) or 0)
110
+ labels_map = config.get("labels") or ["package"]
111
+ nms_iou = float(config.get("nmsIouThreshold", DEFAULT_NMS_IOU) or DEFAULT_NMS_IOU)
112
+ max_area = float(config.get("maxAreaFraction", DEFAULT_MAX_AREA_FRACTION) or DEFAULT_MAX_AREA_FRACTION)
113
+
114
+ dets, logits = _resolve_outputs(predictions)
115
+ if dets is None or logits is None:
116
+ return {"kind": "detections", "detections": []}
117
+
118
+ dets = dets[0] if dets.ndim == 3 else dets
119
+ logits = logits[0] if logits.ndim == 3 else logits
120
+ if dets.ndim != 2 or logits.ndim != 2 or logits.shape[1] < 2:
121
+ # A single-column `labels` would be the no-object slot alone — nothing
122
+ # to decode, and slicing it would raise instead of saying so.
123
+ return {"kind": "detections", "detections": []}
124
+
125
+ probs = 1.0 / (1.0 + np.exp(-logits.astype(np.float32)))
126
+ # Column 0 is the DETR background / no-object slot — NOT the last column.
127
+ # See the module docstring: dropping the wrong end silently yields zero
128
+ # detections on every frame.
129
+ class_probs = probs[:, 1:]
130
+ best_class = class_probs.argmax(axis=1)
131
+ best_score = class_probs.max(axis=1)
132
+
133
+ frame_area = float(orig_w * orig_h)
134
+ if frame_area <= 0:
135
+ return {"kind": "detections", "detections": []}
136
+
137
+ candidates: list = []
138
+ for i in np.nonzero(best_score >= conf_threshold)[0]:
139
+ cx, cy, w, h = (float(v) for v in dets[i][:4])
140
+ # Normalised, NOT sigmoid-bounded: edge queries exceed [0,1] and are
141
+ # clamped to the frame rather than dropped.
142
+ x1 = max(0.0, (cx - w / 2.0) * orig_w)
143
+ y1 = max(0.0, (cy - h / 2.0) * orig_h)
144
+ x2 = min(float(orig_w), (cx + w / 2.0) * orig_w)
145
+ y2 = min(float(orig_h), (cy + h / 2.0) * orig_h)
146
+ if x2 <= x1 or y2 <= y1:
147
+ continue
148
+ if ((x2 - x1) * (y2 - y1)) / frame_area > max_area:
149
+ continue # degenerate whole-crop query (the doormat failure mode)
150
+ cls = int(best_class[i])
151
+ candidates.append({
152
+ "class": labels_map[cls] if cls < len(labels_map) else str(cls),
153
+ "score": round(float(best_score[i]), 4),
154
+ "bbox": [round(x1, 1), round(y1, 1), round(x2, 1), round(y2, 1)],
155
+ "_xyxy": (x1, y1, x2, y2),
156
+ })
157
+
158
+ kept = _nms(candidates, nms_iou)
159
+ for box in kept:
160
+ box.pop("_xyxy", None)
161
+ return {"kind": "detections", "detections": kept}
camstack_train/_vendor/pipeline_python/postprocessors/saliency.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Saliency / segmentation refiner postprocessor.
2
+
3
+ Used for: u2netp.
4
+
5
+ Input: raw saliency map tensor
6
+ Output: {"kind": "mask", "mask": "<base64>", "maskWidth": N, "maskHeight": N}
7
+ """
8
+ import base64
9
+ import numpy as np
10
+
11
+
12
+ def _largest_component_bbox(binary: np.ndarray) -> tuple[int, int, int, int] | None:
13
+ """Bounding box [x, y, w, h] (mask space) of the largest 4-connected
14
+ foreground component. Pure-numpy flood fill — no OpenCV dependency.
15
+ Returns None when there is no foreground pixel."""
16
+ h, w = binary.shape
17
+ fg = binary > 0
18
+ visited = np.zeros((h, w), dtype=bool)
19
+ best: tuple[int, int, int, int, int] | None = None # (area, x, y, w, h)
20
+ ys, xs = np.nonzero(fg)
21
+ for sy, sx in zip(ys.tolist(), xs.tolist()):
22
+ if visited[sy, sx]:
23
+ continue
24
+ stack = [(sy, sx)]
25
+ visited[sy, sx] = True
26
+ minx = maxx = sx
27
+ miny = maxy = sy
28
+ area = 0
29
+ while stack:
30
+ cy, cx = stack.pop()
31
+ area += 1
32
+ if cx < minx:
33
+ minx = cx
34
+ if cx > maxx:
35
+ maxx = cx
36
+ if cy < miny:
37
+ miny = cy
38
+ if cy > maxy:
39
+ maxy = cy
40
+ for ny, nx in ((cy - 1, cx), (cy + 1, cx), (cy, cx - 1), (cy, cx + 1)):
41
+ if 0 <= ny < h and 0 <= nx < w and fg[ny, nx] and not visited[ny, nx]:
42
+ visited[ny, nx] = True
43
+ stack.append((ny, nx))
44
+ if best is None or area > best[0]:
45
+ best = (area, minx, miny, maxx - minx + 1, maxy - miny + 1)
46
+ if best is None:
47
+ return None
48
+ return (best[1], best[2], best[3], best[4])
49
+
50
+
51
+ def postprocess_saliency(
52
+ predictions: dict,
53
+ config: dict,
54
+ orig_w: int,
55
+ orig_h: int,
56
+ scale: float,
57
+ pad: tuple[int, int],
58
+ ) -> dict:
59
+ """Sigmoid → threshold → binary mask → base64."""
60
+ mask_threshold = config.get("maskThreshold", 0.5)
61
+
62
+ # U2-Net outputs multiple saliency maps; use the first (highest resolution)
63
+ raw = np.array(list(predictions.values())[0]).astype(np.float32)
64
+
65
+ # Remove batch/channel dims to get [H, W]
66
+ while raw.ndim > 2:
67
+ raw = raw[0]
68
+
69
+ # Sigmoid
70
+ saliency = 1.0 / (1.0 + np.exp(-raw))
71
+
72
+ # Threshold to binary uint8 (0 or 255)
73
+ binary = (saliency > mask_threshold).astype(np.uint8) * 255
74
+
75
+ h, w = binary.shape
76
+ mask_b64 = base64.b64encode(binary.tobytes()).decode("ascii")
77
+
78
+ result = {
79
+ "kind": "mask",
80
+ "mask": mask_b64,
81
+ "maskWidth": int(w),
82
+ "maskHeight": int(h),
83
+ }
84
+
85
+ bbox = _largest_component_bbox(binary)
86
+ if bbox is not None:
87
+ bx, by, bw, bh = bbox
88
+ result["maskBbox"] = [int(bx), int(by), int(bw), int(bh)]
89
+
90
+ return result
camstack_train/_vendor/pipeline_python/postprocessors/scrfd.py ADDED
@@ -0,0 +1,235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SCRFD face detection postprocessor.
2
+
3
+ Multi-stride anchor-based face detector (strides 8, 16, 32).
4
+ Originally ported from packages/addon-vision/src/shared/postprocess/scrfd.ts,
5
+ whose Node.js raw-tensor onnxruntime path was retired in favour of doing all
6
+ postprocessing here, in the Python inference pool — that TS file, and the
7
+ addon-vision package it lived in, no longer exist (see the header comment in
8
+ packages/addon-pipeline/src/detection-pipeline/postprocess/dispatch.ts). This
9
+ module is now the sole SCRFD decoder in the repo.
10
+
11
+ Output: {"kind": "detections", "detections": [{"class": "face", "score", "bbox": [x1,y1,x2,y2], "landmarks?"}]}
12
+ """
13
+
14
+ from __future__ import annotations
15
+ import numpy as np
16
+
17
+
18
+ STRIDES = [8, 16, 32]
19
+ NUM_ANCHORS_PER_STRIDE = 2
20
+
21
+
22
+ def _generate_anchors(stride: int, input_size: int) -> list[tuple[float, float]]:
23
+ """Generate anchor centers for a given stride.
24
+
25
+ Anchor centers are `(x * stride, y * stride)` — NO half-cell `+0.5` offset.
26
+ This matches upstream InsightFace's reference decoder exactly
27
+ (`insightface/model_zoo/scrfd.py`: `anchor_centers = np.stack([anchor_centers_x,
28
+ anchor_centers_y], axis=-1).astype(np.float32)`, built from a raw
29
+ `np.mgrid[:h, :w]` and then `anchor_centers * stride` — no `+0.5` anywhere
30
+ in that path). The previous `(x + 0.5) * stride` formula shifted every box
31
+ and landmark down-and-right by half a stride (4/8/16 px at 640), which is a
32
+ large fraction of a face after the letterbox upscale from a person crop.
33
+ Fixing this moved NME (COCO eyes+nose vs GT) 0.352 → 0.115 and AuraFace
34
+ self-consistency cosine 0.79 → 0.91 (2026-09-26 model-replacement spike,
35
+ docs/superpowers/specs/2026-09-26-replacement-models-spike.md §2.4-1).
36
+ """
37
+ feat_size = int(np.ceil(input_size / stride))
38
+ anchors = []
39
+ for y in range(feat_size):
40
+ for x in range(feat_size):
41
+ for _ in range(NUM_ANCHORS_PER_STRIDE):
42
+ anchors.append((x * stride, y * stride))
43
+ return anchors
44
+
45
+
46
+ def _iou(a: tuple, b: tuple) -> float:
47
+ ax1, ay1, ax2, ay2 = a
48
+ bx1, by1, bx2, by2 = b
49
+ ix1, iy1 = max(ax1, bx1), max(ay1, by1)
50
+ ix2, iy2 = min(ax2, bx2), min(ay2, by2)
51
+ inter = max(0, ix2 - ix1) * max(0, iy2 - iy1)
52
+ if inter == 0:
53
+ return 0.0
54
+ union = (ax2 - ax1) * (ay2 - ay1) + (bx2 - bx1) * (by2 - by1) - inter
55
+ return inter / union if union > 0 else 0.0
56
+
57
+
58
+ def _nms(boxes: list[dict], iou_threshold: float = 0.45) -> list[dict]:
59
+ if not boxes:
60
+ return []
61
+ sorted_boxes = sorted(boxes, key=lambda b: b["score"], reverse=True)
62
+ kept = []
63
+ suppressed = set()
64
+ for i, box in enumerate(sorted_boxes):
65
+ if i in suppressed:
66
+ continue
67
+ kept.append(box)
68
+ for j in range(i + 1, len(sorted_boxes)):
69
+ if j in suppressed:
70
+ continue
71
+ if _iou(box["_xyxy"], sorted_boxes[j]["_xyxy"]) > iou_threshold:
72
+ suppressed.add(j)
73
+ return kept
74
+
75
+
76
+ def _match_outputs_to_strides(
77
+ raw_outputs: dict[str, np.ndarray],
78
+ strides: list[int],
79
+ input_size: int,
80
+ ) -> dict[int, dict]:
81
+ """Match model output tensors to strides.
82
+
83
+ Handles two naming conventions:
84
+ 1. Named keys: 'score_8', 'bbox_8', 'kps_8' (ONNX/OpenVINO)
85
+ 2. Generic keys: 'var_732' etc. — matched by shape (CoreML)
86
+
87
+ For CoreML, each stride has 3 tensors with num_anchors matching the stride:
88
+ stride 8 → ceil(input/8)^2 * 2 anchors → shapes (1, N, 1), (1, N, 4), (1, N, 10)
89
+ stride 16 → ceil(input/16)^2 * 2
90
+ stride 32 → ceil(input/32)^2 * 2
91
+ """
92
+ result: dict[int, dict] = {}
93
+
94
+ # Try named keys first
95
+ flat = {k: v.flatten() for k, v in raw_outputs.items()}
96
+ for stride in strides:
97
+ score_key = bbox_key = kps_key = None
98
+ for k in flat:
99
+ if f"score_{stride}" in k or f"_{stride}_score" in k:
100
+ score_key = k
101
+ elif f"bbox_{stride}" in k or f"_{stride}_bbox" in k:
102
+ bbox_key = k
103
+ elif f"kps_{stride}" in k or f"_{stride}_kps" in k:
104
+ kps_key = k
105
+ if score_key and bbox_key:
106
+ result[stride] = {
107
+ "scores": flat[score_key],
108
+ "bboxes": flat[bbox_key],
109
+ "kps": flat.get(kps_key),
110
+ }
111
+
112
+ if len(result) == len(strides):
113
+ return result
114
+
115
+ # Fallback: match by shape (CoreML generic names)
116
+ # Group outputs by last dimension: 1=scores, 4=bboxes, 10=landmarks
117
+ scores_by_n: dict[int, np.ndarray] = {}
118
+ bboxes_by_n: dict[int, np.ndarray] = {}
119
+ kps_by_n: dict[int, np.ndarray] = {}
120
+
121
+ for _k, arr in raw_outputs.items():
122
+ if arr.ndim == 3:
123
+ arr = arr[0] # remove batch dim → (N, C)
124
+ elif arr.ndim == 1:
125
+ continue
126
+ n, c = arr.shape
127
+ if c == 1:
128
+ scores_by_n[n] = arr.flatten()
129
+ elif c == 4:
130
+ bboxes_by_n[n] = arr.flatten()
131
+ elif c == 10:
132
+ kps_by_n[n] = arr.flatten()
133
+
134
+ result = {}
135
+ for stride in strides:
136
+ feat = int(np.ceil(input_size / stride))
137
+ expected_n = feat * feat * NUM_ANCHORS_PER_STRIDE
138
+ if expected_n in scores_by_n and expected_n in bboxes_by_n:
139
+ result[stride] = {
140
+ "scores": scores_by_n[expected_n],
141
+ "bboxes": bboxes_by_n[expected_n],
142
+ "kps": kps_by_n.get(expected_n),
143
+ }
144
+
145
+ return result
146
+
147
+
148
+ def postprocess_scrfd(
149
+ predictions: dict,
150
+ config: dict,
151
+ orig_w: int,
152
+ orig_h: int,
153
+ scale: float,
154
+ pad: tuple[int, int],
155
+ ) -> dict:
156
+ """Postprocess SCRFD output to structured face detections."""
157
+ conf_threshold = float(config.get("confidence", 0.5) or 0.5)
158
+ input_size = config.get("inputSize", 640)
159
+ strides = config.get("strides", STRIDES)
160
+ # NMS IoU from the step config (operator slider); the 0.45 literal stays as
161
+ # the fallback so an unset value is byte-identical to today (#4).
162
+ nms_iou = float(config.get("nmsIouThreshold", 0.45))
163
+
164
+ # Letterbox transform: scale and pad are passed from inference_pool
165
+ # scale = input_size / max(orig_w, orig_h)
166
+ # pad = ((input_size - orig_w*scale)//2, (input_size - orig_h*scale)//2)
167
+
168
+ # Convert all outputs to numpy arrays (keep ndim)
169
+ raw_outputs = {k: np.array(v) for k, v in predictions.items()}
170
+
171
+ # Match outputs to strides — by named key or by shape
172
+ stride_data = _match_outputs_to_strides(raw_outputs, strides, input_size)
173
+
174
+ candidates = []
175
+
176
+ for stride in strides:
177
+ data = stride_data.get(stride)
178
+ if data is None:
179
+ continue
180
+
181
+ scores = data["scores"]
182
+ bboxes = data["bboxes"]
183
+ kps = data.get("kps")
184
+ anchors = _generate_anchors(stride, input_size)
185
+
186
+ n = len(anchors)
187
+ for i in range(n):
188
+ if i >= len(scores):
189
+ break
190
+ score = float(scores[i])
191
+ if score < conf_threshold:
192
+ continue
193
+
194
+ cx, cy = anchors[i]
195
+
196
+ # Bbox: offsets from anchor center, scaled by stride
197
+ x1 = cx - float(bboxes[i * 4]) * stride
198
+ y1 = cy - float(bboxes[i * 4 + 1]) * stride
199
+ x2 = cx + float(bboxes[i * 4 + 2]) * stride
200
+ y2 = cy + float(bboxes[i * 4 + 3]) * stride
201
+
202
+ # Transform from letterbox coords to original image
203
+ ox1 = max(0, min(orig_w, (x1 - pad[0]) / scale))
204
+ oy1 = max(0, min(orig_h, (y1 - pad[1]) / scale))
205
+ ox2 = max(0, min(orig_w, (x2 - pad[0]) / scale))
206
+ oy2 = max(0, min(orig_h, (y2 - pad[1]) / scale))
207
+
208
+ if (ox2 - ox1) < 1 or (oy2 - oy1) < 1:
209
+ continue
210
+
211
+ det = {
212
+ "class": "face",
213
+ "score": round(score, 4),
214
+ "bbox": [round(ox1, 1), round(oy1, 1), round(ox2, 1), round(oy2, 1)],
215
+ "_xyxy": (ox1, oy1, ox2, oy2),
216
+ }
217
+
218
+ # Landmarks (5 points × 2 coords)
219
+ if kps is not None and (i * 10 + 9) < len(kps):
220
+ landmarks = []
221
+ for p in range(5):
222
+ raw_lx = cx + float(kps[i * 10 + p * 2]) * stride
223
+ raw_ly = cy + float(kps[i * 10 + p * 2 + 1]) * stride
224
+ lx = (raw_lx - pad[0]) / scale
225
+ ly = (raw_ly - pad[1]) / scale
226
+ landmarks.append({"x": round(lx, 1), "y": round(ly, 1)})
227
+ det["landmarks"] = landmarks
228
+
229
+ candidates.append(det)
230
+
231
+ kept = _nms(candidates, nms_iou)
232
+ for d in kept:
233
+ del d["_xyxy"]
234
+
235
+ return {"kind": "detections", "detections": kept}
camstack_train/_vendor/pipeline_python/postprocessors/softmax.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Softmax classifier postprocessor.
2
+
3
+ Used for: animal-classifier, bird-classifier, vehicle-type-v1, and any custom
4
+ softmax classifier.
5
+
6
+ Input: raw logits tensor
7
+ Output: {"kind": "classifications", "classifications": [{"class", "score"}]}
8
+ """
9
+ import numpy as np
10
+
11
+
12
+ def postprocess_softmax(
13
+ predictions: dict,
14
+ config: dict,
15
+ orig_w: int,
16
+ orig_h: int,
17
+ scale: float,
18
+ pad: tuple[int, int],
19
+ ) -> dict:
20
+ """Softmax + argmax + label lookup.
21
+
22
+ When ``config['outputProbabilities']`` is set the model already applies
23
+ softmax IN-GRAPH (e.g. the Google AIY Birds classifier) — its raw output is
24
+ a probability distribution, so we consume it directly. Re-softmaxing an
25
+ already-normalised probability vector is NOT a no-op: it collapses the
26
+ distribution toward uniform (the top-1 score craters far below its true
27
+ value), which silently defeats every downstream confidence gate.
28
+ """
29
+ labels = config.get("labels", [])
30
+
31
+ # Get first output tensor
32
+ raw = np.array(list(predictions.values())[0]).flatten().astype(np.float32)
33
+
34
+ if config.get("outputProbabilities"):
35
+ # Model output is already a softmax distribution — do NOT re-softmax.
36
+ probs = raw
37
+ else:
38
+ # Stable softmax over raw logits.
39
+ shifted = raw - np.max(raw)
40
+ exps = np.exp(shifted)
41
+ probs = exps / np.sum(exps)
42
+
43
+ # Return top-K classifications — normalization (top-1 + alternates) happens in TypeScript
44
+ top_k = min(5, len(probs))
45
+ top_indices = np.argsort(probs)[::-1][:top_k]
46
+
47
+ classifications = []
48
+ for idx in top_indices:
49
+ idx = int(idx)
50
+ score = float(probs[idx])
51
+ if score < 0.01:
52
+ break
53
+ label = labels[idx] if idx < len(labels) else str(idx)
54
+ classifications.append({"class": label, "score": round(score, 4)})
55
+
56
+ return {"kind": "classifications", "classifications": classifications}
camstack_train/_vendor/pipeline_python/postprocessors/ssd.py ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SSD / EfficientDet-Lite postprocessor (TFLite_Detection_PostProcess op).
2
+
3
+ Used by the EdgeTPU (Coral USB Edge TPU) runtime with the Coral model-zoo
4
+ SSD MobileNet V2 and EfficientDet-Lite detectors. Those models embed the
5
+ ``TFLite_Detection_PostProcess`` op, so the 4 output tensors are ALREADY
6
+ decoded (boxes + per-box class + score, post-NMS):
7
+
8
+ output 0 boxes [1, N, 4] (ymin, xmin, ymax, xmax) normalised 0..1
9
+ output 1 classes [1, N] float class indices (COCO 90-class map)
10
+ output 2 scores [1, N] float 0..1
11
+ output 3 count [1] number of valid detections
12
+
13
+ The inference_pool ``edgetpu`` predict closure returns them keyed by their
14
+ positional output index ("0".."3"). Output matches the yolo postprocessor:
15
+ ``{"kind": "detections", "detections": [{"class", "score", "bbox":[x1,y1,x2,y2]}]}``
16
+ with pixel bboxes in ORIGINAL frame coordinates.
17
+
18
+ The same layout, verified per model on the upstream graph (2026-09-26):
19
+
20
+ ssd-mobilenet-v2-coco-edgetpu 300x300 uint8, N=20
21
+ efficientdet-lite0-edgetpu 320x320 uint8, N=25
22
+ ssdlite-mobiledet-coco-edgetpu 320x320 uint8, N=100, 91 score columns
23
+ (background + COCO-90), so the emitted
24
+ class index is already 0-based: 0 = person
25
+
26
+ The decode is plain Python over nested lists. Each tensor is turned into a list
27
+ once (``ndarray.tolist()``), so this module needs no numpy: CI's self-hosted
28
+ runner has none, and the MobileDet fixture test (``test_ssd.py``) must run
29
+ there, not be skipped there. Measured: ~85 µs per frame against ~14 µs for
30
+ the old numpy decode (about 6×), under 1% of a core at 100 fps and small next
31
+ to a ~9 ms Coral invoke.
32
+ """
33
+ from __future__ import annotations
34
+
35
+ from typing import Any
36
+
37
+ # Coral COCO 90-class label map (github.com/google-coral/test_data coco_labels.txt).
38
+ # The "n/a" placeholders keep the raw model class indices aligned and are skipped.
39
+ COCO_90 = [
40
+ "person", "bicycle", "car", "motorcycle", "airplane", "bus", "train", "truck",
41
+ "boat", "traffic light", "fire hydrant", "n/a", "stop sign", "parking meter",
42
+ "bench", "bird", "cat", "dog", "horse", "sheep", "cow", "elephant", "bear",
43
+ "zebra", "giraffe", "n/a", "backpack", "umbrella", "n/a", "n/a", "handbag",
44
+ "tie", "suitcase", "frisbee", "skis", "snowboard", "sports ball", "kite",
45
+ "baseball bat", "baseball glove", "skateboard", "surfboard", "tennis racket",
46
+ "bottle", "n/a", "wine glass", "cup", "fork", "knife", "spoon", "bowl",
47
+ "banana", "apple", "sandwich", "orange", "broccoli", "carrot", "hot dog",
48
+ "pizza", "donut", "cake", "chair", "couch", "potted plant", "bed", "n/a",
49
+ "dining table", "n/a", "n/a", "toilet", "n/a", "tv", "laptop", "mouse",
50
+ "remote", "keyboard", "cell phone", "microwave", "oven", "toaster", "sink",
51
+ "refrigerator", "n/a", "book", "clock", "vase", "scissors", "teddy bear",
52
+ "hair drier", "toothbrush",
53
+ ]
54
+
55
+
56
+ def _as_list(value: Any) -> Any:
57
+ """A tensor as nested Python lists (a scalar stays a scalar)."""
58
+ return value.tolist() if hasattr(value, "tolist") else value
59
+
60
+
61
+ def _shape(value: Any) -> tuple:
62
+ """numpy-style shape of a nested list; ``()`` for a scalar."""
63
+ dims: list[int] = []
64
+ while isinstance(value, (list, tuple)):
65
+ dims.append(len(value))
66
+ if not value:
67
+ break
68
+ value = value[0]
69
+ return tuple(dims)
70
+
71
+
72
+ def _flatten(value: Any) -> list:
73
+ """Every scalar of a nested list, in row-major order (``reshape(-1)``)."""
74
+ if not isinstance(value, (list, tuple)):
75
+ return [value]
76
+ flat: list = []
77
+ for item in value:
78
+ flat.extend(_flatten(item))
79
+ return flat
80
+
81
+
82
+ def _rows_of_4(value: Any) -> list:
83
+ """``reshape(-1, 4)`` of a nested list; refuses a size numpy would refuse."""
84
+ flat = _flatten(value)
85
+ if len(flat) % 4 != 0:
86
+ raise ValueError(f"cannot reshape {len(flat)} box values into rows of 4")
87
+ return [flat[i:i + 4] for i in range(0, len(flat), 4)]
88
+
89
+
90
+ def _split_outputs(predictions: dict) -> tuple:
91
+ """Resolve (boxes, classes, scores, count) from the raw prediction dict.
92
+
93
+ Primary path: positional keys "0".."3" (the edgetpu predict closure).
94
+ Fallback: shape-based identification for name-keyed / reordered exports.
95
+ """
96
+ if all(k in predictions for k in ("0", "1", "2", "3")):
97
+ return (
98
+ _as_list(predictions["0"]),
99
+ _as_list(predictions["1"]),
100
+ _as_list(predictions["2"]),
101
+ _as_list(predictions["3"]),
102
+ )
103
+ vals = [_as_list(v) for v in predictions.values()]
104
+ boxes = count = None
105
+ two_d: list = []
106
+ for v in vals:
107
+ shape = _shape(v)
108
+ ndim = len(shape)
109
+ if ndim >= 2 and shape[-1] == 4:
110
+ boxes = v
111
+ elif ndim == 1 or (ndim == 2 and shape[-1] == 1):
112
+ count = v
113
+ elif ndim == 2:
114
+ two_d.append(v)
115
+ # TFLite_Detection_PostProcess emits classes BEFORE scores.
116
+ classes = two_d[0] if len(two_d) > 0 else None
117
+ scores = two_d[1] if len(two_d) > 1 else None
118
+ return boxes, classes, scores, count
119
+
120
+
121
+ def label_for_class(cls: int) -> str:
122
+ """COCO-90 label of a raw class index; ``"n/a"`` for a gap, ``str`` if outside."""
123
+ return COCO_90[cls] if 0 <= cls < len(COCO_90) else str(cls)
124
+
125
+
126
+ def postprocess_ssd(
127
+ predictions: dict,
128
+ config: dict,
129
+ orig_w: int,
130
+ orig_h: int,
131
+ scale: float,
132
+ pad: tuple,
133
+ ) -> dict:
134
+ """Decode SSD/EfficientDet postprocess outputs to original-frame detections."""
135
+ conf_threshold = float(config.get("confidence", 0.4) or 0.4)
136
+ input_size = int(config.get("inputSize", 300) or 300)
137
+
138
+ boxes, classes, scores, count = _split_outputs(predictions)
139
+ if boxes is None or scores is None:
140
+ return {"kind": "detections", "detections": []}
141
+
142
+ boxes = _rows_of_4(boxes)
143
+ scores = _flatten(scores)
144
+ classes = _flatten(classes) if classes is not None else [0.0] * len(scores)
145
+ n = len(scores)
146
+ count_flat = _flatten(count) if count is not None else []
147
+ if count_flat:
148
+ n = min(n, int(count_flat[0]))
149
+ n = min(n, len(boxes), len(classes))
150
+
151
+ pad_x, pad_y = pad
152
+ is_letterbox = not (abs(scale - 1.0) < 1e-6 and pad_x == 0 and pad_y == 0)
153
+
154
+ detections = []
155
+ for i in range(n):
156
+ score = float(scores[i])
157
+ if score < conf_threshold:
158
+ continue
159
+ ymin, xmin, ymax, xmax = (float(v) for v in boxes[i])
160
+ if is_letterbox:
161
+ # Invert letterbox: normalised -> input px -> remove pad -> unscale.
162
+ x1 = (xmin * input_size - pad_x) / scale
163
+ y1 = (ymin * input_size - pad_y) / scale
164
+ x2 = (xmax * input_size - pad_x) / scale
165
+ y2 = (ymax * input_size - pad_y) / scale
166
+ else:
167
+ # Plain resize: normalised coords map linearly to the original frame.
168
+ x1, y1, x2, y2 = xmin * orig_w, ymin * orig_h, xmax * orig_w, ymax * orig_h
169
+ x1 = max(0.0, min(x1, orig_w))
170
+ x2 = max(0.0, min(x2, orig_w))
171
+ y1 = max(0.0, min(y1, orig_h))
172
+ y2 = max(0.0, min(y2, orig_h))
173
+ if x2 <= x1 or y2 <= y1:
174
+ continue
175
+ label = label_for_class(int(classes[i]))
176
+ if label == "n/a":
177
+ continue
178
+ detections.append({
179
+ "class": label,
180
+ "score": round(score, 4),
181
+ "bbox": [round(x1, 1), round(y1, 1), round(x2, 1), round(y2, 1)],
182
+ })
183
+
184
+ return {"kind": "detections", "detections": detections}
camstack_train/_vendor/pipeline_python/postprocessors/yamnet.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """YAMNet audio classification postprocessor.
2
+
3
+ Input: [numFrames, 521] score matrix
4
+ Output: {"kind": "classifications", "classifications": [{"class", "score"}]}
5
+ """
6
+ import numpy as np
7
+
8
+
9
+ def postprocess_yamnet(
10
+ predictions: dict,
11
+ config: dict,
12
+ orig_w: int,
13
+ orig_h: int,
14
+ scale: float,
15
+ pad: tuple[int, int],
16
+ ) -> dict:
17
+ """Average scores across frames, return top classes above threshold."""
18
+ labels = config.get("labels", [])
19
+ min_score = config.get("confidence", 0.1)
20
+
21
+ # Get output_0 (scores) — shape [numFrames, 521]
22
+ raw = np.array(list(predictions.values())[0]).astype(np.float32)
23
+ if raw.ndim == 1:
24
+ raw = raw.reshape(1, -1)
25
+
26
+ num_frames, num_classes = raw.shape
27
+
28
+ # Average across frames
29
+ avg_scores = np.mean(raw, axis=0)
30
+
31
+ # Collect classes above threshold, sorted by score
32
+ results = []
33
+ for c in range(num_classes):
34
+ score = float(avg_scores[c])
35
+ if score >= min_score:
36
+ label = labels[c] if c < len(labels) else str(c)
37
+ results.append({"class": label, "score": round(score, 4)})
38
+
39
+ results.sort(key=lambda x: x["score"], reverse=True)
40
+
41
+ return {"kind": "classifications", "classifications": results}
camstack_train/_vendor/pipeline_python/postprocessors/yolo.py ADDED
@@ -0,0 +1,364 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """YOLO v8/v9/v11 postprocessor.
2
+
3
+ Input: raw predictions dict from CoreML/OpenVINO/ONNX
4
+ Output: {"kind": "detections", "detections": [{"class", "score", "bbox": [x1,y1,x2,y2]}]}
5
+
6
+ Handles both standard and built-in NMS outputs.
7
+ """
8
+ import numpy as np
9
+
10
+ COCO_80 = [
11
+ "person", "bicycle", "car", "motorcycle", "airplane", "bus", "train", "truck",
12
+ "boat", "traffic light", "fire hydrant", "stop sign", "parking meter", "bench",
13
+ "bird", "cat", "dog", "horse", "sheep", "cow", "elephant", "bear", "zebra",
14
+ "giraffe", "backpack", "umbrella", "handbag", "tie", "suitcase", "frisbee",
15
+ "skis", "snowboard", "sports ball", "kite", "baseball bat", "baseball glove",
16
+ "skateboard", "surfboard", "tennis racket", "bottle", "wine glass", "cup",
17
+ "fork", "knife", "spoon", "bowl", "banana", "apple", "sandwich", "orange",
18
+ "broccoli", "carrot", "hot dog", "pizza", "donut", "cake", "chair", "couch",
19
+ "potted plant", "bed", "dining table", "toilet", "tv", "laptop", "mouse",
20
+ "remote", "keyboard", "cell phone", "microwave", "oven", "toaster", "sink",
21
+ "refrigerator", "book", "clock", "vase", "scissors", "teddy bear", "hair drier",
22
+ "toothbrush",
23
+ ]
24
+
25
+
26
+ def _iou(a: tuple, b: tuple) -> float:
27
+ """IoU between two boxes (x1, y1, x2, y2)."""
28
+ ax1, ay1, ax2, ay2 = a
29
+ bx1, by1, bx2, by2 = b
30
+ ix1 = max(ax1, bx1)
31
+ iy1 = max(ay1, by1)
32
+ ix2 = min(ax2, bx2)
33
+ iy2 = min(ay2, by2)
34
+ inter = max(0, ix2 - ix1) * max(0, iy2 - iy1)
35
+ if inter == 0:
36
+ return 0.0
37
+ area_a = (ax2 - ax1) * (ay2 - ay1)
38
+ area_b = (bx2 - bx1) * (by2 - by1)
39
+ union = area_a + area_b - inter
40
+ return inter / union if union > 0 else 0.0
41
+
42
+
43
+ def _nms(boxes: list[dict], iou_threshold: float = 0.45) -> list[dict]:
44
+ """Non-maximum suppression. Input: list of {bbox, score, class}."""
45
+ if not boxes:
46
+ return []
47
+ sorted_boxes = sorted(boxes, key=lambda b: b["score"], reverse=True)
48
+ kept = []
49
+ suppressed = set()
50
+ for i, box in enumerate(sorted_boxes):
51
+ if i in suppressed:
52
+ continue
53
+ kept.append(box)
54
+ for j in range(i + 1, len(sorted_boxes)):
55
+ if j in suppressed:
56
+ continue
57
+ if _iou(box["_xyxy"], sorted_boxes[j]["_xyxy"]) > iou_threshold:
58
+ suppressed.add(j)
59
+ return kept
60
+
61
+
62
+ # The raw YOLOX head of the `yolox-tiny-plate` Edge TPU build (D710): the
63
+ # graph stops before YOLOX's grid decode (an `Exp` and a coordinate Concat would
64
+ # share one int8 scale between pixel coordinates and probabilities) and emits,
65
+ # per stride s, `reg_s` = (dx, dy, log w, log h) in stride units and
66
+ # `score_s` = obj*cls, NHWC as the TFLite file stores them. Decoded here to the
67
+ # YOLOv8 layout every other build of the model already emits, so the rest of
68
+ # this postprocessor is shared. Only the Edge TPU file carries these names.
69
+ YOLOX_HEAD_STRIDES: tuple[int, ...] = (8, 16, 32)
70
+
71
+
72
+ def yolox_head_keys() -> list[str]:
73
+ return [f"{kind}_{s}" for s in YOLOX_HEAD_STRIDES for kind in ("reg", "score")]
74
+
75
+
76
+ def yolox_head_to_v8(predictions: dict, input_size: int) -> "np.ndarray | None":
77
+ """``[1, 5, N]`` (cx, cy, w, h, score) from a raw YOLOX head, or ``None``
78
+ when ``predictions`` is not one. RAISES on a head sized for another input:
79
+ decoding it would put every prior in the wrong cell."""
80
+ if not all(k in predictions for k in yolox_head_keys()):
81
+ return None
82
+ columns = []
83
+ for s in YOLOX_HEAD_STRIDES:
84
+ n = input_size // s
85
+ reg = np.asarray(predictions[f"reg_{s}"], dtype=np.float32).reshape(-1)
86
+ score = np.asarray(predictions[f"score_{s}"], dtype=np.float32).reshape(-1)
87
+ if reg.size != n * n * 4 or score.size != n * n:
88
+ raise ValueError(
89
+ f"yolox head: stride {s} has {reg.size} reg / {score.size} score values, "
90
+ f"expected {n * n * 4} / {n * n} for inputSize {input_size}"
91
+ )
92
+ reg = reg.reshape(n, n, 4)
93
+ rows, cols = np.meshgrid(np.arange(n, dtype=np.float32), np.arange(n, dtype=np.float32), indexing="ij")
94
+ cx = (reg[..., 0] + cols) * s
95
+ cy = (reg[..., 1] + rows) * s
96
+ w = np.exp(reg[..., 2]) * s
97
+ h = np.exp(reg[..., 3]) * s
98
+ columns.append(np.stack([cx, cy, w, h, score.reshape(n, n)], axis=0).reshape(5, -1))
99
+ return np.concatenate(columns, axis=1)[np.newaxis]
100
+
101
+
102
+ def _postprocess_coreml_nms(
103
+ predictions: dict,
104
+ config: dict,
105
+ orig_w: int,
106
+ orig_h: int,
107
+ input_size: int,
108
+ ) -> dict:
109
+ """Handle CoreML models with built-in NMS.
110
+
111
+ CoreML NMS output has two tensors:
112
+ - 'confidence': (N, numClasses) — class scores post-NMS
113
+ - 'coordinates': (N, 4) — [cx, cy, w, h] normalized to input image size
114
+ """
115
+ conf_threshold = float(config.get("confidence", 0))
116
+ labels = config.get("labels", COCO_80)
117
+
118
+ confidence = np.array(predictions["confidence"])
119
+ coordinates = np.array(predictions["coordinates"])
120
+
121
+ if confidence.ndim == 1:
122
+ confidence = confidence.reshape(1, -1)
123
+ if coordinates.ndim == 1:
124
+ coordinates = coordinates.reshape(1, -1)
125
+
126
+ detections = []
127
+ for i in range(confidence.shape[0]):
128
+ best_class = int(np.argmax(confidence[i]))
129
+ best_score = float(confidence[i, best_class])
130
+
131
+ if best_score < conf_threshold:
132
+ continue
133
+
134
+ # CoreML coordinates: [cx, cy, w, h] normalized to input_size
135
+ cx, cy, bw, bh = coordinates[i]
136
+ # Scale to input pixel coords
137
+ cx_px = cx * input_size
138
+ cy_px = cy * input_size
139
+ bw_px = bw * input_size
140
+ bh_px = bh * input_size
141
+
142
+ x1 = cx_px - bw_px / 2
143
+ y1 = cy_px - bh_px / 2
144
+ x2 = cx_px + bw_px / 2
145
+ y2 = cy_px + bh_px / 2
146
+
147
+ # Map back to original image coords (undo letterbox)
148
+ # CoreML imageType with letterbox: the model sees the padded image
149
+ # so we need to figure out the letterbox transform
150
+ scale = input_size / max(orig_w, orig_h)
151
+ new_w = int(orig_w * scale)
152
+ new_h = int(orig_h * scale)
153
+ pad_x = (input_size - new_w) / 2
154
+ pad_y = (input_size - new_h) / 2
155
+
156
+ ox1 = max(0, min(orig_w, (x1 - pad_x) / scale))
157
+ oy1 = max(0, min(orig_h, (y1 - pad_y) / scale))
158
+ ox2 = max(0, min(orig_w, (x2 - pad_x) / scale))
159
+ oy2 = max(0, min(orig_h, (y2 - pad_y) / scale))
160
+
161
+ label = labels[best_class] if best_class < len(labels) else str(best_class)
162
+ detections.append({
163
+ "class": label,
164
+ "score": round(best_score, 4),
165
+ "bbox": [round(ox1, 1), round(oy1, 1), round(ox2, 1), round(oy2, 1)],
166
+ })
167
+
168
+ return {"kind": "detections", "detections": detections}
169
+
170
+
171
+ def _postprocess_yolo11_nms(
172
+ output: np.ndarray,
173
+ config: dict,
174
+ orig_w: int,
175
+ orig_h: int,
176
+ scale: float,
177
+ pad: tuple[int, int],
178
+ ) -> dict:
179
+ """Handle YOLO11/v26 post-NMS output: [N, 6] — (x1, y1, x2, y2, score, class_id).
180
+
181
+ Coordinates are in input pixel space (0..inputSize). Already post-NMS.
182
+ """
183
+ rows = decode_yolo11_nms_rows(output, config, orig_w, orig_h, scale, pad)
184
+ return {"kind": "detections", "detections": [d for _, d in rows]}
185
+
186
+
187
+ def decode_yolo11_nms_rows(
188
+ output: np.ndarray,
189
+ config: dict,
190
+ orig_w: int,
191
+ orig_h: int,
192
+ scale: float,
193
+ pad: tuple[int, int],
194
+ ) -> list[tuple[int, dict]]:
195
+ """The post-NMS box decode, each kept detection paired with its ROW in
196
+ ``output``. Only the first 6 columns are read, so a seg head's
197
+ ``[N, 6 + 32]`` rows decode exactly like the plain detector's ``[N, 6]``
198
+ (D737) and the row index finds the detection's mask coefficients."""
199
+ conf_threshold = float(config.get("confidence", 0))
200
+ labels = config.get("labels", COCO_80)
201
+
202
+ detections: list[tuple[int, dict]] = []
203
+ for i in range(output.shape[0]):
204
+ x1, y1, x2, y2, score, class_id = output[i][:6]
205
+ score = float(score)
206
+ if score < conf_threshold:
207
+ continue
208
+ class_id = int(class_id)
209
+ if class_id < 0:
210
+ continue
211
+
212
+ # Transform from letterbox pixel coords to original image
213
+ ox1 = max(0, min(orig_w, (float(x1) - pad[0]) / scale))
214
+ oy1 = max(0, min(orig_h, (float(y1) - pad[1]) / scale))
215
+ ox2 = max(0, min(orig_w, (float(x2) - pad[0]) / scale))
216
+ oy2 = max(0, min(orig_h, (float(y2) - pad[1]) / scale))
217
+
218
+ label = labels[class_id] if class_id < len(labels) else str(class_id)
219
+ detections.append((i, {
220
+ "class": label,
221
+ "score": round(score, 4),
222
+ "bbox": [round(ox1, 1), round(oy1, 1), round(ox2, 1), round(oy2, 1)],
223
+ }))
224
+
225
+ return detections
226
+
227
+
228
+ def postprocess_yolo(
229
+ predictions: dict,
230
+ config: dict,
231
+ orig_w: int,
232
+ orig_h: int,
233
+ scale: float,
234
+ pad: tuple[int, int],
235
+ ) -> dict:
236
+ """Postprocess YOLO output to structured detections.
237
+
238
+ Handles two formats:
239
+ 1. Raw tensor (ONNX/OpenVINO): [4+numClasses, numBoxes] — needs NMS
240
+ 2. CoreML built-in NMS: 'confidence' + 'coordinates' tensors — already post-NMS
241
+ """
242
+ labels = config.get("labels", COCO_80)
243
+ num_classes = config.get("numClasses", len(labels))
244
+
245
+ yolox = yolox_head_to_v8(predictions, int(config.get("inputSize", 640)))
246
+ if yolox is not None:
247
+ predictions = {"output0": yolox}
248
+
249
+ # Detect CoreML NMS output format
250
+ if "confidence" in predictions and "coordinates" in predictions:
251
+ input_size = config.get("inputSize", 640)
252
+ return _postprocess_coreml_nms(predictions, config, orig_w, orig_h, input_size)
253
+
254
+ # Detect YOLO11/v26 post-NMS format: [batch, N, 6] — (x1, y1, x2, y2, score, class_id)
255
+ first_arr = np.array(list(predictions.values())[0])
256
+ if first_arr.ndim == 3:
257
+ first_arr = first_arr[0] # remove batch dim
258
+ if first_arr.ndim == 2 and first_arr.shape[1] == 6:
259
+ return _postprocess_yolo11_nms(first_arr, config, orig_w, orig_h, scale, pad)
260
+
261
+ # Raw tensor path (ONNX / OpenVINO / CoreML without NMS)
262
+ output = None
263
+ for key, val in predictions.items():
264
+ arr = np.array(val)
265
+ if arr.ndim >= 2 and any(d in (4 + num_classes, 84, 116, 144) for d in arr.shape):
266
+ output = arr
267
+ break
268
+ if output is None:
269
+ output = np.array(list(predictions.values())[0])
270
+
271
+ # Handle various shapes
272
+ if output.ndim == 3:
273
+ output = output[0] # remove batch dim
274
+
275
+ # YOLO output: [4+numClasses, numBoxes] or [numBoxes, 4+numClasses]
276
+ if output.shape[0] == 4 + num_classes:
277
+ pass # already [C, N]
278
+ elif output.shape[1] == 4 + num_classes:
279
+ output = output.T # transpose to [C, N]
280
+ else:
281
+ # Try to infer
282
+ if output.shape[0] < output.shape[1]:
283
+ pass # likely [C, N]
284
+ else:
285
+ output = output.T
286
+
287
+ rows = decode_raw_yolo_columns(output, config, orig_w, orig_h, scale, pad)
288
+ return {"kind": "detections", "detections": [d for _, d in rows]}
289
+
290
+
291
+ def decode_raw_yolo_columns(
292
+ output: np.ndarray,
293
+ config: dict,
294
+ orig_w: int,
295
+ orig_h: int,
296
+ scale: float,
297
+ pad: tuple[int, int],
298
+ ) -> list[tuple[int, dict]]:
299
+ """The raw-head box decode (``[4 + numClasses (+ extra), numBoxes]``, one
300
+ COLUMN per candidate): sigmoid when the scores are logits, confidence
301
+ floor, NMS. Each kept detection is paired with its column in ``output``.
302
+ Rows past ``4 + numClasses`` are never read, so a seg head's mask
303
+ coefficients ride along untouched and decode exactly like the plain
304
+ detector (D737). May apply the sigmoid IN PLACE on the class rows."""
305
+ conf_threshold = float(config.get("confidence", 0))
306
+ labels = config.get("labels", COCO_80)
307
+ num_classes = config.get("numClasses", len(labels))
308
+ # NMS IoU from the step config (operator slider); the 0.45 literal stays as
309
+ # the fallback so an unset value is byte-identical to today (#4).
310
+ nms_iou = float(config.get("nmsIouThreshold", 0.45))
311
+
312
+ num_boxes = output.shape[1]
313
+
314
+ # Detect if scores are raw logits (YOLO11/v26) vs sigmoid-activated (YOLOv8/v9).
315
+ # Sigmoid-activated scores are in [0, 1]. If any class score exceeds 1.0,
316
+ # the model outputs raw logits and we need to apply sigmoid.
317
+ sample_scores = output[4:4 + num_classes, :min(100, num_boxes)]
318
+ needs_sigmoid = float(np.max(sample_scores)) > 1.0 or float(np.min(sample_scores)) < 0.0
319
+ if needs_sigmoid:
320
+ # Apply sigmoid to all class scores in-place (rows 4..4+numClasses)
321
+ output[4:4 + num_classes, :] = 1.0 / (1.0 + np.exp(-np.clip(output[4:4 + num_classes, :], -50, 50)))
322
+
323
+ # Vectorised candidate extraction — replaces the per-box Python loop
324
+ # that took ~20ms on 8400 boxes. Pure numpy: ~0.5ms.
325
+ class_scores = output[4:4 + num_classes, :] # (C, N)
326
+ best_classes = np.argmax(class_scores, axis=0) # (N,)
327
+ best_scores = class_scores[best_classes, np.arange(num_boxes)] # (N,)
328
+ mask = best_scores >= conf_threshold
329
+ if not np.any(mask):
330
+ return []
331
+
332
+ # Gather only above-threshold boxes
333
+ idxs = np.nonzero(mask)[0]
334
+ cx = output[0, idxs]
335
+ cy = output[1, idxs]
336
+ bw = output[2, idxs]
337
+ bh = output[3, idxs]
338
+ sc = best_scores[idxs]
339
+ cl = best_classes[idxs]
340
+
341
+ # Centre → corner, letterbox → original coords
342
+ x1 = np.clip((cx - bw / 2 - pad[0]) / scale, 0, orig_w)
343
+ y1 = np.clip((cy - bh / 2 - pad[1]) / scale, 0, orig_h)
344
+ x2 = np.clip((cx + bw / 2 - pad[0]) / scale, 0, orig_w)
345
+ y2 = np.clip((cy + bh / 2 - pad[1]) / scale, 0, orig_h)
346
+
347
+ candidates = []
348
+ for k in range(len(idxs)):
349
+ ci = int(cl[k])
350
+ candidates.append({
351
+ "class": labels[ci] if ci < len(labels) else str(ci),
352
+ "score": round(float(sc[k]), 4),
353
+ "bbox": [round(float(x1[k]), 1), round(float(y1[k]), 1), round(float(x2[k]), 1), round(float(y2[k]), 1)],
354
+ "_xyxy": (float(x1[k]), float(y1[k]), float(x2[k]), float(y2[k])),
355
+ "_col": int(idxs[k]),
356
+ })
357
+
358
+ kept = _nms(candidates, nms_iou)
359
+ # Remove the internal fields; the column travels beside the detection.
360
+ out: list[tuple[int, dict]] = []
361
+ for d in kept:
362
+ del d["_xyxy"]
363
+ out.append((d.pop("_col"), d))
364
+ return out
camstack_train/_vendor/pipeline_python/postprocessors/yolo_seg.py ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """YOLO-seg postprocessor (YOLO26-seg instance segmentation).
2
+
3
+ Input: raw predictions dict with two outputs:
4
+ - detections: post-NMS ``[1, 300, 6 + 32]`` rows
5
+ ``[x1, y1, x2, y2, conf, class_id, coeff_0..coeff_31]``, or a raw head
6
+ ``[1, 4 + numClasses + 32, numBoxes]`` (one column per candidate)
7
+ - prototypes: ``[1, 32, P, P]`` (P = 160 at a 640 input)
8
+
9
+ Output: ``{"kind": "detections", "detections": [{..., "mask": "<b64>",
10
+ "maskWidth": N, "maskHeight": N}]}`` — the mask is the detection's box region
11
+ of the prototype grid, thresholded to 0/255 bytes, row-major, base64.
12
+
13
+ The BOXES are decoded by the plain detector's own functions
14
+ (``yolo.decode_yolo11_nms_rows`` / ``yolo.decode_raw_yolo_columns``): same
15
+ confidence floor, same sigmoid detection, same NMS (and none on a post-NMS
16
+ head, exactly like ``yolo``), same letterbox inversion and rounding. A seg
17
+ model chosen as the camera ROOT therefore produces the detections a plain
18
+ detector would from the same tensors, plus a mask on each (D737). Masks are
19
+ computed only for the detections that survive, and only over their box.
20
+ """
21
+ import base64
22
+
23
+ import numpy as np
24
+
25
+ from .yolo import COCO_80, decode_raw_yolo_columns, decode_yolo11_nms_rows
26
+
27
+ NUM_MASK_COEFFS = 32
28
+ PROTO_SIZE = 160
29
+
30
+
31
+ def _sigmoid(x: np.ndarray) -> np.ndarray:
32
+ """Numerically stable sigmoid."""
33
+ return np.where(x >= 0, 1 / (1 + np.exp(-x)), np.exp(x) / (1 + np.exp(x)))
34
+
35
+
36
+ def _as_prototypes(arr: np.ndarray) -> np.ndarray | None:
37
+ """``[32, P, P]`` when ``arr`` is a prototype tensor, else ``None``."""
38
+ squeezed = arr
39
+ while squeezed.ndim > 3 and squeezed.shape[0] == 1:
40
+ squeezed = squeezed[0]
41
+ if squeezed.ndim == 3 and squeezed.shape[0] == NUM_MASK_COEFFS and squeezed.shape[1] == squeezed.shape[2]:
42
+ return squeezed
43
+ if arr.size == NUM_MASK_COEFFS * PROTO_SIZE * PROTO_SIZE:
44
+ return arr.reshape(NUM_MASK_COEFFS, PROTO_SIZE, PROTO_SIZE)
45
+ return None
46
+
47
+
48
+ def _find_tensors(predictions: dict, num_classes: int) -> tuple[np.ndarray, np.ndarray]:
49
+ """Locate the detection tensor and prototype tensor from model outputs.
50
+
51
+ Returns (detections, prototypes) as numpy arrays.
52
+ detections: [num_dets, channels] row-major
53
+ prototypes: [32, P, P]
54
+ """
55
+ det_tensor = None
56
+ proto_tensor = None
57
+
58
+ for _key, val in predictions.items():
59
+ arr = np.array(val, dtype=np.float32)
60
+
61
+ protos = _as_prototypes(arr)
62
+ if protos is not None and proto_tensor is None:
63
+ proto_tensor = protos
64
+ continue
65
+
66
+ # Skip tiny tensors
67
+ if arr.size < 10:
68
+ continue
69
+
70
+ # Detection tensor: the other large tensor
71
+ if det_tensor is None:
72
+ det_tensor = arr
73
+
74
+ if det_tensor is None:
75
+ raise ValueError("YOLO-seg postprocessor: could not find detection tensor")
76
+ if proto_tensor is None:
77
+ raise ValueError("YOLO-seg postprocessor: could not find prototype tensor")
78
+
79
+ # Normalize detection tensor to [num_dets, channels]
80
+ while det_tensor.ndim > 2:
81
+ det_tensor = det_tensor[0] # strip batch dim
82
+
83
+ # NMS format: [300, 38] — rows are detections
84
+ # Raw format: [4+nc+32, N] — cols are detections, needs transpose
85
+ nms_cols = 6 + NUM_MASK_COEFFS # 38
86
+ raw_cols = 4 + num_classes + NUM_MASK_COEFFS
87
+
88
+ if det_tensor.shape[1] == nms_cols:
89
+ pass # already [N, 38]
90
+ elif det_tensor.shape[0] == nms_cols:
91
+ det_tensor = det_tensor.T
92
+ elif det_tensor.shape[1] == raw_cols:
93
+ pass # already [N, raw_cols]
94
+ elif det_tensor.shape[0] == raw_cols:
95
+ det_tensor = det_tensor.T
96
+ elif det_tensor.shape[0] < det_tensor.shape[1]:
97
+ det_tensor = det_tensor.T # likely [C, N] -> [N, C]
98
+
99
+ return det_tensor, proto_tensor
100
+
101
+
102
+ def _instance_mask(
103
+ coeffs: np.ndarray,
104
+ protos: np.ndarray,
105
+ bbox: list[float],
106
+ scale: float,
107
+ pad: tuple[int, int],
108
+ input_size: int,
109
+ mask_threshold: float,
110
+ ) -> tuple[str, int, int]:
111
+ """The detection's mask over its OWN box: the emitted (original-image)
112
+ bbox mapped back into the letterboxed input, then onto the prototype grid.
113
+ Only that window of ``coeffs @ protos`` is computed."""
114
+ proto_size = protos.shape[-1]
115
+ proto_scale = proto_size / input_size
116
+ ox1, oy1, ox2, oy2 = bbox
117
+ px1 = max(0, int(np.floor((ox1 * scale + pad[0]) * proto_scale)))
118
+ py1 = max(0, int(np.floor((oy1 * scale + pad[1]) * proto_scale)))
119
+ px2 = min(proto_size, int(np.ceil((ox2 * scale + pad[0]) * proto_scale)))
120
+ py2 = min(proto_size, int(np.ceil((oy2 * scale + pad[1]) * proto_scale)))
121
+ crop_w = max(1, px2 - px1)
122
+ crop_h = max(1, py2 - py1)
123
+ window = protos[:, py1:py1 + crop_h, px1:px1 + crop_w]
124
+ h, w = window.shape[1], window.shape[2]
125
+ if h == 0 or w == 0:
126
+ binary = np.zeros((crop_h, crop_w), dtype=np.uint8)
127
+ else:
128
+ logits = coeffs.astype(np.float32) @ window.reshape(NUM_MASK_COEFFS, -1)
129
+ binary = (_sigmoid(logits.reshape(h, w)) > mask_threshold).astype(np.uint8) * 255
130
+ crop_h, crop_w = h, w
131
+ return base64.b64encode(binary.tobytes()).decode("ascii"), crop_w, crop_h
132
+
133
+
134
+ def postprocess_yolo_seg(
135
+ predictions: dict,
136
+ config: dict,
137
+ orig_w: int,
138
+ orig_h: int,
139
+ scale: float,
140
+ pad: tuple[int, int],
141
+ ) -> dict:
142
+ """Postprocess YOLO-seg output to detections with per-instance masks.
143
+
144
+ Handles:
145
+ 1. NMS-enabled export: [N, 6+32] with [x1,y1,x2,y2,conf,class_id,...coeffs]
146
+ 2. Raw export: [4+numClasses+32, N] standard YOLO transposed format
147
+ """
148
+ labels = config.get("labels", COCO_80)
149
+ num_classes = config.get("numClasses", len(labels))
150
+ input_size = int(config.get("inputSize", 640))
151
+ mask_threshold = float(config.get("maskThreshold", 0.5))
152
+
153
+ det_tensor, proto_tensor = _find_tensors(predictions, num_classes)
154
+
155
+ if det_tensor.shape[1] == 6 + NUM_MASK_COEFFS:
156
+ kept = decode_yolo11_nms_rows(det_tensor, config, orig_w, orig_h, scale, pad)
157
+ coeffs = det_tensor[:, 6:6 + NUM_MASK_COEFFS]
158
+ else:
159
+ # The raw decode reads [C, N] and may sigmoid its class rows in place:
160
+ # hand it its own copy so the coefficients below stay untouched.
161
+ kept = decode_raw_yolo_columns(
162
+ np.ascontiguousarray(det_tensor.T), config, orig_w, orig_h, scale, pad
163
+ )
164
+ coeffs = det_tensor[:, 4 + num_classes:4 + num_classes + NUM_MASK_COEFFS]
165
+
166
+ detections = []
167
+ for index, det in kept:
168
+ mask_b64, mask_w, mask_h = _instance_mask(
169
+ coeffs[index], proto_tensor, det["bbox"], scale, pad, input_size, mask_threshold
170
+ )
171
+ detections.append({**det, "mask": mask_b64, "maskWidth": mask_w, "maskHeight": mask_h})
172
+
173
+ return {"kind": "detections", "detections": detections}
camstack_train/_vendor/pipeline_python/postprocessors/yolo_split_dfl.py ADDED
@@ -0,0 +1,255 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """YOLO split-head DFL postprocessor (Scrypted YOLOv9 TFLite / EdgeTPU).
2
+
3
+ Scrypted's TFLite builds emit SIX tensors (3 strides × box+cls), not the
4
+ ONNX ``[1, 7, 2100]`` packed layout:
5
+
6
+ box: [1, H·W, 4·reg_max] with ``reg_max=16`` (DFL distances l,t,r,b)
7
+ cls: [1, H·W, C] logits over person/vehicle/animal (C=3)
8
+
9
+ Pairs are matched by shared spatial length ``N`` (1600/400/100 for 320²), not
10
+ by positional key order — TFLite output order has drifted between exports.
11
+ Coordinates are decoded in INPUT pixel space, then mapped back to the original
12
+ frame: stretch when ``preprocessMode == 'resize'`` (Scrypted default), else
13
+ letterbox undo via the pool's ``scale`` + ``pad``.
14
+
15
+ Pure nested lists so CI's bare interpreter (no numpy) still pins the math —
16
+ same discipline as ``ssd.py``. A live ndarray from the pool is accepted via
17
+ ``.tolist()``.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import math
22
+ from typing import Any
23
+
24
+ REG_MAX = 16
25
+
26
+
27
+ def _as_list(value: Any) -> Any:
28
+ return value.tolist() if hasattr(value, "tolist") else value
29
+
30
+
31
+ def _shape(value: Any) -> tuple[int, ...]:
32
+ dims: list[int] = []
33
+ cur = value
34
+ while isinstance(cur, (list, tuple)):
35
+ dims.append(len(cur))
36
+ if not cur:
37
+ break
38
+ cur = cur[0]
39
+ return tuple(dims)
40
+
41
+
42
+ def _sigmoid(x: float) -> float:
43
+ if x >= 0:
44
+ z = math.exp(-x)
45
+ return 1.0 / (1.0 + z)
46
+ z = math.exp(x)
47
+ return z / (1.0 + z)
48
+
49
+
50
+ def _softmax(xs: list[float]) -> list[float]:
51
+ m = max(xs) if xs else 0.0
52
+ exps = [math.exp(x - m) for x in xs]
53
+ s = sum(exps) or 1.0
54
+ return [e / s for e in exps]
55
+
56
+
57
+ def _dfl_distance(logits16: list[float]) -> float:
58
+ """Softmax over ``reg_max`` bins · project ``[0..reg_max)`` → distance in cells."""
59
+ probs = _softmax(logits16)
60
+ return sum(i * p for i, p in enumerate(probs))
61
+
62
+
63
+ def _iou(a: tuple[float, float, float, float], b: tuple[float, float, float, float]) -> float:
64
+ ax1, ay1, ax2, ay2 = a
65
+ bx1, by1, bx2, by2 = b
66
+ ix1, iy1 = max(ax1, bx1), max(ay1, by1)
67
+ ix2, iy2 = min(ax2, bx2), min(ay2, by2)
68
+ inter = max(0.0, ix2 - ix1) * max(0.0, iy2 - iy1)
69
+ if inter <= 0:
70
+ return 0.0
71
+ area_a = max(0.0, ax2 - ax1) * max(0.0, ay2 - ay1)
72
+ area_b = max(0.0, bx2 - bx1) * max(0.0, by2 - by1)
73
+ union = area_a + area_b - inter
74
+ return inter / union if union > 0 else 0.0
75
+
76
+
77
+ def _nms(boxes: list[dict], iou_threshold: float) -> list[dict]:
78
+ ordered = sorted(boxes, key=lambda b: b["score"], reverse=True)
79
+ kept: list[dict] = []
80
+ suppressed: set[int] = set()
81
+ for i, box in enumerate(ordered):
82
+ if i in suppressed:
83
+ continue
84
+ kept.append(box)
85
+ for j in range(i + 1, len(ordered)):
86
+ if j in suppressed:
87
+ continue
88
+ if _iou(box["_xyxy"], ordered[j]["_xyxy"]) > iou_threshold:
89
+ suppressed.add(j)
90
+ return kept
91
+
92
+
93
+ def _squeeze_batch(tensor: Any) -> Any:
94
+ """Drop a leading batch dim of 1: [1, N, C] → [N, C]."""
95
+ shape = _shape(tensor)
96
+ if len(shape) == 3 and shape[0] == 1:
97
+ return tensor[0]
98
+ return tensor
99
+
100
+
101
+ def _pair_box_cls(predictions: dict) -> list[tuple[Any, Any]]:
102
+ """Group outputs into (box, cls) pairs by shared N (= H·W)."""
103
+ tensors = {str(k): _squeeze_batch(_as_list(v)) for k, v in predictions.items()}
104
+ boxes: dict[int, Any] = {}
105
+ classes: dict[int, Any] = {}
106
+ for tensor in tensors.values():
107
+ shape = _shape(tensor)
108
+ if len(shape) != 2:
109
+ continue
110
+ n, width = shape
111
+ if width == 4 * REG_MAX:
112
+ boxes[n] = tensor
113
+ elif width > 0 and width != 4 * REG_MAX:
114
+ classes[n] = tensor
115
+ pairs: list[tuple[Any, Any]] = []
116
+ for n, box in boxes.items():
117
+ cls = classes.get(n)
118
+ if cls is None:
119
+ continue
120
+ pairs.append((box, cls))
121
+ if not pairs:
122
+ raise ValueError(
123
+ "yolo-split-dfl: expected paired box[N,64] + cls[N,C] tensors; "
124
+ f"got shapes {[(_shape(_squeeze_batch(_as_list(v)))) for v in predictions.values()]}"
125
+ )
126
+ # Largest N first (finest stride) — stable, matches Scrypted 1600→400→100.
127
+ pairs.sort(key=lambda p: _shape(p[0])[0], reverse=True)
128
+ return pairs
129
+
130
+
131
+ def _infer_stride(n: int, input_size: int) -> int:
132
+ """N = (input_size / stride)² → stride = input_size / sqrt(N)."""
133
+ side = int(round(math.sqrt(n)))
134
+ if side * side != n or side <= 0:
135
+ # Fall back to the canonical 320² strides by matching known Ns.
136
+ known = {1600: 8, 400: 16, 100: 32, (640 // 8) ** 2: 8}
137
+ if n in known:
138
+ return known[n]
139
+ raise ValueError(f"yolo-split-dfl: cannot infer grid side from N={n}")
140
+ stride = input_size // side
141
+ if stride * side != input_size:
142
+ # Non-divisible — still use the rounded stride from side.
143
+ stride = max(1, int(round(input_size / side)))
144
+ return stride
145
+
146
+
147
+ def _to_original(
148
+ x1: float,
149
+ y1: float,
150
+ x2: float,
151
+ y2: float,
152
+ *,
153
+ orig_w: int,
154
+ orig_h: int,
155
+ scale: float,
156
+ pad: tuple[int, int],
157
+ config: dict,
158
+ ) -> tuple[float, float, float, float]:
159
+ mode = config.get("preprocessMode", "letterbox")
160
+ if mode == "resize":
161
+ in_w = float(config.get("inputWidth") or config.get("inputSize") or 320)
162
+ in_h = float(config.get("inputHeight") or config.get("inputSize") or 320)
163
+ if in_w <= 0 or in_h <= 0:
164
+ in_w = in_h = 320.0
165
+ ox1 = x1 * orig_w / in_w
166
+ oy1 = y1 * orig_h / in_h
167
+ ox2 = x2 * orig_w / in_w
168
+ oy2 = y2 * orig_h / in_h
169
+ else:
170
+ ox1 = (x1 - pad[0]) / scale
171
+ oy1 = (y1 - pad[1]) / scale
172
+ ox2 = (x2 - pad[0]) / scale
173
+ oy2 = (y2 - pad[1]) / scale
174
+ return (
175
+ max(0.0, min(float(orig_w), ox1)),
176
+ max(0.0, min(float(orig_h), oy1)),
177
+ max(0.0, min(float(orig_w), ox2)),
178
+ max(0.0, min(float(orig_h), oy2)),
179
+ )
180
+
181
+
182
+ def postprocess_yolo_split_dfl(
183
+ predictions: dict,
184
+ config: dict,
185
+ orig_w: int,
186
+ orig_h: int,
187
+ scale: float,
188
+ pad: tuple[int, int],
189
+ ) -> dict:
190
+ conf_threshold = float(config.get("confidence", 0))
191
+ labels = config.get("labels") or ["person", "vehicle", "animal"]
192
+ nms_iou = float(config.get("nmsIouThreshold", 0.45))
193
+ input_size = int(config.get("inputSize") or config.get("inputWidth") or 320)
194
+
195
+ candidates: list[dict] = []
196
+ for box_tensor, cls_tensor in _pair_box_cls(predictions):
197
+ n = _shape(box_tensor)[0]
198
+ stride = _infer_stride(n, input_size)
199
+ grid = int(round(math.sqrt(n)))
200
+ num_classes = _shape(cls_tensor)[1]
201
+
202
+ for i in range(n):
203
+ # Class score first — skip cold cells before DFL.
204
+ logits = cls_tensor[i]
205
+ best_c = 0
206
+ best_logit = float(logits[0])
207
+ for c in range(1, num_classes):
208
+ v = float(logits[c])
209
+ if v > best_logit:
210
+ best_logit = v
211
+ best_c = c
212
+ score = _sigmoid(best_logit)
213
+ if score < conf_threshold:
214
+ continue
215
+
216
+ dist = box_tensor[i]
217
+ if len(dist) != 4 * REG_MAX:
218
+ continue
219
+ l = _dfl_distance([float(x) for x in dist[0:16]])
220
+ t = _dfl_distance([float(x) for x in dist[16:32]])
221
+ r = _dfl_distance([float(x) for x in dist[32:48]])
222
+ b = _dfl_distance([float(x) for x in dist[48:64]])
223
+
224
+ gy, gx = i // grid, i % grid
225
+ # Ultralytics-style anchor centre at (gx + 0.5, gy + 0.5) * stride;
226
+ # distances are in cell units → multiply by stride for pixels.
227
+ cx = (gx + 0.5) * stride
228
+ cy = (gy + 0.5) * stride
229
+ x1 = cx - l * stride
230
+ y1 = cy - t * stride
231
+ x2 = cx + r * stride
232
+ y2 = cy + b * stride
233
+
234
+ ox1, oy1, ox2, oy2 = _to_original(
235
+ x1, y1, x2, y2,
236
+ orig_w=orig_w, orig_h=orig_h, scale=scale, pad=pad, config=config,
237
+ )
238
+ if ox2 <= ox1 or oy2 <= oy1:
239
+ continue
240
+
241
+ label = labels[best_c] if best_c < len(labels) else str(best_c)
242
+ candidates.append(
243
+ {
244
+ "class": label,
245
+ "score": round(score, 4),
246
+ "bbox": [round(ox1, 1), round(oy1, 1), round(ox2, 1), round(oy2, 1)],
247
+ "_xyxy": (ox1, oy1, ox2, oy2),
248
+ }
249
+ )
250
+
251
+ kept = _nms(candidates, nms_iou)
252
+ detections = [
253
+ {"class": d["class"], "score": d["score"], "bbox": d["bbox"]} for d in kept
254
+ ]
255
+ return {"kind": "detections", "detections": detections}
camstack_train/_vendor/pipeline_python/postprocessors/yolonas.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """YOLO-NAS postprocessor — Frigate-style flat batched-NMS ONNX export.
2
+
3
+ Frigate+ ``yolonas`` models (and any super-gradients ``model.export()`` with
4
+ fused NMS in FLAT format) emit ONE output tensor of shape ``[N, 7]`` where each
5
+ row is ``(batch_index, x1, y1, x2, y2, score, class_id)``:
6
+
7
+ - coordinates are in INPUT pixel space (0..inputSize), post-NMS,
8
+ - GPU exports pad unused slots with a NEGATIVE class id (skip them),
9
+ - class ids index the model's own label map (Frigate+ label set or COCO-80).
10
+
11
+ Output matches the yolo postprocessor:
12
+ ``{"kind": "detections", "detections": [{"class", "score", "bbox": [x1,y1,x2,y2]}]}``
13
+ with pixel bboxes re-projected to ORIGINAL frame coordinates (letterbox undone
14
+ via the pool-provided ``scale`` + ``pad``).
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import numpy as np
19
+
20
+ from .yolo import COCO_80
21
+
22
+
23
+ def postprocess_yolonas(
24
+ predictions: dict,
25
+ config: dict,
26
+ orig_w: int,
27
+ orig_h: int,
28
+ scale: float,
29
+ pad: tuple[int, int],
30
+ ) -> dict:
31
+ conf_threshold = float(config.get("confidence", 0))
32
+ labels = config.get("labels", COCO_80)
33
+
34
+ output = np.asarray(list(predictions.values())[0])
35
+ # Accept [1, N, 7] batched exports by squeezing the batch axis.
36
+ if output.ndim == 3:
37
+ output = output.reshape(-1, output.shape[-1])
38
+ if output.ndim != 2 or output.shape[-1] != 7:
39
+ raise ValueError(
40
+ f"yolonas postprocessor expects a flat [N, 7] output, got shape {output.shape}"
41
+ )
42
+
43
+ detections = []
44
+ for row in output:
45
+ _batch_idx, x1, y1, x2, y2, score, class_id = row
46
+ score = float(score)
47
+ class_id = int(class_id)
48
+ if class_id < 0:
49
+ continue
50
+ if score < conf_threshold:
51
+ continue
52
+
53
+ ox1 = max(0.0, min(float(orig_w), (float(x1) - pad[0]) / scale))
54
+ oy1 = max(0.0, min(float(orig_h), (float(y1) - pad[1]) / scale))
55
+ ox2 = max(0.0, min(float(orig_w), (float(x2) - pad[0]) / scale))
56
+ oy2 = max(0.0, min(float(orig_h), (float(y2) - pad[1]) / scale))
57
+
58
+ label = labels[class_id] if class_id < len(labels) else str(class_id)
59
+ detections.append(
60
+ {
61
+ "class": label,
62
+ "score": round(score, 4),
63
+ "bbox": [round(ox1, 1), round(oy1, 1), round(ox2, 1), round(oy2, 1)],
64
+ }
65
+ )
66
+
67
+ return {"kind": "detections", "detections": detections}
camstack_train/_vendor/pipeline_python/postprocessors/yunet.py ADDED
@@ -0,0 +1,275 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """YuNet face detection postprocessor (OpenCV Zoo `face_detection_yunet`, 2023mar).
2
+
3
+ Used by the `yunet-2023mar` catalog entry on the `face-detection` step through a
4
+ per-MODEL `postprocessor: 'yunet'` override (the step default stays `scrfd`).
5
+
6
+ Model contract, as published by `scripts/build-yunet-model.py`:
7
+
8
+ input "image" [1, 3, 640, 640] RGB float in [0,1] — the pool's plain
9
+ `/255` tensor. Upstream YuNet wants BGR
10
+ 0..255; `Mul(255)` + `Gather([2,1,0])`
11
+ are BAKED into the graph ahead of the
12
+ first Conv, identically in all three
13
+ formats, so no pool normalisation runs
14
+ (the entry declares none).
15
+ outputs cls_{8,16,32} [1, N, 1] classification probability
16
+ obj_{8,16,32} [1, N, 1] objectness probability
17
+ bbox_{8,16,32} [1, N, 4] (dx, dy, log w, log h), stride units
18
+ kps_{8,16,32} [1, N, 10] 5 × (dx, dy), stride units
19
+
20
+ Why it cannot share `scrfd.py` (measured 2026-09-26: the SCRFD decoder finds 0
21
+ faces on 413 person crops with YuNet's outputs):
22
+
23
+ 1. ONE prior per grid cell, not two; anchor-free — the prior is the cell's
24
+ top-left corner, `(col, row) * stride`, row-major, `N = ceil(640/s)^2`.
25
+ 2. The box is centre + size, with `exp()` on width and height — SCRFD's is four
26
+ distances from the anchor.
27
+ 3. The score is `sqrt(cls * obj)` over TWO tensors, both clamped to [0,1].
28
+
29
+ This is a port of OpenCV's `FaceDetectorYN::postProcess`
30
+ (`modules/objdetect/src/face_detect.cpp`).
31
+
32
+ Landmarks come out in YuNet's order — right eye, left eye, nose tip, right
33
+ mouth corner, left mouth corner, where right/left are the SUBJECT'S. That is
34
+ image-left eye first, the same order SCRFD emits and `face-align.ts` expects
35
+ (ARCFACE_TEMPLATE_112), so they are passed through unchanged. Measured on 196
36
+ COCO faces: index 0 is the image-left eye except on 3 profile faces, the same
37
+ rate as SCRFD's 2.
38
+
39
+ The per-candidate arithmetic (`prior_cell`, `decode_prior`, `to_source`, `nms`)
40
+ is plain Python so the layout, decode and landmark-order tests run on CI's bare
41
+ interpreter; numpy only vectorises the score threshold over the 8400 priors.
42
+
43
+ Output matches the scrfd postprocessor:
44
+ ``{"kind": "detections", "detections": [{"class": "face", "score", "bbox": [x1,y1,x2,y2], "landmarks"}]}``
45
+ in ORIGINAL (pre-letterbox) pixel coordinates.
46
+ """
47
+ from __future__ import annotations
48
+
49
+ import math
50
+ import sys
51
+ import time
52
+
53
+ import numpy as np
54
+
55
+ STRIDES: tuple[int, ...] = (8, 16, 32)
56
+ NUM_LANDMARKS = 5
57
+ DEFAULT_NMS_IOU = 0.45
58
+
59
+ # At most this many candidates, best first, reach the NMS. OpenCV's
60
+ # FaceDetectorYN caps at 5000 (`top_k`), but its NMS is C++; `nms` below is
61
+ # pure Python and O(n·kept), measured on this Mac at 28 ms for 500 disjoint
62
+ # candidates, 113 ms for 1000 and 2.8 s for 5000 — an unbounded frame (every
63
+ # prior passing a low floor: 8400) would hold a pool worker for ~8 s. Real
64
+ # frames are far below the cap: on the spike's 413 person crops the most
65
+ # candidates any frame produced was 38 at the step's 0.5 floor and 110 at 0.1.
66
+ TOP_K = 750
67
+ # A frame that hits the cap is reported, at most once per this interval, on
68
+ # stderr (the pool's log): dropped work is never silent, and a pathological
69
+ # model/threshold must not flood the log either.
70
+ TOP_K_LOG_INTERVAL_S = 60.0
71
+ _last_top_k_log = 0.0
72
+
73
+
74
+ def grid_cols(stride: int, input_size: int) -> int:
75
+ """Priors per row (and per column — the input is square) at `stride`."""
76
+ return int(math.ceil(input_size / stride))
77
+
78
+
79
+ def prior_cell(index: int, cols: int) -> tuple[int, int]:
80
+ """`(row, col)` of prior `index` — row-major, one prior per cell."""
81
+ return divmod(index, cols)
82
+
83
+
84
+ def score_of(cls: float, obj: float) -> float:
85
+ """YuNet's score: geometric mean of the two probabilities, each clamped."""
86
+ return math.sqrt(min(max(cls, 0.0), 1.0) * min(max(obj, 0.0), 1.0))
87
+
88
+
89
+ def decode_prior(
90
+ stride: int,
91
+ row: int,
92
+ col: int,
93
+ bbox: "list[float] | tuple[float, ...]",
94
+ kps: "list[float] | tuple[float, ...]",
95
+ ) -> tuple[tuple[float, float, float, float], list[tuple[float, float]]]:
96
+ """One prior → `(x1, y1, x2, y2)` and 5 landmark points, in NETWORK space."""
97
+ cx = (col + bbox[0]) * stride
98
+ cy = (row + bbox[1]) * stride
99
+ w = math.exp(bbox[2]) * stride
100
+ h = math.exp(bbox[3]) * stride
101
+ box = (cx - w / 2.0, cy - h / 2.0, cx + w / 2.0, cy + h / 2.0)
102
+ points = [
103
+ ((kps[2 * p] + col) * stride, (kps[2 * p + 1] + row) * stride)
104
+ for p in range(NUM_LANDMARKS)
105
+ ]
106
+ return box, points
107
+
108
+
109
+ def to_source(x: float, y: float, scale: float, pad: tuple[int, int]) -> tuple[float, float]:
110
+ """Undo the pool's letterbox: network pixels → original frame pixels."""
111
+ return (x - pad[0]) / scale, (y - pad[1]) / scale
112
+
113
+
114
+ def _iou(a: tuple, b: tuple) -> float:
115
+ ix = max(0.0, min(a[2], b[2]) - max(a[0], b[0]))
116
+ iy = max(0.0, min(a[3], b[3]) - max(a[1], b[1]))
117
+ inter = ix * iy
118
+ if inter == 0:
119
+ return 0.0
120
+ union = (a[2] - a[0]) * (a[3] - a[1]) + (b[2] - b[0]) * (b[3] - b[1]) - inter
121
+ return inter / union if union > 0 else 0.0
122
+
123
+
124
+ def cap_top_k(candidates: list[dict], k: int = TOP_K) -> tuple[list[dict], int]:
125
+ """The `k` best candidates by score, and how many were dropped."""
126
+ if len(candidates) <= k:
127
+ return candidates, 0
128
+ best = sorted(candidates, key=lambda c: c["score"], reverse=True)
129
+ return best[:k], len(candidates) - k
130
+
131
+
132
+ def _log_top_k_drop(dropped: int, total: int) -> None:
133
+ global _last_top_k_log
134
+ now = time.monotonic()
135
+ if now - _last_top_k_log < TOP_K_LOG_INTERVAL_S:
136
+ return
137
+ _last_top_k_log = now
138
+ sys.stderr.write(
139
+ f"yunet: {total} candidates above the confidence floor, kept the best {TOP_K} "
140
+ f"for NMS and dropped {dropped} (logged at most once per "
141
+ f"{TOP_K_LOG_INTERVAL_S:.0f}s)\n"
142
+ )
143
+ sys.stderr.flush()
144
+
145
+
146
+ def nms(candidates: list[dict], iou_threshold: float) -> list[dict]:
147
+ """Greedy NMS on `_xyxy`, highest score first."""
148
+ kept: list[dict] = []
149
+ for cand in sorted(candidates, key=lambda c: c["score"], reverse=True):
150
+ if all(_iou(cand["_xyxy"], k["_xyxy"]) <= iou_threshold for k in kept):
151
+ kept.append(cand)
152
+ return kept
153
+
154
+
155
+ _OUTPUT_WIDTHS: tuple[tuple[str, int], ...] = (
156
+ ("cls", 1), ("obj", 1), ("bbox", 4), ("kps", 2 * NUM_LANDMARKS),
157
+ )
158
+
159
+
160
+ def missing_outputs(output_names) -> list[str]:
161
+ """The named outputs this decoder needs that the model did not produce.
162
+
163
+ Matched by NAME only, with no shape fallback: `cls_*` and `obj_*` have
164
+ identical shapes, so a fallback could only guess. Every published format
165
+ names its outputs (the CoreML conversion names them explicitly, the
166
+ OpenVINO IR has its internal aliases cleared)."""
167
+ present = set(output_names)
168
+ return [
169
+ f"{kind}_{stride}" for stride in STRIDES for kind, _ in _OUTPUT_WIDTHS
170
+ if f"{kind}_{stride}" not in present
171
+ ]
172
+
173
+
174
+ def value_count(value) -> int:
175
+ """Number of scalars in an ndarray-like (`.size`) or a nested sequence.
176
+ Pure Python, so the size gate below needs no numpy."""
177
+ size = getattr(value, "size", None)
178
+ if isinstance(size, int):
179
+ return size
180
+ if isinstance(value, (list, tuple)):
181
+ return sum(value_count(v) for v in value)
182
+ return 1
183
+
184
+
185
+ def check_outputs(predictions: dict, input_size: int) -> None:
186
+ """RAISE unless all twelve outputs are present and sized for `input_size`.
187
+
188
+ Returning `[]` instead would read as "no faces" forever with no line
189
+ saying why — which is exactly what the SCRFD decoder did on this graph. A
190
+ size mismatch means the model's input is not `inputSize` (e.g. a 320
191
+ export under a 640 catalog entry): decoding it would put every prior in the
192
+ wrong cell."""
193
+ missing = missing_outputs(predictions.keys())
194
+ if missing:
195
+ raise ValueError(
196
+ f"yunet: outputs {missing} missing (model outputs: {sorted(predictions)})"
197
+ )
198
+ for stride in STRIDES:
199
+ priors = grid_cols(stride, input_size) ** 2
200
+ for kind, width in _OUTPUT_WIDTHS:
201
+ key = f"{kind}_{stride}"
202
+ got = value_count(predictions[key])
203
+ if got != priors * width:
204
+ raise ValueError(
205
+ f"yunet: output '{key}' has {got} values, expected {priors * width} "
206
+ f"({priors} priors × {width}) — inputSize {input_size} does not match the model"
207
+ )
208
+
209
+
210
+ def _stride_tensors(predictions: dict, stride: int) -> dict:
211
+ """The four flattened tensors of one stride (sizes already checked)."""
212
+ return {
213
+ kind: np.asarray(predictions[f"{kind}_{stride}"], dtype=np.float32).reshape(-1)
214
+ for kind, _ in _OUTPUT_WIDTHS
215
+ }
216
+
217
+
218
+ def postprocess_yunet(
219
+ predictions: dict,
220
+ config: dict,
221
+ orig_w: int,
222
+ orig_h: int,
223
+ scale: float,
224
+ pad: tuple[int, int],
225
+ ) -> dict:
226
+ """Postprocess YuNet output to structured face detections."""
227
+ conf_threshold = float(config.get("confidence", 0.5) or 0.5)
228
+ input_size = int(config.get("inputSize", 640))
229
+ nms_iou = float(config.get("nmsIouThreshold", DEFAULT_NMS_IOU))
230
+
231
+ check_outputs(predictions, input_size)
232
+
233
+ candidates: list[dict] = []
234
+ for stride in STRIDES:
235
+ cols = grid_cols(stride, input_size)
236
+ t = _stride_tensors(predictions, stride)
237
+ bbox = t["bbox"].reshape(-1, 4)
238
+ kps = t["kps"].reshape(-1, 2 * NUM_LANDMARKS)
239
+ # numpy only PRE-filters, conservatively: sqrt(cls·obj) <= max(cls, obj),
240
+ # so a prior below the threshold on both tensors cannot pass. The
241
+ # decision itself is `score_of`, per prior.
242
+ maybe = np.flatnonzero(np.maximum(t["cls"], t["obj"]) >= conf_threshold)
243
+ for index in maybe.tolist():
244
+ score = score_of(float(t["cls"][index]), float(t["obj"][index]))
245
+ if score < conf_threshold:
246
+ continue
247
+ row, col = prior_cell(index, cols)
248
+ (x1, y1, x2, y2), points = decode_prior(
249
+ stride, row, col, bbox[index].tolist(), kps[index].tolist()
250
+ )
251
+ sx1, sy1 = to_source(x1, y1, scale, pad)
252
+ sx2, sy2 = to_source(x2, y2, scale, pad)
253
+ ox1, oy1 = max(0.0, min(orig_w, sx1)), max(0.0, min(orig_h, sy1))
254
+ ox2, oy2 = max(0.0, min(orig_w, sx2)), max(0.0, min(orig_h, sy2))
255
+ if (ox2 - ox1) < 1 or (oy2 - oy1) < 1:
256
+ continue
257
+ landmarks = []
258
+ for px, py in points:
259
+ lx, ly = to_source(px, py, scale, pad)
260
+ landmarks.append({"x": round(lx, 1), "y": round(ly, 1)})
261
+ candidates.append({
262
+ "class": "face",
263
+ "score": round(score, 4),
264
+ "bbox": [round(ox1, 1), round(oy1, 1), round(ox2, 1), round(oy2, 1)],
265
+ "landmarks": landmarks,
266
+ "_xyxy": (ox1, oy1, ox2, oy2),
267
+ })
268
+
269
+ capped, dropped = cap_top_k(candidates)
270
+ if dropped:
271
+ _log_top_k_drop(dropped, len(candidates))
272
+ kept = nms(capped, nms_iou)
273
+ for det in kept:
274
+ del det["_xyxy"]
275
+ return {"kind": "detections", "detections": kept}
camstack_train/cli.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``python -m camstack_train run|dataset``.
2
+
3
+ ``run``: one job. The hub's provider starts the container with
4
+ ``--job /data/job.json --dataset /data/dataset.tar --out /data/out --cache /cache``.
5
+ ``dataset``: the export → YOLO layout only, for training by hand.
6
+
7
+ Exit codes: 0 ok, 64 bad job.json, 65 dataset unusable, 70 internal.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import tempfile
13
+ from pathlib import Path
14
+ from typing import Optional, Sequence
15
+
16
+ from .dataset import extract_archive, frames_from_manifest, write_layout
17
+ from .errors import EXIT_BAD_SPEC, EXIT_OK, TrainerError
18
+ from .events import Emitter
19
+ from .manifest import failed_result, write_result
20
+ from .run import Deps, Paths, run_job
21
+ from .spec import job_key_of, load_job
22
+
23
+
24
+ def _parser() -> argparse.ArgumentParser:
25
+ parser = argparse.ArgumentParser(prog="camstack_train", description="CamStack detector trainer")
26
+ sub = parser.add_subparsers(dest="command", required=True)
27
+ run = sub.add_parser("run", help="run one training job")
28
+ run.add_argument("--job", type=Path, required=True)
29
+ run.add_argument("--dataset", type=Path, required=True)
30
+ run.add_argument("--out", type=Path, required=True)
31
+ run.add_argument("--cache", type=Path, required=True)
32
+ run.add_argument("--work", type=Path, default=None, help="scratch dir (default: a temp dir)")
33
+ ds = sub.add_parser("dataset", help="convert a hub export to a YOLO layout (all frames → train)")
34
+ ds.add_argument("--export", type=Path, required=True)
35
+ ds.add_argument("--out", type=Path, required=True)
36
+ return parser
37
+
38
+
39
+ def _run(args: argparse.Namespace, deps: Optional[Deps]) -> int:
40
+ emitter = deps.emitter if deps is not None else Emitter()
41
+ try:
42
+ job = load_job(args.job)
43
+ except TrainerError as exc:
44
+ emitter.error(exc.stage, exc.code, exc.message)
45
+ digest = write_result(args.out / "result.json", failed_result(job_key_of(args.job), exc.as_dict()))
46
+ emitter.done("failed", digest)
47
+ return EXIT_BAD_SPEC
48
+ work = args.work or Path(tempfile.mkdtemp(prefix="camstack-train-"))
49
+ paths = Paths(job=args.job, dataset=args.dataset, out=args.out, cache=args.cache, work=work)
50
+ return run_job(job, paths, deps)
51
+
52
+
53
+ def _dataset(args: argparse.Namespace) -> int:
54
+ source = args.out / "export"
55
+ manifest = extract_archive(args.export, source)
56
+ frames = [f for f in frames_from_manifest(manifest) if f.trainable]
57
+ counts = write_layout({"train": frames}, source, args.out / "yolo")
58
+ print(f"{counts.frames.get('train', 0)} frames, {counts.boxes.get('train', 0)} boxes → {args.out / 'yolo'}")
59
+ return EXIT_OK
60
+
61
+
62
+ def main(argv: Optional[Sequence[str]] = None, deps: Optional[Deps] = None) -> int:
63
+ args = _parser().parse_args(argv)
64
+ if args.command == "run":
65
+ return _run(args, deps)
66
+ try:
67
+ return _dataset(args)
68
+ except TrainerError as exc:
69
+ print(f"{exc.code}: {exc.message}")
70
+ return exc.exit_code
camstack_train/dataset.py ADDED
@@ -0,0 +1,228 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The hub's annotated export → an Ultralytics YOLO layout.
2
+
3
+ Input: the retrain archive (``annotations.json`` first, then ``frames/…`` JPEGs;
4
+ schema ``camstack.retrain-export/v2`` or ``/v3``), exactly what
5
+ ``pipelineAnalytics.writeRetrainArchive`` streams.
6
+
7
+ What becomes a label (COCO-80 ids, the head the fleet's root keeps):
8
+
9
+ - ``subject`` of macro ``person`` → ``person`` (0).
10
+ - ``subject`` of macro ``vehicle`` / ``animal`` → its COCO class, taken from the
11
+ annotation's ``label`` (``car``, ``dog``…) when that label is a COCO class of
12
+ the same macro. A vehicle or animal box WITHOUT such a label cannot be given
13
+ a COCO class honestly (training "dog" on a cat teaches the wrong species), so
14
+ its frame is left out of training and counted as ``unmappable``. Evaluation
15
+ is per macro class and still uses it.
16
+ - ``model_error`` boxes are never positives: the region is learned as
17
+ background, which is what marking a phantom asks for.
18
+ - ``package`` / ``face`` / ``plate`` boxes are not root-detector classes and are
19
+ ignored (no label line).
20
+
21
+ Which frames TRAIN:
22
+
23
+ - every ``complete`` frame ("every subject is boxed"), including one with no
24
+ subject at all — a hard negative;
25
+ - an INCOMPLETE frame only when it carries at least one annotation (a subject
26
+ or a model error): the operator picked it to teach that box. YOLO has no
27
+ "ignore region", so an unboxed subject on such a frame IS learned as
28
+ background; restricting incomplete frames to annotated ones is the bound on
29
+ that cost. Incomplete frames are never evaluated.
30
+
31
+ Files are renamed to neutral ``000001.jpg`` names: archive paths carry device
32
+ and track ids, and nothing the container writes or logs names a frame.
33
+ """
34
+ from __future__ import annotations
35
+
36
+ import hashlib
37
+ import json
38
+ import shutil
39
+ import tarfile
40
+ from dataclasses import dataclass, field
41
+ from pathlib import Path, PurePosixPath
42
+ from typing import Iterable, Optional
43
+
44
+ from ._vendor import ensure_pipeline_on_path
45
+ from ._vendor.camstack_eval.classes import COCO_TO_MACRO
46
+ from .errors import dataset_error
47
+
48
+ MANIFEST = "annotations.json"
49
+ ACCEPTED_SCHEMAS = ("camstack.retrain-export/v2", "camstack.retrain-export/v3")
50
+ IGNORED_MACROS = ("package", "face", "plate")
51
+
52
+
53
+ def coco_80() -> list[str]:
54
+ ensure_pipeline_on_path()
55
+ from postprocessors.yolo import COCO_80 # the pool's own label list
56
+
57
+ return list(COCO_80)
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class YoloBox:
62
+ cls_index: int
63
+ cx: float
64
+ cy: float
65
+ w: float
66
+ h: float
67
+
68
+ def line(self) -> str:
69
+ return f"{self.cls_index} {self.cx:.6f} {self.cy:.6f} {self.w:.6f} {self.h:.6f}"
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class Frame:
74
+ frame_id: str
75
+ path: str
76
+ device_id: int
77
+ captured_at: Optional[int]
78
+ complete: bool
79
+ labels: tuple[YoloBox, ...]
80
+ subjects: int
81
+ model_errors: int
82
+ unmappable: int
83
+
84
+ @property
85
+ def trainable(self) -> bool:
86
+ if self.unmappable > 0:
87
+ return False
88
+ return self.complete or (self.subjects + self.model_errors) > 0
89
+
90
+
91
+ @dataclass
92
+ class LayoutCounts:
93
+ frames: dict[str, int] = field(default_factory=dict)
94
+ boxes: dict[str, int] = field(default_factory=dict)
95
+
96
+
97
+ def sha256_file(path: Path) -> str:
98
+ digest = hashlib.sha256()
99
+ with path.open("rb") as handle:
100
+ for chunk in iter(lambda: handle.read(1 << 20), b""):
101
+ digest.update(chunk)
102
+ return digest.hexdigest()
103
+
104
+
105
+ def verify_archive(path: Path, expected_sha256: str) -> None:
106
+ if not path.is_file():
107
+ raise dataset_error("dataset-missing", "dataset.tar not found")
108
+ actual = sha256_file(path)
109
+ if actual != expected_sha256:
110
+ raise dataset_error("dataset-sha256", "dataset.tar does not match the job's sha256")
111
+
112
+
113
+ def _safe_member(name: str) -> bool:
114
+ pure = PurePosixPath(name)
115
+ if pure.is_absolute() or ".." in pure.parts:
116
+ return False
117
+ return name == MANIFEST or (pure.parts[:1] == ("frames",) and pure.suffix.lower() == ".jpg")
118
+
119
+
120
+ def extract_archive(path: Path, dest: Path) -> dict:
121
+ """Extract the manifest and the frame JPEGs only; return the manifest."""
122
+ dest.mkdir(parents=True, exist_ok=True)
123
+ try:
124
+ with tarfile.open(path) as tar:
125
+ for member in tar:
126
+ if not member.isfile() or not _safe_member(member.name):
127
+ continue
128
+ target = dest / member.name
129
+ target.parent.mkdir(parents=True, exist_ok=True)
130
+ source = tar.extractfile(member)
131
+ if source is None:
132
+ continue
133
+ with target.open("wb") as out:
134
+ shutil.copyfileobj(source, out)
135
+ except tarfile.TarError as exc:
136
+ raise dataset_error("dataset-unreadable", "dataset.tar is not a readable tar") from exc
137
+ manifest_path = dest / MANIFEST
138
+ if not manifest_path.is_file():
139
+ raise dataset_error("dataset-no-manifest", "dataset.tar has no annotations.json")
140
+ manifest = json.loads(manifest_path.read_text("utf-8"))
141
+ if manifest.get("schema") not in ACCEPTED_SCHEMAS:
142
+ raise dataset_error("dataset-schema", f"export schema {manifest.get('schema')!r} is not supported")
143
+ return manifest
144
+
145
+
146
+ def _clamped(bbox: dict) -> Optional[tuple[float, float, float, float]]:
147
+ x1 = max(0.0, float(bbox["x"]))
148
+ y1 = max(0.0, float(bbox["y"]))
149
+ x2 = min(1.0, float(bbox["x"]) + float(bbox["w"]))
150
+ y2 = min(1.0, float(bbox["y"]) + float(bbox["h"]))
151
+ if x2 - x1 <= 1e-4 or y2 - y1 <= 1e-4:
152
+ return None
153
+ return (x1 + x2) / 2, (y1 + y2) / 2, x2 - x1, y2 - y1
154
+
155
+
156
+ def coco_index_for(macro: str, label: Optional[str], names: list[str]) -> Optional[int]:
157
+ """The COCO-80 index a subject trains as, or ``None`` when none is honest."""
158
+ if macro == "person":
159
+ return names.index("person")
160
+ wanted = (label or "").strip().lower()
161
+ if wanted in names and COCO_TO_MACRO.get(wanted) == macro:
162
+ return names.index(wanted)
163
+ return None
164
+
165
+
166
+ def frame_from_entry(entry: dict, names: list[str]) -> Frame:
167
+ labels: list[YoloBox] = []
168
+ subjects = model_errors = unmappable = 0
169
+ for annotation in entry.get("annotations", []):
170
+ macro = annotation.get("macroClass")
171
+ if annotation.get("kind") == "model_error":
172
+ model_errors += 1
173
+ continue
174
+ if annotation.get("kind") != "subject" or macro in IGNORED_MACROS:
175
+ continue
176
+ subjects += 1
177
+ index = coco_index_for(str(macro), annotation.get("label"), names)
178
+ if index is None:
179
+ unmappable += 1
180
+ continue
181
+ box = _clamped(annotation["bbox"])
182
+ if box is not None:
183
+ labels.append(YoloBox(index, *box))
184
+ captured = entry.get("capturedAt")
185
+ return Frame(
186
+ frame_id=str(entry["frameId"]),
187
+ path=str(entry["path"]),
188
+ device_id=int(entry["deviceId"]),
189
+ captured_at=int(captured) if isinstance(captured, (int, float)) else None,
190
+ complete=entry.get("complete") is True,
191
+ labels=tuple(labels),
192
+ subjects=subjects,
193
+ model_errors=model_errors,
194
+ unmappable=unmappable,
195
+ )
196
+
197
+
198
+ def frames_from_manifest(manifest: dict, names: Optional[list[str]] = None) -> list[Frame]:
199
+ names = names if names is not None else coco_80()
200
+ return [frame_from_entry(e, names) for e in manifest.get("frames", [])]
201
+
202
+
203
+ def write_split(frames: Iterable[Frame], source_root: Path, out_root: Path, split: str) -> tuple[int, int]:
204
+ """Copy one split's frames + YOLO label files; returns (frames, boxes)."""
205
+ images = out_root / "images" / split
206
+ labels = out_root / "labels" / split
207
+ images.mkdir(parents=True, exist_ok=True)
208
+ labels.mkdir(parents=True, exist_ok=True)
209
+ count = boxes = 0
210
+ for frame in frames:
211
+ source = source_root / frame.path
212
+ if not source.is_file():
213
+ continue
214
+ count += 1
215
+ stem = f"{count:06d}"
216
+ shutil.copyfile(source, images / f"{stem}.jpg")
217
+ (labels / f"{stem}.txt").write_text("".join(b.line() + "\n" for b in frame.labels), "utf-8")
218
+ boxes += len(frame.labels)
219
+ return count, boxes
220
+
221
+
222
+ def write_layout(splits: dict[str, list[Frame]], source_root: Path, out_root: Path) -> LayoutCounts:
223
+ counts = LayoutCounts()
224
+ for split, frames in splits.items():
225
+ n, boxes = write_split(frames, source_root, out_root, split)
226
+ counts.frames[split] = n
227
+ counts.boxes[split] = boxes
228
+ return counts
camstack_train/errors.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The trainer's one failure type and its exit codes."""
2
+ from __future__ import annotations
3
+
4
+ #: Process exit codes the hub reads.
5
+ EXIT_OK = 0
6
+ EXIT_BAD_SPEC = 64
7
+ EXIT_DATASET = 65
8
+ EXIT_INTERNAL = 70
9
+
10
+
11
+ class TrainerError(Exception):
12
+ """A failure with a stage, a stable code and an exit code.
13
+
14
+ ``message`` is shown to the operator: it never names a frame path or a
15
+ camera, only counts and codes.
16
+ """
17
+
18
+ def __init__(self, stage: str, code: str, message: str, exit_code: int) -> None:
19
+ super().__init__(message)
20
+ self.stage = stage
21
+ self.code = code
22
+ self.message = message
23
+ self.exit_code = exit_code
24
+
25
+ def as_dict(self) -> dict[str, str]:
26
+ return {"stage": self.stage, "code": self.code, "message": self.message}
27
+
28
+
29
+ def spec_error(code: str, message: str) -> TrainerError:
30
+ return TrainerError("prepare", code, message, EXIT_BAD_SPEC)
31
+
32
+
33
+ def dataset_error(code: str, message: str) -> TrainerError:
34
+ return TrainerError("prepare", code, message, EXIT_DATASET)
camstack_train/evaluate.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Baseline vs candidate on the holdout, through the hub's own evaluation harness.
2
+
3
+ The harness (``_vendor/camstack_eval``, byte-identical to
4
+ ``scripts/eval/camstack_eval``) runs both models with the inference pool's
5
+ letterbox and decode, scores P/R/AP50 per macro class, camera, day/night and
6
+ small subjects, and proposes the candidate's floors by the D739 rule against
7
+ the baseline's shipped floors. Only COMPLETE holdout frames are ground truth.
8
+
9
+ The baseline is the catalog ONNX the hub names (``baseline.onnxUrl``), fetched
10
+ once into the cache volume. The candidate is the exported ONNX; the INT8 IR is
11
+ evaluated separately, as its own model with its own floors.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+ from typing import Callable, Optional, Sequence
19
+
20
+ from ._vendor import ensure_pipeline_on_path
21
+ from ._vendor.camstack_eval.compare import compare_models
22
+ from ._vendor.camstack_eval.dataset import EvalFrame, load_export
23
+ from ._vendor.camstack_eval.floors import FloorPolicy
24
+ from ._vendor.camstack_eval.metrics import Detection
25
+ from .replay import Fetch
26
+ from .spec import JobSpec
27
+
28
+ Detector = Callable[[bytes], Sequence[Detection]]
29
+ DetectorFactory = Callable[[str, int], Detector]
30
+
31
+
32
+ def harness_detector(model_path: str, input_size: int) -> Detector:
33
+ ensure_pipeline_on_path()
34
+ from ._vendor.camstack_eval.pipeline import make_detector
35
+
36
+ return make_detector(model_path, input_size).detect
37
+
38
+
39
+ def policy_of(job: JobSpec) -> FloorPolicy:
40
+ return FloorPolicy(min_support=job.floors.min_support, clamp=job.floors.clamp)
41
+
42
+
43
+ def baseline_model(job: JobSpec, cache: Path, fetch: Fetch) -> Path:
44
+ digest = hashlib.sha256(job.baseline.onnx_url.encode()).hexdigest()[:16]
45
+ target = cache / "baselines" / f"{digest}.onnx"
46
+ if not target.is_file():
47
+ target.parent.mkdir(parents=True, exist_ok=True)
48
+ partial = target.with_suffix(".part")
49
+ partial.write_bytes(fetch(job.baseline.onnx_url))
50
+ partial.rename(target)
51
+ return target
52
+
53
+
54
+ def holdout_frames(dataset_root: Path, frame_ids: set[str]) -> list[EvalFrame]:
55
+ return [f for f in load_export(str(dataset_root), complete_only=True) if f.truth.frame_id in frame_ids]
56
+
57
+
58
+ def map50(report: Optional[dict]) -> Optional[float]:
59
+ """Mean AP50 of the candidate over the classes that have one."""
60
+ if report is None:
61
+ return None
62
+ values = [c["candidate"]["ap50"] for c in report.get("classes", {}).values() if c["candidate"]["ap50"] is not None]
63
+ return round(sum(values) / len(values), 4) if values else None
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class Evaluation:
68
+ candidate: Optional[dict]
69
+ candidate_int8: Optional[dict]
70
+ holdout_frames: int
71
+
72
+
73
+ def evaluate(
74
+ job: JobSpec,
75
+ frames: Sequence[EvalFrame],
76
+ baseline_path: Path,
77
+ candidate_onnx: Path,
78
+ candidate_int8: Optional[Path],
79
+ make: DetectorFactory = harness_detector,
80
+ ) -> Evaluation:
81
+ """Each report holds ``baseline`` and ``candidate`` per class (the harness's shape)."""
82
+ if not frames:
83
+ return Evaluation(None, None, 0)
84
+ baseline = make(str(baseline_path), job.baseline.imgsz)
85
+ policy = policy_of(job)
86
+ floors = dict(job.baseline.floors)
87
+ candidate = compare_models(frames, baseline, make(str(candidate_onnx), job.base.imgsz), floors, policy=policy)
88
+ int8 = None
89
+ if candidate_int8 is not None:
90
+ int8 = compare_models(frames, baseline, make(str(candidate_int8), job.base.imgsz), floors, policy=policy)
91
+ return Evaluation(candidate, int8, len(frames))
92
+
93
+
94
+ def split_reports(evaluation: Evaluation) -> dict:
95
+ """The result.json ``evaluation`` block: baseline / candidate / candidateInt8."""
96
+
97
+ def side(report: Optional[dict], key: str) -> Optional[dict]:
98
+ if report is None:
99
+ return None
100
+ return {
101
+ "frames": report["frames"],
102
+ "classes": {cls: {"positives": c["positives"], **c[key]} for cls, c in report["classes"].items()},
103
+ }
104
+
105
+ def deltas(report: Optional[dict]) -> Optional[dict]:
106
+ if report is None:
107
+ return None
108
+ return {cls: c["recallDeltaByCamera"] for cls, c in report["classes"].items()}
109
+
110
+ return {
111
+ "holdoutFrames": evaluation.holdout_frames,
112
+ "baseline": side(evaluation.candidate, "baseline"),
113
+ "candidate": side(evaluation.candidate, "candidate"),
114
+ "candidateInt8": side(evaluation.candidate_int8, "candidate"),
115
+ "recallDeltaByCamera": deltas(evaluation.candidate),
116
+ "map50": {"candidate": map50(evaluation.candidate), "candidateInt8": map50(evaluation.candidate_int8)},
117
+ }
camstack_train/events.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``CAMSTACK_EVENT {json}`` progress lines on stdout.
2
+
3
+ The hub reads the provider's log and parses these lines; it never needs to
4
+ understand anything else the container prints. Every event carries ``v`` (the
5
+ event format version, 1), ``ts`` (epoch ms) and ``type``. One event per line,
6
+ compact JSON, flushed immediately.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import sys
12
+ import time
13
+ from typing import Any, Callable, Optional, TextIO
14
+
15
+ PREFIX = "CAMSTACK_EVENT "
16
+ EVENT_VERSION = 1
17
+
18
+ STAGES = ("prepare", "replay", "train", "export", "eval", "package")
19
+
20
+
21
+ class Emitter:
22
+ def __init__(
23
+ self,
24
+ stream: Optional[TextIO] = None,
25
+ clock: Callable[[], float] = time.time,
26
+ ) -> None:
27
+ self._stream = stream
28
+ self._clock = clock
29
+
30
+ def emit(self, event_type: str, **fields: Any) -> dict[str, Any]:
31
+ event: dict[str, Any] = {
32
+ "v": EVENT_VERSION,
33
+ "ts": int(self._clock() * 1000),
34
+ "type": event_type,
35
+ **fields,
36
+ }
37
+ stream = self._stream if self._stream is not None else sys.stdout
38
+ stream.write(PREFIX + json.dumps(event, separators=(",", ":"), sort_keys=False) + "\n")
39
+ stream.flush()
40
+ return event
41
+
42
+ def stage(self, stage: str) -> None:
43
+ if stage not in STAGES:
44
+ raise ValueError(f"unknown stage {stage!r}")
45
+ self.emit("stage", stage=stage)
46
+
47
+ def epoch(
48
+ self,
49
+ epoch: int,
50
+ epochs: int,
51
+ box_loss: Optional[float],
52
+ map50: Optional[float],
53
+ map50_95: Optional[float],
54
+ eta_sec: Optional[float],
55
+ ) -> None:
56
+ self.emit(
57
+ "epoch",
58
+ epoch=epoch,
59
+ epochs=epochs,
60
+ boxLoss=box_loss,
61
+ map50=map50,
62
+ map50_95=map50_95,
63
+ etaSec=eta_sec,
64
+ )
65
+
66
+ def metric(self, name: str, value: float) -> None:
67
+ self.emit("metric", name=name, value=value)
68
+
69
+ def artifact(self, path: str, size: int, sha256: str) -> None:
70
+ self.emit("artifact", path=path, bytes=size, sha256=sha256)
71
+
72
+ def error(self, stage: str, code: str, message: str) -> None:
73
+ self.emit("error", stage=stage, code=code, message=message)
74
+
75
+ def done(self, status: str, result_sha256: Optional[str]) -> None:
76
+ self.emit("done", status=status, resultSha256=result_sha256)
77
+
78
+
79
+ def parse_line(line: str) -> Optional[dict[str, Any]]:
80
+ """The event on a log line, or ``None`` for any other line."""
81
+ if not line.startswith(PREFIX):
82
+ return None
83
+ try:
84
+ value = json.loads(line[len(PREFIX):])
85
+ except json.JSONDecodeError:
86
+ return None
87
+ return value if isinstance(value, dict) else None
camstack_train/export.py ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """best.pt → ONNX → OpenVINO fp16 / int8 → CoreML fp16, into the staging dir.
2
+
3
+ Every artefact is named after the catalog id, because a node's models
4
+ directory is FLAT and a format's file is found by its URL's basename:
5
+
6
+ <id>.pt <id>.onnx <id>-fp16.xml/.bin <id>-int8.xml/.bin <id>.mlpackage/
7
+
8
+ ONNX is required (every other format is built from it, and the evaluation runs
9
+ it): its failure fails the job. Each other format is isolated — a failure
10
+ drops THAT format with its reason (``exportsDropped``) and the job goes on;
11
+ CoreML on Linux is the expected casualty, and a Mac node can still convert the
12
+ ONNX later through Model Studio.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import shutil
18
+ import subprocess
19
+ import sys
20
+ from dataclasses import dataclass, field
21
+ from pathlib import Path
22
+ from typing import Protocol
23
+
24
+ from .errors import EXIT_INTERNAL, TrainerError
25
+
26
+ #: Characters of a failed step's output kept as its reason.
27
+ REASON_CHARS = 400
28
+
29
+
30
+ class Toolchain(Protocol):
31
+ def onnx(self, pt: Path, out: Path, imgsz: int) -> None: ...
32
+ def openvino(self, onnx: Path, xml: Path, fp16: bool) -> None: ...
33
+ def openvino_int8(self, onnx: Path, xml: Path, calib: list[Path], imgsz: int) -> None: ...
34
+ def coreml(self, pt: Path, out: Path, imgsz: int) -> None: ...
35
+
36
+
37
+ class StepFailed(Exception):
38
+ pass
39
+
40
+
41
+ class SubprocessToolchain:
42
+ """Runs each step in a child (``camstack_train._steps``)."""
43
+
44
+ def _run(self, step: str, payload: dict) -> None:
45
+ proc = subprocess.run(
46
+ [sys.executable, "-m", "camstack_train._steps", step, json.dumps(payload)],
47
+ capture_output=True,
48
+ text=True,
49
+ check=False,
50
+ )
51
+ if proc.returncode != 0:
52
+ tail = (proc.stderr or proc.stdout or "").strip()[-REASON_CHARS:]
53
+ raise StepFailed(f"{step} exited {proc.returncode}: {tail}")
54
+
55
+ def onnx(self, pt: Path, out: Path, imgsz: int) -> None:
56
+ self._run("onnx", {"pt": str(pt), "out": str(out), "imgsz": imgsz})
57
+
58
+ def openvino(self, onnx: Path, xml: Path, fp16: bool) -> None:
59
+ self._run("openvino", {"onnx": str(onnx), "xml": str(xml), "fp16": fp16})
60
+
61
+ def openvino_int8(self, onnx: Path, xml: Path, calib: list[Path], imgsz: int) -> None:
62
+ self._run("openvino-int8", {"onnx": str(onnx), "xml": str(xml), "calib": [str(p) for p in calib], "imgsz": imgsz})
63
+
64
+ def coreml(self, pt: Path, out: Path, imgsz: int) -> None:
65
+ self._run("coreml", {"pt": str(pt), "out": str(out), "imgsz": imgsz})
66
+
67
+
68
+ @dataclass
69
+ class ExportOutcome:
70
+ produced: list[str] = field(default_factory=list)
71
+ dropped: list[dict[str, str]] = field(default_factory=list)
72
+
73
+ def drop(self, export: str, reason: str) -> None:
74
+ self.dropped.append({"export": export, "reason": reason[:REASON_CHARS]})
75
+
76
+
77
+ def artefact_names(catalog_id: str) -> dict[str, str]:
78
+ return {
79
+ "pt": f"{catalog_id}.pt",
80
+ "onnx": f"{catalog_id}.onnx",
81
+ "openvino-fp16": f"{catalog_id}-fp16.xml",
82
+ "openvino-int8": f"{catalog_id}-int8.xml",
83
+ "coreml-fp16": f"{catalog_id}.mlpackage",
84
+ }
85
+
86
+
87
+ def _optional(outcome: ExportOutcome, export: str, run) -> None:
88
+ try:
89
+ run()
90
+ outcome.produced.append(export)
91
+ except Exception as exc: # isolated: a format is dropped, the job goes on
92
+ outcome.drop(export, str(exc) or type(exc).__name__)
93
+
94
+
95
+ def export_all(
96
+ exports: list[str],
97
+ best_pt: Path,
98
+ catalog_id: str,
99
+ imgsz: int,
100
+ calib: list[Path],
101
+ staging: Path,
102
+ toolchain: Toolchain,
103
+ ) -> ExportOutcome:
104
+ names = artefact_names(catalog_id)
105
+ staging.mkdir(parents=True, exist_ok=True)
106
+ shutil.copyfile(best_pt, staging / names["pt"])
107
+ onnx = staging / names["onnx"]
108
+ outcome = ExportOutcome()
109
+ try:
110
+ toolchain.onnx(best_pt, onnx, imgsz)
111
+ except Exception as exc:
112
+ raise TrainerError("export", "export-onnx", f"ONNX export failed: {str(exc)[:REASON_CHARS]}", EXIT_INTERNAL) from exc
113
+ outcome.produced.append("onnx")
114
+ if "openvino-fp16" in exports:
115
+ _optional(outcome, "openvino-fp16", lambda: toolchain.openvino(onnx, staging / names["openvino-fp16"], True))
116
+ if "openvino-int8" in exports:
117
+ if not calib:
118
+ outcome.drop("openvino-int8", "no calibration frames in the train split")
119
+ else:
120
+ _optional(
121
+ outcome,
122
+ "openvino-int8",
123
+ lambda: toolchain.openvino_int8(onnx, staging / names["openvino-int8"], calib, imgsz),
124
+ )
125
+ if "coreml-fp16" in exports:
126
+ _optional(outcome, "coreml-fp16", lambda: toolchain.coreml(best_pt, staging / names["coreml-fp16"], imgsz))
127
+ return outcome
128
+
129
+
130
+ def remove_export(staging: Path, catalog_id: str, export: str) -> None:
131
+ """Delete one format's files from the staging dir (the int8 gate)."""
132
+ main = staging / artefact_names(catalog_id)[export]
133
+ for path in (main, main.with_suffix(".bin")):
134
+ if path.is_dir():
135
+ shutil.rmtree(path)
136
+ elif path.exists():
137
+ path.unlink()
138
+
139
+
140
+ def calibration_frames(train_images: Path, max_frames: int) -> list[Path]:
141
+ """The first ``max_frames`` of the user's own TRAIN frames, in name order."""
142
+ return sorted(p for p in train_images.glob("*.jpg"))[:max_frames]
camstack_train/floors.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The candidate's per-class floors, by the D739 rule, with their reasons.
2
+
3
+ The rule itself is the harness's (``_vendor/camstack_eval/floors.py``): the
4
+ lowest grid floor at which the candidate raises no more false positives on the
5
+ holdout than the baseline does at its shipped floor; a class with fewer than
6
+ ``minSupport`` holdout positives inherits the baseline's floor; the result is
7
+ clamped to ±``clamp`` of it. This module turns the harness's per-class
8
+ proposals into the ``floors`` block of result.json, with one human note per
9
+ class that did not get a fitted floor — and inherits every floor when there
10
+ was no holdout to fit on.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from typing import Optional, Sequence
15
+
16
+ from ._vendor.camstack_eval.floors import FloorPolicy, ProposedFloor, ScoredDetection, propose_floor
17
+ from .spec import FLOOR_CLASSES
18
+
19
+
20
+ def propose(
21
+ baseline: Sequence[ScoredDetection],
22
+ baseline_floor: float,
23
+ candidate: Sequence[ScoredDetection],
24
+ positives: int,
25
+ policy: FloorPolicy,
26
+ ) -> ProposedFloor:
27
+ return propose_floor(baseline, baseline_floor, candidate, positives, policy)
28
+
29
+
30
+ def note_for(cls: str, proposal: dict, positives: int, baseline_floor: float, policy: FloorPolicy) -> Optional[str]:
31
+ reason = proposal["reason"]
32
+ if reason == "low-support":
33
+ return f"{cls}: {positives} holdout positives < {policy.min_support}, inherits {baseline_floor}"
34
+ if reason == "clamped":
35
+ fitted = proposal.get("fitted")
36
+ fit = "no grid floor met the false-positive budget" if fitted is None else f"fitted {fitted}"
37
+ return f"{cls}: {fit}, clamped to {proposal['value']} (±{policy.clamp} of {baseline_floor})"
38
+ return None
39
+
40
+
41
+ def floors_from_report(
42
+ report: Optional[dict], baseline_floors: dict[str, float], policy: FloorPolicy
43
+ ) -> tuple[dict[str, float], list[str]]:
44
+ """(floors, notes) for one candidate report; no report ⇒ the baseline's floors."""
45
+ if report is None:
46
+ return {c: baseline_floors[c] for c in FLOOR_CLASSES}, [
47
+ "no complete holdout frame: every floor inherits the baseline"
48
+ ]
49
+ floors: dict[str, float] = {}
50
+ notes: list[str] = []
51
+ for cls in FLOOR_CLASSES:
52
+ entry = report["classes"].get(cls)
53
+ if entry is None:
54
+ floors[cls] = baseline_floors[cls]
55
+ notes.append(f"{cls}: not evaluated, inherits {baseline_floors[cls]}")
56
+ continue
57
+ proposal = entry["proposedFloor"]
58
+ floors[cls] = float(proposal["value"])
59
+ note = note_for(cls, proposal, int(entry["positives"]), baseline_floors[cls], policy)
60
+ if note is not None:
61
+ notes.append(note)
62
+ return floors, notes
63
+
64
+
65
+ def floors_block(
66
+ candidate: Optional[dict],
67
+ candidate_int8: Optional[dict],
68
+ baseline_floors: dict[str, float],
69
+ policy: FloorPolicy,
70
+ int8_present: bool,
71
+ ) -> dict:
72
+ base, notes = floors_from_report(candidate, baseline_floors, policy)
73
+ int8 = None
74
+ if int8_present:
75
+ int8, int8_notes = floors_from_report(candidate_int8, baseline_floors, policy)
76
+ notes = notes + [f"int8 {n}" for n in int8_notes]
77
+ return {
78
+ "baseline": {c: baseline_floors[c] for c in FLOOR_CLASSES},
79
+ "candidate": base,
80
+ "candidateInt8": int8,
81
+ "notes": notes,
82
+ }
camstack_train/manifest.py ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``result.json`` (schema ``camstack.training-result/v1``) and ``bundle.tar``.
2
+
3
+ ``bundle.tar`` holds every artefact under its FLAT catalog name (a Core ML
4
+ package as its directory), and ``files[]`` lists one row per regular file in it
5
+ with its size and sha256, so the hub verifies what it fetched file by file.
6
+
7
+ ``catalogDraft`` is a ``ModelCatalogEntry`` per variant whose format URLs are
8
+ RELATIVE file names (the hub rewrites them to ``camstack-local://``), with the
9
+ revision every format carries under D743/D744: the sha256 of its weight file
10
+ (the ONNX itself, an IR's ``.bin``, a Core ML package's ``weight.bin``).
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import importlib.metadata
16
+ import json
17
+ import os
18
+ import platform
19
+ import tarfile
20
+ from pathlib import Path
21
+ from typing import Optional
22
+
23
+ from . import __version__
24
+ from .dataset import sha256_file
25
+ from .spec import JobSpec
26
+
27
+ RESULT_SCHEMA = "camstack.training-result/v1"
28
+ LICENCE = "AGPL-3.0 (Ultralytics derivative)"
29
+ MLPACKAGE_FILES = (
30
+ "Manifest.json",
31
+ "Data/com.apple.CoreML/model.mlmodel",
32
+ "Data/com.apple.CoreML/weights/weight.bin",
33
+ )
34
+ TOOLCHAIN_PACKAGES = ("torch", "ultralytics", "openvino", "nncf", "coremltools", "onnx")
35
+
36
+
37
+ def toolchain_versions() -> dict[str, Optional[str]]:
38
+ out: dict[str, Optional[str]] = {"python": platform.python_version()}
39
+ for name in TOOLCHAIN_PACKAGES:
40
+ try:
41
+ out[name] = importlib.metadata.version(name)
42
+ except importlib.metadata.PackageNotFoundError:
43
+ out[name] = None
44
+ return out
45
+
46
+
47
+ def trainer_block() -> dict[str, str]:
48
+ return {"version": __version__, "revision": os.environ.get("TRAINER_REVISION", "local")}
49
+
50
+
51
+ def classify(rel: str, catalog_id: str) -> tuple[str, str]:
52
+ """(format, variant) of a bundle file."""
53
+ if rel.startswith(f"{catalog_id}.mlpackage/"):
54
+ return "coreml", "base"
55
+ if rel.endswith(".pt"):
56
+ return "pt", "base"
57
+ if rel.endswith(".onnx"):
58
+ return "onnx", "base"
59
+ return "openvino", "int8" if rel.startswith(f"{catalog_id}-int8.") else "base"
60
+
61
+
62
+ def file_rows(staging: Path, catalog_id: str) -> list[dict]:
63
+ rows = []
64
+ for path in sorted(p for p in staging.rglob("*") if p.is_file()):
65
+ rel = path.relative_to(staging).as_posix()
66
+ fmt, variant = classify(rel, catalog_id)
67
+ rows.append({"path": rel, "bytes": path.stat().st_size, "sha256": sha256_file(path), "format": fmt, "variant": variant})
68
+ return rows
69
+
70
+
71
+ def write_bundle(staging: Path, rows: list[dict], out: Path) -> dict:
72
+ """A plain, deterministic tar of exactly the listed files."""
73
+ out.parent.mkdir(parents=True, exist_ok=True)
74
+ with tarfile.open(out, "w", format=tarfile.PAX_FORMAT) as tar:
75
+ for row in rows:
76
+ info = tar.gettarinfo(str(staging / row["path"]), arcname=row["path"])
77
+ info.mtime, info.uid, info.gid, info.uname, info.gname = 0, 0, 0, "", ""
78
+ with (staging / row["path"]).open("rb") as handle:
79
+ tar.addfile(info, handle)
80
+ return {"path": out.name, "bytes": out.stat().st_size, "sha256": sha256_file(out)}
81
+
82
+
83
+ def _row(rows: list[dict], path: str) -> Optional[dict]:
84
+ return next((r for r in rows if r["path"] == path), None)
85
+
86
+
87
+ def _mb(*rows: Optional[dict]) -> float:
88
+ return round(sum(r["bytes"] for r in rows if r is not None) / 1e6, 2)
89
+
90
+
91
+ def _openvino_format(rows: list[dict], stem: str) -> Optional[dict]:
92
+ xml, weights = _row(rows, f"{stem}.xml"), _row(rows, f"{stem}.bin")
93
+ if xml is None or weights is None:
94
+ return None
95
+ return {"url": xml["path"], "files": [weights["path"]], "runtimes": ["python"], "sizeMB": _mb(xml, weights), "revision": weights["sha256"]}
96
+
97
+
98
+ def _formats_base(rows: list[dict], cid: str) -> dict:
99
+ formats: dict = {}
100
+ onnx = _row(rows, f"{cid}.onnx")
101
+ if onnx is not None:
102
+ formats["onnx"] = {"url": onnx["path"], "sizeMB": _mb(onnx), "revision": onnx["sha256"]}
103
+ ov = _openvino_format(rows, f"{cid}-fp16")
104
+ if ov is not None:
105
+ formats["openvino"] = ov
106
+ members = [_row(rows, f"{cid}.mlpackage/{m}") for m in MLPACKAGE_FILES]
107
+ if all(m is not None for m in members):
108
+ formats["coreml"] = {
109
+ "url": f"{cid}.mlpackage",
110
+ "isDirectory": True,
111
+ "files": list(MLPACKAGE_FILES),
112
+ "runtimes": ["python"],
113
+ "sizeMB": _mb(*members),
114
+ "revision": members[2]["sha256"],
115
+ }
116
+ return formats
117
+
118
+
119
+ def _entry(job: JobSpec, entry_id: str, name: str, description: str, formats: dict, floors: dict) -> dict:
120
+ return {
121
+ "id": entry_id,
122
+ "name": name,
123
+ "description": description,
124
+ "inputSize": {"width": job.base.imgsz, "height": job.base.imgsz},
125
+ "labels": [],
126
+ "preprocessMode": "letterbox",
127
+ "defaultClassFloors": floors,
128
+ "formats": formats,
129
+ }
130
+
131
+
132
+ def catalog_draft(job: JobSpec, rows: list[dict], floors: dict) -> dict:
133
+ cid = job.catalog.id
134
+ origin = f"fine-tuned from {job.base.model_id} on dataset version {job.dataset.version_id}"
135
+ base = _entry(job, cid, job.catalog.name, f"{job.catalog.name} — {origin}", _formats_base(rows, cid), floors["candidate"])
136
+ int8 = None
137
+ int8_format = _openvino_format(rows, f"{cid}-int8")
138
+ if int8_format is not None and floors.get("candidateInt8") is not None:
139
+ int8 = _entry(
140
+ job, f"{cid}-int8", f"{job.catalog.name} (INT8)",
141
+ f"{job.catalog.name} — OpenVINO INT8 calibrated on the user's own frames; {origin}",
142
+ {"openvino": int8_format}, floors["candidateInt8"],
143
+ )
144
+ return {"base": base, "int8": int8}
145
+
146
+
147
+ def result_sha256(path: Path) -> str:
148
+ return hashlib.sha256(path.read_bytes()).hexdigest()
149
+
150
+
151
+ def write_result(path: Path, result: dict) -> str:
152
+ path.parent.mkdir(parents=True, exist_ok=True)
153
+ path.write_text(json.dumps(result, indent=2, sort_keys=False) + "\n", "utf-8")
154
+ return result_sha256(path)
155
+
156
+
157
+ def failed_result(job_key: str, error: dict) -> dict:
158
+ return {
159
+ "schema": RESULT_SCHEMA,
160
+ "jobKey": job_key,
161
+ "status": "failed",
162
+ "error": error,
163
+ "trainer": trainer_block(),
164
+ "toolchain": toolchain_versions(),
165
+ "licence": LICENCE,
166
+ }
camstack_train/replay.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """COCO replay: public images mixed into a detector fine-tune.
2
+
3
+ A fine-tune on a few thousand frames of ~10 cameras forgets what the COCO head
4
+ knew about everything those cameras never show. Mixing a fixed slice of COCO
5
+ val2017 back in keeps the 80 classes alive. The slice is pinned: the images of
6
+ val2017 that hold at least one person / vehicle / animal, in file-name order,
7
+ the first ``count`` of them — the same images on every run.
8
+
9
+ Nothing here is uploaded by the hub: the container fetches the public files
10
+ once into the provider's cache volume (``/cache/coco``) and reuses them. All
11
+ 80 COCO classes are labelled (the head is kept whole).
12
+
13
+ ``fetch`` is injectable: tests never touch the network.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import io
18
+ import json
19
+ import urllib.request
20
+ import zipfile
21
+ from pathlib import Path
22
+ from typing import Callable
23
+
24
+ from ._vendor.camstack_eval.classes import DETECTOR_CLASSES, macro_of
25
+
26
+ ANNOTATIONS_URL = "http://images.cocodataset.org/annotations/annotations_trainval2017.zip"
27
+ ANNOTATIONS_MEMBER = "annotations/instances_val2017.json"
28
+ IMAGE_URL = "http://images.cocodataset.org/val2017/{file}"
29
+
30
+ Fetch = Callable[[str], bytes]
31
+
32
+
33
+ def http_fetch(url: str) -> bytes:
34
+ with urllib.request.urlopen(url, timeout=120) as response: # noqa: S310 (fixed public URLs)
35
+ return response.read()
36
+
37
+
38
+ def load_annotations(cache: Path, fetch: Fetch) -> dict:
39
+ """``instances_val2017.json``, fetched once into ``cache``."""
40
+ target = cache / "instances_val2017.json"
41
+ if not target.is_file():
42
+ cache.mkdir(parents=True, exist_ok=True)
43
+ archive = zipfile.ZipFile(io.BytesIO(fetch(ANNOTATIONS_URL)))
44
+ target.write_bytes(archive.read(ANNOTATIONS_MEMBER))
45
+ return json.loads(target.read_text("utf-8"))
46
+
47
+
48
+ def select_images(annotations: dict, count: int) -> list[dict]:
49
+ """The pinned slice: images with a detector-class object, file-name order."""
50
+ names = {c["id"]: c["name"] for c in annotations["categories"]}
51
+ wanted: set[int] = set()
52
+ for a in annotations["annotations"]:
53
+ if not a.get("iscrowd") and macro_of(names[a["category_id"]]) in DETECTOR_CLASSES:
54
+ wanted.add(a["image_id"])
55
+ images = sorted((im for im in annotations["images"] if im["id"] in wanted), key=lambda im: im["file_name"])
56
+ return images[:count]
57
+
58
+
59
+ def yolo_lines(annotations: dict, image: dict, coco80: list[str]) -> list[str]:
60
+ names = {c["id"]: c["name"] for c in annotations["categories"]}
61
+ w, h = float(image["width"]), float(image["height"])
62
+ lines = []
63
+ for a in annotations["annotations"]:
64
+ if a["image_id"] != image["id"] or a.get("iscrowd"):
65
+ continue
66
+ name = names[a["category_id"]]
67
+ if name not in coco80:
68
+ continue
69
+ x, y, bw, bh = a["bbox"]
70
+ if bw <= 0 or bh <= 0:
71
+ continue
72
+ lines.append(
73
+ f"{coco80.index(name)} {(x + bw / 2) / w:.6f} {(y + bh / 2) / h:.6f} {bw / w:.6f} {bh / h:.6f}"
74
+ )
75
+ return lines
76
+
77
+
78
+ def prepare_replay(count: int, cache: Path, out_root: Path, coco80: list[str], fetch: Fetch = http_fetch) -> int:
79
+ """Write ``count`` replay images + labels under ``out_root/images|labels/replay``."""
80
+ if count <= 0:
81
+ return 0
82
+ annotations = load_annotations(cache, fetch)
83
+ images_dir = out_root / "images" / "replay"
84
+ labels_dir = out_root / "labels" / "replay"
85
+ images_dir.mkdir(parents=True, exist_ok=True)
86
+ labels_dir.mkdir(parents=True, exist_ok=True)
87
+ by_image: dict[int, list[dict]] = {}
88
+ for a in annotations["annotations"]:
89
+ by_image.setdefault(a["image_id"], []).append(a)
90
+ indexed = {**annotations, "annotations": []}
91
+ written = 0
92
+ for image in select_images(annotations, count):
93
+ cached = cache / "val2017" / image["file_name"]
94
+ if not cached.is_file():
95
+ cached.parent.mkdir(parents=True, exist_ok=True)
96
+ cached.write_bytes(fetch(IMAGE_URL.format(file=image["file_name"])))
97
+ stem = Path(image["file_name"]).stem
98
+ (images_dir / f"{stem}.jpg").write_bytes(cached.read_bytes())
99
+ lines = yolo_lines({**indexed, "annotations": by_image.get(image["id"], [])}, image, coco80)
100
+ (labels_dir / f"{stem}.txt").write_text("".join(line + "\n" for line in lines), "utf-8")
101
+ written += 1
102
+ return written
camstack_train/result_model.py ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The shape of ``result.json`` (``camstack.training-result/v1``), as pydantic.
2
+
3
+ ``manifest.py`` builds the document as plain dicts; this model is the contract
4
+ it is checked against (in the tests, and by ``tools/gen_schemas.py`` into
5
+ ``schemas/result.schema.json``, which the hub mirrors in zod). The harness's
6
+ per-class reports stay open (``dict``): their shape is the harness's own.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Literal, Optional
11
+
12
+ from pydantic import BaseModel, ConfigDict, Field
13
+
14
+
15
+ class _Open(BaseModel):
16
+ model_config = ConfigDict(extra="forbid", protected_namespaces=())
17
+
18
+
19
+ class ErrorBlock(_Open):
20
+ stage: str
21
+ code: str
22
+ message: str
23
+
24
+
25
+ class TrainerBlock(_Open):
26
+ version: str
27
+ revision: str
28
+
29
+
30
+ class Frames(_Open):
31
+ train: int
32
+ val: int
33
+ holdout: int
34
+
35
+
36
+ class DatasetBlock(_Open):
37
+ versionId: str
38
+ annotationHash: str
39
+ sha256: str
40
+ frames: Frames
41
+ holdoutCameras: list[int]
42
+ replayImages: int
43
+
44
+
45
+ class TrainingBlock(_Open):
46
+ epochsRun: int
47
+ bestEpoch: int
48
+ bestMap50: float
49
+ bestMap50_95: float
50
+ seconds: float
51
+
52
+
53
+ class EvaluationBlock(_Open):
54
+ holdoutFrames: int
55
+ baseline: Optional[dict[str, Any]]
56
+ candidate: Optional[dict[str, Any]]
57
+ candidateInt8: Optional[dict[str, Any]]
58
+ recallDeltaByCamera: Optional[dict[str, dict[str, float]]]
59
+ map50: dict[str, Optional[float]]
60
+
61
+
62
+ class FloorsBlock(_Open):
63
+ baseline: dict[str, float]
64
+ candidate: dict[str, float]
65
+ candidateInt8: Optional[dict[str, float]]
66
+ notes: list[str]
67
+
68
+
69
+ class Int8Block(_Open):
70
+ kept: bool
71
+ map50Fp16: Optional[float]
72
+ map50Int8: Optional[float]
73
+ reason: Optional[str]
74
+
75
+
76
+ class Dropped(_Open):
77
+ export: str
78
+ reason: str
79
+
80
+
81
+ class FileRow(_Open):
82
+ path: str
83
+ bytes: int
84
+ sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
85
+ format: Literal["onnx", "openvino", "coreml", "pt"]
86
+ variant: Literal["base", "int8"]
87
+
88
+
89
+ class Bundle(_Open):
90
+ path: Literal["bundle.tar"]
91
+ bytes: int
92
+ sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
93
+
94
+
95
+ class FormatEntry(_Open):
96
+ url: str
97
+ sizeMB: float
98
+ revision: str
99
+ files: Optional[list[str]] = None
100
+ runtimes: Optional[list[Literal["python"]]] = None
101
+ isDirectory: Optional[bool] = None
102
+
103
+
104
+ class CatalogEntryDraft(_Open):
105
+ id: str
106
+ name: str
107
+ description: str
108
+ inputSize: dict[Literal["width", "height"], int]
109
+ labels: list[Any]
110
+ preprocessMode: Literal["letterbox"]
111
+ defaultClassFloors: dict[str, float]
112
+ formats: dict[Literal["onnx", "openvino", "coreml"], FormatEntry]
113
+
114
+
115
+ class CatalogDraft(_Open):
116
+ base: CatalogEntryDraft
117
+ int8: Optional[CatalogEntryDraft]
118
+
119
+
120
+ class TrainingResult(_Open):
121
+ """A succeeded result carries every block; a failed one the error and whatever was known."""
122
+
123
+ schema_: Literal["camstack.training-result/v1"] = Field(alias="schema")
124
+ jobKey: str
125
+ status: Literal["succeeded", "failed"]
126
+ error: Optional[ErrorBlock]
127
+ trainer: TrainerBlock
128
+ toolchain: dict[str, Optional[str]]
129
+ dataset: Optional[DatasetBlock] = None
130
+ training: Optional[TrainingBlock] = None
131
+ evaluation: Optional[EvaluationBlock] = None
132
+ floors: Optional[FloorsBlock] = None
133
+ int8: Optional[Int8Block] = None
134
+ exportsDropped: Optional[list[Dropped]] = None
135
+ files: Optional[list[FileRow]] = None
136
+ bundle: Optional[Bundle] = None
137
+ catalogDraft: Optional[CatalogDraft] = None
138
+ licence: str
camstack_train/run.py ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """One training job, end to end: prepare → replay → train → export → eval → package.
2
+
3
+ Everything heavy is injected (:class:`Deps`) so the orchestration is tested
4
+ without torch, Ultralytics or OpenVINO. ``result.json`` is written on every
5
+ exit, success or failure, and the last line is always a ``done`` event.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+ from typing import Optional
12
+
13
+ from .dataset import coco_80, extract_archive, frames_from_manifest, verify_archive, write_layout
14
+ from .errors import EXIT_INTERNAL, EXIT_OK, TrainerError
15
+ from .evaluate import DetectorFactory, Evaluation, baseline_model, evaluate, harness_detector, holdout_frames, map50, policy_of, split_reports
16
+ from .events import Emitter
17
+ from .export import SubprocessToolchain, Toolchain, calibration_frames, export_all, remove_export
18
+ from .floors import floors_block
19
+ from .manifest import (
20
+ LICENCE, RESULT_SCHEMA, catalog_draft, failed_result, file_rows, toolchain_versions,
21
+ trainer_block, write_bundle, write_result,
22
+ )
23
+ from .replay import Fetch, http_fetch, prepare_replay
24
+ from .spec import JobSpec
25
+ from .split import SplitResult, assign
26
+ from .train_det import Trainer, TrainOutcome, ultralytics_train, write_data_yaml
27
+
28
+
29
+ @dataclass
30
+ class Deps:
31
+ fetch: Fetch = http_fetch
32
+ trainer: Trainer = ultralytics_train
33
+ toolchain: Toolchain = field(default_factory=SubprocessToolchain)
34
+ detector: DetectorFactory = harness_detector
35
+ emitter: Emitter = field(default_factory=Emitter)
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class Paths:
40
+ job: Path
41
+ dataset: Path
42
+ out: Path
43
+ cache: Path
44
+ work: Path
45
+
46
+
47
+ def _int8_gate(job: JobSpec, evaluation: Evaluation, produced: bool) -> dict:
48
+ fp16, int8 = map50(evaluation.candidate), map50(evaluation.candidate_int8)
49
+ if not produced:
50
+ return {"kept": False, "map50Fp16": fp16, "map50Int8": None, "reason": "not exported"}
51
+ if fp16 is None or int8 is None:
52
+ return {"kept": True, "map50Fp16": fp16, "map50Int8": int8, "reason": "no holdout to gate on"}
53
+ drop = round(fp16 - int8, 4)
54
+ if drop > job.int8_gate.max_map50_drop:
55
+ return {"kept": False, "map50Fp16": fp16, "map50Int8": int8, "reason": f"mAP50 drop {drop} > {job.int8_gate.max_map50_drop}"}
56
+ return {"kept": True, "map50Fp16": fp16, "map50Int8": int8, "reason": None}
57
+
58
+
59
+ def _prepare(job: JobSpec, paths: Paths, emitter: Emitter) -> tuple[SplitResult, Path, Path]:
60
+ emitter.stage("prepare")
61
+ verify_archive(paths.dataset, job.dataset.sha256)
62
+ source = paths.work / "export"
63
+ manifest = extract_archive(paths.dataset, source)
64
+ split = assign(frames_from_manifest(manifest), job.split)
65
+ yolo = paths.work / "yolo"
66
+ counts = write_layout({"train": list(split.train), "val": list(split.val)}, source, yolo)
67
+ emitter.metric("frames.train", counts.frames.get("train", 0))
68
+ emitter.metric("frames.val", counts.frames.get("val", 0))
69
+ emitter.metric("frames.holdout", len(split.holdout))
70
+ return split, source, yolo
71
+
72
+
73
+ def _dataset_block(job: JobSpec, split: SplitResult, replayed: int) -> dict:
74
+ return {
75
+ "versionId": job.dataset.version_id,
76
+ "annotationHash": job.dataset.annotation_hash,
77
+ "sha256": job.dataset.sha256,
78
+ "frames": {"train": len(split.train), "val": len(split.val), "holdout": len(split.holdout)},
79
+ "holdoutCameras": list(split.holdout_cameras),
80
+ "replayImages": replayed,
81
+ }
82
+
83
+
84
+ def _training_block(outcome: TrainOutcome) -> dict:
85
+ return {
86
+ "epochsRun": outcome.epochs_run,
87
+ "bestEpoch": outcome.best_epoch,
88
+ "bestMap50": outcome.best_map50,
89
+ "bestMap50_95": outcome.best_map50_95,
90
+ "seconds": outcome.seconds,
91
+ }
92
+
93
+
94
+ def execute(job: JobSpec, paths: Paths, deps: Deps, partial: dict) -> dict:
95
+ emitter = deps.emitter
96
+ split, source, yolo = _prepare(job, paths, emitter)
97
+ emitter.stage("replay")
98
+ names = coco_80()
99
+ replayed = prepare_replay(job.replay.count, paths.cache / "coco", yolo, names, deps.fetch)
100
+ partial["dataset"] = _dataset_block(job, split, replayed)
101
+ emitter.stage("train")
102
+ data_yaml = write_data_yaml(yolo, names, replayed > 0, len(split.val) > 0)
103
+ outcome = deps.trainer(job, data_yaml, paths.work, paths.cache, emitter)
104
+ partial["training"] = _training_block(outcome)
105
+ emitter.stage("export")
106
+ staging = paths.work / "artifacts"
107
+ calib = calibration_frames(yolo / "images" / "train", job.calibration.max_frames)
108
+ exported = export_all(list(job.exports), outcome.best_pt, job.catalog.id, job.base.imgsz, calib, staging, deps.toolchain)
109
+ emitter.stage("eval")
110
+ int8_path = staging / f"{job.catalog.id}-int8.xml" if "openvino-int8" in exported.produced else None
111
+ frames = holdout_frames(source, {f.frame_id for f in split.holdout_eval})
112
+ baseline = baseline_model(job, paths.cache, deps.fetch) if frames else None
113
+ evaluation = (
114
+ evaluate(job, frames, baseline, staging / f"{job.catalog.id}.onnx", int8_path, deps.detector)
115
+ if baseline is not None else Evaluation(None, None, 0)
116
+ )
117
+ gate = _int8_gate(job, evaluation, int8_path is not None)
118
+ if int8_path is not None and not gate["kept"]:
119
+ remove_export(staging, job.catalog.id, "openvino-int8")
120
+ exported.drop("openvino-int8", gate["reason"])
121
+ floors = floors_block(evaluation.candidate, evaluation.candidate_int8, dict(job.baseline.floors), policy_of(job), gate["kept"] and int8_path is not None)
122
+ emitter.stage("package")
123
+ rows = file_rows(staging, job.catalog.id)
124
+ for row in rows:
125
+ emitter.artifact(row["path"], row["bytes"], row["sha256"])
126
+ bundle = write_bundle(staging, rows, paths.out / "bundle.tar")
127
+ return {
128
+ "schema": RESULT_SCHEMA,
129
+ "jobKey": job.job_key,
130
+ "status": "succeeded",
131
+ "error": None,
132
+ "trainer": trainer_block(),
133
+ "toolchain": toolchain_versions(),
134
+ **partial,
135
+ "evaluation": split_reports(evaluation),
136
+ "floors": floors,
137
+ "int8": gate,
138
+ "exportsDropped": exported.dropped,
139
+ "files": rows,
140
+ "bundle": bundle,
141
+ "catalogDraft": catalog_draft(job, rows, floors),
142
+ "licence": LICENCE,
143
+ }
144
+
145
+
146
+ def scrub(message: str, paths: Paths) -> str:
147
+ """No frame path leaves the container: every one lives under the work dir."""
148
+ return message.replace(str(paths.work), "<work>").replace(str(paths.dataset), "<dataset>")[:300]
149
+
150
+
151
+ def run_job(job: JobSpec, paths: Paths, deps: Optional[Deps] = None) -> int:
152
+ deps = deps or Deps()
153
+ partial: dict = {}
154
+ try:
155
+ result = execute(job, paths, deps, partial)
156
+ code, status = EXIT_OK, "succeeded"
157
+ except TrainerError as exc:
158
+ deps.emitter.error(exc.stage, exc.code, exc.message)
159
+ result, code, status = {**failed_result(job.job_key, exc.as_dict()), **partial}, exc.exit_code, "failed"
160
+ except Exception as exc: # anything unforeseen: still a result and a done line
161
+ message = scrub(f"{type(exc).__name__}: {exc}", paths)
162
+ error = {"stage": "internal", "code": "internal", "message": message}
163
+ deps.emitter.error("internal", "internal", message)
164
+ result, code, status = {**failed_result(job.job_key, error), **partial}, EXIT_INTERNAL, "failed"
165
+ digest = write_result(paths.out / "result.json", result)
166
+ deps.emitter.done(status, digest)
167
+ return code
camstack_train/spec.py ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``job.json``, schema ``camstack.training/v1``.
2
+
3
+ The hub fills EVERY field: nothing here relies on a default, and an unknown
4
+ key is refused, so a hub and a trainer that disagree about the contract fail
5
+ loudly at the first line instead of training on a silently ignored setting.
6
+ An unknown ``schema`` exits 64 before anything is read.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from pathlib import Path
12
+ from typing import Literal
13
+
14
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator
15
+ from pydantic.alias_generators import to_camel
16
+
17
+ from .errors import TrainerError, spec_error
18
+
19
+ JOB_SCHEMA = "camstack.training/v1"
20
+
21
+ ExportName = Literal["onnx", "openvino-fp16", "openvino-int8", "coreml-fp16"]
22
+
23
+ #: Macro classes a root detector's floors are kept for.
24
+ FLOOR_CLASSES = ("person", "vehicle", "animal")
25
+
26
+ #: A catalog id becomes file names on every node: lower-case, no path parts.
27
+ CATALOG_ID_PATTERN = r"^[a-z0-9][a-z0-9._-]{0,95}$"
28
+
29
+
30
+ class _Model(BaseModel):
31
+ model_config = ConfigDict(
32
+ alias_generator=to_camel,
33
+ populate_by_name=True,
34
+ extra="forbid",
35
+ frozen=True,
36
+ protected_namespaces=(),
37
+ )
38
+
39
+
40
+ class Target(_Model):
41
+ kind: Literal["detector"]
42
+ step: Literal["object-detection"]
43
+
44
+
45
+ class Base(_Model):
46
+ model_id: str = Field(min_length=1)
47
+ weights: str = Field(pattern=r"^[A-Za-z0-9._-]+\.pt$")
48
+ imgsz: int = Field(ge=64, le=2048)
49
+
50
+
51
+ class Baseline(_Model):
52
+ model_id: str = Field(min_length=1)
53
+ onnx_url: str = Field(pattern=r"^https://")
54
+ imgsz: int = Field(ge=64, le=2048)
55
+ floors: dict[str, float]
56
+
57
+ @field_validator("floors")
58
+ @classmethod
59
+ def _every_class(cls, value: dict[str, float]) -> dict[str, float]:
60
+ missing = [c for c in FLOOR_CLASSES if c not in value]
61
+ if missing:
62
+ raise ValueError(f"baseline floors miss {', '.join(missing)}")
63
+ if any(not 0.0 <= v <= 1.0 for v in value.values()):
64
+ raise ValueError("a baseline floor is outside [0, 1]")
65
+ return value
66
+
67
+
68
+ class Dataset(_Model):
69
+ version_id: str = Field(min_length=1)
70
+ annotation_hash: str
71
+ sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
72
+
73
+
74
+ class Split(_Model):
75
+ holdout_cameras: list[int]
76
+ holdout_last_days: int = Field(ge=0, le=365)
77
+ val_fraction: float = Field(ge=0.0, le=0.5)
78
+ seed: int
79
+
80
+
81
+ class Hyper(_Model):
82
+ epochs: int = Field(ge=1, le=1000)
83
+ batch: int = Field(ge=1, le=512)
84
+ lr0: float = Field(gt=0.0, le=1.0)
85
+ freeze: int = Field(ge=0, le=100)
86
+ patience: int = Field(ge=0, le=1000)
87
+ seed: int
88
+
89
+
90
+ class Replay(_Model):
91
+ source: Literal["coco2017"]
92
+ count: int = Field(ge=0, le=20000)
93
+
94
+
95
+ class Calibration(_Model):
96
+ max_frames: int = Field(ge=1, le=5000)
97
+
98
+
99
+ class Floors(_Model):
100
+ min_support: int = Field(ge=0)
101
+ clamp: float = Field(ge=0.0, le=1.0)
102
+
103
+
104
+ class Int8Gate(_Model):
105
+ max_map50_drop: float = Field(ge=0.0, le=1.0)
106
+
107
+
108
+ class Catalog(_Model):
109
+ id: str = Field(pattern=CATALOG_ID_PATTERN)
110
+ name: str = Field(min_length=1, max_length=120)
111
+
112
+
113
+ class JobSpec(_Model):
114
+ schema_: Literal["camstack.training/v1"] = Field(alias="schema")
115
+ job_key: str = Field(min_length=1, max_length=64, pattern=r"^[A-Za-z0-9-]+$")
116
+ target: Target
117
+ base: Base
118
+ baseline: Baseline
119
+ dataset: Dataset
120
+ split: Split
121
+ hyper: Hyper
122
+ replay: Replay
123
+ exports: list[ExportName] = Field(min_length=1)
124
+ calibration: Calibration
125
+ floors: Floors
126
+ int8_gate: Int8Gate
127
+ catalog: Catalog
128
+
129
+ @field_validator("exports")
130
+ @classmethod
131
+ def _onnx_first(cls, value: list[str]) -> list[str]:
132
+ if "onnx" not in value:
133
+ raise ValueError("exports must include onnx: every other format is built from it")
134
+ if len(set(value)) != len(value):
135
+ raise ValueError("exports lists a format twice")
136
+ return value
137
+
138
+
139
+ def parse_job(raw: object) -> JobSpec:
140
+ """Validate a decoded ``job.json``; any refusal is a spec error (exit 64)."""
141
+ if not isinstance(raw, dict):
142
+ raise spec_error("bad-spec", "job.json is not an object")
143
+ schema = raw.get("schema")
144
+ if schema != JOB_SCHEMA:
145
+ raise spec_error("unknown-schema", f"job schema {schema!r} is not {JOB_SCHEMA}")
146
+ try:
147
+ return JobSpec.model_validate(raw)
148
+ except ValidationError as exc:
149
+ fields = sorted({".".join(str(p) for p in e["loc"]) for e in exc.errors()})
150
+ raise spec_error("bad-spec", f"job.json refused at: {', '.join(fields)}") from exc
151
+
152
+
153
+ def load_job(path: Path) -> JobSpec:
154
+ try:
155
+ raw = json.loads(path.read_text("utf-8"))
156
+ except FileNotFoundError as exc:
157
+ raise spec_error("bad-spec", "job.json not found") from exc
158
+ except json.JSONDecodeError as exc:
159
+ raise spec_error("bad-spec", f"job.json is not JSON (line {exc.lineno})") from exc
160
+ return parse_job(raw)
161
+
162
+
163
+ def job_key_of(path: Path) -> str:
164
+ """The ``jobKey`` of a job file even when the rest is refused (for result.json)."""
165
+ try:
166
+ raw = json.loads(path.read_text("utf-8"))
167
+ except (OSError, json.JSONDecodeError):
168
+ return ""
169
+ key = raw.get("jobKey") if isinstance(raw, dict) else None
170
+ return key if isinstance(key, str) else ""
171
+
172
+
173
+ __all__ = ["JobSpec", "JOB_SCHEMA", "TrainerError", "parse_job", "load_job", "job_key_of"]
camstack_train/split.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The train / val / holdout assignment, deterministic from ``job.split``.
2
+
3
+ Never random per frame: two frames a second apart on one camera are near
4
+ copies, and splitting them between train and holdout measures memory, not the
5
+ model. So:
6
+
7
+ - **holdout**: whole cameras (``holdoutCameras``) when the hub named any;
8
+ otherwise the last ``holdoutLastDays`` capture days of EVERY camera (a frame
9
+ with no capture time is never held out); otherwise nothing.
10
+ - **val**: whole (camera, day) groups of the remaining trainable frames, in a
11
+ seeded hash order, until ``valFraction`` of them is reached — never the last
12
+ train group.
13
+ - **train**: everything else that is trainable (``dataset.Frame.trainable``).
14
+
15
+ A holdout frame never trains, whether or not it is complete; only complete
16
+ holdout frames are evaluated. A shortage of holdout positives does NOT refuse
17
+ the job — the floor rule then inherits the baseline's floor. An empty train
18
+ split does (exit 65).
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import hashlib
23
+ from dataclasses import dataclass
24
+ from typing import Optional, Sequence
25
+
26
+ from .dataset import Frame
27
+ from .errors import dataset_error
28
+ from .spec import Split
29
+
30
+ DAY_MS = 86_400_000
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class SplitResult:
35
+ train: tuple[Frame, ...]
36
+ val: tuple[Frame, ...]
37
+ holdout: tuple[Frame, ...]
38
+ holdout_cameras: tuple[int, ...]
39
+
40
+ @property
41
+ def holdout_eval(self) -> tuple[Frame, ...]:
42
+ return tuple(f for f in self.holdout if f.complete)
43
+
44
+
45
+ def day_of(frame: Frame) -> Optional[int]:
46
+ return None if frame.captured_at is None else frame.captured_at // DAY_MS
47
+
48
+
49
+ def _holdout_by_days(frames: Sequence[Frame], days: int) -> set[str]:
50
+ last_day: dict[int, int] = {}
51
+ for f in frames:
52
+ d = day_of(f)
53
+ if d is not None:
54
+ last_day[f.device_id] = max(last_day.get(f.device_id, d), d)
55
+ held = set()
56
+ for f in frames:
57
+ d = day_of(f)
58
+ if d is not None and d > last_day[f.device_id] - days:
59
+ held.add(f.frame_id)
60
+ return held
61
+
62
+
63
+ def _group_order(seed: int, device_id: int, day: Optional[int]) -> str:
64
+ return hashlib.sha256(f"{seed}:{device_id}:{day}".encode()).hexdigest()
65
+
66
+
67
+ def _val_groups(frames: Sequence[Frame], fraction: float, seed: int) -> set[tuple[int, Optional[int]]]:
68
+ groups: dict[tuple[int, Optional[int]], int] = {}
69
+ for f in frames:
70
+ key = (f.device_id, day_of(f))
71
+ groups[key] = groups.get(key, 0) + 1
72
+ target = round(fraction * len(frames))
73
+ chosen: set[tuple[int, Optional[int]]] = set()
74
+ taken = 0
75
+ ordered = sorted(groups, key=lambda k: _group_order(seed, k[0], k[1]))
76
+ for key in ordered:
77
+ if taken >= target or len(chosen) >= len(groups) - 1:
78
+ break
79
+ chosen.add(key)
80
+ taken += groups[key]
81
+ return chosen
82
+
83
+
84
+ def assign(frames: Sequence[Frame], policy: Split) -> SplitResult:
85
+ if policy.holdout_cameras:
86
+ wanted = set(policy.holdout_cameras)
87
+ held = {f.frame_id for f in frames if f.device_id in wanted}
88
+ cameras = tuple(sorted(wanted))
89
+ elif policy.holdout_last_days > 0:
90
+ held = _holdout_by_days(frames, policy.holdout_last_days)
91
+ cameras = ()
92
+ else:
93
+ held, cameras = set(), ()
94
+ holdout = tuple(f for f in frames if f.frame_id in held)
95
+ rest = [f for f in frames if f.frame_id not in held and f.trainable]
96
+ val_keys = _val_groups(rest, policy.val_fraction, policy.seed)
97
+ val = tuple(f for f in rest if (f.device_id, day_of(f)) in val_keys)
98
+ train = tuple(f for f in rest if (f.device_id, day_of(f)) not in val_keys)
99
+ if not train:
100
+ raise dataset_error("no-train-frames", "no trainable frame is left outside the holdout")
101
+ return SplitResult(train=train, val=val, holdout=holdout, holdout_cameras=cameras)
camstack_train/train_det.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Fine-tune the YOLOv9 root detector with Ultralytics.
2
+
3
+ This is the ONLY module that imports Ultralytics (AGPL-3.0), and only inside
4
+ :func:`ultralytics_train`, so the rest of the package — and its tests — never
5
+ load it. The hub never imports this package at all.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import time
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Any, Callable, Optional, Protocol
13
+
14
+ from .events import Emitter
15
+ from .spec import JobSpec
16
+
17
+ #: Where Ultralytics' base weights are cached between jobs (the cache volume).
18
+ WEIGHTS_DIR = "weights"
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class TrainOutcome:
23
+ best_pt: Path
24
+ epochs_run: int
25
+ best_epoch: int
26
+ best_map50: float
27
+ best_map50_95: float
28
+ seconds: float
29
+
30
+
31
+ class Trainer(Protocol):
32
+ def __call__(self, job: JobSpec, data_yaml: Path, work: Path, cache: Path, emitter: Emitter) -> TrainOutcome: ...
33
+
34
+
35
+ def write_data_yaml(root: Path, names: list[str], with_replay: bool, has_val: bool) -> Path:
36
+ """The Ultralytics dataset file. Without a val split, val = train (stated)."""
37
+ train = ["images/train"] + (["images/replay"] if with_replay else [])
38
+ val = "images/val" if has_val else "images/train"
39
+ lines = [f"path: {root.as_posix()}", "train:"]
40
+ lines += [f" - {t}" for t in train]
41
+ lines.append(f"val: {val}")
42
+ lines.append("names:")
43
+ lines += [f" {i}: {name!r}" for i, name in enumerate(names)]
44
+ path = root / "data.yaml"
45
+ path.write_text("\n".join(lines) + "\n", "utf-8")
46
+ return path
47
+
48
+
49
+ @dataclass
50
+ class EpochTracker:
51
+ """Turns Ultralytics' per-epoch callback into ``epoch`` events + the best epoch."""
52
+
53
+ emitter: Emitter
54
+ epochs: int
55
+ started: float
56
+ clock: Callable[[], float] = time.monotonic
57
+ epochs_run: int = 0
58
+ best_epoch: int = 0
59
+ best_map50: float = 0.0
60
+ best_map50_95: float = 0.0
61
+
62
+ def on_epoch(self, epoch: int, box_loss: Optional[float], metrics: dict[str, Any]) -> None:
63
+ map50 = _number(metrics.get("metrics/mAP50(B)"))
64
+ map50_95 = _number(metrics.get("metrics/mAP50-95(B)"))
65
+ self.epochs_run = epoch
66
+ if map50_95 is not None and map50_95 >= self.best_map50_95:
67
+ self.best_epoch, self.best_map50_95 = epoch, map50_95
68
+ self.best_map50 = map50 or 0.0
69
+ elapsed = self.clock() - self.started
70
+ eta = elapsed / epoch * (self.epochs - epoch) if epoch > 0 else None
71
+ self.emitter.epoch(epoch, self.epochs, box_loss, map50, map50_95, None if eta is None else round(eta, 1))
72
+
73
+
74
+ def _number(value: Any) -> Optional[float]:
75
+ try:
76
+ return None if value is None else round(float(value), 5)
77
+ except (TypeError, ValueError):
78
+ return None
79
+
80
+
81
+ def base_weights(job: JobSpec, cache: Path) -> Path:
82
+ """The base checkpoint, downloaded once into the cache volume."""
83
+ from ultralytics.utils.downloads import attempt_download_asset
84
+
85
+ target = cache / WEIGHTS_DIR / job.base.weights
86
+ target.parent.mkdir(parents=True, exist_ok=True)
87
+ if not target.is_file():
88
+ attempt_download_asset(str(target))
89
+ return target
90
+
91
+
92
+ def ultralytics_train(job: JobSpec, data_yaml: Path, work: Path, cache: Path, emitter: Emitter) -> TrainOutcome:
93
+ import torch
94
+ from ultralytics import YOLO
95
+
96
+ model = YOLO(str(base_weights(job, cache)))
97
+ tracker = EpochTracker(emitter, job.hyper.epochs, time.monotonic())
98
+
99
+ def on_fit_epoch_end(trainer: Any) -> None:
100
+ loss = getattr(trainer, "tloss", None)
101
+ box_loss = _number(loss[0]) if loss is not None and len(loss) > 0 else None
102
+ tracker.on_epoch(int(trainer.epoch) + 1, box_loss, dict(getattr(trainer, "metrics", {}) or {}))
103
+
104
+ model.add_callback("on_fit_epoch_end", on_fit_epoch_end)
105
+ started = time.monotonic()
106
+ model.train(
107
+ data=str(data_yaml),
108
+ epochs=job.hyper.epochs,
109
+ batch=job.hyper.batch,
110
+ lr0=job.hyper.lr0,
111
+ freeze=job.hyper.freeze or None,
112
+ patience=job.hyper.patience,
113
+ seed=job.hyper.seed,
114
+ deterministic=True,
115
+ imgsz=job.base.imgsz,
116
+ device=0 if torch.cuda.is_available() else "cpu",
117
+ project=str(work / "runs"),
118
+ name="train",
119
+ exist_ok=True,
120
+ plots=False,
121
+ verbose=False,
122
+ )
123
+ best = work / "runs" / "train" / "weights" / "best.pt"
124
+ return TrainOutcome(
125
+ best_pt=best,
126
+ epochs_run=tracker.epochs_run,
127
+ best_epoch=tracker.best_epoch,
128
+ best_map50=tracker.best_map50,
129
+ best_map50_95=tracker.best_map50_95,
130
+ seconds=round(time.monotonic() - started, 1),
131
+ )
pyproject.toml ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Tracked in camstack/server as hf/camstack-trainer/; edit it there, not on the Hub.
2
+ [build-system]
3
+ requires = ["setuptools>=75", "wheel"]
4
+ build-backend = "setuptools.build_meta"
5
+
6
+ [project]
7
+ name = "camstack-train"
8
+ version = "0.1.0"
9
+ description = "CamStack detector trainer: fine-tunes the YOLOv9 root on a hub's annotated export"
10
+ readme = "README.md"
11
+ license = "AGPL-3.0-only"
12
+ requires-python = ">=3.10"
13
+ # The light core: spec, dataset conversion, split, floors, manifest and the
14
+ # vendored evaluation harness. Enough to run the unit tests.
15
+ dependencies = [
16
+ "numpy==2.5.1",
17
+ "pillow==12.3.0",
18
+ "pydantic==2.13.4",
19
+ "onnxruntime==1.27.0",
20
+ ]
21
+
22
+ [project.optional-dependencies]
23
+ # torch comes from the base image (pytorch/pytorch:2.13.0-cuda12.6-cudnn9-runtime);
24
+ # it is deliberately NOT pinned here so pip never replaces the CUDA build.
25
+ train = [
26
+ "ultralytics==8.4.98",
27
+ ]
28
+ export = [
29
+ "onnx==1.22.0",
30
+ "onnxslim==0.1.94",
31
+ "openvino==2026.2.1",
32
+ "nncf==3.2.0",
33
+ "coremltools==9.0",
34
+ ]
35
+ test = [
36
+ "pytest==9.1.1",
37
+ ]
38
+
39
+ [project.scripts]
40
+ camstack-train = "camstack_train.cli:main"
41
+
42
+ [tool.setuptools.packages.find]
43
+ include = ["camstack_train*"]
44
+ namespaces = true
45
+
46
+ [tool.setuptools.package-data]
47
+ "*" = ["*.py"]
48
+
49
+ [tool.pytest.ini_options]
50
+ testpaths = ["tests"]
schemas/job.schema.json ADDED
@@ -0,0 +1,380 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "$defs": {
3
+ "Base": {
4
+ "additionalProperties": false,
5
+ "properties": {
6
+ "imgsz": {
7
+ "maximum": 2048,
8
+ "minimum": 64,
9
+ "title": "Imgsz",
10
+ "type": "integer"
11
+ },
12
+ "modelId": {
13
+ "minLength": 1,
14
+ "title": "Modelid",
15
+ "type": "string"
16
+ },
17
+ "weights": {
18
+ "pattern": "^[A-Za-z0-9._-]+\\.pt$",
19
+ "title": "Weights",
20
+ "type": "string"
21
+ }
22
+ },
23
+ "required": [
24
+ "modelId",
25
+ "weights",
26
+ "imgsz"
27
+ ],
28
+ "title": "Base",
29
+ "type": "object"
30
+ },
31
+ "Baseline": {
32
+ "additionalProperties": false,
33
+ "properties": {
34
+ "floors": {
35
+ "additionalProperties": {
36
+ "type": "number"
37
+ },
38
+ "title": "Floors",
39
+ "type": "object"
40
+ },
41
+ "imgsz": {
42
+ "maximum": 2048,
43
+ "minimum": 64,
44
+ "title": "Imgsz",
45
+ "type": "integer"
46
+ },
47
+ "modelId": {
48
+ "minLength": 1,
49
+ "title": "Modelid",
50
+ "type": "string"
51
+ },
52
+ "onnxUrl": {
53
+ "pattern": "^https://",
54
+ "title": "Onnxurl",
55
+ "type": "string"
56
+ }
57
+ },
58
+ "required": [
59
+ "modelId",
60
+ "onnxUrl",
61
+ "imgsz",
62
+ "floors"
63
+ ],
64
+ "title": "Baseline",
65
+ "type": "object"
66
+ },
67
+ "Calibration": {
68
+ "additionalProperties": false,
69
+ "properties": {
70
+ "maxFrames": {
71
+ "maximum": 5000,
72
+ "minimum": 1,
73
+ "title": "Maxframes",
74
+ "type": "integer"
75
+ }
76
+ },
77
+ "required": [
78
+ "maxFrames"
79
+ ],
80
+ "title": "Calibration",
81
+ "type": "object"
82
+ },
83
+ "Catalog": {
84
+ "additionalProperties": false,
85
+ "properties": {
86
+ "id": {
87
+ "pattern": "^[a-z0-9][a-z0-9._-]{0,95}$",
88
+ "title": "Id",
89
+ "type": "string"
90
+ },
91
+ "name": {
92
+ "maxLength": 120,
93
+ "minLength": 1,
94
+ "title": "Name",
95
+ "type": "string"
96
+ }
97
+ },
98
+ "required": [
99
+ "id",
100
+ "name"
101
+ ],
102
+ "title": "Catalog",
103
+ "type": "object"
104
+ },
105
+ "Dataset": {
106
+ "additionalProperties": false,
107
+ "properties": {
108
+ "annotationHash": {
109
+ "title": "Annotationhash",
110
+ "type": "string"
111
+ },
112
+ "sha256": {
113
+ "pattern": "^[0-9a-f]{64}$",
114
+ "title": "Sha256",
115
+ "type": "string"
116
+ },
117
+ "versionId": {
118
+ "minLength": 1,
119
+ "title": "Versionid",
120
+ "type": "string"
121
+ }
122
+ },
123
+ "required": [
124
+ "versionId",
125
+ "annotationHash",
126
+ "sha256"
127
+ ],
128
+ "title": "Dataset",
129
+ "type": "object"
130
+ },
131
+ "Floors": {
132
+ "additionalProperties": false,
133
+ "properties": {
134
+ "clamp": {
135
+ "maximum": 1.0,
136
+ "minimum": 0.0,
137
+ "title": "Clamp",
138
+ "type": "number"
139
+ },
140
+ "minSupport": {
141
+ "minimum": 0,
142
+ "title": "Minsupport",
143
+ "type": "integer"
144
+ }
145
+ },
146
+ "required": [
147
+ "minSupport",
148
+ "clamp"
149
+ ],
150
+ "title": "Floors",
151
+ "type": "object"
152
+ },
153
+ "Hyper": {
154
+ "additionalProperties": false,
155
+ "properties": {
156
+ "batch": {
157
+ "maximum": 512,
158
+ "minimum": 1,
159
+ "title": "Batch",
160
+ "type": "integer"
161
+ },
162
+ "epochs": {
163
+ "maximum": 1000,
164
+ "minimum": 1,
165
+ "title": "Epochs",
166
+ "type": "integer"
167
+ },
168
+ "freeze": {
169
+ "maximum": 100,
170
+ "minimum": 0,
171
+ "title": "Freeze",
172
+ "type": "integer"
173
+ },
174
+ "lr0": {
175
+ "exclusiveMinimum": 0.0,
176
+ "maximum": 1.0,
177
+ "title": "Lr0",
178
+ "type": "number"
179
+ },
180
+ "patience": {
181
+ "maximum": 1000,
182
+ "minimum": 0,
183
+ "title": "Patience",
184
+ "type": "integer"
185
+ },
186
+ "seed": {
187
+ "title": "Seed",
188
+ "type": "integer"
189
+ }
190
+ },
191
+ "required": [
192
+ "epochs",
193
+ "batch",
194
+ "lr0",
195
+ "freeze",
196
+ "patience",
197
+ "seed"
198
+ ],
199
+ "title": "Hyper",
200
+ "type": "object"
201
+ },
202
+ "Int8Gate": {
203
+ "additionalProperties": false,
204
+ "properties": {
205
+ "maxMap50Drop": {
206
+ "maximum": 1.0,
207
+ "minimum": 0.0,
208
+ "title": "Maxmap50Drop",
209
+ "type": "number"
210
+ }
211
+ },
212
+ "required": [
213
+ "maxMap50Drop"
214
+ ],
215
+ "title": "Int8Gate",
216
+ "type": "object"
217
+ },
218
+ "Replay": {
219
+ "additionalProperties": false,
220
+ "properties": {
221
+ "count": {
222
+ "maximum": 20000,
223
+ "minimum": 0,
224
+ "title": "Count",
225
+ "type": "integer"
226
+ },
227
+ "source": {
228
+ "const": "coco2017",
229
+ "title": "Source",
230
+ "type": "string"
231
+ }
232
+ },
233
+ "required": [
234
+ "source",
235
+ "count"
236
+ ],
237
+ "title": "Replay",
238
+ "type": "object"
239
+ },
240
+ "Split": {
241
+ "additionalProperties": false,
242
+ "properties": {
243
+ "holdoutCameras": {
244
+ "items": {
245
+ "type": "integer"
246
+ },
247
+ "title": "Holdoutcameras",
248
+ "type": "array"
249
+ },
250
+ "holdoutLastDays": {
251
+ "maximum": 365,
252
+ "minimum": 0,
253
+ "title": "Holdoutlastdays",
254
+ "type": "integer"
255
+ },
256
+ "seed": {
257
+ "title": "Seed",
258
+ "type": "integer"
259
+ },
260
+ "valFraction": {
261
+ "maximum": 0.5,
262
+ "minimum": 0.0,
263
+ "title": "Valfraction",
264
+ "type": "number"
265
+ }
266
+ },
267
+ "required": [
268
+ "holdoutCameras",
269
+ "holdoutLastDays",
270
+ "valFraction",
271
+ "seed"
272
+ ],
273
+ "title": "Split",
274
+ "type": "object"
275
+ },
276
+ "Target": {
277
+ "additionalProperties": false,
278
+ "properties": {
279
+ "kind": {
280
+ "const": "detector",
281
+ "title": "Kind",
282
+ "type": "string"
283
+ },
284
+ "step": {
285
+ "const": "object-detection",
286
+ "title": "Step",
287
+ "type": "string"
288
+ }
289
+ },
290
+ "required": [
291
+ "kind",
292
+ "step"
293
+ ],
294
+ "title": "Target",
295
+ "type": "object"
296
+ }
297
+ },
298
+ "$id": "camstack.training/v1",
299
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
300
+ "additionalProperties": false,
301
+ "properties": {
302
+ "base": {
303
+ "$ref": "#/$defs/Base"
304
+ },
305
+ "baseline": {
306
+ "$ref": "#/$defs/Baseline"
307
+ },
308
+ "calibration": {
309
+ "$ref": "#/$defs/Calibration"
310
+ },
311
+ "catalog": {
312
+ "$ref": "#/$defs/Catalog"
313
+ },
314
+ "dataset": {
315
+ "$ref": "#/$defs/Dataset"
316
+ },
317
+ "exports": {
318
+ "items": {
319
+ "enum": [
320
+ "onnx",
321
+ "openvino-fp16",
322
+ "openvino-int8",
323
+ "coreml-fp16"
324
+ ],
325
+ "type": "string"
326
+ },
327
+ "minItems": 1,
328
+ "title": "Exports",
329
+ "type": "array"
330
+ },
331
+ "floors": {
332
+ "$ref": "#/$defs/Floors"
333
+ },
334
+ "hyper": {
335
+ "$ref": "#/$defs/Hyper"
336
+ },
337
+ "int8Gate": {
338
+ "$ref": "#/$defs/Int8Gate"
339
+ },
340
+ "jobKey": {
341
+ "maxLength": 64,
342
+ "minLength": 1,
343
+ "pattern": "^[A-Za-z0-9-]+$",
344
+ "title": "Jobkey",
345
+ "type": "string"
346
+ },
347
+ "replay": {
348
+ "$ref": "#/$defs/Replay"
349
+ },
350
+ "schema": {
351
+ "const": "camstack.training/v1",
352
+ "title": "Schema",
353
+ "type": "string"
354
+ },
355
+ "split": {
356
+ "$ref": "#/$defs/Split"
357
+ },
358
+ "target": {
359
+ "$ref": "#/$defs/Target"
360
+ }
361
+ },
362
+ "required": [
363
+ "schema",
364
+ "jobKey",
365
+ "target",
366
+ "base",
367
+ "baseline",
368
+ "dataset",
369
+ "split",
370
+ "hyper",
371
+ "replay",
372
+ "exports",
373
+ "calibration",
374
+ "floors",
375
+ "int8Gate",
376
+ "catalog"
377
+ ],
378
+ "title": "JobSpec",
379
+ "type": "object"
380
+ }
schemas/result.schema.json ADDED
@@ -0,0 +1,756 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "$defs": {
3
+ "Bundle": {
4
+ "additionalProperties": false,
5
+ "properties": {
6
+ "bytes": {
7
+ "title": "Bytes",
8
+ "type": "integer"
9
+ },
10
+ "path": {
11
+ "const": "bundle.tar",
12
+ "title": "Path",
13
+ "type": "string"
14
+ },
15
+ "sha256": {
16
+ "pattern": "^[0-9a-f]{64}$",
17
+ "title": "Sha256",
18
+ "type": "string"
19
+ }
20
+ },
21
+ "required": [
22
+ "path",
23
+ "bytes",
24
+ "sha256"
25
+ ],
26
+ "title": "Bundle",
27
+ "type": "object"
28
+ },
29
+ "CatalogDraft": {
30
+ "additionalProperties": false,
31
+ "properties": {
32
+ "base": {
33
+ "$ref": "#/$defs/CatalogEntryDraft"
34
+ },
35
+ "int8": {
36
+ "anyOf": [
37
+ {
38
+ "$ref": "#/$defs/CatalogEntryDraft"
39
+ },
40
+ {
41
+ "type": "null"
42
+ }
43
+ ]
44
+ }
45
+ },
46
+ "required": [
47
+ "base",
48
+ "int8"
49
+ ],
50
+ "title": "CatalogDraft",
51
+ "type": "object"
52
+ },
53
+ "CatalogEntryDraft": {
54
+ "additionalProperties": false,
55
+ "properties": {
56
+ "defaultClassFloors": {
57
+ "additionalProperties": {
58
+ "type": "number"
59
+ },
60
+ "title": "Defaultclassfloors",
61
+ "type": "object"
62
+ },
63
+ "description": {
64
+ "title": "Description",
65
+ "type": "string"
66
+ },
67
+ "formats": {
68
+ "additionalProperties": {
69
+ "$ref": "#/$defs/FormatEntry"
70
+ },
71
+ "propertyNames": {
72
+ "enum": [
73
+ "onnx",
74
+ "openvino",
75
+ "coreml"
76
+ ]
77
+ },
78
+ "title": "Formats",
79
+ "type": "object"
80
+ },
81
+ "id": {
82
+ "title": "Id",
83
+ "type": "string"
84
+ },
85
+ "inputSize": {
86
+ "additionalProperties": {
87
+ "type": "integer"
88
+ },
89
+ "propertyNames": {
90
+ "enum": [
91
+ "width",
92
+ "height"
93
+ ]
94
+ },
95
+ "title": "Inputsize",
96
+ "type": "object"
97
+ },
98
+ "labels": {
99
+ "items": {},
100
+ "title": "Labels",
101
+ "type": "array"
102
+ },
103
+ "name": {
104
+ "title": "Name",
105
+ "type": "string"
106
+ },
107
+ "preprocessMode": {
108
+ "const": "letterbox",
109
+ "title": "Preprocessmode",
110
+ "type": "string"
111
+ }
112
+ },
113
+ "required": [
114
+ "id",
115
+ "name",
116
+ "description",
117
+ "inputSize",
118
+ "labels",
119
+ "preprocessMode",
120
+ "defaultClassFloors",
121
+ "formats"
122
+ ],
123
+ "title": "CatalogEntryDraft",
124
+ "type": "object"
125
+ },
126
+ "DatasetBlock": {
127
+ "additionalProperties": false,
128
+ "properties": {
129
+ "annotationHash": {
130
+ "title": "Annotationhash",
131
+ "type": "string"
132
+ },
133
+ "frames": {
134
+ "$ref": "#/$defs/Frames"
135
+ },
136
+ "holdoutCameras": {
137
+ "items": {
138
+ "type": "integer"
139
+ },
140
+ "title": "Holdoutcameras",
141
+ "type": "array"
142
+ },
143
+ "replayImages": {
144
+ "title": "Replayimages",
145
+ "type": "integer"
146
+ },
147
+ "sha256": {
148
+ "title": "Sha256",
149
+ "type": "string"
150
+ },
151
+ "versionId": {
152
+ "title": "Versionid",
153
+ "type": "string"
154
+ }
155
+ },
156
+ "required": [
157
+ "versionId",
158
+ "annotationHash",
159
+ "sha256",
160
+ "frames",
161
+ "holdoutCameras",
162
+ "replayImages"
163
+ ],
164
+ "title": "DatasetBlock",
165
+ "type": "object"
166
+ },
167
+ "Dropped": {
168
+ "additionalProperties": false,
169
+ "properties": {
170
+ "export": {
171
+ "title": "Export",
172
+ "type": "string"
173
+ },
174
+ "reason": {
175
+ "title": "Reason",
176
+ "type": "string"
177
+ }
178
+ },
179
+ "required": [
180
+ "export",
181
+ "reason"
182
+ ],
183
+ "title": "Dropped",
184
+ "type": "object"
185
+ },
186
+ "ErrorBlock": {
187
+ "additionalProperties": false,
188
+ "properties": {
189
+ "code": {
190
+ "title": "Code",
191
+ "type": "string"
192
+ },
193
+ "message": {
194
+ "title": "Message",
195
+ "type": "string"
196
+ },
197
+ "stage": {
198
+ "title": "Stage",
199
+ "type": "string"
200
+ }
201
+ },
202
+ "required": [
203
+ "stage",
204
+ "code",
205
+ "message"
206
+ ],
207
+ "title": "ErrorBlock",
208
+ "type": "object"
209
+ },
210
+ "EvaluationBlock": {
211
+ "additionalProperties": false,
212
+ "properties": {
213
+ "baseline": {
214
+ "anyOf": [
215
+ {
216
+ "additionalProperties": true,
217
+ "type": "object"
218
+ },
219
+ {
220
+ "type": "null"
221
+ }
222
+ ],
223
+ "title": "Baseline"
224
+ },
225
+ "candidate": {
226
+ "anyOf": [
227
+ {
228
+ "additionalProperties": true,
229
+ "type": "object"
230
+ },
231
+ {
232
+ "type": "null"
233
+ }
234
+ ],
235
+ "title": "Candidate"
236
+ },
237
+ "candidateInt8": {
238
+ "anyOf": [
239
+ {
240
+ "additionalProperties": true,
241
+ "type": "object"
242
+ },
243
+ {
244
+ "type": "null"
245
+ }
246
+ ],
247
+ "title": "Candidateint8"
248
+ },
249
+ "holdoutFrames": {
250
+ "title": "Holdoutframes",
251
+ "type": "integer"
252
+ },
253
+ "map50": {
254
+ "additionalProperties": {
255
+ "anyOf": [
256
+ {
257
+ "type": "number"
258
+ },
259
+ {
260
+ "type": "null"
261
+ }
262
+ ]
263
+ },
264
+ "title": "Map50",
265
+ "type": "object"
266
+ },
267
+ "recallDeltaByCamera": {
268
+ "anyOf": [
269
+ {
270
+ "additionalProperties": {
271
+ "additionalProperties": {
272
+ "type": "number"
273
+ },
274
+ "type": "object"
275
+ },
276
+ "type": "object"
277
+ },
278
+ {
279
+ "type": "null"
280
+ }
281
+ ],
282
+ "title": "Recalldeltabycamera"
283
+ }
284
+ },
285
+ "required": [
286
+ "holdoutFrames",
287
+ "baseline",
288
+ "candidate",
289
+ "candidateInt8",
290
+ "recallDeltaByCamera",
291
+ "map50"
292
+ ],
293
+ "title": "EvaluationBlock",
294
+ "type": "object"
295
+ },
296
+ "FileRow": {
297
+ "additionalProperties": false,
298
+ "properties": {
299
+ "bytes": {
300
+ "title": "Bytes",
301
+ "type": "integer"
302
+ },
303
+ "format": {
304
+ "enum": [
305
+ "onnx",
306
+ "openvino",
307
+ "coreml",
308
+ "pt"
309
+ ],
310
+ "title": "Format",
311
+ "type": "string"
312
+ },
313
+ "path": {
314
+ "title": "Path",
315
+ "type": "string"
316
+ },
317
+ "sha256": {
318
+ "pattern": "^[0-9a-f]{64}$",
319
+ "title": "Sha256",
320
+ "type": "string"
321
+ },
322
+ "variant": {
323
+ "enum": [
324
+ "base",
325
+ "int8"
326
+ ],
327
+ "title": "Variant",
328
+ "type": "string"
329
+ }
330
+ },
331
+ "required": [
332
+ "path",
333
+ "bytes",
334
+ "sha256",
335
+ "format",
336
+ "variant"
337
+ ],
338
+ "title": "FileRow",
339
+ "type": "object"
340
+ },
341
+ "FloorsBlock": {
342
+ "additionalProperties": false,
343
+ "properties": {
344
+ "baseline": {
345
+ "additionalProperties": {
346
+ "type": "number"
347
+ },
348
+ "title": "Baseline",
349
+ "type": "object"
350
+ },
351
+ "candidate": {
352
+ "additionalProperties": {
353
+ "type": "number"
354
+ },
355
+ "title": "Candidate",
356
+ "type": "object"
357
+ },
358
+ "candidateInt8": {
359
+ "anyOf": [
360
+ {
361
+ "additionalProperties": {
362
+ "type": "number"
363
+ },
364
+ "type": "object"
365
+ },
366
+ {
367
+ "type": "null"
368
+ }
369
+ ],
370
+ "title": "Candidateint8"
371
+ },
372
+ "notes": {
373
+ "items": {
374
+ "type": "string"
375
+ },
376
+ "title": "Notes",
377
+ "type": "array"
378
+ }
379
+ },
380
+ "required": [
381
+ "baseline",
382
+ "candidate",
383
+ "candidateInt8",
384
+ "notes"
385
+ ],
386
+ "title": "FloorsBlock",
387
+ "type": "object"
388
+ },
389
+ "FormatEntry": {
390
+ "additionalProperties": false,
391
+ "properties": {
392
+ "files": {
393
+ "anyOf": [
394
+ {
395
+ "items": {
396
+ "type": "string"
397
+ },
398
+ "type": "array"
399
+ },
400
+ {
401
+ "type": "null"
402
+ }
403
+ ],
404
+ "default": null,
405
+ "title": "Files"
406
+ },
407
+ "isDirectory": {
408
+ "anyOf": [
409
+ {
410
+ "type": "boolean"
411
+ },
412
+ {
413
+ "type": "null"
414
+ }
415
+ ],
416
+ "default": null,
417
+ "title": "Isdirectory"
418
+ },
419
+ "revision": {
420
+ "title": "Revision",
421
+ "type": "string"
422
+ },
423
+ "runtimes": {
424
+ "anyOf": [
425
+ {
426
+ "items": {
427
+ "const": "python",
428
+ "type": "string"
429
+ },
430
+ "type": "array"
431
+ },
432
+ {
433
+ "type": "null"
434
+ }
435
+ ],
436
+ "default": null,
437
+ "title": "Runtimes"
438
+ },
439
+ "sizeMB": {
440
+ "title": "Sizemb",
441
+ "type": "number"
442
+ },
443
+ "url": {
444
+ "title": "Url",
445
+ "type": "string"
446
+ }
447
+ },
448
+ "required": [
449
+ "url",
450
+ "sizeMB",
451
+ "revision"
452
+ ],
453
+ "title": "FormatEntry",
454
+ "type": "object"
455
+ },
456
+ "Frames": {
457
+ "additionalProperties": false,
458
+ "properties": {
459
+ "holdout": {
460
+ "title": "Holdout",
461
+ "type": "integer"
462
+ },
463
+ "train": {
464
+ "title": "Train",
465
+ "type": "integer"
466
+ },
467
+ "val": {
468
+ "title": "Val",
469
+ "type": "integer"
470
+ }
471
+ },
472
+ "required": [
473
+ "train",
474
+ "val",
475
+ "holdout"
476
+ ],
477
+ "title": "Frames",
478
+ "type": "object"
479
+ },
480
+ "Int8Block": {
481
+ "additionalProperties": false,
482
+ "properties": {
483
+ "kept": {
484
+ "title": "Kept",
485
+ "type": "boolean"
486
+ },
487
+ "map50Fp16": {
488
+ "anyOf": [
489
+ {
490
+ "type": "number"
491
+ },
492
+ {
493
+ "type": "null"
494
+ }
495
+ ],
496
+ "title": "Map50Fp16"
497
+ },
498
+ "map50Int8": {
499
+ "anyOf": [
500
+ {
501
+ "type": "number"
502
+ },
503
+ {
504
+ "type": "null"
505
+ }
506
+ ],
507
+ "title": "Map50Int8"
508
+ },
509
+ "reason": {
510
+ "anyOf": [
511
+ {
512
+ "type": "string"
513
+ },
514
+ {
515
+ "type": "null"
516
+ }
517
+ ],
518
+ "title": "Reason"
519
+ }
520
+ },
521
+ "required": [
522
+ "kept",
523
+ "map50Fp16",
524
+ "map50Int8",
525
+ "reason"
526
+ ],
527
+ "title": "Int8Block",
528
+ "type": "object"
529
+ },
530
+ "TrainerBlock": {
531
+ "additionalProperties": false,
532
+ "properties": {
533
+ "revision": {
534
+ "title": "Revision",
535
+ "type": "string"
536
+ },
537
+ "version": {
538
+ "title": "Version",
539
+ "type": "string"
540
+ }
541
+ },
542
+ "required": [
543
+ "version",
544
+ "revision"
545
+ ],
546
+ "title": "TrainerBlock",
547
+ "type": "object"
548
+ },
549
+ "TrainingBlock": {
550
+ "additionalProperties": false,
551
+ "properties": {
552
+ "bestEpoch": {
553
+ "title": "Bestepoch",
554
+ "type": "integer"
555
+ },
556
+ "bestMap50": {
557
+ "title": "Bestmap50",
558
+ "type": "number"
559
+ },
560
+ "bestMap50_95": {
561
+ "title": "Bestmap50 95",
562
+ "type": "number"
563
+ },
564
+ "epochsRun": {
565
+ "title": "Epochsrun",
566
+ "type": "integer"
567
+ },
568
+ "seconds": {
569
+ "title": "Seconds",
570
+ "type": "number"
571
+ }
572
+ },
573
+ "required": [
574
+ "epochsRun",
575
+ "bestEpoch",
576
+ "bestMap50",
577
+ "bestMap50_95",
578
+ "seconds"
579
+ ],
580
+ "title": "TrainingBlock",
581
+ "type": "object"
582
+ }
583
+ },
584
+ "$id": "camstack.training-result/v1",
585
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
586
+ "additionalProperties": false,
587
+ "description": "A succeeded result carries every block; a failed one the error and whatever was known.",
588
+ "properties": {
589
+ "bundle": {
590
+ "anyOf": [
591
+ {
592
+ "$ref": "#/$defs/Bundle"
593
+ },
594
+ {
595
+ "type": "null"
596
+ }
597
+ ],
598
+ "default": null
599
+ },
600
+ "catalogDraft": {
601
+ "anyOf": [
602
+ {
603
+ "$ref": "#/$defs/CatalogDraft"
604
+ },
605
+ {
606
+ "type": "null"
607
+ }
608
+ ],
609
+ "default": null
610
+ },
611
+ "dataset": {
612
+ "anyOf": [
613
+ {
614
+ "$ref": "#/$defs/DatasetBlock"
615
+ },
616
+ {
617
+ "type": "null"
618
+ }
619
+ ],
620
+ "default": null
621
+ },
622
+ "error": {
623
+ "anyOf": [
624
+ {
625
+ "$ref": "#/$defs/ErrorBlock"
626
+ },
627
+ {
628
+ "type": "null"
629
+ }
630
+ ]
631
+ },
632
+ "evaluation": {
633
+ "anyOf": [
634
+ {
635
+ "$ref": "#/$defs/EvaluationBlock"
636
+ },
637
+ {
638
+ "type": "null"
639
+ }
640
+ ],
641
+ "default": null
642
+ },
643
+ "exportsDropped": {
644
+ "anyOf": [
645
+ {
646
+ "items": {
647
+ "$ref": "#/$defs/Dropped"
648
+ },
649
+ "type": "array"
650
+ },
651
+ {
652
+ "type": "null"
653
+ }
654
+ ],
655
+ "default": null,
656
+ "title": "Exportsdropped"
657
+ },
658
+ "files": {
659
+ "anyOf": [
660
+ {
661
+ "items": {
662
+ "$ref": "#/$defs/FileRow"
663
+ },
664
+ "type": "array"
665
+ },
666
+ {
667
+ "type": "null"
668
+ }
669
+ ],
670
+ "default": null,
671
+ "title": "Files"
672
+ },
673
+ "floors": {
674
+ "anyOf": [
675
+ {
676
+ "$ref": "#/$defs/FloorsBlock"
677
+ },
678
+ {
679
+ "type": "null"
680
+ }
681
+ ],
682
+ "default": null
683
+ },
684
+ "int8": {
685
+ "anyOf": [
686
+ {
687
+ "$ref": "#/$defs/Int8Block"
688
+ },
689
+ {
690
+ "type": "null"
691
+ }
692
+ ],
693
+ "default": null
694
+ },
695
+ "jobKey": {
696
+ "title": "Jobkey",
697
+ "type": "string"
698
+ },
699
+ "licence": {
700
+ "title": "Licence",
701
+ "type": "string"
702
+ },
703
+ "schema": {
704
+ "const": "camstack.training-result/v1",
705
+ "title": "Schema",
706
+ "type": "string"
707
+ },
708
+ "status": {
709
+ "enum": [
710
+ "succeeded",
711
+ "failed"
712
+ ],
713
+ "title": "Status",
714
+ "type": "string"
715
+ },
716
+ "toolchain": {
717
+ "additionalProperties": {
718
+ "anyOf": [
719
+ {
720
+ "type": "string"
721
+ },
722
+ {
723
+ "type": "null"
724
+ }
725
+ ]
726
+ },
727
+ "title": "Toolchain",
728
+ "type": "object"
729
+ },
730
+ "trainer": {
731
+ "$ref": "#/$defs/TrainerBlock"
732
+ },
733
+ "training": {
734
+ "anyOf": [
735
+ {
736
+ "$ref": "#/$defs/TrainingBlock"
737
+ },
738
+ {
739
+ "type": "null"
740
+ }
741
+ ],
742
+ "default": null
743
+ }
744
+ },
745
+ "required": [
746
+ "schema",
747
+ "jobKey",
748
+ "status",
749
+ "error",
750
+ "trainer",
751
+ "toolchain",
752
+ "licence"
753
+ ],
754
+ "title": "TrainingResult",
755
+ "type": "object"
756
+ }