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

Руками перебивать 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".