clicker / app.py
ebanek's picture
todos
f95d0f0
Raw
History Blame Contribute Delete
13.4 kB
#!/usr/bin/env python3
"""
Map Georectification App
Workflow:
1. Select projection type (with guidance on which points to pick)
2. Click known lat/lon positions on the map β†’ add Ground Control Points (GCPs)
3. Compute the affine pixel→geographic transform
4. Switch to Query tab β†’ click anywhere to get lat/lon, label and save points
Run with:
uv run app.py
"""
import csv
import io
import tempfile
import gradio as gr
from PIL import Image
from georect import (
IMAGE_PATH,
PROJECTIONS,
gcps_df,
get_proj4,
points_df,
px_to_lonlat,
render_calib,
render_query,
solve_affine,
)
ZOOM_JS = """
const MIN_SCALE = 0.5, MAX_SCALE = 20, STEP = 0.15;
function initZoom(container) {
if (container.dataset.zoomInit) return;
container.dataset.zoomInit = '1';
let scale = 1, panX = 0, panY = 0;
let isPanning = false, startX = 0, startY = 0;
function getImg() { return container.querySelector('img'); }
function apply() {
const img = getImg();
if (!img) return;
img.style.transformOrigin = '0 0';
img.style.transform = `translate(${panX}px, ${panY}px) scale(${scale})`;
}
container.addEventListener('wheel', (e) => {
if (!e.shiftKey) return;
e.preventDefault();
e.stopPropagation();
const img = getImg();
if (!img) return;
const rect = img.getBoundingClientRect();
const cx = (e.clientX - rect.left) / scale;
const cy = (e.clientY - rect.top) / scale;
const prev = scale;
const delta = e.deltaY > 0 ? -STEP : STEP;
scale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, scale * (1 + delta)));
panX += cx * (prev - scale);
panY += cy * (prev - scale);
apply();
}, {passive: false});
// middle-click drag to pan (avoids conflict with left-click point selection)
container.addEventListener('mousedown', (e) => {
if (e.button === 1) {
isPanning = true;
startX = e.clientX - panX;
startY = e.clientY - panY;
e.preventDefault();
}
});
window.addEventListener('mousemove', (e) => {
if (!isPanning) return;
panX = e.clientX - startX;
panY = e.clientY - startY;
apply();
});
window.addEventListener('mouseup', () => { isPanning = false; });
// double-click to reset zoom
container.addEventListener('dblclick', () => {
scale = 1; panX = 0; panY = 0;
apply();
});
container.style.overflow = 'hidden';
}
function scan() {
document.querySelectorAll('.image-frame').forEach(initZoom);
}
scan();
new MutationObserver(scan).observe(document.body, {childList: true, subtree: true});
"""
def build_app():
base = Image.open(IMAGE_PATH).convert("RGB")
with gr.Blocks(title="Map Georectification") as demo:
# ── Shared state ──────────────────────────────────────────────────────
base_st = gr.State(base)
gcps_st = gr.State([])
tfm_st = gr.State({"cx": None, "cy": None, "proj4": None})
picked_st = gr.State([])
last_px_st = gr.State(None)
last_py_st = gr.State(None)
gr.Markdown(
"## πŸ—Ί Map Georectification\n"
"**β‘  Calibrate** – choose projection and add Ground Control Points (GCPs). "
"**β‘‘ Compute** – fit pixelβ†’coordinate transform. "
"**β‘’ Query** – click anywhere to get lat/lon coordinates."
)
with gr.Tabs():
# ── Tab 1 Β· Calibrate ─────────────────────────────────────────────
with gr.Tab("β‘  Calibrate"):
with gr.Row():
with gr.Column(scale=3):
calib_img = gr.Image(
value=base,
label="Click to place a control point",
type="pil",
interactive=True,
buttons=[],
)
with gr.Column(scale=2):
proj_sel = gr.Dropdown(
list(PROJECTIONS.keys()),
value="Albers Equal Area (CONUS)",
label="Map Projection",
)
proj_hint = gr.Markdown(PROJECTIONS["Albers Equal Area (CONUS)"]["hint"])
custom_box = gr.Textbox(
label="Custom PROJ4 / EPSG string",
placeholder="e.g. +proj=aea +lat_1=29.5 ... or EPSG:5070",
visible=False,
)
gr.Markdown("---\n**Add Control Point** \n"
"*Click a known location on the map, then enter its coordinates:*")
with gr.Row():
px_box = gr.Number(label="Pixel X", precision=0, interactive=False)
py_box = gr.Number(label="Pixel Y", precision=0, interactive=False)
with gr.Row():
lon_box = gr.Number(label="Longitude Β°E (negative = West)")
lat_box = gr.Number(label="Latitude Β°N")
with gr.Row():
add_btn = gr.Button("Add GCP", variant="primary")
del_btn = gr.Button("Remove Last")
clr_btn = gr.Button("Clear All", variant="stop")
gcp_tbl = gr.Dataframe(
value=gcps_df([]), interactive=False, wrap=True,
label="Control Points",
)
compute_btn = gr.Button(
"βš™ Compute Georectification", variant="primary", size="lg"
)
status_md = gr.Markdown("*Add β‰₯ 3 control points, then click Compute.*")
# ── Tab 2 Β· Query ─────────────────────────────────────────────────
with gr.Tab("β‘‘ Query"):
warn_md = gr.Markdown(
"> ⚠️ No transform yet – go to the **Calibrate** tab first.",
visible=True,
)
with gr.Row():
with gr.Column(scale=3):
query_img = gr.Image(
value=base,
label="Click to query geographic coordinates",
type="pil",
interactive=True,
buttons=[],
)
with gr.Column(scale=2):
gr.Markdown("**Last click**")
with gr.Row():
q_lat = gr.Number(label="Latitude Β°N", precision=6, interactive=False)
q_lon = gr.Number(label="Longitude Β°E", precision=6, interactive=False)
q_info = gr.Markdown("*Click on the map.*")
q_label = gr.Textbox(
label="Label for this point",
placeholder="e.g. Sample location A",
)
save_btn = gr.Button("Save Point", variant="primary")
gr.Markdown("---\n**Saved Points**")
pts_tbl = gr.Dataframe(
value=points_df([]), interactive=False, wrap=True,
)
with gr.Row():
exp_btn = gr.Button("Export CSV")
clr_pts_btn = gr.Button("Clear Points")
csv_out = gr.File(label="Download CSV", visible=False)
# ── Event handlers ────────────────────────────────────────────────────
def on_proj(name):
return PROJECTIONS[name]["hint"], gr.update(visible=(name == "Custom PROJ4 String"))
proj_sel.change(on_proj, proj_sel, [proj_hint, custom_box])
def on_calib_select(evt: gr.SelectData):
return int(evt.index[0]), int(evt.index[1])
calib_img.select(on_calib_select, outputs=[px_box, py_box])
def on_upload(img):
if img is None:
img = base
new_base = img.convert("RGB")
tfm = {"cx": None, "cy": None, "proj4": None}
return (
new_base, # base_st
[], gcps_df([]), # gcps_st, gcp_tbl
tfm, # tfm_st
[], points_df([]), # picked_st, pts_tbl
new_base, new_base, # calib_img, query_img
gr.update(visible=True), # warn_md
"*Add β‰₯ 3 control points, then click Compute.*",
None, None, # px_box, py_box
None, None, # last_px_st, last_py_st
)
upload_outputs = [
base_st, gcps_st, gcp_tbl, tfm_st, picked_st, pts_tbl,
calib_img, query_img, warn_md, status_md,
px_box, py_box, last_px_st, last_py_st,
]
calib_img.upload(on_upload, calib_img, upload_outputs)
query_img.upload(on_upload, query_img, upload_outputs)
def on_add(base_img, gcps, px, py, lon, lat):
if None in (px, py, lon, lat):
return gcps, render_calib(base_img, gcps), gcps_df(gcps)
new = gcps + [{"px": float(px), "py": float(py),
"lon": float(lon), "lat": float(lat)}]
return new, render_calib(base_img, new), gcps_df(new)
add_btn.click(on_add, [base_st, gcps_st, px_box, py_box, lon_box, lat_box],
[gcps_st, calib_img, gcp_tbl])
def on_del(base_img, gcps):
new = gcps[:-1]
return new, render_calib(base_img, new), gcps_df(new)
del_btn.click(on_del, [base_st, gcps_st], [gcps_st, calib_img, gcp_tbl])
def on_clr_gcps(base_img):
return [], base_img, gcps_df([])
clr_btn.click(on_clr_gcps, base_st, [gcps_st, calib_img, gcp_tbl])
def on_compute(base_img, gcps, proj_name, custom):
proj4 = get_proj4(proj_name, custom)
cx, cy, msg = solve_affine(gcps, proj4)
tfm = {"cx": cx, "cy": cy, "proj4": proj4}
return tfm, msg, gr.update(visible=(cx is None)), render_query(base_img, gcps, [])
compute_btn.click(
on_compute, [base_st, gcps_st, proj_sel, custom_box],
[tfm_st, status_md, warn_md, query_img],
)
def on_query_select(tfm, evt: gr.SelectData):
px, py = int(evt.index[0]), int(evt.index[1])
cx, cy, proj4 = tfm["cx"], tfm["cy"], tfm["proj4"]
if cx is None:
return px, py, None, None, "*No transform – calibrate first.*"
lon, lat = px_to_lonlat(px, py, cx, cy, proj4)
msg = f"Pixel **({px}, {py})** β†’ **{lat:.5f}Β° N, {lon:.5f}Β° E**"
return px, py, float(lat), float(lon), msg
query_img.select(
on_query_select, [tfm_st],
[last_px_st, last_py_st, q_lat, q_lon, q_info],
)
def on_save(base_img, last_px, last_py, tfm, gcps, picked, label):
if last_px is None or tfm["cx"] is None:
return picked, points_df(picked), render_query(base_img, gcps, picked)
lon, lat = px_to_lonlat(last_px, last_py, tfm["cx"], tfm["cy"], tfm["proj4"])
pt = {
"px": last_px, "py": last_py, "lon": lon, "lat": lat,
"label": label.strip() or f"Point {len(picked) + 1}",
}
new = picked + [pt]
return new, points_df(new), render_query(base_img, gcps, new)
save_btn.click(
on_save, [base_st, last_px_st, last_py_st, tfm_st, gcps_st, picked_st, q_label],
[picked_st, pts_tbl, query_img],
)
def on_clr_pts(base_img, gcps):
return [], points_df([]), render_query(base_img, gcps, [])
clr_pts_btn.click(on_clr_pts, [base_st, gcps_st], [picked_st, pts_tbl, query_img])
def on_export(picked):
if not picked:
return gr.update(visible=False)
buf = io.StringIO()
w = csv.writer(buf)
w.writerow(["Label", "Latitude_N", "Longitude_E", "Pixel_X", "Pixel_Y"])
for p in picked:
w.writerow([p.get("label", ""), p["lat"], p["lon"], p["px"], p["py"]])
with tempfile.NamedTemporaryFile(
mode="w", suffix=".csv", prefix="georect_points_", delete=False
) as f:
f.write(buf.getvalue())
return gr.update(value=f.name, visible=True)
exp_btn.click(on_export, picked_st, csv_out)
return demo
if __name__ == "__main__":
build_app().launch(theme=gr.themes.Soft(), js=ZOOM_JS)