Visualizzazione post con etichetta WebOdm. Mostra tutti i post
Visualizzazione post con etichetta WebOdm. Mostra tutti i post

mercoledì 14 ottobre 2026

WebOdm e Aruco

Esiste gia' un plugin per usare aruco tag come marker all'interno di un progetto di fotogrammetria con webodm in modo da eliminare la necessita' di cercare a mano il centro dei tag e popolare il file gcp_list.txt

 


tag stampato su foglio A3
  

Ho fatto una prova con un Dji Matrice 4T con camera impostata a 12 Mpx a quota di 30 m e con tag stampato su foglio A3 (25 cm di lato). Con queste impostazioni siamo al limite del riconoscimento 

Come miglioramento si potrebbe salvare le immagini in raw e poi convertire in formato lossless invece di usare il jpg di default 

Si crea il seguente file usando i dati di un gps differenziale misurando il centro dei tag aruco  

gcp_rtk.csv 

id,easting,northing,elevation
5,652522.164,4842209.312,68.660
6,652542.163,4842206.497,68.626
2,652532.991,4842178.100,68.634
8,652547.155,4842150.228,68.728
4,652530.467,4842146.604,68.663

si lancia poi lo script per generare il file gcp_list.txt

detect_aruco.py 

#!/usr/bin/env python3
"""
Rileva i marker ArUco in tutte le immagini JPG di una cartella e salva:
  1) un CSV con: immagine, id, pixel centrale (cx, cy) a piena risoluzione
  2) (opzionale, con --gcp) un file gcp_list.txt per ODM/WebODM, unendo i pixel
     rilevati con le coordinate RTK lette da un CSV (id,easting,northing,elevation)

Formato gcp_list.txt (separato da TAB), come nel file di riferimento:
    EPSG:xxxx
    X  Y  Z  im_x  im_y  nome_immagine.JPG  etichetta

Uso:
    python detect_aruco.py                                  # solo CSV dei pixel
    python detect_aruco.py --gcp gcp_rtk.csv --epsg EPSG:32632
    python detect_aruco.py -i immagini -d DICT_4X4_50 --gcp gcp_rtk.csv --annotate

Requisiti: pip install opencv-contrib-python numpy
"""
import argparse
import csv
import sys
from pathlib import Path

import cv2
import numpy as np


def parse_ids(s):
    """'0-20,35,40' -> {0..20, 35, 40}; None se vuoto."""
    if not s:
        return None
    out = set()
    for part in s.split(","):
        if "-" in part:
            a, b = part.split("-")
            out.update(range(int(a), int(b) + 1))
        else:
            out.add(int(part))
    return out


def load_gcp_csv(path):
    """Legge il CSV RTK. Colonne: id, easting, northing, elevation (nomi tollerati,
    separatore virgola/punto e virgola/tab). Colonna opzionale 'label'.
    Ritorna {id: (x, y, z, label_o_None)}."""
    text = Path(path).read_text(encoding="utf-8-sig")
    first = text.splitlines()[0]
    delim = max([",", ";", "\t"], key=first.count)
    rows = list(csv.DictReader(text.splitlines(), delimiter=delim))
    if not rows:
        sys.exit(f"{path}: file vuoto")

    def col(row, names):
        low = {k.strip().lower(): v for k, v in row.items() if k}
        for n in names:
            if n in low and low[n] not in (None, ""):
                return low[n].strip()
        return None

    gcp = {}
    for r in rows:
        tid = col(r, ["id", "tag", "marker"])
        x = col(r, ["easting", "east", "x", "e"])
        y = col(r, ["northing", "north", "y", "n"])
        z = col(r, ["elevation", "quota", "z", "h", "height", "elev"])
        if None in (tid, x, y, z):
            sys.exit(f"{path}: riga non valida o colonne mancanti: {r}")
        gcp[int(tid)] = (float(x.replace(",", ".")), float(y.replace(",", ".")),
                         float(z.replace(",", ".")), col(r, ["label", "name", "nome"]))
    return gcp


def make_detector(dict_name):
    if not hasattr(cv2.aruco, dict_name):
        sys.exit(f"Dizionario sconosciuto: {dict_name}")
    dictionary = cv2.aruco.getPredefinedDictionary(getattr(cv2.aruco, dict_name))
    p = cv2.aruco.DetectorParameters()
    # Marker piccoli (riprese da drone): accetta perimetri ridotti
    p.minMarkerPerimeterRate = 0.005
    p.adaptiveThreshWinSizeMin = 3
    p.adaptiveThreshWinSizeMax = 63
    p.adaptiveThreshWinSizeStep = 6
    p.cornerRefinementMethod = cv2.aruco.CORNER_REFINE_SUBPIX
    # Riduce i falsi positivi su erba/recinzioni
    p.errorCorrectionRate = 0.3
    p.maxErroneousBitsInBorderRate = 0.25
    return cv2.aruco.ArucoDetector(dictionary, p)


