tonigi's picture
Add complete Gradio interface
3c2cd23 verified
Raw History Blame Contribute Delete
7.55 kB
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)