Visual positionning

Visual Positionning is a technique that uses one (or more) pictures and surrounding geolocated data to determine the location from which the picture was taken.

I found at least 2 open research work on the topic that could be used with Panoramax and openStreetMap data and allow to get a more accurate positionning than GPS alone giving us a way to find a more accurate geolocation for pictures especially in urban area where the GPS signals can be affected by buildings.

OrienterNet (2023) :

OSMloc (2024) :

MaplocNet

I’ve not looked into the detail but the process is globally the following:

  • depth estimation on the picture, with optional semantic segmentation to determine the type of objets
  • get surrouding OSM vector data, and build a virtual 3D twin
  • match both to find the camera location and heading

Here is OrenterNET :

and OSMloc:

One (or two) more thing to test !!

I’ll start with OSMloc… which seems to provide better results.

2 Likes

Ça serait intéressant de comparer avec les positions supposées précises des mesures GNSS différentielles. Typiquement les miennes (:rofl: ) avec une précision de positionnement 5* et prises en voiture où on doit avoir une orientation correcte (sans doute plus fiable qu’en vélo).

Maybe a few more interesting things here:

In Lyon where I live, getting fine-grained GPS localization is challenging, even with RTK equipment, due to the urban canyon effect. I currently adjust the position of each photo manually using high-resolution aerial imagery at 5cm/pixel from the Grand Lyon metropolitan area. Any automatic solution would save me hours of tedious work.

I started reading about what the research community calls cross-view geo-localization — using aerial imagery to refine street-level photo positioning. Since I already have a coarse GPS position (±10m), I’m specifically interested in “coarse-to-fine localization” approaches that can go from ~10m accuracy down to <1m.

So far I’ve read:

  1. VIGOR (CVPR 2021) — [2011.12172] VIGOR: Cross-View Image Geo-localization beyond One-to-one Retrieval
  2. CVSat + AuxGeo (2024) — [2412.11529] Cross-View Geo-Localization with Street-View and VHR Satellite Imagery in Decentrality Settings
  3. Sample4Geo (ICCV 2023) — [2303.11851] Sample4Geo: Hard Negative Sampling For Cross-View Geo-Localisation

OrienterNet and OSMLoc use OSM vector data. Do you know if they combine it with aerial imagery ? These seem complementary — OSM-based approaches generalize worldwide but depend on OSM completeness, while orthophoto-based approaches could be more precise in well-covered areas like Lyon.

Has anyone tried anything around cross-view geo-localization ?

PS : I can create a new forum post if that becomes a separate subject.

PPS : I get help from Claude but read everything myself

It’s not quite the visual positioning like the above but I thought it was interesting to explore!

If I have a 360 video, I can feed it into a visual simultaneous localization and mapping (vSLAM) application where it visually tracks the movement and maps the trajectory. It still will not know the position yet. So, it would need to be determined manually or based on some known good positions like when camera reports good GPS accuracy or RTK fix before/after entering an urban canyon. Then with known GPS locations, SLAM can be used to fill in the gaps of where GPS struggled.

I used stella_vslam which I tested with a 360 video. Since I didn’t want to get out of my seat, I used a New York City electric scooter sample. It can create a log file of frame trajectories. I manually found the coordinates then with a script that I generated to convert the trajectory to gpx, I can then map out the position of the video without the original GPS data. The result? The worst accuracy was only a few metres off for a 1.7 km route where the known coordinates were only provided at the start and end of the route.

Of course, more testing would be needed. In the sample, the rider only turned to another street once and it was in New York City where the roads are straight. I had some concern where moving trucks very close along with camera might cause tracking confusion but that was not the case. It can handle smaller movements well like when rider needed to go around a truck blocking the intersection. I tested at 10fps so for a car, a much higher framerate would likely be needed.

Some AI generated code for testing to convert the trajectory to gpx if anyone is interested.
#!/usr/bin/env python3
import argparse
import math
import xml.etree.ElementTree as ET
from datetime import datetime, timezone

R = 6378137.0


def parse_latlon_pair(value: str):
    try:
        lat_s, lon_s = value.split(",", 1)
        return float(lat_s), float(lon_s)
    except Exception:
        raise argparse.ArgumentTypeError("Expected format: lat,lon")


def unix_to_gpx_time(timestamp: float) -> str:
    dt = datetime.fromtimestamp(timestamp, tz=timezone.utc)
    return dt.isoformat(timespec="milliseconds").replace("+00:00", "Z")


