> ## Documentation Index
> Fetch the complete documentation index at: https://docs.wherobots.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Enrich Roof Detections with Buildings and Canopy Height

> Part 2 of the Roof Inspection Guide: match SAM3 roof detections to Overture building footprints and measure nearby canopy height with Wherobots catalog data.

<Badge color="purple">Public Preview</Badge>

<br />

<br />

<CardGroup cols={2}>
  <Card title="Start with Part 1: Roof Inspection Guide" icon="house-chimney" href="/develop/rasterflow/rasterflow-roof-screening">
    Run the SAM3 detection and score the roofs it returns
  </Card>

  <Card title="SAM3 Notebook" icon="wand-magic-sparkles" href="/tutorials/example-notebooks/rasterflow-sam3">
    The full text-prompted geometry inference walkthrough
  </Card>

  <Card title="Insurance & Risk solutions" icon="file-shield" href="/tutorials/use-cases/insurance">
    Catastrophe exposure, underwriting enrichment, and portfolio concentration patterns
  </Card>

  <Card title="RasterFlow Billing" icon="credit-card" href="/get-started/organization-management/rasterflow-billing">
    Learn how RasterFlow usage is metered in RasterFlow Spatial Units
  </Card>
</CardGroup>

<Note>
  **Run the [Roof Inspection Guide](/develop/rasterflow/rasterflow-roof-screening) first.** This is the second half of that walkthrough and does not stand alone. The queries below run in the `SedonaContext` session Part 1 opens, against the `examples_temp.sam3_db.sam3_{AOI_NAME}` table the SAM3 run writes. The canopy query also needs the `scored_roofs` temp view, which Part 1 builds from that table and your own `my_portfolio` records.
</Note>

## What the Wherobots catalogs add to a roof

The roof polygons from Part 1 are useful as a join key. The examples below match them to Overture building footprints and measure canopy height within 10 m of each roof. These two checks add context to the detections without licensing another dataset.

<AccordionGroup>
  <Accordion title="Overture building footprints" icon="building">
    `wherobots_open_data.overture_maps_foundation.buildings_building`

    A stable building identity to deduplicate detections against, plus `height`, `num_floors`, and `class`. Also `roof_material`, `roof_shape`, and `roof_color` where a contributor supplied them.

    **Data vintage:** OpenStreetMap contributions through July 2026.
  </Accordion>

  <Accordion title="Meta canopy height" icon="tree">
    `wherobots_open_data.meta_canopy_height.global_v2`

    Tree height around the structure, for overhang, debris, and windthrow exposure.

    **Data vintage:** not carried in the catalog. The table exposes only `ingested_at`, which is when Wherobots loaded it, not when the imagery was flown.
  </Accordion>
</AccordionGroup>

<Tip>
  **Check Overture before you buy roof material.** `buildings_building` carries `roof_material`, `roof_shape`, `roof_color`, and `roof_height`. They are contributor-supplied, so coverage is sparse and uneven, but they are free. Join them to your detections and count the non-null rate over your own footprint before you price a commercial roof-attribute feed.
</Tip>

## Reconcile detections with building footprints

A detection is not a building record. One structure can come back as two polygons, and a row of attached houses can come back as one, so the row count off the SAM3 table is not a building count. Overture's `id` identifies matched footprints; detections with no match and detections spanning several footprints still need review.

This pairs every roof detection with footprints it overlaps by area, keeps the largest overlap, and records how many footprints it covered. A shared edge alone does not count as a match:

```python theme={"system"}
matched_table = f"examples_temp.sam3_db.roof_footprints_{AOI_NAME}"

sedona.sql(f"""
    WITH detections AS (
      SELECT
        ROW_NUMBER() OVER (ORDER BY bbox_score DESC, area_m2 DESC, ST_AsText(geometry)) AS detection_id,
        geometry,
        area_m2,
        bbox_score
      FROM examples_temp.sam3_db.sam3_{AOI_NAME}
      WHERE layer = 'roofs'
    ),
    candidates AS (
      SELECT
        d.detection_id,
        b.id                                                     AS building_id,
        b.class                                                  AS building_class,
        b.height                                                 AS building_height_m,
        b.roof_material,
        b.roof_shape,
        ST_AreaSpheroid(ST_Intersection(d.geometry, b.geometry))  AS overlap_m2
      FROM detections d
      JOIN wherobots_open_data.overture_maps_foundation.buildings_building b
        ON ST_Intersects(d.geometry, b.geometry)
    ), ranked AS (
      SELECT *,
        COUNT(*) OVER (PARTITION BY detection_id)                 AS footprints_touched,
        ROW_NUMBER() OVER (
          PARTITION BY detection_id
          ORDER BY overlap_m2 DESC, building_id
        )                                                         AS overlap_rank
      FROM candidates
      WHERE overlap_m2 > 0
    ), best AS (
      SELECT * FROM ranked WHERE overlap_rank = 1
    )
    SELECT
      d.detection_id, d.geometry, d.area_m2, d.bbox_score,
      b.building_id, b.building_class, b.building_height_m,
      b.roof_material, b.roof_shape, b.overlap_m2,
      COALESCE(b.footprints_touched, 0) AS footprints_touched
    FROM detections d
    LEFT JOIN best b ON d.detection_id = b.detection_id
""").writeTo(matched_table).createOrReplace()

print(f"{matched_table}: {sedona.table(matched_table).count():,} roof detections matched")
```

