sliding window for vision builder image strip pannel

This commit is contained in:
meelstorm
2025-11-14 10:26:43 +01:00
parent 0ee132788d
commit e5eaa88e51
7 changed files with 1090 additions and 193 deletions

View File

@@ -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
{
/// <summary>
/// Represents a single image thumbnail item with an image and filename label.
/// </summary>
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);
}
}
}

View File

@@ -0,0 +1,47 @@
namespace Hawkeye.VisionBuilder.Features.ImagesStrip.Components
{
partial class VirtualizedImageList
{
/// <summary>
/// Required designer variable.
/// </summary>
private System.ComponentModel.IContainer components = null;
#region Component Designer generated code
/// <summary>
/// Required method for Designer support - do not modify
/// the contents of this method with the code editor.
/// </summary>
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;
}
}

View File

@@ -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
{
/// <summary>
/// A virtualized image list control that efficiently displays image thumbnails
/// by loading only the images in the current window (managed by ImagesViewModel).
/// </summary>
public partial class VirtualizedImageList : UserControl
{
private ImagesViewModel _viewModel;
private Dictionary<int, ImageThumbnailControl> _thumbnailControls = new Dictionary<int, ImageThumbnailControl>();
private bool _isUpdating = false;
/// <summary>
/// Gets or sets the ImagesViewModel that manages the image data and windowing logic.
/// </summary>
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();
}
}
}
/// <summary>
/// Event raised when a thumbnail is clicked by the user.
/// </summary>
public event EventHandler<string> 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);
}
}
}

View File

@@ -0,0 +1,120 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
</root>

View File

@@ -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;
}
}

View File

@@ -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<Workflow.Datatypes.HawkeyeImage> 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<Mat> 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<string> GetFileList()
{
return listView1.Items.Cast<ListViewItem>().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<ListViewItem>().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);
}
}

View File

@@ -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<string> _allImagePaths = new();
private int _currentIndex = -1;
private int _bufferSize;
private HashSet<int> _currentLoadedIndices = new();
public ObservableCollection<ImageItem> 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<ImageAddedEventArgs[]>? ImagesAdded;
public event EventHandler<ImageRemovedEventArgs[]>? ImagesRemoved;
public event EventHandler? ImagesCleared;
public event EventHandler<CurrentImageChangedEventArgs>? CurrentImageChanged;
public event EventHandler<ImagesLoadedEventArgs>? 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);
}
/// <summary>
/// Loads all image files from the specified directory path.
/// </summary>
/// <param name="directoryPath">The directory path containing images</param>
/// <param name="searchPattern">Optional search pattern (default: *.* for all files)</param>
/// <param name="supportedExtensions">Optional list of supported extensions (e.g., .jpg, .png, .bmp)</param>
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));
}
/// <summary>
/// Navigate to the first image.
/// </summary>
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;
}
/// <summary>
/// Navigate to the previous image.
/// </summary>
public bool Previous()
{
if (!CanGoPrevious)
return false;
var oldIndex = _currentIndex;
_currentIndex--;
LoadWindow();
OnCurrentImageChanged(new CurrentImageChangedEventArgs(oldIndex, _currentIndex, CurrentImagePath));
return true;
}
/// <summary>
/// Navigate to the next image.
/// </summary>
public bool Next()
{
if (!CanGoNext)
return false;
var oldIndex = _currentIndex;
_currentIndex++;
LoadWindow();
OnCurrentImageChanged(new CurrentImageChangedEventArgs(oldIndex, _currentIndex, CurrentImagePath));
return true;
}
/// <summary>
/// Navigate to the last image.
/// </summary>
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;
}
/// <summary>
/// Navigate to the Nth image (0-based index).
/// </summary>
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);
}
/// <summary>
/// Clears all loaded images and resets the state.
/// </summary>
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();
}
/// <summary>
/// Refreshes the current window by reloading images from disk.
/// </summary>
public void RefreshWindow()
{
LoadWindow();
OnWindowRefreshed();
}
/// <summary>
/// Reloads the directory, useful when files have been added or removed.
/// </summary>
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<int>();
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;
}
}