|
Novarum DX Ltd
|
Document ID: IFU051
|
Overview
The NDX Imaging Web SDK (@novarumdx/ndx-imaging-web) enables web applications to capture and analyse lateral flow tests using the device's camera. It handles camera setup, image capture, and analysis result delivery using a WebAssembly imaging engine running in a background web worker.
This guide explains how to integrate the NovarumDX Reader SDK WASM package into web applications using either React or Vanilla JavaScript. It is intended as a practical reference for teams implementing camera-based lateral flow test capture and analysis in the browser.
Deployment benefit
The SDK requires no SharedArrayBuffer and therefore no COOP/COEP headers, which simplifies deployment to standard web hosting environments.
Before You Start
|
Requirement |
Detail |
|---|---|
|
Node.js |
18 or later |
|
Browser |
Chrome 80+, Safari 15+, Firefox 79+, Edge 80+ |
|
HTTPS |
Required for camera access - localhost counts |
|
AWS CLI |
Required to fetch a registry auth token |
|
Registry credentials |
AWS Access Key ID + Secret from NovarumDX |
|
Cassette configuration file |
|
Important
Camera access in browsers is typically only available in secure contexts. Use HTTPS in deployed environments, or localhost during local development.
How to use this guide
If you want the fastest route to a working integration, start with the Quickstart section. If you are setting up package access or diagnosing environment problems, go to the Installation or Troubleshooting sections first.
React Quickstart
Get from zero to a working scanner in five steps. This page assumes you have Node 18+, your .npmrc file from NovarumDX, and your cassette .unified.json file.
If npm install fails with 401 Unauthorized, refresh your auth token first. The token expires after 12 hours.
Step 1 - Get an auth token
If you haven't set up the AWS CLI yet, run aws configure --profile novarumdx-sdk first (see React Installation → Step 1 for the credential details). Then fetch a token:
export CODEARTIFACT_AUTH_TOKEN=$(aws codeartifact get-authorization-token \
--domain novarumdx \
--domain-owner 945969778369 \
--region eu-west-2 \
--query authorizationToken \
--output text \
--profile novarumdx-sdk)
Check the token was actually set:
echo ${CODEARTIFACT_AUTH_TOKEN:+TOKEN_IS_SET}
If that prints nothing, the AWS command failed. Because it runs inside $( ), its error is hidden and npm will later report a confusing 401 Unauthorized instead. Run the aws codeartifact command on its own to see the real error.
Tip: save it as a shell alias so you don't retype it every 12 hours:
alias novarumdx-login='export CODEARTIFACT_AUTH_TOKEN=$(aws codeartifact get-authorization-token --domain novarumdx --domain-owner 945969778369 --region eu-west-2 --query authorizationToken --output text --profile novarumdx-sdk)'
The token lasts 12 hours. Run this command again if npm install fails with 401 Unauthorized.
Step 2 - Create a Vite React app and install the SDK
Create the app:
npm create vite@latest my-scanner-app -- --template react
cd my-scanner-app
npm install
Copy your .npmrc file into the project root, then install the SDK:
npm install @novarumdx/ndx-imaging-web
After install, check that these files appeared in public/:
-
public/imagingWorker.js -
public/ndx_imaging_wasm.js -
public/ndx_imaging_wasm.wasm
If any of these files are missing, refresh your token and run npm install again.
Step 3 - Place your cassette file
Copy your .unified.json file into public/models/:
public/models/my-cassette/my-cassette.unified.json
Step 4 - Add the scanner to your app
Replace src/App.jsx with:
Vite's React template ships default styles that constrain #root to a fixed width with a centre border. Clear src/index.css (or remove its #root and body rules) before adding the scanner, otherwise the camera view will render in a narrow box instead of filling the screen. Also set html, body { overflow: hidden } - a vertical scrollbar shifts the camera view sideways.
import React, { useState } from 'react';
import { useNDXReader, NDXReaderView } from '@novarumdx/ndx-imaging-web/react';
function App() {
const [results, setResults] = useState(null);
const [scanning, setScanning] = useState(false);
const scanner = useNDXReader({
config: '/models/my-cassette/my-cassette.unified.json',
onReady: () => console.log('Ready to scan'),
onSuccess: (strips, pmfStory, diagnostics, analysisMs) => {
scanner.stop();
setResults(strips);
setScanning(false);
},
onError: (error) => {
console.error(error.message);
setScanning(false);
},
});
if (results) {
return (
<div>
<h1>Results</h1>
<p>T/C ratio: {results[0].tcRatio[0].toFixed(4)}</p>
<button onClick={() => setResults(null)}>Scan again</button>
</div>
);
}
if (scanning) {
return <NDXReaderView scanner={scanner} style={{ width: '100%', height: '100vh' }} />;
}
return (
<div>
<h1>Ready to scan</h1>
<button onClick={() => { scanner.reset(); setScanning(true); }}>Begin Scan</button>
</div>
);
}
export default App;
Call scanner.reset() before each scan. Without it the first scan works, but a second scan opens the camera without starting a new session, so no fit or progress is ever reported.
Step 5 - Run it
npm run dev
Open http://localhost:5173 and click Begin Scan, allow camera access, and hold your cassette in front of the camera. When the scan completes, you will see the T/C ratio on screen.
Testing on a phone
The scanner is built for phone cameras, so you'll usually want to test on a real device. Camera access needs a secure context - localhost counts, but a phone isn't localhost, so the phone must reach the app over HTTPS. The easiest way is a tunnel: a public, already-trusted HTTPS URL, no certificates.
Vite blocks requests from unknown hosts by default, so first allow the tunnel host in vite.config.js:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: { host: true, allowedHosts: ['.trycloudflare.com'] },
})
Then, using cloudflared (no account needed - brew install cloudflared on macOS, or see the cloudflared downloads page), open two terminals:
# terminal 1 - run the app
npm run dev
# terminal 2 - open a tunnel to it
cloudflared tunnel --url http://localhost:5173
cloudflared prints a lot of log lines; near the top, in a boxed message, is your URL - open that https://<random>.trycloudflare.com address on your phone and allow the camera. The URL is temporary and changes each run.
Next steps
-
Full install reference including CI/CD setup: React Installation
-
Complete scan flow with all callbacks: React - Your First Scan
-
All configuration options: React - Configuration Reference
React Installation
Use this page when you need the complete setup flow
This guide covers local development, private npm registry authentication, generated runtime files, CI/CD setup, and common installation problems.
Full installation reference for the NDX Imaging Web SDK in a React project. If you followed the React Quickstart and everything worked, you can skip this section and come back if you run into issues.
Requirements
|
Requirement |
Detail |
|---|---|
|
Node.js |
18 or later |
|
npm |
8 or later |
|
Browser |
Chrome 80+, Safari 15+, Firefox 79+, Edge 80+ |
|
HTTPS |
Required for camera access, localhost counts |
|
AWS CLI |
Required to fetch an auth token |
Step 1: Install and configure the AWS CLI
You need the AWS CLI to fetch an auth token for the private npm registry. You only do this setup once.
Install the AWS CLI by following the official instructions for your operating system: AWS CLI installation guide
Verify it installed correctly:
aws --version
Configure your credentials. NovarumDX will have sent you an Access Key ID and a Secret Access Key. Run this command and enter them when prompted:
aws configure --profile novarumdx-sdk
The profile name is your choice - we use novarumdx-sdk throughout these examples, so if you pick a different name, use it consistently in every command.
Credential safety
The AWS Access Key ID and Secret Access Key are issued to your organisation by NovarumDX and grant read-only access to the Novarum SDK package registry only. Store them in your secret manager, do not commit them to source control, and do not share them. If they are ever exposed, contact NovarumDX so we can rotate them.
Enter the following values when prompted:
-
AWS Access Key ID: the value from NovarumDX
-
AWS Secret Access Key: the value from NovarumDX
-
Default region name: eu-west-2
-
Default output format: json
Your credentials are saved to ~/.aws/credentials. You only run this command once, and the credentials do not expire.
Important
Be sure to include --profile novarumdx-sdk when configuring credentials. Later commands in this guide rely on that profile name.
Step 2: Place your .npmrc file
NovarumDX will have provided a .npmrc file. Copy it into the root of your project, in the same folder as your package.json.
The file should contain these three lines:
@novarumdx:registry=https://novarumdx-945969778369.d.codeartifact.eu-west-2.amazonaws.com/npm/novarum.client.npm/
//novarumdx-945969778369.d.codeartifact.eu-west-2.amazonaws.com/npm/novarum.client.npm/:always-auth=true
//novarumdx-945969778369.d.codeartifact.eu-west-2.amazonaws.com/npm/novarum.client.npm/:_authToken=${CODEARTIFACT_AUTH_TOKEN}
The ${CODEARTIFACT_AUTH_TOKEN} part is not a placeholder. npm reads it from an environment variable that you set in the next step.
Git safety
Do not commit .npmrc to git if it contains credentials. In this setup, the file uses an environment variable for the token, so it is safe to commit as-is.
Step 3: Get an auth token
The registry requires a short-lived token. Run this command to fetch one:
export CODEARTIFACT_AUTH_TOKEN=$(aws codeartifact get-authorization-token \
--domain novarumdx \
--domain-owner 945969778369 \
--region eu-west-2 \
--query authorizationToken \
--output text \
--profile novarumdx-sdk)
The token lasts 12 hours. Run this again each day or whenever you open a new terminal. If npm install fails with 401 Unauthorized, re-run this command and try again.
Tip: save this as a shell alias in your ~/.zshrc or ~/.bashrc:
alias novarumdx-login='export CODEARTIFACT_AUTH_TOKEN=$(aws codeartifact get-authorization-token \
--domain novarumdx \
--domain-owner 945969778369 \
--region eu-west-2 \
--query authorizationToken \
--output text \
--profile novarumdx-sdk)'
Then run:
source ~/.zshrc
From then on, run novarumdx-login to refresh your token.
Step 4: Install the package
Install the SDK with npm:
npm install @novarumdx/ndx-imaging-web
This installs the SDK and automatically runs a setup script that copies the required engine files into your public/ folder.
|
File |
What it is |
|---|---|
|
imagingWorker.js |
The scanner runs inside this background worker thread |
|
ndx_imaging_wasm.js |
The WebAssembly imaging engine loader |
|
ndx_imaging_wasm.wasm |
The compiled WebAssembly imaging engine |
Required at runtime
These files are required at runtime. Do not delete or move them.
Step 5: Update your .gitignore
The files copied into public/ are generated automatically on every npm install and should not be committed to git. Add these lines to your .gitignore:
/public/imagingWorker.js
/public/ndx_imaging_wasm.js
/public/ndx_imaging_wasm.wasm
Step 6: Verify the install
Check that the three engine files are present:
ls public/imagingWorker.js public/ndx_imaging_wasm.js public/ndx_imaging_wasm.wasm
You should see all three files listed without errors.
Expected result
If the files are present and npm install completed successfully, the SDK is installed correctly and ready to use in your React project.
CI/CD environments
In a CI pipeline you cannot run the interactive aws configure command. Instead:
-
Store your Access Key ID and Secret Access Key as secret environment variables in your CI provider
-
Add a step before
npm installthat fetches the token
export AWS_ACCESS_KEY_ID=$YOUR_CI_SECRET_KEY_ID
export AWS_SECRET_ACCESS_KEY=$YOUR_CI_SECRET_ACCESS_KEY
export AWS_DEFAULT_REGION=eu-west-2
export CODEARTIFACT_AUTH_TOKEN=$(aws codeartifact get-authorization-token \
--domain novarumdx \
--domain-owner 945969778369 \
--region eu-west-2 \
--query authorizationToken \
--output text)
npm install
Contact NovarumDX if you need help configuring this for your specific CI platform.
Troubleshooting
References
React - Your First Scan
Use this page when
You want a production-style scan flow with clear app states before, during, and after scanning.
This page walks through building a complete scan screen for a real application. It covers the full lifecycle: loading, scanning, results, and scan again.
If you just want the minimum to get something on screen, the React Quickstart section is shorter.
What the SDK gives you
When you call useNDXReader, the hook manages the scanning pipeline for you.
-
Starting and stopping the imaging worker
-
Loading the cassette model
-
Opening the camera
-
Running the scan loop
-
Drawing the alignment overlay
-
Tracking fit quality, progress, and warnings
You get back a scanner object with the current state. Pass it to NDXReaderView and it renders the camera feed, overlay, progress bar, and warnings automatically.
Your application still owns the surrounding product flow.
-
What page the user is on, such as pre-scan, scanning, or results
-
What cassette to use
-
What to do with the result
A complete scan screen
This component handles the full flow: a Start button before scanning, the scanner view while scanning, and a results screen after.
Create src/ScanPage.jsx:
import { useState } from 'react';
import { useNDXReader, NDXReaderView } from '@novarumdx/ndx-imaging-web/react';
const centred = { minHeight: '100vh', display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center', fontFamily: 'sans-serif' };
const backdrop = { position: 'fixed', inset: 0, background: '#000',
display: 'flex', alignItems: 'center', justifyContent: 'center' };
function ScanPage({ cassetteConfigUrl }) {
const [page, setPage] = useState('pre');
const [results, setResults] = useState(null);
const scanner = useNDXReader({
config: cassetteConfigUrl,
onSuccess: (strips) => {
setResults(strips);
setPage('post');
},
onError: (error) => {
console.error('Scanner error:', error.message);
setPage('pre');
},
});
if (page === 'pre') {
return (
<div style={centred}>
<h1>Ready to scan</h1>
<button
onClick={() => {
scanner.reset();
setPage('scan');
}}
disabled={!scanner.engineReady}
style={{ padding: '1rem 2rem', fontSize: '1rem' }}
>
{scanner.engineReady ? 'Begin Scan' : 'Loading...'}
</button>
</div>
);
}
if (page === 'scan') {
return (
<div style={backdrop}>
<NDXReaderView scanner={scanner} />
<button
onClick={() => {
scanner.stop();
setPage('pre');
}}
style={{ position: 'fixed', top: '1rem', left: '1rem', zIndex: 10,
background: 'rgba(0,0,0,0.5)', color: '#fff', border: 'none', padding: '0.5rem 1rem' }}
>
Back
</button>
</div>
);
}
const strip = results[0];
return (
<div style={{ padding: '2rem', fontFamily: 'sans-serif', height: '100vh', overflowY: 'auto' }}>
<h1>Results</h1>
<table style={{ borderCollapse: 'collapse', marginBottom: '1rem' }}>
<tbody>
<tr>
<td style={{ padding: '0.5rem 1rem' }}>Control line</td>
<td style={{ padding: '0.5rem 1rem' }}>{strip.cHeight.toFixed(4)}</td>
</tr>
{strip.tcRatio.map((ratio, i) => (
<tr key={i}>
<td style={{ padding: '0.5rem 1rem' }}>T/C ratio (line {i + 1})</td>
<td style={{ padding: '0.5rem 1rem' }}>{ratio.toFixed(4)}</td>
</tr>
))}
</tbody>
</table>
<button
onClick={() => {
setResults(null);
setPage('pre');
}}
style={{ padding: '1rem 2rem', fontSize: '1rem' }}
>
Scan again
</button>
<div style={{ height: '5rem' }} aria-hidden="true" />
</div>
);
}
export default ScanPage;
NDXReaderView keeps its own aspect ratio, so give it a full-bleed parent or the camera view will sit in the top part of a white page. On the results screen, use height rather than maxHeight to make a real scroll container, and reserve space after the last control with a spacer element - a scroll container's own padding-bottom is dropped by some browsers once the content overflows.
Mount it in src/App.jsx:
import ScanPage from './ScanPage';
function App() {
return (
<ScanPage cassetteConfigUrl="/models/my-cassette/my-cassette.unified.json" />
);
}
export default App;
Why this pattern works
It keeps scanning logic inside the SDK hook while your app controls navigation and result handling. That makes it easier to add validation, analytics, or custom result screens later.
How the flow works
-
Pre-scan: the page shows a button while the engine loads.
-
Scanning: when the user starts, the scanner view opens and the camera feed begins.
-
Success: the
onSuccesscallback receives strip results and moves the app to the results screen. -
Exit or retry: the user can go back or choose to scan again.
This separation is useful in real applications because the scan UI is only one part of the overall patient or test workflow.
Scanner state reference
|
Field |
Type |
What it tells you |
|---|---|---|
|
|
boolean |
Model is loaded and the engine is ready to scan |
|
|
boolean |
Camera stream is open and capture pipeline is running |
|
|
0, 1, or 2 |
Current frame alignment: 0 means no fit, 1 means marginal, 2 means aligned |
|
|
number |
Frames captured so far |
|
|
number |
Total frames needed before analysis runs |
|
|
array |
Active quality warnings such as exposure or baseline issues |
|
|
number or null |
Time in milliseconds to process the last frame |
|
|
object or null |
Set if the camera or engine encountered an error |
Scanner methods
|
Method |
What it does |
|---|---|
|
|
Clears the previous scan so the next scan starts fresh |
|
|
Stops the camera and scan loop, call this when the user navigates away |
|
|
Ends the scan early and delivers the partial PMF story via onAbort |
Common patterns
Showing a progress indicator yourself
NDXReaderView includes a built-in progress bar. If you want to build your own:
<p>{scanner.progress} / {scanner.maxProgress} frames</p>
Reacting to fit status
The overlay colour changes automatically inside NDXReaderView. If you also want to update your own UI:
const scanner = useNDXReader({
config: cassetteConfigUrl,
onFitStatus: (status) => {
console.log('Fit status:', status);
},
onSuccess: (strips) => setResults(strips),
});
Handling camera errors gracefully
onError: (error) => {
if (error.code === 'camera-permission-denied') {
setErrorMessage('Camera access was denied. Please allow camera access and try again.');
} else {
setErrorMessage('Something went wrong. Please try again.');
}
setPage('pre');
},
Plotting the strip profile
Every StripAnalysis carries the data needed to plot the strip: profile and baseline are the two series, cPos and tPos are the line positions, and profileRegions gives the baseline/control/test bands if you want to shade them. The SDK does not include a charting component, so use any chart library. This example uses Chart.js, the same approach as the vanilla guide.
npm install chart.js
Create src/ProfileChart.jsx:
import { useRef, useEffect } from 'react';
import Chart from 'chart.js/auto';
function ProfileChart({ strip }) {
const canvasRef = useRef(null);
useEffect(() => {
if (!canvasRef.current || !strip) return;
const n = strip.profile.length;
const toPx = (chart, v) => {
const { left, right } = chart.chartArea;
return left + (v / (n - 1)) * (right - left);
};
// Dashed markers at the control and test line positions
const markers = {
id: 'markers',
afterDraw(chart) {
const { ctx, chartArea: { top, bottom } } = chart;
const mark = (pos, label, color) => {
const px = toPx(chart, pos);
ctx.save();
ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.setLineDash([4, 3]);
ctx.beginPath(); ctx.moveTo(px, top); ctx.lineTo(px, bottom); ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = color; ctx.font = 'bold 10px sans-serif'; ctx.textAlign = 'center';
ctx.fillText(label, px, top + 12);
ctx.restore();
};
mark(strip.cPos, 'C', '#2e7d32');
strip.tPos.forEach((pos, i) => mark(pos, `T${i + 1}`, '#b71c1c'));
},
};
const chart = new Chart(canvasRef.current, {
type: 'line',
plugins: [markers],
data: {
labels: Array.from({ length: n }, (_, i) => i),
datasets: [
{ label: 'Profile', data: strip.profile, borderColor: '#1976d2', borderWidth: 2, pointRadius: 0, tension: 0 },
{ label: 'Baseline', data: strip.baseline, borderColor: '#9e9e9e', borderWidth: 1.5, borderDash: [4, 3], pointRadius: 0, tension: 0 },
],
},
options: {
responsive: true, maintainAspectRatio: false, animation: false,
plugins: { legend: { position: 'top' }, tooltip: { enabled: false } },
scales: { y: { min: 0, max: 285 }, x: { ticks: { maxTicksLimit: 6 } } },
},
});
return () => chart.destroy();
}, [strip]);
return <div style={{ height: 260 }}><canvas ref={canvasRef} /></div>;
}
export default ProfileChart;
Then render it on your results screen, passing one strip:
<ProfileChart strip={results[0]} />
Always destroy the chart in the effect cleanup, otherwise Chart.js leaks an instance on every re-render. Render one chart per strip for multi-strip cassettes - the fields are identical on every StripAnalysis.
Next steps
-
Read and display results: React - Working with Results
-
Capture diagnostic images: React - PMF Story and Diagnostics
-
All configuration options: React - Configuration Reference
React - Working with Results
When a scan completes, the SDK calls your onSuccess callback with an array of StripAnalysis objects, one per strip on the cassette. This page explains what is in that array and how to use it.
Good default: Most applications only need the strips array. The additional callback parameters are mainly useful for diagnostics and advanced support workflows.
The onSuccess callback
Use onSuccess to receive scan output after analysis completes.
const scanner = useNDXReader({
config: '/models/my-cassette/my-cassette.unified.json',
onSuccess: (strips, pmfStory, diagnostics, analysisMs) => {
// strips - array of StripAnalysis, one per strip on the cassette
// pmfStory - per-frame diagnostic log (see PMF Story page)
// diagnostics - image capture diagnostics (see PMF Story page)
// analysisMs - time taken to run the final analysis in milliseconds
console.log(strips);
},
});
For most applications you only need strips. The other parameters are covered in the PMF Story and Diagnostics page.
What is in StripAnalysis
Each object in the strips array represents one physical test strip on the cassette.
|
Field |
Type |
What it is |
|---|---|---|
|
|
|
T/C ratio for each test line, the primary result. One value per T line. |
|
|
|
Height of the control (C) line peak above the baseline. |
|
|
|
Height of each test (T) line peak above the baseline. |
|
|
|
Pixel position of the C line along the strip profile. |
|
|
|
Pixel positions of each T line. |
|
|
|
Integrated area of the C line peak. |
|
|
|
Integrated area of each T line peak. |
|
|
|
The full intensity profile across the strip width. |
|
|
|
The fitted polynomial baseline for the profile. |
|
|
|
Mean squared error of the baseline fit, lower is better. |
The primary result: T/C ratio
tcRatio is the ratio of each test line height to the control line height. It is the number you use to determine a positive or negative result.
onSuccess: (strips) => {
const strip = strips[0];
const ratio = strip.tcRatio[0];
console.log('T/C ratio:', ratio);
}
The SDK gives you the raw ratio. Your application decides what it means.
NovarumDX will give you a threshold value for your specific assay. A ratio above the threshold is positive and below is negative.
Do not hardcode a threshold without confirming it with NovarumDX because it varies by cassette type.
Example using a threshold:
const THRESHOLD = 0.05; // example only, confirm with NovarumDX for your assay
onSuccess: (strips) => {
const ratio = strips[0].tcRatio[0];
const result = ratio > THRESHOLD ? 'Positive' : 'Negative';
setResult(result);
},
Checking the control line
Before trusting a T/C ratio, check that the control line is valid. A low control line height can indicate poor scan quality.
const C_HEIGHT_THRESHOLD = 8.0; // example, confirm with NovarumDX
onSuccess: (strips) => {
const strip = strips[0];
if (strip.cHeight < C_HEIGHT_THRESHOLD) {
setError('Scan quality too low. Please try again.');
return;
}
const ratio = strip.tcRatio[0];
setResult(ratio > THRESHOLD ? 'Positive' : 'Negative');
},
Only use example thresholds for development. Production thresholds should come from assay-specific guidance provided by NovarumDX.
Deciding the result: positive / negative / inconclusive
Putting the two checks together, most assays resolve to three outcomes:
-
Inconclusive / invalid - the control line is too weak (
cHeightbelow the control threshold). The scan can't be trusted; ask the user to re-scan. -
Positive or Negative - with a valid control line, compare
tcRatio[0]against your assay threshold. Both thresholds are assay-specific and supplied by NovarumDX - never hardcode production values without confirmation.
onSuccess: (strips) => {
const strip = strips[0];
// 1. Is the control line valid?
if (strip.cHeight < C_HEIGHT_THRESHOLD) { // threshold from NovarumDX
setResult('Inconclusive - please re-scan');
return;
}
// 2. Positive or negative?
const positive = strip.tcRatio[0] > TC_THRESHOLD; // threshold from NovarumDX
setResult(positive ? 'Positive' : 'Negative');
},
Multi-strip cassettes
Some cassettes have more than one test strip. In that case, strips will contain more than one entry, one per strip.
onSuccess: (strips) => {
strips.forEach((strip, index) => {
console.log(`Strip ${index + 1}: T/C ratio = ${strip.tcRatio[0].toFixed(4)}`);
});
},
Each strip can also have more than one test line. tcRatio, tHeight, tPos, and tArea are all arrays with one entry per T line.
Displaying results
This example renders control line strength and T/C ratios for every strip and line returned by the SDK.
function ResultsView({ strips, onScanAgain }) {
return (
<div style={{ padding: '2rem', fontFamily: 'sans-serif' }}>
<h2>Results</h2>
{strips.map((strip, stripIndex) => (
<div key={stripIndex} style={{ marginBottom: '1.5rem' }}>
{strips.length > 1 && <h3>Strip {stripIndex + 1}</h3>}
<p>
<strong>Control line:</strong> {strip.cHeight.toFixed(4)}
</p>
<p>
<strong>Control position:</strong> {strip.cPos.toFixed(2)}
</p>
<p>
<strong>Control area:</strong> {strip.cArea.toFixed(4)}
</p>
{strip.tcRatio.map((ratio, lineIndex) => (
<div key={lineIndex} style={{ marginLeft: '1rem' }}>
<p>
<strong>T line {lineIndex + 1}:</strong>
{' '}T/C ratio {ratio.toFixed(4)},
{' '}height {strip.tHeight[lineIndex].toFixed(4)},
{' '}position {strip.tPos[lineIndex].toFixed(2)},
{' '}area {strip.tArea[lineIndex].toFixed(4)}
</p>
</div>
))}
<p>
<strong>Baseline MSE:</strong> {strip.baselineMse.toFixed(4)}
</p>
</div>
))}
<button onClick={onScanAgain}>Scan again</button>
</div>
);
}
Sending results to your server
The SDK includes serializeAnalysisResult for formatting results into the JSON payload expected by the NovarumDX server API.
Contact NovarumDX for the server API specification and the correct integration approach.
This function requires engine configuration context that NovarumDX will help you wire up as part of your integration.
Exporting results
For diagnostics or support, let the user download the raw analysis and the serialised PMF story:
import { serializePmfStory } from '@novarumdx/ndx-imaging-web/react';
function downloadJSON(filename, text) {
const url = URL.createObjectURL(new Blob([text], { type: 'application/json' }));
const a = document.createElement('a');
a.href = url; a.download = filename; a.click();
URL.revokeObjectURL(url);
}
// strips is the array from onSuccess; story is the pmfStory argument
<button onClick={() => downloadJSON('result-strips.json', JSON.stringify(strips, null, 2))}>
Download result (JSON)
</button>
<button onClick={() => downloadJSON('pmf-story.json', serializePmfStory(story))}>
Download PMF story (JSON)
</button>
To send results to your own server, POST the raw strips. The NovarumDX server envelope format is arranged with NovarumDX as part of your integration - contact them for the server API specification.
Next steps
-
Capture diagnostic images alongside results: React - PMF Story and Diagnostics
-
All configuration options: React - Configuration Reference
React - PMF Story and Diagnostics
This page explains how to capture and use the PMF story in a React application with the NDX Imaging Web SDK. The PMF story is a per-frame diagnostic log recorded during scanning. It is useful for debugging, quality assurance, and sending richer diagnostic data to the server. Most applications do not need to show this data to end users.
Good to know: PMF story capture is mainly for diagnostics and support workflows. Most production apps only need to store or forward it, not render it in the main user experience.
Enabling PMF story capture
PMF story capture is enabled by default and controlled through the pmfStoryConfig option.
const scanner = useNDXReader({
config: '/models/my-cassette/my-cassette.unified.json',
pmfStoryConfig: {
enabled: true,
captureImages: true,
captureErrorFrameImages: true,
},
onSuccess: (strips, pmfStory, diagnostics) => {
console.log(`Captured ${pmfStory.length} frames`);
},
});
|
Option |
Default |
What it does |
|---|---|---|
|
|
|
Captures the per-frame log. Set to |
|
|
|
Saves a PNG of each well-aligned frame to IndexedDB. |
|
|
|
Saves PNGs for frames where baseline or exposure errors occur. |
Image capture requires OffscreenCanvas. It is supported in Chrome 69+, Firefox 105+, and Safari 16.4+. On unsupported browsers, captureImages is ignored and imageUrl is null for all frames.
The pmfStory array
Each item in pmfStory is a FrameData object representing one processed frame.
|
Field |
Type |
What it is |
|---|---|---|
|
|
|
Unix timestamp in milliseconds for when the frame was processed. |
|
|
|
Alignment quality where |
|
|
|
Estimated ambient light level in lux. |
|
|
|
Status of each strip such as |
|
|
|
Intensity profile for each strip for that frame. |
|
|
|
IndexedDB key for the captured full-frame image, or |
|
|
|
IndexedDB keys for cropped strip images for that frame. |
Displaying captured images
Captured images are stored in IndexedDB. To display them in an img tag, first resolve the stored keys into Blob URLs.
import { resolveStoryImages, revokeStoryImageUrls } from '@novarumdx/ndx-imaging-web/react';
onSuccess: async (strips, pmfStory) => {
const resolvedFrames = await resolveStoryImages(pmfStory);
setFrames(resolvedFrames);
},
Each resolved frame includes two extra fields:
-
imageObjectUrl, a Blob URL for the full frame PNG, ornull -
stripImageObjectUrls, Blob URLs for cropped strip PNGs, ornullper strip
You can then render the images in your UI.
function StoryViewer({ frames }) {
return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
{frames
.filter(frame => frame.imageObjectUrl !== null)
.map((frame, i) => (
<img
key={i}
src={frame.imageObjectUrl}
alt={`Frame ${i}`}
style={{ width: 120, height: 90, objectFit: 'cover' }}
/>
))}
</div>
);
}
Memory note: Blob URLs keep memory alive until they are revoked. Always release them when the images are no longer needed.
Clean up Blob URLs when leaving the page or replacing the image set.
useEffect(() => {
return () => {
if (frames.length > 0) {
revokeStoryImageUrls(frames);
}
};
}, [frames]);
Identifying error frames
You can distinguish error frames from normal captured frames using the frame metadata:
-
Normal captured frame:
pmfStatusis2andstripImageUrlscontains strip crop keys -
Error frame:
pmfStatusis2butstripImageUrlsare allnull
const errorFrames = pmfStory.filter(frame =>
frame.imageUrl !== null && frame.stripImageUrls.every(url => url === null)
);
Cleaning up after a session
Images can accumulate in IndexedDB across scans. The SDK clears them automatically at the start of each scan, but images may remain if the app closes during a session. You can clean up old images when your app mounts.
import { clearAllPmfStoryImages } from '@novarumdx/ndx-imaging-web/react';
useEffect(() => {
clearAllPmfStoryImages().catch(() => {
// best effort, ignore errors
});
}, []);
Sending PMF story to the server
The SDK includes serializeAnalysisResult, which packages strips, PMF story, and diagnostics into the JSON structure expected by the NovarumDX server API. Contact NovarumDX for the server API specification and integration guidance. This function requires engine configuration context that NovarumDX will help you wire up as part of your integration.
Next steps
-
All configuration options: React - Configuration Reference
-
Troubleshooting image capture: Troubleshooting
React - Configuration Reference
Complete reference for all useNDXReader options. All options are optional unless marked required.
useNDXReader options
const scanner = useNDXReader({
config,
hScale,
nFrames,
collectionMode,
cameraDeviceId,
torchEnabled,
testName,
pmfStoryConfig,
onReady,
onFitStatus,
onProgress,
onWarnings,
onSuccess,
onError,
onAbort,
});
config
Type: string
Required: yes
URL path to the unified JSON cassette configuration file in your public/ folder.
config: '/models/my-cassette/my-cassette.unified.json'
The path must be accessible from the browser at runtime. Place the file in public/models/ and reference it with a leading /.
hScale
Type: number
Default: value from the cassette config file
Homography scale factor override. Controls how the SDK maps the cassette shape in the camera frame. The cassette config file contains the correct default value for your assay. Only set this if NovarumDX has asked you to override it.
nFrames
Type: number
Default: value from the cassette config file
Number of frames to collect per strip before running analysis. The cassette config file contains the correct default. Higher values give more accurate results but take longer.
collectionMode
Type: boolean
Default: false
When true, the SDK collects frames and images but skips the final strip analysis. onSuccess is still called but strips will be empty. Use this for data collection workflows where analysis happens server-side.
cameraDeviceId
Type: string
Default: default rear camera
The device ID of the camera to use. Leave empty to use the default rear-facing camera, which is recommended for most apps.
const devices = await navigator.mediaDevices.enumerateDevices();
const cameras = devices.filter(d => d.kind === 'videoinput');
// cameras[i].deviceId - pass to cameraDeviceId
torchEnabled
Type: boolean
Default: false
Request the device torch while scanning. This only works on devices that support torch control through the browser camera API, mainly Android Chrome. It is silently ignored on iOS and desktop browsers.
testName
Type: string
Default: 'unknown'
A name for this scan, used to generate the session ID included in the PMF story. Set this to the name of the assay or test type, for example 'covid-antigen'. The session ID format is {testName}-{timestamp}.
pmfStoryConfig
Type: object
Default: { enabled: true, captureImages: false }
Controls PMF story capture. See React - PMF Story and Diagnostics for full details.
pmfStoryConfig: {
enabled: true,
captureImages: true,
captureErrorFrameImages: true,
maxFrames: 1000,
}
onReady
Type: () => void
Called when the cassette model is loaded and the engine is ready to scan. This is the right time to enable your Begin Scan button.
onReady: () => {
setEngineReady(true);
},
onFitStatus
Type: (fitStatus: 0 | 1 | 2) => void
Called on every frame with the current alignment quality. NDXReaderView uses this to colour the overlay, red for no fit, amber for marginal, green for aligned. You only need this callback if you are building your own scan UI outside of NDXReaderView.
onProgress
Type: (value: number, max: number, strip: number) => void
Called each time a frame is stored. value is the current frame count, max is the total needed, and strip is the strip index. NDXReaderView renders a progress bar automatically. You only need this if you are building your own.
onWarnings
Type: (warnings: NDXReaderWarning[]) => void
Called when the set of active quality warnings changes.
|
warnCode |
Meaning |
|---|---|
|
|
Baseline quality too low, check for shadows or uneven lighting |
|
|
Exposure out of range, too bright or too dark |
|
|
Both baseline and exposure problems |
NDXReaderView renders warning messages automatically.
onSuccess
Type: (strips, pmfStory, diagnostics, analysisMs) => void
Called when the scan completes successfully. See React - Working with Results and React - PMF Story and Diagnostics for details on the parameters.
onError
Type: (error: NDXReaderError) => void
Called when the camera or engine encounters an error it cannot recover from.
|
Field |
Type |
What it is |
|---|---|---|
|
|
|
Error code identifier (see below) |
|
|
|
Human-readable description, safe to show to the user |
Common error codes:
|
code |
Cause |
|---|---|
|
|
User denied camera access |
|
|
No camera available on this device |
|
|
Camera is being used by another app or tab |
|
|
The page is not a secure context (needs HTTPS or localhost) |
|
|
Imaging engine failed to initialise |
onAbort
Type: (pmfStory, diagnostics) => void
Called if scanner.abort() is invoked before the scan completes. Delivers the partial PMF story collected up to that point.
NDXReaderView props
NDXReaderView accepts the scanner object and optional props.
<NDXReaderView
scanner={scanner}
autoStart={true}
/>
|
Prop |
Type |
Default |
What it does |
|---|---|---|---|
|
|
|
required |
The object returned by |
|
|
|
|
Starts the camera automatically when the component mounts |
The view manages its own camera lifecycle. When autoStart is true, which is the default, mounting NDXReaderView opens the camera immediately. When the component unmounts, the camera is stopped automatically.
TypeScript types
import type {
NDXReader,
NDXReaderConfig,
NDXReaderError,
NDXReaderWarning,
NDXReaderStatus,
} from '@novarumdx/ndx-imaging-web/react';
import type {
StripAnalysis,
FrameData,
PmfStoryConfig,
PmfStoryDiagnostics,
} from '@novarumdx/ndx-imaging-web';
5. Support & Troubleshooting
Common installation, camera, and runtime issues - for both React and Vanilla - are collected on the shared Troubleshooting page: Troubleshooting. Check there first.
For access credentials, configuration files, or integration assistance, please contact your Novarum support representative or technical account manager.
For streamlined support and rapid troubleshooting, users can access the SDK Service Desk. This portal provides a direct channel for submitting integration queries, reporting issues, and tracking the status of your requests. In addition, the service desk is equipped with an intelligent agent capable of answering common technical questions and guiding you through standard troubleshooting steps.
We recommend using this resource for the fastest resolution of SDK-related issues or to obtain up-to-date technical guidance.
6. Document History
| Revision | Summary | Date |
|---|---|---|
| 01 | initial revision | Jul 20, 2026 |