【代码】无人机航拍图像几何校正

经过这几天的测试,完成无人机航拍图像几何校正,也叫做航片的正射影像处理。

主要步骤如下:

image-20240512182008484

现在对每一个步骤分析,谈论为什么要这样做。

获取航片信息

image-20240512183940941

获取航片信息是为了计算影像的分辨率,相关操作可以查看这里。

根据偏航角旋转图片

这步骤是必要的。详细讲解如何实现这一步骤。

image-20240512184238007

扩充图像的意思是,用0值在图像的边缘填充,这一步骤的目的是,为了 旋转后的图像 还在指定尺寸范围内。

扩充图像效果如下:

image-20240512185126099

下一步是对图像进行旋转,假设偏航角为-45°,则此时要对图像进行逆时针旋转45°。

这涉及到另一个问题,偏航角的定义。示意图如下:

这里写图片描述

简单来说,航迹线偏在航线右边,偏航角为正,航迹线偏在航线左边,偏航角为负。请看下面的灵魂画图。

image-20240512190947793

这里假设偏航角是-45度,旋转中心为图像的中心,旋转后的图像如下:

image-20240512191155774

为什么要先扩充图像,再旋转?因为这里调用opencv的函数,如果直接对原始图像进行旋转,会丢失部分信息。

直接对原始图像进行旋转,效果如下:

image-20240512191815207

如上图所示,部分信息被省略了,所以要先用0值扩充图像边缘,使图像的尺寸增加一倍,然后再对扩充后的图像进行旋转。

裁剪图像

这一步骤的目的是,为了减少旋转图像的无效像素,缩小图像的尺寸的同时,仅保留有用信息。

裁剪后的旋转图像的效果如下:

image-20240512192248669

计算左上角坐标

现在得到的结果,是不包含经纬度信息的。我们在第一步的时候,获取了图像的中心的的经纬度、高度、镜头参数。所以我们可以计算此时无人机拍摄照片的空间分辨率。

空间分辨率 = (像元尺寸 x 高度)/ 焦距

以上的单位都要换算为米。

像元尺寸要根据你的无人机硬件参数决定。现在我们的无人机镜头的像元尺寸为4.4微米。

高度、焦距自动从影像文件中获取。

此时的空间分辨率为米,我们需要进行单位换算,把米换算为度。

假设地球的半径为6371004米,则有以下的换算公式:

x_res = x_res_meter / (2 * math.pi * 6371004) * 360 

左上角经纬度换算公式如下:

左上角经度 = 中心点经度 - 裁剪后的旋转图像的宽度 x 0.5 x 经度分辨率

左上角纬度 = 中心点纬度 + 裁剪后的旋转图像的宽度 x 0.5 x 纬度分辨率

仿射六参数拟合

通过以上的步骤,我们获得了

1.左上角经纬度

2.空间分辨率

所以我们可以得到该影像的仿射六参数。其格式如下:

[左上角经度,经度分辨率,0,左上角纬度,0,纬度分辨率 ]

由于我们在北半球,所以,上面公式中的经纬度分辨率换算公式如下:

经度分辨率 = 空间分辨率

纬度分辨率 = -1 x 空间分辨率

合成为TIF格式

这里跳用GDAL库,轻松地把裁剪后的旋转图像与仿射六参数结合,生成TIF格式的影像。

代码

#!/usr/bin/env python
# -*- coding: utf-8 -*-
# file: AerialCorrection.py
# time: 2024/5/12 16:49

# 航片校正

import os
try:
    import Image
    import ImageDraw
except:
    from PIL import Image
    from PIL.ExifTags import TAGS
import math
from osgeo import gdal, osr

import numpy as np
import cv2
import matplotlib.pyplot as plt

