Skip to content

Commit c8ac784

Browse files
committed
Port Points Displacement algorithm to new API
Rework algorithm to use same approach as points displacement renderer. Also maintain Z/M values, and add tests and docs.
1 parent 69c991e commit c8ac784

File tree

9 files changed

+245
-49
lines changed

9 files changed

+245
-49
lines changed

python/plugins/processing/algs/help/qgis.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -360,7 +360,7 @@ qgis:pointsalonglines: >
360360
An optional start and end offset can be specified, which controls how far from the start and end of the geometry the points should be created.
361361

362362
qgis:pointsdisplacement:
363-
363+
Offsets nearby point features by moving nearby points by a preset amount to minimize overlapping features.
364364

365365
qgis:pointslayerfromtable: >
366366
This algorithm generates a points layer based on the values from an input table.

python/plugins/processing/algs/qgis/PointsDisplacement.py

Lines changed: 94 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -27,27 +27,26 @@
2727
__revision__ = '$Format:%H$'
2828

2929
import math
30-
from qgis.core import (QgsApplication,
31-
QgsFeatureRequest,
32-
QgsFeature,
33-
QgsFeatureSink,
30+
from qgis.core import (QgsFeatureSink,
3431
QgsGeometry,
3532
QgsPointXY,
36-
QgsProcessingUtils)
37-
from processing.tools import dataobjects
33+
QgsSpatialIndex,
34+
QgsRectangle,
35+
QgsProcessing,
36+
QgsProcessingParameterFeatureSource,
37+
QgsProcessingParameterNumber,
38+
QgsProcessingParameterBoolean,
39+
QgsProcessingParameterFeatureSink)
3840
from processing.algs.qgis.QgisAlgorithm import QgisAlgorithm
39-
from processing.core.parameters import ParameterVector
40-
from processing.core.parameters import ParameterNumber
41-
from processing.core.parameters import ParameterBoolean
42-
from processing.core.outputs import OutputVector
4341

4442

4543
class PointsDisplacement(QgisAlgorithm):
4644

47-
INPUT_LAYER = 'INPUT_LAYER'
45+
INPUT = 'INPUT'
4846
DISTANCE = 'DISTANCE'
47+
PROXIMITY = 'PROXIMITY'
4948
HORIZONTAL = 'HORIZONTAL'
50-
OUTPUT_LAYER = 'OUTPUT_LAYER'
49+
OUTPUT = 'OUTPUT'
5150

5251
def group(self):
5352
return self.tr('Vector geometry tools')
@@ -56,14 +55,17 @@ def __init__(self):
5655
super().__init__()
5756

5857
def initAlgorithm(self, config=None):
59-
self.addParameter(ParameterVector(self.INPUT_LAYER,
60-
self.tr('Input layer'), [dataobjects.TYPE_VECTOR_POINT]))
61-
self.addParameter(ParameterNumber(self.DISTANCE,
62-
self.tr('Displacement distance'),
63-
0.00001, 999999999.999990, 0.00015))
64-
self.addParameter(ParameterBoolean(self.HORIZONTAL,
65-
self.tr('Horizontal distribution for two point case')))
66-
self.addOutput(OutputVector(self.OUTPUT_LAYER, self.tr('Displaced'), datatype=[dataobjects.TYPE_VECTOR_POINT]))
58+
self.addParameter(QgsProcessingParameterFeatureSource(self.INPUT,
59+
self.tr('Input layer'), [QgsProcessing.TypeVectorPoint]))
60+
self.addParameter(QgsProcessingParameterNumber(self.PROXIMITY,
61+
self.tr('Minimum distance to other points'), type=QgsProcessingParameterNumber.Double,
62+
minValue=0.00001, defaultValue=0.00015))
63+
self.addParameter(QgsProcessingParameterNumber(self.DISTANCE,
64+
self.tr('Displacement distance'), type=QgsProcessingParameterNumber.Double,
65+
minValue=0.00001, defaultValue=0.00015))
66+
self.addParameter(QgsProcessingParameterBoolean(self.HORIZONTAL,
67+
self.tr('Horizontal distribution for two point case')))
68+
self.addParameter(QgsProcessingParameterFeatureSink(self.OUTPUT, self.tr('Displaced'), QgsProcessing.TypeVectorPoint))
6769

