pypindou.pattern.model 源代码

"""
Pattern data model and render helpers.

The :class:`Pattern` dataclass is the main structured output of
:func:`pypindou.pattern.generate_pattern`.  It stores the palette-index grid,
active bead mask, rendered RGB preview data, per-pixel error, and metadata that
describes how the pattern was generated.

Example::

    >>> import numpy as np
    >>> from pypindou.color import BeadColor, Palette
    >>> from pypindou.pattern.model import Pattern
    >>> palette = Palette("demo", "Demo", (BeadColor("001", (255, 255, 255)),))
    >>> pattern = Pattern(1, 1, palette, np.array([[0]]), np.array([[[255, 255, 255]]], dtype=np.uint8), np.ones((1, 1), dtype=bool), np.zeros((1, 1)))
    >>> pattern.bead_count
    1
"""

from __future__ import annotations

from collections import Counter
from dataclasses import dataclass, field
from html import escape
from pathlib import Path
from typing import Any, Dict, List, Literal, Mapping, Optional, Tuple, Union

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

from pypindou.color import BeadColor, Palette

SymbolFormat = Literal["png", "svg"]
SymbolLabelMode = Literal["code", "short", "symbol"]

_GRID_RGB = (80, 80, 80)
_SVG_GRID = "#505050"
_SVG_FONT_FAMILY = "DejaVu Sans Mono, Consolas, Menlo, monospace"
_LABEL_MODES = ("code", "short", "symbol")


