UNPKG

@edumedia/handwriting-character-recognition

Version:

Tool to recognize handwritten characters

1,096 lines (1,078 loc) 37.1 kB
"use strict"; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // lib/index.ts var index_exports = {}; __export(index_exports, { DrawingAnalyzer: () => DrawingAnalyzer }); module.exports = __toCommonJS(index_exports); // src/PathSimplifier.ts var PathSimplifier = class { dist(p1, p2, p3) { return Math.abs( (p2[1] - p1[1]) * p3[0] - (p2[0] - p1[0]) * p3[1] + p2[0] * p1[1] - p2[1] * p1[0] ) / Math.sqrt(Math.pow(p2[1] - p1[1], 2) + Math.pow(p2[0] - p1[0], 2)); } /** * Implementation of Ramer-Douglas-Peucker algorithm * @see https://en.wikipedia.org/wiki/Ramer%E2%80%93Douglas%E2%80%93Peucker_algorithm * * @param points * @param start * @param end * @param epsilon * @param result */ recursiveRdp(points, start, end, epsilon, result) { let maxDist = 0; let index = start; for (let i = start + 1; i < end; i++) { const d = this.dist(points[start], points[end], points[i]); if (d > maxDist) { maxDist = d; index = i; } } if (maxDist > epsilon) { this.recursiveRdp(points, start, index, epsilon, result); result.push(points[index]); this.recursiveRdp(points, index, end, epsilon, result); } } simplify(points, epsilon = 2) { const result = [points[0]]; this.recursiveRdp(points, 0, points.length - 1, epsilon, result); result.push(points[points.length - 1]); return result; } simplifyMultiPath(strokes, epsilon = 2) { const result = strokes; for (let stroke of result) { stroke = this.simplify(stroke, epsilon); } return result; } }; // src/config.ts var NO_RESULT_CHAR = "-"; var CONFIDENCE_THRESHOLD = 0.6; var TRANSFORM_DIMENSION = 100; var NORMALIZER_DEFAULT_LENGTH = 40; // src/PathNormalizer.ts var PathNormalizer = class { /** * Normalize the points to fit desiredLength * @returns normalized points */ normalize(points, desiredLength = NORMALIZER_DEFAULT_LENGTH) { const distances = [0]; for (let i = 1; i < points.length; i++) { const dx = points[i][0] - points[i - 1][0]; const dy = points[i][1] - points[i - 1][1]; distances.push(distances[i - 1] + Math.sqrt(dx * dx + dy * dy)); } const totalDistance = distances[distances.length - 1]; const step = totalDistance / (desiredLength - 1); const normalizedPoints = []; let currentDistance = 0; let pointIndex = 1; for (let i = 0; i < desiredLength; i++) { while (pointIndex < distances.length - 1 && distances[pointIndex] < currentDistance) { pointIndex++; } const ratio = (currentDistance - distances[pointIndex - 1]) / (distances[pointIndex] - distances[pointIndex - 1]); const x = points[pointIndex - 1][0] + ratio * (points[pointIndex][0] - points[pointIndex - 1][0]); const y = points[pointIndex - 1][1] + ratio * (points[pointIndex][1] - points[pointIndex - 1][1]); normalizedPoints.push([x, y]); currentDistance += step; } return normalizedPoints; } /** * Normalize character with a multi path * @param paths * @param desiredLength */ normalizeMultiPath(paths, desiredLength = NORMALIZER_DEFAULT_LENGTH) { const pathLength = (path) => { let length = 0; for (let i = 1; i < path.length; i++) { const dx = path[i][0] - path[i - 1][0]; const dy = path[i][1] - path[i - 1][1]; length += Math.sqrt(dx * dx + dy * dy); } return length; }; const lengths = paths.map((path) => pathLength(path)); const totalLength = lengths.reduce((sum, len) => sum + len, 0); const normalizedPaths = []; let totalAllocated = 0; for (let i = 0; i < paths.length; i++) { let allocated = Math.round(lengths[i] / totalLength * desiredLength); if (i === paths.length - 1) { allocated = desiredLength - totalAllocated; } else { totalAllocated += allocated; } const normalized = this.normalize(paths[i], allocated); normalizedPaths.push(normalized); } return normalizedPaths; } }; // src/Logger.ts var Logger = class { constructor(showLogs = false) { this.showLogs = showLogs; } log(...args) { if (this.showLogs) { console.log(...args); } } }; // src/ConfidenceCalculator.ts var ConfidenceCalculator = class { constructor(logger) { this.logger = logger; } calcConfidence(matches) { const freqMap = {}; let maxScore = 0; const distances = [...matches.map((match) => match.distance)]; if (distances.length === 0) { this.logger.log("No matches"); return { character: NO_RESULT_CHAR, confidence: 0 }; } const maxDistance = Math.max(...distances); const minDistance = Math.min(...distances); const meanDistance = distances.reduce((sum, dist) => sum + dist) / distances.length; if (minDistance > 12.5) { this.logger.log("min distance too high"); return { character: NO_RESULT_CHAR, confidence: 0 }; } if (meanDistance > 20) { this.logger.log("mean distance too high:", meanDistance); this.logger.log("matches", matches); return { character: NO_RESULT_CHAR, confidence: 0 }; } if (matches[0].character !== matches[1].character && matches[1].distance - matches[0].distance < 2) { let sideWithNo1 = 0; let sideWithNo2 = 0; for (const match of matches) { if (match.character === matches[0].character) { sideWithNo1++; } else if (match.character === matches[1].character) { sideWithNo2++; } } if (!(sideWithNo1 === 0 || sideWithNo2 === 0)) { this.logger.log("Top 2 is different char with close distance"); return { character: NO_RESULT_CHAR, confidence: 0 }; } else { this.logger.log("Top 2 was close, but other matches decided"); } } matches.forEach((match) => { const weight = 1 - (match.distance - minDistance) / (maxDistance - minDistance); maxScore += weight; if (freqMap[match.character]) { freqMap[match.character] += weight; } else { freqMap[match.character] = weight; } }); let maxWeight = 0; let maxCharacter = ""; for (const [character, weight] of Object.entries(freqMap)) { if (weight > maxWeight) { maxWeight = weight; maxCharacter = character; } } const confidence = maxWeight / maxScore; return { character: maxCharacter, confidence }; } }; // src/utils/boundingBox.ts function getStrokeBoundingBox(stroke, strokeWidth = 0) { let left = Infinity, top = Infinity, right = -Infinity, bottom = -Infinity; for (const [x, y] of stroke) { left = Math.min(left, x); top = Math.min(top, y); right = Math.max(right, x); bottom = Math.max(bottom, y); } return { left: left - strokeWidth, top: top - strokeWidth, right: right + strokeWidth, bottom: bottom + strokeWidth, width: right - left + 2 * strokeWidth, height: bottom - top + 2 * strokeWidth }; } function getDrawingBoundingBox(drawing) { let left = Infinity, top = Infinity, right = -Infinity, bottom = -Infinity; for (const stroke of drawing.strokes) { for (const [x, y] of stroke) { left = Math.min(left, x); top = Math.min(top, y); right = Math.max(right, x); bottom = Math.max(bottom, y); } } return { left: left - drawing.strokeWidth, top: top - drawing.strokeWidth, right: right + drawing.strokeWidth, bottom: bottom + drawing.strokeWidth, width: right - left + 2 * drawing.strokeWidth, height: bottom - top + 2 * drawing.strokeWidth }; } function boxesIntersect(a, b) { return !(b.left > a.right || b.right < a.left || b.top > a.bottom || b.bottom < a.top); } // src/GridCropper.ts var GridCropper = class { /** * Crop drawing to it's smallest box * @param drawing * @returns */ crop(drawing) { const drawingCopy = JSON.parse(JSON.stringify(drawing)); let { left, top, width, height } = getDrawingBoundingBox(drawingCopy); let diff = 0; let offsetX = 0; let offsetY = 0; if (width > height) { diff = width - height; offsetY = Math.floor(diff / 2); drawingCopy.height = width; drawingCopy.width = width; } else if (height > width) { diff = height - width; offsetX = Math.floor(diff / 2); drawingCopy.height = height; drawingCopy.width = height; } for (let stroke of drawingCopy.strokes) { for (let point of stroke) { point[0] = point[0] - left + offsetX; point[1] = point[1] - top + offsetY; } } return drawingCopy; } }; // src/GridStretcher.ts var GridStretcher = class { stretch(drawing, targetX, targetY) { const drawingCopy = JSON.parse(JSON.stringify(drawing)); const scaleX = targetX / drawingCopy.width; const scaleY = targetY / drawingCopy.height; if (scaleX !== scaleY) { console.warn( "Scaling factors should be equal. Use the GridCropper before applying the GridStretcher." ); } for (let stroke of drawingCopy.strokes) { stroke = stroke.map((point) => [ point[0] *= scaleX, point[1] *= scaleY ]); } drawingCopy.strokeWidth = parseFloat( (drawingCopy.strokeWidth * scaleX).toFixed(2) ); drawingCopy.width = targetX; drawingCopy.height = targetY; return drawingCopy; } }; // src/DrawingTransfomer.ts var DrawingTransformer = class { cropper = new GridCropper(); stretcher = new GridStretcher(); joinTouchingStrokes(drawing) { const drawingCopy = JSON.parse(JSON.stringify(drawing)); const isTouching = (pointA, pointB, threshold2) => { if (!pointA || !pointB) return false; if (pointA.length !== 2 || pointB.length !== 2) return false; const dx = pointA[0] - pointB[0]; const dy = pointA[1] - pointB[1]; return Math.sqrt(dx * dx + dy * dy) <= threshold2; }; const threshold = 2 * drawingCopy.strokeWidth; for (let i = 0; i < drawingCopy.strokes.length; i++) { const strokeA = drawingCopy.strokes[i]; for (let j = i + 1; j < drawingCopy.strokes.length; j++) { const strokeB = drawingCopy.strokes[j]; const startPointA = strokeA[0]; const endPointA = strokeA[strokeA.length - 1]; const startPointB = strokeB[0]; const endPointB = strokeB[strokeB.length - 1]; if (isTouching(endPointA, startPointB, threshold)) { strokeA.push(...strokeB); drawingCopy.strokes.splice(j, 1); j--; } else if (isTouching(startPointA, endPointB, threshold)) { strokeB.push(...strokeA); drawingCopy.strokes.splice(i, 1); i--; break; } else if (isTouching(endPointA, endPointB, threshold)) { strokeA.push(...strokeB.reverse()); drawingCopy.strokes.splice(j, 1); j--; } else if (isTouching(startPointA, startPointB, threshold)) { strokeA.unshift(...strokeB.reverse()); drawingCopy.strokes.splice(j, 1); j--; } } } return drawingCopy; } transformDrawing(drawing) { const cropped = this.cropper.crop(drawing); const stretched = this.stretcher.stretch( cropped, TRANSFORM_DIMENSION, TRANSFORM_DIMENSION ); const joinedStrokesDrawing = this.joinTouchingStrokes(stretched); const drawingTopToBottom = this.setDrawingPathDirection(joinedStrokesDrawing); return { cropped, stretched, last: drawingTopToBottom }; } setDrawingPathDirection(drawing) { const drawingCopy = JSON.parse(JSON.stringify(drawing)); const isStrokeTopLeftToBottomRight = (firstPoint, lastPoint) => { if (firstPoint[1] < lastPoint[1]) { return true; } else if (firstPoint[1] > lastPoint[1]) { return false; } return firstPoint[0] < lastPoint[0]; }; for (let stroke of drawingCopy.strokes) { const firstPoint = stroke[0]; const lastPoint = stroke[stroke.length - 1]; if (!firstPoint || !lastPoint) continue; if (firstPoint.length !== 2 || lastPoint.length !== 2) continue; if (!isStrokeTopLeftToBottomRight(firstPoint, lastPoint)) { stroke.reverse(); } } if (drawingCopy.strokes.length > 1) { drawingCopy.strokes.sort((strokeA, strokeB) => { const pointA = strokeA[0]; const pointB = strokeB[0]; if (pointA[1] < pointB[1]) { return -1; } else if (pointA[1] > pointB[1]) { return 1; } if (pointA[0] < pointB[0]) { return -1; } else if (pointA[0] > pointB[0]) { return 1; } return 0; }); } return drawingCopy; } }; // src/types/IdentifiableCharacters.ts var IdentifiableNumbers = [ "0", "1", "2", "3", "4", "5", "6", "7", "8", "9" ]; var IdentifiableOperators = ["<", ">", "="]; var IdentifiableCharacters = [ ...IdentifiableNumbers, ...IdentifiableOperators ]; function isIdentifiableCharacter(char) { return IdentifiableNumbers.includes(char) || IdentifiableOperators.includes(char); } // src/CharacterLibrary.ts var CharacterLibrary = class { characterDrawingMap = /* @__PURE__ */ new Map(); normalizedDrawingMap = /* @__PURE__ */ new Map(); normalizer = new PathNormalizer(); transformer = new DrawingTransformer(); /** * Load CharacterDrawings data from a path * @param path */ initializeFromDataPath(path) { fetch(path).then((response) => response.json()).then((data) => { this.initializeFromObject(data); }).catch( (error) => console.error( "Error loading character data. LibData should be available at /data.json or you should provide the path to the data file.", error ) ); } /** * Initialize CharacterDrawings data * * Sets raw data (and pre-normalized data from build) in characterDrawingMap and normalized data in normalizedDrawingMap * @param data */ initializeFromObject(data) { this.characterDrawingMap.clear(); for (const [key, value] of Object.entries(data)) { this.characterDrawingMap.set( key, value ); } this.normalizeDrawings(); } /** * Normalize the drawing when loading the data into the app * * Only Normalize data that has not been pre-normalized on build * @private */ normalizeDrawings() { this.characterDrawingMap.forEach((drawingArray, key) => { if (!this.normalizedDrawingMap.has(key)) { this.normalizedDrawingMap.set(key, []); } const characterDrawings = this.normalizedDrawingMap.get(key); for (const drawing of drawingArray) { const drawingCopy = JSON.parse( JSON.stringify(drawing) ); let transformedLibDrawing = null; const totalPoints = drawing.strokes.flat().length; if (totalPoints !== NORMALIZER_DEFAULT_LENGTH || drawingCopy.height !== TRANSFORM_DIMENSION || drawingCopy.width !== TRANSFORM_DIMENSION) { drawingCopy.strokes = this.normalizer.normalizeMultiPath( drawingCopy.strokes ); transformedLibDrawing = this.transformer.transformDrawing(drawingCopy).last; } if (characterDrawings) { if (transformedLibDrawing) { characterDrawings.push(transformedLibDrawing); } else { characterDrawings.push(drawingCopy); } } } }); } /** * Add a new CharacterDrawing in the Library. * * Only process made on the CharacterDrawing was cropping the excess unused space of the canvas. * * TODO Also add a normalized version of the CharacterDrawing to this.normalizedDrawingMap * @param collectionCharacter * @param drawing */ addCharacterDrawing(collectionCharacter, drawing) { if (!this.characterDrawingMap.has(collectionCharacter)) { this.characterDrawingMap.set(collectionCharacter, []); } const characterDrawings = this.characterDrawingMap.get(collectionCharacter); if (characterDrawings) { characterDrawings.push(drawing); } else { console.warn(`Could not add character drawing "${collectionCharacter}"`); } } /** * Remove a CharacterDrawing from the raw data Library (characterDrawingMap) * @param drawingId */ removeCharacterDrawing(drawingId) { const collectionCharacter = drawingId.split("_")[0]; if (!isIdentifiableCharacter(collectionCharacter)) throw new Error(`Drawing id is not an IdentifiableCharacter!`); const characterDrawings = this.characterDrawingMap.get(collectionCharacter); if (!characterDrawings) throw new Error( `Can't find this character in lib : ${collectionCharacter}` ); const updatedDrawings = characterDrawings.filter((d) => d.id !== drawingId); this.characterDrawingMap.set(collectionCharacter, updatedDrawings); } /** * Get the raw data */ rawDataToObject() { return Object.fromEntries(this.characterDrawingMap); } /** * Get the normalized data used by the DrawingAnalyzer. */ toObject() { return Object.fromEntries(this.normalizedDrawingMap); } }; // src/utils/segmentUtils.ts function pointToSegmentDistance(p, v, w) { const [px, py] = p; const [vx, vy] = v; const [wx, wy] = w; const l2 = (wx - vx) ** 2 + (wy - vy) ** 2; if (l2 === 0) return Math.hypot(px - vx, py - vy); let t = ((px - vx) * (wx - vx) + (py - vy) * (wy - vy)) / l2; t = Math.max(0, Math.min(1, t)); const projX = vx + t * (wx - vx); const projY = vy + t * (wy - vy); return Math.hypot(px - projX, py - projY); } function segmentsAreClose(p1, p2, p3, p4, threshold) { return pointToSegmentDistance(p1, p3, p4) <= threshold || pointToSegmentDistance(p2, p3, p4) <= threshold || pointToSegmentDistance(p3, p1, p2) <= threshold || pointToSegmentDistance(p4, p1, p2) <= threshold; } function intersects(p1, p2, p3, p4) { const [x1, y1] = p1; const [x2, y2] = p2; const [x3, y3] = p3; const [x4, y4] = p4; if (x1 === x2 && y1 === y2 || x3 === x4 && y3 === y4) { return false; } const denominator = (y4 - y3) * (x2 - x1) - (x4 - x3) * (y2 - y1); if (denominator === 0) { return false; } const ua = ((x4 - x3) * (y1 - y3) - (y4 - y3) * (x1 - x3)) / denominator; const ub = ((x2 - x1) * (y1 - y3) - (y2 - y1) * (x1 - x3)) / denominator; if (ua < 0 || ua > 1 || ub < 0 || ub > 1) { return false; } const x = x1 + ua * (x2 - x1); const y = y1 + ua * (y2 - y1); return { x, y }; } function isStrokeClosed(stroke) { for (let i = 0; i < stroke.length - 2; i++) { const segA = [stroke[i], stroke[i + 1]]; for (let j = i + 2; j < stroke.length - 1; j++) { const segB = [stroke[j], stroke[j + 1]]; const isIntersect = intersects(segA[0], segA[1], segB[0], segB[1]); if (isIntersect) { return true; } } } return false; } // src/CharacterGuesser.ts var CharacterGuesser = class { constructor(library, logger) { this.library = library; this.logger = logger; this.confidenceCalculator = new ConfidenceCalculator(logger); } confidenceCalculator; transformer = new DrawingTransformer(); /** * Calculate the DTW distance between two sequences of points, used to compare the drawn points with our data * @see {@link https://en.wikipedia.org/wiki/Dynamic_time_warping} */ dtw(p1, p2) { const n = p1.length; const m = p2.length; if (n !== m) throw new Error("Strokes must be the same length to calc DTW"); const dtwMatrix = Array(n + 1).fill(0).map(() => Array(m + 1).fill(Infinity)); dtwMatrix[0][0] = 0; for (let i = 1; i <= n; i++) { for (let j = 1; j <= m; j++) { const cost = Math.sqrt( Math.pow(p1[i - 1][0] - p2[j - 1][0], 2) + Math.pow(p1[i - 1][1] - p2[j - 1][1], 2) ); dtwMatrix[i][j] = cost + Math.min( dtwMatrix[i - 1][j], dtwMatrix[i][j - 1], dtwMatrix[i - 1][j - 1] ); } } return dtwMatrix[n][m]; } isMostlyStraightLine(points, threshold = 10) { if (points.length < 2) { return true; } const [x1, y1] = points[0]; const [x2, y2] = points[points.length - 1]; const A = y2 - y1; const B = x1 - x2; const C = x2 * y1 - x1 * y2; for (const [x, y] of points) { const distance = Math.abs(A * x + B * y + C) / Math.sqrt(A * A + B * B); if (distance > threshold) { return false; } } return true; } getCurves(drawing, threshold = 0.25) { const curveCounts = { right: 0, left: 0 }; for (const stroke of drawing.strokes) { if (stroke.length < 3) { continue; } let previousDirection = null; for (let i = 1; i < stroke.length - 1; i++) { const [x1, y1] = stroke[i - 1]; const [x2, y2] = stroke[i]; const [x3, y3] = stroke[i + 1]; const vector1 = [x2 - x1, y2 - y1]; const vector2 = [x3 - x2, y3 - y2]; const crossProduct = vector1[0] * vector2[1] - vector1[1] * vector2[0]; const magnitude1 = Math.sqrt(vector1[0] ** 2 + vector1[1] ** 2); const magnitude2 = Math.sqrt(vector2[0] ** 2 + vector2[1] ** 2); const sineAngle = crossProduct / (magnitude1 * magnitude2); if (Math.abs(sineAngle) < threshold) { continue; } let currentDirection = null; if (sineAngle > 0) { currentDirection = "right"; } else if (sineAngle < 0) { currentDirection = "left"; } if (currentDirection && currentDirection !== previousDirection) { curveCounts[currentDirection] += 1; previousDirection = currentDirection; } } } return curveCounts; } getMatches(drawing, searchCharacters) { const matches = []; let libraryData = JSON.parse( JSON.stringify(this.library.toObject()) ); for (const character in libraryData) { if (!isIdentifiableCharacter(character)) { console.error( `Corrupt LibData: found data for a character that should not be there (${character})` ); continue; } if (searchCharacters && !searchCharacters?.includes(character)) continue; const characterDrawings = libraryData[character]; for (let currentDrawing of characterDrawings) { if (!currentDrawing) continue; const userStrokes = drawing.strokes; const libStrokes = currentDrawing.strokes; let distance = 0; const fallbackCalc = (userStrokes2, libStrokes2) => { const userPoints = userStrokes2.flat(); const libPoints = libStrokes2.flat(); distance = this.dtw(userPoints, libPoints); return distance; }; if (userStrokes.length === libStrokes.length) { for (let i = 0; i < userStrokes.length; i++) { const userStroke = userStrokes[i]; const libStroke = libStrokes[i]; if (userStroke.length !== libStroke.length) { distance = fallbackCalc(userStrokes, libStrokes); break; } distance += this.dtw(userStroke, libStroke); } } else { distance = fallbackCalc(userStrokes, libStrokes); } distance /= NORMALIZER_DEFAULT_LENGTH; matches.push({ character, drawing: currentDrawing, distance }); } } matches.sort((a, b) => a.distance - b.distance); return matches; } /** * Returns the guessed characters along with the top matches found. * * Best used when first splitting the user drawing into multiple characters * with the help of the CharacterSplitter * * 1. Transform the user's drawing to fit the library's drawing parameters * * 2. Gets top 10 closest matches * * 3. Calculate confidence level * * 4. Final verifications and returns guess * @param drawing */ guess(drawing) { const transformed = this.transformer.transformDrawing(drawing); const curves = this.getCurves(transformed.last); if (transformed.last.strokes.length === 2 && this.isMostlyStraightLine(transformed.last.strokes[0]) && this.isMostlyStraightLine(transformed.last.strokes[1])) { this.logger.log("Found two straight lines, must be a '='"); return { character: "=", confidence: 1, transformResult: transformed, topMatches: [] }; } const matches = this.getMatches(transformed.last); const topMatches = matches.slice(0, 6); let { character, confidence } = this.confidenceCalculator.calcConfidence(topMatches); function isWeirdZero(stroke, strokeWidth) { const startingPoint = stroke.at(0); const endingPoint = stroke.at(-1); if (!startingPoint || !endingPoint) return true; const distanceStartEnd = Math.hypot( endingPoint[0] - startingPoint[0], endingPoint[1] - startingPoint[1] ); return distanceStartEnd > strokeWidth * 5 && !isStrokeClosed(stroke); } if (character === "7" && curves.right > 2 && transformed.last.strokes.length === 1) { character = NO_RESULT_CHAR; confidence = 0; this.logger.log("7 with many curves, might be a 3 skinny 3?"); } else if (character === "0" && transformed.last.strokes.length === 1 && isWeirdZero(transformed.last.strokes[0], transformed.last.strokeWidth)) { character = NO_RESULT_CHAR; confidence = 0; this.logger.log("found a weird zero"); } return { transformResult: transformed, character, topMatches, // Include the top 6 matches confidence }; } }; // src/CharacterSplitter.ts var CharacterSplitter = class { constructor(characterGuesser, logger) { this.characterGuesser = characterGuesser; this.logger = logger; } simplifier = new PathSimplifier(); normalizer = new PathNormalizer(); getSmallestDistance(strokeA, strokeB) { let minDistance = Infinity; for (let i = 0; i < strokeA.length - 1; i++) { for (let j = 0; j < strokeB.length - 1; j++) { const p1 = strokeA[i]; const p2 = strokeA[i + 1]; const p3 = strokeB[j]; const distance = pointToSegmentDistance(p3, p1, p2); minDistance = Math.min(minDistance, distance); } } return minDistance; } getClosestStroke(stroke, strokes, strokeIndex) { let closestStrokeIndex = -1; let minDistance = Infinity; for (let i = 0; i < strokes.length; i++) { if (i === strokeIndex) continue; const otherStroke = strokes[i]; const distance = this.getSmallestDistance(stroke, otherStroke); if (distance < minDistance) { minDistance = distance; closestStrokeIndex = i; } } return closestStrokeIndex; } sortDrawings(a, b) { const aMinX = Math.min( ...a.strokes.map((stroke) => Math.min(...stroke.map((point) => point[0]))) ); const bMinX = Math.min( ...b.strokes.map((stroke) => Math.min(...stroke.map((point) => point[0]))) ); return aMinX - bMinX; } splitChar(drawing, numberOfCharToDetect) { const strokes = drawing.strokes; const strokeWidth = drawing.strokeWidth; if (numberOfCharToDetect && numberOfCharToDetect === 1) { return [drawing]; } const graph = Array.from( { length: strokes.length }, () => /* @__PURE__ */ new Set() ); for (let i = 0; i < strokes.length; i++) { const strokeA = this.simplifier.simplify(strokes[i]); const bboxA = getStrokeBoundingBox(strokeA, strokeWidth); if ((bboxA.right - bboxA.left) / (bboxA.bottom - bboxA.top) > 2) { const closestStrokeIndex = this.getClosestStroke(strokeA, strokes, i); if (closestStrokeIndex !== -1) { graph[i].add(closestStrokeIndex); graph[closestStrokeIndex].add(i); continue; } } for (let j = i + 1; j < strokes.length; j++) { const strokeB = this.simplifier.simplify(strokes[j]); const bboxB = getStrokeBoundingBox(strokeB, strokeWidth); if (!boxesIntersect(bboxA, bboxB)) continue; let foundIntersection = false; for (let k = 0; k < strokeA.length - 1 && !foundIntersection; k++) { for (let l = 0; l < strokeB.length - 1 && !foundIntersection; l++) { const p1 = strokeA[k]; const p2 = strokeA[k + 1]; const p3 = strokeB[l]; const p4 = strokeB[l + 1]; if (segmentsAreClose(p1, p2, p3, p4, strokeWidth * 1.5) || intersects(p1, p2, p3, p4)) { graph[i].add(j); graph[j].add(i); foundIntersection = true; } } } } } const visited = new Array(strokes.length).fill(false); const splitDrawings = []; function dfs(node, strokeGroup) { visited[node] = true; strokeGroup.push(strokes[node]); for (const neighbor of graph[node]) { if (!visited[neighbor]) dfs(neighbor, strokeGroup); } } for (let i = 0; i < strokes.length; i++) { if (!visited[i]) { const strokeGroup = []; dfs(i, strokeGroup); const normalizedPaths = this.normalizer.normalizeMultiPath(strokeGroup); const { width, height } = getDrawingBoundingBox({ id: drawing.id, strokes: normalizedPaths, strokeWidth: drawing.strokeWidth, width: drawing.width, height: drawing.height }); splitDrawings.push({ id: drawing.id, strokes: normalizedPaths, strokeWidth: drawing.strokeWidth, width, height }); } } splitDrawings.sort((a, b) => this.sortDrawings(a, b)); this.logger.log( `Found ${splitDrawings.length} characters in user drawing before checking for individual strokes as characters` ); for (let i = splitDrawings.length - 1; i >= 0; i--) { const drawing2 = splitDrawings[i]; const drawingCopy = JSON.parse(JSON.stringify(drawing2)); drawingCopy.strokes = this.normalizer.normalizeMultiPath( drawingCopy.strokes ); this.logger.log( `/--Guess character ${i} as a whole after stroke grouping--/` ); const guessResult = this.characterGuesser.guess(drawingCopy); this.logger.log( "Char: ", guessResult.character, "Confidence: ", guessResult.confidence, "TopMatches: ", guessResult.topMatches ); if (guessResult.confidence >= CONFIDENCE_THRESHOLD) { this.logger.log( `Identified character ${guessResult.character}, will not check individual strokes.` ); continue; } if (drawing2.strokes.length > 1) { const individualStrokesAsDrawings = drawing2.strokes.map( (stroke, i2) => ({ id: `${drawing2.id}_${i2}`, strokes: [stroke], strokeWidth: drawing2.strokeWidth, width: drawing2.width, height: drawing2.height }) ); individualStrokesAsDrawings.sort((a, b) => this.sortDrawings(a, b)); const identifiedCharacters = []; let allIdentified = true; for (const singleStrokeDrawing of individualStrokesAsDrawings) { singleStrokeDrawing.strokes = this.normalizer.normalizeMultiPath( singleStrokeDrawing.strokes ); this.logger.log( `/--Guess single stroke ${singleStrokeDrawing.id.at(-1)} as an individual character--/` ); const guessResult2 = this.characterGuesser.guess(singleStrokeDrawing); if (guessResult2.confidence >= CONFIDENCE_THRESHOLD) { identifiedCharacters.push(singleStrokeDrawing); this.logger.log( `Identified single stroke ${singleStrokeDrawing.id.at(-1)} as a character: ${guessResult2.character}` ); } else { this.logger.log( `Could not identify single stroke ${singleStrokeDrawing.id.at(-1)} as a character, breaking.` ); allIdentified = false; break; } } if (allIdentified) { splitDrawings.splice(i, 1, ...identifiedCharacters); } } } return splitDrawings; } }; // src/utils/initCharacterLibrary.ts function initCharacterLibrary(dataPath = "./data.json") { const characterLibrary = new CharacterLibrary(); characterLibrary.initializeFromDataPath(dataPath); return characterLibrary; } // src/DrawingAnalyzer.ts var DrawingAnalyzer = class { guesser; characterSplitter; logger; library; showLogs; /** * constructor description * * dataPath: Optional, provide the path to your data if you don't want to use the default `/data.json` * * showLogs: Optional, output debug logs in the console */ constructor(drawingAnalyzerProps) { this.library = initCharacterLibrary(drawingAnalyzerProps?.dataPath); this.logger = new Logger(drawingAnalyzerProps?.showLogs); this.guesser = new CharacterGuesser(this.library, this.logger); this.characterSplitter = new CharacterSplitter(this.guesser, this.logger); this.showLogs = drawingAnalyzerProps?.showLogs ?? false; } /** * Main function to be used after collecting points with a drawing component * * Analyzes a given character drawing to detect characters and determine their accuracy. * * @param {CharacterDrawing} drawing - The drawing input containing character strokes to analyze. * @param {number} [numberOfCharToDetect] - Optional parameter specifying the number of characters to detect from the drawing. ONLY IMPLEMENTED WITH VALUE `1` * @return {AnalyzerResult} The analysis results, including the detected character string and details of individual character guesses. */ analyze(drawing, numberOfCharToDetect) { const debugResult = { resultType: "debug", characterString: "", guessResults: [] }; try { this.logger.log("/-----START-----/"); drawing.strokes = drawing.strokes.filter((stroke) => stroke.length > 1); this.logger.log("/---Split start---/"); const splitDrawings = this.characterSplitter.splitChar( drawing, numberOfCharToDetect ); this.logger.log( `User drawing split into ${splitDrawings.length} char${splitDrawings.length > 1 ? "s" : ""}.` ); this.logger.log("/---Split done---/"); this.logger.log("/---Start Guessing---/"); let numberFlag = false; let operatorFlag = false; for (const char of splitDrawings) { const guessResult = this.guesser.guess(char); debugResult.guessResults.push(guessResult); debugResult.characterString += guessResult.character; if (guessResult.confidence < CONFIDENCE_THRESHOLD) { debugResult.characterString = NO_RESULT_CHAR; this.logger.log("Could not guess with high confidence. Breaking."); break; } if (IdentifiableNumbers.includes( guessResult.character )) { numberFlag = true; } if (IdentifiableOperators.includes( guessResult.character )) { if (operatorFlag) { debugResult.characterString = NO_RESULT_CHAR; this.logger.log("Found two operators. Breaking."); break; } operatorFlag = true; } if (numberFlag && operatorFlag) { this.logger.log("Number and operator found"); debugResult.characterString = NO_RESULT_CHAR; break; } } } catch (err) { console.error(err); debugResult.characterString = NO_RESULT_CHAR; debugResult.guessResults = []; } this.logger.log("/---Guessing Done---/"); this.logger.log("Analyzer Results : ", debugResult); this.logger.log("/-----END-----/"); if (this.showLogs) { return debugResult; } return { resultType: "character", characterString: debugResult.characterString }; } }; // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { DrawingAnalyzer });