class AerialCorrection():
    def __init__(self, img, out_folder, pixel_size=4.4):
        self.img = img
        self.out_folder = out_folder
        self.temp_name = os.path.join(self.out_folder, os.path.splitext(os.path.basename(img))[0] + '_temp.tif')
        self.out_name = os.path.join(self.out_folder, os.path.splitext(os.path.basename(img))[0] + '.tif')
        self.pixel_size = pixel_size

    def rotation(self):
        latitude1, longitude1 = self.get_gps_info()

        roll_angle, yaw_angle, pitch_angle, altitude = self.get_image_info()
        yaw_angle = -1*yaw_angle
        roll_angle = -1*roll_angle
        img = cv2.imdecode(np.fromfile(self.img ,dtype=np.uint8),-1)
        # img = cv2.imread(self.img)  # 读取彩色图像(BGR)
        height, width = img.shape[:2]  # 图片的高度和宽度
        # 计算新的图像尺寸(这里实际上是原始尺寸的两倍)

        # 计算每个方向的填充量(这里是原始尺寸的一半)
        top = bottom = height // 2
        left = right = width // 2
        padded_img = cv2.copyMakeBorder(img, top, bottom, left, right, cv2.BORDER_CONSTANT, value=[0, 0, 0])

        rotated_image = rotate_image(padded_img, yaw_angle)

        cropped_image = self.find_edge(rotated_image)

        new_height,new_width = cropped_image.shape[:2]

        focal_length = self.get_len_info()  # 镜头参数
        x_res_meter = float(self.pixel_size) * altitude / float(focal_length)  * 0.001  # 空间分辨率  单位是米

        x_res = x_res_meter / (2 * math.pi * 6371004) * 360   # 空间分辨率  单位是度 0.000008983
        left_coord = [longitude1 - x_res * new_width/ 2, latitude1 + x_res * new_height / 2]
        # left_coord = [longitude1, latitude1]
        out_png_geom = [left_coord[0], x_res, 0.0, left_coord[1], 0.0, -x_res]

        srs = osr.SpatialReference()
        srs.ImportFromEPSG(4326)

        # export the png tif
        driver = gdal.GetDriverByName('GTiff')
        # out_tif = driver.Create(name=out_name, ysize=tar_y, xsize=tar_x, bands=tar_bandnum, eType=tar_datatype)
        out_tif = driver.Create(self.out_name, new_width, new_height, 3, eType=gdal.GDT_Byte)
        j=2
        for i in range(3):

            band = out_tif.GetRasterBand(i + 1).WriteArray(cropped_image[:,:,j-i])
            del band

        out_tif.SetProjection(srs.ExportToWkt())
        out_tif.SetGeoTransform(out_png_geom)
        out_tif.FlushCache()

    def find_edge(self,arr):
        # 删减图像中的黑框
        temp_arr = arr[:,:,0]
        # 二值化
        _, thresh = cv2.threshold(temp_arr, 1, 255, cv2.THRESH_BINARY)

        # 找到轮廓
        contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)

        # 如果没有检测到轮廓,则退出
        if not contours:
            print("No contours found.")
            exit()

        # 获取最大轮廓
        c = max(contours, key=cv2.contourArea)
        # 计算边界框
        x, y, w, h = cv2.boundingRect(c)
        cropped_image = arr[y:y+h, x:x+w,:]
        plt.imshow(cv2.cvtColor(cropped_image, cv2.COLOR_BGR2RGB))
        plt.show()
        return cropped_image

    def exif(self):
        """
        从图片中返回EXIF元数据
        """
        exif_data = {}

        try:
            i = Image.open(self.img)  # 使用PIL库打开图片
            tags = i._getexif()  # 获取图片的EXIF标签

            for tag, value in tags.items():
                decoded = TAGS.get(tag, tag)  # 尝试从预定义的TAGS字典中获取标签的中文描述,否则使用标签ID
                exif_data[decoded] = value  # 将标签及其值存储到exif_data字典中

        except Exception as e:
            print(e)  # 捕获所有异常并忽略,这通常不是一个好的做法,应该明确指定要捕获的异常

        return exif_data
    def get_image_size(self):
        i = Image.open(self.img)
        return i.width, i.height



    def get_image_info(self):
        """
        :param file_path: 输入图片路径
        :return: 图片的偏航角
        """
        # 获取图片偏航角
        b = b"\x3c\x2f\x72\x64\x66\x3a\x44\x65\x73\x63\x72\x69\x70\x74\x69\x6f\x6e\x3e"
        a = b"\x3c\x72\x64\x66\x3a\x44\x65\x73\x63\x72\x69\x70\x74\x69\x6f\x6e\x20"
        img = open(self.img, 'rb')
        data = bytearray()
        dj_data_dict = {}
        flag = False
        for line in img.readlines():
            if a in line:
                flag = True
            if flag:
                data += line
            if b in line:
                break
        if len(data) > 0:
            data = str(data.decode('ascii'))
            lines = list(filter(lambda x: 'drone-dji:' in x, data.split("\n")))
            for d in lines:
                d = d.strip()[10:]
                key, value = d.split("=")
                dj_data_dict[key] = value

        return float(dj_data_dict["FlightRollDegree"][1:-1]),float(dj_data_dict["FlightYawDegree"][1:-1]),float(dj_data_dict["FlightPitchDegree"][1:-1]), float(dj_data_dict["RelativeAltitude"][1:-1])

    def get_len_info(self):
        exif = self.exif()
        lat = None  # 纬度
        lon = None  # 经度

        if exif.get('LensSpecification'):  # 如果EXIF中包含GPS信息
            # 纬度
            Lens = exif['LensSpecification']
            focal_length = Lens[1]  # 焦距
        return focal_length

    def get_gps_info(self):
        """
        从EXIF元数据中提取GPS信息、偏航信息
        """
        exif = self.exif()
        lat = None  # 纬度
        lon = None  # 经度

        if exif.get('GPSInfo'):  # 如果EXIF中包含GPS信息
            # 纬度
            coords = exif['GPSInfo']
            i = coords[1]  # 纬度方向(N/S)
            d = coords[2][0]  # 纬度度数
            m = coords[2][1]  # 纬度分钟
            s = coords[2][2]  # 纬度秒
            lat = self.dms2dd(d, m, s, i)  # 将纬度转换为十进制度

            # 经度
            i = coords[3]  # 经度方向(E/W)
            d = coords[4][0]  # 经度度数
            m = coords[4][1]  # 经度分钟
            s = coords[4][2]  # 经度秒
            lon = self.dms2dd(d, m, s, i)  # 将经度转换为十进制度

        return lat, lon

    def dms2dd(self, d, m, s, i):
        """
        将度/分/秒转换为十进制度
        """
        sec = float((m * 60) + s)  # 将分和秒转换为秒
        dec = float(sec / 3600)  # 将秒转换为小数度
        deg = float(d + dec)  # 将度和小数度相加

        if i.upper() == 'W':  # 如果方向是西
            deg = deg * -1  # 将度数变为负数

        elif i.upper() == 'S':  # 如果方向是南
            deg = deg * -1  # 将度数变为负数

        return float(deg)
