Files
rond-with-you/tools/photo_coord_audit.py
2026-09-18 13:05:49 +08:00

355 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""照片导入的坐标体检与批量换算。
为什么需要它:导入页把「用户选的坐标系」交给 /api/regeo(coord 字段),服务端只对 wgs84
做换算,坐标再 toFixed(6) 回填表单落库。选错一次,显示坐标就一字不差等于 EXIF 原值,在
GCJ-02 底图上偏 350~680m。库里没有 created_at,也没记录导入时的坐标系选择,所以判据只有
原图 EXIF:
库内坐标 == round(EXIF, 6) -> 没转换,需要修
库内坐标 == gcj(round(EXIF, 6)) -> 已转换,正确
(实测 24/24 命中,0 反例)
没有原图时退化为「sp > rond 基线上限 = 站点新建」,只能靠 --include-unverified 才动。
用法:
python tools/photo_coord_audit.py --site https://life.shiroko.one --cookie "uid|tok" \
--photos D:/photos/libo --photos D:/photos/foshan --out fix.csv
# 确认清单后加 --apply
"""
import argparse
import csv
import html as htmllib
import io
import json
import math
import os
import re
import socket
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
# 生产机只监听 IPv4:默认解析出 IPv6 会先撞在超时上,而 urllib 不会自动回退
_gai = socket.getaddrinfo
def _gai_prefer_v4(host, port, family=0, type=0, proto=0, flags=0):
infos = _gai(host, port, family, type, proto, flags)
v4 = [i for i in infos if i[0] == socket.AF_INET]
return v4 or infos
socket.getaddrinfo = _gai_prefer_v4
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) rond-coord-audit"
FORM_RE = re.compile(r'<form method="post" action="/admin/edit/place/update".*?</form>', re.S)
ORIG_RE = re.compile(r'导入时的坐标:<span class="mono">([-0-9.]+),\s*([-0-9.]+)</span>')
class _NoRedirect(urllib.request.HTTPRedirectHandler):
# 保存成功是 303 + Location 里带 ok=/err=,跟过去就看不到结论了;
# 会话失效也是 303 跳登录页,跟着走会静默拿到登录页 HTML 当成空数据
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
_opener = urllib.request.build_opener(_NoRedirect)
def http(url, cookie="", data=None, timeout=60, tries=3):
# 走 Cloudflare 时长页面偶发读超时,重试比直接失败划算;写库的重试由调用方控
last = None
for i in range(tries):
req = urllib.request.Request(url, data=data)
req.add_header("User-Agent", UA)
if cookie:
req.add_header("Cookie", "rond_session=" + cookie)
if data is not None:
req.add_header("Content-Type", "application/x-www-form-urlencoded")
try:
with _opener.open(req, timeout=timeout) as r:
return r.status, dict(r.headers), r.read()
except urllib.error.HTTPError as e:
return e.code, dict(e.headers), e.read()
except Exception as e: # 超时 / 连接被掐
last = e
if i + 1 < tries:
time.sleep(1.5 * (i + 1))
raise RuntimeError("请求失败 %s:%s" % (url, last))
def field(block, name):
m = re.search(r'name="%s"[^>]*?value="([^"]*)"' % re.escape(name), block)
return htmllib.unescape(m.group(1)) if m else ""
def parse_places(page_html):
out = []
for block in FORM_RE.findall(page_html):
src = field(block, "src_pk")
if not src.isdigit():
continue
cat = ""
sel = re.search(r'<select name="category".*?</select>', block, re.S)
if sel:
cur = re.search(r'<option value="([^"]*)" selected>', sel.group(0))
cat = htmllib.unescape(cur.group(1)) if cur else ""
p = {
"src_pk": int(src),
"name": field(block, "name"),
"category": cat,
"lat": field(block, "lat"),
"lon": field(block, "lon"),
"province": field(block, "province"),
"city": field(block, "city"),
"district": field(block, "district"),
}
mo = ORIG_RE.search(block)
p["orig"] = (mo.group(1) + "," + mo.group(2)) if mo else ""
out.append(p)
return out
def fetch_places(site, cookie, verbose=True):
"""翻页读完整个地点列表(含被区域黑名单/迷雾遮罩挡住、公开接口看不到的点)。"""
places, seen, page = [], set(), 1
while page <= 200:
status, _, body = http("%s/admin/edit?page=%d" % (site, page), cookie)
if status != 200:
raise SystemExit("读 /admin/edit?page=%d 失败:HTTP %d(会话过期?)" % (page, status))
chunk = parse_places(body.decode("utf-8", "replace"))
chunk = [p for p in chunk if p["src_pk"] not in seen]
if not chunk:
break
for p in chunk:
seen.add(p["src_pk"])
places.extend(chunk)
if verbose:
sys.stderr.write("\r读取地点列表… 第 %d 页,累计 %d 个" % (page, len(places)))
sys.stderr.flush()
page += 1
if verbose:
sys.stderr.write("\n")
return places
def wgs2gcj(lat, lon):
a, ee = 6378245.0, 0.00669342162296594323
def tl(x, y):
r = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * math.sqrt(abs(x))
r += (20.0 * math.sin(6 * x * math.pi) + 20.0 * math.sin(2 * x * math.pi)) * 2 / 3
r += (20.0 * math.sin(y * math.pi) + 40.0 * math.sin(y / 3 * math.pi)) * 2 / 3
r += (160.0 * math.sin(y / 12 * math.pi) + 320 * math.sin(y * math.pi / 30)) * 2 / 3
return r
def tg(x, y):
r = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * math.sqrt(abs(x))
r += (20.0 * math.sin(6 * x * math.pi) + 20.0 * math.sin(2 * x * math.pi)) * 2 / 3
r += (20.0 * math.sin(x * math.pi) + 40.0 * math.sin(x / 3 * math.pi)) * 2 / 3
r += (150.0 * math.sin(x / 12 * math.pi) + 300.0 * math.sin(x / 30 * math.pi)) * 2 / 3
return r
dl, dn = tl(lon - 105.0, lat - 35.0), tg(lon - 105.0, lat - 35.0)
rl = lat / 180 * math.pi
m = math.sin(rl)
m = 1 - ee * m * m
sm = math.sqrt(m)
return (round(lat + (dl * 180) / ((a * (1 - ee)) / (m * sm) * math.pi), 6),
round(lon + (dn * 180) / (a / sm * math.cos(rl) * math.pi), 6))
def haversine(a, b, c, d):
R = 6371.0088
p1, p2 = math.radians(a), math.radians(c)
dp, dl = math.radians(c - a), math.radians(d - b)
x = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
return 2 * R * math.asin(math.sqrt(x)) * 1000
def read_exif(dirs):
"""读出照片目录里所有带 GPS 的坐标(四舍五入到 6 位,与导入页 toFixed(6) 对齐)。"""
try:
from PIL import Image
except ImportError:
raise SystemExit("需要 Pillow:pip install pillow")
try:
import pillow_heif
pillow_heif.register_heif_opener()
except ImportError:
pass
pts, skipped = [], 0
for root_dir in dirs:
for root, _, files in os.walk(root_dir):
for fn in sorted(files):
if not fn.lower().endswith((".jpg", ".jpeg", ".heic", ".heif", ".png")):
continue
path = os.path.join(root, fn)
try:
ex = Image.open(path).getexif()
g = ex.get_ifd(34853)
la, lo = g.get(2), g.get(4)
if not la or not lo:
skipped += 1
continue
lat = float(la[0]) + float(la[1]) / 60 + float(la[2]) / 3600
lon = float(lo[0]) + float(lo[1]) / 60 + float(lo[2]) / 3600
if str(g.get(1)).upper().startswith("S"):
lat = -lat
if str(g.get(3)).upper().startswith("W"):
lon = -lon
pts.append({"file": fn, "lat": round(lat, 6), "lon": round(lon, 6),
"taken": (ex.get(36867) or ex.get(306) or "")})
except Exception:
skipped += 1
return pts, skipped
def classify(place, photos):
try:
lat, lon = round(float(place["lat"]), 6), round(float(place["lon"]), 6)
except ValueError:
return "invalid", None
for ph in photos:
if ph["lat"] == lat and ph["lon"] == lon:
return "raw", ph
for ph in photos:
if wgs2gcj(ph["lat"], ph["lon"]) == (lat, lon):
return "gcj", ph
return "unknown", None
def api_coords(site, cookie, lat, lon):
q = urllib.parse.urlencode({"mode": "wgs2gcj", "lat": lat, "lon": lon})
status, _, body = http("%s/api/coords?%s" % (site, q), cookie)
if status != 200:
raise RuntimeError("换算接口 HTTP %d" % status)
d = json.loads(body.decode("utf-8"))
if d.get("error"):
raise RuntimeError(d["error"])
return d
def save_place(site, cookie, p, lat, lon):
form = urllib.parse.urlencode({
"src_pk": p["src_pk"], "name": p["name"], "category": p["category"],
"lat": "%.6f" % lat, "lon": "%.6f" % lon,
"province": p["province"], "city": p["city"], "district": p["district"],
}).encode("utf-8")
status, headers, _ = http("%s/admin/edit/place/update" % site, cookie, data=form, timeout=60)
loc = headers.get("Location", "")
if status not in (302, 303):
return False, "HTTP %d" % status
if "err=" in loc:
return False, urllib.parse.unquote(loc.split("err=")[-1])
return True, urllib.parse.unquote(loc.split("ok=")[-1]) if "ok=" in loc else "ok"
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--site", required=True, help="站点根地址,如 https://life.shiroko.one")
ap.add_argument("--cookie", default=os.environ.get("ROND_COOKIE", ""),
help="rond_session 的值(也可用环境变量 ROND_COOKIE)")
ap.add_argument("--photos", action="append", default=[], help="原图目录,可重复")
ap.add_argument("--baseline-max-sp", type=int, default=323,
help="rond 基线里 ZLOCATION 的 Z_PK 上限,大于它的都是站点新建")
ap.add_argument("--out", default="photo-coord-fix.csv")
ap.add_argument("--apply", action="store_true", help="真的写库(不加则只出清单)")
ap.add_argument("--include-unverified", action="store_true",
help="连没有原图可核对的点一起换算(风险自负)")
ap.add_argument("--only", default="", help="只处理这些 src_pk,逗号分隔")
ap.add_argument("--sleep", type=float, default=0.15, help="每次写库之间的间隔秒数")
args = ap.parse_args()
site = args.site.rstrip("/")
if not args.cookie:
raise SystemExit("缺少 --cookie(浏览器登录后复制 rond_session 的值)")
places = fetch_places(site, args.cookie)
created = [p for p in places if p["src_pk"] > args.baseline_max_sp]
print("地点总数 %d,其中站点新建(sp > %d)%d 个"
% (len(places), args.baseline_max_sp, len(created)))
photos = []
if args.photos:
photos, skipped = read_exif(args.photos)
print("原图:读到 %d 张带 GPS 的照片(%d 张无 GPS/读不了)" % (len(photos), skipped))
rows = []
for p in created:
verdict, ph = classify(p, photos)
new = wgs2gcj(round(float(p["lat"]), 6), round(float(p["lon"]), 6))
moved = haversine(round(float(p["lat"]), 6), round(float(p["lon"]), 6), new[0], new[1])
rows.append({**p, "verdict": verdict, "photo": ph["file"] if ph else "",
"new_lat": new[0], "new_lon": new[1], "moved": moved})
todo = [r for r in rows if r["verdict"] == "raw"]
if args.include_unverified:
todo += [r for r in rows if r["verdict"] == "unknown"]
if args.only:
want = {int(x) for x in args.only.replace(" ", "").split(",") if x}
todo = [r for r in todo if r["src_pk"] in want]
n_raw = sum(1 for r in rows if r["verdict"] == "raw")
n_gcj = sum(1 for r in rows if r["verdict"] == "gcj")
n_unk = sum(1 for r in rows if r["verdict"] == "unknown")
print("判定:没转换 %d | 已正确 %d | 无原图可核 %d" % (n_raw, n_gcj, n_unk))
with io.open(args.out, "w", encoding="utf-8-sig", newline="") as f:
w = csv.writer(f)
w.writerow(["src_pk", "名称", "城市", "判定", "原图", "现在纬度", "现在经度",
"换算后纬度", "换算后经度", "移动(米)"])
for r in sorted(rows, key=lambda x: x["src_pk"]):
w.writerow([r["src_pk"], r["name"], r["city"],
{"raw": "没转换", "gcj": "已正确", "unknown": "无原图", "invalid": "坐标无效"}[r["verdict"]],
r["photo"], r["lat"], r["lon"],
"%.6f" % r["new_lat"], "%.6f" % r["new_lon"], "%.0f" % r["moved"]])
print("清单已写:%s" % args.out)
for r in todo[:10]:
print(" sp=%-5s %-20s %s,%s → %.6f,%.6f (%.0fm)"
% (r["src_pk"], r["name"][:18], r["lat"], r["lon"], r["new_lat"], r["new_lon"], r["moved"]))
if len(todo) > 10:
print(" …共 %d 条" % len(todo))
if not args.apply:
print("\n[dry-run] 加 --apply 才会写库。")
return
if not todo:
print("\n没有需要处理的点。")
return
applied, failed = [], []
for i, r in enumerate(todo, 1):
try:
d = api_coords(site, args.cookie, r["lat"], r["lon"])
ok, msg = save_place(site, args.cookie, r, d["lat"], d["lon"])
except Exception as e:
ok, msg, d = False, str(e), None
if ok:
applied.append({**r, "applied_lat": "%.6f" % d["lat"], "applied_lon": "%.6f" % d["lon"]})
sys.stderr.write("\r已改 %d/%d sp=%s %s " % (i, len(todo), r["src_pk"], msg[:40]))
else:
failed.append({**r, "err": msg})
sys.stderr.write("\r失败 %d/%d sp=%s %s " % (i, len(todo), r["src_pk"], msg[:40]))
sys.stderr.flush()
time.sleep(args.sleep)
print()
log = args.out + ".applied.csv"
with io.open(log, "w", encoding="utf-8-sig", newline="") as f:
w = csv.writer(f)
w.writerow(["src_pk", "名称", "原纬度", "原经度", "新纬度", "新经度", "回退用经度", "回退用纬度"])
for r in applied:
w.writerow([r["src_pk"], r["name"], r["lat"], r["lon"],
r["applied_lat"], r["applied_lon"], r["lat"], r["lon"]])
print("成功 %d 条,失败 %d 条;回退清单:%s" % (len(applied), len(failed), log))
for r in failed:
print(" 失败 sp=%s %s:%s" % (r["src_pk"], r["name"][:16], r["err"]))
if __name__ == "__main__":
main()