venki101 commited on
Commit
97b64b9
·
verified ·
1 Parent(s): 4940033

Add files using upload-large-folder tool

Browse files
Files changed (50) hide show
  1. .gitignore +12 -0
  2. README.md +546 -0
  3. annotations/bank_doc_01.json +32 -0
  4. annotations/bank_doc_02.json +23 -0
  5. annotations/bank_doc_03.json +32 -0
  6. annotations/bank_doc_04.json +32 -0
  7. annotations/bank_doc_05.json +32 -0
  8. annotations/bank_doc_06.json +32 -0
  9. annotations/bank_doc_07.json +32 -0
  10. annotations/bank_doc_08.json +32 -0
  11. annotations/bank_doc_09.json +32 -0
  12. configs/default.yaml +216 -0
  13. configs/examples/clean_easy.yaml +31 -0
  14. configs/examples/hard_degraded.yaml +47 -0
  15. configs/examples/single_document_demo.yaml +50 -0
  16. configs/examples/verification_cases.yaml +34 -0
  17. configs/generate.yaml +171 -0
  18. dataset/metadata.csv +12 -0
  19. main.py +111 -0
  20. pytest.ini +4 -0
  21. requirements.txt +15 -0
  22. src/config/__init__.py +6 -0
  23. src/config/schema.py +267 -0
  24. src/metadata/__init__.py +1 -0
  25. src/metadata/metadata_exporter.py +234 -0
  26. src/placement/__init__.py +1 -0
  27. src/placement/placement_engine.py +189 -0
  28. src/rendering/__init__.py +1 -0
  29. src/rendering/printed_text.py +64 -0
  30. src/rendering/renderer.py +105 -0
  31. src/rendering/signature_preprocessor.py +123 -0
  32. src/signatures/__init__.py +1 -0
  33. src/signatures/signature_dataset.py +208 -0
  34. src/templates/__init__.py +1 -0
  35. src/templates/annotation_schema.py +69 -0
  36. src/templates/box_detector.py +133 -0
  37. src/templates/template_loader.py +146 -0
  38. src/utils/__init__.py +1 -0
  39. src/utils/geometry_utils.py +158 -0
  40. src/utils/image_io.py +62 -0
  41. src/utils/logging_utils.py +53 -0
  42. src/utils/names.py +62 -0
  43. src/utils/random_utils.py +96 -0
  44. src/utils/transform_utils.py +49 -0
  45. src/verification/__init__.py +0 -0
  46. src/verification/case_types.py +55 -0
  47. src/verification/registry.py +95 -0
  48. src/visualization/__init__.py +1 -0
  49. src/visualization/visualizer.py +137 -0
  50. 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