diff --git a/.gitignore b/.gitignore
index af7bdaf..79ec79b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -362,3 +362,10 @@ MigrationBackup/
# Fody - auto-generated XML schema
FodyWeavers.xsd
/.claude
+
+# Release packages / build artifacts (not tracked in git)
+Releases/
+dist/
+*.zip
+*.rar
+*.7z
diff --git a/README.md b/README.md
index 8fc5b07..9491e62 100644
--- a/README.md
+++ b/README.md
@@ -31,8 +31,12 @@ For non-WPF version you can build the legacy source or use v1.2.0 from releases
### 📥 Installation
+**Requirement:** Yoable needs the **[.NET 9 Desktop Runtime (x64)](https://dotnet.microsoft.com/download/dotnet/9.0/runtime?cid=getdotnetcore&runtime=desktop)** installed. If the app does not launch, install it first.
+
+需求:Yoable 需要先安裝 **[.NET 9 Desktop Runtime (x64)](https://dotnet.microsoft.com/download/dotnet/9.0/runtime?cid=getdotnetcore&runtime=desktop)**。如果程式無法開啟,請先安裝它。
+
1. Download the latest release from our [GitHub Releases](https://github.com/Babyhamsta/Yoable/releases).
-2. Download and run Yoable (No install required!).
+2. Extract the zip and run `YoableWPF.exe`.
3. (Optional) Load a **YOLO v5/v8/v11 (ONNX)** model for AI-assisted labeling.
### 🛠️ How to Use
diff --git a/YoableWPF/MainWindow.xaml b/YoableWPF/MainWindow.xaml
index a07bbb3..b2cd6fa 100644
--- a/YoableWPF/MainWindow.xaml
+++ b/YoableWPF/MainWindow.xaml
@@ -209,6 +209,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
@@ -901,8 +913,9 @@
+ SelectionMode="Single"
+ SelectionChanged="LabelListBox_SelectionChanged"
+ MouseDoubleClick="LabelListBox_MouseDoubleClick">
@@ -915,16 +928,35 @@
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/YoableWPF/MainWindow.xaml.cs b/YoableWPF/MainWindow.xaml.cs
index a21fa71..d5f7546 100644
--- a/YoableWPF/MainWindow.xaml.cs
+++ b/YoableWPF/MainWindow.xaml.cs
@@ -393,6 +393,8 @@ private async void SaveProjectAs_Click(object sender, RoutedEventArgs e)
var saveFileDialog = new SaveFileDialog
{
Filter = "Yoable Project Files (*.yoable)|*.yoable",
+ DefaultExt = ".yoable",
+ AddExtension = true,
Title = "Save Project As",
FileName = projectManager.CurrentProject.ProjectName
};
@@ -410,11 +412,8 @@ private async void SaveProjectAs_Click(object sender, RoutedEventArgs e)
projectManager.CurrentProject.Classes = projectClasses;
}
- // Export current state to project
- projectManager.ExportProjectData();
-
// Save to new location
- if (projectManager.SaveProjectAs(saveFileDialog.FileName))
+ if (await projectManager.SaveProjectAsAsync(saveFileDialog.FileName))
{
ProjectNameText.Text = projectManager.CurrentProject.ProjectName;
UpdateProjectUI();
@@ -785,12 +784,24 @@ public void OnLabelsChanged()
MarkProjectDirty();
}
- public void ImageListBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ public async void ImageListBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (ImageListBox.SelectedItem is ImageListItem selected &&
imageManager.ImagePathMap.TryGetValue(selected.FileName, out ImageManager.ImageInfo imageInfo))
{
- // Save existing labels
+ // Decode off the UI thread on a cache miss; a newer selection abandons this one
+ int loadGeneration = ++imageLoadGeneration;
+ var bitmap = imageManager.Cache.TryGet(imageInfo.Path);
+ if (bitmap == null)
+ {
+ bitmap = await Task.Run(() => imageManager.Cache.GetOrLoad(imageInfo.Path));
+ if (loadGeneration != imageLoadGeneration) return;
+ if (bitmap == null) return; // File unreadable
+ }
+
+ // Save labels of the image currently in the canvas. Kept after the await so
+ // CurrentImagePath always matches the canvas content, even when selections
+ // change faster than images decode.
if (!string.IsNullOrEmpty(imageManager.CurrentImagePath))
{
labelManager.SaveLabels(imageManager.CurrentImagePath, drawingCanvas.Labels);
@@ -799,7 +810,7 @@ public void ImageListBox_SelectionChanged(object sender, SelectionChangedEventAr
imageManager.CurrentImagePath = selected.FileName;
- drawingCanvas.LoadImage(imageInfo.Path, imageInfo.OriginalDimensions);
+ drawingCanvas.LoadImage(bitmap, imageInfo.OriginalDimensions);
// Reset zoom on image change
drawingCanvas.ResetZoom();
@@ -843,7 +854,39 @@ public void ImageListBox_SelectionChanged(object sender, SelectionChangedEventAr
// CRITICAL FIX: Force canvas redraw after loading labels
drawingCanvas.InvalidateVisual();
+
+ // Warm the cache with neighboring images for instant navigation
+ PrefetchNeighboringImages();
+ }
+ }
+
+ // Bumped on every image selection so stale async decodes can be abandoned
+ private int imageLoadGeneration = 0;
+
+ private void PrefetchNeighboringImages()
+ {
+ int index = ImageListBox.SelectedIndex;
+ if (index < 0) return;
+
+ // Favor forward navigation: prefetch more items ahead than behind
+ const int aheadCount = 12;
+ const int behindCount = 4;
+
+ var paths = new List(aheadCount + behindCount);
+ void AddPath(int i)
+ {
+ if (i < 0 || i >= ImageListBox.Items.Count || i == index) return;
+ if (ImageListBox.Items[i] is ImageListItem item &&
+ imageManager.ImagePathMap.TryGetValue(item.FileName, out var info))
+ {
+ paths.Add(info.Path);
+ }
}
+
+ for (int offset = 1; offset <= aheadCount; offset++) AddPath(index + offset);
+ for (int offset = 1; offset <= behindCount; offset++) AddPath(index - offset);
+
+ imageManager.Cache.Prefetch(paths);
}
private void LabelListBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
@@ -909,6 +952,67 @@ private void RejectSuggestion_Click(object sender, RoutedEventArgs e)
}
}
+ private void ChangeLabelClass_Click(object sender, RoutedEventArgs e)
+ {
+ if (sender is not Button button || button.Tag is not LabelListItemView item || item.Label == null)
+ return;
+
+ ShowChangeClassMenu(item.Label, button);
+ }
+
+ private void LabelListBox_MouseDoubleClick(object sender, MouseButtonEventArgs e)
+ {
+ if (LabelListBox.SelectedItem is not LabelListItemView item || item.Label == null)
+ return;
+
+ // Open the class picker anchored on the clicked item container
+ var placement = LabelListBox.ItemContainerGenerator.ContainerFromItem(item) as UIElement ?? LabelListBox;
+ ShowChangeClassMenu(item.Label, placement);
+ e.Handled = true;
+ }
+
+ private void ShowChangeClassMenu(LabelData label, UIElement placementTarget)
+ {
+ if (label == null || projectClasses == null || projectClasses.Count == 0)
+ return;
+
+ var menu = new ContextMenu();
+
+ foreach (var cls in projectClasses)
+ {
+ var menuItem = new MenuItem
+ {
+ Header = cls.DisplayText,
+ IsCheckable = true,
+ IsChecked = cls.ClassId == label.ClassId,
+ Icon = new System.Windows.Shapes.Rectangle
+ {
+ Width = 12,
+ Height = 12,
+ Fill = cls.ColorBrush
+ }
+ };
+
+ int targetClassId = cls.ClassId;
+ menuItem.Click += (s, args) =>
+ {
+ if (label.ClassId == targetClassId)
+ return;
+
+ label.ClassId = targetClassId;
+ uiStateManager.RefreshLabelList();
+ drawingCanvas.InvalidateVisual();
+ OnLabelsChanged();
+ MarkProjectDirty();
+ };
+
+ menu.Items.Add(menuItem);
+ }
+
+ menu.PlacementTarget = placementTarget;
+ menu.IsOpen = true;
+ }
+
private void AcceptAllSuggestions_Click(object sender, RoutedEventArgs e)
{
string currentFile = GetCurrentFileName();
@@ -2098,6 +2202,11 @@ await labelManager.ExportLabelsBatchAsync(
progress,
tokenSource.Token);
+ // Export class names file (classes.txt) so training keeps the class names.
+ // YOLO label .txt files only store numeric class ids; without this file the
+ // class names are lost.
+ ExportClassesFile(exportDirectory);
+
overlayManager.HideOverlay();
CustomMessageBox.Show(LanguageManager.Instance.GetString("Msg_LabelsExported") ?? "Labels exported successfully!", LanguageManager.Instance.GetString("Msg_ExportComplete") ?? "Export Complete",
MessageBoxButton.OK, MessageBoxImage.Information);
@@ -2116,6 +2225,54 @@ await labelManager.ExportLabelsBatchAsync(
}
}
+ ///
+ /// Writes the project's class names to disk alongside the YOLO labels.
+ /// Produces classes.txt (one name per ClassId line, indexed so line N == class N)
+ /// and a data.yaml for direct use in YOLO training.
+ ///
+ private void ExportClassesFile(string exportDirectory)
+ {
+ try
+ {
+ // Only real classes (skip the "nan"/ -1 placeholder), ordered by ClassId.
+ var classes = projectClasses
+ .Where(c => c.ClassId >= 0)
+ .OrderBy(c => c.ClassId)
+ .ToList();
+
+ if (classes.Count == 0)
+ return;
+
+ // Build a contiguous name list indexed by ClassId so that line index == id.
+ int maxClassId = classes.Max(c => c.ClassId);
+ var names = new string[maxClassId + 1];
+ for (int i = 0; i < names.Length; i++)
+ names[i] = $"class_{i}"; // placeholder for any gap in ids
+
+ foreach (var c in classes)
+ names[c.ClassId] = string.IsNullOrWhiteSpace(c.Name) ? $"class_{c.ClassId}" : c.Name.Trim();
+
+ // classes.txt
+ System.IO.File.WriteAllLines(
+ System.IO.Path.Combine(exportDirectory, "classes.txt"),
+ names);
+
+ // data.yaml (YOLO format)
+ var yaml = new System.Text.StringBuilder();
+ yaml.AppendLine($"nc: {names.Length}");
+ yaml.Append("names: [");
+ yaml.Append(string.Join(", ", names.Select(n => $"'{n.Replace("'", "''")}'")));
+ yaml.AppendLine("]");
+ System.IO.File.WriteAllText(
+ System.IO.Path.Combine(exportDirectory, "data.yaml"),
+ yaml.ToString());
+ }
+ catch (Exception ex)
+ {
+ System.Diagnostics.Debug.WriteLine($"Failed to export classes file: {ex.Message}");
+ }
+ }
+
private void RestoreImageSelection(ImageListItem previousSelection)
{
if (previousSelection == null) return;
@@ -2138,6 +2295,11 @@ private ImageStatus DetermineImageStatus(string fileName)
if (!labelManager.LabelStorage.TryGetValue(fileName, out var labels) || labels.Count == 0)
return ImageStatus.NoLabel;
+ // Imported/AI labels at or before the saved manual checkpoint have already
+ // been reviewed by the user and must stay verified after another import.
+ if (IsAtOrBeforeManualProgress(fileName))
+ return ImageStatus.Verified;
+
//if we are working on the image, don't mark it as review
string currentFile = GetCurrentFileName();
bool isCurrentImage = string.Equals(fileName, currentFile, StringComparison.OrdinalIgnoreCase);
@@ -2163,6 +2325,75 @@ private ImageStatus DetermineImageStatus(string fileName)
return ImageStatus.Verified;
}
+ public bool IsAtOrBeforeManualProgress(string fileName)
+ {
+ string? checkpoint = projectManager?.CurrentProject?.ManualProgressImageFile;
+ return !string.IsNullOrWhiteSpace(checkpoint) &&
+ StringComparer.OrdinalIgnoreCase.Compare(fileName, checkpoint) <= 0;
+ }
+
+ private async void SetManualProgress_Click(object sender, RoutedEventArgs e)
+ {
+ if (projectManager?.CurrentProject == null)
+ {
+ CustomMessageBox.Show(
+ LanguageManager.Instance.GetString("Msg_ManualProgress_ProjectRequired") ?? "Open or create a project before setting a manual progress point.",
+ LanguageManager.Instance.GetString("Msg_ManualProgress_Title") ?? "Manual Labeling Progress",
+ MessageBoxButton.OK, MessageBoxImage.Information);
+ return;
+ }
+
+ if (ImageListBox.SelectedItem is not ImageListItem selected)
+ {
+ CustomMessageBox.Show(
+ LanguageManager.Instance.GetString("Msg_ManualProgress_SelectImage") ?? "Select an image first.",
+ LanguageManager.Instance.GetString("Msg_ManualProgress_Title") ?? "Manual Labeling Progress",
+ MessageBoxButton.OK, MessageBoxImage.Information);
+ return;
+ }
+
+ projectManager.CurrentProject.ManualProgressImageFile = selected.FileName;
+ await UpdateAllImageStatusesAsync();
+ uiStateManager.RefreshAllImagesList();
+ MarkProjectDirty();
+
+ CustomMessageBox.Show(
+ string.Format(LanguageManager.Instance.GetString("Msg_ManualProgress_Set") ?? "Manual progress point set to: {0}", selected.FileName),
+ LanguageManager.Instance.GetString("Msg_ManualProgress_Title") ?? "Manual Labeling Progress",
+ MessageBoxButton.OK, MessageBoxImage.Information);
+ }
+
+ private void JumpToManualProgress_Click(object sender, RoutedEventArgs e)
+ {
+ string? checkpoint = projectManager?.CurrentProject?.ManualProgressImageFile;
+ if (string.IsNullOrWhiteSpace(checkpoint))
+ {
+ CustomMessageBox.Show(
+ LanguageManager.Instance.GetString("Msg_ManualProgress_NotSet") ?? "No manual progress point has been set yet.",
+ LanguageManager.Instance.GetString("Msg_ManualProgress_Title") ?? "Manual Labeling Progress",
+ MessageBoxButton.OK, MessageBoxImage.Information);
+ return;
+ }
+
+ // Ensure the target is visible even when a status filter is active.
+ FilterAll_Click(sender, e);
+ var target = ImageListBox.Items.Cast()
+ .FirstOrDefault(item => string.Equals(item.FileName, checkpoint, StringComparison.OrdinalIgnoreCase));
+
+ if (target == null)
+ {
+ CustomMessageBox.Show(
+ string.Format(LanguageManager.Instance.GetString("Msg_ManualProgress_Missing") ?? "The progress image is not currently loaded: {0}", checkpoint),
+ LanguageManager.Instance.GetString("Msg_ManualProgress_Title") ?? "Manual Labeling Progress",
+ MessageBoxButton.OK, MessageBoxImage.Warning);
+ return;
+ }
+
+ ImageListBox.SelectedItem = target;
+ ImageListBox.ScrollIntoView(target);
+ ImageListBox.Focus();
+ }
+
private void UpdateImageStatus(string fileName)
{
if (!imageManager.ImagePathMap.ContainsKey(fileName)) return;
@@ -2519,6 +2750,21 @@ private void Window_PreviewMouseWheel(object sender, MouseWheelEventArgs e)
return; // Let other handlers (like zoom) handle it
}
+ // If the pointer is over the class list, scroll that list instead of
+ // navigating images. The window-level Preview handler would otherwise
+ // swallow every wheel event, so we forward it to the ClassListBox here.
+ if (e.OriginalSource is DependencyObject src && IsDescendantOf(src, ClassListBox))
+ {
+ var scrollViewer = FindVisualChild(ClassListBox);
+ if (scrollViewer != null)
+ {
+ scrollViewer.ScrollToVerticalOffset(
+ scrollViewer.VerticalOffset - e.Delta / 3.0);
+ e.Handled = true;
+ }
+ return;
+ }
+
// Only navigate if we have images loaded
if (ImageListBox.Items.Count == 0)
{
@@ -2550,6 +2796,48 @@ private void Window_PreviewMouseWheel(object sender, MouseWheelEventArgs e)
}
}
}
+ // Returns true if 'node' is (or is contained within) 'ancestor' in the visual tree.
+ private static bool IsDescendantOf(DependencyObject node, DependencyObject ancestor)
+ {
+ while (node != null)
+ {
+ if (node == ancestor)
+ return true;
+
+ // VisualTreeHelper.GetParent throws for non-visual nodes such as
+ // System.Windows.Documents.Run (inline text inside a TextBlock),
+ // which can appear as e.OriginalSource. Only walk the visual tree
+ // for actual Visual/Visual3D nodes; otherwise fall back to the
+ // logical tree.
+ DependencyObject parent = null;
+ if (node is Visual || node is System.Windows.Media.Media3D.Visual3D)
+ parent = VisualTreeHelper.GetParent(node);
+
+ node = parent
+ ?? LogicalTreeHelper.GetParent(node)
+ ?? (node as FrameworkElement)?.Parent
+ ?? (node as FrameworkContentElement)?.Parent;
+ }
+ return false;
+ }
+
+ // Depth-first search for the first visual child of type T.
+ private static T FindVisualChild(DependencyObject parent) where T : DependencyObject
+ {
+ int count = VisualTreeHelper.GetChildrenCount(parent);
+ for (int i = 0; i < count; i++)
+ {
+ var child = VisualTreeHelper.GetChild(parent, i);
+ if (child is T typed)
+ return typed;
+
+ var result = FindVisualChild(child);
+ if (result != null)
+ return result;
+ }
+ return null;
+ }
+
private void SortByName_Click(object sender, RoutedEventArgs e)
{
uiStateManager.SortImagesByName();
diff --git a/YoableWPF/Managers/ImageCacheManager.cs b/YoableWPF/Managers/ImageCacheManager.cs
new file mode 100644
index 0000000..a949bcc
--- /dev/null
+++ b/YoableWPF/Managers/ImageCacheManager.cs
@@ -0,0 +1,180 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Windows.Media.Imaging;
+
+namespace YoableWPF.Managers
+{
+ ///
+ /// In-memory LRU cache of decoded (and frozen) bitmaps, with background prefetching
+ /// of neighboring images so switching images feels instant on large datasets.
+ /// All public members are thread-safe; cached bitmaps are frozen so they can be
+ /// decoded on background threads and rendered on the UI thread.
+ ///
+ public class ImageCacheManager
+ {
+ // Memory budget for cached bitmaps (estimated as width * height * 4 bytes).
+ public long MaxCacheBytes { get; set; } = 512L * 1024 * 1024;
+
+ // Hard cap on entry count regardless of size, protects against tiny-image datasets.
+ public int MaxCacheEntries { get; set; } = 512;
+
+ private class CacheEntry
+ {
+ public string Path;
+ public BitmapImage Bitmap;
+ public long SizeBytes;
+ }
+
+ private readonly object cacheLock = new();
+ private readonly Dictionary> cacheMap = new(StringComparer.OrdinalIgnoreCase);
+ private readonly LinkedList lruList = new(); // Most recently used at the front
+ private long currentCacheBytes = 0;
+
+ private CancellationTokenSource prefetchCts;
+
+ ///
+ /// Returns the cached bitmap for the given path, or null on a cache miss.
+ ///
+ public BitmapImage TryGet(string imagePath)
+ {
+ if (string.IsNullOrEmpty(imagePath)) return null;
+
+ lock (cacheLock)
+ {
+ if (cacheMap.TryGetValue(imagePath, out var node))
+ {
+ lruList.Remove(node);
+ lruList.AddFirst(node);
+ return node.Value.Bitmap;
+ }
+ }
+ return null;
+ }
+
+ ///
+ /// Returns the cached bitmap, decoding and caching it on a miss.
+ /// Safe to call from any thread. Returns null if the file cannot be decoded.
+ ///
+ public BitmapImage GetOrLoad(string imagePath)
+ {
+ var cached = TryGet(imagePath);
+ if (cached != null) return cached;
+
+ var bitmap = DecodeFrozen(imagePath);
+ if (bitmap != null)
+ {
+ Add(imagePath, bitmap);
+ }
+ return bitmap;
+ }
+
+ ///
+ /// Kicks off background decoding of the given paths (typically neighbors of the
+ /// currently displayed image). Cancels any prefetch batch still in flight.
+ ///
+ public void Prefetch(IEnumerable imagePaths)
+ {
+ var paths = imagePaths?.Where(p => !string.IsNullOrEmpty(p)).ToList();
+ if (paths == null || paths.Count == 0) return;
+
+ CancellationTokenSource cts;
+ lock (cacheLock)
+ {
+ prefetchCts?.Cancel();
+ prefetchCts = new CancellationTokenSource();
+ cts = prefetchCts;
+ }
+ var token = cts.Token;
+
+ Task.Run(() =>
+ {
+ var options = new ParallelOptions
+ {
+ CancellationToken = token,
+ MaxDegreeOfParallelism = Math.Clamp(Environment.ProcessorCount / 2, 1, 4)
+ };
+
+ try
+ {
+ Parallel.ForEach(paths, options, path =>
+ {
+ if (token.IsCancellationRequested) return;
+ if (TryGet(path) != null) return; // Already cached
+
+ var bitmap = DecodeFrozen(path);
+ if (bitmap != null && !token.IsCancellationRequested)
+ {
+ Add(path, bitmap);
+ }
+ });
+ }
+ catch (OperationCanceledException)
+ {
+ // A newer prefetch batch superseded this one
+ }
+ }, token);
+ }
+
+ public void Clear()
+ {
+ lock (cacheLock)
+ {
+ prefetchCts?.Cancel();
+ prefetchCts = null;
+ cacheMap.Clear();
+ lruList.Clear();
+ currentCacheBytes = 0;
+ }
+ }
+
+ private void Add(string imagePath, BitmapImage bitmap)
+ {
+ long sizeBytes = (long)bitmap.PixelWidth * bitmap.PixelHeight * 4;
+
+ lock (cacheLock)
+ {
+ if (cacheMap.ContainsKey(imagePath)) return;
+
+ // Evict least recently used entries until the new bitmap fits
+ while (lruList.Count > 0 &&
+ (currentCacheBytes + sizeBytes > MaxCacheBytes || lruList.Count >= MaxCacheEntries))
+ {
+ var lru = lruList.Last;
+ lruList.RemoveLast();
+ cacheMap.Remove(lru.Value.Path);
+ currentCacheBytes -= lru.Value.SizeBytes;
+ }
+
+ var entry = new CacheEntry { Path = imagePath, Bitmap = bitmap, SizeBytes = sizeBytes };
+ var node = lruList.AddFirst(entry);
+ cacheMap[imagePath] = node;
+ currentCacheBytes += sizeBytes;
+ }
+ }
+
+ private static BitmapImage DecodeFrozen(string imagePath)
+ {
+ try
+ {
+ if (!File.Exists(imagePath)) return null;
+
+ var bitmap = new BitmapImage();
+ bitmap.BeginInit();
+ bitmap.UriSource = new Uri(imagePath, UriKind.Absolute);
+ bitmap.CacheOption = BitmapCacheOption.OnLoad;
+ bitmap.CreateOptions = BitmapCreateOptions.IgnoreImageCache;
+ bitmap.EndInit();
+ bitmap.Freeze(); // Allows cross-thread use and avoids WPF cloning costs
+ return bitmap;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+ }
+}
diff --git a/YoableWPF/Managers/ImageManager.cs b/YoableWPF/Managers/ImageManager.cs
index 759aa7f..bb1b40d 100644
--- a/YoableWPF/Managers/ImageManager.cs
+++ b/YoableWPF/Managers/ImageManager.cs
@@ -18,6 +18,9 @@ public class ImageManager
private ConcurrentQueue duplicateImageFiles = new();
private string currentImagePath = "";
+ // Decoded bitmap cache shared by the canvas and prefetcher
+ public ImageCacheManager Cache { get; } = new ImageCacheManager();
+
// Add settings for performance
public int BatchSize { get; set; } = 100; // Configurable batch size for processing
@@ -62,6 +65,7 @@ public string[] LoadImagesFromDirectory(string directoryPath)
// Clear collections just like original
imagePathMap.Clear();
imageStatuses.Clear();
+ Cache.Clear();
while (duplicateImageFiles.TryDequeue(out _)) { }
foreach (string file in files)
@@ -96,14 +100,18 @@ public async Task LoadImagesFromPathsAsync(
IEnumerable files,
IProgress<(int current, int total, string message)> progress = null,
CancellationToken cancellationToken = default,
- bool enableParallelProcessing = true)
+ bool enableParallelProcessing = true,
+ bool clearExisting = true)
{
var fileArray = files?.Where(f => !string.IsNullOrWhiteSpace(f)).ToArray() ?? Array.Empty();
- // Clear collections
- imagePathMap.Clear();
- imageStatuses.Clear();
- while (duplicateImageFiles.TryDequeue(out _)) { }
+ if (clearExisting)
+ {
+ imagePathMap.Clear();
+ imageStatuses.Clear();
+ Cache.Clear();
+ while (duplicateImageFiles.TryDequeue(out _)) { }
+ }
int totalFiles = fileArray.Length;
int processedFiles = 0;
@@ -182,6 +190,27 @@ public bool AddImage(string filePath)
}
}
+ ///
+ /// Adds an image whose dimensions are already known (e.g. stored in the project file),
+ /// skipping the expensive per-file header decode. Caller is responsible for existence checks.
+ ///
+ public bool AddImageWithKnownSize(string filePath, Size dimensions)
+ {
+ string fileName = Path.GetFileName(filePath);
+
+ if (!imagePathMap.TryAdd(fileName, new ImageInfo(filePath, dimensions)))
+ {
+ if (imagePathMap.TryGetValue(fileName, out var existing) &&
+ !string.Equals(existing.Path, filePath, StringComparison.OrdinalIgnoreCase))
+ {
+ duplicateImageFiles.Enqueue($"{fileName} -> {filePath}");
+ }
+ return false;
+ }
+ imageStatuses.TryAdd(fileName, ImageStatus.NoLabel);
+ return true;
+ }
+
// Thread-safe version for parallel processing
private bool AddImageThreadSafe(string filePath)
{
@@ -243,6 +272,7 @@ public void ClearAll()
{
imagePathMap.Clear();
imageStatuses.Clear();
+ Cache.Clear();
currentImagePath = "";
while (duplicateImageFiles.TryDequeue(out _)) { }
}
diff --git a/YoableWPF/Managers/LabelManager.cs b/YoableWPF/Managers/LabelManager.cs
index 8fcf918..e75a7ac 100644
--- a/YoableWPF/Managers/LabelManager.cs
+++ b/YoableWPF/Managers/LabelManager.cs
@@ -27,15 +27,58 @@ public class LabelManager
// Track valid class IDs for orphan detection
private HashSet validClassIds = new HashSet { 0 }; // Always include default class
private int defaultClassId = 0;
+ // Until the project explicitly registers its classes, we must NOT treat any
+ // ClassId as orphaned. Otherwise labels drawn with classes 2..N get silently
+ // collapsed to the default class (0) before SetValidClassIds runs.
+ private bool validClassIdsInitialized = false;
private static readonly CultureInfo CommaCulture = CultureInfo.GetCultureInfo("de-DE"); // Comma decimal separator
+ // YOLO coordinates are commonly rounded during export. Treat sub-pixel
+ // differences as the same box so re-importing an exported dataset is idempotent.
+ private const double ImportedLabelCoordinateTolerance = 0.01;
+
+ private static bool IsSameImportedLabel(LabelData left, LabelData right)
+ {
+ return left.ClassId == right.ClassId &&
+ Math.Abs(left.Rect.X - right.Rect.X) <= ImportedLabelCoordinateTolerance &&
+ Math.Abs(left.Rect.Y - right.Rect.Y) <= ImportedLabelCoordinateTolerance &&
+ Math.Abs(left.Rect.Width - right.Rect.Width) <= ImportedLabelCoordinateTolerance &&
+ Math.Abs(left.Rect.Height - right.Rect.Height) <= ImportedLabelCoordinateTolerance;
+ }
+
+ private static List MergeImportedLabels(
+ IEnumerable existing,
+ IEnumerable imported,
+ out int addedCount)
+ {
+ var merged = new List();
+
+ // Also collapse duplicates left behind by older versions of the importer.
+ foreach (var label in existing)
+ {
+ if (!merged.Any(candidate => IsSameImportedLabel(candidate, label)))
+ merged.Add(label);
+ }
+
+ int countBeforeImport = merged.Count;
+ foreach (var label in imported)
+ {
+ if (!merged.Any(candidate => IsSameImportedLabel(candidate, label)))
+ merged.Add(label);
+ }
+
+ addedCount = merged.Count - countBeforeImport;
+ return merged;
+ }
+
///
/// Sets the valid class IDs from the project. Call this when project classes change.
///
public void SetValidClassIds(IEnumerable classIds)
{
validClassIds = new HashSet(classIds);
-
+ validClassIdsInitialized = true;
+
// Ensure we always have at least one valid class (default)
if (validClassIds.Count == 0)
{
@@ -51,6 +94,12 @@ public void SetValidClassIds(IEnumerable classIds)
///
private bool ValidateAndFixClassId(LabelData label)
{
+ // Never reassign ClassIds until the project's class list has been registered.
+ // This prevents legitimately-drawn labels (classes 2..N) from being collapsed
+ // to the default class when the valid-id set is still stale.
+ if (!validClassIdsInitialized)
+ return false;
+
if (!validClassIds.Contains(label.ClassId))
{
label.ClassId = defaultClassId;
@@ -610,7 +659,6 @@ private int LoadYoloLabelsOptimized(string labelFile, ImageManager imageManager,
new Rect(x, y, width, height),
classId);
newLabels.Add(label);
- labelsAdded++;
}
catch (FormatException)
{
@@ -622,16 +670,15 @@ private int LoadYoloLabelsOptimized(string labelFile, ImageManager imageManager,
// Add all labels at once (thread-safe)
if (newLabels.Count > 0)
{
+ int actuallyAdded = 0;
labelStorage.AddOrUpdate(
matchingImageFile,
- new List(newLabels),
+ _ => MergeImportedLabels(Array.Empty(), newLabels, out actuallyAdded),
(k, existing) =>
{
- var merged = new List(existing.Count + newLabels.Count);
- merged.AddRange(existing);
- merged.AddRange(newLabels);
- return merged;
+ return MergeImportedLabels(existing, newLabels, out actuallyAdded);
});
+ labelsAdded = actuallyAdded;
}
}
catch (Exception ex)
@@ -706,7 +753,6 @@ public int LoadYoloLabels(string labelFile, string imagePath, ImageManager image
var label = new LabelData($"Imported Label {labelCount}", new Rect(x, y, width, height), classId);
newLabels.Add(label);
- labelsAdded++;
}
catch (FormatException)
{
@@ -717,16 +763,15 @@ public int LoadYoloLabels(string labelFile, string imagePath, ImageManager image
if (newLabels.Count > 0)
{
+ int actuallyAdded = 0;
labelStorage.AddOrUpdate(
fileName,
- new List(newLabels),
+ _ => MergeImportedLabels(Array.Empty(), newLabels, out actuallyAdded),
(k, existing) =>
{
- var merged = new List(existing.Count + newLabels.Count);
- merged.AddRange(existing);
- merged.AddRange(newLabels);
- return merged;
+ return MergeImportedLabels(existing, newLabels, out actuallyAdded);
});
+ labelsAdded = actuallyAdded;
}
}
}
diff --git a/YoableWPF/Managers/LanguageManager.cs b/YoableWPF/Managers/LanguageManager.cs
index e093e35..89cb822 100644
--- a/YoableWPF/Managers/LanguageManager.cs
+++ b/YoableWPF/Managers/LanguageManager.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Globalization;
using System.Linq;
using System.Windows;
@@ -30,16 +31,67 @@ private LanguageManager()
// Load English as fallback
_fallbackDictionary = LoadLanguageResource("en-US");
- // Load saved language preference
+ // Load saved language preference; if none, auto-detect from the system UI language
var savedLanguage = Properties.Settings.Default.Language;
if (!string.IsNullOrEmpty(savedLanguage) && SupportedLanguages.Any(l => l.Code == savedLanguage))
{
_currentLanguage = savedLanguage;
}
+ else
+ {
+ _currentLanguage = DetectSystemLanguage();
+ }
LoadLanguage(_currentLanguage);
}
+ ///
+ /// Picks the best supported language for the current OS UI culture.
+ /// Chinese maps by script: Simplified (zh-Hans) -> zh-CN, Traditional (zh-Hant) -> zh-TW.
+ /// Falls back to English when the system language isn't supported.
+ ///
+ private static string DetectSystemLanguage()
+ {
+ try
+ {
+ var culture = CultureInfo.CurrentUICulture;
+
+ // Chinese: distinguish Simplified vs Traditional by script/region rather than exact code
+ if (culture.TwoLetterISOLanguageName.Equals("zh", StringComparison.OrdinalIgnoreCase))
+ {
+ var name = culture.Name; // e.g. zh-CN, zh-TW, zh-HK, zh-Hans, zh-Hant-TW
+ if (name.IndexOf("Hant", StringComparison.OrdinalIgnoreCase) >= 0 ||
+ name.IndexOf("TW", StringComparison.OrdinalIgnoreCase) >= 0 ||
+ name.IndexOf("HK", StringComparison.OrdinalIgnoreCase) >= 0 ||
+ name.IndexOf("MO", StringComparison.OrdinalIgnoreCase) >= 0)
+ {
+ return "zh-TW";
+ }
+ return "zh-CN";
+ }
+
+ // Exact match (e.g. ja-JP, ru-RU)
+ if (SupportedLanguages.Any(l => l.Code.Equals(culture.Name, StringComparison.OrdinalIgnoreCase)))
+ {
+ return SupportedLanguages.First(l => l.Code.Equals(culture.Name, StringComparison.OrdinalIgnoreCase)).Code;
+ }
+
+ // Two-letter match (e.g. system "ja" -> ja-JP, "ru" -> ru-RU)
+ var byLanguage = SupportedLanguages.FirstOrDefault(l =>
+ l.Code.StartsWith(culture.TwoLetterISOLanguageName + "-", StringComparison.OrdinalIgnoreCase));
+ if (byLanguage != null)
+ {
+ return byLanguage.Code;
+ }
+ }
+ catch (Exception ex)
+ {
+ System.Diagnostics.Debug.WriteLine($"DetectSystemLanguage failed: {ex.Message}");
+ }
+
+ return "en-US";
+ }
+
public List GetAvailableLanguages() => SupportedLanguages.ToList();
public string CurrentLanguage => _currentLanguage;
diff --git a/YoableWPF/Managers/ProjectManager.cs b/YoableWPF/Managers/ProjectManager.cs
index 48e6115..8a9da68 100644
--- a/YoableWPF/Managers/ProjectManager.cs
+++ b/YoableWPF/Managers/ProjectManager.cs
@@ -349,38 +349,45 @@ public bool SaveProjectSync()
///
/// Saves the project to a new location
///
- public bool SaveProjectAs(string newPath)
+ public async Task SaveProjectAsAsync(string newPath)
{
if (CurrentProject == null)
return false;
+ string oldProjectName = CurrentProject.ProjectName;
+ string oldProjectPath = CurrentProject.ProjectPath;
+ string oldProjectFolder = CurrentProject.ProjectFolder;
+ DateTime oldLastModified = CurrentProject.LastModified;
+
try
{
// Update project info
string newFolder = Path.GetDirectoryName(newPath);
string newName = Path.GetFileNameWithoutExtension(newPath);
- // Create new project folder structure
string newProjectFolder = Path.Combine(newFolder, newName);
- Directory.CreateDirectory(newProjectFolder);
-
string newLabelsFolder = Path.Combine(newProjectFolder, LABELS_FOLDER);
- Directory.CreateDirectory(newLabelsFolder);
-
string newBackupFolder = Path.Combine(newProjectFolder, BACKUP_FOLDER);
- Directory.CreateDirectory(newBackupFolder);
+ string oldLabelsFolder = Path.Combine(oldProjectFolder, LABELS_FOLDER);
- // Copy all label files to new location
- string oldLabelsFolder = Path.Combine(CurrentProject.ProjectFolder, LABELS_FOLDER);
- if (Directory.Exists(oldLabelsFolder))
+ // Folder creation and label copying can be slow for large
+ // projects, so keep that work off the UI thread.
+ await Task.Run(() =>
{
- foreach (string file in Directory.GetFiles(oldLabelsFolder))
+ Directory.CreateDirectory(newProjectFolder);
+ Directory.CreateDirectory(newLabelsFolder);
+ Directory.CreateDirectory(newBackupFolder);
+
+ if (Directory.Exists(oldLabelsFolder))
{
- string fileName = Path.GetFileName(file);
- string destFile = Path.Combine(newLabelsFolder, fileName);
- File.Copy(file, destFile, true);
+ foreach (string file in Directory.GetFiles(oldLabelsFolder))
+ {
+ string fileName = Path.GetFileName(file);
+ string destFile = Path.Combine(newLabelsFolder, fileName);
+ File.Copy(file, destFile, true);
+ }
}
- }
+ });
// Update project paths
CurrentProject.ProjectName = newName;
@@ -398,16 +405,27 @@ public bool SaveProjectAs(string newPath)
CurrentProject.AppCreatedLabels = updatedAppLabels;
// Save to new location
- if (SaveProjectSync())
+ if (await SaveProjectAsync())
{
AddToRecentProjects(CurrentProject.ProjectPath);
return true;
}
+ // A failed save must not leave the live project pointing at a
+ // destination where no project file was created.
+ CurrentProject.ProjectName = oldProjectName;
+ CurrentProject.ProjectPath = oldProjectPath;
+ CurrentProject.ProjectFolder = oldProjectFolder;
+ CurrentProject.LastModified = oldLastModified;
return false;
}
catch (Exception ex)
{
+ CurrentProject.ProjectName = oldProjectName;
+ CurrentProject.ProjectPath = oldProjectPath;
+ CurrentProject.ProjectFolder = oldProjectFolder;
+ CurrentProject.LastModified = oldLastModified;
+
CustomMessageBox.Show(
string.Format(LanguageManager.Instance.GetString("Msg_FailedToSaveProjectAs") ?? "Failed to save project as:\n\n{0}", ex.Message),
LanguageManager.Instance.GetString("Msg_SaveAsError") ?? "Save As Error",
@@ -591,10 +609,13 @@ private void ExportProjectDataCore(int? selectedIndex, string sortMode)
string fileName = kvp.Key;
string fullPath = kvp.Value.Path;
+ // Store dimensions so project load can skip decoding every image header
CurrentProject.Images.Add(new ImageReference
{
FileName = fileName,
- FullPath = fullPath
+ FullPath = fullPath,
+ Width = kvp.Value.OriginalDimensions.Width,
+ Height = kvp.Value.OriginalDimensions.Height
});
}
@@ -753,13 +774,18 @@ await mainWindow.Dispatcher.InvokeAsync(() =>
// Set batch size for image processing (separate from UI batching)
mainWindow.imageManager.BatchSize = processingBatchSize;
- // Build list of image paths and log missing files
- var imagePaths = new List(CurrentProject.Images.Count);
+ // Split image references: those with stored dimensions skip the expensive
+ // per-file header decode; legacy entries (0x0) fall back to decoding.
+ var knownSizeRefs = new List(CurrentProject.Images.Count);
+ var unknownSizePaths = new List();
foreach (var imageRef in CurrentProject.Images)
{
if (File.Exists(imageRef.FullPath))
{
- imagePaths.Add(imageRef.FullPath);
+ if (imageRef.Width > 0 && imageRef.Height > 0)
+ knownSizeRefs.Add(imageRef);
+ else
+ unknownSizePaths.Add(imageRef.FullPath);
}
else
{
@@ -767,19 +793,38 @@ await mainWindow.Dispatcher.InvokeAsync(() =>
}
}
- if (imagePaths.Count > 0)
+ if (knownSizeRefs.Count > 0 || unknownSizePaths.Count > 0)
{
- var imageProgress = new Progress<(int current, int total, string message)>(report =>
+ if (knownSizeRefs.Count > 0)
{
- int overallProgress = 10 + (int)((report.current / (double)report.total) * 30);
- progress?.Report((overallProgress, 100, report.message));
- });
+ await Task.Run(() =>
+ {
+ foreach (var imageRef in knownSizeRefs)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ mainWindow.imageManager.AddImageWithKnownSize(imageRef.FullPath, imageRef.GetSize());
+ }
+ }, cancellationToken);
- await mainWindow.imageManager.LoadImagesFromPathsAsync(
- imagePaths,
- imageProgress,
- cancellationToken,
- enableParallel);
+ progress?.Report((unknownSizePaths.Count > 0 ? 20 : 40, 100,
+ $"Loaded {knownSizeRefs.Count} images from project cache"));
+ }
+
+ if (unknownSizePaths.Count > 0)
+ {
+ var imageProgress = new Progress<(int current, int total, string message)>(report =>
+ {
+ int overallProgress = 10 + (int)((report.current / (double)report.total) * 30);
+ progress?.Report((overallProgress, 100, report.message));
+ });
+
+ await mainWindow.imageManager.LoadImagesFromPathsAsync(
+ unknownSizePaths,
+ imageProgress,
+ cancellationToken,
+ enableParallel,
+ clearExisting: knownSizeRefs.Count == 0);
+ }
// Warn about duplicate file names that were skipped
var duplicates = mainWindow.imageManager.ConsumeDuplicateImageFiles();
@@ -1326,7 +1371,8 @@ await Task.Run(() =>
}
else
{
- if (savedStatus == ImageStatus.Verified)
+ if (savedStatus == ImageStatus.Verified ||
+ mainWindow.IsAtOrBeforeManualProgress(fileName))
{
correctedStatus = ImageStatus.Verified;
}
diff --git a/YoableWPF/Managers/YoutubeDownloader.cs b/YoableWPF/Managers/YoutubeDownloader.cs
index 2dac635..fdba0b0 100644
--- a/YoableWPF/Managers/YoutubeDownloader.cs
+++ b/YoableWPF/Managers/YoutubeDownloader.cs
@@ -1,15 +1,30 @@
using OpenCvSharp;
using System.IO;
+using System.Net.Http;
using System.Windows;
using YoableWPF.Managers;
using YoableWPF;
using YoutubeExplode;
+using YoutubeExplode.Videos.Streams;
using Size = OpenCvSharp.Size;
using Rect = OpenCvSharp.Rect;
public class YoutubeDownloader
{
- private readonly YoutubeClient youtube = new YoutubeClient();
+ // Shared HttpClient with a browser-like User-Agent. YouTube's streaming CDN
+ // returns 403 Forbidden for some requests that don't look like they come from
+ // a real client, so we set a desktop User-Agent to reduce those rejections.
+ private static readonly HttpClient httpClient = CreateHttpClient();
+ private readonly YoutubeClient youtube = new YoutubeClient(httpClient);
+
+ private static HttpClient CreateHttpClient()
+ {
+ var client = new HttpClient();
+ client.DefaultRequestHeaders.UserAgent.ParseAdd(
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
+ "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36");
+ return client;
+ }
private MainWindow mainWindow;
private OverlayManager overlayManager;
private CancellationTokenSource downloadCancellationToken;
@@ -42,21 +57,24 @@ public async Task DownloadAndProcessVideo(string videoUrl, int desiredFps
var video = await youtube.Videos.GetAsync(videoUrl);
var streamManifest = await youtube.Videos.Streams.GetManifestAsync(videoUrl);
- // Prefer H.264 (avc1) codec for best OpenCV compatibility
- // AV1 and HEVC codecs often fail with OpenCV's bundled FFmpeg
- var streamInfo = streamManifest.GetVideoStreams()
+ // Build an ordered list of candidate streams. We try them in order and
+ // fall back to the next one if a stream URL returns 403 Forbidden
+ // (YouTube sometimes rejects individual stream URLs even when the
+ // manifest succeeds).
+ var avc1Streams = streamManifest.GetVideoStreams()
.Where(s => s.Container == YoutubeExplode.Videos.Streams.Container.Mp4)
.Where(s => s.VideoCodec.Contains("avc1", StringComparison.OrdinalIgnoreCase))
- .OrderByDescending(s => s.VideoQuality)
- .FirstOrDefault();
+ .OrderByDescending(s => s.VideoQuality);
- // Fall back to any MP4 stream if no H.264 available
- streamInfo ??= streamManifest.GetVideoStreams()
+ // Then any remaining MP4 streams (non-avc1) as a fallback.
+ var otherMp4Streams = streamManifest.GetVideoStreams()
.Where(s => s.Container == YoutubeExplode.Videos.Streams.Container.Mp4)
- .OrderByDescending(s => s.VideoQuality)
- .FirstOrDefault();
+ .Where(s => !s.VideoCodec.Contains("avc1", StringComparison.OrdinalIgnoreCase))
+ .OrderByDescending(s => s.VideoQuality);
- if (streamInfo == null)
+ var candidateStreams = avc1Streams.Concat(otherMp4Streams).ToList();
+
+ if (candidateStreams.Count == 0)
{
CustomMessageBox.Show(string.Format(LanguageManager.Instance.GetString("Msg_YouTube_NoCompatibleStream") ?? "No compatible video streams found for {0}", video.Title),
LanguageManager.Instance.GetString("Msg_YouTube_DownloadError") ?? "Download Error", MessageBoxButton.OK, MessageBoxImage.Error);
@@ -69,25 +87,57 @@ public async Task DownloadAndProcessVideo(string videoUrl, int desiredFps
Directory.CreateDirectory(Path.Combine(videoDirectory, "frames"));
videoPath = Path.Combine(videoDirectory, $"{video.Id}.mp4");
- long totalBytes = streamInfo.Size.Bytes;
- long downloadedBytes = 0;
- var progress = new Progress(p =>
+ // Try each candidate stream until one downloads successfully.
+ HttpRequestException lastDownloadError = null;
+ bool downloaded = false;
+
+ for (int i = 0; i < candidateStreams.Count; i++)
{
- if (!isDownloading) return;
- downloadProgress = p * 100;
- downloadedBytes = (long)(totalBytes * p);
+ IStreamInfo streamInfo = candidateStreams[i];
+ long totalBytes = streamInfo.Size.Bytes;
+ long downloadedBytes = 0;
+ isDownloading = true;
- mainWindow.Dispatcher.Invoke(() =>
+ var progress = new Progress(p =>
{
- overlayManager.UpdateMessage($"Downloading {video.Title}... {downloadProgress:F2}% ({FormatFileSize(downloadedBytes)} / {FormatFileSize(totalBytes)})");
- overlayManager.UpdateProgress((int)downloadProgress);
+ if (!isDownloading) return;
+ downloadProgress = p * 100;
+ downloadedBytes = (long)(totalBytes * p);
+
+ mainWindow.Dispatcher.Invoke(() =>
+ {
+ overlayManager.UpdateMessage($"Downloading {video.Title}... {downloadProgress:F2}% ({FormatFileSize(downloadedBytes)} / {FormatFileSize(totalBytes)})");
+ overlayManager.UpdateProgress((int)downloadProgress);
+ });
});
- });
- await youtube.Videos.Streams.DownloadAsync(streamInfo, videoPath, progress, downloadCancellationToken.Token);
+ try
+ {
+ await youtube.Videos.Streams.DownloadAsync(streamInfo, videoPath, progress, downloadCancellationToken.Token);
+ downloaded = true;
+ break;
+ }
+ catch (HttpRequestException ex)
+ {
+ // 403 Forbidden (or similar) on this stream URL: try the next candidate.
+ lastDownloadError = ex;
+ mainWindow.Dispatcher.Invoke(() =>
+ {
+ overlayManager.UpdateMessage($"Stream failed, trying alternative ({i + 1}/{candidateStreams.Count})...");
+ overlayManager.UpdateProgress(0);
+ });
+ }
+ }
+
isDownloading = false;
+ if (!downloaded)
+ {
+ // All candidate streams failed. Surface the underlying error.
+ throw lastDownloadError ?? new Exception("All video streams failed to download.");
+ }
+
mainWindow.Dispatcher.Invoke(() => {
overlayManager.UpdateMessage("Preparing to extract frames...");
overlayManager.UpdateProgress(0);
diff --git a/YoableWPF/Models/ProjectData.cs b/YoableWPF/Models/ProjectData.cs
index 8f836e4..337ac48 100644
--- a/YoableWPF/Models/ProjectData.cs
+++ b/YoableWPF/Models/ProjectData.cs
@@ -56,6 +56,8 @@ public int GetNextClassId()
// UI State
public Dictionary ImageStatuses { get; set; } = new Dictionary();
public int LastSelectedImageIndex { get; set; } = -1;
+ // Inclusive filename checkpoint for the user's manual labeling progress.
+ public string ManualProgressImageFile { get; set; } = string.Empty;
public string CurrentSortMode { get; set; } = "ByName";
public string CurrentFilterMode { get; set; } = "All";
diff --git a/YoableWPF/NewProjectDialog.xaml b/YoableWPF/NewProjectDialog.xaml
index aa935df..62f5921 100644
--- a/YoableWPF/NewProjectDialog.xaml
+++ b/YoableWPF/NewProjectDialog.xaml
@@ -49,12 +49,8 @@