Spaces:
Running on Zero
Running on Zero
Add glyph rendering and region preprocessing
Browse files
vendor/pixtext/preprocessing/__init__.py
ADDED
|
File without changes
|
vendor/pixtext/preprocessing/compositing.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from collections.abc import Sequence
|
| 4 |
+
|
| 5 |
+
import torch
|
| 6 |
+
|
| 7 |
+
from pixtext.preprocessing.geometry import GroupGeometry, ImageSize
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def _validate_generated_image(image: torch.Tensor, *, expected_size: ImageSize, label: str) -> None:
|
| 11 |
+
if not isinstance(image, torch.Tensor):
|
| 12 |
+
raise TypeError(f"{label} must be a torch.Tensor")
|
| 13 |
+
if image.ndim != 3 or image.shape[0] != 3:
|
| 14 |
+
raise ValueError(
|
| 15 |
+
f"{label} must be one RGB tensor in (C, H, W) layout, got shape {tuple(image.shape)}"
|
| 16 |
+
)
|
| 17 |
+
actual_size = tuple(image.shape[-2:])
|
| 18 |
+
planned_size = (expected_size.height, expected_size.width)
|
| 19 |
+
if actual_size != planned_size:
|
| 20 |
+
raise ValueError(
|
| 21 |
+
f"{label} size does not match its geometry: got {actual_size}, expected {planned_size}"
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def compose_native_edit_items(
|
| 26 |
+
clean_reference: torch.Tensor, generated_items: Sequence[torch.Tensor], geometry: GroupGeometry
|
| 27 |
+
) -> torch.Tensor:
|
| 28 |
+
"""Paste generated item crops on an undegraded reference canvas."""
|
| 29 |
+
_validate_generated_image(
|
| 30 |
+
clean_reference, expected_size=geometry.reference.size, label="clean reference image"
|
| 31 |
+
)
|
| 32 |
+
if len(generated_items) != len(geometry.items):
|
| 33 |
+
raise ValueError(
|
| 34 |
+
f"generated item count does not match geometry: got {len(generated_items)}, expected {len(geometry.items)}"
|
| 35 |
+
)
|
| 36 |
+
composite = clean_reference.detach().float().clone().contiguous()
|
| 37 |
+
for item_index, (generated_item, item_geometry) in enumerate(
|
| 38 |
+
zip(generated_items, geometry.items, strict=True), start=1
|
| 39 |
+
):
|
| 40 |
+
_validate_generated_image(
|
| 41 |
+
generated_item,
|
| 42 |
+
expected_size=item_geometry.crop.size,
|
| 43 |
+
label=f"generated item {item_index}",
|
| 44 |
+
)
|
| 45 |
+
if generated_item.device != composite.device:
|
| 46 |
+
raise ValueError(
|
| 47 |
+
f"generated item {item_index} is on {generated_item.device}, but the generated main image is on {composite.device}"
|
| 48 |
+
)
|
| 49 |
+
top = item_geometry.crop.top
|
| 50 |
+
left = item_geometry.crop.left
|
| 51 |
+
bottom = top + item_geometry.crop.size.height
|
| 52 |
+
right = left + item_geometry.crop.size.width
|
| 53 |
+
composite[:, top:bottom, left:right] = generated_item.detach().float()
|
| 54 |
+
return composite
|
vendor/pixtext/preprocessing/geometry.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Deterministic geometry planning for group-image JiT inputs.
|
| 2 |
+
|
| 3 |
+
This module computes pixel-coordinate transforms only. Image interpolation and
|
| 4 |
+
tensor slicing are deliberately left to data adapters so the geometry contract
|
| 5 |
+
does not depend on PIL, NumPy, or PyTorch.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import math
|
| 11 |
+
from collections.abc import Iterable
|
| 12 |
+
from dataclasses import dataclass
|
| 13 |
+
from fractions import Fraction
|
| 14 |
+
from numbers import Real
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@dataclass(frozen=True, slots=True)
|
| 18 |
+
class ImageSize:
|
| 19 |
+
"""An image size in ``(height, width)`` order."""
|
| 20 |
+
|
| 21 |
+
height: int
|
| 22 |
+
width: int
|
| 23 |
+
|
| 24 |
+
def __post_init__(self) -> None:
|
| 25 |
+
for name, value in (("height", self.height), ("width", self.width)):
|
| 26 |
+
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
|
| 27 |
+
raise ValueError(f"{name} must be a positive integer, got {value!r}")
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@dataclass(frozen=True, slots=True)
|
| 31 |
+
class BoundingBox:
|
| 32 |
+
"""A half-open pixel-boundary box in ``[x0, y0, x1, y1)`` form."""
|
| 33 |
+
|
| 34 |
+
x0: Real
|
| 35 |
+
y0: Real
|
| 36 |
+
x1: Real
|
| 37 |
+
y1: Real
|
| 38 |
+
|
| 39 |
+
def __post_init__(self) -> None:
|
| 40 |
+
for name, value in (("x0", self.x0), ("y0", self.y0), ("x1", self.x1), ("y1", self.y1)):
|
| 41 |
+
if isinstance(value, bool) or not isinstance(value, Real) or (not math.isfinite(value)):
|
| 42 |
+
raise ValueError(f"{name} must be a finite number, got {value!r}")
|
| 43 |
+
if self.x1 <= self.x0 or self.y1 <= self.y0:
|
| 44 |
+
raise ValueError(f"bbox must be non-degenerate, got {self!r}")
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
@dataclass(frozen=True, slots=True)
|
| 48 |
+
class ReferenceGeometry:
|
| 49 |
+
"""The reference canvas and its scale from the logical canvas."""
|
| 50 |
+
|
| 51 |
+
size: ImageSize
|
| 52 |
+
scale_h: float
|
| 53 |
+
scale_w: float
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
@dataclass(frozen=True, slots=True)
|
| 57 |
+
class MainImageGeometry:
|
| 58 |
+
"""The main image size and its actual scale from the reference canvas."""
|
| 59 |
+
|
| 60 |
+
size: ImageSize
|
| 61 |
+
scale_h: float
|
| 62 |
+
scale_w: float
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
@dataclass(frozen=True, slots=True)
|
| 66 |
+
class CropGeometry:
|
| 67 |
+
"""An integer slice rectangle on the reference image."""
|
| 68 |
+
|
| 69 |
+
top: int
|
| 70 |
+
left: int
|
| 71 |
+
size: ImageSize
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
@dataclass(frozen=True, slots=True)
|
| 75 |
+
class ItemGeometry:
|
| 76 |
+
"""One item's bbox in logical and reference coordinates."""
|
| 77 |
+
|
| 78 |
+
logical_bbox: BoundingBox
|
| 79 |
+
reference_bbox: BoundingBox
|
| 80 |
+
crop: CropGeometry
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
@dataclass(frozen=True, slots=True)
|
| 84 |
+
class GroupGeometry:
|
| 85 |
+
"""All deterministic geometry needed to prepare one group."""
|
| 86 |
+
|
| 87 |
+
logical_size: ImageSize
|
| 88 |
+
reference: ReferenceGeometry
|
| 89 |
+
main: MainImageGeometry
|
| 90 |
+
items: tuple[ItemGeometry, ...]
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def _round_to_nearest_patch(value: float, patch_size: int) -> int:
|
| 94 |
+
return patch_size * max(1, math.floor(value / patch_size + 0.5))
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def _validate_bbox_in_canvas(bbox: BoundingBox, canvas: ImageSize) -> None:
|
| 98 |
+
if bbox.x0 < 0 or bbox.y0 < 0 or bbox.x1 > canvas.width or (bbox.y1 > canvas.height):
|
| 99 |
+
raise ValueError(f"bbox {bbox!r} lies outside logical canvas {canvas!r}")
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def _as_exact_coordinate(coordinate: Real) -> Fraction:
|
| 103 |
+
if isinstance(coordinate, Fraction):
|
| 104 |
+
return coordinate
|
| 105 |
+
if isinstance(coordinate, int):
|
| 106 |
+
return Fraction(coordinate)
|
| 107 |
+
return Fraction.from_float(float(coordinate))
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def _scale_coordinate_exact(
|
| 111 |
+
coordinate: float, logical_extent: int, reference_extent: int
|
| 112 |
+
) -> Fraction:
|
| 113 |
+
return _as_exact_coordinate(coordinate) * reference_extent / logical_extent
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def _aligned_axis_crop(
|
| 117 |
+
bbox_start: Fraction, bbox_end: Fraction, canvas_extent: int, patch_size: int
|
| 118 |
+
) -> tuple[int, int]:
|
| 119 |
+
pixel_start = math.floor(bbox_start)
|
| 120 |
+
pixel_end = math.ceil(bbox_end)
|
| 121 |
+
required_extent = pixel_end - pixel_start
|
| 122 |
+
crop_extent = (required_extent + patch_size - 1) // patch_size * patch_size
|
| 123 |
+
feasible_start = max(0, pixel_end - crop_extent)
|
| 124 |
+
feasible_end = min(pixel_start, canvas_extent - crop_extent)
|
| 125 |
+
if feasible_start > feasible_end:
|
| 126 |
+
raise RuntimeError("no in-canvas crop can contain a valid bbox")
|
| 127 |
+
centered_start = math.floor((bbox_start + bbox_end - crop_extent) / 2 + Fraction(1, 2))
|
| 128 |
+
crop_start = min(max(centered_start, feasible_start), feasible_end)
|
| 129 |
+
return (crop_start, crop_extent)
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def plan_group_geometry(
|
| 133 |
+
logical_size: ImageSize,
|
| 134 |
+
item_bboxes: Iterable[BoundingBox] = (),
|
| 135 |
+
*,
|
| 136 |
+
patch_size: int = 16,
|
| 137 |
+
main_target_area: float = 1048576,
|
| 138 |
+
clamp_main_to_reference: bool = True,
|
| 139 |
+
) -> GroupGeometry:
|
| 140 |
+
"""Plan the reference canvas and main image for one group.
|
| 141 |
+
|
| 142 |
+
``clamp_main_to_reference`` caps the main scale at 1.0 so the main is never
|
| 143 |
+
upsampled past the reference canvas, which keeps the RoPE main-grid step at
|
| 144 |
+
or above the item-grid step of 1.
|
| 145 |
+
"""
|
| 146 |
+
if isinstance(patch_size, bool) or not isinstance(patch_size, int) or patch_size <= 0:
|
| 147 |
+
raise ValueError(f"patch_size must be a positive integer, got {patch_size!r}")
|
| 148 |
+
if (
|
| 149 |
+
isinstance(main_target_area, bool)
|
| 150 |
+
or not isinstance(main_target_area, (int, float))
|
| 151 |
+
or (not math.isfinite(main_target_area))
|
| 152 |
+
or (main_target_area <= 0)
|
| 153 |
+
):
|
| 154 |
+
raise ValueError(
|
| 155 |
+
f"main_target_area must be a positive finite number, got {main_target_area!r}"
|
| 156 |
+
)
|
| 157 |
+
if not isinstance(clamp_main_to_reference, bool):
|
| 158 |
+
raise TypeError("clamp_main_to_reference must be a bool")
|
| 159 |
+
reference_size = ImageSize(
|
| 160 |
+
height=_round_to_nearest_patch(logical_size.height, patch_size),
|
| 161 |
+
width=_round_to_nearest_patch(logical_size.width, patch_size),
|
| 162 |
+
)
|
| 163 |
+
main_scale = math.sqrt(main_target_area / (reference_size.height * reference_size.width))
|
| 164 |
+
if clamp_main_to_reference:
|
| 165 |
+
main_scale = min(main_scale, 1.0)
|
| 166 |
+
main_size = ImageSize(
|
| 167 |
+
height=_round_to_nearest_patch(reference_size.height * main_scale, patch_size),
|
| 168 |
+
width=_round_to_nearest_patch(reference_size.width * main_scale, patch_size),
|
| 169 |
+
)
|
| 170 |
+
items_list = []
|
| 171 |
+
for bbox in item_bboxes:
|
| 172 |
+
_validate_bbox_in_canvas(bbox, logical_size)
|
| 173 |
+
reference_x0 = _scale_coordinate_exact(bbox.x0, logical_size.width, reference_size.width)
|
| 174 |
+
reference_y0 = _scale_coordinate_exact(bbox.y0, logical_size.height, reference_size.height)
|
| 175 |
+
reference_x1 = _scale_coordinate_exact(bbox.x1, logical_size.width, reference_size.width)
|
| 176 |
+
reference_y1 = _scale_coordinate_exact(bbox.y1, logical_size.height, reference_size.height)
|
| 177 |
+
reference_bbox = BoundingBox(
|
| 178 |
+
x0=reference_x0, y0=reference_y0, x1=reference_x1, y1=reference_y1
|
| 179 |
+
)
|
| 180 |
+
(crop_left, crop_width) = _aligned_axis_crop(
|
| 181 |
+
reference_x0, reference_x1, reference_size.width, patch_size
|
| 182 |
+
)
|
| 183 |
+
(crop_top, crop_height) = _aligned_axis_crop(
|
| 184 |
+
reference_y0, reference_y1, reference_size.height, patch_size
|
| 185 |
+
)
|
| 186 |
+
items_list.append(
|
| 187 |
+
ItemGeometry(
|
| 188 |
+
logical_bbox=bbox,
|
| 189 |
+
reference_bbox=reference_bbox,
|
| 190 |
+
crop=CropGeometry(
|
| 191 |
+
top=crop_top,
|
| 192 |
+
left=crop_left,
|
| 193 |
+
size=ImageSize(height=crop_height, width=crop_width),
|
| 194 |
+
),
|
| 195 |
+
)
|
| 196 |
+
)
|
| 197 |
+
items = tuple(items_list)
|
| 198 |
+
return GroupGeometry(
|
| 199 |
+
logical_size=logical_size,
|
| 200 |
+
reference=ReferenceGeometry(
|
| 201 |
+
size=reference_size,
|
| 202 |
+
scale_h=reference_size.height / logical_size.height,
|
| 203 |
+
scale_w=reference_size.width / logical_size.width,
|
| 204 |
+
),
|
| 205 |
+
main=MainImageGeometry(
|
| 206 |
+
size=main_size,
|
| 207 |
+
scale_h=main_size.height / reference_size.height,
|
| 208 |
+
scale_w=main_size.width / reference_size.width,
|
| 209 |
+
),
|
| 210 |
+
items=items,
|
| 211 |
+
)
|
vendor/pixtext/preprocessing/glyphs.py
ADDED
|
@@ -0,0 +1,549 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Canonical per-item glyph rendering and font coverage validation.
|
| 2 |
+
|
| 3 |
+
The renderer owns the inference glyph seam. It deliberately owns shaping identity, font
|
| 4 |
+
measurement, bbox-local placement, rasterization, and failure behaviour.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import ctypes
|
| 10 |
+
import ctypes.util
|
| 11 |
+
import hashlib
|
| 12 |
+
import json
|
| 13 |
+
import math
|
| 14 |
+
import unicodedata
|
| 15 |
+
from dataclasses import asdict, dataclass
|
| 16 |
+
from functools import lru_cache
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
|
| 19 |
+
import numpy as np
|
| 20 |
+
import torch
|
| 21 |
+
from PIL import Image, ImageDraw, ImageFont, features
|
| 22 |
+
|
| 23 |
+
from pixtext.preprocessing.geometry import BoundingBox, CropGeometry, ItemGeometry
|
| 24 |
+
|
| 25 |
+
CANONICAL_GLYPH_POLICY = "whole_item_glyph"
|
| 26 |
+
CANONICAL_FONT_RESOLUTION_POLICY = "first_font_covering_entire_item"
|
| 27 |
+
CANONICAL_FONT_PATH = (
|
| 28 |
+
Path(__file__).resolve().parents[2] / "assets/fonts/canonical/AlibabaPuHuiTi-3-55-Regular.ttf"
|
| 29 |
+
)
|
| 30 |
+
CANONICAL_FONT_SHA256 = "be33bc8d45ced30fa5c542f6f056565baf85003d4e079522110d76016f8a04e2"
|
| 31 |
+
CANONICAL_FALLBACK_FONT_PATH = (
|
| 32 |
+
Path(__file__).resolve().parents[2] / "assets/fonts/canonical/GoNotoCJKCore-v7.0.ttf"
|
| 33 |
+
)
|
| 34 |
+
CANONICAL_FALLBACK_FONT_SHA256 = "bfe18b47612e58ff28b568f32b55e35fde4505d6137581bf11d2363e257e2ad4"
|
| 35 |
+
CANONICAL_LANGUAGE = "zh-CN"
|
| 36 |
+
CANONICAL_FEATURES = ("locl",)
|
| 37 |
+
CANONICAL_OCCUPANCY = 0.85
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _sha256_file(path: Path) -> str:
|
| 41 |
+
digest = hashlib.sha256()
|
| 42 |
+
with path.open("rb") as handle:
|
| 43 |
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
| 44 |
+
digest.update(chunk)
|
| 45 |
+
return digest.hexdigest()
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@dataclass(frozen=True, slots=True)
|
| 49 |
+
class FontCoverageResult:
|
| 50 |
+
"""Observable shaping/coverage verdict for one unchanged Unicode string."""
|
| 51 |
+
|
| 52 |
+
text: str
|
| 53 |
+
missing_codepoints: tuple[int, ...]
|
| 54 |
+
notdef_glyph_count: int
|
| 55 |
+
expected_visible_ink: bool
|
| 56 |
+
rendered_ink_nonempty: bool
|
| 57 |
+
|
| 58 |
+
@property
|
| 59 |
+
def covered(self) -> bool:
|
| 60 |
+
return (
|
| 61 |
+
not self.missing_codepoints
|
| 62 |
+
and self.notdef_glyph_count == 0
|
| 63 |
+
and (not self.expected_visible_ink or self.rendered_ink_nonempty)
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
@dataclass(frozen=True, slots=True)
|
| 68 |
+
class GlyphFontIdentity:
|
| 69 |
+
index: int
|
| 70 |
+
role: str
|
| 71 |
+
path: str
|
| 72 |
+
sha256: str
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
@dataclass(frozen=True, slots=True)
|
| 76 |
+
class GlyphRendererIdentity:
|
| 77 |
+
policy: str
|
| 78 |
+
font_resolution_policy: str
|
| 79 |
+
font_chain: tuple[GlyphFontIdentity, ...]
|
| 80 |
+
occupancy: float
|
| 81 |
+
rasterization: str
|
| 82 |
+
layout_engine: str
|
| 83 |
+
language: str
|
| 84 |
+
opentype_features: tuple[str, ...]
|
| 85 |
+
pillow_version: str
|
| 86 |
+
freetype_version: str
|
| 87 |
+
raqm_version: str
|
| 88 |
+
polarity: str
|
| 89 |
+
orientation: str
|
| 90 |
+
|
| 91 |
+
@property
|
| 92 |
+
def digest(self) -> str:
|
| 93 |
+
payload = json.dumps(
|
| 94 |
+
asdict(self), ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
| 95 |
+
).encode("utf-8")
|
| 96 |
+
return hashlib.sha256(payload).hexdigest()
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
@dataclass(frozen=True, slots=True)
|
| 100 |
+
class GlyphCondition:
|
| 101 |
+
"""The exact coverage tensor consumed by the model plus its provenance."""
|
| 102 |
+
|
| 103 |
+
image: torch.Tensor
|
| 104 |
+
text: str
|
| 105 |
+
pre_alignment_bbox: BoundingBox
|
| 106 |
+
aligned_crop: CropGeometry
|
| 107 |
+
renderer_identity: str
|
| 108 |
+
selected_font_index: int
|
| 109 |
+
|
| 110 |
+
def __post_init__(self) -> None:
|
| 111 |
+
if not isinstance(self.image, torch.Tensor):
|
| 112 |
+
raise TypeError("glyph image must be a torch.Tensor")
|
| 113 |
+
expected = (1, self.aligned_crop.size.height, self.aligned_crop.size.width)
|
| 114 |
+
if self.image.shape != expected:
|
| 115 |
+
raise ValueError(
|
| 116 |
+
f"glyph image shape {tuple(self.image.shape)} does not match {expected}"
|
| 117 |
+
)
|
| 118 |
+
if not self.image.is_floating_point():
|
| 119 |
+
raise ValueError("glyph image must use a floating-point dtype")
|
| 120 |
+
torch._assert_async(
|
| 121 |
+
torch.isfinite(self.image).all() & self.image.ge(0).all() & self.image.le(1).all(),
|
| 122 |
+
"glyph coverage must be finite and lie in [0, 1]",
|
| 123 |
+
)
|
| 124 |
+
if not isinstance(self.text, str):
|
| 125 |
+
raise ValueError("glyph text must be a string")
|
| 126 |
+
if not self.text:
|
| 127 |
+
if not self.renderer_identity.endswith(":blank-v1"):
|
| 128 |
+
raise ValueError("empty glyph text requires explicit blank conditioning")
|
| 129 |
+
torch._assert_async(self.image.eq(0).all(), "blank glyph must have zero ink")
|
| 130 |
+
if not isinstance(self.renderer_identity, str) or not self.renderer_identity:
|
| 131 |
+
raise ValueError("renderer_identity must be non-empty")
|
| 132 |
+
if (
|
| 133 |
+
isinstance(self.selected_font_index, bool)
|
| 134 |
+
or not isinstance(self.selected_font_index, int)
|
| 135 |
+
or self.selected_font_index < 0
|
| 136 |
+
):
|
| 137 |
+
raise ValueError("selected_font_index must be a non-negative integer")
|
| 138 |
+
|
| 139 |
+
def pin_memory(self) -> GlyphCondition:
|
| 140 |
+
if self.image.device.type != "cpu":
|
| 141 |
+
raise ValueError("only CPU glyph conditions can be pinned")
|
| 142 |
+
return GlyphCondition(
|
| 143 |
+
image=self.image.pin_memory(),
|
| 144 |
+
text=self.text,
|
| 145 |
+
pre_alignment_bbox=self.pre_alignment_bbox,
|
| 146 |
+
aligned_crop=self.aligned_crop,
|
| 147 |
+
renderer_identity=self.renderer_identity,
|
| 148 |
+
selected_font_index=self.selected_font_index,
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
class _HarfBuzzGlyphInfo(ctypes.Structure):
|
| 153 |
+
_fields_ = (
|
| 154 |
+
("codepoint", ctypes.c_uint32),
|
| 155 |
+
("mask", ctypes.c_uint32),
|
| 156 |
+
("cluster", ctypes.c_uint32),
|
| 157 |
+
("var1", ctypes.c_uint32),
|
| 158 |
+
("var2", ctypes.c_uint32),
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
class FontCoverageChecker:
|
| 163 |
+
"""Check cmap and shaped glyph IDs through the renderer's pinned font."""
|
| 164 |
+
|
| 165 |
+
def __init__(self, font_path: Path = CANONICAL_FONT_PATH) -> None:
|
| 166 |
+
self.font_path = Path(font_path)
|
| 167 |
+
library_path = ctypes.util.find_library("harfbuzz")
|
| 168 |
+
if library_path is None:
|
| 169 |
+
raise RuntimeError("HarfBuzz is required for canonical glyph coverage")
|
| 170 |
+
self._hb = ctypes.CDLL(library_path)
|
| 171 |
+
self._bind_harfbuzz()
|
| 172 |
+
self._font_bytes = self.font_path.read_bytes()
|
| 173 |
+
self._font_buffer = ctypes.create_string_buffer(self._font_bytes)
|
| 174 |
+
self._blob = self._hb.hb_blob_create(
|
| 175 |
+
self._font_buffer, len(self._font_bytes), 2, None, None
|
| 176 |
+
)
|
| 177 |
+
if not self._blob:
|
| 178 |
+
raise RuntimeError(f"HarfBuzz could not read font: {self.font_path}")
|
| 179 |
+
self._face = self._hb.hb_face_create(self._blob, 0)
|
| 180 |
+
self._font = self._hb.hb_font_create(self._face)
|
| 181 |
+
if not self._face or not self._font:
|
| 182 |
+
self.close()
|
| 183 |
+
raise RuntimeError("HarfBuzz could not create the canonical font")
|
| 184 |
+
self._hb.hb_ot_font_set_funcs(self._font)
|
| 185 |
+
|
| 186 |
+
def _bind_harfbuzz(self) -> None:
|
| 187 |
+
hb = self._hb
|
| 188 |
+
hb.hb_blob_create.argtypes = [
|
| 189 |
+
ctypes.c_void_p,
|
| 190 |
+
ctypes.c_uint,
|
| 191 |
+
ctypes.c_int,
|
| 192 |
+
ctypes.c_void_p,
|
| 193 |
+
ctypes.c_void_p,
|
| 194 |
+
]
|
| 195 |
+
hb.hb_blob_create.restype = ctypes.c_void_p
|
| 196 |
+
hb.hb_blob_destroy.argtypes = [ctypes.c_void_p]
|
| 197 |
+
hb.hb_face_create.argtypes = [ctypes.c_void_p, ctypes.c_uint]
|
| 198 |
+
hb.hb_face_create.restype = ctypes.c_void_p
|
| 199 |
+
hb.hb_face_destroy.argtypes = [ctypes.c_void_p]
|
| 200 |
+
hb.hb_font_create.argtypes = [ctypes.c_void_p]
|
| 201 |
+
hb.hb_font_create.restype = ctypes.c_void_p
|
| 202 |
+
hb.hb_font_destroy.argtypes = [ctypes.c_void_p]
|
| 203 |
+
hb.hb_ot_font_set_funcs.argtypes = [ctypes.c_void_p]
|
| 204 |
+
hb.hb_font_get_nominal_glyph.argtypes = [
|
| 205 |
+
ctypes.c_void_p,
|
| 206 |
+
ctypes.c_uint32,
|
| 207 |
+
ctypes.POINTER(ctypes.c_uint32),
|
| 208 |
+
]
|
| 209 |
+
hb.hb_font_get_nominal_glyph.restype = ctypes.c_int
|
| 210 |
+
hb.hb_buffer_create.restype = ctypes.c_void_p
|
| 211 |
+
hb.hb_buffer_destroy.argtypes = [ctypes.c_void_p]
|
| 212 |
+
hb.hb_buffer_add_utf8.argtypes = [
|
| 213 |
+
ctypes.c_void_p,
|
| 214 |
+
ctypes.c_char_p,
|
| 215 |
+
ctypes.c_int,
|
| 216 |
+
ctypes.c_uint,
|
| 217 |
+
ctypes.c_int,
|
| 218 |
+
]
|
| 219 |
+
hb.hb_buffer_guess_segment_properties.argtypes = [ctypes.c_void_p]
|
| 220 |
+
hb.hb_language_from_string.argtypes = [ctypes.c_char_p, ctypes.c_int]
|
| 221 |
+
hb.hb_language_from_string.restype = ctypes.c_void_p
|
| 222 |
+
hb.hb_buffer_set_language.argtypes = [ctypes.c_void_p, ctypes.c_void_p]
|
| 223 |
+
hb.hb_feature_from_string.argtypes = [ctypes.c_char_p, ctypes.c_int, ctypes.c_void_p]
|
| 224 |
+
hb.hb_feature_from_string.restype = ctypes.c_int
|
| 225 |
+
hb.hb_shape.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_uint]
|
| 226 |
+
hb.hb_buffer_get_glyph_infos.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_uint)]
|
| 227 |
+
hb.hb_buffer_get_glyph_infos.restype = ctypes.POINTER(_HarfBuzzGlyphInfo)
|
| 228 |
+
|
| 229 |
+
@staticmethod
|
| 230 |
+
def _requires_nominal_glyph(character: str) -> bool:
|
| 231 |
+
codepoint = ord(character)
|
| 232 |
+
category = unicodedata.category(character)
|
| 233 |
+
return not (
|
| 234 |
+
unicodedata.combining(character)
|
| 235 |
+
or category in {"Cf", "Cc", "Cs", "Zl", "Zp"}
|
| 236 |
+
or 65024 <= codepoint <= 65039
|
| 237 |
+
or (917760 <= codepoint <= 917999)
|
| 238 |
+
or (codepoint == 8205)
|
| 239 |
+
)
|
| 240 |
+
|
| 241 |
+
@staticmethod
|
| 242 |
+
def _expects_visible_ink(text: str) -> bool:
|
| 243 |
+
return any(
|
| 244 |
+
not character.isspace() and unicodedata.category(character)[0] not in {"C", "Z"}
|
| 245 |
+
for character in text
|
| 246 |
+
)
|
| 247 |
+
|
| 248 |
+
@lru_cache(maxsize=65536)
|
| 249 |
+
def check(self, text: str) -> FontCoverageResult:
|
| 250 |
+
if not isinstance(text, str) or not text:
|
| 251 |
+
raise ValueError("coverage text must be a non-empty string")
|
| 252 |
+
missing: list[int] = []
|
| 253 |
+
for character in text:
|
| 254 |
+
if not self._requires_nominal_glyph(character):
|
| 255 |
+
continue
|
| 256 |
+
glyph = ctypes.c_uint32()
|
| 257 |
+
if (
|
| 258 |
+
not self._hb.hb_font_get_nominal_glyph(
|
| 259 |
+
self._font, ord(character), ctypes.byref(glyph)
|
| 260 |
+
)
|
| 261 |
+
or glyph.value == 0
|
| 262 |
+
):
|
| 263 |
+
missing.append(ord(character))
|
| 264 |
+
encoded = text.encode("utf-8")
|
| 265 |
+
buffer = self._hb.hb_buffer_create()
|
| 266 |
+
if not buffer:
|
| 267 |
+
raise RuntimeError("HarfBuzz could not allocate a shaping buffer")
|
| 268 |
+
try:
|
| 269 |
+
self._hb.hb_buffer_add_utf8(buffer, encoded, len(encoded), 0, len(encoded))
|
| 270 |
+
self._hb.hb_buffer_guess_segment_properties(buffer)
|
| 271 |
+
language = self._hb.hb_language_from_string(CANONICAL_LANGUAGE.encode(), -1)
|
| 272 |
+
self._hb.hb_buffer_set_language(buffer, language)
|
| 273 |
+
feature = (ctypes.c_uint32 * 4)()
|
| 274 |
+
if not self._hb.hb_feature_from_string(b"locl=1", -1, ctypes.byref(feature)):
|
| 275 |
+
raise RuntimeError("HarfBuzz rejected the pinned locl feature")
|
| 276 |
+
self._hb.hb_shape(self._font, buffer, ctypes.byref(feature), 1)
|
| 277 |
+
length = ctypes.c_uint()
|
| 278 |
+
infos = self._hb.hb_buffer_get_glyph_infos(buffer, ctypes.byref(length))
|
| 279 |
+
notdef_count = sum(infos[index].codepoint == 0 for index in range(length.value))
|
| 280 |
+
finally:
|
| 281 |
+
self._hb.hb_buffer_destroy(buffer)
|
| 282 |
+
expected_ink = self._expects_visible_ink(text)
|
| 283 |
+
rendered_ink = True
|
| 284 |
+
if expected_ink:
|
| 285 |
+
font = ImageFont.truetype(
|
| 286 |
+
str(self.font_path), size=128, layout_engine=ImageFont.Layout.RAQM
|
| 287 |
+
)
|
| 288 |
+
mask = font.getmask(
|
| 289 |
+
text, mode="L", language=CANONICAL_LANGUAGE, features=list(CANONICAL_FEATURES)
|
| 290 |
+
)
|
| 291 |
+
rendered_ink = mask.getbbox() is not None
|
| 292 |
+
return FontCoverageResult(
|
| 293 |
+
text=text,
|
| 294 |
+
missing_codepoints=tuple(sorted(set(missing))),
|
| 295 |
+
notdef_glyph_count=notdef_count,
|
| 296 |
+
expected_visible_ink=expected_ink,
|
| 297 |
+
rendered_ink_nonempty=rendered_ink,
|
| 298 |
+
)
|
| 299 |
+
|
| 300 |
+
def close(self) -> None:
|
| 301 |
+
font = getattr(self, "_font", None)
|
| 302 |
+
face = getattr(self, "_face", None)
|
| 303 |
+
blob = getattr(self, "_blob", None)
|
| 304 |
+
if font:
|
| 305 |
+
self._hb.hb_font_destroy(font)
|
| 306 |
+
self._font = None
|
| 307 |
+
if face:
|
| 308 |
+
self._hb.hb_face_destroy(face)
|
| 309 |
+
self._face = None
|
| 310 |
+
if blob:
|
| 311 |
+
self._hb.hb_blob_destroy(blob)
|
| 312 |
+
self._blob = None
|
| 313 |
+
|
| 314 |
+
def __del__(self) -> None:
|
| 315 |
+
try:
|
| 316 |
+
self.close()
|
| 317 |
+
except Exception:
|
| 318 |
+
pass
|
| 319 |
+
|
| 320 |
+
|
| 321 |
+
@dataclass(frozen=True, slots=True)
|
| 322 |
+
class FontResolution:
|
| 323 |
+
"""Whole-item result from an ordered, pinned font chain."""
|
| 324 |
+
|
| 325 |
+
text: str
|
| 326 |
+
selected_font_index: int | None
|
| 327 |
+
attempts: tuple[FontCoverageResult, ...]
|
| 328 |
+
|
| 329 |
+
@property
|
| 330 |
+
def covered(self) -> bool:
|
| 331 |
+
return self.selected_font_index is not None
|
| 332 |
+
|
| 333 |
+
@property
|
| 334 |
+
def selected_coverage(self) -> FontCoverageResult | None:
|
| 335 |
+
if self.selected_font_index is None:
|
| 336 |
+
return None
|
| 337 |
+
return self.attempts[self.selected_font_index]
|
| 338 |
+
|
| 339 |
+
|
| 340 |
+
class PinnedFontResolver:
|
| 341 |
+
"""Select the first font covering the entire unchanged item string."""
|
| 342 |
+
|
| 343 |
+
def __init__(self, font_paths: tuple[Path, ...]) -> None:
|
| 344 |
+
if not font_paths:
|
| 345 |
+
raise ValueError("canonical glyph font chain must not be empty")
|
| 346 |
+
self.checkers = tuple(FontCoverageChecker(path) for path in font_paths)
|
| 347 |
+
|
| 348 |
+
@lru_cache(maxsize=65536)
|
| 349 |
+
def resolve(self, text: str) -> FontResolution:
|
| 350 |
+
attempts: list[FontCoverageResult] = []
|
| 351 |
+
for index, checker in enumerate(self.checkers):
|
| 352 |
+
result = checker.check(text)
|
| 353 |
+
attempts.append(result)
|
| 354 |
+
if result.covered:
|
| 355 |
+
return FontResolution(
|
| 356 |
+
text=text, selected_font_index=index, attempts=tuple(attempts)
|
| 357 |
+
)
|
| 358 |
+
return FontResolution(text=text, selected_font_index=None, attempts=tuple(attempts))
|
| 359 |
+
|
| 360 |
+
def check(self, text: str) -> FontResolution:
|
| 361 |
+
"""Compatibility alias for callers interested only in chain coverage."""
|
| 362 |
+
return self.resolve(text)
|
| 363 |
+
|
| 364 |
+
def close(self) -> None:
|
| 365 |
+
for checker in self.checkers:
|
| 366 |
+
checker.close()
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
class CanonicalItemGlyphRenderer:
|
| 370 |
+
"""Render upright, bbox-centered 1x grayscale canonical glyphs."""
|
| 371 |
+
|
| 372 |
+
def __init__(
|
| 373 |
+
self,
|
| 374 |
+
*,
|
| 375 |
+
font_path: Path = CANONICAL_FONT_PATH,
|
| 376 |
+
expected_font_sha256: str = CANONICAL_FONT_SHA256,
|
| 377 |
+
fallback_font_path: Path = CANONICAL_FALLBACK_FONT_PATH,
|
| 378 |
+
expected_fallback_font_sha256: str = CANONICAL_FALLBACK_FONT_SHA256,
|
| 379 |
+
font_resolution_policy: str = CANONICAL_FONT_RESOLUTION_POLICY,
|
| 380 |
+
occupancy: float = CANONICAL_OCCUPANCY,
|
| 381 |
+
) -> None:
|
| 382 |
+
self.font_paths = (Path(font_path), Path(fallback_font_path))
|
| 383 |
+
expected_sha256s = (expected_font_sha256, expected_fallback_font_sha256)
|
| 384 |
+
actual_sha256s: list[str] = []
|
| 385 |
+
for index, (path, expected_sha256) in enumerate(
|
| 386 |
+
zip(self.font_paths, expected_sha256s, strict=True)
|
| 387 |
+
):
|
| 388 |
+
if not path.is_file():
|
| 389 |
+
raise ValueError(f"canonical font {index} is not readable: {path}")
|
| 390 |
+
actual_sha256 = _sha256_file(path)
|
| 391 |
+
if actual_sha256 != expected_sha256:
|
| 392 |
+
raise ValueError(
|
| 393 |
+
f"canonical font {index} SHA-256 mismatch: got {actual_sha256}, expected {expected_sha256}"
|
| 394 |
+
)
|
| 395 |
+
actual_sha256s.append(actual_sha256)
|
| 396 |
+
if font_resolution_policy != CANONICAL_FONT_RESOLUTION_POLICY:
|
| 397 |
+
raise ValueError("unsupported canonical font resolution policy")
|
| 398 |
+
if not math.isfinite(occupancy) or not 0 < occupancy <= 1:
|
| 399 |
+
raise ValueError("glyph occupancy must lie in (0, 1]")
|
| 400 |
+
if not features.check_feature("raqm"):
|
| 401 |
+
raise RuntimeError("canonical glyph rendering requires Pillow RAQM")
|
| 402 |
+
self.occupancy = float(occupancy)
|
| 403 |
+
self.coverage = PinnedFontResolver(self.font_paths)
|
| 404 |
+
import PIL
|
| 405 |
+
|
| 406 |
+
self.identity = GlyphRendererIdentity(
|
| 407 |
+
policy=CANONICAL_GLYPH_POLICY,
|
| 408 |
+
font_resolution_policy=font_resolution_policy,
|
| 409 |
+
font_chain=tuple(
|
| 410 |
+
GlyphFontIdentity(
|
| 411 |
+
index=index, role=role, path=str(path.resolve()), sha256=actual_sha256s[index]
|
| 412 |
+
)
|
| 413 |
+
for (index, (role, path)) in enumerate(
|
| 414 |
+
zip(("primary", "fallback"), self.font_paths, strict=True)
|
| 415 |
+
)
|
| 416 |
+
),
|
| 417 |
+
occupancy=self.occupancy,
|
| 418 |
+
rasterization="direct_1x_grayscale",
|
| 419 |
+
layout_engine="RAQM",
|
| 420 |
+
language=CANONICAL_LANGUAGE,
|
| 421 |
+
opentype_features=CANONICAL_FEATURES,
|
| 422 |
+
pillow_version=PIL.__version__,
|
| 423 |
+
freetype_version=features.version_module("freetype2") or "unknown",
|
| 424 |
+
raqm_version=features.version_feature("raqm") or "unknown",
|
| 425 |
+
polarity="black_0_white_1",
|
| 426 |
+
orientation="horizontal_upright_uniform_scale",
|
| 427 |
+
)
|
| 428 |
+
|
| 429 |
+
@lru_cache(maxsize=1024)
|
| 430 |
+
def _font(self, font_index: int, size: int) -> ImageFont.FreeTypeFont:
|
| 431 |
+
return ImageFont.truetype(
|
| 432 |
+
str(self.font_paths[font_index]), size=max(1, size), layout_engine=ImageFont.Layout.RAQM
|
| 433 |
+
)
|
| 434 |
+
|
| 435 |
+
@staticmethod
|
| 436 |
+
def _ink_bbox(font: ImageFont.FreeTypeFont, text: str) -> tuple[int, int, int, int]:
|
| 437 |
+
(mask, offset) = font.getmask2(
|
| 438 |
+
text, mode="L", language=CANONICAL_LANGUAGE, features=list(CANONICAL_FEATURES)
|
| 439 |
+
)
|
| 440 |
+
mask_bbox = mask.getbbox()
|
| 441 |
+
if mask_bbox is None:
|
| 442 |
+
raise ValueError(f"canonical glyph has an empty ink bbox: {text!r}")
|
| 443 |
+
return (
|
| 444 |
+
offset[0] + mask_bbox[0],
|
| 445 |
+
offset[1] + mask_bbox[1],
|
| 446 |
+
offset[0] + mask_bbox[2],
|
| 447 |
+
offset[1] + mask_bbox[3],
|
| 448 |
+
)
|
| 449 |
+
|
| 450 |
+
def render_blank(self, item_geometry: ItemGeometry) -> GlyphCondition:
|
| 451 |
+
"""Present, zero-ink condition for erasure; distinct from absent CFG glyphs."""
|
| 452 |
+
crop = item_geometry.crop
|
| 453 |
+
return GlyphCondition(
|
| 454 |
+
image=torch.zeros((1, crop.size.height, crop.size.width)),
|
| 455 |
+
text="",
|
| 456 |
+
pre_alignment_bbox=item_geometry.reference_bbox,
|
| 457 |
+
aligned_crop=crop,
|
| 458 |
+
renderer_identity=self.identity.digest + ":blank-v1",
|
| 459 |
+
selected_font_index=0,
|
| 460 |
+
)
|
| 461 |
+
|
| 462 |
+
def render(self, text: str, item_geometry: ItemGeometry) -> GlyphCondition:
|
| 463 |
+
if not isinstance(text, str) or not text:
|
| 464 |
+
raise ValueError("canonical glyph text must be non-empty")
|
| 465 |
+
if not isinstance(item_geometry, ItemGeometry):
|
| 466 |
+
raise TypeError("item_geometry must be an ItemGeometry")
|
| 467 |
+
resolution = self.coverage.resolve(text)
|
| 468 |
+
if not resolution.covered:
|
| 469 |
+
attempts = tuple(
|
| 470 |
+
{
|
| 471 |
+
"font_index": index,
|
| 472 |
+
"missing": tuple(f"U+{value:04X}" for value in result.missing_codepoints),
|
| 473 |
+
"notdef": result.notdef_glyph_count,
|
| 474 |
+
"nonempty": result.rendered_ink_nonempty,
|
| 475 |
+
}
|
| 476 |
+
for (index, result) in enumerate(resolution.attempts)
|
| 477 |
+
)
|
| 478 |
+
raise ValueError(f"canonical font chain coverage failed for {text!r}: {attempts}")
|
| 479 |
+
font_index = resolution.selected_font_index
|
| 480 |
+
assert font_index is not None
|
| 481 |
+
coverage = resolution.selected_coverage
|
| 482 |
+
assert coverage is not None
|
| 483 |
+
crop = item_geometry.crop
|
| 484 |
+
bbox = item_geometry.reference_bbox
|
| 485 |
+
local_x0 = float(bbox.x0) - crop.left
|
| 486 |
+
local_y0 = float(bbox.y0) - crop.top
|
| 487 |
+
local_x1 = float(bbox.x1) - crop.left
|
| 488 |
+
local_y1 = float(bbox.y1) - crop.top
|
| 489 |
+
target_width = (local_x1 - local_x0) * self.occupancy
|
| 490 |
+
target_height = (local_y1 - local_y0) * self.occupancy
|
| 491 |
+
if target_width <= 0 or target_height <= 0:
|
| 492 |
+
raise ValueError("canonical glyph bbox is degenerate after localization")
|
| 493 |
+
probe_size = 128
|
| 494 |
+
probe_bbox = self._ink_bbox(self._font(font_index, probe_size), text)
|
| 495 |
+
probe_width = probe_bbox[2] - probe_bbox[0]
|
| 496 |
+
probe_height = probe_bbox[3] - probe_bbox[1]
|
| 497 |
+
fitted_size = max(
|
| 498 |
+
1,
|
| 499 |
+
math.floor(probe_size * min(target_width / probe_width, target_height / probe_height)),
|
| 500 |
+
)
|
| 501 |
+
while fitted_size > 1:
|
| 502 |
+
font = self._font(font_index, fitted_size)
|
| 503 |
+
(left, top, right, bottom) = self._ink_bbox(font, text)
|
| 504 |
+
if right - left <= target_width and bottom - top <= target_height:
|
| 505 |
+
break
|
| 506 |
+
fitted_size -= 1
|
| 507 |
+
else:
|
| 508 |
+
font = self._font(font_index, 1)
|
| 509 |
+
(left, top, right, bottom) = self._ink_bbox(font, text)
|
| 510 |
+
if right - left > target_width or bottom - top > target_height:
|
| 511 |
+
raise ValueError(
|
| 512 |
+
f"canonical glyph cannot fit the bbox occupancy at the minimum font size: text={text!r}, target={target_width}x{target_height}"
|
| 513 |
+
)
|
| 514 |
+
while True:
|
| 515 |
+
next_font = self._font(font_index, fitted_size + 1)
|
| 516 |
+
next_bbox = self._ink_bbox(next_font, text)
|
| 517 |
+
if (
|
| 518 |
+
next_bbox[2] - next_bbox[0] > target_width
|
| 519 |
+
or next_bbox[3] - next_bbox[1] > target_height
|
| 520 |
+
):
|
| 521 |
+
break
|
| 522 |
+
fitted_size += 1
|
| 523 |
+
font = next_font
|
| 524 |
+
(left, top, right, bottom) = next_bbox
|
| 525 |
+
center_x = (local_x0 + local_x1) / 2
|
| 526 |
+
center_y = (local_y0 + local_y1) / 2
|
| 527 |
+
draw_x = center_x - (left + right) / 2
|
| 528 |
+
draw_y = center_y - (top + bottom) / 2
|
| 529 |
+
canvas = Image.new("L", (crop.size.width, crop.size.height), 0)
|
| 530 |
+
ImageDraw.Draw(canvas).text(
|
| 531 |
+
(draw_x, draw_y),
|
| 532 |
+
text,
|
| 533 |
+
font=font,
|
| 534 |
+
fill=255,
|
| 535 |
+
language=CANONICAL_LANGUAGE,
|
| 536 |
+
features=list(CANONICAL_FEATURES),
|
| 537 |
+
)
|
| 538 |
+
array = np.asarray(canvas, dtype=np.float32)
|
| 539 |
+
image = torch.from_numpy(array.copy()).unsqueeze(0).div_(255.0)
|
| 540 |
+
if coverage.expected_visible_ink and (not bool(image.gt(0).any())):
|
| 541 |
+
raise ValueError(f"canonical glyph rasterized to empty ink: {text!r}")
|
| 542 |
+
return GlyphCondition(
|
| 543 |
+
image=image.contiguous(),
|
| 544 |
+
text=text,
|
| 545 |
+
pre_alignment_bbox=bbox,
|
| 546 |
+
aligned_crop=crop,
|
| 547 |
+
renderer_identity=self.identity.digest,
|
| 548 |
+
selected_font_index=font_index,
|
| 549 |
+
)
|
vendor/pixtext/preprocessing/images.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Apply deterministic group geometry plans to RGB image tensors."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from dataclasses import dataclass
|
| 6 |
+
|
| 7 |
+
import torch
|
| 8 |
+
from torchvision.transforms import InterpolationMode
|
| 9 |
+
from torchvision.transforms.functional import resize
|
| 10 |
+
|
| 11 |
+
from pixtext.preprocessing.geometry import GroupGeometry
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@dataclass(frozen=True, slots=True)
|
| 15 |
+
class PreparedGroupImages:
|
| 16 |
+
"""Reference, main, and ordered item tensors for one group."""
|
| 17 |
+
|
| 18 |
+
reference: torch.Tensor
|
| 19 |
+
main: torch.Tensor
|
| 20 |
+
items: tuple[torch.Tensor, ...]
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _resize_bicubic(image: torch.Tensor, height: int, width: int) -> torch.Tensor:
|
| 24 |
+
return resize(
|
| 25 |
+
image, [height, width], interpolation=InterpolationMode.BICUBIC, antialias=True
|
| 26 |
+
).contiguous()
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def prepare_group_images(image: torch.Tensor, geometry: GroupGeometry) -> PreparedGroupImages:
|
| 30 |
+
"""Resize and crop one RGB image according to a geometry plan."""
|
| 31 |
+
if not isinstance(image, torch.Tensor):
|
| 32 |
+
raise TypeError(f"image must be a torch.Tensor, got {type(image).__name__}")
|
| 33 |
+
if image.ndim != 3 or image.shape[0] != 3:
|
| 34 |
+
raise ValueError(
|
| 35 |
+
f"image must be one RGB tensor in (C, H, W) layout, got shape {tuple(image.shape)}"
|
| 36 |
+
)
|
| 37 |
+
actual_size = tuple(image.shape[-2:])
|
| 38 |
+
expected_size = (geometry.logical_size.height, geometry.logical_size.width)
|
| 39 |
+
if actual_size != expected_size:
|
| 40 |
+
raise ValueError(
|
| 41 |
+
f"image size does not match the geometry logical canvas: got {actual_size}, expected {expected_size}"
|
| 42 |
+
)
|
| 43 |
+
reference = _resize_bicubic(
|
| 44 |
+
image, geometry.reference.size.height, geometry.reference.size.width
|
| 45 |
+
)
|
| 46 |
+
main = _resize_bicubic(reference, geometry.main.size.height, geometry.main.size.width)
|
| 47 |
+
items = tuple(
|
| 48 |
+
reference[
|
| 49 |
+
:,
|
| 50 |
+
item.crop.top : item.crop.top + item.crop.size.height,
|
| 51 |
+
item.crop.left : item.crop.left + item.crop.size.width,
|
| 52 |
+
].contiguous()
|
| 53 |
+
for item in geometry.items
|
| 54 |
+
)
|
| 55 |
+
return PreparedGroupImages(reference=reference, main=main, items=items)
|
vendor/pixtext/preprocessing/main_condition.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import math
|
| 4 |
+
from dataclasses import dataclass
|
| 5 |
+
|
| 6 |
+
import cv2
|
| 7 |
+
import numpy as np
|
| 8 |
+
import torch
|
| 9 |
+
|
| 10 |
+
from pixtext.preprocessing.geometry import CropGeometry, GroupGeometry, ImageSize, ItemGeometry
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@dataclass(frozen=True)
|
| 14 |
+
class NativeEditConfig:
|
| 15 |
+
degradation_region: str = "strict_ocr_bbox"
|
| 16 |
+
operators: tuple[str, ...] = ("defocus", "gaussian_blur")
|
| 17 |
+
required_operators: tuple[str, ...] = ()
|
| 18 |
+
min_operators: int = 2
|
| 19 |
+
max_operators: int = 2
|
| 20 |
+
severity_min: float = 0.7
|
| 21 |
+
severity_max: float = 1.0
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class NativeEditMainConditioner:
|
| 25 |
+
def __init__(self, config: NativeEditConfig):
|
| 26 |
+
self.config = config
|
| 27 |
+
|
| 28 |
+
def __call__(self, clean_main, geometry, rng):
|
| 29 |
+
self._validate_inputs(clean_main, geometry)
|
| 30 |
+
cv2.setNumThreads(1)
|
| 31 |
+
seed = int(torch.randint(0, 2**63 - 1, (), dtype=torch.int64, generator=rng).item())
|
| 32 |
+
local_rng = torch.Generator(device="cpu").manual_seed(seed)
|
| 33 |
+
(operators, severities) = self._sample_operator_plan(local_rng)
|
| 34 |
+
operator_seeds = torch.randint(
|
| 35 |
+
0, 2**31 - 1, (len(operators),), generator=local_rng
|
| 36 |
+
).tolist()
|
| 37 |
+
original = clean_main.permute(1, 2, 0).contiguous().numpy()
|
| 38 |
+
candidate = original.copy()
|
| 39 |
+
for operator, severity, operator_seed in zip(
|
| 40 |
+
operators, severities, operator_seeds, strict=True
|
| 41 |
+
):
|
| 42 |
+
candidate = self._apply_operator(candidate, operator, severity, operator_seed)
|
| 43 |
+
footprint = self._replacement_footprint(geometry)
|
| 44 |
+
result = np.where(footprint[:, :, None], candidate, original).astype(np.uint8, copy=False)
|
| 45 |
+
return torch.from_numpy(np.ascontiguousarray(result)).permute(2, 0, 1).contiguous()
|
| 46 |
+
|
| 47 |
+
@staticmethod
|
| 48 |
+
def _validate_inputs(clean_main: torch.Tensor, geometry: GroupGeometry) -> None:
|
| 49 |
+
if not isinstance(clean_main, torch.Tensor):
|
| 50 |
+
raise TypeError("clean_main must be a torch.Tensor")
|
| 51 |
+
if not isinstance(geometry, GroupGeometry):
|
| 52 |
+
raise TypeError("geometry must be a GroupGeometry")
|
| 53 |
+
expected_shape = (3, geometry.main.size.height, geometry.main.size.width)
|
| 54 |
+
if clean_main.shape != expected_shape or clean_main.dtype is not torch.uint8:
|
| 55 |
+
raise ValueError(
|
| 56 |
+
f"clean_main must be uint8 RGB matching geometry.main.size, got {tuple(clean_main.shape)} and {clean_main.dtype}"
|
| 57 |
+
)
|
| 58 |
+
if clean_main.device.type != "cpu":
|
| 59 |
+
raise ValueError("native edit CPU conditioning requires a CPU tensor")
|
| 60 |
+
if len(geometry.items) == 0:
|
| 61 |
+
raise ValueError("native edit conditioning requires at least one item")
|
| 62 |
+
|
| 63 |
+
def _sample_operator_plan(
|
| 64 |
+
self, rng: torch.Generator
|
| 65 |
+
) -> tuple[tuple[str, ...], tuple[float, ...]]:
|
| 66 |
+
required = self.config.required_operators
|
| 67 |
+
if required:
|
| 68 |
+
minimum_count = max(self.config.min_operators, len(required))
|
| 69 |
+
operator_count = int(
|
| 70 |
+
torch.randint(
|
| 71 |
+
minimum_count, self.config.max_operators + 1, (), generator=rng
|
| 72 |
+
).item()
|
| 73 |
+
)
|
| 74 |
+
optional = tuple(
|
| 75 |
+
operator for operator in self.config.operators if operator not in required
|
| 76 |
+
)
|
| 77 |
+
optional_count = operator_count - len(required)
|
| 78 |
+
optional_permutation = torch.randperm(len(optional), generator=rng)[
|
| 79 |
+
:optional_count
|
| 80 |
+
].tolist()
|
| 81 |
+
selected = required + tuple(optional[index] for index in optional_permutation)
|
| 82 |
+
order = torch.randperm(len(selected), generator=rng).tolist()
|
| 83 |
+
operators = tuple(selected[index] for index in order)
|
| 84 |
+
else:
|
| 85 |
+
operator_count = int(
|
| 86 |
+
torch.randint(
|
| 87 |
+
self.config.min_operators, self.config.max_operators + 1, (), generator=rng
|
| 88 |
+
).item()
|
| 89 |
+
)
|
| 90 |
+
permutation = torch.randperm(len(self.config.operators), generator=rng)[
|
| 91 |
+
:operator_count
|
| 92 |
+
].tolist()
|
| 93 |
+
operators = tuple(self.config.operators[index] for index in permutation)
|
| 94 |
+
severities = tuple(
|
| 95 |
+
float(value)
|
| 96 |
+
for value in (
|
| 97 |
+
torch.rand(operator_count, generator=rng)
|
| 98 |
+
* (self.config.severity_max - self.config.severity_min)
|
| 99 |
+
+ self.config.severity_min
|
| 100 |
+
).tolist()
|
| 101 |
+
)
|
| 102 |
+
return (operators, severities)
|
| 103 |
+
|
| 104 |
+
def _footprint_crop(self, item: ItemGeometry) -> CropGeometry:
|
| 105 |
+
"""Resolve the reference-space rectangle this item degrades."""
|
| 106 |
+
if self.config.degradation_region == "aligned_item_crop":
|
| 107 |
+
return item.crop
|
| 108 |
+
bbox = item.reference_bbox
|
| 109 |
+
top = math.floor(bbox.y0)
|
| 110 |
+
left = math.floor(bbox.x0)
|
| 111 |
+
return CropGeometry(
|
| 112 |
+
top=top,
|
| 113 |
+
left=left,
|
| 114 |
+
size=ImageSize(height=math.ceil(bbox.y1) - top, width=math.ceil(bbox.x1) - left),
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
@staticmethod
|
| 118 |
+
def _projected_main_box(
|
| 119 |
+
crop: CropGeometry, geometry: GroupGeometry, main_h: int, main_w: int
|
| 120 |
+
) -> tuple[int, int, int, int]:
|
| 121 |
+
reference_h = geometry.reference.size.height
|
| 122 |
+
reference_w = geometry.reference.size.width
|
| 123 |
+
left = math.floor(crop.left * main_w / reference_w)
|
| 124 |
+
top = math.floor(crop.top * main_h / reference_h)
|
| 125 |
+
right = math.ceil((crop.left + crop.size.width) * main_w / reference_w)
|
| 126 |
+
bottom = math.ceil((crop.top + crop.size.height) * main_h / reference_h)
|
| 127 |
+
return (
|
| 128 |
+
min(max(left, 0), main_w),
|
| 129 |
+
min(max(top, 0), main_h),
|
| 130 |
+
min(max(right, 0), main_w),
|
| 131 |
+
min(max(bottom, 0), main_h),
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
def _replacement_footprint(self, geometry: GroupGeometry) -> np.ndarray:
|
| 135 |
+
main_h = geometry.main.size.height
|
| 136 |
+
main_w = geometry.main.size.width
|
| 137 |
+
mask = np.zeros((main_h, main_w), dtype=np.bool_)
|
| 138 |
+
for item in geometry.items:
|
| 139 |
+
(left, top, right, bottom) = self._projected_main_box(
|
| 140 |
+
self._footprint_crop(item), geometry, main_h, main_w
|
| 141 |
+
)
|
| 142 |
+
mask[top:bottom, left:right] = True
|
| 143 |
+
return mask
|
| 144 |
+
|
| 145 |
+
@staticmethod
|
| 146 |
+
def _apply_operator(
|
| 147 |
+
image: np.ndarray, operator: str, severity: float, seed: int, scale: float | None = None
|
| 148 |
+
) -> np.ndarray:
|
| 149 |
+
if operator == "solid_black":
|
| 150 |
+
return np.zeros_like(image)
|
| 151 |
+
(height, width) = image.shape[:2]
|
| 152 |
+
if scale is None:
|
| 153 |
+
scale = min(height, width)
|
| 154 |
+
if operator == "gaussian_blur":
|
| 155 |
+
sigma = scale * (0.005 + 0.025 * severity)
|
| 156 |
+
radius = max(1, math.ceil(3 * sigma))
|
| 157 |
+
kernel = 2 * radius + 1
|
| 158 |
+
return cv2.GaussianBlur(
|
| 159 |
+
image,
|
| 160 |
+
(kernel, kernel),
|
| 161 |
+
sigmaX=sigma,
|
| 162 |
+
sigmaY=sigma,
|
| 163 |
+
borderType=cv2.BORDER_REFLECT_101,
|
| 164 |
+
)
|
| 165 |
+
if operator == "defocus":
|
| 166 |
+
radius = max(1, math.ceil(scale * (0.005 + 0.025 * severity)))
|
| 167 |
+
coordinates = np.arange(-radius, radius + 1, dtype=np.float32)
|
| 168 |
+
(grid_y, grid_x) = np.meshgrid(coordinates, coordinates)
|
| 169 |
+
kernel = (grid_x**2 + grid_y**2 <= radius**2).astype(np.float32)
|
| 170 |
+
kernel /= kernel.sum()
|
| 171 |
+
alias_sigma = 0.1 + 0.4 * severity
|
| 172 |
+
kernel = cv2.GaussianBlur(
|
| 173 |
+
kernel, (3, 3), sigmaX=alias_sigma, borderType=cv2.BORDER_REFLECT_101
|
| 174 |
+
)
|
| 175 |
+
kernel /= kernel.sum()
|
| 176 |
+
return cv2.filter2D(image, -1, kernel, borderType=cv2.BORDER_REFLECT_101)
|
| 177 |
+
raise ValueError(f"Unsupported blur operator: {operator}")
|