Tetrak OCR
GitHub ↗
Open source

Built to be forked: how to get involved in the Armenian recogniser

The trainer and the EasyOCR library behind our Armenian model are public, Apache 2.0, and put together so that somebody else can pick them up — to send us a fix, add a source, or train a recogniser for a script of their own. This is a tour of what a newcomer finds: the checks that run before code can land, the release automation, and the guidance files that give a coding assistant the same orientation we have

Most of what we have written this week has been about numbers — which engine reads which register, and by how much. This article is about the two repositories those numbers come from, and it is written for a different reader: not someone deciding whether to use the model, but someone wondering whether to get their hands on it.

That might mean sending us a fix. It might mean adding a source we have not harvested, or a typeface we have not rendered, or a held-out set for material we do not have. It might mean none of those and instead taking a fork to train a recogniser for a script that is not Armenian at all, which the pipeline is shaped to allow. All of those are welcome, and the repositories have been arranged so that each is possible without asking us first. Here is how they are arranged, and why.

Two repositories, one handshake

The model lives in two public repositories under the Apache 2.0 licence, and the split between them is the first thing to understand.

tetrak-hy-trainer produces the model. It owns the census of Armenian Wikisource, the harvester that fetches proofread pages and their scans, the character set, the synthetic renderer that draws corpus text in Armenian typefaces with archival damage, the training and fine-tuning scripts, the per-register evaluation, and the packaging step that emits the three files EasyOCR needs. It is also where the training data and the weights are uploaded to Hugging Face from.

tetrak-easyocr-armenian ships it. It is a small installable library — pip install tetrak-easyocr-armenian — whose whole job is to be the thing EasyOCR imports when asked for the tetrak_hy network, to fetch the right weights, and to prove they are the right weights before loading them. Its audience is EasyOCR users, most of whom will never hear of Tetrak, and its README is written for them.

The handshake between the two is deliberately narrow. The trainer uploads a versioned model to Hugging Face. The library then pins that upload by its commit — a forty-character identifier that can never be moved, unlike a tag or a branch — alongside a checksum of the weights file. A library release is therefore a weights release: one version number covers the code, the weights and the provenance record that says which data, fonts, crops and training configuration produced them. Weights are never committed to either repository, and a URL without its checksum is not allowed to ship. If you take a fork and train your own model, this is the arrangement you inherit, and it is the reason a user of your fork can trust that pip install gave them the weights you meant.

The first ten minutes

Both repositories set up the same way, and the setup is written down in each one’s CONTRIBUTING.md — a file whose opening line says that it applies to us as much as to anyone sending a pull request, which is true and is the point.

git clone https://github.com/scattercode/tetrak-hy-trainer
cd tetrak-hy-trainer
python3 -m venv .venv && source .venv/bin/activate
pip install -e '.[dev]'
lefthook install        # or: git config core.hooksPath .githooks
pytest

The test suite runs in seconds and needs no OCR engine, no PyTorch, and no GPU. That is a design decision rather than an accident: the parts of the pipeline that can be tested without a trained model — the character set, the packaging, the held-out registry, the harvest manifests, the label writer — are the parts where a mistake is most expensive and least visible, so they are the parts a newcomer can check on a laptop before touching anything heavier. The library’s continuous-integration jobs go further and never install PyTorch at all; the tests that need it are marked to skip in CI and run locally.

The last line of that block installs the git hooks, and that is where the hardening starts.

The checks that run whether or not you remember

Everything in this section is automatic. A contributor can run any of it by hand, and CONTRIBUTING.md shows how, but none of it depends on anyone remembering.

Before a commit is accepted, Ruff — a fast linter and formatter for Python — checks the staged files against a rule set that is declared explicitly in pyproject.toml. The explicitness matters: Ruff’s defaults change between versions, and a project that relies on them ends up with a laptop and a CI server that disagree about whether the code is clean. At the same time the lockfile is checked. A lockfile is the fully resolved list of every package the project depends on, including the packages those packages depend on, pinned to exact versions; pyproject.toml says what we want, uv.lock says what we actually get. It exists so that security scanning sees real versions rather than ranges, and a change to the dependencies that arrives without a regenerated lock is refused.

