#!/usr/bin/env python3 """推論の記録に成功/失敗を付ける判定の画面 (依頼 183). python3 pi05/verdict_viewer/server.py # http://localhost:8765/ をブラウザで開く python3 pi05/verdict_viewer/server.py --no-open # ブラウザを開かない - 元の記録 (runs/pi05_policy/) は読むだけ. 判定は pi05_policy_verdicts_v1.csv に 1 行ずつ追記するだけ - 書く前に KIOXIA のマウントを確かめ, なければ書かずに画面へエラーを返す - localhost だけで待ち受ける """ import argparse import csv import json import mimetypes import re import sys import threading import webbrowser from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import unquote, urlparse HERE = Path(__file__).resolve().parent sys.path.insert(0, str(HERE)) import verdicts as V # noqa: E402 STEM_RE = re.compile(r"^pi05_[0-9_]+$") FRAME_RE = re.compile(r"^[0-9A-Za-z_]+\.jpg$") MAX_STATE_POINTS = 1500 def state_series(run_dir): """関節 6 本と爪の時系列. recording/states.csv があればそれ, なければ log.jsonl の state.""" rd = Path(run_dir) p = V.safe_file(rd, "recording", "states.csv") t, joints, grip = [], [], [] stages = [] if p is not None: t0 = None with open(p, newline="") as f: for r in csv.DictReader(f): try: ts = float(r["ts"]) except (TypeError, ValueError): continue t0 = ts if t0 is None else t0 ev = r.get("event_type") if ev == "actual": try: joints.append([float(r[f"j{i}"]) for i in range(6)]) except (TypeError, ValueError): continue t.append(round(ts - t0, 3)) elif ev == "gripper_cmd" and r.get("gripper_state") not in (None, ""): try: grip.append([round(ts - t0, 3), float(r["gripper_state"])]) except ValueError: pass elif ev == "stage": stages.append([round(ts - t0, 3), r.get("stage", "")]) if len(t) > MAX_STATE_POINTS: k = len(t) / MAX_STATE_POINTS idx = sorted(set(int(i * k) for i in range(MAX_STATE_POINTS)) | {len(t) - 1}) t = [t[i] for i in idx] joints = [joints[i] for i in idx] return {"source": "recording/states.csv", "t": t, "joints": joints, "grip": grip, "grip_label": "爪の命令 (0 開 / 1 閉)", "stages": stages, "t0": t0} t0 = None for x in V.read_log(rd): if "chunk" not in x or "state" not in x: continue try: st = json.loads(x["state"]) if isinstance(x["state"], str) else x["state"] ts = float(x["t"]) except Exception: continue if not isinstance(st, list) or len(st) < 6: continue t0 = ts if t0 is None else t0 t.append(round(ts - t0, 3)) joints.append([float(v) for v in st[:6]]) if len(st) >= 7: grip.append([round(ts - t0, 3), float(st[6])]) return {"source": "log.jsonl の state (推論の前に読んだ値)", "t": t, "joints": joints, "grip": grip, "grip_label": "state の爪 (7 番目の値)", "stages": [], "t0": t0} def video_times(run_dir, t0): """top.mp4 の各コマの時刻 (states の始まりからの秒). 再生位置のしるしに使う.""" p = V.safe_file(run_dir, "recording", "top_frame_log.csv") if t0 is None or p is None: return None out = [] try: with open(p, newline="") as f: for r in csv.DictReader(f): out.append(round(float(r["capture_ts"]) - t0, 3)) except Exception: return None return out class App: def __init__(self, runs_dir, csv_path): self.runs_dir = Path(runs_dir) self.csv_path = Path(csv_path) self.lock = threading.Lock() self.done_ops = {} # 画面の 1 回の操作 (op_id) → 書いた行. 応答が届かず押し直しても 2 回書かない (codex R7) def runs_summary(self): rows = V.read_rows(self.csv_path) latest = V.current_by_stem(rows) # 確定の行があればそれ (依頼 185) ai = V.latest_by_stem(rows, labelers=("ai",)) human = V.latest_by_stem(rows, labelers=V.HUMAN_LABELERS) out = [] for stem in V.list_run_stems(self.runs_dir): rd = self.runs_dir / stem lt, a, h = latest.get(stem), ai.get(stem), human.get(stem) out.append({ "run_stem": stem, "has_mp4": V.has_top_mp4(rd), "n_frames": len(V.frame_files(rd)), "outcome": lt["outcome"] if lt else "", "labeler": lt["labeler"] if lt else "", "ai_outcome": a["outcome"] if a else "", "human_outcome": h["outcome"] if h else "", "human_at": h["labeled_at"] if h else "", # 人が確定した時刻 (自分が確定した回の一覧. 依頼 187) "confirmed": bool(lt and lt["labeler"] in V.CONFIRMED_LABELERS), }) return out def stats(self): """確定した回だけの成功率 (依頼 185). 記録のフォルダにある回だけ数える.""" stems = set(V.list_run_stems(self.runs_dir)) return V.success_rate([r for r in V.read_rows(self.csv_path) if r["run_stem"] in stems]) def run_detail(self, stem): rd = self.runs_dir / stem rows = [r for r in V.read_rows(self.csv_path) if r["run_stem"] == stem] ai = [r for r in rows if r["labeler"] == "ai"] j = V.read_run_json(rd) log = V.read_log(rd) st = state_series(rd) return { "run_stem": stem, "has_mp4": V.has_top_mp4(rd), "frames": V.frame_files(rd), "states": st, "video_t": video_times(rd, st.get("t0")) if V.has_top_mp4(rd) else None, "ai": ai[-1] if ai else None, "latest": V.current_by_stem(rows).get(stem), "history": rows, "executed_chunks": V.executed_chunks(log), "end_reason": (j or {}).get("end_reason", ""), "model": next((x.get("model") for x in log if x.get("type") == "model"), "") or ((j or {}).get("args") or {}).get("model", ""), } def add_verdict(self, body): stem = body.get("run_stem", "") if (not STEM_RE.match(stem) or not (self.runs_dir / stem).is_dir() or not V.within(self.runs_dir, self.runs_dir / stem)): raise ValueError(f"run_stem がありません: {stem!r}") outcome = body.get("outcome") if outcome not in V.OUTCOMES: raise ValueError(f"outcome が正しくありません: {outcome!r}") rd = self.runs_dir / stem op_id = str(body.get("op_id") or "") with self.lock: if op_id and op_id in self.done_ops: print(f"[SAVE ] {stem} 同じ操作の送り直し. 書いていない (op_id {op_id})") return self.done_ops[op_id] ai = V.latest_by_stem(V.read_rows(self.csv_path), labelers=("ai",)).get(stem) default = "yes" if outcome == "success" else "unknown" pick = body.get("pick") or default place = body.get("place") or default row = {"labeled_at": V.now_iso(), "run_stem": stem, "pick": pick, "place": place, "outcome": outcome, "labeler": V.human_labeler_for(outcome, ai), "ai_outcome": ai["outcome"] if ai else "", "ai_reason": ai["ai_reason"] if ai else "", "evidence": "top.mp4" if V.has_top_mp4(rd) else ("frames" if V.frame_files(rd) else ""), "note": str(body.get("note", "")).replace("\n", " ").strip()} written = V.append_rows([row], self.csv_path, forbid_dirs=(self.runs_dir,))[0] if op_id: self.done_ops[op_id] = written print(f"[SAVE ] {written['run_stem']} {written['outcome']} pick={written['pick']} " f"place={written['place']} labeler={written['labeler']}") return written def make_handler(app): class H(BaseHTTPRequestHandler): def log_message(self, fmt, *args): pass def _send(self, code, data, ctype="application/json; charset=utf-8", extra=None): if not isinstance(data, (bytes, bytearray)): data = json.dumps(data, ensure_ascii=False).encode("utf-8") self.send_response(code) self.send_header("Content-Type", ctype) self.send_header("Content-Length", str(len(data))) self.send_header("Cache-Control", "no-store") for k, v in (extra or {}).items(): self.send_header(k, v) self.end_headers() if self.command != "HEAD": self.wfile.write(data) def _file(self, stem, *rel): # リンクでその回の記録の外へ出ない (codex R6・3 回目 R2) path = V.safe_file(app.runs_dir / stem, *rel) if V.within(app.runs_dir, app.runs_dir / stem) else None if path is None: return self._send(404, {"error": "not found"}) size = path.stat().st_size ctype = mimetypes.guess_type(str(path))[0] or "application/octet-stream" rng = self.headers.get("Range") start, end = 0, size - 1 if rng: m = re.match(r"bytes=(\d*)-(\d*)", rng) if m: if m.group(1): start = int(m.group(1)) if m.group(2): end = min(int(m.group(2)), size - 1) elif m.group(2): start = max(0, size - int(m.group(2))) if start > end or start >= size: self.send_response(416) self.send_header("Content-Range", f"bytes */{size}") self.end_headers() return length = end - start + 1 self.send_response(206 if rng else 200) self.send_header("Content-Type", ctype) self.send_header("Accept-Ranges", "bytes") self.send_header("Content-Length", str(length)) if rng: self.send_header("Content-Range", f"bytes {start}-{end}/{size}") self.end_headers() if self.command == "HEAD": return with open(path, "rb") as f: f.seek(start) left = length while left > 0: chunk = f.read(min(1 << 20, left)) if not chunk: break try: self.wfile.write(chunk) except (BrokenPipeError, ConnectionResetError): return left -= len(chunk) def do_HEAD(self): self.do_GET() def do_GET(self): u = urlparse(self.path) parts = [unquote(p) for p in u.path.split("/") if p] try: if not parts: return self._send(200, (HERE / "index.html").read_bytes(), "text/html; charset=utf-8") if parts == ["api", "runs"]: return self._send(200, {"runs": app.runs_summary(), "stats": app.stats(), "csv": str(app.csv_path)}) if (len(parts) == 3 and parts[:2] == ["api", "run"] and STEM_RE.match(parts[2]) and (app.runs_dir / parts[2]).is_dir() and V.within(app.runs_dir, app.runs_dir / parts[2])): return self._send(200, app.run_detail(parts[2])) if len(parts) == 3 and parts[0] == "media" and STEM_RE.match(parts[1]) and parts[2] == "top.mp4": return self._file(parts[1], "recording", "top.mp4") if (len(parts) == 4 and parts[0] == "media" and STEM_RE.match(parts[1]) and parts[2] == "frames" and FRAME_RE.match(parts[3])): return self._file(parts[1], "frames", parts[3]) return self._send(404, {"error": "not found"}) except Exception as e: # 画面に理由を返す return self._send(500, {"error": f"{type(e).__name__}: {e}"}) def do_POST(self): if urlparse(self.path).path != "/api/verdict": return self._send(404, {"error": "not found"}) try: n = int(self.headers.get("Content-Length") or 0) body = json.loads(self.rfile.read(n).decode("utf-8") or "{}") row = app.add_verdict(body) return self._send(200, {"ok": True, "row": row}) except (V.NotMounted, V.BrokenCsv) as e: print(f"[ABORT] {e}") return self._send(503, {"error": str(e)}) except ValueError as e: return self._send(400, {"error": str(e)}) except Exception as e: return self._send(500, {"error": f"{type(e).__name__}: {e}"}) return H def main(): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--port", type=int, default=8765) ap.add_argument("--no-open", action="store_true", help="ブラウザを開かない") ap.add_argument("--runs-dir", default=str(V.RUNS_DIR)) ap.add_argument("--test-csv", default=None, help="画面の確かめ用. 判定を保存先テーブルの CSV でなくこのファイルに書く (ふだんは付けない)") a = ap.parse_args() if not Path(a.runs_dir).is_dir(): print(f"[ABORT] 記録のフォルダがありません ({a.runs_dir}). KIOXIA をつないでください") return 2 csv_path = Path(a.test_csv) if a.test_csv else V.VERDICTS_CSV if a.test_csv and V.within(a.runs_dir, csv_path): print(f"[ABORT] --test-csv が記録のフォルダの中です ({csv_path}). 元の記録には書きません") return 2 if a.test_csv: print(f"[TEST ] 確かめ用: 判定は {csv_path} に書きます (保存先テーブルの CSV には書きません)") app = App(a.runs_dir, csv_path) srv = ThreadingHTTPServer(("127.0.0.1", a.port), make_handler(app)) url = f"http://localhost:{a.port}/" print(f"[VIEW ] {url} (判定は {csv_path} に追記. Ctrl-C で終わる)") if not a.no_open: threading.Timer(0.5, lambda: webbrowser.open(url)).start() try: srv.serve_forever() except KeyboardInterrupt: pass finally: srv.server_close() return 0 if __name__ == "__main__": sys.exit(main())