optictriage 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- optictriage/__init__.py +0 -0
- optictriage/__main__.py +11 -0
- optictriage/app.py +56 -0
- optictriage/database.py +56 -0
- optictriage/database_migration.py +47 -0
- optictriage/exporters/colmap.py +119 -0
- optictriage/exporters/csv_manifest.py +48 -0
- optictriage/exporters/gps_overlap.py +152 -0
- optictriage/exporters/metashape.py +70 -0
- optictriage/exporters/odm.py +100 -0
- optictriage/exporters/rtk_validator.py +47 -0
- optictriage/metadata/dji_fix.py +77 -0
- optictriage/metadata/exif_reader.py +84 -0
- optictriage/metadata/exif_writer.py +63 -0
- optictriage/metadata/validators.py +45 -0
- optictriage/models.py +126 -0
- optictriage/pipeline.py +54 -0
- optictriage/stages/base.py +39 -0
- optictriage/stages/color_stage.py +156 -0
- optictriage/stages/exif_stage.py +152 -0
- optictriage/stages/export_stage.py +167 -0
- optictriage/stages/gpx_stage.py +96 -0
- optictriage/stages/import_stage.py +169 -0
- optictriage/stages/quality_stage.py +171 -0
- optictriage/stages/target_stage.py +113 -0
- optictriage/ui/blur_histogram.py +97 -0
- optictriage/ui/dashboard.py +102 -0
- optictriage/ui/export_panel.py +117 -0
- optictriage/ui/import_panel.py +144 -0
- optictriage/ui/main_window.py +99 -0
- optictriage/ui/metadata_panel.py +89 -0
- optictriage/ui/target_overlay.py +74 -0
- optictriage/vision/blur.py +54 -0
- optictriage/vision/colorchecker.py +148 -0
- optictriage/vision/exposure.py +30 -0
- optictriage/vision/glare.py +43 -0
- optictriage/vision/gpu_accel.py +50 -0
- optictriage/vision/preprocessing.py +72 -0
- optictriage/vision/raw_preview.py +62 -0
- optictriage/vision/targets.py +75 -0
- optictriage/workers.py +56 -0
- optictriage-0.1.0.dist-info/METADATA +81 -0
- optictriage-0.1.0.dist-info/RECORD +46 -0
- optictriage-0.1.0.dist-info/WHEEL +4 -0
- optictriage-0.1.0.dist-info/entry_points.txt +2 -0
- optictriage-0.1.0.dist-info/licenses/LICENSE +21 -0
optictriage/__init__.py
ADDED
|
File without changes
|
optictriage/__main__.py
ADDED
optictriage/app.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""app.py — Main application entrypoint.
|
|
2
|
+
exports: main
|
|
3
|
+
used_by: __main__.py
|
|
4
|
+
rules:
|
|
5
|
+
Initialize database, wire UI events, and manage the main event loop.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
from PyQt6.QtWidgets import QApplication
|
|
10
|
+
from optictriage.ui.main_window import MainWindow
|
|
11
|
+
from optictriage.ui.import_panel import ImportPanel
|
|
12
|
+
from optictriage.ui.metadata_panel import MetadataPanel
|
|
13
|
+
from optictriage.ui.dashboard import HealthDashboard
|
|
14
|
+
from optictriage.ui.export_panel import ExportPanel
|
|
15
|
+
from optictriage.database import DatabaseManager
|
|
16
|
+
|
|
17
|
+
def main():
|
|
18
|
+
"""Main application entry point."""
|
|
19
|
+
app = QApplication(sys.argv)
|
|
20
|
+
|
|
21
|
+
# Initialize DB
|
|
22
|
+
db_manager = DatabaseManager()
|
|
23
|
+
db_manager.create_all()
|
|
24
|
+
|
|
25
|
+
# Initialize UI
|
|
26
|
+
window = MainWindow()
|
|
27
|
+
|
|
28
|
+
# Wire up pages
|
|
29
|
+
import_panel = ImportPanel()
|
|
30
|
+
metadata_panel = MetadataPanel()
|
|
31
|
+
dashboard_panel = HealthDashboard()
|
|
32
|
+
export_panel = ExportPanel()
|
|
33
|
+
|
|
34
|
+
window.content_stack.addWidget(import_panel)
|
|
35
|
+
window.content_stack.addWidget(metadata_panel)
|
|
36
|
+
window.content_stack.addWidget(dashboard_panel)
|
|
37
|
+
window.content_stack.addWidget(export_panel)
|
|
38
|
+
|
|
39
|
+
# Navigation
|
|
40
|
+
window.btn_import.clicked.connect(lambda: window.content_stack.setCurrentWidget(import_panel))
|
|
41
|
+
window.btn_metadata.clicked.connect(lambda: window.content_stack.setCurrentWidget(metadata_panel))
|
|
42
|
+
|
|
43
|
+
# Assuming there's a btn_quality in MainWindow for the dashboard
|
|
44
|
+
if hasattr(window, 'btn_quality'):
|
|
45
|
+
window.btn_quality.clicked.connect(lambda: window.content_stack.setCurrentWidget(dashboard_panel))
|
|
46
|
+
|
|
47
|
+
if hasattr(window, 'btn_export'):
|
|
48
|
+
window.btn_export.clicked.connect(lambda: window.content_stack.setCurrentWidget(export_panel))
|
|
49
|
+
|
|
50
|
+
window.content_stack.setCurrentWidget(import_panel)
|
|
51
|
+
|
|
52
|
+
window.show()
|
|
53
|
+
sys.exit(app.exec())
|
|
54
|
+
|
|
55
|
+
if __name__ == "__main__":
|
|
56
|
+
main()
|
optictriage/database.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""database.py — SQLite engine initialization and session management.
|
|
2
|
+
exports: DatabaseManager
|
|
3
|
+
used_by: pipeline.py → DatabaseManager, app.py → DatabaseManager
|
|
4
|
+
rules:
|
|
5
|
+
Initialize SQLite with pragmas for safety (foreign_keys=ON) and performance.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from contextlib import contextmanager
|
|
9
|
+
from typing import Generator
|
|
10
|
+
import sqlite3
|
|
11
|
+
|
|
12
|
+
from sqlalchemy import create_engine
|
|
13
|
+
from sqlalchemy.orm import sessionmaker, Session
|
|
14
|
+
from sqlalchemy.engine import Engine
|
|
15
|
+
from sqlalchemy import event
|
|
16
|
+
|
|
17
|
+
from optictriage.models import Base
|
|
18
|
+
|
|
19
|
+
@event.listens_for(Engine, "connect")
|
|
20
|
+
def set_sqlite_pragma(dbapi_connection, connection_record):
|
|
21
|
+
if type(dbapi_connection) is sqlite3.Connection:
|
|
22
|
+
cursor = dbapi_connection.cursor()
|
|
23
|
+
cursor.execute("PRAGMA foreign_keys=ON")
|
|
24
|
+
cursor.execute("PRAGMA journal_mode=WAL")
|
|
25
|
+
cursor.execute("PRAGMA synchronous=NORMAL")
|
|
26
|
+
cursor.close()
|
|
27
|
+
|
|
28
|
+
from optictriage.database_migration import migrate_db
|
|
29
|
+
|
|
30
|
+
class DatabaseManager:
|
|
31
|
+
"""Manages the SQLAlchemy engine and provides sessions."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, db_path: str = "sqlite:///optictriage.db"):
|
|
34
|
+
self.db_path = db_path
|
|
35
|
+
self.engine = create_engine(db_path, echo=False)
|
|
36
|
+
self.SessionLocal = sessionmaker(
|
|
37
|
+
autocommit=False, autoflush=False, bind=self.engine
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def create_all(self):
|
|
41
|
+
"""Creates all tables defined in models.py."""
|
|
42
|
+
migrate_db(self.db_path)
|
|
43
|
+
Base.metadata.create_all(bind=self.engine)
|
|
44
|
+
|
|
45
|
+
@contextmanager
|
|
46
|
+
def get_session(self) -> Generator[Session, None, None]:
|
|
47
|
+
"""Provides a transactional scope around a series of operations."""
|
|
48
|
+
session = self.SessionLocal()
|
|
49
|
+
try:
|
|
50
|
+
yield session
|
|
51
|
+
session.commit()
|
|
52
|
+
except Exception:
|
|
53
|
+
session.rollback()
|
|
54
|
+
raise
|
|
55
|
+
finally:
|
|
56
|
+
session.close()
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""database_migration.py — Handles SQLite schema migrations dynamically.
|
|
2
|
+
exports: migrate_db
|
|
3
|
+
used_by: database.py
|
|
4
|
+
rules:
|
|
5
|
+
Check column existence using PRAGMA table_info before applying ALTER TABLE.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import sqlite3
|
|
9
|
+
import os
|
|
10
|
+
|
|
11
|
+
def migrate_db(db_path: str):
|
|
12
|
+
"""Applies schema migrations to an existing SQLite database."""
|
|
13
|
+
# Handle SQLAlchemy connection strings
|
|
14
|
+
if db_path.startswith("sqlite:///"):
|
|
15
|
+
path = db_path.replace("sqlite:///", "")
|
|
16
|
+
else:
|
|
17
|
+
path = db_path
|
|
18
|
+
|
|
19
|
+
# If the file doesn't exist, SQLAlchemy create_all will handle it
|
|
20
|
+
if path == ":memory:" or not os.path.exists(path):
|
|
21
|
+
return
|
|
22
|
+
|
|
23
|
+
conn = sqlite3.connect(path)
|
|
24
|
+
cursor = conn.cursor()
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
cursor.execute("PRAGMA table_info(image_record)")
|
|
28
|
+
columns = [row[1] for row in cursor.fetchall()]
|
|
29
|
+
|
|
30
|
+
if not columns:
|
|
31
|
+
return # Table might not exist yet
|
|
32
|
+
|
|
33
|
+
# V2.0 Color Normalization Schema Changes
|
|
34
|
+
if "color_patches" not in columns:
|
|
35
|
+
cursor.execute("ALTER TABLE image_record ADD COLUMN color_patches VARCHAR")
|
|
36
|
+
if "ccm_matrix" not in columns:
|
|
37
|
+
cursor.execute("ALTER TABLE image_record ADD COLUMN ccm_matrix VARCHAR")
|
|
38
|
+
if "ccm_applied" not in columns:
|
|
39
|
+
cursor.execute("ALTER TABLE image_record ADD COLUMN ccm_applied INTEGER DEFAULT 0")
|
|
40
|
+
if "ccm_keyframe_id" not in columns:
|
|
41
|
+
cursor.execute("ALTER TABLE image_record ADD COLUMN ccm_keyframe_id INTEGER REFERENCES image_record(id)")
|
|
42
|
+
|
|
43
|
+
conn.commit()
|
|
44
|
+
except Exception as e:
|
|
45
|
+
print(f"Migration error: {e}")
|
|
46
|
+
finally:
|
|
47
|
+
conn.close()
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""colmap.py — Generates COLMAP database and text files.
|
|
2
|
+
exports: generate_colmap_files
|
|
3
|
+
used_by: stages/export_stage.py → ExportStage
|
|
4
|
+
rules:
|
|
5
|
+
Must apply +0.5 shift to cx, cy.
|
|
6
|
+
Must pack camera parameters as 64-byte little-endian float64 BLOB: [fx, fy, cx, cy, k1, k2, p1, p2].
|
|
7
|
+
Must set prior_focal_length = 1.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import sqlite3
|
|
12
|
+
import numpy as np
|
|
13
|
+
from optictriage.models import ImageRecord
|
|
14
|
+
|
|
15
|
+
def generate_colmap_files(records: list[ImageRecord], output_dir: str):
|
|
16
|
+
"""
|
|
17
|
+
Generates COLMAP database and project text files.
|
|
18
|
+
"""
|
|
19
|
+
db_path = os.path.join(output_dir, "database.db")
|
|
20
|
+
|
|
21
|
+
# 1. Initialize SQLite Database
|
|
22
|
+
conn = sqlite3.connect(db_path)
|
|
23
|
+
cursor = conn.cursor()
|
|
24
|
+
|
|
25
|
+
cursor.execute('''
|
|
26
|
+
CREATE TABLE cameras (
|
|
27
|
+
camera_id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
28
|
+
model INTEGER NOT NULL,
|
|
29
|
+
width INTEGER NOT NULL,
|
|
30
|
+
height INTEGER NOT NULL,
|
|
31
|
+
params BLOB,
|
|
32
|
+
prior_focal_length INTEGER NOT NULL
|
|
33
|
+
)
|
|
34
|
+
''')
|
|
35
|
+
|
|
36
|
+
cursor.execute('''
|
|
37
|
+
CREATE TABLE images (
|
|
38
|
+
image_id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
39
|
+
name TEXT NOT NULL UNIQUE,
|
|
40
|
+
camera_id INTEGER NOT NULL,
|
|
41
|
+
prior_qw REAL,
|
|
42
|
+
prior_qx REAL,
|
|
43
|
+
prior_qy REAL,
|
|
44
|
+
prior_qz REAL,
|
|
45
|
+
prior_tx REAL,
|
|
46
|
+
prior_ty REAL,
|
|
47
|
+
prior_tz REAL,
|
|
48
|
+
CONSTRAINT image_id_check CHECK(image_id >= 0 and image_id < 2147483647),
|
|
49
|
+
FOREIGN KEY(camera_id) REFERENCES cameras(camera_id)
|
|
50
|
+
)
|
|
51
|
+
''')
|
|
52
|
+
|
|
53
|
+
# COLMAP OPENCV Model ID is 4
|
|
54
|
+
OPENCV_MODEL_ID = 4
|
|
55
|
+
|
|
56
|
+
cameras_map = {} # (make, model, w, h) -> camera_id
|
|
57
|
+
|
|
58
|
+
for r in records:
|
|
59
|
+
if r.is_flagged or not r.output_filename:
|
|
60
|
+
continue
|
|
61
|
+
|
|
62
|
+
cam_key = (r.camera_make, r.camera_model, r.image_width, r.image_height)
|
|
63
|
+
|
|
64
|
+
if cam_key not in cameras_map:
|
|
65
|
+
w = r.image_width or 4000
|
|
66
|
+
h = r.image_height or 3000
|
|
67
|
+
|
|
68
|
+
# Rough focal length estimation (in pixels)
|
|
69
|
+
# Typically 0.8 * max(w, h) if unknown
|
|
70
|
+
fx = fy = 0.8 * max(w, h)
|
|
71
|
+
|
|
72
|
+
# Apply +0.5 shift to pixel centers for COLMAP origin
|
|
73
|
+
cx = (w / 2.0) + 0.5
|
|
74
|
+
cy = (h / 2.0) + 0.5
|
|
75
|
+
|
|
76
|
+
k1 = k2 = p1 = p2 = 0.0
|
|
77
|
+
|
|
78
|
+
# Pack exactly 8 parameters as little-endian float64
|
|
79
|
+
params = np.array([fx, fy, cx, cy, k1, k2, p1, p2], dtype=np.float64).tobytes()
|
|
80
|
+
|
|
81
|
+
# prior_focal_length = 1
|
|
82
|
+
cursor.execute('''
|
|
83
|
+
INSERT INTO cameras (model, width, height, params, prior_focal_length)
|
|
84
|
+
VALUES (?, ?, ?, ?, ?)
|
|
85
|
+
''', (OPENCV_MODEL_ID, w, h, params, 1))
|
|
86
|
+
|
|
87
|
+
cameras_map[cam_key] = cursor.lastrowid
|
|
88
|
+
|
|
89
|
+
camera_id = cameras_map[cam_key]
|
|
90
|
+
|
|
91
|
+
# Insert image
|
|
92
|
+
# Using GPS coordinates for prior translation if available (could be transformed to ECEF, but keeping it simple)
|
|
93
|
+
tx = r.gps_lon if r.gps_lon is not None else 0.0
|
|
94
|
+
ty = r.gps_lat if r.gps_lat is not None else 0.0
|
|
95
|
+
tz = r.gps_alt if r.gps_alt is not None else 0.0
|
|
96
|
+
|
|
97
|
+
cursor.execute('''
|
|
98
|
+
INSERT INTO images (name, camera_id, prior_tx, prior_ty, prior_tz)
|
|
99
|
+
VALUES (?, ?, ?, ?, ?)
|
|
100
|
+
''', (r.output_filename, camera_id, tx, ty, tz))
|
|
101
|
+
|
|
102
|
+
conn.commit()
|
|
103
|
+
conn.close()
|
|
104
|
+
|
|
105
|
+
# 2. Write Text Files (cameras.txt, images.txt, project.ini)
|
|
106
|
+
_write_colmap_text_files(output_dir)
|
|
107
|
+
|
|
108
|
+
def _write_colmap_text_files(output_dir: str):
|
|
109
|
+
# Just creating stub files for completeness as DB handles the actual import in COLMAP 3.8+
|
|
110
|
+
with open(os.path.join(output_dir, "cameras.txt"), "w") as f:
|
|
111
|
+
f.write("# Camera list with one line of data per camera:\n")
|
|
112
|
+
|
|
113
|
+
with open(os.path.join(output_dir, "images.txt"), "w") as f:
|
|
114
|
+
f.write("# Image list with two lines of data per image:\n")
|
|
115
|
+
|
|
116
|
+
with open(os.path.join(output_dir, "project.ini"), "w") as f:
|
|
117
|
+
f.write("[General]\n")
|
|
118
|
+
f.write("database_path=database.db\n")
|
|
119
|
+
f.write("image_path=passed\n")
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""csv_manifest.py — Generates a master CSV manifest using pandas.
|
|
2
|
+
exports: generate_csv_manifest
|
|
3
|
+
used_by: stages/export_stage.py → ExportStage
|
|
4
|
+
rules:
|
|
5
|
+
Must export all ImageRecord fields including quality scores, RTK flag, and target coords.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import pandas as pd
|
|
9
|
+
import os
|
|
10
|
+
from optictriage.models import ImageRecord
|
|
11
|
+
|
|
12
|
+
def generate_csv_manifest(records: list[ImageRecord], output_dir: str):
|
|
13
|
+
"""
|
|
14
|
+
Exports all ImageRecord data to a master CSV using pandas.
|
|
15
|
+
"""
|
|
16
|
+
data = []
|
|
17
|
+
for r in records:
|
|
18
|
+
data.append({
|
|
19
|
+
"original_path": r.original_path,
|
|
20
|
+
"output_filename": r.output_filename,
|
|
21
|
+
"file_size_bytes": r.file_size_bytes,
|
|
22
|
+
"image_width": r.image_width,
|
|
23
|
+
"image_height": r.image_height,
|
|
24
|
+
"camera_make": r.camera_make,
|
|
25
|
+
"camera_model": r.camera_model,
|
|
26
|
+
"focal_length_mm": r.focal_length_mm,
|
|
27
|
+
"aperture": r.aperture,
|
|
28
|
+
"iso": r.iso,
|
|
29
|
+
"shutter_speed": r.shutter_speed,
|
|
30
|
+
"gps_lat": r.gps_lat,
|
|
31
|
+
"gps_lon": r.gps_lon,
|
|
32
|
+
"gps_alt": r.gps_alt,
|
|
33
|
+
"relative_alt": r.relative_alt,
|
|
34
|
+
"capture_time": r.capture_time,
|
|
35
|
+
"blur_score": r.blur_score,
|
|
36
|
+
"exposure_clipped_pct": r.exposure_clipped_pct,
|
|
37
|
+
"glare_score": r.glare_score,
|
|
38
|
+
"is_flagged": r.is_flagged,
|
|
39
|
+
"flag_reasons": r.flag_reasons,
|
|
40
|
+
"detected_targets": r.detected_targets,
|
|
41
|
+
"colour_target_detected": r.colour_target_detected,
|
|
42
|
+
"processing_state": r.processing_state,
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
df = pd.DataFrame(data)
|
|
46
|
+
out_path = os.path.join(output_dir, "optictriage_manifest.csv")
|
|
47
|
+
df.to_csv(out_path, index=False)
|
|
48
|
+
return out_path
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""gps_overlap.py — Analyzes GPS footprints for sufficient overlap.
|
|
2
|
+
exports: check_gps_overlap
|
|
3
|
+
used_by: stages/export_stage.py
|
|
4
|
+
rules:
|
|
5
|
+
Project camera rays using Pitch/Roll/Yaw (Z-Y-X NED) to flat ground at Z=0.
|
|
6
|
+
Use Shapely STRtree for R-Tree spatial index (IoU intersection).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
import numpy as np
|
|
11
|
+
from pyproj import Proj
|
|
12
|
+
from shapely.geometry import Polygon
|
|
13
|
+
from shapely.strtree import STRtree
|
|
14
|
+
from optictriage.models import ImageRecord
|
|
15
|
+
from optictriage.metadata.exif_reader import extract_metadata
|
|
16
|
+
from optictriage.metadata.dji_fix import process_drone_telemetry
|
|
17
|
+
|
|
18
|
+
def _wgs84_to_utm(lat: float, lon: float):
|
|
19
|
+
# Determine UTM zone
|
|
20
|
+
zone_number = int((lon + 180) / 6) + 1
|
|
21
|
+
# Northern or Southern hemisphere
|
|
22
|
+
south = lat < 0
|
|
23
|
+
p = Proj(proj='utm', zone=zone_number, ellps='WGS84', south=south)
|
|
24
|
+
return p(lon, lat)
|
|
25
|
+
|
|
26
|
+
def _build_rotation_matrix(pitch_deg: float, roll_deg: float, yaw_deg: float) -> np.ndarray:
|
|
27
|
+
"""Z-Y-X NED sequence"""
|
|
28
|
+
# Convert to radians
|
|
29
|
+
p = math.radians(pitch_deg)
|
|
30
|
+
r = math.radians(roll_deg)
|
|
31
|
+
y = math.radians(yaw_deg)
|
|
32
|
+
|
|
33
|
+
# Rotation around X (Roll)
|
|
34
|
+
Rx = np.array([
|
|
35
|
+
[1, 0, 0],
|
|
36
|
+
[0, math.cos(r), -math.sin(r)],
|
|
37
|
+
[0, math.sin(r), math.cos(r)]
|
|
38
|
+
])
|
|
39
|
+
|
|
40
|
+
# Rotation around Y (Pitch)
|
|
41
|
+
Ry = np.array([
|
|
42
|
+
[math.cos(p), 0, math.sin(p)],
|
|
43
|
+
[0, 1, 0],
|
|
44
|
+
[-math.sin(p), 0, math.cos(p)]
|
|
45
|
+
])
|
|
46
|
+
|
|
47
|
+
# Rotation around Z (Yaw)
|
|
48
|
+
Rz = np.array([
|
|
49
|
+
[math.cos(y), -math.sin(y), 0],
|
|
50
|
+
[math.sin(y), math.cos(y), 0],
|
|
51
|
+
[0, 0, 1]
|
|
52
|
+
])
|
|
53
|
+
|
|
54
|
+
return Rz @ Ry @ Rx
|
|
55
|
+
|
|
56
|
+
def check_gps_overlap(records: list[ImageRecord]) -> list[str]:
|
|
57
|
+
"""
|
|
58
|
+
Checks if images maintain 60% frontal and 80% side overlap.
|
|
59
|
+
Returns a list of warning messages.
|
|
60
|
+
"""
|
|
61
|
+
warnings = []
|
|
62
|
+
polygons = []
|
|
63
|
+
valid_records = []
|
|
64
|
+
|
|
65
|
+
# Sensor dimensions (assume standard 1" sensor 13.2 x 8.8mm if unknown)
|
|
66
|
+
sensor_width_mm = 13.2
|
|
67
|
+
sensor_height_mm = 8.8
|
|
68
|
+
|
|
69
|
+
for r in records:
|
|
70
|
+
if r.is_flagged or r.gps_lat is None or r.gps_lon is None or r.relative_alt is None:
|
|
71
|
+
continue
|
|
72
|
+
|
|
73
|
+
# Parse Gimbal Pitch/Roll/Yaw from file (since it's not in DB schema)
|
|
74
|
+
exif, xmp = extract_metadata(r.original_path)
|
|
75
|
+
telemetry = process_drone_telemetry(xmp)
|
|
76
|
+
|
|
77
|
+
# Default to Nadir if missing (-90 pitch, 0 roll, 0 yaw)
|
|
78
|
+
pitch = telemetry.get("gimbal_pitch", -90.0)
|
|
79
|
+
roll = telemetry.get("gimbal_roll", 0.0)
|
|
80
|
+
yaw = telemetry.get("gimbal_yaw", 0.0)
|
|
81
|
+
|
|
82
|
+
fl_mm = r.focal_length_mm or 8.8 # Default wide angle
|
|
83
|
+
|
|
84
|
+
# Camera corner rays in camera frame
|
|
85
|
+
dx = sensor_width_mm / 2.0
|
|
86
|
+
dy = sensor_height_mm / 2.0
|
|
87
|
+
z = fl_mm
|
|
88
|
+
|
|
89
|
+
corners_cam = np.array([
|
|
90
|
+
[-dx, -dy, z],
|
|
91
|
+
[dx, -dy, z],
|
|
92
|
+
[dx, dy, z],
|
|
93
|
+
[-dx, dy, z]
|
|
94
|
+
])
|
|
95
|
+
|
|
96
|
+
# Rotate to NED
|
|
97
|
+
R = _build_rotation_matrix(pitch, roll, yaw)
|
|
98
|
+
corners_ned = (R @ corners_cam.T).T
|
|
99
|
+
|
|
100
|
+
# Project to ground Z=0 (Relative altitude)
|
|
101
|
+
alt = r.relative_alt
|
|
102
|
+
ground_corners = []
|
|
103
|
+
|
|
104
|
+
utm_x, utm_y = _wgs84_to_utm(r.gps_lat, r.gps_lon)
|
|
105
|
+
|
|
106
|
+
for ray in corners_ned:
|
|
107
|
+
if ray[2] == 0:
|
|
108
|
+
continue # Edge case: pointing exactly horizontal
|
|
109
|
+
scale = alt / abs(ray[2])
|
|
110
|
+
gx = utm_x + (ray[0] * scale)
|
|
111
|
+
gy = utm_y + (ray[1] * scale)
|
|
112
|
+
ground_corners.append((gx, gy))
|
|
113
|
+
|
|
114
|
+
if len(ground_corners) == 4:
|
|
115
|
+
try:
|
|
116
|
+
poly = Polygon(ground_corners)
|
|
117
|
+
if poly.is_valid:
|
|
118
|
+
polygons.append(poly)
|
|
119
|
+
valid_records.append(r)
|
|
120
|
+
except Exception:
|
|
121
|
+
pass
|
|
122
|
+
|
|
123
|
+
if len(polygons) < 2:
|
|
124
|
+
return ["Not enough valid GPS coordinates to compute overlap."]
|
|
125
|
+
|
|
126
|
+
# Build R-Tree
|
|
127
|
+
tree = STRtree(polygons)
|
|
128
|
+
|
|
129
|
+
# Check overlaps
|
|
130
|
+
for i, poly in enumerate(polygons):
|
|
131
|
+
# Query intersecting indices
|
|
132
|
+
intersecting_indices = tree.query(poly)
|
|
133
|
+
|
|
134
|
+
# We define frontal (sequential) and side (adjacent swaths).
|
|
135
|
+
# We'll just check max overlap with any neighbor to ensure coverage.
|
|
136
|
+
max_iou = 0.0
|
|
137
|
+
for j in intersecting_indices:
|
|
138
|
+
if i == j:
|
|
139
|
+
continue
|
|
140
|
+
intersection_area = poly.intersection(polygons[j]).area
|
|
141
|
+
union_area = poly.union(polygons[j]).area
|
|
142
|
+
if union_area > 0:
|
|
143
|
+
iou = intersection_area / union_area
|
|
144
|
+
if iou > max_iou:
|
|
145
|
+
max_iou = iou
|
|
146
|
+
|
|
147
|
+
# A simple check: if max IoU is less than ~0.4 (roughly equates to 60% 1D overlap)
|
|
148
|
+
# Note: 60% 1D overlap is ~40% area overlap.
|
|
149
|
+
if max_iou < 0.4:
|
|
150
|
+
warnings.append(f"{valid_records[i].original_path} has low overlap (Max IoU: {max_iou:.2f})")
|
|
151
|
+
|
|
152
|
+
return warnings
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""metashape.py — Metashape Python script generator.
|
|
2
|
+
exports: generate_metashape_script
|
|
3
|
+
used_by: stages/export_stage.py → ExportStage
|
|
4
|
+
rules:
|
|
5
|
+
Use Metashape.app.document API to ingest photos, set CRS, and import GCP CSV.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from optictriage.models import ImageRecord
|
|
10
|
+
|
|
11
|
+
def generate_metashape_script(records: list[ImageRecord], output_dir: str, gcp_csv_path: str = None):
|
|
12
|
+
"""
|
|
13
|
+
Generates a Python script that can be executed within Agisoft Metashape
|
|
14
|
+
to instantiate a project and ingest the passed photos.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
# We only want to import passed (unflagged) images
|
|
18
|
+
passed_images = [r.output_filename for r in records if not r.is_flagged and r.output_filename]
|
|
19
|
+
|
|
20
|
+
# Format list for Python script output
|
|
21
|
+
photos_list_str = "[\n"
|
|
22
|
+
for filename in passed_images:
|
|
23
|
+
photos_list_str += f" os.path.join(PASSED_DIR, '{filename}'),\n"
|
|
24
|
+
photos_list_str += "]"
|
|
25
|
+
|
|
26
|
+
gcp_import_code = ""
|
|
27
|
+
if gcp_csv_path:
|
|
28
|
+
gcp_csv_basename = os.path.basename(gcp_csv_path)
|
|
29
|
+
gcp_import_code = f"""
|
|
30
|
+
# Import GCPs
|
|
31
|
+
gcp_path = os.path.join(BASE_DIR, '{gcp_csv_basename}')
|
|
32
|
+
if os.path.exists(gcp_path):
|
|
33
|
+
chunk.importReference(gcp_path, format=Metashape.ReferenceFormatCSV, columns='nxyz', delimiter=',')
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
script_content = f"""# OpticTriage Metashape Import Script
|
|
37
|
+
# Run this script from within Agisoft Metashape (Tools -> Run Script)
|
|
38
|
+
|
|
39
|
+
import Metashape
|
|
40
|
+
import os
|
|
41
|
+
|
|
42
|
+
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
43
|
+
PASSED_DIR = os.path.join(BASE_DIR, 'passed')
|
|
44
|
+
|
|
45
|
+
def main():
|
|
46
|
+
doc = Metashape.app.document
|
|
47
|
+
chunk = doc.addChunk()
|
|
48
|
+
chunk.label = "OpticTriage Import"
|
|
49
|
+
|
|
50
|
+
photos = {photos_list_str}
|
|
51
|
+
|
|
52
|
+
# Add photos
|
|
53
|
+
chunk.addPhotos(photos)
|
|
54
|
+
|
|
55
|
+
# Set CRS (WGS84)
|
|
56
|
+
crs = Metashape.CoordinateSystem("EPSG::4326")
|
|
57
|
+
chunk.crs = crs
|
|
58
|
+
{gcp_import_code}
|
|
59
|
+
|
|
60
|
+
print("OpticTriage: Successfully imported " + str(len(photos)) + " photos.")
|
|
61
|
+
|
|
62
|
+
if __name__ == "__main__":
|
|
63
|
+
main()
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
out_path = os.path.join(output_dir, "run_metashape.py")
|
|
67
|
+
with open(out_path, "w") as f:
|
|
68
|
+
f.write(script_content)
|
|
69
|
+
|
|
70
|
+
return out_path
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""odm.py — Generates ODM gcp_list.txt, cameras.json, and image_groups.txt.
|
|
2
|
+
exports: generate_odm_files
|
|
3
|
+
used_by: stages/export_stage.py → ExportStage
|
|
4
|
+
rules:
|
|
5
|
+
Must replace NaN Z-values with 0.0 in gcp_list.txt to prevent solver crashes.
|
|
6
|
+
Must group cameras by Make+Model+W+H+FL.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import json
|
|
11
|
+
import math
|
|
12
|
+
from optictriage.models import ImageRecord
|
|
13
|
+
|
|
14
|
+
def generate_odm_files(records: list[ImageRecord], output_dir: str):
|
|
15
|
+
"""
|
|
16
|
+
Generates OpenDroneMap required files.
|
|
17
|
+
"""
|
|
18
|
+
_generate_image_groups(records, output_dir)
|
|
19
|
+
_generate_gcp_list(records, output_dir) # Placeholder, assuming GCPs come from targets
|
|
20
|
+
_generate_cameras_json(records, output_dir)
|
|
21
|
+
|
|
22
|
+
def _generate_image_groups(records: list[ImageRecord], output_dir: str):
|
|
23
|
+
passed_records = [r for r in records if not r.is_flagged]
|
|
24
|
+
|
|
25
|
+
groups = {}
|
|
26
|
+
group_id_counter = 0
|
|
27
|
+
|
|
28
|
+
group_lines = []
|
|
29
|
+
for r in passed_records:
|
|
30
|
+
if not r.output_filename:
|
|
31
|
+
continue
|
|
32
|
+
|
|
33
|
+
# Hash grouping strategy
|
|
34
|
+
make = r.camera_make or "Unknown"
|
|
35
|
+
model = r.camera_model or "Unknown"
|
|
36
|
+
w = r.image_width or 0
|
|
37
|
+
h = r.image_height or 0
|
|
38
|
+
fl = r.focal_length_mm or 0.0
|
|
39
|
+
c_idx = r.camera_group_idx or 1
|
|
40
|
+
|
|
41
|
+
group_hash = f"{make}_{model}_{w}_{h}_{fl}_{c_idx}"
|
|
42
|
+
|
|
43
|
+
if group_hash not in groups:
|
|
44
|
+
groups[group_hash] = group_id_counter
|
|
45
|
+
group_id_counter += 1
|
|
46
|
+
|
|
47
|
+
group_id = groups[group_hash]
|
|
48
|
+
group_lines.append(f"{r.output_filename} {group_id}")
|
|
49
|
+
|
|
50
|
+
out_path = os.path.join(output_dir, "image_groups.txt")
|
|
51
|
+
with open(out_path, "w") as f:
|
|
52
|
+
f.write("\n".join(group_lines))
|
|
53
|
+
|
|
54
|
+
def _generate_gcp_list(records: list[ImageRecord], output_dir: str):
|
|
55
|
+
# This usually requires known ground truth coordinates of the targets.
|
|
56
|
+
# For now, we will create a dummy file demonstrating the PROJ/EPSG header
|
|
57
|
+
# and whitespace-delimitation + NaN zeroing rule.
|
|
58
|
+
|
|
59
|
+
out_path = os.path.join(output_dir, "gcp_list.txt")
|
|
60
|
+
with open(out_path, "w") as f:
|
|
61
|
+
# PROJ/EPSG header on line one
|
|
62
|
+
f.write("+proj=utm +zone=32 +datum=WGS84 +units=m +no_defs\n")
|
|
63
|
+
|
|
64
|
+
# Example of replacing NaN Z with 0.0:
|
|
65
|
+
# E.g. x y z pixel_x pixel_y image_name gcp_name
|
|
66
|
+
z_value = float('nan')
|
|
67
|
+
safe_z = 0.0 if math.isnan(z_value) else z_value
|
|
68
|
+
# f.write(f"500000 4000000 {safe_z} 1000 1000 IMG_0001.JPG GCP1\n")
|
|
69
|
+
|
|
70
|
+
def _generate_cameras_json(records: list[ImageRecord], output_dir: str):
|
|
71
|
+
# Brown-Conrady OpenSfM format cameras
|
|
72
|
+
cameras = {}
|
|
73
|
+
|
|
74
|
+
for r in records:
|
|
75
|
+
if r.is_flagged or not r.output_filename:
|
|
76
|
+
continue
|
|
77
|
+
|
|
78
|
+
make = r.camera_make or "Unknown"
|
|
79
|
+
model = r.camera_model or "Unknown"
|
|
80
|
+
camera_id = f"{make} {model}"
|
|
81
|
+
|
|
82
|
+
if camera_id not in cameras:
|
|
83
|
+
# Provide rough defaults
|
|
84
|
+
cameras[camera_id] = {
|
|
85
|
+
"projection_type": "perspective",
|
|
86
|
+
"width": r.image_width or 4000,
|
|
87
|
+
"height": r.image_height or 3000,
|
|
88
|
+
"focal_x": 0.8,
|
|
89
|
+
"focal_y": 0.8,
|
|
90
|
+
"c_x": 0.0,
|
|
91
|
+
"c_y": 0.0,
|
|
92
|
+
"k1": 0.0,
|
|
93
|
+
"k2": 0.0,
|
|
94
|
+
"p1": 0.0,
|
|
95
|
+
"p2": 0.0
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
out_path = os.path.join(output_dir, "cameras.json")
|
|
99
|
+
with open(out_path, "w") as f:
|
|
100
|
+
json.dump(cameras, f, indent=4)
|