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,519 @@
1
+ """
2
+ Qwen3-VL Vision Language Model node.
3
+ Performs vision-language inference on images using the Qwen3-VL model.
4
+
5
+ Supports two modes:
6
+ - Generic: Answer any text prompt about an image.
7
+ - Detection: Locate objects and return structured detections compatible with
8
+ downstream YOLO-style nodes (requires the `supervision` package).
9
+ """
10
+
11
+ import logging
12
+ from typing import Any, Dict, List, Optional
13
+
14
+ import numpy as np
15
+
16
+ from pynode.nodes.base_node import BaseNode, Info, MessageKeys
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ # ---------------------------------------------------------------------------
21
+ # Build node info/help content
22
+ # ---------------------------------------------------------------------------
23
+ _info = Info()
24
+ _info.add_text(
25
+ "Runs the Qwen3-VL vision language model against an incoming image and "
26
+ "produces either a text response (Generic mode) or structured object "
27
+ "detections (Detection mode)."
28
+ )
29
+ _info.add_header("Inputs")
30
+ _info.add_bullet("Input 0:", "Image message (same format as CameraNode / UltralyticsNode)")
31
+ _info.add_header("Outputs")
32
+ _info.add_bullet("Output 0 – Generic:", "payload.text with the model's answer")
33
+ _info.add_bullet(
34
+ "Output 0 – Detection:",
35
+ "payload.detections, payload.detection_count, payload.bbox_format, "
36
+ "and optionally payload.image (annotated)"
37
+ )
38
+ _info.add_header("Properties")
39
+ _info.add_bullet("Model:", "Hugging Face model ID (e.g. Qwen/Qwen3-VL-2B-Instruct)")
40
+ _info.add_bullet("Device:", "cpu or cuda:N")
41
+ _info.add_bullet("Mode:", "Generic (free-form Q&A) or Detection (object localisation)")
42
+ _info.add_bullet("Prompt:", "Question in Generic mode, or object-class target in Detection mode")
43
+ _info.add_bullet("Max Tokens:", "Maximum tokens generated by the model")
44
+ _info.add_bullet("Draw Results:", "Draw bounding boxes on output image (Detection mode only)")
45
+
46
+
47
+ class Qwen3VLMNode(BaseNode):
48
+ """
49
+ Qwen3-VL Vision Language Model node.
50
+
51
+ Generic mode – sends the configured prompt alongside the image and emits
52
+ the model's text response as ``payload.text``.
53
+
54
+ Detection mode – instructs the model to outline all instances of the
55
+ given target class, parses the JSON bounding-box response
56
+ via ``supervision`` and emits detections in the same
57
+ schema as UltralyticsNode for pipeline compatibility.
58
+ Requires the ``supervision`` package (>=0.25.0).
59
+ """
60
+
61
+ # ------------------------------------------------------------------
62
+ # Visual / palette properties
63
+ # ------------------------------------------------------------------
64
+ display_name = "Qwen3-VL"
65
+ icon = "🧠"
66
+ category = "vision"
67
+ color = "#7C3AED"
68
+ border_color = "#5B21B6"
69
+ text_color = "#FFFFFF"
70
+ input_count = 1
71
+ output_count = 1
72
+ info = str(_info)
73
+
74
+ # ------------------------------------------------------------------
75
+ # Default configuration
76
+ # ------------------------------------------------------------------
77
+ DEFAULT_CONFIG: Dict[str, Any] = {
78
+ "model_name": "Qwen/Qwen3-VL-2B-Instruct",
79
+ "hf_token": "",
80
+ "mode": "generic",
81
+ "prompt": "Describe this image in detail.",
82
+ "max_tokens": "512",
83
+ "draw_results": "true",
84
+ "include_image": True,
85
+ }
86
+
87
+ # ------------------------------------------------------------------
88
+ # Dynamic device options
89
+ # ------------------------------------------------------------------
90
+ @staticmethod
91
+ def _get_device_options() -> List[Dict[str, str]]:
92
+ devices = [{"value": "cpu", "label": "CPU"}]
93
+ try:
94
+ import torch # type: ignore
95
+
96
+ if torch.cuda.is_available():
97
+ for i in range(torch.cuda.device_count()):
98
+ props = torch.cuda.get_device_properties(i)
99
+ mem_gb = props.total_memory / (1024 ** 3)
100
+ devices.append(
101
+ {
102
+ "value": f"cuda:{i}",
103
+ "label": f"CUDA:{i} – {props.name} ({mem_gb:.1f} GB)",
104
+ }
105
+ )
106
+ except Exception as exc:
107
+ logger.warning("Error detecting CUDA devices: %s", exc)
108
+ return devices
109
+
110
+ @staticmethod
111
+ def _default_device() -> str:
112
+ try:
113
+ import torch # type: ignore
114
+
115
+ return "cuda:0" if torch.cuda.is_available() else "cpu"
116
+ except ImportError:
117
+ return "cpu"
118
+
119
+ # ------------------------------------------------------------------
120
+ # Properties panel schema
121
+ # ------------------------------------------------------------------
122
+ @classmethod
123
+ def get_properties(cls) -> List[Dict[str, Any]]:
124
+ return [
125
+ {
126
+ "name": "model_name",
127
+ "label": "Model",
128
+ "type": "select",
129
+ "options": [
130
+ {"value": "Qwen/Qwen3-VL-2B-Instruct", "label": "Qwen3-VL 2B Instruct (fast)"},
131
+ {"value": "Qwen/Qwen3-VL-7B-Instruct", "label": "Qwen3-VL 7B Instruct"},
132
+ {"value": "Qwen/Qwen3-VL-72B-Instruct", "label": "Qwen3-VL 72B Instruct (large)"},
133
+ {"value": "Qwen/Qwen2.5-VL-3B-Instruct", "label": "Qwen2.5-VL 3B Instruct"},
134
+ {"value": "Qwen/Qwen2.5-VL-7B-Instruct", "label": "Qwen2.5-VL 7B Instruct"},
135
+ ],
136
+ },
137
+ {
138
+ "name": "hf_token",
139
+ "label": "HuggingFace Token",
140
+ "type": "password",
141
+ "placeholder": "hf_… (required for gated models)",
142
+ },
143
+ {
144
+ "name": "device",
145
+ "label": "Device",
146
+ "type": "select",
147
+ "options": cls._get_device_options(),
148
+ },
149
+ {
150
+ "name": "mode",
151
+ "label": "Mode",
152
+ "type": "select",
153
+ "options": [
154
+ {"value": "generic", "label": "Generic (Q&A / description)"},
155
+ {"value": "detection", "label": "Detection (object localisation)"},
156
+ ],
157
+ },
158
+ {
159
+ "name": "prompt",
160
+ "label": "Prompt / Detection Target",
161
+ "type": "textarea",
162
+ },
163
+ {
164
+ "name": "max_tokens",
165
+ "label": "Max Tokens",
166
+ "type": "text",
167
+ "placeholder": "512",
168
+ },
169
+ {
170
+ "name": "draw_results",
171
+ "label": "Draw Detections on Image",
172
+ "type": "select",
173
+ "options": [
174
+ {"value": "true", "label": "Yes"},
175
+ {"value": "false", "label": "No"},
176
+ ],
177
+ },
178
+ {
179
+ "name": "include_image",
180
+ "label": "Include Image in Output",
181
+ "type": "checkbox",
182
+ "default": True,
183
+ },
184
+ ]
185
+
186
+ properties = property(lambda self: self.get_properties()) # type: ignore[assignment]
187
+
188
+ # ------------------------------------------------------------------
189
+ # Lifecycle
190
+ # ------------------------------------------------------------------
191
+ def __init__(self, node_id: Optional[str] = None, name: str = "qwen3-vlm"):
192
+ super().__init__(node_id, name)
193
+ cfg = self.DEFAULT_CONFIG.copy()
194
+ cfg["device"] = self._default_device()
195
+ self.configure(cfg)
196
+
197
+ self._model = None
198
+ self._processor = None
199
+ self._loaded_model_name: Optional[str] = None
200
+
201
+ # ------------------------------------------------------------------
202
+ # Configure – reload model when model_name or device changes
203
+ # ------------------------------------------------------------------
204
+ def configure(self, config: Dict[str, Any]):
205
+ old_name = self.config.get("model_name") if hasattr(self, "config") else None
206
+ old_device = self.config.get("device") if hasattr(self, "config") else None
207
+ super().configure(config)
208
+ new_name = self.config.get("model_name")
209
+ new_device = self.config.get("device")
210
+
211
+ if (old_name != new_name or old_device != new_device) and old_name is not None:
212
+ self._model = None
213
+ self._processor = None
214
+ self._loaded_model_name = None
215
+
216
+ # ------------------------------------------------------------------
217
+ # Lazy model loading
218
+ # ------------------------------------------------------------------
219
+ def _load_model(self):
220
+ """Load (or reload) the Qwen3-VL model and processor."""
221
+ if self._model is not None:
222
+ return True
223
+
224
+ model_name = self.config.get("model_name", "Qwen/Qwen3-VL-2B-Instruct")
225
+ device = self.config.get("device", "cpu")
226
+
227
+ token = self.config.get("hf_token", "").strip() or None
228
+
229
+ try:
230
+ from transformers import AutoModelForImageTextToText, AutoProcessor # type: ignore
231
+
232
+ logger.info("Qwen3VLMNode: loading model '%s' …", model_name)
233
+ self._model = AutoModelForImageTextToText.from_pretrained(
234
+ model_name, torch_dtype="auto", device_map=device, token=token
235
+ )
236
+ self._processor = AutoProcessor.from_pretrained(model_name, token=token)
237
+ self._loaded_model_name = model_name
238
+ logger.info("Qwen3VLMNode: model loaded successfully.")
239
+ return True
240
+
241
+ except ImportError:
242
+ self.report_error(
243
+ "transformers package not installed. "
244
+ "Run: pip install transformers torch Pillow"
245
+ )
246
+ except Exception as exc:
247
+ self.report_error(f"Failed to load model '{model_name}': {exc}")
248
+
249
+ self._model = None
250
+ self._processor = None
251
+ return False
252
+
253
+ # ------------------------------------------------------------------
254
+ # Core inference helpers
255
+ # ------------------------------------------------------------------
256
+ def _run_inference(self, pil_image, prompt: str, max_tokens: int) -> Optional[str]:
257
+ """Run the model and return the decoded text response."""
258
+ messages = [
259
+ {
260
+ "role": "user",
261
+ "content": [
262
+ {"type": "image", "image": pil_image},
263
+ {"type": "text", "text": prompt},
264
+ ],
265
+ }
266
+ ]
267
+
268
+ try:
269
+ import torch # type: ignore
270
+
271
+ inputs = self._processor.apply_chat_template(
272
+ messages,
273
+ tokenize=True,
274
+ add_generation_prompt=True,
275
+ return_dict=True,
276
+ return_tensors="pt",
277
+ )
278
+ inputs = inputs.to(self._model.device)
279
+
280
+ with torch.inference_mode():
281
+ generated_ids = self._model.generate(
282
+ **inputs,
283
+ max_new_tokens=max_tokens,
284
+ )
285
+
286
+ trimmed = [
287
+ out_ids[len(in_ids):]
288
+ for in_ids, out_ids in zip(inputs.input_ids, generated_ids)
289
+ ]
290
+ return self._processor.batch_decode(
291
+ trimmed,
292
+ skip_special_tokens=True,
293
+ clean_up_tokenization_spaces=False,
294
+ )[0]
295
+
296
+ except Exception as exc:
297
+ self.report_error(f"Inference error: {exc}")
298
+ return None
299
+
300
+ # ------------------------------------------------------------------
301
+ # Detection mode
302
+ # ------------------------------------------------------------------
303
+ def _run_detection(
304
+ self, pil_image, target: str, max_tokens: int, draw: bool, include_image: bool
305
+ ) -> Optional[Dict[str, Any]]:
306
+ """
307
+ Run detection-mode inference.
308
+ Returns a payload dict compatible with downstream YOLO-style nodes,
309
+ or None on failure.
310
+ """
311
+ try:
312
+ import supervision as sv # type: ignore
313
+ except ImportError:
314
+ self.report_error(
315
+ "supervision package not installed. "
316
+ "Run: pip install supervision"
317
+ )
318
+ return None
319
+
320
+ prompt = (
321
+ f"Outline the position of {target} and output all the coordinates "
322
+ "in JSON format."
323
+ )
324
+ raw_response = self._run_inference(pil_image, prompt, max_tokens)
325
+ if raw_response is None:
326
+ return None
327
+
328
+ logger.debug("Qwen3VLMNode detection response: %s", raw_response)
329
+
330
+ try:
331
+ detections = sv.Detections.from_vlm(
332
+ vlm=sv.VLM.QWEN_3_VL,
333
+ result=raw_response,
334
+ resolution_wh=pil_image.size,
335
+ )
336
+ except Exception as exc:
337
+ self.report_error(f"Failed to parse detections: {exc}")
338
+ return None
339
+
340
+ # Convert sv.Detections to serialisable list (compatible with YOLO output)
341
+ det_list = []
342
+ for i in range(len(detections)):
343
+ entry: Dict[str, Any] = {
344
+ "class_id": int(detections.class_id[i]) if detections.class_id is not None else 0,
345
+ "class_name": (
346
+ detections.data.get("class_name", [target] * len(detections))[i]
347
+ if isinstance(detections.data.get("class_name"), (list, np.ndarray))
348
+ else target
349
+ ),
350
+ "confidence": (
351
+ float(detections.confidence[i])
352
+ if detections.confidence is not None
353
+ else 1.0
354
+ ),
355
+ "bbox": detections.xyxy[i].tolist(),
356
+ "bbox_format": "xyxy",
357
+ }
358
+ det_list.append(entry)
359
+
360
+ payload: Dict[str, Any] = {
361
+ "detections": det_list,
362
+ "detection_count": len(det_list),
363
+ "bbox_format": "xyxy",
364
+ "raw_response": raw_response,
365
+ }
366
+
367
+ if include_image:
368
+ # Annotate the image with supervision annotators
369
+ annotated_pil = self._annotate(pil_image, detections, draw)
370
+ # Convert PIL -> BGR numpy for encode_image
371
+ annotated_np = self._pil_to_bgr(annotated_pil)
372
+ # We encode to the same format the message arrived in; at this
373
+ # point we only have the numpy array so use bgr_numpy_dict.
374
+ encoded = self.encode_image(annotated_np, "bgr_numpy_dict")
375
+ if encoded is not None:
376
+ payload[MessageKeys.IMAGE.PATH] = encoded
377
+
378
+ return payload
379
+
380
+ @staticmethod
381
+ def _annotate(image, detections, draw: bool):
382
+ """Return annotated PIL image (or a copy if draw=False)."""
383
+ import supervision as sv # type: ignore
384
+
385
+ if not draw or len(detections) == 0:
386
+ return image.copy()
387
+
388
+ try:
389
+ text_scale = sv.calculate_optimal_text_scale(resolution_wh=image.size)
390
+ thickness = sv.calculate_optimal_line_thickness(resolution_wh=image.size)
391
+ color_by_class = detections.class_id is not None
392
+
393
+ color_palette = sv.ColorPalette.from_hex([
394
+ "#ffff00", "#ff9b00", "#ff66ff", "#3399ff",
395
+ "#ff66b2", "#ff8080", "#b266ff", "#9999ff",
396
+ "#66ffff", "#33ff99", "#66ff66", "#99ff00",
397
+ ])
398
+ lookup = sv.ColorLookup.CLASS if color_by_class else sv.ColorLookup.INDEX
399
+
400
+ box_ann = sv.BoxAnnotator(color=color_palette, color_lookup=lookup, thickness=thickness)
401
+ label_ann = sv.LabelAnnotator(
402
+ color=color_palette,
403
+ color_lookup=lookup,
404
+ text_color=sv.Color.BLACK,
405
+ text_scale=text_scale,
406
+ text_thickness=max(1, thickness - 1),
407
+ smart_position=True,
408
+ )
409
+
410
+ annotated = image.copy()
411
+ annotated = box_ann.annotate(annotated, detections)
412
+ annotated = label_ann.annotate(annotated, detections)
413
+ return annotated
414
+ except Exception as exc:
415
+ logger.warning("Annotation failed: %s", exc)
416
+ return image.copy()
417
+
418
+ # ------------------------------------------------------------------
419
+ # Generic mode
420
+ # ------------------------------------------------------------------
421
+ def _run_generic(
422
+ self, pil_image, prompt: str, max_tokens: int, include_image: bool,
423
+ encode_format: str
424
+ ) -> Optional[Dict[str, Any]]:
425
+ """
426
+ Run generic (Q&A / description) inference.
427
+ Returns a payload dict with ``text`` and optionally the original image.
428
+ """
429
+ response = self._run_inference(pil_image, prompt, max_tokens)
430
+ if response is None:
431
+ return None
432
+
433
+ payload: Dict[str, Any] = {
434
+ "text": response,
435
+ "prompt": prompt,
436
+ }
437
+
438
+ if include_image:
439
+ bgr = self._pil_to_bgr(pil_image)
440
+ encoded = self.encode_image(bgr, encode_format)
441
+ if encoded is not None:
442
+ payload[MessageKeys.IMAGE.PATH] = encoded
443
+
444
+ return payload
445
+
446
+ # ------------------------------------------------------------------
447
+ # Image conversion helpers
448
+ # ------------------------------------------------------------------
449
+ @staticmethod
450
+ def _bgr_to_pil(bgr_array: np.ndarray):
451
+ """Convert OpenCV BGR numpy array to PIL Image (RGB)."""
452
+ import cv2 # type: ignore
453
+ from PIL import Image # type: ignore
454
+
455
+ rgb = cv2.cvtColor(bgr_array, cv2.COLOR_BGR2RGB)
456
+ return Image.fromarray(rgb)
457
+
458
+ @staticmethod
459
+ def _pil_to_bgr(pil_image) -> np.ndarray:
460
+ """Convert PIL Image (RGB) to OpenCV BGR numpy array."""
461
+ import cv2 # type: ignore
462
+ import numpy as np # type: ignore
463
+
464
+ rgb_array = np.array(pil_image)
465
+ return cv2.cvtColor(rgb_array, cv2.COLOR_RGB2BGR)
466
+
467
+ # ------------------------------------------------------------------
468
+ # Message handler
469
+ # ------------------------------------------------------------------
470
+ def on_input(self, msg: Dict[str, Any], input_index: int = 0):
471
+ """
472
+ Process an incoming image message through the Qwen3-VL model.
473
+ """
474
+ # --- lazy model load ---
475
+ if self._model is None and not self._load_model():
476
+ return
477
+
478
+ # --- extract image ---
479
+ payload = msg.get(MessageKeys.PAYLOAD)
480
+ if payload is None:
481
+ self.report_error("No payload in message")
482
+ return
483
+
484
+ bgr_image, input_format = self.decode_image(payload)
485
+ if bgr_image is None or input_format is None:
486
+ self.report_error("Failed to decode image from payload")
487
+ return
488
+
489
+ pil_image = self._bgr_to_pil(bgr_image)
490
+
491
+ # --- read config ---
492
+ mode = self.config.get("mode", "generic")
493
+ prompt = self.config.get("prompt", "Describe this image in detail.")
494
+ max_tokens = self.get_config_int("max_tokens", 512)
495
+ draw_results = self.get_config_bool("draw_results", True)
496
+ include_image = self.config.get("include_image", True)
497
+ if isinstance(include_image, str):
498
+ include_image = include_image.lower() == "true"
499
+
500
+ # --- run inference ---
501
+ try:
502
+ if mode == "detection":
503
+ out_payload = self._run_detection(
504
+ pil_image, prompt, max_tokens, draw_results, include_image
505
+ )
506
+ else:
507
+ out_payload = self._run_generic(
508
+ pil_image, prompt, max_tokens, include_image, input_format
509
+ )
510
+ except Exception as exc:
511
+ self.report_error(f"Unexpected error during inference: {exc}")
512
+ return
513
+
514
+ if out_payload is None:
515
+ return
516
+
517
+ msg[MessageKeys.PAYLOAD] = out_payload
518
+ msg[MessageKeys.TOPIC] = msg.get(MessageKeys.TOPIC, "qwen3-vlm")
519
+ self.send(msg)
@@ -0,0 +1,5 @@
1
+ transformers>=4.50.0
2
+ torch>=2.0.0
3
+ Pillow>=9.0.0
4
+ supervision>=0.25.0
5
+ qwen-vl-utils>=0.0.8
@@ -0,0 +1,3 @@
1
+ from .rest_endpoint_node import RESTEndpointNode
2
+
3
+ __all__ = ['RESTEndpointNode']