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,652 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Omron Camera node - captures frames from OMRON Sentech GigE cameras using STAPI.
|
|
3
|
+
|
|
4
|
+
Compatible with the existing gigecams library (optional dependency for Bayer conversion).
|
|
5
|
+
Supports connection by device index or IP address, and multiple working modes.
|
|
6
|
+
|
|
7
|
+
Message output format matches CameraNode / FrameSourceNode for drop-in compatibility.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import logging
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
import queue
|
|
15
|
+
import ipaddress
|
|
16
|
+
from typing import Any, Dict, Optional
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
from pynode.nodes.base_node import BaseNode, Info, MessageKeys
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
# STAPI lazy import
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
_st: Any = None # module will be set on first successful import
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _get_stapi():
|
|
31
|
+
"""Lazy-import stapipy. Returns the module or None."""
|
|
32
|
+
global _st
|
|
33
|
+
if _st is not None:
|
|
34
|
+
return _st
|
|
35
|
+
try:
|
|
36
|
+
import stapipy as m
|
|
37
|
+
_st = m
|
|
38
|
+
return _st
|
|
39
|
+
except ImportError:
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
# Optional gigecams converter
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
_has_gigecams = False
|
|
47
|
+
_gige_bayer_to_rgb = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _try_load_gigecams():
|
|
51
|
+
"""Try to import Bayer converter from gigecams (non-fatal if missing)."""
|
|
52
|
+
global _has_gigecams, _gige_bayer_to_rgb
|
|
53
|
+
if _has_gigecams:
|
|
54
|
+
return True
|
|
55
|
+
try:
|
|
56
|
+
from gigecams.convert import bayer_to_rgb
|
|
57
|
+
_gige_bayer_to_rgb = bayer_to_rgb
|
|
58
|
+
_has_gigecams = True
|
|
59
|
+
return True
|
|
60
|
+
except ImportError:
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
# Info panel
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
_info = Info()
|
|
68
|
+
_info.add_text(
|
|
69
|
+
"Captures frames from OMRON Sentech GigE cameras using the STAPI SDK "
|
|
70
|
+
"(Sentech Transfer API). Supports connection by device index or IP address."
|
|
71
|
+
)
|
|
72
|
+
_info.add_header("Outputs")
|
|
73
|
+
_info.add_bullets(
|
|
74
|
+
("Output 0:", "Frame message with image data (numpy array or JPEG encoded)."),
|
|
75
|
+
)
|
|
76
|
+
_info.add_header("Connection")
|
|
77
|
+
_info.add_bullets(
|
|
78
|
+
("Camera Index:", "Connects by enumerating detected devices (0 = first camera)."),
|
|
79
|
+
("IP Address:", "Connects to a specific camera by its GigE IP."),
|
|
80
|
+
)
|
|
81
|
+
_info.add_header("Working Modes")
|
|
82
|
+
_info.add_bullets(
|
|
83
|
+
("Continuous:", "Free-running acquisition at the configured FPS."),
|
|
84
|
+
("SoftwareTrigger:", "Camera waits for a software trigger signal to capture one frame."),
|
|
85
|
+
)
|
|
86
|
+
_info.add_header("Properties")
|
|
87
|
+
_info.add_bullets(
|
|
88
|
+
("Exposure Time:", "Microseconds."),
|
|
89
|
+
("FPS:", "Target output frame rate (and camera frame rate in Continuous mode)."),
|
|
90
|
+
("Width / Height:", "Resolution. Falls back to camera defaults if not supported."),
|
|
91
|
+
("Encode as JPEG:", "Base64 JPEG output instead of raw numpy array."),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ===================================================================
|
|
96
|
+
# Node
|
|
97
|
+
# ===================================================================
|
|
98
|
+
class OmronCameraNode(BaseNode):
|
|
99
|
+
"""
|
|
100
|
+
Omron Camera node - captures frames from OMRON Sentech GigE cameras.
|
|
101
|
+
|
|
102
|
+
Uses the STAPI SDK (stapipy) for camera communication. Can optionally
|
|
103
|
+
import from the ``gigecams`` library for Bayer-to-RGB conversion.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
display_name = "Omron Camera"
|
|
107
|
+
icon = "📷"
|
|
108
|
+
category = "input"
|
|
109
|
+
color = "#C0DEED"
|
|
110
|
+
border_color = "#7FA7C9"
|
|
111
|
+
text_color = "#000000"
|
|
112
|
+
input_count = 0
|
|
113
|
+
output_count = 1
|
|
114
|
+
info = str(_info)
|
|
115
|
+
|
|
116
|
+
# ------------------------------------------------------------------
|
|
117
|
+
# Configuration schema
|
|
118
|
+
# ------------------------------------------------------------------
|
|
119
|
+
DEFAULT_CONFIG = {
|
|
120
|
+
"connection_type": "index", # "index" | "ip"
|
|
121
|
+
"camera_index": 0,
|
|
122
|
+
"camera_ip": "",
|
|
123
|
+
"working_mode": "Continuous", # "Continuous" | "SoftwareTrigger"
|
|
124
|
+
"exposure_time": 5000, # microseconds
|
|
125
|
+
"fps": 30,
|
|
126
|
+
"width": 640,
|
|
127
|
+
"height": 480,
|
|
128
|
+
"encode_jpeg": False,
|
|
129
|
+
"jpeg_quality": 75,
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
properties = [
|
|
133
|
+
{
|
|
134
|
+
"name": "connection_type",
|
|
135
|
+
"label": "Connection Type",
|
|
136
|
+
"type": "select",
|
|
137
|
+
"options": [
|
|
138
|
+
{"value": "index", "label": "Camera Index"},
|
|
139
|
+
{"value": "ip", "label": "IP Address"},
|
|
140
|
+
],
|
|
141
|
+
"default": DEFAULT_CONFIG["connection_type"],
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
"name": "camera_index",
|
|
145
|
+
"label": "Camera Index",
|
|
146
|
+
"type": "number",
|
|
147
|
+
"default": DEFAULT_CONFIG["camera_index"],
|
|
148
|
+
"showIf": {"connection_type": "index"},
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
"name": "camera_ip",
|
|
152
|
+
"label": "Camera IP Address",
|
|
153
|
+
"type": "text",
|
|
154
|
+
"default": DEFAULT_CONFIG["camera_ip"],
|
|
155
|
+
"showIf": {"connection_type": "ip"},
|
|
156
|
+
"placeholder": "e.g. 192.168.0.100",
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
"name": "working_mode",
|
|
160
|
+
"label": "Working Mode",
|
|
161
|
+
"type": "select",
|
|
162
|
+
"options": [
|
|
163
|
+
{"value": "Continuous", "label": "Continuous (Free-run)"},
|
|
164
|
+
{"value": "SoftwareTrigger", "label": "Software Trigger"},
|
|
165
|
+
],
|
|
166
|
+
"default": DEFAULT_CONFIG["working_mode"],
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
"name": "exposure_time",
|
|
170
|
+
"label": "Exposure Time (µs)",
|
|
171
|
+
"type": "number",
|
|
172
|
+
"default": DEFAULT_CONFIG["exposure_time"],
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
"name": "fps",
|
|
176
|
+
"label": "Output Frame Rate (FPS)",
|
|
177
|
+
"type": "number",
|
|
178
|
+
"default": DEFAULT_CONFIG["fps"],
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
"name": "width",
|
|
182
|
+
"label": "Width",
|
|
183
|
+
"type": "number",
|
|
184
|
+
"default": DEFAULT_CONFIG["width"],
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
"name": "height",
|
|
188
|
+
"label": "Height",
|
|
189
|
+
"type": "number",
|
|
190
|
+
"default": DEFAULT_CONFIG["height"],
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
"name": "encode_jpeg",
|
|
194
|
+
"label": "Encode as JPEG",
|
|
195
|
+
"type": "checkbox",
|
|
196
|
+
"default": DEFAULT_CONFIG["encode_jpeg"],
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
"name": "jpeg_quality",
|
|
200
|
+
"label": "JPEG Quality (1-100)",
|
|
201
|
+
"type": "number",
|
|
202
|
+
"default": DEFAULT_CONFIG["jpeg_quality"],
|
|
203
|
+
"showIf": {"encode_jpeg": True},
|
|
204
|
+
},
|
|
205
|
+
]
|
|
206
|
+
|
|
207
|
+
# ------------------------------------------------------------------
|
|
208
|
+
# Lifecycle
|
|
209
|
+
# ------------------------------------------------------------------
|
|
210
|
+
def __init__(self, node_id: Optional[str] = None, name: str = "omron_camera"):
|
|
211
|
+
super().__init__(node_id, name)
|
|
212
|
+
|
|
213
|
+
# STAPI handles
|
|
214
|
+
self._st_system: Any = None
|
|
215
|
+
self._st_device: Any = None
|
|
216
|
+
self._st_datastream: Any = None
|
|
217
|
+
self._callback_handle: Any = None
|
|
218
|
+
self._trigger_software_cmd: Any = None
|
|
219
|
+
self._st_initialized: bool = False
|
|
220
|
+
|
|
221
|
+
# Frame transport
|
|
222
|
+
self._frame_queue: "queue.Queue[np.ndarray]" = queue.Queue(maxsize=8)
|
|
223
|
+
self._cv2: Any = None
|
|
224
|
+
|
|
225
|
+
# Threading
|
|
226
|
+
self.capture_thread: Optional[threading.Thread] = None
|
|
227
|
+
self.running: bool = False
|
|
228
|
+
self._frame_count: int = 0
|
|
229
|
+
|
|
230
|
+
# -- start ---------------------------------------------------------
|
|
231
|
+
def on_start(self):
|
|
232
|
+
"""Connect to the Omron camera, configure it, and begin acquisition."""
|
|
233
|
+
super().on_start()
|
|
234
|
+
|
|
235
|
+
# --- validate dependencies ---
|
|
236
|
+
st = _get_stapi()
|
|
237
|
+
if st is None:
|
|
238
|
+
self.report_error(
|
|
239
|
+
"stapipy is not installed. Install the OMRON Sentech STAPI SDK "
|
|
240
|
+
"(pip install stapipy or use the .whl from gigecams/Omron/)."
|
|
241
|
+
)
|
|
242
|
+
return
|
|
243
|
+
|
|
244
|
+
import cv2 as _cv2
|
|
245
|
+
self._cv2 = _cv2
|
|
246
|
+
_try_load_gigecams()
|
|
247
|
+
|
|
248
|
+
connection_type = self.config.get("connection_type", "index")
|
|
249
|
+
working_mode = self.config.get("working_mode", "Continuous")
|
|
250
|
+
exposure_time = self.get_config_int("exposure_time", 5000)
|
|
251
|
+
fps = self.get_config_int("fps", 30)
|
|
252
|
+
|
|
253
|
+
try:
|
|
254
|
+
# 1. Initialise STAPI
|
|
255
|
+
st.initialize()
|
|
256
|
+
self._st_initialized = True
|
|
257
|
+
|
|
258
|
+
# 2. Connect to device
|
|
259
|
+
if connection_type == "ip":
|
|
260
|
+
camera_ip = self.config.get("camera_ip", "").strip()
|
|
261
|
+
if not camera_ip:
|
|
262
|
+
self.report_error("IP address is empty.")
|
|
263
|
+
return
|
|
264
|
+
self._connect_by_ip(st, camera_ip)
|
|
265
|
+
else:
|
|
266
|
+
idx = self.get_config_int("camera_index", 0)
|
|
267
|
+
self._connect_by_index(st, idx)
|
|
268
|
+
|
|
269
|
+
dev_name = self._st_device.info.display_name
|
|
270
|
+
logger.info(f"[OmronCamera] Connected to {dev_name}")
|
|
271
|
+
|
|
272
|
+
# 3. Configure GenICam nodes
|
|
273
|
+
nodemap = self._st_device.remote_port.nodemap
|
|
274
|
+
self._configure_nodemap(st, nodemap, working_mode, exposure_time, fps)
|
|
275
|
+
|
|
276
|
+
# 4. Create data stream & register callback
|
|
277
|
+
self._st_datastream = self._st_device.create_datastream()
|
|
278
|
+
self._callback_handle = self._st_datastream.register_callback(
|
|
279
|
+
self._on_stapi_buffer,
|
|
280
|
+
None, # context (unused — we use self)
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
# 5. Keep a handle to TriggerSoftware for later software-trigger calls
|
|
284
|
+
if working_mode == "SoftwareTrigger":
|
|
285
|
+
try:
|
|
286
|
+
node = nodemap.get_node("TriggerSoftware")
|
|
287
|
+
self._trigger_software_cmd = st.PyICommand(node)
|
|
288
|
+
except Exception as exc:
|
|
289
|
+
logger.warning(f"[OmronCamera] Could not get TriggerSoftware node: {exc}")
|
|
290
|
+
|
|
291
|
+
# 6. Start acquisition
|
|
292
|
+
self._st_datastream.start_acquisition()
|
|
293
|
+
self._st_device.acquisition_start()
|
|
294
|
+
logger.info(f"[OmronCamera] Acquisition started ({working_mode})")
|
|
295
|
+
|
|
296
|
+
# 7. Launch capture thread
|
|
297
|
+
self.running = True
|
|
298
|
+
self._frame_count = 0
|
|
299
|
+
self.capture_thread = threading.Thread(
|
|
300
|
+
target=self._capture_loop,
|
|
301
|
+
args=(fps,),
|
|
302
|
+
daemon=True,
|
|
303
|
+
)
|
|
304
|
+
self.capture_thread.start()
|
|
305
|
+
|
|
306
|
+
except Exception as exc:
|
|
307
|
+
self.report_error(f"Failed to start Omron camera: {exc}")
|
|
308
|
+
self._teardown_stapi()
|
|
309
|
+
|
|
310
|
+
# -- stop ----------------------------------------------------------
|
|
311
|
+
def on_stop(self):
|
|
312
|
+
"""Stop acquisition and release all STAPI resources."""
|
|
313
|
+
super().on_stop()
|
|
314
|
+
self.running = False
|
|
315
|
+
|
|
316
|
+
if self.capture_thread and self.capture_thread.is_alive():
|
|
317
|
+
self.capture_thread.join(timeout=2.0)
|
|
318
|
+
self.capture_thread = None
|
|
319
|
+
|
|
320
|
+
self._teardown_stapi()
|
|
321
|
+
|
|
322
|
+
def on_close(self):
|
|
323
|
+
"""Cleanup on node deletion."""
|
|
324
|
+
self.on_stop()
|
|
325
|
+
|
|
326
|
+
# ------------------------------------------------------------------
|
|
327
|
+
# Connection helpers
|
|
328
|
+
# ------------------------------------------------------------------
|
|
329
|
+
def _connect_by_index(self, st, index: int):
|
|
330
|
+
"""Connect to the *index*-th camera found on the first interface."""
|
|
331
|
+
self._st_system = st.create_system()
|
|
332
|
+
dev = None
|
|
333
|
+
# create_first_device() always returns the first. To get the Nth
|
|
334
|
+
# device we iterate and collect, then pick the Nth.
|
|
335
|
+
candidates: list = []
|
|
336
|
+
while True:
|
|
337
|
+
try:
|
|
338
|
+
candidates.append(self._st_system.create_first_device())
|
|
339
|
+
except Exception:
|
|
340
|
+
break
|
|
341
|
+
if index >= len(candidates):
|
|
342
|
+
raise RuntimeError(
|
|
343
|
+
f"Only {len(candidates)} camera(s) found, cannot use index {index}."
|
|
344
|
+
)
|
|
345
|
+
self._st_device = candidates[index]
|
|
346
|
+
|
|
347
|
+
def _connect_by_ip(self, st, ip_str: str):
|
|
348
|
+
"""Scan all interfaces and connect to the device with matching IP."""
|
|
349
|
+
ip_int = int(ipaddress.ip_address(ip_str))
|
|
350
|
+
self._st_system = st.create_system()
|
|
351
|
+
|
|
352
|
+
for if_idx in range(self._st_system.interface_count):
|
|
353
|
+
iface = self._st_system.get_interface(if_idx)
|
|
354
|
+
iface.update_device_list()
|
|
355
|
+
iface_nodemap = iface.port.nodemap
|
|
356
|
+
|
|
357
|
+
dev_selector = iface_nodemap.get_node("DeviceSelector")
|
|
358
|
+
ip_node = iface_nodemap.get_node("GevDeviceIPAddress")
|
|
359
|
+
|
|
360
|
+
# Determine max device index
|
|
361
|
+
try:
|
|
362
|
+
max_idx = int(dev_selector.max)
|
|
363
|
+
except Exception:
|
|
364
|
+
max_idx = 64 # safe upper bound
|
|
365
|
+
|
|
366
|
+
for dev_idx in range(max_idx + 1):
|
|
367
|
+
try:
|
|
368
|
+
dev_selector.value = dev_idx
|
|
369
|
+
if ip_node.is_available and ip_node.value == ip_int:
|
|
370
|
+
self._st_device = iface.create_device_by_index(dev_idx)
|
|
371
|
+
return
|
|
372
|
+
except Exception:
|
|
373
|
+
continue
|
|
374
|
+
|
|
375
|
+
raise RuntimeError(f"Camera with IP {ip_str} not found on any interface.")
|
|
376
|
+
|
|
377
|
+
# ------------------------------------------------------------------
|
|
378
|
+
# GenICam configuration
|
|
379
|
+
# ------------------------------------------------------------------
|
|
380
|
+
def _configure_nodemap(self, st, nodemap, mode: str, exp_us: int, fps: int):
|
|
381
|
+
"""Apply user configuration to the camera's remote nodemap."""
|
|
382
|
+
|
|
383
|
+
# --- ExposureTime ---
|
|
384
|
+
try:
|
|
385
|
+
node = nodemap.get_node("ExposureTime")
|
|
386
|
+
itype = node.principal_interface_type
|
|
387
|
+
if itype == st.EGCInterfaceType.IFloat:
|
|
388
|
+
obj = st.PyIFloat(node)
|
|
389
|
+
obj.value = float(exp_us)
|
|
390
|
+
elif itype == st.EGCInterfaceType.IInteger:
|
|
391
|
+
obj = st.PyIInteger(node)
|
|
392
|
+
obj.value = int(exp_us)
|
|
393
|
+
except Exception as exc:
|
|
394
|
+
logger.warning(f"[OmronCamera] Could not set ExposureTime: {exc}")
|
|
395
|
+
|
|
396
|
+
# --- AcquisitionFrameRate ---
|
|
397
|
+
try:
|
|
398
|
+
fr_node = nodemap.get_node("AcquisitionFrameRate")
|
|
399
|
+
fr = st.PyIFloat(fr_node)
|
|
400
|
+
fr.value = float(fps)
|
|
401
|
+
except Exception:
|
|
402
|
+
pass # not all cameras support this
|
|
403
|
+
|
|
404
|
+
# --- Resolution (Width / Height) ---
|
|
405
|
+
w_val = self.get_config_int("width", 640)
|
|
406
|
+
h_val = self.get_config_int("height", 480)
|
|
407
|
+
try:
|
|
408
|
+
w = st.PyIInteger(nodemap.get_node("Width"))
|
|
409
|
+
w.value = w_val
|
|
410
|
+
except Exception:
|
|
411
|
+
logger.warning(f"[OmronCamera] Width {w_val} not supported, using default.")
|
|
412
|
+
try:
|
|
413
|
+
h = st.PyIInteger(nodemap.get_node("Height"))
|
|
414
|
+
h.value = h_val
|
|
415
|
+
except Exception:
|
|
416
|
+
logger.warning(f"[OmronCamera] Height {h_val} not supported, using default.")
|
|
417
|
+
|
|
418
|
+
# --- Trigger configuration ---
|
|
419
|
+
if mode == "SoftwareTrigger":
|
|
420
|
+
# TriggerSelector → FrameStart (fallback ExposureStart)
|
|
421
|
+
try:
|
|
422
|
+
ts = st.PyIEnumeration(nodemap.get_node("TriggerSelector"))
|
|
423
|
+
ts.set_symbolic_value("FrameStart")
|
|
424
|
+
except Exception:
|
|
425
|
+
try:
|
|
426
|
+
ts.set_symbolic_value("ExposureStart")
|
|
427
|
+
except Exception as exc:
|
|
428
|
+
logger.warning(f"[OmronCamera] Could not set TriggerSelector: {exc}")
|
|
429
|
+
|
|
430
|
+
# TriggerMode → On
|
|
431
|
+
try:
|
|
432
|
+
tm = st.PyIEnumeration(nodemap.get_node("TriggerMode"))
|
|
433
|
+
tm.set_symbolic_value("On")
|
|
434
|
+
except Exception as exc:
|
|
435
|
+
logger.warning(f"[OmronCamera] Could not set TriggerMode: {exc}")
|
|
436
|
+
|
|
437
|
+
# TriggerSource → Software
|
|
438
|
+
try:
|
|
439
|
+
src = st.PyIEnumeration(nodemap.get_node("TriggerSource"))
|
|
440
|
+
src.set_symbolic_value("Software")
|
|
441
|
+
except Exception as exc:
|
|
442
|
+
logger.warning(f"[OmronCamera] Could not set TriggerSource: {exc}")
|
|
443
|
+
else:
|
|
444
|
+
# Continuous → TriggerMode Off
|
|
445
|
+
try:
|
|
446
|
+
tm = st.PyIEnumeration(nodemap.get_node("TriggerMode"))
|
|
447
|
+
tm.set_symbolic_value("Off")
|
|
448
|
+
except Exception:
|
|
449
|
+
pass
|
|
450
|
+
|
|
451
|
+
# ------------------------------------------------------------------
|
|
452
|
+
# STAPI buffer callback (invoked from STAPI internal thread)
|
|
453
|
+
# ------------------------------------------------------------------
|
|
454
|
+
def _on_stapi_buffer(self, handle, context):
|
|
455
|
+
"""Called by STAPI when a new buffer arrives. Pushes frame into a queue."""
|
|
456
|
+
if not self.running:
|
|
457
|
+
return
|
|
458
|
+
st = _get_stapi()
|
|
459
|
+
if st is None:
|
|
460
|
+
return
|
|
461
|
+
|
|
462
|
+
try:
|
|
463
|
+
if handle.callback_type != st.EStCallbackType.GenTLDataStreamNewBuffer:
|
|
464
|
+
return
|
|
465
|
+
except Exception:
|
|
466
|
+
return
|
|
467
|
+
|
|
468
|
+
try:
|
|
469
|
+
with self._st_datastream.retrieve_buffer(100) as st_buffer:
|
|
470
|
+
if not st_buffer.info.is_image_present:
|
|
471
|
+
return
|
|
472
|
+
|
|
473
|
+
st_image = st_buffer.get_image()
|
|
474
|
+
frame = self._stapi_image_to_numpy(st, st_image)
|
|
475
|
+
if frame is None:
|
|
476
|
+
return
|
|
477
|
+
|
|
478
|
+
# Non-blocking push; drop oldest if full (keep latest frame)
|
|
479
|
+
try:
|
|
480
|
+
self._frame_queue.put_nowait(frame)
|
|
481
|
+
except queue.Full:
|
|
482
|
+
try:
|
|
483
|
+
self._frame_queue.get_nowait()
|
|
484
|
+
self._frame_queue.put_nowait(frame)
|
|
485
|
+
except queue.Empty:
|
|
486
|
+
pass
|
|
487
|
+
|
|
488
|
+
except Exception as exc:
|
|
489
|
+
# Silently swallow transient STAPI errors in callback
|
|
490
|
+
if self.running:
|
|
491
|
+
logger.error(f"[OmronCamera] Callback error: {exc}")
|
|
492
|
+
|
|
493
|
+
# ------------------------------------------------------------------
|
|
494
|
+
# Image conversion
|
|
495
|
+
# ------------------------------------------------------------------
|
|
496
|
+
def _stapi_image_to_numpy(self, st, st_image) -> Optional[np.ndarray]:
|
|
497
|
+
"""Convert a STAPI image to a BGR numpy array."""
|
|
498
|
+
raw = st_image.get_image_data()
|
|
499
|
+
arr = np.frombuffer(raw, dtype=np.uint8)
|
|
500
|
+
h, w = st_image.height, st_image.width
|
|
501
|
+
|
|
502
|
+
# Try gigecams converter first
|
|
503
|
+
if _has_gigecams and _gige_bayer_to_rgb is not None:
|
|
504
|
+
try:
|
|
505
|
+
bayer_frame, _ = _gige_bayer_to_rgb(st_image)
|
|
506
|
+
return bayer_frame
|
|
507
|
+
except Exception:
|
|
508
|
+
pass # fall through
|
|
509
|
+
|
|
510
|
+
# Pixel-format-aware conversion
|
|
511
|
+
try:
|
|
512
|
+
pix_fmt = st_image.pixel_format
|
|
513
|
+
except Exception:
|
|
514
|
+
pix_fmt = None
|
|
515
|
+
|
|
516
|
+
cv2 = self._cv2
|
|
517
|
+
|
|
518
|
+
if pix_fmt == st.EStPixelFormatNamingConvention.BayerRG8:
|
|
519
|
+
# Bayer RG8 → BGR via OpenCV
|
|
520
|
+
bayer = arr.reshape(h, w)
|
|
521
|
+
return cv2.cvtColor(bayer, cv2.COLOR_BayerRG2BGR)
|
|
522
|
+
|
|
523
|
+
elif pix_fmt == st.EStPixelFormatNamingConvention.BGR8:
|
|
524
|
+
return arr.reshape(h, w, 3)
|
|
525
|
+
|
|
526
|
+
elif pix_fmt == st.EStPixelFormatNamingConvention.Mono8:
|
|
527
|
+
gray = arr.reshape(h, w)
|
|
528
|
+
return cv2.cvtColor(gray, cv2.COLOR_GRAY2BGR)
|
|
529
|
+
|
|
530
|
+
elif pix_fmt == st.EStPixelFormatNamingConvention.RGB8:
|
|
531
|
+
rgb = arr.reshape(h, w, 3)
|
|
532
|
+
return cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR)
|
|
533
|
+
|
|
534
|
+
else:
|
|
535
|
+
# Blind fallback: try 3-channel first, then 1-channel → gray2bgr
|
|
536
|
+
try:
|
|
537
|
+
return arr.reshape(h, w, 3)
|
|
538
|
+
except Exception:
|
|
539
|
+
try:
|
|
540
|
+
gray = arr.reshape(h, w)
|
|
541
|
+
return cv2.cvtColor(gray, cv2.COLOR_GRAY2BGR)
|
|
542
|
+
except Exception as exc:
|
|
543
|
+
logger.error(f"[OmronCamera] Cannot convert image: {exc}")
|
|
544
|
+
return None
|
|
545
|
+
|
|
546
|
+
# ------------------------------------------------------------------
|
|
547
|
+
# Capture loop
|
|
548
|
+
# ------------------------------------------------------------------
|
|
549
|
+
def _capture_loop(self, target_fps: int):
|
|
550
|
+
"""Poll the frame queue at the configured rate and emit messages."""
|
|
551
|
+
interval = 1.0 / max(target_fps, 1)
|
|
552
|
+
encode_jpeg = self.config.get("encode_jpeg", False)
|
|
553
|
+
|
|
554
|
+
while self.running:
|
|
555
|
+
t_start = time.time()
|
|
556
|
+
|
|
557
|
+
try:
|
|
558
|
+
frame = self._frame_queue.get(timeout=interval)
|
|
559
|
+
except queue.Empty:
|
|
560
|
+
continue
|
|
561
|
+
|
|
562
|
+
if frame is None:
|
|
563
|
+
continue
|
|
564
|
+
|
|
565
|
+
# --- encode payload ---
|
|
566
|
+
if encode_jpeg:
|
|
567
|
+
quality = self.get_config_int("jpeg_quality", 75)
|
|
568
|
+
ok, buf = self._cv2.imencode(
|
|
569
|
+
".jpg", frame, [self._cv2.IMWRITE_JPEG_QUALITY, quality]
|
|
570
|
+
)
|
|
571
|
+
if not ok:
|
|
572
|
+
self.report_error("JPEG encoding failed")
|
|
573
|
+
continue
|
|
574
|
+
b64 = base64.b64encode(buf.tobytes()).decode("utf-8")
|
|
575
|
+
payload = {
|
|
576
|
+
MessageKeys.IMAGE.FORMAT: "jpeg",
|
|
577
|
+
MessageKeys.IMAGE.ENCODING: "base64",
|
|
578
|
+
MessageKeys.IMAGE.DATA: b64,
|
|
579
|
+
MessageKeys.IMAGE.WIDTH: frame.shape[1],
|
|
580
|
+
MessageKeys.IMAGE.HEIGHT: frame.shape[0],
|
|
581
|
+
}
|
|
582
|
+
else:
|
|
583
|
+
payload = {
|
|
584
|
+
MessageKeys.IMAGE.FORMAT: "bgr",
|
|
585
|
+
MessageKeys.IMAGE.ENCODING: "numpy",
|
|
586
|
+
MessageKeys.IMAGE.DATA: frame,
|
|
587
|
+
MessageKeys.IMAGE.WIDTH: frame.shape[1],
|
|
588
|
+
MessageKeys.IMAGE.HEIGHT: frame.shape[0],
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
self._frame_count += 1
|
|
592
|
+
msg = self.create_message(
|
|
593
|
+
payload={MessageKeys.IMAGE.PATH: payload},
|
|
594
|
+
topic="omron/frame",
|
|
595
|
+
frame_count=self._frame_count,
|
|
596
|
+
)
|
|
597
|
+
self.send(msg)
|
|
598
|
+
|
|
599
|
+
# Throttle
|
|
600
|
+
elapsed = time.time() - t_start
|
|
601
|
+
leftover = interval - elapsed
|
|
602
|
+
if leftover > 0:
|
|
603
|
+
time.sleep(leftover)
|
|
604
|
+
|
|
605
|
+
# ------------------------------------------------------------------
|
|
606
|
+
# Cleanup
|
|
607
|
+
# ------------------------------------------------------------------
|
|
608
|
+
def _teardown_stapi(self):
|
|
609
|
+
"""Safely release all STAPI resources."""
|
|
610
|
+
st = _get_stapi()
|
|
611
|
+
if st is None:
|
|
612
|
+
self._st_initialized = False
|
|
613
|
+
return
|
|
614
|
+
|
|
615
|
+
# Stop acquisition
|
|
616
|
+
if self._st_datastream is not None:
|
|
617
|
+
try:
|
|
618
|
+
self._st_datastream.stop_acquisition()
|
|
619
|
+
except Exception:
|
|
620
|
+
pass
|
|
621
|
+
if self._st_device is not None:
|
|
622
|
+
try:
|
|
623
|
+
self._st_device.acquisition_stop()
|
|
624
|
+
except Exception:
|
|
625
|
+
pass
|
|
626
|
+
|
|
627
|
+
# Deregister callback
|
|
628
|
+
if self._callback_handle is not None and self._st_datastream is not None:
|
|
629
|
+
try:
|
|
630
|
+
self._st_datastream.deregister_callback(self._callback_handle)
|
|
631
|
+
except Exception:
|
|
632
|
+
pass
|
|
633
|
+
|
|
634
|
+
self._st_datastream = None
|
|
635
|
+
self._st_device = None
|
|
636
|
+
self._st_system = None
|
|
637
|
+
self._callback_handle = None
|
|
638
|
+
self._trigger_software_cmd = None
|
|
639
|
+
|
|
640
|
+
if self._st_initialized:
|
|
641
|
+
try:
|
|
642
|
+
st.terminate()
|
|
643
|
+
except Exception:
|
|
644
|
+
pass
|
|
645
|
+
self._st_initialized = False
|
|
646
|
+
|
|
647
|
+
# Drain frame queue
|
|
648
|
+
while not self._frame_queue.empty():
|
|
649
|
+
try:
|
|
650
|
+
self._frame_queue.get_nowait()
|
|
651
|
+
except queue.Empty:
|
|
652
|
+
break
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# OpenCV Nodes Package
|