当前位置:首页>python>编程自动化 · Python 把几百张无人机照片自动变成点位图和飞行台账

编程自动化 · Python 把几百张无人机照片自动变成点位图和飞行台账

  • 2026-09-10 04:28:26
编程自动化 · Python 把几百张无人机照片自动变成点位图和飞行台账

一、做完你能拿到什么(先看成果)

学完这一篇,你能把一个文件夹里几百张无人机航拍照片,自动变成两样东西:

  1. 点位图——每张照片拍在哪一目了然,能直接拖进 QGIS / 谷歌地球打开;
  2. 飞行台账 Excel——按"架次"自动分组:哪趟飞了几张、几点到几点、中心坐标、覆盖范围。

全程零手工登记,外业回来把卡一插、命令一敲,台账就出来了。

适用人群:有航测 / 无人机照片管理需求的外业、内业、项目负责人;只要你会开命令行、装过 Python 包,就能跟下来。脚本已经实跑通过,照抄即可。


二、准备清单(逐项对一下)

项目
内容
备注
运行环境
Python 3.10+(教程实测 3.13.12)
3.8+ 都行,别用太老的
必装库
pillow
、piexif、openpyxl
读 EXIF / 写 Excel 用
可选库
pyshp
想导出 .shp 再装
输入数据
一个装满 .jpg 的文件夹
大疆等无人机默认把 GPS 写进 EXIF,无需额外文件
前置知识
会双击运行 .py、知道"命令行 cd 到目录"
不需要会写代码

三、操作步骤(逐条来,别跳)

步骤 1 装环境(约 5 分钟)

菜单 / 操作:打开终端(Windows 用 PowerShell 或 cmd),逐行执行下面命令。

# 1) 先确认 Python 版本(要 3.8 以上)python --version# 2) 装三个库(国内慢就加清华源,见易错点)pip install pillow piexif openpyxl# 3) 验证装上了pip show pillow piexif openpyxl | findstr /i version

预期结果:第 3 行能看到三个库的版本号,例如 Version: 12.3.0(pillow)、Version: 3.1.5(openpyxl)。

就地易错提醒:

  • ① 你电脑上可能有两个 Python,装错环境。验证用 python -m pip show pillow 而不是裸 pip,确保装进当前这个解释器。
  • ② 国内 pip 慢/超时,加源:pip install -i https://pypi.tuna.tsinghua.edu.cn/simple pillow piexif openpyxl。
  • ③ Windows PowerShell 执行策略可能挡脚本,用普通 cmd 最稳。

终端里 python --version 的回显 + pip show pillow 的 Version: 那一行。标注:红框圈出 Python 版本号、pillow 版本号,确认"≥3.8 且三个库都在"。


步骤 2 放好脚本和照片

菜单 / 操作:

  1. 把配套脚本 2026-08-16_无人机照片台账脚本.py 存到任意目录(比如 D:/航测/)。
  2. 把你的航拍照片平铺放进一个文件夹,例如 D:/航测/2026宜昌。

就地易错提醒:

  • ① 脚本只认 .jpg / .jpeg,其余文件(.png 屏摄、.MP4、.RAW)会被自动跳过,不报错但也不进点位图。
  • ② 本期不递归:子文件夹里的照片不会被扫描,要先把照片平铺到一个文件夹。
  • ③ 照片必须是原片——从相机卡 / 无人机存储里拷出来的,别用微信转发过的(见常见坑第 1 条)。

步骤 3 先用 --demo 自测(1 分钟,零成本验证链路通不通)

菜单 / 操作:在脚本所在目录执行:

python 2026-08-16_无人机照片台账脚本.py --demo

这条命令会自动造 12 张带 GPS 的假照片(2 个架次),然后跑完整流程,用来确认你的环境、库、路径全都 OK,再上真实数据。

预期结果(教程实跑输出,供你对照):

扫描到 12 张照片(按扩展名筛选)有效带 GPS 照片:12 张,聚成 2 个架次  架次1:7 张,10:00:00~10:06:00,中心 (30.60060, 114.30090)  架次2:5 张,11:00:00~11:04:00,中心 (30.61040, 114.31060)成果已写入:...\demo_output  - points.kml / points.geojson / points.csv (点位图)  - 飞行台账.xlsx (按架次汇总)  - 异常清单.txt

生成的 demo_output/points.csv 前几行(经度、纬度、海拔、时间自动提出来了):

