gable_roof_extraction.py
1 ## gable roof extraction
2 #
3 # Script for detecting gable roof segment pairs (neighboring segments with
4 # similar inclination but opposite orientation).
5 #
6 # Workflow:
7 # 1. Reads a point cloud file and stores the intermediate results in a .odm file.
8 # 2. Computes normals and extracts roof segments (alpha shapes, bounding boxes).
9 # 3. Identifies potential gable roof pairs based on the angle between segment normals.
10 # 4. Computes ridge lines by intersecting the corresponding planes.
11 # 5. Visualizes the segment pairs and ridge lines, either iteratively
12 # (argument: --mode iterative) or together in a single figure (--mode all).
13 # 6. Plots can be shown interactively, saved, or both (--outputMode show|save|both).
14 #
15 # For an overview of all parameters, run:
16 # python gable_roof_extraction.py --help
17 
18 
19 
20 import opals
21 from opals import Histo, Import, Normals, Segmentation, pyDM
22 import numpy as np
23 import math
24 import matplotlib.pyplot as plt
25 from opals.tools.opalsargparse import ArgumentParser, Boolean
26 import os
27 
28 #----------setting arguments for parsing----------
29 
30 
31 
32 def get_args():
33  parser = ArgumentParser(description="The program reads a point cloud file, extracts possible gable roof segments and calculates the ridge line. The output is a 3d plot of the roof segments and their ridge lines")
34 
35  # input file
36  parser.add_argument("-inFile",
37  type=str,
38  required=True,
39  help="Point cloud input file to be analyzed"
40  )
41 
42  # output
43  parser.add_argument("-outFile",
44  default=None,
45  type=str,
46  help="Base name for generated output files")
47 
48  # preprocessing
49  parser.add_argument("-searchRadius",
50  type=float,
51  default=1.0,
52  help="Search radius (in meters) for normal computation and segmentation"
53  )
54  parser.add_argument("-neighbors",
55  type=int,
56  default=8,
57  help="Number of neighbors used for normal vector computation"
58  )
59  parser.add_argument("-skipIfExists",
60  type=Boolean,
61  default=True,
62  help="Skip Import, Normals, and Segmentation steps if outputs already exist"
63  )
64  # segmentation
65  parser.add_argument("-minSegSize",
66  type=int,
67  default=50,
68  help="Minimum number of points required per segment"
69  )
70 
71  parser.add_argument("-maxDist",
72  type=float,
73  default=0.05,
74  help="Maximum allowed point-to-plane distance during plane extraction"
75  )
76 
77  parser.add_argument("-maxSigma",
78  type=float,
79  default=0.15,
80  help="Maximum sigma0 value allowed during plane adjustment"
81  )
82 
83  parser.add_argument("-normalSigma0",
84  type=float,
85  default=0.02,
86  help="Points with a NormalSigma0 below this threshold are used as seed candidates"
87  )
88 
89  parser.add_argument("-alphaRadius",
90  type=float,
91  default=2,
92  help="Radius for alpha shape construction"
93  )
94 
95  parser.add_argument("-radius",
96  type=float,
97  default=1,
98  help="Search radius for local homogeneity checks")
99  # analysis
100  parser.add_argument("-searchExpand",
101  type=float,
102  default= 2.0,
103  help="Expands the bounding box by this value (in meters) when searching for intersecting segments"
104  )
105  # visualization
106  parser.add_argument("-mode",
107  type=str,
108  default="all",
109  choices=["all", "iterative"],
110  help="Plot mode: 'all' for a single figure, 'iterative' for one figure per pair"
111  )
112 
113  parser.add_argument("-outputMode",
114  type=str,
115  default="show",
116  choices=["show", "save", "both"],
117  help="Output mode: show plots, save them as .svg, or both"
118  )
119 
120  args = parser.parse_args()
121 
122  if args.outFile is None:
123  base = os.path.splitext(os.path.basename(args.inFile))[0]
124  args.outFile = base
125 
126  return args
127 
128 
129 #------------functions------------
130 
131 ## @package python.demo.gable_roof_extraction
132 # Creates a search box for a spatial query and expands it to make sure to find intersecting segments
133 def get_boxes(geometry, expand):
134  box = pyDM.Box(geometry)
135  box.expand(expand)
136 
137  return box
138 
139 # [Checking Roof Pairs section] start
140 
141 ##
142 # Checks if normals might correspond to gable roof pair
143 def check_gable_roof_normals(nx1, ny1, nz1, nx2, ny2, nz2, cogz1, cogz2):
144  angle_thres = math.pi / 180 * 5 # tolerance of 5 degrees
145  rel_distance_thres = 0.10
146  height_diff_thres = 0.1
147 
148  if None in (nx1, ny1, nz1, nx2, ny2, nz2):
149  return False
150  if nz1 < 0:
151  nx1, ny1, nz1 = -nx1, -ny1, -nz1
152  if nz2 < 0:
153  nx2, ny2, nz2 = -nx2, -ny2, -nz2
154 
155  alpha1 = math.atan2(ny1, nx1)
156  d1 = math.hypot(nx1, ny1)
157  alpha2 = math.atan2(ny2, nx2) + math.pi
158  d2 = math.hypot(nx2, ny2)
159  d = max(d1, d2)
160  dalpha = alpha1 - alpha2
161 
162  while dalpha < -math.pi:
163  dalpha += 2 * math.pi
164  while dalpha > math.pi:
165  dalpha += 2 * -math.pi
166 
167  angle_crit = abs(dalpha) < angle_thres
168  distance_crit = (1 - d1 / d) < rel_distance_thres and (1 - d2 / d) < rel_distance_thres
169  heightdiff_crit = abs(cogz1-cogz2) < height_diff_thres
170 
171  return angle_crit and distance_crit and heightdiff_crit
172 
173 # [Checking Roof Pairs section] finished
174 
175 ##
176 # Computes the intersection line between two roof segments (returns one point and a direction vector).
177 # Because of large coordinate values, reduced coordinates are used
178 def compute_ridge_line(c1, n1, c2, n2):
179  n1 = np.asarray(n1, dtype=float)
180  n2 = np.asarray(n2, dtype=float)
181  c1 = np.asarray(c1, dtype=float)
182  c2 = np.asarray(c2, dtype=float)
183 
184  # plane 1: NormalX[1]*Xred + NormalY[1]*Yred + NormalZ[1]*Zred = n1 * Xred = 0
185  # plane 2: NormalX[2]*Xred + NormalY[2]*Yred + NormalZ[2]*Zred + offset = 0
186  # offset = - NormalX[2]*(cogX[2]- cogX[1]) - NormalY[2]*(cogY[2]- cogY[1]) -NormalZ[2]*(cogZ[2]- cogZ[1])
187  offset = -np.dot(n2, c2 - c1)
188 
189  # direction of itersection line
190  v = np.cross(n1, n2)
191  v_squared = np.dot(v, v)
192 
193  # base point, d1,d2 are the distance from origin
194  d1 = 0
195  d2 = -offset
196  p0_red = (d1 * np.cross(n2, v) + d2 * np.cross(v, n1)) / v_squared
197  p0_global = p0_red + c1
198 
199  return p0_global, v
200 
201 def compute_ridge_line_segment(p0, v, poa1, poa2):
202  lambdas = []
203  v_2 = np.dot(v, v)
204  for i in range(len(poa1[0])):
205  p = np.array([poa1[0][i], poa1[1][i], poa1[2][i]])
206  lam = np.dot((p - p0), v) / v_2
207  lambdas.append(lam)
208 
209  for i in range(len(poa2[0])):
210  p = np.array([poa2[0][i], poa2[1][i], poa2[2][i]])
211  lam = np.dot((p - p0), v) / v_2
212  lambdas.append(lam)
213 
214  p1 = p0 + min(lambdas) * v
215  p2 = p0 + max(lambdas) * v
216 
217  return p1, p2
218 
219 def set_axes_radius(ax, origin, radius):
220  ax.set_xlim3d([origin[0] - radius, origin[0] + radius])
221  ax.set_ylim3d([origin[1] - radius, origin[1] + radius])
222  ax.set_zlim3d([origin[2] - radius, origin[2] + radius])
223 
224 ##
225 # Makes axes of a 3D plot have equal scale so that spheres appear as spheres,
226 # cubes as cubes, etc. This is one possible solution to Matplotlib's
227 # ax.set_aspect('equal') and ax.axis('equal') not working for 3D.
228 # Input: ax: a matplotlib axis, e.g., as output from plt.gca().
229 #
230 def set_axes_equal(ax):
231  limits = np.array([
232  ax.get_xlim3d(),
233  ax.get_ylim3d(),
234  ax.get_zlim3d(),
235  ])
236 
237  origin = np.mean(limits, axis=1)
238  radius = 0.5 * np.max(np.abs(limits[:, 1] - limits[:, 0]))
239  set_axes_radius(ax, origin, radius)
240 
241 def main():
242  args = get_args()
243 
244  if args.skipIfExists == False or not os.path.exists(f"{args.outFile}_segments.odm"):
245  # import file
246  imp = Import.Import()
247  imp.inFile = args.inFile
248  imp.outFile = f"{args.outFile}.odm"
249  imp.run()
250  imp.reset()
251 
252  # calculating normal vectors for segmentation
253  norm = Normals.Normals()
254  norm.inFile = f"{args.outFile}.odm"
255  norm.neighbours = int(args.neighbors)
256  norm.searchRadius = float(args.searchRadius)
257  norm.run()
258  norm.reset()
259 
260  # creates roofsegments and their alpha shapes as well as bounding boxes
261  seg = Segmentation.Segmentation()
262  seg.inFile = f"{args.outFile}.odm"
263  seg.searchRadius = float(args.radius)
264  seg.minSegSize = int(args.minSegSize)
265  seg.method = opals.Types.SegmentationMethod.planeExtraction
266  seg.planeExtraction.maxDist = float(args.maxDist)
267  seg.planeExtraction.maxSigma = float(args.maxSigma)
268  seg.alphaRadius = int(args.alphaRadius)
269  seg.planeExtraction.seedCalculator = f"NormalSigma0<{args.normalSigma0} ? NormalSigma0 : invalid"
270  seg.byproduct = f"{args.outFile}_segments.odm"
271  seg.run()
272  seg.reset()
273 
274  # define layout for iterating over odm file
275  f = pyDM.AddInfoLayoutFactory()
276  f.addColumn(pyDM.ColumnSemantic.SegmentID)
277  f.addColumn(pyDM.ColumnType.bool_, "_AlphaShape")
278  f.addColumn(pyDM.ColumnType.double_, "_cogx")
279  f.addColumn(pyDM.ColumnType.double_, "_cogy")
280  f.addColumn(pyDM.ColumnType.double_, "_cogz")
281  f.addColumn(pyDM.ColumnSemantic.NormalX)
282  f.addColumn(pyDM.ColumnSemantic.NormalY)
283  f.addColumn(pyDM.ColumnSemantic.NormalZ)
284  f.addColumn(pyDM.ColumnType.uint32, "_PointCount")
285  layout = f.getLayout()
286 
287  # load roofsegment.odm and Polylines
288  odm = pyDM.Datamanager.load(f"{args.outFile}_segments.odm", True, False)
289  pi = odm.getPolylineIndex()
290 
291  # [Searching section] start
292 
293  #searching for gable roof pairs
294  pairs = {} #uses a dictionary to remove redundant pairs via next()
295  for geom in odm.geometries(layout):
296  box = get_boxes(geom, expand= float(args.searchExpand))
297  info = geom.info()
298  # makes sure to just use bounding boxes, alpha shapes do not have normal vectors or cog
299  if info.get(1):
300  continue
301  boxID = info.get(0)
302  nx1, ny1, nz1 = info.get(5), info.get(6), info.get(7)
303  cogz1 = info.get(4)
304  pointCountA = info.get(8)
305 
306  window = pyDM.Window(box)
307  segs = pi.searchGeometry(window, pyDM.SpatialQueryMode.intersect) #gets all other boxes intercepting with the given box
308 
309  for seg in segs:
310  seg.cloneAddInfoView(layout, True)
311  sinfo = seg.info()
312  if sinfo.get(1):
313  continue
314  segID = sinfo.get(0)
315  nx2, ny2, nz2 = sinfo.get(5), sinfo.get(6), sinfo.get(7)
316  cogz2 = info.get(4)
317  pointCountB = sinfo.get(8)
318 
319  if check_gable_roof_normals(nx1, ny1, nz1, nx2, ny2, nz2, cogz1, cogz2):
320  total_points = pointCountA + pointCountB
321  pair = tuple(sorted((boxID,segID)))
322  existing_pair = next((p for p in pairs.keys() if boxID in p or segID in p), None)
323  if existing_pair:
324  if pairs[existing_pair] < total_points:
325  del pairs[existing_pair]
326  pairs[pair] = total_points
327  else:
328  pairs[pair] = total_points
329  print(f"found {len(pairs)} potential roof pairs")
330 
331  # [Searching section] finished
332 
333  # collect bounding boxes and necessary attributes for ridgeline computation
334  boxes = {}
335  for geom in odm.geometries(layout):
336  info = geom.info()
337  if info.get(1):
338  continue
339  segID = info.get(0)
340  n = (info.get(5), info.get(6), info.get(7))
341  c = (info.get(2), info.get(3), info.get(4))
342  b = pyDM.Box(geom)
343  boxes[segID] = dict(n=n, c=c, xmin=b.xmin, ymin=b.ymin, xmax=b.xmax, ymax=b.ymax)
344 
345  #collect points of alpha shapes
346  points_of_alphashape = dict()
347  for geom in odm.geometries(layout):
348  info = geom.info()
349  if not info.get(1):
350  continue
351  segID = info.get(0)
352 
353  all_x = [pt.x for pt in geom.points()]
354  all_y = [pt.y for pt in geom.points()]
355  all_z = [pt.z for pt in geom.points()]
356  points_of_alphashape[segID] = [all_x, all_y, all_z]
357 
358 
359  # [Computation section] start
360  # computing ridge lines
361  ridge_lines = []
362  for segA, segB in pairs.keys():
363  A, B = boxes[segA], boxes[segB]
364  p0, v = compute_ridge_line(A["c"], A["n"], B["c"], B["n"])
365 
366  p1, p2 = compute_ridge_line_segment(p0, v, points_of_alphashape[segA], points_of_alphashape[segB])
367 
368  ridge_lines.append((segA, segB, tuple(p1.tolist()), tuple(p2.tolist())))
369 
370  print(f"{len(ridge_lines)} ridge lines found")
371 
372  # [Computation section] finished
373 
374  # function for visualising
375 
376  def plot_results(pairs, ridge_lines, layout, odm, title=""):
377  fig = plt.figure(figsize=(10, 8))
378  ax = fig.add_subplot(111, projection= "3d")
379  # get segment IDs
380  paired_ids = set()
381  colors_of_pair = dict()
382  for pair in pairs:
383  color = np.random.rand(3,)
384  for segID in pair:
385  paired_ids.add(segID)
386  colors_of_pair[segID] = color
387 
388  #plot gable roof surface
389  for geom in odm.geometries(layout):
390  info = geom.info()
391  segID = info.get(0)
392  isAlpha = info.get(1)
393  if not isAlpha or segID not in paired_ids:
394  continue
395 
396  #color = np.random.rand(3,)
397 
398  # gets points from sub-segments
399  for part in geom.parts():
400  xs = [pt.x for pt in part.points()]
401  ys = [pt.y for pt in part.points()]
402  zs = [pt.z for pt in part.points()]
403  ax.plot(xs, ys, zs, color=colors_of_pair[segID], linewidth=1.0, alpha=0.7)
404 
405  # drawing ridge lines
406  for (a, b, p1, p2) in ridge_lines:
407  if (a, b) in pairs or (b, a) in pairs:
408  xs = [p1[0], p2[0]]
409  ys = [p1[1], p2[1]]
410  zs = [p1[2], p2[2]]
411  ax.plot(xs, ys, zs, color="k", linewidth=1.0)
412 
413  set_axes_equal(ax)
414  plt.title(title)
415  plt.axis("off")
416  return fig, ax
417 
418  # visualization
419  mode = args.mode
420  output_mode = args.outputMode
421  figures = []
422 
423  if mode == "all":
424  # creates one plot containing all roof pairs
425  print("creating one plot to show all roof pairs")
426  fig, ax = plot_results(pairs.keys(), ridge_lines, layout, odm, "all roof pairs")
427  filename=f"{args.outFile}_all_roof_pairs.svg"
428  if output_mode in ("save", "both"):
429  plt.savefig(filename)
430  if output_mode == "save":
431  plt.close(fig)
432  if output_mode in("show", "both"):
433  plt.show()
434 
435  elif mode == "iterative":
436  # creates a .svg file for each pair
437  print("creating one plot for each of the pairs")
438  for i, pair in enumerate(pairs.keys()):
439  fig, ax = plot_results([pair], ridge_lines, layout, odm, f"Roof pair {i + 1}: {pair}")
440  filename = f"{args.outFile}_roof_pair_{i + 1}.svg"
441  if output_mode in ("save", "both"):
442  plt.savefig(filename)
443  if output_mode == "save":
444  plt.close(fig)
445  else:
446  figures.append(fig)
447  if output_mode in ("show", "both"):
448  plt.show()
449 
450  plt.close(fig)
451 
452 if __name__ == "__main__":
453  main()
454 
455