IID Facade
Intent
Provide simplified, URL-based launching of imaging viewers with patient and study context, enabling seamless integration between clinical applications and imaging viewers.
Forces
- Interactive Viewing & Context Sync: Clinical workflows require real-time coordination between multiple applications.
- Metadata vs Payload (Imaging): Clinical data (FHIR) and imaging data (DICOM) live in separate systems with different access patterns.
Structure
The IID Facade pattern implements the IHE Invoke Image Display profile, providing a simple URL-based interface for launching imaging viewers with clinical context.
Key Components
IIDFacade
Main interface for constructing viewer launch URLs
ContextBuilder
Builds context parameters from FHIR resources
ViewerRegistry
Manages available viewer configurations
URLGenerator
Generates properly formatted launch URLs
AuthTokenProvider
Provides authentication tokens for viewer access
Behavior
Viewer Launch Flow
The following sequence shows how an EMR launches an imaging viewer with context:
Launch Steps
- Build Context
- Select Viewer
- Generate URL
- Add Authentication
- Launch Viewer
Implementation Considerations
IID URL Construction
Builds IHE Invoke Image Display compliant URLs for launching DICOM viewers with study, series, and instance context parameters.
from typing import Dict, List, Optional
from urllib.parse import urlencode, urljoin
from dataclasses import dataclass
@dataclass
class ViewerConfig:
"""Configuration for an IID-compliant viewer"""
name: str
base_url: str
url_template: str
supported_params: List[str]
requires_auth: bool = True
auth_method: str = "token" # token, saml, basic
class IIDURLBuilder:
"""
Builds IHE Invoke Image Display (IID) compliant URLs
for launching DICOM viewers from FHIR context.
"""
# Standard IID parameters
STANDARD_PARAMS = {
'requestType': 'Request type (STUDY, SERIES, INSTANCE)',
'studyUID': 'Study Instance UID',
'seriesUID': 'Series Instance UID',
'objectUID': 'SOP Instance UID',
'patientID': 'Patient ID',
'accessionNumber': 'Accession Number',
'wadoRsEndpoint': 'WADO-RS base URL',
'contentType': 'Preferred content type',
}
def __init__(self, viewer_config: ViewerConfig):
self.config = viewer_config
def build_study_url(self,
study_uid: str,
wado_endpoint: str = None,
patient_id: str = None,
additional_params: Dict = None) -> str:
"""
Build URL to view an entire study.
"""
params = {
'requestType': 'STUDY',
'studyUID': study_uid
}
if wado_endpoint:
params['wadoRsEndpoint'] = wado_endpoint
if patient_id:
params['patientID'] = patient_id
if additional_params:
params.update(additional_params)
return self._build_url(params)
def build_series_url(self,
study_uid: str,
series_uid: str,
wado_endpoint: str = None,
additional_params: Dict = None) -> str:
"""
Build URL to view a specific series.
"""
params = {
'requestType': 'SERIES',
'studyUID': study_uid,
'seriesUID': series_uid
}
if wado_endpoint:
params['wadoRsEndpoint'] = wado_endpoint
if additional_params:
params.update(additional_params)
return self._build_url(params)
def build_instance_url(self,
study_uid: str,
series_uid: str,
instance_uid: str,
wado_endpoint: str = None,
additional_params: Dict = None) -> str:
"""
Build URL to view a specific instance.
"""
params = {
'requestType': 'INSTANCE',
'studyUID': study_uid,
'seriesUID': series_uid,
'objectUID': instance_uid
}
if wado_endpoint:
params['wadoRsEndpoint'] = wado_endpoint
if additional_params:
params.update(additional_params)
return self._build_url(params)
def build_url_from_context(self, context: Dict) -> str:
"""
Build URL from viewer context dictionary.
Automatically determines request type.
"""
params = {'requestType': self._determine_request_type(context)}
# Add all provided context as params
param_mapping = {
'study_uid': 'studyUID',
'series_uid': 'seriesUID',
'instance_uid': 'objectUID',
'patient_id': 'patientID',
'accession_number': 'accessionNumber',
'wado_endpoint': 'wadoRsEndpoint'
}
for context_key, param_key in param_mapping.items():
if context_key in context and context[context_key]:
params[param_key] = context[context_key]
return self._build_url(params)
def _determine_request_type(self, context: Dict) -> str:
"""Determine request type based on context."""
if context.get('instance_uid'):
return 'INSTANCE'
elif context.get('series_uid'):
return 'SERIES'
else:
return 'STUDY'
def _build_url(self, params: Dict) -> str:
"""Build final URL with parameters."""
# Filter to supported params
filtered_params = {
k: v for k, v in params.items()
if k in self.config.supported_params or k in self.STANDARD_PARAMS
}
# Use template if provided, otherwise append query params
if self.config.url_template:
url = self.config.url_template.format(**filtered_params)
# Add remaining params as query string
remaining = {k: v for k, v in filtered_params.items()
if f'{{{k}}}' not in self.config.url_template}
if remaining:
url = f"{url}?{urlencode(remaining)}"
return url
else:
query_string = urlencode(filtered_params)
return f"{self.config.base_url}?{query_string}"
def add_authentication(self, url: str, token: str = None,
assertion: str = None) -> str:
"""
Add authentication parameters to URL.
"""
if not self.config.requires_auth:
return url
separator = '&' if '?' in url else '?'
if self.config.auth_method == 'token' and token:
return f"{url}{separator}access_token={token}"
elif self.config.auth_method == 'saml' and assertion:
# SAML assertions typically go in POST body or header
# URL parameter approach for simple cases
import base64
encoded = base64.urlsafe_b64encode(assertion.encode()).decode()
return f"{url}{separator}SAMLResponse={encoded}"
return url
# Example viewer configurations
VIEWER_CONFIGS = {
'ohif': ViewerConfig(
name='OHIF Viewer',
base_url='https://viewer.example.org/viewer',
url_template='https://viewer.example.org/viewer/{studyUID}',
supported_params=['studyUID', 'seriesUID', 'wadoRsEndpoint'],
requires_auth=True,
auth_method='token'
),
'osirix': ViewerConfig(
name='OsiriX',
base_url='osirix://open',
url_template='osirix://open?studyUID={studyUID}',
supported_params=['studyUID', 'seriesUID', 'objectUID', 'wadoRsEndpoint'],
requires_auth=False
),
'horos': ViewerConfig(
name='Horos',
base_url='horos://open',
url_template='horos://open?studyUID={studyUID}',
supported_params=['studyUID', 'wadoRsEndpoint'],
requires_auth=False
),
'weasis': ViewerConfig(
name='Weasis',
base_url='weasis://',
url_template='weasis://$dicom:rs --url "{wadoRsEndpoint}" -r "studyUID={studyUID}"',
supported_params=['studyUID', 'seriesUID', 'objectUID', 'wadoRsEndpoint'],
requires_auth=True,
auth_method='token'
)
}
# Usage example
if __name__ == "__main__":
# Create builder for OHIF viewer
builder = IIDURLBuilder(VIEWER_CONFIGS['ohif'])
# Build study URL
url = builder.build_study_url(
study_uid='1.2.840.113619.2.55.3.123456789',
wado_endpoint='https://pacs.example.org/dicom-web',
patient_id='12345'
)
print(f"Study URL: {url}")
# Build with authentication
auth_url = builder.add_authentication(url, token='eyJhbGciOiJSUzI1NiIs...')
print(f"Authenticated URL: {auth_url}")
Context Mapping
Maps FHIR ImagingStudy resources to IID URL parameters, extracting study UIDs, patient references, and accession numbers.
from typing import Dict, List, Optional, Any
from dataclasses import dataclass
@dataclass
class ViewerContext:
"""Context for IID viewer launch"""
patient_id: str
study_uid: str
series_uid: Optional[str] = None
instance_uid: Optional[str] = None
accession_number: Optional[str] = None
class IIDContextMapper:
"""
Maps FHIR ImagingStudy context to IID URL parameters.
Handles context extraction and parameter building.
"""
def __init__(self, fhir_client):
self.fhir_client = fhir_client
def extract_context_from_imaging_study(self,
imaging_study: Dict) -> ViewerContext:
"""
Extract viewer context from FHIR ImagingStudy resource.
"""
# Extract patient reference
patient_ref = imaging_study.get('subject', {}).get('reference', '')
patient_id = patient_ref.replace('Patient/', '')
# Extract study UID from identifier
study_uid = None
for identifier in imaging_study.get('identifier', []):
if identifier.get('system') == 'urn:dicom:uid':
study_uid = identifier.get('value', '').replace('urn:oid:', '')
break
# Fallback to id if no DICOM UID found
if not study_uid:
study_uid = imaging_study.get('id')
# Extract accession number
accession = None
for identifier in imaging_study.get('identifier', []):
system = identifier.get('system', '')
if 'accession' in system.lower():
accession = identifier.get('value')
break
return ViewerContext(
patient_id=patient_id,
study_uid=study_uid,
accession_number=accession
)
def extract_context_from_series(self,
imaging_study: Dict,
series_uid: str) -> ViewerContext:
"""
Extract viewer context for a specific series.
"""
context = self.extract_context_from_imaging_study(imaging_study)
# Find series in study
for series in imaging_study.get('series', []):
if series.get('uid') == series_uid:
context.series_uid = series_uid
break
return context
def extract_context_from_instance(self,
imaging_study: Dict,
instance_uid: str) -> ViewerContext:
"""
Extract viewer context for a specific instance.
"""
context = self.extract_context_from_imaging_study(imaging_study)
# Find instance in study/series
for series in imaging_study.get('series', []):
for instance in series.get('instance', []):
if instance.get('uid') == instance_uid:
context.series_uid = series.get('uid')
context.instance_uid = instance_uid
break
return context
async def resolve_context_from_reference(self,
reference: str) -> ViewerContext:
"""
Resolve viewer context from a FHIR reference.
Handles ImagingStudy, DiagnosticReport, etc.
"""
resource_type, resource_id = reference.split('/')
if resource_type == 'ImagingStudy':
imaging_study = await self.fhir_client.read('ImagingStudy', resource_id)
return self.extract_context_from_imaging_study(imaging_study)
elif resource_type == 'DiagnosticReport':
report = await self.fhir_client.read('DiagnosticReport', resource_id)
# Find associated ImagingStudy
for media in report.get('media', []):
link = media.get('link', {}).get('reference', '')
if link.startswith('ImagingStudy/'):
return await self.resolve_context_from_reference(link)
raise ValueError(f"Cannot extract imaging context from {resource_type}")
def build_iid_params(self, context: ViewerContext,
include_optional: bool = True) -> Dict[str, str]:
"""
Build IID URL parameters from viewer context.
"""
params = {}
# Required parameters
if context.study_uid:
params['studyUID'] = context.study_uid
# Optional parameters
if include_optional:
if context.patient_id:
params['patientID'] = context.patient_id
if context.series_uid:
params['seriesUID'] = context.series_uid
if context.instance_uid:
params['objectUID'] = context.instance_uid
if context.accession_number:
params['accessionNumber'] = context.accession_number
return params
def map_fhir_to_dicom_identifier(self,
fhir_identifier: Dict) -> Optional[str]:
"""
Map FHIR identifier to DICOM UID format.
"""
system = fhir_identifier.get('system', '')
value = fhir_identifier.get('value', '')
# Handle urn:dicom:uid format
if system == 'urn:dicom:uid':
return value.replace('urn:oid:', '')
# Handle urn:oid format
if value.startswith('urn:oid:'):
return value.replace('urn:oid:', '')
# Handle plain OID
if self._is_valid_uid(value):
return value
return None
def _is_valid_uid(self, value: str) -> bool:
"""Check if value is a valid DICOM UID."""
if not value:
return False
# UID contains only digits and dots
if not all(c.isdigit() or c == '.' for c in value):
return False
# UID doesn't start or end with dot
if value.startswith('.') or value.endswith('.'):
return False
# UID is max 64 characters
if len(value) > 64:
return False
return True
Viewer Integration
Client-side JavaScript for launching DICOM viewers from web applications with support for embedded, popup, and protocol-based viewing.
/**
* IID Facade - Viewer Integration Module
*
* Client-side JavaScript for integrating DICOM viewers with FHIR-based
* applications using IHE Invoke Image Display (IID) profile.
*/
class IIDViewerIntegration {
constructor(config) {
this.config = {
defaultViewer: 'ohif',
wadoRsEndpoint: null,
authTokenProvider: null,
onViewerLaunch: null,
onError: null,
...config
};
this.viewers = {
ohif: {
name: 'OHIF Viewer',
urlTemplate: (params) =>
`${this.config.ohifBaseUrl}/viewer/${params.studyUID}`,
supportsEmbedded: true,
supportsNewWindow: true
},
osirix: {
name: 'OsiriX',
urlTemplate: (params) =>
`osirix://open?studyUID=${params.studyUID}`,
supportsEmbedded: false,
supportsNewWindow: true
},
weasis: {
name: 'Weasis',
urlTemplate: (params) =>
`weasis://$dicom:rs --url "${params.wadoRsEndpoint}" ` +
`-r "studyUID=${params.studyUID}"`,
supportsEmbedded: false,
supportsNewWindow: true
}
};
}
/**
* Launch viewer for a FHIR ImagingStudy resource
*/
async launchFromImagingStudy(imagingStudy, options = {}) {
const context = this.extractContext(imagingStudy);
return this.launchViewer(context, options);
}
/**
* Launch viewer for a specific study UID
*/
async launchStudy(studyUID, options = {}) {
const context = {
studyUID,
requestType: 'STUDY'
};
return this.launchViewer(context, options);
}
/**
* Launch viewer for a specific series
*/
async launchSeries(studyUID, seriesUID, options = {}) {
const context = {
studyUID,
seriesUID,
requestType: 'SERIES'
};
return this.launchViewer(context, options);
}
/**
* Launch viewer for a specific instance
*/
async launchInstance(studyUID, seriesUID, instanceUID, options = {}) {
const context = {
studyUID,
seriesUID,
objectUID: instanceUID,
requestType: 'INSTANCE'
};
return this.launchViewer(context, options);
}
/**
* Extract viewer context from FHIR ImagingStudy
*/
extractContext(imagingStudy) {
const context = {
requestType: 'STUDY'
};
// Extract study UID from identifiers
const identifiers = imagingStudy.identifier || [];
for (const id of identifiers) {
if (id.system === 'urn:dicom:uid') {
context.studyUID = id.value.replace('urn:oid:', '');
break;
}
}
// Fallback to resource id
if (!context.studyUID) {
context.studyUID = imagingStudy.id;
}
// Extract patient ID
if (imagingStudy.subject?.reference) {
context.patientID = imagingStudy.subject.reference.replace('Patient/', '');
}
// Extract accession number
for (const id of identifiers) {
if (id.system?.toLowerCase().includes('accession')) {
context.accessionNumber = id.value;
break;
}
}
return context;
}
/**
* Launch viewer with given context
*/
async launchViewer(context, options = {}) {
const viewerName = options.viewer || this.config.defaultViewer;
const viewer = this.viewers[viewerName];
if (!viewer) {
throw new Error(`Unknown viewer: ${viewerName}`);
}
// Add WADO-RS endpoint
context.wadoRsEndpoint = options.wadoRsEndpoint || this.config.wadoRsEndpoint;
// Build URL
let url = viewer.urlTemplate(context);
// Add authentication if needed
if (this.config.authTokenProvider) {
const token = await this.config.authTokenProvider();
url = this.addAuthToUrl(url, token);
}
// Launch based on mode
const mode = options.mode || 'newWindow';
if (mode === 'embedded' && viewer.supportsEmbedded) {
return this.launchEmbedded(url, options.container);
} else if (mode === 'newWindow' || mode === 'popup') {
return this.launchNewWindow(url, options);
} else if (mode === 'protocol') {
return this.launchProtocol(url);
}
// Callback
if (this.config.onViewerLaunch) {
this.config.onViewerLaunch({ viewer: viewerName, url, context });
}
}
/**
* Launch viewer in embedded iframe
*/
launchEmbedded(url, container) {
const iframe = document.createElement('iframe');
iframe.src = url;
iframe.style.width = '100%';
iframe.style.height = '100%';
iframe.style.border = 'none';
iframe.setAttribute('allowfullscreen', 'true');
if (typeof container === 'string') {
container = document.querySelector(container);
}
if (container) {
container.innerHTML = '';
container.appendChild(iframe);
}
return iframe;
}
/**
* Launch viewer in new window/tab
*/
launchNewWindow(url, options = {}) {
const width = options.width || 1200;
const height = options.height || 800;
const features = `width=${width},height=${height},menubar=no,toolbar=no`;
const win = window.open(url, '_blank', options.popup ? features : '');
if (!win) {
// Popup blocked - try regular link
window.location.href = url;
}
return win;
}
/**
* Launch viewer via custom protocol (e.g., osirix://)
*/
launchProtocol(url) {
// Create hidden iframe to trigger protocol handler
const iframe = document.createElement('iframe');
iframe.style.display = 'none';
iframe.src = url;
document.body.appendChild(iframe);
// Remove after timeout
setTimeout(() => {
document.body.removeChild(iframe);
}, 1000);
}
/**
* Add authentication token to URL
*/
addAuthToUrl(url, token) {
const separator = url.includes('?') ? '&' : '?';
return `${url}${separator}access_token=${encodeURIComponent(token)}`;
}
/**
* Register a custom viewer
*/
registerViewer(name, config) {
this.viewers[name] = config;
}
/**
* Get list of available viewers
*/
getAvailableViewers() {
return Object.entries(this.viewers).map(([key, viewer]) => ({
id: key,
name: viewer.name,
supportsEmbedded: viewer.supportsEmbedded,
supportsNewWindow: viewer.supportsNewWindow
}));
}
}
// Export for module systems
if (typeof module !== 'undefined' && module.exports) {
module.exports = IIDViewerIntegration;
}
// Usage example
/*
const viewer = new IIDViewerIntegration({
defaultViewer: 'ohif',
ohifBaseUrl: 'https://viewer.example.org',
wadoRsEndpoint: 'https://pacs.example.org/dicom-web',
authTokenProvider: async () => {
// Get token from your auth system
return await getAccessToken();
}
});
// Launch from FHIR ImagingStudy
const imagingStudy = await fhirClient.read('ImagingStudy', '12345');
viewer.launchFromImagingStudy(imagingStudy, {
mode: 'newWindow'
});
// Or launch by study UID
viewer.launchStudy('1.2.840.113619.2.55.3.123456789');
*/
Related Patterns
- Imaging Bridge: Imaging Bridge provides study metadata used to construct IID launch URLs
- Event Observer: Event Observer enables ongoing context sync after IID launches the viewer
- Security Strategy: Security tokens may be included in launch context for viewer authentication
Benefits
- Simple Integration: URL-based launching requires minimal integration effort
- Viewer Independence: Works with any IID-compliant viewer
- Context Preservation: Patient and study context passed automatically
- Deep Linking: Direct access to specific studies or series
- Standards Compliance: Implements IHE IID profile
Trade-offs
- Limited Interaction: One-way context passing only
- URL Length: Complex contexts may exceed URL length limits
- Security: Context in URL may be visible in logs
- Viewer Dependency: Requires IID-compliant viewers
References
- IHE IID Profile - Invoke Image Display profile
- DICOMweb - DICOM web services
Combine with FHIRcast
Use IID for initial launch and FHIRcast (Event Observer) for ongoing context synchronization. This provides both simple integration and real-time coordination.