Menu
An Android app showing a captured photo of a printed ECG strip, precordial leads V1–V6, on a dark background

A Service to Digitize ECG Strips

Every electrocardiogram (ECG) machine in routine clinical use does the same thing with its result: it prints it on a strip of paper. The waveform that follows is proof — a cardiologist can read it, but a computer can't, and the reading is stuck on that one piece of paper forever unless someone re-measures it by hand with a ruler. This is an undergraduate thesis project (Universidad Simón Bolívar, Ingeniería Electrónica, defended May 2018, advised by Prof. Masun Homsi) built with Emanuel Sánchez to close that gap: a photo of a printed 12-lead ECG goes in, and a calibrated millivolts-vs-milliseconds signal comes out — exportable as JSON, WFDB, MATLAB, or the FDA-backed HL7 annotated-ECG (aECG) XML standard used in clinical trials.

The project has three parts that all shipped together: a Python/OpenCV image-processing pipeline that does the actual digitization, a Node.js RESTful API that exposes it to the world with user accounts and a MongoDB-backed job queue, and a companion Android app that lets a patient photograph their own ECG strip, watch it get processed, and pull up the resulting waveform on their phone.

A clean scanned 12-lead ECG in the standard 4x4 layout: I aVR V1 V4 / II aVL V2 V5 / III aVF V3 V6, plus a lead-II rhythm strip

A real Schiller ECG paper strip photographed on a wooden table with a phone camera, showing the device's gain and filter settings printed along the strip

The two kinds of input the pipeline had to handle: a clean scan (left) and an uncontrolled phone photo (right).

A Very Short ECG Primer

A standard resting ECG records 12 different views ("leads") of the heart's electrical activity from combinations of electrodes on the limbs and chest, and prints them on grid paper where 1 small square = 40 ms horizontally and 100 µV vertically. That grid calibration is exactly what makes digitization possible: if you can find the grid, you can convert pixels back into real units.

Einthoven's triangle: the three bipolar limb leads I, II, III formed between the right arm, left arm and left leg

The six precordial chest leads V1 through V6 arranged in an arc around the heart

The bipolar limb leads I/II/III plus augmented aVR/aVL/aVF (left), and the six precordial chest leads V1–V6 (right).

The pipeline targets the printout convention most machines default to: a 4×4 grid — row 1: I, aVR, V1, V4; row 2: II, aVL, V2, V5; row 3: III, aVF, V3, V6; row 4: an extended single-lead rhythm strip (usually lead II) — which is also why lead II ends up being the one the thesis analyzes in full depth.

Two Test Datasets, on Purpose

Two datasets were built specifically to separate two very different failure modes:

DatasetSourceSizePurpose
Database 1Web-collected scanned ECG images107 collected → 54 kept (right grid spec + 4×4 layout), avg. ~1200×640 pxAlready flat — used to evaluate segmentation and signal extraction in isolation
Database 2Self-captured phone photos10 images, uncontrolled angle & lightingThe only dataset that can test perspective correction

System Architecture

The three pieces talk over a JSON/multipart REST API; the heavy image processing runs in Python and is invoked from the Node service via python-shell rather than being reimplemented in JavaScript.

Layered service architecture: OpenAPI/Swagger presentation layer, cloud processing (signal extraction + additional algorithms), REST API with JSON and HL7-aECG, and a User to ECG to Segment MongoDB data model

Tech stack diagram: OpenAPI and Swagger for docs, OpenCV and scikit-learn for cloud processing, Node.js, JSON and HL7 for the web service, MongoDB and Mongoose for storage

Service layers and the concrete tools behind each one.

LayerChoiceWhy
API styleREST over SOAP/WSDLThe thesis cites Fielding's 2000 dissertation directly; REST's stateless, resource-based model was judged a better fit than SOAP's contract-heavy tooling for a service this size
Backend runtimeNode.js 8.11.1 over JavaA cited PayPal benchmark showed roughly 2× requests/sec and 35% lower response time versus an equivalent Java app
Backend modulesexpress, mongoose, passport, jsonwebtoken, bcrypt, multer, body-parser, python-shell, rotating-file-streamRouting, MongoDB ODM, auth strategies, JWT issuing, password hashing, multipart uploads, calling into Python, and weekly-rotated access/error logs
DatabaseMongoDB + MongooseDocument storage suited the variable, nested shape of an ECG job (segments, leads, processing state) better than a fixed relational schema
Image processingPython: OpenCV, NumPy, SciPy, Pandas, scikit-learn, WFDBOpenCV alone exposes 500+ ready-made CV primitives; scikit-learn's clustering and WFDB's export routines were reused rather than rewritten
API docsOpenAPI v3 + Swagger UI, served at /api-docsInteractive, testable documentation instead of a static spec
Push notificationsFirebase Cloud MessagingDigitization is asynchronous; FCM tells the phone the moment a result is ready instead of polling

