> ## 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.

# Example: Low-Cost Roof and Solar Inspection with SAM3

> Run SAM3 with text prompts over the area you actually care about, then decide which properties are worth paying for detailed data.

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

<br />

<br />

<CardGroup cols={2}>
  <Card title="RasterFlow Overview" icon="file-lines" href="/develop/rasterflow/">
    Learn about RasterFlow's key features and capabilities
  </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="RasterFlow Datasets" icon="satellite" href="/develop/rasterflow/rasterflow-datasets">
    Learn about built-in datasets and how to bring your own
  </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>

SAM3 detects objects directly from text prompts, so there is no fixed set of detectable categories. The same function call that finds roofs can help find solar panels, shipping containers, parking lots, roads, or sidewalks.

**Goal of this guide**: Roof detection across a property portfolio is the working example this guide follows. Nothing in the workflow is specific to roofs: change the prompt and the same steps apply to whatever you need to find.

<Info>
  **What you will be able to do at the end of this guide:**

  1. **Estimate each task before you start it**, from the area and resolution you chose. The preview mosaic and overviews in this walkthrough are billed separately from the recipe's mosaic and inference.
  2. **Confirm 30 cm NAIP covers your area** in the window you asked for, and get back the years that would work when it does not, before anything is billed.
  3. **Establish how old the imagery is** from the scene index, which is the only place the flight date is available.
  4. **Build a preview mosaic and inspect it** for gaps and seams before the model runs.
  5. **Run one inference call with several text prompts** and read the polygons and confidence scores it returns.
  6. **Set an area floor, persist the detections, and put them on a map**, then score roof age against the staleness of the evidence.

  **Reading this guide:** The numbered steps under [Inspect your own area of interest](#inspect-your-own-area-of-interest) and [Augment old data with newer available datasets](#augment-old-data-with-newer-available-datasets) are the parts you run yourself. Every other section is reference: a finished run to look at, what it costs, and where the model's limits are.
</Info>

## Spend your data budget on the properties that need it

Managing a portfolio of 40,000 properties usually means you only have the budget to closely inspect a few hundred. Licensing national roof-condition and hazard datasets charges you for nationwide coverage when you only need three counties, and renews every year. RasterFlow runs on demand instead: describe what you want to find in plain language, define your target area, and receive georeferenced polygons with confidence scores. Estimate the charges for each task from your area of interest before you run it.

RasterFlow **provides a fast triage layer**, low-cost detection you run ahead of the better and more expensive options, helping you pinpoint the exact addresses that justify an expensive full inspection or condition report, instead of having to buy the whole thing.

<Note>
  **What SAM3 returns:** A polygon and a confidence score for each detection. There is no condition grade and no material class. Roof and solar detections are separate polygons; review or spatially match them before deciding whether a particular roof carries solar. The detections help target a closer inspection. Grading condition still takes a condition dataset, an aerial-inspection vendor, or an adjuster.
</Note>

## What an inspection run costs

RasterFlow Spatial Units come from pixels, so you can estimate each task before you start it. The recipe in step 7 bills a mosaic and SAM3 inference. This walkthrough also builds a separate preview mosaic and runs Build Multiscales in step 6. The estimates below assume 30 cm NAIP, four-band mosaics, three RGB bands for SAM3 inference, and one time period. Each task carries a [1 RasterFlow Spatial Unit minimum](/get-started/organization-management/rasterflow-billing#how-a-tasks-cost-is-calculated).

| Area of interest | Area | Preview mosaic (4 bands, 0.1x) | Build Multiscales (4 bands) | Recipe mosaic (4 bands, 0.1x) | SAM3 inference (RGB, 1.0x) |
| - | - | - | - | - | - |
| Keizer, OR, the city in the screenshots | 18.8 km² | **1.0** (minimum) | **1.0** (minimum) | **1.0** (minimum) | **1.0** (minimum) |
| Salem, OR, a mid-sized city | 127.4 km² | **1.0** (minimum) | **5.7** | **1.0** (minimum) | **4.2** |
| Marion County, OR, the full reference run | 3,084.7 km² | **13.7** | **137.1** | **13.7** | **102.8** |

These are area-based estimates; the recipe adjusts its mosaic grid and may buffer a small area of interest, so actual usage can differ. Each task has its own price per Spatial Unit, so [multiply the units in each column by that task's current price and add the costs](/get-started/organization-management/rasterflow-billing#how-a-tasks-cost-is-calculated), rather than treating the units as one combined rate. A full cache hit for an identical task within seven days is not charged, but the separate preview mosaic and the recipe mosaic use different inputs and should be budgeted as separate tasks. A WherobotsDB runtime is [metered separately](/get-started/organization-management/billing#what-initiates-a-billable-event).

<Tip>
  Validate the prompt and the confidence threshold on the smallest area that contains a representative sample, then scale up. Resolution and area are the only large levers, and a rerun of an identical task within the 7-day cache period is not charged. For the current rate per RasterFlow Spatial Unit, see [Wherobots Pricing](https://wherobots.com/pricing).
</Tip>

## What one prompt returns

This run has already finished, so there is nothing to run or set up here. The screenshot below comes from a SAM3 run over the whole of Marion County, Oregon: 3,085 km² in one call. The red polygons are the `"roofs"` prompt.

Two things had to be computed before this map existed, both of them by that finished run:

* **The input mosaic.** RasterFlow pulled the 30 cm NAIP scenes covering the county over the date window and stitched them into one seamless Zarr mosaic. That is the imagery under the polygons, and it is what SAM3 reads.
* **The detections.** SAM3 ran over that mosaic and returned a polygon and a confidence score for every object it found. For a county-sized result those polygons are tiled into a single PMTiles layer so the whole county renders in a browser.

Both layers are on the map, listed in the Layers panel as **SAM3 input mosaic** and **SAM3 PM Tiles**, so you can switch between the detections and the raw imagery SAM3 saw. When you inspect your own area further down, `predict_mosaic_geometries_recipe()` does both steps in one call.

<Frame caption="A finished SAM3 run over Marion County, Oregon, viewed in Wherobots Cloud. Every red polygon is a roof SAM3 detected from the prompt 'roofs'. The sublayers on the left are the text prompts submitted in the same call.">
  <img src="https://mintcdn.com/wherobots/9BygqgN_IJaBLFn2/images/develop/rasterflow/sam3-roofs-solar-marion-county.jpg?fit=max&auto=format&n=9BygqgN_IJaBLFn2&q=85&s=104c959aa7fd89333c94e3826fb791ae" alt="The Wherobots Cloud map viewer showing several hundred red roof polygons over 30 cm aerial imagery of a residential neighborhood, with a Layers panel on the left listing eight SAM3 sublayers, one per text prompt" width="1600" height="953" data-path="images/develop/rasterflow/sam3-roofs-solar-marion-county.jpg" />
</Frame>

<Card title="Open the Marion County run on the map" icon="map" href="https://viz.wherobots.com/?z=9.61&lat=44.98530&lng=-122.47688&l0.id=3930c361-98e6-4b7a-a534-ef69709881a7&l0.src=s3%3A%2F%2Fwherobots-examples%2Frasterflow%2Fmosaics%2Fmarion_county_optimized.zarr&l0.name=SAM3+input+mosaic&l0.clim=35.23256150636151%2C187.34808753310205&l0.mode=rgb&l0.rgb=red%2Cgreen%2Cblue%2Cband&l0.ndvi=nir%2Cred%2Cband&l0.var=variables&l1.id=4c8693bf-6375-4e97-887c-6c08b7e4d10b&l1.src=s3%3A%2F%2Fwherobots-examples%2Frasterflow%2Fmodel-outputs%2Fmarion_county_sam3.pmtiles&l1.name=SAM3+PM+Tiles&l1.type=pmtiles&l1.lw=2&l1.cr=5&title=SAM3+Marion+County+OR" horizontal>
  No account or runtime needed. Zoom into a neighborhood and toggle the sublayers.
</Card>

## Several prompts in one run

That run carried eight text prompts. The output is a single PMTiles layer with one sublayer per prompt, so you toggle whichever question you are asking.

<Frame caption="The sublayers of the Marion County output. Each one is a text prompt passed to the same predict_mosaic_geometries_recipe() call.">
  <img src="https://mintcdn.com/wherobots/9BygqgN_IJaBLFn2/images/develop/rasterflow/sam3-sublayers-eight-prompts.jpg?fit=max&auto=format&n=9BygqgN_IJaBLFn2&q=85&s=1c4f7e45494792ddefa1768c0b4afc7c" alt="The Layers panel from Wherobots Cloud, showing a single layer named SAM3 PM Tiles with eight sublayers listed beneath it, one for each text prompt in the run. The solar panels and roofs sublayers are visible; the rest are toggled off." style={{ maxWidth: "350px" }} width="700" height="974" data-path="images/develop/rasterflow/sam3-sublayers-eight-prompts.jpg" />
</Frame>

```python theme={"system"}
text_prompt=["roofs", "solar panels"]
```

The published RasterFlow Spatial Unit estimate uses `spatial pixels × bands × time periods`; prompt count is not a term in that estimate. See [RasterFlow Billing](/get-started/organization-management/rasterflow-billing#what-is-a-rasterflow-spatial-unit) for the calculation, and check [Workload History](https://cloud.wherobots.com/workloads) for the units actually billed on your run.

For a property portfolio, one pass answers several underwriting questions:

| Prompt | What it tells you |
| - | - |
| `"roofs"` | Candidate roof polygons and areas. Match them to building footprints before using them as a replacement-cost denominator |
| `"solar panels"` | Candidate PV locations to check against the roof polygons. Panels have to come off and go back on, which changes the cost of a roof claim |
| `"shipping containers"` | Candidate storage structures to check against permits on commercial or rural risks |

<Tip>
  The `"solar panels"` sublayer is switched on in the screenshots and returns few detections. Compare a sample with known panel locations before interpreting that count; the model can miss panels, especially small or obscured ones.
</Tip>

## Before you start

<AccordionGroup>
  <Accordion title="Wherobots requirements" icon="cloud">
    * RasterFlow access in your Organization. RasterFlow is in Public Preview for all paid Organizations.
    * A Wherobots notebook, a [VS Code Extension](/develop/vscode-extension/notebooks) workspace, or a [Job Run](/develop/rasterflow/rasterflow-jobs) with `rasterflow_remote` available.
    * The **Micro** [runtime](/develop/runtimes/) is enough. RasterFlow manages its own compute, so a larger runtime adds cost without making the run faster.
  </Accordion>

  <Accordion title="Area of interest requirements" icon="location-crosshairs">
    * An area of interest in the continental United States, in any format GeoPandas can read (GeoJSON, GeoParquet, or Shapefile) or an in-memory `GeoDataFrame`.
    * **30 cm NAIP coverage for that area and date range.** Both SAM3 recipes are fixed to 30 cm NAIP, and not every state has 30 cm in every year. Some states have none. [Confirm 30 cm NAIP coverage for your area](#inspect-your-own-area-of-interest) before you commit to one.
  </Accordion>
</AccordionGroup>

## Inspect your own area of interest

Each step below is work you do in a notebook, against your own area of interest. If you have not opened [the finished Marion County run](#what-one-prompt-returns) yet, do that first: it needs no account or runtime, and it gives you a reference output to compare yours against.

The code below is the notebook's own cells. Everything you would change to point the run somewhere else is in the first cell, and the rest of the notebook reads from it.

<Steps>
  <Step title="Start a Micro runtime and open the notebook" icon="play">
    Open the [SAM3 solution notebook](https://cloud.wherobots.com/model-hub/object-detection-sam3) from the **Model Hub** in Wherobots Cloud, or open `examples/Analyzing_Data/RasterFlow_SAM3.ipynb` in a Wherobots notebook.

    <Note>
      Starting a runtime begins a [billable event](/get-started/organization-management/billing#what-initiates-a-billable-event) in Spatial Units. RasterFlow tasks are billed separately, in [RasterFlow Spatial Units](/get-started/organization-management/rasterflow-billing).
    </Note>
  </Step>

  <Step title="Set what you edit in one cell" icon="sliders">
    Every parameter the run takes is hoisted into a single cell, so pointing the notebook at a different area or a different set of objects is one edit in one place.

    ```python theme={"system"}
    from datetime import datetime
    import os

    import geopandas as gpd
    import wkls

    # Area of interest. `or` is a Python keyword, so wkls exposes Oregon by name.
    # wkls.us.oregon.cities() and wkls.us.oregon.counties() list what is available,
    # and a wrong name raises an error suggesting the closest matches.
    AOI = wkls.us.oregon.keizer
    AOI_NAME = "keizer"

    # The window RasterFlow searches for imagery. Keizer's only 30 cm NAIP is 2022, so
    # this window is 2022. Another area needs its own year: step 4 prints the years that work.
    START = datetime(2022, 1, 1)
    END = datetime(2023, 1, 1)

    # What to detect. A list requests several object types in one inference call.
    # "roofs", "roof", and "rooftops" are three different prompts and can return
    # different detections.
    TEXT_PROMPT = ["parking lots", "roads", "roofs", "solar panels"]

    # Required, and there is no safe default. At 0.3 primary structures detect reliably
    # and small accessory structures are missed.
    CONFIDENCE_THRESHOLD = 0.3

    # Start with a 30 m² roof floor, but retain small solar arrays for review.
    # Step 9 shows what each floor drops before you apply it.
    MIN_AREA_M2 = 30
    SOLAR_MIN_AREA_M2 = 0

    # Where results go. USER_S3_PATH is set for you in a Wherobots notebook.
    aoi_path = os.getenv("USER_S3_PATH") + f"{AOI_NAME}.parquet"
    ```

    <Tip>
      The [cost estimate](#what-an-inspection-run-costs) uses area, resolution, bands, and time periods, so these four prompts do not change that estimate. Keep `"roofs"` and `"solar panels"` for the inspection; `"parking lots"` and `"roads"` show what else a multi-prompt pass returns and can be removed.
    </Tip>
  </Step>

  <Step title="Point the run at your own area of interest" icon="location-dot">
    `wkls` resolves a city or county name to a boundary, so there is no boundary file to find. The recipe reads the area of interest from storage, so it is written out to your own S3 path first.

    The screenshots above are from Keizer, Oregon, a city of 18.8 km² inside the Marion County run. Starting there lets you compare your own output against the reference map.

    ```python theme={"system"}
    import matplotlib.pyplot as plt

    gdf = gpd.read_file(AOI.geojson())
    gdf.to_parquet(aoi_path)

    area_km2 = gdf.to_crs(gdf.estimate_utm_crs()).area.sum() / 1e6
    print(f"{AOI_NAME}: {area_km2:,.1f} km2")
    print(f"Boundary written to {aoi_path}")

    fig, ax = plt.subplots(figsize=(4, 4))
    for geom in gdf.geometry:
        # `.geoms` for a MultiPolygon AOI, the geometry itself for a Polygon
        for poly in getattr(geom, "geoms", [geom]):
            x, y = poly.exterior.xy
            ax.plot(x, y, color="black", linewidth=0.8)
    ax.set_aspect("equal")
    ax.set_title(f"{AOI_NAME}: {area_km2:,.1f} km2")
    plt.show()
    ```

    <Tip>
      For your own area, `wkls.us.oregon.cities()` and `wkls.us.oregon.counties()` list what is available at each level. A wrong name raises an error that suggests the closest matches. County accessors carry a `county` suffix, so Marion County is `wkls.us.oregon.marioncounty`.
    </Tip>

    **Other areas to try.** Each of these resolves through `wkls`. The two estimates below are for the recipe's mosaic and SAM3 inference only; the [separate preview tasks](#what-an-inspection-run-costs) add charges. The last column is the acquisition year that carries complete 30 cm NAIP coverage for that area, so set `START` and `END` to it.

    | Area of interest | Accessor | Area | Recipe mosaic + SAM3 inference (Spatial Units) | 30 cm NAIP year |
    | - | - | - | - | - |
    | Keizer, OR, the default here | `wkls.us.oregon.keizer` | 18.8 km² | 1.0 + 1.0 | 2022 |
    | Moore, OK, tornado and hail corridor | `wkls.us.oklahoma.moore` | 56.5 km² | 1.0 + 1.9 | 2023 |
    | Boulder, CO, hail and wildland-urban interface | `wkls.us.colorado.boulder` | 72.3 km² | 1.0 + 2.4 | 2023 |
    | Chandler, AZ, high rooftop solar uptake | `wkls.us.arizona.chandler` | 170.1 km² | 1.0 + 5.7 | 2023 |
    | Cape Coral, FL, hurricane wind and heavy solar | `wkls.us.florida.capecoral` | 306.6 km² | 1.4 + 10.2 | 2023 |
  </Step>

  <Step title="Confirm 30 cm NAIP coverage for your area" icon="magnifying-glass">
    NAIP collects state-level imagery at mixed resolutions, so SAM3's required 30 cm data may not exist for your area or timeframe. Start with everything NAIP has flown over your area, at any resolution. The scene index RasterFlow queries is a public object, read here straight from S3.

    ```python theme={"system"}
    # One row per NAIP scene, with its footprint, resolution (res), acquisition year and time
    naip_index = gpd.read_parquet(
        "s3://wherobots-examples/rasterflow/indexes/naip_index.parquet",
        columns=["geometry", "res", "year", "time"],
    )

    # Every scene over the AOI, compared in the index's CRS
    aoi_geom = gdf.geometry.to_crs(naip_index.crs).union_all()
    touching = naip_index.iloc[naip_index.sindex.query(aoi_geom, predicate="intersects")]

    # What has been flown here, and at what resolution. Only 0.3 is usable by SAM3.
    print("Scenes over this AOI, by acquisition year and resolution:")
    print(touching.groupby(["year", "res"]).size())
    ```

    For Keizer this returns three scenes in each of 2011, 2012 and 2014 at 1.0 m, three in 2020 at 0.6 m, and three in 2022 at 0.3 m. Only the 2022 scenes are usable, which is why the window in step 2 is 2022.

    Two details decide whether a year is usable:

    * **The area has to be fully contained, not merely overlapped.** A set of scenes covering most of your area still fails.
    * **The filter is on `time`, not `year`.** A flight on 2022-07-14 falls outside a window of 2023-01-01 to 2024-01-01, so an area flown in mid-2022 needs a 2022 window even though the calendar years sit next to each other.

    The cell below applies the same test the recipe applies, and raises before anything is billed.

    ```python theme={"system"}
    # The SAM3 recipes run on 30 cm NAIP
    MODEL_RES = 0.3

    # Scenes at the model's resolution touching the AOI
    nearby = touching.query(f"res == {MODEL_RES}")

    # Narrowed to the requested date range
    covering = nearby.query(f"time >= '{START:%Y-%m-%d}' and time <= '{END:%Y-%m-%d}'")

    # The recipe needs the AOI fully inside the matching scenes
    if not covering.geometry.union_all().contains(aoi_geom):
        # How much of the AOI is covered, measured in an equal-area CRS
        area_crs = gdf.estimate_utm_crs()
        aoi_area = gdf.geometry.to_crs(area_crs).union_all()
        tiles = covering.geometry.to_crs(area_crs).union_all()
        fraction = max(0.0, 1.0 - aoi_area.difference(tiles).area / aoi_area.area)

        # Years that would work, applying the same full-coverage test as the gate above
        available = sorted(
            year
            for year, scenes in nearby.groupby("year")
            if scenes.geometry.union_all().contains(aoi_geom)
        )
        raise ValueError(
            f"{MODEL_RES:g}m NAIP covers {fraction:.1%} of this AOI between "
            f"{START:%Y-%m-%d} and {END:%Y-%m-%d}; the recipe needs the AOI fully covered. "
            + (
                f"Years with complete {MODEL_RES:g}m coverage over this AOI: {available}. "
                "Set START and END to one of them."
                if available
                else f"No year has complete {MODEL_RES:g}m NAIP coverage over this AOI."
            )
        )

    print(f"NAIP coverage is available. Found {len(covering)} NAIP scenes at {MODEL_RES:g}m covering the AOI")
    ```

    The USDA's [NAIP coverage map](https://esri.maps.arcgis.com/apps/mapviewer/index.html?webmap=6cc0dcb225de4cb8aaa23c6a9cb59db8) shows the same picture geographically.

    <Warning>
      **Some areas have no 30 cm NAIP in any year.** Where a state was flown at 60 cm throughout, no date window will satisfy the recipe. California is the clearest case: roughly 100 of the \~68,000 NAIP scenes over the state are 30 cm, so most Californian areas of interest cannot run these recipes at all. [Bring your own rasters](/tutorials/example-notebooks/rasterflow-bring-your-own-rasters-naip) through a STAC catalog for those areas.
    </Warning>
  </Step>

  <Step title="Read how old the imagery is" icon="calendar-days">
    `covering` now holds the exact scenes the mosaic will be built from, and their `time` values are the flight dates. This is the age of the evidence behind every detection you are about to produce, so read it here, before the run.

    ```python theme={"system"}
    import pandas as pd

    # Flight dates of the scenes behind the mosaic
    flights = pd.to_datetime(covering["time"])
    if flights.dt.tz is not None:
        flights = flights.dt.tz_localize(None)

    newest, oldest = flights.max(), flights.min()
    today = pd.Timestamp.today().normalize()

    print(f"{len(covering)} scenes, flown {oldest.date()} to {newest.date()}")
    print(f"Newest imagery: {(today - newest).days / 365.25:.1f} years old")
    print(f"Oldest imagery: {(today - oldest).days / 365.25:.1f} years old")
    print()
    print("Scenes per flight date:")
    print(flights.dt.date.value_counts().sort_index())
    ```

    Keizer's three 2022 scenes were all flown on 2022-07-14. Keep that date: it is the vintage of your evidence, and the [scoring below](#augment-old-data-with-newer-available-datasets) needs it.

    <Warning>
      This is the only place the flight date is available. The `time` column on the detections is the mosaic's own time coordinate, not the date the imagery was flown, so it cannot be used for this. See [Data freshness](#data-freshness).
    </Warning>
  </Step>

  <Step title="Build a preview mosaic and look at it" icon="layer-group">
    The recipe in the next step builds its own mosaic internally and returns the detections. Building a separate preview mosaic here, over the same area and window, gives you imagery to inspect before inference. The recipe can adjust its mosaic grid for inference, so this preview is a coverage and visual check, not an exact copy of the pixels SAM3 will read. The preview mosaic and Build Multiscales are [separate billable tasks](#what-an-inspection-run-costs).

    ```python theme={"system"}
    from rasterflow_remote import RasterflowClient
    from rasterflow_remote.data_models import DatasetEnum, GeometryModelRecipes

    rf_client = RasterflowClient()
    ```

    [`build_mosaics()`](/reference/rasterflow/client#build_mosaics) stitches the 30 cm NAIP scenes verified above into one Zarr store. `resolution` is left at its default, the native resolution of the dataset: 30 cm for `NAIP_30CM`, which is what SAM3 needs.

    ```python theme={"system"}
    # Preview the same source imagery, AOI, date window, and mosaic CRS
    mosaic_index = rf_client.build_mosaics(
        datasets=[DatasetEnum.NAIP_30CM],
        aoi=aoi_path,
        start=START,
        end=END,
        target_crs=3857,
    )

    # The URI of the store for the first mosaic location, which is what an AOI of a single
    # geometry produces. `wkls` returns one geometry per city or county.
    mosaic_store = mosaic_index.first_row_mosaic
    if mosaic_store is None:
        raise RuntimeError(
            "build_mosaics returned no mosaic store. The index it wrote is "
            f"{mosaic_index.mosaic_index_uri}; check the run in Workload History."
        )

    print(f"Mosaic index: {mosaic_index.mosaic_index_uri}")
    print(f"Mosaic store: {mosaic_store}")
    ```

    [`build_zarr_multiscales()`](/develop/rasterflow/rasterflow-multiscales) reads that store and writes a second one carrying overview levels and per-band histograms. A mosaic is written at a single native resolution, so without overviews a map has to stream full-resolution pixels for every pan and zoom.

    ```python theme={"system"}
    # Reads the mosaic and writes a second store carrying overview levels and per-band
    # histograms. The source mosaic is read, never modified.
    optimized_store = rf_client.build_zarr_multiscales(source_store=mosaic_store)

    print(f"Source mosaic:   {mosaic_store}")
    print(f"Optimized store: {optimized_store.uri}")
    ```

    Look at the mosaic before the model run:

    * **Gaps.** A hole means this preview mosaic has no imagery there. Investigate it before inference; the recipe builds a separate mosaic and may have different edges.
    * **Seams.** A tone shift across a straight line is where two flight dates meet.
    * **The season.** NAIP is flown leaf-on, so canopy sitting over a roof here is canopy the model sees too.
    * **Coarse zoom levels.** Blank or washed out means nodata pixels are being averaged into the overviews. Rebuild with an explicit fill value, `rf_client.build_zarr_multiscales(source_store=mosaic_store, nodata=0)` for 8-bit NAIP.

    ```python theme={"system"}
    from wherobots_gl import Map

    # A dark basemap makes the mosaic's own edges visible. Over a satellite basemap a
    # gap in the mosaic still looks like imagery.
    Map(
        layers=[{"type": "zarr", "source": optimized_store.uri, "name": "NAIP preview mosaic"}],
        basemap="dark",
    )
    ```

    <Note>
      The widget loads the optimized preview store, not the source mosaic: the overview levels are what let it redraw as you zoom.
    </Note>
  </Step>

  <Step title="Run the inference recipe" icon="wand-magic-sparkles">
    One call ingests the imagery, builds the mosaic, and runs text-prompted geometry inference with SAM3. Every argument comes from the cell in step 2.

    ```python theme={"system"}
    model_output = rf_client.predict_mosaic_geometries_recipe(
        # Path to our AOI in GeoParquet or GeoJSON format
        aoi=aoi_path,

        # Date range for imagery to be used by the model (verified above)
        start=START,
        end=END,

        # CRS for the internal mosaic; SAM3 geometry output is EPSG:4326
        target_crs=3857,

        # The model recipe and text prompt for object detection
        model_recipe=GeometryModelRecipes.SAM3_TEXT_GEOMETRY,
        text_prompt=TEXT_PROMPT,
        confidence_threshold=CONFIDENCE_THRESHOLD,
    )

    # The URI is None when nothing cleared the confidence threshold
    if model_output.uri is None:
        raise RuntimeError(
            "No detections. Lower CONFIDENCE_THRESHOLD, or reword TEXT_PROMPT: "
            "'roofs', 'roof', and 'rooftops' are three different prompts."
        )

    detections_gdf = gpd.read_parquet(model_output.uri)
    print(f"{len(detections_gdf):,} detections in {detections_gdf.crs.name} (EPSG:{detections_gdf.crs.to_epsg()})")
    detections_gdf.head()
    ```

    <Warning>
      Budget roughly 22 minutes on a first run, most of it fixed setup rather than a function of your area. Nothing needs your attention while it runs, and [Workload History](https://cloud.wherobots.com/workloads) shows progress and the RasterFlow Spatial Units each task consumed. A rerun of an identical task within the 7-day cache period is not charged.
    </Warning>
  </Step>

  <Step title="Read the detections" icon="table">
    Each row is one detected object:

    * `geometry`: the georeferenced polygon, in EPSG:4326 lon/lat
    * `label`: the text prompt that matched, exactly as you wrote it
    * `bbox_score`: confidence score for the detection
    * `time`: the time coordinate of the mosaic the pixel came from, **not** the flight date. Read the vintage off the scene dates printed in step 5 instead.
    * `source_store`: the mosaic the detection came from
    * `local_x_offset`, `local_y_offset`, `global_x_offset`, `global_y_offset`: the patch the detection was found in

    ```python theme={"system"}
    print(f"Total detections: {len(detections_gdf)}")
    print(f"Columns: {list(detections_gdf.columns)}")
    print()
    print(detections_gdf.groupby("label").size())
    print()
    print("Confidence score stats:")
    print(detections_gdf.groupby("label")["bbox_score"].describe()[["count", "min", "50%", "max"]].round(3))
    print()
    print(f"`time` values on the detections: {sorted(pd.to_datetime(detections_gdf['time']).dt.date.unique())}")
    print("Flight dates from the scene index:", sorted(flights.dt.date.unique()))
    ```

    The last two lines print the two dates side by side, so the gap between the mosaic's time coordinate and the actual flight date is visible in your own run.

    <Note>
      The file also carries a `bbox` struct (`xmin`, `ymin`, `xmax`, `ymax`): the GeoParquet covering for each polygon. GeoPandas consumes it as spatial metadata, so it is absent from `detections_gdf.columns` while `sedona.read` exposes it as a column.
    </Note>
  </Step>

  <Step title="Set area floors and save to the catalog" icon="filter">
    Persist the detections to the catalog so the SQL below has something to join to.

    `ST_AreaSpheroid` measures on the WGS 84 spheroid and reads coordinates as lon/lat. The recipe returns EPSG:4326, so it can be applied directly. If you ever hand it projected geometries, every area comes back near zero and the filter silently empties your table, so the cell below derives the CRS from the output rather than assuming it.

    ```python theme={"system"}
    from sedona.spark import *

    config = SedonaContext.builder().getOrCreate()
    sedona = SedonaContext.create(config)

    (
        sedona.read.format("geoparquet")
        .load(model_output.uri)
        .createOrReplaceTempView("sam3_detections")
    )

    # ST_AreaSpheroid needs lon/lat. The recipe returns EPSG:4326, so this is a no-op,
    # and it keeps working if a run ever comes back projected.
    epsg = detections_gdf.crs.to_epsg() if detections_gdf.crs else 4326
    geom = "geometry" if epsg == 4326 else f"ST_Transform(geometry, 'EPSG:{epsg}', 'EPSG:4326')"
    floor_sql = f"CASE WHEN label = 'solar panels' THEN {SOLAR_MIN_AREA_M2} ELSE {MIN_AREA_M2} END"
    print(f"Detections are EPSG:{epsg}; measuring area on `{geom}`")
    ```

    SAM3 emits a long tail of slivers and partial detections. Look at what the prompt-specific floors would drop before applying them.

    ```python theme={"system"}
    # What the floors would drop, before applying them
    sedona.sql(f"""
        SELECT
          label,
          COUNT(*)                                                  AS detections,
          ROUND(PERCENTILE_APPROX(ST_AreaSpheroid({geom}), 0.5), 1) AS median_area_m2,
          ROUND(PERCENTILE_APPROX(ST_AreaSpheroid({geom}), 0.9), 1) AS p90_area_m2,
          SUM(CASE WHEN ST_AreaSpheroid({geom}) <= {floor_sql} THEN 1 ELSE 0 END)
                                                                    AS below_floor
        FROM sam3_detections
        GROUP BY label
        ORDER BY detections DESC
    """).show(truncate=False)
    ```

    The write keeps every prompt's detections in one table, with the matched prompt in a `layer` column, so a multi-prompt run stays queryable and mappable as one layer set.

    ```python theme={"system"}
    sedona.sql("CREATE DATABASE IF NOT EXISTS examples_temp.sam3_db")

    table_name = f"examples_temp.sam3_db.sam3_{AOI_NAME}"

    sedona.sql(f"""
        WITH measured AS (
          SELECT
            {geom}                    AS geometry,
            label                     AS layer,
            bbox_score,
            ST_AreaSpheroid({geom})   AS area_m2,
            {floor_sql}              AS min_area_m2
          FROM sam3_detections
        )
        SELECT geometry, layer, bbox_score, area_m2
        FROM measured WHERE area_m2 > min_area_m2
    """).writeTo(table_name).createOrReplace()

    df_filtered = sedona.table(table_name)
    kept, total = df_filtered.count(), sedona.table("sam3_detections").count()

    if kept == 0:
        raise RuntimeError(
            f"The per-prompt area floors removed all {total:,} detections. Check the area summary "
            "above: if every median is near zero, the geometries are not lon/lat and "
            "ST_AreaSpheroid cannot measure them."
        )

    print(f"{table_name}: kept {kept:,} of {total:,} detections after per-prompt area floors")
    df_filtered.groupBy("layer").count().orderBy("layer").show()
    ```

    <Note>
      Without an area filter the roof layer carries slivers and partial detections that inflate counts. The 30 m² starting floor applies to roofs and other prompts; solar panels use a 0 m² floor so small arrays remain available for review. Choose each floor from the summary above and inspect the retained polygons on the map. A roof-sized floor will also discard many small `"roads"` segments.
    </Note>
  </Step>

  <Step title="Map your own detections" icon="map">
    The row count is not the check. Put the polygons over the imagery for your own area and zoom in.

    ```python theme={"system"}
    from wherobots_gl import Map

    # The widget loads layers by URL, so write the filtered detections out before mapping them
    filtered_path = os.getenv("USER_S3_PATH") + f"sam3_{AOI_NAME}_filtered.parquet"
    df_filtered.write.format("geoparquet").mode("overwrite").save(filtered_path)

    Map(
        layers=[{"type": "geoparquet", "source": filtered_path, "name": f"SAM3 detections: {AOI_NAME}"}],
        basemap="satellite",
    )
    ```

    For a large detection set, generate PMTiles instead. The `layer` column becomes one sublayer per prompt, so a multi-prompt run stays togglable on the map, the way [the Marion County run](#several-prompts-in-one-run) is.

    ```python theme={"system"}
    from wherobots import vtiles

    full_tiles_path = os.getenv("USER_S3_PATH") + f"sam3_{AOI_NAME}_tiles.pmtiles"
    vtiles.generate_pmtiles(df_filtered, full_tiles_path)
    print(full_tiles_path)
    ```

    ```python theme={"system"}
    vtiles.show_pmtiles(full_tiles_path)
    ```

    Two stores go on the map together: the preview mosaic from step 6, and the PMTiles of the filtered detections. In [Map](https://cloud.wherobots.com/map), paste a URI into **Layer URL**, select **Add Layer**, and repeat for the second one.

    ```python theme={"system"}
    # Paste each URI into Layer URL at https://cloud.wherobots.com/map, then Add Layer
    print("Layers to add")
    print(f"  NAIP preview mosaic (zarr): {optimized_store.uri}")
    print(f"  SAM3 PM Tiles (pmtiles):  {full_tiles_path}")

    # map_url opens the mosaic on its own; it is None when no viewer URL is available
    if optimized_store.map_url:
        print(f"\nMosaic in the map viewer: {optimized_store.map_url}")
    ```
  </Step>
</Steps>

## How to approach output triage

The map from the last step is where triage happens. The rest of this section is what to expect when you zoom in, not more to run.

<Frame caption="Roof detections at full zoom in Keizer, Oregon. An attached garage comes through inside the house polygon. At a 0.3 confidence threshold, detached garages and sheds get no polygon at all.">
  <img src="https://mintcdn.com/wherobots/9BygqgN_IJaBLFn2/images/develop/rasterflow/sam3-roof-polygons-detail.jpg?fit=max&auto=format&n=9BygqgN_IJaBLFn2&q=85&s=bfa9c0b3a664de330d1bb6808fff0dcf" alt="Close-up of 30 cm aerial imagery over a residential street, with red polygons outlining each house roof. The polygons follow complex L-shaped and T-shaped rooflines closely. Several small outbuildings and a detached garage carry no polygon." width="1600" height="900" data-path="images/develop/rasterflow/sam3-roof-polygons-detail.jpg" />
</Frame>

* **Primary structures.** SAM3 traces the roofline closely, L-shaped and T-shaped plans included. An attached garage lands inside the same polygon as the house.
* **Detached garages, sheds, and small outbuildings.** Most get no polygon at `confidence_threshold=0.3`. To pick them up, lower the threshold and take on the extra false positives, or prompt for them in a separate pass.
* **Tree canopy.** NAIP is flown during the agricultural growing season, so every scene is leaf-on. A roof under mature canopy comes back partial or missing, and the built-in datasets carry no leaf-off alternative.
* **A detection is not a building record.** RasterFlow does not deduplicate these polygons against a parcel or a policy. One structure can produce two polygons, and the model can return a row of attached houses as one. Match to a footprint layer, then review unmatched and multi-footprint detections before counting buildings: see the [Advanced Roof Inspection Guide](/develop/rasterflow/rasterflow-roof-inspection-advanced).

## Data freshness

Age drives an important component of a roof's risk profile. As such, the age of the imagery matters considerably.

| The imagery behind a SAM3 run | |
| - | - |
| **Program** | [National Agriculture Imagery Program (NAIP)](https://www.usgs.gov/centers/eros/science/usgs-eros-archive-aerial-photography-national-agriculture-imagery-program-naip), USDA |
| **Coverage** | Continental United States only |
| **Revisit** | No more than a 3-year cycle, generally every other year for most states |
| **Most recent acquisition year** | 2025, with roughly half the states delivered at 60 cm and half at 30 cm |
| **Season** | Agricultural growing season, so leaf-on |
| **Resolution SAM3 requires** | 30 cm. Both `SAM3_TEXT_BBOX` and `SAM3_TEXT_GEOMETRY` are fixed to it |

<Warning>
  **The most recent year for your state may not be 30 cm.** Because 2025 delivered only about half the states at 30 cm, the newest imagery over your area of interest may be 60 cm, which SAM3's recipes cannot use. Satisfying the 30 cm requirement can mean going back to an older acquisition year. Check the [NAIP coverage map](https://esri.maps.arcgis.com/apps/mapviewer/index.html?webmap=6cc0dcb225de4cb8aaa23c6a9cb59db8) for both resolution and year before you pick a date window.
</Warning>

<Warning>
  **Read the vintage off the scene index, not off the detections.** The `start` and `end` arguments are a window RasterFlow searches, not the date it found, and the `time` column on the detections is the mosaic's own time coordinate rather than the date the imagery was flown. Over Keizer that column reads 2022-01-01 while the three scenes behind the mosaic were flown on 2022-07-14. The flight dates in [step 5](#inspect-your-own-area-of-interest) are the age of your evidence, and the input to the scoring below.
</Warning>

### Caveats and limitations

<AccordionGroup>
  <Accordion title="United States only, at 30 cm" icon="flag-usa">
    Both SAM3 recipes are configured for 30 cm NAIP, which covers the continental United States. For areas outside that footprint, or for imagery newer than the last NAIP flight, [bring your own rasters](/tutorials/example-notebooks/rasterflow-bring-your-own-rasters-naip) through a STAC catalog and a GDAL Raster Tile Index.
  </Accordion>

  <Accordion title="No condition or material classification" icon="clipboard-question">
    The output is geometry plus a confidence score. There is no condition grade, material class, or damage flag. You can prompt for visually distinct roof types such as `"metal roofs"` or `"tarped roofs"`, but SAM3's accuracy on those categories is not benchmarked. Validate any such prompt against a sample you have ground truth for before it feeds a pricing decision.
  </Accordion>

  <Accordion title="Confidence threshold is a recall and precision trade" icon="sliders">
    `confidence_threshold` is required and has no safe default. At 0.3, primary structures detect reliably and small accessory structures are missed. Lower it to catch more and expect more false positives: driveway aprons, patios, and pale ground surfaces read as roofs. Tune it on a small area first, and check the result on a map instead of a row count.
  </Accordion>

  <Accordion title="Prompt wording changes the output" icon="quote-left">
    `"roofs"`, `"roof"`, and `"rooftops"` are three different prompts and can return different detections. Fix the wording once you have validated it, and record it alongside the results.
  </Accordion>

  <Accordion title="Patch size is resized to 1008x1008" icon="crop-simple">
    The SAM3 recipes always resize the configured `patch_size` to 1008x1008. You can use it to control how much spatial context the model sees per pass, but a patch larger than 1008x1008 upsamples the imagery instead of adding detail.
  </Accordion>

  <Accordion title="Public Preview" icon="flask">
    RasterFlow is in Public Preview. Interfaces and recipe behavior can change. Keep a copy of any result you make decisions from; a rerun may not reproduce it exactly.
  </Accordion>
</AccordionGroup>

### Augment old data with newer available datasets

If your imagery is from 2023 and you are underwriting in 2026, the roof in the picture is three years older than it looks. Add those years back before you score it.

Divide the roof's age by the expected service life of the covering. That service life depends on the material, roughly 20 years for three-tab asphalt shingle and 25 to 30 for architectural laminate. The number in the query below is a stand-in for whatever your own prediction curve says.

<Steps>
  <Step title="Register your own policy records" icon="table">
    The scoring query joins the detections to your own policy records, so register those as `my_portfolio` first. It needs one row per insured property, with `policy_id`, a `geometry` to match against the detections, and `roof_installed_year`:

    ```python theme={"system"}
    (
        sedona.read.format("geoparquet")
        .load("s3://your-bucket/portfolio/policies.parquet")  # your own policy records
        .createOrReplaceTempView("my_portfolio")
    )
    ```
  </Step>

  <Step title="Score the roofs against imagery age" icon="calculator">
    The imagery date is bound in from [step 5](#inspect-your-own-area-of-interest) rather than read off a column, because the detections do not carry the flight date. The table holds every prompt from the run, so the join filters to the `roofs` layer.

    ```python theme={"system"}
    # The flight date from step 5. The oldest scene is the conservative choice when a
    # mosaic spans more than one flight.
    imagery_date = oldest.date()

    sedona.sql(f"""
        CREATE OR REPLACE TEMP VIEW scored_roofs AS
        SELECT
          p.policy_id,
          r.geometry,
          r.area_m2                                          AS roof_area_m2,
          DATE '{imagery_date}'                              AS imagery_date,
          -- How stale is the evidence?
          months_between(current_date(), DATE '{imagery_date}') / 12
                                                             AS imagery_age_years,
          -- Straight-line consumed life as of today, not as of the flyover.
          -- Replace 25.0 with the expected service life of the covering on that risk.
          greatest(0.0, least(1.0,
            (year(current_date()) - p.roof_installed_year) / 25.0
          ))                                                 AS consumed_life_fraction
        FROM examples_temp.sam3_db.sam3_{AOI_NAME} r
        JOIN my_portfolio p
          ON ST_Intersects(r.geometry, p.geometry)
        WHERE r.layer = 'roofs'
    """)

    sedona.sql("""
        SELECT * FROM scored_roofs
        ORDER BY consumed_life_fraction DESC, imagery_age_years DESC
    """).show()
    ```
  </Step>
</Steps>

<Note>
  `roof_installed_year` has to come from your own records: policy data, permit data, or a prior inspection. No open dataset carries it, and RasterFlow does not infer it. Without it, `imagery_age_years` on its own still works as a staleness flag, telling you which parts of your inspection rest on the oldest evidence.
</Note>

## Next steps

<CardGroup cols={2}>
  <Card title="Advanced Roof Inspection Guide" icon="layer-group" href="/develop/rasterflow/rasterflow-roof-inspection-advanced">
    Match detections to building footprints and measure nearby canopy height.
  </Card>

  <Card title="SAM3 notebook" icon="wand-magic-sparkles" href="/tutorials/example-notebooks/rasterflow-sam3">
    The full walkthrough, including PMTiles generation for large detection sets.
  </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="Visualize RasterFlow outputs" icon="map" href="/develop/rasterflow/rasterflow-visualization">
    Check a mosaic before inference and detections after, on the same map.
  </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="Bring your own rasters" icon="cloud-arrow-up" href="/tutorials/example-notebooks/rasterflow-bring-your-own-rasters-naip">
    Use newer or non-US imagery through a STAC catalog when NAIP is too old.
  </Card>

  <Card title="RasterFlow Billing" icon="credit-card" href="/get-started/organization-management/rasterflow-billing">
    The full RasterFlow Spatial Unit calculation and the cost controls that matter.
  </Card>
</CardGroup>


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