[Python] Why every 4-word address decoding outcome in my Flask app is East Antarctica, ATA?

Discussão em 'Python' iniciado por Stack, Maio 15, 2025.

  1. Stack

    Stack Membro Participativo

    I'm working on a Flask-based geolocation tool that encodes (lat, lon) coordinates into four words, using a custom grid system over land and water. Here's a simplified version of the code:

    import math
    import csv
    from typing import List, Tuple, Dict

    import geopandas as gpd
    from shapely.geometry import Point
    from flask import Flask, request, render_template
    from geopy.geocoders import ArcGIS

    # ------------------------------------------------------------------------------
    # FLASK APP INIT
    # ------------------------------------------------------------------------------

    app = Flask(__name__)
    geolocator = ArcGIS()

    # ------------------------------------------------------------------------------
    # CONSTANTS
    # ------------------------------------------------------------------------------

    GRID_SIZE_LAND = 5 # metres
    GRID_SIZE_WATER = 10 # metres

    NUM_LAT_CELLS_LAND = int(180_000_000 / GRID_SIZE_LAND)
    NUM_LON_CELLS_LAND = int(360_000_000 / GRID_SIZE_LAND)

    NUM_LAT_CELLS_WATER = int(180_000_000 / GRID_SIZE_WATER)
    NUM_LON_CELLS_WATER = int(360_000_000 / GRID_SIZE_WATER)

    TOTAL_LAND_CELLS = NUM_LAT_CELLS_LAND * NUM_LON_CELLS_LAND
    LEGACY_SPLIT = 10 ** 12 # old 4-digit prefix boundary

    # ------------------------------------------------------------------------------
    # WORD LIST
    # ------------------------------------------------------------------------------

    def load_words_from_csv(filename: str) -> Tuple[List[str], Dict[str, int]]:
    """Return a de-duplicated word list and lookup table."""
    words: List[str] = []
    lookup: Dict[str, int] = {}

    with open(filename, newline="") as fh:
    for row in csv.DictReader(fh):
    w = row["word"].strip().lower()
    if w and w not in lookup: # drop blanks & duplicates
    lookup[w] = len(words)
    words.append(w)

    return words, lookup

    WORDS, WORD_TO_INDEX = load_words_from_csv("data/fourwords_wordlist.csv")
    BASE = len(WORDS)

    # ------------------------------------------------------------------------------
    # LAND / WATER TEST
    # ------------------------------------------------------------------------------

    land_union = gpd.read_file("data/ne_50m_land.shp").unary_union

    def is_land(lat: float, lon: float) -> bool:
    return land_union.covers(Point(lon, lat))

    # ------------------------------------------------------------------------------
    # ENCODING
    # ------------------------------------------------------------------------------

    def latlon_to_cell(lat: float, lon: float) -> int:
    """Current contiguous ID scheme (land first, then water)."""
    grid = GRID_SIZE_LAND if is_land(lat, lon) else GRID_SIZE_WATER

    if grid == GRID_SIZE_LAND:
    lat_i = int((lat + 90) * 1e6 // GRID_SIZE_LAND)
    lon_i = int((lon + 180) * 1e6 // GRID_SIZE_LAND)
    return lat_i * NUM_LON_CELLS_LAND + lon_i
    else: # water
    lat_i = int((lat + 90) * 1e6 // GRID_SIZE_WATER)
    lon_i = int((lon + 180) * 1e6 // GRID_SIZE_WATER)
    return TOTAL_LAND_CELLS + lat_i * NUM_LON_CELLS_WATER + lon_i

    def encode(lat: float, lon: float) -> List[str]:
    cell_id = latlon_to_cell(lat, lon)
    digits = [(cell_id // BASE ** i) % BASE for i in range(3, -1, -1)] # MSB→LSB
    return [WORDS[d] for d in digits]

    # ------------------------------------------------------------------------------
    # DECODING
    # ------------------------------------------------------------------------------

    def _decode_current(cell_id: int) -> Tuple[float, float]:
    """Decode using the current contiguous ID scheme."""
    if cell_id < TOTAL_LAND_CELLS:
    grid = GRID_SIZE_LAND
    lon_cells = NUM_LON_CELLS_LAND
    core_id = cell_id
    else:
    grid = GRID_SIZE_WATER
    lon_cells = NUM_LON_CELLS_WATER
    core_id = cell_id - TOTAL_LAND_CELLS

    lat_i = core_id // lon_cells
    lon_i = core_id % lon_cells

    lat = (lat_i * grid) / 1e6 - 90 + grid / (2 * 1e6)
    lon = (lon_i * grid) / 1e6 - 180 + grid / (2 * 1e6)
    return lat, lon

    def _decode_legacy(cell_id: int) -> Tuple[float, float]:
    """Decode a legacy ID using the 10¹² prefix trick."""
    prefix = cell_id // LEGACY_SPLIT
    core_id = cell_id % LEGACY_SPLIT

    if prefix not in (0, 1):
    raise ValueError

    grid = GRID_SIZE_LAND if prefix == 0 else GRID_SIZE_WATER
    lon_cells = NUM_LON_CELLS_LAND if prefix == 0 else NUM_LON_CELLS_WATER

    lat_i = core_id // lon_cells
    lon_i = core_id % lon_cells

    lat = (lat_i * grid) / 1e6 - 90 + grid / (2 * 1e6)
    lon = (lon_i * grid) / 1e6 - 180 + grid / (2 * 1e6)
    return lat, lon

    def decode(words: List[str]) -> Tuple[float, float]:
    """Decode four words to (lat, lon)."""
    try:
    digits = [WORD_TO_INDEX[w] for w in words]
    except KeyError as e:
    raise ValueError(f"Unknown word in code: {e.args[0]}") from None

    cell_id = 0
    for d in digits:
    cell_id = cell_id * BASE + d

    lat, lon = _decode_current(cell_id)

    if lat < -89.82: # likely bogus, near South Pole
    try:
    lat, lon = _decode_legacy(cell_id)
    except ValueError:
    pass

    return lat, lon

    # ------------------------------------------------------------------------------
    # UTILITIES
    # ------------------------------------------------------------------------------

    def format_words(words: List[str]) -> str:
    return ".".join(words)

    def parse_words(code: str) -> List[str]:
    return code.lower().strip().split(".")

    def resolve_input(query: str):
    query = query.strip()

    if "." in query and len(query.split(".")) == 4:
    return "decode", query.lower().split(".")

    try:
    lat, lon = map(float, query.split(","))
    return "encode", (lat, lon)
    except:
    pass

    location = geolocator.geocode(query)
    if location:
    return "encode", (location.latitude, location.longitude)

    raise ValueError("Input could not be recognized.")`

    The Issue


    The encode() function returns a list of four words, based on a calculated cell_id. However, when I decode those four words back using decode(), the resulting (lat, lon) coordinates are often significantly different from the original ones—sometimes off by several kilometers or more.

    What I’ve Tried


    • Ensured word list has no duplicates: I'm deduplicating the word list and building a clean word → index mapping.


    • Tried different word list sizes (e.g. 10000, 20000 words): BASE is recalculated accordingly, and everything downstream updates as expected.


    • Tested both legacy and current decoding logic: For IDs < 10¹², I treat them as current format; others fall back to the legacy decode logic. No success yet.


    • Verified latlon_to_cell() and _decode_current() mapping symmetry: Still seems like cell_id → words → cell_id is not invertible, which suggests either:
      • Off-by-one errors in grid math
      • BASE conversion misalignment (MSB/LSB ordering, overflow)
      • Word index ordering issue

    • Tested with known points (e.g. Eiffel Tower, (48.8584, 2.2945)) Encoding gives reasonable-looking words, but decoding gives a location ~200m off.


    • Asked ChatGPT, Gemini: Got suggestions about endian issues and BASE math, but nothing resolved it yet.
    Questions

    1. Is there something obviously wrong in how I convert cell_id → words and back?
    2. Could there be an off-by-one or rounding error in the grid size calculations?
    3. Is there a better way to organize the land/water split without affecting the cell_id math?
    4. Is decode() failing silently and returning a "fallback" coordinate without throwing?
    Repro Steps


    You can test with:

    lat, lon = 48.8584, 2.2945
    words = encode(lat, lon)
    decoded = decode(words)
    print(f"Original: {lat}, {lon} → Encoded: {format_words(words)} → Decoded: {decoded}")


    If the decoded location isn’t within a few meters of the original, then something is breaking down.

    Continue reading...

Compartilhe esta Página