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