pynode-flow 0.2.5__tar.gz → 0.2.6__tar.gz

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 (324) hide show
  1. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/PKG-INFO +1 -1
  2. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/_version.py +3 -3
  3. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/SliceNode/slice_collector_node.py +247 -9
  4. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/SliceNode/slice_image_node.py +124 -15
  5. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/VideoReaderNode/video_reader_node.py +8 -2
  6. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/static/js/debug.js +13 -0
  7. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/static/js/nodes.js +64 -20
  8. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode_flow.egg-info/PKG-INFO +1 -1
  9. pynode_flow-0.2.6/pynode_flow.egg-info/scm_version.json +8 -0
  10. pynode_flow-0.2.5/pynode_flow.egg-info/scm_version.json +0 -8
  11. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/.claude/skills/pynode-workflows/SKILL.md +0 -0
  12. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/.dockerignore +0 -0
  13. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/.github/workflows/ci.yml +0 -0
  14. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/.github/workflows/publish.yaml +0 -0
  15. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/.gitignore +0 -0
  16. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/DOCKER.md +0 -0
  17. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/Dockerfile +0 -0
  18. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/Dockerfile.cpu +0 -0
  19. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/INSTALL.md +0 -0
  20. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/LICENSE +0 -0
  21. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/MANIFEST.in +0 -0
  22. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/README.md +0 -0
  23. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/docker-compose.yml +0 -0
  24. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/docker-entrypoint.sh +0 -0
  25. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/docs/CREATING_NODES.md +0 -0
  26. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/docs/CUSTOM_NODES.md +0 -0
  27. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/docs/DOCKER.md +0 -0
  28. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/docs/EXTENSIBILITY.md +0 -0
  29. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/docs/UI_COMPONENTS.md +0 -0
  30. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/examples/README.md +0 -0
  31. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/examples/camera_workflow.py +0 -0
  32. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/examples/camera_yolo_workflow.py +0 -0
  33. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/install_nodes.bat +0 -0
  34. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/install_nodes.sh +0 -0
  35. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/__init__.py +0 -0
  36. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/__main__.py +0 -0
  37. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/_perf.py +0 -0
  38. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/api/__init__.py +0 -0
  39. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/api/helpers.py +0 -0
  40. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/api/nodes.py +0 -0
  41. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/api/services.py +0 -0
  42. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/api/sse.py +0 -0
  43. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/api/uploads.py +0 -0
  44. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/api/workflows.py +0 -0
  45. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/config.py +0 -0
  46. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/install_nodes.py +0 -0
  47. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/main.py +0 -0
  48. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/node_registry.py +0 -0
  49. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/AutoSyncNode/auto_sync_node.py +0 -0
  50. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/BBoxMetricsNode/__init__.py +0 -0
  51. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/BBoxMetricsNode/bbox_metrics_node.py +0 -0
  52. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/BBoxMetricsNode/requirements.txt +0 -0
  53. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/BatchNode/__init__.py +0 -0
  54. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/BatchNode/batch_node.py +0 -0
  55. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/BatchNode/requirements.txt +0 -0
  56. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/CameraNode/__init__.py +0 -0
  57. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/CameraNode/camera_node.py +0 -0
  58. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/CameraNode/requirements.txt +0 -0
  59. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/ChangeNode/__init__.py +0 -0
  60. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/ChangeNode/change_node.py +0 -0
  61. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/ChangeNode/requirements.txt +0 -0
  62. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/ConfidenceFilterNode/__init__.py +0 -0
  63. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/ConfidenceFilterNode/confidence_filter_node.py +0 -0
  64. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/ConfidenceFilterNode/requirements.txt +0 -0
  65. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/ControlSliderNode/control_slider_node.py +0 -0
  66. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/CounterNode/__init__.py +0 -0
  67. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/CounterNode/counter_node.py +0 -0
  68. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/CropNode/__init__.py +0 -0
  69. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/CropNode/crop_node.py +0 -0
  70. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/CropNode/requirements.txt +0 -0
  71. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/DebugNode/__init__.py +0 -0
  72. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/DebugNode/debug_node.py +0 -0
  73. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/DebugNode/requirements.txt +0 -0
  74. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/DelayNode/delay_node.py +0 -0
  75. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/DelayNode/requirements.txt +0 -0
  76. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/DrawPredictionsNode/__init__.py +0 -0
  77. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/DrawPredictionsNode/draw_predictions_node.py +0 -0
  78. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/DrawPredictionsNode/requirements.txt +0 -0
  79. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/ErrorNode/error_node.py +0 -0
  80. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/ErrorNode/requirements.txt +0 -0
  81. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/FilterNode/__init__.py +0 -0
  82. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/FilterNode/filter_node.py +0 -0
  83. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/FilterNode/requirements.txt +0 -0
  84. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/FrameSourceNode/__init__.py +0 -0
  85. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/FrameSourceNode/frame_source_node.py +0 -0
  86. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/FrameSourceNode/requirements.txt +0 -0
  87. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/FunctionNode/__init__.py +0 -0
  88. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/FunctionNode/function_node.py +0 -0
  89. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/FunctionNode/requirements.txt +0 -0
  90. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/GateNode/gate_node.py +0 -0
  91. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/GateNode/requirements.txt +0 -0
  92. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/ImageFormatNode/__init__.py +0 -0
  93. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/ImageFormatNode/image_format_node.py +0 -0
  94. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/ImageFormatNode/requirements.txt +0 -0
  95. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/ImageUploadNode/image_upload_node.py +0 -0
  96. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/ImageViewerNode/__init__.py +0 -0
  97. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/ImageViewerNode/image_viewer_node.py +0 -0
  98. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/ImageViewerNode/requirements.txt +0 -0
  99. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/ImageWriterNode/__init__.py +0 -0
  100. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/ImageWriterNode/imagewriter_node.py +0 -0
  101. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/ImageWriterNode/requirements.txt +0 -0
  102. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/InferenceNode/InferenceEngine/__init__.py +0 -0
  103. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/InferenceNode/InferenceEngine/device_detection.py +0 -0
  104. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/InferenceNode/InferenceEngine/engines/base_engine.py +0 -0
  105. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/InferenceNode/InferenceEngine/engines/example_engine_template.py +0 -0
  106. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/InferenceNode/InferenceEngine/engines/onnx_engine.py +0 -0
  107. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/InferenceNode/InferenceEngine/engines/pass_engine.py +0 -0
  108. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/InferenceNode/InferenceEngine/engines/ultralytics_engine.py +0 -0
  109. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/InferenceNode/InferenceEngine/inference_engine_factory.py +0 -0
  110. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/InferenceNode/InferenceEngine/readme.md +0 -0
  111. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/InferenceNode/InferenceEngine/result_converters.py +0 -0
  112. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/InferenceNode/inference_node.py +0 -0
  113. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/InferenceNode/requirements.txt +0 -0
  114. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/InjectNode/inject_node.py +0 -0
  115. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/InjectNode/requirements.txt +0 -0
  116. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/JoinNode/__init__.py +0 -0
  117. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/JoinNode/join_node.py +0 -0
  118. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/JoinNode/requirements.txt +0 -0
  119. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/LabelFilterNode/__init__.py +0 -0
  120. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/LabelFilterNode/label_filter_node.py +0 -0
  121. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/LabelFilterNode/requirements.txt +0 -0
  122. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/LinkNode/__init__.py +0 -0
  123. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/LinkNode/link_bus.py +0 -0
  124. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/LinkNode/link_in_node.py +0 -0
  125. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/LinkNode/link_out_node.py +0 -0
  126. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/LinkNode/requirements.txt +0 -0
  127. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/MQTTNode/__init__.py +0 -0
  128. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/MQTTNode/mqtt_in_node.py +0 -0
  129. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/MQTTNode/mqtt_out_node.py +0 -0
  130. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/MQTTNode/mqtt_service.py +0 -0
  131. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/MQTTNode/requirements.txt +0 -0
  132. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/MessageWriterNode/__init__.py +0 -0
  133. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/MessageWriterNode/messagereader_node.py +0 -0
  134. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/MessageWriterNode/messagewriter_node.py +0 -0
  135. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/OmronCameraNode/__init__.py +0 -0
  136. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/OmronCameraNode/omron_camera_node.py +0 -0
  137. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/OmronCameraNode/requirements.txt +0 -0
  138. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/OpenCV/__init__.py +0 -0
  139. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/OpenCV/arithmetic_node.py +0 -0
  140. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/OpenCV/bitwise_node.py +0 -0
  141. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/OpenCV/blend_node.py +0 -0
  142. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/OpenCV/blob_detector_node.py +0 -0
  143. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/OpenCV/blur_node.py +0 -0
  144. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/OpenCV/color_space_node.py +0 -0
  145. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/OpenCV/colormap_node.py +0 -0
  146. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/OpenCV/contours_node.py +0 -0
  147. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/OpenCV/denormalize_coords_node.py +0 -0
  148. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/OpenCV/draw_node.py +0 -0
  149. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/OpenCV/edge_detector_node.py +0 -0
  150. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/OpenCV/equirectangular_node.py +0 -0
  151. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/OpenCV/fft_node.py +0 -0
  152. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/OpenCV/focus_estimation_node.py +0 -0
  153. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/OpenCV/frequency_filter_node.py +0 -0
  154. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/OpenCV/histogram_node.py +0 -0
  155. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/OpenCV/hough_circles_node.py +0 -0
  156. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/OpenCV/hough_lines_node.py +0 -0
  157. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/OpenCV/in_range_node.py +0 -0
  158. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/OpenCV/morphology_node.py +0 -0
  159. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/OpenCV/normalize_coords_node.py +0 -0
  160. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/OpenCV/paste_node.py +0 -0
  161. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/OpenCV/perspective_node.py +0 -0
  162. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/OpenCV/realsense_depth_node.py +0 -0
  163. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/OpenCV/requirements.txt +0 -0
  164. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/OpenCV/resize_node.py +0 -0
  165. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/OpenCV/rotate_node.py +0 -0
  166. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/OpenCV/template_match_node.py +0 -0
  167. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/OpenCV/threshold_node.py +0 -0
  168. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/PointInShapeNode/__init__.py +0 -0
  169. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/PointInShapeNode/point_in_shape_node.py +0 -0
  170. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/PointInShapeNode/requirements.txt +0 -0
  171. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/PolygonMetricsNode/__init__.py +0 -0
  172. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/PolygonMetricsNode/polygon_metrics_node.py +0 -0
  173. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/PolygonMetricsNode/requirements.txt +0 -0
  174. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/QueueLengthProbeNode/__init__.py +0 -0
  175. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/QueueLengthProbeNode/queue_length_probe_node.py +0 -0
  176. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/Qwen3VLMNode/__init__.py +0 -0
  177. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/Qwen3VLMNode/qwen3_vlm_node.py +0 -0
  178. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/Qwen3VLMNode/requirements.txt +0 -0
  179. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/RESTEndpointNode/__init__.py +0 -0
  180. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/RESTEndpointNode/rest_endpoint_node.py +0 -0
  181. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/RangeNode/__init__.py +0 -0
  182. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/RangeNode/range_node.py +0 -0
  183. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/RangeNode/requirements.txt +0 -0
  184. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/RateProbeNode/__init__.py +0 -0
  185. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/RateProbeNode/rate_probe_node.py +0 -0
  186. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/RateProbeNode/requirements.txt +0 -0
  187. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/RoboflowUploadNode/__init__.py +0 -0
  188. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/RoboflowUploadNode/requirements.txt +0 -0
  189. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/RoboflowUploadNode/roboflow_upload_node.py +0 -0
  190. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/SliceNode/README.md +0 -0
  191. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/SliceNode/__init__.py +0 -0
  192. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/SliceNode/merge_predictions_node.py +0 -0
  193. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/SliceNode/requirements.txt +0 -0
  194. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/SocketNode/__init__.py +0 -0
  195. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/SocketNode/interop/README.md +0 -0
  196. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/SocketNode/interop/nodered-example-flow.json +0 -0
  197. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/SocketNode/ndjson_protocol.py +0 -0
  198. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/SocketNode/nodered_snippets.py +0 -0
  199. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/SocketNode/requirements.txt +0 -0
  200. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/SocketNode/tcp_in_node.py +0 -0
  201. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/SocketNode/tcp_out_node.py +0 -0
  202. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/SocketNode/udp_in_node.py +0 -0
  203. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/SocketNode/udp_out_node.py +0 -0
  204. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/SocketNode/udp_probe.py +0 -0
  205. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/SocketNode/udp_protocol.py +0 -0
  206. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/SplitNode/__init__.py +0 -0
  207. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/SplitNode/requirements.txt +0 -0
  208. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/SplitNode/split_node.py +0 -0
  209. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/SwitchNode/requirements.txt +0 -0
  210. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/SwitchNode/switch_node.py +0 -0
  211. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/SyncNode/__init__.py +0 -0
  212. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/SyncNode/sync_node.py +0 -0
  213. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/TemplateNode/requirements.txt +0 -0
  214. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/TemplateNode/template_node.py +0 -0
  215. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/TrackerNode/__init__.py +0 -0
  216. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/TrackerNode/supervision_tracker_node.py +0 -0
  217. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/TrackerNode/tracker_node.py +0 -0
  218. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/UltralyticsNode/__init__.py +0 -0
  219. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/UltralyticsNode/model_paths.py +0 -0
  220. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/UltralyticsNode/requirements.txt +0 -0
  221. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/UltralyticsNode/ultralytics_node.py +0 -0
  222. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/UnknownNode/__init__.py +0 -0
  223. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/VLLMNode/__init__.py +0 -0
  224. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/VLLMNode/vllm_node.py +0 -0
  225. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/VideoReaderNode/__init__.py +0 -0
  226. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/VideoWriterNode/__init__.py +0 -0
  227. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/VideoWriterNode/video_writer_node.py +0 -0
  228. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/WebhookNode/__init__.py +0 -0
  229. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/WebhookNode/webhook_node.py +0 -0
  230. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/__init__.py +0 -0
  231. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/base_node.py +0 -0
  232. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/image_utils.py +0 -0
  233. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/info.py +0 -0
  234. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/mDNSNode/__init__.py +0 -0
  235. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/mDNSNode/mdns_broadcast_node.py +0 -0
  236. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/mDNSNode/mdns_discovery_node.py +0 -0
  237. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/mDNSNode/mdns_manager.py +0 -0
  238. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/mDNSNode/requirements.txt +0 -0
  239. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/messages.py +0 -0
  240. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/nodes/pacing.py +0 -0
  241. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/server.py +0 -0
  242. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/static/examples/01-hello-world.json +0 -0
  243. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/static/examples/02-function-transform.json +0 -0
  244. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/static/examples/03-switch-routing.json +0 -0
  245. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/static/examples/04-flow-control.json +0 -0
  246. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/static/examples/05-webcam-viewer.json +0 -0
  247. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/static/examples/06-webcam-yolo.json +0 -0
  248. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/static/examples/07-blur-people.json +0 -0
  249. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/static/examples/08-video-yolo-recorder.json +0 -0
  250. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/static/examples/09-mqtt-pub-sub.json +0 -0
  251. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/static/examples/10-person-alert-mqtt.json +0 -0
  252. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/static/examples/16-socket-bridge.json +0 -0
  253. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/static/examples/17-slider-crop.json +0 -0
  254. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/static/examples/README.md +0 -0
  255. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/static/examples/manifest.json +0 -0
  256. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/static/images/favicon4.png +0 -0
  257. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/static/index.html +0 -0
  258. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/static/js/README.md +0 -0
  259. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/static/js/auth.js +0 -0
  260. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/static/js/clipboard.js +0 -0
  261. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/static/js/config.js +0 -0
  262. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/static/js/connections.js +0 -0
  263. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/static/js/events.js +0 -0
  264. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/static/js/history.js +0 -0
  265. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/static/js/main.js +0 -0
  266. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/static/js/minimap.js +0 -0
  267. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/static/js/mqtt-services.js +0 -0
  268. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/static/js/palette.js +0 -0
  269. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/static/js/properties.js +0 -0
  270. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/static/js/selection.js +0 -0
  271. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/static/js/state.js +0 -0
  272. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/static/js/ui-utils.js +0 -0
  273. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/static/js/viewport.js +0 -0
  274. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/static/js/workflow.js +0 -0
  275. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/static/js/workflows.js +0 -0
  276. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/static/style.css +0 -0
  277. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/workflow_engine.py +0 -0
  278. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode/workflow_manager.py +0 -0
  279. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode_flow.egg-info/SOURCES.txt +0 -0
  280. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode_flow.egg-info/dependency_links.txt +0 -0
  281. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode_flow.egg-info/entry_points.txt +0 -0
  282. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode_flow.egg-info/requires.txt +0 -0
  283. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode_flow.egg-info/scm_file_list.json +0 -0
  284. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pynode_flow.egg-info/top_level.txt +0 -0
  285. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/pyproject.toml +0 -0
  286. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/requirements.txt +0 -0
  287. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/setup.bat +0 -0
  288. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/setup.cfg +0 -0
  289. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/setup.py +0 -0
  290. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/setup.sh +0 -0
  291. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/tests/__init__.py +0 -0
  292. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/tests/conftest.py +0 -0
  293. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/tests/test_api_auth.py +0 -0
  294. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/tests/test_api_errors.py +0 -0
  295. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/tests/test_api_nodes.py +0 -0
  296. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/tests/test_api_security.py +0 -0
  297. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/tests/test_api_workflows.py +0 -0
  298. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/tests/test_base_node_messaging.py +0 -0
  299. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/tests/test_camera_node_backend.py +0 -0
  300. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/tests/test_concurrency.py +0 -0
  301. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/tests/test_config.py +0 -0
  302. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/tests/test_control_slider_node.py +0 -0
  303. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/tests/test_counter_sse.py +0 -0
  304. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/tests/test_crop_node.py +0 -0
  305. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/tests/test_debug_workflow_scope.py +0 -0
  306. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/tests/test_delay_rate_limit.py +0 -0
  307. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/tests/test_device_detection.py +0 -0
  308. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/tests/test_frame_pacing.py +0 -0
  309. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/tests/test_function_node.py +0 -0
  310. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/tests/test_image_upload_node.py +0 -0
  311. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/tests/test_link_nodes.py +0 -0
  312. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/tests/test_message_reader_pickle.py +0 -0
  313. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/tests/test_models_dir.py +0 -0
  314. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/tests/test_mqtt_services.py +0 -0
  315. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/tests/test_node_registration.py +0 -0
  316. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/tests/test_node_types_api.py +0 -0
  317. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/tests/test_persistence.py +0 -0
  318. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/tests/test_range_node.py +0 -0
  319. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/tests/test_send_copy_semantics.py +0 -0
  320. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/tests/test_serialization.py +0 -0
  321. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/tests/test_socket_nodes.py +0 -0
  322. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/tests/test_video_reader_node.py +0 -0
  323. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/tests/test_workflow_engine.py +0 -0
  324. {pynode_flow-0.2.5 → pynode_flow-0.2.6}/tests/test_workflow_roundtrip.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pynode-flow
