math snipping extension

README.md

integrand #

math snipping extension

Screenshot a math expression in the browser, land on integral-calculator.com or derivative-calculator.net with the problem already filled in.

Running the prototype #

# the service (Symbolab OCR by default; see the note below)
uv run --group service uvicorn integrand.service:app --port 8765

# or with the local model, which is what ships
INTEGRAND_OCR=pix2tex uv run --group service --group ocr \
  uvicorn integrand.service:app --port 8765

Then load extension/ unpacked: chrome://extensions → developer mode → Load unpacked. Drag over a problem, check what it read, hit Open calculator.

Out of the box it talks to https://integrand.dunkirk.sh, so it works before you have run anything. To use the service above, put http://localhost:8765 in the options page and save — saving asks for permission to reach that host, since it is no longer one the extension ships with.

Hint? sits under every result. Clicking it names the technique — try a substitution, integration by parts — and clicking again gives up u = ln(x). Three steps, each asked for, because that a hint exists gives nothing away and what u is has done the problem.

Both steps take the same beat whether or not there is anything to wait for. Working out a technique runs from a twentieth of a second to two, and an interface that is sometimes instant and sometimes not reads as unreliable. It comes from sympy's integral_steps rule tree, so it is a decision a CAS actually made rather than a sentence a model produced, and it cannot be confidently wrong.

Where the page knows its own maths, expressions light up as you move over them and one click takes the whole thing — no rectangle to drag. Everything else still crops.

Four ways to start a snip:

  • the toolbar button
  • ⌘⇧Y / Ctrl+Shift+Y
  • right-click → Snip this maths problem
  • the ∫ on the right edge, on sites you have opted into

The first three carry Chrome's activeTab with them, which is what allows a screenshot. The ∫ is a button on the page and carries nothing, so from there a click on an expression the page already knows works and dragging a rectangle has nothing to photograph with. Screenshots in the options page trades that for a standing grant on every site; it ships off, and the panel says which key to press instead.

Nine homework sites ship switched on — WebAssign, MyOpenMath, Canvas, DeltaMath, Gradescope, Khan Academy, Wikipedia, math.stackexchange, OpenStax — each one a site the page reader can read directly rather than photograph. Even there the button only appears on pages where it actually finds maths, so a course's login page stays clean while its homework pages do not.

During a selection the button becomes the way out of one: it stays put, turns into a cross, and cancels — as does Esc. Toggle any site off in the options page. To add a site that isn't listed, open it and right-click the toolbar icon.

The extension only ever talks to the service, so swapping OCR backends does not touch it. Before it captures anything it asks the page whether it already knows its own maths — KaTeX, MathJax, MathML, Wikipedia alt text and WebAssign's watex markup all carry it — and where they do, the result is exact and OCR never runs. See RECOGNITION.md.

The default symbolab backend posts images to Symbolab's unauthenticated getImageId endpoint. It is there because it is accurate enough to develop against while the local model catches up. It is not something to ship: it is someone else's compute, and it breaks the promise that images never leave your box. Set INTEGRAND_OCR=pix2tex before letting anyone else near this.

Deploying #

Two images, built from one tree, because the two halves have nothing in common but the code:

converter sympy, antlr and fastapi — 337MB, starts instantly
ocr the same code plus a model and torch — gigabytes

They meet at one route, POST /v1/ocr: image in, LaTeX out. Splitting them means the part that has to be up whenever you snip is the cheap part, the expensive part can be moved or switched off without taking the rest down, and neither needs redeploying when the other changes.

docker compose up -d                        # both
docker compose up -d --no-deps integrand    # converter only
INTEGRAND_PORT=8799 docker compose up -d    # when 8765 is already yours

--no-deps is load-bearing: depends_on would otherwise start the model alongside, which is the opposite of what you asked for.

Each half is built with uv and then left behind — the runtime stages start from plain python and receive only the virtualenv, so no build toolchain is shipped. Both run as a non-root user with no-new-privileges, the converter read-only with all capabilities dropped, and both carry a healthcheck. The OCR container is not published on the host; the converter reaches it over the compose network, because nothing else has any business sending it images.

