中間発表の精度実験を効率化するためのコードおよびテストを追加
This commit is contained in:
1 parent
048bad7065
commit
2afebcb041
4 files changed
+992
No files matched your search
@@ -0,0 +1,464 @@
|
||||
"""Accuracy-experiment report: join trial markers with ``*_face_frames.csv`` by timestamp.
|
||||
|
||||
Usage (after ``src.accuracy_session`` recorded the markers)::
|
||||
|
||||
python -m src.accuracy_report --trials data/analysis/accuracy/trials_<session>.csv ^
|
||||
--face-csv "data/logs/face/*_face_frames.csv"
|
||||
|
||||
Per trial the baseline yaw (subject facing the camera) is averaged, the hold-phase yaw is
|
||||
compared against it, and ``error = (hold - baseline) - true_yaw``. Outputs (``--out``):
|
||||
``accuracy_trials.csv``, ``accuracy_conditions.csv``, ``accuracy_thresholds.csv``,
|
||||
``accuracy_report.md`` and ``plots/*.png``.
|
||||
|
||||
Threshold: ``theta_th = |bias| + k * SD`` of the frame errors for conditions with
|
||||
``|true_yaw| <= --pool-max-angle`` (pitch 0), per distance. ``--basis worst`` (default) takes the
|
||||
worst single condition, ``pooled`` pools all of them. k=3 and the pooling range are provisional
|
||||
choices to be agreed on with the supervisor.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import bisect
|
||||
import csv
|
||||
import glob
|
||||
import math
|
||||
import statistics
|
||||
import sys
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Sequence
|
||||
|
||||
NAN = float("nan")
|
||||
TRIAL_COLUMNS = ["session_id", "seq", "condition_id", "distance_m", "true_yaw_deg", "pitch_deg", "repeat", "subject",
|
||||
"status", "track_id", "baseline_n", "baseline_mean", "baseline_sd", "hold_n", "hold_mean", "hold_sd",
|
||||
"delta_deg", "error_deg", "detection_rate"]
|
||||
CONDITION_COLUMNS = ["distance_m", "pitch_deg", "true_yaw_deg", "n_trials", "n_frames", "bias_deg", "sd_trials_deg",
|
||||
"sd_within_deg", "sd_total_deg", "rmse_frame_deg", "p95_abs_error_deg", "mean_detection_rate",
|
||||
"exceed_rate"]
|
||||
THRESHOLD_COLUMNS = ["distance_m", "n_frames", "bias_deg", "sd_deg", "theta_pooled_deg", "theta_worst_deg",
|
||||
"theta_th_deg", "slope", "intercept_deg", "r2", "min_detectable_deg"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Options:
|
||||
signal: str = "yaw"
|
||||
flip_sign: bool = False
|
||||
settle: float = 1.5
|
||||
baseline_trim: float = 0.5
|
||||
min_frames: int = 10
|
||||
face_every: int = 1
|
||||
track_id: int | None = None
|
||||
fps: float | None = None
|
||||
k: float = 3.0
|
||||
pool_max_angle: float = 15.0
|
||||
basis: str = "worst"
|
||||
detect_ratio: float = 0.95
|
||||
pitch_ref: float = 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class FaceSeries:
|
||||
times: list[float]
|
||||
track_ids: list[int]
|
||||
pose: list[bool]
|
||||
yaw: list[float | None]
|
||||
raw_yaw: list[float | None]
|
||||
fps: list[float]
|
||||
|
||||
|
||||
def _ts(value: object) -> float | None:
|
||||
try:
|
||||
return datetime.fromisoformat(str(value).strip()).timestamp()
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _float(value: object) -> float | None:
|
||||
try:
|
||||
number = float(str(value))
|
||||
except ValueError:
|
||||
return None
|
||||
return number if math.isfinite(number) else None
|
||||
|
||||
|
||||
def _mean(values: Sequence[float]) -> float:
|
||||
return statistics.fmean(values) if values else NAN
|
||||
|
||||
|
||||
def _sd(values: Sequence[float]) -> float:
|
||||
return statistics.stdev(values) if len(values) >= 2 else NAN
|
||||
|
||||
|
||||
def _wrap(angle: float) -> float:
|
||||
return (angle + 180.0) % 360.0 - 180.0
|
||||
|
||||
|
||||
def percentile(sorted_values: Sequence[float], q: float) -> float:
|
||||
if not sorted_values:
|
||||
return NAN
|
||||
position = (len(sorted_values) - 1) * q / 100.0
|
||||
lower, upper = math.floor(position), math.ceil(position)
|
||||
return float(sorted_values[lower] + (sorted_values[upper] - sorted_values[lower]) * (position - lower))
|
||||
|
||||
|
||||
def expand_paths(patterns: Sequence[str]) -> list[Path]:
|
||||
"""Expand globs ourselves because Windows shells do not."""
|
||||
paths: list[Path] = []
|
||||
for pattern in patterns:
|
||||
matches = sorted(glob.glob(pattern)) if any(c in pattern for c in "*?[") else [pattern]
|
||||
paths.extend(Path(m) for m in matches)
|
||||
return paths
|
||||
|
||||
|
||||
def load_face_series(paths: Sequence[Path], default_fps: float = 30.0) -> FaceSeries:
|
||||
rows: list[tuple[float, int, bool, float | None, float | None, float]] = []
|
||||
for path in paths:
|
||||
file_rows: list[tuple[float, int, bool, float | None, float | None]] = []
|
||||
frames: list[tuple[float, float]] = []
|
||||
with path.open(encoding="utf-8-sig", newline="") as stream:
|
||||
for row in csv.DictReader(stream):
|
||||
t, track = _ts(row.get("timestamp")), _float(row.get("track_id"))
|
||||
if t is None or track is None:
|
||||
continue
|
||||
file_rows.append((t, int(track), str(row.get("pose_estimated", "")).lower() == "true",
|
||||
_float(row.get("yaw")), _float(row.get("raw_yaw"))))
|
||||
frame = _float(row.get("frame_number"))
|
||||
if frame is not None:
|
||||
frames.append((t, frame))
|
||||
fps = default_fps
|
||||
if len(frames) >= 2:
|
||||
dt, df = frames[-1][0] - frames[0][0], frames[-1][1] - frames[0][1]
|
||||
if dt > 0 and df > 0:
|
||||
fps = df / dt
|
||||
rows.extend((*r, fps) for r in file_rows)
|
||||
rows.sort(key=lambda r: r[0])
|
||||
return FaceSeries([r[0] for r in rows], [r[1] for r in rows], [r[2] for r in rows], [r[3] for r in rows],
|
||||
[r[4] for r in rows], [r[5] for r in rows])
|
||||
|
||||
|
||||
def load_trials(paths: Sequence[Path], exclude_seqs: Sequence[int] = ()) -> list[dict[str, str]]:
|
||||
"""Last log row per (session, seq); only status ``ok`` survives."""
|
||||
last: dict[tuple[str, int], dict[str, str]] = {}
|
||||
for path in paths:
|
||||
with path.open(encoding="utf-8-sig", newline="") as stream:
|
||||
for row in csv.DictReader(stream):
|
||||
last[(row.get("session_id", ""), int(row["seq"]))] = row
|
||||
return [row for (_, seq), row in sorted(last.items()) if row["status"] == "ok" and seq not in set(exclude_seqs)]
|
||||
|
||||
|
||||
def evaluate_trial(trial: dict[str, str], series: FaceSeries, opt: Options) -> tuple[dict[str, object], list[float]]:
|
||||
"""Return the trial record and the frame-level deltas (degrees, signed, hold phase)."""
|
||||
record: dict[str, object] = {column: NAN for column in TRIAL_COLUMNS}
|
||||
record.update({
|
||||
"session_id": trial.get("session_id", ""), "seq": int(trial["seq"]), "condition_id": trial["condition_id"],
|
||||
"distance_m": float(trial["distance_m"]), "true_yaw_deg": float(trial["true_yaw_deg"]),
|
||||
"pitch_deg": float(trial["pitch_deg"]), "repeat": trial.get("repeat", ""), "subject": trial.get("subject", ""),
|
||||
"status": "ok", "track_id": "",
|
||||
})
|
||||
t_base, t_cue, t_end = (_ts(trial.get(key)) for key in ("t_baseline_start", "t_turn_cue", "t_hold_end"))
|
||||
if t_base is None or t_cue is None or t_end is None:
|
||||
record["status"] = "bad_timestamps"
|
||||
return record, []
|
||||
b0, b1, h0, h1 = t_base + opt.baseline_trim, t_cue, t_cue + opt.settle, t_end
|
||||
values = series.raw_yaw if opt.signal == "raw_yaw" else series.yaw
|
||||
|
||||
def span(a: float, b: float) -> tuple[int, int]:
|
||||
return bisect.bisect_left(series.times, a), bisect.bisect_right(series.times, b)
|
||||
|
||||
lo, hi = span(b0, h1)
|
||||
counts = Counter(series.track_ids[i] for i in range(lo, hi) if series.pose[i] and values[i] is not None)
|
||||
track = opt.track_id if opt.track_id is not None else (counts.most_common(1)[0][0] if counts else None)
|
||||
if track is None or counts.get(track, 0) == 0:
|
||||
record["status"] = "no_track"
|
||||
return record, []
|
||||
record["track_id"] = track
|
||||
|
||||
def pick(a: float, b: float) -> list[int]:
|
||||
s, e = span(a, b)
|
||||
return [i for i in range(s, e) if series.track_ids[i] == track and series.pose[i] and values[i] is not None]
|
||||
|
||||
base, hold = pick(b0, b1), pick(h0, h1)
|
||||
base_yaw = [float(values[i]) for i in base] # type: ignore[arg-type]
|
||||
hold_yaw = [float(values[i]) for i in hold] # type: ignore[arg-type]
|
||||
record.update({"baseline_n": len(base), "hold_n": len(hold), "baseline_mean": _mean(base_yaw),
|
||||
"baseline_sd": _sd(base_yaw), "hold_mean": _mean(hold_yaw), "hold_sd": _sd(hold_yaw)})
|
||||
if len(base) < opt.min_frames:
|
||||
record["status"] = "baseline_insufficient"
|
||||
return record, []
|
||||
if len(hold) < opt.min_frames:
|
||||
record["status"] = "hold_insufficient"
|
||||
return record, []
|
||||
sign = -1.0 if opt.flip_sign else 1.0
|
||||
base_mean = float(record["baseline_mean"]) # type: ignore[arg-type]
|
||||
deltas = [_wrap(sign * (y - base_mean)) for y in hold_yaw]
|
||||
fps = opt.fps or series.fps[hold[0]]
|
||||
expected = max(1.0, (h1 - h0) * fps / max(1, opt.face_every))
|
||||
record["detection_rate"] = min(1.0, len(hold) / expected)
|
||||
record["delta_deg"] = _mean(deltas)
|
||||
record["error_deg"] = float(record["delta_deg"]) - float(record["true_yaw_deg"]) # type: ignore[arg-type]
|
||||
return record, deltas
|
||||
|
||||
|
||||
Usable = list[tuple[dict[str, object], list[float]]]
|
||||
|
||||
|
||||
def _errors(record: dict[str, object], deltas: Sequence[float]) -> list[float]:
|
||||
return [d - float(record["true_yaw_deg"]) for d in deltas] # type: ignore[arg-type]
|
||||
|
||||
|
||||
def summarize_conditions(usable: Usable) -> list[dict[str, object]]:
|
||||
groups: dict[tuple[float, float, float], Usable] = {}
|
||||
for record, deltas in usable:
|
||||
key = (float(record["distance_m"]), float(record["pitch_deg"]), float(record["true_yaw_deg"])) # type: ignore[arg-type]
|
||||
groups.setdefault(key, []).append((record, deltas))
|
||||
rows: list[dict[str, object]] = []
|
||||
for (distance, pitch, true), items in sorted(groups.items()):
|
||||
frame_errors = [e for record, deltas in items for e in _errors(record, deltas)]
|
||||
within = [float(r["hold_sd"]) ** 2 for r, _ in items if not math.isnan(float(r["hold_sd"]))] # type: ignore[arg-type]
|
||||
rows.append({
|
||||
"distance_m": distance, "pitch_deg": pitch, "true_yaw_deg": true, "n_trials": len(items),
|
||||
"n_frames": len(frame_errors), "bias_deg": _mean([float(r["error_deg"]) for r, _ in items]), # type: ignore[arg-type]
|
||||
"sd_trials_deg": _sd([float(r["error_deg"]) for r, _ in items]), # type: ignore[arg-type]
|
||||
"sd_within_deg": math.sqrt(_mean(within)) if within else NAN, "sd_total_deg": _sd(frame_errors),
|
||||
"rmse_frame_deg": math.sqrt(_mean([e * e for e in frame_errors])),
|
||||
"p95_abs_error_deg": percentile(sorted(abs(e) for e in frame_errors), 95),
|
||||
"mean_detection_rate": _mean([float(r["detection_rate"]) for r, _ in items]), # type: ignore[arg-type]
|
||||
"exceed_rate": NAN,
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def _line_fit(points: Sequence[tuple[float, float]]) -> tuple[float, float, float]:
|
||||
xs, ys = [p[0] for p in points], [p[1] for p in points]
|
||||
if len(points) < 2 or len(set(xs)) < 2:
|
||||
return NAN, NAN, NAN
|
||||
mx, my = statistics.fmean(xs), statistics.fmean(ys)
|
||||
sxx = sum((x - mx) ** 2 for x in xs)
|
||||
sxy = sum((x - mx) * (y - my) for x, y in points)
|
||||
syy = sum((y - my) ** 2 for y in ys)
|
||||
slope = sxy / sxx
|
||||
return slope, my - slope * mx, (sxy * sxy / (sxx * syy) if syy > 0 else NAN)
|
||||
|
||||
|
||||
def compute_thresholds(usable: Usable, opt: Options) -> dict[float, dict[str, float]]:
|
||||
result: dict[float, dict[str, float]] = {}
|
||||
for distance in sorted({float(r["distance_m"]) for r, _ in usable}): # type: ignore[arg-type]
|
||||
ref = [(r, d) for r, d in usable if float(r["distance_m"]) == distance # type: ignore[arg-type]
|
||||
and float(r["pitch_deg"]) == opt.pitch_ref] # type: ignore[arg-type]
|
||||
pooled_sel = [(r, d) for r, d in ref if abs(float(r["true_yaw_deg"])) <= opt.pool_max_angle] # type: ignore[arg-type]
|
||||
all_errors = [e for r, d in pooled_sel for e in _errors(r, d)]
|
||||
if len(all_errors) < 2:
|
||||
continue
|
||||
pooled = abs(_mean(all_errors)) + opt.k * _sd(all_errors)
|
||||
by_condition: dict[float, list[float]] = {}
|
||||
for r, d in pooled_sel:
|
||||
by_condition.setdefault(float(r["true_yaw_deg"]), []).extend(_errors(r, d)) # type: ignore[arg-type]
|
||||
worst_values = [abs(_mean(e)) + opt.k * _sd(e) for e in by_condition.values() if len(e) >= 2]
|
||||
worst = max(worst_values) if worst_values else pooled
|
||||
slope, intercept, r2 = _line_fit([(float(r["true_yaw_deg"]), float(r["delta_deg"])) for r, _ in ref]) # type: ignore[arg-type]
|
||||
result[distance] = {
|
||||
"n_frames": float(len(all_errors)), "bias_deg": _mean(all_errors), "sd_deg": _sd(all_errors),
|
||||
"theta_pooled_deg": pooled, "theta_worst_deg": worst,
|
||||
"theta_th_deg": worst if opt.basis == "worst" else pooled,
|
||||
"slope": slope, "intercept_deg": intercept, "r2": r2, "min_detectable_deg": NAN,
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def add_exceed_rates(conditions: list[dict[str, object]], usable: Usable, thresholds: dict[float, dict[str, float]],
|
||||
opt: Options) -> None:
|
||||
"""exceed_rate = share of hold frames with |delta| >= theta_th (false-positive rate at 0 deg)."""
|
||||
frames: dict[tuple[float, float, float], list[float]] = {}
|
||||
for record, deltas in usable:
|
||||
key = (float(record["distance_m"]), float(record["pitch_deg"]), float(record["true_yaw_deg"])) # type: ignore[arg-type]
|
||||
frames.setdefault(key, []).extend(deltas)
|
||||
for row in conditions:
|
||||
theta = thresholds.get(float(row["distance_m"]), {}).get("theta_th_deg") # type: ignore[arg-type]
|
||||
data = frames[(float(row["distance_m"]), float(row["pitch_deg"]), float(row["true_yaw_deg"]))] # type: ignore[arg-type]
|
||||
row["exceed_rate"] = sum(abs(d) >= theta for d in data) / len(data) if theta is not None and data else NAN
|
||||
for distance, info in thresholds.items():
|
||||
ref = [c for c in conditions if c["distance_m"] == distance and c["pitch_deg"] == opt.pitch_ref
|
||||
and float(c["true_yaw_deg"]) != 0.0 and not math.isnan(float(c["exceed_rate"]))] # type: ignore[arg-type]
|
||||
for angle in sorted({abs(float(c["true_yaw_deg"])) for c in ref}): # type: ignore[arg-type]
|
||||
if all(float(c["exceed_rate"]) >= opt.detect_ratio for c in ref if abs(float(c["true_yaw_deg"])) >= angle): # type: ignore[arg-type]
|
||||
info["min_detectable_deg"] = angle
|
||||
break
|
||||
|
||||
|
||||
def _fmt(value: object, digits: int = 2) -> str:
|
||||
if isinstance(value, float):
|
||||
return "-" if math.isnan(value) else f"{value:.{digits}f}"
|
||||
return str(value)
|
||||
|
||||
|
||||
def _write_csv(path: Path, columns: Sequence[str], rows: Sequence[dict[str, object]]) -> None:
|
||||
with path.open("w", encoding="utf-8-sig", newline="") as stream:
|
||||
writer = csv.DictWriter(stream, fieldnames=list(columns), extrasaction="ignore")
|
||||
writer.writeheader()
|
||||
for row in rows:
|
||||
writer.writerow({k: (_fmt(v, 4) if isinstance(v, float) else v) for k, v in row.items()})
|
||||
|
||||
|
||||
def build_markdown(records: list[dict[str, object]], conditions: list[dict[str, object]],
|
||||
thresholds: dict[float, dict[str, float]], opt: Options) -> str:
|
||||
lines = ["# 振り向き角度推定の精度実験 結果", ""]
|
||||
status = Counter(str(r["status"]) for r in records)
|
||||
lines += [f"- 記録された有効試行: {len(records)}、解析に使用: {status['ok']}", "- 除外: " + (
|
||||
", ".join(f"{k}={v}" for k, v in status.items() if k != "ok") or "なし")]
|
||||
bad = [str(r["seq"]) for r in records if r["status"] != "ok"]
|
||||
if bad:
|
||||
lines.append(f"- 除外された seq: {', '.join(bad)}")
|
||||
lines += [f"- 解析設定: signal={opt.signal}, flip_sign={opt.flip_sign}, settle={opt.settle:g}s, "
|
||||
f"baseline_trim={opt.baseline_trim:g}s, k={opt.k:g}, pool_max_angle={opt.pool_max_angle:g}°, "
|
||||
f"basis={opt.basis}", "", "誤差の定義: error = (保持区間の平均yaw − 基準yaw) − 真値角(度)。", ""]
|
||||
lines += ["## しきい値 (距離別・pitch 基準条件)", "",
|
||||
"| 距離[m] | frames | bias | SD | θ_pooled | θ_worst | **θ_th** | 傾き | 切片 | R² | 判定可能な最小角 |",
|
||||
"|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|"]
|
||||
warn = []
|
||||
for distance, t in sorted(thresholds.items()):
|
||||
lines.append(f"| {distance:g} | {int(t['n_frames'])} | {_fmt(t['bias_deg'])} | {_fmt(t['sd_deg'])} | "
|
||||
f"{_fmt(t['theta_pooled_deg'])} | {_fmt(t['theta_worst_deg'])} | **{_fmt(t['theta_th_deg'])}** | "
|
||||
f"{_fmt(t['slope'], 3)} | {_fmt(t['intercept_deg'])} | {_fmt(t['r2'], 3)} | "
|
||||
f"{_fmt(t['min_detectable_deg'])} |")
|
||||
if not math.isnan(t["slope"]) and t["slope"] < 0:
|
||||
warn.append(f"{distance:g} m: 傾きが負です。角度の符号定義が逆の可能性があります (--flip-sign)。")
|
||||
if thresholds:
|
||||
lines += ["", f"全距離で共通に使う保守的な値: θ_th = {max(t['theta_th_deg'] for t in thresholds.values()):.2f}°"]
|
||||
lines += [f"- {w}" for w in warn]
|
||||
lines += ["", "## 条件別の結果", "",
|
||||
"| 距離[m] | pitch | 真値[°] | 試行 | frames | bias | SD(試行間) | SD(試行内) | SD(全体) | RMSE | 95%|誤差| | 検出率 | θ超過率 |",
|
||||
"|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|"]
|
||||
for c in conditions:
|
||||
lines.append(f"| {float(c['distance_m']):g} | {float(c['pitch_deg']):+g} | {float(c['true_yaw_deg']):+g} | " # type: ignore[arg-type]
|
||||
f"{c['n_trials']} | {c['n_frames']} | {_fmt(c['bias_deg'])} | {_fmt(c['sd_trials_deg'])} | "
|
||||
f"{_fmt(c['sd_within_deg'])} | {_fmt(c['sd_total_deg'])} | {_fmt(c['rmse_frame_deg'])} | "
|
||||
f"{_fmt(c['p95_abs_error_deg'])} | {_fmt(c['mean_detection_rate'], 3)} | {_fmt(c['exceed_rate'], 3)} |")
|
||||
lines += ["", "注: θ超過率は保持区間のフレームのうち |Δyaw| ≥ θ_th の割合 (真値0°では誤検出率、それ以外では検出率)。",
|
||||
"検出率は姿勢推定に成功したフレーム数 / 期待フレーム数 (フレームレートはログから推定)。"]
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def make_plots(usable: Usable, thresholds: dict[float, dict[str, float]], out_dir: Path, opt: Options) -> list[Path]:
|
||||
try:
|
||||
import matplotlib
|
||||
matplotlib.use("Agg", force=True)
|
||||
import matplotlib.pyplot as plt
|
||||
except ImportError:
|
||||
return []
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
ref = [(r, d) for r, d in usable if float(r["pitch_deg"]) == opt.pitch_ref] # type: ignore[arg-type]
|
||||
distances = sorted({float(r["distance_m"]) for r, _ in ref}) # type: ignore[arg-type]
|
||||
if not distances:
|
||||
return []
|
||||
paths: list[Path] = []
|
||||
fig, ax = plt.subplots(figsize=(6, 6))
|
||||
for distance in distances:
|
||||
pts = [(float(r["true_yaw_deg"]), float(r["delta_deg"])) for r, _ in ref if float(r["distance_m"]) == distance] # type: ignore[arg-type]
|
||||
ax.scatter([p[0] for p in pts], [p[1] for p in pts], s=18, alpha=0.7, label=f"{distance:g} m")
|
||||
lim = max(abs(float(r["true_yaw_deg"])) for r, _ in ref) * 1.15 + 1 # type: ignore[arg-type]
|
||||
ax.plot([-lim, lim], [-lim, lim], "k--", linewidth=1, label="y = x")
|
||||
ax.set(xlabel="true yaw [deg]", ylabel="estimated yaw change [deg]", title="Estimated vs true")
|
||||
ax.legend()
|
||||
ax.grid(alpha=0.3)
|
||||
fig.tight_layout()
|
||||
paths.append(out_dir / "accuracy_scatter.png")
|
||||
fig.savefig(paths[-1])
|
||||
plt.close(fig)
|
||||
fig, axes = plt.subplots(len(distances), 1, figsize=(8, 3.2 * len(distances)), squeeze=False)
|
||||
for ax, distance in zip(axes[:, 0], distances):
|
||||
by_angle: dict[float, list[float]] = {}
|
||||
for r, d in ref:
|
||||
if float(r["distance_m"]) == distance: # type: ignore[arg-type]
|
||||
by_angle.setdefault(float(r["true_yaw_deg"]), []).extend(_errors(r, d)) # type: ignore[arg-type]
|
||||
angles = sorted(by_angle)
|
||||
ax.boxplot([by_angle[a] for a in angles], positions=list(range(len(angles))), showfliers=False)
|
||||
ax.set_xticks(list(range(len(angles))))
|
||||
ax.set_xticklabels([f"{a:+g}" for a in angles])
|
||||
theta = thresholds.get(distance, {}).get("theta_th_deg")
|
||||
ax.axhline(0, color="k", linewidth=0.8)
|
||||
if theta is not None:
|
||||
ax.axhline(theta, color="r", linestyle=":")
|
||||
ax.axhline(-theta, color="r", linestyle=":")
|
||||
ax.set(title=f"Frame error by true yaw ({distance:g} m)", xlabel="true yaw [deg]", ylabel="error [deg]")
|
||||
fig.tight_layout()
|
||||
paths.append(out_dir / "accuracy_error_by_condition.png")
|
||||
fig.savefig(paths[-1])
|
||||
plt.close(fig)
|
||||
fig, ax = plt.subplots(figsize=(7, 4))
|
||||
for distance in distances:
|
||||
errors = [e for r, d in ref if float(r["distance_m"]) == distance # type: ignore[arg-type]
|
||||
and abs(float(r["true_yaw_deg"])) <= opt.pool_max_angle for e in _errors(r, d)] # type: ignore[arg-type]
|
||||
if errors:
|
||||
ax.hist(errors, bins=40, histtype="step", density=True, label=f"{distance:g} m")
|
||||
ax.set(xlabel="error [deg]", ylabel="density", title=f"Frame error distribution (|true| <= {opt.pool_max_angle:g} deg)")
|
||||
ax.legend()
|
||||
fig.tight_layout()
|
||||
paths.append(out_dir / "accuracy_error_hist.png")
|
||||
fig.savefig(paths[-1])
|
||||
plt.close(fig)
|
||||
return paths
|
||||
|
||||
|
||||
def run_report(trial_paths: Sequence[Path], face_paths: Sequence[Path], out_dir: Path, opt: Options,
|
||||
exclude_seqs: Sequence[int] = (), plots: bool = True) -> dict[str, object]:
|
||||
trials = load_trials(trial_paths, exclude_seqs)
|
||||
series = load_face_series(face_paths)
|
||||
evaluated = [evaluate_trial(t, series, opt) for t in trials]
|
||||
records = [rec for rec, _ in evaluated]
|
||||
usable: Usable = [(rec, deltas) for rec, deltas in evaluated if rec["status"] == "ok"]
|
||||
conditions = summarize_conditions(usable)
|
||||
thresholds = compute_thresholds(usable, opt)
|
||||
add_exceed_rates(conditions, usable, thresholds, opt)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
_write_csv(out_dir / "accuracy_trials.csv", TRIAL_COLUMNS, records)
|
||||
_write_csv(out_dir / "accuracy_conditions.csv", CONDITION_COLUMNS, conditions)
|
||||
_write_csv(out_dir / "accuracy_thresholds.csv", THRESHOLD_COLUMNS,
|
||||
[{"distance_m": d, **t} for d, t in sorted(thresholds.items())])
|
||||
markdown = build_markdown(records, conditions, thresholds, opt)
|
||||
(out_dir / "accuracy_report.md").write_text(markdown, encoding="utf-8")
|
||||
figures = make_plots(usable, thresholds, out_dir / "plots", opt) if plots else []
|
||||
return {"records": records, "conditions": conditions, "thresholds": thresholds, "markdown": markdown,
|
||||
"figures": figures}
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None, out=print) -> int: # type: ignore[no-untyped-def]
|
||||
parser = argparse.ArgumentParser(description="Accuracy report from trial markers and face_frames logs")
|
||||
parser.add_argument("--trials", nargs="+", required=True, help="trials CSV file(s) from accuracy_session")
|
||||
parser.add_argument("--face-csv", nargs="+", required=True, help="*_face_frames.csv file(s); globs allowed")
|
||||
parser.add_argument("--out", type=Path, default=Path("data/analysis/accuracy/report"))
|
||||
parser.add_argument("--signal", choices=("yaw", "raw_yaw"), default="yaw")
|
||||
parser.add_argument("--flip-sign", action="store_true")
|
||||
parser.add_argument("--settle", type=float, default=1.5, help="seconds after the cue ignored (turn in progress)")
|
||||
parser.add_argument("--baseline-trim", type=float, default=0.5)
|
||||
parser.add_argument("--min-frames", type=int, default=10)
|
||||
parser.add_argument("--face-every", type=int, default=1, help="face.process_every_n_frames of the run")
|
||||
parser.add_argument("--track-id", type=int, default=None)
|
||||
parser.add_argument("--fps", type=float, default=None, help="override the frame rate used for detection rate")
|
||||
parser.add_argument("--k", type=float, default=3.0)
|
||||
parser.add_argument("--pool-max-angle", type=float, default=15.0)
|
||||
parser.add_argument("--basis", choices=("worst", "pooled"), default="worst")
|
||||
parser.add_argument("--detect-ratio", type=float, default=0.95)
|
||||
parser.add_argument("--exclude-seq", type=int, nargs="*", default=[])
|
||||
parser.add_argument("--no-plots", action="store_true")
|
||||
args = parser.parse_args(argv)
|
||||
trial_paths, face_paths = expand_paths(args.trials), expand_paths(args.face_csv)
|
||||
missing = [str(p) for p in trial_paths + face_paths if not p.is_file()]
|
||||
if missing or not trial_paths or not face_paths:
|
||||
print(f"file not found: {', '.join(missing) or 'no input files'}", file=sys.stderr)
|
||||
return 2
|
||||
opt = Options(args.signal, args.flip_sign, args.settle, args.baseline_trim, args.min_frames, args.face_every,
|
||||
args.track_id, args.fps, args.k, args.pool_max_angle, args.basis, args.detect_ratio)
|
||||
result = run_report(trial_paths, face_paths, args.out, opt, args.exclude_seq, not args.no_plots)
|
||||
if not result["records"]:
|
||||
print("no usable trials (status ok) in the trials file", file=sys.stderr)
|
||||
return 1
|
||||
out(str(result["markdown"]))
|
||||
out(f"-> {args.out}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,290 @@
|
||||
"""Trial-session runner for the head-yaw accuracy experiment.
|
||||
|
||||
Run it in a second terminal while ``python main.py`` records ``*_face_frames.csv``.
|
||||
The runner only writes *wall-clock markers* (``data/analysis/accuracy/trials_*.csv``);
|
||||
``src.accuracy_report`` later joins those markers with the face log by timestamp, so
|
||||
the live pipeline is not touched.
|
||||
|
||||
1. Generate a randomized plan (blocked by distance, shuffled inside each block)::
|
||||
|
||||
python -m src.accuracy_session plan --distances 1,2,3 --repeats 3 --seed 1 --subject S01
|
||||
|
||||
2. Start the system (voice prompt off), then run the session::
|
||||
|
||||
python main.py --disable-voice --camera-position-note accuracy
|
||||
python -m src.accuracy_session run --plan data/analysis/accuracy/plan.csv
|
||||
|
||||
Per trial: baseline (look straight ahead) -> cue -> turn the head to the target and
|
||||
hold -> cue -> return. Prompt keys: Enter=start, r=redo previous, x=discard previous,
|
||||
s=skip this condition, q=quit. ``--auto-interval 6`` removes the Enter key between
|
||||
trials (single-person operation); a distance change always waits for Enter.
|
||||
Re-run with ``--resume --trials <same file>`` to continue an interrupted session.
|
||||
|
||||
The sign of ``true_yaw_deg`` is whatever you define on the floor sheet; the report
|
||||
checks the regression slope and tells you when ``--flip-sign`` is needed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import random
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Callable, Sequence
|
||||
|
||||
DEFAULT_DIRECTORY = Path("data/analysis/accuracy")
|
||||
DEFAULT_ANGLES = (0.0, 5.0, -5.0, 10.0, -10.0, 15.0, -15.0, 20.0, -20.0, 30.0, -30.0)
|
||||
PLAN_FIELDS = ["seq", "block", "condition_id", "distance_m", "true_yaw_deg", "pitch_deg", "repeat", "subject"]
|
||||
TRIAL_FIELDS = ["session_id", "seq", "block", "condition_id", "distance_m", "true_yaw_deg", "pitch_deg", "repeat",
|
||||
"subject", "status", "t_baseline_start", "t_turn_cue", "t_hold_end", "baseline_sec", "hold_sec"]
|
||||
FINAL_STATUSES = {"ok", "skipped"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlanRow:
|
||||
seq: int
|
||||
block: int
|
||||
condition_id: str
|
||||
distance_m: float
|
||||
true_yaw_deg: float
|
||||
pitch_deg: float
|
||||
repeat: int
|
||||
subject: str = ""
|
||||
|
||||
|
||||
def condition_id(distance: float, yaw: float, pitch: float) -> str:
|
||||
return f"d{distance:g}_y{yaw + 0.0:+g}_p{pitch + 0.0:+g}"
|
||||
|
||||
|
||||
def parse_floats(text: str) -> list[float]:
|
||||
return [float(part) for part in text.replace(" ", "").split(",") if part]
|
||||
|
||||
|
||||
def build_plan(
|
||||
distances: Sequence[float], angles: Sequence[float], repeats: int, *, seed: int | None = None,
|
||||
subject: str = "", pitch_levels: Sequence[float] = (), pitch_distances: Sequence[float] = (),
|
||||
pitch_angles: Sequence[float] = (0.0, 10.0, -10.0),
|
||||
) -> list[PlanRow]:
|
||||
"""One block per distance; trials are shuffled inside a block (moving the subject is slow)."""
|
||||
rng = random.Random(seed)
|
||||
rows: list[PlanRow] = []
|
||||
seq = 0
|
||||
for block, distance in enumerate(distances, start=1):
|
||||
combos = [(a, 0.0) for a in angles]
|
||||
if distance in pitch_distances:
|
||||
combos += [(a, p) for p in pitch_levels if p != 0 for a in pitch_angles]
|
||||
items = [(a, p, r) for a, p in combos for r in range(1, repeats + 1)]
|
||||
rng.shuffle(items)
|
||||
for a, p, r in items:
|
||||
seq += 1
|
||||
rows.append(PlanRow(seq, block, condition_id(distance, a, p), distance, a + 0.0, p + 0.0, r, subject))
|
||||
return rows
|
||||
|
||||
|
||||
def write_plan(rows: Sequence[PlanRow], path: Path) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("w", encoding="utf-8-sig", newline="") as stream:
|
||||
writer = csv.DictWriter(stream, fieldnames=PLAN_FIELDS)
|
||||
writer.writeheader()
|
||||
for row in rows:
|
||||
writer.writerow({field: getattr(row, field) for field in PLAN_FIELDS})
|
||||
|
||||
|
||||
def read_plan(path: Path) -> list[PlanRow]:
|
||||
with path.open(encoding="utf-8-sig", newline="") as stream:
|
||||
return [
|
||||
PlanRow(int(r["seq"]), int(r["block"]), r["condition_id"], float(r["distance_m"]),
|
||||
float(r["true_yaw_deg"]), float(r["pitch_deg"]), int(r["repeat"]), r.get("subject", ""))
|
||||
for r in csv.DictReader(stream)
|
||||
]
|
||||
|
||||
|
||||
def load_done(path: Path) -> set[int]:
|
||||
"""Sequence numbers whose *last* log row is final (ok / skipped)."""
|
||||
last: dict[int, str] = {}
|
||||
with path.open(encoding="utf-8-sig", newline="") as stream:
|
||||
for row in csv.DictReader(stream):
|
||||
last[int(row["seq"])] = row["status"]
|
||||
return {seq for seq, status in last.items() if status in FINAL_STATUSES}
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now().astimezone().isoformat(timespec="milliseconds")
|
||||
|
||||
|
||||
def _beep(kind: str) -> None:
|
||||
try:
|
||||
import winsound
|
||||
winsound.Beep({"start": 880, "turn": 1320, "end": 440}.get(kind, 660), 200)
|
||||
except (ImportError, RuntimeError):
|
||||
print("\a", end="", flush=True)
|
||||
|
||||
|
||||
def _sign(value: float) -> str:
|
||||
return f"{value + 0.0:+g}"
|
||||
|
||||
|
||||
class SessionRunner:
|
||||
"""Interactive loop; all I/O and time sources are injectable for tests."""
|
||||
|
||||
def __init__(
|
||||
self, plan: Sequence[PlanRow], trials_path: Path, session_id: str, *, baseline_sec: float = 3.0,
|
||||
hold_sec: float = 5.0, auto_interval: float = 0.0, input_fn: Callable[[str], str] = input,
|
||||
out: Callable[[str], object] = print, sleep: Callable[[float], object] = time.sleep,
|
||||
clock: Callable[[], str] = _now_iso, beep: Callable[[str], object] = _beep,
|
||||
) -> None:
|
||||
self.plan, self.trials_path, self.session_id = list(plan), trials_path, session_id
|
||||
self.baseline_sec, self.hold_sec, self.auto_interval = baseline_sec, hold_sec, auto_interval
|
||||
self.input_fn, self.out, self.sleep, self.clock, self.beep = input_fn, out, sleep, clock, beep
|
||||
|
||||
def run(self, resume: bool = False) -> int:
|
||||
path = self.trials_path
|
||||
exists = path.is_file() and path.stat().st_size > 0
|
||||
if exists and not resume:
|
||||
self.out(f"trials file already exists: {path} (use --resume or another --trials)")
|
||||
return 2
|
||||
pending = [row for row in self.plan if row.seq not in (load_done(path) if exists else set())]
|
||||
if not pending:
|
||||
self.out("nothing to do: every planned condition is already finished")
|
||||
return 0
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
estimate = len(pending) * (self.baseline_sec + self.hold_sec + 4.0) / 60.0
|
||||
self.out(f"{len(pending)} trials pending (rough duration {estimate:.0f} min). session={self.session_id}")
|
||||
with path.open("a", encoding="utf-8-sig", newline="") as stream:
|
||||
writer = csv.DictWriter(stream, fieldnames=TRIAL_FIELDS)
|
||||
if not exists:
|
||||
writer.writeheader()
|
||||
return self._loop(pending, writer, stream)
|
||||
|
||||
def _record(self, row: PlanRow, status: str, t0: str = "", t1: str = "", t2: str = "") -> dict[str, object]:
|
||||
return {"session_id": self.session_id, "seq": row.seq, "block": row.block, "condition_id": row.condition_id,
|
||||
"distance_m": row.distance_m, "true_yaw_deg": row.true_yaw_deg, "pitch_deg": row.pitch_deg,
|
||||
"repeat": row.repeat, "subject": row.subject, "status": status, "t_baseline_start": t0,
|
||||
"t_turn_cue": t1, "t_hold_end": t2, "baseline_sec": self.baseline_sec, "hold_sec": self.hold_sec}
|
||||
|
||||
def _loop(self, pending: list[PlanRow], writer: csv.DictWriter, stream) -> int: # type: ignore[type-arg]
|
||||
def log(row: PlanRow, status: str, *stamps: str) -> None:
|
||||
writer.writerow(self._record(row, status, *stamps))
|
||||
stream.flush()
|
||||
|
||||
index, last_index, current_block, finished = 0, None, None, 0
|
||||
while index < len(pending):
|
||||
row = pending[index]
|
||||
new_block = row.block != current_block
|
||||
if new_block:
|
||||
current_block = row.block
|
||||
self.out(f"\n=== block {row.block}: set the subject at {row.distance_m:g} m, then continue ===")
|
||||
self.out(f"[{index + 1}/{len(pending)}] seq {row.seq} distance {row.distance_m:g} m "
|
||||
f"yaw {_sign(row.true_yaw_deg)} deg pitch {_sign(row.pitch_deg)} deg (repeat {row.repeat})")
|
||||
try:
|
||||
if self.auto_interval > 0 and not new_block:
|
||||
self._countdown()
|
||||
command = ""
|
||||
else:
|
||||
command = self.input_fn("Enter=start r=redo-prev x=discard-prev s=skip q=quit > ").strip().lower()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
command = "q"
|
||||
if command == "q":
|
||||
break
|
||||
if command in {"r", "x"}:
|
||||
if last_index is None:
|
||||
self.out("no previous trial in this run")
|
||||
continue
|
||||
log(pending[last_index], "redo" if command == "r" else "discarded")
|
||||
finished -= 1
|
||||
if command == "r":
|
||||
index, last_index, current_block = last_index, None, None
|
||||
else:
|
||||
last_index = None
|
||||
continue
|
||||
if command == "s":
|
||||
log(row, "skipped")
|
||||
index, last_index = index + 1, None
|
||||
continue
|
||||
try:
|
||||
stamps = self._execute(row)
|
||||
except KeyboardInterrupt:
|
||||
log(row, "aborted")
|
||||
self.out("aborted; the interrupted trial is marked and can be re-run with --resume")
|
||||
return 130
|
||||
log(row, "ok", *stamps)
|
||||
finished += 1
|
||||
last_index, index = index, index + 1
|
||||
self.out(f"session ended: {finished} trials recorded in this run -> {self.trials_path}")
|
||||
return 0
|
||||
|
||||
def _countdown(self) -> None:
|
||||
remaining = self.auto_interval
|
||||
while remaining > 0:
|
||||
self.out(f" starting in {remaining:.0f} s ...")
|
||||
self.sleep(min(1.0, remaining))
|
||||
remaining -= 1.0
|
||||
|
||||
def _execute(self, row: PlanRow) -> tuple[str, str, str]:
|
||||
t_base = self.clock()
|
||||
self.beep("start")
|
||||
self.out(f" baseline {self.baseline_sec:g} s: face the camera and stay still")
|
||||
self.sleep(self.baseline_sec)
|
||||
t_cue = self.clock()
|
||||
self.beep("turn")
|
||||
self.out(f" TURN to {_sign(row.true_yaw_deg)} deg (head only) and hold {self.hold_sec:g} s")
|
||||
self.sleep(self.hold_sec)
|
||||
t_end = self.clock()
|
||||
self.beep("end")
|
||||
self.out(" return to the front")
|
||||
return t_base, t_cue, t_end
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="Accuracy experiment: plan generation and trial session")
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
plan = sub.add_parser("plan", help="write a randomized condition plan CSV")
|
||||
plan.add_argument("--distances", default="1,2,3", help="metres, comma separated (one block each)")
|
||||
plan.add_argument("--angles", default=",".join(f"{a:g}" for a in DEFAULT_ANGLES), help="true yaw angles in degrees")
|
||||
plan.add_argument("--repeats", type=int, default=3)
|
||||
plan.add_argument("--seed", type=int, default=None)
|
||||
plan.add_argument("--subject", default="")
|
||||
plan.add_argument("--pitch-levels", default="", help="extra pitch conditions, e.g. -10,10 (optional)")
|
||||
plan.add_argument("--pitch-distances", default="2", help="distances that also get the pitch conditions")
|
||||
plan.add_argument("--pitch-angles", default="0,10,-10", help="yaw angles used with the extra pitch levels")
|
||||
plan.add_argument("--out", type=Path, default=DEFAULT_DIRECTORY / "plan.csv")
|
||||
run = sub.add_parser("run", help="run the interactive trial session")
|
||||
run.add_argument("--plan", type=Path, default=DEFAULT_DIRECTORY / "plan.csv")
|
||||
run.add_argument("--trials", type=Path, default=None, help="output markers CSV (default: trials_<session>.csv)")
|
||||
run.add_argument("--session-id", default=None)
|
||||
run.add_argument("--baseline-sec", type=float, default=3.0)
|
||||
run.add_argument("--hold-sec", type=float, default=5.0)
|
||||
run.add_argument("--auto-interval", type=float, default=0.0, help="seconds between trials; 0 = wait for Enter")
|
||||
run.add_argument("--resume", action="store_true")
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None, out: Callable[[str], object] = print) -> int:
|
||||
args = _build_parser().parse_args(argv)
|
||||
if args.command == "plan":
|
||||
rows = build_plan(parse_floats(args.distances), parse_floats(args.angles), args.repeats, seed=args.seed,
|
||||
subject=args.subject, pitch_levels=parse_floats(args.pitch_levels),
|
||||
pitch_distances=parse_floats(args.pitch_distances),
|
||||
pitch_angles=parse_floats(args.pitch_angles))
|
||||
write_plan(rows, args.out)
|
||||
out(f"{len(rows)} trials, {len({r.block for r in rows})} blocks -> {args.out}")
|
||||
return 0
|
||||
if not args.plan.is_file():
|
||||
print(f"plan not found: {args.plan} (run the 'plan' command first)", file=sys.stderr)
|
||||
return 2
|
||||
session_id = args.session_id or datetime.now().strftime("acc_%Y%m%d_%H%M%S")
|
||||
trials = args.trials or DEFAULT_DIRECTORY / f"trials_{session_id}.csv"
|
||||
if args.resume and args.trials is None:
|
||||
print("--resume needs --trials <existing file>", file=sys.stderr)
|
||||
return 2
|
||||
runner = SessionRunner(read_plan(args.plan), trials, session_id, baseline_sec=args.baseline_sec,
|
||||
hold_sec=args.hold_sec, auto_interval=args.auto_interval)
|
||||
return runner.run(resume=args.resume)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in new issue
Block a user