learning-loop-node 0.25.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.
@@ -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 does not return status code 200.
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
- response = await self.loop_communicator.put(f'/{context.organization}/projects/{context.project}/trainings/{training_number}/models/latest/{mformat}/file', files=files)
181
- if response.status_code != 200:
182
- logging.error('Could not upload model for training %s, format %s: %s',
183
- training_number, mformat, response.text)
184
- raise CriticalError(
185
- f'Could not upload model for training {training_number}, format {mformat}: {response.text}')
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
- logging.info('Uploaded model for training %s, format %s. Response is: %s',
189
- training_number, mformat, uploaded_model)
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 numpy_image_from_dict
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
- - bytes: bytes of the ndarray
234
- - dtype: data type of the ndarray
235
- - shape: shape of the ndarray
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 = numpy_image_from_dict(data['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 = [numpy_image_from_dict(image) for image in images_data]
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
- - bytes: bytes of the ndarray (retrieved via `ndarray.tobytes(order='C')`)
340
- - dtype: data type of the ndarray as string (e.g. `uint8`, `float32`, etc.)
341
- - shape: shape of the ndarray as tuple of ints (e.g. `(480, 640, 3)`)
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 = numpy_image_from_dict(data['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: np.ndarray,
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, image)
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: List[np.ndarray],
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, images)
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: List[np.ndarray],
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: np.ndarray) -> List[str]:
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: np.ndarray,
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=jpg_bytes_to_numpy_array(file_bytes),
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
- files_bytes = [await file.read() for file in files]
33
- image_metadatas = []
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
- """Convert jpg bytes to numpy array."""
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
- """Convert jpg bytes to numpy array."""
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
- - bytes: bytes of the image in C order (row-major)
243
- - dtype: data type of the pixel values
244
- - shape: shape of the image (height, width, channels)
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]:
@@ -33,9 +33,17 @@ from .training_slot import TrainingSlot, training_slot_from_env
33
33
  if TYPE_CHECKING:
34
34
  from .trainer_node import TrainerNode
35
35
 
36
+ BASE_RETRY_DELAY = 5 # seconds before the first retry of a failed state
37
+ MAX_RETRY_DELAY = 300 # seconds
38
+
36
39
  logger = logging.getLogger('learning_loop_node.trainer_logic_generic')
37
40
 
38
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
+
39
47
  class TrainerLogicGeneric(ABC):
40
48
 
41
49
  def __init__(self, model_format: str, training_slot: TrainingSlot | None = None) -> None:
@@ -45,6 +53,7 @@ class TrainerLogicGeneric(ABC):
45
53
  # '/{context.organization}/projects/{context.project}/models/{model_id}/{model_format}/file'
46
54
  self.model_format: str = model_format
47
55
  self.errors = Errors()
56
+ self._state_failures: Dict[str, int] = {} # failed attempts in a row, by error key
48
57
 
49
58
  self.training_task: Optional[asyncio.Task] = None
50
59
  self.shutdown_event: asyncio.Event = asyncio.Event()
@@ -264,6 +273,7 @@ class TrainerLogicGeneric(ABC):
264
273
  Either via `begin_training` or `try_continue_run_if_incomplete`.
265
274
  """
266
275
  self.errors.reset_all()
276
+ self._state_failures.clear()
267
277
  try:
268
278
  await self.training_slot.wait_for(self._slot_claim(self.training.context, self.training.training_number))
269
279
  await self._training_loop()
@@ -321,8 +331,11 @@ class TrainerLogicGeneric(ABC):
321
331
  - If the loop sends a StopTraining event, this will raise a CancelledError.
322
332
  - States can raise a CriticalError indicating that there is no point in retrying the state.
323
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.
324
335
  '''
325
336
 
337
+ if failures := self._state_failures.get(error_key):
338
+ await asyncio.sleep(_retry_delay(failures))
326
339
  await asyncio.sleep(0.1)
327
340
  logger.info('Performing state: %s', state_during)
328
341
  previous_state = self.training.training_state
@@ -351,6 +364,7 @@ class TrainerLogicGeneric(ABC):
351
364
  print('Error in %s - Exception: %s', state_during, e, flush=True)
352
365
  self.errors.set(error_key, str(e))
353
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
354
368
  self.training.training_state = previous_state
355
369
  return
356
370
  else:
@@ -359,6 +373,7 @@ class TrainerLogicGeneric(ABC):
359
373
  self.errors.reset(error_key)
360
374
  self.training.training_state = state_after
361
375
 
376
+ self._state_failures.pop(error_key, None)
362
377
  self.last_training_io.save(self.training)
363
378
 
364
379
  async def _prepare(self) -> None:
@@ -440,10 +455,13 @@ class TrainerLogicGeneric(ABC):
440
455
  Note that with the latest trainers the conversion to (.wts) is done by the trainer.
441
456
  The conversion from .wts to .engine is done by the detector (needs to be done on target hardware).
442
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.
443
460
 
444
461
  :return: The new model UUID.
445
- :raise CriticalError: If the latest model files cannot be obtained.
446
- :raise Exception: If the response for the model upload attempt is invalid.
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.
447
465
  """
