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,505 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Message Writer Node - writes messages or message components to disk.
|
|
3
|
+
Supports dynamic filenames and flexible data extraction from messages.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import json
|
|
8
|
+
import pickle
|
|
9
|
+
import time
|
|
10
|
+
import base64
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from typing import Any, Dict
|
|
13
|
+
from pynode.nodes.base_node import BaseNode, Info, MessageKeys
|
|
14
|
+
|
|
15
|
+
_info = Info()
|
|
16
|
+
_info.add_text("Writes messages or message components to disk. Supports JSON, binary, or text formats with dynamic filenames.")
|
|
17
|
+
_info.add_header("Inputs")
|
|
18
|
+
_info.add_bullets(
|
|
19
|
+
("Input 0:", "Message with data to write. Can write whole message or specific components.")
|
|
20
|
+
)
|
|
21
|
+
_info.add_header("Outputs")
|
|
22
|
+
_info.add_bullets(
|
|
23
|
+
("Output 0:", "Status message with path, filename, bytes written, and counter.")
|
|
24
|
+
)
|
|
25
|
+
_info.add_header("Message Properties")
|
|
26
|
+
_info.add_bullets(
|
|
27
|
+
("msg.fname:", "Override filename from message."),
|
|
28
|
+
("msg.extension:", "Override file extension from message.")
|
|
29
|
+
)
|
|
30
|
+
_info.add_header("Data Source Options")
|
|
31
|
+
_info.add_bullets(
|
|
32
|
+
("whole_message:", "Write entire message object"),
|
|
33
|
+
("msg.payload:", "Write only the payload"),
|
|
34
|
+
("msg.topic:", "Write only the topic"),
|
|
35
|
+
("custom:", "Write custom message path (e.g., msg.payload.detections)")
|
|
36
|
+
)
|
|
37
|
+
_info.add_header("Naming Modes")
|
|
38
|
+
_info.add_bullets(
|
|
39
|
+
("Counter:", "data_0001.json, data_0002.json, ..."),
|
|
40
|
+
("Timestamp:", "data_1234567890.json (Unix timestamp)"),
|
|
41
|
+
("DateTime:", "data_2024-12-03_153045.json"),
|
|
42
|
+
("Message:", "Use msg.fname from incoming message")
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class MessageWriterNode(BaseNode):
|
|
47
|
+
"""
|
|
48
|
+
Message Writer node - writes messages or message components to disk.
|
|
49
|
+
Supports flexible data extraction and multiple output formats.
|
|
50
|
+
"""
|
|
51
|
+
display_name = 'Message Writer'
|
|
52
|
+
icon = '📝'
|
|
53
|
+
category = 'output'
|
|
54
|
+
color = '#9B7CB6'
|
|
55
|
+
border_color = '#6B4C7B'
|
|
56
|
+
text_color = '#FFFFFF'
|
|
57
|
+
input_count = 1
|
|
58
|
+
output_count = 1
|
|
59
|
+
info = str(_info)
|
|
60
|
+
|
|
61
|
+
DEFAULT_CONFIG = {
|
|
62
|
+
'directory': './output',
|
|
63
|
+
'filename': 'message',
|
|
64
|
+
'extension': 'json',
|
|
65
|
+
'naming_mode': 'counter',
|
|
66
|
+
'counter_digits': '4',
|
|
67
|
+
'overwrite': 'false',
|
|
68
|
+
'create_subdirs': 'true',
|
|
69
|
+
'data_source': 'whole_message',
|
|
70
|
+
'custom_path': f'{MessageKeys.MSG}.{MessageKeys.PAYLOAD}',
|
|
71
|
+
'output_format': 'json'
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
properties = [
|
|
75
|
+
{
|
|
76
|
+
'name': 'directory',
|
|
77
|
+
'label': 'Output Directory',
|
|
78
|
+
'type': 'text',
|
|
79
|
+
'default': DEFAULT_CONFIG['directory'],
|
|
80
|
+
'help': 'Directory where files will be saved (created if doesn\'t exist)'
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
'name': 'filename',
|
|
84
|
+
'label': 'Filename/Prefix',
|
|
85
|
+
'type': 'text',
|
|
86
|
+
'default': DEFAULT_CONFIG['filename'],
|
|
87
|
+
'help': 'Filename or prefix. Can be overridden by msg.fname. Supports {timestamp}, {counter}'
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
'name': 'extension',
|
|
91
|
+
'label': 'File Extension',
|
|
92
|
+
'type': 'select',
|
|
93
|
+
'options': [
|
|
94
|
+
{'value': 'json', 'label': '.json'},
|
|
95
|
+
{'value': 'txt', 'label': '.txt'},
|
|
96
|
+
{'value': 'bin', 'label': '.bin (binary)'},
|
|
97
|
+
{'value': 'pkl', 'label': '.pkl (pickle)'},
|
|
98
|
+
{'value': 'npy', 'label': '.npy (numpy)'},
|
|
99
|
+
{'value': 'csv', 'label': '.csv'},
|
|
100
|
+
{'value': 'log', 'label': '.log'}
|
|
101
|
+
],
|
|
102
|
+
'default': DEFAULT_CONFIG['extension'],
|
|
103
|
+
'help': 'File extension. Can be overridden by msg.extension'
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
'name': 'naming_mode',
|
|
107
|
+
'label': 'Naming Mode',
|
|
108
|
+
'type': 'select',
|
|
109
|
+
'options': [
|
|
110
|
+
{'value': 'counter', 'label': 'Counter (message_0001.json)'},
|
|
111
|
+
{'value': 'timestamp', 'label': 'Timestamp (message_1234567890.json)'},
|
|
112
|
+
{'value': 'datetime', 'label': 'DateTime (message_2024-12-03_153045.json)'},
|
|
113
|
+
{'value': 'message', 'label': 'From Message (msg.fname)'}
|
|
114
|
+
],
|
|
115
|
+
'default': DEFAULT_CONFIG['naming_mode'],
|
|
116
|
+
'help': 'How to generate filenames'
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
'name': 'counter_digits',
|
|
120
|
+
'label': 'Counter Digits',
|
|
121
|
+
'type': 'text',
|
|
122
|
+
'default': DEFAULT_CONFIG['counter_digits'],
|
|
123
|
+
'help': 'Number of digits for counter padding (e.g., 4 = 0001, 0002, ...)'
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
'name': 'data_source',
|
|
127
|
+
'label': 'Data Source',
|
|
128
|
+
'type': 'select',
|
|
129
|
+
'options': [
|
|
130
|
+
{'value': 'whole_message', 'label': 'Whole Message'},
|
|
131
|
+
{'value': MessageKeys.PAYLOAD, 'label': f'{MessageKeys.MSG}.{MessageKeys.PAYLOAD}'},
|
|
132
|
+
{'value': MessageKeys.TOPIC, 'label': f'{MessageKeys.MSG}.{MessageKeys.TOPIC}'},
|
|
133
|
+
{'value': 'custom', 'label': 'Custom Path'}
|
|
134
|
+
],
|
|
135
|
+
'default': DEFAULT_CONFIG['data_source'],
|
|
136
|
+
'help': 'What part of the message to write'
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
'name': 'custom_path',
|
|
140
|
+
'label': 'Custom Message Path',
|
|
141
|
+
'type': 'text',
|
|
142
|
+
'default': DEFAULT_CONFIG['custom_path'],
|
|
143
|
+
'help': 'Custom path when data_source is "custom" (e.g., msg.payload.detections, msg.cv.image)',
|
|
144
|
+
'showIf': {'data_source': 'custom'}
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
'name': 'output_format',
|
|
148
|
+
'label': 'Output Format',
|
|
149
|
+
'type': 'select',
|
|
150
|
+
'options': [
|
|
151
|
+
{'value': 'json', 'label': 'JSON (human readable)'},
|
|
152
|
+
{'value': 'json_compact', 'label': 'JSON (compact)'},
|
|
153
|
+
{'value': 'pickle', 'label': 'Pickle (binary)'},
|
|
154
|
+
{'value': 'numpy', 'label': 'NumPy (.npy binary)'},
|
|
155
|
+
{'value': 'text', 'label': 'Text (string representation)'},
|
|
156
|
+
{'value': 'raw', 'label': 'Raw (bytes as-is)'}
|
|
157
|
+
],
|
|
158
|
+
'default': DEFAULT_CONFIG['output_format'],
|
|
159
|
+
'help': 'How to format the data before writing'
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
'name': 'overwrite',
|
|
163
|
+
'label': 'Overwrite Existing',
|
|
164
|
+
'type': 'select',
|
|
165
|
+
'options': [
|
|
166
|
+
{'value': 'false', 'label': 'No (skip if exists)'},
|
|
167
|
+
{'value': 'true', 'label': 'Yes (overwrite)'}
|
|
168
|
+
],
|
|
169
|
+
'default': DEFAULT_CONFIG['overwrite'],
|
|
170
|
+
'help': 'Overwrite existing files or skip them'
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
'name': 'create_subdirs',
|
|
174
|
+
'label': 'Create Subdirectories',
|
|
175
|
+
'type': 'select',
|
|
176
|
+
'options': [
|
|
177
|
+
{'value': 'true', 'label': 'Yes'},
|
|
178
|
+
{'value': 'false', 'label': 'No'}
|
|
179
|
+
],
|
|
180
|
+
'default': DEFAULT_CONFIG['create_subdirs'],
|
|
181
|
+
'help': 'Create directory structure if it doesn\'t exist'
|
|
182
|
+
}
|
|
183
|
+
]
|
|
184
|
+
|
|
185
|
+
def __init__(self, node_id=None, name="message writer"):
|
|
186
|
+
super().__init__(node_id, name)
|
|
187
|
+
self._counter = 0
|
|
188
|
+
self._last_written = None
|
|
189
|
+
|
|
190
|
+
def on_input(self, msg: Dict[str, Any], input_index: int = 0):
|
|
191
|
+
"""
|
|
192
|
+
Write message data to disk. Can write whole message or specific components.
|
|
193
|
+
"""
|
|
194
|
+
try:
|
|
195
|
+
# Get directory from config
|
|
196
|
+
directory = self.config.get('directory', './output')
|
|
197
|
+
|
|
198
|
+
# Get filename/prefix - prioritize msg.fname over config
|
|
199
|
+
if 'fname' in msg:
|
|
200
|
+
filename = str(msg['fname'])
|
|
201
|
+
else:
|
|
202
|
+
filename = self.config.get('filename', 'message')
|
|
203
|
+
|
|
204
|
+
# Get extension - prioritize msg.extension over config
|
|
205
|
+
if 'extension' in msg:
|
|
206
|
+
extension = str(msg['extension']).lstrip('.')
|
|
207
|
+
else:
|
|
208
|
+
extension = self.config.get('extension', 'json')
|
|
209
|
+
|
|
210
|
+
# Generate full filename based on naming mode
|
|
211
|
+
naming_mode = self.config.get('naming_mode', 'counter')
|
|
212
|
+
full_filename = self._generate_filename(filename, extension, naming_mode, msg)
|
|
213
|
+
|
|
214
|
+
# Build full path
|
|
215
|
+
full_path = os.path.join(directory, full_filename)
|
|
216
|
+
|
|
217
|
+
# Create directory if needed
|
|
218
|
+
create_subdirs = self.get_config_bool('create_subdirs', True)
|
|
219
|
+
if create_subdirs:
|
|
220
|
+
os.makedirs(directory, exist_ok=True)
|
|
221
|
+
elif not os.path.exists(directory):
|
|
222
|
+
self.report_error(f"Directory does not exist: {directory}")
|
|
223
|
+
return
|
|
224
|
+
|
|
225
|
+
# Check if file exists and handle overwrite setting
|
|
226
|
+
overwrite = self.get_config_bool('overwrite', False)
|
|
227
|
+
if os.path.exists(full_path) and not overwrite:
|
|
228
|
+
output_msg = self.create_message(
|
|
229
|
+
payload={
|
|
230
|
+
'status': 'skipped',
|
|
231
|
+
'reason': 'file_exists',
|
|
232
|
+
'path': full_path
|
|
233
|
+
},
|
|
234
|
+
topic=msg.get(MessageKeys.TOPIC, 'MessageWriter')
|
|
235
|
+
)
|
|
236
|
+
self.send(output_msg)
|
|
237
|
+
return
|
|
238
|
+
|
|
239
|
+
# Extract data to write based on data_source setting
|
|
240
|
+
data_to_write = self._extract_data(msg)
|
|
241
|
+
|
|
242
|
+
if data_to_write is None:
|
|
243
|
+
self.report_error("No data found to write")
|
|
244
|
+
return
|
|
245
|
+
|
|
246
|
+
# Write data based on output format
|
|
247
|
+
bytes_written = self._write_data(full_path, data_to_write, extension)
|
|
248
|
+
|
|
249
|
+
# Track last written file
|
|
250
|
+
self._last_written = full_path
|
|
251
|
+
|
|
252
|
+
# Send success message
|
|
253
|
+
output_msg = self.create_message(
|
|
254
|
+
payload={
|
|
255
|
+
'status': 'success',
|
|
256
|
+
'path': full_path,
|
|
257
|
+
'filename': full_filename,
|
|
258
|
+
'bytes': bytes_written,
|
|
259
|
+
'counter': self._counter,
|
|
260
|
+
'data_source': self.config.get('data_source', 'whole_message')
|
|
261
|
+
},
|
|
262
|
+
topic=msg.get(MessageKeys.TOPIC, 'MessageWriter')
|
|
263
|
+
)
|
|
264
|
+
self.send(output_msg)
|
|
265
|
+
|
|
266
|
+
except Exception as e:
|
|
267
|
+
self.report_error(f"Failed to write file: {str(e)}")
|
|
268
|
+
output_msg = self.create_message(
|
|
269
|
+
payload={
|
|
270
|
+
'status': 'error',
|
|
271
|
+
'error': str(e)
|
|
272
|
+
},
|
|
273
|
+
topic=msg.get(MessageKeys.TOPIC, 'MessageWriter/error')
|
|
274
|
+
)
|
|
275
|
+
self.send(output_msg)
|
|
276
|
+
|
|
277
|
+
def _extract_data(self, msg: Dict[str, Any]) -> Any:
|
|
278
|
+
"""Extract data from message based on data_source configuration."""
|
|
279
|
+
data_source = self.config.get('data_source', 'whole_message')
|
|
280
|
+
|
|
281
|
+
if data_source == 'whole_message':
|
|
282
|
+
return msg
|
|
283
|
+
elif data_source == MessageKeys.PAYLOAD:
|
|
284
|
+
return msg.get(MessageKeys.PAYLOAD)
|
|
285
|
+
elif data_source == MessageKeys.TOPIC:
|
|
286
|
+
return msg.get(MessageKeys.TOPIC)
|
|
287
|
+
elif data_source == 'custom':
|
|
288
|
+
custom_path = self.config.get('custom_path', 'msg.payload')
|
|
289
|
+
return self._extract_custom_path(msg, custom_path)
|
|
290
|
+
else:
|
|
291
|
+
return msg.get(MessageKeys.PAYLOAD) # Default fallback
|
|
292
|
+
|
|
293
|
+
def _extract_custom_path(self, msg: Dict[str, Any], path: str) -> Any:
|
|
294
|
+
"""Extract data using custom path like 'msg.payload.detections'."""
|
|
295
|
+
try:
|
|
296
|
+
# Remove 'msg.' prefix if present
|
|
297
|
+
if path.startswith('msg.'):
|
|
298
|
+
path = path[4:]
|
|
299
|
+
|
|
300
|
+
# Split path into components
|
|
301
|
+
parts = path.split('.')
|
|
302
|
+
|
|
303
|
+
# Start with the message
|
|
304
|
+
current = msg
|
|
305
|
+
|
|
306
|
+
# Navigate through the path
|
|
307
|
+
for part in parts:
|
|
308
|
+
if isinstance(current, dict):
|
|
309
|
+
current = current.get(part)
|
|
310
|
+
elif hasattr(current, part):
|
|
311
|
+
current = getattr(current, part)
|
|
312
|
+
else:
|
|
313
|
+
return None
|
|
314
|
+
|
|
315
|
+
if current is None:
|
|
316
|
+
return None
|
|
317
|
+
|
|
318
|
+
return current
|
|
319
|
+
|
|
320
|
+
except Exception as e:
|
|
321
|
+
self.report_error(f"Failed to extract custom path '{path}': {str(e)}")
|
|
322
|
+
return None
|
|
323
|
+
|
|
324
|
+
def _generate_filename(self, base: str, extension: str, mode: str, msg: Dict[str, Any]) -> str:
|
|
325
|
+
"""Generate filename based on naming mode."""
|
|
326
|
+
|
|
327
|
+
if mode == 'message':
|
|
328
|
+
# Use filename from message as-is (msg.fname already used for base)
|
|
329
|
+
return f"{base}.{extension}"
|
|
330
|
+
|
|
331
|
+
elif mode == 'counter':
|
|
332
|
+
# Increment counter
|
|
333
|
+
self._counter += 1
|
|
334
|
+
digits = self.get_config_int('counter_digits', 4)
|
|
335
|
+
counter_str = str(self._counter).zfill(digits)
|
|
336
|
+
|
|
337
|
+
# Replace {counter} placeholder if present, or append
|
|
338
|
+
if '{counter}' in base:
|
|
339
|
+
filename = base.replace('{counter}', counter_str)
|
|
340
|
+
else:
|
|
341
|
+
filename = f"{base}_{counter_str}"
|
|
342
|
+
|
|
343
|
+
return f"{filename}.{extension}"
|
|
344
|
+
|
|
345
|
+
elif mode == 'timestamp':
|
|
346
|
+
# Unix timestamp
|
|
347
|
+
timestamp = int(time.time() * 1000) # milliseconds
|
|
348
|
+
|
|
349
|
+
# Replace {timestamp} placeholder if present, or append
|
|
350
|
+
if '{timestamp}' in base:
|
|
351
|
+
filename = base.replace('{timestamp}', str(timestamp))
|
|
352
|
+
else:
|
|
353
|
+
filename = f"{base}_{timestamp}"
|
|
354
|
+
|
|
355
|
+
return f"{filename}.{extension}"
|
|
356
|
+
|
|
357
|
+
elif mode == 'datetime':
|
|
358
|
+
# Human-readable datetime
|
|
359
|
+
dt = datetime.now()
|
|
360
|
+
datetime_str = dt.strftime('%Y%m%d_%H%M%S_%f')[:-3] # Include milliseconds
|
|
361
|
+
|
|
362
|
+
# Replace {datetime} placeholder if present, or append
|
|
363
|
+
if '{datetime}' in base:
|
|
364
|
+
filename = base.replace('{datetime}', datetime_str)
|
|
365
|
+
else:
|
|
366
|
+
filename = f"{base}_{datetime_str}"
|
|
367
|
+
|
|
368
|
+
return f"{filename}.{extension}"
|
|
369
|
+
|
|
370
|
+
else:
|
|
371
|
+
# Default: just append extension
|
|
372
|
+
return f"{base}.{extension}"
|
|
373
|
+
|
|
374
|
+
def _write_data(self, path: str, data: Any, extension: str) -> int:
|
|
375
|
+
"""Write data to file based on output_format configuration."""
|
|
376
|
+
output_format = self.config.get('output_format', 'json')
|
|
377
|
+
|
|
378
|
+
try:
|
|
379
|
+
if output_format == 'json':
|
|
380
|
+
# Pretty-printed JSON with proper numpy array handling
|
|
381
|
+
json_str = json.dumps(data, indent=2, default=self._json_serializer)
|
|
382
|
+
with open(path, 'w', encoding='utf-8') as f:
|
|
383
|
+
f.write(json_str)
|
|
384
|
+
return len(json_str.encode('utf-8'))
|
|
385
|
+
|
|
386
|
+
elif output_format == 'json_compact':
|
|
387
|
+
# Compact JSON with proper numpy array handling
|
|
388
|
+
json_str = json.dumps(data, separators=(',', ':'), default=self._json_serializer)
|
|
389
|
+
with open(path, 'w', encoding='utf-8') as f:
|
|
390
|
+
f.write(json_str)
|
|
391
|
+
return len(json_str.encode('utf-8'))
|
|
392
|
+
|
|
393
|
+
elif output_format == 'pickle':
|
|
394
|
+
# Pickle binary format
|
|
395
|
+
with open(path, 'wb') as f:
|
|
396
|
+
pickle.dump(data, f)
|
|
397
|
+
return os.path.getsize(path)
|
|
398
|
+
|
|
399
|
+
elif output_format == 'numpy':
|
|
400
|
+
# NumPy binary format (.npy)
|
|
401
|
+
try:
|
|
402
|
+
import numpy as np
|
|
403
|
+
if isinstance(data, np.ndarray):
|
|
404
|
+
np.save(path, data)
|
|
405
|
+
return os.path.getsize(path)
|
|
406
|
+
else:
|
|
407
|
+
# If not a numpy array, try to convert it
|
|
408
|
+
np_data = np.array(data)
|
|
409
|
+
np.save(path, np_data)
|
|
410
|
+
return os.path.getsize(path)
|
|
411
|
+
except ImportError:
|
|
412
|
+
# Fallback to pickle if numpy not available
|
|
413
|
+
with open(path, 'wb') as f:
|
|
414
|
+
pickle.dump(data, f)
|
|
415
|
+
return os.path.getsize(path)
|
|
416
|
+
|
|
417
|
+
elif output_format == 'text':
|
|
418
|
+
# String representation
|
|
419
|
+
text_data = str(data)
|
|
420
|
+
with open(path, 'w', encoding='utf-8') as f:
|
|
421
|
+
f.write(text_data)
|
|
422
|
+
return len(text_data.encode('utf-8'))
|
|
423
|
+
|
|
424
|
+
elif output_format == 'raw':
|
|
425
|
+
# Raw bytes - data must be bytes-like
|
|
426
|
+
if isinstance(data, bytes):
|
|
427
|
+
with open(path, 'wb') as f:
|
|
428
|
+
f.write(data)
|
|
429
|
+
return len(data)
|
|
430
|
+
elif isinstance(data, str):
|
|
431
|
+
# Convert string to bytes
|
|
432
|
+
data_bytes = data.encode('utf-8')
|
|
433
|
+
with open(path, 'wb') as f:
|
|
434
|
+
f.write(data_bytes)
|
|
435
|
+
return len(data_bytes)
|
|
436
|
+
else:
|
|
437
|
+
# Check if it's a numpy array
|
|
438
|
+
try:
|
|
439
|
+
import numpy as np
|
|
440
|
+
if isinstance(data, np.ndarray):
|
|
441
|
+
# Save as raw numpy bytes
|
|
442
|
+
data_bytes = data.tobytes()
|
|
443
|
+
with open(path, 'wb') as f:
|
|
444
|
+
f.write(data_bytes)
|
|
445
|
+
return len(data_bytes)
|
|
446
|
+
except ImportError:
|
|
447
|
+
pass
|
|
448
|
+
|
|
449
|
+
# Try to convert to bytes via pickle
|
|
450
|
+
data_bytes = pickle.dumps(data)
|
|
451
|
+
with open(path, 'wb') as f:
|
|
452
|
+
f.write(data_bytes)
|
|
453
|
+
return len(data_bytes)
|
|
454
|
+
|
|
455
|
+
else:
|
|
456
|
+
# Default to JSON
|
|
457
|
+
json_str = json.dumps(data, indent=2, default=self._json_serializer)
|
|
458
|
+
with open(path, 'w', encoding='utf-8') as f:
|
|
459
|
+
f.write(json_str)
|
|
460
|
+
return len(json_str.encode('utf-8'))
|
|
461
|
+
|
|
462
|
+
except Exception as e:
|
|
463
|
+
# Fallback to string representation if serialization fails
|
|
464
|
+
text_data = str(data)
|
|
465
|
+
with open(path, 'w', encoding='utf-8') as f:
|
|
466
|
+
f.write(text_data)
|
|
467
|
+
return len(text_data.encode('utf-8'))
|
|
468
|
+
|
|
469
|
+
def _json_serializer(self, obj):
|
|
470
|
+
"""Custom JSON serializer to handle numpy arrays and other special types."""
|
|
471
|
+
try:
|
|
472
|
+
import numpy as np
|
|
473
|
+
if isinstance(obj, np.ndarray):
|
|
474
|
+
# Convert numpy array to base64 string for JSON serialization
|
|
475
|
+
return {
|
|
476
|
+
"_numpy_array": True,
|
|
477
|
+
"data": base64.b64encode(obj.tobytes()).decode('utf-8'),
|
|
478
|
+
"dtype": str(obj.dtype),
|
|
479
|
+
"shape": obj.shape
|
|
480
|
+
}
|
|
481
|
+
elif isinstance(obj, np.integer):
|
|
482
|
+
return int(obj)
|
|
483
|
+
elif isinstance(obj, np.floating):
|
|
484
|
+
return float(obj)
|
|
485
|
+
except ImportError:
|
|
486
|
+
pass
|
|
487
|
+
|
|
488
|
+
# Handle other types that aren't JSON serializable
|
|
489
|
+
if hasattr(obj, '__dict__'):
|
|
490
|
+
return obj.__dict__
|
|
491
|
+
|
|
492
|
+
# Default fallback
|
|
493
|
+
return str(obj)
|
|
494
|
+
|
|
495
|
+
def get_counter(self) -> int:
|
|
496
|
+
"""Get current counter value."""
|
|
497
|
+
return self._counter
|
|
498
|
+
|
|
499
|
+
def reset_counter(self):
|
|
500
|
+
"""Reset counter to 0."""
|
|
501
|
+
self._counter = 0
|
|
502
|
+
|
|
503
|
+
def get_last_written(self):
|
|
504
|
+
"""Get path of last written file."""
|
|
505
|
+
return self._last_written
|