TopoColors.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. """
  2. ***************************************************************************
  3. TopoColors.py
  4. --------------
  5. Date : February 2017
  6. Copyright : (C) 2017 by Nyall Dawson
  7. Email : nyall dot dawson at gmail dot com
  8. ***************************************************************************
  9. * *
  10. * This program is free software; you can redistribute it and/or modify *
  11. * it under the terms of the GNU General Public License as published by *
  12. * the Free Software Foundation; either version 2 of the License, or *
  13. * (at your option) any later version. *
  14. * *
  15. ***************************************************************************
  16. """
  17. __author__ = 'Nyall Dawson'
  18. __date__ = 'February 2017'
  19. __copyright__ = '(C) 2017, Nyall Dawson'
  20. import os
  21. import operator
  22. import sys
  23. from collections import defaultdict
  24. from qgis.core import (QgsField,
  25. QgsFeatureSink,
  26. QgsGeometry,
  27. QgsSpatialIndex,
  28. QgsPointXY,
  29. NULL,
  30. QgsProcessing,
  31. QgsProcessingException,
  32. QgsProcessingParameterFeatureSource,
  33. QgsProcessingParameterDistance,
  34. QgsProcessingParameterNumber,
  35. QgsProcessingParameterEnum,
  36. QgsProcessingParameterFeatureSink)
  37. from qgis.PyQt.QtCore import (QVariant)
  38. from processing.algs.qgis.QgisAlgorithm import QgisAlgorithm
  39. pluginPath = os.path.split(os.path.split(os.path.dirname(__file__))[0])[0]
  40. class TopoColor(QgisAlgorithm):
  41. INPUT = 'INPUT'
  42. MIN_COLORS = 'MIN_COLORS'
  43. MIN_DISTANCE = 'MIN_DISTANCE'
  44. BALANCE = 'BALANCE'
  45. OUTPUT = 'OUTPUT'
  46. def tags(self):
  47. return self.tr('topocolor,colors,graph,adjacent,assign').split(',')
  48. def group(self):
  49. return self.tr('Cartography')
  50. def groupId(self):
  51. return 'cartography'
  52. def __init__(self):
  53. super().__init__()
  54. def initAlgorithm(self, config=None):
  55. self.addParameter(QgsProcessingParameterFeatureSource(self.INPUT,
  56. self.tr('Input layer'),
  57. [QgsProcessing.TypeVectorPolygon]))
  58. self.addParameter(QgsProcessingParameterNumber(self.MIN_COLORS,
  59. self.tr('Minimum number of colors'), minValue=1, maxValue=1000,
  60. defaultValue=4))
  61. self.addParameter(QgsProcessingParameterDistance(self.MIN_DISTANCE,
  62. self.tr('Minimum distance between features'),
  63. parentParameterName=self.INPUT, minValue=0.0,
  64. defaultValue=0.0))
  65. balance_by = [self.tr('By feature count'),
  66. self.tr('By assigned area'),
  67. self.tr('By distance between colors')]
  68. self.addParameter(QgsProcessingParameterEnum(
  69. self.BALANCE,
  70. self.tr('Balance color assignment'),
  71. options=balance_by, defaultValue=0))
  72. self.addParameter(
  73. QgsProcessingParameterFeatureSink(self.OUTPUT, self.tr('Colored'), QgsProcessing.TypeVectorPolygon))
  74. def name(self):
  75. return 'topologicalcoloring'
  76. def displayName(self):
  77. return self.tr('Topological coloring')
  78. def processAlgorithm(self, parameters, context, feedback):
  79. source = self.parameterAsSource(parameters, self.INPUT, context)
  80. if source is None:
  81. raise QgsProcessingException(self.invalidSourceError(parameters, self.INPUT))
  82. min_colors = self.parameterAsInt(parameters, self.MIN_COLORS, context)
  83. balance_by = self.parameterAsEnum(parameters, self.BALANCE, context)
  84. min_distance = self.parameterAsDouble(parameters, self.MIN_DISTANCE, context)
  85. fields = source.fields()
  86. fields.append(QgsField('color_id', QVariant.Int))
  87. (sink, dest_id) = self.parameterAsSink(parameters, self.OUTPUT, context,
  88. fields, source.wkbType(), source.sourceCrs())
  89. if sink is None:
  90. raise QgsProcessingException(self.invalidSinkError(parameters, self.OUTPUT))
  91. features = {f.id(): f for f in source.getFeatures()}
  92. topology, id_graph = self.compute_graph(features, feedback, min_distance=min_distance)
  93. feature_colors = ColoringAlgorithm.balanced(features,
  94. balance=balance_by,
  95. graph=topology,
  96. feedback=feedback,
  97. min_colors=min_colors)
  98. if len(feature_colors) == 0:
  99. return {self.OUTPUT: dest_id}
  100. max_colors = max(feature_colors.values())
  101. feedback.pushInfo(self.tr('{} colors required').format(max_colors))
  102. total = 20.0 / len(features)
  103. current = 0
  104. for feature_id, input_feature in features.items():
  105. if feedback.isCanceled():
  106. break
  107. output_feature = input_feature
  108. attributes = input_feature.attributes()
  109. if feature_id in feature_colors:
  110. attributes.append(feature_colors[feature_id])
  111. else:
  112. attributes.append(NULL)
  113. output_feature.setAttributes(attributes)
  114. sink.addFeature(output_feature, QgsFeatureSink.FastInsert)
  115. current += 1
  116. feedback.setProgress(80 + int(current * total))
  117. return {self.OUTPUT: dest_id}
  118. @staticmethod
  119. def compute_graph(features, feedback, create_id_graph=False, min_distance=0):
  120. """ compute topology from a layer/field """
  121. s = Graph(sort_graph=False)
  122. id_graph = None
  123. if create_id_graph:
  124. id_graph = Graph(sort_graph=True)
  125. # skip features without geometry
  126. features_with_geometry = {f_id: f for (f_id, f) in features.items() if f.hasGeometry()}
  127. total = 70.0 / len(features_with_geometry) if features_with_geometry else 1
  128. index = QgsSpatialIndex()
  129. i = 0
  130. for feature_id, f in features_with_geometry.items():
  131. if feedback.isCanceled():
  132. break
  133. g = f.geometry()
  134. if min_distance > 0:
  135. g = g.buffer(min_distance, 5)
  136. engine = QgsGeometry.createGeometryEngine(g.constGet())
  137. engine.prepareGeometry()
  138. feature_bounds = g.boundingBox()
  139. # grow bounds a little so we get touching features
  140. feature_bounds.grow(feature_bounds.width() * 0.01)
  141. intersections = index.intersects(feature_bounds)
  142. for l2 in intersections:
  143. f2 = features_with_geometry[l2]
  144. if engine.intersects(f2.geometry().constGet()):
  145. s.add_edge(f.id(), f2.id())
  146. s.add_edge(f2.id(), f.id())
  147. if id_graph:
  148. id_graph.add_edge(f.id(), f2.id())
  149. index.addFeature(f)
  150. i += 1
  151. feedback.setProgress(int(i * total))
  152. for feature_id, f in features_with_geometry.items():
  153. if feedback.isCanceled():
  154. break
  155. if feature_id not in s.node_edge:
  156. s.add_edge(feature_id, None)
  157. return s, id_graph
  158. class ColoringAlgorithm:
  159. @staticmethod
  160. def balanced(features, graph, feedback, balance=0, min_colors=4):
  161. feature_colors = {}
  162. # start with minimum number of colors in pool
  163. color_pool = set(range(1, min_colors + 1))
  164. # calculate count of neighbours
  165. neighbour_count = defaultdict(int)
  166. for feature_id, neighbours in graph.node_edge.items():
  167. neighbour_count[feature_id] += len(neighbours)
  168. # sort features by neighbour count - we want to handle those with more neighbours first
  169. sorted_by_count = [feature_id for feature_id in sorted(neighbour_count.items(),
  170. key=operator.itemgetter(1),
  171. reverse=True)]
  172. # counts for each color already assigned
  173. color_counts = defaultdict(int)
  174. color_areas = defaultdict(float)
  175. for c in color_pool:
  176. color_counts[c] = 0
  177. color_areas[c] = 0
  178. total = 10.0 / len(sorted_by_count) if sorted_by_count else 1
  179. i = 0
  180. for (feature_id, n) in sorted_by_count:
  181. if feedback.isCanceled():
  182. break
  183. # first work out which already assigned colors are adjacent to this feature
  184. adjacent_colors = {
  185. feature_colors[neighbour]
  186. for neighbour in graph.node_edge[feature_id]
  187. if neighbour in feature_colors
  188. }
  189. # from the existing colors, work out which are available (ie non-adjacent)
  190. available_colors = color_pool.difference(adjacent_colors)
  191. feature_color = -1
  192. if len(available_colors) == 0:
  193. # no existing colors available for this feature, so add new color to pool and repeat
  194. min_colors += 1
  195. return ColoringAlgorithm.balanced(features, graph, feedback, balance, min_colors)
  196. else:
  197. if balance == 0:
  198. # choose least used available color
  199. counts = [(c, v) for c, v in color_counts.items() if c in available_colors]
  200. feature_color = sorted(counts, key=operator.itemgetter(1))[0][0]
  201. color_counts[feature_color] += 1
  202. elif balance == 1:
  203. areas = [(c, v) for c, v in color_areas.items() if c in available_colors]
  204. feature_color = sorted(areas, key=operator.itemgetter(1))[0][0]
  205. color_areas[feature_color] += features[feature_id].geometry().area()
  206. elif balance == 2:
  207. min_distances = {c: sys.float_info.max for c in available_colors}
  208. this_feature_centroid = features[feature_id].geometry().centroid().constGet()
  209. # find features for all available colors
  210. other_features = {f_id: c for (f_id, c) in feature_colors.items() if c in available_colors}
  211. # loop through these, and calculate the minimum distance from this feature to the nearest
  212. # feature with each assigned color
  213. for other_feature_id, c in other_features.items():
  214. if feedback.isCanceled():
  215. break
  216. other_geometry = features[other_feature_id].geometry()
  217. other_centroid = other_geometry.centroid().constGet()
  218. distance = this_feature_centroid.distanceSquared(other_centroid)
  219. if distance < min_distances[c]:
  220. min_distances[c] = distance
  221. # choose color such that minimum distance is maximised! ie we want MAXIMAL separation between
  222. # features with the same color
  223. feature_color = sorted(min_distances, key=min_distances.__getitem__, reverse=True)[0]
  224. feature_colors[feature_id] = feature_color
  225. i += 1
  226. feedback.setProgress(70 + int(i * total))
  227. return feature_colors
  228. class Graph:
  229. def __init__(self, sort_graph=True):
  230. self.sort_graph = sort_graph
  231. self.node_edge = {}
  232. def add_edge(self, i, j):
  233. ij = [i, j]
  234. if self.sort_graph:
  235. ij.sort()
  236. (i, j) = ij
  237. if i in self.node_edge:
  238. self.node_edge[i].add(j)
  239. else:
  240. self.node_edge[i] = {j}
  241. def make_full(self):
  242. g = Graph(sort_graph=False)
  243. for k in self.node_edge.keys():
  244. for v in self.node_edge[k]:
  245. g.add_edge(v, k)
  246. g.add_edge(k, v)
  247. return g