Repository navigation
Optimize OCR performance and align upstream behavior - #89
Conversation
* Rewrite gray masking * Extract hsv masker * Align hsv ranges with upstream
|
/pi Review this PR |
There was a problem hiding this comment.
Overall this is a solid, well-structured refactor: DeviceOcrScenario gives a single ownership story for the cropped Mat + extractor/masker, the layout JSON removes a lot of duplicated arithmetic, hsvVote/stableTopKBy are genuinely good perf wins with tests, and the assert → require change fixes a real correctness hole (asserts are no-ops on Android JVM, so CropBlackEdges.crop could previously produce a non-positive Rect).
I verified the layout mapping numerically against the old formulas and they match on 16:9 (both T1/T2), which is the important compatibility check. A few things need attention before merge, one of which looks like a real regression:
Blocking
getCropRect's new "keep at least two columns" guard silently truncates the crop. In the right/bottom loops,if (i <= left + 1) break/if (i <= top + 1) breakstop scanning before checking pixels, so the loop can't distinguish "we hit the guard" from "we found a non-black pixel" —right/bottomjust stop where the guard fires. Consider an image whose left two columns are the only non-black ones: the crop returns width 2 instead of width 10, i.e. a 5× data loss that then feeds ROI extraction for every T2/T1 field. The old code had no such guard and produced the correct full rect (relying onrequireto catch the degenerate case). If the intent is only "never return a degenerate rect", the guard should clamp after the scan, not terminate it.DeviceRoisMaskerAutoT1.gray()semantics changed — oldpfl()wasdilate(2×2)thenerode(1×1)(a net dilation), newgray()iserode(2×2)only, andpure/far/lost/maxRecallall route through it. That's a morphological change on the ROI masks that feedcountNonZero-based decisions — fine if deliberate (the T2 detector thresholds were also retuned,pflHsvLower/Upper245→248 and 30→40), but it isn't mentioned in the description and CI can't catch it. Worth a note in the PR body or a comment.T1Detector/T2Detectornow probe on the cropped image with layout-derived label rects, whereas the old code probed on the rawimgBgrwithroiRect[0] - PFL_LABEL_WIDTH * factor.DeviceRoisAutoSelector.select()is called withimgCroppedinDeviceOcrScenario.forImage, butT1Detector.confidence(imgBgr)buildsDeviceRoisAutoT1(imgBgr.width(), imgBgr.height())inside — so the rois are scaled to the cropped dimensions while the label rects in the JSON were authored on the full-frame design canvas. Please double-check thepure_label/far_label/lost_labelJSON values against a real screenshot: they're the only fields whose old position was expressed relative to a ROI rect rather than absolutely, so a mismatch here makes T1/T2 detection fail closed (→UNKNOWN→ exception fromforImageunless a fallback is passed, whichDeviceOcrHelperdoesn't do).
Should fix
DeviceOcrScenario.forImagedefaultsfallbacktonullandDeviceOcrHelperpasses nothing, so any screenshot where all detectors score 0 now throwsIllegalArgumentExceptionout of OCR. The previous code fell back to T2 unconditionally. This is a UX regression for margin-of-error images (and it's a plainExceptionfrom a helper, so it'll surface as a crash unless the caller catches it). Consider defaultingfallbacktoT2in the app call site, or catching at the helper boundary.DeviceOcr.ocrIntOrNullmaps empty OCR to0formaxRecall, but the field's type isInt?andPLAYER_MAX_RECALL/null is the meaningful "not present" value;0will trip the< 0/bound validators differently and round-trip intoPlayResult.maxRecall = 0. The doc comment also says "Returns null when the OCR result is empty" — correct — but then the?: 0at every call site contradicts the intent.maxRecallshould probably staynullon empty.DeviceRoisMasker.ratingClassVotes/clearStatusVotesdocument index order as "part of the persisted result contract", but the implementation only guarantees that by the ordering of theratingClassRanges/clearStatusRangeslists in each masker. A reordering of either list silently changes persistedclearStatusvalues. Since the contract is stated so strongly, an explicit mapping (index constants or a test assertinghsvVoteorder ==ArcaeaRatingClass.fromInt(i)) would be worth it. Also, both lists are byte-for-byte duplicated between the T1 and T2 maskers — hoisting them to the interface file next tohsvVotewould remove the drift risk.stableTopKByusestop.indexOfFirstinside the loop, so it is O(n·k) with k=10 — fine, but forallHashes(potentially hundreds of thousands of rows) theasSequence().filter{}plus a 10-element ArrayList is good; just note thatrequire(limit > 0)is the only guard, and callers passinglimitlarger than the population are fine.
Nits
DeviceLayoutTest.screenRectOnNarrowerScreenCentersVerticallyasserts248.0for y on 1024×768 — 768/720 and 1024/1280 both give factor 0.8, and1024/768 = 4:3 ≥ 16:9is false so it centers vertically; the value checks out, but the test name says "narrower" while the comment says "negative design x, so the mapped x clamps to 0" — two behaviors in one assertion. Splitting them would make failures readable.t2.jsoncontains raw-3.3333333333333712and646.6666666666666, which are float artifacts of the original pixel constants. Rounding to-10.0/3or expressing the design canvas in the T2 native units (e.g. design[1920, 1080], where these become-5,970,100…) would be much easier to review against the old formulas.CropBlackEdges.ktstill has an unused importCropBlackEdges.Companion.cropOrOriginalremoved in the diff but the KDoc references still resolve; fine.ScreenshotDetectswitched.toInt()→roundToInt()— that changes the probe rect by up to 1px for every call, including thew/hused in the ratio denominator; harmless but it's an unrelated behavior change bundled in.DeviceOcrScenario.close()releasesimgCroppedbut theDeviceRoisExtractorholds submat views into it; any caller that keeps aDeviceOcrResult-adjacentMatfrom the extractor will use freed memory. The KDoc says so, butOcrPerformanceBenchmark.extractRoisclones correctly while nothing enforces this at the type level — opening the extractor publicly (val extractor) is the risk; consider a helperwithRois { }on the scenario instead.
The new StableTopKTest property test against sortedBy().take(10) is exactly the right way to pin that optimization. Let me know if you'd like me to push a patch for the getCropRect guard (item 1) and the maxRecall null handling (item 5).
|
I'll start by fetching the PR details and diff.Let me examine the actual files to verify contract consistency and check for issues the diff alone doesn't reveal.The working tree is at master (pre-PR). Let me check the PR branch and remaining referenced sources.Let me check the PR branch source to verify the new code (resources location, tests, masker semantics).Let me check the CI status and test configuration, and verify the test source set actually runs.Let me verify a few behaviors numerically — the crop-edge loop, layout mapping vs. old formulas, and the screenshot-detect rounding change.Let me check the remaining call sites (benchmark's use of SummaryVerdict: strong refactor, but one real regression to fix before merge. What's good:
Blocking issues raised:
Also flagged: View action run | Model: deepseek/deepseek-flash | Time: 2m 4s | Tokens: 520.8K | Cost: $0.03 | Pi SDK v1.5.2 | Action v2.30.2 |
This reverts commit 830a9f5.
|
/pi "DeviceRoisMaskerAutoT1.gray() semantics changed" "T1Detector/T2Detector now probe on the cropped image" are intentional. "DeviceOcr.ocrIntOrNull maps empty OCR to 0 for maxRecall" is now reverted. Update your findings. |
|
I'll examine the PR and its current state to update the findings.Let me check the current state of the code for the reverted Updated findings for PR #89Reviewed at head Withdrawn — items now resolved or accepted as intentional1. maxRecall = ocrIntOrNull(extractor.maxRecall), // nullable, no default
pure = ocrIntOrNull(extractor.pure) ?: 0, // fields where 0 is saneI verified the 2. 3. T1/T2 detectors probing the cropped image — withdrawn as a blocker, intentional. The risk I was pointing at was narrower than I stated: the concern is not that detection runs on the cropped image, but that the Still open1. Applying the minimum after the scan keeps both the intent and the old behavior: val croppedW = max(2, right - left).coerceAtMost(width - left)
return Rect(left, top, croppedW, bottom - top)( 2. 3. Vote-index contract is only implicit — still stands, low priority. The KDoc on VerdictNet change from my previous review: two blockers and one behavioral objection drop out — View action run | Model: deepseek/deepseek-flash | Time: 1m 26s | Tokens: 238.3K | Cost: $0.01 | Pi SDK v1.5.2 | Action v2.30.2-develop (develop @ c266065) |
|
/pi Re-assess "getCropRect's new 'keep at least two columns' guard silently truncates the crop", and "DeviceOcrScenario.forImage defaults fallback to null" is also intentional |
There was a problem hiding this comment.
Re-assessment of the two remaining findings
I re-checked both against the code and against core-ocr 0.1.0 (the reference in the PR description). Both of my earlier findings were wrong, and I'm withdrawing them. The head is in good shape — the only thing I'd still add is one test.
1. CropBlackEdges.getCropRect "keep at least two columns" guard — withdrawn, my analysis was wrong
My claim was: a 10px-wide image whose only non-black columns are 0–1 now returns width == 2 instead of 10. That input is impossible. The left loop advances left while a column is black, so if column 0 is non-black the loop breaks immediately with left == 0 — there is no way to reach the right loop with left >= 2 and columns 0–1 non-black. I built the example backwards from the guard rather than from the loop.
I enumerated the actual behavior instead — every black/white column pattern up to width 14, comparing the new code against the pre-PR master code:
- Patterns where the new width is narrower than the old width: 0.
- Every differing pattern falls into exactly two groups:
(old_width ≤ 1 → new_width 2)and(old_width ≤ 0 → new_width 0).
The full set of differing (old_width, new_width) pairs is:
(-13,0) (-12,0) ... (-2,0) (1,2)
So the guard fires only where the old code produced a width of 1 or a negative width — exactly the degenerate cases right > left is there to reject. On any image with a crop of width ≥ 2 it is a no-op, and it can never truncate a usable crop. The require still catches the width-0 outcome, so the "fully black" path stays loud.
It also mirrors upstream exactly. The Python is:
if i <= left + 1 or not cls.is_black_edge(column, black_threshold):
breakThe Kotlin hoists the first disjunct into a separate if i <= left + 1) break — and I verified those two forms are equivalent over all patterns up to width 10 (0 mismatches), because both are pure short-circuit disjunctions. So the i == left iteration I said was "skipped" is skipped in upstream too; the port is faithful, not a drift.
The right/bottom guards are symmetric, so this covers all four loops.
One genuine issue in this area, and it's not in the PR: the master version this replaces loops for (i in 0..width) and calls submat(Rect(i, 0, 1, height)), which reads column width and throws IndexOutOfBounds on every image whose left edge is fully black — i.e. the crop path was largely broken before. The PR's 0 until width / width - 1 downTo 0 form fixes that, and assert → require fixes the silent no-op. Both are strict improvements.
2. DeviceOcrScenario.forImage defaulting fallback to null — withdrawn as intentional
Confirmed this matches upstream, which has the same default and raises on UNKNOWN rather than substituting T2:
def for_image(cls, img_bgr, crnn_provider, image_id_provider, *, fallback=None):
...
if result is DeviceRoisAutoSelectorResult.UNKNOWN:
raise ValueError("cannot determine device rois type; pass an explicit fallback")My concern assumed "every detector returned 0.0" previously meant "use T2". It didn't — it meant UNKNOWN, and createRoisAndMasker has no UNKNOWN branch in either implementation, so the old Kotlin path would have thrown there anyway (IllegalArgumentException("No rois/masker pair for UNKNOWN"), the same message the PR keeps). Falling back to T2 on a 0.0-everywhere result would be guessing a layout from zero evidence, which is worse than failing loudly on a screenshot the app doesn't recognise. fallback stays available for callers that genuinely want that guess.
The KDoc is accurate as written: the throw is conditional on "fallback is null", and the require message tells the caller how to opt in. I'd only note that DeviceOcrHelper/OcrPerformanceBenchmark not passing a fallback is the intended posture, not an oversight — worth a one-line comment at the DeviceOcrHelper call site so nobody "fixes" it later by passing T2.
Suggestion: pin the crop loop with a test
Nothing currently covers getCropRect (the PR's only new tests are StableTopKTest and DeviceLayoutTest). The guard is load-bearing for the upstream-equivalence claim above and, as this exchange demonstrates, easy to misread. A table-driven test over a handful of synthetic Mats would lock it in:
- all-black image → throws
IllegalArgumentException(viacrop),cropOrOriginalreturns the original; - black borders on all four sides → rect equals the known inner box;
- leading black + a single non-black column →
width == 2(the clamp); - one fully-black row at top and bottom → same for height;
- no black edges at all → full-size rect.
That last one also guards the master off-by-one that 0 until width fixed.
Net verdict
With these two withdrawn, every objection I raised across the three reviews is resolved or accepted as intentional. maxRecall nullability, the gray() deduplication, cropped-image detection, the crop guard, and the null fallback are all correct and all match upstream 0.1.0. No blocking changes from me. The crop test is the one thing I'd add, and the vote-index ordering note from the previous review still stands as a low-priority nicety.
|
I'll start by fetching the PR thread and diff to understand the context.I'm on master. Let me check out the PR head and examine the two files in question.Now let me look at the upstream reference, the selector, and the callers to properly reassess both points.Both callers use the default 1.
|
core-ocr behavior alignments:
ocr/layouts/device/DeviceRoisgains high score, pure shiny, early/late and PFL label windowsDeviceOcrScenario, keeping the steps consistentCropBlackEdgesscans probing out-of-range pixelsPerformance optimizations:
DeviceRoisMaskerAutoT1Other optimizations:
.use {}See also: https://github.com/ArcaeaOffline/core-ocr/tree/0.1.0