@omniradiology/omnirad 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +438 -0
- package/app/api/ai-config/route.ts +131 -0
- package/app/api/ai-config/test/route.ts +49 -0
- package/app/api/auth/auto-login/route.ts +66 -0
- package/app/api/auth/check/route.ts +17 -0
- package/app/api/auth/login/route.ts +72 -0
- package/app/api/auth/logout/route.ts +25 -0
- package/app/api/auth/me/route.ts +75 -0
- package/app/api/auth/password/route.ts +49 -0
- package/app/api/auth/setup/route.ts +63 -0
- package/app/api/auth/users/route.ts +100 -0
- package/app/api/auth/wipe/route.ts +27 -0
- package/app/api/compliance/anonymize/patient/[id]/route.ts +104 -0
- package/app/api/compliance/audit/route.ts +110 -0
- package/app/api/compliance/export/patient/[id]/route.ts +108 -0
- package/app/api/compliance/restrict/patient/[id]/route.ts +59 -0
- package/app/api/compliance/settings/route.ts +93 -0
- package/app/api/copilot/annotate/route.ts +94 -0
- package/app/api/copilot/chat/route.ts +238 -0
- package/app/api/copilot/history/route.ts +95 -0
- package/app/api/copilot/reports/route.ts +81 -0
- package/app/api/fhir/Bundle/report/[id]/route.ts +85 -0
- package/app/api/fhir/DiagnosticReport/[id]/route.ts +45 -0
- package/app/api/fhir/ImagingStudy/[id]/route.ts +57 -0
- package/app/api/fhir/Patient/[id]/route.ts +26 -0
- package/app/api/fhir/ServiceRequest/route.ts +85 -0
- package/app/api/fhir/config/route.ts +102 -0
- package/app/api/fhir/config/test-connection/route.ts +49 -0
- package/app/api/fhir/metadata/route.ts +51 -0
- package/app/api/pacs/metadata/route.ts +32 -0
- package/app/api/pacs/qido/instances/route.ts +39 -0
- package/app/api/pacs/qido/series/route.ts +38 -0
- package/app/api/pacs/qido/studies/route.ts +37 -0
- package/app/api/pacs/test/route.ts +30 -0
- package/app/api/pacs/wado/render/route.ts +51 -0
- package/app/api/patients/[id]/reports/route.ts +18 -0
- package/app/api/patients/[id]/route.ts +43 -0
- package/app/api/patients/merge/route.ts +57 -0
- package/app/api/patients/route.ts +67 -0
- package/app/api/patients/search/route.ts +25 -0
- package/app/api/reports/[id]/route.ts +84 -0
- package/app/api/reports/[id]/status/route.ts +87 -0
- package/app/api/reports/clear/route.ts +16 -0
- package/app/api/reports/route.ts +112 -0
- package/app/api/segmentation-config/route.ts +238 -0
- package/app/api/settings/route.ts +245 -0
- package/app/api/settings/test-supabase/route.ts +103 -0
- package/app/api/upload/route.ts +48 -0
- package/app/copilot/page.tsx +30 -0
- package/app/globals.css +141 -0
- package/app/history/page.tsx +242 -0
- package/app/icon.svg +3 -0
- package/app/layout.tsx +47 -0
- package/app/login/page.tsx +175 -0
- package/app/pacs/page.tsx +78 -0
- package/app/page.tsx +125 -0
- package/app/patients/[id]/page.tsx +315 -0
- package/app/patients/page.tsx +110 -0
- package/app/profile/page.tsx +208 -0
- package/app/reports/page.tsx +432 -0
- package/app/settings/page.tsx +454 -0
- package/app/setup/page.tsx +199 -0
- package/components/admin/AuditLogTable.tsx +293 -0
- package/components/copilot/ActivityIndicator.tsx +215 -0
- package/components/copilot/ChatHistoryPanel.tsx +140 -0
- package/components/copilot/ChatMessage.tsx +251 -0
- package/components/copilot/ClickableReference.tsx +40 -0
- package/components/copilot/CopilotCornerstoneViewer.tsx +562 -0
- package/components/copilot/CopilotPanel.tsx +311 -0
- package/components/copilot/FindingsList.tsx +75 -0
- package/components/copilot/ViewerPanel.tsx +460 -0
- package/components/copilot/WorkspaceLayout.tsx +398 -0
- package/components/dashboard/AIConfigPanel.tsx +339 -0
- package/components/dashboard/AppearancePanel.tsx +491 -0
- package/components/dashboard/ApprovalModal.tsx +163 -0
- package/components/dashboard/CollaborationPanel.tsx +134 -0
- package/components/dashboard/CopilotConfigPanel.tsx +337 -0
- package/components/dashboard/DicomViewer.tsx +645 -0
- package/components/dashboard/FhirIntegrationPanel.tsx +331 -0
- package/components/dashboard/FullReportOverlay.tsx +269 -0
- package/components/dashboard/ImageViewer.tsx +541 -0
- package/components/dashboard/PatientForm.tsx +597 -0
- package/components/dashboard/RejectionModal.tsx +74 -0
- package/components/dashboard/ReportEditor.tsx +160 -0
- package/components/dashboard/ReportTemplates.tsx +729 -0
- package/components/dashboard/ReportView.tsx +539 -0
- package/components/dashboard/SegmentationConfigPanel.tsx +490 -0
- package/components/dashboard/StudyPlaceholder.tsx +17 -0
- package/components/dashboard/SupabaseIntegrationPanel.tsx +345 -0
- package/components/dashboard/UserManagementPanel.tsx +272 -0
- package/components/layout/ClientLayout.tsx +39 -0
- package/components/layout/Header.tsx +20 -0
- package/components/layout/Sidebar.tsx +119 -0
- package/components/pacs/PacsImageViewerModal.tsx +121 -0
- package/components/pacs/PacsSearchFilters.tsx +117 -0
- package/components/pacs/PacsSeriesViewer.tsx +190 -0
- package/components/pacs/PacsStudyTable.tsx +113 -0
- package/components/patients/patient-card.tsx +117 -0
- package/components/patients/patient-header.tsx +122 -0
- package/components/patients/patient-search.tsx +137 -0
- package/components/patients/patient-timeline.tsx +153 -0
- package/components/settings/ComplianceSettingsPanel.tsx +278 -0
- package/components/settings/SecurityPanel.tsx +418 -0
- package/components/ui/badge.tsx +19 -0
- package/components/ui/basic.tsx +156 -0
- package/db/index.ts +350 -0
- package/db/migrations/0000_odd_quasimodo.sql +117 -0
- package/db/migrations/meta/0000_snapshot.json +778 -0
- package/db/migrations/meta/_journal.json +13 -0
- package/db/schema.ts +239 -0
- package/drizzle.config.ts +10 -0
- package/lib/api.ts +689 -0
- package/lib/auth.ts +22 -0
- package/lib/copilot/action-executor.ts +94 -0
- package/lib/copilot/action-types.ts +72 -0
- package/lib/copilot/coordinate-mapper.ts +84 -0
- package/lib/dicomImageExtractor.ts +103 -0
- package/lib/dicomMetadataParser.ts +111 -0
- package/lib/fhir/client.ts +25 -0
- package/lib/fhir/constants.ts +21 -0
- package/lib/fhir/diagnostic-report.ts +88 -0
- package/lib/fhir/helpers.ts +73 -0
- package/lib/fhir/imaging-study.ts +49 -0
- package/lib/fhir/patient.ts +55 -0
- package/lib/fhir/service-request.ts +85 -0
- package/lib/fhir.ts +6 -0
- package/lib/pacs/dicom-utils.ts +72 -0
- package/lib/pacs/dicomweb.ts +72 -0
- package/lib/pacs/server-utils.ts +37 -0
- package/lib/patients.ts +25 -0
- package/lib/pdfHelper.ts +119 -0
- package/lib/reportHtmlGenerator.ts +581 -0
- package/lib/security/audit.ts +180 -0
- package/lib/security/authz.ts +246 -0
- package/lib/security/phi-redaction.ts +156 -0
- package/lib/security/rate-limit.ts +106 -0
- package/lib/security/secrets.ts +179 -0
- package/lib/supabase.ts +72 -0
- package/lib/utils.ts +6 -0
- package/next.config.ts +35 -0
- package/package.json +76 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/logo.svg +8 -0
- package/public/next.svg +1 -0
- package/public/omnirad-favicon.svg +8 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/tsconfig.json +34 -0
- package/types/copilot-viewer.ts +155 -0
- package/types/copilot.ts +105 -0
- package/types/fhir.ts +21 -0
- package/types/html2pdf.d.ts +20 -0
- package/types/index.ts +139 -0
- package/types/pacs.ts +41 -0
package/lib/auth.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import bcrypt from "bcryptjs";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Hashes a plaintext password using bcrypt.
|
|
5
|
+
*/
|
|
6
|
+
export async function hashPassword(password: string): Promise<string> {
|
|
7
|
+
return await bcrypt.hash(password, 10);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Verifies a plaintext password against a stored bcrypt hash.
|
|
12
|
+
*/
|
|
13
|
+
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
|
14
|
+
return await bcrypt.compare(password, hash);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Generates a secure random session ID.
|
|
19
|
+
*/
|
|
20
|
+
export function generateSessionId(): string {
|
|
21
|
+
return crypto.randomUUID();
|
|
22
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// ─── Viewer Action Executor ──────────────────────────────────────────────────
|
|
2
|
+
// Deterministic executor that processes AI viewer actions against the
|
|
3
|
+
// CopilotCornerstoneViewer ref in the correct order.
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
AIViewerAction,
|
|
7
|
+
CopilotViewerRef,
|
|
8
|
+
AnnotationAction,
|
|
9
|
+
SegmentationAction,
|
|
10
|
+
} from "@/types/copilot-viewer";
|
|
11
|
+
import {
|
|
12
|
+
sortViewerActions,
|
|
13
|
+
isNavigateAction,
|
|
14
|
+
isAnnotationAction,
|
|
15
|
+
isSegmentationAction,
|
|
16
|
+
isViewportAction,
|
|
17
|
+
isClearAction,
|
|
18
|
+
} from "./action-types";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Execute a list of AI viewer actions against the viewer ref.
|
|
22
|
+
* Actions are sorted by priority: clear → navigate → segment → annotate → viewport
|
|
23
|
+
* and executed sequentially with a small delay between groups.
|
|
24
|
+
*/
|
|
25
|
+
export async function executeViewerActions(
|
|
26
|
+
actions: AIViewerAction[],
|
|
27
|
+
viewerRef: CopilotViewerRef | null,
|
|
28
|
+
): Promise<void> {
|
|
29
|
+
if (!viewerRef || !actions || actions.length === 0) return;
|
|
30
|
+
|
|
31
|
+
const sorted = sortViewerActions(actions);
|
|
32
|
+
|
|
33
|
+
for (const action of sorted) {
|
|
34
|
+
try {
|
|
35
|
+
executeSingleAction(action, viewerRef);
|
|
36
|
+
// Small delay between actions for visual feedback
|
|
37
|
+
await sleep(50);
|
|
38
|
+
} catch (err) {
|
|
39
|
+
console.error("[ActionExecutor] Error executing action:", action, err);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Execute a single viewer action.
|
|
46
|
+
*/
|
|
47
|
+
function executeSingleAction(
|
|
48
|
+
action: AIViewerAction,
|
|
49
|
+
viewer: CopilotViewerRef,
|
|
50
|
+
): void {
|
|
51
|
+
if (isClearAction(action)) {
|
|
52
|
+
switch (action.action) {
|
|
53
|
+
case "clear_ai_annotations":
|
|
54
|
+
case "clear_ai_segmentations":
|
|
55
|
+
case "clear_all_ai_findings":
|
|
56
|
+
viewer.clearAIFindings();
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (isNavigateAction(action)) {
|
|
63
|
+
viewer.jumpToSlice(action.slice);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (isSegmentationAction(action)) {
|
|
68
|
+
viewer.addSegmentation(action as SegmentationAction);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (isAnnotationAction(action)) {
|
|
73
|
+
viewer.addAnnotation(action as AnnotationAction);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (isViewportAction(action)) {
|
|
78
|
+
switch (action.action) {
|
|
79
|
+
case "zoom_to_region":
|
|
80
|
+
if (action.x !== undefined && action.y !== undefined && action.width !== undefined && action.height !== undefined) {
|
|
81
|
+
viewer.zoomToRegion(action.x, action.y, action.width, action.height);
|
|
82
|
+
}
|
|
83
|
+
break;
|
|
84
|
+
case "reset_viewport":
|
|
85
|
+
viewer.resetViewport();
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function sleep(ms: number): Promise<void> {
|
|
93
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
94
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// ─── Copilot Viewer Action Types & Utilities ─────────────────────────────────
|
|
2
|
+
// Central definitions and type guards for all viewer actions.
|
|
3
|
+
|
|
4
|
+
import type {
|
|
5
|
+
AIViewerAction,
|
|
6
|
+
NavigateAction,
|
|
7
|
+
AnnotationAction,
|
|
8
|
+
SegmentationAction,
|
|
9
|
+
ViewportAction,
|
|
10
|
+
ClearAction,
|
|
11
|
+
} from "@/types/copilot-viewer";
|
|
12
|
+
|
|
13
|
+
// ── Type Guards ──────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
export function isNavigateAction(a: AIViewerAction): a is NavigateAction {
|
|
16
|
+
return a.type === "navigate";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function isAnnotationAction(a: AIViewerAction): a is AnnotationAction {
|
|
20
|
+
return a.type === "annotation";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function isSegmentationAction(a: AIViewerAction): a is SegmentationAction {
|
|
24
|
+
return a.type === "segmentation";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function isViewportAction(a: AIViewerAction): a is ViewportAction {
|
|
28
|
+
return a.type === "viewport";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function isClearAction(a: AIViewerAction): a is ClearAction {
|
|
32
|
+
return a.type === "clear";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ── Action Sorting ───────────────────────────────────────────────────────────
|
|
36
|
+
// Execution order: clear → navigate → segment → annotate → viewport
|
|
37
|
+
|
|
38
|
+
const ACTION_PRIORITY: Record<string, number> = {
|
|
39
|
+
clear: 0,
|
|
40
|
+
navigate: 1,
|
|
41
|
+
segmentation: 2,
|
|
42
|
+
annotation: 3,
|
|
43
|
+
viewport: 4,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export function sortViewerActions(actions: AIViewerAction[]): AIViewerAction[] {
|
|
47
|
+
return [...actions].sort(
|
|
48
|
+
(a, b) => (ACTION_PRIORITY[a.type] ?? 99) - (ACTION_PRIORITY[b.type] ?? 99)
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── Default Styling ──────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
export const AI_ANNOTATION_DEFAULTS = {
|
|
55
|
+
color: "#ff4d4f", // Red for abnormal findings
|
|
56
|
+
normalColor: "#52c41a", // Green for normal findings
|
|
57
|
+
labelMode: "always" as const,
|
|
58
|
+
segmentationOpacity: 0.3,
|
|
59
|
+
segmentationColor: "#ff4d4f",
|
|
60
|
+
contourColor: "#ff6b6b",
|
|
61
|
+
contourWidth: 2,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// ── Label Formatting ─────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
export function formatAILabel(name: string, confidence?: number): string {
|
|
67
|
+
const base = name || "Finding";
|
|
68
|
+
if (confidence !== undefined && confidence > 0) {
|
|
69
|
+
return `${base} (AI) — ${Math.round(confidence * 100)}%`;
|
|
70
|
+
}
|
|
71
|
+
return `${base} (AI)`;
|
|
72
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// ─── Coordinate Mapper ───────────────────────────────────────────────────────
|
|
2
|
+
// Maps between MedSAM3 pixel-space coordinates and the Cornerstone viewport.
|
|
3
|
+
// First implementation uses image pixel space which is sufficient for stack viewports.
|
|
4
|
+
|
|
5
|
+
export interface ImageDimensions {
|
|
6
|
+
width: number;
|
|
7
|
+
height: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Convert image pixel coordinates to viewport coordinates.
|
|
12
|
+
* For stack viewports with standard 2D slice rendering, image-space
|
|
13
|
+
* coordinates map 1:1 with Cornerstone image coordinates.
|
|
14
|
+
*/
|
|
15
|
+
export function imageToViewportCoordinates(
|
|
16
|
+
imageX: number,
|
|
17
|
+
imageY: number,
|
|
18
|
+
imageDims: ImageDimensions,
|
|
19
|
+
viewportDims: ImageDimensions
|
|
20
|
+
): { x: number; y: number } {
|
|
21
|
+
const scaleX = viewportDims.width / imageDims.width;
|
|
22
|
+
const scaleY = viewportDims.height / imageDims.height;
|
|
23
|
+
const scale = Math.min(scaleX, scaleY);
|
|
24
|
+
|
|
25
|
+
const offsetX = (viewportDims.width - imageDims.width * scale) / 2;
|
|
26
|
+
const offsetY = (viewportDims.height - imageDims.height * scale) / 2;
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
x: imageX * scale + offsetX,
|
|
30
|
+
y: imageY * scale + offsetY,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Convert viewport coordinates back to image pixel coordinates.
|
|
36
|
+
*/
|
|
37
|
+
export function viewportToImageCoordinates(
|
|
38
|
+
viewportX: number,
|
|
39
|
+
viewportY: number,
|
|
40
|
+
imageDims: ImageDimensions,
|
|
41
|
+
viewportDims: ImageDimensions
|
|
42
|
+
): { x: number; y: number } {
|
|
43
|
+
const scaleX = viewportDims.width / imageDims.width;
|
|
44
|
+
const scaleY = viewportDims.height / imageDims.height;
|
|
45
|
+
const scale = Math.min(scaleX, scaleY);
|
|
46
|
+
|
|
47
|
+
const offsetX = (viewportDims.width - imageDims.width * scale) / 2;
|
|
48
|
+
const offsetY = (viewportDims.height - imageDims.height * scale) / 2;
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
x: (viewportX - offsetX) / scale,
|
|
52
|
+
y: (viewportY - offsetY) / scale,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Convert percentage-based coordinates (0-1 range) to pixel coordinates.
|
|
58
|
+
* Useful when an upstream vision model returns normalized bounding boxes.
|
|
59
|
+
*/
|
|
60
|
+
export function percentToPixelCoordinates(
|
|
61
|
+
percentX: number,
|
|
62
|
+
percentY: number,
|
|
63
|
+
imageDims: ImageDimensions
|
|
64
|
+
): { x: number; y: number } {
|
|
65
|
+
return {
|
|
66
|
+
x: percentX * imageDims.width,
|
|
67
|
+
y: percentY * imageDims.height,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Convert a percentage-based bounding box to pixel bbox.
|
|
73
|
+
*/
|
|
74
|
+
export function percentBboxToPixelBbox(
|
|
75
|
+
bbox: [number, number, number, number],
|
|
76
|
+
imageDims: ImageDimensions
|
|
77
|
+
): [number, number, number, number] {
|
|
78
|
+
return [
|
|
79
|
+
bbox[0] * imageDims.width,
|
|
80
|
+
bbox[1] * imageDims.height,
|
|
81
|
+
bbox[2] * imageDims.width,
|
|
82
|
+
bbox[3] * imageDims.height,
|
|
83
|
+
];
|
|
84
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import * as dicomParser from 'dicom-parser';
|
|
2
|
+
|
|
3
|
+
export async function extractUncompressedDicomFrame(file: File): Promise<string | null> {
|
|
4
|
+
try {
|
|
5
|
+
const arrayBuffer = await file.arrayBuffer();
|
|
6
|
+
const byteArray = new Uint8Array(arrayBuffer);
|
|
7
|
+
const dataSet = dicomParser.parseDicom(byteArray);
|
|
8
|
+
|
|
9
|
+
const transferSyntax = dataSet.string('x00020010')?.trim() || '';
|
|
10
|
+
|
|
11
|
+
// Uncompressed syntaxes (Implicit VR Little Endian, Explicit VR Little/Big Endian)
|
|
12
|
+
const uncompressedSyntaxes = ['1.2.840.10008.1.2', '1.2.840.10008.1.2.1', '1.2.840.10008.1.2.2'];
|
|
13
|
+
|
|
14
|
+
if (!uncompressedSyntaxes.includes(transferSyntax)) {
|
|
15
|
+
// Not supported by this simple extractor. Will fall back to Cornerstone Viewer.
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const rows = dataSet.uint16('x00280010');
|
|
20
|
+
const columns = dataSet.uint16('x00280011');
|
|
21
|
+
const bitsAllocated = dataSet.uint16('x00280100');
|
|
22
|
+
const pixelRepresentation = dataSet.uint16('x00280103') || 0; // 0=unsigned, 1=signed
|
|
23
|
+
|
|
24
|
+
let windowCenter = dataSet.string('x00281050') ? parseFloat(dataSet.string('x00281050')!) : undefined;
|
|
25
|
+
let windowWidth = dataSet.string('x00281051') ? parseFloat(dataSet.string('x00281051')!) : undefined;
|
|
26
|
+
|
|
27
|
+
if (!rows || !columns || !bitsAllocated) return null;
|
|
28
|
+
|
|
29
|
+
const pixelDataElement = dataSet.elements.x7fe00010;
|
|
30
|
+
if (!pixelDataElement) return null;
|
|
31
|
+
|
|
32
|
+
// Extract raw bytes
|
|
33
|
+
const pixelBytes = byteArray.slice(pixelDataElement.dataOffset, pixelDataElement.dataOffset + pixelDataElement.length);
|
|
34
|
+
|
|
35
|
+
let minPixel = Infinity;
|
|
36
|
+
let maxPixel = -Infinity;
|
|
37
|
+
const pixelValues = new Float32Array(rows * columns);
|
|
38
|
+
|
|
39
|
+
if (bitsAllocated === 16) {
|
|
40
|
+
const dataView = new DataView(pixelBytes.buffer, pixelBytes.byteOffset, pixelBytes.byteLength);
|
|
41
|
+
for (let i = 0; i < rows * columns; i++) {
|
|
42
|
+
let val = pixelRepresentation === 1 ? dataView.getInt16(i * 2, true) : dataView.getUint16(i * 2, true);
|
|
43
|
+
pixelValues[i] = val;
|
|
44
|
+
if (val < minPixel) minPixel = val;
|
|
45
|
+
if (val > maxPixel) maxPixel = val;
|
|
46
|
+
}
|
|
47
|
+
} else if (bitsAllocated === 8) {
|
|
48
|
+
for (let i = 0; i < rows * columns; i++) {
|
|
49
|
+
let val = pixelBytes[i];
|
|
50
|
+
pixelValues[i] = val;
|
|
51
|
+
if (val < minPixel) minPixel = val;
|
|
52
|
+
if (val > maxPixel) maxPixel = val;
|
|
53
|
+
}
|
|
54
|
+
} else {
|
|
55
|
+
return null; // Unsupported bit depth
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Apply Window/Level if provided, otherwise auto-window to min/max
|
|
59
|
+
if (windowCenter === undefined || windowWidth === undefined) {
|
|
60
|
+
windowCenter = (maxPixel + minPixel) / 2;
|
|
61
|
+
windowWidth = maxPixel - minPixel;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const lowerBound = windowCenter - 0.5 - (windowWidth - 1) / 2;
|
|
65
|
+
const upperBound = windowCenter - 0.5 + (windowWidth - 1) / 2;
|
|
66
|
+
|
|
67
|
+
const canvas = document.createElement('canvas');
|
|
68
|
+
canvas.width = columns;
|
|
69
|
+
canvas.height = rows;
|
|
70
|
+
const ctx = canvas.getContext('2d');
|
|
71
|
+
if (!ctx) return null;
|
|
72
|
+
|
|
73
|
+
const imageData = ctx.createImageData(columns, rows);
|
|
74
|
+
const data = imageData.data;
|
|
75
|
+
|
|
76
|
+
for (let i = 0; i < pixelValues.length; i++) {
|
|
77
|
+
let val = pixelValues[i];
|
|
78
|
+
|
|
79
|
+
// Apply VOI LUT
|
|
80
|
+
let luminance = 0;
|
|
81
|
+
if (val <= lowerBound) {
|
|
82
|
+
luminance = 0;
|
|
83
|
+
} else if (val >= upperBound) {
|
|
84
|
+
luminance = 255;
|
|
85
|
+
} else {
|
|
86
|
+
luminance = Math.floor(((val - windowCenter - 0.5) / (windowWidth - 1) + 0.5) * 255);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const offset = i * 4;
|
|
90
|
+
data[offset] = luminance; // R
|
|
91
|
+
data[offset + 1] = luminance; // G
|
|
92
|
+
data[offset + 2] = luminance; // B
|
|
93
|
+
data[offset + 3] = 255; // A
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
ctx.putImageData(imageData, 0, 0);
|
|
97
|
+
return canvas.toDataURL('image/jpeg', 0.92);
|
|
98
|
+
|
|
99
|
+
} catch (e) {
|
|
100
|
+
console.error("Simple extractor failed:", e);
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import * as dicomParser from 'dicom-parser';
|
|
2
|
+
import { DicomMetadata, DicomExtractionResult } from '@/types';
|
|
3
|
+
|
|
4
|
+
// Helper to safely extract and trim string values
|
|
5
|
+
function getString(dataSet: dicomParser.DataSet, tag: string): string | undefined {
|
|
6
|
+
const val = dataSet.string(tag);
|
|
7
|
+
if (val) return val.trim();
|
|
8
|
+
return undefined;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Helper to safely extract numeric values
|
|
12
|
+
function getNumber(dataSet: dicomParser.DataSet, tag: string): number | undefined {
|
|
13
|
+
// Try uint16 first, which is common for rows/cols/bits
|
|
14
|
+
try {
|
|
15
|
+
const val = dataSet.uint16(tag);
|
|
16
|
+
if (val !== undefined && !isNaN(val)) return val;
|
|
17
|
+
|
|
18
|
+
const val32 = dataSet.uint32(tag);
|
|
19
|
+
if (val32 !== undefined && !isNaN(val32)) return val32;
|
|
20
|
+
} catch (e) {
|
|
21
|
+
// Tag might missing or wrong type
|
|
22
|
+
}
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Formats the patient name from DICOM but does not filter out technical names.
|
|
28
|
+
*/
|
|
29
|
+
function formatPatientName(raw: string | undefined): string | undefined {
|
|
30
|
+
if (!raw) return undefined;
|
|
31
|
+
|
|
32
|
+
// Replace DICOM ^ separator with spaces
|
|
33
|
+
let name = raw.replace(/\^/g, ' ').trim();
|
|
34
|
+
if (name.length === 0) return undefined;
|
|
35
|
+
|
|
36
|
+
// Proper case formatting: "DOE^JOHN" or "doe john" -> "Doe John"
|
|
37
|
+
// (Only applies to words separated by spaces. Things like Ultra_fast_Brain will become Ultra_fast_brain)
|
|
38
|
+
name = name
|
|
39
|
+
.split(/\s+/)
|
|
40
|
+
.map(w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
|
|
41
|
+
.join(' ');
|
|
42
|
+
|
|
43
|
+
return name;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Returns the patient ID as-is, trimmed.
|
|
48
|
+
*/
|
|
49
|
+
function formatPatientId(raw: string | undefined): string | undefined {
|
|
50
|
+
if (!raw) return undefined;
|
|
51
|
+
const id = raw.trim();
|
|
52
|
+
if (id.length === 0) return undefined;
|
|
53
|
+
return id;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function parseDicomMetadata(file: File): Promise<DicomExtractionResult> {
|
|
57
|
+
try {
|
|
58
|
+
const arrayBuffer = await file.arrayBuffer();
|
|
59
|
+
const byteArray = new Uint8Array(arrayBuffer);
|
|
60
|
+
|
|
61
|
+
// Parse the DICOM file
|
|
62
|
+
// This throws if the file is not a valid DICOM file
|
|
63
|
+
const dataSet = dicomParser.parseDicom(byteArray);
|
|
64
|
+
|
|
65
|
+
// Extract raw tags
|
|
66
|
+
const rawPatientName = getString(dataSet, 'x00100010');
|
|
67
|
+
const rawPatientId = getString(dataSet, 'x00100020');
|
|
68
|
+
|
|
69
|
+
// Format names but allow all values
|
|
70
|
+
const cleanName = formatPatientName(rawPatientName);
|
|
71
|
+
const cleanId = formatPatientId(rawPatientId);
|
|
72
|
+
|
|
73
|
+
const metadata: DicomMetadata = {
|
|
74
|
+
patientName: cleanName,
|
|
75
|
+
patientId: cleanId,
|
|
76
|
+
patientBirthDate: getString(dataSet, 'x00100030'),
|
|
77
|
+
patientAge: getString(dataSet, 'x00101010'),
|
|
78
|
+
patientSex: getString(dataSet, 'x00100040'),
|
|
79
|
+
modality: getString(dataSet, 'x00080060'),
|
|
80
|
+
studyDate: getString(dataSet, 'x00080020'),
|
|
81
|
+
studyTime: getString(dataSet, 'x00080030'),
|
|
82
|
+
institutionName: getString(dataSet, 'x00080080'),
|
|
83
|
+
studyDescription: getString(dataSet, 'x00081030'),
|
|
84
|
+
seriesDescription: getString(dataSet, 'x0008103e'),
|
|
85
|
+
bodyPartExamined: getString(dataSet, 'x00180015'),
|
|
86
|
+
referringPhysicianName: getString(dataSet, 'x00080090'),
|
|
87
|
+
accessionNumber: getString(dataSet, 'x00080050'),
|
|
88
|
+
|
|
89
|
+
// Note: transfer syntax is technically in the File Meta Information (x00020010)
|
|
90
|
+
// dicom-parser handles this if Part 10 file format.
|
|
91
|
+
transferSyntaxUID: getString(dataSet, 'x00020010'),
|
|
92
|
+
|
|
93
|
+
rows: getNumber(dataSet, 'x00280010'),
|
|
94
|
+
columns: getNumber(dataSet, 'x00280011'),
|
|
95
|
+
bitsAllocated: getNumber(dataSet, 'x00280100'),
|
|
96
|
+
numberOfFrames: getNumber(dataSet, 'x00280008') || 1, // Default to 1 if missing
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
success: true,
|
|
101
|
+
metadata
|
|
102
|
+
};
|
|
103
|
+
} catch (error: any) {
|
|
104
|
+
console.error('DICOM parsing error:', error);
|
|
105
|
+
return {
|
|
106
|
+
success: false,
|
|
107
|
+
error: error?.message || 'File does not appear to be a valid DICOM file.'
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Client } from "fhir-kit-client";
|
|
2
|
+
|
|
3
|
+
export function createFhirClient(
|
|
4
|
+
baseUrl: string,
|
|
5
|
+
authType: string,
|
|
6
|
+
bearerToken?: string,
|
|
7
|
+
clientId?: string,
|
|
8
|
+
clientSecret?: string
|
|
9
|
+
): Client {
|
|
10
|
+
const config: any = {
|
|
11
|
+
baseUrl: baseUrl
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
if (authType === "bearer" && bearerToken) {
|
|
15
|
+
config.bearerToken = bearerToken;
|
|
16
|
+
} else if (authType === "basic" && clientId && clientSecret) {
|
|
17
|
+
// According to fhir-kit-client, for basic auth we can pass custom headers
|
|
18
|
+
const encoded = Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
|
|
19
|
+
config.customHeaders = {
|
|
20
|
+
Authorization: `Basic ${encoded}`
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return new Client(config);
|
|
25
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export const FHIR_SYSTEMS = {
|
|
2
|
+
LOINC: "http://loinc.org",
|
|
3
|
+
HL7_V2_0074: "http://terminology.hl7.org/CodeSystem/v2-0074",
|
|
4
|
+
OMNIRAD_REPORT_ID: "urn:oid:1.3.6.1.4.1.59626.1.1", // Or another domain-specific URL
|
|
5
|
+
OMNIRAD_PATIENT_ID: "urn:oid:1.3.6.1.4.1.59626.1.2",
|
|
6
|
+
OMNIRAD_STUDY_UID: "urn:ietf:rfc:3986" // For DICOM UIDs like urn:oid:...
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_DIAGNOSTIC_REPORT_CODE = {
|
|
10
|
+
system: FHIR_SYSTEMS.LOINC,
|
|
11
|
+
code: "18748-4",
|
|
12
|
+
display: "Diagnostic imaging study"
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const RADIOLOGY_CATEGORY = {
|
|
16
|
+
system: FHIR_SYSTEMS.HL7_V2_0074,
|
|
17
|
+
code: "RAD",
|
|
18
|
+
display: "Radiology"
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const FHIR_CONTENT_TYPE = "application/fhir+json";
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/// <reference types="fhir" />
|
|
2
|
+
import { DiagnosticReportInput } from "../../types/fhir";
|
|
3
|
+
import { FHIR_SYSTEMS, DEFAULT_DIAGNOSTIC_REPORT_CODE, RADIOLOGY_CATEGORY } from "./constants";
|
|
4
|
+
import { sanitizeId } from "./helpers";
|
|
5
|
+
|
|
6
|
+
export function toFhirDiagnosticReport({
|
|
7
|
+
report,
|
|
8
|
+
reportData,
|
|
9
|
+
patient,
|
|
10
|
+
pdfBase64,
|
|
11
|
+
serviceRequestId
|
|
12
|
+
}: DiagnosticReportInput): fhir4.DiagnosticReport {
|
|
13
|
+
let status: fhir4.DiagnosticReport["status"] = "unknown";
|
|
14
|
+
const pStatus = report.reportStatus?.toLowerCase();
|
|
15
|
+
if (pStatus === "pending") status = "preliminary";
|
|
16
|
+
else if (pStatus === "approved" || pStatus === "final") status = "final";
|
|
17
|
+
else if (pStatus === "rejected") status = "cancelled";
|
|
18
|
+
|
|
19
|
+
const identifiers = [];
|
|
20
|
+
if (reportData?.report_header?.report_id) {
|
|
21
|
+
identifiers.push({
|
|
22
|
+
system: FHIR_SYSTEMS.OMNIRAD_REPORT_ID,
|
|
23
|
+
value: reportData.report_header.report_id
|
|
24
|
+
});
|
|
25
|
+
} else {
|
|
26
|
+
identifiers.push({
|
|
27
|
+
system: FHIR_SYSTEMS.OMNIRAD_REPORT_ID,
|
|
28
|
+
value: report.id
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const effectiveDateTime = reportData?.report_header?.report_date || report.createdAt;
|
|
33
|
+
let issued = reportData?.report_footer?.approved_at || report.createdAt;
|
|
34
|
+
|
|
35
|
+
const conclusion = reportData?.impression ? reportData.impression.join("\n") : undefined;
|
|
36
|
+
|
|
37
|
+
const resource: fhir4.DiagnosticReport = {
|
|
38
|
+
resourceType: "DiagnosticReport",
|
|
39
|
+
id: sanitizeId(report.id),
|
|
40
|
+
identifier: identifiers,
|
|
41
|
+
status,
|
|
42
|
+
category: [
|
|
43
|
+
{
|
|
44
|
+
coding: [RADIOLOGY_CATEGORY],
|
|
45
|
+
text: RADIOLOGY_CATEGORY.display
|
|
46
|
+
}
|
|
47
|
+
],
|
|
48
|
+
code: {
|
|
49
|
+
coding: [DEFAULT_DIAGNOSTIC_REPORT_CODE],
|
|
50
|
+
text: reportData?.study?.examination || `${report.modality || "Imaging"} report`
|
|
51
|
+
},
|
|
52
|
+
subject: {
|
|
53
|
+
reference: patient ? `Patient/${sanitizeId(patient.id)}` : `Patient/${sanitizeId(report.patientId || "unknown")}`,
|
|
54
|
+
display: patient?.patientName || report.patientName || undefined
|
|
55
|
+
},
|
|
56
|
+
effectiveDateTime: effectiveDateTime ? new Date(effectiveDateTime).toISOString() : undefined,
|
|
57
|
+
issued: issued ? new Date(issued).toISOString() : undefined,
|
|
58
|
+
conclusion
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
if (report.pacsStudyUid || report.id) {
|
|
62
|
+
resource.imagingStudy = [
|
|
63
|
+
{
|
|
64
|
+
reference: `ImagingStudy/${sanitizeId(report.pacsStudyUid || report.id)}`
|
|
65
|
+
}
|
|
66
|
+
];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (serviceRequestId) {
|
|
70
|
+
resource.basedOn = [
|
|
71
|
+
{
|
|
72
|
+
reference: `ServiceRequest/${sanitizeId(serviceRequestId)}`
|
|
73
|
+
}
|
|
74
|
+
];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (pdfBase64) {
|
|
78
|
+
resource.presentedForm = [
|
|
79
|
+
{
|
|
80
|
+
contentType: "application/pdf",
|
|
81
|
+
title: "OmniRad Radiology Report",
|
|
82
|
+
data: pdfBase64
|
|
83
|
+
}
|
|
84
|
+
];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return resource;
|
|
88
|
+
}
|