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

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

+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())