<?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: m-naoki-m</title>
    <description>The latest articles on DEV Community by m-naoki-m (@m-naoki-m).</description>
    <link>https://dev.to/m-naoki-m</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%2F3988118%2F22f342ba-c2b8-4731-80b5-103b4ac578fe.png</url>
      <title>DEV Community: m-naoki-m</title>
      <link>https://dev.to/m-naoki-m</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/m-naoki-m"/>
    <language>en</language>
    <item>
      <title>jq, json.tool, or a browser formatter? How I pretty-print curl JSON</title>
      <dc:creator>m-naoki-m</dc:creator>
      <pubDate>Thu, 09 Jul 2026 05:21:59 +0000</pubDate>
      <link>https://dev.to/m-naoki-m/jq-jsontool-or-a-browser-formatter-how-i-pretty-print-curl-json-gbn</link>
      <guid>https://dev.to/m-naoki-m/jq-jsontool-or-a-browser-formatter-how-i-pretty-print-curl-json-gbn</guid>
      <description>&lt;h2&gt;
  
  
  The actual problem
&lt;/h2&gt;

&lt;p&gt;Every developer I know runs the same loop several times a day:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl https://api.example.com/some-endpoint
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response comes back as one minified line of JSON. You squint, scroll, give up, and reach for a formatter.&lt;/p&gt;

&lt;p&gt;I have ended up using three different formatters for this depending on the situation. None of these are novel — the interesting part is the &lt;em&gt;picking&lt;/em&gt;. Below is the boring rule of thumb I actually follow, and the redaction step most posts about this skip entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Approach 1: pipe through &lt;code&gt;jq&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;This is the default and almost always the right answer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://api.example.com/users | jq &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;✅ Fastest path to pretty output&lt;/li&gt;
&lt;li&gt;✅ Tells you where the parse fails with a column reference&lt;/li&gt;
&lt;li&gt;✅ Composable with filtering: &lt;code&gt;jq '.users[] | select(.active)'&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;❌ Requires &lt;code&gt;jq&lt;/code&gt; to be installed (not guaranteed on minimal containers or fresh CI)&lt;/li&gt;
&lt;li&gt;❌ The filter mini-language has a learning curve&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When to &lt;em&gt;not&lt;/em&gt; reach for jq first: when you only need to read the response and you are already about to paste it somewhere else (a Slack thread, a GitHub issue, a ticket). At that point you are leaving the terminal anyway and the savings are marginal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Approach 2: Python &lt;code&gt;json.tool&lt;/code&gt; as a fallback
&lt;/h2&gt;

&lt;p&gt;When &lt;code&gt;jq&lt;/code&gt; is inconvenient to install — locked-down CI runners, remote SSH on a server you do not own, minimal Alpine images where you happen to have Python but not jq — the stdlib has you covered:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://api.example.com/users | python3 &lt;span class="nt"&gt;-m&lt;/span&gt; json.tool
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;✅ Often available where installing &lt;code&gt;jq&lt;/code&gt; is inconvenient&lt;/li&gt;
&lt;li&gt;✅ Sufficient for pretty printing + parse validation&lt;/li&gt;
&lt;li&gt;❌ Error messages are less precise than &lt;code&gt;jq&lt;/code&gt; on truncated input&lt;/li&gt;
&lt;li&gt;❌ No filter / select capability — it formats, that's it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I use this maybe twice a week. It is the "good enough" tool, not the "right" tool. Note that Python is not actually guaranteed either — distroless images and BusyBox containers ship with neither.&lt;/p&gt;

&lt;h2&gt;
  
  
  Approach 3: a browser-side formatter for the share step
&lt;/h2&gt;

&lt;p&gt;I only paste curl output into a browser formatter when I am about to share the result with a teammate (Slack, GitHub issue, ticket review), &lt;em&gt;and&lt;/em&gt; the payload is safe for that audience.&lt;/p&gt;

