Spaces:
Build error
Build error
File size: 7,550 Bytes
3c2cd23 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 | from __future__ import annotations
from dataclasses import dataclass
import cv2
import numpy as np
from PIL import Image, ImageDraw, ImageFont
from .fonts import FACES
from .models import Box, Point, TextStyle
from .ocr import quad_box, quad_rotation
@dataclass(slots=True)
class StyleResult:
box: Box
rotation: float
style: TextStyle
def estimate_style(rgba: np.ndarray, text: str, quad: list[Point]) -> StyleResult:
x, y, width, height = quad_box(quad)
max_height, max_width = rgba.shape[:2]
left = max(0, int(x))
top = max(0, int(y))
right = min(max_width, int(np.ceil(x + width)))
bottom = min(max_height, int(np.ceil(y + height)))
crop = rgba[top:bottom, left:right, :3]
font_size = max(6.0, height * 0.92)
foreground = estimate_foreground_color(crop)
face, adjusted_size = match_font(crop, text, font_size, width)
style = TextStyle(
font_family=face.family,
font_size=adjusted_size,
font_weight=700 if face.weight >= 700 else 400,
italic=face.italic,
fill=foreground,
)
return StyleResult(
box=Box(x=x, y=y, width=max(1, width), height=max(1, height)),
rotation=max(-180, min(180, quad_rotation(quad))),
style=style,
)
def estimate_foreground_color(crop: np.ndarray) -> str:
if crop.size == 0:
return "#111111"
pixels = crop.reshape(-1, 3).astype(np.float32)
border = np.concatenate(
[crop[0, :, :3], crop[-1, :, :3], crop[:, 0, :3], crop[:, -1, :3]], axis=0
).astype(np.float32)
background = np.median(border, axis=0)
distances = np.linalg.norm(pixels - background, axis=1)
threshold = max(24.0, float(np.percentile(distances, 75)))
candidates = pixels[distances >= threshold]
if len(candidates) < 3:
candidates = pixels[np.argsort(distances)[-max(3, len(pixels) // 10) :]]
color = np.median(candidates, axis=0).clip(0, 255).astype(int)
return "#{:02x}{:02x}{:02x}".format(*color)
def match_font(crop: np.ndarray, text: str, size: float, target_width: float):
available = [face for face in FACES if face.family == "Lato" and face.path.exists()]
if not available:
raise FileNotFoundError("Bundled fonts are missing")
best_face = available[0]
best_size = max(6, int(round(size)))
best_score = float("inf")
observed = foreground_binary(crop)
likely_italic = estimate_italic(observed)
available = [face for face in available if face.italic == likely_italic]
for face in available:
candidate_size = max(6, int(round(size)))
font = ImageFont.truetype(str(face.path), candidate_size)
bounds = font.getbbox(text or " ")
rendered_width = max(1, bounds[2] - bounds[0])
scaled_size = int(np.clip(candidate_size * target_width / rendered_width, 6, 1000))
mask = render_text_mask(text, face.path, scaled_size, crop.shape[:2])
score = mask_distance(observed, mask)
score += 0.002 if face.weight >= 700 else 0
if score < best_score:
best_score = score
best_face = face
best_size = scaled_size
return best_face, float(best_size)
def foreground_binary(crop: np.ndarray) -> np.ndarray:
if crop.size == 0:
return np.zeros((1, 1), dtype=np.uint8)
gray = cv2.cvtColor(crop, cv2.COLOR_RGB2GRAY)
border = np.concatenate([gray[0], gray[-1], gray[:, 0], gray[:, -1]])
background = float(np.median(border))
delta = cv2.absdiff(gray, np.full_like(gray, int(background)))
_, mask = cv2.threshold(delta, 18, 255, cv2.THRESH_BINARY)
return mask
def render_text_mask(text: str, path, size: int, shape: tuple[int, int]) -> np.ndarray:
height, width = max(1, shape[0]), max(1, shape[1])
image = Image.new("L", (width, height), 0)
draw = ImageDraw.Draw(image)
font = ImageFont.truetype(str(path), size)
bounds = draw.textbbox((0, 0), text, font=font)
y = max(-bounds[1], (height - (bounds[3] - bounds[1])) // 2 - bounds[1])
draw.text((0, y), text, font=font, fill=255)
return np.asarray(image)
def mask_distance(observed: np.ndarray, candidate: np.ndarray) -> float:
if observed.shape != candidate.shape:
candidate = cv2.resize(candidate, (observed.shape[1], observed.shape[0]))
observed_pixels = observed > 0
candidate_pixels = candidate > 0
if not np.any(observed_pixels) or not np.any(candidate_pixels):
return 1.0
distance_to_observed = cv2.distanceTransform(
(~observed_pixels).astype(np.uint8), cv2.DIST_L2, 3
)
distance_to_candidate = cv2.distanceTransform(
(~candidate_pixels).astype(np.uint8), cv2.DIST_L2, 3
)
chamfer = (
float(distance_to_observed[candidate_pixels].mean())
+ float(distance_to_candidate[observed_pixels].mean())
) / (2 * max(1, observed.shape[0]))
density_delta = abs(
float(candidate_pixels.mean()) - float(observed_pixels.mean())
)
return chamfer + density_delta * 0.45
def estimate_italic(mask: np.ndarray) -> bool:
if mask.size == 0 or np.count_nonzero(mask) < 12:
return False
height, width = mask.shape
shears = np.linspace(-0.3, 0.3, 13)
scores: list[float] = []
for shear in shears:
transform = np.float32([[1, shear, -shear * height / 2], [0, 1, 0]])
deskewed = cv2.warpAffine(mask, transform, (width, height)) > 0
columns = deskewed.sum(axis=0).astype(np.float32)
scores.append(float(np.square(columns).sum() / max(1, columns.sum())))
return float(shears[int(np.argmax(scores))]) >= 0.12
def snap_sizes_to_distribution_peaks(sizes: list[float]) -> list[float]:
if len(sizes) < 2:
return list(sizes)
values = np.asarray(sizes, dtype=np.float64)
bandwidth = max(0.75, float(np.median(values)) * 0.06)
peaks = font_size_distribution_peaks(values, bandwidth)
if not peaks:
return list(sizes)
snap_distance = bandwidth * 2
snapped: list[float] = []
for value in values:
nearest = min(peaks, key=lambda peak: abs(peak - value))
snapped.append(nearest if abs(nearest - value) <= snap_distance else float(value))
return snapped
def font_size_distribution_peaks(values: np.ndarray, bandwidth: float) -> list[float]:
lower = float(values.min())
upper = float(values.max())
if upper - lower < 1e-6:
return [lower]
sample_count = min(
4096,
max(64, int(np.ceil((upper - lower) / (bandwidth / 12))) + 1),
)
grid = np.linspace(lower, upper, sample_count)
offsets = (grid[:, None] - values[None, :]) / bandwidth
density = np.exp(-0.5 * np.square(offsets)).sum(axis=1)
candidates: list[int] = []
if density[0] >= density[1]:
candidates.append(0)
candidates.extend(
index
for index in range(1, sample_count - 1)
if density[index] >= density[index - 1]
and density[index] >= density[index + 1]
)
if density[-1] >= density[-2]:
candidates.append(sample_count - 1)
peaks: list[float] = []
for index in sorted(candidates, key=lambda item: density[item], reverse=True):
center = grid[index]
distances = np.abs(values - center)
supported = values[distances <= bandwidth * 2]
if len(supported) < 2:
continue
peak = round(float(np.mean(supported)) * 2) / 2
if all(abs(peak - existing) > bandwidth * 2 for existing in peaks):
peaks.append(peak)
return sorted(peaks)
|