[general]
name=SkyStitch - Drone Orthomosaic
qgisMinimumVersion=3.16
description=The SkyStitch plugin allows for the stitching of raw drone photos into a single georeferenced orthomosaic, directly inside QGIS, providing tools for GPS-based alignment, feature matching and GeoTIFF export.

version=2.3.2
author=Muhammad Kholifatkhur Rohman
email=muhammadkholifatkhurrohman@gmail.com

license=GPL-2.0-or-later

about=Developed by Muhammad Kholifatkhur Rohman, the SkyStitch plugin allows for turning a folder of raw drone photos (JPG with GPS EXIF data) into one seamless, georeferenced orthomosaic (GeoTIFF), directly inside QGIS. It reads the GPS location and camera parameters from each photo, automatically matches ground features between overlapping photos using SIFT, aligns and fine-tunes the fit using the real GPS coordinates from the flight, then blends everything into one map and loads it straight onto the QGIS canvas -- no need for WebODM, Pix4D, DroneDeploy, or Metashape. Photos need GPS data. Photos should overlap by at least 60-70%% with their neighbors for the best (feature-matched) alignment, but this isn't a hard requirement: any photo that doesn't overlap with the rest is still placed on the canvas at its own GPS coordinate rather than being dropped. Best suited for quick previews, small-to-medium survey sites, and relatively flat terrain; for hilly terrain, very large areas, or survey-grade accuracy, a dedicated tool such as WebODM, Pix4D, DroneDeploy, or Metashape is still recommended. This plugin requires the installation of OpenCV, exifread, Rasterio, PyProj, SciPy, and Pillow into QGIS's own Python (step-by-step instructions are in README.md, included in the plugin folder). Version 2.0 adds an optional OpenDroneMap (3D) mode -- a second tool in the same plugin that drives OpenDroneMap (via Docker) to produce a true orthophoto plus DSM, DTM/DEM, point cloud and textured 3D mesh, and can derive contour, slope and hillshade in QGIS -- for when you need full photogrammetry and elevation rather than a quick 2D mosaic (this mode needs Docker instead of the packages above). For more information please visit https://github.com/muhammadkholifatkhurrohman-spec/SkyStitch-Drone-Orthomosaic .

tracker=https://github.com/muhammadkholifatkhurrohman-spec/SkyStitch-Drone-Orthomosaic/issues
repository=https://github.com/muhammadkholifatkhurrohman-spec/SkyStitch-Drone-Orthomosaic
homepage=https://github.com/muhammadkholifatkhurrohman-spec/SkyStitch-Drone-Orthomosaic

category=Raster
icon=icon.png

tags=drone,orthomosaic,photogrammetry,mosaic,uav,geotiff,mapping,stitching,opendronemap,odm,dsm,dem,pointcloud,mesh,3d,webodm,remote,nodeodm

experimental=False
deprecated=False
server=False
hasProcessingProvider=no

