diff --git a/Hawkeye.VisionBuilder/Features/ImagesStrip/Components/ImageThumbnailControl.cs b/Hawkeye.VisionBuilder/Features/ImagesStrip/Components/ImageThumbnailControl.cs new file mode 100644 index 0000000..c70b509 --- /dev/null +++ b/Hawkeye.VisionBuilder/Features/ImagesStrip/Components/ImageThumbnailControl.cs @@ -0,0 +1,150 @@ +using System; +using System.Drawing; +using System.IO; +using System.Windows.Forms; +using OpenCvSharp; +using OpenCvSharp.Extensions; + +namespace Hawkeye.VisionBuilder.Features.ImagesStrip.Components +{ + /// + /// Represents a single image thumbnail item with an image and filename label. + /// + public class ImageThumbnailControl : Panel + { + private const int ThumbnailSize = 108; + private const int BorderWidth = 3; + + private readonly PictureBox _pictureBox; + private readonly Label _label; + private bool _isSelected; + private string _imagePath; + + public string ImagePath + { + get => _imagePath; + set + { + _imagePath = value; + LoadImage(value); + _label.Text = Path.GetFileNameWithoutExtension(value); + } + } + + public bool IsSelected + { + get => _isSelected; + set + { + if (_isSelected != value) + { + _isSelected = value; + Invalidate(); // Trigger repaint to show/hide border + } + } + } + + public event EventHandler ThumbnailClicked; + + public ImageThumbnailControl() + { + // Set up the container panel + Width = ThumbnailSize + (BorderWidth * 2); + Height = ThumbnailSize + 25 + (BorderWidth * 2); // Extra space for label + Margin = new Padding(5); + Cursor = Cursors.Hand; + + // Create PictureBox for the image + _pictureBox = new PictureBox + { + Width = ThumbnailSize, + Height = ThumbnailSize, + SizeMode = PictureBoxSizeMode.StretchImage, + Location = new System.Drawing.Point(BorderWidth, BorderWidth), + BackColor = Color.Black + }; + _pictureBox.Click += OnThumbnailClick; + + // Create Label for the filename + _label = new Label + { + Width = ThumbnailSize, + Height = 20, + Location = new System.Drawing.Point(BorderWidth, ThumbnailSize + BorderWidth + 2), + TextAlign = ContentAlignment.MiddleCenter, + AutoEllipsis = true, + Font = new Font("Segoe UI", 8f) + }; + _label.Click += OnThumbnailClick; + + Controls.Add(_pictureBox); + Controls.Add(_label); + + // Enable double buffering to reduce flicker + DoubleBuffered = true; + } + + private void LoadImage(string imagePath) + { + try + { + if (File.Exists(imagePath)) + { + using (var mat = new Mat(imagePath)) + { + var resized = mat.Resize(new OpenCvSharp.Size(ThumbnailSize, ThumbnailSize)); + var bitmap = resized.ToBitmap(); + + // Dispose old image if exists + var oldImage = _pictureBox.Image; + _pictureBox.Image = bitmap; + oldImage?.Dispose(); + } + } + } + catch (Exception ex) + { + // If image loading fails, show error in label + _label.Text = "Error loading image"; + _label.ForeColor = Color.Red; + } + } + + private void OnThumbnailClick(object sender, EventArgs e) + { + ThumbnailClicked?.Invoke(this, EventArgs.Empty); + } + + protected override void OnPaint(PaintEventArgs e) + { + base.OnPaint(e); + + // Draw selection border if selected + if (_isSelected) + { + using (var pen = new Pen(Color.DodgerBlue, BorderWidth)) + { + var rect = new Rectangle( + BorderWidth / 2, + BorderWidth / 2, + Width - BorderWidth, + Height - BorderWidth + ); + e.Graphics.DrawRectangle(pen, rect); + } + } + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + // Dispose the image to free memory + _pictureBox?.Image?.Dispose(); + _pictureBox?.Dispose(); + _label?.Dispose(); + } + base.Dispose(disposing); + } + } +} diff --git a/Hawkeye.VisionBuilder/Features/ImagesStrip/Components/VirtualizedImageList.Designer.cs b/Hawkeye.VisionBuilder/Features/ImagesStrip/Components/VirtualizedImageList.Designer.cs new file mode 100644 index 0000000..e2f4b2c --- /dev/null +++ b/Hawkeye.VisionBuilder/Features/ImagesStrip/Components/VirtualizedImageList.Designer.cs @@ -0,0 +1,47 @@ +namespace Hawkeye.VisionBuilder.Features.ImagesStrip.Components +{ + partial class VirtualizedImageList + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + + + #region Component Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + flowLayoutPanel = new FlowLayoutPanel(); + SuspendLayout(); + // + // flowLayoutPanel + // + flowLayoutPanel.AutoScroll = true; + flowLayoutPanel.Dock = DockStyle.Fill; + flowLayoutPanel.Location = new Point(0, 0); + flowLayoutPanel.Name = "flowLayoutPanel"; + flowLayoutPanel.Size = new Size(800, 150); + flowLayoutPanel.TabIndex = 0; + flowLayoutPanel.WrapContents = false; + // + // VirtualizedImageList + // + AutoScaleDimensions = new SizeF(7F, 15F); + AutoScaleMode = AutoScaleMode.Font; + Controls.Add(flowLayoutPanel); + Name = "VirtualizedImageList"; + Size = new Size(800, 150); + ResumeLayout(false); + } + + #endregion + + private System.Windows.Forms.FlowLayoutPanel flowLayoutPanel; + } +} diff --git a/Hawkeye.VisionBuilder/Features/ImagesStrip/Components/VirtualizedImageList.cs b/Hawkeye.VisionBuilder/Features/ImagesStrip/Components/VirtualizedImageList.cs new file mode 100644 index 0000000..251283a --- /dev/null +++ b/Hawkeye.VisionBuilder/Features/ImagesStrip/Components/VirtualizedImageList.cs @@ -0,0 +1,272 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Linq; +using System.Windows.Forms; + +namespace Hawkeye.VisionBuilder.Features.ImagesStrip.Components +{ + /// + /// A virtualized image list control that efficiently displays image thumbnails + /// by loading only the images in the current window (managed by ImagesViewModel). + /// + public partial class VirtualizedImageList : UserControl + { + private ImagesViewModel _viewModel; + private Dictionary _thumbnailControls = new Dictionary(); + private bool _isUpdating = false; + + /// + /// Gets or sets the ImagesViewModel that manages the image data and windowing logic. + /// + public ImagesViewModel ViewModel + { + get => _viewModel; + set + { + if (_viewModel != null) + { + // Unsubscribe from old view model + _viewModel.ImagesAdded -= OnImagesAdded; + _viewModel.ImagesRemoved -= OnImagesRemoved; + _viewModel.CurrentImageChanged -= OnCurrentImageChanged; + _viewModel.ImagesCleared -= OnImagesCleared; + } + + _viewModel = value; + + if (_viewModel != null) + { + // Subscribe to new view model + _viewModel.ImagesAdded += OnImagesAdded; + _viewModel.ImagesRemoved += OnImagesRemoved; + _viewModel.CurrentImageChanged += OnCurrentImageChanged; + _viewModel.ImagesCleared += OnImagesCleared; + + // Load initial images + RefreshImages(); + } + } + } + + /// + /// Event raised when a thumbnail is clicked by the user. + /// + public event EventHandler ImageSelected; + + public VirtualizedImageList() + { + InitializeComponent(); + } + + private void OnImagesAdded(object sender, ImageAddedEventArgs[] e) + { + if (_isUpdating) return; + + _isUpdating = true; + flowLayoutPanel.SuspendLayout(); + + try + { + foreach (var args in e.OrderBy(x => x.Index)) + { + AddThumbnail(args.Index, args.FilePath); + } + } + finally + { + flowLayoutPanel.ResumeLayout(); + _isUpdating = false; + } + + UpdateCurrentSelection(); + } + + private void OnImagesRemoved(object sender, ImageRemovedEventArgs[] e) + { + if (_isUpdating) return; + + _isUpdating = true; + flowLayoutPanel.SuspendLayout(); + + try + { + foreach (var args in e.OrderByDescending(x => x.Index)) + { + RemoveThumbnail(args.Index); + } + } + finally + { + flowLayoutPanel.ResumeLayout(); + _isUpdating = false; + } + } + + private void OnCurrentImageChanged(object sender, CurrentImageChangedEventArgs e) + { + UpdateCurrentSelection(); + ScrollToCurrentImage(); + } + + private void OnImagesCleared(object sender, EventArgs e) + { + ClearAllThumbnails(); + } + + private void AddThumbnail(int index, string filePath) + { + // Create new thumbnail control + var thumbnail = new ImageThumbnailControl + { + ImagePath = filePath, + Tag = index // Store the index in Tag for later reference + }; + + thumbnail.ThumbnailClicked += OnThumbnailClicked; + + // Find the correct insertion position to maintain order + int insertPosition = 0; + foreach (Control control in flowLayoutPanel.Controls) + { + if (control.Tag is int controlIndex && controlIndex < index) + { + insertPosition++; + } + else + { + break; + } + } + + flowLayoutPanel.Controls.Add(thumbnail); + flowLayoutPanel.Controls.SetChildIndex(thumbnail, insertPosition); + _thumbnailControls[index] = thumbnail; + } + + private void RemoveThumbnail(int index) + { + if (_thumbnailControls.TryGetValue(index, out var thumbnail)) + { + thumbnail.ThumbnailClicked -= OnThumbnailClicked; + flowLayoutPanel.Controls.Remove(thumbnail); + thumbnail.Dispose(); + _thumbnailControls.Remove(index); + } + } + + private void ClearAllThumbnails() + { + _isUpdating = true; + flowLayoutPanel.SuspendLayout(); + + try + { + foreach (var thumbnail in _thumbnailControls.Values) + { + thumbnail.ThumbnailClicked -= OnThumbnailClicked; + thumbnail.Dispose(); + } + + _thumbnailControls.Clear(); + flowLayoutPanel.Controls.Clear(); + } + finally + { + flowLayoutPanel.ResumeLayout(); + _isUpdating = false; + } + } + + private void UpdateCurrentSelection() + { + if (_viewModel == null) return; + + int currentIndex = _viewModel.CurrentIndex; + + // Update IsSelected property for all thumbnails + foreach (var kvp in _thumbnailControls) + { + kvp.Value.IsSelected = (kvp.Key == currentIndex); + } + + // Raise event for external listeners + if (_thumbnailControls.TryGetValue(currentIndex, out var currentThumbnail)) + { + ImageSelected?.Invoke(this, currentThumbnail.ImagePath); + } + } + + private void ScrollToCurrentImage() + { + if (_viewModel == null) return; + + int currentIndex = _viewModel.CurrentIndex; + + if (_thumbnailControls.TryGetValue(currentIndex, out var thumbnail)) + { + // Scroll the thumbnail into view + flowLayoutPanel.ScrollControlIntoView(thumbnail); + } + } + + private void OnThumbnailClicked(object sender, EventArgs e) + { + if (sender is ImageThumbnailControl thumbnail && _viewModel != null) + { + string imagePath = thumbnail.ImagePath; + + // Notify view model of selection + _viewModel.SelectByPath(imagePath); + + // Raise event for external listeners + ImageSelected?.Invoke(this, imagePath); + } + } + + private void RefreshImages() + { + if (_viewModel == null) return; + + ClearAllThumbnails(); + + _isUpdating = true; + flowLayoutPanel.SuspendLayout(); + + try + { + // Add all currently loaded images + foreach (var imageItem in _viewModel.LoadedImages.OrderBy(x => x.Index)) + { + AddThumbnail(imageItem.Index, imageItem.FilePath); + } + } + finally + { + flowLayoutPanel.ResumeLayout(); + _isUpdating = false; + } + + UpdateCurrentSelection(); + ScrollToCurrentImage(); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + if (_viewModel != null) + { + _viewModel.ImagesAdded -= OnImagesAdded; + _viewModel.ImagesRemoved -= OnImagesRemoved; + _viewModel.CurrentImageChanged -= OnCurrentImageChanged; + _viewModel.ImagesCleared -= OnImagesCleared; + } + + ClearAllThumbnails(); + } + + base.Dispose(disposing); + } + } +} diff --git a/Hawkeye.VisionBuilder/Features/ImagesStrip/Components/VirtualizedImageList.resx b/Hawkeye.VisionBuilder/Features/ImagesStrip/Components/VirtualizedImageList.resx new file mode 100644 index 0000000..8b2ff64 --- /dev/null +++ b/Hawkeye.VisionBuilder/Features/ImagesStrip/Components/VirtualizedImageList.resx @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/Hawkeye.VisionBuilder/Features/ImagesStrip/ImageStripPanel.Designer.cs b/Hawkeye.VisionBuilder/Features/ImagesStrip/ImageStripPanel.Designer.cs index dfb626c..0438739 100644 --- a/Hawkeye.VisionBuilder/Features/ImagesStrip/ImageStripPanel.Designer.cs +++ b/Hawkeye.VisionBuilder/Features/ImagesStrip/ImageStripPanel.Designer.cs @@ -43,7 +43,7 @@ ckbExecuteWorkflow = new ToolStripButton(); ckbStopOnError = new ToolStripButton(); ckbStopOnNoError = new ToolStripButton(); - listView1 = new ListView(); + virtualizedImageList1 = new Hawkeye.VisionBuilder.Features.ImagesStrip.Components.VirtualizedImageList(); toolStrip1.SuspendLayout(); SuspendLayout(); // @@ -173,25 +173,21 @@ ckbStopOnNoError.Size = new Size(97, 23); ckbStopOnNoError.Text = "Stop on no error"; // - // listView1 + // virtualizedImageList1 // - listView1.Alignment = ListViewAlignment.Left; - listView1.Dock = DockStyle.Fill; - listView1.HeaderStyle = ColumnHeaderStyle.None; - listView1.LabelWrap = false; - listView1.Location = new Point(0, 26); - listView1.MultiSelect = false; - listView1.Name = "listView1"; - listView1.Size = new Size(876, 171); - listView1.TabIndex = 1; - listView1.UseCompatibleStateImageBehavior = false; + virtualizedImageList1.Dock = DockStyle.Fill; + virtualizedImageList1.Location = new Point(0, 26); + virtualizedImageList1.Name = "virtualizedImageList1"; + virtualizedImageList1.Size = new Size(876, 171); + virtualizedImageList1.TabIndex = 1; + virtualizedImageList1.ViewModel = null; // // ImageStripPanel // AutoScaleDimensions = new SizeF(7F, 15F); AutoScaleMode = AutoScaleMode.Font; ClientSize = new Size(876, 197); - Controls.Add(listView1); + Controls.Add(virtualizedImageList1); Controls.Add(toolStrip1); MaximizeBox = false; MinimizeBox = false; @@ -212,12 +208,12 @@ private ToolStripButton btnToEnd; private ToolStripSeparator toolStripSeparator1; private ToolStripButton cbRepeat; - private ListView listView1; private ToolStripLabel toolStripLabel1; private ToolStripNumberControl ncInterval; private ToolStripButton ckbExecuteWorkflow; private ToolStripLabel lblSelectedImage; private ToolStripButton ckbStopOnError; private ToolStripButton ckbStopOnNoError; + private Components.VirtualizedImageList virtualizedImageList1; } } diff --git a/Hawkeye.VisionBuilder/Features/ImagesStrip/ImageStripPanel.cs b/Hawkeye.VisionBuilder/Features/ImagesStrip/ImageStripPanel.cs index 534c71a..e86019f 100644 --- a/Hawkeye.VisionBuilder/Features/ImagesStrip/ImageStripPanel.cs +++ b/Hawkeye.VisionBuilder/Features/ImagesStrip/ImageStripPanel.cs @@ -1,4 +1,5 @@ using System.Drawing.Drawing2D; +using Hawkeye.VisionBuilder.Workflow.Datatypes; using OpenCvSharp; using OpenCvSharp.Extensions; using VisionBuilder.UI.Common.Processing; @@ -8,35 +9,51 @@ namespace Hawkeye.VisionBuilder.Features.ImagesStrip { public partial class ImageStripPanel : Form, IImageSource { + + public ImagesViewModel ImagesViewModel { get; set; } public ImageStripPanel() { InitializeComponent(); - - listView1.SelectedIndexChanged += ListView1_SelectedIndexChanged; + + ImagesViewModel = new ImagesViewModel(20); + //ImagesViewModel.CurrentImageChanged += ImagesViewModel_CurrentImageChanged; + //ImagesViewModel.ImagesAdded += ImagesViewModel_ImageAdded; + //ImagesViewModel.ImagesRemoved += ImagesViewModel_ImageRemoved; + virtualizedImageList1.ViewModel=ImagesViewModel; + virtualizedImageList1.ImageSelected += VirtualizedImageList1_ImageSelected; } - private void ListView1_SelectedIndexChanged(object? sender, EventArgs e) + private void VirtualizedImageList1_ImageSelected(object? sender, string e) { - if (listView1.SelectedItems.Count > 0) - { - lblSelectedImage.Text = $"{listView1.SelectedIndices[0] + 1}/{listView1.Items.Count}"; - var path = (string)listView1.SelectedItems[0].Tag; - listView1.SelectedItems[0].ImageKey = path; - var img = new Mat(path); - var image = new Workflow.Datatypes.HawkeyeImage() - { - Filename = path, - ImageData = img - }; - listView1.LargeImageList.Images.RemoveByKey(path); - listView1.LargeImageList.Images.Add(path, img.Resize(new OpenCvSharp.Size(108, 108)).ToBitmap()); - ImageSelected(image); - } + lblSelectedImage.Text = $"{this.ImagesViewModel.CurrentIndex + 1}/{ImagesViewModel.TotalCount}"; + ImageSelected(new HawkeyeImage() { Filename = ImagesViewModel.CurrentImagePath, ImageData = new Mat(ImagesViewModel.CurrentImagePath) }); + } + + + + //private void ImagesViewModel_CurrentImageChanged(object? sender, CurrentImageChangedEventArgs e) + //{ + // var path = e.NewImagePath; + // for (int i = 0; i < listView1.Items.Count; i++) + // { + // var item = listView1.Items[i]; + // var itemPath = (string)item.ImageKey; + // if (itemPath == path) + // { + // SetSelectedImage(i); + // ImageSelected(new HawkeyeImage(){Filename = ImagesViewModel.CurrentImagePath, ImageData = new Mat(ImagesViewModel.CurrentImagePath)}); + // break; + // } + // } + + //} + + public bool IsExecutingWorkflow { get => ckbExecuteWorkflow.Checked; @@ -44,145 +61,53 @@ namespace Hawkeye.VisionBuilder.Features.ImagesStrip } public event Action ImageSelected = delegate { }; - public void LoadImage(Mat image) - { - ImageList imageList = new ImageList(); - imageList.ImageSize = new Size(108, 108); - listView1.LargeImageList = imageList; - listView1.Items.Clear(); - imageList.Images.Add("image", image.ToBitmap()); - var item = listView1.Items.Add("image"); - item.Tag = new Workflow.Datatypes.HawkeyeImage() - { - Filename = "image", - ImageData = image - }; - item.Text = ""; - item.ImageKey = "image"; + - listView1.SelectedIndices.Clear(); - listView1.SelectedIndices.Add(0); - } - public Bitmap FitImage(Image image, int targetWidth, int targetHeight) - { - var aspectRatio = (float)image.Width / image.Height; - int width; - int height; - if ((float)targetWidth / targetHeight > aspectRatio) - { - width = (int)(targetHeight * aspectRatio); - height = targetHeight; - } - else - { - width = targetWidth; - height = (int)(targetWidth / aspectRatio); - } - Bitmap bitmap = new Bitmap(targetWidth, targetHeight); - using (Graphics graphics = Graphics.FromImage(bitmap)) - { - graphics.DrawImage(image, new Rectangle(0, 0, width, height)); - return bitmap; - } - } + public void LoadImages(string directory) { - listView1.BeginUpdate(); - ImageList imageList = new ImageList(); - imageList.ImageSize = new Size(108, 108); - listView1.LargeImageList = imageList; - listView1.Items.Clear(); - var mock = new Bitmap(108, 108); - using (Graphics g = Graphics.FromImage(mock)) - { - var brush = new HatchBrush(HatchStyle.Percent50, Color.LightGray, Color.Transparent); - g.FillRectangle(brush, 0, 0, 108, 108); - - g.DrawString("Not loaded", new Font("Arial", 12), Brushes.Gray, new PointF(0, 0)); - } - var allImages = Directory.GetFiles(directory, "*.bmp", SearchOption.AllDirectories) - .Concat(Directory.GetFiles(directory, "*.png", SearchOption.AllDirectories)) - .Concat(Directory.GetFiles(directory, "*.jpg", SearchOption.AllDirectories)) - .Where(x => !Path.GetFileNameWithoutExtension(x).Contains("_analysis")) - //.Select(x=>new {path=x,bmp = new Bitmap(x)}) - .Select(x => new { path = x, bmp = mock }) - .ToArray(); - - - imageList.Images.Add("none", mock); - - foreach (var image in allImages) - { - - - var item = listView1.Items.Add(image.path); - item.Text = ""; - item.Tag = image.path; - item.ImageKey = "none"; - } - - if (listView1.Items.Count > 0) - { - listView1.SelectedIndices.Clear(); - listView1.SelectedIndices.Add(0); - toolStrip1.Enabled = true; - } - else - { - toolStrip1.Enabled = false; - } - listView1.EndUpdate(); + //listView1.BeginUpdate(); + ImagesViewModel.LoadFromDirectory(directory); + //listView1.EndUpdate(); } private void btnToStart_Click(object sender, EventArgs e) { - listView1.SelectedIndices.Clear(); - listView1.SelectedIndices.Add(0); - listView1.EnsureVisible(listView1.SelectedIndices[0]); + ImagesViewModel.First(); } public void Prev() { - btnPrev_Click(null, null); + ImagesViewModel.Previous(); + } private void btnPrev_Click(object sender, EventArgs e) { - if (listView1.SelectedIndices.Count == 0) return; - var index = listView1.SelectedIndices[0]; - index--; - if (index < 0) index++; - listView1.SelectedIndices.Clear(); - listView1.SelectedIndices.Add(index); - listView1.EnsureVisible(listView1.SelectedIndices[0]); + Prev(); } public void Next() { - btnNext_Click(null, null); + ImagesViewModel.Next(); } private void btnNext_Click(object sender, EventArgs e) { - if (listView1.SelectedIndices.Count == 0) return; - var index = listView1.SelectedIndices[0]; - index++; - if (index > listView1.Items.Count - 1) index--; - listView1.SelectedIndices.Clear(); - listView1.SelectedIndices.Add(index); - listView1.EnsureVisible(listView1.SelectedIndices[0]); + Next(); } private void btnToEnd_Click(object sender, EventArgs e) { - listView1.SelectedIndices.Clear(); - listView1.SelectedIndices.Add(listView1.Items.Count - 1); - listView1.EnsureVisible(listView1.SelectedIndices[0]); + ImagesViewModel.Last(); } + + + private void btnPlay_Click(object sender, EventArgs e) { Task.Run(Playback); @@ -194,14 +119,17 @@ namespace Hawkeye.VisionBuilder.Features.ImagesStrip { Action a = () => { - if (listView1.SelectedIndices.Count == 0) return; - var index = listView1.SelectedIndices[0]; - index++; - if (index > listView1.Items.Count - 1) + if (ImagesViewModel.TotalCount == 0) + { + btnPlay.Checked = false; + return; + } + + if (ImagesViewModel.CurrentIndex > ImagesViewModel.TotalCount - 1) { if (cbRepeat.Checked) { - index = 0; + ImagesViewModel.First(); } else { @@ -210,19 +138,14 @@ namespace Hawkeye.VisionBuilder.Features.ImagesStrip } } - listView1.SelectedIndices.Clear(); - listView1.SelectedIndices.Add(index); - listView1.EnsureVisible(listView1.SelectedIndices[0]); + Next(); }; - listView1.Invoke(a); + virtualizedImageList1.Invoke(a); + virtualizedImageList1.Invalidate(); await Task.Delay(int.Parse((string)ncInterval.Text)); } } - public string GetName() - { - return "ImageStripPanel"; - } public Task GetImage(CancellationToken token) { @@ -231,19 +154,13 @@ namespace Hawkeye.VisionBuilder.Features.ImagesStrip public Mat GetImage() { - if (listView1.SelectedIndices.Count == 0) return null; - - var path = listView1.SelectedItems[0].ImageKey; - - return new Mat(path); + + return new Mat(ImagesViewModel.CurrentImagePath); } public string GetImagePath() { - if (listView1.SelectedIndices.Count == 0) return null; - var path = listView1.SelectedItems[0].Tag as string; - if (path == null) return null; - return path; + return ImagesViewModel.CurrentImagePath; } public bool StopOnErrors @@ -277,42 +194,11 @@ namespace Hawkeye.VisionBuilder.Features.ImagesStrip } } - public void RemoveImage(Workflow.Datatypes.HawkeyeImage currentImage) - { - if (listView1.SelectedIndices.Count == 0) return; - var index = listView1.SelectedIndices[0]; - listView1.Items.RemoveAt(index); - if (listView1.Items.Count > 0) - { - if (index > listView1.Items.Count - 1) index--; - listView1.SelectedIndices.Clear(); - listView1.SelectedIndices.Add(index); - listView1.EnsureVisible(listView1.SelectedIndices[0]); - } - - } - public List GetFileList() - { - return listView1.Items.Cast().Select(x => (string)x.Tag).ToList(); - } - - public void ReSelect() - { - if(listView1.SelectedIndices.Count == 0) return; - var index = listView1.SelectedIndices[0]; - listView1.SelectedIndices.Clear(); - listView1.SelectedIndices.Add(index); - listView1.EnsureVisible(listView1.SelectedIndices[0]); - } public void SelectImage(string nextFile) { - var index = listView1.Items.Cast().Where(x => (string)x.Tag == nextFile).Select(x => x.Index).FirstOrDefault(); - if (index == -1) return; - listView1.SelectedIndices.Clear(); - listView1.SelectedIndices.Add(index); - listView1.EnsureVisible(listView1.SelectedIndices[0]); + ImagesViewModel.SelectByPath(nextFile); } } diff --git a/Hawkeye.VisionBuilder/Features/ImagesStrip/ImagesViewModel.cs b/Hawkeye.VisionBuilder/Features/ImagesStrip/ImagesViewModel.cs new file mode 100644 index 0000000..cfbb539 --- /dev/null +++ b/Hawkeye.VisionBuilder/Features/ImagesStrip/ImagesViewModel.cs @@ -0,0 +1,426 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; + +namespace Hawkeye.VisionBuilder.Features.ImagesStrip; + +public class ImagesViewModel +{ + private const int DefaultBufferSize = 10; + + private string? _directoryPath; + private List _allImagePaths = new(); + private int _currentIndex = -1; + private int _bufferSize; + private HashSet _currentLoadedIndices = new(); + + public ObservableCollection LoadedImages { get; } = new(); + + public int BufferSize + { + get => _bufferSize; + set + { + if (value < 0) + throw new ArgumentOutOfRangeException(nameof(value), "Buffer size must be non-negative."); + + _bufferSize = value; + if (_currentIndex >= 0) + LoadWindow(); + } + } + + public int CurrentIndex => _currentIndex; + public int CurrentRelativeIndex => _currentIndex - (_currentIndex - _bufferSize < 0 ? 0 : _currentIndex - _bufferSize); + + public int CurrentIndexOnLoaded => GetIndexOfLoaded(); + + public int TotalCount => _allImagePaths.Count; + + public string? CurrentImagePath => _currentIndex >= 0 && _currentIndex < _allImagePaths.Count + ? _allImagePaths[_currentIndex] + : null; + + public bool CanGoNext => _currentIndex < _allImagePaths.Count - 1; + + public bool CanGoPrevious => _currentIndex > 0; + + public bool HasImages => _allImagePaths.Count > 0; + + // Events + public event EventHandler? ImagesAdded; + public event EventHandler? ImagesRemoved; + public event EventHandler? ImagesCleared; + public event EventHandler? CurrentImageChanged; + public event EventHandler? ImagesLoaded; + public event EventHandler? WindowRefreshed; + + public ImagesViewModel() : this(DefaultBufferSize) + { + } + + public ImagesViewModel(int bufferSize) + { + if (bufferSize < 0) + throw new ArgumentOutOfRangeException(nameof(bufferSize), "Buffer size must be non-negative."); + + _bufferSize = bufferSize; + } + + private int GetIndexOfLoaded() + { + return LoadedImages.FirstOrDefault(img => img.IsCurrent)?.Index ?? -1; + } + + public int GetIndexFromPath(string path) + { + return _allImagePaths.IndexOf(path); + } + + /// + /// Loads all image files from the specified directory path. + /// + /// The directory path containing images + /// Optional search pattern (default: *.* for all files) + /// Optional list of supported extensions (e.g., .jpg, .png, .bmp) + public void LoadFromDirectory(string directoryPath, string searchPattern = "*.*", string[]? supportedExtensions = null) + { + if (string.IsNullOrWhiteSpace(directoryPath)) + throw new ArgumentException("Directory path cannot be null or empty.", nameof(directoryPath)); + + if (!Directory.Exists(directoryPath)) + throw new DirectoryNotFoundException($"Directory not found: {directoryPath}"); + + _directoryPath = directoryPath; + + var defaultExtensions = new[] { ".jpg", ".jpeg", ".png", ".bmp", ".gif", ".tif", ".tiff" }; + var extensions = supportedExtensions ?? defaultExtensions; + + var files = Directory.GetFiles(directoryPath, searchPattern, SearchOption.AllDirectories) + .Where(f => !Path.GetFileNameWithoutExtension(f).Contains("_analysis") && extensions.Contains(Path.GetExtension(f), StringComparer.OrdinalIgnoreCase)) + .OrderBy(f => f) + .ToList(); + + _allImagePaths = files; + _currentIndex = files.Count > 0 ? 0 : -1; + + LoadWindow(); + + OnImagesLoaded(new ImagesLoadedEventArgs(files.Count, _directoryPath)); + OnCurrentImageChanged(new CurrentImageChangedEventArgs(CurrentIndex,CurrentIndex,CurrentImagePath)); + } + + /// + /// Navigate to the first image. + /// + public bool First() + { + if (_allImagePaths.Count == 0) + return false; + + if (_currentIndex == 0) + return false; + + var oldIndex = _currentIndex; + _currentIndex = 0; + LoadWindow(); + OnCurrentImageChanged(new CurrentImageChangedEventArgs(oldIndex, _currentIndex, CurrentImagePath)); + + return true; + } + + /// + /// Navigate to the previous image. + /// + public bool Previous() + { + if (!CanGoPrevious) + return false; + + var oldIndex = _currentIndex; + _currentIndex--; + LoadWindow(); + OnCurrentImageChanged(new CurrentImageChangedEventArgs(oldIndex, _currentIndex, CurrentImagePath)); + + return true; + } + + /// + /// Navigate to the next image. + /// + public bool Next() + { + if (!CanGoNext) + return false; + + var oldIndex = _currentIndex; + _currentIndex++; + LoadWindow(); + OnCurrentImageChanged(new CurrentImageChangedEventArgs(oldIndex, _currentIndex, CurrentImagePath)); + + return true; + } + + /// + /// Navigate to the last image. + /// + public bool Last() + { + if (_allImagePaths.Count == 0) + return false; + + var lastIndex = _allImagePaths.Count - 1; + if (_currentIndex == lastIndex) + return false; + + var oldIndex = _currentIndex; + _currentIndex = lastIndex; + LoadWindow(); + OnCurrentImageChanged(new CurrentImageChangedEventArgs(oldIndex, _currentIndex, CurrentImagePath)); + + return true; + } + + /// + /// Navigate to the Nth image (0-based index). + /// + public bool SelectNth(int index) + { + if (index < 0 || index >= _allImagePaths.Count) + throw new ArgumentOutOfRangeException(nameof(index), $"Index must be between 0 and {_allImagePaths.Count - 1}."); + + + var oldIndex = _currentIndex; + _currentIndex = index; + LoadWindow(); + OnCurrentImageChanged(new CurrentImageChangedEventArgs(oldIndex, _currentIndex, CurrentImagePath)); + + return true; + } + + public bool SelectByPath(string path) + { + var index = _allImagePaths.IndexOf(path); + if (index < 0) + return false; + + return SelectNth(index); + } + + /// + /// Clears all loaded images and resets the state. + /// + public void Clear() + { + // Fire remove events for all currently loaded images + + OnImagesRemoved(_currentLoadedIndices.OrderByDescending(i => i).Select(x => new ImageRemovedEventArgs(x, _allImagePaths[x])).ToArray()); + _allImagePaths.Clear(); + LoadedImages.Clear(); + _currentLoadedIndices.Clear(); + _currentIndex = -1; + _directoryPath = null; + + OnImagesCleared(); + } + + /// + /// Refreshes the current window by reloading images from disk. + /// + public void RefreshWindow() + { + LoadWindow(); + OnWindowRefreshed(); + } + + /// + /// Reloads the directory, useful when files have been added or removed. + /// + public void ReloadDirectory(bool preserveCurrentImage = true) + { + if (string.IsNullOrWhiteSpace(_directoryPath)) + return; + + string? currentPath = preserveCurrentImage ? CurrentImagePath : null; + LoadFromDirectory(_directoryPath); + + if (preserveCurrentImage && currentPath != null) + { + var newIndex = _allImagePaths.IndexOf(currentPath); + if (newIndex >= 0) + { + _currentIndex = newIndex; + LoadWindow(); + } + } + } + + private void LoadWindow() + { + if (_currentIndex < 0 || _allImagePaths.Count == 0) + { + + OnImagesRemoved(_currentLoadedIndices.OrderByDescending(i => i).Select(x => new ImageRemovedEventArgs(x, _allImagePaths[x])).ToArray()); + LoadedImages.Clear(); + _currentLoadedIndices.Clear(); + return; + } + + int startIndex = Math.Max(0, _currentIndex - _bufferSize); + int endIndex = Math.Min(_allImagePaths.Count - 1, _currentIndex + _bufferSize); + + // Create a set of new indices that should be loaded + var newIndices = new HashSet(); + for (int i = startIndex; i <= endIndex; i++) + { + newIndices.Add(i); + } + + // Find indices to remove (in current but not in new) + var indicesToRemove = _currentLoadedIndices.Except(newIndices).OrderByDescending(i => i).ToList(); + + // Find indices to add (in new but not in current) + var indicesToAdd = newIndices.Except(_currentLoadedIndices).OrderBy(i => i).ToList(); + + // Remove images that are no longer in the window + foreach (var index in indicesToRemove) + { + var item = LoadedImages.FirstOrDefault(img => img.Index == index); + if (item != null) + { + LoadedImages.Remove(item); + } + _currentLoadedIndices.Remove(index); + } + OnImagesRemoved(indicesToRemove.Select(x=>new ImageRemovedEventArgs(x, _allImagePaths[x])).ToArray()); + + // Add new images to the window + foreach (var index in indicesToAdd) + { + var item = new ImageItem(index, _allImagePaths[index], index == _currentIndex); + + // Insert at the correct position to maintain order + int insertIndex = 0; + for (int i = 0; i < LoadedImages.Count; i++) + { + if (LoadedImages[i].Index < index) + insertIndex = i + 1; + else + break; + } + + LoadedImages.Insert(insertIndex, item); + _currentLoadedIndices.Add(index); + } + OnImagesAdded(indicesToAdd.Select(x => new ImageAddedEventArgs(x, _allImagePaths[x])).ToArray()); + + // Update IsCurrent flag for all items + for (int i = 0; i < LoadedImages.Count; i++) + { + var currentItem = LoadedImages[i]; + if (currentItem.IsCurrent != (currentItem.Index == _currentIndex)) + { + // Replace with updated item + LoadedImages[i] = new ImageItem(currentItem.Index, currentItem.FilePath, currentItem.Index == _currentIndex); + } + } + } + + protected virtual void OnImagesAdded(ImageAddedEventArgs[] e) + { + ImagesAdded?.Invoke(this, e); + } + + protected virtual void OnImagesRemoved(ImageRemovedEventArgs[] e) + { + ImagesRemoved?.Invoke(this, e); + } + + protected virtual void OnImagesCleared() + { + ImagesCleared?.Invoke(this, EventArgs.Empty); + } + + protected virtual void OnCurrentImageChanged(CurrentImageChangedEventArgs e) + { + CurrentImageChanged?.Invoke(this, e); + } + + protected virtual void OnImagesLoaded(ImagesLoadedEventArgs e) + { + ImagesLoaded?.Invoke(this, e); + } + + protected virtual void OnWindowRefreshed() + { + WindowRefreshed?.Invoke(this, EventArgs.Empty); + } +} + +public class ImageItem +{ + public int Index { get; } + public string FilePath { get; } + public string FileName { get; } + public bool IsCurrent { get; } + + public ImageItem(int index, string filePath, bool isCurrent) + { + Index = index; + FilePath = filePath; + FileName = Path.GetFileName(filePath); + IsCurrent = isCurrent; + } +} + +public class ImageAddedEventArgs : EventArgs +{ + public int Index { get; } + public string FilePath { get; } + + public ImageAddedEventArgs(int index, string filePath) + { + Index = index; + FilePath = filePath; + } +} + +public class ImageRemovedEventArgs : EventArgs +{ + public int Index { get; } + public string FilePath { get; } + + public ImageRemovedEventArgs(int index, string filePath) + { + Index = index; + FilePath = filePath; + } +} + +public class CurrentImageChangedEventArgs : EventArgs +{ + public int OldIndex { get; } + public int NewIndex { get; } + public string? NewImagePath { get; } + + public CurrentImageChangedEventArgs(int oldIndex, int newIndex, string? newImagePath) + { + OldIndex = oldIndex; + NewIndex = newIndex; + NewImagePath = newImagePath; + } +} + +public class ImagesLoadedEventArgs : EventArgs +{ + public int TotalCount { get; } + public string DirectoryPath { get; } + + public ImagesLoadedEventArgs(int totalCount, string directoryPath) + { + TotalCount = totalCount; + DirectoryPath = directoryPath; + } +} \ No newline at end of file