3
- Version: 0.2.5
3
+ Version: 0.2.6
4
4
  Summary: Python based Node-RED-like visual workflow system
5
5
  Author: olkham
6
6
  License-Expression: MIT
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.2.5'
22
- __version_tuple__ = version_tuple = (0, 2, 5)
21
+ __version__ = version = '0.2.6'
22
+ __version_tuple__ = version_tuple = (0, 2, 6)
23
23
 
24
- __commit_id__ = commit_id = 'g5581f8362'
24
+ __commit_id__ = commit_id = 'gfd6285bef'
@@ -6,12 +6,20 @@ Designed to work with SliceImageNode in a slice-detect-merge workflow.
6
6
  from dataclasses import dataclass
7
7
  import time
8
8
  from typing import Any, Dict, List, Optional
9
+ import numpy as np
9
10
  from pynode.nodes.base_node import BaseNode, Info, MessageKeys
10
11
 
12
+ try:
13
+ import cv2
14
+ _HAS_CV2 = True
15
+ except ImportError:
16
+ cv2 = None
17
+ _HAS_CV2 = False
18
+
11
19
  _info = Info()
12
20
  _info.add_text("Collects detection predictions from multiple image slices sent as separate messages, then merges them into a unified result.")
13
21
  _info.add_header("Inputs")
