diff --git a/WindowTranslator.Abstractions/TextRect.cs b/WindowTranslator.Abstractions/TextRect.cs index 84ab3c88..379cdda4 100644 --- a/WindowTranslator.Abstractions/TextRect.cs +++ b/WindowTranslator.Abstractions/TextRect.cs @@ -32,6 +32,16 @@ public record TextRect(string SourceText, double X, double Y, double Width, doub /// public double MaxWidth { get; init; } = double.NaN; + /// + /// 表示可能な翻訳結果を待っている理由。 + /// + public TextRegionBusyReason BusyReasons { get; init; } + + /// + /// 領域内にBusyを表示するかどうか。 + /// + public bool IsBusy => this.BusyReasons != TextRegionBusyReason.None; + /// /// コンストラクタ /// diff --git a/WindowTranslator.Abstractions/TextRegionBusyReason.cs b/WindowTranslator.Abstractions/TextRegionBusyReason.cs new file mode 100644 index 00000000..c1e4ba82 --- /dev/null +++ b/WindowTranslator.Abstractions/TextRegionBusyReason.cs @@ -0,0 +1,23 @@ +namespace WindowTranslator; + +/// +/// テキスト領域が表示可能な翻訳結果を待っている理由。 +/// +[Flags] +public enum TextRegionBusyReason +{ + /// + /// 待機していない。 + /// + None = 0, + + /// + /// 翻訳結果を待っている。 + /// + Translation = 1 << 0, + + /// + /// 文字送りの完了を待っている。 + /// + Typewriter = 1 << 1, +} diff --git a/WindowTranslator.Tests/OcrTextTrackerSettingsTests.cs b/WindowTranslator.Tests/OcrTextTrackerSettingsTests.cs index e4c3efd7..4cc71089 100644 --- a/WindowTranslator.Tests/OcrTextTrackerSettingsTests.cs +++ b/WindowTranslator.Tests/OcrTextTrackerSettingsTests.cs @@ -109,12 +109,14 @@ public void RecognitionLevelControlsMergeAndRestoreIndependentlyOfGeometry(int l public void UnsettledTextEventuallyUsesTheLatestRecognition(int level, int historySize) { OcrTextTracker tracker = Create(recognition: level); - Update(tracker, 0, initial); + // 文字送りを起こさず、認識候補の履歴による確定を検証する。 + TextRect initialText = initial with { SourceText = "Panel0" }; + Update(tracker, 0, initialText); for (int frame = 1; frame <= historySize; frame++) { - TextRect changed = initial with { SourceText = $"Panel{frame}" }; + TextRect changed = initialText with { SourceText = $"Panel{frame}" }; TextRect result = Assert.Single(Update(tracker, frame, changed)); - Assert.Equal(level == 1 || frame == historySize ? changed.SourceText : initial.SourceText, result.SourceText); + Assert.Equal(level == 1 || frame == historySize ? changed.SourceText : initialText.SourceText, result.SourceText); } } @@ -124,12 +126,14 @@ public void UnsettledTextEventuallyUsesTheLatestRecognition(int level, int histo public void ScatteredTextVotesNeedEnoughRecentWeight(int level, int pendingFrames) { OcrTextTracker tracker = Create(recognition: level); - Update(tracker, 0, initial); + // 文字送りを起こさず、認識候補の票数と重みによる確定を検証する。 + TextRect initialText = initial with { SourceText = "Panel0" }; + Update(tracker, 0, initialText); string[] observations = ["PanelA", "PanelB", "PanelA", "PanelC", "PanelA", "PanelA", "PanelA"]; for (int frame = 1; frame <= pendingFrames + 1; frame++) { - TextRect result = Assert.Single(Update(tracker, frame, initial with { SourceText = observations[frame - 1] })); - Assert.Equal(frame <= pendingFrames ? initial.SourceText : "PanelA", result.SourceText); + TextRect result = Assert.Single(Update(tracker, frame, initialText with { SourceText = observations[frame - 1] })); + Assert.Equal(frame <= pendingFrames ? initialText.SourceText : "PanelA", result.SourceText); } } diff --git a/WindowTranslator.Tests/OcrTypewriterTrackingTests.cs b/WindowTranslator.Tests/OcrTypewriterTrackingTests.cs new file mode 100644 index 00000000..9c178fa8 --- /dev/null +++ b/WindowTranslator.Tests/OcrTypewriterTrackingTests.cs @@ -0,0 +1,310 @@ +using System.Drawing; +using Microsoft.Extensions.Logging.Abstractions; +using WindowTranslator.Modules.Ocr; + +namespace WindowTranslator.Tests; + +public sealed class OcrTypewriterTrackingTests +{ + private static readonly Size imageSize = new(1280, 720); + private const string longEnglishText = "While the message is being revealed character by character, OCR may briefly misread letters or lose part of a line. Only the complete message should be translated after the text stops changing."; + private const string longJapaneseText = "これは文字送り中の長い文章を検証するためのメッセージです。途中で文字の誤認識や行末の欠落が発生しても、文字送りが完了するまでは翻訳せず、最後に確定した文章だけを翻訳することを確認します。"; + + [Fact] + public void ProgressiveTextStaysBusyAndOnlyTheFinalTextBecomesTranslatable() + { + OcrTextTracker tracker = CreateTracker(); + List translationRequests = []; + + ObserveAndCollect(tracker, translationRequests, 0, Rect("H")); + TextRect he = ObserveAndCollect(tracker, translationRequests, 500, Rect("He")).Single(); + TextRect hel = ObserveAndCollect(tracker, translationRequests, 1000, Rect("Hel")).Single(); + TextRect helloBusy = ObserveAndCollect(tracker, translationRequests, 1500, Rect("Hello")).Single(); + TextRect hello = ObserveAndCollect(tracker, translationRequests, 2000, Rect("Hello")).Single(); + + Assert.Equal(TextRegionBusyReason.Typewriter, he.BusyReasons); + Assert.Equal(TextRegionBusyReason.Typewriter, hel.BusyReasons); + Assert.Equal(TextRegionBusyReason.Typewriter, helloBusy.BusyReasons); + Assert.False(hello.IsBusy); + Assert.Equal("Hello", hello.SourceText); + Assert.Equal(["H", "Hello"], translationRequests); + } + + [Theory] + [InlineData(longEnglishText)] + [InlineData(longJapaneseText)] + public void LongProgressiveTextWithRecognitionErrorsStaysBusyUntilTheCompleteTextSettles(string text) + { + OcrTextTracker tracker = CreateTracker(); + List translationRequests = []; + TextRect region = Rect(text[..8]) with { Width = 900, Height = 90, MultiLine = true }; + + ObserveAndCollect(tracker, translationRequests, 0, region); + int milliseconds = 0; + int frame = 0; + for (int length = 16; length < text.Length; length += 8) + { + frame++; + milliseconds += 500; + string observation = text[..length]; + observation = frame switch + { + 2 => observation[..^1] + "0", // 伸長した末尾の誤認識 + 6 => observation[..^12], // 一時的な行末の欠落 + 8 => "X" + observation[1..], // 先頭の一時的な誤認識 + _ => observation, + }; + TextRect progressing = ObserveAndCollect( + tracker, translationRequests, milliseconds, region with { SourceText = observation }).Single(); + + Assert.True(progressing.IsBusy, $"文字送り中のフレーム {frame} でBusyが解除されました。"); + Assert.Equal(region.SourceText, progressing.SourceText); + } + + TextRect complete = region with { SourceText = text }; + TextRect waiting = ObserveAndCollect(tracker, translationRequests, milliseconds + 500, complete).Single(); + TextRect final = ObserveAndCollect(tracker, translationRequests, milliseconds + 1000, complete).Single(); + + Assert.True(waiting.IsBusy); + Assert.False(final.IsBusy); + Assert.Equal(text, final.SourceText); + Assert.Equal([region.SourceText, text], translationRequests); + } + + [Theory] + [InlineData(longEnglishText)] + [InlineData(longJapaneseText)] + public void LongTextWithAnOscillatingFinalRecognitionTranslatesOnlyTheCompleteCandidate(string text) + { + OcrTextTracker tracker = CreateTracker(); + List translationRequests = []; + TextRect region = Rect(text[..8]) with { Width = 900, Height = 90, MultiLine = true }; + int milliseconds = 0; + for (int length = 8; length < text.Length; length += 8) + { + ObserveAndCollect(tracker, translationRequests, milliseconds, region with { SourceText = text[..length] }); + milliseconds += 500; + } + + TextRect complete = region with { SourceText = text }; + TextRect noisy = region with { SourceText = text[..^2] + "0" + text[^1..] }; + ObserveAndCollect(tracker, translationRequests, milliseconds, complete); + TextRect firstNoise = ObserveAndCollect(tracker, translationRequests, milliseconds + 500, noisy).Single(); + TextRect corrected = ObserveAndCollect(tracker, translationRequests, milliseconds + 1000, complete).Single(); + TextRect final = ObserveAndCollect(tracker, translationRequests, milliseconds + 1500, noisy).Single(); + + Assert.True(firstNoise.IsBusy); + Assert.True(corrected.IsBusy); + Assert.False(final.IsBusy); + Assert.Equal(text, final.SourceText); + Assert.Equal([region.SourceText, text], translationRequests); + } + + [Fact] + public void OscillatingTailSelectsTheMostProgressedCandidateAndLeavesTypewriterBusy() + { + OcrTextTracker tracker = CreateTracker(); + List translationRequests = []; + + ObserveAndCollect(tracker, translationRequests, 0, Rect("H")); + ObserveAndCollect(tracker, translationRequests, 500, Rect("He")); + ObserveAndCollect(tracker, translationRequests, 1000, Rect("Hel")); + ObserveAndCollect(tracker, translationRequests, 1500, Rect("Hello")); + ObserveAndCollect(tracker, translationRequests, 2000, Rect("Hell0")); + ObserveAndCollect(tracker, translationRequests, 2500, Rect("Hello")); + TextRect final = ObserveAndCollect(tracker, translationRequests, 3000, Rect("Hell0")).Single(); + + Assert.False(final.IsBusy); + Assert.Equal("Hello", final.SourceText); + Assert.Equal(["H", "Hello"], translationRequests); + } + + [Fact] + public void ReturningToTheConfirmedTextCancelsTypewriterBusy() + { + OcrTextTracker tracker = CreateTracker(); + + TextRect original = Update(tracker, 0, Rect("Menu")).Single(); + TextRect progressing = Update(tracker, 500, Rect("Menu...")).Single(); + TextRect restored = Update(tracker, 1000, Rect("Menu")).Single(); + + Assert.False(original.IsBusy); + Assert.Equal(TextRegionBusyReason.Typewriter, progressing.BusyReasons); + Assert.False(restored.IsBusy); + Assert.Equal("Menu", restored.SourceText); + } + + [Fact] + public void OrdinaryReplacementUsesTheExistingTextConfirmation() + { + OcrTextTracker tracker = CreateTracker(); + + Update(tracker, 0, Rect("Menu")); + TextRect candidate = Update(tracker, 500, Rect("Game")).Single(); + TextRect confirmed = Update(tracker, 1000, Rect("Game")).Single(); + + Assert.False(candidate.IsBusy); + Assert.Equal("Menu", candidate.SourceText); + Assert.False(confirmed.IsBusy); + Assert.Equal("Game", confirmed.SourceText); + } + + [Fact] + public void ProgressionCanStartAfterThePreviousTextWasReplaced() + { + OcrTextTracker tracker = CreateTracker(); + + Update(tracker, 0, Rect("Menu")); + TextRect firstCharacter = Update(tracker, 500, Rect("H")).Single(); + TextRect progressing = Update(tracker, 1000, Rect("He")).Single(); + + Assert.False(firstCharacter.IsBusy); + Assert.Equal("Menu", firstCharacter.SourceText); + Assert.Equal(TextRegionBusyReason.Typewriter, progressing.BusyReasons); + } + + [Fact] + public void ASingleExtensionAfterCompletionUsesTheExistingStabilization() + { + OcrTextTracker tracker = CreateTracker(); + + Update(tracker, 0, Rect("H")); + Update(tracker, 500, Rect("He")); + Update(tracker, 1000, Rect("Hello")); + TextRect completed = Update(tracker, 1500, Rect("Hello")).Single(); + TextRect noise = Update(tracker, 2000, Rect("Hello!")).Single(); + + Assert.False(completed.IsBusy); + Assert.Equal("Hello", completed.SourceText); + Assert.False(noise.IsBusy); + Assert.Equal("Hello", noise.SourceText); + } + + [Fact] + public void RapidRepeatedFramesDoNotEndTypewriterBeforeThePauseThreshold() + { + OcrTextTracker tracker = CreateTracker(); + + Update(tracker, 0, Rect("H")); + Update(tracker, 50, Rect("He")); + TextRect earlyRepeat = Update(tracker, 100, Rect("He")).Single(); + TextRect stillProgressing = Update(tracker, 400, Rect("He")).Single(); + TextRect completed = Update(tracker, 550, Rect("He")).Single(); + + Assert.True(earlyRepeat.IsBusy); + Assert.True(stillProgressing.IsBusy); + Assert.False(completed.IsBusy); + Assert.Equal("He", completed.SourceText); + } + + [Fact] + public void ReusingAnOcrObservationDoesNotConfirmTypewriterBeforeAnotherRecognition() + { + OcrTextTracker tracker = CreateTracker(); + TextRect[] observation = [Rect("He")]; + + Update(tracker, 0, Rect("H")); + Update(tracker, 500, observation); + for (int milliseconds = 600; milliseconds <= 1000; milliseconds += 100) + { + TextRect waiting = Update(tracker, milliseconds, observation).Single(); + Assert.True(waiting.IsBusy); + Assert.Equal("H", waiting.SourceText); + } + + TextRect confirmed = Update(tracker, 1100, Rect("He")).Single(); + Assert.False(confirmed.IsBusy); + Assert.Equal("He", confirmed.SourceText); + } + + [Fact] + public void ReusingANoisyObservationDoesNotEvictEarlierProgression() + { + OcrTextTracker tracker = CreateTracker(); + TextRect[] noisy = [Rect("Hell0")]; + TextRect[] complete = [Rect("Hello!")]; + + Update(tracker, 0, Rect("H")); + Update(tracker, 500, Rect("He")); + Update(tracker, 1000, Rect("Hel")); + Update(tracker, 1500, noisy); + for (int milliseconds = 1600; milliseconds <= 2700; milliseconds += 100) + { + Assert.True(Update(tracker, milliseconds, noisy).Single().IsBusy); + } + + Assert.True(Update(tracker, 2800, complete).Single().IsBusy); + Assert.True(Update(tracker, 3300, complete).Single().IsBusy); + TextRect confirmed = Update(tracker, 3400, Rect("Hello!")).Single(); + + Assert.False(confirmed.IsBusy); + Assert.Equal("Hello!", confirmed.SourceText); + } + + [Fact] + public void ReusingAnOcrObservationDoesNotConsumePostCompletionCooldown() + { + OcrTextTracker tracker = CreateTracker(); + TextRect[] complete = [Rect("Hello")]; + TextRect[] noise = [Rect("Hello!")]; + + Update(tracker, 0, Rect("H")); + Update(tracker, 500, Rect("He")); + Update(tracker, 1000, Rect("Hello")); + Assert.False(Update(tracker, 1500, complete).Single().IsBusy); + Update(tracker, 1600, complete); + Update(tracker, 1700, complete); + TextRect pending = Update(tracker, 2000, noise).Single(); + TextRect confirmed = Update(tracker, 2300, noise).Single(); + + Assert.False(pending.IsBusy); + Assert.Equal("Hello", pending.SourceText); + Assert.False(confirmed.IsBusy); + Assert.Equal("Hello!", confirmed.SourceText); + } + + [Fact] + public void ATypewriterTrackDoesNotBlockAnotherLogicalTrack() + { + OcrTextTracker tracker = CreateTracker(); + + Update(tracker, 0, Rect("H"), Rect("Status", x: 400)); + IReadOnlyList output = Update( + tracker, + 500, + Rect("He"), + Rect("Status", x: 400)); + + TextRect typewriter = Assert.Single(output, text => text.X == 100); + TextRect stable = Assert.Single(output, text => text.X == 400); + Assert.True(typewriter.IsBusy); + Assert.False(stable.IsBusy); + Assert.Equal("Status", stable.SourceText); + } + + private static OcrTextTracker CreateTracker() + => new(NullLogger.Instance); + + private static IReadOnlyList ObserveAndCollect( + OcrTextTracker tracker, + List translationRequests, + int milliseconds, + params TextRect[] observations) + { + IReadOnlyList output = Update(tracker, milliseconds, observations); + translationRequests.AddRange(output + .Where(text => !text.IsBusy) + .Select(text => text.SourceText) + .Where(text => !translationRequests.Contains(text, StringComparer.Ordinal))); + return output; + } + + private static IReadOnlyList Update( + OcrTextTracker tracker, + int milliseconds, + params TextRect[] observations) + => tracker.Update(observations, imageSize, TimeSpan.FromMilliseconds(milliseconds)); + + private static TextRect Rect(string text, double x = 100) + => new(text, x, 100, 160, 30, 20, false); +} diff --git a/WindowTranslator/Data/BoolToDataTemplateConverter.cs b/WindowTranslator/Data/BoolToDataTemplateConverter.cs new file mode 100644 index 00000000..f1cbe2ef --- /dev/null +++ b/WindowTranslator/Data/BoolToDataTemplateConverter.cs @@ -0,0 +1,19 @@ +using System.Globalization; +using System.Windows; +using System.Windows.Data; + +namespace WindowTranslator.Data; + +[ValueConversion(typeof(bool), typeof(DataTemplate))] +public sealed class BoolToDataTemplateConverter : IValueConverter +{ + public required DataTemplate FalseContent { get; set; } + + public required DataTemplate TrueContent { get; set; } + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + => value is true ? this.TrueContent : this.FalseContent; + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotSupportedException(); +} diff --git a/WindowTranslator/Modules/Main/MainViewModelBase.cs b/WindowTranslator/Modules/Main/MainViewModelBase.cs index df56b745..b1fe06c9 100644 --- a/WindowTranslator/Modules/Main/MainViewModelBase.cs +++ b/WindowTranslator/Modules/Main/MainViewModelBase.cs @@ -286,6 +286,7 @@ private async ValueTask> ProcessTrackingAsync((IEnumerable texts = (await this.color.ConvertColorAsync(bitmap, texts)).ToArray(); texts = texts.Select(t => t switch { + { IsBusy: true } => t with { TranslatedText = null }, { TranslatedText: null } when this.cache.Contains(t.SourceText) => t with { TranslatedText = this.cache.Get(t.SourceText) }, _ => t, }).ToArray(); @@ -315,6 +316,7 @@ private async ValueTask ProcessTranslationAsync((IEnumerable Texts, Fi try { var requests = texts + .Where(t => !t.IsBusy) .Where(t => t.TranslatedText is null) .ToArray(); if (requests.Length > 0) @@ -323,7 +325,7 @@ private async ValueTask ProcessTranslationAsync((IEnumerable Texts, Fi 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 + texts = texts.Select(t => t.TranslatedText is null && !t.IsBusy ? t with { TranslatedText = translated[translatedIndex++] } : t).ToArray(); } @@ -363,7 +365,14 @@ private async ValueTask ProcessTranslationAsync((IEnumerable Texts, Fi private void UpdateOcrTexts(IEnumerable texts) { this.logger.LogDebug("UI適用"); - var hash = texts.Select(t => t with { Background = Color.FromArgb((int)(255 * this.overlayOpacity), t.Background) }).ToHashSet(); + // 翻訳待ちは表示用の複製にだけ付ける。 + var hash = texts.Select(t => t with + { + Background = Color.FromArgb((int)(255 * this.overlayOpacity), t.Background), + BusyReasons = t.TranslatedText is null && !t.IsBusy + ? TextRegionBusyReason.Translation + : t.BusyReasons, + }).ToHashSet(); foreach (var text in this.OcrTexts.Where(t => !hash.Contains(t)).ToArray()) { this.OcrTexts.Remove(text); diff --git a/WindowTranslator/Modules/Ocr/OcrTextTracker.cs b/WindowTranslator/Modules/Ocr/OcrTextTracker.cs index 3adce69a..9e51eff8 100644 --- a/WindowTranslator/Modules/Ocr/OcrTextTracker.cs +++ b/WindowTranslator/Modules/Ocr/OcrTextTracker.cs @@ -18,6 +18,8 @@ public sealed class OcrTextTracker(ILogger logger, TargetSetting private const int MaxOneToOneCandidatesPerResource = 3; private const double MinimumAssignmentScore = 0.58; private const double StructureAssignmentBonus = 0.05; + private const int TypewriterCandidateHistorySize = 7; + private const int TypewriterCooldownFrames = 2; private const double MinimumStructureSizeRatio = 0.65; private const double MinimumStructureOverlap = 0.45; private const double MinimumStructureTextSimilarity = 0.65; @@ -33,6 +35,8 @@ public sealed class OcrTextTracker(ILogger logger, TargetSetting private const double StrongOneToOneScore = 0.9; private const double AngleVectorEpsilon = 0.000000000001; private static readonly TimeSpan dormantRetention = TimeSpan.FromSeconds(5); + private static readonly TimeSpan typewriterStableDuration = TimeSpan.FromMilliseconds(500); + private static readonly TimeSpan typewriterTailDuration = TimeSpan.FromMilliseconds(1500); private readonly ILogger logger = logger; private readonly TrackingProfile profile = TrackingProfile.FromSettings(settings ?? new()); @@ -1933,6 +1937,8 @@ private sealed class TextTrack( private TimeSpan normalGeometryCandidateSince; private TimeSpan noiseGeometrySince; private readonly List noiseGeometrySamples = []; + private TypewriterState? typewriter; + private int typewriterCooldownFrames; private string? textCandidate; private TimeSpan textCandidateSince; private TimeSpan textChangeSince; @@ -1981,13 +1987,15 @@ private sealed class TextTrack( public void Observe(TextRect current, TimeSpan timestamp, TimeSpan observationTimestamp) { this.MissingSince = null; - if (observationTimestamp != this.LastObservationTime) + bool isNewObservation = observationTimestamp != this.LastObservationTime; + if (isNewObservation) { this.UpdateMotion(current, observationTimestamp); this.LastObservationTime = observationTimestamp; } + string previousText = this.LatestObservation.SourceText; this.LatestObservation = current; - this.UpdateText(current.SourceText, timestamp); + this.UpdateText(previousText, current.SourceText, timestamp, isNewObservation); this.UpdateGeometry(current, timestamp); this.Stabilized = current with { @@ -1999,6 +2007,9 @@ public void Observe(TextRect current, TimeSpan timestamp, TimeSpan observationTi FontSize = this.Stabilized.FontSize, MultiLine = this.Stabilized.MultiLine, Angle = this.Stabilized.Angle, + BusyReasons = this.typewriter is null + ? current.BusyReasons & ~TextRegionBusyReason.Typewriter + : current.BusyReasons | TextRegionBusyReason.Typewriter, }; } @@ -2053,15 +2064,56 @@ public void Reactivate(TimeSpan timestamp) this.ResetMotion(timestamp); } - private void UpdateText(string current, TimeSpan timestamp) + private void UpdateText(string previous, string current, TimeSpan timestamp, bool isNewObservation) { + string normalizedPrevious = NormalizeText(previous); string normalizedCurrent = NormalizeText(current); + + if (this.typewriter is not null) + { + if (normalizedCurrent == this.typewriter.Start.Normalized) + { + this.typewriter = null; + this.textCandidate = null; + return; + } + + if (this.typewriter.Observe(new(normalizedCurrent, current), timestamp, isNewObservation, out TextVote? final)) + { + TextVote confirmed = final + ?? throw new InvalidOperationException("文字送りの最終候補がありません。"); + this.ConfirmedText = confirmed.Original; + this.normalizedConfirmedText = confirmed.Normalized; + this.typewriter = null; + this.typewriterCooldownFrames = TypewriterCooldownFrames; + this.textCandidate = null; + } + return; + } + + bool canStartTypewriter = this.typewriterCooldownFrames == 0; + if (isNewObservation && this.typewriterCooldownFrames > 0) + { + this.typewriterCooldownFrames--; + } + if (normalizedCurrent == this.normalizedConfirmedText) { this.textCandidate = null; return; } + if (canStartTypewriter && IsProgressiveTextChange(normalizedPrevious, normalizedCurrent)) + { + this.typewriter = new( + new(this.normalizedConfirmedText, this.ConfirmedText), + new(normalizedPrevious, previous), + new(normalizedCurrent, current), + timestamp); + this.textCandidate = null; + return; + } + if (this.textCandidate is null) { this.textChangeSince = timestamp; @@ -2082,6 +2134,11 @@ private void UpdateText(string current, TimeSpan timestamp) } } + private static bool IsProgressiveTextChange(string previous, string current) + => previous.Length > 0 + && current.Length > previous.Length + && current.StartsWith(previous, StringComparison.Ordinal); + private void UpdateMotion(TextRect current, TimeSpan timestamp) { double seconds = (timestamp - this.LastObservationTime).TotalSeconds; @@ -2291,5 +2348,97 @@ public TextRect Restore(TextRect child, TextRect parent) }; } } + + private sealed class TypewriterState( + TextVote start, + TextVote previous, + TextVote current, + TimeSpan timestamp) + { + private readonly List history = [previous, current]; + private TextVote last = current; + private int consecutiveCount = 1; + private TimeSpan lastProgressTime = timestamp; + + public TextVote Start { get; } = start; + + public TextVote MostProgressed { get; private set; } = current; + + public bool Observe(TextVote current, TimeSpan timestamp, bool isNewObservation, out TextVote? final) + { + // タイマーによる再評価では、認識履歴と連続観測回数を増やさない。 + if (isNewObservation) + { + this.AddHistory(current); + if (current.Normalized == this.last.Normalized) + { + this.consecutiveCount++; + } + else + { + this.last = current; + this.consecutiveCount = 1; + } + } + + // 誤認識を挟んでも、直近の候補から文字送りが続いていれば進行とみなす。 + if (current.Normalized.Length > this.MostProgressed.Normalized.Length + && this.history.Any(candidate => IsProgressiveTextChange(candidate.Normalized, current.Normalized))) + { + this.MostProgressed = current; + this.lastProgressTime = timestamp; + final = null; + return false; + } + + if (current.Normalized == this.MostProgressed.Normalized + && this.consecutiveCount >= 2) + { + if (timestamp - this.lastProgressTime >= typewriterStableDuration) + { + final = current; + return true; + } + } + + if (timestamp - this.lastProgressTime >= typewriterTailDuration) + { + final = this.SelectFinal(); + return true; + } + + final = null; + return false; + } + + private void AddHistory(TextVote candidate) + { + this.history.Add(candidate); + if (this.history.Count > TypewriterCandidateHistorySize) + { + this.history.RemoveAt(0); + } + } + + private TextVote SelectFinal() + => this.history + .Where(candidate => candidate.Normalized != this.Start.Normalized) + .Select((candidate, index) => (candidate, index)) + .GroupBy(item => item.candidate.Normalized) + .Select(group => new + { + Candidate = group.Last().candidate, + Count = group.Count(), + IsMostProgressed = group.Key == this.MostProgressed.Normalized, + LastIndex = group.Max(item => item.index), + }) + .OrderByDescending(candidate => candidate.Count) + .ThenByDescending(candidate => candidate.IsMostProgressed) + .ThenByDescending(candidate => candidate.LastIndex) + .Select(candidate => candidate.Candidate) + .FirstOrDefault(this.MostProgressed); + } + + private sealed record TextVote(string Normalized, string Original); } } diff --git a/WindowTranslator/Themes/Generic.xaml b/WindowTranslator/Themes/Generic.xaml index 0ae7061c..da42e7af 100644 --- a/WindowTranslator/Themes/Generic.xaml +++ b/WindowTranslator/Themes/Generic.xaml @@ -50,8 +50,8 @@ CornerRadius="{Binding RelativeSource={RelativeSource Self}, Converter={StaticResource s2crConv}}" Opacity="{Binding TranslatedText, Converter={StaticResource n2dConv}}"> - - + + - - + + - - + + - +