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,102 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OpenCV FFT Node - performs Fast Fourier Transform on an image.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import cv2
|
|
6
|
+
import numpy as np
|
|
7
|
+
from typing import Any, Dict
|
|
8
|
+
from pynode.nodes.base_node import BaseNode, Info, MessageKeys
|
|
9
|
+
|
|
10
|
+
_info = Info()
|
|
11
|
+
_info.add_text("Performs Fast Fourier Transform (FFT) on an input image to extract frequency components.")
|
|
12
|
+
_info.add_header("Inputs")
|
|
13
|
+
_info.add_bullet("Input 0:", "Input image (converted to grayscale automatically)")
|
|
14
|
+
_info.add_header("Outputs")
|
|
15
|
+
_info.add_bullet("Output 0:", "Magnitude Spectrum (visualizable) + FFT Complex Data (in metadata)")
|
|
16
|
+
_info.add_header("Notes")
|
|
17
|
+
_info.add_text("The complex frequency data is stored in the message metadata ('_fft_data') for downstream processing by the Frequency Filter node.")
|
|
18
|
+
_info.add_text("The visible output is the Magnitude Spectrum (log-scaled).")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class FFTNode(BaseNode):
|
|
22
|
+
"""
|
|
23
|
+
FFT Node - converts image to frequency domain.
|
|
24
|
+
"""
|
|
25
|
+
info = str(_info)
|
|
26
|
+
display_name = 'FFT'
|
|
27
|
+
icon = '∿'
|
|
28
|
+
category = 'opencv'
|
|
29
|
+
color = '#8E44AD' # Purple-ish
|
|
30
|
+
border_color = '#6C3483'
|
|
31
|
+
text_color = '#FFFFFF'
|
|
32
|
+
input_count = 1
|
|
33
|
+
output_count = 1
|
|
34
|
+
|
|
35
|
+
DEFAULT_CONFIG = {
|
|
36
|
+
'output_view': 'magnitude'
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
properties = [
|
|
40
|
+
{
|
|
41
|
+
'name': 'output_view',
|
|
42
|
+
'label': 'Output View',
|
|
43
|
+
'type': 'select',
|
|
44
|
+
'options': [
|
|
45
|
+
{'value': 'magnitude', 'label': 'Magnitude Spectrum'}
|
|
46
|
+
],
|
|
47
|
+
'default': DEFAULT_CONFIG['output_view'],
|
|
48
|
+
'help': 'What to display in the image payload'
|
|
49
|
+
}
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
def on_input(self, msg: Dict[str, Any], input_index: int = 0):
|
|
53
|
+
"""Process incoming image and compute FFT."""
|
|
54
|
+
if MessageKeys.PAYLOAD not in msg:
|
|
55
|
+
self.send(msg)
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
img, format_type = self.decode_image(msg[MessageKeys.PAYLOAD])
|
|
59
|
+
if img is None:
|
|
60
|
+
self.send(msg)
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
if format_type is None:
|
|
64
|
+
format_type = 'numpy_array'
|
|
65
|
+
|
|
66
|
+
# Convert to grayscale if necessary
|
|
67
|
+
if len(img.shape) == 3:
|
|
68
|
+
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
|
69
|
+
else:
|
|
70
|
+
gray = img
|
|
71
|
+
|
|
72
|
+
# Compute DFT
|
|
73
|
+
# cv2.dft expects float32
|
|
74
|
+
dft = cv2.dft(np.float32(gray), flags=cv2.DFT_COMPLEX_OUTPUT)
|
|
75
|
+
dft_shift = np.fft.fftshift(dft)
|
|
76
|
+
|
|
77
|
+
# Compute Magnitude Spectrum for visualization
|
|
78
|
+
# magnitude = sqrt(Re^2 + Im^2)
|
|
79
|
+
magnitude = cv2.magnitude(dft_shift[:,:,0], dft_shift[:,:,1])
|
|
80
|
+
|
|
81
|
+
# Log scale: 20 * log(1 + mag)
|
|
82
|
+
magnitude_spectrum = 20 * np.log(magnitude + 1)
|
|
83
|
+
|
|
84
|
+
# Normalize to 0-255
|
|
85
|
+
cv2.normalize(magnitude_spectrum, magnitude_spectrum, 0, 255, cv2.NORM_MINMAX)
|
|
86
|
+
result_img = np.uint8(magnitude_spectrum)
|
|
87
|
+
|
|
88
|
+
# Create new message with result
|
|
89
|
+
new_msg = msg.copy()
|
|
90
|
+
|
|
91
|
+
# Encode result image
|
|
92
|
+
if not isinstance(new_msg.get(MessageKeys.PAYLOAD), dict):
|
|
93
|
+
new_msg[MessageKeys.PAYLOAD] = {}
|
|
94
|
+
|
|
95
|
+
new_msg[MessageKeys.PAYLOAD][MessageKeys.IMAGE.PATH] = self.encode_image(result_img, format_type)
|
|
96
|
+
|
|
97
|
+
# Store complex data for downstream nodes
|
|
98
|
+
# Use a hidden key to pass the raw complex numpy array
|
|
99
|
+
new_msg['_fft_data'] = dft_shift
|
|
100
|
+
new_msg['_fft_shape'] = gray.shape # Store original shape
|
|
101
|
+
|
|
102
|
+
self.send(new_msg)
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OpenCV Focus Estimation Node - measures image sharpness using various focus operators.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import cv2
|
|
6
|
+
import numpy as np
|
|
7
|
+
from typing import Any, Dict
|
|
8
|
+
from pynode.nodes.base_node import BaseNode, process_image, Info, MessageKeys
|
|
9
|
+
|
|
10
|
+
_info = Info()
|
|
11
|
+
_info.add_text("Estimates image focus/sharpness using various computational methods. Outputs a focus score indicating how sharp the image is.")
|
|
12
|
+
_info.add_header("Inputs")
|
|
13
|
+
_info.add_bullets(("Input 0:", "Source image"))
|
|
14
|
+
_info.add_header("Outputs")
|
|
15
|
+
_info.add_bullets(("Output 0:", "Message with focus score (and optionally the image)"))
|
|
16
|
+
_info.add_header("Focus Methods")
|
|
17
|
+
_info.add_bullets(
|
|
18
|
+
("Laplacian:", "Variance of Laplacian operator - best for general use"),
|
|
19
|
+
("Tenengrad:", "Sobel gradient magnitude - excellent edge detection"),
|
|
20
|
+
("Sobel + Variance:", "Combined gradient and variance - robust method"),
|
|
21
|
+
("Brenner Gradient:", "Pixel difference method - fast and simple"),
|
|
22
|
+
("Local Variance:", "Local intensity variations - works for high contrast"),
|
|
23
|
+
("Entropy:", "Shannon entropy - good for texture-rich images")
|
|
24
|
+
)
|
|
25
|
+
_info.add_header("Output")
|
|
26
|
+
_info.add_bullets(
|
|
27
|
+
("focus_score:", "Numerical sharpness value (higher = sharper)"),
|
|
28
|
+
("method:", "The focus estimation method used"),
|
|
29
|
+
("image:", "Original image (if 'Include Image' is enabled)")
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
class FocusEstimationNode(BaseNode):
|
|
33
|
+
"""
|
|
34
|
+
Focus Estimation node - measures image sharpness using various focus operators.
|
|
35
|
+
"""
|
|
36
|
+
info = str(_info)
|
|
37
|
+
display_name = 'Focus Estimation'
|
|
38
|
+
icon = '⌖'
|
|
39
|
+
category = 'opencv'
|
|
40
|
+
color = '#4A90D9'
|
|
41
|
+
border_color = '#2E6BB0'
|
|
42
|
+
text_color = '#FFFFFF'
|
|
43
|
+
input_count = 1
|
|
44
|
+
output_count = 1
|
|
45
|
+
|
|
46
|
+
DEFAULT_CONFIG = {
|
|
47
|
+
'method': 'laplacian',
|
|
48
|
+
'include_image': False
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
properties = [
|
|
52
|
+
{
|
|
53
|
+
'name': 'method',
|
|
54
|
+
'label': 'Focus Method',
|
|
55
|
+
'type': 'select',
|
|
56
|
+
'options': [
|
|
57
|
+
{'value': 'laplacian', 'label': 'Laplacian (Recommended)'},
|
|
58
|
+
{'value': 'tenengrad', 'label': 'Tenengrad (Sobel)'},
|
|
59
|
+
{'value': 'sobel_variance', 'label': 'Sobel + Variance'},
|
|
60
|
+
{'value': 'brenner', 'label': 'Brenner Gradient'},
|
|
61
|
+
{'value': 'variance', 'label': 'Local Variance'},
|
|
62
|
+
{'value': 'entropy', 'label': 'Entropy'}
|
|
63
|
+
],
|
|
64
|
+
'default': DEFAULT_CONFIG['method'],
|
|
65
|
+
'help': 'Method to compute focus score'
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
'name': 'include_image',
|
|
69
|
+
'label': 'Include Image',
|
|
70
|
+
'type': 'checkbox',
|
|
71
|
+
'default': DEFAULT_CONFIG['include_image'],
|
|
72
|
+
'help': 'Include image in output message'
|
|
73
|
+
}
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
def __init__(self, node_id=None, name="focus_estimation"):
|
|
77
|
+
super().__init__(node_id, name)
|
|
78
|
+
|
|
79
|
+
def _compute_laplacian(self, gray: np.ndarray) -> float:
|
|
80
|
+
"""
|
|
81
|
+
Laplacian-based focus measure.
|
|
82
|
+
Returns variance of Laplacian - sharp images have higher variance.
|
|
83
|
+
"""
|
|
84
|
+
laplacian = cv2.Laplacian(gray, cv2.CV_64F)
|
|
85
|
+
return float(np.var(laplacian))
|
|
86
|
+
|
|
87
|
+
def _compute_tenengrad(self, gray: np.ndarray) -> float:
|
|
88
|
+
"""
|
|
89
|
+
Tenengrad (Sobel gradient) focus measure.
|
|
90
|
+
Returns mean gradient magnitude - sharp images have stronger edges.
|
|
91
|
+
"""
|
|
92
|
+
sobel_x = cv2.Sobel(gray, cv2.CV_64F, 1, 0, ksize=3)
|
|
93
|
+
sobel_y = cv2.Sobel(gray, cv2.CV_64F, 0, 1, ksize=3)
|
|
94
|
+
tenengrad = np.sqrt(sobel_x**2 + sobel_y**2)
|
|
95
|
+
return float(np.mean(tenengrad))
|
|
96
|
+
|
|
97
|
+
def _compute_sobel_variance(self, gray: np.ndarray) -> float:
|
|
98
|
+
"""
|
|
99
|
+
Combined Sobel + Variance focus measure.
|
|
100
|
+
Returns sum of mean gradient magnitude and variance.
|
|
101
|
+
"""
|
|
102
|
+
sobel_x = cv2.Sobel(gray, cv2.CV_64F, 1, 0, ksize=3)
|
|
103
|
+
sobel_y = cv2.Sobel(gray, cv2.CV_64F, 0, 1, ksize=3)
|
|
104
|
+
sobel_magnitude = np.sqrt(sobel_x**2 + sobel_y**2)
|
|
105
|
+
variance = np.var(gray)
|
|
106
|
+
return float(np.mean(sobel_magnitude) + variance)
|
|
107
|
+
|
|
108
|
+
def _compute_brenner_gradient(self, gray: np.ndarray) -> float:
|
|
109
|
+
"""
|
|
110
|
+
Brenner gradient focus measure.
|
|
111
|
+
Returns sum of squared differences between pixels 2 positions apart.
|
|
112
|
+
"""
|
|
113
|
+
shifted = np.roll(gray, -2, axis=1)
|
|
114
|
+
diff = (gray.astype(np.float64) - shifted.astype(np.float64)) ** 2
|
|
115
|
+
return float(np.sum(diff))
|
|
116
|
+
|
|
117
|
+
def _compute_local_variance(self, gray: np.ndarray, ksize: int = 5) -> float:
|
|
118
|
+
"""
|
|
119
|
+
Local variance focus measure.
|
|
120
|
+
Returns mean of local variance - sharp images have higher local variations.
|
|
121
|
+
"""
|
|
122
|
+
gray_float = gray.astype(np.float64)
|
|
123
|
+
mean = cv2.blur(gray_float, (ksize, ksize))
|
|
124
|
+
squared_mean = cv2.blur(gray_float**2, (ksize, ksize))
|
|
125
|
+
variance = squared_mean - (mean**2)
|
|
126
|
+
return float(np.mean(variance))
|
|
127
|
+
|
|
128
|
+
def _compute_entropy(self, gray: np.ndarray) -> float:
|
|
129
|
+
"""
|
|
130
|
+
Entropy-based focus measure using Shannon entropy.
|
|
131
|
+
Returns entropy value - sharp images have higher entropy.
|
|
132
|
+
"""
|
|
133
|
+
# Compute histogram
|
|
134
|
+
hist = cv2.calcHist([gray], [0], None, [256], [0, 256])
|
|
135
|
+
hist = hist.flatten()
|
|
136
|
+
|
|
137
|
+
# Normalize histogram to get probability distribution
|
|
138
|
+
hist = hist / hist.sum()
|
|
139
|
+
|
|
140
|
+
# Remove zero entries
|
|
141
|
+
hist = hist[hist > 0]
|
|
142
|
+
|
|
143
|
+
# Compute Shannon entropy
|
|
144
|
+
entropy = -np.sum(hist * np.log2(hist))
|
|
145
|
+
return float(entropy)
|
|
146
|
+
|
|
147
|
+
@process_image()
|
|
148
|
+
def on_input(self, image: np.ndarray, msg: Dict[str, Any], input_index: int = 0):
|
|
149
|
+
"""Compute focus score for the input image."""
|
|
150
|
+
method = self.config.get('method', 'laplacian')
|
|
151
|
+
include_image = self.get_config_bool('include_image', False)
|
|
152
|
+
|
|
153
|
+
# Convert to grayscale for focus computation
|
|
154
|
+
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
|
|
155
|
+
|
|
156
|
+
# Compute focus score based on selected method
|
|
157
|
+
if method == 'laplacian':
|
|
158
|
+
focus_score = self._compute_laplacian(gray)
|
|
159
|
+
elif method == 'tenengrad':
|
|
160
|
+
focus_score = self._compute_tenengrad(gray)
|
|
161
|
+
elif method == 'sobel_variance':
|
|
162
|
+
focus_score = self._compute_sobel_variance(gray)
|
|
163
|
+
elif method == 'brenner':
|
|
164
|
+
focus_score = self._compute_brenner_gradient(gray)
|
|
165
|
+
elif method == 'variance':
|
|
166
|
+
focus_score = self._compute_local_variance(gray)
|
|
167
|
+
elif method == 'entropy':
|
|
168
|
+
focus_score = self._compute_entropy(gray)
|
|
169
|
+
else:
|
|
170
|
+
focus_score = self._compute_laplacian(gray)
|
|
171
|
+
|
|
172
|
+
# Create output message with focus score
|
|
173
|
+
output_msg = {MessageKeys.PAYLOAD: {}}
|
|
174
|
+
|
|
175
|
+
# Store the focus estimation results in msg.payload
|
|
176
|
+
output_msg[MessageKeys.PAYLOAD] = {
|
|
177
|
+
'focus_score': focus_score,
|
|
178
|
+
'method': method
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
# Include image if requested
|
|
182
|
+
if include_image:
|
|
183
|
+
output_msg[MessageKeys.PAYLOAD][MessageKeys.IMAGE.PATH] = image
|
|
184
|
+
|
|
185
|
+
# Send the message with focus information
|
|
186
|
+
self.send(output_msg, 0)
|
|
187
|
+
|
|
188
|
+
# Return None since we're sending a custom message
|
|
189
|
+
return None
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OpenCV Frequency Filter Node - filters frequencies and performs inverse FFT.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
import cv2
|
|
8
|
+
import numpy as np
|
|
9
|
+
from typing import Any, Dict
|
|
10
|
+
from pynode.nodes.base_node import BaseNode, Info, MessageKeys
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
_info = Info()
|
|
15
|
+
_info.add_text("Modifies frequency components (filtering) and reconstructs the image via Inverse FFT.")
|
|
16
|
+
_info.add_header("Inputs")
|
|
17
|
+
_info.add_bullet("Input 0:", "FFT Node output (must contain frequency data)")
|
|
18
|
+
_info.add_header("Outputs")
|
|
19
|
+
_info.add_bullet("Output 0:", "Filtered Image (spatial domain) or Filtered Spectrum")
|
|
20
|
+
_info.add_header("Operations")
|
|
21
|
+
_info.add_bullets(
|
|
22
|
+
("Low Pass:", "Keeps low frequencies (center), suppresses details/noise. Blurs the image."),
|
|
23
|
+
("High Pass:", "Keeps high frequencies (edges), suppresses smooth areas. Edge detection."),
|
|
24
|
+
("Notch Filter:", "Manually blocks specific frequency spots (harmonics/noise) using coordinates.")
|
|
25
|
+
)
|
|
26
|
+
_info.add_header("Notch Configuration")
|
|
27
|
+
_info.add_text("Enter coordinates to block in the 'Notch Filters' field. Format: x,y,radius; x2,y2,radius2.")
|
|
28
|
+
_info.add_text("Example: '100,200,5; 340,150,10' blocks two circular regions.")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class FrequencyFilterNode(BaseNode):
|
|
32
|
+
"""
|
|
33
|
+
Frequency Filter Node - applies masks to frequency domain data.
|
|
34
|
+
"""
|
|
35
|
+
info = str(_info)
|
|
36
|
+
display_name = 'Freq Filter'
|
|
37
|
+
icon = '⌖'
|
|
38
|
+
category = 'opencv'
|
|
39
|
+
color = '#8E44AD' # Same purple scheme
|
|
40
|
+
border_color = '#6C3483'
|
|
41
|
+
text_color = '#FFFFFF'
|
|
42
|
+
input_count = 1
|
|
43
|
+
output_count = 1
|
|
44
|
+
|
|
45
|
+
DEFAULT_CONFIG = {
|
|
46
|
+
'filter_type': 'low_pass',
|
|
47
|
+
'radius': 30,
|
|
48
|
+
'notch_filters': '',
|
|
49
|
+
'symmetric_notches': 'true',
|
|
50
|
+
'output_mode': 'reconstructed'
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
properties = [
|
|
54
|
+
{
|
|
55
|
+
'name': 'filter_type',
|
|
56
|
+
'label': 'Filter Type',
|
|
57
|
+
'type': 'select',
|
|
58
|
+
'options': [
|
|
59
|
+
{'value': 'low_pass', 'label': 'Low Pass (Blur)'},
|
|
60
|
+
{'value': 'high_pass', 'label': 'High Pass (Edges)'},
|
|
61
|
+
{'value': 'none', 'label': 'None (Pass Through / Just Notches)'}
|
|
62
|
+
],
|
|
63
|
+
'default': DEFAULT_CONFIG['filter_type']
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
'name': 'radius',
|
|
67
|
+
'label': 'Cutoff Radius',
|
|
68
|
+
'type': 'number',
|
|
69
|
+
'default': DEFAULT_CONFIG['radius'],
|
|
70
|
+
'min': 1,
|
|
71
|
+
'max': 500
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
'name': 'notch_filters',
|
|
75
|
+
'label': 'Notch Filters (x,y,r; ...)',
|
|
76
|
+
'type': 'text',
|
|
77
|
+
'default': DEFAULT_CONFIG['notch_filters'],
|
|
78
|
+
'help': 'Block specific frequencies. Format: x,y,radius (semicolon separated). E.g. 120,45,5'
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
'name': 'symmetric_notches',
|
|
82
|
+
'label': 'Apply Symmetrically',
|
|
83
|
+
'type': 'checkbox',
|
|
84
|
+
'default': DEFAULT_CONFIG['symmetric_notches'],
|
|
85
|
+
'help': 'Automatically block the symmetric point across the center (required for real signals)'
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
'name': 'output_mode',
|
|
89
|
+
'label': 'Output Mode',
|
|
90
|
+
'type': 'select',
|
|
91
|
+
'options': [
|
|
92
|
+
{'value': 'reconstructed', 'label': 'Reconstructed Image'},
|
|
93
|
+
{'value': 'spectrum', 'label': 'Filtered Spectrum (View)'},
|
|
94
|
+
{'value': 'both', 'label': 'Both (Reconstructed + Spectrum)'}
|
|
95
|
+
],
|
|
96
|
+
'default': DEFAULT_CONFIG['output_mode'],
|
|
97
|
+
'help': 'View the resulting image or the modified frequency spectrum'
|
|
98
|
+
}
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
def on_input(self, msg: Dict[str, Any], input_index: int = 0):
|
|
102
|
+
"""Process incoming frequency data."""
|
|
103
|
+
# Check for FFT data
|
|
104
|
+
dft_shift = msg.get('_fft_data')
|
|
105
|
+
|
|
106
|
+
if dft_shift is None:
|
|
107
|
+
# If no FFT data, try to look for image in payload to auto-convert (optional convenience)
|
|
108
|
+
# But strictly, we should expect FFT data.
|
|
109
|
+
# Let's pass through if we can't process
|
|
110
|
+
self.send(msg)
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
rows, cols = dft_shift.shape[:2]
|
|
114
|
+
crow, ccol = rows//2, cols//2 # Center
|
|
115
|
+
|
|
116
|
+
# Create a mask, first separate channels (2 channels: Real, Imag)
|
|
117
|
+
# We need a mask that applies to both channels
|
|
118
|
+
mask = np.zeros((rows, cols, 2), np.uint8)
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
radius = int(self.config.get('radius', 30))
|
|
122
|
+
except ValueError:
|
|
123
|
+
radius = 30
|
|
124
|
+
|
|
125
|
+
filter_type = self.config.get('filter_type', 'low_pass')
|
|
126
|
+
|
|
127
|
+
# Create circular mask area
|
|
128
|
+
# Using grid indices
|
|
129
|
+
y, x = np.ogrid[:rows, :cols]
|
|
130
|
+
mask_area = (x - ccol)**2 + (y - crow)**2 <= radius*radius
|
|
131
|
+
|
|
132
|
+
if filter_type == 'low_pass':
|
|
133
|
+
# 1 inside circle, 0 outside
|
|
134
|
+
mask[mask_area] = 1
|
|
135
|
+
elif filter_type == 'high_pass':
|
|
136
|
+
# 0 inside circle, 1 outside
|
|
137
|
+
mask[~mask_area] = 1
|
|
138
|
+
else: # none / pass-through
|
|
139
|
+
mask.fill(1)
|
|
140
|
+
|
|
141
|
+
# Apply Notch Filters (Manual Blocking)
|
|
142
|
+
notch_str = self.config.get('notch_filters', '')
|
|
143
|
+
symmetric = self.config.get('symmetric_notches', 'true') == 'true'
|
|
144
|
+
|
|
145
|
+
if notch_str:
|
|
146
|
+
try:
|
|
147
|
+
for notch_def in notch_str.split(';'):
|
|
148
|
+
if not notch_def.strip():
|
|
149
|
+
continue
|
|
150
|
+
|
|
151
|
+
parts = [float(p.strip()) for p in notch_def.split(',') if p.strip()]
|
|
152
|
+
if len(parts) >= 2:
|
|
153
|
+
nx, ny = int(parts[0]), int(parts[1])
|
|
154
|
+
# Default radius 5 if not specified
|
|
155
|
+
nr = int(parts[2]) if len(parts) > 2 else 5
|
|
156
|
+
|
|
157
|
+
# Apply notch at (nx, ny)
|
|
158
|
+
# Remember x matches cols index, y matches rows index
|
|
159
|
+
notch_area = (x - nx)**2 + (y - ny)**2 <= nr*nr
|
|
160
|
+
mask[notch_area] = 0
|
|
161
|
+
|
|
162
|
+
if symmetric:
|
|
163
|
+
# Calculate symmetric point relative to center (ccol, crow)
|
|
164
|
+
# Actually relative to image dimensions: (cols - x, rows - y)
|
|
165
|
+
# Center is at axis of rotation
|
|
166
|
+
# Point (x, y) reflected across (cx, cy) is (2cx - x, 2cy - y)
|
|
167
|
+
# Since cx=cols/2, 2cx = cols. So (cols - x, rows - y) approximately
|
|
168
|
+
# Let's use exact center calculation
|
|
169
|
+
|
|
170
|
+
# Center of image is (cols/2, rows/2).
|
|
171
|
+
# Because indices are 0-based, exact center is (cols-1)/2 ??
|
|
172
|
+
# FFT shift puts DC at rows//2, cols//2.
|
|
173
|
+
|
|
174
|
+
sym_x = 2 * ccol - nx
|
|
175
|
+
sym_y = 2 * crow - ny
|
|
176
|
+
|
|
177
|
+
# Ensure within bounds
|
|
178
|
+
# if 0 <= sym_x < cols and 0 <= sym_y < rows:
|
|
179
|
+
# Actually, simpler logic:
|
|
180
|
+
# The mask uses coordinates. Creating a circular mask at sym coords works.
|
|
181
|
+
|
|
182
|
+
notch_area_sym = (x - sym_x)**2 + (y - sym_y)**2 <= nr*nr
|
|
183
|
+
mask[notch_area_sym] = 0
|
|
184
|
+
|
|
185
|
+
except Exception as e:
|
|
186
|
+
# Log error but continue with what we have
|
|
187
|
+
logger.error(f"Error parsing notch filters: {e}")
|
|
188
|
+
|
|
189
|
+
# Apply mask
|
|
190
|
+
fshift = dft_shift * mask
|
|
191
|
+
|
|
192
|
+
result_img = None
|
|
193
|
+
spectrum_img = None
|
|
194
|
+
output_mode = self.config.get('output_mode', 'reconstructed')
|
|
195
|
+
|
|
196
|
+
def compute_spectrum(f_data):
|
|
197
|
+
mag = cv2.magnitude(f_data[:,:,0], f_data[:,:,1])
|
|
198
|
+
mag_spec = 20 * np.log(mag + 1)
|
|
199
|
+
cv2.normalize(mag_spec, mag_spec, 0, 255, cv2.NORM_MINMAX)
|
|
200
|
+
return np.uint8(mag_spec)
|
|
201
|
+
|
|
202
|
+
def compute_reconstruction(f_data):
|
|
203
|
+
f_ishift = np.fft.ifftshift(f_data)
|
|
204
|
+
img_b = cv2.idft(f_ishift)
|
|
205
|
+
img_b = cv2.magnitude(img_b[:,:,0], img_b[:,:,1])
|
|
206
|
+
cv2.normalize(img_b, img_b, 0, 255, cv2.NORM_MINMAX)
|
|
207
|
+
return np.uint8(img_b)
|
|
208
|
+
|
|
209
|
+
if output_mode == 'spectrum':
|
|
210
|
+
result_img = compute_spectrum(fshift)
|
|
211
|
+
elif output_mode == 'both':
|
|
212
|
+
result_img = compute_reconstruction(fshift)
|
|
213
|
+
spectrum_img = compute_spectrum(fshift)
|
|
214
|
+
else:
|
|
215
|
+
result_img = compute_reconstruction(fshift)
|
|
216
|
+
|
|
217
|
+
# Create response
|
|
218
|
+
new_msg = msg.copy()
|
|
219
|
+
|
|
220
|
+
# Update payload
|
|
221
|
+
# Use existing format type or default to jpg
|
|
222
|
+
# We can extract format from payload path if present, or just assume
|
|
223
|
+
# The encode_image method usually handles basic types.
|
|
224
|
+
|
|
225
|
+
# We need to construct a proper payload structure if it doesn't exist
|
|
226
|
+
if MessageKeys.PAYLOAD not in new_msg or not isinstance(new_msg[MessageKeys.PAYLOAD], dict):
|
|
227
|
+
new_msg[MessageKeys.PAYLOAD] = {}
|
|
228
|
+
|
|
229
|
+
# Encode
|
|
230
|
+
# Use bgr_numpy_dict to provide a consistent object structure (data, width, height, etc)
|
|
231
|
+
# while keeping the data as numpy array for efficiency.
|
|
232
|
+
format_type = 'bgr_numpy_dict'
|
|
233
|
+
new_msg[MessageKeys.PAYLOAD][MessageKeys.IMAGE.PATH] = self.encode_image(result_img, format_type)
|
|
234
|
+
|
|
235
|
+
if spectrum_img is not None:
|
|
236
|
+
new_msg[MessageKeys.PAYLOAD]['spectrum'] = self.encode_image(spectrum_img, format_type)
|
|
237
|
+
|
|
238
|
+
# Update metadata for chaining (optional)
|
|
239
|
+
new_msg['_fft_data'] = fshift
|
|
240
|
+
|
|
241
|
+
self.send(new_msg)
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OpenCV Histogram Node - computes and optionally equalizes image histograms.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import cv2
|
|
6
|
+
import numpy as np
|
|
7
|
+
from typing import Any, Dict
|
|
8
|
+
from pynode.nodes.base_node import BaseNode, process_image, Info
|
|
9
|
+
|
|
10
|
+
_info = Info()
|
|
11
|
+
_info.add_text("Computes histograms and applies histogram operations for contrast enhancement. For color images, operates on the L channel in LAB color space.")
|
|
12
|
+
_info.add_header("Inputs")
|
|
13
|
+
_info.add_bullets(("Input 0:", "Image (color or grayscale)"))
|
|
14
|
+
_info.add_header("Outputs")
|
|
15
|
+
_info.add_bullets(
|
|
16
|
+
("Output 0:", "Processed image"),
|
|
17
|
+
("msg.histogram:", "Array of 256 values representing pixel distribution")
|
|
18
|
+
)
|
|
19
|
+
_info.add_header("Operations")
|
|
20
|
+
_info.add_bullets(
|
|
21
|
+
("Compute only:", "Calculate histogram without modifying image"),
|
|
22
|
+
("Equalize:", "Standard histogram equalization for global contrast"),
|
|
23
|
+
("CLAHE:", "Adaptive equalization that prevents over-amplification"),
|
|
24
|
+
("Normalize:", "Stretch pixel values to specified range")
|
|
25
|
+
)
|
|
26
|
+
_info.add_header("Tips")
|
|
27
|
+
_info.add_bullets(
|
|
28
|
+
("CLAHE:", "Best for images with varying lighting conditions"),
|
|
29
|
+
("Clip Limit:", "Higher values = more contrast, may increase noise")
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class HistogramNode(BaseNode):
|
|
34
|
+
info = str(_info)
|
|
35
|
+
"""
|
|
36
|
+
Histogram node - computes histograms and applies histogram equalization.
|
|
37
|
+
"""
|
|
38
|
+
display_name = 'Histogram'
|
|
39
|
+
icon = '📊'
|
|
40
|
+
category = 'opencv'
|
|
41
|
+
color = '#4A90D9'
|
|
42
|
+
border_color = '#2E6BB0'
|
|
43
|
+
text_color = '#FFFFFF'
|
|
44
|
+
input_count = 1
|
|
45
|
+
output_count = 1
|
|
46
|
+
|
|
47
|
+
DEFAULT_CONFIG = {
|
|
48
|
+
'operation': 'equalize',
|
|
49
|
+
'clip_limit': 2.0,
|
|
50
|
+
'tile_size': 8,
|
|
51
|
+
'normalize_alpha': 0,
|
|
52
|
+
'normalize_beta': 255
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
properties = [
|
|
56
|
+
{
|
|
57
|
+
'name': 'operation',
|
|
58
|
+
'label': 'Operation',
|
|
59
|
+
'type': 'select',
|
|
60
|
+
'options': [
|
|
61
|
+
{'value': 'compute', 'label': 'Compute histogram only'},
|
|
62
|
+
{'value': 'equalize', 'label': 'Histogram equalization'},
|
|
63
|
+
{'value': 'clahe', 'label': 'CLAHE (adaptive)'},
|
|
64
|
+
{'value': 'normalize', 'label': 'Normalize'}
|
|
65
|
+
],
|
|
66
|
+
'default': DEFAULT_CONFIG['operation'],
|
|
67
|
+
'help': 'Histogram operation to perform'
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
'name': 'clip_limit',
|
|
71
|
+
'label': 'CLAHE Clip Limit',
|
|
72
|
+
'type': 'number',
|
|
73
|
+
'default': DEFAULT_CONFIG['clip_limit'],
|
|
74
|
+
'min': 0.1,
|
|
75
|
+
'max': 10,
|
|
76
|
+
'step': 0.1,
|
|
77
|
+
'help': 'Threshold for contrast limiting (CLAHE)',
|
|
78
|
+
'showIf': {'operation': 'clahe'}
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
'name': 'tile_size',
|
|
82
|
+
'label': 'CLAHE Tile Size',
|
|
83
|
+
'type': 'number',
|
|
84
|
+
'default': DEFAULT_CONFIG['tile_size'],
|
|
85
|
+
'min': 2,
|
|
86
|
+
'max': 32,
|
|
87
|
+
'help': 'Size of grid for CLAHE',
|
|
88
|
+
'showIf': {'operation': 'clahe'}
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
'name': 'normalize_alpha',
|
|
92
|
+
'label': 'Normalize Alpha',
|
|
93
|
+
'type': 'number',
|
|
94
|
+
'default': DEFAULT_CONFIG['normalize_alpha'],
|
|
95
|
+
'min': 0,
|
|
96
|
+
'max': 255,
|
|
97
|
+
'help': 'Lower bound for normalization',
|
|
98
|
+
'showIf': {'operation': 'normalize'}
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
'name': 'normalize_beta',
|
|
102
|
+
'label': 'Normalize Beta',
|
|
103
|
+
'type': 'number',
|
|
104
|
+
'default': DEFAULT_CONFIG['normalize_beta'],
|
|
105
|
+
'min': 0,
|
|
106
|
+
'max': 255,
|
|
107
|
+
'help': 'Upper bound for normalization',
|
|
108
|
+
'showIf': {'operation': 'normalize'}
|
|
109
|
+
}
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
def __init__(self, node_id=None, name="histogram"):
|
|
113
|
+
super().__init__(node_id, name)
|
|
114
|
+
|
|
115
|
+
@process_image()
|
|
116
|
+
def on_input(self, image: np.ndarray, msg: Dict[str, Any], input_index: int = 0):
|
|
117
|
+
"""Process image histogram."""
|
|
118
|
+
operation = self.config.get('operation', 'equalize')
|
|
119
|
+
clip_limit = self.get_config_float('clip_limit', 2.0)
|
|
120
|
+
tile_size = self.get_config_int('tile_size', 8)
|
|
121
|
+
alpha = self.get_config_int('normalize_alpha', 0)
|
|
122
|
+
beta = self.get_config_int('normalize_beta', 255)
|
|
123
|
+
|
|
124
|
+
# Convert to grayscale for processing if color
|
|
125
|
+
if len(image.shape) == 3:
|
|
126
|
+
is_color = True
|
|
127
|
+
# Convert to LAB for better color handling
|
|
128
|
+
lab = cv2.cvtColor(image, cv2.COLOR_BGR2LAB)
|
|
129
|
+
l_channel = lab[:, :, 0]
|
|
130
|
+
else:
|
|
131
|
+
is_color = False
|
|
132
|
+
l_channel = image
|
|
133
|
+
|
|
134
|
+
# Compute histogram
|
|
135
|
+
hist = cv2.calcHist([l_channel], [0], None, [256], [0, 256])
|
|
136
|
+
extra_fields = {'histogram': hist.flatten().tolist()}
|
|
137
|
+
|
|
138
|
+
if operation == 'compute':
|
|
139
|
+
# Just compute, don't modify image
|
|
140
|
+
return image, extra_fields
|
|
141
|
+
|
|
142
|
+
if operation == 'equalize':
|
|
143
|
+
result = cv2.equalizeHist(l_channel)
|
|
144
|
+
elif operation == 'clahe':
|
|
145
|
+
clahe = cv2.createCLAHE(clipLimit=clip_limit,
|
|
146
|
+
tileGridSize=(tile_size, tile_size))
|
|
147
|
+
result = clahe.apply(l_channel)
|
|
148
|
+
elif operation == 'normalize':
|
|
149
|
+
result = cv2.normalize(l_channel, None, alpha, beta, cv2.NORM_MINMAX)
|
|
150
|
+
else:
|
|
151
|
+
result = l_channel
|
|
152
|
+
|
|
153
|
+
# Reconstruct color image if needed
|
|
154
|
+
if is_color:
|
|
155
|
+
lab[:, :, 0] = result
|
|
156
|
+
output = cv2.cvtColor(lab, cv2.COLOR_LAB2BGR)
|
|
157
|
+
else:
|
|
158
|
+
output = result
|
|
159
|
+
|
|
160
|
+
return output, extra_fields
|