From 5a5664d90bd81c0f1b5310b337b680a33537e7a3 Mon Sep 17 00:00:00 2001 From: asenyeroao-ct Date: Tue, 23 Jun 2026 21:39:31 +0800 Subject: [PATCH 1/6] 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/6] 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/6] 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/6] 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 @@