From c08a0298e2277bab5e82a229678c05cd07506266 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 11:49:22 +0000 Subject: [PATCH 1/2] Cancel abandoned first loads and surface expansion failures First load not cancellable: Plugin.View ran MiqParser.ParsePartial with no token and only checked cancellation after the full read/decompress, so arrowing through large files left every abandoned load running. ParsePartial now takes a CancellationToken and threads it through the full-parse fallbacks, the volume-0 reads (ReadFully) and GunzipPartial. Reads are chunked at 4 MB so the token is checked during multi-GB reads, including the managed Gunzip loop. Cancellation escapes the partial-load fallback catches instead of triggering a full parse. Expansion failures swallowed: StartExpansion caught every exception, leaving the scrubber on "loading..." forever after an out-of-memory, I/O error or changed file. Only cancellation is now ignored; any other failure calls MiqTriPlanarControl.ExpansionFailed, which shows "first volume only (full load failed)" with the reason in a tooltip. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XqBwhnTRsqxk3SEvp9z4T9 --- MIQ.Core/Parsing/MiqBinaryReader.cs | 19 +++-- MIQ.Core/Parsing/MiqParser.cs | 59 +++++++------ MIQ.Tests/ParseCancellationTests.cs | 95 +++++++++++++++++++++ QuickLook.Plugin.MIQ/MiqTriPlanarControl.cs | 15 ++++ QuickLook.Plugin.MIQ/Plugin.cs | 25 +++++- QuickLook.Plugin.MIQ/WpfPreviewRenderer.cs | 17 ++-- 6 files changed, 191 insertions(+), 39 deletions(-) create mode 100644 MIQ.Tests/ParseCancellationTests.cs diff --git a/MIQ.Core/Parsing/MiqBinaryReader.cs b/MIQ.Core/Parsing/MiqBinaryReader.cs index 175bfcc..88e3fd4 100644 --- a/MIQ.Core/Parsing/MiqBinaryReader.cs +++ b/MIQ.Core/Parsing/MiqBinaryReader.cs @@ -167,6 +167,10 @@ internal static uint TrustedIsize(uint isize, long compressedLength) => // safety margin (gzip framing only adds bytes, lowering the real ratio). private const long MaxDeflateExpansion = 1100; + // Largest single Read in the cancellable loops: one Read can otherwise fill a + // multi-GB buffer before the token is checked again. + private const int CancelChunk = 4 * 1024 * 1024; + /// Decompress into a single buffer pre-sized from /// the gzip ISIZE — avoids the repeated MemoryStream reallocations + final /// ToArray copy (2–3× the decompressed size) the naive approach incurs. @@ -183,7 +187,7 @@ public static byte[] Gunzip(Stream input, uint sizeHint, CancellationToken ct = var total = 0; int n; while (total < buf.Length && - (n = gzip.Read(buf, total, buf.Length - total)) > 0) + (n = gzip.Read(buf, total, Math.Min(CancelChunk, buf.Length - total))) > 0) { total += n; ct.ThrowIfCancellationRequested(); // abandon promptly on nav-away @@ -212,8 +216,9 @@ public static byte[] Gunzip(Stream input, uint sizeHint, CancellationToken ct = /// Decompress at most bytes from a gzip stream, /// stopping as soon as the buffer is full (no further input is consumed). - /// Returns fewer bytes when the stream ends before the limit. - public static byte[] GunzipPartial(Stream input, int maxBytes) + /// Returns fewer bytes when the stream ends before the limit. Checks + /// before each read (abandon promptly on nav-away). + public static byte[] GunzipPartial(Stream input, int maxBytes, CancellationToken ct = default) { try { @@ -221,9 +226,13 @@ public static byte[] GunzipPartial(Stream input, int maxBytes) using var gzip = new GZipStream(input, CompressionMode.Decompress, leaveOpen: true); var buf = new byte[maxBytes]; var total = 0; - int n; - while (total < maxBytes && (n = gzip.Read(buf, total, maxBytes - total)) > 0) + while (total < maxBytes) + { + ct.ThrowIfCancellationRequested(); + var n = gzip.Read(buf, total, Math.Min(CancelChunk, maxBytes - total)); + if (n <= 0) break; total += n; + } return total == maxBytes ? buf : Trim(buf, total); } catch (InvalidDataException) diff --git a/MIQ.Core/Parsing/MiqParser.cs b/MIQ.Core/Parsing/MiqParser.cs index 1d0aa4a..d95ce10 100644 --- a/MIQ.Core/Parsing/MiqParser.cs +++ b/MIQ.Core/Parsing/MiqParser.cs @@ -72,8 +72,10 @@ public static MiqImage Parse(string filePath, CancellationToken ct = default) /// which case it's permanent — ). /// Falls back to a full for 3-D / small files, or when /// ISIZE is unreliable; those return a complete image (IsPartial = false). + /// abandons the read/decompress when the user navigates + /// away (throws ). /// - public static MiqImage ParsePartial(string filePath) + public static MiqImage ParsePartial(string filePath, CancellationToken ct = default) { var kind = MiqFileKindExtensions.FromPath(filePath) ?? throw MiqException.UnsupportedFileFormat(); @@ -82,18 +84,18 @@ public static MiqImage ParsePartial(string filePath) { var len = new FileInfo(filePath).Length; if (len > MaxArrayBytes) - return ParseNiftiFirstVolume(filePath, blocked: true); + return ParseNiftiFirstVolume(filePath, blocked: true, ct); if (len > PartialLoadThreshold) - return ParseNiftiFirstVolume(filePath, blocked: false); + return ParseNiftiFirstVolume(filePath, blocked: false, ct); } // Only multi-volume .nii.gz benefits; everything else parses fully. if (kind != MiqFileKind.NiiGz) - return Parse(filePath); + return Parse(filePath, ct); var isize = ReadGzipIsize(filePath); if (isize == 0) - return Parse(filePath); // unknown / >4 GB — let full path handle it + return Parse(filePath, ct); // unknown / >4 GB — let full path handle it // Probe: decompress just enough to read the NIfTI header (max 1024 B; // NIfTI-2 header is 540 B so 1024 covers both variants with margin). @@ -102,19 +104,19 @@ public static MiqImage ParsePartial(string filePath) try { using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); - probe = MiqBinaryReader.GunzipPartial(fs, probeSize); + probe = MiqBinaryReader.GunzipPartial(fs, probeSize, ct); } - catch { return Parse(filePath); } + catch (Exception e) when (e is not OperationCanceledException) { return Parse(filePath, ct); } if (probe.Length < 4) - return Parse(filePath); + return Parse(filePath, ct); MiqHeader header; try { header = NiftiParser.ParseHeader(probe, compressed: true); } - catch { return Parse(filePath); } + catch { return Parse(filePath, ct); } if (header.Volumes <= 1) - return Parse(filePath); // already 3-D — no benefit + return Parse(filePath, ct); // already 3-D — no benefit // Budget = bytes needed for header + exactly one volume. var budget = (long)header.VoxOffset @@ -122,23 +124,23 @@ public static MiqImage ParsePartial(string filePath) * header.Datatype.BytesPerVoxel(); if (budget >= (long)isize || budget > int.MaxValue) - return Parse(filePath); // volume 0 is the whole file + return Parse(filePath, ct); // volume 0 is the whole file // Too few volumes to pay off (see PartialGzipMinVolumes) — unless the full // data can't fit a byte[], which must stay on the blocked vol-0 view. if (header.Volumes < PartialGzipMinVolumes && isize <= (ulong)MaxArrayBytes) - return Parse(filePath); + return Parse(filePath, ct); byte[] partial; try { using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); - partial = MiqBinaryReader.GunzipPartial(fs, (int)budget); + partial = MiqBinaryReader.GunzipPartial(fs, (int)budget, ct); } - catch { return Parse(filePath); } + catch (Exception e) when (e is not OperationCanceledException) { return Parse(filePath, ct); } if (partial.Length < header.VoxOffset) - return Parse(filePath); // decompression shorter than expected + return Parse(filePath, ct); // decompression shorter than expected return new MiqImage { @@ -158,32 +160,32 @@ public static MiqImage ParsePartial(string filePath) /// : true when the full data can't be /// held (permanent volume-0 view); false to let the viewer expand in the /// background (a latency optimisation for large-but-in-limit files). - private static MiqImage ParseNiftiFirstVolume(string filePath, bool blocked) + private static MiqImage ParseNiftiFirstVolume(string filePath, bool blocked, CancellationToken ct) { using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); // Probe enough for either NIfTI-1 (348 B) or NIfTI-2 (540 B) header. var probe = new byte[1024]; - var probed = ReadFully(fs, probe, probe.Length); + var probed = ReadFully(fs, probe, probe.Length, ct); MiqHeader header; try { header = NiftiParser.ParseHeader(Trim(probe, probed), compressed: false); } - catch { return Parse(filePath); } + catch { return Parse(filePath, ct); } // A single volume is assumed to always fit; if a >2 GB file claims to be // 3-D, fall back to the full path so it fails with a clear message. if (header.Volumes <= 1) - return Parse(filePath); + return Parse(filePath, ct); var budget = (long)header.VoxOffset + (long)header.Width * header.Height * header.Depth * header.Datatype.BytesPerVoxel(); if (budget <= 0 || budget > MaxArrayBytes) - return Parse(filePath); // volume 0 itself doesn't fit — defer to full path + return Parse(filePath, ct); // volume 0 itself doesn't fit — defer to full path var storage = new byte[budget]; fs.Seek(0, SeekOrigin.Begin); - if (ReadFully(fs, storage, storage.Length) < storage.Length) - return Parse(filePath); // file shorter than the header implies + if (ReadFully(fs, storage, storage.Length, ct) < storage.Length) + return Parse(filePath, ct); // file shorter than the header implies return new MiqImage { @@ -197,12 +199,19 @@ private static MiqImage ParseNiftiFirstVolume(string filePath, bool blocked) /// Reads up to bytes into , /// looping over short reads. Returns the number actually read (< count at EOF). - private static int ReadFully(Stream s, byte[] buf, int count) + /// Reads in chunks, checking between them, so a + /// multi-GB volume-0 read abandons promptly on nav-away. + private static int ReadFully(Stream s, byte[] buf, int count, CancellationToken ct) { + const int chunk = 4 * 1024 * 1024; var total = 0; - int n; - while (total < count && (n = s.Read(buf, total, count - total)) > 0) + while (total < count) + { + ct.ThrowIfCancellationRequested(); + var n = s.Read(buf, total, Math.Min(chunk, count - total)); + if (n <= 0) break; total += n; + } return total; } diff --git a/MIQ.Tests/ParseCancellationTests.cs b/MIQ.Tests/ParseCancellationTests.cs new file mode 100644 index 0000000..f9b0fb8 --- /dev/null +++ b/MIQ.Tests/ParseCancellationTests.cs @@ -0,0 +1,95 @@ +using System.Buffers.Binary; +using System.IO.Compression; +using MIQ.Parsing; +using Xunit; + +namespace MIQ.Tests; + +// Changes the process-wide MiqParser.PartialLoadThreshold seam in one test, so +// this class must not overlap others that parse .nii files. +[CollectionDefinition(nameof(PartialLoadSeam), DisableParallelization = true)] +public class PartialLoadSeam { } + +// The first preview load (ParsePartial) must honour its token on every path, so +// navigating away abandons the read/decompress instead of finishing it. +[Collection(nameof(PartialLoadSeam))] +public class ParseCancellationTests +{ + const int Side = 8; + const int VolumeBytes = Side * Side * Side * 2; // int16 + const int Offset = 352; + + static byte[] Nifti1(int volumes) + { + var file = new byte[Offset + volumes * VolumeBytes]; + var h = file.AsSpan(); + BinaryPrimitives.WriteInt32LittleEndian(h, 348); + short[] dims = [(short)(volumes > 1 ? 4 : 3), Side, Side, Side, (short)volumes]; + for (var i = 0; i < dims.Length; i++) + BinaryPrimitives.WriteInt16LittleEndian(h[(40 + i * 2)..], dims[i]); + BinaryPrimitives.WriteInt16LittleEndian(h[70..], (short)MiqDatatype.Int16); + BinaryPrimitives.WriteInt16LittleEndian(h[72..], 16); + for (var i = 0; i < 8; i++) + BinaryPrimitives.WriteSingleLittleEndian(h[(76 + i * 4)..], 1f); + BinaryPrimitives.WriteSingleLittleEndian(h[108..], Offset); + return file; + } + + static byte[] Gzip(byte[] raw) + { + using var ms = new MemoryStream(); + using (var gz = new GZipStream(ms, CompressionLevel.Optimal, leaveOpen: true)) + gz.Write(raw); + return ms.ToArray(); + } + + static CancellationToken Cancelled() + { + var cts = new CancellationTokenSource(); + cts.Cancel(); + return cts.Token; + } + + static void AssertCancels(byte[] bytes, string suffix) + { + var path = Path.Combine(Path.GetTempPath(), $"miq-cancel-{Guid.NewGuid():N}{suffix}"); + File.WriteAllBytes(path, bytes); + try + { + Assert.ThrowsAny( + () => MiqParser.ParsePartial(path, Cancelled())); + // The same file still loads with no token (default: never cancelled). + Assert.NotNull(MiqParser.ParsePartial(path)); + } + finally { File.Delete(path); } + } + + [Fact] + public void MultiVolumeGzip_PartialPath_Cancels() => + AssertCancels(Gzip(Nifti1(MiqParser.PartialGzipMinVolumes)), ".nii.gz"); + + [Fact] + public void SingleVolumeGzip_FullParsePath_Cancels() => + AssertCancels(Gzip(Nifti1(1)), ".nii.gz"); + + [Fact] + public void SmallUncompressed_FullParsePath_Cancels() => + AssertCancels(Nifti1(2), ".nii"); + + [Fact] + public void LargeUncompressed_Volume0Path_Cancels() + { + var saved = MiqParser.PartialLoadThreshold; + MiqParser.PartialLoadThreshold = 0; // force the vol-0-first read on a tiny file + try { AssertCancels(Nifti1(2), ".nii"); } + finally { MiqParser.PartialLoadThreshold = saved; } + } + + [Fact] + public void GunzipPartial_Cancels() + { + using var input = new MemoryStream(Gzip(Nifti1(1))); + Assert.ThrowsAny( + () => MiqBinaryReader.GunzipPartial(input, 1024, Cancelled())); + } +} diff --git a/QuickLook.Plugin.MIQ/MiqTriPlanarControl.cs b/QuickLook.Plugin.MIQ/MiqTriPlanarControl.cs index 28e629e..43c84bc 100644 --- a/QuickLook.Plugin.MIQ/MiqTriPlanarControl.cs +++ b/QuickLook.Plugin.MIQ/MiqTriPlanarControl.cs @@ -57,6 +57,9 @@ private static readonly (SlicePlane plane, double ax, double ay)[] Layout = // through previews never triggers background work. Null once expanded/blocked. private readonly Action? _onExpandRequested; private bool _expansionRequested; // guards a single trigger; flips row to "loading…" + // The background load failed: volume 0 stays, the row shows a failure notice + // (reason in the tooltip) instead of "loading…" forever. No automatic retry. + private bool _expansionFailed; // Per-volume window cache (populated lazily on scrub when PerVolumeWindow = true). private readonly bool _perVolumeWindow; @@ -160,6 +163,17 @@ private void RequestExpansion() _onExpandRequested!(); } + /// Background expansion failed (called on the UI thread). Keeps volume 0 and + /// replaces the "loading…" row with a failure notice; the reason goes in the + /// tooltip since the metadata row has no room for it. + internal void ExpansionFailed(string message) + { + if (_isExpanded) return; + _expansionFailed = true; + ToolTip = $"Could not load all volumes: {message}"; + InvalidateVisual(); + } + /// /// Swaps in the fully-decompressed volume (called from the UI thread once /// background expansion finishes). Enables the volume scrubber. @@ -289,6 +303,7 @@ private void RenderContent(DrawingContext dc, Size sz) var mode = _isExpanded ? WpfPreviewRenderer.ScrubMode.Expanded : _expansionBlocked ? WpfPreviewRenderer.ScrubMode.Blocked : + _expansionFailed ? WpfPreviewRenderer.ScrubMode.Failed : _expansionRequested ? WpfPreviewRenderer.ScrubMode.Loading : _onExpandRequested != null ? WpfPreviewRenderer.ScrubMode.Loadable : WpfPreviewRenderer.ScrubMode.Loading; diff --git a/QuickLook.Plugin.MIQ/Plugin.cs b/QuickLook.Plugin.MIQ/Plugin.cs index c6a1429..c38df5e 100644 --- a/QuickLook.Plugin.MIQ/Plugin.cs +++ b/QuickLook.Plugin.MIQ/Plugin.cs @@ -86,8 +86,9 @@ public void View(string path, ContextObject context) var kind = MiqFileKindExtensions.FromPath(path); // Phase 1: volume-0-only load where it pays off (see ParsePartial); - // otherwise a full parse (IsPartial = false). - var image = MiqParser.ParsePartial(path); + // otherwise a full parse (IsPartial = false). The token abandons the + // read/decompress if the user navigates away mid-load. + var image = MiqParser.ParsePartial(path, cts.Token); if (cts.IsCancellationRequested) return; var fmt = image.Header.FormatLabel ?? kind?.DisplayName() ?? "Unknown"; @@ -137,6 +138,10 @@ public void View(string path, ContextObject context) context.IsBusy = false; }); } + catch (OperationCanceledException) when (cts.IsCancellationRequested) + { + // Navigated away mid-load: nothing to show. + } catch (Exception ex) { control.Dispatcher.BeginInvoke(() => @@ -178,7 +183,21 @@ private void StartExpansion(string path, MiqRenderingOptions options, view.ExpandVolume(fullVolume, fullLut); }); } - catch (Exception) { /* cancelled, or viewer cleaned up */ } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // Navigated away mid-load: nothing to report. + } + catch (Exception ex) + { + // A real failure (out of memory, I/O error, file changed): tell the + // view so it leaves "loading…" for the volume-0-only notice. + var message = ex.Message; + control.Dispatcher.BeginInvoke(() => + { + if (token.IsCancellationRequested) return; + view.ExpansionFailed(message); + }); + } finally { thread.Priority = prevPriority; } }); } diff --git a/QuickLook.Plugin.MIQ/WpfPreviewRenderer.cs b/QuickLook.Plugin.MIQ/WpfPreviewRenderer.cs index 6ca4e6f..1d0f89a 100644 --- a/QuickLook.Plugin.MIQ/WpfPreviewRenderer.cs +++ b/QuickLook.Plugin.MIQ/WpfPreviewRenderer.cs @@ -93,8 +93,9 @@ private static void DrawAxisLabels( /// State of the Volumes row's scrubber. draws an /// interactive track but the full volume isn't loaded yet — the first scrub /// gesture triggers the background load (then the row shows - /// until it expands). is the permanent volume-0-only view. - internal enum ScrubMode { Expanded, Loadable, Loading, Blocked } + /// until it expands). is the permanent volume-0-only view; + /// is the same view after a background load error. + internal enum ScrubMode { Expanded, Loadable, Loading, Blocked, Failed } /// /// Draws metadata entries. When ≥ 0 and the entry @@ -148,10 +149,14 @@ internal static (Rect hitRect, double trackX0, double trackX1) DrawMetadata( } else { - // Loading the rest, or permanently blocked (too large) — vol 0 only. - var valStr = scrubMode == ScrubMode.Blocked - ? $"1 / {scrubTotal} · first volume only (too large for 4-D)" - : $"{scrubVol + 1} / {scrubTotal} loading…"; + // Loading the rest, permanently blocked (too large), or the load + // failed — vol 0 only. + var valStr = scrubMode switch + { + ScrubMode.Blocked => $"1 / {scrubTotal} · first volume only (too large for 4-D)", + ScrubMode.Failed => $"1 / {scrubTotal} · first volume only (full load failed)", + _ => $"{scrubVol + 1} / {scrubTotal} loading…", + }; values[i] = Text(valStr, s.MetadataLabelBrush, fs * 0.9, Regular); } } From 1f8486897c9004f6a78fe6ba119bc41e4250342a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 11:51:48 +0000 Subject: [PATCH 2/2] Apply MRtrix MIF intensity scaling MifParser hard-coded SclSlope/SclInter to 0 and never read the `scaling: offset,scale` field MRtrix writes for non-identity intensity scaling (e.g. integer data converted from DICOM with Rescale Slope/Intercept). The voxel readout showed raw stored values, the Scaling metadata row was missing, a negative scale rendered inverted, and scaled data passed the identity-scaling check for segmentation colouring. The field is now mapped to SclSlope = scale, SclInter = offset. Absent, identity, malformed, non-finite or zero-scale values stay unscaled, so an optional field never fails the parse. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XqBwhnTRsqxk3SEvp9z4T9 --- MIQ.Core/Parsing/MifParser.cs | 26 +++++++++++- MIQ.Tests/MifScalingTests.cs | 80 +++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 MIQ.Tests/MifScalingTests.cs diff --git a/MIQ.Core/Parsing/MifParser.cs b/MIQ.Core/Parsing/MifParser.cs index c2b7f99..836ca04 100644 --- a/MIQ.Core/Parsing/MifParser.cs +++ b/MIQ.Core/Parsing/MifParser.cs @@ -157,6 +157,7 @@ private static MiqImage BuildImage( }; var orientationFrame = BuildOrientationFrame(transformRows, layout, order); + var (sclSlope, sclInter) = ParseScaling(fields); var dimensions = new int[4]; for (var i = 0; i < 4; i++) dimensions[i] = i < storedDim.Length ? storedDim[i] : 1; @@ -168,8 +169,8 @@ private static MiqImage BuildImage( Pixdim = pixdim, Datatype = datatype, VoxOffset = payloadOffset, - SclSlope = 0f, - SclInter = 0f, + SclSlope = sclSlope, + SclInter = sclInter, QformCode = 0, SformCode = 0, SrowX = new float[] { 0f, 0f, 0f, 0f }, @@ -232,6 +233,27 @@ private static byte[] UnpackBits(byte[] data, int payloadOffset, long totalEleme return unpacked; } + /// MRtrix intensity scaling, scaling: offset,scale (value = offset + + /// scale × stored; note the order is the reverse of NIfTI's slope/inter). + /// Written when the header carries non-identity scaling, e.g. integer data + /// converted from DICOM with Rescale Slope/Intercept. + /// + /// Returned as (slope, inter) in MiqHeader's convention, where slope 0 means + /// "unscaled". Absent, identity, malformed, non-finite, or zero-scale values + /// yield (0, 0): an optional field must not fail the parse, and a zero scale + /// would collapse the image to one value. + private static (float slope, float inter) ParseScaling(Dictionary fields) + { + if (!fields.TryGetValue("scaling", out var str)) return (0f, 0f); + var values = TryParseFloatList(str); + if (values is null || values.Length != 2) return (0f, 0f); + var (offset, scale) = (values[0], values[1]); + if (!MiqCompat.IsFinite(offset) || !MiqCompat.IsFinite(scale) || scale == 0f) + return (0f, 0f); + if (scale == 1f && offset == 0f) return (0f, 0f); + return (scale, offset); + } + // ── Orientation ────────────────────────────────────────────────────────── /// diff --git a/MIQ.Tests/MifScalingTests.cs b/MIQ.Tests/MifScalingTests.cs new file mode 100644 index 0000000..1daaa6c --- /dev/null +++ b/MIQ.Tests/MifScalingTests.cs @@ -0,0 +1,80 @@ +using System.Text; +using MIQ.Parsing; +using MIQ.Rendering; +using Xunit; + +namespace MIQ.Tests; + +// MRtrix `scaling: offset,scale` (value = offset + scale × stored). Previously +// ignored, so the voxel readout showed raw stored integers and a negative scale +// rendered inverted. +public class MifScalingTests +{ + /// 2×2×2 uint8 MIF whose first voxel (or, with , every + /// voxel) stores 10. null omits the field; + /// otherwise it is written verbatim. + static byte[] MifFile(string? scaling, bool fill = false) + { + var header = new StringBuilder(); + header.Append("mrtrix image\n"); + header.Append("dim: 2,2,2\n"); + header.Append("vox: 1,1,1\n"); + header.Append("layout: +0,+1,+2\n"); + header.Append("datatype: UInt8\n"); + if (scaling is not null) header.Append("scaling: ").Append(scaling).Append('\n'); + header.Append("file: .\n"); + header.Append("END\n"); + + var bytes = Encoding.ASCII.GetBytes(header.ToString()); + var file = new byte[bytes.Length + 8]; + bytes.CopyTo(file, 0); + for (var i = 0; i < (fill ? 8 : 1); i++) file[bytes.Length + i] = 10; + return file; + } + + static MiqImage Parse(string? scaling, bool fill = false) => + MifParser.Parse(MifFile(scaling, fill)); + + static float FirstVoxel(MiqImage image) => new MiqVolume(image).VoxelValue(0, 0, 0, 0); + + [Fact] + public void Scaling_AppliesOffsetThenScale() + { + var image = Parse("5,2"); // offset 5, scale 2 + Assert.Equal(2f, image.Header.SclSlope); + Assert.Equal(5f, image.Header.SclInter); + Assert.Equal(25f, FirstVoxel(image)); // 5 + 2 × 10 + } + + [Fact] + public void Scaling_ShowsInMetadata() + { + var lines = new MiqMetadata(Parse("5,2").Header, "MRtrix MIF", null).AsDisplayLines(); + Assert.Contains(lines, e => e.Label == "Scaling" && e.Value == "x 2 + 5"); + } + + // A uniform label-like volume colours when unscaled; non-identity scaling + // marks it as intensity data, so it must fall back to windowing. + [Fact] + public void Scaling_NonIdentity_DisablesSegmentationColouring() + { + var auto = new MiqRenderingOptions(Segmentation: MiqSegmentationColoring.Auto); + Assert.NotNull(new MiqVolume(Parse(null, fill: true)).BuildSegmentationLut(auto)); + Assert.Null(new MiqVolume(Parse("0,0.5", fill: true)).BuildSegmentationLut(auto)); + } + + [Theory] + [InlineData(null)] // absent + [InlineData("0,1")] // identity + [InlineData("1,0")] // zero scale + [InlineData("nan,2")] // non-finite + [InlineData("5")] // wrong arity + [InlineData("abc,def")] // malformed + public void Scaling_AbsentOrUnusable_IsUnscaled(string? scaling) + { + var image = Parse(scaling); + Assert.Equal(0f, image.Header.SclSlope); + Assert.Equal(0f, image.Header.SclInter); + Assert.Equal(10f, FirstVoxel(image)); + } +}