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) <noreply@anthropic.com>
This commit is contained in:
Franck Garnier
2026-04-12 22:34:00 -04:00
parent 85e9645363
commit 1aacaea084
3 changed files with 233 additions and 0 deletions

View File

@@ -8,6 +8,7 @@ services:
# Mount NAS astro disk (images source + output) # Mount NAS astro disk (images source + output)
- /mnt/astro/mars_rovers:/data - /mnt/astro/mars_rovers:/data
- /mnt/astro/mars_rovers/images/panorama/perseverance:/output - /mnt/astro/mars_rovers/images/panorama/perseverance:/output
user: "1000:100" # franck:users — avoid root-owned output files
environment: environment:
- MYSQL_HOST=192.168.1.42 - MYSQL_HOST=192.168.1.42
- MYSQL_PORT=3306 - MYSQL_PORT=3306

View File

@@ -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

View File

@@ -19,6 +19,7 @@ import cv2
import glob import glob
import math import math
import mysql.connector import mysql.connector
from panorama_metadata import generate_metadata
import numpy as np import numpy as np
import os import os
import re import re
@@ -515,6 +516,19 @@ def run_pipeline(work_dir, clahe_images, original_images, azs, els,
if os.path.exists(out_tif): if os.path.exists(out_tif):
os.remove(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 return out_png, timings