<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: SightFlight</title>
    <description>The latest articles on DEV Community by SightFlight (@sightflight).</description>
    <link>https://dev.to/sightflight</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4193927%2F87affc12-c27a-4b21-8e03-398735e97a2d.jpg</url>
      <title>DEV Community: SightFlight</title>
      <link>https://dev.to/sightflight</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sightflight"/>
    <language>en</language>
    <item>
      <title>Calculating Roof Pitch and Azimuth from a Drone Point Cloud with Python</title>
      <dc:creator>SightFlight</dc:creator>
      <pubDate>Wed, 21 Oct 2026 09:58:00 +0000</pubDate>
      <link>https://dev.to/sightflight/calculating-roof-pitch-and-azimuth-from-a-drone-point-cloud-with-python-28ma</link>
      <guid>https://dev.to/sightflight/calculating-roof-pitch-and-azimuth-from-a-drone-point-cloud-with-python-28ma</guid>
      <description>&lt;p&gt;A drone survey of a roof ends with a point cloud: millions of 3D points that together describe the surface. For solar design and roofing estimates, the numbers people actually need are much simpler. How steep is each roof plane? Which direction does it face? How much surface area does it cover?&lt;br&gt;
In this tutorial we will compute all three with about 60 lines of Python and NumPy. We will fit planes to points, use RANSAC to separate roof planes from each other, and convert the results into the units roofers and solar designers use: degrees, rise over 12 inches, compass bearing and square meters.&lt;br&gt;
What we are computing&lt;br&gt;
Every flat roof plane can be described by its normal vector, the unit vector pointing straight out of the surface. Once you have it, the rest is geometry:&lt;br&gt;
• Tilt (pitch angle): the angle between the normal and straight up, arccos(n_z).&lt;br&gt;
• Rise per 12: the pitch roofers use, 12 * tan(tilt). A 6/12 roof rises 6 inches for every 12 inches of horizontal run.&lt;br&gt;
• Azimuth: the compass direction the plane faces, from the horizontal part of the normal, atan2(n_x, n_y). Zero is north, 90 is east, 180 is south.&lt;br&gt;
• Surface area: the plane's footprint seen from above, divided by cos(tilt).&lt;br&gt;
This assumes your coordinates are in a projected system in meters (or feet), with x pointing east, y north and z up. Pitch and azimuth do not depend on the unit, as long as x, y and z share it. Area comes out in the square of whatever unit you use.&lt;br&gt;
Setup&lt;br&gt;
You need NumPy and SciPy:&lt;br&gt;
pip install numpy scipy&lt;br&gt;
If you want to load real survey data later, add laspy too.&lt;br&gt;
Step 1: Fit a plane to a set of points&lt;br&gt;
Given the points on one roof plane, the best-fit plane passes through their centroid, and its normal is the direction in which the points vary the least. Singular value decomposition (SVD) gives us that directly.&lt;br&gt;
import numpy as np&lt;br&gt;
from scipy.spatial import ConvexHull&lt;/p&gt;

&lt;p&gt;def fit_plane(points):&lt;br&gt;
    """Least-squares plane through Nx3 points. Returns (unit normal, centroid)."""&lt;br&gt;
    centroid = points.mean(axis=0)&lt;br&gt;
    # The normal is the direction of least variance (smallest singular vector)&lt;br&gt;
    _, _, vt = np.linalg.svd(points - centroid, full_matrices=False)&lt;br&gt;
    normal = vt[-1]&lt;br&gt;
    if normal[2] &amp;lt; 0:          # make the normal point upward&lt;br&gt;
        normal = -normal&lt;br&gt;
    return normal, centroid&lt;br&gt;
