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.
- pynode/__init__.py +37 -0
- pynode/__main__.py +9 -0
- pynode/_perf.py +65 -0
- pynode/_version.py +24 -0
- pynode/api/__init__.py +15 -0
- pynode/api/helpers.py +79 -0
- pynode/api/nodes.py +423 -0
- pynode/api/services.py +124 -0
- pynode/api/sse.py +58 -0
- pynode/api/uploads.py +59 -0
- pynode/api/workflows.py +466 -0
- pynode/config.py +93 -0
- pynode/install_nodes.py +143 -0
- pynode/main.py +118 -0
- pynode/node_registry.py +156 -0
- pynode/nodes/AutoSyncNode/auto_sync_node.py +151 -0
- pynode/nodes/BBoxMetricsNode/__init__.py +3 -0
- pynode/nodes/BBoxMetricsNode/bbox_metrics_node.py +250 -0
- pynode/nodes/BBoxMetricsNode/requirements.txt +1 -0
- pynode/nodes/BatchNode/__init__.py +3 -0
- pynode/nodes/BatchNode/batch_node.py +86 -0
- pynode/nodes/BatchNode/requirements.txt +1 -0
- pynode/nodes/CameraNode/__init__.py +4 -0
- pynode/nodes/CameraNode/camera_node.py +204 -0
- pynode/nodes/CameraNode/requirements.txt +2 -0
- pynode/nodes/ChangeNode/__init__.py +1 -0
- pynode/nodes/ChangeNode/change_node.py +255 -0
- pynode/nodes/ChangeNode/requirements.txt +1 -0
- pynode/nodes/ConfidenceFilterNode/__init__.py +4 -0
- pynode/nodes/ConfidenceFilterNode/confidence_filter_node.py +208 -0
- pynode/nodes/ConfidenceFilterNode/requirements.txt +1 -0
- pynode/nodes/CounterNode/__init__.py +3 -0
- pynode/nodes/CounterNode/counter_node.py +191 -0
- pynode/nodes/CropNode/__init__.py +4 -0
- pynode/nodes/CropNode/crop_node.py +317 -0
- pynode/nodes/CropNode/requirements.txt +2 -0
- pynode/nodes/DebugNode/__init__.py +4 -0
- pynode/nodes/DebugNode/debug_node.py +169 -0
- pynode/nodes/DebugNode/requirements.txt +1 -0
- pynode/nodes/DelayNode/delay_node.py +218 -0
- pynode/nodes/DelayNode/requirements.txt +1 -0
- pynode/nodes/DrawPredictionsNode/__init__.py +3 -0
- pynode/nodes/DrawPredictionsNode/draw_predictions_node.py +365 -0
- pynode/nodes/DrawPredictionsNode/requirements.txt +2 -0
- pynode/nodes/ErrorNode/error_node.py +94 -0
- pynode/nodes/ErrorNode/requirements.txt +1 -0
- pynode/nodes/FilterNode/__init__.py +3 -0
- pynode/nodes/FilterNode/filter_node.py +102 -0
- pynode/nodes/FilterNode/requirements.txt +1 -0
- pynode/nodes/FrameSourceNode/__init__.py +4 -0
- pynode/nodes/FrameSourceNode/frame_source_node.py +319 -0
- pynode/nodes/FrameSourceNode/requirements.txt +1 -0
- pynode/nodes/FunctionNode/__init__.py +3 -0
- pynode/nodes/FunctionNode/function_node.py +168 -0
- pynode/nodes/FunctionNode/requirements.txt +1 -0
- pynode/nodes/GateNode/gate_node.py +60 -0
- pynode/nodes/GateNode/requirements.txt +1 -0
- pynode/nodes/GetiUploadNode/__init__.py +3 -0
- pynode/nodes/GetiUploadNode/geti_upload_node.py +278 -0
- pynode/nodes/GetiUploadNode/requirements.txt +1 -0
- pynode/nodes/ImageFormatNode/__init__.py +4 -0
- pynode/nodes/ImageFormatNode/image_format_node.py +310 -0
- pynode/nodes/ImageFormatNode/requirements.txt +2 -0
- pynode/nodes/ImageUploadNode/image_upload_node.py +96 -0
- pynode/nodes/ImageViewerNode/__init__.py +4 -0
- pynode/nodes/ImageViewerNode/image_viewer_node.py +162 -0
- pynode/nodes/ImageViewerNode/requirements.txt +2 -0
- pynode/nodes/ImageWriterNode/__init__.py +3 -0
- pynode/nodes/ImageWriterNode/imagewriter_node.py +352 -0
- pynode/nodes/ImageWriterNode/requirements.txt +1 -0
- pynode/nodes/InferenceNode/InferenceEngine/__init__.py +15 -0
- pynode/nodes/InferenceNode/InferenceEngine/device_detection.py +149 -0
- pynode/nodes/InferenceNode/InferenceEngine/engines/base_engine.py +155 -0
- pynode/nodes/InferenceNode/InferenceEngine/engines/example_engine_template.py +272 -0
- pynode/nodes/InferenceNode/InferenceEngine/engines/geti_engine.py +309 -0
- pynode/nodes/InferenceNode/InferenceEngine/engines/onnx_engine.py +346 -0
- pynode/nodes/InferenceNode/InferenceEngine/engines/pass_engine.py +86 -0
- pynode/nodes/InferenceNode/InferenceEngine/engines/ultralytics_engine.py +863 -0
- pynode/nodes/InferenceNode/InferenceEngine/inference_engine_factory.py +533 -0
- pynode/nodes/InferenceNode/InferenceEngine/readme.md +107 -0
- pynode/nodes/InferenceNode/InferenceEngine/result_converters.py +121 -0
- pynode/nodes/InferenceNode/inference_node.py +451 -0
- pynode/nodes/InferenceNode/requirements.txt +13 -0
- pynode/nodes/InjectNode/inject_node.py +245 -0
- pynode/nodes/InjectNode/requirements.txt +1 -0
- pynode/nodes/JoinNode/__init__.py +3 -0
- pynode/nodes/JoinNode/join_node.py +156 -0
- pynode/nodes/JoinNode/requirements.txt +1 -0
- pynode/nodes/LabelFilterNode/__init__.py +3 -0
- pynode/nodes/LabelFilterNode/label_filter_node.py +188 -0
- pynode/nodes/LabelFilterNode/requirements.txt +1 -0
- pynode/nodes/MQTTNode/__init__.py +8 -0
- pynode/nodes/MQTTNode/mqtt_in_node.py +148 -0
- pynode/nodes/MQTTNode/mqtt_out_node.py +157 -0
- pynode/nodes/MQTTNode/mqtt_service.py +367 -0
- pynode/nodes/MQTTNode/requirements.txt +1 -0
- pynode/nodes/MessageWriterNode/__init__.py +7 -0
- pynode/nodes/MessageWriterNode/messagereader_node.py +568 -0
- pynode/nodes/MessageWriterNode/messagewriter_node.py +505 -0
- pynode/nodes/OmronCameraNode/__init__.py +3 -0
- pynode/nodes/OmronCameraNode/omron_camera_node.py +652 -0
- pynode/nodes/OmronCameraNode/requirements.txt +4 -0
- pynode/nodes/OpenCV/__init__.py +1 -0
- pynode/nodes/OpenCV/arithmetic_node.py +376 -0
- pynode/nodes/OpenCV/bitwise_node.py +127 -0
- pynode/nodes/OpenCV/blend_node.py +127 -0
- pynode/nodes/OpenCV/blob_detector_node.py +233 -0
- pynode/nodes/OpenCV/blur_node.py +141 -0
- pynode/nodes/OpenCV/color_space_node.py +139 -0
- pynode/nodes/OpenCV/colormap_node.py +262 -0
- pynode/nodes/OpenCV/contours_node.py +258 -0
- pynode/nodes/OpenCV/denormalize_coords_node.py +365 -0
- pynode/nodes/OpenCV/draw_node.py +312 -0
- pynode/nodes/OpenCV/edge_detector_node.py +177 -0
- pynode/nodes/OpenCV/equirectangular_node.py +447 -0
- pynode/nodes/OpenCV/fft_node.py +102 -0
- pynode/nodes/OpenCV/focus_estimation_node.py +189 -0
- pynode/nodes/OpenCV/frequency_filter_node.py +241 -0
- pynode/nodes/OpenCV/histogram_node.py +160 -0
- pynode/nodes/OpenCV/hough_circles_node.py +224 -0
- pynode/nodes/OpenCV/hough_lines_node.py +243 -0
- pynode/nodes/OpenCV/in_range_node.py +186 -0
- pynode/nodes/OpenCV/morphology_node.py +136 -0
- pynode/nodes/OpenCV/normalize_coords_node.py +344 -0
- pynode/nodes/OpenCV/paste_node.py +244 -0
- pynode/nodes/OpenCV/perspective_node.py +174 -0
- pynode/nodes/OpenCV/realsense_depth_node.py +253 -0
- pynode/nodes/OpenCV/requirements.txt +2 -0
- pynode/nodes/OpenCV/resize_node.py +175 -0
- pynode/nodes/OpenCV/rotate_node.py +159 -0
- pynode/nodes/OpenCV/template_match_node.py +227 -0
- pynode/nodes/OpenCV/threshold_node.py +167 -0
- pynode/nodes/PointInShapeNode/__init__.py +3 -0
- pynode/nodes/PointInShapeNode/point_in_shape_node.py +313 -0
- pynode/nodes/PointInShapeNode/requirements.txt +2 -0
- pynode/nodes/PolygonMetricsNode/__init__.py +3 -0
- pynode/nodes/PolygonMetricsNode/polygon_metrics_node.py +331 -0
- pynode/nodes/PolygonMetricsNode/requirements.txt +2 -0
- pynode/nodes/QueueLengthProbeNode/__init__.py +3 -0
- pynode/nodes/QueueLengthProbeNode/queue_length_probe_node.py +116 -0
- pynode/nodes/Qwen3VLMNode/__init__.py +3 -0
- pynode/nodes/Qwen3VLMNode/qwen3_vlm_node.py +519 -0
- pynode/nodes/Qwen3VLMNode/requirements.txt +5 -0
- pynode/nodes/RESTEndpointNode/__init__.py +3 -0
- pynode/nodes/RESTEndpointNode/rest_endpoint_node.py +491 -0
- pynode/nodes/RangeNode/__init__.py +3 -0
- pynode/nodes/RangeNode/range_node.py +115 -0
- pynode/nodes/RangeNode/requirements.txt +1 -0
- pynode/nodes/RateProbeNode/__init__.py +3 -0
- pynode/nodes/RateProbeNode/rate_probe_node.py +183 -0
- pynode/nodes/RateProbeNode/requirements.txt +1 -0
- pynode/nodes/RoboflowUploadNode/__init__.py +3 -0
- pynode/nodes/RoboflowUploadNode/requirements.txt +1 -0
- pynode/nodes/RoboflowUploadNode/roboflow_upload_node.py +229 -0
- pynode/nodes/SliceNode/README.md +149 -0
- pynode/nodes/SliceNode/__init__.py +5 -0
- pynode/nodes/SliceNode/merge_predictions_node.py +444 -0
- pynode/nodes/SliceNode/requirements.txt +3 -0
- pynode/nodes/SliceNode/slice_collector_node.py +370 -0
- pynode/nodes/SliceNode/slice_image_node.py +380 -0
- pynode/nodes/SplitNode/__init__.py +3 -0
- pynode/nodes/SplitNode/requirements.txt +1 -0
- pynode/nodes/SplitNode/split_node.py +155 -0
- pynode/nodes/SwitchNode/requirements.txt +1 -0
- pynode/nodes/SwitchNode/switch_node.py +259 -0
- pynode/nodes/SyncNode/__init__.py +3 -0
- pynode/nodes/SyncNode/sync_node.py +287 -0
- pynode/nodes/TemplateNode/requirements.txt +1 -0
- pynode/nodes/TemplateNode/template_node.py +86 -0
- pynode/nodes/TrackerNode/__init__.py +2 -0
- pynode/nodes/TrackerNode/supervision_tracker_node.py +248 -0
- pynode/nodes/TrackerNode/tracker_node.py +745 -0
- pynode/nodes/UltralyticsNode/__init__.py +4 -0
- pynode/nodes/UltralyticsNode/requirements.txt +2 -0
- pynode/nodes/UltralyticsNode/ultralytics_node.py +364 -0
- pynode/nodes/UnknownNode/__init__.py +93 -0
- pynode/nodes/VLLMNode/__init__.py +3 -0
- pynode/nodes/VLLMNode/vllm_node.py +345 -0
- pynode/nodes/VideoReaderNode/__init__.py +3 -0
- pynode/nodes/VideoReaderNode/video_reader_node.py +425 -0
- pynode/nodes/VideoWriterNode/__init__.py +3 -0
- pynode/nodes/VideoWriterNode/video_writer_node.py +441 -0
- pynode/nodes/WebhookNode/__init__.py +3 -0
- pynode/nodes/WebhookNode/webhook_node.py +591 -0
- pynode/nodes/__init__.py +109 -0
- pynode/nodes/base_node.py +562 -0
- pynode/nodes/image_utils.py +291 -0
- pynode/nodes/info.py +109 -0
- pynode/nodes/mDNSNode/__init__.py +13 -0
- pynode/nodes/mDNSNode/mdns_broadcast_node.py +214 -0
- pynode/nodes/mDNSNode/mdns_discovery_node.py +362 -0
- pynode/nodes/mDNSNode/mdns_manager.py +399 -0
- pynode/nodes/mDNSNode/requirements.txt +1 -0
- pynode/nodes/messages.py +81 -0
- pynode/server.py +187 -0
- pynode/static/images/favicon4.png +0 -0
- pynode/static/index.html +200 -0
- pynode/static/js/README.md +139 -0
- pynode/static/js/auth.js +89 -0
- pynode/static/js/clipboard.js +152 -0
- pynode/static/js/config.js +14 -0
- pynode/static/js/connections.js +844 -0
- pynode/static/js/debug.js +524 -0
- pynode/static/js/events.js +775 -0
- pynode/static/js/history.js +199 -0
- pynode/static/js/main.js +55 -0
- pynode/static/js/minimap.js +242 -0
- pynode/static/js/nodes.js +995 -0
- pynode/static/js/palette.js +255 -0
- pynode/static/js/properties.js +1270 -0
- pynode/static/js/selection.js +241 -0
- pynode/static/js/state.js +211 -0
- pynode/static/js/ui-utils.js +28 -0
- pynode/static/js/viewport.js +204 -0
- pynode/static/js/workflow.js +469 -0
- pynode/static/js/workflows.js +344 -0
- pynode/static/style.css +2751 -0
- pynode/workflow_engine.py +483 -0
- pynode/workflow_manager.py +440 -0
- pynode_flow-0.2.0.dist-info/METADATA +520 -0
- pynode_flow-0.2.0.dist-info/RECORD +227 -0
- pynode_flow-0.2.0.dist-info/WHEEL +5 -0
- pynode_flow-0.2.0.dist-info/entry_points.txt +3 -0
- pynode_flow-0.2.0.dist-info/licenses/LICENSE +21 -0
- pynode_flow-0.2.0.dist-info/scm_file_list.json +270 -0
- pynode_flow-0.2.0.dist-info/scm_version.json +8 -0
- 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]
|