From 1d2f9a0148edd2c6fc89f280a1cf9ab300524150 Mon Sep 17 00:00:00 2001 From: Freesia Date: Mon, 12 Oct 2026 01:18:05 +0900 Subject: [PATCH 01/10] =?UTF-8?q?OCR=E3=83=88=E3=83=A9=E3=83=83=E3=82=AB?= =?UTF-8?q?=E3=83=BC=E3=82=92=E3=82=AD=E3=83=A3=E3=83=97=E3=83=81=E3=83=A3?= =?UTF-8?q?=E3=81=A8=E7=8B=AC=E7=AB=8B=E3=81=97=E3=81=9F=E6=99=82=E9=96=93?= =?UTF-8?q?=E3=83=99=E3=83=BC=E3=82=B9=E3=81=AE=E3=83=AB=E3=83=BC=E3=83=97?= =?UTF-8?q?=E3=81=AB=E5=88=86=E9=9B=A2=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 最新の観測を100ms周期と観測到着時に処理し、欠落領域の保持と安定化を経過時間で判定する。翻訳完了はキャッシュ更新後に同じ表示処理へ通知する。 --- WindowTranslator.Abstractions/UserSettings.cs | 2 +- .../Modules/Main/MainViewModelBase.cs | 146 ++++++++++------- .../Modules/Ocr/IOcrTextTracker.cs | 5 + .../Modules/Ocr/OcrTextTracker.cs | 152 +++++++++++------- WindowTranslator/Properties/Resources.ar.resx | 2 +- WindowTranslator/Properties/Resources.cs.resx | 2 +- WindowTranslator/Properties/Resources.de.resx | 2 +- WindowTranslator/Properties/Resources.en.resx | 2 +- WindowTranslator/Properties/Resources.es.resx | 2 +- WindowTranslator/Properties/Resources.fa.resx | 2 +- .../Properties/Resources.fil.resx | 2 +- WindowTranslator/Properties/Resources.fr.resx | 2 +- WindowTranslator/Properties/Resources.hi.resx | 2 +- WindowTranslator/Properties/Resources.hu.resx | 2 +- WindowTranslator/Properties/Resources.id.resx | 2 +- WindowTranslator/Properties/Resources.ko.resx | 2 +- WindowTranslator/Properties/Resources.ms.resx | 2 +- WindowTranslator/Properties/Resources.pl.resx | 2 +- .../Properties/Resources.pt-BR.resx | 2 +- WindowTranslator/Properties/Resources.resx | 2 +- WindowTranslator/Properties/Resources.ru.resx | 2 +- WindowTranslator/Properties/Resources.th.resx | 2 +- WindowTranslator/Properties/Resources.tr.resx | 2 +- WindowTranslator/Properties/Resources.vi.resx | 2 +- .../Properties/Resources.zh-CN.resx | 2 +- .../Properties/Resources.zh-TW.resx | 2 +- 26 files changed, 208 insertions(+), 141 deletions(-) diff --git a/WindowTranslator.Abstractions/UserSettings.cs b/WindowTranslator.Abstractions/UserSettings.cs index 9119cfac..1f927fd1 100644 --- a/WindowTranslator.Abstractions/UserSettings.cs +++ b/WindowTranslator.Abstractions/UserSettings.cs @@ -108,7 +108,7 @@ public class TargetSettings public int OcrRecognitionStability { get; set => field = Math.Clamp(value, 1, 5); } = 3; /// - /// 表示を保持する連続OCR欠落回数(0~7回) + /// 認識できなくなった領域の表示保持時間(100ミリ秒単位、0~7) /// public int OcrMissingFrameRetention { get; set => field = Math.Clamp(value, 0, 7); } = 3; diff --git a/WindowTranslator/Modules/Main/MainViewModelBase.cs b/WindowTranslator/Modules/Main/MainViewModelBase.cs index 8fa12d80..05e6540d 100644 --- a/WindowTranslator/Modules/Main/MainViewModelBase.cs +++ b/WindowTranslator/Modules/Main/MainViewModelBase.cs @@ -1,4 +1,5 @@ using System.Collections.ObjectModel; +using System.Diagnostics; using System.Drawing; using System.IO; using System.Windows; @@ -23,7 +24,9 @@ namespace WindowTranslator.Modules.Main; public abstract partial class MainViewModelBase : IDisposable { private readonly Subject captureRequests = new(); - private readonly Subject<(IEnumerable Texts, FilterContext Context)> translationRequests = new(); + private readonly Subject<(IEnumerable Texts, FilterContext Context, TimeSpan ObservedAt)?> trackingRequests = new(); + private readonly Subject translationRequests = new(); + private readonly Subject translationCompleted = new(); private readonly Subject> uiRequests = new(); private readonly IDisposable captureLoop; private readonly IDisposable translationLoop; @@ -111,20 +114,52 @@ public MainViewModelBase( var captures = !this.isOneShotMode && captureInterval > TimeSpan.Zero ? this.captureRequests.ThrottleLast(captureInterval) : this.captureRequests; - var previousSize = default(System.Drawing.Size); this.captureLoop = captures .ObserveOnThreadPool() - .SubscribeAwait(async (bitmap, ct) => + .SubscribeAwait(RecognizeAsync, AwaitOperation.ThrottleFirstLast, configureAwait: false); + FilterContext? previousContext = null; + this.trackingRequests + .Select(request => request is null + ? Observable.Return(request) + : Observable.Merge( + this.isOneShotMode + ? Observable.Return(Unit.Default) + : Observable.Timer(TimeSpan.Zero, OcrTextTracker.ProcessingInterval).AsUnitObservable(), + this.translationCompleted) + .Select(_ => request)) + .Switch() + .ObserveOnThreadPool() + .Do(request => { - var size = new System.Drawing.Size(bitmap.PixelWidth, bitmap.PixelHeight); - if (!this.isOneShotMode && size != previousSize) + if (request is null) { this.ocrTextTracker.Reset(); } - previousSize = size; - await RecognizeAsync(bitmap, ct); - }, AwaitOperation.ThrottleFirstLast, configureAwait: false); + }) + .SubscribeAwait(async (request, ct) => + { + if (request is null) + { + previousContext?.SoftwareBitmap.Dispose(); + previousContext = null; + return; + } + var current = request.Value; + if (!ReferenceEquals(previousContext, current.Context)) + { + if (previousContext?.ImageSize != current.Context.ImageSize) + { + this.ocrTextTracker.Reset(); + } + previousContext?.SoftwareBitmap.Dispose(); + previousContext = current.Context; + } + await ProcessTrackingAsync(current, ct); + }, onCompleted: _ => previousContext?.SoftwareBitmap.Dispose(), + awaitOperation: AwaitOperation.ThrottleFirstLast, configureAwait: false); this.translationLoop = this.translationRequests + .DistinctUntilChanged(EqualityComparer.Create((previous, current) => + previous!.Select(t => (t.SourceText, t.Context)).SequenceEqual(current!.Select(t => (t.SourceText, t.Context))))) .ObserveOnThreadPool() .SubscribeAwait(ProcessTranslationAsync, AwaitOperation.ThrottleFirstLast, configureAwait: false); if (!this.isOneShotMode) @@ -137,6 +172,7 @@ public MainViewModelBase( partial void OnOverlayVisibleChanged(bool value) { + this.trackingRequests.OnNext(null); if (value) { this.uiRequests.OnNext([]); @@ -144,10 +180,6 @@ partial void OnOverlayVisibleChanged(bool value) { this.isFirstCapture = true; } - else - { - this.ocrTextTracker.Reset(); - } // Start capture when overlay becomes visible this.capture.StartCapture(this.processInfoStore.MainWindowHandle); } @@ -224,9 +256,7 @@ private async ValueTask RecognizeAsync(SoftwareBitmap capturedBitmap, Cancellati var observations = await this.ocr.RecognizeAsync(new(sbmp, regions)); this.ocrTraceRecorder.Record(observations, new(sbmp.PixelWidth, sbmp.PixelHeight)); - texts = this.isOneShotMode - ? observations - : this.ocrTextTracker.Update(observations, new(sbmp.PixelWidth, sbmp.PixelHeight)); + texts = observations; } catch (ObjectDisposedException) { @@ -252,7 +282,6 @@ private async ValueTask RecognizeAsync(SoftwareBitmap capturedBitmap, Cancellati return; } } - texts = texts.Select(t => t with { FontSize = t.FontSize * this.fontScale }); texts = (await this.color.ConvertColorAsync(sbmp, texts)).ToArray(); cancellationToken.ThrowIfCancellationRequested(); @@ -265,24 +294,21 @@ private async ValueTask RecognizeAsync(SoftwareBitmap capturedBitmap, Cancellati SoftwareBitmap = sbmp, ImageSize = new(sbmp.PixelWidth, sbmp.PixelHeight), }; - texts = texts.Select(t => t switch - { - { TranslatedText: null } when this.cache.Contains(t.SourceText) => t with { TranslatedText = this.cache.Get(t.SourceText) }, - _ => t, - }).ToArray(); - this.uiRequests.OnNext(texts); - this.translationRequests.OnNext((texts, context)); + this.trackingRequests.OnNext((texts, context, TimeSpan.FromSeconds((double)Stopwatch.GetTimestamp() / Stopwatch.Frequency))); sbmp = null; } - private async ValueTask ProcessTranslationAsync((IEnumerable Texts, FilterContext Context) request, CancellationToken cancellationToken) + private async ValueTask ProcessTrackingAsync((IEnumerable Texts, FilterContext Context, TimeSpan ObservedAt) request, CancellationToken cancellationToken) { - using var bitmap = request.Context.SoftwareBitmap; cancellationToken.ThrowIfCancellationRequested(); - IEnumerable texts; + IEnumerable texts = this.isOneShotMode + ? request.Texts + : this.ocrTextTracker.Update(request.Texts, request.Context.ImageSize, + TimeSpan.FromSeconds((double)Stopwatch.GetTimestamp() / Stopwatch.Frequency), request.ObservedAt); + texts = texts.Select(t => t with { FontSize = t.FontSize * this.fontScale }); using (this.Filtering.EnterBusy()) { - var tmp = request.Texts.ToAsyncEnumerable(); + var tmp = texts.ToAsyncEnumerable(); foreach (var filter in this.filters.OrderByDescending(f => f.Priority)) { tmp = filter.ExecutePreTranslate(tmp, request.Context); @@ -290,30 +316,45 @@ private async ValueTask ProcessTranslationAsync((IEnumerable Texts, Fi using var t = this.logger.LogDebugTime("翻訳前処理"); texts = await tmp.ToArrayAsync(cancellationToken); } + texts = texts.Select(t => t switch + { + { TranslatedText: null } when this.cache.Contains(t.SourceText) => t with { TranslatedText = this.cache.Get(t.SourceText) }, + _ => t, + }).ToArray(); + var requests = texts.Where(t => t.TranslatedText is null).ToArray(); + using (this.Filtering.EnterBusy()) + { + var tmp = texts.Where(t => t.TranslatedText is not null).ToAsyncEnumerable(); + foreach (var filter in this.filters.OrderBy(f => f.Priority)) + { + tmp = filter.ExecutePostTranslate(tmp, request.Context); + } + using var t = this.logger.LogDebugTime("翻訳後処理"); + texts = requests.Concat(await tmp.ToArrayAsync(cancellationToken)).ToArray(); + } + cancellationToken.ThrowIfCancellationRequested(); + this.uiRequests.OnNext(texts); + this.translationRequests.OnNext(requests); + } + + private async ValueTask ProcessTranslationAsync(TextRect[] requests, CancellationToken cancellationToken) + { + if (requests.Length == 0) + { + return; + } + cancellationToken.ThrowIfCancellationRequested(); try { - var requests = texts - .Where(t => t.TranslatedText is null) - .ToArray(); if (this.disposedValue) { this.logger.LogDebug("すでに破棄されているので翻訳キューを無視"); return; } - if (requests.Length > 0) - { - using var t = this.logger.LogDebugTime("翻訳"); - var translated = await this.translator.TranslateAsync(requests).ConfigureAwait(false); - this.cache.AddRange(requests.Select(t => t.SourceText).Zip(translated)); - var translatedIndex = 0; - texts = texts.Select(t => t.TranslatedText is null - ? t with { TranslatedText = translated[translatedIndex++] } - : t).ToArray(); - } - else - { - this.logger.LogDebug("翻訳キューに未翻訳がないので翻訳を省略"); - } + using var t = this.logger.LogDebugTime("翻訳"); + var translated = await this.translator.TranslateAsync(requests).ConfigureAwait(false); + this.cache.AddRange(requests.Select(t => t.SourceText).Zip(translated)); + this.translationCompleted.OnNext(Unit.Default); } catch (OperationCanceledException) { @@ -323,23 +364,12 @@ private async ValueTask ProcessTranslationAsync((IEnumerable Texts, Fi { this.logger.LogError(e, "翻訳中にエラーが発生"); this.captureLoop.Dispose(); + this.trackingRequests.OnNext(null); await Application.Current.Dispatcher.InvokeAsync(this.capture.StopCapture); this.translationLoop.Dispose(); await ShowErrorAsync(Resources.FaildOverlay, e, string.Empty); return; } - using (this.Filtering.EnterBusy()) - { - var tmp = texts.ToAsyncEnumerable(); - foreach (var filter in this.filters.OrderBy(f => f.Priority)) - { - tmp = filter.ExecutePostTranslate(tmp, request.Context); - } - using var t = this.logger.LogDebugTime("翻訳後処理"); - texts = await tmp.ToArrayAsync(cancellationToken); - } - cancellationToken.ThrowIfCancellationRequested(); - this.uiRequests.OnNext(texts); } private void UpdateOcrTexts(IEnumerable texts) @@ -375,7 +405,11 @@ protected virtual void Dispose(bool disposing) { // 実行中の処理から遅れて通知されても受け付けない。 this.captureRequests.OnCompleted(); + // タイマーを停止し、実行中のトラッカー処理の完了後に画像を破棄する。 + this.trackingRequests.OnNext(null); + this.trackingRequests.OnCompleted(); this.translationRequests.OnCompleted(); + this.translationCompleted.OnCompleted(); this.uiRequests.OnCompleted(); if (this.capture is IDisposable captureDisposable) { diff --git a/WindowTranslator/Modules/Ocr/IOcrTextTracker.cs b/WindowTranslator/Modules/Ocr/IOcrTextTracker.cs index 5beb2711..948498c3 100644 --- a/WindowTranslator/Modules/Ocr/IOcrTextTracker.cs +++ b/WindowTranslator/Modules/Ocr/IOcrTextTracker.cs @@ -12,6 +12,11 @@ public interface IOcrTextTracker /// IReadOnlyList Update(IEnumerable observations, Size imageSize); + /// + /// 最新の観測を処理時刻に基づいて安定化する。観測時刻は移動予測に使用する。 + /// + IReadOnlyList Update(IEnumerable observations, Size imageSize, TimeSpan timestamp, TimeSpan observationTimestamp); + /// /// 保持しているすべてのトラックを破棄する。 /// diff --git a/WindowTranslator/Modules/Ocr/OcrTextTracker.cs b/WindowTranslator/Modules/Ocr/OcrTextTracker.cs index 69285e69..fcc58b14 100644 --- a/WindowTranslator/Modules/Ocr/OcrTextTracker.cs +++ b/WindowTranslator/Modules/Ocr/OcrTextTracker.cs @@ -12,7 +12,8 @@ namespace WindowTranslator.Modules.Ocr; /// public sealed class OcrTextTracker(ILogger logger, TargetSettings? settings = null) : IOcrTextTracker { - private const int AssignmentRecencyFrames = 4; + public static readonly TimeSpan ProcessingInterval = TimeSpan.FromMilliseconds(100); + private static readonly TimeSpan assignmentRecency = ProcessingInterval * 4; private const int MaxStructureCandidates = 6; private const int MaxStructureSelectionStates = 1024; private const int MaxOneToOneCandidatesPerResource = 3; @@ -39,8 +40,8 @@ public sealed class OcrTextTracker(ILogger logger, TargetSetting private readonly TrackingProfile profile = TrackingProfile.FromSettings(settings ?? new()); private readonly object syncRoot = new(); private readonly List tracks = []; - private Dictionary mergeCandidates = []; - private Dictionary dormantRestoreCandidates = []; + private Dictionary mergeCandidates = []; + private Dictionary dormantRestoreCandidates = []; private long nextTrackId; public IReadOnlyList Update(IEnumerable observations, Size imageSize) @@ -50,11 +51,14 @@ public IReadOnlyList Update(IEnumerable observations, Size i TimeSpan.FromSeconds((double)Stopwatch.GetTimestamp() / Stopwatch.Frequency)); public IReadOnlyList Update(IEnumerable observations, Size imageSize, TimeSpan timestamp) + => this.Update(observations, imageSize, timestamp, timestamp); + + public IReadOnlyList Update(IEnumerable observations, Size imageSize, TimeSpan timestamp, TimeSpan observationTimestamp) { ArgumentNullException.ThrowIfNull(observations); lock (this.syncRoot) { - return this.UpdateCore(observations, imageSize, timestamp); + return this.UpdateCore(observations, imageSize, timestamp, observationTimestamp); } } @@ -69,7 +73,7 @@ public void Reset() } } - private TextRect[] UpdateCore(IEnumerable observations, Size imageSize, TimeSpan timestamp) + private TextRect[] UpdateCore(IEnumerable observations, Size imageSize, TimeSpan timestamp, TimeSpan observationTimestamp) { TextRect[] current = observations.Where(IsValid).ToArray(); foreach (TextTrack track in this.tracks) @@ -80,7 +84,7 @@ private TextRect[] UpdateCore(IEnumerable observations, Size imageSize { foreach (TextRect observation in current) { - this.CreateTrack(observation, timestamp); + this.CreateTrack(observation, observationTimestamp); } return this.GetOutput(); } @@ -99,7 +103,8 @@ private TextRect[] UpdateCore(IEnumerable observations, Size imageSize current, normalizedCurrent, imageSize, - timestamp); + timestamp, + observationTimestamp); MatchCandidate[] strongMatches = SelectOneToOne( oneToOneCandidates .Where(candidate => IsStrongOneToOneCandidate(candidate, normalizedCurrent)) @@ -126,6 +131,7 @@ private TextRect[] UpdateCore(IEnumerable observations, Size imageSize normalizedCurrent, imageSize, timestamp, + observationTimestamp, reservedObservations)); List selected = [.. strongMatches, .. SelectStructures(structureCandidates)]; reservedTracks.UnionWith(selected.SelectMany(candidate => candidate.Tracks)); @@ -164,7 +170,7 @@ private TextRect[] UpdateCore(IEnumerable observations, Size imageSize } reliableMergeOutputGeometryGroups.Add(group); } - this.ApplyMatches(selected, timestamp, matchedTracks, matchedObservations); + this.ApplyMatches(selected, timestamp, observationTimestamp, matchedTracks, matchedObservations); for (int i = 0; i < current.Length; i++) { @@ -172,7 +178,7 @@ private TextRect[] UpdateCore(IEnumerable observations, Size imageSize { TextTrack? coveringTrack = activeTracks.FirstOrDefault(track => IsCoveredTextFragment(current[i], track.Stabilized)); - TextTrack track = this.CreateTrack(current[i], timestamp, coveringTrack?.Id); + TextTrack track = this.CreateTrack(current[i], observationTimestamp, coveringTrack?.Id); matchedTracks.Add(track); tracksWithCurrentGeometry.Add(track); } @@ -199,18 +205,18 @@ private TextRect[] UpdateCore(IEnumerable observations, Size imageSize this.RemoveTrackTree(track); continue; } - track.MarkMissed(); + track.MarkMissed(timestamp); } } foreach (TextTrack track in this.tracks - .Where(track => (!track.IsDormant && track.MissedFrames > this.profile.MaxMissedFrames) + .Where(track => (!track.IsDormant && track.MissingSince is { } since && timestamp - since >= this.profile.MissingRetention) || (track.IsDormant && !matchedTracks.Contains(track) && timestamp - track.DormantSince > dormantRetention)) .ToArray()) { - this.logger.LogDebug("OCR track {TrackId} expired after {MissedFrames} missed frames", track.Id, track.MissedFrames); + this.logger.LogDebug("OCR track {TrackId} expired", track.Id); this.RemoveTrackTree(track); } @@ -396,6 +402,7 @@ private static List BuildOneToOneCandidates( string[] normalizedObservations, Size imageSize, TimeSpan timestamp, + TimeSpan observationTimestamp, Func? geometryOverride = null, Func? distanceGateDimensionOverride = null, HashSet? excludedObservations = null) @@ -417,6 +424,7 @@ private static List BuildOneToOneCandidates( normalizedObservation, imageSize, timestamp, + observationTimestamp, geometry, distanceGateDimensionOverride?.Invoke(track)); if (score >= MinimumAssignmentScore) @@ -618,11 +626,12 @@ private static double ScoreAssignment( string normalizedObservation, Size imageSize, TimeSpan timestamp, + TimeSpan observationTimestamp, TextRect? geometryOverride = null, double? distanceGateDimensionOverride = null) { TextRect previous = geometryOverride ?? track.LatestObservation; - TextRect predicted = geometryOverride ?? track.Predict(timestamp); + TextRect predicted = geometryOverride ?? track.Predict(observationTimestamp); double centerDistance = CenterDistance(predicted, observation); double maximumDimension = Math.Max(previous.Width, previous.Height); double distanceGate = TrackingDistanceGate(previous, imageSize, distanceGateDimensionOverride); @@ -699,7 +708,9 @@ private static double ScoreAssignment( return -1; } double aspect = RatioSimilarity(previous.Width / previous.Height, observation.Width / observation.Height); - double recency = Math.Max(0, 1 - ((double)track.MissedFrames / AssignmentRecencyFrames)); + double recency = track.MissingSince is { } since + ? Math.Max(0, 1 - ((timestamp - since) / assignmentRecency)) + : 1; double score = (text * 0.35) + (overlap * 0.25) + (center * 0.2) @@ -1173,6 +1184,7 @@ private List BuildRestorationCandidates( string[] normalizedObservations, Size imageSize, TimeSpan timestamp, + TimeSpan observationTimestamp, HashSet excludedObservations) { List result = []; @@ -1193,6 +1205,7 @@ private List BuildRestorationCandidates( normalizedObservations, imageSize, timestamp, + observationTimestamp, child => child.RestoreGeometry(parent.LatestObservation), _ => Math.Max(parent.LatestObservation.Width, parent.LatestObservation.Height), excludedObservations); @@ -1268,11 +1281,12 @@ void AddAssignments( private void ApplyMatches( IReadOnlyList selected, TimeSpan timestamp, + TimeSpan observationTimestamp, HashSet matchedTracks, HashSet matchedObservations) { - Dictionary currentMergeCandidates = []; - Dictionary currentRestoreCandidates = []; + Dictionary currentMergeCandidates = []; + Dictionary currentRestoreCandidates = []; foreach (MatchCandidate candidate in selected) { foreach (int observationIndex in candidate.ObservationIndices) @@ -1284,23 +1298,23 @@ private void ApplyMatches( { TextTrack parent = candidate.Tracks[0]; TextTrack[] children = candidate.Tracks.Skip(1).ToArray(); - int restoreCount = this.dormantRestoreCandidates.GetValueOrDefault(parent.Id) + 1; - currentRestoreCandidates[parent.Id] = restoreCount; + TimeSpan restoreSince = this.dormantRestoreCandidates.GetValueOrDefault(parent.Id, timestamp); + currentRestoreCandidates[parent.Id] = restoreSince; matchedTracks.Add(parent); matchedTracks.UnionWith(children); - if (restoreCount >= this.profile.StructureConfirmationFrames) + if (timestamp - restoreSince >= this.profile.StructureConfirmationTime) { foreach (RestorationAssignment assignment in candidate.RestorationAssignments) { - assignment.Track.Reactivate(timestamp); - assignment.Track.Observe(assignment.Observation, timestamp); + assignment.Track.Reactivate(observationTimestamp); + assignment.Track.Observe(assignment.Observation, timestamp, observationTimestamp); } this.tracks.Remove(parent); this.logger.LogDebug("OCR track {TrackId} restored {ChildCount} dormant child tracks", parent.Id, children.Length); } else { - parent.Observe(candidate.Combined, timestamp); + parent.Observe(candidate.Combined, timestamp, observationTimestamp); } continue; } @@ -1308,7 +1322,7 @@ private void ApplyMatches( if (candidate.Kind is MatchKind.OneToOne or MatchKind.Split) { TextTrack track = candidate.Tracks[0]; - track.Observe(candidate.Combined, timestamp); + track.Observe(candidate.Combined, timestamp, observationTimestamp); matchedTracks.Add(track); if (candidate.Kind == MatchKind.Split) { @@ -1318,19 +1332,19 @@ private void ApplyMatches( } string key = string.Join(',', candidate.Tracks.Select(track => track.Id).Order()); - int count = this.mergeCandidates.GetValueOrDefault(key) + 1; - currentMergeCandidates[key] = count; + TimeSpan mergeSince = this.mergeCandidates.GetValueOrDefault(key, timestamp); + currentMergeCandidates[key] = mergeSince; foreach (TextTrack track in candidate.Tracks) { matchedTracks.Add(track); } - if (count >= this.profile.StructureConfirmationFrames) + if (timestamp - mergeSince >= this.profile.StructureConfirmationTime) { - TextTrack parent = this.CreateTrack(candidate.Combined, timestamp); + TextTrack parent = this.CreateTrack(candidate.Combined, observationTimestamp); matchedTracks.Add(parent); foreach (TextTrack child in candidate.Tracks) { - child.MarkDormant(parent, timestamp); + child.MarkDormant(parent, timestamp, observationTimestamp); } this.logger.LogDebug("OCR tracks {TrackIds} converged into track {TrackId}", key, parent.Id); } @@ -1338,7 +1352,7 @@ private void ApplyMatches( { foreach (TextTrack track in candidate.Tracks) { - track.MarkObservedWithoutChangingStructure(timestamp); + track.MarkObservedWithoutChangingStructure(observationTimestamp); } this.logger.LogDebug("OCR tracks {TrackIds} retained during a temporary merge", key); } @@ -1874,13 +1888,13 @@ private sealed record MatchCandidate( } private sealed record TrackingProfile( - int GeometryConfirmationFrames, - int MicroGeometryConfirmationFrames, - int StructureConfirmationFrames, + TimeSpan GeometryConfirmationTime, + TimeSpan MicroGeometryConfirmationTime, + TimeSpan StructureConfirmationTime, int MinimumTextVotes, double TextVoteThreshold, int TextVoteHistorySize, - int MaxMissedFrames) + TimeSpan MissingRetention) { public static TrackingProfile FromSettings(TargetSettings settings) { @@ -1900,7 +1914,12 @@ public static TrackingProfile FromSettings(TargetSettings settings) 5 => (4, 4, 2.5, 8), _ => (2, 2, 1.5, 5), }; - return new(geometry, microGeometry, structure, votes, threshold, history, settings.OcrMissingFrameRetention); + return new( + ProcessingInterval * (geometry - 1), + ProcessingInterval * (microGeometry - 1), + ProcessingInterval * (structure - 1), + votes, threshold, history, + ProcessingInterval * settings.OcrMissingFrameRetention); } } @@ -1912,7 +1931,8 @@ private sealed class TextTrack( TrackingProfile profile) { private TextRect? normalGeometryCandidate; - private int normalGeometryCandidateCount; + private TimeSpan normalGeometryCandidateSince; + private TimeSpan noiseGeometrySince; private readonly List noiseGeometrySamples = []; private readonly List textVotes = []; private string normalizedConfirmedText = NormalizeText(observation.SourceText); @@ -1945,7 +1965,7 @@ private sealed class TextTrack( public string NormalizedConfirmedText => this.normalizedConfirmedText; - public int MissedFrames { get; private set; } + public TimeSpan? MissingSince { get; private set; } public TimeSpan LastObservationTime { get; private set; } = timestamp; @@ -1957,14 +1977,17 @@ private sealed class TextTrack( public TimeSpan DormantSince { get; private set; } - public void Observe(TextRect current, TimeSpan timestamp) + public void Observe(TextRect current, TimeSpan timestamp, TimeSpan observationTimestamp) { - this.MissedFrames = 0; - this.UpdateMotion(current, timestamp); + this.MissingSince = null; + if (observationTimestamp != this.LastObservationTime) + { + this.UpdateMotion(current, observationTimestamp); + this.LastObservationTime = observationTimestamp; + } this.LatestObservation = current; - this.LastObservationTime = timestamp; - this.UpdateText(current.SourceText); - this.UpdateGeometry(current); + this.UpdateText(current.SourceText, timestamp); + this.UpdateGeometry(current, timestamp); this.Stabilized = current with { SourceText = this.ConfirmedText, @@ -1980,13 +2003,13 @@ public void Observe(TextRect current, TimeSpan timestamp) public void MarkObservedWithoutChangingStructure(TimeSpan timestamp) { - this.MissedFrames = 0; + this.MissingSince = null; this.LastObservationTime = timestamp; } - public void MarkMissed() + public void MarkMissed(TimeSpan timestamp) { - this.MissedFrames++; + this.MissingSince ??= timestamp; } public TextRect Predict(TimeSpan timestamp) @@ -2010,14 +2033,14 @@ public TextRect Predict(TimeSpan timestamp) public TextRect RestoreGeometry(TextRect parent) => this.dormantGeometry?.Restore(this.Stabilized, parent) ?? this.Stabilized; - public void MarkDormant(TextTrack parent, TimeSpan timestamp) + public void MarkDormant(TextTrack parent, TimeSpan timestamp, TimeSpan observationTimestamp) { this.dormantGeometry = DormantGeometry.Create(this.Stabilized, parent.Stabilized); this.IsDormant = true; this.DormantParentId = parent.Id; this.DormantSince = timestamp; - this.MissedFrames = 0; - this.ResetMotion(timestamp); + this.MissingSince = null; + this.ResetMotion(observationTimestamp); } public void Reactivate(TimeSpan timestamp) @@ -2025,11 +2048,11 @@ public void Reactivate(TimeSpan timestamp) this.IsDormant = false; this.DormantParentId = null; this.dormantGeometry = null; - this.MissedFrames = 0; + this.MissingSince = null; this.ResetMotion(timestamp); } - private void UpdateText(string current) + private void UpdateText(string current, TimeSpan timestamp) { string normalizedCurrent = NormalizeText(current); if (normalizedCurrent == this.normalizedConfirmedText) @@ -2038,14 +2061,21 @@ private void UpdateText(string current) return; } - this.textVotes.Add(new(normalizedCurrent, current)); + if (this.textVotes.Count > 0 && timestamp - this.textVotes[^1].Timestamp < ProcessingInterval) + { + this.textVotes[^1] = new(normalizedCurrent, current, this.textVotes[^1].Timestamp); + } + else + { + this.textVotes.Add(new(normalizedCurrent, current, timestamp)); + } if (this.textVotes.Count > profile.TextVoteHistorySize) { this.textVotes.RemoveAt(0); } (string normalized, int count, double weight)? winner = this.textVotes - .Select((vote, index) => (vote, age: this.textVotes.Count - index - 1)) + .Select(vote => (vote, age: (timestamp - vote.Timestamp) / ProcessingInterval)) .GroupBy(item => item.vote.Normalized) .Select(group => ( normalized: group.Key, @@ -2065,7 +2095,7 @@ private void UpdateText(string current) return; } - if (this.textVotes.Count == profile.TextVoteHistorySize) + if (timestamp - this.textVotes[0].Timestamp >= ProcessingInterval * (profile.TextVoteHistorySize - 1)) { TextVote latest = this.textVotes[^1]; this.ConfirmedText = latest.Original; @@ -2112,7 +2142,7 @@ private void ResetMotion(TimeSpan timestamp) this.LastObservationTime = timestamp; } - private void UpdateGeometry(TextRect current) + private void UpdateGeometry(TextRect current, TimeSpan timestamp) { double movement = CenterDistance(this.Stabilized, current); if (movement > Math.Max(this.Stabilized.Width, this.Stabilized.Height) * 0.75) @@ -2123,21 +2153,21 @@ private void UpdateGeometry(TextRect current) if (IsGeometryNoise(this.Stabilized, current)) { - if (profile.MicroGeometryConfirmationFrames == 1) + if (profile.MicroGeometryConfirmationTime == TimeSpan.Zero) { this.ApplyGeometry(current); return; } this.normalGeometryCandidate = null; - this.normalGeometryCandidateCount = 0; if (this.noiseGeometrySamples.Count == 0) { + this.noiseGeometrySince = timestamp; this.noiseGeometrySamples.Add(this.Stabilized); } this.noiseGeometrySamples.Add(current); // 現在の表示値を含めた中央値で微小な揺れを抑える。 - if (this.noiseGeometrySamples.Count >= profile.MicroGeometryConfirmationFrames + 1) + if (timestamp - this.noiseGeometrySince >= profile.MicroGeometryConfirmationTime) { this.ApplyGeometry(RepresentativeGeometry(this.noiseGeometrySamples)); } @@ -2149,15 +2179,14 @@ private void UpdateGeometry(TextRect current) && GeometryCandidateMatches(this.normalGeometryCandidate, current)) { this.normalGeometryCandidate = current; - this.normalGeometryCandidateCount++; } else { this.normalGeometryCandidate = current; - this.normalGeometryCandidateCount = 1; + this.normalGeometryCandidateSince = timestamp; } - if (this.normalGeometryCandidateCount >= profile.GeometryConfirmationFrames) + if (timestamp - this.normalGeometryCandidateSince >= profile.GeometryConfirmationTime) { this.ApplyGeometry(current); } @@ -2229,7 +2258,6 @@ private void ApplyGeometry(TextRect current) Angle = current.Angle, }; this.normalGeometryCandidate = null; - this.normalGeometryCandidateCount = 0; this.noiseGeometrySamples.Clear(); } @@ -2286,6 +2314,6 @@ public TextRect Restore(TextRect child, TextRect parent) } } - private sealed record TextVote(string Normalized, string Original); + private sealed record TextVote(string Normalized, string Original, TimeSpan Timestamp); } } diff --git a/WindowTranslator/Properties/Resources.ar.resx b/WindowTranslator/Properties/Resources.ar.resx index 55000961..9e616fe5 100644 --- a/WindowTranslator/Properties/Resources.ar.resx +++ b/WindowTranslator/Properties/Resources.ar.resx @@ -527,7 +527,7 @@ استقرار نتائج التعرّف يقلل التغيّرات المؤقتة في النص وتقسيم المناطق أو دمجها. 1: استجابة سريعة، 5: استقرار أكبر (الافتراضي: 3). الاحتفاظ عند الاختفاء المؤقت - عدد مرات فشل التعرّف المتتالية التي يبقى خلالها النص ظاهرًا. 0: إزالة عند أول فشل، الحد الأقصى: 7، الافتراضي: 3. يُحسب بالمرات وليس بالثواني. + مدة إبقاء المنطقة ظاهرة بعد فقدان التعرّف، بوحدات 100 مللي ثانية. 0: إزالة عند أول فقدان، الحد الأقصى: 0.7 ثانية، الافتراضي: 0.3 ثانية. لا تعتمد على الفاصل الزمني للالتقاط. الكل diff --git a/WindowTranslator/Properties/Resources.cs.resx b/WindowTranslator/Properties/Resources.cs.resx index b079d49e..accf6f02 100644 --- a/WindowTranslator/Properties/Resources.cs.resx +++ b/WindowTranslator/Properties/Resources.cs.resx @@ -417,7 +417,7 @@ Monitory nejsou podporovány. Stabilita rozpoznávání Omezuje dočasné změny textu a rozdělování nebo slučování oblastí. 1: rychlá odezva, 5: vysoká stabilita (výchozí: 3). Zachování při dočasném zmizení - Počet po sobě jdoucích neúspěšných rozpoznání, při nichž text zůstane zobrazený. 0: odstranit při prvním výpadku, maximum: 7, výchozí: 3. Jde o počet výpadků, nikoli sekund. + Doba zobrazení oblasti po ztrátě rozpoznání v jednotkách 100 ms. 0: odstranit při prvním výpadku, maximum: 0,7 sekundy, výchozí: 0,3 sekundy. Nezávisí na intervalu snímání. Vše diff --git a/WindowTranslator/Properties/Resources.de.resx b/WindowTranslator/Properties/Resources.de.resx index 900d32d5..a4ab5178 100644 --- a/WindowTranslator/Properties/Resources.de.resx +++ b/WindowTranslator/Properties/Resources.de.resx @@ -536,7 +536,7 @@ Monitore werden nicht unterstützt. Erkennungsstabilität Reduziert vorübergehende Änderungen am Text sowie das Aufteilen oder Zusammenführen von Bereichen. 1: schnelle Reaktion, 5: hohe Stabilität (Standard: 3). Anzeige bei vorübergehendem Verschwinden - Anzahl aufeinanderfolgender fehlender Erkennungen, während der Text sichtbar bleibt. 0: beim ersten Fehlen entfernen, Maximum: 7, Standard: 3. Gezählt werden Ausfälle, keine Sekunden. + Anzeigedauer eines Bereichs nach ausbleibender Erkennung in Einheiten von 100 ms. 0: beim ersten Fehlen entfernen, Maximum: 0,7 Sekunden, Standard: 0,3 Sekunden. Unabhängig vom Aufnahmeintervall. Alle diff --git a/WindowTranslator/Properties/Resources.en.resx b/WindowTranslator/Properties/Resources.en.resx index 12f6fb4a..a4b51128 100644 --- a/WindowTranslator/Properties/Resources.en.resx +++ b/WindowTranslator/Properties/Resources.en.resx @@ -558,7 +558,7 @@ Select a slightly wider area so that text is not cut off Recognition stability Reduces temporary changes in text and region splitting or merging. 1: responsive, 5: stable (default: 3). Temporary disappearance retention - Number of consecutive missed recognitions to keep displaying text. 0: remove on the first miss, maximum: 7, default: 3. Counted in misses, not seconds. + How long to keep displaying a region after recognition is lost, in 100 ms units. 0: remove on the first miss, maximum: 0.7 seconds, default: 0.3 seconds. Independent of the capture interval. All diff --git a/WindowTranslator/Properties/Resources.es.resx b/WindowTranslator/Properties/Resources.es.resx index ceb7df00..f34d9083 100644 --- a/WindowTranslator/Properties/Resources.es.resx +++ b/WindowTranslator/Properties/Resources.es.resx @@ -527,7 +527,7 @@ Estabilidad del reconocimiento Reduce los cambios temporales del texto y la división o unión de regiones. 1: respuesta rápida, 5: mayor estabilidad (predeterminado: 3). Retención ante desapariciones temporales - Número de fallos consecutivos de reconocimiento durante los que se mantiene el texto visible. 0: eliminar en el primer fallo, máximo: 7, predeterminado: 3. Se cuenta en fallos, no en segundos. + Tiempo que se mantiene visible una región tras perder el reconocimiento, en unidades de 100 ms. 0: eliminar en el primer fallo, máximo: 0,7 segundos, predeterminado: 0,3 segundos. Independiente del intervalo de captura. Todos diff --git a/WindowTranslator/Properties/Resources.fa.resx b/WindowTranslator/Properties/Resources.fa.resx index 75f61d4b..90fff064 100644 --- a/WindowTranslator/Properties/Resources.fa.resx +++ b/WindowTranslator/Properties/Resources.fa.resx @@ -521,7 +521,7 @@ پایداری تشخیص تغییرات موقت متن و تقسیم یا ادغام ناحیه‌ها را کاهش می‌دهد. 1: پاسخ سریع، 5: پایداری بیشتر (پیش‌فرض: 3). حفظ نمایش هنگام ناپدید شدن موقت - تعداد دفعات متوالی عدم تشخیص که متن همچنان نمایش داده می‌شود. 0: حذف در اولین عدم تشخیص، حداکثر: 7، پیش‌فرض: 3. بر حسب تعداد دفعات است، نه ثانیه. + مدت نمایش ناحیه پس از از دست رفتن تشخیص، در واحدهای 100 میلی‌ثانیه. 0: حذف در اولین عدم تشخیص، حداکثر: 0.7 ثانیه، پیش‌فرض: 0.3 ثانیه. مستقل از فاصلهٔ زمانی ثبت تصویر است. همه diff --git a/WindowTranslator/Properties/Resources.fil.resx b/WindowTranslator/Properties/Resources.fil.resx index ac245093..5e85460f 100644 --- a/WindowTranslator/Properties/Resources.fil.resx +++ b/WindowTranslator/Properties/Resources.fil.resx @@ -536,7 +536,7 @@ Ang monitor ay hindi suportado. Katatagan ng pagkilala Binabawasan ang pansamantalang pagbabago sa teksto at paghahati o pagsasama ng mga rehiyon. 1: mabilis na pagtugon, 5: mas matatag (default: 3). Pagpapanatili kapag pansamantalang nawala - Bilang ng sunod-sunod na hindi pagkilala habang nananatiling nakikita ang teksto. 0: alisin sa unang pagkabigo, pinakamataas: 7, default: 3. Bilang ng pagkabigo, hindi segundo. + Tagal ng pagpapakita ng rehiyon matapos itong hindi makilala, sa mga yunit na 100 ms. 0: alisin sa unang pagkabigo, pinakamataas: 0.7 segundo, default: 0.3 segundo. Hindi nakadepende sa pagitan ng pagkuha. Lahat diff --git a/WindowTranslator/Properties/Resources.fr.resx b/WindowTranslator/Properties/Resources.fr.resx index 727c6981..8b381a63 100644 --- a/WindowTranslator/Properties/Resources.fr.resx +++ b/WindowTranslator/Properties/Resources.fr.resx @@ -527,7 +527,7 @@ Stabilité de la reconnaissance Réduit les variations temporaires du texte et de la division ou fusion des zones. 1 : réactivité, 5 : stabilité (par défaut : 3). Maintien en cas de disparition temporaire - Nombre d'échecs consécutifs de reconnaissance pendant lesquels le texte reste affiché. 0 : supprimer dès le premier échec, maximum : 7, par défaut : 3. Il s'agit d'échecs, pas de secondes. + Durée d'affichage d'une zone après la perte de reconnaissance, par unités de 100 ms. 0 : supprimer dès le premier échec, maximum : 0,7 seconde, par défaut : 0,3 seconde. Indépendante de l'intervalle de capture. Tous diff --git a/WindowTranslator/Properties/Resources.hi.resx b/WindowTranslator/Properties/Resources.hi.resx index 5c3b338e..d742e82a 100644 --- a/WindowTranslator/Properties/Resources.hi.resx +++ b/WindowTranslator/Properties/Resources.hi.resx @@ -529,7 +529,7 @@ पहचान की स्थिरता पाठ और क्षेत्रों के विभाजन या विलय में अस्थायी बदलाव कम करता है। 1: तेज़ प्रतिक्रिया, 5: अधिक स्थिरता (डिफ़ॉल्ट: 3)। अस्थायी रूप से गायब होने पर बनाए रखना - लगातार कितनी बार पहचान विफल होने तक पाठ दिखाई देता रहे। 0: पहली विफलता पर हटाएँ, अधिकतम: 7, डिफ़ॉल्ट: 3। यह विफलताओं की गिनती है, सेकंड नहीं। + पहचान खोने के बाद क्षेत्र को दिखाते रहने का समय, 100 मिलीसेकंड की इकाइयों में। 0: पहली विफलता पर हटाएँ, अधिकतम: 0.7 सेकंड, डिफ़ॉल्ट: 0.3 सेकंड। कैप्चर अंतराल से स्वतंत्र। सभी diff --git a/WindowTranslator/Properties/Resources.hu.resx b/WindowTranslator/Properties/Resources.hu.resx index ae5be271..656e303f 100644 --- a/WindowTranslator/Properties/Resources.hu.resx +++ b/WindowTranslator/Properties/Resources.hu.resx @@ -417,7 +417,7 @@ A monitorok nem támogatottak. Felismerés stabilitása Csökkenti a szöveg, valamint a területek felosztásának és egyesítésének átmeneti változásait. 1: gyors reakció, 5: nagy stabilitás (alapérték: 3). Megjelenítés megtartása átmeneti eltűnéskor - Egymást követő sikertelen felismerések száma, ameddig a szöveg látható marad. 0: eltávolítás az első kimaradáskor, maximum: 7, alapérték: 3. Kimaradásokban mérve, nem másodpercekben. + A terület megjelenítésének időtartama a felismerés elvesztése után, 100 ms-os egységekben. 0: eltávolítás az első kimaradáskor, maximum: 0,7 másodperc, alapérték: 0,3 másodperc. Független a rögzítési időköztől. Összes diff --git a/WindowTranslator/Properties/Resources.id.resx b/WindowTranslator/Properties/Resources.id.resx index a50a1146..fd054fcc 100644 --- a/WindowTranslator/Properties/Resources.id.resx +++ b/WindowTranslator/Properties/Resources.id.resx @@ -535,7 +535,7 @@ Monitor tidak didukung. Stabilitas pengenalan Mengurangi perubahan sementara pada teks serta pemisahan atau penggabungan area. 1: respons cepat, 5: lebih stabil (default: 3). Pertahankan saat hilang sementara - Jumlah kegagalan pengenalan berturut-turut untuk mempertahankan tampilan. 0: hapus saat kegagalan pertama, maksimum: 7, default: 3. Dihitung dalam jumlah kegagalan, bukan detik. + Lama wilayah tetap ditampilkan setelah tidak dikenali, dalam satuan 100 ms. 0: hapus saat kegagalan pertama, maksimum: 0,7 detik, default: 0,3 detik. Tidak bergantung pada interval tangkapan. Semua diff --git a/WindowTranslator/Properties/Resources.ko.resx b/WindowTranslator/Properties/Resources.ko.resx index fcb51f1f..92306745 100644 --- a/WindowTranslator/Properties/Resources.ko.resx +++ b/WindowTranslator/Properties/Resources.ko.resx @@ -536,7 +536,7 @@ 인식 결과 안정성 문자열이나 영역의 분할·병합에 따른 일시적인 변동을 줄입니다. 1: 빠른 반응, 5: 높은 안정성(기본값: 3). 일시적 사라짐 유지 - 인식이 연속으로 누락될 때 표시를 유지할 횟수입니다. 0: 첫 누락 시 제거, 최대: 7회, 기본값: 3회. 초 단위가 아닙니다. + 인식이 누락된 영역의 표시 유지 시간을 100ms 단위로 지정합니다. 0: 첫 누락 시 제거, 최대: 0.7초, 기본값: 0.3초. 캡처 간격과 무관합니다. 전체 diff --git a/WindowTranslator/Properties/Resources.ms.resx b/WindowTranslator/Properties/Resources.ms.resx index 4a8d4ae9..779217a8 100644 --- a/WindowTranslator/Properties/Resources.ms.resx +++ b/WindowTranslator/Properties/Resources.ms.resx @@ -535,7 +535,7 @@ Monitor tidak disokong. Kestabilan pengecaman Mengurangkan perubahan sementara pada teks serta pemisahan atau penggabungan kawasan. 1: respons pantas, 5: lebih stabil (lalai: 3). Kekalkan paparan semasa kehilangan sementara - Bilangan kegagalan pengecaman berturut-turut untuk mengekalkan paparan. 0: buang pada kegagalan pertama, maksimum: 7, lalai: 3. Dikira mengikut bilangan kegagalan, bukan saat. + Tempoh kawasan kekal dipaparkan selepas pengecaman hilang, dalam unit 100 ms. 0: buang pada kegagalan pertama, maksimum: 0.7 saat, lalai: 0.3 saat. Tidak bergantung pada sela tangkapan. Semua diff --git a/WindowTranslator/Properties/Resources.pl.resx b/WindowTranslator/Properties/Resources.pl.resx index dc40c6c7..d1921bca 100644 --- a/WindowTranslator/Properties/Resources.pl.resx +++ b/WindowTranslator/Properties/Resources.pl.resx @@ -536,7 +536,7 @@ Monitory nie są obsługiwane. Stabilność rozpoznawania Ogranicza chwilowe zmiany tekstu oraz podział lub łączenie obszarów. 1: szybka reakcja, 5: wysoka stabilność (domyślnie: 3). Zachowanie przy chwilowym zniknięciu - Liczba kolejnych nieudanych rozpoznań, podczas których tekst pozostaje widoczny. 0: usuń przy pierwszym braku, maksimum: 7, domyślnie: 3. Liczone w brakach rozpoznania, nie w sekundach. + Czas wyświetlania obszaru po utracie rozpoznania, w jednostkach 100 ms. 0: usuń przy pierwszym braku, maksimum: 0,7 sekundy, domyślnie: 0,3 sekundy. Niezależny od interwału przechwytywania. Wszystkie diff --git a/WindowTranslator/Properties/Resources.pt-BR.resx b/WindowTranslator/Properties/Resources.pt-BR.resx index 254dcc25..9f2008e4 100644 --- a/WindowTranslator/Properties/Resources.pt-BR.resx +++ b/WindowTranslator/Properties/Resources.pt-BR.resx @@ -535,7 +535,7 @@ Monitor tidak didukung. Estabilidade do reconhecimento Reduz alterações temporárias no texto e na divisão ou união de regiões. 1: resposta rápida, 5: maior estabilidade (padrão: 3). Retenção durante desaparecimento temporário - Número de falhas consecutivas de reconhecimento durante as quais o texto permanece visível. 0: remover na primeira falha, máximo: 7, padrão: 3. Contado em falhas, não em segundos. + Tempo para manter uma região visível após a perda do reconhecimento, em unidades de 100 ms. 0: remover na primeira falha, máximo: 0,7 segundo, padrão: 0,3 segundo. Independente do intervalo de captura. Todos diff --git a/WindowTranslator/Properties/Resources.resx b/WindowTranslator/Properties/Resources.resx index 99922acc..56365c0e 100644 --- a/WindowTranslator/Properties/Resources.resx +++ b/WindowTranslator/Properties/Resources.resx @@ -490,7 +490,7 @@ 認識結果の安定性 文字列や領域の分割・統合の一時的な変動を抑えます。1: 追従重視、5: 安定重視(既定: 3)。 一時消失の保持 - 認識できない状態が連続して何回続くまで表示を保持するかを指定します。0: 最初の欠落で除去、最大: 7回、既定: 3回。秒数ではありません。 + 認識できなくなった領域の表示保持時間を100ミリ秒単位で指定します。0: 最初の欠落で除去、最大: 0.7秒、既定: 0.3秒。キャプチャ間隔には依存しません。 プラグイン diff --git a/WindowTranslator/Properties/Resources.ru.resx b/WindowTranslator/Properties/Resources.ru.resx index e705782f..94333377 100644 --- a/WindowTranslator/Properties/Resources.ru.resx +++ b/WindowTranslator/Properties/Resources.ru.resx @@ -527,7 +527,7 @@ Стабильность распознавания Уменьшает временные изменения текста, разделение и объединение областей. 1: быстрый отклик, 5: высокая стабильность (по умолчанию: 3). Сохранение при временном исчезновении - Число последовательных пропусков распознавания, при которых текст остаётся видимым. 0: удалить при первом пропуске, максимум: 7, по умолчанию: 3. Измеряется в пропусках, а не секундах. + Время отображения области после потери распознавания в единицах по 100 мс. 0: удалить при первом пропуске, максимум: 0,7 секунды, по умолчанию: 0,3 секунды. Не зависит от интервала захвата. Все diff --git a/WindowTranslator/Properties/Resources.th.resx b/WindowTranslator/Properties/Resources.th.resx index 461c4e20..0c46eb67 100644 --- a/WindowTranslator/Properties/Resources.th.resx +++ b/WindowTranslator/Properties/Resources.th.resx @@ -536,7 +536,7 @@ ความคงที่ของผลการรู้จำ ลดการเปลี่ยนแปลงชั่วคราวของข้อความและการแยกหรือรวมพื้นที่ 1: ตอบสนองเร็ว, 5: เน้นความคงที่ (ค่าเริ่มต้น: 3) คงการแสดงผลเมื่อหายไปชั่วคราว - จำนวนครั้งที่รู้จำไม่สำเร็จติดต่อกันซึ่งยังคงแสดงข้อความไว้ 0: ลบเมื่อพลาดครั้งแรก, สูงสุด: 7 ครั้ง, ค่าเริ่มต้น: 3 ครั้ง นับเป็นครั้ง ไม่ใช่วินาที + ระยะเวลาที่คงแสดงพื้นที่หลังจากรู้จำไม่สำเร็จ หน่วยละ 100 มิลลิวินาที 0: ลบเมื่อพลาดครั้งแรก, สูงสุด: 0.7 วินาที, ค่าเริ่มต้น: 0.3 วินาที ไม่ขึ้นกับช่วงเวลาจับภาพ ทั้งหมด diff --git a/WindowTranslator/Properties/Resources.tr.resx b/WindowTranslator/Properties/Resources.tr.resx index b0d1d8bd..678f1423 100644 --- a/WindowTranslator/Properties/Resources.tr.resx +++ b/WindowTranslator/Properties/Resources.tr.resx @@ -536,7 +536,7 @@ Monitör desteklenmiyor. Tanıma kararlılığı Metindeki ve bölgelerin bölünmesi veya birleşmesindeki geçici değişiklikleri azaltır. 1: hızlı tepki, 5: yüksek kararlılık (varsayılan: 3). Geçici kaybolmada görüntüyü koruma - Metnin görüntülenmeye devam edeceği art arda başarısız tanıma sayısı. 0: ilk başarısızlıkta kaldır, en fazla: 7, varsayılan: 3. Saniye değil, başarısızlık sayısıdır. + Tanıma kaybolduktan sonra bölgenin gösterilme süresi, 100 ms birimleriyle. 0: ilk kayıpta kaldır, en fazla: 0,7 saniye, varsayılan: 0,3 saniye. Yakalama aralığından bağımsızdır. Tümü diff --git a/WindowTranslator/Properties/Resources.vi.resx b/WindowTranslator/Properties/Resources.vi.resx index 961b60a3..961d75b7 100644 --- a/WindowTranslator/Properties/Resources.vi.resx +++ b/WindowTranslator/Properties/Resources.vi.resx @@ -536,7 +536,7 @@ Màn hình không được hỗ trợ. Độ ổn định nhận dạng Giảm thay đổi tạm thời của văn bản và việc tách hoặc gộp vùng. 1: phản hồi nhanh, 5: ưu tiên ổn định (mặc định: 3). Giữ hiển thị khi tạm biến mất - Số lần liên tiếp không nhận dạng được mà vẫn giữ hiển thị. 0: xóa ngay lần đầu, tối đa: 7, mặc định: 3. Tính theo số lần, không phải giây. + Thời gian giữ hiển thị vùng sau khi không còn nhận dạng được, theo đơn vị 100 ms. 0: xóa ngay lần đầu, tối đa: 0,7 giây, mặc định: 0,3 giây. Không phụ thuộc vào khoảng thời gian chụp. Tất cả diff --git a/WindowTranslator/Properties/Resources.zh-CN.resx b/WindowTranslator/Properties/Resources.zh-CN.resx index d849b7f7..724c1344 100644 --- a/WindowTranslator/Properties/Resources.zh-CN.resx +++ b/WindowTranslator/Properties/Resources.zh-CN.resx @@ -536,7 +536,7 @@ 识别结果稳定性 减少文字及区域拆分、合并的临时变化。1:优先响应,5:优先稳定(默认:3)。 临时消失保留 - 连续识别缺失时保持显示的次数。0:首次缺失即移除,最多:7次,默认:3次。单位为次数,而非秒数。 + 识别缺失后区域保持显示的时间,以100毫秒为单位。0:首次缺失即移除,最长:0.7秒,默认:0.3秒。不受捕获间隔影响。 全部 diff --git a/WindowTranslator/Properties/Resources.zh-TW.resx b/WindowTranslator/Properties/Resources.zh-TW.resx index 63d7ef86..a19eb016 100644 --- a/WindowTranslator/Properties/Resources.zh-TW.resx +++ b/WindowTranslator/Properties/Resources.zh-TW.resx @@ -536,7 +536,7 @@ 辨識結果穩定性 減少文字及區域分割、合併的暫時變化。1:優先反應,5:優先穩定(預設:3)。 暫時消失保留 - 連續辨識缺失時維持顯示的次數。0:首次缺失即移除,最多:7次,預設:3次。單位為次數,而非秒數。 + 辨識缺失後區域維持顯示的時間,以100毫秒為單位。0:首次缺失即移除,最長:0.7秒,預設:0.3秒。不受擷取間隔影響。 全部 From a165db6fe629245035629d1c409c9f84c4476e84 Mon Sep 17 00:00:00 2001 From: Freesia Date: Mon, 12 Oct 2026 03:19:46 +0900 Subject: [PATCH 02/10] =?UTF-8?q?OCR=E3=83=88=E3=83=A9=E3=83=83=E3=82=AB?= =?UTF-8?q?=E3=83=BC=E3=81=AE=E6=99=82=E5=88=BB=E7=AE=A1=E7=90=86=E3=82=92?= =?UTF-8?q?=E5=AE=9F=E8=A3=85=E3=82=AF=E3=83=A9=E3=82=B9=E3=81=AB=E3=81=BE?= =?UTF-8?q?=E3=81=A8=E3=82=81=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Modules/Main/MainViewModelBase.cs | 12 +++---- .../Modules/Ocr/IOcrTextTracker.cs | 7 +---- .../Modules/Ocr/OcrTextTracker.cs | 31 +++++++++++++------ 3 files changed, 28 insertions(+), 22 deletions(-) diff --git a/WindowTranslator/Modules/Main/MainViewModelBase.cs b/WindowTranslator/Modules/Main/MainViewModelBase.cs index 05e6540d..bbfd6f3d 100644 --- a/WindowTranslator/Modules/Main/MainViewModelBase.cs +++ b/WindowTranslator/Modules/Main/MainViewModelBase.cs @@ -1,5 +1,4 @@ using System.Collections.ObjectModel; -using System.Diagnostics; using System.Drawing; using System.IO; using System.Windows; @@ -24,7 +23,7 @@ namespace WindowTranslator.Modules.Main; public abstract partial class MainViewModelBase : IDisposable { private readonly Subject captureRequests = new(); - private readonly Subject<(IEnumerable Texts, FilterContext Context, TimeSpan ObservedAt)?> trackingRequests = new(); + private readonly Subject<(IEnumerable Texts, FilterContext Context)?> trackingRequests = new(); private readonly Subject translationRequests = new(); private readonly Subject translationCompleted = new(); private readonly Subject> uiRequests = new(); @@ -124,7 +123,7 @@ public MainViewModelBase( : Observable.Merge( this.isOneShotMode ? Observable.Return(Unit.Default) - : Observable.Timer(TimeSpan.Zero, OcrTextTracker.ProcessingInterval).AsUnitObservable(), + : Observable.Timer(TimeSpan.Zero, TimeSpan.FromMilliseconds(100)).AsUnitObservable(), this.translationCompleted) .Select(_ => request)) .Switch() @@ -294,17 +293,16 @@ private async ValueTask RecognizeAsync(SoftwareBitmap capturedBitmap, Cancellati SoftwareBitmap = sbmp, ImageSize = new(sbmp.PixelWidth, sbmp.PixelHeight), }; - this.trackingRequests.OnNext((texts, context, TimeSpan.FromSeconds((double)Stopwatch.GetTimestamp() / Stopwatch.Frequency))); + this.trackingRequests.OnNext((texts, context)); sbmp = null; } - private async ValueTask ProcessTrackingAsync((IEnumerable Texts, FilterContext Context, TimeSpan ObservedAt) request, CancellationToken cancellationToken) + private async ValueTask ProcessTrackingAsync((IEnumerable Texts, FilterContext Context) request, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); IEnumerable texts = this.isOneShotMode ? request.Texts - : this.ocrTextTracker.Update(request.Texts, request.Context.ImageSize, - TimeSpan.FromSeconds((double)Stopwatch.GetTimestamp() / Stopwatch.Frequency), request.ObservedAt); + : this.ocrTextTracker.Update(request.Texts, request.Context.ImageSize); texts = texts.Select(t => t with { FontSize = t.FontSize * this.fontScale }); using (this.Filtering.EnterBusy()) { diff --git a/WindowTranslator/Modules/Ocr/IOcrTextTracker.cs b/WindowTranslator/Modules/Ocr/IOcrTextTracker.cs index 948498c3..6512ff70 100644 --- a/WindowTranslator/Modules/Ocr/IOcrTextTracker.cs +++ b/WindowTranslator/Modules/Ocr/IOcrTextTracker.cs @@ -8,15 +8,10 @@ namespace WindowTranslator.Modules.Ocr; public interface IOcrTextTracker { /// - /// 現在フレームのOCR観測でトラックを更新する。 + /// 最新のOCR観測でトラックを更新し、安定化したテキスト領域を返す。 /// IReadOnlyList Update(IEnumerable observations, Size imageSize); - /// - /// 最新の観測を処理時刻に基づいて安定化する。観測時刻は移動予測に使用する。 - /// - IReadOnlyList Update(IEnumerable observations, Size imageSize, TimeSpan timestamp, TimeSpan observationTimestamp); - /// /// 保持しているすべてのトラックを破棄する。 /// diff --git a/WindowTranslator/Modules/Ocr/OcrTextTracker.cs b/WindowTranslator/Modules/Ocr/OcrTextTracker.cs index fcc58b14..be7151b7 100644 --- a/WindowTranslator/Modules/Ocr/OcrTextTracker.cs +++ b/WindowTranslator/Modules/Ocr/OcrTextTracker.cs @@ -12,7 +12,7 @@ namespace WindowTranslator.Modules.Ocr; /// public sealed class OcrTextTracker(ILogger logger, TargetSettings? settings = null) : IOcrTextTracker { - public static readonly TimeSpan ProcessingInterval = TimeSpan.FromMilliseconds(100); + private static readonly TimeSpan ProcessingInterval = TimeSpan.FromMilliseconds(100); private static readonly TimeSpan assignmentRecency = ProcessingInterval * 4; private const int MaxStructureCandidates = 6; private const int MaxStructureSelectionStates = 1024; @@ -39,26 +39,38 @@ public sealed class OcrTextTracker(ILogger logger, TargetSetting private readonly ILogger logger = logger; private readonly TrackingProfile profile = TrackingProfile.FromSettings(settings ?? new()); private readonly object syncRoot = new(); + private readonly Stopwatch clock = Stopwatch.StartNew(); private readonly List tracks = []; private Dictionary mergeCandidates = []; private Dictionary dormantRestoreCandidates = []; private long nextTrackId; + private IEnumerable? previousObservations; + private TimeSpan observationTimestamp; public IReadOnlyList Update(IEnumerable observations, Size imageSize) - => this.Update( - observations, - imageSize, - TimeSpan.FromSeconds((double)Stopwatch.GetTimestamp() / Stopwatch.Frequency)); + { + ArgumentNullException.ThrowIfNull(observations); + lock (this.syncRoot) + { + var timestamp = this.clock.Elapsed; + if (!ReferenceEquals(this.previousObservations, observations)) + { + this.previousObservations = observations; + this.observationTimestamp = timestamp; + } + return this.UpdateCore(observations, imageSize, timestamp, this.observationTimestamp); + } + } + /// + /// 記録済みのOCR観測を記録時刻で再計算する。 + /// public IReadOnlyList Update(IEnumerable observations, Size imageSize, TimeSpan timestamp) - => this.Update(observations, imageSize, timestamp, timestamp); - - public IReadOnlyList Update(IEnumerable observations, Size imageSize, TimeSpan timestamp, TimeSpan observationTimestamp) { ArgumentNullException.ThrowIfNull(observations); lock (this.syncRoot) { - return this.UpdateCore(observations, imageSize, timestamp, observationTimestamp); + return this.UpdateCore(observations, imageSize, timestamp, timestamp); } } @@ -70,6 +82,7 @@ public void Reset() this.mergeCandidates.Clear(); this.dormantRestoreCandidates.Clear(); this.nextTrackId = 0; + this.previousObservations = null; } } From 4c4f21014fb2732e8c395a015e5a7dec9b4f111d Mon Sep 17 00:00:00 2001 From: Freesia Date: Mon, 12 Oct 2026 03:50:37 +0900 Subject: [PATCH 03/10] =?UTF-8?q?=E4=BF=9D=E6=8C=81=E3=83=BB=E5=AE=89?= =?UTF-8?q?=E5=AE=9A=E5=8C=96=E8=A8=AD=E5=AE=9A=E3=81=AE=E6=99=82=E9=96=93?= =?UTF-8?q?=E6=8F=9B=E7=AE=97=E3=82=92300ms=E5=9F=BA=E6=BA=96=E3=81=AB?= =?UTF-8?q?=E8=AA=BF=E6=95=B4=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- WindowTranslator.Abstractions/UserSettings.cs | 2 +- .../Modules/Ocr/OcrTextTracker.cs | 19 ++++++++++--------- WindowTranslator/Properties/Resources.ar.resx | 2 +- WindowTranslator/Properties/Resources.cs.resx | 2 +- WindowTranslator/Properties/Resources.de.resx | 2 +- WindowTranslator/Properties/Resources.en.resx | 2 +- WindowTranslator/Properties/Resources.es.resx | 2 +- WindowTranslator/Properties/Resources.fa.resx | 2 +- .../Properties/Resources.fil.resx | 2 +- WindowTranslator/Properties/Resources.fr.resx | 2 +- WindowTranslator/Properties/Resources.hi.resx | 2 +- WindowTranslator/Properties/Resources.hu.resx | 2 +- WindowTranslator/Properties/Resources.id.resx | 2 +- WindowTranslator/Properties/Resources.ko.resx | 2 +- WindowTranslator/Properties/Resources.ms.resx | 2 +- WindowTranslator/Properties/Resources.pl.resx | 2 +- .../Properties/Resources.pt-BR.resx | 2 +- WindowTranslator/Properties/Resources.resx | 2 +- WindowTranslator/Properties/Resources.ru.resx | 2 +- WindowTranslator/Properties/Resources.th.resx | 2 +- WindowTranslator/Properties/Resources.tr.resx | 2 +- WindowTranslator/Properties/Resources.vi.resx | 2 +- .../Properties/Resources.zh-CN.resx | 2 +- .../Properties/Resources.zh-TW.resx | 2 +- 24 files changed, 33 insertions(+), 32 deletions(-) diff --git a/WindowTranslator.Abstractions/UserSettings.cs b/WindowTranslator.Abstractions/UserSettings.cs index 1f927fd1..29e1761a 100644 --- a/WindowTranslator.Abstractions/UserSettings.cs +++ b/WindowTranslator.Abstractions/UserSettings.cs @@ -108,7 +108,7 @@ public class TargetSettings public int OcrRecognitionStability { get; set => field = Math.Clamp(value, 1, 5); } = 3; /// - /// 認識できなくなった領域の表示保持時間(100ミリ秒単位、0~7) + /// 認識できなくなった領域の表示保持の強さ(0: 即時除去、7: 保持重視) /// public int OcrMissingFrameRetention { get; set => field = Math.Clamp(value, 0, 7); } = 3; diff --git a/WindowTranslator/Modules/Ocr/OcrTextTracker.cs b/WindowTranslator/Modules/Ocr/OcrTextTracker.cs index be7151b7..f1b2ad3f 100644 --- a/WindowTranslator/Modules/Ocr/OcrTextTracker.cs +++ b/WindowTranslator/Modules/Ocr/OcrTextTracker.cs @@ -12,8 +12,9 @@ namespace WindowTranslator.Modules.Ocr; /// public sealed class OcrTextTracker(ILogger logger, TargetSettings? settings = null) : IOcrTextTracker { - private static readonly TimeSpan ProcessingInterval = TimeSpan.FromMilliseconds(100); - private static readonly TimeSpan assignmentRecency = ProcessingInterval * 4; + // 旧方式で測定したOCR間隔(約280ms)を参考に、設定の段階を時間へ換算する。 + private static readonly TimeSpan ReferenceInterval = TimeSpan.FromMilliseconds(300); + private static readonly TimeSpan assignmentRecency = ReferenceInterval * 4; private const int MaxStructureCandidates = 6; private const int MaxStructureSelectionStates = 1024; private const int MaxOneToOneCandidatesPerResource = 3; @@ -1928,11 +1929,11 @@ public static TrackingProfile FromSettings(TargetSettings settings) _ => (2, 2, 1.5, 5), }; return new( - ProcessingInterval * (geometry - 1), - ProcessingInterval * (microGeometry - 1), - ProcessingInterval * (structure - 1), + ReferenceInterval * (geometry - 1), + ReferenceInterval * (microGeometry - 1), + ReferenceInterval * (structure - 1), votes, threshold, history, - ProcessingInterval * settings.OcrMissingFrameRetention); + ReferenceInterval * settings.OcrMissingFrameRetention); } } @@ -2074,7 +2075,7 @@ private void UpdateText(string current, TimeSpan timestamp) return; } - if (this.textVotes.Count > 0 && timestamp - this.textVotes[^1].Timestamp < ProcessingInterval) + if (this.textVotes.Count > 0 && timestamp - this.textVotes[^1].Timestamp < ReferenceInterval) { this.textVotes[^1] = new(normalizedCurrent, current, this.textVotes[^1].Timestamp); } @@ -2088,7 +2089,7 @@ private void UpdateText(string current, TimeSpan timestamp) } (string normalized, int count, double weight)? winner = this.textVotes - .Select(vote => (vote, age: (timestamp - vote.Timestamp) / ProcessingInterval)) + .Select(vote => (vote, age: (timestamp - vote.Timestamp) / ReferenceInterval)) .GroupBy(item => item.vote.Normalized) .Select(group => ( normalized: group.Key, @@ -2108,7 +2109,7 @@ private void UpdateText(string current, TimeSpan timestamp) return; } - if (timestamp - this.textVotes[0].Timestamp >= ProcessingInterval * (profile.TextVoteHistorySize - 1)) + if (timestamp - this.textVotes[0].Timestamp >= ReferenceInterval * (profile.TextVoteHistorySize - 1)) { TextVote latest = this.textVotes[^1]; this.ConfirmedText = latest.Original; diff --git a/WindowTranslator/Properties/Resources.ar.resx b/WindowTranslator/Properties/Resources.ar.resx index 9e616fe5..1dcc750a 100644 --- a/WindowTranslator/Properties/Resources.ar.resx +++ b/WindowTranslator/Properties/Resources.ar.resx @@ -527,7 +527,7 @@ استقرار نتائج التعرّف يقلل التغيّرات المؤقتة في النص وتقسيم المناطق أو دمجها. 1: استجابة سريعة، 5: استقرار أكبر (الافتراضي: 3). الاحتفاظ عند الاختفاء المؤقت - مدة إبقاء المنطقة ظاهرة بعد فقدان التعرّف، بوحدات 100 مللي ثانية. 0: إزالة عند أول فقدان، الحد الأقصى: 0.7 ثانية، الافتراضي: 0.3 ثانية. لا تعتمد على الفاصل الزمني للالتقاط. + يحدد مدة بقاء المنطقة ظاهرة بعد فقدان التعرّف. 0: إزالة عند أول فقدان، الحد الأقصى: 7 (نحو 2.1 ثانية)، الافتراضي: 3 (نحو 0.9 ثانية). لا تعتمد على الفاصل الزمني للالتقاط. الكل diff --git a/WindowTranslator/Properties/Resources.cs.resx b/WindowTranslator/Properties/Resources.cs.resx index accf6f02..be650d89 100644 --- a/WindowTranslator/Properties/Resources.cs.resx +++ b/WindowTranslator/Properties/Resources.cs.resx @@ -417,7 +417,7 @@ Monitory nejsou podporovány. Stabilita rozpoznávání Omezuje dočasné změny textu a rozdělování nebo slučování oblastí. 1: rychlá odezva, 5: vysoká stabilita (výchozí: 3). Zachování při dočasném zmizení - Doba zobrazení oblasti po ztrátě rozpoznání v jednotkách 100 ms. 0: odstranit při prvním výpadku, maximum: 0,7 sekundy, výchozí: 0,3 sekundy. Nezávisí na intervalu snímání. + Určuje, jak dlouho oblast zůstane zobrazená po ztrátě rozpoznání. 0: odstranit při prvním výpadku, maximum: 7 (přibližně 2,1 sekundy), výchozí: 3 (přibližně 0,9 sekundy). Nezávisí na intervalu snímání. Vše diff --git a/WindowTranslator/Properties/Resources.de.resx b/WindowTranslator/Properties/Resources.de.resx index a4ab5178..36418d78 100644 --- a/WindowTranslator/Properties/Resources.de.resx +++ b/WindowTranslator/Properties/Resources.de.resx @@ -536,7 +536,7 @@ Monitore werden nicht unterstützt. Erkennungsstabilität Reduziert vorübergehende Änderungen am Text sowie das Aufteilen oder Zusammenführen von Bereichen. 1: schnelle Reaktion, 5: hohe Stabilität (Standard: 3). Anzeige bei vorübergehendem Verschwinden - Anzeigedauer eines Bereichs nach ausbleibender Erkennung in Einheiten von 100 ms. 0: beim ersten Fehlen entfernen, Maximum: 0,7 Sekunden, Standard: 0,3 Sekunden. Unabhängig vom Aufnahmeintervall. + Legt fest, wie lange ein Bereich nach ausbleibender Erkennung sichtbar bleibt. 0: beim ersten Fehlen entfernen, Maximum: 7 (etwa 2,1 Sekunden), Standard: 3 (etwa 0,9 Sekunden). Unabhängig vom Aufnahmeintervall. Alle diff --git a/WindowTranslator/Properties/Resources.en.resx b/WindowTranslator/Properties/Resources.en.resx index a4b51128..9bd87bd6 100644 --- a/WindowTranslator/Properties/Resources.en.resx +++ b/WindowTranslator/Properties/Resources.en.resx @@ -558,7 +558,7 @@ Select a slightly wider area so that text is not cut off Recognition stability Reduces temporary changes in text and region splitting or merging. 1: responsive, 5: stable (default: 3). Temporary disappearance retention - How long to keep displaying a region after recognition is lost, in 100 ms units. 0: remove on the first miss, maximum: 0.7 seconds, default: 0.3 seconds. Independent of the capture interval. + Controls how long a region remains visible after recognition is lost. 0: remove on the first miss, maximum: 7 (about 2.1 seconds), default: 3 (about 0.9 seconds). Independent of the capture interval. All diff --git a/WindowTranslator/Properties/Resources.es.resx b/WindowTranslator/Properties/Resources.es.resx index f34d9083..be11eaf4 100644 --- a/WindowTranslator/Properties/Resources.es.resx +++ b/WindowTranslator/Properties/Resources.es.resx @@ -527,7 +527,7 @@ Estabilidad del reconocimiento Reduce los cambios temporales del texto y la división o unión de regiones. 1: respuesta rápida, 5: mayor estabilidad (predeterminado: 3). Retención ante desapariciones temporales - Tiempo que se mantiene visible una región tras perder el reconocimiento, en unidades de 100 ms. 0: eliminar en el primer fallo, máximo: 0,7 segundos, predeterminado: 0,3 segundos. Independiente del intervalo de captura. + Controla cuánto tiempo permanece visible una región tras perder el reconocimiento. 0: eliminar en el primer fallo, máximo: 7 (unos 2,1 segundos), predeterminado: 3 (unos 0,9 segundos). Independiente del intervalo de captura. Todos diff --git a/WindowTranslator/Properties/Resources.fa.resx b/WindowTranslator/Properties/Resources.fa.resx index 90fff064..0961fae2 100644 --- a/WindowTranslator/Properties/Resources.fa.resx +++ b/WindowTranslator/Properties/Resources.fa.resx @@ -521,7 +521,7 @@ پایداری تشخیص تغییرات موقت متن و تقسیم یا ادغام ناحیه‌ها را کاهش می‌دهد. 1: پاسخ سریع، 5: پایداری بیشتر (پیش‌فرض: 3). حفظ نمایش هنگام ناپدید شدن موقت - مدت نمایش ناحیه پس از از دست رفتن تشخیص، در واحدهای 100 میلی‌ثانیه. 0: حذف در اولین عدم تشخیص، حداکثر: 0.7 ثانیه، پیش‌فرض: 0.3 ثانیه. مستقل از فاصلهٔ زمانی ثبت تصویر است. + مدت نمایش ناحیه پس از از دست رفتن تشخیص را تعیین می‌کند. 0: حذف در اولین عدم تشخیص، حداکثر: 7 (حدود 2.1 ثانیه)، پیش‌فرض: 3 (حدود 0.9 ثانیه). مستقل از فاصلهٔ زمانی ثبت تصویر است. همه diff --git a/WindowTranslator/Properties/Resources.fil.resx b/WindowTranslator/Properties/Resources.fil.resx index 5e85460f..bdae8fa4 100644 --- a/WindowTranslator/Properties/Resources.fil.resx +++ b/WindowTranslator/Properties/Resources.fil.resx @@ -536,7 +536,7 @@ Ang monitor ay hindi suportado. Katatagan ng pagkilala Binabawasan ang pansamantalang pagbabago sa teksto at paghahati o pagsasama ng mga rehiyon. 1: mabilis na pagtugon, 5: mas matatag (default: 3). Pagpapanatili kapag pansamantalang nawala - Tagal ng pagpapakita ng rehiyon matapos itong hindi makilala, sa mga yunit na 100 ms. 0: alisin sa unang pagkabigo, pinakamataas: 0.7 segundo, default: 0.3 segundo. Hindi nakadepende sa pagitan ng pagkuha. + Kinokontrol kung gaano katagal mananatiling nakikita ang rehiyon matapos itong hindi makilala. 0: alisin sa unang pagkabigo, pinakamataas: 7 (humigit-kumulang 2.1 segundo), default: 3 (humigit-kumulang 0.9 segundo). Hindi nakadepende sa pagitan ng pagkuha. Lahat diff --git a/WindowTranslator/Properties/Resources.fr.resx b/WindowTranslator/Properties/Resources.fr.resx index 8b381a63..5af4c6c3 100644 --- a/WindowTranslator/Properties/Resources.fr.resx +++ b/WindowTranslator/Properties/Resources.fr.resx @@ -527,7 +527,7 @@ Stabilité de la reconnaissance Réduit les variations temporaires du texte et de la division ou fusion des zones. 1 : réactivité, 5 : stabilité (par défaut : 3). Maintien en cas de disparition temporaire - Durée d'affichage d'une zone après la perte de reconnaissance, par unités de 100 ms. 0 : supprimer dès le premier échec, maximum : 0,7 seconde, par défaut : 0,3 seconde. Indépendante de l'intervalle de capture. + Définit combien de temps une zone reste affichée après la perte de reconnaissance. 0 : supprimer dès le premier échec, maximum : 7 (environ 2,1 secondes), par défaut : 3 (environ 0,9 seconde). Indépendante de l'intervalle de capture. Tous diff --git a/WindowTranslator/Properties/Resources.hi.resx b/WindowTranslator/Properties/Resources.hi.resx index d742e82a..32ac7666 100644 --- a/WindowTranslator/Properties/Resources.hi.resx +++ b/WindowTranslator/Properties/Resources.hi.resx @@ -529,7 +529,7 @@ पहचान की स्थिरता पाठ और क्षेत्रों के विभाजन या विलय में अस्थायी बदलाव कम करता है। 1: तेज़ प्रतिक्रिया, 5: अधिक स्थिरता (डिफ़ॉल्ट: 3)। अस्थायी रूप से गायब होने पर बनाए रखना - पहचान खोने के बाद क्षेत्र को दिखाते रहने का समय, 100 मिलीसेकंड की इकाइयों में। 0: पहली विफलता पर हटाएँ, अधिकतम: 0.7 सेकंड, डिफ़ॉल्ट: 0.3 सेकंड। कैप्चर अंतराल से स्वतंत्र। + पहचान खोने के बाद क्षेत्र कितनी देर तक दिखाई देगा, यह तय करता है। 0: पहली विफलता पर हटाएँ, अधिकतम: 7 (लगभग 2.1 सेकंड), डिफ़ॉल्ट: 3 (लगभग 0.9 सेकंड)। कैप्चर अंतराल से स्वतंत्र। सभी diff --git a/WindowTranslator/Properties/Resources.hu.resx b/WindowTranslator/Properties/Resources.hu.resx index 656e303f..a459c78b 100644 --- a/WindowTranslator/Properties/Resources.hu.resx +++ b/WindowTranslator/Properties/Resources.hu.resx @@ -417,7 +417,7 @@ A monitorok nem támogatottak. Felismerés stabilitása Csökkenti a szöveg, valamint a területek felosztásának és egyesítésének átmeneti változásait. 1: gyors reakció, 5: nagy stabilitás (alapérték: 3). Megjelenítés megtartása átmeneti eltűnéskor - A terület megjelenítésének időtartama a felismerés elvesztése után, 100 ms-os egységekben. 0: eltávolítás az első kimaradáskor, maximum: 0,7 másodperc, alapérték: 0,3 másodperc. Független a rögzítési időköztől. + Meghatározza, mennyi ideig marad látható a terület a felismerés elvesztése után. 0: eltávolítás az első kimaradáskor, maximum: 7 (kb. 2,1 másodperc), alapérték: 3 (kb. 0,9 másodperc). Független a rögzítési időköztől. Összes diff --git a/WindowTranslator/Properties/Resources.id.resx b/WindowTranslator/Properties/Resources.id.resx index fd054fcc..002641ba 100644 --- a/WindowTranslator/Properties/Resources.id.resx +++ b/WindowTranslator/Properties/Resources.id.resx @@ -535,7 +535,7 @@ Monitor tidak didukung. Stabilitas pengenalan Mengurangi perubahan sementara pada teks serta pemisahan atau penggabungan area. 1: respons cepat, 5: lebih stabil (default: 3). Pertahankan saat hilang sementara - Lama wilayah tetap ditampilkan setelah tidak dikenali, dalam satuan 100 ms. 0: hapus saat kegagalan pertama, maksimum: 0,7 detik, default: 0,3 detik. Tidak bergantung pada interval tangkapan. + Menentukan berapa lama wilayah tetap ditampilkan setelah tidak dikenali. 0: hapus saat kegagalan pertama, maksimum: 7 (sekitar 2,1 detik), default: 3 (sekitar 0,9 detik). Tidak bergantung pada interval tangkapan. Semua diff --git a/WindowTranslator/Properties/Resources.ko.resx b/WindowTranslator/Properties/Resources.ko.resx index 92306745..48bc6695 100644 --- a/WindowTranslator/Properties/Resources.ko.resx +++ b/WindowTranslator/Properties/Resources.ko.resx @@ -536,7 +536,7 @@ 인식 결과 안정성 문자열이나 영역의 분할·병합에 따른 일시적인 변동을 줄입니다. 1: 빠른 반응, 5: 높은 안정성(기본값: 3). 일시적 사라짐 유지 - 인식이 누락된 영역의 표시 유지 시간을 100ms 단위로 지정합니다. 0: 첫 누락 시 제거, 최대: 0.7초, 기본값: 0.3초. 캡처 간격과 무관합니다. + 인식이 누락된 영역의 표시 유지 정도를 지정합니다. 0: 첫 누락 시 제거, 최대: 7 (약 2.1초), 기본값: 3 (약 0.9초). 캡처 간격과 무관합니다. 전체 diff --git a/WindowTranslator/Properties/Resources.ms.resx b/WindowTranslator/Properties/Resources.ms.resx index 779217a8..9536dabb 100644 --- a/WindowTranslator/Properties/Resources.ms.resx +++ b/WindowTranslator/Properties/Resources.ms.resx @@ -535,7 +535,7 @@ Monitor tidak disokong. Kestabilan pengecaman Mengurangkan perubahan sementara pada teks serta pemisahan atau penggabungan kawasan. 1: respons pantas, 5: lebih stabil (lalai: 3). Kekalkan paparan semasa kehilangan sementara - Tempoh kawasan kekal dipaparkan selepas pengecaman hilang, dalam unit 100 ms. 0: buang pada kegagalan pertama, maksimum: 0.7 saat, lalai: 0.3 saat. Tidak bergantung pada sela tangkapan. + Menentukan tempoh kawasan kekal dipaparkan selepas pengecaman hilang. 0: buang pada kegagalan pertama, maksimum: 7 (kira-kira 2.1 saat), lalai: 3 (kira-kira 0.9 saat). Tidak bergantung pada sela tangkapan. Semua diff --git a/WindowTranslator/Properties/Resources.pl.resx b/WindowTranslator/Properties/Resources.pl.resx index d1921bca..f6b8046b 100644 --- a/WindowTranslator/Properties/Resources.pl.resx +++ b/WindowTranslator/Properties/Resources.pl.resx @@ -536,7 +536,7 @@ Monitory nie są obsługiwane. Stabilność rozpoznawania Ogranicza chwilowe zmiany tekstu oraz podział lub łączenie obszarów. 1: szybka reakcja, 5: wysoka stabilność (domyślnie: 3). Zachowanie przy chwilowym zniknięciu - Czas wyświetlania obszaru po utracie rozpoznania, w jednostkach 100 ms. 0: usuń przy pierwszym braku, maksimum: 0,7 sekundy, domyślnie: 0,3 sekundy. Niezależny od interwału przechwytywania. + Określa, jak długo obszar pozostaje widoczny po utracie rozpoznania. 0: usuń przy pierwszym braku, maksimum: 7 (około 2,1 sekundy), domyślnie: 3 (około 0,9 sekundy). Niezależny od interwału przechwytywania. Wszystkie diff --git a/WindowTranslator/Properties/Resources.pt-BR.resx b/WindowTranslator/Properties/Resources.pt-BR.resx index 9f2008e4..dfc42139 100644 --- a/WindowTranslator/Properties/Resources.pt-BR.resx +++ b/WindowTranslator/Properties/Resources.pt-BR.resx @@ -535,7 +535,7 @@ Monitor tidak didukung. Estabilidade do reconhecimento Reduz alterações temporárias no texto e na divisão ou união de regiões. 1: resposta rápida, 5: maior estabilidade (padrão: 3). Retenção durante desaparecimento temporário - Tempo para manter uma região visível após a perda do reconhecimento, em unidades de 100 ms. 0: remover na primeira falha, máximo: 0,7 segundo, padrão: 0,3 segundo. Independente do intervalo de captura. + Controla por quanto tempo uma região permanece visível após a perda do reconhecimento. 0: remover na primeira falha, máximo: 7 (cerca de 2,1 segundos), padrão: 3 (cerca de 0,9 segundo). Independente do intervalo de captura. Todos diff --git a/WindowTranslator/Properties/Resources.resx b/WindowTranslator/Properties/Resources.resx index 56365c0e..aca2f40f 100644 --- a/WindowTranslator/Properties/Resources.resx +++ b/WindowTranslator/Properties/Resources.resx @@ -490,7 +490,7 @@ 認識結果の安定性 文字列や領域の分割・統合の一時的な変動を抑えます。1: 追従重視、5: 安定重視(既定: 3)。 一時消失の保持 - 認識できなくなった領域の表示保持時間を100ミリ秒単位で指定します。0: 最初の欠落で除去、最大: 0.7秒、既定: 0.3秒。キャプチャ間隔には依存しません。 + 認識できなくなった領域の表示を保持する強さを指定します。0: 最初の欠落で除去、最大: 7(約2.1秒)、既定: 3(約0.9秒)。キャプチャ間隔には依存しません。 プラグイン diff --git a/WindowTranslator/Properties/Resources.ru.resx b/WindowTranslator/Properties/Resources.ru.resx index 94333377..24060e28 100644 --- a/WindowTranslator/Properties/Resources.ru.resx +++ b/WindowTranslator/Properties/Resources.ru.resx @@ -527,7 +527,7 @@ Стабильность распознавания Уменьшает временные изменения текста, разделение и объединение областей. 1: быстрый отклик, 5: высокая стабильность (по умолчанию: 3). Сохранение при временном исчезновении - Время отображения области после потери распознавания в единицах по 100 мс. 0: удалить при первом пропуске, максимум: 0,7 секунды, по умолчанию: 0,3 секунды. Не зависит от интервала захвата. + Задаёт, как долго область остаётся видимой после потери распознавания. 0: удалить при первом пропуске, максимум: 7 (около 2,1 секунды), по умолчанию: 3 (около 0,9 секунды). Не зависит от интервала захвата. Все diff --git a/WindowTranslator/Properties/Resources.th.resx b/WindowTranslator/Properties/Resources.th.resx index 0c46eb67..a0e01f00 100644 --- a/WindowTranslator/Properties/Resources.th.resx +++ b/WindowTranslator/Properties/Resources.th.resx @@ -536,7 +536,7 @@ ความคงที่ของผลการรู้จำ ลดการเปลี่ยนแปลงชั่วคราวของข้อความและการแยกหรือรวมพื้นที่ 1: ตอบสนองเร็ว, 5: เน้นความคงที่ (ค่าเริ่มต้น: 3) คงการแสดงผลเมื่อหายไปชั่วคราว - ระยะเวลาที่คงแสดงพื้นที่หลังจากรู้จำไม่สำเร็จ หน่วยละ 100 มิลลิวินาที 0: ลบเมื่อพลาดครั้งแรก, สูงสุด: 0.7 วินาที, ค่าเริ่มต้น: 0.3 วินาที ไม่ขึ้นกับช่วงเวลาจับภาพ + กำหนดระยะเวลาที่คงแสดงพื้นที่หลังจากรู้จำไม่สำเร็จ 0: ลบเมื่อพลาดครั้งแรก, สูงสุด: 7 (ประมาณ 2.1 วินาที), ค่าเริ่มต้น: 3 (ประมาณ 0.9 วินาที) ไม่ขึ้นกับช่วงเวลาจับภาพ ทั้งหมด diff --git a/WindowTranslator/Properties/Resources.tr.resx b/WindowTranslator/Properties/Resources.tr.resx index 678f1423..52a93737 100644 --- a/WindowTranslator/Properties/Resources.tr.resx +++ b/WindowTranslator/Properties/Resources.tr.resx @@ -536,7 +536,7 @@ Monitör desteklenmiyor. Tanıma kararlılığı Metindeki ve bölgelerin bölünmesi veya birleşmesindeki geçici değişiklikleri azaltır. 1: hızlı tepki, 5: yüksek kararlılık (varsayılan: 3). Geçici kaybolmada görüntüyü koruma - Tanıma kaybolduktan sonra bölgenin gösterilme süresi, 100 ms birimleriyle. 0: ilk kayıpta kaldır, en fazla: 0,7 saniye, varsayılan: 0,3 saniye. Yakalama aralığından bağımsızdır. + Tanıma kaybolduktan sonra bölgenin ne kadar süre gösterileceğini belirler. 0: ilk kayıpta kaldır, en fazla: 7 (yaklaşık 2,1 saniye), varsayılan: 3 (yaklaşık 0,9 saniye). Yakalama aralığından bağımsızdır. Tümü diff --git a/WindowTranslator/Properties/Resources.vi.resx b/WindowTranslator/Properties/Resources.vi.resx index 961d75b7..6bc51938 100644 --- a/WindowTranslator/Properties/Resources.vi.resx +++ b/WindowTranslator/Properties/Resources.vi.resx @@ -536,7 +536,7 @@ Màn hình không được hỗ trợ. Độ ổn định nhận dạng Giảm thay đổi tạm thời của văn bản và việc tách hoặc gộp vùng. 1: phản hồi nhanh, 5: ưu tiên ổn định (mặc định: 3). Giữ hiển thị khi tạm biến mất - Thời gian giữ hiển thị vùng sau khi không còn nhận dạng được, theo đơn vị 100 ms. 0: xóa ngay lần đầu, tối đa: 0,7 giây, mặc định: 0,3 giây. Không phụ thuộc vào khoảng thời gian chụp. + Quy định thời gian vùng vẫn hiển thị sau khi không còn nhận dạng được. 0: xóa ngay lần đầu, tối đa: 7 (khoảng 2,1 giây), mặc định: 3 (khoảng 0,9 giây). Không phụ thuộc vào khoảng thời gian chụp. Tất cả diff --git a/WindowTranslator/Properties/Resources.zh-CN.resx b/WindowTranslator/Properties/Resources.zh-CN.resx index 724c1344..ac5a4819 100644 --- a/WindowTranslator/Properties/Resources.zh-CN.resx +++ b/WindowTranslator/Properties/Resources.zh-CN.resx @@ -536,7 +536,7 @@ 识别结果稳定性 减少文字及区域拆分、合并的临时变化。1:优先响应,5:优先稳定(默认:3)。 临时消失保留 - 识别缺失后区域保持显示的时间,以100毫秒为单位。0:首次缺失即移除,最长:0.7秒,默认:0.3秒。不受捕获间隔影响。 + 设置识别缺失后区域保持显示的程度。0:首次缺失即移除,最大:7(约2.1秒),默认:3(约0.9秒)。不受捕获间隔影响。 全部 diff --git a/WindowTranslator/Properties/Resources.zh-TW.resx b/WindowTranslator/Properties/Resources.zh-TW.resx index a19eb016..5b88d157 100644 --- a/WindowTranslator/Properties/Resources.zh-TW.resx +++ b/WindowTranslator/Properties/Resources.zh-TW.resx @@ -536,7 +536,7 @@ 辨識結果穩定性 減少文字及區域分割、合併的暫時變化。1:優先反應,5:優先穩定(預設:3)。 暫時消失保留 - 辨識缺失後區域維持顯示的時間,以100毫秒為單位。0:首次缺失即移除,最長:0.7秒,預設:0.3秒。不受擷取間隔影響。 + 設定辨識缺失後區域維持顯示的程度。0:首次缺失即移除,最大:7(約2.1秒),預設:3(約0.9秒)。不受擷取間隔影響。 全部 From 5fb63baa8a90d46e05a295ad7a561cd8b9db3cf5 Mon Sep 17 00:00:00 2001 From: Freesia Date: Mon, 12 Oct 2026 15:03:33 +0900 Subject: [PATCH 04/10] =?UTF-8?q?=E8=BF=BD=E8=B7=A1=E3=83=AB=E3=83=BC?= =?UTF-8?q?=E3=83=97=E3=81=AE=E9=80=9A=E7=9F=A5=E3=82=92=E6=95=B4=E7=90=86?= =?UTF-8?q?=E3=81=97=E7=94=BB=E5=83=8F=E3=81=AE=E5=AF=BF=E5=91=BD=E3=82=92?= =?UTF-8?q?R3=E3=81=A7=E7=AE=A1=E7=90=86=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Modules/Main/MainViewModelBase.cs | 78 +++++++++---------- 1 file changed, 36 insertions(+), 42 deletions(-) diff --git a/WindowTranslator/Modules/Main/MainViewModelBase.cs b/WindowTranslator/Modules/Main/MainViewModelBase.cs index bbfd6f3d..ef6bb191 100644 --- a/WindowTranslator/Modules/Main/MainViewModelBase.cs +++ b/WindowTranslator/Modules/Main/MainViewModelBase.cs @@ -23,11 +23,11 @@ namespace WindowTranslator.Modules.Main; public abstract partial class MainViewModelBase : IDisposable { private readonly Subject captureRequests = new(); - private readonly Subject<(IEnumerable Texts, FilterContext Context)?> trackingRequests = new(); + private readonly Subject<(IEnumerable Texts, FilterContext Context, Observable Lifetime, BooleanDisposable Released)> trackingRequests = new(); private readonly Subject translationRequests = new(); - private readonly Subject translationCompleted = new(); private readonly Subject> uiRequests = new(); private readonly IDisposable captureLoop; + private readonly IDisposable trackingLoop; private readonly IDisposable translationLoop; private readonly IDisposable uiLoop; private readonly IOcrModule ocr; @@ -116,46 +116,37 @@ public MainViewModelBase( this.captureLoop = captures .ObserveOnThreadPool() .SubscribeAwait(RecognizeAsync, AwaitOperation.ThrottleFirstLast, configureAwait: false); - FilterContext? previousContext = null; - this.trackingRequests - .Select(request => request is null - ? Observable.Return(request) - : Observable.Merge( - this.isOneShotMode - ? Observable.Return(Unit.Default) - : Observable.Timer(TimeSpan.Zero, TimeSpan.FromMilliseconds(100)).AsUnitObservable(), - this.translationCompleted) - .Select(_ => request)) - .Switch() + var previousSize = default(System.Drawing.Size); + var visible = Observable.ObservePropertyChanged(this, x => x.OverlayVisible) .ObserveOnThreadPool() - .Do(request => + .Do(_ => this.ocrTextTracker.Reset()); + this.trackingLoop = this.trackingRequests + .Select(request => { - if (request is null) - { - this.ocrTextTracker.Reset(); - } + var lifetime = request.Lifetime.Subscribe(); + return Observable.Timer(TimeSpan.Zero, TimeSpan.FromMilliseconds(100)) + .Select(_ => request) + .Do(onDispose: lifetime.Dispose); }) + .Switch() + .CombineLatest(visible, (request, isVisible) => (Request: request, IsVisible: isVisible)) + .Where(value => value.IsVisible) + .Select(value => value.Request) + .ObserveOnThreadPool() .SubscribeAwait(async (request, ct) => { - if (request is null) + using var lifetime = request.Lifetime.Subscribe(); + if (request.Released.IsDisposed) { - previousContext?.SoftwareBitmap.Dispose(); - previousContext = null; return; } - var current = request.Value; - if (!ReferenceEquals(previousContext, current.Context)) + if (previousSize != request.Context.ImageSize) { - if (previousContext?.ImageSize != current.Context.ImageSize) - { - this.ocrTextTracker.Reset(); - } - previousContext?.SoftwareBitmap.Dispose(); - previousContext = current.Context; + this.ocrTextTracker.Reset(); + previousSize = request.Context.ImageSize; } - await ProcessTrackingAsync(current, ct); - }, onCompleted: _ => previousContext?.SoftwareBitmap.Dispose(), - awaitOperation: AwaitOperation.ThrottleFirstLast, configureAwait: false); + await ProcessTrackingAsync((request.Texts, request.Context), ct); + }, AwaitOperation.ThrottleFirstLast, configureAwait: false); this.translationLoop = this.translationRequests .DistinctUntilChanged(EqualityComparer.Create((previous, current) => previous!.Select(t => (t.SourceText, t.Context)).SequenceEqual(current!.Select(t => (t.SourceText, t.Context))))) @@ -171,7 +162,6 @@ public MainViewModelBase( partial void OnOverlayVisibleChanged(bool value) { - this.trackingRequests.OnNext(null); if (value) { this.uiRequests.OnNext([]); @@ -293,16 +283,23 @@ private async ValueTask RecognizeAsync(SoftwareBitmap capturedBitmap, Cancellati SoftwareBitmap = sbmp, ImageSize = new(sbmp.PixelWidth, sbmp.PixelHeight), }; - this.trackingRequests.OnNext((texts, context)); + // 最新観測としての保持と処理中の利用を、購読の参照数で管理する。 + var released = new BooleanDisposable(); + var image = Disposable.Create(() => + { + released.Dispose(); + context.SoftwareBitmap.Dispose(); + }); + var lifetime = Observable.Create(_ => image).Publish().RefCount(); + using var reference = lifetime.Subscribe(); sbmp = null; + this.trackingRequests.OnNext((texts, context, lifetime, released)); } private async ValueTask ProcessTrackingAsync((IEnumerable Texts, FilterContext Context) request, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - IEnumerable texts = this.isOneShotMode - ? request.Texts - : this.ocrTextTracker.Update(request.Texts, request.Context.ImageSize); + IEnumerable texts = this.ocrTextTracker.Update(request.Texts, request.Context.ImageSize); texts = texts.Select(t => t with { FontSize = t.FontSize * this.fontScale }); using (this.Filtering.EnterBusy()) { @@ -352,7 +349,6 @@ private async ValueTask ProcessTranslationAsync(TextRect[] requests, Cancellatio using var t = this.logger.LogDebugTime("翻訳"); var translated = await this.translator.TranslateAsync(requests).ConfigureAwait(false); this.cache.AddRange(requests.Select(t => t.SourceText).Zip(translated)); - this.translationCompleted.OnNext(Unit.Default); } catch (OperationCanceledException) { @@ -362,7 +358,7 @@ private async ValueTask ProcessTranslationAsync(TextRect[] requests, Cancellatio { this.logger.LogError(e, "翻訳中にエラーが発生"); this.captureLoop.Dispose(); - this.trackingRequests.OnNext(null); + this.trackingLoop.Dispose(); await Application.Current.Dispatcher.InvokeAsync(this.capture.StopCapture); this.translationLoop.Dispose(); await ShowErrorAsync(Resources.FaildOverlay, e, string.Empty); @@ -403,11 +399,9 @@ protected virtual void Dispose(bool disposing) { // 実行中の処理から遅れて通知されても受け付けない。 this.captureRequests.OnCompleted(); - // タイマーを停止し、実行中のトラッカー処理の完了後に画像を破棄する。 - this.trackingRequests.OnNext(null); + this.trackingLoop.Dispose(); this.trackingRequests.OnCompleted(); this.translationRequests.OnCompleted(); - this.translationCompleted.OnCompleted(); this.uiRequests.OnCompleted(); if (this.capture is IDisposable captureDisposable) { From 8b6e5f9f853958d1aa9cdad6e1b0357feb2f5e87 Mon Sep 17 00:00:00 2001 From: Freesia Date: Mon, 12 Oct 2026 18:56:35 +0900 Subject: [PATCH 05/10] =?UTF-8?q?=E8=BF=BD=E8=B7=A1=E3=83=AA=E3=82=AF?= =?UTF-8?q?=E3=82=A8=E3=82=B9=E3=83=88=E3=82=92=E5=85=83=E3=81=AE=E5=9E=8B?= =?UTF-8?q?=E3=81=AB=E6=88=BB=E3=81=97=E8=B3=BC=E8=AA=AD=E3=81=AE=E3=82=B9?= =?UTF-8?q?=E3=82=B3=E3=83=BC=E3=83=97=E3=81=A7=E7=94=BB=E5=83=8F=E3=82=92?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Modules/Main/MainViewModelBase.cs | 60 +++++++------------ 1 file changed, 22 insertions(+), 38 deletions(-) diff --git a/WindowTranslator/Modules/Main/MainViewModelBase.cs b/WindowTranslator/Modules/Main/MainViewModelBase.cs index 45767c0a..5f54c694 100644 --- a/WindowTranslator/Modules/Main/MainViewModelBase.cs +++ b/WindowTranslator/Modules/Main/MainViewModelBase.cs @@ -23,7 +23,7 @@ namespace WindowTranslator.Modules.Main; public abstract partial class MainViewModelBase : IDisposable { private readonly Subject captureRequests = new(); - private readonly Subject<(IEnumerable Texts, FilterContext Context, Observable Lifetime, BooleanDisposable Released)> trackingRequests = new(); + private readonly Subject<(IEnumerable Texts, FilterContext Context)> trackingRequests = new(); private readonly Subject translationRequests = new(); private readonly Subject> uiRequests = new(); private readonly IDisposable captureLoop; @@ -117,36 +117,31 @@ public MainViewModelBase( .ObserveOnThreadPool() .SubscribeAwait(RecognizeAsync, AwaitOperation.ThrottleFirstLast, configureAwait: false); var previousSize = default(System.Drawing.Size); - var visible = Observable.ObservePropertyChanged(this, x => x.OverlayVisible) - .ObserveOnThreadPool() - .Do(_ => this.ocrTextTracker.Reset()); - this.trackingLoop = this.trackingRequests - .Select(request => - { - var lifetime = request.Lifetime.Subscribe(); - return Observable.Timer(TimeSpan.Zero, TimeSpan.FromMilliseconds(100)) - .Select(_ => request) - .Do(onDispose: lifetime.Dispose); - }) - .Switch() - .CombineLatest(visible, (request, isVisible) => (Request: request, IsVisible: isVisible)) - .Where(value => value.IsVisible) - .Select(value => value.Request) - .ObserveOnThreadPool() - .SubscribeAwait(async (request, ct) => + this.trackingLoop = Disposable.Combine( + Observable.ObservePropertyChanged(this, x => x.OverlayVisible) + .ObserveOnThreadPool() + .Subscribe(_ => this.ocrTextTracker.Reset()), + this.trackingRequests.SubscribeAwait(async (request, ct) => { - using var lifetime = request.Lifetime.Subscribe(); - if (request.Released.IsDisposed) - { - return; - } + // 認識側の画像が破棄される前に、追跡の購読で使う画像を確保する。 + using var bitmap = SoftwareBitmap.Copy(request.Context.SoftwareBitmap); + request.Context = request.Context with { SoftwareBitmap = bitmap }; if (previousSize != request.Context.ImageSize) { this.ocrTextTracker.Reset(); previousSize = request.Context.ImageSize; } - await ProcessTrackingAsync((request.Texts, request.Context), ct); - }, AwaitOperation.ThrottleFirstLast, configureAwait: false); + // 切替時はタイマーを止め、処理の終了を待ってから画像を破棄する。 + await Observable.Timer(TimeSpan.Zero, TimeSpan.FromMilliseconds(100), CancellationToken.None) + .TakeUntil(ct) + .Where(_ => this.OverlayVisible) + .SelectAwait(async (_, _) => + { + await ProcessTrackingAsync(request, ct); + return Unit.Default; + }, AwaitOperation.Drop, configureAwait: false) + .LastOrDefaultAsync(cancellationToken: CancellationToken.None); + }, AwaitOperation.Switch, configureAwait: false)); this.translationLoop = this.translationRequests .DistinctUntilChanged(EqualityComparer.Create((previous, current) => previous!.Select(t => (t.SourceText, t.Context)).SequenceEqual(current!.Select(t => (t.SourceText, t.Context))))) @@ -203,8 +198,7 @@ private async Task Capture_CapturedAsync(object? sender, CapturedEventArgs args) private async ValueTask RecognizeAsync(SoftwareBitmap capturedBitmap, CancellationToken cancellationToken) { - SoftwareBitmap? sbmp = capturedBitmap; - using var bitmap = new DisposeAction(() => sbmp?.Dispose()); + using var sbmp = capturedBitmap; IEnumerable texts; using (this.Recognizing.EnterBusy()) { @@ -280,17 +274,7 @@ private async ValueTask RecognizeAsync(SoftwareBitmap capturedBitmap, Cancellati SoftwareBitmap = sbmp, ImageSize = new(sbmp.PixelWidth, sbmp.PixelHeight), }; - // 最新観測としての保持と処理中の利用を、購読の参照数で管理する。 - var released = new BooleanDisposable(); - var image = Disposable.Create(() => - { - released.Dispose(); - context.SoftwareBitmap.Dispose(); - }); - var lifetime = Observable.Create(_ => image).Publish().RefCount(); - using var reference = lifetime.Subscribe(); - sbmp = null; - this.trackingRequests.OnNext((texts, context, lifetime, released)); + this.trackingRequests.OnNext((texts, context)); } private async ValueTask ProcessTrackingAsync((IEnumerable Texts, FilterContext Context) request, CancellationToken cancellationToken) From fb66a267c500d6237c9cec0092df5730453736fb Mon Sep 17 00:00:00 2001 From: Freesia Date: Mon, 12 Oct 2026 19:15:37 +0900 Subject: [PATCH 06/10] =?UTF-8?q?=E8=BF=BD=E8=B7=A1=E7=94=BB=E5=83=8F?= =?UTF-8?q?=E3=81=AE=E8=BF=BD=E5=8A=A0=E3=82=B3=E3=83=94=E3=83=BC=E3=82=92?= =?UTF-8?q?=E3=81=AA=E3=81=8F=E3=81=97=E3=81=A6=E6=89=80=E6=9C=89=E3=82=92?= =?UTF-8?q?=E5=BC=95=E3=81=8D=E7=B6=99=E3=81=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- WindowTranslator/Modules/Main/MainViewModelBase.cs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/WindowTranslator/Modules/Main/MainViewModelBase.cs b/WindowTranslator/Modules/Main/MainViewModelBase.cs index 5f54c694..4c0751ff 100644 --- a/WindowTranslator/Modules/Main/MainViewModelBase.cs +++ b/WindowTranslator/Modules/Main/MainViewModelBase.cs @@ -26,6 +26,7 @@ public abstract partial class MainViewModelBase : IDisposable private readonly Subject<(IEnumerable Texts, FilterContext Context)> trackingRequests = new(); private readonly Subject translationRequests = new(); private readonly Subject> uiRequests = new(); + private SoftwareBitmap? pendingTrackingBitmap; private readonly IDisposable captureLoop; private readonly IDisposable trackingLoop; private readonly IDisposable translationLoop; @@ -123,9 +124,8 @@ public MainViewModelBase( .Subscribe(_ => this.ocrTextTracker.Reset()), this.trackingRequests.SubscribeAwait(async (request, ct) => { - // 認識側の画像が破棄される前に、追跡の購読で使う画像を確保する。 - using var bitmap = SoftwareBitmap.Copy(request.Context.SoftwareBitmap); - request.Context = request.Context with { SoftwareBitmap = bitmap }; + // OnNext 内で所有を引き取り、認識側の破棄対象から外す。 + using var bitmap = Interlocked.Exchange(ref this.pendingTrackingBitmap, null); if (previousSize != request.Context.ImageSize) { this.ocrTextTracker.Reset(); @@ -198,7 +198,9 @@ private async Task Capture_CapturedAsync(object? sender, CapturedEventArgs args) private async ValueTask RecognizeAsync(SoftwareBitmap capturedBitmap, CancellationToken cancellationToken) { - using var sbmp = capturedBitmap; + this.pendingTrackingBitmap = capturedBitmap; + using var release = new DisposeAction(() => Interlocked.Exchange(ref this.pendingTrackingBitmap, null)?.Dispose()); + var sbmp = capturedBitmap; IEnumerable texts; using (this.Recognizing.EnterBusy()) { From fade88ff3469b9ab8ca04dca2e96e9697b1cd677 Mon Sep 17 00:00:00 2001 From: Freesia Date: Mon, 12 Oct 2026 21:57:36 +0900 Subject: [PATCH 07/10] =?UTF-8?q?=E7=94=BB=E5=83=8F=E3=81=AE=E5=85=B1?= =?UTF-8?q?=E6=9C=89=E3=83=95=E3=82=A3=E3=83=BC=E3=83=AB=E3=83=89=E3=82=92?= =?UTF-8?q?=E5=89=8A=E9=99=A4=E3=81=97R3=E3=81=AE=E5=AE=8C=E4=BA=86?= =?UTF-8?q?=E9=80=9A=E7=9F=A5=E3=81=A7=E3=83=AB=E3=83=BC=E3=83=97=E3=82=92?= =?UTF-8?q?=E7=B5=82=E4=BA=86=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Modules/Main/MainViewModelBase.cs | 66 +++++++++---------- 1 file changed, 32 insertions(+), 34 deletions(-) diff --git a/WindowTranslator/Modules/Main/MainViewModelBase.cs b/WindowTranslator/Modules/Main/MainViewModelBase.cs index 4c0751ff..ddda4c0d 100644 --- a/WindowTranslator/Modules/Main/MainViewModelBase.cs +++ b/WindowTranslator/Modules/Main/MainViewModelBase.cs @@ -26,8 +26,6 @@ public abstract partial class MainViewModelBase : IDisposable private readonly Subject<(IEnumerable Texts, FilterContext Context)> trackingRequests = new(); private readonly Subject translationRequests = new(); private readonly Subject> uiRequests = new(); - private SoftwareBitmap? pendingTrackingBitmap; - private readonly IDisposable captureLoop; private readonly IDisposable trackingLoop; private readonly IDisposable translationLoop; private readonly IDisposable uiLoop; @@ -114,34 +112,38 @@ public MainViewModelBase( var captures = !this.isOneShotMode && captureInterval > TimeSpan.Zero ? this.captureRequests.ThrottleLast(captureInterval) : this.captureRequests; - this.captureLoop = captures + captures .ObserveOnThreadPool() - .SubscribeAwait(RecognizeAsync, AwaitOperation.ThrottleFirstLast, configureAwait: false); + .SubscribeAwait(RecognizeAsync, + onCompleted: _ => this.trackingRequests.OnCompleted(), + awaitOperation: AwaitOperation.ThrottleFirstLast, configureAwait: false, cancelOnCompleted: false); var previousSize = default(System.Drawing.Size); this.trackingLoop = Disposable.Combine( Observable.ObservePropertyChanged(this, x => x.OverlayVisible) .ObserveOnThreadPool() .Subscribe(_ => this.ocrTextTracker.Reset()), - this.trackingRequests.SubscribeAwait(async (request, ct) => - { - // OnNext 内で所有を引き取り、認識側の破棄対象から外す。 - using var bitmap = Interlocked.Exchange(ref this.pendingTrackingBitmap, null); - if (previousSize != request.Context.ImageSize) + this.trackingRequests + .ObserveOnThreadPool() + .SubscribeAwait(async (request, ct) => { - this.ocrTextTracker.Reset(); - previousSize = request.Context.ImageSize; - } - // 切替時はタイマーを止め、処理の終了を待ってから画像を破棄する。 - await Observable.Timer(TimeSpan.Zero, TimeSpan.FromMilliseconds(100), CancellationToken.None) - .TakeUntil(ct) - .Where(_ => this.OverlayVisible) - .SelectAwait(async (_, _) => + using var bitmap = request.Context.SoftwareBitmap; + if (previousSize != request.Context.ImageSize) { - await ProcessTrackingAsync(request, ct); - return Unit.Default; - }, AwaitOperation.Drop, configureAwait: false) - .LastOrDefaultAsync(cancellationToken: CancellationToken.None); - }, AwaitOperation.Switch, configureAwait: false)); + this.ocrTextTracker.Reset(); + previousSize = request.Context.ImageSize; + } + // 切替時はタイマーを止め、処理の終了を待ってから画像を破棄する。 + await Observable.Timer(TimeSpan.Zero, TimeSpan.FromMilliseconds(100), CancellationToken.None) + .TakeUntil(ct) + .Where(_ => this.OverlayVisible) + .SelectAwait(async (_, _) => + { + await ProcessTrackingAsync(request, ct); + return Unit.Default; + }, AwaitOperation.Drop, configureAwait: false) + .LastOrDefaultAsync(cancellationToken: CancellationToken.None); + }, onCompleted: _ => this.trackingLoop!.Dispose(), + awaitOperation: AwaitOperation.Switch, configureAwait: false, cancelOnCompleted: true)); this.translationLoop = this.translationRequests .DistinctUntilChanged(EqualityComparer.Create((previous, current) => previous!.Select(t => (t.SourceText, t.Context)).SequenceEqual(current!.Select(t => (t.SourceText, t.Context))))) @@ -198,9 +200,8 @@ private async Task Capture_CapturedAsync(object? sender, CapturedEventArgs args) private async ValueTask RecognizeAsync(SoftwareBitmap capturedBitmap, CancellationToken cancellationToken) { - this.pendingTrackingBitmap = capturedBitmap; - using var release = new DisposeAction(() => Interlocked.Exchange(ref this.pendingTrackingBitmap, null)?.Dispose()); - var sbmp = capturedBitmap; + SoftwareBitmap? sbmp = capturedBitmap; + using var bitmap = new DisposeAction(() => sbmp?.Dispose()); IEnumerable texts; using (this.Recognizing.EnterBusy()) { @@ -243,20 +244,20 @@ private async ValueTask RecognizeAsync(SoftwareBitmap capturedBitmap, Cancellati catch (ObjectDisposedException) { // すでに破棄されている場合は何もしない - this.captureLoop.Dispose(); + this.captureRequests.OnCompleted(); await Application.Current.Dispatcher.InvokeAsync(this.capture.StopCapture); return; } catch (OperationCanceledException) { // キャンセルされた場合は何もしない - this.captureLoop.Dispose(); + this.captureRequests.OnCompleted(); await Application.Current.Dispatcher.InvokeAsync(this.capture.StopCapture); return; } catch (Exception e) { - this.captureLoop.Dispose(); + this.captureRequests.OnCompleted(); await Application.Current.Dispatcher.InvokeAsync(this.capture.StopCapture); var path = Path.Combine(PathUtility.UserDir, $"ocr_error", $"{DateTime.UtcNow:yyyyMMdd'T'HHmmss'Z'}.png"); await sbmp.TrySaveImage(path); @@ -277,6 +278,7 @@ private async ValueTask RecognizeAsync(SoftwareBitmap capturedBitmap, Cancellati ImageSize = new(sbmp.PixelWidth, sbmp.PixelHeight), }; this.trackingRequests.OnNext((texts, context)); + sbmp = null; } private async ValueTask ProcessTrackingAsync((IEnumerable Texts, FilterContext Context) request, CancellationToken cancellationToken) @@ -340,8 +342,7 @@ private async ValueTask ProcessTranslationAsync(TextRect[] requests, Cancellatio catch (Exception e) { this.logger.LogError(e, "翻訳中にエラーが発生"); - this.captureLoop.Dispose(); - this.trackingLoop.Dispose(); + this.captureRequests.OnCompleted(); await Application.Current.Dispatcher.InvokeAsync(this.capture.StopCapture); this.translationLoop.Dispose(); await ShowErrorAsync(Resources.FaildOverlay, e, string.Empty); @@ -380,17 +381,14 @@ protected virtual void Dispose(bool disposing) this.disposedValue = true; if (disposing) { - // 実行中の処理から遅れて通知されても受け付けない。 + // 実行中の認識が終わってから追跡を終了し、渡した画像を破棄する。 this.captureRequests.OnCompleted(); - this.trackingLoop.Dispose(); - this.trackingRequests.OnCompleted(); this.translationRequests.OnCompleted(); this.uiRequests.OnCompleted(); if (this.capture is IDisposable captureDisposable) { captureDisposable.Dispose(); } - this.captureLoop.Dispose(); this.translationLoop.Dispose(); this.uiLoop.Dispose(); } From 14fdb66e736531c64652add7f02fb775ad54ec58 Mon Sep 17 00:00:00 2001 From: Freesia Date: Mon, 12 Oct 2026 23:31:31 +0900 Subject: [PATCH 08/10] =?UTF-8?q?=E5=85=83=E3=81=AE=E5=87=A6=E7=90=86?= =?UTF-8?q?=E9=A0=86=E3=82=92=E7=B6=AD=E6=8C=81=E3=81=97=E3=81=A6OCR?= =?UTF-8?q?=E7=9B=B4=E5=BE=8C=E3=81=A7=E8=BF=BD=E8=B7=A1=E3=83=AB=E3=83=BC?= =?UTF-8?q?=E3=83=97=E3=82=92=E5=88=86=E9=9B=A2=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Modules/Main/MainViewModelBase.cs | 152 +++++++++++------- 1 file changed, 97 insertions(+), 55 deletions(-) diff --git a/WindowTranslator/Modules/Main/MainViewModelBase.cs b/WindowTranslator/Modules/Main/MainViewModelBase.cs index ddda4c0d..8b53e009 100644 --- a/WindowTranslator/Modules/Main/MainViewModelBase.cs +++ b/WindowTranslator/Modules/Main/MainViewModelBase.cs @@ -24,8 +24,9 @@ public abstract partial class MainViewModelBase : IDisposable { private readonly Subject captureRequests = new(); private readonly Subject<(IEnumerable Texts, FilterContext Context)> trackingRequests = new(); - private readonly Subject translationRequests = new(); + private readonly Subject<(IEnumerable Texts, FilterContext Context)> translationRequests = new(); private readonly Subject> uiRequests = new(); + private readonly IDisposable captureLoop; private readonly IDisposable trackingLoop; private readonly IDisposable translationLoop; private readonly IDisposable uiLoop; @@ -112,43 +113,60 @@ public MainViewModelBase( var captures = !this.isOneShotMode && captureInterval > TimeSpan.Zero ? this.captureRequests.ThrottleLast(captureInterval) : this.captureRequests; - captures + this.captureLoop = captures .ObserveOnThreadPool() - .SubscribeAwait(RecognizeAsync, - onCompleted: _ => this.trackingRequests.OnCompleted(), - awaitOperation: AwaitOperation.ThrottleFirstLast, configureAwait: false, cancelOnCompleted: false); + .SubscribeAwait(RecognizeAsync, AwaitOperation.ThrottleFirstLast, configureAwait: false); var previousSize = default(System.Drawing.Size); this.trackingLoop = Disposable.Combine( Observable.ObservePropertyChanged(this, x => x.OverlayVisible) + .Where(visible => visible) .ObserveOnThreadPool() .Subscribe(_ => this.ocrTextTracker.Reset()), this.trackingRequests - .ObserveOnThreadPool() .SubscribeAwait(async (request, ct) => { - using var bitmap = request.Context.SoftwareBitmap; + // 認識側で画像を閉じる前に、画素を共有するビューを取得する。 + using var bitmap = request.Context.SoftwareBitmap.GetReadOnlyView(); + request.Context = request.Context with { SoftwareBitmap = bitmap }; if (previousSize != request.Context.ImageSize) { this.ocrTextTracker.Reset(); previousSize = request.Context.ImageSize; } // 切替時はタイマーを止め、処理の終了を待ってから画像を破棄する。 - await Observable.Timer(TimeSpan.Zero, TimeSpan.FromMilliseconds(100), CancellationToken.None) + var isOneShotPreview = this.isOneShotMode; + await Observable.Timer(TimeSpan.Zero, TimeSpan.FromMilliseconds(100), TimeProvider.System, CancellationToken.None) .TakeUntil(ct) .Where(_ => this.OverlayVisible) .SelectAwait(async (_, _) => { - await ProcessTrackingAsync(request, ct); + var texts = await ProcessTrackingAsync(request, ct); + if (this.isFirstCapture || isOneShotPreview) + { + if (!this.isOneShotMode) + { + this.isFirstCapture = false; + } + isOneShotPreview = false; + this.uiRequests.OnNext(texts); + } + this.translationRequests.OnNext((texts, request.Context)); return Unit.Default; }, AwaitOperation.Drop, configureAwait: false) .LastOrDefaultAsync(cancellationToken: CancellationToken.None); - }, onCompleted: _ => this.trackingLoop!.Dispose(), - awaitOperation: AwaitOperation.Switch, configureAwait: false, cancelOnCompleted: true)); - this.translationLoop = this.translationRequests - .DistinctUntilChanged(EqualityComparer.Create((previous, current) => - previous!.Select(t => (t.SourceText, t.Context)).SequenceEqual(current!.Select(t => (t.SourceText, t.Context))))) - .ObserveOnThreadPool() - .SubscribeAwait(ProcessTranslationAsync, AwaitOperation.ThrottleFirstLast, configureAwait: false); + }, AwaitOperation.Switch, configureAwait: false, cancelOnCompleted: true)); + // 待機用ビューは差し替え時に閉じ、実行中のビューは翻訳側で保持する。 + var pendingTranslationImage = new SerialDisposable(); + this.translationLoop = Disposable.Combine(pendingTranslationImage, + this.translationRequests + .Select(request => + { + var bitmap = request.Context.SoftwareBitmap.GetReadOnlyView(); + pendingTranslationImage.Disposable = bitmap; + return (request.Texts, Context: request.Context with { SoftwareBitmap = bitmap }); + }) + .ObserveOnThreadPool() + .SubscribeAwait(ProcessTranslationAsync, AwaitOperation.ThrottleFirstLast, configureAwait: false)); if (!this.isOneShotMode) { this.capture.StartCapture(this.processInfoStore.MainWindowHandle); @@ -200,8 +218,7 @@ private async Task Capture_CapturedAsync(object? sender, CapturedEventArgs args) private async ValueTask RecognizeAsync(SoftwareBitmap capturedBitmap, CancellationToken cancellationToken) { - SoftwareBitmap? sbmp = capturedBitmap; - using var bitmap = new DisposeAction(() => sbmp?.Dispose()); + using var sbmp = capturedBitmap; IEnumerable texts; using (this.Recognizing.EnterBusy()) { @@ -244,20 +261,20 @@ private async ValueTask RecognizeAsync(SoftwareBitmap capturedBitmap, Cancellati catch (ObjectDisposedException) { // すでに破棄されている場合は何もしない - this.captureRequests.OnCompleted(); + this.captureLoop.Dispose(); await Application.Current.Dispatcher.InvokeAsync(this.capture.StopCapture); return; } catch (OperationCanceledException) { // キャンセルされた場合は何もしない - this.captureRequests.OnCompleted(); + this.captureLoop.Dispose(); await Application.Current.Dispatcher.InvokeAsync(this.capture.StopCapture); return; } catch (Exception e) { - this.captureRequests.OnCompleted(); + this.captureLoop.Dispose(); await Application.Current.Dispatcher.InvokeAsync(this.capture.StopCapture); var path = Path.Combine(PathUtility.UserDir, $"ocr_error", $"{DateTime.UtcNow:yyyyMMdd'T'HHmmss'Z'}.png"); await sbmp.TrySaveImage(path); @@ -265,8 +282,6 @@ private async ValueTask RecognizeAsync(SoftwareBitmap capturedBitmap, Cancellati return; } } - texts = (await this.color.ConvertColorAsync(sbmp, texts)).ToArray(); - cancellationToken.ThrowIfCancellationRequested(); if (this.disposedValue) { @@ -278,62 +293,73 @@ private async ValueTask RecognizeAsync(SoftwareBitmap capturedBitmap, Cancellati ImageSize = new(sbmp.PixelWidth, sbmp.PixelHeight), }; this.trackingRequests.OnNext((texts, context)); - sbmp = null; } - private async ValueTask ProcessTrackingAsync((IEnumerable Texts, FilterContext Context) request, CancellationToken cancellationToken) + private async ValueTask> ProcessTrackingAsync((IEnumerable Texts, FilterContext Context) request, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); IEnumerable texts = this.ocrTextTracker.Update(request.Texts, request.Context.ImageSize); texts = texts.Select(t => t with { FontSize = t.FontSize * this.fontScale }); - using (this.Filtering.EnterBusy()) - { - var tmp = texts.ToAsyncEnumerable(); - foreach (var filter in this.filters.OrderByDescending(f => f.Priority)) - { - tmp = filter.ExecutePreTranslate(tmp, request.Context); - } - using var t = this.logger.LogDebugTime("翻訳前処理"); - texts = await tmp.ToArrayAsync(cancellationToken); - } + texts = (await this.color.ConvertColorAsync(request.Context.SoftwareBitmap, texts)).ToArray(); texts = texts.Select(t => t switch { { TranslatedText: null } when this.cache.Contains(t.SourceText) => t with { TranslatedText = this.cache.Get(t.SourceText) }, _ => t, }).ToArray(); - var requests = texts.Where(t => t.TranslatedText is null).ToArray(); - using (this.Filtering.EnterBusy()) - { - var tmp = texts.Where(t => t.TranslatedText is not null).ToAsyncEnumerable(); - foreach (var filter in this.filters.OrderBy(f => f.Priority)) - { - tmp = filter.ExecutePostTranslate(tmp, request.Context); - } - using var t = this.logger.LogDebugTime("翻訳後処理"); - texts = requests.Concat(await tmp.ToArrayAsync(cancellationToken)).ToArray(); - } cancellationToken.ThrowIfCancellationRequested(); - this.uiRequests.OnNext(texts); - this.translationRequests.OnNext(requests); + return texts; } - private async ValueTask ProcessTranslationAsync(TextRect[] requests, CancellationToken cancellationToken) + private async ValueTask ProcessTranslationAsync((IEnumerable Texts, FilterContext Context) request, CancellationToken cancellationToken) { - if (requests.Length == 0) + SoftwareBitmap bitmap; + try + { + bitmap = request.Context.SoftwareBitmap.GetReadOnlyView(); + } + catch (ObjectDisposedException) { + // 新しい要求に差し替えられ、待機用のビューが閉じられている。 return; } + using var release = new DisposeAction(bitmap.Dispose); + request.Context = request.Context with { SoftwareBitmap = bitmap }; cancellationToken.ThrowIfCancellationRequested(); + IEnumerable texts; + using (this.Filtering.EnterBusy()) + { + var tmp = request.Texts.ToAsyncEnumerable(); + foreach (var filter in this.filters.OrderByDescending(f => f.Priority)) + { + tmp = filter.ExecutePreTranslate(tmp, request.Context); + } + using var t = this.logger.LogDebugTime("翻訳前処理"); + texts = await tmp.ToArrayAsync(cancellationToken); + } try { + var requests = texts + .Where(t => t.TranslatedText is null) + .ToArray(); if (this.disposedValue) { this.logger.LogDebug("すでに破棄されているので翻訳キューを無視"); return; } - using var t = this.logger.LogDebugTime("翻訳"); - var translated = await this.translator.TranslateAsync(requests).ConfigureAwait(false); - this.cache.AddRange(requests.Select(t => t.SourceText).Zip(translated)); + if (requests.Length > 0) + { + using var t = this.logger.LogDebugTime("翻訳"); + var translated = await this.translator.TranslateAsync(requests).ConfigureAwait(false); + this.cache.AddRange(requests.Select(t => t.SourceText).Zip(translated)); + var translatedIndex = 0; + texts = texts.Select(t => t.TranslatedText is null + ? t with { TranslatedText = translated[translatedIndex++] } + : t).ToArray(); + } + else + { + this.logger.LogDebug("翻訳キューに未翻訳がないので翻訳を省略"); + } } catch (OperationCanceledException) { @@ -342,12 +368,25 @@ private async ValueTask ProcessTranslationAsync(TextRect[] requests, Cancellatio catch (Exception e) { this.logger.LogError(e, "翻訳中にエラーが発生"); - this.captureRequests.OnCompleted(); + this.captureLoop.Dispose(); + this.trackingLoop.Dispose(); await Application.Current.Dispatcher.InvokeAsync(this.capture.StopCapture); this.translationLoop.Dispose(); await ShowErrorAsync(Resources.FaildOverlay, e, string.Empty); return; } + using (this.Filtering.EnterBusy()) + { + var tmp = texts.ToAsyncEnumerable(); + foreach (var filter in this.filters.OrderBy(f => f.Priority)) + { + tmp = filter.ExecutePostTranslate(tmp, request.Context); + } + using var t = this.logger.LogDebugTime("翻訳後処理"); + texts = await tmp.ToArrayAsync(cancellationToken); + } + cancellationToken.ThrowIfCancellationRequested(); + this.uiRequests.OnNext(texts); } private void UpdateOcrTexts(IEnumerable texts) @@ -381,14 +420,17 @@ protected virtual void Dispose(bool disposing) this.disposedValue = true; if (disposing) { - // 実行中の認識が終わってから追跡を終了し、渡した画像を破棄する。 + // 実行中の処理から遅れて通知されても受け付けない。 this.captureRequests.OnCompleted(); + this.trackingRequests.OnCompleted(); this.translationRequests.OnCompleted(); this.uiRequests.OnCompleted(); if (this.capture is IDisposable captureDisposable) { captureDisposable.Dispose(); } + this.captureLoop.Dispose(); + this.trackingLoop.Dispose(); this.translationLoop.Dispose(); this.uiLoop.Dispose(); } From 72dfbdeb31ad2a12fb3352acebda4c5da92671f4 Mon Sep 17 00:00:00 2001 From: Freesia Date: Tue, 13 Oct 2026 13:08:49 +0900 Subject: [PATCH 09/10] =?UTF-8?q?=E8=BF=BD=E8=B7=A1=E3=81=B8=E5=85=83?= =?UTF-8?q?=E7=94=BB=E5=83=8F=E3=81=AE=E6=89=80=E6=9C=89=E3=82=92=E6=B8=A1?= =?UTF-8?q?=E3=81=97=E7=BF=BB=E8=A8=B3=E7=94=A8=E3=83=93=E3=83=A5=E3=83=BC?= =?UTF-8?q?=E3=81=AE=E5=8F=97=E3=81=91=E6=B8=A1=E3=81=97=E3=82=92=E6=95=B4?= =?UTF-8?q?=E7=90=86=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Modules/Main/MainViewModelBase.cs | 53 +++++++++++-------- 1 file changed, 31 insertions(+), 22 deletions(-) diff --git a/WindowTranslator/Modules/Main/MainViewModelBase.cs b/WindowTranslator/Modules/Main/MainViewModelBase.cs index 8b53e009..617faa41 100644 --- a/WindowTranslator/Modules/Main/MainViewModelBase.cs +++ b/WindowTranslator/Modules/Main/MainViewModelBase.cs @@ -125,9 +125,7 @@ public MainViewModelBase( this.trackingRequests .SubscribeAwait(async (request, ct) => { - // 認識側で画像を閉じる前に、画素を共有するビューを取得する。 - using var bitmap = request.Context.SoftwareBitmap.GetReadOnlyView(); - request.Context = request.Context with { SoftwareBitmap = bitmap }; + using var bitmap = request.Context.SoftwareBitmap; if (previousSize != request.Context.ImageSize) { this.ocrTextTracker.Reset(); @@ -157,16 +155,36 @@ await Observable.Timer(TimeSpan.Zero, TimeSpan.FromMilliseconds(100), TimeProvid }, AwaitOperation.Switch, configureAwait: false, cancelOnCompleted: true)); // 待機用ビューは差し替え時に閉じ、実行中のビューは翻訳側で保持する。 var pendingTranslationImage = new SerialDisposable(); - this.translationLoop = Disposable.Combine(pendingTranslationImage, - this.translationRequests - .Select(request => + this.translationLoop = this.translationRequests + .Select(request => + { + lock (pendingTranslationImage) { var bitmap = request.Context.SoftwareBitmap.GetReadOnlyView(); pendingTranslationImage.Disposable = bitmap; return (request.Texts, Context: request.Context with { SoftwareBitmap = bitmap }); - }) - .ObserveOnThreadPool() - .SubscribeAwait(ProcessTranslationAsync, AwaitOperation.ThrottleFirstLast, configureAwait: false)); + } + }) + .Do(onDispose: () => + { + lock (pendingTranslationImage) + { + pendingTranslationImage.Dispose(); + } + }) + .ObserveOnThreadPool() + .SubscribeAwait(async (request, ct) => + { + lock (pendingTranslationImage) + { + if (!ReferenceEquals(pendingTranslationImage.Disposable, request.Context.SoftwareBitmap)) + { + return; + } + request.Context = request.Context with { SoftwareBitmap = request.Context.SoftwareBitmap.GetReadOnlyView() }; + } + await ProcessTranslationAsync(request, ct); + }, AwaitOperation.ThrottleFirstLast, configureAwait: false); if (!this.isOneShotMode) { this.capture.StartCapture(this.processInfoStore.MainWindowHandle); @@ -218,7 +236,8 @@ private async Task Capture_CapturedAsync(object? sender, CapturedEventArgs args) private async ValueTask RecognizeAsync(SoftwareBitmap capturedBitmap, CancellationToken cancellationToken) { - using var sbmp = capturedBitmap; + SoftwareBitmap? sbmp = capturedBitmap; + using var bitmap = new DisposeAction(() => sbmp?.Dispose()); IEnumerable texts; using (this.Recognizing.EnterBusy()) { @@ -293,6 +312,7 @@ private async ValueTask RecognizeAsync(SoftwareBitmap capturedBitmap, Cancellati ImageSize = new(sbmp.PixelWidth, sbmp.PixelHeight), }; this.trackingRequests.OnNext((texts, context)); + sbmp = null; } private async ValueTask> ProcessTrackingAsync((IEnumerable Texts, FilterContext Context) request, CancellationToken cancellationToken) @@ -312,18 +332,7 @@ private async ValueTask> ProcessTrackingAsync((IEnumerable private async ValueTask ProcessTranslationAsync((IEnumerable Texts, FilterContext Context) request, CancellationToken cancellationToken) { - SoftwareBitmap bitmap; - try - { - bitmap = request.Context.SoftwareBitmap.GetReadOnlyView(); - } - catch (ObjectDisposedException) - { - // 新しい要求に差し替えられ、待機用のビューが閉じられている。 - return; - } - using var release = new DisposeAction(bitmap.Dispose); - request.Context = request.Context with { SoftwareBitmap = bitmap }; + using var bitmap = request.Context.SoftwareBitmap; cancellationToken.ThrowIfCancellationRequested(); IEnumerable texts; using (this.Filtering.EnterBusy()) From 055c0b0ddc3c335ac72b69316c0489b9085ae3f9 Mon Sep 17 00:00:00 2001 From: Freesia Date: Sun, 25 Oct 2026 20:26:49 +0900 Subject: [PATCH 10/10] =?UTF-8?q?=E5=AE=9F=E8=A3=85=E6=95=B4=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ObservableExtensionsTests.cs | 241 ++++++++++++++++++ .../Extensions/ObservableExtensions.cs | 144 +++++++++++ .../Modules/Main/MainViewModelBase.cs | 143 ++++------- 3 files changed, 437 insertions(+), 91 deletions(-) create mode 100644 WindowTranslator.Tests/ObservableExtensionsTests.cs create mode 100644 WindowTranslator/Extensions/ObservableExtensions.cs diff --git a/WindowTranslator.Tests/ObservableExtensionsTests.cs b/WindowTranslator.Tests/ObservableExtensionsTests.cs new file mode 100644 index 00000000..60c0bb3b --- /dev/null +++ b/WindowTranslator.Tests/ObservableExtensionsTests.cs @@ -0,0 +1,241 @@ +using System.Collections.Concurrent; +using R3; +using WindowTranslator.Extensions; + +namespace WindowTranslator.Tests; + +public class ObservableExtensionsTests +{ + [Fact] + public async Task 処理中に届いた値は最後の値だけを実行する() + { + using var source = new Subject(); + var processor = new Processor(); + using var subscription = source.SubscribeAwaitThrottleFirstLast(processor.ProcessAsync, Discard, configureAwait: false); + var first = new TestValue(1); + var replaced = new TestValue(2); + var last = new TestValue(3); + + source.OnNext(first); + await first.WaitUntilStartedAsync(); + source.OnNext(replaced); + source.OnNext(last); + last.Release(); + first.Release(); + await last.WaitUntilDisposedAsync(); + + Assert.Equal([1, 3], processor.StartedIds.ToArray()); + } + + [Fact] + public async Task 差し替えられた待機値は一度だけ破棄する() + { + using var source = new Subject(); + var processor = new Processor(); + using var subscription = source.SubscribeAwaitThrottleFirstLast(processor.ProcessAsync, Discard, configureAwait: false); + var first = new TestValue(1); + var replaced = new TestValue(2); + + source.OnNext(first); + await first.WaitUntilStartedAsync(); + source.OnNext(replaced); + source.OnNext(new TestValue(3)); + subscription.Dispose(); + + Assert.Equal(1, replaced.DisposeCount); + } + + [Fact] + public async Task 待機値を差し替えても実行中の値は破棄しない() + { + using var source = new Subject(); + var processor = new Processor(); + using var subscription = source.SubscribeAwaitThrottleFirstLast(processor.ProcessAsync, Discard, configureAwait: false); + var first = new TestValue(1); + + source.OnNext(first); + await first.WaitUntilStartedAsync(); + source.OnNext(new TestValue(2)); + source.OnNext(new TestValue(3)); + + Assert.Equal(0, first.DisposeCount); + } + + [Fact] + public async Task 購読解除で待機値を一度だけ破棄する() + { + using var source = new Subject(); + var processor = new Processor(); + using var subscription = source.SubscribeAwaitThrottleFirstLast(processor.ProcessAsync, Discard, configureAwait: false); + var first = new TestValue(1); + var pending = new TestValue(2); + + source.OnNext(first); + await first.WaitUntilStartedAsync(); + source.OnNext(pending); + subscription.Dispose(); + subscription.Dispose(); + + Assert.Equal(1, pending.DisposeCount); + } + + [Fact] + public async Task 購読解除で実行中の処理をキャンセルする() + { + using var source = new Subject(); + var processor = new Processor(); + using var subscription = source.SubscribeAwaitThrottleFirstLast(processor.ProcessAsync, Discard, configureAwait: false); + var first = new TestValue(1); + + source.OnNext(first); + await first.WaitUntilStartedAsync(); + subscription.Dispose(); + await first.WaitUntilDisposedAsync(); + + Assert.True(first.CancellationToken.IsCancellationRequested); + } + + [Fact] + public async Task 通常完了では最後の待機値まで処理する() + { + using var source = new Subject(); + var processor = new Processor(); + using var subscription = source.SubscribeAwaitThrottleFirstLast(processor.ProcessAsync, Discard, configureAwait: false); + var first = new TestValue(1); + var last = new TestValue(2); + + source.OnNext(first); + await first.WaitUntilStartedAsync(); + source.OnNext(last); + source.OnCompleted(); + last.Release(); + first.Release(); + await last.WaitUntilDisposedAsync(); + + Assert.Equal([1, 2], processor.StartedIds.ToArray()); + } + + [Fact] + public async Task 完了時キャンセルを指定すると待機値を破棄する() + { + using var source = new Subject(); + var processor = new Processor(); + using var subscription = source.SubscribeAwaitThrottleFirstLast( + processor.ProcessAsync, Discard, configureAwait: false, cancelOnCompleted: true); + var first = new TestValue(1); + var pending = new TestValue(2); + + source.OnNext(first); + await first.WaitUntilStartedAsync(); + source.OnNext(pending); + source.OnCompleted(); + + Assert.Equal(1, pending.DisposeCount); + } + + [Fact] + public async Task 失敗による完了では待機値を破棄する() + { + using var source = new Subject(); + var processor = new Processor(); + var errors = new ConcurrentQueue(); + using var subscription = source.SubscribeAwaitThrottleFirstLast( + processor.ProcessAsync, Discard, configureAwait: false, onErrorResume: errors.Enqueue); + var first = new TestValue(1); + var pending = new TestValue(2); + + source.OnNext(first); + await first.WaitUntilStartedAsync(); + source.OnNext(pending); + source.OnCompleted(new InvalidOperationException()); + + Assert.Equal(1, pending.DisposeCount); + } + + [Fact] + public async Task 処理の例外を通知して次の待機値を処理する() + { + using var source = new Subject(); + var processor = new Processor(); + var errors = new ConcurrentQueue(); + using var subscription = source.SubscribeAwaitThrottleFirstLast( + processor.ProcessAsync, Discard, configureAwait: false, onErrorResume: errors.Enqueue); + var first = new TestValue(1); + var last = new TestValue(2); + var error = new InvalidOperationException(); + + source.OnNext(first); + await first.WaitUntilStartedAsync(); + source.OnNext(last); + last.Release(); + first.Fail(error); + await last.WaitUntilDisposedAsync(); + + Assert.Same(error, Assert.Single(errors)); + } + + [Fact] + public async Task 処理がキャンセルされた場合も残った待機値を破棄する() + { + using var source = new Subject(); + var processor = new Processor(); + using var subscription = source.SubscribeAwaitThrottleFirstLast(processor.ProcessAsync, Discard, configureAwait: false); + var first = new TestValue(1); + var pending = new TestValue(2); + + source.OnNext(first); + await first.WaitUntilStartedAsync(); + source.OnNext(pending); + first.Fail(new OperationCanceledException()); + await pending.WaitUntilDisposedAsync(); + + Assert.Equal(1, pending.DisposeCount); + } + + private static void Discard(TestValue value) => value.Dispose(); + + private sealed class Processor + { + public ConcurrentQueue StartedIds { get; } = new(); + + public async ValueTask ProcessAsync(TestValue value, CancellationToken cancellationToken) + { + using (value) + { + this.StartedIds.Enqueue(value.Id); + value.Start(cancellationToken); + await value.WaitUntilReleasedAsync(cancellationToken).ConfigureAwait(false); + } + } + } + + private sealed class TestValue(int id) : IDisposable + { + private readonly TaskCompletionSource started = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource released = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource disposed = new(TaskCreationOptions.RunContinuationsAsynchronously); + private int disposeCount; + + public int Id { get; } = id; + public int DisposeCount => Volatile.Read(ref this.disposeCount); + public CancellationToken CancellationToken { get; private set; } + + public void Start(CancellationToken cancellationToken) + { + this.CancellationToken = cancellationToken; + this.started.TrySetResult(); + } + + public void Release() => this.released.TrySetResult(); + public void Fail(Exception error) => this.released.TrySetException(error); + public Task WaitUntilStartedAsync() => this.started.Task.WaitAsync(TimeSpan.FromSeconds(10)); + public Task WaitUntilDisposedAsync() => this.disposed.Task.WaitAsync(TimeSpan.FromSeconds(10)); + public Task WaitUntilReleasedAsync(CancellationToken cancellationToken) => this.released.Task.WaitAsync(cancellationToken); + + public void Dispose() + { + Interlocked.Increment(ref this.disposeCount); + this.disposed.TrySetResult(); + } + } +} diff --git a/WindowTranslator/Extensions/ObservableExtensions.cs b/WindowTranslator/Extensions/ObservableExtensions.cs new file mode 100644 index 00000000..2cb76d25 --- /dev/null +++ b/WindowTranslator/Extensions/ObservableExtensions.cs @@ -0,0 +1,144 @@ +using System.Threading.Channels; +using R3; + +namespace WindowTranslator.Extensions; + +public static class ObservableExtensions +{ + /// + /// 先頭と処理中に届いた最新の値を順次処理し、処理されない待機値を破棄します。 + /// + /// + /// 処理を開始した値の後始末は onNextAsync、差し替え・購読終了で不要になった値の後始末は onDiscard が担当します。 + /// + public static IDisposable SubscribeAwaitThrottleFirstLast( + this Observable source, + Func onNextAsync, + Action onDiscard, + bool configureAwait = true, + bool cancelOnCompleted = false, + Action? onErrorResume = null) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(onNextAsync); + ArgumentNullException.ThrowIfNull(onDiscard); + + return source.Subscribe(new ThrottleFirstLastObserver( + onNextAsync, onDiscard, configureAwait, cancelOnCompleted, + onErrorResume ?? ObservableSystem.GetUnhandledExceptionHandler())); + } + + private sealed class ThrottleFirstLastObserver : Observer + { + private readonly Func onNextAsync; + private readonly Action onDiscard; + private readonly Action onErrorResume; + private readonly bool configureAwait; + private readonly bool cancelOnCompleted; + private readonly Channel channel; + private readonly IDisposable worker; + + protected override bool AutoDisposeOnCompleted => false; + + public ThrottleFirstLastObserver( + Func onNextAsync, + Action onDiscard, + bool configureAwait, + bool cancelOnCompleted, + Action onErrorResume) + { + this.onNextAsync = onNextAsync; + this.onDiscard = onDiscard; + this.onErrorResume = onErrorResume; + this.configureAwait = configureAwait; + this.cancelOnCompleted = cancelOnCompleted; + this.channel = Channel.CreateBounded(new BoundedChannelOptions(1) + { + AllowSynchronousContinuations = true, + FullMode = BoundedChannelFullMode.DropOldest, + }, onDiscard); + this.worker = Observable.FromAsync(RunAsync, configureAwait) + .Subscribe(_ => { }, onErrorResume, Complete); + } + + protected override void OnNextCore(T value) + { + if (!this.channel.Writer.TryWrite(value)) + { + this.onDiscard(value); + } + } + + protected override void OnErrorResumeCore(Exception error) => this.onErrorResume(error); + + protected override void OnCompletedCore(Result result) + { + this.channel.Writer.TryComplete(); + if (this.cancelOnCompleted || result.IsFailure) + { + Complete(result); + } + } + + protected override void DisposeCore() + { + this.channel.Writer.TryComplete(); + this.worker.Dispose(); + while (this.channel.Reader.TryRead(out var value)) + { + this.onDiscard(value); + } + } + + private void Complete(Result result) + { + try + { + if (result.Exception is { } error) + { + this.onErrorResume(error); + } + } + finally + { + Dispose(); + } + } + + private async ValueTask RunAsync(CancellationToken cancellationToken) + { + try + { + while (await this.channel.Reader.WaitToReadAsync(cancellationToken).ConfigureAwait(this.configureAwait)) + { + while (this.channel.Reader.TryRead(out var value)) + { + if (cancellationToken.IsCancellationRequested) + { + this.onDiscard(value); + return; + } + + try + { + await this.onNextAsync(value, cancellationToken).ConfigureAwait(this.configureAwait); + } + catch (OperationCanceledException) + { + return; + } + // 購読コールバックの例外は R3 と同様に通知し、次の待機値の処理を続ける。 + catch (Exception error) when (error is not OperationCanceledException) + { + this.onErrorResume(error); + } + } + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // 購読解除による終了では、待機値の破棄を DisposeCore が担当する。 + } + } + } +} diff --git a/WindowTranslator/Modules/Main/MainViewModelBase.cs b/WindowTranslator/Modules/Main/MainViewModelBase.cs index 617faa41..df56b745 100644 --- a/WindowTranslator/Modules/Main/MainViewModelBase.cs +++ b/WindowTranslator/Modules/Main/MainViewModelBase.cs @@ -108,6 +108,7 @@ public MainViewModelBase( this.logger = logger; this.uiLoop = this.uiRequests .ObserveOn(new DispatcherSynchronizationContext(Application.Current.Dispatcher)) + .Where(_ => this.OverlayVisible) .Subscribe(UpdateOcrTexts); var captureInterval = TimeSpan.FromSeconds(options.Value.CaptureInterval); var captures = !this.isOneShotMode && captureInterval > TimeSpan.Zero @@ -116,75 +117,45 @@ public MainViewModelBase( this.captureLoop = captures .ObserveOnThreadPool() .SubscribeAwait(RecognizeAsync, AwaitOperation.ThrottleFirstLast, configureAwait: false); - var previousSize = default(System.Drawing.Size); this.trackingLoop = Disposable.Combine( - Observable.ObservePropertyChanged(this, x => x.OverlayVisible) - .Where(visible => visible) - .ObserveOnThreadPool() - .Subscribe(_ => this.ocrTextTracker.Reset()), + Disposable.Create(() => this.trackingRequests.OnCompleted()), + this.ObservePropertyChanged(x => x.OverlayVisible).Where(v => v).ObserveOnThreadPool().Subscribe(_ => this.ocrTextTracker.Reset()), + Observable.Timer(TimeSpan.Zero, TimeSpan.FromMilliseconds(100), TimeProvider.System).Subscribe(_ => this.trackingRequests.OnNext(default)), this.trackingRequests - .SubscribeAwait(async (request, ct) => + .Materialize() + .Synchronize() + // タイマーの空通知では直前のOCR結果を再利用する。 + .Scan(default((IEnumerable Texts, FilterContext Context)), (previous, current) => { - using var bitmap = request.Context.SoftwareBitmap; - if (previousSize != request.Context.ImageSize) + if (current.Value.Context is { } context) { - this.ocrTextTracker.Reset(); - previousSize = request.Context.ImageSize; - } - // 切替時はタイマーを止め、処理の終了を待ってから画像を破棄する。 - var isOneShotPreview = this.isOneShotMode; - await Observable.Timer(TimeSpan.Zero, TimeSpan.FromMilliseconds(100), TimeProvider.System, CancellationToken.None) - .TakeUntil(ct) - .Where(_ => this.OverlayVisible) - .SelectAwait(async (_, _) => + if (previous.Context?.ImageSize != context.ImageSize) { - var texts = await ProcessTrackingAsync(request, ct); - if (this.isFirstCapture || isOneShotPreview) - { - if (!this.isOneShotMode) - { - this.isFirstCapture = false; - } - isOneShotPreview = false; - this.uiRequests.OnNext(texts); - } - this.translationRequests.OnNext((texts, request.Context)); - return Unit.Default; - }, AwaitOperation.Drop, configureAwait: false) - .LastOrDefaultAsync(cancellationToken: CancellationToken.None); - }, AwaitOperation.Switch, configureAwait: false, cancelOnCompleted: true)); - // 待機用ビューは差し替え時に閉じ、実行中のビューは翻訳側で保持する。 - var pendingTranslationImage = new SerialDisposable(); - this.translationLoop = this.translationRequests - .Select(request => - { - lock (pendingTranslationImage) - { - var bitmap = request.Context.SoftwareBitmap.GetReadOnlyView(); - pendingTranslationImage.Disposable = bitmap; - return (request.Texts, Context: request.Context with { SoftwareBitmap = bitmap }); - } - }) - .Do(onDispose: () => - { - lock (pendingTranslationImage) - { - pendingTranslationImage.Dispose(); - } - }) - .ObserveOnThreadPool() - .SubscribeAwait(async (request, ct) => - { - lock (pendingTranslationImage) + this.ocrTextTracker.Reset(); + } + this.isFirstCapture |= this.isOneShotMode; + } + else if (current.Kind == NotificationKind.OnNext) + { + return previous; + } + previous.Context?.SoftwareBitmap.Dispose(); + return current.Value; + }) + .Where(request => request.Context is not null) + .SubscribeAwait(async (request, ct) => { - if (!ReferenceEquals(pendingTranslationImage.Disposable, request.Context.SoftwareBitmap)) + using var bitmap = request.Context.SoftwareBitmap.GetReadOnlyView(); + var texts = await ProcessTrackingAsync(request, bitmap, ct); + if (this.isFirstCapture) { - return; + this.isFirstCapture = false; + this.uiRequests.OnNext(texts); } - request.Context = request.Context with { SoftwareBitmap = request.Context.SoftwareBitmap.GetReadOnlyView() }; - } - await ProcessTranslationAsync(request, ct); - }, AwaitOperation.ThrottleFirstLast, configureAwait: false); + this.translationRequests.OnNext((texts, request.Context with { SoftwareBitmap = bitmap.GetReadOnlyView(), })); + }, AwaitOperation.Drop, configureAwait: false, cancelOnCompleted: true)); + this.translationLoop = this.translationRequests + .SubscribeAwaitThrottleFirstLast(ProcessTranslationAsync, static r => r.Context.SoftwareBitmap.Dispose(), configureAwait: false); if (!this.isOneShotMode) { this.capture.StartCapture(this.processInfoStore.MainWindowHandle); @@ -198,7 +169,7 @@ partial void OnOverlayVisibleChanged(bool value) if (value) { this.uiRequests.OnNext([]); - this.isFirstCapture = true; + this.isFirstCapture = !this.isOneShotMode; // Start capture when overlay becomes visible this.capture.StartCapture(this.processInfoStore.MainWindowHandle); } @@ -214,11 +185,6 @@ private async Task Capture_CapturedAsync(object? sender, CapturedEventArgs args) { if (this.isOneShotMode) { - if (!this.isFirstCapture) - { - return; - } - this.isFirstCapture = false; this.capture.StopCapture(); } @@ -236,8 +202,7 @@ private async Task Capture_CapturedAsync(object? sender, CapturedEventArgs args) private async ValueTask RecognizeAsync(SoftwareBitmap capturedBitmap, CancellationToken cancellationToken) { - SoftwareBitmap? sbmp = capturedBitmap; - using var bitmap = new DisposeAction(() => sbmp?.Dispose()); + using var bitmap = new DisposeAction(capturedBitmap.Dispose); IEnumerable texts; using (this.Recognizing.EnterBusy()) { @@ -247,17 +212,17 @@ private async ValueTask RecognizeAsync(SoftwareBitmap capturedBitmap, Cancellati var regions = new List(); if (this.priorityRects.Count == 0) { - regions.Add(new(new(0, 0, sbmp.PixelWidth, sbmp.PixelHeight))); + regions.Add(new(new(0, 0, capturedBitmap.PixelWidth, capturedBitmap.PixelHeight))); } else { foreach (var priorityRect in this.priorityRects) { - var rect = priorityRect.ToAbsoluteRect(sbmp.PixelWidth, sbmp.PixelHeight); - var left = Math.Clamp(rect.Left, 0, sbmp.PixelWidth); - var top = Math.Clamp(rect.Top, 0, sbmp.PixelHeight); - var right = Math.Clamp(rect.Right, 0, sbmp.PixelWidth); - var bottom = Math.Clamp(rect.Bottom, 0, sbmp.PixelHeight); + var rect = priorityRect.ToAbsoluteRect(capturedBitmap.PixelWidth, capturedBitmap.PixelHeight); + var left = Math.Clamp(rect.Left, 0, capturedBitmap.PixelWidth); + var top = Math.Clamp(rect.Top, 0, capturedBitmap.PixelHeight); + var right = Math.Clamp(rect.Right, 0, capturedBitmap.PixelWidth); + var bottom = Math.Clamp(rect.Bottom, 0, capturedBitmap.PixelHeight); if (right - left < 1 || bottom - top < 1) { continue; @@ -273,8 +238,8 @@ private async ValueTask RecognizeAsync(SoftwareBitmap capturedBitmap, Cancellati } } - var observations = await this.ocr.RecognizeAsync(new(sbmp, regions)); - this.ocrTraceRecorder.Record(observations, new(sbmp.PixelWidth, sbmp.PixelHeight)); + var observations = await this.ocr.RecognizeAsync(new(capturedBitmap, regions)); + this.ocrTraceRecorder.Record(observations, new(capturedBitmap.PixelWidth, capturedBitmap.PixelHeight)); texts = observations; } catch (ObjectDisposedException) @@ -296,7 +261,7 @@ private async ValueTask RecognizeAsync(SoftwareBitmap capturedBitmap, Cancellati this.captureLoop.Dispose(); await Application.Current.Dispatcher.InvokeAsync(this.capture.StopCapture); var path = Path.Combine(PathUtility.UserDir, $"ocr_error", $"{DateTime.UtcNow:yyyyMMdd'T'HHmmss'Z'}.png"); - await sbmp.TrySaveImage(path); + await capturedBitmap.TrySaveImage(path); await ShowErrorAsync(Resources.FaildOcr, e, path); return; } @@ -308,19 +273,17 @@ private async ValueTask RecognizeAsync(SoftwareBitmap capturedBitmap, Cancellati } var context = new FilterContext { - SoftwareBitmap = sbmp, - ImageSize = new(sbmp.PixelWidth, sbmp.PixelHeight), + SoftwareBitmap = capturedBitmap.GetReadOnlyView(), + ImageSize = new(capturedBitmap.PixelWidth, capturedBitmap.PixelHeight), }; this.trackingRequests.OnNext((texts, context)); - sbmp = null; } - private async ValueTask> ProcessTrackingAsync((IEnumerable Texts, FilterContext Context) request, CancellationToken cancellationToken) + private async ValueTask> ProcessTrackingAsync((IEnumerable Texts, FilterContext Context) request, SoftwareBitmap bitmap, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - IEnumerable texts = this.ocrTextTracker.Update(request.Texts, request.Context.ImageSize); - texts = texts.Select(t => t with { FontSize = t.FontSize * this.fontScale }); - texts = (await this.color.ConvertColorAsync(request.Context.SoftwareBitmap, texts)).ToArray(); + var texts = this.ocrTextTracker.Update(request.Texts, request.Context.ImageSize).Select(t => t with { FontSize = t.FontSize * this.fontScale }); + texts = (await this.color.ConvertColorAsync(bitmap, texts)).ToArray(); texts = texts.Select(t => t switch { { TranslatedText: null } when this.cache.Contains(t.SourceText) => t with { TranslatedText = this.cache.Get(t.SourceText) }, @@ -334,6 +297,10 @@ private async ValueTask ProcessTranslationAsync((IEnumerable Texts, Fi { using var bitmap = request.Context.SoftwareBitmap; cancellationToken.ThrowIfCancellationRequested(); + if (this.disposedValue || !this.OverlayVisible) + { + return; + } IEnumerable texts; using (this.Filtering.EnterBusy()) { @@ -350,11 +317,6 @@ private async ValueTask ProcessTranslationAsync((IEnumerable Texts, Fi var requests = texts .Where(t => t.TranslatedText is null) .ToArray(); - if (this.disposedValue) - { - this.logger.LogDebug("すでに破棄されているので翻訳キューを無視"); - return; - } if (requests.Length > 0) { using var t = this.logger.LogDebugTime("翻訳"); @@ -431,7 +393,6 @@ protected virtual void Dispose(bool disposing) { // 実行中の処理から遅れて通知されても受け付けない。 this.captureRequests.OnCompleted(); - this.trackingRequests.OnCompleted(); this.translationRequests.OnCompleted(); this.uiRequests.OnCompleted(); if (this.capture is IDisposable captureDisposable)