Skip to content

Commit 18b9f40

Browse files
authored
Merge pull request #4154 from nyalldawson/coloring
[FEATURE][processing] New algorithm for topological coloring of polygons
2 parents bde4ff9 + 74abd5b commit 18b9f40

File tree

10 files changed

+838
-1
lines changed

10 files changed

+838
-1
lines changed

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

100644100755
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -544,6 +544,19 @@ qgis:symmetricaldifference: >
544544
qgis:texttofloat: >
545545
This algorithm modifies the type of a given attribute in a vector layer, converting a text attribute containing numeric strings into a numeric attribute.
546546

547+
qgis:topologicalcoloring: >
548+
This algorithm assigns a color index to polygon features in such a way that no adjacent polygons share the same color index, whilst minimizing the number of colors required.
549+
550+
An optional minimum distance between features assigned the same color can be set to prevent nearby (but non-touching) features from being assigned equal colors.
551+
552+
The algorithm allows choice of method to use when assigning colors. The default method attempts to assign colors so that the count of features assigned to each individual color index is balanced.
553+
554+
The 'by assigned area' mode instead assigns colors so that the total area of features assigned to each color is balanced. This mode can be useful to help avoid large features resulting in one of the colors appearing more dominant on a colored map.
555+
556+
The 'by distance between colors' mode will assign colors in order to maximize the distance between features of the same color. This mode helps to create a more uniform distribution of colors across a map.
557+
558+
A minimum number of colors can be specified if desired. The color index is saved to a new attribute named color_id.
559+
547560
qgis:translate: >
548561
This algorithm moves the geometries within a layer, by offsetting them with a specified x and y displacement.
549562

@@ -595,3 +608,4 @@ qgis:fixgeometries: >
595608
This algorithm attempts to create a valid representation of a given invalid geometry without losing any of the input vertices. Already-valid geometries are returned without further intervention. Always outputs multi-geometry layer.
596609

597610
NOTE: M values will be dropped from the output.
611+

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

100644100755
Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@
186186
from .FixGeometry import FixGeometry
187187
from .ExecuteSQL import ExecuteSQL
188188
from .FindProjection import FindProjection
189+
from .TopoColors import TopoColor
189190

190191
pluginPath = os.path.normpath(os.path.join(
191192
os.path.split(os.path.dirname(__file__))[0], os.pardir))
@@ -255,7 +256,8 @@ def __init__(self):
255256
ShortestPathPointToPoint(), ShortestPathPointToLayer(),
256257
ShortestPathLayerToPoint(), ServiceAreaFromPoint(),
257258
ServiceAreaFromLayer(), TruncateTable(), Polygonize(),
258-
FixGeometry(), ExecuteSQL(), FindProjection()
259+
FixGeometry(), ExecuteSQL(), FindProjection(),
260+
TopoColor()
259261
]
260262