One row per detection now, so the reconciliation is a single aggregate. Its last two columns answer the question the [Tip above](#what-the-wherobots-catalogs-add-to-a-roof) asks, over your own area rather than in general:

```python theme={"system"}
sedona.sql(f"""
    SELECT
      COUNT(*)                                                AS roof_detections,
      COUNT(DISTINCT building_id)                             AS distinct_buildings,
      SUM(CASE WHEN building_id IS NULL THEN 1 ELSE 0 END)    AS no_footprint,
      SUM(CASE WHEN footprints_touched > 1 THEN 1 ELSE 0 END) AS spans_several_footprints,
      ROUND(100.0 * COUNT(DISTINCT CASE WHEN roof_material IS NOT NULL THEN building_id END)
            / NULLIF(COUNT(DISTINCT building_id), 0), 1)
                                                              AS roof_material_pct,
      ROUND(100.0 * COUNT(DISTINCT CASE WHEN roof_shape IS NOT NULL THEN building_id END)
            / NULLIF(COUNT(DISTINCT building_id), 0), 1)
                                                              AS roof_shape_pct
    FROM {matched_table}
""").show()
```

How to read it:

* **`distinct_buildings` counts the Overture building IDs selected as best matches.** It is not a complete building count: a single detection can span several footprints, and the best-match filter keeps only one of them.
* **`no_footprint` counts unmatched detections, not buildings.** Overture is contributor-supplied, so a real structure may be absent, but multiple unmatched polygons can also describe one structure. Inspect these detections before adding them to an inventory.
* **`spans_several_footprints` flags detections that cover more than one building footprint.** Before assigning these to policies, run a separate join that keeps every positive-area detection–footprint pair and cut each piece with `ST_Intersection(d.geometry, b.geometry)`. The best-match query above intentionally retains only one pair per detection.
* **`roof_material_pct` and `roof_shape_pct` are the non-null rates over distinct matched building IDs.** Run them before pricing a commercial roof-attribute feed.

<Note>
  Reconcile unmatched detections and those spanning multiple footprints before reporting a building inventory or using the counts in a pricing decision. This summary identifies both cases; it does not resolve them.
</Note>

## Tree overhang over each detected roof

Canopy above a roof is a windthrow and debris exposure, and the reason some of your detections are incomplete. Buffer each roof by 10 m, include every canopy tile touching that buffer, and take the maximum of the per-tile results. `allTouched=true` includes canopy pixels touched by the buffer even when their centers fall outside it. The inner spatial join lets Sedona optimize tile matching; a regular left join brings roofs outside canopy coverage back with a null height.

```python theme={"system"}
sedona.sql("""
    WITH roofs AS (
      SELECT
        policy_id,
        roof_area_m2,
        ST_AsText(geometry) AS roof_wkt,
        ST_Buffer(geometry, 10, true) AS zone  -- 10 m around the roof, in meters
      FROM scored_roofs
    ), canopy_by_roof AS (
      SELECT r.roof_wkt,
             MAX(RS_ZonalStats(c.rast, r.zone, 1, 'max', true)) AS max_canopy_height_m
      FROM roofs r
      JOIN wherobots_open_data.meta_canopy_height.global_v2 c
        ON RS_Intersects(c.rast, r.zone)
      GROUP BY r.roof_wkt
    )
    SELECT r.policy_id, r.roof_area_m2, c.max_canopy_height_m
    FROM roofs r
    LEFT JOIN canopy_by_roof c ON r.roof_wkt = c.roof_wkt
    ORDER BY c.max_canopy_height_m DESC
""").show()
```

## Next steps

<CardGroup cols={2}>
  <Card title="Roof Inspection Guide" icon="house-chimney" href="/develop/rasterflow/rasterflow-roof-screening">
    Back to the detection run, the cost table, and output triage.
  </Card>

  <Card title="Insurance & Risk solutions" icon="file-shield" href="/tutorials/use-cases/insurance">
    Catastrophe exposure, underwriting enrichment, and portfolio concentration patterns.
  </Card>

  <Card title="Run as a Job" icon="bolt" href="/develop/rasterflow/rasterflow-jobs">
    Put the inspection on a schedule once the prompt and threshold are settled.
  </Card>

  <Card title="Visualize RasterFlow outputs" icon="map" href="/develop/rasterflow/rasterflow-visualization">
    Check a mosaic before inference and detections after, on the same map.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.