"""Assembles and writes the complete per-sample metadata JSON. The exported schema always includes every documented field -- augmentation parameters that were not applied are still present, with a neutral value (``0``/``null``/``false``) rather than being omitted, so downstream consumers can rely on a stable schema across the whole dataset. """ from __future__ import annotations import json from pathlib import Path from typing import Any import numpy as np from src.analysis.difficulty_analyzer import DifficultyResult from src.analysis.geometry_analyzer import GeometryResult from src.augmentation.augmentation_engine import AugmentationRecord from src.config.schema import QualityConfig from src.placement.placement_engine import PlacementResult from src.rendering.renderer import RenderResult from src.signatures.signature_dataset import SignatureRecord from src.utils.logging_utils import get_logger logger = get_logger(__name__) def _json_default(obj: Any) -> Any: if isinstance(obj, np.integer): return int(obj) if isinstance(obj, np.floating): return float(obj) if isinstance(obj, np.bool_): return bool(obj) if isinstance(obj, np.ndarray): return obj.tolist() if isinstance(obj, Path): return obj.as_posix() return str(obj) class MetadataExporter: """Builds and writes the full per-sample metadata JSON document. Example: >>> exporter = MetadataExporter() >>> metadata = exporter.build(...) >>> exporter.export(metadata, "output/sample_000001.json") """ def build( self, document_id: str, template_id: str, document_type: "str | None", page: int, page_size: tuple[int, int], dpi: int, signer_name: str, signer_role: str, signature_source: "SignatureRecord | None", placement: PlacementResult, render: RenderResult, signature_aug: dict[str, AugmentationRecord], document_aug: dict[str, AugmentationRecord], geometry: GeometryResult, difficulty: DifficultyResult, quality_cfg: QualityConfig, quality_values: dict[str, float], seed: int, sample_index: int, verification: "dict[str, Any] | None" = None, ) -> dict[str, Any]: """Assemble the complete metadata dict for one generated sample.""" degraded = self._compute_degraded(quality_cfg) augmentation_section = self._build_augmentation_section(placement, render, signature_aug, document_aug) metadata: dict[str, Any] = { "document_id": document_id, "template": template_id, "document_type": document_type, "page": page, "page_size": list(page_size), "dpi": dpi, "signer": { "name": signer_name, "role": signer_role, }, "signature_source": ( { "signature_id": signature_source.signature_id, "path": signature_source.path.as_posix(), "signer_group": signature_source.signer_group, "source_type": signature_source.source_type, "script": signature_source.script, } if signature_source is not None else {"signature_id": None, "path": None, "signer_group": None, "source_type": "none", "script": None} ), "placement": { "x_offset": placement.x_offset, "y_offset": placement.y_offset, "rotation_deg": round(placement.rotation_deg, 3), "scale_factor": round(placement.scale_factor, 4), "anchor_point": placement.anchor_point, "margin_target_box": list(placement.target_box), "misplaced": placement.misplaced, "misplacement_dx": round(placement.misplacement_dx, 2), "misplacement_dy": round(placement.misplacement_dy, 2), }, "rendering": { "blend_mode": render.blend_mode, "ink_opacity": render.ink_opacity, "blur_sigma": render.blur_sigma, "feather_px": render.feather_px, "scanner_noise": render.scanner_noise, }, "difficulty": { "score": difficulty.score, "tier": difficulty.tier, "misplacement": difficulty.misplacement, "whitespace": difficulty.whitespace, "faintness": difficulty.faintness, "smallness": difficulty.smallness, "low_texture": difficulty.low_texture, }, "quality": { "degraded": degraded, "case_type": quality_cfg.case_type, "notes": quality_cfg.notes, "scanner_quality": quality_values["scanner_quality"], "paper_quality": quality_values["paper_quality"], "render_quality": quality_values["render_quality"], }, "verification": verification or { "case_type": None, "verdict": None, "reject_code": None, "target_box_id": None, "expected_signer_group": None, "expected_signer_script": None, "actual_signer_group": None, "actual_source_type": None, "actual_signer_script": None, "companion": None, }, "augmentation": augmentation_section, "geometry": { "box_px": list(geometry.target_box), "signature_bbox_px": list(geometry.signature_bbox_px), "target_box": list(geometry.target_box), "actual_box": list(geometry.actual_box), "search_box_px": list(geometry.search_box), "signature_polygon_px": geometry.signature_polygon_px, "convex_hull_px": geometry.convex_hull_px, "min_rotated_rect": geometry.min_rotated_rect, "centroid_px": list(geometry.centroid_px), "orientation_deg": geometry.orientation_deg, "ink_pixels": geometry.ink_pixels, "detected": geometry.found, }, "metrics": { "box_coverage_pct": geometry.box_coverage_pct, "containment_pct": geometry.containment_pct, "ink_fill_ratio": geometry.ink_fill_ratio, "polygon_overlap_proxy": geometry.polygon_overlap_proxy, "distance_from_box_center_px": geometry.distance_from_box_center_px, "margin_utilization": geometry.margin_utilization, "mean_dark_intensity": geometry.mean_dark_intensity, "gray_stddev": geometry.gray_stddev, "signature_area_ratio": geometry.signature_area_ratio, }, "provenance": { "master_seed": seed, "sample_index": sample_index, }, } return metadata @staticmethod def _compute_degraded(quality_cfg: QualityConfig) -> bool: case_type_flag = quality_cfg.case_type == "1d" notes_flag = bool(quality_cfg.notes and "low-quality" in quality_cfg.notes.lower()) return bool(case_type_flag or notes_flag) @staticmethod def _build_augmentation_section( placement: PlacementResult, render: RenderResult, signature_aug: dict[str, AugmentationRecord], document_aug: dict[str, AugmentationRecord], ) -> dict[str, Any]: def rec(records: dict[str, AugmentationRecord], name: str) -> AugmentationRecord: return records[name] stroke_rec = rec(signature_aug, "stroke_dilation") noise_rec = rec(document_aug, "scanner_noise") stain_rec = rec(document_aug, "stain") shadow_rec = rec(document_aug, "shadow") coffee_rec = rec(document_aug, "coffee_stain") crumple_rec = rec(document_aug, "crumple") tilt_rec = rec(document_aug, "page_tilt") discoloration_rec = rec(document_aug, "discoloration") jpeg_rec = rec(document_aug, "jpeg_compression") effects: dict[str, Any] = {} for name, r in {**signature_aug, **document_aug}.items(): effects[name] = { "enabled": r.enabled, "applied": r.applied, "strength": r.strength, "probability": r.probability, "seed": r.seed, "params": r.params, } return { "rotate_deg": round(placement.rotation_deg, 3), "scale_factor": round(placement.scale_factor, 4), "stroke": stroke_rec.strength if stroke_rec.applied else 0.0, "noise_amount": noise_rec.strength if noise_rec.applied else 0.0, "stain_type": stain_rec.params.get("resolved_type") if stain_rec.applied else None, "stain_strength": stain_rec.strength if stain_rec.applied else 0.0, "shadow_type": shadow_rec.params.get("resolved_type") if shadow_rec.applied else None, "shadow_strength": shadow_rec.strength if shadow_rec.applied else 0.0, "coffee_stain_strength": coffee_rec.strength if coffee_rec.applied else 0.0, "crumple_strength": crumple_rec.strength if crumple_rec.applied else 0.0, "tilt_deg": tilt_rec.params.get("tilt_deg", 0.0) if tilt_rec.applied else 0.0, "discoloration_strength": discoloration_rec.strength if discoloration_rec.applied else 0.0, "blur_sigma": render.blur_sigma, "jpeg_quality": jpeg_rec.params.get("quality") if jpeg_rec.applied else None, "scanner_noise": render.scanner_noise, "effects": effects, } def export(self, metadata: dict[str, Any], path: "str | Path") -> None: """Write the metadata dict to disk as pretty-printed JSON.""" path = Path(path) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(metadata, indent=2, default=_json_default), encoding="utf-8")