π°οΈ nazarGeo: A Real-Time Multi-Modal System for High-Precision Building Geo-Localization Using LiDAR, Vision, and Sensor Fusion
nazarGeo is a real-time building intelligence pipeline that combines camera perception, LiDAR depth extraction, and ego-pose estimation to compute accurate geo-coordinates of detected structures. The system matches these projections against a geo-spatial building database using a multi-factor scoring mechanism and stabilizes results across frames using tracking and calibration.
- Project Overview
- System Architecture
- Hardware & Sensor Setup
- Repository Structure
- Pipeline Stages
- Configuration Reference
- Installation & Dependencies
- Running the Pipeline
- Calibration Workflow
- Output Schema
- Dashboard β Viewer
- Results & Accuracy
- Tuning Guide
- Known Limitations & Future Work
nazarGeo is a full-stack autonomous building geo-localization pipeline that fuses:
- Camera frames (4K, 30 fps) from a vehicle-mounted lens
- LiDAR point clouds from dual Velodyne VLP-16 lidars (PCAP streams)
- IMU/GNSS telemetry (interpolated at frame rate)
- GOB (Ground-truth Object footprint Base) β a pre-surveyed CSV of building centroids and polygon footprints
The system processes each video frame to detect a building, estimate its GPS coordinates using LiDAR depth + ego-pose projection, then match that estimate against the GOB database to produce a precise, calibrated GPS tag for the building facade.
β¦ SUB-3M TARGET ACHIEVED
Inlier mean error : 2.62 m
Inlier median error: 2.31 m
Calibrated avg : 3.8 m (β 77% from pre-calibration 16.2 m)
Polygon hits (0 m) : 2 / 8 frames
Inliers (β€ 8.77 m) : 6 / 8 frames
The polygon-edge error metric measures the distance from the estimated GPS point to the nearest edge of the building's WKT footprint polygon β or zero if the point falls inside. A sub-3m inlier mean means the system is, on average, placing the estimated GPS within the building footprint or within one typical room-width of its facade.
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β INPUT SENSORS β
β Camera (LENS1, 3840Γ2160 @ 30fps) β LiDAR L1 + L2 (PCAP) β
β IMU/GNSS CSV (lat, lon, yaw @ ~10Hz) β
βββββββββββββββββββββ¬βββββββββββββββββββββββββββββ¬βββββββββββββββββββββ
β β
ββββββββΌβββββββ ββββββββββΌβββββββββ
β sync.py β β lidar_pcap.py β
β Manifest β β Point Cloud β
β Generation β β Fusion (L1+L2) β
ββββββββ¬βββββββ ββββββββββ¬βββββββββ
β β
ββββββββΌβββββββββββββββββββββββββββββββββββββββ
β perceive.py β
β YOLO-World (building detection, 4 passes) β
β SAM (building segmentation mask) β
β LiDAR projection β depth from mask β
ββββββββββββββββββββββββ¬βββββββββββββββββββββββ
β
ββββββββΌβββββββ
β project.py β
β ego GPS + β
β depth β β
β target GPS β
ββββββββ¬βββββββ
β
ββββββββββββββββββββββββΌβββββββββββββββββββββββ
β match.py β
β GOB radius query (150 m) β
β Angle cone filter (35Β°) β
β Scoring: angle + proximity + width + depth β
ββββββββββββββββββββββββ¬βββββββββββββββββββββββ
β
βββββββββββββββββΌββββββββββββββββ
β β β
ββββββββΌβββββββ ββββββββΌβββββββ ββββββββΌβββββββ
β tracker.py β β select_ β β tim.py / β
β Kalman GPS β β buildings β β tom.py β
β Stabilizer β β .py β β Calibrationβ
ββββββββ¬βββββββ ββββββββ¬βββββββ ββββββββ¬βββββββ
β β β
βββββββββββββββββΌββββββββββββββββ
β
ββββββββΌβββββββ
β viewer.py β
β Streamlit β
β Dashboard β
βββββββββββββββ
| Component | Specification |
|---|---|
| Camera | 1Γ lens, 3840Γ2160 (4K UHD), 30 fps |
| LiDAR L1 | Velodyne VLP-16 ("PUCK"), 16-channel |
| LiDAR L2 | Velodyne VLP-16, rigidly offset from L1 |
| IMU/GNSS | MEMS IMU + GNSS, ~10 Hz, fused Kalman output |
| Platform | Vehicle (road survey, Chennai area) |
LiDAR β Camera transform (T_LIDAR_TO_CAM) was computed with FAST-Calib (target-based, RMSE = 0.0068 px):
0.20927 -0.977663 -0.0195126 2.05416
0.0386136 0.0282009 -0.998856 0.401883
0.977095 0.208277 0.0436527 -0.035744
0.0 0.0 0.0 1.0
L2 β L1 transform (T_L2_TO_L1) aligns the second lidar into the L1 frame before point cloud fusion:
0.99976 0.02097 -0.00644 -0.11370
-0.02103 0.99974 -0.00915 0.91939
0.00624 0.00929 0.99994 -0.00164
0.0 0.0 0.0 1.0
| Parameter | Value |
|---|---|
| Resolution | 3840 Γ 2160 |
| Fx | 1106.46 |
| Fy | 1077.77 |
| Cx | 1920.0 |
| Cy | 1080.0 |
| HFOV | ~118.2Β° |
| Distortion | Pinhole (k1=k2=p1=p2=0) |
nazarGeo/
βββ cfg.py # All configuration constants
βββ sync.py # Phase 0: IMU/GNSS interpolation β manifest
βββ lidar_pcap.py # LiDAR PCAP parser + dual-lidar fusion
βββ perceive.py # Phase 1: YOLO detection + SAM mask + LiDAR depth
βββ project.py # Phase 2: GPS projection (ego + depth + heading)
βββ match.py # Phase 3: GOB database query + scoring
βββ gob.py # GOB CSV loader + WKT polygon utilities
βββ tracker.py # Kalman filter GPS stabilizer (SORT)
β
βββ run.py # Single-frame pipeline runner
βββ run_batch.py # Batch runner with GPS stabilization
βββ presniff.py # Full batch: syncβperceiveβprojectβmatch
βββ postsniff.py # Aggregate match JSONs β unique building clusters
βββ sniff.py # Lightweight unique-building aggregator
βββ select_buildings.py # Quality scoring + cluster deduplication
β
βββ tim.py # Offset calibration (lat/lon delta from GOB)
βββ tom.py # Aggressive sub-3m search (along/cross ray offset)
βββ verify.py # Step-by-step diagnostic tool
βββ viewer.py # Streamlit dashboard
β
βββ data/
β βββ LENS1_fixed/ # Extracted camera frames (frame_000001.jpg β¦)
β βββ *.pcap # LiDAR captures (L1 and L2)
β βββ *_imu_gnss_*.csv # IMU/GNSS telemetry
β βββ gob_india.csv # GOB building database
β
βββ outi/ # All outputs (auto-created)
βββ manifest.json
βββ raw/
βββ perceive_json/
βββ perceive_vis/
βββ project_json/
βββ match_json/
βββ stable_json/
βββ final/
βββ summary/
βββ selected_buildings.json
βββ selection_report.txt
βββ gps_comparison.json
βββ calibrated_results.json
βββ calibration_results.json
Reads the IMU/GNSS CSV and builds manifest.json β a per-frame lookup table mapping each camera frame ID to an interpolated ego-pose (lat, lon, heading).
- IMU/GNSS is sampled at ~10 Hz; camera runs at 30 fps, so cubic interpolation (
scipy.interp1d) is used to fill in between GPS fixes. - Yaw is unwrapped before interpolation to prevent 0Β°/360Β° discontinuities.
- Output:
outi/manifest.jsonwith one entry per frame containingego_pose.latitude,ego_pose.longitude,ego_pose.heading_deg.
Detects the primary building in each frame and estimates its LiDAR depth.
Detection (4-pass strategy):
| Pass | Region | Model Classes | Conf Threshold |
|---|---|---|---|
| 1 | Full image | Primary (building, facadeβ¦) | 0.25 |
| 2 | Full image | Fallback (house, wall, structureβ¦) | 0.10 |
| 3 | Right 60% crop | Fallback | 0.07 |
| 4 | Centre 50% strip | Fallback | 0.06 |
Best bounding box is selected by a scoring heuristic that rewards area, confidence, and right-side position (buildings typically dominate the right half when driving).
Segmentation: SAM (MobileSAM) generates a pixel-accurate building mask from the bounding box. The mask is morphologically cleaned and the sky band is suppressed.
Depth estimation:
- Dual-lidar point cloud is fused and projected onto the image plane using the calibrated
T_LIDAR_TO_CAMtransform. - LiDAR points falling inside the SAM mask are extracted; their depths are trimmed (5thβ95th percentile) and the 35th percentile is taken as the facade depth.
- If the mask has fewer than 5 LiDAR hits, fallback uses a centre-strip or full-bbox approach; if still sparse, defaults to 25 m.
Output: perceive_json/frame_XXXXXX_perceive.json + annotated overlay image.
Projects the detected building's GPS coordinates from the ego-vehicle pose.
target_GPS = geodesic(depth_m).destination(ego_GPS, heading_deg)
+ PROJECTION_LAT_OFFSET
+ PROJECTION_LON_OFFSET
The PROJECTION_LAT/LON_OFFSET is a learned correction (calibrated by tim.py) that accounts for systematic bias in heading, camera-to-GNSS lever arm, and LiDAR depth overshoot.
Output: project_json/frame_XXXXXX_project.json.
Queries the GOB database and scores each candidate building.
Query: All GOB entries within GOB_RADIUS_M = 150 m of the ego-vehicle position.
Cone filter: Only buildings within ANGLE_CONE_DEG = 35Β° of the vehicle heading are considered.
Scoring formula:
total = angle_score + prox_score + width_score + depth_bonus
angle_score = SCORE_ANGLE_MAX Γ max(0, 1 β (angle_off / cone_deg)^1.6)
prox_score = SCORE_PROX_MAX Γ exp(βdist_m / PROX_DECAY_M)
width_score = SCORE_WIDTH_MAX Γ (min(det_frac, exp_frac) / max(det_frac, exp_frac))
depth_bonus = 10.0 Γ exp(β|dist_m β depth_m| / 15.0)
The best-scoring candidate is saved to match_json/frame_XXXXXX_match.json.
A Kalman-filter-based SORT tracker (BuildingSORT) smooths noisy per-frame GPS estimates across multiple frames of the same building.
- Each track maintains a 5-state Kalman filter:
[lat, lon, depth, dlat, dlon]. - Measurement noise is scaled inversely with the GOB match score (high confidence β trust more).
- Tracks that go unmatched for more than
max_misses = 3frames are pruned. - The
GPSStabilizerwrapper reports the smoothed GPS along with asmoothing_delta_mmetric.
Clusters all matched buildings by geographic proximity (default radius 18 m), then scores each cluster by a weighted quality formula:
| Factor | Weight |
|---|---|
| GOB match score | 35% |
| Angle quality | 25% |
| Distance quality (optimal 25β40 m) | 18% |
| GOB confidence | 12% |
| Candidate count | 6% |
| Cluster size (frame count) | 4% |
GPS is smoothed per cluster using the polygon centroid (preferred) or confidence-weighted mean.
Output: selected_buildings.json + selection_report.txt.
Two complementary calibration tools are provided:
tim.py β Lat/Lon offset calibration
Computes the systematic offset between raw projected GPS and GOB centroids across all matched frames. Outputs PROJECTION_LAT_OFFSET and PROJECTION_LON_OFFSET to paste into cfg.py.
tom.py β Along/Cross-ray offset search
Performs a grid search over along-ray (depth correction, 25β55 m range) and cross-ray (lateral offset, Β±6 m) parameters to minimise the inlier mean polygon-edge error. Uses a curated set of 8 reference frames. Targets sub-3m mean error.
All tunable parameters live in cfg.py:
# ββ Paths ββββββββββββββββββββββββββββββββββββββββββ
LENS1_DIR = r"path/to/camera/frames"
IMU_CSV_PATH = r"path/to/imu_gnss.csv"
L1_PCAP_PATH = r"path/to/l1.pcap"
L2_PCAP_PATH = r"path/to/l2.pcap"
GOB_CSV_PATH = r"path/to/gob.csv"
# ββ Frame range βββββββββββββββββββββββββββββββββββββ
FRAME_START = 1
FRAME_END = 3599
CAMERA_FPS = 30.0
# ββ Camera intrinsics βββββββββββββββββββββββββββββββ
IMG_W, IMG_H = 3840, 2160
FX, FY = 1106.46, 1077.77
CX, CY = 1920.0, 1080.0
# ββ GOB matching ββββββββββββββββββββββββββββββββββββ
GOB_RADIUS_M = 150 # Search radius around ego GPS
ANGLE_CONE_DEG = 35.0 # Max heading deviation to consider
PROX_DECAY_M = 5.0 # Proximity score decay constant
# ββ Score weights βββββββββββββββββββββββββββββββββββ
SCORE_ANGLE_MAX = 50.0
SCORE_PROX_MAX = 30.0
SCORE_WIDTH_MAX = 15.0
# ββ Calibration offsets (output of tim.py / tom.py) β
PROJECTION_LAT_OFFSET = 0.0
PROJECTION_LON_OFFSET = 0.0
ALONG_RAY_OFFSET_M = 25.00
CROSS_RAY_OFFSET_M = -6.00pip install ultralytics # YOLOv8-World + SAM (MobileSAM)
pip install opencv-python
pip install numpy pandas scipy
pip install geopy
pip install dpkt # PCAP parsing
pip install streamlit folium streamlit-folium PillowPython 3.9+ recommended. GPU strongly recommended for YOLO/SAM inference (perceive stage).
Pre-trained model weights required:
yolov8s-world.ptβ YOLOv8s-World (open-vocabulary detection)mobile_sam.ptβ MobileSAM (fast segmentation)
python run.py --frame 840python presniff.py --start 1 --end 3599 --step 25# Select best buildings from all match JSONs
python select_buildings.py --start 1 --end 3599
# Compute and print calibration offsets
python tim.py
# Sub-3m aggressive offset search (curated frames)
python tom.py
# Full diagnostic for a specific frame
python verify.py --frame 840
# Multi-frame error summary
python verify.py --frames 76,151,226,276,401,626,1826,3351 --stage summarystreamlit run viewer.pyThe calibration is a two-step refinement loop:
Step 1 β Run presniff.py over the full dataset
β
Step 2 β Run tim.py
Computes PROJECTION_LAT_OFFSET and PROJECTION_LON_OFFSET
Copy values into cfg.py
β
Step 3 β Re-run presniff.py (or just project.py) with new offsets
β
Step 4 β Run tom.py
Grid-searches ALONG_RAY_OFFSET_M and CROSS_RAY_OFFSET_M
Copy values into cfg.py
β
Step 5 β Run verify.py to confirm improvement
β
Step 6 β Repeat from Step 3 if mean error > 3 m
The curated reference frames used by tom.py are:
[76, 151, 226, 276, 401, 626, 1826, 3351]
These were selected for diverse headings, unambiguous building detections, and GOB polygon coverage.
{
"frame_id": 840,
"bbox": [x1, y1, x2, y2],
"det_conf": 0.72,
"median_depth": 26.3,
"depth_pts": 142,
"img_w": 3840,
"img_h": 2160,
"vis": "outi/perceive_vis/frame_000840_perceive_vis.jpg"
}{
"frame_id": 840,
"ego_lat": 12.944554,
"ego_lon": 80.238342,
"heading_deg": 87.3,
"depth_m": 26.3,
"raw_target_lat": 12.944399,
"raw_target_lon": 80.238539,
"target_lat": 12.944399,
"target_lon": 80.238539,
"gmaps": "https://www.google.com/maps/search/?api=1&query=..."
}{
"frame_id": 840,
"best_match": {
"centroid_lat": 12.944399,
"centroid_lon": 80.238539,
"dist_m": 26.0,
"angle_off_deg": 4.61,
"confidence": 0.899,
"footprint_w_m": 16.2,
"score": 71.4,
"score_angle": 33.6,
"score_prox": 21.0,
"score_width": 14.1,
"score_depth": 2.6,
"geometry": "POLYGON ((80.2384 12.9443, ...))"
},
"all_candidates": [...]
}Array of building objects with quality score, smoothed GPS, error metrics, cluster info, and Google Maps links.
{
"along_offset_m": 25.0,
"cross_offset_m": -6.0,
"inlier_mean_m": 2.62,
"inlier_median_m": 2.31,
"target_achieved": true,
"sub3m_hits": 2,
"inlier_count": 6,
"total_frames": 8
}viewer.py is a Streamlit web dashboard providing:
- Calibration banner β live display of inlier mean/median, polygon hits, inlier count, and target-achieved status
- KPI cards β buildings count, avg quality score, avg match score, calibrated error with delta vs pre-calibration
- Interactive map β vehicle positions (blue), building centroids (colour-coded by score), dashed projection lines, and error-radius circles
- Building Inspector β click any building on the map to see full score breakdown, per-component bar charts, polygon-edge error (pre/post calibration), selection rationale, and the annotated frame image
- Grid gallery β all selected buildings with thumbnail, error chips, and inlier/outlier status
- Sidebar β raw data table, calibration JSON, report download
streamlit run viewer.py
# opens at http://localhost:8501
Results reported on 8 curated reference frames from a road survey in Chennai, India (approx. 12.94Β°N, 80.24Β°E).
| Metric | Value |
|---|---|
| Inlier mean error | 2.62 m |
| Inlier median error | 2.31 m |
| Calibrated avg (all frames) | 3.8 m |
| Pre-calibration avg | ~16.2 m |
| Improvement | β 77% |
| Inside polygon (0 m error) | 2 / 8 frames |
| Inliers (β€ 8.77 m threshold) | 6 / 8 frames |
| Best single-frame error | 1.03 m (Frame 3351) |
| Field | Value |
|---|---|
| Quality score | 84.4 / 100 |
| GOB match score | 71.4 / 100 |
| Angle off heading | 4.61Β° |
| LiDAR distance | 26.0 m |
| GOB confidence | 89.9% |
| Footprint width | 16.2 m |
| Pre-cal edge error | 15.5 m |
| Calibrated edge error | 1.03 m |
| Band | Frames |
|---|---|
| Inside footprint (0 m) | 2 |
| Excellent (< 5 m) | 4 |
| Good (< 10 m) | 2 |
| Fair (< 20 m) | 0 |
| Poor (β₯ 20 m) | 0 |
| Symptom | Likely cause | Fix |
|---|---|---|
| No buildings detected | Conf threshold too high | Lower YOLO_CONF / FALLBACK_CONF |
| Many wrong buildings matched | Cone too wide | Reduce ANGLE_CONE_DEG from 35Β° |
| Depth unreliable | Sparse LiDAR in mask | Check T_LIDAR_TO_CAM; verify PCAP paths |
| High centroid error, low polygon error | Polygon centroid is off-centre | Already handled β smoothing uses polygon centroid |
| All errors > 20 m | Offset not calibrated | Run tim.py, update cfg.py, re-run |
| Mean error 5β15 m | Offset applied but suboptimal | Run tom.py grid search |
| Outlier frames | Wrong GOB match (occlusion) | Inspect match_json; lower GOB_RADIUS_M |
| Too few GOB candidates | GOB CSV doesn't cover area | Check lat/lon bounding box in gob.py |
Current limitations:
- The heading used for projection is taken directly from the IMU yaw, which can drift on curves. A vision-based heading refinement (e.g. vanishing-point estimation) would improve accuracy on non-straight roads.
- The GOB matching uses a single-best-match strategy per frame. In dense urban scenes with multiple large buildings, a ranked multi-hypothesis approach would be more robust.
- Depth estimation relies on LiDAR points projected through a static extrinsic calibration. Rolling shutter effects and vibration can introduce sub-pixel projection errors at long range.
- The
PROX_DECAY_M = 5.0parameter gives very high proximity scores to nearby buildings (< 10 m), which can outcompete the correct match when the vehicle is adjacent to a wall.
Planned improvements:
- Replace the lat/lon delta offset with a full 6-DOF lever-arm calibration between camera, LiDAR, and GNSS antenna.
- Integrate road-graph information to constrain heading estimation.
- Add multi-frame depth fusion (temporal smoothing) before projection.
- Extend the GOB to include building height for 3D matching.
- Export results as GeoJSON for GIS ingestion.
| Term | Definition |
|---|---|
| GOB | Google Open Buildings β surveyed building DB with WKT polygon footprints |
| Polygon-edge error | Distance from estimated GPS to nearest polygon boundary (0 if inside) |
| Inlier | A frame whose calibrated error is within OUTLIER_SCALE Γ median |
| Ego-pose | Vehicle position and orientation at a given frame timestamp |
| Along-ray offset | Depth correction applied along the projection ray (accounts for facade penetration) |
| Cross-ray offset | Lateral offset perpendicular to the projection ray (accounts for camera-GNSS lever arm) |
| HFOV | Horizontal field of view derived from FX and image width |
| SORT | Simple Online and Realtime Tracking β multi-object Kalman tracker adapted here for GPS stabilization |