Thursday, May 14, 2026

 Drone Survey Area reconstitution:

Problem statement:

Aerial drone images extracted from a drone video are sufficient to reconstitute the survey area with image selection to create a mosaic that fully covers the survey area. This method does away with the knowledge of flight path of the drone. Write a python implementation that places selections from the input on the tiles in a grid to increase the likelihood of match with the overall survey area.

Solution:

The following is a visual survey approximation, not a georeferenced orthomosaic. Without GPS/EXIF or camera poses from the previous example, the script cannot know the true ground positions, so the grid is an informed montage rather than a mathematically correct map.

Usage:

pip install pyodm

docker run -p 3000:3000 opendronemap/nodeodm --test

Code:

#! /usr/bin/python

from pathlib import Path

import cv2

import numpy as np

import math

import shutil

import sys

def list_images(folder):

    exts = {".jpg", ".jpeg", ".JPG", ".JPEG"}

    files = [p for p in Path(folder).iterdir() if p.suffix in exts]

    return sorted(files, key=lambda p: p.name)

def make_detector():

    try:

        return cv2.SIFT_create()

    except Exception:

        return cv2.ORB_create(4000)

def detect(detector, img):

    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)

    return detector.detectAndCompute(gray, None)

def match_score(des1, des2, use_sift=True):

    if des1 is None or des2 is None:

        return 0

    if use_sift:

        matcher = cv2.FlannBasedMatcher(dict(algorithm=1, trees=5), dict(checks=40))

        matches = matcher.knnMatch(des1, des2, k=2)

    else:

        matcher = cv2.BFMatcher(cv2.NORM_HAMMING)

        matches = matcher.knnMatch(des1, des2, k=2)

    good = 0

    for pair in matches:

        if len(pair) < 2:

            continue

        m, n = pair

        if m.distance < 0.75 * n.distance:

            good += 1

    return good

def overlap_score(img1, img2, detector):

    kp1, des1 = detect(detector, img1)

    kp2, des2 = detect(detector, img2)

    use_sift = hasattr(cv2, "SIFT_create") and detector.__class__.__name__.lower().find("sift") >= 0

    return match_score(des1, des2, use_sift=use_sift)

def choose_grid(n, aspect=1.0):

    best = None

    for rows in range(1, n + 1):

        cols = math.ceil(n / rows)

        score = abs((cols / rows) - aspect)

        waste = rows * cols - n

        cand = (score, waste, abs(rows - cols), rows, cols)

        if best is None or cand < best:

            best = cand

    return best[3], best[4]

def fit_tile(img, tile_w, tile_h, pad=8, bg=(255, 255, 255)):

    h, w = img.shape[:2]

    scale = min((tile_w - 2 * pad) / w, (tile_h - 2 * pad) / h)

    nw, nh = max(1, int(round(w * scale))), max(1, int(round(h * scale)))

    resized = cv2.resize(img, (nw, nh), interpolation=cv2.INTER_AREA)

    canvas = np.full((tile_h, tile_w, 3), bg, dtype=np.uint8)

    x = (tile_w - nw) // 2

    y = (tile_h - nh) // 2

    canvas[y:y+nh, x:x+nw] = resized

    return canvas

def build_montage(folder, max_tiles=30, tile_w=360, tile_h=240, pad=8):

    folder = Path(folder).resolve()

    files = list_images(folder)

    if not files:

        raise ValueError("No JPG images found.")

    imgs = []

    for p in files:

        im = cv2.imread(str(p))

        if im is not None:

            imgs.append((p, im))

    if not imgs:

        raise ValueError("Could not read any images.")

    detector = make_detector()

    n = min(len(imgs), max_tiles)

    used = imgs[:n]

    scores = np.zeros((n, n), dtype=int)

    for i in range(n):

        for j in range(i + 1, n):

            s = overlap_score(used[i][1], used[j][1], detector)

            scores[i, j] = scores[j, i] = s

    remaining = set(range(1, n))

    order = [0]

    while remaining:

        last = order[-1]

        nxt = max(remaining, key=lambda j: (scores[last, j], -j))

        order.append(nxt)

        remaining.remove(nxt)

    rows, cols = choose_grid(n, aspect=1.0)

    while len(order) < rows * cols:

        order.append(None)

    montage = np.full((rows * tile_h, cols * tile_w, 3), 255, dtype=np.uint8)

    for idx in range(rows * cols):

        r = idx // cols

        c = idx % cols

        x0, y0 = c * tile_w, r * tile_h

        cv2.rectangle(montage, (x0, y0), (x0 + tile_w - 1, y0 + tile_h - 1), (230, 230, 230), 1)

        item_idx = order[idx]

        if item_idx is None:

            continue

        p, img = used[item_idx]

        tile = fit_tile(img, tile_w, tile_h, pad=pad)

        montage[y0:y0 + tile_h, x0:x0 + tile_w] = tile

        label = p.stem[:34]

        cv2.putText(

            montage,

            label,

            (x0 + 10, y0 + tile_h - 12),

            cv2.FONT_HERSHEY_SIMPLEX,

            0.5,

            (20, 20, 20),

            1,

            cv2.LINE_AA,

        )

    out_dir = folder / "montage_output"

    out_dir.mkdir(exist_ok=True)

    out_path = out_dir / f"{folder.name}_grid_montage.png"

    cv2.imwrite(str(out_path), montage)

    same_folder_copy = folder / out_path.name

    shutil.copy2(out_path, same_folder_copy)

    return str(same_folder_copy)

if __name__ == "__main__":

    if len(sys.argv) < 2:

        print("Usage: python grid_montage.py /path/to/folder")

        sys.exit(1)

    print(build_montage(sys.argv[1]))


No comments:

Post a Comment