learning-loop-node 0.25.0__py3-none-any.whl → 0.27.0__py3-none-any.whl
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/data_classes/general.py +2 -0
- learning_loop_node/data_classes/training.py +11 -5
- learning_loop_node/data_exchanger.py +36 -13
- learning_loop_node/detector/detector_node.py +37 -18
- learning_loop_node/detector/inbox_filter/relevance_filter.py +2 -3
- learning_loop_node/detector/outbox.py +5 -15
- learning_loop_node/detector/rest/detect.py +1 -2
- learning_loop_node/detector/rest/upload.py +2 -9
- learning_loop_node/helpers/misc.py +33 -8
- learning_loop_node/trainer/exceptions.py +6 -0
- learning_loop_node/trainer/io_helpers.py +12 -0
- learning_loop_node/trainer/rest/backdoor_controls.py +1 -2
- learning_loop_node/trainer/trainer_logic.py +17 -7
- learning_loop_node/trainer/trainer_logic_generic.py +55 -21
- learning_loop_node/trainer/validation_set.py +63 -0
- {learning_loop_node-0.25.0.dist-info → learning_loop_node-0.27.0.dist-info}/METADATA +12 -7
- {learning_loop_node-0.25.0.dist-info → learning_loop_node-0.27.0.dist-info}/RECORD +19 -18
- {learning_loop_node-0.25.0.dist-info → learning_loop_node-0.27.0.dist-info}/WHEEL +1 -1
- {learning_loop_node-0.25.0.dist-info → learning_loop_node-0.27.0.dist-info}/top_level.txt +0 -0
|
@@ -59,6 +59,8 @@ class ModelInformation():
|
|
|
59
59
|
default=None, metadata={"description": "The path of the parent directory of the model in the file system."})
|
|
60
60
|
model_size: Optional[str] = field(default=None, metadata={
|
|
61
61
|
"description": "The size of the model (i.e. the specification or variant of the model architecture)."})
|
|
62
|
+
conf_threshold: Optional[float] = field(default=None, metadata={
|
|
63
|
+
"description": "The minimum confidence the trainer chose for the detections of this model, if it chose one."})
|
|
62
64
|
|
|
63
65
|
@property
|
|
64
66
|
def context(self):
|
|
@@ -103,13 +103,19 @@ class Training():
|
|
|
103
103
|
assert self.image_data is not None, 'Image data not set'
|
|
104
104
|
return [image['id'] for image in self.image_data]
|
|
105
105
|
|
|
106
|
-
def
|
|
107
|
-
|
|
108
|
-
|
|
106
|
+
def train_images(self) -> list[dict]:
|
|
107
|
+
return self._images_of_set('train')
|
|
108
|
+
|
|
109
|
+
def val_images(self) -> list[dict]:
|
|
110
|
+
"""The images to select models on: the `val` images if there are any, else the test images."""
|
|
111
|
+
return self._images_of_set('val') or self.test_images()
|
|
112
|
+
|
|
113
|
+
def test_images(self) -> list[dict]:
|
|
114
|
+
return self._images_of_set('test')
|
|
109
115
|
|
|
110
|
-
def
|
|
116
|
+
def _images_of_set(self, set_name: str) -> list[dict]:
|
|
111
117
|
assert self.image_data is not None, 'Image data not set'
|
|
112
|
-
return
|
|
118
|
+
return [image for image in self.image_data if image['set'] == set_name]
|
|
113
119
|
|
|
114
120
|
|
|
115
121
|
@dataclass(**KWONLY_SLOTS)
|
|
@@ -12,9 +12,11 @@ from typing import Any, Dict, List, Optional
|
|
|
12
12
|
import aiofiles # type: ignore
|
|
13
13
|
|
|
14
14
|
from .data_classes import Context
|
|
15
|
-
from .helpers.misc import create_resource_paths, create_task, is_valid_image
|
|
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 CriticalError
|
|
17
|
+
from .trainer.exceptions import CriticalError, ModelUploadFailedError
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
18
20
|
|
|
19
21
|
|
|
20
22
|
class DownloadError(Exception):
|
|
@@ -171,20 +173,41 @@ class DataExchanger():
|
|
|
171
173
|
logging.info('Downloaded model %s(%s) to %s.', model_uuid, model_format, target_folder)
|
|
172
174
|
return created_files
|
|
173
175
|
|
|
174
|
-
async def upload_model_get_uuid(self, context: Context, files: List[str], training_number: Optional[int], mformat: str) -> str:
|
|
176
|
+
async def upload_model_get_uuid(self, context: Context, files: List[str], training_number: Optional[int], mformat: str) -> str | None:
|
|
175
177
|
"""Used by the trainers. Function returns the new model uuid to use for detection.
|
|
178
|
+
A loop that already stores the model in this format answers 409 and should name the model's uuid there.
|
|
176
179
|
|
|
177
|
-
:return: The new model uuid.
|
|
178
|
-
:raise CriticalError: If the upload
|
|
180
|
+
:return: The new model uuid, or None if the loop already stores the model in this format but names no valid uuid.
|
|
181
|
+
:raise CriticalError: If the loop rejects the upload with a client error (other than 408 and 429).
|
|
182
|
+
:raise ModelUploadFailedError: If the loop could not store the model, e.g. on a server error.
|
|
179
183
|
"""
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
184
|
+
path = f'/{context.organization}/projects/{context.project}/trainings/{training_number}/models/latest/{mformat}/file'
|
|
185
|
+
response = await self.loop_communicator.put(path, files=files)
|
|
186
|
+
if response.status_code == HTTPStatus.CONFLICT:
|
|
187
|
+
try:
|
|
188
|
+
stored_model_id = response.json()['detail']['id']
|
|
189
|
+
except (ValueError, KeyError, TypeError): # older loops name no id
|
|
190
|
+
stored_model_id = None
|
|
191
|
+
if not is_valid_uuid4(stored_model_id):
|
|
192
|
+
logger.warning('The loop already stores the model of training %s in format %s, but names no id',
|
|
193
|
+
training_number, mformat)
|
|
194
|
+
return None
|
|
195
|
+
logger.info('The loop already stores model %s of training %s in format %s',
|
|
196
|
+
stored_model_id, training_number, mformat)
|
|
197
|
+
return stored_model_id
|
|
198
|
+
if response.status_code != HTTPStatus.OK:
|
|
199
|
+
logger.error('Could not upload model for training %s, format %s: %s %s',
|
|
200
|
+
training_number, mformat, response.status_code, response.text)
|
|
201
|
+
error = CriticalError if _rejects_upload(response.status_code) else ModelUploadFailedError
|
|
202
|
+
raise error(
|
|
203
|
+
f'Could not upload model for training {training_number}, format {mformat}: {response.status_code} {response.text}')
|
|
186
204
|
|
|
187
205
|
uploaded_model = response.json()
|
|
188
|
-
|
|
189
|
-
|
|
206
|
+
logger.info('Uploaded model for training %s, format %s. Response is: %s',
|
|
207
|
+
training_number, mformat, uploaded_model)
|
|
190
208
|
return uploaded_model['id']
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _rejects_upload(status_code: int) -> bool:
|
|
212
|
+
"""A client error other than 408 and 429 answers the same however often the upload is sent."""
|
|
213
|
+
return 400 <= status_code < 500 and status_code not in (HTTPStatus.REQUEST_TIMEOUT, HTTPStatus.TOO_MANY_REQUESTS)
|
|
@@ -6,6 +6,7 @@ import os
|
|
|
6
6
|
import shutil
|
|
7
7
|
import subprocess
|
|
8
8
|
import sys
|
|
9
|
+
from collections.abc import Sequence
|
|
9
10
|
from dataclasses import asdict, dataclass
|
|
10
11
|
from datetime import datetime
|
|
11
12
|
from typing import Dict, List, Literal, Optional, Union
|
|
@@ -30,7 +31,7 @@ from ..data_exchanger import DataExchanger, DownloadError
|
|
|
30
31
|
from ..enums import OperationMode, VersionMode
|
|
31
32
|
from ..globals import GLOBALS
|
|
32
33
|
from ..helpers import background_tasks, environment_reader, run
|
|
33
|
-
from ..helpers.misc import
|
|
34
|
+
from ..helpers.misc import ImageData, image_from_dict, jpg_bytes_to_numpy_array
|
|
34
35
|
from ..node import Node
|
|
35
36
|
from .categories import category_by_name
|
|
36
37
|
from .detector_logic import DetectorLogic, DetectorLogicFactory
|
|
@@ -230,9 +231,10 @@ class DetectorNode(Node):
|
|
|
230
231
|
|
|
231
232
|
The data dict has the following schema:
|
|
232
233
|
- image: The image data as dictionary:
|
|
233
|
-
-
|
|
234
|
-
-
|
|
235
|
-
-
|
|
234
|
+
- format: 'raw' (default) or 'jpeg' (optional)
|
|
235
|
+
- bytes: bytes of the ndarray, or the JPEG-encoded image for format 'jpeg'
|
|
236
|
+
- dtype: data type of the ndarray (raw only)
|
|
237
|
+
- shape: shape of the ndarray (raw only)
|
|
236
238
|
- camera_id: Optional camera ID
|
|
237
239
|
- tags: Optional list of tags
|
|
238
240
|
- source: Optional source string
|
|
@@ -240,7 +242,7 @@ class DetectorNode(Node):
|
|
|
240
242
|
- creation_date: Optional creation date in isoformat string
|
|
241
243
|
"""
|
|
242
244
|
try:
|
|
243
|
-
image =
|
|
245
|
+
image = image_from_dict(data['image'])
|
|
244
246
|
except Exception:
|
|
245
247
|
self.log.exception('could not parse image from socketio')
|
|
246
248
|
return {'error': 'could not parse image from data'}
|
|
@@ -252,7 +254,7 @@ class DetectorNode(Node):
|
|
|
252
254
|
tags=data.get('tags', []),
|
|
253
255
|
source=data.get('source', None),
|
|
254
256
|
autoupload=data.get('autoupload', 'filtered'),
|
|
255
|
-
creation_date=data.get('creation_date', None)
|
|
257
|
+
creation_date=data.get('creation_date', None),
|
|
256
258
|
)
|
|
257
259
|
return jsonable_encoder(asdict(det))
|
|
258
260
|
except DetectorUnavailableError as e:
|
|
@@ -271,7 +273,7 @@ class DetectorNode(Node):
|
|
|
271
273
|
"""
|
|
272
274
|
try:
|
|
273
275
|
images_data = data['images']
|
|
274
|
-
images = [
|
|
276
|
+
images = [image_from_dict(image) for image in images_data]
|
|
275
277
|
except Exception:
|
|
276
278
|
self.log.exception('could not parse images from socketio')
|
|
277
279
|
return {'error': 'could not parse images from data'}
|
|
@@ -283,7 +285,7 @@ class DetectorNode(Node):
|
|
|
283
285
|
camera_id=data.get('camera_id', None),
|
|
284
286
|
source=data.get('source', None),
|
|
285
287
|
autoupload=data.get('autoupload', 'filtered'),
|
|
286
|
-
creation_date=data.get('creation_date', None)
|
|
288
|
+
creation_date=data.get('creation_date', None),
|
|
287
289
|
)
|
|
288
290
|
return jsonable_encoder(asdict(det))
|
|
289
291
|
except DetectorUnavailableError as e:
|
|
@@ -336,9 +338,11 @@ class DetectorNode(Node):
|
|
|
336
338
|
|
|
337
339
|
The data dict must contain:
|
|
338
340
|
- image: The image data as dictionary with the following keys:
|
|
339
|
-
-
|
|
340
|
-
-
|
|
341
|
-
|
|
341
|
+
- format: 'raw' (default) or 'jpeg' (optional)
|
|
342
|
+
- bytes: bytes of the ndarray (retrieved via `ndarray.tobytes(order='C')`),
|
|
343
|
+
or the JPEG-encoded image for format 'jpeg'
|
|
344
|
+
- dtype: data type of the ndarray as string (e.g. `uint8`, `float32`, etc.) (raw only)
|
|
345
|
+
- shape: shape of the ndarray as tuple of ints (e.g. `(480, 640, 3)`) (raw only)
|
|
342
346
|
- metadata: The metadata for the image (optional). Besides detections and annotations it
|
|
343
347
|
may carry `tags`, `source`, `created` and `state`. A `state` of e.g. "trash" makes the
|
|
344
348
|
loop file the image into that state instead of its default one.
|
|
@@ -362,7 +366,7 @@ class DetectorNode(Node):
|
|
|
362
366
|
image_metadata = ImageMetadata()
|
|
363
367
|
|
|
364
368
|
try:
|
|
365
|
-
image =
|
|
369
|
+
image = image_from_dict(data['image'])
|
|
366
370
|
except Exception:
|
|
367
371
|
self.log.exception('could not parse image from socketio')
|
|
368
372
|
return {'error': 'could not parse image from data'}
|
|
@@ -607,7 +611,7 @@ class DetectorNode(Node):
|
|
|
607
611
|
self.log.error('could not reload app')
|
|
608
612
|
|
|
609
613
|
async def get_detections(self,
|
|
610
|
-
image:
|
|
614
|
+
image: ImageData,
|
|
611
615
|
tags: List[str],
|
|
612
616
|
*,
|
|
613
617
|
camera_id: Optional[str] = None,
|
|
@@ -620,11 +624,15 @@ class DetectorNode(Node):
|
|
|
620
624
|
Used when an image is received via REST or SocketIO.
|
|
621
625
|
This function infers the detections from the image,
|
|
622
626
|
cares about uploading to the loop and returns the detections as ImageMetadata object.
|
|
627
|
+
The image is a pixel array or JPEG bytes; JPEG bytes are decoded for the inference
|
|
628
|
+
and stored as they are if the image is uploaded.
|
|
623
629
|
Raises exception if no model is loaded.
|
|
624
630
|
"""
|
|
631
|
+
_unwrap_detector(self._detector) # fails fast, before the decode
|
|
632
|
+
array = await _pixel_array(image)
|
|
625
633
|
async with self.detection_lock:
|
|
626
634
|
detector = _unwrap_detector(self._detector)
|
|
627
|
-
metadata = await run.io_bound(detector.logic.evaluate,
|
|
635
|
+
metadata = await run.io_bound(detector.logic.evaluate, array)
|
|
628
636
|
|
|
629
637
|
metadata.tags.extend(tags)
|
|
630
638
|
metadata.source = source
|
|
@@ -646,7 +654,7 @@ class DetectorNode(Node):
|
|
|
646
654
|
return metadata
|
|
647
655
|
|
|
648
656
|
async def get_batch_detections(self,
|
|
649
|
-
images:
|
|
657
|
+
images: Sequence[ImageData],
|
|
650
658
|
tags: List[str],
|
|
651
659
|
*,
|
|
652
660
|
camera_id: Optional[str] = None,
|
|
@@ -658,11 +666,15 @@ class DetectorNode(Node):
|
|
|
658
666
|
|
|
659
667
|
This function infers the detections from all images,
|
|
660
668
|
cares about uploading to the loop and returns the detections as a list of ImageMetadata.
|
|
669
|
+
Each image is a pixel array or JPEG bytes; JPEG bytes are decoded for the inference
|
|
670
|
+
and stored as they are if the image is uploaded.
|
|
661
671
|
Raises exception if no model is loaded.
|
|
662
672
|
"""
|
|
673
|
+
_unwrap_detector(self._detector) # fails fast, before the decode
|
|
674
|
+
arrays = await asyncio.gather(*(_pixel_array(image) for image in images))
|
|
663
675
|
async with self.detection_lock:
|
|
664
676
|
detector = _unwrap_detector(self._detector)
|
|
665
|
-
all_detections = await run.io_bound(detector.logic.batch_evaluate,
|
|
677
|
+
all_detections = await run.io_bound(detector.logic.batch_evaluate, arrays)
|
|
666
678
|
|
|
667
679
|
for metadata in all_detections.items:
|
|
668
680
|
metadata.tags.extend(tags)
|
|
@@ -687,14 +699,14 @@ class DetectorNode(Node):
|
|
|
687
699
|
|
|
688
700
|
async def upload_images(
|
|
689
701
|
self, *,
|
|
690
|
-
images:
|
|
702
|
+
images: Sequence[ImageData],
|
|
691
703
|
images_metadata: Optional[ImagesMetadata] = None,
|
|
692
704
|
upload_priority: bool = False
|
|
693
705
|
) -> None:
|
|
694
706
|
"""Save images to the outbox using an asyncio executor.
|
|
695
707
|
Used by SIO and REST upload endpoints.
|
|
696
708
|
|
|
697
|
-
:param images: List of images to upload
|
|
709
|
+
:param images: List of images to upload, pixel arrays or JPEG bytes
|
|
698
710
|
:param images_metadata: Optional metadata for all images
|
|
699
711
|
:param upload_priority: Whether to upload the images with priority
|
|
700
712
|
:raises ValueError: If the number of images and number of metadata items do not match
|
|
@@ -760,6 +772,13 @@ class DetectorUnavailableError(Exception):
|
|
|
760
772
|
pass
|
|
761
773
|
|
|
762
774
|
|
|
775
|
+
async def _pixel_array(image: ImageData) -> np.ndarray:
|
|
776
|
+
"""The pixels of an image; JPEG bytes are decoded in a thread, as that takes a while for a camera image."""
|
|
777
|
+
if isinstance(image, np.ndarray):
|
|
778
|
+
return image
|
|
779
|
+
return await run.io_bound(jpg_bytes_to_numpy_array, image)
|
|
780
|
+
|
|
781
|
+
|
|
763
782
|
def _unwrap_detector(state: '_DetectorState') -> _ActiveDetector:
|
|
764
783
|
"""Return the active detector or raise DetectorUnavailableError."""
|
|
765
784
|
match state:
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
from typing import Dict, List, Optional
|
|
2
2
|
|
|
3
|
-
import numpy as np
|
|
4
|
-
|
|
5
3
|
from ...data_classes.image_metadata import ImageMetadata
|
|
4
|
+
from ...helpers.misc import ImageData
|
|
6
5
|
from ..outbox import Outbox
|
|
7
6
|
from .cam_observation_history import CamObservationHistory
|
|
8
7
|
|
|
@@ -17,7 +16,7 @@ class RelevanceFilter():
|
|
|
17
16
|
async def may_upload_detections(self,
|
|
18
17
|
image_metadata: ImageMetadata,
|
|
19
18
|
cam_id: Optional[str],
|
|
20
|
-
image:
|
|
19
|
+
image: ImageData) -> List[str]:
|
|
21
20
|
"""Check if the detection should be uploaded to the outbox.
|
|
22
21
|
If so, upload it and return the list of causes for the upload.
|
|
23
22
|
"""
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
-
import io
|
|
3
2
|
import json
|
|
4
3
|
import logging
|
|
5
4
|
import os
|
|
@@ -17,15 +16,13 @@ from typing import List, Optional, Tuple, TypeVar, Union
|
|
|
17
16
|
|
|
18
17
|
import aiohttp
|
|
19
18
|
import numpy as np
|
|
20
|
-
import PIL
|
|
21
|
-
import PIL.Image # type: ignore
|
|
22
19
|
from fastapi.encoders import jsonable_encoder
|
|
23
20
|
|
|
24
21
|
from ..data_classes import ImageMetadata
|
|
25
22
|
from ..enums import OutboxMode
|
|
26
23
|
from ..globals import GLOBALS
|
|
27
24
|
from ..helpers import environment_reader, run
|
|
28
|
-
from ..helpers.misc import numpy_array_to_jpg_bytes
|
|
25
|
+
from ..helpers.misc import ImageData, numpy_array_to_jpg_bytes
|
|
29
26
|
|
|
30
27
|
T = TypeVar('T')
|
|
31
28
|
|
|
@@ -80,16 +77,17 @@ class Outbox():
|
|
|
80
77
|
self.upload_folders.append(file)
|
|
81
78
|
|
|
82
79
|
async def save(self,
|
|
83
|
-
image:
|
|
80
|
+
image: ImageData,
|
|
84
81
|
image_metadata: Optional[ImageMetadata] = None,
|
|
85
82
|
upload_priority: bool = False) -> None:
|
|
86
83
|
"""
|
|
87
|
-
Save an image and its metadata to disk.
|
|
84
|
+
Save an image and its metadata to disk.
|
|
88
85
|
|
|
86
|
+
A pixel array is encoded as JPEG here, image bytes are stored unchanged for the loop to validate.
|
|
89
87
|
The data will be picked up by the continuous upload process.
|
|
90
88
|
"""
|
|
91
89
|
|
|
92
|
-
jpg_bytes = numpy_array_to_jpg_bytes(image)
|
|
90
|
+
jpg_bytes = numpy_array_to_jpg_bytes(image) if isinstance(image, np.ndarray) else image
|
|
93
91
|
|
|
94
92
|
if image_metadata is None:
|
|
95
93
|
image_metadata = ImageMetadata()
|
|
@@ -262,14 +260,6 @@ class Outbox():
|
|
|
262
260
|
else:
|
|
263
261
|
self.log.error('Could not upload images: %s', response.content)
|
|
264
262
|
|
|
265
|
-
def _is_valid_jpg(self, image: bytes) -> bool:
|
|
266
|
-
try:
|
|
267
|
-
_ = PIL.Image.open(io.BytesIO(image), formats=['JPEG'])
|
|
268
|
-
return True
|
|
269
|
-
except Exception:
|
|
270
|
-
self.log.exception('Invalid jpg image')
|
|
271
|
-
return False
|
|
272
|
-
|
|
273
263
|
async def ensure_continuous_upload_stopped(self) -> bool:
|
|
274
264
|
self.log.debug('Outbox: Ensuring continuous upload')
|
|
275
265
|
if not self._upload_process_alive():
|
|
@@ -4,7 +4,6 @@ from typing import TYPE_CHECKING, Literal, Optional
|
|
|
4
4
|
from fastapi import APIRouter, File, Header, Request, UploadFile
|
|
5
5
|
|
|
6
6
|
from ...data_classes.image_metadata import ImageMetadata
|
|
7
|
-
from ...helpers.misc import jpg_bytes_to_numpy_array
|
|
8
7
|
|
|
9
8
|
if TYPE_CHECKING:
|
|
10
9
|
from ..detector_node import DetectorNode
|
|
@@ -42,7 +41,7 @@ async def http_detect(
|
|
|
42
41
|
raise Exception(f'Uploaded file {file.filename} is no image file.') from exc
|
|
43
42
|
|
|
44
43
|
try:
|
|
45
|
-
detections = await node.get_detections(image=
|
|
44
|
+
detections = await node.get_detections(image=file_bytes,
|
|
46
45
|
camera_id=camera_id or None,
|
|
47
46
|
tags=tags.split(',') if tags else [],
|
|
48
47
|
source=source,
|
|
@@ -3,7 +3,6 @@ from typing import TYPE_CHECKING, List, Optional
|
|
|
3
3
|
from fastapi import APIRouter, File, Query, Request, UploadFile
|
|
4
4
|
|
|
5
5
|
from ...data_classes.image_metadata import ImageMetadata, ImagesMetadata
|
|
6
|
-
from ...helpers.misc import jpg_bytes_to_numpy_array
|
|
7
6
|
|
|
8
7
|
if TYPE_CHECKING:
|
|
9
8
|
from ..detector_node import DetectorNode
|
|
@@ -29,14 +28,8 @@ async def upload_image(request: Request,
|
|
|
29
28
|
"""
|
|
30
29
|
node: 'DetectorNode' = request.app
|
|
31
30
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
images = []
|
|
35
|
-
for file_bytes in files_bytes:
|
|
36
|
-
images.append(jpg_bytes_to_numpy_array(file_bytes))
|
|
37
|
-
image_metadatas.append(ImageMetadata(source=source, created=creation_date))
|
|
38
|
-
|
|
39
|
-
images_metadata = ImagesMetadata(items=image_metadatas)
|
|
31
|
+
images = [await file.read() for file in files]
|
|
32
|
+
images_metadata = ImagesMetadata(items=[ImageMetadata(source=source, created=creation_date) for _ in images])
|
|
40
33
|
|
|
41
34
|
await node.upload_images(images=images,
|
|
42
35
|
images_metadata=images_metadata,
|
|
@@ -10,12 +10,12 @@ import sys
|
|
|
10
10
|
from dataclasses import asdict
|
|
11
11
|
from glob import glob
|
|
12
12
|
from time import perf_counter
|
|
13
|
-
from typing import Any, Coroutine, Dict, List, Optional, Tuple, TypeVar
|
|
13
|
+
from typing import Any, Coroutine, Dict, List, Literal, Optional, Tuple, TypeVar, Union, get_args
|
|
14
14
|
from uuid import UUID, uuid4
|
|
15
15
|
|
|
16
16
|
import numpy as np
|
|
17
17
|
import pynvml
|
|
18
|
-
from PIL import Image
|
|
18
|
+
from PIL import Image, ImageOps
|
|
19
19
|
|
|
20
20
|
from ..data_classes.general import Context
|
|
21
21
|
from ..data_classes.socket_response import SocketResponse
|
|
@@ -220,31 +220,56 @@ def create_training_folder(project_folder: str, trainings_id: str) -> str:
|
|
|
220
220
|
return training_folder
|
|
221
221
|
|
|
222
222
|
|
|
223
|
+
ImageData = Union[np.ndarray, bytes]
|
|
224
|
+
"""An image as it was received: either a pixel array or a JPEG-encoded image."""
|
|
225
|
+
|
|
226
|
+
ImageFormat = Literal['raw', 'jpeg']
|
|
227
|
+
"""How the `bytes` of an image dict are encoded: a pixel array in C order described by `dtype` and `shape`, or a JPEG."""
|
|
228
|
+
|
|
229
|
+
|
|
223
230
|
def jpg_bytes_to_numpy_array(jpg_bytes: bytes) -> np.ndarray:
|
|
224
|
-
"""
|
|
225
|
-
image = Image.open(io.BytesIO(jpg_bytes))
|
|
231
|
+
"""Decode image bytes into a pixel array, rotated as the EXIF orientation tag says."""
|
|
232
|
+
image = ImageOps.exif_transpose(Image.open(io.BytesIO(jpg_bytes)))
|
|
226
233
|
return np.array(image)
|
|
227
234
|
|
|
228
235
|
|
|
229
236
|
def numpy_array_to_jpg_bytes(image_array: np.ndarray) -> bytes:
|
|
230
|
-
"""
|
|
237
|
+
"""Encode a pixel array as JPEG."""
|
|
231
238
|
buffer = io.BytesIO()
|
|
232
239
|
Image.fromarray(image_array).save(buffer, format="JPEG")
|
|
233
240
|
jpg_bytes = buffer.getvalue()
|
|
234
241
|
return jpg_bytes
|
|
235
242
|
|
|
236
243
|
|
|
244
|
+
def image_format_of(image_data: Dict) -> ImageFormat:
|
|
245
|
+
"""The format an image dict declares, 'raw' if it declares none."""
|
|
246
|
+
image_format = image_data.get('format', 'raw')
|
|
247
|
+
if image_format not in get_args(ImageFormat):
|
|
248
|
+
raise ValueError(f'unknown image format: {image_format}')
|
|
249
|
+
return image_format
|
|
250
|
+
|
|
251
|
+
|
|
237
252
|
def numpy_image_from_dict(image_data: Dict) -> np.ndarray:
|
|
238
253
|
"""
|
|
239
254
|
Convert image dict to numpy array.
|
|
240
255
|
|
|
241
256
|
The image_data dict should have the following keys:
|
|
242
|
-
-
|
|
243
|
-
-
|
|
244
|
-
-
|
|
257
|
+
- format: 'raw' (default) or 'jpeg' (optional)
|
|
258
|
+
- bytes: bytes of the image in C order (row-major), or the JPEG-encoded image for format 'jpeg'
|
|
259
|
+
- dtype: data type of the pixel values (raw only)
|
|
260
|
+
- shape: shape of the image (height, width, channels) (raw only)
|
|
245
261
|
"""
|
|
262
|
+
if image_format_of(image_data) == 'jpeg':
|
|
263
|
+
return jpg_bytes_to_numpy_array(image_data['bytes'])
|
|
246
264
|
|
|
247
265
|
image_bytes = image_data['bytes']
|
|
248
266
|
image_dtype = image_data['dtype']
|
|
249
267
|
image_shape = image_data['shape']
|
|
250
268
|
return np.frombuffer(image_bytes, dtype=image_dtype).reshape(image_shape, order='C')
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def image_from_dict(image_data: Dict) -> ImageData:
|
|
272
|
+
"""The image of an image dict as received: the JPEG bytes for format 'jpeg', the pixel array otherwise."""
|
|
273
|
+
if image_format_of(image_data) == 'jpeg':
|
|
274
|
+
return image_data['bytes']
|
|
275
|
+
return numpy_image_from_dict(image_data)
|
|
@@ -5,6 +5,12 @@ class CriticalError(Exception):
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
|
|
8
|
+
class ModelUploadFailedError(Exception):
|
|
9
|
+
"""Raised when the loop could not store an uploaded model, e.g. because it answered with a server error.
|
|
10
|
+
Unlike a CriticalError it keeps the training, so the upload is tried again later.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
|
|
8
14
|
class TrainingRefusedError(Exception):
|
|
9
15
|
"""Raised when a trainer cannot take on a training it was asked to begin; nothing was set up."""
|
|
10
16
|
|
|
@@ -55,6 +55,7 @@ class ActiveTrainingIO:
|
|
|
55
55
|
self.context = context
|
|
56
56
|
|
|
57
57
|
self.mup_path = f'{training_folder}/model_uploading_progress.txt'
|
|
58
|
+
self.muuid_path = f'{training_folder}/uploaded_model_uuid.txt'
|
|
58
59
|
# string with placeholder gor index
|
|
59
60
|
self.det_path = f'{training_folder}' + '/detections_{0}.json'
|
|
60
61
|
self.dufi_path = f'{training_folder}/detection_uploading_json_index.txt'
|
|
@@ -73,6 +74,17 @@ class ActiveTrainingIO:
|
|
|
73
74
|
with open(self.mup_path, 'r') as f:
|
|
74
75
|
return f.read().split(',')
|
|
75
76
|
|
|
77
|
+
def save_uploaded_model_uuid(self, model_uuid: str) -> None:
|
|
78
|
+
"""Replaces the file in one step, so a crash while writing cannot leave it empty."""
|
|
79
|
+
with open(f'{self.muuid_path}.tmp', 'w') as f:
|
|
80
|
+
f.write(model_uuid)
|
|
81
|
+
os.replace(f'{self.muuid_path}.tmp', self.muuid_path)
|
|
82
|
+
|
|
83
|
+
def load_uploaded_model_uuid(self) -> str:
|
|
84
|
+
""":raise FileNotFoundError: If no upload has named the model uuid yet."""
|
|
85
|
+
with open(self.muuid_path) as f:
|
|
86
|
+
return f.read()
|
|
87
|
+
|
|
76
88
|
# detections
|
|
77
89
|
|
|
78
90
|
def _get_detection_file_names(self) -> List[Path]:
|
|
@@ -38,8 +38,7 @@ async def reset(request: Request):
|
|
|
38
38
|
trainer_node: 'TrainerNode' = request.app
|
|
39
39
|
async with trainer_node.repeat_loop_lock:
|
|
40
40
|
|
|
41
|
-
await trainer_node.trainer_logic.
|
|
42
|
-
await trainer_node.trainer_logic.stop()
|
|
41
|
+
await trainer_node.trainer_logic.abort()
|
|
43
42
|
trainer_node.last_training_io.delete()
|
|
44
43
|
trainer_node.status.reset_all_errors()
|
|
45
44
|
|
|
@@ -25,6 +25,8 @@ class TrainerLogic(TrainerLogicGeneric):
|
|
|
25
25
|
super().__init__(model_format)
|
|
26
26
|
self._detection_progress: Optional[float] = None
|
|
27
27
|
self._executor: Optional[Executor] = None
|
|
28
|
+
self._process_started: bool = False
|
|
29
|
+
self._stop_requested: bool = False
|
|
28
30
|
self.start_training_task: Optional[Coroutine] = None
|
|
29
31
|
self.inference_batch_size = self._environment_vars.inference_batch_size
|
|
30
32
|
|
|
@@ -47,10 +49,13 @@ class TrainerLogic(TrainerLogicGeneric):
|
|
|
47
49
|
previous_state = TrainerState.TrainModelDownloaded
|
|
48
50
|
error_key = 'run_training'
|
|
49
51
|
self._executor = Executor(self.training.training_folder)
|
|
52
|
+
self._process_started = False
|
|
53
|
+
self._stop_requested = False
|
|
50
54
|
self.training.training_state = TrainerState.TrainingRunning
|
|
51
55
|
|
|
52
56
|
try:
|
|
53
57
|
await self._start_training()
|
|
58
|
+
self._process_started = True
|
|
54
59
|
last_sync_time = datetime.now()
|
|
55
60
|
|
|
56
61
|
while True:
|
|
@@ -77,11 +82,17 @@ class TrainerLogic(TrainerLogicGeneric):
|
|
|
77
82
|
# if self.executor.return_code != 0:
|
|
78
83
|
# raise TrainingError(cause=f'Executor returned with error code: {self.executor.return_code}')
|
|
79
84
|
|
|
80
|
-
except TrainingError:
|
|
85
|
+
except TrainingError as e:
|
|
81
86
|
logging.exception('Exception in trainer_logic._train')
|
|
87
|
+
if self._stop_requested:
|
|
88
|
+
# ending the process may be what failed it; a stopped training must not start over, and the
|
|
89
|
+
# wind-down uploads the model it has (or ends with a CriticalError if there is none)
|
|
90
|
+
logging.warning('Training process failed after a stop (%s), winding down with its model', e.cause)
|
|
91
|
+
return
|
|
82
92
|
self.training.training_state = previous_state
|
|
83
93
|
raise
|
|
84
94
|
finally:
|
|
95
|
+
self._process_started = False
|
|
85
96
|
# shield so the subprocess is stopped even when the task is cancelled (e.g. on abort())
|
|
86
97
|
if self._executor and self._executor.is_running():
|
|
87
98
|
await asyncio.shield(self.executor.stop_and_wait())
|
|
@@ -147,15 +158,14 @@ class TrainerLogic(TrainerLogicGeneric):
|
|
|
147
158
|
|
|
148
159
|
# ---------------------------------------- OVERWRITTEN METHODS ----------------------------------------
|
|
149
160
|
|
|
150
|
-
async def
|
|
151
|
-
"""
|
|
152
|
-
|
|
153
|
-
|
|
161
|
+
async def _stop_running_training(self) -> None:
|
|
162
|
+
"""End the training process; before it has started, abort."""
|
|
163
|
+
if not self._process_started:
|
|
164
|
+
await super()._stop_running_training()
|
|
154
165
|
return
|
|
166
|
+
self._stop_requested = True
|
|
155
167
|
if self._executor and self._executor.is_running():
|
|
156
168
|
await self.executor.stop_and_wait()
|
|
157
|
-
return
|
|
158
|
-
await super().stop()
|
|
159
169
|
|
|
160
170
|
# ---------------------------------------- ABSTRACT METHODS ----------------------------------------
|
|
161
171
|
|
|
@@ -29,13 +29,22 @@ from .downloader import TrainingsDownloader
|
|
|
29
29
|
from .exceptions import CriticalError, NodeNeedsRestartError, TrainingRefusedError
|
|
30
30
|
from .io_helpers import ActiveTrainingIO, EnvironmentVars, LastTrainingIO
|
|
31
31
|
from .training_slot import TrainingSlot, training_slot_from_env
|
|
32
|
+
from .validation_set import assign_validation_set
|
|
32
33
|
|
|
33
34
|
if TYPE_CHECKING:
|
|
34
35
|
from .trainer_node import TrainerNode
|
|
35
36
|
|
|
37
|
+
BASE_RETRY_DELAY = 5 # seconds before the first retry of a failed state
|
|
38
|
+
MAX_RETRY_DELAY = 300 # seconds
|
|
39
|
+
|
|
36
40
|
logger = logging.getLogger('learning_loop_node.trainer_logic_generic')
|
|
37
41
|
|
|
38
42
|
|
|
43
|
+
def _retry_delay(failures: int) -> float:
|
|
44
|
+
"""The wait before the next attempt of a state that has failed `failures` times in a row."""
|
|
45
|
+
return min(BASE_RETRY_DELAY * 2 ** min(failures - 1, 16), MAX_RETRY_DELAY)
|
|
46
|
+
|
|
47
|
+
|
|
39
48
|
class TrainerLogicGeneric(ABC):
|
|
40
49
|
|
|
41
50
|
def __init__(self, model_format: str, training_slot: TrainingSlot | None = None) -> None:
|
|
@@ -45,6 +54,7 @@ class TrainerLogicGeneric(ABC):
|
|
|
45
54
|
# '/{context.organization}/projects/{context.project}/models/{model_id}/{model_format}/file'
|
|
46
55
|
self.model_format: str = model_format
|
|
47
56
|
self.errors = Errors()
|
|
57
|
+
self._state_failures: Dict[str, int] = {} # failed attempts in a row, by error key
|
|
48
58
|
|
|
49
59
|
self.training_task: Optional[asyncio.Task] = None
|
|
50
60
|
self.shutdown_event: asyncio.Event = asyncio.Event()
|
|
@@ -264,6 +274,7 @@ class TrainerLogicGeneric(ABC):
|
|
|
264
274
|
Either via `begin_training` or `try_continue_run_if_incomplete`.
|
|
265
275
|
"""
|
|
266
276
|
self.errors.reset_all()
|
|
277
|
+
self._state_failures.clear()
|
|
267
278
|
try:
|
|
268
279
|
await self.training_slot.wait_for(self._slot_claim(self.training.context, self.training.training_number))
|
|
269
280
|
await self._training_loop()
|
|
@@ -318,11 +329,14 @@ class TrainerLogicGeneric(ABC):
|
|
|
318
329
|
async def _perform_state(self, error_key: str, state_during: TrainerState, state_after: TrainerState, action: Callable[[], Coroutine], reset_early=False):
|
|
319
330
|
'''
|
|
320
331
|
Perform a training state and handle errors.
|
|
321
|
-
- If the
|
|
332
|
+
- If the training is aborted, the CancelledError moves it to ReadyForCleanup; on shutdown it is re-raised.
|
|
322
333
|
- States can raise a CriticalError indicating that there is no point in retrying the state.
|
|
323
334
|
- If any other error occurs, the error is stored in the errors object and the state is reset to the previous state.
|
|
335
|
+
The state is retried after a wait until the training is aborted.
|
|
324
336
|
'''
|
|
325
337
|
|
|
338
|
+
if failures := self._state_failures.get(error_key):
|
|
339
|
+
await asyncio.sleep(_retry_delay(failures))
|
|
326
340
|
await asyncio.sleep(0.1)
|
|
327
341
|
logger.info('Performing state: %s', state_during)
|
|
328
342
|
previous_state = self.training.training_state
|
|
@@ -351,6 +365,7 @@ class TrainerLogicGeneric(ABC):
|
|
|
351
365
|
print('Error in %s - Exception: %s', state_during, e, flush=True)
|
|
352
366
|
self.errors.set(error_key, str(e))
|
|
353
367
|
logger.exception('Error in %s - Exception: %s', state_during, e)
|
|
368
|
+
self._state_failures[error_key] = self._state_failures.get(error_key, 0) + 1
|
|
354
369
|
self.training.training_state = previous_state
|
|
355
370
|
return
|
|
356
371
|
else:
|
|
@@ -359,14 +374,15 @@ class TrainerLogicGeneric(ABC):
|
|
|
359
374
|
self.errors.reset(error_key)
|
|
360
375
|
self.training.training_state = state_after
|
|
361
376
|
|
|
377
|
+
self._state_failures.pop(error_key, None)
|
|
362
378
|
self.last_training_io.save(self.training)
|
|
363
379
|
|
|
364
380
|
async def _prepare(self) -> None:
|
|
365
|
-
"""Downloads images to the images_folder and saves annotations to training.data.image_data.
|
|
366
|
-
"""
|
|
381
|
+
"""Downloads images to the images_folder and saves annotations to training.data.image_data."""
|
|
367
382
|
self.node.data_exchanger.set_context(self.training.context)
|
|
368
383
|
downloader = TrainingsDownloader(self.node.data_exchanger)
|
|
369
384
|
image_data, skipped_image_count = await downloader.download_training_data(self.training.images_folder)
|
|
385
|
+
assign_validation_set(image_data, self.training.hyperparameters)
|
|
370
386
|
|
|
371
387
|
self.training.image_data = image_data
|
|
372
388
|
self.training.skipped_image_count = skipped_image_count
|
|
@@ -406,11 +422,12 @@ class TrainerLogicGeneric(ABC):
|
|
|
406
422
|
try:
|
|
407
423
|
new_best_model = self._get_new_best_training_state()
|
|
408
424
|
if new_best_model:
|
|
425
|
+
sets = [image['set'] for image in self.training.image_data or []]
|
|
409
426
|
new_training = TrainingOut(trainer_id=self.node.uuid,
|
|
410
427
|
trainer_name=self.node.name,
|
|
411
428
|
confusion_matrix=new_best_model.confusion_matrix,
|
|
412
|
-
train_image_count=
|
|
413
|
-
test_image_count=
|
|
429
|
+
train_image_count=sets.count('train') + sets.count('val'),
|
|
430
|
+
test_image_count=sets.count('test'),
|
|
414
431
|
hyperparameters=self.hyperparameters_for_state_sync,
|
|
415
432
|
best_epoch=new_best_model.epoch)
|
|
416
433
|
await asyncio.sleep(0.1) # NOTE needed for tests.
|
|
@@ -440,10 +457,13 @@ class TrainerLogicGeneric(ABC):
|
|
|
440
457
|
Note that with the latest trainers the conversion to (.wts) is done by the trainer.
|
|
441
458
|
The conversion from .wts to .engine is done by the detector (needs to be done on target hardware).
|
|
442
459
|
Note that trainer may train with different classes, which is why we send an initial model.json file.
|
|
460
|
+
A format the loop already stores counts as uploaded.
|
|
461
|
+
The model UUID is kept beside the upload progress, so a retry after a restart still knows it.
|
|
443
462
|
|
|
444
463
|
:return: The new model UUID.
|
|
445
|
-
:raise CriticalError: If the latest model files cannot be obtained
|
|
446
|
-
|
|
464
|
+
:raise CriticalError: If the latest model files cannot be obtained,
|
|
465
|
+
or the model is uploaded but its UUID is unknown.
|
|
466
|
+
:raise ModelUploadFailedError: If the loop could not store a model file; the state is retried.
|
|
447
467
|
"""
|
|
448
468
|
|
|
449
469
|
files = await self._get_latest_model_files()
|
|
@@ -456,25 +476,27 @@ class TrainerLogicGeneric(ABC):
|
|
|
456
476
|
|
|
457
477
|
already_uploaded_formats = self.active_training_io.load_model_upload_progress()
|
|
458
478
|
|
|
459
|
-
model_uuid = None
|
|
460
479
|
for file_format in [f for f in files if f not in already_uploaded_formats]:
|
|
461
|
-
_files = files[file_format] + [self.
|
|
480
|
+
_files = files[file_format] + [self._dump_model_json()]
|
|
462
481
|
assert len([f for f in _files if 'model.json' in f]) == 1, "model.json must be included exactly once"
|
|
463
482
|
|
|
464
483
|
model_uuid = await self.node.data_exchanger.upload_model_get_uuid(context, _files, self.training.training_number, file_format)
|
|
484
|
+
if model_uuid is not None:
|
|
485
|
+
self.active_training_io.save_uploaded_model_uuid(model_uuid)
|
|
465
486
|
|
|
466
487
|
already_uploaded_formats.append(file_format)
|
|
467
488
|
self.active_training_io.save_model_upload_progress(already_uploaded_formats)
|
|
468
489
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
490
|
+
try:
|
|
491
|
+
return self.active_training_io.load_uploaded_model_uuid()
|
|
492
|
+
except FileNotFoundError as e:
|
|
493
|
+
raise CriticalError('The model is uploaded, but the loop never answered with its id') from e
|
|
473
494
|
|
|
474
|
-
def
|
|
475
|
-
"""Dumps the categories to a json file and returns the path to the file.
|
|
495
|
+
def _dump_model_json(self) -> str:
|
|
496
|
+
"""Dumps the categories and the trainer's model metadata to a json file and returns the path to the file.
|
|
476
497
|
"""
|
|
477
|
-
content = {'categories': [asdict(c) for c in self._training.categories],
|
|
498
|
+
content = {'categories': [asdict(c) for c in self._training.categories],
|
|
499
|
+
**self._get_model_metadata()} if self._training else None
|
|
478
500
|
json_path = '/tmp/model.json'
|
|
479
501
|
with open(json_path, 'w') as f:
|
|
480
502
|
json.dump(content, f)
|
|
@@ -496,12 +518,17 @@ class TrainerLogicGeneric(ABC):
|
|
|
496
518
|
|
|
497
519
|
async def on_shutdown(self) -> None:
|
|
498
520
|
self.shutdown_event.set()
|
|
499
|
-
await self.
|
|
500
|
-
await self.stop()
|
|
521
|
+
await self.abort()
|
|
501
522
|
|
|
502
|
-
async def stop(self):
|
|
503
|
-
"""Stop
|
|
504
|
-
|
|
523
|
+
async def stop(self) -> None:
|
|
524
|
+
"""Stop a running training but keep its model. In every other state a stop does nothing."""
|
|
525
|
+
if not self.training_active or self.training.training_state != TrainerState.TrainingRunning:
|
|
526
|
+
logger.info('Received stop in state %s. This is ignored.', self.training.training_state if self.training_active else None)
|
|
527
|
+
return
|
|
528
|
+
await self._stop_running_training()
|
|
529
|
+
|
|
530
|
+
async def _stop_running_training(self) -> None:
|
|
531
|
+
"""End the running training so it winds down with its model. This base class cannot, so it aborts."""
|
|
505
532
|
await self.abort()
|
|
506
533
|
|
|
507
534
|
async def abort(self):
|
|
@@ -587,6 +614,13 @@ class TrainerLogicGeneric(ABC):
|
|
|
587
614
|
"""
|
|
588
615
|
raise NotImplementedError
|
|
589
616
|
|
|
617
|
+
def _get_model_metadata(self) -> dict[str, Any]:
|
|
618
|
+
"""Called when the files of `_get_latest_model_files` are uploaded.
|
|
619
|
+
Returns values the trainer determined for this model, merged into its model.json.
|
|
620
|
+
The keys must be fields of `ModelInformation` (e.g. `conf_threshold`) so detectors can read them.
|
|
621
|
+
"""
|
|
622
|
+
return {}
|
|
623
|
+
|
|
590
624
|
@abstractmethod
|
|
591
625
|
async def _clear_training_data(self, training_folder: str) -> None:
|
|
592
626
|
"""Called after a training has finished. Deletes all data that is not needed anymore after a training run.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Which images a training selects its models on.
|
|
2
|
+
|
|
3
|
+
A node selects models (best checkpoint, early stopping, confidence threshold) on
|
|
4
|
+
`Training.val_images()` and reads `Training.test_images()` only for the counts it reports. It never
|
|
5
|
+
decides between the two itself: `val_images()` always holds a set to select on, which is the test
|
|
6
|
+
images unless the training has `val` images of its own. A node may notice that both hold the same
|
|
7
|
+
images and spare itself a second pass, but nothing more.
|
|
8
|
+
|
|
9
|
+
Where the `val` images come from, in this order:
|
|
10
|
+
|
|
11
|
+
1. The loop sent a dedicated validation set (`set == 'val'`); it is used as it is.
|
|
12
|
+
2. The `compute_validation_set` hyperparameter is true: `assign_validation_set` moves about a tenth of the
|
|
13
|
+
train images to `val`, chosen by a hash of the image id, so an image stays on its side when new
|
|
14
|
+
images arrive or a training is continued.
|
|
15
|
+
3. Otherwise there are no `val` images and `val_images()` falls back to the test images, so the
|
|
16
|
+
model is selected on the test set.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import hashlib
|
|
20
|
+
import logging
|
|
21
|
+
from collections.abc import Mapping
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from .exceptions import CriticalError
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
COMPUTE_VALIDATION_SET = 'compute_validation_set'
|
|
29
|
+
"""The hyperparameter that asks to split a validation set off the train images when the loop sent none."""
|
|
30
|
+
|
|
31
|
+
VALIDATION_FRACTION = 0.1
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def compute_validation_set(hyperparameters: Mapping[str, Any]) -> bool:
|
|
35
|
+
"""Whether `compute_validation_set` is set, defaulting to false; anything but a boolean is a CriticalError."""
|
|
36
|
+
value = hyperparameters.get(COMPUTE_VALIDATION_SET, False)
|
|
37
|
+
if not isinstance(value, bool):
|
|
38
|
+
raise CriticalError(f"'{COMPUTE_VALIDATION_SET}' must be true or false, got {value!r}")
|
|
39
|
+
return value
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def assign_validation_set(image_data: list[dict], hyperparameters: Mapping[str, Any]) -> None:
|
|
43
|
+
"""Move part of the train images to `val` if `compute_validation_set` asks for it and the loop sent no `val` images.
|
|
44
|
+
"""
|
|
45
|
+
if any(image['set'] == 'val' for image in image_data):
|
|
46
|
+
logger.info('Selecting models on the validation set of the loop')
|
|
47
|
+
return
|
|
48
|
+
if not compute_validation_set(hyperparameters):
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
train_images = [image for image in image_data if image['set'] == 'train']
|
|
52
|
+
if len(train_images) < 2:
|
|
53
|
+
raise CriticalError(f'{len(train_images)} training image(s) are too few to split off a validation set, '
|
|
54
|
+
'add more training images')
|
|
55
|
+
val_images = [image for image in train_images if _position(image) < VALIDATION_FRACTION] \
|
|
56
|
+
or [min(train_images, key=_position)]
|
|
57
|
+
for image in val_images:
|
|
58
|
+
image['set'] = 'val'
|
|
59
|
+
logger.info('Split %d of %d training images off as validation set', len(val_images), len(train_images))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _position(image: dict) -> float:
|
|
63
|
+
return int(hashlib.sha256(image['id'].encode()).hexdigest()[:8], 16) / 16**8
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: learning_loop_node
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.27.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
|
|
@@ -114,9 +114,10 @@ Example usage:
|
|
|
114
114
|
To use the **SocketIO** inference EPs, the caller needs to connect to the detector node's SocketIO server and emit the `detect` or `batch_detect` event with the image data and image metadata. The `detect` endpoint receives a dictionary, with the following entries:
|
|
115
115
|
|
|
116
116
|
- `image`: The image data as dictionary with the following keys:
|
|
117
|
-
- `
|
|
118
|
-
- `
|
|
119
|
-
- `
|
|
117
|
+
- `format`: `raw` (default) or `jpeg`
|
|
118
|
+
- `bytes`: bytes of the ndarray (retrieved via `ndarray.tobytes(order='C')`), or the JPEG-encoded image for format `jpeg`
|
|
119
|
+
- `dtype`: data type of the ndarray as string (e.g. `uint8`, `float32`, etc.) (raw only)
|
|
120
|
+
- `shape`: shape of the ndarray as tuple of ints (e.g. `(480, 640, 3)`) (raw only)
|
|
120
121
|
- `camera_id`: optional camera identifier (string) used to improve the autoupload filtering
|
|
121
122
|
- `tags`: optional list of tags to add to the image in the learning loop
|
|
122
123
|
- `source`: optional source string
|
|
@@ -127,6 +128,8 @@ The `batch_detect` endpoint receives a dictionary, with the same entries as the
|
|
|
127
128
|
|
|
128
129
|
- `images`: List of image data dictionaries, each with the same structure as the `image` entry in the `detect` endpoint
|
|
129
130
|
|
|
131
|
+
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.
|
|
132
|
+
|
|
130
133
|
Example code can be found [in the rosys implementation](https://github.com/zauberzeug/rosys/blob/main/rosys/vision/detector_hardware.py).
|
|
131
134
|
|
|
132
135
|
### Upload API
|
|
@@ -144,9 +147,10 @@ Example:
|
|
|
144
147
|
The detector also has a **SocketIO** upload endpoint that can be used to upload images and detections to the learning loop. The function receives a dictionary, with the following entries:
|
|
145
148
|
|
|
146
149
|
- `image`: the image data as dictionary with the following keys:
|
|
147
|
-
- `
|
|
148
|
-
- `
|
|
149
|
-
- `
|
|
150
|
+
- `format`: `raw` (default) or `jpeg`
|
|
151
|
+
- `bytes`: bytes of the ndarray (retrieved via `ndarray.tobytes(order='C')`), or the JPEG-encoded image for format `jpeg`
|
|
152
|
+
- `dtype`: data type of the ndarray as string (e.g. `uint8`, `float32`, etc.) (raw only)
|
|
153
|
+
- `shape`: shape of the ndarray as tuple of ints (e.g. `(480, 640, 3)`) (raw only)
|
|
150
154
|
- `metadata`: a dictionary representing the image metadata. If metadata contains detections and/or annotations, UUIDs for the classes are automatically determined based on the category names. Metadata should follow the schema of the `ImageMetadata` data class.
|
|
151
155
|
- `upload_priority`: Optional boolean flag to prioritize the upload (defaults to False)
|
|
152
156
|
|
|
@@ -345,3 +349,4 @@ After a short time the converted model should be available as well.
|
|
|
345
349
|
- Nodes add properties to `model.json`, which contains all the information which are needed by subsequent nodes. These are typically the properties:
|
|
346
350
|
- `resolution`: resolution in which the model expects images (as `int`, since the resolution is mostly square - later, `resolution_x`` resolution_y` would also be conceivable or `resolutions` to give a list of possible resolutions)
|
|
347
351
|
- `categories`: list of categories with name, id, (later also type), in the order in which they are used by the model -- this is neccessary to be robust about renamings
|
|
352
|
+
- `conf_threshold` (optional): the minimum confidence the trainer chose for this model's detections
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
learning_loop_node/__init__.py,sha256=LzNoXBrcL6yJTyisuwJokZ9LTiYQwY3yRY3tmy83FlI,410
|
|
2
|
-
learning_loop_node/data_exchanger.py,sha256=
|
|
2
|
+
learning_loop_node/data_exchanger.py,sha256=Rlql4WSDgF6uUivH77deoIl2vnTKL1rlEhQmAUmJ9IU,10642
|
|
3
3
|
learning_loop_node/globals.py,sha256=a50Qg4z2205Zcw4Cs8z417kn1x8s78o2FJfZB9-ZZI4,211
|
|
4
4
|
learning_loop_node/loop_communication.py,sha256=d1tJZ9T_y22FrEq1XzvidgH6d-byGxFq3ovLvPBS4FA,7611
|
|
5
5
|
learning_loop_node/node.py,sha256=CABqe-sqUlcwi9CYVXBkrfxh0Xgtzj3AFLQcupSX-Ms,12158
|
|
@@ -12,29 +12,29 @@ learning_loop_node/data_classes/__init__.py,sha256=_2dxfVkkI9kNM955Y7ZE8RLgfpegZ
|
|
|
12
12
|
learning_loop_node/data_classes/annotation_data.py,sha256=NfMlTv2_5AfVY_JDM4tbjETFjSN2S2I2LJJPMMcDT50,966
|
|
13
13
|
learning_loop_node/data_classes/annotations.py,sha256=ha7uuWmZqW-LU2vYrkvfnaVpvf5C2xdm826IVVBu0w0,1848
|
|
14
14
|
learning_loop_node/data_classes/detections.py,sha256=hGSKc1elk4Drp4XUjQ3F9HGeRpyLvA0q064b-S9lIug,5335
|
|
15
|
-
learning_loop_node/data_classes/general.py,sha256=
|
|
15
|
+
learning_loop_node/data_classes/general.py,sha256=LTRtA_sPUKbXkNrka01_o8qwRN6r56tQJCSHPNdg_MI,7522
|
|
16
16
|
learning_loop_node/data_classes/image_metadata.py,sha256=QPLJ4gcVj33xaaCFF1n5e8G6higOJ6iIKy3YhGZMfkw,2459
|
|
17
17
|
learning_loop_node/data_classes/socket_response.py,sha256=tIdt-oYf6ULoJIDYQCecNM9OtWR6_wJ9tL0Ksu83Vko,655
|
|
18
|
-
learning_loop_node/data_classes/training.py,sha256=
|
|
18
|
+
learning_loop_node/data_classes/training.py,sha256=OlbPhm5kYHaZqgpz4_DfWnW4DQ_DK91AurReDhvWpRo,5944
|
|
19
19
|
learning_loop_node/detector/__init__.py,sha256=wroy0W1X5MX16C-peA-UHYrcdXet7GhleIlC_Pqa6hI,128
|
|
20
20
|
learning_loop_node/detector/categories.py,sha256=qgquuop3geKXXsNBurO0eC2b64h_ojL37RBYMv0ZzOg,1147
|
|
21
21
|
learning_loop_node/detector/detector_logic.py,sha256=XJaHNiw5PaHoCWnJ-qsxdUELLevvztYx54TQ8KcHl18,1479
|
|
22
|
-
learning_loop_node/detector/detector_node.py,sha256=
|
|
22
|
+
learning_loop_node/detector/detector_node.py,sha256=3e6UU1fl6IVkEgcfJTiOE-W30yebXcd5IpPOBYWV2mM,36950
|
|
23
23
|
learning_loop_node/detector/exceptions.py,sha256=C6KbNPlSbtfgDrZx2Hbhm7Suk9jVoR3fMRCO0CkrMsQ,196
|
|
24
24
|
learning_loop_node/detector/geometry.py,sha256=bZCF9xuK7qy6Ipv5aQYrTlwSmjaiUz5VEUYnc1zCe-w,1101
|
|
25
|
-
learning_loop_node/detector/outbox.py,sha256=
|
|
25
|
+
learning_loop_node/detector/outbox.py,sha256=mtMxdcComjRyM6qc-O1fBv9ABCau4-oCCV442hMaryQ,12351
|
|
26
26
|
learning_loop_node/detector/postprocess.py,sha256=fPBbgE6Obkej709zoFPGqA2gnbJNbpbMuZogilKy4pI,7274
|
|
27
27
|
learning_loop_node/detector/inbox_filter/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
28
28
|
learning_loop_node/detector/inbox_filter/cam_observation_history.py,sha256=slPGm87TbBCfDUdpRTAM5yOz1An8Br5X_2HFJOGSrFg,3540
|
|
29
|
-
learning_loop_node/detector/inbox_filter/relevance_filter.py,sha256=
|
|
29
|
+
learning_loop_node/detector/inbox_filter/relevance_filter.py,sha256=kFwXIEE6ddkJ6qq8vfTLMAUP7FayOGgyrNHBhLtxVD0,1535
|
|
30
30
|
learning_loop_node/detector/rest/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
31
31
|
learning_loop_node/detector/rest/about.py,sha256=evHJ2svUZY_DFz0FSef5u9c5KW4Uc3GL7EbPinG9-dg,583
|
|
32
32
|
learning_loop_node/detector/rest/backdoor_controls.py,sha256=ZNaFOvC0OLWNtcLiG-NIqS_y1kkLP4csgk3CHhp8Gis,885
|
|
33
|
-
learning_loop_node/detector/rest/detect.py,sha256=
|
|
33
|
+
learning_loop_node/detector/rest/detect.py,sha256=xr6VVCiH-wzvwTzTVAnzBR0vc_gTPwGL9OSSK2PJiOI,2518
|
|
34
34
|
learning_loop_node/detector/rest/model_version_control.py,sha256=P4FOG0U9HT6QtCoNt-1s1pT6drtgdVjGZWEuCAyuNmA,1370
|
|
35
35
|
learning_loop_node/detector/rest/operation_mode.py,sha256=eHOqbPdyfgzLtlz8sYs3WjESQ_kvxEIEP-cMuDQnsDI,1562
|
|
36
36
|
learning_loop_node/detector/rest/outbox_mode.py,sha256=H8coDNbgLGEfXmKQrhtXWeUHBAHpnrdZktuHXQz0xis,1148
|
|
37
|
-
learning_loop_node/detector/rest/upload.py,sha256=
|
|
37
|
+
learning_loop_node/detector/rest/upload.py,sha256=qNXTLXY2b4A7Wu2GDhKU9bkfF8-QyFd-zdqWld3Jj0o,1483
|
|
38
38
|
learning_loop_node/enums/__init__.py,sha256=tjSrhztIQ8W656_QuXfTbbVNtH_wDXP5hpYZgzfgRhc,285
|
|
39
39
|
learning_loop_node/enums/annotator.py,sha256=mtTAw-8LJIrHcYkBjYHCZuhYEEHS6QzSK8k6BhLusvQ,285
|
|
40
40
|
learning_loop_node/enums/detector.py,sha256=Qvm5LWWR9BfsDxHEQ8YzaPaUuSmp4BescYuV4X4ikwE,512
|
|
@@ -46,25 +46,26 @@ learning_loop_node/helpers/entrypoint.py,sha256=U4vHFqgke6mXjDL-E4MND8RO7P1k2VBG
|
|
|
46
46
|
learning_loop_node/helpers/environment_reader.py,sha256=zsoFysxNxBCOJzUwX4Pbm3ftYhxx5zsbOHQfD9VcfvA,1937
|
|
47
47
|
learning_loop_node/helpers/gdrive_downloader.py,sha256=zeYJciTAJVRpu_eFjwgYLCpIa6hU1d71anqEBb564Rk,1145
|
|
48
48
|
learning_loop_node/helpers/log_conf.py,sha256=hqVAa_9NnYEU6N0dcOKmph82p7MpgKqeF_eomTLYzWY,961
|
|
49
|
-
learning_loop_node/helpers/misc.py,sha256=
|
|
49
|
+
learning_loop_node/helpers/misc.py,sha256=ury0HOuUxDwdYNxmiDuaWOw77lUXv0DwqQhrHqoliY4,10158
|
|
50
50
|
learning_loop_node/helpers/run.py,sha256=_uox-j3_K_bL3yCAwy3JYSOiIxrnhzVxyxWpCe8_J9U,876
|
|
51
51
|
learning_loop_node/trainer/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
52
52
|
learning_loop_node/trainer/batch_size.py,sha256=LjA-zyY4hdnSMy6ZsdQOhWbmd5H_DI-9LG3yFInv04E,5476
|
|
53
53
|
learning_loop_node/trainer/cuda.py,sha256=ehpfWqPBCVFu06SnGsjmWbSDRJ8sZc-L_nCtemRLUOU,12567
|
|
54
54
|
learning_loop_node/trainer/downloader.py,sha256=AIyEKM4XYtd6VgmXrP3VayV9DpJzdURK1Brx81ePNSM,1470
|
|
55
|
-
learning_loop_node/trainer/exceptions.py,sha256=
|
|
55
|
+
learning_loop_node/trainer/exceptions.py,sha256=uCqGzni5CO8R8pHnVjsucuM4S00UxY35qxJaWIaqqjk,1186
|
|
56
56
|
learning_loop_node/trainer/executor.py,sha256=XQ1TwgOF2pQv7bgzSaljDWG4I1ySjYXL8r9c_p9MX1Q,3947
|
|
57
|
-
learning_loop_node/trainer/io_helpers.py,sha256=
|
|
57
|
+
learning_loop_node/trainer/io_helpers.py,sha256=fmSh2-9sEfBavYxfa_zT4FfZMByT3KxDbA3O57I8iBY,7834
|
|
58
58
|
learning_loop_node/trainer/metrics.py,sha256=H1jQh5fHN3-cTdMTuH8mF_uODV0T9DBdqcPUD-ZqFAI,1439
|
|
59
59
|
learning_loop_node/trainer/subprocess.py,sha256=WxuTly0zhpClEjTd9wqoI_-7YTNMaiZpRHxu00vttKw,2647
|
|
60
60
|
learning_loop_node/trainer/test_executor.py,sha256=6BVGDN_6f5GEMMEvDLSG1yzMybSvgXaP5uYpSfsVPP0,2224
|
|
61
|
-
learning_loop_node/trainer/trainer_logic.py,sha256=
|
|
62
|
-
learning_loop_node/trainer/trainer_logic_generic.py,sha256=
|
|
61
|
+
learning_loop_node/trainer/trainer_logic.py,sha256=x15bEy7uTfaGGHGYRGUl2olMeL0x0QW0vlu8nXZOaGg,9769
|
|
62
|
+
learning_loop_node/trainer/trainer_logic_generic.py,sha256=j_SBUejnZZqxvwzrNzwS1XifscaKwxlbnMJczzIETdA,31154
|
|
63
63
|
learning_loop_node/trainer/trainer_node.py,sha256=GU4LyFA-JC9s4d-PbyKhKWyGMMIzTMqKJFNxM6tDJE4,5316
|
|
64
64
|
learning_loop_node/trainer/training_slot.py,sha256=Njq6Z0T2Lqlr24ftoIp11Pw2OxCo9Z2RGn1tuFhHqLY,6737
|
|
65
|
+
learning_loop_node/trainer/validation_set.py,sha256=3gyqw88jadYRYDTUZVsaE0o5LGShmqaJykjr0sdNLJo,2907
|
|
65
66
|
learning_loop_node/trainer/rest/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
66
|
-
learning_loop_node/trainer/rest/backdoor_controls.py,sha256=
|
|
67
|
-
learning_loop_node-0.
|
|
68
|
-
learning_loop_node-0.
|
|
69
|
-
learning_loop_node-0.
|
|
70
|
-
learning_loop_node-0.
|
|
67
|
+
learning_loop_node/trainer/rest/backdoor_controls.py,sha256=bCjrQvqtgd2DtyYNgftkcDe9AFHExFLtfdW_jISLwuk,5026
|
|
68
|
+
learning_loop_node-0.27.0.dist-info/METADATA,sha256=6U988q8DyrBZ1fVd6Dcoqm3wWQuXglUbQ-jiORrbsuE,20317
|
|
69
|
+
learning_loop_node-0.27.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
70
|
+
learning_loop_node-0.27.0.dist-info/top_level.txt,sha256=nS5qiFc4stVF_VXVCVJxZnrqVOv3IGX5-_E3sLby6OE,19
|
|
71
|
+
learning_loop_node-0.27.0.dist-info/RECORD,,
|
|
File without changes
|