Follow-up: Large raw NIR misalignment (DJI M3M) – cause found and fixed

@Kupry this is a follow-up to Large raw NIR misalignment (DJI M3M) causing orthophoto artifacts (the original topic is closed).

We ran your Test_data subset (MSI) with your settings (primary-band green, radiometric-calibration camera, gps-accuracy 0.1, pc-quality high, skip-3dmodel, orthophoto-resolution 2) and measured the band offsets in the resulting orthophoto (edge correlation in 68 tiles of 384 px):

Red vs Green NIR vs Green RedEdge vs Green
ODM as is 0 px 20 px (≈ 40 cm) 1 px
with fix below 0 px 1 px 1 px


False-colour (NIR-R-G) crop of the orthophoto, same 20 × 15 m area

Cause: ODM estimates one warp matrix per band by image matching/ECC against the primary band. On your plots the crop rows repeat every ~20 px, so the NIR estimate locks onto the neighbouring row (ODM: x shift −20.9 px). The other bands happen to be right.

Fix: the M3M already ships a factory calibration per band in the XMP (drone-dji:CalibratedHMatrix). inv(H_primary) · H_band maps each band onto the primary band. For your capture 0376 it gives Red +8.4 px (ODM: +9.2, so it agrees), but NIR −51.1 px instead of ODM’s −20.9. We patched opendm/multispectral.py to use that matrix (median over 12 captures) when it is present, plus a small residual correction (±8 px). With that the NIR misalignment and the seam stripes in NIR are gone.

Side note: in the subset one Green image is missing (28 vs 29), so ODM drops capture 0027 completely; that is expected.

Happy to share the patch or open a PR if that helps.

Hi,

Thank you very much for the turnaround and for the detailed analysis. The explanation makes perfect sense, my plots have strongly periodic row structure, so the NIR matching locking onto the neighbouring row fits what I saw.

Yes, I would be very happy if you could share the patch, and I think opening a PR would be valuable for other M3M users too, especially over row crops where the same issue is likely to appear.

I would like to test it on the full dataset, and also on other flights where I saw the same NIR misalignment and striping. I will report back with the results, including NIR vs Green offsets measured on the orthophoto before and after the patch.

Thanks again!

Thanks a lot – glad it matches what you saw.

I opened a pull request with the change: Multispectral: use DJI factory band calibration (CalibratedHMatrix) for band alignment by Ghost7K · Pull Request #2080 · OpenDroneMap/ODM · GitHub

Here is the patch as a unified diff against current ODM master (only opendm/multispectral.py is touched):

dji-calibrated-band-alignment.diff
--- a/opendm/multispectral.py
+++ b/opendm/multispectral.py
@@ -284,6 +284,95 @@
 
         return s2p, p2s
 