Flipping the normal when z is negative matters. A plane has two valid normals, and without this step the azimuth could point 180 degrees the wrong way.&lt;br&gt;
Step 2: Find the dominant plane with RANSAC&lt;br&gt;
A real roof is not one plane. It has several, plus chimneys, vents, noise and stray points. A plain least-squares fit over everything would produce a meaningless average.&lt;br&gt;
RANSAC solves this. It repeatedly picks three random points, builds a plane through them, and counts how many other points lie within a small distance of it. The plane with the most supporters wins.&lt;br&gt;
def ransac_plane(points, threshold=0.05, iterations=500, rng=None):&lt;br&gt;
    """Find the dominant plane. Returns a boolean inlier mask."""&lt;br&gt;
    rng = rng or np.random.default_rng(42)&lt;br&gt;
    best_mask = np.zeros(len(points), dtype=bool)&lt;br&gt;
    for _ in range(iterations):&lt;br&gt;
        sample = points[rng.choice(len(points), 3, replace=False)]&lt;br&gt;
        n = np.cross(sample[1] - sample[0], sample[2] - sample[0])&lt;br&gt;
        norm = np.linalg.norm(n)&lt;br&gt;
        if norm &amp;lt; 1e-9:        # the three points are collinear&lt;br&gt;
            continue&lt;br&gt;
        n /= norm&lt;br&gt;
        dist = np.abs((points - sample[0]) @ n)&lt;br&gt;
        mask = dist &amp;lt; threshold&lt;br&gt;
        if mask.sum() &amp;gt; best_mask.sum():&lt;br&gt;
            best_mask = mask&lt;br&gt;
    return best_mask&lt;br&gt;
The threshold is the distance, in your coordinate units, within which a point counts as lying on the plane. With meters, 0.05 means 5 centimeters. Choose it based on your data's noise. Too small and real roof points get rejected; too large and neighboring planes merge.&lt;br&gt;
Step 3: Turn a plane into roofing numbers&lt;br&gt;
Now we refit using only the inliers and compute the metrics.&lt;br&gt;
def plane_metrics(points):&lt;br&gt;
    """Pitch, azimuth and surface area for one roof plane."""&lt;br&gt;
    normal, _ = fit_plane(points)&lt;br&gt;
    tilt = np.degrees(np.arccos(np.clip(normal[2], -1, 1)))&lt;br&gt;
    rise_per_12 = 12 * np.tan(np.radians(tilt))&lt;br&gt;
    # Compass bearing of the downslope direction (0 = north, 90 = east)&lt;br&gt;
    azimuth = np.degrees(np.arctan2(normal[0], normal[1])) % 360&lt;br&gt;
    # Footprint area divided by cos(tilt) gives sloped surface area&lt;br&gt;
    footprint = ConvexHull(points[:, :2]).volume   # in 2D, .volume is the area&lt;br&gt;
    area = footprint / np.cos(np.radians(tilt))&lt;br&gt;
    return {"tilt_deg": tilt, "rise_per_12": rise_per_12,&lt;br&gt;
            "azimuth_deg": azimuth, "area_m2": area}&lt;br&gt;
One detail that trips people up: for a 2D convex hull, SciPy's .volume attribute is the area, and .area is the perimeter.&lt;br&gt;
Step 4: Peel off one plane at a time&lt;br&gt;
To handle a roof with several planes, run RANSAC, remove the points it found, and repeat on what is left.&lt;br&gt;
def segment_roof(points, max_planes=4, threshold=0.05, min_points=200):&lt;br&gt;
    """Sequential RANSAC: peel off one plane at a time."""&lt;br&gt;
    remaining = points.copy()&lt;br&gt;
    planes = []&lt;br&gt;
    for _ in range(max_planes):&lt;br&gt;
        if len(remaining) &amp;lt; min_points:&lt;br&gt;
            break&lt;br&gt;
        mask = ransac_plane(remaining, threshold)&lt;br&gt;
        if mask.sum() &amp;lt; min_points:&lt;br&gt;
            break&lt;br&gt;
        planes.append(remaining[mask])&lt;br&gt;
        remaining = remaining[~mask]&lt;br&gt;
    return planes&lt;br&gt;
Try it on a synthetic roof&lt;br&gt;
Before touching real data, test on something where you know the answer. Here is a 12 by 10 meter gable roof with a ridge running east to west and a 6/12 pitch, which is a tilt of about 26.57 degrees. We add 1 centimeter of noise.&lt;br&gt;
rng = np.random.default_rng(1)&lt;/p&gt;

