Add files using upload-large-folder tool
Browse files- .gitignore +12 -0
- README.md +546 -0
- annotations/bank_doc_01.json +32 -0
- annotations/bank_doc_02.json +23 -0
- annotations/bank_doc_03.json +32 -0
- annotations/bank_doc_04.json +32 -0
- annotations/bank_doc_05.json +32 -0
- annotations/bank_doc_06.json +32 -0
- annotations/bank_doc_07.json +32 -0
- annotations/bank_doc_08.json +32 -0
- annotations/bank_doc_09.json +32 -0
- configs/default.yaml +216 -0
- configs/examples/clean_easy.yaml +31 -0
- configs/examples/hard_degraded.yaml +47 -0
- configs/examples/single_document_demo.yaml +50 -0
- configs/examples/verification_cases.yaml +34 -0
- configs/generate.yaml +171 -0
- dataset/metadata.csv +12 -0
- main.py +111 -0
- pytest.ini +4 -0
- requirements.txt +15 -0
- src/config/__init__.py +6 -0
- src/config/schema.py +267 -0
- src/metadata/__init__.py +1 -0
- src/metadata/metadata_exporter.py +234 -0
- src/placement/__init__.py +1 -0
- src/placement/placement_engine.py +189 -0
- src/rendering/__init__.py +1 -0
- src/rendering/printed_text.py +64 -0
- src/rendering/renderer.py +105 -0
- src/rendering/signature_preprocessor.py +123 -0
- src/signatures/__init__.py +1 -0
- src/signatures/signature_dataset.py +208 -0
- src/templates/__init__.py +1 -0
- src/templates/annotation_schema.py +69 -0
- src/templates/box_detector.py +133 -0
- src/templates/template_loader.py +146 -0
- src/utils/__init__.py +1 -0
- src/utils/geometry_utils.py +158 -0
- src/utils/image_io.py +62 -0
- src/utils/logging_utils.py +53 -0
- src/utils/names.py +62 -0
- src/utils/random_utils.py +96 -0
- src/utils/transform_utils.py +49 -0
- src/verification/__init__.py +0 -0
- src/verification/case_types.py +55 -0
- src/verification/registry.py +95 -0
- src/visualization/__init__.py +1 -0
- src/visualization/visualizer.py +137 -0
- tests/__init__.py +0 -0
.gitignore
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
| 3 |
+
.venv/
|
| 4 |
+
venv/
|
| 5 |
+
.pytest_cache/
|
| 6 |
+
*.egg-info/
|
| 7 |
+
|
| 8 |
+
output/*.png
|
| 9 |
+
output/*.json
|
| 10 |
+
output/visualizations/
|
| 11 |
+
output/.cache/
|
| 12 |
+
!output/.gitkeep
|
README.md
ADDED
|
@@ -0,0 +1,546 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Synthetic Signed Bank Document Generator
|
| 2 |
+
|
| 3 |
+
A modular, deterministic, configuration-driven pipeline that takes scanned
|
| 4 |
+
bank document templates with blank signature fields, fills them with
|
| 5 |
+
realistic handwritten signatures drawn from a public signature dataset,
|
| 6 |
+
applies configurable physical/scanner degradation, and exports both the
|
| 7 |
+
rendered document and a comprehensive per-sample metadata JSON — suitable
|
| 8 |
+
for training and benchmarking document-AI / signature-verification models.
|
| 9 |
+
|
| 10 |
+
```
|
| 11 |
+
python main.py --config configs/generate.yaml --count 10000
|
| 12 |
+
```
|
| 13 |
+
|
| 14 |
+
---
|
| 15 |
+
|
| 16 |
+
## Table of contents
|
| 17 |
+
|
| 18 |
+
1. [Installation](#installation)
|
| 19 |
+
2. [Project layout](#project-layout)
|
| 20 |
+
3. [Input assets](#input-assets)
|
| 21 |
+
4. [Quick start](#quick-start)
|
| 22 |
+
5. [Configuration](#configuration)
|
| 23 |
+
6. [Architecture / pipeline stages](#architecture--pipeline-stages)
|
| 24 |
+
7. [Metadata schema](#metadata-schema)
|
| 25 |
+
8. [Verification test cases](#verification-test-cases)
|
| 26 |
+
9. [Difficulty scoring](#difficulty-scoring)
|
| 27 |
+
10. [Signature box annotation](#signature-box-annotation)
|
| 28 |
+
11. [Determinism & reproducibility](#determinism--reproducibility)
|
| 29 |
+
12. [Validation](#validation)
|
| 30 |
+
13. [Visualization](#visualization)
|
| 31 |
+
14. [Testing](#testing)
|
| 32 |
+
15. [Extending the pipeline](#extending-the-pipeline)
|
| 33 |
+
|
| 34 |
+
---
|
| 35 |
+
|
| 36 |
+
## Installation
|
| 37 |
+
|
| 38 |
+
Requires **Python 3.12+** (developed and tested against 3.11.9 as well; no
|
| 39 |
+
3.12-only syntax is used, so 3.11 works too).
|
| 40 |
+
|
| 41 |
+
```bash
|
| 42 |
+
python -m venv .venv
|
| 43 |
+
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
| 44 |
+
pip install -r requirements.txt
|
| 45 |
+
```
|
| 46 |
+
|
| 47 |
+
Core dependencies: OpenCV, NumPy, SciPy, Pillow, scikit-image, Shapely,
|
| 48 |
+
PyYAML, Pydantic, Albumentations, OmegaConf, Matplotlib, tqdm, pytest.
|
| 49 |
+
|
| 50 |
+
## Project layout
|
| 51 |
+
|
| 52 |
+
```
|
| 53 |
+
cc/
|
| 54 |
+
├── image/ # 9 scanned bank document templates (provided)
|
| 55 |
+
├── dataset/ # Public handwritten signature dataset (provided)
|
| 56 |
+
├── annotations/ # Signature box annotations, one JSON per template
|
| 57 |
+
├── configs/
|
| 58 |
+
│ ├── default.yaml # Every config field with its default value
|
| 59 |
+
│ ├── generate.yaml # Example large-batch dataset config
|
| 60 |
+
│ └── examples/ # Focused example configs (see below)
|
| 61 |
+
├── output/ # Generated PNG + JSON land here
|
| 62 |
+
├── src/
|
| 63 |
+
│ ├── config/ # Pydantic schema + OmegaConf loader
|
| 64 |
+
│ ├── templates/ # TemplateLoader, annotation schema, CV box detector
|
| 65 |
+
│ ├── signatures/ # SignatureDataset discovery/sampling
|
| 66 |
+
│ ├── rendering/ # SignaturePreprocessor, Renderer (blending)
|
| 67 |
+
│ ├── placement/ # PlacementEngine
|
| 68 |
+
│ ├── augmentation/ # AugmentationEngine + effect implementations
|
| 69 |
+
│ ├── analysis/ # GeometryAnalyzer, DifficultyAnalyzer, ValidationEngine
|
| 70 |
+
│ ├── verification/ # Verification test-case registry + CASE_TABLE (case_type 1a-5b)
|
| 71 |
+
│ ├── metadata/ # MetadataExporter
|
| 72 |
+
│ ├── visualization/ # Debug figure rendering
|
| 73 |
+
│ ├── utils/ # Logging, seeding, geometry, I/O, names
|
| 74 |
+
│ └── generator.py # DatasetGenerator orchestrator (+ multiprocessing)
|
| 75 |
+
├── tests/ # pytest unit + integration tests
|
| 76 |
+
├── main.py # CLI entry point
|
| 77 |
+
└── requirements.txt
|
| 78 |
+
```
|
| 79 |
+
|
| 80 |
+
## Input assets
|
| 81 |
+
|
| 82 |
+
This repository ships with:
|
| 83 |
+
|
| 84 |
+
* **`image/`** — 9 real scanned bank document templates (guarantee letters,
|
| 85 |
+
loan agreements, account mandates), auto-discovered recursively regardless
|
| 86 |
+
of subfolder layout. Each gets a stable id `bank_doc_01` … `bank_doc_09`,
|
| 87 |
+
assigned by a deterministic sort of their discovered paths.
|
| 88 |
+
* **`dataset/`** — A public handwritten signature dataset (CEDAR-style:
|
| 89 |
+
`genuine/original_<signer>_<sample>.png` and
|
| 90 |
+
`forged/forgeries_<signer>_<sample>.png`), auto-discovered recursively.
|
| 91 |
+
Nested `dataset/personNNN/*.png`-style layouts are also supported — the
|
| 92 |
+
loader falls back to using each signature's parent folder name as its
|
| 93 |
+
signer group when the CEDAR filename pattern doesn't match, and to an
|
| 94 |
+
auto-generated id when no identity can be inferred at all. An optional
|
| 95 |
+
`dataset/metadata.csv` with `path`/`filename` + `signer`/`source_type`
|
| 96 |
+
columns can override both the identity association and genuine/forged
|
| 97 |
+
labeling for any file, plus an optional `script` column (`"latin"` by
|
| 98 |
+
default) recording the handwriting script a signature is written in.
|
| 99 |
+
`dataset/hindi_bengali/` + the matching rows in `dataset/metadata.csv`
|
| 100 |
+
bundle 7 real, named, multi-script signers (Hindi/Devanagari-style,
|
| 101 |
+
Bengali-style, and Latin cursive) alongside the 55 anonymous CEDAR
|
| 102 |
+
identities, so generated samples aren't exclusively Latin-script English
|
| 103 |
+
names — `SignerIdentityRegistry.display_name()` (`src/verification/registry.py`)
|
| 104 |
+
returns a metadata.csv-sourced name as-is instead of generating one, and
|
| 105 |
+
every `SignatureRecord`/`verification` metadata block carries its `script`.
|
| 106 |
+
These signers have very few reference samples each (1-3, no forged
|
| 107 |
+
counterpart) — `records_for_group(..., "forged")` falls back to their
|
| 108 |
+
genuine samples with a logged warning rather than failing, so the `3b`
|
| 109 |
+
skilled-forgery case degrades gracefully rather than erroring for them.
|
| 110 |
+
|
| 111 |
+
Nothing about either directory's internal layout is hardcoded — add more
|
| 112 |
+
template images or more signature images/folders and they will be picked up
|
| 113 |
+
automatically on the next run.
|
| 114 |
+
|
| 115 |
+
## Quick start
|
| 116 |
+
|
| 117 |
+
```bash
|
| 118 |
+
# 1. Generate 10 samples with all defaults (configs/default.yaml)
|
| 119 |
+
python main.py
|
| 120 |
+
|
| 121 |
+
# 2. Generate a large, varied batch
|
| 122 |
+
python main.py --config configs/generate.yaml --count 10000
|
| 123 |
+
|
| 124 |
+
# 3. Generate one fully reproducible, fixed-identity sample with a debug figure
|
| 125 |
+
python main.py --config configs/examples/single_document_demo.yaml --save-visualization
|
| 126 |
+
|
| 127 |
+
# 4. Override arbitrary nested config values from the command line
|
| 128 |
+
python main.py --set placement.rotation_deg.max=6 --set augmentation.coffee_stain.enabled=true
|
| 129 |
+
|
| 130 |
+
# 5. Restrict to one template, use 8 worker processes, don't overwrite existing files
|
| 131 |
+
python main.py --template bank_doc_03 --count 5000 --workers 8
|
| 132 |
+
```
|
| 133 |
+
|
| 134 |
+
Every run writes `output/<prefix>_<NNNNNN>.png` + `.json` pairs, plus
|
| 135 |
+
`output/generation_summary.json` (batch statistics) and
|
| 136 |
+
`output/resolved_config.yaml` (the fully-resolved config actually used, for
|
| 137 |
+
provenance/reproduction).
|
| 138 |
+
|
| 139 |
+
### CLI reference
|
| 140 |
+
|
| 141 |
+
| Flag | Effect |
|
| 142 |
+
|---|---|
|
| 143 |
+
| `--config PATH` | User YAML merged on top of `configs/default.yaml` |
|
| 144 |
+
| `--count N` | Number of samples |
|
| 145 |
+
| `--seed N` | Master random seed |
|
| 146 |
+
| `--workers N` | Worker process count (`1` = sequential, in-process) |
|
| 147 |
+
| `--output DIR` | Output directory |
|
| 148 |
+
| `--template ID` | Force every sample to use one template (e.g. `bank_doc_03`) |
|
| 149 |
+
| `--start-index N` | First sample number (for resuming/sharding a batch) |
|
| 150 |
+
| `--overwrite` | Regenerate samples even if their files already exist |
|
| 151 |
+
| `--save-visualization` | Also write a debug figure per sample |
|
| 152 |
+
| `--set key.path=value` | Arbitrary dot-path config override (repeatable) |
|
| 153 |
+
| `--log-level LEVEL` | `DEBUG` / `INFO` / `WARNING` / `ERROR` |
|
| 154 |
+
|
| 155 |
+
## Configuration
|
| 156 |
+
|
| 157 |
+
Configuration is layered: `configs/default.yaml` (every field, documented) →
|
| 158 |
+
an optional `--config` user YAML (merged on top) → `--set key=value` CLI
|
| 159 |
+
overrides (highest precedence). The merged result is validated against a
|
| 160 |
+
Pydantic schema (`src/config/schema.py`) — invalid types, out-of-range
|
| 161 |
+
values, or unknown keys fail fast with a clear error instead of silently
|
| 162 |
+
being ignored.
|
| 163 |
+
|
| 164 |
+
Any placement/augmentation numeric parameter accepts either a fixed scalar
|
| 165 |
+
or a range, resolved independently per sample by a seeded RNG:
|
| 166 |
+
|
| 167 |
+
```yaml
|
| 168 |
+
placement:
|
| 169 |
+
scale_factor: 0.84 # fixed
|
| 170 |
+
rotation_deg:
|
| 171 |
+
min: -5
|
| 172 |
+
max: 5 # sampled uniformly per sample
|
| 173 |
+
```
|
| 174 |
+
|
| 175 |
+
Every augmentation effect follows the same shape:
|
| 176 |
+
|
| 177 |
+
```yaml
|
| 178 |
+
augmentation:
|
| 179 |
+
coffee_stain:
|
| 180 |
+
enabled: true
|
| 181 |
+
strength: 0.42 # or {min: ..., max: ...}
|
| 182 |
+
probability: 0.1 # per-sample chance of actually applying
|
| 183 |
+
seed: null # optional explicit seed; default derives from (master_seed, sample_index, name)
|
| 184 |
+
```
|
| 185 |
+
|
| 186 |
+
These effects are covered by **20 configurable families** (a couple are
|
| 187 |
+
strict specializations of another and share one family with a `type`/`tone`
|
| 188 |
+
selector, so the config surface stays coherent):
|
| 189 |
+
|
| 190 |
+
| Family | Covers |
|
| 191 |
+
|---|---|
|
| 192 |
+
| `rotation` | Extra probabilistic jitter on top of `placement.rotation_deg` |
|
| 193 |
+
| `stroke_dilation` | Ink stroke thickness variation |
|
| 194 |
+
| `motion_blur`, `gaussian_blur` | Signature-layer blur |
|
| 195 |
+
| `perspective` | Whole-page perspective warp |
|
| 196 |
+
| `scanner_noise`, `salt_pepper` | Sensor/impulse noise |
|
| 197 |
+
| `jpeg_compression` | Lossy re-encode artifacts (applied last) |
|
| 198 |
+
| `contrast`, `gamma` | Photometric shifts |
|
| 199 |
+
| `coffee_stain` | Coffee-ring blotches |
|
| 200 |
+
| `stain` (`type: paper\|water\|random`) | Paper stains + water stains |
|
| 201 |
+
| `shadow` (`type: edge\|corner\|fold\|random`) | Edge / corner / fold shadows |
|
| 202 |
+
| `crumple` | Paper crumpling + wrinkle lines |
|
| 203 |
+
| `discoloration` (`tone: yellow\|grey\|sepia\|random`) | General discoloration + paper yellowing |
|
| 204 |
+
| `page_tilt` | Crooked scanner feed |
|
| 205 |
+
| `dust`, `scanner_streaks` | Sensor debris / dirty-glass streaks |
|
| 206 |
+
| `ink_fading`, `ink_bleed` | Ink density effects |
|
| 207 |
+
|
| 208 |
+
See `configs/default.yaml` for every field and its default, and
|
| 209 |
+
`configs/generate.yaml` / `configs/examples/*.yaml` for worked examples
|
| 210 |
+
(a large varied batch, a fixed-identity single-document demo, an "easy"
|
| 211 |
+
preset, and a "hard/degraded" preset).
|
| 212 |
+
|
| 213 |
+
## Architecture / pipeline stages
|
| 214 |
+
|
| 215 |
+
Each sample flows through the same 11 stages the spec describes, one class
|
| 216 |
+
per stage:
|
| 217 |
+
|
| 218 |
+
| # | Stage | Class |
|
| 219 |
+
|---|---|---|
|
| 220 |
+
| 1 | Load template | `TemplateLoader` |
|
| 221 |
+
| 2 | Resolve signature box(es) | `TemplateLoader` (manual annotation, or CV auto-detect fallback) |
|
| 222 |
+
| 3 | Assign signer name/role | `src.utils.names` + config |
|
| 223 |
+
| 4 | Select a signature image | `SignatureDataset` |
|
| 224 |
+
| 5 | Clean/preprocess signature | `SignaturePreprocessor` |
|
| 225 |
+
| 6 | Fit & position in box | `PlacementEngine` |
|
| 226 |
+
| 7 | Signature-layer augmentation | `AugmentationEngine.apply_to_signature` |
|
| 227 |
+
| 8 | Blend onto document | `Renderer` |
|
| 228 |
+
| 9 | Document-level augmentation | `AugmentationEngine.apply_to_document` |
|
| 229 |
+
| 10 | Detect rendered signature + geometry | `GeometryAnalyzer` |
|
| 230 |
+
| 11 | Difficulty score | `DifficultyAnalyzer` |
|
| 231 |
+
| — | Export | `MetadataExporter` |
|
| 232 |
+
| — | Verify | `ValidationEngine` |
|
| 233 |
+
|
| 234 |
+
`GeneratorContext` (in `src/generator.py`) wires all of the above together
|
| 235 |
+
for one sample; `DatasetGenerator` drives it across a batch, either
|
| 236 |
+
sequentially (`workers=1`) or via a `ProcessPoolExecutor` where each worker
|
| 237 |
+
builds one `GeneratorContext` (via a pool initializer) and reuses it for
|
| 238 |
+
every sample it's assigned.
|
| 239 |
+
|
| 240 |
+
### Signature cleaning (`SignaturePreprocessor`)
|
| 241 |
+
|
| 242 |
+
Signature dataset images are flat grayscale/RGB scans with no alpha
|
| 243 |
+
channel. The preprocessor estimates the local paper background level,
|
| 244 |
+
derives a soft per-pixel alpha from how dark each pixel is relative to that
|
| 245 |
+
background (gamma-softened to preserve antialiased stroke edges/natural
|
| 246 |
+
texture rather than a hard binary mask), removes sub-pixel speckle noise via
|
| 247 |
+
connected-component filtering, and tightly crops to the ink content. A
|
| 248 |
+
flat ink color (randomly chosen per sample from a small realistic pool —
|
| 249 |
+
black, blue-black, ballpoint blue) is applied to the RGB channels; the alpha
|
| 250 |
+
channel carries all of the texture.
|
| 251 |
+
|
| 252 |
+
### Placement (`PlacementEngine`)
|
| 253 |
+
|
| 254 |
+
Rotates first (about the signature's own center, on an auto-expanding
|
| 255 |
+
transparent canvas so nothing clips), then scales to fit the box's
|
| 256 |
+
margin-adjusted interior while preserving aspect ratio (never stretches),
|
| 257 |
+
then positions via an anchor point (`center`, `top_left`, …) plus
|
| 258 |
+
pixel offsets. A configurable `misplacement_probability` optionally kicks
|
| 259 |
+
the signature away from its anchored position by up to
|
| 260 |
+
`misplacement_strength * max(box_w, box_h)` in a random direction — the
|
| 261 |
+
mechanism behind intentionally "hard" (badly-placed) samples.
|
| 262 |
+
A signature is never allowed to shrink below a legible minimum height
|
| 263 |
+
(16px) even inside a very short box; on the real bundled templates several
|
| 264 |
+
signature lines are only 12-24px tall, and a naive fit-to-box scale would
|
| 265 |
+
otherwise render an invisible, sub-pixel signature.
|
| 266 |
+
|
| 267 |
+
### Rendering (`Renderer`)
|
| 268 |
+
|
| 269 |
+
Supports `multiply`, `darken`, and `alpha` blend modes, configurable ink
|
| 270 |
+
opacity, alpha-edge feathering (`feather_px`), an overall blur to match scan
|
| 271 |
+
resolution (`blur_sigma`), a light Gaussian sensor-noise pass baked into
|
| 272 |
+
every render, and a final mild anti-aliasing blur.
|
| 273 |
+
|
| 274 |
+
### Detection (`GeometryAnalyzer`)
|
| 275 |
+
|
| 276 |
+
Detection is scoped to the *known* signature field (the annotated box, and,
|
| 277 |
+
if nothing is found there, a widened window around the union of the box and
|
| 278 |
+
the pre-augmentation placement location) rather than searching the whole
|
| 279 |
+
page — exactly how a real pipeline would use its layout annotations, and
|
| 280 |
+
what makes the box-vs-detection coverage metrics meaningful in the first
|
| 281 |
+
place. Ink is separated from the local paper background via adaptive
|
| 282 |
+
thresholding; a heuristic filter discards thin, wide, solid components
|
| 283 |
+
(printed "sign here" rule lines) so a blank field is never mistaken for a
|
| 284 |
+
signature. From the resulting ink mask it computes: axis-aligned bounding
|
| 285 |
+
box, `cv2.minAreaRect` minimum rotated rectangle, a convex-hull polygon,
|
| 286 |
+
centroid, PCA-based orientation, ink pixel count, `box_coverage_pct`,
|
| 287 |
+
`containment_pct`, `ink_fill_ratio`, `polygon_overlap_proxy` (IoU),
|
| 288 |
+
distance from box center, and margin utilization.
|
| 289 |
+
|
| 290 |
+
## Metadata schema
|
| 291 |
+
|
| 292 |
+
One JSON per sample, always containing the full schema (fields for
|
| 293 |
+
augmentations that weren't applied are present with a neutral value, not
|
| 294 |
+
omitted):
|
| 295 |
+
|
| 296 |
+
```jsonc
|
| 297 |
+
{
|
| 298 |
+
"document_id": "sample_000001",
|
| 299 |
+
"template": "bank_doc_03",
|
| 300 |
+
"document_type": "deed_of_guarantee",
|
| 301 |
+
"page": 1, "page_size": [1024, 559], "dpi": 300,
|
| 302 |
+
"signer": {"name": "Jane Doe", "role": "Guarantor (Authorized Signatory)"},
|
| 303 |
+
"signature_source": {"signature_id": "sig_001437", "path": "dataset/genuine/original_14_7.png",
|
| 304 |
+
"signer_group": "signer_0014", "source_type": "genuine"},
|
| 305 |
+
"placement": {"x_offset": 8.0, "y_offset": -4.0, "rotation_deg": -3.0, "scale_factor": 0.84,
|
| 306 |
+
"anchor_point": "center", "misplaced": false, ...},
|
| 307 |
+
"rendering": {"blend_mode": "multiply", "ink_opacity": 0.91, "blur_sigma": 0.34, ...},
|
| 308 |
+
"difficulty": {"score": 0.57, "tier": "medium", "misplacement": 0.38, "whitespace": 0.14,
|
| 309 |
+
"faintness": 0.45, "smallness": 0.31, "low_texture": 0.12},
|
| 310 |
+
"quality": {"degraded": false, "scanner_quality": 0.85, "paper_quality": 0.85, "render_quality": 0.85},
|
| 311 |
+
"verification": {
|
| 312 |
+
"case_type": "3a", "verdict": "REJECT", "reject_code": "IDENTITY_MISMATCH",
|
| 313 |
+
"target_box_id": "sig1", "expected_signer_group": "signer_0014", "expected_signer_script": "latin",
|
| 314 |
+
"actual_signer_group": "signer_0027", "actual_source_type": "genuine", "actual_signer_script": "latin",
|
| 315 |
+
"companion": null
|
| 316 |
+
},
|
| 317 |
+
"augmentation": {
|
| 318 |
+
"rotate_deg": -2, "scale_factor": 0.84, "stroke": 0.12, "noise_amount": 0.06,
|
| 319 |
+
"stain_type": null, "stain_strength": 0.0, "shadow_type": "corner", "shadow_strength": 0.55,
|
| 320 |
+
"coffee_stain_strength": 0.42, "crumple_strength": 0.18, "tilt_deg": 1.3,
|
| 321 |
+
"discoloration_strength": 0.0, "blur_sigma": 0.35, "jpeg_quality": 82, "scanner_noise": 0.02,
|
| 322 |
+
"effects": { "...every single effect, always present, with enabled/applied/strength/probability/seed/params...": {} }
|
| 323 |
+
},
|
| 324 |
+
"geometry": {
|
| 325 |
+
"box_px": [1200, 830, 1530, 950], "target_box": [...], "actual_box": [...],
|
| 326 |
+
"signature_bbox_px": [...], "signature_polygon_px": [[x, y], ...], "convex_hull_px": [...],
|
| 327 |
+
"min_rotated_rect": {"center": [...], "size": [...], "angle_deg": ...},
|
| 328 |
+
"centroid_px": [...], "orientation_deg": ..., "ink_pixels": ..., "detected": true
|
| 329 |
+
},
|
| 330 |
+
"metrics": {
|
| 331 |
+
"box_coverage_pct": 84.2, "containment_pct": 96.3, "ink_fill_ratio": 0.28,
|
| 332 |
+
"polygon_overlap_proxy": 0.71, "distance_from_box_center_px": 12.4, "margin_utilization": 0.6,
|
| 333 |
+
"mean_dark_intensity": 142.1, "gray_stddev": 38.6, "signature_area_ratio": 0.011
|
| 334 |
+
},
|
| 335 |
+
"provenance": {"master_seed": 1234, "sample_index": 1}
|
| 336 |
+
}
|
| 337 |
+
```
|
| 338 |
+
|
| 339 |
+
The `augmentation.effects.<name>` block is the full audit trail for every
|
| 340 |
+
one of the 20 effect families: whether it was `enabled` in config, whether
|
| 341 |
+
its probability roll actually `applied` it, the resolved `strength`, and
|
| 342 |
+
any effect-specific `params` (e.g. `{"resolved_type": "corner"}` for
|
| 343 |
+
shadow, `{"quality": 58}` for JPEG compression) — this is present even when
|
| 344 |
+
`applied: false`, so the schema is identical across every sample regardless
|
| 345 |
+
of what happened to fire.
|
| 346 |
+
|
| 347 |
+
`quality.degraded` is `true` iff `quality.case_type == "1d"` **or**
|
| 348 |
+
`quality.notes` contains the substring `"low-quality"` (case-insensitive) —
|
| 349 |
+
both driven by `quality.case_type` / `quality.notes` in config (see
|
| 350 |
+
`configs/examples/hard_degraded.yaml`). `verification` is present (with
|
| 351 |
+
every field `null`) on every sample; see the next section for when it's
|
| 352 |
+
populated.
|
| 353 |
+
|
| 354 |
+
## Verification test cases
|
| 355 |
+
|
| 356 |
+
Setting `quality.case_type` to one of 12 recognized codes switches sample
|
| 357 |
+
generation into **scenario-driven mode**: instead of a random genuine
|
| 358 |
+
signature in a random box, the pipeline renders the specific scenario the
|
| 359 |
+
code describes and stamps the sample's `verification` metadata block with
|
| 360 |
+
the matching ground-truth `verdict`/`reject_code` — producing a labeled
|
| 361 |
+
benchmark for a downstream verifier. Any other value (or leaving it unset)
|
| 362 |
+
behaves exactly as before.
|
| 363 |
+
|
| 364 |
+
| Code | Scenario | Verdict | Reject code |
|
| 365 |
+
|---|---|---|---|
|
| 366 |
+
| `1a` | Correct signer signs normally | PASS | - |
|
| 367 |
+
| `1b` | Correct signer, natural pen variation (a different genuine sample of the same person) | PASS | - |
|
| 368 |
+
| `1c` | Multi-signer form, both boxes sign correctly | PASS | - |
|
| 369 |
+
| `1d` | Correct signer, scan degraded (pair with `configs/examples/hard_degraded.yaml`) | PASS | - |
|
| 370 |
+
| `2a` | Box left completely blank | REJECT | `NO_SIGNATURE` |
|
| 371 |
+
| `2b` | Printed/typed name only, no handwriting | REJECT | `NO_SIGNATURE` |
|
| 372 |
+
| `3a` | Wrong real person signs the box (a different genuine signer) | REJECT | `IDENTITY_MISMATCH` |
|
| 373 |
+
| `3b` | Skilled-forgery sample used (CEDAR `forged/` sample of the authorized signer) | REJECT | `IDENTITY_MISMATCH` |
|
| 374 |
+
| `4a` | Authorized signer, but wrong role's box (the *other* box's authorized signer signs here instead) | REJECT | `ROLE_MISMATCH` |
|
| 375 |
+
| `4b` | Signer from a different form entirely (authorized elsewhere, not on this template) | REJECT | `ROLE_MISMATCH` |
|
| 376 |
+
| `5a` | Required signer's box left empty (while a companion box is correctly signed) | REJECT | `REQUIRED_SIGNER_ABSENT` |
|
| 377 |
+
| `5b` | Someone else signs in the required signer's place (while a companion box is correctly signed) | REJECT | `REQUIRED_SIGNER_ABSENT` |
|
| 378 |
+
|
| 379 |
+
```bash
|
| 380 |
+
python main.py --config configs/examples/verification_cases.yaml --set quality.case_type=3a --count 10
|
| 381 |
+
python scripts/generate_verification_suite.py --per-case 2 # one labeled batch covering all 12 codes
|
| 382 |
+
```
|
| 383 |
+
|
| 384 |
+
### How ground truth is derived
|
| 385 |
+
|
| 386 |
+
`src/verification/registry.py`'s `SignerIdentityRegistry` deterministically
|
| 387 |
+
assigns an "authorized signer" identity (one of the discovered CEDAR signer
|
| 388 |
+
groups) to every `(template_id, box_id)` pair, purely as a function of the
|
| 389 |
+
master seed — there's no persisted state, consistent with the rest of the
|
| 390 |
+
pipeline's seeding model. Every case type is defined declaratively in
|
| 391 |
+
`src/verification/case_types.py` (`CASE_TABLE`) as what should actually be
|
| 392 |
+
rendered (`signature_mode`: genuine-correct, blank, printed-text,
|
| 393 |
+
wrong-signer, skilled-forgery, role-swap, foreign-template-signer, ...) plus
|
| 394 |
+
whether a companion box on the same page should also be filled with its own
|
| 395 |
+
correctly-authorized signature (`1c`/`5a`/`5b` — the cases whose label only
|
| 396 |
+
makes sense in a multi-signer-page context). The `verification` metadata
|
| 397 |
+
block records both the expected and actual signer group so the label is
|
| 398 |
+
independently auditable, not just asserted.
|
| 399 |
+
|
| 400 |
+
Companion-box rendering exists purely for visual/contextual realism (so a
|
| 401 |
+
"required signer absent" sample actually shows a properly multi-signed
|
| 402 |
+
page); it does not get its own `geometry`/`difficulty` block — the schema
|
| 403 |
+
stays single-target-box-centric, same as it's always been. `signature_source`
|
| 404 |
+
is `null` for the two blank scenarios (`2a`/`5a`); the expected identity in
|
| 405 |
+
that case lives only in `verification.expected_signer_group`.
|
| 406 |
+
|
| 407 |
+
## Difficulty scoring
|
| 408 |
+
|
| 409 |
+
Computed exactly as specified, from the post-render, post-augmentation
|
| 410 |
+
detected geometry (a pure function — no randomness — so it is always
|
| 411 |
+
reproducible from its own recorded components):
|
| 412 |
+
|
| 413 |
+
```
|
| 414 |
+
misplacement = clamp(1 - polygon_overlap_proxy, 0, 1)
|
| 415 |
+
whitespace = clamp(whitespace_ratio, 0, 1) # whitespace_ratio = 1 - ink_fill_ratio
|
| 416 |
+
faintness = clamp((mean_dark_intensity - 90) / 120, 0, 1)
|
| 417 |
+
smallness = clamp(1 - signature_area_ratio / 0.04, 0, 1)
|
| 418 |
+
low_texture = clamp(1 - gray_stddev / 60, 0, 1)
|
| 419 |
+
|
| 420 |
+
score = 0.35*misplacement + 0.20*whitespace + 0.20*faintness + 0.15*smallness + 0.10*low_texture
|
| 421 |
+
|
| 422 |
+
tier = "easy" if score < 0.33 else "medium" if score < 0.66 else "hard"
|
| 423 |
+
```
|
| 424 |
+
|
| 425 |
+
**A note on the bundled templates:** `smallness` uses a fixed reference of
|
| 426 |
+
4% of page area. On the 9 real bank forms shipped in `image/`, the actual
|
| 427 |
+
signature lines are (realistically) much smaller than that — typically
|
| 428 |
+
0.2%–1.5% of the page — so `smallness`, and with it the overall score, runs
|
| 429 |
+
structurally high (mostly `medium`/`hard`) even under the "clean" example
|
| 430 |
+
preset. This is an accurate reflection of how small real bank-form
|
| 431 |
+
signature fields are relative to a full page, not a bug in the scoring
|
| 432 |
+
formula, which is intentionally implemented exactly as specified (weights
|
| 433 |
+
and the `0.04` reference are not something the pipeline silently retunes).
|
| 434 |
+
If your use case wants a different-looking tier distribution, adjust
|
| 435 |
+
`difficulty.easy_threshold` / `difficulty.hard_threshold` in config, or
|
| 436 |
+
supply larger signature boxes in your own annotations.
|
| 437 |
+
|
| 438 |
+
## Signature box annotation
|
| 439 |
+
|
| 440 |
+
Two modes, in order of preference:
|
| 441 |
+
|
| 442 |
+
* **Mode 2 — manual annotation (preferred, used for all 9 bundled
|
| 443 |
+
templates):** `annotations/<template_id>.json`:
|
| 444 |
+
|
| 445 |
+
```json
|
| 446 |
+
{
|
| 447 |
+
"id": "bank_doc_03",
|
| 448 |
+
"source_image": "image/guarantee/image.png",
|
| 449 |
+
"document_type": "deed_of_guarantee",
|
| 450 |
+
"pages": [{
|
| 451 |
+
"page": 1, "page_size": [1024, 559], "dpi": 300,
|
| 452 |
+
"signature_boxes": [
|
| 453 |
+
{"id": "sig1", "x1": 595, "y1": 399, "x2": 748, "y2": 431,
|
| 454 |
+
"role": "Guarantor (Authorized Signatory)", "name": "Guarantor"}
|
| 455 |
+
]
|
| 456 |
+
}]
|
| 457 |
+
}
|
| 458 |
+
```
|
| 459 |
+
|
| 460 |
+
The 9 bundled annotation files were hand-measured against each template
|
| 461 |
+
image (using a coordinate-grid overlay for precision) — every signature
|
| 462 |
+
field, its role, and page geometry are captured exactly.
|
| 463 |
+
|
| 464 |
+
* **Mode 1 — automatic CV detection (fallback):** if no
|
| 465 |
+
`annotations/<template_id>.json` exists for a discovered template,
|
| 466 |
+
`src/templates/box_detector.py` looks for long horizontal "sign here"
|
| 467 |
+
rule lines (morphological opening + contour filtering) and places a
|
| 468 |
+
candidate box directly above each one. The result is cached back to
|
| 469 |
+
`annotations/<template_id>.json` so detection only ever runs once per
|
| 470 |
+
template — subsequent runs (including a fresh checkout with new
|
| 471 |
+
templates dropped into `image/`) reuse the cached annotation. If a
|
| 472 |
+
template has no such lines at all, a generic lower-right fallback box is
|
| 473 |
+
used so the pipeline never crashes on an unannotated template.
|
| 474 |
+
|
| 475 |
+
To add a 10th template: drop its image into any subfolder of `image/` and
|
| 476 |
+
either let auto-detection run once, or hand-author its annotation file
|
| 477 |
+
(fastest way: temporarily overlay a coordinate grid on the image — see the
|
| 478 |
+
`_grid_debug` pattern used during development — and read off box corners).
|
| 479 |
+
|
| 480 |
+
## Determinism & reproducibility
|
| 481 |
+
|
| 482 |
+
Every random draw anywhere in the pipeline comes from an RNG whose seed is
|
| 483 |
+
`sha256(master_seed | sample_index | stage_name)` (`src/utils/random_utils.py`),
|
| 484 |
+
never from a shared/global RNG. Consequences:
|
| 485 |
+
|
| 486 |
+
* The same `(seed, config)` always produces byte-identical PNGs and
|
| 487 |
+
semantically identical JSON, regardless of `--workers` or scheduling order
|
| 488 |
+
(verified in `tests/test_end_to_end.py`, and by hand: a `workers=1` run
|
| 489 |
+
and a `workers=4` run of the same seed produce `cmp`-identical PNGs).
|
| 490 |
+
* Every individual augmentation effect's own `seed` field in its metadata
|
| 491 |
+
record is independently derivable/overridable — set `augmentation.<name>.seed`
|
| 492 |
+
explicitly in config to pin one specific effect while leaving everything
|
| 493 |
+
else seed-derived.
|
| 494 |
+
* `output/resolved_config.yaml` captures the exact fully-resolved
|
| 495 |
+
configuration used for a run, so it can be handed to `--config` later to
|
| 496 |
+
reproduce that run exactly (given the same `--seed`).
|
| 497 |
+
|
| 498 |
+
## Validation
|
| 499 |
+
|
| 500 |
+
`ValidationEngine` runs (by default) after every sample and checks:
|
| 501 |
+
metadata schema completeness, the signature was actually detected, its
|
| 502 |
+
polygon is non-degenerate and geometrically valid, every bounding box is
|
| 503 |
+
well-formed, boxes lie on the page (a warning, or an error under
|
| 504 |
+
`validation.strict: true`), no `NaN`/`Inf` leaked into the JSON, and the
|
| 505 |
+
difficulty score/tier are exactly reproducible from their own recorded
|
| 506 |
+
components. Per-sample pass/fail rolls up into
|
| 507 |
+
`output/generation_summary.json`. Disable with `generation.run_validation: false`.
|
| 508 |
+
|
| 509 |
+
## Visualization
|
| 510 |
+
|
| 511 |
+
`--save-visualization` writes `output/visualizations/<id>_viz.png` per
|
| 512 |
+
sample: the full rendered document with the target box (green), detected
|
| 513 |
+
bounding box (blue), and detected polygon (red) overlaid; a crop of the
|
| 514 |
+
signature field; the reconstructed detected-ink mask; a coverage-metrics
|
| 515 |
+
readout; and a difficulty-component bar chart.
|
| 516 |
+
|
| 517 |
+
## Testing
|
| 518 |
+
|
| 519 |
+
```bash
|
| 520 |
+
python -m pytest -q
|
| 521 |
+
```
|
| 522 |
+
|
| 523 |
+
80 tests covering geometry math (coverage/containment/IoU/margin
|
| 524 |
+
utilization), the exact difficulty formula (including weight-sum
|
| 525 |
+
validation and clamping), seed derivation/independence, placement
|
| 526 |
+
(determinism, aspect-ratio preservation, the minimum-height floor,
|
| 527 |
+
misplacement gating), signature/template discovery against the real
|
| 528 |
+
bundled assets (including the multi-script named signer pool), ink
|
| 529 |
+
detection (including the printed-rule-line false-positive guard), metadata
|
| 530 |
+
validation, full end-to-end reproducibility, and the case_type-driven
|
| 531 |
+
verification scenarios (`tests/test_verification_cases.py`: registry
|
| 532 |
+
determinism, ground truth for all 12 codes, multi-script signer handling,
|
| 533 |
+
and backward compatibility with the non-case-type path).
|
| 534 |
+
|
| 535 |
+
## Extending the pipeline
|
| 536 |
+
|
| 537 |
+
* **New augmentation effect:** add a function to
|
| 538 |
+
`src/augmentation/document_effects.py` or `signature_effects.py`, a field
|
| 539 |
+
to `AugmentationsConfig` in `src/config/schema.py`, and a branch in
|
| 540 |
+
`AugmentationEngine.apply_to_document`/`apply_to_signature`.
|
| 541 |
+
* **New template:** drop an image into `image/`; annotate manually or let
|
| 542 |
+
auto-detection + caching handle it.
|
| 543 |
+
* **New signature source:** drop images anywhere under `dataset/`
|
| 544 |
+
(optionally with a `metadata.csv`); no code changes needed.
|
| 545 |
+
* **Different difficulty calibration:** override `difficulty.*` weights/
|
| 546 |
+
thresholds in a config (must still sum to 1.0 — enforced by the schema).
|
annotations/bank_doc_01.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"id": "bank_doc_01",
|
| 3 |
+
"source_image": "image/guarantee/image copy 2.png",
|
| 4 |
+
"document_type": "bank_letter_of_guarantee",
|
| 5 |
+
"pages": [
|
| 6 |
+
{
|
| 7 |
+
"page": 1,
|
| 8 |
+
"page_size": [791, 1024],
|
| 9 |
+
"dpi": 300,
|
| 10 |
+
"signature_boxes": [
|
| 11 |
+
{
|
| 12 |
+
"id": "sig1",
|
| 13 |
+
"x1": 118,
|
| 14 |
+
"y1": 493,
|
| 15 |
+
"x2": 396,
|
| 16 |
+
"y2": 508,
|
| 17 |
+
"role": "Bank Officer",
|
| 18 |
+
"name": "Bank Officer"
|
| 19 |
+
},
|
| 20 |
+
{
|
| 21 |
+
"id": "sig2",
|
| 22 |
+
"x1": 118,
|
| 23 |
+
"y1": 700,
|
| 24 |
+
"x2": 404,
|
| 25 |
+
"y2": 715,
|
| 26 |
+
"role": "Notary Public",
|
| 27 |
+
"name": "Notary Public"
|
| 28 |
+
}
|
| 29 |
+
]
|
| 30 |
+
}
|
| 31 |
+
]
|
| 32 |
+
}
|
annotations/bank_doc_02.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"id": "bank_doc_02",
|
| 3 |
+
"source_image": "image/guarantee/image copy.png",
|
| 4 |
+
"document_type": "bank_letter_of_guarantee",
|
| 5 |
+
"pages": [
|
| 6 |
+
{
|
| 7 |
+
"page": 1,
|
| 8 |
+
"page_size": [1024, 559],
|
| 9 |
+
"dpi": 300,
|
| 10 |
+
"signature_boxes": [
|
| 11 |
+
{
|
| 12 |
+
"id": "sig1",
|
| 13 |
+
"x1": 150,
|
| 14 |
+
"y1": 434,
|
| 15 |
+
"x2": 420,
|
| 16 |
+
"y2": 458,
|
| 17 |
+
"role": "Bank Representative",
|
| 18 |
+
"name": "Michael Chen"
|
| 19 |
+
}
|
| 20 |
+
]
|
| 21 |
+
}
|
| 22 |
+
]
|
| 23 |
+
}
|
annotations/bank_doc_03.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"id": "bank_doc_03",
|
| 3 |
+
"source_image": "image/guarantee/image.png",
|
| 4 |
+
"document_type": "deed_of_guarantee",
|
| 5 |
+
"pages": [
|
| 6 |
+
{
|
| 7 |
+
"page": 1,
|
| 8 |
+
"page_size": [1024, 559],
|
| 9 |
+
"dpi": 300,
|
| 10 |
+
"signature_boxes": [
|
| 11 |
+
{
|
| 12 |
+
"id": "sig1",
|
| 13 |
+
"x1": 595,
|
| 14 |
+
"y1": 399,
|
| 15 |
+
"x2": 748,
|
| 16 |
+
"y2": 431,
|
| 17 |
+
"role": "Guarantor (Authorized Signatory)",
|
| 18 |
+
"name": "Guarantor"
|
| 19 |
+
},
|
| 20 |
+
{
|
| 21 |
+
"id": "sig2",
|
| 22 |
+
"x1": 805,
|
| 23 |
+
"y1": 399,
|
| 24 |
+
"x2": 955,
|
| 25 |
+
"y2": 431,
|
| 26 |
+
"role": "Bank Representative",
|
| 27 |
+
"name": "Lender"
|
| 28 |
+
}
|
| 29 |
+
]
|
| 30 |
+
}
|
| 31 |
+
]
|
| 32 |
+
}
|
annotations/bank_doc_04.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"id": "bank_doc_04",
|
| 3 |
+
"source_image": "image/loan_forms/image copy 2.png",
|
| 4 |
+
"document_type": "corporate_bank_loan_agreement",
|
| 5 |
+
"pages": [
|
| 6 |
+
{
|
| 7 |
+
"page": 1,
|
| 8 |
+
"page_size": [1024, 559],
|
| 9 |
+
"dpi": 300,
|
| 10 |
+
"signature_boxes": [
|
| 11 |
+
{
|
| 12 |
+
"id": "sig1",
|
| 13 |
+
"x1": 580,
|
| 14 |
+
"y1": 435,
|
| 15 |
+
"x2": 750,
|
| 16 |
+
"y2": 475,
|
| 17 |
+
"role": "Borrower (Authorized Signatory)",
|
| 18 |
+
"name": "Borrower"
|
| 19 |
+
},
|
| 20 |
+
{
|
| 21 |
+
"id": "sig2",
|
| 22 |
+
"x1": 795,
|
| 23 |
+
"y1": 435,
|
| 24 |
+
"x2": 960,
|
| 25 |
+
"y2": 475,
|
| 26 |
+
"role": "Bank Representative",
|
| 27 |
+
"name": "Lender"
|
| 28 |
+
}
|
| 29 |
+
]
|
| 30 |
+
}
|
| 31 |
+
]
|
| 32 |
+
}
|
annotations/bank_doc_05.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"id": "bank_doc_05",
|
| 3 |
+
"source_image": "image/loan_forms/image copy.png",
|
| 4 |
+
"document_type": "corporate_bank_loan_agreement",
|
| 5 |
+
"pages": [
|
| 6 |
+
{
|
| 7 |
+
"page": 1,
|
| 8 |
+
"page_size": [687, 1024],
|
| 9 |
+
"dpi": 300,
|
| 10 |
+
"signature_boxes": [
|
| 11 |
+
{
|
| 12 |
+
"id": "sig1",
|
| 13 |
+
"x1": 65,
|
| 14 |
+
"y1": 780,
|
| 15 |
+
"x2": 320,
|
| 16 |
+
"y2": 815,
|
| 17 |
+
"role": "Borrower",
|
| 18 |
+
"name": "Borrower"
|
| 19 |
+
},
|
| 20 |
+
{
|
| 21 |
+
"id": "sig2",
|
| 22 |
+
"x1": 370,
|
| 23 |
+
"y1": 780,
|
| 24 |
+
"x2": 625,
|
| 25 |
+
"y2": 815,
|
| 26 |
+
"role": "Authorized Signatory",
|
| 27 |
+
"name": "Lender"
|
| 28 |
+
}
|
| 29 |
+
]
|
| 30 |
+
}
|
| 31 |
+
]
|
| 32 |
+
}
|
annotations/bank_doc_06.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"id": "bank_doc_06",
|
| 3 |
+
"source_image": "image/loan_forms/image.png",
|
| 4 |
+
"document_type": "corporate_bank_loan_agreement",
|
| 5 |
+
"pages": [
|
| 6 |
+
{
|
| 7 |
+
"page": 1,
|
| 8 |
+
"page_size": [1024, 559],
|
| 9 |
+
"dpi": 300,
|
| 10 |
+
"signature_boxes": [
|
| 11 |
+
{
|
| 12 |
+
"id": "sig1",
|
| 13 |
+
"x1": 90,
|
| 14 |
+
"y1": 421,
|
| 15 |
+
"x2": 345,
|
| 16 |
+
"y2": 433,
|
| 17 |
+
"role": "Borrower (Authorized Signatory)",
|
| 18 |
+
"name": "Borrower"
|
| 19 |
+
},
|
| 20 |
+
{
|
| 21 |
+
"id": "sig2",
|
| 22 |
+
"x1": 590,
|
| 23 |
+
"y1": 421,
|
| 24 |
+
"x2": 845,
|
| 25 |
+
"y2": 433,
|
| 26 |
+
"role": "Bank Representative",
|
| 27 |
+
"name": "Lender"
|
| 28 |
+
}
|
| 29 |
+
]
|
| 30 |
+
}
|
| 31 |
+
]
|
| 32 |
+
}
|
annotations/bank_doc_07.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"id": "bank_doc_07",
|
| 3 |
+
"source_image": "image/mandate/image copy 2.png",
|
| 4 |
+
"document_type": "bank_mandate_attestation",
|
| 5 |
+
"pages": [
|
| 6 |
+
{
|
| 7 |
+
"page": 1,
|
| 8 |
+
"page_size": [1024, 1536],
|
| 9 |
+
"dpi": 300,
|
| 10 |
+
"signature_boxes": [
|
| 11 |
+
{
|
| 12 |
+
"id": "sig1",
|
| 13 |
+
"x1": 535,
|
| 14 |
+
"y1": 918,
|
| 15 |
+
"x2": 935,
|
| 16 |
+
"y2": 935,
|
| 17 |
+
"role": "Account Holder",
|
| 18 |
+
"name": "Account Holder"
|
| 19 |
+
},
|
| 20 |
+
{
|
| 21 |
+
"id": "sig2",
|
| 22 |
+
"x1": 345,
|
| 23 |
+
"y1": 973,
|
| 24 |
+
"x2": 700,
|
| 25 |
+
"y2": 990,
|
| 26 |
+
"role": "Bank Manager",
|
| 27 |
+
"name": "Bank Manager"
|
| 28 |
+
}
|
| 29 |
+
]
|
| 30 |
+
}
|
| 31 |
+
]
|
| 32 |
+
}
|
annotations/bank_doc_08.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"id": "bank_doc_08",
|
| 3 |
+
"source_image": "image/mandate/image copy.png",
|
| 4 |
+
"document_type": "bank_account_verification_mandate",
|
| 5 |
+
"pages": [
|
| 6 |
+
{
|
| 7 |
+
"page": 1,
|
| 8 |
+
"page_size": [1024, 1536],
|
| 9 |
+
"dpi": 300,
|
| 10 |
+
"signature_boxes": [
|
| 11 |
+
{
|
| 12 |
+
"id": "sig1",
|
| 13 |
+
"x1": 345,
|
| 14 |
+
"y1": 672,
|
| 15 |
+
"x2": 495,
|
| 16 |
+
"y2": 696,
|
| 17 |
+
"role": "Account Holder",
|
| 18 |
+
"name": "Account Holder"
|
| 19 |
+
},
|
| 20 |
+
{
|
| 21 |
+
"id": "sig2",
|
| 22 |
+
"x1": 685,
|
| 23 |
+
"y1": 568,
|
| 24 |
+
"x2": 935,
|
| 25 |
+
"y2": 590,
|
| 26 |
+
"role": "Bank Manager",
|
| 27 |
+
"name": "Bank Manager"
|
| 28 |
+
}
|
| 29 |
+
]
|
| 30 |
+
}
|
| 31 |
+
]
|
| 32 |
+
}
|
annotations/bank_doc_09.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"id": "bank_doc_09",
|
| 3 |
+
"source_image": "image/mandate/image.png",
|
| 4 |
+
"document_type": "corporate_account_opening_mandate",
|
| 5 |
+
"pages": [
|
| 6 |
+
{
|
| 7 |
+
"page": 1,
|
| 8 |
+
"page_size": [687, 1024],
|
| 9 |
+
"dpi": 300,
|
| 10 |
+
"signature_boxes": [
|
| 11 |
+
{
|
| 12 |
+
"id": "sig1",
|
| 13 |
+
"x1": 90,
|
| 14 |
+
"y1": 770,
|
| 15 |
+
"x2": 315,
|
| 16 |
+
"y2": 805,
|
| 17 |
+
"role": "First Authorised Signatory",
|
| 18 |
+
"name": "First Authorised Signatory"
|
| 19 |
+
},
|
| 20 |
+
{
|
| 21 |
+
"id": "sig2",
|
| 22 |
+
"x1": 350,
|
| 23 |
+
"y1": 770,
|
| 24 |
+
"x2": 575,
|
| 25 |
+
"y2": 805,
|
| 26 |
+
"role": "Second Authorised Signatory",
|
| 27 |
+
"name": "Second Authorised Signatory"
|
| 28 |
+
}
|
| 29 |
+
]
|
| 30 |
+
}
|
| 31 |
+
]
|
| 32 |
+
}
|
configs/default.yaml
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Base configuration for the synthetic signed bank document generator.
|
| 2 |
+
#
|
| 3 |
+
# This file defines every default value used by src.config.schema.PipelineConfig.
|
| 4 |
+
# User configs (see configs/generate.yaml, configs/examples/*.yaml) are merged
|
| 5 |
+
# on top of this file, so they only need to specify what they want to change.
|
| 6 |
+
#
|
| 7 |
+
# Any numeric placement/augmentation "strength"-like value may be given either
|
| 8 |
+
# as a fixed scalar (e.g. `0.4`) or as a range (e.g. `{min: 0.2, max: 0.6}`),
|
| 9 |
+
# which is resolved by a seeded RNG independently for every generated sample.
|
| 10 |
+
|
| 11 |
+
seed: 1234
|
| 12 |
+
|
| 13 |
+
paths:
|
| 14 |
+
image_dir: image
|
| 15 |
+
dataset_dir: dataset
|
| 16 |
+
annotations_dir: annotations
|
| 17 |
+
output_dir: output
|
| 18 |
+
cache_dir: output/.cache
|
| 19 |
+
|
| 20 |
+
generation:
|
| 21 |
+
count: 10
|
| 22 |
+
workers: 1
|
| 23 |
+
save_visualization: false
|
| 24 |
+
overwrite: false
|
| 25 |
+
run_validation: true
|
| 26 |
+
start_index: 1
|
| 27 |
+
filename_prefix: sample
|
| 28 |
+
filename_digits: 6
|
| 29 |
+
|
| 30 |
+
document:
|
| 31 |
+
template: null # null = pick uniformly at random (deterministically, via seed)
|
| 32 |
+
page: null # null = use the template's first page
|
| 33 |
+
|
| 34 |
+
signer:
|
| 35 |
+
name: null # null = generate a deterministic synthetic name
|
| 36 |
+
role: null # null = use the signature box's annotated role
|
| 37 |
+
|
| 38 |
+
signature_selection:
|
| 39 |
+
source_filter: genuine # genuine | forged | any
|
| 40 |
+
signature_id: null # null = pick uniformly at random from the filtered pool
|
| 41 |
+
|
| 42 |
+
placement:
|
| 43 |
+
x_offset: 0.0
|
| 44 |
+
y_offset: 0.0
|
| 45 |
+
rotation_deg:
|
| 46 |
+
min: -4.0
|
| 47 |
+
max: 4.0
|
| 48 |
+
scale_factor:
|
| 49 |
+
min: 0.78
|
| 50 |
+
max: 0.95
|
| 51 |
+
anchor_point: center
|
| 52 |
+
margin: 0.08
|
| 53 |
+
misplacement_probability: 0.12
|
| 54 |
+
misplacement_strength: 0.35
|
| 55 |
+
allow_partial_outside: false
|
| 56 |
+
|
| 57 |
+
rendering:
|
| 58 |
+
blend_mode: multiply
|
| 59 |
+
ink_opacity:
|
| 60 |
+
min: 0.75
|
| 61 |
+
max: 0.95
|
| 62 |
+
blur_sigma:
|
| 63 |
+
min: 0.15
|
| 64 |
+
max: 0.55
|
| 65 |
+
feather_px:
|
| 66 |
+
min: 0.5
|
| 67 |
+
max: 1.8
|
| 68 |
+
scanner_noise: 0.02
|
| 69 |
+
anti_alias: true
|
| 70 |
+
|
| 71 |
+
augmentation:
|
| 72 |
+
rotation:
|
| 73 |
+
enabled: false
|
| 74 |
+
strength: 0.0
|
| 75 |
+
probability: 0.0
|
| 76 |
+
seed: null
|
| 77 |
+
|
| 78 |
+
stroke_dilation:
|
| 79 |
+
enabled: false
|
| 80 |
+
strength: 0.2
|
| 81 |
+
probability: 0.25
|
| 82 |
+
seed: null
|
| 83 |
+
|
| 84 |
+
motion_blur:
|
| 85 |
+
enabled: false
|
| 86 |
+
strength: 0.2
|
| 87 |
+
probability: 0.15
|
| 88 |
+
seed: null
|
| 89 |
+
|
| 90 |
+
gaussian_blur:
|
| 91 |
+
enabled: true
|
| 92 |
+
strength: 0.15
|
| 93 |
+
probability: 0.3
|
| 94 |
+
seed: null
|
| 95 |
+
|
| 96 |
+
perspective:
|
| 97 |
+
enabled: false
|
| 98 |
+
strength: 0.1
|
| 99 |
+
probability: 0.1
|
| 100 |
+
seed: null
|
| 101 |
+
|
| 102 |
+
scanner_noise:
|
| 103 |
+
enabled: true
|
| 104 |
+
strength: 0.06
|
| 105 |
+
probability: 1.0
|
| 106 |
+
seed: null
|
| 107 |
+
|
| 108 |
+
salt_pepper:
|
| 109 |
+
enabled: false
|
| 110 |
+
strength: 0.03
|
| 111 |
+
probability: 0.15
|
| 112 |
+
seed: null
|
| 113 |
+
|
| 114 |
+
jpeg_compression:
|
| 115 |
+
enabled: true
|
| 116 |
+
strength: 0.3
|
| 117 |
+
probability: 0.5
|
| 118 |
+
seed: null
|
| 119 |
+
min_quality: 35
|
| 120 |
+
max_quality: 90
|
| 121 |
+
|
| 122 |
+
contrast:
|
| 123 |
+
enabled: true
|
| 124 |
+
strength: 0.15
|
| 125 |
+
probability: 0.4
|
| 126 |
+
seed: null
|
| 127 |
+
|
| 128 |
+
gamma:
|
| 129 |
+
enabled: false
|
| 130 |
+
strength: 0.15
|
| 131 |
+
probability: 0.2
|
| 132 |
+
seed: null
|
| 133 |
+
|
| 134 |
+
coffee_stain:
|
| 135 |
+
enabled: false
|
| 136 |
+
strength: 0.4
|
| 137 |
+
probability: 0.1
|
| 138 |
+
seed: null
|
| 139 |
+
|
| 140 |
+
stain:
|
| 141 |
+
enabled: false
|
| 142 |
+
strength: 0.4
|
| 143 |
+
probability: 0.1
|
| 144 |
+
seed: null
|
| 145 |
+
type: random
|
| 146 |
+
|
| 147 |
+
shadow:
|
| 148 |
+
enabled: true
|
| 149 |
+
strength: 0.35
|
| 150 |
+
probability: 0.4
|
| 151 |
+
seed: null
|
| 152 |
+
type: random
|
| 153 |
+
|
| 154 |
+
crumple:
|
| 155 |
+
enabled: false
|
| 156 |
+
strength: 0.2
|
| 157 |
+
probability: 0.15
|
| 158 |
+
seed: null
|
| 159 |
+
wrinkle_density: 0.4
|
| 160 |
+
|
| 161 |
+
discoloration:
|
| 162 |
+
enabled: true
|
| 163 |
+
strength: 0.2
|
| 164 |
+
probability: 0.5
|
| 165 |
+
seed: null
|
| 166 |
+
tone: random
|
| 167 |
+
|
| 168 |
+
page_tilt:
|
| 169 |
+
enabled: true
|
| 170 |
+
strength: 0.3
|
| 171 |
+
probability: 0.3
|
| 172 |
+
seed: null
|
| 173 |
+
|
| 174 |
+
dust:
|
| 175 |
+
enabled: false
|
| 176 |
+
strength: 0.2
|
| 177 |
+
probability: 0.2
|
| 178 |
+
seed: null
|
| 179 |
+
|
| 180 |
+
scanner_streaks:
|
| 181 |
+
enabled: false
|
| 182 |
+
strength: 0.2
|
| 183 |
+
probability: 0.1
|
| 184 |
+
seed: null
|
| 185 |
+
|
| 186 |
+
ink_fading:
|
| 187 |
+
enabled: false
|
| 188 |
+
strength: 0.3
|
| 189 |
+
probability: 0.2
|
| 190 |
+
seed: null
|
| 191 |
+
|
| 192 |
+
ink_bleed:
|
| 193 |
+
enabled: false
|
| 194 |
+
strength: 0.25
|
| 195 |
+
probability: 0.15
|
| 196 |
+
seed: null
|
| 197 |
+
|
| 198 |
+
quality:
|
| 199 |
+
case_type: null
|
| 200 |
+
notes: null
|
| 201 |
+
scanner_quality: 0.85
|
| 202 |
+
paper_quality: 0.85
|
| 203 |
+
render_quality: 0.85
|
| 204 |
+
|
| 205 |
+
difficulty:
|
| 206 |
+
misplacement: 0.35
|
| 207 |
+
whitespace: 0.20
|
| 208 |
+
faintness: 0.20
|
| 209 |
+
smallness: 0.15
|
| 210 |
+
low_texture: 0.10
|
| 211 |
+
easy_threshold: 0.33
|
| 212 |
+
hard_threshold: 0.66
|
| 213 |
+
|
| 214 |
+
validation:
|
| 215 |
+
strict: false
|
| 216 |
+
max_coordinate_epsilon: 0.5
|
configs/examples/clean_easy.yaml
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# A "mostly easy" dataset preset: well-centered signatures, minimal
|
| 2 |
+
# degradation. Useful for a sanity-check baseline or an "easy" evaluation
|
| 3 |
+
# split.
|
| 4 |
+
#
|
| 5 |
+
# python main.py --config configs/examples/clean_easy.yaml --count 500
|
| 6 |
+
|
| 7 |
+
seed: 1000
|
| 8 |
+
|
| 9 |
+
generation:
|
| 10 |
+
count: 500
|
| 11 |
+
workers: 4
|
| 12 |
+
|
| 13 |
+
placement:
|
| 14 |
+
scale_factor: { min: 0.85, max: 0.98 }
|
| 15 |
+
rotation_deg: { min: -1.5, max: 1.5 }
|
| 16 |
+
margin: 0.1
|
| 17 |
+
misplacement_probability: 0.0
|
| 18 |
+
allow_partial_outside: false
|
| 19 |
+
|
| 20 |
+
rendering:
|
| 21 |
+
ink_opacity: { min: 0.85, max: 0.98 }
|
| 22 |
+
blur_sigma: { min: 0.1, max: 0.25 }
|
| 23 |
+
scanner_noise: 0.01
|
| 24 |
+
|
| 25 |
+
augmentation:
|
| 26 |
+
gaussian_blur: { enabled: false }
|
| 27 |
+
shadow: { enabled: false }
|
| 28 |
+
discoloration: { enabled: false }
|
| 29 |
+
page_tilt: { enabled: true, strength: 0.1, probability: 0.15 }
|
| 30 |
+
scanner_noise: { enabled: true, strength: 0.02, probability: 1.0 }
|
| 31 |
+
jpeg_compression: { enabled: true, strength: 0.15, probability: 0.3, min_quality: 80, max_quality: 95 }
|
configs/examples/hard_degraded.yaml
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# A "mostly hard" dataset preset: frequent misplacement, faint ink, heavy
|
| 2 |
+
# scanner/paper degradation. Useful for stress-testing a downstream
|
| 3 |
+
# detector/verifier or building a "hard" evaluation split.
|
| 4 |
+
#
|
| 5 |
+
# python main.py --config configs/examples/hard_degraded.yaml --count 500
|
| 6 |
+
|
| 7 |
+
seed: 9001
|
| 8 |
+
|
| 9 |
+
generation:
|
| 10 |
+
count: 500
|
| 11 |
+
workers: 4
|
| 12 |
+
|
| 13 |
+
quality:
|
| 14 |
+
case_type: "1d"
|
| 15 |
+
notes: "low-quality scan, batch 9001"
|
| 16 |
+
|
| 17 |
+
placement:
|
| 18 |
+
scale_factor: { min: 0.35, max: 0.7 }
|
| 19 |
+
rotation_deg: { min: -12.0, max: 12.0 }
|
| 20 |
+
margin: 0.04
|
| 21 |
+
misplacement_probability: 0.45
|
| 22 |
+
misplacement_strength: { min: 0.3, max: 0.8 }
|
| 23 |
+
allow_partial_outside: true
|
| 24 |
+
|
| 25 |
+
rendering:
|
| 26 |
+
ink_opacity: { min: 0.35, max: 0.65 }
|
| 27 |
+
blur_sigma: { min: 0.6, max: 1.4 }
|
| 28 |
+
feather_px: { min: 1.5, max: 3.0 }
|
| 29 |
+
scanner_noise: 0.05
|
| 30 |
+
|
| 31 |
+
augmentation:
|
| 32 |
+
ink_fading: { enabled: true, strength: { min: 0.4, max: 0.8 }, probability: 0.5 }
|
| 33 |
+
motion_blur: { enabled: true, strength: { min: 0.3, max: 0.6 }, probability: 0.3 }
|
| 34 |
+
perspective: { enabled: true, strength: { min: 0.15, max: 0.3 }, probability: 0.3 }
|
| 35 |
+
coffee_stain: { enabled: true, strength: { min: 0.4, max: 0.8 }, probability: 0.25 }
|
| 36 |
+
stain: { enabled: true, strength: { min: 0.4, max: 0.8 }, probability: 0.3, type: random }
|
| 37 |
+
shadow: { enabled: true, strength: { min: 0.5, max: 0.9 }, probability: 0.6, type: random }
|
| 38 |
+
crumple: { enabled: true, strength: { min: 0.4, max: 0.8 }, probability: 0.4 }
|
| 39 |
+
discoloration: { enabled: true, strength: { min: 0.4, max: 0.7 }, probability: 0.7, tone: random }
|
| 40 |
+
page_tilt: { enabled: true, strength: { min: 0.5, max: 1.0 }, probability: 0.5 }
|
| 41 |
+
dust: { enabled: true, strength: { min: 0.3, max: 0.7 }, probability: 0.4 }
|
| 42 |
+
scanner_streaks: { enabled: true, strength: { min: 0.3, max: 0.6 }, probability: 0.3 }
|
| 43 |
+
salt_pepper: { enabled: true, strength: { min: 0.03, max: 0.09 }, probability: 0.35 }
|
| 44 |
+
jpeg_compression: { enabled: true, strength: 0.7, probability: 0.7, min_quality: 20, max_quality: 55 }
|
| 45 |
+
|
| 46 |
+
validation:
|
| 47 |
+
strict: false
|
configs/examples/single_document_demo.yaml
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Demonstrates every "single value or range" knob on one fixed template with
|
| 2 |
+
# a fixed signer identity -- handy for iterating on how one specific
|
| 3 |
+
# document/box combination looks before scaling up to a full batch run.
|
| 4 |
+
#
|
| 5 |
+
# python main.py --config configs/examples/single_document_demo.yaml --count 1
|
| 6 |
+
|
| 7 |
+
seed: 4242
|
| 8 |
+
|
| 9 |
+
document:
|
| 10 |
+
template: bank_doc_03
|
| 11 |
+
|
| 12 |
+
signer:
|
| 13 |
+
name: Jane Doe
|
| 14 |
+
role: Branch Manager
|
| 15 |
+
|
| 16 |
+
signature_selection:
|
| 17 |
+
source_filter: genuine
|
| 18 |
+
|
| 19 |
+
placement:
|
| 20 |
+
scale_factor: 0.84
|
| 21 |
+
rotation_deg: -3.0
|
| 22 |
+
x_offset: 8.0
|
| 23 |
+
y_offset: -4.0
|
| 24 |
+
misplacement_probability: 0.32
|
| 25 |
+
|
| 26 |
+
generation:
|
| 27 |
+
count: 1
|
| 28 |
+
save_visualization: true
|
| 29 |
+
|
| 30 |
+
augmentation:
|
| 31 |
+
coffee_stain:
|
| 32 |
+
enabled: true
|
| 33 |
+
strength: 0.42
|
| 34 |
+
probability: 1.0
|
| 35 |
+
|
| 36 |
+
shadow:
|
| 37 |
+
enabled: true
|
| 38 |
+
strength: 0.55
|
| 39 |
+
probability: 1.0
|
| 40 |
+
type: corner
|
| 41 |
+
|
| 42 |
+
crumple:
|
| 43 |
+
enabled: true
|
| 44 |
+
strength: 0.18
|
| 45 |
+
probability: 1.0
|
| 46 |
+
|
| 47 |
+
scanner_noise:
|
| 48 |
+
enabled: true
|
| 49 |
+
strength: 0.06
|
| 50 |
+
probability: 1.0
|
configs/examples/verification_cases.yaml
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Signature-verification test-case generation. Set quality.case_type to one
|
| 2 |
+
# of the 12 codes below to switch GeneratorContext into scenario-driven
|
| 3 |
+
# generation: it renders the described scenario and stamps the sample's
|
| 4 |
+
# metadata `verification` block with the matching ground-truth verdict/
|
| 5 |
+
# reject_code. Left unset (the default), generation is completely unaffected.
|
| 6 |
+
#
|
| 7 |
+
# python main.py --config configs/examples/verification_cases.yaml \
|
| 8 |
+
# --set quality.case_type=3a --count 10
|
| 9 |
+
#
|
| 10 |
+
# For "1d" (correct signer, but the scan itself is degraded) use
|
| 11 |
+
# configs/examples/hard_degraded.yaml instead -- it already sets
|
| 12 |
+
# `quality.case_type: "1d"` plus the matching degraded-scan overrides; this
|
| 13 |
+
# file's job is everything else, where a clean/legible scan is expected.
|
| 14 |
+
#
|
| 15 |
+
# | code | scenario | verdict | reject_code |
|
| 16 |
+
# |------|------------------------------------------------------|---------|-------------------------|
|
| 17 |
+
# | 1a | Correct signer signs normally | PASS | - |
|
| 18 |
+
# | 1b | Correct signer, natural pen variation | PASS | - |
|
| 19 |
+
# | 1c | Multi-signer form, both sign correctly | PASS | - |
|
| 20 |
+
# | 1d | Correct signer, scan degraded (see hard_degraded.yaml)| PASS | - |
|
| 21 |
+
# | 2a | Box left completely blank | REJECT | NO_SIGNATURE |
|
| 22 |
+
# | 2b | Printed/typed name only, no handwriting | REJECT | NO_SIGNATURE |
|
| 23 |
+
# | 3a | Wrong real person signs the box | REJECT | IDENTITY_MISMATCH |
|
| 24 |
+
# | 3b | Skilled-forgery sample used | REJECT | IDENTITY_MISMATCH |
|
| 25 |
+
# | 4a | Authorized signer, but wrong role's box | REJECT | ROLE_MISMATCH |
|
| 26 |
+
# | 4b | Signer from a different form entirely | REJECT | ROLE_MISMATCH |
|
| 27 |
+
# | 5a | Required signer's box left empty | REJECT | REQUIRED_SIGNER_ABSENT |
|
| 28 |
+
# | 5b | Someone else signs in the required signer's place | REJECT | REQUIRED_SIGNER_ABSENT |
|
| 29 |
+
|
| 30 |
+
quality:
|
| 31 |
+
case_type: "1a" # <- override with --set quality.case_type=<code>
|
| 32 |
+
|
| 33 |
+
validation:
|
| 34 |
+
strict: false
|
configs/generate.yaml
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Example large-batch dataset generation config.
|
| 2 |
+
#
|
| 3 |
+
# Merged on top of configs/default.yaml. Only values that differ from the
|
| 4 |
+
# defaults need to be listed here. Run with:
|
| 5 |
+
#
|
| 6 |
+
# python main.py --config configs/generate.yaml --count 10000
|
| 7 |
+
#
|
| 8 |
+
# (--count on the command line overrides generation.count below.)
|
| 9 |
+
|
| 10 |
+
seed: 20240601
|
| 11 |
+
|
| 12 |
+
paths:
|
| 13 |
+
output_dir: output
|
| 14 |
+
|
| 15 |
+
generation:
|
| 16 |
+
count: 2000
|
| 17 |
+
workers: 4
|
| 18 |
+
save_visualization: false
|
| 19 |
+
overwrite: false
|
| 20 |
+
run_validation: true
|
| 21 |
+
|
| 22 |
+
# document.template left as null -> every sample independently and
|
| 23 |
+
# deterministically picks one of the 9 discovered templates.
|
| 24 |
+
document:
|
| 25 |
+
template: null
|
| 26 |
+
|
| 27 |
+
# signer.name/role left as null -> a synthetic name is generated per sample
|
| 28 |
+
# and the role is taken from the chosen signature box's annotation.
|
| 29 |
+
signer:
|
| 30 |
+
name: null
|
| 31 |
+
role: null
|
| 32 |
+
|
| 33 |
+
signature_selection:
|
| 34 |
+
source_filter: genuine
|
| 35 |
+
|
| 36 |
+
placement:
|
| 37 |
+
scale_factor:
|
| 38 |
+
min: 0.75
|
| 39 |
+
max: 0.98
|
| 40 |
+
rotation_deg:
|
| 41 |
+
min: -6.0
|
| 42 |
+
max: 6.0
|
| 43 |
+
margin: 0.07
|
| 44 |
+
misplacement_probability: 0.15
|
| 45 |
+
misplacement_strength:
|
| 46 |
+
min: 0.15
|
| 47 |
+
max: 0.5
|
| 48 |
+
allow_partial_outside: true
|
| 49 |
+
|
| 50 |
+
rendering:
|
| 51 |
+
blend_mode: multiply
|
| 52 |
+
ink_opacity:
|
| 53 |
+
min: 0.7
|
| 54 |
+
max: 0.97
|
| 55 |
+
blur_sigma:
|
| 56 |
+
min: 0.15
|
| 57 |
+
max: 0.6
|
| 58 |
+
feather_px:
|
| 59 |
+
min: 0.4
|
| 60 |
+
max: 2.0
|
| 61 |
+
scanner_noise: 0.02
|
| 62 |
+
|
| 63 |
+
augmentation:
|
| 64 |
+
stroke_dilation:
|
| 65 |
+
enabled: true
|
| 66 |
+
strength: { min: 0.1, max: 0.35 }
|
| 67 |
+
probability: 0.2
|
| 68 |
+
|
| 69 |
+
gaussian_blur:
|
| 70 |
+
enabled: true
|
| 71 |
+
strength: { min: 0.05, max: 0.3 }
|
| 72 |
+
probability: 0.3
|
| 73 |
+
|
| 74 |
+
motion_blur:
|
| 75 |
+
enabled: true
|
| 76 |
+
strength: { min: 0.1, max: 0.35 }
|
| 77 |
+
probability: 0.12
|
| 78 |
+
|
| 79 |
+
ink_fading:
|
| 80 |
+
enabled: true
|
| 81 |
+
strength: { min: 0.1, max: 0.5 }
|
| 82 |
+
probability: 0.2
|
| 83 |
+
|
| 84 |
+
ink_bleed:
|
| 85 |
+
enabled: true
|
| 86 |
+
strength: { min: 0.1, max: 0.4 }
|
| 87 |
+
probability: 0.15
|
| 88 |
+
|
| 89 |
+
perspective:
|
| 90 |
+
enabled: true
|
| 91 |
+
strength: { min: 0.05, max: 0.2 }
|
| 92 |
+
probability: 0.12
|
| 93 |
+
|
| 94 |
+
contrast:
|
| 95 |
+
enabled: true
|
| 96 |
+
strength: { min: 0.05, max: 0.3 }
|
| 97 |
+
probability: 0.4
|
| 98 |
+
|
| 99 |
+
gamma:
|
| 100 |
+
enabled: true
|
| 101 |
+
strength: { min: 0.05, max: 0.25 }
|
| 102 |
+
probability: 0.2
|
| 103 |
+
|
| 104 |
+
coffee_stain:
|
| 105 |
+
enabled: true
|
| 106 |
+
strength: { min: 0.2, max: 0.6 }
|
| 107 |
+
probability: 0.08
|
| 108 |
+
|
| 109 |
+
stain:
|
| 110 |
+
enabled: true
|
| 111 |
+
strength: { min: 0.2, max: 0.6 }
|
| 112 |
+
probability: 0.1
|
| 113 |
+
type: random
|
| 114 |
+
|
| 115 |
+
shadow:
|
| 116 |
+
enabled: true
|
| 117 |
+
strength: { min: 0.2, max: 0.6 }
|
| 118 |
+
probability: 0.35
|
| 119 |
+
type: random
|
| 120 |
+
|
| 121 |
+
crumple:
|
| 122 |
+
enabled: true
|
| 123 |
+
strength: { min: 0.1, max: 0.4 }
|
| 124 |
+
probability: 0.12
|
| 125 |
+
wrinkle_density: { min: 0.2, max: 0.7 }
|
| 126 |
+
|
| 127 |
+
discoloration:
|
| 128 |
+
enabled: true
|
| 129 |
+
strength: { min: 0.1, max: 0.4 }
|
| 130 |
+
probability: 0.45
|
| 131 |
+
tone: random
|
| 132 |
+
|
| 133 |
+
page_tilt:
|
| 134 |
+
enabled: true
|
| 135 |
+
strength: { min: 0.1, max: 0.5 }
|
| 136 |
+
probability: 0.3
|
| 137 |
+
|
| 138 |
+
dust:
|
| 139 |
+
enabled: true
|
| 140 |
+
strength: { min: 0.1, max: 0.4 }
|
| 141 |
+
probability: 0.2
|
| 142 |
+
|
| 143 |
+
scanner_streaks:
|
| 144 |
+
enabled: true
|
| 145 |
+
strength: { min: 0.1, max: 0.35 }
|
| 146 |
+
probability: 0.1
|
| 147 |
+
|
| 148 |
+
scanner_noise:
|
| 149 |
+
enabled: true
|
| 150 |
+
strength: { min: 0.02, max: 0.1 }
|
| 151 |
+
probability: 1.0
|
| 152 |
+
|
| 153 |
+
salt_pepper:
|
| 154 |
+
enabled: true
|
| 155 |
+
strength: { min: 0.01, max: 0.06 }
|
| 156 |
+
probability: 0.15
|
| 157 |
+
|
| 158 |
+
jpeg_compression:
|
| 159 |
+
enabled: true
|
| 160 |
+
strength: { min: 0.2, max: 0.6 }
|
| 161 |
+
probability: 0.5
|
| 162 |
+
min_quality: 40
|
| 163 |
+
max_quality: 92
|
| 164 |
+
|
| 165 |
+
quality:
|
| 166 |
+
scanner_quality: { min: 0.6, max: 0.98 }
|
| 167 |
+
paper_quality: { min: 0.6, max: 0.98 }
|
| 168 |
+
render_quality: { min: 0.6, max: 0.98 }
|
| 169 |
+
|
| 170 |
+
validation:
|
| 171 |
+
strict: false
|
dataset/metadata.csv
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
path,signer,source_type,script
|
| 2 |
+
hindi_bengali/priya_ref01.png,Priya Sharma,genuine,hindi
|
| 3 |
+
hindi_bengali/raj_ref01.png,Raj Malhotra,genuine,hindi
|
| 4 |
+
hindi_bengali/raj_ref03.png,Raj Malhotra,genuine,hindi
|
| 5 |
+
hindi_bengali/vikram_ref01.png,Vikram Singh,genuine,hindi
|
| 6 |
+
hindi_bengali/vikram_ref02.png,Vikram Singh,genuine,hindi
|
| 7 |
+
hindi_bengali/vikram_ref03.png,Vikram Singh,genuine,hindi
|
| 8 |
+
hindi_bengali/suman_ref01.png,Suman Das,genuine,bengali
|
| 9 |
+
hindi_bengali/anika_ref01.png,Anika Roy,genuine,bengali
|
| 10 |
+
hindi_bengali/anika_ref02.png,Anika Roy,genuine,bengali
|
| 11 |
+
hindi_bengali/carol_ref01.png,Carol Wong,genuine,latin
|
| 12 |
+
hindi_bengali/henry_ref01.png,Henry Ooi,genuine,latin
|
main.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
"""CLI entry point for the synthetic signed bank document generation pipeline.
|
| 3 |
+
|
| 4 |
+
Examples:
|
| 5 |
+
Generate 10 samples with all defaults::
|
| 6 |
+
|
| 7 |
+
python main.py
|
| 8 |
+
|
| 9 |
+
Generate 10,000 samples using a custom config::
|
| 10 |
+
|
| 11 |
+
python main.py --config configs/generate.yaml --count 10000
|
| 12 |
+
|
| 13 |
+
Override arbitrary nested config values from the command line::
|
| 14 |
+
|
| 15 |
+
python main.py --set placement.rotation_deg.max=6 --set augmentation.coffee_stain.enabled=true
|
| 16 |
+
|
| 17 |
+
Generate a single, fully reproducible sample from one template with a debug figure::
|
| 18 |
+
|
| 19 |
+
python main.py --count 1 --seed 42 --template bank_doc_03 --save-visualization
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
import argparse
|
| 25 |
+
import json
|
| 26 |
+
import logging
|
| 27 |
+
import sys
|
| 28 |
+
from pathlib import Path
|
| 29 |
+
|
| 30 |
+
from src.config.loader import load_config, save_resolved_config
|
| 31 |
+
from src.generator import DatasetGenerator
|
| 32 |
+
from src.utils.logging_utils import configure_logging, get_logger
|
| 33 |
+
|
| 34 |
+
logger = get_logger(__name__)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def build_arg_parser() -> argparse.ArgumentParser:
|
| 38 |
+
parser = argparse.ArgumentParser(
|
| 39 |
+
description="Generate synthetic signed bank documents with rich ML-ready metadata.",
|
| 40 |
+
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
| 41 |
+
)
|
| 42 |
+
parser.add_argument("--config", type=str, default=None, help="Path to a user YAML config, merged over configs/default.yaml.")
|
| 43 |
+
parser.add_argument("--count", type=int, default=None, help="Number of samples to generate (overrides config).")
|
| 44 |
+
parser.add_argument("--seed", type=int, default=None, help="Master random seed (overrides config).")
|
| 45 |
+
parser.add_argument("--workers", type=int, default=None, help="Number of worker processes (overrides config).")
|
| 46 |
+
parser.add_argument("--output", type=str, default=None, help="Output directory (overrides config).")
|
| 47 |
+
parser.add_argument("--template", type=str, default=None, help="Restrict generation to a single template id, e.g. bank_doc_03.")
|
| 48 |
+
parser.add_argument("--start-index", type=int, default=None, help="First sample index (overrides config).")
|
| 49 |
+
parser.add_argument("--overwrite", action="store_true", default=None, help="Regenerate samples even if their output files already exist.")
|
| 50 |
+
parser.add_argument("--save-visualization", action="store_true", default=None, help="Also save a debug visualization PNG per sample.")
|
| 51 |
+
parser.add_argument(
|
| 52 |
+
"--set", dest="overrides", action="append", default=[],
|
| 53 |
+
metavar="key.path=value", help="Arbitrary dot-path config override; may be repeated.",
|
| 54 |
+
)
|
| 55 |
+
parser.add_argument("--log-level", type=str, default="INFO", choices=["DEBUG", "INFO", "WARNING", "ERROR"])
|
| 56 |
+
return parser
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _collect_overrides(args: argparse.Namespace) -> list[str]:
|
| 60 |
+
overrides = list(args.overrides)
|
| 61 |
+
if args.count is not None:
|
| 62 |
+
overrides.append(f"generation.count={args.count}")
|
| 63 |
+
if args.workers is not None:
|
| 64 |
+
overrides.append(f"generation.workers={args.workers}")
|
| 65 |
+
if args.output is not None:
|
| 66 |
+
overrides.append(f"paths.output_dir={args.output}")
|
| 67 |
+
if args.start_index is not None:
|
| 68 |
+
overrides.append(f"generation.start_index={args.start_index}")
|
| 69 |
+
if args.overwrite:
|
| 70 |
+
overrides.append("generation.overwrite=true")
|
| 71 |
+
if args.save_visualization:
|
| 72 |
+
overrides.append("generation.save_visualization=true")
|
| 73 |
+
if args.seed is not None:
|
| 74 |
+
overrides.append(f"seed={args.seed}")
|
| 75 |
+
if args.template is not None:
|
| 76 |
+
overrides.append(f"document.template={args.template}")
|
| 77 |
+
return overrides
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def main(argv: "list[str] | None" = None) -> int:
|
| 81 |
+
args = build_arg_parser().parse_args(argv)
|
| 82 |
+
configure_logging(level=getattr(logging, args.log_level))
|
| 83 |
+
|
| 84 |
+
overrides = _collect_overrides(args)
|
| 85 |
+
config = load_config(config_path=args.config, overrides=overrides)
|
| 86 |
+
|
| 87 |
+
logger.info("Starting generation: seed=%d, count=%d, workers=%d, output=%s",
|
| 88 |
+
config.seed, config.generation.count, config.generation.workers, config.paths.output_dir)
|
| 89 |
+
|
| 90 |
+
output_dir = Path(config.paths.output_dir)
|
| 91 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 92 |
+
save_resolved_config(config, output_dir / "resolved_config.yaml")
|
| 93 |
+
|
| 94 |
+
generator = DatasetGenerator(config)
|
| 95 |
+
summary = generator.generate()
|
| 96 |
+
|
| 97 |
+
summary_path = output_dir / "generation_summary.json"
|
| 98 |
+
summary_path.write_text(json.dumps(summary.as_dict(), indent=2), encoding="utf-8")
|
| 99 |
+
logger.info("Wrote generation summary to %s", summary_path)
|
| 100 |
+
logger.info(
|
| 101 |
+
"Done: %d/%d succeeded, %d failed, %d skipped (already existed). Difficulty tiers: %s",
|
| 102 |
+
summary.succeeded, summary.total, summary.failed, summary.skipped_existing, summary.tier_counts,
|
| 103 |
+
)
|
| 104 |
+
if summary.validation_failed:
|
| 105 |
+
logger.warning("%d sample(s) failed validation.", summary.validation_failed)
|
| 106 |
+
|
| 107 |
+
return 1 if summary.failed else 0
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
if __name__ == "__main__":
|
| 111 |
+
sys.exit(main())
|
pytest.ini
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[pytest]
|
| 2 |
+
pythonpath = .
|
| 3 |
+
testpaths = tests
|
| 4 |
+
python_files = test_*.py
|
requirements.txt
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
numpy>=1.26
|
| 2 |
+
opencv-python>=4.9
|
| 3 |
+
scipy>=1.12
|
| 4 |
+
Pillow>=10.2
|
| 5 |
+
scikit-image>=0.22
|
| 6 |
+
Shapely>=2.0
|
| 7 |
+
PyYAML>=6.0
|
| 8 |
+
pydantic>=2.6
|
| 9 |
+
albumentations>=1.4
|
| 10 |
+
omegaconf>=2.3
|
| 11 |
+
matplotlib>=3.8
|
| 12 |
+
tqdm>=4.66
|
| 13 |
+
pytest>=8.0
|
| 14 |
+
rich>=13.7
|
| 15 |
+
questionary>=2.0
|
src/config/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Configuration schema (Pydantic) and OmegaConf-based YAML loading."""
|
| 2 |
+
|
| 3 |
+
from src.config.schema import PipelineConfig
|
| 4 |
+
from src.config.loader import load_config
|
| 5 |
+
|
| 6 |
+
__all__ = ["PipelineConfig", "load_config"]
|
src/config/schema.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pydantic configuration schema for the synthetic document generation pipeline.
|
| 2 |
+
|
| 3 |
+
Every user-facing parameter is validated here. Numeric placement/augmentation
|
| 4 |
+
parameters accept either a fixed scalar or a ``{min, max}`` range (resolved
|
| 5 |
+
per-sample by a seeded RNG, see :mod:`src.utils.random_utils`).
|
| 6 |
+
|
| 7 |
+
The individual augmentation effects requested by the product spec are
|
| 8 |
+
consolidated into configurable *families* where an effect is a strict
|
| 9 |
+
specialization of another (paper stains + water stains -> ``stain`` with a
|
| 10 |
+
``type`` selector; edge/corner/fold shadows -> ``shadow`` with a ``type``
|
| 11 |
+
selector; crumpling + wrinkles -> ``crumple``; general discoloration + paper
|
| 12 |
+
yellowing -> ``discoloration`` with a ``tone`` selector). This keeps the
|
| 13 |
+
config surface coherent while still reaching every requested visual effect.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
from typing import Literal, Union
|
| 19 |
+
|
| 20 |
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
| 21 |
+
|
| 22 |
+
NumericOrRange_T = Union[float, "Range"]
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class Range(BaseModel):
|
| 26 |
+
"""An inclusive ``[min, max]`` range a value is uniformly sampled from."""
|
| 27 |
+
|
| 28 |
+
model_config = ConfigDict(extra="forbid")
|
| 29 |
+
|
| 30 |
+
min: float
|
| 31 |
+
max: float
|
| 32 |
+
|
| 33 |
+
@model_validator(mode="after")
|
| 34 |
+
def _check_order(self) -> "Range":
|
| 35 |
+
if self.max < self.min:
|
| 36 |
+
raise ValueError(f"Range max ({self.max}) must be >= min ({self.min})")
|
| 37 |
+
return self
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
NumericOrRange = Union[float, Range]
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class AugmentationParam(BaseModel):
|
| 44 |
+
"""Base schema shared by every augmentation effect.
|
| 45 |
+
|
| 46 |
+
Extra keys are allowed so effect-specific parameters (e.g. ``type`` for
|
| 47 |
+
:class:`ShadowParam`, ``tone`` for discoloration) can be declared inline
|
| 48 |
+
in YAML without needing a bespoke subclass for every single effect.
|
| 49 |
+
"""
|
| 50 |
+
|
| 51 |
+
model_config = ConfigDict(extra="allow")
|
| 52 |
+
|
| 53 |
+
enabled: bool = False
|
| 54 |
+
strength: NumericOrRange = 0.0
|
| 55 |
+
probability: float = Field(default=1.0, ge=0.0, le=1.0)
|
| 56 |
+
seed: "int | None" = None
|
| 57 |
+
|
| 58 |
+
@field_validator("strength")
|
| 59 |
+
@classmethod
|
| 60 |
+
def _strength_bounds(cls, v: NumericOrRange) -> NumericOrRange:
|
| 61 |
+
if isinstance(v, (int, float)) and not (0.0 <= float(v) <= 1.0):
|
| 62 |
+
raise ValueError("strength must be within [0, 1]")
|
| 63 |
+
return v
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
class ShadowParam(AugmentationParam):
|
| 67 |
+
type: Literal["edge", "corner", "fold", "random"] = "random"
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
class StainParam(AugmentationParam):
|
| 71 |
+
type: Literal["paper", "water", "random"] = "random"
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
class DiscolorationParam(AugmentationParam):
|
| 75 |
+
tone: Literal["yellow", "grey", "sepia", "random"] = "random"
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
class CrumpleParam(AugmentationParam):
|
| 79 |
+
wrinkle_density: NumericOrRange = 0.4
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
class JpegCompressionParam(AugmentationParam):
|
| 83 |
+
min_quality: int = Field(default=35, ge=1, le=100)
|
| 84 |
+
max_quality: int = Field(default=90, ge=1, le=100)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
class AugmentationsConfig(BaseModel):
|
| 88 |
+
"""The full family of configurable document/signature augmentations."""
|
| 89 |
+
|
| 90 |
+
model_config = ConfigDict(extra="forbid")
|
| 91 |
+
|
| 92 |
+
rotation: AugmentationParam = Field(default_factory=lambda: AugmentationParam())
|
| 93 |
+
stroke_dilation: AugmentationParam = Field(default_factory=lambda: AugmentationParam())
|
| 94 |
+
motion_blur: AugmentationParam = Field(default_factory=lambda: AugmentationParam())
|
| 95 |
+
gaussian_blur: AugmentationParam = Field(default_factory=lambda: AugmentationParam())
|
| 96 |
+
perspective: AugmentationParam = Field(default_factory=lambda: AugmentationParam())
|
| 97 |
+
scanner_noise: AugmentationParam = Field(default_factory=lambda: AugmentationParam(enabled=True, strength=0.06, probability=1.0))
|
| 98 |
+
salt_pepper: AugmentationParam = Field(default_factory=lambda: AugmentationParam())
|
| 99 |
+
jpeg_compression: JpegCompressionParam = Field(default_factory=lambda: JpegCompressionParam())
|
| 100 |
+
contrast: AugmentationParam = Field(default_factory=lambda: AugmentationParam())
|
| 101 |
+
gamma: AugmentationParam = Field(default_factory=lambda: AugmentationParam())
|
| 102 |
+
coffee_stain: AugmentationParam = Field(default_factory=lambda: AugmentationParam())
|
| 103 |
+
stain: StainParam = Field(default_factory=lambda: StainParam())
|
| 104 |
+
shadow: ShadowParam = Field(default_factory=lambda: ShadowParam())
|
| 105 |
+
crumple: CrumpleParam = Field(default_factory=lambda: CrumpleParam())
|
| 106 |
+
discoloration: DiscolorationParam = Field(default_factory=lambda: DiscolorationParam())
|
| 107 |
+
page_tilt: AugmentationParam = Field(default_factory=lambda: AugmentationParam())
|
| 108 |
+
dust: AugmentationParam = Field(default_factory=lambda: AugmentationParam())
|
| 109 |
+
scanner_streaks: AugmentationParam = Field(default_factory=lambda: AugmentationParam())
|
| 110 |
+
ink_fading: AugmentationParam = Field(default_factory=lambda: AugmentationParam())
|
| 111 |
+
ink_bleed: AugmentationParam = Field(default_factory=lambda: AugmentationParam())
|
| 112 |
+
|
| 113 |
+
def items(self):
|
| 114 |
+
"""Yield ``(name, AugmentationParam)`` pairs for every configured effect."""
|
| 115 |
+
for name in self.model_fields:
|
| 116 |
+
yield name, getattr(self, name)
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
class PlacementConfig(BaseModel):
|
| 120 |
+
"""Controls where and how the signature is positioned inside its target box."""
|
| 121 |
+
|
| 122 |
+
model_config = ConfigDict(extra="forbid")
|
| 123 |
+
|
| 124 |
+
x_offset: NumericOrRange = 0.0
|
| 125 |
+
y_offset: NumericOrRange = 0.0
|
| 126 |
+
rotation_deg: NumericOrRange = 0.0
|
| 127 |
+
scale_factor: NumericOrRange = 0.9
|
| 128 |
+
anchor_point: Literal[
|
| 129 |
+
"center", "top_left", "top_right", "bottom_left", "bottom_right",
|
| 130 |
+
"top_center", "bottom_center", "left_center", "right_center",
|
| 131 |
+
] = "center"
|
| 132 |
+
margin: float = Field(default=0.06, ge=0.0, le=0.45)
|
| 133 |
+
misplacement_probability: float = Field(default=0.0, ge=0.0, le=1.0)
|
| 134 |
+
misplacement_strength: NumericOrRange = 0.3
|
| 135 |
+
allow_partial_outside: bool = False
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
class RenderingConfig(BaseModel):
|
| 139 |
+
"""Controls how the signature layer is composited onto the document."""
|
| 140 |
+
|
| 141 |
+
model_config = ConfigDict(extra="forbid")
|
| 142 |
+
|
| 143 |
+
blend_mode: Literal["multiply", "darken", "alpha"] = "multiply"
|
| 144 |
+
ink_opacity: NumericOrRange = 0.88
|
| 145 |
+
blur_sigma: NumericOrRange = 0.35
|
| 146 |
+
feather_px: NumericOrRange = 1.2
|
| 147 |
+
scanner_noise: NumericOrRange = 0.02
|
| 148 |
+
anti_alias: bool = True
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
class SignerConfig(BaseModel):
|
| 152 |
+
"""Optional overrides for the assigned signer identity."""
|
| 153 |
+
|
| 154 |
+
model_config = ConfigDict(extra="forbid")
|
| 155 |
+
|
| 156 |
+
name: "str | None" = None
|
| 157 |
+
role: "str | None" = None
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
class SignatureSelectionConfig(BaseModel):
|
| 161 |
+
"""Controls how a signature image is drawn from the discovered dataset."""
|
| 162 |
+
|
| 163 |
+
model_config = ConfigDict(extra="forbid")
|
| 164 |
+
|
| 165 |
+
source_filter: Literal["genuine", "forged", "any"] = "genuine"
|
| 166 |
+
signature_id: "str | None" = None
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
class DocumentConfig(BaseModel):
|
| 170 |
+
"""Selects which template (and page) to render."""
|
| 171 |
+
|
| 172 |
+
model_config = ConfigDict(extra="forbid")
|
| 173 |
+
|
| 174 |
+
template: "str | None" = None
|
| 175 |
+
page: "int | None" = None
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
class QualityConfig(BaseModel):
|
| 179 |
+
"""Document quality-flag inputs (feed into the ``quality`` metadata block)."""
|
| 180 |
+
|
| 181 |
+
model_config = ConfigDict(extra="forbid")
|
| 182 |
+
|
| 183 |
+
case_type: "str | None" = None
|
| 184 |
+
notes: "str | None" = None
|
| 185 |
+
scanner_quality: NumericOrRange = 0.85
|
| 186 |
+
paper_quality: NumericOrRange = 0.85
|
| 187 |
+
render_quality: NumericOrRange = 0.85
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
class DifficultyWeights(BaseModel):
|
| 191 |
+
"""Weights and tier thresholds for the difficulty score.
|
| 192 |
+
|
| 193 |
+
Defaults match the specification exactly:
|
| 194 |
+
``0.35*misplacement + 0.20*whitespace + 0.20*faintness + 0.15*smallness + 0.10*low_texture``.
|
| 195 |
+
"""
|
| 196 |
+
|
| 197 |
+
model_config = ConfigDict(extra="forbid")
|
| 198 |
+
|
| 199 |
+
misplacement: float = 0.35
|
| 200 |
+
whitespace: float = 0.20
|
| 201 |
+
faintness: float = 0.20
|
| 202 |
+
smallness: float = 0.15
|
| 203 |
+
low_texture: float = 0.10
|
| 204 |
+
easy_threshold: float = 0.33
|
| 205 |
+
hard_threshold: float = 0.66
|
| 206 |
+
|
| 207 |
+
@model_validator(mode="after")
|
| 208 |
+
def _check_weights(self) -> "DifficultyWeights":
|
| 209 |
+
total = self.misplacement + self.whitespace + self.faintness + self.smallness + self.low_texture
|
| 210 |
+
if abs(total - 1.0) > 1e-6:
|
| 211 |
+
raise ValueError(f"Difficulty weights must sum to 1.0, got {total}")
|
| 212 |
+
return self
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
class PathsConfig(BaseModel):
|
| 216 |
+
"""Filesystem locations for inputs/outputs."""
|
| 217 |
+
|
| 218 |
+
model_config = ConfigDict(extra="forbid")
|
| 219 |
+
|
| 220 |
+
image_dir: str = "image"
|
| 221 |
+
dataset_dir: str = "dataset"
|
| 222 |
+
annotations_dir: str = "annotations"
|
| 223 |
+
output_dir: str = "output"
|
| 224 |
+
cache_dir: str = "output/.cache"
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
class GenerationConfig(BaseModel):
|
| 228 |
+
"""Batch-generation controls."""
|
| 229 |
+
|
| 230 |
+
model_config = ConfigDict(extra="forbid")
|
| 231 |
+
|
| 232 |
+
count: int = Field(default=10, ge=1)
|
| 233 |
+
workers: int = Field(default=1, ge=1)
|
| 234 |
+
save_visualization: bool = False
|
| 235 |
+
overwrite: bool = False
|
| 236 |
+
run_validation: bool = True
|
| 237 |
+
start_index: int = Field(default=1, ge=0)
|
| 238 |
+
filename_prefix: str = "sample"
|
| 239 |
+
filename_digits: int = Field(default=6, ge=1)
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
class ValidationConfig(BaseModel):
|
| 243 |
+
"""Controls strictness of the post-generation ValidationEngine."""
|
| 244 |
+
|
| 245 |
+
model_config = ConfigDict(extra="forbid")
|
| 246 |
+
|
| 247 |
+
strict: bool = False
|
| 248 |
+
max_coordinate_epsilon: float = 0.5
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
class PipelineConfig(BaseModel):
|
| 252 |
+
"""Top-level configuration for a full generation run."""
|
| 253 |
+
|
| 254 |
+
model_config = ConfigDict(extra="forbid")
|
| 255 |
+
|
| 256 |
+
seed: int = 1234
|
| 257 |
+
paths: PathsConfig = Field(default_factory=PathsConfig)
|
| 258 |
+
generation: GenerationConfig = Field(default_factory=GenerationConfig)
|
| 259 |
+
document: DocumentConfig = Field(default_factory=DocumentConfig)
|
| 260 |
+
signer: SignerConfig = Field(default_factory=SignerConfig)
|
| 261 |
+
signature_selection: SignatureSelectionConfig = Field(default_factory=SignatureSelectionConfig)
|
| 262 |
+
placement: PlacementConfig = Field(default_factory=PlacementConfig)
|
| 263 |
+
rendering: RenderingConfig = Field(default_factory=RenderingConfig)
|
| 264 |
+
augmentation: AugmentationsConfig = Field(default_factory=AugmentationsConfig)
|
| 265 |
+
quality: QualityConfig = Field(default_factory=QualityConfig)
|
| 266 |
+
difficulty: DifficultyWeights = Field(default_factory=DifficultyWeights)
|
| 267 |
+
validation: ValidationConfig = Field(default_factory=ValidationConfig)
|
src/metadata/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Metadata assembly and export."""
|
src/metadata/metadata_exporter.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Assembles and writes the complete per-sample metadata JSON.
|
| 2 |
+
|
| 3 |
+
The exported schema always includes every documented field -- augmentation
|
| 4 |
+
parameters that were not applied are still present, with a neutral value
|
| 5 |
+
(``0``/``null``/``false``) rather than being omitted, so downstream
|
| 6 |
+
consumers can rely on a stable schema across the whole dataset.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import json
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
from typing import Any
|
| 14 |
+
|
| 15 |
+
import numpy as np
|
| 16 |
+
|
| 17 |
+
from src.analysis.difficulty_analyzer import DifficultyResult
|
| 18 |
+
from src.analysis.geometry_analyzer import GeometryResult
|
| 19 |
+
from src.augmentation.augmentation_engine import AugmentationRecord
|
| 20 |
+
from src.config.schema import QualityConfig
|
| 21 |
+
from src.placement.placement_engine import PlacementResult
|
| 22 |
+
from src.rendering.renderer import RenderResult
|
| 23 |
+
from src.signatures.signature_dataset import SignatureRecord
|
| 24 |
+
from src.utils.logging_utils import get_logger
|
| 25 |
+
|
| 26 |
+
logger = get_logger(__name__)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _json_default(obj: Any) -> Any:
|
| 30 |
+
if isinstance(obj, np.integer):
|
| 31 |
+
return int(obj)
|
| 32 |
+
if isinstance(obj, np.floating):
|
| 33 |
+
return float(obj)
|
| 34 |
+
if isinstance(obj, np.bool_):
|
| 35 |
+
return bool(obj)
|
| 36 |
+
if isinstance(obj, np.ndarray):
|
| 37 |
+
return obj.tolist()
|
| 38 |
+
if isinstance(obj, Path):
|
| 39 |
+
return obj.as_posix()
|
| 40 |
+
return str(obj)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class MetadataExporter:
|
| 44 |
+
"""Builds and writes the full per-sample metadata JSON document.
|
| 45 |
+
|
| 46 |
+
Example:
|
| 47 |
+
>>> exporter = MetadataExporter()
|
| 48 |
+
>>> metadata = exporter.build(...)
|
| 49 |
+
>>> exporter.export(metadata, "output/sample_000001.json")
|
| 50 |
+
"""
|
| 51 |
+
|
| 52 |
+
def build(
|
| 53 |
+
self,
|
| 54 |
+
document_id: str,
|
| 55 |
+
template_id: str,
|
| 56 |
+
document_type: "str | None",
|
| 57 |
+
page: int,
|
| 58 |
+
page_size: tuple[int, int],
|
| 59 |
+
dpi: int,
|
| 60 |
+
signer_name: str,
|
| 61 |
+
signer_role: str,
|
| 62 |
+
signature_source: "SignatureRecord | None",
|
| 63 |
+
placement: PlacementResult,
|
| 64 |
+
render: RenderResult,
|
| 65 |
+
signature_aug: dict[str, AugmentationRecord],
|
| 66 |
+
document_aug: dict[str, AugmentationRecord],
|
| 67 |
+
geometry: GeometryResult,
|
| 68 |
+
difficulty: DifficultyResult,
|
| 69 |
+
quality_cfg: QualityConfig,
|
| 70 |
+
quality_values: dict[str, float],
|
| 71 |
+
seed: int,
|
| 72 |
+
sample_index: int,
|
| 73 |
+
verification: "dict[str, Any] | None" = None,
|
| 74 |
+
) -> dict[str, Any]:
|
| 75 |
+
"""Assemble the complete metadata dict for one generated sample."""
|
| 76 |
+
degraded = self._compute_degraded(quality_cfg)
|
| 77 |
+
|
| 78 |
+
augmentation_section = self._build_augmentation_section(placement, render, signature_aug, document_aug)
|
| 79 |
+
|
| 80 |
+
metadata: dict[str, Any] = {
|
| 81 |
+
"document_id": document_id,
|
| 82 |
+
"template": template_id,
|
| 83 |
+
"document_type": document_type,
|
| 84 |
+
"page": page,
|
| 85 |
+
"page_size": list(page_size),
|
| 86 |
+
"dpi": dpi,
|
| 87 |
+
"signer": {
|
| 88 |
+
"name": signer_name,
|
| 89 |
+
"role": signer_role,
|
| 90 |
+
},
|
| 91 |
+
"signature_source": (
|
| 92 |
+
{
|
| 93 |
+
"signature_id": signature_source.signature_id,
|
| 94 |
+
"path": signature_source.path.as_posix(),
|
| 95 |
+
"signer_group": signature_source.signer_group,
|
| 96 |
+
"source_type": signature_source.source_type,
|
| 97 |
+
"script": signature_source.script,
|
| 98 |
+
}
|
| 99 |
+
if signature_source is not None
|
| 100 |
+
else {"signature_id": None, "path": None, "signer_group": None, "source_type": "none", "script": None}
|
| 101 |
+
),
|
| 102 |
+
"placement": {
|
| 103 |
+
"x_offset": placement.x_offset,
|
| 104 |
+
"y_offset": placement.y_offset,
|
| 105 |
+
"rotation_deg": round(placement.rotation_deg, 3),
|
| 106 |
+
"scale_factor": round(placement.scale_factor, 4),
|
| 107 |
+
"anchor_point": placement.anchor_point,
|
| 108 |
+
"margin_target_box": list(placement.target_box),
|
| 109 |
+
"misplaced": placement.misplaced,
|
| 110 |
+
"misplacement_dx": round(placement.misplacement_dx, 2),
|
| 111 |
+
"misplacement_dy": round(placement.misplacement_dy, 2),
|
| 112 |
+
},
|
| 113 |
+
"rendering": {
|
| 114 |
+
"blend_mode": render.blend_mode,
|
| 115 |
+
"ink_opacity": render.ink_opacity,
|
| 116 |
+
"blur_sigma": render.blur_sigma,
|
| 117 |
+
"feather_px": render.feather_px,
|
| 118 |
+
"scanner_noise": render.scanner_noise,
|
| 119 |
+
},
|
| 120 |
+
"difficulty": {
|
| 121 |
+
"score": difficulty.score,
|
| 122 |
+
"tier": difficulty.tier,
|
| 123 |
+
"misplacement": difficulty.misplacement,
|
| 124 |
+
"whitespace": difficulty.whitespace,
|
| 125 |
+
"faintness": difficulty.faintness,
|
| 126 |
+
"smallness": difficulty.smallness,
|
| 127 |
+
"low_texture": difficulty.low_texture,
|
| 128 |
+
},
|
| 129 |
+
"quality": {
|
| 130 |
+
"degraded": degraded,
|
| 131 |
+
"case_type": quality_cfg.case_type,
|
| 132 |
+
"notes": quality_cfg.notes,
|
| 133 |
+
"scanner_quality": quality_values["scanner_quality"],
|
| 134 |
+
"paper_quality": quality_values["paper_quality"],
|
| 135 |
+
"render_quality": quality_values["render_quality"],
|
| 136 |
+
},
|
| 137 |
+
"verification": verification or {
|
| 138 |
+
"case_type": None, "verdict": None, "reject_code": None,
|
| 139 |
+
"target_box_id": None, "expected_signer_group": None, "expected_signer_script": None,
|
| 140 |
+
"actual_signer_group": None, "actual_source_type": None, "actual_signer_script": None,
|
| 141 |
+
"companion": None,
|
| 142 |
+
},
|
| 143 |
+
"augmentation": augmentation_section,
|
| 144 |
+
"geometry": {
|
| 145 |
+
"box_px": list(geometry.target_box),
|
| 146 |
+
"signature_bbox_px": list(geometry.signature_bbox_px),
|
| 147 |
+
"target_box": list(geometry.target_box),
|
| 148 |
+
"actual_box": list(geometry.actual_box),
|
| 149 |
+
"search_box_px": list(geometry.search_box),
|
| 150 |
+
"signature_polygon_px": geometry.signature_polygon_px,
|
| 151 |
+
"convex_hull_px": geometry.convex_hull_px,
|
| 152 |
+
"min_rotated_rect": geometry.min_rotated_rect,
|
| 153 |
+
"centroid_px": list(geometry.centroid_px),
|
| 154 |
+
"orientation_deg": geometry.orientation_deg,
|
| 155 |
+
"ink_pixels": geometry.ink_pixels,
|
| 156 |
+
"detected": geometry.found,
|
| 157 |
+
},
|
| 158 |
+
"metrics": {
|
| 159 |
+
"box_coverage_pct": geometry.box_coverage_pct,
|
| 160 |
+
"containment_pct": geometry.containment_pct,
|
| 161 |
+
"ink_fill_ratio": geometry.ink_fill_ratio,
|
| 162 |
+
"polygon_overlap_proxy": geometry.polygon_overlap_proxy,
|
| 163 |
+
"distance_from_box_center_px": geometry.distance_from_box_center_px,
|
| 164 |
+
"margin_utilization": geometry.margin_utilization,
|
| 165 |
+
"mean_dark_intensity": geometry.mean_dark_intensity,
|
| 166 |
+
"gray_stddev": geometry.gray_stddev,
|
| 167 |
+
"signature_area_ratio": geometry.signature_area_ratio,
|
| 168 |
+
},
|
| 169 |
+
"provenance": {
|
| 170 |
+
"master_seed": seed,
|
| 171 |
+
"sample_index": sample_index,
|
| 172 |
+
},
|
| 173 |
+
}
|
| 174 |
+
return metadata
|
| 175 |
+
|
| 176 |
+
@staticmethod
|
| 177 |
+
def _compute_degraded(quality_cfg: QualityConfig) -> bool:
|
| 178 |
+
case_type_flag = quality_cfg.case_type == "1d"
|
| 179 |
+
notes_flag = bool(quality_cfg.notes and "low-quality" in quality_cfg.notes.lower())
|
| 180 |
+
return bool(case_type_flag or notes_flag)
|
| 181 |
+
|
| 182 |
+
@staticmethod
|
| 183 |
+
def _build_augmentation_section(
|
| 184 |
+
placement: PlacementResult, render: RenderResult,
|
| 185 |
+
signature_aug: dict[str, AugmentationRecord], document_aug: dict[str, AugmentationRecord],
|
| 186 |
+
) -> dict[str, Any]:
|
| 187 |
+
def rec(records: dict[str, AugmentationRecord], name: str) -> AugmentationRecord:
|
| 188 |
+
return records[name]
|
| 189 |
+
|
| 190 |
+
stroke_rec = rec(signature_aug, "stroke_dilation")
|
| 191 |
+
noise_rec = rec(document_aug, "scanner_noise")
|
| 192 |
+
stain_rec = rec(document_aug, "stain")
|
| 193 |
+
shadow_rec = rec(document_aug, "shadow")
|
| 194 |
+
coffee_rec = rec(document_aug, "coffee_stain")
|
| 195 |
+
crumple_rec = rec(document_aug, "crumple")
|
| 196 |
+
tilt_rec = rec(document_aug, "page_tilt")
|
| 197 |
+
discoloration_rec = rec(document_aug, "discoloration")
|
| 198 |
+
jpeg_rec = rec(document_aug, "jpeg_compression")
|
| 199 |
+
|
| 200 |
+
effects: dict[str, Any] = {}
|
| 201 |
+
for name, r in {**signature_aug, **document_aug}.items():
|
| 202 |
+
effects[name] = {
|
| 203 |
+
"enabled": r.enabled,
|
| 204 |
+
"applied": r.applied,
|
| 205 |
+
"strength": r.strength,
|
| 206 |
+
"probability": r.probability,
|
| 207 |
+
"seed": r.seed,
|
| 208 |
+
"params": r.params,
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
return {
|
| 212 |
+
"rotate_deg": round(placement.rotation_deg, 3),
|
| 213 |
+
"scale_factor": round(placement.scale_factor, 4),
|
| 214 |
+
"stroke": stroke_rec.strength if stroke_rec.applied else 0.0,
|
| 215 |
+
"noise_amount": noise_rec.strength if noise_rec.applied else 0.0,
|
| 216 |
+
"stain_type": stain_rec.params.get("resolved_type") if stain_rec.applied else None,
|
| 217 |
+
"stain_strength": stain_rec.strength if stain_rec.applied else 0.0,
|
| 218 |
+
"shadow_type": shadow_rec.params.get("resolved_type") if shadow_rec.applied else None,
|
| 219 |
+
"shadow_strength": shadow_rec.strength if shadow_rec.applied else 0.0,
|
| 220 |
+
"coffee_stain_strength": coffee_rec.strength if coffee_rec.applied else 0.0,
|
| 221 |
+
"crumple_strength": crumple_rec.strength if crumple_rec.applied else 0.0,
|
| 222 |
+
"tilt_deg": tilt_rec.params.get("tilt_deg", 0.0) if tilt_rec.applied else 0.0,
|
| 223 |
+
"discoloration_strength": discoloration_rec.strength if discoloration_rec.applied else 0.0,
|
| 224 |
+
"blur_sigma": render.blur_sigma,
|
| 225 |
+
"jpeg_quality": jpeg_rec.params.get("quality") if jpeg_rec.applied else None,
|
| 226 |
+
"scanner_noise": render.scanner_noise,
|
| 227 |
+
"effects": effects,
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
def export(self, metadata: dict[str, Any], path: "str | Path") -> None:
|
| 231 |
+
"""Write the metadata dict to disk as pretty-printed JSON."""
|
| 232 |
+
path = Path(path)
|
| 233 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 234 |
+
path.write_text(json.dumps(metadata, indent=2, default=_json_default), encoding="utf-8")
|
src/placement/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Signature placement: fitting, transforming, and positioning a signature stamp inside a target box."""
|
src/placement/placement_engine.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Places a preprocessed signature stamp inside (or intentionally near) a target signature box.
|
| 2 |
+
|
| 3 |
+
Handles aspect-ratio-preserving scale-to-fit, rotation, anchor-based
|
| 4 |
+
positioning, sub-pixel offsets, and controlled "misplacement" (an
|
| 5 |
+
intentional, probability-gated extra translation used to generate
|
| 6 |
+
hard/misaligned samples for benchmarking).
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from dataclasses import dataclass
|
| 12 |
+
|
| 13 |
+
import cv2
|
| 14 |
+
import numpy as np
|
| 15 |
+
|
| 16 |
+
from src.config.schema import PlacementConfig
|
| 17 |
+
from src.utils.geometry_utils import Box
|
| 18 |
+
from src.utils.logging_utils import get_logger
|
| 19 |
+
from src.utils.random_utils import SeedManager, resolve_scalar_or_range
|
| 20 |
+
from src.utils.transform_utils import rotate_and_scale_rgba
|
| 21 |
+
|
| 22 |
+
logger = get_logger(__name__)
|
| 23 |
+
|
| 24 |
+
AnchorOffsetFn = "dict[str, callable]"
|
| 25 |
+
|
| 26 |
+
MIN_SIGNATURE_HEIGHT_PX = 16.0
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@dataclass
|
| 30 |
+
class PlacementResult:
|
| 31 |
+
"""Everything downstream stages need to know about how the signature was placed."""
|
| 32 |
+
|
| 33 |
+
layer: np.ndarray # full-canvas-sized RGBA layer, transparent outside the signature
|
| 34 |
+
target_box: Box
|
| 35 |
+
intended_bbox: Box # the signature's transformed bounding box in canvas coordinates
|
| 36 |
+
x_offset: float
|
| 37 |
+
y_offset: float
|
| 38 |
+
rotation_deg: float
|
| 39 |
+
scale_factor: float
|
| 40 |
+
anchor_point: str
|
| 41 |
+
misplaced: bool
|
| 42 |
+
misplacement_dx: float
|
| 43 |
+
misplacement_dy: float
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _anchor_top_left(anchor: str, box_w: float, box_h: float, sig_w: float, sig_h: float) -> tuple[float, float]:
|
| 47 |
+
positions = {
|
| 48 |
+
"center": ((box_w - sig_w) / 2.0, (box_h - sig_h) / 2.0),
|
| 49 |
+
"top_left": (0.0, 0.0),
|
| 50 |
+
"top_right": (box_w - sig_w, 0.0),
|
| 51 |
+
"bottom_left": (0.0, box_h - sig_h),
|
| 52 |
+
"bottom_right": (box_w - sig_w, box_h - sig_h),
|
| 53 |
+
"top_center": ((box_w - sig_w) / 2.0, 0.0),
|
| 54 |
+
"bottom_center": ((box_w - sig_w) / 2.0, box_h - sig_h),
|
| 55 |
+
"left_center": (0.0, (box_h - sig_h) / 2.0),
|
| 56 |
+
"right_center": (box_w - sig_w, (box_h - sig_h) / 2.0),
|
| 57 |
+
}
|
| 58 |
+
return positions.get(anchor, positions["center"])
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class PlacementEngine:
|
| 62 |
+
"""Fits, transforms, and positions a signature stamp relative to a target box.
|
| 63 |
+
|
| 64 |
+
Example:
|
| 65 |
+
>>> engine = PlacementEngine(cfg.placement)
|
| 66 |
+
>>> result = engine.place(signature_rgba, target_box=(100, 100, 300, 160),
|
| 67 |
+
... canvas_size=(1024, 559), seed_manager=sm)
|
| 68 |
+
"""
|
| 69 |
+
|
| 70 |
+
def __init__(self, config: PlacementConfig) -> None:
|
| 71 |
+
self.config = config
|
| 72 |
+
|
| 73 |
+
def place(
|
| 74 |
+
self,
|
| 75 |
+
signature_rgba: np.ndarray,
|
| 76 |
+
target_box: Box,
|
| 77 |
+
canvas_size: tuple[int, int],
|
| 78 |
+
seed_manager: SeedManager,
|
| 79 |
+
) -> PlacementResult:
|
| 80 |
+
"""Compute the placed signature layer for one sample.
|
| 81 |
+
|
| 82 |
+
Args:
|
| 83 |
+
signature_rgba: The tightly-cropped RGBA signature stamp from
|
| 84 |
+
:class:`~src.rendering.signature_preprocessor.SignaturePreprocessor`.
|
| 85 |
+
target_box: The ``(x1, y1, x2, y2)`` target signature field, in
|
| 86 |
+
canvas pixel coordinates.
|
| 87 |
+
canvas_size: ``(width, height)`` of the full document canvas.
|
| 88 |
+
seed_manager: Per-sample deterministic RNG provider.
|
| 89 |
+
|
| 90 |
+
Returns:
|
| 91 |
+
A :class:`PlacementResult` with the full-canvas RGBA layer ready
|
| 92 |
+
for blending, plus every resolved placement parameter for
|
| 93 |
+
metadata export.
|
| 94 |
+
"""
|
| 95 |
+
cfg = self.config
|
| 96 |
+
rng = seed_manager.numpy_rng("placement")
|
| 97 |
+
canvas_w, canvas_h = canvas_size
|
| 98 |
+
|
| 99 |
+
rotation_deg = resolve_scalar_or_range(cfg.rotation_deg, rng)
|
| 100 |
+
scale_factor = resolve_scalar_or_range(cfg.scale_factor, rng)
|
| 101 |
+
x_offset = resolve_scalar_or_range(cfg.x_offset, rng)
|
| 102 |
+
y_offset = resolve_scalar_or_range(cfg.y_offset, rng)
|
| 103 |
+
|
| 104 |
+
x1, y1, x2, y2 = target_box
|
| 105 |
+
box_w, box_h = (x2 - x1), (y2 - y1)
|
| 106 |
+
margin_x, margin_y = box_w * cfg.margin, box_h * cfg.margin
|
| 107 |
+
inner_x1, inner_y1 = x1 + margin_x, y1 + margin_y
|
| 108 |
+
inner_w = max(box_w - 2 * margin_x, 1.0)
|
| 109 |
+
inner_h = max(box_h - 2 * margin_y, 1.0)
|
| 110 |
+
|
| 111 |
+
rotated = rotate_and_scale_rgba(signature_rgba, rotation_deg, 1.0)
|
| 112 |
+
sig_h, sig_w = rotated.shape[:2]
|
| 113 |
+
|
| 114 |
+
fit_scale = min(inner_w / max(sig_w, 1), inner_h / max(sig_h, 1))
|
| 115 |
+
final_scale = max(fit_scale * scale_factor, 1e-3)
|
| 116 |
+
|
| 117 |
+
# Never let the signature shrink below a legible minimum height, even
|
| 118 |
+
# inside a very short box (real "sign here" lines are often only
|
| 119 |
+
# 12-20px tall at document resolution) -- a genuinely tiny box should
|
| 120 |
+
# make the signature overflow it, not vanish into sub-pixel noise.
|
| 121 |
+
min_height_scale = MIN_SIGNATURE_HEIGHT_PX / max(sig_h, 1)
|
| 122 |
+
final_scale = max(final_scale, min_height_scale)
|
| 123 |
+
|
| 124 |
+
placed = cv2.resize(
|
| 125 |
+
rotated, (max(int(round(sig_w * final_scale)), 1), max(int(round(sig_h * final_scale)), 1)),
|
| 126 |
+
interpolation=cv2.INTER_AREA if final_scale < 1.0 else cv2.INTER_CUBIC,
|
| 127 |
+
)
|
| 128 |
+
placed_h, placed_w = placed.shape[:2]
|
| 129 |
+
|
| 130 |
+
ax, ay = _anchor_top_left(cfg.anchor_point, inner_w, inner_h, placed_w, placed_h)
|
| 131 |
+
base_x = inner_x1 + ax + x_offset
|
| 132 |
+
base_y = inner_y1 + ay + y_offset
|
| 133 |
+
|
| 134 |
+
misplaced = bool(rng.uniform(0.0, 1.0) < cfg.misplacement_probability)
|
| 135 |
+
misplacement_dx = misplacement_dy = 0.0
|
| 136 |
+
if misplaced:
|
| 137 |
+
strength = resolve_scalar_or_range(cfg.misplacement_strength, rng)
|
| 138 |
+
max_shift = strength * max(box_w, box_h)
|
| 139 |
+
angle = rng.uniform(0, 2 * np.pi)
|
| 140 |
+
radius = rng.uniform(0.3, 1.0) * max_shift
|
| 141 |
+
misplacement_dx = float(np.cos(angle) * radius)
|
| 142 |
+
misplacement_dy = float(np.sin(angle) * radius)
|
| 143 |
+
|
| 144 |
+
final_x = base_x + misplacement_dx
|
| 145 |
+
final_y = base_y + misplacement_dy
|
| 146 |
+
|
| 147 |
+
if not cfg.allow_partial_outside and not misplaced:
|
| 148 |
+
final_x = float(np.clip(final_x, inner_x1, inner_x1 + max(inner_w - placed_w, 0)))
|
| 149 |
+
final_y = float(np.clip(final_y, inner_y1, inner_y1 + max(inner_h - placed_h, 0)))
|
| 150 |
+
|
| 151 |
+
# Hard safety clamp: never let the signature drift entirely off the physical page.
|
| 152 |
+
final_x = float(np.clip(final_x, -placed_w * 0.6, canvas_w - placed_w * 0.4))
|
| 153 |
+
final_y = float(np.clip(final_y, -placed_h * 0.6, canvas_h - placed_h * 0.4))
|
| 154 |
+
|
| 155 |
+
layer = np.zeros((canvas_h, canvas_w, 4), dtype=np.uint8)
|
| 156 |
+
self._paste(layer, placed, int(round(final_x)), int(round(final_y)))
|
| 157 |
+
|
| 158 |
+
intended_bbox: Box = (final_x, final_y, final_x + placed_w, final_y + placed_h)
|
| 159 |
+
|
| 160 |
+
return PlacementResult(
|
| 161 |
+
layer=layer,
|
| 162 |
+
target_box=target_box,
|
| 163 |
+
intended_bbox=intended_bbox,
|
| 164 |
+
x_offset=x_offset,
|
| 165 |
+
y_offset=y_offset,
|
| 166 |
+
rotation_deg=rotation_deg,
|
| 167 |
+
scale_factor=scale_factor,
|
| 168 |
+
anchor_point=cfg.anchor_point,
|
| 169 |
+
misplaced=misplaced,
|
| 170 |
+
misplacement_dx=misplacement_dx,
|
| 171 |
+
misplacement_dy=misplacement_dy,
|
| 172 |
+
)
|
| 173 |
+
|
| 174 |
+
@staticmethod
|
| 175 |
+
def _paste(canvas: np.ndarray, sprite: np.ndarray, x: int, y: int) -> None:
|
| 176 |
+
"""Alpha-aware paste of ``sprite`` onto ``canvas`` at ``(x, y)``, clipped to bounds."""
|
| 177 |
+
canvas_h, canvas_w = canvas.shape[:2]
|
| 178 |
+
sprite_h, sprite_w = sprite.shape[:2]
|
| 179 |
+
|
| 180 |
+
dst_x1, dst_y1 = max(x, 0), max(y, 0)
|
| 181 |
+
dst_x2, dst_y2 = min(x + sprite_w, canvas_w), min(y + sprite_h, canvas_h)
|
| 182 |
+
if dst_x2 <= dst_x1 or dst_y2 <= dst_y1:
|
| 183 |
+
logger.warning("Placed signature falls entirely outside the canvas; skipping paste.")
|
| 184 |
+
return
|
| 185 |
+
|
| 186 |
+
src_x1, src_y1 = dst_x1 - x, dst_y1 - y
|
| 187 |
+
src_x2, src_y2 = src_x1 + (dst_x2 - dst_x1), src_y1 + (dst_y2 - dst_y1)
|
| 188 |
+
|
| 189 |
+
canvas[dst_y1:dst_y2, dst_x1:dst_x2] = sprite[src_y1:src_y2, src_x1:src_x2]
|
src/rendering/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Signature preprocessing and document/signature compositing."""
|
src/rendering/printed_text.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Renders a plain typed/printed name as an RGBA stamp (case_type "2b": no handwriting).
|
| 2 |
+
|
| 3 |
+
Produces a tightly-cropped RGBA image shaped exactly like a
|
| 4 |
+
:class:`~src.rendering.signature_preprocessor.SignaturePreprocessor` output,
|
| 5 |
+
so it can flow through the existing
|
| 6 |
+
:class:`~src.placement.placement_engine.PlacementEngine` and
|
| 7 |
+
:class:`~src.rendering.renderer.Renderer` unchanged.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import numpy as np
|
| 13 |
+
from PIL import Image, ImageDraw, ImageFont
|
| 14 |
+
|
| 15 |
+
from src.utils.logging_utils import get_logger
|
| 16 |
+
|
| 17 |
+
logger = get_logger(__name__)
|
| 18 |
+
|
| 19 |
+
_FONT_CANDIDATES: tuple[str, ...] = (
|
| 20 |
+
"C:/Windows/Fonts/arial.ttf",
|
| 21 |
+
"C:/Windows/Fonts/calibri.ttf",
|
| 22 |
+
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
| 23 |
+
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
|
| 24 |
+
"/System/Library/Fonts/Helvetica.ttc",
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
_FONT_SIZE_PX = 64
|
| 28 |
+
_PAD_PX = 6
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _load_font(size: int = _FONT_SIZE_PX) -> "ImageFont.ImageFont":
|
| 32 |
+
for path in _FONT_CANDIDATES:
|
| 33 |
+
try:
|
| 34 |
+
return ImageFont.truetype(path, size)
|
| 35 |
+
except OSError:
|
| 36 |
+
continue
|
| 37 |
+
logger.warning("No truetype font found; falling back to PIL's bitmap default font.")
|
| 38 |
+
return ImageFont.load_default()
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def render_printed_name_stamp(name: str, ink_color: tuple[int, int, int] = (15, 15, 20)) -> np.ndarray:
|
| 42 |
+
"""Render ``name`` as plain typed text, returned as a tightly-cropped RGBA stamp.
|
| 43 |
+
|
| 44 |
+
Args:
|
| 45 |
+
name: The printed/typed name to render (no handwriting stroke texture).
|
| 46 |
+
ink_color: Flat RGB color for the text (matches the pipeline's ink color pool).
|
| 47 |
+
|
| 48 |
+
Returns:
|
| 49 |
+
An ``(H, W, 4)`` uint8 RGBA array: opaque text on a transparent background.
|
| 50 |
+
"""
|
| 51 |
+
font = _load_font()
|
| 52 |
+
|
| 53 |
+
scratch = Image.new("RGBA", (8, 8), (0, 0, 0, 0))
|
| 54 |
+
scratch_draw = ImageDraw.Draw(scratch)
|
| 55 |
+
text_bbox = scratch_draw.textbbox((0, 0), name, font=font)
|
| 56 |
+
text_w = max(text_bbox[2] - text_bbox[0], 1)
|
| 57 |
+
text_h = max(text_bbox[3] - text_bbox[1], 1)
|
| 58 |
+
|
| 59 |
+
canvas_w, canvas_h = text_w + 2 * _PAD_PX, text_h + 2 * _PAD_PX
|
| 60 |
+
image = Image.new("RGBA", (canvas_w, canvas_h), (0, 0, 0, 0))
|
| 61 |
+
draw = ImageDraw.Draw(image)
|
| 62 |
+
draw.text((_PAD_PX - text_bbox[0], _PAD_PX - text_bbox[1]), name, font=font, fill=(*ink_color, 255))
|
| 63 |
+
|
| 64 |
+
return np.array(image, dtype=np.uint8)
|
src/rendering/renderer.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Composites the placed (and signature-augmented) RGBA signature layer onto the document.
|
| 2 |
+
|
| 3 |
+
Implements the blend modes and realism passes required by the spec: multiply
|
| 4 |
+
blend, darken blend, alpha blend, configurable ink opacity, feathering
|
| 5 |
+
(alpha-edge softening), a slight blur to match scan resolution, sensor/scan
|
| 6 |
+
noise, and anti-aliasing.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from dataclasses import dataclass
|
| 12 |
+
|
| 13 |
+
import cv2
|
| 14 |
+
import numpy as np
|
| 15 |
+
|
| 16 |
+
from src.config.schema import RenderingConfig
|
| 17 |
+
from src.utils.logging_utils import get_logger
|
| 18 |
+
from src.utils.random_utils import SeedManager, resolve_scalar_or_range
|
| 19 |
+
|
| 20 |
+
logger = get_logger(__name__)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@dataclass
|
| 24 |
+
class RenderResult:
|
| 25 |
+
"""The blended document image plus the resolved rendering parameters."""
|
| 26 |
+
|
| 27 |
+
image: np.ndarray
|
| 28 |
+
blend_mode: str
|
| 29 |
+
ink_opacity: float
|
| 30 |
+
blur_sigma: float
|
| 31 |
+
feather_px: float
|
| 32 |
+
scanner_noise: float
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class Renderer:
|
| 36 |
+
"""Blends a signature RGBA layer onto a document canvas with configurable realism.
|
| 37 |
+
|
| 38 |
+
Example:
|
| 39 |
+
>>> renderer = Renderer(cfg.rendering)
|
| 40 |
+
>>> result = renderer.blend(document_rgb, signature_layer_rgba, seed_manager)
|
| 41 |
+
"""
|
| 42 |
+
|
| 43 |
+
def __init__(self, config: RenderingConfig) -> None:
|
| 44 |
+
self.config = config
|
| 45 |
+
|
| 46 |
+
def blend(
|
| 47 |
+
self, document_rgb: np.ndarray, signature_layer_rgba: np.ndarray, seed_manager: SeedManager,
|
| 48 |
+
) -> RenderResult:
|
| 49 |
+
"""Blend the signature layer onto the document and return the composited image.
|
| 50 |
+
|
| 51 |
+
Args:
|
| 52 |
+
document_rgb: The scanned document canvas, ``(H, W, 3)`` uint8.
|
| 53 |
+
signature_layer_rgba: A full-canvas-sized RGBA layer (as produced
|
| 54 |
+
by :class:`~src.placement.placement_engine.PlacementEngine`,
|
| 55 |
+
after any signature-level augmentation), transparent outside
|
| 56 |
+
the signature.
|
| 57 |
+
seed_manager: Per-sample deterministic RNG provider.
|
| 58 |
+
|
| 59 |
+
Returns:
|
| 60 |
+
A :class:`RenderResult` with the composited uint8 RGB image and
|
| 61 |
+
the resolved rendering parameters (for metadata export).
|
| 62 |
+
"""
|
| 63 |
+
cfg = self.config
|
| 64 |
+
rng = seed_manager.numpy_rng("rendering")
|
| 65 |
+
|
| 66 |
+
ink_opacity = resolve_scalar_or_range(cfg.ink_opacity, rng)
|
| 67 |
+
blur_sigma = resolve_scalar_or_range(cfg.blur_sigma, rng)
|
| 68 |
+
feather_px = resolve_scalar_or_range(cfg.feather_px, rng)
|
| 69 |
+
scanner_noise = resolve_scalar_or_range(cfg.scanner_noise, rng)
|
| 70 |
+
|
| 71 |
+
layer = signature_layer_rgba.astype(np.float32)
|
| 72 |
+
if feather_px > 0:
|
| 73 |
+
layer[:, :, 3] = cv2.GaussianBlur(layer[:, :, 3], (0, 0), sigmaX=feather_px)
|
| 74 |
+
if blur_sigma > 0:
|
| 75 |
+
layer = cv2.GaussianBlur(layer, (0, 0), sigmaX=blur_sigma)
|
| 76 |
+
|
| 77 |
+
sig_rgb = layer[:, :, :3]
|
| 78 |
+
alpha = np.clip(layer[:, :, 3] / 255.0, 0.0, 1.0) * float(np.clip(ink_opacity, 0.0, 1.0))
|
| 79 |
+
alpha3 = alpha[:, :, None]
|
| 80 |
+
|
| 81 |
+
base = document_rgb.astype(np.float32)
|
| 82 |
+
|
| 83 |
+
if cfg.blend_mode == "multiply":
|
| 84 |
+
blended_pixel = base * (sig_rgb / 255.0)
|
| 85 |
+
elif cfg.blend_mode == "darken":
|
| 86 |
+
blended_pixel = np.minimum(base, sig_rgb)
|
| 87 |
+
else: # alpha
|
| 88 |
+
blended_pixel = sig_rgb
|
| 89 |
+
|
| 90 |
+
composited = base * (1 - alpha3) + blended_pixel * alpha3
|
| 91 |
+
|
| 92 |
+
if scanner_noise > 0:
|
| 93 |
+
noise = rng.normal(0.0, scanner_noise * 255.0, size=composited.shape).astype(np.float32)
|
| 94 |
+
composited += noise
|
| 95 |
+
|
| 96 |
+
if cfg.anti_alias:
|
| 97 |
+
composited = cv2.GaussianBlur(composited, (0, 0), sigmaX=0.3)
|
| 98 |
+
|
| 99 |
+
out = np.clip(composited, 0, 255).astype(np.uint8)
|
| 100 |
+
|
| 101 |
+
return RenderResult(
|
| 102 |
+
image=out, blend_mode=cfg.blend_mode, ink_opacity=round(ink_opacity, 4),
|
| 103 |
+
blur_sigma=round(blur_sigma, 4), feather_px=round(feather_px, 4),
|
| 104 |
+
scanner_noise=round(scanner_noise, 4),
|
| 105 |
+
)
|
src/rendering/signature_preprocessor.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Signature cleaning: white-background removal and alpha-mask extraction.
|
| 2 |
+
|
| 3 |
+
Public handwritten signature datasets are typically flat scans: dark ink
|
| 4 |
+
strokes on an off-white/paper background, with no alpha channel. This module
|
| 5 |
+
converts such a scan into a clean RGBA "stamp" -- background fully
|
| 6 |
+
transparent, ink strokes opaque-to-translucent according to how dark the
|
| 7 |
+
original pixel was (preserving natural stroke texture: thin/faint pen
|
| 8 |
+
lift-offs stay translucent, heavy strokes stay opaque) -- ready to be
|
| 9 |
+
transformed and composited onto a document by :class:`~src.placement.placement_engine.PlacementEngine`
|
| 10 |
+
and :class:`~src.rendering.renderer.Renderer`.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import cv2
|
| 16 |
+
import numpy as np
|
| 17 |
+
|
| 18 |
+
from src.utils.logging_utils import get_logger
|
| 19 |
+
|
| 20 |
+
logger = get_logger(__name__)
|
| 21 |
+
|
| 22 |
+
DEFAULT_INK_COLOR: tuple[int, int, int] = (18, 18, 24)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class SignaturePreprocessor:
|
| 26 |
+
"""Cleans a raw signature scan into a tightly-cropped, transparent RGBA stamp.
|
| 27 |
+
|
| 28 |
+
Example:
|
| 29 |
+
>>> pre = SignaturePreprocessor()
|
| 30 |
+
>>> rgba = pre.process(raw_signature_array)
|
| 31 |
+
"""
|
| 32 |
+
|
| 33 |
+
def __init__(
|
| 34 |
+
self,
|
| 35 |
+
ink_color: tuple[int, int, int] = DEFAULT_INK_COLOR,
|
| 36 |
+
min_component_area: int = 4,
|
| 37 |
+
pad_px: int = 6,
|
| 38 |
+
alpha_gamma: float = 0.85,
|
| 39 |
+
) -> None:
|
| 40 |
+
self.ink_color = ink_color
|
| 41 |
+
self.min_component_area = min_component_area
|
| 42 |
+
self.pad_px = pad_px
|
| 43 |
+
self.alpha_gamma = alpha_gamma
|
| 44 |
+
|
| 45 |
+
def process(self, raw_image: np.ndarray, ink_color: "tuple[int, int, int] | None" = None) -> np.ndarray:
|
| 46 |
+
"""Convert a raw signature scan into a clean, tightly-cropped RGBA stamp.
|
| 47 |
+
|
| 48 |
+
Args:
|
| 49 |
+
raw_image: The raw signature image as loaded (grayscale ``(H, W)``,
|
| 50 |
+
RGB ``(H, W, 3)``, or RGBA ``(H, W, 4)`` uint8).
|
| 51 |
+
ink_color: Optional override for the flat ink color used for the
|
| 52 |
+
RGB channels (alpha still carries the natural stroke texture).
|
| 53 |
+
|
| 54 |
+
Returns:
|
| 55 |
+
An ``(H', W', 4)`` uint8 RGBA array, cropped tightly to the ink
|
| 56 |
+
content with a small transparent pad.
|
| 57 |
+
"""
|
| 58 |
+
gray, existing_alpha = self._to_gray(raw_image)
|
| 59 |
+
gray = cv2.medianBlur(gray, 3)
|
| 60 |
+
alpha = self._compute_alpha(gray)
|
| 61 |
+
if existing_alpha is not None:
|
| 62 |
+
alpha = np.minimum(alpha, existing_alpha)
|
| 63 |
+
alpha = self._remove_speckles(alpha)
|
| 64 |
+
rgba = self._compose_rgba(alpha, ink_color or self.ink_color)
|
| 65 |
+
rgba = self._crop_to_content(rgba, pad=self.pad_px)
|
| 66 |
+
return rgba
|
| 67 |
+
|
| 68 |
+
@staticmethod
|
| 69 |
+
def _to_gray(raw_image: np.ndarray) -> tuple[np.ndarray, "np.ndarray | None"]:
|
| 70 |
+
if raw_image.ndim == 2:
|
| 71 |
+
return raw_image.copy(), None
|
| 72 |
+
if raw_image.shape[2] == 4:
|
| 73 |
+
rgb = raw_image[:, :, :3]
|
| 74 |
+
alpha = raw_image[:, :, 3]
|
| 75 |
+
gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY)
|
| 76 |
+
return gray, alpha
|
| 77 |
+
gray = cv2.cvtColor(raw_image[:, :, :3], cv2.COLOR_RGB2GRAY)
|
| 78 |
+
return gray, None
|
| 79 |
+
|
| 80 |
+
@staticmethod
|
| 81 |
+
def _compute_alpha(gray: np.ndarray) -> np.ndarray:
|
| 82 |
+
gray_f = gray.astype(np.float64)
|
| 83 |
+
background = float(np.percentile(gray_f, 92))
|
| 84 |
+
dark_pixels = gray_f[gray_f < background - 8]
|
| 85 |
+
dark_level = float(np.percentile(dark_pixels, 5)) if dark_pixels.size > 20 else max(background - 140, 0.0)
|
| 86 |
+
denom = max(background - dark_level, 1e-3)
|
| 87 |
+
|
| 88 |
+
raw = (background - gray_f) / denom
|
| 89 |
+
raw = np.clip(raw, 0.0, 1.0)
|
| 90 |
+
# Discard near-background noise (sub-threshold jitter from scanning artifacts).
|
| 91 |
+
raw[raw < 0.06] = 0.0
|
| 92 |
+
softened = raw**0.85
|
| 93 |
+
return (softened * 255.0).astype(np.uint8)
|
| 94 |
+
|
| 95 |
+
def _remove_speckles(self, alpha: np.ndarray) -> np.ndarray:
|
| 96 |
+
binary = (alpha > 15).astype(np.uint8)
|
| 97 |
+
n_labels, labels, stats, _ = cv2.connectedComponentsWithStats(binary, connectivity=8)
|
| 98 |
+
if n_labels <= 1:
|
| 99 |
+
return alpha
|
| 100 |
+
cleaned = alpha.copy()
|
| 101 |
+
for label in range(1, n_labels):
|
| 102 |
+
area = stats[label, cv2.CC_STAT_AREA]
|
| 103 |
+
if area < self.min_component_area:
|
| 104 |
+
cleaned[labels == label] = 0
|
| 105 |
+
return cleaned
|
| 106 |
+
|
| 107 |
+
@staticmethod
|
| 108 |
+
def _compose_rgba(alpha: np.ndarray, ink_color: tuple[int, int, int]) -> np.ndarray:
|
| 109 |
+
h, w = alpha.shape
|
| 110 |
+
rgb = np.empty((h, w, 3), dtype=np.uint8)
|
| 111 |
+
rgb[:, :] = ink_color
|
| 112 |
+
return np.dstack([rgb, alpha])
|
| 113 |
+
|
| 114 |
+
@staticmethod
|
| 115 |
+
def _crop_to_content(rgba: np.ndarray, pad: int) -> np.ndarray:
|
| 116 |
+
alpha = rgba[:, :, 3]
|
| 117 |
+
ys, xs = np.where(alpha > 4)
|
| 118 |
+
if ys.size == 0:
|
| 119 |
+
logger.warning("Signature preprocessing found no ink pixels; returning a 1x1 transparent stamp.")
|
| 120 |
+
return np.zeros((1, 1, 4), dtype=np.uint8)
|
| 121 |
+
y1, y2 = max(ys.min() - pad, 0), min(ys.max() + pad + 1, rgba.shape[0])
|
| 122 |
+
x1, x2 = max(xs.min() - pad, 0), min(xs.max() + pad + 1, rgba.shape[1])
|
| 123 |
+
return rgba[y1:y2, x1:x2].copy()
|
src/signatures/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Signature dataset discovery and sampling."""
|
src/signatures/signature_dataset.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Recursive discovery of the public handwritten signature dataset.
|
| 2 |
+
|
| 3 |
+
Scans ``dataset/`` recursively for signature images (PNG/JPG/JPEG/TIFF/BMP),
|
| 4 |
+
building an internal database without requiring any particular naming
|
| 5 |
+
convention or folder layout. Two conventions are detected automatically
|
| 6 |
+
when present, but neither is required:
|
| 7 |
+
|
| 8 |
+
* ``dataset/metadata.csv`` -- if present, associates files with a signer
|
| 9 |
+
identity via a ``filename``/``path`` column plus a ``signer``/``name``
|
| 10 |
+
column, and optionally a ``source_type`` and ``script`` column (the
|
| 11 |
+
handwriting script the signature is written in, e.g. ``hindi``,
|
| 12 |
+
``bengali``, ``latin`` -- see ``dataset/hindi_bengali/`` for the bundled
|
| 13 |
+
multi-script signer pool).
|
| 14 |
+
* Filenames matching CEDAR-style ``original_<signer>_<sample>.png`` /
|
| 15 |
+
``forgeries_<signer>_<sample>.png`` (as found in the bundled dataset) --
|
| 16 |
+
used to recover a signer group id and genuine/forged label even without
|
| 17 |
+
a metadata file.
|
| 18 |
+
|
| 19 |
+
Otherwise, each signature is assigned an automatically generated id and its
|
| 20 |
+
immediate parent folder name is used as its signer group (matching the
|
| 21 |
+
``dataset/personNNN/*.png`` layout described in the project spec).
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
from __future__ import annotations
|
| 25 |
+
|
| 26 |
+
import csv
|
| 27 |
+
import re
|
| 28 |
+
from dataclasses import dataclass
|
| 29 |
+
from pathlib import Path
|
| 30 |
+
|
| 31 |
+
import numpy as np
|
| 32 |
+
|
| 33 |
+
from src.utils.image_io import discover_images
|
| 34 |
+
from src.utils.logging_utils import get_logger
|
| 35 |
+
|
| 36 |
+
logger = get_logger(__name__)
|
| 37 |
+
|
| 38 |
+
_CEDAR_PATTERN = re.compile(r"^(?P<prefix>[a-zA-Z]+)_(?P<signer>\d+)_(?P<sample>\d+)$")
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
@dataclass(frozen=True)
|
| 42 |
+
class SignatureRecord:
|
| 43 |
+
"""One discovered signature image and its inferred identity metadata."""
|
| 44 |
+
|
| 45 |
+
signature_id: str
|
| 46 |
+
path: Path
|
| 47 |
+
signer_group: str
|
| 48 |
+
source_type: str # "genuine" | "forged" | "unknown"
|
| 49 |
+
script: str = "latin" # handwriting script, e.g. "latin" | "hindi" | "bengali"
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class SignatureDataset:
|
| 53 |
+
"""Discovers and indexes the signature image dataset for deterministic sampling.
|
| 54 |
+
|
| 55 |
+
Example:
|
| 56 |
+
>>> ds = SignatureDataset("dataset")
|
| 57 |
+
>>> ds.discover()
|
| 58 |
+
>>> record = ds.sample(rng, source_filter="genuine")
|
| 59 |
+
"""
|
| 60 |
+
|
| 61 |
+
def __init__(self, dataset_dir: "str | Path") -> None:
|
| 62 |
+
self.dataset_dir = Path(dataset_dir)
|
| 63 |
+
self._records: "list[SignatureRecord] | None" = None
|
| 64 |
+
self._by_id: "dict[str, SignatureRecord] | None" = None
|
| 65 |
+
|
| 66 |
+
def discover(self) -> list[SignatureRecord]:
|
| 67 |
+
"""Recursively discover all signature images and build the internal database."""
|
| 68 |
+
if self._records is not None:
|
| 69 |
+
return self._records
|
| 70 |
+
|
| 71 |
+
paths = discover_images(self.dataset_dir)
|
| 72 |
+
if not paths:
|
| 73 |
+
raise FileNotFoundError(f"No signature images found under '{self.dataset_dir}'.")
|
| 74 |
+
|
| 75 |
+
metadata_map = self._load_metadata_csv()
|
| 76 |
+
|
| 77 |
+
records: list[SignatureRecord] = []
|
| 78 |
+
for idx, path in enumerate(paths):
|
| 79 |
+
rel = path.relative_to(self.dataset_dir)
|
| 80 |
+
stem = path.stem
|
| 81 |
+
|
| 82 |
+
meta = metadata_map.get(rel.as_posix()) or metadata_map.get(path.name)
|
| 83 |
+
signer_group = None
|
| 84 |
+
source_type = "unknown"
|
| 85 |
+
|
| 86 |
+
match = _CEDAR_PATTERN.match(stem)
|
| 87 |
+
if match:
|
| 88 |
+
signer_group = f"signer_{int(match.group('signer')):04d}"
|
| 89 |
+
prefix = match.group("prefix").lower()
|
| 90 |
+
if "orig" in prefix or "genuine" in prefix:
|
| 91 |
+
source_type = "genuine"
|
| 92 |
+
elif "forg" in prefix:
|
| 93 |
+
source_type = "forged"
|
| 94 |
+
|
| 95 |
+
if meta and meta.get("signer"):
|
| 96 |
+
signer_group = meta["signer"]
|
| 97 |
+
if meta and meta.get("source_type"):
|
| 98 |
+
source_type = meta["source_type"]
|
| 99 |
+
|
| 100 |
+
if signer_group is None:
|
| 101 |
+
signer_group = rel.parent.as_posix() if rel.parent != Path(".") else "unassigned"
|
| 102 |
+
|
| 103 |
+
if source_type == "unknown":
|
| 104 |
+
lowered = rel.as_posix().lower()
|
| 105 |
+
if "genuine" in lowered or "original" in lowered:
|
| 106 |
+
source_type = "genuine"
|
| 107 |
+
elif "forg" in lowered:
|
| 108 |
+
source_type = "forged"
|
| 109 |
+
|
| 110 |
+
signature_id = meta.get("signature_id") if meta else None
|
| 111 |
+
if not signature_id:
|
| 112 |
+
signature_id = f"sig_{idx:06d}"
|
| 113 |
+
|
| 114 |
+
script = (meta.get("script") if meta else None) or "latin"
|
| 115 |
+
|
| 116 |
+
records.append(SignatureRecord(signature_id, path, signer_group, source_type, script))
|
| 117 |
+
|
| 118 |
+
self._records = records
|
| 119 |
+
self._by_id = {r.signature_id: r for r in records}
|
| 120 |
+
n_genuine = sum(1 for r in records if r.source_type == "genuine")
|
| 121 |
+
n_forged = sum(1 for r in records if r.source_type == "forged")
|
| 122 |
+
n_groups = len({r.signer_group for r in records})
|
| 123 |
+
logger.info(
|
| 124 |
+
"Discovered %d signature images (%d genuine, %d forged, %d unknown) across %d signer group(s).",
|
| 125 |
+
len(records), n_genuine, n_forged, len(records) - n_genuine - n_forged, n_groups,
|
| 126 |
+
)
|
| 127 |
+
return records
|
| 128 |
+
|
| 129 |
+
def _load_metadata_csv(self) -> dict:
|
| 130 |
+
csv_path = self.dataset_dir / "metadata.csv"
|
| 131 |
+
if not csv_path.exists():
|
| 132 |
+
return {}
|
| 133 |
+
mapping: dict = {}
|
| 134 |
+
with csv_path.open("r", encoding="utf-8", newline="") as f:
|
| 135 |
+
reader = csv.DictReader(f)
|
| 136 |
+
for row in reader:
|
| 137 |
+
key = row.get("path") or row.get("filename") or row.get("file")
|
| 138 |
+
if not key:
|
| 139 |
+
continue
|
| 140 |
+
normalized = {k.strip().lower(): (v.strip() if v else v) for k, v in row.items()}
|
| 141 |
+
signer = normalized.get("signer") or normalized.get("name") or normalized.get("signer_name")
|
| 142 |
+
mapping[key] = {
|
| 143 |
+
"signer": signer,
|
| 144 |
+
"source_type": normalized.get("source_type") or normalized.get("type"),
|
| 145 |
+
"signature_id": normalized.get("signature_id") or normalized.get("id"),
|
| 146 |
+
"script": normalized.get("script"),
|
| 147 |
+
}
|
| 148 |
+
if mapping:
|
| 149 |
+
logger.info("Loaded signature metadata for %d entries from %s", len(mapping), csv_path)
|
| 150 |
+
return mapping
|
| 151 |
+
|
| 152 |
+
def get(self, signature_id: str) -> SignatureRecord:
|
| 153 |
+
"""Return a signature record by id."""
|
| 154 |
+
self.discover()
|
| 155 |
+
if signature_id not in self._by_id:
|
| 156 |
+
raise KeyError(f"Unknown signature id '{signature_id}'.")
|
| 157 |
+
return self._by_id[signature_id]
|
| 158 |
+
|
| 159 |
+
def filtered_records(self, source_filter: str = "any") -> list[SignatureRecord]:
|
| 160 |
+
"""Return records matching ``source_filter`` ("genuine" | "forged" | "any")."""
|
| 161 |
+
records = self.discover()
|
| 162 |
+
if source_filter == "any":
|
| 163 |
+
return records
|
| 164 |
+
filtered = [r for r in records if r.source_type == source_filter]
|
| 165 |
+
if not filtered:
|
| 166 |
+
logger.warning(
|
| 167 |
+
"No signatures match source_filter='%s'; falling back to the full dataset.",
|
| 168 |
+
source_filter,
|
| 169 |
+
)
|
| 170 |
+
return records
|
| 171 |
+
return filtered
|
| 172 |
+
|
| 173 |
+
def sample(self, rng: np.random.Generator, source_filter: str = "genuine") -> SignatureRecord:
|
| 174 |
+
"""Deterministically sample one signature record using the given seeded generator."""
|
| 175 |
+
pool = self.filtered_records(source_filter)
|
| 176 |
+
idx = int(rng.integers(0, len(pool)))
|
| 177 |
+
return pool[idx]
|
| 178 |
+
|
| 179 |
+
def signer_groups(self) -> list[str]:
|
| 180 |
+
"""Return every distinct signer group id, sorted for deterministic indexing."""
|
| 181 |
+
return sorted({r.signer_group for r in self.discover()})
|
| 182 |
+
|
| 183 |
+
def records_for_group(self, signer_group: str, source_filter: str = "any") -> list[SignatureRecord]:
|
| 184 |
+
"""Return records belonging to one signer group, optionally filtered by source type."""
|
| 185 |
+
records = [r for r in self.discover() if r.signer_group == signer_group]
|
| 186 |
+
if source_filter == "any":
|
| 187 |
+
return records
|
| 188 |
+
filtered = [r for r in records if r.source_type == source_filter]
|
| 189 |
+
if not filtered:
|
| 190 |
+
logger.warning(
|
| 191 |
+
"Signer group '%s' has no '%s' records; falling back to all its records.",
|
| 192 |
+
signer_group, source_filter,
|
| 193 |
+
)
|
| 194 |
+
return records
|
| 195 |
+
return filtered
|
| 196 |
+
|
| 197 |
+
def sample_from_group(
|
| 198 |
+
self, rng: np.random.Generator, signer_group: str, source_filter: str = "genuine",
|
| 199 |
+
) -> SignatureRecord:
|
| 200 |
+
"""Deterministically sample one signature record from a specific signer group."""
|
| 201 |
+
pool = self.records_for_group(signer_group, source_filter)
|
| 202 |
+
idx = int(rng.integers(0, len(pool)))
|
| 203 |
+
return pool[idx]
|
| 204 |
+
|
| 205 |
+
def script_for_group(self, signer_group: str) -> str:
|
| 206 |
+
"""Return the handwriting script associated with a signer group (all its records share one)."""
|
| 207 |
+
records = self.records_for_group(signer_group, source_filter="any")
|
| 208 |
+
return records[0].script if records else "latin"
|
src/templates/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Template discovery, annotation loading, and CV-based signature box detection."""
|
src/templates/annotation_schema.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pydantic schema for template annotation files (``annotations/<template_id>.json``)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class SignatureBoxAnnotation(BaseModel):
|
| 9 |
+
"""A single signature field on a page, in pixel coordinates of the template image."""
|
| 10 |
+
|
| 11 |
+
model_config = ConfigDict(extra="forbid")
|
| 12 |
+
|
| 13 |
+
id: str
|
| 14 |
+
x1: float
|
| 15 |
+
y1: float
|
| 16 |
+
x2: float
|
| 17 |
+
y2: float
|
| 18 |
+
role: str = "Signer"
|
| 19 |
+
name: str = "Signer"
|
| 20 |
+
|
| 21 |
+
@model_validator(mode="after")
|
| 22 |
+
def _normalize_box(self) -> "SignatureBoxAnnotation":
|
| 23 |
+
x1, x2 = sorted((self.x1, self.x2))
|
| 24 |
+
y1, y2 = sorted((self.y1, self.y2))
|
| 25 |
+
self.x1, self.x2, self.y1, self.y2 = x1, x2, y1, y2
|
| 26 |
+
if x2 <= x1 or y2 <= y1:
|
| 27 |
+
raise ValueError(f"Signature box '{self.id}' has non-positive area: {self.as_tuple()}")
|
| 28 |
+
return self
|
| 29 |
+
|
| 30 |
+
def as_tuple(self) -> tuple[float, float, float, float]:
|
| 31 |
+
"""Return the box as ``(x1, y1, x2, y2)``."""
|
| 32 |
+
return (self.x1, self.y1, self.x2, self.y2)
|
| 33 |
+
|
| 34 |
+
@property
|
| 35 |
+
def width(self) -> float:
|
| 36 |
+
return self.x2 - self.x1
|
| 37 |
+
|
| 38 |
+
@property
|
| 39 |
+
def height(self) -> float:
|
| 40 |
+
return self.y2 - self.y1
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class TemplatePageAnnotation(BaseModel):
|
| 44 |
+
"""A single page of a template document."""
|
| 45 |
+
|
| 46 |
+
model_config = ConfigDict(extra="forbid")
|
| 47 |
+
|
| 48 |
+
page: int = 1
|
| 49 |
+
page_size: tuple[int, int]
|
| 50 |
+
dpi: int = 300
|
| 51 |
+
signature_boxes: list[SignatureBoxAnnotation] = Field(default_factory=list)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class TemplateAnnotation(BaseModel):
|
| 55 |
+
"""Full annotation for one template document (possibly multi-page)."""
|
| 56 |
+
|
| 57 |
+
model_config = ConfigDict(extra="forbid")
|
| 58 |
+
|
| 59 |
+
id: str
|
| 60 |
+
source_image: "str | None" = None
|
| 61 |
+
document_type: "str | None" = None
|
| 62 |
+
pages: list[TemplatePageAnnotation]
|
| 63 |
+
auto_detected: bool = False
|
| 64 |
+
|
| 65 |
+
def page_by_number(self, page: int) -> TemplatePageAnnotation:
|
| 66 |
+
for p in self.pages:
|
| 67 |
+
if p.page == page:
|
| 68 |
+
return p
|
| 69 |
+
raise KeyError(f"Template '{self.id}' has no page {page}")
|
src/templates/box_detector.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Mode 1: automatic signature-field detection via classical computer vision.
|
| 2 |
+
|
| 3 |
+
Bank document signature fields are almost always denoted by a horizontal
|
| 4 |
+
"sign here" rule (an underscore/line) with a caption below it ("Signature",
|
| 5 |
+
"Authorized Signatory", ...). This detector isolates long, thin horizontal
|
| 6 |
+
line segments via morphological opening, filters them by geometry, and
|
| 7 |
+
places a signature box directly above each surviving line -- the same
|
| 8 |
+
region a human would use to place a pen.
|
| 9 |
+
|
| 10 |
+
This is a heuristic, not a learned layout model; it is intentionally used
|
| 11 |
+
only as a *fallback* when no manual annotation file exists (Mode 2 is
|
| 12 |
+
preferred whenever available, see :class:`~src.templates.template_loader.TemplateLoader`).
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
from dataclasses import dataclass
|
| 18 |
+
|
| 19 |
+
import cv2
|
| 20 |
+
import numpy as np
|
| 21 |
+
|
| 22 |
+
from src.utils.logging_utils import get_logger
|
| 23 |
+
|
| 24 |
+
logger = get_logger(__name__)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@dataclass
|
| 28 |
+
class DetectedLine:
|
| 29 |
+
x: int
|
| 30 |
+
y: int
|
| 31 |
+
w: int
|
| 32 |
+
h: int
|
| 33 |
+
|
| 34 |
+
@property
|
| 35 |
+
def aspect_ratio(self) -> float:
|
| 36 |
+
return self.w / max(self.h, 1)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _binarize(gray: np.ndarray) -> np.ndarray:
|
| 40 |
+
"""Return a binary ink mask (255 = ink) using adaptive thresholding."""
|
| 41 |
+
blurred = cv2.GaussianBlur(gray, (3, 3), 0)
|
| 42 |
+
binary = cv2.adaptiveThreshold(
|
| 43 |
+
blurred, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 25, 10,
|
| 44 |
+
)
|
| 45 |
+
return binary
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _detect_horizontal_lines(binary: np.ndarray, min_width_frac: float = 0.08) -> list[DetectedLine]:
|
| 49 |
+
"""Detect long horizontal rule segments (signature/blank-field underscores)."""
|
| 50 |
+
h, w = binary.shape[:2]
|
| 51 |
+
kernel_w = max(int(w * min_width_frac), 15)
|
| 52 |
+
horiz_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (kernel_w, 1))
|
| 53 |
+
opened = cv2.morphologyEx(binary, cv2.MORPH_OPEN, horiz_kernel, iterations=1)
|
| 54 |
+
opened = cv2.dilate(opened, cv2.getStructuringElement(cv2.MORPH_RECT, (3, 1)), iterations=1)
|
| 55 |
+
|
| 56 |
+
contours, _ = cv2.findContours(opened, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
| 57 |
+
lines: list[DetectedLine] = []
|
| 58 |
+
for c in contours:
|
| 59 |
+
x, y, cw, ch = cv2.boundingRect(c)
|
| 60 |
+
line = DetectedLine(x, y, cw, ch)
|
| 61 |
+
if line.w < w * min_width_frac:
|
| 62 |
+
continue
|
| 63 |
+
if line.aspect_ratio < 6.0:
|
| 64 |
+
continue
|
| 65 |
+
if line.h > 12:
|
| 66 |
+
continue
|
| 67 |
+
lines.append(line)
|
| 68 |
+
return lines
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _merge_nearby_lines(lines: list[DetectedLine], y_tol: int = 8) -> list[DetectedLine]:
|
| 72 |
+
"""Merge line fragments that are on the same visual row into one segment."""
|
| 73 |
+
if not lines:
|
| 74 |
+
return []
|
| 75 |
+
lines = sorted(lines, key=lambda l: (l.y, l.x))
|
| 76 |
+
merged: list[DetectedLine] = [lines[0]]
|
| 77 |
+
for line in lines[1:]:
|
| 78 |
+
last = merged[-1]
|
| 79 |
+
if abs(line.y - last.y) <= y_tol and line.x <= last.x + last.w + 40:
|
| 80 |
+
new_x2 = max(last.x + last.w, line.x + line.w)
|
| 81 |
+
merged[-1] = DetectedLine(last.x, min(last.y, line.y), new_x2 - last.x, max(last.h, line.h))
|
| 82 |
+
else:
|
| 83 |
+
merged.append(line)
|
| 84 |
+
return merged
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def detect_signature_boxes(
|
| 88 |
+
image_rgb: np.ndarray,
|
| 89 |
+
max_boxes: int = 4,
|
| 90 |
+
box_height_frac: float = 0.55,
|
| 91 |
+
) -> list[tuple[float, float, float, float]]:
|
| 92 |
+
"""Detect likely signature box regions above horizontal rule lines.
|
| 93 |
+
|
| 94 |
+
Args:
|
| 95 |
+
image_rgb: The template page as an ``(H, W, 3)`` uint8 array.
|
| 96 |
+
max_boxes: Maximum number of candidate boxes to return (keeps the
|
| 97 |
+
widest/most line-like candidates, which correlate with genuine
|
| 98 |
+
signature/date fields rather than table gridlines).
|
| 99 |
+
box_height_frac: Height of the returned box above the line, as a
|
| 100 |
+
fraction of the line's width (wider lines get proportionally
|
| 101 |
+
taller signature boxes, capped below).
|
| 102 |
+
|
| 103 |
+
Returns:
|
| 104 |
+
A list of ``(x1, y1, x2, y2)`` boxes in image pixel coordinates,
|
| 105 |
+
sorted left-to-right, top-to-bottom. Empty if nothing was detected.
|
| 106 |
+
"""
|
| 107 |
+
h, w = image_rgb.shape[:2]
|
| 108 |
+
gray = cv2.cvtColor(image_rgb, cv2.COLOR_RGB2GRAY)
|
| 109 |
+
binary = _binarize(gray)
|
| 110 |
+
lines = _detect_horizontal_lines(binary)
|
| 111 |
+
lines = _merge_nearby_lines(lines)
|
| 112 |
+
|
| 113 |
+
if not lines:
|
| 114 |
+
logger.warning("Automatic signature box detection found no candidate lines.")
|
| 115 |
+
return []
|
| 116 |
+
|
| 117 |
+
lines = sorted(lines, key=lambda l: l.w, reverse=True)[: max_boxes * 2]
|
| 118 |
+
|
| 119 |
+
boxes: list[tuple[float, float, float, float]] = []
|
| 120 |
+
for line in lines:
|
| 121 |
+
box_h = min(max(int(line.w * box_height_frac * 0.25), 20), 80)
|
| 122 |
+
x1 = float(max(line.x, 0))
|
| 123 |
+
x2 = float(min(line.x + line.w, w))
|
| 124 |
+
y2 = float(line.y)
|
| 125 |
+
y1 = float(max(line.y - box_h, 0))
|
| 126 |
+
if (x2 - x1) < 20 or (y2 - y1) < 8:
|
| 127 |
+
continue
|
| 128 |
+
boxes.append((x1, y1, x2, y2))
|
| 129 |
+
|
| 130 |
+
boxes = boxes[:max_boxes]
|
| 131 |
+
boxes.sort(key=lambda b: (round(b[1] / 50.0), b[0]))
|
| 132 |
+
logger.info("Automatic detection found %d candidate signature box(es).", len(boxes))
|
| 133 |
+
return boxes
|
src/templates/template_loader.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Template discovery and annotation resolution.
|
| 2 |
+
|
| 3 |
+
Scans ``image/`` recursively for scanned bank document templates, assigns
|
| 4 |
+
each a stable ``template_id`` (``bank_doc_01``, ``bank_doc_02``, ...) based
|
| 5 |
+
on a deterministic sort of their paths, and resolves signature-box
|
| 6 |
+
annotations for each using, in order of preference:
|
| 7 |
+
|
| 8 |
+
1. **Mode 2 (preferred when present):** a manually authored
|
| 9 |
+
``annotations/<template_id>.json`` file.
|
| 10 |
+
2. **Mode 1 (fallback):** automatic CV-based detection
|
| 11 |
+
(:mod:`src.templates.box_detector`), whose result is cached to
|
| 12 |
+
``annotations/<template_id>.json`` so subsequent runs reuse it without
|
| 13 |
+
re-running detection.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import json
|
| 19 |
+
from dataclasses import dataclass
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
|
| 22 |
+
import numpy as np
|
| 23 |
+
|
| 24 |
+
from src.templates.annotation_schema import TemplateAnnotation, TemplatePageAnnotation, SignatureBoxAnnotation
|
| 25 |
+
from src.templates.box_detector import detect_signature_boxes
|
| 26 |
+
from src.utils.image_io import discover_images, load_image_rgb
|
| 27 |
+
from src.utils.logging_utils import get_logger
|
| 28 |
+
|
| 29 |
+
logger = get_logger(__name__)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@dataclass
|
| 33 |
+
class Template:
|
| 34 |
+
"""A discovered template document: its image path and resolved annotation."""
|
| 35 |
+
|
| 36 |
+
template_id: str
|
| 37 |
+
image_path: Path
|
| 38 |
+
annotation: TemplateAnnotation
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class TemplateLoader:
|
| 42 |
+
"""Discovers bank document templates and resolves their signature-box annotations.
|
| 43 |
+
|
| 44 |
+
Example:
|
| 45 |
+
>>> loader = TemplateLoader(image_dir="image", annotations_dir="annotations")
|
| 46 |
+
>>> templates = loader.discover()
|
| 47 |
+
>>> tmpl = loader.get("bank_doc_01")
|
| 48 |
+
"""
|
| 49 |
+
|
| 50 |
+
def __init__(self, image_dir: "str | Path", annotations_dir: "str | Path") -> None:
|
| 51 |
+
self.image_dir = Path(image_dir)
|
| 52 |
+
self.annotations_dir = Path(annotations_dir)
|
| 53 |
+
self.annotations_dir.mkdir(parents=True, exist_ok=True)
|
| 54 |
+
self._templates: "dict[str, Template] | None" = None
|
| 55 |
+
|
| 56 |
+
def discover(self) -> "dict[str, Template]":
|
| 57 |
+
"""Discover all templates under ``image_dir`` and resolve their annotations.
|
| 58 |
+
|
| 59 |
+
Returns:
|
| 60 |
+
An ordered mapping of ``template_id -> Template``, sorted by id.
|
| 61 |
+
"""
|
| 62 |
+
if self._templates is not None:
|
| 63 |
+
return self._templates
|
| 64 |
+
|
| 65 |
+
image_paths = discover_images(self.image_dir)
|
| 66 |
+
if not image_paths:
|
| 67 |
+
raise FileNotFoundError(
|
| 68 |
+
f"No supported template images found under '{self.image_dir}'. "
|
| 69 |
+
"Expected PNG/JPG/JPEG/TIFF/BMP files."
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
templates: "dict[str, Template]" = {}
|
| 73 |
+
for idx, image_path in enumerate(image_paths, start=1):
|
| 74 |
+
template_id = f"bank_doc_{idx:02d}"
|
| 75 |
+
annotation = self._resolve_annotation(template_id, image_path)
|
| 76 |
+
templates[template_id] = Template(template_id, image_path, annotation)
|
| 77 |
+
logger.info(
|
| 78 |
+
"Registered template '%s' -> %s (%d signature box(es), auto_detected=%s)",
|
| 79 |
+
template_id, image_path, sum(len(p.signature_boxes) for p in annotation.pages),
|
| 80 |
+
annotation.auto_detected,
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
self._templates = templates
|
| 84 |
+
return templates
|
| 85 |
+
|
| 86 |
+
def get(self, template_id: str) -> Template:
|
| 87 |
+
"""Return a discovered template by id, raising ``KeyError`` if unknown."""
|
| 88 |
+
templates = self.discover()
|
| 89 |
+
if template_id not in templates:
|
| 90 |
+
raise KeyError(f"Unknown template id '{template_id}'. Known: {sorted(templates)}")
|
| 91 |
+
return templates[template_id]
|
| 92 |
+
|
| 93 |
+
def list_ids(self) -> list[str]:
|
| 94 |
+
"""Return all discovered template ids, sorted."""
|
| 95 |
+
return sorted(self.discover().keys())
|
| 96 |
+
|
| 97 |
+
def random_template(self, rng: np.random.Generator) -> Template:
|
| 98 |
+
"""Pick a uniformly random template using the given seeded generator."""
|
| 99 |
+
ids = self.list_ids()
|
| 100 |
+
choice = ids[int(rng.integers(0, len(ids)))]
|
| 101 |
+
return self.get(choice)
|
| 102 |
+
|
| 103 |
+
def _resolve_annotation(self, template_id: str, image_path: Path) -> TemplateAnnotation:
|
| 104 |
+
annotation_path = self.annotations_dir / f"{template_id}.json"
|
| 105 |
+
if annotation_path.exists():
|
| 106 |
+
data = json.loads(annotation_path.read_text(encoding="utf-8"))
|
| 107 |
+
return TemplateAnnotation.model_validate(data)
|
| 108 |
+
|
| 109 |
+
logger.warning(
|
| 110 |
+
"No manual annotation found for '%s'; falling back to automatic CV detection.",
|
| 111 |
+
template_id,
|
| 112 |
+
)
|
| 113 |
+
annotation = self._auto_detect_annotation(template_id, image_path)
|
| 114 |
+
annotation_path.write_text(
|
| 115 |
+
json.dumps(annotation.model_dump(), indent=2), encoding="utf-8"
|
| 116 |
+
)
|
| 117 |
+
logger.info("Cached auto-detected annotation to %s", annotation_path)
|
| 118 |
+
return annotation
|
| 119 |
+
|
| 120 |
+
def _auto_detect_annotation(self, template_id: str, image_path: Path) -> TemplateAnnotation:
|
| 121 |
+
image = load_image_rgb(image_path)
|
| 122 |
+
h, w = image.shape[:2]
|
| 123 |
+
boxes = detect_signature_boxes(image)
|
| 124 |
+
|
| 125 |
+
if not boxes:
|
| 126 |
+
# Last-resort fallback: a generic box in the lower-right quadrant,
|
| 127 |
+
# where signature fields conventionally sit on Western bank forms.
|
| 128 |
+
boxes = [(w * 0.55, h * 0.80, w * 0.92, h * 0.90)]
|
| 129 |
+
logger.warning(
|
| 130 |
+
"'%s': no lines detected; using generic lower-right fallback box.", template_id
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
signature_boxes = [
|
| 134 |
+
SignatureBoxAnnotation(
|
| 135 |
+
id=f"sig{i + 1}", x1=x1, y1=y1, x2=x2, y2=y2, role="Signer", name="Signer",
|
| 136 |
+
)
|
| 137 |
+
for i, (x1, y1, x2, y2) in enumerate(boxes)
|
| 138 |
+
]
|
| 139 |
+
page = TemplatePageAnnotation(page=1, page_size=(w, h), dpi=300, signature_boxes=signature_boxes)
|
| 140 |
+
return TemplateAnnotation(
|
| 141 |
+
id=template_id,
|
| 142 |
+
source_image=str(image_path.as_posix()),
|
| 143 |
+
document_type="unknown",
|
| 144 |
+
pages=[page],
|
| 145 |
+
auto_detected=True,
|
| 146 |
+
)
|
src/utils/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Shared utilities: logging, deterministic RNG management, geometry helpers, I/O, name generation."""
|
src/utils/geometry_utils.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Geometry helper functions built on Shapely and NumPy.
|
| 2 |
+
|
| 3 |
+
Centralizes every polygon/box computation used by placement and analysis so
|
| 4 |
+
that the same, well-tested math backs both "where should the signature go"
|
| 5 |
+
and "how well did it land" — avoiding subtle mismatches between the two.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from typing import Sequence
|
| 11 |
+
|
| 12 |
+
import numpy as np
|
| 13 |
+
from shapely.geometry import Polygon, box as shapely_box
|
| 14 |
+
from shapely.validation import make_valid
|
| 15 |
+
|
| 16 |
+
Box = tuple[float, float, float, float] # x1, y1, x2, y2
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def box_to_polygon(box_coords: Box) -> Polygon:
|
| 20 |
+
"""Convert an ``(x1, y1, x2, y2)`` axis-aligned box into a Shapely polygon."""
|
| 21 |
+
x1, y1, x2, y2 = box_coords
|
| 22 |
+
return shapely_box(min(x1, x2), min(y1, y2), max(x1, x2), max(y1, y2))
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def points_to_polygon(points: Sequence[Sequence[float]]) -> Polygon:
|
| 26 |
+
"""Build a valid Shapely polygon from an ordered list of ``(x, y)`` points.
|
| 27 |
+
|
| 28 |
+
Degenerate inputs (fewer than 3 points, zero area) are expanded into a
|
| 29 |
+
minimal valid polygon by buffering, so downstream geometry ops never
|
| 30 |
+
raise on edge cases like a single-pixel detected signature.
|
| 31 |
+
"""
|
| 32 |
+
pts = np.asarray(points, dtype=float)
|
| 33 |
+
if len(pts) < 3:
|
| 34 |
+
if len(pts) == 0:
|
| 35 |
+
return Polygon()
|
| 36 |
+
center = pts.mean(axis=0)
|
| 37 |
+
return shapely_box(center[0] - 0.5, center[1] - 0.5, center[0] + 0.5, center[1] + 0.5)
|
| 38 |
+
poly = Polygon(pts)
|
| 39 |
+
if not poly.is_valid:
|
| 40 |
+
poly = make_valid(poly)
|
| 41 |
+
if poly.geom_type != "Polygon":
|
| 42 |
+
poly = poly.convex_hull
|
| 43 |
+
if poly.area <= 0:
|
| 44 |
+
poly = poly.buffer(0.5)
|
| 45 |
+
return poly
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def safe_area(poly: Polygon) -> float:
|
| 49 |
+
"""Return polygon area, guarding against ``None``/empty geometries."""
|
| 50 |
+
if poly is None or poly.is_empty:
|
| 51 |
+
return 0.0
|
| 52 |
+
return float(poly.area)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def intersection_area(poly_a: Polygon, poly_b: Polygon) -> float:
|
| 56 |
+
"""Return the intersection area of two polygons, 0 if either is empty/invalid."""
|
| 57 |
+
if poly_a is None or poly_b is None or poly_a.is_empty or poly_b.is_empty:
|
| 58 |
+
return 0.0
|
| 59 |
+
if not poly_a.is_valid:
|
| 60 |
+
poly_a = make_valid(poly_a)
|
| 61 |
+
if not poly_b.is_valid:
|
| 62 |
+
poly_b = make_valid(poly_b)
|
| 63 |
+
try:
|
| 64 |
+
return float(poly_a.intersection(poly_b).area)
|
| 65 |
+
except Exception:
|
| 66 |
+
return 0.0
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def iou(poly_a: Polygon, poly_b: Polygon) -> float:
|
| 70 |
+
"""Intersection-over-Union of two polygons."""
|
| 71 |
+
inter = intersection_area(poly_a, poly_b)
|
| 72 |
+
union = safe_area(poly_a) + safe_area(poly_b) - inter
|
| 73 |
+
if union <= 0:
|
| 74 |
+
return 0.0
|
| 75 |
+
return float(np.clip(inter / union, 0.0, 1.0))
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def coverage_pct(target_box_poly: Polygon, signature_poly: Polygon) -> float:
|
| 79 |
+
"""``100 * intersection(target_box, polygon) / area(target_box)``."""
|
| 80 |
+
target_area = safe_area(target_box_poly)
|
| 81 |
+
if target_area <= 0:
|
| 82 |
+
return 0.0
|
| 83 |
+
inter = intersection_area(target_box_poly, signature_poly)
|
| 84 |
+
return float(np.clip(100.0 * inter / target_area, 0.0, 100.0))
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def containment_pct(target_box_poly: Polygon, signature_poly: Polygon) -> float:
|
| 88 |
+
"""``100 * intersection(target_box, polygon) / area(polygon)``."""
|
| 89 |
+
sig_area = safe_area(signature_poly)
|
| 90 |
+
if sig_area <= 0:
|
| 91 |
+
return 0.0
|
| 92 |
+
inter = intersection_area(target_box_poly, signature_poly)
|
| 93 |
+
return float(np.clip(100.0 * inter / sig_area, 0.0, 100.0))
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def centroid_of(poly: Polygon) -> tuple[float, float]:
|
| 97 |
+
"""Return the ``(x, y)`` centroid of a polygon, ``(nan, nan)`` if empty."""
|
| 98 |
+
if poly is None or poly.is_empty:
|
| 99 |
+
return (float("nan"), float("nan"))
|
| 100 |
+
c = poly.centroid
|
| 101 |
+
return (float(c.x), float(c.y))
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def box_center(box_coords: Box) -> tuple[float, float]:
|
| 105 |
+
"""Return the center point of an axis-aligned box."""
|
| 106 |
+
x1, y1, x2, y2 = box_coords
|
| 107 |
+
return ((x1 + x2) / 2.0, (y1 + y2) / 2.0)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def box_size(box_coords: Box) -> tuple[float, float]:
|
| 111 |
+
"""Return ``(width, height)`` of an axis-aligned box."""
|
| 112 |
+
x1, y1, x2, y2 = box_coords
|
| 113 |
+
return (abs(x2 - x1), abs(y2 - y1))
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def distance_between(p1: tuple[float, float], p2: tuple[float, float]) -> float:
|
| 117 |
+
"""Euclidean distance between two points, ``nan``-safe."""
|
| 118 |
+
if any(np.isnan(v) for v in (*p1, *p2)):
|
| 119 |
+
return float("nan")
|
| 120 |
+
return float(np.hypot(p1[0] - p2[0], p1[1] - p2[1]))
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def margin_utilization(box_coords: Box, signature_poly: Polygon) -> float:
|
| 124 |
+
"""Fraction of the box's available margin consumed by the signature (0..1+).
|
| 125 |
+
|
| 126 |
+
0 means the signature sits exactly at the box center with no spatial
|
| 127 |
+
extent; 1 means it exactly fills the box's half-diagonal. Values above 1
|
| 128 |
+
indicate the signature protrudes outside the box.
|
| 129 |
+
"""
|
| 130 |
+
if signature_poly is None or signature_poly.is_empty:
|
| 131 |
+
return 0.0
|
| 132 |
+
bx, by = box_center(box_coords)
|
| 133 |
+
bw, bh = box_size(box_coords)
|
| 134 |
+
half_diag = float(np.hypot(bw, bh) / 2.0) or 1e-6
|
| 135 |
+
minx, miny, maxx, maxy = signature_poly.bounds
|
| 136 |
+
corners = [(minx, miny), (minx, maxy), (maxx, miny), (maxx, maxy)]
|
| 137 |
+
max_dist = max(distance_between((bx, by), c) for c in corners)
|
| 138 |
+
return float(max_dist / half_diag)
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def clamp01(value: float) -> float:
|
| 142 |
+
"""Clamp a float into ``[0, 1]``, mapping NaN to 0."""
|
| 143 |
+
if value is None or np.isnan(value):
|
| 144 |
+
return 0.0
|
| 145 |
+
return float(np.clip(value, 0.0, 1.0))
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def rotated_rect_polygon(center: tuple[float, float], size: tuple[float, float], angle_deg: float) -> Polygon:
|
| 149 |
+
"""Build a Shapely polygon for a rectangle rotated about its center (OpenCV convention)."""
|
| 150 |
+
cx, cy = center
|
| 151 |
+
w, h = size
|
| 152 |
+
theta = np.deg2rad(angle_deg)
|
| 153 |
+
dx = w / 2.0
|
| 154 |
+
dy = h / 2.0
|
| 155 |
+
corners = np.array([(-dx, -dy), (dx, -dy), (dx, dy), (-dx, dy)])
|
| 156 |
+
rot = np.array([[np.cos(theta), -np.sin(theta)], [np.sin(theta), np.cos(theta)]])
|
| 157 |
+
rotated = corners @ rot.T + np.array([cx, cy])
|
| 158 |
+
return Polygon(rotated)
|
src/utils/image_io.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Image I/O helpers built on Pillow + NumPy.
|
| 2 |
+
|
| 3 |
+
Pillow is used for decoding (broad format support: PNG/JPG/JPEG/TIFF/BMP)
|
| 4 |
+
while all pixel manipulation downstream is done with NumPy/OpenCV arrays in
|
| 5 |
+
RGB(A) uint8 layout for consistency across the pipeline.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
import numpy as np
|
| 13 |
+
from PIL import Image
|
| 14 |
+
|
| 15 |
+
SUPPORTED_IMAGE_EXTENSIONS: tuple[str, ...] = (
|
| 16 |
+
".png", ".jpg", ".jpeg", ".tif", ".tiff", ".bmp",
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def load_image_rgb(path: "Path | str") -> np.ndarray:
|
| 21 |
+
"""Load an image file as an ``(H, W, 3)`` uint8 RGB array."""
|
| 22 |
+
with Image.open(path) as im:
|
| 23 |
+
return np.array(im.convert("RGB"))
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def load_image_any(path: "Path | str") -> np.ndarray:
|
| 27 |
+
"""Load an image preserving its native mode (grayscale, RGB, or RGBA) as uint8.
|
| 28 |
+
|
| 29 |
+
Used for signature source images, which may be grayscale scans, palette
|
| 30 |
+
images, or already have an alpha channel.
|
| 31 |
+
"""
|
| 32 |
+
with Image.open(path) as im:
|
| 33 |
+
if im.mode in ("L", "RGB", "RGBA"):
|
| 34 |
+
return np.array(im)
|
| 35 |
+
if im.mode == "LA":
|
| 36 |
+
return np.array(im.convert("RGBA"))
|
| 37 |
+
# Palette / CMYK / other -> normalize to RGB
|
| 38 |
+
return np.array(im.convert("RGB"))
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def save_image_rgb(path: "Path | str", array: np.ndarray) -> None:
|
| 42 |
+
"""Save an ``(H, W, 3)`` or ``(H, W, 4)`` uint8 array as PNG/JPEG depending on extension."""
|
| 43 |
+
path = Path(path)
|
| 44 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 45 |
+
mode = "RGBA" if array.ndim == 3 and array.shape[2] == 4 else "RGB"
|
| 46 |
+
Image.fromarray(array.astype(np.uint8), mode=mode).save(path)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def save_image_gray(path: "Path | str", array: np.ndarray) -> None:
|
| 50 |
+
"""Save a single-channel uint8 array as a grayscale image."""
|
| 51 |
+
path = Path(path)
|
| 52 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 53 |
+
Image.fromarray(array.astype(np.uint8), mode="L").save(path)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def discover_images(root: "Path | str", extensions: tuple[str, ...] = SUPPORTED_IMAGE_EXTENSIONS) -> list[Path]:
|
| 57 |
+
"""Recursively discover image files under ``root``, sorted deterministically."""
|
| 58 |
+
root = Path(root)
|
| 59 |
+
if not root.exists():
|
| 60 |
+
return []
|
| 61 |
+
found = [p for p in root.rglob("*") if p.is_file() and p.suffix.lower() in extensions]
|
| 62 |
+
return sorted(found, key=lambda p: p.as_posix().lower())
|
src/utils/logging_utils.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Centralized logging configuration.
|
| 2 |
+
|
| 3 |
+
The pipeline uses ``logging`` exclusively (no ``print``). Every module obtains
|
| 4 |
+
its logger via :func:`get_logger`, which guarantees handlers are configured
|
| 5 |
+
exactly once per process, even under multiprocessing worker re-imports.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import logging
|
| 11 |
+
import sys
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
_CONFIGURED = False
|
| 15 |
+
|
| 16 |
+
LOG_FORMAT = "%(asctime)s | %(levelname)-8s | %(processName)s | %(name)s | %(message)s"
|
| 17 |
+
DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def configure_logging(level: int = logging.INFO, log_file: "Path | None" = None) -> None:
|
| 21 |
+
"""Configure the root logger once for the entire process.
|
| 22 |
+
|
| 23 |
+
Args:
|
| 24 |
+
level: Root logging level (e.g. ``logging.INFO``, ``logging.DEBUG``).
|
| 25 |
+
log_file: Optional file path to additionally write logs to.
|
| 26 |
+
"""
|
| 27 |
+
global _CONFIGURED
|
| 28 |
+
root = logging.getLogger()
|
| 29 |
+
if _CONFIGURED:
|
| 30 |
+
root.setLevel(level)
|
| 31 |
+
return
|
| 32 |
+
|
| 33 |
+
root.setLevel(level)
|
| 34 |
+
formatter = logging.Formatter(LOG_FORMAT, datefmt=DATE_FORMAT)
|
| 35 |
+
|
| 36 |
+
stream_handler = logging.StreamHandler(stream=sys.stdout)
|
| 37 |
+
stream_handler.setFormatter(formatter)
|
| 38 |
+
root.addHandler(stream_handler)
|
| 39 |
+
|
| 40 |
+
if log_file is not None:
|
| 41 |
+
log_file.parent.mkdir(parents=True, exist_ok=True)
|
| 42 |
+
file_handler = logging.FileHandler(log_file, encoding="utf-8")
|
| 43 |
+
file_handler.setFormatter(formatter)
|
| 44 |
+
root.addHandler(file_handler)
|
| 45 |
+
|
| 46 |
+
_CONFIGURED = True
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def get_logger(name: str) -> logging.Logger:
|
| 50 |
+
"""Return a module-scoped logger, configuring root logging with sane defaults if needed."""
|
| 51 |
+
if not _CONFIGURED:
|
| 52 |
+
configure_logging()
|
| 53 |
+
return logging.getLogger(name)
|
src/utils/names.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Deterministic synthetic signer name/role generation.
|
| 2 |
+
|
| 3 |
+
No external dependency (e.g. Faker) is required: a compact, self-contained
|
| 4 |
+
name pool keeps the pipeline dependency-light and fully reproducible from a
|
| 5 |
+
seed alone (Faker's own generators are not guaranteed stable across
|
| 6 |
+
versions, which would break the "same seed -> same output" contract).
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import random
|
| 12 |
+
|
| 13 |
+
FIRST_NAMES: tuple[str, ...] = (
|
| 14 |
+
"James", "Mary", "Robert", "Patricia", "John", "Jennifer", "Michael", "Linda",
|
| 15 |
+
"David", "Elizabeth", "William", "Barbara", "Richard", "Susan", "Joseph", "Jessica",
|
| 16 |
+
"Thomas", "Sarah", "Charles", "Karen", "Christopher", "Nancy", "Daniel", "Lisa",
|
| 17 |
+
"Matthew", "Betty", "Anthony", "Margaret", "Mark", "Sandra", "Donald", "Ashley",
|
| 18 |
+
"Steven", "Kimberly", "Paul", "Emily", "Andrew", "Donna", "Joshua", "Michelle",
|
| 19 |
+
"Kenneth", "Carol", "Kevin", "Amanda", "Brian", "Melissa", "George", "Deborah",
|
| 20 |
+
"Edward", "Stephanie", "Ronald", "Rebecca", "Timothy", "Laura", "Jason", "Sharon",
|
| 21 |
+
"Jeffrey", "Cynthia", "Ryan", "Kathleen", "Jacob", "Amy", "Gary", "Angela",
|
| 22 |
+
"Nicholas", "Shirley", "Eric", "Anna", "Jonathan", "Brenda", "Stephen", "Pamela",
|
| 23 |
+
"Larry", "Emma", "Justin", "Nicole", "Scott", "Helen", "Brandon", "Samantha",
|
| 24 |
+
"Wei", "Mei", "Hiroshi", "Yuki", "Raj", "Priya", "Ahmed", "Fatima",
|
| 25 |
+
"Carlos", "Sofia", "Hans", "Ingrid", "Liam", "Olivia", "Noah", "Ava",
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
LAST_NAMES: tuple[str, ...] = (
|
| 29 |
+
"Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", "Miller", "Davis",
|
| 30 |
+
"Rodriguez", "Martinez", "Hernandez", "Lopez", "Gonzalez", "Wilson", "Anderson", "Thomas",
|
| 31 |
+
"Taylor", "Moore", "Jackson", "Martin", "Lee", "Perez", "Thompson", "White",
|
| 32 |
+
"Harris", "Sanchez", "Clark", "Ramirez", "Lewis", "Robinson", "Walker", "Young",
|
| 33 |
+
"Allen", "King", "Wright", "Scott", "Torres", "Nguyen", "Hill", "Flores",
|
| 34 |
+
"Green", "Adams", "Nelson", "Baker", "Hall", "Rivera", "Campbell", "Mitchell",
|
| 35 |
+
"Carter", "Roberts", "Chen", "Tan", "Kumar", "Patel", "Khan", "Ali",
|
| 36 |
+
"Muller", "Schmidt", "Rossi", "Dubois", "Kowalski", "Ivanov", "Suzuki", "Tanaka",
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
ROLE_POOL: tuple[str, ...] = (
|
| 40 |
+
"Account Holder",
|
| 41 |
+
"Authorized Signatory",
|
| 42 |
+
"Branch Manager",
|
| 43 |
+
"Bank Representative",
|
| 44 |
+
"Bank Officer",
|
| 45 |
+
"Loan Officer",
|
| 46 |
+
"Notary Public",
|
| 47 |
+
"Guarantor",
|
| 48 |
+
"Co-Signer",
|
| 49 |
+
"Compliance Officer",
|
| 50 |
+
"First Authorised Signatory",
|
| 51 |
+
"Second Authorised Signatory",
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def generate_name(rng: random.Random) -> str:
|
| 56 |
+
"""Generate a deterministic synthetic full name from a seeded RNG."""
|
| 57 |
+
return f"{rng.choice(FIRST_NAMES)} {rng.choice(LAST_NAMES)}"
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def generate_role(rng: random.Random) -> str:
|
| 61 |
+
"""Generate a deterministic synthetic role from a seeded RNG."""
|
| 62 |
+
return rng.choice(ROLE_POOL)
|
src/utils/random_utils.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Deterministic random-state management.
|
| 2 |
+
|
| 3 |
+
The generator must produce byte-identical output for a given ``(master_seed,
|
| 4 |
+
sample_index)`` pair regardless of process/thread scheduling, batch size, or
|
| 5 |
+
worker count. To guarantee this we never share a single global RNG across
|
| 6 |
+
samples or pipeline stages. Instead, every stage of every sample draws from
|
| 7 |
+
an independently *derived* RNG whose seed is a pure function of
|
| 8 |
+
``(master_seed, sample_index, stage_name)``.
|
| 9 |
+
|
| 10 |
+
This makes the pipeline safe for multiprocessing: worker N generating sample
|
| 11 |
+
57 produces exactly the same random draws as a single-process run would have,
|
| 12 |
+
because nothing depends on execution order.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import hashlib
|
| 18 |
+
import random
|
| 19 |
+
from typing import Any
|
| 20 |
+
|
| 21 |
+
import numpy as np
|
| 22 |
+
|
| 23 |
+
_MOD32 = 2**32
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def derive_seed(master_seed: int, *parts: Any) -> int:
|
| 27 |
+
"""Derive a deterministic 32-bit seed from a master seed and arbitrary parts.
|
| 28 |
+
|
| 29 |
+
Uses SHA-256 over the concatenated string representation of all parts so
|
| 30 |
+
that the mapping is stable across Python versions and processes (unlike
|
| 31 |
+
Python's salted ``hash()``).
|
| 32 |
+
|
| 33 |
+
Args:
|
| 34 |
+
master_seed: The top-level seed configured by the user.
|
| 35 |
+
*parts: Any number of hashable/stringifiable disambiguators, e.g.
|
| 36 |
+
sample index and pipeline stage name.
|
| 37 |
+
|
| 38 |
+
Returns:
|
| 39 |
+
A non-negative integer in ``[0, 2**32)`` suitable for seeding
|
| 40 |
+
``numpy.random.Generator`` or ``random.Random``.
|
| 41 |
+
"""
|
| 42 |
+
key = f"{master_seed}|" + "|".join(str(p) for p in parts)
|
| 43 |
+
digest = hashlib.sha256(key.encode("utf-8")).digest()
|
| 44 |
+
return int.from_bytes(digest[:4], byteorder="big") % _MOD32
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class SeedManager:
|
| 48 |
+
"""Provides independent, deterministic RNGs for each pipeline stage of one sample.
|
| 49 |
+
|
| 50 |
+
Example:
|
| 51 |
+
>>> sm = SeedManager(master_seed=42, sample_index=7)
|
| 52 |
+
>>> rng = sm.numpy_rng("placement")
|
| 53 |
+
>>> rng.uniform(0, 1)
|
| 54 |
+
"""
|
| 55 |
+
|
| 56 |
+
def __init__(self, master_seed: int, sample_index: int) -> None:
|
| 57 |
+
self.master_seed = master_seed
|
| 58 |
+
self.sample_index = sample_index
|
| 59 |
+
|
| 60 |
+
def seed_for(self, stage: str) -> int:
|
| 61 |
+
"""Return the deterministic integer seed for a named pipeline stage."""
|
| 62 |
+
return derive_seed(self.master_seed, self.sample_index, stage)
|
| 63 |
+
|
| 64 |
+
def numpy_rng(self, stage: str) -> np.random.Generator:
|
| 65 |
+
"""Return a fresh ``numpy.random.Generator`` (PCG64) seeded for ``stage``."""
|
| 66 |
+
return np.random.default_rng(self.seed_for(stage))
|
| 67 |
+
|
| 68 |
+
def python_rng(self, stage: str) -> random.Random:
|
| 69 |
+
"""Return a fresh ``random.Random`` seeded for ``stage``."""
|
| 70 |
+
return random.Random(self.seed_for(stage))
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def resolve_scalar_or_range(value: Any, rng: np.random.Generator) -> float:
|
| 74 |
+
"""Resolve a config parameter that is either a fixed scalar or a ``{min, max}`` range.
|
| 75 |
+
|
| 76 |
+
Args:
|
| 77 |
+
value: Either a plain number, or an object/mapping exposing ``min``
|
| 78 |
+
and ``max`` attributes/keys (a :class:`~src.config.schema.Range`
|
| 79 |
+
or an equivalent dict).
|
| 80 |
+
rng: The seeded generator to draw from when ``value`` is a range.
|
| 81 |
+
|
| 82 |
+
Returns:
|
| 83 |
+
A resolved float value.
|
| 84 |
+
"""
|
| 85 |
+
if isinstance(value, (int, float)):
|
| 86 |
+
return float(value)
|
| 87 |
+
if isinstance(value, dict) and "min" in value and "max" in value:
|
| 88 |
+
lo, hi = float(value["min"]), float(value["max"])
|
| 89 |
+
return float(rng.uniform(lo, hi)) if hi > lo else lo
|
| 90 |
+
# Pydantic Range model (duck-typed access)
|
| 91 |
+
lo = getattr(value, "min", None)
|
| 92 |
+
hi = getattr(value, "max", None)
|
| 93 |
+
if lo is not None and hi is not None:
|
| 94 |
+
lo, hi = float(lo), float(hi)
|
| 95 |
+
return float(rng.uniform(lo, hi)) if hi > lo else lo
|
| 96 |
+
raise TypeError(f"Cannot resolve scalar-or-range value: {value!r}")
|
src/utils/transform_utils.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared geometric raster transforms used by both placement and augmentation."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import cv2
|
| 6 |
+
import numpy as np
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def rotate_and_scale_rgba(rgba: np.ndarray, angle_deg: float, scale: float = 1.0) -> np.ndarray:
|
| 10 |
+
"""Rotate (and optionally scale) an RGBA image about its own center.
|
| 11 |
+
|
| 12 |
+
The output canvas automatically expands to tightly bound the rotated
|
| 13 |
+
rectangle so no content is clipped; newly exposed corners are fully
|
| 14 |
+
transparent.
|
| 15 |
+
"""
|
| 16 |
+
h, w = rgba.shape[:2]
|
| 17 |
+
if h == 0 or w == 0:
|
| 18 |
+
return rgba
|
| 19 |
+
cx, cy = w / 2.0, h / 2.0
|
| 20 |
+
matrix = cv2.getRotationMatrix2D((cx, cy), angle_deg, scale)
|
| 21 |
+
cos, sin = abs(matrix[0, 0]), abs(matrix[0, 1])
|
| 22 |
+
new_w = max(int(np.ceil(h * sin + w * cos)), 1)
|
| 23 |
+
new_h = max(int(np.ceil(h * cos + w * sin)), 1)
|
| 24 |
+
matrix[0, 2] += new_w / 2.0 - cx
|
| 25 |
+
matrix[1, 2] += new_h / 2.0 - cy
|
| 26 |
+
return cv2.warpAffine(
|
| 27 |
+
rgba, matrix, (new_w, new_h),
|
| 28 |
+
flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_CONSTANT, borderValue=(0, 0, 0, 0),
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def smooth_noise_field(shape: tuple[int, int], rng: np.random.Generator, blur_frac: float = 0.08) -> np.ndarray:
|
| 33 |
+
"""Generate a smooth, low-frequency noise field in ``[0, 1]`` of the given ``(h, w)`` shape.
|
| 34 |
+
|
| 35 |
+
Built by upsampling coarse random noise, which is a fast and dependency-free
|
| 36 |
+
stand-in for Perlin/simplex noise -- adequate for subtle shading effects
|
| 37 |
+
like paper texture, discoloration, and ink fading.
|
| 38 |
+
"""
|
| 39 |
+
h, w = shape
|
| 40 |
+
coarse_h = max(int(h * blur_frac), 4)
|
| 41 |
+
coarse_w = max(int(w * blur_frac), 4)
|
| 42 |
+
coarse = rng.uniform(0.0, 1.0, size=(coarse_h, coarse_w)).astype(np.float32)
|
| 43 |
+
field = cv2.resize(coarse, (w, h), interpolation=cv2.INTER_CUBIC)
|
| 44 |
+
field = cv2.GaussianBlur(field, (0, 0), sigmaX=max(w, h) * 0.01 + 1)
|
| 45 |
+
field -= field.min()
|
| 46 |
+
max_val = field.max()
|
| 47 |
+
if max_val > 1e-6:
|
| 48 |
+
field /= max_val
|
| 49 |
+
return field
|
src/verification/__init__.py
ADDED
|
File without changes
|
src/verification/case_types.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The verification test-case taxonomy: ``case_type`` -> ground-truth verdict.
|
| 2 |
+
|
| 3 |
+
Each :class:`CaseSpec` encodes one row of the signature-verification test
|
| 4 |
+
plan: what should actually be rendered into the target signature box
|
| 5 |
+
(``signature_mode``), whether a companion box on the same page should also
|
| 6 |
+
be filled with its own correctly-authorized signature (``fill_companion`` --
|
| 7 |
+
only meaningful for the multi-signer-context cases), and the ground-truth
|
| 8 |
+
``verdict``/``reject_code`` a downstream verifier is expected to produce.
|
| 9 |
+
|
| 10 |
+
Driven by the existing ``quality.case_type`` config field (see
|
| 11 |
+
``configs/examples/hard_degraded.yaml`` for its prior, simpler use as a
|
| 12 |
+
plain metadata label) -- setting it to one of the codes below switches
|
| 13 |
+
:meth:`~src.generator.GeneratorContext.run_sample` into scenario-driven
|
| 14 |
+
generation. Any other value (or ``None``) leaves generation entirely
|
| 15 |
+
unaffected.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
from dataclasses import dataclass
|
| 21 |
+
|
| 22 |
+
SignatureMode = str # one of the _SIGNATURE_MODES below, kept as a plain str for simplicity
|
| 23 |
+
|
| 24 |
+
NO_SIGNATURE = "NO_SIGNATURE"
|
| 25 |
+
IDENTITY_MISMATCH = "IDENTITY_MISMATCH"
|
| 26 |
+
ROLE_MISMATCH = "ROLE_MISMATCH"
|
| 27 |
+
REQUIRED_SIGNER_ABSENT = "REQUIRED_SIGNER_ABSENT"
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@dataclass(frozen=True)
|
| 31 |
+
class CaseSpec:
|
| 32 |
+
"""One row of the verification test-case taxonomy."""
|
| 33 |
+
|
| 34 |
+
code: str
|
| 35 |
+
description: str
|
| 36 |
+
verdict: str # "PASS" | "REJECT"
|
| 37 |
+
reject_code: "str | None"
|
| 38 |
+
signature_mode: SignatureMode
|
| 39 |
+
fill_companion: bool = False
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
CASE_TABLE: dict[str, CaseSpec] = {
|
| 43 |
+
"1a": CaseSpec("1a", "Correct signer signs normally", "PASS", None, "genuine_correct"),
|
| 44 |
+
"1b": CaseSpec("1b", "Correct signer, natural pen variation applied", "PASS", None, "genuine_variation"),
|
| 45 |
+
"1c": CaseSpec("1c", "Multi-signer form, both sign correctly", "PASS", None, "genuine_correct", fill_companion=True),
|
| 46 |
+
"1d": CaseSpec("1d", "Correct signer, scan degraded to test robustness", "PASS", None, "genuine_correct"),
|
| 47 |
+
"2a": CaseSpec("2a", "Box left completely blank", "REJECT", NO_SIGNATURE, "blank"),
|
| 48 |
+
"2b": CaseSpec("2b", "Printed/typed name only, no handwriting", "REJECT", NO_SIGNATURE, "printed_text"),
|
| 49 |
+
"3a": CaseSpec("3a", "Wrong real person signs the box", "REJECT", IDENTITY_MISMATCH, "wrong_signer_genuine"),
|
| 50 |
+
"3b": CaseSpec("3b", "Skilled-forgery sample used", "REJECT", IDENTITY_MISMATCH, "forged_skilled"),
|
| 51 |
+
"4a": CaseSpec("4a", "Authorized signer, but wrong role's box", "REJECT", ROLE_MISMATCH, "role_swap", fill_companion=True),
|
| 52 |
+
"4b": CaseSpec("4b", "Signer from a different form entirely", "REJECT", ROLE_MISMATCH, "foreign_template_signer", fill_companion=True),
|
| 53 |
+
"5a": CaseSpec("5a", "Required signer's box left empty", "REJECT", REQUIRED_SIGNER_ABSENT, "blank", fill_companion=True),
|
| 54 |
+
"5b": CaseSpec("5b", "Someone else signs in the required signer's place", "REJECT", REQUIRED_SIGNER_ABSENT, "wrong_signer_genuine", fill_companion=True),
|
| 55 |
+
}
|
src/verification/registry.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Deterministic authorized-signer identity registry.
|
| 2 |
+
|
| 3 |
+
The base generation pipeline has no notion of "the correct signer for this
|
| 4 |
+
box" -- it just draws a random genuine signature for whichever box was
|
| 5 |
+
picked. Every verification case type (see :mod:`src.verification.case_types`)
|
| 6 |
+
hinges on comparing an *authorized* identity against an *actual* one, so this
|
| 7 |
+
module derives that ground truth purely from ``(master_seed, template_id,
|
| 8 |
+
box_id)`` via :func:`~src.utils.random_utils.derive_seed` -- no persisted
|
| 9 |
+
state, fully reproducible, consistent with the rest of the pipeline's
|
| 10 |
+
seeding philosophy.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import random
|
| 16 |
+
import re
|
| 17 |
+
|
| 18 |
+
from src.signatures.signature_dataset import SignatureDataset
|
| 19 |
+
from src.templates.template_loader import TemplateLoader
|
| 20 |
+
from src.utils.names import generate_name
|
| 21 |
+
from src.utils.random_utils import derive_seed
|
| 22 |
+
|
| 23 |
+
# Signer groups auto-derived by SignatureDataset (CEDAR filenames, or no
|
| 24 |
+
# metadata.csv identity override) look like "signer_0014" and carry no real
|
| 25 |
+
# name -- those get a generated display name. Groups sourced from
|
| 26 |
+
# metadata.csv (e.g. "Priya Sharma") already *are* a real name.
|
| 27 |
+
_AUTO_GROUP_PATTERN = re.compile(r"^signer_\d+$")
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class SignerIdentityRegistry:
|
| 31 |
+
"""Resolves ground-truth authorized signer identities for template boxes.
|
| 32 |
+
|
| 33 |
+
Example:
|
| 34 |
+
>>> registry = SignerIdentityRegistry(template_loader, signature_dataset, master_seed=1234)
|
| 35 |
+
>>> registry.authorized_signer_group("bank_doc_03", "sig1")
|
| 36 |
+
'signer_0014'
|
| 37 |
+
"""
|
| 38 |
+
|
| 39 |
+
def __init__(self, template_loader: TemplateLoader, signature_dataset: SignatureDataset, master_seed: int) -> None:
|
| 40 |
+
self.template_loader = template_loader
|
| 41 |
+
self.signature_dataset = signature_dataset
|
| 42 |
+
self.master_seed = master_seed
|
| 43 |
+
self._signer_groups = signature_dataset.signer_groups()
|
| 44 |
+
|
| 45 |
+
def authorized_signer_group(self, template_id: str, box_id: str) -> str:
|
| 46 |
+
"""Return the deterministic ground-truth authorized signer for one (template, box)."""
|
| 47 |
+
seed = derive_seed(self.master_seed, template_id, box_id, "authorized_signer")
|
| 48 |
+
idx = seed % len(self._signer_groups)
|
| 49 |
+
return self._signer_groups[idx]
|
| 50 |
+
|
| 51 |
+
def foreign_authorized(self, exclude_template_id: str) -> tuple[str, str, str]:
|
| 52 |
+
"""Return ``(signer_group, template_id, box_id)`` for a box on a *different* template.
|
| 53 |
+
|
| 54 |
+
Used for the "signer from a different form entirely" scenario: the
|
| 55 |
+
identity is a legitimately authorized signer, just not for this
|
| 56 |
+
document.
|
| 57 |
+
"""
|
| 58 |
+
templates = self.template_loader.discover()
|
| 59 |
+
candidate_ids = sorted(tid for tid in templates if tid != exclude_template_id)
|
| 60 |
+
if not candidate_ids:
|
| 61 |
+
# Only one template discovered; fall back to a different box on the same one.
|
| 62 |
+
candidate_ids = [exclude_template_id]
|
| 63 |
+
|
| 64 |
+
seed = derive_seed(self.master_seed, exclude_template_id, "foreign_template")
|
| 65 |
+
foreign_template_id = candidate_ids[seed % len(candidate_ids)]
|
| 66 |
+
|
| 67 |
+
boxes = templates[foreign_template_id].annotation.pages[0].signature_boxes
|
| 68 |
+
box_seed = derive_seed(self.master_seed, exclude_template_id, foreign_template_id, "foreign_box")
|
| 69 |
+
box = boxes[box_seed % len(boxes)]
|
| 70 |
+
|
| 71 |
+
return self.authorized_signer_group(foreign_template_id, box.id), foreign_template_id, box.id
|
| 72 |
+
|
| 73 |
+
def random_wrong_signer_group(self, rng: random.Random, exclude_group: str) -> str:
|
| 74 |
+
"""Return a uniformly random signer group other than ``exclude_group``."""
|
| 75 |
+
pool = [g for g in self._signer_groups if g != exclude_group]
|
| 76 |
+
return rng.choice(pool)
|
| 77 |
+
|
| 78 |
+
def display_name(self, signer_group: str) -> str:
|
| 79 |
+
"""Return a stable, human-readable display name for a signer group.
|
| 80 |
+
|
| 81 |
+
Groups sourced from ``dataset/metadata.csv`` (e.g. ``"Priya Sharma"``)
|
| 82 |
+
already carry a real name and are returned as-is. Anonymous
|
| 83 |
+
auto-derived groups (e.g. ``"signer_0014"``, from the bundled CEDAR
|
| 84 |
+
dataset) get a generated name, stable across an entire run so a
|
| 85 |
+
benchmark built from many samples reads as consistent identities
|
| 86 |
+
rather than a fresh random name every time.
|
| 87 |
+
"""
|
| 88 |
+
if not _AUTO_GROUP_PATTERN.match(signer_group):
|
| 89 |
+
return signer_group
|
| 90 |
+
seed = derive_seed(self.master_seed, signer_group, "display_name")
|
| 91 |
+
return generate_name(random.Random(seed))
|
| 92 |
+
|
| 93 |
+
def script_for(self, signer_group: str) -> str:
|
| 94 |
+
"""Return the handwriting script (e.g. ``"latin"``, ``"hindi"``, ``"bengali"``) for a signer group."""
|
| 95 |
+
return self.signature_dataset.script_for_group(signer_group)
|
src/visualization/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Debug visualization figures for generated samples."""
|
src/visualization/visualizer.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Renders a multi-panel debug figure for one generated sample.
|
| 2 |
+
|
| 3 |
+
Shows the full rendered document with the target box, detected bounding
|
| 4 |
+
box, and detected polygon overlaid; a crop of the signature field; a mask
|
| 5 |
+
reconstructed from the detected ink polygon; the resolved coverage metrics;
|
| 6 |
+
and a difficulty-component breakdown bar chart.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
from typing import Any
|
| 13 |
+
|
| 14 |
+
import cv2
|
| 15 |
+
import matplotlib
|
| 16 |
+
|
| 17 |
+
matplotlib.use("Agg")
|
| 18 |
+
import matplotlib.pyplot as plt
|
| 19 |
+
import numpy as np
|
| 20 |
+
|
| 21 |
+
from src.utils.logging_utils import get_logger
|
| 22 |
+
|
| 23 |
+
logger = get_logger(__name__)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _draw_overlays(image: np.ndarray, metadata: dict[str, Any]) -> np.ndarray:
|
| 27 |
+
out = image.copy()
|
| 28 |
+
geometry = metadata["geometry"]
|
| 29 |
+
|
| 30 |
+
def as_int_box(box):
|
| 31 |
+
return tuple(int(round(v)) for v in box)
|
| 32 |
+
|
| 33 |
+
tb = as_int_box(geometry["target_box"])
|
| 34 |
+
ab = as_int_box(geometry["actual_box"])
|
| 35 |
+
cv2.rectangle(out, (tb[0], tb[1]), (tb[2], tb[3]), (0, 200, 0), 2)
|
| 36 |
+
cv2.rectangle(out, (ab[0], ab[1]), (ab[2], ab[3]), (30, 80, 230), 2)
|
| 37 |
+
|
| 38 |
+
polygon = geometry.get("signature_polygon_px") or []
|
| 39 |
+
if len(polygon) >= 3:
|
| 40 |
+
pts = np.array(polygon, dtype=np.int32).reshape(-1, 1, 2)
|
| 41 |
+
cv2.polylines(out, [pts], isClosed=True, color=(220, 30, 30), thickness=2)
|
| 42 |
+
|
| 43 |
+
return out
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _crop_region(image: np.ndarray, box: list, pad: int = 25) -> np.ndarray:
|
| 47 |
+
h, w = image.shape[:2]
|
| 48 |
+
x1, y1, x2, y2 = (int(round(v)) for v in box)
|
| 49 |
+
x1, y1 = max(x1 - pad, 0), max(y1 - pad, 0)
|
| 50 |
+
x2, y2 = min(x2 + pad, w), min(y2 + pad, h)
|
| 51 |
+
if x2 <= x1 or y2 <= y1:
|
| 52 |
+
return image
|
| 53 |
+
return image[y1:y2, x1:x2]
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _reconstruct_mask(shape: tuple[int, int], box: list, polygon: list) -> np.ndarray:
|
| 57 |
+
mask = np.zeros(shape, dtype=np.uint8)
|
| 58 |
+
if len(polygon) >= 3:
|
| 59 |
+
pts = np.array(polygon, dtype=np.int32).reshape(-1, 1, 2)
|
| 60 |
+
cv2.fillPoly(mask, [pts], 255)
|
| 61 |
+
x1, y1, x2, y2 = (int(round(v)) for v in box)
|
| 62 |
+
pad = 25
|
| 63 |
+
x1c, y1c = max(x1 - pad, 0), max(y1 - pad, 0)
|
| 64 |
+
x2c, y2c = min(x2 + pad, shape[1]), min(y2 + pad, shape[0])
|
| 65 |
+
if x2c <= x1c or y2c <= y1c:
|
| 66 |
+
return mask
|
| 67 |
+
return mask[y1c:y2c, x1c:x2c]
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def save_visualization(path: "str | Path", final_image: np.ndarray, metadata: dict[str, Any]) -> None:
|
| 71 |
+
"""Render and save a multi-panel debug figure for one generated sample.
|
| 72 |
+
|
| 73 |
+
Args:
|
| 74 |
+
path: Output PNG path.
|
| 75 |
+
final_image: The fully rendered (and augmented) document.
|
| 76 |
+
metadata: The sample's full metadata dict, as built by
|
| 77 |
+
:class:`~src.metadata.metadata_exporter.MetadataExporter`.
|
| 78 |
+
"""
|
| 79 |
+
path = Path(path)
|
| 80 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 81 |
+
|
| 82 |
+
geometry = metadata["geometry"]
|
| 83 |
+
difficulty = metadata["difficulty"]
|
| 84 |
+
metrics = metadata["metrics"]
|
| 85 |
+
|
| 86 |
+
overlaid = _draw_overlays(final_image, metadata)
|
| 87 |
+
crop = _crop_region(final_image, geometry["actual_box"])
|
| 88 |
+
mask = _reconstruct_mask(final_image.shape[:2], geometry["actual_box"], geometry["signature_polygon_px"])
|
| 89 |
+
|
| 90 |
+
fig = plt.figure(figsize=(15, 9.5), dpi=110)
|
| 91 |
+
fig.suptitle(
|
| 92 |
+
f"{metadata['document_id']} | template={metadata['template']} | box={metadata['geometry']['target_box']}",
|
| 93 |
+
x=0.02, ha="left", fontsize=11, family="monospace",
|
| 94 |
+
)
|
| 95 |
+
grid = fig.add_gridspec(2, 3, width_ratios=[2.0, 1.0, 1.0], height_ratios=[1.0, 1.0])
|
| 96 |
+
|
| 97 |
+
ax_full = fig.add_subplot(grid[:, 0])
|
| 98 |
+
ax_full.imshow(overlaid)
|
| 99 |
+
ax_full.axis("off")
|
| 100 |
+
|
| 101 |
+
ax_crop = fig.add_subplot(grid[0, 1])
|
| 102 |
+
ax_crop.imshow(crop)
|
| 103 |
+
ax_crop.set_title("Signature field (cropped)")
|
| 104 |
+
ax_crop.axis("off")
|
| 105 |
+
|
| 106 |
+
ax_mask = fig.add_subplot(grid[0, 2])
|
| 107 |
+
ax_mask.imshow(mask, cmap="gray")
|
| 108 |
+
ax_mask.set_title("Detected ink mask")
|
| 109 |
+
ax_mask.axis("off")
|
| 110 |
+
|
| 111 |
+
ax_metrics = fig.add_subplot(grid[1, 1])
|
| 112 |
+
ax_metrics.axis("off")
|
| 113 |
+
metric_lines = [
|
| 114 |
+
f"box_coverage_pct: {metrics['box_coverage_pct']:.2f}",
|
| 115 |
+
f"containment_pct: {metrics['containment_pct']:.2f}",
|
| 116 |
+
f"ink_fill_ratio: {metrics['ink_fill_ratio']:.3f}",
|
| 117 |
+
f"polygon_overlap: {metrics['polygon_overlap_proxy']:.3f}",
|
| 118 |
+
f"dist_from_center: {metrics['distance_from_box_center_px']:.1f}px",
|
| 119 |
+
f"margin_util: {metrics['margin_utilization']:.3f}",
|
| 120 |
+
f"mean_dark_int: {metrics['mean_dark_intensity']:.1f}",
|
| 121 |
+
f"gray_stddev: {metrics['gray_stddev']:.1f}",
|
| 122 |
+
f"signer: {metadata['signer']['name']} ({metadata['signer']['role']})",
|
| 123 |
+
]
|
| 124 |
+
ax_metrics.text(0.0, 1.0, "\n".join(metric_lines), va="top", ha="left", family="monospace", fontsize=9)
|
| 125 |
+
ax_metrics.set_title("Coverage metrics")
|
| 126 |
+
|
| 127 |
+
ax_diff = fig.add_subplot(grid[1, 2])
|
| 128 |
+
components = ["misplacement", "whitespace", "faintness", "smallness", "low_texture"]
|
| 129 |
+
values = [difficulty[c] for c in components]
|
| 130 |
+
colors = ["#d62728" if v >= 0.66 else "#ff7f0e" if v >= 0.33 else "#2ca02c" for v in values]
|
| 131 |
+
ax_diff.barh(components, values, color=colors)
|
| 132 |
+
ax_diff.set_xlim(0, 1)
|
| 133 |
+
ax_diff.set_title(f"Difficulty: {difficulty['score']:.3f} ({difficulty['tier']})")
|
| 134 |
+
|
| 135 |
+
fig.tight_layout(rect=(0.0, 0.0, 1.0, 0.94))
|
| 136 |
+
fig.savefig(path)
|
| 137 |
+
plt.close(fig)
|
tests/__init__.py
ADDED
|
File without changes
|