6870
def name(self):
6971
return 'pointsdisplacement'
@@ -72,64 +74,110 @@ def displayName(self):
7274
return self.tr('Points displacement')
7375

7476
def processAlgorithm(self, parameters, context, feedback):
75-
radius = self.getParameterValue(self.DISTANCE)
76-
horizontal = self.getParameterValue(self.HORIZONTAL)
77-
output = self.getOutputFromName(self.OUTPUT_LAYER)
77+
source = self.parameterAsSource(parameters, self.INPUT, context)
78+
proximity = self.parameterAsDouble(parameters, self.PROXIMITY, context)
79+
radius = self.parameterAsDouble(parameters, self.DISTANCE, context)
80+
horizontal = self.parameterAsBool(parameters, self.HORIZONTAL, context)
7881

79-
layer = QgsProcessingUtils.mapLayerFromString(self.getParameterValue(self.INPUT_LAYER), context)
82+
(sink, dest_id) = self.parameterAsSink(parameters, self.OUTPUT, context,
83+
source.fields(), source.wkbType(), source.sourceCrs())
8084

81-
writer = output.getVectorWriter(layer.fields(), layer.wkbType(), layer.crs(), context)
85+
features = source.getFeatures()
8286

83-
features = QgsProcessingUtils.getFeatures(layer, context)
87+
total = 100.0 / source.featureCount() if source.featureCount() else 0
8488

85-
total = 100.0 / layer.featureCount() if layer.featureCount() else 0
89+
def searchRect(p):
90+
return QgsRectangle(p.x() - proximity, p.y() - proximity, p.x() + proximity, p.y() + proximity)
8691

87-
duplicates = dict()
92+
index = QgsSpatialIndex()
93+
94+
# NOTE: this is a Python port of QgsPointDistanceRenderer::renderFeature. If refining this algorithm,
95+
# please port the changes to QgsPointDistanceRenderer::renderFeature also!
96+
97+
clustered_groups = []
98+
group_index = {}
99+
group_locations = {}
88100
for current, f in enumerate(features):
89-
wkt = f.geometry().exportToWkt()
90-
if wkt not in duplicates:
91-
duplicates[wkt] = [f.id()]
101+
if feedback.isCanceled():
102+
break
103+
104+
if not f.hasGeometry():
105+
continue
106+
107+
point = f.geometry().asPoint()
108+
109+
other_features_within_radius = index.intersects(searchRect(point))
110+
if not other_features_within_radius:
111+
index.insertFeature(f)
112+
group = [f]
113+
clustered_groups.append(group)
114+
group_index[f.id()] = len(clustered_groups) - 1
115+
group_locations[f.id()] = point
92116
else:
93-
duplicates[wkt].extend([f.id()])
117+
# find group with closest location to this point (may be more than one within search tolerance)
118+
min_dist_feature_id = other_features_within_radius[0]
119+
min_dist = group_locations[min_dist_feature_id].distance(point)
120+
for i in range(1, len(other_features_within_radius)):
121+
candidate_id = other_features_within_radius[i]
122+
new_dist = group_locations[candidate_id].distance(point)
123+
if new_dist < min_dist:
124+
min_dist = new_dist
125+
min_dist_feature_id = candidate_id
126+
127+
group_index_pos = group_index[min_dist_feature_id]
128+
group = clustered_groups[group_index_pos]
129+
130+
# calculate new centroid of group
131+
old_center = group_locations[min_dist_feature_id]
132+
group_locations[min_dist_feature_id] = QgsPointXY((old_center.x() * len(group) + point.x()) / (len(group) + 1.0),
133+
(old_center.y() * len(group) + point.y()) / (len(group) + 1.0))
134+
# add to a group
135+
clustered_groups[group_index_pos].append(f)
136+
group_index[f.id()] = group_index_pos
94137

