Skip to main content

dicom_viewer 0.6.0 copy "dicom_viewer: ^0.6.0" to clipboard
dicom_viewer: ^0.6.0 copied to clipboard

A pure-Dart DICOM viewer for Flutter. Parses uncompressed, RLE, and JPEG DICOM files, applies VOI windowing and PALETTE LUTs, and renders images across Web, Desktop, and Mobile.

example/lib/main.dart

import 'dart:async';
import 'dart:typed_data';
import 'package:dicom_viewer/dicom_viewer.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';

void main() {
  runApp(const DicomViewerExampleApp());
}

class DicomViewerExampleApp extends StatelessWidget {
  const DicomViewerExampleApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'DICOM Viewer Demo',
      debugShowCheckedModeBanner: false,
      theme: ThemeData.dark(useMaterial3: true).copyWith(
        scaffoldBackgroundColor: const Color(0xFF121212),
        colorScheme: ColorScheme.fromSeed(
          seedColor: Colors.cyan,
          brightness: Brightness.dark,
        ),
      ),
      home: const DicomViewerScreen(),
    );
  }
}

class DicomViewerScreen extends StatefulWidget {
  const DicomViewerScreen({super.key});

  @override
  State<DicomViewerScreen> createState() => _DicomViewerScreenState();
}

class _DicomViewerScreenState extends State<DicomViewerScreen> {
  DicomDataset? _dataset;
  String? _fileName;
  bool _isLoading = false;
  String? _error;
  double? _activeWc;
  double? _activeWw;

  // Interactive Viewer State
  bool _enableZoom = true;
  DicomTool _selectedTool = DicomTool.pan;
  double _currentScale = 1.0;
  Offset _currentOffset = Offset.zero;

  // Multi-Frame Navigation & Cine Playback State
  int _currentFrame = 0;
  Timer? _playTimer;
  bool get _isPlaying => _playTimer != null && _playTimer!.isActive;

  /// Incremented only on explicit preset selection, reset, or new-file load.
  /// Changing this key forces DicomImageWidget to recreate with the intended
  /// initialWindowCenter/Width. Tool switching and frame scrubbing do NOT
  /// increment this — the build() method handles structural changes without
  /// recreating state.
  int _presetGeneration = 0;

  @override
  void dispose() {
    _stopPlayback();
    super.dispose();
  }

  void _stopPlayback() {
    _playTimer?.cancel();
    _playTimer = null;
  }

  void _setFrame(int frame) {
    if (_dataset == null) return;
    final total = _dataset!.numberOfFrames;
    if (total <= 1) return;
    setState(() {
      _currentFrame = frame.clamp(0, total - 1);
    });
  }

  void _togglePlayback() {
    if (_isPlaying) {
      setState(() {
        _stopPlayback();
      });
    } else {
      if (_dataset == null || _dataset!.numberOfFrames <= 1) return;
      setState(() {
        _playTimer = Timer.periodic(const Duration(milliseconds: 200), (_) {
          if (!mounted || _dataset == null || _dataset!.numberOfFrames <= 1) {
            _stopPlayback();
            return;
          }
          setState(() {
            _currentFrame = (_currentFrame + 1) % _dataset!.numberOfFrames;
          });
        });
      });
    }
  }

  Future<void> _pickFile() async {
    _stopPlayback();
    setState(() {
      _isLoading = true;
      _error = null;
    });

    try {
      final result = await FilePicker.platform.pickFiles(
        type: FileType.any,
        withData: true,
      );

      if (result != null && result.files.isNotEmpty) {
        final file = result.files.first;
        final Uint8List? bytes = file.bytes;

        if (bytes == null || bytes.isEmpty) {
          throw Exception('Failed to read file bytes. Selected file is empty.');
        }

        final dataset = DicomDataset.fromBytes(bytes);

        setState(() {
          _dataset = dataset;
          _fileName = file.name;
          _activeWc = dataset.windowCenter;
          _activeWw = dataset.windowWidth;
          _currentScale = 1.0;
          _currentOffset = Offset.zero;
          _currentFrame = 0;
          _isLoading = false;
          _presetGeneration++; // new file → fresh initial values
        });
      } else {
        setState(() {
          _isLoading = false;
        });
      }
    } catch (e) {
      setState(() {
        _error = e.toString();
        _isLoading = false;
      });
    }
  }