14
- _info.add_bullets(("Input 0:", "Individual slice prediction messages with slice metadata (from split output mode)"))
22
+ _info.add_bullets(("Input 0:", "Slice predictions from SliceImageNode. Auto-detects 'array' mode (all slices in one message) or 'split' mode (separate messages)."))
15
23
  _info.add_header("Outputs")
16
24
  _info.add_bullets(
17
25
  ("Output 0:", "Merged predictions after all slices collected and NMS applied"),
@@ -22,9 +30,11 @@ _info.add_bullets(
22
30
  ("NMS IoU Threshold:", "Threshold for Non-Maximum Suppression"),
23
31
  ("Match Metric:", "IoU or IoS for duplicate detection matching"),
24
32
  ("Class Agnostic NMS:", "Apply NMS across all classes vs per-class"),
33
+ ("Reconstruct Image from Slices:", "Rebuild the original parent image by stitching the collected tiles back together"),
34
+ ("Draw Slice Bounds:", "Draw each slice's rectangle on the reconstructed image to visualize tile placement and overlap"),
25
35
  )
26
36
  _info.add_header("Usage")
27
- _info.add_text("Designed for workflows: SliceImageNode (split mode) → Inference → SliceCollectorNode")
37
+ _info.add_text("Works with either SliceImageNode output mode. Split: SliceImageNode (split) → Split → Inference → SliceCollectorNode. Array: SliceImageNode (array) → SliceCollectorNode")
28
38
 
29
39
  @dataclass(frozen=True)
30
40
  class NodeKeys:
@@ -45,6 +55,9 @@ class NodeKeys:
45
55
  MATCH_METRIC = 'match_metric'
46
56
  CLASS_AGNOSTIC = 'class_agnostic'
47
57
  TIMEOUT = 'timeout'
58
+ RECONSTRUCT_IMAGE = 'reconstruct_image'
59
+ DRAW_SLICE_BOUNDS = 'draw_slice_bounds'
60
+ SLICE_IMAGE = 'slice_image'
48
61
 
49
62
 
50
63
  class SliceCollectorNode(BaseNode):
@@ -74,6 +87,8 @@ class SliceCollectorNode(BaseNode):
74
87
  NodeKeys.NMS_THRESHOLD: 0.5,
75
88
  NodeKeys.MATCH_METRIC: 'iou',
76
89
  NodeKeys.CLASS_AGNOSTIC: False,
90
+ NodeKeys.RECONSTRUCT_IMAGE: False,
91
+ NodeKeys.DRAW_SLICE_BOUNDS: False,
77
92
  MessageKeys.DROP_MESSAGES: False
78
93
  }
