Prvy
This commit is contained in:
commit
f9265f2d4d
18
.gitignore
vendored
Normal file
18
.gitignore
vendored
Normal file
@ -0,0 +1,18 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
*.py[cod]
|
||||
*.db
|
||||
*.sqlite3
|
||||
.env
|
||||
data/raw/*
|
||||
!data/raw/*/
|
||||
!data/raw/*/.gitkeep
|
||||
data/processed/*
|
||||
!data/processed/.gitkeep
|
||||
results/*
|
||||
!results/.gitkeep
|
||||
*.png
|
||||
*.pdf
|
||||
*.csv
|
||||
!src/**/*.csv
|
||||
BIN
MOBISIG.ZIP
Normal file
BIN
MOBISIG.ZIP
Normal file
Binary file not shown.
78
README.md
Normal file
78
README.md
Normal file
@ -0,0 +1,78 @@
|
||||
# Signature verification baseline
|
||||
|
||||
A small research starter project for online handwritten-signature verification.
|
||||
It includes common-format CSV/JSON loading, preprocessing, feature extraction,
|
||||
DTW matching, threshold metrics, and a local FastAPI capture demo.
|
||||
|
||||
## Setup
|
||||
|
||||
From the project root:
|
||||
|
||||
```powershell
|
||||
py -m venv .venv
|
||||
.\.venv\Scripts\Activate.ps1
|
||||
python -m pip install -e ".[dev]"
|
||||
$env:SIGVERIFY_FERNET_KEY = python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
Open <http://127.0.0.1:8000/>. The generated Fernet key is for local testing;
|
||||
keep the same key between runs to read previously enrolled templates. Store a
|
||||
production key in a secret manager, never in source control. Without a key, the
|
||||
API rejects enrollment and verification rather than storing plaintext.
|
||||
|
||||
Run tests with `pytest`. The package can also be installed with `pip install -e .`.
|
||||
|
||||
## Data format
|
||||
|
||||
The common signature format has one sample per point with `user_id`, `t`, `x`,
|
||||
`y`, optional `p` (pressure), optional `area` (contact area), and optional
|
||||
`pen_down`. CSV loaders expect these names by default; pass column overrides
|
||||
for a dataset with different headers. A JSON signature uses the same names
|
||||
with array-valued `t`, `x`, and `y`. For MOBISIG, check the downloaded release's
|
||||
layout is supported directly by `load_mobisig`: `timestamp`, `x`, `y`,
|
||||
`pressure`, and `fingerarea` are mapped automatically. Names such as
|
||||
`SIGN_GEN_USER1_USER1_1.csv` and `SIGN_FOR_USER1_USER2_1.csv` determine the
|
||||
claimed user and genuine/forgery label; metadata files such as `users.csv` are
|
||||
ignored. Put downloaded or self-collected material under `data/raw/`. Raw data
|
||||
is ignored by Git.
|
||||
|
||||
## Baseline workflow
|
||||
|
||||
1. Load signatures into `Signature`.
|
||||
2. Sort timestamps, collapse repeated times, lightly smooth, resample, and
|
||||
translate/scale positions. Preprocessing records the original bounding-box
|
||||
diagonal because size may itself be discriminative. Pen-up samples are
|
||||
excluded from the baseline trajectory.
|
||||
3. Extract x/y and motion features; pressure and contact area can be enabled as
|
||||
explicit ablations.
|
||||
4. Enroll several genuine examples, compare using band-constrained DTW, and
|
||||
divide by the median within-enrollment distance.
|
||||
5. Tune global and per-user thresholds on a development split; evaluate only
|
||||
on held-out attempts. Thresholds in the demo are illustrative, not research
|
||||
results.
|
||||
|
||||
`experiments/` contains starter scripts for inspecting data, running a simple
|
||||
split, and comparing feature sets. Report user-disjoint development/test
|
||||
results separately from the API demo. Do not train or tune on the test set.
|
||||
|
||||
For the included MOBISIG layout, run:
|
||||
|
||||
```powershell
|
||||
python experiments/02_baseline_dtw.py --data-dir data/raw/mobisig `
|
||||
--output results/mobisig_baseline.json
|
||||
```
|
||||
|
||||
## Capture and consent
|
||||
|
||||
The browser sends pointer positions, elapsed milliseconds, and optional
|
||||
pressure. It does not persist anything in the browser. Review
|
||||
[`docs/data_collection_protocol.md`](docs/data_collection_protocol.md) and
|
||||
[`docs/consent_form.md`](docs/consent_form.md) before collecting data; adapt
|
||||
them to institutional requirements and obtain approval where required.
|
||||
|
||||
The SQLite demo stores encrypted template JSON and a small lockout counter in
|
||||
process memory. Lockout state resets when the server restarts, so this is not a
|
||||
production account-security system. Use HTTPS, authentication, durable
|
||||
rate-limiting, access controls, key management, and a retention/deletion policy
|
||||
before exposing a service beyond localhost.
|
||||
1
app/__init__.py
Normal file
1
app/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Local FastAPI application."""
|
||||
38
app/db.py
Normal file
38
app/db.py
Normal file
@ -0,0 +1,38 @@
|
||||
"""SQLite persistence for encrypted enrollment templates."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import LargeBinary, String, create_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, sessionmaker
|
||||
|
||||
DATABASE_URL = os.environ.get(
|
||||
"SIGVERIFY_DATABASE_URL",
|
||||
f"sqlite:///{(Path(__file__).resolve().parents[1] / 'data' / 'signatures.sqlite3').as_posix()}",
|
||||
)
|
||||
engine = create_engine(
|
||||
DATABASE_URL,
|
||||
connect_args={"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {},
|
||||
)
|
||||
SessionLocal = sessionmaker(bind=engine, expire_on_commit=False)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Declarative base for API database tables."""
|
||||
|
||||
|
||||
class SignatureTemplate(Base):
|
||||
"""An encrypted sample belonging to one claimed user identity."""
|
||||
|
||||
__tablename__ = "signature_templates"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[str] = mapped_column(String(200), index=True)
|
||||
encrypted_payload: Mapped[bytes] = mapped_column(LargeBinary)
|
||||
|
||||
|
||||
def initialize_database() -> None:
|
||||
"""Create missing tables and ensure the SQLite data directory exists."""
|
||||
if DATABASE_URL.startswith("sqlite:///"):
|
||||
Path(DATABASE_URL.removeprefix("sqlite:///")).parent.mkdir(parents=True, exist_ok=True)
|
||||
Base.metadata.create_all(engine)
|
||||
146
app/main.py
Normal file
146
app/main.py
Normal file
@ -0,0 +1,146 @@
|
||||
"""FastAPI endpoints for enrollment, verification, and user listing."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db import SessionLocal, SignatureTemplate, initialize_database
|
||||
from sigverify.decision import Thresholds
|
||||
from sigverify.io.schema import Signature
|
||||
from sigverify.matching.models import DTWVerifier
|
||||
from sigverify.preprocessing import preprocess_signature
|
||||
from sigverify.security import AttemptLockout, TemplateCipher
|
||||
|
||||
app = FastAPI(title="Signature Verification Demo", version="0.1.0")
|
||||
STATIC_DIR = Path(__file__).resolve().parent / "static"
|
||||
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
||||
initialize_database()
|
||||
LOCKOUT = AttemptLockout(max_failures=5, lockout_seconds=60)
|
||||
THRESHOLDS = Thresholds(global_threshold=float(os.environ.get("SIGVERIFY_THRESHOLD", "1.0")))
|
||||
|
||||
|
||||
class CapturePayload(BaseModel):
|
||||
"""JSON body accepted by enrollment and verification endpoints."""
|
||||
|
||||
user_id: str = Field(min_length=1, max_length=200)
|
||||
t: list[float] = Field(min_length=1)
|
||||
x: list[float] = Field(min_length=1)
|
||||
y: list[float] = Field(min_length=1)
|
||||
p: list[float] | None = None
|
||||
area: list[float] | None = None
|
||||
pen_down: list[bool] | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_lengths(self) -> "CapturePayload":
|
||||
length = len(self.t)
|
||||
for name in ("x", "y", "p", "area", "pen_down"):
|
||||
values = getattr(self, name)
|
||||
if values is not None and len(values) != length:
|
||||
raise ValueError(f"{name} must have the same number of points as t")
|
||||
return self
|
||||
|
||||
def to_signature(self, label: str) -> Signature:
|
||||
return Signature(
|
||||
user_id=self.user_id,
|
||||
label=label,
|
||||
t=self.t,
|
||||
x=self.x,
|
||||
y=self.y,
|
||||
p=self.p,
|
||||
area=self.area,
|
||||
pen_down=self.pen_down,
|
||||
)
|
||||
|
||||
|
||||
def _cipher() -> TemplateCipher:
|
||||
try:
|
||||
return TemplateCipher()
|
||||
except RuntimeError as error:
|
||||
raise HTTPException(status_code=503, detail=str(error)) from error
|
||||
|
||||
|
||||
def _prepared(payload: CapturePayload, label: str) -> Signature:
|
||||
try:
|
||||
return preprocess_signature(payload.to_signature(label))
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=422, detail=str(error)) from error
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def home():
|
||||
"""Serve the browser signing page."""
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
return FileResponse(STATIC_DIR / "index.html")
|
||||
|
||||
|
||||
@app.post("/enroll")
|
||||
def enroll(payload: CapturePayload) -> dict[str, str]:
|
||||
"""Add one genuine enrollment template for a user."""
|
||||
cipher = _cipher()
|
||||
signature = _prepared(payload, "genuine")
|
||||
with SessionLocal() as session:
|
||||
session.add(
|
||||
SignatureTemplate(
|
||||
user_id=signature.user_id,
|
||||
encrypted_payload=cipher.encrypt(signature),
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
return {"status": "enrolled", "user_id": signature.user_id}
|
||||
|
||||
|
||||
@app.post("/verify")
|
||||
def verify(payload: CapturePayload) -> dict[str, float | bool | str]:
|
||||
"""Score a capture against all encrypted samples enrolled for its user."""
|
||||
user_id = payload.user_id
|
||||
remaining = LOCKOUT.remaining_lockout(user_id)
|
||||
if remaining > 0:
|
||||
raise HTTPException(status_code=429, detail=f"locked; retry in {remaining:.1f} seconds")
|
||||
|
||||
cipher = _cipher()
|
||||
with SessionLocal() as session:
|
||||
records = session.scalars(
|
||||
select(SignatureTemplate).where(SignatureTemplate.user_id == user_id)
|
||||
).all()
|
||||
if not records:
|
||||
raise HTTPException(status_code=404, detail="no enrollment templates for this user")
|
||||
try:
|
||||
templates = [cipher.decrypt(record.encrypted_payload) for record in records]
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=503, detail=str(error)) from error
|
||||
|
||||
verifier = DTWVerifier()
|
||||
verifier.enroll(templates)
|
||||
score = verifier.score(_prepared(payload, "query"))
|
||||
accepted = THRESHOLDS.accept(user_id, score)
|
||||
if accepted:
|
||||
LOCKOUT.record_success(user_id)
|
||||
else:
|
||||
LOCKOUT.record_failure(user_id)
|
||||
return {
|
||||
"user_id": user_id,
|
||||
"score": score,
|
||||
"threshold": THRESHOLDS.threshold_for(user_id),
|
||||
"accepted": accepted,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/users")
|
||||
def users() -> dict[str, list[dict[str, str | int]]]:
|
||||
"""List enrolled identities and their template counts."""
|
||||
with SessionLocal() as session:
|
||||
records = session.scalars(select(SignatureTemplate)).all()
|
||||
counts: dict[str, int] = {}
|
||||
for record in records:
|
||||
counts[record.user_id] = counts.get(record.user_id, 0) + 1
|
||||
return {
|
||||
"users": [
|
||||
{"user_id": user_id, "template_count": count}
|
||||
for user_id, count in sorted(counts.items())
|
||||
]
|
||||
}
|
||||
98
app/static/capture.js
Normal file
98
app/static/capture.js
Normal file
@ -0,0 +1,98 @@
|
||||
(() => {
|
||||
const canvas = document.getElementById("pad");
|
||||
const context = canvas.getContext("2d");
|
||||
const userId = document.getElementById("user-id");
|
||||
const status = document.getElementById("status");
|
||||
const points = [];
|
||||
let drawing = false;
|
||||
let startTime = 0;
|
||||
|
||||
context.lineWidth = 3;
|
||||
context.lineCap = "round";
|
||||
context.lineJoin = "round";
|
||||
|
||||
function pointFrom(event, penDown) {
|
||||
const bounds = canvas.getBoundingClientRect();
|
||||
const scaleX = canvas.width / bounds.width;
|
||||
const scaleY = canvas.height / bounds.height;
|
||||
return {
|
||||
t: performance.now() - startTime,
|
||||
x: (event.clientX - bounds.left) * scaleX,
|
||||
y: (event.clientY - bounds.top) * scaleY,
|
||||
p: event.pressure || (penDown ? 0.5 : 0),
|
||||
area: event.width * event.height,
|
||||
pen_down: penDown,
|
||||
};
|
||||
}
|
||||
|
||||
canvas.addEventListener("pointerdown", (event) => {
|
||||
event.preventDefault();
|
||||
canvas.setPointerCapture(event.pointerId);
|
||||
if (points.length === 0) startTime = performance.now();
|
||||
drawing = true;
|
||||
const point = pointFrom(event, true);
|
||||
points.push(point);
|
||||
context.beginPath();
|
||||
context.moveTo(point.x, point.y);
|
||||
});
|
||||
|
||||
canvas.addEventListener("pointermove", (event) => {
|
||||
if (!drawing) return;
|
||||
const point = pointFrom(event, true);
|
||||
points.push(point);
|
||||
context.lineTo(point.x, point.y);
|
||||
context.stroke();
|
||||
});
|
||||
|
||||
function endStroke(event) {
|
||||
if (!drawing) return;
|
||||
points.push(pointFrom(event, false));
|
||||
drawing = false;
|
||||
}
|
||||
canvas.addEventListener("pointerup", endStroke);
|
||||
canvas.addEventListener("pointercancel", endStroke);
|
||||
|
||||
document.getElementById("clear").addEventListener("click", () => {
|
||||
points.length = 0;
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
status.textContent = "Signature cleared.";
|
||||
});
|
||||
|
||||
async function send(path) {
|
||||
if (!userId.value.trim()) {
|
||||
status.textContent = "Enter a participant ID first.";
|
||||
return;
|
||||
}
|
||||
if (points.filter((point) => point.pen_down).length < 2) {
|
||||
status.textContent = "Draw a signature with at least two points.";
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
user_id: userId.value.trim(),
|
||||
t: points.map((point) => point.t),
|
||||
x: points.map((point) => point.x),
|
||||
y: points.map((point) => point.y),
|
||||
p: points.map((point) => point.p),
|
||||
area: points.map((point) => point.area),
|
||||
pen_down: points.map((point) => point.pen_down),
|
||||
};
|
||||
status.textContent = "Sending…";
|
||||
try {
|
||||
const response = await fetch(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok) throw new Error(result.detail || `Request failed (${response.status})`);
|
||||
status.textContent = path === "/verify"
|
||||
? `Accepted: ${result.accepted}; score: ${result.score.toFixed(3)}; threshold: ${result.threshold.toFixed(3)}`
|
||||
: `Enrolled sample for ${result.user_id}. Enroll multiple genuine samples.`;
|
||||
} catch (error) {
|
||||
status.textContent = `Error: ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("enroll").addEventListener("click", () => send("/enroll"));
|
||||
document.getElementById("verify").addEventListener("click", () => send("/verify"));
|
||||
})();
|
||||
27
app/static/index.html
Normal file
27
app/static/index.html
Normal file
@ -0,0 +1,27 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Signature verification demo</title>
|
||||
<style>
|
||||
body { font: 16px system-ui, sans-serif; margin: 2rem auto; max-width: 52rem; padding: 0 1rem; }
|
||||
canvas { border: 1px solid #777; border-radius: .4rem; display: block; touch-action: none; width: 100%; }
|
||||
button, input { font: inherit; margin: .5rem .4rem .5rem 0; padding: .45rem .7rem; }
|
||||
#status { min-height: 1.5em; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Signature verification demo</h1>
|
||||
<p>Use a study participant ID (not a real name). Draw a signature, then enroll
|
||||
several genuine samples or verify against the enrolled templates.</p>
|
||||
<label for="user-id">Participant ID</label>
|
||||
<input id="user-id" autocomplete="off" maxlength="200" required>
|
||||
<canvas id="pad" width="900" height="320" aria-label="Signature drawing area"></canvas>
|
||||
<button id="clear" type="button">Clear</button>
|
||||
<button id="enroll" type="button">Enroll this sample</button>
|
||||
<button id="verify" type="button">Verify this sample</button>
|
||||
<p id="status" role="status" aria-live="polite"></p>
|
||||
<script src="/static/capture.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
0
data/processed/.gitkeep
Normal file
0
data/processed/.gitkeep
Normal file
0
data/raw/mobisig/.gitkeep
Normal file
0
data/raw/mobisig/.gitkeep
Normal file
8
data/raw/mobisig/statistics.txt
Normal file
8
data/raw/mobisig/statistics.txt
Normal file
@ -0,0 +1,8 @@
|
||||
Statistics
|
||||
|
||||
All: 83
|
||||
Male: 49
|
||||
Female: 34
|
||||
|
||||
Age: 26.03 (9.84)
|
||||
|
||||
0
data/raw/own/.gitkeep
Normal file
0
data/raw/own/.gitkeep
Normal file
45
docs/consent_form.md
Normal file
45
docs/consent_form.md
Normal file
@ -0,0 +1,45 @@
|
||||
# Participant information and consent (draft)
|
||||
|
||||
**This is a study-material template, not legal advice or institutional approval.**
|
||||
Adapt it to the actual study, data controller, storage location, retention
|
||||
period, withdrawal process, and applicable ethics/privacy requirements before
|
||||
use. Do not collect signatures until required approvals have been obtained.
|
||||
|
||||
## Study
|
||||
|
||||
Study title: Online handwritten-signature verification
|
||||
Researcher/contact: ____________________________________
|
||||
Institution/contact: ___________________________________
|
||||
|
||||
## What participation involves
|
||||
|
||||
You may be asked to provide several genuine signatures and, if applicable,
|
||||
clearly identified simulated forgeries. The capture may include pen/cursor
|
||||
position, timestamps, pressure, and contact area when the device provides them.
|
||||
The purpose is to evaluate a research algorithm; the system is experimental and
|
||||
must not be used for legal identity decisions.
|
||||
|
||||
## Data use and protection
|
||||
|
||||
The study team will use the collected data for the stated research purpose.
|
||||
Signature dynamics can be sensitive biometric information. The project should
|
||||
use participant codes instead of names, limit access, encrypt stored templates,
|
||||
set a deletion date, and explain any sharing or publication of derived results.
|
||||
The researcher must fill in the actual retention and deletion arrangements
|
||||
before presenting this form.
|
||||
|
||||
## Voluntary participation
|
||||
|
||||
Participation is voluntary. You may stop at any time and may request removal of
|
||||
your identifiable raw records until: ____________________. Explain any limits
|
||||
on withdrawal after data have been anonymized or published.
|
||||
|
||||
## Consent
|
||||
|
||||
I have read and understood the information above, had the opportunity to ask
|
||||
questions, and voluntarily agree to participate.
|
||||
|
||||
Participant code: ____________________
|
||||
Participant signature: ____________________ Date: __________
|
||||
Researcher signature: ____________________ Date: __________
|
||||
|
||||
25
docs/data_collection_protocol.md
Normal file
25
docs/data_collection_protocol.md
Normal file
@ -0,0 +1,25 @@
|
||||
# Data collection protocol (draft)
|
||||
|
||||
1. Obtain institutional ethics/privacy review where required and use the
|
||||
completed participant information and consent form.
|
||||
2. Assign a random participant code. Do not put names or contact details in
|
||||
filenames or the signature database.
|
||||
3. Record device type, sampling setup, session date/code, and whether pressure
|
||||
or contact-area data are available in a separate access-controlled study
|
||||
log. Do not write unnecessary personal information into the signature CSV.
|
||||
4. Collect several genuine samples per participant, with a short pause between
|
||||
attempts. Mark examples explicitly as `genuine`.
|
||||
5. If studying skilled or random forgeries, collect them only under the approved
|
||||
protocol, keep the target identity in `user_id`, and label their type in the
|
||||
`label` column. Avoid coercive or deceptive collection.
|
||||
6. Use the common CSV columns `user_id,label,t,x,y,p,area,pen_down`. Each file
|
||||
should contain one signature sequence; timestamp units must be consistent
|
||||
within a dataset. Optional `p` and `area` fields should be blank or omitted
|
||||
when unsupported.
|
||||
7. Store raw files in `data/raw/own/`, restrict access, and keep them out of
|
||||
version control. Store encryption keys separately from the database.
|
||||
8. Keep participant-level identities together when making train/test splits.
|
||||
Tune thresholds on a development set and report the final test set only
|
||||
once. Report the number of participants, genuine samples, and each forgery
|
||||
type alongside FAR, FRR, and EER.
|
||||
|
||||
55
experiments/01_explore_data.ipynb
Normal file
55
experiments/01_explore_data.ipynb
Normal file
@ -0,0 +1,55 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Explore signature captures\n",
|
||||
"\n",
|
||||
"Set `DATA_DIR` to a folder of common-format CSV signatures. Raw dataset formats may need a column mapping in `sigverify.io.loaders` first."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from pathlib import Path\n",
|
||||
"import matplotlib.pyplot as plt\n",
|
||||
"from sigverify.io.loaders import load_csv_directory\n",
|
||||
"\n",
|
||||
"DATA_DIR = Path('../data/raw/mobisig')\n",
|
||||
"signatures = load_csv_directory(DATA_DIR)\n",
|
||||
"len(signatures)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"for signature in signatures[:6]:\n",
|
||||
" plt.plot(signature.x, signature.y, label=f'{signature.user_id} ({signature.label})')\n",
|
||||
"plt.gca().invert_yaxis()\n",
|
||||
"plt.axis('equal')\n",
|
||||
"plt.legend()\n",
|
||||
"plt.show()"
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"name": "python",
|
||||
"version": "3.10"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
44
experiments/02_baseline_dtw.py
Normal file
44
experiments/02_baseline_dtw.py
Normal file
@ -0,0 +1,44 @@
|
||||
"""Run the DTW baseline on a folder of per-signature CSV files."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from experiments.common import evaluate_directory
|
||||
except ModuleNotFoundError:
|
||||
from common import evaluate_directory
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--data-dir", default="data/raw/mobisig")
|
||||
parser.add_argument("--enrollment-count", type=int, default=3)
|
||||
parser.add_argument("--seed", type=int, default=0)
|
||||
parser.add_argument("--output", type=Path)
|
||||
args = parser.parse_args()
|
||||
metrics = evaluate_directory(
|
||||
args.data_dir, enrollment_count=args.enrollment_count, seed=args.seed, mobisig=True
|
||||
)
|
||||
print(f"EER: {metrics['eer']:.4f}")
|
||||
print(f"Threshold at approximate EER: {metrics['eer_threshold']:.4f}")
|
||||
if args.output:
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"dataset": str(args.data_dir),
|
||||
"enrollment_count": args.enrollment_count,
|
||||
"seed": args.seed,
|
||||
"eer": metrics["eer"],
|
||||
"eer_threshold": metrics["eer_threshold"],
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
34
experiments/03_feature_ablation.py
Normal file
34
experiments/03_feature_ablation.py
Normal file
@ -0,0 +1,34 @@
|
||||
"""Compare DTW with optional pressure and contact-area channels."""
|
||||
|
||||
import argparse
|
||||
|
||||
try:
|
||||
from experiments.common import evaluate_directory
|
||||
except ModuleNotFoundError:
|
||||
from common import evaluate_directory
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--data-dir", default="data/raw/mobisig")
|
||||
parser.add_argument("--enrollment-count", type=int, default=3)
|
||||
args = parser.parse_args()
|
||||
configurations = [
|
||||
("position and motion", False, False),
|
||||
("plus pressure", True, False),
|
||||
("plus contact area", False, True),
|
||||
("pressure and area", True, True),
|
||||
]
|
||||
for name, pressure, area in configurations:
|
||||
metrics = evaluate_directory(
|
||||
args.data_dir,
|
||||
enrollment_count=args.enrollment_count,
|
||||
include_pressure=pressure,
|
||||
include_area=area,
|
||||
mobisig=True,
|
||||
)
|
||||
print(f"{name}: EER={metrics['eer']:.4f}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
25
experiments/04_own_data_eval.py
Normal file
25
experiments/04_own_data_eval.py
Normal file
@ -0,0 +1,25 @@
|
||||
"""Evaluate the same DTW baseline on locally collected signatures."""
|
||||
|
||||
import argparse
|
||||
|
||||
try:
|
||||
from experiments.common import evaluate_directory
|
||||
except ModuleNotFoundError:
|
||||
from common import evaluate_directory
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--data-dir", default="data/raw/own")
|
||||
parser.add_argument("--enrollment-count", type=int, default=3)
|
||||
parser.add_argument("--seed", type=int, default=0)
|
||||
args = parser.parse_args()
|
||||
metrics = evaluate_directory(
|
||||
args.data_dir, enrollment_count=args.enrollment_count, seed=args.seed
|
||||
)
|
||||
print(f"Own-data EER: {metrics['eer']:.4f}")
|
||||
print(f"Threshold at approximate EER: {metrics['eer_threshold']:.4f}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
2
experiments/__init__.py
Normal file
2
experiments/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
"""Reproducible baseline experiment entry points."""
|
||||
|
||||
45
experiments/common.py
Normal file
45
experiments/common.py
Normal file
@ -0,0 +1,45 @@
|
||||
"""Shared helpers for command-line signature evaluation scripts."""
|
||||
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
from sigverify.evaluation.metrics import verification_metrics
|
||||
from sigverify.evaluation.protocols import enrollment_test_split
|
||||
from sigverify.io.loaders import load_csv_directory, load_mobisig
|
||||
from sigverify.io.schema import Signature
|
||||
from sigverify.matching.models import DTWVerifier
|
||||
from sigverify.preprocessing import preprocess_signature
|
||||
|
||||
|
||||
def evaluate_directory(
|
||||
directory: str | Path,
|
||||
*,
|
||||
enrollment_count: int = 3,
|
||||
include_pressure: bool = False,
|
||||
include_area: bool = False,
|
||||
seed: int = 0,
|
||||
mobisig: bool = False,
|
||||
) -> dict[str, object]:
|
||||
"""Load, preprocess, split, and score known-identity attempts."""
|
||||
raw = load_mobisig(directory) if mobisig else load_csv_directory(directory)
|
||||
samples = [
|
||||
preprocess_signature(signature)
|
||||
for signature in raw
|
||||
]
|
||||
enrollment, test = enrollment_test_split(
|
||||
samples, enrollment_count=enrollment_count, seed=seed
|
||||
)
|
||||
templates: dict[str, list[Signature]] = defaultdict(list)
|
||||
for signature in enrollment:
|
||||
templates[signature.user_id].append(signature)
|
||||
|
||||
labels: list[int] = []
|
||||
scores: list[float] = []
|
||||
for sample in test:
|
||||
if sample.user_id not in templates:
|
||||
raise ValueError(f"test identity {sample.user_id!r} has no enrollment samples")
|
||||
verifier = DTWVerifier(include_pressure=include_pressure, include_area=include_area)
|
||||
verifier.enroll(templates[sample.user_id])
|
||||
scores.append(verifier.score(sample))
|
||||
labels.append(int(sample.label.lower() in {"genuine", "g", "1"}))
|
||||
return verification_metrics(labels, scores)
|
||||
33
pyproject.toml
Normal file
33
pyproject.toml
Normal file
@ -0,0 +1,33 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "sigverify"
|
||||
version = "0.1.0"
|
||||
description = "A small online handwritten-signature verification baseline"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"cryptography>=42",
|
||||
"fastapi>=0.110",
|
||||
"matplotlib>=3.8",
|
||||
"numpy>=1.26",
|
||||
"pandas>=2.1",
|
||||
"pydantic>=2.6",
|
||||
"scikit-learn>=1.4",
|
||||
"scipy>=1.12",
|
||||
"sqlalchemy>=2.0",
|
||||
"uvicorn[standard]>=0.27",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["httpx>=0.27", "pytest>=8"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
pythonpath = ["src"]
|
||||
testpaths = ["tests"]
|
||||
|
||||
0
results/.gitkeep
Normal file
0
results/.gitkeep
Normal file
89
src/sigverify.egg-info/PKG-INFO
Normal file
89
src/sigverify.egg-info/PKG-INFO
Normal file
@ -0,0 +1,89 @@
|
||||
Metadata-Version: 2.4
|
||||
Name: sigverify
|
||||
Version: 0.1.0
|
||||
Summary: A small online handwritten-signature verification baseline
|
||||
Requires-Python: >=3.10
|
||||
Description-Content-Type: text/markdown
|
||||
Requires-Dist: cryptography>=42
|
||||
Requires-Dist: fastapi>=0.110
|
||||
Requires-Dist: matplotlib>=3.8
|
||||
Requires-Dist: numpy>=1.26
|
||||
Requires-Dist: pandas>=2.1
|
||||
Requires-Dist: pydantic>=2.6
|
||||
Requires-Dist: scikit-learn>=1.4
|
||||
Requires-Dist: scipy>=1.12
|
||||
Requires-Dist: sqlalchemy>=2.0
|
||||
Requires-Dist: uvicorn[standard]>=0.27
|
||||
Provides-Extra: dev
|
||||
Requires-Dist: httpx>=0.27; extra == "dev"
|
||||
Requires-Dist: pytest>=8; extra == "dev"
|
||||
|
||||
# Signature verification baseline
|
||||
|
||||
A small research starter project for online handwritten-signature verification.
|
||||
It includes common-format CSV/JSON loading, preprocessing, feature extraction,
|
||||
DTW matching, threshold metrics, and a local FastAPI capture demo.
|
||||
|
||||
## Setup
|
||||
|
||||
From the project root:
|
||||
|
||||
```powershell
|
||||
py -m venv .venv
|
||||
.\.venv\Scripts\Activate.ps1
|
||||
python -m pip install -e ".[dev]"
|
||||
$env:SIGVERIFY_FERNET_KEY = python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
Open <http://127.0.0.1:8000/>. The generated Fernet key is for local testing;
|
||||
keep the same key between runs to read previously enrolled templates. Store a
|
||||
production key in a secret manager, never in source control. Without a key, the
|
||||
API rejects enrollment and verification rather than storing plaintext.
|
||||
|
||||
Run tests with `pytest`. The package can also be installed with `pip install -e .`.
|
||||
|
||||
## Data format
|
||||
|
||||
The common signature format has one sample per point with `user_id`, `t`, `x`,
|
||||
`y`, optional `p` (pressure), optional `area` (contact area), and optional
|
||||
`pen_down`. CSV loaders expect these names by default; pass column overrides
|
||||
for a dataset with different headers. A JSON signature uses the same names
|
||||
with array-valued `t`, `x`, and `y`. For MOBISIG, check the downloaded release's
|
||||
file layout and map its columns with the loader arguments; dataset releases can
|
||||
vary. Put downloaded or self-collected material under `data/raw/`. Raw data is
|
||||
ignored by Git.
|
||||
|
||||
## Baseline workflow
|
||||
|
||||
1. Load signatures into `Signature`.
|
||||
2. Sort timestamps, collapse repeated times, lightly smooth, resample, and
|
||||
translate/scale positions. Preprocessing records the original bounding-box
|
||||
diagonal because size may itself be discriminative. Pen-up samples are
|
||||
excluded from the baseline trajectory.
|
||||
3. Extract x/y and motion features; pressure and contact area can be enabled as
|
||||
explicit ablations.
|
||||
4. Enroll several genuine examples, compare using band-constrained DTW, and
|
||||
divide by the median within-enrollment distance.
|
||||
5. Tune global and per-user thresholds on a development split; evaluate only
|
||||
on held-out attempts. Thresholds in the demo are illustrative, not research
|
||||
results.
|
||||
|
||||
`experiments/` contains starter scripts for inspecting data, running a simple
|
||||
split, and comparing feature sets. Report user-disjoint development/test
|
||||
results separately from the API demo. Do not train or tune on the test set.
|
||||
|
||||
## Capture and consent
|
||||
|
||||
The browser sends pointer positions, elapsed milliseconds, and optional
|
||||
pressure. It does not persist anything in the browser. Review
|
||||
[`docs/data_collection_protocol.md`](docs/data_collection_protocol.md) and
|
||||
[`docs/consent_form.md`](docs/consent_form.md) before collecting data; adapt
|
||||
them to institutional requirements and obtain approval where required.
|
||||
|
||||
The SQLite demo stores encrypted template JSON and a small lockout counter in
|
||||
process memory. Lockout state resets when the server restarts, so this is not a
|
||||
production account-security system. Use HTTPS, authentication, durable
|
||||
rate-limiting, access controls, key management, and a retention/deletion policy
|
||||
before exposing a service beyond localhost.
|
||||
|
||||
21
src/sigverify.egg-info/SOURCES.txt
Normal file
21
src/sigverify.egg-info/SOURCES.txt
Normal file
@ -0,0 +1,21 @@
|
||||
README.md
|
||||
pyproject.toml
|
||||
src/sigverify/__init__.py
|
||||
src/sigverify/decision.py
|
||||
src/sigverify/features.py
|
||||
src/sigverify/preprocessing.py
|
||||
src/sigverify/security.py
|
||||
src/sigverify.egg-info/PKG-INFO
|
||||
src/sigverify.egg-info/SOURCES.txt
|
||||
src/sigverify.egg-info/dependency_links.txt
|
||||
src/sigverify.egg-info/requires.txt
|
||||
src/sigverify.egg-info/top_level.txt
|
||||
src/sigverify/evaluation/metrics.py
|
||||
src/sigverify/evaluation/plots.py
|
||||
src/sigverify/evaluation/protocols.py
|
||||
src/sigverify/io/__init__.py
|
||||
src/sigverify/io/loaders.py
|
||||
src/sigverify/io/schema.py
|
||||
src/sigverify/matching/dtw.py
|
||||
src/sigverify/matching/models.py
|
||||
tests/test_baseline.py
|
||||
1
src/sigverify.egg-info/dependency_links.txt
Normal file
1
src/sigverify.egg-info/dependency_links.txt
Normal file
@ -0,0 +1 @@
|
||||
|
||||
14
src/sigverify.egg-info/requires.txt
Normal file
14
src/sigverify.egg-info/requires.txt
Normal file
@ -0,0 +1,14 @@
|
||||
cryptography>=42
|
||||
fastapi>=0.110
|
||||
matplotlib>=3.8
|
||||
numpy>=1.26
|
||||
pandas>=2.1
|
||||
pydantic>=2.6
|
||||
scikit-learn>=1.4
|
||||
scipy>=1.12
|
||||
sqlalchemy>=2.0
|
||||
uvicorn[standard]>=0.27
|
||||
|
||||
[dev]
|
||||
httpx>=0.27
|
||||
pytest>=8
|
||||
1
src/sigverify.egg-info/top_level.txt
Normal file
1
src/sigverify.egg-info/top_level.txt
Normal file
@ -0,0 +1 @@
|
||||
sigverify
|
||||
2
src/sigverify/__init__.py
Normal file
2
src/sigverify/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
"""Online handwritten-signature verification research starter."""
|
||||
|
||||
19
src/sigverify/decision.py
Normal file
19
src/sigverify/decision.py
Normal file
@ -0,0 +1,19 @@
|
||||
"""Global and per-user threshold decisions."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Thresholds:
|
||||
"""Threshold registry; scores at or below a threshold are accepted."""
|
||||
|
||||
global_threshold: float
|
||||
user_thresholds: dict[str, float] = field(default_factory=dict)
|
||||
|
||||
def threshold_for(self, user_id: str, *, user_dependent: bool = True) -> float:
|
||||
if user_dependent and user_id in self.user_thresholds:
|
||||
return self.user_thresholds[user_id]
|
||||
return self.global_threshold
|
||||
|
||||
def accept(self, user_id: str, score: float, *, user_dependent: bool = True) -> bool:
|
||||
return score <= self.threshold_for(user_id, user_dependent=user_dependent)
|
||||
36
src/sigverify/evaluation/metrics.py
Normal file
36
src/sigverify/evaluation/metrics.py
Normal file
@ -0,0 +1,36 @@
|
||||
"""Threshold-independent and threshold-dependent verification metrics."""
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import ArrayLike
|
||||
from sklearn.metrics import det_curve, roc_curve
|
||||
|
||||
|
||||
def verification_metrics(labels: ArrayLike, scores: ArrayLike) -> dict[str, object]:
|
||||
"""Compute FAR/FRR curves, ROC, DET and the approximate EER.
|
||||
|
||||
Labels use 0 for forgery and 1 for genuine. Scores are lower for more
|
||||
genuine-looking samples.
|
||||
"""
|
||||
y = np.asarray(labels, dtype=np.int8)
|
||||
values = np.asarray(scores, dtype=np.float64)
|
||||
if y.ndim != 1 or values.ndim != 1 or len(y) != len(values) or len(y) == 0:
|
||||
raise ValueError("labels and scores must be non-empty 1D arrays of equal length")
|
||||
if not np.isin(y, [0, 1]).all() or len(np.unique(y)) != 2:
|
||||
raise ValueError("labels must contain both classes, encoded as 0=forgery and 1=genuine")
|
||||
|
||||
fpr, tpr, roc_thresholds = roc_curve(y, -values)
|
||||
false_accept, false_reject, det_thresholds = det_curve(y, -values)
|
||||
fnr = 1.0 - tpr
|
||||
crossing = int(np.argmin(np.abs(fpr - fnr)))
|
||||
return {
|
||||
"far": fpr,
|
||||
"frr": fnr,
|
||||
"roc_fpr": fpr,
|
||||
"roc_tpr": tpr,
|
||||
"roc_thresholds": -roc_thresholds,
|
||||
"det_fpr": false_accept,
|
||||
"det_fnr": false_reject,
|
||||
"det_thresholds": -det_thresholds,
|
||||
"eer": float((fpr[crossing] + fnr[crossing]) / 2.0),
|
||||
"eer_threshold": float(-roc_thresholds[crossing]),
|
||||
}
|
||||
33
src/sigverify/evaluation/plots.py
Normal file
33
src/sigverify/evaluation/plots.py
Normal file
@ -0,0 +1,33 @@
|
||||
"""Plot ROC and DET curves for evaluation results."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
|
||||
def plot_roc(metrics: dict[str, object], destination: str | Path) -> None:
|
||||
"""Write a ROC curve image from `verification_metrics` output."""
|
||||
target = Path(destination)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
plt.figure()
|
||||
plt.plot(metrics["roc_fpr"], metrics["roc_tpr"], label=f"EER={metrics['eer']:.3f}")
|
||||
plt.plot([0, 1], [0, 1], "--", color="gray")
|
||||
plt.xlabel("False acceptance rate")
|
||||
plt.ylabel("True acceptance rate")
|
||||
plt.legend()
|
||||
plt.tight_layout()
|
||||
plt.savefig(target)
|
||||
plt.close()
|
||||
|
||||
|
||||
def plot_det(metrics: dict[str, object], destination: str | Path) -> None:
|
||||
"""Write a DET curve image from `verification_metrics` output."""
|
||||
target = Path(destination)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
plt.figure()
|
||||
plt.plot(metrics["det_fpr"], metrics["det_fnr"])
|
||||
plt.xlabel("False acceptance rate")
|
||||
plt.ylabel("False rejection rate")
|
||||
plt.tight_layout()
|
||||
plt.savefig(target)
|
||||
plt.close()
|
||||
51
src/sigverify/evaluation/protocols.py
Normal file
51
src/sigverify/evaluation/protocols.py
Normal file
@ -0,0 +1,51 @@
|
||||
"""Simple enrollment/test and user-disjoint evaluation splits."""
|
||||
|
||||
from collections import defaultdict
|
||||
from collections.abc import Sequence
|
||||
|
||||
import numpy as np
|
||||
|
||||
from sigverify.io.schema import Signature
|
||||
|
||||
|
||||
def enrollment_test_split(
|
||||
samples: Sequence[Signature],
|
||||
*,
|
||||
enrollment_count: int = 3,
|
||||
seed: int = 0,
|
||||
) -> tuple[list[Signature], list[Signature]]:
|
||||
"""Split genuine samples per user; non-genuine samples remain in test."""
|
||||
if enrollment_count < 1:
|
||||
raise ValueError("enrollment_count must be positive")
|
||||
groups: dict[str, list[Signature]] = defaultdict(list)
|
||||
test = [sample for sample in samples if sample.label.lower() not in {"genuine", "g", "1"}]
|
||||
for sample in samples:
|
||||
if sample.label.lower() in {"genuine", "g", "1"}:
|
||||
groups[sample.user_id].append(sample)
|
||||
rng = np.random.default_rng(seed)
|
||||
enrollment: list[Signature] = []
|
||||
for user_id, group in groups.items():
|
||||
if len(group) <= enrollment_count:
|
||||
raise ValueError(f"user {user_id!r} needs more than {enrollment_count} genuine samples")
|
||||
order = rng.permutation(len(group))
|
||||
enrollment.extend(group[index] for index in order[:enrollment_count])
|
||||
test.extend(group[index] for index in order[enrollment_count:])
|
||||
return enrollment, test
|
||||
|
||||
|
||||
def user_disjoint_split(
|
||||
samples: Sequence[Signature], *, test_fraction: float = 0.2, seed: int = 0
|
||||
) -> tuple[list[Signature], list[Signature]]:
|
||||
"""Split complete user identities, suitable for learning-model evaluation."""
|
||||
if not 0 < test_fraction < 1:
|
||||
raise ValueError("test_fraction must be between 0 and 1")
|
||||
users = np.asarray(sorted({sample.user_id for sample in samples}))
|
||||
if len(users) < 2:
|
||||
raise ValueError("at least two users are required for a user-disjoint split")
|
||||
test_users = set(np.random.default_rng(seed).permutation(users)[: max(
|
||||
1, min(len(users) - 1, round(len(users) * test_fraction))
|
||||
)])
|
||||
return (
|
||||
[sample for sample in samples if sample.user_id not in test_users],
|
||||
[sample for sample in samples if sample.user_id in test_users],
|
||||
)
|
||||
56
src/sigverify/features.py
Normal file
56
src/sigverify/features.py
Normal file
@ -0,0 +1,56 @@
|
||||
"""Per-point trajectory features and per-signature global descriptors."""
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import NDArray
|
||||
|
||||
from sigverify.io.schema import Signature
|
||||
|
||||
|
||||
def extract_features(
|
||||
signature: Signature,
|
||||
*,
|
||||
include_pressure: bool = False,
|
||||
include_area: bool = False,
|
||||
include_acceleration: bool = True,
|
||||
include_curvature: bool = True,
|
||||
) -> NDArray[np.float64]:
|
||||
"""Return point features; x/y, velocity and speed are the initial channels."""
|
||||
elapsed = signature.t - signature.t[0]
|
||||
dt = np.maximum(np.gradient(elapsed), 1e-6)
|
||||
|
||||
def derivative(values: NDArray[np.float64]) -> NDArray[np.float64]:
|
||||
if len(values) < 2:
|
||||
return np.zeros_like(values)
|
||||
return np.gradient(values) / dt
|
||||
|
||||
dx = derivative(signature.x)
|
||||
dy = derivative(signature.y)
|
||||
columns = [signature.x, signature.y, dx, dy, np.hypot(dx, dy)]
|
||||
if include_acceleration:
|
||||
columns.append(derivative(columns[-1]))
|
||||
if include_curvature:
|
||||
ddx = derivative(dx)
|
||||
ddy = derivative(dy)
|
||||
denominator = np.maximum((dx * dx + dy * dy) ** 1.5, 1e-8)
|
||||
columns.append((dx * ddy - dy * ddx) / denominator)
|
||||
if include_pressure:
|
||||
if signature.p is None:
|
||||
raise ValueError("pressure requested but this signature has no pressure data")
|
||||
columns.append(signature.p)
|
||||
if include_area:
|
||||
if signature.area is None:
|
||||
raise ValueError("area requested but this signature has no area data")
|
||||
columns.append(signature.area)
|
||||
return np.column_stack(columns).astype(np.float64, copy=False)
|
||||
|
||||
|
||||
def global_features(signature: Signature) -> dict[str, float]:
|
||||
"""Return duration, path length, and original size where available."""
|
||||
path_length = float(np.hypot(np.diff(signature.x), np.diff(signature.y)).sum())
|
||||
result = {
|
||||
"duration": float(signature.t[-1] - signature.t[0]),
|
||||
"path_length": path_length,
|
||||
}
|
||||
if signature.original_size is not None:
|
||||
result["original_size"] = signature.original_size
|
||||
return result
|
||||
2
src/sigverify/io/__init__.py
Normal file
2
src/sigverify/io/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
"""Data schemas and loaders."""
|
||||
|
||||
136
src/sigverify/io/loaders.py
Normal file
136
src/sigverify/io/loaders.py
Normal file
@ -0,0 +1,136 @@
|
||||
"""Load CSV and JSON captures into the common signature schema."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from sigverify.io.schema import Signature
|
||||
|
||||
|
||||
def load_csv_directory(
|
||||
directory: str | Path,
|
||||
*,
|
||||
user_column: str = "user_id",
|
||||
time_column: str = "t",
|
||||
x_column: str = "x",
|
||||
y_column: str = "y",
|
||||
pressure_column: str | None = "p",
|
||||
area_column: str | None = "area",
|
||||
pen_down_column: str | None = "pen_down",
|
||||
label_column: str | None = "label",
|
||||
) -> list[Signature]:
|
||||
"""Load CSV files; each file may contain one or multiple users."""
|
||||
root = Path(directory)
|
||||
if not root.is_dir():
|
||||
raise FileNotFoundError(f"signature directory does not exist: {root}")
|
||||
|
||||
signatures: list[Signature] = []
|
||||
for path in sorted(root.rglob("*.csv")):
|
||||
frame = pd.read_csv(path)
|
||||
required = [time_column, x_column, y_column]
|
||||
missing = [column for column in required if column not in frame.columns]
|
||||
if missing:
|
||||
raise ValueError(f"{path} is missing required columns: {', '.join(missing)}")
|
||||
if user_column not in frame.columns:
|
||||
frame[user_column] = path.stem
|
||||
|
||||
for user_id, group in frame.groupby(user_column, sort=False, dropna=False):
|
||||
def optional_column(column: str | None):
|
||||
return group[column].to_numpy() if column and column in group.columns else None
|
||||
|
||||
labels = group[label_column].dropna().unique() if label_column and label_column in group else []
|
||||
label = str(labels[0]) if len(labels) else "genuine"
|
||||
signatures.append(
|
||||
Signature(
|
||||
user_id=str(user_id),
|
||||
label=label,
|
||||
t=group[time_column].to_numpy(),
|
||||
x=group[x_column].to_numpy(),
|
||||
y=group[y_column].to_numpy(),
|
||||
p=optional_column(pressure_column),
|
||||
area=optional_column(area_column),
|
||||
pen_down=optional_column(pen_down_column),
|
||||
)
|
||||
)
|
||||
return signatures
|
||||
|
||||
|
||||
def load_json_signature(path: str | Path) -> Signature:
|
||||
"""Load one signature from a JSON object in the common format."""
|
||||
source = Path(path)
|
||||
with source.open(encoding="utf-8") as stream:
|
||||
data = json.load(stream)
|
||||
return Signature(
|
||||
user_id=str(data["user_id"]),
|
||||
label=str(data.get("label", "genuine")),
|
||||
t=data["t"],
|
||||
x=data["x"],
|
||||
y=data["y"],
|
||||
p=data.get("p"),
|
||||
area=data.get("area"),
|
||||
pen_down=data.get("pen_down"),
|
||||
)
|
||||
|
||||
|
||||
def load_mobisig(directory: str | Path, **column_mapping: str | None) -> list[Signature]:
|
||||
"""Load the released MOBISIG directory layout.
|
||||
|
||||
MOBISIG files encode the claimed signer and genuine/forgery label in names
|
||||
such as ``SIGN_GEN_USER1_USER1_1.csv`` and
|
||||
``SIGN_FOR_USER1_USER2_1.csv``. The parent directory is used as a
|
||||
fallback identity, while the filename's claimed signer is authoritative.
|
||||
"""
|
||||
root = Path(directory)
|
||||
if not root.is_dir():
|
||||
raise FileNotFoundError(f"signature directory does not exist: {root}")
|
||||
mapping = {
|
||||
"time": "timestamp",
|
||||
"x": "x",
|
||||
"y": "y",
|
||||
"pressure": "pressure",
|
||||
"area": "fingerarea",
|
||||
}
|
||||
mapping.update({key: value for key, value in column_mapping.items() if value is not None})
|
||||
signatures: list[Signature] = []
|
||||
for path in sorted(root.rglob("*.csv")):
|
||||
if not path.stem.upper().startswith("SIGN_"):
|
||||
continue
|
||||
frame = pd.read_csv(path)
|
||||
required = [mapping["time"], mapping["x"], mapping["y"]]
|
||||
missing = [column for column in required if column not in frame.columns]
|
||||
if missing:
|
||||
raise ValueError(f"{path} is missing MOBISIG columns: {', '.join(missing)}")
|
||||
|
||||
parts = path.stem.upper().split("_")
|
||||
if len(parts) >= 4 and parts[1] in {"GEN", "FOR"}:
|
||||
label = "genuine" if parts[1] == "GEN" else "forgery"
|
||||
user_id = parts[2]
|
||||
else:
|
||||
label = "genuine"
|
||||
user_id = path.parent.name
|
||||
signatures.append(
|
||||
Signature(
|
||||
user_id=user_id,
|
||||
label=label,
|
||||
t=frame[mapping["time"]].to_numpy(dtype=float),
|
||||
x=frame[mapping["x"]].to_numpy(dtype=float),
|
||||
y=frame[mapping["y"]].to_numpy(dtype=float),
|
||||
p=(
|
||||
frame[mapping["pressure"]].to_numpy(dtype=float)
|
||||
if mapping["pressure"] in frame
|
||||
else None
|
||||
),
|
||||
area=(
|
||||
frame[mapping["area"]].to_numpy(dtype=float)
|
||||
if mapping["area"] in frame
|
||||
else None
|
||||
),
|
||||
)
|
||||
)
|
||||
return signatures
|
||||
|
||||
|
||||
def load_own_data(directory: str | Path) -> list[Signature]:
|
||||
"""Load locally captured CSVs using the common column names."""
|
||||
return load_csv_directory(directory)
|
||||
71
src/sigverify/io/schema.py
Normal file
71
src/sigverify/io/schema.py
Normal file
@ -0,0 +1,71 @@
|
||||
"""Common representation for time-series signature captures."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import NDArray
|
||||
|
||||
FloatArray = NDArray[np.float64]
|
||||
BoolArray = NDArray[np.bool_]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Signature:
|
||||
"""One captured signature, represented as equally sized point arrays."""
|
||||
|
||||
user_id: str
|
||||
label: str
|
||||
t: FloatArray
|
||||
x: FloatArray
|
||||
y: FloatArray
|
||||
p: FloatArray | None = None
|
||||
area: FloatArray | None = None
|
||||
pen_down: BoolArray | None = None
|
||||
original_size: float | None = None
|
||||
size_normalized: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.t = np.asarray(self.t, dtype=np.float64)
|
||||
self.x = np.asarray(self.x, dtype=np.float64)
|
||||
self.y = np.asarray(self.y, dtype=np.float64)
|
||||
for name in ("p", "area"):
|
||||
value = getattr(self, name)
|
||||
if value is not None:
|
||||
setattr(self, name, np.asarray(value, dtype=np.float64))
|
||||
if self.pen_down is not None:
|
||||
self.pen_down = np.asarray(self.pen_down, dtype=np.bool_)
|
||||
|
||||
n = self.t.size
|
||||
if not self.user_id:
|
||||
raise ValueError("user_id must not be empty")
|
||||
if n == 0:
|
||||
raise ValueError("a signature must contain at least one point")
|
||||
for name in ("x", "y", "p", "area", "pen_down"):
|
||||
value = getattr(self, name)
|
||||
if value is not None:
|
||||
if value.ndim != 1:
|
||||
raise ValueError(f"{name} must be a one-dimensional array")
|
||||
if value.size != n:
|
||||
raise ValueError(f"{name} must have the same number of points as t")
|
||||
if not (np.isfinite(self.t).all() and np.isfinite(self.x).all() and np.isfinite(self.y).all()):
|
||||
raise ValueError("t, x, and y must contain only finite values")
|
||||
for name in ("p", "area"):
|
||||
value = getattr(self, name)
|
||||
if value is not None and not np.isfinite(value).all():
|
||||
raise ValueError(f"{name} must contain only finite values")
|
||||
|
||||
def copy(self, **changes: object) -> "Signature":
|
||||
values = {
|
||||
"user_id": self.user_id,
|
||||
"label": self.label,
|
||||
"t": self.t.copy(),
|
||||
"x": self.x.copy(),
|
||||
"y": self.y.copy(),
|
||||
"p": None if self.p is None else self.p.copy(),
|
||||
"area": None if self.area is None else self.area.copy(),
|
||||
"pen_down": None if self.pen_down is None else self.pen_down.copy(),
|
||||
"original_size": self.original_size,
|
||||
"size_normalized": self.size_normalized,
|
||||
}
|
||||
values.update(changes)
|
||||
return Signature(**values)
|
||||
35
src/sigverify/matching/dtw.py
Normal file
35
src/sigverify/matching/dtw.py
Normal file
@ -0,0 +1,35 @@
|
||||
"""Dynamic time warping with an optional Sakoe-Chiba constraint."""
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import ArrayLike
|
||||
|
||||
|
||||
def dtw_distance(
|
||||
first: ArrayLike,
|
||||
second: ArrayLike,
|
||||
*,
|
||||
band_fraction: float | None = 0.15,
|
||||
) -> float:
|
||||
"""Compute Euclidean local-cost DTW, normalized by the longer sequence."""
|
||||
a = np.asarray(first, dtype=np.float64)
|
||||
b = np.asarray(second, dtype=np.float64)
|
||||
if a.ndim == 1:
|
||||
a = a[:, None]
|
||||
if b.ndim == 1:
|
||||
b = b[:, None]
|
||||
if a.ndim != 2 or b.ndim != 2 or a.shape[1] != b.shape[1]:
|
||||
raise ValueError("sequences must be 1D or 2D with matching feature counts")
|
||||
if not len(a) or not len(b):
|
||||
raise ValueError("sequences must not be empty")
|
||||
if band_fraction is not None and band_fraction < 0:
|
||||
raise ValueError("band_fraction must be non-negative or None")
|
||||
|
||||
n, m = len(a), len(b)
|
||||
band = max(abs(n - m), int(max(n, m) * band_fraction)) if band_fraction is not None else max(n, m)
|
||||
costs = np.full((n + 1, m + 1), np.inf, dtype=np.float64)
|
||||
costs[0, 0] = 0.0
|
||||
for i in range(1, n + 1):
|
||||
for j in range(max(1, i - band), min(m, i + band) + 1):
|
||||
local = float(np.linalg.norm(a[i - 1] - b[j - 1]))
|
||||
costs[i, j] = local + min(costs[i - 1, j], costs[i, j - 1], costs[i - 1, j - 1])
|
||||
return float(costs[n, m] / max(n, m))
|
||||
75
src/sigverify/matching/models.py
Normal file
75
src/sigverify/matching/models.py
Normal file
@ -0,0 +1,75 @@
|
||||
"""Verifier interface and a DTW nearest-enrollment baseline."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Sequence
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import NDArray
|
||||
|
||||
from sigverify.features import extract_features
|
||||
from sigverify.io.schema import Signature
|
||||
from sigverify.matching.dtw import dtw_distance
|
||||
|
||||
|
||||
class Verifier(ABC):
|
||||
"""Minimal interface implemented by signature verifiers."""
|
||||
|
||||
@abstractmethod
|
||||
def enroll(self, samples: Sequence[Signature]) -> None:
|
||||
"""Fit an identity template from genuine enrollment samples."""
|
||||
|
||||
@abstractmethod
|
||||
def score(self, sample: Signature) -> float:
|
||||
"""Return a lower-is-more-genuine comparison score."""
|
||||
|
||||
|
||||
class DTWVerifier(Verifier):
|
||||
"""Nearest-template DTW score normalized by enrollment variability."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
band_fraction: float = 0.15,
|
||||
aggregation: str = "minimum",
|
||||
include_pressure: bool = False,
|
||||
include_area: bool = False,
|
||||
) -> None:
|
||||
if aggregation not in {"minimum", "mean"}:
|
||||
raise ValueError("aggregation must be 'minimum' or 'mean'")
|
||||
self.band_fraction = band_fraction
|
||||
self.aggregation = aggregation
|
||||
self.include_pressure = include_pressure
|
||||
self.include_area = include_area
|
||||
self._templates: list[NDArray[np.float64]] = []
|
||||
self._within_distance = 1.0
|
||||
|
||||
def _features(self, signature: Signature) -> NDArray[np.float64]:
|
||||
return extract_features(
|
||||
signature,
|
||||
include_pressure=self.include_pressure,
|
||||
include_area=self.include_area,
|
||||
)
|
||||
|
||||
def enroll(self, samples: Sequence[Signature]) -> None:
|
||||
if not samples:
|
||||
raise ValueError("at least one enrollment sample is required")
|
||||
if any(sample.label.lower() not in {"genuine", "g", "1"} for sample in samples):
|
||||
raise ValueError("enrollment samples must be labelled genuine")
|
||||
self._templates = [self._features(sample) for sample in samples]
|
||||
distances = [
|
||||
dtw_distance(a, b, band_fraction=self.band_fraction)
|
||||
for index, a in enumerate(self._templates)
|
||||
for b in self._templates[index + 1 :]
|
||||
]
|
||||
self._within_distance = max(float(np.median(distances)) if distances else 1.0, 1e-8)
|
||||
|
||||
def score(self, sample: Signature) -> float:
|
||||
if not self._templates:
|
||||
raise RuntimeError("enroll must be called before score")
|
||||
test = self._features(sample)
|
||||
distances = [
|
||||
dtw_distance(template, test, band_fraction=self.band_fraction)
|
||||
for template in self._templates
|
||||
]
|
||||
aggregate = min(distances) if self.aggregation == "minimum" else float(np.mean(distances))
|
||||
return float(aggregate / self._within_distance)
|
||||
76
src/sigverify/preprocessing.py
Normal file
76
src/sigverify/preprocessing.py
Normal file
@ -0,0 +1,76 @@
|
||||
"""Cleaning, smoothing, resampling, and spatial normalization."""
|
||||
|
||||
import numpy as np
|
||||
from scipy.ndimage import gaussian_filter1d
|
||||
|
||||
from sigverify.io.schema import Signature
|
||||
|
||||
|
||||
def preprocess_signature(
|
||||
signature: Signature,
|
||||
*,
|
||||
points: int = 100,
|
||||
smoothing_sigma: float = 0.8,
|
||||
normalize_position: bool = True,
|
||||
) -> Signature:
|
||||
"""Return a cleaned signature; original size is retained as metadata."""
|
||||
if points < 2:
|
||||
raise ValueError("points must be at least 2")
|
||||
if smoothing_sigma < 0:
|
||||
raise ValueError("smoothing_sigma must not be negative")
|
||||
|
||||
order = np.argsort(signature.t, kind="stable")
|
||||
sorted_t = signature.t[order]
|
||||
order = order[np.r_[True, np.diff(sorted_t) > 0]]
|
||||
pen_down = None if signature.pen_down is None else signature.pen_down[order]
|
||||
if pen_down is not None:
|
||||
order = order[pen_down]
|
||||
if order.size == 0:
|
||||
raise ValueError("signature has no pen-down points")
|
||||
|
||||
t = signature.t[order]
|
||||
x = signature.x[order].copy()
|
||||
y = signature.y[order].copy()
|
||||
p = None if signature.p is None else signature.p[order].copy()
|
||||
area = None if signature.area is None else signature.area[order].copy()
|
||||
original_size = float(np.hypot(np.ptp(x), np.ptp(y)))
|
||||
|
||||
if smoothing_sigma and t.size > 2:
|
||||
x = gaussian_filter1d(x, smoothing_sigma, mode="nearest")
|
||||
y = gaussian_filter1d(y, smoothing_sigma, mode="nearest")
|
||||
if p is not None:
|
||||
p = gaussian_filter1d(p, smoothing_sigma, mode="nearest")
|
||||
|
||||
if t.size == 1:
|
||||
new_t = np.linspace(t[0], t[0] + 1.0, points)
|
||||
x = np.repeat(x, points)
|
||||
y = np.repeat(y, points)
|
||||
p = None if p is None else np.repeat(p, points)
|
||||
area = None if area is None else np.repeat(area, points)
|
||||
else:
|
||||
new_t = np.linspace(t[0], t[-1], points)
|
||||
x = np.interp(new_t, t, x)
|
||||
y = np.interp(new_t, t, y)
|
||||
p = None if p is None else np.interp(new_t, t, p)
|
||||
area = None if area is None else np.interp(new_t, t, area)
|
||||
|
||||
if normalize_position:
|
||||
x -= float(np.mean(x))
|
||||
y -= float(np.mean(y))
|
||||
scale = max(float(np.ptp(x)), float(np.ptp(y)))
|
||||
if scale > np.finfo(np.float64).eps:
|
||||
x /= scale
|
||||
y /= scale
|
||||
|
||||
return Signature(
|
||||
user_id=signature.user_id,
|
||||
label=signature.label,
|
||||
t=new_t,
|
||||
x=x,
|
||||
y=y,
|
||||
p=p,
|
||||
area=area,
|
||||
pen_down=np.ones(points, dtype=np.bool_),
|
||||
original_size=original_size,
|
||||
size_normalized=normalize_position,
|
||||
)
|
||||
72
src/sigverify/security.py
Normal file
72
src/sigverify/security.py
Normal file
@ -0,0 +1,72 @@
|
||||
"""Encrypted template serialization and in-memory failed-attempt lockout."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
|
||||
from sigverify.io.schema import Signature
|
||||
|
||||
|
||||
class TemplateCipher:
|
||||
"""Encrypt/decrypt signatures with an externally configured Fernet key."""
|
||||
|
||||
def __init__(self, key: str | bytes | None = None) -> None:
|
||||
configured_key = key or os.environ.get("SIGVERIFY_FERNET_KEY")
|
||||
if not configured_key:
|
||||
raise RuntimeError("SIGVERIFY_FERNET_KEY must be configured before storing templates")
|
||||
self._fernet = Fernet(configured_key.encode() if isinstance(configured_key, str) else configured_key)
|
||||
|
||||
def encrypt(self, signature: Signature) -> bytes:
|
||||
payload = {
|
||||
"user_id": signature.user_id,
|
||||
"label": signature.label,
|
||||
"t": signature.t.tolist(),
|
||||
"x": signature.x.tolist(),
|
||||
"y": signature.y.tolist(),
|
||||
"p": None if signature.p is None else signature.p.tolist(),
|
||||
"area": None if signature.area is None else signature.area.tolist(),
|
||||
"pen_down": None if signature.pen_down is None else signature.pen_down.tolist(),
|
||||
"original_size": signature.original_size,
|
||||
"size_normalized": signature.size_normalized,
|
||||
}
|
||||
return self._fernet.encrypt(json.dumps(payload).encode("utf-8"))
|
||||
|
||||
def decrypt(self, token: bytes) -> Signature:
|
||||
try:
|
||||
data = json.loads(self._fernet.decrypt(token).decode("utf-8"))
|
||||
except (InvalidToken, UnicodeDecodeError, json.JSONDecodeError) as error:
|
||||
raise ValueError("stored signature could not be decrypted") from error
|
||||
return Signature(**data)
|
||||
|
||||
|
||||
class AttemptLockout:
|
||||
"""Process-local lockout. Persistent deployments should use shared storage."""
|
||||
|
||||
def __init__(self, *, max_failures: int = 5, lockout_seconds: float = 60.0) -> None:
|
||||
if max_failures < 1 or lockout_seconds <= 0:
|
||||
raise ValueError("max_failures and lockout_seconds must be positive")
|
||||
self.max_failures = max_failures
|
||||
self.lockout_seconds = lockout_seconds
|
||||
self._failures: dict[str, int] = defaultdict(int)
|
||||
self._locked_until: dict[str, float] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def remaining_lockout(self, user_id: str) -> float:
|
||||
with self._lock:
|
||||
return max(0.0, self._locked_until.get(user_id, 0.0) - time.monotonic())
|
||||
|
||||
def record_failure(self, user_id: str) -> None:
|
||||
with self._lock:
|
||||
self._failures[user_id] += 1
|
||||
if self._failures[user_id] >= self.max_failures:
|
||||
self._locked_until[user_id] = time.monotonic() + self.lockout_seconds
|
||||
self._failures[user_id] = 0
|
||||
|
||||
def record_success(self, user_id: str) -> None:
|
||||
with self._lock:
|
||||
self._failures.pop(user_id, None)
|
||||
self._locked_until.pop(user_id, None)
|
||||
29
tests/test_api.py
Normal file
29
tests/test_api.py
Normal file
@ -0,0 +1,29 @@
|
||||
import importlib
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def test_enroll_verify_and_list_users(tmp_path, monkeypatch):
|
||||
database = tmp_path / "test.sqlite3"
|
||||
monkeypatch.setenv("SIGVERIFY_DATABASE_URL", f"sqlite:///{database.as_posix()}")
|
||||
monkeypatch.setenv("SIGVERIFY_FERNET_KEY", Fernet.generate_key().decode())
|
||||
module = importlib.import_module("app.main")
|
||||
client = TestClient(module.app)
|
||||
payload = {
|
||||
"user_id": "test-user",
|
||||
"t": [0, 1, 2, 3],
|
||||
"x": [0, 1, 2, 3],
|
||||
"y": [1, 2, 1, 0],
|
||||
"p": [0.5, 0.6, 0.7, 0.5],
|
||||
}
|
||||
|
||||
assert client.get("/").status_code == 200
|
||||
assert client.post("/enroll", json=payload).status_code == 200
|
||||
assert client.post("/enroll", json=payload).status_code == 200
|
||||
verified = client.post("/verify", json=payload)
|
||||
assert verified.status_code == 200
|
||||
assert verified.json()["accepted"] is True
|
||||
assert client.get("/users").json()["users"] == [
|
||||
{"user_id": "test-user", "template_count": 2}
|
||||
]
|
||||
109
tests/test_baseline.py
Normal file
109
tests/test_baseline.py
Normal file
@ -0,0 +1,109 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from sigverify.evaluation.metrics import verification_metrics
|
||||
from sigverify.features import extract_features, global_features
|
||||
from sigverify.io.schema import Signature
|
||||
from sigverify.io.loaders import load_mobisig
|
||||
from sigverify.matching.dtw import dtw_distance
|
||||
from sigverify.matching.models import DTWVerifier
|
||||
from sigverify.preprocessing import preprocess_signature
|
||||
from sigverify.security import AttemptLockout, TemplateCipher
|
||||
|
||||
|
||||
def sample(label="genuine", scale=1.0):
|
||||
t = np.array([2.0, 0.0, 1.0, 1.0, 3.0])
|
||||
x = scale * np.array([2.0, 0.0, 1.0, 99.0, 3.0])
|
||||
y = scale * np.array([0.0, 0.0, 1.0, 99.0, 1.0])
|
||||
return Signature("person-1", label, t, x, y, p=np.ones(5))
|
||||
|
||||
|
||||
def test_preprocessing_sorts_deduplicates_and_retains_original_size():
|
||||
cleaned = preprocess_signature(sample(), points=20, smoothing_sigma=0)
|
||||
|
||||
assert len(cleaned.t) == 20
|
||||
assert np.all(np.diff(cleaned.t) > 0)
|
||||
assert cleaned.size_normalized
|
||||
assert cleaned.original_size == pytest.approx(np.hypot(3, 1))
|
||||
assert np.max(np.abs(cleaned.x)) == pytest.approx(0.5)
|
||||
assert cleaned.p is not None and len(cleaned.p) == 20
|
||||
|
||||
|
||||
def test_pen_up_points_are_removed_before_resampling():
|
||||
raw = Signature(
|
||||
"person-1", "genuine", [0, 1, 2], [0, 100, 2], [0, 100, 2],
|
||||
pen_down=[True, False, True],
|
||||
)
|
||||
cleaned = preprocess_signature(raw, points=3, smoothing_sigma=0, normalize_position=False)
|
||||
assert cleaned.x.tolist() == pytest.approx([0, 1, 2])
|
||||
assert cleaned.pen_down is not None and cleaned.pen_down.all()
|
||||
|
||||
|
||||
def test_features_support_optional_pressure_and_global_descriptors():
|
||||
clean = preprocess_signature(sample(), points=10, smoothing_sigma=0)
|
||||
base = extract_features(clean, include_acceleration=False, include_curvature=False)
|
||||
with_pressure = extract_features(
|
||||
clean, include_pressure=True, include_acceleration=False, include_curvature=False
|
||||
)
|
||||
|
||||
assert base.shape == (10, 5)
|
||||
assert with_pressure.shape == (10, 6)
|
||||
assert global_features(clean)["original_size"] > 0
|
||||
|
||||
|
||||
def test_dtw_is_zero_for_identical_sequences_and_handles_band():
|
||||
sequence = np.array([[0.0], [1.0], [2.0]])
|
||||
assert dtw_distance(sequence, sequence) == 0
|
||||
assert dtw_distance(sequence, sequence[::-1]) > 0
|
||||
assert np.isfinite(dtw_distance(sequence, sequence[:2]))
|
||||
|
||||
|
||||
def test_dtw_verifier_enrollment_and_normalized_score():
|
||||
first = preprocess_signature(sample(), points=20, smoothing_sigma=0)
|
||||
second = preprocess_signature(sample(scale=1.02), points=20, smoothing_sigma=0)
|
||||
verifier = DTWVerifier()
|
||||
verifier.enroll([first, second])
|
||||
assert verifier.score(first) < 0.1
|
||||
with pytest.raises(ValueError, match="genuine"):
|
||||
verifier.enroll([first, first.copy(label="forgery")])
|
||||
|
||||
|
||||
def test_metrics_detect_perfect_separation():
|
||||
result = verification_metrics([1, 1, 0, 0], [0.1, 0.2, 0.8, 0.9])
|
||||
assert result["eer"] == 0
|
||||
|
||||
|
||||
def test_template_cipher_round_trip_and_lockout():
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
signature = preprocess_signature(sample(), points=8)
|
||||
cipher = TemplateCipher(Fernet.generate_key())
|
||||
restored = cipher.decrypt(cipher.encrypt(signature))
|
||||
np.testing.assert_allclose(restored.x, signature.x)
|
||||
|
||||
lockout = AttemptLockout(max_failures=2, lockout_seconds=1)
|
||||
lockout.record_failure("person-1")
|
||||
assert lockout.remaining_lockout("person-1") == 0
|
||||
lockout.record_failure("person-1")
|
||||
assert lockout.remaining_lockout("person-1") > 0
|
||||
|
||||
|
||||
def test_mobisig_loader_maps_columns_and_filename_metadata(tmp_path):
|
||||
user_dir = tmp_path / "USER1"
|
||||
user_dir.mkdir()
|
||||
(user_dir / "SIGN_GEN_USER1_USER1_1.csv").write_text(
|
||||
"x,y,timestamp,pressure,fingerarea\n1,2,10,0.5,3\n2,4,20,0.6,4\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(user_dir / "SIGN_FOR_USER1_USER2_1.csv").write_text(
|
||||
"x,y,timestamp,pressure,fingerarea\n1,2,10,0.5,3\n2,3,20,0.6,4\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
signatures = load_mobisig(tmp_path)
|
||||
|
||||
assert [(item.user_id, item.label) for item in signatures] == [
|
||||
("USER1", "forgery"),
|
||||
("USER1", "genuine"),
|
||||
]
|
||||
assert signatures[0].p is not None and signatures[0].area is not None
|
||||
Loading…
Reference in New Issue
Block a user