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
2 changes: 2 additions & 0 deletions MIQ.Core/Parsing/MifParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,8 @@ private static (MiqDatatype datatype, bool littleEndian, bool isBit) ParseDataty
"int32" or "int32_t" => MiqDatatype.Int32,
"float32" => MiqDatatype.Float32,
"float64" => MiqDatatype.Float64,
"uint64" or "uint64_t" => MiqDatatype.Uint64,
"int64" or "int64_t" => MiqDatatype.Int64,
_ => throw new MiqException($"MIF: unsupported datatype '{s}'."),
};

Expand Down
6 changes: 5 additions & 1 deletion MIQ.Core/Parsing/MiqDatatype.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ public enum MiqDatatype : short
Uint32 = 768,
Float32 = 16,
Float64 = 64,
Int64 = 1024,
Uint64 = 1280,
Rgb24 = 128,
Rgba32 = 2304,
}
Expand All @@ -27,7 +29,7 @@ public static class MiqDatatypeExtensions
MiqDatatype.Int8 or MiqDatatype.Uint8 => 1,
MiqDatatype.Int16 or MiqDatatype.Uint16 => 2,
MiqDatatype.Int32 or MiqDatatype.Uint32 or MiqDatatype.Float32 => 4,
MiqDatatype.Float64 => 8,
MiqDatatype.Float64 or MiqDatatype.Int64 or MiqDatatype.Uint64 => 8,
MiqDatatype.Rgb24 => 3,
MiqDatatype.Rgba32 => 4,
_ => throw new ArgumentOutOfRangeException(nameof(dt)),
Expand All @@ -43,6 +45,8 @@ public static class MiqDatatypeExtensions
MiqDatatype.Uint32 => "uint32",
MiqDatatype.Float32 => "float32",
MiqDatatype.Float64 => "float64",
MiqDatatype.Int64 => "int64",
MiqDatatype.Uint64 => "uint64",
MiqDatatype.Rgb24 => "rgb24",
MiqDatatype.Rgba32 => "rgba32",
_ => throw new ArgumentOutOfRangeException(nameof(dt)),
Expand Down
7 changes: 3 additions & 4 deletions MIQ.Core/Parsing/NrrdParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -388,10 +388,9 @@ private static MiqDatatype ParseDatatype(string value) =>
"float" => MiqDatatype.Float32,
"double" => MiqDatatype.Float64,
"int64" or "int64_t" or "longlong" or "long long"
or "signed long long" or "signed long long int"
or "uint64" or "uint64_t" or "ulonglong"
or "unsigned long long" or "unsigned long long int" => throw new MiqException(
"NRRD 64-bit integer types are not supported; convert to float or int32 before previewing."),
or "signed long long" or "signed long long int" => MiqDatatype.Int64,
"uint64" or "uint64_t" or "ulonglong"
or "unsigned long long" or "unsigned long long int" => MiqDatatype.Uint64,
_ => throw new MiqException($"Unrecognised NRRD type '{value}'."),
};

Expand Down
41 changes: 36 additions & 5 deletions MIQ.Core/Rendering/IntensityWindow.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ public readonly struct Bounds(float low, float high)
public float High { get; } = high;
}

/// Magnitude at or below which a value counts as background for the preferred
/// foreground subset (see <see cref="GetBounds"/>).
internal const float NonZeroFloor = 1e-6f;

/// Derives window bounds from a pooled value set. Returns null if no finite
/// values are present.
public static Bounds? GetBounds(IReadOnlyList<float> values, double lowerPercentile, double upperPercentile)
Expand All @@ -19,12 +23,39 @@ public readonly struct Bounds(float low, float high)
if (MiqCompat.IsFinite(v)) finite.Add(v);
if (finite.Count == 0) return null;