261263
if hasPlotly:
Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
# -*- coding: utf-8 -*-
2+
3+
"""
4+
***************************************************************************
5+
TopoColors.py
6+
--------------
7+
Date : February 2017
8+
Copyright : (C) 2017 by Nyall Dawson
9+
Email : nyall dot dawson at gmail dot com
10+
***************************************************************************
11+
* *
12+
* This program is free software; you can redistribute it and/or modify *
13+
* it under the terms of the GNU General Public License as published by *
14+
* the Free Software Foundation; either version 2 of the License, or *
15+
* (at your option) any later version. *
16+
* *
17+
***************************************************************************
18+
"""
19+
20+
__author__ = 'Nyall Dawson'
21+
__date__ = 'February 2017'
22+
__copyright__ = '(C) 2017, Nyall Dawson'
23+
24+
# This will get replaced with a git SHA1 when you do a git archive323
25+
26+
__revision__ = '$Format:%H$'
27+
28+
import os
29+
import operator
30+
import sys
31+
32+
from collections import defaultdict, deque
33+
34+
from qgis.core import (QgsField,
35+
QgsGeometry,
36+
QgsSpatialIndex,
37+
QgsPointV2,
38+
NULL)
39+
40+
from qgis.PyQt.QtCore import (QVariant)
41+
42+
from processing.core.GeoAlgorithm import GeoAlgorithm
43+
from processing.core.parameters import (ParameterVector,
44+
ParameterSelection,
45+
ParameterNumber)
46+
from processing.core.outputs import OutputVector
47+
from processing.tools import dataobjects, vector
48+
49+
pluginPath = os.path.split(os.path.split(os.path.dirname(__file__))[0])[0]
50+
51+
52+
class TopoColor(GeoAlgorithm):
53+
INPUT_LAYER = 'INPUT_LAYER'
54+
MIN_COLORS = 'MIN_COLORS'
55+
MIN_DISTANCE = 'MIN_DISTANCE'
56+
BALANCE = 'BALANCE'
57+
OUTPUT_LAYER = 'OUTPUT_LAYER'
58+
59+
def defineCharacteristics(self):
60+
self.name, self.i18n_name = self.trAlgorithm('Topological coloring')
61+
self.group, self.i18n_group = self.trAlgorithm('Cartographic tools')
62+
self.tags = self.tr('topocolor,colors,graph,adjacent,assign')
63+
64+
self.addParameter(ParameterVector(self.INPUT_LAYER,
65+
self.tr('Input layer'), [dataobjects.TYPE_VECTOR_POLYGON]))
66+
self.addParameter(ParameterNumber(self.MIN_COLORS,
67+
self.tr('Minimum number of colors'), 1, 1000, 4))
68+
self.addParameter(ParameterNumber(self.MIN_DISTANCE,
69+
self.tr('Minimum distance between features'), 0.0, 999999999.0, 0.0))
70+
balance_by = [self.tr('By feature count'),
71+
self.tr('By assigned area'),
72+
self.tr('By distance between colors')]
73+
self.addParameter(ParameterSelection(
74+
self.BALANCE,
75+
self.tr('Balance color assignment'),
76+
balance_by, default=0))
77+
78+
self.addOutput(OutputVector(self.OUTPUT_LAYER, self.tr('Colored'), datatype=[dataobjects.TYPE_VECTOR_POLYGON]))
79+
80+
def processAlgorithm(self, feedback):
81+
layer = dataobjects.getObjectFromUri(
82+
self.getParameterValue(self.INPUT_LAYER))
83+
min_colors = self.getParameterValue(self.MIN_COLORS)
84+
balance_by = self.getParameterValue(self.BALANCE)
85+
min_distance = self.getParameterValue(self.MIN_DISTANCE)
86+
87+
fields = layer.fields()
88+
fields.append(QgsField('color_id', QVariant.Int))
89+
90+
writer = self.getOutputFromName(
91+
self.OUTPUT_LAYER).getVectorWriter(
92+
fields,
93+
layer.wkbType(),
94+
layer.crs())
95+
96+
features = {f.id(): f for f in vector.features(layer)}
97+
98+
topology, id_graph = self.compute_graph(features, feedback, min_distance=min_distance)
99+
feature_colors = ColoringAlgorithm.balanced(features,
100+
balance=balance_by,
101+
graph=topology,
102+
feedback=feedback,
103+
min_colors=min_colors)
104+
105+
max_colors = max(feature_colors.values())
106+
feedback.pushInfo(self.tr('{} colors required').format(max_colors))
107+
108+
total = 20.0 / len(features)
109+
current = 0
110+
for feature_id, input_feature in features.items():
111+
output_feature = input_feature
112+
attributes = input_feature.attributes()
113+
if feature_id in feature_colors:
114+
attributes.append(feature_colors[feature_id])
115+
else:
116+
attributes.append(NULL)
117+
output_feature.setAttributes(attributes)
118+
119+
writer.addFeature(output_feature)
120+
current += 1
121+
feedback.setProgress(80 + int(current * total))
122+
123+
del writer
124+
125+
@staticmethod
126+
def compute_graph(features, feedback, create_id_graph=False, min_distance=0):
127+
""" compute topology from a layer/field """
128+
s = Graph(sort_graph=False)
129+
id_graph = None
130+
if create_id_graph:
131+
id_graph = Graph(sort_graph=True)
132+
133+
# skip features without geometry
134+
features_with_geometry = {f_id: f for (f_id, f) in features.items() if f.hasGeometry()}
135+
136+
total = 70.0 / len(features_with_geometry)
137+
index = QgsSpatialIndex()
138+
139+
i = 0
140+
for feature_id, f in features_with_geometry.items():
141+
g = f.geometry()
142+
if min_distance > 0:
143+
g = g.buffer(min_distance, 5)
144+
145+
engine = QgsGeometry.createGeometryEngine(g.geometry())
146+
engine.prepareGeometry()
147+
148+
feature_bounds = g.boundingBox()
149+
# grow bounds a little so we get touching features
150+
feature_bounds.grow(feature_bounds.width() * 0.01)
151+
intersections = index.intersects(feature_bounds)
152+
for l2 in intersections:
153+
f2 = features_with_geometry[l2]
154+
if engine.intersects(f2.geometry().geometry()):
155+
s.add_edge(f.id(), f2.id())
156+
s.add_edge(f2.id(), f.id())
157+
if id_graph:
158+
id_graph.add_edge(f.id(), f2.id())
159+
160+
index.insertFeature(f)
161+
i += 1
162+
feedback.setProgress(int(i * total))
163+
164+
for feature_id, f in features_with_geometry.items():
165+
if not feature_id in s.node_edge:
166+
s.add_edge(feature_id, None)
167+
168+
return s, id_graph
169+
170+
171+
class ColoringAlgorithm:
172+
173+
@staticmethod
174+
def balanced(features, graph, feedback, balance=0, min_colors=4):
175+
feature_colors = {}
176+
# start with minimum number of colors in pool
177+
color_pool = set(range(1, min_colors + 1))
178+
179+
# calculate count of neighbours
180+
neighbour_count = defaultdict(int)
181+
for feature_id, neighbours in graph.node_edge.items():
182+
neighbour_count[feature_id] += len(neighbours)
183+
184+
# sort features by neighbour count - we want to handle those with more neighbours first
185+
sorted_by_count = [feature_id for feature_id in sorted(neighbour_count.items(),
186+
key=operator.itemgetter(1),
187+
reverse=True)]
188+
# counts for each color already assigned
189+
color_counts = defaultdict(int)
190+
color_areas = defaultdict(float)
191+
for c in color_pool:
192+
color_counts[c] = 0
193+
color_areas[c] = 0
194+
195+
total = 10.0 / len(sorted_by_count)
196+
i = 0
197+
198+
for (feature_id, n) in sorted_by_count:
199+
# first work out which already assigned colors are adjacent to this feature
200+
adjacent_colors = set()
201+
for neighbour in graph.node_edge[feature_id]:
202+
if neighbour in feature_colors:
203+
adjacent_colors.add(feature_colors[neighbour])
204+
205+
# from the existing colors, work out which are available (ie non-adjacent)
206+
available_colors = color_pool.difference(adjacent_colors)
207+
208+
feature_color = -1
209+
if len(available_colors) == 0:
210+
# no existing colors available for this feature, so add new color to pool and repeat
211+
min_colors += 1
212+
return ColoringAlgorithm.balanced(features, graph, feedback, balance, min_colors)
213+
else:
214+
if balance == 0:
215+
# choose least used available color
216+
counts = [(c, v) for c, v in color_counts.items() if c in available_colors]
217+
feature_color = sorted(counts, key=operator.itemgetter(1))[0][0]
218+
color_counts[feature_color] += 1
219+
elif balance == 1:
220+
areas = [(c, v) for c, v in color_areas.items() if c in available_colors]
221+
feature_color = sorted(areas, key=operator.itemgetter(1))[0][0]
222+
color_areas[feature_color] += features[feature_id].geometry().area()
223+
elif balance == 2:
224+
min_distances = {c: sys.float_info.max for c in available_colors}
225+
this_feature_centroid = QgsPointV2(features[feature_id].geometry().centroid().geometry())
226+
227+
# find features for all available colors
228+
other_features = {f_id: c for (f_id, c) in feature_colors.items() if c in available_colors}
229+
230+
# loop through these, and calculate the minimum distance from this feature to the nearest
231+
# feature with each assigned color
232+
for other_feature_id, c in other_features.items():
233+
other_geometry = features[other_feature_id].geometry()
234+
other_centroid = QgsPointV2(other_geometry.centroid().geometry())
235+
236+
distance = this_feature_centroid.distanceSquared(other_centroid)
237+
if distance < min_distances[c]:
238+
min_distances[c] = distance
239+
240+
# choose color such that minimum distance is maximised! ie we want MAXIMAL separation between
241+
# features with the same color
242+
feature_color = sorted(min_distances, key=min_distances.__getitem__, reverse=True)[0]
243+
244+
feature_colors[feature_id] = feature_color
245+
246+
i += 1
247+
feedback.setProgress(70 + int(i * total))
248+
249+
return feature_colors
250+
251+
252+
class Graph:
253+
254+
def __init__(self, sort_graph=True):
255+
self.sort_graph = sort_graph
256+
self.node_edge = {}
257+
258+
def add_edge(self, i, j):
259+
ij = [i, j]
260+
if self.sort_graph:
261+
ij.sort()
262+
(i, j) = ij
263+
if i in self.node_edge:
264+
self.node_edge[i].add(j)
265+
else:
266+
self.node_edge[i] = {j}
267+
268+
def make_full(self):
269+
g = Graph(sort_graph=False)
270+
for k in self.node_edge.keys():
271+
for v in self.node_edge[k]:
272+
g.add_edge(v, k)
273+
g.add_edge(k, v)
274+
return g
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<GMLFeatureClassList>
2+
<GMLFeatureClass>
3+
<Name>adjacent_polys</Name>
4+
<ElementPath>adjacent_polys</ElementPath>
5+
<!--POLYGON-->
6+
<GeometryType>3</GeometryType>
7+
<SRSName>EPSG:4326</SRSName>
8+
<DatasetSpecificInfo>
9+
<FeatureCount>11</FeatureCount>
10+
<ExtentXMin>-0.76065</ExtentXMin>
11+
<ExtentXMax>14.23935</ExtentXMax>
12+
<ExtentYMin>-6.11331</ExtentYMin>
13+
<ExtentYMax>5.88669</ExtentYMax>
14+
</DatasetSpecificInfo>
15+
<PropertyDefn>
16+
<Name>left</Name>
17+
<ElementPath>left</ElementPath>
18+
<Type>Real</Type>
19+
</PropertyDefn>
20+
<PropertyDefn>
21+
<Name>top</Name>
22+
<ElementPath>top</ElementPath>
23+
<Type>Real</Type>
24+
</PropertyDefn>
25+
<PropertyDefn>
26+
<Name>right</Name>
27+
<ElementPath>right</ElementPath>
28+
<Type>Real</Type>
29+
</PropertyDefn>
30+
<PropertyDefn>
31+
<Name>bottom</Name>
32+
<ElementPath>bottom</ElementPath>
33+
<Type>Real</Type>
34+
</PropertyDefn>
35+
<PropertyDefn>
36+
<Name>id</Name>
37+
<ElementPath>id</ElementPath>
38+
<Type>Integer</Type>
39+
</PropertyDefn>
40+
</GMLFeatureClass>
41+
</GMLFeatureClassList>

0 commit comments

Comments
 (0)