clicker / georect.py
ebanek's picture
bla
013b84b
Raw
History Blame Contribute Delete
7.12 kB
"""
Core logic for map georectification:
- Projection definitions and helpers
- Affine pixel→projected transform (least-squares fit)
- Image rendering (crosshair markers)
- DataFrame helpers for GCPs and picked points
"""
from pathlib import Path
import numpy as np
import pandas as pd
import pyproj
from PIL import Image, ImageDraw
IMAGE_PATH = Path(__file__).parent / "img.jpg"
# ── Projection definitions ────────────────────────────────────────────────────
PROJECTIONS = {
"Albers Equal Area (CONUS)": {
"proj4": "+proj=aea +lat_1=29.5 +lat_2=45.5 +lat_0=37.5 +lon_0=-96 +x_0=0 +y_0=0 +datum=NAD83 +units=m",
"hint": (
"**Albers Equal Area** – standard for US geological/geographic maps. \n"
"Click on **lat/lon grid intersections** visible on the map "
"(e.g. where the 40Β°N line meets the 100Β°W line). \n"
"Need **β‰₯ 3 points**, ideally spread to the corners."
),
},
"Lambert Conformal Conic (CONUS)": {
"proj4": "+proj=lcc +lat_1=33 +lat_2=45 +lat_0=39 +lon_0=-96 +x_0=0 +y_0=0 +datum=NAD83 +units=m",
"hint": (
"**Lambert Conformal Conic** – common for North American maps. \n"
"Pick **β‰₯ 4** lat/lon grid intersections spread across the map."
),
},
"Mercator": {
"proj4": "+proj=merc +datum=WGS84 +units=m",
"hint": (
"**Mercator** – horizontal grid lines are evenly spaced; "
"vertical spacing increases toward the poles. \n"
"Pick **β‰₯ 3** widely-spaced lat/lon intersections."
),
},
"Equirectangular (Plate CarrΓ©e)": {
"proj4": None,
"hint": (
"**Equirectangular** – lon/lat vary linearly with pixel x/y. \n"
"Two diagonally-opposite corner points are enough."
),
},
"Custom PROJ4 String": {
"proj4": "custom",
"hint": "Enter a valid PROJ4 (or `EPSG:XXXX`) string below, then add control points.",
},
}
# ── Geo math ──────────────────────────────────────────────────────────────────
def get_proj4(proj_name: str, custom: str) -> str | None:
if proj_name == "Custom PROJ4 String":
return custom.strip() or None
return PROJECTIONS[proj_name]["proj4"]
def _lonlat_to_proj(lon: float, lat: float, proj4: str | None):
if proj4 is None:
return lon, lat
return pyproj.Proj(proj4)(lon, lat)
def _proj_to_lonlat(x: float, y: float, proj4: str | None):
if proj4 is None:
return x, y # lon, lat
return pyproj.Proj(proj4)(x, y, inverse=True) # lon, lat
def solve_affine(gcps: list, proj4: str | None) -> tuple:
"""
Fit an affine pixel→projected transform from a list of GCP dicts
(each with keys px, py, lon, lat).
Returns (cx, cy, message) where cx/cy are the 3-element coefficient
vectors [a, b, c] such that x_proj = a*px + b*py + c.
"""
n = len(gcps)
if n < 3:
return None, None, f"Need β‰₯ 3 control points (have {n})."
A, Bx, By = [], [], []
for g in gcps:
px, py = float(g["px"]), float(g["py"])
try:
xp, yp = _lonlat_to_proj(float(g["lon"]), float(g["lat"]), proj4)
except Exception as e:
return None, None, f"Projection error at ({g['lon']}, {g['lat']}): {e}"
A.append([px, py, 1.0])
Bx.append(xp)
By.append(yp)
A = np.array(A)
Bx = np.array(Bx)
By = np.array(By)
cx, *_ = np.linalg.lstsq(A, Bx, rcond=None)
cy, *_ = np.linalg.lstsq(A, By, rcond=None)
residuals = np.sqrt((A @ cx - Bx) ** 2 + (A @ cy - By) ** 2)
rmse = float(np.sqrt(np.mean(residuals ** 2)))
unit = "m" if proj4 else "Β°"
approx = f" (~{rmse / 111_000 * 60:.2f}β€² arc)" if proj4 else ""
if n == 3:
summary = (
f"βœ… Transform fitted from {n} points. "
f"RMSE: {rmse:.2f} {unit} *(always 0 with exactly 3 pts β€” add more for a meaningful residual)*"
)
else:
summary = f"βœ… Transform fitted from {n} points. RMSE: {rmse:.2f} {unit}{approx}"
per_point = "\n".join(
f" - GCP {i+1} ({g['lon']}Β°E, {g['lat']}Β°N): {r:.2f} {unit}"
for i, (g, r) in enumerate(zip(gcps, residuals))
)
msg = f"{summary}\n\n**Per-point residuals:**\n{per_point}"
return cx.tolist(), cy.tolist(), msg
def px_to_lonlat(px: float, py: float, cx, cy, proj4: str | None) -> tuple:
"""Convert pixel coordinates to (lon, lat) using the fitted transform."""
if cx is None:
return None, None
v = np.array([float(px), float(py), 1.0])
return _proj_to_lonlat(float(np.dot(cx, v)), float(np.dot(cy, v)), proj4)
# ── Image rendering ───────────────────────────────────────────────────────────
def _crosshair(draw: ImageDraw.ImageDraw, px, py, color, r=10, w=2, label=None):
draw.ellipse([px - r, py - r, px + r, py + r], outline=color, width=w)
draw.line([px - r - 4, py, px + r + 4, py], fill=color, width=w)
draw.line([px, py - r - 4, px, py + r + 4], fill=color, width=w)
if label:
draw.text((px + r + 4, py - r), label, fill=color)
def render_calib(base: Image.Image, gcps: list) -> Image.Image:
"""Return a copy of base with GCP crosshairs drawn in red."""
img = base.copy()
draw = ImageDraw.Draw(img)
for i, g in enumerate(gcps):
_crosshair(draw, int(g["px"]), int(g["py"]), "#FF4444", label=str(i + 1))
return img
def render_query(base: Image.Image, gcps: list, picked: list) -> Image.Image:
"""Return a copy of base with grey GCP markers and cyan picked-point markers."""
img = base.copy()
draw = ImageDraw.Draw(img)
for g in gcps:
_crosshair(draw, int(g["px"]), int(g["py"]), "#888888", r=6, w=1)
for pt in picked:
_crosshair(draw, int(pt["px"]), int(pt["py"]), "#44CCFF", label=pt.get("label", ""))
return img
# ── DataFrame helpers ─────────────────────────────────────────────────────────
def gcps_df(gcps: list) -> pd.DataFrame:
cols = ["#", "Pixel X", "Pixel Y", "Lon Β°E", "Lat Β°N"]
if not gcps:
return pd.DataFrame(columns=cols)
return pd.DataFrame(
[[i + 1, g["px"], g["py"], g["lon"], g["lat"]] for i, g in enumerate(gcps)],
columns=cols,
)
def points_df(pts: list) -> pd.DataFrame:
cols = ["Label", "Lat Β°N", "Lon Β°E", "Pixel X", "Pixel Y"]
if not pts:
return pd.DataFrame(columns=cols)
return pd.DataFrame(
[[p.get("label", ""), round(p["lat"], 6), round(p["lon"], 6), p["px"], p["py"]]
for p in pts],
columns=cols,
)