79
94
 
@@ -105,6 +120,19 @@ class SliceCollectorNode(BaseNode):
105
120
  'label': 'Class Agnostic NMS',
106
121
  'type': 'checkbox',
107
122
  'default': DEFAULT_CONFIG[NodeKeys.CLASS_AGNOSTIC]
123
+ },
124
+ {
125
+ 'name': NodeKeys.RECONSTRUCT_IMAGE,
126
+ 'label': 'Reconstruct Image from Slices',
127
+ 'type': 'checkbox',
128
+ 'default': DEFAULT_CONFIG[NodeKeys.RECONSTRUCT_IMAGE]
129
+ },
130
+ {
131
+ 'name': NodeKeys.DRAW_SLICE_BOUNDS,
132
+ 'label': 'Draw Slice Bounds',
133
+ 'type': 'checkbox',
134
+ 'default': DEFAULT_CONFIG[NodeKeys.DRAW_SLICE_BOUNDS],
135
+ 'showIf': {NodeKeys.RECONSTRUCT_IMAGE: True}
108
136
  }
109
137
  ]
110
138
 
@@ -227,11 +255,14 @@ class SliceCollectorNode(BaseNode):
227
255
  del self._collections[k]
228
256
 
229
257
  def _process_collection(self, collection_id: str) -> Optional[Dict]:
230
- """Process a complete collection and return merged result."""
258
+ """Process a complete collection (by id) and return merged result."""
231
259
  collection = self._collections.get(collection_id)
232
260
  if not collection:
233
261
  return None
234
-
262
+ return self._merge_collection(collection)
263
+
264
+ def _merge_collection(self, collection: Dict) -> Optional[Dict]:
265
+ """Merge all slices in a collection dict and return the merged result."""
235
266
  nms_threshold = self.get_config_float(NodeKeys.NMS_THRESHOLD, self.DEFAULT_CONFIG[NodeKeys.NMS_THRESHOLD])
236
267
  match_metric = self.config.get(NodeKeys.MATCH_METRIC, self.DEFAULT_CONFIG[NodeKeys.MATCH_METRIC])
