Skip to content

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.

IID Facade Architecture

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:

IID Facade Sequence

Launch Steps

  1. Build Context
  2. Select Viewer
  3. Generate URL
  4. Add Authentication
  5. 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.

IID URL Construction
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.

Context Mapping
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.

Viewer Integration
/**
 * 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');
*/

  • 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


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.