From HackerTV to Binocular Vision: A Developer's Personal Stack for Lazy Eye Recovery

I still remember the night the HackerTV livestream flickered on my second monitor while I was debugging a CI pipeline. The host was talking about “binocular vision therapy” — a phrase that sounded more like a graphics‑pipeline term than a medical one. My left eye had been drifting since childhood, a lazy eye that never quite caught up after the patching years. I’d tried the usual apps, the printed cards, the occasional visit to a vision therapist, but nothing stuck. That night, half‑asleep, I thought: what if I could treat this like a software problem — instrumented, measurable, iterable?

The next morning I opened a fresh repo, lazy‑eye‑stack, and started sketching a minimal architecture. I wanted three things:

  1. Real‑time stimulus generation – controllable contrast, spatial frequency, and dichoptic presentation.
  2. Objective telemetry – eye‑tracking data, fixation stability, and a simple “fusion score” I could plot over weeks.
  3. A low‑friction UI – something I could launch from a terminal, run on a laptop, and later port to a phone for daily sessions.

I chose Python for the core because the scientific stack (NumPy, SciPy, OpenCV) is battle‑tested and because I could prototype a GStreamer pipeline in a few lines. The dichoptic rendering lives in a tiny Unity‑based viewer (WebGL export) that receives JSON‑encoded stimulus parameters over a local WebSocket. The eye‑tracker is a cheap IR‑camera (≈ $30) fed into pupil‑labs/pupil via its Python bindings; I only needed the 2‑D gaze point, not the full 3‑D model.

# core/stimulus.py import numpy as np import cv2 def grating(width, height, freq_cpd, contrast=0.8, phase=0.0): """Return a sinusoidal grating image (float32, 0‑1).""" x = np.arange(width) - width // 2 y = np.arange(height) - height // 2 X, Y = np.meshgrid(x, y) rad = np.sqrt(X**2 + Y**2) # cycles per degree → cycles per pixel (approx 60 px/° at 57 cm)  cpp = freq_cpd / 60.0 img = 0.5 + 0.5 * contrast * np.sin(2 * np.pi * cpp * rad + phase) return img.astype(np.float32) 

The Unity side subscribes to a stimulus/update topic, builds two textures (left/right eye) with independent phase offsets, and drives a stereoscopic shader. The shader is only ~30 lines of HLSL, but it guarantees that the GPU does the heavy lifting — no CPU‑side pixel loops, no frame‑drops.

// Assets/Shaders/DichopticGrating.shader Shader "Custom/DichopticGrating" { Properties { _MainTex ("Texture", 2D) = "white" {} _Phase ("Phase", Float) = 0 _Contrast("Contrast", Range(0,1)) = 0.8 } SubShader { Pass { CGPROGRAM #pragma vertex vert  #pragma fragment frag  #include "UnityCG.cginc"  struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; sampler2D _MainTex; float _Phase; float _Contrast; v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = v.uv; return o; } fixed4 frag (v2f i) : SV_Target { float val = tex2D(_MainTex, i.uv).r; val = 0.5 + 0.5 * _Contrast * sin(6.28318 * val + _Phase); return fixed4(val, val, val, 1); } ENDCG } } } 

The first week I ran a 10‑minute session each evening. The telemetry script wrote a CSV line per frame: timestamp, left‑eye gaze, right‑eye gaze, fusion score (inverse of inter‑ocular distance). I plotted it with a tiny Jupyter notebook — matplotlib + pandas — and watched the fusion score creep from 0.42 to 0.68. The numbers felt more honest than any “you’re improving” badge in a commercial app.

There were setbacks. One night the IR camera slipped, and the pupil detection threw NaNs for 200 frames. My script crashed, the Unity viewer froze, and I stared at a blank screen, frustrated. I added a watchdog that restarts the GStreamer pipeline and a fallback that logs “lost‑track” instead of aborting. The next session ran clean.

$ python -m lazy_eye_stack.run --duration 600 --freq 4 --contrast 0.7 [INFO] Starting stimulus @ 4 cpd, contrast 0.7 [INFO] Eye‑tracker connected (serial=IR‑CAM‑01) [INFO] Session 2025‑11‑12 20:13:45 – 20:23:45 completed, 36000 frames, 0 lost 

After six weeks the CSV showed a plateau around 0.78 fusion. I added a “progressive difficulty” scheduler: every three sessions the spatial frequency steps up 0.5 cpd, contrast drops 0.05. The curve resumed its climb. I also swapped the laptop screen for a 27‑inch 144 Hz monitor — higher refresh meant smoother phase updates, less temporal aliasing, and a measurable 3 % gain in fixation stability (standard deviation of gaze position dropped from 0.34° to 0.27°).

The stack is deliberately not a polished product. It’s a collection of scripts, a Unity WebGL build, a Dockerfile that pins python:3.11-slim and unity:2022.3, and a README that explains how to calibrate the IR camera with a printed checkerboard. I publish the repo under MIT because the real value is the process — the ability to tweak a parameter, re‑run, and see the effect in a plot the next morning.

If you’ve ever felt that off‑the‑shelf vision‑therapy apps are black boxes, try building your own loop. Start with a single grating, a cheap camera, and a CSV. Measure. Iterate. The code is there, the hardware is cheap, and the feedback loop is yours.

Feel free to clone the repo, open an issue, or just try a 5‑minute session tonight — you might be surprised how quickly the numbers start to move.