Three medical laboratories ran semen analysis by hand, each on its own local machine, and none of them could see another's work. The brief sounded simple: one central server all three sites could connect to, AI-based analysis on a GPU, and a web interface technicians could use from anywhere. Then came the constraint that changed the job. Everything had to work in real time.
The situation
I built this alone, in a previous role at RSSD Infotech, and it was software and deployment engineering rather than platform work. The Flask and Flask-SocketIO backend, the microscope SDK integration, the GPU processing path and the Docker and Nginx deployment were mine.
"Real time" meant a technician at any of the three labs could open a browser, see the microscope's live feed, drive the hardware remotely and run AI tests on the sample in front of them. That's what turned a simple web app into a latency problem. Every hop between the microscope and the browser was a place where lag could creep in, and the analysis had to come back fast enough to feel like part of the same workflow.
I chose Python for the backend because the work was dominated by image manipulation, AI models and a vendor SDK that already had Python bindings, and I used Flask-SocketIO so frames and control events could be pushed in real time.
The frontend was the client's call more than mine. I offered a React rebuild with a modern UI, but this was a private internal tool used only by lab staff, and the client was comfortable with PHP-style templates and a single index-style structure.
EJS server-rendered templates for the frontend
A React refactor with a modern UI (which I offered)
Why: It's a private internal app used only by lab staff, and modern design wasn't the priority. The client was comfortable with PHP-style templates and wanted a single index-style structure. I pushed EJS as the middle ground. It was more maintainable than what they had and still familiar to them. My job was to guide the decision, then respect it.
Putting the microscope on the web
The client wanted the live feed in the browser with full remote control: lens movement, frame rate, speed, contrast, saturation, gamma, hue and alpha. The Toupcam SDK supported streaming through Python, so each control on the dashboard became a Flask-SocketIO event that called into the SDK, and the resulting frames streamed back to every connected browser.
That part worked surprisingly well. Then the client said, "There's a lag when I move the lens."
Finding where the lag lived
My first suspects were the usual ones for a live video feed: dropped frames, network delay, or the browser struggling to keep up. It wasn't any of them. The lag was coming straight from the SDK.
At that point I could have wrapped the public API in polling or debouncing so the lag was less noticeable. That would only have hidden the symptom, because the slow part was the call itself. So I read the SDK's source line by line instead. Buried in it were control functions that existed but weren't in any documentation, and wiring those into the control flow removed the lag at its source.
Read the Toupcam SDK source line by line and call its undocumented functions directly
Building a polling / debounce workaround around the laggy public API
Why: The lag was in the SDK, not the network, so a workaround would only hide the symptom. Buried in the source were control functions that existed but weren't in any documentation. Wiring those into the control flow removed the lag at its source.
Once the labs were using it
I deployed the platform on the client's on-premise server, which meant the networking setup, NAT configuration with their ISP, and exposing the server securely through Nginx as a reverse proxy. It was stable, and technicians at the labs used it daily.
Daily use brought practical requirements the brief hadn't mentioned. Each test had its own AI model, with different inputs, processing logic and outputs, and each had its own quirks. Debris was one example: non-sperm particles sometimes showed up in a sample and confused the analysis, so the client wanted technicians to mark debris by hand and exclude it. On the frontend, marking debris places an overlay on the image, and I capture its pixel coordinates and send them to the backend with the sample, where the AI pipeline ignores those regions entirely.
The motility bottleneck
Later the client added a GPU to the server and asked for faster analysis. Most tests were image-based and easy to optimize. Motility wasn't. It analyses short video clips of live movement, so the obvious shortcuts of shrinking the video or dropping its quality would have hurt the very thing being measured.
Moving the model to CUDA on the new GPU helped, but not enough on its own, so I made two more changes.
The first was frame skipping. Out of the ~90 frames in a clip, 5–10 were near-duplicates of their neighbours, so the model was doing full work on frames that added nothing new, and I skipped them.
The second was multithreading. I split each video into chunks, processed the chunks in parallel, and combined the partial scores into one result. Thread count mattered: with too many threads, the coordination overhead ate the gains, and five turned out to be the sweet spot.
How I know it's faster
I timed it directly. I processed a sample video of the same length and frame count before and after the optimization, timing the processing function itself in Python, so the comparison measures the pipeline rather than the network or the browser.
The result
Three labs that used to work separately, by hand, ran their analysis through one platform, drove their microscopes from a browser without the lens lag, and got motility results back from the GPU server. Technicians used it daily.
What's still open
It ran without drama, but a real-time platform on a single on-prem box has failure modes worth naming. None of these broke; they're the signals I'd instrument first if I owned it long-term.
- WebSocket health. Reconnect rate and dropped connections per lab. A site quietly losing its live feed is the worst failure here, because it looks like "the microscope is slow," not "the socket died."
- GPU contention. One GPU is shared across three labs, so contention shows up as slow outliers long before it shows up as errors. Per-sample p95 processing time by test type is the number I'd alert on.
- Frame-skip rate. Skipping frames trades some correctness risk for speed, so I'd track how many frames get skipped per sample and make sure an accuracy drop can't hide inside a speed win.
- Host basics. Disk, memory and the reverse proxy's error rate, since there's no cloud autoscaling to absorb a bad day.