Skip to content
Open
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
10 changes: 10 additions & 0 deletions WindowTranslator.Abstractions/Properties/Resources.Designer.cs

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

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 @@ -1827,10 +1827,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
4 changes: 3 additions & 1 deletion WindowTranslator/Modules/Main/CaptureMainWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,12 @@
<ctl:WindowCaptureCompositionHost.Adornment>
<Viewbox>
<control:OverlayTextsControl
x:Name="overlay"
FontFamily="{Binding Font, Mode=OneWay}"
RectHeight="{Binding Height}"
RectWidth="{Binding Width}"
Texts="{Binding OcrTexts}" />
Texts="{Binding OcrTexts}"
Visibility="{Binding OverlayVisible, Mode=OneWayToSource, Converter={StaticResource b2vConv}}" />
</Viewbox>
</ctl:WindowCaptureCompositionHost.Adornment>
</ctl:WindowCaptureCompositionHost>
Expand Down
52 changes: 51 additions & 1 deletion WindowTranslator/Modules/Main/CaptureMainWindow.xaml.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Interop;
using System.Windows.Threading;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.Extensions.Options;
using Windows.Win32.Foundation;
using Windows.Win32.UI.Input.KeyboardAndMouse;
using Windows.Win32.UI.WindowsAndMessaging;
using WindowTranslator.Extensions;
using WindowTranslator.Stores;
using static Windows.Win32.PInvoke;

Expand All @@ -14,20 +18,37 @@ namespace WindowTranslator.Modules.Main;
/// </summary>
public partial class CaptureMainWindow
{
private readonly OverlaySwitch overlaySwitch;
private readonly IProcessInfoStore processInfo;
private readonly DispatcherTimer timer = new();
private readonly HOT_KEY_MODIFIERS shortcutModifiers;
private readonly int shortcutKey;
private IntPtr windowHandle;
private int overlayHiddenCount;

public CaptureMainWindow(IProcessInfoStore processInfo)
public CaptureMainWindow(
IOptionsSnapshot<CommonSettings> settings,
IOptionsSnapshot<TargetSettings> targetSettings,
IProcessInfoStore processInfo)
{
InitializeComponent();
this.overlaySwitch = settings.Value.OverlaySwitch;
if (targetSettings.Value.IsOneShotMode)
{
this.overlay.SetCurrentValue(VisibilityProperty, Visibility.Hidden);
}
this.processInfo = processInfo;
this.timer.Interval = TimeSpan.FromMilliseconds(10);
this.timer.Tick += (s, e) => CheckTargetWindow();
(this.shortcutModifiers, this.shortcutKey) = targetSettings.Value.OverlayShortcut.ToHotKey();
}

private void Window_Loaded(object sender, RoutedEventArgs e)
{
this.windowHandle = new WindowInteropHelper(this).Handle;
this.timer.Start();
RegisterHotKey(new(this.windowHandle), 0, this.shortcutModifiers, (uint)this.shortcutKey);
HwndSource.FromHwnd(this.windowHandle).AddHook(WndProc);
StrongReferenceMessenger.Default.Register<CaptureMainWindow, CloseMessage>(this, CloseIfViewModel);
}

Expand All @@ -45,9 +66,38 @@ protected override void OnClosed(EventArgs e)
{
base.OnClosed(e);
this.timer.Stop();
UnregisterHotKey(new(this.windowHandle), 0);
StrongReferenceMessenger.Default.Unregister<CloseMessage>(this);
}

private nint WndProc(nint hwnd, int msg, nint wParam, nint lParam, ref bool handled)
{
if (msg != WM_HOTKEY)
{
return 0;
}
if (this.overlaySwitch == OverlaySwitch.Hold)
{
HoldHideOverlay();
}
else
{
this.overlay.SetCurrentValue(VisibilityProperty, this.overlay.Visibility == Visibility.Visible ? Visibility.Hidden : Visibility.Visible);
}
return 0;
}

private async void HoldHideOverlay()
{
var current = Interlocked.Increment(ref this.overlayHiddenCount);
this.overlay.SetCurrentValue(VisibilityProperty, Visibility.Hidden);
await Task.Delay(500);
if (Interlocked.CompareExchange(ref this.overlayHiddenCount, 0, current) == current)
{
this.overlay.SetCurrentValue(VisibilityProperty, Visibility.Visible);
}
}

private static void CloseIfViewModel(CaptureMainWindow w, CloseMessage m)
{
if (w.DataContext == m.ViewModel)
Expand Down
112 changes: 84 additions & 28 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 List<PriorityRect> priorityRects;
private readonly IOcrTextTracker ocrTextTracker;
Expand All @@ -40,6 +40,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 @@ -60,6 +61,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 Down Expand Up @@ -87,6 +89,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 @@ -98,8 +101,11 @@ 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})";
}
Expand All @@ -109,7 +115,14 @@ partial void OnOverlayVisibleChanged(bool value)
if (value)
{
this.OcrTexts.Clear();
this.ocrTextTracker.Reset();
if (this.isOneShotMode)
{
this.isFirstCapture = true;
}
else
{
this.ocrTextTracker.Reset();
}
// Start capture when overlay becomes visible
this.capture.StartCapture(this.processInfoStore.MainWindowHandle);
}
Expand All @@ -123,6 +136,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 Down Expand Up @@ -155,7 +178,8 @@ private async Task CreateTextOverlayAsync()
{
if (this.analyzingBmp is { } previousBmp)
{
if (previousBmp.PixelWidth != sbmp.PixelWidth || previousBmp.PixelHeight != sbmp.PixelHeight)
if (!this.isOneShotMode
&& (previousBmp.PixelWidth != sbmp.PixelWidth || previousBmp.PixelHeight != sbmp.PixelHeight))
{
this.ocrTextTracker.Reset();
}
Expand Down Expand Up @@ -202,26 +226,28 @@ private async Task CreateTextOverlayAsync()
}
}

texts = await this.ocr.RecognizeAsync(new(sbmp, regions));
texts = this.ocrTextTracker.Update(texts, new(sbmp.PixelWidth, sbmp.PixelHeight));
var observations = await this.ocr.RecognizeAsync(new(sbmp, regions));
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 @@ -233,11 +259,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 @@ -251,26 +278,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
{
{ TranslatedText: null } when this.cache.Contains(t.SourceText) => t with { TranslatedText = this.cache.Get(t.SourceText) },
_ => t,
}).ToArray();
if (!this.isOneShotMode)
{
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);
}

// 背景色に不透明度を設定
texts = texts.Select(t => t with { Background = Color.FromArgb((int)(255 * this.overlayOpacity), t.Background) }).ToArray();
UpdateOcrTexts(displayedTexts);
if (!this.isOneShotMode)
{
return;
}

await TranslateAsync(texts);

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 @@ -319,7 +367,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 @@ -332,6 +380,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 @@ -72,6 +72,10 @@ public OverlayMainWindow(
{
InitializeComponent();
this.overlaySwitch = settings.Value.OverlaySwitch;
if (targetSettings.Value.IsOneShotMode)
{
this.overlay.SetCurrentValue(VisibilityProperty, Visibility.Hidden);
}
this.isEnableCapture = settings.Value.IsEnableCaptureOverlay;
this.IsSwapVisibility = settings.Value.IsOverlayPointSwap;
this.processInfo = processInfo;
Expand Down
Loading
Loading