fieldview 0.1.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.
- fieldview/__init__.py +0 -0
- fieldview/core/__init__.py +0 -0
- fieldview/core/data_container.py +158 -0
- fieldview/layers/__init__.py +0 -0
- fieldview/layers/data_layer.py +89 -0
- fieldview/layers/heatmap_layer.py +332 -0
- fieldview/layers/layer.py +31 -0
- fieldview/layers/pin_layer.py +37 -0
- fieldview/layers/svg_layer.py +33 -0
- fieldview/layers/text_layer.py +224 -0
- fieldview/rendering/__init__.py +0 -0
- fieldview/rendering/colormaps.py +97 -0
- fieldview/resources/fonts/JetBrainsMono-Regular.ttf +0 -0
- fieldview/ui/__init__.py +0 -0
- fieldview-0.1.0.dist-info/METADATA +121 -0
- fieldview-0.1.0.dist-info/RECORD +18 -0
- fieldview-0.1.0.dist-info/WHEEL +4 -0
- fieldview-0.1.0.dist-info/licenses/LICENSE +21 -0
fieldview/__init__.py
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from PySide6.QtCore import QObject, Signal
|
|
3
|
+
|
|
4
|
+
class DataContainer(QObject):
|
|
5
|
+
"""
|
|
6
|
+
Manages the core data (points and values) for the FieldView library.
|
|
7
|
+
Emits signals when data changes.
|
|
8
|
+
"""
|
|
9
|
+
dataChanged = Signal()
|
|
10
|
+
|
|
11
|
+
def __init__(self):
|
|
12
|
+
super().__init__()
|
|
13
|
+
self._points = np.empty((0, 2), dtype=float)
|
|
14
|
+
self._values = np.empty((0,), dtype=float)
|
|
15
|
+
self._labels = []
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def points(self):
|
|
19
|
+
return self._points
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def values(self):
|
|
23
|
+
return self._values
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def labels(self):
|
|
27
|
+
return self._labels
|
|
28
|
+
|
|
29
|
+
def set_data(self, points, values, labels=None):
|
|
30
|
+
"""
|
|
31
|
+
Sets the data points, values, and optional labels.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
points (np.ndarray): Nx2 array of (x, y) coordinates.
|
|
35
|
+
values (np.ndarray): N array of values.
|
|
36
|
+
labels (list): Optional list of N strings.
|
|
37
|
+
"""
|
|
38
|
+
points = np.array(points)
|
|
39
|
+
values = np.array(values)
|
|
40
|
+
|
|
41
|
+
if points.ndim != 2 or points.shape[1] != 2:
|
|
42
|
+
raise ValueError("Points must be an Nx2 array.")
|
|
43
|
+
if values.ndim != 1:
|
|
44
|
+
raise ValueError("Values must be a 1D array.")
|
|
45
|
+
if len(points) != len(values):
|
|
46
|
+
raise ValueError("Points and values must have the same length.")
|
|
47
|
+
|
|
48
|
+
if labels is None:
|
|
49
|
+
labels = [""] * len(points)
|
|
50
|
+
elif len(labels) != len(points):
|
|
51
|
+
raise ValueError("Labels must have the same length as points.")
|
|
52
|
+
|
|
53
|
+
self._points = points
|
|
54
|
+
self._values = values
|
|
55
|
+
self._labels = list(labels)
|
|
56
|
+
self.dataChanged.emit()
|
|
57
|
+
|
|
58
|
+
def add_points(self, points, values, labels=None):
|
|
59
|
+
"""
|
|
60
|
+
Adds new points, values, and optional labels.
|
|
61
|
+
"""
|
|
62
|
+
points = np.array(points)
|
|
63
|
+
values = np.array(values)
|
|
64
|
+
|
|
65
|
+
if len(points) == 0:
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
if labels is None:
|
|
69
|
+
labels = [""] * len(points)
|
|
70
|
+
elif len(labels) != len(points):
|
|
71
|
+
raise ValueError("Labels must have the same length as points.")
|
|
72
|
+
|
|
73
|
+
if self._points.shape[0] == 0:
|
|
74
|
+
self.set_data(points, values, labels)
|
|
75
|
+
else:
|
|
76
|
+
self._points = np.vstack((self._points, points))
|
|
77
|
+
self._values = np.concatenate((self._values, values))
|
|
78
|
+
self._labels.extend(labels)
|
|
79
|
+
self.dataChanged.emit()
|
|
80
|
+
|
|
81
|
+
def update_point(self, index, value=None, point=None, label=None):
|
|
82
|
+
"""
|
|
83
|
+
Updates the value, coordinate, or label of a specific point.
|
|
84
|
+
"""
|
|
85
|
+
if index < 0 or index >= len(self._points):
|
|
86
|
+
raise IndexError("Point index out of range.")
|
|
87
|
+
|
|
88
|
+
changed = False
|
|
89
|
+
if value is not None:
|
|
90
|
+
self._values[index] = value
|
|
91
|
+
changed = True
|
|
92
|
+
|
|
93
|
+
if point is not None:
|
|
94
|
+
self._points[index] = point
|
|
95
|
+
changed = True
|
|
96
|
+
|
|
97
|
+
if label is not None:
|
|
98
|
+
self._labels[index] = label
|
|
99
|
+
changed = True
|
|
100
|
+
|
|
101
|
+
if changed:
|
|
102
|
+
self.dataChanged.emit()
|
|
103
|
+
|
|
104
|
+
def remove_points(self, indices):
|
|
105
|
+
"""
|
|
106
|
+
Removes points at the specified indices.
|
|
107
|
+
"""
|
|
108
|
+
if len(indices) == 0:
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
# Sort indices in descending order to avoid shifting issues if we were popping,
|
|
112
|
+
# but numpy delete handles it. For list, we need to be careful.
|
|
113
|
+
# It's better to create a mask or rebuild the list.
|
|
114
|
+
|
|
115
|
+
mask = np.ones(len(self._points), dtype=bool)
|
|
116
|
+
mask[indices] = False
|
|
117
|
+
|
|
118
|
+
self._points = self._points[mask]
|
|
119
|
+
self._values = self._values[mask]
|
|
120
|
+
self._labels = [self._labels[i] for i in range(len(self._labels)) if mask[i]]
|
|
121
|
+
|
|
122
|
+
self.dataChanged.emit()
|
|
123
|
+
|
|
124
|
+
def clear(self):
|
|
125
|
+
"""
|
|
126
|
+
Removes all data.
|
|
127
|
+
"""
|
|
128
|
+
self._points = np.empty((0, 2), dtype=float)
|
|
129
|
+
self._values = np.empty((0,), dtype=float)
|
|
130
|
+
self._labels = []
|
|
131
|
+
self.dataChanged.emit()
|
|
132
|
+
|
|
133
|
+
def get_closest_point(self, x, y, threshold=None):
|
|
134
|
+
"""
|
|
135
|
+
Finds the index of the closest point to (x, y).
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
x, y: Coordinates.
|
|
139
|
+
threshold: Optional maximum distance. If closest point is further, returns None.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
int: Index of the closest point, or None.
|
|
143
|
+
"""
|
|
144
|
+
if len(self._points) == 0:
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
# Calculate squared distances
|
|
148
|
+
diff = self._points - np.array([x, y])
|
|
149
|
+
dist_sq = np.sum(diff**2, axis=1)
|
|
150
|
+
|
|
151
|
+
min_idx = np.argmin(dist_sq)
|
|
152
|
+
min_dist_sq = dist_sq[min_idx]
|
|
153
|
+
|
|
154
|
+
if threshold is not None:
|
|
155
|
+
if min_dist_sq > threshold**2:
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
return min_idx
|
|
File without changes
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from PySide6.QtCore import QRectF
|
|
2
|
+
import numpy as np
|
|
3
|
+
from fieldview.layers.layer import Layer
|
|
4
|
+
from fieldview.core.data_container import DataContainer
|
|
5
|
+
|
|
6
|
+
class DataLayer(Layer):
|
|
7
|
+
"""
|
|
8
|
+
Base class for layers that visualize data from a DataContainer.
|
|
9
|
+
Handles data change signals and excluded indices.
|
|
10
|
+
"""
|
|
11
|
+
def __init__(self, data_container: DataContainer, parent=None):
|
|
12
|
+
super().__init__(parent)
|
|
13
|
+
self._data_container = data_container
|
|
14
|
+
self._excluded_indices = set()
|
|
15
|
+
|
|
16
|
+
# Connect signal
|
|
17
|
+
self._data_container.dataChanged.connect(self.on_data_changed)
|
|
18
|
+
# Initial update
|
|
19
|
+
self.on_data_changed()
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def data_container(self):
|
|
23
|
+
return self._data_container
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def excluded_indices(self):
|
|
27
|
+
return self._excluded_indices
|
|
28
|
+
|
|
29
|
+
def set_excluded_indices(self, indices):
|
|
30
|
+
"""
|
|
31
|
+
Sets the set of indices to exclude from visualization.
|
|
32
|
+
"""
|
|
33
|
+
self._excluded_indices = set(indices)
|
|
34
|
+
self.update_layer()
|
|
35
|
+
|
|
36
|
+
def add_excluded_index(self, index):
|
|
37
|
+
self._excluded_indices.add(index)
|
|
38
|
+
self.update_layer()
|
|
39
|
+
|
|
40
|
+
def remove_excluded_index(self, index):
|
|
41
|
+
if index in self._excluded_indices:
|
|
42
|
+
self._excluded_indices.remove(index)
|
|
43
|
+
self.update_layer()
|
|
44
|
+
|
|
45
|
+
def clear_excluded_indices(self):
|
|
46
|
+
self._excluded_indices.clear()
|
|
47
|
+
self.update_layer()
|
|
48
|
+
|
|
49
|
+
def on_data_changed(self):
|
|
50
|
+
"""
|
|
51
|
+
Slot called when DataContainer data changes.
|
|
52
|
+
"""
|
|
53
|
+
self._update_bounding_rect()
|
|
54
|
+
self.update_layer()
|
|
55
|
+
|
|
56
|
+
def _update_bounding_rect(self):
|
|
57
|
+
points = self._data_container.points
|
|
58
|
+
if len(points) == 0:
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
min_x = np.min(points[:, 0])
|
|
62
|
+
max_x = np.max(points[:, 0])
|
|
63
|
+
min_y = np.min(points[:, 1])
|
|
64
|
+
max_y = np.max(points[:, 1])
|
|
65
|
+
|
|
66
|
+
# Add padding (e.g., for icons or text)
|
|
67
|
+
padding = 50
|
|
68
|
+
rect = QRectF(min_x - padding, min_y - padding,
|
|
69
|
+
max_x - min_x + 2*padding, max_y - min_y + 2*padding)
|
|
70
|
+
self.set_bounding_rect(rect)
|
|
71
|
+
|
|
72
|
+
def get_valid_data(self):
|
|
73
|
+
"""
|
|
74
|
+
Returns points, values, and labels excluding the excluded indices.
|
|
75
|
+
"""
|
|
76
|
+
points = self._data_container.points
|
|
77
|
+
values = self._data_container.values
|
|
78
|
+
labels = self._data_container.labels
|
|
79
|
+
|
|
80
|
+
if not self._excluded_indices:
|
|
81
|
+
return points, values, labels
|
|
82
|
+
|
|
83
|
+
# Create a mask for valid indices
|
|
84
|
+
mask = [i for i in range(len(points)) if i not in self._excluded_indices]
|
|
85
|
+
|
|
86
|
+
# Filter labels (list)
|
|
87
|
+
valid_labels = [labels[i] for i in mask]
|
|
88
|
+
|
|
89
|
+
return points[mask], values[mask], valid_labels
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import time
|
|
3
|
+
from scipy.interpolate import RBFInterpolator, LinearNDInterpolator
|
|
4
|
+
from scipy.spatial import cKDTree
|
|
5
|
+
from PySide6.QtGui import QImage, QPainter, QColor, QPolygonF, QPainterPath
|
|
6
|
+
from PySide6.QtCore import Qt, QTimer, QRectF, QPointF, Signal
|
|
7
|
+
|
|
8
|
+
from fieldview.layers.data_layer import DataLayer
|
|
9
|
+
|
|
10
|
+
from fieldview.rendering.colormaps import get_colormap
|
|
11
|
+
|
|
12
|
+
class HeatmapLayer(DataLayer):
|
|
13
|
+
"""
|
|
14
|
+
Layer for rendering a heatmap from data points.
|
|
15
|
+
Implements hybrid interpolation (Linear for speed, RBF for quality)
|
|
16
|
+
and dynamic quality adjustment.
|
|
17
|
+
Supports arbitrary polygon boundaries.
|
|
18
|
+
"""
|
|
19
|
+
renderingFinished = Signal(float, int) # Duration in ms, Grid Size
|
|
20
|
+
|
|
21
|
+
def __init__(self, data_container, parent=None):
|
|
22
|
+
super().__init__(data_container, parent)
|
|
23
|
+
|
|
24
|
+
# Configuration
|
|
25
|
+
self._boundary_shape = QPolygonF() # Default empty
|
|
26
|
+
self._auto_boundary = True # Default to auto-fit data
|
|
27
|
+
self._grid_size = 50 # Start low for fast initial render
|
|
28
|
+
self._neighbors = 30
|
|
29
|
+
self._target_render_time = 100 # Default High (100ms)
|
|
30
|
+
self._hq_delay = 300 # ms
|
|
31
|
+
self._colormap = get_colormap("viridis")
|
|
32
|
+
|
|
33
|
+
# Initialize with empty shape, will be set by on_data_changed if data exists
|
|
34
|
+
# or user can set it manually.
|
|
35
|
+
|
|
36
|
+
# State
|
|
37
|
+
self._cached_image = None
|
|
38
|
+
self._heatmap_rect = QRectF()
|
|
39
|
+
self._is_hq_pending = False
|
|
40
|
+
|
|
41
|
+
# Timer for High Quality update
|
|
42
|
+
self._hq_timer = QTimer()
|
|
43
|
+
self._hq_timer.setSingleShot(True)
|
|
44
|
+
self._hq_timer.setInterval(self._hq_delay)
|
|
45
|
+
self._hq_timer.timeout.connect(self._perform_hq_update)
|
|
46
|
+
|
|
47
|
+
# Initial update
|
|
48
|
+
self.on_data_changed()
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def colormap(self):
|
|
52
|
+
return self._colormap.name
|
|
53
|
+
|
|
54
|
+
@colormap.setter
|
|
55
|
+
def colormap(self, name):
|
|
56
|
+
self._colormap = get_colormap(name)
|
|
57
|
+
self.update_layer()
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def target_render_time(self):
|
|
61
|
+
return self._target_render_time
|
|
62
|
+
|
|
63
|
+
@target_render_time.setter
|
|
64
|
+
def target_render_time(self, ms):
|
|
65
|
+
self._target_render_time = float(ms)
|
|
66
|
+
# Trigger update to adapt immediately
|
|
67
|
+
self.on_data_changed()
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def quality(self):
|
|
71
|
+
# Backward compatibility / UI helper
|
|
72
|
+
if self._target_render_time <= 20: return 'low'
|
|
73
|
+
if self._target_render_time <= 50: return 'medium'
|
|
74
|
+
return 'high'
|
|
75
|
+
|
|
76
|
+
@quality.setter
|
|
77
|
+
def quality(self, value):
|
|
78
|
+
if isinstance(value, str):
|
|
79
|
+
value = value.lower()
|
|
80
|
+
|
|
81
|
+
if value in ['low', 0]:
|
|
82
|
+
self.target_render_time = 20
|
|
83
|
+
elif value in ['medium', 1]:
|
|
84
|
+
self.target_render_time = 50
|
|
85
|
+
elif value in ['high', 2]:
|
|
86
|
+
self.target_render_time = 100
|
|
87
|
+
else:
|
|
88
|
+
print(f"Warning: Invalid quality '{value}'. Ignoring.")
|
|
89
|
+
|
|
90
|
+
def set_boundary_shape(self, shape):
|
|
91
|
+
"""
|
|
92
|
+
Sets the boundary polygon for the heatmap.
|
|
93
|
+
Accepts QPolygonF, QRectF, or QPainterPath.
|
|
94
|
+
Disables auto-boundary mode.
|
|
95
|
+
"""
|
|
96
|
+
self._auto_boundary = False
|
|
97
|
+
|
|
98
|
+
if isinstance(shape, QRectF):
|
|
99
|
+
self._boundary_shape = QPolygonF(shape)
|
|
100
|
+
elif isinstance(shape, QPainterPath):
|
|
101
|
+
self._boundary_shape = shape.toFillPolygon()
|
|
102
|
+
elif isinstance(shape, QPolygonF):
|
|
103
|
+
self._boundary_shape = shape
|
|
104
|
+
else:
|
|
105
|
+
raise TypeError("Shape must be QPolygonF, QRectF, or QPainterPath")
|
|
106
|
+
|
|
107
|
+
self.set_bounding_rect(self._boundary_shape.boundingRect())
|
|
108
|
+
# Trigger update to regenerate heatmap with new boundary
|
|
109
|
+
self.on_data_changed()
|
|
110
|
+
|
|
111
|
+
def on_data_changed(self):
|
|
112
|
+
"""
|
|
113
|
+
Override to trigger fast update and schedule HQ update.
|
|
114
|
+
"""
|
|
115
|
+
# Check if initialized
|
|
116
|
+
if not hasattr(self, '_grid_size'):
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
# Auto-boundary logic
|
|
120
|
+
if self._auto_boundary:
|
|
121
|
+
points, _, _ = self.get_valid_data()
|
|
122
|
+
if len(points) > 0:
|
|
123
|
+
min_x = np.min(points[:, 0])
|
|
124
|
+
max_x = np.max(points[:, 0])
|
|
125
|
+
min_y = np.min(points[:, 1])
|
|
126
|
+
max_y = np.max(points[:, 1])
|
|
127
|
+
|
|
128
|
+
# Add some padding (e.g. 10%)
|
|
129
|
+
width = max_x - min_x
|
|
130
|
+
height = max_y - min_y
|
|
131
|
+
padding_x = max(10, width * 0.1)
|
|
132
|
+
padding_y = max(10, height * 0.1)
|
|
133
|
+
|
|
134
|
+
rect = QRectF(min_x - padding_x, min_y - padding_y,
|
|
135
|
+
width + 2*padding_x, height + 2*padding_y)
|
|
136
|
+
self._boundary_shape = QPolygonF(rect)
|
|
137
|
+
self.set_bounding_rect(rect)
|
|
138
|
+
else:
|
|
139
|
+
self._boundary_shape = QPolygonF()
|
|
140
|
+
self.set_bounding_rect(QRectF())
|
|
141
|
+
|
|
142
|
+
# 1. Cancel any pending HQ update
|
|
143
|
+
if hasattr(self, '_hq_timer'):
|
|
144
|
+
self._hq_timer.stop()
|
|
145
|
+
|
|
146
|
+
# 2. Perform Fast Update (Low-Res RBF)
|
|
147
|
+
# Use 1/10th of the grid size for speed (e.g., 30x30 instead of 300x300)
|
|
148
|
+
low_res_size = max(10, int(self._grid_size / 1.6))
|
|
149
|
+
self._generate_heatmap(method='rbf', neighbors=self._neighbors, grid_size=low_res_size)
|
|
150
|
+
self.update()
|
|
151
|
+
|
|
152
|
+
# 3. Schedule High Quality Update
|
|
153
|
+
if hasattr(self, '_hq_timer'):
|
|
154
|
+
self._hq_timer.start()
|
|
155
|
+
|
|
156
|
+
def _perform_hq_update(self):
|
|
157
|
+
"""
|
|
158
|
+
Slot for HQ timer timeout. Performs RBF interpolation.
|
|
159
|
+
"""
|
|
160
|
+
self._generate_heatmap(method='rbf', neighbors=self._neighbors, grid_size=self._grid_size)
|
|
161
|
+
self.update()
|
|
162
|
+
|
|
163
|
+
def _generate_heatmap(self, method='rbf', neighbors=30, grid_size=None):
|
|
164
|
+
"""
|
|
165
|
+
Generates the heatmap image.
|
|
166
|
+
"""
|
|
167
|
+
start_time = time.perf_counter()
|
|
168
|
+
|
|
169
|
+
if grid_size is None:
|
|
170
|
+
grid_size = self._grid_size
|
|
171
|
+
|
|
172
|
+
points, values, _ = self.get_valid_data()
|
|
173
|
+
|
|
174
|
+
if len(points) < 3 or self._boundary_shape.isEmpty():
|
|
175
|
+
self._cached_image = None
|
|
176
|
+
return
|
|
177
|
+
|
|
178
|
+
# print(f"Total: {duration_ms:.1f}ms | Boundary: {(t1-t0)*1000:.1f}ms | Interp: {(t3-t2)*1000:.1f}ms | Image: {(t4-t3)*1000:.1f}ms")
|
|
179
|
+
|
|
180
|
+
self.renderingFinished.emit(duration_ms, grid_size)
|
|
181
|
+
|
|
182
|
+
# 7. Adaptive Quality Adjustment
|
|
183
|
+
if self._target_render_time > 0 and duration_ms > 0:
|
|
184
|
+
# Calculate target grid size
|
|
185
|
+
# Time is roughly proportional to grid_size^2 (number of evaluation points)
|
|
186
|
+
# target_time / current_time = target_grid^2 / current_grid^2
|
|
187
|
+
# target_grid = current_grid * sqrt(target_time / current_time)
|
|
188
|
+
|
|
189
|
+
ratio = self._target_render_time / duration_ms
|
|
190
|
+
# Clamp ratio to prevent wild swings (e.g., 0.5x to 2.0x)
|
|
191
|
+
ratio = max(0.5, min(2.0, ratio))
|
|
192
|
+
|
|
193
|
+
new_grid_size = int(grid_size * np.sqrt(ratio))
|
|
194
|
+
|
|
195
|
+
# Clamp grid size
|
|
196
|
+
new_grid_size = max(30, min(500, new_grid_size))
|
|
197
|
+
|
|
198
|
+
# Apply smoothing (EMA)
|
|
199
|
+
alpha = 0.3
|
|
200
|
+
self._grid_size = int(alpha * new_grid_size + (1 - alpha) * self._grid_size)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
print(f"Render: {duration_ms:.1f}ms, Target: {self._target_render_time}ms, Ratio {ratio:.2f} New Grid: {self._grid_size}")
|
|
204
|
+
|
|
205
|
+
def _generate_boundary_points(self, points, values):
|
|
206
|
+
"""
|
|
207
|
+
Generates ghost points on the boundary using adaptive sampling and IDW.
|
|
208
|
+
"""
|
|
209
|
+
if len(points) < 2:
|
|
210
|
+
return np.empty((0, 2)), np.empty((0,))
|
|
211
|
+
|
|
212
|
+
# Adaptive Sampling
|
|
213
|
+
tree = cKDTree(points)
|
|
214
|
+
distances, _ = tree.query(points, k=2)
|
|
215
|
+
avg_dist = np.mean(distances[:, 1])
|
|
216
|
+
target_segment_length = avg_dist * 1.2
|
|
217
|
+
if target_segment_length <= 0: target_segment_length = 10.0
|
|
218
|
+
|
|
219
|
+
boundary_points_list = []
|
|
220
|
+
|
|
221
|
+
# Iterate through polygon edges
|
|
222
|
+
poly_points = [self._boundary_shape.at(i) for i in range(self._boundary_shape.count())]
|
|
223
|
+
if self._boundary_shape.isClosed():
|
|
224
|
+
# QPolygonF might be closed or not depending on creation.
|
|
225
|
+
# If last point != first point, close it effectively for iteration
|
|
226
|
+
if poly_points[0] != poly_points[-1]:
|
|
227
|
+
poly_points.append(poly_points[0])
|
|
228
|
+
else:
|
|
229
|
+
poly_points.append(poly_points[0]) # Close the loop
|
|
230
|
+
|
|
231
|
+
for i in range(len(poly_points) - 1):
|
|
232
|
+
p1 = np.array([poly_points[i].x(), poly_points[i].y()])
|
|
233
|
+
p2 = np.array([poly_points[i+1].x(), poly_points[i+1].y()])
|
|
234
|
+
|
|
235
|
+
segment_len = np.linalg.norm(p2 - p1)
|
|
236
|
+
n_segments = int(np.ceil(segment_len / target_segment_length))
|
|
237
|
+
n_segments = max(1, n_segments)
|
|
238
|
+
|
|
239
|
+
for j in range(n_segments):
|
|
240
|
+
t = j / n_segments
|
|
241
|
+
pt = p1 + t * (p2 - p1)
|
|
242
|
+
boundary_points_list.append(pt)
|
|
243
|
+
|
|
244
|
+
boundary_points = np.array(boundary_points_list)
|
|
245
|
+
|
|
246
|
+
if len(boundary_points) == 0:
|
|
247
|
+
return np.empty((0, 2)), np.empty((0,))
|
|
248
|
+
|
|
249
|
+
# IDW for Values
|
|
250
|
+
dists, indices = tree.query(boundary_points, k=2)
|
|
251
|
+
boundary_values = []
|
|
252
|
+
|
|
253
|
+
for i in range(len(boundary_points)):
|
|
254
|
+
d1, d2 = dists[i]
|
|
255
|
+
idx1, idx2 = indices[i]
|
|
256
|
+
v1, v2 = values[idx1], values[idx2]
|
|
257
|
+
|
|
258
|
+
if d1 < 1e-9: val = v1
|
|
259
|
+
elif d2 < 1e-9: val = v2
|
|
260
|
+
else:
|
|
261
|
+
w1 = 1.0 / d1
|
|
262
|
+
w2 = 1.0 / d2
|
|
263
|
+
val = (w1 * v1 + w2 * v2) / (w1 + w2)
|
|
264
|
+
boundary_values.append(val)
|
|
265
|
+
|
|
266
|
+
return boundary_points, np.array(boundary_values)
|
|
267
|
+
|
|
268
|
+
def _array_to_qimage(self, Z):
|
|
269
|
+
"""
|
|
270
|
+
Converts 2D array Z to QImage using vectorized operations.
|
|
271
|
+
"""
|
|
272
|
+
height, width = Z.shape
|
|
273
|
+
|
|
274
|
+
# 1. Normalize Z to 0-255 indices
|
|
275
|
+
Z_norm = np.nan_to_num(Z, nan=-1)
|
|
276
|
+
max_val = np.nanmax(Z_norm)
|
|
277
|
+
if max_val == 0: max_val = 1
|
|
278
|
+
|
|
279
|
+
# Create indices array
|
|
280
|
+
# Values < 0 (NaNs) will be handled separately or mapped to 0
|
|
281
|
+
# We want -1 to stay -1 or be handled.
|
|
282
|
+
# Let's map valid values to 0-255.
|
|
283
|
+
|
|
284
|
+
# Mask for transparent pixels
|
|
285
|
+
mask = (Z_norm == -1)
|
|
286
|
+
|
|
287
|
+
# Normalize valid values to 0.0-1.0
|
|
288
|
+
normalized = np.clip(Z_norm / max_val, 0.0, 1.0)
|
|
289
|
+
|
|
290
|
+
# Map to 0-255 indices
|
|
291
|
+
indices = (normalized * 255).astype(np.uint8)
|
|
292
|
+
|
|
293
|
+
# 2. Get LUT
|
|
294
|
+
lut = self._colormap.get_lut(256)
|
|
295
|
+
|
|
296
|
+
# 3. Map indices to ARGB values
|
|
297
|
+
# lut is (256,) uint32
|
|
298
|
+
# buffer will be (height, width) uint32
|
|
299
|
+
buffer = lut[indices]
|
|
300
|
+
|
|
301
|
+
# 4. Apply Transparency
|
|
302
|
+
# Set alpha to 0 for masked pixels
|
|
303
|
+
# 0x00FFFFFF mask clears Alpha channel, but we want 0x00000000 for full transparency
|
|
304
|
+
buffer[mask] = 0x00000000
|
|
305
|
+
|
|
306
|
+
# 5. Create QImage from buffer
|
|
307
|
+
# We need to ensure the buffer is contiguous and kept alive
|
|
308
|
+
# QImage(uchar *data, int width, int height, Format format)
|
|
309
|
+
# We can use memoryview
|
|
310
|
+
|
|
311
|
+
# Make sure buffer is C-contiguous
|
|
312
|
+
if not buffer.flags['C_CONTIGUOUS']:
|
|
313
|
+
buffer = np.ascontiguousarray(buffer)
|
|
314
|
+
|
|
315
|
+
image = QImage(buffer.data, width, height, width * 4, QImage.Format.Format_ARGB32)
|
|
316
|
+
|
|
317
|
+
# We must copy the image data because QImage doesn't own the buffer
|
|
318
|
+
# and 'buffer' might be garbage collected after this function returns.
|
|
319
|
+
return image.copy()
|
|
320
|
+
|
|
321
|
+
def paint(self, painter, option, widget):
|
|
322
|
+
if self._cached_image:
|
|
323
|
+
# Clip to polygon
|
|
324
|
+
path = QPainterPath()
|
|
325
|
+
path.addPolygon(self._boundary_shape)
|
|
326
|
+
painter.setClipPath(path)
|
|
327
|
+
|
|
328
|
+
# Draw the expanded heatmap
|
|
329
|
+
# Enable smooth transformation for upscaling low-res images
|
|
330
|
+
painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform, True)
|
|
331
|
+
painter.drawImage(self._heatmap_rect, self._cached_image)
|
|
332
|
+
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from PySide6.QtWidgets import QGraphicsObject
|
|
2
|
+
from PySide6.QtCore import QRectF
|
|
3
|
+
|
|
4
|
+
class Layer(QGraphicsObject):
|
|
5
|
+
"""
|
|
6
|
+
Abstract base class for all visual layers in FieldView.
|
|
7
|
+
Inherits from QGraphicsObject to support signals and integration with QGraphicsScene.
|
|
8
|
+
"""
|
|
9
|
+
def __init__(self, parent=None):
|
|
10
|
+
super().__init__(parent)
|
|
11
|
+
self._bounding_rect = QRectF(0, 0, 300, 300) # Default size, should be updated by subclasses
|
|
12
|
+
|
|
13
|
+
def boundingRect(self):
|
|
14
|
+
return self._bounding_rect
|
|
15
|
+
|
|
16
|
+
def set_bounding_rect(self, rect):
|
|
17
|
+
self._bounding_rect = rect
|
|
18
|
+
self.prepareGeometryChange()
|
|
19
|
+
self.update()
|
|
20
|
+
|
|
21
|
+
def paint(self, painter, option, widget):
|
|
22
|
+
"""
|
|
23
|
+
Default paint implementation. Subclasses should override this.
|
|
24
|
+
"""
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
def update_layer(self):
|
|
28
|
+
"""
|
|
29
|
+
Triggers a layer update. Can be overridden to perform calculation before repaint.
|
|
30
|
+
"""
|
|
31
|
+
self.update()
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from PySide6.QtGui import QPixmap, QPainter
|
|
2
|
+
from PySide6.QtCore import QPointF, Qt, QRectF
|
|
3
|
+
from fieldview.layers.data_layer import DataLayer
|
|
4
|
+
|
|
5
|
+
class PinLayer(DataLayer):
|
|
6
|
+
"""
|
|
7
|
+
Layer for rendering icons (pins) at data points.
|
|
8
|
+
"""
|
|
9
|
+
def __init__(self, data_container, parent=None):
|
|
10
|
+
super().__init__(data_container, parent)
|
|
11
|
+
self._icon = None
|
|
12
|
+
self._icon_size = 24 # Default size
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
def icon(self):
|
|
16
|
+
return self._icon
|
|
17
|
+
|
|
18
|
+
def set_icon(self, icon: QPixmap):
|
|
19
|
+
self._icon = icon
|
|
20
|
+
self.update_layer()
|
|
21
|
+
|
|
22
|
+
def paint(self, painter, option, widget):
|
|
23
|
+
points, _, _ = self.get_valid_data()
|
|
24
|
+
|
|
25
|
+
if self._icon:
|
|
26
|
+
w = self._icon.width()
|
|
27
|
+
h = self._icon.height()
|
|
28
|
+
for x, y in points:
|
|
29
|
+
target_rect = QRectF(x - w/2, y - h/2, w, h)
|
|
30
|
+
painter.drawPixmap(target_rect.toRect(), self._icon)
|
|
31
|
+
else:
|
|
32
|
+
# Default: Black dot
|
|
33
|
+
painter.setBrush(Qt.GlobalColor.black)
|
|
34
|
+
painter.setPen(Qt.NoPen)
|
|
35
|
+
r = 3 # Radius
|
|
36
|
+
for x, y in points:
|
|
37
|
+
painter.drawEllipse(QPointF(x, y), r, r)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from PySide6.QtSvg import QSvgRenderer
|
|
2
|
+
from PySide6.QtGui import QPainter
|
|
3
|
+
from PySide6.QtCore import QRectF
|
|
4
|
+
from fieldview.layers.layer import Layer
|
|
5
|
+
|
|
6
|
+
class SvgLayer(Layer):
|
|
7
|
+
"""
|
|
8
|
+
Layer for rendering an SVG file.
|
|
9
|
+
"""
|
|
10
|
+
def __init__(self, parent=None):
|
|
11
|
+
super().__init__(parent)
|
|
12
|
+
self._renderer = QSvgRenderer()
|
|
13
|
+
self._svg_path = ""
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
def svg_path(self):
|
|
17
|
+
return self._svg_path
|
|
18
|
+
|
|
19
|
+
def load_svg(self, path):
|
|
20
|
+
"""
|
|
21
|
+
Loads an SVG file from the given path.
|
|
22
|
+
"""
|
|
23
|
+
self._svg_path = path
|
|
24
|
+
if self._renderer.load(path):
|
|
25
|
+
self.set_bounding_rect(self._renderer.viewBoxF())
|
|
26
|
+
self.update_layer()
|
|
27
|
+
else:
|
|
28
|
+
print(f"Failed to load SVG: {path}")
|
|
29
|
+
|
|
30
|
+
def paint(self, painter, option, widget):
|
|
31
|
+
if self._renderer.isValid():
|
|
32
|
+
# Render SVG to fit the bounding rect
|
|
33
|
+
self._renderer.render(painter, self.boundingRect())
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
from PySide6.QtGui import QFont, QColor, QFontDatabase
|
|
2
|
+
from PySide6.QtCore import Qt, QRectF, QPointF
|
|
3
|
+
import os
|
|
4
|
+
from fieldview.layers.data_layer import DataLayer
|
|
5
|
+
|
|
6
|
+
class TextLayer(DataLayer):
|
|
7
|
+
"""
|
|
8
|
+
Abstract base class for text-based layers.
|
|
9
|
+
Handles font, opacity, and highlighting.
|
|
10
|
+
"""
|
|
11
|
+
def __init__(self, data_container, parent=None):
|
|
12
|
+
super().__init__(data_container, parent)
|
|
13
|
+
|
|
14
|
+
# Load embedded font
|
|
15
|
+
font_path = os.path.join(os.path.dirname(__file__), '..', 'resources', 'fonts', 'JetBrainsMono-Regular.ttf')
|
|
16
|
+
font_path = os.path.abspath(font_path)
|
|
17
|
+
|
|
18
|
+
font_id = QFontDatabase.addApplicationFont(font_path)
|
|
19
|
+
if font_id != -1:
|
|
20
|
+
font_family = QFontDatabase.applicationFontFamilies(font_id)[0]
|
|
21
|
+
self._font = QFont(font_family)
|
|
22
|
+
else:
|
|
23
|
+
# Fallback
|
|
24
|
+
self._font = QFont("JetBrains Mono")
|
|
25
|
+
if not QFontDatabase.families(QFontDatabase.WritingSystem.Latin).count("JetBrains Mono"):
|
|
26
|
+
self._font.setStyleHint(QFont.StyleHint.Monospace)
|
|
27
|
+
|
|
28
|
+
self._font.setPixelSize(12)
|
|
29
|
+
|
|
30
|
+
self._text_color = QColor(Qt.GlobalColor.white)
|
|
31
|
+
self._bg_color = QColor(0, 0, 0, 180) # Semi-transparent black
|
|
32
|
+
self._highlight_color = QColor(Qt.GlobalColor.yellow)
|
|
33
|
+
self._highlighted_indices = set()
|
|
34
|
+
|
|
35
|
+
self._collision_avoidance_enabled = True
|
|
36
|
+
self._collision_offset_factor = 0.6 # Default 60%
|
|
37
|
+
self._cached_layout = None
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def font(self):
|
|
41
|
+
return self._font
|
|
42
|
+
|
|
43
|
+
@font.setter
|
|
44
|
+
def font(self, value):
|
|
45
|
+
self._font = value
|
|
46
|
+
self.update_layer()
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def highlighted_indices(self):
|
|
50
|
+
return self._highlighted_indices
|
|
51
|
+
|
|
52
|
+
def set_highlighted_indices(self, indices):
|
|
53
|
+
self._highlighted_indices = set(indices)
|
|
54
|
+
self.update_layer()
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def collision_avoidance_enabled(self):
|
|
58
|
+
return self._collision_avoidance_enabled
|
|
59
|
+
|
|
60
|
+
@collision_avoidance_enabled.setter
|
|
61
|
+
def collision_avoidance_enabled(self, enabled):
|
|
62
|
+
self._collision_avoidance_enabled = enabled
|
|
63
|
+
self.update_layer()
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def collision_offset_factor(self):
|
|
67
|
+
return self._collision_offset_factor
|
|
68
|
+
|
|
69
|
+
@collision_offset_factor.setter
|
|
70
|
+
def collision_offset_factor(self, factor):
|
|
71
|
+
self._collision_offset_factor = factor
|
|
72
|
+
self.update_layer()
|
|
73
|
+
|
|
74
|
+
def update_layer(self):
|
|
75
|
+
self._cached_layout = None
|
|
76
|
+
super().update_layer()
|
|
77
|
+
|
|
78
|
+
def paint(self, painter, option, widget):
|
|
79
|
+
points, values, labels = self.get_valid_data()
|
|
80
|
+
|
|
81
|
+
painter.setFont(self._font)
|
|
82
|
+
metrics = painter.fontMetrics()
|
|
83
|
+
|
|
84
|
+
if self._cached_layout is None:
|
|
85
|
+
self._cached_layout = self._calculate_layout(points, values, labels, metrics)
|
|
86
|
+
|
|
87
|
+
for i, rect in self._cached_layout.items():
|
|
88
|
+
text = self._get_text(i, values[i], labels[i])
|
|
89
|
+
if not text: continue
|
|
90
|
+
|
|
91
|
+
# Determine background color
|
|
92
|
+
bg_color = self._highlight_color if i in self._highlighted_indices else self._bg_color
|
|
93
|
+
|
|
94
|
+
# Draw background
|
|
95
|
+
painter.fillRect(rect, bg_color)
|
|
96
|
+
|
|
97
|
+
# Draw text
|
|
98
|
+
painter.setPen(self._text_color if i not in self._highlighted_indices else Qt.GlobalColor.black)
|
|
99
|
+
painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, text)
|
|
100
|
+
|
|
101
|
+
def _calculate_layout(self, points, values, labels, metrics):
|
|
102
|
+
layout = {} # index -> QRectF
|
|
103
|
+
placed_rects = []
|
|
104
|
+
|
|
105
|
+
for i, (x, y) in enumerate(points):
|
|
106
|
+
text = self._get_text(i, values[i], labels[i])
|
|
107
|
+
if not text: continue
|
|
108
|
+
|
|
109
|
+
rect = metrics.boundingRect(text)
|
|
110
|
+
# Add padding
|
|
111
|
+
rect.adjust(-2, -2, 2, 2)
|
|
112
|
+
w, h = rect.width(), rect.height()
|
|
113
|
+
|
|
114
|
+
# Calculate offset distance based on factor
|
|
115
|
+
# Factor 0.6 means move 60% of dimension.
|
|
116
|
+
# Center is 0 offset.
|
|
117
|
+
# Top: y - h * factor
|
|
118
|
+
# Bottom: y + h * factor
|
|
119
|
+
# Left: x - w * factor
|
|
120
|
+
# Right: x + w * factor
|
|
121
|
+
|
|
122
|
+
factor = self._collision_offset_factor
|
|
123
|
+
|
|
124
|
+
candidates = [
|
|
125
|
+
QPointF(x, y), # Center
|
|
126
|
+
QPointF(x, y - h * factor), # Top
|
|
127
|
+
QPointF(x, y + h * factor), # Bottom
|
|
128
|
+
QPointF(x - w * factor, y), # Left
|
|
129
|
+
QPointF(x + w * factor, y) # Right
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
chosen_rect = None
|
|
133
|
+
|
|
134
|
+
if self._collision_avoidance_enabled:
|
|
135
|
+
best_rect = None
|
|
136
|
+
min_cost = float('inf')
|
|
137
|
+
|
|
138
|
+
for center in candidates:
|
|
139
|
+
candidate_rect = QRectF(rect)
|
|
140
|
+
candidate_rect.moveCenter(center)
|
|
141
|
+
|
|
142
|
+
# Calculate cost (total intersection area)
|
|
143
|
+
cost = 0.0
|
|
144
|
+
for placed in placed_rects:
|
|
145
|
+
intersection = candidate_rect.intersected(placed)
|
|
146
|
+
if not intersection.isEmpty():
|
|
147
|
+
cost += intersection.width() * intersection.height()
|
|
148
|
+
|
|
149
|
+
# If perfect placement found, take it immediately
|
|
150
|
+
if cost == 0:
|
|
151
|
+
best_rect = candidate_rect
|
|
152
|
+
break
|
|
153
|
+
|
|
154
|
+
# Otherwise keep track of the best so far
|
|
155
|
+
if cost < min_cost:
|
|
156
|
+
min_cost = cost
|
|
157
|
+
best_rect = candidate_rect
|
|
158
|
+
|
|
159
|
+
chosen_rect = best_rect
|
|
160
|
+
else:
|
|
161
|
+
# Just Center
|
|
162
|
+
chosen_rect = QRectF(rect)
|
|
163
|
+
chosen_rect.moveCenter(QPointF(x, y))
|
|
164
|
+
|
|
165
|
+
layout[i] = chosen_rect
|
|
166
|
+
placed_rects.append(chosen_rect)
|
|
167
|
+
|
|
168
|
+
return layout
|
|
169
|
+
|
|
170
|
+
def _get_text(self, index, value, label):
|
|
171
|
+
"""
|
|
172
|
+
Abstract method to get text for a point.
|
|
173
|
+
"""
|
|
174
|
+
raise NotImplementedError
|
|
175
|
+
|
|
176
|
+
class ValueLayer(TextLayer):
|
|
177
|
+
"""
|
|
178
|
+
Renders numerical values.
|
|
179
|
+
"""
|
|
180
|
+
def __init__(self, data_container, parent=None):
|
|
181
|
+
super().__init__(data_container, parent)
|
|
182
|
+
self._decimal_places = 2
|
|
183
|
+
self._suffix = ""
|
|
184
|
+
self._postfix = "" # Same as suffix? antigravity.md says both. Let's assume prefix/suffix or just suffix.
|
|
185
|
+
# antigravity.md says "Can add suffix, postfix". Maybe prefix/suffix?
|
|
186
|
+
# Let's implement prefix and suffix.
|
|
187
|
+
self._prefix = ""
|
|
188
|
+
|
|
189
|
+
@property
|
|
190
|
+
def decimal_places(self):
|
|
191
|
+
return self._decimal_places
|
|
192
|
+
|
|
193
|
+
@decimal_places.setter
|
|
194
|
+
def decimal_places(self, value):
|
|
195
|
+
self._decimal_places = value
|
|
196
|
+
self.update_layer()
|
|
197
|
+
|
|
198
|
+
@property
|
|
199
|
+
def suffix(self):
|
|
200
|
+
return self._suffix
|
|
201
|
+
|
|
202
|
+
@suffix.setter
|
|
203
|
+
def suffix(self, value):
|
|
204
|
+
self._suffix = value
|
|
205
|
+
self.update_layer()
|
|
206
|
+
|
|
207
|
+
@property
|
|
208
|
+
def prefix(self):
|
|
209
|
+
return self._prefix
|
|
210
|
+
|
|
211
|
+
@prefix.setter
|
|
212
|
+
def prefix(self, value):
|
|
213
|
+
self._prefix = value
|
|
214
|
+
self.update_layer()
|
|
215
|
+
|
|
216
|
+
def _get_text(self, index, value, label):
|
|
217
|
+
return f"{self._prefix}{value:.{self._decimal_places}f}{self._suffix}"
|
|
218
|
+
|
|
219
|
+
class LabelLayer(TextLayer):
|
|
220
|
+
"""
|
|
221
|
+
Renders text labels.
|
|
222
|
+
"""
|
|
223
|
+
def _get_text(self, index, value, label):
|
|
224
|
+
return str(label)
|
|
File without changes
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from PySide6.QtGui import QColor
|
|
2
|
+
import numpy as np
|
|
3
|
+
|
|
4
|
+
class Colormap:
|
|
5
|
+
"""
|
|
6
|
+
Simple colormap implementation using linear interpolation of stops.
|
|
7
|
+
"""
|
|
8
|
+
def __init__(self, name, stops):
|
|
9
|
+
"""
|
|
10
|
+
Args:
|
|
11
|
+
name (str): Name of the colormap.
|
|
12
|
+
stops (list): List of (position, color_hex) tuples.
|
|
13
|
+
Position is 0.0 to 1.0.
|
|
14
|
+
"""
|
|
15
|
+
self.name = name
|
|
16
|
+
self.stops = sorted(stops, key=lambda x: x[0])
|
|
17
|
+
|
|
18
|
+
def map(self, value):
|
|
19
|
+
"""
|
|
20
|
+
Maps a value (0.0 to 1.0) to a QColor.
|
|
21
|
+
"""
|
|
22
|
+
value = max(0.0, min(1.0, value))
|
|
23
|
+
|
|
24
|
+
# Find segment
|
|
25
|
+
for i in range(len(self.stops) - 1):
|
|
26
|
+
p1, c1_hex = self.stops[i]
|
|
27
|
+
p2, c2_hex = self.stops[i+1]
|
|
28
|
+
|
|
29
|
+
if p1 <= value <= p2:
|
|
30
|
+
t = (value - p1) / (p2 - p1) if p2 > p1 else 0
|
|
31
|
+
c1 = QColor(c1_hex)
|
|
32
|
+
c2 = QColor(c2_hex)
|
|
33
|
+
|
|
34
|
+
r = int(c1.red() * (1-t) + c2.red() * t)
|
|
35
|
+
g = int(c1.green() * (1-t) + c2.green() * t)
|
|
36
|
+
b = int(c1.blue() * (1-t) + c2.blue() * t)
|
|
37
|
+
|
|
38
|
+
return QColor(r, g, b)
|
|
39
|
+
|
|
40
|
+
# Fallback (should cover 0.0 to 1.0 if stops are correct)
|
|
41
|
+
return QColor(self.stops[-1][1])
|
|
42
|
+
|
|
43
|
+
def get_lut(self, size=256):
|
|
44
|
+
"""
|
|
45
|
+
Returns a numpy array of shape (size,) containing uint32 ARGB values.
|
|
46
|
+
"""
|
|
47
|
+
if hasattr(self, '_lut') and len(self._lut) == size:
|
|
48
|
+
return self._lut
|
|
49
|
+
|
|
50
|
+
lut = np.zeros(size, dtype=np.uint32)
|
|
51
|
+
|
|
52
|
+
for i in range(size):
|
|
53
|
+
val = i / (size - 1)
|
|
54
|
+
color = self.map(val)
|
|
55
|
+
# ARGB32 format: 0xAARRGGBB
|
|
56
|
+
# QColor.rgba() returns 0xAARRGGBB (unsigned int)
|
|
57
|
+
lut[i] = color.rgba()
|
|
58
|
+
|
|
59
|
+
self._lut = lut
|
|
60
|
+
return lut
|
|
61
|
+
|
|
62
|
+
# Standard Colormaps (Approximations)
|
|
63
|
+
VIRIDIS = Colormap("viridis", [
|
|
64
|
+
(0.0, "#440154"), (0.25, "#3b528b"), (0.5, "#21918c"), (0.75, "#5ec962"), (1.0, "#fde725")
|
|
65
|
+
])
|
|
66
|
+
|
|
67
|
+
PLASMA = Colormap("plasma", [
|
|
68
|
+
(0.0, "#0d0887"), (0.25, "#7e03a8"), (0.5, "#cc4778"), (0.75, "#f89540"), (1.0, "#f0f921")
|
|
69
|
+
])
|
|
70
|
+
|
|
71
|
+
INFERNO = Colormap("inferno", [
|
|
72
|
+
(0.0, "#000004"), (0.25, "#57106e"), (0.5, "#bb3754"), (0.75, "#f98e09"), (1.0, "#fcffa4")
|
|
73
|
+
])
|
|
74
|
+
|
|
75
|
+
MAGMA = Colormap("magma", [
|
|
76
|
+
(0.0, "#000004"), (0.25, "#51127c"), (0.5, "#b73779"), (0.75, "#fc8961"), (1.0, "#fcfdbf")
|
|
77
|
+
])
|
|
78
|
+
|
|
79
|
+
COOLWARM = Colormap("coolwarm", [
|
|
80
|
+
(0.0, "#3b4cc0"), (0.5, "#dddddd"), (1.0, "#b40426")
|
|
81
|
+
])
|
|
82
|
+
|
|
83
|
+
JET = Colormap("jet", [
|
|
84
|
+
(0.0, "#000080"), (0.125, "#0000ff"), (0.375, "#00ffff"), (0.625, "#ffff00"), (0.875, "#ff0000"), (1.0, "#800000")
|
|
85
|
+
])
|
|
86
|
+
|
|
87
|
+
COLORMAPS = {
|
|
88
|
+
"viridis": VIRIDIS,
|
|
89
|
+
"plasma": PLASMA,
|
|
90
|
+
"inferno": INFERNO,
|
|
91
|
+
"magma": MAGMA,
|
|
92
|
+
"coolwarm": COOLWARM,
|
|
93
|
+
"jet": JET
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
def get_colormap(name):
|
|
97
|
+
return COLORMAPS.get(name.lower(), VIRIDIS)
|
|
Binary file
|
fieldview/ui/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fieldview
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python + Qt based 2D data visualization solution for irregular data points
|
|
5
|
+
Author-email: FieldView Team <dev@fieldview.org>
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Requires-Dist: numpy>=1.24.1
|
|
9
|
+
Requires-Dist: pyside6>=6.6.0
|
|
10
|
+
Requires-Dist: scipy>=1.10.0
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# FieldView
|
|
14
|
+
|
|
15
|
+
**FieldView** is a high-performance Python + Qt (PySide6) library for 2D data visualization, specifically designed for handling irregular data points. It provides a robust rendering engine for heatmaps, markers, and text labels with minimal external dependencies.
|
|
16
|
+
|
|
17
|
+
<img src="assets/quick_start.png" alt="Quick Start" width="480">
|
|
18
|
+
|
|
19
|
+
## Key Features
|
|
20
|
+
|
|
21
|
+
* **Fast Heatmap Rendering**: Hybrid RBF (Radial Basis Function) interpolation for high-quality visualization with real-time performance optimization.
|
|
22
|
+
* **Irregular Data Support**: Native handling of non-grid data points.
|
|
23
|
+
* **Polygon Masking**: Support for arbitrary boundary shapes (Polygon, Circle, Rectangle) to clip heatmaps.
|
|
24
|
+
* **Layer System**: Modular architecture with support for:
|
|
25
|
+
* **HeatmapLayer**: Color-based data visualization.
|
|
26
|
+
* **ValueLayer/LabelLayer**: Text rendering with collision avoidance.
|
|
27
|
+
* **PinLayer**: Marker placement.
|
|
28
|
+
* **SvgLayer**: Background floor plans or overlays.
|
|
29
|
+
* **Minimal Dependencies**: Built on `numpy`, `scipy`, and `PySide6`.
|
|
30
|
+
|
|
31
|
+
## Installation
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install fieldview
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
*Note: Requires Python 3.10+*
|
|
38
|
+
|
|
39
|
+
## Quick Start
|
|
40
|
+
|
|
41
|
+
Here is a minimal example to get a heatmap up and running:
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
import sys
|
|
45
|
+
import os
|
|
46
|
+
import numpy as np
|
|
47
|
+
from PySide6.QtWidgets import QApplication, QGraphicsView, QGraphicsScene
|
|
48
|
+
from PySide6.QtCore import Qt
|
|
49
|
+
from fieldview.core.data_container import DataContainer
|
|
50
|
+
from fieldview.layers.heatmap_layer import HeatmapLayer
|
|
51
|
+
from fieldview.layers.text_layer import ValueLayer
|
|
52
|
+
from fieldview.layers.svg_layer import SvgLayer
|
|
53
|
+
from fieldview.layers.pin_layer import PinLayer
|
|
54
|
+
|
|
55
|
+
app = QApplication(sys.argv)
|
|
56
|
+
|
|
57
|
+
# 1. Setup Data
|
|
58
|
+
data = DataContainer()
|
|
59
|
+
np.random.seed(44)
|
|
60
|
+
points = (np.random.rand(20, 2) - 0.5) * 300
|
|
61
|
+
values = np.random.rand(20) * 100
|
|
62
|
+
data.set_data(points, values)
|
|
63
|
+
|
|
64
|
+
# 2. Create Scene & Layers
|
|
65
|
+
scene = QGraphicsScene()
|
|
66
|
+
|
|
67
|
+
# SVG Layer (Background)
|
|
68
|
+
# Assuming floorplan.svg exists in current dir or provide path
|
|
69
|
+
svg_layer = SvgLayer()
|
|
70
|
+
svg_layer.load_svg("examples/floorplan.svg")
|
|
71
|
+
svg_layer.setZValue(0)
|
|
72
|
+
scene.addItem(svg_layer)
|
|
73
|
+
|
|
74
|
+
# Heatmap Layer
|
|
75
|
+
heatmap = HeatmapLayer(data)
|
|
76
|
+
heatmap.setOpacity(0.6)
|
|
77
|
+
heatmap.setZValue(1)
|
|
78
|
+
heatmap.set_boundary_shape(svg_layer._bounding_rect)
|
|
79
|
+
scene.addItem(heatmap)
|
|
80
|
+
|
|
81
|
+
# Pin Layer
|
|
82
|
+
pin_layer = PinLayer(data)
|
|
83
|
+
pin_layer.setZValue(2)
|
|
84
|
+
scene.addItem(pin_layer)
|
|
85
|
+
|
|
86
|
+
# Value Layer
|
|
87
|
+
values_layer = ValueLayer(data)
|
|
88
|
+
values_layer.setZValue(3)
|
|
89
|
+
scene.addItem(values_layer)
|
|
90
|
+
|
|
91
|
+
# 3. Setup View
|
|
92
|
+
view = QGraphicsView(scene)
|
|
93
|
+
view.resize(800, 600)
|
|
94
|
+
view.show()
|
|
95
|
+
|
|
96
|
+
# Ensure content is visible
|
|
97
|
+
scene.setSceneRect(scene.itemsBoundingRect())
|
|
98
|
+
view.fitInView(scene.sceneRect(), Qt.AspectRatioMode.KeepAspectRatio)
|
|
99
|
+
|
|
100
|
+
sys.exit(app.exec())
|
|
101
|
+
```
|
|
102
|
+
≈
|
|
103
|
+
|
|
104
|
+
## Running the Demo
|
|
105
|
+
|
|
106
|
+
To see all features in action, including the property editor and real-time interaction:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
# Clone the repository
|
|
110
|
+
git clone https://github.com/yourusername/fieldview.git
|
|
111
|
+
cd fieldview
|
|
112
|
+
|
|
113
|
+
# Run the demo using uv (recommended)
|
|
114
|
+
uv run examples/demo.py
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
<img src="assets/demo.gif" alt="Demo" width="800">
|
|
118
|
+
|
|
119
|
+
## License
|
|
120
|
+
|
|
121
|
+
MIT License
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
fieldview/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
fieldview/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
fieldview/core/data_container.py,sha256=yS3FAD7dd7kFhhwYwm_UM7q4ACAuNLvJryVOXjV8eIA,4856
|
|
4
|
+
fieldview/layers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
fieldview/layers/data_layer.py,sha256=UAF0xGAhfK0ajOkI0c6h7BSC50tctqTWkBx4MMgQyvE,2781
|
|
6
|
+
fieldview/layers/heatmap_layer.py,sha256=JqMpzNqm4qcJUCrNmrF-mdi-2889FuGi-A_nar5Lpos,12177
|
|
7
|
+
fieldview/layers/layer.py,sha256=nf8n4wwu7OZ-jlGvBUv4p2Ve1CG8ydDIMcABv9UdRFQ,966
|
|
8
|
+
fieldview/layers/pin_layer.py,sha256=OzVaaTNAa2-U9HbW81f0-Z2dJQ3FIUf-pLx46r6eBZU,1173
|
|
9
|
+
fieldview/layers/svg_layer.py,sha256=k8TmHtsNpc91f5XJThwv8yiz8Q6CKXQvLuu0FHtqp3g,958
|
|
10
|
+
fieldview/layers/text_layer.py,sha256=kcElddy_4ptg3C78FKne7SXdz_oidJbpVRcic3X0M3g,7531
|
|
11
|
+
fieldview/rendering/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
+
fieldview/rendering/colormaps.py,sha256=sAMTrIaPduDbo3J4CHGgzkyrj7P9l12RJ7CQrlSzz-c,2936
|
|
13
|
+
fieldview/resources/fonts/JetBrainsMono-Regular.ttf,sha256=5v0NfpFVCz7StzXUMSR0NixHFu3E_AV3oPYe14LVrtE,270224
|
|
14
|
+
fieldview/ui/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
15
|
+
fieldview-0.1.0.dist-info/METADATA,sha256=AYq0l90iQ2US8MyBTz-NkealoWoLcW1YRGIy3OzAYpk,3417
|
|
16
|
+
fieldview-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
17
|
+
fieldview-0.1.0.dist-info/licenses/LICENSE,sha256=_dBWGupPPCAjQesnfC8ptV_FibraRiMR04MCJlpQFDw,1071
|
|
18
|
+
fieldview-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 FieldView Team
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|