<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Gabriele Pieretti</title>
    <description>The latest articles on DEV Community by Gabriele Pieretti (@gabbrowick).</description>
    <link>https://dev.to/gabbrowick</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4081509%2F97ad5a9c-8094-4644-b924-bfeb72c87649.jpg</url>
      <title>DEV Community: Gabriele Pieretti</title>
      <link>https://dev.to/gabbrowick</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/gabbrowick"/>
    <language>en</language>
    <item>
      <title>The GPU that says yes and does nothing: debugging real-time hair segmentation on mid-range Android</title>
      <dc:creator>Gabriele Pieretti</dc:creator>
      <pubDate>Mon, 17 Aug 2026 13:14:55 +0000</pubDate>
      <link>https://dev.to/gabbrowick/the-gpu-that-says-yes-and-does-nothing-debugging-real-time-hair-segmentation-on-mid-range-android-2990</link>
      <guid>https://dev.to/gabbrowick/the-gpu-that-says-yes-and-does-nothing-debugging-real-time-hair-segmentation-on-mid-range-android-2990</guid>
      <description>&lt;p&gt;We build &lt;a href="https://miraviso.it/prova-colore-live" rel="noopener noreferrer"&gt;a virtual mirror for hair salons&lt;/a&gt;: a tablet&lt;br&gt;
camera feed where the customer's hair changes colour in real time while they move their head.&lt;br&gt;
Two constraints shaped every decision.&lt;/p&gt;

&lt;p&gt;The first is privacy: no frame ever leaves the device. Everything runs locally — the model,&lt;br&gt;
the fonts, the runtime — with no CDN dependency, because a CDN request would both break&lt;br&gt;
offline use and leak the salon's IP to a third party.&lt;/p&gt;

&lt;p&gt;The second is hardware: salons don't buy flagship tablets. We had to work on whatever&lt;br&gt;
mid-range Android is on the counter.&lt;/p&gt;

&lt;p&gt;This is what we learned making it fast enough. All numbers were measured on real devices with&lt;br&gt;
diagnostics built into the engine — none of them are estimates.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug that looked like slowness
&lt;/h2&gt;

&lt;p&gt;Our starting point was bad in a confusing way. A Pixel 8 Pro ran at 9 fps — poor, but working.&lt;br&gt;
A Samsung A56 (Exynos 1580, Mali GPU) showed &lt;em&gt;no colour at all&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The cause took a while to find, and it's the most useful thing in this article:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MediaPipe's &lt;code&gt;ImageSegmenter&lt;/code&gt; with the GPU delegate was created without throwing, ran in 5 ms,&lt;br&gt;
and returned zero hair pixels.&lt;/strong&gt; Both the confidence mask and the category mask came back&lt;br&gt;
empty. Every time.&lt;/p&gt;

&lt;p&gt;Our fallback to CPU only triggered on exceptions. There were no exceptions. So it never&lt;br&gt;
triggered, and the app simply looked slow instead of broken.&lt;/p&gt;

&lt;p&gt;A clean comparison, same scene minutes apart:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Delegate&lt;/th&gt;
&lt;th&gt;Mask coverage&lt;/th&gt;
&lt;th&gt;Time per frame&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CPU&lt;/td&gt;
&lt;td&gt;1.5–2.1 %&lt;/td&gt;
&lt;td&gt;483 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPU&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.0 %&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;5 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Note the trap in that table. The GPU path is 96× faster precisely &lt;em&gt;because it isn't doing&lt;br&gt;
anything&lt;/em&gt;. If you benchmark by timing alone, the broken path wins.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The lesson: don't assume that a delegate which constructs successfully actually works.&lt;/strong&gt;&lt;br&gt;
Check the output, not the exit code. We now measure mask coverage and surface it in the&lt;br&gt;
diagnostics.&lt;/p&gt;

&lt;h3&gt;
  
  
  Detecting it at runtime
&lt;/h3&gt;

&lt;p&gt;The fix is a fallback that rebuilds the engine on CPU. The rule matters more than it looks:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;After five consecutive empty masks, &lt;strong&gt;having never seen a good one in the whole session&lt;/strong&gt;,&lt;br&gt;
rebuild on CPU.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The condition is "never seen a mask", not "empty right now". An empty mask right now is&lt;br&gt;
completely normal — it happens whenever nobody is in front of the lens. We had to encode the&lt;br&gt;
difference between &lt;em&gt;broken&lt;/em&gt; and &lt;em&gt;nobody's there&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;On the A56 this took us from 2 fps to 14 fps with colour applied.&lt;/p&gt;

&lt;h3&gt;
  
  
  A diagnostic that costs more than what it measures
&lt;/h3&gt;

&lt;p&gt;While chasing this, we left &lt;code&gt;outputCategoryMask&lt;/code&gt; enabled from an earlier experiment. The&lt;br&gt;
diagnostic itself became a significant part of the frame budget. Worth remembering when your&lt;br&gt;
measurements start shaping the thing you're measuring.&lt;/p&gt;

&lt;h2&gt;
  
  
  It was never the model
&lt;/h2&gt;

&lt;p&gt;With the GPU path honest, we profiled the Pixel 8 Pro's 9 fps. The model inference cost 6 ms.&lt;br&gt;
The other 126 ms were ours:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;66 ms&lt;/strong&gt; in a JavaScript loop recolouring pixels one at a time&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;two GPU→CPU readbacks&lt;/strong&gt; per frame, one of them costing 123 ms&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's the whole story of most "the ML model is too slow" complaints we've had since: the&lt;br&gt;
model was never the problem. The problem was everything we did around it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fix 1 — recolour in a fragment shader (66 ms → 0)
&lt;/h3&gt;