def center_from_diagonals(p):
    a, b, c, d = p
    A = np.array([[c[0] - a[0], -(d[0] - b[0])], [c[1] - a[1], -(d[1] - b[1])]])
    rhs = np.array([b[0] - a[0], b[1] - a[1]])
    try:
        t, _ = np.linalg.solve(A, rhs)
        q = a + t * (c - a)
        return float(q[0]), float(q[1])
    except np.linalg.LinAlgError:
        m = p.mean(axis=0)
        return float(m[0]), float(m[1])


def detect(detector, gray, allowed_ids, min_side):
    """Ritorna lista di (id, cx, cy, lato_medio_px, corners 4x2)."""
    corners, ids, _ = detector.detectMarkers(gray)
    results = []
    if ids is None:
        return results
    for c, i in zip(corners, ids.flatten()):
        pts = c.reshape(4, 2)
        if allowed_ids is not None and int(i) not in allowed_ids:
            continue
        side = np.mean([np.linalg.norm(pts[k] - pts[(k + 1) % 4]) for k in range(4)])
        if side < min_side:
            continue
        cx, cy = center_from_diagonals(pts)  # corretto anche in prospettiva
        results.append((int(i), cx, cy, float(side), pts))
    return results


def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("-i", "--input", default="immagini", help="cartella con le immagini (default: immagini)")
    ap.add_argument("-o", "--output", default="aruco_tags.csv", help="CSV con i pixel rilevati")
    ap.add_argument("-d", "--dict", default="DICT_4X4_50", help="dizionario ArUco (default: DICT_4X4_50)")
    ap.add_argument("--ids", default=None,
                    help="ID ammessi, es. '0-20,35'. Con --gcp, di default sono gli ID presenti nel CSV RTK")
    ap.add_argument("--min-side", type=float, default=12.0, help="lato minimo del marker in px (default: 12)")
    ap.add_argument("--scale", type=float, default=1.0,
                    help="upscaling prima del rilevamento (es. 2); le coordinate restano a piena risoluzione")
    ap.add_argument("--annotate", action="store_true", help="salva immagini annotate in <output>_annotate/")
    ap.add_argument("-r", "--recursive", action="store_true", help="cerca anche nelle sottocartelle")
    g = ap.add_argument_group("GCP per ODM/WebODM")
    g.add_argument("--gcp", default=None, help="CSV RTK con colonne id,easting,northing,elevation")
    g.add_argument("--gcp-out", default="gcp_list.txt", help="file gcp di output (default: gcp_list.txt)")
    g.add_argument("--epsg", default="EPSG:32632",
                   help="riga di proiezione del gcp_list (default: EPSG:32632). "
                        "DEVE corrispondere al sistema delle coordinate RTK (es. EPSG:3003 per Gauss-Boaga)")
    g.add_argument("--prefix", default="GCP",
                   help="prefisso etichetta se il CSV non ha colonna 'label' (default: GCP -> GCP-05)")
    args = ap.parse_args()

    folder = Path(args.input)
    if not folder.is_dir():
        sys.exit(f"Cartella non trovata: {folder}")
    pattern = "**/*" if args.recursive else "*"
    files = sorted(f for f in folder.glob(pattern) if f.suffix.lower() in (".jpg", ".jpeg"))
    if not files:
        sys.exit(f"Nessuna immagine JPG in {folder}")

    gcp = load_gcp_csv(args.gcp) if args.gcp else None
    allowed = parse_ids(args.ids)
    if allowed is None and gcp is not None:
        allowed = set(gcp.keys())  # scarta automaticamente ID non presenti nel rilievo

    detector = make_detector(args.dict)
    ann_dir = None
    if args.annotate:
        base = Path(args.output).with_suffix("")
        ann_dir = base.parent / (base.name + "_annotate")
        ann_dir.mkdir(parents=True, exist_ok=True)

    n_tags = 0
    gcp_rows = []
    seen_ids = set()
    counts = {}
    with open(args.output, "w", newline="") as fh:
        w = csv.writer(fh)
        w.writerow(["image", "id", "cx", "cy", "side_px"])
        for k, f in enumerate(files, 1):
            img = cv2.imread(str(f))
            if img is None:
                print(f"[{k}/{len(files)}] {f.name}: impossibile leggere", file=sys.stderr)
                continue
            gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
            s = args.scale
            if s != 1.0:
                gray = cv2.resize(gray, None, fx=s, fy=s, interpolation=cv2.INTER_CUBIC)

            found = detect(detector, gray, allowed, args.min_side * s)
            for tid, cx, cy, side, pts in found:
                cx, cy, side, pts = cx / s, cy / s, side / s, pts / s
                w.writerow([f.name, tid, f"{cx:.2f}", f"{cy:.2f}", f"{side:.1f}"])
                n_tags += 1
                seen_ids.add(tid)
                counts[tid] = counts.get(tid, 0) + 1
                if gcp is not None:
                    x, y, z, label = gcp[tid]
                    label = label or f"{args.prefix}-{tid:02d}"
                    gcp_rows.append((x, y, z, cx, cy, f.name, label))
                if ann_dir is not None:
                    cv2.polylines(img, [pts.astype(np.int32).reshape(-1, 1, 2)], True, (0, 0, 255), 3)
                    cv2.circle(img, (int(round(cx)), int(round(cy))), 6, (0, 255, 255), -1)
                    cv2.putText(img, str(tid), (int(cx) + 12, int(cy) - 12),
                                cv2.FONT_HERSHEY_SIMPLEX, 1.5, (0, 0, 255), 3)
            if ann_dir is not None and found:
                cv2.imwrite(str(ann_dir / f.name), img)
            print(f"[{k}/{len(files)}] {f.name}: {len(found)} tag"
                  + (f" -> id {[t[0] for t in found]}" if found else ""))

    print(f"\nFatto: {n_tags} tag in {len(files)} immagini -> {args.output}")

    if gcp is not None:
        # Stesso formato del file di riferimento: riga EPSG + righe separate da TAB
        with open(args.gcp_out, "w", newline="") as fh:
            fh.write(args.epsg + "\n")
            for x, y, z, cx, cy, name, label in gcp_rows:
                fh.write(f"{x:.3f}\t{y:.3f}\t{z:.3f}\t{cx:.2f}\t{cy:.2f}\t{name}\t{label}\n")
        print(f"GCP: {len(gcp_rows)} righe -> {args.gcp_out} ({args.epsg})")
        missing = sorted(set(gcp) - seen_ids)
        if missing:
            print(f"Attenzione: ID presenti nel CSV RTK ma mai rilevati nelle immagini: {missing}")
        poor = sorted(t for t, n in counts.items() if n < 3)
        if poor:
            print(f"Attenzione: ID visti in meno di 3 immagini (GCP poco vincolanti): {poor}")