95138
feedback.setProgress(int(current * total))
96139

97140
current = 0
98-
total = 100.0 / len(duplicates) if duplicates else 1
141+
total = 100.0 / len(clustered_groups) if clustered_groups else 1
99142
feedback.setProgress(0)
100143

101144
fullPerimeter = 2 * math.pi
102145

103-
for (geom, fids) in list(duplicates.items()):
104-
count = len(fids)
146+
for group in clustered_groups:
147+
if feedback.isCanceled():
148+
break
149+
150+
count = len(group)
105151
if count == 1:
106-
f = next(layer.getFeatures(QgsFeatureRequest().setFilterFid(fids[0])))
107-
writer.addFeature(f, QgsFeatureSink.FastInsert)
152+
sink.addFeature(group[0], QgsFeatureSink.FastInsert)
108153
else:
109154
angleStep = fullPerimeter / count
110155
if count == 2 and horizontal:
111156
currentAngle = math.pi / 2
112157
else:
113158
currentAngle = 0
114159

115-
old_point = QgsGeometry.fromWkt(geom).asPoint()
160+
old_point = group_locations[group[0].id()]
161+
162+
for f in group:
163+
if feedback.isCanceled():
164+
break
116165

117-
request = QgsFeatureRequest().setFilterFids(fids).setFlags(QgsFeatureRequest.NoGeometry)
118-
for f in layer.getFeatures(request):
119166
sinusCurrentAngle = math.sin(currentAngle)
120167
cosinusCurrentAngle = math.cos(currentAngle)
121168
dx = radius * sinusCurrentAngle
122169
dy = radius * cosinusCurrentAngle
123170

124-
new_point = QgsPointXY(old_point.x() + dx, old_point.y() + dy)
125-
out_feature = QgsFeature()
126-
out_feature.setGeometry(QgsGeometry.fromPoint(new_point))
127-
out_feature.setAttributes(f.attributes())
171+
# we want to keep any existing m/z values
172+
point = f.geometry().geometry().clone()
173+
point.setX(old_point.x() + dx)
174+
point.setY(old_point.y() + dy)
175+
f.setGeometry(QgsGeometry(point))
128176

129-
writer.addFeature(out_feature, QgsFeatureSink.FastInsert)
177+
sink.addFeature(f, QgsFeatureSink.FastInsert)
130178
currentAngle += angleStep
131179

132180
current += 1
133181
feedback.setProgress(int(current * total))
134182

135-
del writer
183+
return {self.OUTPUT: dest_id}

