Files
screen-translator/src/Program.cs
T
gusadmin 7c6b182402
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>
2026-07-27 15:54:02 +03:00

399 lines
17 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Diagnostics;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
namespace ScreenTranslator
{
static class Program
{
[STAThread]
static void Main(string[] args)
{
// Сборщику нужен эталонный config.json рядом с exe, а держать его в репозитории
// нельзя — файл правится пользователем и обрастает личными настройками.
// Ключ создаёт его из вшитого умолчания и сразу выходит, без окна и трея.
foreach (var a in args)
if (string.Equals(a, "--write-config", StringComparison.OrdinalIgnoreCase))
{
string err;
AppConfig.Load(out err); // создаёт файл, если его нет
return;
}
bool created;
using (var mutex = new Mutex(true, "Local\\ScreenTranslator.SingleInstance", out created))
{
if (!created)
{
MessageBox.Show("Переводчик экрана уже запущен — значок в трее.",
"Переводчик экрана", MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Application.Run(new TrayApp());
GC.KeepAlive(mutex);
}
}
}
/// <summary>Приложение живёт в трее и просыпается по горячей клавише.</summary>
internal class TrayApp : ApplicationContext
{
const int HK_SCREEN = 1, HK_REGION = 2;
AppConfig _cfg;
OcrService _ocr;
OcrService _ocrOther; // модель другого языка — чтобы не переводить надписи на нём
TranslationCache _cache;
TranslationChain _chain;
readonly NotifyIcon _tray;
readonly HotkeyWindow _hotkeys;
OverlayForm _overlay;
CancellationTokenSource _cts;
bool _busy;
public TrayApp()
{
_tray = new NotifyIcon
{
Icon = MakeIcon(),
Text = "Переводчик экрана",
Visible = true
};
_tray.DoubleClick += delegate { Start(false); };
_hotkeys = new HotkeyWindow();
_hotkeys.Pressed += OnHotkey;
LoadConfig(true);
BuildMenu();
}
// ---------------- конфигурация ----------------
void LoadConfig(bool firstRun)
{
string err;
_cfg = AppConfig.Load(out err);
if (_cache != null) _cache.Save();
_cache = _cfg.Cache ? new TranslationCache(_cfg) : null;
_chain = new TranslationChain(_cfg, _cache);
_ocr = OcrService.Create(_cfg.OcrLanguage);
_ocrOther = _cfg.DetectForeign && _ocr != null ? OcrService.CreateOther(_ocr.LanguageTag) : null;
if (_ocr == null)
{
Balloon("OCR недоступен",
"В Windows не установлена ни одна модель распознавания. Параметры → Время и язык → " +
"Язык и регион → у нужного языка «Компоненты языка» → Распознавание текста.",
ToolTipIcon.Error);
}
else if (!string.Equals(_ocr.LanguageTag, _cfg.OcrLanguage, StringComparison.OrdinalIgnoreCase))
{
Balloon("Модель OCR заменена",
"Модели «" + _cfg.OcrLanguage + "» нет, используется «" + _ocr.LanguageTag +
"». Доступны: " + string.Join(", ", OcrService.AvailableLanguages()),
ToolTipIcon.Warning);
}
RegisterHotkeys();
if (err != null) Balloon("Настройки", err, ToolTipIcon.Warning);
else if (firstRun)
{
_tray.Text = "Переводчик экрана — " + _cfg.HotkeyTranslate;
Balloon("Переводчик экрана запущен",
_cfg.HotkeyTranslate + " — перевести экран, " + _cfg.HotkeyRegion + " — область.",
ToolTipIcon.Info);
}
}
void RegisterHotkeys()
{
_hotkeys.UnregisterAll();
string bad = null;
if (!_hotkeys.Register(HK_SCREEN, _cfg.HotkeyTranslate)) bad = _cfg.HotkeyTranslate;
if (!_hotkeys.Register(HK_REGION, _cfg.HotkeyRegion))
bad = bad == null ? _cfg.HotkeyRegion : bad + " и " + _cfg.HotkeyRegion;
if (bad != null)
Balloon("Горячая клавиша занята",
"Не удалось назначить " + bad + " — комбинацию уже держит другая программа. " +
"Поменяйте её в config.json.", ToolTipIcon.Warning);
}
void BuildMenu()
{
var m = new ContextMenuStrip();
m.Items.Add("Перевести экран\t" + _cfg.HotkeyTranslate, null, delegate { Start(false); });
m.Items.Add("Перевести область\t" + _cfg.HotkeyRegion, null, delegate { Start(true); });
m.Items.Add(new ToolStripSeparator());
m.Items.Add("Проверить переводчики…", null, delegate { ProbeEngines(); });
m.Items.Add("Открыть config.json", null, delegate
{
try { Process.Start(new ProcessStartInfo(AppConfig.Path_) { UseShellExecute = true }); }
catch (Exception ex) { Balloon("Не открылось", ex.Message, ToolTipIcon.Error); }
});
m.Items.Add("Перечитать настройки", null, delegate
{
LoadConfig(false);
BuildMenu();
Balloon("Настройки перечитаны",
"Движки: " + string.Join(" → ", _chain.EngineNames) + ". OCR: " +
(_ocr == null ? "нет" : _ocr.LanguageTag), ToolTipIcon.Info);
});
m.Items.Add(new ToolStripSeparator());
m.Items.Add("Выход", null, delegate { ExitThread(); });
_tray.ContextMenuStrip = m;
}
void OnHotkey(int id)
{
if (id == HK_SCREEN) Start(false);
else if (id == HK_REGION) Start(true);
}
// ---------------- основной сценарий ----------------
void Start(bool region)
{
// повторное нажатие при открытом оверлее — закрыть, это же и «отмена»
if (_overlay != null && !_overlay.IsDisposed)
{
_overlay.Close();
return;
}
if (_busy) return;
if (_ocr == null)
{
Balloon("OCR недоступен", "Нет модели распознавания текста, перевод невозможен.", ToolTipIcon.Error);
return;
}
RunAsync(region);
}
async void RunAsync(bool region)
{
_busy = true;
var sw = Stopwatch.StartNew();
CapturedFrame frame = null;
try
{
var area = region || string.Equals(_cfg.CaptureScope, "all", StringComparison.OrdinalIgnoreCase)
? Native.VirtualScreen()
: Native.MonitorUnderCursor();
frame = ScreenCapture.Capture(area);
if (region)
{
var sel = RegionSelector.Select(frame);
if (sel.IsEmpty) { frame.Image.Dispose(); return; }
var crop = ScreenCapture.Crop(frame, sel);
frame.Image.Dispose();
frame = crop;
}
_cts = new CancellationTokenSource();
var ct = _cts.Token;
_overlay = new OverlayForm(_cfg, frame.Image, frame.Area);
_overlay.CancelRequested += delegate { try { _cts.Cancel(); } catch { } };
_overlay.Show();
_overlay.Refresh(); // кадр должен появиться до того, как начнётся распознавание
var lines = await _ocr.RecognizeAsync(frame, _cfg.OcrScale);
if (ct.IsCancellationRequested || _overlay.IsDisposed) return;
var sampler = new FrameSampler(frame);
var blocks = Blocks.Group(lines, _cfg.MinChars, _cfg.SkipGarbage, sampler, _cfg.MinWords);
if (_cfg.DetectForeign && _ocrOther != null && blocks.Count > 0)
{
_overlay.SetBlocks(blocks, "Проверка языка…");
var otherLines = await _ocrOther.RecognizeAsync(frame, _cfg.OcrScale);
if (ct.IsCancellationRequested || _overlay.IsDisposed) return;
int before = blocks.Count;
Blocks.DropForeign(blocks, otherLines, Blocks.IsCyrillicLanguage(_cfg.SourceLang));
if (blocks.Count < before)
Debug.WriteLine("отброшено как чужой язык: " + (before - blocks.Count));
}
if (blocks.Count == 0)
{
_overlay.SetBlocks(blocks, "Текст не найден");
_overlay.SetStatus("Текст не найден — Esc, чтобы закрыть", false);
return;
}
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;
}
_overlay.SetBlocks(blocks, "Перевод: " + blocks.Count + " фрагм. …");
var sources = blocks.Select(b => b.Source).ToArray();
var translated = await _chain.TranslateAsync(sources, ct);
if (ct.IsCancellationRequested || _overlay.IsDisposed) return;
int ok = 0;
for (int i = 0; i < blocks.Count; i++)
{
var t = translated[i];
if (string.IsNullOrWhiteSpace(t)) { blocks[i].Failed = true; continue; }
blocks[i].Translated = t;
ok++;
}
sw.Stop();
string status = ok > 0
? string.Format("{0} · {1} из {2} · {3:0.0} с",
_chain.LastUsed ?? "?", ok, blocks.Count, sw.ElapsedMilliseconds / 1000.0)
: "Не удалось перевести: " + (_chain.LastError ?? "нет доступного движка");
_overlay.Refresh(blocks, status, false);
if (ok == 0)
Balloon("Перевод не удался", _chain.LastError ?? "все движки недоступны", ToolTipIcon.Error);
}
catch (Exception ex)
{
if (_overlay != null && !_overlay.IsDisposed) _overlay.Close();
Balloon("Ошибка", ex.Message, ToolTipIcon.Error);
}
finally
{
if (frame != null) frame.Dispose();
if (_cache != null) _cache.Save();
_busy = false;
}
}
async void ProbeEngines()
{
_tray.Text = "Переводчик экрана — проверяю движки…";
var sb = new StringBuilder();
try
{
using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(40)))
{
var list = await _chain.ProbeAsync(cts.Token);
foreach (var s in list)
sb.AppendLine(string.Format("{0,-16} {1}",
s.Name, s.Ok ? "ок, " + s.Ms + " мс" : "не отвечает — " + s.Error));
}
}
catch (Exception ex) { sb.AppendLine("Проверка прервана: " + ex.Message); }
_tray.Text = "Переводчик экрана — " + _cfg.HotkeyTranslate;
MessageBox.Show(sb.ToString() +
"\nПорядок использования задаётся полем \"chain\" в config.json.",
"Доступность переводчиков", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
void Balloon(string title, string text, ToolTipIcon icon)
{
if (!_cfg.ShowTrayBalloonOnError && icon != ToolTipIcon.Info) return;
try
{
_tray.BalloonTipTitle = title;
_tray.BalloonTipText = text;
_tray.BalloonTipIcon = icon;
_tray.ShowBalloonTip(6000);
}
catch { }
}
/// <summary>Иконка рисуется в коде, чтобы приложение оставалось одним файлом без ресурсов.</summary>
static Icon MakeIcon()
{
var bmp = new Bitmap(32, 32);
using (var g = Graphics.FromImage(bmp))
{
g.SmoothingMode = SmoothingMode.AntiAlias;
g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.AntiAlias;
using (var b = new LinearGradientBrush(new Rectangle(0, 0, 32, 32),
Color.FromArgb(38, 84, 160), Color.FromArgb(22, 42, 88), 45f))
g.FillEllipse(b, 0, 0, 31, 31);
using (var f = new Font("Segoe UI", 15f, FontStyle.Bold, GraphicsUnit.Pixel))
{
g.DrawString("A", f, Brushes.White, new PointF(2.5f, 4f));
g.DrawString("Я", f, new SolidBrush(Color.FromArgb(120, 200, 255)), new PointF(13.5f, 12f));
}
}
IntPtr h = bmp.GetHicon();
try { return (Icon)Icon.FromHandle(h).Clone(); }
finally { DestroyIcon(h); bmp.Dispose(); }
}
[System.Runtime.InteropServices.DllImport("user32.dll", SetLastError = true)]
static extern bool DestroyIcon(IntPtr handle);
protected override void Dispose(bool disposing)
{
if (disposing)
{
try { if (_cts != null) _cts.Cancel(); } catch { }
if (_cache != null) _cache.Save();
if (_hotkeys != null) _hotkeys.Dispose();
if (_tray != null) { _tray.Visible = false; _tray.Dispose(); }
}
base.Dispose(disposing);
}
}
/// <summary>Невидимое окно, принимающее WM_HOTKEY: глобальные клавиши нужны и когда фокус
/// в игре или чужом окне, поэтому обычные события формы тут не годятся.</summary>
internal class HotkeyWindow : NativeWindow, IDisposable
{
readonly System.Collections.Generic.List<int> _ids = new System.Collections.Generic.List<int>();
public event Action<int> Pressed;
public HotkeyWindow() { CreateHandle(new CreateParams()); }
public bool Register(int id, string combo)
{
uint mods, vk; string display;
if (!AppConfig.ParseHotkey(combo, out mods, out vk, out display)) return false;
if (!Native.RegisterHotKey(Handle, id, mods | Native.MOD_NOREPEAT, vk)) return false;
_ids.Add(id);
return true;
}
public void UnregisterAll()
{
foreach (var id in _ids) Native.UnregisterHotKey(Handle, id);
_ids.Clear();
}
protected override void WndProc(ref Message m)
{
if (m.Msg == Native.WM_HOTKEY)
{
var h = Pressed;
if (h != null) h(m.WParam.ToInt32());
}
base.WndProc(ref m);
}
public void Dispose()
{
UnregisterAll();
DestroyHandle();
}
}
}