ARTICOLO

La prova colore dal vivo, tutta sul telefono: MediaPipe in una WebView dentro Flutter

FlutterAIprivacy

Nell'app Miraviso Colore inquadri la faccia con la fotocamera frontale, tocchi un castano ramato e i tuoi capelli diventano castano ramato mentre giri la testa. Nessun fotogramma esce dal telefono. Detta così sembra una funzione da app di filtri; a farla bene, su un Android qualunque e con una promessa di privacy che si possa verificare guardando la rete, ci sono voluti più giri di quanti ne avessi messi in conto. Questo è il motore com'è oggi, con i numeri che ho misurato.

Perché c'è un browser dentro un'app Flutter

La prima cosa che sorprende chi apre il codice: la prova colore non usa tflite_flutter, non c'è un plugin Kotlin e non c'è uno shader Flutter. Lo schermo è una WebViewWidget, e dentro gira una pagina con MediaPipe Tasks Vision in WebAssembly e un fragment shader WebGL2.

Il motore è nato come prototipo nel browser e lì ha trovato la sua forma. Portarlo in Dart avrebbe voluto dire riscrivere segmentazione, shader e tutte le correzioni che racconto sotto, per ottenere lo stesso risultato. Oggi vive in un pacchetto, live_color, condiviso fra l'app per il tablet del salone e l'app per chi vuole provare il colore a casa. Prima era copiato nelle due app e le copie avevano già preso strade diverse una volta: un motore solo, due presentazioni.

Il prezzo di questa scelta si paga subito. I moduli ES non si caricano da file://, e getUserMedia vuole un contesto sicuro. La soluzione è un piccolo server HTTP scritto in Dart che si mette in ascolto su 127.0.0.1, su una porta scelta dal sistema, e serve gli asset del motore dal bundle dell'app:

final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
// la WebView apre http://127.0.0.1:<porta>/index.html

localhost conta come contesto sicuro, quindi la fotocamera parte. Nel manifest Android il traffico in chiaro è permesso solo verso 127.0.0.1 e localhost, e il permesso della fotocamera chiesto dalla WebView viene concesso dal lato Flutter, dopo che l'utente l'ha dato all'app.

Dalla fotocamera alla maschera

La fotocamera frontale viene aperta a 640×480 con 30 fps ideali, e il canvas di lavoro ha il lato lungo a 480 pixel. Più risoluzione non serve: la maschera dei capelli è morbida per natura, e ogni pixel in più si paga su tutti i passaggi successivi.

Il modello è hair_segmenter.tflite, quello della vecchia demo di MediaPipe dedicata ai capelli. Due classi, sfondo e capelli, 780 KB. Fino a metà agosto usavo selfie_multiclass_256x256, che segmenta sei classi (capelli, pelle, vestiti...) e di cui mi serviva una sola. Il cambio ha avuto tre effetti misurabili:

Il segmenter gira in modalità VIDEO e restituisce solo le confidenceMasks: un valore fra 0 e 1 per pixel, invece di una maschera binaria. Provo prima il delegate GPU e ripiego sulla CPU se la creazione fallisce. Il dettaglio che fa la differenza è che il segmenter riceve lo stesso canvas WebGL su cui disegno: la maschera arriva come texture con getAsWebGLTexture(), senza mai tornare in memoria JavaScript.

Il colore: moltiplicare quando si scurisce, riaccendere quando si schiarisce

La ricolorazione è un fragment shader GLSL ES 3.0. La prima versione, in JavaScript sui pixel del canvas, costava 66 ms a fotogramma da sola, e con quella cifra non c'è specchio che tenga.

Il principio di base è semplice: calcolo la luminanza del capello (coefficienti Rec.601, 0,299, 0,587, 0,114) e la moltiplico per la tinta. Ombre e riflessi restano dove sono, perché vengono dal capello vero. Funziona benissimo quando la tinta è più scura dei capelli. Quando invece è più chiara non funziona per niente: moltiplicare un nero per un platino dà un grigio sale e pepe, perché la moltiplicazione non può schiarire.

Per le tinte più chiare lo shader cambia strategia, e io la chiamo "relight": porta il tono medio dei capelli alla luminanza della tinta e conserva solo lo shading relativo attorno a quella media. I numeri, scelti a occhio davanti allo specchio e poi tenuti:

La confidenza della maschera fa da alpha: i capelli radi e i bordi vengono colorati in proporzione, e sotto 0,10 non si tocca niente. Non c'è blur né feather sulla maschera, perché la confidenza è già morbida di suo. La luminanza media dei capelli, che il relight usa come riferimento, passa da una media mobile esponenziale con peso 0,25 e si azzera se salta di più di 0,15, per esempio quando entri in una stanza più luminosa.

Il catalogo ha 54 voci: 53 colori in 7 famiglie (biondi, castani, rossi, rame, freddi, neri, fantasia) più "Naturale", che toglie la tinta. Sopra il colore pieno ci sono le tecniche: balayage, ombré e sombré dal vivo, il pennello solo sulle foto.

Tre bug che sembravano altro

Le correzioni più utili sono arrivate da sintomi che non puntavano alla causa.

Il colore lampeggiava. Legavo direttamente la texture della maschera restituita da MediaPipe. Ma quella texture viene riciclata dalla libreria per il fotogramma successivo, e ogni tanto lo shader leggeva una maschera già sovrascritta. Ora la copio in una texture mia con un passaggio di shader e tengo l'ultima buona.

