BioGTK  6.7.0
A .NET library & program for annotating, editing various microscopy imaging formats using Bioformats supported images. Including whole slide, pyramidal, and series.
Loading...
Searching...
No Matches
Zarr.cs
1using AForge;
2using BioLib;
3using ZarrNET.Core;
4using ZarrNET.Core.Nodes;
5using ZarrNET.Core.Helpers;
6using ZarrNET.Core.OmeZarr.Metadata;
7using BioGTK;
8using System;
9using System.Collections.Generic;
10using System.Globalization;
11using System.IO;
12using System.Linq;
13using System.Text.Json;
14using System.Text.Json.Serialization;
15using ZarrNET;
16using ZarrNET.Core;
17using ZarrNET.Core.OmeZarr.Nodes;
18
19namespace BioLib
20{
35 public static class Zarr
36 {
37 private static readonly object s_labelColorLock = new();
38 private static readonly Random s_labelColorRandom = new(unchecked((int)(DateTime.UtcNow.Ticks ^ Environment.TickCount)));
39 private static readonly Dictionary<int, Color> s_generatedLabelColors = new();
40
41 private sealed record ExportLevelInfo(
42 int SourceIndex,
43 int SizeX,
44 int SizeY,
45 ResolutionLevelDescriptor Descriptor,
46 double Downsample,
47 double ScaleX,
48 double ScaleY,
49 double PhysicalSizeX,
50 double PhysicalSizeY,
51 double PhysicalSizeZ)
52 {
53 public double AverageScale => (ScaleX + ScaleY) / 2.0;
54 }
55
56 private sealed record AssociatedLevelInfo(
57 string Name,
58 int SourceIndex,
59 int SizeX,
60 int SizeY);
61
62 private static double NormalizePhysicalSize(double value)
63 {
64 return value > 0 && !double.IsNaN(value) && !double.IsInfinity(value)
65 ? value
66 : 1.0;
67 }
68
69 private static double GetRootPhysicalSizeX(BioImage image)
70 {
71 if (image?.Resolutions != null &&
72 image.Resolutions.Count > 0 &&
73 image.Resolutions[0].PhysicalSizeX > 0)
74 return NormalizePhysicalSize(image.Resolutions[0].PhysicalSizeX);
75
76 return NormalizePhysicalSize(image?.PhysicalSizeX ?? 0);
77 }
78
79 private static double GetRootPhysicalSizeY(BioImage image)
80 {
81 if (image?.Resolutions != null &&
82 image.Resolutions.Count > 0 &&
83 image.Resolutions[0].PhysicalSizeY > 0)
84 return NormalizePhysicalSize(image.Resolutions[0].PhysicalSizeY);
85
86 return NormalizePhysicalSize(image?.PhysicalSizeY ?? 0);
87 }
88
89 private static double GetRootPhysicalSizeZ(BioImage image)
90 {
91 if (image?.Resolutions != null &&
92 image.Resolutions.Count > 0 &&
93 image.Resolutions[0].PhysicalSizeZ > 0)
94 return NormalizePhysicalSize(image.Resolutions[0].PhysicalSizeZ);
95
96 return NormalizePhysicalSize(image?.PhysicalSizeZ ?? 0);
97 }
98
99 // =====================================================================
100 // Public entry point
101 // =====================================================================
102
108 public static void SaveZarr(BioImage b, string outputDir)
109 {
110 // ------------------------------------------------------------------
111 // 1. Resolve dimensions and data type from the BioImage
112 // ------------------------------------------------------------------
113
114 // Guard against zero-size dimensions which cause ZarrNET to throw
115 // "regionStart[0] = 0 is out of bounds [0, 0)".
116 if (b.SizeX <= 0 || b.SizeY <= 0 || b.SizeZ <= 0 || b.SizeC <= 0 || b.SizeT <= 0)
117 throw new InvalidOperationException(
118 $"BioImage has invalid dimensions: X={b.SizeX} Y={b.SizeY} Z={b.SizeZ} C={b.SizeC} T={b.SizeT}. " +
119 "Ensure the image is fully loaded before saving as Zarr.");
120
121 int bitsPerSample = DetermineSourceBitsPerSample(b);
122 var sourcePixelFormat = DetermineSourcePixelFormat(b);
123 bool sourceIsInterleavedRgb = IsInterleavedRgbFormat(sourcePixelFormat);
124 int sourceChannelCount = sourceIsInterleavedRgb ? BioImage.GetBands(sourcePixelFormat) : 1;
125 bool sourceIsBGRA = IsBGRAFormat(sourcePixelFormat);
126 int exportChannelCount = sourceIsInterleavedRgb && b.SizeC == 1
127 ? Math.Min(3, sourceChannelCount)
128 : b.SizeC;
129
130 var bytesPerSample = bitsPerSample / 8;
131 var zarrDataType = MapDataType(bitsPerSample);
132 var totalChannels = exportChannelCount;
133 int tileW = 512;
134 int tileH = 512;
135
136 // ------------------------------------------------------------------
137 // 2. Build a real multiscale pyramid from b.Resolutions.
138 // BioImage may append macro/label images to the end of
139 // Resolutions, but those are exported separately as
140 // associated images. The multiscale pyramid should only
141 // contain the actual pyramid levels, in source order.
142 // ------------------------------------------------------------------
143
144 var exportLevels = BuildExportLevels(b);
145 var levelDescriptors = exportLevels.Select(l => l.Descriptor).ToList();
146 if (levelDescriptors.Count == 0)
147 {
148 levelDescriptors.Add(new ResolutionLevelDescriptor(b.SizeX, b.SizeY, 1.0));
149 exportLevels.Add(new ExportLevelInfo(
150 0,
151 b.SizeX,
152 b.SizeY,
153 levelDescriptors[0],
154 1.0,
155 1.0,
156 1.0,
157 GetRootPhysicalSizeX(b),
158 GetRootPhysicalSizeY(b),
159 GetRootPhysicalSizeZ(b)));
160 }
161
162 // ------------------------------------------------------------------
163 // 3. Build the base descriptor (T/C/Z/dtype — shared by all levels)
164 // ------------------------------------------------------------------
165
166 var coord = new ZarrNET.Core.ZCT(b.SizeZ, totalChannels, b.SizeT);
167
168 var baseDescriptor = new BioImageDescriptor(b.SizeX, b.SizeY, coord)
169 {
170 Name = Path.GetFileNameWithoutExtension(b.Filename),
171 DataType = zarrDataType,
172 PhysicalSizeX = exportLevels.Count > 0 ? exportLevels[0].PhysicalSizeX : GetRootPhysicalSizeX(b),
173 PhysicalSizeY = exportLevels.Count > 0 ? exportLevels[0].PhysicalSizeY : GetRootPhysicalSizeY(b),
174 PhysicalSizeZ = GetRootPhysicalSizeZ(b),
175 ChunkY = tileH,
176 ChunkX = tileW,
177 };
178
179 // ------------------------------------------------------------------
180 // 4. Create the writer (bootstraps zarr.json metadata on disk for
181 // all levels at once)
182 // ------------------------------------------------------------------
183
184 var writer = OmeZarrWriter.CreateAsync(outputDir, baseDescriptor, levelDescriptors).Result;
185
186 try
187 {
188 // ------------------------------------------------------------------
189 // 5. Stream tiles into each resolution level
190 // ------------------------------------------------------------------
191 for (int exportLevelIndex = 0; exportLevelIndex < exportLevels.Count; exportLevelIndex++)
192 {
193 var exportLevel = exportLevels[exportLevelIndex];
194 var lvlDesc = exportLevel.Descriptor;
195 for (int t = 0; t < b.SizeT; t++)
196 {
197 for (int z = 0; z < b.SizeZ; z++)
198 {
199 for (int c = 0; c < exportChannelCount; c++)
200 {
201 if (sourceIsInterleavedRgb)
202 {
203 WritePlaneInTiles(
204 writer, b, lvlDesc,
205 t, c, z,
206 bytesPerSample, tileW, tileH,
207 sourceLevelIndex: exportLevel.SourceIndex,
208 scaleX: exportLevel.ScaleX,
209 scaleY: exportLevel.ScaleY,
210 levelIndex: exportLevelIndex,
211 rgbChannelIndex: c,
212 srcChannelCount: sourceChannelCount,
213 isBGRA: sourceIsBGRA,
214 logicalC: 0).GetAwaiter().GetResult();
215 }
216 else
217 {
218 WritePlaneInTiles(
219 writer, b, lvlDesc,
220 t, c, z,
221 bytesPerSample, tileW, tileH,
222 sourceLevelIndex: exportLevel.SourceIndex,
223 scaleX: exportLevel.ScaleX,
224 scaleY: exportLevel.ScaleY,
225 levelIndex: exportLevelIndex).GetAwaiter().GetResult();
226 }
227 }
228 }
229 }
230 }
231
232 // ------------------------------------------------------------------
233 // 6. Export macro/label levels as associated images, if present.
234 // ------------------------------------------------------------------
235 SaveAssociatedResolutionImages(
236 b,
237 outputDir,
238 zarrDataType,
239 baseDescriptor,
240 bytesPerSample,
241 tileW,
242 tileH,
243 sourcePixelFormat,
244 sourceIsInterleavedRgb,
245 sourceChannelCount,
246 exportChannelCount,
247 sourceIsBGRA);
248
249 WriteRootMetadata(outputDir, b, exportLevels);
250 }
251 catch (Exception e)
252 {
253 Console.WriteLine(e.ToString());
254 try
255 {
256 File.WriteAllText(Path.Combine(outputDir, "save-error.txt"), e.ToString());
257 }
258 catch
259 {
260 }
261 }
262
263 Recorder.Record(BioLib.Recorder.GetCurrentMethodInfo(), false, b);
264 }
265
266 // =====================================================================
267 // Tiled plane writer
268 // =====================================================================
269
275 private static async Task WritePlaneInTiles(
276 OmeZarrWriter writer,
277 BioImage b,
278 ResolutionLevelDescriptor lvlDesc,
279 int t, int c, int z,
280 int bytesPerSample,
281 int tileW, int tileH,
282 int sourceLevelIndex = 0,
283 double scaleX = 1.0,
284 double scaleY = 1.0,
285 int levelIndex = 0,
286 int rgbChannelIndex = -1,
287 int srcChannelCount = 1,
288 bool isBGRA = false,
289 int logicalC = -1)
290 {
291 bool needsDeinterleave = rgbChannelIndex >= 0;
292 int srcC = needsDeinterleave ? logicalC : c;
293
294 for (int tileY = 0; tileY < lvlDesc.SizeY; tileY += tileH)
295 {
296 for (int tileX = 0; tileX < lvlDesc.SizeX; tileX += tileW)
297 {
298 // Clamp to image bounds — edge tiles may be smaller.
299 int actualW = Math.Min(tileW, lvlDesc.SizeX - tileX);
300 int actualH = Math.Min(tileH, lvlDesc.SizeY - tileY);
301 int pixelCount = actualW * actualH;
302 int interleavedBytes = pixelCount * srcChannelCount * bytesPerSample;
303 int singleChannelBytes = pixelCount * bytesPerSample;
304
305 int frameIndex = b.GetFrameIndex(z, srcC, t);
306
307 byte[]? pixelBytes = null;
308 AForge.PixelFormat tilePixelFormat = DetermineSourcePixelFormat(b);
309 bool useDirectSourceRead = sourceLevelIndex <= 0 ||
310 (Math.Abs(scaleX - 1.0) < 0.0001 && Math.Abs(scaleY - 1.0) < 0.0001);
311
312 if (needsDeinterleave)
313 {
314 if (useDirectSourceRead)
315 {
316 using Bitmap tileBitmap = await b.GetTile(
317 frameIndex, Math.Max(0, sourceLevelIndex), tileX, tileY, actualW, actualH,
318 new AForge.ZCT(z, srcC, t), true).ConfigureAwait(false);
319
320 tilePixelFormat = tileBitmap.PixelFormat;
321 pixelBytes = tileBitmap.Bytes;
322 }
323 else
324 {
325 int srcX = Math.Max(0, (int)Math.Round(tileX * scaleX));
326 int srcY = Math.Max(0, (int)Math.Round(tileY * scaleY));
327 int srcW = Math.Max(1, (int)Math.Round(actualW * scaleX));
328 int srcH = Math.Max(1, (int)Math.Round(actualH * scaleY));
329
330 using Bitmap tileBitmap = await b.GetTile(
331 frameIndex, 0, srcX, srcY, srcW, srcH,
332 new AForge.ZCT(z, srcC, t), true).ConfigureAwait(false);
333
334 tilePixelFormat = tileBitmap.PixelFormat;
335 pixelBytes = DownsampleBgraNearest(tileBitmap.Bytes, srcW, srcH, actualW, actualH);
336 }
337 }
338 else
339 {
340 if (useDirectSourceRead)
341 {
342 pixelBytes = await b.GetTileBytesRaw(
343 frameIndex, Math.Max(0, sourceLevelIndex), tileX, tileY, actualW, actualH,
344 new AForge.ZCT(z, srcC, t)).ConfigureAwait(false);
345 }
346 else
347 {
348 int srcX = Math.Max(0, (int)Math.Round(tileX * scaleX));
349 int srcY = Math.Max(0, (int)Math.Round(tileY * scaleY));
350 int srcW = Math.Max(1, (int)Math.Round(actualW * scaleX));
351 int srcH = Math.Max(1, (int)Math.Round(actualH * scaleY));
352
353 var srcBytes = await b.GetTileBytesRaw(
354 frameIndex, 0, srcX, srcY, srcW, srcH,
355 new AForge.ZCT(z, srcC, t)).ConfigureAwait(false);
356 pixelBytes = DownsampleRawNearest(srcBytes, srcW, srcH, actualW, actualH, bytesPerSample);
357 }
358 }
359
360 if (pixelBytes == null || pixelBytes.Length == 0)
361 {
362 pixelBytes = new byte[needsDeinterleave ? interleavedBytes : singleChannelBytes];
363 }
364
365 if (needsDeinterleave)
366 {
367 int inferredChannelCount = srcChannelCount;
368 if (pixelCount > 0 && pixelBytes.Length > 0)
369 {
370 int inferred = pixelBytes.Length / (pixelCount * bytesPerSample);
371 if (inferred >= 1 && inferred <= 4)
372 inferredChannelCount = inferred;
373 }
374
375 // Ensure the buffer is the full interleaved size before
376 // extracting a single channel from it.
377 int expectedInterleavedBytes = pixelCount * inferredChannelCount * bytesPerSample;
378 if (pixelBytes.Length != expectedInterleavedBytes)
379 {
380 var trimmed = new byte[expectedInterleavedBytes];
381 Buffer.BlockCopy(pixelBytes, 0, trimmed, 0,
382 Math.Min(pixelBytes.Length, expectedInterleavedBytes));
383 pixelBytes = trimmed;
384 }
385
386 // For BGRA buffers the byte order is [B, G, R, A].
387 // OpenSlide SVS tiles commonly arrive as BGRA even when the
388 // nominal source pixel format is reported as 24bpp RGB.
389 bool actualIsBGRA = isBGRA || inferredChannelCount == 4 || IsBGRAFormat(tilePixelFormat);
390 int srcByteIndex = actualIsBGRA ? (2 - rgbChannelIndex) : rgbChannelIndex;
391
392 pixelBytes = DeinterleaveChannel(
393 pixelBytes, srcByteIndex, inferredChannelCount,
394 bytesPerSample, pixelCount);
395 }
396 else
397 {
398 // Single-channel path: trim/pad to the exact scalar size.
399 if (pixelBytes.Length != singleChannelBytes)
400 {
401 var trimmed = new byte[singleChannelBytes];
402 Buffer.BlockCopy(pixelBytes, 0, trimmed, 0,
403 Math.Min(pixelBytes.Length, singleChannelBytes));
404 pixelBytes = trimmed;
405 }
406 }
407
408 await writer.WriteRegionAsync(
409 t, c, z,
410 tileY, tileX,
411 actualH, actualW,
412 pixelBytes,
413 levelIndex: levelIndex).ConfigureAwait(false);
414
415 }
416 }
417 }
418
423 private static byte[] ExtractRawPixels(
424 Bitmap tileBitmap, int width, int height, int bytesPerSample)
425 {
426 int rowBytes = width * bytesPerSample;
427 int totalBytes = rowBytes * height;
428 var sourceBytes = tileBitmap.Bytes;
429
430 if (sourceBytes.Length == totalBytes)
431 return sourceBytes;
432
433 var output = new byte[totalBytes];
434 int sourceStride = tileBitmap.Stride;
435
436 for (int row = 0; row < height; row++)
437 {
438 Buffer.BlockCopy(sourceBytes, row * sourceStride, output, row * rowBytes, rowBytes);
439 }
440
441 return output;
442 }
443
444 // =====================================================================
445 // Shared helpers
446 // =====================================================================
447
451 private static string MapDataType(int bitsPerSample)
452 {
453 return bitsPerSample switch
454 {
455 8 => "uint8",
456 16 => "uint16",
457 32 => "float32",
458 _ => "uint16"
459 };
460 }
461
462 private static int DetermineSourceBitsPerSample(BioImage b)
463 {
464 if (b.Resolutions.Count > 0)
465 {
466 return b.Resolutions[0].PixelFormat switch
467 {
468 AForge.PixelFormat.Format8bppIndexed => 8,
469 AForge.PixelFormat.Format24bppRgb => 8,
470 AForge.PixelFormat.Format32bppArgb => 8,
471 AForge.PixelFormat.Format32bppRgb => 8,
472 AForge.PixelFormat.Format16bppGrayScale => 16,
473 AForge.PixelFormat.Format48bppRgb => 16,
474 AForge.PixelFormat.Format64bppArgb => 16,
475 AForge.PixelFormat.Format64bppPArgb => 16,
476 _ => Math.Max(8, b.bitsPerPixel)
477 };
478 }
479
480 return Math.Max(8, b.bitsPerPixel);
481 }
482
483 private static AForge.PixelFormat DetermineSourcePixelFormat(BioImage image)
484 {
485 if (image.Resolutions.Count > 0)
486 return image.Resolutions[0].PixelFormat;
487
488 if (image.Buffers.Count > 0)
489 return image.Buffers[0].PixelFormat;
490
491 return AForge.PixelFormat.Format16bppGrayScale;
492 }
493
494 private static bool IsInterleavedRgbFormat(AForge.PixelFormat format)
495 {
496 return format == AForge.PixelFormat.Format24bppRgb ||
497 format == AForge.PixelFormat.Format32bppArgb ||
498 format == AForge.PixelFormat.Format32bppPArgb ||
499 format == AForge.PixelFormat.Format32bppRgb ||
500 format == AForge.PixelFormat.Format48bppRgb ||
501 format == AForge.PixelFormat.Format64bppArgb ||
502 format == AForge.PixelFormat.Format64bppPArgb;
503 }
504
505 private static bool IsBGRAFormat(AForge.PixelFormat format)
506 {
507 return format == AForge.PixelFormat.Format24bppRgb ||
508 format == AForge.PixelFormat.Format32bppArgb ||
509 format == AForge.PixelFormat.Format32bppPArgb ||
510 format == AForge.PixelFormat.Format32bppRgb ||
511 format == AForge.PixelFormat.Format64bppArgb ||
512 format == AForge.PixelFormat.Format64bppPArgb;
513 }
514
516 // =====================================================================
517 // ROI persistence — JSON sidecar at <outputDir>/rois.json
518 // =====================================================================
519
523 public const string RoiFileName = "rois.json";
524
530 private sealed class RoiDto
531 {
532 public string RoiId { get; set; } = "";
533 public string RoiName { get; set; } = "";
534 public string Type { get; set; } = "";
535 public string Id { get; set; } = "";
536 public int ShapeIndex { get; set; }
537 public string Text { get; set; } = "";
538 public int Serie { get; set; }
539 public int Z { get; set; }
540 public int C { get; set; }
541 public int T { get; set; }
542 public double X { get; set; }
543 public double Y { get; set; }
544 public double W { get; set; }
545 public double H { get; set; }
547 public string Points { get; set; } = "";
549 public string StrokeColor { get; set; } = "";
550 public double StrokeWidth { get; set; }
552 public string FillColor { get; set; } = "";
553 public float FontSize { get; set; }
554 }
555
563 public static void SaveROIs(BioImage b, string outputDir)
564 {
565 var dtos = new List<RoiDto>(b.Annotations.Count);
566
567 foreach (ROI an in b.Annotations)
568 {
569 if (an == null)
570 continue;
571
572 if (!string.IsNullOrWhiteSpace(an.roiID) &&
573 an.roiID.StartsWith("zarr-label:", StringComparison.OrdinalIgnoreCase))
574 {
575 continue;
576 }
577
578 // Encode points as "x,y x,y …" (invariant culture) — same
579 // format used by BioImage.ROIToString / stringToPoints.
580 PointD[] pts = an.GetPoints();
581 var ptSb = new System.Text.StringBuilder();
582 for (int i = 0; i < pts.Length; i++)
583 {
584 if (i > 0) ptSb.Append(' ');
585 ptSb.Append(pts[i].X.ToString(CultureInfo.InvariantCulture));
586 ptSb.Append(',');
587 ptSb.Append(pts[i].Y.ToString(CultureInfo.InvariantCulture));
588 }
589
590 dtos.Add(new RoiDto
591 {
592 RoiId = an.roiID,
593 RoiName = an.roiName,
594 Type = an.type.ToString(),
595 Id = an.id,
596 ShapeIndex = an.shapeIndex,
597 Text = an.Text,
598 Serie = an.serie,
599 Z = an.coord.Z,
600 C = an.coord.C,
601 T = an.coord.T,
602 X = an.X,
603 Y = an.Y,
604 W = an.W,
605 H = an.H,
606 Points = ptSb.ToString(),
607 StrokeColor = $"{an.strokeColor.A},{an.strokeColor.R},{an.strokeColor.G},{an.strokeColor.B}",
608 StrokeWidth = an.strokeWidth,
609 FillColor = $"{an.fillColor.A},{an.fillColor.R},{an.fillColor.G},{an.fillColor.B}",
610 FontSize = an.fontSize,
611 });
612 }
613
614 var opts = new JsonSerializerOptions { WriteIndented = true };
615 string json = JsonSerializer.Serialize(dtos, opts);
616 File.WriteAllText(Path.Combine(outputDir, RoiFileName), json);
617
618 Recorder.Record(BioLib.Recorder.GetCurrentMethodInfo(), false, b, outputDir);
619 }
620
627 public static void SaveV2Compatibility(BioImage image, string outputDir)
628 {
629 if (image == null)
630 return;
631
632 if (string.IsNullOrWhiteSpace(outputDir))
633 return;
634
635 if (!Directory.Exists(outputDir) && !outputDir.EndsWith(".zarr", StringComparison.OrdinalIgnoreCase))
636 return;
637
638 try
639 {
640 var sourcePixelFormat = DetermineSourcePixelFormat(image);
641 bool sourceIsInterleavedRgb = IsInterleavedRgbFormat(sourcePixelFormat);
642 int sourceChannelCount = sourceIsInterleavedRgb ? BioImage.GetBands(sourcePixelFormat) : 1;
643 int exportChannelCount = sourceIsInterleavedRgb && image.SizeC == 1
644 ? Math.Min(3, sourceChannelCount)
645 : image.SizeC;
646
647 WriteText(Path.Combine(outputDir, ".zgroup"), JsonSerializer.Serialize(new { zarr_format = 2 }, new JsonSerializerOptions { WriteIndented = true }));
648
649 var exportLevels = BuildExportLevels(image);
650 var multiscales = new[]
651 {
652 new
653 {
654 version = "0.4",
655 name = Path.GetFileNameWithoutExtension(image.Filename),
656 axes = new object[]
657 {
658 new { name = "t", type = "time" },
659 new { name = "c", type = "channel" },
660 new { name = "z", type = "space", unit = "micrometer" },
661 new { name = "y", type = "space", unit = "micrometer" },
662 new { name = "x", type = "space", unit = "micrometer" }
663 },
664 datasets = BuildV2Datasets(image, exportLevels)
665 }
666 };
667
668 var omeroNode = ZarrMetadataFixup.CreateRgbDisplayMetadata(image);
669 WriteText(Path.Combine(outputDir, ".zattrs"), JsonSerializer.Serialize(new { multiscales, omero = omeroNode }, new JsonSerializerOptions { WriteIndented = true }));
670
671 for (int i = 0; i < exportLevels.Count; i++)
672 {
673 var exportLevel = exportLevels[i];
674 var dtype = MapV2Dtype(sourcePixelFormat);
675 var chunks = new[]
676 {
677 1,
678 1,
679 Math.Max(1, image.SizeZ),
680 Math.Min(512, exportLevel.SizeY),
681 Math.Min(512, exportLevel.SizeX)
682 };
683
684 var arrayDoc = new
685 {
686 zarr_format = 2,
687 shape = new long[] { image.SizeT, exportChannelCount, image.SizeZ, exportLevel.SizeY, exportLevel.SizeX },
688 chunks,
689 dtype,
690 compressor = (object?)null,
691 fill_value = 0,
692 order = "C",
693 filters = (object?)null,
694 dimension_separator = "/"
695 };
696
697 var levelDir = Path.Combine(outputDir, i.ToString());
698 WriteText(Path.Combine(levelDir, ".zarray"), JsonSerializer.Serialize(arrayDoc, new JsonSerializerOptions { WriteIndented = true }));
699 WriteText(Path.Combine(levelDir, ".zattrs"), JsonSerializer.Serialize(new { omero = omeroNode }, new JsonSerializerOptions { WriteIndented = true }));
700 }
701
702 WriteV2Chunks(outputDir, image, exportLevels, sourcePixelFormat, sourceIsInterleavedRgb, sourceChannelCount, exportChannelCount);
703 ZarrMetadataFixup.AddRgbDisplayMetadata(outputDir, image);
704 }
705 catch (Exception ex)
706 {
707 Console.WriteLine($"Save Zarr v2 compatibility failed: {ex.Message}");
708 try
709 {
710 File.WriteAllText(Path.Combine(outputDir, "save-v2-error.txt"), ex.ToString());
711 }
712 catch
713 {
714 }
715 }
716 }
717
718 private static void SaveAssociatedResolutionImages(
719 BioImage image,
720 string outputDir,
721 string dataType,
722 BioImageDescriptor baseDescriptor,
723 int bytesPerSample,
724 int tileW,
725 int tileH,
726 AForge.PixelFormat sourcePixelFormat,
727 bool sourceIsInterleavedRgb,
728 int sourceChannelCount,
729 int exportChannelCount,
730 bool sourceIsBGRA)
731 {
732 var associatedLevels = BuildAssociatedLevelInfos(image);
733 if (associatedLevels.Count == 0)
734 return;
735
736 var manifest = new List<object>();
737 foreach (var associated in associatedLevels)
738 {
739 try
740 {
741 string associatedDir = Path.Combine(outputDir, associated.Name);
742 var associatedDescriptor = new BioImageDescriptor(associated.SizeX, associated.SizeY, new ZarrNET.Core.ZCT(image.SizeZ, exportChannelCount, image.SizeT))
743 {
744 Name = $"{Path.GetFileNameWithoutExtension(image.Filename)}_{associated.Name}",
745 DataType = dataType,
746 PhysicalSizeX = baseDescriptor.PhysicalSizeX,
747 PhysicalSizeY = baseDescriptor.PhysicalSizeY,
748 PhysicalSizeZ = baseDescriptor.PhysicalSizeZ,
749 ChunkY = tileH,
750 ChunkX = tileW,
751 };
752
753 var associatedWriter = OmeZarrWriter.CreateAsync(
754 associatedDir,
755 associatedDescriptor,
756 new[]
757 {
758 new ResolutionLevelDescriptor(associated.SizeX, associated.SizeY, 1.0)
759 }).Result;
760
761 try
762 {
763 for (int t = 0; t < image.SizeT; t++)
764 {
765 for (int z = 0; z < image.SizeZ; z++)
766 {
767 for (int c = 0; c < exportChannelCount; c++)
768 {
769 if (sourceIsInterleavedRgb)
770 {
771 WritePlaneInTiles(
772 associatedWriter, image, new ResolutionLevelDescriptor(associated.SizeX, associated.SizeY, 1.0),
773 t, c, z,
774 bytesPerSample, tileW, tileH,
775 sourceLevelIndex: associated.SourceIndex,
776 scaleX: 1.0,
777 scaleY: 1.0,
778 levelIndex: 0,
779 rgbChannelIndex: c,
780 srcChannelCount: sourceChannelCount,
781 isBGRA: sourceIsBGRA,
782 logicalC: 0).GetAwaiter().GetResult();
783 }
784 else
785 {
786 WritePlaneInTiles(
787 associatedWriter, image, new ResolutionLevelDescriptor(associated.SizeX, associated.SizeY, 1.0),
788 t, c, z,
789 bytesPerSample, tileW, tileH,
790 sourceLevelIndex: associated.SourceIndex,
791 scaleX: 1.0,
792 scaleY: 1.0,
793 levelIndex: 0).GetAwaiter().GetResult();
794 }
795 }
796 }
797 }
798 }
799 finally
800 {
801 associatedWriter.DisposeAsync().GetAwaiter().GetResult();
802 }
803
804 ZarrMetadataFixup.AddRgbDisplayMetadata(associatedDir, image);
805 manifest.Add(new
806 {
807 name = associated.Name,
808 path = associated.Name,
809 sourceIndex = associated.SourceIndex,
810 sizeX = associated.SizeX,
811 sizeY = associated.SizeY
812 });
813 }
814 catch (Exception ex)
815 {
816 Console.WriteLine($"Save associated Zarr '{associated.Name}' failed: {ex.Message}");
817 try
818 {
819 File.WriteAllText(Path.Combine(outputDir, $"save-associated-{associated.Name}-error.txt"), ex.ToString());
820 }
821 catch
822 {
823 }
824 }
825 }
826
827 if (manifest.Count > 0)
828 {
829 WriteText(
830 Path.Combine(outputDir, "associated-images.json"),
831 JsonSerializer.Serialize(manifest, new JsonSerializerOptions { WriteIndented = true }));
832 }
833 }
834
835 private static List<AssociatedLevelInfo> BuildAssociatedLevelInfos(BioImage image)
836 {
837 var levels = new List<AssociatedLevelInfo>();
838 if (image == null || image.Resolutions == null || image.Resolutions.Count == 0)
839 return levels;
840
841 void AddLevel(string name, int? index)
842 {
843 if (!index.HasValue)
844 return;
845
846 int sourceIndex = index.Value;
847 if (sourceIndex < 0 || sourceIndex >= image.Resolutions.Count)
848 return;
849
850 if (levels.Any(l => l.SourceIndex == sourceIndex))
851 return;
852
853 var res = image.Resolutions[sourceIndex];
854 if (res.SizeX <= 0 || res.SizeY <= 0)
855 return;
856
857 levels.Add(new AssociatedLevelInfo(name, sourceIndex, res.SizeX, res.SizeY));
858 }
859
860 AddLevel("macro", image.MacroResolution);
861 AddLevel("label", image.LabelResolution);
862 return levels;
863 }
864
865 private static void WriteV2Chunks(
866 string outputDir,
867 BioImage image,
868 List<ExportLevelInfo> exportLevels,
869 AForge.PixelFormat sourcePixelFormat,
870 bool sourceIsInterleavedRgb,
871 int sourceChannelCount,
872 int exportChannelCount)
873 {
874 for (int exportLevelIndex = 0; exportLevelIndex < exportLevels.Count; exportLevelIndex++)
875 {
876 var exportLevel = exportLevels[exportLevelIndex];
877 int bytesPerSample = Math.Max(1, DetermineSourceBitsPerSample(image) / 8);
878
879 for (int t = 0; t < image.SizeT; t++)
880 {
881 for (int z = 0; z < image.SizeZ; z++)
882 {
883 for (int tileY = 0; tileY < exportLevel.SizeY; tileY += 512)
884 {
885 for (int tileX = 0; tileX < exportLevel.SizeX; tileX += 512)
886 {
887 int actualW = Math.Min(512, exportLevel.SizeX - tileX);
888 int actualH = Math.Min(512, exportLevel.SizeY - tileY);
889 if (actualW <= 0 || actualH <= 0)
890 continue;
891
892 for (int c = 0; c < exportChannelCount; c++)
893 {
894 byte[] planeBytes = GetV2PlaneBytes(
895 image, exportLevel.SourceIndex, t, z, c,
896 tileX, tileY, actualW, actualH,
897 exportLevel.ScaleX, exportLevel.ScaleY,
898 sourceIsInterleavedRgb, sourceChannelCount, bytesPerSample, sourcePixelFormat).GetAwaiter().GetResult();
899
900 byte[] chunkBytes = BuildV2ChunkBytes(
901 planeBytes,
902 actualW,
903 actualH,
904 bytesPerSample,
905 512,
906 512);
907
908 string chunkPath = Path.Combine(
909 outputDir,
910 exportLevelIndex.ToString(),
911 t.ToString(),
912 c.ToString(),
913 z.ToString(),
914 (tileY / 512).ToString(),
915 (tileX / 512).ToString());
916 string? chunkDir = Path.GetDirectoryName(chunkPath);
917 if (!string.IsNullOrWhiteSpace(chunkDir))
918 Directory.CreateDirectory(chunkDir);
919
920 File.WriteAllBytes(chunkPath, chunkBytes);
921 }
922 }
923 }
924 }
925 }
926 }
927 }
928
929 private static async Task<byte[]> GetV2PlaneBytes(
930 BioImage image,
931 int levelIndex,
932 int t,
933 int z,
934 int c,
935 int tileX,
936 int tileY,
937 int actualW,
938 int actualH,
939 double scaleX,
940 double scaleY,
941 bool sourceIsInterleavedRgb,
942 int sourceChannelCount,
943 int bytesPerSample,
944 AForge.PixelFormat sourcePixelFormat)
945 {
946 if (!sourceIsInterleavedRgb)
947 {
948 bool useDirectLevel0RawRead = levelIndex <= 0 ||
949 (Math.Abs(scaleX - 1.0) < 0.0001 && Math.Abs(scaleY - 1.0) < 0.0001);
950 if (useDirectLevel0RawRead)
951 {
952 return await image.GetTileBytesRaw(
953 image.GetFrameIndex(z, c, t),
954 levelIndex,
955 tileX,
956 tileY,
957 actualW,
958 actualH,
959 new AForge.ZCT(z, c, t)).ConfigureAwait(false);
960 }
961
962 int srcX = Math.Max(0, (int)Math.Round(tileX * scaleX));
963 int srcY = Math.Max(0, (int)Math.Round(tileY * scaleY));
964 int srcW = Math.Max(1, (int)Math.Round(actualW * scaleX));
965 int srcH = Math.Max(1, (int)Math.Round(actualH * scaleY));
966 return await image.GetTileBytesRaw(
967 image.GetFrameIndex(z, c, t),
968 0,
969 srcX,
970 srcY,
971 srcW,
972 srcH,
973 new AForge.ZCT(z, c, t)).ConfigureAwait(false);
974 }
975
976 int frameIndex = image.GetFrameIndex(z, 0, t);
977 byte[]? pixelBytes = null;
978 AForge.PixelFormat tilePixelFormat = sourcePixelFormat;
979 bool useDirectLevel0RgbRead = levelIndex <= 0 ||
980 (Math.Abs(scaleX - 1.0) < 0.0001 && Math.Abs(scaleY - 1.0) < 0.0001);
981 if (useDirectLevel0RgbRead)
982 {
983 using Bitmap tileBitmap = await image.GetTile(
984 frameIndex,
985 levelIndex,
986 tileX,
987 tileY,
988 actualW,
989 actualH,
990 new AForge.ZCT(z, 0, t),
991 true).ConfigureAwait(false);
992 tilePixelFormat = tileBitmap.PixelFormat;
993 pixelBytes = tileBitmap.Bytes;
994 }
995 else
996 {
997 int srcX = Math.Max(0, (int)Math.Round(tileX * scaleX));
998 int srcY = Math.Max(0, (int)Math.Round(tileY * scaleY));
999 int srcW = Math.Max(1, (int)Math.Round(actualW * scaleX));
1000 int srcH = Math.Max(1, (int)Math.Round(actualH * scaleY));
1001 using Bitmap tileBitmap = await image.GetTile(
1002 frameIndex,
1003 0,
1004 srcX,
1005 srcY,
1006 srcW,
1007 srcH,
1008 new AForge.ZCT(z, 0, t),
1009 true).ConfigureAwait(false);
1010 tilePixelFormat = tileBitmap.PixelFormat;
1011 pixelBytes = DownsampleBgraNearest(tileBitmap.Bytes, srcW, srcH, actualW, actualH);
1012 }
1013
1014 pixelBytes ??= Array.Empty<byte>();
1015
1016 int pixelCount = actualW * actualH;
1017 if (pixelBytes == null || pixelBytes.Length == 0)
1018 return new byte[pixelCount * bytesPerSample];
1019
1020 int singleChannelBytes = pixelCount * bytesPerSample;
1021 if (pixelBytes.Length == singleChannelBytes)
1022 return pixelBytes;
1023
1024 int inferredChannelCount = sourceChannelCount;
1025 if (pixelCount > 0 && pixelBytes.Length > 0)
1026 {
1027 int inferred = pixelBytes.Length / (pixelCount * bytesPerSample);
1028 if (inferred >= 1 && inferred <= 4)
1029 inferredChannelCount = inferred;
1030 }
1031
1032 int expectedInterleavedBytes = pixelCount * inferredChannelCount * bytesPerSample;
1033 if (pixelBytes.Length != expectedInterleavedBytes)
1034 {
1035 var trimmed = new byte[expectedInterleavedBytes];
1036 Buffer.BlockCopy(pixelBytes, 0, trimmed, 0, Math.Min(pixelBytes.Length, expectedInterleavedBytes));
1037 pixelBytes = trimmed;
1038 }
1039
1040 bool actualIsBGRA = IsBGRAFormat(sourcePixelFormat) || inferredChannelCount == 4 || IsBGRAFormat(tilePixelFormat);
1041 int srcByteIndex = actualIsBGRA ? (2 - c) : c;
1042 return DeinterleaveChannel(pixelBytes, srcByteIndex, inferredChannelCount, bytesPerSample, pixelCount);
1043 }
1044
1045 private static byte[] DownsampleBgraNearest(byte[] source, int sourceWidth, int sourceHeight, int targetWidth, int targetHeight)
1046 {
1047 if (targetWidth <= 0 || targetHeight <= 0)
1048 return Array.Empty<byte>();
1049
1050 int targetBytes = targetWidth * targetHeight * 4;
1051 if (source == null || source.Length == 0 || sourceWidth <= 0 || sourceHeight <= 0)
1052 return new byte[targetBytes];
1053
1054 if (sourceWidth == targetWidth && sourceHeight == targetHeight)
1055 {
1056 if (source.Length == targetBytes)
1057 return source;
1058
1059 var exact = new byte[targetBytes];
1060 Buffer.BlockCopy(source, 0, exact, 0, Math.Min(source.Length, targetBytes));
1061 return exact;
1062 }
1063
1064 var dst = new byte[targetBytes];
1065 double scaleX = (double)sourceWidth / targetWidth;
1066 double scaleY = (double)sourceHeight / targetHeight;
1067
1068 for (int y = 0; y < targetHeight; y++)
1069 {
1070 int srcY = Math.Min(sourceHeight - 1, (int)Math.Floor(y * scaleY));
1071 int srcRow = srcY * sourceWidth * 4;
1072 int dstRow = y * targetWidth * 4;
1073 for (int x = 0; x < targetWidth; x++)
1074 {
1075 int srcX = Math.Min(sourceWidth - 1, (int)Math.Floor(x * scaleX));
1076 int srcOff = srcRow + srcX * 4;
1077 int dstOff = dstRow + x * 4;
1078 if (srcOff + 3 < source.Length)
1079 {
1080 dst[dstOff + 0] = source[srcOff + 0];
1081 dst[dstOff + 1] = source[srcOff + 1];
1082 dst[dstOff + 2] = source[srcOff + 2];
1083 dst[dstOff + 3] = source[srcOff + 3];
1084 }
1085 }
1086 }
1087
1088 return dst;
1089 }
1090
1091 private static byte[] DownsampleRawNearest(byte[] source, int sourceWidth, int sourceHeight, int targetWidth, int targetHeight, int bytesPerSample)
1092 {
1093 if (targetWidth <= 0 || targetHeight <= 0 || bytesPerSample <= 0)
1094 return Array.Empty<byte>();
1095
1096 int targetBytes = targetWidth * targetHeight * bytesPerSample;
1097 if (source == null || source.Length == 0 || sourceWidth <= 0 || sourceHeight <= 0)
1098 return new byte[targetBytes];
1099
1100 if (sourceWidth == targetWidth && sourceHeight == targetHeight)
1101 {
1102 if (source.Length == targetBytes)
1103 return source;
1104
1105 var exact = new byte[targetBytes];
1106 Buffer.BlockCopy(source, 0, exact, 0, Math.Min(source.Length, targetBytes));
1107 return exact;
1108 }
1109
1110 var dst = new byte[targetBytes];
1111 double scaleX = (double)sourceWidth / targetWidth;
1112 double scaleY = (double)sourceHeight / targetHeight;
1113
1114 for (int y = 0; y < targetHeight; y++)
1115 {
1116 int srcY = Math.Min(sourceHeight - 1, (int)Math.Floor(y * scaleY));
1117 int srcRow = srcY * sourceWidth * bytesPerSample;
1118 int dstRow = y * targetWidth * bytesPerSample;
1119 for (int x = 0; x < targetWidth; x++)
1120 {
1121 int srcX = Math.Min(sourceWidth - 1, (int)Math.Floor(x * scaleX));
1122 int srcOff = srcRow + srcX * bytesPerSample;
1123 int dstOff = dstRow + x * bytesPerSample;
1124 if (srcOff + bytesPerSample <= source.Length)
1125 Buffer.BlockCopy(source, srcOff, dst, dstOff, bytesPerSample);
1126 }
1127 }
1128
1129 return dst;
1130 }
1131
1132 private static Task<byte[]?> TryGetRawTileFromBufferAsync(
1133 BioImage image,
1134 int index,
1135 int tileX,
1136 int tileY,
1137 int actualW,
1138 int actualH)
1139 {
1140 try
1141 {
1142 var method = typeof(BioImage).GetMethod(
1143 "TryGetRawTileFromBuffer",
1144 System.Reflection.BindingFlags.Instance |
1145 System.Reflection.BindingFlags.NonPublic);
1146
1147 if (method == null)
1148 return Task.FromResult<byte[]?>(null);
1149
1150 var result = method.Invoke(image, new object[] { index, tileX, tileY, actualW, actualH });
1151 return Task.FromResult(result as byte[]);
1152 }
1153 catch
1154 {
1155 return Task.FromResult<byte[]?>(null);
1156 }
1157 }
1158
1159 private static byte[] BuildV2ChunkBytes(
1160 byte[] planeBytes,
1161 int actualW,
1162 int actualH,
1163 int bytesPerSample,
1164 int chunkW,
1165 int chunkH)
1166 {
1167 if (planeBytes == null)
1168 return Array.Empty<byte>();
1169
1170 int chunkPixels = Math.Max(0, chunkW) * Math.Max(0, chunkH);
1171 var output = new byte[chunkPixels * bytesPerSample];
1172
1173 int actualRowBytes = Math.Max(0, actualW) * bytesPerSample;
1174 int paddedRowBytes = Math.Max(0, chunkW) * bytesPerSample;
1175 int copyHeight = Math.Min(actualH, chunkH);
1176 int copyWidth = Math.Min(actualW, chunkW);
1177
1178 for (int row = 0; row < copyHeight; row++)
1179 {
1180 int srcOffset = row * actualRowBytes;
1181 int dstOffset = row * paddedRowBytes;
1182 int copyBytes = Math.Min(copyWidth * bytesPerSample, Math.Max(0, planeBytes.Length - srcOffset));
1183 if (copyBytes > 0)
1184 Buffer.BlockCopy(planeBytes, srcOffset, output, dstOffset, copyBytes);
1185 }
1186
1187 return output;
1188 }
1189
1195 public static List<ROI> LoadROIs(string zarrDir)
1196 {
1197 var result = new List<ROI>();
1198 string path = Path.Combine(zarrDir, RoiFileName);
1199
1200 if (!File.Exists(path))
1201 return result;
1202
1203 string json = File.ReadAllText(path);
1204 var dtos = JsonSerializer.Deserialize<List<RoiDto>>(json);
1205 if (dtos == null)
1206 return result;
1207
1208 foreach (var dto in dtos)
1209 {
1210 if (!string.IsNullOrWhiteSpace(dto.RoiId) &&
1211 dto.RoiId.StartsWith("zarr-label:", StringComparison.OrdinalIgnoreCase))
1212 {
1213 continue;
1214 }
1215
1216 var an = new ROI
1217 {
1218 roiID = dto.RoiId,
1219 roiName = dto.RoiName,
1220 type = (ROI.Type)Enum.Parse(typeof(ROI.Type), dto.Type),
1221 id = dto.Id,
1222 shapeIndex = dto.ShapeIndex,
1223 Text = dto.Text,
1224 serie = dto.Serie,
1225 coord = new AForge.ZCT(dto.Z, dto.C, dto.T),
1226 strokeWidth= dto.StrokeWidth,
1227 fontSize = dto.FontSize,
1228 };
1229
1230 // Closed shapes
1231 if (an.type == ROI.Type.Freeform || an.type == ROI.Type.Polygon)
1232 an.closed = true;
1233
1234 // Restore colours
1235 if (!string.IsNullOrEmpty(dto.StrokeColor))
1236 {
1237 var sc = dto.StrokeColor.Split(',');
1238 an.strokeColor = AForge.Color.FromArgb(
1239 int.Parse(sc[0]), int.Parse(sc[1]),
1240 int.Parse(sc[2]), int.Parse(sc[3]));
1241 }
1242 if (!string.IsNullOrEmpty(dto.FillColor))
1243 {
1244 var fc = dto.FillColor.Split(',');
1245 an.fillColor = AForge.Color.FromArgb(
1246 int.Parse(fc[0]), int.Parse(fc[1]),
1247 int.Parse(fc[2]), int.Parse(fc[3]));
1248 }
1249
1250 // Restore points then bounding box
1251 if (!string.IsNullOrEmpty(dto.Points))
1252 an.AddPoints(an.stringToPoints(dto.Points));
1253
1254 an.BoundingBox = new AForge.RectangleD(dto.X, dto.Y, dto.W, dto.H);
1255
1256 result.Add(an);
1257 }
1258
1259 Recorder.Record(BioLib.Recorder.GetCurrentMethodInfo(), false, zarrDir);
1260 return result;
1261 }
1262
1266 public static void SaveLabelOverlays(BioImage image, IReadOnlyList<ROI> overlays, string outputDir)
1267 {
1268 SaveLabelOverlaysAsync(image, overlays, outputDir).GetAwaiter().GetResult();
1269 }
1270
1274 public static async System.Threading.Tasks.Task SaveLabelOverlaysAsync(
1275 BioImage image,
1276 IReadOnlyList<ROI> overlays,
1277 string outputDir,
1278 CancellationToken ct = default)
1279 {
1280 if (image == null || overlays == null || overlays.Count == 0)
1281 return;
1282
1283 if (string.IsNullOrWhiteSpace(outputDir))
1284 return;
1285
1286 if (!Directory.Exists(outputDir) && !outputDir.EndsWith(".zarr", StringComparison.OrdinalIgnoreCase))
1287 return;
1288
1289 var labelRois = overlays
1290 .Where(IsSupportedLabelRoi)
1291 .ToList();
1292
1293 if (labelRois.Count == 0)
1294 return;
1295
1296 int baseWidth = image.Resolutions.Count > 0 ? image.Resolutions[0].SizeX : image.SizeX;
1297 int baseHeight = image.Resolutions.Count > 0 ? image.Resolutions[0].SizeY : image.SizeY;
1298 if (baseWidth <= 0 || baseHeight <= 0)
1299 return;
1300
1301 int sizeT = Math.Max(1, image.SizeT);
1302 int sizeC = Math.Max(1, image.SizeC);
1303 int sizeZ = Math.Max(1, image.SizeZ);
1304 var labelColors = new Dictionary<int, AForge.Color>();
1305 var roiEntries = new List<(ROI Roi, int LabelId)>();
1306
1307 foreach (var roi in labelRois)
1308 {
1309 int labelId = ParseLabelId(roi.Text, roiEntries.Count + 1);
1310 if (labelId <= 0)
1311 continue;
1312
1313 roiEntries.Add((roi, labelId));
1314
1315 if (!labelColors.ContainsKey(labelId))
1316 {
1317 var col = roi.fillColor;
1318 if (col.A == 0)
1319 col = roi.strokeColor;
1320 if (col.A == 0)
1321 col = GetFallbackLabelColor(labelId);
1322 labelColors[labelId] = col;
1323 }
1324 }
1325
1326 if (labelColors.Count == 0)
1327 return;
1328
1329 var exportLevels = BuildExportLevels(image);
1330 if (baseWidth <= 0 || baseHeight <= 0)
1331 return;
1332
1333 using var store = new ZarrNET.Core.Zarr.Store.LocalFileSystemStore(outputDir);
1334 var rootGroup = await ZarrNET.Core.Zarr.ZarrGroup.OpenRootAsync(store, ct).ConfigureAwait(false);
1335
1336 await WriteJsonAsync(
1337 store,
1338 "labels/zarr.json",
1339 new
1340 {
1341 zarr_format = 3,
1342 node_type = "group",
1343 attributes = new
1344 {
1345 ome = new
1346 {
1347 version = "0.5",
1348 labels = new[] { "0" }
1349 }
1350 }
1351 },
1352 ct).ConfigureAwait(false);
1353
1354 var colors = labelColors
1355 .OrderBy(kv => kv.Key)
1356 .Select(kv => new Dictionary<string, object?>
1357 {
1358 ["label-value"] = kv.Key,
1359 ["rgba"] = new[] { kv.Value.R, kv.Value.G, kv.Value.B, kv.Value.A }
1360 })
1361 .ToArray();
1362
1363 var labelAxes = new object[]
1364 {
1365 new { name = "t", type = "time" },
1366 new { name = "c", type = "channel" },
1367 new { name = "z", type = "space", unit = "micrometer" },
1368 new { name = "y", type = "space", unit = "micrometer" },
1369 new { name = "x", type = "space", unit = "micrometer" }
1370 };
1371
1372 var labelImageGroupDoc = new Dictionary<string, object?>
1373 {
1374 ["version"] = "0.5",
1375 ["multiscales"] = new[]
1376 {
1377 new
1378 {
1379 version = "0.5",
1380 name = "0",
1381 axes = labelAxes,
1382 datasets = BuildV3Datasets(exportLevels)
1383 }
1384 },
1385 ["image-label"] = new Dictionary<string, object?>
1386 {
1387 ["version"] = "0.5",
1388 ["source"] = new Dictionary<string, object?>
1389 {
1390 ["image"] = "../../"
1391 },
1392 ["colors"] = colors,
1393 ["properties"] = Array.Empty<object>()
1394 }
1395 };
1396
1397 await WriteJsonAsync(store, "labels/0/zarr.json", new
1398 {
1399 zarr_format = 3,
1400 node_type = "group",
1401 attributes = new
1402 {
1403 ome = labelImageGroupDoc
1404 }
1405 }, ct).ConfigureAwait(false);
1406
1407 var labelGroup = await rootGroup.OpenGroupAsync("labels", ct).ConfigureAwait(false);
1408 var labelImageGroup = await labelGroup.OpenGroupAsync("0", ct).ConfigureAwait(false);
1409
1410 for (int levelIndex = 0; levelIndex < exportLevels.Count; levelIndex++)
1411 {
1412 var exportLevel = exportLevels[levelIndex];
1413 int levelWidth = exportLevel.SizeX;
1414 int levelHeight = exportLevel.SizeY;
1415 double scaleX = baseWidth > 0 ? levelWidth / (double)baseWidth : 1.0;
1416 double scaleY = baseHeight > 0 ? levelHeight / (double)baseHeight : 1.0;
1417
1418 int planePixels = levelWidth * levelHeight;
1419 int planeStride = planePixels;
1420 int zStride = planeStride;
1421 int cStride = zStride * sizeZ;
1422 int tStride = cStride * sizeC;
1423
1424 var labelVolume = new short[planePixels * sizeZ * sizeC * sizeT];
1425 foreach (var entry in roiEntries)
1426 {
1427 var roi = entry.Roi;
1428 int labelId = entry.LabelId;
1429
1430 int z = Math.Clamp(roi.coord.Z, 0, sizeZ - 1);
1431 int c = Math.Clamp(roi.coord.C, 0, sizeC - 1);
1432 int t = Math.Clamp(roi.coord.T, 0, sizeT - 1);
1433 int offset = (t * tStride) + (c * cStride) + (z * zStride);
1434
1435 RasterizeRoi(
1436 labelVolume,
1437 levelWidth,
1438 levelHeight,
1439 roi,
1440 labelId,
1441 offset,
1442 scaleX,
1443 scaleY,
1444 image.Volume.Location.X,
1445 image.Volume.Location.Y,
1446 exportLevel.PhysicalSizeX,
1447 exportLevel.PhysicalSizeY);
1448 }
1449
1450 var levelArrayDoc = new
1451 {
1452 zarr_format = 3,
1453 node_type = "array",
1454 shape = new long[] { sizeT, sizeC, sizeZ, levelHeight, levelWidth },
1455 data_type = "int16",
1456 chunk_grid = new
1457 {
1458 name = "regular",
1459 configuration = new
1460 {
1461 chunk_shape = new[]
1462 {
1463 1,
1464 1,
1465 1,
1466 Math.Min(512, levelHeight),
1467 Math.Min(512, levelWidth)
1468 }
1469 }
1470 },
1471 chunk_key_encoding = new
1472 {
1473 name = "default",
1474 configuration = new { separator = "/" }
1475 },
1476 fill_value = 0,
1477 dimension_names = new[] { "t", "c", "z", "y", "x" },
1478 codecs = new object[]
1479 {
1480 new
1481 {
1482 name = "blosc",
1483 configuration = new
1484 {
1485 cname = "lz4",
1486 clevel = 5,
1487 shuffle = "byteshuffle",
1488 typesize = 2,
1489 blocksize = 0
1490 }
1491 }
1492 }
1493 };
1494
1495 await WriteJsonAsync(store, $"labels/0/{levelIndex}/zarr.json", levelArrayDoc, ct).ConfigureAwait(false);
1496
1497 var labelArray = await labelImageGroup.OpenArrayAsync(levelIndex.ToString(), ct).ConfigureAwait(false);
1498 var labelBytes = new byte[labelVolume.Length * sizeof(short)];
1499 Buffer.BlockCopy(labelVolume, 0, labelBytes, 0, labelBytes.Length);
1500
1501 await labelArray.WriteRegionAsync(
1502 new long[] { 0, 0, 0, 0, 0 },
1503 new long[] { sizeT, sizeC, sizeZ, levelHeight, levelWidth },
1504 labelBytes,
1505 ct).ConfigureAwait(false);
1506 }
1507
1508 WriteText(Path.Combine(outputDir, "labels", ".zgroup"), JsonSerializer.Serialize(new { zarr_format = 2 }, new JsonSerializerOptions { WriteIndented = true }));
1509 WriteText(Path.Combine(outputDir, "labels", ".zattrs"), JsonSerializer.Serialize(new { labels = new[] { "0" } }, new JsonSerializerOptions { WriteIndented = true }));
1510
1511 WriteText(Path.Combine(outputDir, "labels", "0", ".zgroup"), JsonSerializer.Serialize(new { zarr_format = 2 }, new JsonSerializerOptions { WriteIndented = true }));
1512
1513 var labelV2Array = new
1514 {
1515 zarr_format = 2,
1516 shape = new long[] { sizeT, sizeC, sizeZ, baseHeight, baseWidth },
1517 chunks = new[] { 1, 1, 1, Math.Min(512, baseHeight), Math.Min(512, baseWidth) },
1518 dtype = "<i2",
1519 compressor = new
1520 {
1521 id = "blosc",
1522 cname = "lz4",
1523 clevel = 5,
1524 shuffle = 1,
1525 blocksize = 0
1526 },
1527 fill_value = 0,
1528 order = "C",
1529 filters = (object?)null,
1530 dimension_separator = "/"
1531 };
1532
1533 WriteText(Path.Combine(outputDir, "labels", "0", "0", ".zarray"), JsonSerializer.Serialize(labelV2Array, new JsonSerializerOptions { WriteIndented = true }));
1534 var labelAttrs = new Dictionary<string, object?>
1535 {
1536 ["multiscales"] = new[]
1537 {
1538 new
1539 {
1540 version = "0.4",
1541 name = "0",
1542 axes = new object[]
1543 {
1544 new { name = "t", type = "time" },
1545 new { name = "c", type = "channel" },
1546 new { name = "z", type = "space", unit = "micrometer" },
1547 new { name = "y", type = "space", unit = "micrometer" },
1548 new { name = "x", type = "space", unit = "micrometer" }
1549 },
1550 datasets = new[]
1551 {
1552 new
1553 {
1554 path = "0",
1555 coordinateTransformations = new object[]
1556 {
1557 new
1558 {
1559 type = "scale",
1560 scale = new[]
1561 {
1562 1.0,
1563 1.0,
1564 GetRootPhysicalSizeZ(image),
1565 GetRootPhysicalSizeY(image),
1566 GetRootPhysicalSizeX(image)
1567 }
1568 }
1569 }
1570 }
1571 }
1572 }
1573 },
1574 ["image-label"] = new Dictionary<string, object?>
1575 {
1576 ["version"] = "0.4",
1577 ["source"] = new Dictionary<string, object?>
1578 {
1579 ["href"] = "../.."
1580 },
1581 ["colors"] = colors,
1582 ["properties"] = Array.Empty<object>()
1583 }
1584 };
1585
1586 WriteText(
1587 Path.Combine(outputDir, "labels", "0", ".zattrs"),
1588 JsonSerializer.Serialize(labelAttrs, new JsonSerializerOptions { WriteIndented = true }));
1589
1590 Recorder.Record(BioLib.Recorder.GetCurrentMethodInfo(), false, image, outputDir, labelRois.Count);
1591 }
1592
1597 public static async System.Threading.Tasks.Task<List<ROI>> LoadLabelOverlaysAsync(BioImage image, string? sourceOverride = null)
1598 {
1599 var overlays = new List<ROI>();
1600
1601 if (image == null)
1602 return overlays;
1603
1604 string source = sourceOverride ?? image.file;
1605 if (string.IsNullOrWhiteSpace(source))
1606 source = image.Filename;
1607
1608 if (string.IsNullOrWhiteSpace(source) ||
1609 (!source.Contains(".zarr", StringComparison.OrdinalIgnoreCase) && !Directory.Exists(source)))
1610 return overlays;
1611
1612 try
1613 {
1614 var reader = await OmeZarrReader.OpenAsync(source).ConfigureAwait(false);
1615 var imageNode = await reader.AsMultiscaleImageAsync().ConfigureAwait(false);
1616
1617 if (!await imageNode.HasLabelsAsync().ConfigureAwait(false))
1618 return overlays;
1619
1620 var labelGroup = await imageNode.OpenLabelsAsync().ConfigureAwait(false);
1621 foreach (var labelName in labelGroup.LabelNames)
1622 {
1623 try
1624 {
1625 var labelNode = await labelGroup.OpenLabelAsync(labelName).ConfigureAwait(false);
1626 var multiscale = labelNode.Multiscales.FirstOrDefault();
1627 if (multiscale == null || multiscale.Datasets.Length == 0)
1628 continue;
1629
1630 var level = await labelNode.OpenResolutionLevelAsync(datasetIndex: 0).ConfigureAwait(false);
1631 var axes = level.EffectiveAxes;
1632 var rawShape = level.Shape;
1633 var shape = rawShape;
1634 bool hasLeadingSingletonT = shape.Length == axes.Length + 1 && shape[0] == 1;
1635 if (hasLeadingSingletonT)
1636 {
1637 // Local Zarr label exports currently include an
1638 // extra leading singleton t dimension even though
1639 // the label metadata only exposes c/z/y/x axes.
1640 // Normalize that here so existing saved datasets
1641 // can be read without misaligning axes and shape.
1642 shape = shape.Skip(1).ToArray();
1643 }
1644
1645 int sizeX = GetAxisSizeStatic(axes, shape, "x");
1646 int sizeY = GetAxisSizeStatic(axes, shape, "y");
1647 int sizeZ = Math.Max(1, GetAxisSizeStatic(axes, shape, "z", 1));
1648 int sizeC = Math.Max(1, GetAxisSizeStatic(axes, shape, "c", 1));
1649 int sizeT = Math.Max(1, GetAxisSizeStatic(axes, shape, "t", 1));
1650
1651 if (sizeX <= 0 || sizeY <= 0)
1652 continue;
1653
1654 double physX = GetRootPhysicalSizeX(image);
1655 double physY = GetRootPhysicalSizeY(image);
1656 double stageX = image.Volume.Location.X;
1657 double stageY = image.Volume.Location.Y;
1658 if (TryGetMultiscaleLabelTransform(multiscale, axes, out double labelPhysX, out double labelPhysY, out double labelStageX, out double labelStageY))
1659 {
1660 physX = labelPhysX;
1661 physY = labelPhysY;
1662 stageX = labelStageX;
1663 stageY = labelStageY;
1664 }
1665
1666 try
1667 {
1668 int shapeIdx = 0;
1669 for (int t = 0; t < sizeT; t++)
1670 {
1671 for (int z = 0; z < sizeZ; z++)
1672 {
1673 for (int c = 0; c < sizeC; c++)
1674 {
1675 int sourceC = c;
1676 int viewerC = c;
1677 if (sizeZ == 1 && sizeC == 1 && sizeT == 1 && image.SizeC > 1)
1678 viewerC = image.SizeC - 1;
1679
1680 var regionData = await level.ReadTileAsync(
1681 0,
1682 0,
1683 sizeX,
1684 sizeY,
1685 z: z,
1686 c: sourceC,
1687 t: t).ConfigureAwait(false);
1688 if (regionData == null || regionData.Data == null || regionData.Data.Length == 0)
1689 continue;
1690
1691 GetRegionDimensions(regionData, sizeX, sizeY, out int planeW, out int planeH);
1692 if (planeW <= 0 || planeH <= 0)
1693 continue;
1694
1695 int totalPixels = planeW * planeH;
1696 if (totalPixels <= 0)
1697 continue;
1698
1699 int bytesPerPixel = Math.Max(1, regionData.Data.Length / totalPixels);
1700 var knownLabelValues = GetKnownLabelValues(labelNode.ImageLabelMetadata);
1701 var labelMasks = SplitLabelPlane(regionData.Data, planeW, planeH, bytesPerPixel, knownLabelValues);
1702 if (labelMasks.Count == 0)
1703 continue;
1704
1705 var zct = new AForge.ZCT(z, viewerC, t);
1706 foreach (var kv in labelMasks)
1707 {
1708 int labelId = kv.Key;
1709 byte[] maskData = kv.Value;
1710 var color = PickLabelColor(labelNode.ImageLabelMetadata, labelId);
1711
1712 var mask = new ROI.Mask(
1713 maskData,
1714 planeW,
1715 planeH,
1716 physX,
1717 physY,
1718 stageX,
1719 stageY,
1720 preserveDimensions: false);
1721 var roi = new ROI
1722 {
1723 type = ROI.Type.Mask,
1724 roiID = $"zarr-label:{labelName}_{labelId}",
1725 roiName = labelName,
1726 Text = labelId.ToString(),
1727 coord = zct,
1728 shapeIndex = shapeIdx++,
1729 strokeColor = color,
1730 fillColor = color,
1731 strokeWidth = 1,
1732 roiMask = mask,
1733 };
1734 roi.UpdateBoundingBox();
1735 overlays.Add(roi);
1736 }
1737 }
1738 }
1739 }
1740 }
1741 catch (Exception ex)
1742 {
1743 Console.WriteLine($"Zarr label '{labelName}' plane t={image.Coordinate.T} c={image.Coordinate.C} z={image.Coordinate.Z} failed: {ex.Message}");
1744 }
1745 }
1746 catch (Exception ex)
1747 {
1748 Console.WriteLine($"Load Zarr labels failed for '{labelName}': {ex.Message}");
1749 }
1750 }
1751 }
1752 catch (Exception ex)
1753 {
1754 Console.WriteLine($"Load Zarr labels failed: {ex.Message}");
1755 }
1756
1757 return overlays;
1758 }
1759
1760 private static async System.Threading.Tasks.Task WriteJsonAsync(
1761 ZarrNET.Core.Zarr.Store.IZarrStore store,
1762 string key,
1763 object document,
1764 CancellationToken ct = default)
1765 {
1766 var options = new JsonSerializerOptions { WriteIndented = true };
1767 var json = JsonSerializer.SerializeToUtf8Bytes(document, options);
1768 await store.WriteAsync(key, json, ct).ConfigureAwait(false);
1769 }
1770
1771 private static int ParseLabelId(string? text, int fallback)
1772 {
1773 if (!string.IsNullOrWhiteSpace(text) &&
1774 int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsed))
1775 {
1776 return parsed;
1777 }
1778
1779 return fallback;
1780 }
1781
1782 private static bool IsSupportedLabelRoi(ROI roi)
1783 {
1784 if (roi == null)
1785 return false;
1786
1787 return roi.type == ROI.Type.Mask ||
1788 roi.type == ROI.Type.Point ||
1789 roi.type == ROI.Type.Line ||
1790 roi.type == ROI.Type.Rectangle ||
1791 roi.type == ROI.Type.Ellipse ||
1792 roi.type == ROI.Type.Polygon ||
1793 roi.type == ROI.Type.Polyline ||
1794 roi.type == ROI.Type.Freeform ||
1795 roi.type == ROI.Type.Label;
1796 }
1797
1798 private static void RasterizeRoi(
1799 short[] labelPlane,
1800 int imageWidth,
1801 int imageHeight,
1802 ROI roi,
1803 int labelId,
1804 int planeOffset,
1805 double scaleX,
1806 double scaleY,
1807 double imageStageX,
1808 double imageStageY,
1809 double targetPhysicalSizeX,
1810 double targetPhysicalSizeY)
1811 {
1812 if (roi == null || labelPlane == null || labelPlane.Length == 0)
1813 return;
1814
1815 if (roi.type == ROI.Type.Mask && roi.roiMask != null)
1816 {
1817 RasterizeMask(
1818 labelPlane,
1819 imageWidth,
1820 imageHeight,
1821 roi.roiMask,
1822 labelId,
1823 planeOffset,
1824 imageStageX,
1825 imageStageY,
1826 targetPhysicalSizeX,
1827 targetPhysicalSizeY);
1828 return;
1829 }
1830
1831 int thickness = Math.Max(1, (int)Math.Round(Math.Max(1.0, roi.strokeWidth)));
1832
1833 switch (roi.type)
1834 {
1835 case ROI.Type.Point:
1836 case ROI.Type.Label:
1837 {
1838 var p = roi.Point;
1839 int px = (int)Math.Round(p.X * scaleX);
1840 int py = (int)Math.Round(p.Y * scaleY);
1841 PlotThickPoint(labelPlane, imageWidth, imageHeight, planeOffset, px, py, labelId, Math.Max(1, thickness));
1842 return;
1843 }
1844
1845 case ROI.Type.Line:
1846 {
1847 var pts = roi.GetPoints();
1848 if (pts == null || pts.Length < 2)
1849 return;
1850
1851 DrawLine(labelPlane, imageWidth, imageHeight, planeOffset,
1852 (int)Math.Round(pts[0].X * scaleX), (int)Math.Round(pts[0].Y * scaleY),
1853 (int)Math.Round(pts[1].X * scaleX), (int)Math.Round(pts[1].Y * scaleY),
1854 labelId, thickness);
1855 return;
1856 }
1857
1858 case ROI.Type.Rectangle:
1859 FillRectangle(labelPlane, imageWidth, imageHeight, planeOffset, roi.BoundingBox, labelId, scaleX, scaleY);
1860 return;
1861
1862 case ROI.Type.Ellipse:
1863 FillEllipse(labelPlane, imageWidth, imageHeight, planeOffset, roi.BoundingBox, labelId, scaleX, scaleY);
1864 return;
1865
1866 case ROI.Type.Polygon:
1867 RasterizePolygonLike(labelPlane, imageWidth, imageHeight, planeOffset, roi, labelId, scaleX, scaleY, thickness);
1868 return;
1869
1870 case ROI.Type.Polyline:
1871 RasterizePolyline(labelPlane, imageWidth, imageHeight, planeOffset, roi, labelId, scaleX, scaleY, thickness);
1872 return;
1873
1874 case ROI.Type.Freeform:
1875 RasterizePolygonLike(labelPlane, imageWidth, imageHeight, planeOffset, roi, labelId, scaleX, scaleY, thickness);
1876 return;
1877 }
1878 }
1879
1880 private static void RasterizePolyline(short[] labelPlane, int imageWidth, int imageHeight, int planeOffset, ROI roi, int labelId, double scaleX, double scaleY, int thickness)
1881 {
1882 var pts = roi.GetPoints();
1883 if (pts == null || pts.Length < 2)
1884 return;
1885
1886 for (int i = 0; i < pts.Length - 1; i++)
1887 {
1888 DrawLine(labelPlane, imageWidth, imageHeight, planeOffset,
1889 (int)Math.Round(pts[i].X * scaleX), (int)Math.Round(pts[i].Y * scaleY),
1890 (int)Math.Round(pts[i + 1].X * scaleX), (int)Math.Round(pts[i + 1].Y * scaleY),
1891 labelId, thickness);
1892 }
1893 }
1894
1895 private static void RasterizePolygonLike(short[] labelPlane, int imageWidth, int imageHeight, int planeOffset, ROI roi, int labelId, double scaleX, double scaleY, int thickness)
1896 {
1897 var pts = roi.GetPoints();
1898 if (pts == null || pts.Length == 0)
1899 return;
1900
1901 if (pts.Length < 3)
1902 {
1903 RasterizePolyline(labelPlane, imageWidth, imageHeight, planeOffset, roi, labelId, scaleX, scaleY, thickness);
1904 return;
1905 }
1906
1907 if (roi.type == ROI.Type.Freeform || roi.closed)
1908 {
1909 var scaled = pts.Select(p => new PointD(p.X * scaleX, p.Y * scaleY)).ToArray();
1910 FillPolygon(labelPlane, imageWidth, imageHeight, planeOffset, scaled, labelId);
1911 }
1912 else
1913 {
1914 RasterizePolyline(labelPlane, imageWidth, imageHeight, planeOffset, roi, labelId, scaleX, scaleY, thickness);
1915 }
1916 }
1917
1918 private static void FillRectangle(short[] labelPlane, int imageWidth, int imageHeight, int planeOffset, AForge.RectangleD rect, int labelId, double scaleX, double scaleY)
1919 {
1920 double x0 = rect.X * scaleX;
1921 double y0 = rect.Y * scaleY;
1922 double x1 = (rect.X + rect.W) * scaleX;
1923 double y1 = (rect.Y + rect.H) * scaleY;
1924
1925 int minX = Math.Max(0, (int)Math.Floor(Math.Min(x0, x1)));
1926 int maxX = Math.Min(imageWidth - 1, (int)Math.Ceiling(Math.Max(x0, x1)));
1927 int minY = Math.Max(0, (int)Math.Floor(Math.Min(y0, y1)));
1928 int maxY = Math.Min(imageHeight - 1, (int)Math.Ceiling(Math.Max(y0, y1)));
1929
1930 for (int y = minY; y <= maxY; y++)
1931 {
1932 int rowOffset = planeOffset + (y * imageWidth);
1933 for (int x = minX; x <= maxX; x++)
1934 {
1935 if (rowOffset + x >= 0 && rowOffset + x < labelPlane.Length)
1936 labelPlane[rowOffset + x] = (short)Math.Clamp(labelId, short.MinValue, short.MaxValue);
1937 }
1938 }
1939 }
1940
1941 private static void FillEllipse(short[] labelPlane, int imageWidth, int imageHeight, int planeOffset, AForge.RectangleD rect, int labelId, double scaleX, double scaleY)
1942 {
1943 double x0 = rect.X * scaleX;
1944 double y0 = rect.Y * scaleY;
1945 double x1 = (rect.X + rect.W) * scaleX;
1946 double y1 = (rect.Y + rect.H) * scaleY;
1947
1948 double left = Math.Min(x0, x1);
1949 double top = Math.Min(y0, y1);
1950 double right = Math.Max(x0, x1);
1951 double bottom = Math.Max(y0, y1);
1952 double rx = (right - left) / 2.0;
1953 double ry = (bottom - top) / 2.0;
1954 if (rx <= 0 || ry <= 0)
1955 {
1956 FillRectangle(labelPlane, imageWidth, imageHeight, planeOffset, rect, labelId, scaleX, scaleY);
1957 return;
1958 }
1959
1960 double cx = left + rx;
1961 double cy = top + ry;
1962 int minX = Math.Max(0, (int)Math.Floor(left));
1963 int maxX = Math.Min(imageWidth - 1, (int)Math.Ceiling(right));
1964 int minY = Math.Max(0, (int)Math.Floor(top));
1965 int maxY = Math.Min(imageHeight - 1, (int)Math.Ceiling(bottom));
1966
1967 for (int y = minY; y <= maxY; y++)
1968 {
1969 int rowOffset = planeOffset + (y * imageWidth);
1970 double ny = ((y + 0.5) - cy) / ry;
1971 for (int x = minX; x <= maxX; x++)
1972 {
1973 double nx = ((x + 0.5) - cx) / rx;
1974 if ((nx * nx) + (ny * ny) <= 1.0 && rowOffset + x >= 0 && rowOffset + x < labelPlane.Length)
1975 labelPlane[rowOffset + x] = (short)Math.Clamp(labelId, short.MinValue, short.MaxValue);
1976 }
1977 }
1978 }
1979
1980 private static void FillPolygon(short[] labelPlane, int imageWidth, int imageHeight, int planeOffset, PointD[] points, int labelId)
1981 {
1982 if (points == null || points.Length < 3)
1983 return;
1984
1985 double minX = points.Min(p => p.X);
1986 double maxX = points.Max(p => p.X);
1987 double minY = points.Min(p => p.Y);
1988 double maxY = points.Max(p => p.Y);
1989
1990 int iMinX = Math.Max(0, (int)Math.Floor(minX));
1991 int iMaxX = Math.Min(imageWidth - 1, (int)Math.Ceiling(maxX));
1992 int iMinY = Math.Max(0, (int)Math.Floor(minY));
1993 int iMaxY = Math.Min(imageHeight - 1, (int)Math.Ceiling(maxY));
1994
1995 for (int y = iMinY; y <= iMaxY; y++)
1996 {
1997 int rowOffset = planeOffset + (y * imageWidth);
1998 for (int x = iMinX; x <= iMaxX; x++)
1999 {
2000 if (!PointInPolygon(x + 0.5, y + 0.5, points))
2001 continue;
2002
2003 if (rowOffset + x >= 0 && rowOffset + x < labelPlane.Length)
2004 labelPlane[rowOffset + x] = (short)Math.Clamp(labelId, short.MinValue, short.MaxValue);
2005 }
2006 }
2007 }
2008
2009 private static bool PointInPolygon(double x, double y, PointD[] points)
2010 {
2011 bool inside = false;
2012 int j = points.Length - 1;
2013
2014 for (int i = 0; i < points.Length; i++)
2015 {
2016 double xi = points[i].X;
2017 double yi = points[i].Y;
2018 double xj = points[j].X;
2019 double yj = points[j].Y;
2020
2021 bool intersect = ((yi > y) != (yj > y)) &&
2022 (x < (xj - xi) * (y - yi) / Math.Max(1e-12, yj - yi) + xi);
2023 if (intersect)
2024 inside = !inside;
2025
2026 j = i;
2027 }
2028
2029 return inside;
2030 }
2031
2032 private static void DrawLine(short[] labelPlane, int imageWidth, int imageHeight, int planeOffset, int x0, int y0, int x1, int y1, int labelId, int thickness)
2033 {
2034 int dx = Math.Abs(x1 - x0);
2035 int sx = x0 < x1 ? 1 : -1;
2036 int dy = -Math.Abs(y1 - y0);
2037 int sy = y0 < y1 ? 1 : -1;
2038 int err = dx + dy;
2039
2040 while (true)
2041 {
2042 PlotThickPoint(labelPlane, imageWidth, imageHeight, planeOffset, x0, y0, labelId, thickness);
2043 if (x0 == x1 && y0 == y1)
2044 break;
2045
2046 int e2 = 2 * err;
2047 if (e2 >= dy)
2048 {
2049 err += dy;
2050 x0 += sx;
2051 }
2052 if (e2 <= dx)
2053 {
2054 err += dx;
2055 y0 += sy;
2056 }
2057 }
2058 }
2059
2060 private static void PlotThickPoint(short[] labelPlane, int imageWidth, int imageHeight, int planeOffset, int x, int y, int labelId, int thickness)
2061 {
2062 int radius = Math.Max(0, (thickness - 1) / 2);
2063 for (int yy = y - radius; yy <= y + radius; yy++)
2064 {
2065 if (yy < 0 || yy >= imageHeight)
2066 continue;
2067
2068 int rowOffset = planeOffset + (yy * imageWidth);
2069 for (int xx = x - radius; xx <= x + radius; xx++)
2070 {
2071 if (xx < 0 || xx >= imageWidth)
2072 continue;
2073
2074 int index = rowOffset + xx;
2075 if (index >= 0 && index < labelPlane.Length)
2076 labelPlane[index] = (short)Math.Clamp(labelId, short.MinValue, short.MaxValue);
2077 }
2078 }
2079 }
2080
2081 private static void RasterizeMask(
2082 short[] labelPlane,
2083 int imageWidth,
2084 int imageHeight,
2085 ROI.Mask mask,
2086 int labelId,
2087 int planeOffset,
2088 double imageStageX,
2089 double imageStageY,
2090 double targetPhysicalSizeX,
2091 double targetPhysicalSizeY)
2092 {
2093 targetPhysicalSizeX = targetPhysicalSizeX > 0 ? targetPhysicalSizeX : 1.0;
2094 targetPhysicalSizeY = targetPhysicalSizeY > 0 ? targetPhysicalSizeY : 1.0;
2095 double maskPhysicalSizeX = mask.PhysicalSizeX > 0 ? mask.PhysicalSizeX : targetPhysicalSizeX;
2096 double maskPhysicalSizeY = mask.PhysicalSizeY > 0 ? mask.PhysicalSizeY : targetPhysicalSizeY;
2097
2098 int startY = 0;
2099 int startX = 0;
2100 int endY = mask.Height;
2101 int endX = mask.Width;
2102
2103 for (int y = startY; y < endY; y++)
2104 {
2105 double worldY = (mask.Y + y) * maskPhysicalSizeY;
2106 int dstY = (int)Math.Round((worldY - imageStageY) / targetPhysicalSizeY);
2107 if (dstY < 0 || dstY >= imageHeight)
2108 continue;
2109
2110 int rowOffset = dstY * imageWidth;
2111
2112 for (int x = startX; x < endX; x++)
2113 {
2114 if (mask.GetValue(x, y) <= 0)
2115 continue;
2116
2117 double worldX = (mask.X + x) * maskPhysicalSizeX;
2118 int dstX = (int)Math.Round((worldX - imageStageX) / targetPhysicalSizeX);
2119 if (dstX < 0 || dstX >= imageWidth)
2120 continue;
2121
2122 int index = planeOffset + rowOffset + dstX;
2123 if (index >= 0 && index < labelPlane.Length)
2124 labelPlane[index] = (short)Math.Clamp(labelId, short.MinValue, short.MaxValue);
2125 }
2126 }
2127 }
2128
2129 private static int GetAxisLength(AxisMetadata[] axes, long[] shape, string axisName)
2130 {
2131 for (int i = 0; i < axes.Length && i < shape.Length; i++)
2132 {
2133 if (string.Equals(axes[i].Name, axisName, StringComparison.OrdinalIgnoreCase))
2134 return (int)shape[i];
2135 }
2136 return 0;
2137 }
2138
2139 private static void GetRegionDimensions(RegionResult region, int fallbackWidth, int fallbackHeight, out int width, out int height)
2140 {
2141 width = Math.Max(1, fallbackWidth);
2142 height = Math.Max(1, fallbackHeight);
2143
2144 if (region?.Shape == null || region.Shape.Length == 0)
2145 return;
2146
2147 if (region.Axes != null && region.Axes.Length == region.Shape.Length)
2148 {
2149 for (int i = 0; i < region.Axes.Length; i++)
2150 {
2151 string axisName = region.Axes[i].Name?.ToLowerInvariant() ?? string.Empty;
2152 if (axisName == "x")
2153 width = (int)Math.Max(1, region.Shape[i]);
2154 else if (axisName == "y")
2155 height = (int)Math.Max(1, region.Shape[i]);
2156 }
2157 return;
2158 }
2159
2160 if (region.Shape.Length >= 2)
2161 {
2162 width = (int)Math.Max(1, region.Shape[^1]);
2163 height = (int)Math.Max(1, region.Shape[^2]);
2164 }
2165 }
2166
2167 private static HashSet<int>? GetKnownLabelValues(ImageLabelMetadata metadata)
2168 {
2169 if (metadata?.Colors == null)
2170 return null;
2171
2172 HashSet<int> values = new();
2173 foreach (var entry in metadata.Colors)
2174 {
2175 if (entry.LabelValue > 0)
2176 values.Add(entry.LabelValue);
2177 }
2178
2179 return values.Count > 0 ? values : null;
2180 }
2181
2182 private static Dictionary<int, byte[]> SplitLabelPlane(byte[] raw, int w, int h, int bytesPerPixel, ISet<int>? knownLabelValues = null)
2183 {
2184 int total = w * h;
2185 var result = new Dictionary<int, byte[]>();
2186
2187 for (int i = 0; i < total; i++)
2188 {
2189 int labelId = ReadLabelId(raw, i, bytesPerPixel);
2190 if (labelId == 0)
2191 continue;
2192 if (knownLabelValues != null && !knownLabelValues.Contains(labelId))
2193 continue;
2194
2195 if (!result.TryGetValue(labelId, out byte[]? mask))
2196 {
2197 mask = new byte[total];
2198 result[labelId] = mask;
2199 }
2200
2201 mask[i] = 255;
2202 }
2203
2204 return result;
2205 }
2206
2207 private static int ReadLabelId(byte[] raw, int pixelIndex, int bytesPerPixel)
2208 {
2209 int byteOffset = pixelIndex * bytesPerPixel;
2210 if (byteOffset < 0 || byteOffset >= raw.Length)
2211 return 0;
2212
2213 return bytesPerPixel switch
2214 {
2215 1 => raw[byteOffset],
2216 2 => BitConverter.ToUInt16(raw, byteOffset),
2217 4 => BitConverter.ToInt32(raw, byteOffset),
2218 _ => raw[byteOffset],
2219 };
2220 }
2221
2222 private static long[] BuildStart(AxisMetadata[] axes, long[] shape, int t, int c, int z)
2223 {
2224 long[] start = new long[axes.Length];
2225 for (int i = 0; i < axes.Length; i++)
2226 {
2227 start[i] = axes[i].Name.ToLowerInvariant() switch
2228 {
2229 "t" => t,
2230 "c" => c,
2231 "z" => z,
2232 _ => 0,
2233 };
2234 }
2235 return start;
2236 }
2237
2238 private static long[] BuildEnd(AxisMetadata[] axes, long[] shape, int t, int c, int z, int sizeX, int sizeY)
2239 {
2240 long[] end = new long[axes.Length];
2241 for (int i = 0; i < axes.Length; i++)
2242 {
2243 end[i] = axes[i].Name.ToLowerInvariant() switch
2244 {
2245 "t" => t + 1,
2246 "c" => c + 1,
2247 "z" => z + 1,
2248 "x" => sizeX,
2249 "y" => sizeY,
2250 _ => shape[i],
2251 };
2252 }
2253 return end;
2254 }
2255
2256 private static int GetAxisSizeStatic(AxisMetadata[] axes, long[] shape, string name, int fallback = 0)
2257 {
2258 for (int i = 0; i < axes.Length; i++)
2259 {
2260 if (axes[i].Name.Equals(name, StringComparison.OrdinalIgnoreCase))
2261 return (int)shape[i];
2262 }
2263 return fallback;
2264 }
2265
2266 private static byte[] ToBinaryMask(byte[] data, string dataType, int pixelCount)
2267 {
2268 int bytesPerPixel = dataType switch
2269 {
2270 "int8" => 1,
2271 "uint8" => 1,
2272 "int16" => 2,
2273 "uint16" => 2,
2274 "int32" => 4,
2275 "uint32" => 4,
2276 "float32" => 4,
2277 "int64" => 8,
2278 "uint64" => 8,
2279 "float64" => 8,
2280 _ => pixelCount > 0 ? Math.Max(1, data.Length / pixelCount) : 1
2281 };
2282
2283 if (pixelCount <= 0 || data.Length == 0)
2284 return Array.Empty<byte>();
2285
2286 var mask = new byte[pixelCount];
2287 for (int i = 0; i < pixelCount; i++)
2288 {
2289 int offset = i * bytesPerPixel;
2290 if (offset >= data.Length)
2291 break;
2292
2293 bool nonZero = false;
2294 for (int b = 0; b < bytesPerPixel && (offset + b) < data.Length; b++)
2295 {
2296 if (data[offset + b] != 0)
2297 {
2298 nonZero = true;
2299 break;
2300 }
2301 }
2302
2303 if (nonZero)
2304 mask[i] = 255;
2305 }
2306
2307 return mask;
2308 }
2309
2310 private static object[] BuildV2Datasets(BioImage image, List<ExportLevelInfo> exportLevels)
2311 {
2312 var datasets = new List<object>();
2313 for (int i = 0; i < exportLevels.Count; i++)
2314 {
2315 var exportLevel = exportLevels[i];
2316 datasets.Add(new
2317 {
2318 path = i.ToString(),
2319 coordinateTransformations = new object[]
2320 {
2321 new
2322 {
2323 type = "scale",
2324 scale = new[]
2325 {
2326 1.0,
2327 1.0,
2328 exportLevel.PhysicalSizeZ > 0 ? exportLevel.PhysicalSizeZ : 1.0,
2329 exportLevel.PhysicalSizeY > 0 ? exportLevel.PhysicalSizeY : 1.0,
2330 exportLevel.PhysicalSizeX > 0 ? exportLevel.PhysicalSizeX : 1.0
2331 }
2332 }
2333 }
2334 });
2335 }
2336
2337 return datasets.ToArray();
2338 }
2339
2340 private static void WriteRootMetadata(string outputDir, BioImage image, List<ExportLevelInfo> exportLevels)
2341 {
2342 var omeroNode = ZarrMetadataFixup.CreateRgbDisplayMetadata(image);
2343 var multiscales = new[]
2344 {
2345 new
2346 {
2347 version = "0.5",
2348 name = Path.GetFileNameWithoutExtension(image.Filename),
2349 axes = new object[]
2350 {
2351 new { name = "t", type = "time" },
2352 new { name = "c", type = "channel" },
2353 new { name = "z", type = "space", unit = "micrometer" },
2354 new { name = "y", type = "space", unit = "micrometer" },
2355 new { name = "x", type = "space", unit = "micrometer" }
2356 },
2357 datasets = BuildV3Datasets(exportLevels)
2358 }
2359 };
2360
2361 var rootDoc = new
2362 {
2363 zarr_format = 3,
2364 node_type = "group",
2365 attributes = new
2366 {
2367 ome = new
2368 {
2369 version = "0.5",
2370 multiscales,
2371 omero = omeroNode
2372 }
2373 }
2374 };
2375
2376 WriteText(
2377 Path.Combine(outputDir, "zarr.json"),
2378 JsonSerializer.Serialize(rootDoc, new JsonSerializerOptions { WriteIndented = true }));
2379 }
2380
2381 private static object[] BuildV3Datasets(List<ExportLevelInfo> exportLevels)
2382 {
2383 var datasets = new List<object>();
2384 for (int i = 0; i < exportLevels.Count; i++)
2385 {
2386 var exportLevel = exportLevels[i];
2387 datasets.Add(new
2388 {
2389 path = i.ToString(),
2390 coordinateTransformations = new object[]
2391 {
2392 new
2393 {
2394 type = "scale",
2395 scale = new[]
2396 {
2397 1.0,
2398 1.0,
2399 exportLevel.PhysicalSizeZ > 0 ? exportLevel.PhysicalSizeZ : 1.0,
2400 exportLevel.PhysicalSizeY > 0 ? exportLevel.PhysicalSizeY : 1.0,
2401 exportLevel.PhysicalSizeX > 0 ? exportLevel.PhysicalSizeX : 1.0
2402 }
2403 }
2404 }
2405 });
2406 }
2407
2408 return datasets.ToArray();
2409 }
2410
2411 private static List<ExportLevelInfo> BuildExportLevels(BioImage b)
2412 {
2413 var exportLevels = new List<ExportLevelInfo>();
2414 if (b == null)
2415 return exportLevels;
2416
2417 if (b.Resolutions.Count == 0)
2418 {
2419 exportLevels.Add(new ExportLevelInfo(
2420 0,
2421 b.SizeX,
2422 b.SizeY,
2423 new ResolutionLevelDescriptor(b.SizeX, b.SizeY, 1.0),
2424 1.0,
2425 1.0,
2426 1.0,
2427 GetRootPhysicalSizeX(b),
2428 GetRootPhysicalSizeY(b),
2429 GetRootPhysicalSizeZ(b)));
2430 return exportLevels;
2431 }
2432
2433 int lastSizeX = int.MaxValue;
2434 int lastSizeY = int.MaxValue;
2435 bool haveLast = false;
2436
2437 for (int sourceIndex = 0; sourceIndex < b.Resolutions.Count; sourceIndex++)
2438 {
2439 var res = b.Resolutions[sourceIndex];
2440 if (res.SizeX <= 0 || res.SizeY <= 0)
2441 continue;
2442
2443 if (haveLast && (res.SizeX > lastSizeX || res.SizeY > lastSizeY))
2444 break;
2445
2446 if (haveLast && res.SizeX == lastSizeX && res.SizeY == lastSizeY)
2447 continue;
2448
2449 double downsample = b.GetLevelDownsample(sourceIndex);
2450 if (!double.IsFinite(downsample) || downsample <= 0)
2451 downsample = 1.0;
2452
2453 double basePhysicalSizeX = exportLevels.Count > 0
2454 ? exportLevels[0].PhysicalSizeX
2455 : GetRootPhysicalSizeX(b);
2456 double basePhysicalSizeY = exportLevels.Count > 0
2457 ? exportLevels[0].PhysicalSizeY
2458 : GetRootPhysicalSizeY(b);
2459 double physicalSizeX = NormalizePhysicalSize(basePhysicalSizeX * downsample);
2460 double physicalSizeY = NormalizePhysicalSize(basePhysicalSizeY * downsample);
2461
2462 exportLevels.Add(new ExportLevelInfo(
2463 sourceIndex,
2464 res.SizeX,
2465 res.SizeY,
2466 new ResolutionLevelDescriptor(res.SizeX, res.SizeY, downsample),
2467 downsample,
2468 downsample,
2469 downsample,
2470 physicalSizeX,
2471 physicalSizeY,
2472 NormalizePhysicalSize(res.PhysicalSizeZ)));
2473
2474 lastSizeX = res.SizeX;
2475 lastSizeY = res.SizeY;
2476 haveLast = true;
2477 }
2478
2479 if (exportLevels.Count == 0 && b.SizeX > 0 && b.SizeY > 0)
2480 {
2481 exportLevels.Add(new ExportLevelInfo(
2482 0,
2483 b.SizeX,
2484 b.SizeY,
2485 new ResolutionLevelDescriptor(b.SizeX, b.SizeY, 1.0),
2486 1.0,
2487 1.0,
2488 1.0,
2489 GetRootPhysicalSizeX(b),
2490 GetRootPhysicalSizeY(b),
2491 GetRootPhysicalSizeZ(b)));
2492 }
2493
2494 return exportLevels;
2495 }
2496
2497 private static bool IsAssociatedResolution(BioImage image, int sourceIndex)
2498 {
2499 if (image == null)
2500 return false;
2501
2502 return (image.MacroResolution.HasValue && image.MacroResolution.Value == sourceIndex) ||
2503 (image.LabelResolution.HasValue && image.LabelResolution.Value == sourceIndex);
2504 }
2505
2506 private static string MapV2Dtype(AForge.PixelFormat pixelFormat)
2507 {
2508 return pixelFormat switch
2509 {
2510 AForge.PixelFormat.Format8bppIndexed => "|u1",
2511 AForge.PixelFormat.Format24bppRgb => "|u1",
2512 AForge.PixelFormat.Format32bppArgb => "|u1",
2513 AForge.PixelFormat.Format32bppRgb => "|u1",
2514 AForge.PixelFormat.Format16bppGrayScale => "<u2",
2515 AForge.PixelFormat.Format48bppRgb => "<u2",
2516 _ => "<u2"
2517 };
2518 }
2519
2520 private static object BuildV2BloscCompressor(AForge.PixelFormat pixelFormat)
2521 {
2522 return new
2523 {
2524 id = "blosc",
2525 cname = "lz4",
2526 clevel = 5,
2527 shuffle = 1,
2528 blocksize = 0
2529 };
2530 }
2531
2532 private static void WriteText(string path, string content)
2533 {
2534 var directory = Path.GetDirectoryName(path);
2535 if (!string.IsNullOrWhiteSpace(directory))
2536 Directory.CreateDirectory(directory);
2537
2538 File.WriteAllText(path, content);
2539 }
2540
2541 private static bool IsSupportedLabelDataType(string dataType)
2542 {
2543 return string.Equals(dataType, "bool", StringComparison.OrdinalIgnoreCase) ||
2544 string.Equals(dataType, "int8", StringComparison.OrdinalIgnoreCase) ||
2545 string.Equals(dataType, "uint8", StringComparison.OrdinalIgnoreCase) ||
2546 string.Equals(dataType, "int16", StringComparison.OrdinalIgnoreCase) ||
2547 string.Equals(dataType, "uint16", StringComparison.OrdinalIgnoreCase) ||
2548 string.Equals(dataType, "int32", StringComparison.OrdinalIgnoreCase) ||
2549 string.Equals(dataType, "uint32", StringComparison.OrdinalIgnoreCase) ||
2550 string.Equals(dataType, "int64", StringComparison.OrdinalIgnoreCase) ||
2551 string.Equals(dataType, "uint64", StringComparison.OrdinalIgnoreCase) ||
2552 string.Equals(dataType, "float32", StringComparison.OrdinalIgnoreCase) ||
2553 string.Equals(dataType, "float64", StringComparison.OrdinalIgnoreCase);
2554 }
2555
2556 private static Color PickLabelColor(ImageLabelMetadata metadata, int labelValue)
2557 {
2558 if (metadata?.Colors != null)
2559 {
2560 foreach (var entry in metadata.Colors)
2561 {
2562 if (entry.LabelValue != labelValue)
2563 continue;
2564
2565 var rgba = entry.Rgba;
2566 if (rgba != null && rgba.Length >= 4 &&
2567 (rgba[0] != 0 || rgba[1] != 0 || rgba[2] != 0 || rgba[3] != 0))
2568 {
2569 return Color.FromArgb(rgba[3], rgba[0], rgba[1], rgba[2]);
2570 }
2571 }
2572 }
2573
2574 return GetFallbackLabelColor(labelValue);
2575 }
2576
2577 private static bool TryGetMultiscaleLabelTransform(object? multiscale, AxisMetadata[] axes, out double physX, out double physY, out double stageX, out double stageY)
2578 {
2579 physX = 1.0;
2580 physY = 1.0;
2581 stageX = 0.0;
2582 stageY = 0.0;
2583
2584 if (multiscale == null || axes == null || axes.Length == 0)
2585 return false;
2586
2587 object? dataset = GetEnumerableFirst(GetPropertyValue(multiscale, "Datasets"));
2588 if (dataset == null)
2589 return false;
2590
2591 object? transforms = GetPropertyValue(dataset, "CoordinateTransformations");
2592 if (transforms == null)
2593 return false;
2594
2595 int xIndex = Array.FindIndex(axes, a => string.Equals(a.Name, "x", StringComparison.OrdinalIgnoreCase));
2596 int yIndex = Array.FindIndex(axes, a => string.Equals(a.Name, "y", StringComparison.OrdinalIgnoreCase));
2597 if (xIndex < 0 || yIndex < 0)
2598 return false;
2599
2600 double[] origin = ApplyCoordinateTransforms(transforms, axes.Length, xIndex, yIndex, 0.0, 0.0);
2601 double[] xPoint = ApplyCoordinateTransforms(transforms, axes.Length, xIndex, yIndex, 1.0, 0.0);
2602 double[] yPoint = ApplyCoordinateTransforms(transforms, axes.Length, xIndex, yIndex, 0.0, 1.0);
2603
2604 if (origin.Length <= Math.Max(xIndex, yIndex) ||
2605 xPoint.Length <= xIndex ||
2606 yPoint.Length <= yIndex)
2607 return false;
2608
2609 stageX = origin[xIndex];
2610 stageY = origin[yIndex];
2611 physX = xPoint[xIndex] - origin[xIndex];
2612 physY = yPoint[yIndex] - origin[yIndex];
2613
2614 if (!double.IsFinite(physX) || physX <= 0)
2615 physX = 1.0;
2616 if (!double.IsFinite(physY) || physY <= 0)
2617 physY = 1.0;
2618
2619 return true;
2620 }
2621
2622 private static object? GetEnumerableFirst(object? value)
2623 {
2624 if (value is not System.Collections.IEnumerable enumerable)
2625 return null;
2626
2627 foreach (var item in enumerable)
2628 return item;
2629 return null;
2630 }
2631
2632 private static object? GetPropertyValue(object? obj, params string[] names)
2633 {
2634 if (obj == null || names == null || names.Length == 0)
2635 return null;
2636
2637 var type = obj.GetType();
2638 foreach (var name in names)
2639 {
2640 var prop = type.GetProperty(name);
2641 if (prop != null)
2642 return prop.GetValue(obj);
2643 }
2644
2645 return null;
2646 }
2647
2648 private static double[] ApplyCoordinateTransforms(object transforms, int rank, int xIndex, int yIndex, double x, double y)
2649 {
2650 var coordinates = new double[Math.Max(rank, Math.Max(xIndex, yIndex) + 1)];
2651 if (xIndex >= 0 && xIndex < coordinates.Length)
2652 coordinates[xIndex] = x;
2653 if (yIndex >= 0 && yIndex < coordinates.Length)
2654 coordinates[yIndex] = y;
2655
2656 if (transforms is not System.Collections.IEnumerable enumerable)
2657 return coordinates;
2658
2659 foreach (var transform in enumerable)
2660 {
2661 if (transform == null)
2662 continue;
2663
2664 string? type = GetPropertyValue(transform, "Type")?.ToString() ?? GetPropertyValue(transform, "type")?.ToString();
2665 if (string.IsNullOrWhiteSpace(type))
2666 continue;
2667
2668 if (type.Equals("scale", StringComparison.OrdinalIgnoreCase))
2669 {
2670 double[]? scale = GetDoubleArray(GetPropertyValue(transform, "Scale", "scale"));
2671 if (scale == null || scale.Length == 0)
2672 continue;
2673
2674 for (int i = 0; i < coordinates.Length && i < scale.Length; i++)
2675 coordinates[i] *= scale[i];
2676 }
2677 else if (type.Equals("translation", StringComparison.OrdinalIgnoreCase))
2678 {
2679 double[]? translation = GetDoubleArray(GetPropertyValue(transform, "Translation", "translation"));
2680 if (translation == null || translation.Length == 0)
2681 continue;
2682
2683 for (int i = 0; i < coordinates.Length && i < translation.Length; i++)
2684 coordinates[i] += translation[i];
2685 }
2686 }
2687
2688 return coordinates;
2689 }
2690
2691 private static double[]? GetDoubleArray(object? value)
2692 {
2693 if (value == null)
2694 return null;
2695
2696 if (value is double[] d)
2697 return d;
2698
2699 if (value is float[] f)
2700 return f.Select(v => (double)v).ToArray();
2701
2702 if (value is int[] i)
2703 return i.Select(v => (double)v).ToArray();
2704
2705 if (value is long[] l)
2706 return l.Select(v => (double)v).ToArray();
2707
2708 if (value is System.Collections.IEnumerable enumerable)
2709 {
2710 var list = new List<double>();
2711 foreach (var item in enumerable)
2712 {
2713 if (item == null)
2714 continue;
2715
2716 list.Add(Convert.ToDouble(item, CultureInfo.InvariantCulture));
2717 }
2718 return list.ToArray();
2719 }
2720
2721 return null;
2722 }
2723
2724 private static Color GetFallbackLabelColor(int labelValue)
2725 {
2726 lock (s_labelColorLock)
2727 {
2728 if (s_generatedLabelColors.TryGetValue(labelValue, out var cached))
2729 return cached;
2730
2731 var color = ColorFromHsv((s_labelColorRandom.NextDouble() + (labelValue * 0.6180339887498949)) % 1.0);
2732 s_generatedLabelColors[labelValue] = color;
2733 return color;
2734 }
2735 }
2736
2737 private static Color ColorFromHsv(double hue)
2738 {
2739 double saturation = 0.65 + (s_labelColorRandom.NextDouble() * 0.2);
2740 double value = 0.80 + (s_labelColorRandom.NextDouble() * 0.15);
2741 hue = (hue % 1.0 + 1.0) % 1.0;
2742 double h = hue * 6.0;
2743 int sector = (int)Math.Floor(h);
2744 double fraction = h - sector;
2745 double p = value * (1.0 - saturation);
2746 double q = value * (1.0 - saturation * fraction);
2747 double t = value * (1.0 - saturation * (1.0 - fraction));
2748
2749 double r, g, b;
2750 switch (sector % 6)
2751 {
2752 case 0: r = value; g = t; b = p; break;
2753 case 1: r = q; g = value; b = p; break;
2754 case 2: r = p; g = value; b = t; break;
2755 case 3: r = p; g = q; b = value; break;
2756 case 4: r = t; g = p; b = value; break;
2757 default: r = value; g = p; b = q; break;
2758 }
2759
2760 return Color.FromArgb(
2761 96,
2762 (byte)Math.Clamp((int)Math.Round(r * 255.0), 0, 255),
2763 (byte)Math.Clamp((int)Math.Round(g * 255.0), 0, 255),
2764 (byte)Math.Clamp((int)Math.Round(b * 255.0), 0, 255));
2765 }
2766
2767 // =====================================================================
2768 // Channel-extraction helper
2769 // =====================================================================
2770
2779 private static byte[] DeinterleaveChannel(
2780 byte[] interleaved,
2781 int channelIndex,
2782 int totalChannels,
2783 int bytesPerSample,
2784 int pixelCount)
2785 {
2786 var output = new byte[pixelCount * bytesPerSample];
2787 var stride = totalChannels * bytesPerSample;
2788 var channelStart = channelIndex * bytesPerSample;
2789
2790 for (int px = 0; px < pixelCount; px++)
2791 {
2792 var srcOffset = px * stride + channelStart;
2793 var dstOffset = px * bytesPerSample;
2794
2795 for (int byteIdx = 0; byteIdx < bytesPerSample; byteIdx++)
2796 output[dstOffset + byteIdx] = interleaved[srcOffset + byteIdx];
2797 }
2798
2799 return output;
2800 }
2801 }
2802}
Definition ML.cs:17
Definition Zarr.cs:20