Every commit message must follow Conventional Commits: a type such as feat, fix, docs or ci, an optional scope, and a description with no trailing full stop. A dependency-free shell script under .githooks/ enforces it, and the same script lives, byte for byte, in every repository we maintain. This is not house style for its own sake. The release automation reads the commit types to decide the next version number — a fix bumps the patch version, a feat the minor, and a ! marks a breaking change — and it writes the changelog from the messages. So the message is written for the person reading the changelog later, and a wrong type is not a cosmetic slip; it cuts the wrong release.

Before a push, the whole test suite runs, through the project’s own interpreter rather than whatever pytest happens to be on the path. That detail cost us a run of pushes that failed before a single test ran, because the hook could not import the package, and the fix was to put the knowledge in the hook rather than in the advice.

On the server, CI repeats the lint, the format check, the lockfile check and the tests, and adds two things a laptop would not. Trivy scans the resolved dependency tree for known vulnerabilities and produces a software bill of materials — an SBOM, a machine-readable list of every component in the build — that is attached to the run. And a small job checks that the files we copy between repositories, the commit hook and the changelog configuration among them, are still identical to the shared originals, because we once had three copies of the version-computation logic that were byte-identical and nothing would have told us when they stopped being so. Dependabot opens a pull request a week for dependency and action updates, with commit prefixes chosen so its messages pass the same hook that ours do, and the vulnerability scan re-runs every Monday morning whether or not anything changed.

On a merge to main, the release happens without a human. A shared action computes the next version from the commit history, git-cliff prepends a section to CHANGELOG.md, the workflow tags the commit and publishes a GitHub Release, builds the wheel, checks its metadata, installs it in a clean environment to prove it imports, and publishes it to PyPI through a Trusted Publisher — a mechanism by which PyPI accepts uploads from a named GitHub workflow rather than from a password or token that could leak. For the library, a further job downloads the weights the new version pins, verifies their checksum, and attaches them to the Release as a mirror. Nobody edits the changelog, nobody creates a tag, nobody types a version number: the version comes from the tag and the tag comes from the commits.

If that sounds like a great deal of machinery for a two-person project, that is the wrong way round. It is because the project is two people that the machinery is there. Each of those checks stands in for a review we would otherwise have to remember to do, and each one does its job identically for a stranger’s pull request and for ours.

Guidance written for a coding assistant, and useful to everyone

The part of the last few days we most want to draw attention to is the least visible in a file listing. Both repositories carry a CLAUDE.md at the root, and each carries a .claude/skills/ directory with a skill file inside. These are plain Markdown. Claude Code reads them automatically when it opens the repository; any other coding assistant can be pointed at them; and a person can simply read them, which we would recommend before reading anything else.

CLAUDE.md is orientation. It says what the repository is and what its neighbour is, and then it states the hard rules — not as a list of prohibitions but with the reason attached to each, because a rule without its reason gets worked around the first time it is inconvenient. The trainer’s version explains why the character set is a single source of truth and why changing it is a new model version rather than a patch (the set is positional, so adding a character invalidates every weight file ever trained). It explains the held-out split — which pages are reserved for evaluation, why the guard checks the harvest manifest rather than the directory name so a renamed copy cannot get past it, and why a guard that fires must be resolved by changing the split deliberately rather than by working around it. It explains the loading contract with EasyOCR, verified against EasyOCR’s source rather than its documentation, including the counter-intuitive fact that the model has to be loaded with the language set to English because EasyOCR ships no Armenian character file and the setting is provably inert for a custom model anyway. It carries a table of which of the three interpreters runs which script, because picking the wrong one is the usual first five minutes lost. And it says where the decisions live, so that a reader who wants to know why the space is in the character set can find the answer rather than re-litigate it.

