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:
218
pipeline/panorama_metadata.py
Normal file
218
pipeline/panorama_metadata.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user