文件名,经度,纬度,海拔(米),拍摄时间DJI_000.JPG,114.300000,30.600000,120.0,2026-08-16 10:00:00DJI_001.JPG,114.300300,30.600200,121.0,2026-08-16 10:01:00DJI_010.JPG,114.310000,30.610000,130.0,2026-08-16 11:00:00

就地易错提醒:如果这里就报 ModuleNotFoundError,回去重做步骤 1;如果照片数是 0,检查扩展名是不是大写 .JPG(脚本已兼容,但 .PNG 确实不认)。

终端跑完 --demo 后的完整回显(含"聚成 2 个架次"那几行)+ 文件管理器里 demo_output 文件夹展开(看到 points.kml / geojson / csv / xlsx / 异常清单.txt 五个文件)。标注:黄框圈出"有效带 GPS 照片:12 张,聚成 2 个架次",蓝框圈出五个成果文件。


步骤 4 跑你自己的照片

菜单 / 操作:把 demo 换成真实路径:

python 2026-08-16_无人机照片台账脚本.py -i ”D:/航测/2026宜昌” -o ”D:/航测/成果”

参数说明:-i 照片文件夹,-o 成果输出文件夹(不存在会自动建)。

预期结果:终端打印 扫描到 N 张照片 → 有效带 GPS 照片:M 张,聚成 K 个架次 → 成果写入 -o 目录。

就地易错提醒:

  • ① 路径含空格或中文必须加英文双引号(上面已加),否则脚本把空格当成参数分隔符,报"找不到文件"。
  • ② Windows 路径分隔符用 / 或 \\,别用单 \(会被当转义)。
  • ③ 如果 有效带 GPS 照片 远少于总张数,说明很多照片没 GPS——看步骤 7 的异常清单处理。

步骤 5 读懂四种产物

文件
怎么用
给谁看
points.kml
直接拖进 Google 地球 / 拖进 QGIS 图层
自己快速看分布
points.geojson
QGIS、ArcGIS、网页地图都能吃
二次处理 / 共享
points.csv
Excel 直接开,最朴素
发给不懂 GIS 的领导/同事
飞行台账.xlsx
两张表:飞行台账(按架次)+ 全部点位明细
归档、汇报
异常清单.txt
列无 GPS / 坐标跑飞的照片
补拍 / 补坐标

飞行台账.xlsx 的"飞行台账"表长这样(教程实跑,2 架次示例):

架次 | 照片张数 | 开始时间 | 结束时间 | 中心纬度 | 中心经度 | 纬度跨度 | 经度跨度 | 最低海拔 | 最高海拔1    | 7       | 10:00:00 | 10:06:00 | 30.60060 | 114.30090 | 0.00120 | 0.00180 | 120.0   | 126.02    | 5       | 11:00:00 | 11:04:00 | 30.61040 | 114.31060 | 0.00080 | 0.00120 | 130.0   | 134.0

【截图标注区块 · S5】 请截:QGIS 里把 points.kml 拖进去后的画面。标注:① 红框圈出左侧"图层"面板里出现的 points 图层;② 黄框圈出右侧"坐标/比例尺"显示,确认点位经纬度正确;③ 蓝框圈出某个点位点击后弹出的属性(文件名 + 海拔 + 时间)。这一步证明"照片→点位"真的通了。


步骤 6 架次怎么切?调 -g 阈值

脚本默认30 分钟:两张照片拍摄间隔 > 30 分钟,就判定是下一架次。但外业节奏千差万别——连飞间隔短、转场间隔长。用 -g 调:

# 外业连飞、间隔很短,用 10 分钟切分python 2026-08-16_无人机照片台账脚本.py -i ”D:/航测/2026宜昌” -o ”D:/航测/成果” -g 10
  • 间隔普遍 < 10 分钟 → -g 10 把一趟完整飞行归到一个架次;
  • 转场慢、间隔大 → -g 60 避免把两次飞行并错。

原理:脚本先按拍摄时间排序,相邻两张时间差 ≤ 阈值就并入当前架次,超过就开新架次。下面的交互演示可以拖动阈值,直观看照片怎么被切开。


步骤 7 异常清单处理 + 补坐标

异常清单.txt 里列的是无 GPS或坐标跑飞(纬度不在 -90~90 之类)的照片,它们不会进点位图。

