Instructions to use camstack/camstack-trainer with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- ultralytics
How to use camstack/camstack-trainer with ultralytics:
from huggingface_hub import hf_hub_download from ultralytics import YOLO # pick the weights file from this repo's "Files and versions" tab weights = hf_hub_download("camstack/camstack-trainer", "<weights>.pt") model = YOLO(weights) source = 'http://images.cocodataset.org/val2017/000000039769.jpg' model.predict(source=source, save=True) - Notebooks
- Google Colab
- Kaggle
camstack-train 0.1.0: YOLOv9 root detector fine-tune (job.json in, result.json + bundle.tar out)
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- Dockerfile +23 -0
- LICENSE +666 -0
- README.md +96 -0
- camstack_train/__init__.py +9 -0
- camstack_train/__main__.py +7 -0
- camstack_train/_steps.py +101 -0
- camstack_train/_vendor/__init__.py +31 -0
- camstack_train/_vendor/camstack_eval/__init__.py +6 -0
- camstack_train/_vendor/camstack_eval/bootstrap.py +107 -0
- camstack_train/_vendor/camstack_eval/classes.py +40 -0
- camstack_train/_vendor/camstack_eval/compare.py +102 -0
- camstack_train/_vendor/camstack_eval/dataset.py +181 -0
- camstack_train/_vendor/camstack_eval/floors.py +79 -0
- camstack_train/_vendor/camstack_eval/metrics.py +273 -0
- camstack_train/_vendor/camstack_eval/pipeline.py +170 -0
- camstack_train/_vendor/camstack_eval/pseudo_gt.py +68 -0
- camstack_train/_vendor/pipeline_python/inference_pool.py +0 -0
- camstack_train/_vendor/pipeline_python/postprocessors/__init__.py +38 -0
- camstack_train/_vendor/pipeline_python/postprocessors/arcface.py +31 -0
- camstack_train/_vendor/pipeline_python/postprocessors/clip.py +35 -0
- camstack_train/_vendor/pipeline_python/postprocessors/ctc.py +406 -0
- camstack_train/_vendor/pipeline_python/postprocessors/plate_slots.py +180 -0
- camstack_train/_vendor/pipeline_python/postprocessors/rfdetr.py +161 -0
- camstack_train/_vendor/pipeline_python/postprocessors/saliency.py +90 -0
- camstack_train/_vendor/pipeline_python/postprocessors/scrfd.py +235 -0
- camstack_train/_vendor/pipeline_python/postprocessors/softmax.py +56 -0
- camstack_train/_vendor/pipeline_python/postprocessors/ssd.py +184 -0
- camstack_train/_vendor/pipeline_python/postprocessors/yamnet.py +41 -0
- camstack_train/_vendor/pipeline_python/postprocessors/yolo.py +364 -0
- camstack_train/_vendor/pipeline_python/postprocessors/yolo_seg.py +173 -0
- camstack_train/_vendor/pipeline_python/postprocessors/yolo_split_dfl.py +255 -0
- camstack_train/_vendor/pipeline_python/postprocessors/yolonas.py +67 -0
- camstack_train/_vendor/pipeline_python/postprocessors/yunet.py +275 -0
- camstack_train/cli.py +70 -0
- camstack_train/dataset.py +228 -0
- camstack_train/errors.py +34 -0
- camstack_train/evaluate.py +117 -0
- camstack_train/events.py +87 -0
- camstack_train/export.py +142 -0
- camstack_train/floors.py +82 -0
- camstack_train/manifest.py +166 -0
- camstack_train/replay.py +102 -0
- camstack_train/result_model.py +138 -0
- camstack_train/run.py +167 -0
- camstack_train/spec.py +173 -0
- camstack_train/split.py +101 -0
- camstack_train/train_det.py +131 -0
- pyproject.toml +50 -0
- schemas/job.schema.json +380 -0
- 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 |
+
}
|