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,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,4 @@
1
+ stapipy>=1.2.0
2
+ opencv-python>=4.5.0
3
+ numpy>=1.19.0
4
+ gigecams # optional - for Bayer conversion utilities
@@ -0,0 +1 @@
1
+ # OpenCV Nodes Package