Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
7e140cf
added a2 dev
dmitriy-myz Mar 3, 2021
d2dc7ea
added a2 dev
dmitriy-myz Mar 3, 2021
7ddfe80
06cb:00a2 work: this will recognize already added fingers; but how mi…
dmitriy-myz May 31, 2021
f303917
Support for 06cb:00a2 (enroll on host, match on chip)
dmitriy-myz May 20, 2026
647704b
Merge remote-tracking branch 'origin/master' into moh-opencv-cleanup
dmitriy-myz Jun 2, 2026
14cb813
fix for multiple finger enrollment
dmitriy-myz Jun 2, 2026
6a47a2f
reset blob reverted
dmitriy-myz Jun 2, 2026
67ebd21
correct init_hardcoded_clean_slate
dmitriy-myz Jun 2, 2026
c54e166
fix reset flow
dmitriy-myz Jun 2, 2026
862cb11
Refactor MoH enrollment out of sensor.py; merge moh_extract into moh_…
dmitriy-myz Jun 6, 2026
997c154
refactor(moh_native): remove unused code
dmitriy-myz Jun 6, 2026
9a7fbf5
Merge pull request #2 from dmitriy-myz/moh-native-remove-unused
dmitriy-myz Jun 6, 2026
048fe75
refactor(moh_native): native float pipeline (non-byte-exact experiment)
dmitriy-myz Jun 11, 2026
f7fbe9a
test(moh_native): smoke tests + review cleanups
dmitriy-myz Jun 11, 2026
480788c
Merge pull request #3 from dmitriy-myz/moh-native-float-experiment
dmitriy-myz Jun 11, 2026
3e870c2
build: declare numpy runtime dependency
dmitriy-myz Jun 11, 2026
ba8e1ee
refactor(enroll_moh_chip): drop optional cv2 resize path
dmitriy-myz Jun 11, 2026
3ee5e1c
Merge pull request #4 from dmitriy-myz/moh-native-float-experiment
dmitriy-myz Jun 11, 2026
504fe87
review cleanups: drop dev artifacts, align frame count, document capt…
dmitriy-myz Jun 11, 2026
377d233
enroll_moh: capture 6 frames by default, fill sections with the best 4
dmitriy-myz Jun 11, 2026
8451429
enroll_moh: document that frame selection approximates the DLL
dmitriy-myz Jun 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions debian/control
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Depends: ${python3:Depends},
python3-dbus,
python3-usb,
python3-yaml,
python3-numpy,
dbus,
open-fprintd (>= 0.6~),
innoextract (>= 1.6~)
Expand Down
226 changes: 226 additions & 0 deletions scripts/enroll_moh_chip.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
"""Debug/CLI tool: native enrollment on a 06cb:00a2 sensor.

Captures N frames from the connected sensor, runs the native feature
pipeline (validitysensor/moh_native.py), builds a chip-storable template
from the baked-in framing scaffold (blobs_a2.build_ws_scaffold) — no
captured reference template needed — stores it via raw 0x47, then
optionally tries to identify the finger to verify the chip accepts our
template.

Run on a machine with the sensor plugged in and python-validity
initialised (i.e., the usual `validity-sensors-firmware` & TLS handshake
have already been done — same prerequisites as the existing `enroll`
script in this repo).

Usage:
sudo python3 scripts/enroll_moh_chip.py \\
--parent <user_dbid> \\
[--match] # try to identify after enroll

--subtype N WinBio subtype (= finger position). Defaults to 0xf5
(right index, common test). Look at validitysensor/
fingerprint_constants.py for the full list.

--match after storing, capture a fresh frame and ask the chip
to identify (sensor.match_finger). If the chip matches
it back to the userid we just enrolled, the pipeline
is end-to-end working.
"""
import argparse
import logging
import os
import sys

sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))