+def get_dji_calibrated_hmatrix(path):
+    """Read the factory band-to-band homography (drone-dji:CalibratedHMatrix)
+    that DJI multispectral cameras (e.g. Mavic 3M) store in the XMP of each band image."""
+    try:
+        with open(path, 'rb') as f:
+            data = f.read()
+        m = re.search(rb'CalibratedHMatrix(?:="|>)\s*([-0-9eE.,+\s]+)', data)
+        if not m:
+            return None
+        v = [float(x) for x in m.group(1).decode().replace(' ', '').strip(',').split(',') if x]
+        if len(v) != 9:
+            return None
+        return np.array(v, dtype=np.float64).reshape(3, 3)
+    except Exception:
+        return None
+
+
+def compute_dji_alignment_matrix(band, images_path, s2p, max_samples=12, max_residual_shift=8):
+    """Band alignment from the DJI factory calibration instead of image matching.
+
+    M = inv(H_primary) * H_band maps a band image onto its primary band image. Image matching
+    (features/ECC) can lock onto the wrong period on strongly repetitive scenes (e.g. crop rows),
+    the factory calibration cannot. A small residual translation (+- max_residual_shift px) is
+    then estimated by gradient correlation on a few captures.
+    Returns an alignment_info entry, or None if the calibration is not available."""
+    ms, dim = [], None
+    for p in band['photos'][:max_samples * 3]:
+        if len(ms) >= max_samples:
+            break
+        prim = s2p.get(p.filename)
+        if prim is None:
+            continue
+        hb = get_dji_calibrated_hmatrix(os.path.join(images_path, p.filename))
+        hp = get_dji_calibrated_hmatrix(os.path.join(images_path, prim.filename))
+        if hb is None or hp is None:
+            return None
+        m = np.linalg.inv(hp) @ hb
+        m = m / m[2, 2]
+        d = np.linalg.det(m[:2, :2])
+        if not (0.8 < d < 1.25):
+            return None
+        ms.append(m)
+        if dim is None:
+            im = imread(os.path.join(images_path, prim.filename), unchanged=True, anydepth=True)
+            dim = (im.shape[1], im.shape[0])
+    if not ms or dim is None:
+        return None
+    wm = np.median(np.array(ms), axis=0)
+
+    def grad(x):
+        x = cv2.GaussianBlur(x.astype(np.float32), (0, 0), 1.5)
+        return cv2.magnitude(cv2.Sobel(x, cv2.CV_32F, 1, 0), cv2.Sobel(x, cv2.CV_32F, 0, 1))
+
+    try:
+        shifts = []
+        r = max_residual_shift
+        for p in band['photos'][:6]:
+            prim = s2p.get(p.filename)
+            if prim is None:
+                continue
+            a = imread(os.path.join(images_path, prim.filename), unchanged=True, anydepth=True)
+            b = imread(os.path.join(images_path, p.filename), unchanged=True, anydepth=True)
+            a = a[:, :, 0] if a.ndim == 3 else a
+            b = b[:, :, 0] if b.ndim == 3 else b
+            ga = grad(a)
+            gb = grad(cv2.warpPerspective(b.astype(np.float32), wm, dim))
+            h, w = ga.shape
+            A = ga[h // 4:3 * h // 4, w // 4:3 * w // 4]
+            A = (A - A.mean()) / (A.std() + 1e-9)
+            best = None
+            for dy in range(-r, r + 1):
+                for dx in range(-r, r + 1):
+                    B = gb[h // 4 - dy:3 * h // 4 - dy, w // 4 - dx:3 * w // 4 - dx]
+                    B = (B - B.mean()) / (B.std() + 1e-9)
+                    v = float((A * B).mean())
+                    if best is None or v > best[0]:
+                        best = (v, dx, dy)
+            shifts.append(best[1:])
+        if shifts:
+            dx, dy = np.median(np.array(shifts), axis=0)
+            wm = np.array([[1, 0, dx], [0, 1, dy], [0, 0, 1]], dtype=np.float64) @ wm
+            log.ODM_INFO("%s band: residual shift after DJI calibration dx=%.1f dy=%.1f px" % (band['name'], dx, dy))
+    except Exception as e:
+        log.ODM_WARNING("%s band: residual shift estimation skipped: %s" % (band['name'], str(e)))
+
+    wm = wm.astype(np.float32)
+    return {'warp_matrix': wm, 'eigvals': np.linalg.eigvals(wm), 'dimension': dim, 'algo': 'dji-calibration', 'score': 0.0}
+
+
 def compute_alignment_matrices(multi_camera, primary_band_name, images_path, s2p, p2s, max_concurrency=1, max_samples=30):
     log.ODM_INFO("Computing band alignment")
 
@@ -292,6 +381,12 @@
     # For each secondary band
     for band in multi_camera:
         if band['name'] != primary_band_name:
+            dji = compute_dji_alignment_matrix(band, images_path, s2p)
+            if dji is not None:
+                alignment_info[band['name']] = dji
+                log.ODM_INFO("%s band will be aligned using DJI calibration matrix %s" % (band['name'], dji['warp_matrix']))
+                continue
+
             matrices = []
 
             def parallel_compute_homography(p):

What it does:

  • If every band image has drone-dji:CalibratedHMatrix in its XMP (Mavic 3M and other DJI multispectral cameras), the band-to-primary warp is inv(H_primary) · H_band (median over up to 12 captures, sanity check on the determinant).
  • A residual translation of at most ±8 px is then estimated by gradient correlation on 6 captures (on your subset: Red 0/−1 px, RedEdge 2/2 px, NIR 0/0 px).
  • If the calibration is missing, ODM falls back to the existing feature/ECC matching, so other cameras are not affected.

To try it with Docker without rebuilding: take opendm/multispectral.py from the PR branch (ODM/opendm/multispectral.py at dji-calibrated-band-alignment · Ghost7K/ODM · GitHub) and bind-mount it over the original, e.g. -v /path/multispectral.py:/code/opendm/multispectral.py. The log then shows “band will be aligned using DJI calibration matrix”.

Looking forward to your before/after numbers on the full dataset and the other flights – that is exactly the validation the PR needs.