learning-loop-node 0.22.0__tar.gz → 0.24.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.
Files changed (74) hide show
  1. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/PKG-INFO +22 -2
  2. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/README.md +20 -1
  3. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/learning_loop_node/data_classes/image_metadata.py +2 -0
  4. learning_loop_node-0.24.0/learning_loop_node/detector/categories.py +27 -0
  5. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/learning_loop_node/detector/detector_node.py +26 -22
  6. learning_loop_node-0.24.0/learning_loop_node/detector/geometry.py +39 -0
  7. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/learning_loop_node/detector/outbox.py +4 -2
  8. learning_loop_node-0.24.0/learning_loop_node/detector/postprocess.py +219 -0
  9. learning_loop_node-0.24.0/learning_loop_node/helpers/entrypoint.py +87 -0
  10. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/learning_loop_node/helpers/environment_reader.py +13 -7
  11. learning_loop_node-0.24.0/learning_loop_node/trainer/batch_size.py +126 -0
  12. learning_loop_node-0.24.0/learning_loop_node/trainer/cuda.py +279 -0
  13. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/learning_loop_node/trainer/exceptions.py +8 -4
  14. learning_loop_node-0.24.0/learning_loop_node/trainer/metrics.py +28 -0
  15. learning_loop_node-0.24.0/learning_loop_node/trainer/subprocess.py +91 -0
  16. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/learning_loop_node/trainer/trainer_logic.py +20 -9
  17. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/learning_loop_node.egg-info/PKG-INFO +22 -2
  18. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/learning_loop_node.egg-info/SOURCES.txt +8 -0
  19. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/learning_loop_node.egg-info/requires.txt +1 -0
  20. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/pyproject.toml +2 -1
  21. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/learning_loop_node/__init__.py +0 -0
  22. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/learning_loop_node/annotation/__init__.py +0 -0
  23. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/learning_loop_node/annotation/annotator_logic.py +0 -0
  24. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/learning_loop_node/annotation/annotator_node.py +0 -0
  25. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/learning_loop_node/data_classes/__init__.py +0 -0
  26. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/learning_loop_node/data_classes/annotation_data.py +0 -0
  27. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/learning_loop_node/data_classes/annotations.py +0 -0
  28. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/learning_loop_node/data_classes/detections.py +0 -0
  29. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/learning_loop_node/data_classes/general.py +0 -0
  30. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/learning_loop_node/data_classes/socket_response.py +0 -0
  31. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/learning_loop_node/data_classes/training.py +0 -0
  32. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/learning_loop_node/data_exchanger.py +0 -0
  33. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/learning_loop_node/detector/__init__.py +0 -0
  34. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/learning_loop_node/detector/detector_logic.py +0 -0
  35. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/learning_loop_node/detector/exceptions.py +0 -0
  36. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/learning_loop_node/detector/inbox_filter/__init__.py +0 -0
  37. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/learning_loop_node/detector/inbox_filter/cam_observation_history.py +0 -0
  38. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/learning_loop_node/detector/inbox_filter/relevance_filter.py +0 -0
  39. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/learning_loop_node/detector/rest/__init__.py +0 -0
  40. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/learning_loop_node/detector/rest/about.py +0 -0
  41. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/learning_loop_node/detector/rest/backdoor_controls.py +0 -0
  42. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/learning_loop_node/detector/rest/detect.py +0 -0
  43. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/learning_loop_node/detector/rest/model_version_control.py +0 -0
  44. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/learning_loop_node/detector/rest/operation_mode.py +0 -0
  45. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/learning_loop_node/detector/rest/outbox_mode.py +0 -0
  46. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/learning_loop_node/detector/rest/upload.py +0 -0
  47. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/learning_loop_node/enums/__init__.py +0 -0
  48. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/learning_loop_node/enums/annotator.py +0 -0
  49. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/learning_loop_node/enums/detector.py +0 -0
  50. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/learning_loop_node/enums/general.py +0 -0
  51. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/learning_loop_node/enums/trainer.py +0 -0
  52. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/learning_loop_node/globals.py +0 -0
  53. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/learning_loop_node/helpers/__init__.py +0 -0
  54. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/learning_loop_node/helpers/background_tasks.py +0 -0
  55. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/learning_loop_node/helpers/gdrive_downloader.py +0 -0
  56. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/learning_loop_node/helpers/log_conf.py +0 -0
  57. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/learning_loop_node/helpers/misc.py +0 -0
  58. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/learning_loop_node/helpers/run.py +0 -0
  59. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/learning_loop_node/loop_communication.py +0 -0
  60. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/learning_loop_node/node.py +0 -0
  61. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/learning_loop_node/py.typed +0 -0
  62. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/learning_loop_node/rest.py +0 -0
  63. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/learning_loop_node/trainer/__init__.py +0 -0
  64. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/learning_loop_node/trainer/downloader.py +0 -0
  65. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/learning_loop_node/trainer/executor.py +0 -0
  66. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/learning_loop_node/trainer/io_helpers.py +0 -0
  67. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/learning_loop_node/trainer/rest/__init__.py +0 -0
  68. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/learning_loop_node/trainer/rest/backdoor_controls.py +0 -0
  69. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/learning_loop_node/trainer/test_executor.py +0 -0
  70. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/learning_loop_node/trainer/trainer_logic_generic.py +0 -0
  71. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/learning_loop_node/trainer/trainer_node.py +0 -0
  72. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/learning_loop_node.egg-info/dependency_links.txt +0 -0
  73. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/learning_loop_node.egg-info/top_level.txt +0 -0
  74. {learning_loop_node-0.22.0 → learning_loop_node-0.24.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: learning_loop_node
3
- Version: 0.22.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 None if the upload was successful and an error message otherwise.
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
 
@@ -27,8 +27,10 @@ You can configure connection to our Learning Loop by specifying the following en
27
27
  | LOOP_PROJECT | PROJECT | Project ID | Detector | - |
28
28
  | MIN_UNCERTAIN_THRESHOLD | - | smallest confidence (float) at which auto-upload will happen | Detector (opt.) | 0.3 |
29
29
  | MAX_UNCERTAIN_THRESHOLD | - | largest confidence (float) at which auto-upload will happen | Detector (opt.) | 0.6 |
30
+ | MAX_OUTBOX_SIZE | - | max. number of non-priority images queued for upload | Detector (opt.) | 1000 |
30
31
  | EXCLUSIVE_MODEL_BUILD | - | Reject detections during update to save VRAM (set to 1) | Detector (opt.) | 0 |
31
32
  | INFERENCE_BATCH_SIZE | - | Batch size of trainer when calculating detections | Trainer (opt.) | 10 |
33
+ | 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) |
32
34
  | RESTART_AFTER_TRAINING | - | Restart the trainer after training (set to 1) | Trainer (opt.) | 0 |
