diff --git a/src/Translumo.OCR/WindowsOCR/PositionalOcrLine.cs b/src/Translumo.OCR/WindowsOCR/PositionalOcrLine.cs new file mode 100644 index 00000000..c32cda66 --- /dev/null +++ b/src/Translumo.OCR/WindowsOCR/PositionalOcrLine.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; +using System.Drawing; + +namespace Translumo.OCR.WindowsOCR +{ + /// + /// A single recognized text line together with its pixel bounding box inside the source image. + /// + public sealed class PositionalOcrLine + { + public string Text { get; init; } + + /// Bounding box in source-image pixels. + public RectangleF Box { get; init; } + } + + /// + /// Result of recognizing a captured region: the chosen recognizer language, the recognized + /// lines with positions, and the source image size (pixels). + /// + public sealed class OcrRegionResult + { + public string LanguageTag { get; init; } + + public IReadOnlyList Lines { get; init; } + + public int ImageWidth { get; init; } + + public int ImageHeight { get; init; } + } +} diff --git a/src/Translumo.OCR/WindowsOCR/WindowsOcrPositional.cs b/src/Translumo.OCR/WindowsOCR/WindowsOcrPositional.cs new file mode 100644 index 00000000..fdc0cce0 --- /dev/null +++ b/src/Translumo.OCR/WindowsOCR/WindowsOcrPositional.cs @@ -0,0 +1,241 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Windows.Globalization; +using Windows.Graphics.Imaging; +using Windows.Media.Ocr; +using Windows.Storage.Streams; + +namespace Translumo.OCR.WindowsOCR +{ + /// + /// Positional Windows OCR used by the instant image-translation feature. + /// Unlike (plain text), this keeps per-line bounding boxes and + /// can auto-detect the source language by trying the installed recognizer packs and scoring the + /// result by how many characters match each language's writing system (the UWP OCR API does not + /// expose per-word confidence, so a script-match heuristic is used instead). + /// + public static class WindowsOcrPositional + { + /// Installed Windows OCR recognizers as (BCP-47 tag, human-readable name). + public static IReadOnlyList<(string Tag, string DisplayName)> GetInstalledRecognizers() + { + return OcrEngine.AvailableRecognizerLanguages + .Select(l => (l.LanguageTag, l.DisplayName)) + .ToArray(); + } + + /// + /// Recognizes (encoded bytes). When + /// is null the source language is auto-detected across installed recognizer packs. + /// Returns null when no OCR recognizer is available at all. + /// + public static async Task DetectAndRecognizeAsync(byte[] image, string forcedLanguageTag = null) + { + var (softwareBitmap, width, height) = await DecodeAsync(image).ConfigureAwait(false); + + IReadOnlyList candidates = forcedLanguageTag != null + ? new[] { forcedLanguageTag } + : GetCandidateLanguageTags(); + + if (candidates.Count == 0) + { + return null; + } + + OcrRegionResult best = null; + var bestScore = -1; + foreach (var tag in candidates) + { + var engine = OcrEngine.TryCreateFromLanguage(new Language(tag)); + if (engine == null) + { + continue; + } + + OcrResult ocr = await engine.RecognizeAsync(softwareBitmap).AsTask().ConfigureAwait(false); + var lines = ExtractLines(ocr); + var score = ScoreForLanguage(ocr.Text, tag); + + if (best == null || score > bestScore) + { + bestScore = score; + best = new OcrRegionResult + { + LanguageTag = tag, + Lines = lines, + ImageWidth = width, + ImageHeight = height + }; + } + } + + return best; + } + + private static async Task<(SoftwareBitmap bitmap, int width, int height)> DecodeAsync(byte[] image) + { + using var memory = new MemoryStream(image); + var decoder = await BitmapDecoder.CreateAsync(memory.AsRandomAccessStream()).AsTask().ConfigureAwait(false); + var bitmap = await decoder.GetSoftwareBitmapAsync().AsTask().ConfigureAwait(false); + + return (bitmap, (int)decoder.PixelWidth, (int)decoder.PixelHeight); + } + + private static List ExtractLines(OcrResult ocr) + { + var result = new List(); + foreach (var line in ocr.Lines) + { + if (line.Words.Count == 0) + { + continue; + } + + double left = double.MaxValue, top = double.MaxValue, right = 0, bottom = 0; + foreach (var word in line.Words) + { + var r = word.BoundingRect; + left = Math.Min(left, r.X); + top = Math.Min(top, r.Y); + right = Math.Max(right, r.X + r.Width); + bottom = Math.Max(bottom, r.Y + r.Height); + } + + var text = line.Text?.Trim(); + if (string.IsNullOrEmpty(text)) + { + continue; + } + + result.Add(new PositionalOcrLine + { + Text = text, + Box = new System.Drawing.RectangleF( + (float)left, (float)top, (float)(right - left), (float)(bottom - top)) + }); + } + + return result; + } + + /// + /// One representative installed recognizer per writing system. CJK scripts (zh/ja/ko) are kept + /// separate because they need different packs; the many Latin/Cyrillic packs collapse to one + /// representative each to keep the number of OCR passes small. + /// + private static IReadOnlyList GetCandidateLanguageTags() + { + var byFamily = new Dictionary(); + foreach (var lang in OcrEngine.AvailableRecognizerLanguages) + { + var tag = lang.LanguageTag; + var family = ScriptFamily(tag); + // Keep the canonical representative when available, otherwise the first seen. + if (!byFamily.ContainsKey(family) || IsCanonicalRepresentative(tag, family)) + { + byFamily[family] = tag; + } + } + + return byFamily.Values.ToArray(); + } + + private static bool IsCanonicalRepresentative(string tag, string family) + { + var shortTag = ShortTag(tag); + return family switch + { + "latin" => shortTag == "en", + "cyrillic" => shortTag == "ru", + "arabic" => shortTag == "ar", + "greek" => shortTag == "el", + _ => false + }; + } + + private static string ScriptFamily(string tag) + { + var s = ShortTag(tag); + switch (s) + { + case "zh": + return "zh"; + case "ja": + return "ja"; + case "ko": + return "ko"; + case "ru": + case "uk": + case "be": + case "bg": + case "sr": + case "mk": + return "cyrillic"; + case "ar": + case "fa": + case "ur": + return "arabic"; + case "el": + return "greek"; + default: + return "latin"; + } + } + + private static string ShortTag(string tag) => tag.Split('-')[0].ToLowerInvariant(); + + /// Number of characters in the recognized text that belong to the language's script. + private static int ScoreForLanguage(string text, string tag) + { + if (string.IsNullOrEmpty(text)) + { + return 0; + } + + var family = ScriptFamily(tag); + var score = 0; + foreach (var c in text) + { + if (MatchesScript(c, family)) + { + score++; + } + } + + return score; + } + + private static bool MatchesScript(char c, string family) + { + switch (family) + { + case "zh": + return IsCjk(c); + case "ja": + return IsCjk(c) || IsKana(c); + case "ko": + return IsHangul(c); + case "cyrillic": + return c >= 0x0400 && c <= 0x04FF; + case "arabic": + return (c >= 0x0600 && c <= 0x06FF) || (c >= 0x0750 && c <= 0x077F) || + (c >= 0xFB50 && c <= 0xFDFF) || (c >= 0xFE70 && c <= 0xFEFF); + case "greek": + return c >= 0x0370 && c <= 0x03FF; + default: + return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || + (c >= 0x00C0 && c <= 0x024F); + } + } + + private static bool IsCjk(char c) => (c >= 0x4E00 && c <= 0x9FFF) || (c >= 0x3400 && c <= 0x4DBF); + + private static bool IsKana(char c) => c >= 0x3040 && c <= 0x30FF; + + private static bool IsHangul(char c) => + (c >= 0xAC00 && c <= 0xD7A3) || (c >= 0x1100 && c <= 0x11FF) || (c >= 0x3130 && c <= 0x318F); + } +} diff --git a/src/Translumo.Processing/ImageTranslation/ImageTranslationResult.cs b/src/Translumo.Processing/ImageTranslation/ImageTranslationResult.cs new file mode 100644 index 00000000..43718a27 --- /dev/null +++ b/src/Translumo.Processing/ImageTranslation/ImageTranslationResult.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; +using System.Drawing; + +namespace Translumo.Processing.ImageTranslation +{ + /// One OCR line with its translation and pixel bounding box (image-relative). + public sealed class TranslatedLine + { + public string Source { get; init; } + + public string Translation { get; init; } + + public RectangleF Box { get; init; } + } + + /// Full result of translating a captured region (Google Lens style). + public sealed class ImageTranslationResult + { + public IReadOnlyList Lines { get; init; } = new List(); + + /// BCP-47 tag of the recognizer used (e.g. "en-US"); null when nothing was detected. + public string DetectedLanguageTag { get; init; } + + public int ImageWidth { get; init; } + + public int ImageHeight { get; init; } + + public bool HasText => Lines.Count > 0; + } +} diff --git a/src/Translumo.Processing/ImageTranslation/ImageTranslationService.cs b/src/Translumo.Processing/ImageTranslation/ImageTranslationService.cs new file mode 100644 index 00000000..0be41e31 --- /dev/null +++ b/src/Translumo.Processing/ImageTranslation/ImageTranslationService.cs @@ -0,0 +1,102 @@ +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Translumo.Infrastructure.Language; +using Translumo.OCR.WindowsOCR; +using Translumo.Translation.Google; + +namespace Translumo.Processing.ImageTranslation +{ + /// + /// Orchestrates the instant image-translation ("Google Lens") flow: positional OCR of a captured + /// region (with source-language auto-detect) + per-line translation with source auto-detect. + /// The WPF layer captures the region bytes and renders the returned lines over their boxes. + /// + public sealed class ImageTranslationService + { + private const int MAX_CONCURRENT_TRANSLATIONS = 3; + + private readonly LanguageService _languageService; + private readonly ILogger _logger; + private readonly AutoSourceGoogleTranslator _translator = new AutoSourceGoogleTranslator(); + + public ImageTranslationService(LanguageService languageService, ILogger logger) + { + _languageService = languageService; + _logger = logger; + } + + /// Installed Windows OCR source languages as (BCP-47 tag, display name), for the override dropdown. + public IReadOnlyList<(string Tag, string DisplayName)> GetAvailableSourceLanguages() + { + return WindowsOcrPositional.GetInstalledRecognizers(); + } + + /// Encoded screenshot bytes of the selected region. + /// BCP-47 recognizer tag to force, or null to auto-detect. + /// Target translation language. + public async Task TranslateRegionAsync(byte[] regionImage, string forcedSourceTag, Languages target) + { + var ocr = await WindowsOcrPositional.DetectAndRecognizeAsync(regionImage, forcedSourceTag).ConfigureAwait(false); + if (ocr == null || ocr.Lines.Count == 0) + { + return new ImageTranslationResult + { + DetectedLanguageTag = ocr?.LanguageTag, + ImageWidth = ocr?.ImageWidth ?? 0, + ImageHeight = ocr?.ImageHeight ?? 0 + }; + } + + var targetIso = _languageService.GetLanguageDescriptor(target).IsoCode; + var translations = await TranslateLinesAsync(ocr.Lines.Select(l => l.Text), targetIso).ConfigureAwait(false); + + var lines = ocr.Lines + .Select(l => new TranslatedLine + { + Source = l.Text, + Translation = translations.TryGetValue(l.Text, out var tr) ? tr : l.Text, + Box = l.Box + }) + .ToList(); + + return new ImageTranslationResult + { + Lines = lines, + DetectedLanguageTag = ocr.LanguageTag, + ImageWidth = ocr.ImageWidth, + ImageHeight = ocr.ImageHeight + }; + } + + private async Task> TranslateLinesAsync(IEnumerable sources, string targetIso) + { + var distinct = sources.Distinct().ToList(); + var map = new ConcurrentDictionary(); + using var throttle = new SemaphoreSlim(MAX_CONCURRENT_TRANSLATIONS); + + await Task.WhenAll(distinct.Select(async src => + { + await throttle.WaitAsync().ConfigureAwait(false); + try + { + map[src] = await _translator.TranslateAsync(src, targetIso).ConfigureAwait(false); + } + catch (System.Exception ex) + { + _logger.LogWarning(ex, "Image line translation failed; keeping source text"); + map[src] = src; + } + finally + { + throttle.Release(); + } + })).ConfigureAwait(false); + + return map; + } + } +} diff --git a/src/Translumo.Translation/Google/AutoSourceGoogleTranslator.cs b/src/Translumo.Translation/Google/AutoSourceGoogleTranslator.cs new file mode 100644 index 00000000..a2a8af28 --- /dev/null +++ b/src/Translumo.Translation/Google/AutoSourceGoogleTranslator.cs @@ -0,0 +1,44 @@ +using System.Net; +using System.Threading.Tasks; +using System.Web; +using Translumo.Infrastructure.Constants; +using Translumo.Translation.Exceptions; +using Translumo.Utils.Http; + +namespace Translumo.Translation.Google +{ + /// + /// Lightweight Google translator used by the instant image-translation feature. It always sends + /// sl=auto so Google detects the source language server-side, and lets the target language + /// be chosen per request. It reuses (proxy-aware reader) and + /// without touching the config-bound + /// used by continuous translation. + /// + public sealed class AutoSourceGoogleTranslator + { + private const string TRANSLATE_URL = "https://translate.google.com/m?hl={0}&sl=auto&tl={0}&ie=UTF-8&prev=_m&q={1}"; + + private readonly GoogleContainer _container = new GoogleContainer(isPrimary: true); + + public async Task TranslateAsync(string sourceText, string targetIsoCode) + { + if (string.IsNullOrWhiteSpace(sourceText)) + { + return sourceText; + } + + var url = string.Format(TRANSLATE_URL, targetIsoCode, HttpUtility.UrlEncode(sourceText)); + var response = await _container.Reader.RequestWebDataAsync(url, HttpMethods.GET, true).ConfigureAwait(false); + if (response.IsSuccessful) + { + var match = RegexStorage.GoogleTranslateResultRegex.Match(response.Body); + if (match.Success) + { + return WebUtility.HtmlDecode(match.Value); + } + } + + throw new TranslationException($"Unexpected web response: '{response.Body}'"); + } + } +} diff --git a/src/Translumo/App.xaml.cs b/src/Translumo/App.xaml.cs index c3923966..502ec6b2 100644 --- a/src/Translumo/App.xaml.cs +++ b/src/Translumo/App.xaml.cs @@ -150,6 +150,8 @@ private void ConfigureServices(ServiceCollection services) services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); diff --git a/src/Translumo/HotKeys/HotKeysConfiguration.cs b/src/Translumo/HotKeys/HotKeysConfiguration.cs index 89b6e7dc..c8387b05 100644 --- a/src/Translumo/HotKeys/HotKeysConfiguration.cs +++ b/src/Translumo/HotKeys/HotKeysConfiguration.cs @@ -14,6 +14,7 @@ public class HotKeysConfiguration : BindableBase TranslationStateKey = new HotKeyInfo(Key.OemTilde, KeyModifier.None), ShowSelectionAreaKey = new HotKeyInfo(Key.Y, KeyModifier.Alt), OnceTranslateKey = new HotKeyInfo(Key.F, KeyModifier.Shift), + ImageTranslateKey = new HotKeyInfo(Key.D, KeyModifier.Alt), WindowStyleChangeKey = new HotKeyInfo(Key.T, KeyModifier.Ctrl), ChatVisibilityGamepadKey = new GamepadHotKeyInfo(GamepadKeyCode.None), @@ -22,6 +23,7 @@ public class HotKeysConfiguration : BindableBase TranslationStateGamepadKey = new GamepadHotKeyInfo(GamepadKeyCode.None), ShowSelectionAreaGamepadKey = new GamepadHotKeyInfo(GamepadKeyCode.None), OnceTranslateGamepadKey = new GamepadHotKeyInfo(GamepadKeyCode.None), + ImageTranslateGamepadKey = new GamepadHotKeyInfo(GamepadKeyCode.None), WindowStyleChangeGamepadKey = new GamepadHotKeyInfo(GamepadKeyCode.None) }; @@ -79,6 +81,15 @@ public HotKeyInfo OnceTranslateKey } } + public HotKeyInfo ImageTranslateKey + { + get => _imageTranslateKey; + set + { + SetProperty(ref _imageTranslateKey, value); + } + } + public HotKeyInfo WindowStyleChangeKey { get => _windowStyleChangeKey; @@ -144,6 +155,15 @@ public GamepadHotKeyInfo OnceTranslateGamepadKey } } + public GamepadHotKeyInfo ImageTranslateGamepadKey + { + get => _imageTranslateGamepadKey; + set + { + SetProperty(ref _imageTranslateGamepadKey, value); + } + } + public GamepadHotKeyInfo WindowStyleChangeGamepadKey { get => _windowStyleChangeGamepadKey; @@ -159,6 +179,7 @@ public GamepadHotKeyInfo WindowStyleChangeGamepadKey private HotKeyInfo _settingVisibilityKey; private HotKeyInfo _showSelectionAreaKey; private HotKeyInfo _onceTranslateKey; + private HotKeyInfo _imageTranslateKey = new HotKeyInfo(Key.D, KeyModifier.Alt); private HotKeyInfo _windowStyleChangeKey = new HotKeyInfo(Key.T, KeyModifier.Ctrl); private GamepadHotKeyInfo _chatVisibilityGamepadKey; @@ -167,6 +188,7 @@ public GamepadHotKeyInfo WindowStyleChangeGamepadKey private GamepadHotKeyInfo _settingVisibilityGamepadKey; private GamepadHotKeyInfo _showSelctionAreaGamepadKey; private GamepadHotKeyInfo _onceTranslateGamepadKey; + private GamepadHotKeyInfo _imageTranslateGamepadKey = new GamepadHotKeyInfo(GamepadKeyCode.None); private GamepadHotKeyInfo _windowStyleChangeGamepadKey = new GamepadHotKeyInfo(GamepadKeyCode.None); } } diff --git a/src/Translumo/HotKeys/HotKeysServiceManager.cs b/src/Translumo/HotKeys/HotKeysServiceManager.cs index a3ee1a20..9e7a9b1d 100644 --- a/src/Translumo/HotKeys/HotKeysServiceManager.cs +++ b/src/Translumo/HotKeys/HotKeysServiceManager.cs @@ -16,6 +16,7 @@ public class HotKeysServiceManager public event EventHandler SettingVisibilityKeyPressed; public event EventHandler ShowSelectionAreaKeyPressed; public event EventHandler OnceTranslateKeyPressed; + public event EventHandler ImageTranslateKeyPressed; public event EventHandler WindowStyleChangeKeyPressed; public HotKeysConfiguration Configuration { get; } @@ -41,6 +42,7 @@ public HotKeysServiceManager(HotKeysConfiguration configuration, IControllerInpu (nameof(configuration.SettingVisibilityKey), nameof(configuration.SettingVisibilityGamepadKey)), (nameof(configuration.ShowSelectionAreaKey), nameof(configuration.ShowSelectionAreaGamepadKey)), (nameof(configuration.OnceTranslateKey), nameof(configuration.OnceTranslateGamepadKey)), + (nameof(configuration.ImageTranslateKey), nameof(configuration.ImageTranslateGamepadKey)), (nameof(configuration.WindowStyleChangeKey), nameof(configuration.WindowStyleChangeGamepadKey)), }; @@ -195,6 +197,11 @@ private void OnOnceTranslatePressed() OnceTranslateKeyPressed?.Invoke(this, EventArgs.Empty); } + private void OnImageTranslatePressed() + { + ImageTranslateKeyPressed?.Invoke(this, EventArgs.Empty); + } + private void OnWindowStyleChangePressed() { WindowStyleChangeKeyPressed?.Invoke(this, EventArgs.Empty); @@ -228,6 +235,10 @@ private IDictionary InitializeHotKeys(HotKeysConfiguration confi nameof(configuration.OnceTranslateKey), new HotKey(configuration.OnceTranslateKey.Key, configuration.OnceTranslateKey.KeyModifier, OnOnceTranslatePressed) }, + { + nameof(configuration.ImageTranslateKey), new HotKey(configuration.ImageTranslateKey.Key, + configuration.ImageTranslateKey.KeyModifier, OnImageTranslatePressed) + }, { nameof(configuration.WindowStyleChangeKey), new HotKey(configuration.WindowStyleChangeKey.Key, configuration.WindowStyleChangeKey.KeyModifier, OnWindowStyleChangePressed) @@ -245,6 +256,7 @@ private IDictionary InitializeGamepadHotKeys(HotKeysConfi { nameof(configuration.SettingVisibilityGamepadKey), new GamepadHotKey(configuration.SettingVisibilityGamepadKey.Key, OnSettingVisibilityPressed) }, { nameof(configuration.ShowSelectionAreaGamepadKey), new GamepadHotKey(configuration.ShowSelectionAreaGamepadKey.Key, OnShowSelectionAreaPressed) }, { nameof(configuration.OnceTranslateGamepadKey), new GamepadHotKey(configuration.OnceTranslateGamepadKey.Key, OnOnceTranslatePressed) }, + { nameof(configuration.ImageTranslateGamepadKey), new GamepadHotKey(configuration.ImageTranslateGamepadKey.Key, OnImageTranslatePressed) }, { nameof(configuration.WindowStyleChangeGamepadKey), new GamepadHotKey(configuration.WindowStyleChangeGamepadKey.Key, OnWindowStyleChangePressed) } }; } diff --git a/src/Translumo/ImageTranslationOverlayWindow.xaml b/src/Translumo/ImageTranslationOverlayWindow.xaml new file mode 100644 index 00000000..b22877c3 --- /dev/null +++ b/src/Translumo/ImageTranslationOverlayWindow.xaml @@ -0,0 +1,92 @@ + + + + + + + + + + + +