diff --git a/mrd-viz/README.md b/mrd-viz/README.md index 27bc572a..5acb9c0d 100644 --- a/mrd-viz/README.md +++ b/mrd-viz/README.md @@ -2,4 +2,12 @@ Lightweight MRD inspection and preview VS Code extension. -The current focus is direct inspection of existing `.mrd` files rather than reconstruction orchestration. See `docs/TECHNICAL_DESIGN.md` for detailed project context and implementation plan. \ No newline at end of file +The current focus is direct inspection of existing `.mrd` files rather than reconstruction orchestration. See `docs/TECHNICAL_DESIGN.md` for detailed project context and implementation plan. + +## Install from the latest GitHub Release + +Use the stable latest-release page: + + + +Download the platform VSIX attached to that release, then install it with **Extensions → … → Install from VSIX…** or `code --install-extension .vsix`. \ No newline at end of file diff --git a/mrd-viz/docs/TECHNICAL_DESIGN.md b/mrd-viz/docs/TECHNICAL_DESIGN.md index 82320965..1d865259 100644 --- a/mrd-viz/docs/TECHNICAL_DESIGN.md +++ b/mrd-viz/docs/TECHNICAL_DESIGN.md @@ -81,6 +81,17 @@ Out of scope for now: ## Product Surface +### Functionality and rollout table + +| Track | Before | After | Functions / UX created | Rollout priority | +| --- | --- | --- | --- | --- | +| D1: backend setup | Installed users had to reason through local Python setup and backend discovery failures manually. | The extension first uses a bundled backend binary when present; when that fails or is unavailable, **Set Up Backend Automatically…** builds a managed virtual environment and stores the machine-scoped `mrdViz.backendPath`. | Backend candidate planning, detailed probe failures, managed `backend-venv`, automatic setup command, interpreter picker, cleanup of partial venvs, and backend-missing guidance in the webview. | First researcher bundle; removes the biggest onboarding blocker. | +| Release: GitHub Release VSIX | Users needed local build knowledge or development-host setup to try MRD Viz. | Tag pushes build platform-specific VSIX artifacts with staged backend binaries and attach them to a GitHub Release; users start from the stable `/releases/latest` page and pick the platform VSIX. | `mrd_viz_release.yml` build matrix, PyInstaller backend staging, VSIX artifacts, release publishing, icon/license packaging, and release docs. | Same PR/release train as D1 so reviewers approve one deployable handoff. | +| D3: richer metadata grouping | Metadata exists, but users still scan summary panels and raw stream/JSON details for common interpretation patterns. | Metadata is scaffolded into file, header, image, acquisition, and stream groups, with the Groups tab becoming the first metadata view. | Typed metadata group model, grouped webview cards/tables, and a shell regression test, all derived from the existing backend payload without changing the backend contract. | Included with D1/release because it improves first-run researcher comprehension without adding install risk. | +| D2: multi-file / comparison workflow | Users open one `.mrd` file at a time and compare files manually across tabs. | Users should be able to classify and compare related files or batches in one workflow. | Planned multi-file selector, batch classification, side-by-side summary state, and comparison-oriented webview surfaces. | After initial feedback clarifies the common comparison tasks. | +| Marketplace | Users install from GitHub Release VSIX files. | Users install/update from the VS Code Marketplace once publisher, credential, and compliance work are complete. | Guarded Marketplace publish job, publisher checklist, `VSCE_PAT` activation path, optional Open VSX follow-up. | Later; do not block researcher access through GitHub Releases. | +| F10: feedback / follow-up loop | Feedback is ad hoc after manual testing. | Researchers can use the D1/release bundle while follow-up UX work continues in parallel. | Release notes, known-issues tracking, focused follow-up PRs for onboarding failures and newly observed MRD variants. | Starts with the first researcher release. | + ### Why a VS Code plugin `mrd-viz` will be a VS Code plugin because the initial users are technical researchers and operators who already inspect files, notebooks, scripts, and reconstruction outputs inside VS Code. A plugin keeps MRD inspection inside that existing development loop instead of forcing users to switch to a separate command-line-only workflow. diff --git a/mrd-viz/extension/mrd-viz/README.md b/mrd-viz/extension/mrd-viz/README.md index 414819ba..53e604a2 100644 --- a/mrd-viz/extension/mrd-viz/README.md +++ b/mrd-viz/extension/mrd-viz/README.md @@ -28,6 +28,8 @@ This extension contributes the following settings: ## Commands - `MRD Viz: Open File` — open the selected or picked `.mrd` file in MRD Viz. +- `MRD Viz: Set Up Backend` — create or rebuild a managed backend environment when the bundled backend is unavailable. +- `MRD Viz: Select Python Interpreter` — point `mrdViz.backendPath` at an existing backend environment. ## Known Issues diff --git a/mrd-viz/extension/mrd-viz/media/viewer.css b/mrd-viz/extension/mrd-viz/media/viewer.css index 3aa2f42a..8fb4da1b 100644 --- a/mrd-viz/extension/mrd-viz/media/viewer.css +++ b/mrd-viz/extension/mrd-viz/media/viewer.css @@ -410,6 +410,15 @@ li + li { margin-top: 4px; } .metadata-section { display: grid; gap: 8px; } .metadata-section h3 { margin: 0; font-size: 12px; font-weight: 650; } .metadata-note { color: var(--mrd-muted); font-size: 12px; line-height: 1.4; } +.metadata-group { + display: grid; + gap: 8px; + padding: 9px; + border: 1px solid var(--mrd-line); + border-radius: 6px; + background: var(--mrd-panel-strong); +} +.metadata-group h3 { margin: 0; font-size: 12px; font-weight: 650; } .metadata-table { width: 100%; border-collapse: collapse; font-size: 12px; } .metadata-table th, .metadata-table td { padding: 4px 6px; border-bottom: 1px solid var(--mrd-line); text-align: left; vertical-align: top; } diff --git a/mrd-viz/extension/mrd-viz/media/viewer/metadata.ts b/mrd-viz/extension/mrd-viz/media/viewer/metadata.ts index 1160643d..dc6d8857 100644 --- a/mrd-viz/extension/mrd-viz/media/viewer/metadata.ts +++ b/mrd-viz/extension/mrd-viz/media/viewer/metadata.ts @@ -3,6 +3,7 @@ import { payload } from './state'; import { stat, notice, section, addField, valueOrUnknown, formatList } from './dom'; +import { buildMetadataGroups } from './metadataGroups'; function metadata() { return payload.metadata || {}; @@ -129,6 +130,7 @@ export function renderShell() { } function renderMetadata() { + renderGroupedMetadata(); renderSummaryMetadata(); renderOrganizationMetadata(); renderStreamMetadata(); @@ -149,6 +151,47 @@ function activateTab(name) { }); } +function renderGroupedMetadata() { + const root = document.getElementById('metadata-groups'); + root.textContent = ''; + const groups = buildMetadataGroups(payload); + if (!groups.length) { + appendEmpty(root, 'No grouped metadata is available for this file.'); + return; + } + + groups.forEach(function (group) { + const article = document.createElement('article'); + article.className = 'metadata-group'; + + const heading = document.createElement('h3'); + heading.textContent = group.title; + article.appendChild(heading); + + const description = document.createElement('div'); + description.className = 'metadata-note'; + description.textContent = group.description; + article.appendChild(description); + + if (group.fields.length) { + article.appendChild(definitionList(group.fields.map(function (field) { + return [field.label, field.value]; + }))); + } + + group.tables.forEach(function (metadataTable) { + if (!metadataTable.rows.length) { + return; + } + const tableSection = section(metadataTable.title); + tableSection.appendChild(table(metadataTable.headers, metadataTable.rows)); + article.appendChild(tableSection); + }); + + root.appendChild(article); + }); +} + function renderSummaryMetadata() { const root = document.getElementById('metadata-summary'); root.textContent = ''; diff --git a/mrd-viz/extension/mrd-viz/media/viewer/metadataGroups.ts b/mrd-viz/extension/mrd-viz/media/viewer/metadataGroups.ts new file mode 100644 index 00000000..549c2a11 --- /dev/null +++ b/mrd-viz/extension/mrd-viz/media/viewer/metadataGroups.ts @@ -0,0 +1,214 @@ +// D3 metadata grouping scaffold: derive user-facing metadata sections from the existing backend +// payload so richer labels/search can evolve without changing the backend contract first. + +import { formatList, valueOrUnknown } from './dom'; + +export interface MetadataField { + label: string; + value: string; +} + +export interface MetadataTable { + title: string; + headers: string[]; + rows: string[][]; +} + +export interface MetadataGroup { + id: string; + title: string; + description: string; + fields: MetadataField[]; + tables: MetadataTable[]; +} + +export function buildMetadataGroups(payload): MetadataGroup[] { + return [ + buildFileGroup(payload), + buildHeaderGroup(payload), + buildImageGroup(payload), + buildAcquisitionGroup(payload), + buildStreamGroup(payload), + ].filter(group => group.fields.length > 0 || group.tables.some(table => table.rows.length > 0)); +} + +function buildFileGroup(payload): MetadataGroup { + return { + id: 'file', + title: 'File', + description: 'Open state and high-level classification.', + fields: [ + field('class', payload.file_class), + field('classification reliable', payload.file_class_reliable), + field('display mode', payload.display_mode), + field('schema version', payload.schema_version), + field('file size bytes', payload.file_size_bytes), + ], + tables: (payload.warnings || []).length ? [{ + title: 'Warnings', + headers: ['warning'], + rows: payload.warnings.map(warning => [formatValue(warning)]), + }] : [], + }; +} + +function buildHeaderGroup(payload): MetadataGroup { + const summary = payload.summary || {}; + return { + id: 'header', + title: 'Header', + description: 'Encoding and reconstructed-space summary from the MRD header.', + fields: [ + field('encoding count', summary.encoding_count), + field('encoded matrix', summary.encoded_matrix), + field('recon matrix', summary.recon_matrix), + field('encoded FOV mm', summary.encoded_fov_mm), + field('recon FOV mm', summary.recon_fov_mm), + ], + tables: [], + }; +} + +function buildImageGroup(payload): MetadataGroup { + const imageItems = images(payload); + return { + id: 'images', + title: 'Images', + description: 'Image-item organization for slice/type/series scanning.', + fields: [ + field('images', imageItems.length), + field('unique slices', uniqueCount(imageItems.map(image => headValue(image, 'slice')))), + field('unique image types', uniqueCount(imageItems.map(image => headValue(image, 'image_type')))), + field('unique series', uniqueCount(imageItems.map(image => headValue(image, 'image_series_index')))), + field('unique shapes', uniqueCount(imageItems.map(shapeKey))), + field('unique dtypes', uniqueCount(imageItems.map(image => image.dtype))), + ], + tables: [ + distributionTable('Slice distribution', 'slice', imageItems.map(image => headValue(image, 'slice'))), + distributionTable('Image type distribution', 'image type', imageItems.map(image => headValue(image, 'image_type'))), + distributionTable('Shape / dtype consistency', 'shape / dtype', imageItems.map(image => shapeKey(image) + ' / ' + formatValue(image.dtype))), + ], + }; +} + +function buildAcquisitionGroup(payload): MetadataGroup { + const acquisitionItems = acquisitions(payload); + const firstAcquisition = acquisitionItems[0] || {}; + const idx = firstAcquisition.idx || {}; + return { + id: 'acquisitions', + title: 'Acquisitions', + description: 'Raw acquisition examples and encoding counters returned by the backend.', + fields: [ + field('examples returned', acquisitionItems.length), + field('first stream index', firstAcquisition.stream_index), + field('first shape', firstAcquisition.data_shape), + field('first dtype', firstAcquisition.dtype), + field('first flags', firstAcquisition.flags), + field('first scan counter', firstAcquisition.scan_counter), + field('first slice', idx.slice), + field('first k-space step 1', idx.kspace_encode_step_1), + field('first k-space step 2', idx.kspace_encode_step_2), + ], + tables: [], + }; +} + +function buildStreamGroup(payload): MetadataGroup { + const stream = payload.stream || {}; + const itemRows = Object.entries(stream.item_counts || {}) + .sort(function (left, right) { return left[0].localeCompare(right[0]); }) + .map(function (entry) { return [entry[0], formatValue(entry[1])]; }); + + return { + id: 'stream', + title: 'Stream', + description: 'Stream item counts and metadata sample coverage.', + fields: [ + field('images', stream.image_count), + field('acquisitions', stream.acquisition_count), + field('waveforms', stream.waveform_count), + field('other items', stream.other_count), + field('partial stream read', stream.partial), + field('thumbnail payload truncated', payload.mosaic && payload.mosaic.truncated), + field('returned thumbnails', payload.mosaic && payload.mosaic.thumbnails && payload.mosaic.thumbnails.length), + field('image metadata entries', images(payload).length), + field('acquisition examples', acquisitions(payload).length), + field('waveform entries', waveforms(payload).length), + field('other item entries', otherItems(payload).length), + ], + tables: itemRows.length ? [{ + title: 'Stream item counts', + headers: ['item type', 'count'], + rows: itemRows, + }] : [], + }; +} + +function field(label: string, value): MetadataField { + return { label, value: formatValue(value) }; +} + +function metadata(payload) { + return payload.metadata || {}; +} + +function images(payload) { + return metadata(payload).images || []; +} + +function acquisitions(payload) { + return metadata(payload).acquisitions || []; +} + +function waveforms(payload) { + return metadata(payload).waveforms || []; +} + +function otherItems(payload) { + return metadata(payload).other_items || []; +} + +function headValue(image, key: string) { + return image && image.head ? image.head[key] : undefined; +} + +function shapeKey(image) { + return formatList(image && image.data_shape) || 'unknown'; +} + +function distributionTable(title: string, label: string, values): MetadataTable { + return { + title, + headers: [label, 'count'], + rows: distribution(values), + }; +} + +function distribution(values): string[][] { + const counts = new Map(); + values.forEach(function (value) { + const key = formatValue(value); + counts.set(key, (counts.get(key) || 0) + 1); + }); + + return Array.from(counts.entries()).sort(function (left, right) { + return left[0].localeCompare(right[0], undefined, { numeric: true }); + }).map(function (entry) { + return [entry[0], String(entry[1])]; + }); +} + +function uniqueCount(values): number { + return distribution(values).length; +} + +function formatValue(value): string { + if (Array.isArray(value)) { + return formatList(value); + } + if (value && typeof value === 'object') { + return JSON.stringify(value); + } + return valueOrUnknown(value); +} diff --git a/mrd-viz/extension/mrd-viz/src/test/extension.test.ts b/mrd-viz/extension/mrd-viz/src/test/extension.test.ts index cbc91c0f..9a091763 100644 --- a/mrd-viz/extension/mrd-viz/src/test/extension.test.ts +++ b/mrd-viz/extension/mrd-viz/src/test/extension.test.ts @@ -8,7 +8,7 @@ import * as vscode from 'vscode'; import { classifyProvisioningFailure, getOpenWithMrdEditorArgs, removeIncompleteVenv } from '../extension'; import { MRD_VIEW_TYPE } from '../mrdEditorProvider'; import { planBackendCandidates } from '../backendResolver'; -import { getMrdBackendMissingHtml } from '../webviewHtml'; +import { getMrdBackendMissingHtml, getMrdViewerHtml } from '../webviewHtml'; import { getMrdErrorHtml } from '../stateHtml'; import { isViewerToExtensionMessage } from '../contracts'; @@ -73,6 +73,18 @@ suite('MRD Viz Extension', () => { }); }); + test('renders the D3 metadata groups tab in the viewer shell', () => { + const webview = { + cspSource: 'vscode-resource:', + asWebviewUri: (uri: vscode.Uri) => uri, + } as vscode.Webview; + const html = getMrdViewerHtml(webview, sampleOpenPayload(), vscode.Uri.file('/extension')); + + assert.ok(html.includes('data-tab="groups"')); + assert.ok(html.includes('id="metadata-groups"')); + assert.ok(html.includes('metadata-summary" role="tabpanel" aria-hidden="true"')); + }); + test('escapes backend error details rendered inside the editor', () => { const webview = { cspSource: 'vscode-resource:' } as vscode.Webview; const html = getMrdErrorHtml(webview, 'Unable to open scan.mrd', 'Bad & path', 'C:\\tmp\\scan.mrd'); @@ -225,3 +237,46 @@ async function makeFakeVenv(): Promise { await writeFile(path.join(dir, 'pyvenv.cfg'), 'home = /usr\n'); return dir; } + +function sampleOpenPayload() { + return { + ok: true, + schema_version: 1, + path: '/tmp/sample.mrd', + filename: 'sample.mrd', + file_size_bytes: 42, + file_class: 'reconstructed', + file_class_reliable: true, + display_mode: 'mosaic', + summary: { + encoding_count: 1, + encoded_matrix: [64, 64, 1], + recon_matrix: [64, 64, 1], + encoded_fov_mm: [200, 200, 5], + recon_fov_mm: [200, 200, 5], + }, + stream: { + item_counts: { ImageFloat: 2 }, + image_count: 2, + acquisition_count: 0, + waveform_count: 0, + other_count: 0, + partial: false, + }, + mosaic: { + tile_unit: 'mrd_image_item', + thumbnails: [], + truncated: false, + }, + metadata: { + images: [ + { image_index: 0, data_shape: [1, 1, 64, 64], dtype: 'float32', head: { slice: 0, image_type: 5, image_series_index: 1 } }, + { image_index: 1, data_shape: [1, 1, 64, 64], dtype: 'float32', head: { slice: 1, image_type: 5, image_series_index: 1 } }, + ], + acquisitions: [], + waveforms: [], + other_items: [], + }, + warnings: [], + }; +} diff --git a/mrd-viz/extension/mrd-viz/src/webviewHtml.ts b/mrd-viz/extension/mrd-viz/src/webviewHtml.ts index 6f04060e..c5101fad 100644 --- a/mrd-viz/extension/mrd-viz/src/webviewHtml.ts +++ b/mrd-viz/extension/mrd-viz/src/webviewHtml.ts @@ -169,12 +169,14 @@ export function getMrdViewerHtml(webview: vscode.Webview, payload: MrdOpenPayloa

Metadata

- + +
-
+
+