33
35
  | KEEP_OLD_TRAININGS | - | Do not delete old trainings (set to 1) | Trainer (opt.) | 0 |
34
36
  | TRAINER_IDLE_TIMEOUT_SEC | - | Automatically shutdown trainer after timeout (in seconds) | Trainer (opt.) | 0 (disabled) |
@@ -36,6 +38,20 @@ You can configure connection to our Learning Loop by specifying the following en
36
38
 
37
39
  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
40
 
41
+ Where a name has an alias, either spelling works. If both are set to **different** values the
42
+ prefixed name wins and a warning names the value used — the variable is never treated as unset,
43
+ which would otherwise let `LOOP_HOST` fall back to its default of `learning-loop.ai`.
44
+
45
+ #### Debug logging
46
+
47
+ 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:
48
+
49
+ - Turn debug logging on: `curl -X PUT -d "on" http://localhost/debug_logging`
50
+ - Turn debug logging off: `curl -X PUT -d "off" http://localhost/debug_logging`
51
+
52
+ The response repeats the new state. Any other body returns status code 400.
53
+ 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`.
54
+
39
55
  #### Testing
40
56
 
41
57
  We use github actions for CI. Tests can also be executed locally by running
@@ -100,7 +116,10 @@ The detector also has a **SocketIO** upload endpoint that can be used to upload
100
116
  - `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
117
  - `upload_priority`: Optional boolean flag to prioritize the upload (defaults to False)
102
118
 
