中間発表の精度実験を効率化するためのコードおよびテストを追加

This commit is contained in:
agomizi1cm committed 2026-10-04 23:30:37 +09:00
1 parent 048bad7065
commit 2afebcb041
4 files changed
+992

No files matched your search

+464
View File
@@ -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())
+290
View File
@@ -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())