  void _loadSampleDicom() {
    _stopPlayback();
    setState(() {
      _isLoading = true;
      _error = null;
    });

    try {
      final bytes = _generateSampleCtDicom();
      final dataset = DicomDataset.fromBytes(bytes);

      setState(() {
        _dataset = dataset;
        _fileName = 'Sample_CT_Head.dcm';
        _activeWc = dataset.windowCenter;
        _activeWw = dataset.windowWidth;
        _currentScale = 1.0;
        _currentOffset = Offset.zero;
        _currentFrame = 0;
        _isLoading = false;
        _presetGeneration++; // new sample → fresh initial values
      });
    } catch (e) {
      setState(() {
        _error = e.toString();
        _isLoading = false;
      });
    }
  }

  void _applyPreset(double center, double width) {
    setState(() {
      _activeWc = center;
      _activeWw = width;
      _presetGeneration++; // explicit preset → recreate widget with preset initial values
    });
  }

  void _resetWindowing() {
    if (_dataset != null) {
      setState(() {
        _activeWc = _dataset!.windowCenter;
        _activeWw = _dataset!.windowWidth;
        _currentScale = 1.0;
        _currentOffset = Offset.zero;
        _presetGeneration++; // explicit reset → recreate widget with original initial values
      });
    }
  }