The skill files are procedure. tetrak-hy-training, in the trainer, is the whole pipeline in the order it runs — census, harvest, character check, fonts, synthetic pre-train, real crops, fine-tune, evaluate, package, publish — with the command for each step and the trap beside it. The traps are the valuable part, and they are valuable because every one of them cost us a training run. The label file that the vendored trainer reads with a regular expression rather than as CSV, so that a standard CSV writer’s quotation marks became part of 21% of v1’s labels and the model learned to invent a quotation mark. The abbreviation dot that was missing from the character set, so that 6% of the evaluation pages were unwinnable by construction. The crop filenames built from an encyclopedia volume number that seven of the new sources did not have, so that three books’ page 100 wrote the same file and one image carried three labels — the bug yesterday’s article is largely about. Each of those is now a sentence in a file that the assistant reads before it runs anything, which is a different thing from being a sentence in an article that a person might remember.

tetrak-hy-weights-release, in the library, is the other half of the handshake: how to resolve a Hugging Face tag to the commit behind it, which three things must move together in the same change, which commit type cuts the right version, and what the release workflow will then do on its own. It is short, because the procedure is short, and it exists because the procedure is also easy to get subtly wrong.

The effect we have noticed, working this way for a week, is not mainly speed. It is that a fork keeps the lessons. If you clone the trainer tomorrow to train a Georgian or a Syriac recogniser — swap the character set, fetch different fonts, point the harvester at a different Wikisource — the assistant you open it with will refuse to train on your held-out pages, will tell you to diff the character set before the first run, and will know not to write the label file with a CSV writer. None of that knowledge is in our heads any more; it is in the repository, where it belongs.

The pipeline’s own repository, the one behind this site, works the same way, with skills for the evaluation corpus, the site’s design system and the writing of articles like this one. We mention it because the pattern is the point: a skill is written the first time a procedure has to be done twice, and it is written down at the moment the procedure has just gone wrong, while the cost is fresh.

Ways in

Some concrete things a newcomer could do, roughly in order of how much of the pipeline each one touches.

Measure something. The per-register evaluation sets are built from Wikisource by a script, and the comparison against other engines is a tool that reads their results. If you have an engine we have not measured, or a machine that can run one we could not, a new row in the comparison is a contribution that needs no training at all.

Add a source. The census ranks every proofread work on Armenian Wikisource; we have harvested fourteen. Adding one is a harvest command, a character-set diff, and an entry in the held-out registry choosing which of its pages are reserved for evaluation before anything trains on it. The discipline is written down, the guard enforces it, and the register you add becomes a row in every future evaluation.

Add a face. The renderer takes any font whose licence allows it and checks, glyph by glyph, that the font can actually draw each line rather than trusting its name. Armenian typefaces from a period or a publisher we have not covered are a direct lever on the model’s generality, and the fetch script records each font’s licence from the file itself.

Build a held-out set for material we do not have. Every figure we publish is set type, scanned well, transcribed by volunteers. The damaged, handwritten, pre-reform material this project exists for has no evaluation set, for us or for anyone, and a small proofread set of such pages would be the most valuable contribution on this list by some distance.

Take a fork for another script. The character set is one module, the fonts are one script, the harvester works on any Wikisource with scan-and-transcript pairs, and the detector needs no training because it is script-agnostic. What the fork inherits is everything above: the checks, the release automation, the provenance handshake, and the guidance files.

One constraint applies to all of it and is stated at the top of both CONTRIBUTING.md files. A separate, non-commercially licensed Armenian OCR repository validated the approach we use, and nothing derived from it — code, annotations or weights — can be accepted into an Apache 2.0 project, however small the fragment. Re-implementing an idea from it is fine; copying its expression is not. If you are not sure which side of that line a contribution falls on, say so in the pull request and we will work it out together.

Security reports go privately, through the SECURITY.md in either repository, rather than the issue tracker. Everything else goes through issues and pull requests in the usual way, and the hooks will tell you what they think of your commit message before we do.