Auth is stateless: bcrypt (cost factor 10, random salt) hashes passwords, and a signed JWT — its payload additionally protected with SHA-256 and a server secret — travels in an x-api-key header on every subsequent request.

The Digitization Pipeline

This is the core of the thesis: turning a photograph of a paper ECG into a clean, calibrated signal. It runs in three stages.

Pipeline overview: image acquisition, perspective adjustment, partial segmentation of the four lead rows, then per-lead analysis, ending in a calibrated digital signal

Top-level flow: acquire → correct perspective (phone photos only) → segment into the four lead-rows → extract & calibrate the target lead.

Stage 1 — Perspective Correction

Only phone photos need this stage — scans are already flat. It assumes the paper is the largest object in frame and all four corners are visible:

  1. Downscale the photo to roughly 240×320 (the exact factor depends on aspect ratio) so contour-finding stays fast.
  2. Run Canny edge detection (thresholds 50/150) and keep the largest closed contour — that's assumed to be the paper.
  3. Run a Hough line transform over that contour (ρ resolution 1 px, θ resolution 1°, minimum 70 intersections per line), intersect the detected lines pairwise, and pick the four points that form the largest quadrilateral as the paper's corners.
  4. Scale those corner coordinates back up to the original photo resolution.
  5. Compute a perspective transform matrix from the four corners and warp the full-resolution image with it.

A playing card photographed at an angle, used as a controlled test target for the perspective-correction algorithm

The largest-contour detection step isolating the playing card's outline against a black background

The four detected corners of the playing card, found by intersecting Hough lines along its outline

The same contour→corners logic, sanity-checked on a playing card before trusting it on paper.

Block diagram of the largest-contour-detection stage

Block diagram of the corner-detection-on-contour stage

This turned out to be the weakest link in the whole pipeline: roughly half of the 10 phone photos in Database 2 failed here, mostly to uneven lighting (of the failures, 30% found no corners at all, 20% found the wrong ones). Because it's also the first stage, a bad result here poisons everything downstream with no fallback — which is why the accuracy numbers further down were only measured on the already-flat Database 1.

Stage 2 — Splitting the Four Lead Rows

A flat 4×4 image is next split into its four row-groups (and the row-4 rhythm strip is set aside for full analysis). Two independent signals are combined to find the row boundaries:

  • Color clustering — pixels are grouped into 3 clusters in RGB space (a K-means-style "K-Vecinos" pass), producing binary masks for signal, grid and background. The cluster with the fewest pixels is assumed to be the trace; the one with the most, the background; the rest, the grid.
  • Entropy filtering — local image entropy is computed with a radius-7 disk structuring element; a histogram of entropy values is bucketed, and everything past the highest-density bucket is thresholded away (the grid tends to read as higher-entropy than the signal at this stage).

The normalized row-sums of the color mask and the entropy mask are added together; the four largest peaks in that combined curve mark the centers of the four lead rows, and the nearest local minima on each side (via the first derivative) mark where to cut.

An ECG image with its four lead rows outlined in green boxes after successful row segmentation

The four rows located and boxed off.

An RGB color cube used to illustrate the color space the pixel-clustering step operates in

A histogram of image entropy values used to pick the grid-removal threshold

Block diagram of the partial lead-row segmentation stage

Block diagram of the row-boundary selection stage using the combined color and entropy histogram

Combining color and entropy clearly beats either alone at this stage: row-boundary accuracy was 48.1% with color only, 63.0% with entropy only, and the two together landed at 61.1% — a large jump over color alone, though roughly a wash against entropy on its own. For the narrower job of separating lead II specifically, the combination did win outright: 85.2% (color), 83.3% (entropy), 90.7% combined — the version actually carried forward.

Stage 3 — Extracting and Calibrating the Signal

