Files
screen-translator/src/Blocks.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

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);
}
}
}