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
ImageView.cs
1using AForge;
2using AForge.Imaging.Filters;
3using Bio;
4using BruTile;
5using Gdk;
6using Gtk;
7using OpenSlideGTK;
8using OpenTK.Mathematics;
9using OpenTK.Windowing.Common;
10using OpenTK.Windowing.Desktop;
11using SkiaSharp;
12using SkiaSharp.Views.Gtk;
13using System.Linq;
14using System.Threading;
15using ZarrNET.Core.Nodes;
16using Color = AForge.Color;
17using PointD = AForge.PointD;
18using PointF = AForge.PointF;
19using Rectangle = AForge.Rectangle;
20using SizeF = AForge.SizeF;
21using ZCT = AForge.ZCT;
22
23namespace BioGTK
24{
25 public class ImageView : Gtk.Window
26 {
27 #region Properties
28
30 private Builder _builder;
31 public List<BioImage> Images = new List<BioImage>();
32 private bool _updatingCoordinate = false;
33 //public List<SKImage> SKImages = new List<SKImage>();
34 public void SetCoordinate(int z, int c, int t)
35 {
36 if (SelectedImage == null)
37 return;
38 if (_updatingCoordinate)
39 return;
40
41 ZCT previousCoordinate = SelectedImage.Coordinate;
42 _updatingCoordinate = true;
43 try
44 {
45 if (z > SelectedImage.SizeZ - 1 && SelectedImage.Type == BioImage.ImageType.well)
46 zBar.Value = zBar.Adjustment.Upper;
47 else
48 zBar.Value = z;
49 if (c > SelectedImage.SizeC - 1)
50 cBar.Value = cBar.Adjustment.Upper;
51 else
52 cBar.Value = c;
53 if (t > SelectedImage.SizeT - 1)
54 tBar.Value = tBar.Adjustment.Upper;
55 else
56 tBar.Value = t;
57
58 if (SelectedImage.Type == BioImage.ImageType.well)
59 SelectedImage.Coordinate = new ZCT(0, (int)cBar.Value, (int)tBar.Value);
60 else
61 SelectedImage.Coordinate = new ZCT((int)zBar.Value, (int)cBar.Value, (int)tBar.Value);
62 }
63 finally
64 {
65 _updatingCoordinate = false;
66 }
67
68 bool coordinateChanged = SelectedImage.Coordinate != previousCoordinate;
69 if (coordinateChanged)
70 App.channelsTool?.RefreshStatisticsForCoordinate(SelectedImage, SelectedImage.Coordinate);
71 if (SelectedImage.isPyramidal && coordinateChanged)
72 {
73 PreparePyramidalCoordinateRender();
74 ForceNavigationRefresh();
75 BioLib.Recorder.Record(BioLib.Recorder.GetCurrentMethodInfo(), false, z, c, t);
76 return;
77 }
78
80 if (SelectedImage?.isPyramidal != true && View != null)
81 {
82 RequestViewportRefresh();
83 }
84 BioLib.Recorder.Record(BioLib.Recorder.GetCurrentMethodInfo(), false, z, c, t);
85 }
86
87 private void PreparePyramidalCoordinateRender()
88 {
89 if (SelectedImage?.isPyramidal != true)
90 return;
91
92 // A slider drag can generate many coordinate changes per second.
93 // Mark earlier plane results stale, but let their HTTP reads finish so
94 // HttpClient does not surface routine socket-abort TaskCanceledExceptions.
95 slideRenderer?.InvalidateRenderState(cancelActiveFetches: false);
96 sKSlideRenderer?.InvalidateRenderState();
97 SelectedImage.InvalidateTileCache();
98 slideRenderer?.ClearCache(resetLevel: false, cancelActiveFetches: false);
99 sKSlideRenderer?.ClearCache();
100 SelectedImage.ZarrDisplayMax = 0;
101 }
102
103 private void RequestViewportRefresh(bool scheduleIdleRetry = true)
104 {
105 if (View == null)
106 return;
107
108 View.QueueDraw();
109 if (!MacOS)
110 {
111 glArea?.RequestRedraw();
112 glArea?.QueueDraw();
113 }
114
115 if (!scheduleIdleRetry)
116 return;
117
118 GLib.Idle.Add(() =>
119 {
120 if (_viewerCleanupDone || View == null)
121 return false;
122
123 View.QueueDraw();
124 if (!MacOS)
125 {
126 glArea?.RequestRedraw();
127 glArea?.QueueDraw();
128 }
129
130 return false;
131 });
132 }
136 public ZCT GetCoordinate()
137 {
138 if (SelectedImage == null)
139 return new ZCT();
140 BioLib.Recorder.Record(BioLib.Recorder.GetCurrentMethodInfo(), false, SelectedImage.Coordinate.Z, SelectedImage.Coordinate.C, SelectedImage.Coordinate.T);
141 return SelectedImage.Coordinate;
142 }
143
144 private bool TrySelectVisibleMaskAt(double worldX, double worldY, bool ctrlHeld)
145 {
146 if (SelectedImage?.Annotations == null)
147 return false;
148
149 PointD worldPoint = new PointD(worldX, worldY);
150 ZCT coord = GetCoordinate();
151 ROI hit = null;
152
153 var candidates = new List<ROI>();
154 candidates.AddRange(SelectedImage.Annotations);
155 candidates.AddRange(GetZarrLabelOverlays(SelectedImage));
156
157 // Match render order: selected masks are drawn last, so they must win
158 // hit-testing before any non-selected mask underneath them.
159 for (int pass = 0; pass < 2 && hit == null; pass++)
160 {
161 for (int i = candidates.Count - 1; i >= 0; i--)
162 {
163 ROI an = candidates[i];
164 if (an == null || an.type != ROI.Type.Mask || an.roiMask == null || an.coord != coord)
165 continue;
166
167 if (pass == 0 && !an.Selected)
168 continue;
169 if (pass == 1 && an.Selected)
170 continue;
171
172 if (Tools.HitTestMask(an, worldPoint))
173 {
174 hit = an;
175 break;
176 }
177 }
178 }
179
180 if (hit == null)
181 return false;
182
183 if (!ctrlHeld)
184 {
185 foreach (ROI other in candidates)
186 {
187 if (other != hit)
188 {
189 other.Selected = false;
190 other.selectedPoints?.Clear();
191 }
192 }
193 }
194
195 hit.Selected = true;
196 hit.selectedPoints?.Clear();
197 Tools.selectedROI = hit;
198 UpdateView();
199 return true;
200 }
201
205 public void AddImage(BioImage im)
206 {
207 Images.Add(im);
208 selectedIndex = Images.Count - 1;
209 if (im.Resolutions[0].SizeX < 1920 && im.Resolutions[0].SizeY < 1080)
210 {
211 if (View != null)
212 {
213 View.WidthRequest = im.Resolutions[0].SizeX;
214 View.HeightRequest = im.Resolutions[0].SizeY;
215 }
216 else
217 {
218 View.WidthRequest = im.Resolutions[0].SizeX;
219 View.HeightRequest = im.Resolutions[0].SizeY;
220 }
221 }
222
223 Initialize();
224 InitPreview();
225 UpdateImages(true);
226 UpdateGUI();
227 App.roiManager?.UpdateAnnotationList();
228 BioLib.Recorder.Record(BioLib.Recorder.GetCurrentMethodInfo(), false, im?.file ?? string.Empty);
229 }
230
231 public void SetZarrLabelOverlays(BioImage image, List<ROI> overlays, string? keyOverride = null)
232 {
233 if (image == null)
234 return;
235
236 string key = string.IsNullOrWhiteSpace(keyOverride) ? GetOverlayKey(image) : keyOverride;
237 if (string.IsNullOrWhiteSpace(key))
238 return;
239
240 zarrLabelOverlays[key] = overlays ?? new List<ROI>();
241 BioLib.Recorder.Record(BioLib.Recorder.GetCurrentMethodInfo(), false, image?.file ?? string.Empty, overlays?.Count ?? 0, keyOverride ?? string.Empty);
242 }
243
244 public void RefreshZarrLabelOverlays(string? sourceOverride = null)
245 {
246 RefreshZarrLabelOverlays(SelectedImage, sourceOverride);
247 }
248
249 public void RefreshZarrLabelOverlays(BioImage image, string? sourceOverride = null)
250 {
251 if (image == null)
252 return;
253
254 string source = sourceOverride ?? image.file;
255 if (string.IsNullOrWhiteSpace(source))
256 source = image.Filename;
257
258 if (string.IsNullOrWhiteSpace(source) ||
259 (!source.Contains(".zarr", StringComparison.OrdinalIgnoreCase) && !Directory.Exists(source)))
260 return;
261
262 try
263 {
264 var overlays = BioLib.Zarr.LoadLabelOverlaysAsync(image, source).GetAwaiter().GetResult();
265 if (overlays == null || overlays.Count == 0)
266 {
267 // Some saved Zarrs expose the labels on disk but do not
268 // advertise them through the async label discovery path.
269 // Fall back to a direct labels-folder scan so the masks
270 // still render after reopen.
271 var zarrType = typeof(BioImage).Assembly.GetType("BioLib.Zarr");
272 var method = zarrType?.GetMethod(
273 "LoadLabelsAsROIs",
274 System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static);
275 if (method != null)
276 {
277 var result = method.Invoke(null, new object[] { image, source }) as List<ROI>;
278 if (result != null && result.Count > 0)
279 overlays = result;
280 }
281 }
282 if ((overlays == null || overlays.Count == 0) && image.Annotations != null)
283 {
284 var existing = image.Annotations.Where(IsZarrLabelRoi).ToList();
285 if (existing.Count > 0)
286 overlays = existing;
287 }
288
289 SetZarrLabelOverlays(image, overlays);
290 SetZarrLabelOverlays(image, overlays, source);
291 SyncZarrLabelAnnotations(image, overlays);
292 BioLib.Recorder.Record(BioLib.Recorder.GetCurrentMethodInfo(), false, source);
293 }
294 catch (Exception)
295 {
296 }
297 }
298
299 private List<ROI> GetZarrLabelOverlays(BioImage image)
300 {
301 string key = GetOverlayKey(image);
302 if (string.IsNullOrWhiteSpace(key))
303 return new List<ROI>();
304
305 if (zarrLabelOverlays.TryGetValue(key, out var overlays) && overlays != null && overlays.Count > 0)
306 return overlays;
307
308 if (!zarrLabelLoadAttempted.Contains(key))
309 {
310 zarrLabelLoadAttempted.Add(key);
311 RefreshZarrLabelOverlays(image, key);
312 if (zarrLabelOverlays.TryGetValue(key, out overlays) && overlays != null)
313 return overlays;
314 }
315
316 if (image?.Annotations != null)
317 {
318 var synced = image.Annotations.Where(IsZarrLabelRoi).ToList();
319 if (synced.Count > 0)
320 return synced;
321 }
322
323 return new List<ROI>();
324 }
325
326 public List<ROI> GetZarrLabelOverlaysForImage(BioImage image)
327 => new List<ROI>(GetZarrLabelOverlays(image));
328
329 private static bool IsZarrLabelRoi(ROI roi)
330 {
331 return roi != null &&
332 !string.IsNullOrWhiteSpace(roi.roiID) &&
333 roi.roiID.StartsWith("zarr-label:", StringComparison.OrdinalIgnoreCase);
334 }
335
336 private void SyncZarrLabelAnnotations(BioImage image, List<ROI> overlays)
337 {
338 if (image == null)
339 return;
340
341 image.Annotations.RemoveAll(IsZarrLabelRoi);
342
343 if (overlays == null || overlays.Count == 0)
344 return;
345
346 foreach (var overlay in overlays)
347 {
348 if (!IsZarrLabelRoi(overlay))
349 continue;
350
351 image.Annotations.Add(overlay);
352 }
353 }
354
355 private static string GetOverlayKey(BioImage image)
356 {
357 if (image == null)
358 return string.Empty;
359
360 if (!string.IsNullOrWhiteSpace(image.file))
361 return image.file;
362
363 return image.Filename ?? string.Empty;
364 }
365
366 double pxWmicron = 5;
367 double pxHmicron = 5;
368 /* A property of the class. */
369 public double PxWmicron
370 {
371 get
372 {
373 if (SelectedImage == null)
374 return 1;
375 if (SelectedImage.Type == BioImage.ImageType.pyramidal)
376 if (openSlide)
377 {
378 if (_openSlideBase?.Schema?.Resolutions != null && _openSlideBase.Schema.Resolutions.Count > 0)
379 {
380 int lev = OpenSlideGTK.TileUtil.GetLevel(_openSlideBase.Schema.Resolutions, Resolution);
381 lev = Math.Clamp(lev, 0, _openSlideBase.Schema.Resolutions.Count - 1);
382 return _openSlideBase.Schema.Resolutions[lev].UnitsPerPixel * pxWmicron;
383 }
384 }
385 else
386 {
387 if (_slideBase?.Schema?.Resolutions != null && _slideBase.Schema.Resolutions.Count > 0)
388 {
389 int lev = OpenSlideGTK.TileUtil.GetLevel(_slideBase.Schema.Resolutions, Resolution);
390 lev = Math.Clamp(lev, 0, _slideBase.Schema.Resolutions.Count - 1);
391 return _slideBase.Schema.Resolutions[lev].UnitsPerPixel * pxWmicron;
392 }
393 }
394
395 return pxWmicron;
396 }
397 set
398 {
399 pxWmicron = value;
400 }
401 }
402 public double PxHmicron
403 {
404 get
405 {
406 if (SelectedImage == null)
407 return 1;
408 if (SelectedImage.Type == BioImage.ImageType.pyramidal)
409 if (openSlide)
410 {
411 if (_openSlideBase?.Schema?.Resolutions != null && _openSlideBase.Schema.Resolutions.Count > 0)
412 {
413 int lev = OpenSlideGTK.TileUtil.GetLevel(_openSlideBase.Schema.Resolutions, Resolution);
414 lev = Math.Clamp(lev, 0, _openSlideBase.Schema.Resolutions.Count - 1);
415 return _openSlideBase.Schema.Resolutions[lev].UnitsPerPixel * pxHmicron;
416 }
417 }
418 else
419 {
420 if (_slideBase?.Schema?.Resolutions != null && _slideBase.Schema.Resolutions.Count > 0)
421 {
422 int lev = OpenSlideGTK.TileUtil.GetLevel(_slideBase.Schema.Resolutions, Resolution);
423 lev = Math.Clamp(lev, 0, _slideBase.Schema.Resolutions.Count - 1);
424 return _slideBase.Schema.Resolutions[lev].UnitsPerPixel * pxHmicron;
425 }
426 }
427 return pxHmicron;
428 }
429 set
430 {
431 pxHmicron = value;
432 }
433 }
434 bool allowNavigation = true;
435 public bool AllowNavigation
436 {
437 get { return allowNavigation; }
438 set { allowNavigation = value; }
439 }
440
441 public bool ShowMasks { get; set; } = true;
442 public bool ShowOverview { get; set; } = true;
443 private readonly Dictionary<string, List<ROI>> zarrLabelOverlays = new Dictionary<string, List<ROI>>();
444 private readonly HashSet<string> zarrLabelLoadAttempted = new HashSet<string>();
445 /* Getting the selected buffer from the selected image. */
446 public static AForge.Bitmap SelectedBuffer
447 {
448 get
449 {
450 int ind = SelectedImage.GetFrameIndex(SelectedImage.Coordinate.Z, SelectedImage.Coordinate.C, SelectedImage.Coordinate.T);
451 return SelectedImage.Buffers[ind];
452 }
453 }
454 int selectedIndex = 0;
455 public int SelectedIndex
456 {
457 get
458 {
459 if(selectedIndex >= Images.Count)
460 selectedIndex = Images.Count - 1;
461 return selectedIndex;
462 }
463 set
464 {
465 if (value >= 0 && value < Images.Count)
466 {
467 selectedIndex = value;
468 Images[selectedIndex] = SelectedImage;
469 }
470 }
471 }
472 Menu imagesMenu;
473 public static List<ROI> selectedAnnotations
474 {
475 get
476 {
477 List<ROI> rois = new List<ROI>();
478 foreach (var item in SelectedImage.Annotations)
479 {
480 if (item != null && item.Selected)
481 rois.Add(item);
482 }
483 if (App.viewer != null)
484 {
485 foreach (var item in App.viewer.GetZarrLabelOverlaysForImage(SelectedImage))
486 {
487 if (item != null && item.Selected)
488 rois.Add(item);
489 }
490 }
491 return rois;
492 }
493 }
494#pragma warning disable 649
495 [Builder.Object]
496 private Gtk.Scale zBar;
497 [Builder.Object]
498 private Gtk.Scale tBar;
499 [Builder.Object]
500 private Gtk.Scale cBar;
501 [Builder.Object]
502 private Gtk.Label statusLabel;
503 [Builder.Object]
504 private Gtk.Stack rgbStack;
505 [Builder.Object]
506 private Gtk.Grid grid;
507 [Builder.Object]
508 private Gtk.Box rgbBox;
509 [Builder.Object]
510 private Gtk.Box controlsBox;
511 [Builder.Object]
512 public Gtk.Box mainBox;
513 [Builder.Object]
514 private Gtk.ComboBox rBox;
515 [Builder.Object]
516 private Gtk.ComboBox gBox;
517 [Builder.Object]
518 private Gtk.ComboBox bBox;
519 [Builder.Object]
520 private Gtk.Scrollbar hScroll;
521 [Builder.Object]
522 private Gtk.Scrollbar vScroll;
523 [Builder.Object]
524 public Menu contextMenu;
525 [Builder.Object]
526 private MenuItem goToOriginMenu;
527 [Builder.Object]
528 private MenuItem goToImageMenu;
529 [Builder.Object]
530 private MenuItem overView;
531 [Builder.Object]
532 private MenuItem roi;
533 [Builder.Object]
534 private Menu roiMenu;
535 [Builder.Object]
536 private MenuItem roiShow;
537 [Builder.Object]
538 private MenuItem roiHide;
539 [Builder.Object]
540 private MenuItem roiDelete;
541 [Builder.Object]
542 private MenuItem roiID;
543 [Builder.Object]
544 private MenuItem copy;
545 [Builder.Object]
546 private MenuItem paste;
547 [Builder.Object]
548 private MenuItem draw;
549 [Builder.Object]
550 private MenuItem fill;
551 [Builder.Object]
552 private Menu barMenu;
553 [Builder.Object]
554 private MenuItem play;
555 [Builder.Object]
556 private MenuItem stop;
557 [Builder.Object]
558 private MenuItem playSpeed;
559 [Builder.Object]
560 private MenuItem setValueRange;
561 [Builder.Object]
562 private MenuItem loop;
563#pragma warning restore 649
564 #endregion
565
566
567 private SKDrawingArea sk = new SKDrawingArea();
568 private Widget currentView;
569
570 public SlideGLArea glArea;
571 public SlideRenderer slideRenderer;
572 public SKSlideRenderer sKSlideRenderer;
573 public GLWindow window;
574 public static bool MacOS = false;
575
576 #region Constructors / Destructors
577
585 public static ImageView Create(BioImage bm)
586 {
587 string gladePath = System.IO.Path.Combine(
588 System.IO.Path.GetDirectoryName(Environment.ProcessPath)!,
589 "Glade",
590 "ImageView.glade");
591 if (App.tools == null)
592 App.tools = Tools.Create();
593 Builder builder = new Builder();
594 builder.AddFromFile(gladePath);
595
596 var imageViewObj = builder.GetObject("imageView");
597 ImageView v = new ImageView(builder, imageViewObj.Handle, bm);
598 v.Title = bm.Filename;
599
600 return v;
601 }
602
603 /* The above code is a constructor for the ImageView class in C#. It takes in a Builder object,
604 a handle, and a BioImage object as parameters. */
605 protected ImageView(Builder builder, IntPtr handle, BioImage im) : base(handle)
606 {
607 _builder = builder;
608 App.viewer = this;
609 builder.Autoconnect(this);
610 var gameWindowSettings = new GameWindowSettings()
611 {
612 UpdateFrequency = 60,
613 };
614 NativeWindowSettings nativeSettings;
615
616 if (OperatingSystem.IsMacOS())
617 {
618 MacOS = true;
619 // Create the renderer bridge
620 View = new SKDrawingArea();
621 sKSlideRenderer = new SKSlideRenderer(sk);
622 }
623 else if (OperatingSystem.IsLinux())
624 {
625 nativeSettings = new NativeWindowSettings()
626 {
627 ClientSize = new Vector2i(600, 400),
628 Location = new Vector2i(0, 0),
629 Title = "OpenGL Context",
630 StartVisible = false, // Window starts hidden
631 API = ContextAPI.OpenGL,
632 Profile = ContextProfile.Core,
633 APIVersion = new Version(3, 3)
634 };
635 //tileCopy = new TileCopyGL(gameWindowSettings, nativeSettings);
636 Environment.SetEnvironmentVariable("GDK_BACKEND", "x11");
637 Environment.SetEnvironmentVariable("GTK_BACKEND", "x11");
638 if (im.isPyramidal)
639 {
640 window = new GLWindow(gameWindowSettings, nativeSettings);
641 View = new SlideGLArea();
642 ((SlideGLArea)View).OnSkiaRender += RenderSkia;
643 slideRenderer = new SlideRenderer((SlideGLArea)View);
644 }
645 else
646 {
647 View = new SlideArea();
648 ((SlideArea)View).OnSkiaRender += RenderSkia;
649 }
650 GLFWProvider.SetErrorCallback((error, description) =>
651 {
652 });
653
654 }
655 else if (OperatingSystem.IsWindows())
656 {
657 nativeSettings = new NativeWindowSettings()
658 {
659 ClientSize = new Vector2i(600, 400),
660 Location = new Vector2i(0, 0),
661 Title = "OpenGL Context",
662 StartVisible = false, // Window starts hidden
663 API = ContextAPI.OpenGL,
664 Profile = ContextProfile.Core,
665 APIVersion = new Version(3, 3)
666 };
667 if (im.isPyramidal)
668 {
669 window = new GLWindow(gameWindowSettings, nativeSettings);
670 View = new SlideGLArea();
671 ((SlideGLArea)View).OnSkiaRender += RenderSkia;
672 slideRenderer = new SlideRenderer((SlideGLArea)View);
673 }
674 else
675 {
676 View = new SlideArea();
677 ((SlideArea)View).OnSkiaRender += RenderSkia;
678 }
679 GLFWProvider.SetErrorCallback((error, description) =>
680 {
681 });
682 }
683
684 if (MacOS)
685 {
686 // View is the sole rendering surface on macOS for both pyramidal and non-pyramidal.
687 // Always attach it to the grid so the widget appears in the window layout.
688 View.Expand = true;
689 grid.Attach(View, 0, 0, 1, 1);
690
691 if (im.isPyramidal)
692 {
693 // Set the source based on image type
694 if (im.OpenSlideBase != null)
695 {
696 sKSlideRenderer.SetSource(im.OpenSlideBase);
697 }
698 else if (im.SlideBase != null)
699 {
700 sKSlideRenderer.SetSource(im.SlideBase);
701 }
702 }
703 }
704 else
705 {
706 // Set the source based on image type
707 if (slideRenderer != null && im.OpenSlideBase != null)
708 {
709 slideRenderer.SetSource(im.OpenSlideBase);
710 }
711 else if (slideRenderer != null && im.SlideBase != null)
712 {
713 slideRenderer.SetSource(im.SlideBase);
714 }
715 if (im.isPyramidal)
716 {
717 View.Expand = true;
718 grid.Attach(View,0,0,1,1);
719 }
720 else
721 {
722 View.Name = "View";
723 View.WidthRequest = 600;
724 View.HeightRequest = 400;
725 grid.Attach(View, 0, 0, 1, 1);
726 }
727 }
728
729 if (!MacOS)
730 {
731 if (im.isPyramidal)
732 {
733 View.WidthRequest = 600;
734 View.HeightRequest = 400;
735 View.Show();
736 }
737 else
738 View.ShowAll();
739 }
740 else
741 {
742 View.WidthRequest = 600;
743 View.HeightRequest = 400;
744 View.Show();
745 }
746 roi.Submenu = roiMenu;
747 UpdateRoiMenuState();
748 roi.ShowAll();
749 AddImage(im);
750 ShowMasks = true;
751 pxWmicron = SelectedImage.PhysicalSizeX;
752 pxHmicron = SelectedImage.PhysicalSizeY;
754 EnsureInitialPyramidalRender();
755 //pictureBox.WidthRequest = im.SizeX;
756 //pictureBox.HeightRequest = im.SizeY;
757 Function.InitializeContextMenu();
758 this.Scale = new SizeF(1, 1);
759 // Set the text column to display for comboboxs.
760 var rendererr = new CellRendererText();
761 rBox.PackStart(rendererr, false);
762 rBox.AddAttribute(rendererr, "text", 0);
763 var rendererg = new CellRendererText();
764 gBox.PackStart(rendererg, false);
765 gBox.AddAttribute(rendererg, "text", 0);
766 var rendererb = new CellRendererText();
767 bBox.PackStart(rendererb, false);
768 bBox.AddAttribute(rendererb, "text", 0);
769 UpdateScrollBars();
770
771 Gtk.Application.Invoke((s, e) =>
772 {
773 if (SelectedImage != null)
774 GoToImage(SelectedIndex);
775 });
776
777 //App.ApplyStyles(this);
778 }
779 /*
780 GRBackendRenderTarget _renderTarget;
781 GRContext _grContext;
782 SKSurface _skSurface;
783 private void InitializeSkia()
784 {
785 // 1. Get the High-DPI scaling factor for the laptop screen
786 int scale = ScaleFactor;
787 int width = AllocatedWidth * scale;
788 int height = AllocatedHeight * scale;
789
790 if (width <= 0 || height <= 0) return;
791
792 // 2. Query the current GL state for the framebuffer configuration
793 GL.GetInteger(GetPName.FramebufferBinding, out int framebufferId);
794 GL.GetInteger(GetPName.Samples, out int samples); // <--- This fills your 'samples'
795 // Get Stencil Bits with a fallback
796 int stencil;
797 try
798 {
799 // 0x0D57 is the GL constant for STENCIL_BITS
800 GL.GetInteger((GetPName)0x0D57, out stencil);
801 }
802 catch
803 {
804 stencil = 8; // Standard fallback for Skia
805 }
806
807 var glInfo = new GRGlFramebufferInfo(
808 fboId: (uint)framebufferId,
809 format: 0x8058); // GL_RGBA8
810
811 // 4. Create the render target with physical pixel dimensions
812 _renderTarget = new GRBackendRenderTarget(
813 width,
814 height,
815 samples,
816 stencil,
817 glInfo);
818
819 // 5. Create the context and surface
820 _grContext = GRContext.CreateGl();
821 _skSurface = SKSurface.Create(_grContext, _renderTarget, GRSurfaceOrigin.BottomLeft, SKColorType.Rgba8888);
822 }
823 */
828 private void RenderSkia(SKCanvas canvas, int width, int height)
829 {
830 try
831 {
832 canvas.Save();
833 canvas.Scale(ScaleFactor); // Scale coordinates to match High-DPI screen
834
835 var paint = new SKPaint
836 {
837 Color = SKColors.Gray,
838 BlendMode = SKBlendMode.SrcOver,
839 IsAntialias = true,
840 Style = SKPaintStyle.Fill
841 };
842 // Draw annotations (ROIs) - keep existing annotation drawing code
843 int i = 0;
844 foreach (BioImage im in Images)
845 {
846 canvas.Save();
847 List<ROI> rois = BuildRenderableRois(im);
848 int ri = 0;
849 ZCT co = GetCoordinate();
850 RectangleD rp = ToScreenSpace(new RectangleD(im.StageSizeX * im.PhysicalSizeX, im.StageSizeX * im.PhysicalSizeY, im.SizeX * im.PhysicalSizeX, im.SizeY * im.PhysicalSizeY));
851 if (!im.isPyramidal)
852 canvas.DrawImage(SKImages[i], ToRectangle((float)rp.X, (float)rp.Y, (float)rp.W, (float)rp.H), paint);
853 if (rois.Count > 0)
854 {
855 RenderSkiaAnnotations(canvas, width, height, im);
856 }
857 i++;
858 canvas.Restore();
859 }
860 canvas.Restore();
861 // Draw overview if enabled
862 EnsureOverviewCurrent();
863 if (overviewImage != null && ShowOverview)
864 {
865 paint = new SKPaint
866 {
867 Color = SKColors.Gray,
868 BlendMode = SKBlendMode.SrcOver,
869 IsAntialias = true,
870 Style = SKPaintStyle.Fill
871 };
872 // Draw the overview image at the top-left corner
873 canvas.DrawImage(overviewImage, 0, 0, paint);
874 paint.Style = SKPaintStyle.Stroke;
875 canvas.DrawRect(0, 0, overview.Width, overview.Height, paint);
876 }
877 else if (overviewImage == null && ShowOverview)
878 {
879 paint.Style = SKPaintStyle.Stroke;
880 paint.Color = SKColors.Gray;
881 paint.StrokeWidth = 2;
882 canvas.DrawRect(0, 0, overview.Width, overview.Height, paint);
883 }
884 SKImageInfo inf = new SKImageInfo(width, height);
885 if(SelectedImage!=null)
886 if (SelectedImage.isPyramidal)
887 DrawViewportRectangle(canvas, paint);
888
889 // Plugin rendering hook
890 Plugins.Render(this, new SkiaSharp.Views.Desktop.SKPaintSurfaceEventArgs(canvas.Surface, inf));
891 paint.Dispose();
892 canvas.Restore();
893 }
894 catch (Exception ex)
895 {
896 Console.WriteLine($"Annotation render error: {ex.Message}");
897 }
898 }
899 private PointD ToScreenSpaceForImage(BioImage image, PointD p)
900 {
901 if (image != null && image.isPyramidal)
902 {
903 double resolution = image.Resolution > 0 ? image.Resolution : 1;
904 PointD origin = image.PyramidalOrigin;
905 return new PointD((p.X - origin.X) / resolution, (p.Y - origin.Y) / resolution);
906 }
907
908 double dx = (pxWmicron * (Origin.X)) * Scale.Width;
909 double dy = (pxHmicron * (Origin.Y)) * Scale.Height;
910 return new PointD((PxWmicron * p.X * Scale.Width + dx), (PxHmicron * p.Y * Scale.Height + dy));
911 }
912
913 private RectangleD ToScreenRectForImage(BioImage image, double x, double y, double w, double h)
914 {
915 if (image != null && image.isPyramidal)
916 {
917 double resolution = image.Resolution > 0 ? image.Resolution : 1;
918 PointD origin = image.PyramidalOrigin;
919 return new RectangleD(
920 (x - origin.X) / resolution,
921 (y - origin.Y) / resolution,
922 w / resolution,
923 h / resolution);
924 }
925
926 double dx = (pxWmicron * (Origin.X)) * Scale.Width;
927 double dy = (pxHmicron * (Origin.Y)) * Scale.Height;
928 return new RectangleD(
929 (PxWmicron * x * Scale.Width + dx),
930 (PxHmicron * y * Scale.Height + dy),
931 (PxWmicron * w * Scale.Width),
932 (PxHmicron * h * Scale.Height));
933 }
934
935 private void RenderSkiaAnnotations(SKCanvas canvas, int width, int height, BioImage im)
936 {
937 try
938 {
939 canvas.Save();
940 canvas.Scale(ScaleFactor); // Scale coordinates to match High-DPI screen
941
942 var paint = new SKPaint
943 {
944 Color = SKColors.Gray,
945 BlendMode = SKBlendMode.SrcOver,
946 IsAntialias = true,
947 Style = SKPaintStyle.Fill
948 };
949
950 canvas.Save();
951 List<ROI> rois = BuildRenderableRois(im);
952 int ri = 0;
953 ZCT co = im?.Coordinate ?? new ZCT();
954 if (rois.Count > 0)
955 {
956 List<ROI> selectedMasks = new List<ROI>();
957 foreach (ROI an in rois)
958 {
959 if (an.coord != co)
960 continue;
961 if (Mode == ViewMode.RGBImage)
962 {
963 if (!showRROIs && an.coord.C == 0)
964 continue;
965 if (!showGROIs && an.coord.C == 1)
966 continue;
967 if (!showBROIs && an.coord.C == 2)
968 continue;
969 }
970 if (an.Selected)
971 {
972 paint.Color = SKColors.Magenta;
973 }
974 else
975 paint.Color = new SKColor(an.strokeColor.R, an.strokeColor.G, an.strokeColor.B);
976 paint.StrokeWidth = (float)an.strokeWidth;
977 paint.Style = SKPaintStyle.Stroke;
978 PointF pc = new PointF((float)(an.BoundingBox.X + (an.BoundingBox.W / 2)), (float)(an.BoundingBox.Y + (an.BoundingBox.H / 2)));
979 float widths = ROI.selectBoxSize * (float)Resolution;
980
981 if (an.type == ROI.Type.Mask && ShowMasks && an.coord == co)
982 {
983 if (an.roiMask == null)
984 continue;
985 if (an.Selected)
986 {
987 selectedMasks.Add(an);
988 continue;
989 }
990 paint.Style = SKPaintStyle.Fill;
991 RectangleD p = ToScreenRectForImage(im, an.X, an.Y, an.W, an.H);
992 if (!IsScreenRectVisible(p, width, height))
993 continue;
994 const byte maskOverlayAlpha = 96;
995 using SKImage sim = MaskToSKImage(an.roiMask, Color.FromArgb(maskOverlayAlpha, an.fillColor.R, an.fillColor.G, an.fillColor.B), maskOverlayAlpha);
996 canvas.DrawImage(sim, ToRectangle((float)p.X, (float)p.Y, (float)p.W, (float)p.H), paint);
997 continue;
998 }
999 else
1000 if (an.type == ROI.Type.Point)
1001 {
1002 RectangleD r1 = ToScreenRect(an.Point.X, an.Point.Y, ToViewW(3), ToViewH(3));
1003 canvas.DrawCircle((float)r1.X, (float)r1.Y, 3, paint);
1004 }
1005 else
1006 if (an.type == ROI.Type.Line)
1007 {
1008 for (int p = 0; p < an.Points.Count - 1; p++)
1009 {
1010 PointD p1 = ToScreenSpaceForImage(im, an.Points[p]);
1011 PointD p2 = ToScreenSpaceForImage(im, an.Points[p + 1]);
1012 canvas.DrawLine(new SKPoint((float)p1.X, (float)p1.Y), new SKPoint((float)p2.X, (float)p2.Y), paint);
1013 }
1014 }
1015 else
1016 if (an.type == ROI.Type.Rectangle)
1017 {
1018 RectangleD rectt = ToScreenRectForImage(im, an.BoundingBox.X, an.BoundingBox.Y, an.BoundingBox.W, an.BoundingBox.H);
1019 canvas.DrawRect((float)rectt.X, (float)rectt.Y, (float)rectt.W, (float)rectt.H, paint);
1020 }
1021 else
1022 if (an.type == ROI.Type.Ellipse)
1023 {
1024 RectangleD rect = ToScreenRectForImage(im, an.BoundingBox.X, an.BoundingBox.Y, an.BoundingBox.W, an.BoundingBox.H);
1025 canvas.DrawOval((float)rect.X, (float)rect.Y, (float)rect.W / 2, (float)rect.H / 2, paint);
1026 }
1027 else
1028 if ((an.type == ROI.Type.Polygon && an.closed))
1029 {
1030 for (int p = 0; p < an.Points.Count - 1; p++)
1031 {
1032 PointD p1 = ToScreenSpaceForImage(im, an.Points[p]);
1033 PointD p2 = ToScreenSpaceForImage(im, an.Points[p + 1]);
1034 canvas.DrawLine(new SKPoint((float)p1.X, (float)p1.Y), new SKPoint((float)p2.X, (float)p2.Y), paint);
1035 }
1036 PointD pp1 = ToScreenSpaceForImage(im, an.Points[0]);
1037 PointD pp2 = ToScreenSpaceForImage(im, an.Points[an.Points.Count - 1]);
1038 canvas.DrawLine(new SKPoint((float)pp1.X, (float)pp1.Y), new SKPoint((float)pp2.X, (float)pp2.Y), paint);
1039 }
1040 else
1041 if ((an.type == ROI.Type.Polygon && !an.closed) || an.type == ROI.Type.Polyline)
1042 {
1043 for (int p = 0; p < an.Points.Count - 1; p++)
1044 {
1045 PointD p1 = ToScreenSpaceForImage(im, an.Points[p]);
1046 PointD p2 = ToScreenSpaceForImage(im, an.Points[p + 1]);
1047 canvas.DrawLine(new SKPoint((float)p1.X, (float)p1.Y), new SKPoint((float)p2.X, (float)p2.Y), paint);
1048 }
1049 }
1050 else
1051 if (an.type == ROI.Type.Freeform)
1052 {
1053 for (int p = 0; p < an.Points.Count - 1; p++)
1054 {
1055 PointD p1 = ToScreenSpaceForImage(im, an.Points[p]);
1056 PointD p2 = ToScreenSpaceForImage(im, an.Points[p + 1]);
1057 canvas.DrawLine(new SKPoint((float)p1.X, (float)p1.Y), new SKPoint((float)p2.X, (float)p2.Y), paint);
1058 }
1059 PointD pp1 = ToScreenSpaceForImage(im, an.Points[0]);
1060 PointD pp2 = ToScreenSpaceForImage(im, an.Points[an.Points.Count - 1]);
1061 canvas.DrawLine(new SKPoint((float)pp1.X, (float)pp1.Y), new SKPoint((float)pp2.X, (float)pp2.Y), paint);
1062 }
1063 else
1064 if (an.type == ROI.Type.Label)
1065 {
1066 PointD p = ToScreenSpaceForImage(im, an.Point);
1067 canvas.DrawText(an.Text, (float)p.X, (float)p.Y, new SKFont(SKTypeface.Default, an.fontSize, 1, 0), paint);
1068 }
1069
1070 if (ROIManager.showText)
1071 {
1072 RectangleD p = ToScreenRectForImage(im, an.Point.X, an.Point.Y, 1, 1);
1073 canvas.DrawText(an.Text, (float)p.X, (float)p.Y, new SKFont(SKTypeface.Default, an.fontSize, 1, 0), paint);
1074 }
1075 if (ROIManager.showBounds && an.type != ROI.Type.Rectangle && an.type != ROI.Type.Mask && an.type != ROI.Type.Label)
1076 {
1077 RectangleD rrf = ToScreenRectForImage(im, an.BoundingBox.X, an.BoundingBox.Y, an.BoundingBox.W, an.BoundingBox.H);
1078 canvas.DrawRect((float)rrf.X, (float)rrf.Y, (float)rrf.W, (float)rrf.H, paint);
1079 }
1080 paint.Color = SKColors.Red;
1081 if (!(an.type == ROI.Type.Freeform && !an.Selected) && an.type != ROI.Type.Mask)
1082 foreach (RectangleD re in an.GetSelectBoxes(1))
1083 {
1084 RectangleD recd = ToScreenRectForImage(im, re.X, re.Y, re.W, re.H);
1085 canvas.DrawRect((float)recd.X, (float)recd.Y, (float)recd.W, (float)recd.H, paint);
1086 }
1087 if (an.type != ROI.Type.Mask)
1088 {
1089 //Lets draw the selection Boxes.
1090 List<RectangleD> rects = new List<RectangleD>();
1091 RectangleD[] sels = an.GetSelectBoxes(widths);
1092 for (int p = 0; p < an.selectedPoints.Count; p++)
1093 {
1094 if (an.selectedPoints[p] < an.GetPointCount())
1095 {
1096 rects.Add(sels[an.selectedPoints[p]]);
1097 }
1098 }
1099 //Lets draw selected selection boxes.
1100 paint.Color = SKColors.Blue;
1101 if (rects.Count > 0)
1102 {
1103 int ind = 0;
1104 foreach (RectangleD re in an.GetSelectBoxes(1))
1105 {
1106 RectangleD recd = ToScreenRectForImage(im, re.X, re.Y, re.W, re.H);
1107 if (an.selectedPoints.Contains(ind))
1108 {
1109 canvas.DrawRect((float)recd.X, (float)recd.Y, (float)recd.W, (float)recd.H, paint);
1110 }
1111 ind++;
1112 }
1113 }
1114 rects.Clear();
1115 }
1116 ri++;
1117 }
1118 foreach (ROI an in selectedMasks)
1119 {
1120 if (an == null || an.roiMask == null)
1121 continue;
1122 paint.Style = SKPaintStyle.Fill;
1123 RectangleD p = ToScreenRectForImage(im, an.X, an.Y, an.W, an.H);
1124 if (!IsScreenRectVisible(p, width, height))
1125 continue;
1126 using SKImage sim = MaskToSKImage(an.roiMask, Color.FromArgb(220, 0, 120, 255), 220);
1127 canvas.DrawImage(sim, ToRectangle((float)p.X, (float)p.Y, (float)p.W, (float)p.H), paint);
1128 paint.Style = SKPaintStyle.Stroke;
1129 paint.Color = SKColors.Blue;
1130 paint.StrokeWidth = 2;
1131 canvas.DrawRect(ToRectangle((float)p.X, (float)p.Y, (float)p.W, (float)p.H), paint);
1132 }
1133 }
1134 canvas.Restore();
1135 SKImageInfo inf = new SKImageInfo(width, height);
1136 // Plugin rendering hook
1137 Plugins.Render(this, new SkiaSharp.Views.Desktop.SKPaintSurfaceEventArgs(canvas.Surface, inf));
1138 paint.Dispose();
1139 canvas.Restore();
1140 }
1141 catch (Exception ex)
1142 {
1143 Console.WriteLine($"Annotation render error: {ex.Message}");
1144 }
1145 }
1146
1147 private List<ROI> BuildRenderableRois(BioImage image)
1148 {
1149 if (!ShowROIs)
1150 return new List<ROI>();
1151
1152 List<ROI> rois = image?.Annotations?.ToList() ?? new List<ROI>();
1153 List<ROI> overlays = GetZarrLabelOverlays(image);
1154 if (overlays.Count == 0)
1155 return rois;
1156
1157 HashSet<ROI> existing = new HashSet<ROI>(rois.Where(r => r != null));
1158 foreach (ROI overlay in overlays)
1159 {
1160 if (overlay != null && existing.Add(overlay))
1161 rois.Add(overlay);
1162 }
1163 return rois;
1164 }
1165
1166 private static bool IsScreenRectVisible(RectangleD rect, int width, int height)
1167 {
1168 if (rect.W <= 0 || rect.H <= 0 || width <= 0 || height <= 0)
1169 return false;
1170
1171 return rect.X < width &&
1172 rect.Y < height &&
1173 rect.X + rect.W > 0 &&
1174 rect.Y + rect.H > 0;
1175 }
1176 public static SKImage BitmapToSKImage(Bitmap bmp)
1177 {
1178 var rect = new Rectangle(0, 0, bmp.Width, bmp.Height);
1179
1180 var data = bmp.LockBits(
1181 rect,
1182 ImageLockMode.ReadOnly,
1183 AForge.PixelFormat.Format32bppArgb); // This is BGRA in memory
1184
1185 try
1186 {
1187 var info = new SKImageInfo(
1188 bmp.Width,
1189 bmp.Height,
1190 SKColorType.Bgra8888,
1191 SKAlphaType.Premul);
1192
1193 var pixmap = new SKPixmap(info, data.Scan0, data.Stride);
1194
1195 return SKImage.FromPixels(pixmap);
1196 }
1197 finally
1198 {
1199 bmp.UnlockBits(data);
1200 }
1201 }
1202
1203 private static SKImage MaskToSKImage(ROI.Mask mask, Color color, byte alpha)
1204 {
1205 if (mask == null || mask.Width <= 0 || mask.Height <= 0)
1206 return SKImage.FromBitmap(new SKBitmap(1, 1));
1207
1208 byte[] values = mask.GetBytes();
1209 int width = mask.Width;
1210 int height = mask.Height;
1211 int pixelCount = width * height;
1212 byte[] bgra = new byte[pixelCount * 4];
1213 int count = Math.Min(pixelCount, values?.Length ?? 0);
1214
1215 for (int i = 0; i < count; i++)
1216 {
1217 if (values[i] == 0)
1218 continue;
1219
1220 int d = i * 4;
1221 bgra[d + 0] = color.B;
1222 bgra[d + 1] = color.G;
1223 bgra[d + 2] = color.R;
1224 bgra[d + 3] = alpha;
1225 }
1226
1227 var info = new SKImageInfo(width, height, SKColorType.Bgra8888, SKAlphaType.Unpremul);
1228 var data = SKData.CreateCopy(bgra);
1229 var pixmap = new SKPixmap(info, data.Data, info.RowBytes);
1230 return SKImage.FromPixels(pixmap, (address, context) => data.Dispose(), null);
1231 }
1232 /*
1236 private void DrawOverview(SKCanvas canvas, SKPaint paint)
1237 {
1238 var ims = BitmapToSKImage(overviewImage.GetImageRGBA(overviewImage.LittleEndian));
1239 paint.Style = SKPaintStyle.Fill;
1240 canvas.DrawImage(ims, 0, 0, paint);
1241
1242 paint.Style = SKPaintStyle.Stroke;
1243 paint.Color = SKColors.Gray;
1244 canvas.DrawRect(overview.X, overview.Y, overview.Width, overview.Height, paint);
1245
1246 // Draw viewport indicator
1247 paint.Color = SKColors.Red;
1248 // ... existing viewport rect calculation ...
1249
1250 ims.Dispose();
1251 }
1252 */
1253 // Immediate render for interactive operations like panning
1254 public void RequestImmediateRender()
1255 {
1256 UpdateView(true);
1257 }
1258
1259 private void CleanupViewerResources()
1260 {
1261 if (_viewerCleanupDone)
1262 return;
1263
1264 _viewerCleanupDone = true;
1265
1266 _tileFetchCancellation?.Cancel();
1267 _tileFetchCancellation?.Dispose();
1268 _tileFetchCancellation = null;
1269
1270 lock (_pendingTileFetches)
1271 {
1272 _pendingTileFetches.Clear();
1273 }
1274
1275 overviewImage?.Dispose();
1276 overviewImage = null;
1277 _overviewRequestId++;
1278
1279 slideRenderer?.ClearCache();
1280 slideRenderer = null;
1281 sKSlideRenderer?.Dispose();
1282 sKSlideRenderer = null;
1283 }
1284
1285 private static SkiaSharp.SKRect ToRectangle(float x1, float y1, float x2, float y2)
1286 {
1287 return new SkiaSharp.SKRect()
1288 {
1289 Location = new SKPoint(x1, y1),
1290 Size = new SKSize(x2, y2)
1291 };
1292 }
1293 private bool refresh = false;
1294 SKImage overviewSKImage;
1295 private bool _viewerCleanupDone = false;
1296 private bool _destroyRequested = false;
1297 private async Task PrefetchSurroundingTiles(Extent viewportExtent, int level)
1298 {
1299 if (_viewerCleanupDone)
1300 return;
1301 // Cancel any pending prefetch operations
1302 _tileFetchCancellation?.Cancel();
1303 _tileFetchCancellation = new CancellationTokenSource();
1304 var token = _tileFetchCancellation.Token;
1305
1306 // Expand viewport by 1 tile in each direction for prefetching
1307 var expandedExtent = new Extent(
1308 viewportExtent.MinX - 256,
1309 viewportExtent.MinY - 256,
1310 viewportExtent.MaxX + 256,
1311 viewportExtent.MaxY + 256
1312 );
1313 IEnumerable<TileInfo> tileInfos;
1314 if (SelectedImage.OpenSlideBase != null)
1315 tileInfos = SelectedImage.OpenSlideBase.Schema.GetTileInfos(expandedExtent, level);
1316 else
1317 tileInfos = SelectedImage.SlideBase.Schema.GetTileInfos(expandedExtent, level);
1318 // Start fetching tiles asynchronously
1319 var fetchTasks = new List<Task>();
1320 foreach (var tileInfo in tileInfos)
1321 {
1322 if (token.IsCancellationRequested) break;
1323
1324 var coord = SelectedImage.Coordinate;
1325 var info = new Info(coord, tileInfo.Index, tileInfo.Extent, level);
1326 string pendingKey = $"{coord.Z}:{coord.C}:{coord.T}:{tileInfo.Index.Level}:{tileInfo.Index.Col}:{tileInfo.Index.Row}";
1327
1328 // Check the active source cache before scheduling another fetch.
1329 if (SelectedImage.OpenSlideBase != null)
1330 {
1331 if (SelectedImage.OpenSlideBase.cache.GetTile(info) != null)
1332 continue;
1333 }
1334 else
1335 {
1336 BioLib.TileInformation tf = new TileInformation(tileInfo.Index, tileInfo.Extent, SelectedImage.Coordinate);
1337 if (SelectedImage.SlideBase.cache.GetTile(tf) != null)
1338 continue;
1339 }
1340
1341 // Start async fetch if not already pending
1342 if (!_pendingTileFetches.ContainsKey(pendingKey))
1343 {
1344 var fetchTask = Task.Run(async () =>
1345 {
1346 try
1347 {
1348 if (SelectedImage.OpenSlideBase != null)
1349 {
1350 Info inf = new Info(coord, tileInfo.Index, tileInfo.Extent, tileInfo.Index.Level);
1351 var tile = await SelectedImage.OpenSlideBase.cache.GetTile(inf);
1352 return tile;
1353 }
1354 else
1355 {
1356 TileInformation tf = new TileInformation(tileInfo.Index, tileInfo.Extent, coord);
1357 var tile = await SelectedImage.SlideBase.cache.GetTile(tf);
1358 return tile;
1359 }
1360 }
1361 catch (Exception)
1362 {
1363 return null;
1364 }
1365 }, token);
1366
1367 _pendingTileFetches[pendingKey] = fetchTask;
1368 fetchTasks.Add(fetchTask);
1369 }
1370 }
1371
1372 // Don't await - let prefetching happen in background
1373 _ = Task.WhenAll(fetchTasks).ContinueWith(t =>
1374 {
1375 // Clean up completed fetches
1376 lock (_pendingTileFetches)
1377 {
1378 var completed = _pendingTileFetches
1379 .Where(kvp => kvp.Value.IsCompleted)
1380 .Select(kvp => kvp.Key)
1381 .ToList();
1382
1383 foreach (var key in completed)
1384 _pendingTileFetches.Remove(key);
1385 }
1386
1387 // Request a render update if we prefetched any tiles
1388 if (fetchTasks.Count > 0 && !token.IsCancellationRequested)
1389 RequestDeferredRender();
1390 }, token);
1391 }
1392 public void RequestDeferredRender()
1393 {
1394 if (_viewerCleanupDone)
1395 return;
1396 if (System.Threading.Interlocked.Exchange(ref _deferredRenderQueued, 1) == 1)
1397 return;
1398
1399 GLib.Timeout.Add(16, () =>
1400 {
1401 if (_viewerCleanupDone)
1402 {
1403 System.Threading.Interlocked.Exchange(ref _deferredRenderQueued, 0);
1404 return false;
1405 }
1406
1407 try
1408 {
1409 ExecuteDeferredRender();
1410 }
1411 finally
1412 {
1413 System.Threading.Interlocked.Exchange(ref _deferredRenderQueued, 0);
1414 }
1415 return false;
1416 });
1417 }
1418 private void ExecuteDeferredRender()
1419 {
1420 if (_viewerCleanupDone)
1421 return;
1422 // Run directly if already on the GTK main thread so panning and zooming
1423 // update immediately. Post via Invoke only for background-thread callers.
1424 if (Thread.CurrentThread == _gtkMainThread)
1425 DoRender();
1426 else
1427 Gtk.Application.Invoke((sender, args) => DoRender());
1428 }
1429 private void DoRender()
1430 {
1431 if (_viewerCleanupDone)
1432 return;
1433 if (SelectedImage?.isPyramidal != true) return;
1434
1435 if (MacOS)
1436 {
1437 AForge.Size s;
1438 if (View.AllocatedWidth <= 1 || View.AllocatedHeight <= 1)
1439 s = new AForge.Size(600, 400);
1440 else
1441 s = new AForge.Size(View.AllocatedWidth, View.AllocatedHeight);
1442
1443 var brutileOrigin = new PointD(PyramidalOrigin.X, -PyramidalOrigin.Y);
1444 int myGeneration = System.Threading.Interlocked.Increment(ref _renderGeneration);
1445
1446 var renderTask = sKSlideRenderer?.UpdateViewAsync(
1447 brutileOrigin,
1448 Resolution,
1449 GetCoordinate(),
1450 s.Width,
1451 s.Height
1452 );
1453 if (renderTask != null)
1454 {
1455 _ = renderTask.ContinueWith(t =>
1456 {
1457 if (!t.IsFaulted && !_viewerCleanupDone && _renderGeneration == myGeneration)
1458 Gtk.Application.Invoke((s2, a2) => View?.QueueDraw());
1459 });
1460 }
1461 }
1462 else
1463 {
1464 if (View.AllocatedWidth > 1 && View.AllocatedHeight > 1)
1465 {
1466 _ = slideRenderer?.UpdateViewAsync(
1467 PyramidalOrigin,
1468 View.AllocatedWidth,
1469 View.AllocatedHeight,
1470 Resolution,
1472 );
1473 }
1474 }
1475 }
1476 bool initrend = false;
1477 #endregion
1478 private void DrawViewportRectangle(SKCanvas canvas, SKPaint paint)
1479 {
1480 if (SelectedImage == null || !SelectedImage.isPyramidal || !ShowOverview)
1481 return;
1482
1483 try
1484 {
1485 TryGetPyramidalViewerBounds(SelectedImage, out double imageMinX, out double imageMinY, out double imageMaxX, out double imageMaxY);
1486 double imageWorldW = imageMaxX - imageMinX;
1487 double imageWorldH = imageMaxY - imageMinY;
1488
1489 if (imageWorldW <= 0) imageWorldW = 1;
1490 if (imageWorldH <= 0) imageWorldH = 1;
1491
1492 // The overview bitmap is a scaled representation of the full image,
1493 // so the viewport rectangle must be mapped in the same world-space
1494 // units that GoToImage and the zoom/pan logic use.
1495 double visibleLeft = PyramidalOrigin.X;
1496 double visibleTop = PyramidalOrigin.Y;
1497 double visibleRight = visibleLeft + (View.AllocatedWidth * Resolution);
1498 double visibleBottom = visibleTop + (View.AllocatedHeight * Resolution);
1499
1500 paint.Color = SKColors.Yellow;
1501 if (visibleLeft <= imageMinX &&
1502 visibleTop <= imageMinY &&
1503 visibleRight >= imageMaxX &&
1504 visibleBottom >= imageMaxY)
1505 {
1506 paint.Style = SKPaintStyle.Stroke;
1507 paint.StrokeWidth = 1f;
1508 paint.IsAntialias = true;
1509 canvas.DrawRect(0, 0, overview.Width, overview.Height, paint);
1510 return;
1511 }
1512
1513 float ovScaleX = (float)overview.Width / (float)imageWorldW;
1514 float ovScaleY = (float)overview.Height / (float)imageWorldH;
1515
1516 float ovX = (float)((visibleLeft - imageMinX) * ovScaleX);
1517 float ovY = (float)((visibleTop - imageMinY) * ovScaleY);
1518 float ovW = (float)((visibleRight - visibleLeft) * ovScaleX);
1519 float ovH = (float)((visibleBottom - visibleTop) * ovScaleY);
1520
1521 // 4. Clamp to overview bounds
1522 float rectLeft = Math.Max(0, ovX);
1523 float rectTop = Math.Max(0, ovY);
1524 float rectRight = Math.Min((float)overview.Width, ovX + ovW);
1525 float rectBottom = Math.Min((float)overview.Height, ovY + ovH);
1526
1527 // 5. Draw
1528 if (rectRight > rectLeft && rectBottom > rectTop)
1529 {
1530 paint.Style = SKPaintStyle.Stroke;
1531 paint.StrokeWidth = 1f;
1532 paint.IsAntialias = true;
1533
1534 canvas.DrawRect(rectLeft, rectTop, rectRight - rectLeft, rectBottom - rectTop, paint);
1535 }
1536 }
1537 catch (Exception)
1538 {
1539 }
1540 }
1541 private void SetResolution(double newResolution, PointD mouseScreenPos)
1542 {
1543 if (newResolution <= 0 || newResolution == Resolution)
1544 return;
1545
1546 double oldResolution = Resolution;
1547
1548 // 1. World-space point currently under the cursor.
1549 // PyramidalOrigin and Resolution are both in world (micron) units.
1550 double worldX = PyramidalOrigin.X + mouseScreenPos.X * oldResolution;
1551 double worldY = PyramidalOrigin.Y + mouseScreenPos.Y * oldResolution;
1552
1553 // 2. Apply the new resolution directly on the image to avoid triggering
1554 // UpdateView before the origin is also updated.
1555 _suppressViewUpdates = true;
1556 try
1557 {
1558 if (SelectedImage.isPyramidal)
1559 SelectedImage.Resolution = newResolution;
1560 }
1561 finally
1562 {
1563 _suppressViewUpdates = false;
1564 }
1565
1566 double actualResolution = Resolution;
1567 if (actualResolution == oldResolution)
1568 return;
1569
1570 // 3. Reposition origin so the same world point stays under the cursor.
1571 double newOriginX = worldX - mouseScreenPos.X * actualResolution;
1572 double newOriginY = worldY - mouseScreenPos.Y * actualResolution;
1573
1574 // 4. Clamp to translated pyramid bounds in viewer coordinates.
1575 TryGetPyramidalViewerBounds(SelectedImage, out double imageMinX, out double imageMinY, out double imageMaxX, out double imageMaxY);
1576 double viewWorldW = Math.Max(1, View?.AllocatedWidth ?? 1) * actualResolution;
1577 double viewWorldH = Math.Max(1, View?.AllocatedHeight ?? 1) * actualResolution;
1578
1579 PyramidalOrigin = new PointD(
1580 ClampPyramidalOrigin(newOriginX, imageMinX, imageMaxX, viewWorldW),
1581 ClampPyramidalOrigin(newOriginY, imageMinY, imageMaxY, viewWorldH)
1582 );
1583 // PyramidalOrigin setter fires UpdateView with both the new origin and
1584 // new resolution already in place — one render, correct result.
1585 }
1586 public void SetTitle(string s)
1587 {
1588 this.Title = s;
1589 }
1590 // In your resize handler
1591 private void OnViewResized(object sender, EventArgs e)
1592 {
1593 if (SelectedImage?.isPyramidal == true)
1594 {
1595 UpdateView(true);
1596 UpdateImages();
1597 }
1598 }
1599
1600 // Add these fields to ImageView class
1601 // Captured at construction time on the GTK main thread so ExecuteDeferredRender
1602 // can detect whether it's already on the right thread without GLib internals.
1603 private readonly Thread _gtkMainThread = Thread.CurrentThread;
1604 // Incremented each time a new macOS pyramidal render is requested.
1605 // ContinueWith callbacks check this to discard stale results.
1606 private volatile int _renderGeneration = 0;
1607 private int _deferredRenderQueued = 0;
1608 // Set to true while GoToImage is applying a batch of origin/resolution changes
1609 // so that intermediate setter calls don't each fire a separate UpdateView.
1610 private bool _suppressViewUpdates = false;
1611 private bool _isApplyingGoToImage = false;
1612 private int? _pendingGoToImageIndex = null;
1613 private bool _updatingGui = false;
1614 private readonly HashSet<BioImage> _initializedRgbChannelDefaults = new HashSet<BioImage>();
1615 private CancellationTokenSource _tileFetchCancellation;
1616 private Dictionary<string, Task<byte[]>> _pendingTileFetches = new Dictionary<string, Task<byte[]>>();
1617 private List<SKImage> SKImages = new List<SKImage>();
1618 private List<Bitmap> Bitmaps = new List<Bitmap>();
1620 public void UpdateImages(bool force = false)
1621 {
1622 App.viewer = this;
1623 if (selectedIndex < 0 || selectedIndex >= Images.Count)
1624 return;
1625 BioImage currentImage = Images[selectedIndex];
1626 if (currentImage == null)
1627 return;
1628 EnsureImageChannels(currentImage);
1629 if (zBar.Adjustment.Upper != currentImage.SizeZ - 1 ||
1630 tBar.Adjustment.Upper != currentImage.SizeT - 1)
1631 {
1632 UpdateGUI();
1633 }
1634 if (!MacOS)
1635 {
1636 if (currentImage.isPyramidal && glArea.AllocatedWidth > 1 && glArea.AllocatedHeight > 1)
1637 {
1638 // Use the new renderer - no more ReadPixels!
1639 _ = slideRenderer.UpdateViewAsync(
1640 currentImage.PyramidalOrigin,
1641 glArea.AllocatedWidth,
1642 glArea.AllocatedHeight,
1643 currentImage.Resolution,
1644 currentImage.Coordinate
1645 );
1646 }
1647 else if (!currentImage.isPyramidal)
1648 {
1649 for (int i = 0; i < SKImages.Count; i++)
1650 {
1651 SKImages[i].Dispose();
1652 Bitmaps[i].Dispose();
1653 }
1654 SKImages.Clear();
1655 Bitmaps.Clear();
1656 foreach (BioImage b in Images)
1657 {
1658 ZCT c = b.Coordinate;
1659 AForge.Bitmap bitmap = null;
1660 int index = b.GetFrameIndex(c.Z, c.C, c.T);
1661 if (Mode == ViewMode.Filtered)
1662 {
1663 bitmap = b.GetFiltered(c, b.RChannel.RangeR, b.GChannel.RangeG, b.BChannel.RangeB);
1664 }
1665 else if (Mode == ViewMode.RGBImage)
1666 {
1667 bitmap = b.GetRGBBitmap(c, b.RChannel.RangeR, b.GChannel.RangeG, b.BChannel.RangeB);
1668 }
1669 else if (Mode == ViewMode.Raw)
1670 {
1671 bitmap = b.Buffers[index];
1672 }
1673 else
1674 {
1675 bitmap = b.GetEmission(c, b.RChannel.RangeR, b.GChannel.RangeG, b.BChannel.RangeB);
1676 }
1677 if (bitmap == null)
1678 return;
1679 var bm = bitmap.GetImageRGBA(bitmap.LittleEndian);
1680 SKImage skim = BitmapToSKImage(bm);
1681 SKImages.Add(skim);
1682 Bitmaps.Add(bm);
1683 }
1684 }
1685 }
1686 else
1687 {
1688 if (currentImage.isPyramidal && sk.AllocatedWidth > 1 && sk.AllocatedHeight > 1)
1689 {
1690 // Use the new renderer - no more ReadPixels!
1691 _ = sKSlideRenderer.UpdateViewAsync(
1692 currentImage.PyramidalOrigin,
1693 currentImage.Resolution,
1694 currentImage.Coordinate,
1695 sk.AllocatedWidth,
1696 sk.AllocatedHeight
1697 );
1698 }
1699 else if (!currentImage.isPyramidal)
1700 {
1701 for (int i = 0; i < SKImages.Count; i++)
1702 {
1703 SKImages[i].Dispose();
1704 Bitmaps[i].Dispose();
1705 }
1706 SKImages.Clear();
1707 Bitmaps.Clear();
1708 foreach (BioImage b in Images)
1709 {
1710 ZCT c = b.Coordinate;
1711 AForge.Bitmap bitmap = null;
1712 int index = b.GetFrameIndex(c.Z, c.C, c.T);
1713 if (Mode == ViewMode.Filtered)
1714 {
1715 bitmap = b.GetFiltered(c, b.RChannel.RangeR, b.GChannel.RangeG, b.BChannel.RangeB);
1716 }
1717 else if (Mode == ViewMode.RGBImage)
1718 {
1719 bitmap = b.GetRGBBitmap(c, b.RChannel.RangeR, b.GChannel.RangeG, b.BChannel.RangeB);
1720 }
1721 else if (Mode == ViewMode.Raw)
1722 {
1723 bitmap = b.Buffers[index];
1724 }
1725 else
1726 {
1727 bitmap = b.GetEmission(c, b.RChannel.RangeR, b.GChannel.RangeG, b.BChannel.RangeB);
1728 }
1729 if (bitmap == null)
1730 return;
1731 var bm = bitmap.GetImageRGBA(bitmap.LittleEndian);
1732 SKImage skim = BitmapToSKImage(bm);
1733 SKImages.Add(skim);
1734 Bitmaps.Add(bm);
1735 }
1736 }
1737 }
1738 }/*
1743 private async Task UpdateWellImageAsync()
1744 {
1745 try
1746 {
1747 var bitmap = await SelectedImage.GetWellFieldBitmap().ConfigureAwait(false);
1748 if (bitmap == null) return;
1749
1750 var tcs = new System.Threading.Tasks.TaskCompletionSource<bool>();
1751 Gtk.Application.Invoke((s, e) =>
1752 {
1753 try
1754 {
1755 for (int i = 0; i < SKImages.Count; i++) { SKImages[i].Dispose(); Bitmaps[i].Dispose(); }
1756 SKImages.Clear();
1757 Bitmaps.Clear();
1758
1759 SKImage skim = BitmapToSKImage(bitmap);
1760 SKImages.Add(skim);
1761 Bitmaps.Add(bitmap);
1762
1763 View?.QueueDraw();
1764 tcs.SetResult(true);
1765 }
1766 catch (Exception ex) { tcs.SetException(ex); }
1767 });
1768 await tcs.Task.ConfigureAwait(false);
1769 }
1770 catch (Exception ex)
1771 {
1772 Console.WriteLine($"UpdateWellImageAsync error: {ex.Message}");
1773 }
1774 }
1775 */
1777 public void UpdateImage()
1778 {
1779 UpdateImages();
1780 BioLib.Recorder.Record(BioLib.Recorder.GetCurrentMethodInfo(), false);
1781 }
1787 {
1788 if (SelectedImage?.isPyramidal != true)
1789 return;
1790
1791 // Threshold/channel edits invalidate pixels but do not require socket
1792 // aborts. Let older Zarr reads drain and discard them by render version.
1793 slideRenderer?.InvalidateRenderState(cancelActiveFetches: false);
1794 sKSlideRenderer?.InvalidateRenderState();
1795 SelectedImage.InvalidateTileCache();
1796 slideRenderer?.ClearCache(resetLevel: false, cancelActiveFetches: false);
1797 sKSlideRenderer?.ClearCache();
1798 SelectedImage.ZarrDisplayMax = 0;
1799 _overviewSource = null;
1800 BioLib.Recorder.Record(BioLib.Recorder.GetCurrentMethodInfo(), false, SelectedImage?.file ?? string.Empty);
1801 }
1802
1803 public Task<bool> PopulateSelectedZarrStatisticsAsync()
1804 {
1805 var image = SelectedImage;
1806 if (image?.IsZarrSource != true || slideRenderer == null)
1807 return Task.FromResult(false);
1808
1809 return slideRenderer.PopulateZarrStatisticsAsync(image, image.Coordinate);
1810 }
1811 Rectangle overview;
1812 SKImage overviewImage;
1813 private BioImage _overviewSource;
1814 private ZCT _overviewCoordinate;
1815 private int _overviewDisplayMin, _overviewDisplayMax, _overviewWellIndex;
1816 private int _overviewRequestId;
1817
1818 private void EnsureOverviewCurrent()
1819 {
1820 var image = SelectedImage;
1821 if (_viewerCleanupDone || image?.isPyramidal != true || !ShowOverview)
1822 return;
1823
1824 if (!ReferenceEquals(_overviewSource, image) ||
1825 _overviewCoordinate != image.Coordinate ||
1826 _overviewDisplayMin != image.ZarrDisplayMin ||
1827 _overviewDisplayMax != image.ZarrDisplayMax ||
1828 _overviewWellIndex != image.WellIndex)
1829 InitPreview();
1830 }
1831 public int? MacroResolution { get { return SelectedImage.MacroResolution; } }
1832 public int? LabelResolution { get { return SelectedImage.LabelResolution; } }
1833
1840 private async void InitPreview()
1841 {
1842 var image = SelectedImage;
1843 if (_viewerCleanupDone || image == null || !image.isPyramidal)
1844 return;
1845
1846 int requestId = ++_overviewRequestId;
1847 _overviewSource = image;
1848 _overviewCoordinate = image.Coordinate;
1849 _overviewDisplayMin = image.ZarrDisplayMin;
1850 _overviewDisplayMax = image.ZarrDisplayMax;
1851 _overviewWellIndex = image.WellIndex;
1852 ZCT coordinate = image.Coordinate;
1853 try
1854 {
1855 refresh = false;
1856 int OVERVIEW_SIZE = View.ScaleFactor * 160;
1857
1858 var (sourceBitmap, srcW, srcH) = await FetchPreviewBitmap(image, coordinate, OVERVIEW_SIZE);
1859 Gtk.Application.Invoke((s, e) =>
1860 {
1861 using (sourceBitmap)
1862 {
1863 if (_viewerCleanupDone || requestId != _overviewRequestId || !ReferenceEquals(image, SelectedImage))
1864 return;
1865 try
1866 {
1867 var (ow, oh) = CalcOverviewSize(srcW, srcH, OVERVIEW_SIZE);
1868 var preview = ScaleToOverview(sourceBitmap, srcW, srcH, ow, oh);
1869 overview = new Rectangle(0, 0, ow, oh);
1870 overviewImage?.Dispose();
1871 overviewImage = preview;
1872 ShowOverview = overviewImage != null;
1873 }
1874 catch (Exception ex)
1875 {
1876 Console.WriteLine($"Overview rendering failed: {ex.Message}");
1877 ShowOverview = false;
1878 overviewImage?.Dispose();
1879 overviewImage = null;
1880 }
1881 // SlideGLArea uses AutoRender=false, so QueueDraw alone does
1882 // not invoke its GL render callback. Request a GL render as
1883 // soon as the asynchronously prepared overview is installed;
1884 // otherwise it only appears after the next pan/zoom event.
1885 if (View is SlideGLArea slideGlArea)
1886 slideGlArea.RequestRedraw();
1887 else
1888 View?.QueueDraw();
1889 }
1890 });
1891 }
1892 catch (Exception ex)
1893 {
1894 Gtk.Application.Invoke((s, e) =>
1895 {
1896 if (_viewerCleanupDone || requestId != _overviewRequestId)
1897 return;
1898 Console.WriteLine($"Overview loading failed: {ex.Message}");
1899 ShowOverview = false;
1900 overviewImage?.Dispose();
1901 overviewImage = null;
1902 });
1903 }
1904 }
1905
1906 // Returns (bitmap, srcW, srcH), or (null, 0, 0) on failure.
1907 private async Task<(Bitmap bitmap, int w, int h)> FetchPreviewBitmap(BioImage image, ZCT coordinate, int overviewSize)
1908 {
1909 if (image.Type == BioImage.ImageType.well)
1910 {
1911 var bmp = await image.GetWellFieldBitmap(image.WellIndex, coordinate.Z, coordinate.C, coordinate.T);
1912 if (bmp == null)
1913 {
1914 return (null, 0, 0);
1915 }
1916 return (bmp, bmp.Width, bmp.Height);
1917 }
1918
1919 // The initial pyramidal view is already fit to the complete image.
1920 // Reuse that rendered frame instead of independently downloading and
1921 // decoding another multi-megapixel pyramid level for the minimap.
1922 if (View is SlideGLArea slideGlArea)
1923 {
1924 var renderedPreview = await CaptureRenderedPreviewAsync(slideGlArea).ConfigureAwait(false);
1925 if (renderedPreview.bitmap != null)
1926 return renderedPreview;
1927 }
1928
1929 if (image.OpenSlideBase?.SlideImage != null)
1930 return await Task.Run(() => FetchOpenSlidePreview(image, overviewSize)).ConfigureAwait(false);
1931
1932 int level = FindOverviewLevel(image, overviewSize);
1933 if (level < 0)
1934 return (null, 0, 0);
1935
1936 for (int lev = level; lev >= 0; lev--)
1937 {
1938 var res = image.Resolutions[lev];
1939 int srcW = res.SizeX, srcH = res.SizeY;
1940
1941 try
1942 {
1943 var tileBitmap = await image.GetTile(
1944 image.GetFrameIndex(coordinate.Z, coordinate.C, coordinate.T),
1945 lev,
1946 0, 0,
1947 srcW, srcH, coordinate);
1948 if (tileBitmap != null && !LooksBlank(tileBitmap))
1949 return (tileBitmap, srcW, srcH);
1950 tileBitmap?.Dispose();
1951 }
1952 catch (Exception)
1953 {
1954 }
1955
1956 if (image.SlideBase == null)
1957 return (null, 0, 0);
1958
1959 try
1960 {
1961 var sliceInfo = new BioLib.SliceInfo(
1962 0, 0,
1963 srcW, srcH,
1964 image.GetUnitPerPixel(lev),
1965 coordinate);
1966
1967 byte[] bt = await image.SlideBase.GetSlice(
1968 sliceInfo,
1969 new PointD(0, 0),
1970 new AForge.Size(srcW, srcH));
1971
1972 var bmp = CreatePreviewBitmap(srcW, srcH, bt);
1973 if (!LooksBlank(bmp))
1974 return (bmp, srcW, srcH);
1975 bmp?.Dispose();
1976 }
1977 catch (Exception)
1978 {
1979 }
1980 }
1981
1982 return (null, 0, 0);
1983 }
1984
1985 private static async Task<(Bitmap bitmap, int w, int h)> CaptureRenderedPreviewAsync(SlideGLArea slideGlArea)
1986 {
1987 var completion = new TaskCompletionSource<(Bitmap bitmap, int w, int h)>(
1988 TaskCreationOptions.RunContinuationsAsynchronously);
1989 Action<byte[], int, int> captureCallback = null;
1990
1991 captureCallback = (bytes, width, height) =>
1992 {
1993 try
1994 {
1995 completion.TrySetResult((
1996 CreatePreviewBitmap(width, height, bytes), width, height));
1997 }
1998 catch (Exception ex)
1999 {
2000 completion.TrySetException(ex);
2001 }
2002 };
2003 Gtk.Application.Invoke((s, e) =>
2004 {
2005 slideGlArea.CaptureNextWholeImageFrame(captureCallback);
2006 });
2007
2008 // A user can zoom before the initial fit frame completes. In that case
2009 // retain the source-reader fallback rather than waiting indefinitely.
2010 Task finished = await Task.WhenAny(completion.Task, Task.Delay(TimeSpan.FromSeconds(15)))
2011 .ConfigureAwait(false);
2012 if (finished == completion.Task)
2013 return await completion.Task.ConfigureAwait(false);
2014
2015 Gtk.Application.Invoke((s, e) =>
2016 slideGlArea.CancelWholeImageFrameCapture(captureCallback));
2017 return (null, 0, 0);
2018 }
2019
2020 private static (Bitmap bitmap, int w, int h) FetchOpenSlidePreview(BioImage image, int overviewSize)
2021 {
2022 var slide = image?.OpenSlideBase?.SlideImage;
2023 if (slide == null || slide.LevelCount <= 0)
2024 return (null, 0, 0);
2025
2026 int targetSize = Math.Max(overviewSize * 2, 512);
2027 var levels = Enumerable.Range(0, slide.LevelCount)
2028 .Select(level =>
2029 {
2030 var dimensions = slide.GetLevelDimension(level);
2031 int width = (int)Math.Min(int.MaxValue, dimensions.Width);
2032 int height = (int)Math.Min(int.MaxValue, dimensions.Height);
2033 long score = Math.Abs((long)Math.Max(width, height) - targetSize);
2034 return (Level: level, Width: width, Height: height, Score: score);
2035 })
2036 .Where(item => item.Width > 0 && item.Height > 0)
2037 .OrderBy(item => item.Score);
2038
2039 foreach (var item in levels)
2040 {
2041 try
2042 {
2043 byte[] bytes = slide.ReadRegion(item.Level, 0, 0, item.Width, item.Height);
2044 Bitmap bitmap = CreatePreviewBitmap(item.Width, item.Height, bytes);
2045 if (!LooksBlank(bitmap, rejectBlack: true))
2046 return (bitmap, item.Width, item.Height);
2047 bitmap?.Dispose();
2048 }
2049 catch (Exception)
2050 {
2051 }
2052 }
2053
2054 return (null, 0, 0);
2055 }
2056
2057 private static Bitmap CreatePreviewBitmap(int width, int height, byte[] bytes)
2058 {
2059 if (bytes == null)
2060 return null;
2061
2062 // OpenSlide/Vips preview slices can arrive as packed RGB24. Convert that
2063 // layout into the BGRA-style buffer expected by the AForge bitmap path.
2064 if (bytes.Length == width * height * 3)
2065 {
2066 byte[] bgra = new byte[width * height * 4];
2067 for (int i = 0, j = 0; i < bytes.Length; i += 3, j += 4)
2068 {
2069 bgra[j + 0] = bytes[i + 2];
2070 bgra[j + 1] = bytes[i + 1];
2071 bgra[j + 2] = bytes[i + 0];
2072 bgra[j + 3] = 255;
2073 }
2074 return new Bitmap(width, height, AForge.PixelFormat.Format32bppArgb, bgra, new ZCT(), "");
2075 }
2076
2077 return new Bitmap(width, height, AForge.PixelFormat.Format32bppArgb, bytes, new ZCT(), "");
2078 }
2079
2080 private int FindOverviewLevel(BioImage image, int overviewSize)
2081 {
2082 // Exclude macro/label thumbnail levels so the overview shows tissue, not a slide label.
2083 int maxLevel = image.MacroResolution.HasValue
2084 ? image.MacroResolution.Value - 1
2085 : image.Resolutions.Count - 1;
2086 maxLevel = Math.Clamp(maxLevel, 0, image.Resolutions.Count - 1);
2087
2088 // A small amount of oversampling is sufficient for a 160-pixel minimap.
2089 // Reading a ~4096-pixel level here duplicated most of the initial viewport
2090 // work and could transfer tens of megabytes for a tiny preview.
2091 int targetSize = Math.Max(overviewSize * 2, 512);
2092
2093 int bestLevel = -1;
2094 int bestScore = int.MaxValue;
2095
2096 for (int lev = maxLevel; lev >= 0; lev--)
2097 {
2098 var res = image.Resolutions[lev];
2099 int maxDim = Math.Max(res.SizeX, res.SizeY);
2100 if (maxDim < 16)
2101 continue;
2102
2103 int score = Math.Abs(maxDim - targetSize);
2104 if (score < bestScore)
2105 {
2106 bestScore = score;
2107 bestLevel = lev;
2108 }
2109 }
2110
2111 return bestLevel;
2112 }
2113
2114 private static (int w, int h) CalcOverviewSize(int srcW, int srcH, int maxSize)
2115 {
2116 double ar = (double)srcW / Math.Max(1, srcH);
2117 int w = ar >= 1.0 ? maxSize : (int)(maxSize * ar);
2118 int h = ar >= 1.0 ? (int)(maxSize / ar) : maxSize;
2119 return (Math.Max(w, 20), Math.Max(h, 20));
2120 }
2121
2122 private static bool LooksBlank(Bitmap bmp, bool rejectBlack = false)
2123 {
2124 if (bmp == null || bmp.Width <= 0 || bmp.Height <= 0)
2125 return true;
2126
2127 int stepX = Math.Max(1, bmp.Width / 16);
2128 int stepY = Math.Max(1, bmp.Height / 16);
2129 int samples = 0;
2130 int bright = 0;
2131 int dark = 0;
2132
2133 for (int y = 0; y < bmp.Height; y += stepY)
2134 {
2135 for (int x = 0; x < bmp.Width; x += stepX)
2136 {
2137 var p = bmp.GetPixel(x, y);
2138 int lum = (p.R + p.G + p.B) / 3;
2139 if (lum >= 245)
2140 bright++;
2141 if (rejectBlack && lum <= 5)
2142 dark++;
2143 samples++;
2144 }
2145 }
2146
2147 return samples > 0 &&
2148 (bright * 100 / samples > 95 ||
2149 rejectBlack && dark * 100 / samples > 98);
2150 }
2151
2152 private SKImage ScaleToOverview(Bitmap src, int srcW, int srcH, int destW, int destH)
2153 {
2154 if (src == null)
2155 return null;
2156
2157 Bitmap previewBitmap = NormalizeOverviewBitmap(src,
2158 SelectedImage?.ZarrDisplayMin ?? 0, SelectedImage?.ZarrDisplayMax ?? 0);
2159
2160 try
2161 {
2162 // Match the main render path: normalize to RGBA first, then
2163 // hand Skia a 32-bit BGRA bitmap.
2164 using var rgbaBitmap = previewBitmap.GetImageRGBA(previewBitmap.LittleEndian);
2165 using (var fullSK = BioImage.BitmapToSKImage(rgbaBitmap))
2166 {
2167 using var scaled = new SKBitmap(destW, destH, SKColorType.Bgra8888, SKAlphaType.Premul);
2168 fullSK.ScalePixels(scaled.PeekPixels(), SKSamplingOptions.Default);
2169 return SKImage.FromBitmap(scaled);
2170 }
2171 }
2172 finally
2173 {
2174 if (!ReferenceEquals(previewBitmap, src))
2175 previewBitmap?.Dispose();
2176 }
2177 }
2178
2179 private static Bitmap NormalizeOverviewBitmap(Bitmap src, ushort displayMin, int displayMax)
2180 {
2181 if (src == null)
2182 return null;
2183
2184 // GetTile and the slice readers already return display-mapped RGB(A).
2185 // Applying a 16-bit display maximum again makes an 8-bit preview black.
2186 // Only raw high-bit-depth bitmaps (e.g. well fields) need conversion.
2187 if (src.PixelFormat != AForge.PixelFormat.Format16bppGrayScale &&
2188 src.PixelFormat != AForge.PixelFormat.Format48bppRgb)
2189 return src;
2190
2191 if (displayMax <= displayMin || displayMax > ushort.MaxValue)
2192 {
2193 displayMin = 0;
2194 displayMax = ComputeOverviewDisplayMax(src);
2195 }
2196
2197 if (displayMax == 0)
2198 displayMax = 1;
2199
2200 if (src.PixelFormat == AForge.PixelFormat.Format16bppGrayScale)
2201 return NormalizeGray16ToArgb(src, displayMin, (ushort)displayMax);
2202
2203 return NormalizeRgb48ToArgb(src, displayMin, (ushort)displayMax);
2204 }
2205
2206 private static Bitmap NormalizeGray16ToArgb(Bitmap src, ushort displayMin, ushort displayMax)
2207 {
2208 byte[] bytes = src.Bytes;
2209 if (bytes == null || bytes.Length == 0)
2210 return src;
2211
2212 int pixelCount = src.Width * src.Height;
2213 if (pixelCount <= 0)
2214 return src;
2215
2216 byte[] bgra = new byte[pixelCount * 4];
2217 bool littleEndian = src.LittleEndian;
2218 float scale = 255f / Math.Max(1, displayMax - displayMin);
2219
2220 for (int srcIndex = 0, dstIndex = 0; srcIndex + 1 < bytes.Length && dstIndex + 3 < bgra.Length; srcIndex += 2, dstIndex += 4)
2221 {
2222 ushort value = littleEndian
2223 ? (ushort)(bytes[srcIndex] | (bytes[srcIndex + 1] << 8))
2224 : (ushort)((bytes[srcIndex] << 8) | bytes[srcIndex + 1]);
2225 byte gray = (byte)Math.Clamp((value - displayMin) * scale, 0f, 255f);
2226 bgra[dstIndex + 0] = gray;
2227 bgra[dstIndex + 1] = gray;
2228 bgra[dstIndex + 2] = gray;
2229 bgra[dstIndex + 3] = 255;
2230 }
2231
2232 return new Bitmap(src.Width, src.Height, AForge.PixelFormat.Format32bppArgb, bgra, new ZCT(), "");
2233 }
2234
2235 private static Bitmap NormalizeRgb48ToArgb(Bitmap src, ushort displayMin, ushort displayMax)
2236 {
2237 byte[] bytes = src.Bytes;
2238 if (bytes == null || bytes.Length == 0)
2239 return src;
2240
2241 int pixelCount = src.Width * src.Height;
2242 if (pixelCount <= 0)
2243 return src;
2244
2245 byte[] bgra = new byte[pixelCount * 4];
2246 bool littleEndian = src.LittleEndian;
2247 float scale = 255f / Math.Max(1, displayMax - displayMin);
2248
2249 for (int srcIndex = 0, dstIndex = 0; srcIndex + 5 < bytes.Length && dstIndex + 3 < bgra.Length; srcIndex += 6, dstIndex += 4)
2250 {
2251 ushort r = littleEndian
2252 ? (ushort)(bytes[srcIndex + 0] | (bytes[srcIndex + 1] << 8))
2253 : (ushort)((bytes[srcIndex + 0] << 8) | bytes[srcIndex + 1]);
2254 ushort g = littleEndian
2255 ? (ushort)(bytes[srcIndex + 2] | (bytes[srcIndex + 3] << 8))
2256 : (ushort)((bytes[srcIndex + 2] << 8) | bytes[srcIndex + 3]);
2257 ushort b = littleEndian
2258 ? (ushort)(bytes[srcIndex + 4] | (bytes[srcIndex + 5] << 8))
2259 : (ushort)((bytes[srcIndex + 4] << 8) | bytes[srcIndex + 5]);
2260
2261 bgra[dstIndex + 0] = (byte)Math.Clamp((b - displayMin) * scale, 0f, 255f);
2262 bgra[dstIndex + 1] = (byte)Math.Clamp((g - displayMin) * scale, 0f, 255f);
2263 bgra[dstIndex + 2] = (byte)Math.Clamp((r - displayMin) * scale, 0f, 255f);
2264 bgra[dstIndex + 3] = 255;
2265 }
2266
2267 return new Bitmap(src.Width, src.Height, AForge.PixelFormat.Format32bppArgb, bgra, new ZCT(), "");
2268 }
2269
2270 private static ushort ComputeOverviewDisplayMax(Bitmap src)
2271 {
2272 if (src == null || src.Bytes == null || src.Bytes.Length == 0)
2273 return 1;
2274
2275 byte[] bytes = src.Bytes;
2276 bool littleEndian = src.LittleEndian;
2277
2278 if (src.PixelFormat == AForge.PixelFormat.Format16bppGrayScale ||
2279 src.PixelFormat == AForge.PixelFormat.Format48bppRgb)
2280 {
2281 ushort max = 0;
2282 for (int i = 0; i + 1 < bytes.Length; i += 2)
2283 {
2284 ushort value = littleEndian
2285 ? (ushort)(bytes[i] | (bytes[i + 1] << 8))
2286 : (ushort)((bytes[i] << 8) | bytes[i + 1]);
2287 if (value > max)
2288 max = value;
2289 }
2290 return max == 0 ? (ushort)1 : max;
2291 }
2292
2293 int stride = bytes.Length >= src.Width * src.Height * 4 ? 4 : 3;
2294 byte maxByte = 0;
2295 for (int i = 0; i + stride - 1 < bytes.Length; i += stride)
2296 {
2297 if (bytes[i + 0] > maxByte) maxByte = bytes[i + 0];
2298 if (bytes[i + 1] > maxByte) maxByte = bytes[i + 1];
2299 if (bytes[i + 2] > maxByte) maxByte = bytes[i + 2];
2300 }
2301
2302 return maxByte == 0 ? (ushort)1 : maxByte;
2303 }
2304 #region Handlers
2305
2307 protected void SetupHandlers()
2308 {
2309 View.Realized += View_Realized;
2310 this.Shown += ImageView_Shown;
2311 zBar.ValueChanged += ValueChangedZ;
2312 cBar.ValueChanged += ValueChangedC;
2313 tBar.ValueChanged += ValueChangedT;
2314 zBar.ScrollEvent += ZBar_ScrollEvent;
2315 cBar.ScrollEvent += CBar_ScrollEvent;
2316 tBar.ScrollEvent += TBar_ScrollEvent;
2317 zBar.AddEvents((int)EventMask.ScrollMask);
2318 cBar.AddEvents((int)EventMask.ScrollMask);
2319 tBar.AddEvents((int)EventMask.ScrollMask);
2320 this.ScrollEvent += ImageView_WindowScrollEvent;
2321 this.AddEvents((int)EventMask.ScrollMask);
2322 if (MacOS)
2323 {
2324 View.MotionNotifyEvent += ImageView_MotionNotifyEvent;
2325 View.ButtonPressEvent += ImageView_ButtonPressEvent;
2326 View.ButtonReleaseEvent += ImageView_ButtonReleaseEvent;
2327 View.ScrollEvent += ImageView_ScrollEvent;
2328 // OnMouseWheel is intentionally NOT registered here: ImageView_ScrollEvent already
2329 // handles zoom-to-cursor via SetResolution. Registering both causes double-zoom jumps.
2330 ((SKDrawingArea)View).PaintSurface += Sk_PaintSurface;
2331 View.SizeAllocated += PictureBox_SizeAllocated;
2332 View.SetSizeRequest(600, 400);
2333 View.AddEvents((int)(EventMask.ButtonPressMask
2334 | EventMask.ButtonReleaseMask
2335 | EventMask.KeyPressMask
2336 | EventMask.PointerMotionMask
2337 | EventMask.ScrollMask));
2338 }
2339 else
2340 {
2341 View.MotionNotifyEvent += ImageView_MotionNotifyEvent;
2342 View.ButtonPressEvent += ImageView_ButtonPressEvent;
2343 View.ButtonReleaseEvent += ImageView_ButtonReleaseEvent;
2344 View.ScrollEvent += ImageView_ScrollEvent;
2345 // OnMouseWheel removed: ImageView_ScrollEvent handles zoom-to-cursor via SetResolution.
2346 if (glArea != null)
2347 glArea.Render += GlArea_Render;
2348 View.SizeAllocated += PictureBox_SizeAllocated;
2349 View.AddEvents((int)(EventMask.ButtonPressMask
2350 | EventMask.ButtonReleaseMask
2351 | EventMask.KeyPressMask
2352 | EventMask.PointerMotionMask
2353 | EventMask.ScrollMask));
2354 }
2355 vScroll.ValueChanged += VScroll_ValueChanged;
2356 hScroll.ValueChanged += HScroll_ValueChanged;
2357 this.KeyPressEvent += ImageView_KeyPressEvent;
2358 this.DestroyEvent += ImageView_DestroyEvent;
2359 this.DeleteEvent += ImageView_DeleteEvent;
2360 this.FocusInEvent += ImageView_FocusInEvent;
2361 rBox.Changed += RBox_Changed;
2362 gBox.Changed += GBox_Changed;
2363 bBox.Changed += BBox_Changed;
2364 //Context Menu
2365 goToImageMenu.ButtonPressEvent += GoToImageMenu_ButtonPressEvent;
2366 goToOriginMenu.ButtonPressEvent += GoToOriginMenu_ButtonPressEvent;
2367 overView.ButtonPressEvent += OverView_ButtonPressEvent;
2368
2369 roiShow.ButtonPressEvent += RoiShow_ButtonPressEvent;
2370 roiHide.ButtonPressEvent += RoiHide_ButtonPressEvent;
2371 roiDelete.ButtonPressEvent += RoiDelete_ButtonPressEvent;
2372 roiID.ButtonPressEvent += RoiID_ButtonPressEvent;
2373 copy.ButtonPressEvent += Copy_ButtonPressEvent;
2374 paste.ButtonPressEvent += Paste_ButtonPressEvent;
2375 draw.ButtonPressEvent += Draw_ButtonPressEvent;
2376 fill.ButtonPressEvent += Fill_ButtonPressEvent;
2377
2378 play.ButtonPressEvent += Play_ButtonPressEvent;
2379 stop.ButtonPressEvent += Stop_ButtonPressEvent;
2380 playSpeed.ButtonPressEvent += PlaySpeed_ButtonPressEvent;
2381 setValueRange.ButtonPressEvent += SetValueRange_ButtonPressEvent;
2382 loop.ButtonPressEvent += Loop_ButtonPressEvent;
2383
2384 zBar.ButtonPressEvent += ZBar_ButtonPressEvent;
2385 tBar.ButtonPressEvent += TBar_ButtonPressEvent;
2386 cBar.ButtonPressEvent += CBar_ButtonPressEvent;
2387
2388 }
2389
2390 private bool _initialPyramidalRenderQueued = false;
2391
2392 private void EnsureInitialPyramidalRender()
2393 {
2394 if (_initialPyramidalRenderQueued || SelectedImage?.isPyramidal != true)
2395 return;
2396
2397 _initialPyramidalRenderQueued = true;
2398 int attempts = 0;
2399 GLib.Timeout.Add(75, () =>
2400 {
2401 if (SelectedImage?.isPyramidal != true)
2402 return false;
2403
2404 bool hasRenderedImage = MacOS
2405 ? (sKSlideRenderer?.HasValidImage ?? false)
2406 : ((slideRenderer?.CachedTextureCount ?? 0) > 0);
2407 if (hasRenderedImage)
2408 return false;
2409
2410 if (View == null || !View.IsRealized || View.AllocatedWidth <= 1 || View.AllocatedHeight <= 1)
2411 return true;
2412
2413 App.viewer = this;
2414 GoToImage(SelectedIndex);
2415 RequestDeferredRender();
2416 if (!MacOS)
2417 glArea?.RequestRedraw();
2418 else
2419 View.QueueDraw();
2420
2421 attempts++;
2422 return attempts < 20;
2423 });
2424 }
2425
2426 private void View_Realized(object sender, EventArgs e)
2427 {
2428 EnsureInitialPyramidalRender();
2429 }
2430
2431 private void ImageView_Shown(object sender, EventArgs e)
2432 {
2433 EnsureInitialPyramidalRender();
2434 }
2435
2436 private void OverView_ButtonPressEvent(object o, ButtonPressEventArgs args)
2437 {
2438 if(ShowOverview)
2439 {
2440 ShowOverview = false;
2441 overView.Label = "Show Overview";
2442 }
2443 else
2444 {
2445 ShowOverview = true;
2446 overView.Label = "Hide Overview";
2447 }
2448 UpdateView();
2449 }
2450
2451 private void RoiShow_ButtonPressEvent(object o, ButtonPressEventArgs args)
2452 {
2453 ShowROIs = true;
2454 UpdateRoiMenuState();
2455 UpdateView();
2456 }
2457
2458 private void RoiHide_ButtonPressEvent(object o, ButtonPressEventArgs args)
2459 {
2460 ShowROIs = false;
2461 UpdateRoiMenuState();
2462 UpdateView();
2463 }
2464
2465 private void HScroll_ValueChanged(object sender, EventArgs e)
2466 {
2467 if (_updatingScrollBars) return;
2468 if (MacOS) return; // scrollbars hidden on macOS; ignore stale callbacks
2469 PyramidalOrigin = new PointD(hScroll.Value, PyramidalOrigin.Y);
2470 }
2471
2472 private void VScroll_ValueChanged(object sender, EventArgs e)
2473 {
2474 if (_updatingScrollBars) return;
2475 if (MacOS) return; // scrollbars hidden on macOS; ignore stale callbacks
2476 PyramidalOrigin = new PointD(PyramidalOrigin.X, vScroll.Value);
2477 }
2478
2479 private void Sk_PaintSurface(object sender, SkiaSharp.Views.Desktop.SKPaintSurfaceEventArgs e)
2480 {
2481 if (MacOS)
2482 {
2483 var canvas = e.Surface.Canvas;
2484 var width = e.Info.Width;
2485 var height = e.Info.Height;
2486 if (SelectedImage?.isPyramidal == true && sKSlideRenderer != null)
2487 {
2488 if (sKSlideRenderer.HasValidImage)
2489 canvas.Clear(SKColors.Black);
2490 sKSlideRenderer.DrawToCanvas(canvas, width, height);
2491 RenderSkiaAnnotations(canvas, width, height, SelectedImage);
2492 }
2493 else
2494 {
2495 canvas.Clear(SKColors.Black);
2496 // Render non-pyramidal images and annotations
2497 RenderSkia(canvas, width, height);
2498 }
2499 }
2500 }
2501
2502 private void GlArea_Render(object o, RenderArgs args)
2503 {
2504 // The GL area redraws itself from tiles already uploaded to the GPU.
2505 // Tile fetching and uploading is handled by UpdateViewAsync via RequestDeferredRender.
2506 }
2507
2508 private void ImageView_FocusInEvent(object o, FocusInEventArgs args)
2509 {
2510 //App.SelectWindow(this.Title);
2511 }
2512
2513 private void ImageView_DestroyEvent(object o, DestroyEventArgs args)
2514 {
2515 _destroyRequested = true;
2516 // Guard: DeleteEvent already performed full cleanup. Only run if
2517 // this viewer is still registered (e.g. programmatic Destroy()
2518 // without going through the close button).
2519 if (App.tabsView == null) return;
2520 // Check if we're still in the viewers list; if not, cleanup already happened.
2521 bool stillRegistered = false;
2522 for (int v = 0; v < App.tabsView.GetViewerCount(); v++)
2523 {
2524 if (App.tabsView.GetViewer(v) == this)
2525 {
2526 stillRegistered = true;
2527 break;
2528 }
2529 }
2530 if (!stillRegistered) return;
2531
2532 App.tabsView.RemoveViewer(this);
2533 CleanupViewerResources();
2534 foreach (var item in this.Images)
2535 {
2536 BioLib.Images.RemoveImage(item);
2537 }
2538 }
2539
2540 int bar = 0;
2541 System.Threading.Thread threadZ = null;
2542 System.Threading.Thread threadC = null;
2543 System.Threading.Thread threadT = null;
2544 static bool playZ = false;
2545 static bool playC = false;
2546 static bool playT = false;
2547 static bool loopZ = true;
2548 static bool loopC = true;
2549 static bool loopT = true;
2550 public static int waitz = 1000;
2551 public static int waitc = 1000;
2552 public static int waitt = 1000;
2553 public static int startz = 0;
2554 public static int startc = 0;
2555 public static int startt = 0;
2556 public static int endz = 0;
2557 public static int endc = 0;
2558 public static int endt = 0;
2562 private static void PlayZ()
2563 {
2564 do
2565 {
2566 ZCT coord = App.viewer.GetCoordinate();
2567 //Update view on main UI thread
2568 Application.Invoke(delegate
2569 {
2570 App.viewer.SetCoordinate(coord.Z + 1, coord.C, coord.T);
2571 });
2572 if (coord.Z == endz)
2573 {
2574 if (loopZ)
2575 {
2576 //Update view on main UI thread
2577 Application.Invoke(delegate
2578 {
2579 App.viewer.SetCoordinate(startz, coord.C, coord.T);
2580 });
2581 }
2582 else
2583 return;
2584 }
2585 System.Threading.Thread.Sleep(waitz);
2586 } while (playZ);
2587 }
2592 private static void PlayC()
2593 {
2594 do
2595 {
2596 ZCT coord = App.viewer.GetCoordinate();
2597 Application.Invoke(delegate
2598 {
2599 App.viewer.SetCoordinate(coord.Z, coord.C + 1, coord.T);
2600 });
2601 if (coord.C == endc)
2602 {
2603 if (loopC)
2604 {
2605 //Update view on main UI thread
2606 Application.Invoke(delegate
2607 {
2608 App.viewer.SetCoordinate(coord.Z, startc, coord.T);
2609 });
2610 }
2611 else
2612 return;
2613 }
2614 System.Threading.Thread.Sleep(waitc);
2615 } while (playC);
2616 }
2621 private static void PlayT()
2622 {
2623 do
2624 {
2625 ZCT coord = App.viewer.GetCoordinate();
2626 Application.Invoke(delegate
2627 {
2628 App.viewer.SetCoordinate(coord.Z, coord.C, coord.T + 1);
2629 });
2630 if (coord.T == endt)
2631 {
2632 if (loopT)
2633 {
2634 //Update view on main UI thread
2635 Application.Invoke(delegate
2636 {
2637 App.viewer.SetCoordinate(coord.Z, coord.C, startt);
2638 });
2639 }
2640 else
2641 return;
2642 }
2643 System.Threading.Thread.Sleep(waitt);
2644 } while (playT);
2645 }
2650 private void CBar_ButtonPressEvent(object o, ButtonPressEventArgs args)
2651 {
2652 bar = 2;
2653 if (args.Event.Button == 3)
2654 barMenu.Popup();
2655 }
2656
2661 private void TBar_ButtonPressEvent(object o, ButtonPressEventArgs args)
2662 {
2663 bar = 1;
2664 if (args.Event.Button == 3)
2665 barMenu.Popup();
2666 }
2667
2672 private void ZBar_ButtonPressEvent(object o, ButtonPressEventArgs args)
2673 {
2674 bar = 0;
2675 if (args.Event.Button == 3)
2676 barMenu.Popup();
2677 }
2678
2683 private void Loop_ButtonPressEvent(object o, ButtonPressEventArgs args)
2684 {
2685 if (bar == 0)
2686 {
2687 if (loopZ)
2688 loopZ = false;
2689 else
2690 loopZ = true;
2691 }
2692 else if (bar == 1)
2693 {
2694 if (loopC)
2695 loopC = false;
2696 else
2697 loopC = true;
2698 }
2699 else if (bar == 2)
2700 {
2701 if (loopT)
2702 loopT = false;
2703 else
2704 loopT = true;
2705 }
2706 }
2707
2713 private void SetValueRange_ButtonPressEvent(object o, ButtonPressEventArgs args)
2714 {
2715 Play play = Play.Create();
2716 play.Show();
2717 }
2718
2723 private void PlaySpeed_ButtonPressEvent(object o, ButtonPressEventArgs args)
2724 {
2725 Play play = Play.Create();
2726 play.Show();
2727 }
2728
2733 private void Stop_ButtonPressEvent(object o, ButtonPressEventArgs args)
2734 {
2735 if (bar == 0)
2736 {
2737 if (playZ)
2738 playZ = false;
2739 else
2740 playZ = true;
2741 }
2742 else if (bar == 1)
2743 {
2744 if (playT)
2745 playT = false;
2746 else
2747 playT = true;
2748 }
2749 else if (bar == 2)
2750 {
2751 if (playC)
2752 playC = false;
2753 else
2754 playC = true;
2755 }
2756 }
2757
2763 private void Play_ButtonPressEvent(object o, ButtonPressEventArgs args)
2764 {
2765 if (bar == 0)
2766 {
2767 if (endz == 0)
2768 endz = SelectedImage.SizeZ - 1;
2769 playZ = true;
2770 threadZ = new System.Threading.Thread(PlayZ);
2771 threadZ.Start();
2772 }
2773 else if (bar == 1)
2774 {
2775 if (endt == 0)
2776 endt = SelectedImage.SizeT - 1;
2777 playT = true;
2778 threadT = new System.Threading.Thread(PlayT);
2779 threadT.Start();
2780 }
2781 else if (bar == 2)
2782 {
2783 if (endc == 0)
2784 endc = SelectedImage.SizeC - 1;
2785 playC = true;
2786 threadC = new System.Threading.Thread(PlayC);
2787 threadC.Start();
2788 }
2789 }
2790
2795 private void Fill_ButtonPressEvent(object o, ButtonPressEventArgs args)
2796 {
2797 Bio.Graphics.Graphics g = Bio.Graphics.Graphics.FromImage(SelectedBuffer);
2798 List<ROI> rois = new List<ROI>();
2799 rois.AddRange(SelectedImage.AnnotationsR);
2800 rois.AddRange(SelectedImage.AnnotationsG);
2801 rois.AddRange(SelectedImage.AnnotationsB);
2802 foreach (ROI item in rois)
2803 {
2804 Bio.Graphics.Pen p = new Bio.Graphics.Pen(Tools.DrawColor, (int)Tools.StrokeWidth, SelectedBuffer.BitsPerPixel);
2805 if (item.Selected)
2806 {
2807 if (item.type == ROI.Type.Line)
2808 {
2809 PointD pf = SelectedImage.ToImageSpace(item.GetPoint(0));
2810 PointD pf2 = SelectedImage.ToImageSpace(item.GetPoint(1));
2811 g.DrawLine((int)pf.X, (int)pf.Y, (int)pf2.X, (int)pf2.Y);
2812 }
2813 else
2814 if (item.type == ROI.Type.Rectangle)
2815 {
2816 g.FillRectangle(SelectedImage.ToImageSpace(item.BoundingBox), p.color);
2817 }
2818 else
2819 if (item.type == ROI.Type.Ellipse)
2820 {
2821 g.FillEllipse(SelectedImage.ToImageSpace(item.BoundingBox), p.color);
2822 }
2823 else
2824 if (item.type == ROI.Type.Freeform || item.type == ROI.Type.Polygon || item.type == ROI.Type.Polyline)
2825 {
2826 g.FillPolygon(SelectedImage.ToImageSpace(item.GetPointsF()), SelectedImage.ToImageSpace(item.BoundingBox), p.color);
2827 }
2828 }
2829 }
2830 UpdateImage();
2831 UpdateView();
2832 }
2833
2838 private void Draw_ButtonPressEvent(object o, ButtonPressEventArgs args)
2839 {
2840 Bio.Graphics.Graphics g = Bio.Graphics.Graphics.FromImage(SelectedBuffer);
2841 List<ROI> rois = new List<ROI>();
2842 rois.AddRange(SelectedImage.AnnotationsR);
2843 rois.AddRange(SelectedImage.AnnotationsG);
2844 rois.AddRange(SelectedImage.AnnotationsB);
2845 foreach (ROI item in rois)
2846 {
2847 Bio.Graphics.Pen p = new Bio.Graphics.Pen(Tools.DrawColor, (int)Tools.StrokeWidth, SelectedBuffer.BitsPerPixel);
2848 g.pen = p;
2849 if (item.Selected)
2850 {
2851 if (item.type == ROI.Type.Line)
2852 {
2853 PointD pf = SelectedImage.ToImageSpace(item.GetPoint(0));
2854 PointD pf2 = SelectedImage.ToImageSpace(item.GetPoint(1));
2855 g.DrawLine((int)pf.X, (int)pf.Y, (int)pf2.X, (int)pf2.Y);
2856 }
2857 else
2858 if (item.type == ROI.Type.Rectangle)
2859 {
2860 g.DrawRectangle(SelectedImage.ToImageSpace(item.BoundingBox));
2861 }
2862 else
2863 if (item.type == ROI.Type.Ellipse)
2864 {
2865 g.DrawEllipse(SelectedImage.ToImageSpace(item.BoundingBox));
2866 }
2867 else
2868 if (item.type == ROI.Type.Freeform || item.type == ROI.Type.Polygon || item.type == ROI.Type.Polyline)
2869 {
2870 if (item.closed)
2871 {
2872 for (int i = 0; i < item.GetPointCount() - 1; i++)
2873 {
2874 PointD pf = SelectedImage.ToImageSpace(item.GetPoint(i));
2875 PointD pf2 = SelectedImage.ToImageSpace(item.GetPoint(i + 1));
2876 g.DrawLine((int)pf.X, (int)pf.Y, (int)pf2.X, (int)pf2.Y);
2877 }
2878 PointD pp = SelectedImage.ToImageSpace(item.GetPoint(0));
2879 PointD p2 = SelectedImage.ToImageSpace(item.GetPoint(item.GetPointCount() - 1));
2880 g.DrawLine((int)pp.X, (int)pp.Y, (int)p2.X, (int)p2.Y);
2881 }
2882 else
2883 {
2884 for (int i = 0; i < item.GetPointCount() - 1; i++)
2885 {
2886 PointD pf = SelectedImage.ToImageSpace(item.GetPoint(i));
2887 PointD pf2 = SelectedImage.ToImageSpace(item.GetPoint(i + 1));
2888 g.DrawLine((int)pf.X, (int)pf.Y, (int)pf2.X, (int)pf2.Y);
2889 }
2890 }
2891 }
2892 }
2893 }
2894 UpdateImage();
2895 UpdateView();
2896 }
2897
2903 private void Paste_ButtonPressEvent(object o, ButtonPressEventArgs args)
2904 {
2906 }
2907
2913 private void Copy_ButtonPressEvent(object o, ButtonPressEventArgs args)
2914 {
2915 CopySelection();
2916 }
2917
2923 private void ImageView_DeleteEvent(object o, DeleteEventArgs args)
2924 {
2925 if (_destroyRequested)
2926 {
2927 args.RetVal = true;
2928 return;
2929 }
2930
2931 _destroyRequested = true;
2932 // Prevent GTK's default delete handling — we manage teardown ourselves.
2933 // Without this, GTK proceeds to destroy and then the TabsView
2934 // WindowStateEvent sees a non-visible viewer and calls Present(),
2935 // which recreates the native window as an empty shell.
2936 args.RetVal = true;
2937
2938 // Remove this viewer from the TabsView list BEFORE disposing images,
2939 // so no event handler can re-present it while we're tearing down.
2940 App.tabsView.RemoveViewer(this);
2941 CleanupViewerResources();
2942
2943 for (int i = 0; i < this.Images.Count; i++)
2944 {
2945 var item = this.Images[i];
2946 BioLib.Images.RemoveImage(item);
2947 App.tabsView.RemoveTab(item.Filename);
2948 item.Dispose();
2949 }
2950 Hide();
2951 }
2952
2962 private void RoiID_ButtonPressEvent(object o, ButtonPressEventArgs args)
2963 {
2964 TextInput ti = TextInput.Create();
2965 ti.Show();
2966 UpdateView();
2967 }
2968
2974 private void RoiDelete_ButtonPressEvent(object o, ButtonPressEventArgs args)
2975 {
2976 DeleteSelectedRois();
2977 UpdateView();
2978 }
2979
2980 private void DeleteSelectedRois()
2981 {
2982 if (SelectedImage == null)
2983 return;
2984
2985 var toDelete = new HashSet<ROI>(selectedAnnotations);
2986 if (toDelete.Count == 0 && Tools.selectedROI != null)
2987 toDelete.Add(Tools.selectedROI);
2988
2989 if (toDelete.Count == 0)
2990 return;
2991
2992 foreach (var roi in toDelete)
2993 {
2994 if (roi == null)
2995 continue;
2996
2997 if (SelectedImage.Annotations.Contains(roi))
2998 SelectedImage.Annotations.Remove(roi);
2999 }
3000
3001 string key = GetOverlayKey(SelectedImage);
3002 var overlays = GetZarrLabelOverlays(SelectedImage);
3003 if (overlays != null && overlays.Count > 0)
3004 {
3005 overlays.RemoveAll(r => r != null && toDelete.Contains(r));
3006 SetZarrLabelOverlays(SelectedImage, overlays, key);
3007 }
3008
3009 if (Tools.selectedROI != null && toDelete.Contains(Tools.selectedROI))
3010 Tools.selectedROI = null;
3011
3012 foreach (var roi in toDelete)
3013 {
3014 if (roi != null)
3015 roi.Selected = false;
3016 }
3017 }
3018
3025 private void GoToOriginMenu_ButtonPressEvent(object o, ButtonPressEventArgs args)
3026 {
3027 if (SelectedImage?.isPyramidal == true)
3028 PyramidalOrigin = new PointD(0, 0);
3029 else
3030 Origin = new PointD(-SelectedImage.Volume.Location.X, -SelectedImage.Volume.Location.Y);
3031
3032 ForceNavigationRefresh();
3033 }
3034
3040 private void GoToImageMenu_ButtonPressEvent(object o, ButtonPressEventArgs args)
3041 {
3042 if (Images.Count == 0)
3043 return;
3044
3045 var picker = NumberPicker.Create(0, Images.Count - 1, SelectedIndex);
3046 picker.Title = "Go To Image";
3047 if (picker.Run() != (int)ResponseType.Ok)
3048 return;
3049
3050 GoToImage((int)picker.SelectedValue);
3051 }
3052
3053 #endregion
3054
3060 private void BBox_Changed(object sender, EventArgs e)
3061 {
3062 if (_updatingGui || SelectedImage == null)
3063 return;
3064 SelectedImage.rgbChannels[2] = bBox.Active;
3065 RefreshDisplayState(invalidatePyramidalTiles: true);
3066 }
3067
3072 private void GBox_Changed(object sender, EventArgs e)
3073 {
3074 if (_updatingGui || SelectedImage == null)
3075 return;
3076 SelectedImage.rgbChannels[1] = gBox.Active;
3077 RefreshDisplayState(invalidatePyramidalTiles: true);
3078 }
3079
3085 private void RBox_Changed(object sender, EventArgs e)
3086 {
3087 if (_updatingGui || SelectedImage == null)
3088 return;
3089 SelectedImage.rgbChannels[0] = rBox.Active;
3090 RefreshDisplayState(invalidatePyramidalTiles: true);
3091 }
3092
3093 private void RefreshDisplayState(bool invalidatePyramidalTiles = false)
3094 {
3095 if (SelectedImage == null)
3096 return;
3097
3098 if (invalidatePyramidalTiles && SelectedImage.isPyramidal)
3099 {
3101 UpdateView();
3102 return;
3103 }
3104
3105 UpdateView();
3106 }
3107
3108 bool initialized = false;
3109 private int _lastAllocatedWidth = 0;
3110 private int _lastAllocatedHeight = 0;
3111
3112 private void PictureBox_SizeAllocated(object o, SizeAllocatedArgs args)
3113 {
3114 if (SelectedImage == null) return;
3115
3116 int newW = MacOS ? View.AllocatedWidth : (SelectedImage.isPyramidal ? View.AllocatedWidth : View.AllocatedWidth);
3117 int newH = MacOS ? View.AllocatedHeight : (SelectedImage.isPyramidal ? View.AllocatedHeight : View.AllocatedHeight);
3118
3119 // Ignore trivial/degenerate sizes that GTK emits before the window is realized.
3120 if (newW <= 1 || newH <= 1) return;
3121
3122 bool sizeChanged = (newW != _lastAllocatedWidth || newH != _lastAllocatedHeight);
3123 _lastAllocatedWidth = newW;
3124 _lastAllocatedHeight = newH;
3125
3126 if (SelectedImage.isPyramidal)
3127 {
3128 SelectedImage.PyramidalSize = new AForge.Size(newW, newH);
3129 UpdateScrollBars();
3130
3131 // The first reliable moment we know the actual drawable size is
3132 // the first real SizeAllocated callback. Kick the initial fit +
3133 // render from here so startup does not depend on earlier window
3134 // lifecycle ordering.
3135 if (!initrend)
3136 {
3137 initrend = true;
3138 GLib.Idle.Add(() =>
3139 {
3140 if (SelectedImage?.isPyramidal != true || View == null ||
3141 View.AllocatedWidth <= 1 || View.AllocatedHeight <= 1)
3142 return false;
3143
3144 GoToImage(SelectedIndex);
3145 RequestDeferredRender();
3146 return false;
3147 });
3148 }
3149 }
3150 else
3151 UpdateImage();
3152
3153 if (!initialized)
3154 {
3155 // On macOS, GoToImage already ran in AddImage before SetupHandlers registered
3156 // this handler. On other platforms it may not have, so we call it once here
3157 // when we first get a real size.
3158 if (!MacOS)
3159 GoToImage();
3160 initialized = true;
3161 }
3162
3163 // Only re-render if the viewport size actually changed (not just GTK noise).
3164 if (sizeChanged)
3165 UpdateView();
3166 }
3167
3175 private static void DrawEllipse(Cairo.Context g, double x, double y, double width, double height)
3176 {
3177 g.Save();
3178 g.MoveTo(x + (width / 2), y);
3179 g.Translate(x, y);
3180 g.Scale(width / 2.0, height / 2.0);
3181 g.Arc(0.0, 0.0, 1.0, 0.0, 2 * Math.PI);
3182 g.Restore();
3183 g.Stroke();
3184 g.Stroke();
3185 }
3191 public static Cairo.Color FromColor(Color color)
3192 {
3193 return new Cairo.Color((double)color.R / 255, (double)color.G / 255, (double)color.B / 255);
3194 }
3195
3202 private void TBar_ScrollEvent(object o, ScrollEventArgs args)
3203 {
3204 if (AdjustSliderFromScroll(tBar, args))
3205 args.RetVal = true;
3206 }
3213 private void CBar_ScrollEvent(object o, ScrollEventArgs args)
3214 {
3215 if (AdjustSliderFromScroll(cBar, args))
3216 args.RetVal = true;
3217 }
3224 private void ZBar_ScrollEvent(object o, ScrollEventArgs args)
3225 {
3226 if (AdjustSliderFromScroll(zBar, args))
3227 args.RetVal = true;
3228 }
3229
3230 private void ImageView_WindowScrollEvent(object o, ScrollEventArgs args)
3231 {
3232 if (args.RetVal is bool handled && handled)
3233 return;
3234
3235 if (TryAdjustSliderFromPointer(zBar, args) ||
3236 TryAdjustSliderFromPointer(cBar, args) ||
3237 TryAdjustSliderFromPointer(tBar, args))
3238 {
3239 args.RetVal = true;
3240 }
3241 }
3242
3243 private bool TryAdjustSliderFromPointer(Gtk.Scale scale, ScrollEventArgs args)
3244 {
3245 if (scale == null || !scale.Visible || !scale.IsSensitive || !scale.IsRealized)
3246 return false;
3247
3248 Gdk.Window hostWindow = scale.Window;
3249 if (hostWindow == null)
3250 return false;
3251
3252 var allocation = scale.Allocation;
3253 hostWindow.GetOrigin(out int hostX, out int hostY);
3254 int widgetX = hostX + allocation.X;
3255 int widgetY = hostY + allocation.Y;
3256
3257 double pointerX = args.Event.XRoot;
3258 double pointerY = args.Event.YRoot;
3259
3260 bool inside =
3261 pointerX >= widgetX &&
3262 pointerX <= widgetX + allocation.Width &&
3263 pointerY >= widgetY &&
3264 pointerY <= widgetY + allocation.Height;
3265
3266 return inside && AdjustSliderFromScroll(scale, args);
3267 }
3268
3269 private bool AdjustSliderFromScroll(Gtk.Scale scale, ScrollEventArgs args)
3270 {
3271 if (scale?.Adjustment == null)
3272 return false;
3273
3274 double delta = GetSliderScrollDelta(args);
3275 if (delta == 0)
3276 return false;
3277
3278 double lower = scale.Adjustment.Lower;
3279 double upper = scale.Adjustment.Upper;
3280 double newValue = Math.Clamp(scale.Value + delta, lower, upper);
3281 if (Math.Abs(newValue - scale.Value) < double.Epsilon)
3282 return false;
3283
3284 scale.Value = newValue;
3285 return true;
3286 }
3287
3288 private static double GetSliderScrollDelta(ScrollEventArgs args)
3289 {
3290 if (args.Event.Direction == ScrollDirection.Up)
3291 return 1;
3292 if (args.Event.Direction == ScrollDirection.Down)
3293 return -1;
3294 if (args.Event.Direction == ScrollDirection.Smooth)
3295 {
3296 if (args.Event.DeltaY < 0)
3297 return 1;
3298 if (args.Event.DeltaY > 0)
3299 return -1;
3300 }
3301 return 0;
3302 }
3303
3304 private void RefreshAfterCoordinateSliderChange()
3305 {
3306 if (SelectedImage?.isPyramidal == true)
3307 ForceNavigationRefresh();
3308 else
3309 {
3310 UpdateImages(true);
3311 RequestViewportRefresh();
3312 }
3313 }
3314
3315 public static Gdk.Key keyDown = Gdk.Key.Key_3270_Test;
3324 private void ImageView_KeyPressEvent(object o, KeyPressEventArgs e)
3325 {
3326 Plugins.KeyDownEvent(o, e);
3327 keyDown = e.Event.Key;
3328 double moveAmount = 5 * Scale.Width;
3329 double movepyr = 50 * (Level + 1);
3330 if (e.Event.Key == Gdk.Key.c && e.Event.State == ModifierType.ControlMask)
3331 {
3332 CopySelection();
3333 }
3334 if (e.Event.Key == Gdk.Key.v && e.Event.State == ModifierType.ControlMask)
3335 {
3337 }
3338 if (e.Event.Key == Gdk.Key.e)
3339 {
3340 if (SelectedImage.isPyramidal)
3341 {
3342 SetResolution(Resolution * 1.25, new PointD(View.AllocatedWidth / 2.0, View.AllocatedHeight / 2.0));
3343 }
3344 else
3345 ZoomStackAtPoint(new PointD(View.AllocatedWidth / 2.0, View.AllocatedHeight / 2.0), 0.9);
3346 }
3347 if (e.Event.Key == Gdk.Key.q)
3348 {
3349 if (SelectedImage.isPyramidal)
3350 {
3351 SetResolution(Resolution * 0.8, new PointD(View.AllocatedWidth / 2.0, View.AllocatedHeight / 2.0));
3352 }
3353 else
3354 ZoomStackAtPoint(new PointD(View.AllocatedWidth / 2.0, View.AllocatedHeight / 2.0), 1.1);
3355 }
3356 if (e.Event.Key == Gdk.Key.s || e.Event.Key == Gdk.Key.S)
3357 {
3358 if (SelectedImage.isPyramidal)
3359 {
3360 PyramidalOrigin = new PointD(PyramidalOrigin.X, PyramidalOrigin.Y + movepyr);
3361 }
3362 else
3363 Origin = new PointD(Origin.X, Origin.Y + moveAmount);
3364 }
3365 if (e.Event.Key == Gdk.Key.w || e.Event.Key == Gdk.Key.W)
3366 {
3367 if (SelectedImage.isPyramidal)
3368 {
3369 PyramidalOrigin = new PointD(PyramidalOrigin.X, PyramidalOrigin.Y - movepyr);
3370 }
3371 else
3372 Origin = new PointD(Origin.X, Origin.Y - moveAmount);
3373 }
3374 if (e.Event.Key == Gdk.Key.a || e.Event.Key == Gdk.Key.A)
3375 {
3376 if (SelectedImage.isPyramidal)
3377 {
3378 PyramidalOrigin = new PointD(PyramidalOrigin.X - movepyr, PyramidalOrigin.Y);
3379 }
3380 else
3381 Origin = new PointD(Origin.X + moveAmount, Origin.Y);
3382 }
3383 if (e.Event.Key == Gdk.Key.d || e.Event.Key == Gdk.Key.D)
3384 {
3385 if (SelectedImage.isPyramidal)
3386 {
3387 PyramidalOrigin = new PointD(PyramidalOrigin.X + movepyr, PyramidalOrigin.Y);
3388 }
3389 else
3390 Origin = new PointD(Origin.X - moveAmount, Origin.Y);
3391 }
3392 foreach (Function item in Function.Functions.Values)
3393 {
3394 if (item.Key == e.Event.Key && item.Modifier == e.Event.State)
3395 {
3396 if (item.FuncType == Function.FunctionType.ImageJ)
3397 item.PerformFunction(true);
3398 else
3399 item.PerformFunction(false);
3400 }
3401 }
3402 Scripting.State st = new Scripting.State();
3403 st.key = e.Event.Key;
3404 st.p = mouseDown;
3405 st.type = Scripting.Event.Down;
3406 Scripting.UpdateState(st);
3407 UpdateView(true);
3408 }
3414 private void ImageView_KeyUpEvent(object o, KeyReleaseEventArgs e)
3415 {
3416 Plugins.KeyUpEvent(o, e);
3417 keyDown = Gdk.Key.Key_3270_Test;
3418 Scripting.State st = new Scripting.State();
3419 st.key = e.Event.Key;
3420 st.p = mouseUp;
3421 st.type = Scripting.Event.Up;
3422 Scripting.UpdateState(st);
3423 }
3431 private void ImageView_ScrollEvent(object o, ScrollEventArgs e)
3432 {
3433 Plugins.ScrollEvent(o, e);
3434 if (SelectedImage.isPyramidal)
3435 {
3436 // Anchor zoom to the mouse cursor for natural zoom-to-point behaviour.
3437 PointD anchor = MouseMove;
3438 if (e.Event.Direction == ScrollDirection.Smooth)
3439 {
3440 // Smooth / trackpad scroll — use the continuous delta for fluid zoom.
3441 double delta = e.Event.DeltaY;
3442 if (delta == 0) return;
3443 double factor = Math.Pow(2.0, delta * 0.1);
3444 SetResolution(Resolution * factor, anchor);
3445 }
3446 else if (e.Event.Direction == ScrollDirection.Up)
3447 SetResolution(Resolution * 0.8, anchor); // zoom in
3448 else if (e.Event.Direction == ScrollDirection.Down)
3449 SetResolution(Resolution * 1.25, anchor); // zoom out
3450 }
3451 else
3452 {
3453 PointD anchor = MouseMove;
3454 if (e.Event.Direction == ScrollDirection.Smooth)
3455 {
3456 double delta = e.Event.DeltaY;
3457 if (delta == 0) return;
3458 double factor = Math.Pow(2.0, delta * 0.1);
3459 ZoomStackAtPoint(anchor, factor);
3460 }
3461 else if (e.Event.Direction == ScrollDirection.Up)
3462 ZoomStackAtPoint(anchor, 1.1);
3463 else if (e.Event.Direction == ScrollDirection.Down)
3464 ZoomStackAtPoint(anchor, 0.9);
3465 }
3466 }
3467
3472 private void ValueChangedZ(object? sender, EventArgs e)
3473 {
3474 if (_updatingCoordinate)
3475 return;
3476 SetCoordinate((int)zBar.Value, (int)cBar.Value, (int)tBar.Value);
3477 }
3482 private void ValueChangedC(object? sender, EventArgs e)
3483 {
3484 if (_updatingCoordinate)
3485 return;
3486 SetCoordinate((int)zBar.Value, (int)cBar.Value, (int)tBar.Value);
3487 }
3492 private void ValueChangedT(object? sender, EventArgs e)
3493 {
3494 if (_updatingCoordinate)
3495 return;
3496 SetCoordinate((int)zBar.Value, (int)cBar.Value, (int)tBar.Value);
3497 }
3498 private void UpdateScrollBars()
3499 {
3500 if (SelectedImage.isPyramidal)
3501 {
3502 // On macOS, panning is handled entirely by the pan tool (mouse drag).
3503 // The scrollbars use current-level pixel units but PyramidalOrigin is stored in
3504 // full-resolution pixel units — the mismatch causes GTK to clamp values and fire
3505 // spurious ValueChanged callbacks that jump the view. Hide them on macOS.
3506 if (MacOS)
3507 {
3508 hScroll.Hide();
3509 vScroll.Hide();
3510 return;
3511 }
3512 hScroll.ShowAll();
3513 vScroll.ShowAll();
3514
3515 // Keep the scrollbar adjustment coherent with the viewport size.
3516 // PyramidalOrigin is stored in full-resolution pixel space, so the
3517 // adjustment range and page size must also be in full-resolution pixels.
3518 double viewportW = Math.Max(1, View?.AllocatedWidth ?? 1) * Resolution;
3519 double viewportH = Math.Max(1, View?.AllocatedHeight ?? 1) * Resolution;
3520
3521 var hAdj = hScroll.Adjustment;
3522 var vAdj = vScroll.Adjustment;
3523
3524 TryGetPyramidalViewerBounds(SelectedImage, out double imageMinX, out double imageMinY, out double imageMaxX, out double imageMaxY);
3525
3526 hAdj.Lower = imageMinX;
3527 hAdj.Upper = imageMaxX;
3528 hAdj.PageSize = Math.Min(viewportW, Math.Max(1, hAdj.Upper - hAdj.Lower));
3529
3530 vAdj.Lower = imageMinY;
3531 vAdj.Upper = imageMaxY;
3532 vAdj.PageSize = Math.Min(viewportH, Math.Max(1, vAdj.Upper - vAdj.Lower));
3533
3534 hAdj.StepIncrement = Math.Max(1, hAdj.PageSize / 20.0);
3535 hAdj.PageIncrement = Math.Max(1, hAdj.PageSize / 2.0);
3536 vAdj.StepIncrement = Math.Max(1, vAdj.PageSize / 20.0);
3537 vAdj.PageIncrement = Math.Max(1, vAdj.PageSize / 2.0);
3538 }
3539 else
3540 {
3541 vScroll.Hide();
3542 hScroll.Hide();
3543 }
3544 }
3546 public void UpdateGUI()
3547 {
3548 if (SelectedImage == null)
3549 return;
3550 if (_updatingGui)
3551 return;
3552
3553 _updatingGui = true;
3554 try
3555 {
3556 EnsureImageChannels(SelectedImage);
3557 cBar.Adjustment.Lower = 0;
3558 cBar.Adjustment.Upper = Images[selectedIndex].SizeC - 1;
3559 zBar.Adjustment.Lower = 0;
3560 zBar.Adjustment.Upper = Images[selectedIndex].SizeZ - 1;
3561 tBar.Adjustment.Lower = 0;
3562 tBar.Adjustment.Upper = Images[selectedIndex].SizeT - 1;
3563 if (endz == 0 || endz > Images[selectedIndex].SizeZ - 1)
3564 endz = Images[selectedIndex].SizeZ - 1;
3565 if (endc == 0 || endc > Images[selectedIndex].SizeC - 1)
3566 endc = Images[selectedIndex].SizeC - 1;
3567 if (endt == 0 || endt > Images[selectedIndex].SizeT - 1)
3568 endt = Images[selectedIndex].SizeT - 1;
3569 UpdateScrollBars();
3570 var store = new ListStore(typeof(string));
3571 for (int i = 0; i < SelectedImage.Channels.Count; i++)
3572 {
3573 store.AppendValues(GetChannelDisplayLabel(SelectedImage.Channels[i], i));
3574 }
3575 rBox.Model = store;
3576 gBox.Model = store;
3577 bBox.Model = store;
3578 if (SelectedImage.Channels.Count > 0)
3579 {
3580 int lastChannel = SelectedImage.Channels.Count - 1;
3581 rBox.Active = Math.Clamp(SelectedImage.rgbChannels[0], 0, lastChannel);
3582 gBox.Active = Math.Clamp(SelectedImage.rgbChannels[1], 0, lastChannel);
3583 bBox.Active = Math.Clamp(SelectedImage.rgbChannels[2], 0, lastChannel);
3584 }
3585 imagesMenu = new Menu();
3586 for (int i = 0; i < Images.Count; i++)
3587 {
3588 if (Images[i] == null)
3589 {
3590 if (SelectedIndex >= Images.Count)
3591 SelectedIndex--;
3592 Images.RemoveAt(i);
3593 }
3594 MenuItem mi = new MenuItem(Images[i].Filename);
3595 mi.ButtonPressEvent += Mi_ButtonPressEvent;
3596 imagesMenu.Append(mi);
3597 }
3598 goToImageMenu.Submenu = imagesMenu;
3599 goToImageMenu.ShowAll();
3600 }
3601 finally
3602 {
3603 _updatingGui = false;
3604 }
3605 }
3606
3607 private static string GetChannelDisplayLabel(Channel channel, int index)
3608 {
3609 string name = channel?.Name;
3610 if (string.IsNullOrWhiteSpace(name) ||
3611 (int.TryParse(name, out int numericName) && numericName == index))
3612 name = $"Channel {index + 1}";
3613 return $"{name} ({index + 1})";
3614 }
3615
3616 private void EnsureImageChannels(BioImage image)
3617 {
3618 if (image?.Channels == null || image.Channels.Count == 0)
3619 return;
3620
3621 int expectedChannels = Math.Max(1, Math.Max(image.SizeC, image.RGBChannelCount));
3622 if (image.Resolutions != null && image.Resolutions.Count > 0)
3623 {
3624 var pixelFormat = image.Resolutions[0].PixelFormat;
3625 if (pixelFormat == AForge.PixelFormat.Format24bppRgb ||
3626 pixelFormat == AForge.PixelFormat.Format48bppRgb)
3627 {
3628 expectedChannels = Math.Max(expectedChannels, 3);
3629 }
3630 }
3631
3632 if (expectedChannels > image.Channels.Count)
3633 {
3634 Channel template = image.Channels[0];
3635 while (image.Channels.Count < expectedChannels)
3636 {
3637 Channel clone = new Channel(0, image.bitsPerPixel, 1)
3638 {
3639 Name = $"Channel {image.Channels.Count + 1}",
3640 Fluor = template.Fluor,
3641 Color = template.Color
3642 };
3643 image.Channels.Add(clone);
3644 }
3645 }
3646
3647 for (int i = 0; i < image.Channels.Count; i++)
3648 {
3649 if (string.IsNullOrWhiteSpace(image.Channels[i].Name))
3650 image.Channels[i].Name = $"Channel {i + 1}";
3651 }
3652
3653 if (_initializedRgbChannelDefaults.Add(image) &&
3654 image.rgbChannels[0] == 0 &&
3655 image.rgbChannels[1] == 0 &&
3656 image.rgbChannels[2] == 0)
3657 {
3658 image.rgbChannels[0] = 0;
3659 image.rgbChannels[1] = Math.Min(1, image.Channels.Count - 1);
3660 image.rgbChannels[2] = Math.Min(2, image.Channels.Count - 1);
3661 }
3662
3663 image.rgbChannels[0] = Math.Clamp(image.rgbChannels[0], 0, image.Channels.Count - 1);
3664 image.rgbChannels[1] = Math.Clamp(image.rgbChannels[1], 0, image.Channels.Count - 1);
3665 image.rgbChannels[2] = Math.Clamp(image.rgbChannels[2], 0, image.Channels.Count - 1);
3666 }
3667
3676 private void Mi_ButtonPressEvent(object o, ButtonPressEventArgs args)
3677 {
3678 MenuItem menuItem = (MenuItem)o;
3679 int i = 0;
3680 foreach (BioImage b in Images)
3681 {
3682 if (b.Filename == menuItem.Label)
3683 {
3684 GoToImage(i);
3685 return;
3686 }
3687 i++;
3688 }
3689 }
3690
3692 public void CopySelection()
3693 {
3694 copys.Clear();
3695 string s = "";
3696 List<ROI> rois = new List<ROI>();
3697 rois.AddRange(SelectedImage.AnnotationsR);
3698 rois.AddRange(SelectedImage.AnnotationsG);
3699 rois.AddRange(SelectedImage.AnnotationsB);
3700 foreach (ROI item in rois)
3701 {
3702 if (item.Selected)
3703 {
3704 copys.Add(item);
3705 s += BioImage.ROIToString(item);
3706 }
3707 }
3708 Clipboard clipboard = Clipboard.Get(Gdk.Selection.Clipboard);
3709 clipboard.Text = s;
3710 }
3713 public void PasteSelection()
3714 {
3715 Clipboard clipboard = Clipboard.Get(Gdk.Selection.Clipboard);
3716 string text = clipboard.WaitForText();
3717 string[] sts = text.Split(BioImage.NewLine);
3718 foreach (string line in sts)
3719 {
3720 if (line.Length > 8)
3721 {
3722 ROI an = BioImage.StringToROI(line);
3723 //We set the coordinates of the ROI's we are pasting
3724 an.coord = GetCoordinate();
3725 SelectedImage.Annotations.Add(an);
3726 }
3727 }
3728 UpdateView();
3729 }
3730
3731 /* Defining an enum. */
3732 public enum ViewMode
3733 {
3734 Raw,
3735 Filtered,
3736 RGBImage,
3737 Emission,
3738 }
3739 bool openSlide = false;
3740 public bool OpenSlide
3741 {
3742 get { return openSlide; }
3743 set { openSlide = value; }
3744 }
3745 public static BioImage SelectedImage
3746 {
3747 get
3748 {
3749 if (App.viewer == null)
3750 return null;
3751 if (App.viewer.Images.Count == 0)
3752 return null;
3753 return App.viewer.Images[App.viewer.SelectedIndex];
3754 }
3755 set
3756 {
3757 if (value == null)
3758 return;
3759 if (App.viewer == null)
3760 App.viewer = ImageView.Create(value);
3761 App.viewer.Images[App.viewer.SelectedIndex] = value;
3762 }
3763 }
3764 private ViewMode viewMode = ViewMode.Filtered;
3765 /* Setting the view mode of the application. */
3766 public ViewMode Mode
3767 {
3768 get
3769 {
3770 return viewMode;
3771 }
3772 set
3773 {
3774 viewMode = value;
3775 if (viewMode == ViewMode.RGBImage)
3776 {
3777 rgbStack.VisibleChild = rgbStack.Children[1];
3778 }
3779 else
3780 if (viewMode == ViewMode.Filtered)
3781 {
3782 rgbStack.VisibleChild = rgbStack.Children[0];
3783 }
3784 else
3785 if (viewMode == ViewMode.Raw)
3786 {
3787 rgbStack.VisibleChild = rgbStack.Children[0];
3788 }
3789 else
3790 {
3791 rgbStack.VisibleChild = rgbStack.Children[0];
3792 }
3793 RefreshDisplayState(invalidatePyramidalTiles: true);
3794 }
3795 }
3796 /* A property that returns the R channel of the selected image. */
3797 public Channel RChannel
3798 {
3799 get
3800 {
3801 return SelectedImage.Channels[SelectedImage.rgbChannels[0]];
3802 }
3803 }
3804 /* A property that returns the G channel of the selected image. */
3805 public Channel GChannel
3806 {
3807 get
3808 {
3809 return SelectedImage.Channels[SelectedImage.rgbChannels[1]];
3810 }
3811 }
3812 /* A property that returns the B channel of the selected image. */
3813 public Channel BChannel
3814 {
3815 get
3816 {
3817 return SelectedImage.Channels[SelectedImage.rgbChannels[2]];
3818 }
3819 }
3820 PointD origin = new PointD(0, 0);
3821 /* Origin of the viewer in microns */
3822 public PointD Origin
3823 {
3824 get { return origin; }
3825 set
3826 {
3827 if (AllowNavigation)
3828 origin = value;
3829 if (View != null)
3830 {
3831 UpdateImages();
3832 if (!MacOS)
3833 glArea?.RequestRedraw();
3834 else
3835 View.QueueDraw();
3836 }
3837 }
3838 }
3839 /* Origin of the viewer in microns */
3840 public PointD TopRightOrigin
3841 {
3842 get
3843 {
3844 return new PointD((Origin.X - ((View.AllocatedWidth / 2) * pxWmicron)), (Origin.Y - ((View.AllocatedHeight / 2) * pxHmicron)));
3845 }
3846 }
3847 private bool _updatingScrollBars = false;
3848 public PointD PyramidalOriginTransformed
3849 {
3850 get { return new PointD(PyramidalOrigin.X * Resolution, PyramidalOrigin.Y * Resolution); }
3851 set { PyramidalOrigin = new PointD(value.X / Resolution, value.Y / Resolution); }
3852 }
3853
3854 /* Setting the origin of a pyramidal image. */
3855 public PointD PyramidalOrigin
3856 {
3857 get
3858 {
3859 return SelectedImage.PyramidalOrigin;
3860 }
3861 set
3862 {
3863 if (!AllowNavigation)
3864 return;
3865 SelectedImage.PyramidalOrigin = value;
3866 if (!MacOS)
3867 {
3868 double viewWorldW = Math.Max(1, View?.AllocatedWidth ?? 1) * Resolution;
3869 double viewWorldH = Math.Max(1, View?.AllocatedHeight ?? 1) * Resolution;
3870 double worldRangeX = Math.Max(0, hScroll.Adjustment.Upper - hScroll.Adjustment.Lower);
3871 double worldRangeY = Math.Max(0, vScroll.Adjustment.Upper - vScroll.Adjustment.Lower);
3872 bool canPanX = SelectedImage?.isPyramidal == true && worldRangeX > viewWorldW;
3873 bool canPanY = SelectedImage?.isPyramidal == true && worldRangeY > viewWorldH;
3874
3875 double maxX = Math.Max(hScroll.Adjustment.Lower, hScroll.Adjustment.Upper - hScroll.Adjustment.PageSize);
3876 double maxY = Math.Max(vScroll.Adjustment.Lower, vScroll.Adjustment.Upper - vScroll.Adjustment.PageSize);
3877 double clampedX = canPanX
3878 ? Math.Clamp(value.X, hScroll.Adjustment.Lower, maxX)
3879 : value.X;
3880 double clampedY = canPanY
3881 ? Math.Clamp(value.Y, vScroll.Adjustment.Lower, maxY)
3882 : value.Y;
3883
3884 _updatingScrollBars = true;
3885 if (canPanX && hScroll.Value != clampedX)
3886 hScroll.Value = clampedX;
3887 if (canPanY && vScroll.Value != clampedY)
3888 vScroll.Value = clampedY;
3889 _updatingScrollBars = false;
3890 }
3891 if (_suppressViewUpdates)
3892 return;
3893
3894 if (SelectedImage?.isPyramidal == true)
3895 {
3896 // Pan and zoom only change which part of the same image is
3897 // visible. In-flight tiles remain valid cache entries, so do
3898 // not cancel their HTTP requests here. Cancellation is still
3899 // used for channel, Z/T, threshold, and source changes where
3900 // an old tile could contain genuinely stale pixel data.
3901 slideRenderer?.InvalidateViewport();
3902 // The Skia renderer does not cancel network I/O when its
3903 // render state changes, so its existing generation invalidation
3904 // is the viewport-safe equivalent.
3905 sKSlideRenderer?.InvalidateRenderState();
3906 UpdateView();
3907 }
3908 else if (View != null)
3909 {
3910 UpdateImages();
3911 View.QueueDraw();
3912 }
3913 }
3914 }
3915 // Mouse wheel handler
3916 public void OnMouseWheel(object sender, ScrollEventArgs e)
3917 {
3918 double mouseX = e?.Event?.X ?? PyramidalOrigin.X;
3919 double mouseY = e?.Event?.Y ?? PyramidalOrigin.Y;
3920 if (e.Event.Direction == ScrollDirection.Up)
3921 ZoomAtPoint(mouseX, mouseY, false);
3922 else if (e.Event.Direction == ScrollDirection.Down)
3923 ZoomAtPoint(mouseX, mouseY, true);
3924 }
3925 public void ZoomAtPoint(double mouseX, double mouseY, bool zoomIn)
3926 {
3927 if (SelectedImage == null || !SelectedImage.isPyramidal) return;
3928
3929 int currentLevel = Level;
3930 int nextLevel = zoomIn
3931 ? Math.Max(0, currentLevel - 1)
3932 : Math.Min(SelectedImage.Resolutions.Count - 1, currentLevel + 1);
3933
3934 if (nextLevel == currentLevel)
3935 return;
3936
3937 double currentResolution = SelectedImage.GetUnitPerPixel(currentLevel);
3938 double nextResolution = SelectedImage.GetUnitPerPixel(nextLevel);
3939 if (currentResolution <= 0 || nextResolution <= 0)
3940 return;
3941
3942 // Batch the level/origin change so zoom only triggers one viewport
3943 // update instead of two separate renders.
3944 SetResolution(nextResolution, new PointD(mouseX, mouseY));
3945 BioLib.Recorder.Record(BioLib.Recorder.GetCurrentMethodInfo(), false, mouseX, mouseY, zoomIn);
3946 }
3947 public double Resolution
3948 {
3949 get
3950 {
3951 if (!SelectedImage.isPyramidal)
3952 return 1;
3953 return SelectedImage.Resolution;
3954 }
3955 set
3956 {
3957 if (SelectedImage.isPyramidal)
3958 SelectedImage.Resolution = value;
3959 if (!_suppressViewUpdates)
3960 {
3961 if (SelectedImage?.isPyramidal == true)
3962 UpdateView();
3963 else if (View != null)
3964 {
3965 try
3966 {
3967 UpdateImages();
3968 }
3969 catch (Exception)
3970 {
3971
3972 }
3973 if (!MacOS)
3974 glArea?.RequestRedraw();
3975 else
3976 View.QueueDraw();
3977 }
3978 }
3979 }
3980 }
3981
3982 private void UpdateLevel()
3983 {
3984 if (!SelectedImage.isPyramidal) return;
3985 if (l != ImageView.SelectedImage.Level)
3986 {
3987 if (openSlide)
3988 for (int j = 0; j < ImageView.SelectedImage.OpenSlideBase.stitch.gpuTiles.Count; j++)
3989 {
3990 var tile = ImageView.SelectedImage.OpenSlideBase.stitch.gpuTiles[j];
3991 if (tile.Index.Level != Level)
3992 tile.Dispose();
3993 }
3994 else if (ImageView.SelectedImage.SlideBase?.stitch != null)
3995 for (int j = 0; j < ImageView.SelectedImage.SlideBase.stitch.gpuTiles.Count; j++)
3996 {
3997 var tile = ImageView.SelectedImage.SlideBase.stitch.gpuTiles[j];
3998 if (tile.Index.Level != Level)
3999 tile.Dispose();
4000 }
4001 l = ImageView.SelectedImage.Level;
4002 }
4003 }
4004
4005 private int l;
4006 public int Level
4007 {
4008 get
4009 {
4010 return SelectedImage.LevelFromResolution(SelectedImage.Resolution);
4011 }
4012 set
4013 {
4014 if (value < 0)
4015 return;
4016 if (SelectedImage?.Type == BioImage.ImageType.well)
4017 {
4018 if (l != value)
4019 l = value;
4020 SelectedImage.Level = value;
4021 UpdateScrollBars();
4022 if (View != null)
4023 {
4024 UpdateImages();
4025 View.QueueDraw();
4026 }
4027 return;
4028 }
4029 if (l != value)
4030 UpdateLevel();
4031 l = value;
4032 SelectedImage.Level = l;
4033 UpdateScrollBars();
4034 }
4035 }
4036
4037 SizeF scale = new SizeF(1, 1);
4038 /* A property that is used to set the scale of the view. */
4039 public SizeF Scale
4040 {
4041 get
4042 {
4043 return scale;
4044 }
4045 set
4046 {
4047 scale = value;
4048 }
4049 }
4051 public void UpdateStatus()
4052 {
4053 if (SelectedImage.Buffers.Count == 0 &&
4054 SelectedImage.Type != BioImage.ImageType.well &&
4055 !SelectedImage.IsZarrSource)
4056 return;
4057 string formattedResolution = FormatResolution(Resolution);
4058 if (SelectedImage.Type == BioImage.ImageType.stack)
4059 statusLabel.Text = (zBar.Value + 1) + "/" + (zBar.Adjustment.Upper + 1) + ", " + (cBar.Value + 1) + "/" + (cBar.Adjustment.Upper + 1) + ", " + (tBar.Value + 1) + "/" + (tBar.Adjustment.Upper + 1) + ", " +
4060 mousePoint + mouseColor + ", " + SelectedImage.Resolutions[0].PixelFormat.ToString() + ", (" + SelectedImage.Volume.Location.X.ToString("N2") + ", " + SelectedImage.Volume.Location.Y.ToString("N2") + ") "
4061 + Origin.X.ToString("N2") + "," + Origin.Y.ToString("N2") + ", Res:" + formattedResolution + " Level:" + Level;
4062 else if (SelectedImage.Type == BioImage.ImageType.pyramidal ||
4063 SelectedImage.Type == BioImage.ImageType.well)
4064 {
4065 string wellStatus = SelectedImage.Type == BioImage.ImageType.well
4066 ? ", WellIndex:" + SelectedImage.WellIndex
4067 : string.Empty;
4068 statusLabel.Text = (zBar.Value + 1) + "/" + (zBar.Adjustment.Upper + 1) + ", " + (cBar.Value + 1) + "/" + (cBar.Adjustment.Upper + 1) + ", " + (tBar.Value + 1) + "/" + (tBar.Adjustment.Upper + 1) + ", " +
4069 mousePoint + mouseColor + ", " + SelectedImage.Resolutions[0].PixelFormat.ToString() + ", (" + SelectedImage.Volume.Location.X.ToString("N2") + ", " + SelectedImage.Volume.Location.Y.ToString("N2") + ") "
4070 + PyramidalOrigin.X.ToString("N2") + "," + PyramidalOrigin.Y.ToString("N2") + " , Level:" + Level + " Res:" + formattedResolution + wellStatus;
4071 }
4072 }
4073
4074 private static string FormatResolution(double resolution)
4075 {
4076 double magnitude = Math.Abs(resolution);
4077 if (magnitude >= 1000) return resolution.ToString("F0");
4078 if (magnitude >= 100) return resolution.ToString("F1");
4079 if (magnitude >= 10) return resolution.ToString("F2");
4080 if (magnitude >= 1) return resolution.ToString("F3");
4081 if (magnitude >= 0.1) return resolution.ToString("F4");
4082 if (magnitude >= 0.01) return resolution.ToString("F5");
4083 return resolution.ToString("G4");
4084 }
4086 public void UpdateView(bool updateImages = true)
4087 {
4088 refresh = true;
4089 if (SelectedImage?.isPyramidal == true)
4090 {
4091 RequestDeferredRender();
4092 }
4093 else
4094 {
4095 UpdateImages(true);
4096 RequestViewportRefresh();
4097 }
4098 BioLib.Recorder.Record(BioLib.Recorder.GetCurrentMethodInfo(), false, updateImages);
4099 }
4100 private string mousePoint = "";
4101 private string mouseColor = "";
4102 private int _cursorPixelSampleRequestId;
4103
4104 private void UpdateCursorPixelSample(PointD imagePoint)
4105 {
4106 var image = SelectedImage;
4107 if (image == null)
4108 return;
4109
4110 int pixelX = (int)Math.Floor(imagePoint.X);
4111 int pixelY = (int)Math.Floor(imagePoint.Y);
4112 if (pixelX < 0 || pixelY < 0 || pixelX >= image.SizeX || pixelY >= image.SizeY)
4113 {
4114 Interlocked.Increment(ref _cursorPixelSampleRequestId);
4115 mouseColor = string.Empty;
4116 return;
4117 }
4118
4119 if (image.IsZarrSource)
4120 {
4121 QueueCursorPixelSample(imagePoint);
4122 return;
4123 }
4124
4125 try
4126 {
4127 ZCT coordinate = image.Coordinate;
4128 if (image.isRGB)
4129 {
4130 double red = image.GetValueRGB(coordinate.Z, RChannel.Index, coordinate.T,
4131 pixelX, pixelY, 0);
4132 double green = image.GetValueRGB(coordinate.Z, GChannel.Index, coordinate.T,
4133 pixelX, pixelY, 1);
4134 double blue = image.GetValueRGB(coordinate.Z, BChannel.Index, coordinate.T,
4135 pixelX, pixelY, 2);
4136 mouseColor = $", Val: ({FormatPixelValue(red)}, {FormatPixelValue(green)}, {FormatPixelValue(blue)})";
4137 }
4138 else
4139 {
4140 double value = image.GetValueRGB(coordinate.Z, coordinate.C, coordinate.T,
4141 pixelX, pixelY, 0);
4142 mouseColor = $", Val: {FormatPixelValue(value)}";
4143 }
4144 }
4145 catch (Exception)
4146 {
4147 mouseColor = string.Empty;
4148 }
4149 }
4150
4151 private static string FormatPixelValue(double value)
4152 {
4153 return Math.Abs(value - Math.Round(value)) < 0.000001
4154 ? value.ToString("F0")
4155 : value.ToString("G6");
4156 }
4157
4158 private void QueueCursorPixelSample(PointD imagePoint)
4159 {
4160 var image = SelectedImage;
4161 if (image?.IsZarrSource != true || slideRenderer == null)
4162 return;
4163
4164 int pixelX = (int)Math.Floor(imagePoint.X);
4165 int pixelY = (int)Math.Floor(imagePoint.Y);
4166 int requestId = Interlocked.Increment(ref _cursorPixelSampleRequestId);
4167 if (pixelX < 0 || pixelY < 0 || pixelX >= image.SizeX || pixelY >= image.SizeY)
4168 {
4169 mouseColor = string.Empty;
4170 return;
4171 }
4172
4173 ZCT coordinate = image.Coordinate;
4174 _ = UpdateCursorPixelValueAsync(image, coordinate, pixelX, pixelY, requestId);
4175 }
4176
4177 private async Task UpdateCursorPixelValueAsync(
4178 BioImage image,
4179 ZCT coordinate,
4180 int pixelX,
4181 int pixelY,
4182 int requestId)
4183 {
4184 try
4185 {
4186 // Avoid a remote request for every mouse event. Only the last
4187 // position after a short pause proceeds to the Zarr reader.
4188 await Task.Delay(60).ConfigureAwait(false);
4189 if (requestId != _cursorPixelSampleRequestId || !ReferenceEquals(image, SelectedImage))
4190 return;
4191
4192 double? value = await slideRenderer.SampleZarrPixelAsync(
4193 image, coordinate, pixelX, pixelY).ConfigureAwait(false);
4194 if (!value.HasValue || requestId != _cursorPixelSampleRequestId ||
4195 !ReferenceEquals(image, SelectedImage))
4196 return;
4197
4198 Gtk.Application.Invoke((s, e) =>
4199 {
4200 if (requestId != _cursorPixelSampleRequestId || !ReferenceEquals(image, SelectedImage))
4201 return;
4202 mouseColor = $", Val: {FormatPixelValue(value.Value)}";
4203 UpdateStatus();
4204 });
4205 }
4206 catch (Exception ex)
4207 {
4208 AppLog.Append($"Cursor Zarr sample failed: {ex.Message}");
4209 }
4210 }
4211
4212 public bool showRROIs = true;
4213 public bool showGROIs = true;
4214 public bool showBROIs = true;
4215 public bool ShowROIs { get; private set; } = true;
4216
4217 private void UpdateRoiMenuState()
4218 {
4219 if (roiShow != null)
4220 roiShow.Sensitive = !ShowROIs;
4221 if (roiHide != null)
4222 roiHide.Sensitive = ShowROIs;
4223 }
4224
4225 public double GetScale()
4226 {
4227 return ToViewSizeW(ROI.selectBoxSize / Scale.Width);
4228 }
4229 public static bool x1State;
4230 public static bool x2State;
4231 public static bool mouseLeftState;
4232 public static ModifierType Modifiers;
4233 PointD mouseD = new PointD(0, 0);
4234 PointD pd;
4235 PointD mouseDownInt = new PointD(0, 0);
4236 PointD mouseMoveInt = new PointD(0, 0);
4237 PointD mouseUpInt = new PointD(0, 0);
4238 PointD mouseUp = new PointD(0, 0);
4239 PointD mouseDown = new PointD(0, 0);
4240 PointD mouseMove = new PointD(0, 0);
4241 public ROI selectedROI = new ROI();
4242 /* A property that returns the value of the mouseDownInt variable. */
4243 public PointD MouseDownInt
4244 {
4245 get { return mouseDownInt; }
4246 set { mouseDownInt = value; }
4247 }
4248 public PointD MouseMoveInt
4249 {
4250 get { return mouseMoveInt; }
4251 set { mouseMoveInt = value; }
4252 }
4253 public PointD MouseUpInt
4254 {
4255 get { return mouseUpInt; }
4256 set { mouseUpInt = value; }
4257 }
4258 public PointD MouseDown
4259 {
4260 get { return mouseDown; }
4261 set { mouseDown = value; }
4262 }
4263 public PointD MouseUp
4264 {
4265 get { return mouseUp; }
4266 set { mouseUp = value; }
4267 }
4268 public PointD MouseMove
4269 {
4270 get { return mouseMove; }
4271 set { mouseMove = value; }
4272 }
4273
4274 List<ROI> copys = new List<ROI>();
4275 public List<ROI> GetSelectedROIs()
4276 {
4277 List<ROI> roi = new List<ROI>();
4278 List<ROI> rois = new List<ROI>();
4279 rois.AddRange(SelectedImage.AnnotationsR);
4280 rois.AddRange(SelectedImage.AnnotationsG);
4281 rois.AddRange(SelectedImage.AnnotationsB);
4282 foreach (ROI r in rois)
4283 {
4284 if (r.Selected)
4285 {
4286 roi.Add(r);
4287 }
4288 }
4289 return roi;
4290 }
4291 public void ZoomAtPoint(float mouseX, float mouseY, bool zoomIn)
4292 {
4293 if (SelectedImage == null) return;
4294
4295 // 1. Define zoom factor (e.g., 2.0x zoom steps)
4296 float zoomFactor = zoomIn ? 2.0f : 0.5f;
4297
4298 // 2. Get the current level's scale relative to Full Res (Level 0)
4299 // Formula: CurrentLevelWidth / FullResWidth
4300 double currentLevelScale = (double)SelectedImage.Resolutions[SelectedImage.Level].SizeX / SelectedImage.SizeX;
4301
4302 // 3. Find the "World Point" (Full Res Level 0) currently under the mouse
4303 // We add the origin to the mouse offset and then scale up to Level 0
4304 double worldX = (PyramidalOrigin.X + mouseX) / currentLevelScale;
4305 double worldY = (PyramidalOrigin.Y + mouseY) / currentLevelScale;
4306
4307 // 4. Update your Level logic here
4308 // This is where you switch your 'CurrentLevelWidth' to the next level in the pyramid
4309 //UpdateLevel(zoomIn);
4310
4311 // 5. Get the NEW level's scale relative to Full Res
4312 double newLevelScale = (double)SelectedImage.Resolutions[SelectedImage.Level].SizeX / SelectedImage.SizeX;
4313
4314 // 6. Calculate the NEW PyramidalOrigin
4315 // To keep the world point at the same mouse position:
4316 // NewOrigin = (WorldPoint * NewScale) - MouseOffset
4317 float newOriginX = (float)(worldX * newLevelScale - mouseX);
4318 float newOriginY = (float)(worldY * newLevelScale - mouseY);
4319
4320 // 7. Apply and Clamp (to ensure we don't scroll past image edges)
4321 TryGetPyramidalViewerBounds(SelectedImage, out double imageMinX, out double imageMinY, out double imageMaxX, out double imageMaxY);
4322 double viewWorldW = Math.Max(1, View?.AllocatedWidth ?? 1) * Resolution;
4323 double viewWorldH = Math.Max(1, View?.AllocatedHeight ?? 1) * Resolution;
4324 PyramidalOrigin = new PointD(
4325 ClampPyramidalOrigin(newOriginX, imageMinX, imageMaxX, viewWorldW),
4326 ClampPyramidalOrigin(newOriginY, imageMinY, imageMaxY, viewWorldH)
4327 );
4328 BioLib.Recorder.Record(BioLib.Recorder.GetCurrentMethodInfo(), false, mouseX, mouseY, zoomIn);
4329 }
4330
4331 private void ZoomStackAtPoint(PointD mouseScreenPos, double factor)
4332 {
4333 if (SelectedImage == null || SelectedImage.isPyramidal)
4334 return;
4335 if (factor <= 0 || double.IsNaN(factor) || double.IsInfinity(factor))
4336 return;
4337
4338 double oldScaleX = Math.Max(double.Epsilon, Scale.Width);
4339 double oldScaleY = Math.Max(double.Epsilon, Scale.Height);
4340 // Keep zoom-out effectively unbounded while avoiding zero/negative
4341 // scale values that would break coordinate conversion.
4342 double newScaleX = Math.Clamp(oldScaleX * factor, 1e-6, 4096.0);
4343 double newScaleY = Math.Clamp(oldScaleY * factor, 1e-6, 4096.0);
4344
4345 double viewCenterX = View?.AllocatedWidth > 0 ? View.AllocatedWidth / 2.0 : 0;
4346 double viewCenterY = View?.AllocatedHeight > 0 ? View.AllocatedHeight / 2.0 : 0;
4347
4348 double worldX = ((mouseScreenPos.X - viewCenterX) / (PxWmicron * oldScaleX)) - Origin.X;
4349 double worldY = ((mouseScreenPos.Y - viewCenterY) / (PxHmicron * oldScaleY)) - Origin.Y;
4350
4351 Scale = new SizeF((float)newScaleX, (float)newScaleY);
4352
4353 double newOriginX = ((mouseScreenPos.X - viewCenterX) / (PxWmicron * newScaleX)) - worldX;
4354 double newOriginY = ((mouseScreenPos.Y - viewCenterY) / (PxHmicron * newScaleY)) - worldY;
4355
4356 Origin = new PointD(newOriginX, newOriginY);
4357 UpdateView(true);
4358 BioLib.Recorder.Record(BioLib.Recorder.GetCurrentMethodInfo(), false, mouseScreenPos.X, mouseScreenPos.Y, factor);
4359 }
4360
4364 private void ImageView_MotionNotifyEvent(object o, MotionNotifyEventArgs e)
4365 {
4366 App.viewer = this;
4367 Modifiers = e.Event.State;
4368
4369 // Capture previous screen position BEFORE updating MouseMoveInt.
4370 PointD prevScreenPos = MouseMoveInt;
4371
4372 MouseMove = new PointD(e.Event.X, e.Event.Y);
4373 MouseMoveInt = new PointD((int)e.Event.X, (int)e.Event.Y);
4374
4375 // Convert to view space (world coordinates)
4376 PointD p = ImageToViewSpace(e.Event.X, e.Event.Y);
4377 mousePoint = "(" + (p.X.ToString("F")) + ", " + (p.Y.ToString("F")) + ")";
4378 if (SelectedImage == null)
4379 return;
4380 mouseD = SelectedImage.ToImageSpace(p);
4381 UpdateCursorPixelSample(mouseD);
4382 if (e.Event.State.HasFlag(ModifierType.Button2Mask) &&
4383 Tools.currentTool.type != Tools.Tool.Type.pan)
4384 {
4385 Tools.currentTool = Tools.GetTool("pan");
4386 }
4387 // Handle pyramidal overview navigation only when the drag is inside
4388 // the overview widget. Otherwise the active tool needs the event.
4389 if (e.Event.State.HasFlag(ModifierType.Button1Mask) &&
4390 TryNavigateFromOverview(e.Event.X, e.Event.Y))
4391 {
4392 UpdateStatus();
4393 pd = p;
4394 return;
4395 }
4396 if(App.tools!=null)
4397 App.tools.ToolMove(p, e);
4398 UpdateStatus();
4399 pd = p;
4400 }
4401
4402 private bool TryNavigateFromOverview(double eventX, double eventY)
4403 {
4404 if (SelectedImage?.isPyramidal != true || !ShowOverview || overview.Width <= 0 || overview.Height <= 0)
4405 return false;
4406
4407 double overviewScale = Math.Max(1, View?.ScaleFactor ?? 1);
4408 double overviewX = eventX * overviewScale;
4409 double overviewY = eventY * overviewScale;
4410 if (!overview.IntersectsWith(overviewX, overviewY))
4411 return false;
4412
4413 TryGetPyramidalViewerBounds(SelectedImage, out double imageMinX, out double imageMinY, out double imageMaxX, out double imageMaxY);
4414 double fullResWidth = Math.Max(1, imageMaxX - imageMinX);
4415 double fullResHeight = Math.Max(1, imageMaxY - imageMinY);
4416 double viewWorldW = View.AllocatedWidth * Resolution;
4417 double viewWorldH = View.AllocatedHeight * Resolution;
4418
4419 double fullResX = imageMinX + (((overviewX - overview.X) / overview.Width) * fullResWidth);
4420 double fullResY = imageMinY + (((overviewY - overview.Y) / overview.Height) * fullResHeight);
4421
4422 double originX = fullResX - (viewWorldW / 2.0);
4423 double originY = fullResY - (viewWorldH / 2.0);
4424
4425 PyramidalOrigin = new PointD(
4426 ClampPyramidalOrigin(originX, imageMinX, imageMaxX, viewWorldW),
4427 ClampPyramidalOrigin(originY, imageMinY, imageMaxY, viewWorldH));
4428 return true;
4429 }
4430
4434 private void ImageView_ButtonReleaseEvent(object o, ButtonReleaseEventArgs e)
4435 {
4436 Modifiers = e.Event.State;
4437 MouseUpInt = new PointD((int)e.Event.X, (int)e.Event.Y);
4438
4439 if (Tools.currentTool.type == Tools.Tool.Type.pan && e.Event.Button == 2)
4440 Tools.currentTool = Tools.GetTool("move");
4441 if (e.Event.Button == 1)
4442 mouseLeftState = false;
4443 if (SelectedImage == null)
4444 return;
4445 App.viewer = this;
4446 PointD pointer = ImageToViewSpace(e.Event.X, e.Event.Y);
4447 mouseUp = pointer;
4448
4449 if (SelectedImage == null)
4450 return;
4451 // Delegate all tool logic to Tools.cs
4452 App.tools.ToolUp(pointer, e);
4453 }
4454
4458 private void ImageView_ButtonPressEvent(object o, ButtonPressEventArgs e)
4459 {
4460 Modifiers = e.Event.State;
4461
4462 if (e.Event.Button == 1)
4463 mouseLeftState = true;
4464 else
4465 mouseLeftState = false;
4466 if (SelectedImage == null)
4467 return;
4468 App.viewer = this;
4469 PointD pointer = ImageToViewSpace(e.Event.X, e.Event.Y);
4470 MouseDownInt = new PointD(e.Event.X, e.Event.Y);
4471 pd = pointer;
4472 mouseDown = pd;
4473 if (e.Event.Button == 2)
4474 Tools.currentTool = Tools.GetTool("pan");
4475 if (SelectedImage == null)
4476 return;
4477
4478 mouseD = SelectedImage.ToImageSpace(pd);
4479
4480 if (e.Event.Button == 1 && TryNavigateFromOverview(e.Event.X, e.Event.Y))
4481 return;
4482
4483 if (e.Event.Button == 1)
4484 TrySelectVisibleMaskAt(pointer.X, pointer.Y, e.Event.State.HasFlag(ModifierType.ControlMask));
4485
4486 // Handle well plate level navigation
4487 if (ImageView.SelectedImage.Type == BioImage.ImageType.well)
4488 {
4489 if (e.Event.Button == 4)
4490 {
4491 Level = ImageView.SelectedImage.Level - 1;
4492 }
4493 else if (e.Event.Button == 5)
4494 {
4495 Level = ImageView.SelectedImage.Level + 1;
4496 }
4497 }
4498
4499 // Context menu
4500 if (e.Event.Button == 3)
4501 {
4502 contextMenu.Popup();
4503 return; // Don't process tool events for right-click
4504 }
4505
4506 // Mouse wheel scrolling for channel/time navigation
4507 if (e.Event.Button == 4 && Mode != ViewMode.RGBImage)
4508 {
4509 if (cBar.Value < cBar.Adjustment.Upper)
4510 cBar.Value++;
4511 x1State = true;
4512 }
4513 else if (e.Event.Button == 4 && e.Event.State == ModifierType.ControlMask)
4514 {
4515 if (tBar.Value < tBar.Adjustment.Upper)
4516 tBar.Value++;
4517 x1State = true;
4518 }
4519
4520 if (e.Event.State != ModifierType.Button4Mask)
4521 x1State = false;
4522
4523 if (e.Event.Button == 5 && Mode != ViewMode.RGBImage)
4524 {
4525 if (cBar.Value > cBar.Adjustment.Lower)
4526 cBar.Value--;
4527 x2State = true;
4528 }
4529 else if (e.Event.Button == 5 && Modifiers == ModifierType.ControlMask && Mode != ViewMode.RGBImage)
4530 {
4531 if (tBar.Value > cBar.Adjustment.Lower)
4532 tBar.Value--;
4533 x2State = true;
4534 }
4535
4536 if (e.Event.State != ModifierType.Button5Mask)
4537 x2State = false;
4538
4539 // Select which image is clicked (for multi-image views)
4540 int ind = 0;
4541 foreach (BioImage b in Images)
4542 {
4543 RectangleD r = new RectangleD(b.Volume.Location.X, b.Volume.Location.Y,
4544 b.Volume.Width, b.Volume.Height);
4545 if (r.IntersectsWith(pointer))
4546 {
4547 selectedIndex = ind;
4548 UpdateGUI();
4549 break;
4550 }
4551 ind++;
4552 }
4553
4554 // Refresh the status value immediately on a click as well as on hover.
4555 if (e.Event.Button == 1)
4556 UpdateCursorPixelSample(mouseD);
4557 UpdateStatus();
4558
4559 App.tools.ToolDown(pointer, e);
4560 // On macOS pyramidal, a click alone doesn't change origin/resolution so there
4561 // is nothing to re-render. Avoid launching a spurious async pipeline invocation.
4562 if (!(MacOS && SelectedImage?.isPyramidal == true))
4563 UpdateView();
4564 }
4565 protected override void OnDestroyed()
4566 {
4567 base.OnDestroyed();
4568 }
4569
4570
4571 #region Conversion
4572 private int Width
4573 {
4574 get
4575 {
4576 return View.AllocatedWidth;
4577 }
4578 }
4579 private int Height
4580 {
4581 get
4582 {
4583 return View.AllocatedHeight;
4584 }
4585 }
4586
4587 public static ROI SelectedAnnotation
4588 {
4589 get
4590 {
4591 foreach (var item in SelectedImage.Annotations)
4592 {
4593 if (item.Selected)
4594 return item;
4595 }
4596 return null;
4597 }
4598 }
4599
4600 public double ImageViewWidth
4601 {
4602 get
4603 {
4604 double d = View.AllocatedWidth / Resolution;
4605 return d;
4606 }
4607 }
4608 public double ImageViewHeight
4609 {
4610 get
4611 {
4612 double d = View.AllocatedHeight / Resolution;
4613 return d;
4614 }
4615 }
4622 public PointD ImageToViewSpace(double x, double y)
4623 {
4624 if (SelectedImage == null)
4625 return ToViewSpace(x, y);
4626 if (SelectedImage.isPyramidal)
4627 {
4628 // PyramidalOrigin is already in world coordinates. Convert the
4629 // screen-space mouse offset back into the same world space that
4630 // ToViewSpace() consumes.
4631 return new PointD(PyramidalOrigin.X + (x * Resolution),
4632 PyramidalOrigin.Y + (y * Resolution));
4633 }
4634 else
4635 return ToViewSpace(x, y);
4636 }
4642 public PointF ToViewSpace(PointF p)
4643 {
4644 PointD d = ToViewSpace(p.X, p.Y);
4645 return new PointF((float)d.X, (float)d.Y);
4646 }
4652 public PointD ToViewSpace(PointD p)
4653 {
4654 return ToViewSpace(p.X, p.Y); ;
4655 }
4656 public Widget View
4657 {
4658 get
4659 {
4660 if (currentView != null)
4661 return currentView;
4662 if (MacOS)
4663 return sk;
4664 return glArea;
4665 }
4666 set
4667 {
4668 currentView = value;
4669 if (value is SKDrawingArea drawingArea)
4670 sk = drawingArea;
4671 if (value is SlideGLArea slideGlArea)
4672 glArea = slideGlArea;
4673 }
4674 }
4675 public PointD ToViewSpace(double x, double y)
4676 {
4677 if (SelectedImage == null)
4678 {
4679 double dx = (ToViewSizeW(x - (View.AllocatedWidth / 2)) / Scale.Width) - Origin.X;
4680 double dy = (ToViewSizeH(y - (View.AllocatedHeight / 2)) / Scale.Height) - Origin.Y;
4681 return new PointD(dx, dy);
4682 }
4683 if (SelectedImage.isPyramidal)
4684 {
4685 // x, y are world (full-resolution pixel) coordinates.
4686 // Subtract the viewport origin and divide by Resolution to get screen pixels.
4687 return new PointD((x - PyramidalOrigin.X) / Resolution,
4688 (y - PyramidalOrigin.Y) / Resolution);
4689 }
4690 else
4691 {
4692 double dx = (ToViewSizeW(x - (View.AllocatedWidth / 2)) / Scale.Width) - Origin.X;
4693 double dy = (ToViewSizeH(y - (View.AllocatedHeight / 2)) / Scale.Height) - Origin.Y;
4694 return new PointD(dx, dy);
4695 }
4696 }
4697
4703 private double ToViewSizeW(double d)
4704 {
4705 if (SelectedImage != null)
4706 if (SelectedImage.isPyramidal)
4707 {
4708 return d / Resolution;
4709 }
4710 double x = (double)(d / PxWmicron);
4711 return x;
4712 }
4718 public double ToViewSizeH(double d)
4719 {
4720 if (SelectedImage != null)
4721 if (SelectedImage.isPyramidal)
4722 {
4723 return d / Resolution;
4724 }
4725 double y = (double)(d / PxHmicron);
4726 return y;
4727 }
4733 public double ToViewW(double d)
4734 {
4735 if (SelectedImage != null)
4736 if (SelectedImage.isPyramidal)
4737 {
4738 return d / Resolution;
4739 }
4740 double x = (double)(d / PxWmicron) / scale.Width;
4741 return x;
4742 }
4748 public double ToViewH(double d)
4749 {
4750 if (SelectedImage != null)
4751 if (SelectedImage.isPyramidal)
4752 {
4753 return d / Resolution;
4754 }
4755 double y = (double)(d / PxHmicron) / scale.Height;
4756 return y;
4757 }
4764 public PointD ToScreenSpace(double x, double y)
4765 {
4766 RectangleD f = ToScreenRect(x, y, 1, 1);
4767 return new PointD(f.X, f.Y);
4768 }
4774 public PointD ToScreenSpace(PointD p)
4775 {
4776 return ToScreenSpace(p.X, p.Y);
4777 }
4783 public PointF ToScreenSpace(PointF p)
4784 {
4785 PointD pd = ToScreenSpace(p.X, p.Y);
4786 return new PointF((float)pd.X, (float)pd.Y);
4787 }
4793 public PointF[] ToScreenSpace(PointF[] p)
4794 {
4795 PointF[] pf = new PointF[p.Length];
4796 for (int i = 0; i < p.Length; i++)
4797 {
4798 pf[i] = ToScreenSpace(p[i]);
4799 }
4800 return pf;
4801 }
4807 public PointF ToScreenSpace(Point3D p)
4808 {
4809 PointD pd = ToScreenSpace(p.X, p.Y);
4810 return new PointF((float)pd.X, (float)pd.Y);
4811 }
4817 public float ToScreenScaleW(double x)
4818 {
4819 return (float)(-x * PxWmicron * Scale.Width);
4820 }
4826 public float ToScreenScaleH(double y)
4827 {
4828 return (float)(-y * PxHmicron * Scale.Height);
4829 }
4835 public PointF ToScreenScale(PointD p)
4836 {
4837 float x = ToScreenScaleW((float)p.X);
4838 float y = ToScreenScaleH((float)p.Y);
4839 return new PointF(x, y);
4840 }
4849 public RectangleD ToScreenRect(double x, double y, double w, double h)
4850 {
4851 if (SelectedImage == null)
4852 {
4853 double dx = (pxWmicron * (-Origin.X)) * Scale.Width;
4854 double dy = (pxHmicron * (-Origin.Y)) * Scale.Height;
4855 RectangleD rf = new RectangleD((PxWmicron * x * Scale.Width + dx), (PxHmicron * y * Scale.Height + dy), (PxWmicron * w * Scale.Width), (PxHmicron * h * Scale.Height));
4856 return rf;
4857 }
4858 if (SelectedImage.isPyramidal)
4859 {
4860 PointD d = ToViewSpace(x, y);
4861 double dw = ToViewSizeW(w);
4862 double dh = ToViewSizeH(h);
4863 return new RectangleD(d.X, d.Y, dw, dh);
4864 }
4865 else
4866 {
4867 double dx = (pxWmicron * (Origin.X)) * Scale.Width;
4868 double dy = (pxHmicron * (Origin.Y)) * Scale.Height;
4869 RectangleD rf = new RectangleD((PxWmicron * x * Scale.Width + dx), (PxHmicron * y * Scale.Height + dy), (PxWmicron * w * Scale.Width), (PxHmicron * h * Scale.Height));
4870 return rf;
4871 }
4872 }
4873
4879 public RectangleD ToScreenSpace(RectangleD p)
4880 {
4881 return ToScreenRect(p.X, p.Y, p.W, p.H);
4882 }
4888 public RectangleD ToScreenSpace(RectangleF p)
4889 {
4890 return ToScreenRect(p.X, p.Y, p.Width, p.Height);
4891 }
4897 public RectangleD[] ToScreenSpace(RectangleD[] p)
4898 {
4899 RectangleD[] rs = new RectangleD[p.Length];
4900 for (int i = 0; i < p.Length; i++)
4901 {
4902 rs[i] = ToScreenSpace(p[i]);
4903 }
4904 return rs;
4905 }
4906
4912 public PointD[] ToScreenSpace(PointD[] p)
4913 {
4914 PointD[] rs = new PointD[p.Length];
4915 for (int i = 0; i < p.Length; i++)
4916 {
4917 PointD pd = ToScreenSpace(p[i]);
4918 rs[i] = new PointD((float)pd.X, (float)pd.Y);
4919 }
4920 return rs;
4921 }
4927 public double ToScreenW(double x)
4928 {
4929 return (float)(x * PxWmicron);
4930 }
4936 public float ToScreenH(double y)
4937 {
4938 return (float)(y * PxHmicron);
4939 }
4941 public void GoToImage()
4942 {
4943 GoToImage(SelectedIndex);
4944 }
4945
4946 private static bool IsZarrSource(BioImage image)
4947 {
4948 if (image == null)
4949 return false;
4950
4951 string source = image.file;
4952 if (string.IsNullOrWhiteSpace(source))
4953 source = image.Filename;
4954
4955 return source.Contains(".zarr", StringComparison.OrdinalIgnoreCase) ||
4956 source.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ||
4957 source.StartsWith("s3://", StringComparison.OrdinalIgnoreCase);
4958 }
4959
4960 private bool TryGetPyramidalViewerBounds(BioImage image, out double minX, out double minY, out double maxX, out double maxY)
4961 {
4962 minX = 0;
4963 minY = 0;
4964 maxX = 1;
4965 maxY = 1;
4966
4967 if (image == null)
4968 return false;
4969
4970 minX = image.Volume.Location.X;
4971 minY = image.Volume.Location.Y;
4972 maxX = minX + image.Volume.Width;
4973 maxY = minY + image.Volume.Height;
4974
4975 ITileSchema schema = null;
4976 if (image == SelectedImage)
4977 {
4978 schema = openSlide
4979 ? _openSlideBase?.Schema
4980 : _slideBase?.Schema;
4981 }
4982
4983 schema ??= image.OpenSlideBase?.Schema;
4984 schema ??= image.SlideBase?.Schema;
4985
4986 if (schema?.Extent != null)
4987 {
4988 minX = schema.Extent.MinX;
4989 maxX = schema.Extent.MaxX;
4990 minY = -schema.Extent.MaxY;
4991 maxY = -schema.Extent.MinY;
4992 }
4993
4994 if (maxX <= minX)
4995 maxX = minX + 1;
4996 if (maxY <= minY)
4997 maxY = minY + 1;
4998
4999 return true;
5000 }
5001
5002 private static void NormalizeOsmSchemaOrigin(SlideBase slideBase, BioImage image = null)
5003 {
5004 if (slideBase?.Schema is not TileSchema schema)
5005 return;
5006 if (schema.YAxis != YAxis.OSM)
5007 return;
5008
5009 BioImage bioImage = image ?? slideBase.Image?.BioImage;
5010 if (bioImage?.Type != BioImage.ImageType.well &&
5011 bioImage?.Resolutions != null &&
5012 bioImage.Resolutions.Count > 0 &&
5013 schema.Resolutions != null &&
5014 schema.Resolutions.Count > 0)
5015 {
5016 var baseResolution = bioImage.Resolutions[0];
5017 double unitsPerPixel = schema.Resolutions.ContainsKey(0)
5018 ? schema.Resolutions[0].UnitsPerPixel
5019 : schema.Resolutions.OrderBy(kv => kv.Key).First().Value.UnitsPerPixel;
5020
5021 if (unitsPerPixel > 0 && baseResolution.SizeX > 0 && baseResolution.SizeY > 0)
5022 {
5023 double expectedWidth = baseResolution.SizeX * unitsPerPixel;
5024 double expectedHeight = baseResolution.SizeY * unitsPerPixel;
5025 double extentWidth = schema.Extent.MaxX - schema.Extent.MinX;
5026 double extentHeight = schema.Extent.MaxY - schema.Extent.MinY;
5027
5028 if (Math.Abs(extentWidth - expectedWidth) > Math.Max(1e-6, expectedWidth * 0.001) ||
5029 Math.Abs(extentHeight - expectedHeight) > Math.Max(1e-6, expectedHeight * 0.001))
5030 {
5031 double stageX = baseResolution.StageSizeX;
5032 double stageY = baseResolution.StageSizeY;
5033 schema.Extent = new Extent(
5034 stageX,
5035 -(stageY + expectedHeight),
5036 stageX + expectedWidth,
5037 -stageY);
5038 }
5039 }
5040 }
5041
5042 schema.OriginX = schema.Extent.MinX;
5043 schema.OriginY = schema.Extent.MaxY;
5044 }
5045
5046 private static double ClampPyramidalOrigin(double requestedOrigin, double imageMin, double imageMax, double viewWorldSize)
5047 {
5048 double imageSize = Math.Max(1, imageMax - imageMin);
5049 if (viewWorldSize >= imageSize)
5050 return imageMin - ((viewWorldSize - imageSize) / 2.0);
5051
5052 double maxOrigin = imageMax - viewWorldSize;
5053 return Math.Max(imageMin, Math.Min(requestedOrigin, maxOrigin));
5054 }
5055
5056 private void ForceNavigationRefresh()
5057 {
5058 if (SelectedImage?.isPyramidal == true)
5059 {
5060 // Go To, pan, and zoom only change viewport geometry. Keep
5061 // reusable tile downloads alive and supersede stale layout work.
5062 slideRenderer?.InvalidateViewport();
5063 sKSlideRenderer?.InvalidateRenderState();
5064 ExecuteDeferredRender();
5065 ScheduleNavigationRenderRetry(50);
5066 ScheduleNavigationRenderRetry(150);
5067 }
5068
5069 UpdateView();
5070 View?.QueueDraw();
5071 if (!MacOS)
5072 glArea?.RequestRedraw();
5073 }
5074
5075 private void ScheduleNavigationRenderRetry(uint delayMs)
5076 {
5077 GLib.Timeout.Add(delayMs, () =>
5078 {
5079 if (_viewerCleanupDone || SelectedImage?.isPyramidal != true)
5080 return false;
5081
5082 ExecuteDeferredRender();
5083 View?.QueueDraw();
5084 if (!MacOS)
5085 glArea?.RequestRedraw();
5086
5087 return false;
5088 });
5089 }
5090
5091 public void GoToImage(int i)
5092 {
5093 if (i < 0 || Images.Count <= i)
5094 return;
5095 if (_isApplyingGoToImage)
5096 {
5097 _pendingGoToImageIndex = i;
5098 return;
5099 }
5100
5101 // Ensure viewport is valid before doing any math
5102 int vpW = View.AllocatedWidth;
5103 int vpH = View.AllocatedHeight;
5104
5105 if (vpW < 10 || vpH < 10)
5106 {
5107 void OnAllocated(object sender, SizeAllocatedArgs args)
5108 {
5109 if (args.Allocation.Width < 10 || args.Allocation.Height < 10)
5110 return;
5111
5112 View.SizeAllocated -= OnAllocated;
5113 GoToImage(i);
5114 }
5115
5116 View.SizeAllocated += OnAllocated;
5117 return;
5118 }
5119
5120 bool imageChanged = selectedIndex != i;
5121 _isApplyingGoToImage = true;
5122 _suppressViewUpdates = true;
5123 try
5124 {
5125 if (imageChanged)
5126 {
5127 selectedIndex = i;
5128 Initialize();
5129 InitPreview();
5130 UpdateGUI();
5131 }
5132
5133 BioImage currentImage = Images[i];
5134 if (currentImage == null)
5135 return;
5136
5137 if (currentImage.isPyramidal)
5138 {
5139 TryGetPyramidalViewerBounds(currentImage, out double imageMinX, out double imageMinY, out double imageMaxX, out double imageMaxY);
5140
5141 double imageWorldW = imageMaxX - imageMinX;
5142 double imageWorldH = imageMaxY - imageMinY;
5143 if (imageWorldW <= 0) imageWorldW = 1;
5144 if (imageWorldH <= 0) imageWorldH = 1;
5145
5146 double targetRes = Math.Max(imageWorldW / vpW, imageWorldH / vpH);
5147 if (targetRes <= 0) targetRes = 1;
5148
5149 Resolution = targetRes;
5150 UpdateScrollBars();
5151
5152 double viewWorldW = vpW * Resolution;
5153 double viewWorldH = vpH * Resolution;
5154 double originX = imageMinX - ((viewWorldW - imageWorldW) / 2.0);
5155 double originY = imageMinY - ((viewWorldH - imageWorldH) / 2.0);
5156 PyramidalOrigin = new PointD(originX, originY);
5157 }
5158 else
5159 {
5160 double imageWorldW = Math.Max(currentImage.Volume.Width, currentImage.SizeX * currentImage.PhysicalSizeX);
5161 double imageWorldH = Math.Max(currentImage.Volume.Height, currentImage.SizeY * currentImage.PhysicalSizeY);
5162 if (imageWorldW <= 0) imageWorldW = 1;
5163 if (imageWorldH <= 0) imageWorldH = 1;
5164
5165 double wx = vpW / (PxWmicron * imageWorldW);
5166 double wy = vpH / (PxHmicron * imageWorldH);
5167
5168 float fitScale = (float)Math.Min(wx, wy);
5169 if (fitScale <= 0) fitScale = 1;
5170
5171 Scale = new SizeF(fitScale, fitScale);
5172
5173 double viewWorldW = vpW / (PxWmicron * fitScale);
5174 double viewWorldH = vpH / (PxHmicron * fitScale);
5175 double originX = -currentImage.Volume.Location.X + ((viewWorldW - imageWorldW) / 2.0);
5176 double originY = -currentImage.Volume.Location.Y + ((viewWorldH - imageWorldH) / 2.0);
5177 Origin = new PointD(originX, originY);
5178 }
5179 }
5180 finally
5181 {
5182 _suppressViewUpdates = false;
5183 _isApplyingGoToImage = false;
5184 }
5185
5186 ForceNavigationRefresh();
5187 BioLib.Recorder.Record(BioLib.Recorder.GetCurrentMethodInfo(), false, i);
5188
5189 if (_pendingGoToImageIndex.HasValue)
5190 {
5191 int pendingIndex = _pendingGoToImageIndex.Value;
5192 _pendingGoToImageIndex = null;
5193 if (pendingIndex != i)
5194 GoToImage(pendingIndex);
5195 }
5196 }
5197
5203 private double GetViewportFitResolution(double targetResolution)
5204 {
5205 if (!SelectedImage.isPyramidal || targetResolution <= 0)
5206 return targetResolution <= 0 ? 1 : targetResolution;
5207
5208 var schema = openSlide
5209 ? _openSlideBase?.Schema
5210 : _slideBase?.Schema;
5211
5212 if (schema?.Resolutions != null && schema.Resolutions.Count > 0)
5213 {
5214 double? smallest = null;
5215 double? best = null;
5216 foreach (var kv in schema.Resolutions.OrderBy(kv => kv.Value.UnitsPerPixel))
5217 {
5218 double upx = kv.Value.UnitsPerPixel;
5219 smallest ??= upx;
5220 if (upx >= targetResolution)
5221 {
5222 best = upx;
5223 break;
5224 }
5225 }
5226
5227 if (best.HasValue)
5228 return best.Value;
5229 if (schema.Resolutions.Count > 0)
5230 return schema.Resolutions.OrderByDescending(kv => kv.Value.UnitsPerPixel).First().Value.UnitsPerPixel;
5231 }
5232
5233 double fallback = SelectedImage.GetUnitPerPixel(SelectedImage.Resolutions.Count - 1);
5234 return fallback > 0 ? fallback : targetResolution;
5235 }
5236
5237 private double PickInitialResolution()
5238 {
5239 // Exclude macro/label levels.
5240 int maxLevel = MacroResolution.HasValue
5241 ? MacroResolution.Value - 1
5242 : SelectedImage.Resolutions.Count - 1;
5243 maxLevel = Math.Clamp(maxLevel, 0, SelectedImage.Resolutions.Count - 1);
5244
5245 // Prefer reading from the schema so we match TileUtil.GetLevel exactly.
5246 var schema = openSlide
5247 ? (ITileSchema)_openSlideBase?.Schema
5248 : _slideBase?.Schema;
5249
5250 if (schema?.Resolutions != null && schema.Resolutions.Count > 0)
5251 {
5252 // Schema resolutions are keyed 0..N where 0=finest, N=coarsest.
5253 // Find the largest key that is <= maxLevel.
5254 int schemaMax = Math.Min(maxLevel, schema.Resolutions.Keys.Max());
5255 return schema.Resolutions[schemaMax].UnitsPerPixel;
5256 }
5257
5258 // Fallback: derive from BioImage geometry (used before SlideBase is set).
5259 return SelectedImage.GetUnitPerPixel(maxLevel);
5260 }
5261
5262 #endregion
5263
5264 #region OpenSlide
5265 private OpenSlideBase _openSlideBase;
5266 private OpenSlideGTK.ISlideSource _openSlideSource;
5267 private BioLib.ISlideSource _slideSource;
5268 private SlideBase _slideBase;
5280 {
5281 if (SelectedImage?.Type != BioImage.ImageType.well) return;
5282 if (_slideBase == null) return;
5283
5284 _slideBase.RebuildSchemaForWell(SelectedImage);
5285 NormalizeOsmSchemaOrigin(_slideBase, SelectedImage);
5286
5287 if (MacOS) sKSlideRenderer?.SetSource(_slideBase);
5288 else slideRenderer?.SetSource(_slideBase);
5289
5290 // GoToImage resets the viewport to fit the new field and triggers a render.
5291 GoToImage(selectedIndex);
5292 BioLib.Recorder.Record(BioLib.Recorder.GetCurrentMethodInfo(), false, SelectedImage?.file ?? string.Empty, SelectedImage.WellIndex);
5293 }
5294
5295 private void Initialize()
5296 {
5297 // Pyramidal images can come from either OpenSlideBase or SlideBase.
5298 // Zarr-backed pyramids are not guaranteed to report ImageType.pyramidal
5299 // even when they need the pyramidal tile renderer, so key off isPyramidal.
5300 if (SelectedImage?.isPyramidal == true)
5301 {
5302 bool useSlideBase = SelectedImage.SlideBase != null;
5303 bool useOpenSlideBase = !useSlideBase && SelectedImage.OpenSlideBase != null;
5304
5305 if (useSlideBase)
5306 {
5307 openSlide = false;
5308 var sb = SelectedImage.SlideBase;
5309 _slideSource = SelectedImage.SlideBase;
5310 _slideBase = sb;
5311 NormalizeOsmSchemaOrigin(sb, SelectedImage);
5312 var sbResolutions = sb.Schema?.Resolutions;
5313 if (sbResolutions != null && sbResolutions.Count > 0)
5314 Resolution = sbResolutions[sbResolutions.Count - 1].UnitsPerPixel;
5315 if (MacOS) sKSlideRenderer?.SetSource(sb);
5316 else slideRenderer?.SetSource(sb);
5317
5318 if (SelectedImage.Type == BioImage.ImageType.well)
5319 {
5320 RebuildSchemaForWell(SelectedImage);
5321 NormalizeOsmSchemaOrigin(sb, SelectedImage);
5322 }
5323 }
5324 else if (useOpenSlideBase)
5325 {
5326 openSlide = true;
5327 var sb = SelectedImage.OpenSlideBase;
5328 _openSlideSource = SelectedImage.OpenSlideBase;
5329 _openSlideBase = sb;
5330 var sbResolutions = sb.Schema?.Resolutions;
5331 if (sbResolutions != null && sbResolutions.Count > 0)
5332 Resolution = sbResolutions[sbResolutions.Count - 1].UnitsPerPixel;
5333
5334 if (MacOS) sKSlideRenderer?.SetSource(sb);
5335 else slideRenderer?.SetSource(sb);
5336
5337 if (SelectedImage.Type == BioImage.ImageType.well)
5338 {
5339 RebuildSchemaForWell(SelectedImage);
5340 }
5341 }
5342 else
5343 {
5344 }
5345 }
5346
5347 // Do not force a thumbnail-like pyramid level here. GoToImage handles
5348 // the visible zoom for pyramidal images; non-pyramidal images still
5349 // use the legacy initial resolution path.
5350 if (!SelectedImage.isPyramidal)
5351 Resolution = PickInitialResolution();
5352 }
5353
5354 public void RebuildSchemaForWell(BioImage b)
5355 {
5356 if (b.ZarrWellLevels == null || b.ZarrWellLevels.Count == 0)
5357 {
5358 return;
5359 }
5360 int fi = Math.Clamp(b.Level, 0, b.ZarrWellLevels.Count - 1);
5361 var fieldLevels = b.ZarrWellLevels[fi];
5362 if (fieldLevels == null || fieldLevels.Count == 0)
5363 {
5364 // Requested field not yet loaded — fall back to first non-null field
5365 // so the schema matches what TryReadRegionAsync will actually serve.
5366 fieldLevels = b.ZarrWellLevels.FirstOrDefault(l => l != null && l.Count > 0);
5367 if (fieldLevels == null)
5368 {
5369 return;
5370 }
5371 }
5372
5373 int w0 = 1, h0 = 1;
5374 GetFieldDims(fieldLevels[0], out w0, out h0);
5375
5376 var newSchema = new BruTile.TileSchema
5377 {
5378 YAxis = BruTile.YAxis.OSM,
5379 Format = "jpg",
5380 Extent = new BruTile.Extent(0, -h0, w0, 0),
5381 OriginX = 0,
5382 OriginY = 0,
5383 };
5384
5385 for (int lev = 0; lev < fieldLevels.Count; lev++)
5386 {
5387 GetFieldDims(fieldLevels[lev], out int wL, out int hL);
5388 double downsample = w0 > 0 ? (double)w0 / Math.Max(1, wL) : Math.Pow(2, lev);
5389 newSchema.Resolutions[lev] = new BruTile.Resolution(lev, downsample, 256, 256);
5390 }
5391
5392 var slideBase = SelectedImage?.OpenSlideBase;
5393 if (slideBase == null)
5394 {
5395 return;
5396 }
5397 //OpenSlideBase op = new OpenSlideBase(SelectedImage.Filename);
5398 slideBase.Schema = newSchema;
5399 BioLib.Recorder.Record(BioLib.Recorder.GetCurrentMethodInfo(), false, b?.file ?? string.Empty, b?.Level ?? 0);
5400 }
5401
5402 private static void GetFieldDims(ResolutionLevelNode node, out int w, out int h)
5403 {
5404 w = 1; h = 1;
5405 var axes = node.EffectiveAxes;
5406 var shape = node.Shape;
5407 for (int i = 0; i < axes.Length; i++)
5408 {
5409 switch (axes[i].Name.ToLowerInvariant())
5410 {
5411 case "x": w = (int)shape[i]; break;
5412 case "y": h = (int)shape[i]; break;
5413 }
5414 }
5415 }
5416
5417 #endregion
5418
5419 }
5420
5421 internal static class Gray16TileConverter
5422 {
5427 internal static byte[] ToBgra(byte[] raw, int width, int height,
5428 int tileWidth, int tileHeight, int min, int max)
5429 {
5430 if (width <= 0 || height <= 0 || width > tileWidth || height > tileHeight)
5431 throw new ArgumentOutOfRangeException(nameof(width), "The region must fit inside the tile.");
5432 if (raw == null || raw.Length != checked(width * height * 2))
5433 throw new ArgumentException("The pixel buffer must match the region dimensions.", nameof(raw));
5434 if (max <= min)
5435 throw new ArgumentOutOfRangeException(nameof(max), "The display maximum must exceed the minimum.");
5436
5437 byte[] bgra = new byte[checked(tileWidth * tileHeight * 4)];
5438 float scale = 255f / (max - min);
5439 for (int row = 0; row < height; row++)
5440 {
5441 int srcRow = row * width * 2;
5442 int dstRow = row * tileWidth * 4;
5443 for (int col = 0; col < width; col++)
5444 {
5445 int source = srcRow + col * 2;
5446 ushort value = (ushort)(raw[source] | (raw[source + 1] << 8));
5447 byte gray = (byte)Math.Clamp((value - min) * scale, 0f, 255f);
5448 int destination = dstRow + col * 4;
5449 bgra[destination] = gray;
5450 bgra[destination + 1] = gray;
5451 bgra[destination + 2] = gray;
5452 bgra[destination + 3] = 255;
5453 }
5454 }
5455 return bgra;
5456 }
5457 }
5458}
double ToViewH(double d)
void UpdateView(bool updateImages=true)
It updates the view.
double ToViewSizeH(double d)
void UpdateImage()
It updates the image.
float ToScreenScaleW(double x)
double ToViewW(double d)
RectangleD ToScreenRect(double x, double y, double w, double h)
PointD ToViewSpace(PointD p)
RectangleD[] ToScreenSpace(RectangleD[] p)
RectangleD ToScreenSpace(RectangleD p)
void PasteSelection()
PointF ToViewSpace(PointF p)
PointF ToScreenSpace(PointF p)
RectangleD ToScreenSpace(RectangleF p)
void CopySelection()
It takes the selected ROIs and copies them to the clipboard.
void SetupHandlers()
Sets up the handlers.
PointD ToScreenSpace(double x, double y)
PointD ImageToViewSpace(double x, double y)
PointD ToScreenSpace(PointD p)
static ImageView Create(BioImage bm)
Definition ImageView.cs:585
void GoToImage()
This function is used to go to the image at the specified index.
void AddImage(BioImage im)
Definition ImageView.cs:205
PointF ToScreenScale(PointD p)
void UpdateGUI()
It updates the GUI to reflect the current state of the image.
void ReinitializeWellField()
Open slide file.
PointF[] ToScreenSpace(PointF[] p)
static Cairo.Color FromColor(Color color)
PointD[] ToScreenSpace(PointD[] p)
void RefreshPyramidalTiles()
Clears cached pyramidal tiles so channel/threshold changes take effect immediately on the tile render...
PointF ToScreenSpace(Point3D p)
void UpdateImages(bool force=false)
It updates the images.
float ToScreenScaleH(double y)
void UpdateStatus()
It updates the status of the user.
float ToScreenH(double y)
double ToScreenW(double x)
void InvalidateRenderState()
Marks any in-flight render as stale so the next viewport update can replace it without the old result...
void ClearCache()
Clear the tile cache.
void RequestRedraw()
Request a redraw of the GLArea.
void InvalidateRenderState(bool cancelActiveFetches=true)
Marks all in-flight tile work as stale. Call this before changing threshold/range state so old tiles ...
Implementations for all tool types defined in Tools.cs.
Definition Tools.cs:23
static Tools Create()
Definition Tools.cs:36
Definition ML.cs:17
Definition Zarr.cs:20