optigami / engine /metrics.py
ianalin123's picture
feat: update engine modules, remove research 2 docs
ca61c8d
raw
history blame
8.79 kB
"""
Quality metrics for folded origami.
Computes bounding box, deployment ratio, fold count, and aggregated
metric dictionaries for the trainer reward functions.
"""
from __future__ import annotations
import numpy as np
from .paper import Paper
def compute_bounding_box(paper: Paper) -> np.ndarray:
"""Axis-aligned bounding-box dimensions (dx, dy, dz).
Returns shape (3,) array. Minimum z-thickness accounts for
material thickness times estimated layer count.
"""
if len(paper.vertices) == 0:
return np.zeros(3)
ptp = np.ptp(paper.vertices, axis=0)
ptp = np.where(np.abs(ptp) < 1e-12, 0.0, ptp)
# Minimum z from material thickness * layers
t = paper.material.thickness_mm / 1000.0
ptp[2] = max(ptp[2], t * paper.num_layers)
return ptp
def compute_deployment_ratio(paper: Paper) -> float:
"""Ratio of folded XY footprint area to original sheet area.
A fully flat unfolded sheet has ratio 1.0; a tightly folded sheet
approaches 0.0.
"""
if paper.original_area <= 0:
return 1.0
bb = compute_bounding_box(paper)
folded_area = bb[0] * bb[1]
ratio = folded_area / paper.original_area
return float(np.clip(ratio, 0.0, 1.0))
def compute_fold_count(paper: Paper) -> int:
"""Number of mountain (M) and valley (V) edges."""
return sum(1 for a in paper.assignments if a in ("M", "V"))
def compute_compactness(paper: Paper) -> float:
"""1 - deployment_ratio. Higher is more compact."""
return 1.0 - compute_deployment_ratio(paper)
def compute_volume(paper: Paper) -> float:
"""Bounding-box volume in cubic meters."""
bb = compute_bounding_box(paper)
return float(bb[0] * bb[1] * bb[2])
def compute_metrics(paper: Paper, original_paper: Paper | None = None) -> dict:
"""Compute all quality metrics and return as a dict.
Parameters
----------
paper : Paper
The current (folded) paper state.
original_paper : Paper or None
The original (unfolded) paper, used for strain comparison.
If None, strain is computed against the current paper's rest lengths.
Returns
-------
dict with keys:
bounding_box, deployment_ratio, fold_count, compactness,
volume, max_strain, mean_strain, num_vertices, num_faces,
num_layers.
"""
from .physics import compute_strain # local import to avoid circular
bb = compute_bounding_box(paper)
strain = compute_strain(paper)
return {
"bounding_box": {
"x": float(bb[0]),
"y": float(bb[1]),
"z": float(bb[2]),
},
"deployment_ratio": compute_deployment_ratio(paper),
"fold_count": compute_fold_count(paper),
"compactness": compute_compactness(paper),
"volume": compute_volume(paper),
"max_strain": float(np.max(strain)) if len(strain) > 0 else 0.0,
"mean_strain": float(np.mean(strain)) if len(strain) > 0 else 0.0,
"num_vertices": len(paper.vertices),
"num_faces": len(paper.faces),
"num_layers": paper.num_layers,
}
def compute_all_metrics(paper, task: dict, validation: dict) -> dict:
"""Compute every metric and return a flat dict.
Called after physics + validation. Combines validity, compactness,
structural, efficiency, and deployability metrics.
Parameters
----------
paper : Paper
Current paper state (after simulate()).
task : dict
Task definition with keys: width, height, target_ratio, target_box, must_deploy.
validation : dict
Output of validate_state(paper).
"""
import numpy as np
bb = paper.bounding_box # (3,) array
original_area = paper.original_area if paper.original_area > 0 else (paper.material.thickness_mm / 1000.0)
t = paper.material.thickness_mm / 1000.0
original_bbox_vol = original_area * t
folded_bbox_vol = float(bb[0] * bb[1] * bb[2]) if bb[2] > 0 else float(bb[0] * bb[1] * t)
# ── Folded area (XY footprint) ────────────────────────────────
if len(paper.vertices) >= 3:
try:
from scipy.spatial import ConvexHull
hull = ConvexHull(paper.vertices[:, :2])
folded_area = float(hull.volume)
except Exception:
ptp = np.ptp(paper.vertices[:, :2], axis=0)
folded_area = float(ptp[0] * ptp[1])
else:
folded_area = original_area
deployment_ratio = folded_area / original_area if original_area > 0 else 1.0
compactness = 1.0 - deployment_ratio
volume_compaction = folded_bbox_vol / original_bbox_vol if original_bbox_vol > 0 else 1.0
material_volume = original_area * t
packing_efficiency = material_volume / folded_bbox_vol if folded_bbox_vol > 0 else 0.0
# ── Target box check ─────────────────────────────────────────
target_box = task.get("target_box")
fits_target_box = False
if target_box and len(target_box) == 3:
fits_target_box = bool(
bb[0] <= target_box[0] + 1e-6 and
bb[1] <= target_box[1] + 1e-6 and
bb[2] <= target_box[2] + 1e-6
)
# ── Strain ───────────────────────────────────────────────────
strain = paper.strain_per_vertex
max_strain = float(np.max(strain)) if len(strain) > 0 else 0.0
mean_strain = float(np.mean(strain)) if len(strain) > 0 else 0.0
# ── Energy ───────────────────────────────────────────────────
energy = paper.energy
# ── Efficiency ───────────────────────────────────────────────
fold_count = paper.fold_count
# Crease complexity: entropy of M/V assignment distribution
mv_assignments = [a for a in paper.assignments if a in ("M", "V")]
if mv_assignments:
total = len(mv_assignments)
m_count = mv_assignments.count("M")
v_count = mv_assignments.count("V")
p_m = m_count / total if total > 0 else 0
p_v = v_count / total if total > 0 else 0
crease_complexity = 0.0
if p_m > 0:
crease_complexity -= p_m * np.log2(p_m)
if p_v > 0:
crease_complexity -= p_v * np.log2(p_v)
else:
crease_complexity = 0.0
folding_efficiency = compactness / max(fold_count, 1)
# ── Deployability ─────────────────────────────────────────────
must_deploy = task.get("must_deploy", False)
# Simple deployability heuristic: if valid and compactness > 0, assume deployable
is_deployable = bool(validation.get("is_valid", False) and compactness > 0.01) if must_deploy else None
# Deployment force estimate from total energy gradient (rough)
deployment_force_estimate = float(energy.get("fold", 0.0)) / max(paper.original_area, 1e-6)
return {
# Validity (from validation dict)
"is_valid": validation.get("is_valid", False),
"kawasaki_violations": validation.get("kawasaki_violations", 0),
"kawasaki_total_error": validation.get("kawasaki_total_error", 0.0),
"maekawa_violations": validation.get("maekawa_violations", 0),
"self_intersections": validation.get("self_intersections", 0),
"strain_exceeded": validation.get("strain_exceeded", False),
# Compactness
"deployment_ratio": float(deployment_ratio),
"compactness": float(compactness),
"volume_compaction": float(volume_compaction),
"packing_efficiency": float(packing_efficiency),
"fits_target_box": fits_target_box,
"bounding_box": bb.tolist(),
# Structural
"max_strain": max_strain,
"mean_strain": mean_strain,
"total_energy": float(energy.get("total", 0.0)),
"energy_bar": float(energy.get("bar", 0.0)),
"energy_facet": float(energy.get("facet", 0.0)),
"energy_fold": float(energy.get("fold", 0.0)),
# Efficiency
"fold_count": fold_count,
"folding_efficiency": float(folding_efficiency),
"crease_complexity": float(crease_complexity),
# Deployability
"is_deployable": is_deployable,
"deployment_force_estimate": float(deployment_force_estimate),
# Shape similarity placeholders
"chamfer_distance": None,
"hausdorff_distance": None,
}