| name | freehands-architecture |
| description | Arquitectura completa del proyecto FreeHands — control del PC sin manos: mirada + gestos + voz. Módulos, flujos de datos y patrón para añadir nuevas funcionalidades. |
| version | 4.0.0 |
| author | Ntizar |
| tags | ["mastermind","freehands","gaze","gestures","voice"] |
FreeHands — Arquitectura del Proyecto
Proyecto de control del PC sin manos: gaze tracking + gestos de mano + comandos de voz. PyQt6 + MediaPipe FaceMesh + pyautogui.
📁 Estructura del proyecto
src/freehands/
├── __main__.py # Entry point: freehands CLI
├── main.py # run_system() — orquestador principal
├── config.py # Constantes globales (FPS, dwell, thresholds)
├── gaze/
│ ├── __init__.py # Exporta GazeTracker, GazeRegressor, DeadZoneClamper
│ ├── tracker.py # GazeTracker — MediaPipe FaceMesh → GazeFeatures (6-d)
│ ├── calibration.py # GazeRegressor — ridge regression → coordenadas pantalla
│ ├── dead_zones.py # DeadZoneClamper — recorte de bordes de pantalla
│ └── head_pose.py # HeadPose — estimación 6DoF desde FaceMesh landmarks
├── gestures/
│ ├── hand_tracker.py # MediaPipe Hands → gesture classification
│ ├── face_tracker.py # Expresiones faciales (AUs)
│ └── stabilizer.py # Debounce + stability frames para gestos
├── fusion/
│ ├── fusion.py # MultimodalFusion — estado, dwell, bindings
│ ├── state_machine.py # StateMachine — IDLE → ACTIVE → CONFIRMING → COOLDOWN
│ └── channel_priority.py # Priorización dinámica: gesto vs voz cuando compiten
├── actions/
│ └── dispatcher.py # ActionDispatcher — pyautogui (click, scroll, zoom...)
├── voice/
│ ├── whisper_listener.py # Faster-Whisper ASR → acciones de voz
│ └── continuous_dictation.py # Transcripción continua con feedback visual
├── plugins/
│ ├── base.py # FreeHandsPlugin base class — 7 pipeline hooks
│ ├── loader.py # PluginLoader — discovery, priority, lifecycle
│ └── example_cursor_trail.py # Ejemplo: trail de cursor
├── profiles/
│ ├── store.py # Profile (Pydantic) — JSON persistence, migration
│ └── __init__.py # Gestos, bindings, thresholds por defecto
├── capture/
│ ├── camera.py # OpenCV webcam capture
│ └── __init__.py
└── ui/
├── overlay.py # GazeOverlay — widget PyQt6 transparente
├── theme.py # Estilos CSS (azul #1E5BFF + naranja #FF7A1A)
├── calibration_game.py # Juego de calibración 9 puntos
└── camera_selector.py # Selector de cámara
tests/
├── test_dead_zones.py # Tests dead zones
├── test_fusion.py # Tests multimodal fusion
├── test_state_machine.py # Tests state machine
├── test_profiles.py # Tests profiles
├── test_hand_tracker.py # Tests hand tracker
├── test_stabilizer.py # Tests gesture stabilizer
├── test_voice_commands.py # Tests voice commands
└── test_head_pose.py # Tests head pose estimation
🔄 Flujo de datos principal
Camera ──frame──▶ GazeTracker ──GazeFeatures──▶ GazeRegressor ──cursor (x,y)
│
DeadZoneClamper ──cursor_clamped
│
FineAimPointer ──cursor_smooth
│
HandTracker ──gesture──▶ GestureStabilizer ──confirmed_gesture
│
┌─────────────────────────┘
▼
MultimodalFusion
(state machine + dwell + bindings)
│
▼
FusionResult {cursor_xy, state, dwell, fired_action}
│
▼
PluginPipeline (hooks: on_gaze, on_action, ...)
│
▼
ActionDispatcher ──pyautogui events
🔑 Conceptos clave
GazeFeatures (6-dimensional)
Vector compacto extraído por frame:
l_rel[0:2] — iris izquierdo normalizado por ancho/alto ojo izq
r_rel[0:2] — iris derecho normalizado por ancho/alto ojo der
head[0:2] — proxy de pose de cabeza (nariz vs centro ojos)
State Machine
Estados: IDLE → ACTIVE → CONFIRMING → COOLDOWN → ACTIVE
- IDLE: sistema pausado (palma abierta → resume)
- ACTIVE: esperando mirada estable
- CONFIRMING: mirada estable durante dwell_time_ms
- COOLDOWN: tras acción, cooldown de COOLDOWN_MS_AFTER_ACTION
Palm-scroll
Gestos de scroll por palma (palm_scroll_up/down) son instantáneos — bypass del state machine, disparan directamente scroll_up/scroll_down.
Dead Zones
Recorte de coordenadas del cursor para que no alcance bordes extremos de pantalla. Por defecto 5% de margen en cada lado, mínimo 40px.
Channel Priority (priorización dinámica)
Cuando gesto y voz proponen acciones diferentes en el mismo frame, decide_channel_priority() resuelve el conflicto:
- Comandos de sistema (volume, screenshot, show_desktop) → voz siempre gana
- Mismas acciones en ambos → gesto gana (modalidad primaria)
- Acciones diferentes no-sistema → gesto gana (tiene contexto espacial)
voice_should_bypass_fusion() identifica acciones que no pasan por el state machine.
- Ver
references/channel-priority-pattern.md para detalles completos.
Head Pose (6DoF coarse displacement)
El módulo gaze/head_pose.py estima rotación de cabeza (yaw, pitch, roll) desde landmarks de FaceMesh:
- Yaw: vector nariz-ojo en plano XZ con
arctan2
- Pitch: eje frente-mentón relativo a línea horizontal ojo
- Roll: inclinación lateral
- Se aplica como desplazamiento "coarse" en la capa de fusión, complementando el pointer "fine" de gaze
- Dead zone por defecto: ±5° para evitar jitter en pose neutral
- Ver
references/head-pose-estimation-pattern.md para patrón completo.
Control de volumen por posición Y de mano
Módulo gestures/volume_control.py — detector de volumen basado en la posición vertical del centroid de la mano en el frame. Es un gesto de posición de zona, distinto de palm-scroll (motion-based) y expresiones faciales (state-based):
- Zona superior (Y < 0.35) →
volume_up
- Zona inferior (Y > 0.65) →
volume_down
- Zona neutra (0.35 ≤ Y ≤ 0.65) → sin acción
- Usa centroid (promedio 21 landmarks) en lugar de muñeca sola → más estable
- Cooldown de 15 frames (~0.5s) para evitar disparos continuos
- Se ejecuta directamente vía
dispatcher.execute(), sin pasar por gesture bindings
- Ver
references/volume-control-pattern.md para patrón completo.
Fusión bimanual (HandFusion)
Módulo gestures/hand_fusion.py — asigna roles complementarios cuando ambas manos están visibles:
- Mano derecha → offset de cursor fino (±10px basado en posición relativa al centro)
- Mano izquierda → scroll vertical (barrido) + zoom (pinch open/close)
- Con una sola mano visible → no-op, comportamiento normal
- Se integra en
tick() después de volume_control, antes de fusion.step_and_voice()
- Ver
references/bimanual-fusion-pattern.md para patrón completo de integración.
Integración de widgets UI overlay (patrón radial-menu)
Para añadir un nuevo widget PyQt6 overlay al pipeline principal:
- Crear el módulo en
ui/ con clase principal heredando de QtWidgets.QWidget
- Configurar flags Qt:
FramelessWindowHint | WindowStaysOnTopHint | Tool + WA_TranslucentBackground + WA_TransparentForMouseEvents
- Exportar en
ui/__init__.py para acceso desde main.py
- Integrar en
main.py:
- Importar la clase + constantes necesarias (ej:
MENU_OPEN_DURATION_MS)
- Instanciar en
run_system() con variables de estado (hold frames, gesture tracking)
- Conectar señales con
pyqtSignal → función de callback en scope de run_system
- En el loop
tick():
nonlocal variables de estado del menú
- Detectar trigger (ej: palm-hold sostenido)
- Llamar
widget.update_dwell(cursor_xy) si visible
- Llamar
widget.close() en transición a IDLE
- Verificar sintaxis con
ast.parse() antes de commit
- Commit + push a ambos repos (FreeHands + Mastermind plan)
Ver references/radial-menu-integration.md para ejemplo completo.
Patrón para widgets que siguen al cursor (magnifier overlay)
Algunos widgets no se abren/cierran con gestos sino que siguen al cursor de mirada de forma continua. Patrón diferente al de radial-menu:
- Crear el módulo en
ui/ con clase principal que capture la pantalla (QScreen.grabWindow) y la escale.
- Método
update_cursor(cursor): si cursor es None → hide; si no → show + update.
- Timer de refresh interno (~80ms) para recapturar la pantalla sin depender del tick principal.
- Integrar en
main.py:
- Instanciar con
zoom_factor y radius configurables.
- Variable de estado
magnifier_visible (bool) declarada en nonlocal en tick().
- En
handle_voice_action(): interceptar comandos como "zoom_in"/"zoom_out" ANTES de llegar al dispatcher, para repurposarlos como toggle del widget en lugar de Ctrl++/Ctrl--.
- En el loop
tick(): si magnifier_visible, llamar magnifier.update_cursor(result.cursor_xy); si no y widget visible, llamar update_cursor(None).
- En
State.IDLE: cerrar el widget y resetear magnifier_visible = False.
- Importante: usar
overlay.flash_action() para feedback visual, NO magnifier.flash_action() (el magnifier no tiene ese método).
- Verificar balance de braces/parens/brackets con Python tras los cambios en main.py.
Ver references/magnifier-overlay-pattern.md para patrón completo.
Teclado virtual dual layout por ojos (Keyboard-Typing-with-Eyes)
Teclado dividido en mitades izquierda/derecha; se muestra solo la mitad donde está el cursor de mirada. Reduce el espacio de búsqueda a la mitad.
- Clase
LayoutSide (enum: BOTH, LEFT, RIGHT)
- Cada tecla marcada con
is_left_key: bool
get_visible_keys() filtra dinámicamente
update_layout_side(cursor_x) detecta lado del cursor
process_blink(blink) soporta blink-to-select
- Signal
layout_changed para audio feedback
- Ver
references/dual-layout-keyboard-pattern.md para patrón completo.
Detección de intención multimodal (gaze dwell + voz)
Patrón para activar funcionalidades de dictado por voz mediante detección multimodal: el usuario debe mirar una región de texto (dwell) Y decir un comando de voz. Esto evita activaciones accidentales y da control preciso sobre dónde se inserta el texto dictado.
- Clase
DictationIntentDetector en voice/dictation_intent.py
- Estado:
idle → gazing → ready → activate → reset
- Dwell configurable (default 500ms), debounce configurable (default 300ms)
- Feedback visual: anillo pulsante naranja + badge "🎙 DICTANDO"
- Integrado en
main.py tick loop, independiente de gaze_text_sel
- Ver
references/multimodal-dictation-intent-pattern.md para patrón completo.
Perfiles (Pydantic)
gesture_bindings: dict → mapea gesture_name → action_name
gesture_thresholds: dict → stability_frames + confidence_min por gesture
- Migraciones automáticas en
load_profile() para compatibilidad con versiones anteriores
Sistema de Plugins (mejora #16)
Arquitectura extensible para inyectar lógica custom en cada etapa del pipeline:
Camera → [on_frame] → Gaze → [on_gaze] → Filter → [on_filter]
→ Gesture → [on_gesture] → Fusion → [on_fusion] → Action → [on_action]
→ Overlay → [on_overlay]
API principal:
FreeHandsPlugin — clase base con 7 hooks (todos opcionales, passthrough por defecto)
PluginContext — dataclass con frame, cursor, gesture, action, blink, metadata
PluginLoader — descubrimiento automático desde directorio, registro manual, orden por priority
plugins_dir en Profile para configurar ruta de plugins
Patrón para crear un plugin:
from freehands.plugins import FreeHandsPlugin, PluginContext
class MiPlugin(FreeHandsPlugin):
name = "mi_plugin"
version = "1.0.0"
description = "Descripción"
priority = 50
def on_gaze(self, cursor, ctx):
return modified_cursor
def on_action(self, action, ctx):
return suppressed_or_modified_action
Reglas:
- Hooks no implementados se saltan silenciosamente (no override)
- Excepciones en hooks se capturan y loguean, no rompen el pipeline
ctx.metadata es un dict compartido entre todos los plugins del frame
loader.run_all(ctx) modifica el contexto en-place, se aplica al main loop
Ver references/plugin-system-pattern.md para API completa y ejemplos.
Calibración con Gaussian Process (auto-calibración continua)
Módulo adicional gaze/calibration.py que añade un modelo GP alongside el Ridge regression:
GPGazeModel — modelo serializable (kernel RBF/Matern, lengthscale, noise_level, training data sliding window)
GPGazeRegressor — predictor con Kalman smoothing, API idéntica a GazeRegressor
fit_gp_model() — entrena GP desde samples de calibración
update_gp_model() — añade muestras y reentrena con ventana deslizante (max 200 samples)
- Auto-calibración en
main.py: durante uso normal en estado ACTIVE, cuando cursor es estable (<15px movimiento) y confianza >0.7, se recopilan muestras implícitas
- Reentrenamiento cada 30 frames, guardado al perfil cada 5 min
- Ver
references/gp-auto-calibration-pattern.md para patrón completo.
🛠️ Patrón para añadir un nuevo módulo
Cuando se añade una funcionalidad nueva a FreeHands:
- Crear el módulo en el subdirectorio correspondiente (
gaze/, gestures/, fusion/, voice/, ui/, actions/)
- Exportar en
__init__.py del subdirectorio
- Integrar en
main.py:
- Importar la nueva clase/módulo
- Instanciar en
run_system() con los parámetros adecuados
- Aplicar en el loop
tick() en el punto correcto del pipeline
- Añadir tests en
tests/test_{modulo}.py
- Actualizar perfil si hay nuevas configuraciones (
profiles/store.py)
- Verificar sintaxis antes de commit:
python3 -c "import py_compile; py_compile.compile('path/to/file.py', doraise=True)"
- Commit + push:
git add -A && git commit -m "9009: mejora #N: descripción" && git push
Patrón para detector de intención multimodal (gaze + voz)
Cuando la mejora requiere activar una funcionalidad por mirada + voz simultáneamente:
- Crear detector en
voice/ con estado: idle → gazing → ready → activate → reset
- Hit-test contra regiones de texto (coordenadas relativas al centro del overlay)
- Dwell configurable + debounce para evitar falsos positivos por glitches de MediaPipe
- Integrar en main.py tick(): actualizar detector con regiones, check voice commands si
ready_to_activate
- Feedback visual en overlay: anillo pulsante cuando
ready, badge cuando activo
- Consume el estado con
consume_ready() para evitar activaciones repetidas
📐 Convenciones de código
from __future__ import annotations en todos los archivos
- Dataclasses con
frozen=True para configuraciones inmutables
- Type hints en todos los parámetros y retornos
- Docstrings en inglés, comentarios en castellano
- Líneas máx: 100 caracteres (ruff config)
- Tests: nombre
test_{funcionalidad}.py, funciones test_{descripción}()
- No usar
pytest como dependencia — los tests pueden correr sin él (import directo)
📚 Referencias
| Archivo | Descripción |
|---|
references/channel-priority-pattern.md | Priorización dinámica de canales: reglas de prioridad gesto vs voz |
references/audio-feedback-pattern.md | Patrón para añadir feedback auditivo (beeps) en gestos y voz |
references/dead-zones-pattern.md | Patrón para dead zones en bordes de pantalla |
references/blink-detection-ear.md | Detección de parpadeo via Eye Aspect Ratio (EAR) — mejora #5 |
references/external-gesture-profiles.md | Perfiles de gestos externos JSON: carga, merge, pitfall de _repair_essential_bindings |
references/asr-backend-pattern.md | Patrón para añadir nuevos backends ASR (Vosk, whisper.cpp, etc.) a VoiceListener |
references/radial-menu-integration.md | Patrón para integrar widgets overlay PyQt6 (radial menu, OSD) en el pipeline principal |
references/dual-layout-keyboard-pattern.md | Teclado virtual dual-layout: split izq/der basado en posición del cursor, blink-to-select |
references/overlay-enhancement-pattern.md | Patrón para mejorar GazeOverlay: halo radial, pens cosméticos, soporte multi-monitor |
references/plugin-system-pattern.md | API completa del sistema de plugins: FreeHandsPlugin, PluginContext, PluginLoader, hooks, ejemplos |
references/head-pose-estimation-pattern.md | Estimación 6DoF head pose desde FaceMesh: algoritmo yaw/pitch/roll, dead zones, coarse displacement |
references/volume-control-pattern.md | Control de volumen por posición Y de mano: gesto de posición de zona, cooldown, centroid vs muñeca |
references/multimodal-dictation-intent-pattern.md | Detección de intención multimodal: gaze dwell en región de texto + comando de voz para activar dictado sin falsos positivos |
references/air-scroll-pattern.md | Air-scroll / swipe: detección de barrido vertical con cualquier pose de mano, umbral de desplazamiento |