&lt;p&gt;The reason that condition matters: a security firm disclosed last year that two popular online formatters had been retaining 80,000+ saved snippets, including AWS keys and JWTs. I covered the story separately in &lt;a href="https://dev.to/m-naoki-m/what-the-json-formatter-leaks-taught-me-about-browser-side-tools-38ao"&gt;What the JSON Formatter Leaks Taught Me About Browser-Side Tools&lt;/a&gt;; here I only care about the practical rule it implies. &lt;strong&gt;Pick a static-site formatter that does no server-side conversion, verify in DevTools that no outgoing request carries your payload, and accept that this is something you re-verify per session and per build — not a permanent brand promise.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For my own sharing slot I use &lt;a href="https://formatarc.com/en/json-formatter/" rel="noopener noreferrer"&gt;FormatArc's JSON formatter&lt;/a&gt;. It is a static site with no &lt;code&gt;/api/*&lt;/code&gt; routes, the parser core is open source (&lt;a href="https://github.com/m-naoki-m/formatarc" rel="noopener noreferrer"&gt;repo&lt;/a&gt;), and the no-upload claim is checkable in your own DevTools before you paste anything real.&lt;/p&gt;

&lt;h2&gt;
  
  
  Redact before you share
&lt;/h2&gt;

&lt;p&gt;This is the step most "format your curl response" posts leave out. Even when the payload looks innocent, it likely contains things that should not survive into a public Slack channel or a GitHub issue: API endpoints, internal IDs, error stack traces with file paths, customer references.&lt;/p&gt;

&lt;p&gt;The pragmatic approach is to strip known secret-bearing keys with &lt;code&gt;jq&lt;/code&gt; before you paste anywhere:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Drop the obvious secrets at the top level&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://api.example.com/users &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="s1"&gt;'del(.token, .password, .authorization)'&lt;/span&gt;

&lt;span class="c"&gt;# Walk the entire tree and drop them at every depth&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://api.example.com/users &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="s1"&gt;'walk(if type == "object"
             then del(.password, .token, .secret, .authorization)
             else . end)'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second form catches secrets nested inside response bodies (&lt;code&gt;data[].credentials.token&lt;/code&gt;, &lt;code&gt;result.session.authorization&lt;/code&gt;, etc). I have it as a shell alias because I want zero friction between "I should redact this" and actually doing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  A picking heuristic
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Local terminal, exploring response shape&lt;/td&gt;
&lt;td&gt;&lt;code&gt;jq&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Local terminal, need to filter or transform&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;jq&lt;/code&gt; (or &lt;code&gt;mlr&lt;/code&gt; if the data is tabular)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CI / Docker without &lt;code&gt;jq&lt;/code&gt; (but with Python)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;python3 -m json.tool&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pasting to share — already redacted, public-safe&lt;/td&gt;
&lt;td&gt;browser formatter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pasting to share — payload contains anything internal&lt;/td&gt;
&lt;td&gt;redact in &lt;code&gt;jq&lt;/code&gt; first&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Large file (&amp;gt; 100 MB)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;jq --stream&lt;/code&gt; or DuckDB, not a browser&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For most of these rows, &lt;code&gt;jq&lt;/code&gt; is the answer. The "switching between three" framing is mostly an excuse to write down the boundaries — that is where I see people lose time.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I am explicitly not claiming
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;jq&lt;/code&gt; is not "my discovery." It has been the default in this niche for over a decade. &lt;code&gt;python3 -m json.tool&lt;/code&gt; is well-known. The only thing I am asserting here is that the &lt;em&gt;boundaries between these three approaches&lt;/em&gt;, plus the redaction step, are where the friction actually lives.&lt;/p&gt;

&lt;p&gt;A few honest limits worth flagging:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A browser-side formatter is not immune to leaks — the threat model still includes XSS in the parser, malicious extensions reading the DOM, supply-chain hits on npm deps. The browser is a smaller blast radius than a server-side formatter, not a zero one. Re-verify the no-upload claim in DevTools per session or build; do not trust it as branding.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;python3 -m json.tool&lt;/code&gt; is the &lt;em&gt;fallback&lt;/em&gt;. If &lt;code&gt;jq&lt;/code&gt; is reachable, prefer &lt;code&gt;jq&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try them
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;jq&lt;/code&gt;: &lt;a href="https://stedolan.github.io/jq/" rel="noopener noreferrer"&gt;https://stedolan.github.io/jq/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;FormatArc JSON formatter: &lt;a href="https://formatarc.com/en/json-formatter/" rel="noopener noreferrer"&gt;https://formatarc.com/en/json-formatter/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a deeper dive on why JSON specs disallow trailing commas or comments (with cross-parser verification against GitHub, marked, and remark-gfm), see &lt;a href="https://formatarc.com/en/blog/json-comments/" rel="noopener noreferrer"&gt;Are JSON comments allowed? RFC 8259 and 4 fixes&lt;/a&gt;. It is the article Google's AI Overview cites when asked about ECMA-404 / RFC 8259 comment rules.&lt;/p&gt;