The same 3-cluster/entropy pass is repeated at the single-lead crop scale (the earlier row-scale masks are too coarse to trace a single line reliably). From there:

  1. Minimum-distance-between-points tracking: scanning column by column, pixels within 1 px of each other are grouped, and each column's group is chosen by proximity to the group picked in the previous column — a simple nearest-neighbor trace. This runs twice, left-to-right and right-to-left, and the two passes are XOR'd: columns that agree both ways become "definitive"; ambiguous columns are resolved against the nearest definitive ones. The very first column (no history yet) picks whichever group sits closest to the row with the most signal pixels overall.
  2. One pixel per column: within the winning group, the point farthest from that central row is kept — which is what correctly captures the tip of a QRS spike instead of clipping it.
  3. Grid calibration: a second, more permissive Hough transform (ρ=1 px, θ=1°, 0–1 minimum intersections) finds the grid lines themselves — horizontal near θ∈[89°,91°], vertical near θ≈0°/180° — and a histogram of the spacing between consecutive lines gives the pixel size of one grid square in each direction.
  4. Unit conversion: the pixel-space point vector is rescaled into millivolts vs. milliseconds using that calibration and the standard 40 ms / 100 µV grid spec.

Block diagram of the single-lead analysis stage

Block diagram of the minimum-distance-between-points tracing algorithm, run left-to-right and right-to-left then combined

Block diagram of the grid-line-distance calibration algorithm

A histogram combining color and entropy row-sums, used to locate lead-row centers

The final digitized lead-II signal, plotted in millivolts against milliseconds

The end product of the pipeline: a calibrated, plottable signal.

Grid calibration turned out uneven — correct to within 1 px on width in 59.3% of images but on height in only 33.3% (RMSE 0.407 vs. 1.111 grid squares), which the thesis flags as a real weak point, since height error propagates directly into amplitude error.

A development screenshot: the pipeline's OpenCV debug viewer and a Matplotlib figure showing the same digitized ECG lead side by side

The debug tooling used while developing the extraction stage — an OpenCV image viewer next to the equivalent Matplotlib plot.

The REST API

Everything is under /api-v1 and returns JSON with a success flag (except binary/XML result downloads):

EndpointWhat it does
POST /auth/registerCreate an account (email, name, password)
POST /auth/loginReturns a signed JWT + user object
POST /user/{userId}/ecgUpload an ECG image (multipart) for digitization, optionally with an FCM token for a push notification on completion
GET /user/{userId}/ecgList a user's submitted ECG jobs
GET /user/{userId}/ecg/{ecgId}?format=Fetch a result as JSON, HL7-aECG, MATLAB (.m) or WFDB — returns 409 while still processing
POST /user/{userId}/ecg/{ecgId}/preprocessedAttach an already perspective-corrected image (e.g. corrected client-side by the app)
POST /user/{userId}/ecg/{ecgId}/segmentedAttach an already-segmented single lead, skipping straight to Stage 3
DELETE /user/{userId}/ecg/{ecgId}Delete a job and its stored files

The staged-upload endpoints (/preprocessed, /segmented) exist so a thinner client — or a different app entirely — can do part of the pipeline locally and only hand the server what it can't do itself.

MongoDB data model: User, ECG, Segment, DigitizationStatus, ProcessingType and Role collections and their relationships

The MongoDB collections behind the API: a User owns ECG jobs, each made of Segments tagged with a lead, a DigitizationStatus (raw/preprocessed/segmented/digitized) and a ProcessingType.

The Swagger UI auto-generated documentation for the ECG digitization API, listing the auth and ECG endpoints

Interactive API docs, generated straight from the OpenAPI v3 spec.

A Postman request uploading an ECG image to the digitization endpoint

A Postman response showing a digitization result exported as HL7 annotated-ECG XML

Submitting an image for digitization, and pulling the result back out as HL7-aECG XML.

The Android App

The mobile client wraps the whole API behind a reusable API-client class, targets API 16+, and mirrors the server-side pipeline locally with OpenCV4Android for the parts a phone can do on its own.

The Android app's login screen

The app's camera capture screen framing an ECG strip

The app highlighting the detected paper outline over the captured photo in green

The app's manual lead-selection screen with a draggable, resizable rectangle over the target lead

The app's results screen showing a zoomable, pannable chart of the digitized ECG signal

The app's list of previously submitted ECG exams with their processing status and date

Login → capture → leaf detection → manual lead selection → interactive result chart → exam history.

Login is optional — you can continue as a guest, you just don't get your results saved. Once a photo is captured, the app runs the same contour/corner homography as Stage 1 locally (via OpenCV4Android) to flag the detected sheet outline in green; the user then drags a resizable rectangle over the lead they want digitized before the crop is uploaded. A RegistrationIntentService keeps the device's FCM token fresh with the server, and a FirebaseMessagingService listener fires a local notification — and immediately fetches the JSON result — the moment the server finishes processing. Results render in MPAndroidChart, with pinch-zoom and pan over the mV-vs-ms trace.

