Source code for manuscript.utils.visualization

from typing import Tuple, Optional, Union
from pathlib import Path

import cv2
import numpy as np
from PIL import Image, ImageDraw

from ..data import Page
from .io import read_image


def _draw_quads(
    image: Union[str, Path, np.ndarray, Image.Image],
    quads: np.ndarray,
    color: Tuple[int, int, int] = (0, 255, 0),
    thickness: int = 2,
    dark_alpha: float = 0.3,
    blur_ksize: int = 5,
) -> Image.Image:
    """
    Draw quadrilateral boxes on an image with semi-transparent overlay.

    Parameters
    ----------
    image : str, Path, np.ndarray, or PIL.Image
        Input image. Can be:
        - Path to image file (str or Path)
        - RGB numpy array with shape (H, W, 3)
        - PIL Image object
    quads : np.ndarray
        Array of quad boxes with shape (N, 8) or (N, 9).
        Each row contains [x1, y1, x2, y2, x3, y3, x4, y4] or with score.
    color : tuple of int, default=(0, 255, 0)
        RGB color for drawing boxes.
    thickness : int, default=2
        Line thickness in pixels.
    dark_alpha : float, default=0.3
        Alpha value for darkening the image (0=no darkening, 1=fully dark).
    blur_ksize : int, default=5
        Kernel size for Gaussian blur (must be odd, 0=no blur).

    Returns
    -------
    PIL.Image.Image
        Image with drawn quadrilaterals.

    Examples
    --------
    >>> import numpy as np
    >>> from PIL import Image
    >>> # From numpy array
    >>> img = np.zeros((480, 640, 3), dtype=np.uint8)
    >>> quads = np.array([[100, 100, 200, 100, 200, 150, 100, 150]])
    >>> result = draw_quads(img, quads, color=(255, 0, 0))

    >>> # From file path
    >>> result = draw_quads("document.jpg", quads, color=(255, 0, 0))
    """
    img = read_image(image).copy()

    # Apply darkening if requested
    if dark_alpha > 0:
        overlay = (img * (1 - dark_alpha)).astype(np.uint8)
    else:
        overlay = img

    # Apply blur if requested
    if blur_ksize > 0:
        overlay = cv2.GaussianBlur(overlay, (blur_ksize, blur_ksize), 0)

    # Draw each quad
    for quad in quads:
        coords = quad[:8].reshape(4, 2).astype(np.int32)
        cv2.polylines(
            overlay, [coords], isClosed=True, color=color, thickness=thickness
        )

    return Image.fromarray(overlay)