python/plugins/processing/algs/qgis/QGISAlgorithmProvider.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@
9494
from .PointDistance import PointDistance
9595
from .PointOnSurface import PointOnSurface
9696
from .PointsAlongGeometry import PointsAlongGeometry
97+
from .PointsDisplacement import PointsDisplacement
9798
from .PointsInPolygon import PointsInPolygon
9899
from .PointsLayerFromTable import PointsLayerFromTable
99100
from .PointsToPaths import PointsToPaths
@@ -153,7 +154,6 @@
153154
# from .GeometryConvert import GeometryConvert
154155
# from .FieldsCalculator import FieldsCalculator
155156
# from .FieldPyculator import FieldsPyculator
156-
# from .PointsDisplacement import PointsDisplacement
157157
# from .PointsFromPolygons import PointsFromPolygons
158158
# from .PointsFromLines import PointsFromLines
159159
# from .SetVectorStyle import SetVectorStyle
@@ -192,7 +192,7 @@ def getAlgs(self):
192192
# GeometryConvert(), FieldsCalculator(),
193193
# FieldsPyculator(),
194194
#
195-
# RasterLayerStatistics(), PointsDisplacement(),
195+
# RasterLayerStatistics(),
196196
# PointsFromPolygons(),
197197
# PointsFromLines(),
198198
# SetVectorStyle(), SetRasterStyle(),
@@ -261,6 +261,7 @@ def getAlgs(self):
261261
PointDistance(),
262262
PointOnSurface(),
263263
PointsAlongGeometry(),
264+
PointsDisplacement(),
264265
PointsInPolygon(),
265266
PointsLayerFromTable(),
266267
PointsToPaths(),
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<GMLFeatureClassList>
2+
<GMLFeatureClass>
3+
<Name>displace_points</Name>
4+
<ElementPath>displace_points</ElementPath>
5+
<!--POINT-->
6+
<GeometryType>1</GeometryType>
7+
<SRSName>EPSG:4326</SRSName>
8+
<DatasetSpecificInfo>
9+
<FeatureCount>4</FeatureCount>
10+
<ExtentXMin>1.00000</ExtentXMin>
11+
<ExtentXMax>4.00000</ExtentXMax>
12+
<ExtentYMin>1.00000</ExtentYMin>
13+
<ExtentYMax>1.00000</ExtentYMax>
14+
</DatasetSpecificInfo>
15+
<PropertyDefn>
16+
<Name>id</Name>
17+
<ElementPath>id</ElementPath>
18+
<Type>Integer</Type>
19+
</PropertyDefn>
20+
<PropertyDefn>
21+
<Name>id2</Name>
22+
<ElementPath>id2</ElementPath>
23+
<Type>Integer</Type>
24+
</PropertyDefn>
25+
</GMLFeatureClass>
26+
</GMLFeatureClassList>
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?xml version="1.0" encoding="utf-8" ?>
2+
<ogr:FeatureCollection
3+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
4+
xsi:schemaLocation="http://ogr.maptools.org/ displace_points.xsd"
5+
xmlns:ogr="http://ogr.maptools.org/"
6+
xmlns:gml="http://www.opengis.net/gml">
7+
<gml:boundedBy>
8+
<gml:Box>
9+
<gml:coord><gml:X>1</gml:X><gml:Y>1</gml:Y></gml:coord>
10+
<gml:coord><gml:X>4</gml:X><gml:Y>1</gml:Y></gml:coord>
11+
</gml:Box>
12+
</gml:boundedBy>
13+
14+
<gml:featureMember>
15+
<ogr:displace_points fid="points.4">
16+
<ogr:geometryProperty><gml:Point srsName="EPSG:4326"><gml:coordinates>4,1</gml:coordinates></gml:Point></ogr:geometryProperty>
17+
<ogr:id>5</ogr:id>
18+
<ogr:id2>1</ogr:id2>
19+
</ogr:displace_points>
20+
</gml:featureMember>
21+
<gml:featureMember>
22+
<ogr:displace_points fid="points.3">
23+
<ogr:geometryProperty><gml:Point srsName="EPSG:4326"><gml:coordinates>4,1</gml:coordinates></gml:Point></ogr:geometryProperty>
24+
<ogr:id>4</ogr:id>
25+
<ogr:id2>2</ogr:id2>
26+
</ogr:displace_points>
27+
</gml:featureMember>
28+
<gml:featureMember>
29+
<ogr:displace_points fid="points.0">
30+
<ogr:geometryProperty><gml:Point srsName="EPSG:4326"><gml:coordinates>1,1</gml:coordinates></gml:Point></ogr:geometryProperty>
31+
<ogr:id>1</ogr:id>
32+
<ogr:id2>2</ogr:id2>
33+
</ogr:displace_points>
34+
</gml:featureMember>
35+
<gml:featureMember>
36+
<ogr:displace_points fid="points.1">
37+
<ogr:id>2</ogr:id>
38+
<ogr:id2>1</ogr:id2>
39+
</ogr:displace_points>
40+
</gml:featureMember>
41+
</ogr:FeatureCollection>
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<GMLFeatureClassList>
2+
<GMLFeatureClass>
3+
<Name>displaced_points</Name>
4+
<ElementPath>displaced_points</ElementPath>
5+
<!--POINT-->
6+
<GeometryType>1</GeometryType>
7+
<SRSName>EPSG:4326</SRSName>
8+
<DatasetSpecificInfo>
9+
<FeatureCount>3</FeatureCount>
10+
<ExtentXMin>1.00000</ExtentXMin>
11+
<ExtentXMax>4.86603</ExtentXMax>
12+
<ExtentYMin>0.50000</ExtentYMin>
13+
<ExtentYMax>2.00000</ExtentYMax>
14+
</DatasetSpecificInfo>
15+
<PropertyDefn>
16+
<Name>id</Name>
17+
<ElementPath>id</ElementPath>
18+
<Type>Integer</Type>
19+
</PropertyDefn>
20+
<PropertyDefn>
21+
<Name>id2</Name>
22+
<ElementPath>id2</ElementPath>
23+
<Type>Integer</Type>
24+
</PropertyDefn>
25+
</GMLFeatureClass>
26+
</GMLFeatureClassList>
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?xml version="1.0" encoding="utf-8" ?>
2+
<ogr:FeatureCollection
3+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
4+
xsi:schemaLocation=""
5+
xmlns:ogr="http://ogr.maptools.org/"
6+
xmlns:gml="http://www.opengis.net/gml">
7+
<gml:boundedBy>
8+
<gml:Box>
9+
<gml:coord><gml:X>1</gml:X><gml:Y>0</gml:Y></gml:coord>
10+
<gml:coord><gml:X>4</gml:X><gml:Y>2</gml:Y></gml:coord>
11+
</gml:Box>
12+
</gml:boundedBy>
13+
14+
<gml:featureMember>
15+
<ogr:displaced_points fid="points.4">
16+
<ogr:geometryProperty><gml:Point srsName="EPSG:4326"><gml:coordinates>4,2</gml:coordinates></gml:Point></ogr:geometryProperty>
17+
<ogr:id>5</ogr:id>
18+
<ogr:id2>1</ogr:id2>
19+
</ogr:displaced_points>
20+
</gml:featureMember>
21+
<gml:featureMember>
22+
<ogr:displaced_points fid="points.3">
23+
<ogr:geometryProperty><gml:Point srsName="EPSG:4326"><gml:coordinates>4,0</gml:coordinates></gml:Point></ogr:geometryProperty>
24+
<ogr:id>4</ogr:id>
25+
<ogr:id2>2</ogr:id2>
26+
</ogr:displaced_points>
27+
</gml:featureMember>
28+
<gml:featureMember>
29+
<ogr:displaced_points fid="points.0">
30+
<ogr:geometryProperty><gml:Point srsName="EPSG:4326"><gml:coordinates>1,1</gml:coordinates></gml:Point></ogr:geometryProperty>
31+
<ogr:id>1</ogr:id>
32+
<ogr:id2>2</ogr:id2>
33+
</ogr:displaced_points>
34+
</gml:featureMember>
35+
</ogr:FeatureCollection>

python/plugins/processing/tests/testdata/qgis_algorithm_tests.yaml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2905,3 +2905,17 @@ tests:
29052905
- 'Mean value: 147.17197994967066'
29062906
- 'Standard deviation: 43.9618116337985'
29072907
- 'Sum of the squares: 252304334.52061242'
2908+
2909+
- algorithm: qgis:pointsdisplacement
2910+
name: Point displacement
2911+
params:
2912+
DISTANCE: 1.0
2913+
HORIZONTAL: false
2914+
INPUT:
2915+
name: custom/displace_points.gml
2916+
type: vector
2917+
PROXIMITY: 2.0
2918+
results:
2919+
OUTPUT:
2920+
name: expected/displaced_points.gml
2921+
type: vector

0 commit comments

Comments
 (0)