  void _showDisclaimerDialog(BuildContext context) {
    showDialog<void>(
      context: context,
      builder:
          (ctx) => AlertDialog(
            title: const Row(
              children: [
                Icon(
                  Icons.medical_information_outlined,
                  color: Colors.cyanAccent,
                  size: 22,
                ),
                SizedBox(width: 8),
                Text(
                  'Medical Use Disclaimer',
                  style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
                ),
              ],
            ),
            content: const SingleChildScrollView(
              child: Text(
                'Medical Use Disclaimer: This example is for software development, research, testing, and visualization purposes. It is not a certified or approved medical device and is not intended for primary clinical diagnosis, treatment, or patient-care decisions.\n\nDevelopers are responsible for determining the suitability, validation, regulatory requirements, and intended use of applications built using this library.',
                style: TextStyle(
                  fontSize: 13,
                  color: Colors.white70,
                  height: 1.4,
                ),
              ),
            ),
            actions: [
              TextButton(
                onPressed: () => Navigator.of(ctx).pop(),
                child: const Text(
                  'OK',
                  style: TextStyle(color: Colors.cyanAccent),
                ),
              ),
            ],
          ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('DICOM Viewer Demo'),
        actions: [
          IconButton(
            icon: const Icon(Icons.info_outline),
            tooltip: 'Medical Use Disclaimer',
            onPressed: () => _showDisclaimerDialog(context),
          ),
          IconButton(
            icon: const Icon(Icons.analytics_outlined),
            tooltip: 'Load Sample CT',
            onPressed: _loadSampleDicom,
          ),
          IconButton(
            icon: const Icon(Icons.folder_open),
            tooltip: 'Open DICOM File',
            onPressed: _pickFile,
          ),
        ],
      ),
      body: Column(
        children: [
          // Clinical Preset Buttons & Interactive Mode Toggle Bar
          if (_dataset != null)
            Container(
              padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
              color: const Color(0xFF1A1A1A),
              child: SingleChildScrollView(
                scrollDirection: Axis.horizontal,
                child: Row(
                  children: [
                    // Mode Toggle: Interactive Pan & Zoom vs Legacy Windowing
                    Container(
                      padding: const EdgeInsets.symmetric(
                        horizontal: 8,
                        vertical: 2,
                      ),
                      decoration: BoxDecoration(
                        color: Colors.cyan.withValues(alpha: 0.15),
                        borderRadius: BorderRadius.circular(8),
                        border: Border.all(
                          color: Colors.cyan.withValues(alpha: 0.4),
                        ),
                      ),
                      child: Row(
                        mainAxisSize: MainAxisSize.min,
                        children: [
                          Icon(
                            _enableZoom ? Icons.zoom_in : Icons.contrast,
                            size: 16,
                            color: Colors.cyanAccent,
                          ),
                          const SizedBox(width: 6),
                          Text(
                            _enableZoom ? 'Interactive Mode' : 'Legacy Mode',
                            style: const TextStyle(
                              fontSize: 12,
                              fontWeight: FontWeight.bold,
                              color: Colors.cyanAccent,
                            ),
                          ),
                          Switch(
                            value: _enableZoom,
                            activeThumbColor: Colors.cyanAccent,
                            onChanged: (val) {
                              setState(() {
                                _enableZoom = val;
                              });
                            },
                          ),
                        ],
                      ),
                    ),
                    if (_enableZoom) ...[
                      const SizedBox(width: 8),
                      SegmentedButton<DicomTool>(
                        segments: const [
                          ButtonSegment<DicomTool>(
                            value: DicomTool.pan,
                            icon: Icon(Icons.pan_tool_outlined, size: 14),
                            label: Text(
                              'Pan & Zoom',
                              style: TextStyle(fontSize: 11),
                            ),
                          ),
                          ButtonSegment<DicomTool>(
                            value: DicomTool.windowing,
                            icon: Icon(Icons.contrast, size: 14),
                            label: Text(
                              'Windowing',
                              style: TextStyle(fontSize: 11),
                            ),
                          ),
                          ButtonSegment<DicomTool>(
                            value: DicomTool.measure,
                            icon: Icon(Icons.straighten, size: 14),
                            label: Text(
                              'Measure',
                              style: TextStyle(fontSize: 11),
                            ),
                          ),
                          ButtonSegment<DicomTool>(
                            value: DicomTool.rectangleRoi,
                            icon: Icon(Icons.crop_square, size: 14),
                            label: Text(
                              'Rect ROI',
                              style: TextStyle(fontSize: 11),
                            ),
                          ),
                          ButtonSegment<DicomTool>(
                            value: DicomTool.probe,
                            icon: Icon(Icons.colorize, size: 14),
                            label: Text(
                              'Probe',
                              style: TextStyle(fontSize: 11),
                            ),
                          ),
                        ],
                        selected: {_selectedTool},
                        onSelectionChanged: (newSelection) {
                          setState(() {
                            _selectedTool = newSelection.first;
                          });
                        },
                        style: const ButtonStyle(
                          visualDensity: VisualDensity.compact,
                          tapTargetSize: MaterialTapTargetSize.shrinkWrap,
                        ),
                      ),
                    ],
                    const SizedBox(width: 12),
                    const Text(
                      'Presets: ',
                      style: TextStyle(
                        fontSize: 12,
                        fontWeight: FontWeight.bold,
                        color: Colors.white70,
                      ),
                    ),
                    _PresetChip(
                      label: '🧠 Brain (40/80)',
                      onPressed: () => _applyPreset(40, 80),
                    ),
                    const SizedBox(width: 6),
                    _PresetChip(
                      label: '🫁 Lung (-600/1500)',
                      onPressed: () => _applyPreset(-600, 1500),
                    ),
                    const SizedBox(width: 6),
                    _PresetChip(
                      label: '🦴 Bone (400/1800)',
                      onPressed: () => _applyPreset(400, 1800),
                    ),
                    const SizedBox(width: 6),
                    _PresetChip(
                      label: '🩺 Soft Tissue (40/400)',
                      onPressed: () => _applyPreset(40, 400),
                    ),
                    const SizedBox(width: 6),
                    ActionChip(
                      avatar: const Icon(Icons.refresh, size: 14),
                      label: const Text(
                        'Reset',
                        style: TextStyle(fontSize: 12),
                      ),
                      onPressed: _resetWindowing,
                    ),
                  ],
                ),
              ),
            ),

          // Main Display Area
          Expanded(
            child:
                _isLoading
                    ? const Center(
                      child: Column(
                        mainAxisAlignment: MainAxisAlignment.center,
                        children: [
                          CircularProgressIndicator(),
                          SizedBox(height: 16),
                          Text('Parsing DICOM file...'),
                        ],
                      ),
                    )
                    : _error != null
                    ? Center(
                      child: Padding(
                        padding: const EdgeInsets.all(24.0),
                        child: Column(
                          mainAxisAlignment: MainAxisAlignment.center,
                          children: [
                            const Icon(
                              Icons.error_outline,
                              color: Colors.red,
                              size: 48,
                            ),
                            const SizedBox(height: 16),
                            Text(
                              'Error loading DICOM file:\n$_error',
                              textAlign: TextAlign.center,
                              style: const TextStyle(color: Colors.redAccent),
                            ),
                            const SizedBox(height: 24),
                            ElevatedButton.icon(
                              onPressed: _pickFile,
                              icon: const Icon(Icons.folder_open),
                              label: const Text('Try Another File'),
                            ),
                          ],
                        ),
                      ),
                    )
                    : _dataset == null
                    ? Center(
                      child: Column(
                        mainAxisAlignment: MainAxisAlignment.center,
                        children: [
                          Icon(
                            Icons.medical_services_outlined,
                            size: 64,
                            color: Colors.cyan.shade300,
                          ),
                          const SizedBox(height: 16),
                          const Text(
                            'No DICOM file loaded',
                            style: TextStyle(
                              fontSize: 18,
                              fontWeight: FontWeight.bold,
                            ),
                          ),
                          const SizedBox(height: 8),
                          const Text(
                            'Select a DICOM file (.dcm) or load sample.',
                            style: TextStyle(color: Colors.white60),
                          ),
                          const SizedBox(height: 24),
                          Wrap(
                            spacing: 12,
                            runSpacing: 12,
                            alignment: WrapAlignment.center,
                            children: [
                              ElevatedButton.icon(
                                onPressed: _loadSampleDicom,
                                icon: const Icon(Icons.science_outlined),
                                label: const Text('Load Sample CT'),
                                style: ElevatedButton.styleFrom(
                                  backgroundColor: Colors.cyan.shade800,
                                  foregroundColor: Colors.white,
                                ),
                              ),
                              ElevatedButton.icon(
                                onPressed: _pickFile,
                                icon: const Icon(Icons.folder_open),
                                label: const Text('Select DICOM File'),
                              ),
                            ],
                          ),
                          const SizedBox(height: 32),
                          Padding(
                            padding: const EdgeInsets.symmetric(
                              horizontal: 24.0,
                            ),
                            child: Text(
                              'Medical Use Disclaimer: This example is for software development, research, testing, and visualization purposes. It is not a certified or approved medical device and is not intended for primary clinical diagnosis, treatment, or patient-care decisions.',
                              textAlign: TextAlign.center,
                              style: TextStyle(
                                fontSize: 11,
                                color: Colors.white.withValues(alpha: 0.38),
                                height: 1.3,
                              ),
                            ),
                          ),
                        ],
                      ),
                    )
                    : Stack(
                      children: [
                        Positioned.fill(
                          child: DicomImageWidget(
                            // Key only changes on: explicit preset, reset, or new-file
                            // load (via _presetGeneration). Tool switching and frame scrubbing
                            // do NOT recreate the widget — the build() method
                            // handles those structurally, preserving internal WC/WW state.
                            key: ValueKey(
                              '${_fileName ?? ""}-$_presetGeneration',
                            ),
                            dataset: _dataset!,
                            frameIndex: _currentFrame,
                            initialWindowCenter: _activeWc,
                            initialWindowWidth: _activeWw,
                            enableZoom: _enableZoom,
                            tool: _selectedTool,
                            showOverlay: true,
                            onWindowChanged: (center, width) {
                              // Track silently — no setState to avoid unnecessary
                              // rebuilds during drag. Values are up-to-date whenever
                              // _presetGeneration increments and the widget recreates.
                              _activeWc = center;
                              _activeWw = width;
                            },
                            onViewChanged: (scale, offset) {
                              setState(() {
                                _currentScale = scale;
                                _currentOffset = offset;
                              });
                            },
                          ),
                        ),

                        // Gesture Legend / Guidance Card
                        Positioned(
                          top: 12,
                          right: 12,
                          child: Container(
                            padding: const EdgeInsets.all(10),
                            decoration: BoxDecoration(
                              color: Colors.black.withValues(alpha: 0.75),
                              borderRadius: BorderRadius.circular(8),
                              border: Border.all(color: Colors.white24),
                            ),
                            child: Column(
                              crossAxisAlignment: CrossAxisAlignment.start,
                              children: [
                                Text(
                                  !_enableZoom
                                      ? 'Legacy Windowing Mode:'
                                      : _selectedTool == DicomTool.pan
                                      ? 'Interactive Pan & Zoom Tool:'
                                      : _selectedTool == DicomTool.windowing
                                      ? 'Interactive Windowing Tool:'
                                      : _selectedTool == DicomTool.rectangleRoi
                                      ? 'Interactive Rectangle ROI Tool:'
                                      : _selectedTool == DicomTool.probe
                                      ? 'Interactive Pixel Probe Tool:'
                                      : 'Interactive Measurement Tool:',
                                  style: TextStyle(
                                    fontSize: 11,
                                    fontWeight: FontWeight.bold,
                                    color:
                                        (_selectedTool == DicomTool.measure ||
                                                _selectedTool ==
                                                    DicomTool.rectangleRoi ||
                                                _selectedTool ==
                                                    DicomTool.probe)
                                            ? Colors.yellowAccent
                                            : Colors.cyanAccent,
                                  ),
                                ),
                                const SizedBox(height: 4),
                                if (_enableZoom) ...[
                                  if (_selectedTool == DicomTool.pan)
                                    const Text(
                                      '🖐️ Drag: Pan Image Viewport',
                                      style: TextStyle(
                                        fontSize: 10,
                                        color: Colors.white70,
                                      ),
                                    )
                                  else if (_selectedTool == DicomTool.windowing)
                                    const Text(
                                      '🖐️ Drag: Adjust Contrast / Brightness',
                                      style: TextStyle(
                                        fontSize: 10,
                                        color: Colors.white70,
                                      ),
                                    )
                                  else if (_selectedTool ==
                                      DicomTool.rectangleRoi)
                                    const Text(
                                      '📐 Drag: Draw Rectangle ROI',
                                      style: TextStyle(
                                        fontSize: 10,
                                        color: Colors.yellowAccent,
                                      ),
                                    )
                                  else if (_selectedTool == DicomTool.probe)
                                    const Text(
                                      '🎯 Hover: Inspect Pixel Coordinate & Intensity',
                                      style: TextStyle(
                                        fontSize: 10,
                                        color: Colors.yellowAccent,
                                      ),
                                    )
                                  else
                                    const Text(
                                      '📏 Drag: Draw 2D Distance Caliper Line',
                                      style: TextStyle(
                                        fontSize: 10,
                                        color: Colors.yellowAccent,
                                      ),
                                    ),
                                  const Text(
                                    '🔍 Pinch / Wheel: Zoom Image (0.5x - 5.0x)',
                                    style: TextStyle(
                                      fontSize: 10,
                                      color: Colors.white70,
                                    ),
                                  ),
                                ] else ...[
                                  const Text(
                                    '🖐️ Drag: Adjust Contrast / Brightness',
                                    style: TextStyle(
                                      fontSize: 10,
                                      color: Colors.white70,
                                    ),
                                  ),
                                ],
                                const Text(
                                  '👆 Double-Tap: Reset Scale & Windowing',
                                  style: TextStyle(
                                    fontSize: 10,
                                    color: Colors.white70,
                                  ),
                                ),
                              ],
                            ),
                          ),
                        ),
                      ],
                    ),
          ),

          // Multi-Frame Navigation Bar (Only visible when numberOfFrames > 1)
          if (_dataset != null && _dataset!.numberOfFrames > 1)
            Container(
              padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
              color: const Color(0xFF161616),
              child: Row(
                children: [
                  IconButton(
                    icon: Icon(
                      _isPlaying
                          ? Icons.pause_circle_filled
                          : Icons.play_circle_filled,
                    ),
                    color: Colors.cyanAccent,
                    iconSize: 28,
                    tooltip:
                        _isPlaying
                            ? 'Pause Cine Playback'
                            : 'Start Cine Playback',
                    onPressed: _togglePlayback,
                  ),
                  IconButton(
                    icon: const Icon(Icons.skip_previous, size: 20),
                    tooltip: 'Previous Frame',
                    onPressed:
                        _currentFrame > 0
                            ? () {
                              _stopPlayback();
                              _setFrame(_currentFrame - 1);
                            }
                            : null,
                  ),
                  Expanded(
                    child: SliderTheme(
                      data: SliderTheme.of(context).copyWith(
                        activeTrackColor: Colors.cyanAccent,
                        inactiveTrackColor: Colors.white24,
                        thumbColor: Colors.cyanAccent,
                        overlayColor: Colors.cyanAccent.withValues(alpha: 0.2),
                        trackHeight: 3,
                        thumbShape: const RoundSliderThumbShape(
                          enabledThumbRadius: 6,
                        ),
                      ),
                      child: Slider(
                        value: _currentFrame.toDouble().clamp(
                          0.0,
                          (_dataset!.numberOfFrames - 1).toDouble(),
                        ),
                        min: 0.0,
                        max: (_dataset!.numberOfFrames - 1).toDouble(),
                        divisions: _dataset!.numberOfFrames - 1,
                        onChanged: (val) {
                          _stopPlayback();
                          _setFrame(val.round());
                        },
                      ),
                    ),
                  ),
                  IconButton(
                    icon: const Icon(Icons.skip_next, size: 20),
                    tooltip: 'Next Frame',
                    onPressed:
                        _currentFrame < _dataset!.numberOfFrames - 1
                            ? () {
                              _stopPlayback();
                              _setFrame(_currentFrame + 1);
                            }
                            : null,
                  ),
                  const SizedBox(width: 8),
                  Container(
                    padding: const EdgeInsets.symmetric(
                      horizontal: 10,
                      vertical: 4,
                    ),
                    decoration: BoxDecoration(
                      color: Colors.white10,
                      borderRadius: BorderRadius.circular(6),
                      border: Border.all(color: Colors.white24),
                    ),
                    child: Text(
                      'Frame ${_currentFrame + 1} / ${_dataset!.numberOfFrames}',
                      style: const TextStyle(
                        fontSize: 12,
                        fontWeight: FontWeight.bold,
                        color: Colors.cyanAccent,
                      ),
                    ),
                  ),
                ],
              ),
            ),

          // Metadata & Controls Footer
          if (_dataset != null)
            Container(
              padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
              color: const Color(0xFF1E1E1E),
              child: Row(
                children: [
                  Expanded(
                    child: Text(
                      'File: ${_fileName ?? "Unsaved"} | Modality: ${_dataset!.modality} | Size: ${_dataset!.columns}x${_dataset!.rows}${_dataset!.numberOfFrames > 1 ? " | Frame: ${_currentFrame + 1}/${_dataset!.numberOfFrames}" : ""}\nRescale: ${_dataset!.rescaleSlope}x + ${_dataset!.rescaleIntercept} | Zoom: ${_currentScale.toStringAsFixed(2)}x | Offset: (${_currentOffset.dx.toStringAsFixed(0)}, ${_currentOffset.dy.toStringAsFixed(0)})',
                      style: const TextStyle(
                        fontSize: 11,
                        color: Colors.white70,
                      ),
                    ),
                  ),
                  ElevatedButton.icon(
                    onPressed: _pickFile,
                    icon: const Icon(Icons.folder_open, size: 16),
                    label: const Text('Open'),
                  ),
                ],
              ),
            ),
        ],
      ),
    );
  }