&lt;p&gt;n = 4000&lt;br&gt;
x = rng.uniform(0, 12, n)&lt;br&gt;
y = rng.uniform(0, 10, n)&lt;br&gt;
ridge_height = 6.0&lt;br&gt;
z = ridge_height - 0.5 * np.abs(y - 5) + rng.normal(0, 0.01, n)&lt;br&gt;
points = np.column_stack([x, y, z])&lt;/p&gt;

&lt;p&gt;for i, plane in enumerate(segment_roof(points, max_planes=2), 1):&lt;br&gt;
    m = plane_metrics(plane)&lt;br&gt;
    print(f"Plane {i}: tilt {m['tilt_deg']:.1f} deg | "&lt;br&gt;
          f"{m['rise_per_12']:.1f}/12 | faces {round(m['azimuth_deg']) % 360} deg | "&lt;br&gt;
          f"area {m['area_m2']:.1f} m2")&lt;br&gt;
Running it prints:&lt;br&gt;
Plane 1: tilt 26.5 deg | 6.0/12 | faces 0 deg | area 67.2 m2&lt;br&gt;
Plane 2: tilt 26.6 deg | 6.0/12 | faces 180 deg | area 66.4 m2&lt;br&gt;
That matches the geometry. Each half should be 6/12, one facing north and one facing south. The true surface area of each is 12 x 5 / cos(26.57 degrees), about 67.1 square meters, and the small differences are noise.&lt;br&gt;
Using real survey data&lt;br&gt;
Drone processing software usually exports point clouds as LAS or LAZ files. Load one with laspy:&lt;br&gt;
import laspy&lt;/p&gt;

&lt;p&gt;las = laspy.read("roof.laz")&lt;br&gt;
points = np.column_stack([las.x, las.y, las.z])&lt;br&gt;
Then crop it to a single building before segmenting. Running RANSAC on a whole site, with ground and trees, will find the ground first.&lt;br&gt;
Gotchas to watch for&lt;br&gt;
Use a projected coordinate system. If your data is in latitude and longitude, x and y are in degrees while z is in meters, and every number will be wrong. Reproject to a local system such as UTM first.&lt;br&gt;
Remove the ground and vegetation. Classify and filter before segmenting, or crop tightly to the roof. A common step is to keep only points above a minimum height.&lt;br&gt;
Convex hulls overestimate non-convex planes. An L-shaped roof section will report more area than it has. For production work, replace the hull with an alpha shape or rasterize the inlier points and count the cells.&lt;br&gt;
Dormers, vents and chimneys create small planes. The min_points setting filters most of them, but you may want to handle obstructions explicitly, since solar designers need to know where they are.&lt;br&gt;
Check the pitch against a known reference. If you can measure one roof by hand, or you have the plans, compare it. A consistent offset usually points to a scale or coordinate problem.&lt;br&gt;
Use a library for large clouds. Plain NumPy is fine for tens of thousands of points. For millions, Open3D has a built-in segment_plane method that is much faster.&lt;br&gt;
Where this fits in a real workflow&lt;br&gt;
Plane fitting is one step in a longer chain: plan the flight, capture overlapping images, process them into a point cloud, segment the roof, measure each plane, and produce a report or design file. Teams that do this often can either assemble the chain themselves, as in this tutorial, or use a tool that does it for them. Full disclosure: I work on &lt;a href="//sightflight.io"&gt;Sightflight&lt;/a&gt;, drone software for solar and roofing, which is built around that second option. Either way, understanding what happens under the hood makes it easier to judge any tool's output.&lt;br&gt;
Wrapping up&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw2d5bvwrou67ox4rlmit.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw2d5bvwrou67ox4rlmit.webp" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;br&gt;
With SVD, RANSAC and a little trigonometry you can turn a pile of points into the numbers a roofer or solar designer actually uses. Try it on one of your own scans, compare the output with a hand measurement, and tune the threshold until the planes match what you see on the roof.&lt;br&gt;
If you extend this, for example with alpha-shape areas or obstruction detection, share what you built in the comments.&lt;/p&gt;

</description>
      <category>solar</category>
      <category>software</category>
      <category>drone</category>
      <category>roof</category>
    </item>
  </channel>
</rss>