I capelli erano sempre un passo indietro. Segmentavo il video e poi disegnavo il video: nel frattempo il video era già avanzato di un fotogramma. Adesso il fotogramma viene congelato in una texture prima della segmentazione, e maschera e immagine sono la stessa istantanea per costruzione.

Lo specchio "respirava". Una volta al secondo l'immagine sembrava fare un piccolo zoom. Era la lettura sincrona di circa 300 KB di pixel dalla GPU per calcolare la luminanza media dei capelli: bloccava la pipeline quanto bastava per vederlo. Ora la media la fa la GPU, riducendo la texture con le mipmap fino a 1×1, e leggo un solo pixel in modo asincrono con un pixel buffer object e una fence, raccogliendo il valore al giro dopo. Zero letture sincrone GPU→CPU nel ciclo.

Poi c'è il caso peggiore: il Galaxy A56 (Exynos 1580, GPU Xclipse 540) con il delegate GPU restituisce maschere vuote senza dare errori. Niente eccezione, niente log, solo uno specchio che non colora. Il motore ora se ne accorge e ricostruisce da solo il segmenter su CPU. Sulla CPU una segmentazione costava fra 437 e 483 ms su quel telefono, quindi lì la cadenza è adattiva: segmento ogni N fotogrammi, fino a uno ogni 20, e il colore resta agganciato all'ultima maschera. Sulla GPU invece non diluisco mai: quando segui la testa che si gira, l'aggancio conta più della fluidità.

Ultima lezione: un'eccezione non gestita dentro il ciclo di disegno uccideva requestVideoFrameCallback, e lo specchio si congelava al primo fotogramma senza dire niente. Ora il ciclo sta in un try che scrive l'errore nella riga di stato.

Quanto va veloce, davvero

A metà agosto, con il lavoro del motore ormai sotto i 25 ms a fotogramma e la fotocamera che ne consegnava 30, ho misurato tre telefoni: 15 fps sul Pixel 8 Pro, 14 sull'A56, 5 su un Galaxy S21. Il giorno dopo, con il nuovo modello e il ciclo portato tutto su GPU, il Pixel 8 Pro è salito a 22 fps, con 10 ms di segmentazione e la maschera presente su 398 fotogrammi su 398.

Il tetto però non è più il motore: è la composizione della WebView dentro Flutter. Sul Galaxy S21 restano circa 180 ms a fotogramma che non compaiono in nessuna delle mie misure. Ho provato e scartato l'ipotesi del ritaglio ad arco dello specchio; il prossimo indiziato è la modalità di composizione di webview_flutter su Android. È il prezzo dell'architettura scelta all'inizio, e preferisco scriverlo che nasconderlo.

La telemetria che non mi aspettavo

"Nessun fotogramma lascia il telefono" è sempre stato vero: la segmentazione e il colore girano in locale. Nelle prime versioni però MediaPipe, il modello e i font arrivavano da jsDelivr e da Google a ogni avvio; ad agosto li ho messi tutti dentro l'app, circa 39 MB in più, e da allora nessuno di quei file viene scaricato da fuori. Poi, guardando la rete con un Chromium headless ho scoperto che il bundle di MediaPipe, 60 secondi dopo la creazione del segmenter, fa una POST a odml.pa.googleapis.com/v1/log con una chiave API ricomposta a runtime. Sono circa 90 byte di protobuf con versione, delegate e contatori, nessuna immagine. Parte anche se non avvii la fotocamera, e Google risponde 200. Cercarla con grep nei sorgenti non serve: la chiave non compare in chiaro.

Non ho patchato il bundle minificato, perché una modifica chirurgica su codice di terzi sparisce al primo aggiornamento. L'ho chiusa con una Content-Security-Policy servita dallo stesso server su loopback:

connect-src 'self';
script-src 'self' 'wasm-unsafe-eval';

connect-src 'self' toglie al motore il permesso di parlare con chiunque non sia il server locale. 'wasm-unsafe-eval' serve, senza MediaPipe non compila il WASM; 'unsafe-eval' invece no. Misurato per 200 secondi, cioè tre finestre di invio: la richiesta parte a +60 secondi, il browser la blocca, le connessioni verso l'esterno restano zero. Siccome un'intestazione che sparisce non rompe niente di visibile, c'è un test (csp_test.dart) che interroga il server vero e controlla la risposta.

Per completezza: l'app ha comunque il permesso INTERNET, perché la directory dei saloni, la mappa e il modulo per chiedere una demo ne hanno bisogno. La promessa riguarda il motore del colore, e il motore non ha strada per uscire.

Cosa me ne porto

Due cose. La prima è che "on-device" non è una proprietà dell'architettura, è una proprietà che si misura: avevo un motore tutto locale e una libreria che telefonava a casa, e me ne sono accorto solo guardando il traffico. La seconda è che i bug peggiori di un motore video non sono quelli che lo rompono, sono quelli che lo rendono un po' strano: un lampeggio, un ritardo, un respiro. Ognuno aveva una causa precisa, e nessuna era quella che avrei detto all'inizio.

Se fai il parrucchiere e vuoi vedere come lo uso in salone, è tutto su miraviso.it. Se invece hai un prodotto con un problema di questo tipo (visione su dispositivo, Flutter, dati che non devono uscire) ne parlo volentieri: qui c'è come lavoro, e qui il caso studio di Miraviso. Dello stesso motore ho scritto anche in quando non serve un LLM.

← Tutti gli articoli