def _cell_padding(cell_size: int) -> int:
    return min(max(1, int(round(cell_size * 0.1))), max(0, cell_size // 3))


def _cell_inner_size(cell_size: int) -> Tuple[int, int, int]:
    padding = _cell_padding(cell_size)
    inner = max(1, cell_size - 2 * padding)
    return padding, inner, inner


def _load_font(size: int) -> ImageFont.ImageFont:
    try:
        return ImageFont.load_default(size=max(1, int(size)))
    except TypeError:
        return ImageFont.load_default()


def _text_bbox(font: ImageFont.ImageFont, label: str) -> Tuple[int, int, int, int]:
    bbox = font.getbbox(label)
    return int(bbox[0]), int(bbox[1]), int(bbox[2]), int(bbox[3])


def _label_for_color(color: BeadColor, mode: SymbolLabelMode) -> str:
    if mode == "code":
        return color.code
    if mode == "short":
        return color.code[-3:]
    if mode == "symbol":
        symbol = color.metadata.get("symbol")
        return str(symbol) if symbol else color.code[-3:]
    raise ValueError(f"Unsupported label mode: {mode!r}.")


def _check_label_mode(mode: str) -> SymbolLabelMode:
    if mode not in _LABEL_MODES:
        raise ValueError(f"Unsupported label mode: {mode!r}.")
    return mode  # type: ignore[return-value]


def _text_rgb_for_background(rgb: Tuple[int, int, int]) -> Tuple[int, int, int]:
    luminance = 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]
    return (0, 0, 0) if luminance > 150 else (255, 255, 255)


def _render_fitted_text(label: str, color: Tuple[int, int, int], inner_width: int, inner_height: int) -> Image.Image:
    font_size = max(1, inner_height)
    while font_size > 1:
        font = _load_font(font_size)
        left, top, right, bottom = _text_bbox(font, label)
        if bottom - top <= inner_height:
            break
        font_size -= 1
    font = _load_font(font_size)
    left, top, right, bottom = _text_bbox(font, label)
    width = max(1, right - left)
    height = max(1, bottom - top)

    text = Image.new("RGBA", (width, height), (255, 255, 255, 0))
    draw = ImageDraw.Draw(text)
    draw.text((-left, -top), label, fill=(*color, 255), font=font)

    scale = min(1.0, inner_width / width, inner_height / height)
    if scale < 1.0:
        target = (max(1, int(width * scale)), max(1, int(height * scale)))
        text = text.resize(target, Image.Resampling.LANCZOS)
    return text


def _svg_text_plan(label: str, inner_width: int, inner_height: int) -> Tuple[float, float]:
    font_size = min(inner_height * 0.78, inner_width / max(1.0, len(label) * 0.62))
    font_size = max(1.0, font_size)
    text_length = min(inner_width, max(font_size * 0.35, len(label) * font_size * 0.62))
    return font_size, text_length


def _rgb_to_svg(rgb: Tuple[int, int, int]) -> str:
    return "#{:02X}{:02X}{:02X}".format(*rgb)


[文档] @dataclass(frozen=True) class Pattern: """ A bead pattern generated from one image. :param width: Pattern width in beads. :type width: int :param height: Pattern height in beads. :type height: int :param palette: Palette used by ``indices``. :type palette: pypindou.color.Palette :param indices: Palette-index grid with shape ``(height, width)``. :type indices: numpy.ndarray :param rgb_image: Quantized RGB preview array with shape ``(height, width, 3)``. :type rgb_image: numpy.ndarray :param active_mask: Boolean mask of active beads. :type active_mask: numpy.ndarray :param error: Per-pixel quantization error. :type error: numpy.ndarray :param metadata: Generation metadata, defaults to an empty mapping. :type metadata: Mapping[str, Any], optional :raises ValueError: If array shapes do not match ``width`` and ``height``. """ width: int height: int palette: Palette indices: np.ndarray rgb_image: np.ndarray active_mask: np.ndarray error: np.ndarray metadata: Mapping[str, Any] = field(default_factory=dict) def __post_init__(self) -> None: indices = np.asarray(self.indices, dtype=np.int32) active = np.asarray(self.active_mask, dtype=bool) rgb = np.asarray(self.rgb_image, dtype=np.uint8) error = np.asarray(self.error, dtype=np.float64) if indices.shape != (self.height, self.width): raise ValueError("indices shape does not match pattern size.") if active.shape != (self.height, self.width): raise ValueError("active_mask shape does not match pattern size.") if error.shape != (self.height, self.width): raise ValueError("error shape does not match pattern size.") if rgb.shape != (self.height, self.width, 3): raise ValueError("rgb_image shape does not match pattern size.") object.__setattr__(self, "indices", indices) object.__setattr__(self, "active_mask", active) object.__setattr__(self, "rgb_image", rgb) object.__setattr__(self, "error", error) @property def bead_count(self) -> int: """ Number of active beads. :return: Number of active beads. :rtype: int """ return int(self.active_mask.sum()) @property def board_size(self) -> Tuple[int, int]: """ Pattern grid size as ``(width, height)``. :return: Pattern size. :rtype: Tuple[int, int] """ return self.width, self.height
[文档] def color_counts(self) -> Dict[str, int]: """ Count beads by color code. :return: Mapping from bead color code to usage count. :rtype: Dict[str, int] """ values = self.indices[self.active_mask] counts = Counter(int(item) for item in values if int(item) >= 0) return { self.palette.colors[idx].code: int(count) for idx, count in sorted(counts.items(), key=lambda item: self.palette.colors[item[0]].code) }
[文档] def legend(self) -> List[Dict[str, Any]]: """ Return legend rows sorted by count descending. :return: Legend rows with code, name, RGB, hex, count, and unidentified flag. :rtype: List[Dict[str, Any]] """ counts = self.color_counts() rows = [] for color in self.palette.colors: count = counts.get(color.code, 0) if count: rows.append( { "code": color.code, "name": color.name, "rgb": list(color.rgb), "hex": color.hex, "count": count, "unidentified": color.unidentified, } ) return sorted(rows, key=lambda item: (-item["count"], item["code"]))
[文档] def color_grid(self) -> List[List[Optional[str]]]: """ Return the pattern grid as color codes. :return: Two-dimensional grid of color codes, using ``None`` for inactive cells. :rtype: List[List[Optional[str]]] """ grid: List[List[Optional[str]]] = [] for y in range(self.height): row: List[Optional[str]] = [] for x in range(self.width): idx = int(self.indices[y, x]) row.append(self.palette.colors[idx].code if idx >= 0 and self.active_mask[y, x] else None) grid.append(row) return grid
[文档] def to_dict(self) -> Dict[str, Any]: """ Convert the pattern to a JSON-serializable dictionary. :return: Dictionary representation of the pattern. :rtype: Dict[str, Any] """ return { "width": self.width, "height": self.height, "bead_count": self.bead_count, "palette": { "id": self.palette.id, "title": self.palette.title, }, "legend": self.legend(), "grid": self.color_grid(), "mean_error": float(self.error[self.active_mask].mean()) if self.bead_count else 0.0, "metadata": dict(self.metadata), }
[文档] def to_image(self, *, scale: int = 16, grid: bool = True) -> Image.Image: """ Render the pattern as a preview image. :param scale: Pixel size of one bead cell, defaults to ``16``. :type scale: int, optional :param grid: Whether to draw cell grid lines, defaults to ``True``. :type grid: bool, optional :return: RGB preview image. :rtype: PIL.Image.Image :raises ValueError: If ``scale`` is not positive. """ if scale <= 0: raise ValueError("scale should be positive.") canvas = Image.new("RGB", (self.width * scale, self.height * scale), "white") draw = ImageDraw.Draw(canvas) for y in range(self.height): for x in range(self.width): left, top = x * scale, y * scale box = (left, top, left + scale, top + scale) if self.active_mask[y, x] and self.indices[y, x] >= 0: color = tuple(int(item) for item in self.rgb_image[y, x]) draw.rectangle(box, fill=color) else: draw.rectangle(box, fill=(255, 255, 255)) if grid and scale >= 4: draw.rectangle(box, outline=(210, 210, 210)) return canvas
[文档] def to_symbol_image( self, *, cell_size: int = 24, show_grid: bool = True, label_mode: SymbolLabelMode = "code", ) -> Image.Image: """ Render a PNG-compatible symbol map with bead-code labels. Labels are rendered into a transparent temporary image and scaled into the padded area of each cell. This guarantees that even long color codes stay strictly inside their own cells in the returned bitmap. :param cell_size: Pixel size of one cell, defaults to ``24``. :type cell_size: int, optional :param show_grid: Whether to draw cell borders, defaults to ``True``. :type show_grid: bool, optional :param label_mode: Label text strategy, defaults to ``"code"``. Use ``"short"`` for the last three code characters or ``"symbol"`` for upstream symbol metadata when available. :type label_mode: SymbolLabelMode, optional :return: RGB symbol image. :rtype: PIL.Image.Image :raises ValueError: If ``cell_size`` is not positive or ``label_mode`` is unsupported. """ if cell_size <= 0: raise ValueError("cell_size should be positive.") label_mode = _check_label_mode(label_mode) canvas = Image.new("RGB", (self.width * cell_size, self.height * cell_size), "white") draw = ImageDraw.Draw(canvas) padding, inner_width, inner_height = _cell_inner_size(cell_size) for y in range(self.height): for x in range(self.width): left, top = x * cell_size, y * cell_size box = (left, top, left + cell_size, top + cell_size) if self.active_mask[y, x] and self.indices[y, x] >= 0: color = self.palette.colors[int(self.indices[y, x])] draw.rectangle(box, fill=color.rgb) label = _label_for_color(color, label_mode) if label: text = _render_fitted_text(label, _text_rgb_for_background(color.rgb), inner_width, inner_height) tx = left + padding + (inner_width - text.width) // 2 ty = top + padding + (inner_height - text.height) // 2 canvas.paste(text.convert("RGB"), (tx, ty), text) if show_grid: draw.rectangle(box, outline=_GRID_RGB) return canvas
[文档] def to_symbol_svg( self, *, cell_size: int = 24, show_grid: bool = True, label_mode: SymbolLabelMode = "code", font_family: str = _SVG_FONT_FAMILY, ) -> str: """ Render a symbol map as SVG text. The SVG renderer uses the same cell padding and label strategy as :meth:`to_symbol_image`. Each label is placed in a per-cell clip path and receives a conservative ``textLength`` constraint, so SVG viewers cannot draw code text outside its own cell. :param cell_size: SVG user-unit size of one cell, defaults to ``24``. :type cell_size: int, optional :param show_grid: Whether to draw cell borders, defaults to ``True``. :type show_grid: bool, optional :param label_mode: Label text strategy, defaults to ``"code"``. :type label_mode: SymbolLabelMode, optional :param font_family: SVG font-family declaration, defaults to a monospace fallback stack. :type font_family: str, optional :return: SVG document text. :rtype: str :raises ValueError: If ``cell_size`` is not positive or ``label_mode`` is unsupported. """ if cell_size <= 0: raise ValueError("cell_size should be positive.") label_mode = _check_label_mode(label_mode) width = self.width * cell_size height = self.height * cell_size padding, inner_width, inner_height = _cell_inner_size(cell_size) font_family_text = escape(font_family, quote=True) lines = [ '<?xml version="1.0" encoding="UTF-8"?>', ( f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" ' f'viewBox="0 0 {width} {height}" shape-rendering="crispEdges">' ), ' <rect width="100%" height="100%" fill="#FFFFFF"/>', " <defs>", ] for y in range(self.height): for x in range(self.width): if self.active_mask[y, x] and self.indices[y, x] >= 0: clip_id = f"cell-{x}-{y}-clip" left = x * cell_size + padding top = y * cell_size + padding lines.append( f' <clipPath id="{clip_id}">' f'<rect x="{left}" y="{top}" width="{inner_width}" height="{inner_height}"/>' f"</clipPath>" ) lines.append(" </defs>") for y in range(self.height): for x in range(self.width): left, top = x * cell_size, y * cell_size stroke = f' stroke="{_SVG_GRID}" stroke-width="1"' if show_grid else "" if self.active_mask[y, x] and self.indices[y, x] >= 0: color = self.palette.colors[int(self.indices[y, x])] fill = _rgb_to_svg(color.rgb) lines.append( f' <rect x="{left}" y="{top}" width="{cell_size}" height="{cell_size}" fill="{fill}"{stroke}/>' ) label = _label_for_color(color, label_mode) if label: text_rgb = _text_rgb_for_background(color.rgb) text_fill = _rgb_to_svg(text_rgb) font_size, text_length = _svg_text_plan(label, inner_width, inner_height) cx = left + cell_size / 2 cy = top + cell_size / 2 clip_id = f"cell-{x}-{y}-clip" lines.append( f' <text x="{cx:.3f}" y="{cy:.3f}" fill="{text_fill}" ' f'font-family="{font_family_text}" font-size="{font_size:.3f}" ' f'text-anchor="middle" dominant-baseline="central" alignment-baseline="central" ' f'textLength="{text_length:.3f}" lengthAdjust="spacingAndGlyphs" ' f'clip-path="url(#{clip_id})">{escape(label)}</text>' ) else: lines.append( f' <rect x="{left}" y="{top}" width="{cell_size}" height="{cell_size}" fill="#FFFFFF"{stroke}/>' ) lines.append("</svg>") return "\n".join(lines) + "\n"
[文档] def save_symbol_chart( self, path: Union[str, Path], *, format: Optional[SymbolFormat] = None, cell_size: int = 24, show_grid: bool = True, label_mode: SymbolLabelMode = "code", ) -> Path: """ Save a symbol chart as PNG or SVG. When ``format`` is omitted, it is inferred from the file suffix. The PNG path uses :meth:`to_symbol_image`; the SVG path uses :meth:`to_symbol_svg`. :param path: Output path. :type path: Union[str, pathlib.Path] :param format: Output format, either ``"png"`` or ``"svg"``. When omitted, the suffix of ``path`` is used. :type format: Optional[SymbolFormat], optional :param cell_size: Size of one chart cell, defaults to ``24``. :type cell_size: int, optional :param show_grid: Whether to draw cell borders, defaults to ``True``. :type show_grid: bool, optional :param label_mode: Label text strategy, defaults to ``"code"``. :type label_mode: SymbolLabelMode, optional :return: Saved output path. :rtype: pathlib.Path :raises ValueError: If the format cannot be inferred or is unsupported. """ output = Path(path) output.parent.mkdir(parents=True, exist_ok=True) fmt = (format or output.suffix.lstrip(".")).lower() if fmt == "png": self.to_symbol_image(cell_size=cell_size, show_grid=show_grid, label_mode=label_mode).save(output) elif fmt == "svg": output.write_text( self.to_symbol_svg(cell_size=cell_size, show_grid=show_grid, label_mode=label_mode), encoding="utf-8", ) else: raise ValueError(f"Unsupported symbol chart format: {fmt!r}.") return output
[文档] def color_for_code(pattern: Pattern, code: str) -> BeadColor: """ Return the palette color for ``code`` in a pattern. :param pattern: Pattern whose palette should be queried. :type pattern: Pattern :param code: Palette color code. :type code: str :return: Matching bead color. :rtype: pypindou.color.BeadColor :raises KeyError: If the code is not present in the pattern palette. """ return pattern.palette.by_code(code)