// Prefer a non-zero subset if substantial; the /20 ratio guards against
// rejecting legitimate dim regions when most voxels are background.
var nonZero = new List<float>(finite.Count);
// Window over the foreground when it's substantial; the /20 ratio keeps a
// dim region from being rejected when most voxels are background.
//
// Two tiers, tried in order. The NonZeroFloor also drops near-zero
// interpolation residue, and wins whenever it leaves enough voxels (every
// ordinary image, unchanged from before). Only when it doesn't (data in
// tiny units: an SI-unit ADC map sits around 1e-9, entirely below the
// floor) do exactly-non-zero values stand in, so such a map windows over
// its tissue instead of over tissue plus background zeros. Deliberately
// not a floor scaled by the data's maximum: one outlier voxel (1e30 from a
// failed fit) would lift that above real tissue. Port of MIQCore's bounds.
var minimumSubset = Math.Max(64, finite.Count / 20);
var aboveFloor = new List<float>(finite.Count);
var nonZeroCount = 0;
foreach (var v in finite)
if (Math.Abs(v) > 1e-6f) nonZero.Add(v);
var source = nonZero.Count >= Math.Max(64, finite.Count / 20) ? nonZero : finite;
{
if (v == 0f) continue;
nonZeroCount++;
if (Math.Abs(v) > NonZeroFloor) aboveFloor.Add(v);
}

List<float> source;
if (aboveFloor.Count >= minimumSubset)
source = aboveFloor;
else if (nonZeroCount >= minimumSubset)
{
// Tiny-unit fallback: the only case that pays a second pass.
source = new List<float>(nonZeroCount);
foreach (var v in finite)
if (v != 0f) source.Add(v);
}
else
source = finite;
source.Sort();

var lower = Percentile(source, (float)lowerPercentile / 100f);
Expand Down
53 changes: 45 additions & 8 deletions MIQ.Core/Rendering/MiqVolume.cs
Original file line number Diff line number Diff line change
Expand Up @@ -423,17 +423,17 @@ bool Add(float v)
return !(labels.Add(label) && labels.Count > SegmentationLut.MaxLabels);
}

