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.
Files changed (46) hide show
  1. optictriage/__init__.py +0 -0
  2. optictriage/__main__.py +11 -0
  3. optictriage/app.py +56 -0
  4. optictriage/database.py +56 -0
  5. optictriage/database_migration.py +47 -0
  6. optictriage/exporters/colmap.py +119 -0
  7. optictriage/exporters/csv_manifest.py +48 -0
  8. optictriage/exporters/gps_overlap.py +152 -0
  9. optictriage/exporters/metashape.py +70 -0
  10. optictriage/exporters/odm.py +100 -0
  11. optictriage/exporters/rtk_validator.py +47 -0
  12. optictriage/metadata/dji_fix.py +77 -0
  13. optictriage/metadata/exif_reader.py +84 -0
  14. optictriage/metadata/exif_writer.py +63 -0
  15. optictriage/metadata/validators.py +45 -0
  16. optictriage/models.py +126 -0
  17. optictriage/pipeline.py +54 -0
  18. optictriage/stages/base.py +39 -0
  19. optictriage/stages/color_stage.py +156 -0
  20. optictriage/stages/exif_stage.py +152 -0
  21. optictriage/stages/export_stage.py +167 -0
  22. optictriage/stages/gpx_stage.py +96 -0
  23. optictriage/stages/import_stage.py +169 -0
  24. optictriage/stages/quality_stage.py +171 -0
  25. optictriage/stages/target_stage.py +113 -0
  26. optictriage/ui/blur_histogram.py +97 -0
  27. optictriage/ui/dashboard.py +102 -0
  28. optictriage/ui/export_panel.py +117 -0
  29. optictriage/ui/import_panel.py +144 -0
  30. optictriage/ui/main_window.py +99 -0
  31. optictriage/ui/metadata_panel.py +89 -0
  32. optictriage/ui/target_overlay.py +74 -0
  33. optictriage/vision/blur.py +54 -0
  34. optictriage/vision/colorchecker.py +148 -0
  35. optictriage/vision/exposure.py +30 -0
  36. optictriage/vision/glare.py +43 -0
  37. optictriage/vision/gpu_accel.py +50 -0
  38. optictriage/vision/preprocessing.py +72 -0
  39. optictriage/vision/raw_preview.py +62 -0
  40. optictriage/vision/targets.py +75 -0
  41. optictriage/workers.py +56 -0
  42. optictriage-0.1.0.dist-info/METADATA +81 -0
  43. optictriage-0.1.0.dist-info/RECORD +46 -0
  44. optictriage-0.1.0.dist-info/WHEEL +4 -0
  45. optictriage-0.1.0.dist-info/entry_points.txt +2 -0
  46. optictriage-0.1.0.dist-info/licenses/LICENSE +21 -0
File without changes
@@ -0,0 +1,11 @@
1
+ """__main__.py — Package execution entry point.
2
+ exports: N/A
3
+ used_by: optictriage CLI entrypoint
4
+ rules:
5
+ Simply proxy to app.main()
6
+ """
7
+
8
+ from optictriage.app import main
9
+
10
+ if __name__ == "__main__":
11
+ main()
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()
@@ -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)