changelog=2.3.2
  - FIX: plugin files are now packaged with standard 0644 (non-executable)
    permissions, resolving the QGIS "Python file has executable permission"
    review warnings. No code change.

  2.3.1
  - CLEANUP: addressed the QGIS plugin Bandit security scan (16 findings, all
    low/informational). Bare `except: pass` blocks now log at debug level via
    the standard logging module instead of silently discarding errors; the
    WebODMClient `password` default is None instead of an empty string; and the
    intentional, fixed-argv subprocess calls in the (hidden) local-Docker engine
    are annotated with `# nosec` (no shell=True; commands built from the
    plugin's own validated options). No behavior change.

  2.3.0
  - NEW: default 3D engine "SkyStitch - 3D Photogrammetry (local node)". It
    talks directly to a NodeODM node running on the user's own machine (e.g. the
    free WebODM native installer's node, or a standalone NodeODM): uploads the
    photos (resumable), processes locally, downloads all.zip, extracts it, and
    loads ortho/DSM/DTM/point cloud into QGIS. Each user installs the engine
    once; the plugin then runs everything from QGIS -- no Docker to launch, no
    WebODM login, no cloud. Has a Test Connection button and optional node token.
    New files: nodeodm_dialog.py, nodeodm_worker.py, pipeline/nodeodm_client.py.
  - CHANGED: menu now shows just the 2D engine and the local-node 3D engine. The
    WebODM (login) and local-Docker engines still ship but are hidden by default
    (SHOW_WEBODM_REMOTE_ENGINE / SHOW_LOCAL_DOCKER_ENGINE in skystitch_plugin.py).

  2.2.2
  - CHANGED: the local Docker OpenDroneMap tool is now hidden from the menu to
    avoid Docker confusion. The menu shows just two tools: the 2D stitch and
    "3D Photogrammetry (WebODM, no Docker)". Nothing was deleted -- the Docker
    engine's files still ship and can be brought back by setting
    SHOW_LOCAL_DOCKER_ENGINE = True in skystitch_plugin.py.

  2.2.1
  - CHANGED: WebODM is now the recommended path for 3D. Menu order is
    2D -> "3D Photogrammetry (WebODM, no Docker)" -> "3D via local Docker
    (advanced)", and the tools were renamed to make clear that WebODM needs no
    Docker (its server can be your own PC at http://localhost:8000 via the free
    native installer), while the local OpenDroneMap engine is the Docker-based
    advanced option. No functionality removed.

  2.2.0
  - NEW: third engine "SkyStitch - WebODM (remote)". It connects to a WebODM
    server (the free native Windows installer running locally, or a remote
    server) over the WebODM REST API: it uploads the photos, processes on the
    server, and downloads the orthophoto, DSM, DTM, point cloud and 3D mesh
    back into QGIS -- with NO Docker needed on the user's machine. Has a
    "Test Connection" button, resumable per-image upload, live progress/log,
    and cancel. New files: webodm_dialog.py, webodm_worker.py,
    pipeline/webodm_client.py. Requires the Python package "requests" (a small,
    common dependency; the dialog shows a pip hint if missing).
    Tip: WebODM's Windows installer (webodm.org/download) runs natively without
    Docker, so pairing it with this engine avoids Docker/WSL entirely.

  2.1.0
  - NEW: "Test Docker" and "Install Docker" buttons in the OpenDroneMap (3D)
    dialog (Advanced section). Test checks that the docker CLI and engine
    respond; Install runs "winget install Docker.DockerDesktop" on Windows
    (a UAC prompt appears; a restart is needed afterwards) or opens the
    official download page on macOS/Linux. Helper logic lives in
    pipeline/odm_engine.py (docker_status, docker_install_hint,
    start_docker_install). Note: Docker still cannot be bundled inside the
    plugin zip -- it is system software needing admin rights and a reboot -- so
    these buttons make setup easier but can't make it fully silent.

  2.0.0
  - NEW: optional "OpenDroneMap (3D)" engine, added as a second tool/dialog
    alongside the original 2D stitcher (the 2D engine is left unchanged). It
    drives OpenDroneMap through its official Docker image to run real SfM/MVS
    photogrammetry and produce a true (relief-corrected) orthophoto plus DSM,
    DTM/DEM, point cloud (LAZ) and textured 3D mesh (OBJ). It can optionally
    generate contour, slope and hillshade from the DSM using QGIS Processing,
    and accepts an ODM gcp_list.txt for survey-grade accuracy. This mode needs
    Docker (see README.md -> "OpenDroneMap (3D) mode"); the 2D engine's Python
    dependencies are not required for it.
    New files: odm_dialog.py, odm_worker.py, pipeline/odm_engine.py,
    pipeline/derivatives.py.

  1.9.5
  - FIX: a GCP/ICP file with more than one row pointing at the same photo
    previously had each later row silently overwrite the earlier one, so
    only the last-listed point for that photo was ever used. Multiple GCP
    rows matched to the same photo are now averaged into a single anchor
    position for that photo (this plugin anchors whole photos, not
    individual pixels within one, so averaging is the correct way to fold
    several points into that model). If the matched rows disagree by more
    than 2m after conversion, a [WARNING] is now logged so a mistyped
    coordinate, wrong photo name, or wrong CRS gets noticed instead of
    silently producing an averaged-but-wrong anchor.
  - Cleaned up three bare `except: pass`/`except: continue` blocks
    (flagged by static analysis) in geo_utils.py, proj_fix.py, and
    skystitch_dialog.py. Behavior is unchanged (all three are genuinely
    optional/best-effort paths), but failures are now logged at debug
    level via the standard `logging` module instead of being silently
    discarded, so a real underlying problem is still discoverable.

  1.9.4
  - NEW: output GeoTIFF compression is now selectable (Advanced options ->
    "Output compression"): DEFLATE (previous default, lossless), ZSTD
    (lossless, usually smaller/faster than DEFLATE), LZW (lossless), JPEG
    (lossy, smallest files, adjustable quality 1-100%%), or None
    (uncompressed). JPEG mode stores the transparency mask as an internal
    GDAL mask band instead of a literal 4th band, since JPEG doesn't
    support an alpha channel directly; QGIS and other GDAL-aware viewers
    still render it transparently the same way. The chosen setting is
    remembered between sessions, same as the other advanced options.

  1.9.3
  - CHANGED: photos that don't overlap with the main feature-matched group
    (or, in the extreme case, when NO photo overlaps any other at all) are
    no longer dropped from the output. They're still placed on the canvas
    at their own GPS coordinate (using the same scale/rotation as the
    fitted mosaic, since there's no matching info to refine their own
    orientation), and a [NOTICE] is logged listing which photos were
    placed this way -- the build no longer stops or silently skips them.
    Overlap between a GPS-only-placed photo and any other photo (matched
    or GPS-only) is handled the same as any other overlap (seam/blend),
    not treated as an error. Previously, a fully non-overlapping photo set
    raised "No photos could be matched to each other" and stopped the
    whole build; individual non-overlapping photos within an otherwise
    fine set were silently left out of the mosaic.

  1.9.2
  - CRITICAL FIX: the output .tif's 4th band was written as the alpha
    channel, but only got a text label (set_band_description(4, "alpha"))
    -- it was never actually tagged with GDAL's alpha color
    interpretation. QGIS (and other GDAL-aware viewers) only render
    nodata as transparent when a band is tagged that way; without it,
    band 4 was treated as an ordinary band and the literal black (0,0,0)
    pixels filling the canvas outside each photo's rotated footprint
    showed up as a solid black border around the mosaic, even inside
    QGIS with a basemap underneath. Band 4 is now explicitly set to
    GDAL's alpha color interpretation (rasterio.enums.ColorInterp.alpha)
    on save, so that area renders transparent as intended. Mosaics built
    with 1.9.1 or earlier will need to be rebuilt to get the fix (existing
    .tif files aren't modified retroactively).

  1.9.1
  - FIX: the output .tif already carries a proper alpha band, so the area
    outside the mosaic's tilted footprint shows up as transparent (not
    black) in GIS software like QGIS. But the separate "_preview" image
    the pipeline also saves alongside it was a plain RGB .jpg, which
    dropped that alpha band entirely (and JPEG can't represent
    transparency even if it hadn't) -- so that same area showed as a
    solid black border whenever the preview was opened outside a GIS
    viewer. The preview is now saved as "_preview.png" with the same
    alpha channel as the .tif, so the border is transparent there too.

  1.9.0
  - NEW: optional GCP/ICP correction. A new "GCP / ICP file (optional)"
    field in Advanced options accepts a CSV or Excel (.xlsx) file with
    surveyed, high-accuracy coordinates for specific photos (matched by
    filename). Matched photos are used as trusted anchors, refined with
    an iterative (ICP-style) reweighting pass that also down-weights
    GPS-derived positions that disagree strongly with the GCP-anchored
    fit, correcting the whole mosaic's position/scale/rotation beyond
    plain consumer-GPS accuracy. Also available on the CLI via --gcp.
    Reading .xlsx files requires the optional 'openpyxl' package; .csv
    needs no extra dependency. Leaving the field empty uses GPS only,
    identical to previous behavior.
  - The small result-preview thumbnail previously shown in the dialog
    after a build finished has been removed from the UI. The preview
    .jpg is still saved next to the output .tif by the pipeline; open
    the layer in the QGIS canvas (or that file) to inspect the result.

  1.8.0
  - NEW: heads-up warning when photo GPS altitudes vary by more than 15m
    across the flight, which can indicate hilly/non-flat terrain or a
    mid-flight altitude change -- this plugin does not perform DEM-based
    elevation correction, so the result may be misaligned in those areas.
    This is a lightweight heuristic based on EXIF GPS altitude only (no
    DEM/elevation data is used or required).
  - All [WARNING] messages (unrecognized camera model, GPS baseline too
    tight, terrain relief, ...) now also appear in the QGIS message bar
    as they happen, not just in the scrolling log.

  1.7.0
  - NEW: optional exposure/brightness compensation step (on by default,
    can be turned off via a new checkbox in Advanced options, or
    --no-exposure-compensation on the CLI). Equalizes each photo's
    overall brightness to the group's median before blending, so a
    frame taken under a passing cloud (or with slightly different
    auto-exposure) doesn't leave a visible brightness seam. Runs as a
    new "[STEP 5/7]" stage; total step count increased from 6 to 7.
  - The dialog now shows a rough pre-run estimate of build time and
    peak RAM usage (based on photo count/resolution) next to the photo
    count, before "Build Mosaic" is even clicked.
  - The output CRS (previously only visible buried in the log text) is
    now shown as a selectable/copyable label in the dialog once a
    build finishes, alongside the final resolution.

  1.6.2
  - GeoTIFF output now gets internal overviews/pyramids (average
    resampling) built automatically, plus horizontal-differencing
    (predictor=2) and RGB-tagged compression, so large mosaics pan/zoom
    smoothly in QGIS instead of being resampled on the fly every time.
  - The dialog now shows which of the 6 pipeline steps is currently
    running next to the progress bar, instead of only in the scrolling
    log.
  - The dialog now displays the small preview .jpg the pipeline already
    generates, right below the progress bar, once the run finishes --
    no need to open the output folder or the QGIS canvas to sanity-check
    the result.
  - The success message now reports the final ground sample distance
    (m/px) actually used, since it can differ from the analytic GSD
    estimate shown earlier in the log (e.g. after the memory-based
    resolution guard in mosaic_builder.py lowers it).

  1.6.1
  - CRITICAL FIX: the parallel feature detection added in 1.6 used
    ProcessPoolExecutor, which spawns brand-new OS processes. Inside a
    plugin running in QGIS's own embedded Python, a new process's entry
    point is the QGIS executable itself, not this plugin -- so every
    worker "process" actually relaunched a whole new QGIS window (several
    of which then crashed with "Unexpected error: A process in the
    process pool was terminated abruptly..." once the pool tried to use
    them as plain workers). Switched to ThreadPoolExecutor, which never
    spawns a process and so cannot trigger this. Detection is still run
    in parallel and still benefits from multiple CPU cores, since cv2's
    heavy C++ calls (including SIFT's detectAndCompute) release Python's
    GIL while running -- same mechanism numpy already relies on for
    thread-based parallelism. If 1.6 caused extra QGIS windows to open on
    your machine, close them and update to this version.

  1.6
  - Feature detection (SIFT) in build_match_graph now runs across multiple
    processes (concurrent.futures.ProcessPoolExecutor) instead of one
    photo at a time, since detecting each photo's features is completely
    independent of the others. On a multi-core machine this cuts Step 2's
    detection time roughly in proportion to available CPU cores for
    flights with dozens-to-hundreds of photos. Cancellation remains
    responsive: an in-progress batch stops promptly instead of waiting for
    every worker to finish. No effect on results -- same keypoints/
    descriptors are produced either way, just computed in parallel.

  - render_mosaic gained a new default blending mode, blend_mode="seam"
    (previous behavior kept as blend_mode="feather"). The old feather mode
    averaged every overlapping photo together weighted by distance to each
    photo's edge; this is seamless on flat ground but any object taller
    than ground level (rooftops, walls, trees) is seen in a slightly
    different pixel position by each photo, so it got blended into a
    semi-transparent double image ("ghosting") instead of a clean line.
    The new seam mode instead finds a cut line between each pair of
    overlapping photos with graph-cut seam finding
    (cv2.detail.GraphCutSeamFinder, run only on the overlap's own bounding
    box for speed, with an automatic downscale-then-upscale fallback for
    unusually large overlaps) and hard-assigns each pixel to one photo or
    the other, with only a narrow (~12px) feathered band right at the cut
    line to hide pixel-level aliasing. This does not fix the underlying
    parallax error on tall objects (that needs true DSM-based
    orthorectification, which is out of scope for this planar-homography
    pipeline) but replaces the ghosted double-exposure look with a
    sharper, cleaner cut through it. Takes noticeably longer to render
    than feather mode (graph-cut has a real per-overlap cost); "feather"
    is still available for flat/open terrain (farmland, open fields) where
    there's nothing tall to ghost and a fully seamless blend is
    preferable.

  1.5
  - The tight-GPS-baseline fallback in fit_world_similarity (triggered
    when photos are too close together to fit rotation/scale from GPS
    alone) previously always assumed zero rotation ('photo up = north'),
    which was only right by coincidence. It now uses the root photo's
    gimbal yaw (read from DJI-style XMP metadata, previously parsed by
    read_xmp_gimbal_yaw() but never actually used anywhere) as a real
    orientation estimate when available, falling back to the previous
    zero-rotation behavior when it isn't (e.g. camera doesn't expose
    gimbal yaw). No effect on the normal (wide GPS baseline) case.

  1.4
  - Unrecognized camera models used to silently fall back to a guessed
    sensor width (DEFAULT_SENSOR_WIDTH_MM) when estimating GSD from camera
    parameters, with no indication to the user. Now flagged explicitly:
    estimate_analytic_gsd() reports whether the sensor width was actually
    known, and the pipeline logs a warning (once per distinct unrecognized
    make/model) when this estimate is computed, since it's silently relied
    on as the scale fallback whenever the GPS baseline between photos is
    too tight to calibrate scale on its own.

  1.3
  - render_mosaic now warps each photo only into its own footprint's
    bounding box on the canvas, instead of warping every photo onto the
    FULL canvas every time. This was previously the biggest memory/CPU
    cost in the whole pipeline for large surveys -- cost scaled with
    (number of photos x full canvas size) even though each photo only
    ever covers a small fraction of the final mosaic.
  - Replaced the old fixed max_canvas_dim=18000px cap with a memory-budget
    based guard (default 2 GiB for the accumulator buffers): if the
    canvas at the estimated GSD would need more memory than that, GSD is
    automatically coarsened (same coverage area, lower resolution) instead
    of risking a MemoryError/crash. A clear warning is logged when this
    happens. A secondary max_canvas_dim=30000px hard cap still protects
    against degenerate/very elongated canvases.

  1.2
  - Feature matching (SIFT) now uses each photo's GPS position to only try
    matching it against its nearest neighbors (default: 12), instead of
    every other photo in the flight. This turns matching from O(n^2) into
    roughly O(n x k), which matters a lot for flights with hundreds of
    photos -- most distant pairs could never overlap anyway. Falls back to
    matching every pair when GPS positions aren't available or the photo
    count is small, so behavior/accuracy is unchanged for small flights.

  1.1
  - Fixed 6 Qt6-incompatible unscoped enum references (e.g. QgsTask.CanCancel ->
    QgsTask.Flag.CanCancel, QgsFileWidget.GetDirectory/SaveFile ->
    QgsFileWidget.StorageMode.GetDirectory/.SaveFile, Qt.AlignRight/AlignVCenter ->
    Qt.AlignmentFlag.AlignRight/.AlignVCenter, QMessageBox.Yes/No ->
    QMessageBox.StandardButton.Yes/.No) in worker.py and skystitch_dialog.py, for
    compatibility with upcoming Qt6-based QGIS builds. No behavior change; fully-
    qualified enum paths work identically on current Qt5-based QGIS.
