diff --git a/README.md b/README.md index 09b38cb..d76d106 100644 --- a/README.md +++ b/README.md @@ -1,59 +1,584 @@ -# Formular Test Data Generator +# Formular Test Data Generator -This project provides a complete workflow for creating synthetic form datasets. It includes a visual editor for defining field regions on a template image and a generator that fills these templates with synthetic data. The system is fully extensible and allows adding new data generation functions without modifying the generator code. The license is intentionally open so it can be used commercially as long as it remains open. +FormGenX creates synthetic form datasets from image templates. It supports the existing text and checkbox workflow and now also supports realistic, parameter-driven stamp generation for OCR, document AI, and synthetic training data generation. -## Features +The stamp system is implemented as an extension of the current pipeline. Existing configs continue to work unchanged. Stamp rendering is optional and activates when a layout field is `type: "stamp"` (configured under `stamps.`) or when a text field uses `render_mode: "stamp"`. -- Visual template editor using OpenCV. -- Supports text fields, single checkboxes, and checkbox groups. -- Generates synthetic datasets with realistic variability. -- Configurable data generation through external JSON files. -- Dynamic discovery of generation functions. -- Outputs image-based form examples. -- Stores a JSON per sample that captures who filled the form and which values/checks were applied. -- Fully open licensing for commercial use. +## What the Project Does + +- Edit a form template visually with OpenCV. +- Save field coordinates into a layout JSON file. +- Generate many synthetic filled forms from a JSON config. +- Render normal computer text and checkboxes. +- Render realistic stamp content with uneven ink, blur, ghosting, crop, scan-like degradation, and black handwriting/signature overlays. +- Save a metadata JSON next to each generated image. ## Installation -1. Install Python 3.10 or higher. -2. Install dependencies: +Requirements: +- Python `3.11+` +- A working virtual environment for the repository -``` +Install dependencies: + +```bash uv sync ``` -3. Place your template image in the project folder. +## Quick Start + +Edit a template: + +```bash +python main.py edit --template template.png +``` -## Usage +Generate a normal sample set without stamps: -### 1. Editing a Template +```bash +python main.py generate --template examples/without_stempel/example.png --config examples/without_stempel/config.json --gennum 20 --outputfolder out/without_stempel --outputtype png +``` -Run the editor to define text fields and checkbox areas on your template: +Generate samples with realistic stamps: +```bash +python main.py generate --template examples/with_stempel/example.png --config examples/with_stempel/config.json --gennum 20 --outputfolder out/with_stempel --outputtype png ``` + +## CLI Reference + +### Edit mode + +```bash python main.py edit --template template.png ``` -Controls: -- t for text fields -- c for single checkbox fields -- ca for checkbox groups -- s to save annotations -- q to quit +Editor controls: +- `t`: create a text field +- `c`: create a checkbox +- `st`: create a stamp field (layout type `stamp`) +- `ca`: create a checkbox group +- `s`: save layout JSON +- `q`: quit + +### Generate mode + +```bash +python main.py generate --template template.png --config config.json --gennum 20 --outputfolder out --outputtype png +``` + +Arguments: +- `--template` / `-t`: template image path +- `--config` / `-c`: generator config path +- `--gennum` / `-n`: number of images to generate +- `--outputfolder` / `-f`: output directory +- `--outputtype` / `-o`: image format such as `png` or `jpg` +- `--data-path` / `-d`: optional extra JSON data file + +## Project Structure + +- [main.py](main.py): CLI entrypoint +- [generator.py](generator.py): generation pipeline and stamp integration +- [dataGenFunctions.py](dataGenFunctions.py): generator functions for text, checkboxes, and stamp payloads +- `stempel_module/`: stamp rendering subsystem +- `examples/without_stempel/`: baseline example with no stamp +- `examples/with_stempel/`: example with realistic named stamp fields (`stamp1`, `stamp2`) +- `tests/`: unit tests + +## Core Config Structure + +The generator config uses these top-level sections: + +```json +{ + "template": "example.png", + "layout": "example.json", + "global": {}, + "fields": {}, + "stamp_presets": {}, + "stamps": {} +} +``` + +Meaning: +- `template`: image used as the source form +- `layout`: layout JSON created by the editor +- `global`: default rendering settings +- `fields`: per-field generation config +- `stamp_presets`: optional project-specific preset overrides or additions +- `stamps`: per-stamp-name config for layout fields of `type: "stamp"` + +## Standard Field Configuration + +Normal text and checkbox fields still work exactly as before. + +Example: + +```json +{ + "fields": { + "name": { + "generator": "from_list", + "presence_prob": 1.0, + "style": "computer", + "params": { + "values": ["Mueller", "Schmidt"] + } + } + } +} +``` + +Important field keys: +- `generator`: function name from `DataGenFunctions` +- `presence_prob`: probability the field appears +- `style`: current normal text rendering style +- `params`: parameters passed to the generator function + +## Stamp System Overview + +The stamp system is Pillow-based and is separate from the normal `cv2.putText(...)` path. OpenCV is still used for the base image and checkbox drawing, but stamp drawing uses layered RGBA rendering so it can support realistic local damage and overlays. + +Two stamp entry points are supported: + +1. Named stamp layout field (`type: "stamp"`) configured via `stamps.` +2. Text field rendered as stamp (`render_mode: "stamp"`) + +### Variant 1: Stamp field from editor (`type: "stamp"`) + +You can now create stamp regions directly in editor mode with `st`. + +Example layout entry produced by the editor: + +```json +{ + "name": "stamp1", + "type": "stamp", + "x1": 620, + "y1": 150, + "x2": 980, + "y2": 410 +} +``` + +To render this field, configure `stamps.` in config: + +```json +{ + "stamps": { + "stamp1": { + "generator": "doctor_stamp_lines", + "presence_prob": 0.85, + "params": { + "preset": "medical_with_black_signature" + } + } + } +} +``` + +### Variant 2: Stamp as a text field + + +Use this when a field region should behave like a stamp instead of normal text. + +```json +{ + "fields": { + "arztstempel": { + "generator": "doctor_stamp_lines", + "render_mode": "stamp", + "presence_prob": 0.85, + "params": { + "preset": "medical_with_black_signature", + "line_template": [ + "Dr. med. {full_name}", + "Facharzt fuer {specialty}", + "{street}", + "{postcode} {city}", + "BSNR: {bsnr}" + ] + } + } + } +} +``` + +How it works: +- the field still comes from the normal layout JSON +- the configured generator returns a structured payload with stamp lines +- `generator.py` detects `render_mode: "stamp"` +- the renderer switches from normal text drawing to the stamp subsystem + +## Stamp Presets + +Built-in presets currently available in [stempel_module/stamp_presets.py](stempel_module/stamp_presets.py): + +- `clean` +- `light_faded` +- `medical_faded` +- `medical_uneven_ink` +- `medical_with_black_signature` +- `medical_with_black_handwriting` +- `medical_ghosted` +- `medical_washed_out` +- `medical_extreme_scan` + +Preset intent: +- `clean`: almost no damage, close to a fresh stamp +- `light_faded`: mild fade and mild blur +- `medical_faded`: stronger per-character variation and fade +- `medical_uneven_ink`: more line/section-based unevenness +- `medical_with_black_signature`: faded stamp with black signature overlay +- `medical_with_black_handwriting`: faded stamp with text-style black note overlay +- `medical_ghosted`: stronger double-stamp tendency +- `medical_washed_out`: washed and weak stamp +- `medical_extreme_scan`: degraded scan/fax style output + +You can define your own presets under `stamp_presets` in a config. Project-level presets override or extend the built-ins. + +Example: + +```json +{ + "stamp_presets": { + "my_heavy_red_stamp": { + "effects": { + "ink_color": [160, 60, 60], + "opacity_min": 0.25, + "opacity_max": 0.70, + "visibility_mode": "per_section", + "ghost_prob": 0.25, + "blur_radius_min": 0.7, + "blur_radius_max": 1.4 + }, + "handwriting": { + "enabled": true, + "mode": "signature" + } + } + } +} +``` + +## Stamp Effects and Configuration + +The stamp renderer is driven by two main dataclasses: +- `StampEffects` +- `HandwritingOverlaySpec` + +Their defaults are defined in [stempel_module/stamp_models.py](stempel_module/stamp_models.py). + +### `StampEffects` fields + +- `ink_color`: base RGB ink color, usually blue or purple-like +- `opacity_min`, `opacity_max`: visibility range used when sampling glyph and section opacity +- `visibility_mode`: one of `uniform`, `per_character`, `per_word`, `per_section` +- `char_dropout_prob`: chance of heavily weakening individual characters +- `word_dropout_prob`: chance of weakening words +- `section_dropout_prob`: chance of weakening whole sections/lines +- `missing_ink_prob`: chance of applying clustered missing ink +- `missing_ink_strength`: strength of the missing-ink mask +- `blur_radius_min`, `blur_radius_max`: sampled blur range +- `rotation_min`, `rotation_max`: normal rotation range +- `strong_rotation_prob`: chance to use the strong rotation path +- `strong_rotation_min`, `strong_rotation_max`: stronger rotation range +- `ghost_prob`: chance to create a ghost/double stamp +- `ghost_offset_min`, `ghost_offset_max`: offset range for ghost placement +- `ghost_opacity_min`, `ghost_opacity_max`: alpha range for ghost stamp +- `washed_out_prob`: chance to desaturate and weaken the stamp +- `washed_out_strength_min`, `washed_out_strength_max`: washed-out intensity range +- `edge_fade_prob`: chance to weaken edges +- `crop_prob`: chance to apply internal crop and stronger placement offset +- `crop_max_ratio`: maximum crop ratio per side +- `paper_bleed_prob`: chance to modulate stamp alpha by paper brightness +- `scan_noise_prob`: chance to add scan/noise/banding style degradation +- `jpeg_artifact_prob`: chance to apply JPEG-like compression artifacts -A JSON layout file will be generated automatically. +### Visibility modes -### 2. Generating Data +`uniform`: +- one opacity tendency for the whole stamp +- use this when you want a mostly clean stamp -Use a config JSON to map fields to generator functions. +`per_character`: +- each character can fade independently +- best when you want weak letters like `Dr.` or partially missing characters + +`per_word`: +- entire words vary together +- useful for address lines or stamps where one word is much weaker + +`per_section`: +- different lines or sections vary separately +- useful when title, address, or BSNR line should have different intensities + +### `HandwritingOverlaySpec` fields + +- `enabled`: enable black overlay rendering +- `color`: RGB color, typically dark gray or black +- `mode`: `text`, `signature`, or `text_or_signature` +- `text_prob`: probability for text when mixed mode is used +- `signature_prob`: probability for signature when mixed mode is used +- `overlap_prob`: chance that the handwriting overlay is actually applied +- `rotation_min`, `rotation_max`: handwriting rotation +- `text_values`: optional values for handwritten notes +- `opacity_min`, `opacity_max`: handwriting opacity range +- `line_width_min`, `line_width_max`: signature stroke width range + +## Stamp Content Generators + +The new stamp-related generators live in [dataGenFunctions.py](dataGenFunctions.py). + +Available helpers: +- `doctor_stamp_lines` +- `doctor_name` +- `doctor_specialty` +- `doctor_bsnr` +- `doctor_lanr` +- `doctor_phone` +- `handwritten_note` + +### `doctor_stamp_lines(params)` + +This is the main stamp payload generator. It returns a structure like: + +```json +{ + "preset": "medical_with_black_signature", + "lines": [ + { "text": "Dr. med. Anna Schmidt", "section_id": "line_0", "font_size": 28 }, + { "text": "Facharzt fuer Allgemeinmedizin", "section_id": "line_1", "font_size": 28 } + ], + "fields": { + "full_name": "Anna Schmidt", + "city": "Berlin", + "street": "Hauptstrasse 12", + "postcode": "10115", + "phone": "030 1234567", + "bsnr": "123456789", + "lanr": "123456789" + } +} +``` + +Useful parameters: +- `preset` +- `line_template` +- `font_size` +- `first_names` +- `last_names` +- `specialties` +- `cities` +- `streets` +- `titles` +- `area_codes` +- `bsnr_min`, `bsnr_max` +- `lanr_min`, `lanr_max` Example: +```json +{ + "generator": "doctor_stamp_lines", + "params": { + "preset": "medical_faded", + "font_size": 30, + "titles": ["Dr. med.", "PD Dr."], + "specialties": ["Dermatologie", "Neurologie"], + "cities": ["Berlin", "Hamburg"], + "line_template": [ + "{doctor_name}", + "Facharzt fuer {specialty}", + "{street}", + "{postcode} {city}", + "LANR: {lanr}", + "BSNR: {bsnr}" + ] + } +} ``` -python main.py generate --template example.png --config config.json --gennum 20 --outputfolder out --outputtype png + +## Full Configuration Examples + +### Example A: Text field rendered as stamp + +```json +{ + "fields": { + "arztstempel": { + "generator": "doctor_stamp_lines", + "render_mode": "stamp", + "presence_prob": 0.85, + "params": { + "preset": "medical_with_black_handwriting", + "line_template": [ + "{doctor_name}", + "Facharzt fuer {specialty}", + "{street}", + "{postcode} {city}", + "Tel.: {phone}" + ] + } + } + } +} ``` -The generator will create multiple synthetic example images based on the template and configuration. +### Example B: Two named stamps with different style config + +```json +{ + "stamp_presets": { + "my_stamp": { + "effects": { + "ink_color": [120, 75, 150], + "visibility_mode": "per_section", + "opacity_min": 0.20, + "opacity_max": 0.75, + "ghost_prob": 0.3 + }, + "handwriting": { + "enabled": true, + "mode": "text_or_signature", + "text_values": ["gez.", "i.A.", "ok"] + } + } + }, + "stamps": { + "stamp1": { + "presence_prob": 0.9, + "generator": "doctor_stamp_lines", + "params": { + "preset": "my_stamp", + "line_template": [ + "{doctor_name}", + "{street}", + "{postcode} {city}", + "BSNR: {bsnr}" + ] + } + }, + "stamp2": { + "presence_prob": 0.6, + "generator": "doctor_stamp_lines", + "params": { + "preset": "medical_faded", + "line_template": [ + "Praxis {full_name}", + "{street}", + "{postcode} {city}", + "LANR: {lanr}" + ] + }, + "stamp": { + "effects": { + "visibility_mode": "per_section", + "opacity_min": 0.2, + "opacity_max": 0.75 + } + } + } + } +} +``` + +## Output Metadata + +Every generated image gets a sibling JSON metadata file. + +Stamp metadata includes sampled values such as: +- preset name +- region +- line, word, and character counts +- visibility mode +- sampled opacity min/max +- blur radius +- whether washed-out effect was applied +- whether ghosting was applied +- whether crop was applied +- whether handwriting/signature was applied +- placement offset + +This is useful for debugging realism and for future deterministic replay support. + +## Examples Directory + +See [examples/README.md](examples/README.md) for a guide to the example folders. + +Folders: +- [examples/without_stempel/config.json](examples/without_stempel/config.json): baseline config +- [examples/with_stempel/config.json](examples/with_stempel/config.json): stamp-enabled config + +## Q&A + +### Does the new stamp system break existing configs? + +No. Existing configs still use the old text and checkbox rendering path unless stamp-specific config is added. + +### When should I use `render_mode: "stamp"` instead of `stamps`? + +Use `stamps.` with layout fields of `type: "stamp"` when you want explicit stamp regions configured by name (`stamp1`, `stamp2`, etc.). Use `render_mode: "stamp"` when an existing text field should be rendered with stamp effects. + +### Can I still use normal text and checkboxes in the same document? + +Yes. The generator supports normal fields and stamp rendering in the same sample. + +### How do I make a stamp look more faded? + +Use a weaker preset such as `medical_faded` or reduce `opacity_min` and `opacity_max`, increase `missing_ink_prob`, and increase blur or washed-out probabilities. + +### How do I get per-character fading? + +Set `visibility_mode` to `per_character`. That is the best option when individual letters should weaken differently. + +### How do I get line-based or section-based variation? + +Set `visibility_mode` to `per_section`. Each line or section can then get a different sampled opacity profile. + +### How do I add handwriting or a black signature on top? + +Enable handwriting in the preset or config and set `mode` to `text`, `signature`, or `text_or_signature`. + +### Can I create my own preset in the config file? + +Yes. Add a `stamp_presets` block. Custom presets are merged with the built-in presets and can override them by name. + +### Where do the stamp lines come from? + +Usually from `doctor_stamp_lines(params)`, which creates structured lines from random doctor data and the `line_template`. + +### Can I render more than one stamp on a document? + +Yes. Add multiple `type: "stamp"` fields in layout JSON and configure each one under `stamps.`. + +### Is the stamp renderer deterministic? + +Not yet by explicit seed control. It uses random sampling internally. Deterministic generation can be added later by introducing seed plumbing through the pipeline. +## Disclaimer + +This repository, including all generated images, metadata, synthetic or fictitious forms, stamps, handwriting overlays, and related outputs, is provided solely for educational, research, testing, and demonstration purposes. + +The generated data is artificial, synthetic, and fictitious, and must not be relied upon, deployed, or used in any real-world application, production system, operational workflow, compliance process, medical process, legal process, governmental process, financial process, identity verification process, or decision-making system. + +This repository is not intended for, and must not be used for, fraud, impersonation, deception, forgery, falsification of records, circumvention of verification processes, or any unlawful, unethical, or misleading activity. + +No representation or warranty is made that the generated data is accurate, complete, safe, lawful, compliant, unbiased, or fit for any particular purpose. Any resemblance to real persons, institutions, records, identifiers, or documents is incidental and does not create any authorization for real-world use. + +By using this project, the user accepts full responsibility for ensuring that all use remains limited to lawful educational or experimental contexts. Any use outside those limited contexts is expressly discouraged and is undertaken entirely at the user's own risk. + +### Ethical Use + +This project is intended to support research, experimentation, and educational work related to synthetic data generation, machine learning, and computer vision. Users are expected to use this repository responsibly and in accordance with applicable laws and ethical standards. + +The tools and generated outputs provided by this repository must not be used to create deceptive materials, impersonate individuals or institutions, produce fraudulent documents, or otherwise mislead people or systems. + +Any misuse of this project for illegal, harmful, deceptive, or unethical purposes is strictly discouraged and is solely the responsibility of the user. + +### Dataset Limitations + +All generated images, documents, metadata, and related artifacts produced by this repository are synthetic and may contain inaccuracies, unrealistic artifacts, structural inconsistencies, or incomplete representations of real-world documents. -Each output sample now produces a companion JSON file in the same folder. The metadata file shares the image's base name and ends with `.json` (for example `sample_1.json`) and records when the form was generated, who triggered the run, and the exact field values or checkbox selections that were written to the image. +These outputs are not intended to reflect real institutions, official document formats, or authentic administrative processes. The generated data may also contain biases or simplifications introduced by the generation process. +Users should treat all generated outputs strictly as experimental or illustrative data and should not assume correctness, realism, or suitability for real-world applications. diff --git a/dataGenFunctions.py b/dataGenFunctions.py index 01b3273..3c78afa 100644 --- a/dataGenFunctions.py +++ b/dataGenFunctions.py @@ -1,44 +1,154 @@ -# ============================ -# DATA GENERATION FUNCTIONS -# ============================ -from datetime import datetime, timedelta -import random - -class DataGenFunctions: - def __init__(self, data_store=None): - self.data_store = data_store - - def from_list(self, params): - values = params.get("values", []) - return random.choice(values) if values else "" - - def date(self, params): - start_year = params.get("start_year", 1970) - end_year = params.get("end_year", 2020) - start = datetime(start_year, 1, 1) - end = datetime(end_year, 12, 31) - d = start + timedelta(days=random.randint(0, (end - start).days)) - return d.strftime("%d.%m.%Y") - - def checkbox_binary(self, params): - p = params.get("true_prob", 0.5) - return random.random() < p - - def checkbox_group_random(self, params): - children = params.get("children", []) - mode = params.get("mode", "single") - missing_prob = params.get("missing_prob", 0.0) - - if not children: - return [] - - if random.random() < missing_prob: - return [] - - names = [c["name"] for c in children] - - if mode == "single": - return [random.choice(names)] - - k = random.randint(1, len(names)) - return random.sample(names, k) \ No newline at end of file +# ============================ +# DATA GENERATION FUNCTIONS +# ============================ +from datetime import datetime, timedelta +import random + + +class DataGenFunctions: + def __init__(self, data_store=None): + self.data_store = data_store + + def from_list(self, params): + values = params.get("values", []) + return random.choice(values) if values else "" + + def date(self, params): + start_year = params.get("start_year", 1970) + end_year = params.get("end_year", 2020) + start = datetime(start_year, 1, 1) + end = datetime(end_year, 12, 31) + d = start + timedelta(days=random.randint(0, (end - start).days)) + return d.strftime("%d.%m.%Y") + + def checkbox_binary(self, params): + p = params.get("true_prob", 0.5) + return random.random() < p + + def checkbox_group_random(self, params): + children = params.get("children", []) + mode = params.get("mode", "single") + missing_prob = params.get("missing_prob", 0.0) + + if not children: + return [] + + if random.random() < missing_prob: + return [] + + names = [c["name"] for c in children] + + if mode == "single": + return [random.choice(names)] + + k = random.randint(1, len(names)) + return random.sample(names, k) + + # ============================ + # STAMP CONTENT GENERATORS + # ============================ + def doctor_name(self, params): + first_names = params.get("first_names", ["Anna", "Max", "Julia", "Leon", "Nina", "David"]) + last_names = params.get("last_names", ["Schmidt", "Mueller", "Weber", "Braun", "Fischer", "Klein"]) + return f"{random.choice(first_names)} {random.choice(last_names)}" + + def doctor_specialty(self, params): + specialties = params.get( + "specialties", + [ + "Allgemeinmedizin", + "Innere Medizin", + "Orthopaedie", + "Dermatologie", + "Neurologie", + "Kardiologie", + ], + ) + return random.choice(specialties) + + def doctor_bsnr(self, params): + min_value = int(params.get("bsnr_min", 100000000)) + max_value = int(params.get("bsnr_max", 999999999)) + return str(random.randint(min_value, max_value)) + + def doctor_lanr(self, params): + min_value = int(params.get("lanr_min", 100000000)) + max_value = int(params.get("lanr_max", 999999999)) + return str(random.randint(min_value, max_value)) + + def doctor_phone(self, params): + area_codes = params.get("area_codes", ["030", "040", "0221", "089", "069", "0711"]) + area = random.choice(area_codes) + body_a = random.randint(100, 999) + body_b = random.randint(10000, 99999) + return f"{area} {body_a}{body_b}" + + def handwritten_note(self, params): + notes = params.get( + "values", + ["gez.", "i.A.", "ok", "dringend", "eilig", "heute", datetime.now().strftime("%d.%m.%y")], + ) + return random.choice(notes) + + def doctor_stamp_lines(self, params): + first_names = params.get("first_names", ["Anna", "Max", "Julia", "Leon", "Nina", "David"]) + last_names = params.get("last_names", ["Schmidt", "Mueller", "Weber", "Braun", "Fischer", "Klein"]) + specialties = params.get( + "specialties", + ["Allgemeinmedizin", "Innere Medizin", "Orthopaedie", "Dermatologie"], + ) + cities = params.get("cities", ["Berlin", "Koeln", "Hamburg", "Muenchen", "Bonn"]) + streets = params.get("streets", ["Hauptstrasse", "Bahnhofstrasse", "Gartenweg", "Lindenweg"]) + + first = random.choice(first_names) + last = random.choice(last_names) + doctor_title = random.choice(params.get("titles", ["Dr. med.", "Dr.", "PD Dr."])) + specialty = random.choice(specialties) + city = random.choice(cities) + street = random.choice(streets) + + payload = { + "full_name": f"{first} {last}", + "doctor_name": f"{doctor_title} {first} {last}", + "specialty": specialty, + "city": city, + "street": f"{street} {random.randint(1, 90)}", + "postcode": str(random.randint(10000, 99999)), + "phone": self.doctor_phone(params), + "bsnr": self.doctor_bsnr(params), + "lanr": self.doctor_lanr(params), + } + + line_template = params.get( + "line_template", + [ + "{doctor_name}", + "Facharzt fuer {specialty}", + "{street}", + "{postcode} {city}", + "Tel.: {phone}", + "BSNR: {bsnr}", + ], + ) + + lines = [] + for idx, template in enumerate(line_template): + try: + txt = str(template).format(**payload) + except (KeyError, ValueError): + txt = str(template) + if not txt: + continue + lines.append( + { + "text": txt, + "section_id": f"line_{idx}", + "font_size": int(params.get("font_size", 28)), + } + ) + + return { + "preset": params.get("preset", "medical_with_black_signature"), + "lines": lines, + "fields": payload, + } diff --git a/editor.py b/editor.py index dfaa6db..35ca56b 100644 --- a/editor.py +++ b/editor.py @@ -1,193 +1,209 @@ -import cv2 -import json -import os - - -class EditConfig: - def __init__(self, template: str): - self.template = template - - -class Editor: - def __init__(self, config: EditConfig): - self.template_path = config.template - self.img = cv2.imread(self.template_path) - - if self.img is None: - raise FileNotFoundError(f"Template not found: {self.template_path}") - - self.display_img = self.img.copy() - self.fields = [] - - # ============================ - # MAIN LOOP - # ============================ - def run(self): - print("=== FORMULAR EDITOR ===") - - while True: - mode = self.ask_mode() - - if mode == "t": - self.process_field_loop("text") - - elif mode == "c": - self.process_field_loop("checkbox") - - elif mode == "ca": - self.process_checkbox_area() - - elif mode == "s": - end = self.template_path.split(".")[-1] - cv2.imwrite(f"edited_image.{end}",self.display_img) - self.save_json() - - elif mode == "q": - print("Exiting editor.") - break - - # ============================ - # MODE SELECTOR - # ============================ - def ask_mode(self): - print("\n--- Select mode ---") - print("t: text field") - print("c: checkbox field") - print("ca: checkbox area (parent + children)") - print("s: save") - print("q: quit") - - while True: - mode = input("Mode: ").lower().strip() - if mode in ["t", "c", "ca", "s", "q"]: - return mode - print("Invalid mode. Use t/c/ca/s/q.") - - # ============================ - # SIMPLE TEXT OR CHECKBOX FIELD - # ============================ - def process_field_loop(self, field_type): - print(f"\n=== {field_type.upper()} MODE ACTIVE ===") - print("Select area with mouse, press ENTER to confirm.") - print("Press 'q' in ROI window to return to mode selection.") - - while True: - roi = cv2.selectROI("Select Field", self.display_img, - showCrosshair=True, fromCenter=False) - - # User pressed q → ROI empty - if roi == (0, 0, 0, 0): - print("Returning to mode selection...") - cv2.destroyWindow("Select Field") - return - - x, y, w, h = roi - cv2.destroyWindow("Select Field") - - x1, y1, x2, y2 = x, y, x + w, y + h - print(f"Selected: {x1}, {y1} → {x2}, {y2}") - - name = input("Enter field name: ").strip() - - field = { - "name": name, - "type": field_type, - "x1": int(x1), - "y1": int(y1), - "x2": int(x2), - "y2": int(y2) - } - - self.fields.append(field) - self.draw_field(field, (0, 0, 255)) # red - - # ============================ - # CHECKBOX GROUP MODE - # ============================ - def process_checkbox_area(self): - print("\n=== CHECKBOX AREA MODE ===") - print("Select PARENT checkbox area.") - - roi = cv2.selectROI("Select Parent Checkbox", self.display_img, - showCrosshair=True, fromCenter=False) - cv2.destroyWindow("Select Parent Checkbox") - - x, y, w, h = roi - if w == 0 or h == 0: - print("Cancelled.") - return - - x1, y1, x2, y2 = x, y, x + w, y + h - pname = input("Parent checkbox group name: ").strip() - - parent = { - "name": pname, - "type": "checkbox_group", - "x1": int(x1), - "y1": int(y1), - "x2": int(x2), - "y2": int(y2), - "children": [] - } - - self.draw_field(parent, (0, 0, 255)) - - print("Now select CHILD checkboxes.") - print("Press C in ROI window to finish child selection.") - - while True: - roi = cv2.selectROI("Select Child Checkbox", self.display_img, - showCrosshair=True, fromCenter=False) - - if roi == (0, 0, 0, 0): - cv2.destroyWindow("Select Child Checkbox") - print("Finished child selection.") - break - - x, y, w, h = roi - cname = input("Child checkbox name: ").strip() - - child = { - "name": cname, - "type": "checkbox", - "x1": int(x), - "y1": int(y), - "x2": int(x + w), - "y2": int(y + h) - } - - parent["children"].append(child) - self.draw_field(child, (0, 255, 0)) # green - - self.fields.append(parent) - - # ============================ - # DRAW BOX + LABEL - # ============================ - def draw_field(self, field, color): - x1, y1, x2, y2 = field["x1"], field["y1"], field["x2"], field["y2"] - name = field["name"] - - cv2.rectangle(self.display_img, (x1, y1), (x2, y2), color, 2) - - cv2.putText( - self.display_img, - name, - (x1, max(15, y1 - 5)), - cv2.FONT_HERSHEY_SIMPLEX, - 0.6, - color, - 2 - ) - - cv2.imshow("Template", self.display_img) - cv2.waitKey(10) - - # ============================ - # SAVE JSON - # ============================ - def save_json(self): - out = os.path.splitext(self.template_path)[0] + ".json" - with open(out, "w") as f: - json.dump(self.fields, f, indent=4) - print(f"Saved layout JSON → {out}") +import cv2 +import json +import os + + +class EditConfig: + def __init__(self, template: str): + self.template = template + + +class Editor: + def __init__(self, config: EditConfig): + self.template_path = config.template + self.img = cv2.imread(self.template_path) + + if self.img is None: + raise FileNotFoundError(f"Template not found: {self.template_path}") + + self.display_img = self.img.copy() + self.fields = [] + + # ============================ + # MAIN LOOP + # ============================ + def run(self): + print("=== FORMULAR EDITOR ===") + + while True: + mode = self.ask_mode() + + if mode == "t": + self.process_field_loop("text", (0, 0, 255)) + + elif mode == "c": + self.process_field_loop("checkbox", (0, 0, 255)) + + elif mode == "st": + self.process_field_loop("stamp", (180, 0, 180)) + + elif mode == "ca": + self.process_checkbox_area() + + elif mode == "s": + end = self.template_path.split(".")[-1] + cv2.imwrite(f"edited_image.{end}", self.display_img) + self.save_json() + + elif mode == "q": + print("Exiting editor.") + break + + # ============================ + # MODE SELECTOR + # ============================ + def ask_mode(self): + print("\n--- Select mode ---") + print("t: text field") + print("c: checkbox field") + print("st: stamp field") + print("ca: checkbox area (parent + children)") + print("s: save") + print("q: quit") + + while True: + mode = input("Mode: ").lower().strip() + if mode in ["t", "c", "st", "ca", "s", "q"]: + return mode + print("Invalid mode. Use t/c/st/ca/s/q.") + + # ============================ + # SIMPLE TEXT / CHECKBOX / STAMP FIELD + # ============================ + def process_field_loop(self, field_type, color): + print(f"\n=== {field_type.upper()} MODE ACTIVE ===") + print("Select area with mouse, press ENTER to confirm.") + print("Press 'q' in ROI window to return to mode selection.") + + while True: + roi = cv2.selectROI("Select Field", self.display_img, showCrosshair=True, fromCenter=False) + + # User pressed q -> ROI empty + if roi == (0, 0, 0, 0): + print("Returning to mode selection...") + cv2.destroyWindow("Select Field") + return + + x, y, w, h = roi + cv2.destroyWindow("Select Field") + + x1, y1, x2, y2 = x, y, x + w, y + h + print(f"Selected: {x1}, {y1} -> {x2}, {y2}") + + name = input("Enter field name: ").strip() + if not name: + print("Field name cannot be empty. Selection skipped.") + continue + + field = { + "name": name, + "type": field_type, + "x1": int(x1), + "y1": int(y1), + "x2": int(x2), + "y2": int(y2), + } + + self.fields.append(field) + self.draw_field(field, color) + + # ============================ + # CHECKBOX GROUP MODE + # ============================ + def process_checkbox_area(self): + print("\n=== CHECKBOX AREA MODE ===") + print("Select PARENT checkbox area.") + + roi = cv2.selectROI("Select Parent Checkbox", self.display_img, showCrosshair=True, fromCenter=False) + cv2.destroyWindow("Select Parent Checkbox") + + x, y, w, h = roi + if w == 0 or h == 0: + print("Cancelled.") + return + + x1, y1, x2, y2 = x, y, x + w, y + h + pname = input("Parent checkbox group name: ").strip() + if not pname: + print("Parent group name cannot be empty. Cancelled.") + return + + parent = { + "name": pname, + "type": "checkbox_group", + "x1": int(x1), + "y1": int(y1), + "x2": int(x2), + "y2": int(y2), + "children": [], + } + + self.draw_field(parent, (0, 0, 255)) + + print("Now select CHILD checkboxes.") + print("Press C in ROI window to finish child selection.") + + while True: + roi = cv2.selectROI("Select Child Checkbox", self.display_img, showCrosshair=True, fromCenter=False) + + if roi == (0, 0, 0, 0): + cv2.destroyWindow("Select Child Checkbox") + print("Finished child selection.") + break + + x, y, w, h = roi + cname = input("Child checkbox name: ").strip() + if not cname: + print("Child name cannot be empty. Skipped.") + continue + + child = { + "name": cname, + "type": "checkbox", + "x1": int(x), + "y1": int(y), + "x2": int(x + w), + "y2": int(y + h), + } + + parent["children"].append(child) + self.draw_field(child, (0, 255, 0)) + + self.fields.append(parent) + + # ============================ + # DRAW BOX + LABEL + # ============================ + def draw_field(self, field, color): + x1, y1, x2, y2 = field["x1"], field["y1"], field["x2"], field["y2"] + name = field["name"] + + cv2.rectangle(self.display_img, (x1, y1), (x2, y2), color, 2) + + cv2.putText( + self.display_img, + name, + (x1, max(15, y1 - 5)), + cv2.FONT_HERSHEY_SIMPLEX, + 0.6, + color, + 2, + ) + + cv2.imshow("Template", self.display_img) + cv2.waitKey(10) + + # ============================ + # SAVE JSON + # ============================ + def save_json(self): + out = os.path.splitext(self.template_path)[0] + ".json" + h, w = self.img.shape[:2] + payload = { + "template": self.template_path, + "template_size": {"width": int(w), "height": int(h)}, + "fields": self.fields, + } + with open(out, "w", encoding="utf-8") as f: + json.dump(payload, f, indent=4) + print(f"Saved layout JSON -> {out}") diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..a8933e4 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,85 @@ +# Examples + +This folder contains two ready-to-run FormGenX setups. + +## Folders + +- [without_stempel/config.json](without_stempel/config.json): baseline generation with text and checkboxes only. +- [with_stempel/config.json](with_stempel/config.json): same base form plus two named stamp regions (`stamp1`, `stamp2`). + +Each example folder contains: +- `example.png`: template image +- `example.json`: layout file +- `config.json`: generator config + +## Run baseline example + +```bash +python main.py generate --template examples/without_stempel/example.png --config examples/without_stempel/config.json --gennum 10 --outputfolder out/without_stempel --outputtype png +``` + +## Run stamp example + +```bash +python main.py generate --template examples/with_stempel/example.png --config examples/with_stempel/config.json --gennum 10 --outputfolder out/with_stempel --outputtype png +``` + +## Stamp example (new pattern) + +The `with_stempel` setup shows the recommended stamp configuration: +- Stamp coordinates are in layout JSON as `type: "stamp"` fields. +- Stamp style/content is in config JSON under `stamps.`. +- Stamp names come from the layout field names, for example `stamp1`, `stamp2`. + +Layout excerpt (`examples/with_stempel/example.json`): + +```json +[ + { + "name": "stamp1", + "type": "stamp", + "x1": 620, + "y1": 150, + "x2": 980, + "y2": 360 + }, + { + "name": "stamp2", + "type": "stamp", + "x1": 690, + "y1": 390, + "x2": 980, + "y2": 560 + } +] +``` + +Config excerpt (`examples/with_stempel/config.json`): + +```json +{ + "stamps": { + "stamp1": { + "generator": "doctor_stamp_lines", + "presence_prob": 0.9, + "params": { + "preset": "medical_with_black_signature" + } + }, + "stamp2": { + "generator": "doctor_stamp_lines", + "presence_prob": 0.6, + "params": { + "preset": "medical_faded" + }, + "stamp": { + "effects": { + "visibility_mode": "per_section" + } + } + } + } +} +``` + +This allows configuring each stamp independently while keeping coordinates in the template layout file. diff --git a/examples/with_stempel/config.json b/examples/with_stempel/config.json new file mode 100644 index 0000000..143800d --- /dev/null +++ b/examples/with_stempel/config.json @@ -0,0 +1,177 @@ +{ + "template": "examples/with_stempel/example.png", + "layout": "examples/with_stempel/example.json", + "global": { + "default_presence_prob": 0.95, + "default_style": "computer", + "font_scale": 0.6, + "font_thickness": 1 + }, + "fields": { + "name": { + "generator": "from_list", + "presence_prob": 1.0, + "style": "computer", + "params": { + "values": [ + "Mueller", + "Schmidt", + "Becker", + "Klein", + "Weber", + "Richter", + "Brandt", + "Schuster" + ] + } + }, + "geb": { + "generator": "date", + "presence_prob": 1.0, + "style": "computer", + "params": { + "start_year": 1940, + "end_year": 2008 + } + }, + "strasse": { + "generator": "from_list", + "presence_prob": 1.0, + "style": "computer", + "params": { + "values": [ + "Hauptstrasse 12", + "Bahnhofweg 4", + "Gartenallee 9", + "Lindenweg 22", + "Bergstrasse 17", + "Waldweg 8", + "Feldstrasse 3", + "Ringstrasse 14" + ] + } + }, + "telefon": { + "generator": "from_list", + "presence_prob": 1.0, + "style": "computer", + "params": { + "values": [ + "0151 2345678", + "0176 99887766", + "0157 44332211", + "0160 55667788", + "0171 11223344" + ] + } + }, + "beruf": { + "generator": "from_list", + "presence_prob": 0.95, + "style": "computer", + "params": { + "values": [ + "Angestellter", + "Schueler", + "Student", + "Handwerker", + "Buerokraft", + "Selbststaendig", + "Rentner" + ] + } + }, + "warten": { + "generator": "from_list", + "presence_prob": 0.85, + "style": "computer", + "params": { + "values": [ + "Hausarztbesuch wegen Routineuntersuchung", + "Lang anhaltende Erkaeltungssymptome", + "Unklare Bauchschmerzen", + "Allergische Beschwerden", + "Vorsorgeuntersuchung", + "Abklaerung von Schmerzen" + ] + } + }, + "verart": { + "generator": "checkbox_group_random", + "presence_prob": 1.0, + "params": { + "mode": "single", + "missing_prob": 0.05 + } + }, + "alergia": { + "generator": "checkbox_group_random", + "presence_prob": 0.85, + "params": { + "mode": "multi", + "missing_prob": 0.15 + } + }, + "erkrankung": { + "generator": "checkbox_group_random", + "presence_prob": 0.9, + "params": { + "mode": "multi", + "missing_prob": 0.2 + } + } + }, + "stamps": { + "stamp1": { + "generator": "doctor_stamp_lines", + "presence_prob": 0.9, + "params": { + "preset": "medical_with_black_signature", + "line_template": [ + "Dr. med. {full_name}", + "Facharzt fuer {specialty}", + "{street}", + "{postcode} {city}", + "Tel.: {phone}", + "BSNR: {bsnr}" + ], + "specialties": [ + "Allgemeinmedizin", + "Innere Medizin", + "Orthopaedie", + "Dermatologie" + ], + "cities": [ + "Berlin", + "Koeln", + "Hamburg", + "Muenchen" + ] + } + }, + "stamp2": { + "generator": "doctor_stamp_lines", + "presence_prob": 0.6, + "params": { + "preset": "medical_faded", + "line_template": [ + "Praxis {full_name}", + "{street}", + "{postcode} {city}", + "LANR: {lanr}" + ] + }, + "stamp": { + "effects": { + "visibility_mode": "per_section", + "opacity_min": 0.2, + "opacity_max": 0.75, + "ghost_prob": 0.15 + }, + "handwriting": { + "enabled": false + } + } + } + } +} diff --git a/examples/with_stempel/example.json b/examples/with_stempel/example.json new file mode 100644 index 0000000..1758dd4 --- /dev/null +++ b/examples/with_stempel/example.json @@ -0,0 +1,176 @@ +[ + { + "name": "name", + "type": "text", + "x1": 261, + "y1": 225, + "x2": 725, + "y2": 253 + }, + { + "name": "geb", + "type": "text", + "x1": 873, + "y1": 224, + "x2": 985, + "y2": 252 + }, + { + "name": "strasse", + "type": "text", + "x1": 120, + "y1": 282, + "x2": 980, + "y2": 308 + }, + { + "name": "telefon", + "type": "text", + "x1": 124, + "y1": 336, + "x2": 453, + "y2": 363 + }, + { + "name": "beruf", + "type": "text", + "x1": 517, + "y1": 340, + "x2": 983, + "y2": 364 + }, + { + "name": "warten", + "type": "text", + "x1": 225, + "y1": 517, + "x2": 986, + "y2": 547 + }, + { + "name": "verart", + "type": "checkbox_group", + "x1": 277, + "y1": 378, + "x2": 990, + "y2": 446, + "children": [ + { + "name": "g", + "type": "checkbox", + "x1": 295, + "y1": 397, + "x2": 316, + "y2": 422 + } + ] + }, + { + "name": "alergia", + "type": "checkbox_group", + "x1": 37, + "y1": 670, + "x2": 783, + "y2": 754, + "children": [ + { + "name": "s", + "type": "checkbox", + "x1": 44, + "y1": 682, + "x2": 69, + "y2": 707 + }, + { + "name": "p", + "type": "checkbox", + "x1": 45, + "y1": 715, + "x2": 69, + "y2": 744 + }, + { + "name": "n", + "type": "checkbox", + "x1": 360, + "y1": 681, + "x2": 387, + "y2": 706 + }, + { + "name": "i", + "type": "checkbox", + "x1": 362, + "y1": 717, + "x2": 386, + "y2": 740 + }, + { + "name": "m", + "type": "checkbox", + "x1": 606, + "y1": 681, + "x2": 629, + "y2": 706 + }, + { + "name": "u", + "type": "checkbox", + "x1": 606, + "y1": 718, + "x2": 627, + "y2": 741 + } + ] + }, + { + "name": "erkrankung", + "type": "checkbox_group", + "x1": 37, + "y1": 799, + "x2": 265, + "y2": 923, + "children": [ + { + "name": "b", + "type": "checkbox", + "x1": 46, + "y1": 813, + "x2": 66, + "y2": 838 + }, + { + "name": "d", + "type": "checkbox", + "x1": 44, + "y1": 851, + "x2": 67, + "y2": 872 + }, + { + "name": "r", + "type": "checkbox", + "x1": 43, + "y1": 887, + "x2": 68, + "y2": 910 + } + ] + }, + { + "name": "stamp1", + "type": "stamp", + "x1": 37, + "y1": 799, + "x2": 265, + "y2": 923 + }, + { + "name": "stamp2", + "type": "stamp", + "x1": 690, + "y1": 390, + "x2": 980, + "y2": 560 + } +] \ No newline at end of file diff --git a/examples/with_stempel/example.png b/examples/with_stempel/example.png new file mode 100644 index 0000000..d6905bf Binary files /dev/null and b/examples/with_stempel/example.png differ diff --git a/examples/without_stempel/config.json b/examples/without_stempel/config.json new file mode 100644 index 0000000..9e06546 --- /dev/null +++ b/examples/without_stempel/config.json @@ -0,0 +1,124 @@ +{ + "template": "examples/without_stempel/example.png", + "layout": "examples/without_stempel/example.json", + "global": { + "default_presence_prob": 0.95, + "default_style": "computer", + "font_scale": 0.6, + "font_thickness": 1 + }, + "fields": { + "name": { + "generator": "from_list", + "presence_prob": 1.0, + "style": "computer", + "params": { + "values": [ + "Müller", + "Schmidt", + "Becker", + "Klein", + "Weber", + "Richter", + "Brandt", + "Schuster" + ] + } + }, + "geb": { + "generator": "date", + "presence_prob": 1.0, + "style": "computer", + "params": { + "start_year": 1940, + "end_year": 2008 + } + }, + "strasse": { + "generator": "from_list", + "presence_prob": 1.0, + "style": "computer", + "params": { + "values": [ + "Hauptstraße 12", + "Bahnhofweg 4", + "Gartenallee 9", + "Lindenweg 22", + "Bergstraße 17", + "Waldweg 8", + "Feldstraße 3", + "Ringstraße 14" + ] + } + }, + "telefon": { + "generator": "from_list", + "presence_prob": 1.0, + "style": "computer", + "params": { + "values": [ + "0151 2345678", + "0176 99887766", + "0157 44332211", + "0160 55667788", + "0171 11223344" + ] + } + }, + "beruf": { + "generator": "from_list", + "presence_prob": 0.95, + "style": "computer", + "params": { + "values": [ + "Angestellter", + "Schüler", + "Student", + "Handwerker", + "Bürokraft", + "Selbstständig", + "Rentner" + ] + } + }, + "warten": { + "generator": "from_list", + "presence_prob": 0.85, + "style": "computer", + "params": { + "values": [ + "Hausarztbesuch wegen Routineuntersuchung", + "Lang anhaltende Erkältungssymptome", + "Unklare Bauchschmerzen", + "Allergische Beschwerden", + "Vorsorgeuntersuchung", + "Abklärung von Schmerzen" + ] + } + }, + "verart": { + "generator": "checkbox_group_random", + "presence_prob": 1.0, + "params": { + "mode": "single", + "missing_prob": 0.05 + } + }, + "alergia": { + "generator": "checkbox_group_random", + "presence_prob": 0.85, + "params": { + "mode": "multi", + "missing_prob": 0.15 + } + }, + "erkrankung": { + "generator": "checkbox_group_random", + "presence_prob": 0.90, + "params": { + "mode": "multi", + "missing_prob": 0.20 + } + } + } +} \ No newline at end of file diff --git a/examples/without_stempel/example.json b/examples/without_stempel/example.json new file mode 100644 index 0000000..4182b77 --- /dev/null +++ b/examples/without_stempel/example.json @@ -0,0 +1,160 @@ +[ + { + "name": "name", + "type": "text", + "x1": 261, + "y1": 225, + "x2": 725, + "y2": 253 + }, + { + "name": "geb", + "type": "text", + "x1": 873, + "y1": 224, + "x2": 985, + "y2": 252 + }, + { + "name": "strasse", + "type": "text", + "x1": 120, + "y1": 282, + "x2": 980, + "y2": 308 + }, + { + "name": "telefon", + "type": "text", + "x1": 124, + "y1": 336, + "x2": 453, + "y2": 363 + }, + { + "name": "beruf", + "type": "text", + "x1": 517, + "y1": 340, + "x2": 983, + "y2": 364 + }, + { + "name": "warten", + "type": "text", + "x1": 225, + "y1": 517, + "x2": 986, + "y2": 547 + }, + { + "name": "verart", + "type": "checkbox_group", + "x1": 277, + "y1": 378, + "x2": 990, + "y2": 446, + "children": [ + { + "name": "g", + "type": "checkbox", + "x1": 295, + "y1": 397, + "x2": 316, + "y2": 422 + } + ] + }, + { + "name": "alergia", + "type": "checkbox_group", + "x1": 37, + "y1": 670, + "x2": 783, + "y2": 754, + "children": [ + { + "name": "s", + "type": "checkbox", + "x1": 44, + "y1": 682, + "x2": 69, + "y2": 707 + }, + { + "name": "p", + "type": "checkbox", + "x1": 45, + "y1": 715, + "x2": 69, + "y2": 744 + }, + { + "name": "n", + "type": "checkbox", + "x1": 360, + "y1": 681, + "x2": 387, + "y2": 706 + }, + { + "name": "i", + "type": "checkbox", + "x1": 362, + "y1": 717, + "x2": 386, + "y2": 740 + }, + { + "name": "m", + "type": "checkbox", + "x1": 606, + "y1": 681, + "x2": 629, + "y2": 706 + }, + { + "name": "u", + "type": "checkbox", + "x1": 606, + "y1": 718, + "x2": 627, + "y2": 741 + } + ] + }, + { + "name": "erkrankung", + "type": "checkbox_group", + "x1": 37, + "y1": 799, + "x2": 265, + "y2": 923, + "children": [ + { + "name": "b", + "type": "checkbox", + "x1": 46, + "y1": 813, + "x2": 66, + "y2": 838 + }, + { + "name": "d", + "type": "checkbox", + "x1": 44, + "y1": 851, + "x2": 67, + "y2": 872 + }, + { + "name": "r", + "type": "checkbox", + "x1": 43, + "y1": 887, + "x2": 68, + "y2": 910 + } + ] + } +] \ No newline at end of file diff --git a/examples/without_stempel/example.png b/examples/without_stempel/example.png new file mode 100644 index 0000000..d6905bf Binary files /dev/null and b/examples/without_stempel/example.png differ diff --git a/generator.py b/generator.py index d63f72f..bfaf0c2 100644 --- a/generator.py +++ b/generator.py @@ -1,284 +1,6 @@ -import cv2 -import json -import os -import random -import getpass -from datetime import datetime +from generator_core import GeneratorConfig, GeneratorCore +from generator_fields import GeneratorFieldHandlers -from dataGenFunctions import DataGenFunctions - -# ============================ -# GENERATOR CONFIG -# ============================ -class GeneratorConfig: - def __init__(self, template, config_path, gennum, outputfolder, outputtype, data_path=None): - self.template = template - self.config_path = config_path - self.gennum = gennum - self.outputfolder = outputfolder - self.outputtype = outputtype - self.data_path = data_path - - -# ============================ -# GENERATOR IMPLEMENTATION -# ============================ -class Generator: - def __init__(self, cfg: GeneratorConfig): - self.cfg = cfg - - self.template_img = cv2.imread(cfg.template) - if self.template_img is None: - raise FileNotFoundError("Template not found: " + cfg.template) - - with open(cfg.config_path, "r", encoding="utf-8") as f: - self.gen_conf = json.load(f) - - layout_path = self.gen_conf.get( - "layout", - os.path.splitext(cfg.template)[0] + ".json" - ) - - with open(layout_path, "r", encoding="utf-8") as f: - self.layout = json.load(f) - - self.layout_path = layout_path - - global_cfg = self.gen_conf.get("global", {}) - self.presence_default = global_cfg.get("default_presence_prob", 1.0) - self.style_default = global_cfg.get("default_style", "computer") - self.scale = global_cfg.get("font_scale", 0.6) - self.thickness = global_cfg.get("font_thickness", 1) - - self.field_cfg = self.gen_conf.get("fields", {}) - - self.data_store = None - if cfg.data_path and os.path.exists(cfg.data_path): - with open(cfg.data_path, "r", encoding="utf-8") as f: - self.data_store = json.load(f) - - self.data_gen = DataGenFunctions(self.data_store) - - os.makedirs(cfg.outputfolder, exist_ok=True) - - # ============================ - # FUNCTION LOOKUP - # ============================ - def get_func(self, name: str): - func = getattr(self.data_gen, name, None) - - if func is None: - print(f"[WARN] Generator function '{name}' not found.") - return None - - if not callable(func): - print(f"[WARN] '{name}' exists but is not callable.") - return None - - return func - - # ============================ - # MAIN LOOP - # ============================ - def run(self): - for idx in range(self.cfg.gennum): - img = self.template_img.copy() - fields = self.render_sample(img) - - image_path = self._build_output_path(idx) - self.save_image(img, image_path) - - metadata = self.build_metadata(fields, idx, image_path) - self.save_metadata(metadata, image_path) - - # ============================ - # SAMPLE RENDERING - # ============================ - def render_sample(self, img): - entries = [] - for field in self.layout: - entries.append(self._process_field(field, img)) - return entries - - def _process_field(self, field, img): - name = field["name"] - ftype = field["type"] - cfg = self.field_cfg.get(name, {}) - params = dict(cfg.get("params", {})) - - record = { - "name": name, - "type": ftype, - "generator": cfg.get("generator"), - "params": params, - "presence_prob": cfg.get("presence_prob", self.presence_default), - "active": False, - "drawn": False, - "value": None, - "status": "pending" - } - - record["coords"] = self._extract_coords(field) - - if not self._should_render_field(record["presence_prob"]): - record["status"] = "skipped_by_presence_prob" - return record - - record["active"] = True - - if ftype == "text": - return self._handle_text_field(field, cfg, record, img) - if ftype == "checkbox": - return self._handle_checkbox_field(field, cfg, record, img) - if ftype == "checkbox_group": - return self._handle_checkbox_group(field, cfg, record, img) - - record["status"] = "unsupported_field_type" - return record - - def _should_render_field(self, probability): - return random.random() <= probability - - def _handle_text_field(self, field, cfg, record, img): - generator_name = cfg.get("generator") - if not generator_name: - record["status"] = "no_generator_configured" - return record - - func = self.get_func(generator_name) - if not func: - record["status"] = "missing_generator_function" - return record - - value = func(record["params"]) - record["value"] = value - style = cfg.get("style", self.style_default) - record["style"] = style - - if not value: - record["status"] = "no_value_generated" - return record - - self.draw_text(img, field, value, style) - record["drawn"] = True - record["status"] = "rendered" - return record - - def _handle_checkbox_field(self, field, cfg, record, img): - generator_name = cfg.get("generator") or "checkbox_binary" - record["generator"] = generator_name - - func = self.get_func(generator_name) - if not func: - record["status"] = "missing_generator_function" - return record - - value = bool(func(record["params"])) - record["value"] = value - - if value: - self.draw_checkbox(img, field) - record["drawn"] = True - record["status"] = "checked" - else: - record["status"] = "unchecked" - return record - - def _handle_checkbox_group(self, field, cfg, record, img): - generator_name = cfg.get("generator") or "checkbox_group_random" - record["generator"] = generator_name - - func = self.get_func(generator_name) - if not func: - record["status"] = "missing_generator_function" - return record - - children = field.get("children", []) - child_map = {} - child_infos = [] - for child in children: - child_map[child["name"]] = child - child_infos.append({ - "name": child["name"], - "coords": self._extract_coords(child) - }) - record["children"] = child_infos - - render_params = {**record["params"], "children": children} - selection = func(render_params) or [] - record["value"] = selection - - if selection: - for child_name in selection: - child_field = child_map.get(child_name) - if child_field: - self.draw_checkbox(img, child_field) - record["drawn"] = True - record["status"] = "selection" - else: - record["status"] = "no_selection" - return record - - def _extract_coords(self, field): - coords = {} - for key in ("x1", "y1", "x2", "y2"): - if key in field: - coords[key] = field[key] - return coords - - # ============================ - # OUTPUT HELPERS - # ============================ - def _build_output_path(self, index): - name = f"sample_{index + 1}.{self.cfg.outputtype}" - return os.path.join(self.cfg.outputfolder, name) - - def save_image(self, img, path): - cv2.imwrite(path, img) - print("Saved:", path) - - def build_metadata(self, fields, sample_index, image_path): - return { - "sample_index": sample_index + 1, - "generated_at": datetime.utcnow().isoformat() + "Z", - "filled_by": getpass.getuser(), - "template_path": os.path.abspath(self.cfg.template), - "layout_path": os.path.abspath(self.layout_path), - "config_path": os.path.abspath(self.cfg.config_path), - "data_path": os.path.abspath(self.cfg.data_path) if self.cfg.data_path else None, - "output_image": os.path.abspath(image_path), - "fields": fields - } - - def save_metadata(self, metadata, image_path): - metadata_path = self._metadata_path_for(image_path) - with open(metadata_path, "w", encoding="utf-8") as f: - json.dump(metadata, f, ensure_ascii=False, indent=2) - print("Saved metadata:", metadata_path) - - def _metadata_path_for(self, image_path): - base = os.path.splitext(image_path)[0] - return base + ".json" - - # ============================ - # DRAW HELPERS - # ============================ - def draw_text(self, img, field, text, style): - x1, y1, x2, y2 = field["x1"], field["y1"], field["x2"], field["y2"] - height = y2 - y1 - pos = (x1 + 2, y1 + int(height * 0.7)) - - scale = self.scale if style == "computer" else self.scale * 0.9 - - cv2.putText( - img, str(text), pos, - cv2.FONT_HERSHEY_SIMPLEX, - scale, (0, 0, 0), - self.thickness, cv2.LINE_AA - ) - - def draw_checkbox(self, img, field): - x1, y1, x2, y2 = field["x1"], field["y1"], field["x2"], field["y2"] - cv2.line(img, (x1, y1), (x2, y2), (0, 0, 0), 2) - cv2.line(img, (x1, y2), (x2, y1), (0, 0, 0), 2) +class Generator(GeneratorFieldHandlers, GeneratorCore): + pass diff --git a/generator_core.py b/generator_core.py new file mode 100644 index 0000000..643b967 --- /dev/null +++ b/generator_core.py @@ -0,0 +1,249 @@ +import cv2 +import json +import os +import random +import getpass +from copy import deepcopy +from datetime import datetime + +from dataGenFunctions import DataGenFunctions + + +class GeneratorConfig: + def __init__(self, template, config_path, gennum, outputfolder, outputtype, data_path=None): + self.template = template + self.config_path = config_path + self.gennum = gennum + self.outputfolder = outputfolder + self.outputtype = outputtype + self.data_path = data_path + + +class GeneratorCore: + def __init__(self, cfg: GeneratorConfig): + self.cfg = cfg + + self.template_img = cv2.imread(cfg.template) + if self.template_img is None: + raise FileNotFoundError("Template not found: " + cfg.template) + + with open(cfg.config_path, "r", encoding="utf-8") as f: + self.gen_conf = json.load(f) + + layout_path = self.gen_conf.get("layout", os.path.splitext(cfg.template)[0] + ".json") + self.layout, self.layout_template_size = self._load_layout(layout_path) + self.layout_scale = self._resolve_layout_scale(self.layout_template_size) + self.layout_path = layout_path + + global_cfg = self.gen_conf.get("global", {}) + self.presence_default = global_cfg.get("default_presence_prob", 1.0) + self.style_default = global_cfg.get("default_style", "computer") + self.scale = global_cfg.get("font_scale", 0.6) + self.thickness = global_cfg.get("font_thickness", 1) + + self.field_cfg = self.gen_conf.get("fields", {}) + self.stamp_cfg = self.gen_conf.get("stamps", {}) + self.stamp_presets = self.gen_conf.get("stamp_presets", {}) + + if self.gen_conf.get("stamp_overlays"): + print("[WARN] 'stamp_overlays' is deprecated. Move stamp coordinates to layout JSON fields.") + + self.data_store = None + if cfg.data_path and os.path.exists(cfg.data_path): + with open(cfg.data_path, "r", encoding="utf-8") as f: + self.data_store = json.load(f) + + self.data_gen = DataGenFunctions(self.data_store) + + os.makedirs(cfg.outputfolder, exist_ok=True) + + # ============================ + # LAYOUT LOADING + # ============================ + def _load_layout(self, layout_path): + with open(layout_path, "r", encoding="utf-8") as f: + payload = json.load(f) + + # Backward compatibility: old layout format was a plain list. + if isinstance(payload, list): + return payload, None + + if not isinstance(payload, dict): + raise ValueError("Layout JSON must be a list or object with 'fields'.") + + fields = payload.get("fields") + if not isinstance(fields, list): + raise ValueError("Layout object must contain a list field 'fields'.") + + size = payload.get("template_size") + if isinstance(size, dict): + width = int(size.get("width", 0)) + height = int(size.get("height", 0)) + if width > 0 and height > 0: + return fields, {"width": width, "height": height} + + return fields, None + + def _resolve_layout_scale(self, layout_size): + if not layout_size: + return {"x": 1.0, "y": 1.0} + + img_h, img_w = self.template_img.shape[:2] + base_w = layout_size.get("width", 0) + base_h = layout_size.get("height", 0) + if base_w <= 0 or base_h <= 0: + return {"x": 1.0, "y": 1.0} + + sx = img_w / float(base_w) + sy = img_h / float(base_h) + if abs(sx - 1.0) > 0.001 or abs(sy - 1.0) > 0.001: + print( + "[INFO] Layout/template size mismatch detected. " + f"Applying coordinate scale x={sx:.4f}, y={sy:.4f}" + ) + return {"x": sx, "y": sy} + + def _scaled_field(self, field): + scaled = deepcopy(field) + sx = self.layout_scale["x"] + sy = self.layout_scale["y"] + + for key in ("x1", "x2"): + if key in scaled: + scaled[key] = int(round(float(scaled[key]) * sx)) + for key in ("y1", "y2"): + if key in scaled: + scaled[key] = int(round(float(scaled[key]) * sy)) + + children = scaled.get("children") + if isinstance(children, list): + normalized = [] + for child in children: + if isinstance(child, dict): + normalized.append(self._scaled_field(child)) + scaled["children"] = normalized + + return scaled + + # ============================ + # LOOKUP + CONFIG + # ============================ + def get_func(self, name: str): + func = getattr(self.data_gen, name, None) + + if func is None: + print(f"[WARN] Generator function '{name}' not found.") + return None + + if not callable(func): + print(f"[WARN] '{name}' exists but is not callable.") + return None + + return func + + def _resolve_cfg(self, field_name, field_type): + base_cfg = self.field_cfg.get(field_name, {}) + if field_type != "stamp": + return base_cfg + + stamp_cfg = self.stamp_cfg.get(field_name, {}) + if not isinstance(stamp_cfg, dict): + return base_cfg + + return self._deep_merge(base_cfg, stamp_cfg) + + def _deep_merge(self, base, update): + out = deepcopy(base) if isinstance(base, dict) else {} + if not isinstance(update, dict): + return out + + for key, value in update.items(): + if isinstance(value, dict) and isinstance(out.get(key), dict): + out[key] = self._deep_merge(out[key], value) + else: + out[key] = value + return out + + def _should_render_field(self, probability): + try: + probability = float(probability) + except (TypeError, ValueError): + probability = self.presence_default + + probability = max(0.0, min(1.0, probability)) + return random.random() <= probability + + def _extract_coords(self, field): + coords = {} + for key in ("x1", "y1", "x2", "y2"): + if key in field: + coords[key] = int(field[key]) + return coords + + # ============================ + # MAIN LOOP + # ============================ + def run(self): + for idx in range(self.cfg.gennum): + img = self.template_img.copy() + fields = self.render_sample(img) + + image_path = self._build_output_path(idx) + self.save_image(img, image_path) + + metadata = self.build_metadata(fields, idx, image_path) + self.save_metadata(metadata, image_path) + + def render_sample(self, img): + entries = [] + for raw_field in self.layout: + field = self._scaled_field(raw_field) + entries.append(self._process_field(field, img)) + return entries + + # ============================ + # OUTPUT HELPERS + # ============================ + def _build_output_path(self, index): + name = f"sample_{index + 1}.{self.cfg.outputtype}" + return os.path.join(self.cfg.outputfolder, name) + + def save_image(self, img, path): + cv2.imwrite(path, img) + print("Saved:", path) + + def _to_rel_path(self, path): + if not path: + return None + try: + return os.path.relpath(os.path.abspath(path), os.getcwd()) + except ValueError: + # Fallback for uncommon cross-drive situations on Windows. + return path + + def build_metadata(self, fields, sample_index, image_path): + return { + "sample_index": sample_index + 1, + "generated_at": datetime.utcnow().isoformat() + "Z", + "filled_by": getpass.getuser(), + "template_path": self._to_rel_path(self.cfg.template), + "layout_path": self._to_rel_path(self.layout_path), + "config_path": self._to_rel_path(self.cfg.config_path), + "data_path": self._to_rel_path(self.cfg.data_path), + "output_image": self._to_rel_path(image_path), + "layout_scale": { + "x": round(float(self.layout_scale["x"]), 6), + "y": round(float(self.layout_scale["y"]), 6), + }, + "fields": fields, + } + + def save_metadata(self, metadata, image_path): + metadata_path = self._metadata_path_for(image_path) + with open(metadata_path, "w", encoding="utf-8") as f: + json.dump(metadata, f, ensure_ascii=False, indent=2) + print("Saved metadata:", metadata_path) + + def _metadata_path_for(self, image_path): + base = os.path.splitext(image_path)[0] + return base + ".json" diff --git a/generator_fields.py b/generator_fields.py new file mode 100644 index 0000000..7f180e0 --- /dev/null +++ b/generator_fields.py @@ -0,0 +1,229 @@ +import cv2 +import random + +from stempel_module.stamp_renderer import render_stamp_on_image + + +class GeneratorFieldHandlers: + def _process_field(self, field, img): + name = field["name"] + ftype = field["type"] + cfg = self._resolve_cfg(name, ftype) + params = dict(cfg.get("params", {})) + + record = { + "name": name, + "type": ftype, + "generator": cfg.get("generator"), + "params": params, + "presence_prob": cfg.get("presence_prob", self.presence_default), + "active": False, + "drawn": False, + "value": None, + "status": "pending", + } + + record["coords"] = self._extract_coords(field) + + if not self._should_render_field(record["presence_prob"]): + record["status"] = "skipped_by_presence_prob" + return record + + record["active"] = True + + if ftype == "text": + return self._handle_text_field(field, cfg, record, img) + if ftype == "stamp": + return self._handle_stamp_field(field, cfg, record, img) + if ftype == "checkbox": + return self._handle_checkbox_field(field, cfg, record, img) + if ftype == "checkbox_group": + return self._handle_checkbox_group(field, cfg, record, img) + + record["status"] = "unsupported_field_type" + return record + + def _handle_text_field(self, field, cfg, record, img): + generator_name = cfg.get("generator") + if not generator_name: + record["status"] = "no_generator_configured" + return record + + func = self.get_func(generator_name) + if not func: + record["status"] = "missing_generator_function" + return record + + value = func(record["params"]) + record["value"] = value + style = cfg.get("style", self.style_default) + record["style"] = style + + if not value: + record["status"] = "no_value_generated" + return record + + render_mode = cfg.get("render_mode", "text") + record["render_mode"] = render_mode + + if render_mode == "stamp": + payload = value if isinstance(value, dict) else {"lines": [str(value)]} + img, stamp_meta = self.draw_stamp(img=img, field=field, payload=payload, cfg=cfg) + record["value"] = payload + record["stamp_meta"] = stamp_meta + record["drawn"] = True + record["status"] = "rendered_stamp" + return record + + self.draw_text(img, field, value, style) + record["drawn"] = True + record["status"] = "rendered" + return record + + def _handle_stamp_field(self, field, cfg, record, img): + generator_name = cfg.get("generator") + if not generator_name: + record["status"] = "no_generator_configured" + return record + + func = self.get_func(generator_name) + if not func: + record["status"] = "missing_generator_function" + return record + + payload = func(record["params"]) + if not payload: + record["status"] = "no_value_generated" + return record + + payload = payload if isinstance(payload, dict) else {"lines": [str(payload)]} + img, stamp_meta = self.draw_stamp(img=img, field=field, payload=payload, cfg=cfg) + + record["value"] = payload + record["render_mode"] = "stamp" + record["stamp_meta"] = stamp_meta + record["drawn"] = True + record["status"] = "rendered_stamp" + return record + + def _handle_checkbox_field(self, field, cfg, record, img): + generator_name = cfg.get("generator") or "checkbox_binary" + record["generator"] = generator_name + + func = self.get_func(generator_name) + if not func: + record["status"] = "missing_generator_function" + return record + + value = bool(func(record["params"])) + record["value"] = value + + if value: + self.draw_checkbox(img, field) + record["drawn"] = True + record["status"] = "checked" + else: + record["status"] = "unchecked" + return record + + def _handle_checkbox_group(self, field, cfg, record, img): + generator_name = cfg.get("generator") or "checkbox_group_random" + record["generator"] = generator_name + + func = self.get_func(generator_name) + if not func: + record["status"] = "missing_generator_function" + return record + + children = field.get("children", []) + child_map = {} + child_infos = [] + for child in children: + child_map[child["name"]] = child + child_infos.append({"name": child["name"], "coords": self._extract_coords(child)}) + record["children"] = child_infos + + render_params = {**record["params"], "children": children} + selection = func(render_params) or [] + record["value"] = selection + + if selection: + for child_name in selection: + child_field = child_map.get(child_name) + if child_field: + self.draw_checkbox(img, child_field) + record["drawn"] = True + record["status"] = "selection" + else: + record["status"] = "no_selection" + return record + + # ============================ + # DRAW HELPERS + # ============================ + def draw_text(self, img, field, text, style): + x1, y1, x2, y2 = field["x1"], field["y1"], field["x2"], field["y2"] + width = max(1, x2 - x1) + height = max(1, y2 - y1) + + pad_x = max(1, int(width * 0.02)) + pad_y = max(1, int(height * 0.12)) + avail_w = max(1, width - (pad_x * 2)) + avail_h = max(1, height - (pad_y * 2)) + + scale = float(self.scale if style == "computer" else self.scale * 0.9) + font = cv2.FONT_HERSHEY_SIMPLEX + txt = str(text) + + for _ in range(8): + (tw, th), baseline = cv2.getTextSize(txt, font, scale, self.thickness) + if tw <= avail_w or scale <= 0.2: + break + ratio = avail_w / max(1, tw) + scale = max(0.2, scale * ratio * 0.98) + + (tw, th), baseline = cv2.getTextSize(txt, font, scale, self.thickness) + if th + baseline > avail_h and (th + baseline) > 0: + ratio = avail_h / float(th + baseline) + scale = max(0.2, scale * ratio * 0.98) + (tw, th), baseline = cv2.getTextSize(txt, font, scale, self.thickness) + + text_x = x1 + pad_x + text_y = y1 + pad_y + th + max_baseline_y = y2 - pad_y + if text_y > max_baseline_y: + text_y = max(y1 + th, max_baseline_y) + + cv2.putText( + img, + txt, + (int(text_x), int(text_y)), + font, + scale, + (0, 0, 0), + self.thickness, + cv2.LINE_AA, + ) + + def draw_checkbox(self, img, field): + x1, y1, x2, y2 = field["x1"], field["y1"], field["x2"], field["y2"] + cv2.line(img, (x1, y1), (x2, y2), (0, 0, 0), 2) + cv2.line(img, (x1, y2), (x2, y1), (0, 0, 0), 2) + + def draw_stamp(self, img, field, payload, cfg): + region = { + "x1": field["x1"], + "y1": field["y1"], + "x2": field["x2"], + "y2": field["y2"], + } + updated_img, stamp_meta = render_stamp_on_image( + base_img_bgr=img, + region=region, + stamp_payload=payload, + cfg=cfg, + rng=random, + preset_map=self.stamp_presets, + ) + img[:] = updated_img + return img, stamp_meta diff --git a/stempel_module/__init__.py b/stempel_module/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/stempel_module/overlay_renderer.py b/stempel_module/overlay_renderer.py new file mode 100644 index 0000000..4b10529 --- /dev/null +++ b/stempel_module/overlay_renderer.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +import math +import random + +from PIL import Image, ImageDraw, ImageFilter, ImageFont + +from .stamp_models import HandwritingOverlaySpec + + +DEFAULT_NOTES = [ + "gez.", + "i.A.", + "ok", + "dringend", + "eilig", + "heute", +] + + +def _load_font(font_size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: + for candidate in ("DejaVuSans.ttf", "arial.ttf"): + try: + return ImageFont.truetype(candidate, size=font_size) + except OSError: + continue + return ImageFont.load_default() + + +def _signature_points(width: int, height: int, rng: random.Random) -> list[tuple[float, float]]: + start_x = rng.uniform(width * 0.05, width * 0.25) + baseline = rng.uniform(height * 0.45, height * 0.72) + total = rng.randint(12, 20) + step = width / max(total + 2, 8) + points: list[tuple[float, float]] = [] + amp = rng.uniform(height * 0.05, height * 0.17) + + for i in range(total): + x = start_x + i * step + wobble = math.sin(i * rng.uniform(0.6, 1.0)) * amp + jitter = rng.uniform(-height * 0.05, height * 0.05) + y = baseline + wobble + jitter + points.append((x, y)) + + return points + + +def _render_signature(layer: Image.Image, spec: HandwritingOverlaySpec, rng: random.Random, alpha: int) -> dict: + draw = ImageDraw.Draw(layer) + width, height = layer.size + stroke_count = rng.randint(1, 3) + metadata = {"type": "signature", "stroke_count": stroke_count} + + for _ in range(stroke_count): + points = _signature_points(width, height, rng) + line_width = rng.randint(spec.line_width_min, max(spec.line_width_min, spec.line_width_max)) + draw.line(points, fill=(*spec.color, alpha), width=line_width, joint="curve") + + if rng.random() < 0.45: + p1 = points[max(1, len(points) // 3)] + p2 = (p1[0] + rng.uniform(width * 0.08, width * 0.26), p1[1] + rng.uniform(-height * 0.2, height * 0.2)) + draw.line([p1, p2], fill=(*spec.color, alpha), width=max(1, line_width - 1)) + + blur = rng.uniform(0.0, 0.35) + if blur > 0.03: + layer = layer.filter(ImageFilter.GaussianBlur(radius=blur)) + metadata["blur"] = round(float(blur), 3) + return metadata, layer + + +def _render_handwritten_text(layer: Image.Image, spec: HandwritingOverlaySpec, rng: random.Random, alpha: int) -> dict: + draw = ImageDraw.Draw(layer) + width, height = layer.size + choices = spec.text_values or DEFAULT_NOTES + text = rng.choice(choices) + font_size = rng.randint(max(12, height // 10), max(13, height // 5)) + font = _load_font(font_size) + + bbox = draw.textbbox((0, 0), text, font=font) + tw = max(1, bbox[2] - bbox[0]) + th = max(1, bbox[3] - bbox[1]) + + x = rng.randint(max(-tw // 5, -2), max(1, width - tw + max(3, tw // 5))) + y = rng.randint(max(-th // 2, -2), max(1, height - th + max(4, th // 2))) + draw.text((x, y), text, font=font, fill=(*spec.color, alpha)) + + blur = rng.uniform(0.0, 0.22) + if blur > 0.03: + layer = layer.filter(ImageFilter.GaussianBlur(radius=blur)) + + return { + "type": "text", + "text": text, + "font_size": font_size, + "x": x, + "y": y, + "blur": round(float(blur), 3), + }, layer + + +def render_black_handwriting_overlay(size: tuple[int, int], spec: HandwritingOverlaySpec, rng: random.Random): + layer = Image.new("RGBA", size, (0, 0, 0, 0)) + + if not spec.enabled: + return layer, {"enabled": False, "applied": False} + + if rng.random() > spec.overlap_prob: + return layer, {"enabled": True, "applied": False, "reason": "overlap_prob"} + + mode = spec.mode + alpha = int(255 * rng.uniform(spec.opacity_min, spec.opacity_max)) + + if mode == "signature": + meta, layer = _render_signature(layer, spec, rng, alpha) + elif mode == "text": + meta, layer = _render_handwritten_text(layer, spec, rng, alpha) + else: + p_text = max(0.0, spec.text_prob) + p_sig = max(0.0, spec.signature_prob) + if p_text + p_sig <= 0: + p_text = 0.5 + p_sig = 0.5 + if rng.random() < (p_text / (p_text + p_sig)): + meta, layer = _render_handwritten_text(layer, spec, rng, alpha) + else: + meta, layer = _render_signature(layer, spec, rng, alpha) + + rotation = rng.uniform(spec.rotation_min, spec.rotation_max) + if abs(rotation) > 0.05: + layer = layer.rotate(rotation, resample=Image.Resampling.BICUBIC, expand=False) + + metadata = { + "enabled": True, + "applied": True, + "mode": mode, + "rotation": round(float(rotation), 3), + "opacity": round(float(alpha / 255.0), 4), + } + metadata.update(meta) + return layer, metadata diff --git a/stempel_module/paper_effects.py b/stempel_module/paper_effects.py new file mode 100644 index 0000000..176d860 --- /dev/null +++ b/stempel_module/paper_effects.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import random + +import numpy as np +from PIL import Image, ImageEnhance + + +def apply_subtle_paper_texture(stamp_layer: Image.Image, base_region_rgb: Image.Image, rng: random.Random, strength: float = 0.2) -> Image.Image: + """ + Modulate stamp alpha by local paper luminance to mimic absorbency differences. + """ + strength = max(0.0, min(1.0, strength)) + if strength <= 0: + return stamp_layer + + gray = base_region_rgb.convert("L") + gray_arr = np.array(gray, dtype=np.float32) / 255.0 + gray_arr = 0.85 + (gray_arr - 0.5) * (0.35 * strength) + gray_arr = np.clip(gray_arr, 0.65, 1.2) + + alpha = np.array(stamp_layer.split()[3], dtype=np.float32) + alpha *= gray_arr + + out = stamp_layer.copy() + out.putalpha(Image.fromarray(np.clip(alpha, 0, 255).astype(np.uint8), mode="L")) + return out + + +def apply_local_contrast_reduction(img_rgb: Image.Image, rng: random.Random, strength: float = 0.08) -> Image.Image: + strength = max(0.0, min(1.0, strength)) + if strength <= 0: + return img_rgb + factor = 1.0 - rng.uniform(0.0, strength) + return ImageEnhance.Contrast(img_rgb).enhance(factor) diff --git a/stempel_module/stamp_effects.py b/stempel_module/stamp_effects.py new file mode 100644 index 0000000..12770ef --- /dev/null +++ b/stempel_module/stamp_effects.py @@ -0,0 +1,238 @@ +from __future__ import annotations + +import io +import random +from typing import Iterable + +import numpy as np +from PIL import Image, ImageChops, ImageEnhance, ImageFilter + + +def _np_rng(rng: random.Random) -> np.random.Generator: + return np.random.default_rng(rng.randint(0, 2**31 - 1)) + + +def _clip_bbox(bbox: tuple[int, int, int, int], width: int, height: int) -> tuple[int, int, int, int] | None: + x1, y1, x2, y2 = bbox + x1 = max(0, min(width, x1)) + y1 = max(0, min(height, y1)) + x2 = max(0, min(width, x2)) + y2 = max(0, min(height, y2)) + if x2 <= x1 or y2 <= y1: + return None + return x1, y1, x2, y2 + + +def apply_character_opacity_map( + alpha: np.ndarray, + char_bboxes: Iterable[tuple[int, int, int, int]], + opacity_min: float, + opacity_max: float, + char_dropout_prob: float, + rng: random.Random, +) -> tuple[np.ndarray, list[float]]: + sampled = [] + height, width = alpha.shape + for bbox in char_bboxes: + clipped = _clip_bbox(bbox, width, height) + if clipped is None: + sampled.append(0.0) + continue + x1, y1, x2, y2 = clipped + factor = rng.uniform(opacity_min, opacity_max) + if rng.random() < char_dropout_prob: + factor *= rng.uniform(0.05, 0.35) + alpha[y1:y2, x1:x2] = np.clip(alpha[y1:y2, x1:x2].astype(np.float32) * factor, 0, 255).astype(np.uint8) + sampled.append(round(float(factor), 4)) + return alpha, sampled + + +def apply_section_opacity_map( + alpha: np.ndarray, + section_bboxes: dict[str, list[tuple[int, int, int, int]]], + opacity_min: float, + opacity_max: float, + section_dropout_prob: float, + rng: random.Random, +) -> tuple[np.ndarray, dict[str, float]]: + height, width = alpha.shape + sampled: dict[str, float] = {} + for section_id, boxes in section_bboxes.items(): + factor = rng.uniform(opacity_min, opacity_max) + if rng.random() < section_dropout_prob: + factor *= rng.uniform(0.05, 0.4) + sampled[section_id] = round(float(factor), 4) + for bbox in boxes: + clipped = _clip_bbox(bbox, width, height) + if clipped is None: + continue + x1, y1, x2, y2 = clipped + alpha[y1:y2, x1:x2] = np.clip(alpha[y1:y2, x1:x2].astype(np.float32) * factor, 0, 255).astype(np.uint8) + return alpha, sampled + + +def apply_dropout_mask(alpha: np.ndarray, dropout_prob: float, rng: random.Random) -> np.ndarray: + if dropout_prob <= 0: + return alpha + np_rng = _np_rng(rng) + keep = (np_rng.random(alpha.shape) > dropout_prob).astype(np.float32) + out = (alpha.astype(np.float32) * keep).astype(np.uint8) + return out + + +def apply_missing_ink_clusters(alpha: np.ndarray, strength: float, rng: random.Random) -> np.ndarray: + strength = max(0.0, min(1.0, strength)) + if strength <= 0: + return alpha + + np_rng = _np_rng(rng) + h, w = alpha.shape + low_h = max(6, h // 18) + low_w = max(6, w // 18) + low = np_rng.random((low_h, low_w)).astype(np.float32) + low_img = Image.fromarray((low * 255).astype(np.uint8), mode="L") + low_img = low_img.resize((w, h), resample=Image.Resampling.BICUBIC) + low_img = low_img.filter(ImageFilter.GaussianBlur(radius=max(1.2, min(w, h) / 110))) + + noise = np.array(low_img, dtype=np.float32) / 255.0 + mask = np.clip(1.0 - (noise * strength), 0.15, 1.0) + out = np.clip(alpha.astype(np.float32) * mask, 0, 255).astype(np.uint8) + return out + + +def apply_edge_fade(alpha: np.ndarray, strength: float, rng: random.Random) -> np.ndarray: + h, w = alpha.shape + y = np.linspace(-1.0, 1.0, h) + x = np.linspace(-1.0, 1.0, w) + xx, yy = np.meshgrid(x, y) + radius = np.sqrt(xx * xx + yy * yy) + jitter = rng.uniform(0.03, 0.15) + falloff = np.clip(1.0 - np.maximum(0.0, radius - (0.60 + jitter)) * (1.4 + strength), 0.2, 1.0) + return np.clip(alpha.astype(np.float32) * falloff, 0, 255).astype(np.uint8) + + +def apply_gaussian_blur(layer: Image.Image, radius: float) -> Image.Image: + if radius <= 0: + return layer + return layer.filter(ImageFilter.GaussianBlur(radius=radius)) + + +def apply_washed_out_effect(layer: Image.Image, strength: float) -> Image.Image: + strength = max(0.0, min(1.0, strength)) + if strength <= 0: + return layer + + rgba = np.array(layer, dtype=np.float32) + rgb = rgba[:, :, :3] + alpha = rgba[:, :, 3] + + gray = rgb.mean(axis=2, keepdims=True) + rgb = rgb * (1.0 - 0.28 * strength) + gray * (0.28 * strength) + alpha = alpha * (1.0 - 0.55 * strength) + + out = np.dstack([np.clip(rgb, 0, 255), np.clip(alpha, 0, 255)]).astype(np.uint8) + return Image.fromarray(out, mode="RGBA") + + +def apply_rotation(layer: Image.Image, angle: float) -> Image.Image: + if abs(angle) < 0.01: + return layer + return layer.rotate(angle, resample=Image.Resampling.BICUBIC, expand=False) + + +def apply_ghost_stamp(layer: Image.Image, offset: tuple[int, int], opacity_factor: float) -> Image.Image: + dx, dy = offset + if dx == 0 and dy == 0: + return layer + + base = layer.copy() + ghost = Image.new("RGBA", base.size, (0, 0, 0, 0)) + ghost.paste(base, (dx, dy), base) + + ghost_arr = np.array(ghost, dtype=np.float32) + ghost_arr[:, :, 3] = np.clip(ghost_arr[:, :, 3] * opacity_factor, 0, 255) + ghost = Image.fromarray(ghost_arr.astype(np.uint8), mode="RGBA") + + return Image.alpha_composite(base, ghost) + + +def apply_partial_crop(layer: Image.Image, rng: random.Random, max_ratio: float) -> tuple[Image.Image, dict[str, int]]: + max_ratio = max(0.0, min(0.95, max_ratio)) + if max_ratio <= 0: + return layer, {"left": 0, "right": 0, "top": 0, "bottom": 0} + + w, h = layer.size + crop_left = int(w * rng.uniform(0.0, max_ratio) * (1 if rng.random() < 0.5 else 0)) + crop_right = int(w * rng.uniform(0.0, max_ratio) * (1 if rng.random() < 0.5 else 0)) + crop_top = int(h * rng.uniform(0.0, max_ratio) * (1 if rng.random() < 0.5 else 0)) + crop_bottom = int(h * rng.uniform(0.0, max_ratio) * (1 if rng.random() < 0.5 else 0)) + + alpha = layer.split()[3] + alpha_arr = np.array(alpha, dtype=np.uint8) + if crop_left > 0: + alpha_arr[:, :crop_left] = 0 + if crop_right > 0: + alpha_arr[:, w - crop_right :] = 0 + if crop_top > 0: + alpha_arr[:crop_top, :] = 0 + if crop_bottom > 0: + alpha_arr[h - crop_bottom :, :] = 0 + + out = layer.copy() + out.putalpha(Image.fromarray(alpha_arr, mode="L")) + return out, {"left": crop_left, "right": crop_right, "top": crop_top, "bottom": crop_bottom} + + +def apply_scan_noise(layer: Image.Image, rng: random.Random, intensity: float = 0.15) -> Image.Image: + intensity = max(0.0, min(1.0, intensity)) + if intensity <= 0: + return layer + + arr = np.array(layer, dtype=np.float32) + h, w, _ = arr.shape + np_rng = _np_rng(rng) + + noise = np_rng.normal(0.0, 12.0 * intensity, (h, w, 1)).astype(np.float32) + arr[:, :, :3] = np.clip(arr[:, :, :3] + noise, 0, 255) + + band_count = max(1, int(h / 40)) + for _ in range(band_count): + y = int(np_rng.integers(0, h)) + band_h = int(np_rng.integers(1, max(2, h // 60))) + gain = float(np_rng.uniform(0.88, 1.08)) + arr[y : min(h, y + band_h), :, :3] = np.clip(arr[y : min(h, y + band_h), :, :3] * gain, 0, 255) + + return Image.fromarray(arr.astype(np.uint8), mode="RGBA") + + +def apply_jpeg_like_degradation(layer: Image.Image, rng: random.Random) -> Image.Image: + quality = int(rng.uniform(32, 68)) + rgb = layer.convert("RGB") + buffer = io.BytesIO() + rgb.save(buffer, format="JPEG", quality=quality, subsampling=1) + buffer.seek(0) + compressed = Image.open(buffer).convert("RGB") + + alpha = layer.split()[3] + out = compressed.convert("RGBA") + out.putalpha(alpha) + + out = out.filter(ImageFilter.BoxBlur(radius=rng.uniform(0.0, 0.6))) + return out + + +def reduce_alpha(layer: Image.Image, factor: float) -> Image.Image: + factor = max(0.0, min(1.0, factor)) + alpha = layer.split()[3] + alpha = ImageEnhance.Brightness(alpha).enhance(factor) + out = layer.copy() + out.putalpha(alpha) + return out + + +def multiply_alpha_mask(layer: Image.Image, mask: Image.Image) -> Image.Image: + alpha = layer.split()[3] + merged = ImageChops.multiply(alpha, mask.convert("L")) + out = layer.copy() + out.putalpha(merged) + return out diff --git a/stempel_module/stamp_models.py b/stempel_module/stamp_models.py new file mode 100644 index 0000000..f0bf967 --- /dev/null +++ b/stempel_module/stamp_models.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Literal, Optional + + +VisibilityMode = Literal["uniform", "per_character", "per_word", "per_section"] + + +@dataclass +class StampTextLine: + text: str + font_size: int = 34 + font_path: Optional[str] = None + weight: float = 1.0 + section_id: Optional[str] = None + + +@dataclass +class HandwritingOverlaySpec: + enabled: bool = False + color: tuple[int, int, int] = (25, 25, 25) + mode: str = "text_or_signature" + text_prob: float = 0.5 + signature_prob: float = 0.5 + overlap_prob: float = 1.0 + rotation_min: float = -8.0 + rotation_max: float = 8.0 + text_values: list[str] = field(default_factory=list) + opacity_min: float = 0.65 + opacity_max: float = 0.95 + line_width_min: int = 2 + line_width_max: int = 4 + + +@dataclass +class StampEffects: + ink_color: tuple[int, int, int] = (110, 70, 170) + opacity_min: float = 0.35 + opacity_max: float = 0.85 + visibility_mode: VisibilityMode = "per_character" + char_dropout_prob: float = 0.08 + word_dropout_prob: float = 0.04 + section_dropout_prob: float = 0.10 + missing_ink_prob: float = 0.35 + missing_ink_strength: float = 0.45 + blur_radius_min: float = 0.4 + blur_radius_max: float = 1.1 + rotation_min: float = -3.0 + rotation_max: float = 3.0 + strong_rotation_prob: float = 0.12 + strong_rotation_min: float = -10.0 + strong_rotation_max: float = 10.0 + ghost_prob: float = 0.15 + ghost_offset_min: int = 2 + ghost_offset_max: int = 10 + ghost_opacity_min: float = 0.12 + ghost_opacity_max: float = 0.30 + washed_out_prob: float = 0.20 + washed_out_strength_min: float = 0.20 + washed_out_strength_max: float = 0.45 + edge_fade_prob: float = 0.25 + crop_prob: float = 0.15 + crop_max_ratio: float = 0.30 + paper_bleed_prob: float = 0.10 + scan_noise_prob: float = 0.20 + jpeg_artifact_prob: float = 0.10 + + +@dataclass +class StampSpec: + lines: list[StampTextLine] = field(default_factory=list) + effects: StampEffects = field(default_factory=StampEffects) + handwriting: HandwritingOverlaySpec = field(default_factory=HandwritingOverlaySpec) + preset_name: Optional[str] = None diff --git a/stempel_module/stamp_presets.py b/stempel_module/stamp_presets.py new file mode 100644 index 0000000..ab456ab --- /dev/null +++ b/stempel_module/stamp_presets.py @@ -0,0 +1,206 @@ +STAMP_PRESETS = { + "clean": { + "effects": { + "ink_color": (125, 88, 170), + "opacity_min": 0.78, + "opacity_max": 0.95, + "visibility_mode": "uniform", + "char_dropout_prob": 0.0, + "word_dropout_prob": 0.0, + "section_dropout_prob": 0.0, + "missing_ink_prob": 0.0, + "blur_radius_min": 0.0, + "blur_radius_max": 0.2, + "rotation_min": -1.0, + "rotation_max": 1.0, + "ghost_prob": 0.0, + "washed_out_prob": 0.0, + "edge_fade_prob": 0.0, + "crop_prob": 0.0, + "scan_noise_prob": 0.0, + "jpeg_artifact_prob": 0.0, + }, + }, + "light_faded": { + "effects": { + "ink_color": (115, 80, 165), + "opacity_min": 0.45, + "opacity_max": 0.78, + "visibility_mode": "per_word", + "char_dropout_prob": 0.04, + "word_dropout_prob": 0.08, + "missing_ink_prob": 0.25, + "missing_ink_strength": 0.3, + "blur_radius_min": 0.2, + "blur_radius_max": 0.8, + "rotation_min": -3.0, + "rotation_max": 3.0, + "ghost_prob": 0.05, + "washed_out_prob": 0.1, + "scan_noise_prob": 0.1, + }, + }, + "medical_faded": { + "effects": { + "ink_color": (108, 73, 166), + "opacity_min": 0.28, + "opacity_max": 0.72, + "visibility_mode": "per_character", + "char_dropout_prob": 0.12, + "word_dropout_prob": 0.06, + "section_dropout_prob": 0.18, + "missing_ink_prob": 0.45, + "missing_ink_strength": 0.52, + "blur_radius_min": 0.7, + "blur_radius_max": 1.4, + "rotation_min": -4.0, + "rotation_max": 4.0, + "ghost_prob": 0.18, + "washed_out_prob": 0.35, + "edge_fade_prob": 0.30, + "crop_prob": 0.15, + "scan_noise_prob": 0.2, + "jpeg_artifact_prob": 0.12, + }, + }, + "medical_uneven_ink": { + "effects": { + "ink_color": (105, 68, 160), + "opacity_min": 0.25, + "opacity_max": 0.78, + "visibility_mode": "per_section", + "char_dropout_prob": 0.08, + "section_dropout_prob": 0.25, + "missing_ink_prob": 0.55, + "missing_ink_strength": 0.6, + "blur_radius_min": 0.4, + "blur_radius_max": 1.1, + "rotation_min": -4.5, + "rotation_max": 4.5, + "ghost_prob": 0.12, + "washed_out_prob": 0.2, + "edge_fade_prob": 0.35, + }, + }, + "medical_with_black_signature": { + "effects": { + "ink_color": (104, 68, 168), + "opacity_min": 0.30, + "opacity_max": 0.80, + "visibility_mode": "per_character", + "char_dropout_prob": 0.08, + "section_dropout_prob": 0.16, + "missing_ink_prob": 0.42, + "missing_ink_strength": 0.48, + "blur_radius_min": 0.6, + "blur_radius_max": 1.2, + "rotation_min": -5.0, + "rotation_max": 5.0, + "strong_rotation_prob": 0.2, + "strong_rotation_min": -12.0, + "strong_rotation_max": 12.0, + "ghost_prob": 0.2, + "washed_out_prob": 0.22, + "edge_fade_prob": 0.3, + "crop_prob": 0.2, + "scan_noise_prob": 0.25, + "jpeg_artifact_prob": 0.16, + }, + "handwriting": { + "enabled": True, + "mode": "signature", + "overlap_prob": 1.0, + "text_prob": 0.2, + "signature_prob": 0.8, + "opacity_min": 0.82, + "opacity_max": 0.98, + "line_width_min": 2, + "line_width_max": 4, + }, + }, + "medical_with_black_handwriting": { + "effects": { + "ink_color": (110, 72, 168), + "opacity_min": 0.30, + "opacity_max": 0.72, + "visibility_mode": "per_word", + "char_dropout_prob": 0.09, + "section_dropout_prob": 0.12, + "missing_ink_prob": 0.3, + "blur_radius_min": 0.5, + "blur_radius_max": 1.0, + "rotation_min": -4.0, + "rotation_max": 4.0, + "ghost_prob": 0.1, + "washed_out_prob": 0.2, + }, + "handwriting": { + "enabled": True, + "mode": "text", + "overlap_prob": 1.0, + "text_prob": 0.9, + "signature_prob": 0.1, + }, + }, + "medical_ghosted": { + "effects": { + "ink_color": (106, 71, 165), + "opacity_min": 0.32, + "opacity_max": 0.74, + "visibility_mode": "per_character", + "char_dropout_prob": 0.08, + "ghost_prob": 0.45, + "ghost_opacity_min": 0.18, + "ghost_opacity_max": 0.35, + "ghost_offset_min": 2, + "ghost_offset_max": 12, + "blur_radius_min": 0.5, + "blur_radius_max": 1.2, + }, + }, + "medical_washed_out": { + "effects": { + "ink_color": (112, 79, 168), + "opacity_min": 0.22, + "opacity_max": 0.62, + "visibility_mode": "per_section", + "char_dropout_prob": 0.1, + "missing_ink_prob": 0.55, + "missing_ink_strength": 0.65, + "blur_radius_min": 1.1, + "blur_radius_max": 2.0, + "washed_out_prob": 0.6, + "washed_out_strength_min": 0.35, + "washed_out_strength_max": 0.65, + "edge_fade_prob": 0.45, + }, + }, + "medical_extreme_scan": { + "effects": { + "ink_color": (98, 64, 152), + "opacity_min": 0.18, + "opacity_max": 0.58, + "visibility_mode": "per_character", + "char_dropout_prob": 0.16, + "word_dropout_prob": 0.12, + "section_dropout_prob": 0.2, + "missing_ink_prob": 0.65, + "missing_ink_strength": 0.72, + "blur_radius_min": 0.9, + "blur_radius_max": 2.2, + "rotation_min": -6.0, + "rotation_max": 6.0, + "ghost_prob": 0.28, + "washed_out_prob": 0.5, + "edge_fade_prob": 0.4, + "crop_prob": 0.35, + "scan_noise_prob": 0.8, + "jpeg_artifact_prob": 0.65, + }, + "handwriting": { + "enabled": True, + "mode": "text_or_signature", + "overlap_prob": 0.85, + }, + }, +} diff --git a/stempel_module/stamp_renderer.py b/stempel_module/stamp_renderer.py new file mode 100644 index 0000000..5a3accf --- /dev/null +++ b/stempel_module/stamp_renderer.py @@ -0,0 +1,504 @@ +from __future__ import annotations + +import copy +import random +from dataclasses import asdict +from typing import Any + +import cv2 +import numpy as np +from PIL import Image, ImageDraw, ImageFont + +from .overlay_renderer import render_black_handwriting_overlay +from .paper_effects import apply_subtle_paper_texture +from .stamp_effects import ( + apply_character_opacity_map, + apply_dropout_mask, + apply_edge_fade, + apply_gaussian_blur, + apply_ghost_stamp, + apply_jpeg_like_degradation, + apply_missing_ink_clusters, + apply_partial_crop, + apply_rotation, + apply_scan_noise, + apply_section_opacity_map, + apply_washed_out_effect, +) +from .stamp_models import HandwritingOverlaySpec, StampEffects, StampSpec, StampTextLine +from .stamp_presets import STAMP_PRESETS + + +def _deep_merge(base: dict[str, Any], update: dict[str, Any]) -> dict[str, Any]: + out = copy.deepcopy(base) + for key, value in update.items(): + if isinstance(value, dict) and isinstance(out.get(key), dict): + out[key] = _deep_merge(out[key], value) + else: + out[key] = value + return out + + +def _build_dataclass(cls, data: dict[str, Any] | None): + data = data or {} + valid = {k: v for k, v in data.items() if k in cls.__dataclass_fields__} + return cls(**valid) + + +def _normalize_lines(lines_payload: list[Any] | None, effects: StampEffects) -> list[StampTextLine]: + lines: list[StampTextLine] = [] + if not lines_payload: + return lines + + default_size = 26 if effects.visibility_mode == "uniform" else 28 + + for idx, item in enumerate(lines_payload): + if isinstance(item, str): + lines.append(StampTextLine(text=item, font_size=default_size, section_id=f"line_{idx}")) + continue + if isinstance(item, dict): + data = {k: v for k, v in item.items() if k in StampTextLine.__dataclass_fields__} + if "text" not in data: + continue + data.setdefault("font_size", default_size) + data.setdefault("section_id", f"line_{idx}") + lines.append(StampTextLine(**data)) + + return lines + + +def _resolve_stamp_spec(payload: dict[str, Any] | None, cfg: dict[str, Any] | None, preset_map: dict[str, Any] | None) -> StampSpec: + payload = payload or {} + cfg = cfg or {} + + base = asdict(StampSpec()) + + params = cfg.get("params", {}) if isinstance(cfg, dict) else {} + preset_name = payload.get("preset") or params.get("preset") or cfg.get("preset") or "clean" + + merged_presets = {} + merged_presets.update(STAMP_PRESETS) + if isinstance(preset_map, dict): + merged_presets.update(preset_map) + + preset_data = merged_presets.get(preset_name, {}) + if isinstance(preset_data, dict): + base = _deep_merge(base, preset_data) + + # Field-level stamp config can override preset defaults. + if isinstance(cfg.get("stamp"), dict): + base = _deep_merge(base, cfg["stamp"]) + + if isinstance(payload.get("effects"), dict): + base = _deep_merge(base, {"effects": payload["effects"]}) + + if isinstance(payload.get("handwriting"), dict): + base = _deep_merge(base, {"handwriting": payload["handwriting"]}) + + lines_payload = payload.get("lines") + if isinstance(lines_payload, str): + lines_payload = [lines_payload] + if lines_payload is None and isinstance(payload.get("value"), str): + lines_payload = [payload["value"]] + + effects = _build_dataclass(StampEffects, base.get("effects")) + handwriting = _build_dataclass(HandwritingOverlaySpec, base.get("handwriting")) + lines = _normalize_lines(lines_payload, effects) + + if not lines and payload.get("text"): + lines = _normalize_lines([str(payload["text"])], effects) + + spec = StampSpec( + lines=lines, + effects=effects, + handwriting=handwriting, + preset_name=preset_name, + ) + return spec + + +def _load_font(font_path: str | None, font_size: int): + candidates = [] + if font_path: + candidates.append(font_path) + candidates.extend(["DejaVuSans.ttf", "arial.ttf"]) + + for candidate in candidates: + try: + return ImageFont.truetype(candidate, size=font_size) + except OSError: + continue + return ImageFont.load_default() + + +def _pick_color(effects: StampEffects, rng: random.Random) -> tuple[int, int, int]: + r, g, b = effects.ink_color + jitter = lambda c: int(max(0, min(255, c + rng.randint(-8, 8)))) + return jitter(r), jitter(g), jitter(b) + + +def _render_clean_stamp_layer(size: tuple[int, int], spec: StampSpec, rng: random.Random): + width, height = size + layer = Image.new("RGBA", size, (0, 0, 0, 0)) + draw = ImageDraw.Draw(layer) + effects = spec.effects + color = _pick_color(effects, rng) + + if not spec.lines: + return layer, { + "line_count": 0, + "char_count": 0, + "word_count": 0, + "char_bboxes": [], + "section_bboxes": {}, + "sampled": {}, + } + + top_margin = max(2, int(height * 0.06)) + left_margin = max(2, int(width * 0.04)) + available_h = max(1, height - top_margin * 2) + slot_h = available_h / max(1, len(spec.lines)) + + visibility = effects.visibility_mode + char_bboxes: list[tuple[int, int, int, int]] = [] + section_bboxes: dict[str, list[tuple[int, int, int, int]]] = {} + word_boxes: list[tuple[int, int, int, int]] = [] + sampled_per_char: list[float] = [] + sampled_per_word: list[float] = [] + sampled_per_section: dict[str, float] = {} + + uniform_factor = rng.uniform(effects.opacity_min, effects.opacity_max) + + for idx, line in enumerate(spec.lines): + section_id = line.section_id or f"line_{idx}" + font = _load_font(line.font_path, max(10, line.font_size)) + + y = int(top_margin + idx * slot_h + rng.uniform(-2.0, 2.0)) + x = int(left_margin + rng.uniform(-3.0, 4.0)) + section_boxes: list[tuple[int, int, int, int]] = [] + + if visibility == "per_section": + factor = rng.uniform(effects.opacity_min, effects.opacity_max) + if rng.random() < effects.section_dropout_prob: + factor *= rng.uniform(0.05, 0.4) + sampled_per_section[section_id] = round(float(factor), 4) + + words = line.text.split(" ") + for word in words: + if visibility == "per_word": + word_factor = rng.uniform(effects.opacity_min, effects.opacity_max) + if rng.random() < effects.word_dropout_prob: + word_factor *= rng.uniform(0.05, 0.45) + sampled_per_word.append(round(float(word_factor), 4)) + else: + word_factor = 1.0 + + word_x_start = x + for ch in word: + if visibility == "uniform": + alpha_factor = uniform_factor + elif visibility == "per_character": + alpha_factor = rng.uniform(effects.opacity_min, effects.opacity_max) + if rng.random() < effects.char_dropout_prob: + alpha_factor *= rng.uniform(0.05, 0.35) + elif visibility == "per_word": + alpha_factor = word_factor + else: + alpha_factor = sampled_per_section.get(section_id, 1.0) + + sampled_per_char.append(round(float(alpha_factor), 4)) + + bbox = draw.textbbox((x, y), ch, font=font) + ch_w = max(1, bbox[2] - bbox[0]) + ch_h = max(1, bbox[3] - bbox[1]) + + alpha = int(max(0, min(255, 255 * alpha_factor * max(0.1, line.weight)))) + if alpha > 0: + draw.text((x, y), ch, fill=(*color, alpha), font=font) + + adjusted_bbox = (x, y, x + ch_w, y + ch_h) + char_bboxes.append(adjusted_bbox) + section_boxes.append(adjusted_bbox) + x += ch_w + + word_box = (word_x_start, y, x, y + max(1, int(slot_h * 0.72))) + word_boxes.append(word_box) + + # Space width uses font metrics so word separations look natural. + space_bbox = draw.textbbox((0, 0), " ", font=font) + space_w = max(2, space_bbox[2] - space_bbox[0]) + x += space_w + + section_bboxes[section_id] = section_boxes + + metadata = { + "line_count": len(spec.lines), + "char_count": len(char_bboxes), + "word_count": len(word_boxes), + "char_bboxes": char_bboxes, + "word_bboxes": word_boxes, + "section_bboxes": section_bboxes, + "sampled": { + "uniform_factor": round(float(uniform_factor), 4), + "char_factors": sampled_per_char, + "word_factors": sampled_per_word, + "section_factors": sampled_per_section, + }, + } + return layer, metadata + + +def _apply_alpha_effects(layer: Image.Image, clean_meta: dict[str, Any], spec: StampSpec, rng: random.Random): + effects = spec.effects + alpha = np.array(layer.split()[3], dtype=np.uint8) + + # Enforce requested visibility control at mask level too for stronger local differences. + visibility_mode = effects.visibility_mode + section_sampled = {} + char_sampled = [] + + if visibility_mode == "per_character": + alpha, char_sampled = apply_character_opacity_map( + alpha, + clean_meta.get("char_bboxes", []), + effects.opacity_min, + effects.opacity_max, + effects.char_dropout_prob, + rng, + ) + elif visibility_mode == "per_section": + alpha, section_sampled = apply_section_opacity_map( + alpha, + clean_meta.get("section_bboxes", {}), + effects.opacity_min, + effects.opacity_max, + effects.section_dropout_prob, + rng, + ) + elif visibility_mode == "uniform": + uniform = rng.uniform(effects.opacity_min, effects.opacity_max) + alpha = np.clip(alpha.astype(np.float32) * uniform, 0, 255).astype(np.uint8) + else: + # Per-word already applied strongly at glyph render stage; keep mild pixel dropout here. + alpha = apply_dropout_mask(alpha, effects.word_dropout_prob * 0.35, rng) + + missing_ink_applied = False + if rng.random() < effects.missing_ink_prob: + alpha = apply_missing_ink_clusters(alpha, effects.missing_ink_strength, rng) + missing_ink_applied = True + + edge_fade_applied = False + if rng.random() < effects.edge_fade_prob: + alpha = apply_edge_fade(alpha, strength=rng.uniform(0.2, 0.9), rng=rng) + edge_fade_applied = True + + out = layer.copy() + out.putalpha(Image.fromarray(alpha, mode="L")) + + return out, { + "char_factors_mask_stage": char_sampled[:120], + "section_factors_mask_stage": section_sampled, + "missing_ink_applied": missing_ink_applied, + "edge_fade_applied": edge_fade_applied, + } + + +def _apply_degradation_pipeline( + layer: Image.Image, + base_region_rgb: Image.Image, + spec: StampSpec, + clean_meta: dict[str, Any], + rng: random.Random, +): + effects = spec.effects + metadata: dict[str, Any] = { + "visibility_mode": effects.visibility_mode, + "preset": spec.preset_name, + } + + layer, alpha_meta = _apply_alpha_effects(layer, clean_meta, spec, rng) + metadata.update(alpha_meta) + + blur_radius = rng.uniform(effects.blur_radius_min, effects.blur_radius_max) + layer = apply_gaussian_blur(layer, blur_radius) + metadata["blur_radius"] = round(float(blur_radius), 3) + + washed_out = False + washed_out_strength = 0.0 + if rng.random() < effects.washed_out_prob: + washed_out = True + washed_out_strength = rng.uniform(effects.washed_out_strength_min, effects.washed_out_strength_max) + layer = apply_washed_out_effect(layer, washed_out_strength) + metadata["washed_out"] = washed_out + metadata["washed_out_strength"] = round(float(washed_out_strength), 3) + + if rng.random() < effects.strong_rotation_prob: + rotation = rng.uniform(effects.strong_rotation_min, effects.strong_rotation_max) + metadata["rotation_mode"] = "strong" + else: + rotation = rng.uniform(effects.rotation_min, effects.rotation_max) + metadata["rotation_mode"] = "slight" + layer = apply_rotation(layer, rotation) + metadata["rotation_deg"] = round(float(rotation), 3) + + ghost_applied = False + ghost_offset = (0, 0) + ghost_opacity = 0.0 + if rng.random() < effects.ghost_prob: + ghost_applied = True + ghost_offset = ( + rng.randint(-effects.ghost_offset_max, effects.ghost_offset_max), + rng.randint(-effects.ghost_offset_max, effects.ghost_offset_max), + ) + if abs(ghost_offset[0]) < effects.ghost_offset_min and abs(ghost_offset[1]) < effects.ghost_offset_min: + ghost_offset = (effects.ghost_offset_min, 0) + ghost_opacity = rng.uniform(effects.ghost_opacity_min, effects.ghost_opacity_max) + layer = apply_ghost_stamp(layer, ghost_offset, ghost_opacity) + metadata["ghost_applied"] = ghost_applied + metadata["ghost_offset"] = {"x": ghost_offset[0], "y": ghost_offset[1]} + metadata["ghost_opacity"] = round(float(ghost_opacity), 3) + + crop_applied = False + crop_values = {"left": 0, "right": 0, "top": 0, "bottom": 0} + if rng.random() < effects.crop_prob: + crop_applied = True + layer, crop_values = apply_partial_crop(layer, rng, effects.crop_max_ratio) + metadata["crop_applied"] = crop_applied + metadata["crop_values"] = crop_values + + paper_bleed = False + if rng.random() < effects.paper_bleed_prob: + paper_bleed = True + layer = apply_subtle_paper_texture(layer, base_region_rgb, rng, strength=rng.uniform(0.08, 0.32)) + metadata["paper_bleed_applied"] = paper_bleed + + scan_noise = False + if rng.random() < effects.scan_noise_prob: + scan_noise = True + layer = apply_scan_noise(layer, rng, intensity=rng.uniform(0.08, 0.28)) + metadata["scan_noise_applied"] = scan_noise + + jpeg_degrade = False + if rng.random() < effects.jpeg_artifact_prob: + jpeg_degrade = True + layer = apply_jpeg_like_degradation(layer, rng) + metadata["jpeg_like_degradation_applied"] = jpeg_degrade + + return layer, metadata + + +def _compose_full_image( + full_rgba: Image.Image, + stamp_layer: Image.Image, + region: dict[str, int], + rng: random.Random, + allow_border_cutoff: bool, + jitter_x: int, + jitter_y: int, +): + x1 = int(region["x1"]) + y1 = int(region["y1"]) + x2 = int(region["x2"]) + y2 = int(region["y2"]) + width = max(1, x2 - x1) + height = max(1, y2 - y1) + + # Default is deterministic placement (no offset). Optional jitter can be enabled + # through stamp config if imperfect placement is desired. + if allow_border_cutoff: + max_dx = max(jitter_x, max(2, int(width * 0.18))) + max_dy = max(jitter_y, max(2, int(height * 0.18))) + else: + max_dx = max(0, jitter_x) + max_dy = max(0, jitter_y) + + dx = rng.randint(-max_dx, max_dx) if max_dx else 0 + dy = rng.randint(-max_dy, max_dy) if max_dy else 0 + + full_rgba.alpha_composite(stamp_layer, dest=(x1 + dx, y1 + dy)) + return full_rgba, {"dx": dx, "dy": dy} + + +def render_stamp_on_image( + base_img_bgr, + region, + stamp_payload, + cfg: dict[str, Any] | None = None, + rng: random.Random | None = None, + preset_map: dict[str, Any] | None = None, +): + """ + High-level stamp API used by generator.py. + Returns (updated_bgr_image, metadata) + """ + if rng is None: + rng = random.Random() + cfg = cfg or {} + + spec = _resolve_stamp_spec(stamp_payload, cfg, preset_map) + + x1 = int(region["x1"]) + y1 = int(region["y1"]) + x2 = int(region["x2"]) + y2 = int(region["y2"]) + width = max(1, x2 - x1) + height = max(1, y2 - y1) + + full_rgb = cv2.cvtColor(base_img_bgr, cv2.COLOR_BGR2RGB) + full_rgba = Image.fromarray(full_rgb).convert("RGBA") + + stamp_clean, clean_meta = _render_clean_stamp_layer((width, height), spec, rng) + + base_region = full_rgba.crop((x1, y1, x2, y2)).convert("RGB") + stamp_degraded, fx_meta = _apply_degradation_pipeline(stamp_clean, base_region, spec, clean_meta, rng) + + handwriting_meta = {"enabled": False, "applied": False} + overlay = None + if spec.handwriting.enabled: + overlay, handwriting_meta = render_black_handwriting_overlay((width, height), spec.handwriting, rng) + stamp_degraded = Image.alpha_composite(stamp_degraded, overlay) + + placement_cfg = cfg.get("placement", {}) if isinstance(cfg.get("placement"), dict) else {} + allow_border_cutoff = bool(placement_cfg.get("allow_border_cutoff", False)) + jitter_x = int(placement_cfg.get("jitter_x", 0)) + jitter_y = int(placement_cfg.get("jitter_y", 0)) + + full_rgba, placement_meta = _compose_full_image( + full_rgba=full_rgba, + stamp_layer=stamp_degraded, + region=region, + rng=rng, + allow_border_cutoff=allow_border_cutoff, + jitter_x=jitter_x, + jitter_y=jitter_y, + ) + + out_rgb = np.array(full_rgba.convert("RGB")) + out_bgr = cv2.cvtColor(out_rgb, cv2.COLOR_RGB2BGR) + + sampled_char_factors = clean_meta.get("sampled", {}).get("char_factors", []) + opacity_min_sampled = min(sampled_char_factors) if sampled_char_factors else None + opacity_max_sampled = max(sampled_char_factors) if sampled_char_factors else None + + metadata = { + "type": "stamp", + "preset": spec.preset_name, + "region": {"x1": x1, "y1": y1, "x2": x2, "y2": y2}, + "line_count": clean_meta.get("line_count", 0), + "char_count": clean_meta.get("char_count", 0), + "word_count": clean_meta.get("word_count", 0), + "visibility_mode": spec.effects.visibility_mode, + "opacity_min_sampled": round(float(opacity_min_sampled), 4) if opacity_min_sampled is not None else None, + "opacity_max_sampled": round(float(opacity_max_sampled), 4) if opacity_max_sampled is not None else None, + "fx": fx_meta, + "handwriting": handwriting_meta, + "placement": placement_meta, + "sampled": { + "char_factors_preview": sampled_char_factors[:80], + "section_factors": clean_meta.get("sampled", {}).get("section_factors", {}), + "word_factors_preview": clean_meta.get("sampled", {}).get("word_factors", [])[:40], + }, + } + + return out_bgr, metadata + diff --git a/tests/test_stamp_generation.py b/tests/test_stamp_generation.py new file mode 100644 index 0000000..c455a74 --- /dev/null +++ b/tests/test_stamp_generation.py @@ -0,0 +1,256 @@ +import json +import os +import tempfile +import unittest + +import cv2 +import numpy as np + +from generator import Generator, GeneratorConfig + + +class TestStampGeneration(unittest.TestCase): + def _write_json(self, path, data): + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + def _blank_template(self, path, width=420, height=280): + img = np.full((height, width, 3), 255, dtype=np.uint8) + cv2.putText(img, "FORM TEXT", (20, 120), cv2.FONT_HERSHEY_SIMPLEX, 1.0, (60, 60, 60), 2, cv2.LINE_AA) + cv2.imwrite(path, img) + + def test_backward_compat_text_and_checkbox_generation(self): + with tempfile.TemporaryDirectory() as td: + template = os.path.join(td, "template.png") + layout = os.path.join(td, "layout.json") + config = os.path.join(td, "config.json") + out_dir = os.path.join(td, "out") + + self._blank_template(template) + self._write_json( + layout, + [ + {"name": "name", "type": "text", "x1": 40, "y1": 30, "x2": 220, "y2": 60}, + {"name": "accept", "type": "checkbox", "x1": 30, "y1": 180, "x2": 50, "y2": 200}, + ], + ) + self._write_json( + config, + { + "template": template, + "layout": layout, + "global": {"default_presence_prob": 1.0}, + "fields": { + "name": { + "generator": "from_list", + "presence_prob": 1.0, + "style": "computer", + "params": {"values": ["Alice"]}, + }, + "accept": { + "generator": "checkbox_binary", + "presence_prob": 1.0, + "params": {"true_prob": 1.0}, + }, + }, + }, + ) + + gcfg = GeneratorConfig(template=template, config_path=config, gennum=1, outputfolder=out_dir, outputtype="png") + gen = Generator(gcfg) + + img = gen.template_img.copy() + fields = gen.render_sample(img) + + statuses = {f["name"]: f["status"] for f in fields} + self.assertEqual(statuses["name"], "rendered") + self.assertEqual(statuses["accept"], "checked") + self.assertTrue((img < 250).any(), "Expected rendered ink on image") + + def test_named_stamps_use_layout_positions_and_per_stamp_config(self): + with tempfile.TemporaryDirectory() as td: + template = os.path.join(td, "template.png") + layout = os.path.join(td, "layout.json") + config = os.path.join(td, "config.json") + out_dir = os.path.join(td, "out") + + self._blank_template(template) + self._write_json( + layout, + { + "template": template, + "template_size": {"width": 420, "height": 280}, + "fields": [ + {"name": "stamp1", "type": "stamp", "x1": 40, "y1": 40, "x2": 200, "y2": 140}, + {"name": "stamp2", "type": "stamp", "x1": 210, "y1": 50, "x2": 390, "y2": 170}, + ], + }, + ) + self._write_json( + config, + { + "template": template, + "layout": layout, + "global": {"default_presence_prob": 1.0}, + "fields": {}, + "stamps": { + "stamp1": { + "generator": "doctor_stamp_lines", + "presence_prob": 1.0, + "params": { + "preset": "medical_with_black_signature", + "line_template": ["Dr. med. {full_name}", "{street}", "{postcode} {city}"], + }, + }, + "stamp2": { + "generator": "doctor_stamp_lines", + "presence_prob": 1.0, + "params": { + "preset": "clean", + "line_template": ["Praxis {full_name}", "Tel.: {phone}"], + }, + }, + }, + }, + ) + + gcfg = GeneratorConfig(template=template, config_path=config, gennum=1, outputfolder=out_dir, outputtype="png") + gen = Generator(gcfg) + + img = gen.template_img.copy() + fields = gen.render_sample(img) + + by_name = {f["name"]: f for f in fields} + self.assertEqual(by_name["stamp1"]["status"], "rendered_stamp") + self.assertEqual(by_name["stamp2"]["status"], "rendered_stamp") + self.assertEqual(by_name["stamp1"]["stamp_meta"]["preset"], "medical_with_black_signature") + self.assertEqual(by_name["stamp2"]["stamp_meta"]["preset"], "clean") + self.assertEqual(by_name["stamp1"]["stamp_meta"]["placement"]["dx"], 0) + self.assertEqual(by_name["stamp1"]["stamp_meta"]["placement"]["dy"], 0) + self.assertTrue((img < 250).any(), "Expected visible stamp pixels") + + def test_text_field_stamp_mode_per_character_metadata(self): + with tempfile.TemporaryDirectory() as td: + template = os.path.join(td, "template.png") + layout = os.path.join(td, "layout.json") + config = os.path.join(td, "config.json") + out_dir = os.path.join(td, "out") + + self._blank_template(template) + self._write_json( + layout, + [ + {"name": "arztstempel", "type": "text", "x1": 60, "y1": 60, "x2": 380, "y2": 230}, + ], + ) + self._write_json( + config, + { + "template": template, + "layout": layout, + "global": {"default_presence_prob": 1.0}, + "fields": { + "arztstempel": { + "generator": "doctor_stamp_lines", + "render_mode": "stamp", + "presence_prob": 1.0, + "params": { + "preset": "medical_with_black_signature", + "line_template": [ + "Dr. med. {full_name}", + "Facharzt fuer {specialty}", + "BSNR: {bsnr}", + ], + }, + } + }, + }, + ) + + gcfg = GeneratorConfig(template=template, config_path=config, gennum=1, outputfolder=out_dir, outputtype="png") + gen = Generator(gcfg) + + img = gen.template_img.copy() + fields = gen.render_sample(img) + + stamp_records = [f for f in fields if f["name"] == "arztstempel"] + self.assertEqual(len(stamp_records), 1) + rec = stamp_records[0] + self.assertEqual(rec["status"], "rendered_stamp") + + stamp_meta = rec["stamp_meta"] + self.assertEqual(stamp_meta["visibility_mode"], "per_character") + self.assertIsNotNone(stamp_meta["opacity_min_sampled"]) + self.assertIsNotNone(stamp_meta["opacity_max_sampled"]) + self.assertGreater(stamp_meta["opacity_max_sampled"], stamp_meta["opacity_min_sampled"]) + self.assertIn("blur_radius", stamp_meta["fx"]) + self.assertIn("ghost_applied", stamp_meta["fx"]) + self.assertEqual(stamp_meta["placement"]["dx"], 0) + self.assertEqual(stamp_meta["placement"]["dy"], 0) + self.assertTrue((img < 250).any(), "Expected visible stamp pixels") + + def test_layout_template_size_scaling_applies_to_coords(self): + with tempfile.TemporaryDirectory() as td: + template = os.path.join(td, "template_large.png") + layout = os.path.join(td, "layout.json") + config = os.path.join(td, "config.json") + out_dir = os.path.join(td, "out") + + # Actual template: 400x200 + self._blank_template(template, width=400, height=200) + + # Layout saved against a smaller template: 200x100 + self._write_json( + layout, + { + "template": "template_small.png", + "template_size": {"width": 200, "height": 100}, + "fields": [ + {"name": "name", "type": "text", "x1": 20, "y1": 10, "x2": 80, "y2": 25}, + {"name": "stamp1", "type": "stamp", "x1": 90, "y1": 20, "x2": 180, "y2": 70}, + ], + }, + ) + self._write_json( + config, + { + "template": template, + "layout": layout, + "global": {"default_presence_prob": 1.0}, + "fields": { + "name": { + "generator": "from_list", + "presence_prob": 1.0, + "style": "computer", + "params": {"values": ["Alice"]}, + } + }, + "stamps": { + "stamp1": { + "generator": "doctor_stamp_lines", + "presence_prob": 1.0, + "params": { + "preset": "medical_with_black_signature", + "line_template": ["Dr. med. {full_name}", "{street}"] + }, + } + }, + }, + ) + + gcfg = GeneratorConfig(template=template, config_path=config, gennum=1, outputfolder=out_dir, outputtype="png") + gen = Generator(gcfg) + + img = gen.template_img.copy() + records = gen.render_sample(img) + by_name = {f["name"]: f for f in records} + + # Expected scale is exactly 2x on both axes. + self.assertEqual(by_name["name"]["coords"], {"x1": 40, "y1": 20, "x2": 160, "y2": 50}) + self.assertEqual(by_name["stamp1"]["coords"], {"x1": 180, "y1": 40, "x2": 360, "y2": 140}) + self.assertEqual(by_name["stamp1"]["stamp_meta"]["placement"]["dx"], 0) + self.assertEqual(by_name["stamp1"]["stamp_meta"]["placement"]["dy"], 0) + + +if __name__ == "__main__": + unittest.main()