Bring up the converter alone and it still works. Snipping reports that OCR is unreachable and says which host it tried, while /v1/convert and /v1/hint carry on. Measured with the model not running at all:

POST /v1/convert  → sin(x), verified: true
POST /v1/hint     → {"technique":"integration by parts","detail":"u = x, dv = e^(x)"}
POST /v1/snip     → 502 {"error":"ocr_failed",
                         "detail":"no OCR service at http://ocr:8765/v1/ocr: …"}

That degraded state is intended rather than accidental. The extension can still read maths off a page and still name a technique with no model deployed anywhere.

Weights are pulled at build time. Downloading them on first use instead would make the first snip after every deploy the slow one, and a box with no outbound network would look healthy right up until someone used it.

The OCR image takes CPU torch. Left alone, uv resolves the CUDA build on Linux and drags in fifteen nvidia-* packages plus triton — gigabytes of GPU libraries the container will never load. [tool.uv.sources] points torch at PyTorch's CPU index for sys_platform == 'linux' only, so macOS resolution is untouched:

before   15 nvidia packages, triton
after    0, and torch 2.13.0+cpu on linux

That is not a compromise for deployment either — MPS measured 2.4x slower than CPU on this model, which is why CoreML was dropped too.

On another machine #

compose.yaml builds from this checkout; compose.ghcr.yaml runs published images and needs nothing else from the repo, so it can be copied to a host on its own:

docker push ghcr.io/taciturnaxolotl/integrand:0.3.6
docker push ghcr.io/taciturnaxolotl/integrand-ocr:0.3.0

# on the host, alongside compose.yaml
echo "INTEGRAND_BIND=<its tailscale ip>" > .env
docker compose up -d

INTEGRAND_BIND defaults to loopback. Set it to the Tailscale address when something else has to reach the service, which keeps it off the LAN either way. Pulling is left explicit — docker compose pull. The images are public, so no registry credential is needed anywhere. Images are linux/arm64, built on Apple silicon for a host of the same shape; a different architecture needs docker buildx --platform.

Configuration #

INTEGRAND_OCR remote, pix2tex, unimernet, symbolab
INTEGRAND_OCR_URL where remote sends images
INTEGRAND_OCR_TIMEOUT seconds to wait on it, default 30
INTEGRAND_ORIGINS CORS allowlist; set to chrome-extension://<id>

Why this is still Python #

The core is sympy and about two hundred lines. parse_latex, integral_steps — which is where the hints come from, grounded in a decision the CAS actually made rather than a sentence a model generated — and apart have no equivalent in another language. Rewriting would mean reimplementing a computer algebra system, and the heavy dependency was never the algebra: it was the OCR model, which is now a separate image that need not run at all.

Testing #

uv run pytest                              # 61 cases, no network
uv run python scripts/render_corpus.py corpus
uv run --group ocr python scripts/benchmark.py corpus --backend pix2tex --device cpu
node scripts/preview-panel.js          # render the result panel in every state
python3 -m http.server -d .preview 8799

See BENCHMARKS.md for backend numbers.

Second and higher derivatives ride along too: \frac{d^2}{dx^2} is expanded into nested first derivatives so sympy will parse it at all, and the repeat count is sent as the site's own difforder, up to the fifth its dropdown offers.

PDFs work too: Chrome's viewer is a normal https: document, so the overlay paints over it and the crop captures the rendered page.

Fixing the sites we read from #

userscripts/ carries fixes for the sites integrand works against. So far: WebAssign's answer boxes vanish because MathJax is loaded once per question and clobbers its own callbacks, and the bracket pairing that disappeared when they moved off MathQuill. See userscripts/README.md — the causes are not guessable from the symptoms.

This depends on someone else's free site and will break if David Scherfgen changes his URL scheme.

The canonical repo for this is hosted on tangled over at https://tangled.org/dunkirk.sh/integrand

© 2026-present Kieran Klukas