if (_image.ElementStrides is not null)
if (!Volume0IsContiguous)
{
// MIF custom strides may interleave volumes: per-voxel walk.
// Volumes interleaved with space: per-voxel walk.
for (var z = 0; z < Depth; z++)
for (var y = 0; y < Height; y++)
for (var x = 0; x < Width; x++)
if (!Add(Voxel(x, y, z, 0))) return null;
}
else
{
// Row-major: volume 0 is the first W·H·D payload elements.
// Volume 0 is the first W·H·D payload elements (order is irrelevant here).
var bpv = H.Datatype.BytesPerVoxel();
var elems = (int)Math.Min((long)Width * Height * Depth, (long)_image.PayloadCount / bpv);
for (var i = 0; i < elems; i++)
Expand Down Expand Up @@ -579,11 +579,11 @@ private static int GetSingle(HashSet<int> set)
// binary mask scans to completion.
private Vol0LabelShape ScanVolume0(int label)
{
// MIF custom strides may interleave volumes: use the per-voxel walk.
if (_image.ElementStrides is not null)
// A layout that interleaves volumes with space: use the per-voxel walk.
if (!Volume0IsContiguous)
return ScanVolume0PerVoxel(label);

// Row-major formats: volume 0 is the first N payload elements. Only which
// Volume 0 is the first N payload elements, in some order. Only which
// values occur matters, not where, so scan the raw buffer sequentially with
// the datatype switch hoisted out of the loop.
var s = _image.Storage;
Expand Down Expand Up @@ -627,6 +627,11 @@ static long Rd64(byte[] a, long o, bool le)
for (var o = p; o < end; o += 4)
{ int v = Rd32(s, o, le); if (v != 0 && v != label) return Vol0LabelShape.MultiLabel; }
break;
case MiqDatatype.Int64:
case MiqDatatype.Uint64: // a uint64 above long.MaxValue reads negative: still MultiLabel
for (var o = p; o < end; o += 8)
{ long v = Rd64(s, o, le); if (v != 0 && v != label) return Vol0LabelShape.MultiLabel; }
break;
case MiqDatatype.Float32:
for (var o = p; o < end; o += 4)
{
Expand All @@ -653,7 +658,35 @@ static long Rd64(byte[] a, long o, bool le)
return Vol0LabelShape.Binary;
}

// Layout-agnostic fallback for MIF custom strides.
/// Whether volume 0 occupies exactly the first W·H·D payload elements, in any
/// order. The volume-0 label scans don't depend on voxel order, so this (not
/// the absence of custom strides) is what their contiguous path needs.
///
/// True for row-major layouts and for every dense permuted one: the spatial
/// strides sorted ascending are 1, d₀, d₀·d₁. MifParser sets ElementStrides
/// even for an identity layout, so keying off null sent every MIF mask down
/// the per-voxel walk. The time stride never matters: volume 0 is t = 0. A
/// layout with the volume axis faster than a spatial one fails the check, as
/// it must, since its first N elements mix timepoints. Internal so tests can
/// pin which path a layout takes. Port of MIQCore's volumeZeroIsContiguous.
internal bool Volume0IsContiguous
{
get
{
if (_image.ElementStrides is not { } s) return true;
if (s.Length < 3 || _image.BaseElementIndex != 0) return false;
// Ties (a size-1 axis shares its stride with the next) sort by size so
// the size-1 axis comes first and the check still holds. A mis-ordered
// tie would only cost the fast path, never correctness.
var axes = new[] { (stride: s[0], size: Width), (stride: s[1], size: Height), (stride: s[2], size: Depth) };
Array.Sort(axes, (a, b) => a.stride != b.stride ? a.stride.CompareTo(b.stride) : a.size.CompareTo(b.size));
return axes[0].stride == 1
&& axes[1].stride == axes[0].size
&& axes[2].stride == (long)axes[0].size * axes[1].size;
}
}

// Layout-agnostic fallback for layouts that interleave volumes with space.
private Vol0LabelShape ScanVolume0PerVoxel(int label)
{
for (var z = 0; z < Depth; z++)
Expand All @@ -674,7 +707,7 @@ private Vol0LabelShape ScanVolume0PerVoxel(int label)
// Floats included: label maps are often re-saved as float by downstream
// tools. Continuous float intensity fails the integrality check.
MiqDatatype.Int8 or MiqDatatype.Uint8 or MiqDatatype.Int16 or MiqDatatype.Uint16
or MiqDatatype.Int32 or MiqDatatype.Uint32
or MiqDatatype.Int32 or MiqDatatype.Uint32 or MiqDatatype.Int64 or MiqDatatype.Uint64
or MiqDatatype.Float32 or MiqDatatype.Float64 => true,
_ => false,
};
Expand Down Expand Up @@ -929,6 +962,10 @@ private float RawVoxelValue(int byteOffset)
MiqDatatype.Uint32 => MiqBinaryReader.Uint32(s, abs, le),
MiqDatatype.Float32 => MiqCompat.Int32BitsToSingle((int)MiqBinaryReader.Uint32(s, abs, le)),
MiqDatatype.Float64 => (float)MiqCompat.Int64BitsToDouble((long)MiqBinaryReader.Uint64(s, abs, le)),
// Via double to match MIQCore's Float(Double(x)); a direct cast rounds
// once and can differ from it in the last bit.
MiqDatatype.Int64 => (float)(double)MiqBinaryReader.Int64(s, abs, le),
MiqDatatype.Uint64 => (float)(double)MiqBinaryReader.Uint64(s, abs, le),
// RGB normally takes ReadRgb; luminance fallback just in case.
MiqDatatype.Rgb24 or MiqDatatype.Rgba32 =>
0.299f * _image.Byte(byteOffset) + 0.587f * _image.Byte(byteOffset + 1)
Expand Down
108 changes: 108 additions & 0 deletions MIQ.Tests/BinaryMaskLayoutTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
using System.Text;
using MIQ.Parsing;
using MIQ.Rendering;
using Xunit;

namespace MIQ.Tests;

// The volume-0 label scans take the contiguous path whenever volume 0 is the first
// W·H·D payload elements in *any* order, not only when ElementStrides is null.
// MifParser always sets strides, so keying off null sent every MIF mask down the
// per-voxel walk (~8× slower on a 256³ mask in MIQCore). These pin that dense MIF
// layouts take the fast path with the same verdict, and that a volume-interleaved
// layout still does not. Port of MIQ@8323423's BinaryMaskLayoutTests.
public class BinaryMaskLayoutTests
{
static readonly MiqRenderingOptions Auto = new(Segmentation: MiqSegmentationColoring.Auto);

// Distinct sizes so a stride mix-up can't cancel out. The box covers all three
// center planes (x = 6, y = 5, z = 4); voxel (0,0,0) lies on none of them.
static readonly int[] Dims = [12, 10, 8];

static bool InBox(int x, int y, int z) => x is >= 3 and < 9 && y is >= 3 and < 7 && z is >= 2 and < 6;

/// A uint8 MIF whose voxel (x, y, z, t), in `dim:` order, holds value(x, y, z, t),
/// stored in the given layout (MRtrix storage ranks, all ascending).
static MiqVolume MakeMif(int[] dims, int[] layout, Func<int, int, int, int, byte> value)
{
var n = dims.Length;
var byRank = Enumerable.Range(0, n).OrderBy(a => layout[a]).ToArray();
var strides = new int[n];
var stride = 1;
foreach (var axis in byRank) { strides[axis] = stride; stride *= dims[axis]; }

var volumes = n > 3 ? dims[3] : 1;
var payload = new byte[stride];
for (var t = 0; t < volumes; t++)
for (var z = 0; z < dims[2]; z++)
for (var y = 0; y < dims[1]; y++)
for (var x = 0; x < dims[0]; x++)
{
var index = x * strides[0] + y * strides[1] + z * strides[2] + (n > 3 ? t * strides[3] : 0);
payload[index] = value(x, y, z, t);
}

var header = Encoding.ASCII.GetBytes(
"mrtrix image\n"
+ $"dim: {string.Join(",", dims)}\n"
+ $"vox: {string.Join(",", dims.Select(_ => "1"))}\n"
+ $"layout: {string.Join(",", layout.Select(r => "+" + r))}\n"
+ "datatype: UInt8\nfile: .\nEND\n");
return new MiqVolume(MifParser.Parse([.. header, .. payload]));
}

[Theory]
[InlineData(new[] { 0, 1, 2 })]
[InlineData(new[] { 2, 0, 1 })]
[InlineData(new[] { 1, 2, 0 })]
[InlineData(new[] { 1, 0, 2 })]
public void DenseMifMask_TakesContiguousScan(int[] layout)
{
var mask = MakeMif(Dims, layout, (x, y, z, _) => InBox(x, y, z) ? (byte)5 : (byte)0);
Assert.True(mask.Volume0IsContiguous);
Assert.True(mask.BuildSegmentationLut(Auto)!.IsMonochromeWhite);

// A second label off the center planes must still be found by the full scan.
var twoLabels = MakeMif(Dims, layout, (x, y, z, _) =>
x == 0 && y == 0 && z == 0 ? (byte)7 : InBox(x, y, z) ? (byte)5 : (byte)0);
var lut = twoLabels.BuildSegmentationLut(Auto);
Assert.NotNull(lut);
Assert.False(lut!.IsMonochromeWhite);
}

[Fact]
public void VolumeSlowest4DMif_ScansOnlyVolumeZero()
{
// Volume 0 is a clean mask; volume 1 carries another label. The contiguous
// scan stops at W·H·D elements, so volume 1 must not leak into the verdict.
var volume = MakeMif([.. Dims, 2], [0, 1, 2, 3], (x, y, z, t) =>
!InBox(x, y, z) ? (byte)0 : t == 0 ? (byte)5 : (byte)9);
Assert.True(volume.Volume0IsContiguous);
Assert.True(volume.BuildSegmentationLut(Auto)!.IsMonochromeWhite);
}

[Fact]
public void VolumeFastest4DMif_FallsBackToPerVoxelWalk()
{
// DWI-style layout: the volume axis varies fastest, so the first W·H·D
// elements interleave both timepoints. A contiguous scan would see volume
// 1's label and misreport a multi-label volume.
var volume = MakeMif([.. Dims, 2], [1, 2, 3, 0], (x, y, z, t) =>
!InBox(x, y, z) ? (byte)0 : t == 0 ? (byte)5 : (byte)9);
Assert.False(volume.Volume0IsContiguous);
Assert.True(volume.BuildSegmentationLut(Auto)!.IsMonochromeWhite);
}

[Fact]
public void RowMajorImage_IsContiguous()
{
var header = new MiqHeader
{
LittleEndian = true, Dimensions = [2, 2, 2], Pixdim = [1f, 1f, 1f, 1f],
Datatype = MiqDatatype.Uint8, VoxOffset = 0, SclSlope = 0f, SclInter = 0f,
QformCode = 0, SformCode = 0, SrowX = [1, 0, 0], SrowY = [0, 1, 0], SrowZ = [0, 0, 1],
};
var vol = new MiqVolume(new MiqImage { Header = header, Storage = new byte[8], PayloadOffset = 0 });
Assert.True(vol.Volume0IsContiguous);
}
}
Loading
Loading