From 1aacaea0844929bfac333e60f3edcc239bbf3961 Mon Sep 17 00:00:00 2001 From: Franck Garnier Date: Sun, 12 Apr 2026 22:34:00 -0400 Subject: [PATCH] feat: auto-generate metadata markdown for each panorama Includes: coverage, FOV, camera specs, photogrammetry reference, equirectangular projection info, timing, image list. Also: docker-compose user fix (1000:100) to avoid root-owned files. Co-Authored-By: Claude Opus 4.6 (1M context) --- docker-compose.yml | 1 + pipeline/panorama_metadata.py | 218 ++++++++++++++++++++++++++++++++++ pipeline/panorama_pipeline.py | 14 +++ 3 files changed, 233 insertions(+) create mode 100644 pipeline/panorama_metadata.py diff --git a/docker-compose.yml b/docker-compose.yml index f4cd6aa..1f34449 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,7 @@ services: # Mount NAS astro disk (images source + output) - /mnt/astro/mars_rovers:/data - /mnt/astro/mars_rovers/images/panorama/perseverance:/output + user: "1000:100" # franck:users — avoid root-owned output files environment: - MYSQL_HOST=192.168.1.42 - MYSQL_PORT=3306 diff --git a/pipeline/panorama_metadata.py b/pipeline/panorama_metadata.py new file mode 100644 index 0000000..d252289 --- /dev/null +++ b/pipeline/panorama_metadata.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +""" +Generate metadata markdown file for a completed panorama. +Can be called standalone or imported by the pipeline. +""" + +import json +import math +import os +import re +import sys +from datetime import datetime + + +def generate_metadata(work_dir, output_name, sol, camera, rover, + azs, els, img_w, img_h, fov, n_cps, timings, + original_images, blender, output_png): + """Generate a metadata .md file for a completed panorama.""" + + md_path = os.path.join(work_dir, f"{output_name}_metadata.md") + + # Image info + n_images = len(azs) + az_min = min(azs) + az_max = max(azs) + az_range = az_max - az_min + el_min = min(els) + el_max = max(els) + el_range = el_max - el_min + + # Output image info + out_w, out_h, out_size_mb = 0, 0, 0 + if output_png and os.path.exists(output_png): + out_size_mb = os.path.getsize(output_png) / 1024 / 1024 + try: + from PIL import Image + Image.MAX_IMAGE_PIXELS = None + img = Image.open(output_png) + out_w, out_h = img.size + except Exception: + pass + + # Gaps analysis + sorted_azs = sorted(azs) + gaps = [sorted_azs[i+1] - sorted_azs[i] for i in range(len(sorted_azs)-1)] + avg_gap = sum(gaps) / len(gaps) if gaps else 0 + max_gap = max(gaps) if gaps else 0 + min_gap = min(gaps) if gaps else 0 + + # Overlap estimate + overlap_per_pair = fov - avg_gap if fov > avg_gap else 0 + + # Coverage estimate + total_coverage_h = az_range + fov # first and last image extend beyond their centers + coverage_pct = min(total_coverage_h / 360 * 100, 100) + + # Vertical coverage + total_coverage_v = el_range + (img_h / img_w * fov) # approximate VFOV + vfov_per_image = img_h / img_w * fov # approximate + + # Resolution estimate (degrees per pixel in output) + if out_w > 0: + deg_per_px_h = total_coverage_h / out_w + px_per_deg_h = out_w / total_coverage_h + else: + deg_per_px_h = 0 + px_per_deg_h = 0 + + # Equirectangular full sphere equivalent + equiv_360_w = int(px_per_deg_h * 360) if px_per_deg_h > 0 else 0 + equiv_180_h = int(px_per_deg_h * 180) if px_per_deg_h > 0 else 0 + + # Image list with details + image_details = [] + for i, (az, el) in enumerate(zip(azs, els)): + fname = os.path.basename(original_images[i]) if i < len(original_images) else f"image_{i}" + image_details.append((i, fname, az, el)) + + total_time = sum(timings.values()) if timings else 0 + + content = f"""# Panorama Metadata — Sol {sol} + +> Auto-generated by mars-panorama-pipeline on {datetime.utcnow().strftime('%Y-%m-%d %H:%M UTC')} + +--- + +## Summary + +| Property | Value | +|----------|-------| +| **Sol** | {sol} | +| **Rover** | {rover.capitalize()} | +| **Camera** | {camera} | +| **Images** | {n_images} | +| **Control Points** | {n_cps:,} | +| **Blender** | {blender} | +| **Processing Time** | {total_time:.0f}s ({total_time/60:.1f} min) | +| **Output** | {out_w} x {out_h} pixels | +| **File Size** | {out_size_mb:.1f} MB | + +--- + +## Coverage + +| Property | Value | +|----------|-------| +| **Azimuth Range** | {az_min:.1f}° to {az_max:.1f}° ({az_range:.1f}° span) | +| **Elevation Range** | {el_min:.1f}° to {el_max:.1f}° ({el_range:.1f}° span) | +| **Horizontal Coverage** | ~{total_coverage_h:.0f}° ({coverage_pct:.0f}% of 360°) | +| **Vertical Coverage** | ~{total_coverage_v:.0f}° | +| **FOV per Image** | {fov:.2f}° (horizontal) | +| **Avg Gap Between Images** | {avg_gap:.1f}° | +| **Avg Overlap** | {overlap_per_pair:.1f}° per pair | + +### Equirectangular Projection Info + +| Property | Value | +|----------|-------| +| **Projection** | Equirectangular (Hugin f2) | +| **Resolution** | {px_per_deg_h:.1f} px/deg | +| **Full 360° Equivalent Width** | {equiv_360_w} px | +| **Full 180° Equivalent Height** | {equiv_180_h} px | +| **Suitable for VR Skybox** | {'Yes' if coverage_pct > 80 else 'Partial — needs gap filling'} | + +--- + +## Camera Specifications + +| Property | Value | +|----------|-------| +| **Camera** | {camera} | +| **Image Size (per frame)** | {img_w} x {img_h} px | +| **HFOV** | {fov:.2f}° | +| **VFOV** | ~{vfov_per_image:.1f}° (estimated) | +""" + + if "NAVCAM" in camera: + content += f"""| **Tile Combining** | Yes (01+04, 16px overlap) | +| **Native Sensor** | 5120 x 3840 (20 MP) | +| **Focal Length** | 19.1 mm, f/12 | +| **Empirical f (pixels)** | 1468 px | +""" + elif "MCZ" in camera: + content += f"""| **Tile Combining** | No (single frame) | +| **Native Sensor** | 1648 x 1200 | +| **Focal Length** | 26-110 mm (zoom) | +| **Zoom Used** | Wide (26 mm) based on FOV | +""" + + content += f""" +--- + +## Photogrammetry Reference + +These parameters are needed for photogrammetric reconstruction of the terrain. + +| Property | Value | +|----------|-------| +| **Number of Viewpoints** | {n_images} | +| **Baseline Between Views** | ~{avg_gap:.1f}° azimuth ({avg_gap * 0.017:.3f} rad) | +| **Stereo Pair Available** | {'Yes (Left+Right NavCam)' if 'NAVCAM' in camera else 'Single camera only'} | +| **Depth Estimation Potential** | {'Good — multiple overlapping views' if overlap_per_pair > 5 else 'Limited — minimal overlap'} | +| **Recommended Software** | Meshroom, COLMAP, OpenMVS | +| **Notes** | Images sorted by azimuth. Adjacent images have ~{overlap_per_pair:.0f}° overlap. | + +### Rover Position (for scene reconstruction) + +The rover is at the center of the panorama. All azimuth/elevation values are +relative to the rover's mast coordinate system. + +- **Mast height**: ~2.2m above ground (Perseverance) +- **Azimuth 0°**: Rover forward direction +- **Elevation 0°**: Horizontal plane +- **Negative elevation**: Below horizon (ground) + +--- + +## Timing Breakdown + +| Step | Time | +|------|------| +""" + for step, t in timings.items(): + content += f"| {step} | {t:.1f}s |\n" + content += f"| **Total** | **{total_time:.0f}s** |\n" + + content += f""" +--- + +## Image List + +| # | Azimuth | Elevation | Filename | +|---|---------|-----------|----------| +""" + for i, fname, az, el in image_details: + content += f"| {i} | {az:.1f}° | {el:.1f}° | {fname} |\n" + + content += f""" +--- + +## Pipeline Parameters + +``` +Camera FOV: {fov:.2f}° +Image size: {img_w} x {img_h} +Blender: {blender} +Projection: equirectangular (f2) +Photometric optimization: disabled (geometry only) +CLAHE: clipLimit=2.0, tileGrid=16x16 +Vignette correction: 0.35 strength +``` +""" + + with open(md_path, 'w') as f: + f.write(content) + + print(f" Metadata: {md_path}") + return md_path diff --git a/pipeline/panorama_pipeline.py b/pipeline/panorama_pipeline.py index 900b836..0c979ea 100644 --- a/pipeline/panorama_pipeline.py +++ b/pipeline/panorama_pipeline.py @@ -19,6 +19,7 @@ import cv2 import glob import math import mysql.connector +from panorama_metadata import generate_metadata import numpy as np import os import re @@ -515,6 +516,19 @@ def run_pipeline(work_dir, clahe_images, original_images, azs, els, if os.path.exists(out_tif): os.remove(out_tif) + # Generate metadata file + try: + generate_metadata( + work_dir=work_dir, output_name=output_name, + sol=int(re.search(r'\d+', output_name).group()), + camera=camera or "unknown", rover="perseverance", + azs=azs, els=els, img_w=img_w, img_h=img_h, fov=fov, + n_cps=len(cp_lines), timings=timings, + original_images=original_images, blender=blender, + output_png=out_png) + except Exception as e: + print(f" Warning: metadata generation failed: {e}") + return out_png, timings