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,255 @@
1
+ """
2
+ Change node - modifies message properties.
3
+ Similar to Node-RED's change node.
4
+ """
5
+
6
+ import json
7
+ import re
8
+ from typing import Any, Dict
9
+ from pynode.nodes.base_node import BaseNode, Info, MessageKeys
10
+
11
+ _info = Info()
12
+ _info.add_text("Modifies message properties using configurable rules. Similar to Node-RED's change node.")
13
+ _info.add_header("Input")
14
+ _info.add_bullets(
15
+ ("msg:", "Any message to be modified according to the configured rules."),
16
+ )
17
+ _info.add_header("Output")
18
+ _info.add_bullets(
19
+ ("msg:", "The modified message after all rules have been applied."),
20
+ )
21
+ _info.add_header("Operations")
22
+ _info.add_bullets(
23
+ ("Set:", "Set a property to a value or copy from another property."),
24
+ ("Change:", "Search and replace text within a property."),
25
+ ("Delete:", "Remove a property from the message."),
26
+ ("Move:", "Move a property to a different location."),
27
+ )
28
+
29
+
30
+ class ChangeNode(BaseNode):
31
+ """
32
+ Change node - modifies message properties.
33
+ Similar to Node-RED's change node.
34
+
35
+ Supports operations:
36
+ - set: Set a property to a value or another property's value
37
+ - change: Search and replace within a property
38
+ - delete: Remove a property
39
+ - move: Move a property to another location
40
+ """
41
+ info = str(_info)
42
+ display_name = 'Change'
43
+ icon = '✎'
44
+ category = 'function'
45
+ color = '#E6E0F8'
46
+ border_color = '#9F93C6'
47
+ text_color = '#000000'
48
+ input_count = 1
49
+ output_count = 1
50
+
51
+ # Custom UI component for rule editing
52
+ ui_component = 'change-rules-editor'
53
+ ui_component_config = {
54
+ 'operations': ['set', 'change', 'move', 'delete'],
55
+ 'value_types': [
56
+ {'value': 'str', 'label': 'String'},
57
+ {'value': 'num', 'label': 'Number'},
58
+ {'value': 'bool', 'label': 'Boolean'},
59
+ {'value': 'json', 'label': 'JSON'},
60
+ {'value': 'msg', 'label': 'msg.'},
61
+ {'value': 'flow', 'label': 'flow.'},
62
+ {'value': 'global', 'label': 'global.'}
63
+ ]
64
+ }
65
+
66
+ DEFAULT_CONFIG = {
67
+ 'rules': []
68
+ }
69
+
70
+ properties = [
71
+ {
72
+ 'name': 'rules',
73
+ 'label': 'Rules',
74
+ 'type': 'changeRules',
75
+ 'default': [],
76
+ 'help': 'Define rules to modify message properties'
77
+ }
78
+ ]
79
+
80
+ def __init__(self, node_id=None, name="change"):
81
+ super().__init__(node_id, name)
82
+
83
+ def _delete_nested_value(self, obj: Dict, path: str) -> bool:
84
+ """
85
+ Delete a value at a nested path
86
+
87
+ Args:
88
+ obj: The object to delete from
89
+ path: Dot-separated path string
90
+
91
+ Returns:
92
+ True if deleted, False otherwise
93
+ """
94
+ # Handle msg. prefix
95
+ if path.startswith('msg.'):
96
+ path = path[4:]
97
+
98
+ parts = path.split('.')
99
+ current = obj
100
+
101
+ # Navigate to parent of target
102
+ for part in parts[:-1]:
103
+ match = re.match(r'(\w+)\[(\d+)\]', part)
104
+ if match:
105
+ key, index = match.groups()
106
+ if isinstance(current, dict) and key in current:
107
+ current = current[key]
108
+ if isinstance(current, (list, tuple)) and int(index) < len(current):
109
+ current = current[int(index)]
110
+ else:
111
+ return False
112
+ else:
113
+ return False
114
+ elif isinstance(current, dict) and part in current:
115
+ current = current[part]
116
+ else:
117
+ return False
118
+
119
+ # Delete the final key
120
+ final_key = parts[-1]
121
+ match = re.match(r'(\w+)\[(\d+)\]', final_key)
122
+ if match:
123
+ key, index = match.groups()
124
+ if isinstance(current, dict) and key in current:
125
+ if isinstance(current[key], list) and int(index) < len(current[key]):
126
+ del current[key][int(index)]
127
+ return True
128
+ elif isinstance(current, dict) and final_key in current:
129
+ del current[final_key]
130
+ return True
131
+
132
+ return False
133
+
134
+ def _resolve_value(self, msg: Dict, value: Any, value_type: str) -> Any:
135
+ """
136
+ Resolve a value based on its type.
137
+
138
+ Args:
139
+ msg: The message object
140
+ value: The value or path
141
+ value_type: Type of the value ('str', 'num', 'bool', 'json', 'msg', etc.)
142
+
143
+ Returns:
144
+ The resolved value
145
+ """
146
+ if value_type == 'msg':
147
+ # Get value from message path
148
+ return self._get_nested_value(msg, value)
149
+ elif value_type == 'num':
150
+ try:
151
+ return float(value) if '.' in str(value) else int(value)
152
+ except (ValueError, TypeError):
153
+ return 0
154
+ elif value_type == 'bool':
155
+ if isinstance(value, bool):
156
+ return value
157
+ return str(value).lower() in ('true', '1', 'yes', 'on')
158
+ elif value_type == 'json':
159
+ if isinstance(value, str):
160
+ try:
161
+ return json.loads(value)
162
+ except json.JSONDecodeError:
163
+ return value
164
+ return value
165
+ elif value_type == 'str':
166
+ return str(value) if value is not None else ''
167
+ else:
168
+ # Default: return as-is
169
+ return value
170
+
171
+ def on_input(self, msg: Dict[str, Any], input_index: int = 0):
172
+ """
173
+ Apply change rules to the message.
174
+ """
175
+ rules = self.config.get('rules', [])
176
+
177
+ # Handle rules as JSON string (from UI)
178
+ if isinstance(rules, str):
179
+ try:
180
+ rules = json.loads(rules)
181
+ except json.JSONDecodeError:
182
+ rules = []
183
+
184
+ for rule in rules:
185
+ if not isinstance(rule, dict):
186
+ continue
187
+
188
+ rule_type = rule.get('type', rule.get('t', 'set'))
189
+ property_path = rule.get('path', rule.get('p', rule.get('property', f'msg.{MessageKeys.PAYLOAD}')))
190
+
191
+ try:
192
+ if rule_type == 'set':
193
+ # Set property to a value
194
+ value = rule.get('value', rule.get('to', ''))
195
+ value_type = rule.get('valueType', rule.get('tot', 'str'))
196
+
197
+ # Handle path value type (read from another msg property)
198
+ if value_type == 'path':
199
+ resolved_value = self._get_nested_value(msg, 'msg.' + str(value) if not str(value).startswith('msg.') else value)
200
+ elif value_type == 'date':
201
+ import time
202
+ resolved_value = int(time.time() * 1000)
203
+ elif value_type == 'env':
204
+ import os
205
+ resolved_value = os.environ.get(str(value), '')
206
+ else:
207
+ resolved_value = self._resolve_value(msg, value, value_type)
208
+
209
+ self._set_nested_value(msg, property_path, resolved_value)
210
+
211
+ elif rule_type == 'change':
212
+ # Search and replace within a property
213
+ search = rule.get('search', rule.get('from', ''))
214
+ search_type = rule.get('searchType', rule.get('fromt', 'str'))
215
+ replace = rule.get('replace', rule.get('to', ''))
216
+ replace_type = rule.get('replaceType', rule.get('tot', 'str'))
217
+
218
+ current_value = self._get_nested_value(msg, property_path)
219
+
220
+ # Resolve replace value
221
+ if replace_type == 'path':
222
+ replace_value = self._get_nested_value(msg, 'msg.' + str(replace) if not str(replace).startswith('msg.') else replace)
223
+ else:
224
+ replace_value = str(replace)
225
+
226
+ if current_value is not None:
227
+ if isinstance(current_value, str):
228
+ if search_type == 'regex':
229
+ try:
230
+ new_value = re.sub(search, str(replace_value) if replace_value else '', current_value)
231
+ self._set_nested_value(msg, property_path, new_value)
232
+ except re.error as e:
233
+ self.report_error(f"Invalid regex pattern: {search}")
234
+ else:
235
+ new_value = current_value.replace(str(search), str(replace_value) if replace_value else '')
236
+ self._set_nested_value(msg, property_path, new_value)
237
+
238
+ elif rule_type == 'delete':
239
+ # Delete a property
240
+ self._delete_nested_value(msg, property_path)
241
+
242
+ elif rule_type == 'move':
243
+ # Move property to another location
244
+ to_path = rule.get('toPath', rule.get('to', rule.get('toProperty', '')))
245
+
246
+ if to_path:
247
+ current_value = self._get_nested_value(msg, property_path)
248
+ if current_value is not None:
249
+ self._set_nested_value(msg, to_path, current_value)
250
+ self._delete_nested_value(msg, property_path)
251
+
252
+ except Exception as e:
253
+ self.report_error(f"Error applying rule {rule_type} on {property_path}: {str(e)}")
254
+
255
+ self.send(msg)
@@ -0,0 +1 @@
1
+ # No external dependencies - uses only Python standard library
@@ -0,0 +1,4 @@
1
+ from .confidence_filter_node import ConfidenceFilterNode
2
+
3
+ __all__ = ['ConfidenceFilterNode']
4
+
@@ -0,0 +1,208 @@
1
+ """
2
+ Confidence Filter Node - filters messages based on detection confidence.
3
+ Passes through messages that contain detections meeting the confidence threshold.
4
+ """
5
+
6
+ from typing import Any, Dict, List
7
+ from pynode.nodes.base_node import BaseNode, Info, MessageKeys
8
+
9
+ _info = Info()
10
+ _info.add_text("Splits detection arrays based on confidence scores, sending high-confidence detections to output 0 and low-confidence to output 1.")
11
+ _info.add_header("Input")
12
+ _info.add_bullets(
13
+ (f"{MessageKeys.PAYLOAD}.detections:", "Array of detection objects, each containing a confidence score."),
14
+ )
15
+ _info.add_header("Outputs")
16
+ _info.add_bullets(
17
+ ("Output 0:", "Message with detections having confidence >= threshold."),
18
+ ("Output 1:", "Message with detections having confidence < threshold."),
19
+ )
20
+ _info.add_header("Properties")
21
+ _info.add_bullets(
22
+ ("Threshold:", "Minimum confidence score (0-1) to separate detections."),
23
+ ("Threshold Source:", "Use configured value or read from msg.threshold."),
24
+ ("Detections Path:", "Path to the detections array in the message."),
25
+ ("Confidence Field:", "Name of the confidence field within each detection."),
26
+ )
27
+
28
+
29
+ class ConfidenceFilterNode(BaseNode):
30
+ """
31
+ Confidence Filter node - splits detection arrays based on confidence scores.
32
+ Always outputs to both ports:
33
+ Output 0: Detections with confidence >= threshold
34
+ Output 1: Detections with confidence < threshold
35
+ """
36
+ info = str(_info)
37
+ display_name = 'Confidence Filter'
38
+ icon = '📊'
39
+ category = 'vision'
40
+ color = '#C7E9C0'
41
+ border_color = '#74C476'
42
+ text_color = '#000000'
43
+ input_count = 1
44
+ output_count = 2 # Output 0: >= threshold, Output 1: < threshold
45
+
46
+ DEFAULT_CONFIG = {
47
+ 'threshold_source': 'manual', # or 'msg'
48
+ 'threshold': 0.5,
49
+ 'detections_path': f'{MessageKeys.PAYLOAD}.{MessageKeys.CV.DETECTIONS}',
50
+ 'confidence_field': MessageKeys.CV.CONFIDENCE
51
+ }
52
+
53
+ properties = [
54
+ {
55
+ 'name': 'threshold_source',
56
+ 'label': 'Threshold Source',
57
+ 'type': 'select',
58
+ 'options': [
59
+ {'value': 'manual', 'label': 'Use manual value'},
60
+ {'value': 'msg', 'label': 'Use msg.threshold'}
61
+ ],
62
+ 'default': DEFAULT_CONFIG['threshold_source'],
63
+ 'help': 'Where to read the threshold value from'
64
+ },
65
+ {
66
+ 'name': 'threshold',
67
+ 'label': 'Confidence Threshold',
68
+ 'type': 'number',
69
+ 'default': DEFAULT_CONFIG['threshold'],
70
+ 'min': 0,
71
+ 'max': 1,
72
+ 'step': 0.01,
73
+ 'help': 'Minimum confidence score (0-1) to pass to output 0',
74
+ 'showIf': {'threshold_source': 'manual'}
75
+ },
76
+ {
77
+ 'name': 'detections_path',
78
+ 'label': 'Detections Path',
79
+ 'type': 'text',
80
+ 'default': DEFAULT_CONFIG['detections_path'],
81
+ 'help': f'Path to detections array in message (e.g., "{MessageKeys.PAYLOAD}.{MessageKeys.CV.DETECTIONS}")'
82
+ },
83
+ {
84
+ 'name': 'confidence_field',
85
+ 'label': 'Confidence Field',
86
+ 'type': 'text',
87
+ 'default': DEFAULT_CONFIG['confidence_field'],
88
+ 'help': 'Name of the confidence field in each detection object'
89
+ }
90
+ ]
91
+
92
+ def __init__(self, node_id=None, name="confidence filter"):
93
+ super().__init__(node_id, name)
94
+
95
+ def on_input(self, msg: Dict[str, Any], input_index: int = 0):
96
+ """
97
+ Split detections based on confidence threshold.
98
+ Always outputs to both ports:
99
+ Output 0: Detections with confidence >= threshold
100
+ Output 1: Detections with confidence < threshold
101
+ """
102
+ # Get threshold from msg or config based on setting
103
+ threshold_source = self.config.get('threshold_source', 'manual')
104
+ if threshold_source == MessageKeys.MSG and MessageKeys.CV.THRESHOLD in msg:
105
+ try:
106
+ threshold = float(msg[MessageKeys.CV.THRESHOLD])
107
+ except (TypeError, ValueError):
108
+ threshold = self.get_config_float(MessageKeys.CV.THRESHOLD, 0.5)
109
+ else:
110
+ threshold = self.get_config_float(MessageKeys.CV.THRESHOLD, 0.5)
111
+
112
+ detections_path = self.config.get('detections_path', f'{MessageKeys.PAYLOAD}.{MessageKeys.CV.DETECTIONS}')
113
+ confidence_field = self.config.get('confidence_field', MessageKeys.CV.CONFIDENCE)
114
+
115
+ # Get detections array from message
116
+ detections = self._get_nested_value(msg, detections_path)
117
+
118
+ if not isinstance(detections, list):
119
+ # No detections array found, send empty arrays to both outputs
120
+ # Use shallow copies - send() will deep copy for downstream isolation
121
+ msg[MessageKeys.CV.THRESHOLD] = threshold
122
+ high_msg = msg.copy()
123
+ low_msg = msg.copy()
124
+
125
+ # Copy payload dict since shallow copy shares nested objects
126
+ payload = msg.get(MessageKeys.PAYLOAD, {})
127
+ if isinstance(payload, dict):
128
+ high_msg[MessageKeys.PAYLOAD] = payload.copy()
129
+ low_msg[MessageKeys.PAYLOAD] = payload.copy()
130
+
131
+ # Set empty detection arrays
132
+ high_msg[MessageKeys.PAYLOAD][MessageKeys.CV.DETECTIONS] = []
133
+ low_msg[MessageKeys.PAYLOAD][MessageKeys.CV.DETECTIONS] = []
134
+
135
+ # Update detection counts if they exist
136
+ if MessageKeys.CV.DETECTION_COUNT in msg:
137
+ high_msg[MessageKeys.CV.DETECTION_COUNT] = 0
138
+ low_msg[MessageKeys.CV.DETECTION_COUNT] = 0
139
+
140
+ elif MessageKeys.PAYLOAD in msg and isinstance(msg[MessageKeys.PAYLOAD], dict) and MessageKeys.CV.DETECTION_COUNT in msg[MessageKeys.PAYLOAD]:
141
+ high_msg[MessageKeys.PAYLOAD][MessageKeys.CV.DETECTION_COUNT] = 0
142
+ low_msg[MessageKeys.PAYLOAD][MessageKeys.CV.DETECTION_COUNT] = 0
143
+
144
+ else:
145
+ high_msg[MessageKeys.CV.DETECTION_COUNT] = 0
146
+ low_msg[MessageKeys.CV.DETECTION_COUNT] = 0
147
+
148
+ self.send(high_msg, 0)
149
+ self.send(low_msg, 1)
150
+ return
151
+
152
+ # Split detections based on confidence
153
+ high_confidence_detections = []
154
+ low_confidence_detections = []
155
+
156
+ for detection in detections:
157
+ if not isinstance(detection, dict):
158
+ # Invalid detection, add to low confidence
159
+ low_confidence_detections.append(detection)
160
+ continue
161
+
162
+ confidence = detection.get(confidence_field)
163
+ if confidence is None:
164
+ # No confidence field, add to low confidence
165
+ low_confidence_detections.append(detection)
166
+ continue
167
+
168
+ try:
169
+ confidence = float(confidence)
170
+ if confidence >= threshold:
171
+ high_confidence_detections.append(detection)
172
+ else:
173
+ low_confidence_detections.append(detection)
174
+ except (TypeError, ValueError):
175
+ # Invalid confidence value, add to low confidence
176
+ low_confidence_detections.append(detection)
177
+
178
+ # Create output messages - use shallow copies since send() deep copies
179
+ msg[MessageKeys.CV.THRESHOLD] = threshold
180
+ high_msg = msg.copy()
181
+ low_msg = msg.copy()
182
+
183
+ # Copy payload dict since shallow copy shares nested objects
184
+ payload = msg.get(MessageKeys.PAYLOAD, {})
185
+ if isinstance(payload, dict):
186
+ high_msg[MessageKeys.PAYLOAD] = payload.copy()
187
+ low_msg[MessageKeys.PAYLOAD] = payload.copy()
188
+
189
+ # Set filtered detection arrays directly on payload
190
+ high_msg[MessageKeys.PAYLOAD][MessageKeys.CV.DETECTIONS] = high_confidence_detections
191
+ low_msg[MessageKeys.PAYLOAD][MessageKeys.CV.DETECTIONS] = low_confidence_detections
192
+
193
+ # Update detection counts if they exist
194
+ if MessageKeys.CV.DETECTION_COUNT in msg:
195
+ high_msg[MessageKeys.CV.DETECTION_COUNT] = len(high_confidence_detections)
196
+ low_msg[MessageKeys.CV.DETECTION_COUNT] = len(low_confidence_detections)
197
+
198
+ elif MessageKeys.PAYLOAD in msg and isinstance(msg[MessageKeys.PAYLOAD], dict) and MessageKeys.CV.DETECTION_COUNT in msg[MessageKeys.PAYLOAD]:
199
+ high_msg[MessageKeys.PAYLOAD][MessageKeys.CV.DETECTION_COUNT] = len(high_confidence_detections)
200
+ low_msg[MessageKeys.PAYLOAD][MessageKeys.CV.DETECTION_COUNT] = len(low_confidence_detections)
201
+
202
+ else:
203
+ high_msg[MessageKeys.CV.DETECTION_COUNT] = len(high_confidence_detections)
204
+ low_msg[MessageKeys.CV.DETECTION_COUNT] = len(low_confidence_detections)
205
+
206
+ # Always send to both outputs
207
+ self.send(high_msg, 0) # High confidence detections
208
+ self.send(low_msg, 1) # Low confidence detections
@@ -0,0 +1 @@
1
+ # No external dependencies - uses only Python standard library
@@ -0,0 +1,3 @@
1
+ from .counter_node import CounterNode
2
+
3
+ __all__ = ['CounterNode']
@@ -0,0 +1,191 @@
1
+ """
2
+ Counter Node - counts messages and displays the count with a reset button.
3
+ Demonstrates the use of UI components (button + rate-display).
4
+ """
5
+
6
+ from typing import Any, Dict
7
+ from pynode.nodes.base_node import BaseNode, Info, MessageKeys
8
+
9
+ _info = Info()
10
+ _info.add_text("Counts incoming messages and outputs the running total. Includes a reset button to restart the count.")
11
+ _info.add_header("Input")
12
+ _info.add_bullets(
13
+ ("msg:", "Any message triggers the counter increment."),
14
+ )
15
+ _info.add_header("Output")
16
+ _info.add_bullets(
17
+ ("payload.count:", "Current count value."),
18
+ ("payload.original_payload:", "The original payload from the input message."),
19
+ ("payload.display:", "Formatted count for display."),
20
+ )
21
+ _info.add_header("Properties")
22
+ _info.add_bullets(
23
+ ("Initial Value:", "Starting count value."),
24
+ ("Increment By:", "Amount to add per message."),
25
+ )
26
+ _info.add_header("UI")
27
+ _info.add_text("Click the reset button on the node to reset the counter to initial value.")
28
+
29
+
30
+ class CounterNode(BaseNode):
31
+ """
32
+ Counter node - counts incoming messages and displays the total.
33
+ Can be reset via button click.
34
+ """
35
+ info = str(_info)
36
+ display_name = 'Counter'
37
+ icon = '🔢'
38
+ category = 'node probes'
39
+ color = '#E2D96E'
40
+ border_color = '#B8AF4A'
41
+ text_color = '#000000'
42
+ input_count = 1
43
+ output_count = 1
44
+
45
+ # Counter display UI component with reset button
46
+ ui_component = 'counter-display'
47
+ ui_component_config = {
48
+ 'format': '{value}',
49
+ 'action': 'reset_counter',
50
+ 'tooltip': 'Reset Count'
51
+ }
52
+
53
+ # UI-triggerable actions (see BaseNode.actions)
54
+ actions = ['reset_counter']
55
+
56
+ # get_count_sse does its own change-detection (returns None when the count
57
+ # is unchanged), so a low throttle just bounds the cheap comparison rate
58
+ # while letting visible updates through almost immediately.
59
+ sse_handlers = [
60
+ {'type': 'counter', 'handler': 'get_count_sse', 'throttle': 0.1},
61
+ ]
62
+
63
+ DEFAULT_CONFIG = {
64
+ 'initial_value': '0',
65
+ 'increment': '1',
66
+ 'retain_payload': 'false',
67
+ MessageKeys.DROP_MESSAGES: 'false'
68
+ }
69
+
70
+ properties = [
71
+ {
72
+ 'name': MessageKeys.DROP_MESSAGES,
73
+ 'label': 'Drop Messages When Busy',
74
+ 'type': 'checkbox',
75
+ 'default': False
76
+ },
77
+ {
78
+ 'name': 'initial_value',
79
+ 'label': 'Initial Value',
80
+ 'type': 'text',
81
+ 'default': DEFAULT_CONFIG['initial_value'],
82
+ 'help': 'Starting count value'
83
+ },
84
+ {
85
+ 'name': 'increment',
86
+ 'label': 'Increment By',
87
+ 'type': 'text',
88
+ 'default': DEFAULT_CONFIG['increment'],
89
+ 'help': 'Amount to increment per message'
90
+ },
91
+ {
92
+ 'name': 'retain_payload',
93
+ 'label': 'Retain Original Payload',
94
+ 'type': 'select',
95
+ 'options': [
96
+ {'value': 'false', 'label': 'No - Replace payload with count info'},
97
+ {'value': 'true', 'label': 'Yes - Merge count info into payload'}
98
+ ],
99
+ 'default': DEFAULT_CONFIG['retain_payload'],
100
+ 'help': 'Whether to keep original payload data or replace it'
101
+ }
102
+ ]
103
+
104
+ def __init__(self, node_id=None, name="counter"):
105
+ super().__init__(node_id, name)
106
+ self.count = 0
107
+ # Last value emitted via SSE; None means nothing emitted yet so the
108
+ # first poll always broadcasts the current count.
109
+ self._last_sse_count = None
110
+ self._reset_to_initial()
111
+
112
+ def _reset_to_initial(self):
113
+ """Reset counter to initial value from config."""
114
+ self.count = self.get_config_int('initial_value', 0)
115
+
116
+ def on_input(self, msg: Dict[str, Any], input_index: int = 0):
117
+ """
118
+ Increment counter and pass through with count information.
119
+ """
120
+ # Get increment value
121
+ increment = self.get_config_int('increment', 1)
122
+
123
+ # Increment counter
124
+ self.count += increment
125
+
126
+ # Build count info
127
+ count_info = {
128
+ 'count': self.count,
129
+ 'display': self.get_count_display()
130
+ }
131
+
132
+ # Check if we should retain original payload
133
+ if self.get_config_bool('retain_payload', False):
134
+ original_payload = msg.get(MessageKeys.PAYLOAD, {})
135
+ if isinstance(original_payload, dict):
136
+ new_payload = original_payload.copy()
137
+ new_payload.update(count_info)
138
+ else:
139
+ new_payload = {
140
+ 'original_data': original_payload,
141
+ **count_info
142
+ }
143
+ else:
144
+ new_payload = count_info
145
+
146
+ msg[MessageKeys.PAYLOAD] = new_payload
147
+ msg[MessageKeys.TOPIC] = msg.get(MessageKeys.TOPIC, 'counter')
148
+
149
+ self.send(msg)
150
+
151
+ def get_count(self) -> int:
152
+ """Get the current count value."""
153
+ return self.count
154
+
155
+ def get_count_display(self) -> str:
156
+ """Get formatted count string for display."""
157
+ return str(self.count)
158
+
159
+ def reset_counter(self):
160
+ """
161
+ Action triggered by the reset button.
162
+ Resets counter to initial value and sends notification.
163
+ """
164
+ self._reset_to_initial()
165
+
166
+ # Send reset notification
167
+ msg = self.create_message(
168
+ payload={
169
+ 'count': self.count,
170
+ 'action': 'reset',
171
+ 'display': self.get_count_display()
172
+ },
173
+ topic='counter/reset'
174
+ )
175
+ self.send(msg)
176
+
177
+ def get_count_sse(self):
178
+ """SSE handler: return counter data for broadcast.
179
+
180
+ Change-detection: returns None when the count has not changed since
181
+ the last emitted value, so the broadcast worker skips redundant
182
+ messages (it treats a None result as "nothing to send").
183
+ """
184
+ count = self.get_count()
185
+ if count == self._last_sse_count:
186
+ return None
187
+ self._last_sse_count = count
188
+ return {
189
+ 'display': self.get_count_display(),
190
+ 'count': count,
191
+ }
@@ -0,0 +1,4 @@
1
+ from .crop_node import CropNode
2
+
3
+ __all__ = ['CropNode']
4
+