diff --git a/.env.template b/.env.template index 48426f05..22da75af 100644 --- a/.env.template +++ b/.env.template @@ -7,3 +7,28 @@ OAUTHLIB_INSECURE_TRANSPORT=0 # Set this to 1 if in dev environment SECRET_KEY=this-really-needs-to-be-changed SQLALCHEMY_TRACK_MODIFICATIONS=False UPLOAD_DIR="uploads/" + +# ── Guided Learning tutor (LLM) ─────────────────────────────────────────────── +# Optional shared default that powers the /learn AI tutor when a visitor has not +# entered their own key. Any OpenAI-compatible endpoint works. Visitors can +# always override these from the in-page Settings panel (their key stays in +# their browser). If none is set here and no visitor key is provided, the tutor +# streams static reference material instead of failing. +# +# DEFAULT (free, open-source model, no paid key): Groq's Llama 3.3 70B. When a +# key is present, LLM_BASE_URL and LLM_MODEL default to Groq automatically — so +# create a free key at https://console.groq.com/keys and set ONLY the key: +# LLM_API_KEY=gsk_... +# To use a different provider, also set LLM_BASE_URL / LLM_MODEL, e.g.: +# OpenAI : LLM_BASE_URL=https://api.openai.com/v1 LLM_MODEL=gpt-4o-mini (paid) +# Ollama : LLM_BASE_URL=http://localhost:11434/v1 LLM_MODEL=llama3.2 (local, no key) +LLM_API_KEY= +LLM_BASE_URL= +LLM_MODEL= +# Embedding model for RAG knowledge retrieval (defaults follow LLM_API_KEY). +# NOTE: Groq serves chat models only, not embeddings — do NOT run +# `flask ingest-knowledge` against a Groq key. Retrieval degrades gracefully to +# the built-in landmark reference when no embeddings store is present, so the +# tutor works fine without ingestion. For real RAG embeddings use an +# embeddings-capable provider (OpenAI, or local Ollama with nomic-embed-text). +EMBED_MODEL= diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6dca881d..2d07e098 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -20,6 +20,13 @@ jobs: - name: Checkout reference branch uses: actions/checkout@v4 + - name: Setup Python + # Pin to a version inside pyproject's supported range (>=3.8,<3.12); + # ubuntu-latest now defaults to 3.12, which poetry would reject. + uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install poetry uses: snok/install-poetry@v1 with: @@ -41,12 +48,25 @@ jobs: ORCID_OAUTH_CLIENT_ID: ${{ secrets.PRODUCTION_ORCID_OAUTH_CLIENT_ID }} ORCID_OAUTH_CLIENT_SECRET: ${{ secrets.PRODUCTION_ORCID_OAUTH_CLIENT_SECRET }} SECRET_KEY: ${{ secrets.PRODUCTION_SECRET_KEY }} + # Guided-learning tutor. Only the key is a secret; URL/model default to + # Groq's free Llama 3.3 70B endpoint but can be overridden by optional + # secrets to switch providers without editing this file. + LLM_API_KEY: ${{ secrets.PRODUCTION_LLM_API_KEY }} + LLM_BASE_URL: ${{ secrets.PRODUCTION_LLM_BASE_URL || 'https://api.groq.com/openai/v1' }} + LLM_MODEL: ${{ secrets.PRODUCTION_LLM_MODEL || 'llama-3.3-70b-versatile' }} run: | echo DATABASE_URL="$DATABASE_URL" >> .env echo FLASK_ENV="$FLASK_ENV" >> .env echo ORCID_OAUTH_CLIENT_ID="$ORCID_OAUTH_CLIENT_ID" >> .env echo ORCID_OAUTH_CLIENT_SECRET="$ORCID_OAUTH_CLIENT_SECRET" >> .env echo SECRET_KEY="$SECRET_KEY" >> .env + # Only write LLM vars when a key is configured, so a deploy without the + # secret leaves the tutor on its graceful static-reference fallback. + if [ -n "$LLM_API_KEY" ]; then + echo LLM_API_KEY="$LLM_API_KEY" >> .env + echo LLM_BASE_URL="$LLM_BASE_URL" >> .env + echo LLM_MODEL="$LLM_MODEL" >> .env + fi - name: Build release run: | diff --git a/.gitignore b/.gitignore index d575eee7..98fb263f 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,14 @@ _site/ *.pyc .env *.egg-info +*.db +~$* + +# Flask instance folder (local dev DB, cached templates) +instance/ # Workspaces afids.code-workspace + +# Generated Word manuscript (regenerate: python make_paper_doc.py) +*.docx diff --git a/AFIDs_Validator_Aperture_Education_AT_FINAL.md b/AFIDs_Validator_Aperture_Education_AT_FINAL.md new file mode 100644 index 00000000..4aa1dc7e --- /dev/null +++ b/AFIDs_Validator_Aperture_Education_AT_FINAL.md @@ -0,0 +1,274 @@ +# **AFIDs-Validator: An Open-Access, AI-Guided Platform for Learning Anatomical Landmark Placement** + +### **Authors** + +### Alaa Taha1,2, Dhananjhay Bansal2, Arun Thurairajah2,6, Jaime Thrower2, Jason Kai2,5, Tristan Kuehn2,3, Greydon Gilmore2,3,4, Mohamad Abbass2,4,6, Ali R. Khan2,3,5,6,7 & Jonathan C. Lau2,3,4,6,7 + +### **Affiliations** 1\. Stanford University School of Medicine, Stanford, CA, United States of America. 2\. Imaging Research Laboratories, Robarts Research Institute, Western University, London, Canada. 3\. School of Biomedical Engineering, Western University, London, Canada. 4\. Department of Clinical Neurological Sciences, Division of Neurosurgery, Western University, London, Canada. 5\. Department of Medical Biophysics, Schulich School of Medicine and Dentistry, Western University, London, Canada. 6\. Graduate Program in Neuroscience, Western University, London, Canada. 7\. Centre for Functional and Metabolic Mapping, Robarts Research Institute, The University of Western Ontario, London, Canada. + +\*corresponding author: Jonathan C. Lau ([jonathan.c.lau@gmail.com](mailto:jonathan.c.lau@gmail.com)) + +**Keywords:** neuroimaging education; anatomical fiducials; spatial normalization; quality control; AI tutoring; large language models; brain atlas; MRI training; open science + +## **Abstract** + +Accurate placement of anatomical landmarks is a foundational skill in neuroimaging. Yet it is taught informally through expert mentorship and requires desktop software. There is no openly accessible, interactive resource that teaches it from first principles with quantitative feedback. Here we describe the AFIDs-Validator ([https://validator.afids.io](https://validator.afids.io)), an open-access web platform that integrates quality assurance and active learning in the browser. We present: (1) an AI-guided learning mode that embeds a large language model (LLM) neuroanatomy tutor inside a browser-based MRI viewer (NiiVue), delivering anatomy-first, viewer-context-aware instruction for localization of various brain landmarks; and (2) a multi-template validation engine that accepts landmark files and computes per-landmark Euclidean error against expert-annotated reference templates spanning 15 human and 6 macaque brain atlases. Analysis of 492 expert annotations across 132 subjects shows that landmark difficulty spans nearly fourfold — from a median trained-rater error of 0.37 mm at the commissures to ~1.5 mm at the temporal horns — and is heavy-tailed at every landmark; the platform compiles these distributions into a per-landmark reliability prior so that the same 1.2 mm placement is scored as *expert* on a hard landmark yet *off-target* on an easy one, and each learner is placed within the trained-rater distribution rather than against an arbitrary threshold. We further demonstrate the validation engine catching a real template-space mismatch as an anatomically localized error signature (mean 2.9 mm, concentrated in temporal and ventricular landmarks) that global image-similarity checks miss. The AFIDs-Validator platform is a scalable, reproducible, and equitable model for neuroanatomy education, and we release all code, reference data, and the tutor's design under an open license. + +## **1\. Introduction** + +The past decade has produced a remarkable ecosystem of open-source neuroimaging infrastructure such as preprocessing pipelines (fMRIPrep, Esteban et al., 2019; FSL, Jenkinson et al., 2012), automated image-quality metrics (MRIQC, Esteban et al., 2017), data-sharing platforms (OpenNeuro, Markiewicz et al., 2021), standardized template libraries (TemplateFlow, Ciric et al., 2022), and browser-native viewers (NiiVue, Taylor & Rorden, 2023). These tools have collectively lowered the barrier to reproducible, large-scale analysis. They share a common dependency on registration accuracy of individual brain volumes to a standard reference space, which underpins nearly every downstream result. + +Normalization accuracy is routinely assessed by visual inspection or image-similarity metrics, both of which are insensitive to the regionally specific, anatomically interpretable errors that can corrupt group-level analyses. A brain that may appear correctly normalized can harbor misregistration errors that bias parcellation, tractography, and region-of-interest estimates. Landmark-based quality control addresses this directly where precisely defined anatomical landmarks in a normalized image must agree with their template positions. + +Locating the fundus of a sulcus, the tip of a ventricular horn, or the exact crossing of a commissure on a grayscale volume is difficult and error-prone, and the skill is most often taught clinically through apprenticeship. A trainee typically learns through an expert, placing landmarks, and being corrected in real time. This model produces excellent raters but does not scale and may be unavailable to many trainees in lower-resourced settings. Atlas reading and didactic lectures convey where structures are in the abstract but not how to find them on a specific noisy image, and they provide no feedback on whether the learner got it right. + +The Anatomical Fiducials (AFIDs) protocol was developed as a framework for landmark-based correspondence (Lau et al., 2019). It defines precisely specified landmarks distributed throughout the brain (from commissural midline structures to ventricular tips and sulcal fundi) with explicit operational definitions that minimize placement ambiguity. Original validation studies demonstrated mean inter-rater Euclidean errors of 1 to 2 mm and intraclass correlation coefficients (ICC) exceeding 0.9 for most landmarks after minimal training (Lau et al., 2019). Subsequent multi-cohort validation (Taha et al., 2023\) established these reliability benchmarks across 132 subjects, 30 rater sessions, and four imaging datasets, with rater experience spanning trainees with no prior imaging background to neurosurgical residents. The protocol is maintained as an open GitHub organization ([https://github.com/afids](https://github.com/afids)) comprising the specification, a curated multi-rater dataset, automated localization tools (autoafids), and Python utilities (afids-utils). + +Despite this maturity, using AFIDs in practice still requires 3D Slicer (Fedorov et al., 2012\) and access to template reference files and comparison tooling which is a workflow unfamiliar to most incoming trainees. There is no interactive resource for learning landmark placement, and no browser-native way to check a placement against a reference. The gap is in the user-facing infrastructure that would make the AFIDs ecosystem, and the skill it encodes, broadly teachable. In this work, we present the AFIDs-Validator ([https://validator.afids.io](https://validator.afids.io)), which provides **(1) an AI-guided learning mode** that teaches landmark placement interactively with anatomical explanation, quantitative feedback, and no expert supervision and **(2) an instant-feedback validation engine** for any AFIDs file against reference templates. This paper describes the platform, the pedagogical design of the AI tutor, the empirical basis for its feedback, and the accessibility choices that let a trainee use it end-to-end. + +## **2\. Platform overview** + +The AFIDs-Validator (see Figure 1\) is a browser-based web application on a Flask 3.0 / Python 3.11 backend with a React 18 frontend. It is containerized with Docker Compose and deployed via Nginx, supporting both the hosted instance at: [validator.afids.io](https://validator.afids.io) and self-hosted institutional deployments. All source code is public at: [github.com/afids/afids-validator](https://github.com/afids/afids-validator) under GPL-3.0. + +The complete guided-learning and validation workflows run in any modern browser on any operating system. ORCID OAuth ([https://orcid.org](https://orcid.org)) is optionally available for researcher identity, enabling longitudinal tracking of placement performance across sessions; authenticated users may opt in to contribute sessions to an institutional database (PostgreSQL via SQLAlchemy), and no placement data is retained without explicit consent. + +The validator engine accepts two file formats. **FCSV** (Fiducial CSV) is the native export of 3D Slicer's Markups module (Fedorov et al., 2012), the recommended placement tool in the AFIDs protocol. **AFIDs JSON** is a lightweight alternative produced by the `afids-utils` package ([https://github.com/afids/afids-utils](https://github.com/afids/afids-utils)). Both are validated against the landmark schema, with automatic detection and internal conversion of coordinate convention (RAS vs. LPS). + +The learning and validation components of the AFIDs-Validator are deliberately continuous where a learner trains on the guided mode, then uploads an independent placement to the validation engine and receives the same quantitative feedback used in research quality control. + +## **3\. The guided learning mode** + +The guided learning mode (`/learn`) is the platform's central pedagogical contribution. This entails an interactive neuroanatomy training workflow for 32 anatomical landmarks, in a browser, without installation, institutional affiliation, or expert supervision (Figures 2 and 3). It embeds a NiiVue (Taylor & Rorden, 2023\) MRI viewer loaded with the MNI152NLin2009cAsym T1w template from TemplateFlow (Ciric et al., 2022\) beside a streaming LLM chat interface, a live coordinate readout, and per-landmark progress tracking. + +Each landmark proceeds through five steps: + +1. **Introduction** (LLM, streamed, ≤5 sentences): the structure's anatomical identity and functional significance; its appearance on T1w contrast; the recommended imaging plane; and the single most common placement error. +2. **Placement** (learner): a click in NiiVue records a coordinate in RAS mm. +3. **Computation** (server, \<50 ms): Euclidean distance to the template reference, directional offsets, and a quality rating. +4. **Feedback** (LLM, streamed, ≤6 sentences): a one-line verdict; anatomical reasoning about the likely error; directional guidance in anatomical language; and, when relevant, a specific viewer adjustment. +5. **Dialogue** (learner-initiated): free-form Q\&A with maintained conversation history. + +A learner completing the full set produces a downloadable session report containing a per-landmark table of placements, distances, and quality, together with the full tutoring transcript that serves both as a study artifact and as documentation of proficiency. Well-designed intelligent tutoring systems can approach the effectiveness of one-on-one human tutoring when they pair step-level feedback with sound instructional design (VanLehn, 2011); we accordingly design the tutor's behaviour around a small set of principles from the learning sciences: + +1. **Contextual instruction.** The learner encounters each landmark in situ — instruction arrives at the moment of placement, in the same viewer, on the same image they will be evaluated against. Situating learning in the environment of use improves retention and transfer relative to decontextualized atlas study (Lave & Wenger, 1991; Koedinger & Corbett, 2006). +2. **Active generation before instruction.** The mode gives a brief orientation but withholds full explanation until the learner has attempted a placement. This ordering reflects the "productive failure" framework (Kapur, 2008, 2016; Loibl et al., 2017): learners who struggle with a problem before receiving targeted instruction show superior long-term retention and transfer. A trainee who has tried to locate the posterior commissure integrates the subsequent explanation differently than one who merely read it. +3. **Anatomy-first, coordinates-never.** The tutor's system prompt explicitly forbids giving target coordinates or numerical navigation. This defends against the most common failure mode of LLMs used as anatomical assistants which trains lookup rather than recognition and produces a skill that collapses on individual-subject data with variable anatomy. +4. **Viewer-context-aware scaffolding.** The NiiVue viewer state at the moment of placement (e.g., zoom, image resolution, and contrast window) is captured and included in the feedback request, letting the tutor recommend concrete viewer changes: *"you placed this at low zoom in 2 mm resolution; switching to 1 mm (the RES button) and zooming in would reveal the fine structure here"*. This adaptation to the learner's visual environment is often crucial for improving accuracy. +5. **Scaffolded, protocol-grounded feedback.** Rather than embedding all 32 definitions in every prompt, the tutor is grounded by retrieval-augmented generation (RAG): for each landmark or question, the most relevant AFIDs protocol definitions, key MRI features, and catalogued common mistakes are retrieved from a knowledge store and injected into the model's context. This keeps feedback anchored to the published protocol and reduces the tutor's reliance on unverified parametric knowledge. When the knowledge store is unavailable, retrieval falls back to the curated landmark dictionary shipped with the codebase. + +The AI tutor LLM engine is configurable through environment variables (`LLM_API_KEY`, `LLM_BASE_URL`, `LLM_MODEL`). This supports commercial models (e.g., GPT-4o; Anthropic Claude via a compatible endpoint) and locally hosted open-weight models (e.g., Llama 3, Mistral via Ollama), as well as low-cost hosted providers. Institutions without commercial API access can run a fully functional tutor on local GPU resources. The pedagogical value derives from the structured prompt, RAG grounding, and feedback workflow and not from any single model's capabilities. + +## **4\. The validation engine** + +For each of the 32 landmarks the validator computes: + +1. **Euclidean distance** from the user coordinate to the template reference (mm); +2. **Directional decomposition**: left/right, anterior/posterior, superior/inferior components (mm), suppressing components below 0.5 mm; +3. **Quality classification**: excellent (\<1 mm), good (1–2 mm), fair (2–4 mm), needs work (≥4 mm). + + +Session-level statistics across all landmarks are reported: mean error, standard deviation, best- and worst-performing landmarks, and counts within 1 mm and 2 mm. The four-tier quality classification communicated to the user is anchored to the published AFIDs multi-rater reliability dataset (Taha et al., 2023). That dataset comprises 132 subjects across four cohorts, with 30 rater sessions spanning novice (0 months imaging experience) to expert (≥24 months), accumulating \>300 rater-hours and \>45,000 Euclidean-distance measurements. In that dataset, trained raters achieved mean inter-rater errors of 1.0 to 1.5 mm for most landmarks, with ICC \> 0.9 for landmarks including AC, PC, and the mammillary bodies. The 2 mm boundary corresponds to roughly the 82nd percentile of trained-rater errors (§5.1), a realistic proficiency target. Errors exceeding 4 mm corresponded to anatomical confusion events (e.g., placing on the habenular commissure instead of PC, or the corpus-callosum body instead of the splenium) rather than imprecision. The thresholds therefore discriminate distinct performance phenotypes rather than carving an arbitrary continuum, which is what makes them meaningful as feedback to a learner. + +Three complementary Plotly visualizations are generated per session (Figure 4): a **3D scatter** of template and user landmark sets with error-coloured connecting lines (localizing spatial error patterns, e.g., a global lateral shift indicating a convention mismatch); a **ranked error histogram** identifying outlier landmarks for targeted practice; and a **regional radar chart** revealing region-level biases that implicate specific anatomical confusions (e.g., systematic temporal-horn error from confusing the horn lumen with the choroid fissure). + +The reference library contains 21 fully annotated brain atlases (15 human and 6 macaque) each with all 32 AFIDs landmarks (Figure 3), yielding 480 expert-annotated human and 192 macaque landmarks (672 in total). All reference FCSV files are version-controlled in the AFIDs GitHub organization and distributed with the validator. + +All 21 reference templates — 15 human and 6 macaque — are catalogued together in Table 1. The human set spans the major contemporary standards, from MNI305 (Collins et al., 1994\) to the 20-µm BigBrain histological atlas (Amunts et al., 2013), with complete coverage of the MNI152 family that serves as the default output space of major pipelines, plus the Parkinson's-optimized PD25 (Xiao et al., 2017). The six macaque templates are D99 (Saleem & Logothetis, 2012), INIA19 (Rohlfing et al., 2012), MacaqueMNI, NMTv1.3 and NMTv2.0asym (Jung et al., 2021), and Yerkes19 (Donahue et al., 2016). + +**Table 1\. Brain templates in the AFIDs-Validator reference library (15 human, 6 macaque; all with the full 32-landmark set)** + +| Template | Species | Primary use | Citation | +| :---- | :---- | :---- | :---- | +| MNI152NLin2009cAsym | Human | fMRIPrep default; contemporary standard | Fonov et al., 2011 | +| MNI152NLin2009cSym | Human | Symmetric variant; FreeSurfer normalization | Fonov et al., 2011 | +| MNI2009cAsym | Human | Near-duplicate alias of MNI152NLin2009cAsym | Fonov et al., 2011 | +| MNI152NLin2009bAsym/Sym | Human | Legacy MNI152 2009b variants | Fonov et al., 2011 | +| MNI152NLin6Asym/Sym | Human | FSL standard space; diffusion imaging | Fonov et al., 2009 | +| MNI152Lin | Human | SPM linear normalization | Fonov et al., 2011 | +| MNI305 | Human | Original MNI standard; clinical reference | Collins et al., 1994 | +| Colin27 | Human | High-resolution single-subject MNI template | Holmes et al., 1998 | +| BigBrain | Human | 20-µm isotropic histological atlas | Amunts et al., 2013 | +| fsaverage | Human | FreeSurfer average surface template | Fischl et al., 1999 | +| OASIS30ANTs | Human | Multi-subject aging template (ANTs registration) | Marcus et al., 2007 | +| PD25 | Human | Parkinson's disease cohort template | Xiao et al., 2017 | +| Agile12v2016 | Human | Population template | Taha et al., 2023 | +| D99 | Macaque | Histology-based rhesus stereotaxic atlas | Saleem & Logothetis, 2012 | +| INIA19 | Macaque | Primate parcellation & spatial normalization | Rohlfing et al., 2012 | +| MacaqueMNI | Macaque | Macaque MNI-style population template | — | +| NMTv1.3 | Macaque | NIMH Macaque Template v1.3 | Jung et al., 2021 | +| NMTv2.0asym | Macaque | NIMH Macaque Template v2.0 (asymmetric) | Jung et al., 2021 | +| Yerkes19 | Macaque | Surface-based macaque population template | Donahue et al., 2016 | + +## **5\. Analysis of reference data** + +The reference library answers two quantitative questions that ground the platform's two contributions. First, *how hard is each landmark to place?* — a difficulty benchmark, computed from 492 expert placements, that tells the guided-learning tutor what a good attempt actually looks like landmark-by-landmark (§5.1–5.2). Second, *can the validation engine catch a real registration error?* — a worked quality-control example on the reference templates themselves (§5.3–5.4). We deliberately foreground these over the descriptive template statistics that motivated earlier versions of this analysis: what matters for a learner is not that references differ in the abstract, but exactly how much difficulty each landmark carries and whether the engine flags a mistake a practitioner would actually make. + +### 5.1 A difficulty spectrum for landmark placement + +Different landmarks are not equally hard, and the guided-learning mode is only as good as its model of that difficulty. We quantified it directly from the released multi-rater dataset: for every subject with multiple raters, each rater's Euclidean distance to the per-subject consensus — the anatomical fiducial localization error (AFLE), a quantity invariant to coordinate convention — was measured for all 32 landmarks and aggregated across all four cohorts (492 rater files, 132 subjects; Figure 5A). + +Median AFLE spans **3.9-fold** across the protocol (Figure 5A). At the easy end sit the commissures (PC **0.37 mm**, AC **0.38 mm**) and the diencephalic floor (mammillary bodies and intermammillary sulcus, \~0.5 mm), compact high-contrast targets. At the hard end sit the temporal-horn landmarks (RIAMTH **1.46 mm**, LIAMTH **1.40 mm**) and the indusium griseum origins (LIGO **1.37 mm**, RIGO **1.24 mm**), thin CSF-adjacent structures with ambiguous boundaries. The global median is **0.87 mm**, with 57% of trained-rater placements within 1 mm and 82% within 2 mm. Critically, the distribution is **heavy-tailed for every landmark**: even the commissures, placed to a median of \~0.37 mm, have a mean roughly 2.2× that, and across landmarks the mean sits 1.4–2.3× the median (Figure 5A, mean markers). These tails are rare gross "confusion events" (e.g., mistaking the habenular commissure for the PC) superimposed on otherwise sub-millimetre precision — which is why the tutor treats a placement beyond a landmark's 90th rater percentile as a likely mis-identification rather than mere imprecision, instead of applying one global tolerance to all 32. + +### 5.2 Calibrated, "you vs. the experts" feedback + +This spectrum is exactly why a single fixed threshold mis-serves a learner, and the platform replaces it with the empirical per-landmark distribution. The same 1.2 mm placement means opposite things depending on the landmark (Figure 5B): on the anterior commissure it falls *beyond* the trained-rater 90th percentile (\~96th percentile — outside the expert range), whereas on the indusium griseum origin it sits *below* the median (\~42nd percentile — a solidly expert placement). A tutor that praised or corrected both identically would teach the wrong lesson at least once. The reliability prior lets the tutor grade each placement against the raters who defined the landmark, and, after each attempt, the validator reports where the learner falls within that distribution — a percentile plus a four-level band (better than the typical trained rater / within range / at the edge / outside; Figure 5C shows the underlying mm→percentile calibration) — turning an abstract millimetre value into a meaningful "you vs. the experts" benchmark that also appears in the session report. Because the prior is regenerated from the released placements by a small script (§9.5), it sharpens automatically as the dataset grows, and — with ORCID-linked opt-in (§2) — learners' own placements can feed back into it, making the resource a living instrument rather than a static one. + +### 5.3 Two kinds of difficulty, and a template caveat + +Rater difficulty (how hard a landmark is to *localize* on one image) is related to but distinct from inter-template variability (how much it *moves* across reference templates). Across the 31 non-AC landmarks the two correlate at **r \= 0.66** (Figure 6A): hard-to-localize landmarks tend also to vary across templates, yet neither predicts the other completely — the ventral occipital horns, for instance, vary substantially across templates but are placed reliably by raters. A learner benefits from knowing which kind of difficulty a landmark carries. The reliability itself is balanced bilaterally: the mean left–right difference in median AFLE is only **\+0.03 mm** across the 11 homologous pairs, indicating convention-neutral definitions rather than a handedness bias. + +The template-variability axis is a correctness caveat rather than a headline. Expressing each template's landmarks relative to its own AC and restricting to the eight canonical MNI152/MNI305 templates (a directly comparable family), AC-normalized variability ranges from **0.41 mm** (right mammillary body) to **2.35 mm** (left temporal horn), mean 1.20 mm, ranked by region in Figure 6B and Table 2. AC–PC distance spans **27.8–31.0 mm**: the modern nonlinear MNI152 variants cluster within \~28.0 ± 0.2 mm, while the linear MNI152Lin (30.3 mm) and original MNI305 (31.0 mm) diverge by 2–3 mm. The practical consequence is that references are not freely interchangeable for every landmark — which motivates the template-specific reference sets the validator ships, and sets up the worked failure below. + +**Table 2\. Inter-template variability by neuroanatomical region (AC-normalized, 8 MNI templates)** + +| Region | N landmarks | Mean variability (mm) | Max pairwise (mm) | +| :---- | :---- | :---- | :---- | +| Diencephalic | 4 | 0.57 | 3.67 | +| Callosal | 2 | 0.73 | 3.60 | +| Brainstem | 7 | 0.86 | 3.63 | +| Cerebellar | 1 | 0.89 | 3.65 | +| Commissural | 1 | 1.26 | 3.89 | +| Basal/Frontal | 6 | 1.42 | 8.29 | +| Ventricular | 4 | 1.56 | 6.61 | +| Temporal | 6 | 1.75 | 9.65 | + +### 5.4 A worked quality-control catch + +The validation engine's purpose is to surface exactly this kind of error on real data, and it is worth showing it work rather than asserting it. We reproduced a common, silent mistake — landmarks defined in one MNI space checked against a reference in another — by validating the MNI305 landmark set against the platform default (MNI152NLin2009cAsym), aligned only at the anterior commissure so that the residual is the geometry a proper registration would still have to recover (Figure 4). The engine does not report a vague "misregistration"; it reports an **anatomically localized signature**: mean error 2.9 mm, with 13 of 32 landmarks beyond their own trained-rater 90th percentile and the error concentrated in the temporal (5.2 mm) and ventricular (4.1 mm) regions while the diencephalic floor stays tight (1.3 mm). The posterior commissure alone lands at 3.6 mm — **4.1× its trained-rater p90** — an unmistakable flag. Crucially, the engine grades severity: repeating the exercise with the milder MNI152Lin mismatch yields a mean of only 1.6 mm with 23 of 32 landmarks still within 2 mm, correctly reading as a subtle rather than gross discrepancy. This region-resolved, per-landmark verdict is precisely what a global image-similarity score or a visual pass would miss, and it is the same output a learner receives on their own upload. + +## **6\. Accessibility, equity, and reproducibility** + +For an educational resource, being *reachable* is a first-class design requirement, not an afterthought. Three commitments follow: + +**Zero-install, no-gatekeeping access.** The entire experience runs in any modern browser with no local installation; there is no download, no account requirement, and no license. A graduate student with a laptop and an internet connection has the same access to the guided-learning mode as a trainee at a well-resourced imaging centre. The bring-your-own-key design (§3) extends this to the AI tutor itself: learners can run the tutor on a shared default, on a free or low-cost hosted model, or on a locally hosted open-weight model — and, when no model is reachable, still receive protocol-grounded static guidance rather than a failure. The intent is that a visitor can complete every task on the site. + +**Web accessibility.** The learning interface is built to be usable beyond the mouse-and-monitor default. The streaming tutor pane is an ARIA live region so screen readers announce feedback as it arrives; interactive controls carry text labels; keyboard focus is visible throughout; and informational text meets contrast targets on the dark theme. These are incremental steps toward WCAG conformance rather than a certification claim; known gaps (notably full keyboard-only fiducial placement on the WebGL canvas) are documented in §8 as active work. + +**Reproducibility and openness.** All source code, the reference FCSV files, the tutor's system prompt, and the analysis and figure-generation scripts are released under GPL-3.0 (§ Code and Data Availability). The learning template is pulled from TemplateFlow with server-side caching, so the exact image a learner trains on is a versioned, citable artifact; likewise, the rater-reliability prior that calibrates feedback is regenerated by a released script from the public AFIDs data (§5.2, §9.5). Because the platform is model-agnostic and self-hostable, an institution can reproduce the entire tutoring environment — including with a fully local model for privacy-sensitive settings — from the public repository. + +## **7\. Use cases and curriculum integration** + +The AFIDs-Validator serves as a versatile educational and professional tool, supporting individual skill development through the guided 32-landmark workflow as well as collaborative environments like workshops and neuroimaging courses where participants can interact without complex software installations. Beyond standard anatomical fiducials, the platform's extensible training architecture can be adapted to train raters on specialized landmark types, such as the precise localization of surgical targets for neuromodulation or planning for surgical resection, and can even facilitate the training required for complex image segmentation processes. This flexibility makes the validator an ideal instrument for multi-site rater qualification, allowing research groups to establish rigorous proficiency benchmarks and use exportable session reports as verifiable documentation of a rater's readiness to contribute to high-stakes clinical or research datasets. + +## **8\. Evaluation framework and future directions** + +This platform's learning-outcome validation is prospective, not yet complete. The guided mode is engineered from established learning-science principles, but we have not yet measured its effect on learner performance. The ORCID-linked opt-in database is designed to accumulate the longitudinal placement data needed for such studies, and we outline the intended evaluation so that it can be replicated: + +- **Pre/post accuracy** on held-out landmarks and templates, comparing guided learning to self-study and to atlas-only instruction; +- **Transfer** from the training template (MNI152NLin2009cAsym T1w) to individual-subject MRI with variable anatomy; +- **Reliability convergence** (ICC vs. expert consensus) as a function of practice, benchmarked against the trained-rater distribution of Taha et al. (2023); +- **Ablation** of the viewer-context and RAG-grounding components to attribute any effect to specific design choices. + +Other known limitations remain: the guided mode currently uses one template and one contrast (T1w), so it does not yet expose learners to pathological anatomy or acquisition variability. Planned directions include additional templates and contrasts, spaced-repetition review of previously missed landmarks, an instructor view for cohort progress, a REST API and BIDS App wrapper for the validation engine, and localization. + +## **9\. Methods** + +### 9.1 Reference template library + +All reference FCSV files were generated by expert raters following the AFIDs protocol ([https://afids.github.io/afids-protocol/](https://afids.github.io/afids-protocol/)) and version-controlled in the AFIDs GitHub organization. FCSV files encode coordinates in LPS convention (3D Slicer v4.6+); the validator reads the `CoordinateSystem` header and converts to a canonical internal representation. All 21 templates contain all 32 landmarks. + +### 9.2 Inter-template variability analysis + +The analysis was restricted to the eight canonical MNI152/MNI305 templates (MNI152Lin; MNI152NLin2009bAsym/bSym/cAsym/cSym; MNI152NLin6Asym/6Sym; MNI305), excluding the near-duplicate MNI2009cAsym alias, so that variability is measured within a directly comparable family. AC-normalized coordinates were computed by subtracting each template's AC coordinate from all 32 positions. For each of the 31 non-AC landmarks we computed the cross-template centroid, each template's Euclidean distance from it (mean ± SD), and the maximum pairwise distance. AC–PC distance is the Euclidean norm of the AC-normalized PC coordinate. Analysis used Python 3.11 and NumPy; code is in the repository (`make_figures.py`, `analyze_afids_templates.py`). + +### 9.3 Validation engine + +Parsing handles FCSV (Markups v4.6+) and AFIDs JSON. Structural validation confirms all 32 labels present, numeric coordinates, and a valid CoordinateSystem header. Per-landmark distance uses NumPy `linalg.norm`; directional components below 0.5 mm are suppressed. Thresholds: excellent \<1 mm, good 1–2 mm, fair 2–4 mm, needs work ≥4 mm, calibrated to Taha et al. (2023), and refined per landmark by the rater-reliability prior (§9.5). Visualizations use Plotly 5.x. + +### 9.4 Guided learning mode + +NiiVue is embedded as a WebGL2 canvas. The MNI152NLin2009cAsym T1w volume is fetched from TemplateFlow's public S3 bucket on first access and cached server-side, at 2 mm (\~1.7 MB, default) and 1 mm (\~9 MB) isotropic. Placement coordinates are returned in RAS mm via the NiiVue API to the Flask `/learn/check` endpoint. Viewer state (zoom, resolution, contrast window) is captured at placement and included in the feedback request. LLM communication uses the OpenAI-compatible Python SDK (≥1.0) with streaming over chunked HTTP; per-request overrides (`api_key`, `base_url`, `model`) supplied by the client take precedence over the server default, and are neither logged nor persisted. Landmark context is assembled by retrieval-augmented generation over an embedded knowledge store of AFIDs definitions and protocol passages, with fallback to the curated landmark dictionary. Conversation history is maintained client-side; maximum 512 tokens per turn. + +### 9.5 Rater-reliability prior + +Per-landmark trained-rater reliability was computed from the AFIDs multi-rater release (Taha et al., 2023). For each subject with multiple rater placements, each rater's Euclidean distance to the per-subject consensus (groundtruth) was measured for every landmark — a quantity invariant to FCSV coordinate convention — and aggregated across all four released cohorts (AFIDs-HCP, AFIDs-OASIS, SNSX, and LHSCPD; 492 rater files, 132 subjects) to obtain, per landmark, the median, mean, and 10th/25th/50th/75th/90th percentiles of AFLE. `compute_reliability.py` regenerates this table (`rater_reliability.json`) from any local copy of the released placements. At run time the tutor maps a learner's per-landmark error to a percentile within this distribution and a four-level band (better than typical / within range / edge / outside), injected into the feedback prompt and returned by `/learn/check`; landmarks absent from the prior fall back to the global fixed thresholds. + +### 9.6 Platform stack + +Flask 3.0 (Python 3.11), SQLAlchemy \+ PostgreSQL, React 18, ORCID OAuth 2.0, Docker Compose, Nginx. The database stores, per opted-in session: ORCID-linked user ID (nullable), date, selected template, and the 96 landmark coordinate floats. Migrations use Flask-Migrate/Alembic. + +Code and Data Availability +All source code, reference FCSV files, the tutor system prompt, and the analysis and figure-generation scripts (`analyze_afids_templates.py`, `make_figures.py`, `compute_reliability.py`, and the derived `rater_reliability.json`) are available at [https://github.com/afids/afids-validator](https://github.com/afids/afids-validator) under GPL-3.0; Figures 3–6 regenerate deterministically from the released templates and placements. The platform is live at [https://validator.afids.io](https://validator.afids.io) (DOI: 10.5281/zenodo.10694674). The AFIDs multi-rater dataset (Taha et al., 2023\) is at [https://github.com/afids/afids-data](https://github.com/afids/afids-data). The AFIDs protocol is at [https://afids.github.io/afids-protocol/](https://afids.github.io/afids-protocol/). + +Acknowledgements +We thank the TemplateFlow team for maintaining the public template infrastructure, the NiiVue developers (C. Rorden and J. C. Taylor) for the open-source WebGL2 viewer, and the raters who contributed to the AFIDs multi-rater dataset. \[Funding sources TBD.\] + +Author Contributions +Following the CRediT taxonomy: **Conceptualization** — J.C.L., A.R.K., A.T., G.G.; **Software** — A.T., J.K., T.K., G.G., J.C.L. (validation engine and reference-template pipeline), A.T. (AI-guided learning mode and rater-reliability calibration), and D.B., A.Th., J.T. (platform development and testing); **Formal analysis** and **Visualization** — A.T.; **Investigation** — D.B., A.Th., J.T.; **Validation** — D.B., A.Th., J.T., M.A., A.T.; **Data curation** — A.T., J.K., T.K., G.G., M.A., A.R.K., J.C.L.; **Writing – original draft** — A.T., J.C.L., A.R.K.; **Writing – review & editing** — all authors; **Supervision** — J.C.L., A.R.K.; **Funding acquisition** — J.C.L., A.R.K. Initials: A.T. (Alaa Taha), D.B. (Dhananjhay Bansal), A.Th. (Arun Thurairajah), J.T. (Jaime Thrower), J.K. (Jason Kai), T.K. (Tristan Kuehn), G.G. (Greydon Gilmore), M.A. (Mohamad Abbass), A.R.K. (Ali R. Khan), J.C.L. (Jonathan C. Lau). + +Ethics +This work is a secondary analysis of previously published, de-identified imaging data; no new human-subjects data were acquired for this study. The reference imaging and multi-rater landmark datasets are from the openly released AFIDs data (Taha et al., 2023), for which ethics approval was obtained from the Human Subject Research Ethics Board (HSREB) at Western University (REB# 109045 and REB# R-17–156), with written informed consent for participation and open data release. The AFIDs-Validator platform records learner-contributed landmark coordinates only with explicit opt-in consent and stores no personally identifying information (§2). *\[If a separate REB determination governs the platform's optional data collection, insert its approval number here.\]* + +Competing Interests +The authors declare no competing interests. + +References +Amunts, K., Lepage, C., Borgeat, L., Mohlberg, H., Dickscheid, T., Rousseau, M.-É., … & Evans, A. C. (2013). BigBrain: An ultrahigh-resolution 3D human brain model. *Science*, 340(6139), 1472–1475. [https://doi.org/10.1126/science.1235381](https://doi.org/10.1126/science.1235381) + +Ciric, R., Lorenz, R., Thompson, W. H., Goncalves, M., MacNicol, E., Markiewicz, C. J., … & Poldrack, R. A. (2022). TemplateFlow: FAIR-sharing of multi-scale, multi-species brain models. *Nature Methods*, 19, 1568–1571. [https://doi.org/10.1038/s41592-022-01681-2](https://doi.org/10.1038/s41592-022-01681-2) + +Collins, D. L., Neelin, P., Peters, T. M., & Evans, A. C. (1994). Automatic 3D intersubject registration of MR volumetric data in standardized Talairach space. *Journal of Computer Assisted Tomography*, 18(2), 192–205. + +Donahue, C. J., Sotiropoulos, S. N., Jbabdi, S., Hernandez-Fernandez, M., Behrens, T. E., Dyrby, T. B., … & Van Essen, D. C. (2016). Using diffusion tractography to predict cortical connection strength and distance. *Journal of Neuroscience*, 36(25), 6758–6770. [https://doi.org/10.1523/JNEUROSCI.0493-16.2016](https://doi.org/10.1523/JNEUROSCI.0493-16.2016) + +Esteban, O., Birman, D., Schaer, M., Koyejo, O. O., Poldrack, R. A., & Gorgolewski, K. J. (2017). MRIQC: Advancing the automatic prediction of image quality in MRI from unseen sites. *PLOS ONE*, 12(9), e0184661. [https://doi.org/10.1371/journal.pone.0184661](https://doi.org/10.1371/journal.pone.0184661) + +Esteban, O., Markiewicz, C. J., Blair, R. W., Moodie, C. A., Isik, A. I., Erramuzpe, A., … & Gorgolewski, K. J. (2019). fMRIPrep: A robust preprocessing pipeline for functional MRI. *Nature Methods*, 16, 111–116. [https://doi.org/10.1038/s41592-018-0235-4](https://doi.org/10.1038/s41592-018-0235-4) + +Fedorov, A., Beichel, R., Kalpathy-Cramer, J., Finet, J., Fillion-Robin, J.-C., Pujol, S., … & Kikinis, R. (2012). 3D Slicer as an image computing platform for the Quantitative Imaging Network. *Magnetic Resonance Imaging*, 30(9), 1323–1341. [https://doi.org/10.1016/j.mri.2012.05.001](https://doi.org/10.1016/j.mri.2012.05.001) + +Fischl, B., Sereno, M. I., & Dale, A. M. (1999). Cortical surface-based analysis: II. Inflation, flattening, and a surface-based coordinate system. *NeuroImage*, 9(2), 195–207. [https://doi.org/10.1006/nimg.1998.0396](https://doi.org/10.1006/nimg.1998.0396) + +Fonov, V., Evans, A. C., Botteron, K., Almli, C. R., McKinstry, R. C., Collins, D. L., & Brain Development Cooperative Group. (2011). Unbiased average age-appropriate atlases for pediatric studies. *NeuroImage*, 54(1), 313–327. [https://doi.org/10.1016/j.neuroimage.2010.07.033](https://doi.org/10.1016/j.neuroimage.2010.07.033) + +Fonov, V. S., Evans, A. C., McKinstry, R., Almli, C. R., & Collins, D. L. (2009). Unbiased nonlinear average age-appropriate brain templates from birth to adulthood. *NeuroImage*, 47(Suppl. 1), S102. [https://doi.org/10.1016/S1053-8119(09)70884-5](https://doi.org/10.1016/S1053-8119\(09\)70884-5) + +Holmes, C. J., Hoge, R., Collins, L., Woods, R., Toga, A. W., & Evans, A. C. (1998). Enhancement of MR images using registration for signal averaging. *Journal of Computer Assisted Tomography*, 22(2), 324–333. + +Jenkinson, M., Beckmann, C. F., Behrens, T. E. J., Woolrich, M. W., & Smith, S. M. (2012). FSL. *NeuroImage*, 62(2), 782–790. [https://doi.org/10.1016/j.neuroimage.2011.09.015](https://doi.org/10.1016/j.neuroimage.2011.09.015) + +Jung, B., Taylor, P. A., Seidlitz, J., Sponheim, C., Perkins, P., Ungerleider, L. G., … & Messinger, A. (2021). A comprehensive macaque fMRI pipeline and hierarchical atlas. *NeuroImage*, 235, 117997\. [https://doi.org/10.1016/j.neuroimage.2021.117997](https://doi.org/10.1016/j.neuroimage.2021.117997) + +Kapur, M. (2008). Productive failure. *Cognition and Instruction*, 26(3), 379–424. [https://doi.org/10.1080/07370000802212669](https://doi.org/10.1080/07370000802212669) + +Kapur, M. (2016). Examining productive failure, productive success, unproductive failure, and unproductive success in learning. *Educational Psychologist*, 51(2), 289–299. [https://doi.org/10.1080/00461520.2016.1155457](https://doi.org/10.1080/00461520.2016.1155457) + +Koedinger, K. R., & Corbett, A. T. (2006). Cognitive tutors: Technology bringing learning sciences to the classroom. In R. K. Sawyer (Ed.), *The Cambridge Handbook of the Learning Sciences* (pp. 61–77). Cambridge University Press. + +Lau, J. C., Parrent, A. G., Eng, J., Parrish, T. B., & Peters, T. M. (2019). A framework for evaluating correspondence between brain images using anatomical fiducials. *Human Brain Mapping*, 40(14), 4163–4179. [https://doi.org/10.1002/hbm.24693](https://doi.org/10.1002/hbm.24693) + +Lave, J., & Wenger, E. (1991). *Situated Learning: Legitimate Peripheral Participation*. Cambridge University Press. [https://doi.org/10.1017/CBO9780511815355](https://doi.org/10.1017/CBO9780511815355) + +Loibl, K., Roll, I., & Rummel, N. (2017). Towards a theory of when and how problem solving followed by instruction supports learning. *Educational Psychology Review*, 29(4), 693–715. [https://doi.org/10.1007/s10648-016-9379-x](https://doi.org/10.1007/s10648-016-9379-x) + +Marcus, D. S., Wang, T. H., Parker, J., Csernansky, J. G., Morris, J. C., & Buckner, R. L. (2007). Open access series of imaging studies (OASIS): Cross-sectional MRI data in young, middle aged, nondemented, and demented older adults. *Journal of Cognitive Neuroscience*, 19(9), 1498–1507. [https://doi.org/10.1162/jocn.2007.19.9.1498](https://doi.org/10.1162/jocn.2007.19.9.1498) + +Markiewicz, C. J., Gorgolewski, K. J., Feingold, F., Blair, R., Halchenko, Y., Miller, E., … & Poldrack, R. A. (2021). The OpenNeuro resource for sharing of neuroscience data. *eLife*, 10, e71774. [https://doi.org/10.7554/eLife.71774](https://doi.org/10.7554/eLife.71774) + +Rohlfing, T., Kroenke, C. D., Sullivan, E. V., Dubach, M. F., Bowden, D. M., Grant, K. A., & Pfefferbaum, A. (2012). The INIA19 template and NeuroMaps atlas for primate brain image parcellation and spatial normalization. *Frontiers in Neuroinformatics*, 6, 27\. [https://doi.org/10.3389/fninf.2012.00027](https://doi.org/10.3389/fninf.2012.00027) + +Saleem, K. S., & Logothetis, N. K. (2012). *A Combined MRI and Histology Atlas of the Rhesus Monkey Brain in Stereotaxic Coordinates* (2nd ed.). Academic Press. + +Taha, A., Gilmore, G., Abbass, M., Kai, J., Kuehn, T., Demarco, J., … & Lau, J. C. (2023). Magnetic resonance imaging datasets with anatomical fiducials for quality control and registration. *Scientific Data*, 10, 449\. [https://doi.org/10.1038/s41597-023-02330-9](https://doi.org/10.1038/s41597-023-02330-9) + +Taylor, J. C., & Rorden, C. (2023). NiiVue: A WebGL2 image viewer for neuroimaging. *Aperture Neuro*, 3\. [https://doi.org/10.52294/001c.77830](https://doi.org/10.52294/001c.77830) + +VanLehn, K. (2011). The relative effectiveness of human tutoring, intelligent tutoring systems, and other tutoring systems. *Educational Psychologist*, 46(4), 197–221. [https://doi.org/10.1080/00461520.2011.611369](https://doi.org/10.1080/00461520.2011.611369) + +Xiao, Y., Fonov, V., Chakravarty, M. M., Beriault, S., Al Subaie, F., Sadikot, A., … & Collins, D. L. (2017). A dataset of multi-contrast population-averaged brain MRI atlases of a Parkinson's disease cohort. *Data in Brief*, 12, 370–379. [https://doi.org/10.1016/j.dib.2017.04.013](https://doi.org/10.1016/j.dib.2017.04.013) + +Figure Legends +**Graphical abstract.** The platform in one frame: the live browser interface (left); anatomy-first tutoring; grounding in 492 expert placements across 132 subjects (the per-landmark trained-rater error spectrum); and the calibration idea — the same 1.2 mm placement reads as *expert* on a hard landmark (indusium griseum origin) but *off-target* on an easy one (anterior commissure). + +**Figure 1\. The guided-learning interface (live session).** Annotated screenshot of the `/learn` mode running against a local model. ① the browser-based NiiVue MRI viewer (MNI152NLin2009cAsym T1w), here showing a placed fiducial for the anterior commissure across the three orthogonal planes and a 3-D rendering; ② the rater-calibrated result badge reporting Euclidean error and the learner's percentile within the trained-rater distribution for this landmark; ③ the difficulty-aware, anatomy-first AI tutor, whose feedback cites the landmark's real trained-rater difficulty in plain language; ④ the active model and bring-your-own-key control (⚙); ⑤ the 32-landmark progress tracker and session-report export. + +**Figure 2\. The guided-learning cycle and its design principles.** Each landmark proceeds through five steps — (1) AI introduction; (2) learner placement in the viewer; (3) server-side error computation (\<50 ms); (4) anatomy-first AI feedback; (5) free-form dialogue — repeated across all 32 landmarks. The principles governing the tutor are annotated below: productive failure (attempt before full instruction), anatomy-first (never give raw coordinates), viewer-context awareness (feedback adapts to zoom/resolution), and retrieval-augmented grounding in the AFIDs protocol. + +**Figure 3\. A field guide to the 32 AFIDs landmarks on the MNI152NLin2009cAsym T1w template.** Each panel is a 40 mm patch of the template centred on one landmark, with a crosshair marking the exact point, grouped and coloured by neuroanatomical region. The chip in each panel gives that landmark's median trained-rater localization error (AFLE) and the corner tag its viewing plane (sag = sagittal, used for midline landmarks; ax = axial, for bilateral landmarks). Structure by structure, the guide shows what each landmark actually looks like on the image a learner places it on. + +**Figure 4\. A worked quality-control catch on real reference data.** The validation engine run on a genuine template-space mismatch: the MNI305 landmark set validated against the platform default (MNI152NLin2009cAsym), aligned only at the anterior commissure so that the residual is the geometry a proper registration must recover. (A) 3D scatter of the default reference (gold) and the uploaded MNI305 landmarks (black), connected by lines coloured by error magnitude. (B) Per-landmark Euclidean error, ranked and coloured by quality tier (excellent \<1 mm, good 1–2 mm, fair 2–4 mm, needs work ≥4 mm); black ticks mark each landmark's trained-rater 90th percentile — the error exceeds it for 13 of 32 landmarks (mean 2.9 mm), most dramatically at the posterior commissure (3.6 mm, 4.1× its rater p90). (C) Mean error per neuroanatomical region: the signature is localized to the temporal and ventricular landmarks while the diencephalic floor stays tight — exactly what a global similarity score would miss. + +**Figure 5\. How hard is each landmark? A difficulty benchmark from 492 expert placements.** Computed from 492 trained-rater annotations across all 132 subjects of the AFIDs multi-rater release (Taha et al., 2023). (A) Per-landmark trained-rater localization error (AFLE), each landmark shown as its median (dot), interquartile range (bar), and mean (×), ranked from most reliable (PC, AC; median \~0.37 mm) to least (temporal-horn landmarks and indusium griseum origins; median up to \~1.5 mm) — a 3.9-fold spread; the mean sitting right of the median at every landmark shows the heavy tail. (B) The same 1.2 mm placement, two verdicts: read against each landmark's trained-rater distribution, 1.2 mm is beyond the 90th percentile for the anterior commissure (outside the expert range) but near the median for the indusium griseum origin (a solidly expert placement). (C) The underlying calibration the platform applies — the mm→percentile mapping for an easy (AC), typical (IMS), and hard (LIGO) landmark; a single error maps to sharply different percentiles. + +**Figure 6\. Two distinct kinds of difficulty.** (A) Per-landmark trained-rater median error ("localize") vs. inter-template variability ("reproduce") for the 31 non-AC landmarks (r \= 0.66): related but distinct, with labelled points that diverge from the trend (e.g., the ventral occipital horn RVOH varies across templates yet is placed reliably by raters). (B) Per-landmark AC-normalized inter-template variability across the eight canonical MNI152/MNI305 templates (mean distance to the cross-template centroid), ranked from the temporal-horn/ventricular landmarks (LIAMTH 2.35 mm) down to the mammillary bodies (RMB 0.41 mm), coloured by region; dotted lines mark the validator's 1 and 2 mm thresholds. + diff --git a/DEPLOY_HANDOFF.md b/DEPLOY_HANDOFF.md new file mode 100644 index 00000000..bc6db4bb --- /dev/null +++ b/DEPLOY_HANDOFF.md @@ -0,0 +1,127 @@ +# AFIDs Validator — Deploy Handoff: AI Tutor (`/learn`) + Groq + +**Goal:** deploy the new **AI-tutor branch** to the live site **https://validator.afids.io**, with the guided-learning tutor (`/learn`) backed by a shared Groq API key so any visitor can use it without their own key. + +**Who this is for:** whoever currently has **SSH/admin access to the production server**. The code and the app-layer wiring are done and tested; the only remaining work is server-side deployment, which requires access this document's author does not have. + +--- + +## 1. What's being deployed + +- **Repo:** `github.com/afids/afids-validator` +- **Branch:** `enh/ai-tutor-learning-mode` (open PR **#253** → `master`, 17 commits ahead, mergeable) +- **What it adds:** + - `/learn` guided-learning AI tutor (`afidsvalidator/learn.py`, `llm.py`, `templates/learn.html`) + - One **additive**, non-destructive DB migration: a `knowledge_chunks` table (`migrations/versions/c4e7f1a9b2d8_.py`) + - Macaque templates + reliability-calibration features +- **Status:** code complete and pushed; tutor→Groq path tested locally and works (streams live Llama 3.3 70B). Nothing is deployed to production yet — `/learn` currently 404s on the live site. + +> **Note on CI:** PR #253's checks show red only because the repo uses `pull_request_target`, which runs the workflow definition from `master` (still has an old Python 3.8 matrix). This does **not** affect the deploy — the deploy workflow is manual (`workflow_dispatch`) and runs on whatever branch you point it at. You do **not** need CI green or a merge to deploy. + +--- + +## 2. How production deploys work (important — not Docker) + +The live site is **not** run via `docker compose` (that's local-dev only). Production is a **wheel + venv + systemd** setup, deployed by a GitHub Actions workflow (`.github/workflows/deploy.yml`) that SSHes into the server. On trigger it: + +1. Builds a Python wheel from the chosen branch (`poetry build`). +2. Writes a production `.env` from **GitHub Actions Secrets**. +3. Runs `fabrictasks.py`, which SSHes into the server and: + - copies the wheel + `.env` into `/opt/afidsvalidator/releases/afidsvalidator-/` + - flips the `/opt/afidsvalidator/current` symlink to the new release + - `pip install [deploy]` into `/opt/afidsvalidator/venv-afidsvalidator` + - runs `flask db upgrade` (applies the migration) + - `sudo systemctl restart afidsvalidator.service` + +So a deploy = **build wheel → ship over SSH → migrate DB → restart service.** + +--- + +## 3. Prerequisites on the production server + +Confirm these are true on the live box before deploying: + +- [ ] SSH reachable; the deploy user's `~/.ssh/authorized_keys` contains the public key whose private half will be stored as the `PRIVATE_KEY` secret (see §4). +- [ ] Directory layout exists: `/opt/afidsvalidator/releases/` and venv `/opt/afidsvalidator/venv-afidsvalidator/`. +- [ ] **Passwordless sudo** for the deploy user (the workflow runs `sudo systemctl restart afidsvalidator.service`; it will hang on a password prompt). +- [ ] `afidsvalidator.service` systemd unit exists and serves the app from the `current` symlink (already true — the site serves today). +- [ ] Postgres running and reachable at the `DATABASE_URL` you'll put in the secrets. + +Quick verification commands (run on the box / from your machine): + +```bash +ls -ld /opt/afidsvalidator/releases /opt/afidsvalidator/venv-afidsvalidator +/opt/afidsvalidator/venv-afidsvalidator/bin/python --version # note this version (see gotcha in §6) +sudo -n systemctl status afidsvalidator.service >/dev/null && echo "passwordless sudo OK" +ssh -i @ 'echo connected' +``` + +--- + +## 4. GitHub Actions Secrets + +Set these on the repo (Settings → Secrets and variables → Actions, or the `gh secret set` commands below). **Secret values are entered at a hidden prompt or read from a file — never inline, never committed.** + +| Secret | What it is | Likely action | +|---|---|---| +| `PRODUCTION_URL` | SSH deploy target `user@host` (e.g. `ubuntu@`) | **Update** — current value is from 2023 and points at a decommissioned server. | +| `PRIVATE_KEY` | SSH **private** key whose public half is in the box's `authorized_keys` | **Update** — must be a key the current box trusts. | +| `PRODUCTION_DATABASE_URL` | Postgres connection string on the live box | **Verify** it matches the current box (see ⚠️ in §6). | +| `PRODUCTION_SECRET_KEY` | Flask session secret | **Verify** it matches the current box. | +| `PRODUCTION_FLASK_ENV` | e.g. `production` | Verify. | +| `PRODUCTION_ORCID_OAUTH_CLIENT_ID` / `_SECRET` | ORCID login creds | Verify (only affects user login). | +| `PRODUCTION_LLM_API_KEY` | **Groq API key** for the shared tutor | **Already set.** Optionally replace with a production-only key so it can be rotated independently of local dev. | + +The tutor's provider URL/model are **not** secrets — `deploy.yml` defaults them to Groq's Llama 3.3 70B (`https://api.groq.com/openai/v1`, `llama-3.3-70b-versatile`). The deploy is fail-safe: if `PRODUCTION_LLM_API_KEY` is empty, the LLM vars simply aren't written and the tutor falls back to static reference text instead of erroring. + +Commands (run from a clone of the repo): + +```bash +gh secret set PRODUCTION_URL # paste user@host at prompt +gh secret set PRIVATE_KEY < /path/to/deploy_private_key +# only if the 2023 values no longer match the current box: +gh secret set PRODUCTION_DATABASE_URL +gh secret set PRODUCTION_SECRET_KEY +# to use a prod-specific Groq key (optional): +gh secret set PRODUCTION_LLM_API_KEY # paste gsk_... at prompt + +gh secret list # confirm names + timestamps +``` + +To get a free Groq key (if you'd rather use your own): https://console.groq.com/keys — free tier, no credit card. It only needs read access to chat models. + +--- + +## 5. Deploy and verify + +```bash +# Trigger against the tutor branch (or 'master' if you merge #253 first) +gh workflow run "AFIDs Validator Deploy" --ref enh/ai-tutor-learning-mode + +# Watch — the make-or-break step is "Deploy release" (the SSH step) +gh run watch $(gh run list --workflow=deploy.yml --limit 1 --json databaseId -q '.[0].databaseId') +``` + +Verify on the live site: + +- [ ] `https://validator.afids.io/learn` loads (no 404). +- [ ] The tutor returns a **live streamed** response (confirms the Groq key landed) — not just static text. +- [ ] A normal FCSV upload still validates (confirms the migration + restart were clean). + +--- + +## 6. Gotchas (please read) + +1. **⚠️ Stale secrets can break a "successful" deploy.** The deploy **overwrites** the server `.env` from the GitHub Secrets. Most non-LLM secrets were set in **2023** (before the server was re-hosted). If `PRODUCTION_DATABASE_URL` / `PRODUCTION_SECRET_KEY` no longer match the current box, the deploy will run "green" but leave the site pointing at the wrong DB / invalidate sessions. **Before deploying, read the current `/opt/afidsvalidator/current/.env` on the box and make the GitHub Secrets match it** (then add the LLM key). + +2. **Python version hardcode.** `fabrictasks.py` line ~34 hardcodes the migrations path as `.../lib/python3.8/site-packages/migrations`. If the production venv is **not** Python 3.8, change `python3.8` in that line to the venv's actual version, or the `flask db upgrade` step fails. Check with `/opt/afidsvalidator/venv-afidsvalidator/bin/python --version`. + +3. **RAG ingestion is NOT required.** Do **not** run `flask ingest-knowledge` against the Groq key — Groq serves chat models only, no embeddings. The tutor degrades gracefully to built-in landmark reference material without an embeddings store, so it works fine with no ingestion. + +4. **The `Dockerfile` bit-rot is irrelevant to this deploy.** Production builds a wheel, not a Docker image, so the (currently broken) Dockerfile does not block deployment. It only affects local `docker compose`. + +--- + +## 7. One-line summary + +Point `PRODUCTION_URL` + `PRIVATE_KEY` at the current server, make the 2023 DB/secret values match what's actually on the box, confirm the venv's Python version for the migrations path, then run the **AFIDs Validator Deploy** workflow against `enh/ai-tutor-learning-mode`. The Groq key is already configured; the tutor goes live automatically once the branch is deployed. diff --git a/README.md b/README.md index 466416f5..7322f0ff 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,31 @@ Anatomical fiducials (AFIDs) is an open framework for evaluating correspondence # [afids-validator (https://validator.afids.io)](https://validator.afids.io) +## Guided Learning (AI tutor) + +The validator includes a guided-learning mode at `/learn` — an interactive tutor that walks users through placing anatomical fiducials and gives feedback on each placement. It is powered by any OpenAI-compatible language model. + +**Default model:** [Groq's Llama 3.3 70B](https://groq.com) (`llama-3.3-70b-versatile`) — a free, open-source model. When an API key is present, the endpoint and model default to Groq automatically, so the only thing you need to configure is the key. + +Configure via `.env` (see `.env.template`): + +- `LLM_API_KEY` — API key for the shared tutor. Get a free Groq key at https://console.groq.com/keys. **Setting only this is enough** — `LLM_BASE_URL` and `LLM_MODEL` default to Groq. +- `LLM_BASE_URL` — OpenAI-compatible endpoint. Optional; defaults to Groq when a key is set. +- `LLM_MODEL` — Model name. Optional; defaults to `llama-3.3-70b-versatile`. + +The tutor degrades gracefully: + +- Visitors can enter their own key/provider from the in-page **Settings** panel — their key stays in their browser and is never logged or persisted server-side. +- If no key is configured anywhere, the tutor streams static reference material instead of failing. + +**Local, zero-key option:** run [Ollama](https://ollama.com) (`ollama pull llama3.2`) and leave `LLM_API_KEY` empty — the tutor talks to your local model, no key or cost. + +> RAG note: retrieval degrades gracefully to the built-in landmark reference when no embeddings store is present, so the tutor works without ingestion. Do **not** run `flask ingest-knowledge` against a Groq key — Groq serves chat models only, not embeddings. + +## Deployment + +Production is deployed via the **AFIDs Validator Deploy** GitHub Actions workflow (`.github/workflows/deploy.yml`, manually triggered), which builds a wheel and ships it to the server over SSH. Deployment configuration (including `PRODUCTION_LLM_API_KEY` for the shared tutor) is supplied through repository secrets. See `DEPLOY_HANDOFF.md` for the full step-by-step deploy runbook. + ## Development `poetry` is used to manage dependencies. To install, run the following command: diff --git a/afidsvalidator/__init__.py b/afidsvalidator/__init__.py index 7601af6d..35763007 100644 --- a/afidsvalidator/__init__.py +++ b/afidsvalidator/__init__.py @@ -2,6 +2,7 @@ import os +import click from flask import Flask from flask_migrate import Migrate @@ -10,8 +11,10 @@ ProductionConfig, TestingConfig, ) +from afidsvalidator.learn import learn from afidsvalidator.model import db, login_manager from afidsvalidator.orcid import orcid_blueprint +from afidsvalidator.rag import KnowledgeChunk # noqa: F401 — registers model from afidsvalidator.views import validator @@ -59,6 +62,46 @@ def create_app(): # Register blueprints app.register_blueprint(validator) app.register_blueprint(orcid_blueprint) + app.register_blueprint(learn) + + # ── CLI commands ────────────────────────────────────────────────────────── + @app.cli.command("ingest-knowledge") + @click.option( + "--file", + "filepath", + default=None, + help="Path to a plain-text document to ingest (optional).", + ) + @click.option( + "--source", + default=None, + help="Source label for the document (required with --file).", + ) + def ingest_knowledge_cmd(filepath, source): + """Embed and store knowledge chunks in the RAG store. + + Without arguments, ingests the 32 AFIDs landmark definitions from + landmark_info.py. Pass --file and --source to ingest additional + documents such as the AFIDs protocol paper. + + Example: + flask ingest-knowledge + flask ingest-knowledge --file afids_paper.txt --source afids_paper + """ + from afidsvalidator.rag import ingest_landmarks, ingest_text_file + + if filepath: + if not source: + raise click.UsageError( + "--source is required when using --file" + ) + click.echo(f"Ingesting {filepath} as source={source!r} …") + count = ingest_text_file(filepath, source) + else: + click.echo("Ingesting 32 AFIDs landmark definitions …") + count = ingest_landmarks() + + click.echo(f"Done — {count} chunk(s) stored.") return app diff --git a/afidsvalidator/afids-templates/macaca/tpl-D99_afids.fcsv b/afidsvalidator/afids-templates/macaca/tpl-D99_afids.fcsv new file mode 100644 index 00000000..b5329ecb --- /dev/null +++ b/afidsvalidator/afids-templates/macaca/tpl-D99_afids.fcsv @@ -0,0 +1,35 @@ +# Markups fiducial file version = 4.6 +# CoordinateSystem = 0 +# columns = id,x,y,z,ow,ox,oy,oz,vis,sel,lock,label,desc,associatedNodeID +vtkMRMLMarkupsFiducialNode_1,-0.115733333333333,0.128533333333333,-0.1804,0,0,0,1,1,1,0,1,AC,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_2,-0.0898666666666667,-13.5647333333333,1.014,0,0,0,1,1,1,0,2,PC,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_3,-0.154949266666667,-20.8540666666667,-1.18409,0,0,0,1,1,1,0,3,infracollicular sulcus,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_4,-0.1954,-12.1107333333333,-8.9786,0,0,0,1,1,1,0,4,PMJ,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_5,-0.191,-8.80766666666667,-5.34613333333333,0,0,0,1,1,1,0,5,superior interpeduncular fossa,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_6,6.54978,-15.0369933333333,-3.80202133333333,0,0,0,1,1,1,0,6,R superior LMS,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_7,-6.808302,-15.0460333333333,-3.73986933333333,0,0,0,1,1,1,0,7,L superior LMS,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_8,6.30764666666667,-15.90516,-8.184836,0,0,0,1,1,1,0,8,R inferior LMS,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_9,-6.57371466666667,-15.81472,-8.08559733333333,0,0,0,1,1,1,0,9,L inferior LMS,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_10,-0.177700066666667,-27.56682,7.36913333333333,0,0,0,1,1,1,0,10,culmen,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_11,-0.1775278,-6.571888,-6.25063133333333,0,0,0,1,1,1,0,11,intermammillary sulcus,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_12,0.817333333333333,-6.85653333333333,-5.8828,0,0,0,1,1,1,0,12,R MB,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_13,-1.14173333333333,-6.88506666666667,-5.876,0,0,0,1,1,1,0,13,L MB,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_14,-0.115933333333333,-16.6617333333333,3.447,0,0,0,1,1,1,0,14,pineal gland,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_15,3.56786666666667,0.877066666666667,9.03266666666667,0,0,0,1,1,1,0,15,R LV at AC,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_16,-3.81466666666667,0.892533333333333,9.0494,0,0,0,1,1,1,0,16,L LV at AC,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_17,7.7982,-12.8142,10.2004,0,0,0,1,1,1,0,17,R LV at PC,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_18,-7.97086666666667,-12.8361333333333,10.2534,0,0,0,1,1,1,0,18,L LV at PC,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_19,-0.1542,13.2044666666667,5.20166666666667,0,0,0,1,1,1,0,19,genu of CC,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_20,-0.1706,-16.6438666666667,4.73833333333333,0,0,0,1,1,1,0,20,splenium of CC,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_21,13.1057333333333,-1.86335133333333,-14.57104,0,0,0,1,1,1,0,21,R AL temporal horn,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_22,-13.4179866666667,-1.780824,-14.5191,0,0,0,1,1,1,0,22,L AL temporal horn,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_23,7.60346666666667,-5.445,-8.81046666666667,0,0,0,1,1,1,0,23,R superior AM temporal horn,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_24,-7.95793333333333,-5.40726666666667,-8.81153333333333,0,0,0,1,1,1,0,24,L superior AM temporal horn,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_25,8.1622,-2.85706666666667,-14.1586,0,0,0,1,1,1,0,25,R inferior AM temporal horn,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_26,-8.4912,-2.82386666666667,-14.1978,0,0,0,1,1,1,0,26,L inferior AM temporal horn,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_27,7.41781466666667,-20.9339533333333,5.63792333333333,0,0,0,1,1,1,0,27,R indusium griseum origin,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_28,-7.702888,-20.88134,5.59702466666667,0,0,0,1,1,1,0,28,L indusium griseum origin,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_29,16.3349133333333,-24.98622,0.917984933333333,0,0,0,1,1,1,0,29,R ventral occipital horn,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_30,-16.6607266666667,-24.9668066666667,0.914438466666667,0,0,0,1,1,1,0,30,L ventral occipital horn,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_31,4.58283066666667,13.3663933333333,3.26909266666667,0,0,0,1,1,1,0,31,R olfactory sulcal fundus,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_32,-5.05380266666667,13.2956866666667,3.23346066666667,0,0,0,1,1,1,0,32,L olfactory sulcal fundus,vtkMRMLScalarVolumeNode1 diff --git a/afidsvalidator/afids-templates/macaca/tpl-INIA19_afids.fcsv b/afidsvalidator/afids-templates/macaca/tpl-INIA19_afids.fcsv new file mode 100644 index 00000000..c2531195 --- /dev/null +++ b/afidsvalidator/afids-templates/macaca/tpl-INIA19_afids.fcsv @@ -0,0 +1,35 @@ +# Markups fiducial file version = 4.6 +# CoordinateSystem = 0 +# columns = id,x,y,z,ow,ox,oy,oz,vis,sel,lock,label,desc,associatedNodeID +vtkMRMLMarkupsFiducialNode_1,-0.193375,0.088375,0.2631875,0,0,0,1,1,1,0,1,AC,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_2,-0.1525625,-12.9968125,0.0006875,0,0,0,1,1,1,0,2,PC,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_3,-0.216,-19.7260625,-3.4704375,0,0,0,1,1,1,0,3,infracollicular sulcus,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_4,-0.16075,-10.7433125,-9.3593125,0,0,0,1,1,1,0,4,PMJ,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_5,-0.2194375,-7.841125,-5.266125,0,0,0,1,1,1,0,5,superior interpeduncular fossa,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_6,6.76616125,-14.37709375,-4.7679675,0,0,0,1,1,1,0,6,R superior LMS,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_7,-7.07919125,-14.32365,-4.91882375,0,0,0,1,1,1,0,7,L superior LMS,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_8,6.416778125,-15.15824375,-8.782543125,0,0,0,1,1,1,0,8,R inferior LMS,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_9,-6.802196875,-14.99663125,-9.010619375,0,0,0,1,1,1,0,9,L inferior LMS,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_10,-0.1705976875,-29.76313125,4.388538125,0,0,0,1,1,1,0,10,culmen,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_11,-0.1375,-5.114125,-6.151,0,0,0,1,1,1,0,11,intermammillary sulcus,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_12,0.987844125,-5.26671625,-5.97335125,0,0,0,1,1,1,0,12,R MB,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_13,-1.44810625,-5.296490625,-5.947996875,0,0,0,1,1,1,0,13,L MB,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_14,-0.177394125,-16.913725,1.710676875,0,0,0,1,1,1,0,14,pineal gland,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_15,3.594125,-0.02425,9.1958125,0,0,0,1,1,1,0,15,R LV at AC,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_16,-3.996625,-0.039625,9.208125,0,0,0,1,1,1,0,16,L LV at AC,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_17,7.179,-13.176125,8.6773125,0,0,0,1,1,1,0,17,R LV at PC,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_18,-7.46425,-13.1858125,8.51175,0,0,0,1,1,1,0,18,L LV at PC,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_19,-0.2415625,11.6924375,7.926,0,0,0,1,1,1,0,19,genu of CC,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_20,-0.1240625,-17.668375,2.9224375,0,0,0,1,1,1,0,20,splenium of CC,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_21,14.0145,-1.5799375,-12.9796875,0,0,0,1,1,1,0,21,R AL temporal horn,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_22,-14.3650125,-1.2189525,-13.1778625,0,0,0,1,1,1,0,22,L AL temporal horn,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_23,8.0909375,-4.402,-8.93825,0,0,0,1,1,1,0,23,R superior AM temporal horn,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_24,-9.0351875,-4.5783125,-8.91225,0,0,0,1,1,1,0,24,L superior AM temporal horn,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_25,10.080375,-2.2085625,-12.406625,0,0,0,1,1,1,0,25,R inferior AM temporal horn,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_26,-10.2415625,-2.3674375,-12.8078125,0,0,0,1,1,1,0,26,L inferior AM temporal horn,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_27,8.040926875,-20.493525,2.26453125,0,0,0,1,1,1,0,27,R indusium griseum origin,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_28,-8.472299375,-20.51625625,2.381066875,0,0,0,1,1,1,0,28,L indusium griseum origin,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_29,16.016725,-23.318175,-1.614628125,0,0,0,1,1,1,0,29,R ventral occipital horn,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_30,-16.04579375,-23.4388625,-1.75378625,0,0,0,1,1,1,0,30,L ventral occipital horn,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_31,4.852285,12.4047625,5.47498,0,0,0,1,1,1,0,31,R olfactory sulcal fundus,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_32,-5.90880125,12.2564125,5.5147125,0,0,0,1,1,1,0,32,L olfactory sulcal fundus,vtkMRMLScalarVolumeNode1 diff --git a/afidsvalidator/afids-templates/macaca/tpl-MacaqueMNI_afids.fcsv b/afidsvalidator/afids-templates/macaca/tpl-MacaqueMNI_afids.fcsv new file mode 100644 index 00000000..31f5ca09 --- /dev/null +++ b/afidsvalidator/afids-templates/macaca/tpl-MacaqueMNI_afids.fcsv @@ -0,0 +1,35 @@ +# Markups fiducial file version = 4.6 +# CoordinateSystem = 0 +# columns = id,x,y,z,ow,ox,oy,oz,vis,sel,lock,label,desc,associatedNodeID +vtkMRMLMarkupsFiducialNode_1,-0.1525,0.4425625,-0.682,0,0,0,1,1,1,0,1,AC,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_2,-0.25540225,-12.445075,0.7061414375,0,0,0,1,1,1,0,2,PC,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_3,-0.28090875,-18.8221875,-2.358905625,0,0,0,1,1,1,0,3,infracollicular sulcus,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_4,-0.2053125,-10.808125,-9.1360625,0,0,0,1,1,1,0,4,PMJ,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_5,-0.1660625,-7.7131875,-5.22925,0,0,0,1,1,1,0,5,superior interpeduncular fossa,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_6,5.928510625,-13.92930625,-4.000454375,0,0,0,1,1,1,0,6,R superior LMS,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_7,-6.560353125,-13.8463125,-4.19377,0,0,0,1,1,1,0,7,L superior LMS,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_8,5.68814875,-14.6034625,-8.035674375,0,0,0,1,1,1,0,8,R inferior LMS,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_9,-5.92688625,-14.5166625,-8.193253125,0,0,0,1,1,1,0,9,L inferior LMS,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_10,-0.2503125,-25.9071875,6.0988125,0,0,0,1,1,1,0,10,culmen,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_11,-0.077375,-5.0741875,-6.4248125,0,0,0,1,1,1,0,11,intermammillary sulcus,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_12,1.0798125,-5.2316875,-6.2721875,0,0,0,1,1,1,0,12,R MB,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_13,-1.2868125,-5.2730625,-6.3146875,0,0,0,1,1,1,0,13,L MB,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_14,-0.268704125,-15.43580625,2.401966875,0,0,0,1,1,1,0,14,pineal gland,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_15,3.0006875,1.267875,8.3075,0,0,0,1,1,1,0,15,R LV at AC,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_16,-3.49825,1.2855625,8.200625,0,0,0,1,1,1,0,16,L LV at AC,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_17,6.3436875,-11.6543125,9.1398125,0,0,0,1,1,1,0,17,R LV at PC,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_18,-6.6864375,-11.53625,8.9915625,0,0,0,1,1,1,0,18,L LV at PC,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_19,-0.18125,11.1011875,6.292,0,0,0,1,1,1,0,19,genu of CC,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_20,-0.2841875,-16.0635625,3.7816875,0,0,0,1,1,1,0,20,splenium of CC,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_21,11.90005,-2.438523125,-13.0738625,0,0,0,1,1,1,0,21,R AL temporal horn,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_22,-12.41914375,-2.47109875,-13.88066875,0,0,0,1,1,1,0,22,L AL temporal horn,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_23,7.2395625,-4.1586875,-9.254375,0,0,0,1,1,1,0,23,R superior AM temporal horn,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_24,-7.66971625,-4.36832625,-9.954780625,0,0,0,1,1,1,0,24,L superior AM temporal horn,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_25,8.1785,-2.914375,-12.9280625,0,0,0,1,1,1,0,25,R inferior AM temporal horn,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_26,-8.57575,-3.09675,-13.5783125,0,0,0,1,1,1,0,26,L inferior AM temporal horn,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_27,6.385324375,-18.50754375,3.695403125,0,0,0,1,1,1,0,27,R indusium griseum origin,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_28,-6.658700625,-18.5387875,3.43432125,0,0,0,1,1,1,0,28,L indusium griseum origin,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_29,13.9107125,-22.3884,0.8325,0,0,0,1,1,1,0,29,R ventral occipital horn,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_30,-14.35241875,-22.70195,0.0955496875,0,0,0,1,1,1,0,30,L ventral occipital horn,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_31,4.69858375,12.4882625,3.502166875,0,0,0,1,1,1,0,31,R olfactory sulcal fundus,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_32,-5.22115875,12.27171875,3.30565375,0,0,0,1,1,1,0,32,L olfactory sulcal fundus,vtkMRMLScalarVolumeNode1 diff --git a/afidsvalidator/afids-templates/macaca/tpl-NMTv1.3_afids.fcsv b/afidsvalidator/afids-templates/macaca/tpl-NMTv1.3_afids.fcsv new file mode 100644 index 00000000..9852f7a2 --- /dev/null +++ b/afidsvalidator/afids-templates/macaca/tpl-NMTv1.3_afids.fcsv @@ -0,0 +1,35 @@ +# Markups fiducial file version = 4.6 +# CoordinateSystem = 0 +# columns = id,x,y,z,ow,ox,oy,oz,vis,sel,lock,label,desc,associatedNodeID +vtkMRMLMarkupsFiducialNode_1,-0.1445625,-0.3681875,-0.336,0,0,0,1,1,1,0,1,AC,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_2,-0.235375,-13.265,1.5816875,0,0,0,1,1,1,0,2,PC,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_3,-0.17275,-20.46925,-0.992125,0,0,0,1,1,1,0,3,infracollicular sulcus,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_4,-0.0499375,-12.5635625,-8.222625,0,0,0,1,1,1,0,4,PMJ,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_5,-0.0751875,-9.2508125,-4.4854375,0,0,0,1,1,1,0,5,superior interpeduncular fossa,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_6,6.66711875,-15.37825625,-3.071816875,0,0,0,1,1,1,0,6,R superior LMS,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_7,-6.886910625,-15.37699375,-3.236519375,0,0,0,1,1,1,0,7,L superior LMS,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_8,6.601545,-16.4498375,-6.962368125,0,0,0,1,1,1,0,8,R inferior LMS,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_9,-6.57792375,-16.36333125,-7.11506,0,0,0,1,1,1,0,9,L inferior LMS,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_10,-0.27807899375,-26.58195625,7.7371125,0,0,0,1,1,1,0,10,culmen,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_11,-0.0425,-6.6723125,-6.021125,0,0,0,1,1,1,0,11,intermammillary sulcus,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_12,1.032534375,-7.0002275,-5.478975625,0,0,0,1,1,1,0,12,R MB,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_13,-1.114105625,-7.044663125,-5.53065625,0,0,0,1,1,1,0,13,L MB,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_14,-0.2946875,-16.440875,3.681375,0,0,0,1,1,1,0,14,pineal gland,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_15,3.605125,0.962375,8.7104375,0,0,0,1,1,1,0,15,R LV at AC,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_16,-4.136625,0.9684375,8.572875,0,0,0,1,1,1,0,16,L LV at AC,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_17,7.105625,-11.9369375,10.0565625,0,0,0,1,1,1,0,17,R LV at PC,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_18,-7.6420625,-11.863125,9.8369375,0,0,0,1,1,1,0,18,L LV at PC,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_19,-0.2144375,11.63725,5.201875,0,0,0,1,1,1,0,19,genu of CC,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_20,-0.3206875,-16.6750625,4.92025,0,0,0,1,1,1,0,20,splenium of CC,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_21,13.6636875,-3.852625,-12.9245,0,0,0,1,1,1,0,21,R AL temporal horn,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_22,-13.44820625,-3.75252625,-13.2946875,0,0,0,1,1,1,0,22,L AL temporal horn,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_23,7.7138125,-6.0359375,-8.85475,0,0,0,1,1,1,0,23,R superior AM temporal horn,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_24,-7.837173125,-6.116948125,-9.15375125,0,0,0,1,1,1,0,24,L superior AM temporal horn,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_25,8.97875,-4.2975,-12.7649375,0,0,0,1,1,1,0,25,R inferior AM temporal horn,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_26,-8.748625,-4.2636875,-13.1769375,0,0,0,1,1,1,0,26,L inferior AM temporal horn,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_27,8.024435625,-20.0366625,5.476778125,0,0,0,1,1,1,0,27,R indusium griseum origin,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_28,-8.38731125,-19.96438125,5.280025,0,0,0,1,1,1,0,28,L indusium griseum origin,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_29,15.54165625,-26.1188,3.747598125,0,0,0,1,1,1,0,29,R ventral occipital horn,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_30,-15.87663125,-25.64653125,3.335800625,0,0,0,1,1,1,0,30,L ventral occipital horn,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_31,4.974789375,12.44625,2.46409125,0,0,0,1,1,1,0,31,R olfactory sulcal fundus,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_32,-5.724045,12.52948125,2.37324875,0,0,0,1,1,1,0,32,L olfactory sulcal fundus,vtkMRMLScalarVolumeNode1 diff --git a/afidsvalidator/afids-templates/macaca/tpl-NMTv2.0asym_afids.fcsv b/afidsvalidator/afids-templates/macaca/tpl-NMTv2.0asym_afids.fcsv new file mode 100644 index 00000000..67fca31d --- /dev/null +++ b/afidsvalidator/afids-templates/macaca/tpl-NMTv2.0asym_afids.fcsv @@ -0,0 +1,35 @@ +# Markups fiducial file version = 4.6 +# CoordinateSystem = 0 +# columns = id,x,y,z,ow,ox,oy,oz,vis,sel,lock,label,desc,associatedNodeID +vtkMRMLMarkupsFiducialNode_0,0.017712306194739003,19.487752704941716,15.314483484676307,0.0,0.0,0.0,1.0,1,1,0,1,AC,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_1,0.02507324150872536,6.5001968692204875,14.470492570396765,0.0,0.0,0.0,1.0,1,1,0,2,PC,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_8,0.06442593327277103,-0.15965139217043003,10.375316818009642,0.0,0.0,0.0,1.0,1,1,0,3,infracollicular sulcus,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_9,0.09695604666688457,9.265816164861734,4.983816818009642,0.0,0.0,0.0,1.0,1,1,0,4,PMJ,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_10,0.07145481432352847,11.758029209924914,9.373650151342977,0.0,0.0,0.0,1.0,1,1,0,5,superior interpeduncular fossa,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_11,6.977348200500579,5.378365350001412,9.44973348467631,0.0,0.0,0.0,1.0,1,1,0,6,R superior LMS,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_12,-6.800657625097212,5.4475541657561415,9.42148348467631,0.0,0.0,0.0,1.0,1,1,0,7,L superior LMS,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_13,6.74008527933924,5.298340724688141,5.314084929890324,0.0,0.0,0.0,1.0,1,1,0,8,R inferior LMS,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_14,-6.590634602624992,5.402942753646125,5.264310044425934,0.0,0.0,0.0,1.0,1,1,0,9,L inferior LMS,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_15,0.08141666666666672,-7.8352608496132605,17.54030945183531,0.0,0.0,0.0,1.0,1,1,0,10,culmen,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_16,0.07717123538604066,14.65491039017895,8.210161231445943,0.0,0.0,0.0,1.0,1,1,0,11,intermammillary sulcus,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_17,1.2688402122617857,14.13169514231175,8.943494564779277,0.0,0.0,0.0,1.0,1,1,0,12,R MB,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_18,-1.117837406615059,14.117983365304875,8.929161231445944,0.0,0.0,0.0,1.0,1,1,0,13,L MB,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_19,0.05658861845621968,3.069807485816424,15.839583333333332,0.0,0.0,0.0,1.0,1,1,0,14,PG,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_20,4.451996843451092,18.8782419601866,24.61933333333333,0.0,0.0,0.0,1.0,1,1,0,15,R LV at AC,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_21,-4.11949706399852,18.831834104060285,24.669583333333332,0.0,0.0,0.0,1.0,1,1,0,16,L LV at AC,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_22,7.547715764277959,5.965671638768311,22.936416666666663,0.0,0.0,0.0,1.0,1,1,0,17,R LV at PC,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_23,-7.217390318508649,5.8615400434078255,22.933583333333335,0.0,0.0,0.0,1.0,1,1,0,18,L LV at PC,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_24,0.051701710089667834,29.771102742966722,23.20975,0.0,0.0,0.0,1.0,1,1,0,19,genu of CC,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_25,0.048807124904610644,2.3809993316120925,16.91342172091821,0.0,0.0,0.0,1.0,1,1,0,20,splenium of CC,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_26,13.630206738735046,18.951922956490428,1.9276682056104553,0.0,0.0,0.0,1.0,1,1,0,21,R AL temporal horn,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_27,-13.453007880555313,19.043484620942216,1.8324182056104552,0.0,0.0,0.0,1.0,1,1,0,22,L AL temporal horn,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_28,7.7781795356474355,15.847431906184726,5.643083333333332,0.0,0.0,0.0,1.0,1,1,0,23,R superior AM temporal horn,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_29,-7.621876358066017,15.76534380988375,5.649249999999999,0.0,0.0,0.0,1.0,1,1,0,24,L superior AM temporal horn,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_30,8.864692140485749,18.495101897673837,2.1042732334634047,0.0,0.0,0.0,1.0,1,1,0,25,R inferior AM temporal horn,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_31,-8.559627961778657,18.4638541363551,2.0792962441972,0.0,0.0,0.0,1.0,1,1,0,26,L inferior AM temporal horn,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_32,8.991028224369595,-0.7428333418601726,16.278961914111196,0.0,0.0,0.0,1.0,1,1,0,27,R indusium griseum origin,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_33,-8.56842220766469,-0.8226484996903669,16.40748864754982,0.0,0.0,0.0,1.0,1,1,0,28,L indusium griseum origin,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_34,15.755055460105135,-5.730513345214814,13.43473864754982,0.0,0.0,0.0,1.0,1,1,0,29,R ventral occipital horn,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_35,-15.440053436467146,-5.509345111742168,13.630988647549819,0.0,0.0,0.0,1.0,1,1,0,30,L ventral occipital horn,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_36,5.245529717783376,31.16699076681327,20.75807198088315,0.0,0.0,0.0,1.0,1,1,0,31,R olfactory sulcal fundus,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_37,-5.4082911727647485,31.158702247293167,20.80748864754982,0.0,0.0,0.0,1.0,1,1,0,32,L olfactory sulcal fundus,vtkMRMLScalarVolumeNode1 diff --git a/afidsvalidator/afids-templates/macaca/tpl-Yerkes19_afids.fcsv b/afidsvalidator/afids-templates/macaca/tpl-Yerkes19_afids.fcsv new file mode 100644 index 00000000..1217839c --- /dev/null +++ b/afidsvalidator/afids-templates/macaca/tpl-Yerkes19_afids.fcsv @@ -0,0 +1,35 @@ +# Markups fiducial file version = 4.6 +# CoordinateSystem = 0 +# columns = id,x,y,z,ow,ox,oy,oz,vis,sel,lock,label,desc,associatedNodeID +vtkMRMLMarkupsFiducialNode_1,-0.44425,-0.3698125,0.74,0,0,0,1,1,1,0,1,AC,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_2,-0.4267771875,-14.1443125,0.6506348125,0,0,0,1,1,1,0,2,PC,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_3,-0.4103589375,-19.72289375,-3.428631875,0,0,0,1,1,1,0,3,infracollicular sulcus,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_4,-0.41825,-9.8081875,-9.2228125,0,0,0,1,1,1,0,4,PMJ,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_5,-0.4371716875,-7.882514375,-4.486480625,0,0,0,1,1,1,0,5,superior interpeduncular fossa,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_6,6.6565,-14.3133125,-4.7779375,0,0,0,1,1,1,0,6,R superior LMS,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_7,-7.6025,-14.3045625,-4.8665625,0,0,0,1,1,1,0,7,L superior LMS,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_8,6.5045625,-14.35225,-8.9871875,0,0,0,1,1,1,0,8,R inferior LMS,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_9,-7.5005625,-14.4080625,-8.935125,0,0,0,1,1,1,0,9,L inferior LMS,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_10,-0.4625710625,-27.673775,2.92126,0,0,0,1,1,1,0,10,culmen,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_11,-0.5190650625,-5.25716625,-5.947925,0,0,0,1,1,1,0,11,intermammillary sulcus,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_12,0.6485614375,-5.47547375,-5.70790625,0,0,0,1,1,1,0,12,R MB,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_13,-1.71992125,-5.5072525,-5.697660625,0,0,0,1,1,1,0,13,L MB,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_14,-0.4638439375,-18.2865625,1.763011875,0,0,0,1,1,1,0,14,pineal gland,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_15,2.6965,-0.329125,8.820625,0,0,0,1,1,1,0,15,R LV at AC,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_16,-3.81675,-0.275625,8.870625,0,0,0,1,1,1,0,16,L LV at AC,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_17,6.2923125,-14.04675,8.5334375,0,0,0,1,1,1,0,17,R LV at PC,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_18,-7.172125,-14.0260625,8.579625,0,0,0,1,1,1,0,18,L LV at PC,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_19,-0.432125,11.2950625,7.593,0,0,0,1,1,1,0,19,genu of CC,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_20,-0.417125,-18.6766875,2.823375,0,0,0,1,1,1,0,20,splenium of CC,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_21,13.6163125,-1.793125,-13.138875,0,0,0,1,1,1,0,21,R AL temporal horn,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_22,-14.70824375,-2.2890875,-12.69210625,0,0,0,1,1,1,0,22,L AL temporal horn,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_23,7.392625,-4.0513125,-9.181875,0,0,0,1,1,1,0,23,R superior AM temporal horn,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_24,-8.6914375,-4.4888125,-9.145375,0,0,0,1,1,1,0,24,L superior AM temporal horn,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_25,9.3619375,-2.6685625,-13.3648125,0,0,0,1,1,1,0,25,R inferior AM temporal horn,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_26,-10.905,-3.2800625,-13.1568125,0,0,0,1,1,1,0,26,L inferior AM temporal horn,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_27,6.880100625,-20.46729375,1.99231,0,0,0,1,1,1,0,27,R indusium griseum origin,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_28,-7.813145,-20.4593625,2.1077025,0,0,0,1,1,1,0,28,L indusium griseum origin,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_29,15.40296875,-23.8728125,-1.439950625,0,0,0,1,1,1,0,29,R ventral occipital horn,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_30,-15.9348125,-24.46655,-1.131631875,0,0,0,1,1,1,0,30,L ventral occipital horn,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_31,4.71014875,11.98429375,4.73296,0,0,0,1,1,1,0,31,R olfactory sulcal fundus,vtkMRMLScalarVolumeNode1 +vtkMRMLMarkupsFiducialNode_32,-5.52772875,11.9167,4.754100625,0,0,0,1,1,1,0,32,L olfactory sulcal fundus,vtkMRMLScalarVolumeNode1 diff --git a/afidsvalidator/landmark_info.py b/afidsvalidator/landmark_info.py new file mode 100644 index 00000000..cfba38fb --- /dev/null +++ b/afidsvalidator/landmark_info.py @@ -0,0 +1,536 @@ +"""Anatomical descriptions and placement guidance for the 32 AFIDs landmarks.""" + +# Keys match the abbreviation used as the attribute name on the Afids object +# (i.e., EXPECTED_DESCS[i][-1]). +LANDMARK_INFO = { + "AC": { + "full_name": "Anterior Commissure", + "description": ( + "A white matter tract connecting the two temporal lobes, crossing " + "the midline at the base of the septum pellucidum." + ), + "key_features": ( + "On a midsagittal slice, look for a small hyperintense (bright) " + "bundle just anterior to the columns of the fornix and inferior " + "to the genu of the corpus callosum. The AC sits directly at the " + "midline where the lamina terminalis meets the anterior " + "commissure itself." + ), + "common_mistakes": ( + "Placing too posteriorly (confusing with the fornix columns) or " + "too superiorly (landing on the genu of CC). Stay at the " + "midline — any lateral offset is an error." + ), + }, + "PC": { + "full_name": "Posterior Commissure", + "description": ( + "A small white matter tract at the dorsal aspect of the cerebral " + "aqueduct, marking the posterior boundary of the third ventricle." + ), + "key_features": ( + "On midsagittal: find the aqueduct of Sylvius and trace it " + "superiorly to where it opens into the third ventricle. The PC " + "lies at this junction, just superior to the superior colliculi " + "and inferior to the habenular commissure." + ), + "common_mistakes": ( + "Placing on the habenular commissure (too superior) or on the " + "superior colliculi (too inferior and posterior)." + ), + }, + "ICS": { + "full_name": "Infracollicular Sulcus", + "description": ( + "The sulcus immediately inferior to the inferior colliculi on " + "the posterior surface of the midbrain." + ), + "key_features": ( + "On sagittal: identify the quadrigeminal plate (four bumps on " + "the dorsal midbrain). The ICS is the groove just below the " + "inferior colliculi, where the midbrain meets the superior " + "medullary velum." + ), + "common_mistakes": ( + "Confusing with the intercollicular sulcus (the groove between " + "superior and inferior colliculi). The ICS is inferior to both " + "pairs of colliculi." + ), + }, + "PMJ": { + "full_name": "Pontomedullary Junction", + "description": ( + "The anatomical boundary between the pons and the medulla " + "oblongata on the ventral brainstem." + ), + "key_features": ( + "On sagittal: the ventral pons has a characteristic bulge " + "(the basis pontis). The PMJ is where this bulge ends and " + "the more tapered medulla begins — look for the change in " + "ventral contour. On axial: the disappearance of the " + "transverse pontine fibres." + ), + "common_mistakes": ( + "Placing too superiorly (within the pons) or too inferiorly " + "(within the medulla). The junction is often subtler on " + "standard T1 — use multiple planes." + ), + }, + "SIPF": { + "full_name": "Superior Interpeduncular Fossa", + "description": ( + "The apex of the interpeduncular fossa, the CSF-filled space " + "between the cerebral peduncles on the ventral midbrain." + ), + "key_features": ( + "On coronal or axial: the interpeduncular fossa appears as a " + "dark (CSF) inverted V between the two cerebral peduncles. " + "Place at the most superior tip of this space, at the level " + "of the mammillary bodies." + ), + "common_mistakes": ( + "Placing at the midpoint of the fossa rather than its most " + "superior extent. Confirm on sagittal that placement is " + "superior to the pons." + ), + }, + "RSLMS": { + "full_name": "Right Superior Lateral Midbrain Sulcus", + "description": ( + "The sulcus at the superior lateral margin of the right " + "midbrain, at the midbrain–thalamus interface." + ), + "key_features": ( + "On axial at the level of the superior colliculi: identify " + "the lateral margin of the midbrain. The RSLMS is the groove " + "where the superior aspect of the right midbrain meets the " + "overlying thalamus/pulvinar." + ), + "common_mistakes": ( + "Placing on the midbrain surface rather than in the sulcal " + "fundus. Confirm the left–right placement by ensuring " + "symmetry with LSLMS." + ), + }, + "LSLMS": { + "full_name": "Left Superior Lateral Midbrain Sulcus", + "description": ( + "The sulcus at the superior lateral margin of the left " + "midbrain, at the midbrain–thalamus interface." + ), + "key_features": ( + "Mirror of RSLMS. On axial at the level of the superior " + "colliculi: the groove where the superior aspect of the left " + "midbrain meets the overlying thalamus/pulvinar." + ), + "common_mistakes": ( + "Placing on the midbrain surface rather than in the sulcal " + "fundus. Check bilateral symmetry with RSLMS." + ), + }, + "RILMS": { + "full_name": "Right Inferior Lateral Midbrain Sulcus", + "description": ( + "The sulcus at the inferior lateral margin of the right " + "midbrain, at the midbrain–pons interface." + ), + "key_features": ( + "On axial at the level of the inferior colliculi or superior " + "pons: the groove at the lateral inferior margin of the right " + "midbrain, where it blends with the superior pons." + ), + "common_mistakes": ( + "Confusing with RSLMS — RILMS is more inferior and anterior. " + "Use coronal plane to confirm the inferior extent." + ), + }, + "LILMS": { + "full_name": "Left Inferior Lateral Midbrain Sulcus", + "description": ( + "The sulcus at the inferior lateral margin of the left " + "midbrain, at the midbrain–pons interface." + ), + "key_features": ( + "Mirror of RILMS. On axial at the level of the inferior " + "colliculi: the groove at the lateral inferior margin of the " + "left midbrain." + ), + "common_mistakes": ( + "Confusing with LSLMS — LILMS is more inferior. Confirm " + "bilateral symmetry with RILMS." + ), + }, + "CUL": { + "full_name": "Culmen", + "description": ( + "The most superior point of the anterior lobe of the " + "cerebellar vermis (lobule IV/V)." + ), + "key_features": ( + "On midsagittal: follow the superior surface of the cerebellum " + "to its highest midline point. The culmen is the peak of the " + "anterior vermis, immediately posterior to the primary fissure." + ), + "common_mistakes": ( + "Placing on the declive (posterior to the primary fissure) " + "or on a lateral cerebellar hemisphere rather than the midline " + "vermis." + ), + }, + "IMS": { + "full_name": "Intermammillary Sulcus", + "description": ( + "The midpoint of the sulcus between the two mammillary bodies " + "on the inferior hypothalamus." + ), + "key_features": ( + "On coronal: identify the two round mammillary bodies just " + "posterior to the tuber cinereum. The IMS is placed at the " + "midpoint of the groove between them. On axial: two bright " + "dots separated by a narrow sulcus at the floor of the " + "diencephalon." + ), + "common_mistakes": ( + "Placing on one of the mammillary bodies rather than the " + "sulcus between them. The IMS must be at the midline." + ), + }, + "RMB": { + "full_name": "Right Mammillary Body", + "description": ( + "The right mammillary body, a posterior hypothalamic nucleus " + "involved in memory circuits." + ), + "key_features": ( + "On coronal: the right of the two round structures at the " + "floor of the diencephalon, just anterior to the midbrain. " + "On axial: appears as a bright dot just right of midline, " + "posterior to the tuber cinereum." + ), + "common_mistakes": ( + "Placing on the IMS midpoint instead of the centre of the " + "right body, or landing too superiorly in the hypothalamus." + ), + }, + "LMB": { + "full_name": "Left Mammillary Body", + "description": ( + "The left mammillary body, a posterior hypothalamic nucleus " + "involved in memory circuits." + ), + "key_features": ( + "Mirror of RMB. On coronal: the left of the two round " + "structures at the floor of the diencephalon." + ), + "common_mistakes": ( + "Placing on the IMS midpoint instead of the centre of the " + "left body, or landing too superiorly in the hypothalamus." + ), + }, + "PG": { + "full_name": "Pineal Gland", + "description": ( + "A small midline neuroendocrine gland at the posterior roof " + "of the third ventricle." + ), + "key_features": ( + "On midsagittal: posterior to the habenular commissure and " + "superior to the superior colliculi. Often appears as a " + "calcified (hyperdense on CT, variable on MRI) nodule. " + "Place at the centre of the gland." + ), + "common_mistakes": ( + "Confusing with the habenular commissure (too anterior/superior) " + "or placing on the superior colliculi (too inferior). The PG " + "sits in the pineal recess, not on the quadrigeminal plate." + ), + }, + "RLVAC": { + "full_name": "Right Lateral Ventricle at AC", + "description": ( + "The most lateral point of the right lateral ventricle body " + "at the coronal level of the anterior commissure." + ), + "key_features": ( + "On a coronal slice through the AC: find the body of the " + "right lateral ventricle and place at its most lateral " + "extent. The septum pellucidum separates left and right." + ), + "common_mistakes": ( + "Using the wrong coronal level — must be at the AC, not " + "anterior or posterior to it. Confirm AC location on sagittal " + "first, then switch to coronal." + ), + }, + "LLVAC": { + "full_name": "Left Lateral Ventricle at AC", + "description": ( + "The most lateral point of the left lateral ventricle body " + "at the coronal level of the anterior commissure." + ), + "key_features": ( + "Mirror of RLVAC. On coronal at AC level: most lateral " + "extent of the left lateral ventricle body." + ), + "common_mistakes": ( + "Same as RLVAC — confirm AC level on sagittal first. " + "Check bilateral symmetry." + ), + }, + "RLVPC": { + "full_name": "Right Lateral Ventricle at PC", + "description": ( + "The most lateral point of the right lateral ventricle body " + "at the coronal level of the posterior commissure." + ), + "key_features": ( + "On a coronal slice through the PC: the right lateral " + "ventricle body at its most lateral point. The ventricle " + "is typically narrower here than at the AC level." + ), + "common_mistakes": ( + "Confusing the PC level with the AC level. Identify PC on " + "sagittal first (dorsal aqueduct opening), then move to " + "coronal." + ), + }, + "LLVPC": { + "full_name": "Left Lateral Ventricle at PC", + "description": ( + "The most lateral point of the left lateral ventricle body " + "at the coronal level of the posterior commissure." + ), + "key_features": ( + "Mirror of RLVPC. Coronal at PC level: most lateral extent " + "of the left lateral ventricle." + ), + "common_mistakes": ( + "Same as RLVPC — confirm PC level before switching planes. " + "Check bilateral symmetry." + ), + }, + "GENU": { + "full_name": "Genu of the Corpus Callosum", + "description": ( + "The anteriormost bend of the corpus callosum where it " + "curves inferiorly toward the rostrum." + ), + "key_features": ( + "On midsagittal: the anterior end of the corpus callosum " + "where the fibres curve forward and downward. Place at the " + "most anterior and inferior point of this bend, not at the " + "superior surface." + ), + "common_mistakes": ( + "Placing on the superior surface of the CC rather than the " + "anterior-inferior curve, or confusing with the rostrum " + "(the thin portion below the genu)." + ), + }, + "SPLE": { + "full_name": "Splenium of the Corpus Callosum", + "description": ( + "The posterior thickened end of the corpus callosum, " + "connecting the occipital and parietal lobes." + ), + "key_features": ( + "On midsagittal: the bulbous posterior terminus of the " + "corpus callosum. Place at the most posterior point of " + "the splenium, at the midline." + ), + "common_mistakes": ( + "Placing too anteriorly (on the posterior body rather than " + "the splenium), or laterally off the midline." + ), + }, + "RALTH": { + "full_name": "Right Anterolateral Temporal Horn", + "description": ( + "The anterolateral tip of the right temporal horn of the " + "lateral ventricle." + ), + "key_features": ( + "On axial or coronal: follow the temporal horn anteriorly " + "until it terminates. Place at the most anterior and lateral " + "extent of the CSF-filled horn." + ), + "common_mistakes": ( + "Placing within the horn rather than at its tip, or " + "confusing the choroid fissure with the horn lumen." + ), + }, + "LALTH": { + "full_name": "Left Anterolateral Temporal Horn", + "description": ( + "The anterolateral tip of the left temporal horn of the " + "lateral ventricle." + ), + "key_features": ( + "Mirror of RALTH. Axial or coronal: most anterior and " + "lateral extent of the left temporal horn CSF space." + ), + "common_mistakes": ( + "Placing within the horn body rather than at its anterior " + "tip. Check bilateral symmetry with RALTH." + ), + }, + "RSAMTH": { + "full_name": "Right Superior Anteromedial Temporal Horn", + "description": ( + "The superior anteromedial extent of the right temporal horn, " + "near the amygdala." + ), + "key_features": ( + "On coronal anterior to the hippocampal head: find the " + "superomedial corner of the temporal horn where it abuts " + "the amygdala. This is a tight, CSF-filled angle." + ), + "common_mistakes": ( + "Placing too laterally (on the anterolateral horn) or " + "confusing with RIAMTH below. RSAMTH is the superior corner." + ), + }, + "LSAMTH": { + "full_name": "Left Superior Anteromedial Temporal Horn", + "description": ( + "The superior anteromedial extent of the left temporal horn, " + "near the amygdala." + ), + "key_features": ( + "Mirror of RSAMTH. Coronal anterior to hippocampal head: " + "superomedial corner of the left temporal horn." + ), + "common_mistakes": ( + "Confusing with LIAMTH (inferior corner) or LALTH (lateral). " + "Check bilateral symmetry." + ), + }, + "RIAMTH": { + "full_name": "Right Inferior Anteromedial Temporal Horn", + "description": ( + "The inferior anteromedial extent of the right temporal horn, " + "near the hippocampal head." + ), + "key_features": ( + "On coronal at the hippocampal head: the inferomedial corner " + "of the right temporal horn where the horn curves around the " + "hippocampus. Inferior to RSAMTH in the same coronal plane." + ), + "common_mistakes": ( + "Placing on the hippocampal surface rather than in the " + "CSF-filled corner of the horn, or confusing with RSAMTH " + "(which is superior)." + ), + }, + "LIAMTH": { + "full_name": "Left Inferior Anteromedial Temporal Horn", + "description": ( + "The inferior anteromedial extent of the left temporal horn, " + "near the hippocampal head." + ), + "key_features": ( + "Mirror of RIAMTH. Coronal at hippocampal head: inferomedial " + "corner of the left temporal horn." + ), + "common_mistakes": ( + "Confusing with LSAMTH (superior corner). Verify by " + "checking RIAMTH for bilateral symmetry." + ), + }, + "RIGO": { + "full_name": "Right Indusium Griseum Origin", + "description": ( + "The origin of the right indusium griseum — a thin grey " + "matter strip on the superior surface of the corpus callosum " + "continuous with the dentate gyrus." + ), + "key_features": ( + "On sagittal: trace the superior surface of the genu of the " + "CC posteriorly. The RIGO is at the point where the indusium " + "griseum first appears on the right side, just posterior to " + "the genu on the CC surface." + ), + "common_mistakes": ( + "Placing on the CC body surface rather than at the origin " + "point, or confusing with the cingulate gyrus above." + ), + }, + "LIGO": { + "full_name": "Left Indusium Griseum Origin", + "description": ( + "The origin of the left indusium griseum on the superior " + "surface of the corpus callosum." + ), + "key_features": ( + "Mirror of RIGO. Sagittal: posterior to the genu on the " + "left surface of the CC where the indusium griseum begins." + ), + "common_mistakes": ( + "Same as RIGO — avoid placing on the cingulate gyrus or " + "mid-body of the CC." + ), + }, + "RVOH": { + "full_name": "Right Ventral Occipital Horn", + "description": ( + "The most ventral and posterior tip of the right occipital " + "horn of the lateral ventricle." + ), + "key_features": ( + "On axial: follow the occipital horn posteriorly to its " + "terminal point. Place at the most posterior–inferior " + "tip of the right CSF-filled horn." + ), + "common_mistakes": ( + "Placing mid-horn rather than at the terminal tip, or " + "including the calcar avis (the white matter bulge into " + "the occipital horn) as part of the CSF space." + ), + }, + "LVOH": { + "full_name": "Left Ventral Occipital Horn", + "description": ( + "The most ventral and posterior tip of the left occipital " + "horn of the lateral ventricle." + ), + "key_features": ( + "Mirror of RVOH. Axial: most posterior–inferior tip of the " + "left occipital horn." + ), + "common_mistakes": ( + "Same as RVOH. Check bilateral symmetry with RVOH." + ), + }, + "ROSF": { + "full_name": "Right Olfactory Sulcal Fundus", + "description": ( + "The deepest point of the right olfactory sulcus on the " + "orbital (inferior) surface of the right frontal lobe." + ), + "key_features": ( + "On coronal through the frontal lobe: the olfactory sulcus " + "is a vertical groove on the orbital surface housing the " + "olfactory tract. Place at the deepest point (fundus) of " + "this sulcus on the right side." + ), + "common_mistakes": ( + "Placing on the olfactory tract itself rather than the " + "sulcal fundus, or confusing with the medial orbital sulcus." + ), + }, + "LOSF": { + "full_name": "Left Olfactory Sulcal Fundus", + "description": ( + "The deepest point of the left olfactory sulcus on the " + "orbital surface of the left frontal lobe." + ), + "key_features": ( + "Mirror of ROSF. Coronal: deepest point of the left " + "olfactory sulcus on the orbital frontal surface." + ), + "common_mistakes": ( + "Same as ROSF — check bilateral symmetry and use the " + "olfactory tract as a guide to find the sulcus." + ), + }, +} diff --git a/afidsvalidator/learn.py b/afidsvalidator/learn.py new file mode 100644 index 00000000..ad46fab3 --- /dev/null +++ b/afidsvalidator/learn.py @@ -0,0 +1,445 @@ +"""Guided learning mode — LLM-assisted landmark placement on MRI. + +Routes +------ +GET /learn Render the guided learning page +GET /learn/nifti Serve MNI152NLin2009cAsym T1w NIfTI + (downloads from TemplateFlow and caches locally + on first request) +GET /learn/references JSON — all 32 reference coords in RAS mm +POST /learn/check JSON — compute error for a placed coordinate +POST /learn/intro Stream — LLM introduction for a landmark +POST /learn/feedback Stream — LLM feedback after placement +POST /learn/chat Stream — LLM answer to a free-text question +POST /learn/llm-status JSON — active model + whether a key is in use +""" + +from __future__ import annotations + +import math +import urllib.request +from pathlib import Path + +from flask import ( + Blueprint, + Response, + current_app, + jsonify, + render_template, + request, + stream_with_context, +) + +from afidsvalidator import reliability +from afidsvalidator.landmark_info import LANDMARK_INFO +from afidsvalidator.llm import ( + build_feedback_messages, + build_intro_messages, + build_question_messages, + resolve_model_label, + server_has_default_key, + stream_chat, +) +from afidsvalidator.rag import retrieve + +learn = Blueprint("learn", __name__, template_folder="templates") + +# ── Template constants ──────────────────────────────────────────────────────── +TEMPLATE_NAME = "tpl-MNI152NLin2009cAsym" + +# TemplateFlow public S3 — 1 mm isotropic T1w for MNI152NLin2009cAsym + +FCSV_PATH = ( + Path(__file__).parent + / "afids-templates" + / "human" + / f"{TEMPLATE_NAME}_afids.fcsv" +) + +# Ordered list of landmark abbreviations (matches AFIDs protocol / FCSV order) +LANDMARK_ORDER: list[str] = list(LANDMARK_INFO.keys()) + + +# ── Helpers ─────────────────────────────────────────────────────────────────── +def _load_references() -> dict[str, list[float]]: + """Parse FCSV and return {abbrev: [x, y, z]} in RAS mm. + + FCSV CoordinateSystem=0 in this codebase means RAS, which is the same + space NiiVue reports when loading a standard neuroimaging NIfTI. + """ + refs: dict[str, list[float]] = {} + with open(FCSV_PATH) as fh: + for line in fh: + if line.startswith("#"): + continue + parts = line.strip().split(",") + if len(parts) < 13: + continue + try: + x = float(parts[1]) + y = float(parts[2]) + z = float(parts[3]) + # Label column (index 11) is the 1-based landmark number; + # use it to look up the abbreviation from the protocol order. + label = int(parts[11]) + abbrev = LANDMARK_ORDER[label - 1] + refs[abbrev] = [x, y, z] + except (ValueError, IndexError): + continue + return refs + + +def _extract_llm_config(data: dict) -> dict | None: + """Pull an optional per-request LLM override from the request body. + + The visitor's browser may send ``{"llm": {api_key, base_url, model}}`` — + a key held only client-side (localStorage) and forwarded per request. We + never log or persist it. Returns None when no usable override is present. + """ + cfg = data.get("llm") + if not isinstance(cfg, dict): + return None + override = { + k: cfg.get(k) + for k in ("api_key", "base_url", "model") + if isinstance(cfg.get(k), str) and cfg.get(k).strip() + } + return override or None + + +def _offline_fallback(chunks: list[str], abbrev: str = "") -> str: + """Reference text shown when no language model is reachable. + + Guarantees the learner still gets useful anatomy — the tool never + dead-ends — while nudging them to add their own key for interactive + tutoring. + """ + note = ( + "The interactive AI tutor needs a language-model key. Add your own " + "in Settings (⚙) to enable conversational tutoring. In the " + "meantime, here is the reference material for this landmark:\n\n" + ) + body = "\n\n".join(c for c in chunks if c).strip() + if not body and abbrev in LANDMARK_INFO: + info = LANDMARK_INFO[abbrev] + body = ( + f"{abbrev} — {info['full_name']}\n" + f"{info['description']}\n" + f"Key features on MRI: {info['key_features']}\n" + f"Common mistakes: {info['common_mistakes']}" + ) + return note + (body or "Reference material is unavailable right now.") + + +def _stream_llm( + messages: list[dict], + llm_config: dict | None = None, + fallback: str | None = None, +) -> Response: + """Return a streaming plain-text Flask response from the LLM. + + If the model cannot be reached before producing any output and a + *fallback* is provided, the fallback text is streamed instead so the + learner is never left with a dead tutor. + """ + + def generate(): + produced = False + try: + for chunk in stream_chat(messages, llm_config=llm_config): + produced = True + yield chunk + except Exception as exc: # noqa: BLE001 + if not produced and fallback: + yield fallback + else: + yield f"\n\n[Tutor interrupted — {exc}]" + + return Response( + stream_with_context(generate()), + mimetype="text/plain; charset=utf-8", + headers={ + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + "Transfer-Encoding": "chunked", + }, + ) + + +# ── Routes ──────────────────────────────────────────────────────────────────── +@learn.route("/learn") +def learn_page(): + landmarks = [ + { + "abbrev": abbrev, + "full_name": LANDMARK_INFO[abbrev]["full_name"], + } + for abbrev in LANDMARK_ORDER + ] + return render_template( + "learn.html", + landmarks=landmarks, + template_name=TEMPLATE_NAME, + ) + + +def _nifti_url_for_res(res: str) -> str: + return ( + "https://templateflow.s3.amazonaws.com/tpl-MNI152NLin2009cAsym/" + f"tpl-MNI152NLin2009cAsym_res-{res}_T1w.nii.gz" + ) + + +def _nifti_cache_path_for_res(res: str) -> Path: + cache_dir = Path(current_app.instance_path) / "brain_cache" + cache_dir.mkdir(parents=True, exist_ok=True) + return cache_dir / f"{TEMPLATE_NAME}_res-{res}_T1w.nii.gz" + + +@learn.route("/learn/nifti") +def serve_nifti(): + """Serve MNI152NLin2009cAsym T1w NIfTI. + + ?res=02 (default) — 2 mm isotropic, ~1.7 MB, fast + ?res=01 — 1 mm isotropic, ~9 MB, high detail + + Downloads from TemplateFlow on first request, then serves from cache. + """ + res = request.args.get("res", "02") + if res not in ("01", "02"): + res = "02" + + cache = _nifti_cache_path_for_res(res) + + if not cache.exists(): + url = _nifti_url_for_res(res) + current_app.logger.info("Downloading res-%s from TemplateFlow …", res) + try: + urllib.request.urlretrieve(url, cache) + except Exception as exc: # noqa: BLE001 + current_app.logger.error("TemplateFlow download failed: %s", exc) + return jsonify({"error": str(exc)}), 502 + + file_size = cache.stat().st_size + + def generate(): + with open(cache, "rb") as fh: + while True: + chunk = fh.read(65_536) + if not chunk: + break + yield chunk + + return Response( + stream_with_context(generate()), + mimetype="application/octet-stream", + headers={ + "Content-Length": str(file_size), + "Content-Disposition": f'inline; filename="{cache.name}"', + "Cache-Control": "public, max-age=86400", + "Access-Control-Allow-Origin": "*", + }, + ) + + +@learn.route("/learn/references") +def get_references(): + """Return all 32 reference coordinates (RAS mm) as JSON.""" + refs = _load_references() + landmarks = [ + { + "abbrev": abbrev, + "full_name": LANDMARK_INFO[abbrev]["full_name"], + "coords": refs.get(abbrev, [0.0, 0.0, 0.0]), + } + for abbrev in LANDMARK_ORDER + if abbrev in refs + ] + return jsonify({"template": TEMPLATE_NAME, "landmarks": landmarks}) + + +@learn.route("/learn/check", methods=["POST"]) +def check_placement(): + """Compute Euclidean distance and directional error for a placement. + + Request JSON + ------------ + { "landmark": "AC", "coords": [x, y, z] } ← RAS mm from NiiVue + + Response JSON + ------------- + { landmark, distance_mm, user_coords, ref_coords, directions, quality } + """ + data = request.get_json(force=True) + abbrev: str = data.get("landmark", "") + user: list[float] = data.get("coords", []) + + if len(user) != 3: + return jsonify({"error": "coords must be [x, y, z]"}), 400 + + refs = _load_references() + ref = refs.get(abbrev) + if ref is None: + return jsonify({"error": f"Unknown landmark: {abbrev!r}"}), 400 + + dx = user[0] - ref[0] + dy = user[1] - ref[1] + dz = user[2] - ref[2] + distance = math.sqrt(dx**2 + dy**2 + dz**2) + + # RAS: +x right, +y anterior, +z superior + THRESHOLD = 0.5 # mm — suppress tiny directional components + directions: list[str] = [] + if abs(dx) >= THRESHOLD: + directions.append( + f"{'right' if dx > 0 else 'left'} by {abs(dx):.1f} mm" + ) + if abs(dy) >= THRESHOLD: + directions.append( + f"{'anterior' if dy > 0 else 'posterior'} by {abs(dy):.1f} mm" + ) + if abs(dz) >= THRESHOLD: + directions.append( + f"{'superior' if dz > 0 else 'inferior'} by {abs(dz):.1f} mm" + ) + + if distance < 1.0: + quality = "excellent" + elif distance < 2.0: + quality = "good" + elif distance < 4.0: + quality = "fair" + else: + quality = "needs_work" + + return jsonify( + { + "landmark": abbrev, + "distance_mm": round(distance, 2), + "user_coords": user, + "ref_coords": ref, + "directions": directions, + "quality": quality, + # Where this placement sits within the trained-rater distribution + # for THIS landmark (from the AFIDs multi-rater dataset). None when + # the landmark is absent from the reliability prior. + "rater": reliability.band(abbrev, distance), + } + ) + + +@learn.route("/learn/intro", methods=["POST"]) +def stream_intro(): + """Stream an LLM introduction for a landmark. + + Request JSON: { "landmark": "AC" } + """ + data = request.get_json(force=True) + abbrev: str = data.get("landmark", "") + info = LANDMARK_INFO.get(abbrev, {}) + query = ( + f"{abbrev} {info.get('full_name', '')} MRI anatomy placement protocol" + ) + chunks = retrieve(query, landmark_hint=abbrev, top_k=3) + messages = build_intro_messages( + abbrev, + context_chunks=chunks, + rater_context=reliability.intro_line(abbrev), + ) + return _stream_llm( + messages, + llm_config=_extract_llm_config(data), + fallback=_offline_fallback(chunks, abbrev), + ) + + +@learn.route("/learn/feedback", methods=["POST"]) +def stream_feedback(): + """Stream LLM feedback after a placement. + + Request JSON + ------------ + { + "landmark": "AC", + "distance_mm": 2.4, + "quality": "fair", + "history": [ {role, content}, … ] + } + Note: directional error vectors are intentionally excluded so the LLM + teaches anatomy rather than coordinate navigation. + """ + data = request.get_json(force=True) + abbrev: str = data.get("landmark", "") + directions: list[str] = data.get("directions", []) + info = LANDMARK_INFO.get(abbrev, {}) + query = ( + f"{abbrev} {info.get('full_name', '')} placement error " + + " ".join(directions) + ) + chunks = retrieve(query, landmark_hint=abbrev, top_k=3) + messages = build_feedback_messages( + history=data.get("history", []), + abbrev=abbrev, + distance_mm=float(data.get("distance_mm", 0)), + quality=data.get("quality", ""), + user_coords=data.get("user_coords"), + ref_coords=data.get("ref_coords"), + directions=directions, + viewer_state=data.get("viewer_state"), + context_chunks=chunks, + rater_context=reliability.feedback_line( + abbrev, float(data.get("distance_mm", 0)) + ), + ) + return _stream_llm( + messages, + llm_config=_extract_llm_config(data), + fallback=_offline_fallback(chunks, abbrev), + ) + + +@learn.route("/learn/chat", methods=["POST"]) +def stream_chat_route(): + """Stream an LLM answer to a free-text question. + + Request JSON + ------------ + { + "question": "What does the AC look like on T2?", + "history": [ {role, content}, … ] + } + """ + data = request.get_json(force=True) + question: str = data.get("question", "") + chunks = retrieve(question, top_k=4) + messages = build_question_messages( + history=data.get("history", []), + question=question, + context_chunks=chunks, + ) + return _stream_llm( + messages, + llm_config=_extract_llm_config(data), + fallback=_offline_fallback(chunks), + ) + + +@learn.route("/learn/llm-status", methods=["POST"]) +def llm_status(): + """Report the model that will answer, given an optional client override. + + Request JSON: { "llm": {api_key, base_url, model} } (all optional) + Response JSON: { model, using_own_key, shared_default_available } + + Lets the UI show the active model and whether the visitor is running on + their own key or the shared default tutor. No key is stored or logged. + """ + data = request.get_json(force=True, silent=True) or {} + override = _extract_llm_config(data) or {} + return jsonify( + { + "model": resolve_model_label(override), + "using_own_key": bool(override.get("api_key")), + "shared_default_available": server_has_default_key(), + } + ) diff --git a/afidsvalidator/llm.py b/afidsvalidator/llm.py new file mode 100644 index 00000000..3d7ecb52 --- /dev/null +++ b/afidsvalidator/llm.py @@ -0,0 +1,391 @@ +"""Model-agnostic LLM interface for the AFIDs guided learning mode. + +Configure via environment variables: + LLM_API_KEY — API key. If unset, defaults to local Ollama (free, no key needed). + LLM_BASE_URL — Base URL of any OpenAI-compatible endpoint. + Defaults to Groq (https://api.groq.com/openai/v1) when a key is + set, or local Ollama (http://localhost:11434/v1) when not. + Examples: + OpenAI : https://api.openai.com/v1 (needs LLM_API_KEY) + Anthropic : https://api.anthropic.com/v1 (needs LLM_API_KEY) + Groq : https://api.groq.com/openai/v1 (needs LLM_API_KEY) + Together : https://api.together.xyz/v1 (needs LLM_API_KEY) + Ollama : http://localhost:11434/v1 (no key required — free) + LLM_MODEL — Model name. + Defaults to llama-3.3-70b-versatile (Groq) when a key is set, + or llama3.2 (Ollama) when not. + Examples (Ollama): llama3.2, llama3.1, mistral, phi4, gemma3 + Examples (Groq): llama-3.3-70b-versatile, mixtral-8x7b-32768 + Examples (OpenAI): gpt-4o, gpt-4o-mini + +Zero-config quick start (local, free): + 1. Install Ollama: https://ollama.com + 2. Run: ollama pull llama3.2 + 3. Start the app — no env vars needed. + +RAG context +----------- +The message builders accept an optional ``context_chunks`` list (retrieved from +the knowledge store via afidsvalidator.rag.retrieve). When chunks are provided +they are injected into the system prompt in place of the former static landmark +block, giving the model targeted, query-specific context rather than all 32 +landmark definitions on every call. + +When ``context_chunks`` is None or empty the system prompt falls back to a +compact version that still carries the tutor persona and behavioural rules — +the model then relies on its pre-trained neuroanatomy knowledge. +""" + +from __future__ import annotations + +import os +from typing import Generator + +from afidsvalidator.landmark_info import LANDMARK_INFO + +# ── Ordered landmark list (matches FCSV / AFIDs protocol order) ─────────────── +LANDMARK_ORDER: list[str] = list(LANDMARK_INFO.keys()) + +# ── Base system prompt (persona + rules, no landmark definitions) ───────────── +_SYSTEM_PROMPT_BASE = """\ +You are an expert neuroanatomy tutor embedded inside the \ +AFIDs Validator (https://validator.afids.io), an open-access neuroimaging \ +education platform built around the Anatomical Fiducials (AFIDs) protocol \ +(https://afids.github.io/afids-protocol/). + +Your job is to guide users — typically graduate students, postdocs, or \ +clinicians new to neuroimaging — through placing 32 precisely defined \ +anatomical landmarks on a T1-weighted MRI of the MNI152NLin2009cAsym brain \ +template. The viewer uses RAS coordinates (positive x = right, positive y = \ +anterior, positive z = superior). + +YOUR THREE MODES: + +1. INTRODUCTION (when asked to introduce a landmark) + - 3–5 sentences maximum. + - Tell the user WHAT the structure is anatomically (what it does, why it \ +matters in the AFIDs protocol and in neuroimaging normalisation). + - Then describe WHERE to find it on this T1w MRI — which plane to use \ +(axial / coronal / sagittal), what surrounding structures to anchor on, and \ +what the landmark looks like on T1 contrast. + - End with one practical tip to avoid the most common mistake for this \ +landmark. + - Tone: encouraging, clear, expert but not condescending. + +2. PLACEMENT FEEDBACK (when the user has placed a fiducial) + You will receive: the placed coordinate (RAS mm), the reference coordinate \ +(RAS mm), the Euclidean distance, quality, directional offset, AND the \ +current viewer settings (zoom, resolution, contrast window). + Steps: + a) Open with a one-line verdict naming the distance \ +(e.g. "Not quite — 17.3 mm off." or "Excellent — 0.8 mm!"). + b) Reason anatomically about what the user likely clicked instead, drawing \ +on the landmark description and common mistakes in the reference material below. + c) Use anatomical directions freely to orient them \ +("too posterior", "slightly superior to the genu", etc.). + d) VIEWER RECOMMENDATION — always check the viewer settings and advise \ +the user specifically: + • If zoom ≤ 1.5 and error > 3 mm → suggest zooming in ("+/− buttons \ +or scroll) for better precision. + • If resolution is 2mm and error > 4 mm → suggest switching to 1mm \ +(the RES button). 1mm is the FINEST resolution available — NEVER suggest \ +0.5mm or any other value. + • If both apply, mention both. If the placement is already excellent \ +or the viewer is already optimised, skip this step. + - 4–6 sentences total. Keep it tight. + +3. QUESTION ANSWERING (when the user asks a follow-up) + - Answer precisely, using anatomical terminology. + - Reference MRI planes, contrast behaviour, and viewer settings where \ +relevant. + - If the question is about finding a structure, mention which plane and \ +zoom/resolution would help most. + - 2–4 sentences unless the question requires more. + +VIEWER CONTROLS THAT ACTUALLY EXIST (never suggest anything else): +- Resolution: only two options, 2mm and 1mm. 1mm is the finest available. \ +NEVER mention 0.5mm, sub-millimetre, or any other resolution. +- Zoom: the +/− buttons or mouse scroll; a reset-zoom button; and a \ +"Go to landmark" button that jumps the crosshair near the current target. +- View: a radiological/neurological convention toggle and a sagittal flip. +- Actions: Place Fiducial, Show Reference, Next Landmark. +Do NOT invent tools, buttons, filters, overlays, measurements, or resolutions \ +the viewer does not have. If the placement is already good, do not force a \ +viewer suggestion. + +GENERAL RULES: +- Use millimetres (mm) for distances when reporting accuracy scores. +- Anatomical directional language is encouraged ("too posterior", "slightly \ +superior to that", "lateral to the midline") — this is how anatomy is taught. +- NEVER tell the user to "set the cursor to [x, y, z]" or give raw coordinate \ +values as instructions. Teach anatomy and visual landmarks, not number lookup. +- Do not use excessive markdown — this renders in a simple chat pane. +- Never fabricate landmark positions. If unsure, say so. +- TONE — always warm, constructive, and encouraging, even for large errors. \ +Treat a miss as a normal, expected step in learning, never as a failure. Do \ +NOT use alarming or judgemental words such as "concerning", "wrong", "poor", \ +"bad", "off-target", or "problem". Instead use gentle framings like "not \ +quite yet", "close — let's refine this", "a spot that's easy to slip on", or \ +"good instinct — the actual landmark is just nearby". Acknowledge what the \ +learner did reasonably before guiding them to the correction. +- PLAIN LANGUAGE — keep the correct neuroanatomical terms (this is a real \ +protocol), but make them accessible. The FIRST time you use a term, add a \ +short plain-language gloss in the same breath, e.g. "the fornix — the \ +arching band of fibres beneath the corpus callosum". Prefer short sentences, \ +introduce one structure at a time, and never stack several unfamiliar terms \ +in a row. Write for a motivated beginner who is seeing this anatomy for the \ +first time, not for an expert audience. Precise and simple, not dumbed down. +- Neuroanatomy is hard; normalise the difficulty while maintaining precision.\ +""" + + +def build_system_prompt(context_chunks: list[str] | None = None) -> str: + """Build the full system prompt, optionally injecting retrieved context. + + When *context_chunks* is non-empty the retrieved passages are appended + after the base rules under a labelled section. This replaces the former + static block that embedded all 32 landmark definitions on every call. + """ + if not context_chunks: + return _SYSTEM_PROMPT_BASE + + context = "\n\n".join(context_chunks) + return ( + _SYSTEM_PROMPT_BASE + + "\n\n" + + "━" * 48 + + "\nREFERENCE MATERIAL (retrieved for this query)\n" + + "━" * 48 + + "\n\n" + + context + ) + + +# ── Client factory ──────────────────────────────────────────────────────────── +_OLLAMA_DEFAULT_URL = "http://localhost:11434/v1" +_GROQ_DEFAULT_URL = "https://api.groq.com/openai/v1" +_GROQ_DEFAULT_MODEL = "llama-3.3-70b-versatile" + + +def _clean(value: str | None) -> str: + return (value or "").strip() + + +def _pick(override: dict, field: str, env: str) -> str: + """Return the override field if set, else the environment variable.""" + return _clean(override.get(field)) or _clean(os.environ.get(env)) + + +def _resolve_llm(override: dict | None = None): + """Resolve an (OpenAI-compatible client, model name) pair. + + Precedence for each of api_key / base_url / model is: + user-supplied *override* → env variable → built-in default + This is what powers "bring your own key": a request may carry its own + ``{api_key, base_url, model}`` (held only in the visitor's browser and + sent per-request), and it takes priority over the server's shared default. + + When no key is available from either source we fall back to a local + OpenAI-compatible endpoint (Ollama by default) — appropriate for + development, and handled gracefully upstream when unreachable. + """ + try: + from openai import OpenAI + except ImportError as exc: + raise RuntimeError( + "The 'openai' package is required for guided learning. " + "Install it with: poetry add openai" + ) from exc + + override = override or {} + api_key = _pick(override, "api_key", "LLM_API_KEY") + base_url = _pick(override, "base_url", "LLM_BASE_URL") + model = _pick(override, "model", "LLM_MODEL") + + if api_key: + client = OpenAI( + api_key=api_key, base_url=base_url or _GROQ_DEFAULT_URL + ) + return client, (model or _GROQ_DEFAULT_MODEL) + + # No key anywhere → local self-hosted OpenAI-compatible endpoint (Ollama). + client = OpenAI(api_key="ollama", base_url=base_url or _OLLAMA_DEFAULT_URL) + return client, (model or "llama3.2") + + +def resolve_model_label(override: dict | None = None) -> str: + """Return the model name that *would* be used, for display in the UI.""" + override = override or {} + model = _pick(override, "model", "LLM_MODEL") + if model: + return model + has_key = _pick(override, "api_key", "LLM_API_KEY") + return _GROQ_DEFAULT_MODEL if has_key else "llama3.2" + + +def server_has_default_key() -> bool: + """True when the deployment carries a shared default API key.""" + return bool(_clean(os.environ.get("LLM_API_KEY"))) + + +# ── Message builders ────────────────────────────────────────────────────────── + + +def build_intro_messages( + abbrev: str, + context_chunks: list[str] | None = None, + rater_context: str | None = None, +) -> list[dict]: + """Return the messages list that triggers a landmark introduction. + + Parameters + ---------- + abbrev: + AFIDs landmark abbreviation (e.g. "AC"). + context_chunks: + Retrieved knowledge chunks from ``rag.retrieve()`` to inject as + reference material. Pass ``None`` or ``[]`` to use the base prompt + without additional context. + """ + info = LANDMARK_INFO.get(abbrev, {}) + full_name = info.get("full_name", abbrev) + rater_str = f"\n\n[{rater_context}]" if rater_context else "" + return [ + {"role": "system", "content": build_system_prompt(context_chunks)}, + { + "role": "user", + "content": ( + f"Please introduce landmark {abbrev} ({full_name}). " + f"Tell me what it is anatomically and how to find it on the " + f"MNI152NLin2009cAsym T1w MRI in the viewer. " + f"I will then click to place my marker.{rater_str}" + ), + }, + ] + + +def build_feedback_messages( + history: list[dict], + abbrev: str, + distance_mm: float, + quality: str, + user_coords: list[float] | None = None, + ref_coords: list[float] | None = None, + directions: list[str] | None = None, + viewer_state: dict | None = None, + context_chunks: list[str] | None = None, + rater_context: str | None = None, +) -> list[dict]: + """Append a placement result to history and return the full messages list. + + The system message is always rebuilt with fresh retrieved context so the + model has up-to-date reference material regardless of prior conversation + state stored in *history*. + """ + system = {"role": "system", "content": build_system_prompt(context_chunks)} + # Strip any existing system message from history (we just rebuilt it) + prior = [m for m in history if m.get("role") != "system"] + + info = LANDMARK_INFO.get(abbrev, {}) + full_name = info.get("full_name", abbrev) + + quality_labels = { + "excellent": "Excellent", + "good": "Good", + "fair": "Fair — needs improvement", + "needs_work": "Needs significant improvement", + } + verdict = quality_labels.get(quality, quality) + + coord_str = "" + if user_coords and len(user_coords) == 3: + coord_str += ( + f"\nPlaced at : ({user_coords[0]:.1f}, {user_coords[1]:.1f}, " + f"{user_coords[2]:.1f}) mm RAS" + ) + if ref_coords and len(ref_coords) == 3: + coord_str += ( + f"\nReference : ({ref_coords[0]:.1f}, {ref_coords[1]:.1f}, " + f"{ref_coords[2]:.1f}) mm RAS" + ) + if directions: + coord_str += f"\nOffset : {', '.join(directions)}" + + viewer_parts = [] + if viewer_state: + if viewer_state.get("resolution"): + viewer_parts.append(f"resolution: {viewer_state['resolution']}") + if viewer_state.get("zoom"): + viewer_parts.append(f"zoom: {viewer_state['zoom']}×") + if viewer_state.get("contrast_min") and viewer_state.get( + "contrast_max" + ): + viewer_parts.append( + f"contrast window: {viewer_state['contrast_min']}–" + f"{viewer_state['contrast_max']}" + ) + viewer_str = ( + f"\nViewer settings at placement: {', '.join(viewer_parts)}." + if viewer_parts + else "" + ) + + rater_str = f"\n\n[{rater_context}]" if rater_context else "" + content = ( + f"I placed my fiducial for {abbrev} ({full_name})." + f"{coord_str}\n" + f"Distance from reference: {distance_mm:.1f} mm ({verdict})." + f"{viewer_str}{rater_str}\n\n" + f"Please give anatomical feedback and — based on the viewer settings " + f"above — tell me whether I should adjust my zoom or switch to a " + f"higher resolution to place this landmark more precisely." + ) + return [system] + prior + [{"role": "user", "content": content}] + + +def build_question_messages( + history: list[dict], + question: str, + context_chunks: list[str] | None = None, +) -> list[dict]: + """Append a user question to history and return the full messages list. + + The system message is rebuilt with freshly retrieved context chunks so + free-form questions benefit from the most relevant reference material. + """ + system = {"role": "system", "content": build_system_prompt(context_chunks)} + prior = [m for m in history if m.get("role") != "system"] + return [system] + prior + [{"role": "user", "content": question}] + + +# ── Streaming ───────────────────────────────────────────────────────────────── + + +def stream_chat( + messages: list[dict], + llm_config: dict | None = None, +) -> Generator[str, None, None]: + """Yield text chunks from the LLM for the given messages. + + *llm_config* is an optional per-request override + (``{api_key, base_url, model}``) supplied by the visitor's browser; when + present it takes priority over the server's shared default credentials. + """ + # Ensure a system prompt is always first + if not messages or messages[0].get("role") != "system": + messages = [ + {"role": "system", "content": _SYSTEM_PROMPT_BASE} + ] + messages + + client, model = _resolve_llm(llm_config) + stream = client.chat.completions.create( + model=model, + messages=messages, + stream=True, + max_tokens=512, + temperature=0.65, + ) + for chunk in stream: + delta = chunk.choices[0].delta + if delta.content: + yield delta.content diff --git a/afidsvalidator/rag.py b/afidsvalidator/rag.py new file mode 100644 index 00000000..6fb3e8c5 --- /dev/null +++ b/afidsvalidator/rag.py @@ -0,0 +1,245 @@ +"""RAG (Retrieval-Augmented Generation) for the AFIDs guided learning mode. + +Knowledge chunks (landmark definitions, protocol passages, etc.) are stored in +PostgreSQL alongside their embeddings. At query time the most semantically +relevant chunks are retrieved and injected into the LLM context window, +replacing the large static landmark block with targeted, query-specific context. + +Quick start +----------- +1. Apply migrations to create the knowledge_chunks table: + flask db upgrade + +2. Ingest the 32 AFIDs landmark definitions (takes ~30 s with OpenAI embeddings): + flask ingest-knowledge + +3. (Optional) Ingest additional text documents such as the AFIDs protocol paper: + flask ingest-knowledge --file afids_paper.txt --source afids_paper + +Embedding model configuration +------------------------------ +Uses the same LLM_API_KEY / LLM_BASE_URL environment variables as llm.py. + + EMBED_MODEL — Embedding model name. + Defaults to text-embedding-3-small when LLM_API_KEY is set, + or nomic-embed-text when running against local Ollama. + Examples: text-embedding-3-small, nomic-embed-text, + mxbai-embed-large, text-embedding-ada-002 + +Embeddings are stored as JSON-encoded float lists in a standard Text column, so +no Postgres extensions are required. Cosine similarity is computed in NumPy. +For production deployments with larger knowledge bases the column can be +migrated to pgvector's native vector type and an HNSW index added — the retrieval +interface in this module stays unchanged. +""" + +from __future__ import annotations + +import json +import os + +import numpy as np +from sqlalchemy import Column, Integer, String, Text + +from afidsvalidator.landmark_info import LANDMARK_INFO +from afidsvalidator.model import db + +# ── SQLAlchemy model ────────────────────────────────────────────────────────── + + +class KnowledgeChunk(db.Model): + """One chunk of domain knowledge and its embedding vector.""" + + __tablename__ = "knowledge_chunks" + + id = Column(Integer, primary_key=True) + # Where the text came from: "landmark_info", "afids_paper", … + source = Column(String(256), nullable=False) + # AFIDs abbreviation this chunk is primarily about ("AC", "PC", …), or + # None for general / cross-landmark text. + landmark_tag = Column(String(32), nullable=True) + chunk_text = Column(Text, nullable=False) + # JSON-encoded list[float] — the embedding produced by embed_text() + embedding_json = Column(Text, nullable=True) + + +# ── Embedding helpers ───────────────────────────────────────────────────────── + +_OLLAMA_DEFAULT_URL = "http://localhost:11434/v1" +_OPENAI_DEFAULT_URL = "https://api.openai.com/v1" + + +def _embed_base_url() -> str: + if os.environ.get("LLM_API_KEY"): + return os.environ.get("LLM_BASE_URL", _OPENAI_DEFAULT_URL) + return os.environ.get("LLM_BASE_URL", _OLLAMA_DEFAULT_URL) + + +def _embed_model() -> str: + if "EMBED_MODEL" in os.environ: + return os.environ["EMBED_MODEL"] + return ( + "text-embedding-3-small" + if os.environ.get("LLM_API_KEY") + else "nomic-embed-text" + ) + + +def embed_text(text: str) -> list[float]: + """Return a raw embedding vector for *text* from the configured model.""" + try: + from openai import OpenAI + except ImportError as exc: + raise RuntimeError( + "The 'openai' package is required. Install it with: poetry add openai" + ) from exc + + api_key = os.environ.get("LLM_API_KEY", "ollama") + client = OpenAI(api_key=api_key, base_url=_embed_base_url()) + resp = client.embeddings.create(model=_embed_model(), input=text) + return resp.data[0].embedding + + +# ── Similarity ──────────────────────────────────────────────────────────────── + + +def _cosine(a: list[float], b: list[float]) -> float: + va = np.array(a, dtype=np.float32) + vb = np.array(b, dtype=np.float32) + denom = float(np.linalg.norm(va) * np.linalg.norm(vb)) + return float(np.dot(va, vb) / denom) if denom > 0 else 0.0 + + +# ── Retrieval ───────────────────────────────────────────────────────────────── + + +def retrieve( + query: str, + landmark_hint: str | None = None, + top_k: int = 4, +) -> list[str]: + """Return the *top_k* most relevant chunk texts for *query*. + + Falls back to the hard-coded landmark dictionary when the knowledge_chunks + table is empty or an error occurs, so the tutoring mode degrades gracefully + before ``flask ingest-knowledge`` has been run. + + Parameters + ---------- + query: + Free-text query — landmark name, anatomy question, error description, … + landmark_hint: + AFIDs abbreviation of the landmark the user is currently working on. + Chunks tagged with this landmark receive a +0.25 cosine-similarity boost + so they rank higher than equally-relevant general passages. + top_k: + Maximum number of chunks to return (default 4). + """ + try: + return _db_retrieve(query, landmark_hint, top_k) + except Exception: # noqa: BLE001 — graceful degradation + return _fallback_retrieve(landmark_hint) + + +def _db_retrieve( + query: str, + landmark_hint: str | None, + top_k: int, +) -> list[str]: + rows = ( + db.session.query(KnowledgeChunk) + .filter(KnowledgeChunk.embedding_json.isnot(None)) + .all() + ) + if not rows: + return _fallback_retrieve(landmark_hint) + + q_vec = embed_text(query) + scored: list[tuple[float, str]] = [] + for row in rows: + vec = json.loads(row.embedding_json) + sim = _cosine(q_vec, vec) + if row.landmark_tag == landmark_hint: + sim += 0.25 + scored.append((sim, row.chunk_text)) + + scored.sort(key=lambda x: x[0], reverse=True) + return [text for _, text in scored[:top_k]] + + +def _fallback_retrieve(landmark_hint: str | None) -> list[str]: + """Return hard-coded landmark info when the DB is unavailable or empty.""" + if landmark_hint and landmark_hint in LANDMARK_INFO: + info = LANDMARK_INFO[landmark_hint] + return [ + f"{landmark_hint} — {info['full_name']}\n" + f"Description: {info['description']}\n" + f"Key features on MRI: {info['key_features']}\n" + f"Common mistakes: {info['common_mistakes']}" + ] + return [] + + +# ── Ingestion ───────────────────────────────────────────────────────────────── + + +def ingest_landmarks() -> int: + """Embed and store all 32 AFIDs landmark definitions. + + Existing ``landmark_info`` rows are replaced on each run (idempotent). + Returns the number of chunks written. + """ + count = 0 + for abbrev, info in LANDMARK_INFO.items(): + text = ( + f"{abbrev} — {info['full_name']}\n" + f"Description: {info['description']}\n" + f"Key features on MRI: {info['key_features']}\n" + f"Common mistakes: {info['common_mistakes']}" + ) + db.session.query(KnowledgeChunk).filter_by( + source="landmark_info", landmark_tag=abbrev + ).delete() + db.session.add( + KnowledgeChunk( + source="landmark_info", + landmark_tag=abbrev, + chunk_text=text, + embedding_json=json.dumps(embed_text(text)), + ) + ) + count += 1 + + db.session.commit() + return count + + +def ingest_text_file(path: str, source: str) -> int: + """Chunk and ingest a plain-text document (e.g. the AFIDs protocol paper). + + Splits on blank lines (paragraph boundaries), filters fragments shorter + than 80 characters, and stores each chunk with its embedding. + Existing rows for *source* are replaced on each run. + Returns the number of chunks written. + """ + with open(path, encoding="utf-8") as fh: + raw = fh.read() + + paragraphs = [p.strip() for p in raw.split("\n\n") if len(p.strip()) >= 80] + + db.session.query(KnowledgeChunk).filter_by(source=source).delete() + + count = 0 + for para in paragraphs: + db.session.add( + KnowledgeChunk( + source=source, + landmark_tag=None, + chunk_text=para, + embedding_json=json.dumps(embed_text(para)), + ) + ) + count += 1 + + db.session.commit() + return count diff --git a/afidsvalidator/rater_reliability.json b/afidsvalidator/rater_reliability.json new file mode 100644 index 00000000..1859af56 --- /dev/null +++ b/afidsvalidator/rater_reliability.json @@ -0,0 +1,338 @@ +{ + "meta": { + "source": "afids-data release (Taha et al., 2023, Sci Data 10:449)", + "reference": "per-subject consensus groundtruth", + "metric": "Euclidean distance rater -> groundtruth (mm), convention-invariant", + "n_rater_files": 492, + "n_images": 132 + }, + "global": { + "median": 0.867, + "mean": 1.544, + "sd": 2.906, + "within_1mm": 0.567, + "within_2mm": 0.824 + }, + "landmarks": { + "AC": { + "n": 492, + "mean": 0.873, + "sd": 2.679, + "p10": 0.152, + "p25": 0.231, + "p50": 0.381, + "p75": 0.647, + "p90": 0.999 + }, + "PC": { + "n": 492, + "mean": 0.811, + "sd": 2.691, + "p10": 0.158, + "p25": 0.249, + "p50": 0.374, + "p75": 0.589, + "p90": 0.886 + }, + "ICS": { + "n": 492, + "mean": 1.175, + "sd": 2.748, + "p10": 0.263, + "p25": 0.456, + "p50": 0.708, + "p75": 1.091, + "p90": 1.666 + }, + "PMJ": { + "n": 492, + "mean": 1.264, + "sd": 2.666, + "p10": 0.276, + "p25": 0.54, + "p50": 0.804, + "p75": 1.257, + "p90": 1.882 + }, + "SIPF": { + "n": 492, + "mean": 1.482, + "sd": 2.651, + "p10": 0.358, + "p25": 0.583, + "p50": 0.985, + "p75": 1.665, + "p90": 2.342 + }, + "RSLMS": { + "n": 492, + "mean": 1.488, + "sd": 2.741, + "p10": 0.34, + "p25": 0.568, + "p50": 0.961, + "p75": 1.619, + "p90": 2.444 + }, + "LSLMS": { + "n": 492, + "mean": 1.487, + "sd": 2.725, + "p10": 0.341, + "p25": 0.584, + "p50": 0.963, + "p75": 1.648, + "p90": 2.485 + }, + "RILMS": { + "n": 492, + "mean": 1.665, + "sd": 2.709, + "p10": 0.368, + "p25": 0.644, + "p50": 1.115, + "p75": 1.92, + "p90": 2.786 + }, + "LILMS": { + "n": 492, + "mean": 1.636, + "sd": 2.796, + "p10": 0.355, + "p25": 0.561, + "p50": 1.088, + "p75": 1.961, + "p90": 2.845 + }, + "CUL": { + "n": 492, + "mean": 1.516, + "sd": 2.929, + "p10": 0.317, + "p25": 0.509, + "p50": 0.875, + "p75": 1.734, + "p90": 2.797 + }, + "IMS": { + "n": 492, + "mean": 0.955, + "sd": 2.648, + "p10": 0.232, + "p25": 0.349, + "p50": 0.511, + "p75": 0.803, + "p90": 1.149 + }, + "RMB": { + "n": 492, + "mean": 0.965, + "sd": 2.68, + "p10": 0.21, + "p25": 0.323, + "p50": 0.523, + "p75": 0.793, + "p90": 1.222 + }, + "LMB": { + "n": 492, + "mean": 0.963, + "sd": 2.691, + "p10": 0.222, + "p25": 0.332, + "p50": 0.524, + "p75": 0.776, + "p90": 1.188 + }, + "PG": { + "n": 492, + "mean": 1.412, + "sd": 2.728, + "p10": 0.294, + "p25": 0.468, + "p50": 0.918, + "p75": 1.574, + "p90": 2.354 + }, + "RLVAC": { + "n": 492, + "mean": 1.842, + "sd": 3.064, + "p10": 0.374, + "p25": 0.581, + "p50": 0.943, + "p75": 1.774, + "p90": 3.425 + }, + "LLVAC": { + "n": 492, + "mean": 1.88, + "sd": 3.094, + "p10": 0.404, + "p25": 0.604, + "p50": 0.986, + "p75": 1.923, + "p90": 3.456 + }, + "RLVPC": { + "n": 492, + "mean": 1.751, + "sd": 3.018, + "p10": 0.387, + "p25": 0.642, + "p50": 1.03, + "p75": 1.739, + "p90": 3.081 + }, + "LLVPC": { + "n": 492, + "mean": 1.611, + "sd": 2.833, + "p10": 0.402, + "p25": 0.592, + "p50": 1.006, + "p75": 1.637, + "p90": 2.895 + }, + "GENU": { + "n": 492, + "mean": 1.334, + "sd": 2.721, + "p10": 0.316, + "p25": 0.502, + "p50": 0.767, + "p75": 1.237, + "p90": 2.015 + }, + "SPLE": { + "n": 491, + "mean": 1.245, + "sd": 2.773, + "p10": 0.292, + "p25": 0.456, + "p50": 0.725, + "p75": 1.131, + "p90": 1.945 + }, + "RALTH": { + "n": 492, + "mean": 1.677, + "sd": 2.736, + "p10": 0.415, + "p25": 0.639, + "p50": 1.006, + "p75": 1.843, + "p90": 2.828 + }, + "LALTH": { + "n": 492, + "mean": 1.759, + "sd": 2.701, + "p10": 0.451, + "p25": 0.681, + "p50": 1.157, + "p75": 1.856, + "p90": 3.099 + }, + "RSAMTH": { + "n": 492, + "mean": 1.677, + "sd": 3.005, + "p10": 0.321, + "p25": 0.551, + "p50": 0.958, + "p75": 1.723, + "p90": 2.59 + }, + "LSAMTH": { + "n": 492, + "mean": 1.575, + "sd": 2.776, + "p10": 0.296, + "p25": 0.511, + "p50": 0.898, + "p75": 1.592, + "p90": 2.939 + }, + "RIAMTH": { + "n": 492, + "mean": 2.146, + "sd": 2.887, + "p10": 0.472, + "p25": 0.799, + "p50": 1.461, + "p75": 2.463, + "p90": 3.989 + }, + "LIAMTH": { + "n": 492, + "mean": 2.165, + "sd": 2.977, + "p10": 0.425, + "p25": 0.707, + "p50": 1.402, + "p75": 2.586, + "p90": 4.356 + }, + "RIGO": { + "n": 492, + "mean": 1.856, + "sd": 2.901, + "p10": 0.464, + "p25": 0.707, + "p50": 1.24, + "p75": 2.228, + "p90": 3.205 + }, + "LIGO": { + "n": 492, + "mean": 1.938, + "sd": 2.795, + "p10": 0.509, + "p25": 0.841, + "p50": 1.368, + "p75": 2.295, + "p90": 3.348 + }, + "RVOH": { + "n": 492, + "mean": 1.754, + "sd": 3.764, + "p10": 0.313, + "p25": 0.511, + "p50": 0.843, + "p75": 1.396, + "p90": 3.032 + }, + "LVOH": { + "n": 492, + "mean": 1.994, + "sd": 4.01, + "p10": 0.352, + "p25": 0.539, + "p50": 0.925, + "p75": 1.678, + "p90": 3.501 + }, + "ROSF": { + "n": 492, + "mean": 1.777, + "sd": 2.91, + "p10": 0.383, + "p25": 0.621, + "p50": 0.991, + "p75": 1.823, + "p90": 3.225 + }, + "LOSF": { + "n": 491, + "mean": 1.75, + "sd": 2.764, + "p10": 0.375, + "p25": 0.596, + "p50": 1.09, + "p75": 1.897, + "p90": 3.166 + } + } +} \ No newline at end of file diff --git a/afidsvalidator/reliability.py b/afidsvalidator/reliability.py new file mode 100644 index 00000000..a315e0ff --- /dev/null +++ b/afidsvalidator/reliability.py @@ -0,0 +1,176 @@ +"""Per-landmark rater-reliability prior for the guided-learning tutor. + +Loads ``rater_reliability.json`` — the distribution of trained-rater anatomical +fiducial localization error (AFLE) for each of the 32 AFIDs, computed from the +afids-data multi-rater release by ``compute_reliability.py`` — and exposes it in +three forms the tutor and validator use: + +* :func:`percentile` — how precise a learner's placement is relative to the + trained-rater distribution for that landmark, as a "you vs. the experts" + metric where **higher is better** (e.g. 90 = more precise than 90% of trained + raters); +* :func:`band` — a coarse, human-readable classification of that percentile; +* :func:`difficulty` and :func:`expected` — how hard the landmark is for humans, + used to calibrate feedback ("1 mm on the AC is poor; 1 mm on the indusium + griseum origin is expert-level"). + +Everything degrades gracefully to ``None`` when a landmark is absent from the +prior, so callers can fall back to the global fixed thresholds. +""" + +from __future__ import annotations + +import json +from functools import lru_cache +from pathlib import Path + +_JSON = Path(__file__).parent / "rater_reliability.json" + +# Percentile anchors stored per landmark, plus the implicit (0 mm → 0th pct). +_ANCHORS = [ + (0.0, 0.0), + ("p10", 10), + ("p25", 25), + ("p50", 50), + ("p75", 75), + ("p90", 90), +] + + +@lru_cache(maxsize=1) +def _data() -> dict: + try: + return json.loads(_JSON.read_text()) + except (OSError, ValueError): + return {"global": {}, "landmarks": {}} + + +def stats(abbrev: str) -> dict | None: + """Return the raw reliability record for *abbrev*, or None.""" + return _data().get("landmarks", {}).get(abbrev) + + +def expected(abbrev: str) -> tuple[float, float] | None: + """Return (median, p75) trained-rater AFLE in mm for *abbrev*.""" + s = stats(abbrev) + return (s["p50"], s["p75"]) if s else None + + +def difficulty(abbrev: str) -> str | None: + """Coarse human-difficulty tier from the trained-rater median AFLE.""" + s = stats(abbrev) + if not s: + return None + med = s["p50"] + if med < 0.5: + return "high-reliability" + if med < 0.9: + return "moderate" + return "difficult" + + +def _error_percentile(abbrev: str, distance_mm: float) -> int | None: + """Percentile of *distance_mm* within the trained-rater AFLE distribution. + + 0 = better (more precise) than essentially all trained raters; 90+ = at or + beyond the least precise trained-rater placements for this landmark. Linear + interpolation between stored percentile anchors; extrapolated above p90. + + This is the raw error rank (lower = more precise). Callers should use the + public :func:`percentile`, which inverts it into an intuitive precision + percentile where higher = better. + """ + s = stats(abbrev) + if not s: + return None + pts = [(v if isinstance(v, float) else s[v], p) for v, p in _ANCHORS] + for (x0, p0), (x1, p1) in zip(pts, pts[1:]): + if distance_mm <= x1: + if x1 == x0: + return int(p1) + frac = (distance_mm - x0) / (x1 - x0) + return int(round(p0 + frac * (p1 - p0))) + # above p90 → extrapolate toward 99 + p90 = s["p90"] + over = (distance_mm - p90) / (p90 if p90 > 0 else 1.0) + return int(min(99, round(90 + 9 * over))) + + +def percentile(abbrev: str, distance_mm: float) -> int | None: + """Precision percentile of *distance_mm* vs. the trained-rater distribution. + + **Higher is better**: the percentage of trained-rater placements this + learner is at least as precise as. 90 = more precise than 90% of trained + raters (a tight placement); 10 = more precise than only 10% (a loose one). + Inverts :func:`_error_percentile` so the number matches the everyday reading + of "90th percentile = great." Clamped to 1..99. + """ + ep = _error_percentile(abbrev, distance_mm) + if ep is None: + return None + return max(1, min(99, 100 - ep)) + + +_BAND_LABELS = { + "expert": "better than the typical trained rater", + "proficient": "within trained-rater range", + "edge": "at the edge of trained-rater range", + "outside": "outside trained-rater range", +} + + +def band(abbrev: str, distance_mm: float) -> dict | None: + """Classify *distance_mm* against the trained-rater distribution. + + Returns ``{key, label, percentile, median, p75, p90, difficulty}`` or None. + """ + s = stats(abbrev) + if not s: + return None + if distance_mm <= s["p50"]: + key = "expert" + elif distance_mm <= s["p75"]: + key = "proficient" + elif distance_mm <= s["p90"]: + key = "edge" + else: + key = "outside" + return { + "key": key, + "label": _BAND_LABELS[key], + "percentile": percentile(abbrev, distance_mm), + "median": s["p50"], + "p75": s["p75"], + "p90": s["p90"], + "difficulty": difficulty(abbrev), + } + + +def feedback_line(abbrev: str, distance_mm: float) -> str | None: + """One sentence of rater-calibrated context for the tutor's feedback prompt.""" + b = band(abbrev, distance_mm) + if not b: + return None + return ( + f"Rater-calibration for {abbrev}: across trained raters in the AFIDs " + f"multi-rater dataset, median placement error for this landmark is " + f"{b['median']:.2f} mm (75th pct {b['p75']:.2f} mm, 90th pct " + f"{b['p90']:.2f} mm); it is a {b['difficulty']} landmark. The learner's " + f"{distance_mm:.1f} mm is {b['label']} — more precise than about " + f"{b['percentile']}% of trained raters. " + f"Use this to calibrate praise/correction to how hard this landmark " + f"actually is — do not quote these raw numbers unless helpful." + ) + + +def intro_line(abbrev: str) -> str | None: + """Difficulty context for the tutor's landmark introduction prompt.""" + s = stats(abbrev) + if not s: + return None + d = difficulty(abbrev) + return ( + f"Difficulty context for {abbrev}: trained raters place this landmark to " + f"a median of {s['p50']:.2f} mm (a {d} landmark in the AFIDs multi-rater " + f"dataset). If it is difficult, briefly set that expectation." + ) diff --git a/afidsvalidator/static/lib/afidsvalidator-react/dist/index.js b/afidsvalidator/static/lib/afidsvalidator-react/dist/index.js index 78481b9a..6dc53a45 100644 --- a/afidsvalidator/static/lib/afidsvalidator-react/dist/index.js +++ b/afidsvalidator/static/lib/afidsvalidator-react/dist/index.js @@ -1,2 +1,2 @@ -(()=>{let e,n;function t(e,n,t,r){Object.defineProperty(e,n,{get:t,set:r,enumerable:!0,configurable:!0})}function r(e){return e&&e.__esModule?e.default:e}"use strict";var a,i,o,l,u,s,c,f,d="undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:"undefined"!=typeof window?window:"undefined"!=typeof global?global:{},p={},m={},h=d.parcelRequire7bd7;null==h&&((h=function(e){if(e in p)return p[e].exports;if(e in m){var n=m[e];delete m[e];var t={id:e,exports:{}};return p[e]=t,n.call(t.exports,t,t.exports),t.exports}var r=Error("Cannot find module '"+e+"'");throw r.code="MODULE_NOT_FOUND",r}).register=function(e,n){m[e]=n},d.parcelRequire7bd7=h),h.register("kI8SP",function(e,n){"use strict";t(e.exports,"register",()=>r,e=>r=e),t(e.exports,"resolve",()=>a,e=>a=e);var r,a,i={};r=function(e){for(var n=Object.keys(e),t=0;tr,e=>r=e),t(e.exports,"jsx",()=>a,e=>a=e),t(e.exports,"jsxs",()=>i,e=>i=e);var r,a,i,o=h("8NFma"),l=Symbol.for("react.element"),u=Symbol.for("react.fragment"),s=Object.prototype.hasOwnProperty,c=o.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner,f={key:!0,ref:!0,__self:!0,__source:!0};function d(e,n,t){var r,a={},i=null,o=null;for(r in void 0!==t&&(i=""+t),void 0!==n.key&&(i=""+n.key),void 0!==n.ref&&(o=n.ref),n)s.call(n,r)&&!f.hasOwnProperty(r)&&(a[r]=n[r]);if(e&&e.defaultProps)for(r in n=e.defaultProps)void 0===a[r]&&(a[r]=n[r]);return{$$typeof:l,type:e,key:i,ref:o,props:a,_owner:c.current}}r=u,a=d,i=d}),h.register("8NFma",function(e,n){"use strict";e.exports=h("j1rj6")}),h.register("j1rj6",function(e,n){"use strict";t(e.exports,"Children",()=>r,e=>r=e),t(e.exports,"Component",()=>a,e=>a=e),t(e.exports,"Fragment",()=>i,e=>i=e),t(e.exports,"Profiler",()=>o,e=>o=e),t(e.exports,"PureComponent",()=>l,e=>l=e),t(e.exports,"StrictMode",()=>u,e=>u=e),t(e.exports,"Suspense",()=>s,e=>s=e),t(e.exports,"__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED",()=>c,e=>c=e),t(e.exports,"cloneElement",()=>f,e=>f=e),t(e.exports,"createContext",()=>d,e=>d=e),t(e.exports,"createElement",()=>p,e=>p=e),t(e.exports,"createFactory",()=>m,e=>m=e),t(e.exports,"createRef",()=>h,e=>h=e),t(e.exports,"forwardRef",()=>v,e=>v=e),t(e.exports,"isValidElement",()=>g,e=>g=e),t(e.exports,"lazy",()=>b,e=>b=e),t(e.exports,"memo",()=>y,e=>y=e),t(e.exports,"startTransition",()=>k,e=>k=e),t(e.exports,"unstable_act",()=>w,e=>w=e),t(e.exports,"useCallback",()=>x,e=>x=e),t(e.exports,"useContext",()=>S,e=>S=e),t(e.exports,"useDebugValue",()=>E,e=>E=e),t(e.exports,"useDeferredValue",()=>N,e=>N=e),t(e.exports,"useEffect",()=>C,e=>C=e),t(e.exports,"useId",()=>O,e=>O=e),t(e.exports,"useImperativeHandle",()=>P,e=>P=e),t(e.exports,"useInsertionEffect",()=>_,e=>_=e),t(e.exports,"useLayoutEffect",()=>T,e=>T=e),t(e.exports,"useMemo",()=>z,e=>z=e),t(e.exports,"useReducer",()=>R,e=>R=e),t(e.exports,"useRef",()=>L,e=>L=e),t(e.exports,"useState",()=>j,e=>j=e),t(e.exports,"useSyncExternalStore",()=>F,e=>F=e),t(e.exports,"useTransition",()=>M,e=>M=e),t(e.exports,"version",()=>A,e=>A=e);var r,a,i,o,l,u,s,c,f,d,p,m,h,v,g,b,y,k,w,x,S,E,N,C,O,P,_,T,z,R,L,j,F,M,A,I=Symbol.for("react.element"),D=Symbol.for("react.portal"),U=Symbol.for("react.fragment"),$=Symbol.for("react.strict_mode"),B=Symbol.for("react.profiler"),H=Symbol.for("react.provider"),V=Symbol.for("react.context"),W=Symbol.for("react.forward_ref"),Y=Symbol.for("react.suspense"),Q=Symbol.for("react.memo"),K=Symbol.for("react.lazy"),q=Symbol.iterator,X={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},G=Object.assign,Z={};function J(e,n,t){this.props=e,this.context=n,this.refs=Z,this.updater=t||X}function ee(){}function en(e,n,t){this.props=e,this.context=n,this.refs=Z,this.updater=t||X}J.prototype.isReactComponent={},J.prototype.setState=function(e,n){if("object"!=typeof e&&"function"!=typeof e&&null!=e)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,e,n,"setState")},J.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,"forceUpdate")},ee.prototype=J.prototype;var et=en.prototype=new ee;et.constructor=en,G(et,J.prototype),et.isPureReactComponent=!0;var er=Array.isArray,ea=Object.prototype.hasOwnProperty,ei={current:null},eo={key:!0,ref:!0,__self:!0,__source:!0};function el(e,n,t){var r,a={},i=null,o=null;if(null!=n)for(r in void 0!==n.ref&&(o=n.ref),void 0!==n.key&&(i=""+n.key),n)ea.call(n,r)&&!eo.hasOwnProperty(r)&&(a[r]=n[r]);var l=arguments.length-2;if(1===l)a.children=t;else if(1H,e=>H=e),t(e.exports,"createPortal",()=>V,e=>V=e),t(e.exports,"createRoot",()=>W,e=>W=e),t(e.exports,"findDOMNode",()=>Y,e=>Y=e),t(e.exports,"flushSync",()=>Q,e=>Q=e),t(e.exports,"hydrate",()=>K,e=>K=e),t(e.exports,"hydrateRoot",()=>q,e=>q=e),t(e.exports,"render",()=>X,e=>X=e),t(e.exports,"unmountComponentAtNode",()=>G,e=>G=e),t(e.exports,"unstable_batchedUpdates",()=>Z,e=>Z=e),t(e.exports,"unstable_renderSubtreeIntoContainer",()=>J,e=>J=e),t(e.exports,"version",()=>ee,e=>ee=e);var r,a,i,o,l,u,s=h("8NFma"),c=h("clk2u");function f(e){for(var n="https://reactjs.org/docs/error-decoder.html?invariant="+e,t=1;t