Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions MIQ.Core/Parsing/MifParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 },
Expand Down Expand Up @@ -232,6 +233,27 @@ private static byte[] UnpackBits(byte[] data, int payloadOffset, long totalEleme
return unpacked;
}

/// MRtrix intensity scaling, <c>scaling: offset,scale</c> (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<string, string> 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 ──────────────────────────────────────────────────────────

/// <summary>
Expand Down
19 changes: 14 additions & 5 deletions MIQ.Core/Parsing/MiqBinaryReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <paramref name="input"/> 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.
Expand All @@ -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
Expand Down Expand Up @@ -212,18 +216,23 @@ public static byte[] Gunzip(Stream input, uint sizeHint, CancellationToken ct =

/// Decompress at most <paramref name="maxBytes"/> 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
/// <paramref name="ct"/> before each read (abandon promptly on nav-away).
public static byte[] GunzipPartial(Stream input, int maxBytes, CancellationToken ct = default)
{
try
{
// leaveOpen: caller owns the FileStream lifetime.
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)
Expand Down
62 changes: 38 additions & 24 deletions MIQ.Core/Parsing/MiqParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,11 @@ public static MiqImage Parse(string filePath, CancellationToken ct = default)
};
}

/// Uncancellable <see cref="ParsePartial(string, CancellationToken)"/>. An
/// overload rather than an optional parameter so the method group still
/// converts to <c>Func&lt;string, MiqImage&gt;</c>.
public static MiqImage ParsePartial(string filePath) => ParsePartial(filePath, CancellationToken.None);

/// <summary>
/// Fast path for multi-volume NIfTI: returns just volume 0 as a partial
/// <see cref="MiqImage"/> (<see cref="MiqImage.IsPartial"/> = true) so the
Expand All @@ -73,8 +78,10 @@ public static MiqImage Parse(string filePath, CancellationToken ct = default)
/// Falls back to a full <see cref="Parse"/> for 3-D / small files, or when the
/// data is shorter than the header implies; those return a complete image
/// (IsPartial = false).
/// <paramref name="ct"/> abandons the read/decompress when the user navigates
/// away (throws <see cref="OperationCanceledException"/>).
/// </summary>
public static MiqImage ParsePartial(string filePath)
public static MiqImage ParsePartial(string filePath, CancellationToken ct)
{
var kind = MiqFileKindExtensions.FromPath(filePath)
?? throw MiqException.UnsupportedFileFormat();
Expand All @@ -83,14 +90,14 @@ 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);

// 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).
Expand All @@ -99,27 +106,27 @@ 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
+ (long)header.Width * header.Height * header.Depth
* header.Datatype.BytesPerVoxel();

if (budget > int.MaxValue)
return Parse(filePath); // volume 0 alone can't be held — full path reports it
return Parse(filePath, ct); // volume 0 alone can't be held — full path reports it

// Full size from the header, NOT the gzip ISIZE footer: ISIZE is the size
// mod 2^32, so a >4 GiB series can report a small ISIZE and be offered an
Expand All @@ -129,20 +136,20 @@ public static MiqImage ParsePartial(string filePath)
// 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 && !blocked)
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); }

// Stream ended inside volume 0: the file is shorter than its header says,
// so it is small enough for the full path, which reports the truncation.
if (partial.Length < budget)
return Parse(filePath);
return Parse(filePath, ct);

return new MiqImage
{
Expand Down Expand Up @@ -174,32 +181,32 @@ internal static long NiftiFileExtent(MiqHeader header)
/// <see cref="MiqImage.ExpansionBlocked"/>: 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
{
Expand All @@ -213,12 +220,19 @@ private static MiqImage ParseNiftiFirstVolume(string filePath, bool blocked)

/// Reads up to <paramref name="count"/> bytes into <paramref name="buf"/>,
/// looping over short reads. Returns the number actually read (&lt; count at EOF).
private static int ReadFully(Stream s, byte[] buf, int count)
/// Reads in chunks, checking <paramref name="ct"/> 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;
}

Expand Down
80 changes: 80 additions & 0 deletions MIQ.Tests/MifScalingTests.cs
Original file line number Diff line number Diff line change
@@ -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 <paramref name="fill"/>, every
/// voxel) stores 10. <paramref name="scaling"/> 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));
}
}
Loading
Loading