[docs] def visualize_page( image: Union[str, Path, np.ndarray, Image.Image], page: Page, thickness=3, numbers_levels=(1, 2), connections_levels=(), line_color=(255, 165, 0), number_bg=(255, 255, 255), number_color=(0, 0, 0), max_size=4096, upscale=False, show_tables=True, show_cells=True, show_cell_indices=False, show_orientation=False, show_labels=False, ) -> Image.Image: """Render hierarchy depth with alternating brightness and cold-color layers. The background darkens by 30%. Level one restores original brightness, adding 15% per extra overlap. Even levels blend translucent cold colors, contrasting consecutive siblings. Odd levels from three onward lighten accumulated colors by 25% per overlap. Three-pixel dark contours share a color throughout each first-level subtree. numbers_levels and connections_levels select tree depths; empty lists disable their markers. Numbers default to levels one and two. Markers follow depth-first order among visible polygons at that depth. Overlap contributions are capped at two regions per depth. show_labels draws the original model label (or element type) on each region. """ for name, levels in (("numbers_levels", numbers_levels), ("connections_levels", connections_levels)): if not isinstance(levels, (list, tuple)) or any( isinstance(level, bool) or not isinstance(level, int) or level < 1 for level in levels): raise ValueError(f"{name} must be a list or tuple of positive integers") if isinstance(thickness, bool) or not isinstance(thickness, int) or thickness < 1: raise ValueError("thickness must be a positive integer") # Load image using universal reader if isinstance(image, (str, Path)): img = read_image(image) elif isinstance(image, Image.Image): img = np.array(image.convert("RGB")) else: img = image.copy() if max_size is not None: h, w = img.shape[:2] scale = max_size / max(h, w) if scale < 1 or upscale: img = cv2.resize(img, (max(1, round(w * scale)), max(1, round(h * scale))), interpolation=cv2.INTER_CUBIC if scale > 1 else cv2.INTER_AREA) else: scale = 1.0 else: scale = 1.0 if not isinstance(page, Page): raise TypeError("visualize_page requires the document-tree Page") from manuscript.data import Table, Cell def visible(node): return not ((isinstance(node, Table) and not show_tables) or (isinstance(node, Cell) and not show_cells)) h, w = img.shape[:2] regions = [(node, np.asarray(node.polygon, dtype=np.float32) * scale, None) for node in page.descendants if node.polygon is not None and visible(node)] levels = {} for node, points, _ in regions: levels.setdefault(node.depth, []).append((node, points)) palette = ((45, 155, 180), (145, 70, 185), (55, 170, 125), (75, 90, 195)) palette = tuple(tuple(min(255, round(channel * 1.15)) for channel in tint) for tint in palette) pixels = np.clip(img.astype(np.float32) * 0.7, 0, 255).astype(np.uint8) for depth, members in sorted(levels.items()): if depth % 2 == 0: overlay = Image.fromarray(pixels).convert("RGBA") sibling_indices = {} coverage = np.zeros((h, w), dtype=np.uint8) for node, points in members: parent_id = id(node.parent) index = sibling_indices.get(parent_id, 0) sibling_indices[parent_id] = index + 1 layer = np.zeros((h, w, 4), dtype=np.uint8) cv2.fillPoly(layer, [points.astype(np.int32)], (*palette[index % len(palette)], 75)) selected = layer[:, :, 3] != 0 layer[coverage >= 2, 3] = 0 coverage[selected] = np.minimum(coverage[selected] + 1, 2) overlay = Image.alpha_composite(overlay, Image.fromarray(layer, mode="RGBA")) pixels = np.asarray(overlay.convert("RGB")).copy() else: coverage = np.zeros((h, w), dtype=np.uint32) for _, points in members: mask = np.zeros((h, w), dtype=np.uint8) cv2.fillPoly(mask, [points.astype(np.int32)], 1) coverage += mask np.minimum(coverage, 2, out=coverage) selected = coverage != 0 # Level one restores the source; later odd levels lighten the # accumulated image, retaining all underlying color contributions. source = img if depth == 1 else pixels step = 0.15 if depth == 1 else 0.25 count = coverage.astype(np.float32) - (1 if depth == 1 else 0) factor = 1.0 + step * np.maximum(count, 0) brightened = np.clip(source.astype(np.float32) * factor[:, :, None], 0, 255).astype(np.uint8) pixels[selected] = brightened[selected] out = Image.fromarray(pixels).convert("RGB") draw = ImageDraw.Draw(out) outline_palette = ((80, 20, 35), (15, 55, 85), (55, 20, 80), (15, 70, 40), (85, 45, 10), (20, 25, 80)) outline_palette = tuple(tuple(min(255, round(channel * 1.15)) for channel in tint) for tint in outline_palette) outline_palette = tuple(outline_palette[index] for index in np.random.default_rng(0).permutation(len(outline_palette))) root_colors = {id(node): outline_palette[index % len(outline_palette)] for index, node in enumerate(page.children)} for node, points, _ in regions: root = node.ancestors[1] if node.depth > 1 else node contour = [(round(x), round(y)) for x, y in points] outline = root_colors[id(root)] if node.depth % 3: draw.line(contour + [contour[0]], fill=outline, width=thickness) else: # Carry dash phase around corners instead of restarting each edge. dash, gap = 9, 6 distance = 0.0 for start, end in zip(contour, contour[1:] + contour[:1]): start = np.asarray(start, dtype=float) delta = np.asarray(end, dtype=float) - start length = float(np.linalg.norm(delta)) position = 0.0 while position < length: phase = distance % (dash + gap) drawing = phase < dash step = min((dash if drawing else dash + gap) - phase, length - position) if drawing: first = start + delta * (position / length) last = start + delta * ((position + step) / length) draw.line([tuple(first), tuple(last)], fill=outline, width=thickness) position += step distance += step if show_cell_indices: for node, points, _ in regions: if not isinstance(node, Cell): continue label = f"r{node.row} c{node.column}" if node.row_span != 1 or node.column_span != 1: label += f" ({node.row_span}x{node.column_span})" position = (int(points[:, 0].min()) + 4, int(points[:, 1].min()) + 4) draw.rectangle(draw.textbbox(position, label), fill=tuple(number_bg)) draw.text(position, label, fill=tuple(number_color)) def centers_at(depths): return [tuple(points.mean(axis=0)) for node, points, _ in regions if node.depth in depths] if connections_levels: centers = centers_at(connections_levels) for start, end in zip(centers, centers[1:]): draw.line([start, end], fill=line_color, width=thickness) if numbers_levels: centers = centers_at(numbers_levels) overlay = Image.new("RGBA", out.size, (0, 0, 0, 0)) labels = ImageDraw.Draw(overlay) for index, (cx, cy) in enumerate(centers, 1): text = str(index) position = (cx - 6, cy - 8) box = labels.textbbox(position, text) labels.rectangle((box[0]-3,box[1]-3,box[2]+3,box[3]+3), fill=tuple(number_bg) + (140,)) labels.text(position, text, fill=tuple(number_color) + (255,)) out = Image.alpha_composite(out.convert("RGBA"), overlay).convert("RGB") if show_labels: draw = ImageDraw.Draw(out) for node, points, _ in regions: label = node.label or node.type position = (max(0, int(points[:, 0].min()) + 4), max(0, int(points[:, 1].min()) + 4)) bounds = draw.textbbox(position, label) draw.rectangle(bounds, fill=tuple(number_bg)) draw.text(position, label, fill=tuple(number_color)) if show_orientation and isinstance(page, Page): draw = ImageDraw.Draw(out) for node, _, _ in regions: if node.orientation in (None, 0): continue label = f"{node.orientation:g}°" x, y = min(node.polygon, key=lambda point: (point[1], point[0])) bounds = draw.textbbox((0, 0), label) width, height = bounds[2] - bounds[0], bounds[3] - bounds[1] padding = 3 left = max(0, min(round(x * scale), out.width - width - 2 * padding)) top = max(0, min(round(y * scale) - height - 2 * padding, out.height - height - 2 * padding)) draw.rectangle((left, top, left + width + 2 * padding, top + height + 2 * padding), fill=number_bg) draw.text((left + padding - bounds[0], top + padding - bounds[1]), label, fill=number_color) return out