kanji-recognizer 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -2
- package/package.json +2 -2
- package/src/GeometryUtil.js +54 -13
- package/src/KanjiVGParser.js +3 -3
- package/src/KanjiWriter.js +134 -9
- package/src/StrokeRecognizer.js +5 -1
package/README.md
CHANGED
|
@@ -7,8 +7,9 @@ A lightweight, dependency-free JavaScript library for Kanji stroke order recogni
|
|
|
7
7
|
|
|
8
8
|
## ✨ Features
|
|
9
9
|
|
|
10
|
-
- **🎯 Accurate Recognition** - Uses geometric resampling algorithm
|
|
11
|
-
- **📝 Stroke-by-Stroke
|
|
10
|
+
- **🎯 Accurate Recognition** - Uses geometric resampling algorithm with centroid-based alignment for robust recognition
|
|
11
|
+
- **📝 Multiple Modes** - Supports Stroke-by-Stroke validation, Full Kanji check, and Free Write
|
|
12
|
+
- **🖼️ Image Export** - Export your drawings as PNG images for AI analysis or saving
|
|
12
13
|
- **🎨 Fully Customizable** - Easy to style colors, animations, and recognition sensitivity
|
|
13
14
|
- **⚡ Lightweight** - Zero dependencies, pure SVG-based rendering
|
|
14
15
|
- **📱 Mobile-Friendly** - Touch-optimized with pointer events
|
|
@@ -87,6 +88,7 @@ const writer = new KanjiWriter(elementId, kanjiData, options);
|
|
|
87
88
|
// Behavior
|
|
88
89
|
showGhost: true, // Show red guide for next stroke
|
|
89
90
|
showGrid: true, // Show background grid
|
|
91
|
+
checkMode: 'stroke', // 'stroke' (immediate), 'full' (manual), or 'free' (no validation)
|
|
90
92
|
|
|
91
93
|
// Recognition (Adjustable!)
|
|
92
94
|
passThreshold: 15, // Lower = stricter (10-20 recommended)
|
|
@@ -141,6 +143,25 @@ Clean up resources and remove event listeners.
|
|
|
141
143
|
writer.destroy();
|
|
142
144
|
```
|
|
143
145
|
|
|
146
|
+
##### `exportImage(options)`
|
|
147
|
+
Export the current drawing as a base64 PNG image.
|
|
148
|
+
|
|
149
|
+
```javascript
|
|
150
|
+
const dataUrl = await writer.exportImage({
|
|
151
|
+
includeGrid: false,
|
|
152
|
+
backgroundColor: '#ffffff'
|
|
153
|
+
});
|
|
154
|
+
// Send dataUrl to AI or save it
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
##### `check()`
|
|
158
|
+
Manually trigger evaluation of all collected strokes (only for `checkMode: 'full'`).
|
|
159
|
+
|
|
160
|
+
```javascript
|
|
161
|
+
const result = writer.check();
|
|
162
|
+
if (result.success) console.log("All strokes correct!");
|
|
163
|
+
```
|
|
164
|
+
|
|
144
165
|
#### Event Callbacks
|
|
145
166
|
|
|
146
167
|
```javascript
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kanji-recognizer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "A robust Kanji stroke order recognition and validation library using KanjiVG data.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -41,4 +41,4 @@
|
|
|
41
41
|
"url": "https://github.com/mxggle/kanji-recognizer/issues"
|
|
42
42
|
},
|
|
43
43
|
"homepage": "https://github.com/mxggle/kanji-recognizer#readme"
|
|
44
|
-
}
|
|
44
|
+
}
|
package/src/GeometryUtil.js
CHANGED
|
@@ -57,32 +57,73 @@ export class GeometryUtil {
|
|
|
57
57
|
return newPoints;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Get the centroid (center of mass) of a set of points
|
|
62
|
+
*/
|
|
63
|
+
static getCentroid(points) {
|
|
64
|
+
if (!points || points.length === 0) return { x: 0, y: 0 };
|
|
65
|
+
let sumX = 0;
|
|
66
|
+
let sumY = 0;
|
|
67
|
+
for (const p of points) {
|
|
68
|
+
sumX += p.x;
|
|
69
|
+
sumY += p.y;
|
|
70
|
+
}
|
|
71
|
+
return { x: sumX / points.length, y: sumY / points.length };
|
|
72
|
+
}
|
|
73
|
+
|
|
60
74
|
/**
|
|
61
75
|
* Compare two strokes. Returns a score (lower is better, 0 is perfect).
|
|
62
|
-
*
|
|
76
|
+
* Now includes translation normalization to be more robust.
|
|
63
77
|
* @param {Array} userPoints - User's drawn points
|
|
64
78
|
* @param {Array} targetPoints - Target stroke points
|
|
65
|
-
* @param {
|
|
79
|
+
* @param {Object} options - Thresholds and weights
|
|
66
80
|
*/
|
|
67
|
-
static compareStrokes(userPoints, targetPoints,
|
|
81
|
+
static compareStrokes(userPoints, targetPoints, options = {}) {
|
|
82
|
+
const {
|
|
83
|
+
startDistThreshold = 50,
|
|
84
|
+
translationWeight = 0.3, // How much absolute position matters (0-1)
|
|
85
|
+
shapeWeight = 0.7 // How much shape accuracy matters (0-1)
|
|
86
|
+
} = options;
|
|
87
|
+
|
|
68
88
|
const resampledUser = this.resample(userPoints);
|
|
69
89
|
const resampledTarget = this.resample(targetPoints);
|
|
70
90
|
|
|
71
|
-
// 1.
|
|
91
|
+
// 1. Initial Position Check
|
|
92
|
+
// We still want the stroke to start *somewhere* near the expected start
|
|
72
93
|
const startDist = this.distance(resampledUser[0], resampledTarget[0]);
|
|
73
|
-
|
|
94
|
+
if (startDist > startDistThreshold) return Infinity;
|
|
95
|
+
|
|
96
|
+
// 2. Alignment (Translation Normalization)
|
|
97
|
+
// Calculate centroids
|
|
98
|
+
const userCentroid = this.getCentroid(resampledUser);
|
|
99
|
+
const targetCentroid = this.getCentroid(resampledTarget);
|
|
100
|
+
|
|
101
|
+
// Calculate translation cost (distance between centroids)
|
|
102
|
+
const translationCost = this.distance(userCentroid, targetCentroid);
|
|
74
103
|
|
|
75
|
-
//
|
|
76
|
-
|
|
77
|
-
|
|
104
|
+
// 3. Shape Check (Aligned average distance)
|
|
105
|
+
let shapeDist = 0;
|
|
106
|
+
const dx = targetCentroid.x - userCentroid.x;
|
|
107
|
+
const dy = targetCentroid.y - userCentroid.y;
|
|
78
108
|
|
|
79
|
-
// 2. Shape Check: Average distance between points
|
|
80
|
-
let totalDist = 0;
|
|
81
109
|
for (let i = 0; i < resampledUser.length; i++) {
|
|
82
|
-
|
|
110
|
+
// Compare user point (shifted to target space) vs target point
|
|
111
|
+
const shiftedUserPoint = {
|
|
112
|
+
x: resampledUser[i].x + dx,
|
|
113
|
+
y: resampledUser[i].y + dy
|
|
114
|
+
};
|
|
115
|
+
shapeDist += this.distance(shiftedUserPoint, resampledTarget[i]);
|
|
83
116
|
}
|
|
84
|
-
const
|
|
117
|
+
const shapeCost = shapeDist / resampledUser.length;
|
|
118
|
+
|
|
119
|
+
// 4. Combined weighted score
|
|
120
|
+
// This is more robust because if you draw the right shape slightly shifted,
|
|
121
|
+
// the shapeCost will be low, and translationCost will be moderate,
|
|
122
|
+
// allowing it to pass even if the absolute coordinates are off.
|
|
123
|
+
const totalScore = (shapeCost * shapeWeight) + (translationCost * translationWeight);
|
|
124
|
+
|
|
125
|
+
console.log(`Recognition Debug - Shape: ${shapeCost.toFixed(2)}, Trans: ${translationCost.toFixed(2)}, Total: ${totalScore.toFixed(2)}`);
|
|
85
126
|
|
|
86
|
-
return
|
|
127
|
+
return totalScore;
|
|
87
128
|
}
|
|
88
129
|
}
|
package/src/KanjiVGParser.js
CHANGED
|
@@ -40,7 +40,7 @@ export class KanjiVGParser {
|
|
|
40
40
|
throw new Error('Invalid character: must be a non-empty string');
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
let code = char.
|
|
43
|
+
let code = char.codePointAt(0).toString(16).toLowerCase();
|
|
44
44
|
while (code.length < 5) code = "0" + code;
|
|
45
45
|
return code;
|
|
46
46
|
}
|
|
@@ -55,8 +55,8 @@ export class KanjiVGParser {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
let hex = char;
|
|
58
|
-
// If it looks like a single character, convert to hex
|
|
59
|
-
if (char.length === 1
|
|
58
|
+
// If it looks like a single character (including surrogate pairs), convert to hex
|
|
59
|
+
if (Array.from(char).length === 1) {
|
|
60
60
|
hex = this.getHex(char);
|
|
61
61
|
}
|
|
62
62
|
|
package/src/KanjiWriter.js
CHANGED
|
@@ -18,17 +18,12 @@ export class KanjiWriter {
|
|
|
18
18
|
// Toggles
|
|
19
19
|
showGhost: true, // Show red guide for next stroke
|
|
20
20
|
showGrid: true, // Show the background grid
|
|
21
|
+
checkMode: 'stroke', // 'stroke' (immediate), 'full' (manual), or 'free' (no validation)
|
|
21
22
|
|
|
22
23
|
// Appearance
|
|
23
24
|
strokeWidth: 4,
|
|
24
25
|
gridWidth: 0.5,
|
|
25
26
|
ghostOpacity: "0.1",
|
|
26
|
-
|
|
27
|
-
// Animations
|
|
28
|
-
stepDuration: 500,
|
|
29
|
-
hintDuration: 800,
|
|
30
|
-
snapDuration: 200,
|
|
31
|
-
|
|
32
27
|
...options
|
|
33
28
|
};
|
|
34
29
|
|
|
@@ -44,6 +39,7 @@ export class KanjiWriter {
|
|
|
44
39
|
this.currentStrokeIndex = 0;
|
|
45
40
|
this.isDrawing = false;
|
|
46
41
|
this.currentPoints = [];
|
|
42
|
+
this.userStrokes = []; // Store strokes for 'full' mode
|
|
47
43
|
|
|
48
44
|
// Use options.width/height in initSVG
|
|
49
45
|
this.width = this.options.width;
|
|
@@ -112,7 +108,7 @@ export class KanjiWriter {
|
|
|
112
108
|
// Optional: render faint outline of the next stroke
|
|
113
109
|
this.bgGroup.innerHTML = '';
|
|
114
110
|
|
|
115
|
-
if (!this.options.showGhost) return;
|
|
111
|
+
if (!this.options.showGhost || this.options.checkMode === 'free') return;
|
|
116
112
|
|
|
117
113
|
// If we want to show the full ghost:
|
|
118
114
|
this.kanjiData.forEach((d, i) => {
|
|
@@ -136,7 +132,8 @@ export class KanjiWriter {
|
|
|
136
132
|
attachEvents() {
|
|
137
133
|
// Store bound functions as instance properties for cleanup
|
|
138
134
|
this.boundStart = (e) => {
|
|
139
|
-
if
|
|
135
|
+
// Only stop drawing if in a validation mode and kanji is finished
|
|
136
|
+
if (this.options.checkMode !== 'free' && this.currentStrokeIndex >= this.kanjiData.length) return;
|
|
140
137
|
e.preventDefault();
|
|
141
138
|
this.isDrawing = true;
|
|
142
139
|
this.currentPoints = [];
|
|
@@ -168,7 +165,24 @@ export class KanjiWriter {
|
|
|
168
165
|
this.boundEnd = (e) => {
|
|
169
166
|
if (!this.isDrawing) return;
|
|
170
167
|
this.isDrawing = false;
|
|
171
|
-
|
|
168
|
+
|
|
169
|
+
if (this.options.checkMode === 'stroke') {
|
|
170
|
+
this.evaluateStroke();
|
|
171
|
+
} else if (this.options.checkMode === 'full') {
|
|
172
|
+
// In 'full' mode, we just store the points and keep the path
|
|
173
|
+
this.userStrokes.push({
|
|
174
|
+
points: this.currentPoints,
|
|
175
|
+
path: this.currentPath
|
|
176
|
+
});
|
|
177
|
+
this.currentStrokeIndex++;
|
|
178
|
+
this.renderUpcomingStrokes();
|
|
179
|
+
} else {
|
|
180
|
+
// In 'free' mode, we just leave the path in the currentGroup
|
|
181
|
+
// and maybe track it if we want to support undo later
|
|
182
|
+
this.userStrokes.push({
|
|
183
|
+
path: this.currentPath
|
|
184
|
+
});
|
|
185
|
+
}
|
|
172
186
|
};
|
|
173
187
|
|
|
174
188
|
this.svg.addEventListener('pointerdown', this.boundStart);
|
|
@@ -258,11 +272,64 @@ export class KanjiWriter {
|
|
|
258
272
|
setTimeout(() => { pathRef.remove(); }, 600);
|
|
259
273
|
}
|
|
260
274
|
|
|
275
|
+
/**
|
|
276
|
+
* Manual check for 'full' mode
|
|
277
|
+
* @returns {Object} result - Success and detailed results per stroke
|
|
278
|
+
*/
|
|
279
|
+
check() {
|
|
280
|
+
if (this.options.checkMode !== 'full') {
|
|
281
|
+
console.warn("check() called but checkMode is not 'full'");
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const results = [];
|
|
286
|
+
let allCorrect = true;
|
|
287
|
+
|
|
288
|
+
// Evaluate each user stroke against corresponding target stroke
|
|
289
|
+
for (let i = 0; i < this.kanjiData.length; i++) {
|
|
290
|
+
const userStroke = this.userStrokes[i];
|
|
291
|
+
const targetD = this.kanjiData[i];
|
|
292
|
+
|
|
293
|
+
if (!userStroke) {
|
|
294
|
+
results.push({ success: false, message: "Missing stroke" });
|
|
295
|
+
allCorrect = false;
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const result = this.recognizer.evaluate(userStroke.points, targetD);
|
|
300
|
+
results.push(result);
|
|
301
|
+
|
|
302
|
+
if (!result.success) {
|
|
303
|
+
allCorrect = false;
|
|
304
|
+
userStroke.path.setAttribute("stroke", this.options.incorrectColor);
|
|
305
|
+
} else {
|
|
306
|
+
userStroke.path.setAttribute("stroke", this.options.correctColor);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// If there are extra strokes, they are incorrect
|
|
311
|
+
if (this.userStrokes.length > this.kanjiData.length) {
|
|
312
|
+
allCorrect = false;
|
|
313
|
+
for (let i = this.kanjiData.length; i < this.userStrokes.length; i++) {
|
|
314
|
+
this.userStrokes[i].path.setAttribute("stroke", this.options.incorrectColor);
|
|
315
|
+
results.push({ success: false, message: "Extra stroke" });
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (allCorrect && this.userStrokes.length === this.kanjiData.length) {
|
|
320
|
+
console.log("Full Kanji Correct!");
|
|
321
|
+
if (this.onComplete) this.onComplete();
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return { success: allCorrect, results };
|
|
325
|
+
}
|
|
326
|
+
|
|
261
327
|
/**
|
|
262
328
|
* Public API: Clear the canvas and reset progress
|
|
263
329
|
*/
|
|
264
330
|
clear() {
|
|
265
331
|
this.currentStrokeIndex = 0;
|
|
332
|
+
this.userStrokes = [];
|
|
266
333
|
this.drawnGroup.innerHTML = '';
|
|
267
334
|
this.currentGroup.innerHTML = '';
|
|
268
335
|
this.bgGroup.innerHTML = '';
|
|
@@ -389,4 +456,62 @@ export class KanjiWriter {
|
|
|
389
456
|
}
|
|
390
457
|
});
|
|
391
458
|
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Export the current drawing as a base64 PNG image
|
|
462
|
+
* @param {Object} options - Export options (includeGrid, includeGhost, etc.)
|
|
463
|
+
* @returns {Promise<string>} Base64 Data URL
|
|
464
|
+
*/
|
|
465
|
+
async exportImage(options = {}) {
|
|
466
|
+
const {
|
|
467
|
+
includeGrid = false,
|
|
468
|
+
includeGhost = false,
|
|
469
|
+
backgroundColor = "#ffffff",
|
|
470
|
+
width = 109,
|
|
471
|
+
height = 109
|
|
472
|
+
} = options;
|
|
473
|
+
|
|
474
|
+
// Create a clone of the SVG to manipulate without affecting UI
|
|
475
|
+
const clone = this.svg.cloneNode(true);
|
|
476
|
+
|
|
477
|
+
// Find the groups in the clone
|
|
478
|
+
const groups = Array.from(clone.querySelectorAll('g'));
|
|
479
|
+
const gridGroup = groups[0];
|
|
480
|
+
const bgGroup = groups[1];
|
|
481
|
+
|
|
482
|
+
// Toggle visibility based on options
|
|
483
|
+
if (!includeGrid && gridGroup) gridGroup.setAttribute('visibility', 'hidden');
|
|
484
|
+
if (!includeGhost && bgGroup) bgGroup.setAttribute('visibility', 'hidden');
|
|
485
|
+
|
|
486
|
+
// Ensure background color if SVG doesn't have one
|
|
487
|
+
clone.style.background = backgroundColor;
|
|
488
|
+
|
|
489
|
+
// Serialize to XML
|
|
490
|
+
const serializer = new XMLSerializer();
|
|
491
|
+
const svgString = serializer.serializeToString(clone);
|
|
492
|
+
const svgBlob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
|
|
493
|
+
const url = URL.createObjectURL(svgBlob);
|
|
494
|
+
|
|
495
|
+
return new Promise((resolve, reject) => {
|
|
496
|
+
const img = new Image();
|
|
497
|
+
img.onload = () => {
|
|
498
|
+
const canvas = document.createElement('canvas');
|
|
499
|
+
// Use higher resolution for export if desired, but 109x109 is KanjiVG base
|
|
500
|
+
// We scales up to the container's current pixel size for better quality
|
|
501
|
+
const scale = 2; // Export at 2x for better AI recognition
|
|
502
|
+
canvas.width = width * scale;
|
|
503
|
+
canvas.height = height * scale;
|
|
504
|
+
|
|
505
|
+
const ctx = canvas.getContext('2d');
|
|
506
|
+
ctx.fillStyle = backgroundColor;
|
|
507
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
508
|
+
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
|
509
|
+
|
|
510
|
+
URL.revokeObjectURL(url);
|
|
511
|
+
resolve(canvas.toDataURL('image/png'));
|
|
512
|
+
};
|
|
513
|
+
img.onerror = reject;
|
|
514
|
+
img.src = url;
|
|
515
|
+
});
|
|
516
|
+
}
|
|
392
517
|
}
|
package/src/StrokeRecognizer.js
CHANGED
|
@@ -68,7 +68,11 @@ export class StrokeRecognizer {
|
|
|
68
68
|
return { success: false, score: 100, message: "Length mismatch" };
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
const score = GeometryUtil.compareStrokes(userPoints, targetPoints,
|
|
71
|
+
const score = GeometryUtil.compareStrokes(userPoints, targetPoints, {
|
|
72
|
+
startDistThreshold: this.options.startDistThreshold,
|
|
73
|
+
translationWeight: 0.3,
|
|
74
|
+
shapeWeight: 0.7
|
|
75
|
+
});
|
|
72
76
|
|
|
73
77
|
return {
|
|
74
78
|
success: score < this.options.passThreshold,
|