<?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: Taka</title>
    <description>The latest articles on DEV Community by Taka (@y_taka_1d8059437251d47bcc).</description>
    <link>https://dev.to/y_taka_1d8059437251d47bcc</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%2F4021798%2F082c37c1-217a-4868-93bd-1c6b1ff19c0c.png</url>
      <title>DEV Community: Taka</title>
      <link>https://dev.to/y_taka_1d8059437251d47bcc</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/y_taka_1d8059437251d47bcc"/>
    <language>en</language>
    <item>
      <title>I built on-device workout rep counting in Flutter — here's what actually worked</title>
      <dc:creator>Taka</dc:creator>
      <pubDate>Wed, 08 Jul 2026 18:49:31 +0000</pubDate>
      <link>https://dev.to/y_taka_1d8059437251d47bcc/i-built-on-device-workout-rep-counting-in-flutter-heres-what-actually-worked-18ei</link>
      <guid>https://dev.to/y_taka_1d8059437251d47bcc/i-built-on-device-workout-rep-counting-in-flutter-heres-what-actually-worked-18ei</guid>
      <description>&lt;p&gt;I'm building &lt;a href="https://zen-labo.com/trainwiz/" rel="noopener noreferrer"&gt;TrainWiz&lt;/a&gt;, a Flutter app that turns real exercise into a pet-raising game: you do squats or push-ups, your phone counts the reps, and a little creature levels up and evolves. The core technical problem sounds trivial and absolutely is not: &lt;strong&gt;count reps from the camera, on-device, without uploading a single frame.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here's what broke along the way, and what finally worked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why on-device
&lt;/h2&gt;

&lt;p&gt;Two reasons: privacy and latency. A fitness camera that streams your body to a server is a non-starter for most people, and rep feedback has to feel instant or the whole "game" loop dies. So everything runs locally with &lt;code&gt;tflite_flutter&lt;/code&gt; + an on-device pose model — no footage ever leaves the phone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Naive attempt #1: joint-angle thresholds
&lt;/h2&gt;

&lt;p&gt;The obvious approach: track the knee angle, count a rep when it dips below X° and comes back up.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// looks fine in a demo, dies in the real world&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;kneeAngle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;angleBetween&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;knee&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ankle&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;_down&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;kneeAngle&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;_down&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_down&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;kneeAngle&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;160&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;reps&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;_down&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It demos beautifully. Then real users prop the phone on the floor, stand at an angle, and it falls apart.&lt;/p&gt;

&lt;p&gt;The trap: a phone camera gives you &lt;strong&gt;2D&lt;/strong&gt; pose. A "120° knee angle" flattens completely depending on where the camera sits — the same squat reads as 90° or 150° purely from perspective. Lifting to 3D via the model's &lt;code&gt;z&lt;/code&gt; doesn't save you either; monocular &lt;code&gt;z&lt;/code&gt; is noisy enough that the angle jitters across your threshold and double-counts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Naive attempt #2: a "body-line" gate
&lt;/h2&gt;

&lt;p&gt;Next idea: figure out &lt;em&gt;which&lt;/em&gt; exercise you're doing so I can pick the right signal. Standing (squat) vs. horizontal (push-up) should be easy — just check if shoulder, hip and heel form a straight line, right?&lt;/p&gt;

&lt;p&gt;Wrong, again for the 2D reason. In a real push-up shot from the front-corner, shoulder–hip–heel are &lt;strong&gt;not&lt;/strong&gt; collinear on the image plane — perspective bends them. I gated push-up counting on "body is a straight line" and it would just... stop counting mid-set. Nothing is more rage-inducing than an app that silently decides your perfectly good push-up doesn't count.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually worked
&lt;/h2&gt;

&lt;p&gt;Two changes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Stop trying to classify posture from geometry that perspective destroys.&lt;/strong&gt; Instead of "is the body a straight line", use the &lt;strong&gt;orientation of the torso axis&lt;/strong&gt; (vector from hips to shoulders). Vertical-ish → upright exercises, horizontal-ish → floor exercises. That single vector is far more robust to camera angle than any multi-joint colinearity check.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Make the rep signal swappable per exercise, and A/B it on the device.&lt;/strong&gt; There is no single "rep signal" that works for everything, and you cannot pick the right one from a spreadsheet — you have to watch it run on a real body in a real room. I put every candidate signal (torso displacement, normalized limb travel, angle-with-smoothing) behind a debug switch so I could flip between them live and see which one stayed honest.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The meta-lesson: &lt;strong&gt;treat rep counting as a signals problem, not a "compute the angle" problem.&lt;/strong&gt; 2D pose lies to you constantly; the robust features are the ones that survive an arbitrary camera position.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Flutter&lt;/strong&gt; (iOS + Android from one codebase)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;tflite_flutter&lt;/strong&gt; + on-device pose estimation — everything local, nothing uploaded&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Riverpod&lt;/strong&gt; for state, &lt;strong&gt;drift&lt;/strong&gt; (SQLite) as the local-first source of truth&lt;/li&gt;
&lt;li&gt;Cloud sync is fire-and-forget on top of local writes, so a rep counts instantly even offline&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaways if you're doing camera ML on mobile
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Assume &lt;strong&gt;2D perspective will break&lt;/strong&gt; any feature that depends on absolute angles or colinearity. Prefer relative orientation and displacement.&lt;/li&gt;
&lt;li&gt;Monocular &lt;code&gt;z&lt;/code&gt; from a pose model is a hint, not a measurement. Don't threshold on it.&lt;/li&gt;
&lt;li&gt;Build a &lt;strong&gt;debug switch to hot-swap your signal on-device&lt;/strong&gt;. The winning approach was not the one that looked best on paper — it was the one I could only find by flipping between them on a real rep.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want to see where it ended up, it's live here: &lt;a href="https://zen-labo.com/trainwiz/" rel="noopener noreferrer"&gt;TrainWiz&lt;/a&gt; (iOS + Android, free). Happy to answer anything about the pose pipeline in the comments.&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>machinelearning</category>
      <category>mobile</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
