File size: 10,523 Bytes
a74054f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
"""
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()