""" Digitize a hand-traced river line (a colored polyline drawn over a road map, exported as a PNG/screenshot) into a georeferenced centerline CSV of [longitude, latitude] waypoints, ordered along the river's course. Pipeline: 1. Color-threshold the image to isolate just the traced line. 2. Keep only the largest connected component (drops stray marker dots etc. that happen to be a similar color). 3. Skeletonize to a 1px-wide path. 4. Find the true end-to-end path via "double BFS" (find the farthest node from an arbitrary start, then the farthest node from THAT one — the shortest path between those two is the main line; this naturally ignores short skeletonization artifacts/spurs without needing to hand-tune a spur-pruning threshold). 5. Georeference: fit a least-squares affine transform (pixel -> lon/lat) from a handful of reference points (town positions you read off the image, paired with their known real-world coordinates). 6. Downsample to a manageable number of evenly-spaced waypoints and save as CSV. Usage (edit REFERENCE_POINTS for your image, or pass --refs-json): python extract_river_centerline.py \\ --image map.png --target-rgb 30 144 255 --tolerance 40 \\ --refs-json refs.json --output centerline.csv --n-points 120 Where refs.json looks like: [ {"pixel_x": 200, "pixel_y": 45, "lon": 1.1667, "lat": 49.2167}, {"pixel_x": 200, "pixel_y": 183, "lon": 1.1509, "lat": 49.0269}, ... ] ACCURACY NOTE: this only produces a shape that's as good as (a) the traced line's own accuracy and (b) how precisely you read reference point pixel coordinates off the image. Expect a few km of error, not survey-grade. Use >= 4-5 well-spread reference points for a decent least-squares fit; more points and points that span the image well help. Requires: numpy, pandas, Pillow, scikit-image, networkx """ import argparse import json from pathlib import Path from typing import List, Dict import numpy as np import pandas as pd from PIL import Image from skimage.morphology import skeletonize from skimage.measure import label import networkx as nx def isolate_line_mask(image_path: Path, target_rgb: List[int], tolerance: float) -> np.ndarray: """Threshold the image to a boolean mask of pixels close to `target_rgb`.""" img = Image.open(image_path).convert("RGB") arr = np.array(img).astype(int) dist = np.sqrt(((arr - np.array(target_rgb)) ** 2).sum(axis=2)) return dist < tolerance def keep_largest_component(mask: np.ndarray) -> np.ndarray: """Drop everything except the largest connected blob (e.g. excludes a stray start/end marker dot rendered in a similar color).""" lbl = label(mask) if lbl.max() == 0: raise ValueError("No pixels matched the target color — check target_rgb/tolerance.") sizes = [((lbl == i).sum(), i) for i in range(1, lbl.max() + 1)] largest_id = max(sizes)[1] return lbl == largest_id def order_skeleton_pixels(skel: np.ndarray) -> np.ndarray: """ Skeletonize-then-order: build an 8-connectivity graph over skeleton pixels, then find the path between the two most mutually-distant nodes (double BFS). Returns an (N, 2) array of (y, x) pixel coordinates in order along the line. """ ys, xs = np.where(skel) coords = set(zip(ys.tolist(), xs.tolist())) G = nx.Graph() G.add_nodes_from(coords) for (y, x) in coords: for dy in (-1, 0, 1): for dx in (-1, 0, 1): if dy == 0 and dx == 0: continue q = (y + dy, x + dx) if q in coords: G.add_edge((y, x), q) if nx.number_connected_components(G) > 1: # Keep only the largest component of the skeleton graph too. largest_cc = max(nx.connected_components(G), key=len) G = G.subgraph(largest_cc).copy() start = next(iter(G.nodes)) lengths = nx.single_source_shortest_path_length(G, start) a = max(lengths, key=lengths.get) lengths2 = nx.single_source_shortest_path_length(G, a) b = max(lengths2, key=lengths2.get) path = nx.shortest_path(G, a, b) return np.array(path) # (N, 2) as (y, x) def fit_pixel_to_geo_transform(refs: List[Dict[str, float]]): """ Least-squares affine fit: lon = a*px + b*py + c, lat = d*px + e*py + f. Returns (lon_coef, lat_coef), each a length-3 array [a, b, c]. Also prints residuals at the reference points as a sanity check. """ P = np.array([[r["pixel_x"], r["pixel_y"], 1] for r in refs]) lon = np.array([r["lon"] for r in refs]) lat = np.array([r["lat"] for r in refs]) lon_coef, *_ = np.linalg.lstsq(P, lon, rcond=None) lat_coef, *_ = np.linalg.lstsq(P, lat, rcond=None) pred_lon, pred_lat = P @ lon_coef, P @ lat_coef mean_lat = np.mean(lat) err_lon_km = np.abs(pred_lon - lon) * 111 * np.cos(np.radians(mean_lat)) err_lat_km = np.abs(pred_lat - lat) * 111 print(f"Georeferencing fit residuals at the {len(refs)} reference points: " f"max {max(err_lon_km.max(), err_lat_km.max()):.2f} km " f"(lon max {err_lon_km.max():.2f} km, lat max {err_lat_km.max():.2f} km)") return lon_coef, lat_coef def pixels_to_geo(path_px: np.ndarray, lon_coef: np.ndarray, lat_coef: np.ndarray) -> pd.DataFrame: """Apply the fitted transform to an (N, 2) array of (y, x) pixel coords.""" ys, xs = path_px[:, 0], path_px[:, 1] lon = lon_coef[0] * xs + lon_coef[1] * ys + lon_coef[2] lat = lat_coef[0] * xs + lat_coef[1] * ys + lat_coef[2] return pd.DataFrame({"longitude": lon, "latitude": lat}) def downsample_evenly(df: pd.DataFrame, n_points: int) -> pd.DataFrame: """Evenly-spaced-by-index downsampling (fine since the ordered path's pixels are already roughly evenly spaced along the traced curve).""" idx = np.linspace(0, len(df) - 1, n_points).astype(int) out = df.iloc[idx].reset_index(drop=True) out.insert(0, "seq", range(len(out))) return out def extract_centerline( image_path: Path, target_rgb: List[int], tolerance: float, reference_points: List[Dict[str, float]], n_points: int = 120, ) -> pd.DataFrame: """Run the full pipeline and return the final waypoints DataFrame.""" mask = isolate_line_mask(image_path, target_rgb, tolerance) mask = keep_largest_component(mask) print(f"Isolated line: {mask.sum()} pixels") skel = skeletonize(mask) print(f"Skeletonized: {skel.sum()} pixels") path_px = order_skeleton_pixels(skel) coverage = len(path_px) / max(skel.sum(), 1) * 100 print(f"Ordered path: {len(path_px)} pixels ({coverage:.0f}% of the skeleton — " f"a low % here suggests a branchy/noisy skeleton worth checking visually, " f"e.g. by plotting the ordered path over the original image)") lon_coef, lat_coef = fit_pixel_to_geo_transform(reference_points) geo_df = pixels_to_geo(path_px, lon_coef, lat_coef) result = downsample_evenly(geo_df, n_points) print(f"Final centerline: {len(result)} waypoints") return result # ---------------------------------------------------------------------- # Reference points actually used for La Eure and La Risle in this project. # Read by eye off a pixel-gridded version of each map image, paired with # each town's known real-world coordinates. Kept here for reproducibility # and as a template for digitizing further rivers. # ---------------------------------------------------------------------- EURE_REFERENCE_POINTS = [ {"pixel_x": 200, "pixel_y": 45, "lon": 1.1667, "lat": 49.2167}, # Louviers {"pixel_x": 200, "pixel_y": 183, "lon": 1.1509, "lat": 49.0269}, # Evreux {"pixel_x": 290, "pixel_y": 325, "lon": 1.3625, "lat": 48.7377}, # Dreux {"pixel_x": 325, "pixel_y": 125, "lon": 1.4836, "lat": 49.0917}, # Vernon {"pixel_x": 295, "pixel_y": 495, "lon": 1.4894, "lat": 48.4439}, # Chartres {"pixel_x": 300, "pixel_y": 88, "lon": 1.3333, "lat": 49.1667}, # Gaillon {"pixel_x": 90, "pixel_y": 550, "lon": 0.8244, "lat": 48.3236}, # Nogent-le-Rotrou ] EURE_TARGET_RGB = [30, 144, 255] # "dodgerblue"-ish line color used in that map export RISLE_REFERENCE_POINTS = [ {"pixel_x": 55, "pixel_y": 42, "lon": 0.2333, "lat": 49.4197}, # Honfleur {"pixel_x": 175, "pixel_y": 78, "lon": 0.5136, "lat": 49.3547}, # Pont-Audemer {"pixel_x": 245, "pixel_y": 220, "lon": 0.7169, "lat": 49.1936}, # Brionne {"pixel_x": 178, "pixel_y": 250, "lon": 0.5981, "lat": 49.0906}, # Bernay {"pixel_x": 195, "pixel_y": 415, "lon": 0.6339, "lat": 48.7642}, # L'Aigle {"pixel_x": 55, "pixel_y": 200, "lon": 0.2306, "lat": 49.1447}, # Lisieux ] RISLE_TARGET_RGB = [0, 0, 205] # "mediumblue"-ish line color used in that map export def main(): parser = argparse.ArgumentParser(description="Digitize a traced river line into a centerline CSV") parser.add_argument("--image", type=Path, required=True) parser.add_argument("--target-rgb", type=int, nargs=3, required=True, metavar=("R", "G", "B"), help="RGB color of the traced line to isolate") parser.add_argument("--tolerance", type=float, default=40.0, help="Color-distance tolerance for isolating the line (default: 40)") parser.add_argument("--refs-json", type=Path, default=None, help="JSON file of reference points [{pixel_x, pixel_y, lon, lat}, ...]. " "If omitted, --preset must be used instead.") parser.add_argument("--preset", choices=["eure", "risle"], default=None, help="Use the built-in reference points for La Eure or La Risle " "(the ones used in this project) instead of --refs-json") parser.add_argument("--output", type=Path, required=True) parser.add_argument("--n-points", type=int, default=120) args = parser.parse_args() if args.preset == "eure": refs = EURE_REFERENCE_POINTS elif args.preset == "risle": refs = RISLE_REFERENCE_POINTS elif args.refs_json: refs = json.loads(args.refs_json.read_text()) else: parser.error("Provide either --refs-json or --preset eure|risle") result = extract_centerline(args.image, args.target_rgb, args.tolerance, refs, args.n_points) result.to_csv(args.output, index=False) print(f"Saved to {args.output}") if __name__ == "__main__": main()