From 886b2929605aa15ce3d97160013126ee4fa603f7 Mon Sep 17 00:00:00 2001 From: niksedk Date: Wed, 8 Jul 2026 19:56:56 +0200 Subject: [PATCH] Video OCR: test current frame, editable results, double-click seek Three quality-of-life improvements for the burn-in / video OCR window: - "Test current frame" button in the scan-area row: OCRs only the frame at the current preview position with the selected engine and shows the result in the status text - so scan area, engine choice, and brightness can be validated in seconds instead of scanning the whole video. The engine dispatch is factored out of RunOcr into OcrGroups() and shared by both paths; the test frame is extracted with a fast demuxer seek (-ss before -i). - Editable results: the text column in the result grid is now editable so OCR mistakes can be fixed in place, and Delete removes the selected lines (with renumbering) before accepting with OK. - Double-clicking a result line seeks the video preview to that line's start time, making it easy to check what the OCR actually saw. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/ui/Assets/Languages/English.json | 6 +- .../Video/VideoOcr/VideoOcrViewModel.cs | 167 +++++++++++++++++- .../Features/Video/VideoOcr/VideoOcrWindow.cs | 27 ++- .../Config/Language/Video/LanguageVideoOcr.cs | 8 + 4 files changed, 200 insertions(+), 8 deletions(-) diff --git a/src/ui/Assets/Languages/English.json b/src/ui/Assets/Languages/English.json index 3d21e4d417..7ec8084d00 100644 --- a/src/ui/Assets/Languages/English.json +++ b/src/ui/Assets/Languages/English.json @@ -2170,7 +2170,11 @@ "unableToReadVideoTitle": "Unable to read video", "unableToReadVideoMessage": "Could not read video info from: {0}", "abortOcrTitle": "Abort OCR?", - "abortOcrMessage": "Do you want to abort the running OCR?" + "abortOcrMessage": "Do you want to abort the running OCR?", + "testOcr": "Test current frame", + "testOcrRunning": "Testing OCR on current frame...", + "testOcrResultX": "Test result: {0}", + "testOcrNoTextFound": "Test: no text found in the scan area" }, "goToVideoPosition": "Go to video position", "generateBlankVideoDotDotDot": "Generate blank video...", diff --git a/src/ui/Features/Video/VideoOcr/VideoOcrViewModel.cs b/src/ui/Features/Video/VideoOcr/VideoOcrViewModel.cs index 3facc8e48e..0f39ac986c 100644 --- a/src/ui/Features/Video/VideoOcr/VideoOcrViewModel.cs +++ b/src/ui/Features/Video/VideoOcr/VideoOcrViewModel.cs @@ -428,6 +428,126 @@ private void SetScanAreaFullFrame() CropSelector?.SetSelectionVideoRect(0, 0, VideoWidth, VideoHeight); } + /// + /// OCRs only the frame at the current preview position so the user can validate the scan + /// area, engine, and settings without scanning the whole video. The result is shown in the + /// status text. + /// + [RelayCommand] + private async Task TestOcr() + { + if (IsRunning || string.IsNullOrEmpty(_videoFileName) || VideoWidth <= 0 || VideoHeight <= 0) + { + return; + } + + var engineOk = await EnsureEngineIsAvailable(); + if (!engineOk) + { + return; + } + + ClampSelection(); + + _cancellationTokenSource = new CancellationTokenSource(); + var cancellationToken = _cancellationTokenSource.Token; + + IsRunning = true; + ProgressText = Se.Language.Video.VideoOcr.TestOcrRunning; + + var frameFileName = Path.Combine(Path.GetTempPath(), "se_video_ocr_test_" + Guid.NewGuid() + ".jpg"); + try + { + await ExtractSingleFrame(frameFileName, PreviewPositionSeconds, cancellationToken); + if (!File.Exists(frameFileName) || new FileInfo(frameFileName).Length == 0) + { + throw new Exception("Could not extract the current frame - see log for the ffmpeg command line."); + } + + var group = new VideoOcrFrameGroup { RepresentativeFileName = frameFileName }; + await OcrGroups(new List { group }, () => { }, _ => { }, cancellationToken); + + ProgressText = string.IsNullOrWhiteSpace(group.Text) + ? Se.Language.Video.VideoOcr.TestOcrNoTextFound + : string.Format(Se.Language.Video.VideoOcr.TestOcrResultX, group.Text.ReplaceLineEndings(" | ")); + } + catch (OperationCanceledException) + { + ProgressText = string.Empty; + } + catch (Exception exception) + { + Se.LogError(exception, "Video OCR: test on current frame failed"); + ProgressText = string.Empty; + + await MessageBox.Show( + Window!, + Se.Language.General.Error, + exception.Message, + MessageBoxButtons.OK, + MessageBoxIcon.Error); + } + finally + { + IsRunning = false; + + try + { + File.Delete(frameFileName); + } + catch + { + // ignore + } + } + } + + private async Task ExtractSingleFrame(string outputFileName, double positionSeconds, CancellationToken cancellationToken) + { + var scale = string.Empty; + var maxImageWidth = Se.Settings.Video.VideoOcr.MaxImageWidth; + if (maxImageWidth > 0 && SelectionWidth > maxImageWidth) + { + scale = $",scale={maxImageWidth}:-2"; + } + + // -ss before -i: seek in the demuxer, so a test frame late in a long video is still fast. + var arguments = $"-nostdin -y -ss {positionSeconds.ToString("0.###", CultureInfo.InvariantCulture)} " + + $"-i \"{_videoFileName}\" " + + $"-vf \"crop={SelectionWidth}:{SelectionHeight}:{SelectionX}:{SelectionY}{scale}\" " + + $"-frames:v 1 -q:v 2 \"{outputFileName}\""; + + Se.WriteToolsLog("Video OCR: extracting test frame - ffmpeg " + arguments); + var process = FfmpegGenerator.GetProcess(arguments, (_, _) => { }); + +#pragma warning disable CA1416 // Validate platform compatibility + process.Start(); +#pragma warning restore CA1416 + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + try + { + await process.WaitForExitAsync(cancellationToken); + } + catch (OperationCanceledException) + { + try + { + if (!process.HasExited) + { + process.Kill(true); + } + } + catch + { + // ignore + } + + throw; + } + } + [RelayCommand] private async Task StartOcr() { @@ -692,6 +812,15 @@ void AddPreviewLine(VideoOcrFrameGroup group) ProgressText = string.Format(Se.Language.Video.VideoOcr.RunningOcrXY, 0, ocrGroups.Count); }); + await OcrGroups(ocrGroups, ReportOcrProgress, AddPreviewLine, cancellationToken); + } + + /// + /// Runs the selected OCR engine over the given frame groups, storing each result in + /// . Shared by the full scan and the single-frame test. + /// + private async Task OcrGroups(List ocrGroups, Action reportProgress, Action addPreviewLine, CancellationToken cancellationToken) + { var engineType = SelectedEngine.EngineType; if (engineType is OcrEngineType.PaddleOcrStandalone or OcrEngineType.PaddleOcrPython) { @@ -708,8 +837,8 @@ void AddPreviewLine(VideoOcrFrameGroup group) if (group != null) { group.Text = VideoOcrLineBuilder.CleanOcrResult(p.Text); - ReportOcrProgress(); - AddPreviewLine(group); + reportProgress(); + addPreviewLine(group); } }); @@ -731,14 +860,14 @@ void AddPreviewLine(VideoOcrFrameGroup group) var ollamaOcr = new OllamaOcr(Se.Settings.Ocr.OllamaOcrTimeoutMinutes); await RunLlmOcr(ocrGroups, group => OcrWithBitmap(group, bitmap => ollamaOcr.Ocr(bitmap, OllamaUrl, OllamaModel, OllamaLanguage, cancellationToken)), - () => ollamaOcr.Error, ReportOcrProgress, AddPreviewLine, cancellationToken); + () => ollamaOcr.Error, reportProgress, addPreviewLine, cancellationToken); } else if (engineType == OcrEngineType.Glm) { var glmOcr = new GlmOcr(GlmApiKey); await RunLlmOcr(ocrGroups, group => glmOcr.Ocr(group.RepresentativeFileName, GlmUrl, GlmModel, GlmLanguage, cancellationToken), - () => glmOcr.Error, ReportOcrProgress, AddPreviewLine, cancellationToken); + () => glmOcr.Error, reportProgress, addPreviewLine, cancellationToken); } else if (engineType == OcrEngineType.LlamaCpp) { @@ -750,7 +879,7 @@ await RunLlmOcr(ocrGroups, group => var prompt = Se.Settings.Ocr.LlamaCppOcrPrompt; await RunLlmOcr(ocrGroups, group => OcrWithBitmap(group, bitmap => llamaCppOcr.Ocr(bitmap, url, modelName, LlamaCppLanguage, prompt, cancellationToken)), - () => llamaCppOcr.Error, ReportOcrProgress, AddPreviewLine, cancellationToken); + () => llamaCppOcr.Error, reportProgress, addPreviewLine, cancellationToken); } } @@ -915,6 +1044,34 @@ private void Ok() Window?.Close(); } + /// Removes the given result lines and renumbers the rest (Delete key in the grid). + internal void DeleteLines(List items) + { + if (IsRunning || items.Count == 0) + { + return; + } + + foreach (var item in items) + { + Lines.Remove(item); + } + + var number = 1; + foreach (var line in Lines) + { + line.Number = number++; + } + + IsOkEnabled = Lines.Count > 0; + } + + /// Moves the preview to the given line's start time (double-click in the grid). + internal void SeekPreview(VideoOcrLineItem item) + { + PreviewPositionSeconds = Math.Clamp(item.StartTime.TotalSeconds, 0, DurationSeconds); + } + [RelayCommand] private async Task Cancel() { diff --git a/src/ui/Features/Video/VideoOcr/VideoOcrWindow.cs b/src/ui/Features/Video/VideoOcr/VideoOcrWindow.cs index b23d25ffb8..6ff118e009 100644 --- a/src/ui/Features/Video/VideoOcr/VideoOcrWindow.cs +++ b/src/ui/Features/Video/VideoOcr/VideoOcrWindow.cs @@ -11,6 +11,7 @@ using Nikse.SubtitleEdit.Logic.Config; using Nikse.SubtitleEdit.Logic.LlamaCpp; using Nikse.SubtitleEdit.Logic.ValueConverters; +using System.Linq; namespace Nikse.SubtitleEdit.Features.Video.VideoOcr; @@ -157,6 +158,9 @@ private static Border MakePreviewView(VideoOcrViewModel vm) UiUtil.MakeButton(Se.Language.Video.VideoOcr.BottomThird, vm.SetScanAreaBottomThirdCommand), UiUtil.MakeButton(Se.Language.Video.VideoOcr.BottomHalf, vm.SetScanAreaBottomHalfCommand), UiUtil.MakeButton(Se.Language.Video.VideoOcr.FullFrame, vm.SetScanAreaFullFrameCommand), + UiUtil.MakeButton(Se.Language.Video.VideoOcr.TestOcr, vm.TestOcrCommand) + .WithBindEnabled(nameof(vm.IsRunning), new InverseBooleanConverter()) + .WithMarginLeft(10), scanAreaText, }, }; @@ -366,7 +370,7 @@ private static Border MakeLinesView(VideoOcrViewModel vm) CanUserResizeColumns = true, HorizontalAlignment = HorizontalAlignment.Stretch, VerticalAlignment = VerticalAlignment.Stretch, - IsReadOnly = true, + IsReadOnly = false, // the text column is editable so OCR mistakes can be fixed in place DataContext = vm, ItemsSource = vm.Lines, Columns = @@ -404,12 +408,31 @@ private static Border MakeLinesView(VideoOcrViewModel vm) Header = Se.Language.General.Text, CellTheme = UiUtil.DataGridNoBorderNoPaddingCellTheme, Binding = new Binding(nameof(VideoOcrLineItem.Text)), - IsReadOnly = true, + IsReadOnly = false, Width = new DataGridLength(1, DataGridLengthUnitType.Star), }, }, }; + // Double-click a line to see the frame it came from in the preview. + dataGrid.DoubleTapped += (s, e) => + { + if (dataGrid.SelectedItem is VideoOcrLineItem item) + { + vm.SeekPreview(item); + } + }; + + // Delete removes the selected lines (unless a cell edit is in progress). + dataGrid.KeyDown += (s, e) => + { + if (e.Key == Avalonia.Input.Key.Delete && e.Source is not TextBox) + { + vm.DeleteLines(dataGrid.SelectedItems.OfType().ToList()); + e.Handled = true; + } + }; + return UiUtil.MakeBorderForControl(dataGrid); } diff --git a/src/ui/Logic/Config/Language/Video/LanguageVideoOcr.cs b/src/ui/Logic/Config/Language/Video/LanguageVideoOcr.cs index e69a693b01..397a96423b 100644 --- a/src/ui/Logic/Config/Language/Video/LanguageVideoOcr.cs +++ b/src/ui/Logic/Config/Language/Video/LanguageVideoOcr.cs @@ -33,6 +33,10 @@ public class LanguageVideoOcr public string UnableToReadVideoMessage { get; set; } public string AbortOcrTitle { get; set; } public string AbortOcrMessage { get; set; } + public string TestOcr { get; set; } + public string TestOcrRunning { get; set; } + public string TestOcrResultX { get; set; } + public string TestOcrNoTextFound { get; set; } public LanguageVideoOcr() { @@ -67,5 +71,9 @@ public LanguageVideoOcr() UnableToReadVideoMessage = "Could not read video info from: {0}"; AbortOcrTitle = "Abort OCR?"; AbortOcrMessage = "Do you want to abort the running OCR?"; + TestOcr = "Test current frame"; + TestOcrRunning = "Testing OCR on current frame..."; + TestOcrResultX = "Test result: {0}"; + TestOcrNoTextFound = "Test: no text found in the scan area"; } }