ink-signature: pen-plotter pipeline for original wet-ink CMS signatures
The Standard no-login CMS path needs an ORIGINAL ink signature on paper (CMS-10114: 'Stamped, faxed or copied signatures will not be accepted'). This adds a pipeline to redraw the provider's own captured strokes in real ink with a pen on a CR-10 V2 (or any Marlin/GRBL machine) — original, in ink, never copied. - migration 090: esign_records.signature_vector (JSONB stroke paths, 0..1). - signing page now captures normalized stroke paths alongside the PNG; API stores a size-bounded vector for drawn signatures. - ink_signature_plotter.py (hardware-independent): fit strokes to the signature anchor box, PDF-pt -> bed-mm via jig offset, emit Marlin/GRBL G-code (Z pen or M280 servo/BLTouch), SVG toolpath preview, and render_signature_on_pdf (a digital twin that proves the toolpath lands on the cert line). Gated serial sender (dry_run default). - ink_signature_cli.py: end-to-end load-record -> gcode+preview, --test-box jig calibration, --plot to stream over USB. - Corrected CMS-10114 signature anchor to sit inside the Section 4A signing cell (above the bottom rule, below the label). - docs/ink-signature-plotter.md documents the CR-10 retrofit + interpretive risk. Tests: test_ink_signature.py 30/30, test_cms10114.py 27/27, test_paper_batch.py 15/15, API tsc clean, Astro build 58 pages.
This commit is contained in:
parent
e6a630ada1
commit
b0a8563a93
8 changed files with 994 additions and 19 deletions
150
scripts/tests/test_ink_signature.py
Normal file
150
scripts/tests/test_ink_signature.py
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
"""Tests for the pen-plotter ink-signature pipeline (hardware-independent).
|
||||
|
||||
Verifies the geometry + emitters without any plotter:
|
||||
- strokes (0..1) fit inside the signature anchor box, aspect preserved
|
||||
- Y is flipped (capture top-left -> PDF bottom-left), ink rests on the rule
|
||||
- PDF-point -> bed-mm conversion uses the jig offset + correct unit scale
|
||||
- emitted G-code has pen up/down framing and stays on the bed
|
||||
- servo (BLTouch) pen mode emits M280 instead of Z moves
|
||||
- the test-box routine traces the anchor rectangle
|
||||
- render_signature_on_pdf stamps the strokes onto the real CMS-10114 cert page
|
||||
inside the signature cell (the visual correctness proof, checked numerically)
|
||||
|
||||
Run: python scripts/tests/test_ink_signature.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import math
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
|
||||
|
||||
def _load(name, rel):
|
||||
spec = importlib.util.spec_from_file_location(name, ROOT / rel)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[name] = mod # register so dataclass introspection works
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
ink = _load("ink_signature_plotter", "scripts/workers/services/ink_signature_plotter.py")
|
||||
filler = _load("cms10114_pdf_filler", "scripts/document_gen/templates/cms10114_pdf_filler.py")
|
||||
|
||||
_fails = 0
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
global _fails
|
||||
if not cond:
|
||||
_fails += 1
|
||||
print(f" {'PASS' if cond else 'FAIL'} {name}")
|
||||
|
||||
|
||||
def _synth_vector():
|
||||
def wave(n, x0, x1, y0, amp, ph):
|
||||
return [{"x": x0 + (x1 - x0) * k / (n - 1),
|
||||
"y": y0 + amp * math.sin(2 * math.pi * (k / (n - 1) * 2) + ph),
|
||||
"t": k * 8} for k in range(n)]
|
||||
return {"v": 1, "w": 600, "h": 160, "strokes": [
|
||||
wave(40, 0.08, 0.45, 0.55, 0.18, 0.0),
|
||||
wave(40, 0.50, 0.92, 0.55, 0.15, 1.2),
|
||||
]}
|
||||
|
||||
|
||||
BOX = {"field": "signer", "page": 4, "x": 48.0, "y": 443.0, "w": 422.0, "h": 14.0,
|
||||
"page_w": 612.0, "page_h": 792.0}
|
||||
|
||||
|
||||
def main():
|
||||
vector = _synth_vector()
|
||||
|
||||
print("fit_strokes_to_box (PDF points)")
|
||||
fitted = ink.fit_strokes_to_box(vector, BOX)
|
||||
pts = [p for s in fitted for p in s]
|
||||
xs = [p[0] for p in pts]
|
||||
ys = [p[1] for p in pts]
|
||||
check("strokes preserved", len(fitted) == 2)
|
||||
check("all x within box width", all(BOX["x"] - 0.5 <= x <= BOX["x"] + BOX["w"] + 0.5 for x in xs))
|
||||
check("all y within box height", all(BOX["y"] - 0.5 <= y <= BOX["y"] + BOX["h"] + 1.5 for y in ys))
|
||||
check("ink rests near the rule (min y close to box bottom)", min(ys) <= BOX["y"] + 3.0)
|
||||
# Aspect ratio preserved: drawn width/height ratio matches source ratio.
|
||||
src = vector["strokes"]
|
||||
sxs = [p["x"] for s in src for p in s]; sys_ = [p["y"] for s in src for p in s]
|
||||
src_ratio = (max(sxs) - min(sxs)) / (max(sys_) - min(sys_))
|
||||
drawn_ratio = (max(xs) - min(xs)) / (max(ys) - min(ys))
|
||||
check("aspect ratio preserved (±5%)", abs(drawn_ratio - src_ratio) / src_ratio < 0.05)
|
||||
|
||||
print("pdf points -> bed mm")
|
||||
cfg = ink.PlotterConfig()
|
||||
planned = ink.pdf_points_to_bed_mm(fitted, cfg)
|
||||
allmm = [p for s in planned for p in s.points_mm]
|
||||
# Box bottom-left in PDF (48,443) pt -> bed mm = jig + pt*PT_TO_MM.
|
||||
# The ink is left-aligned with a small horizontal pad (h_pad_frac of box w).
|
||||
exp_x = cfg.jig_x_mm + (48.0 + BOX["w"] * 0.04) * ink.PT_TO_MM
|
||||
exp_y = cfg.jig_y_mm + 443.0 * ink.PT_TO_MM
|
||||
minx = min(p[0] for p in allmm); miny = min(p[1] for p in allmm)
|
||||
check("bed x near jig + padded box-left in mm", abs(minx - exp_x) < 2.0)
|
||||
check("bed y near jig + box-bottom in mm", abs(miny - exp_y) < 3.0)
|
||||
check("all points within bed envelope",
|
||||
all(0 <= x <= cfg.bed_max_x_mm and 0 <= y <= cfg.bed_max_y_mm for (x, y) in allmm))
|
||||
|
||||
print("G-code emission (Z pen)")
|
||||
g = ink.emit_gcode(planned, cfg)
|
||||
check("declares mm + absolute", "G21" in g and "G90" in g)
|
||||
check("has pen-up Z move", f"Z{cfg.pen_up_mm:.2f}" in g)
|
||||
check("has pen-down Z move", f"Z{cfg.pen_down_mm:.2f}" in g)
|
||||
check("uses draw feed on G1 XY", f"F{cfg.draw_feed:.0f}" in g)
|
||||
check("uses travel feed on G0", f"F{cfg.travel_feed:.0f}" in g)
|
||||
check("no over-bed warning for in-bounds plot", "outside the configured bed" not in g)
|
||||
check("parks at end", "X0 Y0" in g)
|
||||
|
||||
print("G-code emission (servo / BLTouch pen)")
|
||||
cfg_servo = ink.PlotterConfig(servo_pen=True)
|
||||
gs = ink.emit_gcode(ink.pdf_points_to_bed_mm(fitted, cfg_servo), cfg_servo)
|
||||
check("servo mode uses M280 deploy", f"M280 P0 S{cfg_servo.servo_down_angle}" in gs)
|
||||
check("servo mode uses M280 stow", f"M280 P0 S{cfg_servo.servo_up_angle}" in gs)
|
||||
check("servo mode does not Z-lift", "Z3.00" not in gs)
|
||||
|
||||
print("calibration test-box")
|
||||
tb = ink.emit_test_box_gcode(cfg, BOX)
|
||||
check("test box has pen down + up", "pen down" in tb and "pen up" in tb)
|
||||
check("test box draws 4 segments back to start", tb.count("G1 X") >= 4)
|
||||
|
||||
print("over-bed safety warning")
|
||||
big_box = {**BOX, "x": 40000.0} # absurd -> way off bed
|
||||
g_big = ink.emit_gcode(ink.plan_signature(vector, big_box, cfg), cfg)
|
||||
check("over-bed plot warns", "outside the configured bed" in g_big)
|
||||
|
||||
print("preview SVG")
|
||||
svg = ink.render_preview_svg(planned, cfg)
|
||||
check("svg has path(s)", "<path" in svg)
|
||||
check("svg has pen-up travel dashes", "stroke-dasharray" in svg)
|
||||
|
||||
print("render onto REAL CMS-10114 cert page (visual proof, numeric)")
|
||||
sample = {"provider_name": "Jane Q Smith", "npi": "1234567893",
|
||||
"practice_state": "CA", "enumeration_type": "NPI-1"}
|
||||
pdf_bytes, anchors, _ = filler.fill_cms10114(sample, reason="change")
|
||||
check("filler exposes a signer anchor on cert page",
|
||||
anchors and anchors[0]["field"] == "signer" and anchors[0]["page"] == filler.PAGE_CERT)
|
||||
signed = ink.render_signature_on_pdf(pdf_bytes, vector, anchors, signer_field="signer")
|
||||
check("signed PDF is larger than blank (ink added)", len(signed) > len(pdf_bytes))
|
||||
# Confirm the fitted strokes for the real anchor land inside the cell band
|
||||
# (label at ~458 pt, bottom rule at ~441 pt; ink must sit between).
|
||||
fitted_real = ink.fit_strokes_to_box(vector, anchors[0])
|
||||
ys_real = [p[1] for s in fitted_real for p in s]
|
||||
check("real-anchor ink above bottom rule (>=441)", min(ys_real) >= 441.0)
|
||||
check("real-anchor ink below label (<=458)", max(ys_real) <= 458.0)
|
||||
|
||||
print()
|
||||
if _fails:
|
||||
print(f"FAILED: {_fails} checks")
|
||||
sys.exit(1)
|
||||
print("all ink-signature checks passed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue