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,218 @@
1
+ """
2
+ Delay node - delays message delivery.
3
+ Similar to Node-RED's delay node.
4
+ """
5
+
6
+ import time
7
+ import threading
8
+ from collections import deque
9
+ from typing import Any, Dict
10
+ from pynode.nodes.base_node import BaseNode, Info, MessageKeys
11
+
12
+ _info = Info()
13
+ _info.add_text("Delays or rate-limits message delivery. Similar to Node-RED's delay node.")
14
+ _info.add_header("Input")
15
+ _info.add_bullets(
16
+ ("msg:", "Any message to be delayed or rate-limited."),
17
+ )
18
+ _info.add_header("Output")
19
+ _info.add_bullets(
20
+ ("msg:", "The original message after the configured delay."),
21
+ )
22
+ _info.add_header("Modes")
23
+ _info.add_bullets(
24
+ ("Delay each message:", "Hold each message for a fixed time before sending."),
25
+ ("Delay by message count:", "Release messages after N more messages arrive."),
26
+ ("Rate limit:", "Limit throughput to N messages per time period."),
27
+ )
28
+ _info.add_header("Properties")
29
+ _info.add_bullets(
30
+ ("Delay (seconds):", "Time to delay each message."),
31
+ ("Delay (messages):", "Number of messages to buffer before releasing."),
32
+ ("Rate/Time:", "Messages per time period for rate limiting."),
33
+ ("Intermediate Messages:", "Drop or queue messages during rate limiting."),
34
+ )
35
+
36
+
37
+ class DelayNode(BaseNode):
38
+ """
39
+ Delay node - delays message delivery (non-blocking).
40
+ Similar to Node-RED's delay node.
41
+ """
42
+ info = str(_info)
43
+ display_name = 'Delay'
44
+ icon = '⧗'
45
+ category = 'function'
46
+ color = '#E6E0F8'
47
+ border_color = '#9F93C6'
48
+ text_color = '#000000'
49
+ input_count = 1
50
+ output_count = 1
51
+
52
+ DEFAULT_CONFIG = {
53
+ 'mode': 'delay',
54
+ 'timeout': 0,
55
+ 'delay_count': 1,
56
+ 'rate': 1,
57
+ 'rate_time': 1,
58
+ 'rate_drop': 'drop',
59
+ 'drop_messages': False
60
+ }
61
+
62
+ properties = [
63
+ {
64
+ 'name': 'mode',
65
+ 'label': 'Mode',
66
+ 'type': 'select',
67
+ 'options': [
68
+ {'value': 'delay', 'label': 'Delay each message'},
69
+ {'value': 'delay_count', 'label': 'Delay by message count'},
70
+ {'value': 'rate', 'label': 'Rate limit'}
71
+ ]
72
+ },
73
+ {
74
+ 'name': 'timeout',
75
+ 'label': 'Delay (seconds)',
76
+ 'type': 'number',
77
+ 'default': DEFAULT_CONFIG['timeout'],
78
+ 'help': 'Delay time in seconds (for time-based delay)',
79
+ 'showIf': {'mode': ['delay']}
80
+ },
81
+ {
82
+ 'name': 'delay_count',
83
+ 'label': 'Delay (messages)',
84
+ 'type': 'number',
85
+ 'default': DEFAULT_CONFIG['delay_count'],
86
+ 'min': 1,
87
+ 'help': 'Number of messages to delay by (for count-based delay)',
88
+ 'showIf': {'mode': 'delay_count'}
89
+ },
90
+ {
91
+ 'name': 'rate',
92
+ 'label': 'Rate Limit (count)',
93
+ 'type': 'number',
94
+ 'default': DEFAULT_CONFIG['rate'],
95
+ 'showIf': {'mode': 'rate'}
96
+ },
97
+ {
98
+ 'name': 'rate_time',
99
+ 'label': 'Per Time (seconds)',
100
+ 'type': 'number',
101
+ 'default': DEFAULT_CONFIG['rate_time'],
102
+ 'showIf': {'mode': 'rate'}
103
+ },
104
+ {
105
+ 'name': 'rate_drop',
106
+ 'label': 'Intermediate Messages',
107
+ 'type': 'select',
108
+ 'options': [
109
+ {'value': 'drop', 'label': 'Drop'},
110
+ {'value': 'queue', 'label': 'Queue'}
111
+ ],
112
+ 'showIf': {'mode': 'rate'}
113
+ }
114
+ ]
115
+
116
+ def __init__(self, node_id=None, name="delay"):
117
+ super().__init__(node_id, name)
118
+ self.last_send_time = 0
119
+ self.queued_messages = []
120
+ self.processing_queue = False
121
+ self.queue_lock = threading.Lock()
122
+ # Buffer for count-based delay
123
+ self._message_buffer: deque = deque()
124
+
125
+ def on_start(self):
126
+ """Initialize on start."""
127
+ super().on_start()
128
+ self._message_buffer.clear()
129
+
130
+ def on_input(self, msg: Dict[str, Any], input_index: int = 0):
131
+ """
132
+ Handle message based on mode: delay each message, delay by count, or rate limit.
133
+ """
134
+ mode = self.config.get('mode', 'delay')
135
+
136
+ if mode == 'delay':
137
+ # Delay each message by time
138
+ timeout = self.get_config_float('timeout', 1)
139
+ timer = threading.Timer(timeout, self.send, args=(msg,))
140
+ timer.daemon = True
141
+ timer.start()
142
+ elif mode == 'delay_count':
143
+ # Delay by message count
144
+ self._delay_by_count(msg)
145
+ else:
146
+ # Rate limiting mode
147
+ self._rate_limit(msg)
148
+
149
+ def _delay_by_count(self, msg: Dict[str, Any]):
150
+ """
151
+ Delay messages by a number of messages.
152
+ Each message is released after N more messages have arrived.
153
+ """
154
+ delay_count = self.get_config_int('delay_count', 1)
155
+
156
+ # Add message to buffer
157
+ self._message_buffer.append(msg)
158
+
159
+ # If buffer has more messages than delay count, release the oldest
160
+ while len(self._message_buffer) > delay_count:
161
+ oldest_msg = self._message_buffer.popleft()
162
+ self.send(oldest_msg)
163
+
164
+ def _rate_limit(self, msg: Dict[str, Any]):
165
+ """
166
+ Rate limit messages - only send at specified rate.
167
+ """
168
+ rate = self.get_config_int('rate', 1)
169
+ rate_time = self.get_config_float('rate_time', 1)
170
+ rate_drop = self.config.get('rate_drop', 'drop')
171
+
172
+ # Calculate interval: time / count
173
+ interval = rate_time / rate
174
+
175
+ with self.queue_lock:
176
+ current_time = time.time()
177
+ time_since_last = current_time - self.last_send_time
178
+
179
+ if time_since_last >= interval and not self.queued_messages:
180
+ # Enough time has passed and no queue, send immediately
181
+ self.send(msg)
182
+ self.last_send_time = current_time
183
+ else:
184
+ # Too soon or queue exists, handle based on drop/queue setting
185
+ if rate_drop == 'queue':
186
+ self.queued_messages.append(msg)
187
+ # Start processing queue if not already running
188
+ if not self.processing_queue:
189
+ self.processing_queue = True
190
+ delay = max(0, interval - time_since_last)
191
+ timer = threading.Timer(delay, self._process_queued)
192
+ timer.daemon = True
193
+ timer.start()
194
+ # else: drop (do nothing)
195
+
196
+ def _process_queued(self):
197
+ """Process next queued message if available."""
198
+ with self.queue_lock:
199
+ if not self.queued_messages:
200
+ self.processing_queue = False
201
+ return
202
+
203
+ msg = self.queued_messages.pop(0)
204
+ current_time = time.time()
205
+ self.send(msg)
206
+ self.last_send_time = current_time
207
+
208
+ # Schedule next message if queue not empty
209
+ if self.queued_messages:
210
+ rate = self.get_config_int('rate', 1)
211
+ rate_time = self.get_config_float('rate_time', 1)
212
+ interval = rate_time / rate
213
+
214
+ timer = threading.Timer(interval, self._process_queued)
215
+ timer.daemon = True
216
+ timer.start()
217
+ else:
218
+ self.processing_queue = False
@@ -0,0 +1 @@
1
+ # No external dependencies - uses only Python standard library
@@ -0,0 +1,3 @@
1
+ from ..DrawPredictionsNode.draw_predictions_node import DrawPredictionsNode
2
+
3
+ __all__ = ['DrawPredictionsNode']
@@ -0,0 +1,365 @@
1
+ """
2
+ Draw Predictions Node - draws detection predictions (bounding boxes, labels) on images.
3
+ Takes predictions from UltralyticsNode and original image, outputs annotated image.
4
+ """
5
+
6
+ import base64
7
+ import cv2
8
+ import logging
9
+ import numpy as np
10
+ from typing import Any, Dict, Optional
11
+ from pynode.nodes.base_node import BaseNode, Info, MessageKeys
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ _info = Info()
16
+ _info.add_text("Draws detection results (bounding boxes and labels) on images.")
17
+ _info.add_header("Inputs")
18
+ _info.add_bullets(
19
+ ("Input 0:", "Message with payload.predictions (from YOLO or Inference) and payload.image (base64)"),
20
+ )
21
+ _info.add_header("Outputs")
22
+ _info.add_bullets(
23
+ ("Output 0:", "Annotated image with bounding boxes and labels drawn"),
24
+ )
25
+ _info.add_header("Configuration")
26
+ _info.add_bullets(
27
+ ("Line Width:", "Thickness of bounding box lines"),
28
+ ("Font Scale:", "Size of label text"),
29
+ ("Text Color:", "Color of label text"),
30
+ ("Show Confidence:", "Display confidence scores on labels"),
31
+ ("Show Class:", "Display class names on labels"),
32
+ )
33
+
34
+
35
+ class DrawPredictionsNode(BaseNode):
36
+ """
37
+ Draw Predictions Node - visualizes YOLO or Inference detection results on images.
38
+ Expects msg.payload.predictions (from YOLO or Inference) and msg.payload.image (base64).
39
+ """
40
+ display_name = 'Draw Predictions'
41
+ info = str(_info)
42
+ icon = '🎨'
43
+ category = 'vision'
44
+ color = '#FF6B9D'
45
+ border_color = '#CC5680'
46
+ text_color = '#FFFFFF'
47
+ input_count = 1
48
+ output_count = 1
49
+ ui_component = 'toggle'
50
+ ui_component_config = {
51
+ 'action': 'toggle_drawing',
52
+ 'label': 'Draw'
53
+ }
54
+
55
+ # UI-triggerable actions (see BaseNode.actions)
56
+ actions = ['toggle_drawing']
57
+
58
+ DEFAULT_CONFIG = {
59
+ 'detections_path': 'payload.detections',
60
+ 'line_width': '2',
61
+ 'font_scale': '1',
62
+ 'text_thickness': '2',
63
+ 'text_color': 'white',
64
+ 'show_confidence': 'true',
65
+ 'show_class': 'true'
66
+ }
67
+
68
+ properties = [
69
+ {
70
+ 'name': 'detections_path',
71
+ 'label': 'Detections/Tracks Path',
72
+ 'type': 'text',
73
+ 'default': DEFAULT_CONFIG['detections_path'],
74
+ 'description': 'Dot-separated path to detections/tracks list (e.g. payload.detections or payload.tracks)'
75
+ },
76
+ {
77
+ 'name': 'enabled',
78
+ 'label': 'Drawing Enabled',
79
+ 'type': 'toggle',
80
+ 'action': 'toggle_drawing',
81
+ 'stateField': 'drawingEnabled',
82
+ 'help': 'Toggle drawing on/off'
83
+ },
84
+ {
85
+ 'name': 'line_width',
86
+ 'label': 'Line Width',
87
+ 'type': 'text',
88
+ 'default': DEFAULT_CONFIG['line_width'],
89
+ 'help': 'Thickness of bounding box lines'
90
+ },
91
+ {
92
+ 'name': 'font_scale',
93
+ 'label': 'Font Scale',
94
+ 'type': 'text',
95
+ 'default': DEFAULT_CONFIG['font_scale'],
96
+ 'help': 'Size of label text'
97
+ },
98
+ {
99
+ 'name': 'text_thickness',
100
+ 'label': 'Text Thickness',
101
+ 'type': 'text',
102
+ 'default': DEFAULT_CONFIG['text_thickness'],
103
+ 'help': 'Thickness of label text'
104
+ },
105
+ {
106
+ 'name': 'text_color',
107
+ 'label': 'Text Color',
108
+ 'type': 'select',
109
+ 'options': [
110
+ {'value': 'white', 'label': 'White'},
111
+ {'value': 'black', 'label': 'Black'},
112
+ {'value': 'red', 'label': 'Red'},
113
+ {'value': 'green', 'label': 'Green'},
114
+ {'value': 'blue', 'label': 'Blue'},
115
+ {'value': 'yellow', 'label': 'Yellow'}
116
+ ],
117
+ 'default': DEFAULT_CONFIG['text_color'],
118
+ 'help': 'Color of label text'
119
+ },
120
+ {
121
+ 'name': 'show_confidence',
122
+ 'label': 'Show Confidence',
123
+ 'type': 'select',
124
+ 'options': [
125
+ {'value': 'true', 'label': 'Yes'},
126
+ {'value': 'false', 'label': 'No'}
127
+ ],
128
+ 'default': DEFAULT_CONFIG['show_confidence'],
129
+ 'help': 'Display confidence scores on labels'
130
+ },
131
+ {
132
+ 'name': 'show_class',
133
+ 'label': 'Show Class Name',
134
+ 'type': 'select',
135
+ 'options': [
136
+ {'value': 'true', 'label': 'Yes'},
137
+ {'value': 'false', 'label': 'No'}
138
+ ],
139
+ 'default': DEFAULT_CONFIG['show_class'],
140
+ 'help': 'Display class names on labels'
141
+ }
142
+ ]
143
+
144
+ def __init__(self, node_id=None, name="Draw Predictions"):
145
+ super().__init__(node_id, name)
146
+ self.drawing_enabled = True # Drawing is enabled by default
147
+ self.configure({
148
+ 'line_width': '2',
149
+ 'font_scale': '0.5',
150
+ 'text_thickness': '1',
151
+ 'text_color': 'white',
152
+ 'show_confidence': 'true',
153
+ 'show_class': 'true'
154
+ })
155
+
156
+ # Color palette for different classes
157
+ self.colors = [
158
+ (255, 0, 0), # Red
159
+ (0, 255, 0), # Green
160
+ (0, 0, 255), # Blue
161
+ (255, 255, 0), # Yellow
162
+ (255, 0, 255), # Magenta
163
+ (0, 255, 255), # Cyan
164
+ (255, 128, 0), # Orange
165
+ (128, 0, 255), # Purple
166
+ (0, 255, 128), # Spring Green
167
+ (255, 0, 128), # Rose
168
+ ]
169
+
170
+ # Text color mapping
171
+ self.text_colors = {
172
+ 'white': (255, 255, 255),
173
+ 'black': (0, 0, 0),
174
+ 'red': (0, 0, 255), # BGR format
175
+ 'green': (0, 255, 0),
176
+ 'blue': (255, 0, 0),
177
+ 'yellow': (0, 255, 255)
178
+ }
179
+
180
+ def _decode_image(self, image_data: str) -> Optional[np.ndarray]:
181
+ """Decode base64 image to numpy array."""
182
+ try:
183
+ # Remove data URL prefix if present
184
+ if ',' in image_data:
185
+ image_data = image_data.split(',', 1)[1]
186
+
187
+ # Decode base64
188
+ img_bytes = base64.b64decode(image_data)
189
+ nparr = np.frombuffer(img_bytes, np.uint8)
190
+ img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
191
+ return img
192
+ except Exception as e:
193
+ self.report_error(f"Failed to decode image: {str(e)}")
194
+ return None
195
+
196
+ def _encode_image(self, img: np.ndarray) -> Optional[str]:
197
+ """Encode numpy array to base64 JPEG."""
198
+ try:
199
+ _, buffer = cv2.imencode('.jpg', img)
200
+ img_bytes = buffer.tobytes()
201
+ img_base64 = base64.b64encode(img_bytes).decode('utf-8')
202
+ return img_base64
203
+ except Exception as e:
204
+ self.report_error(f"Failed to encode image: {str(e)}")
205
+ return None
206
+
207
+ def _get_color(self, class_id: int) -> tuple:
208
+ """Get color for a class ID."""
209
+ return self.colors[class_id % len(self.colors)]
210
+
211
+ def toggle_drawing(self):
212
+ """Toggle drawing on/off."""
213
+ self.drawing_enabled = not self.drawing_enabled
214
+ status = "enabled" if self.drawing_enabled else "disabled"
215
+ logger.info(f"Draw Predictions ({self.id}): Drawing {status}")
216
+
217
+ def to_dict(self) -> Dict[str, Any]:
218
+ """Serialize node to dictionary for API/storage, including drawing state."""
219
+ data = super().to_dict()
220
+ data['drawingEnabled'] = self.drawing_enabled
221
+ return data
222
+
223
+ def on_input(self, msg: Dict[str, Any], input_index: int = 0):
224
+ """Draw predictions/tracks on image, with configurable path and track_id support."""
225
+ # If drawing is disabled, pass through the message unchanged
226
+ if not self.drawing_enabled:
227
+ self.send(msg)
228
+ return
229
+
230
+ # Get detections/tracks path from config
231
+ detections_path = self.config.get('detections_path', self.DEFAULT_CONFIG['detections_path'])
232
+
233
+ detections = self._get_nested_value(msg, detections_path)
234
+ payload = msg.get(MessageKeys.PAYLOAD, {})
235
+ image_data = payload.get(MessageKeys.IMAGE.PATH)
236
+
237
+ if image_data is None:
238
+ self.report_error(f"No image data in message. Expected msg.{MessageKeys.PAYLOAD}.{MessageKeys.IMAGE.PATH}")
239
+ return
240
+
241
+ # Decode image using base node helper and make a copy to avoid modifying upstream data
242
+ img, input_format = self.decode_image({MessageKeys.IMAGE.PATH: image_data})
243
+ if img is None or input_format is None:
244
+ self.report_error("Failed to decode image from payload")
245
+ return
246
+ img = img.copy()
247
+
248
+ # Check if there are any detections/tracks
249
+ if isinstance(detections, np.ndarray):
250
+ has_detections = len(detections) > 0
251
+ else:
252
+ has_detections = bool(detections)
253
+
254
+ if not has_detections:
255
+ self.send(msg)
256
+ return
257
+
258
+ # Get configuration
259
+ line_width = self.get_config_int('line_width', 2)
260
+ font_scale = self.get_config_float('font_scale', 0.5)
261
+ text_thickness = self.get_config_int('text_thickness', 1)
262
+ text_color_name = self.config.get('text_color', 'white')
263
+ text_color = self.text_colors.get(text_color_name, (255, 255, 255))
264
+ show_confidence = self.get_config_bool('show_confidence', True)
265
+ show_class = self.get_config_bool('show_class', True)
266
+
267
+ # Draw each detection/track
268
+ for i, det in enumerate(detections):
269
+ try:
270
+ bbox = det.get('bbox', [0, 0, 0, 0])
271
+ if isinstance(bbox, np.ndarray):
272
+ bbox = bbox.tolist()
273
+ x1, y1, x2, y2 = bbox
274
+ class_id = det.get('class_id', 0)
275
+ class_name = det.get('class_name', 'unknown')
276
+ confidence = det.get('confidence', 0.0)
277
+ track_id = det.get('track_id', None)
278
+
279
+ x1, y1, x2, y2 = int(x1), int(y1), int(x2), int(y2)
280
+ color = self._get_color(class_id)
281
+ cv2.rectangle(img, (x1, y1), (x2, y2), color, line_width)
282
+
283
+ # Build label text
284
+ label_parts = []
285
+ if track_id is not None:
286
+ label_parts.append(f"TrackID: {track_id}")
287
+ if show_class:
288
+ label_parts.append(class_name)
289
+ if show_confidence:
290
+ label_parts.append(f"({confidence:.2f})")
291
+
292
+
293
+ if label_parts:
294
+ label = ' '.join(label_parts)
295
+ (text_width, text_height), baseline = cv2.getTextSize(
296
+ label, cv2.FONT_HERSHEY_SIMPLEX, font_scale, 1
297
+ )
298
+ # Clamp label position to image bounds
299
+ img_h, img_w = img.shape[:2]
300
+ # Default: draw above the box with proper padding for tall characters
301
+ padding = int(4 * font_scale) # Scale padding with font size
302
+ # Add extra height for tall characters like parentheses
303
+ extra_height = int(text_height * 0.3) # 30% extra for ascenders
304
+ label_top = y1 - text_height - baseline - padding - extra_height
305
+ label_bottom = y1 # Touch the bounding box directly
306
+ text_org_y = y1 - baseline - padding
307
+ # Gradually clamp label so it stays within image bounds
308
+ if label_top < 0:
309
+ shift = -label_top
310
+ label_top += shift
311
+ label_bottom += shift
312
+ text_org_y += shift
313
+ if label_bottom > img_h:
314
+ shift = label_bottom - img_h
315
+ label_top -= shift
316
+ label_bottom -= shift
317
+ text_org_y -= shift
318
+ # Clamp horizontally
319
+ label_left = x1
320
+ label_right = x1 + text_width
321
+
322
+ # If text overflows right edge, shift it left
323
+ if label_right > img_w:
324
+ shift = label_right - img_w
325
+ label_left -= shift
326
+ label_right -= shift
327
+
328
+ # Ensure left edge doesn't go below 0
329
+ label_left = max(label_left, 0)
330
+ label_right = min(label_left + text_width, img_w)
331
+ # Draw label background
332
+ cv2.rectangle(
333
+ img,
334
+ (label_left, label_top),
335
+ (label_right, label_bottom),
336
+ color,
337
+ -1 # Filled
338
+ )
339
+ # Draw label text
340
+ cv2.putText(
341
+ img,
342
+ label,
343
+ (label_left, text_org_y),
344
+ cv2.FONT_HERSHEY_SIMPLEX,
345
+ font_scale,
346
+ text_color,
347
+ text_thickness,
348
+ cv2.LINE_AA
349
+ )
350
+ except Exception as e:
351
+ import traceback
352
+ self.report_error(f"Failed to draw detection {i}: {str(e)}\n{traceback.format_exc()}")
353
+ continue
354
+
355
+ # Encode image back to same format as input using base node helper
356
+ encoded_image = self.encode_image(img, input_format)
357
+ if encoded_image is not None:
358
+ if not isinstance(msg.get(MessageKeys.PAYLOAD), dict):
359
+ msg[MessageKeys.PAYLOAD] = {}
360
+ msg[MessageKeys.PAYLOAD][MessageKeys.IMAGE.PATH] = encoded_image
361
+ else:
362
+ self.report_error("Failed to encode output image")
363
+ return
364
+
365
+ self.send(msg)
@@ -0,0 +1,2 @@
1
+ opencv-python>=4.5.0
2
+ numpy>=1.19.0
@@ -0,0 +1,94 @@
1
+ """
2
+ Error node - captures and displays error messages from any node in the workflow.
3
+ Automatically receives error events and displays them in the debug panel.
4
+ """
5
+
6
+ from typing import Any, Dict
7
+ from pynode.nodes.base_node import BaseNode, Info, MessageKeys
8
+
9
+ # Build info content
10
+ _info = Info()
11
+ _info.add_text("Captures and displays error messages from any node in the workflow.")
12
+ _info.add_text("Unlike debug nodes that need to be wired, error nodes automatically receive errors from all nodes.")
13
+ _info.add_header("Properties")
14
+ _info.add_bullet("Filter by Node:", "Only show errors from nodes matching this filter (leave empty for all errors)")
15
+ _info.add_header("Features")
16
+ _info.add_bullet("Auto-capture:", "Receives errors from all workflow nodes automatically")
17
+ _info.add_bullet("History:", "Keeps the last 100 errors")
18
+ _info.add_bullet("Filtering:", "Can filter errors by node name")
19
+
20
+
21
+ class ErrorNode(BaseNode):
22
+ """
23
+ Error node - displays error messages in the debug panel.
24
+ Unlike debug nodes that need to be wired, error nodes automatically
25
+ capture errors from all nodes in the workflow.
26
+ """
27
+ display_name = 'Error'
28
+ icon = '⚠️'
29
+ category = 'system'
30
+ color = '#FF6B6B'
31
+ border_color = '#E63946'
32
+ text_color = '#FFFFFF'
33
+ input_count = 0 # No input - errors come automatically
34
+ output_count = 0 # No output - errors are displayed only
35
+ info = str(_info)
36
+ hidden = True # Hide from palette; it's a system node
37
+
38
+ properties = [
39
+ {
40
+ 'name': 'filter',
41
+ 'label': 'Filter by Node',
42
+ 'type': 'text',
43
+ 'placeholder': 'Leave empty for all errors'
44
+ }
45
+ ]
46
+
47
+ def __init__(self, node_id=None, name="error"):
48
+ super().__init__(node_id, name)
49
+ self.configure({'filter': ''})
50
+ self.errors = []
51
+ self.max_errors = 100 # Keep last 100 errors
52
+ self.is_system_node = False # Can be set to True for the system error node
53
+
54
+ def on_input(self, msg: Dict[str, Any], input_index: int = 0):
55
+ """
56
+ This won't be called since input_count = 0.
57
+ Errors are captured via handle_error() method.
58
+ """
59
+ pass
60
+
61
+ def handle_error(self, source_node_id: str, source_node_name: str, error_msg: str):
62
+ """
63
+ Handle error messages from any node in the workflow.
64
+ This is called by the workflow engine when any node reports an error.
65
+ """
66
+ # Check filter
67
+ node_filter = self.config.get('filter', '').strip()
68
+ if node_filter and node_filter.lower() not in source_node_name.lower():
69
+ return # Skip this error
70
+
71
+ # Create error entry
72
+ import time
73
+ error_entry = {
74
+ 'timestamp': time.time(),
75
+ 'source_node_id': source_node_id,
76
+ 'source_node_name': source_node_name,
77
+ 'message': error_msg,
78
+ 'type': 'error'
79
+ }
80
+
81
+ # Add to errors list
82
+ self.errors.append(error_entry)
83
+
84
+ # Trim to max size
85
+ if len(self.errors) > self.max_errors:
86
+ self.errors = self.errors[-self.max_errors:]
87
+
88
+ def get_errors(self):
89
+ """Get all captured errors (returns a copy so clearing doesn't affect it)."""
90
+ return list(self.errors)
91
+
92
+ def clear_errors(self):
93
+ """Clear all captured errors."""
94
+ self.errors.clear()
@@ -0,0 +1 @@
1
+ # No external dependencies - uses only Python standard library