237
268
  class_agnostic = self.get_config_bool(NodeKeys.CLASS_AGNOSTIC, self.DEFAULT_CONFIG[NodeKeys.CLASS_AGNOSTIC])
@@ -263,7 +294,7 @@ class SliceCollectorNode(BaseNode):
263
294
  # Sort by confidence
264
295
  final_detections = sorted(final_detections, key=lambda x: x.get(MessageKeys.CV.CONFIDENCE, 0), reverse=True)
265
296
 
266
- return {
297
+ result = {
267
298
  MessageKeys.CV.DETECTIONS: final_detections,
268
299
  MessageKeys.CV.DETECTION_COUNT: len(final_detections),
269
300
  NodeKeys.ORIGINAL_WIDTH: collection.get(NodeKeys.ORIGINAL_WIDTH, 0),
@@ -272,12 +303,204 @@ class SliceCollectorNode(BaseNode):
272
303
  MessageKeys.CV.BBOX_FORMAT: 'xyxy',
273
304
  MessageKeys.IMAGE.PATH: collection.get(MessageKeys.IMAGE.PATH)
274
305
  }
306
+
307
+ # Optionally rebuild the parent image from the collected tiles
308
+ if self.get_config_bool(NodeKeys.RECONSTRUCT_IMAGE, self.DEFAULT_CONFIG[NodeKeys.RECONSTRUCT_IMAGE]):
309
+ reconstructed = self._reconstruct_image(collection)
310
+ if reconstructed is not None:
311
+ result[MessageKeys.IMAGE.PATH] = reconstructed
312
+
313
+ return result
314
+
315
+ def _reconstruct_image(self, collection: Dict) -> Optional[Any]:
316
+ """
317
+ Rebuild the original parent image by stitching the collected slice
318
+ tiles back into a single canvas sized to the original image.
319
+
320
+ Tiles are placed using their slice bbox (or offset). Overlapping
321
+ regions are overwritten; if a full-image slice is present it is used
322
+ as the base before overlaying the tiles.
323
+ """
324
+ if not _HAS_CV2:
325
+ self.report_error("OpenCV (cv2) is required to reconstruct the image from slices")
326
+ return None
327
+
328
+ width = collection.get(NodeKeys.ORIGINAL_WIDTH, 0)
329
+ height = collection.get(NodeKeys.ORIGINAL_HEIGHT, 0)
330
+
331
+ if width <= 0 or height <= 0:
332
+ self.report_error("Cannot reconstruct image: unknown original dimensions")
333
+ return None
334
+
335
+ canvas = None
336
+ encode_format = None
337
+ tile_bounds: List[tuple] = []
338
+
339
+ # Place the full image (if any) first, then overlay tiles on top
340
+ ordered_slices = sorted(
341
+ collection[NodeKeys.SLICES].values(),
342
+ key=lambda s: 0 if s.get(NodeKeys.IS_FULL_IMAGE, False) else 1
343
+ )
344
+
345
+ for slice_data in ordered_slices:
346
+ img_payload = slice_data.get(NodeKeys.SLICE_IMAGE)
347
+ if img_payload is None:
348
+ continue
349
+
350
+ slice_img, fmt = self.decode_image(img_payload)
351
+ if slice_img is None:
352
+ continue
353
+
354
+ if encode_format is None:
355
+ encode_format = fmt
356
+
357
+ if canvas is None:
358
+ if slice_img.ndim == 3:
359
+ canvas = np.zeros((height, width, slice_img.shape[2]), dtype=slice_img.dtype)
360
+ else:
361
+ canvas = np.zeros((height, width), dtype=slice_img.dtype)
362
+
363
+ sh, sw = slice_img.shape[:2]
364
+ is_full_image = slice_data.get(NodeKeys.IS_FULL_IMAGE, False)
365
+
366
+ if is_full_image:
367
+ x1, y1 = 0, 0
368
+ else:
369
+ bbox = slice_data.get(NodeKeys.SLICE_BBOX)
370
+ if bbox and len(bbox) >= 4:
371
+ x1, y1 = int(bbox[0]), int(bbox[1])
372
+ else:
373
+ offset = slice_data.get(NodeKeys.OFFSET, [0, 0])
374
+ x1, y1 = int(offset[0]), int(offset[1])
375
+
376
+ x1 = max(0, min(x1, width))
377
+ y1 = max(0, min(y1, height))
378
+ x2 = min(x1 + sw, width)
379
+ y2 = min(y1 + sh, height)
380
+
381
+ if x2 <= x1 or y2 <= y1:
382
+ continue
383
+
384
+ canvas[y1:y2, x1:x2] = slice_img[0:(y2 - y1), 0:(x2 - x1)]
385
+
386
+ # Record tile bounds for optional overlay (exclude the full image)
387
+ if not is_full_image:
388
+ tile_bounds.append((x1, y1, x2, y2))
389
+
390
+ if canvas is None:
391
+ return None
392
+
393
+ # Optionally draw the slice bounds so the overlap is visible
394
+ if self.get_config_bool(NodeKeys.DRAW_SLICE_BOUNDS, self.DEFAULT_CONFIG[NodeKeys.DRAW_SLICE_BOUNDS]):
395
+ self._draw_slice_bounds(canvas, tile_bounds)
396
+
397
+ return self.encode_image(canvas, encode_format)
398
+
399
+ def _draw_slice_bounds(self, canvas: Any, tile_bounds: List[tuple]):
400
+ """
401
+ Draw a rectangle for each slice on the reconstructed canvas so the user
402
+ can see where each tile sits and how much adjacent tiles overlap.
403
+ """
404
+ if not _HAS_CV2 or not tile_bounds:
405
+ return
406
+
407
+ # Cycle through distinct colors (BGR) so adjacent tiles are easy to tell apart
408
+ colors = [
409
+ (0, 255, 0), # green
410
+ (0, 0, 255), # red
411
+ (255, 0, 0), # blue
412
+ (0, 255, 255), # yellow
413
+ (255, 0, 255), # magenta
414
+ (255, 255, 0), # cyan
415
+ ]
416
+
417
+ # Scale line thickness with image size for visibility on large images
418
+ max_dim = max(canvas.shape[0], canvas.shape[1])
419
+ thickness = max(1, max_dim // 500)
420
+
421
+ for i, (x1, y1, x2, y2) in enumerate(tile_bounds):
422
+ color = colors[i % len(colors)]
423
+ # Inset by half the thickness so edge tiles' borders stay visible
424
+ cv2.rectangle( # type: ignore[union-attr]
425
+ canvas,
426
+ (int(x1), int(y1)),
427
+ (int(x2) - 1, int(y2) - 1),
428
+ color,
429
+ thickness
430
+ )
431
+
432
+ def _handle_array(self, msg: Dict[str, Any], slices: List[Any]):
433
+ """
434
+ Handle 'array' output mode from SliceImageNode where every slice arrives
435
+ together in a single message as a list. All slices are available at once,
436
+ so the collection is built and merged immediately (no buffering/timeout).
437
+ """
438
+ collection: Dict[str, Any] = {
439
+ NodeKeys.SLICES: {},
440
+ NodeKeys.ORIGINAL_WIDTH: msg.get(NodeKeys.ORIGINAL_WIDTH, 0),
441
+ NodeKeys.ORIGINAL_HEIGHT: msg.get(NodeKeys.ORIGINAL_HEIGHT, 0),
442
+ MessageKeys.IMAGE.PATH: None
443
+ }
444
+
445
+ for i, item in enumerate(slices):
446
+ if not isinstance(item, dict):
447
+ continue
448
+
449
+ item_payload = item.get(MessageKeys.PAYLOAD, {})
450
+ if not isinstance(item_payload, dict):
451
+ item_payload = {}
452
+
453
+ offset = item.get(NodeKeys.OFFSET, item_payload.get(NodeKeys.OFFSET, [0, 0]))
454
+ slice_bbox = item.get(NodeKeys.BBOX,
455
+ item.get(NodeKeys.SLICE_BBOX, item_payload.get(NodeKeys.BBOX)))
456
+ is_full_image = item.get(NodeKeys.IS_FULL_IMAGE, item_payload.get(NodeKeys.IS_FULL_IMAGE, False))
457
+ slice_index = item.get(NodeKeys.SLICE_INDEX, i)
458
+ detections = item_payload.get(MessageKeys.CV.DETECTIONS,
459
+ item.get(MessageKeys.CV.DETECTIONS, []))
460
+ image = item_payload.get(MessageKeys.IMAGE.PATH, item.get(MessageKeys.IMAGE.PATH))
461
+
462
+ ow = item.get(NodeKeys.ORIGINAL_WIDTH, item_payload.get(NodeKeys.ORIGINAL_WIDTH, 0))
463
+ oh = item.get(NodeKeys.ORIGINAL_HEIGHT, item_payload.get(NodeKeys.ORIGINAL_HEIGHT, 0))
464
+ if ow > 0:
465
+ collection[NodeKeys.ORIGINAL_WIDTH] = ow
466
+ if oh > 0:
467
+ collection[NodeKeys.ORIGINAL_HEIGHT] = oh
468
+
469
+ collection[NodeKeys.SLICES][slice_index] = {
470
+ MessageKeys.CV.DETECTIONS: detections,
471
+ NodeKeys.OFFSET: offset,
472
+ NodeKeys.SLICE_BBOX: slice_bbox,
473
+ NodeKeys.SLICE_IMAGE: image,
474
+ NodeKeys.IS_FULL_IMAGE: is_full_image
475
+ }
476
+
477
+ if is_full_image and image is not None:
478
+ collection[MessageKeys.IMAGE.PATH] = image
479
+
480
+ if not collection[NodeKeys.SLICES]:
481
+ self.report_error("No slices found in array-mode message")
482
+ return
483
+
484
+ result = self._merge_collection(collection)
485
+ if result:
486
+ out_msg = msg.copy()
487
+ out_msg[MessageKeys.PAYLOAD] = result
488
+ out_msg[MessageKeys.TOPIC] = msg.get(MessageKeys.TOPIC, 'merged_predictions')
489
+ out_msg.pop('parts', None)
490
+ self.send(out_msg)
275
491
 
276
492
  def on_input(self, msg: Dict[str, Any], input_index: int = 0):
277
493
  """
278
494
  Collect slice predictions and merge when complete.
279
495
 
280
- Expected msg format (from Split of SliceImageNode output, processed by YOLO):
496
+ Works seamlessly with either SliceImageNode output mode:
497
+
498
+ - 'array' mode: all slices arrive together in one message where
499
+ 'payload' is a list of slice dicts. Merged immediately.
500
+ - 'split' mode: each slice arrives as its own message (buffered by
501
+ parent id via 'parts', merged once all slices are collected).
502
+
503
+ Expected 'split' msg format (from Split of SliceImageNode, processed by YOLO):
281
504
  {
282
505
  'payload': {
283
506
  'image': ...,
@@ -299,6 +522,14 @@ class SliceCollectorNode(BaseNode):
299
522
  self._cleanup_expired()
300
523
 
301
524
  payload = msg.get(MessageKeys.PAYLOAD, {})
525
+
526
+ # Auto-detect output mode from SliceImageNode:
527
+ # - 'array' mode: payload is a list of all slices in one message
528
+ # - 'split' mode: payload is a single slice dict across many messages
529
+ if isinstance(payload, list):
530
+ self._handle_array(msg, payload)
531
+ return
532
+
302
533
  parts = msg.get('parts', {})
303
534
 
304
535
  # Get collection ID (from parts or generate one)
@@ -306,9 +537,14 @@ class SliceCollectorNode(BaseNode):
306
537
  expected_count = parts.get('count', 1)
307
538
  slice_index = parts.get('index', msg.get(NodeKeys.SLICE_INDEX, 0))
308
539
 
309
- # Get slice metadata (check message level first, then payload)
310
- offset = msg.get( NodeKeys.OFFSET, payload.get(NodeKeys.OFFSET, [0, 0]) if isinstance(payload, dict) else [0, 0])
311
- slice_bbox = msg.get(NodeKeys.BBOX, payload.get(NodeKeys.BBOX, None) if isinstance(payload, dict) else None)
540
+ # Get slice metadata (check message level first, then payload).
541
+ # SliceImageNode emits these at message level as 'slice_offset' / 'slice_bbox'.
542
+ offset = msg.get(NodeKeys.SLICE_OFFSET,
543
+ msg.get(NodeKeys.OFFSET,
544
+ payload.get(NodeKeys.OFFSET, [0, 0]) if isinstance(payload, dict) else [0, 0]))
545
+ slice_bbox = msg.get(NodeKeys.SLICE_BBOX,
546
+ msg.get(NodeKeys.BBOX,
547
+ payload.get(NodeKeys.BBOX, None) if isinstance(payload, dict) else None))
312
548
  is_full_image = msg.get(NodeKeys.IS_FULL_IMAGE, payload.get(NodeKeys.IS_FULL_IMAGE, False) if isinstance(payload, dict) else False)
313
549
  original_width = msg.get(NodeKeys.ORIGINAL_WIDTH, payload.get(NodeKeys.ORIGINAL_WIDTH, 0) if isinstance(payload, dict) else 0)
314
550
  original_height = msg.get(NodeKeys.ORIGINAL_HEIGHT, payload.get(NodeKeys.ORIGINAL_HEIGHT, 0) if isinstance(payload, dict) else 0)
@@ -339,6 +575,8 @@ class SliceCollectorNode(BaseNode):
339
575
  collection[NodeKeys.SLICES][slice_index] = {
340
576
  MessageKeys.CV.DETECTIONS: detections,
341
577
  NodeKeys.OFFSET: offset,
578
+ NodeKeys.SLICE_BBOX: slice_bbox,
579
+ NodeKeys.SLICE_IMAGE: image,
342
580
  NodeKeys.IS_FULL_IMAGE: is_full_image
343
581
  }
344
582
 
@@ -26,7 +26,9 @@ _info.add_header("Configuration")
26
26
  _info.add_bullets(
27
27
  ("Output Mode:", "Array (all slices in one message) or Split (separate messages)"),
28
28
  ("Auto Slice:", "Automatically calculate slice size based on image resolution"),
29
- ("Slice Width/Height:", "Manual slice dimensions in pixels"),
29
+ ("Slice By:", "Tile Size (pixels) or Tile Count (nx, ny)"),
30
+ ("Slice Width/Height:", "Manual slice dimensions in pixels (Tile Size mode)"),
31
+ ("Num Slices X/Y:", "Number of columns/rows of tiles (Tile Count mode)"),
30
32
  ("Overlap Ratios:", "Overlap between adjacent slices (0-1)"),
31
33
  ("Include Full Image:", "Also include the original full image in output"),
32
34
  )
@@ -53,6 +55,9 @@ class SliceImageNode(BaseNode):
53
55
  DEFAULT_CONFIG = {
54
56
  'slice_width': 640,
55
57
  'slice_height': 640,
58
+ 'num_slices_x': 2,
59
+ 'num_slices_y': 2,
60
+ 'slice_mode': 'size',
56
61
  'overlap_width_ratio': 0.2,
57
62
  'overlap_height_ratio': 0.2,
58
63
  'auto_slice': False,
@@ -79,19 +84,44 @@ class SliceImageNode(BaseNode):
79
84
  'type': 'checkbox',
80
85
  'default': False
81
86
  },
87
+ {
88
+ 'name': 'slice_mode',
89
+ 'label': 'Slice By',
90
+ 'type': 'select',
91
+ 'options': [
92
+ {'value': 'size', 'label': 'Tile Size (pixels)'},
93
+ {'value': 'count', 'label': 'Tile Count (nx, ny)'}
94
+ ],
95
+ 'default': 'size',
96
+ 'showIf': {'auto_slice': False}
97
+ },
82
98
  {
83
99
  'name': 'slice_width',
84
100
  'label': 'Slice Width (pixels)',
85
101
  'type': 'number',
86
102
  'default': 640,
87
- 'showIf': {'auto_slice': False}
103
+ 'showIf': {'auto_slice': False, 'slice_mode': 'size'}
88
104
  },
89
105
  {
90
106
  'name': 'slice_height',
91
107
  'label': 'Slice Height (pixels)',
92
108
  'type': 'number',
93
109
  'default': 640,
94
- 'showIf': {'auto_slice': False}
110
+ 'showIf': {'auto_slice': False, 'slice_mode': 'size'}
111
+ },
112
+ {
113
+ 'name': 'num_slices_x',
114
+ 'label': 'Num Slices X (columns)',
115
+ 'type': 'number',
116
+ 'default': 2,
117
+ 'showIf': {'auto_slice': False, 'slice_mode': 'count'}
118
+ },
119
+ {
120
+ 'name': 'num_slices_y',
121
+ 'label': 'Num Slices Y (rows)',
122
+ 'type': 'number',
123
+ 'default': 2,
124
+ 'showIf': {'auto_slice': False, 'slice_mode': 'count'}
95
125
  },
96
126
  {
97
127
  'name': 'overlap_width_ratio',
@@ -231,6 +261,65 @@ class SliceImageNode(BaseNode):
231
261
 
232
262
  return slice_bboxes
233
263
 
264
+ def _get_slice_bboxes_by_count(
265
+ self,
266
+ image_height: int,
267
+ image_width: int,
268
+ num_slices_x: int,
269
+ num_slices_y: int,
270
+ overlap_height_ratio: float,
271
+ overlap_width_ratio: float
272
+ ) -> List[List[int]]:
273
+ """
274
+ Generate slice bounding boxes for a fixed grid of nx x ny tiles.
275
+
276
+ Tile size is derived from the requested overlap ratio so that adjacent
277
+ tiles overlap by that fraction, while guaranteeing exactly
278
+ num_slices_x * num_slices_y tiles that together cover the whole image.
279
+
280
+ Returns list of [x_min, y_min, x_max, y_max] for each slice.
281
+ """
282
+ if overlap_height_ratio >= 1.0 or overlap_width_ratio >= 1.0:
283
+ raise ValueError("Overlap ratio must be less than 1.0")
284
+
285
+ nx = max(1, int(num_slices_x))
286
+ ny = max(1, int(num_slices_y))
287
+
288
+ x_starts, slice_width = self._axis_starts(image_width, nx, overlap_width_ratio)
289
+ y_starts, slice_height = self._axis_starts(image_height, ny, overlap_height_ratio)
290
+
291
+ slice_bboxes = []
292
+ for y_min in y_starts:
293
+ for x_min in x_starts:
294
+ slice_bboxes.append([
295
+ x_min,
296
+ y_min,
297
+ min(x_min + slice_width, image_width),
298
+ min(y_min + slice_height, image_height)
299
+ ])
300
+
301
+ return slice_bboxes
302
+
303
+ def _axis_starts(self, length: int, count: int, overlap_ratio: float) -> Tuple[List[int], int]:
304
+ """
305
+ Compute evenly spaced tile start positions along one axis.
306
+
307
+ Returns (list of start coordinates, tile size in pixels) for ``count``
308
+ tiles overlapping by ``overlap_ratio`` that span ``length`` pixels.
309
+ """
310
+ if count <= 1:
311
+ return [0], length
312
+
313
+ # size such that: (count-1) * size * (1 - overlap) + size = length
314
+ denom = (count - 1) * (1.0 - overlap_ratio) + 1.0
315
+ slice_size = int(round(length / denom)) if denom > 0 else length
316
+ slice_size = max(1, min(slice_size, length))
317
+
318
+ last_start = length - slice_size
319
+ starts = [int(round(i * last_start / (count - 1))) for i in range(count)]
320
+
321
+ return starts, slice_size
322
+
234
323
  def _slice_image(self, image: np.ndarray) -> List[Dict[str, Any]]:
235
324
  """
236
325
  Slice the image into tiles based on configuration.
@@ -253,21 +342,41 @@ class SliceImageNode(BaseNode):
253
342
  }]
