From 5a5664d90bd81c0f1b5310b337b680a33537e7a3 Mon Sep 17 00:00:00 2001 From: asenyeroao-ct Date: Tue, 23 Jun 2026 21:39:31 +0800 Subject: [PATCH 1/9] Add automatic UI language detection based on system locale On first launch (no saved language preference), detect the OS UI culture and pick the best supported language. Chinese maps by script: Traditional (zh-Hant/zh-TW/zh-HK/zh-MO) -> zh-TW, Simplified -> zh-CN. Falls back to English when the system language isn't supported. User's manual selection is still respected and never overridden. Co-Authored-By: Claude Opus 4.8 --- YoableWPF/Managers/LanguageManager.cs | 54 ++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) 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; From 11029bbde9ce0b82a1e7c695fb0fb296f6da56c6 Mon Sep 17 00:00:00 2001 From: asenyeroao-ct Date: Tue, 23 Jun 2026 21:42:11 +0800 Subject: [PATCH 2/9] Ignore release packages (zip/rar/7z) in git Co-Authored-By: Claude Opus 4.8 --- .gitignore | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.gitignore b/.gitignore index af7bdaf..381045c 100644 --- a/.gitignore +++ b/.gitignore @@ -362,3 +362,9 @@ MigrationBackup/ # Fody - auto-generated XML schema FodyWeavers.xsd /.claude + +# Release packages / build artifacts (not tracked in git) +Releases/ +*.zip +*.rar +*.7z From 2fbe1e4e56d0533aacede3ba8816652c48777b7e Mon Sep 17 00:00:00 2001 From: asenyeroao-ct Date: Tue, 23 Jun 2026 21:43:38 +0800 Subject: [PATCH 3/9] Use dist/ folder for release packages and ignore it Co-Authored-By: Claude Opus 4.8 --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 381045c..79ec79b 100644 --- a/.gitignore +++ b/.gitignore @@ -365,6 +365,7 @@ FodyWeavers.xsd # Release packages / build artifacts (not tracked in git) Releases/ +dist/ *.zip *.rar *.7z From c2c5fdded737cb6a767dae603d246bd4cbfccb77 Mon Sep 17 00:00:00 2001 From: asenyeroao-ct Date: Fri, 26 Jun 2026 02:47:34 +0800 Subject: [PATCH 4/9] Fix label exports and YouTube downloads --- YoableWPF/MainWindow.xaml.cs | 97 +++++++++++++++++++++++++ YoableWPF/Managers/LabelManager.cs | 13 +++- YoableWPF/Managers/YoutubeDownloader.cs | 94 ++++++++++++++++++------ YoableWPF/NewProjectDialog.xaml | 32 ++------ YoableWPF/YoableWPF.csproj | 2 +- 5 files changed, 190 insertions(+), 48 deletions(-) diff --git a/YoableWPF/MainWindow.xaml.cs b/YoableWPF/MainWindow.xaml.cs index a21fa71..6d0cf43 100644 --- a/YoableWPF/MainWindow.xaml.cs +++ b/YoableWPF/MainWindow.xaml.cs @@ -2098,6 +2098,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 +2121,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; @@ -2519,6 +2572,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 +2618,35 @@ 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; + node = VisualTreeHelper.GetParent(node) ?? (node as FrameworkElement)?.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/LabelManager.cs b/YoableWPF/Managers/LabelManager.cs index 8fcf918..b5fb70e 100644 --- a/YoableWPF/Managers/LabelManager.cs +++ b/YoableWPF/Managers/LabelManager.cs @@ -27,6 +27,10 @@ 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 /// @@ -35,7 +39,8 @@ public class LabelManager 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 +56,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; 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/NewProjectDialog.xaml b/YoableWPF/NewProjectDialog.xaml index aa935df..62f5921 100644 --- a/YoableWPF/NewProjectDialog.xaml +++ b/YoableWPF/NewProjectDialog.xaml @@ -49,12 +49,8 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/YoableWPF/DuplicateImageReviewControl.xaml.cs b/YoableWPF/DuplicateImageReviewControl.xaml.cs new file mode 100644 index 0000000..09025c3 --- /dev/null +++ b/YoableWPF/DuplicateImageReviewControl.xaml.cs @@ -0,0 +1,527 @@ +using System.Windows; +using System.Windows.Controls; +using YoableWPF.Managers; +using YoableWPF.Models; + +namespace YoableWPF +{ + public partial class DuplicateImageReviewControl : UserControl + { + private readonly DuplicateImageDetector detector = new(); + private readonly List duplicateGroups = new(); + private ImageManager? imageManager; + private LabelManager? labelManager; + private Func>? getProjectClasses; + private Func? removeImage; + private Func? openImageForEditing; + private CancellationTokenSource? scanCancellation; + private int currentGroupIndex; + private bool hasScanned; + private int lastImageSignature; + private bool isUpdatingGroupSelection; + private bool isResolvingDuplicate; + private bool isGroupListDirty = true; + private int lastAutoResolvedCount; + + public DuplicateImageReviewControl() + { + InitializeComponent(); + } + + public void Initialize( + ImageManager manager, + LabelManager labels, + Func removeImageCallback, + Func openImageForEditingCallback, + Func> projectClassesProvider) + { + imageManager = manager; + labelManager = labels; + removeImage = removeImageCallback; + openImageForEditing = openImageForEditingCallback; + getProjectClasses = projectClassesProvider; + } + + public async Task EnsureScannedAsync() + { + if (!hasScanned || lastImageSignature != CalculateImageSignature()) + await ScanAsync(); + else if (duplicateGroups.Count > 0) + await ShowCurrentPairAsync(); + } + + public void InvalidateResults() + { + hasScanned = false; + duplicateGroups.Clear(); + DuplicateGroupListBox.Items.Clear(); + isGroupListDirty = true; + currentGroupIndex = 0; + lastAutoResolvedCount = 0; + ReviewPanel.Visibility = Visibility.Collapsed; + EmptyPanel.Visibility = Visibility.Visible; + EmptyText.Text = GetString("Duplicate_NoScan", "Scan the current project for duplicate images."); + SummaryText.Text = ""; + } + + private async void ScanButton_Click(object sender, RoutedEventArgs e) + { + await ScanAsync(); + } + + private async Task ScanAsync() + { + if (imageManager == null || labelManager == null) + return; + + scanCancellation?.Cancel(); + scanCancellation?.Dispose(); + scanCancellation = new CancellationTokenSource(); + var cancellationToken = scanCancellation.Token; + + ScanButton.IsEnabled = false; + ReviewPanel.Visibility = Visibility.Collapsed; + EmptyPanel.Visibility = Visibility.Visible; + EmptyText.Text = GetString("Duplicate_Scanning", "Scanning images..."); + ScanProgressBar.Visibility = Visibility.Visible; + ScanProgressBar.Value = 0; + lastAutoResolvedCount = 0; + + var snapshot = imageManager.ImagePathMap.ToArray(); + if (snapshot.Length < 2) + { + FinishEmptyScan("Duplicate_NoImages", "Add at least two images before scanning."); + return; + } + + var progress = new Progress<(int current, int total)>(value => + { + ScanProgressBar.Maximum = Math.Max(1, value.total); + ScanProgressBar.Value = value.current; + }); + + try + { + var results = await detector.FindDuplicatesAsync(snapshot, progress, cancellationToken); + duplicateGroups.Clear(); + duplicateGroups.AddRange(results); + lastAutoResolvedCount = AutoResolveMatchingLabelGroups(); + isGroupListDirty = true; + currentGroupIndex = 0; + hasScanned = true; + lastImageSignature = CalculateImageSignature(); + + if (duplicateGroups.Count == 0) + { + if (lastAutoResolvedCount > 0) + { + FinishEmptyScan( + "Duplicate_AutoResolvedOnly", + "Duplicate images with identical labels were automatically resolved."); + EmptyText.Text = string.Format( + GetString( + "Duplicate_AutoResolvedOnly", + "Automatically removed {0} duplicate image(s) with identical labels from the project."), + lastAutoResolvedCount); + } + else + { + FinishEmptyScan("Duplicate_NoMatches", "No duplicate images found."); + } + return; + } + + ScanProgressBar.Visibility = Visibility.Collapsed; + ScanButton.IsEnabled = true; + EmptyPanel.Visibility = Visibility.Collapsed; + ReviewPanel.Visibility = Visibility.Visible; + await ShowCurrentPairAsync(); + } + catch (OperationCanceledException) + { + ScanButton.IsEnabled = true; + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Duplicate scan failed: {ex.Message}"); + FinishEmptyScan("Duplicate_ScanFailed", "Could not scan duplicate images."); + } + } + + private void FinishEmptyScan(string resourceKey, string fallback) + { + hasScanned = true; + ScanProgressBar.Visibility = Visibility.Collapsed; + ScanButton.IsEnabled = true; + ReviewPanel.Visibility = Visibility.Collapsed; + EmptyPanel.Visibility = Visibility.Visible; + EmptyText.Text = GetString(resourceKey, fallback); + SummaryText.Text = ""; + DuplicateGroupListBox.Items.Clear(); + } + + private async Task ShowCurrentPairAsync() + { + if (imageManager == null || duplicateGroups.Count == 0) + { + FinishEmptyScan("Duplicate_Resolved", "All duplicate images have been resolved."); + return; + } + + var group = duplicateGroups[currentGroupIndex]; + var left = group.Candidates[0]; + var right = group.Candidates[1]; + int remainingImages = duplicateGroups.Sum(item => item.Candidates.Count - 1); + + string summary = string.Format( + GetString("Duplicate_Summary", "{0} duplicate group(s), {1} extra image(s) remaining"), + duplicateGroups.Count, + remainingImages); + if (lastAutoResolvedCount > 0) + { + summary += " | " + string.Format( + GetString( + "Duplicate_AutoResolvedSummary", + "Automatically resolved: {0}"), + lastAutoResolvedCount); + } + SummaryText.Text = summary; + GroupProgressText.Text = string.Format( + GetString("Duplicate_GroupProgress", "Group {0} of {1}"), + Math.Min(currentGroupIndex + 1, duplicateGroups.Count), + duplicateGroups.Count); + RefreshGroupList(); + + LeftFileNameText.Text = left.FileName; + RightFileNameText.Text = right.FileName; + var leftLabels = labelManager?.GetLabels(left.FileName) ?? new List(); + var rightLabels = labelManager?.GetLabels(right.FileName) ?? new List(); + LeftDetailsText.Text = BuildDetails(left, leftLabels.Count); + RightDetailsText.Text = BuildDetails(right, rightLabels.Count); + + int generation = Environment.TickCount; + Tag = generation; + var leftTask = Task.Run(() => imageManager.Cache.GetOrLoad(left.FullPath)); + var rightTask = Task.Run(() => imageManager.Cache.GetOrLoad(right.FullPath)); + await Task.WhenAll(leftTask, rightTask); + + if (Tag is int activeGeneration && activeGeneration == generation) + { + var projectClasses = getProjectClasses?.Invoke() ?? Array.Empty(); + LeftImage.SetContent(leftTask.Result, leftLabels, projectClasses); + RightImage.SetContent(rightTask.Result, rightLabels, projectClasses); + } + } + + private string BuildDetails(DuplicateImageCandidate candidate, int labelCount) + { + string labels = string.Format( + GetString("Duplicate_LabelCount", "{0} label(s)"), + labelCount); + return $"{labels} | {FormatFileSize(candidate.FileSize)}"; + } + + private static string FormatFileSize(long bytes) + { + if (bytes >= 1024 * 1024) + return $"{bytes / (1024d * 1024d):0.##} MB"; + if (bytes >= 1024) + return $"{bytes / 1024d:0.##} KB"; + return $"{bytes} B"; + } + + private async void KeepLeftButton_Click(object sender, RoutedEventArgs e) + { + await KeepCandidateAsync(keepLeft: true); + } + + private async void KeepRightButton_Click(object sender, RoutedEventArgs e) + { + await KeepCandidateAsync(keepLeft: false); + } + + private void EditLeftButton_Click(object sender, RoutedEventArgs e) + { + OpenCandidateForEditing(candidateIndex: 0); + } + + private void EditRightButton_Click(object sender, RoutedEventArgs e) + { + OpenCandidateForEditing(candidateIndex: 1); + } + + private void OpenCandidateForEditing(int candidateIndex) + { + if (openImageForEditing == null || + duplicateGroups.Count == 0 || + candidateIndex < 0 || + candidateIndex >= duplicateGroups[currentGroupIndex].Candidates.Count) + return; + + DuplicateImageCandidate candidate = + duplicateGroups[currentGroupIndex].Candidates[candidateIndex]; + if (!openImageForEditing(candidate.FileName)) + { + CustomMessageBox.Show( + GetString( + "Duplicate_OpenEditFailed", + "Could not open the image for editing."), + GetString("Duplicate_Title", "Duplicate Images"), + MessageBoxButton.OK, + MessageBoxImage.Warning); + } + } + + private async void PreviousGroupButton_Click(object sender, RoutedEventArgs e) + { + if (currentGroupIndex <= 0) + return; + + currentGroupIndex--; + await ShowCurrentPairAsync(); + } + + private async void NextGroupButton_Click(object sender, RoutedEventArgs e) + { + if (currentGroupIndex >= duplicateGroups.Count - 1) + return; + + currentGroupIndex++; + await ShowCurrentPairAsync(); + } + + private async void DuplicateGroupListBox_SelectionChanged( + object sender, + SelectionChangedEventArgs e) + { + if (isUpdatingGroupSelection || + DuplicateGroupListBox.SelectedItem is not DuplicateGroupListItem selected || + selected.GroupIndex == currentGroupIndex) + { + return; + } + + currentGroupIndex = selected.GroupIndex; + await ShowCurrentPairAsync(); + } + + private async Task KeepCandidateAsync(bool keepLeft) + { + if (removeImage == null || duplicateGroups.Count == 0 || isResolvingDuplicate) + return; + + isResolvingDuplicate = true; + KeepLeftButton.IsEnabled = false; + KeepRightButton.IsEnabled = false; + + try + { + var group = duplicateGroups[currentGroupIndex]; + int removeIndex = keepLeft ? 1 : 0; + var candidate = group.Candidates[removeIndex]; + + if (!removeImage(candidate.FileName)) + { + CustomMessageBox.Show( + GetString("Duplicate_RemoveFailed", "The duplicate image could not be removed from the project."), + GetString("Duplicate_Title", "Duplicate Images"), + MessageBoxButton.OK, + MessageBoxImage.Warning); + return; + } + + lastImageSignature = CalculateImageSignature(); + + group.Candidates.RemoveAt(removeIndex); + isGroupListDirty = true; + if (group.Candidates.Count < 2) + { + duplicateGroups.RemoveAt(currentGroupIndex); + if (currentGroupIndex >= duplicateGroups.Count) + currentGroupIndex = Math.Max(0, duplicateGroups.Count - 1); + } + + if (duplicateGroups.Count == 0) + { + LeftImage.Clear(); + RightImage.Clear(); + FinishEmptyScan("Duplicate_Resolved", "All duplicate images have been resolved."); + return; + } + + await ShowCurrentPairAsync(); + } + finally + { + isResolvingDuplicate = false; + KeepLeftButton.IsEnabled = true; + KeepRightButton.IsEnabled = true; + } + } + + private void RefreshGroupList() + { + isUpdatingGroupSelection = true; + try + { + if (isGroupListDirty || DuplicateGroupListBox.Items.Count != duplicateGroups.Count) + { + DuplicateGroupListBox.Items.Clear(); + for (int index = 0; index < duplicateGroups.Count; index++) + { + var group = duplicateGroups[index]; + var left = group.Candidates[0]; + var right = group.Candidates[1]; + int leftLabelCount = labelManager?.GetLabels(left.FileName).Count ?? 0; + int rightLabelCount = labelManager?.GetLabels(right.FileName).Count ?? 0; + string leftCountText = string.Format( + GetString("Duplicate_LabelCount", "{0} label(s)"), + leftLabelCount); + string rightCountText = string.Format( + GetString("Duplicate_LabelCount", "{0} label(s)"), + rightLabelCount); + string groupTitle = string.Format( + GetString("Duplicate_GroupTitle", "Group {0}"), + index + 1); + string extraCount = string.Format( + GetString("Duplicate_ExtraCount", "{0} extra image(s)"), + group.Candidates.Count - 1); + DuplicateGroupListBox.Items.Add(new DuplicateGroupListItem( + index, + $"{groupTitle} | {extraCount}", + left.FullPath, + right.FullPath, + $"{left.FileName} ({leftLabelCount})", + $"{right.FileName} ({rightLabelCount})", + $"{left.FileName} - {leftCountText}", + $"{right.FileName} - {rightCountText}")); + } + + isGroupListDirty = false; + } + + DuplicateGroupListBox.SelectedIndex = currentGroupIndex; + if (DuplicateGroupListBox.SelectedItem != null) + DuplicateGroupListBox.ScrollIntoView(DuplicateGroupListBox.SelectedItem); + } + finally + { + isUpdatingGroupSelection = false; + } + + PreviousGroupButton.IsEnabled = currentGroupIndex > 0; + NextGroupButton.IsEnabled = currentGroupIndex < duplicateGroups.Count - 1; + } + + private int AutoResolveMatchingLabelGroups() + { + if (labelManager == null || removeImage == null) + return 0; + + int removedCount = 0; + foreach (DuplicateImageGroup group in duplicateGroups.ToArray()) + { + var candidates = group.Candidates.ToArray(); + for (int keepIndex = 0; keepIndex < candidates.Length; keepIndex++) + { + DuplicateImageCandidate keepCandidate = candidates[keepIndex]; + if (!group.Candidates.Contains(keepCandidate)) + continue; + + List keepLabels = labelManager.GetLabels(keepCandidate.FileName); + for (int compareIndex = keepIndex + 1; + compareIndex < candidates.Length; + compareIndex++) + { + DuplicateImageCandidate compareCandidate = candidates[compareIndex]; + if (!group.Candidates.Contains(compareCandidate)) + continue; + + List compareLabels = labelManager.GetLabels(compareCandidate.FileName); + if (!HaveEquivalentLabels(keepLabels, compareLabels)) + continue; + + if (removeImage(compareCandidate.FileName)) + { + group.Candidates.Remove(compareCandidate); + removedCount++; + } + } + } + } + + duplicateGroups.RemoveAll(group => group.Candidates.Count < 2); + return removedCount; + } + + private static bool HaveEquivalentLabels( + IReadOnlyCollection leftLabels, + IReadOnlyCollection rightLabels) + { + const double coordinateTolerance = 0.01; + if (leftLabels.Count != rightLabels.Count) + return false; + + var left = leftLabels + .OrderBy(label => label.ClassId) + .ThenBy(label => label.Rect.X) + .ThenBy(label => label.Rect.Y) + .ThenBy(label => label.Rect.Width) + .ThenBy(label => label.Rect.Height) + .ToArray(); + var right = rightLabels + .OrderBy(label => label.ClassId) + .ThenBy(label => label.Rect.X) + .ThenBy(label => label.Rect.Y) + .ThenBy(label => label.Rect.Width) + .ThenBy(label => label.Rect.Height) + .ToArray(); + + for (int index = 0; index < left.Length; index++) + { + if (left[index].ClassId != right[index].ClassId || + Math.Abs(left[index].Rect.X - right[index].Rect.X) > coordinateTolerance || + Math.Abs(left[index].Rect.Y - right[index].Rect.Y) > coordinateTolerance || + Math.Abs(left[index].Rect.Width - right[index].Rect.Width) > coordinateTolerance || + Math.Abs(left[index].Rect.Height - right[index].Rect.Height) > coordinateTolerance) + { + return false; + } + } + + return true; + } + + private static string GetString(string key, string fallback) + { + return LanguageManager.Instance.GetString(key) ?? fallback; + } + + private int CalculateImageSignature() + { + if (imageManager == null) + return 0; + + var hash = new HashCode(); + foreach (var pair in imageManager.ImagePathMap.OrderBy( + item => item.Key, + StringComparer.OrdinalIgnoreCase)) + { + hash.Add(pair.Key, StringComparer.OrdinalIgnoreCase); + hash.Add(pair.Value.FileLength); + hash.Add(pair.Value.LastWriteTimeUtcTicks); + } + return hash.ToHashCode(); + } + } + + internal sealed record DuplicateGroupListItem( + int GroupIndex, + string HeaderText, + string LeftFullPath, + string RightFullPath, + string LeftCaption, + string RightCaption, + string LeftToolTip, + string RightToolTip); +} diff --git a/YoableWPF/DuplicateLabelPreview.cs b/YoableWPF/DuplicateLabelPreview.cs new file mode 100644 index 0000000..e1a0e98 --- /dev/null +++ b/YoableWPF/DuplicateLabelPreview.cs @@ -0,0 +1,154 @@ +using System.Globalization; +using System.IO; +using System.Windows; +using System.Windows.Data; +using System.Windows.Media; +using System.Windows.Media.Imaging; + +namespace YoableWPF +{ + public sealed class DuplicateLabelPreview : FrameworkElement + { + private ImageSource? image; + private IReadOnlyList labels = Array.Empty(); + private IReadOnlyDictionary classes = + new Dictionary(); + + public DuplicateLabelPreview() + { + SnapsToDevicePixels = true; + UseLayoutRounding = true; + } + + public void SetContent( + ImageSource? imageSource, + IReadOnlyList? imageLabels, + IReadOnlyList? projectClasses) + { + image = imageSource; + labels = imageLabels ?? Array.Empty(); + classes = (projectClasses ?? Array.Empty()) + .GroupBy(item => item.ClassId) + .ToDictionary(group => group.Key, group => group.First()); + InvalidateVisual(); + } + + public void Clear() + { + image = null; + labels = Array.Empty(); + classes = new Dictionary(); + InvalidateVisual(); + } + + protected override void OnRender(DrawingContext drawingContext) + { + base.OnRender(drawingContext); + drawingContext.DrawRectangle(Brushes.Black, null, new Rect(RenderSize)); + + if (image == null || image.Width <= 0 || image.Height <= 0 || + ActualWidth <= 0 || ActualHeight <= 0) + { + return; + } + + double scale = Math.Min(ActualWidth / image.Width, ActualHeight / image.Height); + double renderedWidth = image.Width * scale; + double renderedHeight = image.Height * scale; + double offsetX = (ActualWidth - renderedWidth) / 2; + double offsetY = (ActualHeight - renderedHeight) / 2; + var imageRect = new Rect(offsetX, offsetY, renderedWidth, renderedHeight); + + drawingContext.DrawImage(image, imageRect); + drawingContext.PushClip(new RectangleGeometry(imageRect)); + + var imageBounds = new Rect(0, 0, image.Width, image.Height); + double pixelsPerDip = VisualTreeHelper.GetDpi(this).PixelsPerDip; + double lineThickness = Math.Clamp(scale * 2, 1.5, 3.5); + + foreach (var label in labels) + { + Rect clippedLabel = Rect.Intersect(label.Rect, imageBounds); + if (clippedLabel.IsEmpty || clippedLabel.Width <= 0 || clippedLabel.Height <= 0) + continue; + + var color = classes.TryGetValue(label.ClassId, out var labelClass) + ? labelClass.ColorBrush.Color + : Colors.LightCoral; + color.A = 245; + var brush = new SolidColorBrush(color); + brush.Freeze(); + + var displayRect = new Rect( + offsetX + clippedLabel.X * scale, + offsetY + clippedLabel.Y * scale, + clippedLabel.Width * scale, + clippedLabel.Height * scale); + drawingContext.DrawRectangle(null, new Pen(brush, lineThickness), displayRect); + + string className = labelClass?.Name ?? $"Class {label.ClassId}"; + var text = new FormattedText( + className, + CultureInfo.CurrentUICulture, + FlowDirection.LeftToRight, + new Typeface("Segoe UI Semibold"), + 11, + Brushes.White, + pixelsPerDip) + { + MaxTextWidth = Math.Max(1, Math.Min(180, displayRect.Width)) + }; + + double textX = displayRect.Left; + double textY = Math.Max(imageRect.Top, displayRect.Top - text.Height - 3); + var textBackground = new SolidColorBrush(Color.FromArgb(220, color.R, color.G, color.B)); + textBackground.Freeze(); + drawingContext.DrawRectangle( + textBackground, + null, + new Rect(textX, textY, text.Width + 6, text.Height + 2)); + drawingContext.DrawText(text, new Point(textX + 3, textY + 1)); + } + + drawingContext.Pop(); + } + } + + public sealed class DuplicateThumbnailConverter : IValueConverter + { + public object? Convert( + object value, + Type targetType, + object parameter, + CultureInfo culture) + { + if (value is not string path || !File.Exists(path)) + return null; + + try + { + var thumbnail = new BitmapImage(); + thumbnail.BeginInit(); + thumbnail.CacheOption = BitmapCacheOption.OnLoad; + thumbnail.DecodePixelWidth = 180; + thumbnail.UriSource = new Uri(path, UriKind.Absolute); + thumbnail.EndInit(); + thumbnail.Freeze(); + return thumbnail; + } + catch + { + return null; + } + } + + public object ConvertBack( + object value, + Type targetType, + object parameter, + CultureInfo culture) + { + throw new NotSupportedException(); + } + } +} diff --git a/YoableWPF/MainWindow.xaml b/YoableWPF/MainWindow.xaml index b2cd6fa..ef88700 100644 --- a/YoableWPF/MainWindow.xaml +++ b/YoableWPF/MainWindow.xaml @@ -231,6 +231,16 @@ + + + + + + + + + + @@ -249,6 +259,11 @@ + + + + + @@ -267,8 +282,20 @@ + + + + + + + + + - @@ -428,6 +455,18 @@ Background="#4481C784" Foreground="#81C784" BorderThickness="0"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -317,11 +315,16 @@ - + + @@ -338,7 +341,7 @@ + + @@ -373,7 +378,7 @@ + BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"> + + + + +