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,380 @@
1
+ """
2
+ Slice Image Node - slices images into tiles for improved detection of small objects.
3
+ Based on SAHI (Slicing Aided Hyper Inference) methodology.
4
+ """
5
+
6
+ import numpy as np
7
+ from typing import Any, Dict, List, Tuple, Optional
8
+ from pynode.nodes.base_node import BaseNode, Info, MessageKeys
9
+
10
+ try:
11
+ import cv2
12
+ _HAS_CV2 = True
13
+ except ImportError:
14
+ cv2 = None
15
+ _HAS_CV2 = False
16
+
17
+ _info = Info()
18
+ _info.add_text("Divides images into overlapping tiles for improved detection of small objects. Based on SAHI (Slicing Aided Hyper Inference) methodology.")
19
+ _info.add_header("Inputs")
20
+ _info.add_bullets(("Input 0:", "Image message with 'image' field"))
21
+ _info.add_header("Outputs")
22
+ _info.add_bullets(
23
+ ("Output 0:", "Array of image slices with offset metadata, or split messages per slice"),
24
+ )
25
+ _info.add_header("Configuration")
26
+ _info.add_bullets(
27
+ ("Output Mode:", "Array (all slices in one message) or Split (separate messages)"),
28
+ ("Auto Slice:", "Automatically calculate slice size based on image resolution"),
29
+ ("Slice Width/Height:", "Manual slice dimensions in pixels"),
30
+ ("Overlap Ratios:", "Overlap between adjacent slices (0-1)"),
31
+ ("Include Full Image:", "Also include the original full image in output"),
32
+ )
33
+
34
+
35
+ class SliceImageNode(BaseNode):
36
+ """
37
+ Slice Image Node - divides images into overlapping tiles for better detection
38
+ of small objects in large images.
39
+
40
+ Implements SAHI-style slicing with configurable tile size and overlap.
41
+ Outputs an array of image tiles with their offset information.
42
+ """
43
+ display_name = 'Slice Image'
44
+ info = str(_info)
45
+ icon = '🔲'
46
+ category = 'vision'
47
+ color = '#4ECDC4'
48
+ border_color = '#26A69A'
49
+ text_color = '#000000'
50
+ input_count = 1
51
+ output_count = 1
52
+
53
+ DEFAULT_CONFIG = {
54
+ 'slice_width': 640,
55
+ 'slice_height': 640,
56
+ 'overlap_width_ratio': 0.2,
57
+ 'overlap_height_ratio': 0.2,
58
+ 'auto_slice': False,
59
+ 'include_full_image': True,
60
+ 'output_mode': 'array',
61
+ 'min_area_ratio': 0.1,
62
+ 'drop_messages': False
63
+ }
64
+
65
+ properties = [
66
+ {
67
+ 'name': 'output_mode',
68
+ 'label': 'Output Mode',
69
+ 'type': 'select',
70
+ 'options': [
71
+ {'value': 'array', 'label': 'Array (all slices in one message)'},
72
+ {'value': 'split', 'label': 'Split (separate message per slice)'}
73
+ ],
74
+ 'default': 'array'
75
+ },
76
+ {
77
+ 'name': 'auto_slice',
78
+ 'label': 'Auto Slice Resolution',
79
+ 'type': 'checkbox',
80
+ 'default': False
81
+ },
82
+ {
83
+ 'name': 'slice_width',
84
+ 'label': 'Slice Width (pixels)',
85
+ 'type': 'number',
86
+ 'default': 640,
87
+ 'showIf': {'auto_slice': False}
88
+ },
89
+ {
90
+ 'name': 'slice_height',
91
+ 'label': 'Slice Height (pixels)',
92
+ 'type': 'number',
93
+ 'default': 640,
94
+ 'showIf': {'auto_slice': False}
95
+ },
96
+ {
97
+ 'name': 'overlap_width_ratio',
98
+ 'label': 'Overlap Width Ratio (0-1)',
99
+ 'type': 'number',
100
+ 'default': 0.2,
101
+ 'showIf': {'auto_slice': False}
102
+ },
103
+ {
104
+ 'name': 'overlap_height_ratio',
105
+ 'label': 'Overlap Height Ratio (0-1)',
106
+ 'type': 'number',
107
+ 'default': 0.2,
108
+ 'showIf': {'auto_slice': False}
109
+ },
110
+ {
111
+ 'name': 'include_full_image',
112
+ 'label': 'Include Full Image in Output',
113
+ 'type': 'checkbox',
114
+ 'default': True
115
+ },
116
+ {
117
+ 'name': 'min_area_ratio',
118
+ 'label': 'Min Area Ratio (for auto slice)',
119
+ 'type': 'number',
120
+ 'default': 0.1,
121
+ 'showIf': {'auto_slice': True}
122
+ }
123
+ ]
124
+
125
+ def __init__(self, node_id=None, name="slice_image"):
126
+ super().__init__(node_id, name)
127
+
128
+ def _get_auto_slice_params(self, height: int, width: int) -> Tuple[int, int, int, int]:
129
+ """
130
+ Calculate automatic slice parameters based on image resolution.
131
+ Returns (x_overlap, y_overlap, slice_width, slice_height)
132
+ """
133
+ resolution = height * width
134
+ factor = self._calc_resolution_factor(resolution)
135
+
136
+ orientation = self._calc_aspect_ratio_orientation(width, height)
137
+
138
+ if factor <= 18: # Low resolution (e.g., 300x300, 640x640)
139
+ return self._get_resolution_params("low", height, width, orientation)
140
+ elif factor < 21: # Medium resolution (e.g., 1024x1024)
141
+ return self._get_resolution_params("medium", height, width, orientation)
142
+ elif factor < 24: # High resolution (e.g., 2048x2048)
143
+ return self._get_resolution_params("high", height, width, orientation)
144
+ else: # Ultra-high resolution (e.g., 4096x4096+)
145
+ return self._get_resolution_params("ultra-high", height, width, orientation)
146
+
147
+ def _calc_resolution_factor(self, resolution: int) -> int:
148
+ """Calculate power of 2 closest to image resolution."""
149
+ expo = 0
150
+ while 2 ** expo < resolution:
151
+ expo += 1
152
+ return expo - 1
153
+
154
+ def _calc_aspect_ratio_orientation(self, width: int, height: int) -> str:
155
+ """Determine image orientation based on aspect ratio."""
156
+ if width < height:
157
+ return "vertical"
158
+ elif width > height:
159
+ return "horizontal"
160
+ return "square"
161
+
162
+ def _calc_ratio_and_slice(self, orientation: str, slide: int = 1, ratio: float = 0.1) -> Tuple[int, int, float, float]:
163
+ """Calculate slice rows/cols and overlap ratios based on orientation."""
164
+ if orientation == "vertical":
165
+ return slide, slide * 2, ratio, ratio
166
+ elif orientation == "horizontal":
167
+ return slide * 2, slide, ratio, ratio
168
+ return slide, slide, ratio, ratio
169
+
170
+ def _get_resolution_params(self, resolution: str, height: int, width: int, orientation: str) -> Tuple[int, int, int, int]:
171
+ """Get slice parameters based on resolution category."""
172
+ if resolution == "medium":
173
+ split_row, split_col, overlap_h, overlap_w = self._calc_ratio_and_slice(orientation, 1, 0.8)
174
+ elif resolution == "high":
175
+ split_row, split_col, overlap_h, overlap_w = self._calc_ratio_and_slice(orientation, 2, 0.4)
176
+ elif resolution == "ultra-high":
177
+ split_row, split_col, overlap_h, overlap_w = self._calc_ratio_and_slice(orientation, 4, 0.4)
178
+ else: # low
179
+ return 0, 0, width, height # No slicing needed
180
+
181
+ slice_height = height // split_col
182
+ slice_width = width // split_row
183
+ x_overlap = int(slice_width * overlap_w)
184
+ y_overlap = int(slice_height * overlap_h)
185
+
186
+ return x_overlap, y_overlap, slice_width, slice_height
187
+
188
+ def _get_slice_bboxes(
189
+ self,
190
+ image_height: int,
191
+ image_width: int,
192
+ slice_height: int,
193
+ slice_width: int,
194
+ overlap_height_ratio: float,
195
+ overlap_width_ratio: float
196
+ ) -> List[List[int]]:
197
+ """
198
+ Generate bounding boxes for slicing an image.
199
+
200
+ Returns list of [x_min, y_min, x_max, y_max] for each slice.
201
+ """
202
+ if overlap_height_ratio >= 1.0 or overlap_width_ratio >= 1.0:
203
+ raise ValueError("Overlap ratio must be less than 1.0")
204
+
205
+ y_overlap = int(overlap_height_ratio * slice_height)
206
+ x_overlap = int(overlap_width_ratio * slice_width)
207
+
208
+ slice_bboxes = []
209
+ y_min = 0
210
+
211
+ while y_min < image_height:
212
+ y_max = y_min + slice_height
213
+ x_min = 0
214
+
215
+ while x_min < image_width:
216
+ x_max = x_min + slice_width
217
+
218
+ # Handle edge cases - ensure slices don't exceed image bounds
219
+ if y_max > image_height or x_max > image_width:
220
+ xmax = min(image_width, x_max)
221
+ ymax = min(image_height, y_max)
222
+ xmin = max(0, xmax - slice_width)
223
+ ymin = max(0, ymax - slice_height)
224
+ slice_bboxes.append([xmin, ymin, xmax, ymax])
225
+ else:
226
+ slice_bboxes.append([x_min, y_min, x_max, y_max])
227
+
228
+ x_min = x_max - x_overlap
229
+
230
+ y_min = y_max - y_overlap
231
+
232
+ return slice_bboxes
233
+
234
+ def _slice_image(self, image: np.ndarray) -> List[Dict[str, Any]]:
235
+ """
236
+ Slice the image into tiles based on configuration.
237
+
238
+ Returns list of dicts with 'image', 'offset', and 'bbox' for each slice.
239
+ """
240
+ height, width = image.shape[:2]
241
+
242
+ auto_slice = self.get_config_bool('auto_slice', False)
243
+
244
+ if auto_slice:
245
+ x_overlap, y_overlap, slice_width, slice_height = self._get_auto_slice_params(height, width)
246
+ if slice_width >= width and slice_height >= height:
247
+ # No slicing needed - image is small enough
248
+ return [{
249
+ 'image': image,
250
+ 'offset': [0, 0],
251
+ 'bbox': [0, 0, width, height],
252
+ 'is_full_image': True
253
+ }]
254
+ overlap_width_ratio = x_overlap / slice_width if slice_width > 0 else 0.2
255
+ overlap_height_ratio = y_overlap / slice_height if slice_height > 0 else 0.2
256
+ else:
257
+ slice_width = self.get_config_int('slice_width', 640)
258
+ slice_height = self.get_config_int('slice_height', 640)
259
+ overlap_width_ratio = self.get_config_float('overlap_width_ratio', 0.2)
260
+ overlap_height_ratio = self.get_config_float('overlap_height_ratio', 0.2)
261
+
262
+ # Get slice bounding boxes
263
+ slice_bboxes = self._get_slice_bboxes(
264
+ image_height=height,
265
+ image_width=width,
266
+ slice_height=slice_height,
267
+ slice_width=slice_width,
268
+ overlap_height_ratio=overlap_height_ratio,
269
+ overlap_width_ratio=overlap_width_ratio
270
+ )
271
+
272
+ slices = []
273
+ for bbox in slice_bboxes:
274
+ x_min, y_min, x_max, y_max = bbox
275
+ slice_img = image[y_min:y_max, x_min:x_max].copy()
276
+ slices.append({
277
+ 'image': slice_img,
278
+ 'offset': [x_min, y_min],
279
+ 'bbox': bbox,
280
+ 'is_full_image': False
281
+ })
282
+
283
+ return slices
284
+
285
+ def on_input(self, msg: Dict[str, Any], input_index: int = 0):
286
+ """Process incoming image and output slices."""
287
+ if not _HAS_CV2:
288
+ self.report_error("OpenCV (cv2) is required for image slicing")
289
+ return
290
+
291
+ payload = msg.get(MessageKeys.PAYLOAD)
292
+ if payload is None:
293
+ self.report_error("No payload in message")
294
+ return
295
+
296
+ # Decode the image
297
+ image, input_format = self.decode_image(payload)
298
+ if image is None or input_format is None:
299
+ self.report_error("Failed to decode image from payload")
300
+ return
301
+
302
+ # Slice the image
303
+ slices = self._slice_image(image)
304
+
305
+ # Check if we should include the full image
306
+ include_full = self.get_config_bool('include_full_image', True)
307
+ output_mode = self.config.get('output_mode', 'array')
308
+
309
+ # Get original message ID for tracking
310
+ parent_msg_id = msg.get(MessageKeys.MSG_ID, '')
311
+
312
+ # Prepare output slices
313
+ output_slices = []
314
+
315
+ # Add full image first if configured (index 0)
316
+ if include_full:
317
+ full_encoded = self.encode_image(image, input_format)
318
+ output_slices.append({
319
+ MessageKeys.PAYLOAD: {MessageKeys.IMAGE.PATH: full_encoded}, # Standard payload.image format
320
+ 'offset': [0, 0],
321
+ 'bbox': [0, 0, image.shape[1], image.shape[0]],
322
+ 'slice_index': 0,
323
+ 'is_full_image': True,
324
+ 'original_width': image.shape[1],
325
+ 'original_height': image.shape[0]
326
+ })
327
+
328
+ # Add slices
329
+ start_index = 1 if include_full else 0
330
+ for i, slice_data in enumerate(slices):
331
+ encoded = self.encode_image(slice_data[MessageKeys.IMAGE.PATH], input_format)
332
+ output_slices.append({
333
+ MessageKeys.PAYLOAD: {MessageKeys.IMAGE.PATH: encoded}, # Standard payload.image format
334
+ 'offset': slice_data['offset'],
335
+ 'bbox': slice_data['bbox'],
336
+ 'slice_index': start_index + i,
337
+ 'is_full_image': slice_data.get('is_full_image', False),
338
+ 'original_width': image.shape[1],
339
+ 'original_height': image.shape[0]
340
+ })
341
+
342
+ total_count = len(output_slices)
343
+
344
+ if output_mode == 'split':
345
+ # Send each slice as a separate message with parts metadata
346
+ for i, slice_info in enumerate(output_slices):
347
+ slice_msg = msg.copy()
348
+ # Put image directly in payload for YOLO compatibility
349
+ slice_msg[MessageKeys.PAYLOAD] = slice_info[MessageKeys.PAYLOAD]
350
+ slice_msg[MessageKeys.TOPIC] = msg.get(MessageKeys.TOPIC, 'slice')
351
+
352
+ # Add parts metadata for SliceCollectorNode to track
353
+ slice_msg['parts'] = {
354
+ 'index': i,
355
+ 'count': total_count,
356
+ 'id': parent_msg_id
357
+ }
358
+
359
+ # Add slice metadata at message level for SliceCollector
360
+ slice_msg['slice_offset'] = slice_info['offset']
361
+ slice_msg['slice_bbox'] = slice_info['bbox']
362
+ slice_msg['slice_index'] = slice_info['slice_index']
363
+ slice_msg['is_full_image'] = slice_info['is_full_image']
364
+ slice_msg['original_width'] = slice_info['original_width']
365
+ slice_msg['original_height'] = slice_info['original_height']
366
+
367
+ self.send(slice_msg)
368
+ else:
369
+ # Array mode - send slices as array for Split node
370
+ # Each item has 'payload' key which Split will extract
371
+ out_msg = msg.copy()
372
+ out_msg[MessageKeys.PAYLOAD] = output_slices # Direct array for Split node
373
+ out_msg[MessageKeys.TOPIC] = msg.get(MessageKeys.TOPIC, 'sliced')
374
+ out_msg['slice_count'] = total_count
375
+ out_msg['original_width'] = image.shape[1]
376
+ out_msg['original_height'] = image.shape[0]
377
+ out_msg['include_full_image'] = include_full
378
+
379
+ self.send(out_msg)
380
+
@@ -0,0 +1,3 @@
1
+ from .split_node import SplitNode
2
+
3
+ __all__ = ['SplitNode']
@@ -0,0 +1 @@
1
+ # No external dependencies - uses only Python standard library
@@ -0,0 +1,155 @@
1
+ """
2
+ Split Node - splits arrays or strings into separate messages.
3
+ """
4
+
5
+ from typing import Any, Dict
6
+ from pynode.nodes.base_node import BaseNode, Info, MessageKeys
7
+
8
+ _info = Info()
9
+ _info.add_text("Splits arrays, strings, or objects into separate messages. Each element becomes an individual message with parts metadata for reassembly.")
10
+ _info.add_header("Inputs")
11
+ _info.add_bullets(
12
+ ("Input 0:", "Message with array, string, or object payload")
13
+ )
14
+ _info.add_header("Outputs")
15
+ _info.add_bullets(
16
+ ("Output 0:", "Multiple messages, one per element")
17
+ )
18
+ _info.add_header("Configuration")
19
+ _info.add_bullets(
20
+ ("Split Type:", "Auto-detect, Array, String, or Object (key-value pairs)"),
21
+ ("Delimiter:", "Character to split strings on (default: comma)")
22
+ )
23
+ _info.add_header("Output Message")
24
+ _info.add_code('msg.payload').text(" - Individual element from the split").end()
25
+ _info.add_code('msg.parts.index').text(" - Position in the original sequence").end()
26
+ _info.add_code('msg.parts.count').text(" - Total number of parts").end()
27
+ _info.add_code('msg.parts.id').text(" - ID to group related parts").end()
28
+
29
+
30
+ class SplitNode(BaseNode):
31
+ """
32
+ Split Node - splits arrays or strings into separate messages.
33
+ Similar to Node-RED's split node.
34
+ """
35
+ info = str(_info)
36
+ display_name = 'Split'
37
+ icon = '✂️'
38
+ category = 'function'
39
+ color = '#F0E68C'
40
+ border_color = '#DAA520'
41
+ text_color = '#000000'
42
+ input_count = 1
43
+ output_count = 1
44
+
45
+ DEFAULT_CONFIG = {
46
+ 'split_type': 'auto',
47
+ 'delimiter': ',',
48
+ 'drop_messages': False,
49
+ 'split_path': MessageKeys.PAYLOAD
50
+ }
51
+
52
+ properties = [
53
+ {
54
+ 'name': 'split_path',
55
+ 'label': 'Message Path',
56
+ 'type': 'text',
57
+ 'default': DEFAULT_CONFIG['split_path']
58
+ },
59
+ {
60
+ 'name': 'split_type',
61
+ 'label': 'Split Type',
62
+ 'type': 'select',
63
+ 'options': [
64
+ {'value': 'auto', 'label': 'Auto-detect'},
65
+ {'value': 'array', 'label': 'Array'},
66
+ {'value': 'string', 'label': 'String'},
67
+ {'value': 'object', 'label': 'Object (key-value pairs)'}
68
+ ],
69
+ 'default': DEFAULT_CONFIG['split_type']
70
+ },
71
+ {
72
+ 'name': 'delimiter',
73
+ 'label': 'String Delimiter',
74
+ 'type': 'text',
75
+ 'default': DEFAULT_CONFIG['delimiter'],
76
+ 'showIf': {'split_type': 'string'},
77
+ }
78
+ ]
79
+
80
+ def __init__(self, node_id=None, name="split"):
81
+ super().__init__(node_id, name)
82
+
83
+ def on_input(self, msg: Dict[str, Any], input_index: int = 0):
84
+ """Split incoming message into multiple messages."""
85
+ split_path = self.config.get('split_path', MessageKeys.PAYLOAD)
86
+ payload = self._get_nested_value(msg, split_path)
87
+
88
+ if payload is None:
89
+ self.report_error(f"No value found at path: {split_path}")
90
+ return
91
+
92
+ split_type = self.config.get('split_type', 'auto')
93
+
94
+ items = []
95
+
96
+ # Auto-detect or explicit type
97
+ if split_type == 'auto':
98
+ if isinstance(payload, list):
99
+ items = payload
100
+ elif isinstance(payload, dict):
101
+ items = list(payload.items())
102
+ elif isinstance(payload, str):
103
+ delimiter = self.config.get('delimiter', ',')
104
+ items = payload.split(delimiter)
105
+ else:
106
+ self.report_error(f"Cannot split payload of type {type(payload)}")
107
+ return
108
+ elif split_type == 'array':
109
+ if isinstance(payload, list):
110
+ items = payload
111
+ else:
112
+ self.report_error("Payload is not an array")
113
+ return
114
+ elif split_type == 'string':
115
+ if isinstance(payload, str):
116
+ delimiter = self.config.get('delimiter', ',')
117
+ items = payload.split(delimiter)
118
+ else:
119
+ items = str(payload).split(self.config.get('delimiter', ','))
120
+ elif split_type == 'object':
121
+ if isinstance(payload, dict):
122
+ items = list(payload.items())
123
+ else:
124
+ self.report_error("Payload is not an object")
125
+ return
126
+
127
+ # Send each item as a separate message
128
+ total_count = len(items)
129
+ original_msgid = msg.get(MessageKeys.MSG_ID)
130
+
131
+ for i, item in enumerate(items):
132
+ # Create a fresh copy of the message for each item
133
+ out_msg = msg.copy()
134
+
135
+ # For object splits, item is a tuple (key, value)
136
+ if isinstance(item, tuple) and len(item) == 2:
137
+ item_payload = {'key': item[0], 'value': item[1]}
138
+ # For slice objects with embedded payload (from SliceImageNode)
139
+ elif isinstance(item, dict) and MessageKeys.PAYLOAD in item:
140
+ item_payload = item[MessageKeys.PAYLOAD]
141
+ # Copy other keys to message level (slice metadata)
142
+ for key, value in item.items():
143
+ if key != MessageKeys.PAYLOAD:
144
+ out_msg[key] = value
145
+ else:
146
+ item_payload = item
147
+
148
+ out_msg[MessageKeys.PAYLOAD] = item_payload
149
+ out_msg['parts'] = {
150
+ 'index': i,
151
+ 'count': total_count,
152
+ 'id': original_msgid
153
+ }
154
+
155
+ self.send(out_msg)
@@ -0,0 +1 @@
1
+ # No external dependencies - uses only Python standard library