Venket / src /metadata /metadata_exporter.py
venki101's picture
Add files using upload-large-folder tool
97b64b9 verified
Raw History Blame Contribute Delete
10.2 kB
"""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")