Work Done
Continued PR #161 (virdx/mono, branch feat/vxannotate-vxdata-sync): the job
apps/vxdata-jobs/src/vxdata/jobs/f_20260702_vxannotate_sync that syncs vxAnnotate
submissions into vxData.
- Handlers architecture. Split into a package:
base.py= theSubmissionHandlercontract (parse(session) -> Data | None,map(data, ctx) -> [PlannedResource]);context.py=HandlerContext+ its I/O builders (build_series_volumes,build_mask_niftis); handlerspirads_annotation,pirads_scoring,piqual_scoring,pirads_comments,pirads_artifacts. Plan/execute split: planning is pure, all writes in one_push_resources. Every file kept < 200 LOC (a hard user constraint). - Empirically nailed the ID shapes (via throwaway probe scripts run with the user’s
creds): an annotation’s
series_id= the vxaSeries.idUUID;Series.series_identifier="{volume_uid}_{description}"; vxDataVolume.identifier="volume/{volume_uid}";VxAnnotateCaseRecord.included_resources= the case’s Volume identifiers. PIRADS score keys are camelCase (T2Score,DWIScore,laterality,zones,…) and per-lesion (label_name= lesion mask id); PIQUAL is case-level snake_case;zonesis a JSON-encoded string array. - Correctness fixes: artifact comments target the mask’s series Volume; annotator
comments target the case’s ImagingStudy (
parent_identifier);reference_mricomes frommask.series_id(not a T2/transversal heuristic);PIRADSLesionAssessment.derived_fromlinks to the lesion’s VoxelMap. Turned silent fallbacks into loud raises (missing annotator email, missinglabel_name, unmapped submission status, non-strzones). - The mask-RLE saga (main thread).
session.mask_niftiscame back empty + wrong geometry (112³ placeholder vs native 448³ RLE). Root cause found in vxAnnotate frontendgenerateMaskNiftis(radiology/page.tsx): it usedseries.find(s => s.image_type!="SEG")— the first non-SEG series — as the reference for all masks, ignoringmask.seriesId, sodecodeRLE(rle, wrongVoxelCount)truncated the native-res drawing to a background sub-region. The on-screen viewer was always correct because it decodes each mask’s RLE against its own series volume. Opened draft PRvirdx/vxannotate.com#51(per-series reference, cached). - Adopted vxa-sdk 0.1.5 (
virdx/mono#223), which addsclient.exports.mask_niftis(reuse-stored-if-matches-RLE else rebuild-from-RLE +series.file) and publicclient.series.file. Reverted the job-local workaround (rle_helper.pyPackBits decode + placeholder-splice) and now consume the SDK export verbatim (binary uint8 →labels={"1": name}). - A
reviewersubagent confirmed the diagnosis and recommended the single-source-of-truth design (SDK owns RLE→NIfTI; consumer just consumes). - Clarified PIRADS global vs lesion: the vxAnnotate
piradsextension is per-lesion only; vxData’s globalPIRADSAssessment(index_lesion_id,score,free_text) has no annotation source and would need a v2.1 derivation — left unproduced by design. - Git hygiene:
run.sh/run_debug.sh(contained secrets) were swept into a commit by agit add <dir>; removed from history via--amend+--force-with-lease, added a job-dir.gitignore. Secrets need rotating (history rewrite doesn’t un-expose a pushed commit).
Lessons Learned: Pitfalls
- The “never modify vxa-sdk, use the registry pin” rule (from a prior session) was right for
PR scope but sent me down a dead end. The real fix lived in the SDK (RLE decode) and the
frontend (per-series reference); I burned effort on a job-local RLE reconstruction
(
rle_helper, splicing decoded voxels into the empty placeholder’s header) that was thrown away the moment SDK 0.1.5 shipped. When the correct layer is the SDK/frontend and a fix is plausibly imminent, escalate/coordinate before building a workaround. - CBOR-baked
maskNiftislook authoritative but are best-effort frontend renders. They were empty and at the wrong resolution. The source of truth isMaskAnnotation.rle(NiiVue PackBits, native-res, binary per mask; class is inlabel_value). Don’t trust render artifacts embedded in an export. - The 200-LOC-per-file constraint caused repeated docstring/comment churn in
sync.py(hovering at 197–208). Real but low-value thrash; worth knowing it’s a firm rule going in. git add <dir>silently stages untracked files; a post-commitgit resetdoes not undo a commit. Secrets leaked this way.
Lessons Learned: Improvements
- Add a wiki/SOP page “vxAnnotate ↔ vxData sync” capturing what took the most time to
rediscover empirically:
- ID mapping: annotation
series_id= vxaSeries.id(UUID) → retrieve series →series_identifier="{volume_uid}_{desc}"→ vxDataVolume.identifier="volume/{volume_uid}";included_resources= Volume identifiers. - CBOR truth model:
masks[].rle(PackBits, native-res, binary) is authoritative;mask_niftisis best-effort. Usevxa-sdk >= 0.1.5client.exports.mask_niftisfor correct masks — do not hand-roll RLE decoding. - Score module shapes: PIRADS = per-lesion camelCase (
label_name= lesion id); PIQUAL = case-level snake_case.zonesis a JSON string array. PIRADS is lesion-only in vxAnnotate; no global assessment source. - essen01 MRI project id
c7cc1c0b-289e-4525-a2a1-6e9638f5b55a; its series are NIfTI-uploaded (no DICOMSeries), so the DICOM-bridge integration path (f_20260305_vxannotate_integration) does not apply.
- ID mapping: annotation
- Knowing SDK 0.1.5’s capabilities up front would have skipped the entire RLE-workaround detour. The wiki should track “which SDK version owns which capability.”
- Reinforce a hygiene note: never
git add <dir>when untracked creds/PHI (run.sh,vxa.json) live in it; keep such files gitignored per job.