def read_start_end_from_gpx(path):
    root = ET.parse(path).getroot()

    if "}" in root.tag:
        ns_uri = root.tag.split("}")[0].strip("{")
        ns = {"gpx": ns_uri}
        pts = root.findall(".//gpx:trkpt", ns) or root.findall(".//gpx:rtept", ns) or root.findall(".//gpx:wpt", ns)
    else:
        pts = root.findall(".//trkpt") or root.findall(".//rtept") or root.findall(".//wpt")

    if len(pts) < 2:
        raise RuntimeError("Reference GPX must contain at least 2 points")

    start = pts[0]
    end = pts[-1]

    return (
        float(start.attrib["lat"]),
        float(start.attrib["lon"]),
        float(end.attrib["lat"]),
        float(end.attrib["lon"]),
    )


def latlon_to_xy(lat, lon, lat0, lon0):
    x = math.radians(lon - lon0) * R * math.cos(math.radians(lat0))
    y = math.radians(lat - lat0) * R
    return x, y


def xy_to_latlon(x, y, lat0, lon0):
    lat = lat0 + math.degrees(y / R)
    lon = lon0 + math.degrees(x / (R * math.cos(math.radians(lat0))))
    return lat, lon


def read_stella_trajectory(path):
    poses = []

    with open(path, "r", encoding="utf-8") as f:
        for line in f:
            line = line.strip()

            if not line or line.startswith("#"):
                continue

            vals = list(map(float, line.split()))

            if len(vals) < 4:
                continue

            timestamp = vals[0]
            x = vals[1]
            y = vals[2]
            z = vals[3]

            poses.append((timestamp, x, y, z))

    if len(poses) < 2:
        raise RuntimeError("Trajectory must contain at least 2 valid poses")

    return poses


def choose_ground_plane(poses, plane):
    if plane == "xz":
        return [(p[1], p[3]) for p in poses]
    if plane == "xy":
        return [(p[1], p[2]) for p in poses]
    if plane == "zy":
        return [(p[3], p[2]) for p in poses]
    if plane == "zx":
        return [(p[3], p[1]) for p in poses]
    if plane == "negxz":
        return [(-p[1], p[3]) for p in poses]
    if plane == "xnegz":
        return [(p[1], -p[3]) for p in poses]

    raise ValueError(f"Unsupported plane: {plane}")


def convert_to_gpx(
    traj_path,
    out_path,
    start_lat,
    start_lon,
    end_lat,
    end_lon,
    plane="xz",
    elevation_axis="y",
    include_time=True,
):
    poses = read_stella_trajectory(traj_path)
    slam_pts = choose_ground_plane(poses, plane)

    sx0, sy0 = slam_pts[0]
    sx1, sy1 = slam_pts[-1]

    slam_dx = sx1 - sx0
    slam_dy = sy1 - sy0
    slam_len = math.hypot(slam_dx, slam_dy)

    if slam_len == 0:
        raise RuntimeError("SLAM start and end points are identical; cannot align")

    gx1, gy1 = latlon_to_xy(end_lat, end_lon, start_lat, start_lon)
    gps_len = math.hypot(gx1, gy1)

    if gps_len == 0:
        raise RuntimeError("GPS start and end points are identical; cannot align")

    scale = gps_len / slam_len

    slam_ang = math.atan2(slam_dy, slam_dx)
    gps_ang = math.atan2(gy1, gx1)
    rot = gps_ang - slam_ang

    gpx_points = []

    for pose, slam_pt in zip(poses, slam_pts):
        timestamp, x, y, z = pose
        px = slam_pt[0] - sx0
        py = slam_pt[1] - sy0

        rx = scale * (px * math.cos(rot) - py * math.sin(rot))
        ry = scale * (px * math.sin(rot) + py * math.cos(rot))

        lat, lon = xy_to_latlon(rx, ry, start_lat, start_lon)

        if elevation_axis == "x":
            ele = x * scale
        elif elevation_axis == "y":
            ele = y * scale
        elif elevation_axis == "z":
            ele = z * scale
        else:
            ele = 0.0

        gpx_points.append((lat, lon, ele, timestamp))

    with open(out_path, "w", encoding="utf-8") as f:
        f.write('<?xml version="1.0" encoding="UTF-8"?>\n')
        f.write(
            '<gpx version="1.1" creator="stella_vslam_to_gpx" '
            'xmlns="http://www.topografix.com/GPX/1/1">\n'
        )
        f.write("  <trk>\n")
        f.write("    <name>stella_vslam trajectory</name>\n")
        f.write("    <trkseg>\n")

        for lat, lon, ele, timestamp in gpx_points:
            f.write(f'      <trkpt lat="{lat:.8f}" lon="{lon:.8f}">\n')
            f.write(f"        <ele>{ele:.3f}</ele>\n")
            if include_time:
                f.write(f"        <time>{unix_to_gpx_time(timestamp)}</time>\n")
            f.write("      </trkpt>\n")

        f.write("    </trkseg>\n")
        f.write("  </trk>\n")
        f.write("</gpx>\n")

    print(f"Wrote: {out_path}")
    print(f"Points: {len(gpx_points)}")
    print(f"Scale factor: {scale}")
    print(f"Plane used: {plane}")
    print(f"Time included: {include_time}")


