| |
| """ |
| 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: |
|
|
| |
| 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(): |
|
|
| |
| 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.*") |
|
|
| |
| 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) |
|
|
| |
|
|
| 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, |
| [], gcps_df([]), |
| tfm, |
| [], points_df([]), |
| new_base, new_base, |
| gr.update(visible=True), |
| "*Add β₯ 3 control points, then click Compute.*", |
| None, None, |
| None, None, |
| ) |
|
|
| 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) |
|
|