def _try_match(log):
"""Capture a fresh frame and ask the chip to identify. Returns True on a
match, False on 'not recognized' (logged cleanly, no traceback)."""
from validitysensor.sensor import sensor as Sensor
log.info('LIFT FINGER, then place the SAME finger to verify the chip matches ...')
try:
usrid, subtype_out, hsh = Sensor.identify(
lambda e: log.warning(f'identify capture retry: {e}'))
log.info(f'✓ CHIP MATCHED: usrid={usrid}, subtype=0x{subtype_out:x}')
return True
except Exception as e:
log.warning(f'✗ NO MATCH ({e})')
return False


def main():
ap = argparse.ArgumentParser()
ap.add_argument('--subtype', default='0xf5',
help='WinBio subtype, hex or decimal (default 0xf5)')
ap.add_argument('--parent', type=int, default=5,
help='parent user dbid (use --list-users to find the '
'StgWindsor user dbid; default 5)')
ap.add_argument('--frames', type=int, default=6,
help='number of DISTINCT placements to capture (default 6); '
'the best 4 (most keypoints) fill the template\'s 4 v30 '
'sections. Vary finger placement between captures for '
'coverage.')
ap.add_argument('--match', action='store_true',
help='after enroll, capture again and try to identify')
ap.add_argument('--dry-run', action='store_true',
help='build the envelope but DO NOT store on chip; '
'write it to /tmp/native_envelope.bin instead')
ap.add_argument('--match-only', action='store_true',
help='do NOT enroll; just run the chip identify against '
'whatever is already stored.')
ap.add_argument('--delete-dbid', type=int, default=None,
help='delete the FINGER record with this dbid (see '
'--list-users), then exit. '
'Refuses to delete a USER record (would orphan fingers) '
'unless --force.')
ap.add_argument('--force', action='store_true',
help='allow --delete-dbid to delete a non-finger record')
ap.add_argument('--user-sid', default=None,
help='enroll under this user SID, creating the user if it '
'does not exist (self-contained: no pre-existing '
'user needed). Overrides --parent.')
ap.add_argument('--list-users', action='store_true',
help='dump the chip DB tree (db.dump_raw) and exit; '
'use to find a real parent dbid to pass via --parent')
args = ap.parse_args()

logging.basicConfig(level=logging.INFO,
format='%(asctime)s %(levelname)s %(message)s')
log = logging.getLogger('enroll_moh_chip')

# Imports that need the venv + libusb actually wired
from validitysensor.init import open as open_device
from validitysensor.sensor import sensor as Sensor, RebootException
from validitysensor.db import db

subtype = int(args.subtype, 0)
parent = args.parent

log.info('reference-free: using baked-in WS-body framing scaffold')

try:
open_device()
except RebootException:
log.info('sensor rebooted — re-opening')
open_device()

if args.delete_dbid is not None:
try:
rec = db.get_record_value(args.delete_dbid)
rtype = rec.type
except Exception:
rtype = None
if rtype is not None and rtype != 6 and not args.force:
log.error(f'dbid={args.delete_dbid} is type {rtype} '
f'({"USER" if rtype == 5 else "non-finger"}), not a FINGER '
f'(type 6). Deleting it would orphan its children. Pick a '
f'FINGER dbid from --list-users, or pass --force.')
return 2
log.info(f'deleting record dbid={args.delete_dbid} (type {rtype}) ...')
try:
db.del_record(args.delete_dbid)
log.info(f'✓ deleted dbid={args.delete_dbid}')
except Exception as e:
log.error(f'delete failed: {e}')
return 2
return 0

if args.match_only:
ok = _try_match(log)
log.info('MATCH-ONLY (chip identify against stored fingers): '
+ ('MATCHED.' if ok else 'NO MATCH.'))
return 0 if ok else 3