def rotate_image(image, yaw):
    # 获取图像尺寸
    height, width = image.shape[:2]

    # 计算旋转中心
    center = (width // 2, height // 2)

    # 计算旋转矩阵
    rotation_matrix = cv2.getRotationMatrix2D(center, yaw, 1.0)

    # 应用偏航旋转
    rotated_image = cv2.warpAffine(image, rotation_matrix, (width, height))
    return rotated_image



if __name__ == '__main__':
    A = AerialCorrection(r'E:\109弥市镇——康家档子北\DJI_20230410091159_0017.JPG', r'D:\test', pixel_size= 4.4)

    a = A.rotation()
    print(a)

最终结果展示

google earth上的测试区范围如下:

image-20240512202505950

把结果拖到google earth上,如下图所示:

image-20240512201042676

以上可以认为是航片的粗几何校正。存在有一定的偏差,在2米左右。

这是因为我们在计算空间分辨率的时候,是假设地球半径。实际上当地的地球半径不是我们假设的通用地球半径。优化思路可以根据当地的经纬度,进行UTM坐标转换为WGS84坐标。

如果要精细化校正,则再需要进行特征点匹配,进行航片与底图的配准、航片与航片直接的配置。

代码已开源,地址在:

https://github.com/ytkz11/AerialPictureCorrection

欢迎给我一个免费的star,这次代码和样本数据就不放在百度云了,直接放在github。

image-20240512202815823

样本数据是一个小伙伴提供的,现在只分享一景,作为测试数据。

之后会对代码进行讲解。明天先把代码整合,使其可以批量对航片处理,最后打包为exe,会用云盘的方式分享出来。