diff --git a/QuickLook.Plugin/QuickLook.Plugin.ImageViewer/AnimatedImage/Providers/DicomProvider.cs b/QuickLook.Plugin/QuickLook.Plugin.ImageViewer/AnimatedImage/Providers/DicomProvider.cs new file mode 100644 index 0000000..86ec346 --- /dev/null +++ b/QuickLook.Plugin/QuickLook.Plugin.ImageViewer/AnimatedImage/Providers/DicomProvider.cs @@ -0,0 +1,284 @@ +// Copyright © 2017-2026 QL-Win Contributors +// +// This file is part of QuickLook program. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using FellowOakDicom; +using FellowOakDicom.Imaging; +using QuickLook.Common.Helpers; +using QuickLook.Common.Plugin; +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Media; +using System.Windows.Media.Animation; +using System.Windows.Media.Imaging; + +namespace QuickLook.Plugin.ImageViewer.AnimatedImage.Providers; + +internal sealed class DicomProvider : AnimationProvider +{ + private static readonly HashSet SupportedTransferSyntaxes = + [ + DicomTransferSyntax.ImplicitVRLittleEndian, + DicomTransferSyntax.ExplicitVRLittleEndian, + DicomTransferSyntax.ExplicitVRBigEndian, + ]; + + private static readonly object ImageManagerLock = new(); + private static bool _imageManagerInitialized; + + private readonly DicomDataset _dataset; + private readonly DicomImage _dicomImage; + private readonly object _renderLock = new(); + + public DicomProvider(Uri path, MetaProvider meta, ContextObject contextObject) : base(path, meta, contextObject) + { + EnsureWpfImageManager(); + + _dataset = LoadDicomDataset(path.LocalPath); + if (_dataset == null) + return; + + try + { + _dicomImage = new DicomImage(_dataset); + Animator = new Int32AnimationUsingKeyFrames(); + + var duration = GetFrameDuration(_dataset); + var accumulator = TimeSpan.Zero; + + for (var i = 0; i < _dicomImage.NumberOfFrames; i++) + { + Animator.KeyFrames.Add(new DiscreteInt32KeyFrame(i, KeyTime.FromTimeSpan(accumulator))); + accumulator += duration; + } + + if (accumulator == TimeSpan.Zero && _dicomImage.NumberOfFrames > 0) + accumulator = TimeSpan.FromMilliseconds(100); + + Animator.Duration = new Duration(accumulator); + Animator.RepeatBehavior = RepeatBehavior.Forever; + } + catch (Exception e) + { + ProcessHelper.WriteLog(e.ToString()); + _dicomImage = null; + } + } + + public override Task GetThumbnail(Size renderSize) + { + return new Task(() => + { + if (_dicomImage == null) + return null; + + lock (_renderLock) + { + try + { + using var rendered = _dicomImage.RenderImage(0); + var image = TryGetBitmapSource(rendered); + if (image == null) + return null; + + var scaled = ScaleToFit(image, renderSize); + Helper.DpiHack(scaled); + scaled.Freeze(); + + return scaled; + } + catch (Exception e) + { + ProcessHelper.WriteLog(e.ToString()); + return null; + } + } + }); + } + + public override Task GetRenderedFrame(int index) + { + return new Task(() => + { + if (_dicomImage == null) + return null; + + lock (_renderLock) + { + try + { + if (index < 0 || index >= _dicomImage.NumberOfFrames) + return null; + + using var rendered = _dicomImage.RenderImage(index); + var image = TryGetBitmapSource(rendered); + if (image == null) + return null; + + Helper.DpiHack(image); + image.Freeze(); + + return image; + } + catch (Exception e) + { + ProcessHelper.WriteLog(e.ToString()); + return null; + } + } + }); + } + + public override void Dispose() + { + // DicomImage logic does not require disposal, it holds the Dataset which is in memory. + } + + private DicomDataset LoadDicomDataset(string path) + { + try + { + var file = DicomFile.Open(path); + var transferSyntax = file.FileMetaInfo?.TransferSyntax ?? file.Dataset.InternalTransferSyntax; + + // Strict check + if (!SupportedTransferSyntaxes.Contains(transferSyntax)) + { + return null; + } + + var pixelData = DicomPixelData.Create(file.Dataset, false); + if (!IsSupportedPixelData(pixelData)) + return null; + + return file.Dataset; + } + catch (Exception e) + { + ProcessHelper.WriteLog(e.ToString()); + return null; + } + } + + private static TimeSpan GetFrameDuration(DicomDataset dataset) + { + if (TryGetSingleValue(dataset, DicomTag.FrameTime, out double frameTime)) + { + return TimeSpan.FromMilliseconds(frameTime); + } + + if (TryGetSingleValue(dataset, DicomTag.RecommendedDisplayFrameRate, out double frameRate) && frameRate > 0) + { + // FPS to duration + return TimeSpan.FromSeconds(1.0 / frameRate); + } + + return TimeSpan.FromMilliseconds(100); + } + + private static bool TryGetSingleValue(DicomDataset dataset, DicomTag tag, out T value) + { + value = default; + try + { + if (dataset.Contains(tag)) + { + value = dataset.GetValue(tag, 0); // Use GetValue with index 0 which is safer or simple GetValue(tag, 0) + return true; + } + return false; + } + catch + { + return false; + } + } + + private static BitmapSource TryGetBitmapSource(IImage image) + { + try + { + var wpfBitmap = image.AsWriteableBitmap(); + return wpfBitmap; + } + catch (Exception e) + { + ProcessHelper.WriteLog($"Bitmap conversion failed: {e}"); + return null; + } + } + + private static void EnsureWpfImageManager() + { + if (_imageManagerInitialized) + return; + + lock (ImageManagerLock) + { + if (_imageManagerInitialized) + return; + + try + { + new DicomSetupBuilder() + .RegisterServices(s => + { + s.AddFellowOakDicom(); + s.AddImageManager(); + }) + .Build(); + } + catch (Exception e) + { + ProcessHelper.WriteLog($"DICOM Init Failed: {e}"); + } + + _imageManagerInitialized = true; + } + } + + private static bool IsSupportedPixelData(DicomPixelData pixelData) + { + var photometric = pixelData.PhotometricInterpretation; + + if (photometric == PhotometricInterpretation.YbrFull422 || + photometric == PhotometricInterpretation.YbrPartial422) + return false; + + return photometric == PhotometricInterpretation.Monochrome1 || + photometric == PhotometricInterpretation.Monochrome2 || + photometric == PhotometricInterpretation.Rgb || + photometric == PhotometricInterpretation.YbrFull; + } + + private static BitmapSource ScaleToFit(BitmapSource image, Size renderSize) + { + if (image == null) + return null; + + if (renderSize.Width <= 0 || renderSize.Height <= 0) + return image; + + var scale = Math.Min(renderSize.Width / image.PixelWidth, renderSize.Height / image.PixelHeight); + if (double.IsNaN(scale) || double.IsInfinity(scale) || scale <= 0 || scale >= 1) + return image; + + return new TransformedBitmap(image, new ScaleTransform(scale, scale)); + } +} diff --git a/QuickLook.Plugin/QuickLook.Plugin.ImageViewer/MetaProvider.Discom.cs b/QuickLook.Plugin/QuickLook.Plugin.ImageViewer/MetaProvider.Discom.cs new file mode 100644 index 0000000..981b6fc --- /dev/null +++ b/QuickLook.Plugin/QuickLook.Plugin.ImageViewer/MetaProvider.Discom.cs @@ -0,0 +1,53 @@ +// Copyright © 2017-2026 QL-Win Contributors +// +// This file is part of QuickLook program. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using FellowOakDicom; +using System; +using System.Diagnostics; +using System.IO; +using System.Windows; + +namespace QuickLook.Plugin.ImageViewer; + +public partial class MetaProvider +{ + private static bool IsDicomFile(string path) + { + var extension = Path.GetExtension(path); + return extension.Equals(".dcm", StringComparison.OrdinalIgnoreCase) + || extension.Equals(".dicom", StringComparison.OrdinalIgnoreCase); + } + + private static Size TryGetDicomSize(string path) + { + try + { + var file = DicomFile.Open(path); + var rows = file.Dataset.GetSingleValueOrDefault(DicomTag.Rows, 0); + var columns = file.Dataset.GetSingleValueOrDefault(DicomTag.Columns, 0); + + if (rows > 0 && columns > 0) + return new Size(columns, rows); + } + catch (Exception e) + { + Debug.WriteLine(e); + } + + return Size.Empty; + } +} diff --git a/QuickLook.Plugin/QuickLook.Plugin.ImageViewer/MetaProvider.cs b/QuickLook.Plugin/QuickLook.Plugin.ImageViewer/MetaProvider.cs index 5be0723..1c39a91 100644 --- a/QuickLook.Plugin/QuickLook.Plugin.ImageViewer/MetaProvider.cs +++ b/QuickLook.Plugin/QuickLook.Plugin.ImageViewer/MetaProvider.cs @@ -26,7 +26,7 @@ using System.Xml; namespace QuickLook.Plugin.ImageViewer; -public class MetaProvider +public partial class MetaProvider { private readonly SortedDictionary _cache = []; // [key, [label, value]] @@ -79,6 +79,13 @@ public class MetaProvider if (int.TryParse(w_.Item2, out var w) && int.TryParse(h_.Item2, out var h)) return new Size(w, h); + if (IsDicomFile(_path)) + { + var dicomSize = TryGetDicomSize(_path); + if (!dicomSize.IsEmpty) + return dicomSize; + } + // fallback try { diff --git a/QuickLook.Plugin/QuickLook.Plugin.ImageViewer/Plugin.cs b/QuickLook.Plugin/QuickLook.Plugin.ImageViewer/Plugin.cs index e1f001b..04020e9 100644 --- a/QuickLook.Plugin/QuickLook.Plugin.ImageViewer/Plugin.cs +++ b/QuickLook.Plugin/QuickLook.Plugin.ImageViewer/Plugin.cs @@ -36,7 +36,7 @@ public sealed partial class Plugin : IViewer, IMoreMenu ".apng", ".ari", ".arw", ".avif", ".ani", ".bay", ".bmp", ".cap", ".cr2", ".cr3", ".crw", ".cur", - ".dcr", ".dcs", ".dds", ".dng", ".drf", + ".dcr", ".dcs", ".dds", ".dng", ".drf", ".dcm", ".dicom", ".eip", ".emf", ".erf", ".exr", ".fff", ".gif", @@ -107,6 +107,9 @@ public sealed partial class Plugin : IViewer, IMoreMenu new KeyValuePair([".svg"], typeof(SvgProvider))); #endif + AnimatedImage.AnimatedImage.Providers.Add( + new KeyValuePair([".dcm", ".dicom"], + typeof(DicomProvider))); AnimatedImage.AnimatedImage.Providers.Add( new KeyValuePair(["*"], typeof(ImageMagickProvider))); diff --git a/QuickLook.Plugin/QuickLook.Plugin.ImageViewer/QuickLook.Plugin.ImageViewer.csproj b/QuickLook.Plugin/QuickLook.Plugin.ImageViewer/QuickLook.Plugin.ImageViewer.csproj index 2f6dc3f..1cefa30 100644 --- a/QuickLook.Plugin/QuickLook.Plugin.ImageViewer/QuickLook.Plugin.ImageViewer.csproj +++ b/QuickLook.Plugin/QuickLook.Plugin.ImageViewer/QuickLook.Plugin.ImageViewer.csproj @@ -55,6 +55,12 @@ + + all + + + all + all diff --git a/SUPPORTED_FORMATS.md b/SUPPORTED_FORMATS.md index 2322708..c0ce515 100644 --- a/SUPPORTED_FORMATS.md +++ b/SUPPORTED_FORMATS.md @@ -143,6 +143,7 @@ Update not completed yet... - `.cur` (Windows cursor) - `.dcr`, `.dcs`, `.drf` (Kodak RAW image) - `.dds` (DirectDraw Surface) +- `.dcm`, `.dicom` (DICOM medical image, preview) - `.dng` (Digital Negative RAW) - `.eip` (Capture One Enhanced Image Package) - `.emf` (Enhanced Metafile)