  Uint8List _generateSampleCtDicom() {
    final builder = BytesBuilder();
    // 128 bytes preamble
    builder.add(List.filled(128, 0));
    // Prefix 'DICM'
    builder.add([0x44, 0x49, 0x43, 0x4D]);

    // Group 0002 Meta Header
    _writeTag(
      builder,
      0x0002,
      0x0010,
      'UI',
      '1.2.840.10008.1.2.1',
    ); // Explicit VR Little Endian

    // Dataset Metadata
    _writeTag(builder, 0x0008, 0x0060, 'CS', 'CT');
    _writeTag(builder, 0x0010, 0x0010, 'PN', 'Sample^HeadCT');
    _writeTag(builder, 0x0010, 0x0020, 'LO', 'CT-10293');
    _writeTag(builder, 0x0028, 0x0002, 'US', 1); // SamplesPerPixel
    _writeTag(builder, 0x0028, 0x0004, 'CS', 'MONOCHROME2');
    _writeTag(builder, 0x0028, 0x0010, 'US', 256); // Rows
    _writeTag(builder, 0x0028, 0x0011, 'US', 256); // Columns
    _writeTag(builder, 0x0028, 0x0100, 'US', 16); // BitsAllocated
    _writeTag(builder, 0x0028, 0x0101, 'US', 16); // BitsStored
    _writeTag(builder, 0x0028, 0x0102, 'US', 15); // HighBit
    _writeTag(
      builder,
      0x0028,
      0x0103,
      'US',
      1,
    ); // PixelRepresentation (Signed 2's complement)
    _writeTag(builder, 0x0028, 0x1050, 'DS', '40'); // WindowCenter
    _writeTag(builder, 0x0028, 0x1051, 'DS', '400'); // WindowWidth
    _writeTag(builder, 0x0028, 0x1052, 'DS', '-1024'); // RescaleIntercept
    _writeTag(builder, 0x0028, 0x1053, 'DS', '1.0'); // RescaleSlope

    // Generate 256x256 synthetic CT head phantom pixel data
    final pixelsBuilder = BytesBuilder();
    const width = 256;
    const height = 256;
    const centerX = 128;
    const centerY = 128;

    for (int y = 0; y < height; y++) {
      for (int x = 0; x < width; x++) {
        final dx = x - centerX;
        final dy = y - centerY;
        final distSq = dx * dx + dy * dy;

        int hu = -1000; // Air
        if (distSq < 110 * 110) {
          if (distSq > 95 * 95) {
            hu = 1000; // Skull Bone
          } else if (distSq < 30 * 30) {
            hu = 15; // Ventricles CSF
          } else {
            hu = 40; // Brain Soft Tissue
          }
        }

        final storedPixel = hu + 1024; // Reverse Rescale Intercept (-1024)
        final ByteData bd = ByteData(2)
          ..setInt16(0, storedPixel, Endian.little);
        pixelsBuilder.add(bd.buffer.asUint8List());
      }
    }

    final pixelBytes = pixelsBuilder.toBytes();
    // Pixel Data (7FE0,0010) OB/OW
    builder.add([0xE0, 0x7F, 0x10, 0x00]); // Tag (7FE0,0010)
    builder.add([0x4F, 0x57]); // VR 'OW'
    builder.add([0x00, 0x00]); // Reserved
    final lenBd = ByteData(4)..setUint32(0, pixelBytes.length, Endian.little);
    builder.add(lenBd.buffer.asUint8List());
    builder.add(pixelBytes);

    return builder.toBytes();
  }

