learning-loop-node 0.22.0__py3-none-any.whl → 0.24.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- learning_loop_node/data_classes/image_metadata.py +2 -0
- learning_loop_node/detector/categories.py +27 -0
- learning_loop_node/detector/detector_node.py +26 -22
- learning_loop_node/detector/geometry.py +39 -0
- learning_loop_node/detector/outbox.py +4 -2
- learning_loop_node/detector/postprocess.py +219 -0
- learning_loop_node/helpers/entrypoint.py +87 -0
- learning_loop_node/helpers/environment_reader.py +13 -7
- learning_loop_node/trainer/batch_size.py +126 -0
- learning_loop_node/trainer/cuda.py +279 -0
- learning_loop_node/trainer/exceptions.py +8 -4
- learning_loop_node/trainer/metrics.py +28 -0
- learning_loop_node/trainer/subprocess.py +91 -0
- learning_loop_node/trainer/trainer_logic.py +20 -9
- {learning_loop_node-0.22.0.dist-info → learning_loop_node-0.24.0.dist-info}/METADATA +22 -2
- {learning_loop_node-0.22.0.dist-info → learning_loop_node-0.24.0.dist-info}/RECORD +18 -10
- {learning_loop_node-0.22.0.dist-info → learning_loop_node-0.24.0.dist-info}/WHEEL +0 -0
- {learning_loop_node-0.22.0.dist-info → learning_loop_node-0.24.0.dist-info}/top_level.txt +0 -0
|
@@ -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
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
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
|
|
@@ -37,7 +37,7 @@ class Outbox():
|
|
|
37
37
|
It handles upload failures by splitting the upload into two smaller batches until the problematic image is identified - and removed.
|
|
38
38
|
Any image can be saved to the normal or the priority queue.
|
|
39
39
|
Images in the priority queue are uploaded first.
|
|
40
|
-
The
|
|
40
|
+
The normal queue is limited to MAX_OUTBOX_SIZE images (default 1000); the oldest are dropped first.
|
|
41
41
|
"""
|
|
42
42
|
|
|
43
43
|
def __init__(self) -> None:
|
|
@@ -59,7 +59,9 @@ class Outbox():
|
|
|
59
59
|
self.log.info('Outbox initialized with target_uri: %s', self.target_uri)
|
|
60
60
|
|
|
61
61
|
self.BATCH_SIZE = 20
|
|
62
|
-
self.MAX_UPLOAD_LENGTH = 1000 # only
|
|
62
|
+
self.MAX_UPLOAD_LENGTH = int(os.environ.get('MAX_OUTBOX_SIZE', '1000')) # only limits `upload_folders`
|
|
63
|
+
if self.MAX_UPLOAD_LENGTH <= 0:
|
|
64
|
+
raise ValueError(f'MAX_OUTBOX_SIZE must be positive, got {self.MAX_UPLOAD_LENGTH}')
|
|
63
65
|
self.UPLOAD_INTERVAL_S = 5
|
|
64
66
|
self.UPLOAD_TIMEOUT_S = 30
|
|
65
67
|
|
|
@@ -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,87 @@
|
|
|
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
|
+
from ..trainer.batch_size import VRAM_LIMIT_GB_FLAG, VRAM_LIMIT_GB_HELP
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def node_parser(*, description: str, legacy_env_prefix: str = '',
|
|
21
|
+
vram_limit: bool = False) -> configargparse.ArgumentParser:
|
|
22
|
+
"""Build the parser for a node, pre-loaded with the settings every node has.
|
|
23
|
+
|
|
24
|
+
:param legacy_env_prefix: A prefix an earlier version of this node required, e.g.
|
|
25
|
+
``'MY_DETECTOR_'``. Both spellings keep working, with a warning: the prefix on the
|
|
26
|
+
current name (``MY_DETECTOR_NODE_HOST``) and the prefix on the flag it was originally
|
|
27
|
+
applied to (``MY_DETECTOR_HOST``). Leave empty for a node that never used one.
|
|
28
|
+
:param vram_limit: Add :data:`~learning_loop_node.trainer.batch_size.VRAM_LIMIT_GB_FLAG`.
|
|
29
|
+
"""
|
|
30
|
+
parser = _NodeArgumentParser(description=description, legacy_env_prefix=legacy_env_prefix)
|
|
31
|
+
parser.add_argument('--host', default='0.0.0.0', env_var='NODE_HOST',
|
|
32
|
+
help='Host interface to bind to')
|
|
33
|
+
parser.add_argument('--port', type=int, default=80, env_var='NODE_PORT', help='Port to bind to')
|
|
34
|
+
if vram_limit:
|
|
35
|
+
parser.add_argument(VRAM_LIMIT_GB_FLAG, type=float, default=0, help=VRAM_LIMIT_GB_HELP)
|
|
36
|
+
return parser
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def run_node(app: str, args: Namespace) -> None:
|
|
40
|
+
"""Serve the node.
|
|
41
|
+
|
|
42
|
+
:param app: Import string of the node object, conventionally ``'main:node'``.
|
|
43
|
+
"""
|
|
44
|
+
reload = os.getenv('UVICORN_RELOAD', 'FALSE').lower() in ('true', '1')
|
|
45
|
+
logger.info('Uvicorn reload is set to: %s', reload)
|
|
46
|
+
uvicorn.run(app, host=args.host, port=args.port, lifespan='on', reload=reload)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class _NodeArgumentParser(configargparse.ArgumentParser):
|
|
50
|
+
"""Parser whose every setting is also an environment variable named after its flag."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, *, description: str, legacy_env_prefix: str) -> None:
|
|
53
|
+
super().__init__(description=description)
|
|
54
|
+
self.legacy_env_prefix = legacy_env_prefix
|
|
55
|
+
|
|
56
|
+
def add_argument(self, *args, **kwargs) -> Action: # type: ignore[override]
|
|
57
|
+
action = super().add_argument(*args, **kwargs)
|
|
58
|
+
if getattr(action, 'env_var', None) is None and action.dest != 'help':
|
|
59
|
+
action.env_var = action.dest.upper()
|
|
60
|
+
return action
|
|
61
|
+
|
|
62
|
+
def parse_args(self, *args, **kwargs) -> Namespace: # type: ignore[override]
|
|
63
|
+
self._adopt_legacy_env_vars()
|
|
64
|
+
return super().parse_args(*args, **kwargs)
|
|
65
|
+
|
|
66
|
+
def _adopt_legacy_env_vars(self) -> None:
|
|
67
|
+
if not self.legacy_env_prefix:
|
|
68
|
+
return
|
|
69
|
+
for action in self._actions:
|
|
70
|
+
name = getattr(action, 'env_var', None)
|
|
71
|
+
if not name or name in os.environ:
|
|
72
|
+
continue
|
|
73
|
+
for legacy in self._legacy_names(action, name):
|
|
74
|
+
if legacy not in os.environ:
|
|
75
|
+
continue
|
|
76
|
+
os.environ[name] = os.environ[legacy]
|
|
77
|
+
logger.warning('%s is deprecated and will stop being read; set %s instead', legacy, name)
|
|
78
|
+
break
|
|
79
|
+
|
|
80
|
+
def _legacy_names(self, action: Action, name: str) -> list[str]:
|
|
81
|
+
"""The prefixed names this setting may still be configured under, preferred first.
|
|
82
|
+
|
|
83
|
+
A prefix used to be applied to the *flag*, so the old name of ``--host`` is
|
|
84
|
+
``<PREFIX>HOST``, not ``<PREFIX>NODE_HOST``.
|
|
85
|
+
"""
|
|
86
|
+
candidates = [self.legacy_env_prefix + name, self.legacy_env_prefix + action.dest.upper()]
|
|
87
|
+
return list(dict.fromkeys(candidates))
|
|
@@ -1,26 +1,32 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
import os
|
|
3
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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,126 @@
|
|
|
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, Mapping
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from .exceptions import InsufficientMemoryError
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
REQUESTED_BATCH_SIZE = 'max_batch_size'
|
|
21
|
+
"""The hyperparameter every node reads its `measure_batch_size` argument out of.
|
|
22
|
+
|
|
23
|
+
0 or absent means the card decides. It is an input only: a trainer reports the size it settled on
|
|
24
|
+
under a different key, conventionally ``batch_size``, and never writes it back here.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
VRAM_LIMIT_GB_FLAG = '--vram-limit-gb'
|
|
28
|
+
"""The flag a trainer takes its GPU budget from; ``VRAM_LIMIT_GB`` follows from the name."""
|
|
29
|
+
|
|
30
|
+
VRAM_LIMIT_GB_HELP = ('Gigabytes of GPU memory a training may use. The batch size is probed against this limit '
|
|
31
|
+
'instead of the whole card, so a lower limit yields a smaller batch size rather than an '
|
|
32
|
+
'out-of-memory error. Use it to share a GPU or to keep headroom against fragmentation. '
|
|
33
|
+
"The limit is relative to the card's total memory, not to what is currently free. "
|
|
34
|
+
'0 (default) means no limit.')
|
|
35
|
+
|
|
36
|
+
MAX_BATCH_SIZE = 1024
|
|
37
|
+
"""Where a search stops when its caller sets no bound of its own."""
|
|
38
|
+
|
|
39
|
+
NO_GPU_BATCH_SIZE = 8
|
|
40
|
+
"""Batch size used when there is no GPU to probe."""
|
|
41
|
+
|
|
42
|
+
MIN_TRAIN_STEPS_PER_EPOCH = 8
|
|
43
|
+
"""Fewest optimizer steps an epoch must have; :func:`dataset_limit` is derived from it."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def find_batch_size(fits: Callable[[int], bool], *, limit: int, minimum: int = 1,
|
|
47
|
+
candidate: int = 0) -> int:
|
|
48
|
+
"""Return the largest batch size that fits, never exceeding ``limit``.
|
|
49
|
+
|
|
50
|
+
Powers of two, plus ``minimum`` and ``candidate``, the two sizes outside that set this will
|
|
51
|
+
return, and only because somebody named them and they then measured.
|
|
52
|
+
|
|
53
|
+
:param fits: Runs a representative probe; ``False`` on out-of-memory.
|
|
54
|
+
:param limit: Upper bound; the doubling stops at the largest power of two within it.
|
|
55
|
+
:param minimum: Smallest size to try, as named; the doubling continues from the next power of
|
|
56
|
+
two above it. Raise it above one for a step that cannot run on a single sample at all:
|
|
57
|
+
BatchNorm over a 1x1 feature map, a validation pass that halves the batch.
|
|
58
|
+
:param candidate: An exact size, tried once the doubling has reached its ceiling, so a size
|
|
59
|
+
that was asked for is used as asked for rather than rounded down. Ignored unless it lies
|
|
60
|
+
between that ceiling and ``limit``; a bound nobody named — one derived from the dataset,
|
|
61
|
+
say — must not be passed here.
|
|
62
|
+
:raises InsufficientMemoryError: If not even ``minimum`` fits.
|
|
63
|
+
"""
|
|
64
|
+
minimum = max(1, minimum)
|
|
65
|
+
bound = max(limit, minimum)
|
|
66
|
+
ceiling = max(smaller_pot(bound), minimum)
|
|
67
|
+
|
|
68
|
+
if not fits(minimum):
|
|
69
|
+
raise InsufficientMemoryError(f'batch size {minimum} does not fit in memory')
|
|
70
|
+
|
|
71
|
+
size = minimum
|
|
72
|
+
while size < ceiling and fits(smaller_pot(size) * 2):
|
|
73
|
+
size = smaller_pot(size) * 2
|
|
74
|
+
|
|
75
|
+
if size == ceiling and ceiling < candidate <= bound and fits(candidate):
|
|
76
|
+
size = candidate
|
|
77
|
+
|
|
78
|
+
return size
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def requested_batch_size(hyperparameters: Mapping[str, Any]) -> int:
|
|
82
|
+
"""The bound a training asked for, read out of the hyperparameters the loop sent.
|
|
83
|
+
|
|
84
|
+
An unfilled field arrives as absent, ``None`` or ``''``, and all three mean no bound.
|
|
85
|
+
|
|
86
|
+
:raises ValueError: If the value is there but is not a number.
|
|
87
|
+
"""
|
|
88
|
+
return int(hyperparameters.get(REQUESTED_BATCH_SIZE, 0) or 0)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def dataset_limit(sample_count: int) -> int:
|
|
92
|
+
"""The batch size ceiling that still leaves ``MIN_TRAIN_STEPS_PER_EPOCH`` steps per epoch."""
|
|
93
|
+
if sample_count < 1:
|
|
94
|
+
raise ValueError(f'sample_count must be >= 1, got {sample_count}')
|
|
95
|
+
return max(1, sample_count // MIN_TRAIN_STEPS_PER_EPOCH)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def no_gpu_batch_size(limit: int, probe: str, minimum: int = 1) -> int:
|
|
99
|
+
"""The batch size to fall back on when there is no GPU to probe; never below ``minimum``."""
|
|
100
|
+
batch_size = max(min(smaller_pot(limit), NO_GPU_BATCH_SIZE), minimum)
|
|
101
|
+
logger.warning('%s: CUDA is unavailable; using batch size %d without probing', probe, batch_size)
|
|
102
|
+
return batch_size
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def smaller_pot(n: int) -> int:
|
|
106
|
+
"""The largest power of two that is <= ``n``."""
|
|
107
|
+
if n < 1:
|
|
108
|
+
raise ValueError(f'n must be >= 1, got {n}')
|
|
109
|
+
return 1 << (n.bit_length() - 1)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def batch_count(sample_count: int, batch_size: int) -> int:
|
|
113
|
+
"""How many batches a loader yields, the last one possibly short."""
|
|
114
|
+
return -(-sample_count // batch_size)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def is_out_of_memory(exception: BaseException) -> bool:
|
|
118
|
+
"""Whether the exception signals exhausted memory, on the GPU or the host.
|
|
119
|
+
|
|
120
|
+
cuDNN and cuBLAS workspace failures raise a plain ``RuntimeError``, so the message has to be
|
|
121
|
+
matched too.
|
|
122
|
+
"""
|
|
123
|
+
if isinstance(exception, MemoryError):
|
|
124
|
+
return True
|
|
125
|
+
message = str(exception).lower()
|
|
126
|
+
return any(text in message for text in ('out of memory', 'alloc_failed', 'cuda error: unknown error'))
|
|
@@ -0,0 +1,279 @@
|
|
|
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 abc import ABC, abstractmethod
|
|
15
|
+
from argparse import ArgumentParser
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
|
|
18
|
+
import torch
|
|
19
|
+
|
|
20
|
+
from .batch_size import (
|
|
21
|
+
MAX_BATCH_SIZE,
|
|
22
|
+
REQUESTED_BATCH_SIZE,
|
|
23
|
+
VRAM_LIMIT_GB_FLAG,
|
|
24
|
+
VRAM_LIMIT_GB_HELP,
|
|
25
|
+
dataset_limit,
|
|
26
|
+
find_batch_size,
|
|
27
|
+
is_out_of_memory,
|
|
28
|
+
no_gpu_batch_size,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
SAFETY_MARGIN = 0.05
|
|
34
|
+
"""Share of the budget held back while probing, against allocator fragmentation later on."""
|
|
35
|
+
|
|
36
|
+
DEFAULT_STEPS_PER_TRIAL = 4
|
|
37
|
+
"""Training steps a batch size must survive before it counts as fitting.
|
|
38
|
+
|
|
39
|
+
Some trainings reach their peak only after a few steps: an optimizer that allocates its state on
|
|
40
|
+
the first one, gradients that accumulate, a scaler that skips the first updates.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
_budget_gb: float = 0
|
|
44
|
+
"""What :func:`limit_cuda_memory` capped this process to; 0 means the whole card."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ProbeStep(ABC):
|
|
48
|
+
"""A training cycle built for one batch-size probe and released once the probe is done.
|
|
49
|
+
|
|
50
|
+
:func:`measure_batch_size` builds it through a factory, after the safety margin is reserved,
|
|
51
|
+
and holds the only reference to it, so nothing built for the probe stays on the card for the
|
|
52
|
+
training that follows. A trial at one batch size is ``steps_per_trial`` calls of
|
|
53
|
+
:meth:`train_step` followed by one of :meth:`val_step`; a subclass implements both, the other
|
|
54
|
+
two are optional.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
@abstractmethod
|
|
58
|
+
def train_step(self, batch_size: int) -> str | None:
|
|
59
|
+
"""Run one training step at ``batch_size``; may return a detail to append to the log line."""
|
|
60
|
+
|
|
61
|
+
@abstractmethod
|
|
62
|
+
def val_step(self, batch_size: int) -> str | None:
|
|
63
|
+
"""Run the validation a training at ``batch_size`` runs between epochs.
|
|
64
|
+
|
|
65
|
+
Validate at the batch size the training validates with, which need not be ``batch_size``
|
|
66
|
+
(YOLOv5 halves it). A training that does not validate returns ``None`` without running
|
|
67
|
+
anything.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def on_out_of_memory(self) -> None: # noqa: B027
|
|
71
|
+
"""Drop what a trial that ran out of memory left behind (an optimizer's gradients, say)."""
|
|
72
|
+
|
|
73
|
+
def release(self) -> None: # noqa: B027
|
|
74
|
+
"""Undo what building the step changed outside it, such as moving the real model off the card."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def measure_batch_size(build_step: Callable[[], ProbeStep], *, max_batch_size: int = 0,
|
|
78
|
+
sample_count: int | None = None, probe: str = 'batch-size probe',
|
|
79
|
+
minimum: int = 1, steps_per_trial: int = DEFAULT_STEPS_PER_TRIAL) -> int:
|
|
80
|
+
"""Settle a training's batch size against what it asked for and what the card allows.
|
|
81
|
+
|
|
82
|
+
Every training enters here, whatever shape its hyperparameters have; :func:`probe_batch_size` is
|
|
83
|
+
for a probe with no requested size to honour, such as a detection pass bounded only by how many
|
|
84
|
+
images there are. The step is built only once there is a card to probe.
|
|
85
|
+
|
|
86
|
+
:param build_step: Builds the step the trials run; called once, after the margin is reserved.
|
|
87
|
+
:param max_batch_size: What the training asked for, as carried in the
|
|
88
|
+
:data:`~.batch_size.REQUESTED_BATCH_SIZE` hyperparameter: the largest batch it may use,
|
|
89
|
+
measured rather than trusted. A size that fits is used as asked for, whether or not it is a
|
|
90
|
+
power of two; one that does not becomes the largest power of two below it that does. 0 means
|
|
91
|
+
the card decides alone. A request below ``minimum`` is raised to it, with a warning.
|
|
92
|
+
:param sample_count: Samples in the training split, when the caller knows it. The search is
|
|
93
|
+
then bounded by :func:`~.batch_size.dataset_limit`; that bound is never used as the exact
|
|
94
|
+
candidate.
|
|
95
|
+
:param probe: Names this probe in the log, so a node running several stays readable.
|
|
96
|
+
:param minimum: Smallest size to try, taking precedence over ``max_batch_size``; see
|
|
97
|
+
:func:`~.batch_size.find_batch_size`.
|
|
98
|
+
:param steps_per_trial: Training steps a size must survive, each trial then ending in one
|
|
99
|
+
validation step.
|
|
100
|
+
:raises InsufficientMemoryError: If not even ``minimum`` fits.
|
|
101
|
+
:raises ValueError: If the training asked for a negative batch size, or ``steps_per_trial`` is
|
|
102
|
+
below one.
|
|
103
|
+
"""
|
|
104
|
+
if max_batch_size < 0:
|
|
105
|
+
raise ValueError(f'{REQUESTED_BATCH_SIZE} must be >= 0, got {max_batch_size}')
|
|
106
|
+
if steps_per_trial < 1:
|
|
107
|
+
raise ValueError(f'steps_per_trial must be >= 1, got {steps_per_trial}')
|
|
108
|
+
if 0 < max_batch_size < minimum:
|
|
109
|
+
logger.warning('%s: requested %s=%d is below the trainer minimum of %d; using %d', probe,
|
|
110
|
+
REQUESTED_BATCH_SIZE, max_batch_size, minimum, minimum)
|
|
111
|
+
|
|
112
|
+
limit = max_batch_size
|
|
113
|
+
if sample_count is not None:
|
|
114
|
+
limit = min(limit or MAX_BATCH_SIZE, dataset_limit(sample_count))
|
|
115
|
+
logger.info('%s: %d training samples allow at most %d per batch', probe, sample_count,
|
|
116
|
+
dataset_limit(sample_count))
|
|
117
|
+
|
|
118
|
+
return _probe(build_step, probe=probe, limit=limit, steps_per_trial=steps_per_trial,
|
|
119
|
+
candidate=max_batch_size, minimum=minimum)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def probe_batch_size(run_batch: Callable[[int], str | None], *, probe: str = 'batch-size probe',
|
|
123
|
+
limit: int = 0) -> int:
|
|
124
|
+
"""Find the largest batch that fits for a pass with no requested size, such as a detection pass.
|
|
125
|
+
|
|
126
|
+
:param run_batch: Runs the batch; may return a detail to append to the log line.
|
|
127
|
+
:param probe: Names this probe in the log, so a node running several stays readable.
|
|
128
|
+
:param limit: Caps the search; 0 means
|
|
129
|
+
:data:`~learning_loop_node.trainer.batch_size.MAX_BATCH_SIZE`.
|
|
130
|
+
:raises InsufficientMemoryError: If not even a batch of one fits.
|
|
131
|
+
"""
|
|
132
|
+
return _probe(lambda: _PassStep(run_batch), probe=probe, limit=limit, steps_per_trial=1)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class _PassStep(ProbeStep):
|
|
136
|
+
"""A plain callable as a step; there is nothing of its own to build, validate or release."""
|
|
137
|
+
|
|
138
|
+
def __init__(self, run_batch: Callable[[int], str | None]) -> None:
|
|
139
|
+
self._run_batch = run_batch
|
|
140
|
+
|
|
141
|
+
def train_step(self, batch_size: int) -> str | None:
|
|
142
|
+
return self._run_batch(batch_size)
|
|
143
|
+
|
|
144
|
+
def val_step(self, batch_size: int) -> None:
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _probe(build_step: Callable[[], ProbeStep], *, probe: str, limit: int, steps_per_trial: int,
|
|
149
|
+
candidate: int = 0, minimum: int = 1) -> int:
|
|
150
|
+
"""Everything of a probe but the step: the GPU check, the margin, the search, the release.
|
|
151
|
+
|
|
152
|
+
The margin is a share of the budget :func:`limit_cuda_memory` set in this process.
|
|
153
|
+
"""
|
|
154
|
+
bound = max(limit or MAX_BATCH_SIZE, minimum)
|
|
155
|
+
|
|
156
|
+
if not torch.cuda.is_available():
|
|
157
|
+
return no_gpu_batch_size(bound, probe, minimum)
|
|
158
|
+
|
|
159
|
+
margin = reserve_margin(probe=probe)
|
|
160
|
+
try:
|
|
161
|
+
chosen = _search(build_step, probe=probe, bound=bound, steps_per_trial=steps_per_trial,
|
|
162
|
+
minimum=minimum, candidate=candidate)
|
|
163
|
+
finally:
|
|
164
|
+
del margin
|
|
165
|
+
free_cuda_memory()
|
|
166
|
+
logger.info('%s: selected batch size %d (upper bound %d)', probe, chosen, bound)
|
|
167
|
+
return chosen
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _search(build_step: Callable[[], ProbeStep], *, probe: str, bound: int, steps_per_trial: int,
|
|
171
|
+
minimum: int, candidate: int) -> int:
|
|
172
|
+
"""Build the step, search with it and release it; once this returns, nothing references it."""
|
|
173
|
+
step = build_step()
|
|
174
|
+
try:
|
|
175
|
+
fits = measured_fits(_trial(step, steps_per_trial), probe=probe, on_out_of_memory=step.on_out_of_memory)
|
|
176
|
+
return find_batch_size(fits, limit=bound, minimum=minimum, candidate=candidate)
|
|
177
|
+
finally:
|
|
178
|
+
step.release()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _trial(step: ProbeStep, steps_per_trial: int) -> Callable[[int], str | None]:
|
|
182
|
+
"""One trial of ``step``: its training steps, then its validation."""
|
|
183
|
+
def run_trial(batch_size: int) -> str | None:
|
|
184
|
+
train_detail = None
|
|
185
|
+
for _ in range(steps_per_trial):
|
|
186
|
+
train_detail = step.train_step(batch_size)
|
|
187
|
+
val_detail = step.val_step(batch_size)
|
|
188
|
+
return '; '.join(detail for detail in (train_detail, val_detail) if detail) or None
|
|
189
|
+
|
|
190
|
+
return run_trial
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def measured_fits(run_batch: Callable[[int], str | None], *, probe: str,
|
|
194
|
+
on_out_of_memory: Callable[[], None] | None = None) -> Callable[[int], bool]:
|
|
195
|
+
"""Wrap ``run_batch`` into the ``fits`` predicate ``find_batch_size`` searches with.
|
|
196
|
+
|
|
197
|
+
An out-of-memory failure is the answer "does not fit"; anything else is re-raised. Both
|
|
198
|
+
arrive as the same exception types, so they are told apart by
|
|
199
|
+
:func:`~learning_loop_node.trainer.batch_size.is_out_of_memory`, not by ``except``.
|
|
200
|
+
|
|
201
|
+
:param run_batch: Runs the batch; may return a detail to append to the log line.
|
|
202
|
+
:param on_out_of_memory: Runs after a trial ran out of memory, to drop what it left behind
|
|
203
|
+
(an optimizer's gradients, say).
|
|
204
|
+
"""
|
|
205
|
+
def fits(batch_size: int) -> bool:
|
|
206
|
+
free_cuda_memory()
|
|
207
|
+
try:
|
|
208
|
+
torch.cuda.reset_peak_memory_stats()
|
|
209
|
+
detail = run_batch(batch_size)
|
|
210
|
+
torch.cuda.synchronize()
|
|
211
|
+
logger.info('%s: %d fits (peak %.2f GB, margin included)%s', probe, batch_size,
|
|
212
|
+
torch.cuda.max_memory_allocated() / 1024**3, f'; {detail}' if detail else '')
|
|
213
|
+
return True
|
|
214
|
+
except (torch.cuda.OutOfMemoryError, RuntimeError, MemoryError) as exc:
|
|
215
|
+
if not isinstance(exc, torch.cuda.OutOfMemoryError) and not is_out_of_memory(exc):
|
|
216
|
+
raise
|
|
217
|
+
logger.info('%s: %d does not fit (%s)', probe, batch_size, type(exc).__name__)
|
|
218
|
+
if on_out_of_memory is not None:
|
|
219
|
+
on_out_of_memory()
|
|
220
|
+
return False
|
|
221
|
+
|
|
222
|
+
return fits
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def reserve_margin(*, probe: str) -> torch.Tensor:
|
|
226
|
+
"""Claim :data:`SAFETY_MARGIN` of the budget, so a trial competes against a smaller card.
|
|
227
|
+
|
|
228
|
+
Keep the returned tensor alive for as long as the probe runs: releasing it hands the margin
|
|
229
|
+
back, and the chosen size is no longer the size that was measured.
|
|
230
|
+
"""
|
|
231
|
+
margin_bytes = int(usable_memory_bytes() * SAFETY_MARGIN)
|
|
232
|
+
logger.info('%s: keeping %.0f MB free as a safety margin', probe, margin_bytes / 1024**2)
|
|
233
|
+
return torch.empty(margin_bytes, dtype=torch.uint8, device='cuda')
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def usable_memory_bytes() -> int:
|
|
237
|
+
"""How much GPU memory this process may allocate, honouring :func:`limit_cuda_memory`."""
|
|
238
|
+
if _budget_gb > 0:
|
|
239
|
+
return int(_budget_gb * 1024**3)
|
|
240
|
+
return torch.cuda.get_device_properties(None).total_memory
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def add_vram_limit_argument(parser: ArgumentParser) -> None:
|
|
244
|
+
"""Give a spawned training script the same GPU budget flag its node has.
|
|
245
|
+
|
|
246
|
+
The spawned process still has to call :func:`limit_cuda_memory` with it.
|
|
247
|
+
"""
|
|
248
|
+
parser.add_argument(VRAM_LIMIT_GB_FLAG, type=float, default=0, help=VRAM_LIMIT_GB_HELP)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def limit_cuda_memory(vram_limit_gb: float) -> None:
|
|
252
|
+
"""Cap how much of the GPU this process may allocate, to ``vram_limit_gb`` gigabytes.
|
|
253
|
+
|
|
254
|
+
Call this once per process that touches the GPU, a spawned training process included: the
|
|
255
|
+
cap does not survive the spawn. A probe in this process then measures against the same budget.
|
|
256
|
+
|
|
257
|
+
:param vram_limit_gb: 0 or less means no cap.
|
|
258
|
+
"""
|
|
259
|
+
global _budget_gb # pylint: disable=global-statement
|
|
260
|
+
if vram_limit_gb <= 0 or not torch.cuda.is_available():
|
|
261
|
+
return
|
|
262
|
+
|
|
263
|
+
total_bytes = torch.cuda.get_device_properties(None).total_memory
|
|
264
|
+
fraction = vram_limit_gb * 1024**3 / total_bytes
|
|
265
|
+
total_gb = total_bytes / 1024**3
|
|
266
|
+
|
|
267
|
+
if fraction >= 1.0:
|
|
268
|
+
logger.warning('VRAM limit of %.1f GB exceeds the card capacity of %.1f GB; not limiting',
|
|
269
|
+
vram_limit_gb, total_gb)
|
|
270
|
+
return
|
|
271
|
+
|
|
272
|
+
torch.cuda.set_per_process_memory_fraction(fraction, None)
|
|
273
|
+
_budget_gb = vram_limit_gb
|
|
274
|
+
logger.info('Limiting VRAM usage to %.1f GB of %.1f GB (%.0f%%)', vram_limit_gb, total_gb, fraction * 100)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def free_cuda_memory() -> None:
|
|
278
|
+
gc.collect()
|
|
279
|
+
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
Version: 0.24.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
|
|
@@ -59,8 +60,10 @@ You can configure connection to our Learning Loop by specifying the following en
|
|
|
59
60
|
| LOOP_PROJECT | PROJECT | Project ID | Detector | - |
|
|
60
61
|
| MIN_UNCERTAIN_THRESHOLD | - | smallest confidence (float) at which auto-upload will happen | Detector (opt.) | 0.3 |
|
|
61
62
|
| MAX_UNCERTAIN_THRESHOLD | - | largest confidence (float) at which auto-upload will happen | Detector (opt.) | 0.6 |
|
|
63
|
+
| MAX_OUTBOX_SIZE | - | max. number of non-priority images queued for upload | Detector (opt.) | 1000 |
|
|
62
64
|
| EXCLUSIVE_MODEL_BUILD | - | Reject detections during update to save VRAM (set to 1) | Detector (opt.) | 0 |
|
|
63
65
|
| INFERENCE_BATCH_SIZE | - | Batch size of trainer when calculating detections | Trainer (opt.) | 10 |
|
|
66
|
+
| VRAM_LIMIT_GB | - | GPU memory (GB) a training may use; the batch size is probed against it (`--vram-limit-gb`, trainers built with `node_parser(vram_limit=True)`) | Trainer (opt.) | 0 (whole card) |
|
|
64
67
|
| RESTART_AFTER_TRAINING | - | Restart the trainer after training (set to 1) | Trainer (opt.) | 0 |
|
|
65
68
|
| KEEP_OLD_TRAININGS | - | Do not delete old trainings (set to 1) | Trainer (opt.) | 0 |
|
|
66
69
|
| TRAINER_IDLE_TIMEOUT_SEC | - | Automatically shutdown trainer after timeout (in seconds) | Trainer (opt.) | 0 (disabled) |
|
|
@@ -68,6 +71,20 @@ You can configure connection to our Learning Loop by specifying the following en
|
|
|
68
71
|
|
|
69
72
|
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
73
|
|
|
74
|
+
Where a name has an alias, either spelling works. If both are set to **different** values the
|
|
75
|
+
prefixed name wins and a warning names the value used — the variable is never treated as unset,
|
|
76
|
+
which would otherwise let `LOOP_HOST` fall back to its default of `learning-loop.ai`.
|
|
77
|
+
|
|
78
|
+
#### Debug logging
|
|
79
|
+
|
|
80
|
+
Every node type (Detector, Trainer, Annotator, Converter) has a REST endpoint to switch its own log level between `INFO` and `DEBUG` at runtime. Send the plain text `on` or `off` with a PUT request:
|
|
81
|
+
|
|
82
|
+
- Turn debug logging on: `curl -X PUT -d "on" http://localhost/debug_logging`
|
|
83
|
+
- Turn debug logging off: `curl -X PUT -d "off" http://localhost/debug_logging`
|
|
84
|
+
|
|
85
|
+
The response repeats the new state. Any other body returns status code 400.
|
|
86
|
+
Only the node's own logger is affected; third-party loggers such as `httpx` keep their level. The setting is not persistent, a restart brings the log level back to `INFO`.
|
|
87
|
+
|
|
71
88
|
#### Testing
|
|
72
89
|
|
|
73
90
|
We use github actions for CI. Tests can also be executed locally by running
|
|
@@ -132,7 +149,10 @@ The detector also has a **SocketIO** upload endpoint that can be used to upload
|
|
|
132
149
|
- `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
150
|
- `upload_priority`: Optional boolean flag to prioritize the upload (defaults to False)
|
|
134
151
|
|
|
135
|
-
The endpoint returns
|
|
152
|
+
The endpoint returns `{'status': 'OK'}` if the upload was successful and `{'error': '<message>'}` otherwise.
|
|
153
|
+
|
|
154
|
+
Besides detections and annotations, the metadata may carry `tags`, `source`, `created` and `state`.
|
|
155
|
+
`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
156
|
|
|
137
157
|
For both ways to upload an image, the tag `picked_by_system` is automatically added to the image metadata.
|
|
138
158
|
|
|
@@ -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=
|
|
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=
|
|
22
|
+
learning_loop_node/detector/detector_node.py,sha256=GC5CaGcW_qVxyolDEz9U5W7y0ejZGEu9FZ__ckLxd7g,35728
|
|
22
23
|
learning_loop_node/detector/exceptions.py,sha256=C6KbNPlSbtfgDrZx2Hbhm7Suk9jVoR3fMRCO0CkrMsQ,196
|
|
23
|
-
learning_loop_node/detector/
|
|
24
|
+
learning_loop_node/detector/geometry.py,sha256=bZCF9xuK7qy6Ipv5aQYrTlwSmjaiUz5VEUYnc1zCe-w,1101
|
|
25
|
+
learning_loop_node/detector/outbox.py,sha256=HT0GHv6rsQzdbCD8DAT23m1-Q1XEvA_KgwAJLONyX_0,12506
|
|
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/
|
|
45
|
+
learning_loop_node/helpers/entrypoint.py,sha256=U4vHFqgke6mXjDL-E4MND8RO7P1k2VBGwdDOOoJvD9Y,3915
|
|
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=LjA-zyY4hdnSMy6ZsdQOhWbmd5H_DI-9LG3yFInv04E,5476
|
|
53
|
+
learning_loop_node/trainer/cuda.py,sha256=ehpfWqPBCVFu06SnGsjmWbSDRJ8sZc-L_nCtemRLUOU,12567
|
|
48
54
|
learning_loop_node/trainer/downloader.py,sha256=AIyEKM4XYtd6VgmXrP3VayV9DpJzdURK1Brx81ePNSM,1470
|
|
49
|
-
learning_loop_node/trainer/exceptions.py,sha256=
|
|
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=
|
|
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.
|
|
59
|
-
learning_loop_node-0.
|
|
60
|
-
learning_loop_node-0.
|
|
61
|
-
learning_loop_node-0.
|
|
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,,
|
|
File without changes
|
|
File without changes
|