Skip to content

2026-07-09 vxannotate vxdata sync mask rle

Built the vxAnnotate→vxData sync job; diagnosed + fixed the empty-mask RLE bug across job, SDK, and frontend.

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 = the SubmissionHandler contract (parse(session) -> Data | None, map(data, ctx) -> [PlannedResource]); context.py = HandlerContext + its I/O builders (build_series_volumes, build_mask_niftis); handlers pirads_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 vxa Series.id UUID; Series.series_identifier = "{volume_uid}_{description}"; vxData Volume.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; zones is 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_mri comes from mask.series_id (not a T2/transversal heuristic); PIRADSLesionAssessment.derived_from links to the lesion’s VoxelMap. Turned silent fallbacks into loud raises (missing annotator email, missing label_name, unmapped submission status, non-str zones).
  • The mask-RLE saga (main thread). session.mask_niftis came back empty + wrong geometry (112³ placeholder vs native 448³ RLE). Root cause found in vxAnnotate frontend generateMaskNiftis (radiology/page.tsx): it used series.find(s => s.image_type!="SEG") — the first non-SEG series — as the reference for all masks, ignoring mask.seriesId, so decodeRLE(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 PR virdx/vxannotate.com#51 (per-series reference, cached).
  • Adopted vxa-sdk 0.1.5 (virdx/mono#223), which adds client.exports.mask_niftis (reuse-stored-if-matches-RLE else rebuild-from-RLE + series.file) and public client.series.file. Reverted the job-local workaround (rle_helper.py PackBits decode + placeholder-splice) and now consume the SDK export verbatim (binary uint8 → labels={"1": name}).
  • A reviewer subagent 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 pirads extension is per-lesion only; vxData’s global PIRADSAssessment (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 a git 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 maskNiftis look authoritative but are best-effort frontend renders. They were empty and at the wrong resolution. The source of truth is MaskAnnotation.rle (NiiVue PackBits, native-res, binary per mask; class is in label_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-commit git reset does 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 = vxa Series.id (UUID) → retrieve series → series_identifier = "{volume_uid}_{desc}" → vxData Volume.identifier = "volume/{volume_uid}"; included_resources = Volume identifiers.
    • CBOR truth model: masks[].rle (PackBits, native-res, binary) is authoritative; mask_niftis is best-effort. Use vxa-sdk >= 0.1.5 client.exports.mask_niftis for correct masks — do not hand-roll RLE decoding.
    • Score module shapes: PIRADS = per-lesion camelCase (label_name = lesion id); PIQUAL = case-level snake_case. zones is 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.
  • 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.
Navigation

Type to search…

↑↓ navigate↵ selectEsc close