|
| 1 | +package com.kingzcheung.xime.handwriting.capture |
| 2 | + |
| 3 | +import org.json.JSONArray |
| 4 | +import org.json.JSONObject |
| 5 | +import java.io.ByteArrayOutputStream |
| 6 | +import java.nio.charset.Charset |
| 7 | + |
| 8 | +/** |
| 9 | + * HandwritingSample <-> JSON 编解码。 |
| 10 | + * |
| 11 | + * JSON 结构(训练侧契约): |
| 12 | + * ``` |
| 13 | + * { |
| 14 | + * "format": "xime-handwriting-capture", |
| 15 | + * "version": 1, |
| 16 | + * "normalization": "per-stroke-set bounding box: (x-minX)/rangeX, (y-minY)/rangeY", |
| 17 | + * "meta": { "app_version", "device", "android", "exported_at_ms", "sample_count" }, |
| 18 | + * "samples": [ |
| 19 | + * { |
| 20 | + * "target": "中", |
| 21 | + * "canvas": { "w": 520.0, "h": 380.0 }, |
| 22 | + * "strokes": [ { "t0": 0, "pts": [[x,y,dt], ...] }, { "t0": 430, ... } ], |
| 23 | + * "model_top": "中" | 缺省, |
| 24 | + * "model_top_score": 0.97 | 缺省 |
| 25 | + * } |
| 26 | + * ] |
| 27 | + * } |
| 28 | + * ``` |
| 29 | + * - x/y 为作画区局部像素坐标(原点左上,y 向下),未归一化; |
| 30 | + * - t0 为笔画起点相对样本首笔起点的毫秒数,pts 内第三列为相对 t0 的毫秒数; |
| 31 | + * - 归一化在训练侧执行,算法与推理侧 HandwritingEngine.strokesToSequence 一致。 |
| 32 | + */ |
| 33 | +object HandwritingSampleCodec { |
| 34 | + |
| 35 | + const val FORMAT = "xime-handwriting-capture" |
| 36 | + const val VERSION = 1 |
| 37 | + |
| 38 | + fun samplesToJsonArray(samples: List<HandwritingSample>): JSONArray { |
| 39 | + val arr = JSONArray() |
| 40 | + for (sample in samples) arr.put(sampleToJson(sample)) |
| 41 | + return arr |
| 42 | + } |
| 43 | + |
| 44 | + fun sampleToJson(sample: HandwritingSample): JSONObject { |
| 45 | + val obj = JSONObject() |
| 46 | + obj.put("target", sample.target) |
| 47 | + obj.put( |
| 48 | + "canvas", |
| 49 | + JSONObject().put("w", sample.canvasWidthPx.toDouble()).put("h", sample.canvasHeightPx.toDouble()) |
| 50 | + ) |
| 51 | + val strokes = JSONArray() |
| 52 | + for (stroke in sample.strokes) { |
| 53 | + val s = JSONObject() |
| 54 | + val t0 = stroke.firstOrNull()?.t ?: 0L |
| 55 | + s.put("t0", t0) |
| 56 | + val pts = JSONArray() |
| 57 | + for (p in stroke) { |
| 58 | + pts.put(JSONArray().put(p.x.toDouble()).put(p.y.toDouble()).put(p.t - t0)) |
| 59 | + } |
| 60 | + s.put("pts", pts) |
| 61 | + strokes.put(s) |
| 62 | + } |
| 63 | + obj.put("strokes", strokes) |
| 64 | + sample.modelTop?.let { obj.put("model_top", it) } |
| 65 | + sample.modelTopScore?.let { obj.put("model_top_score", it.toDouble()) } |
| 66 | + return obj |
| 67 | + } |
| 68 | + |
| 69 | + fun buildExportJson( |
| 70 | + samples: List<HandwritingSample>, |
| 71 | + appVersion: String, |
| 72 | + device: String, |
| 73 | + androidVersion: String, |
| 74 | + exportedAtMs: Long, |
| 75 | + ): String { |
| 76 | + val root = JSONObject() |
| 77 | + root.put("format", FORMAT) |
| 78 | + root.put("version", VERSION) |
| 79 | + root.put( |
| 80 | + "normalization", |
| 81 | + "per-sample bounding box: (x-minX)/max(rangeX,1), (y-minY)/max(rangeY,1); " + |
| 82 | + "identical to HandwritingEngine.strokesToSequence" |
| 83 | + ) |
| 84 | + root.put( |
| 85 | + "meta", |
| 86 | + JSONObject() |
| 87 | + .put("app_version", appVersion) |
| 88 | + .put("device", device) |
| 89 | + .put("android", androidVersion) |
| 90 | + .put("exported_at_ms", exportedAtMs) |
| 91 | + .put("sample_count", samples.size) |
| 92 | + ) |
| 93 | + root.put("samples", samplesToJsonArray(samples)) |
| 94 | + return root.toString(2) |
| 95 | + } |
| 96 | + |
| 97 | + fun parseFromJson(text: String): List<HandwritingSample> { |
| 98 | + val root = JSONObject(text) |
| 99 | + if (root.optString("format") != FORMAT) return emptyList() |
| 100 | + val arr = root.optJSONArray("samples") ?: return emptyList() |
| 101 | + val out = mutableListOf<HandwritingSample>() |
| 102 | + for (i in 0 until arr.length()) { |
| 103 | + val s = arr.optJSONObject(i) ?: continue |
| 104 | + val target = s.optString("target") |
| 105 | + if (target.isEmpty()) continue |
| 106 | + val canvas = s.optJSONObject("canvas") |
| 107 | + val w = canvas?.optDouble("w")?.toFloat() ?: 0f |
| 108 | + val h = canvas?.optDouble("h")?.toFloat() ?: 0f |
| 109 | + val modelTop = s.optString("model_top").ifEmpty { null } |
| 110 | + val modelTopScore = if (s.has("model_top_score")) s.optDouble("model_top_score").toFloat() else null |
| 111 | + val strokes = mutableListOf<List<StrokePointMs>>() |
| 112 | + val sArr = s.optJSONArray("strokes") ?: JSONArray() |
| 113 | + for (j in 0 until sArr.length()) { |
| 114 | + val st = sArr.optJSONObject(j) ?: continue |
| 115 | + val t0 = st.optLong("t0") |
| 116 | + val ptsArr = st.optJSONArray("pts") ?: JSONArray() |
| 117 | + val pts = mutableListOf<StrokePointMs>() |
| 118 | + for (k in 0 until ptsArr.length()) { |
| 119 | + val p = ptsArr.optJSONArray(k) ?: continue |
| 120 | + if (p.length() < 3) continue |
| 121 | + pts.add( |
| 122 | + StrokePointMs( |
| 123 | + x = p.optDouble(0).toFloat(), |
| 124 | + y = p.optDouble(1).toFloat(), |
| 125 | + t = t0 + p.optLong(2), |
| 126 | + ) |
| 127 | + ) |
| 128 | + } |
| 129 | + if (pts.isNotEmpty()) strokes.add(pts) |
| 130 | + } |
| 131 | + if (strokes.isNotEmpty()) { |
| 132 | + out.add(HandwritingSample(target, w, h, strokes, modelTop, modelTopScore)) |
| 133 | + } |
| 134 | + } |
| 135 | + return out |
| 136 | + } |
| 137 | +} |
0 commit comments