  void _writeTag(
    BytesBuilder bb,
    int group,
    int element,
    String vrStr,
    dynamic val,
  ) {
    bb.add([
      group & 0xFF,
      (group >> 8) & 0xFF,
      element & 0xFF,
      (element >> 8) & 0xFF,
    ]);
    final vrBytes = vrStr.codeUnits;
    bb.add(vrBytes);

    Uint8List valBytes;
    if (vrStr == 'US') {
      valBytes =
          (ByteData(2)
            ..setUint16(0, val as int, Endian.little)).buffer.asUint8List();
    } else {
      final strVal = val.toString();
      var b = strVal.codeUnits;
      if (b.length % 2 != 0) {
        b = [...b, 0x20]; // Space padding
      }
      valBytes = Uint8List.fromList(b);
    }

    if (vrStr == 'OB' ||
        vrStr == 'OW' ||
        vrStr == 'SQ' ||
        vrStr == 'UN' ||
        vrStr == 'UT') {
      bb.add([0x00, 0x00]);
      final lenBd = ByteData(4)..setUint32(0, valBytes.length, Endian.little);
      bb.add(lenBd.buffer.asUint8List());
    } else {
      final lenBd = ByteData(2)..setUint16(0, valBytes.length, Endian.little);
      bb.add(lenBd.buffer.asUint8List());
    }
    bb.add(valBytes);
  }
}

class _PresetChip extends StatelessWidget {
  final String label;
  final VoidCallback onPressed;

  const _PresetChip({required this.label, required this.onPressed});

  @override
  Widget build(BuildContext context) {
    return ActionChip(
      label: Text(label, style: const TextStyle(fontSize: 12)),
      onPressed: onPressed,
      backgroundColor: const Color(0xFF2A2A2A),
    );
  }
}
0
likes
160
points
400
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

A pure-Dart DICOM viewer for Flutter. Parses uncompressed, RLE, and JPEG DICOM files, applies VOI windowing and PALETTE LUTs, and renders images across Web, Desktop, and Mobile.

Repository (GitHub)
View/report issues

Topics

#dicom #medical-imaging #radiology #viewer #flutter

License

MIT (license)

Dependencies

flutter

More

Packages that depend on dicom_viewer