import numpy as np from PIL import Image, ImageDraw, ImageFilter, ImageFont from .fonts import get_cached_font def scale_layout_for_hd(layout, work_scale): if work_scale <= 0: raise ValueError("WORK_SCALE must be greater than zero") return [ ( text, max(1, int(size / work_scale)), (int(y / work_scale), int(x / work_scale)), orient, color, ) for text, size, (y, x), orient, color in layout ] def count_layout_overlap_pixels(layout, mask_shape, font_path, alpha_threshold=0): """Count final rendered pixels occupied by more than one layout entry.""" height, width = mask_shape occupied = np.zeros((height, width), dtype=bool) overlaps = np.zeros((height, width), dtype=bool) measure = ImageDraw.Draw(Image.new("L", (1, 1))) for word, size, (y, x), orient, _color in layout: font = get_cached_font(font_path, size) if orient: font = ImageFont.TransposedFont(font, orientation=orient) bbox = measure.textbbox((0, 0), word, font=font) glyph = font.getmask(word, mode="L") glyph_width, glyph_height = glyph.size if glyph_width <= 0 or glyph_height <= 0: continue ink = np.asarray(glyph, dtype=np.uint8).reshape(glyph_height, glyph_width) > alpha_threshold ink_x = int(x) + int(bbox[0]) ink_y = int(y) + int(bbox[1]) x0 = max(0, ink_x) y0 = max(0, ink_y) x1 = min(width, ink_x + glyph_width) y1 = min(height, ink_y + glyph_height) if x0 >= x1 or y0 >= y1: continue visible_ink = ink[y0 - ink_y:y1 - ink_y, x0 - ink_x:x1 - ink_x] occupied_region = occupied[y0:y1, x0:x1] overlaps[y0:y1, x0:x1] |= occupied_region & visible_ink occupied_region |= visible_ink return int(overlaps.sum()) def refine_layout_with_hd_clearance( layout, mask, font_path, clearance=1, max_shift=24, ): """Validate the whole HD batch and minimally move rasterization collisions.""" height, width = mask.shape blocked = np.asarray(mask) != 0 occupied = np.zeros((height, width), dtype=bool) measure = ImageDraw.Draw(Image.new("L", (1, 1))) refined = [] shifted_words = 0 max_applied_shift = 0 offset_rings = [[(0, 0)]] for radius in range(1, max_shift + 1): ring = [ (dy, dx) for dy in range(-radius, radius + 1) for dx in range(-radius, radius + 1) if max(abs(dy), abs(dx)) == radius ] ring.sort(key=lambda item: (item[0] * item[0] + item[1] * item[1], item[0], item[1])) offset_rings.append(ring) for word, size, (draw_y, draw_x), orient, color in layout: font = get_cached_font(font_path, size) if orient: font = ImageFont.TransposedFont(font, orientation=orient) bbox = measure.textbbox((0, 0), word, font=font) glyph = font.getmask(word, mode="L") glyph_width, glyph_height = glyph.size if glyph_width <= 0 or glyph_height <= 0: refined.append((word, size, (draw_y, draw_x), orient, color)) continue glyph_ink = np.asarray(glyph, dtype=np.uint8).reshape(glyph_height, glyph_width) pad = max(0, int(clearance)) padded = np.zeros((glyph_height + 2 * pad, glyph_width + 2 * pad), dtype=np.uint8) padded[pad:pad + glyph_height, pad:pad + glyph_width] = glyph_ink if pad: collision = np.asarray( Image.fromarray(padded).filter(ImageFilter.MaxFilter(2 * pad + 1)), dtype=np.uint8, ) > 0 else: collision = padded > 0 stamp = padded > 0 base_y = int(draw_y) + int(bbox[1]) - pad base_x = int(draw_x) + int(bbox[0]) - pad def fits(y0, x0): y1 = y0 + collision.shape[0] x1 = x0 + collision.shape[1] if y0 < 0 or x0 < 0 or y1 > height or x1 > width: return False if np.any(blocked[y0:y1, x0:x1] & stamp): return False return not np.any(occupied[y0:y1, x0:x1] & collision) placed_offset = None for ring in offset_rings: for dy, dx in ring: y0 = base_y + dy x0 = base_x + dx if not fits(y0, x0): continue placed_offset = (dy, dx) break if placed_offset is not None: break if placed_offset is None: return None, { "shifted_words": shifted_words, "max_shift": max_applied_shift, "failed_word": word, } dy, dx = placed_offset y0 = base_y + dy x0 = base_x + dx occupied[y0:y0 + stamp.shape[0], x0:x0 + stamp.shape[1]] |= stamp if dy or dx: shifted_words += 1 max_applied_shift = max(max_applied_shift, abs(dy), abs(dx)) refined.append((word, size, (int(draw_y) + dy, int(draw_x) + dx), orient, color)) return refined, { "shifted_words": shifted_words, "max_shift": max_applied_shift, "failed_word": None, } def render_layout_occupancy(layout, mask_shape, font_path): h, w = mask_shape canvas = Image.new("L", (w, h), 0) draw = ImageDraw.Draw(canvas) for word, size, (y, x), orient, _color in layout: font = get_cached_font(font_path, size) if orient: font = ImageFont.TransposedFont(font, orientation=orient) draw.text((x, y), word, font=font, fill=255) return (np.array(canvas) > 0).astype(np.uint8) def compute_fill_ratio_fast(layout, mask, font_path): if not layout: return 0.0, None occ = render_layout_occupancy(layout, mask.shape, font_path) free_area = np.sum(mask == 0) if free_area == 0: return 0.0, occ filled_area = np.sum((mask == 0) & (occ == 1)) return filled_area / free_area, occ def largest_empty_square_size(occupancy, mask): """Return the largest fully empty square inside the fillable mask.""" blocked = (np.asarray(mask) != 0) | (np.asarray(occupancy) != 0) if blocked.ndim != 2 or blocked.size == 0: return 0 integral = np.pad(blocked.astype(np.uint32), ((1, 0), (1, 0))) integral = integral.cumsum(axis=0).cumsum(axis=1) low = 0 high = min(blocked.shape) while low < high: size = (low + high + 1) // 2 window_sums = ( integral[size:, size:] - integral[:-size, size:] - integral[size:, :-size] + integral[:-size, :-size] ) if np.any(window_sums == 0): low = size else: high = size - 1 return int(low)