learning-loop-node 0.22.0__tar.gz → 0.23.0__tar.gz
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-0.22.0 → learning_loop_node-0.23.0}/PKG-INFO +10 -2
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/README.md +8 -1
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/learning_loop_node/data_classes/image_metadata.py +2 -0
- learning_loop_node-0.23.0/learning_loop_node/detector/categories.py +27 -0
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/learning_loop_node/detector/detector_node.py +26 -22
- learning_loop_node-0.23.0/learning_loop_node/detector/geometry.py +39 -0
- learning_loop_node-0.23.0/learning_loop_node/detector/postprocess.py +219 -0
- learning_loop_node-0.23.0/learning_loop_node/helpers/entrypoint.py +81 -0
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/learning_loop_node/helpers/environment_reader.py +13 -7
- learning_loop_node-0.23.0/learning_loop_node/trainer/batch_size.py +81 -0
- learning_loop_node-0.23.0/learning_loop_node/trainer/cuda.py +137 -0
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/learning_loop_node/trainer/exceptions.py +8 -4
- learning_loop_node-0.23.0/learning_loop_node/trainer/metrics.py +28 -0
- learning_loop_node-0.23.0/learning_loop_node/trainer/subprocess.py +91 -0
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/learning_loop_node/trainer/trainer_logic.py +20 -9
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/learning_loop_node.egg-info/PKG-INFO +10 -2
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/learning_loop_node.egg-info/SOURCES.txt +8 -0
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/learning_loop_node.egg-info/requires.txt +1 -0
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/pyproject.toml +2 -1
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/learning_loop_node/__init__.py +0 -0
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/learning_loop_node/annotation/__init__.py +0 -0
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/learning_loop_node/annotation/annotator_logic.py +0 -0
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/learning_loop_node/annotation/annotator_node.py +0 -0
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/learning_loop_node/data_classes/__init__.py +0 -0
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/learning_loop_node/data_classes/annotation_data.py +0 -0
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/learning_loop_node/data_classes/annotations.py +0 -0
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/learning_loop_node/data_classes/detections.py +0 -0
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/learning_loop_node/data_classes/general.py +0 -0
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/learning_loop_node/data_classes/socket_response.py +0 -0
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/learning_loop_node/data_classes/training.py +0 -0
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/learning_loop_node/data_exchanger.py +0 -0
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/learning_loop_node/detector/__init__.py +0 -0
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/learning_loop_node/detector/detector_logic.py +0 -0
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/learning_loop_node/detector/exceptions.py +0 -0
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/learning_loop_node/detector/inbox_filter/__init__.py +0 -0
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/learning_loop_node/detector/inbox_filter/cam_observation_history.py +0 -0
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/learning_loop_node/detector/inbox_filter/relevance_filter.py +0 -0
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/learning_loop_node/detector/outbox.py +0 -0
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/learning_loop_node/detector/rest/__init__.py +0 -0
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/learning_loop_node/detector/rest/about.py +0 -0
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/learning_loop_node/detector/rest/backdoor_controls.py +0 -0
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/learning_loop_node/detector/rest/detect.py +0 -0
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/learning_loop_node/detector/rest/model_version_control.py +0 -0
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/learning_loop_node/detector/rest/operation_mode.py +0 -0
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/learning_loop_node/detector/rest/outbox_mode.py +0 -0
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/learning_loop_node/detector/rest/upload.py +0 -0
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/learning_loop_node/enums/__init__.py +0 -0
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/learning_loop_node/enums/annotator.py +0 -0
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/learning_loop_node/enums/detector.py +0 -0
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/learning_loop_node/enums/general.py +0 -0
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/learning_loop_node/enums/trainer.py +0 -0
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/learning_loop_node/globals.py +0 -0
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/learning_loop_node/helpers/__init__.py +0 -0
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/learning_loop_node/helpers/background_tasks.py +0 -0
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/learning_loop_node/helpers/gdrive_downloader.py +0 -0
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/learning_loop_node/helpers/log_conf.py +0 -0
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/learning_loop_node/helpers/misc.py +0 -0
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/learning_loop_node/helpers/run.py +0 -0
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/learning_loop_node/loop_communication.py +0 -0
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/learning_loop_node/node.py +0 -0
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/learning_loop_node/py.typed +0 -0
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/learning_loop_node/rest.py +0 -0
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/learning_loop_node/trainer/__init__.py +0 -0
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/learning_loop_node/trainer/downloader.py +0 -0
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/learning_loop_node/trainer/executor.py +0 -0
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/learning_loop_node/trainer/io_helpers.py +0 -0
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/learning_loop_node/trainer/rest/__init__.py +0 -0
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/learning_loop_node/trainer/rest/backdoor_controls.py +0 -0
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/learning_loop_node/trainer/test_executor.py +0 -0
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/learning_loop_node/trainer/trainer_logic_generic.py +0 -0
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/learning_loop_node/trainer/trainer_node.py +0 -0
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/learning_loop_node.egg-info/dependency_links.txt +0 -0
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/learning_loop_node.egg-info/top_level.txt +0 -0
- {learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: learning_loop_node
|
|
3
|
-
Version: 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
|
|
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
|
|
|
@@ -36,6 +36,10 @@ You can configure connection to our Learning Loop by specifying the following en
|
|
|
36
36
|
|
|
37
37
|
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.
|
|
38
38
|
|
|
39
|
+
Where a name has an alias, either spelling works. If both are set to **different** values the
|
|
40
|
+
prefixed name wins and a warning names the value used — the variable is never treated as unset,
|
|
41
|
+
which would otherwise let `LOOP_HOST` fall back to its default of `learning-loop.ai`.
|
|
42
|
+
|
|
39
43
|
#### Testing
|
|
40
44
|
|
|
41
45
|
We use github actions for CI. Tests can also be executed locally by running
|
|
@@ -100,7 +104,10 @@ The detector also has a **SocketIO** upload endpoint that can be used to upload
|
|
|
100
104
|
- `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.
|
|
101
105
|
- `upload_priority`: Optional boolean flag to prioritize the upload (defaults to False)
|
|
102
106
|
|
|
103
|
-
The endpoint returns
|
|
107
|
+
The endpoint returns `{'status': 'OK'}` if the upload was successful and `{'error': '<message>'}` otherwise.
|
|
108
|
+
|
|
109
|
+
Besides detections and annotations, the metadata may carry `tags`, `source`, `created` and `state`.
|
|
110
|
+
`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`).
|
|
104
111
|
|
|
105
112
|
For both ways to upload an image, the tag `picked_by_system` is automatically added to the image metadata.
|
|
106
113
|
|
|
@@ -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}')
|
{learning_loop_node-0.22.0 → learning_loop_node-0.23.0}/learning_loop_node/detector/detector_node.py
RENAMED
|
@@ -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
|
|
@@ -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
|
-
|
|
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,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'))
|