Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions WindowTranslator.Abstractions/UserSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ public class TargetSettings
/// </summary>
public bool DisplayBusy { get; set; } = true;

/// <summary>
/// ホットキーが押されたときだけOCRと翻訳を行うか
/// </summary>
public bool IsOneShotMode { get; set; }

/// <summary>
/// マウスポインター判定の余白(WPF上のピクセル値)
/// </summary>
Expand Down
2 changes: 0 additions & 2 deletions WindowTranslator.Tests/OcrTextTrackerAccuracyTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1710,10 +1710,8 @@ public void RemovedFeaturesAreNotExposed()
const System.Reflection.BindingFlags flags = System.Reflection.BindingFlags.Static
| System.Reflection.BindingFlags.Public
| System.Reflection.BindingFlags.NonPublic;
Type appResources = typeof(OcrTextTracker).Assembly.GetType("WindowTranslator.Properties.Resources", throwOnError: true)!;
Type abstractionResources = typeof(TextRect).Assembly.GetType("WindowTranslator.Properties.Resources", throwOnError: true)!;

Assert.Null(appResources.GetProperty("IsOneShotMode", flags));
Assert.Null(abstractionResources.GetProperty("Buffer", flags));
Assert.Null(abstractionResources.GetProperty("BufferSize", flags));
Assert.Null(abstractionResources.GetProperty("IsSuppressVibe", flags));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ private static string GetInfo(Exception ex, UserSettings? settings, string targe
sb.AppendLine(CultureInfo.CurrentCulture, $"OverlayShortcut: {value.OverlayShortcut ?? "N/A"}");
sb.AppendLine(CultureInfo.CurrentCulture, $"OverlayOpacity: {value.OverlayOpacity}");
sb.AppendLine(CultureInfo.CurrentCulture, $"DisplayBusy: {value.DisplayBusy}");
sb.AppendLine(CultureInfo.CurrentCulture, $"IsOneShotMode: {value.IsOneShotMode}");

if (value.SelectedPlugins?.Count > 0)
{
Expand Down
119 changes: 93 additions & 26 deletions WindowTranslator/Modules/Main/MainViewModelBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ namespace WindowTranslator.Modules.Main;
[ObservableObject]
public abstract partial class MainViewModelBase : IDisposable
{
private readonly Timer timer;
private readonly Timer? timer;
private readonly IOcrModule ocr;
private readonly IOcrTextTracker ocrTextTracker;
private readonly ITranslateModule translator;
Expand All @@ -39,6 +39,7 @@ public abstract partial class MainViewModelBase : IDisposable
private readonly double fontScale;
private readonly double overlayOpacity;
private readonly double mousePointerHitTestPadding;
private readonly bool isOneShotMode;
private TextRect[]? lastRequested;

[ObservableProperty]
Expand All @@ -59,6 +60,7 @@ public abstract partial class MainViewModelBase : IDisposable

private SoftwareBitmap? capturedBmp;
private SoftwareBitmap? analyzingBmp;
private bool isFirstCapture;
private bool disposedValue;

public ObservableCollection<TextRect> OcrTexts { get; } = [];
Expand All @@ -85,6 +87,7 @@ public MainViewModelBase(
this.fontScale = options.Value.FontScale;
this.overlayOpacity = options.Value.OverlayOpacity;
this.mousePointerHitTestPadding = options.Value.MousePointerHitTestPadding;
this.isOneShotMode = options.Value.IsOneShotMode;
this.DisplayBusy = options.Value.DisplayBusy;
this.capture = capture ?? throw new ArgumentNullException(nameof(capture));
this.capture.Captured += Capture_CapturedAsync;
Expand All @@ -95,14 +98,23 @@ public MainViewModelBase(
this.color = color ?? throw new ArgumentNullException(nameof(color));
this.filters = filters.ToArray();
this.logger = logger;
this.capture.StartCapture(processInfoStore.MainWindowHandle);
this.timer = new(_ => Application.Current.Dispatcher.Invoke(() => CreateTextOverlayAsync().Forget()), null, 0, 500);
if (!this.isOneShotMode)
{
this.capture.StartCapture(processInfoStore.MainWindowHandle);
this.timer = new(_ => Application.Current.Dispatcher.Invoke(() => CreateTextOverlayAsync().Forget()), null, 0, 500);
}
var transAsm = this.translator.GetType().Assembly;
this.title = $"{this.name} - {this.translator.Name} ({transAsm.GetName().Version})";
}

partial void OnOverlayVisibleChanged(bool value)
{
// OneShotのキャプチャーはRequestOneShotで開始する。
if (this.isOneShotMode)
Comment thread
Freeesia marked this conversation as resolved.
{
return;
}

if (value)
{
this.OcrTexts.Clear();
Expand All @@ -120,6 +132,16 @@ partial void OnOverlayVisibleChanged(bool value)

private async Task Capture_CapturedAsync(object? sender, CapturedEventArgs args)
{
if (this.isOneShotMode)
{
if (!this.isFirstCapture)
{
return;
}
this.isFirstCapture = false;
this.capture.StopCapture();
}

if (this.analyzing.CurrentCount == 0)
{
return;
Expand All @@ -132,6 +154,19 @@ private async Task Capture_CapturedAsync(object? sender, CapturedEventArgs args)
sbmp?.Dispose();
}

public void RequestOneShot()
{
if (!this.isOneShotMode)
{
return;
}

this.logger.LogDebug("OneShot OCR requested");
this.OcrTexts.Clear();
this.isFirstCapture = true;
this.capture.StartCapture(this.processInfoStore.MainWindowHandle);
}

private async Task CreateTextOverlayAsync()
{
if (!await this.analyzing.WaitAsync(0))
Expand Down Expand Up @@ -163,26 +198,28 @@ private async Task CreateTextOverlayAsync()
{
try
{
texts = await this.ocr.RecognizeAsync(sbmp);
texts = this.ocrTextTracker.Update(texts, new(sbmp.PixelWidth, sbmp.PixelHeight));
var observations = await this.ocr.RecognizeAsync(sbmp);
texts = this.isOneShotMode
? observations
: this.ocrTextTracker.Update(observations, new(sbmp.PixelWidth, sbmp.PixelHeight));
}
catch (ObjectDisposedException)
{
// すでに破棄されている場合は何もしない
this.timer.DisposeAsync().Forget();
await DisposeTimerAsync();
this.capture.StopCapture();
return;
}
catch (OperationCanceledException)
{
// キャンセルされた場合は何もしない
this.timer.DisposeAsync().Forget();
await DisposeTimerAsync();
this.capture.StopCapture();
return;
}
catch (Exception e)
{
this.timer.DisposeAsync().Forget();
await DisposeTimerAsync();
this.capture.StopCapture();
var path = Path.Combine(PathUtility.UserDir, $"ocr_error", $"{DateTime.UtcNow:yyyyMMdd'T'HHmmss'Z'}.png");
await sbmp.TrySaveImage(path);
Expand All @@ -194,11 +231,12 @@ private async Task CreateTextOverlayAsync()
texts = texts.Select(t => t with { FontSize = t.FontSize * this.fontScale });

// フィルター&翻訳処理は必ず通す
FilterContext context;
TextRect[] displayedTexts;
using (this.Filtering.EnterBusy())
{
texts = await this.color.ConvertColorAsync(sbmp, texts);

var context = new FilterContext()
context = new()
{
SoftwareBitmap = sbmp,
ImageSize = new(sbmp.PixelWidth, sbmp.PixelHeight),
Expand All @@ -212,26 +250,47 @@ private async Task CreateTextOverlayAsync()
using var t = this.logger.LogDebugTime("PreTranslate");
texts = await tmp.ToArrayAsync();
}
TranslateAsync(texts).Forget();
texts = texts.Select(t => t switch
if (!this.isOneShotMode)
{
{ TranslatedText: null } when this.cache.Contains(t.SourceText) => t with { TranslatedText = this.cache.Get(t.SourceText) },
_ => t,
}).ToArray();
{
var tmp = texts.ToAsyncEnumerable();
foreach (var filter in this.filters.OrderBy(f => f.Priority))
{
tmp = filter.ExecutePostTranslate(tmp, context);
}
using var t = this.logger.LogDebugTime("PostTranslate");
texts = await tmp.ToArrayAsync();
TranslateAsync(texts).Forget();
}
displayedTexts = await CreateDisplayedTextsAsync(texts, context);
}

UpdateOcrTexts(displayedTexts);
if (!this.isOneShotMode)
{
return;
}

await TranslateAsync(texts);

// 背景色に不透明度を設定
texts = texts.Select(t => t with { Background = Color.FromArgb((int)(255 * this.overlayOpacity), t.Background) }).ToArray();
using (this.Filtering.EnterBusy())
{
displayedTexts = await CreateDisplayedTextsAsync(texts, context);
}
UpdateOcrTexts(displayedTexts);
}

private async Task<TextRect[]> CreateDisplayedTextsAsync(IEnumerable<TextRect> texts, FilterContext context)
{
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 tmp = texts.ToAsyncEnumerable();
foreach (var filter in this.filters.OrderBy(f => f.Priority))
{
tmp = filter.ExecutePostTranslate(tmp, context);
}
using var t = this.logger.LogDebugTime("PostTranslate");
texts = await tmp.ToArrayAsync();
return texts.Select(t => t with { Background = Color.FromArgb((int)(255 * this.overlayOpacity), t.Background) }).ToArray();
}

private void UpdateOcrTexts(IEnumerable<TextRect> texts)
{
var hash = texts.ToHashSet();
foreach (var text in this.OcrTexts.Where(t => !hash.Contains(t)).ToArray())
{
Expand Down Expand Up @@ -280,7 +339,7 @@ private async Task TranslateAsync(IEnumerable<TextRect> texts)
catch (Exception e) when (e is not OperationCanceledException)
{
this.logger.LogError(e, "翻訳中にエラーが発生");
this.timer.DisposeAsync().Forget();
await DisposeTimerAsync();
this.capture.StopCapture();
// 翻訳失敗してエラーで閉じる場合はキューをクリア
Interlocked.Exchange(ref this.lastRequested, null);
Expand All @@ -293,6 +352,14 @@ private async Task TranslateAsync(IEnumerable<TextRect> texts)
}
}

private async ValueTask DisposeTimerAsync()
{
if (this.timer is { } timer)
{
await timer.DisposeAsync();
}
}

protected virtual void Dispose(bool disposing)
{
if (disposedValue)
Expand Down
4 changes: 4 additions & 0 deletions WindowTranslator/Modules/Main/OverlayMainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,10 @@ private nint WndProc(nint hwnd, int msg, nint wParam, nint lParam, ref bool hand
{
return 0;
}
if (this.DataContext is OverlayMainViewModel viewModel)
{
viewModel.RequestOneShot();
}
if (this.overlaySwitch == OverlaySwitch.Hold)
{
HoldHideOverlay();
Expand Down
8 changes: 7 additions & 1 deletion WindowTranslator/Modules/Settings/AllSettingsViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,7 @@ public async Task SaveAsync(object window)
},
PluginParams = t.Params.ToDictionary(p => p.GetType().Name),
DisplayBusy = t.DisplayBusy,
IsOneShotMode = t.IsOneShotMode,
OverlayOpacity = t.OverlayOpacity,
MousePointerHitTestPadding = t.MousePointerHitTestPadding,
}),
Expand Down Expand Up @@ -467,10 +468,15 @@ public partial class TargetSettingsViewModel(
[ObservableProperty]
private bool displayBusy = settings.DisplayBusy;

[property: Category("SettingsViewModel|Misc")]
[property: SortIndex(9)]
[ObservableProperty]
private bool isOneShotMode = settings.IsOneShotMode;

[property: Category("SettingsViewModel|Misc")]
[property: LocalizedDescription(typeof(Resources), $"{nameof(MousePointerHitTestPadding)}_Desc")]
[property: Slidable(0, 100, 1, 10, true, 1)]
[property: SortIndex(9)]
[property: SortIndex(10)]
[ObservableProperty]
private double mousePointerHitTestPadding = settings.MousePointerHitTestPadding;

Expand Down
5 changes: 5 additions & 0 deletions WindowTranslator/Properties/Resources.Designer.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions WindowTranslator/Properties/Resources.en.resx
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,9 @@
<data name="DisplayBusy" xml:space="preserve">
<value>Show busy icon</value>
</data>
<data name="IsOneShotMode" xml:space="preserve">
<value>Run OCR and translation only when the hotkey is pressed</value>
</data>
<data name="IsOverlayPointSwap" xml:space="preserve">
<value>Display overlay translation only for text at mouse pointer position</value>
</data>
Expand Down
3 changes: 3 additions & 0 deletions WindowTranslator/Properties/Resources.resx
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,9 @@
<data name="DisplayBusy" xml:space="preserve">
<value>処理中アイコンを表示する</value>
</data>
<data name="IsOneShotMode" xml:space="preserve">
<value>ホットキーを押したときだけOCR・翻訳する</value>
</data>
<data name="IsOverlayPointSwap" xml:space="preserve">
<value>マウスポインター位置のテキストのみオーバレイ翻訳を表示する</value>
</data>
Expand Down
Loading