nepse-tms-captcha
Offline solver for the login captchas on Nepal's NEPSE TMS broker portals.
Solving takes about a millisecond of arithmetic. There is no machine learning model, no network call, and no third-party captcha service — your captcha images and credentials never leave your machine.
from tms_captcha import solve
result = solve("captcha.png")
if result:
print(result.value) # 'qij450'
Contents
- How it works
- Installation
- Quick start
- Usage guide
- Command line
- Accuracy
- Recalibrating
- API reference
- Troubleshooting
- Credits
- Responsible use
How it works
TMS captchas are 290×80 images: dark text over a noisy background. The trick is that the background never changes — it is the same noise on every captcha. So it can simply be subtracted away.
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ ▒▒▒qij450▒▒▒ │ − │ ▒▒▒▒▒▒▒▒▒▒▒▒ │ = │ qij450 │
│ captcha │ │ reference │ │ clean text │
└──────────────┘ └──────────────┘ └──────────────┘
-
Subtract the captcha from a stored reference of an empty captcha, clamping negatives to zero. Only the glyph strokes survive.
-
Amplify and threshold to a clean black-and-white mask.
-
Split on fully blank columns — each gap is a character boundary.
-
Match each glyph against a table of reference glyphs using five cheap statistics and a weighted L1 distance:
# Feature Weight 0 Total ink 1 1 Ink in the left half 3 2 Ink in the top half 2 3 Ink in the bottom half 8 4 Glyph width 3
A match is rejected when the best distance exceeds 60, or when the runner-up is within 5 — meaning the two candidates are indistinguishable and picking either would be a coin flip. TMS renders captchas in two weights, bold and slim; the solver tries bold first and falls back to slim.
Installation
pip install nepse-tms-captcha
Requires Python 3.9+. The only dependencies are numpy and pillow.
Installing from source
git clone https://github.com/DevAbhinav2073/nepse-tms-captcha.git
cd nepse-tms-captcha
pip install -e ".[dev]"
pytest
Quick start
from tms_captcha import solve
result = solve("captcha.png")
print(result.value) # 'qij450' — the solved text
print(result.ok) # True
print(result.status) # Status.SUCCESS
print(result.kind) # Kind.BOLD — which glyph table matched
SolveResult is falsy when solving fails, so the common case reads naturally:
if result := solve(png_bytes):
log_in(captcha=result.value)
else:
# Don't retry the same image — captchas are single use.
# Fetch a fresh one and try again.
...
Usage guide
Input formats
solve() accepts anything you are likely to have on hand:
from pathlib import Path
from PIL import Image
from tms_captcha import solve
solve("captcha.png") # path as str
solve(Path("captcha.png")) # pathlib.Path
solve(response.content) # raw bytes, e.g. straight from requests
solve(open("captcha.png", "rb")) # any file-like object
solve(Image.open("captcha.png")) # a PIL image
solve("data:image/png;base64,iVBORw0...") # a data: URI
Raw bytes is usually what you want: the TMS API hands back a PNG body, so it can be piped straight in without touching disk.
Interpreting the result
status |
Meaning | What to do |
|---|---|---|
Status.SUCCESS |
Six glyphs, all matched confidently | Use result.value |
Status.LOW_CONFIDENCE |
A glyph was ambiguous or matched nothing well | Fetch a fresh captcha and retry |
Status.INVALID_LENGTH |
The image did not split into six glyphs | Fetch a fresh captcha and retry |
On a failure, result.value holds the partial text decoded so far, which is useful for debugging but should not be submitted.
result.scores gives per-character (distance, margin) pairs — the matched distance and how far ahead of the runner-up it was. Larger margins mean more certainty:
for char, (distance, margin) in zip(result.value, result.scores):
print(f"{char}: distance={distance:.1f} margin={margin:.1f}")
Because a captcha is single use, retry with a fresh image rather than re-solving the same one — the answer will not change.
Reusing a solver
solve() uses a lazily created process-wide solver, which is fine for most uses. If you are solving in a loop or a long-lived service, construct a Solver once — loading the reference tables costs more than solving does:
from tms_captcha import Solver
solver = Solver()
for image in images:
print(solver.solve(image).value)
End-to-end: logging in to TMS
A complete unattended login against a broker portal, with retries:
import base64
import httpx
from tms_captcha import Solver
BASE = "https://tms35.nepsetms.com.np"
solver = Solver(min_margin=12) # prefer a retry over a rejected attempt
def login(client: httpx.Client, username: str, password: str, attempts: int = 5):
for _ in range(attempts):
captcha_id = client.get(f"{BASE}/tmsapi/authApi/captcha/id").json()["id"]
image = client.get(f"{BASE}/tmsapi/authApi/captcha/image/{captcha_id}").content
result = solver.solve(image)
if not result:
continue # unreadable render; fetch another
response = client.post(f"{BASE}/tmsapi/authApi/authenticate", json={
"userName": username,
"password": base64.b64encode(password.encode()).decode(),
"jwt": "", "otp": "",
"captchaIdentifier": captcha_id,
"userCaptcha": result.value,
})
if response.status_code == 200:
return response.json()
# status 108 is "wrong captcha" — anything else is a real failure
if response.json().get("status") != "108":
raise RuntimeError(f"login failed: {response.text}")
raise RuntimeError(f"captcha not solved after {attempts} attempts")
A handful of attempts makes an unreadable render a non-event: each retry costs one HTTP round trip.
Command line
# Solve one or more images
tms-captcha solve captcha.png
# captcha.png: qij450
# Just the text, for scripting
tms-captcha solve --quiet captcha.png
# qij450
# From stdin
curl -s "$TMS/tmsapi/authApi/captcha/image/$ID" | tms-captcha solve -q -
# Several at once, as JSON
tms-captcha solve --json *.png
# Force a glyph table instead of auto-detecting
tms-captcha solve --kind slim captcha.png
Exit codes: 0 solved, 1 could not be solved confidently, 2 bad input.
Accuracy
Measured on the 52 labelled captchas from the upstream extension (the filename is the correct answer):
| Metric | Result |
|---|---|
| Solved correctly | 43 / 52 (82.7%) |
Precision — of answers reported as SUCCESS, how many were right |
43 / 45 (95.6%) |
| Reported honestly as unsure rather than guessed | 7 / 9 failures |
| Solve time | ~1 ms |
Broken down by glyph set: bold 25/27 (92.6%), slim 18/25 (72%).
The nine failures are inherited from the original implementation, not introduced by the port — recalibrating from these images reproduces upstream's tables to within 1e-9, so the original misreads them too. They fall into three groups, each pinned individually in the test suite:
- 4 touching glyphs — characters overlap, so the blank-column split finds only five. Unfixable without a different segmentation approach.
- 3 low confidence — an ambiguous glyph; the solver correctly declines to answer.
- 2 confidently wrong —
3m0y55→3m0y5e,tyx099→ty3099. These are the ones that matter, because they get submitted.
This is why retrying matters more than raw accuracy. A failure costs one extra captcha fetch, which is free. At 82.7% per attempt, five attempts succeed 99.98% of the time.
Trading recall for precision
If a wrong submission is more expensive than another round trip, raise min_margin:
solver = Solver(min_margin=12) # no wrong answers on the fixture set
min_margin |
Solved | Precision | Confidently wrong |
|---|---|---|---|
| 5 (default, matches upstream) | 82.7% | 95.6% | 2 |
| 8 | 78.8% | 97.6% | 1 |
| 12 | 63.5% | 100% | 0 |
| 15 | 48.1% | 100% | 0 |
Consider this for automated login: fetching a captcha costs nothing, but a rejected login attempt may count against the account (TMS tracks a loginAttempts counter on the user record — this package's author has not established what threshold, if any, triggers a lockout, so the cautious setting is the safer default for unattended use).
Recalibrating
If TMS changes its captcha rendering, results will degrade and you can rebuild the tables from a fresh batch of solved captchas. Name each image after its solution:
images/
├── qij450.png
├── 5yxd70.png
└── cxxivw.png
tms-captcha calibrate images/ -o bold_data.json --pretty
from tms_captcha.calibrate import calibrate, write_table
table = calibrate("images/") # {'0': [149.58, 77.03, ...], ...}
write_table("images/", "bold_data.json")
Images that do not split into exactly six glyphs are skipped, since their glyph-to-label alignment cannot be trusted. Around 25–30 images gives good coverage of the alphabet.
Note the alphabet excludes l and z — TMS does not render them, presumably because they are too easily confused with 1 and 2.
API reference
solve(source, kind=None) -> SolveResult
Solve a captcha using the shared process-wide solver.
source— path, bytes, data URI, file object, or PIL imagekind—Kind.BOLDorKind.SLIMto pin a table;None(default) tries bold then slim
Solver(data_dir=DATA_DIR, *, max_distance=60, min_margin=5)
Reusable solver holding the reference tables in memory. max_distance and
min_margin set how sure it must be before reporting success — see
Trading recall for precision.
| Member | Description |
|---|---|
solve(source, kind=None) |
As above |
features(source) |
Feature vectors per glyph — for calibration and debugging |
alphabet |
Characters the tables can produce |
SolveResult
| Attribute | Type | Description |
|---|---|---|
value |
str |
Solved text (partial on failure) |
status |
Status |
SUCCESS, LOW_CONFIDENCE, or INVALID_LENGTH |
kind |
Kind | None |
Which table matched |
scores |
tuple[tuple[float, float], ...] |
Per-character (distance, margin) |
ok |
bool |
True on success; also bool(result) |
Exceptions
CaptchaError (a ValueError) is raised for a wrong-sized image or an unsupported source type. A failure to recognise is reported through status, not by raising.
Troubleshooting
CaptchaError: expected a 290x80 TMS captcha — the image is not a raw TMS captcha. Fetch it from /tmsapi/authApi/captcha/image/{id} rather than screenshotting the page, since a screenshot is scaled and cropped differently.
Everything returns LOW_CONFIDENCE — either the images are not TMS captchas, or TMS has changed its rendering. Check that a captcha still looks like dark text on the familiar noise, then recalibrate.
INVALID_LENGTH on some captchas — glyphs are touching, so the blank-column split cannot separate them. Retry with a fresh captcha.
Login fails even though the captcha was solved — captchas are single use. A captcha id that has already been submitted will be rejected no matter how correct the text is; request a new id for every attempt.
Credits
This is a Python port of arpandaze/tms-captcha by Arpan Koirala — the browser extension that devised this approach. The algorithm and the reference glyph tables are theirs; this package reimplements the pipeline in Python, reproducing the original's feature extraction exactly.
Both the original and this port are MIT licensed. See LICENSE.
Responsible use
This tool exists so that you can automate logging into your own broker account. It does not bypass authentication — you still need valid credentials, and the captcha is only a bot-speed-bump in front of a login you are entitled to perform.
Please use it accordingly: don't point it at accounts that aren't yours, and don't use it to hammer broker infrastructure. Rate-limit your retries and be a good citizen of a system that a lot of people depend on.
Metadata
Release files for nepse-tms-captcha 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| nepse_tms_captcha-0.1.0.tar.gz | 1.5 MB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| nepse_tms_captcha-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 1.5 MB
Release files / nepse_tms_captcha-0.1.0.tar.gz
| Download URL | nepse_tms_captcha-0.1.0.tar.gz |
|---|---|
| Size | 1.5 MB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
fbe4ac1db31fc54d36884e078dac2df66941b953a87cf1cfdc238d2d16bb41be
|
|
BLAKE2b-256 checksum How to use checksums |
2b0d87657eceb4b078e7c1c013a823e7b65ee40bc05312943c63dd6c4bd62270
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 16, 2026.
Transparency logRelease files / nepse_tms_captcha-0.1.0-py3-none-any.whl
| Download URL | nepse_tms_captcha-0.1.0-py3-none-any.whl |
|---|---|
| Size | 27.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
b54c4a01d33d2e0dc0e06687eb7cd76166b5508c4257cb8f09e1110e3dc63631
|
|
BLAKE2b-256 checksum How to use checksums |
3d256f6654febac8b33e611489bbaee0db9ca5745d4ba5643b0340c6ac935317
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 16, 2026.
Transparency log