- 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:
1. **Comandos de sistema** (volume, screenshot, show_desktop) → voz siempre gana
2. **Mismas acciones** en ambos → gesto gana (modalidad primaria)
3. **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:
1. **Crear el módulo** en `ui/` con clase principal heredando de `QtWidgets.QWidget`
2. **Configurar flags Qt**: `FramelessWindowHint | WindowStaysOnTopHint | Tool` + `WA_TranslucentBackground` + `WA_TransparentForMouseEvents`
3. **Exportar en `ui/__init__.py`** para acceso desde `main.py`
4. **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
5. **Verificar sintaxis** con `ast.parse()` antes de commit
6. **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:
1. **Crear el módulo** en `ui/` con clase principal que capture la pantalla (`QScreen.grabWindow`) y la escale.
2. **Método `update_cursor(cursor)`**: si cursor es None → hide; si no → show + update.
3. **Timer de refresh** interno (~80ms) para recapturar la pantalla sin depender del tick principal.
4. **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`.
5. **Importante**: usar `overlay.flash_action()` para feedback visual, NO `magnifier.flash_action()` (el magnifier no tiene ese método).
6. **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:**
```python
from freehands.plugins import FreeHandsPlugin, PluginContext
class MiPlugin(FreeHandsPlugin):
name = "mi_plugin"
version = "1.0.0"
description = "Descripción"
priority = 50 # menor = ejecuta antes
def on_gaze(self, cursor, ctx):
# Modificar cursor antes de llegar a fusion
return modified_cursor
def on_action(self, action, ctx):
# Interceptar acción antes de dispatch
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:
1. **Crear el módulo** en el subdirectorio correspondiente (`gaze/`, `gestures/`, `fusion/`, `voice/`, `ui/`, `actions/`)
2. **Exportar en `__init__.py`** del subdirectorio
3. **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
4. **Añadir tests** en `tests/test_{modulo}.py`
5. **Actualizar perfil** si hay nuevas configuraciones (`profiles/store.py`)
6. **Verificar sintaxis** antes de commit: `python3 -c "import py_compile; py_compile.compile('path/to/file.py', doraise=True)"`
7. **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**:
1. **Crear detector** en `voice/` con estado: `idle → gazing → ready → activate → reset`
2. **Hit-test** contra regiones de texto (coordenadas relativas al centro del overlay)
3. **Dwell configurable** + debounce para evitar falsos positivos por glitches de MediaPipe
4. **Integrar en main.py tick()**: actualizar detector con regiones, check voice commands si `ready_to_activate`
5. **Feedback visual** en overlay: anillo pulsante cuando `ready`, badge cuando activo
6. **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 |
View on GitHub