App built to run at the KNI / URz open day. Hardware: Raspberry Pi 5 + Hailo-10H + camera + HDMI monitor. Uses YOLO pose detection.
A game for teams of 3. Teams compete at reproducing displayed body poses. Each team plays on its own, sequentially — one team in front of the camera at a time. After playing, the team lands on a leaderboard visible to everyone.
Every team plays the same poses — fair comparison, the leaderboard makes sense.
- Team name entry — short, for the leaderboard
- Calibration — each player stands in their zone (the screen is split into
3 vertical lanes: left/centre/right). The system waits until the lanes are
stably filled. The required count is configurable in
pose_battle.py:MIN_PLAYERS = 3(event mode — all three zones must be stably occupied) orMIN_PLAYERS = 2(test mode — starts with 2, a late third player may join within aT_CALIB_GRACEwindow). It currently ships set to 2. - Rounds — one round per pose, each round follows the same pattern
- Summary — final score, leaderboard position, write to storage
After the summary it returns to the leaderboard and waits for the next team.
- Intro (2s) — "Round X/N", pose name, difficulty
- Memorize (2.5s) — the target pose shown large in the centre of the screen as a skeleton, with a countdown
- Pose window (3s) — the preview disappears (a small one stays in the corner), players try to reproduce it, the system collects live scores
- Reveal (2.5s) — results: per-player score, team score, bonuses, multiplier
~10 s per round total.
The game loads every pose file from poses/, sorted by name, and plays all
of them — one pose per round. It started as a pool of 5 and was later extended
to 10, so a session is now 10 rounds. The current pool, with difficulty,
tolerance and multiplier for each pose, is documented in
../poses/README.md.
Design intent for the difficulty tiers:
| Difficulty | Tolerance | Multiplier | Character |
|---|---|---|---|
| Easy | wide | ×1.0 | symmetric / simple (e.g. T-pose) |
| Medium | medium | ×1.3 | full body (e.g. warrior from yoga) |
| Hard | tight | ×1.6 | asymmetric / complex (e.g. sprinter, star) |
Each pose is defined in JSON as a set of target joint angles with per-joint weights (arms/elbows weigh more than fingers/neck). Poses load from files — easy to edit without touching the code.
A separate tool (script) is needed to record poses: stand in front of the camera, press space, it averages 30 frames and saves the target angles to a JSON under the given name.
Lane mode — the screen is split into 3 vertical zones. Each player stands in their own. The system assigns detections to players by the X position of the mid-hip:
- left lane → Player 1
- centre lane → Player 2
- right lane → Player 3
If a player leaves their lane / 2 people are in one lane / a lane is empty → score 0 for the affected players in those frames + a visual warning on screen.
For each joint defined in the target pose, the current angle is computed from 3 keypoints (e.g. elbow = shoulder-elbow-wrist angle) and compared to the target.
joint_score = max(0, 1 - |current - target| / tolerance)
player_score = weighted_average(joint_scores) # 0.0 - 1.0
Joints with keypoint confidence < 0.3 are skipped (player partially occluded). If fewer than 4 joints from the target list are visible → player_score = 0 (anti-cheese).
From the 3-second pose window the system takes the maximum score reached in any frame + the timestamp of the moment the player first crossed 0.8 (used to compute sync).
This forces players to hold the pose for at least a moment, but does not penalise small jitter.
team_avg = mean(p1, p2, p3)
cohesion = how close the three players' scores are to each other
base_score = team_avg × cohesion_bonus × 100
round_score = base_score × difficulty_multiplier
Cohesion bonus (up to +30%): rewards a team where everyone is equally good. Three players at 70% > one at 100% + two at 55%. Forces team play.
- Perfect bonus (+30 pts) — if team_avg ≥ 95%
- Sync bonus (+15 pts) — if all 3 players crossed the 80% threshold within a ≤300 ms window (synchronised execution, not one after another)
- Streak bonus (+10 pts × consecutive round) — for consecutive rounds with team_avg ≥ 80%, reset when it drops below
Sum of round_score + bonuses across all rounds. Goes to the leaderboard.
Skeleton overlay on each player's live feed. Per-joint colours updated live:
- green — perfect match (diff < 40% of tolerance)
- yellow — close (diff < tolerance)
- red — off (diff > tolerance)
- grey — joint invisible (low confidence)
A live score % and a progress bar under each player. Players immediately see what to fix.
Persisted across sessions and app restarts. Shows the top 10 teams on the start screen. After a session, an animation shows the new team's position in the ranking.
Everything is saved locally so that results can be analysed in Pandas/Excel after the event. For each team session it saves:
- team name, timestamp, total score
- per round: pose, score per player, team avg, bonuses, round total
- per round per player: every joint, target angle, achieved angle, joint score (for deep analysis like "which team had the worst elbows")
One-button CSV export.
- A key to start a new session (from the idle screen)
- A key to abort a session (back to idle without saving)
- A key to export CSV
- A key to toggle fullscreen
The app must not crash on:
- 0 people in frame
- 4+ people in frame
- a player briefly occluded
- camera disconnect
- team names with Polish characters / emoji / spaces
- A steady 30 FPS during the pose window (pose detection must not block UI rendering)
- The app runs for the whole event (several hours, dozens of teams) without a restart, memory leaks or degradation
- Set up once, then self-service — a moderator presses "start", the rest runs on its own
- Public-facing screen (the audience sees the game live + the leaderboard)
- After the event I run the analysis from the CSV: mean scores per pose, hardest joints, who had the best sync, etc.