def main():
    parser = argparse.ArgumentParser(
        description="Convert stella_vslam frame_trajectory.txt to GPX using manual start/end GPS or a reference GPX."
    )

    parser.add_argument("--traj", required=True, help="Path to frame_trajectory.txt")
    parser.add_argument("--out", default="output.gpx", help="Output GPX path")

    group = parser.add_mutually_exclusive_group(required=True)
    group.add_argument(
        "--ref",
        help="Reference GPX. First point is start GPS, last point is end GPS.",
    )
    group.add_argument(
        "--start-end",
        nargs=2,
        metavar=("START_LAT,LON", "END_LAT,LON"),
        type=parse_latlon_pair,
        help="Manual start and end GPS, e.g. --start-end 12.3,123.4 12.3,123.4",
    )

    parser.add_argument(
        "--plane",
        default="xz",
        choices=["xz", "xy", "zy", "zx", "negxz", "xnegz"],
        help="Which SLAM axes to use as ground plane. Default: xz",
    )

    parser.add_argument(
        "--elevation-axis",
        default="y",
        choices=["x", "y", "z", "none"],
        help="Which SLAM axis to use as GPX elevation. Default: y",
    )

    parser.add_argument(
        "--no-time",
        action="store_true",
        help="Do not write GPX <time> elements.",
    )

    args = parser.parse_args()

    if args.ref:
        start_lat, start_lon, end_lat, end_lon = read_start_end_from_gpx(args.ref)
    else:
        start, end = args.start_end
        start_lat, start_lon = start
        end_lat, end_lon = end

    convert_to_gpx(
        traj_path=args.traj,
        out_path=args.out,
        start_lat=start_lat,
        start_lon=start_lon,
        end_lat=end_lat,
        end_lon=end_lon,
        plane=args.plane,
        elevation_axis=args.elevation_axis,
        include_time=not args.no_time,
    )


if __name__ == "__main__":
    main()

1 Like

So we had the opportunity to test OrenterNET with @cquest during the IGN Hackathon

In short,
the library is fast, very well documented, consumes very few ressources, and can even handle images without GPU.

Some learnings :

  • don’t send full 3D images. This has been trained with crops. We can extract 4 images from a 360 scene and average the 3 closer locations
  • Use 512px images, this is fast and this is the size of the image used in training sets
  • 256 px gives wrong results. > 800px consumes too much ressources for no gain
  • Using sequences is a lot better because it reduces the degrees of freedom in the context used
  • We can train our own dataset and run efficiency benchmarks, but it requires good GPUs (we didn’t test )
  • the code repo is kindof archived. Some pull request branches bring very interesting new features, but the repo should be revived or forked
  • If the environnement has very symetric real objects (building, roads etc..), it will diverge a lot more. But less with sequences. In rural areas with very few OSM objects, the re-positioning can’t be as good as in dense areas
  • It uses 40 classes of OSM ( buildings, roads, urban equipments etc..). Buildings provide more signal than the other classes. Trees could provide good signal but are not often mapped enough in OSM. We can freely configure the classes to use
  • The model is open weight, trained in many european cities and very robust. There is no lockin in training a new model or in evaluation datasets. Very nice.

One example of a picture that has a GPS drift of ~ 10m , with bad verticality

Here is the map with the positioning probalities and in black the proposed location. It is spot on the bike lane, where all the other panoramax images are.

What to do next ?

We don’t have real plans, but we feel a real potential to propose better geographic positionning.

We could :

  • run our own benchmark on RTK acquired pictures to qualify with panoramax specific high quality data.
  • Do some sampling to evaluate if we can spot sequences with very bad positioning and check how good repositioning is.

Side note, I discovered the concept of High Dimensionality Neural Maps, wich encode vector maps object into a high dimensional raster that seem to be very usable in this area. Really promising

5 Likes