Files
ShooterHub/apps/photos/analysis.py
2026-04-02 11:24:30 +02:00

65 lines
2.0 KiB
Python

"""
Group size computation from PointOfImpact real-world coordinates.
All measurements in millimetres. Origin is point-of-aim:
x > 0 = right (windage), y > 0 = up (elevation).
"""
import math
def compute_group_size(
points: list[tuple[float, float]],
distance_m: float | None = None,
) -> dict:
"""
Compute ballistic group metrics from a list of (x_mm, y_mm) coordinates.
Args:
points: list of (x_mm, y_mm) tuples — minimum 2 required.
distance_m: shooting distance in metres, used for MOA conversion.
Pass None to leave MOA fields as None.
Returns:
dict with keys matching GroupPhotoAnalysis fields.
"""
if len(points) < 2:
raise ValueError("At least 2 points of impact are required.")
xs = [p[0] for p in points]
ys = [p[1] for p in points]
n = len(points)
# Extreme spread: maximum pairwise distance
group_size_mm = 0.0
for i in range(n):
for j in range(i + 1, n):
d = math.sqrt((xs[i] - xs[j]) ** 2 + (ys[i] - ys[j]) ** 2)
if d > group_size_mm:
group_size_mm = d
# Centroid
cx = sum(xs) / n
cy = sum(ys) / n
# Mean radius: average distance from centroid
mean_radius_mm = sum(
math.sqrt((x - cx) ** 2 + (y - cy) ** 2) for x, y in points
) / n
def to_moa(mm: float) -> float | None:
"""Convert mm at distance_m to MOA. 1 MOA ≈ 0.29089 mm/m at that distance."""
if distance_m is None or distance_m <= 0:
return None
return round(mm / (distance_m * 0.29089), 3)
return {
'group_size_mm': round(group_size_mm, 2),
'group_size_moa': to_moa(group_size_mm),
'mean_radius_mm': round(mean_radius_mm, 2),
'mean_radius_moa': to_moa(mean_radius_mm),
'windage_offset_mm': round(cx, 2),
'windage_offset_moa': to_moa(cx),
'elevation_offset_mm': round(cy, 2),
'elevation_offset_moa': to_moa(cy),
}