&lt;p&gt;If you have a redaction recipe better than the &lt;code&gt;walk(...)&lt;/code&gt; filter above — especially for nested envelopes from common API frameworks (Laravel, Rails, NestJS, Spring) — I would like to read it.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>devtools</category>
      <category>productivity</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Building an Orthodontic Progress App That Never Uploads Your Face</title>
      <dc:creator>m-naoki-m</dc:creator>
      <pubDate>Sat, 04 Jul 2026 14:09:19 +0000</pubDate>
      <link>https://dev.to/m-naoki-m/building-an-orthodontic-progress-app-that-never-uploads-your-face-53g6</link>
      <guid>https://dev.to/m-naoki-m/building-an-orthodontic-progress-app-that-never-uploads-your-face-53g6</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffyfule50ukt9d58ws3a8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffyfule50ukt9d58ws3a8.png" alt="Vision Framework pipeline that extracts only the mouth region from a face photo" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I recently shipped &lt;strong&gt;SmileProgress&lt;/strong&gt; (Japanese name: 矯正ログ / &lt;em&gt;Kyousei Log&lt;/em&gt;), an indie iOS app for people going through orthodontic treatment — braces, aligners, and everything in between. The core promise is unusual: you can track months or years of progress &lt;strong&gt;without ever putting a photo of your face on a server&lt;/strong&gt;. Not mine. Not iCloud. Not anyone's.&lt;/p&gt;

&lt;p&gt;This post is a technical write-up of the on-device anonymization pipeline that makes that promise possible. It uses only Apple's built-in frameworks — &lt;code&gt;Vision&lt;/code&gt;, &lt;code&gt;ImageIO&lt;/code&gt;, &lt;code&gt;CoreImage&lt;/code&gt; — and stores everything in the user's local &lt;code&gt;Documents&lt;/code&gt; directory.&lt;/p&gt;

&lt;p&gt;If you're building anything privacy-sensitive on iOS and you want a working example that goes beyond "we hash your email," I hope this is useful.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the constraint matters
&lt;/h2&gt;

&lt;p&gt;Orthodontic treatment takes 1–3 years. People want to see the change, but they almost never want to publish "open your mouth wide" photos of their own face to a cloud service. In the Japanese &lt;em&gt;矯正垢&lt;/em&gt; (orthodontic accounts) community on Instagram and X, users routinely crop, blur, or paint over everything except the teeth before sharing.&lt;/p&gt;

&lt;p&gt;So the design constraint was:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The app records mouth photos for years, but never uploads the raw photo&lt;/li&gt;
&lt;li&gt;Only the &lt;strong&gt;mouth region&lt;/strong&gt; — cropped, stripped of metadata — can be shared, and only when the user explicitly opts in&lt;/li&gt;
&lt;li&gt;No account. No sync server. No cloud storage bill I have to pay every month as an indie developer&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All three requirements point at the same technical answer: do the anonymization on the device, right after capture.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4ptdmvxtfl6nc1xw9tk5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4ptdmvxtfl6nc1xw9tk5.png" alt="Diagram: capture, face detection, mouth crop, and EXIF/GPS stripping all happen inside the iPhone. No raw photo is ever uploaded to any server." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The pipeline in one picture
&lt;/h2&gt;

&lt;p&gt;The full flow inside the phone looks like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;AVCaptureSession&lt;/code&gt; grabs a frame from the camera&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;VNDetectFaceLandmarksRequest&lt;/code&gt; finds the face and its landmarks&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;outerLips.normalizedPoints&lt;/code&gt; gives us the mouth contour&lt;/li&gt;
&lt;li&gt;We compute a square crop with a 1.6× margin&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;CGImageDestination&lt;/code&gt; writes a fresh JPEG with &lt;strong&gt;no&lt;/strong&gt; EXIF / GPS / TIFF metadata&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PhotoStore.add(...)&lt;/code&gt; saves it into &lt;code&gt;Documents/photos/&lt;/code&gt; with a JSON sidecar&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Nothing in that list touches the network.&lt;/p&gt;

&lt;h2&gt;
  
  
  Detecting only the mouth
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;VNDetectFaceLandmarksRequest&lt;/code&gt; returns all facial landmarks — eyes, nose, jaw, pupils, and (importantly for us) the outer lip contour.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;import&lt;/span&gt; &lt;span class="kt"&gt;Vision&lt;/span&gt;

