Filter and refine, and the fill ratio
Measure your machineNeeds Python with shapely, pyproj
Problem
Spatial indexes work on bounding boxes: they find every shape whose box intersects the query's box (the filter), and an exact geometry test then removes the ones that do not really intersect (the refine). How much the refine step throws away depends on how well a box fits its shape: a circle fills about 78% of its box, a long diagonal line about 1%, so most of a line's box matches are false. Separately, distances computed on raw latitude and longitude are wrong by a factor that changes with latitude.
Create 100,000 shapes: compact circles and long diagonal lines buffered by 20 m. For 1,000 random query rectangles, count matches by bounding box only and after exact intersects, by shape kind and fill ratio. Use an STRtree and compare time for filter-only, filter-and-refine, and brute-force exact tests. Then compute the distance between two points 1 km apart at latitude 0 and at latitude 60 from raw degrees, and compare with the geodesic distance from pyproj.
The question. What share of bounding-box matches are false for each shape, how much does the index save, and how wrong is a raw-degree distance?
Predict
Compact shapes have a fill ratio near 0.78 and few false positives; diagonal lines have ratios near 0.01 and most box hits are rejected by the refine step. Filter-and-refine is much faster than brute force. A degree of longitude is about 111 km at the equator and about 55 km at latitude 60, so the raw lat/long distance is wrong by a factor that changes with latitude.
Run it
1Install uv once. It fetches Python and the lab's packages for you.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide the code
filter-and-refine-and-the.py # /// script # requires-python = ">=3.10" # dependencies = ["shapely>=2", "pyproj", "numpy"] # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Filter and refine, and the bounding-box false positive. 100,000 shapes in a projected plane (metres): 50,000 compact circles and 50,000 long diagonal lines buffered by 20 m. For 1,000 random query rectangles: matches by bounding box alone and after the exact intersects test, by shape kind and fill ratio (area / bounding-box area). Time with an STRtree (filter only, filter and refine) against brute-force exact tests. Then the distance trap: two points 1 km apart measured in raw degrees at latitude 0 and 60, against the geodesic distance. """ import argparse, json, time import numpy as np, shapely from shapely import STRtree, box from pyproj import Geod p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--shapes", type=int, default=100_000, help="Shapes (half circles, half lines).") p.add_argument("--queries", type=int, default=1000, help="Query rectangles.") p.add_argument("--seed", type=int, default=0, help="Random seed.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() rng = np.random.default_rng(args.seed); half = args.shapes // 2; W = 100_000 cx, cy = rng.uniform(0, W, half), rng.uniform(0, W, half) circles = shapely.buffer(shapely.points(cx, cy), rng.uniform(50, 300, half)) x0, y0, L = rng.uniform(0, W, half), rng.uniform(0, W, half), rng.uniform(2000, 8000, half) lines = shapely.buffer(shapely.linestrings(np.stack([np.stack([x0, y0], 1), np.stack([x0 + L / 1.414, y0 + L / 1.414], 1)], 1)), 20) geoms = np.concatenate([circles, lines]); kind = np.array(["circle"] * half + ["line"] * half) fill = shapely.area(geoms) / shapely.area(shapely.envelope(geoms)) qx, qy = rng.uniform(0, W, args.queries), rng.uniform(0, W, args.queries); qs = [box(a, b, a + 1000, b + 1000) for a, b in zip(qx, qy)] tree = STRtree(geoms) t = time.perf_counter(); cand = [tree.query(q) for q in qs]; t_filter = time.perf_counter() - t t = time.perf_counter(); exact = [c[shapely.intersects(geoms[c], q)] for c, q in zip(cand, qs)]; t_refine = time.perf_counter() - t + t_filter t = time.perf_counter(); brute = [np.flatnonzero(shapely.intersects(geoms, q)) for q in qs[:50]]; t_brute = (time.perf_counter() - t) * args.queries / 50 assert all(set(a) == set(b) for a, b in zip(exact[:50], brute)) rows = [] for k in ("circle", "line"): bb = sum(int((kind[c] == k).sum()) for c in cand); ex = sum(int((kind[e] == k).sum()) for e in exact) rows.append({"shapes": k, "fill_ratio": float(fill[kind == k].mean()), "bbox_matches": bb, "exact_matches": ex, "false_positive_pct": (1 - ex / bb) * 100 if bb else None}) timing = [{"method": "STRtree, bounding boxes only", "seconds": t_filter}, {"method": "STRtree, filter and refine", "seconds": t_refine}, {"method": "brute-force exact test (extrapolated from 50 queries)", "seconds": t_brute}] g = Geod(ellps="WGS84"); trap = [] for lat in (0.0, 60.0): lon2, lat2, _ = g.fwd(0.0, lat, 90, 1000) # a point 1 km due east raw_deg = float(np.hypot(lon2 - 0.0, lat2 - lat)); naive_km = raw_deg * 111.32 _, _, true_m = g.inv(0.0, lat, lon2, lat2) trap.append({"latitude": lat, "raw_degrees": raw_deg, "naive_km": naive_km, "geodesic_km": true_m / 1000}) for r in rows: print(f"{r['shapes']:<7} fill ratio {r['fill_ratio']:.3f}: bounding-box matches {r['bbox_matches']:>7,}, exact {r['exact_matches']:>7,}, false positives {r['false_positive_pct']:5.1f}%") for r in timing: print(f"{r['method']:<54} {r['seconds']:7.2f} s for {args.queries:,} queries") for r in trap: print(f"latitude {r['latitude']:>4.0f}: 1 km east is {r['raw_degrees']:.5f} raw degrees; × 111.32 km = {r['naive_km']:.3f} km; geodesic {r['geodesic_km']:.3f} km") if args.json: with open(args.json, "w") as f: json.dump({"tables": {"shapes": rows, "timing": timing, "distance": trap}}, f)3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.
Terminal uv run filter-and-refine-and-the.py uv run filter-and-refine-and-the.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --shapes100,000 | Shapes (half circles, half lines). |
| --queries1000 | Query rectangles. |
| --seed0 | Random seed. |
Our run
Bounding box against exact test
| Shapes | Fill ratio | Box matches | Exact | False positives |
|---|---|---|---|---|
| circle | 0.78 | 9.17K | 9.01K | 1.76% |
| line | 0.018 | 107K | 40.1K | 62.6% |
1,000 queries
| Method | Time |
|---|---|
| STRtree, bounding boxes only | 6.16 ms |
| STRtree, filter and refine | 136 ms |
| brute-force exact test (extrapolated from 50 queries) | 1.26 s |
Two points 1 km apart
| Latitude | Raw degrees | Degrees × 111.32 km | Geodesic (km) |
|---|---|---|---|
| 0 | 0.009 | 1 | 1 |
| 60 | 0.018 | 1.99 | 1 |
circle fill ratio 0.780: bounding-box matches 9,169, exact 9,008, false positives 1.8%
line fill ratio 0.018: bounding-box matches 107,321, exact 40,112, false positives 62.6%
STRtree, bounding boxes only 0.01 s for 1,000 queries
STRtree, filter and refine 0.14 s for 1,000 queries
brute-force exact test (extrapolated from 50 queries) 1.26 s for 1,000 queries
latitude 0: 1 km east is 0.00898 raw degrees; × 111.32 km = 1.000 km; geodesic 1.000 km
latitude 60: 1 km east is 0.01792 raw degrees; × 111.32 km = 1.995 km; geodesic 1.000 kmWhat we found
Circles filled 0.78 of their boxes and 1.76% of their box matches were false; diagonal lines filled 0.018 and 62.6% were false. With the STRtree, 1,000 queries took 136 ms including the exact refine, against 1.26 s for brute force. A point 1 km east measured 1 km from raw degrees at the equator and 1.99 km at latitude 60, where the geodesic distance was 1 km.
Read it
Filter-and-refine is only as good as the filter's fit: thin diagonal shapes make the cheap step useless and push all the work into exact geometry. Split long shapes into segments with tight boxes, and compute distances on a projection or a geodesic, never on raw degrees.