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.
- pynode/__init__.py +37 -0
- pynode/__main__.py +9 -0
- pynode/_perf.py +65 -0
- pynode/_version.py +24 -0
- pynode/api/__init__.py +15 -0
- pynode/api/helpers.py +79 -0
- pynode/api/nodes.py +423 -0
- pynode/api/services.py +124 -0
- pynode/api/sse.py +58 -0
- pynode/api/uploads.py +59 -0
- pynode/api/workflows.py +466 -0
- pynode/config.py +93 -0
- pynode/install_nodes.py +143 -0
- pynode/main.py +118 -0
- pynode/node_registry.py +156 -0
- pynode/nodes/AutoSyncNode/auto_sync_node.py +151 -0
- pynode/nodes/BBoxMetricsNode/__init__.py +3 -0
- pynode/nodes/BBoxMetricsNode/bbox_metrics_node.py +250 -0
- pynode/nodes/BBoxMetricsNode/requirements.txt +1 -0
- pynode/nodes/BatchNode/__init__.py +3 -0
- pynode/nodes/BatchNode/batch_node.py +86 -0
- pynode/nodes/BatchNode/requirements.txt +1 -0
- pynode/nodes/CameraNode/__init__.py +4 -0
- pynode/nodes/CameraNode/camera_node.py +204 -0
- pynode/nodes/CameraNode/requirements.txt +2 -0
- pynode/nodes/ChangeNode/__init__.py +1 -0
- pynode/nodes/ChangeNode/change_node.py +255 -0
- pynode/nodes/ChangeNode/requirements.txt +1 -0
- pynode/nodes/ConfidenceFilterNode/__init__.py +4 -0
- pynode/nodes/ConfidenceFilterNode/confidence_filter_node.py +208 -0
- pynode/nodes/ConfidenceFilterNode/requirements.txt +1 -0
- pynode/nodes/CounterNode/__init__.py +3 -0
- pynode/nodes/CounterNode/counter_node.py +191 -0
- pynode/nodes/CropNode/__init__.py +4 -0
- pynode/nodes/CropNode/crop_node.py +317 -0
- pynode/nodes/CropNode/requirements.txt +2 -0
- pynode/nodes/DebugNode/__init__.py +4 -0
- pynode/nodes/DebugNode/debug_node.py +169 -0
- pynode/nodes/DebugNode/requirements.txt +1 -0
- pynode/nodes/DelayNode/delay_node.py +218 -0
- pynode/nodes/DelayNode/requirements.txt +1 -0
- pynode/nodes/DrawPredictionsNode/__init__.py +3 -0
- pynode/nodes/DrawPredictionsNode/draw_predictions_node.py +365 -0
- pynode/nodes/DrawPredictionsNode/requirements.txt +2 -0
- pynode/nodes/ErrorNode/error_node.py +94 -0
- pynode/nodes/ErrorNode/requirements.txt +1 -0
- pynode/nodes/FilterNode/__init__.py +3 -0
- pynode/nodes/FilterNode/filter_node.py +102 -0
- pynode/nodes/FilterNode/requirements.txt +1 -0
- pynode/nodes/FrameSourceNode/__init__.py +4 -0
- pynode/nodes/FrameSourceNode/frame_source_node.py +319 -0
- pynode/nodes/FrameSourceNode/requirements.txt +1 -0
- pynode/nodes/FunctionNode/__init__.py +3 -0
- pynode/nodes/FunctionNode/function_node.py +168 -0
- pynode/nodes/FunctionNode/requirements.txt +1 -0
- pynode/nodes/GateNode/gate_node.py +60 -0
- pynode/nodes/GateNode/requirements.txt +1 -0
- pynode/nodes/GetiUploadNode/__init__.py +3 -0
- pynode/nodes/GetiUploadNode/geti_upload_node.py +278 -0
- pynode/nodes/GetiUploadNode/requirements.txt +1 -0
- pynode/nodes/ImageFormatNode/__init__.py +4 -0
- pynode/nodes/ImageFormatNode/image_format_node.py +310 -0
- pynode/nodes/ImageFormatNode/requirements.txt +2 -0
- pynode/nodes/ImageUploadNode/image_upload_node.py +96 -0
- pynode/nodes/ImageViewerNode/__init__.py +4 -0
- pynode/nodes/ImageViewerNode/image_viewer_node.py +162 -0
- pynode/nodes/ImageViewerNode/requirements.txt +2 -0
- pynode/nodes/ImageWriterNode/__init__.py +3 -0
- pynode/nodes/ImageWriterNode/imagewriter_node.py +352 -0
- pynode/nodes/ImageWriterNode/requirements.txt +1 -0
- pynode/nodes/InferenceNode/InferenceEngine/__init__.py +15 -0
- pynode/nodes/InferenceNode/InferenceEngine/device_detection.py +149 -0
- pynode/nodes/InferenceNode/InferenceEngine/engines/base_engine.py +155 -0
- pynode/nodes/InferenceNode/InferenceEngine/engines/example_engine_template.py +272 -0
- pynode/nodes/InferenceNode/InferenceEngine/engines/geti_engine.py +309 -0
- pynode/nodes/InferenceNode/InferenceEngine/engines/onnx_engine.py +346 -0
- pynode/nodes/InferenceNode/InferenceEngine/engines/pass_engine.py +86 -0
- pynode/nodes/InferenceNode/InferenceEngine/engines/ultralytics_engine.py +863 -0
- pynode/nodes/InferenceNode/InferenceEngine/inference_engine_factory.py +533 -0
- pynode/nodes/InferenceNode/InferenceEngine/readme.md +107 -0
- pynode/nodes/InferenceNode/InferenceEngine/result_converters.py +121 -0
- pynode/nodes/InferenceNode/inference_node.py +451 -0
- pynode/nodes/InferenceNode/requirements.txt +13 -0
- pynode/nodes/InjectNode/inject_node.py +245 -0
- pynode/nodes/InjectNode/requirements.txt +1 -0
- pynode/nodes/JoinNode/__init__.py +3 -0
- pynode/nodes/JoinNode/join_node.py +156 -0
- pynode/nodes/JoinNode/requirements.txt +1 -0
- pynode/nodes/LabelFilterNode/__init__.py +3 -0
- pynode/nodes/LabelFilterNode/label_filter_node.py +188 -0
- pynode/nodes/LabelFilterNode/requirements.txt +1 -0
- pynode/nodes/MQTTNode/__init__.py +8 -0
- pynode/nodes/MQTTNode/mqtt_in_node.py +148 -0
- pynode/nodes/MQTTNode/mqtt_out_node.py +157 -0
- pynode/nodes/MQTTNode/mqtt_service.py +367 -0
- pynode/nodes/MQTTNode/requirements.txt +1 -0
- pynode/nodes/MessageWriterNode/__init__.py +7 -0
- pynode/nodes/MessageWriterNode/messagereader_node.py +568 -0
- pynode/nodes/MessageWriterNode/messagewriter_node.py +505 -0
- pynode/nodes/OmronCameraNode/__init__.py +3 -0
- pynode/nodes/OmronCameraNode/omron_camera_node.py +652 -0
- pynode/nodes/OmronCameraNode/requirements.txt +4 -0
- pynode/nodes/OpenCV/__init__.py +1 -0
- pynode/nodes/OpenCV/arithmetic_node.py +376 -0
- pynode/nodes/OpenCV/bitwise_node.py +127 -0
- pynode/nodes/OpenCV/blend_node.py +127 -0
- pynode/nodes/OpenCV/blob_detector_node.py +233 -0
- pynode/nodes/OpenCV/blur_node.py +141 -0
- pynode/nodes/OpenCV/color_space_node.py +139 -0
- pynode/nodes/OpenCV/colormap_node.py +262 -0
- pynode/nodes/OpenCV/contours_node.py +258 -0
- pynode/nodes/OpenCV/denormalize_coords_node.py +365 -0
- pynode/nodes/OpenCV/draw_node.py +312 -0
- pynode/nodes/OpenCV/edge_detector_node.py +177 -0
- pynode/nodes/OpenCV/equirectangular_node.py +447 -0
- pynode/nodes/OpenCV/fft_node.py +102 -0
- pynode/nodes/OpenCV/focus_estimation_node.py +189 -0
- pynode/nodes/OpenCV/frequency_filter_node.py +241 -0
- pynode/nodes/OpenCV/histogram_node.py +160 -0
- pynode/nodes/OpenCV/hough_circles_node.py +224 -0
- pynode/nodes/OpenCV/hough_lines_node.py +243 -0
- pynode/nodes/OpenCV/in_range_node.py +186 -0
- pynode/nodes/OpenCV/morphology_node.py +136 -0
- pynode/nodes/OpenCV/normalize_coords_node.py +344 -0
- pynode/nodes/OpenCV/paste_node.py +244 -0
- pynode/nodes/OpenCV/perspective_node.py +174 -0
- pynode/nodes/OpenCV/realsense_depth_node.py +253 -0
- pynode/nodes/OpenCV/requirements.txt +2 -0
- pynode/nodes/OpenCV/resize_node.py +175 -0
- pynode/nodes/OpenCV/rotate_node.py +159 -0
- pynode/nodes/OpenCV/template_match_node.py +227 -0
- pynode/nodes/OpenCV/threshold_node.py +167 -0
- pynode/nodes/PointInShapeNode/__init__.py +3 -0
- pynode/nodes/PointInShapeNode/point_in_shape_node.py +313 -0
- pynode/nodes/PointInShapeNode/requirements.txt +2 -0
- pynode/nodes/PolygonMetricsNode/__init__.py +3 -0
- pynode/nodes/PolygonMetricsNode/polygon_metrics_node.py +331 -0
- pynode/nodes/PolygonMetricsNode/requirements.txt +2 -0
- pynode/nodes/QueueLengthProbeNode/__init__.py +3 -0
- pynode/nodes/QueueLengthProbeNode/queue_length_probe_node.py +116 -0
- pynode/nodes/Qwen3VLMNode/__init__.py +3 -0
- pynode/nodes/Qwen3VLMNode/qwen3_vlm_node.py +519 -0
- pynode/nodes/Qwen3VLMNode/requirements.txt +5 -0
- pynode/nodes/RESTEndpointNode/__init__.py +3 -0
- pynode/nodes/RESTEndpointNode/rest_endpoint_node.py +491 -0
- pynode/nodes/RangeNode/__init__.py +3 -0
- pynode/nodes/RangeNode/range_node.py +115 -0
- pynode/nodes/RangeNode/requirements.txt +1 -0
- pynode/nodes/RateProbeNode/__init__.py +3 -0
- pynode/nodes/RateProbeNode/rate_probe_node.py +183 -0
- pynode/nodes/RateProbeNode/requirements.txt +1 -0
- pynode/nodes/RoboflowUploadNode/__init__.py +3 -0
- pynode/nodes/RoboflowUploadNode/requirements.txt +1 -0
- pynode/nodes/RoboflowUploadNode/roboflow_upload_node.py +229 -0
- pynode/nodes/SliceNode/README.md +149 -0
- pynode/nodes/SliceNode/__init__.py +5 -0
- pynode/nodes/SliceNode/merge_predictions_node.py +444 -0
- pynode/nodes/SliceNode/requirements.txt +3 -0
- pynode/nodes/SliceNode/slice_collector_node.py +370 -0
- pynode/nodes/SliceNode/slice_image_node.py +380 -0
- pynode/nodes/SplitNode/__init__.py +3 -0
- pynode/nodes/SplitNode/requirements.txt +1 -0
- pynode/nodes/SplitNode/split_node.py +155 -0
- pynode/nodes/SwitchNode/requirements.txt +1 -0
- pynode/nodes/SwitchNode/switch_node.py +259 -0
- pynode/nodes/SyncNode/__init__.py +3 -0
- pynode/nodes/SyncNode/sync_node.py +287 -0
- pynode/nodes/TemplateNode/requirements.txt +1 -0
- pynode/nodes/TemplateNode/template_node.py +86 -0
- pynode/nodes/TrackerNode/__init__.py +2 -0
- pynode/nodes/TrackerNode/supervision_tracker_node.py +248 -0
- pynode/nodes/TrackerNode/tracker_node.py +745 -0
- pynode/nodes/UltralyticsNode/__init__.py +4 -0
- pynode/nodes/UltralyticsNode/requirements.txt +2 -0
- pynode/nodes/UltralyticsNode/ultralytics_node.py +364 -0
- pynode/nodes/UnknownNode/__init__.py +93 -0
- pynode/nodes/VLLMNode/__init__.py +3 -0
- pynode/nodes/VLLMNode/vllm_node.py +345 -0
- pynode/nodes/VideoReaderNode/__init__.py +3 -0
- pynode/nodes/VideoReaderNode/video_reader_node.py +425 -0
- pynode/nodes/VideoWriterNode/__init__.py +3 -0
- pynode/nodes/VideoWriterNode/video_writer_node.py +441 -0
- pynode/nodes/WebhookNode/__init__.py +3 -0
- pynode/nodes/WebhookNode/webhook_node.py +591 -0
- pynode/nodes/__init__.py +109 -0
- pynode/nodes/base_node.py +562 -0
- pynode/nodes/image_utils.py +291 -0
- pynode/nodes/info.py +109 -0
- pynode/nodes/mDNSNode/__init__.py +13 -0
- pynode/nodes/mDNSNode/mdns_broadcast_node.py +214 -0
- pynode/nodes/mDNSNode/mdns_discovery_node.py +362 -0
- pynode/nodes/mDNSNode/mdns_manager.py +399 -0
- pynode/nodes/mDNSNode/requirements.txt +1 -0
- pynode/nodes/messages.py +81 -0
- pynode/server.py +187 -0
- pynode/static/images/favicon4.png +0 -0
- pynode/static/index.html +200 -0
- pynode/static/js/README.md +139 -0
- pynode/static/js/auth.js +89 -0
- pynode/static/js/clipboard.js +152 -0
- pynode/static/js/config.js +14 -0
- pynode/static/js/connections.js +844 -0
- pynode/static/js/debug.js +524 -0
- pynode/static/js/events.js +775 -0
- pynode/static/js/history.js +199 -0
- pynode/static/js/main.js +55 -0
- pynode/static/js/minimap.js +242 -0
- pynode/static/js/nodes.js +995 -0
- pynode/static/js/palette.js +255 -0
- pynode/static/js/properties.js +1270 -0
- pynode/static/js/selection.js +241 -0
- pynode/static/js/state.js +211 -0
- pynode/static/js/ui-utils.js +28 -0
- pynode/static/js/viewport.js +204 -0
- pynode/static/js/workflow.js +469 -0
- pynode/static/js/workflows.js +344 -0
- pynode/static/style.css +2751 -0
- pynode/workflow_engine.py +483 -0
- pynode/workflow_manager.py +440 -0
- pynode_flow-0.2.0.dist-info/METADATA +520 -0
- pynode_flow-0.2.0.dist-info/RECORD +227 -0
- pynode_flow-0.2.0.dist-info/WHEEL +5 -0
- pynode_flow-0.2.0.dist-info/entry_points.txt +3 -0
- pynode_flow-0.2.0.dist-info/licenses/LICENSE +21 -0
- pynode_flow-0.2.0.dist-info/scm_file_list.json +270 -0
- pynode_flow-0.2.0.dist-info/scm_version.json +8 -0
- 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)
|