没 GPS 的常见原因:

  1. 无人机设置里"记录位置"没开;
  2. 照片被微信 / 网盘 / 缩略图工具转过码,EXIF 被抹掉(最常见!);
  3. 是 .png 屏摄,不是相机原片。

补救:回原始存储卡找原片;若只有一份"相机位点表"(经纬度 Excel),可以另写脚本按文件名匹配打点——这块留作下期进阶。


四、完整脚本(已实跑,直接存成 无人机照片台账.py 即可)

# -*- coding: utf-8 -*-”””无人机照片 -> 点位图 + 飞行台账依赖:pip install pillow piexif openpyxl   (可选 shp:pip install pyshp)用法:    真实:python drone_log.py -i ”照片文件夹” -o ”成果文件夹”    自测:python drone_log.py --demo”””import os, csv, json, math, argparse, datetimefrom PIL import Imageimport piexifimport openpyxldef _ratio_to_float(rational):    ”””piexif 读出来是 (分子, 分母) 元组,转浮点。”””    num, den = rational    return 0.0 if den == 0 else num / float(den)def dms_to_decimal(dms, ref):    ”””度分秒 + 参考字母 -> 十进制;南纬/西经取负。”””    degrees = _ratio_to_float(dms[0])    minutes = _ratio_to_float(dms[1])    seconds = _ratio_to_float(dms[2])    decimal = degrees + minutes / 60.0 + seconds / 3600.0    ref = ref.decode(”ascii”).upper() if isinstance(ref, bytes) else str(ref).upper()    return -decimal if ref in (”S”, ”W”) else decimaldef _parse_exif_datetime(exif_ifd):    raw = exif_ifd.get(piexif.ExifIFD.DateTimeOriginal) or exif_ifd.get(piexif.ExifIFD.DateTime)    if not raw:        return None    text = raw.decode(”ascii”) if isinstance(raw, bytes) else str(raw)    try:        return datetime.datetime.strptime(text, ”%Y:%m:%d %H:%M:%S”)    except ValueError:        return Nonedef read_photo(path):    ”””读单张照片 GPS + 拍摄时间,返回 dict。”””    rec = {”file”: os.path.basename(path), ”lat”: None, ”lon”: None,           ”alt”: None, ”dt”: None, ”has_gps”: False}    try:        exif = piexif.load(path)    except Exception:        return rec    gps = exif.get(”GPS”, {})    exif_ifd = exif.get(”Exif”, {})    if piexif.GPSIFD.GPSLatitude in gps and piexif.GPSIFD.GPSLongitude in gps:        try:            rec[”lat”] = dms_to_decimal(gps[piexif.GPSIFD.GPSLatitude],                                        gps.get(piexif.GPSIFD.GPSLatitudeRef, b”N”))            rec[”lon”] = dms_to_decimal(gps[piexif.GPSIFD.GPSLongitude],                                        gps.get(piexif.GPSIFD.GPSLongitudeRef, b”E”))            rec[”has_gps”] = True            alt = gps.get(piexif.GPSIFD.GPSAltitude)            if alt:                rec[”alt”] = _ratio_to_float(alt)        except Exception:            rec[”has_gps”] = False    rec[”dt”] = _parse_exif_datetime(exif_ifd)    return recdef cluster_flights(photos, gap_minutes=30):    ”””按拍摄时间聚类成架次。”””    valid = [p for p in photos if p[”has_gps”] and p[”dt”]]    valid.sort(key=lambda p: p[”dt”])    flights, current, last_dt = [], [], None    for p in valid:        if last_dt is None or (p[”dt”] - last_dt).total_seconds() <= gap_minutes * 60:            current.append(p)        else:            flights.append(current); current = [p]        last_dt = p[”dt”]    if current:        flights.append(current)    return flightsdef _safe(v):    return 0.0 if v is None else vdef write_kml(photos, out):    L = ['<?xml version=”1.0” encoding=”UTF-8”?>',         '无人机照片点位']    for p in photos:        if not p[”has_gps”]:            continue        L.append('  %s%f,%f,%f'                 % (p[”file”], _safe(p[”lon”]), _safe(p[”lat”]), _safe(p[”alt”])))    L.append(””)    open(out, ”w”, encoding=”utf-8”).write(”\n”.join(L))def write_geojson(photos, out):    feats = []    for p in photos:        if not p[”has_gps”]:            continue        feats.append({”type”: ”Feature”,                      ”properties”: {”file”: p[”file”], ”alt”: p[”alt”],                                     ”time”: p[”dt”].isoformat() if p[”dt”] else None},                      ”geometry”: {”type”: ”Point”,                                   ”coordinates”: [_safe(p[”lon”]), _safe(p[”lat”]), _safe(p[”alt”])]}})    json.dump({”type”: ”FeatureCollection”, ”features”: feats},              open(out, ”w”, encoding=”utf-8”), ensure_ascii=False, indent=2)def write_csv(photos, out):    with open(out, ”w”, encoding=”utf-8-sig”, newline=””) as f:        w = csv.writer(f)        w.writerow([”文件名”, ”经度”, ”纬度”, ”海拔(米)”, ”拍摄时间”])        for p in photos:            if not p[”has_gps”]:                continue            w.writerow([p[”file”], ”%.6f” % p[”lon”], ”%.6f” % p[”lat”],                        (”%.1f” % p[”alt”]) if p[”alt”] is not None else ””,                        p[”dt”].strftime(”%Y-%m-%d %H:%M:%S”) if p[”dt”] else ””])def flights_summary(flights):    rows = []    for i, fl in enumerate(flights, 1):        lats = [p[”lat”] for p in fl]; lons = [p[”lon”] for p in fl]        alts = [p[”alt”] for p in fl if p[”alt”] is not None]        dts = [p[”dt”] for p in fl if p[”dt”]]        rows.append({”flight”: i, ”count”: len(fl),                     ”start”: min(dts).strftime(”%H:%M:%S”) if dts else ””,                     ”end”: max(dts).strftime(”%H:%M:%S”) if dts else ””,                     ”center_lat”: sum(lats)/len(lats), ”center_lon”: sum(lons)/len(lons),                     ”span_lat”: max(lats)-min(lats), ”span_lon”: max(lons)-min(lons),                     ”alt_min”: min(alts) if alts else None, ”alt_max”: max(alts) if alts else None})    return rowsdef write_excel(flights, out):    wb = openpyxl.Workbook(); ws = wb.active; ws.title = ”飞行台账”    ws.append([”架次”, ”照片张数”, ”开始时间”, ”结束时间”, ”中心纬度”, ”中心经度”,               ”纬度跨度(度)”, ”经度跨度(度)”, ”最低海拔(米)”, ”最高海拔(米)”])    for r in flights_summary(flights):        ws.append([r[”flight”], r[”count”], r[”start”], r[”end”],                   round(r[”center_lat”], 6), round(r[”center_lon”], 6),                   round(r[”span_lat”], 6), round(r[”span_lon”], 6),                   round(r[”alt_min”], 1) if r[”alt_min”] is not None else ””,                   round(r[”alt_max”], 1) if r[”alt_max”] is not None else ””])    ws2 = wb.create_sheet(”全部点位”)    ws2.append([”文件名”, ”经度”, ”纬度”, ”海拔(米)”, ”拍摄时间”])    for fl in flights:        for p in fl:            ws2.append([p[”file”], round(p[”lon”], 6), round(p[”lat”], 6),                        round(p[”alt”], 1) if p[”alt”] is not None else ””,                        p[”dt”].strftime(”%Y-%m-%d %H:%M:%S”) if p[”dt”] else ””])    wb.save(out)def process_folder(input_dir, out_dir, gap_minutes=30):    os.makedirs(out_dir, exist_ok=True)    exts = (”.jpg”, ”.jpeg”, ”.JPG”, ”.JPEG”)    files = [os.path.join(input_dir, f) for f in os.listdir(input_dir) if f.lower().endswith(exts)]    print(”扫描到 %d 张照片(按扩展名筛选)” % len(files))    photos = [read_photo(f) for f in files]    flights = cluster_flights(photos, gap_minutes=gap_minutes)    anomalies = [p[”file”] for p in photos                 if (not p[”has_gps”]) or not (-90 <= _safe(p[”lat”]) <= 90)                 or not (-180 <= _safe(p[”lon”]) <= 180)]    with open(os.path.join(out_dir, ”异常清单.txt”), ”w”, encoding=”utf-8”) as f:        if anomalies:            f.write(”以下 %d 张照片无 GPS 或坐标异常,未进入点位图:\n” % len(anomalies))            for a in anomalies:                f.write(”  - %s\n” % a)        else:            f.write(”未发现无 GPS / 坐标异常照片。\n”)    valid = sum(1 for p in photos if p[”has_gps”])    write_kml(photos, os.path.join(out_dir, ”points.kml”))    write_geojson(photos, os.path.join(out_dir, ”points.geojson”))    write_csv(photos, os.path.join(out_dir, ”points.csv”))    write_excel(flights, os.path.join(out_dir, ”飞行台账.xlsx”))    print(”有效带 GPS 照片:%d 张,聚成 %d 个架次” % (valid, len(flights)))    for r in flights_summary(flights):        print(”  架次%d:%d 张,%s~%s,中心 (%.5f, %.5f)”              % (r[”flight”], r[”count”], r[”start”], r[”end”], r[”center_lat”], r[”center_lon”]))    print(”成果已写入:%s” % out_dir)def _to_dms(coord):    deg = int(coord); mf = (coord - deg) * 60.0; minute = int(mf)    sec = (mf - minute) * 60.0    return ((deg, 1), (minute, 1), (int(round(sec * 100)), 100))def _make_demo_photo(path, lat, lon, alt, dt):    img = Image.new(”RGB”, (16, 16), (120, 160, 200))    gps_ifd = {piexif.GPSIFD.GPSLatitudeRef: b”N”, piexif.GPSIFD.GPSLatitude: _to_dms(abs(lat)),               piexif.GPSIFD.GPSLongitudeRef: b”E”, piexif.GPSIFD.GPSLongitude: _to_dms(abs(lon)),               piexif.GPSIFD.GPSAltitude: (int(alt*100), 100), piexif.GPSIFD.GPSAltitudeRef: 0}    exif_ifd = {piexif.ExifIFD.DateTimeOriginal: dt.strftime(”%Y:%m:%d %H:%M:%S”).encode()}    img.save(path, exif=piexif.dump({”GPS”: gps_ifd, ”Exif”: exif_ifd}))def run_demo():    dd = os.path.join(os.getcwd(), ”demo_photos”); od = os.path.join(os.getcwd(), ”demo_output”)    os.makedirs(dd, exist_ok=True)    base = datetime.datetime(2026, 8, 16, 10, 0, 0)    for i in range(7):        _make_demo_photo(os.path.join(dd, ”DJI_%03d.JPG” % i), 30.60+i*0.0002, 114.30+i*0.0003,                         120+i, base+datetime.timedelta(minutes=i))    for i in range(5):        _make_demo_photo(os.path.join(dd, ”DJI_%03d.JPG” % (i+10)), 30.61+i*0.0002, 114.31+i*0.0003,                         130+i, base+datetime.timedelta(hours=1, minutes=i))    process_folder(dd, od, gap_minutes=30)if __name__ == ”__main__”:    ap = argparse.ArgumentParser()    ap.add_argument(”-i”, ”--input”); ap.add_argument(”-o”, ”--output”)    ap.add_argument(”-g”, ”--gap”, type=int, default=30); ap.add_argument(”--demo”, action=”store_true”)    a = ap.parse_args()    if a.demo:        run_demo()    elif a.input and a.output:        process_folder(a.input, a.output, gap_minutes=a.gap)    else:        print('真实:python drone_log.py -i ”照片文件夹” -o ”成果文件夹”')        print(”演示:python drone_log.py --demo”)

五、常见坑(按严重程度排序)

  1. 【严重】照片被微信 / QQ 转发过 → EXIF 被剥离,读不到 GPS。 脚本只能读原片里的坐标。务必从相机卡 / 无人机存储直接拷,别走社交软件。
  2. 【严重】路径含中文 / 空格没加引号 → 找不到文件。 Windows 一律用英文双引号包住 -i / -o 路径。
  3. 【中】度分秒 vs 十进制搞混。 DJI 照片 EXIF 里存的是度分秒,脚本已自动转十进制;但如果你手填的坐标表是度分秒,别直接当十进制用(下方转换器可当场验)。
  4. 【中】海拔字段不一定是绝对高程。 部分无人机 EXIF 的海拔是"相对起飞点",别直接当海拔成果用,重要项目以 RTK/水准为准。
  5. 【轻】子文件夹不递归。 分架次存的照片要先平铺到一个文件夹再跑,否则只扫到顶层。
  6. 【轻】时区。 EXIF 时间多为当地时区,跨时区作业注意架次切分和台账时间标注。

最新文章

随机文章