Counting trees from the air, and proving the coordinates are right
Tun KelteschPublished
tkmedia-gis-tree-detection is a small public repo that detects tree crowns on a georeferenced aerial tile and writes each detection out as a real-world coordinate: 55 crowns on one NEON test tile, exported as a GeoJSON point layer, MIT licensed. The detection step is standard. The step that decides whether the output is usable is the coordinate handling, because a mistake there produces a file that still opens and draws in QGIS, only in the wrong place.
What the pipeline does
Reads a georeferenced GeoTIFF, detects crowns, converts each box centre to a real-world coordinate, writes a GIS-ready point layer.
The steps, in order:
rasterio.open()on the input tile, pullingcrs,transform, and pixel size off the dataset. Two asserts guard the only two ways this can go wrong quietly: a missing CRS, and an identity transform. A GeoTIFF with an identity transform is a plain image wearing a GeoTIFF extension, and every coordinate you derive from it will be wrong in a way that looks fine.- DeepForest with the pretrained
weecology/deepforest-treeweights, pinned to CPU. predict_image()when the raster fits one patch,predict_tile()when it does not. The sample tile is 400×400 px, so it takes the first path.- Filter boxes below the score threshold, default 0.3.
- Box centre to coordinate, then reproject.
- Write GeoJSON in EPSG:4326, GeoPackage in the native CRS, an annotated PNG, and a
summary.json.
The sample tile is NEON airborne imagery over the Ordway-Swisher Biological Station in Florida, at 0.1 m ground sample distance, native CRS EPSG:32617. Detection takes a few seconds on a laptop CPU.
The transform, and the offset argument inside it
Pixel centre goes through the raster's own affine transform. Three lines:
col_c = (xmin + xmax) / 2.0
row_c = (ymin + ymax) / 2.0
easting, northing = transform_xy(transform, rows=row_c, cols=col_c, offset="ul")
transform_xy is rasterio.transform.xy. The affine comes from the file, not from anything I supplied: the georeferencing already lives in the GeoTIFF, and the job is to carry it through unchanged.
offset="ul" tells rasterio to treat the row/col you hand it as measured from the upper-left corner of the pixel grid rather than from pixel centres. Pass offset="center" instead and every point shifts by half a pixel on both axes. At 0.1 m GSD that is 5 cm, which will never look wrong on a map and will quietly disagree with any other layer you overlay.
Then to_crs(epsg=4326) for the GeoJSON, because GeoJSON is defined in WGS 84; UTM values written into a .geojson produce points thousands of kilometres from the site.
How the coordinates were verified
By hand, against the affine coefficients read from the file.
results/VERIFICATION.md recomputes one detection:
easting = c + a * col + b * row = 404211.9 + 0.1 * 351.93216 = 404247.0932
northing = f + d * col + e * row = 3285142.9 + (-0.1) * 367.17075 = 3285106.1829
The value the script wrote for that tree is 404247.093, 3285106.183. Agreement to three decimal places, which is where the rounding stops. The document also records the offset="center" alternative and the 0.05 m per-axis shift it produces, so the choice is written down rather than assumed, and a tile-centre sanity check that lands at −81.989891, 29.692504, in Florida, where OSBS is.
There is no automated test. No tests/, no CI, no assertion in the code that the transform is right. The verification is a markdown file a human wrote once. The arithmetic is reproducible, but nothing will notice the day someone changes offset and reruns. It is the first thing I would fix before anyone depended on this pipeline.
What comes out
55 points, each carrying score, easting, northing, native_crs, and a grid_zone of NW, NE, SW or SE. Per-zone counts are 10, 15, 14 and 16.
Counting by quadrant is there because "how many trees" is almost never the real question. "How many in this parcel" is. Zones are a crude stand-in for a real parcel boundary, but they make the output shape right: a count you can group by an area rather than a single number you have to trust.
The highest score in the set is 0.7987.
Where it breaks
The 55 is a detection count at threshold 0.3. It is not ground truth. Nobody walked that plot and counted trees, so the correct reading is "the model found 55 crowns it was at least 30% confident about", not "there are 55 trees".
Beyond that:
- The weights are a generalist pretrained detector with no fine-tuning on this site. Move to a different canopy type, a different season, or a different sensor and the score distribution moves with it.
- Positional accuracy is the crown-box centroid pushed through the raster's georeferencing. It inherits whatever error the orthophoto already has.
- It reports the crown centre, not the trunk. A leaning tree on a slope puts the trunk somewhere the box centre never sees. For forestry inventory that gap matters and this pipeline does not model it.
- Single tile, CPU, one patch. The tiled path exists but the sample never exercises it.
Lowering the threshold raises the count; the change measures the cutoff, not the trees.
The silent failure modes
Detection quality gets benchmark attention. Coordinate handling mostly does not, and it carries four failure modes that raise no error: a missing CRS, an identity transform, a half-pixel offset, and UTM values in a WGS 84 container. Each produces a file that opens and draws normally with every point in the wrong place. The two asserts, the offset choice, the hand check and the reprojection step in this repo each exist to close one of them.
The repo, including the verification document, is at github.com/SM1LE21/tkmedia-gis-tree-detection.
Imagery: NEON Airborne Observation Platform, Ordway-Swisher Biological Station, distributed with the open-source DeepForest sample data.