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>
624 lines
35 KiB
C#
624 lines
35 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Drawing;
|
|
using System.Drawing.Imaging;
|
|
using System.Linq;
|
|
using System.Runtime.InteropServices;
|
|
using System.Text;
|
|
|
|
namespace ScreenTranslator
|
|
{
|
|
/// <summary>Связный кусок текста на экране (абзац, пункт меню, реплика) — единица перевода
|
|
/// и одна плашка в оверлее.</summary>
|
|
internal class TextBlock
|
|
{
|
|
public Rectangle Rect;
|
|
public string Source = "";
|
|
public string Translated;
|
|
public bool Failed;
|
|
public double LineHeight = 14;
|
|
public int LineCount = 1;
|
|
public Color Bg = Color.White;
|
|
public Color Fg = Color.Black;
|
|
/// <summary>Куда реально нарисована плашка — нужно для попадания мышью.</summary>
|
|
public Rectangle DrawnRect;
|
|
/// <summary>Предел разрастания плашки: дальше начинается соседний текст.
|
|
/// Пустой — ограничений нет, кроме краёв кадра.</summary>
|
|
public Rectangle GrowLimit;
|
|
}
|
|
|
|
internal static class Blocks
|
|
{
|
|
/// <summary>Склеить OCR-строки в абзацы. Критерии соседства: строки идут подряд по вертикали,
|
|
/// заметно перекрываются по горизонтали и имеют сопоставимый кегль. Так пункты меню в разных
|
|
/// углах экрана не слипаются в один блок, а абзац текста не рассыпается на строки.</summary>
|
|
public static List<TextBlock> Group(List<OcrLineBox> lines, int minChars, bool skipGarbage = true,
|
|
FrameSampler sampler = null, int minWords = 1)
|
|
{
|
|
if (sampler != null)
|
|
foreach (var l in lines)
|
|
{
|
|
Color bg, fg;
|
|
sampler.Sample(l.Rect, out bg, out fg);
|
|
l.Bg = bg; l.BgKnown = true;
|
|
}
|
|
|
|
var src = MergeHorizontally(lines, sampler)
|
|
.Where(l => IsMeaningful(l.Text, minChars))
|
|
.OrderBy(l => l.Rect.Top).ThenBy(l => l.Rect.Left)
|
|
.ToList();
|
|
|
|
var open = new List<List<OcrLineBox>>();
|
|
var done = new List<List<OcrLineBox>>();
|
|
|
|
foreach (var line in src)
|
|
{
|
|
double h = LineH(line);
|
|
List<OcrLineBox> best = null;
|
|
double bestGap = double.MaxValue;
|
|
|
|
for (int i = open.Count - 1; i >= 0; i--)
|
|
{
|
|
var blk = open[i];
|
|
var last = blk[blk.Count - 1];
|
|
double lh = LineH(last);
|
|
double gap = line.Rect.Top - last.Rect.Bottom;
|
|
|
|
// ушли ниже — блок больше не пополнится
|
|
if (gap > lh * 2.0 && gap > 40) { done.Add(blk); open.RemoveAt(i); continue; }
|
|
if (gap < -lh * 0.5) continue; // строки наложены — разные слои UI
|
|
if (Math.Max(lh, h) / Math.Max(1.0, Math.Min(lh, h)) > 1.5) continue; // разный кегль
|
|
|
|
// Порог по межстрочному интервалу. Строки абзаца стоят с интерлиньяжем около
|
|
// одной высоты глифов, поэтому 0.75 рвал абзацы на куски — а рваные строки и
|
|
// переводятся заметно хуже. Ровная левая кромка — верный признак абзаца,
|
|
// там допускаем разрядку побольше; пункты меню с их шагом в 1.5 высоты
|
|
// при этом остаются раздельными.
|
|
// Разный фон — значит это разные элементы интерфейса, а не строки одного
|
|
// абзаца. В плотных окнах (игры, панели настроек) чекбокс и выпадающий
|
|
// список стоят с тем же интервалом, что строки текста, и по одной геометрии
|
|
// их не развести: слипаясь, они дают и мусорный перевод, и плашку на пол-окна.
|
|
if (line.BgKnown && last.BgKnown && ColorDistance(line.Bg, last.Bg) > 42) continue;
|
|
|
|
// Рамка или край панели между строками — тоже признак разных элементов.
|
|
// Фон при этом может почти совпадать (в окне игры оба тёмные, разница
|
|
// всего 13 из 255), а вот граница поля даёт скачок яркости в разы.
|
|
if (sampler != null && line.Rect.Top - last.Rect.Bottom >= 2)
|
|
{
|
|
int x0 = Math.Max(line.Rect.Left, last.Rect.Left);
|
|
int x1 = Math.Min(line.Rect.Right, last.Rect.Right);
|
|
if (x1 - x0 >= 8 &&
|
|
sampler.BandContrast(x0, x1, last.Rect.Bottom, line.Rect.Top) > 35) continue;
|
|
}
|
|
|
|
// Признак «строка не дотянула до правого края» (ragged-right).
|
|
//
|
|
// В абзаце короткой имеет право быть только последняя строка — остальные
|
|
// заполнены до края, иначе слово перенеслось бы. Значит если предыдущая
|
|
// строка короткая, абзац на ней и кончился, а то, что идёт ниже, —
|
|
// самостоятельная надпись. Именно это разводит «Has Skill Up» и «Trade Goods»,
|
|
// которые стоят с абзацным интервалом и одинаковым отступом.
|
|
int widest = line.Rect.Width;
|
|
foreach (var bl in blk) if (bl.Rect.Width > widest) widest = bl.Rect.Width;
|
|
|
|
// Порог зависит от того, состоялся ли уже абзац. Для одиночной строки он строг:
|
|
// короткая надпись, под которой стоит другая, — это два разных элемента
|
|
// («Has Skill Up» и «Trade Goods»). А внутри абзаца из нескольких строк рваный
|
|
// правый край нормален, и строгий порог отрывал от него последнюю строку.
|
|
double fill = blk.Count >= 2 ? 0.6 : 0.65;
|
|
if (last.Rect.Width < widest * fill) continue;
|
|
|
|
bool aligned = Math.Abs(line.Rect.Left - last.Rect.Left) <= Math.Max(3.0, h * 0.25);
|
|
double thr = aligned ? 1.2 : 1.05;
|
|
if (gap > Math.Max(lh, h) * thr) continue;
|
|
|
|
// На разрядке около предельной одного выравнивания мало: так подпись-заголовок
|
|
// над пунктом меню слипалась со следующим пунктом. Требуем ещё и одинаковый кегль —
|
|
// внутри абзаца он совпадает, а заголовок от текста почти всегда отличается.
|
|
if (gap > Math.Max(lh, h) * 0.9 &&
|
|
Math.Max(lh, h) / Math.Max(1.0, Math.Min(lh, h)) > 1.15) continue;
|
|
|
|
if (HOverlap(line.Rect, last.Rect) < 0.35 &&
|
|
Math.Abs(line.Rect.Left - last.Rect.Left) > h * 0.8) continue;
|
|
|
|
if (gap < bestGap) { bestGap = gap; best = blk; }
|
|
}
|
|
|
|
if (best != null) best.Add(line);
|
|
else open.Add(new List<OcrLineBox> { line });
|
|
}
|
|
done.AddRange(open);
|
|
|
|
var result = new List<TextBlock>(done.Count);
|
|
foreach (var blk in done)
|
|
{
|
|
var rect = blk[0].Rect;
|
|
foreach (var l in blk) rect = Rectangle.Union(rect, l.Rect);
|
|
var heights = blk.Select(LineH).OrderBy(x => x).ToList();
|
|
|
|
result.Add(new TextBlock
|
|
{
|
|
Rect = rect,
|
|
Source = Join(blk),
|
|
LineHeight = heights[heights.Count / 2],
|
|
LineCount = blk.Count
|
|
});
|
|
}
|
|
return result.Where(b => IsMeaningful(b.Source, minChars) &&
|
|
WordCount(b.Source) >= minWords &&
|
|
(!skipGarbage || LooksLikeLanguage(b.Source))).ToList();
|
|
}
|
|
|
|
static int WordCount(string s)
|
|
{
|
|
return s.Split(new[] { ' ', '\t', '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries)
|
|
.Count(w => w.Any(char.IsLetter));
|
|
}
|
|
|
|
static int ColorDistance(Color a, Color b)
|
|
{
|
|
return (Math.Abs(a.R - b.R) + Math.Abs(a.G - b.G) + Math.Abs(a.B - b.B)) / 3;
|
|
}
|
|
|
|
/// <summary>Собрать обратно строку, которую распознавание разрезало по широким пробелам.
|
|
///
|
|
/// В играх с моноширинным или пиксельным шрифтом промежутки между словами широкие, и OCR
|
|
/// отдаёт «SPACE/CLICK», «HERE», «TO CONTINUE» тремя отдельными строками. Переведённые
|
|
/// порознь, они превращаются в бессмыслицу, поэтому фрагменты на одной базовой линии
|
|
/// нужно склеить до того, как строки начнут собираться в абзацы.
|
|
///
|
|
/// Ограничения намеренно жёсткие: соседние элементы интерфейса тоже стоят в одну строку
|
|
/// («Search» — «Has Skill Up» — «Have Materials»), и склеить их было бы хуже, чем
|
|
/// оставить фразу разрезанной.</summary>
|
|
static List<OcrLineBox> MergeHorizontally(List<OcrLineBox> lines, FrameSampler sampler)
|
|
{
|
|
var byX = lines.OrderBy(l => l.Rect.Left).ToList();
|
|
var used = new bool[byX.Count];
|
|
var result = new List<OcrLineBox>();
|
|
|
|
for (int i = 0; i < byX.Count; i++)
|
|
{
|
|
if (used[i]) continue;
|
|
used[i] = true;
|
|
var cur = new OcrLineBox
|
|
{
|
|
Text = byX[i].Text,
|
|
Rect = byX[i].Rect,
|
|
AvgWordHeight = byX[i].AvgWordHeight,
|
|
Bg = byX[i].Bg,
|
|
BgKnown = byX[i].BgKnown
|
|
};
|
|
|
|
bool grew = true;
|
|
while (grew)
|
|
{
|
|
grew = false;
|
|
for (int j = 0; j < byX.Count; j++)
|
|
{
|
|
if (used[j] || !CanMergeH(cur, byX[j], sampler)) continue;
|
|
|
|
// Совсем узкий зазор — это разорванное слово, а не два соседних:
|
|
// «Li gh thouse» нужно собрать обратно в «Lighthouse», иначе не переведётся.
|
|
int gap = byX[j].Rect.Left - cur.Rect.Right;
|
|
string glue = gap < LineH(cur) * 0.32 ? "" : " ";
|
|
cur.Text = cur.Text.TrimEnd() + glue + byX[j].Text.TrimStart();
|
|
cur.Rect = Rectangle.Union(cur.Rect, byX[j].Rect);
|
|
cur.AvgWordHeight = Math.Max(cur.AvgWordHeight, byX[j].AvgWordHeight);
|
|
used[j] = true;
|
|
grew = true;
|
|
break;
|
|
}
|
|
}
|
|
result.Add(cur);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
static bool CanMergeH(OcrLineBox a, OcrLineBox b, FrameSampler sampler)
|
|
{
|
|
int gap = b.Rect.Left - a.Rect.Right;
|
|
if (gap < -2) return false; // перекрываются — разные слои
|
|
|
|
double ha = LineH(a), hb = LineH(b);
|
|
if (Math.Max(ha, hb) / Math.Max(1.0, Math.Min(ha, hb)) > 1.4) return false;
|
|
|
|
// Пробел шире двух высот строки — это уже не пробел, а колонка или другой элемент.
|
|
if (gap > Math.Min(ha, hb) * 2.2) return false;
|
|
|
|
int overlap = Math.Min(a.Rect.Bottom, b.Rect.Bottom) - Math.Max(a.Rect.Top, b.Rect.Top);
|
|
if (overlap <= 0) return false;
|
|
if ((double)overlap / Math.Min(a.Rect.Height, b.Rect.Height) < 0.6) return false;
|
|
|
|
if (a.BgKnown && b.BgKnown && ColorDistance(a.Bg, b.Bg) > 42) return false;
|
|
|
|
// В промежутке стоит рамка, чекбокс или край колонки — значит фрагменты чужие
|
|
if (sampler != null && gap >= 3)
|
|
{
|
|
int top = Math.Min(a.Rect.Top, b.Rect.Top);
|
|
int bottom = Math.Max(a.Rect.Bottom, b.Rect.Bottom);
|
|
if (sampler.BandContrastVertical(a.Rect.Right + 1, b.Rect.Left - 1, top, bottom) > 45)
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
static double LineH(OcrLineBox l)
|
|
{
|
|
return l.AvgWordHeight > 1 ? l.AvgWordHeight : Math.Max(1, l.Rect.Height);
|
|
}
|
|
|
|
static double HOverlap(Rectangle a, Rectangle b)
|
|
{
|
|
int o = Math.Min(a.Right, b.Right) - Math.Max(a.Left, b.Left);
|
|
if (o <= 0) return 0;
|
|
return (double)o / Math.Max(1, Math.Min(a.Width, b.Width));
|
|
}
|
|
|
|
static string Join(List<OcrLineBox> blk)
|
|
{
|
|
var sb = new StringBuilder();
|
|
for (int i = 0; i < blk.Count; i++)
|
|
{
|
|
string t = blk[i].Text.Trim();
|
|
if (t.Length == 0) continue;
|
|
if (sb.Length > 0)
|
|
{
|
|
// перенос по дефису склеиваем обратно в слово
|
|
if (sb[sb.Length - 1] == '-' && t.Length > 0 && char.IsLower(t[0])) sb.Length -= 1;
|
|
else sb.Append(' ');
|
|
}
|
|
sb.Append(t);
|
|
}
|
|
return sb.ToString();
|
|
}
|
|
|
|
static readonly char[] Vowels = "aeiouyAEIOUYаеёиоуыэюяАЕЁИОУЫЭЮЯ".ToCharArray();
|
|
|
|
/// <summary>Насколько текст похож на осмысленный: доля слов без следов покорёженного
|
|
/// распознавания. 1.0 — чистый текст, около 0.5 и ниже — каша.</summary>
|
|
internal static double LanguageScore(string s)
|
|
{
|
|
var words = s.Split(new[] { ' ', '\t', '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries)
|
|
.Where(w => w.Length >= 2).ToList();
|
|
if (words.Count == 0) return 0;
|
|
int good = 0;
|
|
foreach (var w in words)
|
|
if (!LooksMangled(w) && (w.Length < 3 || w.IndexOfAny(Vowels) >= 0)) good++;
|
|
return (double)good / words.Count;
|
|
}
|
|
|
|
/// <summary>Выбросить блоки, написанные не на том языке, под который выбрана модель OCR.
|
|
///
|
|
/// Статистика по одному тексту тут бессильна: «npo cauyyn qnmy» из букв, которые модель
|
|
/// подобрала к кириллице, по форме неотличимо от английского. Зато если тот же участок
|
|
/// экрана прочитать второй моделью и он окажется заметно осмысленнее — вопрос закрыт,
|
|
/// это текст на её языке, и переводить его не нужно.</summary>
|
|
public static void DropForeign(List<TextBlock> blocks, List<OcrLineBox> otherLines,
|
|
bool sourceIsCyrillic, double margin = 0.15)
|
|
{
|
|
if (blocks == null || otherLines == null || otherLines.Count == 0) return;
|
|
|
|
blocks.RemoveAll(b =>
|
|
{
|
|
var sb = new StringBuilder();
|
|
foreach (var l in otherLines.OrderBy(l => l.Rect.Top))
|
|
{
|
|
var inter = Rectangle.Intersect(l.Rect, b.Rect);
|
|
if (inter.IsEmpty) continue;
|
|
long areaLine = (long)l.Rect.Width * l.Rect.Height;
|
|
if (areaLine <= 0) continue;
|
|
// Модели режут строки по-разному, поэтому требовать почти полного совпадения
|
|
// нельзя — иначе сравнение не состоится именно там, где оно нужнее всего.
|
|
if ((double)inter.Width * inter.Height / areaLine < 0.45) continue;
|
|
if (sb.Length > 0) sb.Append(' ');
|
|
sb.Append(l.Text.Trim());
|
|
}
|
|
if (sb.Length < 3) return false;
|
|
string other = sb.ToString();
|
|
|
|
// Решающий признак — письменность, а не осмысленность. Английская модель физически
|
|
// не может выдать кириллицу: «Профиль» она отдаёт как «npocbwlb», что по форме
|
|
// неотличимо от настоящего слова. Зато русская модель читает латиницу как латиницу,
|
|
// поэтому кириллица в её ответе означает, что надпись изначально русская.
|
|
int cyr = 0, lat = 0;
|
|
foreach (char c in other)
|
|
{
|
|
if (c >= 0x0400 && c <= 0x04FF) cyr++;
|
|
else if (c < 128 && char.IsLetter(c)) lat++;
|
|
}
|
|
if (cyr + lat >= 3)
|
|
{
|
|
bool otherIsCyrillic = cyr > lat;
|
|
if (otherIsCyrillic != sourceIsCyrillic)
|
|
{
|
|
// Вторая модель говорит «чужой алфавит», но она же принимает латинские
|
|
// C, a, e за кириллические и так топит короткие английские кнопки.
|
|
// Узнаваемое английское слово в исходнике — весомее её мнения.
|
|
if (!sourceIsCyrillic && EnglishWords.ContainsKnown(b.Source)) return false;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return LanguageScore(other) > LanguageScore(b.Source) + margin;
|
|
});
|
|
}
|
|
|
|
/// <summary>Пишется ли язык кириллицей — нужно, чтобы понимать, какой алфавит ожидать
|
|
/// от текста на языке оригинала.</summary>
|
|
/// <summary>Посчитать, докуда каждой плашке позволено разрастаться, чтобы не залезть
|
|
/// на соседний текст.
|
|
///
|
|
/// Перевод почти всегда длиннее оригинала, и плашке приходится расти. В разреженном
|
|
/// интерфейсе это незаметно, но в плотном (окна игры, таблицы) разросшаяся плашка
|
|
/// перекрывает соседние подписи — читаемым становится один фрагмент ценой двух соседних.</summary>
|
|
public static void ComputeGrowLimits(List<TextBlock> blocks, List<OcrLineBox> allLines, Size canvas)
|
|
{
|
|
// Препятствие — любой распознанный текст, а не только то, что мы переводим.
|
|
// Отфильтрованные строки (числа, надписи на другом языке) с экрана никуда не делись:
|
|
// заголовок, разросшийся на вторую строку, ложился ровно на них.
|
|
var obstacles = new List<Rectangle>();
|
|
foreach (var b in blocks) obstacles.Add(b.Rect);
|
|
if (allLines != null)
|
|
foreach (var l in allLines) obstacles.Add(l.Rect);
|
|
|
|
foreach (var b in blocks)
|
|
{
|
|
int right = canvas.Width, bottom = canvas.Height, top = 0;
|
|
foreach (var o in obstacles)
|
|
{
|
|
if (b.Rect.Contains(o) || o == b.Rect) continue; // часть самого блока
|
|
|
|
bool sameRows = o.Top < b.Rect.Bottom && o.Bottom > b.Rect.Top;
|
|
if (sameRows && o.Left >= b.Rect.Right)
|
|
right = Math.Min(right, o.Left - 2);
|
|
|
|
bool sameCols = o.Left < b.Rect.Right && o.Right > b.Rect.Left;
|
|
if (sameCols && o.Top >= b.Rect.Bottom)
|
|
bottom = Math.Min(bottom, o.Top - 1);
|
|
if (sameCols && o.Bottom <= b.Rect.Top)
|
|
top = Math.Max(top, o.Bottom + 1);
|
|
}
|
|
b.GrowLimit = Rectangle.FromLTRB(
|
|
b.Rect.Left, Math.Min(top, b.Rect.Top),
|
|
Math.Max(b.Rect.Right, right), Math.Max(b.Rect.Bottom, bottom));
|
|
}
|
|
}
|
|
|
|
public static bool IsCyrillicLanguage(string langCode)
|
|
{
|
|
if (string.IsNullOrEmpty(langCode)) return false;
|
|
string two = langCode.Split('-')[0].ToLowerInvariant();
|
|
switch (two)
|
|
{
|
|
case "ru": case "uk": case "be": case "bg": case "sr":
|
|
case "mk": case "kk": case "ky": case "mn": case "tg":
|
|
return true;
|
|
default: return false;
|
|
}
|
|
}
|
|
|
|
/// <summary>Похоже ли это на настоящий текст, а не на кашу распознавания.
|
|
///
|
|
/// На экране почти всегда есть надписи не на том языке, для которого выбрана модель OCR:
|
|
/// английская модель, встретив кириллицу, выдаёт нечто вроде «gT0 ffileals Ha MauiHHe».
|
|
/// Такую кашу бессмысленно переводить — она только засоряет экран плашками и жжёт лимиты
|
|
/// переводчика. Отличаем по статистике: в настоящем тексте почти в каждом слове есть
|
|
/// гласная, мало цифр внутри слов и высокая доля букв.
|
|
///
|
|
/// Короткие подписи (OK, PVE, 45%) пропускаем без проверки — статистика на них не работает.</summary>
|
|
internal static bool LooksLikeLanguage(string s)
|
|
{
|
|
var words = s.Split(new[] { ' ', '\t', '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries)
|
|
.Where(w => w.Length >= 2).ToList();
|
|
if (words.Count == 0) return false;
|
|
|
|
// Сигнатура каши хорошо видна даже в одном слове, поэтому короткие подписи
|
|
// тоже проверяем — именно на них («C03AaTb», «Pa3Mep», «npocM0TpeTb») фильтр
|
|
// по длине раньше и прокалывался, залепляя плашками весь русский интерфейс.
|
|
int suspicious = 0, fileish = 0;
|
|
foreach (var w in words)
|
|
{
|
|
if (LooksLikeFileName(w)) { fileish++; continue; }
|
|
if (LooksMangled(w)) suspicious++;
|
|
}
|
|
if (fileish == words.Count) return false; // имена файлов и пути переводить нечего
|
|
int scored = words.Count - fileish;
|
|
if (scored > 0 && (double)suspicious / scored >= 0.5) return false;
|
|
|
|
// «18 KS», «41 KS» — обрывки размеров и счётчиков: цифры есть, а осмысленного
|
|
// слова нет. Переводить нечего, а плашка поверх мешает.
|
|
bool hasDigit = s.IndexOfAny("0123456789".ToCharArray()) >= 0;
|
|
if (hasDigit && !words.Any(w => w.Count(char.IsLetter) >= 4)) return false;
|
|
|
|
// Частоты буквенных пар. Ловит именно ту кашу, что неотличима по форме от слова:
|
|
// «npocbwlb» (-3,5) и «Bxoaguvqe» (-4,5) против настоящих надписей, которые на этих
|
|
// же замерах не опускались ниже -2,9. Порог взят с зазором, чтобы не задеть их;
|
|
// кашу с цифрами и скачками регистра отсеивают правила выше.
|
|
double bigram = EnglishBigrams.TextScore(s);
|
|
if (!double.IsNaN(bigram) && bigram < -3.0) return false;
|
|
|
|
if (s.Length < 12) return true;
|
|
|
|
int letters = 0, nonSpace = 0;
|
|
foreach (char c in s)
|
|
{
|
|
if (char.IsWhiteSpace(c)) continue;
|
|
nonSpace++;
|
|
if (char.IsLetter(c)) letters++;
|
|
}
|
|
if (nonSpace == 0) return false;
|
|
if ((double)letters / nonSpace < 0.62) return false;
|
|
|
|
var longWords = words.Where(w => w.Length >= 3).ToList();
|
|
if (longWords.Count < 2) return true;
|
|
|
|
int withVowel = 0;
|
|
foreach (var w in longWords) if (w.IndexOfAny(Vowels) >= 0) withVowel++;
|
|
return (double)withVowel / longWords.Count >= 0.72;
|
|
}
|
|
|
|
/// <summary>Признаки слова, покорёженного распознаванием чужого алфавита: цифра, зажатая
|
|
/// между букв («Pa3Mep»), и заглавная сразу после строчной («COPTMPOBaTb»).
|
|
///
|
|
/// Оба признака встречаются и в нормальном тексте — CamelCase, «IPv6», «UTF-8», — поэтому
|
|
/// решение принимается не по слову, а по их доле в блоке.</summary>
|
|
const string OkPunctuation = ".,:;!?'\"()[]-–—/\\%&#@+*=_<>|~$^°«»…№";
|
|
|
|
static bool LooksMangled(string w)
|
|
{
|
|
for (int i = 1; i < w.Length - 1; i++)
|
|
if (char.IsDigit(w[i]) && char.IsLetter(w[i - 1]) && char.IsLetter(w[i + 1])) return true;
|
|
|
|
// Символы, которых в обычной надписи не бывает: так распознавание подменяет
|
|
// непонятые ему глифы («Pa60t-u•1i», «KOMnb}0Tep»).
|
|
foreach (char c in w)
|
|
if (!char.IsLetterOrDigit(c) && OkPunctuation.IndexOf(c) < 0) return true;
|
|
|
|
if (w.Length > 3 && w != w.ToUpperInvariant())
|
|
for (int i = 1; i < w.Length; i++)
|
|
if (char.IsUpper(w[i]) && char.IsLower(w[i - 1])) return true;
|
|
|
|
return false;
|
|
}
|
|
|
|
/// <summary>Имя файла, путь или адрес — переводить их не нужно, «cache.json» не должен
|
|
/// превращаться в «кэш.json».</summary>
|
|
static bool LooksLikeFileName(string w)
|
|
{
|
|
if (w.IndexOf('/') >= 0 || w.IndexOf('\\') >= 0) return true;
|
|
int dot = w.LastIndexOf('.');
|
|
if (dot <= 0 || dot == w.Length - 1) return false;
|
|
string ext = w.Substring(dot + 1).Trim('"', '\'', ',', ')', ':', ';');
|
|
if (ext.Length < 1 || ext.Length > 5) return false;
|
|
foreach (char c in ext) if (!char.IsLetterOrDigit(c)) return false;
|
|
// «конец предложения.» — не имя файла
|
|
return !char.IsWhiteSpace(w[dot - 1]) && ext.ToLowerInvariant() != ext.ToUpperInvariant();
|
|
}
|
|
|
|
/// <summary>Отсев мусора OCR: рамки, иконки, одиночные символы, голые числа —
|
|
/// переводить нечего, а плашка поверх них только мешает.</summary>
|
|
static bool IsMeaningful(string s, int minChars)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(s)) return false;
|
|
int letters = 0;
|
|
foreach (char c in s) if (char.IsLetter(c)) letters++;
|
|
return letters >= Math.Max(1, minChars);
|
|
}
|
|
}
|
|
|
|
/// <summary>Отвечает на вопрос «какого цвета фон и текст в этом прямоугольнике» — чтобы плашка
|
|
/// совпала с окном под ней. Работает по копии пикселей из CapturedFrame, поэтому безопасен
|
|
/// в фоновом потоке параллельно с отрисовкой оверлея.</summary>
|
|
internal sealed class FrameSampler
|
|
{
|
|
readonly byte[] _px;
|
|
readonly int _stride, _w, _h;
|
|
|
|
public FrameSampler(CapturedFrame frame)
|
|
{
|
|
_w = frame.Width; _h = frame.Height;
|
|
_stride = _w * 4;
|
|
_px = frame.Pixels;
|
|
}
|
|
|
|
/// <summary>Разброс средней яркости строк пикселей в горизонтальной полосе.
|
|
///
|
|
/// Отвечает на вопрос «есть ли между двумя строками текста визуальная граница»: рамка поля
|
|
/// или край панели тянутся во всю ширину и дают резкий скачок, а промежуток внутри абзаца
|
|
/// однороден — выносные элементы букв среднюю по строке почти не двигают.</summary>
|
|
/// <summary>То же, но полоса вертикальная: есть ли разделитель СЛЕВА и СПРАВА между
|
|
/// двумя фрагментами на одной строке — рамка чекбокса, край колонки, граница поля.</summary>
|
|
public int BandContrastVertical(int x0, int x1, int y0, int y1)
|
|
{
|
|
x0 = Math.Max(0, x0); x1 = Math.Min(_w, x1);
|
|
y0 = Math.Max(0, y0); y1 = Math.Min(_h, y1);
|
|
if (x1 - x0 < 2 || y1 - y0 < 4) return 0;
|
|
|
|
int min = int.MaxValue, max = int.MinValue;
|
|
int step = Math.Max(1, (y1 - y0) / 40);
|
|
for (int x = x0; x < x1; x++)
|
|
{
|
|
int sum = 0, n = 0;
|
|
for (int y = y0; y < y1; y += step) { sum += Lum(At(x, y)); n++; }
|
|
if (n == 0) continue;
|
|
int avg = sum / n;
|
|
if (avg < min) min = avg;
|
|
if (avg > max) max = avg;
|
|
}
|
|
return max > min ? max - min : 0;
|
|
}
|
|
|
|
public int BandContrast(int x0, int x1, int y0, int y1)
|
|
{
|
|
x0 = Math.Max(0, x0); x1 = Math.Min(_w, x1);
|
|
y0 = Math.Max(0, y0); y1 = Math.Min(_h, y1);
|
|
if (x1 - x0 < 8 || y1 - y0 < 2) return 0;
|
|
|
|
int min = int.MaxValue, max = int.MinValue;
|
|
int step = Math.Max(1, (x1 - x0) / 160);
|
|
for (int y = y0; y < y1; y++)
|
|
{
|
|
int sum = 0, n = 0;
|
|
for (int x = x0; x < x1; x += step) { sum += Lum(At(x, y)); n++; }
|
|
if (n == 0) continue;
|
|
int avg = sum / n;
|
|
if (avg < min) min = avg;
|
|
if (avg > max) max = avg;
|
|
}
|
|
return max > min ? max - min : 0;
|
|
}
|
|
|
|
Color At(int x, int y)
|
|
{
|
|
int i = y * _stride + x * 4;
|
|
return Color.FromArgb(_px[i + 2], _px[i + 1], _px[i]);
|
|
}
|
|
|
|
static int Lum(Color c) { return (c.R * 299 + c.G * 587 + c.B * 114) / 1000; }
|
|
|
|
/// <summary>Доминирующий цвет = фон; самый частый из «далёких по яркости» = цвет текста.
|
|
/// Если контраста нет (градиент, картинка), возвращаем чёрное на белом — читаемо всегда.</summary>
|
|
public void Sample(Rectangle r, out Color bg, out Color fg)
|
|
{
|
|
r.Inflate(2, 1);
|
|
r.Intersect(new Rectangle(0, 0, _w, _h));
|
|
if (r.Width < 2 || r.Height < 2) { bg = Color.White; fg = Color.Black; return; }
|
|
|
|
var hist = new Dictionary<int, int>(256);
|
|
int stepX = Math.Max(1, r.Width / 120), stepY = Math.Max(1, r.Height / 40);
|
|
for (int y = r.Top; y < r.Bottom; y += stepY)
|
|
for (int x = r.Left; x < r.Right; x += stepX)
|
|
{
|
|
var c = At(x, y);
|
|
int key = (c.R >> 3 << 10) | (c.G >> 3 << 5) | (c.B >> 3);
|
|
int n; hist.TryGetValue(key, out n); hist[key] = n + 1;
|
|
}
|
|
if (hist.Count == 0) { bg = Color.White; fg = Color.Black; return; }
|
|
|
|
int bgKey = 0, bgN = -1;
|
|
foreach (var kv in hist) if (kv.Value > bgN) { bgN = kv.Value; bgKey = kv.Key; }
|
|
bg = FromKey(bgKey);
|
|
int bgL = Lum(bg);
|
|
|
|
int fgKey = -1, fgN = -1;
|
|
foreach (var kv in hist)
|
|
{
|
|
var c = FromKey(kv.Key);
|
|
if (Math.Abs(Lum(c) - bgL) < 70) continue;
|
|
if (kv.Value > fgN) { fgN = kv.Value; fgKey = kv.Key; }
|
|
}
|
|
fg = fgKey >= 0 ? FromKey(fgKey) : (bgL > 128 ? Color.FromArgb(20, 20, 20) : Color.FromArgb(240, 240, 240));
|
|
|
|
// «почти совпало» — принудительно разводим, иначе перевод сольётся с плашкой
|
|
if (Math.Abs(Lum(fg) - bgL) < 60)
|
|
fg = bgL > 128 ? Color.FromArgb(20, 20, 20) : Color.FromArgb(240, 240, 240);
|
|
}
|
|
|
|
static Color FromKey(int k)
|
|
{
|
|
int r = (k >> 10) & 31, g = (k >> 5) & 31, b = k & 31;
|
|
return Color.FromArgb(r << 3 | r >> 2, g << 3 | g >> 2, b << 3 | b >> 2);
|
|
}
|
|
}
|
|
}
|