254
343
  overlap_width_ratio = x_overlap / slice_width if slice_width > 0 else 0.2
255
344
  overlap_height_ratio = y_overlap / slice_height if slice_height > 0 else 0.2
345
+ slice_bboxes = self._get_slice_bboxes(
346
+ image_height=height,
347
+ image_width=width,
348
+ slice_height=slice_height,
349
+ slice_width=slice_width,
350
+ overlap_height_ratio=overlap_height_ratio,
351
+ overlap_width_ratio=overlap_width_ratio
352
+ )
256
353
  else:
257
- slice_width = self.get_config_int('slice_width', 640)
258
- slice_height = self.get_config_int('slice_height', 640)
354
+ slice_mode = self.config.get('slice_mode', 'size')
259
355
  overlap_width_ratio = self.get_config_float('overlap_width_ratio', 0.2)
260
356
  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
- )
357
+
358
+ if slice_mode == 'count':
359
+ num_slices_x = self.get_config_int('num_slices_x', 2)
360
+ num_slices_y = self.get_config_int('num_slices_y', 2)
361
+ slice_bboxes = self._get_slice_bboxes_by_count(
362
+ image_height=height,
363
+ image_width=width,
364
+ num_slices_x=num_slices_x,
365
+ num_slices_y=num_slices_y,
366
+ overlap_height_ratio=overlap_height_ratio,
367
+ overlap_width_ratio=overlap_width_ratio
368
+ )
369
+ else:
370
+ slice_width = self.get_config_int('slice_width', 640)
371
+ slice_height = self.get_config_int('slice_height', 640)
372
+ slice_bboxes = self._get_slice_bboxes(
373
+ image_height=height,
374
+ image_width=width,
375
+ slice_height=slice_height,
376
+ slice_width=slice_width,
377
+ overlap_height_ratio=overlap_height_ratio,
378
+ overlap_width_ratio=overlap_width_ratio
379
+ )
271
380
 
