pynode-flow 0.2.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.
Files changed (227) hide show
  1. pynode/__init__.py +37 -0
  2. pynode/__main__.py +9 -0
  3. pynode/_perf.py +65 -0
  4. pynode/_version.py +24 -0
  5. pynode/api/__init__.py +15 -0
  6. pynode/api/helpers.py +79 -0
  7. pynode/api/nodes.py +423 -0
  8. pynode/api/services.py +124 -0
  9. pynode/api/sse.py +58 -0
  10. pynode/api/uploads.py +59 -0
  11. pynode/api/workflows.py +466 -0
  12. pynode/config.py +93 -0
  13. pynode/install_nodes.py +143 -0
  14. pynode/main.py +118 -0
  15. pynode/node_registry.py +156 -0
  16. pynode/nodes/AutoSyncNode/auto_sync_node.py +151 -0
  17. pynode/nodes/BBoxMetricsNode/__init__.py +3 -0
  18. pynode/nodes/BBoxMetricsNode/bbox_metrics_node.py +250 -0
  19. pynode/nodes/BBoxMetricsNode/requirements.txt +1 -0
  20. pynode/nodes/BatchNode/__init__.py +3 -0
  21. pynode/nodes/BatchNode/batch_node.py +86 -0
  22. pynode/nodes/BatchNode/requirements.txt +1 -0
  23. pynode/nodes/CameraNode/__init__.py +4 -0
  24. pynode/nodes/CameraNode/camera_node.py +204 -0
  25. pynode/nodes/CameraNode/requirements.txt +2 -0
  26. pynode/nodes/ChangeNode/__init__.py +1 -0
  27. pynode/nodes/ChangeNode/change_node.py +255 -0
  28. pynode/nodes/ChangeNode/requirements.txt +1 -0
  29. pynode/nodes/ConfidenceFilterNode/__init__.py +4 -0
  30. pynode/nodes/ConfidenceFilterNode/confidence_filter_node.py +208 -0
  31. pynode/nodes/ConfidenceFilterNode/requirements.txt +1 -0
  32. pynode/nodes/CounterNode/__init__.py +3 -0
  33. pynode/nodes/CounterNode/counter_node.py +191 -0
  34. pynode/nodes/CropNode/__init__.py +4 -0
  35. pynode/nodes/CropNode/crop_node.py +317 -0
  36. pynode/nodes/CropNode/requirements.txt +2 -0
  37. pynode/nodes/DebugNode/__init__.py +4 -0
  38. pynode/nodes/DebugNode/debug_node.py +169 -0
  39. pynode/nodes/DebugNode/requirements.txt +1 -0
  40. pynode/nodes/DelayNode/delay_node.py +218 -0
  41. pynode/nodes/DelayNode/requirements.txt +1 -0
  42. pynode/nodes/DrawPredictionsNode/__init__.py +3 -0
  43. pynode/nodes/DrawPredictionsNode/draw_predictions_node.py +365 -0
  44. pynode/nodes/DrawPredictionsNode/requirements.txt +2 -0
  45. pynode/nodes/ErrorNode/error_node.py +94 -0
  46. pynode/nodes/ErrorNode/requirements.txt +1 -0
  47. pynode/nodes/FilterNode/__init__.py +3 -0
  48. pynode/nodes/FilterNode/filter_node.py +102 -0
  49. pynode/nodes/FilterNode/requirements.txt +1 -0
  50. pynode/nodes/FrameSourceNode/__init__.py +4 -0
  51. pynode/nodes/FrameSourceNode/frame_source_node.py +319 -0
  52. pynode/nodes/FrameSourceNode/requirements.txt +1 -0
  53. pynode/nodes/FunctionNode/__init__.py +3 -0
  54. pynode/nodes/FunctionNode/function_node.py +168 -0
  55. pynode/nodes/FunctionNode/requirements.txt +1 -0
  56. pynode/nodes/GateNode/gate_node.py +60 -0
  57. pynode/nodes/GateNode/requirements.txt +1 -0
  58. pynode/nodes/GetiUploadNode/__init__.py +3 -0
  59. pynode/nodes/GetiUploadNode/geti_upload_node.py +278 -0
  60. pynode/nodes/GetiUploadNode/requirements.txt +1 -0
  61. pynode/nodes/ImageFormatNode/__init__.py +4 -0
  62. pynode/nodes/ImageFormatNode/image_format_node.py +310 -0
  63. pynode/nodes/ImageFormatNode/requirements.txt +2 -0
  64. pynode/nodes/ImageUploadNode/image_upload_node.py +96 -0
  65. pynode/nodes/ImageViewerNode/__init__.py +4 -0
  66. pynode/nodes/ImageViewerNode/image_viewer_node.py +162 -0
  67. pynode/nodes/ImageViewerNode/requirements.txt +2 -0
  68. pynode/nodes/ImageWriterNode/__init__.py +3 -0
  69. pynode/nodes/ImageWriterNode/imagewriter_node.py +352 -0
  70. pynode/nodes/ImageWriterNode/requirements.txt +1 -0
  71. pynode/nodes/InferenceNode/InferenceEngine/__init__.py +15 -0
  72. pynode/nodes/InferenceNode/InferenceEngine/device_detection.py +149 -0
  73. pynode/nodes/InferenceNode/InferenceEngine/engines/base_engine.py +155 -0
  74. pynode/nodes/InferenceNode/InferenceEngine/engines/example_engine_template.py +272 -0
  75. pynode/nodes/InferenceNode/InferenceEngine/engines/geti_engine.py +309 -0
  76. pynode/nodes/InferenceNode/InferenceEngine/engines/onnx_engine.py +346 -0
  77. pynode/nodes/InferenceNode/InferenceEngine/engines/pass_engine.py +86 -0
  78. pynode/nodes/InferenceNode/InferenceEngine/engines/ultralytics_engine.py +863 -0
  79. pynode/nodes/InferenceNode/InferenceEngine/inference_engine_factory.py +533 -0
  80. pynode/nodes/InferenceNode/InferenceEngine/readme.md +107 -0
  81. pynode/nodes/InferenceNode/InferenceEngine/result_converters.py +121 -0
  82. pynode/nodes/InferenceNode/inference_node.py +451 -0
  83. pynode/nodes/InferenceNode/requirements.txt +13 -0
  84. pynode/nodes/InjectNode/inject_node.py +245 -0
  85. pynode/nodes/InjectNode/requirements.txt +1 -0
  86. pynode/nodes/JoinNode/__init__.py +3 -0
  87. pynode/nodes/JoinNode/join_node.py +156 -0
  88. pynode/nodes/JoinNode/requirements.txt +1 -0
  89. pynode/nodes/LabelFilterNode/__init__.py +3 -0
  90. pynode/nodes/LabelFilterNode/label_filter_node.py +188 -0
  91. pynode/nodes/LabelFilterNode/requirements.txt +1 -0
  92. pynode/nodes/MQTTNode/__init__.py +8 -0
  93. pynode/nodes/MQTTNode/mqtt_in_node.py +148 -0
  94. pynode/nodes/MQTTNode/mqtt_out_node.py +157 -0
  95. pynode/nodes/MQTTNode/mqtt_service.py +367 -0
  96. pynode/nodes/MQTTNode/requirements.txt +1 -0
  97. pynode/nodes/MessageWriterNode/__init__.py +7 -0
  98. pynode/nodes/MessageWriterNode/messagereader_node.py +568 -0
  99. pynode/nodes/MessageWriterNode/messagewriter_node.py +505 -0
  100. pynode/nodes/OmronCameraNode/__init__.py +3 -0
  101. pynode/nodes/OmronCameraNode/omron_camera_node.py +652 -0
  102. pynode/nodes/OmronCameraNode/requirements.txt +4 -0
  103. pynode/nodes/OpenCV/__init__.py +1 -0
  104. pynode/nodes/OpenCV/arithmetic_node.py +376 -0
  105. pynode/nodes/OpenCV/bitwise_node.py +127 -0
  106. pynode/nodes/OpenCV/blend_node.py +127 -0
  107. pynode/nodes/OpenCV/blob_detector_node.py +233 -0
  108. pynode/nodes/OpenCV/blur_node.py +141 -0
  109. pynode/nodes/OpenCV/color_space_node.py +139 -0
  110. pynode/nodes/OpenCV/colormap_node.py +262 -0
  111. pynode/nodes/OpenCV/contours_node.py +258 -0
  112. pynode/nodes/OpenCV/denormalize_coords_node.py +365 -0
  113. pynode/nodes/OpenCV/draw_node.py +312 -0
  114. pynode/nodes/OpenCV/edge_detector_node.py +177 -0
  115. pynode/nodes/OpenCV/equirectangular_node.py +447 -0
  116. pynode/nodes/OpenCV/fft_node.py +102 -0
  117. pynode/nodes/OpenCV/focus_estimation_node.py +189 -0
  118. pynode/nodes/OpenCV/frequency_filter_node.py +241 -0
  119. pynode/nodes/OpenCV/histogram_node.py +160 -0
  120. pynode/nodes/OpenCV/hough_circles_node.py +224 -0
  121. pynode/nodes/OpenCV/hough_lines_node.py +243 -0
  122. pynode/nodes/OpenCV/in_range_node.py +186 -0
  123. pynode/nodes/OpenCV/morphology_node.py +136 -0
  124. pynode/nodes/OpenCV/normalize_coords_node.py +344 -0
  125. pynode/nodes/OpenCV/paste_node.py +244 -0
  126. pynode/nodes/OpenCV/perspective_node.py +174 -0
  127. pynode/nodes/OpenCV/realsense_depth_node.py +253 -0
  128. pynode/nodes/OpenCV/requirements.txt +2 -0
  129. pynode/nodes/OpenCV/resize_node.py +175 -0
  130. pynode/nodes/OpenCV/rotate_node.py +159 -0
  131. pynode/nodes/OpenCV/template_match_node.py +227 -0
  132. pynode/nodes/OpenCV/threshold_node.py +167 -0
  133. pynode/nodes/PointInShapeNode/__init__.py +3 -0
  134. pynode/nodes/PointInShapeNode/point_in_shape_node.py +313 -0
  135. pynode/nodes/PointInShapeNode/requirements.txt +2 -0
  136. pynode/nodes/PolygonMetricsNode/__init__.py +3 -0
  137. pynode/nodes/PolygonMetricsNode/polygon_metrics_node.py +331 -0
  138. pynode/nodes/PolygonMetricsNode/requirements.txt +2 -0
  139. pynode/nodes/QueueLengthProbeNode/__init__.py +3 -0
  140. pynode/nodes/QueueLengthProbeNode/queue_length_probe_node.py +116 -0
  141. pynode/nodes/Qwen3VLMNode/__init__.py +3 -0
  142. pynode/nodes/Qwen3VLMNode/qwen3_vlm_node.py +519 -0
  143. pynode/nodes/Qwen3VLMNode/requirements.txt +5 -0
  144. pynode/nodes/RESTEndpointNode/__init__.py +3 -0
  145. pynode/nodes/RESTEndpointNode/rest_endpoint_node.py +491 -0
  146. pynode/nodes/RangeNode/__init__.py +3 -0
  147. pynode/nodes/RangeNode/range_node.py +115 -0
  148. pynode/nodes/RangeNode/requirements.txt +1 -0
  149. pynode/nodes/RateProbeNode/__init__.py +3 -0
  150. pynode/nodes/RateProbeNode/rate_probe_node.py +183 -0
  151. pynode/nodes/RateProbeNode/requirements.txt +1 -0
  152. pynode/nodes/RoboflowUploadNode/__init__.py +3 -0
  153. pynode/nodes/RoboflowUploadNode/requirements.txt +1 -0
  154. pynode/nodes/RoboflowUploadNode/roboflow_upload_node.py +229 -0
  155. pynode/nodes/SliceNode/README.md +149 -0
  156. pynode/nodes/SliceNode/__init__.py +5 -0
  157. pynode/nodes/SliceNode/merge_predictions_node.py +444 -0
  158. pynode/nodes/SliceNode/requirements.txt +3 -0
  159. pynode/nodes/SliceNode/slice_collector_node.py +370 -0
  160. pynode/nodes/SliceNode/slice_image_node.py +380 -0
  161. pynode/nodes/SplitNode/__init__.py +3 -0
  162. pynode/nodes/SplitNode/requirements.txt +1 -0
  163. pynode/nodes/SplitNode/split_node.py +155 -0
  164. pynode/nodes/SwitchNode/requirements.txt +1 -0
  165. pynode/nodes/SwitchNode/switch_node.py +259 -0
  166. pynode/nodes/SyncNode/__init__.py +3 -0
  167. pynode/nodes/SyncNode/sync_node.py +287 -0
  168. pynode/nodes/TemplateNode/requirements.txt +1 -0
  169. pynode/nodes/TemplateNode/template_node.py +86 -0
  170. pynode/nodes/TrackerNode/__init__.py +2 -0
  171. pynode/nodes/TrackerNode/supervision_tracker_node.py +248 -0
  172. pynode/nodes/TrackerNode/tracker_node.py +745 -0
  173. pynode/nodes/UltralyticsNode/__init__.py +4 -0
  174. pynode/nodes/UltralyticsNode/requirements.txt +2 -0
  175. pynode/nodes/UltralyticsNode/ultralytics_node.py +364 -0
  176. pynode/nodes/UnknownNode/__init__.py +93 -0
  177. pynode/nodes/VLLMNode/__init__.py +3 -0
  178. pynode/nodes/VLLMNode/vllm_node.py +345 -0
  179. pynode/nodes/VideoReaderNode/__init__.py +3 -0
  180. pynode/nodes/VideoReaderNode/video_reader_node.py +425 -0
  181. pynode/nodes/VideoWriterNode/__init__.py +3 -0
  182. pynode/nodes/VideoWriterNode/video_writer_node.py +441 -0
  183. pynode/nodes/WebhookNode/__init__.py +3 -0
  184. pynode/nodes/WebhookNode/webhook_node.py +591 -0
  185. pynode/nodes/__init__.py +109 -0
  186. pynode/nodes/base_node.py +562 -0
  187. pynode/nodes/image_utils.py +291 -0
  188. pynode/nodes/info.py +109 -0
  189. pynode/nodes/mDNSNode/__init__.py +13 -0
  190. pynode/nodes/mDNSNode/mdns_broadcast_node.py +214 -0
  191. pynode/nodes/mDNSNode/mdns_discovery_node.py +362 -0
  192. pynode/nodes/mDNSNode/mdns_manager.py +399 -0
  193. pynode/nodes/mDNSNode/requirements.txt +1 -0
  194. pynode/nodes/messages.py +81 -0
  195. pynode/server.py +187 -0
  196. pynode/static/images/favicon4.png +0 -0
  197. pynode/static/index.html +200 -0
  198. pynode/static/js/README.md +139 -0
  199. pynode/static/js/auth.js +89 -0
  200. pynode/static/js/clipboard.js +152 -0
  201. pynode/static/js/config.js +14 -0
  202. pynode/static/js/connections.js +844 -0
  203. pynode/static/js/debug.js +524 -0
  204. pynode/static/js/events.js +775 -0
  205. pynode/static/js/history.js +199 -0
  206. pynode/static/js/main.js +55 -0
  207. pynode/static/js/minimap.js +242 -0
  208. pynode/static/js/nodes.js +995 -0
  209. pynode/static/js/palette.js +255 -0
  210. pynode/static/js/properties.js +1270 -0
  211. pynode/static/js/selection.js +241 -0
  212. pynode/static/js/state.js +211 -0
  213. pynode/static/js/ui-utils.js +28 -0
  214. pynode/static/js/viewport.js +204 -0
  215. pynode/static/js/workflow.js +469 -0
  216. pynode/static/js/workflows.js +344 -0
  217. pynode/static/style.css +2751 -0
  218. pynode/workflow_engine.py +483 -0
  219. pynode/workflow_manager.py +440 -0
  220. pynode_flow-0.2.0.dist-info/METADATA +520 -0
  221. pynode_flow-0.2.0.dist-info/RECORD +227 -0
  222. pynode_flow-0.2.0.dist-info/WHEEL +5 -0
  223. pynode_flow-0.2.0.dist-info/entry_points.txt +3 -0
  224. pynode_flow-0.2.0.dist-info/licenses/LICENSE +21 -0
  225. pynode_flow-0.2.0.dist-info/scm_file_list.json +270 -0
  226. pynode_flow-0.2.0.dist-info/scm_version.json +8 -0
  227. pynode_flow-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,370 @@