&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;detectMouth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nv"&gt;image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;UIImage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;CGPoint&lt;/span&gt;&lt;span class="p"&gt;]?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;cgImage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cgImage&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;withCheckedContinuation&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;continuation&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;request&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;VNDetectFaceLandmarksRequest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;revision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;VNDetectFaceLandmarksRequestRevision3&lt;/span&gt;

        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;handler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;VNImageRequestHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nv"&gt;cgImage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cgImage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nv"&gt;orientation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cgImageOrientation&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perform&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
            &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;face&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;first&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                  &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;outerLips&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;face&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;landmarks&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;outerLips&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;continuation&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resume&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;returning&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="n"&gt;continuation&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resume&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;returning&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;outerLips&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;normalizedPoints&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;continuation&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resume&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;returning&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&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;Two things are worth calling out in that snippet.&lt;/p&gt;

&lt;p&gt;First, &lt;code&gt;revision = VNDetectFaceLandmarksRequestRevision3&lt;/code&gt; (available iOS 15+) is a huge accuracy jump over the older revisions. If you're serious about landmark work, don't rely on the default revision — pin the newest one that ships with your minimum target.&lt;/p&gt;

&lt;p&gt;Second, &lt;code&gt;outerLips.normalizedPoints&lt;/code&gt; is &lt;em&gt;not&lt;/em&gt; in image coordinates. It's normalized to the detected &lt;strong&gt;face&lt;/strong&gt; bounding box. Which brings us to the next surprise.&lt;/p&gt;

&lt;h2&gt;
  
  
  The coordinate system will trip you up
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;outerLips.normalizedPoints&lt;/code&gt; gives you &lt;code&gt;(x, y)&lt;/code&gt; in the range &lt;code&gt;0..1&lt;/code&gt;, but that range is measured relative to the face bounding box. And Vision uses a bottom-left origin, while &lt;code&gt;UIImage&lt;/code&gt; and &lt;code&gt;CGImage&lt;/code&gt; use top-left. Convert both:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;faceBBox&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;face&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;boundingBox&lt;/span&gt;                 &lt;span class="c1"&gt;// normalized to full image, 0..1&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;imgWidth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;CGFloat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cgImage&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;imgHeight&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;CGFloat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cgImage&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;height&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;denormalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;CGPoint&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;CGPoint&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;faceBBox&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;origin&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;faceBBox&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;imgWidth&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;faceBBox&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;origin&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;faceBBox&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;height&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;imgHeight&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kt"&gt;CGPoint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;lipPointsInImage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;outerLips&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;normalizedPoints&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;denormalize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Y-axis flip is the classic Vision gotcha. Every couple of months I forget it and spend twenty minutes debugging a phantom rotation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 1.6× margin trick
&lt;/h2&gt;

&lt;p&gt;Now we have the lip contour in image pixels. The naive move is: bounding-box the points and crop exactly that rectangle. That looks awful. A raw lip crop with no surrounding skin feels clinical and slightly uncanny.&lt;/p&gt;

