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
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
#!/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()