-
Notifications
You must be signed in to change notification settings - Fork 9
/
pointtool.py
687 lines (572 loc) · 21 KB
/
pointtool.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
'''
Main functionality of raster tracer.
'''
from enum import Enum
from collections import namedtuple
import numpy as np
from qgis.core import QgsPointXY, QgsPoint, QgsGeometry, QgsFeature, \
QgsVectorLayer, QgsProject, QgsWkbTypes, QgsApplication, \
QgsRectangle, QgsSpatialIndex
from qgis.gui import QgsMapToolEmitPoint, QgsMapToolEdit, \
QgsRubberBand, QgsVertexMarker, QgsMapTool
from qgis.PyQt.QtCore import Qt
from qgis.PyQt.QtGui import QColor
from qgis.core import Qgis
from qgis.core import QgsCoordinateTransform
from .astar import FindPathTask, FindPathFunction
from .line_simplification import smooth, simplify
from .utils import get_whole_raster, PossiblyIndexedImageError
from .pointtool_states import WaitingFirstPointState
from .exceptions import OutsideMapError
# An point on the map where the user clicked along the line
Anchor = namedtuple('Anchor', ['x', 'y', 'i', 'j'])
# Flag for experimental Autofollowing mode
ALLOW_AUTO_FOLLOWING = False
class TracingModes(Enum):
'''
Possible Tracing Modes for Pointtool.
LINE - straight line from start to end.
PATH - tracing along color from start to end.
AUTO - auto tracing mode along color in the given direction.
'''
LINE = 1
PATH = 2
AUTO = 3
def next(self):
'''
Switches between LINE and PATH
'''
cls = self.__class__
members = list(cls)
if not ALLOW_AUTO_FOLLOWING:
return members[0] if self.value == 2 else members[1]
index = members.index(self) + 1
if index >= len(members):
index = 0
return members[index]
def is_tracing(self):
'''
Returns True if mode is PATH
'''
return True if self.value == 2 else False
def is_auto(self):
'''
Returns True if mode is PATH
'''
return True if self.value == 3 else False
# Line styles for the rubber band
RUBBERBAND_LINE_STYLES = {
TracingModes.PATH: Qt.DotLine,
TracingModes.LINE: Qt.SolidLine,
TracingModes.AUTO: Qt.DashDotLine,
}
class PointTool(QgsMapToolEdit):
'''
Implementation of interactions of the user with the main map.
Will called every time the user clicks on the map
or hovers the mouse over the map.
'''
def deactivate(self):
QgsMapTool.deactivate(self)
self.deactivated.emit()
def __init__(self, canvas, iface, turn_off_snap, smooth=False):
'''
canvas - link to the QgsCanvas of the application
iface - link to the Qgis Interface
turn_off_snap - flag sets snapping to the nearest color
smooth - flag sets smoothing of the traced path
'''
self.iface = iface
# list of Anchors for current line
self.anchors = []
# for keeping track of mouse event for rubber band updating
self.last_mouse_event_pos = None
self.tracing_mode = TracingModes.PATH
self.turn_off_snap = turn_off_snap
self.smooth_line = smooth
# possible variants: gray_diff, as_is, color_diff (using v from hsv)
self.grid_conversion = "gray_diff"
# QApplication.restoreOverrideCursor()
# QApplication.setOverrideCursor(Qt.CrossCursor)
QgsMapToolEmitPoint.__init__(self, canvas)
self.rlayer = None
self.grid_changed = None
self.snap_tolerance = None # snap to color
self.snap2_tolerance = None # snap to itself
self.vlayer = None
self.grid = None
self.sample = None
self.tracking_is_active = False
# False = not a polygon
self.rubber_band = QgsRubberBand(self.canvas(), QgsWkbTypes.LineGeometry)
self.markers = []
self.marker_snap = QgsVertexMarker(self.canvas())
self.marker_snap.setColor(QColor(255, 0, 255))
self.find_path_task = None
self.change_state(WaitingFirstPointState)
self.last_vlayer = None
def display_message(self,
title,
message,
level='Info',
duration=2,
):
'''
Shows message bar to the user.
`level` receives one of four possible string values:
Info, Warning, Critical, Success
'''
LEVELS = {
'Info': Qgis.Info,
'Warning': Qgis.Warning,
'Critical': Qgis.Critical,
'Success': Qgis.Success,
}
self.iface.messageBar().pushMessage(
title,
message,
LEVELS[level],
duration)
def change_state(self, state):
self.state = state(self)
def snap_tolerance_changed(self, snap_tolerance):
self.snap_tolerance = snap_tolerance
if snap_tolerance is None:
self.marker_snap.hide()
else:
self.marker_snap.show()
def snap2_tolerance_changed(self, snap_tolerance):
self.snap2_tolerance = snap_tolerance**2
# if snap_tolerance is None:
# self.marker_snap.hide()
# else:
# self.marker_snap.show()
def trace_color_changed(self, color):
r, g, b = self.sample
if color is False:
self.grid_changed = None
else:
r0, g0, b0, t = color.getRgb()
self.grid_changed = np.abs((r0 - r) ** 2 + (g0 - g) ** 2 +
(b0 - b) ** 2)
def get_current_vector_layer(self):
try:
vlayer = self.iface.layerTreeView().selectedLayers()[0]
if isinstance(vlayer, QgsVectorLayer):
if vlayer.wkbType() == QgsWkbTypes.MultiLineString:
# if self.last_vlayer:
# if vlayer != self.last_vlayer:
# self.create_spatial_index_for_vlayer(vlayer)
# else:
# self.create_spatial_index_for_vlayer(vlayer)
# self.last_vlayer = vlayer
return vlayer
else:
self.display_message(
" ",
"The active layer must be" +
" a MultiLineString vector layer",
level='Warning',
duration=2,
)
return None
else:
self.display_message(
"Missing Layer",
"Please select vector layer to draw",
level='Warning',
duration=2,
)
return None
except IndexError:
self.display_message(
"Missing Layer",
"Please select vector layer to draw",
level='Warning',
duration=2,
)
return None
def raster_layer_has_changed(self, raster_layer):
self.rlayer = raster_layer
if self.rlayer is None:
self.display_message(
"Missing Layer",
"Please select raster layer to trace",
level='Warning',
duration=2,
)
return
try:
sample, to_indexes, to_coords, to_coords_provider, \
to_coords_provider2 = \
get_whole_raster(self.rlayer,
QgsProject.instance(),
)
except PossiblyIndexedImageError:
self.display_message(
"Missing Layer",
"Can't trace indexed or gray image",
level='Critical',
duration=2,
)
return
r = sample[0].astype(float)
g = sample[1].astype(float)
b = sample[2].astype(float)
where_are_NaNs = np.isnan(r)
r[where_are_NaNs] = 0
where_are_NaNs = np.isnan(g)
g[where_are_NaNs] = 0
where_are_NaNs = np.isnan(b)
b[where_are_NaNs] = 0
self.sample = (r, g, b)
self.grid = r + g + b
self.to_indexes = to_indexes
self.to_coords = to_coords
self.to_coords_provider = to_coords_provider
self.to_coords_provider2 = to_coords_provider2
def remove_last_anchor_point(self, undo_edit=True, redraw=True):
'''
Removes last anchor point and last marker point
'''
# check if we have at least one feature to delete
vlayer = self.get_current_vector_layer()
if vlayer is None:
return
if vlayer.featureCount() < 1:
return
# remove last marker
if self.markers:
last_marker = self.markers.pop()
self.canvas().scene().removeItem(last_marker)
# remove last anchor
if self.anchors:
self.anchors.pop()
if undo_edit:
# it's a very ugly way of triggering single undo event
self.iface.editMenu().actions()[0].trigger()
if redraw:
self.update_rubber_band()
self.redraw()
def keyPressEvent(self, e):
if e.key() == Qt.Key_Backspace or e.key() == Qt.Key_B:
# delete last segment if backspace is pressed
self.remove_last_anchor_point()
elif e.key() == Qt.Key_A:
# change tracing mode
self.tracing_mode = self.tracing_mode.next()
self.update_rubber_band()
elif e.key() == Qt.Key_S:
# toggle snap mode
self.turn_off_snap()
elif e.key() == Qt.Key_Escape:
# Abort tracing process
self.abort_tracing_process()
def add_anchor_points(self, x1, y1, i1, j1):
'''
Adds anchor points and markers to self.
'''
anchor = Anchor(x1, y1, i1, j1)
self.anchors.append(anchor)
marker = QgsVertexMarker(self.canvas())
marker.setCenter(QgsPointXY(x1, y1))
self.markers.append(marker)
def trace_over_image(self,
start,
goal,
do_it_as_task=False,
vlayer=None):
'''
performs tracing
'''
i0, j0 = start
i1, j1 = goal
r, g, b, = self.sample
try:
r0 = r[i1, j1]
g0 = g[i1, j1]
b0 = b[i1, j1]
except IndexError:
raise OutsideMapError
if self.grid_changed is None:
grid = np.abs((r0 - r) ** 2 + (g0 - g) ** 2 + (b0 - b) ** 2)
else:
grid = self.grid_changed
if do_it_as_task:
# dirty hack to avoid QGIS crashing
self.find_path_task = FindPathTask(
grid.astype(np.dtype('l')),
start,
goal,
self.draw_path,
vlayer,
)
QgsApplication.taskManager().addTask(
self.find_path_task,
)
self.tracking_is_active = True
else:
path, cost = FindPathFunction(
grid.astype(np.dtype('l')),
(i0, j0),
(i1, j1),
)
return path, cost
def trace(self, x1, y1, i1, j1, vlayer):
'''
Traces path from last point to given point.
In case tracing is inactive just creates
straight line.
'''
if self.tracing_mode.is_tracing():
if self.snap_tolerance is not None:
try:
i1, j1 = self.snap(i1, j1)
except OutsideMapError:
return
_, _, i0, j0 = self.anchors[-2]
start_point = i0, j0
end_point = i1, j1
try:
self.trace_over_image(start_point,
end_point,
do_it_as_task=True,
vlayer=vlayer)
except OutsideMapError:
pass
else:
self.draw_path(
None,
vlayer,
was_tracing=False,
x1=x1,
y1=y1,
)
def snap_to_itself(self, x, y, sq_tolerance=1):
'''
finds a nearest segment line to the current vlayer
'''
pt = QgsPointXY(x, y)
# nearest_feature_id = self.spIndex.nearestNeighbor(pt, 1, tolerance)[0]
vlayer = self.get_current_vector_layer()
# feature = vlayer.getFeature(nearest_feature_id)
for feature in vlayer.getFeatures():
closest_point, _, _, _, sq_distance = feature.geometry().closestVertex(pt)
if sq_distance < sq_tolerance:
return closest_point.x(), closest_point.y()
return x, y
def snap(self, i, j):
if self.snap_tolerance is None:
return i, j
if not self.tracing_mode.is_tracing():
return i, j
if self.grid_changed is None:
return i, j
size_i, size_j = self.grid.shape
size = self.snap_tolerance
if i < size or j < size or i + size > size_i or j + size > size_j:
raise OutsideMapError
grid_small = self.grid_changed
grid_small = grid_small[i - size: i + size, j - size: j + size]
smallest_cells = np.where(grid_small == np.amin(grid_small))
coordinates = list(zip(smallest_cells[0], smallest_cells[1]))
if len(coordinates) == 1:
delta_i, delta_j = coordinates[0]
delta_i -= size
delta_j -= size
else:
# find the closest to the center
deltas = [(i - size, j - size) for i, j in coordinates]
lengths = [(i ** 2 + j ** 2) for i, j in deltas]
i = lengths.index(min(lengths))
delta_i, delta_j = deltas[i]
return i+delta_i, j+delta_j
def canvasReleaseEvent(self, mouseEvent):
'''
Method where the actual tracing is performed
after the user clicked on the map
'''
vlayer = self.get_current_vector_layer()
if vlayer is None:
return
if not vlayer.isEditable():
self.display_message(
"Edit mode",
"Please begin editing vector layer to trace",
level='Warning',
duration=2,
)
return
if self.rlayer is None:
self.display_message(
"Missing Layer",
"Please select raster layer to trace",
level='Warning',
duration=2,
)
return
if mouseEvent.button() == Qt.RightButton:
self.state.click_rmb(mouseEvent, vlayer)
elif mouseEvent.button() == Qt.LeftButton:
self.state.click_lmb(mouseEvent, vlayer)
return
def draw_path(self, path, vlayer, was_tracing=True,\
x1=None, y1=None):
'''
Draws a path after tracer found it.
'''
transform = QgsCoordinateTransform(QgsProject.instance().crs(),
vlayer.crs(),
QgsProject.instance())
if was_tracing:
if self.smooth_line:
path = smooth(path, size=5)
path = simplify(path)
vlayer = self.get_current_vector_layer()
current_last_point = self.to_coords(*path[-1])
path_ref = [transform.transform(*self.to_coords_provider(i, j)) for i, j in path]
x0, y0, _, _ = self.anchors[-2]
last_point = transform.transform(*self.to_coords_provider2(x0, y0))
path_ref = [last_point] + path_ref[1:]
else:
x0, y0, _i, _j = self.anchors[-2]
current_last_point = (x1, y1)
path_ref = [transform.transform(*self.to_coords_provider2(x0, y0)),
transform.transform(*self.to_coords_provider2(x1, y1))]
self.ready = False
if len(self.anchors) == 2:
vlayer.beginEditCommand("Adding new line")
add_feature_to_vlayer(vlayer, path_ref)
vlayer.endEditCommand()
else:
vlayer.beginEditCommand("Adding new segment to the line")
add_to_last_feature(vlayer, path_ref)
vlayer.endEditCommand()
_, _, current_last_point_i, current_last_point_j = self.anchors[-1]
self.anchors[-1] = current_last_point[0], current_last_point[1], current_last_point_i, current_last_point_j
self.redraw()
self.tracking_is_active = False
def update_rubber_band(self):
# this is very ugly but I can't make another way
if self.last_mouse_event_pos is None:
return
if not self.anchors:
return
x0, y0, _, _ = self.anchors[-1]
qgsPoint = self.toMapCoordinates(self.last_mouse_event_pos)
x1, y1 = qgsPoint.x(), qgsPoint.y()
points = [QgsPoint(x0, y0), QgsPoint(x1, y1)]
self.rubber_band.setColor(QColor(255, 0, 0))
self.rubber_band.setWidth(3)
self.rubber_band.setLineStyle(
RUBBERBAND_LINE_STYLES[self.tracing_mode],
)
vlayer = self.get_current_vector_layer()
if vlayer is None:
return
self.rubber_band.setToGeometry(
QgsGeometry.fromPolyline(points),
self.vlayer,
)
def canvasMoveEvent(self, mouseEvent):
'''
Store the mouse position for the correct
updating of the rubber band
'''
# we need at least one point to draw
if not self.anchors:
return
if self.snap_tolerance is not None and self.tracing_mode.is_tracing():
qgsPoint = self.toMapCoordinates(mouseEvent.pos())
x1, y1 = qgsPoint.x(), qgsPoint.y()
# i, j = get_indxs_from_raster_coords(self.geo_ref, x1, y1)
i, j = self.to_indexes(x1, y1)
try:
i1, j1 = self.snap(i, j)
except OutsideMapError:
return
# x1, y1 = get_coords_from_raster_indxs(self.geo_ref, i1, j1)
x1, y1 = self.to_coords(i1, j1)
self.marker_snap.setCenter(QgsPointXY(x1, y1))
self.last_mouse_event_pos = mouseEvent.pos()
self.update_rubber_band()
self.redraw()
def abort_tracing_process(self):
'''
Terminate background process of tracing raster
after the user hits Esc.
'''
# check if we have any tasks
if self.find_path_task is None:
return
self.tracking_is_active = False
try:
# send terminate signal to the task
self.find_path_task.cancel()
self.find_path_task = None
except RuntimeError:
return
else:
self.remove_last_anchor_point(
undo_edit=False,
)
def redraw(self):
# If caching is enabled, a simple canvas refresh might not be
# sufficient to trigger a redraw and you must clear the cached image
# for the layer
if self.iface.mapCanvas().isCachingEnabled():
vlayer = self.get_current_vector_layer()
if vlayer is None:
return
vlayer.triggerRepaint()
self.iface.mapCanvas().refresh()
QgsApplication.processEvents()
def pan(self, x, y):
'''
Move the canvas to the x, y position
'''
currExt = self.iface.mapCanvas().extent()
canvasCenter = currExt.center()
dx = x - canvasCenter.x()
dy = y - canvasCenter.y()
xMin = currExt.xMinimum() + dx
xMax = currExt.xMaximum() + dx
yMin = currExt.yMinimum() + dy
yMax = currExt.yMaximum() + dy
newRect = QgsRectangle(xMin, yMin, xMax, yMax)
self.iface.mapCanvas().setExtent(newRect)
def add_last_feature_to_spindex(self, vlayer):
'''
Adds last feature to spatial index
'''
features = list(vlayer.getFeatures())
last_feature = features[-1]
self.spIndex.insertFeature(last_feature)
def create_spatial_index_for_vlayer(self, vlayer):
'''
Creates spatial index for the vlayer
'''
self.spIndex = QgsSpatialIndex()
# features = [f for f in vlayer]
self.spIndex.addFeatures(vlayer.getFeatures())
def add_to_last_feature(vlayer, points):
'''
Adds points to the last line feature in the vlayer
vlayer - QgsLayer of type MultiLine string
points - list of points
'''
features = list(vlayer.getFeatures())
last_feature = features[-1]
fid = last_feature.id()
geom = last_feature.geometry()
points = [QgsPointXY(x, y) for x, y in points]
geom.addPointsXY(points)
vlayer.changeGeometry(fid, geom)
def add_feature_to_vlayer(vlayer, points):
'''
Adds new line feature to the vlayer
'''
feat = QgsFeature(vlayer.fields())
polyline = [QgsPoint(x, y) for x, y in points]
feat.setGeometry(QgsGeometry.fromPolyline(polyline))
vlayer.addFeature(feat)