mirror of
https://github.com/QL-Win/QuickLook.git
synced 2026-02-27 01:00:11 +08:00
Add DICOM image support to ImageViewer plugin #1866
This is not a permanent support and will be adjusted to the plugin later.
This commit is contained in:
@@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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<DicomTransferSyntax> 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<BitmapSource> GetThumbnail(Size renderSize)
|
||||||
|
{
|
||||||
|
return new Task<BitmapSource>(() =>
|
||||||
|
{
|
||||||
|
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<BitmapSource> GetRenderedFrame(int index)
|
||||||
|
{
|
||||||
|
return new Task<BitmapSource>(() =>
|
||||||
|
{
|
||||||
|
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<T>(DicomDataset dataset, DicomTag tag, out T value)
|
||||||
|
{
|
||||||
|
value = default;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (dataset.Contains(tag))
|
||||||
|
{
|
||||||
|
value = dataset.GetValue<T>(tag, 0); // Use GetValue with index 0 which is safer or simple GetValue<T>(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<WPFImageManager>();
|
||||||
|
})
|
||||||
|
.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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@ using System.Xml;
|
|||||||
|
|
||||||
namespace QuickLook.Plugin.ImageViewer;
|
namespace QuickLook.Plugin.ImageViewer;
|
||||||
|
|
||||||
public class MetaProvider
|
public partial class MetaProvider
|
||||||
{
|
{
|
||||||
private readonly SortedDictionary<string, (string, string)> _cache = []; // [key, [label, value]]
|
private readonly SortedDictionary<string, (string, string)> _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))
|
if (int.TryParse(w_.Item2, out var w) && int.TryParse(h_.Item2, out var h))
|
||||||
return new Size(w, h);
|
return new Size(w, h);
|
||||||
|
|
||||||
|
if (IsDicomFile(_path))
|
||||||
|
{
|
||||||
|
var dicomSize = TryGetDicomSize(_path);
|
||||||
|
if (!dicomSize.IsEmpty)
|
||||||
|
return dicomSize;
|
||||||
|
}
|
||||||
|
|
||||||
// fallback
|
// fallback
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ public sealed partial class Plugin : IViewer, IMoreMenu
|
|||||||
".apng", ".ari", ".arw", ".avif", ".ani",
|
".apng", ".ari", ".arw", ".avif", ".ani",
|
||||||
".bay", ".bmp",
|
".bay", ".bmp",
|
||||||
".cap", ".cr2", ".cr3", ".crw", ".cur",
|
".cap", ".cr2", ".cr3", ".crw", ".cur",
|
||||||
".dcr", ".dcs", ".dds", ".dng", ".drf",
|
".dcr", ".dcs", ".dds", ".dng", ".drf", ".dcm", ".dicom",
|
||||||
".eip", ".emf", ".erf", ".exr",
|
".eip", ".emf", ".erf", ".exr",
|
||||||
".fff",
|
".fff",
|
||||||
".gif",
|
".gif",
|
||||||
@@ -107,6 +107,9 @@ public sealed partial class Plugin : IViewer, IMoreMenu
|
|||||||
new KeyValuePair<string[], Type>([".svg"],
|
new KeyValuePair<string[], Type>([".svg"],
|
||||||
typeof(SvgProvider)));
|
typeof(SvgProvider)));
|
||||||
#endif
|
#endif
|
||||||
|
AnimatedImage.AnimatedImage.Providers.Add(
|
||||||
|
new KeyValuePair<string[], Type>([".dcm", ".dicom"],
|
||||||
|
typeof(DicomProvider)));
|
||||||
AnimatedImage.AnimatedImage.Providers.Add(
|
AnimatedImage.AnimatedImage.Providers.Add(
|
||||||
new KeyValuePair<string[], Type>(["*"],
|
new KeyValuePair<string[], Type>(["*"],
|
||||||
typeof(ImageMagickProvider)));
|
typeof(ImageMagickProvider)));
|
||||||
|
|||||||
@@ -55,6 +55,12 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="fo-dicom" Version="5.2.5">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="fo-dicom.Imaging.Desktop" Version="5.2.5">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
<PackageReference Include="System.IO.Compression" Version="4.3.0" />
|
<PackageReference Include="System.IO.Compression" Version="4.3.0" />
|
||||||
<PackageReference Include="QuickLook.ImageGlass.WebP" Version="1.4.0">
|
<PackageReference Include="QuickLook.ImageGlass.WebP" Version="1.4.0">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ Update not completed yet...
|
|||||||
- `.cur` (Windows cursor)
|
- `.cur` (Windows cursor)
|
||||||
- `.dcr`, `.dcs`, `.drf` (Kodak RAW image)
|
- `.dcr`, `.dcs`, `.drf` (Kodak RAW image)
|
||||||
- `.dds` (DirectDraw Surface)
|
- `.dds` (DirectDraw Surface)
|
||||||
|
- `.dcm`, `.dicom` (DICOM medical image, preview)
|
||||||
- `.dng` (Digital Negative RAW)
|
- `.dng` (Digital Negative RAW)
|
||||||
- `.eip` (Capture One Enhanced Image Package)
|
- `.eip` (Capture One Enhanced Image Package)
|
||||||
- `.emf` (Enhanced Metafile)
|
- `.emf` (Enhanced Metafile)
|
||||||
|
|||||||
Reference in New Issue
Block a user