&lt;p&gt;The per-pixel JavaScript loop moved into a fragment shader. This is the obvious one, and it's&lt;br&gt;
free: the pixels are already on the GPU.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fix 2 — decouple the mask from the draw
&lt;/h3&gt;

&lt;p&gt;Hair does not move at 60 Hz. We were segmenting every frame for no reason.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;segmentForVideo&lt;/code&gt; is &lt;strong&gt;synchronous&lt;/strong&gt;, and on an Exynos it costs 437 ms of blocked main thread.&lt;br&gt;
Now we segment one frame in every &lt;code&gt;PASSO_SEG&lt;/code&gt;, and draw the video on all of them using the&lt;br&gt;
most recent mask. The step adapts to the device.&lt;/p&gt;

&lt;p&gt;This is the change that fixed the A56, and it's worth stressing: &lt;strong&gt;we expected to need a Web&lt;br&gt;
Worker and we didn't.&lt;/strong&gt; Decoupling cadence was enough.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fix 3 — share the WebGL context with MediaPipe (123 ms → 0)
&lt;/h3&gt;

&lt;p&gt;This was the big one. We were copying the mask out of MediaPipe and back into our own&lt;br&gt;
pipeline, round-tripping through the CPU.&lt;/p&gt;

&lt;p&gt;MediaPipe accepts a &lt;code&gt;canvas&lt;/code&gt; in its options. Pass it the same canvas you're drawing on, and&lt;br&gt;
you can take the mask with &lt;code&gt;getAsWebGLTexture()&lt;/code&gt; — it never leaves the GPU.&lt;/p&gt;

&lt;p&gt;One catch, learned the hard way: &lt;strong&gt;MediaPipe recycles that texture under your feet.&lt;/strong&gt; Binding&lt;br&gt;
it directly made the colour flicker. We copy it into our own texture with a shader, which is&lt;br&gt;
still enormously cheaper than a CPU round trip.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fix 4 — drop &lt;code&gt;preserveDrawingBuffer&lt;/code&gt; (5 ms per frame)
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;preserveDrawingBuffer: true&lt;/code&gt; costs real time on every single frame. We only needed it to&lt;br&gt;
capture a still of the current look, which is a rare user action.&lt;/p&gt;

&lt;p&gt;We defer the capture to the next draw call, within the same task, and read it there. Verified&lt;br&gt;
on device.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two more things worth knowing
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;MediaPipe will not start inside a &lt;code&gt;type: "module"&lt;/code&gt; worker.&lt;/strong&gt; It dies with&lt;br&gt;
&lt;code&gt;ModuleFactory not set&lt;/code&gt;, because its WASM loader registers the factory via &lt;code&gt;importScripts()&lt;/code&gt;,&lt;br&gt;
which doesn't exist in module workers. If you want MediaPipe in a worker, you need a classic&lt;br&gt;
worker — or you don't get one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Freeze the frame in a texture &lt;em&gt;before&lt;/em&gt; you segment.&lt;/strong&gt; If you segment first and draw after,&lt;br&gt;
the video has already advanced, and the hair lags behind the head permanently. Not&lt;br&gt;
occasionally. Always.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick the specialised model.&lt;/strong&gt; We were running &lt;code&gt;selfie_multiclass&lt;/code&gt;, which segments six classes&lt;br&gt;
when we needed one. Switching to the dedicated &lt;code&gt;hair_segmenter&lt;/code&gt;: 780 KB instead of 16 MB, and&lt;br&gt;
the official Pixel 6 figures are 58 ms vs 217 ms on CPU, 52 ms vs 71 ms on GPU. Our consumer&lt;br&gt;
APK dropped from 40.7 MB to 25.8 MB.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where we landed
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;132 ms → 17 ms per frame. 9 fps → 15 fps&lt;/strong&gt; on the Pixel 8 Pro, and 2 → 14 on the A56.&lt;/p&gt;

&lt;p&gt;You'll notice 17 ms per frame should mean roughly 58 fps, and we get 15. That's honest and&lt;br&gt;
worth explaining: the remaining ceiling is no longer in our code. It's in how the WebView is&lt;br&gt;
composited inside Flutter, and we haven't opened that front yet.&lt;/p&gt;

&lt;p&gt;We also kept the old CPU 2D path as a fallback for devices without WebGL2. That's not&lt;br&gt;
nostalgia — &lt;strong&gt;a canvas has exactly one context&lt;/strong&gt;. If you request WebGL and it fails, you can't&lt;br&gt;
fall back to 2D on the same canvas. You have to decide before you commit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;Four of the five wins here came from removing work we had added ourselves, not from making the&lt;br&gt;
model faster. And the single largest discovery — the silently empty GPU delegate — wasn't a&lt;br&gt;
performance problem at all. It was a correctness bug wearing a performance costume, and it was&lt;br&gt;
invisible until we measured the &lt;em&gt;output&lt;/em&gt; instead of the &lt;em&gt;duration&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;If you're building anything similar: instrument mask coverage, not just milliseconds. The&lt;br&gt;
frame that takes 5 ms might be the one doing nothing.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The engine described here ships in &lt;a href="https://miraviso.it/" rel="noopener noreferrer"&gt;Miraviso&lt;/a&gt;, a consultation tool for&lt;br&gt;
hair salons: the customer sees the colour and the haircut before the stylist starts. Happy to&lt;br&gt;
answer questions about the WebGL side in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webgl</category>
      <category>performance</category>
      <category>machinelearning</category>
      <category>android</category>
    </item>
  </channel>
</rss>
