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.
@@ -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,
@@ -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
- """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,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. Used solely by the node in send_status().
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 (not self.training_active) or (self.training.training_state is None):
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._init_new_training(Context(organization=organization, project=project), training_config)
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
- :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.
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
- if not model_uuid:
440
- raise Exception('Invalid response for model upload attempt')
441
-
442
- 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
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
- await self.trainer_logic.begin_training(organization, project, details)
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 == 'idle':
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.24.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
- - `bytes`: bytes of the ndarray (retrieved via `ndarray.tobytes(order='C')`)
117
- - `dtype`: data type of the ndarray as string (e.g. `uint8`, `float32`, etc.)
118
- - `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)
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
- - `bytes`: bytes of the ndarray (retrieved via `ndarray.tobytes(order='C')`)
147
- - `dtype`: data type of the ndarray as string (e.g. `uint8`, `float32`, etc.)
148
- - `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)
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=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,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=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
41
41
  learning_loop_node/enums/general.py,sha256=OSpcIpdFjuRByON2xBoiIXl1SgDREIqkwnaxkTFjnHU,164
42
- learning_loop_node/enums/trainer.py,sha256=VaD63guLO4aKgVfXT0EryPlXKQGegSET3Cp4R0uC_Ew,746
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=qdN18Ukbw6eR6sFL4BxNq0EZzg2tVm2Cm8cTbFyy8QU,8931
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=IRT7qvn7-RjXaqL8mF21ySm7JWhQtqm1Cl0Gi3DChVY,540
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=IRP68ZGpnOX4OS03OwB7HmTUvk6nbBRmCjMCVupGE_Q,27196
63
- learning_loop_node/trainer/trainer_node.py,sha256=ZnydfHJ2ehAhb821Xke_09qthCE3vDf7iqeYUrliSSo,4938
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.24.0.dist-info/METADATA,sha256=rQNtas26mLdkkjI37mJ_jteRe7RtUO_3DpReanEUC2U,18609
67
- learning_loop_node-0.24.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
68
- learning_loop_node-0.24.0.dist-info/top_level.txt,sha256=nS5qiFc4stVF_VXVCVJxZnrqVOv3IGX5-_E3sLby6OE,19
69
- learning_loop_node-0.24.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