1
+ """
2
+ Slice Collector Node - collects predictions from multiple image slices and merges them.
3
+ Designed to work with SliceImageNode in a slice-detect-merge workflow.
4
+ """
5
+
6
+ from dataclasses import dataclass
7
+ import time
8
+ from typing import Any, Dict, List, Optional
9
+ from pynode.nodes.base_node import BaseNode, Info, MessageKeys
10
+
11
+ _info = Info()
12
+ _info.add_text("Collects detection predictions from multiple image slices sent as separate messages, then merges them into a unified result.")
13
+ _info.add_header("Inputs")
14
+ _info.add_bullets(("Input 0:", "Individual slice prediction messages with slice metadata (from split output mode)"))
15
+ _info.add_header("Outputs")
16
+ _info.add_bullets(
17
+ ("Output 0:", "Merged predictions after all slices collected and NMS applied"),
18
+ )
19
+ _info.add_header("Configuration")
20
+ _info.add_bullets(
21
+ ("Timeout:", "Maximum time to wait for all slices before outputting partial results"),
22
+ ("NMS IoU Threshold:", "Threshold for Non-Maximum Suppression"),
23
+ ("Match Metric:", "IoU or IoS for duplicate detection matching"),
24
+ ("Class Agnostic NMS:", "Apply NMS across all classes vs per-class"),
25
+ )
26
+ _info.add_header("Usage")
27
+ _info.add_text("Designed for workflows: SliceImageNode (split mode) → Inference → SliceCollectorNode")
28
+
29
+ @dataclass(frozen=True)
30
+ class NodeKeys:
31
+ BBOX = 'bbox'
32
+ SLICE_INDEX = 'slice_index'
33
+ SLICE_OFFSET = 'slice_offset'
34
+ SLICE_BBOX = 'slice_bbox'
35
+ SLICES = 'slices'
36
+ SLICE_COUNT = 'slice_count'
37
+ COUNT = 'count'
38
+ EXPECTED_COUNT = 'expected_count'
39
+ OFFSET = 'offset'
40
+ IS_FULL_IMAGE = 'is_full_image'
41
+ ORIGINAL_WIDTH = 'original_width'
42
+ ORIGINAL_HEIGHT = 'original_height'
43
+
44
+ NMS_THRESHOLD = 'nms_threshold'
45
+ MATCH_METRIC = 'match_metric'
46
+ CLASS_AGNOSTIC = 'class_agnostic'
47
+ TIMEOUT = 'timeout'
48
+
49
+
50
+ class SliceCollectorNode(BaseNode):
51
+ """
52
+ Slice Collector Node - collects detection predictions from multiple image slices
53
+ and merges them into a single unified result.
54
+
55
+ This node is designed to pair with SliceImageNode in workflows like:
56
+
57
+ SliceImageNode -> Split -> YOLONode -> SliceCollectorNode
58
+
59
+ It tracks slice messages by their msg_id from the original slice operation,
60
+ collects all predictions, transforms coordinates, and applies NMS.
61
+ """
62
+ display_name = 'Slice Collector'
63
+ info = str(_info)
64
+ icon = '📥'
65
+ category = 'vision'
66
+ color = '#FFA07A'
67
+ border_color = '#FF7F50'
68
+ text_color = '#000000'
69
+ input_count = 1
70
+ output_count = 1
71
+
72
+ DEFAULT_CONFIG = {
73
+ NodeKeys.TIMEOUT: 5.0,
74
+ NodeKeys.NMS_THRESHOLD: 0.5,
75
+ NodeKeys.MATCH_METRIC: 'iou',
76
+ NodeKeys.CLASS_AGNOSTIC: False,
77
+ MessageKeys.DROP_MESSAGES: False
78
+ }
79
+
80
+ properties = [
81
+ {
82
+ 'name': NodeKeys.TIMEOUT,
83
+ 'label': 'Collection Timeout (seconds)',
84
+ 'type': 'number',
85
+ 'default': DEFAULT_CONFIG[NodeKeys.TIMEOUT]
86
+ },
87
+ {
88
+ 'name': NodeKeys.NMS_THRESHOLD,
89
+ 'label': 'NMS IoU Threshold',
90
+ 'type': 'number',
91
+ 'default': DEFAULT_CONFIG[NodeKeys.NMS_THRESHOLD]
92
+ },
93
+ {
94
+ 'name': NodeKeys.MATCH_METRIC,
95
+ 'label': 'Match Metric',
96
+ 'type': 'select',
97
+ 'options': [
98
+ {'value': 'iou', 'label': 'IoU (Intersection over Union)'},
99
+ {'value': 'ios', 'label': 'IoS (Intersection over Smaller)'}
100
+ ],
101
+ 'default': DEFAULT_CONFIG[NodeKeys.MATCH_METRIC]
102
+ },
103
+ {
104
+ 'name': NodeKeys.CLASS_AGNOSTIC,
105
+ 'label': 'Class Agnostic NMS',
106
+ 'type': 'checkbox',
107
+ 'default': DEFAULT_CONFIG[NodeKeys.CLASS_AGNOSTIC]
108
+ }
109
+ ]
110
+
111
+ def __init__(self, node_id=None, name="slice_collector"):
112
+ super().__init__(node_id, name)
113
+ # Track collections by parent message ID
114
+ self._collections: Dict[str, Dict] = {}
115
+
116
+ def _calculate_iou(self, box1: List[float], box2: List[float]) -> float:
117
+ """Calculate IoU between two bounding boxes in [x1, y1, x2, y2] format."""
118
+ x1_1, y1_1, x2_1, y2_1 = box1
119
+ x1_2, y1_2, x2_2, y2_2 = box2
120
+
121
+ x1_i = max(x1_1, x1_2)
122
+ y1_i = max(y1_1, y1_2)
123
+ x2_i = min(x2_1, x2_2)
124
+ y2_i = min(y2_1, y2_2)
125
+
126
+ if x2_i <= x1_i or y2_i <= y1_i:
127
+ return 0.0
128
+
129
+ intersection = (x2_i - x1_i) * (y2_i - y1_i)
130
+ area1 = (x2_1 - x1_1) * (y2_1 - y1_1)
131
+ area2 = (x2_2 - x1_2) * (y2_2 - y1_2)
132
+ union = area1 + area2 - intersection
133
+
134
+ return intersection / union if union > 0 else 0.0
135
+
136
+ def _calculate_ios(self, box1: List[float], box2: List[float]) -> float:
137
+ """Calculate IoS (Intersection over Smaller) between two boxes."""
138
+ x1_1, y1_1, x2_1, y2_1 = box1
139
+ x1_2, y1_2, x2_2, y2_2 = box2
140
+
141
+ x1_i = max(x1_1, x1_2)
142
+ y1_i = max(y1_1, y1_2)
143
+ x2_i = min(x2_1, x2_2)
144
+ y2_i = min(y2_1, y2_2)
145
+
146
+ if x2_i <= x1_i or y2_i <= y1_i:
147
+ return 0.0
148
+
149
+ intersection = (x2_i - x1_i) * (y2_i - y1_i)
150
+ area1 = (x2_1 - x1_1) * (y2_1 - y1_1)
151
+ area2 = (x2_2 - x1_2) * (y2_2 - y1_2)
152
+ smaller = min(area1, area2)
153
+
154
+ return intersection / smaller if smaller > 0 else 0.0
155
+
156
+ def _transform_detections(self, detections: List[Dict], offset: List[int]) -> List[Dict]:
157
+ """Transform detection coordinates from slice-local to original image coords."""
158
+ transformed = []
159
+ offset_x, offset_y = offset
160
+
161
+ for det in detections:
162
+ new_det = det.copy()
163
+ bbox = det.get(NodeKeys.BBOX, [])
164
+
165
+ if len(bbox) >= 4:
166
+ x1, y1, x2, y2 = bbox[:4]
167
+ new_det[NodeKeys.BBOX] = [x1 + offset_x, y1 + offset_y, x2 + offset_x, y2 + offset_y]
168
+ new_det[NodeKeys.SLICE_OFFSET] = offset
169
+
170
+ transformed.append(new_det)
171
+
172
+ return transformed
173
+
174
+ def _nms(self, detections: List[Dict], nms_threshold: float,
175
+ match_metric: str = NodeKeys.MATCH_METRIC, class_agnostic: bool = False) -> List[Dict]:
176
+ """Apply Non-Maximum Suppression to remove duplicate detections."""
177
+ if not detections:
178
+ return []
179
+
180
+ sorted_dets = sorted(detections, key=lambda x: x.get('confidence', 0), reverse=True)
181
+ metric_fn = self._calculate_iou if match_metric == 'iou' else self._calculate_ios
182
+
183
+ if class_agnostic:
184
+ groups = {'all': sorted_dets}
185
+ else:
186
+ groups: Dict[Any, List[Dict]] = {}
187
+ for det in sorted_dets:
188
+ class_id = det.get('class_id', det.get('class_name', 'unknown'))
189
+ if class_id not in groups:
190
+ groups[class_id] = []
191
+ groups[class_id].append(det)
192
+
193
+ kept = []
194
+ for class_id, group in groups.items():
195
+ keep_indices = []
196
+
197
+ for i, det in enumerate(group):
198
+ should_keep = True
199
+ bbox_i = det.get(NodeKeys.BBOX, [])
200
+
201
+ if len(bbox_i) < 4:
202
+ continue
203
+
204
+ for j in keep_indices:
205
+ bbox_j = group[j].get(NodeKeys.BBOX, [])
206
+ if len(bbox_j) < 4:
207
+ continue
208
+
209
+ if metric_fn(bbox_i, bbox_j) > nms_threshold:
210
+ should_keep = False
211
+ break
212
+
213
+ if should_keep:
214
+ keep_indices.append(i)
215
+
216
+ kept.extend([group[i] for i in keep_indices])
217
+
218
+ return kept
219
+
220
+ def _cleanup_expired(self):
221
+ """Remove expired collections."""
222
+ timeout = self.get_config_float(NodeKeys.TIMEOUT, self.DEFAULT_CONFIG[NodeKeys.TIMEOUT])
223
+ current_time = time.time()
224
+ expired = [k for k, v in self._collections.items()
225
+ if current_time - v['start_time'] > timeout]
226
+ for k in expired:
227
+ del self._collections[k]
228
+
229
+ def _process_collection(self, collection_id: str) -> Optional[Dict]:
230
+ """Process a complete collection and return merged result."""
231
+ collection = self._collections.get(collection_id)
232
+ if not collection:
233
+ return None
234
+
235
+ nms_threshold = self.get_config_float(NodeKeys.NMS_THRESHOLD, self.DEFAULT_CONFIG[NodeKeys.NMS_THRESHOLD])
236
+ match_metric = self.config.get(NodeKeys.MATCH_METRIC, self.DEFAULT_CONFIG[NodeKeys.MATCH_METRIC])
237
+ class_agnostic = self.get_config_bool(NodeKeys.CLASS_AGNOSTIC, self.DEFAULT_CONFIG[NodeKeys.CLASS_AGNOSTIC])
238
+
239
+ all_detections = []
240
+ full_image_detections = []
241
+
242
+ for slice_data in collection[NodeKeys.SLICES].values():
243
+ offset = slice_data.get(NodeKeys.OFFSET, [0, 0])
244
+ detections = slice_data.get(MessageKeys.CV.DETECTIONS, [])
245
+ is_full_image = slice_data.get(NodeKeys.IS_FULL_IMAGE, False)
246
+
247
+ if is_full_image:
248
+ full_image_detections.extend(detections)
249
+ else:
250
+ transformed = self._transform_detections(detections, offset)
251
+ all_detections.extend(transformed)
252
+
253
+ # Apply NMS
254
+ merged = self._nms(all_detections, nms_threshold, match_metric, class_agnostic)
255
+
256
+ # Combine with full image detections if present
257
+ if full_image_detections:
258
+ combined = merged + full_image_detections
259
+ final_detections = self._nms(combined, nms_threshold, match_metric, class_agnostic)
260
+ else:
261
+ final_detections = merged
262
+
263
+ # Sort by confidence
264
+ final_detections = sorted(final_detections, key=lambda x: x.get(MessageKeys.CV.CONFIDENCE, 0), reverse=True)
265
+
266
+ return {
267
+ MessageKeys.CV.DETECTIONS: final_detections,
268
+ MessageKeys.CV.DETECTION_COUNT: len(final_detections),
269
+ NodeKeys.ORIGINAL_WIDTH: collection.get(NodeKeys.ORIGINAL_WIDTH, 0),
270
+ NodeKeys.ORIGINAL_HEIGHT: collection.get(NodeKeys.ORIGINAL_HEIGHT, 0),
271
+ NodeKeys.SLICE_COUNT: len(collection[NodeKeys.SLICES]),
272
+ MessageKeys.CV.BBOX_FORMAT: 'xyxy',
273
+ MessageKeys.IMAGE.PATH: collection.get(MessageKeys.IMAGE.PATH)
274
+ }
275
+
276
+ def on_input(self, msg: Dict[str, Any], input_index: int = 0):
277
+ """
278
+ Collect slice predictions and merge when complete.
279
+
280
+ Expected msg format (from Split of SliceImageNode output, processed by YOLO):
281
+ {
282
+ 'payload': {
283
+ 'image': ...,
284
+ 'detections': [...],
285
+ ...
286
+ },
287
+ 'parts': {
288
+ 'index': int,
289
+ 'count': int,
290
+ 'id': str # Parent message ID
291
+ },
292
+ 'slice_offset': [x, y], # From SliceImageNode
293
+ 'slice_index': int,
294
+ 'is_full_image': bool,
295
+ 'original_width': int,
296
+ 'original_height': int
297
+ }
298
+ """
299
+ self._cleanup_expired()
300
+
301
+ payload = msg.get(MessageKeys.PAYLOAD, {})
302
+ parts = msg.get('parts', {})
303
+
304
+ # Get collection ID (from parts or generate one)
305
+ collection_id = parts.get('id', msg.get(MessageKeys.MSG_ID, 'default'))
306
+ expected_count = parts.get('count', 1)
307
+ slice_index = parts.get('index', msg.get(NodeKeys.SLICE_INDEX, 0))
308
+
309
+ # Get slice metadata (check message level first, then payload)
310
+ offset = msg.get( NodeKeys.OFFSET, payload.get(NodeKeys.OFFSET, [0, 0]) if isinstance(payload, dict) else [0, 0])
311
+ slice_bbox = msg.get(NodeKeys.BBOX, payload.get(NodeKeys.BBOX, None) if isinstance(payload, dict) else None)
312
+ is_full_image = msg.get(NodeKeys.IS_FULL_IMAGE, payload.get(NodeKeys.IS_FULL_IMAGE, False) if isinstance(payload, dict) else False)
313
+ original_width = msg.get(NodeKeys.ORIGINAL_WIDTH, payload.get(NodeKeys.ORIGINAL_WIDTH, 0) if isinstance(payload, dict) else 0)
314
+ original_height = msg.get(NodeKeys.ORIGINAL_HEIGHT, payload.get(NodeKeys.ORIGINAL_HEIGHT, 0) if isinstance(payload, dict) else 0)
315
+
316
+ # Get detections
317
+ if isinstance(payload, dict):
318
+ detections = payload.get(MessageKeys.CV.DETECTIONS, [])
319
+ image = payload.get(MessageKeys.IMAGE.PATH)
320
+ else:
321
+ detections = []
322
+ image = None
323
+
324
+ # Initialize collection if needed
325
+ if collection_id not in self._collections:
326
+ self._collections[collection_id] = {
327
+ 'start_time': time.time(),
328
+ NodeKeys.EXPECTED_COUNT: expected_count,
329
+ NodeKeys.SLICES: {},
330
+ NodeKeys.ORIGINAL_WIDTH: original_width,
331
+ NodeKeys.ORIGINAL_HEIGHT: original_height,
332
+ MessageKeys.ORIGINAL_MSG: msg.copy(),
333
+ MessageKeys.IMAGE.PATH: None
334
+ }
335
+
336
+ collection = self._collections[collection_id]
337
+
338
+ # Store slice data
339
+ collection[NodeKeys.SLICES][slice_index] = {
340
+ MessageKeys.CV.DETECTIONS: detections,
341
+ NodeKeys.OFFSET: offset,
342
+ NodeKeys.IS_FULL_IMAGE: is_full_image
343
+ }
344
+
345
+ # Store full image if this is the full image slice
346
+ if is_full_image and image is not None:
347
+ collection[MessageKeys.IMAGE.PATH] = image
348
+
349
+ # Update dimensions if available
350
+ if original_width > 0:
351
+ collection[NodeKeys.ORIGINAL_WIDTH] = original_width
352
+ if original_height > 0:
353
+ collection[NodeKeys.ORIGINAL_HEIGHT] = original_height
354
+
355
+ # Check if complete
356
+ if len(collection[NodeKeys.SLICES]) >= collection[NodeKeys.EXPECTED_COUNT]:
357
+ result = self._process_collection(collection_id)
358
+
359
+ if result:
360
+ out_msg = collection[MessageKeys.ORIGINAL_MSG].copy()
361
+ out_msg[MessageKeys.PAYLOAD] = result
362
+ out_msg[MessageKeys.TOPIC] = out_msg.get(MessageKeys.TOPIC, 'merged_predictions')
363
+
364
+ # Clean up parts since we've merged
365
+ out_msg.pop('parts', None)
366
+
367
+ self.send(out_msg)
368
+
369
+ # Clean up
370
+ del self._collections[collection_id]