Из скриншотов в GeoJSON
Обучение

Из скриншотов в GeoJSON

Вам когда-нибудь приходилось перебивать данные из скриншота обратно в таблицу? Нет, не скопировать — именно перебивать, потому что других исходных данных нет, только картинки. 

Руками перебивать 200+ таблиц по 20-25 точек в каждой — тоска зелёная.

Архитектура решения

Извлечение данных нейросетью

Используем Gemini Pro с жёстким промптом:

PROMPT = """
Извлеки данные из геодезической таблицы на изображении.
Верни ТОЛЬКО JSON без markdown-разметки и пояснений.

Формат:
{
 "points": [
   {
     "id": номер точки (integer),
     "msk89": {"x": float, "y": float},
     "wgs84": {"lat": "DD°MM'SS.SSSSSS\"", "lon": "DD°MM'SS.SSSSSS\""},
     "gsk2011": {"lat": "...", "lon": "..."},
     "pz90": {"lat": "...", "lon": "..."}
   }
 ],
 "metadata": {
   "total_rows": integer,
   "check": "проверь, что сумма градусов+минут/60+секунд/3600 логична для Ямало-Ненецкого округа"
 }
}

Правила:
- Для пустых ячеек используй null, не ноль
- Широта должна быть ~70-72°, долгота ~68-72°
- Если значение выглядит подозрительно — верни null, не додумывай
- Проверь монотонность id (1, 2, 3... без пропусков)
"""

def process_screenshot(path: Path) -> dict:
   with open(path, "rb") as f:
       b64 = base64.b64encode(f.read()).decode()
   
   # Использовал Gemini 1.5 Pro — дешевле и стабильнее для таблиц
   response = client.chat.completions.create(
       model="gemini-1.5-pro",
       messages=[
           {"role": "system", "content": "Ты — геодезический OCR-эксперт. Не додумывай данные. Если не видишь чётко — скажи null."},
           {"role": "user", "content": [
               {"type": "text", "text": PROMPT},
               {"type": "image_url", "image_url": {
                   "url": f"data:image/png;base64,{b64}",
                   "detail": "high"  # Критично! "low" даёт ошибки в цифрах
               }}
           ]}
       ],
       response_format={"type": "json_object"},
       temperature=0.0  # Детерминизм. Нет креативности в координатах.
   )
   
   return json.loads(response.choices[0].message.content)

  • temperature=0 — отключает «творчество», чтобы модель не «додумывала» цифру на размытых скриншотах
  • detail: "high" — без этого модель путает 3 и 8 в мелких цифрах
  • Проверка региона в промпте — модель сама валидирует логичность

Теперь у нас есть JSON с координатами в формате 70°16'15.85801200". GeoJSON хочет десятичные градусы. И ещё он хочет, чтобы мы не перепутали широту с долготой, а то точка улетит в Индийский океан.

Парсер DMS

import re
from decimal import Decimal, ROUND_HALF_UP

def dms_to_decimal(dms_str: str) -> float:
   """
   70°16'15.85801200" → 70.27107167
   Работает с разными разделителями: °'\" или пробелами
   """
   # Нормализуем всякий мусор
   cleaned = dms_str.replace('°', ' ').replace("'", ' ').replace('"', ' ')
   cleaned = re.sub(r'\s+', ' ', cleaned).strip()
   
   parts = cleaned.split()
   if len(parts) != 3:
       raise ValueError(f"Не могу распарсить DMS: {dms_str}")
   
   d, m, s = map(Decimal, parts)
   
   # Геодезические проверки
   assert 0 <= m < 60, f"Минуты вне диапазона: {m}"
   assert 0 <= s < 60, f"Секунды вне диапазона: {s}"
   
   decimal = d + m/Decimal('60') + s/Decimal('3600')
   return float(decimal.quantize(Decimal('0.00000001'), rounding=ROUND_HALF_UP))

Собираем итог: генератор GeoJSON

from dataclasses import dataclass
from typing import List

