Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- **Clubhead-pose geometry (`openflight.camera.clubpose`).** The club mesh and
its normalization tooling, the calibrated camera model with silhouette
projection, and sub-pixel location of the teed ball, which anchors the world
frame and the camera range. First stage of the clubface impact-location work;
not yet wired into the shot pipeline.
- **Clubhead-pose fitting and delivered angles.** Sequence pose fit with
physical bounds, boundary-distance and rotation-consistency scores, clubhead
and shaft separation, and delivered loft/face/lie with plausibility
envelopes. Includes the technical report on what is validated (fused
radar+camera clubhead velocity, ball detection, impact timing) and what is
not (clubhead orientation has no accuracy figure against truth).

### Added
- **Automatic OV9281 exposure control.** High-speed camera capture now measures
the impact area every five seconds, restores the last known-good setting at
Expand Down
379 changes: 379 additions & 0 deletions docs/clubface-impact-location-report.md

Large diffs are not rendered by default.

96 changes: 96 additions & 0 deletions docs/clubface-impact-location.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# Clubface impact location: status and how to help

An investigation into measuring clubface impact location and face angle from
the existing hardware — the single behind-ball OV9281 camera plus the OPS243
and IWR6843 radars, ambient light, no markers on the ball or club.

## Read this first

**[Technical report](clubface-impact-location-report.md)** — the full assessment:
what is measured, what is not, and what would resolve the rest. States every
retracted claim alongside what replaced it. The
[web version](https://claude.ai/code/artifact/c8817c34-c3ea-4455-9700-cf5a4e238b75)
carries eleven figures: real frames with the model’s own projections overlaid.

**[Fusion status, frame by frame](https://claude.ai/code/artifact/ab9f69dd-de06-4335-83b1-29e3e29ee6b9)**
— two real shots with the model's own projections overlaid, nothing padded.
Thirty seconds of stepping through it conveys the state faster than any prose.

**[Full working log](https://claude.ai/code/artifact/42a6f3f4-0b9b-4faf-bf9c-1ff45b4e94dd)**
— the chronological record, corrections applied in place, for tracing how any
conclusion was reached.

## Where it stands, in three lines

- **Validated:** ball detection (21/22), impact timing (camera and radar agree
to 0.66 frames), camera attitude (measured, not assumed), and the fused
radar+camera clubhead velocity, which matches the OPS243's independent club
speed with a mean ratio of 0.970 (sd 0.029, spread 0.941–1.015).
- **Not yet working:** clubhead orientation. Face angle, dynamic loft and
impact location remain model-dependent inferences with no accuracy figure
against truth.
- **Why:** the first 5° of face angle change the projected silhouette by zero
pixels; one pixel of segmentation error is worth about 10° of face angle;
and the club is segmentable for only ~10 pre-impact frames, of which the
current extractor keeps 3–5, against a four-parameter fit.

## Where help is most valuable

**Contributing a capture is the most useful thing you can do**, and you no
longer need anyone else's data to do it — see *Running it on your own device*
below. The current session is 21 shots of 7-iron and 9-iron from a single rig,
thin enough that several tests cannot discriminate.

1. **A session recorded alongside a Trackman.** Nothing here has been scored
against a reference instrument, so no accuracy figure exists for any club
metric. This is the single measurement that would change that.
2. **Clubhead segmentation.** Extracting more of the ~10 frames the club
appears in roughly doubles the observations per shot. The masks currently
come from a hard background-difference threshold.
3. **A capture at 1280×800 1:1** (doubles plate scale at the same frame rate)
**and across a wide club-speed range** (a driver and a wedge; the existing
session is 7-iron/9-iron with no speed overlap, which starves several
discriminating tests of power).

## Running the code

The library lives in `src/openflight/camera/clubpose/`, with its tests in `tests/`,
whose `README.md` maps every script to the question it answers. Per-shot
result JSONs are committed so conclusions can be re-analysed without repeating
fits that cost ~25 minutes per arm.

### Running it on your own device

Two inputs are not in git, and both fail closed with instructions when absent.

**One-time setup — the club mesh.** Every analysis run projects the 7-iron
model, so this is needed whichever capture you use. It is a GrabCAD community
model used as local research truth and is **not redistributed**;
`src/openflight/camera/clubpose/meshes/SOURCES.md` records the source link, expected
SHA-256, and licence position, and you fetch your own copy under GrabCAD's
terms:

```bash
uv run python \
scripts/analysis/download_club_mesh.py --local-iron <path-to-STL>
```

**Then your own captures.** The library takes frames and a mesh; it has no
opinion about where your data lives. A session recorded by `start-kiosk.sh`
already contains everything needed — the camera `frames.npz` and the IWR6843
`.l3dump` per shot — and `openflight.iwr6843.replay.inputs_from_session`
resolves those paths straight out of the session JSONL.

Both the camera and the IWR6843 must be enabled while capturing; a shot
missing either one cannot be fitted.

The reference **capture session** used throughout the report is
available from the maintainer if you want to reproduce its exact numbers. Your
own export works for everything else. The **7-iron mesh** is fetched from
GrabCAD (local research use only, no redistribution);
`src/openflight/camera/clubpose/meshes/SOURCES.md` has the provenance, hashes, and
download script.

Deliberately excluded from this branch: the superseded synthetic-phase
evaluation, the old web studio, and the June–July simulation studies. They
remain on the fork's `feat/silhouette-poc` branch for archaeology.
185 changes: 185 additions & 0 deletions scripts/analysis/download_club_mesh.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
"""Acquire and normalize the club meshes without vendoring them.

The 7-iron used by this research is a GrabCAD community model. It is not
redistributed, so you fetch your own copy under GrabCAD's terms and point this
script at it; see SOURCES.md for the link, the expected SHA-256 and the licence
position. Run from the repository root:

uv run python scripts/analysis/download_club_mesh.py \n --local-iron "/path/to/690CB 7-iron.STL"

"""

from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path
from typing import Any

ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "src"))

from openflight.camera.clubpose.mesh import ( # noqa: E402
ACTIVE_MESH_SOURCES,
CATEGORY_DIMENSIONS_MM,
MESH_SOURCES,
MeshSource,
admit_mesh,
default_mesh_asset_root,
detect_face_plane,
face_detection_record,
load_binary_stl,
load_normalized_mesh,
normalize_clubhead,
save_normalized_mesh,
)

_NORMALIZATION_VERSION = "geometric-face-anchor-v2"


def validate_source_metadata(source: MeshSource, metadata: dict[str, Any]) -> None:
"""Fail closed if identity, author, download status, or license drifted."""
if str(metadata.get("uid")) != source.uid or str(metadata.get("name")) != source.name:
raise ValueError(f"source identity changed for {source.club}")
if not bool(metadata.get("isDownloadable")):
raise ValueError(f"source is no longer downloadable for {source.club}")
author = str(metadata.get("user", {}).get("displayName", ""))
if author != source.author:
raise ValueError(f"source author changed for {source.club}: {author!r}")
license_payload = metadata.get("license", {})
license_url = str(license_payload.get("url", "")).replace("http://", "https://")
if str(license_payload.get("label")) != "CC Attribution" or license_url.rstrip(
"/"
) != source.license_url.rstrip("/"):
raise ValueError(f"source license changed for {source.club}")


def import_local_stl(
source_path: Path | str,
output_root: Path,
*,
expected_sha256: str | None = None,
) -> dict[str, Any]:
"""Import the registered maintainer-local 7-iron without copying its STL."""
source = MESH_SOURCES["poc_7iron"]
registered_hash = source.expected_source_sha256
if expected_sha256 is not None and registered_hash is not None:
if expected_sha256.lower() != registered_hash.lower():
raise ValueError("caller SHA-256 does not match the frozen local-source registration")
required_hash = expected_sha256 or registered_hash
loaded = load_binary_stl(source_path, source_uid=source.uid, expected_sha256=required_hash)
admission = admit_mesh(
loaded,
category_dimensions_mm=CATEGORY_DIMENSIONS_MM[source.club],
source_units_mm=True,
)
if not admission.accepted:
raise ValueError(f"mesh admission failed for {source.club}: {admission.reasons}")
assert admission.face is not None
normalized = normalize_clubhead(
loaded,
CATEGORY_DIMENSIONS_MM[source.club],
source_units_mm=True,
)
normalized_face = detect_face_plane(normalized)
asset_metadata = {
"source_uid": source.uid,
"source_name": source.name,
"author": source.author,
"page_url": source.page_url,
"license_spdx": source.license_spdx,
"license_url": source.license_url,
"source_file_sha256": loaded.source_sha256,
"download_format": "binary_stl_maintainer_local",
"redistribution": "prohibited; local research use only",
"normalization": _NORMALIZATION_VERSION,
"source_units_mm": True,
"category_dimensions_mm": CATEGORY_DIMENSIONS_MM[source.club],
"geometry_sha256": admission.geometry_sha256,
"component_count_after_weld": admission.component_count,
"boundary_edge_count_after_weld": admission.boundary_edge_count,
"boundary_edge_fraction_after_weld": admission.boundary_edge_fraction,
"dimensions_before_normalization_mm": admission.dimensions_mm,
"face_detection_source": face_detection_record(admission.face),
"face_detection_normalized": face_detection_record(normalized_face),
"source_vertex_count": int(len(loaded.vertices_local_mm)),
"source_triangle_count": int(len(loaded.faces)),
"clubhead_vertex_count": int(len(normalized.vertices_local_mm)),
"clubhead_triangle_count": int(len(normalized.faces)),
"trademark_note": "synthetic truth only; no Titleist endorsement implied",
}
asset_path = output_root / f"{source.club}.npz"
asset_sha256 = save_normalized_mesh(asset_path, normalized, asset_metadata)
record = {**asset_metadata, "asset_path": asset_path.name, "asset_sha256": asset_sha256}
print(json.dumps(record, indent=2, sort_keys=True))
return record


def _existing_record(source: MeshSource, output_root: Path) -> dict[str, Any] | None:
asset_path = output_root / f"{source.club}.npz"
if not asset_path.is_file():
return None
mesh, metadata, asset_sha256 = load_normalized_mesh(str(asset_path.resolve()))
if mesh.source_uid != source.uid:
raise ValueError(f"cached source identity mismatch for {source.club}")
if source.expected_source_sha256 is not None and (
mesh.source_sha256 != source.expected_source_sha256
):
raise ValueError(f"cached source SHA-256 mismatch for {source.club}")
if source.expected_asset_sha256 is not None and (asset_sha256 != source.expected_asset_sha256):
raise ValueError(f"cached asset SHA-256 mismatch for {source.club}")
if metadata.get("normalization") != _NORMALIZATION_VERSION:
return None
return {**metadata, "asset_path": asset_path.name, "asset_sha256": asset_sha256}


def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output", type=Path, default=default_mesh_asset_root())
parser.add_argument("--local-iron", type=Path)
args = parser.parse_args()
args.output.mkdir(parents=True, exist_ok=True)
iron = ACTIVE_MESH_SOURCES["poc_7iron"]
iron_record = _existing_record(iron, args.output)
if iron_record is None:
if args.local_iron is None:
parser.error("--local-iron is required to import the missing maintainer-local 690CB")
stl = Path(args.local_iron).expanduser()
if not stl.is_file():
parser.error(
f"no STL at {stl}. Fetch the 690CB 7-iron from the GrabCAD page in "
"src/openflight/camera/clubpose/meshes/SOURCES.md (free account, "
"their terms) and point --local-iron at the downloaded file."
)
iron_record = import_local_stl(stl, args.output)
records = [iron_record]
(args.output / "manifest.json").write_text(
json.dumps(
{
"sources": records,
"retired_sources": [
{
"club": source.club,
"source_uid": source.uid,
"status": source.status,
"reason": source.status_reason,
}
for source in MESH_SOURCES.values()
if source.status != "active"
],
},
indent=2,
sort_keys=True,
)
+ "\n",
encoding="utf-8",
)
return 0


if __name__ == "__main__":
raise SystemExit(main())
(admit_mesh,)
(detect_face_plane,)
(face_detection_record,)
9 changes: 9 additions & 0 deletions src/openflight/camera/clubpose/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
"""Clubhead pose from the behind-ball camera and the radars.

This package lands in stages. This stage carries the geometry: the club mesh
(mesh), the camera model and silhouette projection (projection), and sub-pixel
location of the teed ball (teed_ball), which anchors the world frame. The pose
fit that consumes them follows, and nothing here is wired into the shot
pipeline yet. See docs/clubface-impact-location-report.md for what is
validated and what is not.
"""
Loading