Inspiration:

I've been nearsighted for nine years. My prescription doubled over that time, to −6.00, and it got there because of habits nobody ever told me were habits, reading too close, never resting my eyes, hours of close work with no breaks. By the time a prescription tells you something is wrong, the thing that caused it happened months earlier.

Recently my younger brother was diagnosed with high myopia. Watching that start made me want to build something that catches the behavior rather than the consequence, using hardware that's already in everyone's pocket.

The specific insight: the same TrueDepth camera that unlocks an iPhone can measure two things that matter clinically. How completely you blink, which is tied to dry eye. And how far your face is from the screen, which is tied to myopia progression. Both are near-work habits. Both are invisible to the person doing them.

What it does:

NearWork runs a one-minute reading task. You read a passage on screen while the Face ID camera samples your eyelid position 60 times a second and measures your face-to-screen distance. At the end you get three numbers: your blink rate, what fraction of your blinks fully closed, and your typical reading distance.

Then it does something about it. It coaches you through the blink exercise from the clinical literature, close, squeeze, open, fifteen times, and uses the camera to verify each rep, checking both that your eyes closed and that you held the squeeze. If your symptom score crosses the threshold, it hands you a list of 25 real eye care practices near you, every address and phone number verified by hand against the clinic's own website, with a number that dials.

Apple's Screen Distance feature warns you when the phone is too close. NearWork measures how close, measures the blinking Apple ignores entirely, coaches the exercise that fixes it, and tracks whether any of it is working.

How I built it:

Native iOS, Swift and SwiftUI, no backend.

Measurement. ARFaceTrackingConfiguration gives an ARFaceAnchor per frame at 60 Hz. Eyelid closure comes from the eyeBlinkLeft / eyeBlinkRight blendshapes, continuous values from 0 to 1. Face-to-camera distance comes from transforming the face anchor into camera space and taking the length of the translation column:

swift let t = simd_mul(simd_inverse(frame.camera.transform), face.transform) let cm = Double(simd_length(SIMD3(t.columns.3.x, t.columns.3.y, t.columns.3.z))) * 100

Blink detection is a hysteresis state machine, onset at 0.40, release at 0.2, recording the peak closure value of each blink. For the training exercise I accumulate hold time from frame timestamps, so a rep only counts if closure exceeds threshold and is sustained past 0.8 seconds.

Architecture. Sample fast, publish slow. The tracker processes every frame but only publishes to SwiftUI every 6th, because publishing at 60 Hz rebuilds the view hierarchy sixty times a second and broke the app in ways that took me a day to diagnose.

Persistence is JSON in the app's sandbox. No accounts, no network calls, no analytics, no third-party SDKs. Camera frames are never stored or transmitted, only derived scalars survive the frame they came from.

Stats are pooled rather than averaged per-scan, with a binomial margin reported alongside:

$$\text{margin} = 1.96\sqrt{\frac{p(1-p)}{n}}$$

At 5 blinks that margin is ±43 points, which is why the app flags any scan under 10 blinks as imprecise instead of quietly reporting a number.

Challenges I ran into:

The hardest one wasn't code, my main measurement was wrong.

I scored a blink "incomplete" if closure didn't pass a fixed threshold of 0.88, which I'd set by watching my own deliberate hard blinks. Applied to natural blinks across 12 participants it reported 74% incomplete, including one person blinking at a perfectly normal 24 times a minute. That isn't a finding, it's an artifact.

Early on I'd decided to store the raw peak of every blink rather than just the final percentage. That decision saved the project. I re-scored all 90 recorded blinks at different thresholds without collecting anything new:

Threshold Incomplete 0.70 22% 0.80 44% 0.88 74% 0.90 83%

A 0.20 move in an arbitrary constant swung the result 61 points. The number was measuring my constant, not anyone's eyelids. The reason: per-person maximum closure ranged from 0.55 to 0.95, ARKit's blendshape is not an absolute scale. Re-scoring against each person's own maximum gave 25%.

I did not ship a fix. Per-person calibration needs its own validation, and replacing one arbitrary constant with a second one isn't a solution. Instead I documented it, reported the re-scored figure with its margin, and wrote to Dr. Meng Lin, Co-Chief of the Dry Eye Clinic at UC Berkeley, asking what clinical standard this should anchor to.

Other things that broke:

Sessions silently ran 100 seconds instead of 60. Timer.scheduledTimer attaches in the default run-loop mode, so scrolling the reading passage stalled it. Fixed by adding it in .common. Three views each instantiated their own tracker, so three ARSessions fought over one TrueDepth camera and the app froze. One shared instance via the environment. Adding fields to my record type silently broke decoding of existing data and "lost" a participant's scans. Swift's synthesized Codable requires every key even for properties with defaults. Fixed with a hand-written lenient init(from:). Accomplishments that I'm proud of

My 12 participants blinked 11.0 times per minute while reading. Published lab research reports 10.7. A phone camera landed within a third of a blink of the literature.

And the fact that I caught my own measurement being wrong before anyone else did — by trying to break it rather than waiting for it to work.

What I learned

That building the thing is the easy half.

I spent as much time finding bugs buried inside this as writing it, and the most useful habit I picked up was actively trying to outsmart my own app instead of shipping on "it works in this case." The best example: I tested the training mode by deliberately faking the exercise and found it counted a rep whenever my eyes closed, without checking that I held the squeeze — which is the part the research says actually works. The app looked like it was verifying the exercise while verifying the wrong variable.

Collecting data from real people at school is where I found out the thing I was most confident about was wrong. That was worth more than it working the first time.

What's next for NearWork

Three things, all the same move, replacing my own judgment with outside validation:

A linked parent view. The kids who most need this are the ones who just became myopic, and a seven-year-old doesn't have a phone. For preteens who do, a parent should see the numbers, because the person who can change a child's reading habits usually isn't the child. A validated symptom questionnaire. Mine is 14 items I wrote myself and labelled in-app as not validated. I've requested permission to use the CVS-Q teen©. A defensible blink threshold, the open question from my own data, currently with a clinician at Berkeley.

Version 1 showed the app can measure something real. Version 2 is about proving that what it measures is right.

Built With

Share this project:

Updates

Submission history