Переводчик экрана: наложение перевода поверх текста на экране
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,159 @@
|
||||
# Сборка и публикация «Переводчика экрана».
|
||||
#
|
||||
# Идёт на self-hosted Windows-раннере (метка `windows`, host mode) — иначе никак:
|
||||
# распознавание держится на Windows.Media.Ocr, а сборка — на Roslyn из VS Build Tools
|
||||
# и на Windows.winmd из Windows SDK. В контейнере ничего этого нет.
|
||||
#
|
||||
# * push в master -> сборка + прогон проверок + перезапись релиза `latest`
|
||||
# * push тега v* -> то же, но отдельный постоянный релиз с этой версией
|
||||
#
|
||||
# Требований к раннеру всего два, и оба уже выполнены (тот же хост собирает GusVoice):
|
||||
# Visual Studio Build Tools (нужен Roslyn csc) и Windows SDK (Windows.winmd).
|
||||
# Если Roslyn вдруг отсутствует, build.ps1 сам откатывается на компилятор из состава
|
||||
# Windows — код специально написан без возможностей C# 6 и выше, чтобы это работало.
|
||||
|
||||
name: build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
GITEA_HOST: gitea.gusik.tech
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: windows
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Сборка exe и диагностической утилиты
|
||||
shell: pwsh
|
||||
run: .\build.ps1 -Test
|
||||
|
||||
# Модель правдоподобия английского — единственная часть, которую можно проверить
|
||||
# без экрана и без языковых пакетов. Проверяет, что таблица биграмм на месте и
|
||||
# всё ещё отделяет настоящие надписи от каши распознавания.
|
||||
- name: Проверка биграммной модели
|
||||
shell: pwsh
|
||||
run: |
|
||||
$out = & .\dist\RenderTest.exe --selftest 2>&1 | Out-String
|
||||
Write-Host $out
|
||||
if ($LASTEXITCODE -ne 0) { throw "selftest завершился с кодом $LASTEXITCODE" }
|
||||
if ($out -notmatch 'худший «хороший»') { throw "selftest не выдал сводку — модель не загрузилась" }
|
||||
|
||||
# Гейт качества на эталонном кадре. Работает, только если на раннере установлен
|
||||
# английский пакет распознавания; если его нет — не валим сборку, а предупреждаем:
|
||||
# это среда, а не регрессия кода.
|
||||
- name: Проверка распознавания на эталоне
|
||||
shell: pwsh
|
||||
run: |
|
||||
.\tools\make-sample.ps1
|
||||
$out = & .\dist\RenderTest.exe --in .\dist\sample.png --out .\dist\ci --no-translate 2>&1 | Out-String
|
||||
Write-Host $out
|
||||
if ($out -match 'OCR недоступен') {
|
||||
Write-Host '::warning::на раннере нет модели распознавания — проверка кадра пропущена'
|
||||
exit 0
|
||||
}
|
||||
if ($out -notmatch 'блоков (\d+)') { throw "RenderTest не отчитался о блоках" }
|
||||
$blocks = [int]$Matches[1]
|
||||
Write-Host "распознано блоков: $blocks"
|
||||
# На этом кадре стабильно выходит 27-29 блоков. Резкое расхождение означает, что
|
||||
# правки в Blocks.cs поехали — либо всё слиплось, либо рассыпалось.
|
||||
if ($blocks -lt 18 -or $blocks -gt 40) { throw "неожиданное число блоков ($blocks) — сегментация сломана" }
|
||||
|
||||
- name: Сборка пакета
|
||||
shell: pwsh
|
||||
run: |
|
||||
$rel = 'release'
|
||||
New-Item -ItemType Directory -Force -Path $rel | Out-Null
|
||||
Copy-Item .\dist\ScreenTranslator.exe $rel -Force
|
||||
Copy-Item '.\ЧИТАЙ МЕНЯ.txt' $rel -Force -ErrorAction SilentlyContinue
|
||||
# config.json генерирует само приложение из вшитого умолчания
|
||||
& .\dist\ScreenTranslator.exe --write-config
|
||||
Start-Sleep -Seconds 2
|
||||
if (Test-Path .\dist\config.json) { Copy-Item .\dist\config.json $rel -Force }
|
||||
else { Write-Host '::warning::config.json не создан — пакет уедет без него' }
|
||||
Compress-Archive -Path "$rel\*" -DestinationPath 'ПереводчикЭкрана.zip' -Force
|
||||
Get-ChildItem 'ПереводчикЭкрана.zip' | Format-List Name, Length
|
||||
|
||||
# Тег v* -> постоянный релиз. Любой push в master -> перезаписываемый `latest`,
|
||||
# чтобы у тех, кому программу отдали, всегда была ссылка на свежую сборку.
|
||||
- name: Публикация релиза
|
||||
shell: pwsh
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
REF: ${{ github.ref }}
|
||||
SHA: ${{ github.sha }}
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$host_ = $env:GITEA_HOST
|
||||
$base = "https://${host_}/api/v1/repos/$($env:REPO)"
|
||||
$hdr = @{ Authorization = "token $($env:GITEA_TOKEN)" }
|
||||
$isTag = $env:REF -like 'refs/tags/*'
|
||||
$tag = if ($isTag) { $env:REF_NAME } else { 'latest' }
|
||||
|
||||
if (-not $isTag) {
|
||||
# Скользящий релиз: сносим прошлый вместе с тегом, иначе он останется
|
||||
# висеть на старом коммите и «свежая сборка» перестанет быть свежей.
|
||||
try {
|
||||
$old = Invoke-RestMethod -Uri "$base/releases/tags/$tag" -Headers $hdr
|
||||
if ($old.id) {
|
||||
Invoke-RestMethod -Method Delete -Uri "$base/releases/$($old.id)" -Headers $hdr | Out-Null
|
||||
Write-Host "удалён прошлый релиз $tag"
|
||||
}
|
||||
} catch { }
|
||||
try { Invoke-RestMethod -Method Delete -Uri "$base/tags/$tag" -Headers $hdr | Out-Null } catch { }
|
||||
}
|
||||
|
||||
$short = $env:SHA.Substring(0, 8)
|
||||
$stamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-dd HH:mm') + ' UTC'
|
||||
$notes = if ($isTag) {
|
||||
"Версия $tag`n`nСборка из коммита $short."
|
||||
} else {
|
||||
"Свежая сборка из master, коммит $short, собрана $stamp.`n`n" +
|
||||
"Обновляется автоматически при каждом изменении. Для стабильной версии смотрите релизы с номером."
|
||||
}
|
||||
|
||||
$body = @{
|
||||
tag_name = $tag
|
||||
target_commitish = $env:SHA
|
||||
name = if ($isTag) { $tag } else { "Свежая сборка ($short)" }
|
||||
body = $notes
|
||||
draft = $false
|
||||
prerelease = (-not $isTag)
|
||||
} | ConvertTo-Json -Compress
|
||||
|
||||
try {
|
||||
$rel = Invoke-RestMethod -Method Post -Uri "$base/releases" -Headers $hdr -ContentType 'application/json' -Body $body
|
||||
} catch {
|
||||
# Релиз на этот тег уже есть (перезапуск задания) — берём его и обновляем описание
|
||||
$rel = Invoke-RestMethod -Uri "$base/releases/tags/$tag" -Headers $hdr
|
||||
$patch = @{ body = $notes } | ConvertTo-Json -Compress
|
||||
Invoke-RestMethod -Method Patch -Uri "$base/releases/$($rel.id)" -Headers $hdr -ContentType 'application/json' -Body $patch | Out-Null
|
||||
}
|
||||
if (-not $rel.id) { throw "не удалось получить релиз для тега $tag" }
|
||||
|
||||
# Старые файлы с тем же именем API не заменяет, поэтому сначала чистим
|
||||
try {
|
||||
foreach ($a in (Invoke-RestMethod -Uri "$base/releases/$($rel.id)/assets" -Headers $hdr)) {
|
||||
Invoke-RestMethod -Method Delete -Uri "$base/releases/$($rel.id)/assets/$($a.id)" -Headers $hdr | Out-Null
|
||||
}
|
||||
} catch { }
|
||||
|
||||
foreach ($f in @('ПереводчикЭкрана.zip', '.\dist\ScreenTranslator.exe')) {
|
||||
$item = Get-Item $f
|
||||
Write-Host "загружаю $($item.Name)…"
|
||||
& curl.exe -sf -X POST "$base/releases/$($rel.id)/assets?name=$($item.Name)" `
|
||||
-H "Authorization: token $($env:GITEA_TOKEN)" -F "attachment=@$($item.FullName)"
|
||||
if ($LASTEXITCODE -ne 0) { throw "не удалось загрузить $($item.Name)" }
|
||||
}
|
||||
Write-Host "релиз $tag опубликован: https://${host_}/$($env:REPO)/releases"
|
||||
Reference in New Issue
Block a user