Spaces:
Paused
Paused
Download server.py from Kakituken/anim-user-study: direct link, hf CLI and curl.
- Browser
- Download file 15.9 kB
-
https://huggingface.co/spaces/Kakituken/anim-user-study/resolve/main/server.py
- Command line
-
hf download hf://spaces/Kakituken/anim-user-study/server.py
-
curl -L -o server.py https://huggingface.co/spaces/Kakituken/anim-user-study/resolve/main/server.py
15.9 kB
| #!/usr/bin/env python3 | |
| """User-study server: static pages + a small JSON API. Python standard library only | |
| (huggingface_hub is optional, for backing up data/ to a private HF dataset). | |
| python3 server.py [--port 8000] [--host 0.0.0.0] | |
| Env: | |
| PART1_N (12), PART2_N (10) questions per participant | |
| ADMIN_KEY enables GET /api/admin/summary?key=... and /api/admin/export?key=... | |
| HF_TOKEN + BACKUP_REPO e.g. BACKUP_REPO=user/us_responses (private dataset): restore data/ on start, | |
| upload it every BACKUP_EVERY seconds (120) when it changed | |
| Data (data/): | |
| participants.json pid -> {created, questions: [...with the hidden method order...], done: bool} | |
| responses.jsonl one line per answer (raw labels + resolved methods) | |
| """ | |
| import argparse | |
| import json | |
| import os | |
| import random | |
| import re | |
| import threading | |
| import time | |
| from http import HTTPStatus | |
| from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer | |
| from urllib.parse import parse_qs, urlparse | |
| HERE = os.path.dirname(os.path.abspath(__file__)) | |
| DATA = os.environ.get("US_DATA", os.path.join(HERE, "data")) | |
| STATIC = os.path.join(HERE, "static") | |
| PART1_N = int(os.environ.get("PART1_N", 12)) | |
| PART2_N = int(os.environ.get("PART2_N", 10)) | |
| ADMIN_KEY = os.environ.get("ADMIN_KEY", "") | |
| PID_RE = re.compile(r"^[A-Za-z0-9]{1,5}$") | |
| LABELS = "ABCDE" | |
| # a finished ID that has not been opened again for this long is taken: entering it again is refused | |
| TAKEN_AFTER = int(os.environ.get("TAKEN_AFTER", 30 * 60)) | |
| def load_manifest(): | |
| """private/manifest.json if present (local runs); otherwise manifest.json from the private BACKUP_REPO dataset, | |
| so the method <-> video mapping is never part of a public Space repo.""" | |
| p = os.path.join(HERE, "private", "manifest.json") | |
| if os.path.exists(p): | |
| return json.load(open(p)) | |
| from huggingface_hub import hf_hub_download | |
| return json.load(open(hf_hub_download(os.environ["BACKUP_REPO"], "manifest.json", repo_type="dataset", | |
| token=os.environ["HF_TOKEN"]))) | |
| MAN = load_manifest() | |
| LOCK = threading.Lock() | |
| os.makedirs(DATA, exist_ok=True) | |
| P_FILE = os.path.join(DATA, "participants.json") | |
| R_FILE = os.path.join(DATA, "responses.jsonl") | |
| DIRTY = threading.Event() | |
| # ---------------------------------------------------------------- optional HF backup | |
| def hf_restore(): | |
| tok, repo = os.environ.get("HF_TOKEN"), os.environ.get("BACKUP_REPO") | |
| if not (tok and repo): | |
| return | |
| try: | |
| from huggingface_hub import hf_hub_download | |
| for name in ("participants.json", "responses.jsonl"): | |
| if not os.path.exists(os.path.join(DATA, name)): | |
| try: | |
| p = hf_hub_download(repo, name, repo_type="dataset", token=tok, local_dir=DATA) | |
| print("[backup] restored", p, flush=True) | |
| except Exception as e: # first run: nothing there yet | |
| print("[backup] no %s in %s (%s)" % (name, repo, type(e).__name__), flush=True) | |
| except ImportError: | |
| print("[backup] huggingface_hub not installed; backup disabled", flush=True) | |
| def hf_backup_loop(): | |
| tok, repo = os.environ.get("HF_TOKEN"), os.environ.get("BACKUP_REPO") | |
| if not (tok and repo): | |
| return | |
| from huggingface_hub import HfApi | |
| api, every = HfApi(token=tok), int(os.environ.get("BACKUP_EVERY", 120)) | |
| while True: | |
| time.sleep(every) | |
| if not DIRTY.is_set(): | |
| continue | |
| DIRTY.clear() | |
| try: | |
| with LOCK: | |
| snap = {n: open(os.path.join(DATA, n), "rb").read() for n in ("participants.json", "responses.jsonl") | |
| if os.path.exists(os.path.join(DATA, n))} | |
| from huggingface_hub import CommitOperationAdd | |
| api.create_commit(repo, [CommitOperationAdd(n, b) for n, b in snap.items()], repo_type="dataset", | |
| commit_message="backup %s" % time.strftime("%Y-%m-%d %H:%M:%S")) | |
| print("[backup] uploaded", list(snap), flush=True) | |
| except Exception as e: | |
| DIRTY.set() | |
| print("[backup] failed:", e, flush=True) | |
| # ---------------------------------------------------------------- state | |
| def load_participants(): | |
| if os.path.exists(P_FILE): | |
| return json.load(open(P_FILE)) | |
| return {} | |
| def save_participants(ps): | |
| tmp = P_FILE + ".tmp" | |
| with open(tmp, "w") as f: | |
| json.dump(ps, f) | |
| os.replace(tmp, P_FILE) | |
| DIRTY.set() | |
| def exposure(ps, part, assignable=False): | |
| excl = set(MAN.get("exclude", {}).get(part, [])) if assignable else set() | |
| c = {s: 0 for s in MAN[part] if s not in excl} | |
| for p in ps.values(): | |
| for q in p["questions"]: | |
| if q["part"] == part and q["scene"] in c: | |
| c[q["scene"]] += 1 | |
| return c | |
| def pick(counts, n, exclude, rng): | |
| pool = [s for s in counts if s not in exclude] | |
| rng.shuffle(pool) # random tie-break | |
| pool.sort(key=lambda s: counts[s]) # least-shown scenes first (stable sort keeps the shuffle) | |
| return pool[:n] | |
| def new_assignment(ps, rng): | |
| """Part 2 first (smaller pool, 27 scenes), then part 1 from the scenes not used in part 2.""" | |
| s2 = pick(exposure(ps, "part2", assignable=True), PART2_N, set(), rng) | |
| s1 = pick(exposure(ps, "part1", assignable=True), PART1_N, set(s2), rng) | |
| rng.shuffle(s1) | |
| rng.shuffle(s2) | |
| qs = [] | |
| for part, scenes in (("part1", s1), ("part2", s2)): | |
| for s in scenes: | |
| methods = list(MAN[part][s]) | |
| rng.shuffle(methods) # display order, per question | |
| qs.append({"part": part, "scene": s, "order": methods}) | |
| return qs | |
| def last_seen(p): | |
| return p.get("last_seen") or max([q.get("t", 0) for q in p["questions"]] + [p["created"]]) | |
| def is_taken(p): | |
| return bool(p.get("done")) and time.time() - last_seen(p) > TAKEN_AFTER | |
| def public_questions(p): | |
| out = [] | |
| for i, q in enumerate(p["questions"]): | |
| vids = [{"label": LABELS[k], "url": "/static/v/%s.mp4" % MAN[q["part"]][q["scene"]][m]} | |
| for k, m in enumerate(q["order"])] | |
| out.append({"qi": i, "part": q["part"], "prompt": MAN["prompts"][q["scene"]], "videos": vids, | |
| "answered": "answer" in q, "answer": q.get("answer")}) | |
| return out | |
| # ---------------------------------------------------------------- summary | |
| def summary(): | |
| ps = load_participants() | |
| m1 = {} | |
| m2 = {} | |
| n_done = sum(1 for p in ps.values() if p.get("done")) | |
| for p in ps.values(): | |
| for q in p["questions"]: | |
| a = q.get("answer") | |
| if a is None: | |
| continue | |
| if q["part"] == "part1": | |
| for lab, r in a.items(): | |
| meth = q["order"][LABELS.index(lab)] | |
| d = m1.setdefault(meth, {"n": 0, "rank_sum": 0, "top1": 0}) | |
| d["n"] += 1 | |
| d["rank_sum"] += r | |
| d["top1"] += int(r == 1) | |
| else: | |
| d = m2.setdefault(" vs ".join(sorted(q["order"])), {}) | |
| key = "tie" if a == "tie" else q["order"][LABELS.index(a)] | |
| d[key] = d.get(key, 0) + 1 | |
| part1 = {m: {"mean_rank": round(d["rank_sum"] / d["n"], 3), "top1_rate": round(d["top1"] / d["n"], 3), | |
| "n": d["n"]} for m, d in sorted(m1.items())} | |
| per_scene1 = {s: c for s, c in exposure(ps, "part1").items()} | |
| per_scene2 = {s: c for s, c in exposure(ps, "part2").items()} | |
| return {"participants": len(ps), "completed": n_done, "part1": part1, "part2": m2, | |
| "assigned_per_scene": {"part1": per_scene1, "part2": per_scene2}} | |
| # ---------------------------------------------------------------- HTTP | |
| class Handler(SimpleHTTPRequestHandler): | |
| def translate_path(self, path): | |
| p = urlparse(path).path | |
| p = p[len("/static"):] if p.startswith("/static/") else "/__none__" | |
| return super().translate_path(p) # drops '..' segments, stays inside static/ | |
| def __init__(self, *a, **kw): | |
| super().__init__(*a, directory=STATIC, **kw) # only static/ is ever served | |
| def log_message(self, fmt, *args): | |
| if "/static/v/" not in str(args[0] if args else ""): | |
| super().log_message(fmt, *args) | |
| def end_headers(self): | |
| if self.path.startswith("/static/v/"): | |
| self.send_header("Cache-Control", "public, max-age=86400") | |
| if not self.headers.get("Range"): | |
| self.send_header("Accept-Ranges", "bytes") | |
| else: | |
| self.send_header("Cache-Control", "no-store") | |
| super().end_headers() | |
| def send_json(self, obj, code=200): | |
| b = json.dumps(obj).encode() | |
| self.send_response(code) | |
| self.send_header("Content-Type", "application/json") | |
| self.send_header("Content-Length", str(len(b))) | |
| self.end_headers() | |
| self.wfile.write(b) | |
| def do_GET(self): | |
| u = urlparse(self.path) | |
| if u.path in ("/", "/index.html"): | |
| self.path = "/static/index.html" | |
| elif u.path in ("/survey", "/survey.html"): | |
| self.path = "/static/survey.html" | |
| elif u.path.startswith("/api/admin/"): | |
| key = parse_qs(u.query).get("key", [""])[0] | |
| if not ADMIN_KEY or key != ADMIN_KEY: | |
| return self.send_json({"error": "forbidden"}, 403) | |
| if u.path == "/api/admin/summary": | |
| return self.send_json(summary()) | |
| if u.path == "/api/admin/export": | |
| with LOCK: | |
| return self.send_json({"participants": load_participants(), | |
| "responses": [json.loads(l) for l in open(R_FILE)] if os.path.exists(R_FILE) else []}) | |
| return self.send_json({"error": "not found"}, 404) | |
| elif not u.path.startswith("/static/"): # never serve data/ or private/ | |
| return self.send_json({"error": "not found"}, 404) | |
| if u.path.startswith("/static/v/") and self.headers.get("Range"): | |
| return self.send_range(u.path) | |
| return super().do_GET() | |
| def send_range(self, path): | |
| """HTTP Range for the videos (Safari / iOS will not play an mp4 without 206 responses).""" | |
| fp = self.translate_path(path) | |
| if not os.path.isfile(fp): | |
| return self.send_json({"error": "not found"}, 404) | |
| size = os.path.getsize(fp) | |
| m = re.match(r"bytes=(\d*)-(\d*)$", self.headers["Range"].strip()) | |
| if not m or (m.group(1) == "" and m.group(2) == ""): | |
| self.send_response(HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE) | |
| self.send_header("Content-Range", "bytes */%d" % size) | |
| self.end_headers() | |
| return | |
| if m.group(1) == "": # suffix range: last N bytes | |
| start, end = max(0, size - int(m.group(2))), size - 1 | |
| else: | |
| start = int(m.group(1)) | |
| end = min(int(m.group(2)), size - 1) if m.group(2) else size - 1 | |
| if start >= size or start > end: | |
| self.send_response(HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE) | |
| self.send_header("Content-Range", "bytes */%d" % size) | |
| self.end_headers() | |
| return | |
| self.send_response(HTTPStatus.PARTIAL_CONTENT) | |
| self.send_header("Content-Type", "video/mp4") | |
| self.send_header("Accept-Ranges", "bytes") | |
| self.send_header("Content-Range", "bytes %d-%d/%d" % (start, end, size)) | |
| self.send_header("Content-Length", str(end - start + 1)) | |
| self.end_headers() | |
| with open(fp, "rb") as f: | |
| f.seek(start) | |
| self.wfile.write(f.read(end - start + 1)) | |
| def do_POST(self): | |
| u = urlparse(self.path) | |
| try: | |
| n = int(self.headers.get("Content-Length", 0)) | |
| body = json.loads(self.rfile.read(min(n, 65536)) or b"{}") | |
| except Exception: | |
| return self.send_json({"error": "bad request"}, 400) | |
| pid = str(body.get("pid", "")).strip() | |
| if not PID_RE.match(pid): | |
| return self.send_json({"error": "ID must be 1-5 letters or digits"}, 400) | |
| key = pid.upper() | |
| if u.path == "/api/check": # landing page: is this ID free / resumable / taken? | |
| with LOCK: | |
| p = load_participants().get(key) | |
| st = "new" if p is None else "taken" if is_taken(p) else "done" if p.get("done") else "resume" | |
| return self.send_json({"status": st}) | |
| if u.path == "/api/start": | |
| with LOCK: | |
| ps = load_participants() | |
| if key in ps and is_taken(ps[key]): | |
| return self.send_json({"error": "taken"}, 409) | |
| if key not in ps: | |
| ps[key] = {"pid": pid, "created": time.time(), "ua": self.headers.get("User-Agent", "")[:200], | |
| "questions": new_assignment(ps, random.Random()), "done": False} | |
| ps[key]["last_seen"] = time.time() | |
| save_participants(ps) | |
| p = ps[key] | |
| return self.send_json({"pid": p["pid"], "done": p["done"], "questions": public_questions(p)}) | |
| if u.path == "/api/answer": | |
| try: | |
| qi = int(body["qi"]) | |
| ans = body["answer"] | |
| ms = int(body.get("ms", 0)) | |
| except Exception: | |
| return self.send_json({"error": "bad request"}, 400) | |
| with LOCK: | |
| ps = load_participants() | |
| if key not in ps or not 0 <= qi < len(ps[key]["questions"]): | |
| return self.send_json({"error": "unknown participant or question"}, 400) | |
| q = ps[key]["questions"][qi] | |
| k = len(q["order"]) | |
| if q["part"] == "part1": | |
| ok = isinstance(ans, dict) and sorted(ans) == list(LABELS[:k]) and \ | |
| sorted(ans.values()) == list(range(1, k + 1)) | |
| else: | |
| ok = ans in ("A", "B", "tie") | |
| if not ok: | |
| return self.send_json({"error": "invalid answer"}, 400) | |
| if is_taken(ps[key]): | |
| return self.send_json({"error": "taken"}, 409) | |
| q["answer"], q["ms"], q["t"] = ans, ms, time.time() | |
| ps[key]["last_seen"] = q["t"] | |
| if q["part"] == "part1": | |
| resolved = {q["order"][LABELS.index(l)]: r for l, r in ans.items()} | |
| else: | |
| resolved = "tie" if ans == "tie" else q["order"][LABELS.index(ans)] | |
| ps[key]["done"] = all("answer" in x for x in ps[key]["questions"]) | |
| save_participants(ps) | |
| with open(R_FILE, "a") as f: | |
| f.write(json.dumps({"pid": key, "qi": qi, "part": q["part"], "scene": q["scene"], | |
| "order": q["order"], "answer": ans, "resolved": resolved, "ms": ms, | |
| "t": q["t"]}) + "\n") | |
| done = ps[key]["done"] | |
| return self.send_json({"ok": True, "done": done}) | |
| return self.send_json({"error": "not found"}, 404) | |
| def main(): | |
| ap = argparse.ArgumentParser() | |
| ap.add_argument("--host", default="0.0.0.0") | |
| ap.add_argument("--port", type=int, default=int(os.environ.get("PORT", 8000))) | |
| a = ap.parse_args() | |
| hf_restore() | |
| threading.Thread(target=hf_backup_loop, daemon=True).start() | |
| print("part1 %d scenes, part2 %d scenes; %d + %d questions per participant; http://%s:%d" % ( | |
| len(MAN["part1"]), len(MAN["part2"]), PART1_N, PART2_N, a.host, a.port), flush=True) | |
| ThreadingHTTPServer((a.host, a.port), Handler).serve_forever() | |
| if __name__ == "__main__": | |
| main() | |