@dataclass
class GeodeticPoint:
   id: int
   msk89_x: float
   msk89_y: float
   wgs84_lat: float
   wgs84_lon: float
   gsk2011_lat: float
   gsk2011_lon: float
   pz90_lat: float
   pz90_lon: float
   source_file: str  # Чтобы отследить, откуда пришла точка

def create_geojson(points: List[GeodeticPoint], target_crs: str = "WGS84") -> dict:
   """
   target_crs: "WGS84" | "GSK2011" | "PZ90"
   GeoJSON geometry в выбранной СК, остальные — в properties
   """
   
   crs_fields = {
       "WGS84": ("wgs84_lat", "wgs84_lon", "EPSG:4326"),
       "GSK2011": ("gsk2011_lat", "gsk2011_lon", "EPSG:4979"),
       "PZ90": ("pz90_lat", "pz90_lon", "EPSG:4926")
   }
   
   lat_field, lon_field, epsg = crs_fields.get(target_crs, crs_fields["WGS84"])
   
   features = []
   for pt in points:
       lat = getattr(pt, lat_field)
       lon = getattr(pt, lon_field)
       
       # GeoJSON: [longitude, latitude] — порядок важен!
       feature = {
           "type": "Feature",
           "geometry": {
               "type": "Point",
               "coordinates": [lon, lat]
           },
           "properties": {
               "id": pt.id,
               "source_file": pt.source_file,
               # Все системы координат в properties на всякий случай
               "msk89": {"x": pt.msk89_x, "y": pt.msk89_y},
               "wgs84": {"lat": pt.wgs84_lat, "lon": pt.wgs84_lon},
               "gsk2011": {"lat": pt.gsk2011_lat, "lon": pt.gsk2011_lon},
               "pz90": {"lat": pt.pz90_lat, "lon": pt.pz90_lon}
           }
       }
       features.append(feature)
   
   return {
       "type": "FeatureCollection",
       "crs": {
           "type": "name",
           "properties": {"name": f"urn:ogc:def:crs:{epsg}"}
       },
       "features": features
   }

# Собираем всё вместе
def pipeline():
   screenshots = Path("./screenshots").glob("*.png")
   all_points = []
   
   for img_path in screenshots:
       try:
           raw_data = process_screenshot(img_path)
           
           for pt_data in raw_data["points"]:
               point = GeodeticPoint(
                   id=pt_data["id"],
                   msk89_x=pt_data["msk89"]["x"],
                   msk89_y=pt_data["msk89"]["y"],
                   wgs84_lat=dms_to_decimal(pt_data["wgs84"]["lat"]),
                   wgs84_lon=dms_to_decimal(pt_data["wgs84"]["lon"]),
                   gsk2011_lat=dms_to_decimal(pt_data["gsk2011"]["lat"]),
                   gsk2011_lon=dms_to_decimal(pt_data["gsk2011"]["lon"]),
                   pz90_lat=dms_to_decimal(pt_data["pz90"]["lat"]),
                   pz90_lon=dms_to_decimal(pt_data["pz90"]["lon"]),
                   source_file=img_path.name
               )
               all_points.append(point)
               
       except Exception as e:
           print(f"✗ {img_path.name}: {e}")
           continue
   
   # Генерируем три версии для разных СК
   for crs in ["WGS84", "GSK2011", "PZ90"]:
       geojson = create_geojson(all_points, target_crs=crs)
       output = Path(f"points_{crs.lower()}.geojson")
       
       with open(output, "w", encoding="utf-8") as f:
           json.dump(geojson, f, ensure_ascii=False, indent=2)
       
       print(f"✓ {output}: {len(geojson['features'])} точек")

if __name__ == "__main__":
   pipeline()

На выходе получаем GeoJSON, который загружаем в нашу ГИС для дальнейшего использования. Время на разработку пайплайна — 4 часа, время обработки данных — 12 минут, плюс час на ручную валидацию и пропуски против оценочного времени ручного ввода 40+ часов.

Если у вас есть рутинная задача с числами, которая занимает неделю — потратьте полдня на автоматизацию. И ещё полдня на валидацию, потому что нейросети иногда врут. Но лучше потратить день на код, чем неделю на перебивание 70°16'15.85801200".