Skip to main content

nepse-tms-captcha

PyPI Python License

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

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  │
   └──────────────┘     └──────────────┘     └──────────────┘
  1. Subtract the captcha from a stored reference of an empty captcha, clamping negatives to zero. Only the glyph strokes survive.

  2. Amplify and threshold to a clean black-and-white mask.

  3. Split on fully blank columns — each gap is a character boundary.

  4. 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 image
  • kind — Kind.BOLD or Kind.SLIM to 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)

Source distribution for nepse-tms-captcha 0.1.0
File Size Uploaded
nepse_tms_captcha-0.1.0.tar.gz 1.5 MB Details

Built distribution (wheel)

Table of built distributions (wheels) for nepse-tms-captcha 0.1.0
File Interpreter ABI Platform
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 log

Release 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

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page