RFC: Persistent lastSeen cache for findText / click(text) — ×25 to ×150 speedup on repeated OCR lookups #400
julienmerconsulting
started this conversation in
Ideas
Replies: 1 comment
-
|
@julienmerconsulting your questions:
configurable: IMHO all text related options should live in OCR.options generally: as far as I understand: the scope of the |
Beta Was this translation helpful? Give feedback.
0 replies
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
-
@RaiMan @adriancostin6 — story first, design questions second.
🎯 The story
Two weeks ago I shipped #353 — the persistent
lastSeencache for image find via a PNG ancillary chunk. The idea was honest: the second time you look for the same image, you have already paid the bill once, so the second lookup should not pay it again. The chunk holds the last-seen coordinates inside the PNG itself, the nextfind()checks the cached ROI first, falls back to the full region if the element has moved. Measured speedup on cold-startfind: ~×6.5.It worked. Then yesterday I started a session on a notepad with the word "Espagne" on screen, ran a script that calls
findText("Espagne")eight times in a row, and timed it. Each call took roughly six seconds. Eight calls, forty-eight seconds. The text never moves between two consecutive calls of the same test step. Tesseract was scanning the entire 1920×1080 screen, every single time, just to confirm something it had already located one second earlier.I asked myself the obvious question: why don't we have the same lastSeen pattern for text that we have for images? The data shape is the same — a coordinate, a width, a height, a screen resolution stamp. The fallback semantics are the same — if the cached ROI no longer contains the target, scan the full region and update. The only thing #353 has that this one cannot reuse is the PNG ancillary chunk as a carrier: text has no PNG file to attach to.
So I built it with a JSON sidecar instead. PoC is now validated on a live notepad with two scenarios. Measurements below.
🔧 What the patch does
Single funnel injection in
Region.doFind— the private method through which every text-finding API converges (findText,existsText,waitText,hasText,click(text),doubleClick(text),rightClick(text),hover(text),find(text),dragDrop(text, ...)). One modification at the funnel = full coverage.A new persistence class
org.sikuli.support.TesseractLastSeenexposes three public methods:getRegion(searchRegion, text),put(searchRegion, text, match),invalidate(searchRegion, text). The JSON filetesseract-lastseen.jsonlives inSystem.getProperty("user.dir")— the JVM working directory, which colocates the cache with the user's test project so it can be committed to a repo, shared across CI runners, and survives across runs. Out of~/.oculix/on purpose: this is a project asset, not a per-user setting.The cache key is a pair
(text, context)where context is the absolute bounds of the search region ("x,y,w,h"). This matters because the same text can live in two different sub-regions of the screen at the same time:notepad.findText("Save")andchrome.findText("Save")must not overwrite each other. Each pair gets its own cache slot.Before (cold scan on every call):
After (cached ROI first, fallback on miss):
And after
match = finder.next(), a coordinate translate so the Match returned by a cached-ROI scan is expressed in the same frame of reference as a full-region match:JSON shape:
{ "version": 1, "entries": { "Save": { "100,100,600,400": { "x":280, "y":315, "w":40, "h":18, "screenW":1920, "screenH":1080, "lastSeen":"2026-06-22T14:18:00Z", "hits":3 }, "700,100,800,600": { "x":750, "y":120, "w":40, "h":18, "screenW":1920, "screenH":1080, "lastSeen":"2026-06-22T14:18:00Z", "hits":5 } }, "Logout": { "0,0,1920,1080": { "x":1880, "y":12, "w":60, "h":20, "screenW":1920, "screenH":1080, "lastSeen":"2026-06-22T14:18:00Z", "hits":12 } } } }📊 Measurements
Live test on Windows 10, 1920×1080, full-HD, Tesseract 5.5.0 via Legerix, OculiX 3.0.5-SNAPSHOT (branch
claude/log-modes), Java 17 Temurin. The target word "Espagne" was displayed in a plain Notepad window. Wall-clock timing was captured aroundScreen().existsText("Espagne")withSystem.nanoTime().Test 1 — empty cache → 8 sequential runs (sparse screen):
putRun-2-to-8 average: 249 ms. Speedup vs Run 1: ×25.7.
Test 2 — stale cache (window moved between Test 1 and Test 2) → 8 sequential runs (dense screen):
invalidate→ fallback full +putRun-2-to-8 average: 203 ms. Speedup vs Run 1: ×63.4.
Key observation — the speedup grows with screen text density. Run 1 of Test 2 was twice as long as Run 1 of Test 1 (12881 ms vs 6404 ms) because the screen was visibly fuller of text at that moment, and Tesseract scales linearly with the amount of text it has to OCR. Cached-ROI calls are constant ~200 ms regardless of the rest of the screen, because they only OCR the tight ROI. Extrapolated asymptote: on a very dense dashboard screen where a full scan can climb to 30-40 s, the cached path remains ~200 ms — that's ×150 and growing.
The fallback overhead is not what you might fear. In Test 2, Run 1 took 12881 ms; ~250 ms of that was the failed cached-ROI scan (Tesseract on 87×19 pixels — negligible), ~5 ms was the JSON
invalidatewrite, the remaining ~12.6 s was the full scan that any non-cached path would have paid anyway. The proper fallback overhead is around 2 %, dominated entirely by the underlying full scan.🌿 Why this matters
This isn't a niche optimization. The dominant OculiX workflow in the wild is automation scripts that replay the same UI navigation against the same target screens — login pages, kiosk interfaces, AS/400 terminal sessions, embedded HMI dashboards. In all these cases, the same text is searched repeatedly at the same place. The current behavior pays the full OCR cost every single time. The PoC removes that cost from call 2 onward, deterministically, with zero new API surface and zero behavior change for callers.
It also extends a pattern OculiX has already adopted (lastSeen for images, #353) to a complementary dimension (lastSeen for text). The two caches coexist independently: image find uses the PNG chunk, text find uses the JSON sidecar. Same shape, same semantics, two different carriers because the two data structures don't have the same natural attachment point.
❓ Open design questions
tesseract-lastseen.jsontext-lastseen.json/oculix-text-cache.jsonuser.dir(JVM working dir)~/.oculix/(per-user) / configurablehitscounterputonly (cache write)findAllTextI'd love feedback on these specifically. The PoC works as-is, but the right answers here shape what 3.0.5 (or 3.0.6) actually ships.
🔗 Related
lastSeenfor image find (PNG ancillary chunk, the conceptual sibling of this proposal)findTextperf — cap upscale to 0.8 on large search regions (~×2 OCR speedup, complementary, not competing)🦎 — feedback welcome from anyone who has a heavy OCR workflow and would put this through its paces.
Beta Was this translation helpful? Give feedback.
All reactions