| # Project A β Synthetic Signature Dataset: Technical Overview |
|
|
| ## What the Project Does |
|
|
| Project A generates a **synthetic dataset of signed documents** for training and testing a signature verification model. Real signature images are taken from public datasets, cleaned up, and pasted onto document templates to produce labelled images β each one recording whether the signature on it is genuine or forged, and who signed where. |
|
|
| The project has two main components: |
|
|
| - **`prepare_signatures.py`** β loads signature images from datasets, removes backgrounds, and applies augmentation |
| - **`synthesize_documents.py`** β pastes prepared signatures onto document templates and builds the labelled dataset |
| - **`generate_test_cases.py`** β generates a structured 50-case test suite with ground truth labels across 5 failure scenarios |
|
|
| --- |
|
|
| ## Datasets Used |
|
|
| | Dataset | What it contains | How it is used | |
| |---|---|---| |
| | CEDAR | Genuine and forged signatures in flat folders (`full_org`, `full_forg`) | Loaded as a flat list of file paths | |
| | SigNet | Genuine and forged signatures organised per writer (`001/`, `001_forg/`, etc.) | Loaded per writer so writer identity is preserved | |
| | GPDS | Small sample only | Not actively used yet β reserved for future work | |
|
|
| --- |
|
|
| ## File 1: `prepare_signatures.py` |
| |
| This file handles everything to do with **loading and preparing signature images** before they are pasted onto documents. |
| |
| ### Loading Functions |
| |
| **`list_signature_files(folder)`** |
| |
| Scans a folder and returns all image file paths with valid extensions (`.png`, `.jpg`, `.jpeg`, `.tif`, `.bmp`). Used internally by other loading functions. |
| |
| **`load_cedar_signatures(genuine, limit)`** |
| |
| Loads CEDAR signature file paths. Pass `genuine=True` for real signatures, `False` for forged ones. The `limit` parameter allows loading a small sample for quick testing. |
| |
| **`list_signet_writer_ids(split)`** |
| |
| Lists all writer IDs available in SigNet for a given split (`"train"` or `"test"`). A writer is only included if both their genuine folder and their forged folder exist, ensuring no incomplete writers are returned. |
| |
| **`load_signet_signatures(writer_id, split, genuine)`** |
| |
| Loads signature file paths for a single writer in SigNet. This is per-writer (unlike CEDAR's flat load) because SigNet's folder structure is organised by writer, and knowing which writer a signature belongs to is needed for hard-negative sampling in later work (e.g. deliberately picking visually similar writers as tricky negatives). |
| |
| **`load_signet_all(split, limit_writers)`** |
| |
| Convenience wrapper that calls `load_signet_signatures()` for every available writer and returns one flat list. Each entry is a dictionary with the fields: |
| |
| ``` |
| { |
| "path": "/path/to/sig.png", |
| "writer_id": "001", |
| "label": "genuine" or "forged", |
| "source": "signet" |
| } |
| ``` |
| |
| This is the function `synthesize_documents.py` calls when building the signature pool. |
|
|
| --- |
|
|
| ### Background Removal |
|
|
| **`remove_background(image_path, threshold=200)`** |
|
|
| Loads a signature image and removes its white or light-coloured background using **OpenCV grayscale thresholding**: |
|
|
| 1. Convert the image to grayscale |
| 2. Any pixel brighter than the threshold (default 200) is treated as background |
| 3. Build an alpha channel from that mask so background pixels become transparent |
| 4. Return a BGRA (OpenCV) image with a transparent background |
|
|
| This is a simple, no-ML approach that works well for offline signature scans which typically have a white paper background. |
|
|
| **`crop_to_content(rgba_image, padding=5)`** |
| |
| Finds the bounding box of all non-transparent pixels in the BGRA image and crops tightly around the signature ink, with a small padding margin. This removes excess whitespace so the signature fits neatly inside a document's signature box. |
| |
| --- |
| |
| ### Augmentation |
| |
| Augmentation adds variation to the signatures so the model does not overfit to a fixed set of images. Three individual transforms are available, and one combined function applies them randomly. |
| |
| **`augment_rotate(rgba_image, max_angle=10)`** |
|
|
| Randomly rotates the signature between β10Β° and +10Β° using `cv2.getRotationMatrix2D` and `warpAffine`. The border fill is transparent so no white rectangle is introduced. |
|
|
| **`augment_scale(rgba_image, scale_range=(0.85, 1.15))`** |
| |
| Randomly resizes the signature to between 85% and 115% of its original size using `cv2.resize`. |
| |
| **`augment_noise(rgba_image, amount=6)`** |
| |
| Adds small random pixel noise (Β±6 per channel) to the RGB channels only, leaving the alpha channel unchanged. This simulates slight pen pressure variation or scan noise. |
| |
| **`augment_signature(rgba_image)`** |
| |
| Applies the three transforms above in combination with random probabilities: |
| - Rotation: 70% chance |
| - Scaling: 70% chance |
| - Noise: 50% chance |
| |
| This is the function `synthesize_documents.py` calls when preparing each signature for pasting. |
| |
| --- |
| |
| ### Combined Pipeline |
| |
| **`prepare_signature(image_path, threshold=200, padding=5, augment=False)`** |
|
|
| The full single-signature preparation pipeline in one call: |
|
|
| ``` |
| cv2.imread() β remove_background() β crop_to_content() β [augment_signature()] |
| ``` |
|
|
| Returns a BGRA numpy array ready to be pasted onto a document. Returns `None` if the image cannot be read. This is the main function called by the generation loop. |
|
|
| --- |
|
|
| ## File 2: `synthesize_documents.py` |
| |
| This file handles **pasting prepared signatures onto document templates** and assembling the final labelled dataset. |
| |
| ### Document Templates |
| |
| The project uses **15 document templates** across 5 groups, stored as PNG files in `data/templates/`. The templates are real document layouts converted from PDF: |
| |
| | Group | Documents | |
| |---|---| |
| | Banking forms | Bank account opening, corporate account application, wealth management | |
| | Corporate governance | Board resolution, banking authorisation, shareholder resolution | |
| | Chinese documents | Business registration, employee application, service application | |
| | Tamil documents | Public service, trade licence, residence assessment | |
| | Cheques | Continental Trust, Global Merchant, Standard Clearing | |
| |
| ### Signature Box Coordinates |
| |
| For each template, the pixel coordinates `(x1, y1, x2, y2)` of every signature field are hardcoded in the `DOCUMENT_TYPES` dictionary. These coordinates were measured once using `pdfplumber` on the original born-digital PDFs, then visually verified by cropping and inspecting each box. They are not detected at runtime β the pipeline reads the stored values directly. |
|
|
| This approach works reliably because the templates are fixed PNGs converted from those same PDFs at a consistent resolution (1241Γ1754 pixels at 150 dpi). If a template changes or is a scanned image at a different scale, the coordinates would need to be re-measured. |
|
|
| --- |
|
|
| ### Core Functions |
|
|
| **`remove_white_background(sig_pil)`** |
| |
| A second, PIL-based background removal pass applied just before pasting. It converts the signature image to RGBA and sets any pixel where R > 200 AND G > 200 to fully transparent. This catches white and near-white paper while preserving ink β including blue ballpoint pen ink, which has a low green channel value and is not affected by this condition. |
| |
| **`paste_signature(doc_pil, sig_pil, box)`** |
|
|
| The core pasting function. For each signature box it: |
|
|
| 1. Calls `remove_white_background()` to clean the signature |
| 2. Resizes it to fit inside the box using `thumbnail()` which preserves aspect ratio |
| 3. Applies a small random offset of Β±5 pixels so signatures do not always land at the exact centre |
| 4. Converts the document to RGBA and pastes the signature using its alpha channel as a mask, so only ink pixels are blended in |
| 5. Applies a slight Gaussian blur (radius 0.3) to the whole document so the ink looks like it belongs on the paper rather than sitting on top of it |
| 6. Returns the final image as RGB |
|
|
| **`build_signature_pool()`** |
|
|
| Calls `load_cedar_signatures()` and `load_signet_all()` to combine all available signatures from both datasets into one flat pool. Each entry carries the file path, writer ID, label (genuine or forged), and source dataset. This pool is what the generation loop samples from randomly. |
|
|
| **`generate_synthetic_documents(num_documents=200)`** |
| |
| The main generation loop. For each of the 200 documents it: |
| |
| 1. Randomly picks a document type from `DOCUMENT_TYPES` |
| 2. Loads the corresponding template PNG |
| 3. For each signature box on that template, randomly picks a signature from the pool, runs it through `prepare_signature()` with augmentation, converts it from OpenCV BGRA to PIL RGBA, and calls `paste_signature()` |
| 4. Optional signature boxes (such as the officer rows in `banking_authorization`) have a 70% chance of being filled, simulating real documents that list one to three officers |
| 5. Labels the whole document as `"forged"` if any one signature on it is forged, otherwise `"genuine"` |
| 6. Saves the output PNG and appends a metadata row |
| |
| **`write_dataset_csv(rows)`** |
| |
| Writes metadata for all generated documents to `data/synthetic_dataset.csv`. Columns include filename, document type, group, overall label, number of signatures, and per-role details in the format `role:label:source`. |
| |
| --- |
| |
| ### What Gets Saved to Disk |
| |
| | Item | Saved? | Location | |
| |---|---|---| |
| | Raw signature images | Never written β read-only inputs | `data/raw/` | |
| | Cleaned and augmented signatures | No β RAM only, discarded after pasting | β | |
| | Final document PNGs | Yes | `data/synthetic/doc_001_β¦_genuine.png` | |
| | Dataset metadata | Yes | `data/synthetic_dataset.csv` | |
| |
| --- |
| |
| ### Dataset Label Distribution |
| |
| There is no fixed genuine-to-forged ratio. Because each document's overall label is `"forged"` if **any** signature on it is forged, documents with multiple signature boxes are more likely to be labelled forged β even with a 50/50 signature pool. For a document with three signature boxes, the probability of all three being genuine is only 12.5%, so the dataset will have significantly more forged documents than genuine ones by default. |
|
|
| To check the actual ratio after generation: |
|
|
| ```python |
| import pandas as pd |
| df = pd.read_csv("data/synthetic_dataset.csv") |
| print(df["label"].value_counts()) |
| ``` |
|
|
| --- |
|
|
| ## File 3: `generate_test_cases.py` |
|
|
| This file generates a **structured 50-case test suite** for evaluating a signature verification system. Each case is saved to its own folder and includes a generated document image, reference signature images, and a `ground_truth.json` file. |
|
|
| ### Test Case Types |
|
|
| | Type | Count | Description | Expected outcome | Rejection code | |
| |---|---|---|---|---| |
| | PASS | 20 | Correct person signs the correct field | ACCEPTED | β | |
| | NO_SIGNATURE | 5 | Signature fields left completely blank | REJECTED | `NO_SIGNATURE_FOUND` | |
| | WRONG_PERSON | 10 | An impostor's signature is pasted instead | REJECTED | `IDENTITY_MISMATCH` | |
| | WRONG_ROLE | 10 | Correct person but fields swapped | REJECTED | `WRONG_ROLE` | |
| | REQUIRED_PERSON_MISSING | 5 | One required field left blank on a multi-sig doc | REJECTED | `REQUIRED_SIGNATORY_MISSING` | |
|
|
| ### Persons |
|
|
| Ten named persons are defined, each mapped to a SigNet writer ID (`001`β`010`). Each person has 2β3 reference signature images copied from their genuine SigNet folder into the case directory as `reference_1.png`, `reference_2.png`, etc. |
|
|
| ### Output Structure |
|
|
| ``` |
| data/test_cases/ |
| case_001/ |
| document.png β generated document with signatures pasted |
| reference_1.png β known-good reference signature |
| reference_2.png |
| reference_3.png |
| ground_truth.json β case metadata and expected result |
| case_002/ |
| ... |
| test_cases_summary.csv β one row per case, all cases |
| ``` |
|
|
| ### ground_truth.json Format |
| |
| ```json |
| { |
| "case_id": "case_003", |
| "type": "WRONG_PERSON", |
| "document_type": "board_resolution", |
| "expected_outcome": "REJECTED", |
| "expected_rejection_code": "IDENTITY_MISMATCH", |
| "notes": "Signed by Jane Smith instead of John Doe.", |
| "authorized_signatory": { |
| "name": "John Doe", |
| "role": "director", |
| "ref_paths": ["reference_1.png", "reference_2.png", "reference_3.png"] |
| }, |
| "signed_roles": [ |
| { |
| "role": "director_1", |
| "signed_by": "Jane Smith", |
| "status": "filled_wrong_person" |
| }, |
| { |
| "role": "director_2", |
| "signed_by": "Jane Smith", |
| "status": "filled_wrong_person" |
| } |
| ] |
| } |
| ``` |
| |
| ### How Each Case Type is Generated |
|
|
| **PASS** β loads the authorized person's genuine SigNet signatures and pastes one into each required field with augmentation applied. |
|
|
| **NO_SIGNATURE** β loads the template but pastes nothing. All signature boxes are left as blank fields on the printed template. |
| |
| **WRONG_PERSON** β loads a different (unauthorized) person's genuine signatures and pastes them into all fields. The reference signatures in the folder still belong to the authorized person, making the mismatch detectable. |
|
|
| **WRONG_ROLE** β loads the authorized person's genuine signatures but pastes them into the wrong fields by reversing the order of signature boxes. For example, the person who should sign the applicant box signs the officer box and vice versa. |
| |
| **REQUIRED_PERSON_MISSING** β fills only the first signature box correctly and leaves all remaining required boxes blank. Only used with document types that have two or more signature boxes. |
| |
| ### Summary CSV |
| |
| `test_cases_summary.csv` contains one row per case with these columns: |
| |
| | Column | Description | |
| |---|---| |
| | `case_id` | e.g. `case_003` | |
| | `type` | One of the 5 scenario types | |
| | `document_type` | Which template was used | |
| | `expected_outcome` | `ACCEPTED` or `REJECTED` | |
| | `expected_rejection_code` | Rejection reason, or empty if accepted | |
| | `authorized_name` | Name of the authorized signatory | |
| | `authorized_role` | Their role on the document | |
| | `num_ref_signatures` | How many reference images are in the folder | |
| | `ref_paths` | Semicolon-separated list of reference filenames | |
| | `notes` | Human-readable description of what is wrong | |
| | `case_folder` | Relative path to the case folder | |
| |
| --- |
| |
| ## Full Data Flow Summary |
| |
| ``` |
| Raw datasets (disk) |
| CEDAR full_org / full_forg |
| SigNet train/ test/ per-writer folders |
| β |
| βΌ |
| build_signature_pool() |
| Combined pool: {path, writer_id, label, source} |
| Everything in RAM β nothing saved yet |
| β |
| βΌ (Γ200 documents) |
| For each document: |
| 1. Pick random doc type β load template PNG from disk |
| 2. For each signature box: |
| Pick random signature from pool |
| prepare_signature() |
| β remove_background() OpenCV threshold β transparent bg |
| β crop_to_content() tight crop around ink |
| β augment_signature() rotate / scale / noise |
| Convert BGRA β PIL RGBA |
| paste_signature() |
| β remove_white_background() PIL-based final bg removal |
| β thumbnail() resize to fit box |
| β paste() with alpha mask blend ink onto document |
| β GaussianBlur(0.3) soften ink into paper |
| 3. Label document: "forged" if any sig is forged |
| 4. doc_image.save() β data/synthetic/doc_NNN_β¦_label.png |
| 5. Append metadata row |
| β |
| βΌ |
| write_dataset_csv() |
| β data/synthetic_dataset.csv |
| ``` |
| |