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,562 @@
1
+ """
2
+ Base Node class for the Python Node-RED-like system.
3
+ All custom nodes should inherit from this class.
4
+
5
+ ``Info``, ``MessageKeys``/``sort_msg_keys`` and the image helpers
6
+ (``process_image``, plus the functions behind ``BaseNode.decode_image`` /
7
+ ``BaseNode.encode_image``) live in ``pynode.nodes.info``,
8
+ ``pynode.nodes.messages`` and ``pynode.nodes.image_utils`` respectively, and
9
+ are re-exported here so existing imports keep working::
10
+
11
+ from pynode.nodes.base_node import BaseNode, Info, MessageKeys, \
12
+ process_image, sort_msg_keys
13
+ """
14
+
15
+ from time import time
16
+ import uuid
17
+ import queue
18
+ import threading
19
+ import copy
20
+ from typing import Dict, List, Any, Optional, Tuple
21
+
22
+ from pynode.nodes import image_utils
23
+ from pynode.nodes.image_utils import process_image # noqa: F401 (re-export)
24
+ from pynode.nodes.info import Info # noqa: F401 (re-export)
25
+ from pynode.nodes.messages import MessageKeys, sort_msg_keys
26
+
27
+ # Sentinel so create_message() can distinguish "payload not given" from an
28
+ # explicitly-passed payload=None (which must be included in the message).
29
+ _UNSET = object()
30
+
31
+
32
+ class BaseNode:
33
+ """
34
+ Base class for all nodes in the workflow system.
35
+ Messages follow Node-RED structure with 'payload' and 'topic' fields.
36
+
37
+ Subclasses can define a DEFAULT_CONFIG class attribute which will be
38
+ automatically applied during initialization. This eliminates the need
39
+ to manually call self.configure(self.DEFAULT_CONFIG) in __init__.
40
+ """
41
+
42
+ # Visual properties (can be overridden by subclasses)
43
+ display_name = 'Base Node' # Display name (without 'Node' suffix)
44
+ icon = '◆' # Icon character or emoji
45
+ category = 'custom'
46
+ color = '#2d2d30'
47
+ border_color = '#555'
48
+ text_color = '#d4d4d4'
49
+ input_count = 1 # Number of input ports (0 for input-only nodes)
50
+ output_count = 1 # Number of output ports (0 for output-only nodes)
51
+ hidden = False # If true, node is hidden from palette
52
+
53
+ # Info text displayed in the Information panel when node is selected
54
+ # Supports HTML for formatting
55
+ info = ''
56
+
57
+ # Default configuration - subclasses can override this
58
+ # Will be auto-applied during __init__
59
+ DEFAULT_CONFIG: Dict[str, Any] = {}
60
+
61
+ # Property schema for the properties panel
62
+ # Format: [{'name': 'propName', 'label': 'Display Label', 'type': 'text|textarea|select|button', 'options': [...], 'action': 'methodName'}]
63
+ properties = [
64
+ {
65
+ 'name': MessageKeys.DROP_MESSAGES,
66
+ 'label': 'Drop Messages When Busy',
67
+ 'type': 'checkbox',
68
+ 'default': True
69
+ }
70
+ ]
71
+
72
+ # Action methods that may be triggered from the UI via
73
+ # POST /api/nodes/<node_id>/<action>.
74
+ # Only method names listed here can be invoked through that endpoint;
75
+ # anything not declared (or starting with '_') returns 404.
76
+ # These are the names referenced by 'action' keys in `properties` button/
77
+ # toggle entries and in `ui_component_config`.
78
+ # Format: ['method_name', ...]
79
+ actions: List[str] = []
80
+
81
+ # API routes that this node type exposes.
82
+ # The server will register these as Flask routes at startup.
83
+ # Nodes do NOT need to import Flask - the server handles request/response.
84
+ # Format: [{'route': '/sub_path', 'methods': ['GET'], 'handler': 'method_name'}, ...]
85
+ # The server creates /api/nodes/<node_id>/<route> for each entry.
86
+ # Handler methods receive (request_data: dict) and return a result dict,
87
+ # or just return a dict for GET requests with no input.
88
+ # For file uploads, handler receives (file_storage, filename) parameters.
89
+ api_routes: List[Dict[str, Any]] = []
90
+
91
+ # SSE broadcast handlers for real-time UI updates.
92
+ # The SSE broadcast worker calls these methods and pushes results to clients.
93
+ # Format: [{'type': 'event_type', 'handler': 'method_name', 'throttle': None|seconds}, ...]
94
+ # 'type' is the SSE event type sent to the client.
95
+ # 'handler' is the method name to call on the node instance.
96
+ # 'throttle' (optional): minimum interval in seconds between broadcasts (None = every cycle).
97
+ sse_handlers: List[Dict[str, Any]] = []
98
+
99
+ def __init__(self, node_id: Optional[str] = None, name: str = ""):
100
+ """
101
+ Initialize a base node.
102
+
103
+ Args:
104
+ node_id: Unique identifier for the node. Auto-generated if not provided.
105
+ name: Human-readable name for the node.
106
+ """
107
+ self.id = node_id or str(uuid.uuid4())
108
+ self.name = name or self.__class__.__name__
109
+ self.type = self.__class__.__name__
110
+
111
+ # Connections: output_index -> [(target_node, target_input_index), ...]
112
+ self.outputs: Dict[int, List[tuple]] = {}
113
+
114
+ # Configuration for the node
115
+ self.config: Dict[str, Any] = {}
116
+
117
+ # Initialize drop_while_busy to default (will be overridden by configure if needed)
118
+ self.drop_while_busy = True
119
+ self.drop_count = 0
120
+
121
+ # Auto-apply DEFAULT_CONFIG if defined by subclass
122
+ if self.DEFAULT_CONFIG:
123
+ self.configure(self.DEFAULT_CONFIG)
124
+
125
+ # Node state
126
+ self.enabled = True
127
+
128
+ # Non-blocking message queue
129
+ self._message_queue = queue.Queue(maxsize=1000) # Limit queue size to prevent memory issues
130
+ self._worker_thread = None
131
+ self._stop_worker_flag = False
132
+ self._processing = False # True when actively processing a message
133
+
134
+ # Error handling
135
+ self._workflow_engine = None # Will be set by workflow engine
136
+
137
+ def set_workflow_engine(self, engine):
138
+ """Set reference to the workflow engine for error reporting."""
139
+ self._workflow_engine = engine
140
+
141
+ def report_error(self, error_msg: str):
142
+ """
143
+ Report an error to all ErrorNodes in the workflow.
144
+
145
+ Args:
146
+ error_msg: The error message to report
147
+ """
148
+ if self._workflow_engine:
149
+ self._workflow_engine.broadcast_error(self.id, self.name, error_msg)
150
+
151
+ def connect(self, target_node: 'BaseNode', output_index: int = 0, target_input_index: int = 0):
152
+ """
153
+ Connect this node's output to another node's input.
154
+
155
+ Args:
156
+ target_node: The node to connect to
157
+ output_index: Which output port to use (default 0)
158
+ target_input_index: Which input port on target (default 0)
159
+ """
160
+ if output_index not in self.outputs:
161
+ self.outputs[output_index] = []
162
+
163
+ self.outputs[output_index].append((target_node, target_input_index))
164
+
165
+ def disconnect(self, target_node: 'BaseNode', output_index: int = 0):
166
+ """
167
+ Disconnect this node from a target node.
168
+
169
+ Args:
170
+ target_node: The node to disconnect from
171
+ output_index: Which output port to disconnect
172
+ """
173
+ if output_index in self.outputs:
174
+ self.outputs[output_index] = [
175
+ (node, idx) for node, idx in self.outputs[output_index]
176
+ if node.id != target_node.id
177
+ ]
178
+
179
+ def create_message(self, payload: Any = _UNSET, topic: str = "", **kwargs) -> Dict[str, Any]:
180
+ """
181
+ Create a message in Node-RED format.
182
+
183
+ Args:
184
+ payload: The message payload (any type, optional). Omitted from
185
+ the message when not given; an explicitly-passed None IS
186
+ included (payload=None -> {'payload': None, ...}).
187
+ topic: Optional topic string
188
+ **kwargs: Additional message properties
189
+
190
+ Returns:
191
+ Message dictionary with _msgid, _timestamp, and optional payload/topic
192
+ """
193
+ msg = {
194
+ MessageKeys.MSG_ID: str(uuid.uuid4()),
195
+ MessageKeys.TIMESTAMP_ORIG: time()
196
+ }
197
+
198
+ # Only include payload if explicitly provided (even if None was passed
199
+ # explicitly). The _UNSET sentinel distinguishes create_message() from
200
+ # create_message(payload=None).
201
+ if payload is not _UNSET:
202
+ msg[MessageKeys.PAYLOAD] = payload
203
+
204
+ if topic:
205
+ msg[MessageKeys.TOPIC] = topic
206
+
207
+ msg.update(kwargs)
208
+ return msg
209
+
210
+ def send(self, msg: Dict[str, Any], output_index: int = 0):
211
+ """
212
+ Send a message to connected nodes (non-blocking).
213
+ Messages are queued and processed asynchronously.
214
+ Each recipient gets a deep copy to prevent cross-talk between branches
215
+ and to prevent downstream modifications from affecting the sender.
216
+
217
+ Args:
218
+ msg: Message dictionary (must have 'payload' and 'topic')
219
+ output_index: Which output port to send from (default 0)
220
+ """
221
+ if not self.enabled:
222
+ return
223
+
224
+ if output_index in self.outputs:
225
+ connections = self.outputs[output_index]
226
+
227
+ for target_node, target_input in connections:
228
+ if target_node.enabled:
229
+ # Check if target node prefers direct processing (no outputs = sink node)
230
+ if target_node.output_count == 0 and hasattr(target_node, 'on_input_direct'):
231
+ # Deep copy for direct processing to prevent cross-talk
232
+ msg_to_send = copy.deepcopy(msg)
233
+ msg_to_send[MessageKeys.TIMESTAMP_EMIT] = time()
234
+ msg_to_send[MessageKeys.AGE] = (
235
+ msg_to_send[MessageKeys.TIMESTAMP_EMIT]
236
+ - msg_to_send.get(MessageKeys.TIMESTAMP_ORIG, msg_to_send[MessageKeys.TIMESTAMP_EMIT])
237
+ )
238
+ msg_to_send[MessageKeys.QUEUE_LENGTH] = self._message_queue.qsize()
239
+ msg_to_send = sort_msg_keys(msg_to_send)
240
+
241
+ try:
242
+ target_node.on_input_direct(msg_to_send, target_input)
243
+ except Exception as e:
244
+ target_node.report_error(f"Error in direct processing: {e}")
245
+ else:
246
+ # Check if target wants to drop messages when busy (before expensive copy)
247
+ # Drop if node is processing OR has queued messages
248
+ if target_node.drop_while_busy and (target_node._processing or not target_node._message_queue.empty()):
249
+ # Drop message instead of queuing
250
+ target_node.drop_count += 1
251
+ continue
252
+
253
+ # Deep copy message for each recipient to prevent cross-talk
254
+ msg_to_send = copy.deepcopy(msg)
255
+ msg_to_send[MessageKeys.TIMESTAMP_EMIT] = time()
256
+ msg_to_send[MessageKeys.AGE] = (
257
+ msg_to_send[MessageKeys.TIMESTAMP_EMIT]
258
+ - msg_to_send.get(MessageKeys.TIMESTAMP_ORIG, msg_to_send[MessageKeys.TIMESTAMP_EMIT])
259
+ )
260
+
261
+ # Add this node's drop count to message for monitoring
262
+ # (how many messages THIS node dropped before sending this one)
263
+ msg_to_send[MessageKeys.DROP_COUNT] = self.drop_count
264
+ msg_to_send[MessageKeys.QUEUE_LENGTH] = self._message_queue.qsize()
265
+
266
+ msg_to_send = sort_msg_keys(msg_to_send)
267
+
268
+ # Queue message for target node (non-blocking)
269
+ try:
270
+ target_node._message_queue.put_nowait((msg_to_send, target_input))
271
+ except queue.Full:
272
+ # Queue full - drop message or handle overflow
273
+ target_node.report_error("Message queue full, dropping message")
274
+
275
+ def on_input(self, msg: Dict[str, Any], input_index: int = 0):
276
+ """
277
+ Called when this node receives a message.
278
+ Override this method in subclasses to implement node logic.
279
+
280
+ Args:
281
+ msg: Message dictionary with 'payload', 'topic', etc.
282
+ input_index: Which input port received the message
283
+ """
284
+ # Default behavior: pass through
285
+ self.send(msg)
286
+
287
+ def _start_worker(self):
288
+ """
289
+ Start the worker thread to process queued messages.
290
+ """
291
+ if self._worker_thread is None or not self._worker_thread.is_alive():
292
+ self._stop_worker_flag = False
293
+ self._worker_thread = threading.Thread(target=self._process_messages, daemon=True)
294
+ self._worker_thread.start()
295
+
296
+ def _process_messages(self):
297
+ """
298
+ Worker thread that processes messages from the queue.
299
+ Uses adaptive timeout to balance responsiveness with CPU usage.
300
+ """
301
+ idle_count = 0
302
+ while not self._stop_worker_flag:
303
+ try:
304
+ # Adaptive timeout: faster when active, slower when idle
305
+ timeout = 0.01 if idle_count < 10 else 0.1
306
+ msg, input_index = self._message_queue.get(timeout=timeout)
307
+ idle_count = 0 # Reset on successful message
308
+
309
+ # Process the message
310
+ self._processing = True
311
+ try:
312
+ self.on_input(msg, input_index)
313
+ except Exception as e:
314
+ self.report_error(f"Error processing message: {e}")
315
+ finally:
316
+ self._processing = False
317
+ self._message_queue.task_done()
318
+
319
+ except queue.Empty:
320
+ # No message available, increase idle count
321
+ idle_count += 1
322
+ continue
323
+ except Exception as e:
324
+ self.report_error(f"Worker thread error: {e}")
325
+
326
+ def on_start(self):
327
+ """
328
+ Called when the node is started/deployed.
329
+ Override to implement initialization logic.
330
+ """
331
+ # Start message processing worker
332
+ self._start_worker()
333
+
334
+ def on_stop(self):
335
+ """
336
+ Called when the node is stopped.
337
+ Override to implement cleanup logic.
338
+ """
339
+ # Stop message processing worker
340
+ self._stop_worker_flag = True
341
+ if self._worker_thread and self._worker_thread.is_alive():
342
+ self._worker_thread.join(timeout=2.0)
343
+
344
+ def configure(self, config: Dict[str, Any]):
345
+ """
346
+ Configure the node with settings.
347
+
348
+ Args:
349
+ config: Configuration dictionary
350
+ """
351
+ self.config.update(config)
352
+ # Update drop_while_busy flag from config
353
+ # Handle both boolean and string values for compatibility
354
+ self.drop_while_busy = self.get_config_bool(MessageKeys.DROP_MESSAGES, True)
355
+
356
+ def get_config_bool(self, key: str, default: bool = False) -> bool:
357
+ """
358
+ Get a boolean configuration value, with support for string representations of booleans.
359
+
360
+ Args:
361
+ key: The configuration key
362
+ default: Default value if the key is not present
363
+
364
+ Returns:
365
+ Boolean value of the configuration setting
366
+ """
367
+ val = self.config.get(key, default)
368
+ return val.lower() in ('true', '1', 'yes') if isinstance(val, str) else bool(val)
369
+
370
+ def get_config_int(self, key: str, default: int = 0) -> int:
371
+ """
372
+ Get an integer configuration value.
373
+
374
+ Args:
375
+ key: The configuration key
376
+ default: Default value if the key is not present
377
+
378
+ Returns:
379
+ Integer value of the configuration setting
380
+ """
381
+ return int(self.config.get(key, default))
382
+
383
+ def get_config_float(self, key: str, default: float = 0.0) -> float:
384
+ """
385
+ Get a float configuration value.
386
+
387
+ Args:
388
+ key: The configuration key
389
+ default: Default value if the key is not present
390
+
391
+ Returns:
392
+ Float value of the configuration setting
393
+ """
394
+ return float(self.config.get(key, default))
395
+
396
+ def _get_nested_value(self, obj: Dict, path: str) -> Any:
397
+ """
398
+ Get a value from a nested path using dot notation.
399
+ Supports array indexing like 'items[0]' and optional 'msg.' prefix.
400
+
401
+ Args:
402
+ obj: The object to get the value from (usually a message dict)
403
+ path: Dot-separated path string (e.g., 'payload.data', 'items[0].name')
404
+
405
+ Returns:
406
+ The value at the path, or None if not found
407
+
408
+ Examples:
409
+ self._get_nested_value(msg, 'payload') # msg['payload']
410
+ self._get_nested_value(msg, 'payload.data') # msg['payload']['data']
411
+ self._get_nested_value(msg, 'items[0].name') # msg['items'][0]['name']
412
+ self._get_nested_value(msg, 'msg.payload') # msg['payload'] (msg. prefix stripped)
413
+ """
414
+ import re
415
+
416
+ if not path:
417
+ return None
418
+
419
+ # Handle msg. prefix for compatibility
420
+ if path.startswith('msg.'):
421
+ path = path[4:]
422
+
423
+ parts = path.split('.')
424
+ current = obj
425
+
426
+ for part in parts:
427
+ if current is None:
428
+ return None
429
+
430
+ # Handle array indexing like 'items[0]'
431
+ match = re.match(r'(\w+)\[(\d+)\]', part)
432
+ if match:
433
+ key, index = match.groups()
434
+ if isinstance(current, dict) and key in current:
435
+ current = current[key]
436
+ if isinstance(current, (list, tuple)) and int(index) < len(current):
437
+ current = current[int(index)]
438
+ else:
439
+ return None
440
+ else:
441
+ return None
442
+ elif isinstance(current, dict) and part in current:
443
+ current = current[part]
444
+ else:
445
+ return None
446
+
447
+ return current
448
+
449
+ def _set_nested_value(self, obj: Dict, path: str, value: Any) -> bool:
450
+ """
451
+ Set a value at a nested path like 'payload.image.width'
452
+ Supports array indexing and msg. prefix handling.
453
+
454
+ Args:
455
+ obj: The object to set the value in
456
+ path: Dot-separated path string
457
+ value: The value to set
458
+
459
+ Returns:
460
+ True if successful, False otherwise
461
+ """
462
+ import re
463
+
464
+ # Handle msg. prefix
465
+ if path.startswith('msg.'):
466
+ path = path[4:]
467
+
468
+ parts = path.split('.')
469
+ current = obj
470
+
471
+ # Navigate to parent of target
472
+ for part in parts[:-1]:
473
+ # Handle array indexing
474
+ match = re.match(r'(\w+)\[(\d+)\]', part)
475
+ if match:
476
+ key, index = match.groups()
477
+ if key not in current:
478
+ current[key] = []
479
+ current = current[key]
480
+ index = int(index)
481
+ while len(current) <= index:
482
+ current.append({})
483
+ current = current[index]
484
+ else:
485
+ if part not in current:
486
+ current[part] = {}
487
+ current = current[part]
488
+
489
+ # Set the final value
490
+ final_key = parts[-1]
491
+ match = re.match(r'(\w+)\[(\d+)\]', final_key)
492
+ if match:
493
+ key, index = match.groups()
494
+ if key not in current:
495
+ current[key] = []
496
+ while len(current[key]) <= int(index):
497
+ current[key].append(None)
498
+ current[key][int(index)] = value
499
+ else:
500
+ current[final_key] = value
501
+
502
+ return True
503
+
504
+ # Image processing helper methods (thin delegates; the implementations
505
+ # live in pynode.nodes.image_utils and report failures through this
506
+ # node's report_error).
507
+ def decode_image(self, payload: Any) -> Tuple[Any, Optional[str]]:
508
+ """
509
+ Decode image from various formats into a numpy array.
510
+ Returns (image, format_type) tuple where format_type indicates the input format.
511
+
512
+ Supported formats:
513
+ - Direct numpy array
514
+ - Dict with 'format', 'encoding', 'data' (camera node format)
515
+ - Direct base64 string
516
+
517
+ Args:
518
+ payload: Image payload in any supported format
519
+
520
+ Returns:
521
+ Tuple of (image as numpy array or None, format_identifier string or None)
522
+ """
523
+ return image_utils.decode_image(payload, report_error=self.report_error)
524
+
525
+ def encode_image(self, image: Any, format_type: str) -> Any:
526
+ """
527
+ Encode numpy array image back to the original format.
528
+
529
+ Args:
530
+ image: Numpy array image
531
+ format_type: Format identifier from decode_image()
532
+
533
+ Returns:
534
+ Encoded image in the specified format, or None on error
535
+ """
536
+ return image_utils.encode_image(image, format_type,
537
+ report_error=self.report_error)
538
+
539
+ def to_dict(self) -> Dict[str, Any]:
540
+ """
541
+ Serialize node to dictionary for API/storage.
542
+
543
+ Returns:
544
+ Dictionary representation of the node
545
+ """
546
+ return {
547
+ 'id': self.id,
548
+ 'type': self.type,
549
+ 'name': self.name,
550
+ # Shallow copy so callers can't mutate the node's config in place
551
+ 'config': dict(self.config),
552
+ 'enabled': self.enabled,
553
+ 'inputCount': self.input_count,
554
+ 'outputCount': self.output_count,
555
+ 'outputs': {
556
+ str(idx): [(node.id, target_idx) for node, target_idx in connections]
557
+ for idx, connections in self.outputs.items()
558
+ }
559
+ }
560
+
561
+ def __repr__(self):
562
+ return f"<{self.type}(id={self.id}, name={self.name})>"