learning-loop-node 0.24.0__py3-none-any.whl → 0.26.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_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/enums/trainer.py +2 -0
- learning_loop_node/helpers/misc.py +34 -9
- learning_loop_node/trainer/exceptions.py +18 -0
- learning_loop_node/trainer/io_helpers.py +12 -0
- learning_loop_node/trainer/trainer_logic_generic.py +61 -12
- learning_loop_node/trainer/trainer_node.py +9 -2
- learning_loop_node/trainer/training_slot.py +207 -0
- {learning_loop_node-0.24.0.dist-info → learning_loop_node-0.26.0.dist-info}/METADATA +26 -7
- {learning_loop_node-0.24.0.dist-info → learning_loop_node-0.26.0.dist-info}/RECORD +17 -16
- {learning_loop_node-0.24.0.dist-info → learning_loop_node-0.26.0.dist-info}/WHEEL +1 -1
- {learning_loop_node-0.24.0.dist-info → learning_loop_node-0.26.0.dist-info}/top_level.txt +0 -0
|
@@ -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,
|
|
@@ -4,7 +4,9 @@ from enum import Enum
|
|
|
4
4
|
|
|
5
5
|
class TrainerState(str, Enum):
|
|
6
6
|
Idle = 'idle'
|
|
7
|
+
Blocked = 'blocked' # idle, but unable to take a training
|
|
7
8
|
Initialized = 'initialized'
|
|
9
|
+
WaitingForSlot = 'waiting_for_slot'
|
|
8
10
|
Preparing = 'preparing'
|
|
9
11
|
DataDownloading = 'data_downloading'
|
|
10
12
|
DataDownloaded = 'data_downloaded'
|
|
@@ -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
|
|
@@ -150,7 +150,7 @@ def ensure_socket_response(func):
|
|
|
150
150
|
if isinstance(value, bool):
|
|
151
151
|
return asdict(SocketResponse.from_bool(value))
|
|
152
152
|
if isinstance(value, SocketResponse):
|
|
153
|
-
return value
|
|
153
|
+
return asdict(value)
|
|
154
154
|
if (args[0] in ['connect', 'disconnect', 'connect_error']):
|
|
155
155
|
return value
|
|
156
156
|
if value is None:
|
|
@@ -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,24 @@ 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
|
+
|
|
14
|
+
class TrainingRefusedError(Exception):
|
|
15
|
+
"""Raised when a trainer cannot take on a training it was asked to begin; nothing was set up."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TrainingSlotTakenError(TrainingRefusedError):
|
|
19
|
+
"""Raised when a sibling node holds the training slot."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, holder: str) -> None:
|
|
22
|
+
super().__init__(f'the training slot is held by {holder}')
|
|
23
|
+
self.holder = holder
|
|
24
|
+
|
|
25
|
+
|
|
8
26
|
class InsufficientMemoryError(RuntimeError):
|
|
9
27
|
"""Raised when not even the smallest unit of work fits in memory."""
|
|
10
28
|
|
|
@@ -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]:
|
|
@@ -26,24 +26,34 @@ from ..helpers.misc import (
|
|
|
26
26
|
is_valid_uuid4,
|
|
27
27
|
)
|
|
28
28
|
from .downloader import TrainingsDownloader
|
|
29
|
-
from .exceptions import CriticalError, NodeNeedsRestartError
|
|
29
|
+
from .exceptions import CriticalError, NodeNeedsRestartError, TrainingRefusedError
|
|
30
30
|
from .io_helpers import ActiveTrainingIO, EnvironmentVars, LastTrainingIO
|
|
31
|
+
from .training_slot import TrainingSlot, training_slot_from_env
|
|
31
32
|
|
|
32
33
|
if TYPE_CHECKING:
|
|
33
34
|
from .trainer_node import TrainerNode
|
|
34
35
|
|
|
36
|
+
BASE_RETRY_DELAY = 5 # seconds before the first retry of a failed state
|
|
37
|
+
MAX_RETRY_DELAY = 300 # seconds
|
|
38
|
+
|
|
35
39
|
logger = logging.getLogger('learning_loop_node.trainer_logic_generic')
|
|
36
40
|
|
|
37
41
|
|
|
42
|
+
def _retry_delay(failures: int) -> float:
|
|
43
|
+
"""The wait before the next attempt of a state that has failed `failures` times in a row."""
|
|
44
|
+
return min(BASE_RETRY_DELAY * 2 ** min(failures - 1, 16), MAX_RETRY_DELAY)
|
|
45
|
+
|
|
46
|
+
|
|
38
47
|
class TrainerLogicGeneric(ABC):
|
|
39
48
|
|
|
40
|
-
def __init__(self, model_format: str) -> None:
|
|
49
|
+
def __init__(self, model_format: str, training_slot: TrainingSlot | None = None) -> None:
|
|
41
50
|
|
|
42
51
|
# NOTE: model_format is used in the file path for the model on the server:
|
|
43
52
|
# It acts as a key for list of files (cf. _get_latest_model_files)
|
|
44
53
|
# '/{context.organization}/projects/{context.project}/models/{model_id}/{model_format}/file'
|
|
45
54
|
self.model_format: str = model_format
|
|
46
55
|
self.errors = Errors()
|
|
56
|
+
self._state_failures: Dict[str, int] = {} # failed attempts in a row, by error key
|
|
47
57
|
|
|
48
58
|
self.training_task: Optional[asyncio.Task] = None
|
|
49
59
|
self.shutdown_event: asyncio.Event = asyncio.Event()
|
|
@@ -54,6 +64,7 @@ class TrainerLogicGeneric(ABC):
|
|
|
54
64
|
self._training: Optional[Training] = None
|
|
55
65
|
self._active_training_io: Optional[ActiveTrainingIO] = None
|
|
56
66
|
self._environment_vars = EnvironmentVars()
|
|
67
|
+
self.training_slot = training_slot or training_slot_from_env()
|
|
57
68
|
|
|
58
69
|
# ---------------------------------------- PROPERTIES TO AVOID CHECKING FOR NONE ----------------------------------------
|
|
59
70
|
|
|
@@ -98,9 +109,17 @@ class TrainerLogicGeneric(ABC):
|
|
|
98
109
|
|
|
99
110
|
@property
|
|
100
111
|
def state(self) -> str:
|
|
101
|
-
"""Returns the current state of the training.
|
|
112
|
+
"""Returns the current state of the training.
|
|
113
|
+
|
|
114
|
+
Without a training the state is `idle`, or `blocked` while a sibling node holds the training slot.
|
|
115
|
+
A training resumed after a restart reports `waiting_for_slot` until it gets the slot: it has a
|
|
116
|
+
training, so it is never `blocked`.
|
|
102
117
|
"""
|
|
103
|
-
if
|
|
118
|
+
if not self.training_active:
|
|
119
|
+
return TrainerState.Blocked.value if self.training_slot.taken_by_sibling else TrainerState.Idle.value
|
|
120
|
+
if not self.training_slot.held:
|
|
121
|
+
return TrainerState.WaitingForSlot.value
|
|
122
|
+
if self.training.training_state is None:
|
|
104
123
|
return TrainerState.Idle.value
|
|
105
124
|
return self.training.training_state
|
|
106
125
|
|
|
@@ -213,8 +232,21 @@ class TrainerLogicGeneric(ABC):
|
|
|
213
232
|
|
|
214
233
|
async def begin_training(self, organization: str, project: str, training_config: Dict) -> None:
|
|
215
234
|
"""Called on `begin_training` event from the Learning Loop.
|
|
235
|
+
|
|
236
|
+
The training slot is taken before anything is set up, so a refused training leaves the
|
|
237
|
+
previous training folders in place.
|
|
238
|
+
|
|
239
|
+
:raise TrainingRefusedError: if a training is active or a sibling node holds the training slot.
|
|
216
240
|
"""
|
|
217
|
-
self.
|
|
241
|
+
if self.training_active:
|
|
242
|
+
raise TrainingRefusedError(f'training {self.training.id} is still active')
|
|
243
|
+
context = Context(organization=organization, project=project)
|
|
244
|
+
self.training_slot.claim(self._slot_claim(context, training_config.get('training_number')))
|
|
245
|
+
try:
|
|
246
|
+
self._init_new_training(context, training_config)
|
|
247
|
+
except Exception:
|
|
248
|
+
self.training_slot.release()
|
|
249
|
+
raise
|
|
218
250
|
self._begin_training_task()
|
|
219
251
|
|
|
220
252
|
def _begin_training_task(self) -> None:
|
|
@@ -241,7 +273,9 @@ class TrainerLogicGeneric(ABC):
|
|
|
241
273
|
Either via `begin_training` or `try_continue_run_if_incomplete`.
|
|
242
274
|
"""
|
|
243
275
|
self.errors.reset_all()
|
|
276
|
+
self._state_failures.clear()
|
|
244
277
|
try:
|
|
278
|
+
await self.training_slot.wait_for(self._slot_claim(self.training.context, self.training.training_number))
|
|
245
279
|
await self._training_loop()
|
|
246
280
|
except asyncio.CancelledError:
|
|
247
281
|
if not self.shutdown_event.is_set():
|
|
@@ -254,6 +288,12 @@ class TrainerLogicGeneric(ABC):
|
|
|
254
288
|
logger.info('CancelledError in _run - shutting down')
|
|
255
289
|
except Exception:
|
|
256
290
|
logger.exception('(Ignored) exception in trainer_logic._run:')
|
|
291
|
+
finally:
|
|
292
|
+
self._training = None
|
|
293
|
+
self.training_slot.release()
|
|
294
|
+
|
|
295
|
+
def _slot_claim(self, context: Context, training_number: Any) -> str:
|
|
296
|
+
return f'node {self.node.uuid}, training #{training_number} of {context.organization}/{context.project}'
|
|
257
297
|
|
|
258
298
|
# ---------------------------------------- TRAINING STATES ----------------------------------------
|
|
259
299
|
|
|
@@ -291,8 +331,11 @@ class TrainerLogicGeneric(ABC):
|
|
|
291
331
|
- If the loop sends a StopTraining event, this will raise a CancelledError.
|
|
292
332
|
- States can raise a CriticalError indicating that there is no point in retrying the state.
|
|
293
333
|
- If any other error occurs, the error is stored in the errors object and the state is reset to the previous state.
|
|
334
|
+
The state is retried after a wait until the training is aborted.
|
|
294
335
|
'''
|
|
295
336
|
|
|
337
|
+
if failures := self._state_failures.get(error_key):
|
|
338
|
+
await asyncio.sleep(_retry_delay(failures))
|
|
296
339
|
await asyncio.sleep(0.1)
|
|
297
340
|
logger.info('Performing state: %s', state_during)
|
|
298
341
|
previous_state = self.training.training_state
|
|
@@ -321,6 +364,7 @@ class TrainerLogicGeneric(ABC):
|
|
|
321
364
|
print('Error in %s - Exception: %s', state_during, e, flush=True)
|
|
322
365
|
self.errors.set(error_key, str(e))
|
|
323
366
|
logger.exception('Error in %s - Exception: %s', state_during, e)
|
|
367
|
+
self._state_failures[error_key] = self._state_failures.get(error_key, 0) + 1
|
|
324
368
|
self.training.training_state = previous_state
|
|
325
369
|
return
|
|
326
370
|
else:
|
|
@@ -329,6 +373,7 @@ class TrainerLogicGeneric(ABC):
|
|
|
329
373
|
self.errors.reset(error_key)
|
|
330
374
|
self.training.training_state = state_after
|
|
331
375
|
|
|
376
|
+
self._state_failures.pop(error_key, None)
|
|
332
377
|
self.last_training_io.save(self.training)
|
|
333
378
|
|
|
334
379
|
async def _prepare(self) -> None:
|
|
@@ -410,10 +455,13 @@ class TrainerLogicGeneric(ABC):
|
|
|
410
455
|
Note that with the latest trainers the conversion to (.wts) is done by the trainer.
|
|
411
456
|
The conversion from .wts to .engine is done by the detector (needs to be done on target hardware).
|
|
412
457
|
Note that trainer may train with different classes, which is why we send an initial model.json file.
|
|
458
|
+
A format the loop already stores counts as uploaded.
|
|
459
|
+
The model UUID is kept beside the upload progress, so a retry after a restart still knows it.
|
|
413
460
|
|
|
414
461
|
:return: The new model UUID.
|
|
415
|
-
:raise CriticalError: If the latest model files cannot be obtained
|
|
416
|
-
|
|
462
|
+
:raise CriticalError: If the latest model files cannot be obtained,
|
|
463
|
+
or the model is uploaded but its UUID is unknown.
|
|
464
|
+
:raise ModelUploadFailedError: If the loop could not store a model file; the state is retried.
|
|
417
465
|
"""
|
|
418
466
|
|
|
419
467
|
files = await self._get_latest_model_files()
|
|
@@ -426,20 +474,21 @@ class TrainerLogicGeneric(ABC):
|
|
|
426
474
|
|
|
427
475
|
already_uploaded_formats = self.active_training_io.load_model_upload_progress()
|
|
428
476
|
|
|
429
|
-
model_uuid = None
|
|
430
477
|
for file_format in [f for f in files if f not in already_uploaded_formats]:
|
|
431
478
|
_files = files[file_format] + [self._dump_categories_to_json()]
|
|
432
479
|
assert len([f for f in _files if 'model.json' in f]) == 1, "model.json must be included exactly once"
|
|
433
480
|
|
|
434
481
|
model_uuid = await self.node.data_exchanger.upload_model_get_uuid(context, _files, self.training.training_number, file_format)
|
|
482
|
+
if model_uuid is not None:
|
|
483
|
+
self.active_training_io.save_uploaded_model_uuid(model_uuid)
|
|
435
484
|
|
|
436
485
|
already_uploaded_formats.append(file_format)
|
|
437
486
|
self.active_training_io.save_model_upload_progress(already_uploaded_formats)
|
|
438
487
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
488
|
+
try:
|
|
489
|
+
return self.active_training_io.load_uploaded_model_uuid()
|
|
490
|
+
except FileNotFoundError as e:
|
|
491
|
+
raise CriticalError('The model is uploaded, but the loop never answered with its id') from e
|
|
443
492
|
|
|
444
493
|
def _dump_categories_to_json(self) -> str:
|
|
445
494
|
"""Dumps the categories to a json file and returns the path to the file.
|
|
@@ -7,7 +7,10 @@ from typing import Dict, Optional
|
|
|
7
7
|
from fastapi.encoders import jsonable_encoder
|
|
8
8
|
from socketio import AsyncClient, exceptions
|
|
9
9
|
|
|
10
|
+
from ..data_classes import SocketResponse
|
|
11
|
+
from ..enums import TrainerState
|
|
10
12
|
from ..node import Node
|
|
13
|
+
from .exceptions import TrainingRefusedError
|
|
11
14
|
from .io_helpers import LastTrainingIO
|
|
12
15
|
from .rest import backdoor_controls
|
|
13
16
|
from .trainer_logic_generic import TrainerLogicGeneric
|
|
@@ -64,7 +67,11 @@ class TrainerNode(Node):
|
|
|
64
67
|
@sio_client.event
|
|
65
68
|
async def begin_training(organization: str, project: str, details: Dict):
|
|
66
69
|
self.log.info('received begin_training from server')
|
|
67
|
-
|
|
70
|
+
try:
|
|
71
|
+
await self.trainer_logic.begin_training(organization, project, details)
|
|
72
|
+
except TrainingRefusedError as e:
|
|
73
|
+
self.log.warning('refused begin_training: %s', e)
|
|
74
|
+
return SocketResponse(success=False, error_msg=str(e), payload='refused')
|
|
68
75
|
return True
|
|
69
76
|
|
|
70
77
|
@sio_client.event
|
|
@@ -102,7 +109,7 @@ class TrainerNode(Node):
|
|
|
102
109
|
if not self._idle_timeout:
|
|
103
110
|
return
|
|
104
111
|
|
|
105
|
-
if self.trainer_logic.state
|
|
112
|
+
if self.trainer_logic.state in (TrainerState.Idle, TrainerState.Blocked):
|
|
106
113
|
if self._first_idle_time is None:
|
|
107
114
|
self._first_idle_time = time.time()
|
|
108
115
|
idle_time = time.time() - self._first_idle_time
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""The right to use the GPU, shared between the trainer nodes on one machine.
|
|
2
|
+
|
|
3
|
+
A node holds the slot from the moment it accepts a training until the training is cleared.
|
|
4
|
+
"""
|
|
5
|
+
import asyncio
|
|
6
|
+
import fcntl
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import socket
|
|
10
|
+
import weakref
|
|
11
|
+
from abc import ABC, abstractmethod
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from .exceptions import TrainingSlotTakenError
|
|
15
|
+
|
|
16
|
+
ENV_VAR = 'TRAINING_SLOT_LOCK'
|
|
17
|
+
_CLAIM_ATTEMPTS = 3
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger('learning_loop_node.training_slot')
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TrainingSlot(ABC):
|
|
23
|
+
"""The slot as a trainer uses it; an adapter implements `_acquire`, `_sibling_holder`, `release` and `held`."""
|
|
24
|
+
|
|
25
|
+
def claim(self, training: str) -> None:
|
|
26
|
+
"""Take the slot for `training` without waiting; a no-op if this slot already holds it.
|
|
27
|
+
|
|
28
|
+
:raise TrainingSlotTakenError: if a sibling node holds the slot.
|
|
29
|
+
"""
|
|
30
|
+
holder = self._describe(training)
|
|
31
|
+
for _ in range(_CLAIM_ATTEMPTS - 1):
|
|
32
|
+
try:
|
|
33
|
+
self._acquire(holder)
|
|
34
|
+
return
|
|
35
|
+
except TrainingSlotTakenError:
|
|
36
|
+
if self._sibling_holder() is not None:
|
|
37
|
+
raise
|
|
38
|
+
self._acquire(holder)
|
|
39
|
+
|
|
40
|
+
async def wait_for(self, training: str) -> None:
|
|
41
|
+
"""Take the slot for `training`, waiting as long as a sibling node holds it."""
|
|
42
|
+
try:
|
|
43
|
+
self.claim(training)
|
|
44
|
+
return
|
|
45
|
+
except TrainingSlotTakenError as e:
|
|
46
|
+
logger.info('%s - waiting', e)
|
|
47
|
+
while True:
|
|
48
|
+
await asyncio.sleep(1)
|
|
49
|
+
try:
|
|
50
|
+
self.claim(training)
|
|
51
|
+
except TrainingSlotTakenError:
|
|
52
|
+
continue
|
|
53
|
+
logger.info('acquired training slot')
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
@abstractmethod
|
|
57
|
+
def release(self) -> None:
|
|
58
|
+
"""Give the slot back; a no-op if this slot does not hold it."""
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
@abstractmethod
|
|
62
|
+
def held(self) -> bool:
|
|
63
|
+
"""Whether this slot object holds the slot."""
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def taken_by_sibling(self) -> bool:
|
|
67
|
+
return not self.held and self._sibling_holder() is not None
|
|
68
|
+
|
|
69
|
+
@abstractmethod
|
|
70
|
+
def _acquire(self, holder: str) -> None:
|
|
71
|
+
"""Take the slot without waiting; a no-op if this slot already holds it.
|
|
72
|
+
|
|
73
|
+
:raise TrainingSlotTakenError: if the slot is held elsewhere, including by a momentary probe.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
@abstractmethod
|
|
77
|
+
def _sibling_holder(self) -> str | None:
|
|
78
|
+
"""Who holds the slot, None if it is free or cannot be inspected; only asked while this slot does not hold it."""
|
|
79
|
+
|
|
80
|
+
@staticmethod
|
|
81
|
+
def _describe(training: str) -> str:
|
|
82
|
+
return f'pid {os.getpid()} on {socket.gethostname()}, {training}'
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class AlwaysFreeSlot(TrainingSlot):
|
|
86
|
+
"""The slot of a node that has the GPU to itself."""
|
|
87
|
+
|
|
88
|
+
def __init__(self) -> None:
|
|
89
|
+
self._held = False
|
|
90
|
+
|
|
91
|
+
def release(self) -> None:
|
|
92
|
+
self._held = False
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def held(self) -> bool:
|
|
96
|
+
return self._held
|
|
97
|
+
|
|
98
|
+
def _acquire(self, holder: str) -> None:
|
|
99
|
+
self._held = True
|
|
100
|
+
|
|
101
|
+
def _sibling_holder(self) -> str | None:
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class FileTrainingSlot(TrainingSlot):
|
|
106
|
+
"""A slot backed by an exclusive `flock` on a file every sibling node mounts.
|
|
107
|
+
|
|
108
|
+
The kernel drops the lock with the process, so a node that dies in any way frees the slot.
|
|
109
|
+
The lock belongs to the inode: the file is only ever truncated and rewritten in place, never
|
|
110
|
+
replaced, and the mounted path is its directory.
|
|
111
|
+
|
|
112
|
+
The file's content names the holder and is diagnostic only — `_sibling_holder` tells a free slot
|
|
113
|
+
from a taken one by trying the lock, and that probe holds the lock for an instant, so a
|
|
114
|
+
claim that loses against a probe tries again. The file is made writable for everyone,
|
|
115
|
+
since sibling containers may run as different users.
|
|
116
|
+
|
|
117
|
+
A forked child shares the parent's lock and would keep it alive after the parent releases it,
|
|
118
|
+
so the child closes its copy right after the fork; the parent's lock is unaffected.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
_instances: 'weakref.WeakSet[FileTrainingSlot]' = weakref.WeakSet()
|
|
122
|
+
|
|
123
|
+
def __init__(self, path: str | Path) -> None:
|
|
124
|
+
self.path = Path(path)
|
|
125
|
+
self._fd: int | None = None
|
|
126
|
+
FileTrainingSlot._instances.add(self)
|
|
127
|
+
|
|
128
|
+
def _acquire(self, holder: str) -> None:
|
|
129
|
+
if self._fd is not None:
|
|
130
|
+
return
|
|
131
|
+
fd = self._open()
|
|
132
|
+
try:
|
|
133
|
+
self._lock(fd)
|
|
134
|
+
os.ftruncate(fd, 0)
|
|
135
|
+
os.write(fd, holder.encode())
|
|
136
|
+
except BaseException:
|
|
137
|
+
os.close(fd)
|
|
138
|
+
raise
|
|
139
|
+
self._fd = fd
|
|
140
|
+
|
|
141
|
+
def release(self) -> None:
|
|
142
|
+
if self._fd is None:
|
|
143
|
+
return
|
|
144
|
+
try:
|
|
145
|
+
os.ftruncate(self._fd, 0)
|
|
146
|
+
except OSError:
|
|
147
|
+
logger.warning('cannot clear the holder from training slot %s', self.path, exc_info=True)
|
|
148
|
+
finally:
|
|
149
|
+
os.close(self._fd)
|
|
150
|
+
self._fd = None
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def held(self) -> bool:
|
|
154
|
+
return self._fd is not None
|
|
155
|
+
|
|
156
|
+
def _sibling_holder(self) -> str | None:
|
|
157
|
+
try:
|
|
158
|
+
fd = os.open(self.path, os.O_RDONLY)
|
|
159
|
+
except FileNotFoundError:
|
|
160
|
+
return None
|
|
161
|
+
except OSError:
|
|
162
|
+
logger.warning('cannot inspect training slot %s - treating it as free', self.path, exc_info=True)
|
|
163
|
+
return None
|
|
164
|
+
try:
|
|
165
|
+
self._lock(fd)
|
|
166
|
+
except TrainingSlotTakenError as e:
|
|
167
|
+
return e.holder
|
|
168
|
+
finally:
|
|
169
|
+
os.close(fd)
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
@classmethod
|
|
173
|
+
def _drop_inherited_locks(cls) -> None:
|
|
174
|
+
for slot in list(cls._instances):
|
|
175
|
+
if slot._fd is not None:
|
|
176
|
+
os.close(slot._fd)
|
|
177
|
+
slot._fd = None
|
|
178
|
+
|
|
179
|
+
def _open(self) -> int:
|
|
180
|
+
fd = os.open(self.path, os.O_RDWR | os.O_CREAT, 0o666)
|
|
181
|
+
try:
|
|
182
|
+
os.fchmod(fd, 0o666)
|
|
183
|
+
except PermissionError:
|
|
184
|
+
pass
|
|
185
|
+
return fd
|
|
186
|
+
|
|
187
|
+
@staticmethod
|
|
188
|
+
def _lock(fd: int) -> None:
|
|
189
|
+
try:
|
|
190
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
191
|
+
except BlockingIOError as e:
|
|
192
|
+
raise TrainingSlotTakenError(os.read(fd, 4096).decode(errors='replace') or 'unknown') from e
|
|
193
|
+
|
|
194
|
+
os.register_at_fork(after_in_child=FileTrainingSlot._drop_inherited_locks) # pylint: disable=protected-access
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def training_slot_from_env() -> TrainingSlot:
|
|
198
|
+
"""The slot `TRAINING_SLOT_LOCK` names, or a free one if the variable is unset.
|
|
199
|
+
|
|
200
|
+
:raise FileNotFoundError: if the lock file's directory does not exist.
|
|
201
|
+
"""
|
|
202
|
+
path = os.environ.get(ENV_VAR)
|
|
203
|
+
if not path:
|
|
204
|
+
return AlwaysFreeSlot()
|
|
205
|
+
if not Path(path).parent.is_dir():
|
|
206
|
+
raise FileNotFoundError(f'{ENV_VAR}={path}: the directory does not exist; mount it from the host')
|
|
207
|
+
return FileTrainingSlot(path)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: learning_loop_node
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.26.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
|
|
@@ -67,6 +67,7 @@ You can configure connection to our Learning Loop by specifying the following en
|
|
|
67
67
|
| RESTART_AFTER_TRAINING | - | Restart the trainer after training (set to 1) | Trainer (opt.) | 0 |
|
|
68
68
|
| KEEP_OLD_TRAININGS | - | Do not delete old trainings (set to 1) | Trainer (opt.) | 0 |
|
|
69
69
|
| TRAINER_IDLE_TIMEOUT_SEC | - | Automatically shutdown trainer after timeout (in seconds) | Trainer (opt.) | 0 (disabled) |
|
|
70
|
+
| TRAINING_SLOT_LOCK | - | Lock file shared by the trainers on one GPU (see Trainer Node) | Trainer (opt.) | - (disabled) |
|
|
70
71
|
| USE_BACKDOOR_CONTROLS | - | Always enable backdoor controls (set to 1) | Trainer / Detector (opt.) | 0 |
|
|
71
72
|
|
|
72
73
|
Note that organization and project IDs are always lower case and may differ from the names in the Learning Loop which can have uppercase letters.
|
|
@@ -113,9 +114,10 @@ Example usage:
|
|
|
113
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:
|
|
114
115
|
|
|
115
116
|
- `image`: The image data as dictionary with the following keys:
|
|
116
|
-
- `
|
|
117
|
-
- `
|
|
118
|
-
- `
|
|
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)
|
|
119
121
|
- `camera_id`: optional camera identifier (string) used to improve the autoupload filtering
|
|
120
122
|
- `tags`: optional list of tags to add to the image in the learning loop
|
|
121
123
|
- `source`: optional source string
|
|
@@ -126,6 +128,8 @@ The `batch_detect` endpoint receives a dictionary, with the same entries as the
|
|
|
126
128
|
|
|
127
129
|
- `images`: List of image data dictionaries, each with the same structure as the `image` entry in the `detect` endpoint
|
|
128
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
|
+
|
|
129
133
|
Example code can be found [in the rosys implementation](https://github.com/zauberzeug/rosys/blob/main/rosys/vision/detector_hardware.py).
|
|
130
134
|
|
|
131
135
|
### Upload API
|
|
@@ -143,9 +147,10 @@ Example:
|
|
|
143
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:
|
|
144
148
|
|
|
145
149
|
- `image`: the image data as dictionary with the following keys:
|
|
146
|
-
- `
|
|
147
|
-
- `
|
|
148
|
-
- `
|
|
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)
|
|
149
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.
|
|
150
155
|
- `upload_priority`: Optional boolean flag to prioritize the upload (defaults to False)
|
|
151
156
|
|
|
@@ -250,6 +255,20 @@ Trainers fetch the images and anntoations from the Learning Loop to train new mo
|
|
|
250
255
|
|
|
251
256
|
- if the command line tool "jpeginfo" is installed, the downloader will drop corrupted images automatically
|
|
252
257
|
|
|
258
|
+
### Several trainers on one GPU
|
|
259
|
+
|
|
260
|
+
Trainers that share a GPU (say, one per model architecture on the same machine) coordinate through
|
|
261
|
+
a lock file: mount one host directory into each of them and point `TRAINING_SLOT_LOCK` at a file
|
|
262
|
+
inside it, e.g. `/slot/training.lock`. A trainer takes the lock when it accepts a training and holds
|
|
263
|
+
it until the training is cleaned up, including detection and upload. While a sibling holds it, the
|
|
264
|
+
others report the state `blocked` instead of `idle`, so the Learning Loop neither offers them for a
|
|
265
|
+
training nor dispatches queued jobs to them; a trainer that is sent `begin_training` regardless
|
|
266
|
+
refuses it with `{"success": false, "error_msg": ...}` in the acknowledgement and sets nothing up. A
|
|
267
|
+
training resumed after a restart cannot be refused — it waits for the lock instead, reporting
|
|
268
|
+
`waiting_for_slot` meanwhile. The lock is an `flock`, so the kernel releases it whenever the holding
|
|
269
|
+
process ends, however it ends — there is nothing to clean up after a crash. Mount the directory,
|
|
270
|
+
not the file, and never replace the file: the lock belongs to its inode.
|
|
271
|
+
|
|
253
272
|
## Converter Node
|
|
254
273
|
|
|
255
274
|
A Conveter Node converts models from one format into another.
|
|
@@ -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
|
|
@@ -19,51 +19,52 @@ learning_loop_node/data_classes/training.py,sha256=TybwcCDf_NUaDUaOj30lPm-7Z3Qk9
|
|
|
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
|
|
41
41
|
learning_loop_node/enums/general.py,sha256=OSpcIpdFjuRByON2xBoiIXl1SgDREIqkwnaxkTFjnHU,164
|
|
42
|
-
learning_loop_node/enums/trainer.py,sha256=
|
|
42
|
+
learning_loop_node/enums/trainer.py,sha256=VdamD-k6Q8kMm_cwPA8iaEUfZvmTxLfsBHQpEi43Ahk,849
|
|
43
43
|
learning_loop_node/helpers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
44
44
|
learning_loop_node/helpers/background_tasks.py,sha256=sbbS8JO1ls0ZCbNW68KOT9QLOFyFoJRXwwAq78iOmoA,3256
|
|
45
45
|
learning_loop_node/helpers/entrypoint.py,sha256=U4vHFqgke6mXjDL-E4MND8RO7P1k2VBGwdDOOoJvD9Y,3915
|
|
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
61
|
learning_loop_node/trainer/trainer_logic.py,sha256=Yaw1GNTC05rVLQczY6vKUgkymBYfm3_0XaR5C0q-CP4,9159
|
|
62
|
-
learning_loop_node/trainer/trainer_logic_generic.py,sha256=
|
|
63
|
-
learning_loop_node/trainer/trainer_node.py,sha256=
|
|
62
|
+
learning_loop_node/trainer/trainer_logic_generic.py,sha256=fNvH00Bnu6LfOFVJlCB3WyDccN9sC8tKnPAD-7uZoro,30033
|
|
63
|
+
learning_loop_node/trainer/trainer_node.py,sha256=GU4LyFA-JC9s4d-PbyKhKWyGMMIzTMqKJFNxM6tDJE4,5316
|
|
64
|
+
learning_loop_node/trainer/training_slot.py,sha256=Njq6Z0T2Lqlr24ftoIp11Pw2OxCo9Z2RGn1tuFhHqLY,6737
|
|
64
65
|
learning_loop_node/trainer/rest/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
65
66
|
learning_loop_node/trainer/rest/backdoor_controls.py,sha256=ZnK8ypY5r_q0-YZbtaOxhQThzuZvMsQHM5gJGESd_dE,5131
|
|
66
|
-
learning_loop_node-0.
|
|
67
|
-
learning_loop_node-0.
|
|
68
|
-
learning_loop_node-0.
|
|
69
|
-
learning_loop_node-0.
|
|
67
|
+
learning_loop_node-0.26.0.dist-info/METADATA,sha256=F-zF0LzeauG_ngUiRE8gDZO7qkY-gbdxjM7J9YMZe3g,20215
|
|
68
|
+
learning_loop_node-0.26.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
69
|
+
learning_loop_node-0.26.0.dist-info/top_level.txt,sha256=nS5qiFc4stVF_VXVCVJxZnrqVOv3IGX5-_E3sLby6OE,19
|
|
70
|
+
learning_loop_node-0.26.0.dist-info/RECORD,,
|
|
File without changes
|