中間発表の精度実験を効率化するためのコードおよびテストを追加
This commit is contained in:
1 parent
048bad7065
commit
2afebcb041
4 files changed
+992
No files matched your search
@@ -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