learning-loop-node 0.22.0__py3-none-any.whl → 0.23.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.
@@ -46,6 +46,8 @@ class ImageMetadata():
46
46
  'description': 'Creation date of the image'})
47
47
  source: Optional[str] = field(default=None, metadata={
48
48
  'description': 'Source of the image'})
49
+ state: str | None = field(default=None, metadata={
50
+ 'description': 'State the image should enter the loop in (e.g. "trash"); None uses the loop default'})
49
51
 
50
52
  def __len__(self):
51
53
  return len(self.box_detections) + len(self.point_detections) + len(self.segmentation_detections) + len(self.classification_detections)
@@ -0,0 +1,27 @@
1
+ """Resolving the class indices or names a model emits against ``ModelInformation.categories``."""
2
+
3
+ from ..data_classes import Category, ModelInformation
4
+
5
+
6
+ def category_by_index(model_information: ModelInformation, index: int) -> Category:
7
+ """Resolve the category a model's class index refers to.
8
+
9
+ :raises ValueError: If the index is outside the model's category list.
10
+ """
11
+ categories = model_information.categories
12
+ if not 0 <= index < len(categories):
13
+ raise ValueError(
14
+ f'category index {index} is out of range for a model with {len(categories)} categories')
15
+ return categories[index]
16
+
17
+
18
+ def category_by_name(model_information: ModelInformation, name: str) -> Category:
19
+ """Resolve a category by name, for models whose outputs are named rather than indexed.
20
+
21
+ :raises ValueError: If no category of that name exists.
22
+ """
23
+ for category in model_information.categories:
24
+ if category.name == name:
25
+ return category
26
+ known = ', '.join(category.name for category in model_information.categories)
27
+ raise ValueError(f'unknown category name {name!r}; the model knows: {known}')
@@ -18,7 +18,6 @@ from socketio import AsyncClient
18
18
 
