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
SlideRenderer.cs
1using System;
2using System.Collections.Generic;
3using System.Linq;
4using System.Threading;
5using System.Threading.Tasks;
6using AForge;
7using BruTile;
8using Gtk;
9using OpenSlideGTK;
10using ZarrNET.Core.Nodes;
11using OmeZarrReader = ZarrNET.Core.OmeZarrReader;
12
13namespace BioGTK
14{
15 public class SlideRenderer
16 {
17 private readonly SlideGLArea _glArea;
18 private OpenSlideBase _openSlideBase;
19 private SlideBase _slideBase;
20 private bool _useOpenSlide;
21 private BioLib.TileCache _tileCache;
22 private OpenSlideGTK.TileCache _openTileCache;
23 private HashSet<TileIndex> _uploadedTiles = new();
24 // Stores the TileInfo (including world-space Extent) for every tile in the GPU cache
25 // so the render list can be rebuilt from all cached tiles, not just those fetched this frame.
26 private Dictionary<TileIndex, TileInfo> _uploadedTileInfos = new();
27 private readonly object _uploadedTileLock = new object();
28 private int _currentLevel = -1;
29 private readonly object _pendingLevelSwitchLock = new object();
30 private List<TileRenderInfo> _pendingLevelRenderInfos;
31 private int _pendingLevel = -1;
32 private int _pendingPreviousLevel = -1;
33 private readonly SemaphoreSlim _renderSemaphore = new SemaphoreSlim(1, 1);
34 private readonly SemaphoreSlim _fetchSemaphore = new SemaphoreSlim(6, 6);
35 private static readonly TimeSpan RemoteTileFetchTimeout = TimeSpan.FromSeconds(120);
36 private const int MaxMissingTileRetries = 8;
37 // Bumped whenever image state changes in a way that invalidates the
38 // current render result, such as threshold or channel-range updates.
39 private volatile int _renderStateVersion = 0;
40 // Changes on pan/zoom. Unlike _renderStateVersion, this does not make
41 // downloaded tile pixels stale and therefore must not cancel fetches.
42 private volatile int _viewportVersion = 0;
43 public bool LastRenderSkipped { get; private set; }
44 // Latest args saved when a render is skipped so the re-queued render uses current state.
45 private volatile bool _pendingRequeue = false;
46 // Tracks tiles already being fetched so repeated render passes do not
47 // launch duplicate remote requests for the same tile.
48 private readonly HashSet<TileIndex> _pendingTileFetches = new();
49 private readonly Dictionary<TileIndex, int> _tileRetryCounts = new();
50 private readonly object _pendingTileFetchLock = new object();
51 private readonly object _activeTileFetchLock = new object();
52 private CancellationTokenSource _activeTileFetchCts;
53 private int _redrawQueued = 0;
54 private int _displayRangeProbeStarted = 0;
55 private int _displayRangeSeedLevel = -1;
56 private readonly SemaphoreSlim _directZarrInitSemaphore = new SemaphoreSlim(1, 1);
57 private OmeZarrReader _directZarrReader;
58 private List<ResolutionLevelNode> _directZarrLevels;
59 private string _directZarrSource;
60
61 public SlideRenderer(SlideGLArea glArea)
62 {
63 _glArea = glArea;
64 }
65
66 private static Task RunOnGtkThreadAsync(System.Action action)
67 {
68 var tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
69 Gtk.Application.Invoke((s, a) =>
70 {
71 try
72 {
73 action();
74 tcs.TrySetResult(true);
75 }
76 catch (Exception ex)
77 {
78 tcs.TrySetException(ex);
79 }
80 });
81 return tcs.Task;
82 }
83
84 public void SetSource(OpenSlideBase source)
85 {
86 ResetDirectZarrReader();
87 _glArea.DiscardCompleteFrame();
88 _openSlideBase = source;
89 _slideBase = null;
90 _useOpenSlide = true;
91 _openTileCache = new OpenSlideGTK.TileCache(source, 200);
92 ClearCache();
93 }
94
95 public void SetSource(SlideBase source)
96 {
97 ResetDirectZarrReader();
98 _glArea.DiscardCompleteFrame();
99 _slideBase = source;
100 _openSlideBase = null;
101 _useOpenSlide = false;
102 _tileCache = new BioLib.TileCache(source, 200);
103 ClearCache();
104 }
105
106 private void ResetDirectZarrReader()
107 {
108 _directZarrReader = null;
109 _directZarrLevels = null;
110 _directZarrSource = null;
111 }
112
113 private void CancelActiveTileFetches()
114 {
115 CancellationTokenSource active;
116 lock (_activeTileFetchLock)
117 {
118 active = _activeTileFetchCts;
119 _activeTileFetchCts = null;
120 }
121
122 if (active == null)
123 return;
124
125 try
126 {
127 active.Cancel();
128 }
129 catch (ObjectDisposedException)
130 {
131 }
132 active.Dispose();
133 }
134
135 private async Task<List<ResolutionLevelNode>> GetDirectZarrLevelsAsync(BioImage image, CancellationToken ct)
136 {
137 string source = image?.SourceFile;
138 if (string.IsNullOrWhiteSpace(source))
139 return null;
140
141 if (source.StartsWith("s3://", StringComparison.OrdinalIgnoreCase))
142 {
143 source = "https://uk1s3.embassy.ebi.ac.uk/" +
144 source.Substring("s3://".Length).TrimStart('/');
145 }
146
147 if (_directZarrLevels != null &&
148 string.Equals(_directZarrSource, source, StringComparison.Ordinal))
149 {
150 return _directZarrLevels;
151 }
152
153 await _directZarrInitSemaphore.WaitAsync(ct).ConfigureAwait(false);
154 try
155 {
156 if (_directZarrLevels == null ||
157 !string.Equals(_directZarrSource, source, StringComparison.Ordinal))
158 {
159 _directZarrReader = await OmeZarrReader.OpenAsync(source, ct).ConfigureAwait(false);
160 var multiscale = await _directZarrReader.AsMultiscaleImageAsync(ct).ConfigureAwait(false);
161 _directZarrLevels = (await multiscale.OpenAllResolutionLevelsAsync().ConfigureAwait(false)).ToList();
162 _directZarrSource = source;
163 }
164
165 return _directZarrLevels;
166 }
167 finally
168 {
169 _directZarrInitSemaphore.Release();
170 }
171 }
172
173 public async Task<bool> PopulateZarrStatisticsAsync(
174 BioImage image,
175 ZCT coordinate,
176 CancellationToken ct = default)
177 {
178 if (image?.IsZarrSource != true || image.Channels == null || image.Channels.Count == 0)
179 return false;
180
181 List<ResolutionLevelNode> levels = await GetDirectZarrLevelsAsync(image, ct).ConfigureAwait(false);
182 if (levels == null || levels.Count == 0 || image.Resolutions == null || image.Resolutions.Count == 0)
183 return false;
184
185 // Use a compact but representative pyramid level. This avoids reading
186 // the full-resolution plane merely to draw a small histogram.
187 int usableLevels = Math.Min(levels.Count, image.Resolutions.Count);
188 int sampleLevel = 0;
189 long bestDistance = long.MaxValue;
190 const int targetDimension = 1024;
191 for (int level = 0; level < usableLevels; level++)
192 {
193 int maxDimension = Math.Max(image.Resolutions[level].SizeX, image.Resolutions[level].SizeY);
194 long distance = Math.Abs((long)maxDimension - targetDimension);
195 if (maxDimension > 0 && distance < bestDistance)
196 {
197 bestDistance = distance;
198 sampleLevel = level;
199 }
200 }
201
202 int width = image.Resolutions[sampleLevel].SizeX;
203 int height = image.Resolutions[sampleLevel].SizeY;
204 if (width <= 0 || height <= 0)
205 return false;
206
207 ResolutionLevelNode levelNode = levels[sampleLevel];
208 bool packedPlanarRgb = image.Resolutions[0].PixelFormat == PixelFormat.Format48bppRgb &&
209 image.Channels.Count == 1;
210 int sourceChannelCount = packedPlanarRgb
211 ? Math.Max(3, image.RGBChannelCount)
212 : image.Channels.Count;
213 var imageStatistics = new Statistics(image.bitsPerPixel > 8);
214 bool gotAnyStatistics = false;
215
216 for (int channelIndex = 0; channelIndex < image.Channels.Count; channelIndex++)
217 {
218 int samplesForChannel = packedPlanarRgb ? sourceChannelCount : 1;
219 var channelStatistics = new Statistics[samplesForChannel];
220 for (int sample = 0; sample < samplesForChannel; sample++)
221 {
222 ct.ThrowIfCancellationRequested();
223 int sourceChannel = packedPlanarRgb ? sample : channelIndex;
224 var plane = await levelNode.ReadTileAsync(
225 0, 0, width, height,
226 z: coordinate.Z,
227 c: sourceChannel,
228 t: coordinate.T,
229 ct: ct).ConfigureAwait(false);
230 byte[] data = plane?.Data;
231 if (data == null || data.Length == 0)
232 continue;
233
234 int bytesPerSample = Math.Max(1, data.Length / Math.Max(1, width * height));
235 int bitsPerSample = bytesPerSample >= 2 ? 16 : 8;
236 PixelFormat statisticsFormat = bitsPerSample > 8
237 ? PixelFormat.Format16bppGrayScale
238 : PixelFormat.Format8bppIndexed;
239 int stride = width * (bitsPerSample / 8);
240 Statistics[] rawStatistics = Statistics.FromBytes(
241 data, width, height, 1, bitsPerSample, stride, statisticsFormat);
242 if (rawStatistics == null || rawStatistics.Length == 0 || rawStatistics[0] == null)
243 continue;
244
245 var aggregate = new Statistics(bitsPerSample > 8);
246 aggregate.AddStatistics(rawStatistics[0]);
247 aggregate.MeanHistogram();
248 channelStatistics[sample] = aggregate;
249 imageStatistics.AddStatistics(rawStatistics[0]);
250 gotAnyStatistics = true;
251 }
252
253 if (channelStatistics.All(statistics => statistics != null))
254 image.Channels[channelIndex].stats = channelStatistics;
255 }
256
257 if (!gotAnyStatistics)
258 return false;
259
260 imageStatistics.MeanHistogram();
261 image.Statistics = imageStatistics;
262 return true;
263 }
264
265 public async Task<double?> SampleZarrPixelAsync(
266 BioImage image,
267 ZCT coordinate,
268 int pixelX,
269 int pixelY,
270 CancellationToken ct = default)
271 {
272 if (image?.IsZarrSource != true || pixelX < 0 || pixelY < 0 ||
273 pixelX >= image.SizeX || pixelY >= image.SizeY)
274 return null;
275
276 List<ResolutionLevelNode> levels = await GetDirectZarrLevelsAsync(image, ct).ConfigureAwait(false);
277 if (levels == null || levels.Count == 0)
278 return null;
279
280 var plane = await levels[0].ReadTileAsync(
281 pixelX, pixelY, 1, 1,
282 z: coordinate.Z,
283 c: coordinate.C,
284 t: coordinate.T,
285 ct: ct).ConfigureAwait(false);
286 byte[] data = plane?.Data;
287 if (data == null || data.Length == 0)
288 return null;
289
290 PixelFormat format = image.Resolutions.Count > 0
291 ? image.Resolutions[0].PixelFormat
292 : PixelFormat.Format8bppIndexed;
293 return format switch
294 {
295 PixelFormat.Format16bppGrayScale or PixelFormat.Format48bppRgb
296 when data.Length >= 2 => BitConverter.ToUInt16(data, 0),
297 PixelFormat.Short when data.Length >= 2 => BitConverter.ToInt16(data, 0),
298 PixelFormat.Float when data.Length >= 4 => BitConverter.ToSingle(data, 0),
299 PixelFormat.UInt when data.Length >= 4 => BitConverter.ToUInt32(data, 0),
300 _ => data[0]
301 };
302 }
303
304 public void ClearCache(bool resetLevel = true, bool cancelActiveFetches = true)
305 {
306 // Clearing the visible tile cache must also invalidate any in-flight
307 // fetches. Otherwise an old Z/C/T request can finish after the
308 // coordinate changes and repopulate the GPU cache with stale tiles.
309 Interlocked.Increment(ref _renderStateVersion);
310 if (cancelActiveFetches)
311 CancelActiveTileFetches();
312
313 _glArea.ClearTextureCache();
314 lock (_uploadedTileLock)
315 {
316 _uploadedTiles.Clear();
317 _uploadedTileInfos.Clear();
318 }
319 if (resetLevel)
320 _currentLevel = -1;
321 _tileCache?.Clear();
322 _openTileCache?.cache.cacheMap.Clear();
323
324 _slideBase?.cache?.Clear();
325 _openSlideBase?.cache?.cache.cacheMap.Clear();
326 _displayRangeSeedLevel = -1;
327
328 lock (_pendingLevelSwitchLock)
329 {
330 _pendingLevelRenderInfos = null;
331 _pendingLevel = -1;
332 _pendingPreviousLevel = -1;
333 }
334
335 lock (_pendingTileFetchLock)
336 {
337 _pendingTileFetches.Clear();
338 _tileRetryCounts.Clear();
339 }
340 }
341
346 public void InvalidateRenderState(bool cancelActiveFetches = true)
347 {
348 Interlocked.Increment(ref _renderStateVersion);
349 if (cancelActiveFetches)
350 CancelActiveTileFetches();
351 lock (_pendingTileFetchLock)
352 {
353 _pendingTileFetches.Clear();
354 _tileRetryCounts.Clear();
355 }
356 }
357
363 public void InvalidateViewport()
364 {
365 Interlocked.Increment(ref _viewportVersion);
366 lock (_pendingLevelSwitchLock)
367 {
368 _pendingLevelRenderInfos = null;
369 _pendingLevel = -1;
370 _pendingPreviousLevel = -1;
371 }
372 }
373
374 public async Task UpdateViewAsync(
375 PointD pyramidalOrigin,
376 int viewportWidth,
377 int viewportHeight,
378 double resolution,
379 ZCT coordinate)
380 {
381 if (_openSlideBase == null && _slideBase == null)
382 return;
383 if (viewportWidth <= 1 && viewportHeight <= 1)
384 return;
385
386 // Only allow one render pass at a time. Without this gate, rapid pan
387 // events can pile up overlapping async tile fetches and keep large
388 // byte[] buffers alive until every stale render finishes.
389 if (!_renderSemaphore.Wait(0))
390 {
391 LastRenderSkipped = true;
392 return;
393 }
394
395 LastRenderSkipped = false;
396
397 try
398 {
399 int renderVersion = _renderStateVersion;
400 int viewportVersion = _viewportVersion;
401 CancellationTokenSource tileFetchCts;
402 lock (_activeTileFetchLock)
403 {
404 // This token represents render invalidation only. A timeout on a
405 // shared token would cancel every request in the batch after 60
406 // seconds, including tiles that were started much later. Each
407 // individual tile gets its own timeout in FetchTileAsync.
408 if (_activeTileFetchCts == null || _activeTileFetchCts.IsCancellationRequested)
409 _activeTileFetchCts = new CancellationTokenSource();
410 tileFetchCts = _activeTileFetchCts;
411 }
412
413 var schema = _useOpenSlide ? _openSlideBase.Schema : _slideBase.Schema;
414
415 int level = TileUtil.GetLevel(schema.Resolutions, resolution);
416 var levelRes = schema.Resolutions[level];
417 double levelUnitsPerPixel = levelRes.UnitsPerPixel;
418
419 await EnsureGlobalDisplayRangeAsync(coordinate, renderVersion, level).ConfigureAwait(false);
420
421 bool levelChanged = level != _currentLevel;
422
423 // Keep tile geometry for uploaded textures across viewport changes.
424 // It lets pan/zoom immediately reproject the current pyramid level
425 // while sharper tiles for a new level are still downloading. Entries
426 // whose GPU textures were evicted are filtered by HasTileTexture.
427
428 double originX = pyramidalOrigin.X;
429 double originY = pyramidalOrigin.Y;
430 double minX = originX;
431 double width = viewportWidth * resolution;
432 double height = viewportHeight * resolution;
433 // BioLib pyramids use OSM-style inverted Y coordinates. Keep the
434 // renderer in the same convention rather than trying to infer a
435 // positive-down variant from the schema bounds.
436 var viewportExtent = new Extent(minX, -originY - height, minX + width, -originY);
437
438 // Keep the GL scissor aligned with the actual image boundary so
439 // edge tiles don't leak padded black pixels outside the image.
440 double imageMinX = schema.Extent.MinX;
441 double imageMaxX = schema.Extent.MaxX;
442 double imageMinY = schema.Extent.MinY;
443 double imageMaxY = schema.Extent.MaxY;
444 _glArea.ImageScreenX = (float)((imageMinX - pyramidalOrigin.X) / resolution);
445 _glArea.ImageScreenY = (float)((-imageMaxY - pyramidalOrigin.Y) / resolution);
446 _glArea.ImageScreenW = (float)((imageMaxX - imageMinX) / resolution);
447 _glArea.ImageScreenH = (float)((imageMaxY - imageMinY) / resolution);
448
449 // Prefetch one tile beyond each viewport edge so partially visible
450 // edge tiles are available without overfetching an arbitrary 2x area.
451 double tileMarginX = levelRes.TileWidth * levelUnitsPerPixel;
452 double tileMarginY = levelRes.TileHeight * levelUnitsPerPixel;
453 var fetchExtent = new Extent(
454 viewportExtent.MinX - tileMarginX,
455 viewportExtent.MinY - tileMarginY,
456 viewportExtent.MaxX + tileMarginX,
457 viewportExtent.MaxY + tileMarginY);
458
459 List<TileInfo> tileInfos;
460 List<TileInfo> fallbackInfos;
461 if (_useOpenSlide)
462 {
463 // OpenSlide schemas use their historical pixel-space lookup path.
464 var lookupExtent = BioLib.ExtentEx.WorldToPixelInvertedY(fetchExtent, levelUnitsPerPixel);
465 tileInfos = schema.GetTileInfos(lookupExtent, level).ToList();
466 fallbackInfos = schema.GetTileInfos(fetchExtent, level).ToList();
467
468 if (fallbackInfos.Count > 0)
469 {
470 var seen = new HashSet<TileIndex>(tileInfos.Select(t => t.Index));
471 foreach (var info in fallbackInfos)
472 {
473 if (seen.Add(info.Index))
474 tileInfos.Add(info);
475 }
476 }
477 }
478 else
479 {
480 // BioLib/Zarr SlideBase schemas already use the same inverted-Y
481 // world convention as BioLib's slice path. Keep lookups in that
482 // space so translated datasets stay aligned with the tile schema.
483 tileInfos = GetTileInfosIncludingBoundaries(schema, fetchExtent, level);
484 fallbackInfos = new List<TileInfo>();
485
486 if (tileInfos.Count == 0)
487 {
488 var pixelToWorldExtent = BioLib.ExtentEx.PixelToWorldInvertedY(fetchExtent, levelUnitsPerPixel);
489 fallbackInfos = schema.GetTileInfos(pixelToWorldExtent, level).ToList();
490
491 if (fallbackInfos.Count == 0)
492 {
493 var worldToPixelExtent = BioLib.ExtentEx.WorldToPixelInvertedY(fetchExtent, levelUnitsPerPixel);
494 fallbackInfos = schema.GetTileInfos(worldToPixelExtent, level).ToList();
495 }
496
497 if (fallbackInfos.Count > 0)
498 tileInfos = fallbackInfos;
499 }
500 }
501
502 var renderInfos = new List<TileRenderInfo>();
503 var pendingFetches = new List<(TileInfo Tile, Task<byte[]> FetchTask)>();
504 foreach (var tileInfo in tileInfos)
505 {
506 if (renderVersion != _renderStateVersion || viewportVersion != _viewportVersion)
507 return;
508
509 var fetchTileInfo = tileInfo;
510 var renderTileInfo = tileInfo;
511
512 var renderInfo = CalculateScreenPosition(
513 renderTileInfo,
514 pyramidalOrigin,
515 resolution,
516 level,
517 schema);
518 renderInfos.Add(renderInfo);
519
520 if (!_glArea.HasTileTexture(fetchTileInfo.Index))
521 {
522 bool alreadyPending;
523 lock (_pendingTileFetchLock)
524 {
525 alreadyPending = !_pendingTileFetches.Add(fetchTileInfo.Index);
526 }
527
528 if (!alreadyPending)
529 pendingFetches.Add((fetchTileInfo, FetchTileAsync(fetchTileInfo, level, coordinate, tileFetchCts.Token)));
530 }
531 }
532
533 // Keep the visible target set resident even before it becomes the
534 // displayed set. Otherwise FIFO eviction can continuously discard
535 // newly uploaded target tiles and the level switch never completes.
536 await RunOnGtkThreadAsync(() =>
537 _glArea.SetProtectedTileIndices(renderInfos.Select(info => info.Index)))
538 .ConfigureAwait(false);
539
540 if (pendingFetches.Count > 0)
541 {
542 foreach (var pending in pendingFetches)
543 _ = ProcessPendingFetchAsync(pending.Tile, pending.FetchTask, tileFetchCts.Token, renderVersion, pyramidalOrigin, resolution, level, schema);
544 }
545
546 if (renderVersion != _renderStateVersion || viewportVersion != _viewportVersion)
547 return;
548
549 bool hasRenderableTexture = renderInfos.Any(tile => _glArea.HasTileTexture(tile.Index));
550 if (levelChanged)
551 {
552 var fallbackRenderInfos = GetCachedLevelRenderInfos(
553 _currentLevel,
554 pyramidalOrigin,
555 resolution,
556 viewportWidth,
557 viewportHeight,
558 schema);
559
560 if (hasRenderableTexture)
561 {
562 bool canSwapNow = renderInfos.All(tile => _glArea.HasTileTexture(tile.Index));
563 if (canSwapNow)
564 {
565 _glArea.PreservePreviousFrameWhileIncomplete = false;
566 _glArea.ReleaseLevelTextures(_currentLevel);
567 _currentLevel = level;
568 lock (_pendingLevelSwitchLock)
569 {
570 _pendingLevelRenderInfos = null;
571 _pendingLevel = -1;
572 _pendingPreviousLevel = -1;
573 }
574 }
575 else
576 {
577 _glArea.PreservePreviousFrameWhileIncomplete = true;
578 lock (_pendingLevelSwitchLock)
579 {
580 _pendingLevelRenderInfos = new List<TileRenderInfo>(renderInfos);
581 _pendingLevel = level;
582 _pendingPreviousLevel = _currentLevel;
583 }
584 await ShowImmediateZoomFallbackAsync(fallbackRenderInfos).ConfigureAwait(false);
585 return;
586 }
587 }
588 else
589 {
590 _glArea.PreservePreviousFrameWhileIncomplete = true;
591 lock (_pendingLevelSwitchLock)
592 {
593 _pendingLevelRenderInfos = new List<TileRenderInfo>(renderInfos);
594 _pendingLevel = level;
595 _pendingPreviousLevel = _currentLevel;
596 }
597 await ShowImmediateZoomFallbackAsync(fallbackRenderInfos).ConfigureAwait(false);
598 return;
599 }
600 }
601
602 if (renderVersion != _renderStateVersion || viewportVersion != _viewportVersion)
603 return;
604
605 await RunOnGtkThreadAsync(() =>
606 {
607 _glArea.SetTilesToRender(renderInfos);
608 _glArea.RequestRedraw();
609 });
610 }
611 catch (OperationCanceledException)
612 {
613 // Cancellation is normal when a source closes or display state is
614 // replaced. Never let it fault the fire-and-forget UI render task.
615 }
616 finally
617 {
618 _renderSemaphore.Release();
619
620 if (LastRenderSkipped)
621 {
622 LastRenderSkipped = false;
623 Gtk.Application.Invoke((s, a) => App.viewer?.RequestDeferredRender());
624 }
625 }
626 }
627
628 private static List<TileInfo> GetTileInfosIncludingBoundaries(
629 ITileSchema schema,
630 Extent requestedExtent,
631 int level)
632 {
633 var tiles = schema.GetTileInfos(requestedExtent, level).ToList();
634 if (!schema.Resolutions.TryGetValue(level, out var resolution))
635 return tiles;
636
637 double tileWorldWidth = resolution.TileWidth * resolution.UnitsPerPixel;
638 double tileWorldHeight = resolution.TileHeight * resolution.UnitsPerPixel;
639 if (tileWorldWidth <= 0 || tileWorldHeight <= 0)
640 return tiles;
641
642 double minX = Math.Max(requestedExtent.MinX, schema.Extent.MinX);
643 double maxX = Math.Min(requestedExtent.MaxX, schema.Extent.MaxX);
644 double minY = Math.Max(requestedExtent.MinY, schema.Extent.MinY);
645 double maxY = Math.Min(requestedExtent.MaxY, schema.Extent.MaxY);
646 if (maxX <= minX || maxY <= minY)
647 return tiles;
648
649 int maxColumn = Math.Max(0, (int)Math.Ceiling(schema.Extent.Width / tileWorldWidth) - 1);
650 int maxRow = Math.Max(0, (int)Math.Ceiling(schema.Extent.Height / tileWorldHeight) - 1);
651 double originX = schema.Extent.MinX;
652 double originY = schema.Extent.MaxY;
653 int firstColumn = Math.Clamp(
654 (int)Math.Floor((minX - originX) / tileWorldWidth),
655 0,
656 maxColumn);
657 int lastColumn = Math.Clamp(
658 (int)Math.Floor((Math.BitDecrement(maxX) - originX) / tileWorldWidth),
659 0,
660 maxColumn);
661 int firstRow = Math.Clamp(
662 (int)Math.Floor((originY - maxY) / tileWorldHeight),
663 0,
664 maxRow);
665 int lastRow = Math.Clamp(
666 (int)Math.Floor((originY - Math.BitIncrement(minY)) / tileWorldHeight),
667 0,
668 maxRow);
669
670 var existing = new HashSet<TileIndex>(tiles.Select(tile => tile.Index));
671 for (int row = firstRow; row <= lastRow; row++)
672 {
673 for (int column = firstColumn; column <= lastColumn; column++)
674 {
675 var index = new TileIndex(column, row, level);
676 if (!existing.Add(index))
677 continue;
678
679 double tileMinX = originX + column * tileWorldWidth;
680 double tileMaxY = originY - row * tileWorldHeight;
681 tiles.Add(new TileInfo
682 {
683 Index = index,
684 Extent = new Extent(
685 tileMinX,
686 tileMaxY - tileWorldHeight,
687 tileMinX + tileWorldWidth,
688 tileMaxY)
689 });
690 }
691 }
692
693 return tiles;
694 }
695
696 private List<TileRenderInfo> GetCachedLevelRenderInfos(
697 int level,
698 PointD pyramidalOrigin,
699 double resolution,
700 int viewportWidth,
701 int viewportHeight,
702 ITileSchema schema)
703 {
704 if (level < 0)
705 return new List<TileRenderInfo>();
706
707 List<KeyValuePair<TileIndex, TileInfo>> uploadedTileSnapshot;
708 lock (_uploadedTileLock)
709 uploadedTileSnapshot = _uploadedTileInfos.ToList();
710
711 return uploadedTileSnapshot
712 .Where(pair => pair.Key.Level == level && _glArea.HasTileTexture(pair.Key))
713 .Select(pair => CalculateScreenPosition(
714 pair.Value,
715 pyramidalOrigin,
716 resolution,
717 level,
718 schema))
719 .Where(tile =>
720 tile.ScreenWidth > 0 && tile.ScreenHeight > 0 &&
721 tile.ScreenX < viewportWidth && tile.ScreenY < viewportHeight &&
722 tile.ScreenX + tile.ScreenWidth > 0 &&
723 tile.ScreenY + tile.ScreenHeight > 0)
724 .ToList();
725 }
726
727 private Task ShowImmediateZoomFallbackAsync(List<TileRenderInfo> fallbackRenderInfos)
728 {
729 if (fallbackRenderInfos == null || fallbackRenderInfos.Count == 0)
730 return Task.CompletedTask;
731
732 return RunOnGtkThreadAsync(() =>
733 {
734 _glArea.PreservePreviousFrameWhileIncomplete = true;
735 _glArea.SetTilesToRender(fallbackRenderInfos);
736 _glArea.RequestRedraw();
737 });
738 }
739
740 private async Task ProcessPendingFetchAsync(
741 TileInfo tile,
742 Task<byte[]> fetchTask,
743 CancellationToken ct,
744 int renderVersion,
745 PointD pyramidalOrigin,
746 double resolution,
747 int level,
748 ITileSchema schema)
749 {
750 bool tileUploaded = false;
751 bool retryMissingTile = false;
752 try
753 {
754 byte[] tileData = await fetchTask.ConfigureAwait(false);
755 if (ct.IsCancellationRequested || renderVersion != _renderStateVersion)
756 return;
757
758 if (tileData == null)
759 {
760 retryMissingTile = true;
761 return;
762 }
763
764 int tW = schema.Resolutions[level].TileWidth;
765 int tH = schema.Resolutions[level].TileHeight;
766 // The fetch layer already returns padded edge tiles. The shader
767 // UVs handle world-space clipping, so re-cropping the texture here
768 // would double-clip the edge and leave a visible border.
769 if (tileData.Length < (long)tW * tH * 4)
770 {
771 retryMissingTile = true;
772 return;
773 }
774
775 await RunOnGtkThreadAsync(() =>
776 _glArea.UploadTileTexture(tile.Index, tileData, tW, tH));
777 if (!_glArea.HasTileTexture(tile.Index))
778 {
779 retryMissingTile = true;
780 return;
781 }
782 lock (_uploadedTileLock)
783 {
784 _uploadedTiles.Add(tile.Index);
785 _uploadedTileInfos[tile.Index] = tile;
786 }
787 tileUploaded = true;
788
789 List<TileRenderInfo> pendingLevelRenderInfos = null;
790 int pendingPreviousLevel = -1;
791 bool applyPendingLevel = false;
792 lock (_pendingLevelSwitchLock)
793 {
794 bool completeLevelCanRender = _pendingLevelRenderInfos != null &&
795 _pendingLevelRenderInfos.All(info => _glArea.HasTileTexture(info.Index));
796 if (_pendingLevelRenderInfos != null &&
797 level == _pendingLevel &&
798 completeLevelCanRender)
799 {
800 applyPendingLevel = true;
801 pendingLevelRenderInfos = _pendingLevelRenderInfos;
802 pendingPreviousLevel = _pendingPreviousLevel;
803 _pendingLevelRenderInfos = null;
804 _pendingLevel = -1;
805 _pendingPreviousLevel = -1;
806 _currentLevel = level;
807 }
808 }
809
810 if (applyPendingLevel)
811 {
812 await RunOnGtkThreadAsync(() =>
813 {
814 if (pendingPreviousLevel >= 0 && pendingPreviousLevel != level)
815 _glArea.ReleaseLevelTextures(pendingPreviousLevel);
816 _glArea.PreservePreviousFrameWhileIncomplete = false;
817 _glArea.SetTilesToRender(pendingLevelRenderInfos);
818 _glArea.RequestRedraw();
819 });
820 }
821
822 if (renderVersion == _renderStateVersion &&
823 Interlocked.Exchange(ref _redrawQueued, 1) == 0)
824 {
825 await RunOnGtkThreadAsync(() =>
826 {
827 try
828 {
829 _glArea.RequestRedraw();
830 }
831 finally
832 {
833 Interlocked.Exchange(ref _redrawQueued, 0);
834 }
835 });
836 }
837 }
838 catch (OperationCanceledException)
839 {
840 }
841 catch (Exception ex)
842 {
843 Console.WriteLine($"Error processing tile {tile.Index}: {ex.Message}");
844 retryMissingTile = !ct.IsCancellationRequested &&
845 renderVersion == _renderStateVersion;
846 }
847 finally
848 {
849 int retryAttempt = 0;
850 lock (_pendingTileFetchLock)
851 {
852 _pendingTileFetches.Remove(tile.Index);
853 if (tileUploaded)
854 {
855 _tileRetryCounts.Remove(tile.Index);
856 }
857 else if (retryMissingTile && !ct.IsCancellationRequested &&
858 renderVersion == _renderStateVersion)
859 {
860 _tileRetryCounts.TryGetValue(tile.Index, out int previousAttempts);
861 retryAttempt = previousAttempts + 1;
862 _tileRetryCounts[tile.Index] = retryAttempt;
863 }
864 }
865
866 if (retryAttempt > 0 && retryAttempt <= MaxMissingTileRetries)
867 _ = RequestMissingTileRetryAsync(renderVersion, retryAttempt);
868 }
869 }
870
871 private async Task RequestMissingTileRetryAsync(int renderVersion, int retryAttempt)
872 {
873 try
874 {
875 int exponentialStep = Math.Min(retryAttempt - 1, 4);
876 int delayMilliseconds = Math.Min(4000, 250 * (1 << exponentialStep));
877 await Task.Delay(delayMilliseconds).ConfigureAwait(false);
878 if (renderVersion != _renderStateVersion)
879 return;
880
881 await RunOnGtkThreadAsync(() => App.viewer?.RequestDeferredRender())
882 .ConfigureAwait(false);
883 }
884 catch (Exception)
885 {
886 // The viewer may be closing while a delayed retry is pending.
887 }
888 }
889
890 private static byte[] CropBgraTile(byte[] src, int srcW, int srcH, int srcX, int srcY, int dstW, int dstH)
891 {
892 if (src == null || src.Length == 0 || srcW <= 0 || srcH <= 0 || dstW <= 0 || dstH <= 0)
893 return Array.Empty<byte>();
894 if (srcX < 0 || srcY < 0 || srcX >= srcW || srcY >= srcH)
895 return Array.Empty<byte>();
896 if (srcX == 0 && srcY == 0 && srcW == dstW && srcH == dstH)
897 return src;
898
899 int srcRowBytes = srcW * 4;
900 int dstRowBytes = dstW * 4;
901 byte[] cropped = new byte[dstRowBytes * dstH];
902 int copyRows = Math.Min(dstH, srcH);
903 for (int y = 0; y < copyRows; y++)
904 {
905 int srcRow = (srcY + y) * srcRowBytes + srcX * 4;
906 int dstRow = y * dstRowBytes;
907 if (srcRow < 0 || srcRow >= src.Length)
908 break;
909 int bytesToCopy = Math.Min(dstRowBytes, Math.Max(0, src.Length - srcRow));
910 if (bytesToCopy <= 0)
911 break;
912 Buffer.BlockCopy(src, srcRow, cropped, dstRow, bytesToCopy);
913 }
914 return cropped;
915 }
916
917 private async Task EnsureGlobalDisplayRangeAsync(ZCT coordinate, int renderVersion, int renderLevel)
918 {
919 if (_useOpenSlide || _slideBase?.Image?.BioImage == null)
920 return;
921
922 var bioImage = _slideBase.Image.BioImage;
923 int seedLevel = 0;
924
925 if (bioImage.ZarrDisplayMax > bioImage.ZarrDisplayMin &&
926 _displayRangeSeedLevel == seedLevel)
927 return;
928 if (Interlocked.Exchange(ref _displayRangeProbeStarted, 1) != 0)
929 return;
930 if (bioImage.Resolutions == null || bioImage.Resolutions.Count == 0)
931 {
932 Interlocked.Exchange(ref _displayRangeProbeStarted, 0);
933 return;
934 }
935
936 var pixelFormat = bioImage.Resolutions[0].PixelFormat;
937 // Planar 48-bit RGB Zarr data already carries a meaningful full
938 // ushort display range. Probing it here would read three remote
939 // channel shards for each of nine level-0 samples before a single
940 // visible tile can render.
941 if (pixelFormat != PixelFormat.Format16bppGrayScale)
942 {
943 Interlocked.Exchange(ref _displayRangeProbeStarted, 0);
944 return;
945 }
946
947 int width = bioImage.Resolutions[seedLevel].SizeX;
948 int height = bioImage.Resolutions[seedLevel].SizeY;
949 if (width <= 0 || height <= 0)
950 {
951 Interlocked.Exchange(ref _displayRangeProbeStarted, 0);
952 return;
953 }
954
955 int frameIndex = bioImage.GetFrameIndex(coordinate.Z, coordinate.C, coordinate.T);
956
957 // Probe a small grid of tiles from level 0 so the display range is
958 // stable across zoom levels and does not depend on the current view.
959 int sampleW = Math.Min(width, 128);
960 int sampleH = Math.Min(height, 128);
961 int[] xs = new[]
962 {
963 0,
964 Math.Max(0, (width - sampleW) / 2),
965 Math.Max(0, width - sampleW)
966 };
967 int[] ys = new[]
968 {
969 0,
970 Math.Max(0, (height - sampleH) / 2),
971 Math.Max(0, height - sampleH)
972 };
973
974 ushort bestMax = 0;
975 bool gotRange = false;
976 foreach (int sampleY in ys)
977 {
978 foreach (int sampleX in xs)
979 {
980 var seedBitmap = await bioImage.GetTile(frameIndex, seedLevel, sampleX, sampleY, sampleW, sampleH, null, true);
981 if (TryComputeDisplayRangeFromUsedBits(seedBitmap, out ushort displayMin, out ushort displayMax))
982 {
983 if (displayMax > bestMax)
984 bestMax = displayMax;
985 gotRange = true;
986 }
987 seedBitmap?.Dispose();
988 }
989 }
990
991 if (gotRange && bestMax > 0)
992 {
993 if (renderVersion != _renderStateVersion)
994 {
995 Interlocked.Exchange(ref _displayRangeProbeStarted, 0);
996 return;
997 }
998
999 bool rangeChanged = bioImage.ZarrDisplayMin != 0 ||
1000 bioImage.ZarrDisplayMax != bestMax;
1001 bioImage.ZarrDisplayMin = 0;
1002 bioImage.ZarrDisplayMax = bestMax;
1003 _displayRangeSeedLevel = seedLevel;
1004
1005 if (rangeChanged)
1006 {
1007 await RunOnGtkThreadAsync(() =>
1008 {
1009 // The range probe runs during an active render and slider
1010 // changes may leave older HTTP reads draining. Mark their
1011 // results stale without aborting their network streams.
1012 ClearCache(resetLevel: false, cancelActiveFetches: false);
1013 _displayRangeSeedLevel = seedLevel;
1014 _glArea.RequestRedraw();
1015 App.viewer?.RequestDeferredRender();
1016 });
1017 }
1018 else if (renderVersion == _renderStateVersion)
1019 {
1020 await RunOnGtkThreadAsync(() => _glArea.RequestRedraw());
1021 }
1022 }
1023 Interlocked.Exchange(ref _displayRangeProbeStarted, 0);
1024 }
1025
1026 private static ushort SnapUsedBitCeiling(ushort value)
1027 {
1028 if (value == 0)
1029 return 0;
1030
1031 uint ceiling = 1;
1032 while (ceiling - 1 < value && ceiling < (1u << 16))
1033 ceiling <<= 1;
1034
1035 return (ushort)Math.Min(ushort.MaxValue, ceiling - 1);
1036 }
1037
1038 private static bool TryComputeDisplayRangeFromUsedBits(Bitmap bitmap, out ushort displayMin, out ushort displayMax)
1039 {
1040 displayMin = 0;
1041 displayMax = 0;
1042
1043 if (bitmap == null || bitmap.Bytes == null || bitmap.Bytes.Length < 2)
1044 return false;
1045 if (bitmap.PixelFormat != PixelFormat.Format16bppGrayScale &&
1046 bitmap.PixelFormat != PixelFormat.Format48bppRgb)
1047 return false;
1048
1049 byte[] bytes = bitmap.Bytes;
1050 bool littleEndian = bitmap.LittleEndian;
1051 int valueCount = bytes.Length / 2;
1052 if (valueCount <= 0)
1053 return false;
1054
1055 // Determine the effective bit depth from the highest used sample
1056 // value rather than percentiles. A 16-bit container often stores
1057 // 10/12/14-bit microscopy data and should be normalized to that
1058 // simple max-bit ceiling (for example 4095 for 12-bit data).
1059 ushort rawMax = 0;
1060
1061 for (int valueIndex = 0; valueIndex < valueCount; valueIndex++)
1062 {
1063 int offset = valueIndex * 2;
1064 ushort value = littleEndian
1065 ? (ushort)(bytes[offset] | (bytes[offset + 1] << 8))
1066 : (ushort)((bytes[offset] << 8) | bytes[offset + 1]);
1067 if (value > rawMax) rawMax = value;
1068 }
1069
1070 if (rawMax == 0)
1071 return false;
1072
1073 displayMin = 0;
1074 displayMax = SnapUsedBitCeiling(rawMax);
1075 return true;
1076 }
1077
1078 private async Task<byte[]> FetchTileAsync(TileInfo tileInfo, int level, ZCT coordinate, CancellationToken ct)
1079 {
1080 bool semaphoreEntered = false;
1081 try
1082 {
1083 await _fetchSemaphore.WaitAsync(ct).ConfigureAwait(false);
1084 semaphoreEntered = true;
1085
1086 // HttpZarrStore owns remote timeout and retry policy. Supplying a
1087 // second CancelAfter token here aborts its transport stream and
1088 // turns an ordinary slow tile into a TaskCanceledException.
1089 if (ShouldComposeRgbTile())
1090 return await FetchRgbCompositeTileAsync(tileInfo, level, coordinate, ct).ConfigureAwait(false);
1091
1092 return await FetchSingleTileAsync(tileInfo, level, coordinate, ct).ConfigureAwait(false);
1093 }
1094 catch (OperationCanceledException) when (ct.IsCancellationRequested)
1095 {
1096 // Routine render invalidation (view closed, channel/threshold
1097 // changed, or cache cleared). The caller treats null as stale.
1098 return null;
1099 }
1100 catch (Exception ex)
1101 {
1102 Console.WriteLine($"Error fetching tile {tileInfo.Index}: {ex.Message}");
1103 return null;
1104 }
1105 finally
1106 {
1107 if (semaphoreEntered)
1108 _fetchSemaphore.Release();
1109 }
1110 }
1111
1112 private bool ShouldComposeRgbTile()
1113 {
1114 if (_useOpenSlide || App.viewer?.Mode != ImageView.ViewMode.RGBImage)
1115 return false;
1116
1117 var bioImage = _slideBase?.Image?.BioImage;
1118 if (bioImage == null || bioImage.Channels == null)
1119 return false;
1120 if (bioImage.Resolutions == null || bioImage.Resolutions.Count == 0)
1121 return false;
1122
1123 var pixelFormat = bioImage.Resolutions[0].PixelFormat;
1124 // OME-Zarr represents 16-bit RGB as three independent C planes even
1125 // though BioImage exposes it as one logical Format48bppRgb channel.
1126 // Compose those planes here; BioLib's ordinary packed-RGB path is for
1127 // interleaved data and otherwise returns only one of the three planes.
1128 if (bioImage.IsZarrSource && pixelFormat == PixelFormat.Format48bppRgb)
1129 return true;
1130
1131 if (bioImage.Channels.Count <= 1)
1132 return false;
1133
1134 return pixelFormat != PixelFormat.Format24bppRgb &&
1135 pixelFormat != PixelFormat.Format48bppRgb;
1136 }
1137
1138 private async Task<byte[]> FetchRgbCompositeTileAsync(TileInfo tileInfo, int level, ZCT coordinate, CancellationToken ct)
1139 {
1140 var bioImage = _slideBase?.Image?.BioImage;
1141 if (bioImage == null)
1142 return await FetchSingleTileAsync(tileInfo, level, coordinate, ct).ConfigureAwait(false);
1143
1144 bool planarRgb16 = bioImage.IsZarrSource &&
1145 bioImage.Resolutions.Count > 0 &&
1146 bioImage.Resolutions[0].PixelFormat == PixelFormat.Format48bppRgb;
1147 int channelCount = planarRgb16 ? 3 : Math.Max(1, bioImage.Channels.Count);
1148 int redIndex = Math.Clamp(bioImage.rgbChannels[0], 0, channelCount - 1);
1149 int greenIndex = Math.Clamp(bioImage.rgbChannels[1], 0, channelCount - 1);
1150 int blueIndex = Math.Clamp(bioImage.rgbChannels[2], 0, channelCount - 1);
1151
1152 Task<byte[]> redTask = FetchSlideTileDirectAsync(tileInfo, level, new ZCT(coordinate.Z, redIndex, coordinate.T), ct);
1153 Task<byte[]> greenTask = FetchSlideTileDirectAsync(tileInfo, level, new ZCT(coordinate.Z, greenIndex, coordinate.T), ct);
1154 Task<byte[]> blueTask = FetchSlideTileDirectAsync(tileInfo, level, new ZCT(coordinate.Z, blueIndex, coordinate.T), ct);
1155
1156 await Task.WhenAll(redTask, greenTask, blueTask).ConfigureAwait(false);
1157 return ComposeBgraTiles(redTask.Result, greenTask.Result, blueTask.Result);
1158 }
1159
1160 private async Task<byte[]> FetchSlideTileDirectAsync(TileInfo tileInfo, int level, ZCT coordinate, CancellationToken ct)
1161 {
1162 if (IsPlanar16BitZarrLevel(level))
1163 return await FetchGray16ZarrTileAsync(tileInfo, level, coordinate, ct).ConfigureAwait(false);
1164
1165 // FetchTileAsync already supplies the per-request timeout token.
1166 // Do not create a second timer that aborts the same HTTP stream.
1167 return await _slideBase.GetTileAsync(
1168 new BioLib.TileInformation(tileInfo.Index, tileInfo.Extent, coordinate),
1169 coordinate,
1170 ct).ConfigureAwait(false);
1171 }
1172
1173 private async Task<byte[]> FetchSingleTileAsync(TileInfo tileInfo, int level, ZCT coordinate, CancellationToken ct)
1174 {
1175 if (IsPlanar16BitZarrLevel(level))
1176 return await FetchGray16ZarrTileAsync(tileInfo, level, coordinate, ct).ConfigureAwait(false);
1177
1178 var fetchTask = _useOpenSlide
1179 ? Task.Run(() => _openTileCache.GetTile(new OpenSlideGTK.Info(coordinate, tileInfo.Index, tileInfo.Extent, level)))
1180 : _tileCache.GetTile(new BioLib.TileInformation(tileInfo.Index, tileInfo.Extent, coordinate), ct);
1181
1182 if (_useOpenSlide)
1183 {
1184 var bt = await fetchTask.ConfigureAwait(false);
1185 return bt;
1186 }
1187
1188 return await fetchTask.ConfigureAwait(false);
1189 }
1190
1191 private bool IsPlanar16BitZarrLevel(int level)
1192 {
1193 var image = _slideBase?.Image?.BioImage;
1194 return !_useOpenSlide && image != null && image.IsZarrSource &&
1195 level >= 0 && level < image.Resolutions.Count &&
1196 (image.Resolutions[level].PixelFormat == PixelFormat.Format16bppGrayScale ||
1197 image.Resolutions[level].PixelFormat == PixelFormat.Format48bppRgb);
1198 }
1199
1200 private async Task<byte[]> FetchGray16ZarrTileAsync(TileInfo tileInfo, int level, ZCT coordinate, CancellationToken ct)
1201 {
1202 var image = _slideBase.Image.BioImage;
1203 var tileResolution = _slideBase.Schema.Resolutions[level];
1204 int tileWidth = tileResolution.TileWidth;
1205 int tileHeight = tileResolution.TileHeight;
1206 int x = checked(tileInfo.Index.Col * tileWidth);
1207 int y = checked(tileInfo.Index.Row * tileHeight);
1208 var dimensions = _slideBase.Image.GetLevelDimension(level);
1209 int width = (int)Math.Max(0, Math.Min(tileWidth, dimensions.Width - x));
1210 int height = (int)Math.Max(0, Math.Min(tileHeight, dimensions.Height - y));
1211 if (width == 0 || height == 0 || x < 0 || y < 0)
1212 return new byte[tileWidth * tileHeight * 4];
1213
1214 // Read packed pixels with their known rectangular dimensions. The
1215 // SlideImage BGRA conversion guesses a square from the pixel count,
1216 // which truncates narrow right-edge tiles (e.g. 16x256 becomes 64x64).
1217 byte[] raw;
1218 bool planarRgb16 = image.Resolutions[level].PixelFormat == PixelFormat.Format48bppRgb;
1219 if (planarRgb16)
1220 {
1221 // Read the channel plane directly. BioLib 4.3's compatibility
1222 // APIs either truncate these uint16 samples to one byte or read
1223 // all three channels repeatedly before returning one plane.
1224 List<ResolutionLevelNode> levels = await GetDirectZarrLevelsAsync(image, ct).ConfigureAwait(false);
1225 if (levels == null || levels.Count == 0)
1226 return null;
1227
1228 ResolutionLevelNode levelNode = levels[Math.Clamp(level, 0, levels.Count - 1)];
1229 var plane = await levelNode.ReadTileAsync(
1230 x, y, width, height,
1231 z: coordinate.Z,
1232 c: coordinate.C,
1233 t: coordinate.T,
1234 ct: ct).ConfigureAwait(false);
1235 raw = plane?.Data;
1236 }
1237 else
1238 {
1239 raw = await image.GetTileBytesRaw(
1240 image.GetFrameIndex(coordinate.Z, coordinate.C, coordinate.T),
1241 level, x, y, width, height, coordinate)
1242 .WaitAsync(RemoteTileFetchTimeout, ct).ConfigureAwait(false);
1243 }
1244 if (raw == null || raw.Length == 0)
1245 return null;
1246
1247 int min = image.ZarrDisplayMin;
1248 int max = image.ZarrDisplayMax;
1249 if (max <= min)
1250 {
1251 min = 0;
1252 max = ushort.MaxValue;
1253 }
1254 return Gray16TileConverter.ToBgra(raw, width, height, tileWidth, tileHeight, min, max);
1255 }
1256
1257 private static byte[] ComposeBgraTiles(byte[] redTile, byte[] greenTile, byte[] blueTile)
1258 {
1259 byte[] fallback = redTile ?? greenTile ?? blueTile;
1260 if (fallback == null)
1261 return null;
1262
1263 redTile ??= fallback;
1264 greenTile ??= fallback;
1265 blueTile ??= fallback;
1266
1267 int byteCount = Math.Min(redTile.Length, Math.Min(greenTile.Length, blueTile.Length));
1268 byteCount -= byteCount % 4;
1269 if (byteCount <= 0)
1270 return null;
1271
1272 byte[] composed = new byte[byteCount];
1273 for (int i = 0; i < byteCount; i += 4)
1274 {
1275 byte r = ExtractIntensity(redTile, i);
1276 byte g = ExtractIntensity(greenTile, i);
1277 byte b = ExtractIntensity(blueTile, i);
1278 byte a = (byte)Math.Max(redTile[i + 3], Math.Max(greenTile[i + 3], blueTile[i + 3]));
1279 if (a == 0 && (r != 0 || g != 0 || b != 0))
1280 a = byte.MaxValue;
1281
1282 composed[i] = b;
1283 composed[i + 1] = g;
1284 composed[i + 2] = r;
1285 composed[i + 3] = a;
1286 }
1287
1288 return composed;
1289 }
1290
1291 private static byte ExtractIntensity(byte[] tile, int offset)
1292 {
1293 return (byte)Math.Max(tile[offset], Math.Max(tile[offset + 1], tile[offset + 2]));
1294 }
1295
1296 private TileRenderInfo CalculateScreenPosition(
1297 TileInfo tile,
1298 PointD pyramidalOrigin,
1299 double viewResolution,
1300 int level,
1301 ITileSchema schema)
1302 {
1303 // viewResolution and pyramidalOrigin are in world units (microns or
1304 // GetUnitPerPixel units). Tile extents are already expressed in the
1305 // same world-space convention used by GetTileInfos/SkiaStitch, so use
1306 // them directly here instead of re-clipping against the schema extent.
1307 // That avoids introducing a one-sided padding strip when the dataset
1308 // carries a translation transform.
1309
1310 var tileExtent = tile.Extent;
1311 if (tileExtent.MaxX <= tileExtent.MinX || tileExtent.MaxY <= tileExtent.MinY)
1312 return new TileRenderInfo(tile.Index, -9999, -9999, 0, 0);
1313
1314 float u0 = 0f;
1315 float u1 = 1f;
1316 float v0 = 0f;
1317 float v1 = 1f;
1318
1319 // Screen position: divide world coords by viewResolution to get pixels.
1320 double originScreenX = pyramidalOrigin.X;
1321 double originScreenY = pyramidalOrigin.Y;
1322
1323 float screenX = (float)((tileExtent.MinX - originScreenX) / viewResolution);
1324 float screenY = (float)((-tileExtent.MaxY - originScreenY) / viewResolution);
1325 float screenWidth = (float)((tileExtent.MaxX - tileExtent.MinX) / viewResolution);
1326 float screenHeight = (float)((tileExtent.MaxY - tileExtent.MinY) / viewResolution);
1327
1328 return new TileRenderInfo(tile.Index, screenX, screenY, screenWidth, screenHeight, u0, v0, u1, v1);
1329 }
1330
1331 public int CurrentLevel => _currentLevel;
1332 public int CachedTextureCount
1333 {
1334 get
1335 {
1336 lock (_uploadedTileLock)
1337 return _uploadedTiles.Count;
1338 }
1339 }
1340 }
1341}
A GTK GLArea widget that renders pyramidal slide tiles directly on the GPU. Eliminates ReadPixels ove...
void InvalidateRenderState(bool cancelActiveFetches=true)
Marks all in-flight tile work as stale. Call this before changing threshold/range state so old tiles ...
void InvalidateViewport()
Marks screen-space layout work as stale without cancelling tile I/O. Tile pixels are independent of t...
Definition ML.cs:17
Definition Zarr.cs:20