anim-user-study / server.py
Kakituken's picture
part1 ours man_with_headphone = se3root; stop assigning part2 man_with_headphone
916c7e5 verified
Raw History Blame Contribute Delete
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()