if __name__ == "__main__":
    main()


il risultato e' qualcosa di simile 

gcp_list.txt 

EPSG:32632
652522.164    4842209.312    68.660    3749.82    1573.28    DJI_20261012113902_0011_V.JPG    GCP-05
652522.164    4842209.312    68.660    2205.37    73.27    DJI_20261012113904_0012_V.JPG    GCP-05
652530.467    4842146.604    68.663    3541.01    2929.11    DJI_20261012113956_0036_V.JPG    GCP-04
652532.991    4842178.100    68.634    3644.55    2421.14    DJI_20261012114004_0040_V.JPG    GCP-02
652522.164    4842209.312    68.660    2486.07    1970.55    DJI_20261012114013_0044_V.JPG    GCP-05
652522.164    4842209.312    68.660    2481.56    2593.47    DJI_20261012114015_0045_V.JPG    GCP-05
652542.163    4842206.497    68.626    442.20    131.14    DJI_20261012114030_0052_V.JPG    GCP-06
652522.164    4842209.312    68.660    2311.71    1630.05    DJI_20261012114035_0054_V.JPG    GCP-05
652530.467    4842146.604    68.663    1249.13    55.82    DJI_20261012114050_0061_V.JPG    GCP-04
652530.467    4842146.604    68.663    1926.51    2244.16    DJI_20261012114141_0085_V.JPG    GCP-04
652530.467    4842146.604    68.663    1910.85    2873.87    DJI_20261012114143_0086_V.JPG    GCP-04
652532.991    4842178.100    68.634    2020.32    2358.83    DJI_20261012114152_0090_V.JPG    GCP-02
652532.991    4842178.100    68.634    2006.84    2989.47    DJI_20261012114154_0091_V.JPG    GCP-02
652542.163    4842206.497    68.626    2020.37    2281.77    DJI_20261012114222_0104_V.JPG    GCP-06

 

 


 

 

WebOdm e Aruco

Esiste gia' un plugin per usare aruco tag come marker all'interno di un progetto di fotogrammetria con webodm in modo da eliminare l...