Firebase Cloud Messaging architecture: the web interface and server both talk to Firebase, which pushes notifications to Android, iOS and web clients

How a finished digitization reaches the phone without polling.

Project Methodology

The service and app were built with SCRUM — a backlog of requirements broken into sprints, each aiming to ship a genuinely usable increment.

A simple SCRUM diagram: requirements feed a sprint backlog, daily and monthly meetings drive each sprint iteration toward a deliverable

Results

Digitization Accuracy

Measured on Database 1's 54 images (perspective correction on Database 2's phone photos failed too often — about half — to build a fair accuracy number on, so later stages were only scored on already-flat scans):

Bar chart: percentage of test images in the correct 4x4 ECG format

Bar chart: accuracy of identifying the signal, grid and background regions by RGB distance versus pixel percentage

Bar chart: accuracy of separating lead II using color and entropy combined

StageResult
Signal/grid/background clustering (RGB-distance-from-origin criterion)98.1% correct (53/54)
Same, but classified by pixel-percentage instead88.9% correct (48/54) — and confused grid with background 9.3% of the time, vs. 0% for the distance criterion
Entropy filtering removing genuine signal pixelshappened in 40.7% of images at row-segmentation scale, but only 1.9% at the single-lead re-analysis scale
Row-boundary selection48.1% color only · 63.0% entropy only · 61.1% combined
Lead-II separation85.2% color only · 83.3% entropy only · 90.7% combined (the version used downstream)
Point-selection quality48.1% of images kept under 150 erroneous pixels in the final traced vector
Grid-size detection (±1px)width 59.3% (RMSE 0.407 squares) · height 33.3% (RMSE 1.111 squares)
Overall end-to-end accuracy39% (21/54) — correct grid size and under 150 erroneous points
Heart-rate estimate vs. manual referenceRMSE 11,239.6 bpm² (1–193 bpm spread) — not usable for pathology detection as-is

Two consistent findings across all of this: identifying regions by distance from the RGB origin clearly beats classifying by raw pixel percentage, and combining color and entropy clearly beats using either alone for segmentation. Also worth noting: accuracy held up similarly whether the source printout was in color or grayscale, so the pipeline doesn't quietly depend on colored ink.

Load Testing

The API was load-tested with Artillery against a modest test server (2× Intel Xeon @3.20GHz, 4GB RAM, Ubuntu Server 16.04), sustaining about 1 request/second per virtual user for one minute per tier:

Concurrent usersSessionsCompletedError rateResponses/sec
201,2001,2000%19.85
503,0002,66711.1%14.74
1006,0003,97533.8%26.17
1509,0003,18064.7%37.4

The service handled 20 concurrent users cleanly (0% errors), stayed reasonably usable at 50 (~89% success), and degraded sharply past that on this single, unscaled instance — the recommendations section below is mostly about fixing exactly that.

Limitations

  • At 39% end-to-end accuracy, this is a research prototype, not a clinically reliable digitizer — it was never claimed to be one.
  • There was no independently-verified ground truth to compare against; heart-rate and grid-size references were themselves measured by hand, which puts a human-error floor under every accuracy number above.
  • Perspective correction — the very first stage — is also the least reliable one, and a failure there has no fallback; it just blocks the rest of the pipeline.
  • Lighting normalization was never solved; most of the phone-photo failures trace back to it.
  • Grid-height detection and the point-selection step are the weakest individual links, and they're exactly what feeds the poor heart-rate estimate.

Future Work & Recommendations

From the thesis's own future-work list

  • A web app and an iOS app, alongside the existing Android client
  • Pathology-detection algorithms built on top of the extracted signal
  • A real deployment inside a clinical/telemedicine setting
  • Support for other grid-paper signal types beyond ECG
  • A dedicated shadow-removal preprocessing stage
  • Automating per-lead segmentation (today it's a manual rectangle in the app)
  • Better entropy-threshold selection to keep more real signal while still clearing the grid

Recommendations for the service itself

  • Scale out with a load balancer / server cluster, or a job queue, to survive the 100–150 concurrent-user range
  • Test the Android app across more API levels (only 4.3/4.4 were verified)
  • Extend the data model toward EMR (Electronic Medical Record) compatibility
  • Add doctor-facing feedback/consultation features
Posted In:
Computer Vision