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 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)
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.