learning-loop-node 0.27.2__tar.gz → 0.29.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/PKG-INFO +14 -1
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/README.md +13 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/data_classes/__init__.py +4 -3
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/data_classes/image_metadata.py +13 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/data_classes/training.py +24 -1
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/data_exchanger.py +23 -1
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/detector/detector_node.py +60 -47
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/trainer/exceptions.py +19 -0
- learning_loop_node-0.29.0/learning_loop_node/trainer/hyperparameters.py +180 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/trainer/trainer_logic.py +0 -1
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/trainer/trainer_logic_generic.py +63 -5
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/trainer/trainer_node.py +8 -1
- learning_loop_node-0.29.0/learning_loop_node/trainer/training_results.py +187 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/trainer/validation_set.py +14 -2
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node.egg-info/PKG-INFO +14 -1
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node.egg-info/SOURCES.txt +2 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/pyproject.toml +1 -1
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/__init__.py +0 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/annotation/__init__.py +0 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/annotation/annotator_logic.py +0 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/annotation/annotator_node.py +0 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/data_classes/annotation_data.py +0 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/data_classes/annotations.py +0 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/data_classes/detections.py +0 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/data_classes/general.py +0 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/data_classes/socket_response.py +0 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/detector/__init__.py +0 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/detector/categories.py +0 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/detector/detector_logic.py +0 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/detector/exceptions.py +0 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/detector/geometry.py +0 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/detector/inbox_filter/__init__.py +0 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/detector/inbox_filter/cam_observation_history.py +0 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/detector/inbox_filter/relevance_filter.py +0 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/detector/outbox.py +0 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/detector/postprocess.py +0 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/detector/rest/__init__.py +0 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/detector/rest/about.py +0 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/detector/rest/backdoor_controls.py +0 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/detector/rest/detect.py +0 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/detector/rest/model_version_control.py +0 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/detector/rest/operation_mode.py +0 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/detector/rest/outbox_mode.py +0 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/detector/rest/upload.py +0 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/enums/__init__.py +0 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/enums/annotator.py +0 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/enums/detector.py +0 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/enums/general.py +0 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/enums/trainer.py +0 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/globals.py +0 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/helpers/__init__.py +0 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/helpers/background_tasks.py +0 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/helpers/entrypoint.py +0 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/helpers/environment_reader.py +0 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/helpers/gdrive_downloader.py +0 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/helpers/log_conf.py +0 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/helpers/misc.py +0 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/helpers/run.py +0 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/loop_communication.py +0 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/node.py +0 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/py.typed +0 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/rest.py +0 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/trainer/__init__.py +0 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/trainer/batch_size.py +0 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/trainer/cuda.py +0 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/trainer/downloader.py +0 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/trainer/executor.py +0 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/trainer/io_helpers.py +0 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/trainer/metrics.py +0 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/trainer/rest/__init__.py +0 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/trainer/rest/backdoor_controls.py +0 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/trainer/subprocess.py +0 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/trainer/test_executor.py +0 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/trainer/training_slot.py +0 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node.egg-info/dependency_links.txt +0 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node.egg-info/requires.txt +0 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node.egg-info/top_level.txt +0 -0
- {learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: learning_loop_node
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.29.0
|
|
4
4
|
Summary: Python Library for Nodes which connect to the Zauberzeug Learning Loop
|
|
5
5
|
Author-email: Zauberzeug GmbH <info@zauberzeug.com>
|
|
6
6
|
License: MIT
|
|
@@ -129,6 +129,8 @@ The `batch_detect` endpoint receives a dictionary, with the same entries as the
|
|
|
129
129
|
|
|
130
130
|
- `images`: List of image data dictionaries, each with the same structure as the `image` entry in the `detect` endpoint
|
|
131
131
|
|
|
132
|
+
The entries `camera_id`, `tags`, `source` and `creation_date` apply to every image of the batch. An image data dictionary may also carry its own: its `tags` are added to those of the batch, while its `camera_id`, `source` and `creation_date` replace those of the batch. This way a batch can hold images of several cameras, each filtered and uploaded as its own camera's.
|
|
133
|
+
|
|
132
134
|
Sending a camera's JPEG as it is (`format: 'jpeg'`) saves the bandwidth of a raw pixel array and spares the detector a re-encode for the upload.
|
|
133
135
|
|
|
134
136
|
Example code can be found [in the rosys implementation](https://github.com/zauberzeug/rosys/blob/main/rosys/vision/detector_hardware.py).
|
|
@@ -256,6 +258,17 @@ Trainers fetch the images and anntoations from the Learning Loop to train new mo
|
|
|
256
258
|
|
|
257
259
|
- if the command line tool "jpeginfo" is installed, the downloader will drop corrupted images automatically
|
|
258
260
|
|
|
261
|
+
### Training results
|
|
262
|
+
|
|
263
|
+
Together with its model, a trainer uploads the results of the training to the Learning Loop, e.g. the
|
|
264
|
+
metrics per epoch, the evaluation curves and the confusion matrix. The trainer decides which files
|
|
265
|
+
they are, grouped into the parts `core`, `examples` and `logs`; a trainer that names none uploads
|
|
266
|
+
only its model. `core` is always uploaded, the examples and the raw training log only if the
|
|
267
|
+
training's `upload_examples` and `upload_logs` hyperparameters are true (both default to false).
|
|
268
|
+
The results are sent right after the model and never stop the training: results the Learning Loop
|
|
269
|
+
does not store, e.g. beyond the 5 GiB it keeps per training, are logged and skipped, and a part
|
|
270
|
+
that gets no answer or a server error is sent up to three times.
|
|
271
|
+
|
|
259
272
|
### Several trainers on one GPU
|
|
260
273
|
|
|
261
274
|
Trainers that share a GPU (say, one per model architecture on the same machine) coordinate through
|
|
@@ -96,6 +96,8 @@ The `batch_detect` endpoint receives a dictionary, with the same entries as the
|
|
|
96
96
|
|
|
97
97
|
- `images`: List of image data dictionaries, each with the same structure as the `image` entry in the `detect` endpoint
|
|
98
98
|
|
|
99
|
+
The entries `camera_id`, `tags`, `source` and `creation_date` apply to every image of the batch. An image data dictionary may also carry its own: its `tags` are added to those of the batch, while its `camera_id`, `source` and `creation_date` replace those of the batch. This way a batch can hold images of several cameras, each filtered and uploaded as its own camera's.
|
|
100
|
+
|
|
99
101
|
Sending a camera's JPEG as it is (`format: 'jpeg'`) saves the bandwidth of a raw pixel array and spares the detector a re-encode for the upload.
|
|
100
102
|
|
|
101
103
|
Example code can be found [in the rosys implementation](https://github.com/zauberzeug/rosys/blob/main/rosys/vision/detector_hardware.py).
|
|
@@ -223,6 +225,17 @@ Trainers fetch the images and anntoations from the Learning Loop to train new mo
|
|
|
223
225
|
|
|
224
226
|
- if the command line tool "jpeginfo" is installed, the downloader will drop corrupted images automatically
|
|
225
227
|
|
|
228
|
+
### Training results
|
|
229
|
+
|
|
230
|
+
Together with its model, a trainer uploads the results of the training to the Learning Loop, e.g. the
|
|
231
|
+
metrics per epoch, the evaluation curves and the confusion matrix. The trainer decides which files
|
|
232
|
+
they are, grouped into the parts `core`, `examples` and `logs`; a trainer that names none uploads
|
|
233
|
+
only its model. `core` is always uploaded, the examples and the raw training log only if the
|
|
234
|
+
training's `upload_examples` and `upload_logs` hyperparameters are true (both default to false).
|
|
235
|
+
The results are sent right after the model and never stop the training: results the Learning Loop
|
|
236
|
+
does not store, e.g. beyond the 5 GiB it keeps per training, are logged and skipped, and a part
|
|
237
|
+
that gets no answer or a server error is sent up to three times.
|
|
238
|
+
|
|
226
239
|
### Several trainers on one GPU
|
|
227
240
|
|
|
228
241
|
Trainers that share a GPU (say, one per model architecture on the same machine) coordinate through
|
{learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/data_classes/__init__.py
RENAMED
|
@@ -26,10 +26,11 @@ from .general import (
|
|
|
26
26
|
NodeState,
|
|
27
27
|
NodeStatus,
|
|
28
28
|
)
|
|
29
|
-
from .image_metadata import ImageMetadata, ImagesMetadata
|
|
29
|
+
from .image_metadata import CaptureMetadata, ImageMetadata, ImagesMetadata
|
|
30
30
|
from .socket_response import SocketResponse
|
|
31
31
|
from .training import (
|
|
32
32
|
Errors,
|
|
33
|
+
Hyperparameter,
|
|
33
34
|
PretrainedModel,
|
|
34
35
|
Training,
|
|
35
36
|
TrainingError,
|
|
@@ -43,8 +44,8 @@ __all__ = [
|
|
|
43
44
|
'BoxDetection', 'ClassificationDetection', 'ImageMetadata', 'Observation', 'Point', 'PointDetection',
|
|
44
45
|
'SegmentationDetection', 'Shape', 'Detections',
|
|
45
46
|
'AnnotationNodeStatus', 'Category', 'Context', 'DetectorStatus', 'ErrorConfiguration',
|
|
46
|
-
'ModelInformation', 'NodeState', 'NodeStatus', 'ModelVersionResponse', 'ImagesMetadata',
|
|
47
|
+
'ModelInformation', 'NodeState', 'NodeStatus', 'ModelVersionResponse', 'ImagesMetadata', 'CaptureMetadata',
|
|
47
48
|
'SocketResponse',
|
|
48
|
-
'Errors', 'PretrainedModel', 'Training',
|
|
49
|
+
'Errors', 'Hyperparameter', 'PretrainedModel', 'Training',
|
|
49
50
|
'TrainingError', 'TrainingOut', 'TrainingStateData', 'TrainingStatus',
|
|
50
51
|
]
|
|
@@ -56,3 +56,16 @@ class ImageMetadata():
|
|
|
56
56
|
@dataclass(**KWONLY_SLOTS)
|
|
57
57
|
class ImagesMetadata():
|
|
58
58
|
items: List[ImageMetadata] = field(default_factory=list, metadata={'description': 'List of image metadata'})
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(**KWONLY_SLOTS)
|
|
62
|
+
class CaptureMetadata:
|
|
63
|
+
"""Where an image comes from and how it is tagged."""
|
|
64
|
+
camera_id: str | None = field(default=None, metadata={
|
|
65
|
+
'description': 'Camera the image was taken with; selects the history of the relevance filter'})
|
|
66
|
+
tags: list[str] = field(default_factory=list, metadata={
|
|
67
|
+
'description': 'Tags of the image'})
|
|
68
|
+
source: str | None = field(default=None, metadata={
|
|
69
|
+
'description': 'Source of the image'})
|
|
70
|
+
creation_date: str | None = field(default=None, metadata={
|
|
71
|
+
'description': 'Creation date of the image'})
|
{learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/data_classes/training.py
RENAMED
|
@@ -3,7 +3,7 @@ import sys
|
|
|
3
3
|
import time
|
|
4
4
|
from dataclasses import dataclass, field
|
|
5
5
|
from pathlib import Path
|
|
6
|
-
from typing import Any, Dict, List, Optional
|
|
6
|
+
from typing import Any, Dict, List, Literal, Optional
|
|
7
7
|
from uuid import uuid4
|
|
8
8
|
|
|
9
9
|
from ..enums import TrainerState
|
|
@@ -22,6 +22,28 @@ class PretrainedModel():
|
|
|
22
22
|
description: str
|
|
23
23
|
|
|
24
24
|
|
|
25
|
+
HyperparameterType = Literal['int', 'float', 'bool', 'str', 'pairs']
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(**KWONLY_SLOTS)
|
|
29
|
+
class Hyperparameter:
|
|
30
|
+
"""One hyperparameter a trainer accepts, reported to the loop so it can offer it in the project settings.
|
|
31
|
+
|
|
32
|
+
Plain data only, so it reaches the loop as it is. ``minimum`` and ``maximum`` apply to ``int`` and ``float``,
|
|
33
|
+
``choices`` to ``str``. A ``pairs`` value is text of the form ``"key:value,..."``, which ``parse_pairs`` reads.
|
|
34
|
+
``notes`` help whoever sets the value. ``expert`` ones are offered behind an expansion.
|
|
35
|
+
Whether a value can be changed when a training is started is the project's choice, not the trainer's.
|
|
36
|
+
"""
|
|
37
|
+
name: str
|
|
38
|
+
type: HyperparameterType
|
|
39
|
+
default: Any
|
|
40
|
+
minimum: float | None = None
|
|
41
|
+
maximum: float | None = None
|
|
42
|
+
choices: list[str] | None = None
|
|
43
|
+
notes: str = ''
|
|
44
|
+
expert: bool = False
|
|
45
|
+
|
|
46
|
+
|
|
25
47
|
@dataclass(**KWONLY_SLOTS)
|
|
26
48
|
class TrainingStatus():
|
|
27
49
|
id: str # NOTE this must not be changed, but tests wont detect a change -> update tests!
|
|
@@ -33,6 +55,7 @@ class TrainingStatus():
|
|
|
33
55
|
progress: Optional[float]
|
|
34
56
|
|
|
35
57
|
pretrained_models: List[PretrainedModel] = field(default_factory=list)
|
|
58
|
+
hyperparameters: list[Hyperparameter] = field(default_factory=list)
|
|
36
59
|
architecture: Optional[str] = None
|
|
37
60
|
context: Optional[Context] = None
|
|
38
61
|
|
{learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/data_exchanger.py
RENAMED
|
@@ -14,7 +14,12 @@ import aiofiles # type: ignore
|
|
|
14
14
|
from .data_classes import Context
|
|
15
15
|
from .helpers.misc import create_resource_paths, create_task, is_valid_image, is_valid_uuid4
|
|
16
16
|
from .loop_communication import LoopCommunicator
|
|
17
|
-
from .trainer.exceptions import
|
|
17
|
+
from .trainer.exceptions import (
|
|
18
|
+
CriticalError,
|
|
19
|
+
ModelUploadFailedError,
|
|
20
|
+
TrainingResultsNotAnsweredError,
|
|
21
|
+
TrainingResultsUploadFailedError,
|
|
22
|
+
)
|
|
18
23
|
|
|
19
24
|
logger = logging.getLogger(__name__)
|
|
20
25
|
|
|
@@ -207,6 +212,23 @@ class DataExchanger():
|
|
|
207
212
|
training_number, mformat, uploaded_model)
|
|
208
213
|
return uploaded_model['id']
|
|
209
214
|
|
|
215
|
+
async def upload_training_results(self, context: Context, training_number: int, part: str, zip_path: str) -> None:
|
|
216
|
+
"""Store one part of a training's results beside its latest model; a part sent again replaces the stored one.
|
|
217
|
+
|
|
218
|
+
:raise TrainingResultsUploadFailedError: If the loop answered with a client error other than 408.
|
|
219
|
+
:raise TrainingResultsNotAnsweredError: If the loop answered 408 or a server error.
|
|
220
|
+
"""
|
|
221
|
+
path = (f'/{context.organization}/projects/{context.project}/trainings/{training_number}'
|
|
222
|
+
f'/training_results/{part}')
|
|
223
|
+
response = await self.loop_communicator.put(path, files=[zip_path], timeout=60*10)
|
|
224
|
+
if response.status_code == HTTPStatus.REQUEST_TIMEOUT or response.status_code >= 500:
|
|
225
|
+
raise TrainingResultsNotAnsweredError(f'The loop did not answer the upload of the {part} results of '
|
|
226
|
+
f'training {training_number}: {response.status_code} {response.text}')
|
|
227
|
+
if response.status_code != HTTPStatus.OK:
|
|
228
|
+
raise TrainingResultsUploadFailedError(f'The loop did not store the {part} results of training '
|
|
229
|
+
f'{training_number}: {response.status_code} {response.text}')
|
|
230
|
+
logger.info('Uploaded the %s results of training %s', part, training_number)
|
|
231
|
+
|
|
210
232
|
|
|
211
233
|
def _rejects_upload(status_code: int) -> bool:
|
|
212
234
|
"""A client error other than 408 and 429 answers the same however often the upload is sent."""
|
{learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/detector/detector_node.py
RENAMED
|
@@ -19,6 +19,7 @@ from socketio import AsyncClient
|
|
|
19
19
|
|
|
20
20
|
from ..data_classes import (
|
|
21
21
|
AboutResponse,
|
|
22
|
+
CaptureMetadata,
|
|
22
23
|
Context,
|
|
23
24
|
DetectorStatus,
|
|
24
25
|
ImageMetadata,
|
|
@@ -272,10 +273,16 @@ class DetectorNode(Node):
|
|
|
272
273
|
|
|
273
274
|
Data dict follows the schema of the detect endpoint,
|
|
274
275
|
but 'images' is a list of image dicts.
|
|
276
|
+
Each image dict may also carry its own:
|
|
277
|
+
- camera_id: replaces the camera_id of the batch
|
|
278
|
+
- tags: added to the tags of the batch
|
|
279
|
+
- source: replaces the source of the batch
|
|
280
|
+
- creation_date: replaces the creation_date of the batch
|
|
275
281
|
"""
|
|
276
282
|
try:
|
|
277
283
|
images_data = data['images']
|
|
278
284
|
images = [image_from_dict(image) for image in images_data]
|
|
285
|
+
capture_metadata = [_capture_metadata(data, image) for image in images_data]
|
|
279
286
|
except Exception:
|
|
280
287
|
self.log.exception('could not parse images from socketio')
|
|
281
288
|
return {'error': 'could not parse images from data'}
|
|
@@ -283,11 +290,8 @@ class DetectorNode(Node):
|
|
|
283
290
|
try:
|
|
284
291
|
det = await self.get_batch_detections(
|
|
285
292
|
images=images,
|
|
286
|
-
|
|
287
|
-
camera_id=data.get('camera_id', None),
|
|
288
|
-
source=data.get('source', None),
|
|
293
|
+
capture_metadata=capture_metadata,
|
|
289
294
|
autoupload=data.get('autoupload', 'filtered'),
|
|
290
|
-
creation_date=data.get('creation_date', None),
|
|
291
295
|
)
|
|
292
296
|
return jsonable_encoder(asdict(det))
|
|
293
297
|
except DetectorUnavailableError as e:
|
|
@@ -638,35 +642,17 @@ class DetectorNode(Node):
|
|
|
638
642
|
array = await _pixel_array(image)
|
|
639
643
|
async with self.detection_lock:
|
|
640
644
|
detector = _unwrap_detector(self._detector)
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
metadata.tags.extend(tags)
|
|
644
|
-
metadata.source = source
|
|
645
|
-
metadata.created = creation_date
|
|
646
|
-
|
|
647
|
-
fix_shape_detections(metadata)
|
|
648
|
-
n_bo, n_cl = len(metadata.box_detections), len(metadata.classification_detections)
|
|
649
|
-
n_po, n_se = len(metadata.point_detections), len(metadata.segmentation_detections)
|
|
650
|
-
self.log.debug('Detected: %d boxes, %d points, %d segs, %d classes', n_bo, n_po, n_se, n_cl)
|
|
645
|
+
detections = await run.io_bound(detector.logic.evaluate, array)
|
|
651
646
|
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
background_tasks.create(self.outbox.save(image, metadata))
|
|
656
|
-
elif autoupload == 'disabled':
|
|
657
|
-
pass
|
|
658
|
-
else:
|
|
659
|
-
self.log.error('unknown autoupload value %s', autoupload)
|
|
660
|
-
return metadata
|
|
647
|
+
capture = CaptureMetadata(camera_id=camera_id, tags=tags, source=source, creation_date=creation_date)
|
|
648
|
+
self._finish_detection(detections, image, capture, autoupload)
|
|
649
|
+
return detections
|
|
661
650
|
|
|
662
651
|
async def get_batch_detections(self,
|
|
663
652
|
images: Sequence[ImageData],
|
|
664
|
-
|
|
653
|
+
capture_metadata: Sequence[CaptureMetadata],
|
|
665
654
|
*,
|
|
666
|
-
|
|
667
|
-
source: Optional[str] = None,
|
|
668
|
-
autoupload: str = 'filtered',
|
|
669
|
-
creation_date: Optional[str] = None) -> ImagesMetadata:
|
|
655
|
+
autoupload: str = 'filtered') -> ImagesMetadata:
|
|
670
656
|
"""
|
|
671
657
|
Processing function for the detector node when a batch inference is requested via SocketIO.
|
|
672
658
|
|
|
@@ -674,35 +660,45 @@ class DetectorNode(Node):
|
|
|
674
660
|
cares about uploading to the loop and returns the detections as a list of ImageMetadata.
|
|
675
661
|
Each image is a pixel array or JPEG bytes; JPEG bytes are decoded for the inference
|
|
676
662
|
and stored as they are if the image is uploaded.
|
|
663
|
+
`capture_metadata` holds the camera, tags, source and creation date of each image.
|
|
677
664
|
Raises exception if no model is loaded.
|
|
678
665
|
"""
|
|
666
|
+
if len(capture_metadata) != len(images):
|
|
667
|
+
raise ValueError(f'got {len(capture_metadata)} capture metadata for {len(images)} images')
|
|
679
668
|
_unwrap_detector(self._detector) # fails fast, before the decode
|
|
680
669
|
arrays = await asyncio.gather(*(_pixel_array(image) for image in images))
|
|
681
670
|
async with self.detection_lock:
|
|
682
671
|
detector = _unwrap_detector(self._detector)
|
|
683
672
|
all_detections = await run.io_bound(detector.logic.batch_evaluate, arrays)
|
|
684
673
|
|
|
685
|
-
for
|
|
686
|
-
|
|
687
|
-
metadata.source = source
|
|
688
|
-
metadata.created = creation_date
|
|
689
|
-
|
|
690
|
-
for detections, image in zip(all_detections.items, images):
|
|
691
|
-
fix_shape_detections(detections)
|
|
692
|
-
n_bo, n_cl = len(detections.box_detections), len(detections.classification_detections)
|
|
693
|
-
n_po, n_se = len(detections.point_detections), len(detections.segmentation_detections)
|
|
694
|
-
self.log.debug('Detected: %d boxes, %d points, %d segs, %d classes', n_bo, n_po, n_se, n_cl)
|
|
695
|
-
|
|
696
|
-
if autoupload == 'filtered':
|
|
697
|
-
background_tasks.create(self.relevance_filter.may_upload_detections(detections, camera_id, image))
|
|
698
|
-
elif autoupload == 'all':
|
|
699
|
-
background_tasks.create(self.outbox.save(image, detections))
|
|
700
|
-
elif autoupload == 'disabled':
|
|
701
|
-
pass
|
|
702
|
-
else:
|
|
703
|
-
self.log.error('unknown autoupload value %s', autoupload)
|
|
674
|
+
for detections, image, capture in zip(all_detections.items, images, capture_metadata):
|
|
675
|
+
self._finish_detection(detections, image, capture, autoupload)
|
|
704
676
|
return all_detections
|
|
705
677
|
|
|
678
|
+
def _finish_detection(self,
|
|
679
|
+
detections: ImageMetadata,
|
|
680
|
+
image: ImageData,
|
|
681
|
+
capture: CaptureMetadata,
|
|
682
|
+
autoupload: str) -> None:
|
|
683
|
+
detections.tags.extend(capture.tags)
|
|
684
|
+
detections.source = capture.source
|
|
685
|
+
detections.created = capture.creation_date
|
|
686
|
+
|
|
687
|
+
fix_shape_detections(detections)
|
|
688
|
+
n_bo, n_cl = len(detections.box_detections), len(detections.classification_detections)
|
|
689
|
+
n_po, n_se = len(detections.point_detections), len(detections.segmentation_detections)
|
|
690
|
+
self.log.debug('Detected: %d boxes, %d points, %d segs, %d classes', n_bo, n_po, n_se, n_cl)
|
|
691
|
+
|
|
692
|
+
if autoupload == 'filtered':
|
|
693
|
+
background_tasks.create(
|
|
694
|
+
self.relevance_filter.may_upload_detections(detections, capture.camera_id, image))
|
|
695
|
+
elif autoupload == 'all':
|
|
696
|
+
background_tasks.create(self.outbox.save(image, detections))
|
|
697
|
+
elif autoupload == 'disabled':
|
|
698
|
+
pass
|
|
699
|
+
else:
|
|
700
|
+
self.log.error('unknown autoupload value %s', autoupload)
|
|
701
|
+
|
|
706
702
|
async def upload_images(
|
|
707
703
|
self, *,
|
|
708
704
|
images: Sequence[ImageData],
|
|
@@ -785,6 +781,23 @@ async def _pixel_array(image: ImageData) -> np.ndarray:
|
|
|
785
781
|
return await run.io_bound(jpg_bytes_to_numpy_array, image)
|
|
786
782
|
|
|
787
783
|
|
|
784
|
+
def _capture_metadata(batch_data: dict, image_data: dict) -> CaptureMetadata:
|
|
785
|
+
"""The values of one image dict of a batch: its own where it sets them, the batch's otherwise, and both tags."""
|
|
786
|
+
image_tags = image_data.get('tags') or []
|
|
787
|
+
if not isinstance(image_tags, list):
|
|
788
|
+
raise ValueError(f'tags of an image must be a list, got {type(image_tags).__name__}')
|
|
789
|
+
return CaptureMetadata(camera_id=_own_or_batch('camera_id', image_data, batch_data),
|
|
790
|
+
tags=[*batch_data.get('tags', []), *image_tags],
|
|
791
|
+
source=_own_or_batch('source', image_data, batch_data),
|
|
792
|
+
creation_date=_own_or_batch('creation_date', image_data, batch_data))
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def _own_or_batch(key: str, image_data: dict, batch_data: dict) -> str | None:
|
|
796
|
+
"""`None` counts as not set, so an image cannot clear a value of the batch."""
|
|
797
|
+
own = image_data.get(key)
|
|
798
|
+
return own if own is not None else batch_data.get(key)
|
|
799
|
+
|
|
800
|
+
|
|
788
801
|
def _unwrap_detector(state: '_DetectorState') -> _ActiveDetector:
|
|
789
802
|
"""Return the active detector or raise DetectorUnavailableError."""
|
|
790
803
|
match state:
|
{learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/trainer/exceptions.py
RENAMED
|
@@ -11,10 +11,29 @@ class ModelUploadFailedError(Exception):
|
|
|
11
11
|
"""
|
|
12
12
|
|
|
13
13
|
|
|
14
|
+
class TrainingResultsUploadFailedError(Exception):
|
|
15
|
+
"""Raised when the loop answered the upload of a part of a training's results with a client error other than 408.
|
|
16
|
+
The answer is final: sending the part again would get the same answer.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TrainingResultsNotAnsweredError(Exception):
|
|
21
|
+
"""Raised when the loop gave up waiting for a part of a training's results (408) or answered with a server error;
|
|
22
|
+
the part may be sent again.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
|
|
14
26
|
class TrainingRefusedError(Exception):
|
|
15
27
|
"""Raised when a trainer cannot take on a training it was asked to begin; nothing was set up."""
|
|
16
28
|
|
|
17
29
|
|
|
30
|
+
class HyperparameterMismatchError(Exception):
|
|
31
|
+
"""Raised when a training's hyperparameters do not fit the trainer's declaration; nothing was set up.
|
|
32
|
+
|
|
33
|
+
Unlike a refusal, the training would fail on any trainer of the same declaration.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
|
|
18
37
|
class TrainingSlotTakenError(TrainingRefusedError):
|
|
19
38
|
"""Raised when a sibling node holds the training slot."""
|
|
20
39
|
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Resolving the hyperparameters a training was sent against the ones its trainer declares.
|
|
2
|
+
|
|
3
|
+
The loop sends only what the project sets, plus what the job overrides. Everything else comes from
|
|
4
|
+
the trainer's declared defaults, so after :func:`resolve` a training holds the full set it runs
|
|
5
|
+
with, and that is what gets reported back to the loop.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import contextlib
|
|
9
|
+
import logging
|
|
10
|
+
import math
|
|
11
|
+
from collections.abc import Mapping, Sequence
|
|
12
|
+
from typing import Any, get_args
|
|
13
|
+
|
|
14
|
+
from ..data_classes import Hyperparameter
|
|
15
|
+
from ..data_classes.training import HyperparameterType
|
|
16
|
+
from .batch_size import REQUESTED_BATCH_SIZE
|
|
17
|
+
from .exceptions import CriticalError
|
|
18
|
+
from .validation_set import COMPUTE_VALIDATION_SET
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
LIBRARY_NAMES = (REQUESTED_BATCH_SIZE, COMPUTE_VALIDATION_SET)
|
|
23
|
+
"""Hyperparameters the library reads itself, so a trainer that does not declare them still uses them."""
|
|
24
|
+
|
|
25
|
+
BOOL_STRINGS = {'true': True, '1': True, 'yes': True, 'on': True,
|
|
26
|
+
'false': False, '0': False, 'no': False, 'off': False}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def resolve(declared: Sequence[Hyperparameter], received: Mapping[str, Any]) -> dict[str, Any]:
|
|
30
|
+
"""Coerce and check ``received`` against ``declared`` and fill in the declared defaults.
|
|
31
|
+
|
|
32
|
+
``declared`` is trusted to have passed :func:`check_declaration`. Names the trainer does not declare are kept
|
|
33
|
+
unchanged: old project configurations still carry them, and a resolved training carries the values its trainer
|
|
34
|
+
reported. An unset hyperparameter with a default of ``None`` is left out, so the trainer's own fallback applies.
|
|
35
|
+
Resolving twice gives the same result.
|
|
36
|
+
|
|
37
|
+
:raises CriticalError: If a value cannot be coerced to its type, is out of range or not one of its choices.
|
|
38
|
+
"""
|
|
39
|
+
resolved = dict(received)
|
|
40
|
+
for hyperparameter in declared:
|
|
41
|
+
value = received.get(hyperparameter.name)
|
|
42
|
+
if _is_unset(hyperparameter, value):
|
|
43
|
+
if hyperparameter.default is None:
|
|
44
|
+
resolved.pop(hyperparameter.name, None)
|
|
45
|
+
else:
|
|
46
|
+
resolved[hyperparameter.name] = _coerce(hyperparameter, hyperparameter.default)
|
|
47
|
+
else:
|
|
48
|
+
resolved[hyperparameter.name] = _check(hyperparameter, _coerce(hyperparameter, value))
|
|
49
|
+
return resolved
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def check_declaration(declared: Sequence[Hyperparameter]) -> None:
|
|
53
|
+
"""Check that ``declared`` is consistent, so that :func:`resolve` can rely on it.
|
|
54
|
+
|
|
55
|
+
A trainer's declaration does not change while it runs, so the node checks it once, when it is built. A default of
|
|
56
|
+
``None`` stands for "the trainer decides" and is not checked.
|
|
57
|
+
|
|
58
|
+
:raises ValueError: If a name is declared twice, a hyperparameter contradicts itself or its default does not fit.
|
|
59
|
+
"""
|
|
60
|
+
names = [h.name for h in declared]
|
|
61
|
+
if duplicates := sorted({name for name in names if names.count(name) > 1}):
|
|
62
|
+
raise ValueError(f'the trainer declares these hyperparameters twice: {", ".join(duplicates)}')
|
|
63
|
+
for hyperparameter in declared:
|
|
64
|
+
_check_one_declaration(hyperparameter)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def parse_pairs(text: str) -> list[tuple[str, str]]:
|
|
68
|
+
"""The ``key:value`` items of a ``pairs`` hyperparameter such as ``"uuid:uuid,uuid:uuid"``.
|
|
69
|
+
|
|
70
|
+
Empty items and the spaces around keys and values are skipped, so ``''`` holds no pairs.
|
|
71
|
+
|
|
72
|
+
:raises ValueError: If an item is not a key and a value separated by a single colon.
|
|
73
|
+
"""
|
|
74
|
+
pairs = []
|
|
75
|
+
for item in (i.strip() for i in text.split(',')):
|
|
76
|
+
if not item:
|
|
77
|
+
continue
|
|
78
|
+
key, _, value = (part.strip() for part in item.partition(':'))
|
|
79
|
+
if not key or not value or ':' in value:
|
|
80
|
+
raise ValueError(f'"{item}" is not of the form "key:value"')
|
|
81
|
+
pairs.append((key, value))
|
|
82
|
+
return pairs
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def warn_about_undeclared(declared: Sequence[Hyperparameter], received: Mapping[str, Any]) -> None:
|
|
86
|
+
"""Log the names in ``received`` that neither the trainer nor the library knows; they are not rejected."""
|
|
87
|
+
unknown = sorted(set(received) - {h.name for h in declared} - set(LIBRARY_NAMES))
|
|
88
|
+
if unknown:
|
|
89
|
+
logger.warning('these hyperparameters are not declared by this trainer: %s', ', '.join(unknown))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _check_one_declaration(hyperparameter: Hyperparameter) -> None:
|
|
93
|
+
def contradiction(reason: str) -> ValueError:
|
|
94
|
+
return ValueError(f"the trainer declares hyperparameter '{hyperparameter.name}' {reason}")
|
|
95
|
+
|
|
96
|
+
if hyperparameter.type not in get_args(HyperparameterType):
|
|
97
|
+
raise contradiction(f'with the unknown type {hyperparameter.type!r}')
|
|
98
|
+
if hyperparameter.type not in ('int', 'float') and (hyperparameter.minimum is not None or
|
|
99
|
+
hyperparameter.maximum is not None):
|
|
100
|
+
raise contradiction(f'as {hyperparameter.type} with a minimum or maximum')
|
|
101
|
+
if hyperparameter.type != 'str' and hyperparameter.choices:
|
|
102
|
+
raise contradiction(f'as {hyperparameter.type} with choices')
|
|
103
|
+
for bound in (hyperparameter.minimum, hyperparameter.maximum):
|
|
104
|
+
if bound is not None and (isinstance(bound, bool) or not isinstance(bound, (int, float))):
|
|
105
|
+
raise contradiction(f'with the bound {bound!r}, which is not a number')
|
|
106
|
+
if (hyperparameter.minimum is not None and hyperparameter.maximum is not None and
|
|
107
|
+
hyperparameter.minimum > hyperparameter.maximum):
|
|
108
|
+
raise contradiction('with a minimum above its maximum')
|
|
109
|
+
if hyperparameter.default is not None:
|
|
110
|
+
try:
|
|
111
|
+
_check(hyperparameter, _coerce(hyperparameter, hyperparameter.default))
|
|
112
|
+
except CriticalError as e:
|
|
113
|
+
raise contradiction(f'with a default that does not fit: {e}') from None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _is_unset(hyperparameter: Hyperparameter, value: Any) -> bool:
|
|
117
|
+
"""``None``, or the empty value the loop sends for a field left blank, unless it fits as a value of its own."""
|
|
118
|
+
if value is None:
|
|
119
|
+
return True
|
|
120
|
+
if value != '':
|
|
121
|
+
return False
|
|
122
|
+
try:
|
|
123
|
+
_check(hyperparameter, _coerce(hyperparameter, value))
|
|
124
|
+
except CriticalError:
|
|
125
|
+
return True
|
|
126
|
+
return False
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _invalid(hyperparameter: Hyperparameter, requirement: str, value: Any) -> CriticalError:
|
|
130
|
+
return CriticalError(f"hyperparameter '{hyperparameter.name}' {requirement}, got {value!r}")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _coerce(hyperparameter: Hyperparameter, value: Any) -> Any:
|
|
134
|
+
if hyperparameter.type == 'pairs':
|
|
135
|
+
if not isinstance(value, str):
|
|
136
|
+
raise _invalid(hyperparameter, 'must be text of key:value pairs', value)
|
|
137
|
+
try:
|
|
138
|
+
return ','.join(f'{key}:{item}' for key, item in parse_pairs(value))
|
|
139
|
+
except ValueError as e:
|
|
140
|
+
raise CriticalError(f"hyperparameter '{hyperparameter.name}': {e}") from None
|
|
141
|
+
if hyperparameter.type == 'str':
|
|
142
|
+
if isinstance(value, bool) or not isinstance(value, (str, int, float)):
|
|
143
|
+
raise _invalid(hyperparameter, 'must be text', value)
|
|
144
|
+
return str(value)
|
|
145
|
+
if hyperparameter.type == 'bool':
|
|
146
|
+
if isinstance(value, bool):
|
|
147
|
+
return value
|
|
148
|
+
if isinstance(value, str) and (word := value.strip().lower()) in BOOL_STRINGS:
|
|
149
|
+
return BOOL_STRINGS[word]
|
|
150
|
+
with contextlib.suppress(TypeError, ValueError):
|
|
151
|
+
number = float(value)
|
|
152
|
+
if math.isfinite(number):
|
|
153
|
+
return number != 0
|
|
154
|
+
raise _invalid(hyperparameter, 'must be true or false', value)
|
|
155
|
+
if isinstance(value, bool):
|
|
156
|
+
raise _invalid(hyperparameter, 'must be a number', value)
|
|
157
|
+
if hyperparameter.type == 'int' and isinstance(value, (int, str)):
|
|
158
|
+
with contextlib.suppress(ValueError):
|
|
159
|
+
return int(value)
|
|
160
|
+
try:
|
|
161
|
+
number = float(value)
|
|
162
|
+
except (TypeError, ValueError):
|
|
163
|
+
raise _invalid(hyperparameter, 'must be a number', value) from None
|
|
164
|
+
if not math.isfinite(number):
|
|
165
|
+
raise _invalid(hyperparameter, 'must be finite', value)
|
|
166
|
+
if hyperparameter.type == 'float':
|
|
167
|
+
return number
|
|
168
|
+
if not number.is_integer():
|
|
169
|
+
raise _invalid(hyperparameter, 'must be a whole number', value)
|
|
170
|
+
return int(number)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _check(hyperparameter: Hyperparameter, value: Any) -> Any:
|
|
174
|
+
if hyperparameter.minimum is not None and value < hyperparameter.minimum:
|
|
175
|
+
raise _invalid(hyperparameter, f'must be at least {hyperparameter.minimum}', value)
|
|
176
|
+
if hyperparameter.maximum is not None and value > hyperparameter.maximum:
|
|
177
|
+
raise _invalid(hyperparameter, f'must be at most {hyperparameter.maximum}', value)
|
|
178
|
+
if hyperparameter.choices and value not in hyperparameter.choices:
|
|
179
|
+
raise _invalid(hyperparameter, f"must be one of {', '.join(hyperparameter.choices)}", value)
|
|
180
|
+
return value
|
{learning_loop_node-0.27.2 → learning_loop_node-0.29.0}/learning_loop_node/trainer/trainer_logic.py
RENAMED
|
@@ -26,7 +26,6 @@ class TrainerLogic(TrainerLogicGeneric):
|
|
|
26
26
|
self._detection_progress: Optional[float] = None
|
|
27
27
|
self._executor: Optional[Executor] = None
|
|
28
28
|
self._process_started: bool = False
|
|
29
|
-
self._stop_requested: bool = False
|
|
30
29
|
self.start_training_task: Optional[Coroutine] = None
|
|
31
30
|
self.inference_batch_size = self._environment_vars.inference_batch_size
|
|
32
31
|
|