if args.list_users:
log.info('chip user storage + enrolled users (find parent dbid here):')
try:
stg = db.get_user_storage(name='StgWindsor')
log.info(f' StgWindsor: dbid={stg.dbid}, '
f'{len(stg.users)} user(s)')
for u_meta in stg.users:
udbid = u_meta['dbid']
try:
u = db.get_user(udbid)
log.info(f' user dbid={udbid} '
f'identity={u.identity!r} '
f'fingers={len(u.fingers)}')
for f in u.fingers:
log.info(f' finger dbid={f["dbid"]} '
f'subtype=0x{f["subtype"]:02x}')
except Exception as e:
log.info(f' user dbid={udbid} (could not parse: {e})')
except Exception as e:
log.error(f'get_user_storage failed: {e}')
log.info('try dumping all roots 1..16:')
for r in range(1, 17):
try:
rec = db.get_record_value(r)
val = bytes(rec.value)
log.info(f' root {r}: type={rec.type} '
f'val[:32]={val[:32].hex()}')
except Exception:
pass
return 0

if args.dry_run:
# Capture + build envelope but don't talk to the chip.
from validitysensor.sensor import CaptureMode, glow_start_scan, glow_end_scan
from validitysensor.moh_native import native_template
import numpy as np
glow_start_scan()
log.info('place finger now (dry-run, will not store)')
x, y, w1, w2, img_data = Sensor.capture(CaptureMode.ENROLL)
glow_end_scan()
img = np.frombuffer(img_data, dtype=np.uint8).reshape(x, y) # NO transpose (feature frame)
if img.shape != (112, 112):
ys = (np.arange(112) * img.shape[0] // 112)
xs = (np.arange(112) * img.shape[1] // 112)
img112 = img[ys[:, None], xs[None, :]]
else:
img112 = img
img_q16 = img112.astype(np.int32) << 16
# Log frame stats + save the raw frame for offline inspection.
# A good capture looks like: 112x112, min=0 max=255 mean~135 std~75.
log.info(f' CAPTURE: raw dims {x}x{y} ({len(img_data)}B); 112x112 '
f'min={int(img112.min())} max={int(img112.max())} '
f'mean={float(img112.mean()):.1f} std={float(img112.std()):.1f}')
_p = f'/tmp/native_capture_{x}x{y}.bin'
with open(_p, 'wb') as fp:
fp.write(img112.astype(np.uint8).tobytes())
log.info(f' saved capture -> {_p}')
from validitysensor.moh_native import extract_frame_native as _ext
log.info(f' pipeline on live frame: {len(_ext(img_q16))} keypoints')
envelope = native_template(img_q16, subtype=subtype)
with open('/tmp/native_envelope.bin', 'wb') as f:
f.write(envelope)
log.info(f'✓ wrote /tmp/native_envelope.bin ({len(envelope)} bytes)')
return 0

if args.user_sid:
usr = db.lookup_user(args.user_sid)
if usr is None:
parent = db.new_user(args.user_sid)
log.info(f'created user {args.user_sid!r} → dbid {parent}')
else:
parent = usr.dbid
log.info(f'using existing user {args.user_sid!r} → dbid {parent}')

log.info(f'enrolling subtype 0x{subtype:x} under parent dbid {parent} '
f'with {args.frames} frame(s)...')
recid = Sensor.enroll_moh(parent, subtype,
num_frames=args.frames)
log.info(f'✓ native enrollment stored, recid={recid}')

if args.match:
# identify() = capture(IDENTIFY) (waits for finger-present) + match.
if _try_match(log):
log.info(' → native pipeline produces chip-acceptable templates!')
return 0
return 3

return 0


if __name__ == '__main__':
sys.exit(main())
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
'bin/validity-led-dance',
'bin/validity-sensors-firmware',
],
install_requires=['cryptography >= 2.1.4', 'pyusb >= 1.0.0', 'pyyaml >= 3.12'],
install_requires=['cryptography >= 2.1.4', 'pyusb >= 1.0.0', 'pyyaml >= 3.12', 'numpy'],
data_files=[
('share/dbus-1/system.d/', ['dbus_service/io.github.uunicorn.Fprint.conf']),
('lib/python-validity/', ['dbus_service/dbus-service']),
Expand Down
67 changes: 67 additions & 0 deletions tests/test_moh_native_smoke.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"""Smoke tests for the native (float) MoH pipeline.

These do NOT pin byte-exact output (this branch is intentionally non-byte-exact
— see the commit that introduced the float rewrite). They guard the contract
that the chip parses literally: keypoint bounds, descriptor length, the v30
record layout, the envelope framing, and the TID round-trip.

Run: PYTHONPATH=. python -m pytest tests/test_moh_native_smoke.py
"""
import numpy as np
import pytest

from validitysensor import moh_native as m


def _synthetic_frame(seed=7):
"""A textured 112x112 uint8 frame (gaussian blobs + noise) → Q16."""
rng = np.random.default_rng(seed)
yy, xx = np.mgrid[0:112, 0:112]
img = np.full((112, 112), 128.0)
for _ in range(60):
cy, cx = rng.integers(8, 104, 2)
amp = rng.uniform(-90, 90)
r = rng.uniform(2, 6)
img += amp * np.exp(-((yy - cy) ** 2 + (xx - cx) ** 2) / (2 * r * r))
img = np.clip(img + rng.normal(0, 8, img.shape), 0, 255).astype(np.uint8)
return img.astype(np.int32) << 16


def test_extract_frame_native_shape_and_bounds():
kps = m.extract_frame_native(_synthetic_frame())
assert kps, "expected at least one keypoint on a textured frame"
assert len(kps) <= m.FRAME_KP_CAP
for gx, gy, orient, desc in kps:
assert 3 <= gx < 109 and 3 <= gy < 109, "keypoint outside the A960 bound"
assert 0.0 <= orient < m.TWO_PI
assert len(desc) == m.V30_DESC_LEN == 16


def test_serialize_v30_section_layout():
desc = bytes(range(16))
sec = m.serialize_v30_section([(5, 7, desc)], n_slots=m.V30_SECTION_RECORDS)
assert len(sec) == m.V30_SECTION_BYTES
assert sec[0:16] == desc # [16B desc]
assert sec[16] == 5 and sec[17] == 7 # [x][y]
assert sec[18:36] == bytes(18) # tail zero-padded


def test_native_template_envelope_and_tid():
env = m.native_template(_synthetic_frame(), subtype=0x00f5)
assert len(env) == 23136
ws = env[12:12 + m.WS_SIZE]
assert len(ws) == m.WS_SIZE
# TID is recomputable from the WS body alone (no device secret).
assert env[23072:23104] == m.compute_tid(ws)


def test_compute_tid_rejects_wrong_size():
with pytest.raises(ValueError):
m.compute_tid(b"\x00" * 100)


def test_subpix_refine_rejects_out_of_range():
resp = np.zeros((57, 57))
assert m.subpix_refine_kp(resp, 0, 10) is None # x on the border
assert m.subpix_refine_kp(resp, 10, 0) is None # y on the border
assert m.subpix_refine_kp(resp, 10, 10) is None # flat → singular Hessian
16 changes: 14 additions & 2 deletions validitysensor/blobs.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
def __load_blob(blob: str) -> bytes:
def __device_blobs():
from .usb import usb

if usb.usb_dev().idVendor == 0x138a:
Expand All @@ -11,12 +11,24 @@ def __load_blob(blob: str) -> bytes:
elif usb.usb_dev().idVendor == 0x06cb:
if usb.usb_dev().idProduct == 0x009a:
from . import blobs_9a as blobs
elif usb.usb_dev().idProduct == 0x00a2:
from . import blobs_a2 as blobs

return blobs


globals()[blob] = getattr(blobs, blob)
def __load_blob(blob: str) -> bytes:
globals()[blob] = getattr(__device_blobs(), blob)
return globals()[blob]


init_hardcoded = lambda: __load_blob('init_hardcoded')
init_hardcoded_clean_slate = lambda: __load_blob('init_hardcoded_clean_slate')
reset_blob = lambda: __load_blob('reset_blob')
db_write_enable = lambda: __load_blob('db_write_enable')

# Whether this device should enroll via the byte-exact native pipeline
# (Sensor.enroll_moh) instead of the DLL-style 0x68/0x6b enrollment
# session. Per-device blob modules opt in by defining `moh_enroll`;
# modules that don't define it default to False.
moh_enroll = lambda: bool(getattr(__device_blobs(), 'moh_enroll', False))
Loading