Anteprima gioco:

L’evoluzione delle interfacce naturali ha portato alla nascita di nuove modalità di interazione fra uomo e macchina come ad esempio le gesture recognition rispetto ai classici input da tasti e puntatori.
Oggi vediamo un esempio di hand tracking con Python, che dimostra come sia possibile costruire un giochetto, comandabile con le nostre mani in tempo reale, sfruttando esclusivamente una webcam e librerie open-source.

Questo gioco sfrutta 3 librerie principali:
- MediaPipe (by Google) per il hand landmark detection (una pipeline di machine learning ottimizzata per l’individuazione e il tracciamento di 21 punti chiave della mano).
- OpenCV per la gestione dello stream video e la preelaborazione dei frame (conversione colore, mirroring, downsampling).
- Pygame classica libreria usata per collisioni e rendering degli elementi di gioco.
Queste librerie ci permetteranno di realizzare un programma game/vision pipeline dove il movimento della mano viene tradotto in input in grado di comandare il nostro oggetto senza alcun dispositivo fisico.
Lo scopo principale non è creare un gioco complesso ma mostrare come il sistema riesce a mantenere una sincronizzazione precisa tra webcam, movimenti mano e risposta grafica su schermo.
Come già accennato il gioco è parecchio semplice: il movimento dell'indice sposta l'oggetto, quando il pollice e l’indice si toccano (pinch) il personaggio spara un proiettile verso l’alto quindi l’obiettivo è colpire e distruggere gli ostacoli rossi che cadono dall’alto evitando di essere colpiti.
Codice:
In generale il programma è composto da due componenti principali:
HandTrackerche gestisce webcam e MediaPipe cioè: frame, conversione colore, elaborazione dei landmark, decisione sul pinch e calcolo della posizione X sull'indice.
HandRunnerGameè il gioco vero e proprio quindi: loop principale, gestione oggetti (giocatore, ostacoli, proiettili), collisioni, rendering con Pygame e richiamo del tracker per l’input.
Variabili:
HandTracker:
self.hands istanza MediaPipe Hands (modello ML) restituisce multi_hand_landmarks.
self.cap cv2.VideoCapture(0) webcam.
self.last_update timestamp dell’ultima elaborazione MediaPipe (usato per throttling).
self.interval intervallo minimo tra due elaborazioni (default 0.1 s).
self.last_x ultima posizione X valida (in pixel dello schermo).
self.pinch_active booleano stato corrente del pinch.
self.pinch_frames contatore per il debouncing del pinch.
HandRunnerGame:
- Dimensioni: WIDTH, HEIGHT.
- Player:
player_x, player_y, player_size.
- Liste dinamiche: obstacles (lista di [x,y]), bullets (lista di [x,y]).
- Meccaniche:
score, spawn_timer, shoot_cooldown.
- clock
pygame.time.Clock() per regolare FPS.
Sincronizzazione:
Per quanto riguarda MediaPipe che è relativamente costoso in termini di risorse (soprattutto in questo caso dove viene utilizzato a numerosi fps) per limitare il carico la funzione HandTracker.update() non richiama hands.process() ad ogni iterazione del game loop ma solo se è passato almeno self.interval secondi dall’ultimo processo.
Quindi il default interval è stato settato a 0.1 s cioè circa 10 elaborazioni MediaPipe al secondo, il game loop invece è fissato a 60 FPS. Facendo questo si riduce carico CPU/GPU e si migliorano le performace.
La temporizzazione sul loop principale è fissata a 60 fps con clock.tick(60) anche se alcune variabili sono aggiornate per iterazione (per esempio score += 1, spawn_timer += 1).
Questo significa che molte soglie nel gioco sono espresse in frame (non in secondi), per capirsi meglio:
scoreaumenta di 1 per frame: a 60 FPS cioè scorecirca uguale a 60 al secondo.
spawn_interval iniziale è 40 (frame) cioè circa 0.67 s a 60 FPS. Perciò se diminuisce a 20 diventa circa 0.33 s.
shoot_cooldown = 8 quindi se ridotto per frame e loop a 60 FPS => cooldowncirca 8/60: più o mono 0.133 s.
Coordinate mano:
I landmark di MediaPipe sono normalizzati (x,y in 0,1 rispetto al frame acquisito).
Nel nostro caso sull'update:
- il frame viene
cv2.flip(frame, 1) per specchiarlo (comportamento più intuitivo).
index_tip.x viene moltiplicato per width (schermo Pygame) per avere last_x in pixel.
N.B. il mapping assume SOLO la dimensione compatibile (X), per motivi di ottimizzazione la webcam è ridotta a 320×240 ma viene scalata proporzionalmente al WIDTH e i disallineamenti verticali non sono gestiti, l'oggetto si muoverà solo nella componente X.
Il pinch detection funziona tramite il calcolo distanza euclidea sui landmark:
d = sqrt((ix - tx)^2 + (iy - ty)^2)
In pratica:
- Soglia fissa 0.045 per l'area delle dita dove si toccano.
- Debounce:
pinch_frames deve superare 2 (quindi bisogna avere 3 elaborazioni consecutive con d < soglia) per attivare pinch_active. In questo modo ponderiamo i falsi positivi dovuti al jitter andando però a rallentare l'attivazione.
Ostacoli:
La logica dello spawn degli ostacoli è semplice:
obstacle_speed = 6 + score // 400
score // 400 aumenta di 1 ogni 400 frame.
- A 60 fps cioè ogni 6.67s circa, il valore incrementa (+1 pixel/frame) rendendo gli ostacoli più veloci a scorrere.
spawn_interval = max(20, 40 - score // 400)
- Riduce l’intervallo di spawn con il tempo aumentando il ritmo.
spawn_timer è incrementato a ogni frame e quando supera spawn_interval viene creato un ostacolo e spawn_timer viene azzerato.
Vediamo lo spawn dei proiettili:
- I proiettili sono creati in array [x,y] con velocità fissa
y -= 15 per frame quindi molto veloci: 15 px/frame * 60 = 900 px/s (molto veloce rispetto alle dimensioni della finestra).
- Limitazione sul numero di proiettili
len(bullets) < 10 per evitare eccessivo uso di memoria e troppi rettangoli da calcolare.
Rilevamento collisioni:
Metodo principale:
- Usa rettangoli axis-aligned (pygame.Rect) per proiettile, ostacolo e player.
- Ostacoli: dimensione 40×40.
- Proiettile: rettangolo (b[0]-3, b[1]-10, 6, 20).
- Player: rettangolo costruito attorno a player_x, player_y con player_size.
Complessità:
- Collisioni tra proiettili e ostacoli: controllo naive O(N_obstacles * N_bullets) per frame. Con limiti (max 25 ostacoli, max 10 proiettili) in generale la complessità è O(n*m).
- Collisione player a ostacolo (e viceversa): O(n_obstacles).
Accuratezza e limiti:
Bounding boxes rettangolari sono semplici ma non sempre precisi (angoli occupati male), per una maggiore accuratezza l'ideale sarebbe la collisione circolare (distanza tra centri) o maschere pixel-perfect (pygame.mask) ma sono molto più costosi in termini di risorse quindi il bounding boxes mi sembrava il compromesso perfetto per rendere il tutto giocabile.
Gestione errori:
Se abbiamo perdite di frame nella webcam update() restituisce last_x, pinch_active anche se ret è False (non aggiorna) in modo da evitare crash ma può lasciare il player “fermo”.
Se viene registrato un input fuori range index_tip.x normalizzato dovrebbe restare in [0,1]. Ma per sicurezza è bene clampare (limitare un valore a un intervallo specifico) la last_x dentro [0, WIDTH] per evitare coordinate negative o fuori finestra.
CODICE COMPLETO:
import cv2
import mediapipe as mp
import pygame
import random
import math
import time
import sys
class HandTracker:
def __init__(self, width, update_interval=0.1):
self.hands = mp.solutions.hands.Hands(max_num_hands=1, min_detection_confidence=0.7)
self.cap = cv2.VideoCapture(0)
self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, 320)
self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 240)
self.last_update = 0
self.interval = update_interval
self.last_x = width // 2
self.pinch_active = False
self.pinch_frames = 0
def distance(self, a, b):
return math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2)
def update(self, width):
ret, frame = self.cap.read()
if not ret:
return self.last_x, False
frame = cv2.flip(frame, 1)
now = time.time()
if now - self.last_update < self.interval:
return self.last_x, self.pinch_active
rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
results = self.hands.process(rgb)
self.last_update = now
if results.multi_hand_landmarks:
hand = results.multi_hand_landmarks[0]
index_tip = hand.landmark[8]
thumb_tip = hand.landmark[4]
self.last_x = int(index_tip.x * width)
d = self.distance(index_tip, thumb_tip)
if d < 0.045:
self.pinch_frames += 1
if self.pinch_frames > 2:
self.pinch_active = True
else:
self.pinch_frames = 0
self.pinch_active = False
else:
self.pinch_active = False
return self.last_x, self.pinch_active
def release(self):
self.cap.release()
class HandRunnerGame:
def __init__(self):
pygame.init()
self.WIDTH, self.HEIGHT = 800, 600
self.screen = pygame.display.set_mode((self.WIDTH, self.HEIGHT))
pygame.display.set_caption("Hand Runner Shooter FAST")
self.clock = pygame.time.Clock()
self.colors = {
"white": (255, 255, 255),
"black": (0, 0, 0),
"red": (255, 60, 60),
"blue": (0, 180, 255),
"yellow": (255, 255, 0),
}
self.player_size = 50
self.player_y = self.HEIGHT - 100
self.player_x = self.WIDTH // 2
self.obstacles = []
self.bullets = []
self.score = 0
self.spawn_timer = 0
self.shoot_cooldown = 0
self.running = True
self.tracker = HandTracker(self.WIDTH)
def draw_text(self, text, size, color, x, y):
font = pygame.font.SysFont(None, size)
img = font.render(text, True, color)
self.screen.blit(img, (x, y))
def spawn_obstacle(self):
self.obstacles.append([random.randint(0, self.WIDTH - 40), 0])
def update_obstacles(self, speed):
for obs in self.obstacles:
obs[1] += speed
self.obstacles = [o for o in self.obstacles if o[1] < self.HEIGHT + 50]
def update_bullets(self):
for b in self.bullets:
b[1] -= 15
self.bullets = [b for b in self.bullets if b[1] > -20]
def handle_collisions(self):
new_obstacles = []
for obs in self.obstacles:
obs_rect = pygame.Rect(obs[0], obs[1], 40, 40)
hit = False
for b in self.bullets:
bullet_rect = pygame.Rect(b[0] - 3, b[1] - 10, 6, 20)
if obs_rect.colliderect(bullet_rect):
hit = True
self.score += 50
break
if not hit:
new_obstacles.append(obs)
self.obstacles = new_obstacles
player_rect = pygame.Rect(
self.player_x - self.player_size // 2,
self.player_y - self.player_size // 2,
self.player_size,
self.player_size,
)
for obs in self.obstacles:
if pygame.Rect(obs[0], obs[1], 40, 40).colliderect(player_rect):
self.game_over()
def game_over(self):
self.screen.fill(self.colors["black"])
self.draw_text("GAME OVER", 80, self.colors["red"], self.WIDTH // 2 - 200, self.HEIGHT // 2 - 50)
self.draw_text(f"Punteggio: {self.score}", 60, self.colors["white"], self.WIDTH // 2 - 100, self.HEIGHT // 2 + 40)
pygame.display.flip()
pygame.time.wait(3000)
self.running = False
def run(self):
while self.running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
self.running = False
self.player_x, pinch_active = self.tracker.update(self.WIDTH)
self.score += 1
obstacle_speed = 6 + self.score // 400
spawn_interval = max(20, 40 - self.score // 400)
self.spawn_timer += 1
if self.spawn_timer > spawn_interval and len(self.obstacles) < 25:
self.spawn_timer = 0
self.spawn_obstacle()
self.update_obstacles(obstacle_speed)
self.update_bullets()
if self.shoot_cooldown > 0:
self.shoot_cooldown -= 1
if pinch_active and self.shoot_cooldown == 0 and len(self.bullets) < 10:
self.bullets.append([self.player_x, self.player_y - 30])
self.shoot_cooldown = 8
self.handle_collisions()
self.draw()
self.clock.tick(60)
self.tracker.release()
pygame.quit()
sys.exit()
def draw(self):
self.screen.fill(self.colors["black"])
player_rect = pygame.Rect(
self.player_x - self.player_size // 2,
self.player_y - self.player_size // 2,
self.player_size,
self.player_size,
)
pygame.draw.rect(self.screen, self.colors["blue"], player_rect)
for obs in self.obstacles:
pygame.draw.rect(self.screen, self.colors["red"], (obs[0], obs[1], 40, 40))
for b in self.bullets:
pygame.draw.rect(self.screen, self.colors["yellow"], (b[0] - 3, b[1] - 10, 6, 20))
self.draw_text(f"Punteggio: {self.score}", 30, self.colors["white"], 10, 10)
self.draw_text(f"FPS: {int(self.clock.get_fps())}", 28, self.colors["white"], self.WIDTH - 100, 10)
pygame.display.flip()
if __name__ == "__main__":
HandRunnerGame().run()
Video dimostrazione: