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 { /// Связный кусок текста на экране (абзац, пункт меню, реплика) — единица перевода /// и одна плашка в оверлее. 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; /// Куда реально нарисована плашка — нужно для попадания мышью. public Rectangle DrawnRect; /// Предел разрастания плашки: дальше начинается соседний текст. /// Пустой — ограничений нет, кроме краёв кадра. public Rectangle GrowLimit; } internal static class Blocks { /// Склеить OCR-строки в абзацы. Критерии соседства: строки идут подряд по вертикали, /// заметно перекрываются по горизонтали и имеют сопоставимый кегль. Так пункты меню в разных /// углах экрана не слипаются в один блок, а абзац текста не рассыпается на строки. public static List Group(List 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>(); var done = new List>(); foreach (var line in src) { double h = LineH(line); List 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 { line }); } done.AddRange(open); var result = new List(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; } /// Собрать обратно строку, которую распознавание разрезало по широким пробелам. /// /// В играх с моноширинным или пиксельным шрифтом промежутки между словами широкие, и OCR /// отдаёт «SPACE/CLICK», «HERE», «TO CONTINUE» тремя отдельными строками. Переведённые /// порознь, они превращаются в бессмыслицу, поэтому фрагменты на одной базовой линии /// нужно склеить до того, как строки начнут собираться в абзацы. /// /// Ограничения намеренно жёсткие: соседние элементы интерфейса тоже стоят в одну строку /// («Search» — «Has Skill Up» — «Have Materials»), и склеить их было бы хуже, чем /// оставить фразу разрезанной. static List MergeHorizontally(List lines, FrameSampler sampler) { var byX = lines.OrderBy(l => l.Rect.Left).ToList(); var used = new bool[byX.Count]; var result = new List(); 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 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(); /// Насколько текст похож на осмысленный: доля слов без следов покорёженного /// распознавания. 1.0 — чистый текст, около 0.5 и ниже — каша. 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; } /// Выбросить блоки, написанные не на том языке, под который выбрана модель OCR. /// /// Статистика по одному тексту тут бессильна: «npo cauyyn qnmy» из букв, которые модель /// подобрала к кириллице, по форме неотличимо от английского. Зато если тот же участок /// экрана прочитать второй моделью и он окажется заметно осмысленнее — вопрос закрыт, /// это текст на её языке, и переводить его не нужно. public static void DropForeign(List blocks, List 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; }); } /// Пишется ли язык кириллицей — нужно, чтобы понимать, какой алфавит ожидать /// от текста на языке оригинала. /// Посчитать, докуда каждой плашке позволено разрастаться, чтобы не залезть /// на соседний текст. /// /// Перевод почти всегда длиннее оригинала, и плашке приходится расти. В разреженном /// интерфейсе это незаметно, но в плотном (окна игры, таблицы) разросшаяся плашка /// перекрывает соседние подписи — читаемым становится один фрагмент ценой двух соседних. public static void ComputeGrowLimits(List blocks, List allLines, Size canvas) { // Препятствие — любой распознанный текст, а не только то, что мы переводим. // Отфильтрованные строки (числа, надписи на другом языке) с экрана никуда не делись: // заголовок, разросшийся на вторую строку, ложился ровно на них. var obstacles = new List(); 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; } } /// Похоже ли это на настоящий текст, а не на кашу распознавания. /// /// На экране почти всегда есть надписи не на том языке, для которого выбрана модель OCR: /// английская модель, встретив кириллицу, выдаёт нечто вроде «gT0 ffileals Ha MauiHHe». /// Такую кашу бессмысленно переводить — она только засоряет экран плашками и жжёт лимиты /// переводчика. Отличаем по статистике: в настоящем тексте почти в каждом слове есть /// гласная, мало цифр внутри слов и высокая доля букв. /// /// Короткие подписи (OK, PVE, 45%) пропускаем без проверки — статистика на них не работает. 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; } /// Признаки слова, покорёженного распознаванием чужого алфавита: цифра, зажатая /// между букв («Pa3Mep»), и заглавная сразу после строчной («COPTMPOBaTb»). /// /// Оба признака встречаются и в нормальном тексте — CamelCase, «IPv6», «UTF-8», — поэтому /// решение принимается не по слову, а по их доле в блоке. 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; } /// Имя файла, путь или адрес — переводить их не нужно, «cache.json» не должен /// превращаться в «кэш.json». 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(); } /// Отсев мусора OCR: рамки, иконки, одиночные символы, голые числа — /// переводить нечего, а плашка поверх них только мешает. 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); } } /// Отвечает на вопрос «какого цвета фон и текст в этом прямоугольнике» — чтобы плашка /// совпала с окном под ней. Работает по копии пикселей из CapturedFrame, поэтому безопасен /// в фоновом потоке параллельно с отрисовкой оверлея. 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; } /// Разброс средней яркости строк пикселей в горизонтальной полосе. /// /// Отвечает на вопрос «есть ли между двумя строками текста визуальная граница»: рамка поля /// или край панели тянутся во всю ширину и дают резкий скачок, а промежуток внутри абзаца /// однороден — выносные элементы букв среднюю по строке почти не двигают. /// То же, но полоса вертикальная: есть ли разделитель СЛЕВА и СПРАВА между /// двумя фрагментами на одной строке — рамка чекбокса, край колонки, граница поля. 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; } /// Доминирующий цвет = фон; самый частый из «далёких по яркости» = цвет текста. /// Если контраста нет (градиент, картинка), возвращаем чёрное на белом — читаемо всегда. 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(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); } } }