&lt;p&gt;I experimented with margins from 1.0× to 2.4× and settled on 1.6× — enough to include a strip of skin, chin, and philtrum, not so much that it starts leaking useful identifying features:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;xs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lipPointsInImage&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(\&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;ys&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lipPointsInImage&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(\&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;minX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;xs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;maxX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;xs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
      &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;minY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ys&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;maxY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ys&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;lipBBox&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;CGRect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nv"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;minX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;minY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nv"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;maxX&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;minX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;maxY&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;minY&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;center&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;CGPoint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;lipBBox&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;midX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;lipBBox&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;midY&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;side&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lipBBox&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;1.6&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;squareRect&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;CGRect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nv"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;center&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;side&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nv"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;center&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;side&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nv"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;side&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;side&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cropping to a square (not the lip aspect ratio) matters for a later feature — a Before/After slider that only works when frames share dimensions.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the mouth is wide open, landmarks fly off
&lt;/h2&gt;

&lt;p&gt;Real orthodontic photos are almost always taken &lt;em&gt;with the mouth wide open&lt;/em&gt; to expose the teeth. Vision's landmark model was clearly trained on more neutral expressions, and when the jaw drops significantly, &lt;code&gt;outerLips&lt;/code&gt; can return points that are 20–30% off the actual lip line.&lt;/p&gt;

&lt;p&gt;The heuristic I ended up with is a corner-angle check: if the left and right corners of the lip contour form an angle that couldn't plausibly be a mouth on a roughly upright face, fall back to a different detector.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;leftCorner&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lipPointsInImage&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;by&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;rightCorner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lipPointsInImage&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;by&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;angle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;atan2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;rightCorner&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;leftCorner&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;rightCorner&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;leftCorner&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;angle&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pi&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fallbackDetection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cgImage&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;The fallback path uses simple HSV color analysis — detecting the teeth (bright, slightly blue-shifted white) and the lip (red-shifted) directly in pixel space. It's lower quality than Vision, but it recovers cases where &lt;code&gt;VNDetectFaceLandmarksRequest&lt;/code&gt; returns garbage or nothing at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Close-up shots have no face at all
&lt;/h2&gt;

&lt;p&gt;The other reality check: users import old photos from their camera roll, and orthodontic patients tend to shoot &lt;strong&gt;super close&lt;/strong&gt; — just the mouth, no face in frame. In that case &lt;code&gt;VNDetectFaceLandmarksRequest&lt;/code&gt; returns zero results, and no amount of tweaking will fix it.&lt;/p&gt;

&lt;p&gt;So the whole detector is a two-stage cascade:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;detectMouth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nv"&gt;image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;UIImage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;MouthLandmarks&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// 1. Vision (works when a full face is visible)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;landmarks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;detectViaVision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;landmarks&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// 2. Color-analysis fallback (works on close-up shots)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;detectViaColorAnalysis&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;image&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;h2&gt;
  
  
  Stripping EXIF, GPS, and TIFF metadata
&lt;/h2&gt;

&lt;p&gt;Anonymizing the pixels is only half the job. iPhone photos ship with EXIF (timestamp, exposure), TIFF (device model, software version), and GPS (home address, to be blunt) blocks embedded in the file. If you &lt;code&gt;UIImage.jpegData(compressionQuality:)&lt;/code&gt; and write that, all of it gets carried along.&lt;/p&gt;

&lt;p&gt;The correct move is to skip &lt;code&gt;UIImage&lt;/code&gt; entirely and use &lt;code&gt;ImageIO&lt;/code&gt;'s &lt;code&gt;CGImageDestination&lt;/code&gt;, then simply omit every metadata dictionary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;import&lt;/span&gt; &lt;span class="kt"&gt;ImageIO&lt;/span&gt;
&lt;span class="kd"&gt;import&lt;/span&gt; &lt;span class="kt"&gt;MobileCoreServices&lt;/span&gt;

&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;writeAnonymizedJPEG&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;cgImage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;CGImage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="nv"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throws&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;destination&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;CGImageDestinationCreateWithURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kt"&gt;CFURL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;kUTTypeJPEG&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="kc"&gt;nil&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="kt"&gt;AnonymizationError&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;destinationCreationFailed&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;CFString&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="nv"&gt;kCGImageDestinationLossyCompressionQuality&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.92&lt;/span&gt;
        &lt;span class="c1"&gt;// deliberately no Exif / GPS / TIFF dictionaries&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="kt"&gt;CGImageDestinationAddImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;cgImage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kt"&gt;CFDictionary&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="kt"&gt;CGImageDestinationFinalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="kt"&gt;AnonymizationError&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;finalizeFailed&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;I also added a self-test that reads the JPEG back and asserts every metadata dictionary is &lt;code&gt;nil&lt;/code&gt;. In DEBUG builds, this runs after every save. It's cheap paranoia and it caught a real regression once when a refactor accidentally started passing the source properties through.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;assertNoMetadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throws&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;CGImageSourceCreateWithURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kt"&gt;CFURL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
          &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;props&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;CGImageSourceCopyPropertiesAtIndex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;as?&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;CFString&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="kt"&gt;AnonymizationError&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;readbackFailed&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;props&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;kCGImagePropertyExifDictionary&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;props&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;kCGImagePropertyGPSDictionary&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;props&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;kCGImagePropertyTIFFDictionary&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;nil&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;h2&gt;
  
  
  The full pipeline
&lt;/h2&gt;

&lt;p&gt;Chained together:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;anonymizeAndSave&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nv"&gt;image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;UIImage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="nv"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;URL&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;throws&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// 1. Normalize orientation so pixel space matches display space&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;normalized&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fixedOrientation&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="c1"&gt;// 2. Detect the mouth (Vision, then color-analysis fallback)&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;landmarks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;detectMouth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;in&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;normalized&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="kt"&gt;AnonymizationError&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mouthNotDetected&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// 3. Compute a square with 1.6× margin around the lip bbox&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;square&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;squareRect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;landmarks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;// 4. Crop&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;cgImage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;normalized&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cgImage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;cropped&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cgImage&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cropping&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;square&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="kt"&gt;AnonymizationError&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cropFailed&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// 5. Write a fresh JPEG with no EXIF / GPS / TIFF&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="nf"&gt;writeAnonymizedJPEG&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;cgImage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cropped&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;// 6. Verify (DEBUG only)&lt;/span&gt;
    &lt;span class="cp"&gt;#if DEBUG&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="nf"&gt;assertNoMetadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="cp"&gt;#endif&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Six steps, all local. There is nowhere in this pipeline where the raw photo — the one with the face still in it — leaves the phone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why on-device is the right default for an indie
&lt;/h2&gt;

&lt;p&gt;I'll be honest: I initially considered a Firebase / Supabase-backed version because it would have been faster to prototype. Two things pushed me back to on-device only.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost predictability.&lt;/strong&gt; Orthodontic photos are chunky (multi-megabyte JPEGs) and each user might store hundreds or thousands over a treatment. As an indie developer, "photo storage that scales with your active users" is exactly the kind of open-ended monthly bill I do not want. On-device storage means my hosting costs are effectively my &lt;code&gt;smileprogress.com&lt;/code&gt; marketing site.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trust as a product feature.&lt;/strong&gt; In the orthodontic community, "does this app leak my face photos?" is a live question. Every legacy app in the space has had at least one review complaining about the cloud requirement. Building the entire app around "we cannot upload your raw photos even if we wanted to" turned out to be the strongest positioning I've had for any indie project so far.&lt;/p&gt;

&lt;p&gt;The tradeoff — and it is a real one — is that features requiring backend state (multi-device sync, remote backup, shareable galleries) are all harder. I address the shareable-gallery case by making the sharing action extremely explicit: user taps &lt;em&gt;Share&lt;/em&gt;, gets a preview of exactly what will be sent, has to accept a Terms + Privacy modal, and only &lt;em&gt;then&lt;/em&gt; the already-anonymized mouth crop is uploaded. The face never leaves the device at any point in that path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try SmileProgress
&lt;/h2&gt;

&lt;p&gt;The app is called &lt;strong&gt;SmileProgress&lt;/strong&gt; (Japanese: 矯正ログ) and it's shipping on the App Store today, initially Japan-first and expanding to other regions:&lt;/p&gt;

&lt;p&gt;App Store: &lt;a href="https://apps.apple.com/app/id6780383136" rel="noopener noreferrer"&gt;https://apps.apple.com/app/id6780383136&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It's a one-time purchase — currently a launch price of ¥160 in Japan (regional equivalent in other App Store regions) until 2026-12-31 — with no subscription, no ads, and no cloud-backed features hidden behind a future paywall. Once you own the app, the record / anonymize / compare / anonymous-share flow is yours to keep.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fajpoyxq41i1x0xsrxy9f.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fajpoyxq41i1x0xsrxy9f.png" alt="SmileProgress app icon" width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you're building something in a privacy-adjacent space and want to compare notes on on-device pipelines, App Attest for anonymous auth, or &lt;code&gt;output: "export"&lt;/code&gt; static SSG for a companion gallery site, I'd love to hear from you in the comments. Individual developer, so I read everything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recap
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;VNDetectFaceLandmarksRequest&lt;/code&gt; (revision 3) gives you &lt;code&gt;outerLips.normalizedPoints&lt;/code&gt;, but they're normalized to the face bounding box and use a bottom-left origin&lt;/li&gt;
&lt;li&gt;A 1.6× margin around the lip bounding box strikes a decent balance between "clinical crop" and "leaks features"&lt;/li&gt;
&lt;li&gt;Wide-open mouths break Vision — cascade to a color-analysis fallback for close-ups&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;CGImageDestination&lt;/code&gt; with no metadata dictionaries is the reliable way to strip EXIF/GPS/TIFF; a DEBUG self-test catches regressions&lt;/li&gt;
&lt;li&gt;Six steps, all on-device, zero network traffic for the raw photo&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Thanks for reading. Happy shipping.&lt;/p&gt;

</description>
      <category>swift</category>
      <category>ios</category>
      <category>privacy</category>
      <category>indiedev</category>
    </item>
    <item>
      <title>What the JSON Formatter Leaks Taught Me About Browser-Side Tools</title>
      <dc:creator>m-naoki-m</dc:creator>
      <pubDate>Wed, 24 Jun 2026 01:54:14 +0000</pubDate>
      <link>https://dev.to/m-naoki-m/what-the-json-formatter-leaks-taught-me-about-browser-side-tools-38ao</link>
      <guid>https://dev.to/m-naoki-m/what-the-json-formatter-leaks-taught-me-about-browser-side-tools-38ao</guid>
      <description>&lt;h2&gt;
  
  
  A formatter leak that should change how you pick tools
&lt;/h2&gt;

&lt;p&gt;In late November 2025, security firm watchTowr published a disclosure about two of the most popular online JSON formatter sites — jsonformatter.org and codebeautify.org. Both offered a "Recent Links" feature that let users share formatted output with a teammate. The URLs were predictable, which meant the saved content was effectively reachable to anyone who guessed the URL pattern.&lt;/p&gt;

&lt;p&gt;Per the watchTowr report, the result was &lt;strong&gt;over 80,000 saved snippets, totaling 5 GB+ of data&lt;/strong&gt;, sitting on the public internet without authentication. The content that researchers were able to retrieve included:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Active Directory credentials&lt;/li&gt;
&lt;li&gt;Cloud access keys (AWS / Azure / GCP)&lt;/li&gt;
&lt;li&gt;Private keys and CI/CD secrets&lt;/li&gt;
&lt;li&gt;JWTs from production systems&lt;/li&gt;
&lt;li&gt;AWS Secrets Manager exports&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Based on the content of individual snippets, the data appeared to originate from a wide range of organizations — government, finance, healthcare, aerospace. Full disclosure: &lt;a href="https://labs.watchtowr.com/stop-putting-your-passwords-into-random-websites-yes-seriously-you-are-the-problem/" rel="noopener noreferrer"&gt;watchTowr — "Stop Putting Your Passwords Into Random Websites"&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Two things worth flagging up front:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The vulnerable surface was the &lt;strong&gt;saved-and-shared&lt;/strong&gt; feature, not every act of pasting into the formatter. Plenty of online formatters do not retain saved content, and some have already responded to the disclosure. It is not a blanket "all online tools are leaking" story.&lt;/li&gt;
&lt;li&gt;The lesson generalizes anyway: if a tool &lt;em&gt;can&lt;/em&gt; persist your input on its servers, your threat model has to assume it &lt;em&gt;might&lt;/em&gt;, until proven otherwise.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  How to actually verify what a formatter does
&lt;/h2&gt;

&lt;p&gt;Most developers I know pick a formatter the same way they pick a coffee shop: whichever is closest, whichever rendered first. The leak suggests we should be slightly more deliberate. A few questions you can answer in under a minute per tool:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Open DevTools → Network, paste your input, hit format.&lt;/strong&gt; Does any outgoing request carry a payload that contains your input or the converted output? If yes, your data left the browser. Hostnames matter — first-party analytics pings are different from a &lt;code&gt;POST /api/format&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is there a "save and share" / "recent links" / "history" feature?&lt;/strong&gt; If yes, your input is probably being stored, even if the URL looks ephemeral.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is the conversion logic open source?&lt;/strong&gt; Not the marketing site — the actual parser. A static site that links to its own source repo is much easier to verify than a closed page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is there a CLI version with the same engine?&lt;/strong&gt; If yes, you can usually verify the parser end-to-end with offline tools (&lt;code&gt;strace&lt;/code&gt;, &lt;code&gt;tcpdump&lt;/code&gt;, sandboxed Docker run) before trusting the web version.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is a generic checklist. It is not specific to any tool, including mine.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I built, and what I am not claiming
&lt;/h2&gt;

&lt;p&gt;For my own use I built &lt;a href="https://formatarc.com" rel="noopener noreferrer"&gt;FormatArc&lt;/a&gt; — a converter for JSON, YAML, CSV, Markdown, and HTML. The architecture choices are not novel; they just happen to make some of the questions above answerable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Next.js with &lt;code&gt;output: "export"&lt;/code&gt; — pure static site, no server runtime&lt;/li&gt;
&lt;li&gt;All parsing in the browser (&lt;code&gt;JSON.parse&lt;/code&gt;, &lt;code&gt;js-yaml&lt;/code&gt;, &lt;code&gt;papaparse&lt;/code&gt;, &lt;code&gt;marked&lt;/code&gt;, &lt;code&gt;turndown&lt;/code&gt;, &lt;code&gt;remark&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;No &lt;code&gt;/api/*&lt;/code&gt; routes in the project (you can grep &lt;a href="https://github.com/m-naoki-m/formatarc" rel="noopener noreferrer"&gt;&lt;code&gt;app/&lt;/code&gt;&lt;/a&gt; on the CLI repo for the same parser core)&lt;/li&gt;
&lt;li&gt;No persistence of input to &lt;code&gt;localStorage&lt;/code&gt; or &lt;code&gt;IndexedDB&lt;/code&gt; by design — input lives in React state and goes away with the tab&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What I will &lt;em&gt;not&lt;/em&gt; claim:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"Zero network requests."&lt;/strong&gt; Open DevTools and you will see chunk loads, fonts, the Next.js prefetch, and so on. The honest claim is narrower: &lt;em&gt;no outgoing request whose payload contains the pasted input or the converted output&lt;/em&gt;. That is the one that matters for the leak threat model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Works offline."&lt;/strong&gt; I have not shipped a Service Worker, so a real offline guarantee does not exist. The right phrasing is "no upload of your data," not "works without network."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Bulletproof."&lt;/strong&gt; Browser-side tools have their own failure modes — XSS in a parser, a compromised CDN, a malicious browser extension reading the DOM, a supply-chain hit on an npm dep. None of these are eliminated by removing the backend. The browser is a smaller blast radius, not zero.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A comparison that I think is actually verifiable
&lt;/h2&gt;

&lt;p&gt;I tried to write this table in terms you can verify yourself rather than self-flattering bullet points:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;FormatArc&lt;/th&gt;
&lt;th&gt;jsonformatter.org&lt;/th&gt;
&lt;th&gt;codebeautify.org&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Static site (no server-side conversion)&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Saved-and-share / history feature&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Conversion engine open source&lt;/td&gt;
&lt;td&gt;✅ (&lt;a href="https://github.com/m-naoki-m/formatarc" rel="noopener noreferrer"&gt;repo&lt;/a&gt;)&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Same engine available as a CLI&lt;/td&gt;
&lt;td&gt;✅ (&lt;code&gt;npx formatarc&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multi-format (JSON + YAML + CSV + Markdown + HTML)&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;partial&lt;/td&gt;
&lt;td&gt;partial&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If any cell is wrong by the time you read this, file an issue — I would rather correct it than carry a stale claim. The two competitor sites do change their feature sets.&lt;/p&gt;

&lt;h2&gt;
  
  
  When CLI tools are still the right answer
&lt;/h2&gt;

&lt;p&gt;For shell pipelines, CI scripts, and anything bigger than a paste window, dedicated CLIs are usually still the right tool. &lt;code&gt;jq&lt;/code&gt;, &lt;code&gt;yq&lt;/code&gt;, &lt;code&gt;csvkit&lt;/code&gt;, &lt;code&gt;mlr&lt;/code&gt;, and &lt;code&gt;pandoc&lt;/code&gt; cover the structured-query and large-file cases better than any browser tool. FormatArc is for the interactive, paste-and-go work where you specifically don't want the input ending up on someone else's server.&lt;/p&gt;

&lt;p&gt;For the overlap case, you can use the same parser core from the CLI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# One-shot convert&lt;/span&gt;
npx formatarc json-format &lt;span class="s1"&gt;'{"name":"FormatArc","tools":3}'&lt;/span&gt;

&lt;span class="c"&gt;# Pipe-friendly&lt;/span&gt;
&lt;span class="nb"&gt;cat &lt;/span&gt;config.yaml | npx formatarc yaml-to-json &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; config.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A note on &lt;code&gt;npx&lt;/code&gt;: it is convenient but not a security guarantee on its own. Pin a version, audit the dependency tree, or install from source if you are running this inside a sensitive environment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it, break it, tell me
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Web: &lt;a href="https://formatarc.com" rel="noopener noreferrer"&gt;https://formatarc.com&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Source: &lt;a href="https://github.com/m-naoki-m/formatarc" rel="noopener noreferrer"&gt;https://github.com/m-naoki-m/formatarc&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you find a case where the verification claims above do not hold — a request that leaks input, an edge case where the parser misbehaves, a YAML 1.1 vs 1.2 inconsistency — open an issue. I would rather hear it from you than learn it the hard way.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>security</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
