@edumedia/handwriting-character-recognition
Version:
Tool to recognize handwritten characters
1,096 lines (1,078 loc) • 37.1 kB
JavaScript
;
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
});