Переводчик экрана: наложение перевода поверх текста на экране
build / build (push) Failing after 10s
build / build (push) Failing after 10s
Программа накладывает русский перевод прямо на английские надписи — как переводчик по фото, только для монитора. Работает с играми, интерфейсами и документами: распознаёт кадр, собирает текст в связные блоки, переводит и рисует плашки под цвет фона. Распознавание — встроенный Windows.Media.Ocr, офлайн. Перевод — цепочка движков с автопереключением (google, lingva, mymemory), потому что до разных сервисов из разных сетей достучаться получается по-разному. Собирается без .NET SDK: Roslyn из Build Tools плюс сборки Framework 4.8, которые уже есть в системе. На выходе один exe без установщика. Главная сложность оказалась не в переводе, а в том, чтобы понять, где на экране связный текст, а где отдельные надписи. Классические алгоритмы сегментации решают это по межстрочному зазору, но в плотном интерфейсе он одинаков у строк абзаца и у соседних контролов. Поэтому решение принимает ансамбль признаков: фон, граница между строками, заполненность строки, кегль. Надписи на чужом языке отсеиваются тремя уровнями — форма слова, вторая модель распознавания и частоты буквенных пар. Чтобы правки эвристик не оценивались на глаз, в tools лежат эталонные кадры и RenderTest: прогон конвейера без окна с подсчётом попаданий. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,268 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Imaging;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace ScreenTranslator
|
||||
{
|
||||
/// <summary>Прогон всего конвейера без окна: захват -> OCR -> блоки -> перевод -> отрисовка в PNG.
|
||||
/// Нужен, чтобы проверять качество распознавания и вёрстки плашек, не гоняя оверлей поверх
|
||||
/// рабочего стола и не кликая по нему.
|
||||
///
|
||||
/// RenderTest.exe [--in кадр.png] [--out каталог] [--scale 2.0] [--no-translate]
|
||||
/// </summary>
|
||||
static class RenderTest
|
||||
{
|
||||
[System.Runtime.InteropServices.DllImport("kernel32.dll")]
|
||||
static extern int GetConsoleProcessList(int[] buffer, int count);
|
||||
|
||||
/// <summary>Запущено двойным кликом (своя консоль), а не из терминала — тогда после
|
||||
/// работы надо дождаться пользователя, иначе окно захлопнется вместе с выводом.</summary>
|
||||
static bool OwnsConsole()
|
||||
{
|
||||
try { return GetConsoleProcessList(new int[4], 4) <= 1; }
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
[STAThread]
|
||||
static int Main(string[] args)
|
||||
{
|
||||
Console.OutputEncoding = System.Text.Encoding.UTF8;
|
||||
bool pause = OwnsConsole();
|
||||
try { return Run(args); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("\nОшибка: " + ex.Message);
|
||||
return 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (pause)
|
||||
{
|
||||
Console.WriteLine("\nНажмите Enter, чтобы закрыть окно…");
|
||||
Console.ReadLine();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Проверка биграммной модели на реальных примерах из прошлых прогонов:
|
||||
/// слева то, что должно проходить, справа — то, что модель обязана отбраковывать.
|
||||
/// Порог берётся из этих чисел, а не из общих соображений.</summary>
|
||||
static int SelfTest()
|
||||
{
|
||||
string[] good = {
|
||||
"Profile", "Cancel", "Settings", "Search", "Mining", "Tailoring", "Engineering",
|
||||
"Leatherworking", "Consumable", "Trade Goods", "Not equippable", "All Subclasses",
|
||||
"Have Materials", "Network configuration", "Wireless adapter", "Save changes",
|
||||
"Connection established", "Restore defaults", "First Aid", "Storage almost full"
|
||||
};
|
||||
string[] bad = {
|
||||
"npocbwlb", "C03AaTb", "Pa3Mep", "npocM0TpeTb", "COPTMPOBaTb", "Bxoaguvqe",
|
||||
"KOMnb}0Tep", "Meccetqxep", "KonnpoeaTb", "nepenaeH0BaTb", "Bblpe3aTb",
|
||||
"3TOT KOMnb0Tep", "6b1CTPb1i AOCTyn", "npoeKTb1 Crpeneu", "Pa60ti cron"
|
||||
};
|
||||
|
||||
Console.WriteLine("--- должно проходить ---");
|
||||
double gMin = double.MaxValue;
|
||||
foreach (var s in good)
|
||||
{
|
||||
double v = EnglishBigrams.TextScore(s);
|
||||
Console.WriteLine(" {0,8:F3} {1}", v, s);
|
||||
if (!double.IsNaN(v) && v < gMin) gMin = v;
|
||||
}
|
||||
Console.WriteLine("--- должно отбраковываться ---");
|
||||
double bMax = double.MinValue;
|
||||
foreach (var s in bad)
|
||||
{
|
||||
double v = EnglishBigrams.TextScore(s);
|
||||
Console.WriteLine(" {0,8:F3} {1}", v, s);
|
||||
if (!double.IsNaN(v) && v > bMax) bMax = v;
|
||||
}
|
||||
Console.WriteLine("\nхудший «хороший»: {0:F3} лучший «плохой»: {1:F3} зазор: {2:F3}",
|
||||
gMin, bMax, gMin - bMax);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int Run(string[] args)
|
||||
{
|
||||
if (args.Contains("--selftest")) return SelfTest();
|
||||
|
||||
string inPath = Arg(args, "--in", null);
|
||||
string outDir = Arg(args, "--out", Path.Combine(Path.GetTempPath(), "screen-translator-test"));
|
||||
double scale = double.Parse(Arg(args, "--scale", "2.0"), System.Globalization.CultureInfo.InvariantCulture);
|
||||
bool translate = !args.Contains("--no-translate");
|
||||
Directory.CreateDirectory(outDir);
|
||||
|
||||
string err;
|
||||
var cfg = AppConfig.Load(out err);
|
||||
cfg.OcrScale = scale;
|
||||
if (err != null) Console.WriteLine("config: " + err);
|
||||
|
||||
var ocr = OcrService.Create(cfg.OcrLanguage);
|
||||
if (ocr == null) { Console.WriteLine("OCR недоступен"); return 2; }
|
||||
ocr.Grayscale = args.Contains("--gray");
|
||||
ocr.Invert = Arg(args, "--invert", "never");
|
||||
Console.WriteLine("OCR: {0} масштаб: {1} серый: {2} инверсия: {3}",
|
||||
ocr.LanguageTag, scale, ocr.Grayscale ? "да" : "нет", ocr.Invert);
|
||||
|
||||
CapturedFrame frame = inPath != null ? FromFile(inPath) : ScreenCapture.Capture(Native.MonitorUnderCursor());
|
||||
Console.WriteLine("кадр: {0}x{1}", frame.Width, frame.Height);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var lines = ocr.RecognizeAsync(frame, cfg.OcrScale).GetAwaiter().GetResult();
|
||||
long tOcr = sw.ElapsedMilliseconds;
|
||||
|
||||
var sampler = new FrameSampler(frame);
|
||||
int minWords = int.Parse(Arg(args, "--min-words", cfg.MinWords.ToString()));
|
||||
var blocks = Blocks.Group(lines, cfg.MinChars, cfg.SkipGarbage, sampler, minWords);
|
||||
Console.WriteLine("OCR {0} мс: строк {1} -> блоков {2}", tOcr, lines.Count, blocks.Count);
|
||||
|
||||
if (cfg.DetectForeign)
|
||||
{
|
||||
var other = OcrService.CreateOther(ocr.LanguageTag);
|
||||
if (other == null) Console.WriteLine("второй модели OCR нет — проверка языка пропущена");
|
||||
else
|
||||
{
|
||||
sw.Restart();
|
||||
var otherLines = other.RecognizeAsync(frame, cfg.OcrScale).GetAwaiter().GetResult();
|
||||
var before = new List<TextBlock>(blocks);
|
||||
Blocks.DropForeign(blocks, otherLines, Blocks.IsCyrillicLanguage(cfg.SourceLang));
|
||||
Console.WriteLine("проверка моделью «{0}» {1} мс: отброшено {2}, осталось {3}",
|
||||
other.LanguageTag, sw.ElapsedMilliseconds, before.Count - blocks.Count, blocks.Count);
|
||||
foreach (var dropped in before.Where(b => !blocks.Contains(b)))
|
||||
Console.WriteLine(" отброшено: " + Cut(dropped.Source, 70));
|
||||
}
|
||||
}
|
||||
|
||||
Blocks.ComputeGrowLimits(blocks, lines, new Size(frame.Width, frame.Height));
|
||||
|
||||
foreach (var b in blocks)
|
||||
{
|
||||
Color bg, fg;
|
||||
sampler.Sample(b.Rect, out bg, out fg);
|
||||
b.Bg = bg; b.Fg = fg;
|
||||
}
|
||||
|
||||
long tTr = 0;
|
||||
if (translate && blocks.Count > 0)
|
||||
{
|
||||
var cache = cfg.Cache ? new TranslationCache(cfg) : null;
|
||||
var chain = new TranslationChain(cfg, cache);
|
||||
sw.Restart();
|
||||
var res = chain.TranslateAsync(blocks.Select(b => b.Source).ToArray(), CancellationToken.None)
|
||||
.GetAwaiter().GetResult();
|
||||
tTr = sw.ElapsedMilliseconds;
|
||||
for (int i = 0; i < blocks.Count; i++)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(res[i])) { blocks[i].Failed = true; continue; }
|
||||
blocks[i].Translated = res[i];
|
||||
}
|
||||
if (cache != null) cache.Save();
|
||||
Console.WriteLine("перевод {0} мс движком «{1}»{2}", tTr, chain.LastUsed ?? "—",
|
||||
chain.LastError != null ? " (ошибки: " + chain.LastError + ")" : "");
|
||||
}
|
||||
else foreach (var b in blocks) b.Translated = b.Source;
|
||||
|
||||
// 1) как видит OCR
|
||||
using (var dbg = Clone(frame))
|
||||
using (var g = Graphics.FromImage(dbg))
|
||||
{
|
||||
foreach (var b in blocks)
|
||||
using (var p = new Pen(Color.FromArgb(200, 255, 60, 60)))
|
||||
g.DrawRectangle(p, b.Rect);
|
||||
Save(dbg, Path.Combine(outDir, "1-blocks.png"));
|
||||
}
|
||||
|
||||
// 2) итог: перевод поверх оригинала
|
||||
using (var res = Clone(frame))
|
||||
using (var g = Graphics.FromImage(res))
|
||||
{
|
||||
g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias;
|
||||
var fonts = new Dictionary<TextBlock, Font>();
|
||||
var layout = new Dictionary<TextBlock, Rectangle>();
|
||||
foreach (var b in blocks)
|
||||
{
|
||||
if (b.Translated == null) continue;
|
||||
PlateRenderer.Draw(g, b, cfg, new Size(frame.Width, frame.Height), fonts, layout);
|
||||
}
|
||||
foreach (var f in fonts.Values) f.Dispose();
|
||||
Save(res, Path.Combine(outDir, "2-translated.png"));
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
foreach (var b in blocks.OrderBy(b => b.Rect.Top).Take(15))
|
||||
Console.WriteLine(" [{0,4},{1,4} {2,3}x{3,-3} h={4:0.0}] {5}\n -> {6}",
|
||||
b.Rect.X, b.Rect.Y, b.Rect.Width, b.Rect.Height, b.LineHeight,
|
||||
Cut(b.Source, 70), Cut(b.Translated ?? "—", 70));
|
||||
|
||||
// Замер качества распознавания: сколько заранее известных строк реально нашлось.
|
||||
// Без такой метрики любые правки препроцессинга оцениваются на глаз, а на глаз
|
||||
// одинаково убедительно выглядят и улучшение, и ухудшение.
|
||||
string expect = Arg(args, "--expect", null);
|
||||
if (expect != null)
|
||||
{
|
||||
var want = File.ReadAllLines(expect)
|
||||
.Select(l => l.Trim()).Where(l => l.Length > 0).ToList();
|
||||
var all = string.Join(" | ", blocks.Select(b => b.Source));
|
||||
int hit = 0;
|
||||
var missed = new List<string>();
|
||||
foreach (var w in want)
|
||||
{
|
||||
if (all.IndexOf(w, StringComparison.OrdinalIgnoreCase) >= 0) hit++;
|
||||
else missed.Add(w);
|
||||
}
|
||||
Console.WriteLine("\nРАСПОЗНАНО: {0} из {1} ({2:P0})", hit, want.Count, (double)hit / want.Count);
|
||||
if (missed.Count > 0) Console.WriteLine("не найдено: " + string.Join(", ", missed));
|
||||
}
|
||||
|
||||
Console.WriteLine("\nкартинки: " + outDir);
|
||||
frame.Image.Dispose();
|
||||
return 0;
|
||||
}
|
||||
|
||||
static string Cut(string s, int n) { return s.Length <= n ? s : s.Substring(0, n - 1) + "…"; }
|
||||
|
||||
static string Arg(string[] a, string name, string def)
|
||||
{
|
||||
int i = Array.IndexOf(a, name);
|
||||
return i >= 0 && i + 1 < a.Length ? a[i + 1] : def;
|
||||
}
|
||||
|
||||
static Bitmap Clone(CapturedFrame f)
|
||||
{
|
||||
var b = new Bitmap(f.Width, f.Height, PixelFormat.Format32bppArgb);
|
||||
using (var g = Graphics.FromImage(b)) g.DrawImageUnscaled(f.Image, 0, 0);
|
||||
return b;
|
||||
}
|
||||
|
||||
static CapturedFrame FromFile(string path)
|
||||
{
|
||||
using (var src = new Bitmap(path))
|
||||
{
|
||||
var bmp = new Bitmap(src.Width, src.Height, PixelFormat.Format32bppArgb);
|
||||
using (var g = Graphics.FromImage(bmp)) g.DrawImageUnscaled(src, 0, 0);
|
||||
var tmp = new CapturedFrame
|
||||
{
|
||||
Image = bmp,
|
||||
Width = bmp.Width,
|
||||
Height = bmp.Height,
|
||||
Area = new Rectangle(0, 0, bmp.Width, bmp.Height)
|
||||
};
|
||||
// тот же путь извлечения пикселей, что и у экранного захвата
|
||||
tmp.Pixels = ScreenCapture.Crop(tmp, new Rectangle(0, 0, bmp.Width, bmp.Height)).Pixels;
|
||||
return tmp;
|
||||
}
|
||||
}
|
||||
|
||||
static void Save(Bitmap b, string path)
|
||||
{
|
||||
b.Save(path, ImageFormat.Png);
|
||||
Console.WriteLine(" сохранено: " + path);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user