272
381
  slices = []
273
382
  for bbox in slice_bboxes:
@@ -27,7 +27,9 @@ _info.add_bullets(
27
27
  _info.add_header("Transport Controls")
28
28
  _info.add_bullets(
29
29
  ("⏮ Step back:", "Show the previous frame (while paused/stopped)."),
30
- ("⏯ Play/Pause:", "Toggle playback from the current position."),
30
+ ("▶/⏸ Play/Pause:", "Toggle playback from the current position. The icon "
31
+ "shows what the next click will do: ▶ while paused/"
32
+ "stopped, ⏸ while playing."),
31
33
  ("⏹ Stop:", "Halt playback and seek back to the first frame."),
32
34
  ("⏭ Step forward:", "Show the next frame (while paused/stopped)."),
33
35
  ("Progress bar:", "Drag the slider along the bottom of the node to scrub "
@@ -76,7 +78,11 @@ class VideoReaderNode(BaseNode):
76
78
  ui_component_config = {
77
79
  'buttons': [
78
80
  {'icon': '⏮', 'action': 'step_prev', 'title': 'Step back one frame'},
79
- {'icon': '⏯', 'action': 'play_pause', 'title': 'Play / Pause'},
81
+ # activeIcon/activeTitle are swapped in by the frontend while the
82
+ # node reports playing=true, so the button always shows the action
83
+ # the next click will take.
84
+ {'icon': '▶', 'action': 'play_pause', 'title': 'Play',
85
+ 'activeIcon': '⏸', 'activeTitle': 'Pause'},
80
86
  {'icon': '⏹', 'action': 'stop', 'title': 'Stop (back to first frame)'},
81
87
  {'icon': '⏭', 'action': 'step_next', 'title': 'Step forward one frame'},
82
88
  ],
@@ -89,6 +89,7 @@ export function updateCounterDisplay(nodeId, displayText) {
89
89
  export function updateVideoPosition(nodeId, data) {
90
90
  const posEl = document.getElementById(`transport-pos-${nodeId}`);
91
91
  const sliderEl = document.getElementById(`transport-progress-${nodeId}`);
92
+ const playBtn = document.getElementById(`transport-btn-${nodeId}-play_pause`);
92
93
  const total = data.total > 0 ? data.total : 0;
93
94
  const frame = data.frame ?? 0;
94
95
  if (posEl) {
@@ -107,6 +108,18 @@ export function updateVideoPosition(nodeId, data) {
107
108
  sliderEl.max = total > 0 ? total - 1 : 0;
108
109
  sliderEl.value = frame;
109
110
  }
111
+ // Show the action the next click will take: pause while playing, play
112
+ // while paused/stopped. The icons come from the node's button config
113
+ // (see the transport-controls branch in nodes.js).
114
+ if (playBtn && playBtn.dataset.activeIcon) {
115
+ const playing = !!data.playing;
116
+ const icon = playing ? playBtn.dataset.activeIcon : playBtn.dataset.idleIcon;
117
+ const title = playing ? playBtn.dataset.activeTitle : playBtn.dataset.idleTitle;
118
+ if (playBtn.textContent !== icon) {
119
+ playBtn.textContent = icon;
120
+ }
121
+ playBtn.title = title;
122
+ }
110
123
  }
111
124
 
112
125
  export function updateImageViewer(nodeId, frameData) {