diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index aa85bb9..d4e83fd 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -3,6 +3,7 @@ name: package on: push: branches: + - main - cursor/mathscrap-packaging-* pull_request: branches: diff --git a/README.md b/README.md index 03b01cc..a8cd253 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,10 @@ log_2(8) log_{2}(8) log₂(8) log2(8) log(2, 8) `log(2)` and `log 2` stay log₁₀(2). They are not “base 2”. `lg` does not take a base glued to the name: `lg2(3)` is lg(2)·3. The base must be greater than 0 and not 1, and the argument must be greater than 0. Otherwise there is no real value, and the plot does not draw a fake segment across a gap or an asymptote. +解三角形时还会给出 X(91) 和 X(92),分别是 X(47) 和 X(48) 的等角共轭。直角三角形里没有 X(92)。 + +Solving a triangle also reports X(91) and X(92), the isogonal conjugates of X(47) and X(48). A right triangle has no X(92). + ## 快速开始 ## Quick start @@ -50,7 +54,7 @@ The dev server is `http://127.0.0.1:47321`. `npm run preview` serves the product | `npm run dev` | 本机开发服务器,含 MCP 桥和 AI 转发。 / Local dev server, including the MCP bridge and the AI forwarder. | | `npm run build` | 类型检查并构建 `dist/`。 / Typecheck and build `dist/`. | | `npm run lint` | oxlint,有警告也失败。 / oxlint. Warnings fail the command. | -| `npm test:pack` 或 `npm run test:i18n` | 打包元数据,以及中英词条、语言记忆。 / Packaging metadata, plus Chinese/English keys and saved language. | +| `npm run test:pack` 或 `npm run test:i18n` | 打包元数据,以及中英词条、语言记忆。 / Packaging metadata, plus Chinese/English keys and saved language. | | `npm run smoke` | 用真实 WASM 检查数值、对数和图上有没有点。 / Real WASM: numbers, logarithms, and whether a curve has points. | | `npm run smoke:ocr` | 手写预处理和假的编码器/解码器,不加载 ONNX。 / Ink preprocess and a fake encoder/decoder. No ONNX. | | `npm run ocr:live` | 本机用仓库里的 CoMER INT8 读几笔样例。 / Run the bundled CoMER INT8 on a few sample strokes. | @@ -106,6 +110,10 @@ Recognition is on by default. About a second after the ink settles, a text gate The model is CoMER (ECCV 2022) INT8 ONNX. The weights are distributed by [ink-on](https://github.com/kimseungdae/ink-on) under Apache-2.0. It can read some clear short formulas. It is not a complete recognizer for arbitrary handwriting: a miss leaves the ink, and it does not substitute `y = x`. A lone `sigma` is the symbol. Sums and integrals stay on the paper and do not open the coordinate view by themselves. +模型把单独的 σ 读成 `0`、`Y` 或 `u`,而且笔迹本身像 σ 时,才改写成 `sigma`。公式、闭合的 0、u 形的碗和 Y 的分叉不会被改。求和号上下的两道横杠不当成字母上的箭头。 + +A lone σ that the model reads as `0`, `Y`, or `u` is rewritten to `sigma` only when the ink itself looks like σ. A formula, a closed 0, a u-shaped bowl, and a Y fork are left alone. The two bars of a summation are not treated as an arrow over a letter. + | 文件 File | 内容 / Contents | | --- | --- | | `public/models/comer/encoder_int8.onnx` | 编码器,约 3.4 MB。 / Encoder, about 3.4 MB. | @@ -168,9 +176,9 @@ iOS 最低系统是 16.4。能装到别人手机上的 IPA 需要 Apple Develope The iOS minimum is 16.4. An IPA that installs on someone else's phone needs an Apple Developer certificate and profile: `IOS_CERTIFICATE`, `IOS_CERTIFICATE_PASSWORD`, `IOS_MOBILE_PROVISION`, or `APPLE_API_ISSUER`, `APPLE_API_KEY`, `APPLE_API_KEY_PATH`. This repository does not enroll a developer account, does not upload to TestFlight or the App Store, and has not been installed on an iPhone. -`.github/workflows/package.yml` 里的 `test` 作业在 `ubuntu-latest` 上跑 `npm run lint`、`npm run test:rust`、`npm run smoke`、`npm run smoke:ocr`、`npm run smoke:ai`、`npm run test:pack`,然后拉起 dev 服务器,依次跑 `npm run e2e:sheet` 和 `npm run e2e`(逐条作图)。失败时上传 `e2e-shots`、`e2e-plots` 和 `dev-server.log`。这些步骤不需要密钥。推到 `cursor/mathscrap-packaging-*` 时,测试通过后同一 workflow 再构建网页、debug APK 和未签名的 Windows 安装程序。指向 `main` 的 pull request 只跑 `test`。`.github/workflows/package-ios.yml` 只在手动启动时使用 macOS runner。这些 workflow 不创建 GitHub Release。本文件不提供下载地址。 +`.github/workflows/package.yml` 在推送到 `main` 或 `cursor/mathscrap-packaging-*` 时,以及指向 `main` 的 pull request 上运行。`test` 作业没有额外的跳过条件:这三种情况都会在 `ubuntu-latest` 上跑 `npm run lint`、`npm run test:rust`、`npm run smoke`、`npm run smoke:ocr`、`npm run smoke:ai`、`npm run test:pack`,然后拉起 dev 服务器,依次跑 `npm run e2e:sheet` 和 `npm run e2e`(逐条作图)。失败时上传 `e2e-shots`、`e2e-plots` 和 `dev-server.log`。这些步骤不需要密钥。网页、debug APK 和未签名 Windows 安装程序仍只在 push 上、并且 `test` 通过之后构建;pull request 不打安装包。`.github/workflows/package-ios.yml` 只在手动启动时使用 macOS runner。这些 workflow 不创建 GitHub Release。本文件不提供下载地址。 -The `test` job in `.github/workflows/package.yml` runs on `ubuntu-latest`: `npm run lint`, `npm run test:rust`, `npm run smoke`, `npm run smoke:ocr`, `npm run smoke:ai`, `npm run test:pack`, then starts the dev server and runs `npm run e2e:sheet` and `npm run e2e` (one plot per formula). On failure it uploads `e2e-shots`, `e2e-plots`, and `dev-server.log`. Those steps need no secrets. A push to `cursor/mathscrap-packaging-*` builds the web bundle, a debug APK, and an unsigned Windows installer in the same workflow after that job passes. A pull request into `main` runs only `test`. `.github/workflows/package-ios.yml` uses a macOS runner only when started by hand. These workflows do not create a GitHub Release. This file does not publish a download URL. +`.github/workflows/package.yml` runs on a push to `main` or `cursor/mathscrap-packaging-*`, and on a pull request into `main`. The `test` job has no extra skip condition, so all three of those runs execute it on `ubuntu-latest`: `npm run lint`, `npm run test:rust`, `npm run smoke`, `npm run smoke:ocr`, `npm run smoke:ai`, `npm run test:pack`, then a dev server, then `npm run e2e:sheet` and `npm run e2e` (one plot per formula). On failure it uploads `e2e-shots`, `e2e-plots`, and `dev-server.log`. Those steps need no secrets. The web bundle, debug APK, and unsigned Windows installer still build only on a push, and only after `test` passes. A pull request does not build installers. `.github/workflows/package-ios.yml` uses a macOS runner only when started by hand. These workflows do not create a GitHub Release. This file does not publish a download URL. ## 许可和作者 diff --git a/scripts/ocr-smoke.ts b/scripts/ocr-smoke.ts index 1b546ad..65f313c 100644 --- a/scripts/ocr-smoke.ts +++ b/scripts/ocr-smoke.ts @@ -14,11 +14,12 @@ import { readFileSync } from 'node:fs' import type { Stroke } from '../src/board/types.ts' import type { ComerSession, ComerTensor } from '../src/ocr/comer/decode.ts' import { bindInk, findOverArrow } from '../src/ocr/comer/bind.ts' -import { classifyInk, looksLikeIsolatedSigma } from '../src/ocr/gate.ts' +import { classifyInk, looksLikeIsolatedSigma, mathCues } from '../src/ocr/gate.ts' import { agreeConfidence, recognizeStrokes } from '../src/ocr/comer/engine.ts' import { OCR_ACCEPT } from '../src/ocr/threshold.ts' import { isInkMeaningful, preprocessStrokes, resamplePoints, type InkStroke } from '../src/ocr/comer/preprocess.ts' -import { arrowLetter, inkFixtures, isolatedSigma, proseNote, sumBar, sumGlyph } from '../src/ocr/comer/samples.ts' +import { arrowLetter, bowlU, closedZero, forkY, inkFixtures, isolatedSigma, proseNote, sigmaHook, sigmaLoop, sumBar, sumGlyph } from '../src/ocr/comer/samples.ts' +import { looksLikeIsolatedSigma as sigmaGlyph, repairIsolatedSigma } from '../src/ocr/comer/sigma.ts' import { tokensToSource, vocabFromJson } from '../src/ocr/comer/source.ts' import { filterAbsurd, hasParenGeometry, recognizeInk } from '../src/ocr/inkShape.ts' @@ -443,6 +444,13 @@ const boundVec = bindInk(arrowed, 'a') assert.match(boundVec, /^\\vec\{a\} = \([0-9.-]+, [0-9.-]+\)$/) assert.equal(bindInk(arrowed, 'a = (1, 2, 2)'), boundVec) assert.doesNotMatch(boundVec, /1,\s*2,\s*2/) +assert.equal(findOverArrow(sumBar()), null, 'Σ bars are not an over-arrow') +assert.equal(bindInk(sumBar(), 'a'), '') +assert.equal(bindInk(sumBar(), '\\vec{a}'), '') +assert.equal(bindInk(sumBar(), '\\sum'), '\\sum') +assert.equal(bindInk(sumBar(), '\\sum_{i = 1}^{n} i'), '\\sum_{i = 1}^{n} i') +assert.equal(agreeConfidence(sumBar(), '\\vec{a} = (1, 0)', 0.48), 0.48) +assert.equal(agreeConfidence(sumBar(), '\\sum', 0.45), 0.78) assert.equal(bindInk(arrowed, '\\sum'), '\\sum') assert.equal(bindInk(arrowed, '\\sum_{i = 1}^{n} i'), '\\sum_{i = 1}^{n} i') assert.equal(bindInk(arrowed, 'y = 2'), 'y = 2') @@ -501,6 +509,44 @@ assert.equal(plain[0]?.text, 'y = 2') assert.ok((plain[0]?.confidence ?? 1) < OCR_ACCEPT, `ordinary formula is not lifted (${plain[0]?.confidence})`) console.log('ocr agreement ok', { arrow: agreed[0], sum: sumAgreed[0], faint: faintArrow[0]?.confidence, plain: plain[0]?.confidence }) +const loop = sigmaLoop() +const hook = sigmaHook() +const zero = closedZero() +const bowl = bowlU() +const fork = forkY() +assert.equal(sigmaGlyph(loop), true) +assert.equal(sigmaGlyph(hook), true) +assert.equal(sigmaGlyph(zero), false, 'a closed 0 is not σ') +assert.equal(sigmaGlyph(bowl), false, 'a u bowl is not σ') +assert.equal(sigmaGlyph(fork), false, 'a Y fork is not σ') +assert.equal(sigmaGlyph(proseNote()), false) +assert.equal(sigmaGlyph(y2strokes), false) +assert.equal(repairIsolatedSigma(loop, '0'), 'sigma') +assert.equal(repairIsolatedSigma(loop, 'Y'), 'sigma') +assert.equal(repairIsolatedSigma(hook, 'u'), 'sigma') +assert.equal(repairIsolatedSigma(loop, 'y = 0'), null, 'a formula is not rewritten') +assert.equal(repairIsolatedSigma(zero, '0'), null) +assert.equal(repairIsolatedSigma(bowl, 'u'), null) +assert.equal(repairIsolatedSigma(fork, 'Y'), null) +assert.equal(repairIsolatedSigma(proseNote(), '0'), null) +for (const [ink, token] of [[loop, '0'], [hook, 'u'], [loop, 'Y']] as const) { + const read = await recognizeStrokes(ink, scripted([id(token)], weak), scripted([id(token)], weak), vocab, 1) + assert.equal(read[0]?.text, 'sigma', `${token} on σ → ${JSON.stringify(read)}`) + assert.ok((read[0]?.confidence ?? 0) >= OCR_ACCEPT, `${token} on σ confidence ${read[0]?.confidence}`) +} +const faintZero = await recognizeStrokes(loop, scripted([id('0')], faint), scripted([id('0')], faint), vocab, 1) +assert.notEqual(faintZero[0]?.text, 'sigma', 'a faint 0 is not promoted to σ') +assert.ok((faintZero[0]?.confidence ?? 1) < OCR_ACCEPT) +const realZero = await recognizeStrokes(zero, scripted([id('0')], 6), scripted([id('0')], 6), vocab, 1) +assert.equal(realZero[0]?.text, '0', JSON.stringify(realZero)) +const realU = await recognizeStrokes(bowl, scripted([id('u')], 6), scripted([id('u')], 6), vocab, 1) +assert.ok(!realU.some((c) => c.text === 'sigma'), JSON.stringify(realU)) +const realY = await recognizeStrokes(fork, scripted([id('Y')], 6), scripted([id('Y')], 6), vocab, 1) +assert.ok(!realY.some((c) => c.text === 'sigma'), JSON.stringify(realY)) +const formulaZero = await recognizeStrokes(loop, scripted([id('y'), id('='), id('0')], weak), scripted([id('y'), id('='), id('0')], weak), vocab, 1) +assert.equal(formulaZero[0]?.text, 'y = 0', JSON.stringify(formulaZero)) +console.log('ocr sigma glyph ok') + function sumLikeSigma(): InkStroke[] { return [ { @@ -523,9 +569,14 @@ function sumLikeSigma(): InkStroke[] { assert.equal(classifyInk(y2strokes), 'formula') assert.equal(classifyInk(arrowLetter()), 'formula') assert.equal(classifyInk(proseNote()), 'text', 'a line of letters is not a formula') +assert.equal(classifyInk(sigmaLoop()), 'formula', 'an isolated σ still reaches the model') +assert.equal(classifyInk(closedZero()), 'formula') assert.equal(classifyInk(sumLikeSigma()), 'formula', 'Σ with limits is a formula') assert.equal(classifyInk(sumGlyph()), 'formula') assert.equal(classifyInk(sumBar()), 'formula') +assert.ok(mathCues(sumBar()).includes('sum'), 'barred Σ is a sum, not only an arrow') +assert.ok(!mathCues(sumBar()).includes('arrow')) +assert.ok(mathCues(arrowLetter()).includes('arrow')) assert.equal(classifyInk(inkFixtures(3).find((s) => s.name === 'frac')!.strokes), 'formula') console.log('ocr gate ok', { prose: classifyInk(proseNote()), formula: classifyInk(y2strokes) }) assert.equal(tokensToSource(['\\ln', '(', 'x', ')']).replace(/\s+/g, ''), 'ln(x)') diff --git a/scripts/plot-cases.ts b/scripts/plot-cases.ts index 277afce..5e59d2b 100644 --- a/scripts/plot-cases.ts +++ b/scripts/plot-cases.ts @@ -53,7 +53,8 @@ export const cases: [string, string, Expect][] = [ ['直线过两点', 'y = kx + b, 过(1, 3), 过(2, 5)', { solve: { k: 2, b: 1 } }], // ---- triangles / coordinate geometry ---- ['解三角形 SAS', '△ABC: a = 5, b = 7, C = 60°', { dim: 2, facts: ['\\sqrt{39}'], minFacts: 10 }], - ['解三角形 SSS', '△ABC: a=3, b=4, c=5', { dim: 2, facts: ['90', 'S_{\\text{海伦}}', '|OI|', 'r_a=', 'AF_{c}', '\\Pi_{\\text{Ceva}}', '\\Pi_{\\text{Menelaus}}', '\\text{Stewart}_{m_', 'ID_{a}', '\\mathcal{A}_', 'OG:GH', 'R_{\\text{九点}}', '|NI|', '|NI_a|', 'Sp', 'Na', 'IG:GNa'], minFacts: 45 }], + ['解三角形 SSS', '△ABC: a=3, b=4, c=5', { dim: 2, facts: ['90', 'S_{\\text{海伦}}', '|OI|', 'r_a=', 'AF_{c}', '\\Pi_{\\text{Ceva}}', '\\Pi_{\\text{Menelaus}}', '\\text{Stewart}_{m_', 'ID_{a}', '\\mathcal{A}_', 'OG:GH', 'R_{\\text{九点}}', '|NI|', '|NI_a|', 'Sp', 'Na', 'IG:GNa', 'Ge=', 'K=', '\\omega', '\\Omega', 'L=', 'Mi', 'Sc', 'Be=', 'H_a', 'X_a', 'r_{\\text{Sp}}', 'T_a', 'S_{\\text{Steiner}}', 'M_a', 'Ex=', 'S_{1}', 'St=', 'Ty=', 'N_1', 'Py', 'V_{\\text{out}}', 'X_{39}', 'Hf_0', 'Km', 'Ap', 'F_1', 'Fe', 'Ed', 'Ip', 'Mo', 'S_{+}', 'S_{-}', 'X_{30}', 'X_{57}', 'X_{58}', 'X_{84}', 'X_{12}=', 'X_{59}=', 'X_{60}=', 'Gr=', 'X_{81}=', 'X_{42}=', 'X_{65}=', 'X_{76}=', 'X_{86}=', 'X_{31}=', 'X_{32}=', 'X_{69}=', 'X_{36}', 'X_{41}', 'X_{48}', 'X_{20}=', 'X_{23}=', 'X_{35}=', 'X_{38}=', 'X_{61}=', 'X_{62}=', 'X_{63}=', 'X_{43}=', 'X_{44}=', 'X_{45}=', 'X_{46}=', 'X_{47}=', 'X_{49}=', 'X_{50}=', 'X_{79}=', 'X_{80}=', 'X_{51}=', 'X_{91}=', 'X_{70}=', 'X_{71}=', 'X_{73}='], minFacts: 170 }], + ['欧拉反射点(锐角)', '△ABC: a=6, b=7, c=8', { dim: 2, facts: ['E_r', 'E_r \\in (O)', 'Ko', 'Cl', 'Yff', 'X_{25}', 'O(H_a', 'X_{24}', 'X_{64}', 'X_{26}', 'X_{53}', 'X_{27}', 'X_{29}', 'X_{92}', 'X_{28}'], minFacts: 10 }], ['解三角形 SSA 两解', '△ABC: a = 6, b = 8, A = 40°', { dim: 2 }], ['坐标 + 垂直', 'A(0,0), B(4,0), C(1,t)\nAC ⊥ BC', { dim: 2, solve: { t: Math.sqrt(3) }, inView: [[0, 0], [4, 0]] }], ['平行四边形', 'A(0,0), B(4,0), C(5,3), D(1,3)', { dim: 2, facts: ['平行四边形'], inView: [[5, 3]] }], diff --git a/src/ocr/comer/bind.ts b/src/ocr/comer/bind.ts index 228867c..6bab959 100644 --- a/src/ocr/comer/bind.ts +++ b/src/ocr/comer/bind.ts @@ -41,7 +41,38 @@ function arrowReading(squashed: string): boolean { } /** A flat stroke sitting above the rest of the ink, wider than the letter. */ +/** + * ∑ drawn as a top bar, a zigzag, and a bottom bar. The top bar is flat and + * high, so it used to count as an over-arrow and a letter reading became a vector. + */ +export function looksLikeSumBars(strokes: InkStroke[]): boolean { + const drawn = strokes.filter((s) => s.points.length >= 2) + if (drawn.length < 3) return false + const all = bounds(drawn.flatMap((s) => s.points)) + if (all.h < 24 || all.w < 16) return false + const flats = drawn.filter((s) => { + const b = bounds(s.points) + return b.w > all.w * 0.55 && b.h < Math.max(8, b.w * 0.35) + }) + if (flats.length < 2) return false + const boxes = flats.map((s) => bounds(s.points)).sort((a, b) => a.cy - b.cy) + const top = boxes[0] + const bot = boxes[boxes.length - 1] + if (top.cy > all.y0 + all.h * 0.4) return false + if (bot.cy < all.y0 + all.h * 0.6) return false + const gap = bot.y0 - top.y1 + if (gap < all.h * 0.35) return false + const overlap = Math.min(top.x1, bot.x1) - Math.max(top.x0, bot.x0) + if (overlap < Math.min(top.w, bot.w) * 0.5) return false + return drawn.some((s) => { + if (flats.includes(s)) return false + const b = bounds(s.points) + return b.h > gap * 0.45 && b.y0 < top.y1 + gap * 0.35 && b.y1 > bot.y0 - gap * 0.35 + }) +} + export function findOverArrow(strokes: InkStroke[]): { x: number; y: number } | null { + if (looksLikeSumBars(strokes)) return null const drawn = strokes.filter((s) => s.points.length >= 2) if (drawn.length < 2) return null const all = bounds(drawn.flatMap((s) => s.points)) diff --git a/src/ocr/comer/engine.ts b/src/ocr/comer/engine.ts index 734fbaf..2f9a038 100644 --- a/src/ocr/comer/engine.ts +++ b/src/ocr/comer/engine.ts @@ -2,6 +2,7 @@ import { bindInk, findOverArrow } from './bind.ts' import { decodeComer, type ComerSession, type ComerTensor } from './decode.ts' import { looksLikeIsolatedSigma } from '../gate.ts' import { isInkMeaningful, preprocessStrokes, type InkStroke } from './preprocess.ts' +import { repairIsolatedSigma } from './sigma.ts' import { decodeTokens, tokensToSource, type Vocab } from './source.ts' export interface OcrCandidate { @@ -64,14 +65,19 @@ export async function recognizeStrokes(strokes: InkStroke[], encoder: ComerSessi const out: OcrCandidate[] = [] const seen = new Set() for (const hit of hits) { - const latex = decodeTokens(hit.ids, vocab).join(' ') - const text = bindInk(strokes, tokensToSource(decodeTokens(hit.ids, vocab))) - if (!text || seen.has(text)) continue - seen.add(text) + const decoded = decodeTokens(hit.ids, vocab) + const latex = decoded.join(' ') + const source = tokensToSource(decoded) const n = Math.max(1, hit.ids.length) const mean = hit.logProb / n const model = Math.exp(mean) if (!Number.isFinite(model) || model < 0.2) continue + // A mid-or-better read of 0 / Y / u is σ only when the ink has that glyph. + // Fainter than AGREE_AT stays the model's text, so a weak guess is not promoted. + const repaired = model >= AGREE_AT ? repairIsolatedSigma(strokes, source) : null + const text = repaired ?? bindInk(strokes, source) + if (!text || seen.has(text)) continue + seen.add(text) out.push({ text, confidence: agreeConfidence(strokes, text, model), latex }) } out.sort((a, b) => b.confidence - a.confidence || a.text.localeCompare(b.text)) diff --git a/src/ocr/comer/samples.ts b/src/ocr/comer/samples.ts index 9af68dc..8a273a6 100644 --- a/src/ocr/comer/samples.ts +++ b/src/ocr/comer/samples.ts @@ -306,6 +306,62 @@ export function shiftInk(strokes: InkStroke[], dx: number, dy: number): InkStrok return strokes.map((s) => ({ points: s.points.map((p) => ({ x: p.x + dx, y: p.y + dy })), lineWidth: s.lineWidth })) } +/** Isolated σ: an open loop and a short tail. The model often reads this as 0. */ +export function sigmaLoop(width = 3): InkStroke[] { + const pts: StrokePoint[] = [] + const cx = 48 + const cy = 56 + const rx = 26 + const ry = 30 + const a0 = -0.2 + const a1 = Math.PI * 1.7 + const n = 42 + for (let i = 0; i <= n; i++) { + const t = a0 + ((a1 - a0) * i) / n + pts.push({ x: cx + rx * Math.cos(t), y: cy + ry * Math.sin(t) }) + } + const last = pts[pts.length - 1] + pts.push({ x: last.x + 10, y: last.y - 2 }) + pts.push({ x: last.x + 22, y: last.y + 6 }) + return [{ points: pts, lineWidth: width }] +} + +/** Schoolbook σ: a bowl whose end hooks back inside. Often read as u. */ +export function sigmaHook(width = 3): InkStroke[] { + return [through([[78, 26], [50, 16], [24, 32], [16, 58], [32, 84], [60, 90], [82, 68], [76, 48], [52, 44]], width)] +} + +/** A closed oval. This is a zero, not a sigma. */ +export function closedZero(width = 3): InkStroke[] { + return [circle(48, 56, 28, width, 40)] +} + +/** An open bowl with both ends on top. This is a u, not a sigma. */ +export function bowlU(width = 3): InkStroke[] { + return [through([[18, 22], [16, 50], [30, 76], [50, 86], [72, 74], [82, 48], [80, 22]], width)] +} + +/** Two strokes meeting in a fork. This is a Y, not a sigma. */ +export function forkY(width = 3): InkStroke[] { + return [through([[16, 14], [48, 52], [80, 14]], width), through([[48, 52], [50, 98]], width)] +} + +function through(corners: number[][], lineWidth: number): InkStroke { + const points: StrokePoint[] = [] + for (let i = 1; i < corners.length; i++) { + const [x0, y0] = corners[i - 1] + const [x1, y1] = corners[i] + const n = Math.max(6, Math.ceil(Math.hypot(x1 - x0, y1 - y0) / 3)) + for (let k = 0; k < n; k++) { + const t = k / n + points.push({ x: x0 + (x1 - x0) * t, y: y0 + (y1 - y0) * t }) + } + } + const last = corners[corners.length - 1] + points.push({ x: last[0], y: last[1] }) + return { points, lineWidth } +} + function line(pts: number[][], lineWidth: number): InkStroke { return { points: pts.map(([x, y]) => ({ x, y })), lineWidth } } diff --git a/src/ocr/comer/sigma.ts b/src/ocr/comer/sigma.ts new file mode 100644 index 0000000..d758210 --- /dev/null +++ b/src/ocr/comer/sigma.ts @@ -0,0 +1,223 @@ +import type { InkStroke, StrokePoint } from './preprocess.ts' + +/** + * CoMER often reads an isolated handwritten σ as 0, Y, or u. + * Only those whole-glyph readings are rewritten, and only when the ink + * itself has the loop-plus-tail of σ. A sentence, a real zero, a fork, + * or a bowl is left alone — this does not invent an equation. + */ +const MISREAD = /^(?:0|O|o|Y|y|u|U)$/ + +export function repairIsolatedSigma(strokes: InkStroke[], modelText: string): string | null { + const text = modelText.replace(/\s+/g, '') + if (!MISREAD.test(text)) return null + if (!looksLikeIsolatedSigma(strokes)) return null + return 'sigma' +} + +export function looksLikeIsolatedSigma(strokes: InkStroke[]): boolean { + const drawn = strokes.filter((s) => s.points.length >= 4 && arcLength(s.points) >= 22) + if (drawn.length !== 1) return false + const pts = drawn[0].points + const b = bounds(pts) + if (b.w < 16 || b.h < 16) return false + const aspect = b.h / b.w + if (aspect < 0.5 || aspect > 2.15) return false + const diag = Math.hypot(b.w, b.h) + const len = arcLength(pts) + if (len < diag * 1.65 || len > diag * 5.5) return false + const turn = totalTurn(pts) + if (turn < 3.5 || turn > 8.8) return false + + const cum = cumulative(pts) + const endGap = dist(pts[0], pts[pts.length - 1]) + const approach = nearestApproach(pts, cum) + const body = centroid(pts, cum, 0, 1) + const cv = radiusCv(pts, cum, body, 0, 1) + const tail = hasOutwardTail(pts, cum) + + // A zero meets itself and stays inside its own bowl. σ leaves a gap or a tail. + const closedOval = endGap < diag * 0.1 && cv < 0.25 && !tail + if (closedOval) return false + if (plainBowl(pts, b, endGap, turn, approach, diag)) return false + if (topFork(pts, b)) return false + + const hook = terminalHook(pts, cum) + const loop = approach < diag * 0.34 && endGap > diag * 0.08 + return hook || (loop && (tail || endGap > diag * 0.2)) +} + +function plainBowl(pts: StrokePoint[], b: ReturnType, endGap: number, turn: number, approach: number, diag: number): boolean { + const y0 = (pts[0].y - b.y0) / b.h + const y1 = (pts[pts.length - 1].y - b.y0) / b.h + return y0 < 0.3 && y1 < 0.3 && endGap > b.w * 0.4 && turn < 4.5 && approach > diag * 0.32 +} + +/** A sharp peak in the upper half, with arms leaving both ways: a Y, not σ. */ +function topFork(pts: StrokePoint[], b: ReturnType): boolean { + for (let i = 1; i < pts.length - 1; i++) { + const a = pts[i - 1] + const c = pts[i] + const d = pts[i + 1] + const v1 = unit(a, c) + const v2 = unit(c, d) + if (!v1 || !v2) continue + const dot = v1.x * v2.x + v1.y * v2.y + if (dot > 0.35) continue + const high = c.y < b.y0 + b.h * 0.55 + const opensUp = a.y < c.y + 4 && d.y < c.y + 4 && Math.abs(a.x - d.x) > b.w * 0.35 + if (high && opensUp) return true + } + return false +} + +function terminalHook(pts: StrokePoint[], cum: number[]): boolean { + const mid = chord(pts, cum, 0.5, 0.72) + const end = chord(pts, cum, 0.82, 1) + if (!mid || !end) return false + return mid.x * end.x + mid.y * end.y < 0.2 +} + +/** The last fifth leaves the box of the earlier ink: a tail, not the close of a circle. */ +function hasOutwardTail(pts: StrokePoint[], cum: number[]): boolean { + const total = cum[cum.length - 1] + let x0 = Infinity + let y0 = Infinity + let x1 = -Infinity + let y1 = -Infinity + let n = 0 + for (let i = 0; i < pts.length; i++) { + if (cum[i] > total * 0.8) break + x0 = Math.min(x0, pts[i].x) + y0 = Math.min(y0, pts[i].y) + x1 = Math.max(x1, pts[i].x) + y1 = Math.max(y1, pts[i].y) + n++ + } + if (n < 4) return false + const pad = Math.max(4, Math.hypot(x1 - x0, y1 - y0) * 0.06) + for (let i = 0; i < pts.length; i++) { + if (cum[i] < total * 0.84) continue + const p = pts[i] + if (p.x < x0 - pad || p.x > x1 + pad || p.y < y0 - pad || p.y > y1 + pad) return true + } + return false +} + +function nearestApproach(pts: StrokePoint[], cum: number[]): number { + const total = cum[cum.length - 1] + let best = Infinity + for (let i = 0; i < pts.length; i++) { + if (cum[i] > total * 0.4) break + for (let j = pts.length - 1; j > i; j--) { + if (cum[j] < total * 0.6) break + if (cum[j] - cum[i] < total * 0.35) continue + const d = dist(pts[i], pts[j]) + if (d < best) best = d + } + } + return best +} + +function totalTurn(pts: StrokePoint[]): number { + let turn = 0 + let prev: { x: number; y: number } | null = null + for (let i = 1; i < pts.length; i++) { + const dir = unit(pts[i - 1], pts[i]) + if (!dir) continue + if (prev) turn += Math.acos(clamp(prev.x * dir.x + prev.y * dir.y, -1, 1)) + prev = dir + } + return turn +} + +function radiusCv(pts: StrokePoint[], cum: number[], c: StrokePoint, from: number, to: number): number { + const total = cum[cum.length - 1] + const rs: number[] = [] + for (let i = 0; i < pts.length; i++) { + const t = cum[i] / total + if (t < from || t > to) continue + rs.push(dist(pts[i], c)) + } + if (rs.length < 4) return 1 + const mean = rs.reduce((s, r) => s + r, 0) / rs.length + if (mean < 1) return 1 + const variance = rs.reduce((s, r) => s + (r - mean) ** 2, 0) / rs.length + return Math.sqrt(variance) / mean +} + +function centroid(pts: StrokePoint[], cum: number[], from: number, to: number): StrokePoint { + const total = cum[cum.length - 1] + let x = 0 + let y = 0 + let n = 0 + for (let i = 0; i < pts.length; i++) { + const t = total === 0 ? 0 : cum[i] / total + if (t < from || t > to) continue + x += pts[i].x + y += pts[i].y + n++ + } + if (!n) return pts[0] + return { x: x / n, y: y / n } +} + +function chord(pts: StrokePoint[], cum: number[], from: number, to: number): { x: number; y: number } | null { + const a = pointAt(pts, cum, from) + const b = pointAt(pts, cum, to) + if (!a || !b) return null + return unit(a, b) +} + +function pointAt(pts: StrokePoint[], cum: number[], fraction: number): StrokePoint | null { + const target = cum[cum.length - 1] * fraction + for (let i = 1; i < pts.length; i++) { + if (cum[i] + 1e-9 < target) continue + const span = cum[i] - cum[i - 1] + const t = span < 1e-9 ? 0 : (target - cum[i - 1]) / span + return { x: pts[i - 1].x + (pts[i].x - pts[i - 1].x) * t, y: pts[i - 1].y + (pts[i].y - pts[i - 1].y) * t } + } + return pts[pts.length - 1] ?? null +} + +function cumulative(pts: StrokePoint[]): number[] { + const out = [0] + for (let i = 1; i < pts.length; i++) out.push(out[i - 1] + dist(pts[i - 1], pts[i])) + return out +} + +function arcLength(pts: StrokePoint[]): number { + let n = 0 + for (let i = 1; i < pts.length; i++) n += dist(pts[i - 1], pts[i]) + return n +} + +function unit(a: StrokePoint, b: StrokePoint): { x: number; y: number } | null { + const dx = b.x - a.x + const dy = b.y - a.y + const len = Math.hypot(dx, dy) + if (len < 0.8) return null + return { x: dx / len, y: dy / len } +} + +function dist(a: StrokePoint, b: StrokePoint): number { + return Math.hypot(a.x - b.x, a.y - b.y) +} + +function clamp(v: number, lo: number, hi: number): number { + return Math.min(hi, Math.max(lo, v)) +} + +function bounds(pts: StrokePoint[]) { + let x0 = Infinity + let y0 = Infinity + let x1 = -Infinity + let y1 = -Infinity + for (const p of pts) { + x0 = Math.min(x0, p.x) + y0 = Math.min(y0, p.y) + x1 = Math.max(x1, p.x) + y1 = Math.max(y1, p.y) + } + return { x0, y0, x1, y1, w: x1 - x0, h: y1 - y0 } +} diff --git a/src/ocr/gate.ts b/src/ocr/gate.ts index a382756..be2ae9b 100644 --- a/src/ocr/gate.ts +++ b/src/ocr/gate.ts @@ -1,4 +1,4 @@ -import { findOverArrow } from './comer/bind.ts' +import { findOverArrow, looksLikeSumBars } from './comer/bind.ts' import type { InkStroke, StrokePoint } from './comer/preprocess.ts' /** @@ -20,7 +20,7 @@ export function mathCues(strokes: InkStroke[]): string[] { if (hasFractionBar(strokes)) cues.push('fraction') if (hasSuperscript(strokes)) cues.push('superscript') if (hasParen(strokes)) cues.push('paren') - if (hasSumLike(strokes)) cues.push('sum') + if (hasSumLike(strokes) || looksLikeSumBars(strokes)) cues.push('sum') if (looksLikeIsolatedSigma(strokes)) cues.push('sigma') return cues } diff --git a/vite.config.ts b/vite.config.ts index d105f54..ade106e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -48,7 +48,7 @@ export default defineConfig({ plugins: [react(), tailwindcss(), mathscrapDevBridge(), ortRuntime()], resolve: { alias: { - '@': path.resolve(__dirname, './src'), + '@': path.resolve(import.meta.dirname, './src'), }, }, server: {