From aradotso-trending-skills-37
Geolocates street-level photos to sub-50m GPS coordinates using Netryx open-source engine with CosPlace embeddings, ALIKED/DISK features, and LightGlue matching. Builds local Street View indexes for offline searches.
npx claudepluginhub joshuarweaver/cascade-ai-ml-agents-misc-1 --plugin aradotso-trending-skills-37This skill uses the workspace's default tool permissions.
```markdown
Guides Next.js Cache Components and Partial Prerendering (PPR) with cacheComponents enabled. Implements 'use cache', cacheLife(), cacheTag(), revalidateTag(), static/dynamic optimization, and cache debugging.
Guides building MCP servers enabling LLMs to interact with external services via tools. Covers best practices, TypeScript/Node (MCP SDK), Python (FastMCP).
Generates original PNG/PDF visual art via design philosophy manifestos for posters, graphics, and static designs on user request.
---
name: netryx-street-level-geolocation
description: Expert skill for using Netryx, the open-source local-first street-level geolocation engine that identifies GPS coordinates from street photos using CosPlace, ALIKED/DISK, and LightGlue.
triggers:
- geolocate a street photo
- find GPS coordinates from an image
- street level geolocation
- index street view panoramas
- use netryx to locate
- run netryx geolocation pipeline
- build a netryx index
- identify location from street photo
---
# Netryx Street-Level Geolocation
> Skill by [ara.so](https://ara.so) — Daily 2026 Skills collection.
Netryx is a locally-hosted, open-source geolocation engine that identifies precise GPS coordinates from any street-level photograph. It crawls Street View panoramas, indexes them as CosPlace embeddings, then uses a three-stage computer vision pipeline (global retrieval → local feature matching → refinement) to match a query image to a location. Sub-50m accuracy, no internet required at search time, runs entirely on local hardware.
---
## Installation
```bash
git clone https://github.com/sparkyniner/Netryx-OpenSource-Next-Gen-Street-Level-Geolocation.git
cd Netryx-OpenSource-Next-Gen-Street-Level-Geolocation
python3 -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install -r requirements.txt
pip install git+https://github.com/cvg/LightGlue.git # required
pip install kornia # optional: Ultra Mode (LoFTR)
export GEMINI_API_KEY="your_key_here" # from https://aistudio.google.com
brew install python-tk@3.11 # match your Python version
| Component | Minimum | Recommended |
|---|---|---|
| GPU VRAM | 4 GB | 8 GB+ |
| RAM | 8 GB | 16 GB+ |
| Storage | 10 GB | 50 GB+ |
| Python | 3.9+ | 3.10+ |
GPU backends: CUDA (NVIDIA) → uses ALIKED; MPS (Apple Silicon) → uses DISK; CPU → uses DISK (slow).
netryx/
├── test_super.py # Main GUI application (entry point)
├── cosplace_utils.py # CosPlace model loading + descriptor extraction
├── build_index.py # Standalone high-performance index builder
├── requirements.txt
├── cosplace_parts/ # Raw .npz embedding chunks (written during indexing)
└── index/
├── cosplace_descriptors.npy # All 512-dim global descriptors
└── metadata.npz # lat/lon, heading, panoid per descriptor
python test_super.py
The GUI has two modes: Create (index an area) and Search (geolocate a query image).
Index a geographic area before searching. This crawls Street View panoramas and extracts CosPlace fingerprints.
In GUI:
0.5–1 for testing300, do not changeIndexing time estimates:
| Radius | ~Panoramas | Time (M2 Max) | Index Size |
|---|---|---|---|
| 0.5 km | ~500 | 30 min | ~60 MB |
| 1 km | ~2,000 | 1–2 hours | ~250 MB |
| 5 km | ~30,000 | 8–12 hours | ~3 GB |
| 10 km | ~100,000 | 24–48 hours | ~7 GB |
Indexing is resumable — if interrupted, re-run and it picks up where it left off.
For large datasets, use the standalone builder:
python build_index.py
GEMINI_API_KEY)Enable Ultra Mode for difficult images (night, blur, low texture). Adds LoFTR dense matching, descriptor hopping, and 100m neighborhood expansion. Slower but more robust.
Query image → 512-dim CosPlace descriptor
+ flipped image → 512-dim descriptor
→ Cosine similarity against index (single matrix multiply, <1s)
→ Haversine radius filter
→ Top 500–1000 candidates
For each candidate:
Download Street View panorama (8 tiles, stitched)
→ Rectilinear crop at indexed heading
→ Multi-FOV crops: 70°, 90°, 110°
→ ALIKED (CUDA) or DISK (MPS/CPU) keypoint extraction
→ LightGlue deep feature matching vs query keypoints
→ RANSAC geometric verification → inlier count
Best match = highest inlier count
Processes 300–500 candidates in 2–5 minutes
Top 15 candidates:
→ Heading refinement: ±45° at 15° steps, 3 FOVs
→ Spatial consensus: cluster into 50m cells
→ Confidence scoring: clustering strength + uniqueness ratio
→ Final GPS coordinates
+ LoFTR detector-free dense matching (handles blur/low-contrast)
+ Descriptor hopping: re-search index using matched panorama's descriptor
+ Neighborhood expansion: search all panoramas within 100m of best match
# cosplace_utils.py exposes model loading and descriptor extraction
from cosplace_utils import load_cosplace_model, get_descriptor
from PIL import Image
import torch
# Load model (cached after first call)
model = load_cosplace_model() # auto-detects CUDA / MPS / CPU
# Extract a 512-dim descriptor from any PIL image
img = Image.open("query.jpg").convert("RGB")
descriptor = get_descriptor(model, img) # returns np.ndarray shape (512,)
print(descriptor.shape) # (512,)
# Compare two images via cosine similarity
import numpy as np
desc_a = get_descriptor(model, Image.open("a.jpg").convert("RGB"))
desc_b = get_descriptor(model, Image.open("b.jpg").convert("RGB"))
similarity = np.dot(desc_a, desc_b) / (np.linalg.norm(desc_a) * np.linalg.norm(desc_b))
print(f"Cosine similarity: {similarity:.4f}")
import numpy as np
# Load the compiled index
descriptors = np.load("index/cosplace_descriptors.npy") # shape (N, 512)
meta = np.load("index/metadata.npz", allow_pickle=True)
lats = meta["lats"] # shape (N,)
lons = meta["lons"] # shape (N,)
headings = meta["headings"] # shape (N,)
panoids = meta["panoids"] # shape (N,) — Street View panorama IDs
print(f"Index contains {len(lats):,} panorama views")
from math import radians, sin, cos, sqrt, atan2
import numpy as np
def haversine_km(lat1, lon1, lat2, lon2):
R = 6371.0
dlat = radians(lat2 - lat1)
dlon = radians(lon2 - lon1)
a = sin(dlat/2)**2 + cos(radians(lat1))*cos(radians(lat2))*sin(dlon/2)**2
return R * 2 * atan2(sqrt(a), sqrt(1-a))
def search_index(query_descriptor, center_lat, center_lon, radius_km, top_k=500):
"""Return top_k candidate indices within radius_km of center."""
descriptors = np.load("index/cosplace_descriptors.npy")
meta = np.load("index/metadata.npz", allow_pickle=True)
lats, lons = meta["lats"], meta["lons"]
# Radius filter
distances = np.array([
haversine_km(center_lat, center_lon, lat, lon)
for lat, lon in zip(lats, lons)
])
in_radius = np.where(distances <= radius_km)[0]
if len(in_radius) == 0:
return []
# Cosine similarity
subset = descriptors[in_radius]
q = query_descriptor / (np.linalg.norm(query_descriptor) + 1e-8)
norms = np.linalg.norm(subset, axis=1, keepdims=True) + 1e-8
sims = (subset / norms) @ q
ranked = in_radius[np.argsort(sims)[::-1][:top_k]]
return ranked.tolist()
# Usage
from cosplace_utils import load_cosplace_model, get_descriptor
from PIL import Image
model = load_cosplace_model()
query_desc = get_descriptor(model, Image.open("query.jpg").convert("RGB"))
candidates = search_index(query_desc, center_lat=48.8566, center_lon=2.3522, radius_km=2.0)
print(f"Found {len(candidates)} candidates")
import torch
from lightglue import LightGlue, SuperPoint, ALIKED, DISK
from lightglue.utils import load_image, rbd
from PIL import Image
import numpy as np
device = (
torch.device("cuda") if torch.cuda.is_available()
else torch.device("mps") if torch.backends.mps.is_available()
else torch.device("cpu")
)
# Choose extractor based on device
if device.type == "cuda":
extractor = ALIKED(max_num_keypoints=1024).eval().to(device)
matcher = LightGlue(features="aliked").eval().to(device)
else:
extractor = DISK(max_num_keypoints=768).eval().to(device)
matcher = LightGlue(features="disk").eval().to(device)
def match_images(img_path_a, img_path_b):
img_a = load_image(img_path_a).to(device)
img_b = load_image(img_path_b).to(device)
with torch.no_grad():
feats_a = extractor.extract(img_a)
feats_b = extractor.extract(img_b)
matches_data = matcher({"image0": feats_a, "image1": feats_b})
feats_a, feats_b, matches_data = [rbd(x) for x in (feats_a, feats_b, matches_data)]
matched_kps_a = feats_a["keypoints"][matches_data["matches"][..., 0]]
matched_kps_b = feats_b["keypoints"][matches_data["matches"][..., 1]]
return matched_kps_a.cpu().numpy(), matched_kps_b.cpu().numpy()
kps_a, kps_b = match_images("query.jpg", "candidate_crop.jpg")
print(f"Matched keypoints: {len(kps_a)}")
import cv2
import numpy as np
def count_geometric_inliers(kps_a, kps_b, threshold=3.0):
"""Returns number of RANSAC inliers — higher = better match."""
if len(kps_a) < 8:
return 0
pts_a = kps_a.astype(np.float32)
pts_b = kps_b.astype(np.float32)
_, mask = cv2.findFundamentalMat(pts_a, pts_b, cv2.FM_RANSAC, threshold)
if mask is None:
return 0
return int(mask.sum())
inliers = count_geometric_inliers(kps_a, kps_b)
print(f"Geometric inliers: {inliers}")
# Rule of thumb: >30 inliers = strong match, >100 = high confidence
import kornia
import torch
import cv2
import numpy as np
from kornia.feature import LoFTR
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
loftr = LoFTR(pretrained="outdoor").eval().to(device)
def loftr_match(img_path_a, img_path_b):
def preprocess(path):
img = cv2.imread(path, cv2.IMREAD_GRAYSCALE)
img = cv2.resize(img, (640, 480))
t = torch.from_numpy(img).float() / 255.0
return t.unsqueeze(0).unsqueeze(0).to(device) # (1,1,H,W)
img_a = preprocess(img_path_a)
img_b = preprocess(img_path_b)
with torch.no_grad():
result = loftr({"image0": img_a, "image1": img_b})
kps_a = result["keypoints0"].cpu().numpy()
kps_b = result["keypoints1"].cpu().numpy()
confidence = result["confidence"].cpu().numpy()
# Filter by confidence
mask = confidence > 0.5
return kps_a[mask], kps_b[mask]
kps_a, kps_b = loftr_match("blurry_query.jpg", "candidate_crop.jpg")
print(f"LoFTR matches (conf>0.5): {len(kps_a)}")
import requests
from PIL import Image
from io import BytesIO
import numpy as np
def download_streetview_panorama(panoid: str, heading: float, fov: float = 90.0,
width: int = 640, height: int = 480) -> Image.Image:
"""
Download a rectilinear crop from Google Street View.
Requires a Google Maps Street View Static API key.
"""
api_key = os.environ["GOOGLE_MAPS_API_KEY"]
url = (
f"https://maps.googleapis.com/maps/api/streetview"
f"?size={width}x{height}"
f"&pano={panoid}"
f"&heading={heading}"
f"&fov={fov}"
f"&pitch=0"
f"&key={api_key}"
)
resp = requests.get(url, timeout=10)
resp.raise_for_status()
return Image.open(BytesIO(resp.content)).convert("RGB")
# Multi-FOV crops as used by Netryx pipeline
def get_multi_fov_crops(panoid: str, heading: float):
crops = {}
for fov in [70, 90, 110]:
crops[fov] = download_streetview_panorama(panoid, heading, fov=fov)
return crops
from cosplace_utils import load_cosplace_model, get_descriptor
from PIL import Image
import numpy as np
# 1. Load model and query
model = load_cosplace_model()
query = Image.open("mystery_street.jpg").convert("RGB")
query_flipped = query.transpose(Image.FLIP_LEFT_RIGHT)
desc = get_descriptor(model, query)
desc_fl = get_descriptor(model, query_flipped)
combined = (desc + desc_fl) / 2 # average both views
# 2. Search index
candidates = search_index(combined, center_lat=48.8566, center_lon=2.3522, radius_km=3.0)
# 3. For each candidate: download crop, match keypoints, RANSAC
# (see match_images + count_geometric_inliers examples above)
# 4. Pick best by inlier count
best_idx = max(candidates, key=lambda i: get_inliers_for_candidate(i))
meta = np.load("index/metadata.npz", allow_pickle=True)
print(f"Location: {meta['lats'][best_idx]:.6f}, {meta['lons'][best_idx]:.6f}")
import torch
if torch.cuda.is_available():
device = torch.device("cuda")
feature_extractor = "aliked"
elif torch.backends.mps.is_available():
device = torch.device("mps")
feature_extractor = "disk"
else:
device = torch.device("cpu")
feature_extractor = "disk"
print(f"Using device: {device}, extractor: {feature_extractor}")
| Parameter | Default | Notes |
|---|---|---|
| Grid resolution | 300 | Panorama crawl density — do not change |
| Top-K candidates | 500–1000 | Stage 1 retrieval size |
| Heading refinement range | ±45° | 15° steps, top 15 candidates |
| Heading refinement FOVs | [70, 90, 110] | Degrees |
| Spatial consensus cell | 50 m | Clustering radius |
| Ultra Mode neighborhood | 100 m | Expansion radius for descriptor hopping |
| RANSAC threshold | 3.0 px | Inlier reprojection tolerance |
| Strong match threshold | 30 inliers | Rule of thumb for reliable match |
brew install python-tk@3.11 # match your actual Python version
max_num_keypoints in ALIKED: ALIKED(max_num_keypoints=512)pip install git+https://github.com/cvg/LightGlue.git
# lightglue is NOT on PyPI — must be installed from GitHub
pip install kornia # Ultra Mode requires kornia for LoFTR
center_lat/center_lon are within the indexed arearadius_kmindex/cosplace_descriptors.npy and index/metadata.npz existbuild_index.py if the auto-build step was skippedRe-run the same index creation command — progress is saved incrementally in cosplace_parts/*.npz and resumes automatically.
export GEMINI_API_KEY="your_key_here"
# Verify key is valid at https://aistudio.google.com
# Note: Manual mode (explicit lat/lon + radius) is recommended over AI Coarse
| Model | Task | Paper |
|---|---|---|
| CosPlace | Global place recognition descriptor | CVPR 2022 |
| ALIKED | Local keypoints — CUDA | IEEE TIP 2023 |
| DISK | Local keypoints — MPS/CPU | NeurIPS 2020 |
| LightGlue | Deep feature matching | ICCV 2023 |
| LoFTR | Dense detector-free matching (Ultra) | CVPR 2021 |