448
466
 
449
467
  files = await self._get_latest_model_files()
@@ -456,20 +474,21 @@ class TrainerLogicGeneric(ABC):
456
474
 
457
475
  already_uploaded_formats = self.active_training_io.load_model_upload_progress()
458
476
 
459
- model_uuid = None
460
477
  for file_format in [f for f in files if f not in already_uploaded_formats]:
461
478
  _files = files[file_format] + [self._dump_categories_to_json()]
462
479
  assert len([f for f in _files if 'model.json' in f]) == 1, "model.json must be included exactly once"
463
480
 
464
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)
465
484
 
466
485
  already_uploaded_formats.append(file_format)
467
486
  self.active_training_io.save_model_upload_progress(already_uploaded_formats)
468
487
 
469
- if not model_uuid:
470
- raise Exception('Invalid response for model upload attempt')
471
-
472
- return model_uuid
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
473
492
 
474
493
  def _dump_categories_to_json(self) -> str:
475
494
  """Dumps the categories to a json file and returns the path to the file.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: learning_loop_node
3
- Version: 0.25.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
@@ -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
- - `bytes`: bytes of the ndarray (retrieved via `ndarray.tobytes(order='C')`)
118
- - `dtype`: data type of the ndarray as string (e.g. `uint8`, `float32`, etc.)
119
- - `shape`: shape of the ndarray as tuple of ints (e.g. `(480, 640, 3)`)
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
- - `bytes`: bytes of the ndarray (retrieved via `ndarray.tobytes(order='C')`)
148
- - `dtype`: data type of the ndarray as string (e.g. `uint8`, `float32`, etc.)
149
- - `shape`: shape of the ndarray as tuple of ints (e.g. `(480, 640, 3)`)
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
 
@@ -1,5 +1,5 @@
1
1
  learning_loop_node/__init__.py,sha256=LzNoXBrcL6yJTyisuwJokZ9LTiYQwY3yRY3tmy83FlI,410
2
- learning_loop_node/data_exchanger.py,sha256=nd9JNPLn9amIeTcSIyUPpbE97ORAcb5yNphvmpgWSUQ,9095
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,22 +19,22 @@ 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=GC5CaGcW_qVxyolDEz9U5W7y0ejZGEu9FZ__ckLxd7g,35728
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=HT0GHv6rsQzdbCD8DAT23m1-Q1XEvA_KgwAJLONyX_0,12506
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=81XQV6HST2hSH-In7VF8B4BfubgUdRHTLcc16VLjUxc,1518
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=BHcQbSuC1sYHRKl2wNchiasb3A4HJk9tVia11e9TS6Y,2597
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=MditQ4Tu9PRzW6vhO-I9TJcnjOFpD83XnHx-6uUURkU,1708
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,25 @@ 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=zfy1SumqNBwBw6-BYignQJpwMeMf1k_MA3xhYt_GiG4,8939
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=hhd23WvuqV-6fiJo3vCNLN0hmF6TxAH9C5Bc7pCtmUM,940
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=zWl0-HLP8zxOG3ybm76vXgEozULj_1QRl95jkrF126k,7255
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=OtjdZlixz-2UXZkG_o5PaCdXCFvwgGuD5J4I9n5bROY,28812
62
+ learning_loop_node/trainer/trainer_logic_generic.py,sha256=fNvH00Bnu6LfOFVJlCB3WyDccN9sC8tKnPAD-7uZoro,30033
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
65
  learning_loop_node/trainer/rest/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
66
66
  learning_loop_node/trainer/rest/backdoor_controls.py,sha256=ZnK8ypY5r_q0-YZbtaOxhQThzuZvMsQHM5gJGESd_dE,5131
67
- learning_loop_node-0.25.0.dist-info/METADATA,sha256=UOGaOXyynobyOFSEROGxOPH49odFh2-V2IGZ_LhQNsI,19855
68
- learning_loop_node-0.25.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
69
- learning_loop_node-0.25.0.dist-info/top_level.txt,sha256=nS5qiFc4stVF_VXVCVJxZnrqVOv3IGX5-_E3sLby6OE,19
70
- learning_loop_node-0.25.0.dist-info/RECORD,,
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,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (84.0.0)
2
+ Generator: setuptools (84.2.1)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5