19
19
  from ..data_classes import (
20
20
  AboutResponse,
21
- Category,
22
21
  Context,
23
22
  DetectorStatus,
24
23
  ImageMetadata,
@@ -33,6 +32,7 @@ from ..globals import GLOBALS
33
32
  from ..helpers import background_tasks, environment_reader, run
34
33
  from ..helpers.misc import numpy_image_from_dict
35
34
  from ..node import Node
35
+ from .categories import category_by_name
36
36
  from .detector_logic import DetectorLogic, DetectorLogicFactory
37
37
  from .exceptions import NodeNeedsRestartError
38
38
  from .inbox_filter.relevance_filter import RelevanceFilter
@@ -339,7 +339,9 @@ class DetectorNode(Node):
339
339
  - bytes: bytes of the ndarray (retrieved via `ndarray.tobytes(order='C')`)
340
340
  - dtype: data type of the ndarray as string (e.g. `uint8`, `float32`, etc.)
341
341
  - shape: shape of the ndarray as tuple of ints (e.g. `(480, 640, 3)`)
342
- - metadata: The metadata for the image (optional)
342
+ - metadata: The metadata for the image (optional). Besides detections and annotations it
343
+ may carry `tags`, `source`, `created` and `state`. A `state` of e.g. "trash" makes the
344
+ loop file the image into that state instead of its default one.
343
345
  - upload_priority: Whether to upload with priority (optional)
344
346
  """
345
347
  self.log.debug('Processing upload via socketio.')
@@ -707,26 +709,28 @@ class DetectorNode(Node):
707
709
  await self.outbox.save(image, image_metadata, upload_priority)
708
710
 
709
711
  def add_category_id_to_detections(self, model_info: ModelInformation, image_metadata: ImageMetadata):
710
- def find_category_id_by_name(categories: List[Category], category_name: str):
711
- category_id = [category.id for category in categories if category.name == category_name]
712
- return category_id[0] if category_id else ''
713
-
714
- for box_detection in image_metadata.box_detections:
715
- category_name = box_detection.category_name
716
- category_id = find_category_id_by_name(model_info.categories, category_name)
717
- box_detection.category_id = category_id
718
- for point_detection in image_metadata.point_detections:
719
- category_name = point_detection.category_name
720
- category_id = find_category_id_by_name(model_info.categories, category_name)
721
- point_detection.category_id = category_id
722
- for segmentation_detection in image_metadata.segmentation_detections:
723
- category_name = segmentation_detection.category_name
724
- category_id = find_category_id_by_name(model_info.categories, category_name)
725
- segmentation_detection.category_id = category_id
726
- for classification_detection in image_metadata.classification_detections:
727
- category_name = classification_detection.category_name
728
- category_id = find_category_id_by_name(model_info.categories, category_name)
729
- classification_detection.category_id = category_id
712
+ """Resolve each detection's category id from its name, in metadata a client uploaded.
713
+
714
+ A name the model does not know costs that one detection its id, not the whole upload.
715
+ """
716
+ unknown_names: set[str] = set()
717
+
718
+ def category_id_by_name(category_name: str) -> str:
719
+ try:
720
+ return category_by_name(model_info, category_name).id
721
+ except ValueError:
722
+ unknown_names.add(category_name)
723
+ return ''
724
+
725
+ for detection in (*image_metadata.box_detections,
726
+ *image_metadata.point_detections,
727
+ *image_metadata.segmentation_detections,
728
+ *image_metadata.classification_detections):
729
+ detection.category_id = category_id_by_name(detection.category_name)
730
+
731
+ if unknown_names:
732
+ self.log.warning('Model %s knows no category named %s', model_info.version,
733
+ ', '.join(sorted(unknown_names)))
730
734
  return image_metadata
731
735
 
732
736
  def register_sio_events(self, sio_client: AsyncClient):
@@ -0,0 +1,39 @@
1
+ """Box and point clipping shared by every detector node.
2
+
3
+ The loop stores a box as its top-left corner plus a size, which is the form :func:`clip_box`
4
+ takes and produces.
5
+ """
6
+
7
+
8
+ def clip_box(
9
+ *,
10
+ x1: float,
11
+ y1: float,
12
+ width: float,
13
+ height: float,
14
+ img_width: int,
15
+ img_height: int,
16
+ ) -> tuple[int, int, int, int]:
17
+ """Clip a top-left-anchored box to the image bounds.
18
+
19
+ :return: The clipped ``(x1, y1, width, height)``; the size is never negative.
20
+ """
21
+ x2 = x1 + width
22
+ y2 = y1 + height
23
+
24
+ clipped_x1 = round(max(0.0, x1))
25
+ clipped_y1 = round(max(0.0, y1))
26
+ clipped_x2 = round(min(float(img_width), x2))
27
+ clipped_y2 = round(min(float(img_height), y2))
28
+
29
+ clipped_width = max(clipped_x2 - clipped_x1, 0)
30
+ clipped_height = max(clipped_y2 - clipped_y1, 0)
31
+
32
+ return clipped_x1, clipped_y1, clipped_width, clipped_height
33
+
34
+
35
+ def clip_point(x: float, y: float, img_width: int, img_height: int) -> tuple[float, float]:
36
+ """Clamp a point into the image bounds."""
37
+ x = min(max(0, x), img_width)
38
+ y = min(max(0, y), img_height)
39
+ return x, y
@@ -0,0 +1,219 @@
1
+ """Model-agnostic detection postprocessing."""
2
+
3
+ import logging
4
+ from collections.abc import Sequence
5
+ from dataclasses import dataclass
6
+
7
+ import numpy as np
8
+
9
+ from ..data_classes import (
10
+ BoxDetection,
11
+ Detections,
12
+ ImageMetadata,
13
+ ModelInformation,
14
+ PointDetection,
15
+ )
16
+ from ..enums import CategoryType
17
+ from .categories import category_by_index
18
+ from .geometry import clip_box, clip_point
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ MIN_BOX_SIZE: int = 2
23
+
24
+
25
+ @dataclass(kw_only=True, slots=True, frozen=True)
26
+ class Prediction:
27
+ """One surviving prediction in the model's coordinates: unrounded top-left corner and size
28
+ in pixels, and the category as an index into :attr:`ModelInformation.categories`."""
29
+
30
+ x: float
31
+ y: float
32
+ width: float
33
+ height: float
34
+ category_index: int
35
+ confidence: float
36
+
37
+
38
+ def post_process(
39
+ boxes: np.ndarray,
40
+ scores: np.ndarray,
41
+ classes: np.ndarray,
42
+ *,
43
+ conf_threshold: float,
44
+ iou_threshold: float,
45
+ origin_h: int,
46
+ origin_w: int,
47
+ ) -> list[Prediction]:
48
+ """Filter by confidence, run NMS, return what survives."""
49
+ mask = scores > conf_threshold
50
+ boxes = boxes[mask].copy()
51
+ scores = scores[mask]
52
+ classes = classes[mask]
53
+
54
+ if len(scores) == 0:
55
+ return []
56
+
57
+ boxes, scores, classes = non_max_suppression(
58
+ boxes, scores, classes,
59
+ iou_threshold=iou_threshold, origin_h=origin_h, origin_w=origin_w)
60
+
61
+ return predictions_from_xyxy(labels=classes, boxes=boxes, scores=scores)
62
+
63
+
64
+ def non_max_suppression(
65
+ boxes: np.ndarray,
66
+ scores: np.ndarray,
67
+ classes: np.ndarray,
68
+ *,
69
+ iou_threshold: float,
70
+ origin_h: int,
71
+ origin_w: int,
72
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
73
+ """Clip to image bounds, sort by score descending, apply per-class NMS.
74
+
75
+ :return: ``(boxes, scores, classes)`` — filtered arrays in the same order.
76
+ """
77
+ boxes = boxes.copy()
78
+ boxes[:, 0] = np.clip(boxes[:, 0], 0, origin_w - 1)
79
+ boxes[:, 2] = np.clip(boxes[:, 2], 0, origin_w - 1)
80
+ boxes[:, 1] = np.clip(boxes[:, 1], 0, origin_h - 1)
81
+ boxes[:, 3] = np.clip(boxes[:, 3], 0, origin_h - 1)
82
+
83
+ order = np.argsort(-scores)
84
+ boxes = boxes[order]
85
+ scores = scores[order]
86
+ classes = classes[order]
87
+
88
+ keep_indices: list[int] = []
89
+ for cls in np.unique(classes):
90
+ cls_mask = classes == cls
91
+ cls_indices = np.where(cls_mask)[0]
92
+ cls_boxes = boxes[cls_mask]
93
+
94
+ while len(cls_indices) > 0:
95
+ keep_indices.append(cls_indices[0])
96
+ if len(cls_indices) == 1:
97
+ break
98
+ ious = bbox_iou(cls_boxes[0:1], cls_boxes[1:])
99
+ keep_mask = ious.flatten() <= iou_threshold
100
+ cls_indices = cls_indices[1:][keep_mask]
101
+ cls_boxes = cls_boxes[1:][keep_mask]
102
+
103
+ keep_indices = sorted(keep_indices)
104
+ return boxes[keep_indices], scores[keep_indices], classes[keep_indices]
105
+
106
+
107
+ def bbox_iou(
108
+ box1: np.ndarray,
109
+ box2: np.ndarray,
110
+ ) -> np.ndarray:
111
+ """Compute IoU between box1 (1x4) and box2 (Nx4), both in x1y1x2y2 format."""
112
+ b1_x1, b1_y1, b1_x2, b1_y2 = box1[:, 0], box1[:, 1], box1[:, 2], box1[:, 3]
113
+ b2_x1, b2_y1, b2_x2, b2_y2 = box2[:, 0], box2[:, 1], box2[:, 2], box2[:, 3]
114
+
115
+ inter_x1 = np.maximum(b1_x1, b2_x1)
116
+ inter_y1 = np.maximum(b1_y1, b2_y1)
117
+ inter_x2 = np.minimum(b1_x2, b2_x2)
118
+ inter_y2 = np.minimum(b1_y2, b2_y2)
119
+
120
+ inter_area = np.clip(inter_x2 - inter_x1 + 1, 0, None) * np.clip(inter_y2 - inter_y1 + 1, 0, None)
121
+ b1_area = (b1_x2 - b1_x1 + 1) * (b1_y2 - b1_y1 + 1)
122
+ b2_area = (b2_x2 - b2_x1 + 1) * (b2_y2 - b2_y1 + 1)
123
+
124
+ return inter_area / (b1_area + b2_area - inter_area + 1e-16)
125
+
126
+
127
+ def predictions_from_xyxy(
128
+ *,
129
+ labels: Sequence[float],
130
+ boxes: Sequence[Sequence[float]],
131
+ scores: Sequence[float],
132
+ ) -> list[Prediction]:
133
+ """Convert xyxy model output into predictions, for models that suppress their own overlaps."""
134
+ return [Prediction(x=float(x1), y=float(y1), width=float(x2 - x1), height=float(y2 - y1),
135
+ category_index=int(label), confidence=float(score))
136
+ for label, (x1, y1, x2, y2), score in zip(labels, boxes, scores, strict=True)]
137
+
138
+
139
+ def to_image_metadata(
140
+ predictions: list[Prediction],
141
+ model_information: ModelInformation,
142
+ im_height: int,
143
+ im_width: int,
144
+ ) -> ImageMetadata:
145
+ """Build the container a *detector* node reports."""
146
+ image_metadata = ImageMetadata()
147
+ _append_predictions(image_metadata, predictions, model_information, im_height, im_width)
148
+ return image_metadata
149
+
150
+
151
+ def to_detections(
152
+ predictions: list[Prediction],
153
+ model_information: ModelInformation,
154
+ im_height: int,
155
+ im_width: int,
156
+ *,
157
+ image_id: str | None = None,
158
+ ) -> Detections:
159
+ """Build the container a *trainer*'s auto-detection pass reports."""
160
+ result = Detections(image_id=image_id)
161
+ _append_predictions(result, predictions, model_information, im_height, im_width)
162
+ return result
163
+
164
+
165
+ def _append_predictions(
166
+ target: ImageMetadata | Detections,
167
+ predictions: list[Prediction],
168
+ model_information: ModelInformation,
169
+ im_height: int,
170
+ im_width: int,
171
+ ) -> None:
172
+ """Resolve each prediction's category and append it to ``target``, clipped to the image."""
173
+ skipped_predictions = []
174
+
175
+ for prediction in predictions:
176
+ category = category_by_index(model_information, prediction.category_index)
177
+ if prediction.width <= MIN_BOX_SIZE or prediction.height <= MIN_BOX_SIZE:
178
+ skipped_predictions.append((category.name, prediction))
179
+ continue
180
+ if category.type == CategoryType.Box:
181
+ clipped_x1, clipped_y1, clipped_w, clipped_h = clip_box(
182
+ x1=prediction.x,
183
+ y1=prediction.y,
184
+ width=prediction.width,
185
+ height=prediction.height,
186
+ img_width=im_width,
187
+ img_height=im_height,
188
+ )
189
+ target.box_detections.append(
190
+ BoxDetection(
191
+ category_name=category.name,
192
+ x=clipped_x1,
193
+ y=clipped_y1,
194
+ width=clipped_w,
195
+ height=clipped_h,
196
+ category_id=category.id,
197
+ model_name=model_information.version,
198
+ confidence=prediction.confidence,
199
+ )
200
+ )
201
+ elif category.type == CategoryType.Point:
202
+ cx, cy = prediction.x + prediction.width / 2, prediction.y + prediction.height / 2
203
+ cx, cy = clip_point(cx, cy, im_width, im_height)
204
+ target.point_detections.append(
205
+ PointDetection(
206
+ category_name=category.name,
207
+ x=cx,
208
+ y=cy,
209
+ category_id=category.id,
210
+ model_name=model_information.version,
211
+ confidence=prediction.confidence,
212
+ )
213
+ )
214
+ else:
215
+ logger.warning('Unsupported category type %s for category %s', category.type, category.name)
216
+
217
+ if skipped_predictions:
218
+ log_msg = '\n'.join([str(p) for p in skipped_predictions])
219
+ logger.warning('Removed %d small detections from result: \n%s', len(skipped_predictions), log_msg)
@@ -0,0 +1,81 @@
1
+ """The boilerplate every node's ``main.py`` repeats: reading the settings, serving the node.
2
+
3
+ Every setting is a flag *and* an environment variable named after it: ``--conf-threshold``
4
+ reads ``CONF_THRESHOLD``. ``--host`` and ``--port`` are the exception, reading ``NODE_HOST``
5
+ and ``NODE_PORT``: the bare ``HOST`` is the address of the loop.
6
+ """
7
+
8
+ import logging
9
+ import os
10
+ from argparse import Action, Namespace
11
+
12
+ import configargparse
13
+ import uvicorn
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ def node_parser(*, description: str, legacy_env_prefix: str = '') -> configargparse.ArgumentParser:
19
+ """Build the parser for a node, pre-loaded with the settings every node has.
20
+
21
+ :param legacy_env_prefix: A prefix an earlier version of this node required, e.g.
22
+ ``'MY_DETECTOR_'``. Both spellings keep working, with a warning: the prefix on the
23
+ current name (``MY_DETECTOR_NODE_HOST``) and the prefix on the flag it was originally
24
+ applied to (``MY_DETECTOR_HOST``). Leave empty for a node that never used one.
25
+ """
26
+ parser = _NodeArgumentParser(description=description, legacy_env_prefix=legacy_env_prefix)
27
+ parser.add_argument('--host', default='0.0.0.0', env_var='NODE_HOST',
28
+ help='Host interface to bind to')
29
+ parser.add_argument('--port', type=int, default=80, env_var='NODE_PORT', help='Port to bind to')
30
+ return parser
31
+
32
+
33
+ def run_node(app: str, args: Namespace) -> None:
34
+ """Serve the node.
35
+
36
+ :param app: Import string of the node object, conventionally ``'main:node'``.
37
+ """
38
+ reload = os.getenv('UVICORN_RELOAD', 'FALSE').lower() in ('true', '1')
39
+ logger.info('Uvicorn reload is set to: %s', reload)
40
+ uvicorn.run(app, host=args.host, port=args.port, lifespan='on', reload=reload)
41
+
42
+
43
+ class _NodeArgumentParser(configargparse.ArgumentParser):
44
+ """Parser whose every setting is also an environment variable named after its flag."""
45
+
46
+ def __init__(self, *, description: str, legacy_env_prefix: str) -> None:
47
+ super().__init__(description=description)
48
+ self.legacy_env_prefix = legacy_env_prefix
49
+
50
+ def add_argument(self, *args, **kwargs) -> Action: # type: ignore[override]
51
+ action = super().add_argument(*args, **kwargs)
52
+ if getattr(action, 'env_var', None) is None and action.dest != 'help':
53
+ action.env_var = action.dest.upper()
54
+ return action
55
+
56
+ def parse_args(self, *args, **kwargs) -> Namespace: # type: ignore[override]
57
+ self._adopt_legacy_env_vars()
58
+ return super().parse_args(*args, **kwargs)
59
+
60
+ def _adopt_legacy_env_vars(self) -> None:
61
+ if not self.legacy_env_prefix:
62
+ return
63
+ for action in self._actions:
64
+ name = getattr(action, 'env_var', None)
65
+ if not name or name in os.environ:
66
+ continue
67
+ for legacy in self._legacy_names(action, name):
68
+ if legacy not in os.environ:
69
+ continue
70
+ os.environ[name] = os.environ[legacy]
71
+ logger.warning('%s is deprecated and will stop being read; set %s instead', legacy, name)
72
+ break
73
+
74
+ def _legacy_names(self, action: Action, name: str) -> list[str]:
75
+ """The prefixed names this setting may still be configured under, preferred first.
76
+
77
+ A prefix used to be applied to the *flag*, so the old name of ``--host`` is
78
+ ``<PREFIX>HOST``, not ``<PREFIX>NODE_HOST``.
79
+ """
80
+ candidates = [self.legacy_env_prefix + name, self.legacy_env_prefix + action.dest.upper()]
81
+ return list(dict.fromkeys(candidates))
@@ -1,26 +1,32 @@
1
1
  import logging
2
2
  import os
3
- from typing import List, Optional
3
+
4
+ logger = logging.getLogger(__name__)
4
5
 
5
6
 
6
7
  # TODO ignore_errors should default to False, but maybe some tests rely on this behavior
7
- def read_from_env(possible_names: List[str], ignore_errors: bool = True) -> Optional[str]:
8
+ def read_from_env(possible_names: list[str], ignore_errors: bool = True) -> str | None:
9
+ """Read the first of ``possible_names`` that is set.
10
+
11
+ :param possible_names: In order of preference; on a disagreement the first one set wins.
12
+ :raises ValueError: If nothing is set or the values disagree, unless ``ignore_errors``.
13
+ """
8
14
  values = [os.environ.get(name, None) for name in possible_names]
9
15
  values = list(filter(None, values))
10
16
 
11
17
  # Possible error: no values are set
12
18
  if not values:
13
19
  if ignore_errors:
14
- logging.warning('no environment variable set for %s', possible_names)
20
+ logger.warning('no environment variable set for %s', possible_names)
15
21
  return None
16
22
  raise ValueError(f'no environment variable set for {possible_names}')
17
23
 
18
24
  # Possible error: multiple values are not None and not equal
19
25
  if len(values) > 1 and len(set(values)) > 1:
20
- if ignore_errors:
21
- logging.warning('different environment variables set for %s: %s', possible_names, values)
22
- return None
23
- raise ValueError(f'different environment variables set for {possible_names}: {values}')
26
+ if not ignore_errors:
27
+ raise ValueError(f'different environment variables set for {possible_names}: {values}')
28
+ logger.warning('different environment variables set for %s: %s - using %s',
29
+ possible_names, values, values[0])
24
30
 
25
31
  return values[0]
26
32
 
@@ -0,0 +1,81 @@
1
+ """Choosing a batch size by probing, rather than configuring one.
2
+
3
+ The trainer supplies a ``fits`` predicate that runs a representative step at doubling sizes.
4
+ Only powers of two are visited, so equal hardware yields an equal recipe. Nothing here imports a
5
+ deep-learning framework.
6
+
7
+ Adapted from PyTorch Lightning's ``BatchSizeFinder`` (power-scaling mode).
8
+ Copyright The Lightning AI team. Licensed under the Apache License, Version 2.0.
9
+ https://github.com/Lightning-AI/pytorch-lightning
10
+ """
11
+
12
+ import logging
13
+ from collections.abc import Callable
14
+
15
+ from .exceptions import InsufficientMemoryError
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ MAX_BATCH_SIZE = 1024
20
+ """Where a search stops when its caller sets no bound of its own."""
21
+
22
+ NO_GPU_BATCH_SIZE = 8
23
+ """Batch size used when there is no GPU to probe."""
24
+
25
+ MIN_TRAIN_STEPS_PER_EPOCH = 8
26
+ """Fewest optimizer steps an epoch must have; :func:`dataset_limit` is derived from it."""
27
+
28
+
29
+ def find_batch_size(fits: Callable[[int], bool], *, limit: int) -> int:
30
+ """Return the largest power-of-two batch size that fits, never exceeding ``limit``.
31
+
32
+ :param fits: Runs a representative probe; ``False`` on out-of-memory.
33
+ :raises InsufficientMemoryError: If not even a batch size of 1 fits.
34
+ """
35
+ limit = smaller_pot(limit)
36
+ if not fits(1):
37
+ raise InsufficientMemoryError('batch size 1 does not fit in memory')
38
+
39
+ size = 1
40
+ while size < limit and fits(size * 2):
41
+ size *= 2
42
+
43
+ return size
44
+
45
+
46
+ def dataset_limit(sample_count: int) -> int:
47
+ """The batch size ceiling that still leaves ``MIN_TRAIN_STEPS_PER_EPOCH`` steps per epoch."""
48
+ if sample_count < 1:
49
+ raise ValueError(f'sample_count must be >= 1, got {sample_count}')
50
+ return max(1, sample_count // MIN_TRAIN_STEPS_PER_EPOCH)
51
+
52
+
53
+ def no_gpu_batch_size(limit: int, probe: str) -> int:
54
+ """The batch size to fall back on when there is no GPU to probe."""
55
+ batch_size = min(smaller_pot(limit), NO_GPU_BATCH_SIZE)
56
+ logger.warning('%s: CUDA is unavailable; using batch size %d without probing', probe, batch_size)
57
+ return batch_size
58
+
59
+
60
+ def smaller_pot(n: int) -> int:
61
+ """The largest power of two that is <= ``n``."""
62
+ if n < 1:
63
+ raise ValueError(f'n must be >= 1, got {n}')
64
+ return 1 << (n.bit_length() - 1)
65
+
66
+
67
+ def batch_count(sample_count: int, batch_size: int) -> int:
68
+ """How many batches a loader yields, the last one possibly short."""
69
+ return -(-sample_count // batch_size)
70
+
71
+
72
+ def is_out_of_memory(exception: BaseException) -> bool:
73
+ """Whether the exception signals exhausted memory, on the GPU or the host.
74
+
75
+ cuDNN and cuBLAS workspace failures raise a plain ``RuntimeError``, so the message has to be
76
+ matched too.
77
+ """
78
+ if isinstance(exception, MemoryError):
79
+ return True
80
+ message = str(exception).lower()
81
+ return any(text in message for text in ('out of memory', 'alloc_failed', 'cuda error: unknown error'))
@@ -0,0 +1,137 @@
1
+ """GPU memory budgeting and batch-size probing for trainers that train in-process.
2
+
3
+ ``--vram-limit-gb`` -- gigabytes of the card this training may use -- becomes both the budget a
4
+ probe measures against and the cap that holds the process to it, on whichever GPU the calling
5
+ process is using. The cap is a share of the card's *total* memory, not of what is free.
6
+
7
+ This is the one module in the library that imports torch, which the package does not declare, so
8
+ only a trainer may import it. The search itself is in :mod:`~learning_loop_node.trainer.batch_size`.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import gc
13
+ import logging
14
+ from collections.abc import Callable
15
+
16
+ import torch
17
+
18
+ from .batch_size import MAX_BATCH_SIZE, find_batch_size, is_out_of_memory, no_gpu_batch_size, smaller_pot
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ SAFETY_MARGIN = 0.05
23
+ """Share of the budget held back while probing, against allocator fragmentation later on."""
24
+
25
+
26
+ def probe_batch_size(run_batch: Callable[[int], str | None], *, probe: str = 'batch-size probe',
27
+ limit: int = 0, vram_limit_gb: float = 0) -> int:
28
+ """Find the largest power-of-two batch size ``run_batch`` fits into.
29
+
30
+ For a probe whose measurement is one call. A probe that has to build a throwaway model first
31
+ composes :func:`reserve_margin`, :func:`measured_fits` and ``find_batch_size`` itself.
32
+
33
+ :param run_batch: Runs the batch; may return a detail to append to the log line.
34
+ :param probe: Names this probe in the log, so a node running several stays readable.
35
+ :param limit: Caps the search, rounded down to a power of two; 0 means
36
+ :data:`~learning_loop_node.trainer.batch_size.MAX_BATCH_SIZE`.
37
+ :param vram_limit_gb: The budget the safety margin is a share of; 0 means the whole card.
38
+ :raises InsufficientMemoryError: If not even a batch size of 1 fits.
39
+ """
40
+ limit = smaller_pot(limit or MAX_BATCH_SIZE)
41
+
42
+ if not torch.cuda.is_available():
43
+ return no_gpu_batch_size(limit, probe)
44
+
45
+ margin = reserve_margin(vram_limit_gb, probe=probe)
46
+ try:
47
+ chosen = find_batch_size(measured_fits(run_batch, probe=probe), limit=limit)
48
+ finally:
49
+ del margin
50
+ free_cuda_memory()
51
+ logger.info('%s: selected batch size %d (upper bound %d)', probe, chosen, limit)
52
+ return chosen
53
+
54
+
55
+ def measured_fits(run_batch: Callable[[int], str | None], *, probe: str,
56
+ on_out_of_memory: Callable[[], None] | None = None) -> Callable[[int], bool]:
57
+ """Wrap ``run_batch`` into the ``fits`` predicate ``find_batch_size`` searches with.
58
+
59
+ An out-of-memory failure is the answer "does not fit"; anything else is re-raised. Both
60
+ arrive as the same exception types, so they are told apart by
61
+ :func:`~learning_loop_node.trainer.batch_size.is_out_of_memory`, not by ``except``.
62
+
63
+ :param run_batch: Runs the batch; may return a detail to append to the log line.
64
+ :param on_out_of_memory: Runs after a trial ran out of memory, to drop what it left behind
65
+ (an optimizer's gradients, say).
66
+ """
67
+ def fits(batch_size: int) -> bool:
68
+ free_cuda_memory()
69
+ try:
70
+ torch.cuda.reset_peak_memory_stats()
71
+ detail = run_batch(batch_size)
72
+ torch.cuda.synchronize()
73
+ logger.info('%s: %d fits (peak %.2f GB, margin included)%s', probe, batch_size,
74
+ torch.cuda.max_memory_allocated() / 1024**3, f'; {detail}' if detail else '')
75
+ return True
76
+ except (torch.cuda.OutOfMemoryError, RuntimeError, MemoryError) as exc:
77
+ if not isinstance(exc, torch.cuda.OutOfMemoryError) and not is_out_of_memory(exc):
78
+ raise
79
+ logger.info('%s: %d does not fit (%s)', probe, batch_size, type(exc).__name__)
80
+ if on_out_of_memory is not None:
81
+ on_out_of_memory()
82
+ return False
83
+
84
+ return fits
85
+
86
+
87
+ def reserve_margin(vram_limit_gb: float, *, probe: str) -> torch.Tensor:
88
+ """Claim :data:`SAFETY_MARGIN` of the budget, so a trial competes against a smaller card.
89
+
90
+ Keep the returned tensor alive for as long as the probe runs: releasing it hands the margin
91
+ back, and the chosen size is no longer the size that was measured.
92
+
93
+ :param vram_limit_gb: The budget the margin is a share of; 0 means the whole card.
94
+ """
95
+ margin_bytes = int(usable_memory_bytes(vram_limit_gb) * SAFETY_MARGIN)
96
+ logger.info('%s: keeping %.0f MB free as a safety margin', probe, margin_bytes / 1024**2)
97
+ return torch.empty(margin_bytes, dtype=torch.uint8, device='cuda')
98
+
99
+
100
+ def usable_memory_bytes(vram_limit_gb: float) -> int:
101
+ """How much GPU memory this process may allocate, honouring :func:`limit_cuda_memory`.
102
+
103
+ :param vram_limit_gb: 0 or less means the whole card.
104
+ """
105
+ total_bytes = torch.cuda.get_device_properties(None).total_memory
106
+ if vram_limit_gb <= 0:
107
+ return total_bytes
108
+ return min(total_bytes, int(vram_limit_gb * 1024**3))
109
+
110
+
111
+ def limit_cuda_memory(vram_limit_gb: float) -> None:
112
+ """Cap how much of the GPU this process may allocate, to ``vram_limit_gb`` gigabytes.
113
+
114
+ Call this once per process that touches the GPU, a spawned training process included: the
115
+ cap does not survive the spawn.
116
+
117
+ :param vram_limit_gb: 0 or less means no cap.
118
+ """
119
+ if vram_limit_gb <= 0 or not torch.cuda.is_available():
120
+ return
121
+
122
+ total_bytes = torch.cuda.get_device_properties(None).total_memory
123
+ fraction = vram_limit_gb * 1024**3 / total_bytes
124
+ total_gb = total_bytes / 1024**3
125
+
126
+ if fraction >= 1.0:
127
+ logger.warning('VRAM limit of %.1f GB exceeds the card capacity of %.1f GB; not limiting',
128
+ vram_limit_gb, total_gb)
129
+ return
130
+
131
+ torch.cuda.set_per_process_memory_fraction(fraction, None)
132
+ logger.info('Limiting VRAM usage to %.1f GB of %.1f GB (%.0f%%)', vram_limit_gb, total_gb, fraction * 100)
133
+
134
+
135
+ def free_cuda_memory() -> None:
136
+ gc.collect()
137
+ torch.cuda.empty_cache()
@@ -1,12 +1,16 @@
1
1
  class CriticalError(Exception):
2
- '''
2
+ """
3
3
  CriticalError is raised when the training cannot be continued.
4
4
  In this case the trainer jumps to the TrainerState.ReadyForCleanup and tries to upload the latest model.
5
- '''
5
+ """
6
+
7
+
8
+ class InsufficientMemoryError(RuntimeError):
9
+ """Raised when not even the smallest unit of work fits in memory."""
6
10
 
7
11
 
8
12
  class NodeNeedsRestartError(Exception):
9
- '''
13
+ """
10
14
  NodeNeedsRestartError is raised when the node needs to be restarted.
11
15
  This is e.g. the case when the GPU is not available anymore.
12
- '''
16
+ """
@@ -0,0 +1,28 @@
1
+ """Scoring a training from the confusion matrix the loop stores: one
2
+ ``{'tp': .., 'fp': .., 'fn': ..}`` per category."""
3
+
4
+ import statistics
5
+ from collections.abc import Mapping
6
+
7
+
8
+ def macro_f1(confusion_matrix: Mapping[str, Mapping[str, int]]) -> float:
9
+ """The unweighted mean of the per-category F1 scores, as the loop's UI shows it by default."""
10
+ scores = [category_f1(counts) for counts in confusion_matrix.values()]
11
+ return statistics.mean(scores) if scores else 0.0
12
+
13
+
14
+ def category_f1(counts: Mapping[str, int]) -> float:
15
+ """The F1 score of one category, 0.0 where it is undefined rather than a division error."""
16
+ tp, fp, fn = counts['tp'], counts['fp'], counts['fn']
17
+ precision = tp / (tp + fp) if tp + fp else 0.0
18
+ recall = tp / (tp + fn) if tp + fn else 0.0
19
+ return 2 * precision * recall / (precision + recall) if precision + recall else 0.0
20
+
21
+
22
+ def confusion_matrix_from_counts(true_positives: Mapping[str, int], false_positives: Mapping[str, int],
23
+ false_negatives: Mapping[str, int]) -> dict[str, dict[str, int]]:
24
+ """Assemble the loop's confusion matrix from three per-category count mappings."""
25
+ return {category: {'tp': true_positives.get(category, 0),
26
+ 'fp': false_positives.get(category, 0),
27
+ 'fn': false_negatives.get(category, 0)}
28
+ for category in {*true_positives, *false_positives, *false_negatives}}
@@ -0,0 +1,91 @@
1
+ """Run a blocking, CPU-bound generator in its own process without blocking the event loop.
2
+
3
+ The queue has ``maxsize=1``, so the producer never runs more than one item ahead of the consumer.
4
+ The context is spawn on every platform, so ``it`` and its arguments must be picklable and a
5
+ process that already initialised CUDA is never forked. An exception raised inside the process is
6
+ re-raised in the caller, and the process is killed if the caller leaves the context early.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import logging
12
+ import multiprocessing
13
+ import queue
14
+ from collections.abc import AsyncGenerator, Callable, Iterator
15
+ from contextlib import asynccontextmanager
16
+ from multiprocessing.queues import Queue as MPQueue
17
+ from typing import Any, ParamSpec, TypeVar
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ T = TypeVar('T')
22
+ P = ParamSpec('P')
23
+
24
+
25
+ @asynccontextmanager
26
+ async def iterator_cpu_bound(
27
+ it: Callable[P, Iterator[T]],
28
+ *args: P.args,
29
+ **kwargs: P.kwargs,
30
+ ) -> AsyncGenerator[AsyncGenerator[T, None], None]:
31
+ iterator = _iterator_cpu_bound_inner(it, *args, **kwargs)
32
+ try:
33
+ yield iterator
34
+ finally:
35
+ await asyncio.shield(iterator.aclose())
36
+
37
+
38
+ async def _iterator_cpu_bound_inner(
39
+ it: Callable[P, Iterator[T]],
40
+ *args: P.args,
41
+ **kwargs: P.kwargs,
42
+ ) -> AsyncGenerator[T, None]:
43
+ ctx = multiprocessing.get_context('spawn')
44
+ state_queue: MPQueue[T | Exception | IteratorDone] = ctx.Queue(maxsize=1)
45
+ process = ctx.Process(
46
+ target=_iterator_wrapper,
47
+ args=(it, state_queue, args, kwargs),
48
+ name='iterator_cpu_bound',
49
+ )
50
+
51
+ process.start()
52
+
53
+ try:
54
+ while True:
55
+ try:
56
+ item = await asyncio.to_thread(state_queue.get, True, 0.5)
57
+ except queue.Empty:
58
+ if not process.is_alive():
59
+ break
60
+ continue
61
+ match item:
62
+ case IteratorDone():
63
+ break
64
+ case Exception() as e:
65
+ raise e
66
+ case _ as other:
67
+ yield other
68
+ finally:
69
+ if process.is_alive():
70
+ process.kill()
71
+ process.join()
72
+
73
+
74
+ def _iterator_wrapper(
75
+ it: Callable[..., Iterator[T]],
76
+ state_queue: MPQueue[T | Exception | IteratorDone],
77
+ args: tuple[Any, ...],
78
+ kwargs: dict[str, Any],
79
+ ) -> None:
80
+ try:
81
+ for data in it(*args, **kwargs):
82
+ state_queue.put(data)
83
+ except Exception as e:
84
+ logger.exception('iterator_cpu_bound child process failed')
85
+ state_queue.put(e)
86
+
87
+ state_queue.put(IteratorDone())
88
+
89
+
90
+ class IteratorDone:
91
+ pass
@@ -161,27 +161,38 @@ class TrainerLogic(TrainerLogicGeneric):
161
161
 
162
162
  @abstractmethod
163
163
  async def _start_training_from_base_model(self) -> None:
164
- '''Should be used to start a training on executer, e.g. self.executor.start(cmd).'''
164
+ """Should be used to start a training on executer, e.g. self.executor.start(cmd)."""
165
165
 
166
166
  @abstractmethod
167
167
  async def _start_training_from_scratch(self) -> None:
168
- '''Should be used to start a training from scratch on executer, e.g. self.executor.start(cmd).
168
+ """Should be used to start a training from scratch on executer, e.g. self.executor.start(cmd).
169
169
  NOTE base_model_id is now accessible via self.training.base_model_id
170
- the id of a pretrained model provided by self.provided_pretrained_models.'''
170
+ the id of a pretrained model provided by self.provided_pretrained_models."""
171
171
 
172
172
  @abstractmethod
173
173
  def _can_resume(self) -> bool:
174
- '''Override this method to return True if the trainer can resume training.'''
174
+ """Override this method to return True if the trainer can resume training."""
175
175
 
176
176
  @abstractmethod
177
177
  async def _resume(self) -> None:
178
- '''Is called when self.can_resume() returns True.
179
- One may resume the training on a previously trained model stored by self.on_model_published(basic_model).'''
178
+ """Is called when self.can_resume() returns True.
179
+ One may resume the training on a previously trained model stored by self.on_model_published(basic_model)."""
180
180
 
181
- @abstractmethod
182
181
  def _get_executor_error_from_log(self) -> Optional[str]:
183
- '''Should be used to provide error informations to the Learning Loop by extracting data from self.executor.get_log().'''
182
+ """Reports what went wrong to the Learning Loop by reading self.executor's log.
183
+
184
+ The default recognises the CUDA failures every trainer hits. Override to add messages a
185
+ particular training framework produces, and call super() to keep these.
186
+ """
187
+ if self._executor is None:
188
+ return None
189
+ for line in self._executor.get_log_by_lines(tail=50):
190
+ if 'CUDA out of memory' in line:
191
+ return 'graphics card is out of memory'
192
+ if 'CUDA error: invalid device ordinal' in line:
193
+ return 'graphics card not found'
194
+ return None
184
195
 
185
196
  @abstractmethod
186
197
  async def _detect(self, model_information: ModelInformation, images: List[str], model_folder: str) -> List[Detections]:
187
- '''Called to run detections on a list of images.'''
198
+ """Called to run detections on a list of images."""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: learning_loop_node
3
- Version: 0.22.0
3
+ Version: 0.23.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
@@ -13,6 +13,7 @@ Requires-Dist: requests<3.0.0,>=2.33.0
13
13
  Requires-Dist: python-socketio<6.0.0,>=5.16.2
14
14
  Requires-Dist: aiofiles>=0.7.0
15
15
  Requires-Dist: python-multipart>=0.0.31
16
+ Requires-Dist: configargparse>=1.7.1
16
17
  Requires-Dist: psutil<8.0.0,>=5.9.0
17
18
  Requires-Dist: numpy<3.0,>=2.0
18
19
  Requires-Dist: Pillow<13.0.0,>=12.3.0
@@ -68,6 +69,10 @@ You can configure connection to our Learning Loop by specifying the following en
68
69
 
69
70
  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.
70
71
 
72
+ Where a name has an alias, either spelling works. If both are set to **different** values the
73
+ prefixed name wins and a warning names the value used — the variable is never treated as unset,
74
+ which would otherwise let `LOOP_HOST` fall back to its default of `learning-loop.ai`.
75
+
71
76
  #### Testing
72
77
 
73
78
  We use github actions for CI. Tests can also be executed locally by running
@@ -132,7 +137,10 @@ The detector also has a **SocketIO** upload endpoint that can be used to upload
132
137
  - `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.
133
138
  - `upload_priority`: Optional boolean flag to prioritize the upload (defaults to False)
134
139
 
135
- The endpoint returns None if the upload was successful and an error message otherwise.
140
+ The endpoint returns `{'status': 'OK'}` if the upload was successful and `{'error': '<message>'}` otherwise.
141
+
142
+ Besides detections and annotations, the metadata may carry `tags`, `source`, `created` and `state`.
143
+ `state` names the state the image should enter the loop in (e.g. `trash`); when it is omitted the loop applies its own default (`inbox`).
136
144
 
137
145
  For both ways to upload an image, the tag `picked_by_system` is automatically added to the image metadata.
138
146
 
@@ -13,14 +13,17 @@ learning_loop_node/data_classes/annotation_data.py,sha256=NfMlTv2_5AfVY_JDM4tbjE
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=y_seXgqCWGoUCFzwZKt8eG8rAlANIG8VhzVVivZLklU,7334
16
- learning_loop_node/data_classes/image_metadata.py,sha256=4FFs89iZAfr2Jh-40QqZ_GGmJ8hl_W61eHcCqHflJLE,2293
16
+ learning_loop_node/data_classes/image_metadata.py,sha256=QPLJ4gcVj33xaaCFF1n5e8G6higOJ6iIKy3YhGZMfkw,2459
17
17
  learning_loop_node/data_classes/socket_response.py,sha256=tIdt-oYf6ULoJIDYQCecNM9OtWR6_wJ9tL0Ksu83Vko,655
18
18
  learning_loop_node/data_classes/training.py,sha256=TybwcCDf_NUaDUaOj30lPm-7Z3Qk9XFRibEX5qIv96Y,5737
19
19
  learning_loop_node/detector/__init__.py,sha256=wroy0W1X5MX16C-peA-UHYrcdXet7GhleIlC_Pqa6hI,128
20
+ learning_loop_node/detector/categories.py,sha256=qgquuop3geKXXsNBurO0eC2b64h_ojL37RBYMv0ZzOg,1147
20
21
  learning_loop_node/detector/detector_logic.py,sha256=XJaHNiw5PaHoCWnJ-qsxdUELLevvztYx54TQ8KcHl18,1479
21
- learning_loop_node/detector/detector_node.py,sha256=As8QW2LaR81hsZePONAuf0RBNfoDEBEF18tIRM_-evs,35834
22
+ learning_loop_node/detector/detector_node.py,sha256=GC5CaGcW_qVxyolDEz9U5W7y0ejZGEu9FZ__ckLxd7g,35728
22
23
  learning_loop_node/detector/exceptions.py,sha256=C6KbNPlSbtfgDrZx2Hbhm7Suk9jVoR3fMRCO0CkrMsQ,196
24
+ learning_loop_node/detector/geometry.py,sha256=bZCF9xuK7qy6Ipv5aQYrTlwSmjaiUz5VEUYnc1zCe-w,1101
23
25
  learning_loop_node/detector/outbox.py,sha256=6F0MjFM-zNX-xWP_YGbmU0UYuyb0odsX_OcAxMPSzS0,12288
26
+ learning_loop_node/detector/postprocess.py,sha256=fPBbgE6Obkej709zoFPGqA2gnbJNbpbMuZogilKy4pI,7274
24
27
  learning_loop_node/detector/inbox_filter/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
25
28
  learning_loop_node/detector/inbox_filter/cam_observation_history.py,sha256=slPGm87TbBCfDUdpRTAM5yOz1An8Br5X_2HFJOGSrFg,3540
26
29
  learning_loop_node/detector/inbox_filter/relevance_filter.py,sha256=81XQV6HST2hSH-In7VF8B4BfubgUdRHTLcc16VLjUxc,1518
@@ -39,23 +42,28 @@ learning_loop_node/enums/general.py,sha256=OSpcIpdFjuRByON2xBoiIXl1SgDREIqkwnaxk
39
42
  learning_loop_node/enums/trainer.py,sha256=VaD63guLO4aKgVfXT0EryPlXKQGegSET3Cp4R0uC_Ew,746
40
43
  learning_loop_node/helpers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
41
44
  learning_loop_node/helpers/background_tasks.py,sha256=sbbS8JO1ls0ZCbNW68KOT9QLOFyFoJRXwwAq78iOmoA,3256
42
- learning_loop_node/helpers/environment_reader.py,sha256=6DxDJecLHxiGczByhyVa_JssAwwft7vuNCGaEzoSY2I,1662
45
+ learning_loop_node/helpers/entrypoint.py,sha256=EtRMXZKzNapwbIylf4hg8Obw-2EydrLGKUjbIn5H-94,3591
46
+ learning_loop_node/helpers/environment_reader.py,sha256=zsoFysxNxBCOJzUwX4Pbm3ftYhxx5zsbOHQfD9VcfvA,1937
43
47
  learning_loop_node/helpers/gdrive_downloader.py,sha256=zeYJciTAJVRpu_eFjwgYLCpIa6hU1d71anqEBb564Rk,1145
44
48
  learning_loop_node/helpers/log_conf.py,sha256=hqVAa_9NnYEU6N0dcOKmph82p7MpgKqeF_eomTLYzWY,961
45
49
  learning_loop_node/helpers/misc.py,sha256=qdN18Ukbw6eR6sFL4BxNq0EZzg2tVm2Cm8cTbFyy8QU,8931
46
50
  learning_loop_node/helpers/run.py,sha256=_uox-j3_K_bL3yCAwy3JYSOiIxrnhzVxyxWpCe8_J9U,876
47
51
  learning_loop_node/trainer/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
52
+ learning_loop_node/trainer/batch_size.py,sha256=Ma_nH3PHAk2gzqdkNXstNFqwXUUTt7pVe4aqWRrkHX4,2914
53
+ learning_loop_node/trainer/cuda.py,sha256=LkXJaJ404S64XGRTMqqULB9e6g85dJfjLIAn12lnpTY,5968
48
54
  learning_loop_node/trainer/downloader.py,sha256=AIyEKM4XYtd6VgmXrP3VayV9DpJzdURK1Brx81ePNSM,1470
49
- learning_loop_node/trainer/exceptions.py,sha256=vbuoE6kssLQuA8zd3LiDHmZglP6E2IJJwEi5AZtWXxY,420
55
+ learning_loop_node/trainer/exceptions.py,sha256=IRT7qvn7-RjXaqL8mF21ySm7JWhQtqm1Cl0Gi3DChVY,540
50
56
  learning_loop_node/trainer/executor.py,sha256=XQ1TwgOF2pQv7bgzSaljDWG4I1ySjYXL8r9c_p9MX1Q,3947
51
57
  learning_loop_node/trainer/io_helpers.py,sha256=zWl0-HLP8zxOG3ybm76vXgEozULj_1QRl95jkrF126k,7255
58
+ learning_loop_node/trainer/metrics.py,sha256=H1jQh5fHN3-cTdMTuH8mF_uODV0T9DBdqcPUD-ZqFAI,1439
59
+ learning_loop_node/trainer/subprocess.py,sha256=WxuTly0zhpClEjTd9wqoI_-7YTNMaiZpRHxu00vttKw,2647
52
60
  learning_loop_node/trainer/test_executor.py,sha256=6BVGDN_6f5GEMMEvDLSG1yzMybSvgXaP5uYpSfsVPP0,2224
53
- learning_loop_node/trainer/trainer_logic.py,sha256=BgkeDEVaRVuRETYMt2rKe5cspExTgnO7LXNVYVbIcsw,8679
61
+ learning_loop_node/trainer/trainer_logic.py,sha256=Yaw1GNTC05rVLQczY6vKUgkymBYfm3_0XaR5C0q-CP4,9159
54
62
  learning_loop_node/trainer/trainer_logic_generic.py,sha256=IRP68ZGpnOX4OS03OwB7HmTUvk6nbBRmCjMCVupGE_Q,27196
55
63
  learning_loop_node/trainer/trainer_node.py,sha256=ZnydfHJ2ehAhb821Xke_09qthCE3vDf7iqeYUrliSSo,4938
56
64
  learning_loop_node/trainer/rest/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
57
65
  learning_loop_node/trainer/rest/backdoor_controls.py,sha256=ZnK8ypY5r_q0-YZbtaOxhQThzuZvMsQHM5gJGESd_dE,5131
58
- learning_loop_node-0.22.0.dist-info/METADATA,sha256=AuMYnVs2JgNTb3LNoMJmG0XdjhU2dUpOH_y7L-VpYMk,17030
59
- learning_loop_node-0.22.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
60
- learning_loop_node-0.22.0.dist-info/top_level.txt,sha256=nS5qiFc4stVF_VXVCVJxZnrqVOv3IGX5-_E3sLby6OE,19
61
- learning_loop_node-0.22.0.dist-info/RECORD,,
66
+ learning_loop_node-0.23.0.dist-info/METADATA,sha256=L8ASHsKUg7Z_JGji4tqDJEbsCf44tZoKiPsZ4YeU6oo,17607
67
+ learning_loop_node-0.23.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
68
+ learning_loop_node-0.23.0.dist-info/top_level.txt,sha256=nS5qiFc4stVF_VXVCVJxZnrqVOv3IGX5-_E3sLby6OE,19
69
+ learning_loop_node-0.23.0.dist-info/RECORD,,