learning-loop-node 0.28.0__py3-none-any.whl → 0.29.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.
@@ -26,7 +26,7 @@ from .general import (
26
26
  NodeState,
27
27
  NodeStatus,
28
28
  )
29
- from .image_metadata import ImageMetadata, ImagesMetadata
29
+ from .image_metadata import CaptureMetadata, ImageMetadata, ImagesMetadata
30
30
  from .socket_response import SocketResponse
31
31
  from .training import (
32
32
  Errors,
@@ -44,7 +44,7 @@ __all__ = [
44
44
  'BoxDetection', 'ClassificationDetection', 'ImageMetadata', 'Observation', 'Point', 'PointDetection',
45
45
  'SegmentationDetection', 'Shape', 'Detections',
46
46
  'AnnotationNodeStatus', 'Category', 'Context', 'DetectorStatus', 'ErrorConfiguration',
47
- 'ModelInformation', 'NodeState', 'NodeStatus', 'ModelVersionResponse', 'ImagesMetadata',
47
+ 'ModelInformation', 'NodeState', 'NodeStatus', 'ModelVersionResponse', 'ImagesMetadata', 'CaptureMetadata',
48
48
  'SocketResponse',
49
49
  'Errors', 'Hyperparameter', 'PretrainedModel', 'Training',
50
50
  'TrainingError', 'TrainingOut', 'TrainingStateData', 'TrainingStatus',
@@ -56,3 +56,16 @@ class ImageMetadata():
56
56
  @dataclass(**KWONLY_SLOTS)
57
57
  class ImagesMetadata():
58
58
  items: List[ImageMetadata] = field(default_factory=list, metadata={'description': 'List of image metadata'})
59
+
60
+
61
+ @dataclass(**KWONLY_SLOTS)
62
+ class CaptureMetadata:
63
+ """Where an image comes from and how it is tagged."""
64
+ camera_id: str | None = field(default=None, metadata={
65
+ 'description': 'Camera the image was taken with; selects the history of the relevance filter'})
66
+ tags: list[str] = field(default_factory=list, metadata={
67
+ 'description': 'Tags of the image'})
68
+ source: str | None = field(default=None, metadata={
69
+ 'description': 'Source of the image'})
70
+ creation_date: str | None = field(default=None, metadata={
71
+ 'description': 'Creation date of the image'})
@@ -14,7 +14,12 @@ import aiofiles # type: ignore
14
14
  from .data_classes import Context
15
15
  from .helpers.misc import create_resource_paths, create_task, is_valid_image, is_valid_uuid4
16
16
  from .loop_communication import LoopCommunicator
17
- from .trainer.exceptions import CriticalError, ModelUploadFailedError
17
+ from .trainer.exceptions import (
18
+ CriticalError,
19
+ ModelUploadFailedError,
20
+ TrainingResultsNotAnsweredError,
21
+ TrainingResultsUploadFailedError,
22
+ )
18
23
 
19
24
  logger = logging.getLogger(__name__)
20
25
 
@@ -207,6 +212,23 @@ class DataExchanger():
207
212
  training_number, mformat, uploaded_model)
208
213
  return uploaded_model['id']
209
214
 
215
+ async def upload_training_results(self, context: Context, training_number: int, part: str, zip_path: str) -> None:
216
+ """Store one part of a training's results beside its latest model; a part sent again replaces the stored one.
217
+
218
+ :raise TrainingResultsUploadFailedError: If the loop answered with a client error other than 408.
219
+ :raise TrainingResultsNotAnsweredError: If the loop answered 408 or a server error.
220
+ """
221
+ path = (f'/{context.organization}/projects/{context.project}/trainings/{training_number}'
222
+ f'/training_results/{part}')
223
+ response = await self.loop_communicator.put(path, files=[zip_path], timeout=60*10)
224
+ if response.status_code == HTTPStatus.REQUEST_TIMEOUT or response.status_code >= 500:
225
+ raise TrainingResultsNotAnsweredError(f'The loop did not answer the upload of the {part} results of '
226
+ f'training {training_number}: {response.status_code} {response.text}')
227
+ if response.status_code != HTTPStatus.OK:
228
+ raise TrainingResultsUploadFailedError(f'The loop did not store the {part} results of training '
229
+ f'{training_number}: {response.status_code} {response.text}')
230
+ logger.info('Uploaded the %s results of training %s', part, training_number)
231
+
210
232
 
211
233
  def _rejects_upload(status_code: int) -> bool:
212
234
  """A client error other than 408 and 429 answers the same however often the upload is sent."""
@@ -19,6 +19,7 @@ from socketio import AsyncClient
19
19
 
20
20
  from ..data_classes import (
21
21
  AboutResponse,
22
+ CaptureMetadata,
22
23
  Context,
23
24
  DetectorStatus,
24
25
  ImageMetadata,
@@ -272,10 +273,16 @@ class DetectorNode(Node):
272
273
 
273
274
  Data dict follows the schema of the detect endpoint,
274
275
  but 'images' is a list of image dicts.
276
+ Each image dict may also carry its own:
277
+ - camera_id: replaces the camera_id of the batch
278
+ - tags: added to the tags of the batch
279
+ - source: replaces the source of the batch
280
+ - creation_date: replaces the creation_date of the batch
275
281
  """
276
282
  try:
277
283
  images_data = data['images']
278
284
  images = [image_from_dict(image) for image in images_data]
285
+ capture_metadata = [_capture_metadata(data, image) for image in images_data]
279
286
  except Exception:
280
287
  self.log.exception('could not parse images from socketio')
281
288
  return {'error': 'could not parse images from data'}
@@ -283,11 +290,8 @@ class DetectorNode(Node):
283
290
  try:
284
291
  det = await self.get_batch_detections(
285
292
  images=images,
286
- tags=data.get('tags', []),
287
- camera_id=data.get('camera_id', None),
288
- source=data.get('source', None),
293
+ capture_metadata=capture_metadata,
289
294
  autoupload=data.get('autoupload', 'filtered'),
290
- creation_date=data.get('creation_date', None),
291
295
  )
292
296
  return jsonable_encoder(asdict(det))
293
297
  except DetectorUnavailableError as e:
@@ -638,35 +642,17 @@ class DetectorNode(Node):
638
642
  array = await _pixel_array(image)
639
643
  async with self.detection_lock:
640
644
  detector = _unwrap_detector(self._detector)
641
- metadata = await run.io_bound(detector.logic.evaluate, array)
642
-
643
- metadata.tags.extend(tags)
644
- metadata.source = source
645
- metadata.created = creation_date
646
-
647
- fix_shape_detections(metadata)
648
- n_bo, n_cl = len(metadata.box_detections), len(metadata.classification_detections)
649
- n_po, n_se = len(metadata.point_detections), len(metadata.segmentation_detections)
650
- self.log.debug('Detected: %d boxes, %d points, %d segs, %d classes', n_bo, n_po, n_se, n_cl)
645
+ detections = await run.io_bound(detector.logic.evaluate, array)
651
646
 
652
- if autoupload == 'filtered':
653
- background_tasks.create(self.relevance_filter.may_upload_detections(metadata, camera_id, image))
654
- elif autoupload == 'all':
655
- background_tasks.create(self.outbox.save(image, metadata))
656
- elif autoupload == 'disabled':
657
- pass
658
- else:
659
- self.log.error('unknown autoupload value %s', autoupload)
660
- return metadata
647
+ capture = CaptureMetadata(camera_id=camera_id, tags=tags, source=source, creation_date=creation_date)
648
+ self._finish_detection(detections, image, capture, autoupload)
649
+ return detections
661
650
 
662
651
  async def get_batch_detections(self,
663
652
  images: Sequence[ImageData],
664
- tags: List[str],
653
+ capture_metadata: Sequence[CaptureMetadata],
665
654
  *,
666
- camera_id: Optional[str] = None,
667
- source: Optional[str] = None,
668
- autoupload: str = 'filtered',
669
- creation_date: Optional[str] = None) -> ImagesMetadata:
655
+ autoupload: str = 'filtered') -> ImagesMetadata:
670
656
  """
671
657
  Processing function for the detector node when a batch inference is requested via SocketIO.
672
658
 
@@ -674,35 +660,45 @@ class DetectorNode(Node):
674
660
  cares about uploading to the loop and returns the detections as a list of ImageMetadata.
675
661
  Each image is a pixel array or JPEG bytes; JPEG bytes are decoded for the inference
676
662
  and stored as they are if the image is uploaded.
663
+ `capture_metadata` holds the camera, tags, source and creation date of each image.
677
664
  Raises exception if no model is loaded.
678
665
  """
666
+ if len(capture_metadata) != len(images):
667
+ raise ValueError(f'got {len(capture_metadata)} capture metadata for {len(images)} images')
679
668
  _unwrap_detector(self._detector) # fails fast, before the decode
680
669
  arrays = await asyncio.gather(*(_pixel_array(image) for image in images))
681
670
  async with self.detection_lock:
682
671
  detector = _unwrap_detector(self._detector)
683
672
  all_detections = await run.io_bound(detector.logic.batch_evaluate, arrays)
684
673
 
685
- for metadata in all_detections.items:
686
- metadata.tags.extend(tags)
687
- metadata.source = source
688
- metadata.created = creation_date
689
-
690
- for detections, image in zip(all_detections.items, images):
691
- fix_shape_detections(detections)
692
- n_bo, n_cl = len(detections.box_detections), len(detections.classification_detections)
693
- n_po, n_se = len(detections.point_detections), len(detections.segmentation_detections)
694
- self.log.debug('Detected: %d boxes, %d points, %d segs, %d classes', n_bo, n_po, n_se, n_cl)
695
-
696
- if autoupload == 'filtered':
697
- background_tasks.create(self.relevance_filter.may_upload_detections(detections, camera_id, image))
698
- elif autoupload == 'all':
699
- background_tasks.create(self.outbox.save(image, detections))
700
- elif autoupload == 'disabled':
701
- pass
702
- else:
703
- self.log.error('unknown autoupload value %s', autoupload)
674
+ for detections, image, capture in zip(all_detections.items, images, capture_metadata):
675
+ self._finish_detection(detections, image, capture, autoupload)
704
676
  return all_detections
705
677
 
678
+ def _finish_detection(self,
679
+ detections: ImageMetadata,
680
+ image: ImageData,
681
+ capture: CaptureMetadata,
682
+ autoupload: str) -> None:
683
+ detections.tags.extend(capture.tags)
684
+ detections.source = capture.source
685
+ detections.created = capture.creation_date
686
+
687
+ fix_shape_detections(detections)
688
+ n_bo, n_cl = len(detections.box_detections), len(detections.classification_detections)
689
+ n_po, n_se = len(detections.point_detections), len(detections.segmentation_detections)
690
+ self.log.debug('Detected: %d boxes, %d points, %d segs, %d classes', n_bo, n_po, n_se, n_cl)
691
+
692
+ if autoupload == 'filtered':
693
+ background_tasks.create(
694
+ self.relevance_filter.may_upload_detections(detections, capture.camera_id, image))
695
+ elif autoupload == 'all':
696
+ background_tasks.create(self.outbox.save(image, detections))
697
+ elif autoupload == 'disabled':
698
+ pass
699
+ else:
700
+ self.log.error('unknown autoupload value %s', autoupload)
701
+
706
702
  async def upload_images(
707
703
  self, *,
708
704
  images: Sequence[ImageData],
@@ -785,6 +781,23 @@ async def _pixel_array(image: ImageData) -> np.ndarray:
785
781
  return await run.io_bound(jpg_bytes_to_numpy_array, image)
786
782
 
787
783
 
784
+ def _capture_metadata(batch_data: dict, image_data: dict) -> CaptureMetadata:
785
+ """The values of one image dict of a batch: its own where it sets them, the batch's otherwise, and both tags."""
786
+ image_tags = image_data.get('tags') or []
787
+ if not isinstance(image_tags, list):
788
+ raise ValueError(f'tags of an image must be a list, got {type(image_tags).__name__}')
789
+ return CaptureMetadata(camera_id=_own_or_batch('camera_id', image_data, batch_data),
790
+ tags=[*batch_data.get('tags', []), *image_tags],
791
+ source=_own_or_batch('source', image_data, batch_data),
792
+ creation_date=_own_or_batch('creation_date', image_data, batch_data))
793
+
794
+
795
+ def _own_or_batch(key: str, image_data: dict, batch_data: dict) -> str | None:
796
+ """`None` counts as not set, so an image cannot clear a value of the batch."""
797
+ own = image_data.get(key)
798
+ return own if own is not None else batch_data.get(key)
799
+
800
+
788
801
  def _unwrap_detector(state: '_DetectorState') -> _ActiveDetector:
789
802
  """Return the active detector or raise DetectorUnavailableError."""
790
803
  match state:
@@ -11,6 +11,18 @@ class ModelUploadFailedError(Exception):
11
11
  """
12
12
 
13
13
 
14
+ class TrainingResultsUploadFailedError(Exception):
15
+ """Raised when the loop answered the upload of a part of a training's results with a client error other than 408.
16
+ The answer is final: sending the part again would get the same answer.
17
+ """
18
+
19
+
20
+ class TrainingResultsNotAnsweredError(Exception):
21
+ """Raised when the loop gave up waiting for a part of a training's results (408) or answered with a server error;
22
+ the part may be sent again.
23
+ """
24
+
25
+
14
26
  class TrainingRefusedError(Exception):
15
27
  """Raised when a trainer cannot take on a training it was asked to begin; nothing was set up."""
16
28
 
@@ -26,7 +26,6 @@ class TrainerLogic(TrainerLogicGeneric):
26
26
  self._detection_progress: Optional[float] = None
27
27
  self._executor: Optional[Executor] = None
28
28
  self._process_started: bool = False
29
- self._stop_requested: bool = False
30
29
  self.start_training_task: Optional[Coroutine] = None
31
30
  self.inference_batch_size = self._environment_vars.inference_batch_size
32
31
 
@@ -6,6 +6,7 @@ import sys
6
6
  import time
7
7
  from abc import ABC, abstractmethod
8
8
  from dataclasses import asdict
9
+ from pathlib import Path
9
10
  from typing import TYPE_CHECKING, Any, Callable, Coroutine, Dict, List, Optional
10
11
 
11
12
  from fastapi.encoders import jsonable_encoder
@@ -26,6 +27,7 @@ from ..helpers.misc import (
26
27
  delete_all_training_folders,
27
28
  is_valid_uuid4,
28
29
  )
30
+ from . import training_results
29
31
  from .downloader import TrainingsDownloader
30
32
  from .exceptions import CriticalError, HyperparameterMismatchError, NodeNeedsRestartError, TrainingRefusedError
31
33
  from .hyperparameters import resolve, warn_about_undeclared
@@ -57,6 +59,7 @@ class TrainerLogicGeneric(ABC):
57
59
  self.model_format: str = model_format
58
60
  self.errors = Errors()
59
61
  self._state_failures: Dict[str, int] = {} # failed attempts in a row, by error key
62
+ self._stop_requested: bool = False # set by a trainer that stopped its training process; not kept on restart
60
63
 
61
64
  self.training_task: Optional[asyncio.Task] = None
62
65
  self.shutdown_event: asyncio.Event = asyncio.Event()
@@ -416,7 +419,11 @@ class TrainerLogicGeneric(ABC):
416
419
  self.last_training_io.save(self.training)
417
420
 
418
421
  async def _prepare(self) -> None:
419
- """Downloads images to the images_folder and saves annotations to training.data.image_data."""
422
+ """Downloads images to the images_folder and saves annotations to training.data.image_data.
423
+ Moves part of the train images to `val` if `compute_validation_set` asks for it (see `validation_set`).
424
+ A malformed `upload_examples` or `upload_logs` ends the training here, before it has run.
425
+ """
426
+ training_results.check_hyperparameters(self.training.hyperparameters)
420
427
  self.node.data_exchanger.set_context(self.training.context)
421
428
  downloader = TrainingsDownloader(self.node.data_exchanger)
422
429
  image_data, skipped_image_count = await downloader.download_training_data(self.training.images_folder)
@@ -489,11 +496,16 @@ class TrainerLogicGeneric(ABC):
489
496
  self.errors.reset(error_key)
490
497
 
491
498
  async def _upload_model(self) -> None:
492
- """Uploads the latest model to the Learning Loop.
499
+ """Uploads the latest model and then its training results to the Learning Loop.
500
+ The results never fail the state (see `training_results`).
493
501
  """
494
502
  new_model_uuid = await self._upload_model_return_new_model_uuid(self.training.context)
495
503
  logger.info('Successfully uploaded model and received new model id: %s', new_model_uuid)
496
504
  self.training.model_uuid_for_detecting = new_model_uuid
505
+ await training_results.upload_results(self.training, model_uuid=new_model_uuid, node_name=self.node.name,
506
+ node_uuid=self.node.uuid, get_results=self._get_training_results,
507
+ stop_requested=self._stop_requested,
508
+ data_exchanger=self.node.data_exchanger)
497
509
 
498
510
  async def _upload_model_return_new_model_uuid(self, context: Context) -> str:
499
511
  """Upload model files, usually pytorch model (.pt) hyp.yaml and the converted .wts file.
@@ -658,6 +670,14 @@ class TrainerLogicGeneric(ABC):
658
670
  """
659
671
  raise NotImplementedError
660
672
 
673
+ def _get_training_results(self, training_folder: Path) -> dict[str, list[Path]]:
674
+ """Called when the model is uploaded. Returns the result files of the training by part, stored beside the model.
675
+ The parts are `core` (always uploaded) and `examples` and `logs` (uploaded only if `upload_examples` and
676
+ `upload_logs` are true); see `training_results`. Directories go in as a whole. Name only results, never the
677
+ dataset. Default: no results, and nothing is uploaded.
678
+ """
679
+ return {}
680
+
661
681
  def _get_model_metadata(self) -> dict[str, Any]:
662
682
  """Return the values the trainer determined for this model, to be merged into its model.json.
663
683
 
@@ -0,0 +1,187 @@
1
+ """The results of a training — metrics, curves, logs — uploaded to the loop beside its model.
2
+
3
+ A trainer names its result files in `_get_training_results`, grouped into the parts `core`, `examples` and
4
+ `logs`. `upload_results` sends them once the model is uploaded and never fails the training: a part the loop
5
+ answered is done, a part that got no answer or a server error is sent up to `MAX_ATTEMPTS` times, and
6
+ anything else that goes wrong is logged and skips the part. `core` is always uploaded; `examples` and `logs`
7
+ only if the training's `upload_examples` and `upload_logs` hyperparameters ask for them. Each part goes up as
8
+ one zip that keeps the paths inside the training folder, and `core` additionally holds `meta.json`, where
9
+ the results come from.
10
+ """
11
+ import asyncio
12
+ import json
13
+ import logging
14
+ import os
15
+ import zipfile
16
+ from collections.abc import Callable, Mapping
17
+ from datetime import datetime, timezone
18
+ from importlib.metadata import PackageNotFoundError, version
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ import httpx
23
+
24
+ from ..data_classes import Training
25
+ from ..data_exchanger import DataExchanger
26
+ from .exceptions import CriticalError, TrainingResultsNotAnsweredError, TrainingResultsUploadFailedError
27
+ from .validation_set import bool_hyperparameter
28
+
29
+ CORE = 'core'
30
+ EXAMPLES = 'examples'
31
+ LOGS = 'logs'
32
+ PARTS = (CORE, EXAMPLES, LOGS)
33
+
34
+ UPLOAD_EXAMPLES = 'upload_examples'
35
+ UPLOAD_LOGS = 'upload_logs'
36
+ OPTIONAL_PARTS = {EXAMPLES: UPLOAD_EXAMPLES, LOGS: UPLOAD_LOGS}
37
+ """The parts uploaded only if the hyperparameter they map to is true."""
38
+
39
+ META_FILE = 'meta.json'
40
+ RESULTS_FOLDER = 'training_results'
41
+ """Inside the training folder: the zips and the record of the parts that are done."""
42
+ DONE_FILE = 'uploaded_parts.txt'
43
+
44
+ MAX_ATTEMPTS = 3
45
+ RETRY_DELAY = 5 # seconds before the second attempt, doubled before each further one
46
+
47
+ COMPRESSED_SUFFIXES = {'.jpg', '.jpeg', '.png', '.webp', '.gif', '.mp4', '.zip', '.gz', '.tgz', '.xz', '.7z'}
48
+ """Files stored in a zip as they are; compressing them again costs time and saves almost nothing."""
49
+
50
+ logger = logging.getLogger(__name__)
51
+
52
+
53
+ def check_hyperparameters(hyperparameters: Mapping[str, Any]) -> None:
54
+ """Raise a CriticalError if `upload_examples` or `upload_logs` is set to anything but a boolean."""
55
+ for name in OPTIONAL_PARTS.values():
56
+ bool_hyperparameter(hyperparameters, name)
57
+
58
+
59
+ async def upload_results(training: Training, *, model_uuid: str, node_name: str, node_uuid: str,
60
+ get_results: Callable[[Path], Mapping[str, list[Path]]], stop_requested: bool,
61
+ data_exchanger: DataExchanger) -> None:
62
+ """Upload the parts of `get_results(training_folder)` the training's hyperparameters ask for; never raises.
63
+
64
+ A part is recorded as done once it is answered or skipped, so a second call sends only the parts left.
65
+ """
66
+ training_folder = Path(training.training_folder)
67
+ results_folder = training_folder / RESULTS_FOLDER
68
+ try:
69
+ results = get_results(training_folder)
70
+ parts = _parts_to_upload(results, training.hyperparameters)
71
+ meta_content = _meta(training, model_uuid=model_uuid, node_name=node_name, node_uuid=node_uuid,
72
+ stop_requested=stop_requested)
73
+ done = _load_done(results_folder)
74
+ except Exception:
75
+ logger.exception('Skipping the results of training %s', training.training_number)
76
+ return
77
+ for part in [p for p in parts if p not in done]:
78
+ try:
79
+ await _upload_part(training, part, results[part], meta_content if part == CORE else None, data_exchanger)
80
+ except TrainingResultsUploadFailedError as e:
81
+ logger.warning('Skipping the %s results of training %s: %s', part, training.training_number, e)
82
+ except Exception:
83
+ logger.exception('Skipping the %s results of training %s', part, training.training_number)
84
+ done.append(part)
85
+ try:
86
+ _save_done(results_folder, done)
87
+ except OSError:
88
+ logger.exception('Could not record the %s results of training %s as done', part, training.training_number)
89
+
90
+
91
+ def _parts_to_upload(results: Mapping[str, list[Path]], hyperparameters: Mapping[str, Any]) -> list[str]:
92
+ """The parts of `results` to upload, in upload order: `core` first, the optional ones only if asked for.
93
+
94
+ A part not in `PARTS`, or one whose hyperparameter is malformed, is logged and left out.
95
+ """
96
+ parts = []
97
+ for part in results:
98
+ if part not in PARTS:
99
+ logger.warning('Leaving out the training results part %r, which is none of %s', part, ', '.join(PARTS))
100
+ continue
101
+ if part in OPTIONAL_PARTS:
102
+ try:
103
+ if not bool_hyperparameter(hyperparameters, OPTIONAL_PARTS[part]):
104
+ continue
105
+ except CriticalError as e:
106
+ logger.error('Leaving out the %s results: %s', part, e)
107
+ continue
108
+ parts.append(part)
109
+ return sorted(parts, key=lambda part: part != CORE)
110
+
111
+
112
+ def _meta(training: Training, *, model_uuid: str, node_name: str, node_uuid: str,
113
+ stop_requested: bool) -> dict[str, Any]:
114
+ """Where the results come from, so they explain themselves without the loop's database."""
115
+ return {
116
+ 'node_name': node_name,
117
+ 'node_uuid': node_uuid,
118
+ 'organization': training.context.organization,
119
+ 'project': training.context.project,
120
+ 'training_number': training.training_number,
121
+ 'model_uuid': model_uuid,
122
+ 'start_time': datetime.fromtimestamp(training.start_time, timezone.utc).isoformat(),
123
+ 'uploaded_at': datetime.now(timezone.utc).isoformat(),
124
+ 'stop_requested': stop_requested,
125
+ 'learning_loop_node_version': _library_version(),
126
+ 'hyperparameters': dict(training.hyperparameters),
127
+ }
128
+
129
+
130
+ async def _upload_part(training: Training, part: str, files: list[Path], part_meta: dict[str, Any] | None,
131
+ data_exchanger: DataExchanger) -> None:
132
+ """Zip and send one part; send it again while it gets no answer, up to `MAX_ATTEMPTS` times."""
133
+ training_folder = Path(training.training_folder)
134
+ zip_path = training_folder / RESULTS_FOLDER / f'{part}.zip'
135
+ await asyncio.to_thread(_write_zip, files, training_folder, zip_path, part_meta)
136
+ for attempt in range(1, MAX_ATTEMPTS + 1):
137
+ try:
138
+ await data_exchanger.upload_training_results(training.context, training.training_number, part,
139
+ str(zip_path))
140
+ return
141
+ except (TrainingResultsNotAnsweredError, httpx.TransportError):
142
+ if attempt == MAX_ATTEMPTS:
143
+ raise
144
+ await asyncio.sleep(RETRY_DELAY * 2 ** (attempt - 1))
145
+
146
+
147
+ def _write_zip(files: list[Path], training_folder: Path, zip_path: Path, meta: dict[str, Any] | None) -> None:
148
+ """Zip `files` into `zip_path`; directories go in as a whole, except anything in the folder of `zip_path`.
149
+
150
+ A path inside `training_folder` keeps its path relative to it, any other its path relative to the parent of
151
+ the file or directory named. Files in `COMPRESSED_SUFFIXES` are stored, everything else compressed.
152
+ A file that does not exist is logged and left out.
153
+ """
154
+ results_folder = zip_path.parent
155
+ results_folder.mkdir(parents=True, exist_ok=True)
156
+ partial = zip_path.with_name(f'{zip_path.name}.partial')
157
+ with zipfile.ZipFile(partial, 'w', compression=zipfile.ZIP_DEFLATED) as zipf:
158
+ for file in files:
159
+ if not file.exists():
160
+ logger.warning('training result %s does not exist, leaving it out', file)
161
+ continue
162
+ root = training_folder if file.is_relative_to(training_folder) else file.parent
163
+ for path in sorted(file.rglob('*')) if file.is_dir() else [file]:
164
+ if path.is_file() and not path.is_relative_to(results_folder):
165
+ stored = path.suffix.lower() in COMPRESSED_SUFFIXES
166
+ zipf.write(path, str(path.relative_to(root)),
167
+ compress_type=zipfile.ZIP_STORED if stored else zipfile.ZIP_DEFLATED)
168
+ if meta is not None:
169
+ zipf.writestr(META_FILE, json.dumps(meta, indent=1))
170
+ os.replace(partial, zip_path)
171
+
172
+
173
+ def _load_done(results_folder: Path) -> list[str]:
174
+ path = results_folder / DONE_FILE
175
+ return path.read_text().split() if path.exists() else []
176
+
177
+
178
+ def _save_done(results_folder: Path, done: list[str]) -> None:
179
+ results_folder.mkdir(parents=True, exist_ok=True)
180
+ (results_folder / DONE_FILE).write_text('\n'.join(done))
181
+
182
+
183
+ def _library_version() -> str | None:
184
+ try:
185
+ return version('learning_loop_node')
186
+ except PackageNotFoundError:
187
+ return None
@@ -40,9 +40,14 @@ VALIDATION_FRACTION = 0.1
40
40
 
41
41
  def compute_validation_set(hyperparameters: Mapping[str, Any]) -> bool:
42
42
  """Whether `compute_validation_set` is set, defaulting to false; anything but a boolean is a CriticalError."""
43
- value = hyperparameters.get(COMPUTE_VALIDATION_SET, False)
43
+ return bool_hyperparameter(hyperparameters, COMPUTE_VALIDATION_SET)
44
+
45
+
46
+ def bool_hyperparameter(hyperparameters: Mapping[str, Any], name: str) -> bool:
47
+ """Whether the hyperparameter `name` is set, defaulting to false; anything but a boolean is a CriticalError."""
48
+ value = hyperparameters.get(name, False)
44
49
  if not isinstance(value, bool):
45
- raise CriticalError(f"'{COMPUTE_VALIDATION_SET}' must be true or false, got {value!r}")
50
+ raise CriticalError(f"'{name}' must be true or false, got {value!r}")
46
51
  return value
47
52
 
48
53
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: learning_loop_node
3
- Version: 0.28.0
3
+ Version: 0.29.0
4
4
  Summary: Python Library for Nodes which connect to the Zauberzeug Learning Loop
5
5
  Author-email: Zauberzeug GmbH <info@zauberzeug.com>
6
6
  License: MIT
@@ -129,6 +129,8 @@ The `batch_detect` endpoint receives a dictionary, with the same entries as the
129
129
 
130
130
  - `images`: List of image data dictionaries, each with the same structure as the `image` entry in the `detect` endpoint
131
131
 
132
+ The entries `camera_id`, `tags`, `source` and `creation_date` apply to every image of the batch. An image data dictionary may also carry its own: its `tags` are added to those of the batch, while its `camera_id`, `source` and `creation_date` replace those of the batch. This way a batch can hold images of several cameras, each filtered and uploaded as its own camera's.
133
+
132
134
  Sending a camera's JPEG as it is (`format: 'jpeg'`) saves the bandwidth of a raw pixel array and spares the detector a re-encode for the upload.
133
135
 
134
136
  Example code can be found [in the rosys implementation](https://github.com/zauberzeug/rosys/blob/main/rosys/vision/detector_hardware.py).
@@ -256,6 +258,17 @@ Trainers fetch the images and anntoations from the Learning Loop to train new mo
256
258
 
257
259
  - if the command line tool "jpeginfo" is installed, the downloader will drop corrupted images automatically
258
260
 
261
+ ### Training results
262
+
263
+ Together with its model, a trainer uploads the results of the training to the Learning Loop, e.g. the
264
+ metrics per epoch, the evaluation curves and the confusion matrix. The trainer decides which files
265
+ they are, grouped into the parts `core`, `examples` and `logs`; a trainer that names none uploads
266
+ only its model. `core` is always uploaded, the examples and the raw training log only if the
267
+ training's `upload_examples` and `upload_logs` hyperparameters are true (both default to false).
268
+ The results are sent right after the model and never stop the training: results the Learning Loop
269
+ does not store, e.g. beyond the 5 GiB it keeps per training, are logged and skipped, and a part
270
+ that gets no answer or a server error is sent up to three times.
271
+
259
272
  ### Several trainers on one GPU
260
273
 
261
274
  Trainers that share a GPU (say, one per model architecture on the same machine) coordinate through
@@ -1,5 +1,5 @@
1
1
  learning_loop_node/__init__.py,sha256=LzNoXBrcL6yJTyisuwJokZ9LTiYQwY3yRY3tmy83FlI,410
2
- learning_loop_node/data_exchanger.py,sha256=Rlql4WSDgF6uUivH77deoIl2vnTKL1rlEhQmAUmJ9IU,10642
2
+ learning_loop_node/data_exchanger.py,sha256=Ap5m_Bd_fHKz82YQiLQphAu5mwJ8pc9VllcinqTRrLo,12099
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
@@ -8,18 +8,18 @@ learning_loop_node/rest.py,sha256=5X9IVW9kf1gNf8jifGW9g_gI_-9TEeoMMOW16jvwpRE,15
8
8
  learning_loop_node/annotation/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
9
  learning_loop_node/annotation/annotator_logic.py,sha256=BTaopkJZkIf1CI5lfsVKsxbxoUIbDJrevavuQUT5e_c,1000
10
10
  learning_loop_node/annotation/annotator_node.py,sha256=J5xwSnM5rwTWrTe-TI37J0JHKf_4PlDuABaHvgjYr_Q,4443
11
- learning_loop_node/data_classes/__init__.py,sha256=f9txmNcmpxCfTmgLIwZS0tr1QcJHI45s8Rxgbxk_U7o,1392
11
+ learning_loop_node/data_classes/__init__.py,sha256=1mrGtrDCxILDwmPcbLgjqYp0cg7ObKusFR3BDoSMQIQ,1428
12
12
  learning_loop_node/data_classes/annotation_data.py,sha256=NfMlTv2_5AfVY_JDM4tbjETFjSN2S2I2LJJPMMcDT50,966
13
13
  learning_loop_node/data_classes/annotations.py,sha256=ha7uuWmZqW-LU2vYrkvfnaVpvf5C2xdm826IVVBu0w0,1848
14
14
  learning_loop_node/data_classes/detections.py,sha256=hGSKc1elk4Drp4XUjQ3F9HGeRpyLvA0q064b-S9lIug,5335
15
15
  learning_loop_node/data_classes/general.py,sha256=0p6RhPOEVr_vBoG-TySTUKhjT2_AS9Jb8jEqSkg5ncc,7829
16
- learning_loop_node/data_classes/image_metadata.py,sha256=QPLJ4gcVj33xaaCFF1n5e8G6higOJ6iIKy3YhGZMfkw,2459
16
+ learning_loop_node/data_classes/image_metadata.py,sha256=wRkDRHAhuiBs8ntXeIn5_brHA7bpmL1efQpq2LVq6mk,3058
17
17
  learning_loop_node/data_classes/socket_response.py,sha256=tIdt-oYf6ULoJIDYQCecNM9OtWR6_wJ9tL0Ksu83Vko,655
18
18
  learning_loop_node/data_classes/training.py,sha256=My56tpDHAaUlraACTh-ZJ3y2V6v23IkD_bkkvil9_S8,6902
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=agPbkFvFuQhZ04qQleWji6iYrbXsKqVGL0fseq4OXXY,1554
22
- learning_loop_node/detector/detector_node.py,sha256=AB-uXNPGxRbQwjLIN35uyk1Qww6T1bF4fqWSbwV6F1A,37395
22
+ learning_loop_node/detector/detector_node.py,sha256=4vq2gRb6lVs2lwvhWL4rC3S9D89xWGkGouy506g2h38,38314
23
23
  learning_loop_node/detector/exceptions.py,sha256=C6KbNPlSbtfgDrZx2Hbhm7Suk9jVoR3fMRCO0CkrMsQ,196
24
24
  learning_loop_node/detector/geometry.py,sha256=bZCF9xuK7qy6Ipv5aQYrTlwSmjaiUz5VEUYnc1zCe-w,1101
25
25
  learning_loop_node/detector/outbox.py,sha256=mtMxdcComjRyM6qc-O1fBv9ABCau4-oCCV442hMaryQ,12351
@@ -52,21 +52,22 @@ learning_loop_node/trainer/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJ
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=7PeJ1wXPvf7NYBrEkPNvZvTTGTBXZ9zIGE5tE-tWPu8,1435
55
+ learning_loop_node/trainer/exceptions.py,sha256=31SzhQXf3vsU-2aqoQYTjvsebcHtkJGZnKGzSvgLEvo,1899
56
56
  learning_loop_node/trainer/executor.py,sha256=XQ1TwgOF2pQv7bgzSaljDWG4I1ySjYXL8r9c_p9MX1Q,3947
57
57
  learning_loop_node/trainer/hyperparameters.py,sha256=WtVh3PThIZs0Oe6IGQF73-WfdtHpf6wL6tsc0r9iUAY,8623
58
58
  learning_loop_node/trainer/io_helpers.py,sha256=fmSh2-9sEfBavYxfa_zT4FfZMByT3KxDbA3O57I8iBY,7834
59
59
  learning_loop_node/trainer/metrics.py,sha256=H1jQh5fHN3-cTdMTuH8mF_uODV0T9DBdqcPUD-ZqFAI,1439
60
60
  learning_loop_node/trainer/subprocess.py,sha256=WxuTly0zhpClEjTd9wqoI_-7YTNMaiZpRHxu00vttKw,2647
61
61
  learning_loop_node/trainer/test_executor.py,sha256=6BVGDN_6f5GEMMEvDLSG1yzMybSvgXaP5uYpSfsVPP0,2224
62
- learning_loop_node/trainer/trainer_logic.py,sha256=x15bEy7uTfaGGHGYRGUl2olMeL0x0QW0vlu8nXZOaGg,9769
63
- learning_loop_node/trainer/trainer_logic_generic.py,sha256=_vHtOfgZInGcEwUHZFTcZhiAp7rui4DwTd8GxkH1Wf8,33682
62
+ learning_loop_node/trainer/trainer_logic.py,sha256=ayBo0B9_2ZmoLW97-ahbRUpqz0jTgV7-LbWul2NC248,9726
63
+ learning_loop_node/trainer/trainer_logic_generic.py,sha256=q9QM4mGtIvp3ihMH4yuFvnO0G9t71ojeOya5OYAeKgE,35175
64
64
  learning_loop_node/trainer/trainer_node.py,sha256=viYXQvUnwWfFjMKsh5Y-uWPCY2I2HwQh92w7bnBa0XY,5830
65
+ learning_loop_node/trainer/training_results.py,sha256=WOgT0SmNQ-38p0ZUnhcBWqWYbvEE9YkfGEDcRARJ3zE,8792
65
66
  learning_loop_node/trainer/training_slot.py,sha256=Njq6Z0T2Lqlr24ftoIp11Pw2OxCo9Z2RGn1tuFhHqLY,6737
66
- learning_loop_node/trainer/validation_set.py,sha256=j0KAUP8CN4NpudL180xtT1xUQV0bCfxw1tMlPdb9l4Y,3363
67
+ learning_loop_node/trainer/validation_set.py,sha256=dHEBXqZoDVlWFyV6K5EZ4fXbXa6OuZxSw6fVw0qoJh0,3597
67
68
  learning_loop_node/trainer/rest/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
68
69
  learning_loop_node/trainer/rest/backdoor_controls.py,sha256=bCjrQvqtgd2DtyYNgftkcDe9AFHExFLtfdW_jISLwuk,5026
69
- learning_loop_node-0.28.0.dist-info/METADATA,sha256=9T_Gxp-5PA9CCPsmbBPWy5jBjmDElOaUbDhY1Qfeors,20629
70
- learning_loop_node-0.28.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
71
- learning_loop_node-0.28.0.dist-info/top_level.txt,sha256=nS5qiFc4stVF_VXVCVJxZnrqVOv3IGX5-_E3sLby6OE,19
72
- learning_loop_node-0.28.0.dist-info/RECORD,,
70
+ learning_loop_node-0.29.0.dist-info/METADATA,sha256=0VUirHSUUAuBSs2aUlgoWgSiCIGsA7UjscKnsZViyK8,21772
71
+ learning_loop_node-0.29.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
72
+ learning_loop_node-0.29.0.dist-info/top_level.txt,sha256=nS5qiFc4stVF_VXVCVJxZnrqVOv3IGX5-_E3sLby6OE,19
73
+ learning_loop_node-0.29.0.dist-info/RECORD,,