A prototype that analyzes a hand-drawn analog clock with Claude Opus 4.8 (vision) using established Clock Drawing Test (CDT) scoring.
🚧 ALPHA. Testing only. Use synthetic / fake data. 🗄️ This app stores nothing. It shows the result JSON, then deletes it. 🩺 Not a medical device. Not a diagnosis.
📄 Released to the public domain under CC0 1.0.
A tester enters synthetic age + cognitive status, then asks the person to draw a circular analog clock set to the standardized command time "ten past eleven" (11:10) and photographs it. The image goes to Claude Opus 4.8, which is asked only to describe what it sees — a neutral feature ledger (the circle, every number glyph, the hands). The app then scores that ledger deterministically (see How the scoring works below) against:
- MoCA clock item (0–3: contour / numbers / hands)
- Shulman 5-point and Sunderland 10-point scales
- ACE-III clock item (0–5: circle / numbers / hands — the numbers point is lost when digits are drawn outside the circle)
- Mendez CDIS (20 items, 0–20: general / number / hand groups)
- Rouleau-style qualitative error analysis (visuospatial/executive, conceptual deficit, perseveration, graphic difficulty, stimulus-bound, planning)
- domain observations + cautious literature-association notes
- a target-vs-drawn time-accuracy check against the fixed 11:10 target; the prompt reads the hands by length (hour = the shorter hand, minute = the longer hand)
A Start over button appears with the result; it clears all inputs and the captured photo (the capture widgets re-mount empty) and returns to the acknowledgement gate.
The drawing and result JSON are named <ip>__<date>.png / .json (the client
IP plus a UTC date-time), shown on screen with their contents, then the
temporary files are deleted immediately. Nothing is persisted: the IP and
date are used only to build those filenames and appear in the displayed output.
The IP comes from st.context.ip_address, or a reverse-proxy X-Forwarded-For
/ X-Real-Ip header; a localhost run is recorded as 127.0.0.1 (Streamlit
reports no IP for loopback clients).
The model does perception only — it never assigns a score. It returns a
neutral feature ledger (the contour; an enumeration of every number glyph
exactly as drawn, including duplicates, missing numbers, and anything drawn
outside the circle or beyond 12; and the hands). The app then computes every CDT
scale deterministically in Python (scoring_utils.derive_scores) from that
one ledger — so the same perception always yields the same scores, and the scales
can't contradict one another (a duplicated or missing number moves every scale
coherently instead of being re-judged differently inside each rubric).
Three techniques improve fidelity to a human rater:
- Self-consistency. The drawing is read a few times and the ledgers are merged
by majority vote per clock position (
merge_ledgers), cancelling run-to-run variance in how an ambiguous glyph is read. This costs a few extra API calls per analysis; the count isCONSENSUS_PASSESinclock_analyzer.py. - Position-aware presence. A glyph the model can't confidently read but that sits at a valid clock position is credited as that number — the charitable reading a rater applies to a malformed-but-placed numeral. A legibly wrong number is left as the error it is, and every position-credit is flagged, never silent.
- Transparency. Each result shows a per-criterion rationale (why each MoCA / ACE-III point was earned or lost) and a "What the model saw" panel listing the numbers it read — with duplicates, missing, and position-credited glyphs marked — so a score is always explainable.
Results also include a quantified, non-diagnostic screening signal: how many of the five scales fall below a commonly-cited screening threshold from the literature. This is a screening-awareness cue, not a diagnosis or clinical cut-off. Mind the scale directions — higher is better on MoCA, Sunderland, ACE-III and Mendez, but on Shulman lower is better (1 = best clock, 5 = most difficulty).
The app writes the drawing and the result JSON to temp files (named from IP +
date) purely so it can display real filenames and contents, then calls
os.remove on both and confirms they're gone (transient_output.py). There is
no database, no CSV, no retained images. A tester can optionally click the
"Download to your device" buttons — that's the tester saving locally, not the
app retaining anything.
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
export ANTHROPIC_API_KEY=sk-ant-... # or paste it in the sidebar
streamlit run app.pyapp.py resolves the key in this order: st.secrets["ANTHROPIC_API_KEY"] →
ANTHROPIC_API_KEY env var → a key typed into the sidebar. If a key is found in
secrets (or the environment), the app uses it and shows which source it came
from; otherwise the sidebar prompts for one (entered keys are not stored). The
model is pinned to claude-opus-4-8.
The repo never contains a real key. To run the deployed app without anyone having to paste a key into the sidebar, set the key as a Streamlit secret:
- Streamlit Community Cloud: in the app's Settings → Secrets, add
Streamlit stores it server-side and exposes it via
ANTHROPIC_API_KEY = "sk-ant-..."
st.secrets— it is not committed to GitHub. - Local equivalent: copy
.streamlit/secrets.toml.exampleto.streamlit/secrets.toml(gitignored) and fill in the key.
Capture uses a custom countdown auto-capture component
(components/clock_camera): a live rear-camera preview with a Start countdown
button that counts 5 → 1 and grabs the frame automatically (helpful for someone
who can't reliably tap at the right moment), plus a Take photo now button.
st.file_uploader is always available as the alternative. Browsers only allow
camera access in a secure context — HTTPS, or a localhost origin — so:
http://localhost:8501on the dev machine → camera works.http://<lan-ip>:8501from your phone over plain HTTP → camera is blocked by the browser (not a bug).- Any HTTPS deployment → camera works on desktop and mobile, including iOS Safari.
Detection is optimistic. The only thing that switches the app to an
upload-only path is an insecure context — caught server-side by
camera_can_work(st.context.url) (plain HTTP to a remote host) or by the
client probe returning "insecure". The app does not hide the camera just
because navigator.mediaDevices.enumerateDevices() reports no video device:
that call returns no videoinput before the user grants camera permission on
many browsers (notably iOS Safari), so trusting it would wrongly hide a working
camera over the web. The Upload tab is always available as a fallback, so a
device with genuinely no camera loses nothing. If streamlit-javascript isn't
installed, the app still runs on the server-side check alone.
To test the camera on a phone during development, use an HTTPS URL — deploy to
an HTTPS host, or tunnel (cloudflared tunnel --url http://localhost:8501 /
ngrok http 8501) and open the https://… link on the phone. Running in WSL
and opening http://localhost:8501 in the Windows browser counts as localhost,
so the camera works there.
Note: capture defaults to the rear camera (the camera points at the drawing,
not the user's face — better for privacy). The clock_camera component requests
the rear camera directly; if it can't be imported the app falls back to
streamlit-back-camera-input, then to st.camera_input (browser's default
camera). All capture sources — the countdown component / back-camera widget
(base64 data URL), st.camera_input/st.file_uploader (UploadedFile), or raw
bytes — are normalized to (bytes, mime) by normalize_image() before analysis.
| File | Purpose |
|---|---|
app.py |
Streamlit UI: acknowledgement gate, fixed 11:10 instruction, capture, results (description + screening signal + score tables + per-criterion rationale + "what the model saw"), print-then-delete result |
clock_analyzer.py |
Claude Opus 4.8 vision call + perception-only ledger prompt, read CONSENSUS_PASSES× and merged by majority vote, + JSON parsing; all scoring is delegated to scoring_utils |
scoring_utils.py |
Pure logic: deterministic CDT scoring from the perception ledger (MoCA / Shulman / Sunderland / ACE-III / Mendez), consensus voting (merge_ledgers), position-aware number presence, per-criterion rationale, quantified screening signal, time-accuracy check, result formatting; unit-tested |
components/clock_camera/ |
In-browser countdown auto-capture component (live preview → 5→1 countdown → returns a data: URL); no npm build |
transient_output.py |
Names files from IP + date, writes temp files, then deletes them |
test_basic.py |
pytest suite for the pure logic (no Streamlit/SDK/network) |
logos/ |
Header branding shown at the top of the app (DataTecnica, Center for Alzheimer's, VCU) |
See CLAUDE.md for project context and the hard invariants (no storage, not a
diagnosis, synthetic data only, IP+date filenames, pinned model). Quick loop:
pip install -r requirements-dev.txt
pytest -q # pure logic only — no API key or network neededTests live in test_basic.py at the repo root and import the top-level modules
directly (no tests/ package or pyproject.toml needed). The Anthropic SDK is
imported lazily inside analyze_clock, so the modules and tests import cleanly
without it.
This alpha deliberately punts on storage and compliance. Before collecting any real data, whoever deploys this must handle: a real private store (managed DB + private bucket, not the local filesystem), encryption at rest, consent, a privacy policy, retention/deletion, and — given the health-adjacent nature of the data — HIPAA / GDPR and likely IRB review. Keep the output framed as screening, never diagnostic, to avoid turning it into a regulated medical device.
The scoring and interpretation in clock_analyzer.py are based on these
published Clock Drawing Test frameworks. Links were checked at the time of
writing.
- MoCA (clock item, 0–3). Nasreddine ZS, et al. The Montreal Cognitive Assessment, MoCA: A Brief Screening Tool for Mild Cognitive Impairment. J Am Geriatr Soc. 2005;53(4):695–699. https://doi.org/10.1111/j.1532-5415.2005.53221.x
- Shulman 5-point scale (origin). Shulman KI, Shedletsky R, Silver IL. The challenge of time: clock-drawing and cognitive function in the elderly. Int J Geriatr Psychiatry. 1986;1(2):135–140. This paper introduced the five-point hierarchical error-classification scale. https://doi.org/10.1002/gps.930010209
- Shulman scale (later review). Shulman KI. Clock-drawing: is it the ideal cognitive screening test? Int J Geriatr Psychiatry. 2000;15(6):548–561. https://pubmed.ncbi.nlm.nih.gov/10861923/
- Sunderland 10-point scale. Sunderland T, et al. Clock drawing in Alzheimer's disease: a novel measure of dementia severity. J Am Geriatr Soc. 1989;37(8):725–729. https://pubmed.ncbi.nlm.nih.gov/2754157/
- ACE-III clock item (0–5). Hsieh S, Schubert S, Hoon C, Mioshi E, Hodges JR. Validation of the Addenbrooke's Cognitive Examination III in frontotemporal dementia and Alzheimer's disease. Dement Geriatr Cogn Disord. 2013;36(3–4):242–250. https://doi.org/10.1159/000351671 — the clock is scored circle (1) / numbers (2) / hands (2); the numbers point is lost when digits fall outside the circle. Command time: "ten past five" (the app uses 11:10).
- Mendez CDIS (20-item, 0–20). Mendez MF, Ala T, Underwood KL. Development of scoring criteria for the clock drawing task in Alzheimer's disease. J Am Geriatr Soc. 1992;40(11):1095–1099. https://doi.org/10.1111/j.1532-5415.1992.tb01796.x — 20 items scored 0/1 (general / number / hand groups); built around the "ten past eleven" command.
- Rouleau qualitative error taxonomy. Rouleau I, Salmon DP, Butters N, Kennedy C, McGuire K. Quantitative and qualitative analyses of clock drawings in Alzheimer's and Huntington's disease. Brain Cogn. 1992;18(1):70–87. https://doi.org/10.1016/0278-2626(92)90112-Y
- General review (utility, scoring methods, caveats). Pinto E, Peters R. Literature review of the Clock Drawing Test as a tool for cognitive screening. Dement Geriatr Cogn Disord. 2009;27(3):201–213. https://pubmed.ncbi.nlm.nih.gov/19225234/
Important: these are the frameworks the prompt draws on — they do not make this app a validated instrument. CDT performance is influenced by vision, motor control, education, language, and the drawing medium, and the literature notes limited sensitivity for mild impairment. Subtype "association" notes (e.g. conceptual/stimulus-bound errors discussed more in Alzheimer's contexts vs. visuospatial/graphomotor errors in subcortical/vascular/Parkinsonian contexts) are general tendencies from the literature, not a means of identifying any condition in an individual.