103
- The endpoint returns None if the upload was successful and an error message otherwise.
119
+ The endpoint returns `{'status': 'OK'}` if the upload was successful and `{'error': '<message>'}` otherwise.
120
+
121
+ Besides detections and annotations, the metadata may carry `tags`, `source`, `created` and `state`.
122
+ `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
123
 
105
124
  For both ways to upload an image, the tag `picked_by_system` is automatically added to the image metadata.
106
125
 
@@ -46,6 +46,8 @@ class ImageMetadata():
46
46
  'description': 'Creation date of the image'})
47
47
  source: Optional[str] = field(default=None, metadata={
48
48
  'description': 'Source of the image'})
49
+ state: str | None = field(default=None, metadata={
50
+ 'description': 'State the image should enter the loop in (e.g. "trash"); None uses the loop default'})
49
51
 
50
52
  def __len__(self):
51
53
  return len(self.box_detections) + len(self.point_detections) + len(self.segmentation_detections) + len(self.classification_detections)
@@ -0,0 +1,27 @@
1
+ """Resolving the class indices or names a model emits against ``ModelInformation.categories``."""
2
+
3
+ from ..data_classes import Category, ModelInformation
4
+
5
+
6
+ def category_by_index(model_information: ModelInformation, index: int) -> Category:
7
+ """Resolve the category a model's class index refers to.
8
+
9
+ :raises ValueError: If the index is outside the model's category list.
10
+ """
11
+ categories = model_information.categories
12
+ if not 0 <= index < len(categories):
13
+ raise ValueError(
14
+ f'category index {index} is out of range for a model with {len(categories)} categories')
15
+ return categories[index]
16
+
17
+
18
+ def category_by_name(model_information: ModelInformation, name: str) -> Category:
19
+ """Resolve a category by name, for models whose outputs are named rather than indexed.
20
+
21
+ :raises ValueError: If no category of that name exists.
22
+ """
23
+ for category in model_information.categories:
24
+ if category.name == name:
25
+ return category
26
+ known = ', '.join(category.name for category in model_information.categories)
27
+ raise ValueError(f'unknown category name {name!r}; the model knows: {known}')
@@ -18,7 +18,6 @@ from socketio import AsyncClient
18
18
 
19
19
  from ..data_classes import (
20
20
  AboutResponse,
21
- Category,
22
21
  Context,
23
22
  DetectorStatus,
24
23
  ImageMetadata,
@@ -33,6 +32,7 @@ from ..globals import GLOBALS
33
32
  from ..helpers import background_tasks, environment_reader, run
34
33
  from ..helpers.misc import numpy_image_from_dict
35
34
  from ..node import Node
35
+ from .categories import category_by_name
36
36
  from .detector_logic import DetectorLogic, DetectorLogicFactory
37
37
  from .exceptions import NodeNeedsRestartError
38
38
  from .inbox_filter.relevance_filter import RelevanceFilter
@@ -339,7 +339,9 @@ class DetectorNode(Node):
339
339
  - bytes: bytes of the ndarray (retrieved via `ndarray.tobytes(order='C')`)
340
340
  - dtype: data type of the ndarray as string (e.g. `uint8`, `float32`, etc.)
341
341
  - shape: shape of the ndarray as tuple of ints (e.g. `(480, 640, 3)`)
342
- - metadata: The metadata for the image (optional)
342
+ - metadata: The metadata for the image (optional). Besides detections and annotations it
343
+ may carry `tags`, `source`, `created` and `state`. A `state` of e.g. "trash" makes the
344
+ loop file the image into that state instead of its default one.
343
345
  - upload_priority: Whether to upload with priority (optional)
344
346
  """
345
347
  self.log.debug('Processing upload via socketio.')
@@ -707,26 +709,28 @@ class DetectorNode(Node):
707
709
  await self.outbox.save(image, image_metadata, upload_priority)
708
710
 
709
711
  def add_category_id_to_detections(self, model_info: ModelInformation, image_metadata: ImageMetadata):
710
- def find_category_id_by_name(categories: List[Category], category_name: str):
711
- category_id = [category.id for category in categories if category.name == category_name]
712
- return category_id[0] if category_id else ''
713
-
714
- for box_detection in image_metadata.box_detections:
715
- category_name = box_detection.category_name
716
- category_id = find_category_id_by_name(model_info.categories, category_name)
717
- box_detection.category_id = category_id
718
- for point_detection in image_metadata.point_detections:
719
- category_name = point_detection.category_name
720
- category_id = find_category_id_by_name(model_info.categories, category_name)
721
- point_detection.category_id = category_id
722
- for segmentation_detection in image_metadata.segmentation_detections:
723
- category_name = segmentation_detection.category_name
724
- category_id = find_category_id_by_name(model_info.categories, category_name)
725
- segmentation_detection.category_id = category_id
726
- for classification_detection in image_metadata.classification_detections:
727
- category_name = classification_detection.category_name
728
- category_id = find_category_id_by_name(model_info.categories, category_name)
729
- classification_detection.category_id = category_id
712
+ """Resolve each detection's category id from its name, in metadata a client uploaded.
713
+
714
+ A name the model does not know costs that one detection its id, not the whole upload.
715
+ """
716
+ unknown_names: set[str] = set()
717
+
718
+ def category_id_by_name(category_name: str) -> str:
719
+ try:
720
+ return category_by_name(model_info, category_name).id
721
+ except ValueError:
722
+ unknown_names.add(category_name)
723
+ return ''
724
+
725
+ for detection in (*image_metadata.box_detections,
726
+ *image_metadata.point_detections,
727
+ *image_metadata.segmentation_detections,
728
+ *image_metadata.classification_detections):
729
+ detection.category_id = category_id_by_name(detection.category_name)
730
+
731
+ if unknown_names:
732
+ self.log.warning('Model %s knows no category named %s', model_info.version,
733
+ ', '.join(sorted(unknown_names)))
730
734
  return image_metadata
731
735
 
732
736
  def register_sio_events(self, sio_client: AsyncClient):
@@ -0,0 +1,39 @@
1
+ """Box and point clipping shared by every detector node.
2
+
3
+ The loop stores a box as its top-left corner plus a size, which is the form :func:`clip_box`
4
+ takes and produces.
5
+ """
6
+
7
+
8
+ def clip_box(
9
+ *,
10
+ x1: float,
11
+ y1: float,
12
+ width: float,
13
+ height: float,
14
+ img_width: int,
15
+ img_height: int,
16
+ ) -> tuple[int, int, int, int]:
17
+ """Clip a top-left-anchored box to the image bounds.
18
+
19
+ :return: The clipped ``(x1, y1, width, height)``; the size is never negative.
20
+ """
21
+ x2 = x1 + width
22
+ y2 = y1 + height
23
+
24
+ clipped_x1 = round(max(0.0, x1))
25
+ clipped_y1 = round(max(0.0, y1))
26
+ clipped_x2 = round(min(float(img_width), x2))
27
+ clipped_y2 = round(min(float(img_height), y2))
28
+
29
+ clipped_width = max(clipped_x2 - clipped_x1, 0)
30
+ clipped_height = max(clipped_y2 - clipped_y1, 0)
31
+
32
+ return clipped_x1, clipped_y1, clipped_width, clipped_height
33
+
34
+
35
+ def clip_point(x: float, y: float, img_width: int, img_height: int) -> tuple[float, float]:
36
+ """Clamp a point into the image bounds."""
37
+ x = min(max(0, x), img_width)
38
+ y = min(max(0, y), img_height)
39
+ return x, y
@@ -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 total queue length is limited to 1000 images.
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 affects the `upload_folders` list
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
- from typing import List, Optional
3
+
4
+ logger = logging.getLogger(__name__)
4
5
 
5
6
 
6
7
  # TODO ignore_errors should default to False, but maybe some tests rely on this behavior
7
- def read_from_env(possible_names: List[str], ignore_errors: bool = True) -> Optional[str]:
8
+ def read_from_env(possible_names: list[str], ignore_errors: bool = True) -> str | None:
9
+ """Read the first of ``possible_names`` that is set.
10
+
11
+ :param possible_names: In order of preference; on a disagreement the first one set wins.
12
+ :raises ValueError: If nothing is set or the values disagree, unless ``ignore_errors``.
13
+ """
8
14
  values = [os.environ.get(name, None) for name in possible_names]
9
15
  values = list(filter(None, values))
10
16
 
11
17
  # Possible error: no values are set
12
18
  if not values:
13
19
  if ignore_errors:
14
- logging.warning('no environment variable set for %s', possible_names)
20
+ logger.warning('no environment variable set for %s', possible_names)
15
21
  return None
16
22
  raise ValueError(f'no environment variable set for {possible_names}')
17
23
 
18
24
  # Possible error: multiple values are not None and not equal
19
25
  if len(values) > 1 and len(set(values)) > 1:
20
- if ignore_errors:
21
- logging.warning('different environment variables set for %s: %s', possible_names, values)
22
- return None
23
- raise ValueError(f'different environment variables set for {possible_names}: {values}')
26
+ if not ignore_errors:
27
+ raise ValueError(f'different environment variables set for {possible_names}: {values}')
28
+ logger.warning('different environment variables set for %s: %s - using %s',
29
+ possible_names, values, values[0])
24
30
 
25
31
  return values[0]
26
32