template / learned_cost.py
ChrisMoe's picture
Upload HanziFlow stroke encoder
4ca96d1 verified
Raw History Blame Contribute Delete
2.63 kB
"""Learned stroke cost for HanziFlow.
Needs: numpy, onnxruntime, and the three files from the training notebook:
stroke_encoder.onnx, template_embeddings.npz, calibration.json
Strokes must be in the Make Me a Hanzi frame (1024 box, y up) and resampled to 32 points,
exactly as in the matcher notebook (prep()).
"""
import json
import numpy as np
import onnxruntime as ort
class LearnedCost:
def __init__(self, onnx_path="stroke_encoder.onnx", npz_path="template_embeddings.npz", calib_path="calibration.json"):
self.sess = ort.InferenceSession(onnx_path, providers=["CPUExecutionProvider"])
z = np.load(npz_path)
self.chars = [str(c) for c in z["chars"]]
self.templates = {c: z[f"emb_{i}"] for i, c in enumerate(self.chars)} # (n_strokes, 64) per character
cal = json.load(open(calib_path))
self.scale, self.tau, self.good = cal["scale"], cal["tau"], cal["good"]
self.alpha = cal.get("alpha_recommended", 0.5)
@staticmethod
def features(S):
# (n, 32, 2) points -> (n, 32, 6); identical to stroke_features() in the training notebook
S = np.asarray(S, np.float32)
x = (S[..., 0] - 512) / 512
y = (S[..., 1] - 388) / 512
g = np.gradient(S, axis=-2)
nrm = np.linalg.norm(g, axis=-1) + 1e-6
rx = (S[..., 0] - S[..., :1, 0]) / 512
ry = (S[..., 1] - S[..., :1, 1]) / 512
return np.stack([x, y, g[..., 0] / nrm, g[..., 1] / nrm, rx, ry], axis=-1).astype(np.float32)
def embed(self, S):
return self.sess.run(None, {"stroke_features": self.features(S)})[0]
def costs(self, char, U):
"""Learned cost of every drawn stroke (rows) against every template stroke of `char` (columns).
Direction-agnostic: a backwards stroke is flagged by the geometric part of the matcher, not here."""
te = self.templates[char]
fwd = np.linalg.norm(self.embed(U)[:, None, :] - te[None], axis=-1)
bwd = np.linalg.norm(self.embed(np.ascontiguousarray(U[:, ::-1]))[:, None, :] - te[None], axis=-1)
return np.minimum(fwd, bwd) / self.scale
def cost_fn(self, char, geometric_cost_matrix, alpha=None):
"""Drop-in `cost_fn` for align_full(): blends the geometric cost with the learned one.
alpha = 0 -> geometric only, 1 -> learned only, 0.5 -> half and half (recommended)."""
a = self.alpha if alpha is None else alpha
if a == 0:
return None
def fn(U, T):
C_geo, rev = geometric_cost_matrix(U, T)
return (1 - a) * C_geo + a * self.costs(char, U), rev
return fn