Ottimizzazione face tracking: calibrazione istantanea, eliminazione loop di stream e reattività migliorata

This commit is contained in:
David Frassi
2026-08-24 01:12:50 +02:00
parent 171780bf87
commit c36b0482c5
7 changed files with 411 additions and 153 deletions
+20 -8
View File
@@ -171,6 +171,9 @@
</div> </div>
</div> </div>
<!-- Hidden Camera Video for Head Navigation (needed for face detection API to run) -->
<video #faceVideo id="face-preview-video" muted playsinline autoplay style="position: fixed; width: 1px; height: 1px; opacity: 0; pointer-events: none; z-index: -1;"></video>
</ion-content> </ion-content>
<ion-footer class="ion-no-border"> <ion-footer class="ion-no-border">
@@ -233,12 +236,27 @@
</ion-button> </ion-button>
</div> </div>
<!-- Camera Head Gestures Toggle in Portrait --> <!-- Camera Gestures Toggle in Portrait (Tilt or Distance) -->
<div class="group" *ngIf="settingsService.enableVisualAutoscroll()"> <div class="group" *ngIf="settingsService.enableVisualAutoscroll()">
<ion-button fill="clear" size="small" (click)="toggleCameraNavigation()" [color]="enableCameraNavigation() ? 'success' : 'secondary'" style="margin: 0;"> <ion-button fill="clear" size="small" (click)="toggleCameraNavigation()" [color]="enableCameraNavigation() ? 'success' : 'secondary'" style="margin: 0;">
<ion-icon slot="icon-only" [name]="enableCameraNavigation() ? 'videocam' : 'videocam-off-outline'"></ion-icon> <ion-icon slot="icon-only" [name]="enableCameraNavigation() ? 'videocam' : 'videocam-off-outline'"></ion-icon>
</ion-button> </ion-button>
<div class="camera-indicator" *ngIf="enableCameraNavigation()" [class.tilted]="faceDetector.isTilted()">
<!-- Indicator in Distance Mode -->
<div class="camera-indicator distance-mode"
*ngIf="enableCameraNavigation() && settingsService.cameraTrackingMode() === 'distance'"
[class.approaching]="faceDetector.distanceState() === 'approaching'"
[class.receding]="faceDetector.distanceState() === 'receding'"
(click)="recalibrateDistance($event)"
title="Tocca per ricalibrare e impostare la tua distanza attuale come 100%">
<ion-icon [name]="faceDetector.distanceState() === 'approaching' ? 'arrow-down-circle' : (faceDetector.distanceState() === 'receding' ? 'arrow-up-circle' : 'scan-outline')"></ion-icon>
<span class="val">{{ (faceDetector.currentDistanceRatio() * 100).toFixed(0) }}%</span>
</div>
<!-- Indicator in Tilt Mode -->
<div class="camera-indicator"
*ngIf="enableCameraNavigation() && settingsService.cameraTrackingMode() === 'tilt'"
[class.tilted]="faceDetector.isTilted()">
<ion-icon name="person-outline" <ion-icon name="person-outline"
[style.transform]="'rotate(' + (-faceDetector.currentTiltAngle()) + 'deg)'"></ion-icon> [style.transform]="'rotate(' + (-faceDetector.currentTiltAngle()) + 'deg)'"></ion-icon>
<span class="val">{{ faceDetector.currentTiltAngle() }}°</span> <span class="val">{{ faceDetector.currentTiltAngle() }}°</span>
@@ -349,9 +367,3 @@
</ion-button> </ion-button>
</div> </div>
<p class="car-mode-subtext outfit-font" (click)="deactivateBlackScreen()">Tocca lo sfondo o l'icona per sbloccare</p>
</div>
</div>
<!-- Hidden Camera Video for Head Navigation (needed for face detection API to run) -->
<video *ngIf="enableCameraNavigation()" id="face-preview-video" muted playsinline autoplay style="position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none;"></video>
+15 -2
View File
@@ -409,7 +409,7 @@
font-weight: 700; font-weight: 700;
color: var(--ion-color-secondary); color: var(--ion-color-secondary);
padding: 0 4px; padding: 0 4px;
transition: color 0.15s ease-out; transition: all 0.15s ease-out;
ion-icon { ion-icon {
font-size: 1.1rem; font-size: 1.1rem;
@@ -417,9 +417,21 @@
display: inline-block; display: inline-block;
} }
&.tilted { &.tilted, &.approaching {
color: var(--ion-color-success, #2ed573); color: var(--ion-color-success, #2ed573);
text-shadow: 0 0 5px rgba(46, 213, 115, 0.6); text-shadow: 0 0 5px rgba(46, 213, 115, 0.6);
transform: scale(1.05);
}
&.receding {
color: var(--ion-color-warning, #ffa502);
text-shadow: 0 0 5px rgba(255, 165, 2, 0.6);
transform: scale(1.05);
}
&.distance-mode {
cursor: pointer;
user-select: none;
} }
} }
} }
@@ -1131,3 +1143,4 @@ ion-content.full-screen-content {
transform: rotate(180deg); transform: rotate(180deg);
} }
+59 -27
View File
@@ -1,4 +1,4 @@
import { Component, OnInit, OnDestroy, signal, computed, effect, inject, ElementRef, AfterViewInit, untracked, HostListener } from '@angular/core'; import { Component, OnInit, OnDestroy, signal, computed, effect, inject, ElementRef, AfterViewInit, untracked, HostListener, ViewChild } from '@angular/core';
import { DomSanitizer, SafeResourceUrl, Meta } from '@angular/platform-browser'; import { DomSanitizer, SafeResourceUrl, Meta } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { AlertController, ToastController, GestureController } from '@ionic/angular'; import { AlertController, ToastController, GestureController } from '@ionic/angular';
@@ -24,6 +24,8 @@ import { FaceDetectorService } from '../../services/face-detector.service';
standalone: false standalone: false
}) })
export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
@ViewChild('faceVideo', { static: false }) faceVideoRef!: ElementRef<HTMLVideoElement>;
public canto = signal<Canto | null>(null); public canto = signal<Canto | null>(null);
public activeSongId = signal<string | null>(null); public activeSongId = signal<string | null>(null);
public isTitleWrapped = signal<boolean>(false); public isTitleWrapped = signal<boolean>(false);
@@ -52,6 +54,8 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
} }
} }
private clickTimeout: any = null;
handleLyricsClick(event: MouseEvent) { handleLyricsClick(event: MouseEvent) {
const target = event.target as HTMLElement; const target = event.target as HTMLElement;
if (target && (target.closest('button') || target.closest('ion-button') || target.closest('.side-indicator') || target.closest('.autoscroll-indicator') || target.closest('ion-icon'))) { if (target && (target.closest('button') || target.closest('ion-button') || target.closest('.side-indicator') || target.closest('.autoscroll-indicator') || target.closest('ion-icon'))) {
@@ -61,13 +65,20 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
if (selection && selection.toString().length > 0) { if (selection && selection.toString().length > 0) {
return; return;
} }
const clickX = event.clientX;
const width = window.innerWidth; // Se c'è già un click in attesa, questo è il secondo tocco (Doppio Tap) -> Torna indietro
if (clickX > width / 2) { if (this.clickTimeout) {
this.next(false, true); clearTimeout(this.clickTimeout);
} else { this.clickTimeout = null;
this.prev(true); this.prev(true);
return;
} }
// Altrimenti imposta un timer per il singolo tocco -> Avanza
this.clickTimeout = setTimeout(() => {
this.clickTimeout = null;
this.next(false, true);
}, 250);
} }
@@ -534,9 +545,11 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
onEnd: (ev) => { onEnd: (ev) => {
if (Math.abs(ev.deltaX) > 60) { if (Math.abs(ev.deltaX) > 60) {
if (ev.deltaX > 0) { if (ev.deltaX > 0) {
this.prev(); // Swipe/trascinamento verso destra -> Brano precedente
this.prevSong();
} else { } else {
this.next(); // Swipe/trascinamento verso sinistra -> Brano successivo
this.nextSong();
} }
} }
} }
@@ -1369,6 +1382,18 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
} }
} }
async recalibrateDistance(event?: Event) {
if (event) event.stopPropagation();
this.faceDetector.resetDistanceBaseline();
const toast = await this.toastCtrl.create({
message: 'Distanza ricalibrata al 100%',
duration: 1500,
position: 'bottom',
color: 'secondary'
});
await toast.present();
}
toggleCameraNavigation() { toggleCameraNavigation() {
if (this.settingsService.cameraNavigationActive()) { if (this.settingsService.cameraNavigationActive()) {
this.stopCameraNavigation(); this.stopCameraNavigation();
@@ -1382,28 +1407,31 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
this.initCameraTracking(); this.initCameraTracking();
} }
initCameraTracking() { async initCameraTracking() {
if (!this.settingsService.cameraNavigationActive()) return; if (!this.settingsService.cameraNavigationActive()) return;
setTimeout(async () => { // Usa ViewChild per trovare il video element in modo sicuro
const videoEl = document.querySelector('#face-preview-video') as HTMLVideoElement; const videoEl = this.faceVideoRef?.nativeElement || document.querySelector('#face-preview-video') as HTMLVideoElement;
if (videoEl) { if (!videoEl) {
try { console.error('[PlayerPage] Video element #face-preview-video not found in DOM');
await this.faceDetector.start(videoEl, (direction) => { this.settingsService.cameraNavigationActive.set(false);
console.log(`[PlayerPage] Head tilt trigger received: ${direction}`); return;
if (direction === 'next') { }
this.next(false, true);
} else { try {
this.prev(true); await this.faceDetector.start(videoEl, (direction) => {
} console.log(`[PlayerPage] Head tilt trigger received: ${direction}`);
}); if (direction === 'next') {
} catch (e: any) { this.next(false, true);
this.settingsService.cameraNavigationActive.set(false); } else {
const errorMsg = e?.message || e?.name || (typeof e === 'object' ? JSON.stringify(e) : String(e)) || 'Errore sconosciuto'; this.prev(true);
alert(`Impossibile accedere alla fotocamera. Assicurati di aver concesso i permessi e di usare HTTPS.\n\nDettagli errore: ${errorMsg}`);
} }
} });
}, 300); } catch (e: any) {
this.settingsService.cameraNavigationActive.set(false);
const errorMsg = e?.message || e?.name || (typeof e === 'object' ? JSON.stringify(e) : String(e)) || 'Errore sconosciuto';
alert(`Impossibile accedere alla fotocamera. Assicurati di aver concesso i permessi e di usare HTTPS.\n\nDettagli errore: ${errorMsg}`);
}
} }
stopCameraNavigation() { stopCameraNavigation() {
@@ -1412,6 +1440,10 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
} }
ngOnDestroy() { ngOnDestroy() {
if (this.clickTimeout) {
clearTimeout(this.clickTimeout);
this.clickTimeout = null;
}
this.exitFullscreen(); this.exitFullscreen();
this.stopAutoscroll(); this.stopAutoscroll();
this.logPreviousSongTime(); this.logPreviousSongTime();
+31 -4
View File
@@ -354,10 +354,37 @@
</div> </div>
</div> </div>
<!-- Camera Tracking Mode Section -->
<div class="settings-group glass ion-margin-bottom" *ngIf="settingsService.enableVisualAutoscroll()">
<div class="group-header ion-padding-start ion-padding-top">
<h2 class="outfit-font settings-group-title">
Modalità Tracciamento Fotocamera (Karaoke)
</h2>
</div>
<div class="segment-wrapper ion-padding-horizontal ion-padding-bottom">
<div class="filter-buttons compact-mode ion-padding-horizontal ion-padding-bottom">
<div class="filter-btn glass"
[class.active-btn]="settingsService.cameraTrackingMode() === 'distance'"
(click)="settingsService.setCameraTrackingMode('distance')">
<span>Distanza Viso (Avanti / Dietro)</span>
</div>
<div class="filter-btn glass"
[class.active-btn]="settingsService.cameraTrackingMode() === 'tilt'"
(click)="settingsService.setCameraTrackingMode('tilt')">
<span>Inclinazione Testa</span>
</div>
</div>
<p class="settings-item-subtitle outfit-font" style="margin: 8px 12px 0 12px; font-size: 0.78rem; opacity: 0.8; line-height: 1.4;">
<span *ngIf="settingsService.cameraTrackingMode() === 'distance'">
💡 <strong>Distanza Viso:</strong> avvicinati allo schermo per avanzare nel testo, allontanati per tornare indietro. Tocca la percentuale nel player per calibrare all'istante la tua distanza attuale al 100%.
</span>
<span *ngIf="settingsService.cameraTrackingMode() === 'tilt'">
💡 <strong>Inclinazione:</strong> piega la testa a destra o a sinistra per avanzare o tornare indietro.
</span>
</p>
</div>
</div>
<!-- Legenda Pulsanti Canto --> <!-- Legenda Pulsanti Canto -->
<div class="settings-group glass ion-margin-top"> <div class="settings-group glass ion-margin-top">
@@ -380,7 +407,7 @@
<div class="legend-text"> <div class="legend-text">
<h4 class="outfit-font">Scroll Visuale (Karaoke)</h4> <h4 class="outfit-font">Scroll Visuale (Karaoke)</h4>
<p class="outfit-font"> <p class="outfit-font">
Attiva lo scorrimento visuale intelligente tramite movimenti del capo rilevati dalla fotocamera frontale. Inclinando la testa è possibile scorrere il testo senza toccare lo schermo. Attiva lo scorrimento visuale intelligente tramite fotocamera frontale (avvicinandoti/allontanandoti o inclinando il capo in base alla modalità selezionata).
</p> </p>
</div> </div>
</div> </div>
+248 -106
View File
@@ -1,92 +1,98 @@
import { Injectable, signal } from '@angular/core'; import { Injectable, inject, signal } from '@angular/core';
import { SettingsService } from './settings.service';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
}) })
export class FaceDetectorService { export class FaceDetectorService {
private settingsService = inject(SettingsService);
public isCameraActive = signal<boolean>(false); public isCameraActive = signal<boolean>(false);
public currentTiltAngle = signal<number>(0); public currentTiltAngle = signal<number>(0);
public isTilted = signal<boolean>(false); public isTilted = signal<boolean>(false);
private stream: MediaStream | null = null; // Segnali di stato per la modalità distanza viso
private animFrameId: number | null = null; public currentDistanceRatio = signal<number>(1.0); // Rapporto rispetto alla distanza di riposo (es. 1.25 = 25% più vicino, 0.75 = 25% più lontano)
private faceMesh: any = null; public distanceState = signal<'idle' | 'approaching' | 'receding'>('idle'); // Stato dinamico rilevato
private onTiltCallback: ((direction: 'next' | 'prev') => void) | null = null;
// Gesture state machine private stream: MediaStream | null = null;
private camera: any = null;
private faceMesh: any = null;
private onTriggerCallback: ((direction: 'next' | 'prev') => void) | null = null;
private isProcessingFrame: boolean = false;
// Gesture state machine - Tilt (inclinazione testa)
private tiltStartTime: number = 0; private tiltStartTime: number = 0;
private inCooldown: boolean = false; private tiltInCooldown: boolean = false;
private lastTriggerTime: number = 0; private lastTiltTriggerTime: number = 0;
private readonly TILT_THRESHOLD = 30; // Degrees to trigger next/prev page (30° for stability) private readonly TILT_THRESHOLD = 15; // Gradi per scattare avanti/dietro (originale: 15°)
private readonly TILT_HOLD_MS = 300; // How long to hold the tilt private readonly TILT_HOLD_MS = 250; // Durata mantenimento inclinazione (ms)
private readonly RETURN_THRESHOLD = 15; // Degrees to reset cooldown private readonly RETURN_THRESHOLD = 6; // Gradi per reimpostare il cooldown (originale: 6°)
private readonly TRIGGER_COOLDOWN_MS = 3000; // Minimum time between consecutive gestures in ms private readonly TRIGGER_COOLDOWN_MS = 2000; // Tempo minimo tra due scatti consecutivi (ms)
// Gesture state machine - Distance (avvicinamento / allontanamento viso)
// Utilizziamo la distanza geometrica normalizzata tra gli angoli esterni degli occhi (33 e 263) e tra fronte (10) e mento (152)
private baselineFaceSize: number = 0; // Dimensione di riferimento del viso in posizione neutra
private smoothedFaceSize: number = 0; // Media mobile per evitare jitter/rumore
private distanceStartTime: number = 0; // Timestamp di inizio superamento soglia
private distanceInCooldown: boolean = false; // Flag di attesa ritorno a neutro
private lastDistanceTriggerTime: number = 0; // Timestamp ultimo scatto
private activeDistanceDirection: 'next' | 'prev' | null = null;
constructor() {} constructor() {}
/** /**
* Loads MediaPipe script dynamically if not already loaded. * Loads MediaPipe scripts dynamically if not already loaded.
*/ */
private loadScripts(): Promise<void> { private loadScripts(): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if ((window as any).FaceMesh) { if ((window as any).FaceMesh && (window as any).Camera) {
resolve(); resolve();
return; return;
} }
const cameraScript = document.createElement('script');
cameraScript.src = 'assets/mediapipe/camera_utils.js';
const faceMeshScript = document.createElement('script'); const faceMeshScript = document.createElement('script');
faceMeshScript.src = 'assets/mediapipe/face_mesh.js'; faceMeshScript.src = 'assets/mediapipe/face_mesh.js';
faceMeshScript.onload = () => resolve();
cameraScript.onload = () => {
document.head.appendChild(faceMeshScript);
};
faceMeshScript.onload = () => {
resolve();
};
cameraScript.onerror = (err) => reject(err);
faceMeshScript.onerror = (err) => reject(err); faceMeshScript.onerror = (err) => reject(err);
document.head.appendChild(faceMeshScript); document.head.appendChild(cameraScript);
}); });
} }
/** /**
* Starts or re-binds camera capture and face mesh tracking. * Starts camera capture and face mesh tracking.
* If a stream is already active (e.g. navigating between songs on iPad), reuses it
* to avoid triggering repeated permission prompts on iOS/Safari.
*/ */
async start(videoElement: HTMLVideoElement, onTilt: (direction: 'next' | 'prev') => void): Promise<void> { async start(videoElement: HTMLVideoElement, onTrigger: (direction: 'next' | 'prev') => void): Promise<void> {
this.onTiltCallback = onTilt; this.onTriggerCallback = onTrigger;
if (this.isCameraActive()) {
return;
}
try { try {
await this.loadScripts(); await this.loadScripts();
const isStreamActive = this.stream && const FaceMeshLib = (window as any).FaceMesh;
this.stream.active && const CameraLib = (window as any).Camera;
this.stream.getVideoTracks().some(track => track.readyState === 'live');
if (!isStreamActive) { if (!FaceMeshLib || !CameraLib) {
// Request camera permissions and stream ONCE throw new Error('MediaPipe libraries failed to initialize.');
this.stream = await navigator.mediaDevices.getUserMedia({
video: {
width: { ideal: 320 },
height: { ideal: 240 },
facingMode: 'user'
},
audio: false
});
}
videoElement.srcObject = this.stream;
videoElement.setAttribute('playsinline', 'true');
(videoElement as any).playsInline = true;
videoElement.muted = true;
try {
await videoElement.play();
} catch (playErr) {
console.warn('[FaceDetector] Video play warning:', playErr);
} }
if (!this.faceMesh) { if (!this.faceMesh) {
const FaceMeshLib = (window as any).FaceMesh;
if (!FaceMeshLib) {
throw new Error('MediaPipe FaceMesh library failed to initialize.');
}
this.faceMesh = new FaceMeshLib({ this.faceMesh = new FaceMeshLib({
locateFile: (file: string) => `assets/mediapipe/${file}` locateFile: (file: string) => `assets/mediapipe/${file}`
}); });
@@ -94,8 +100,8 @@ export class FaceDetectorService {
this.faceMesh.setOptions({ this.faceMesh.setOptions({
maxNumFaces: 1, maxNumFaces: 1,
refineLandmarks: false, refineLandmarks: false,
minDetectionConfidence: 0.6, minDetectionConfidence: 0.5,
minTrackingConfidence: 0.6 minTrackingConfidence: 0.5
}); });
this.faceMesh.onResults((results: any) => { this.faceMesh.onResults((results: any) => {
@@ -103,12 +109,33 @@ export class FaceDetectorService {
}); });
} }
// Reset immediato per calibrazione istantanea al primo frame utile
this.resetDistanceBaseline();
this.isCameraActive.set(true); this.isCameraActive.set(true);
// Start custom frame processing loop // Usiamo CameraLib direttamente per gestire lo stream ed evitare doppi stream / loop
this.stopFrameLoop(); this.isProcessingFrame = false;
this.startFrameLoop(videoElement); this.camera = new CameraLib(videoElement, {
onFrame: async () => {
if (!this.isCameraActive() || !this.faceMesh) return;
if (this.isProcessingFrame) return; // Salta il frame se il precedente è ancora in elaborazione (anti-lag & memory leak)
this.isProcessingFrame = true;
try {
await this.faceMesh.send({ image: videoElement });
} catch (e) {
console.warn('[FaceDetector] Error sending frame to FaceMesh:', e);
} finally {
this.isProcessingFrame = false;
}
},
width: 320,
height: 240,
facingMode: 'user'
});
await this.camera.start();
console.log('[FaceDetector] Face tracking started successfully.'); console.log('[FaceDetector] Face tracking started successfully.');
} catch (err) { } catch (err) {
console.error('[FaceDetector] Failed to start face tracking:', err); console.error('[FaceDetector] Failed to start face tracking:', err);
@@ -117,84 +144,189 @@ export class FaceDetectorService {
} }
} }
private stopFrameLoop() { /**
if (this.animFrameId !== null) { * Resetta la dimensione di riferimento (baseline) della distanza del viso
cancelAnimationFrame(this.animFrameId); */
this.animFrameId = null; /**
} * Resetta la dimensione di riferimento (baseline) della distanza del viso per calibrazione istantanea
} */
public resetDistanceBaseline() {
private startFrameLoop(videoElement: HTMLVideoElement) { this.baselineFaceSize = 0;
let lastFrameTime = 0; this.smoothedFaceSize = 0;
const FRAME_INTERVAL_MS = 100; // Analizza massimo 10 fotogrammi al secondo per risparmiare CPU/RAM this.currentDistanceRatio.set(1.0);
this.distanceState.set('idle');
const processFrame = async () => { this.distanceStartTime = 0;
if (!this.isCameraActive()) return; this.distanceInCooldown = false;
this.activeDistanceDirection = null;
const now = Date.now(); this.lastDistanceTriggerTime = 0;
if (now - lastFrameTime >= FRAME_INTERVAL_MS && this.faceMesh && videoElement && videoElement.readyState >= 2) {
lastFrameTime = now;
try {
await this.faceMesh.send({ image: videoElement });
} catch (err) {
console.warn('[FaceDetector] Error processing frame:', err);
}
}
if (this.isCameraActive()) {
this.animFrameId = requestAnimationFrame(processFrame);
}
};
this.animFrameId = requestAnimationFrame(processFrame);
} }
/** /**
* Processes landmarks to calculate head tilt angle. * Processes landmarks to calculate head tilt angle or face distance.
*/ */
private processLandmarks(results: any) { private processLandmarks(results: any) {
if (!results.multiFaceLandmarks || results.multiFaceLandmarks.length === 0) { if (!results.multiFaceLandmarks || results.multiFaceLandmarks.length === 0) {
this.currentTiltAngle.set(0); this.currentTiltAngle.set(0);
this.isTilted.set(false); this.isTilted.set(false);
this.distanceState.set('idle');
return; return;
} }
const landmarks = results.multiFaceLandmarks[0]; const landmarks = results.multiFaceLandmarks[0];
// Left eye corner (landmark 33) and right eye corner (landmark 263) // Landmark essenziali:
// 33: occhio sinistro esterno, 263: occhio destro esterno
// 10: fronte / attaccatura capelli superiore, 152: mento inferiore
const leftEye = landmarks[33]; const leftEye = landmarks[33];
const rightEye = landmarks[263]; const rightEye = landmarks[263];
const topForehead = landmarks[10];
const chin = landmarks[152];
if (!leftEye || !rightEye) return; if (!leftEye || !rightEye) return;
// Calculate angle in degrees // Calcolo roll/inclinazione testa (sempre aggiornato per visualizzazione e modalità tilt)
const dy = rightEye.y - leftEye.y; const dy = rightEye.y - leftEye.y;
const dx = rightEye.x - leftEye.x; const dx = rightEye.x - leftEye.x;
const angle = Math.atan2(dy, dx) * (180 / Math.PI);
// Normalize angle (roll)
let angle = Math.atan2(dy, dx) * (180 / Math.PI);
// Smooth angle updates
this.currentTiltAngle.set(Math.round(angle)); this.currentTiltAngle.set(Math.round(angle));
const trackingMode = this.settingsService.cameraTrackingMode();
if (trackingMode === 'distance') {
this.processDistanceGesture(leftEye, rightEye, topForehead, chin);
} else {
this.processTiltGesture(angle);
}
}
/**
* Gestione del gesto basato sulla distanza del viso dalla fotocamera
*/
private processDistanceGesture(leftEye: any, rightEye: any, topForehead: any, chin: any) {
// Calcoliamo la larghezza occhi (inter-ocular distance) e l'altezza del viso
const eyeDistX = rightEye.x - leftEye.x;
const eyeDistY = rightEye.y - leftEye.y;
const eyeDist = Math.sqrt(eyeDistX * eyeDistX + eyeDistY * eyeDistY);
let faceHeight = eyeDist;
if (topForehead && chin) {
const faceHeightX = chin.x - topForehead.x;
const faceHeightY = chin.y - topForehead.y;
faceHeight = Math.sqrt(faceHeightX * faceHeightX + faceHeightY * faceHeightY);
}
// Media armonica combinata di larghezza occhi e altezza viso per massima accuratezza invariante da rotazioni
const currentMeasuredSize = (eyeDist * 1.5 + faceHeight) / 2.5;
// Calibrazione ISTANTANEA al primo frame utile (funziona subito senza attendere)
if (this.smoothedFaceSize === 0 || this.baselineFaceSize === 0) {
this.smoothedFaceSize = currentMeasuredSize;
this.baselineFaceSize = currentMeasuredSize;
this.currentDistanceRatio.set(1.0);
this.distanceState.set('idle');
return;
}
// Filtro a media mobile esponenziale (EMA) rapido per evitare jitter
this.smoothedFaceSize = this.smoothedFaceSize * 0.65 + currentMeasuredSize * 0.35;
const ratio = this.baselineFaceSize > 0 ? (this.smoothedFaceSize / this.baselineFaceSize) : 1.0;
this.currentDistanceRatio.set(Math.round(ratio * 100) / 100);
const now = Date.now();
// Soglie per scatto immediato ed ergonomico:
// +12% per avanzare, -12% per retrocedere
const approachThreshold = 1.12;
const recedeThreshold = 0.88;
const returnHigh = 1.05;
const returnLow = 0.95;
const holdMs = 120; // Reattività immediata (120ms)
const distanceCooldownMs = 900; // Cooldown fluido tra scatti consecutivi (0.9s)
// Adattamento graduale della baseline SOLO quando l'utente è stabile e rilassato al centro
if (!this.distanceInCooldown && ratio >= returnLow && ratio <= returnHigh && this.activeDistanceDirection === null) {
this.baselineFaceSize = this.baselineFaceSize * 0.985 + this.smoothedFaceSize * 0.015;
}
// Riconoscimento avvicinamento (Next)
if (ratio >= approachThreshold) {
this.distanceState.set('approaching');
if (!this.distanceInCooldown && (now - this.lastDistanceTriggerTime > distanceCooldownMs)) {
if (this.activeDistanceDirection !== 'next') {
this.activeDistanceDirection = 'next';
this.distanceStartTime = now;
} else if (now - this.distanceStartTime >= holdMs) {
// Trigger avvicinamento -> AVANTI
console.log(`[FaceDetector] Face approach gesture detected! Ratio: ${ratio.toFixed(2)} -> NEXT`);
if (this.onTriggerCallback) {
this.onTriggerCallback('next');
}
this.distanceInCooldown = true;
this.activeDistanceDirection = null;
this.distanceStartTime = 0;
this.lastDistanceTriggerTime = now;
}
}
}
// Riconoscimento allontanamento (Prev)
else if (ratio <= recedeThreshold) {
this.distanceState.set('receding');
if (!this.distanceInCooldown && (now - this.lastDistanceTriggerTime > distanceCooldownMs)) {
if (this.activeDistanceDirection !== 'prev') {
this.activeDistanceDirection = 'prev';
this.distanceStartTime = now;
} else if (now - this.distanceStartTime >= holdMs) {
// Trigger allontanamento -> INDIETRO
console.log(`[FaceDetector] Face recede gesture detected! Ratio: ${ratio.toFixed(2)} -> PREV`);
if (this.onTriggerCallback) {
this.onTriggerCallback('prev');
}
this.distanceInCooldown = true;
this.activeDistanceDirection = null;
this.distanceStartTime = 0;
this.lastDistanceTriggerTime = now;
}
}
}
// Posizione neutra
else {
this.distanceState.set('idle');
this.activeDistanceDirection = null;
this.distanceStartTime = 0;
// Reset cooldown quando l'utente torna alla distanza neutra
if (ratio < returnHigh && ratio > returnLow) {
this.distanceInCooldown = false;
}
}
}
/**
* Gestione del gesto basato sull'inclinazione del collo (modalità tilt)
*/
private processTiltGesture(angle: number) {
const absAngle = Math.abs(angle); const absAngle = Math.abs(angle);
const now = Date.now();
if (absAngle > this.TILT_THRESHOLD) { if (absAngle > this.TILT_THRESHOLD) {
this.isTilted.set(true); this.isTilted.set(true);
if (!this.inCooldown && (Date.now() - this.lastTriggerTime > this.TRIGGER_COOLDOWN_MS)) { if (!this.tiltInCooldown && (now - this.lastTiltTriggerTime > this.TRIGGER_COOLDOWN_MS)) {
if (this.tiltStartTime === 0) { if (this.tiltStartTime === 0) {
this.tiltStartTime = Date.now(); this.tiltStartTime = now;
} else if (Date.now() - this.tiltStartTime > this.TILT_HOLD_MS) { } else if (now - this.tiltStartTime >= this.TILT_HOLD_MS) {
// Trigger the tilt gesture! // Trigger the tilt gesture!
const direction = angle > 0 ? 'prev' : 'next'; const direction = angle > 0 ? 'prev' : 'next';
console.log(`[FaceDetector] Head tilt gesture detected! Angle: ${angle.toFixed(1)}°, Direction: ${direction}`); console.log(`[FaceDetector] Head tilt gesture detected! Angle: ${angle.toFixed(1)}°, Direction: ${direction}`);
if (this.onTiltCallback) { if (this.onTriggerCallback) {
this.onTiltCallback(direction); this.onTriggerCallback(direction);
} }
this.inCooldown = true; this.tiltInCooldown = true;
this.tiltStartTime = 0; this.tiltStartTime = 0;
this.lastTriggerTime = Date.now(); this.lastTiltTriggerTime = now;
} }
} }
} else { } else {
@@ -203,7 +335,7 @@ export class FaceDetectorService {
// Reset cooldown when the head returns near the center // Reset cooldown when the head returns near the center
if (absAngle < this.RETURN_THRESHOLD) { if (absAngle < this.RETURN_THRESHOLD) {
this.inCooldown = false; this.tiltInCooldown = false;
} }
} }
} }
@@ -212,8 +344,14 @@ export class FaceDetectorService {
* Pauses the frame tracking loop without killing the underlying media stream hardware. * Pauses the frame tracking loop without killing the underlying media stream hardware.
*/ */
pause() { pause() {
this.stopFrameLoop();
this.isCameraActive.set(false); this.isCameraActive.set(false);
this.isProcessingFrame = false;
if (this.camera) {
try {
this.camera.stop();
} catch (e) {}
this.camera = null;
}
} }
/** /**
@@ -223,12 +361,15 @@ export class FaceDetectorService {
this.pause(); this.pause();
this.currentTiltAngle.set(0); this.currentTiltAngle.set(0);
this.isTilted.set(false); this.isTilted.set(false);
this.inCooldown = false; this.tiltInCooldown = false;
this.tiltStartTime = 0; this.tiltStartTime = 0;
this.lastTriggerTime = 0; this.lastTiltTriggerTime = 0;
this.resetDistanceBaseline();
if (this.stream) { if (this.stream) {
this.stream.getTracks().forEach(track => track.stop()); try {
this.stream.getTracks().forEach(track => track.stop());
} catch (e) {}
this.stream = null; this.stream = null;
} }
@@ -239,7 +380,8 @@ export class FaceDetectorService {
this.faceMesh = null; this.faceMesh = null;
} }
this.onTiltCallback = null; this.onTriggerCallback = null;
console.log('[FaceDetector] Face tracking fully stopped.'); console.log('[FaceDetector] Face tracking fully stopped.');
} }
} }
+36 -4
View File
@@ -53,6 +53,12 @@ export class SettingsService {
/** Navigazione con fotocamera (tracciamento testa) attiva nel player: true = attivo */ /** Navigazione con fotocamera (tracciamento testa) attiva nel player: true = attivo */
public cameraNavigationActive = signal<boolean>(false); public cameraNavigationActive = signal<boolean>(false);
/** Modalità tracciamento fotocamera: 'tilt' (inclinazione collo) o 'distance' (avvicinamento/allontanamento viso) */
public cameraTrackingMode = signal<'tilt' | 'distance'>('tilt');
/** Fascia di distanza utente-fotocamera per taratura sensibilità: 'close' (0-30 cm), 'medium' (30-60 cm), 'far' (1m e oltre) */
public cameraDistanceRange = signal<'close' | 'medium' | 'far'>('medium');
/** Preferenza notazione accordi: diesis o bemolle */ /** Preferenza notazione accordi: diesis o bemolle */
public chordNotationPreference = signal<'diesis' | 'bemolle'>('diesis'); public chordNotationPreference = signal<'diesis' | 'bemolle'>('diesis');
@@ -360,17 +366,35 @@ export class SettingsService {
localStorage.setItem('global-zoom', this.globalZoom().toString()); localStorage.setItem('global-zoom', this.globalZoom().toString());
}); });
const savedCameraNavActive = localStorage.getItem('camera-navigation-active'); // La navigazione con fotocamera deve SEMPRE partire disattivata all'avvio dell'app
if (savedCameraNavActive !== null) { this.cameraNavigationActive.set(false);
this.cameraNavigationActive.set(savedCameraNavActive === 'true');
const savedCameraTrackingMode = localStorage.getItem('camera-tracking-mode');
if (savedCameraTrackingMode === 'tilt' || savedCameraTrackingMode === 'distance') {
this.cameraTrackingMode.set(savedCameraTrackingMode);
} else { } else {
this.cameraNavigationActive.set(false); this.cameraTrackingMode.set('tilt');
}
const savedCameraDistanceRange = localStorage.getItem('camera-distance-range');
if (savedCameraDistanceRange === 'close' || savedCameraDistanceRange === 'medium' || savedCameraDistanceRange === 'far') {
this.cameraDistanceRange.set(savedCameraDistanceRange);
} else {
this.cameraDistanceRange.set('medium');
} }
effect(() => { effect(() => {
localStorage.setItem('camera-navigation-active', this.cameraNavigationActive().toString()); localStorage.setItem('camera-navigation-active', this.cameraNavigationActive().toString());
}); });
effect(() => {
localStorage.setItem('camera-tracking-mode', this.cameraTrackingMode());
});
effect(() => {
localStorage.setItem('camera-distance-range', this.cameraDistanceRange());
});
effect(() => { effect(() => {
const active = this.keepScreenOn(); const active = this.keepScreenOn();
localStorage.setItem('keep-screen-on', active.toString()); localStorage.setItem('keep-screen-on', active.toString());
@@ -522,6 +546,14 @@ export class SettingsService {
this.chordNotationPreference.set(val); this.chordNotationPreference.set(val);
} }
setCameraTrackingMode(val: 'tilt' | 'distance') {
this.cameraTrackingMode.set(val);
}
setCameraDistanceRange(val: 'close' | 'medium' | 'far') {
this.cameraDistanceRange.set(val);
}
setUserUuid(uuid: string) { setUserUuid(uuid: string) {
const trimmed = uuid.trim(); const trimmed = uuid.trim();
if (trimmed) { if (trimmed) {
+1 -1
View File
@@ -1 +1 @@
export const VERSION = '2026.08.09.0826'; export const VERSION = '2026.08.24.0054';