vkit.engine.font.freetype

   1# Copyright 2022 vkit-x Administrator. All Rights Reserved.
   2#
   3# This project (vkit-x/vkit) is dual-licensed under commercial and SSPL licenses.
   4#
   5# The commercial license gives you the full rights to create and distribute software
   6# on your own terms without any SSPL license obligations. For more information,
   7# please see the "LICENSE_COMMERCIAL.txt" file.
   8#
   9# This project is also available under Server Side Public License (SSPL).
  10# The SSPL licensing is ideal for use cases such as open source projects with
  11# SSPL distribution, student/academic purposes, hobby projects, internal research
  12# projects without external distribution, or other projects where all SSPL
  13# obligations can be met. For more information, please see the "LICENSE_SSPL.txt" file.
  14from typing import Optional, Any, Callable, List, Sequence
  15import itertools
  16
  17import attrs
  18import numpy as np
  19from numpy.random import Generator as RandomGenerator
  20import cv2 as cv
  21import freetype
  22
  23from vkit.utility import sample_cv_resize_interpolation
  24from vkit.element import Image, Box, Mask, ScoreMap
  25from vkit.engine.interface import (
  26    NoneTypeEngineInitConfig,
  27    NoneTypeEngineInitResource,
  28    Engine,
  29    EngineExecutorFactory,
  30)
  31from .type import (
  32    CharBox,
  33    FontEngineRunConfigGlyphSequence,
  34    FontEngineRunConfigStyle,
  35    FontEngineRunConfig,
  36    CharGlyph,
  37    TextLine,
  38)
  39
  40
  41def estimate_font_size(config: FontEngineRunConfig):
  42    style = config.style
  43
  44    # Estimate the font size based on height or width.
  45    if config.glyph_sequence == FontEngineRunConfigGlyphSequence.HORI_DEFAULT:
  46        font_size = round(config.height * style.font_size_ratio)
  47    elif config.glyph_sequence == FontEngineRunConfigGlyphSequence.VERT_DEFAULT:
  48        font_size = round(config.width * style.font_size_ratio)
  49    else:
  50        raise NotImplementedError()
  51
  52    font_size = int(np.clip(font_size, style.font_size_min, style.font_size_max))
  53    return font_size
  54
  55
  56def load_freetype_font_face(
  57    run_config: FontEngineRunConfig,
  58    lcd_compression_factor: Optional[int] = None,
  59):
  60    font_variant = run_config.font_variant
  61    if font_variant.is_ttc:
  62        assert font_variant.ttc_font_index is not None
  63        font_face = freetype.Face(str(font_variant.font_file), index=font_variant.ttc_font_index)
  64    else:
  65        font_face = freetype.Face(str(font_variant.font_file))
  66
  67    font_size = estimate_font_size(run_config)
  68
  69    # 1. "The nominal width, in 26.6 fractional points" and "The 26.6 fixed float format
  70    #    used to define fractional pixel coordinates. Here, 1 unit = 1/64 pixel", hence
  71    #    we need to multiple 64 here.
  72    # 2. `height` defaults to 0 and "If either the character width or
  73    #    height is zero, it is set equal to the other value",
  74    #    hence no need to set the `height` parameter.
  75    # 3. `vres` for vertical resolution, and defaults to 72.
  76    #    Since "pixel_size = point_size * resolution / 72", setting resolution to 72 equalize
  77    #    point_size and pixel_size. Since "If either the horizontal or vertical resolution is zero,
  78    #    it is set equal to the other value" and `vres` defaults to 72, we don't need to set the
  79    #    `vres` parameter.
  80    # 4. `hres` for horizontal resolution, and defaults to 72. If `lcd_compression_factor` is set,
  81    #    we need to cancel the sub-pixel rendering effect, by scaling up the same factor to `hres`.
  82    hres = 72
  83    if lcd_compression_factor is not None:
  84        hres *= lcd_compression_factor
  85    font_face.set_char_size(width=font_size * 64, hres=hres)
  86
  87    return font_face
  88
  89
  90def build_freetype_font_face_lcd_hc_matrix(lcd_compression_factor: int):
  91    # "hc" stands for "horizontal compression".
  92    return freetype.Matrix(
  93        int((1 / lcd_compression_factor) * 0x10000),
  94        int(0.0 * 0x10000),
  95        int(0.0 * 0x10000),
  96        int(1.0 * 0x10000),
  97    )
  98
  99
 100def trim_char_np_image_vert(np_image: np.ndarray):
 101    # (H, W) or (H, W, 3)
 102    np_vert_max: np.ndarray = np.amax(np_image, axis=1)
 103    if np_vert_max.ndim == 2:
 104        np_vert_max = np.amax(np_vert_max, axis=1)
 105    assert np_vert_max.ndim == 1
 106
 107    np_vert_nonzero = np.nonzero(np_vert_max)[0]
 108    if len(np_vert_nonzero) == 0:
 109        raise RuntimeError('trim_char_np_image_vert: empty np_image.')
 110
 111    up = int(np_vert_nonzero[0])
 112    down = int(np_vert_nonzero[-1])
 113
 114    height = np_image.shape[0]
 115    return np_image[up:down + 1], up, height - 1 - down
 116
 117
 118def trim_char_np_image_hori(np_image: np.ndarray):
 119    # (H, W) or (H, W, 3)
 120    np_hori_max: np.ndarray = np.amax(np_image, axis=0)
 121    if np_hori_max.ndim == 2:
 122        np_hori_max = np.amax(np_hori_max, axis=1)
 123    assert np_hori_max.ndim == 1
 124
 125    np_hori_nonzero = np.nonzero(np_hori_max)[0]
 126    if len(np_hori_nonzero) == 0:
 127        raise RuntimeError('trim_char_np_image_hori: empty np_image.')
 128
 129    left = int(np_hori_nonzero[0])
 130    right = int(np_hori_nonzero[-1])
 131
 132    width = np_image.shape[1]
 133    return np_image[:, left:right + 1], left, width - 1 - right
 134
 135
 136def build_char_glyph(
 137    config: FontEngineRunConfig,
 138    char: str,
 139    glyph: Any,
 140    np_image: np.ndarray,
 141):
 142    assert not char.isspace()
 143
 144    # References:
 145    # https://freetype.org/freetype2/docs/tutorial/step2.html
 146    # https://freetype-py.readthedocs.io/en/latest/glyph_slot.html
 147
 148    # "the distance from the baseline to the top-most glyph scanline"
 149    ascent = glyph.bitmap_top
 150
 151    # "It is always zero for horizontal layouts, and positive for vertical layouts."
 152    assert glyph.advance.y == 0
 153
 154    # Trim vertically to get pad_up & pad_down.
 155    np_image, pad_up, pad_down = trim_char_np_image_vert(np_image)
 156    ascent -= pad_up
 157
 158    # "bitmap’s left bearing"
 159    # NOTE: `bitmap_left` could be negative, we simply ignore the negative value.
 160    pad_left = max(0, glyph.bitmap_left)
 161
 162    # "It is always zero for horizontal layouts, and zero for vertical ones."
 163    assert glyph.advance.x > 0
 164    width = np_image.shape[1]
 165    # Aforementioned, 1 unit = 1/64 pixel.
 166    pad_right = round(glyph.advance.x / 64) - pad_left - width
 167    # Apply defensive clipping.
 168    pad_right = max(0, pad_right)
 169
 170    # Trim horizontally to increase pad_left & pad_right.
 171    np_image, pad_left_inc, pad_right_inc = trim_char_np_image_hori(np_image)
 172    pad_left += pad_left_inc
 173    pad_right += pad_right_inc
 174
 175    score_map = None
 176    if np_image.ndim == 2:
 177        # Apply gamma correction.
 178        np_alpha = np.power(
 179            np_image.astype(np.float32) / 255.0,
 180            config.style.glyph_color_gamma,
 181        )
 182        score_map = ScoreMap(mat=np_alpha)
 183
 184    # Reference statistics.
 185    font_variant = config.font_variant
 186    tag_to_font_glyph_info = font_variant.font_glyph_info_collection.tag_to_font_glyph_info
 187
 188    assert char in font_variant.char_to_tags
 189    tags = font_variant.char_to_tags[char]
 190
 191    font_glyph_info = None
 192    for tag in tags:
 193        assert tag in tag_to_font_glyph_info
 194        cur_font_glyph_info = tag_to_font_glyph_info[tag]
 195        if font_glyph_info is None:
 196            font_glyph_info = cur_font_glyph_info
 197        else:
 198            assert font_glyph_info == cur_font_glyph_info
 199
 200    assert font_glyph_info is not None
 201
 202    font_size = estimate_font_size(config)
 203    ref_ascent_plus_pad_up = round(
 204        font_glyph_info.ascent_plus_pad_up_min_to_font_size_ratio * font_size
 205    )
 206    ref_char_height = round(font_glyph_info.height_min_to_font_size_ratio * font_size)
 207    ref_char_width = round(font_glyph_info.width_min_to_font_size_ratio * font_size)
 208
 209    return CharGlyph(
 210        char=char,
 211        image=Image(mat=np_image),
 212        score_map=score_map,
 213        ascent=ascent,
 214        pad_up=pad_up,
 215        pad_down=pad_down,
 216        pad_left=pad_left,
 217        pad_right=pad_right,
 218        ref_ascent_plus_pad_up=ref_ascent_plus_pad_up,
 219        ref_char_height=ref_char_height,
 220        ref_char_width=ref_char_width,
 221    )
 222
 223
 224def render_char_glyphs_from_text(
 225    run_config: FontEngineRunConfig,
 226    font_face: freetype.Face,
 227    func_render_char_glyph: Callable[[FontEngineRunConfig, freetype.Face, str], CharGlyph],
 228    chars: Sequence[str],
 229):
 230    char_glyphs: List[CharGlyph] = []
 231    prev_num_spaces_for_char_glyphs: List[int] = []
 232    num_spaces = 0
 233    for idx, char in enumerate(chars):
 234        if char.isspace():
 235            num_spaces += 1
 236            continue
 237
 238        char_glyphs.append(func_render_char_glyph(run_config, font_face, char))
 239
 240        if idx == 0 and num_spaces > 0:
 241            raise RuntimeError('Leading space(s) detected.')
 242        prev_num_spaces_for_char_glyphs.append(num_spaces)
 243        num_spaces = 0
 244
 245    if num_spaces > 0:
 246        raise RuntimeError('Trailing space(s) detected.')
 247
 248    return char_glyphs, prev_num_spaces_for_char_glyphs
 249
 250
 251def get_kerning_limits_hori_default(
 252    char_glyphs: Sequence[CharGlyph],
 253    prev_num_spaces_for_char_glyphs: Sequence[int],
 254):
 255    assert char_glyphs
 256    ascent_max = max(char_glyph.ascent for char_glyph in char_glyphs)
 257
 258    kerning_limits: List[int] = []
 259
 260    prev_glyph_mask = None
 261    prev_np_glyph_mask = None
 262    prev_glyph_mask_up = None
 263    prev_glyph_mask_down = None
 264    for char_glyph, prev_num_spaces in zip(char_glyphs, prev_num_spaces_for_char_glyphs):
 265        glyph_mask = char_glyph.get_glyph_mask()
 266        np_glyph_mask = glyph_mask.mat
 267        glyph_mask_up = ascent_max - char_glyph.ascent
 268        glyph_mask_down = glyph_mask_up + np_glyph_mask.shape[0] - 1
 269
 270        if prev_num_spaces == 0 and prev_np_glyph_mask is not None:
 271            assert prev_glyph_mask is not None
 272            assert prev_glyph_mask_up is not None
 273            assert prev_glyph_mask_down is not None
 274
 275            overlap_up = max(prev_glyph_mask_up, glyph_mask_up)
 276            overlap_down = min(prev_glyph_mask_down, glyph_mask_down)
 277            if overlap_up <= overlap_down:
 278                overlap_prev_np_glyph_mask = \
 279                    prev_np_glyph_mask[overlap_up - prev_glyph_mask_up:
 280                                       overlap_down - prev_glyph_mask_up + 1]
 281                overlap_np_glyph_mask = \
 282                    np_glyph_mask[overlap_up - glyph_mask_up:
 283                                  overlap_down - glyph_mask_up + 1]
 284
 285                kerning_limit = 1
 286                while kerning_limit < prev_glyph_mask.width / 2 \
 287                        and kerning_limit < glyph_mask.width / 2:
 288                    prev_np_glyph_mask_tail = overlap_prev_np_glyph_mask[:, -kerning_limit:]
 289                    np_glyph_mask_head = overlap_np_glyph_mask[:, :kerning_limit]
 290                    if (prev_np_glyph_mask_tail & np_glyph_mask_head).any():
 291                        # Intersection detected.
 292                        kerning_limit -= 1
 293                        break
 294                    kerning_limit += 1
 295
 296                kerning_limits.append(kerning_limit)
 297
 298            else:
 299                # Not overlapped.
 300                kerning_limits.append(0)
 301
 302        else:
 303            # Isolated by word space or being the first glyph, skip.
 304            kerning_limits.append(0)
 305
 306        prev_glyph_mask = glyph_mask
 307        prev_np_glyph_mask = np_glyph_mask
 308        prev_glyph_mask_up = glyph_mask_up
 309        prev_glyph_mask_down = glyph_mask_down
 310
 311    return kerning_limits
 312
 313
 314def render_char_glyphs_in_text_line(
 315    style: FontEngineRunConfigStyle,
 316    text_line_height: int,
 317    text_line_width: int,
 318    char_glyphs: Sequence[CharGlyph],
 319    char_boxes: Sequence[CharBox],
 320):
 321    # Genrate text line image.
 322    np_image = np.full((text_line_height, text_line_width, 3), 255, dtype=np.uint8)
 323    np_mask = np.zeros((text_line_height, text_line_width), dtype=np.uint8)
 324    score_map = None
 325
 326    if char_glyphs[0].image.mat.ndim == 2:
 327        score_map = ScoreMap.from_shape((text_line_height, text_line_width))
 328
 329        # Default or monochrome.
 330        for char_glyph, char_box in zip(char_glyphs, char_boxes):
 331            assert char_glyph.score_map
 332
 333            char_glyph_mask = char_glyph.get_glyph_mask(box=char_box.box)
 334
 335            # Fill color based on RGBA & alpha.
 336            np_char_image = np.full(
 337                (char_glyph.height, char_glyph.width, 4),
 338                (*style.glyph_color, 0),
 339                dtype=np.uint8,
 340            )
 341            np_char_image[:, :, 3] = (char_glyph.score_map.mat * 255).astype(np.uint8)
 342
 343            # To RGB.
 344            np_char_image = cv.cvtColor(np_char_image, cv.COLOR_RGBA2RGB)
 345
 346            # Paste to text line.
 347            char_glyph_mask.fill_np_array(np_image, np_char_image)
 348            char_glyph_mask.fill_np_array(np_mask, 1)
 349            char_box.box.fill_score_map(
 350                score_map,
 351                char_glyph.score_map,
 352                keep_max_value=True,
 353            )
 354
 355    elif char_glyphs[0].image.mat.ndim == 3:
 356        # LCD.
 357        for char_glyph, char_box in zip(char_glyphs, char_boxes):
 358            char_glyph_mask = char_glyph.get_glyph_mask(box=char_box.box)
 359
 360            # NOTE: the `glyph_color` option is ignored in LCD mode.
 361            # Gamma correction.
 362            np_char_image = np.power(
 363                char_glyph.image.mat / 255.0,
 364                style.glyph_color_gamma,
 365            )
 366            np_char_image = ((1 - np_char_image) * 255).astype(np.uint8)  # type: ignore
 367
 368            # Paste to text line.
 369            char_glyph_mask.fill_np_array(np_image, np_char_image)
 370            char_glyph_mask.fill_np_array(np_mask, 1)
 371
 372    else:
 373        raise NotImplementedError()
 374
 375    return (
 376        Image(mat=np_image),
 377        Mask(mat=np_mask),
 378        score_map,
 379        char_boxes,
 380    )
 381
 382
 383def place_char_glyphs_in_text_line_hori_default(
 384    run_config: FontEngineRunConfig,
 385    char_glyphs: Sequence[CharGlyph],
 386    prev_num_spaces_for_char_glyphs: Sequence[int],
 387    kerning_limits: Sequence[int],
 388    rng: RandomGenerator,
 389):
 390    style = run_config.style
 391
 392    assert char_glyphs
 393    char_widths_avg = np.mean([char_glyph.width for char_glyph in char_glyphs])
 394
 395    char_space_min = char_widths_avg * style.char_space_min
 396    char_space_max = char_widths_avg * style.char_space_max
 397    char_space_mean = char_widths_avg * style.char_space_mean
 398    char_space_std = char_widths_avg * style.char_space_std
 399
 400    word_space_min = char_widths_avg * style.word_space_min
 401    word_space_max = char_widths_avg * style.word_space_max
 402    word_space_mean = char_widths_avg * style.word_space_mean
 403    word_space_std = char_widths_avg * style.word_space_std
 404
 405    ascent_plus_pad_up_max = max(
 406        itertools.chain.from_iterable(
 407            (char_glyph.ascent + char_glyph.pad_up, char_glyph.ref_ascent_plus_pad_up)
 408            for char_glyph in char_glyphs
 409        )
 410    )
 411
 412    text_line_height = max(char_glyph.ref_char_height for char_glyph in char_glyphs)
 413
 414    char_boxes: List[CharBox] = []
 415    hori_offset = 0
 416    for char_idx, (char_glyph, prev_num_spaces, kerning_limit) in enumerate(
 417        zip(
 418            char_glyphs,
 419            prev_num_spaces_for_char_glyphs,
 420            kerning_limits,
 421        )
 422    ):
 423        # "Stick" chars together.
 424        hori_offset -= kerning_limit
 425
 426        # Shift by space.
 427        if prev_num_spaces > 0:
 428            # Random word space(s).
 429            space = 0
 430            for _ in range(prev_num_spaces):
 431                space += round(
 432                    np.clip(
 433                        rng.normal(loc=word_space_mean, scale=word_space_std),
 434                        word_space_min,
 435                        word_space_max,
 436                    )  # type: ignore
 437                )
 438
 439        else:
 440            # Random char space.
 441            space = round(
 442                np.clip(
 443                    rng.normal(loc=char_space_mean, scale=char_space_std),
 444                    char_space_min,
 445                    char_space_max,
 446                )  # type: ignore
 447            )
 448
 449        hori_offset += space
 450
 451        # Place char box.
 452        up = ascent_plus_pad_up_max - char_glyph.ascent
 453        down = up + char_glyph.height - 1
 454
 455        left = hori_offset + char_glyph.pad_left
 456        if char_idx == 0:
 457            # Ignore the leading padding.
 458            left = 0
 459        right = left + char_glyph.width - 1
 460
 461        assert not char_glyph.char.isspace()
 462        char_boxes.append(
 463            CharBox(
 464                char=char_glyph.char,
 465                box=Box(
 466                    up=up,
 467                    down=down,
 468                    left=left,
 469                    right=right,
 470                ),
 471            )
 472        )
 473
 474        # Update the height of text line.
 475        text_line_height = max(text_line_height, down + 1 + char_glyph.pad_down)
 476
 477        # Move offset.
 478        hori_offset = right + 1
 479        if char_idx < len(char_glyphs) - 1:
 480            hori_offset += char_glyph.pad_right
 481
 482    text_line_width = hori_offset
 483
 484    return render_char_glyphs_in_text_line(
 485        style=style,
 486        text_line_height=text_line_height,
 487        text_line_width=text_line_width,
 488        char_glyphs=char_glyphs,
 489        char_boxes=char_boxes,
 490    )
 491
 492
 493def place_char_glyphs_in_text_line_vert_default(
 494    run_config: FontEngineRunConfig,
 495    char_glyphs: Sequence[CharGlyph],
 496    prev_num_spaces_for_char_glyphs: Sequence[int],
 497    rng: RandomGenerator,
 498):
 499    style = run_config.style
 500
 501    assert char_glyphs
 502    char_widths_avg = np.mean([char_glyph.width for char_glyph in char_glyphs])
 503
 504    char_space_min = char_widths_avg * style.char_space_min
 505    char_space_max = char_widths_avg * style.char_space_max
 506    char_space_mean = char_widths_avg * style.char_space_mean
 507    char_space_std = char_widths_avg * style.char_space_std
 508
 509    word_space_min = char_widths_avg * style.word_space_min
 510    word_space_max = char_widths_avg * style.word_space_max
 511    word_space_mean = char_widths_avg * style.word_space_mean
 512    word_space_std = char_widths_avg * style.word_space_std
 513
 514    text_line_width = max(
 515        itertools.chain.from_iterable((
 516            char_glyph.pad_left + char_glyph.width + char_glyph.pad_right,
 517            char_glyph.ref_char_width,
 518        ) for char_glyph in char_glyphs)
 519    )
 520
 521    text_line_width_mid = text_line_width // 2
 522
 523    char_boxes: List[CharBox] = []
 524    vert_offset = 0
 525    for char_idx, (char_glyph, prev_num_spaces) in enumerate(
 526        zip(char_glyphs, prev_num_spaces_for_char_glyphs)
 527    ):
 528        # Shift by space.
 529        if prev_num_spaces > 0:
 530            # Random word space(s).
 531            space = 0
 532            for _ in range(prev_num_spaces):
 533                space += round(
 534                    np.clip(
 535                        rng.normal(loc=word_space_mean, scale=word_space_std),
 536                        word_space_min,
 537                        word_space_max,
 538                    )  # type: ignore
 539                )
 540
 541        else:
 542            # Random char space.
 543            space = round(
 544                np.clip(
 545                    rng.normal(loc=char_space_mean, scale=char_space_std),
 546                    char_space_min,
 547                    char_space_max,
 548                )  # type: ignore
 549            )
 550
 551        vert_offset += space
 552
 553        # Place char box.
 554        up = vert_offset + char_glyph.pad_up
 555        if char_idx == 0:
 556            # Ignore the leading padding.
 557            up = 0
 558
 559        down = up + char_glyph.height - 1
 560
 561        # Vertical align in middle.
 562        left = text_line_width_mid - char_glyph.width // 2
 563        right = left + char_glyph.width - 1
 564
 565        assert not char_glyph.char.isspace()
 566        char_boxes.append(
 567            CharBox(
 568                char=char_glyph.char,
 569                box=Box(
 570                    up=up,
 571                    down=down,
 572                    left=left,
 573                    right=right,
 574                ),
 575            )
 576        )
 577
 578        # Move offset.
 579        vert_offset = down + 1
 580        if char_idx < len(char_glyphs) - 1:
 581            vert_offset += char_glyph.pad_down
 582
 583    text_line_height = vert_offset
 584
 585    return render_char_glyphs_in_text_line(
 586        style=style,
 587        text_line_height=text_line_height,
 588        text_line_width=text_line_width,
 589        char_glyphs=char_glyphs,
 590        char_boxes=char_boxes,
 591    )
 592
 593
 594def resize_and_trim_text_line_hori_default(
 595    run_config: FontEngineRunConfig,
 596    cv_resize_interpolation_enlarge: int,
 597    cv_resize_interpolation_shrink: int,
 598    image: Image,
 599    mask: Mask,
 600    score_map: Optional[ScoreMap],
 601    char_boxes: Sequence[CharBox],
 602    char_glyphs: Sequence[CharGlyph],
 603):
 604    # Resize if image height too small or too large.
 605    is_too_small = (image.height / run_config.height < 0.8)
 606    is_too_large = (image.height > run_config.height)
 607
 608    cv_resize_interpolation = cv_resize_interpolation_enlarge
 609    if is_too_large:
 610        cv_resize_interpolation = cv_resize_interpolation_shrink
 611
 612    if is_too_small or is_too_large:
 613        resized_image = image.to_resized_image(
 614            resized_height=run_config.height,
 615            cv_resize_interpolation=cv_resize_interpolation,
 616        )
 617        resized_mask = mask.to_resized_mask(
 618            resized_height=run_config.height,
 619            cv_resize_interpolation=cv_resize_interpolation,
 620        )
 621        resized_char_boxes = [
 622            char_box.to_conducted_resized_char_box(
 623                shapable_or_shape=image,
 624                resized_height=run_config.height,
 625            ) for char_box in char_boxes
 626        ]
 627
 628        image = resized_image
 629        mask = resized_mask
 630        char_boxes = resized_char_boxes
 631
 632        if score_map:
 633            score_map = score_map.to_resized_score_map(
 634                resized_height=run_config.height,
 635                cv_resize_interpolation=cv_resize_interpolation,
 636            )
 637
 638    # Pad vertically.
 639    if image.height != run_config.height:
 640        pad_vert = run_config.height - image.height
 641        assert pad_vert > 0
 642        pad_up = pad_vert // 2
 643        pad_down = pad_vert - pad_up
 644
 645        np_image = np.full((run_config.height, image.width, 3), 255, dtype=np.uint8)
 646        np_image[pad_up:-pad_down] = image.mat
 647        image.assign_mat(np_image)
 648
 649        np_mask = np.zeros((run_config.height, image.width), dtype=np.uint8)
 650        np_mask[pad_up:-pad_down] = mask.mat
 651        mask.assign_mat(np_mask)
 652
 653        padded_char_boxes = []
 654        for char_box in char_boxes:
 655            box = attrs.evolve(
 656                char_box.box,
 657                up=char_box.up + pad_up,
 658                down=char_box.down + pad_up,
 659            )
 660            padded_char_boxes.append(attrs.evolve(char_box, box=box))
 661        char_boxes = padded_char_boxes
 662
 663        if score_map:
 664            padded_score_map = ScoreMap.from_shape((run_config.height, image.width))
 665            with padded_score_map.writable_context:
 666                padded_score_map.mat[pad_up:-pad_down] = score_map.mat
 667            score_map = padded_score_map
 668
 669    # Trim.
 670    if image.width > run_config.width:
 671        last_char_box_idx = len(char_boxes) - 1
 672        while last_char_box_idx >= 0 and char_boxes[last_char_box_idx].right >= run_config.width:
 673            last_char_box_idx -= 1
 674
 675        if last_char_box_idx == len(char_boxes) - 1:
 676            # Corner case: char_boxes[-1].right < config.width but mage.width > config.width.
 677            # This is caused by glyph padding. The solution is to drop this char.
 678            last_char_box_idx -= 1
 679
 680        if last_char_box_idx < 0 or char_boxes[last_char_box_idx].right >= run_config.width:
 681            # Cannot trim.
 682            return None, None, None, None, -1
 683
 684        last_char_box = char_boxes[last_char_box_idx]
 685        last_char_box_right = last_char_box.right
 686
 687        # Clean up residual pixels.
 688        first_trimed_char_box = char_boxes[last_char_box_idx + 1]
 689        if first_trimed_char_box.left <= last_char_box_right:
 690            first_trimed_char_glyph = char_glyphs[last_char_box_idx + 1]
 691
 692            first_trimed_char_glyph_mask = first_trimed_char_glyph.get_glyph_mask(
 693                box=first_trimed_char_box.box,
 694                enable_resize=True,
 695                cv_resize_interpolation=cv_resize_interpolation,
 696            )
 697            first_trimed_char_glyph_mask.fill_image(image, (255, 255, 255))
 698            first_trimed_char_glyph_mask.fill_mask(mask, 0)
 699
 700            if first_trimed_char_glyph.score_map:
 701                assert score_map
 702
 703                first_trimed_char_score_map = first_trimed_char_glyph.score_map
 704                if first_trimed_char_score_map.shape != first_trimed_char_box.shape:
 705                    first_trimed_char_score_map = first_trimed_char_score_map.to_resized_score_map(
 706                        resized_height=first_trimed_char_box.height,
 707                        resized_width=first_trimed_char_box.width,
 708                        cv_resize_interpolation=cv_resize_interpolation,
 709                    )
 710
 711                last_char_score_map = char_glyphs[last_char_box_idx].score_map
 712                assert last_char_score_map
 713                if last_char_score_map.shape != last_char_box.shape:
 714                    last_char_score_map = last_char_score_map.to_resized_score_map(
 715                        resized_height=last_char_box.height,
 716                        resized_width=last_char_box.width,
 717                        cv_resize_interpolation=cv_resize_interpolation,
 718                    )
 719
 720                first_trimed_char_box.box.fill_score_map(score_map, 0)
 721                last_char_box.box.fill_score_map(
 722                    score_map,
 723                    last_char_score_map,
 724                    keep_max_value=True,
 725                )
 726
 727        char_boxes = char_boxes[:last_char_box_idx + 1]
 728        image.assign_mat(image.mat[:, :last_char_box_right + 1])
 729        mask.assign_mat(mask.mat[:, :last_char_box_right + 1])
 730
 731        if score_map:
 732            score_map.assign_mat(score_map.mat[:, :last_char_box_right + 1])
 733
 734    return image, mask, score_map, char_boxes, cv_resize_interpolation
 735
 736
 737def resize_and_trim_text_line_vert_default(
 738    run_config: FontEngineRunConfig,
 739    cv_resize_interpolation_enlarge: int,
 740    cv_resize_interpolation_shrink: int,
 741    image: Image,
 742    mask: Mask,
 743    score_map: Optional[ScoreMap],
 744    char_boxes: Sequence[CharBox],
 745):
 746    # Resize if image width too small or too large.
 747    is_too_small = (image.width / run_config.width < 0.8)
 748    is_too_large = (image.width > run_config.width)
 749
 750    cv_resize_interpolation = cv_resize_interpolation_enlarge
 751    if is_too_large:
 752        cv_resize_interpolation = cv_resize_interpolation_shrink
 753
 754    if is_too_small or is_too_large:
 755        resized_image = image.to_resized_image(
 756            resized_width=run_config.width,
 757            cv_resize_interpolation=cv_resize_interpolation,
 758        )
 759        resized_mask = mask.to_resized_mask(
 760            resized_width=run_config.width,
 761            cv_resize_interpolation=cv_resize_interpolation,
 762        )
 763        resized_char_boxes = [
 764            char_box.to_conducted_resized_char_box(
 765                shapable_or_shape=image,
 766                resized_width=run_config.width,
 767            ) for char_box in char_boxes
 768        ]
 769
 770        image = resized_image
 771        mask = resized_mask
 772        char_boxes = resized_char_boxes
 773
 774        if score_map:
 775            score_map = score_map.to_resized_score_map(
 776                resized_width=run_config.width,
 777                cv_resize_interpolation=cv_resize_interpolation,
 778            )
 779
 780    # Pad horizontally.
 781    if image.width != run_config.width:
 782        pad_hori = run_config.width - image.width
 783        assert pad_hori > 0
 784        pad_left = pad_hori // 2
 785        pad_right = pad_hori - pad_left
 786
 787        np_image = np.full((image.height, run_config.width, 3), 255, dtype=np.uint8)
 788        np_image[:, pad_left:-pad_right] = image.mat
 789        image.assign_mat(np_image)
 790
 791        np_mask = np.zeros((image.height, run_config.width), dtype=np.uint8)
 792        np_mask[:, pad_left:-pad_right] = mask.mat
 793        mask.assign_mat(np_mask)
 794
 795        padded_char_boxes = []
 796        for char_box in char_boxes:
 797            box = attrs.evolve(
 798                char_box.box,
 799                left=char_box.left + pad_left,
 800                right=char_box.right + pad_right,
 801            )
 802            padded_char_boxes.append(attrs.evolve(char_box, box=box))
 803        char_boxes = padded_char_boxes
 804
 805        if score_map:
 806            padded_score_map = ScoreMap.from_shape((image.height, run_config.width))
 807            padded_score_map.mat[:, pad_left:-pad_right] = score_map.mat
 808            score_map = padded_score_map
 809
 810    # Trim.
 811    if image.height > run_config.height:
 812        last_char_box_idx = len(char_boxes) - 1
 813        while last_char_box_idx >= 0 and char_boxes[last_char_box_idx].down >= run_config.height:
 814            last_char_box_idx -= 1
 815
 816        if last_char_box_idx == len(char_boxes) - 1:
 817            last_char_box_idx -= 1
 818
 819        if last_char_box_idx < 0 or char_boxes[last_char_box_idx].down >= run_config.height:
 820            # Cannot trim.
 821            return None, None, None, None, -1
 822
 823        last_char_box_down = char_boxes[last_char_box_idx].down
 824        char_boxes = char_boxes[:last_char_box_idx + 1]
 825        image.assign_mat(image.mat[:last_char_box_down + 1])
 826        mask.assign_mat(mask.mat[:last_char_box_down + 1])
 827
 828        if score_map:
 829            score_map.assign_mat(score_map.mat[:last_char_box_down + 1])
 830
 831    return image, mask, score_map, char_boxes, cv_resize_interpolation
 832
 833
 834def render_text_line_meta(
 835    run_config: FontEngineRunConfig,
 836    font_face: freetype.Face,
 837    func_render_char_glyph: Callable[[FontEngineRunConfig, freetype.Face, str], CharGlyph],
 838    rng: RandomGenerator,
 839    cv_resize_interpolation_enlarge: int = cv.INTER_CUBIC,
 840    cv_resize_interpolation_shrink: int = cv.INTER_AREA,
 841):
 842    (
 843        char_glyphs,
 844        prev_num_spaces_for_char_glyphs,
 845    ) = render_char_glyphs_from_text(
 846        run_config=run_config,
 847        font_face=font_face,
 848        func_render_char_glyph=func_render_char_glyph,
 849        chars=run_config.chars,
 850    )
 851    if not char_glyphs:
 852        return None
 853
 854    if run_config.glyph_sequence == FontEngineRunConfigGlyphSequence.HORI_DEFAULT:
 855        kerning_limits = get_kerning_limits_hori_default(
 856            char_glyphs,
 857            prev_num_spaces_for_char_glyphs,
 858        )
 859        (
 860            image,
 861            mask,
 862            score_map,
 863            char_boxes,
 864        ) = place_char_glyphs_in_text_line_hori_default(
 865            run_config=run_config,
 866            char_glyphs=char_glyphs,
 867            prev_num_spaces_for_char_glyphs=prev_num_spaces_for_char_glyphs,
 868            kerning_limits=kerning_limits,
 869            rng=rng,
 870        )
 871        (
 872            image,
 873            mask,
 874            score_map,
 875            char_boxes,
 876            cv_resize_interpolation,
 877        ) = resize_and_trim_text_line_hori_default(
 878            run_config=run_config,
 879            cv_resize_interpolation_enlarge=cv_resize_interpolation_enlarge,
 880            cv_resize_interpolation_shrink=cv_resize_interpolation_shrink,
 881            image=image,
 882            mask=mask,
 883            score_map=score_map,
 884            char_boxes=char_boxes,
 885            char_glyphs=char_glyphs,
 886        )
 887        is_hori = True
 888
 889    elif run_config.glyph_sequence == FontEngineRunConfigGlyphSequence.VERT_DEFAULT:
 890        # NOTE: No kerning limit detection for VERT_DEFAULT mode.
 891        (
 892            image,
 893            mask,
 894            score_map,
 895            char_boxes,
 896        ) = place_char_glyphs_in_text_line_vert_default(
 897            run_config=run_config,
 898            char_glyphs=char_glyphs,
 899            prev_num_spaces_for_char_glyphs=prev_num_spaces_for_char_glyphs,
 900            rng=rng,
 901        )
 902        (
 903            image,
 904            mask,
 905            score_map,
 906            char_boxes,
 907            cv_resize_interpolation,
 908        ) = resize_and_trim_text_line_vert_default(
 909            run_config=run_config,
 910            cv_resize_interpolation_enlarge=cv_resize_interpolation_enlarge,
 911            cv_resize_interpolation_shrink=cv_resize_interpolation_shrink,
 912            image=image,
 913            mask=mask,
 914            score_map=score_map,
 915            char_boxes=char_boxes,
 916        )
 917        is_hori = False
 918
 919    else:
 920        raise NotImplementedError()
 921
 922    if image is None:
 923        return None
 924    else:
 925        assert mask is not None
 926        assert char_boxes is not None
 927
 928        char_idx = 0
 929        non_space_count = 0
 930        while char_idx < len(run_config.chars) and non_space_count < len(char_boxes):
 931            if not run_config.chars[char_idx].isspace():
 932                non_space_count += 1
 933            char_idx += 1
 934        assert non_space_count == len(char_boxes)
 935
 936        box = Box.from_shapable(image)
 937        image = image.to_box_attached(box)
 938        mask = mask.to_box_attached(box)
 939        if score_map:
 940            score_map = score_map.to_box_attached(box)
 941
 942        return TextLine(
 943            image=image,
 944            mask=mask,
 945            score_map=score_map,
 946            char_boxes=char_boxes,
 947            char_glyphs=char_glyphs[:len(char_boxes)],
 948            cv_resize_interpolation=cv_resize_interpolation,
 949            font_size=estimate_font_size(run_config),
 950            style=run_config.style,
 951            text=''.join(run_config.chars[:char_idx]),
 952            is_hori=is_hori,
 953            font_variant=run_config.font_variant if run_config.return_font_variant else None,
 954        )
 955
 956
 957class FontFreetypeDefaultEngine(
 958    Engine[
 959        NoneTypeEngineInitConfig,
 960        NoneTypeEngineInitResource,
 961        FontEngineRunConfig,
 962        Optional[TextLine],
 963    ]
 964):  # yapf: disable
 965
 966    @classmethod
 967    def get_type_name(cls) -> str:
 968        return 'freetype_default'
 969
 970    @classmethod
 971    def render_char_glyph(
 972        cls,
 973        run_config: FontEngineRunConfig,
 974        font_face: freetype.Face,
 975        char: str,
 976    ):
 977        load_char_flags = freetype.FT_LOAD_RENDER  # type: ignore
 978        if run_config.style.freetype_force_autohint:
 979            load_char_flags |= freetype.FT_LOAD_FORCE_AUTOHINT  # type: ignore
 980        font_face.load_char(char, load_char_flags)
 981
 982        glyph = font_face.glyph
 983        bitmap = glyph.bitmap
 984
 985        height = bitmap.rows
 986        width = bitmap.width
 987        assert width == bitmap.pitch
 988
 989        # (H, W), [0, 255]
 990        np_image = np.asarray(bitmap.buffer, dtype=np.uint8).reshape(height, width)
 991
 992        return build_char_glyph(run_config, char, glyph, np_image)
 993
 994    def run(self, run_config: FontEngineRunConfig, rng: RandomGenerator) -> Optional[TextLine]:
 995        font_face = load_freetype_font_face(run_config)
 996        return render_text_line_meta(
 997            run_config=run_config,
 998            font_face=font_face,
 999            func_render_char_glyph=self.render_char_glyph,
1000            rng=rng,
1001            cv_resize_interpolation_enlarge=sample_cv_resize_interpolation(rng),
1002            cv_resize_interpolation_shrink=sample_cv_resize_interpolation(
1003                rng,
1004                include_cv_inter_area=True,
1005            ),
1006        )
1007
1008
1009font_freetype_default_engine_executor_factory = EngineExecutorFactory(FontFreetypeDefaultEngine)
1010
1011
1012class FontFreetypeLcdEngine(
1013    Engine[
1014        NoneTypeEngineInitConfig,
1015        NoneTypeEngineInitResource,
1016        FontEngineRunConfig,
1017        Optional[TextLine],
1018    ]
1019):  # yapf: disable
1020
1021    @classmethod
1022    def get_type_name(cls) -> str:
1023        return 'freetype_lcd'
1024
1025    @classmethod
1026    def render_char_glyph(
1027        cls,
1028        run_config: FontEngineRunConfig,
1029        font_face: freetype.Face,
1030        lcd_hc_matrix: freetype.Matrix,
1031        char: str,
1032    ):
1033        load_char_flags = freetype.FT_LOAD_RENDER | freetype.FT_LOAD_TARGET_LCD  # type: ignore
1034        if run_config.style.freetype_force_autohint:
1035            load_char_flags |= freetype.FT_LOAD_FORCE_AUTOHINT  # type: ignore
1036        font_face.set_transform(lcd_hc_matrix, freetype.Vector(0, 0))
1037        font_face.load_char(char, load_char_flags)
1038
1039        glyph = font_face.glyph
1040        bitmap = glyph.bitmap
1041
1042        height = bitmap.rows
1043        pitch = bitmap.pitch
1044        flatten_width = bitmap.width
1045        width = flatten_width // 3
1046
1047        # (H, W, 3), [0, 255]
1048        np_image = np.asarray(bitmap.buffer, dtype=np.uint8).reshape(height, pitch)
1049        np_image = np_image[:, :width * 3].reshape(height, width, 3)
1050
1051        return build_char_glyph(run_config, char, glyph, np_image)
1052
1053    @classmethod
1054    def bind_render_char_glyph(cls, lcd_hc_matrix: freetype.Matrix):
1055        return lambda config, font_face, char: cls.render_char_glyph(
1056            config,
1057            font_face,
1058            lcd_hc_matrix,
1059            char,
1060        )
1061
1062    def run(self, run_config: FontEngineRunConfig, rng: RandomGenerator) -> Optional[TextLine]:
1063        lcd_compression_factor = 10
1064        font_face = load_freetype_font_face(
1065            run_config,
1066            lcd_compression_factor=lcd_compression_factor,
1067        )
1068        lcd_hc_matrix = build_freetype_font_face_lcd_hc_matrix(lcd_compression_factor)
1069        return render_text_line_meta(
1070            run_config=run_config,
1071            font_face=font_face,
1072            func_render_char_glyph=self.bind_render_char_glyph(lcd_hc_matrix),
1073            rng=rng,
1074            cv_resize_interpolation_enlarge=sample_cv_resize_interpolation(rng),
1075            cv_resize_interpolation_shrink=sample_cv_resize_interpolation(
1076                rng,
1077                include_cv_inter_area=True,
1078            ),
1079        )
1080
1081
1082font_freetype_lcd_engine_executor_factory = EngineExecutorFactory(FontFreetypeLcdEngine)
1083
1084
1085class FontFreetypeMonochromeEngine(
1086    Engine[
1087        NoneTypeEngineInitConfig,
1088        NoneTypeEngineInitResource,
1089        FontEngineRunConfig,
1090        Optional[TextLine],
1091    ]
1092):  # yapf: disable
1093
1094    @classmethod
1095    def get_type_name(cls) -> str:
1096        return 'freetype_monochrome'
1097
1098    @classmethod
1099    def render_char_glyph(
1100        cls,
1101        run_config: FontEngineRunConfig,
1102        font_face: freetype.Face,
1103        char: str,
1104    ):
1105        load_char_flags = freetype.FT_LOAD_RENDER | freetype.FT_LOAD_TARGET_MONO  # type: ignore
1106        if run_config.style.freetype_force_autohint:
1107            load_char_flags |= freetype.FT_LOAD_FORCE_AUTOHINT  # type: ignore
1108        font_face.load_char(char, load_char_flags)
1109
1110        glyph = font_face.glyph
1111        bitmap = glyph.bitmap
1112
1113        height = bitmap.rows
1114        width = bitmap.width
1115        pitch = bitmap.pitch
1116
1117        # Performance optimization.
1118        bitmap_buffer = bitmap.buffer
1119
1120        data = []
1121        for row_idx in range(height):
1122            row = []
1123            for pitch_idx in range(pitch):
1124                byte = bitmap_buffer[row_idx * pitch + pitch_idx]
1125                bits = []
1126                for _ in range(8):
1127                    bits.append(int((byte & 1) == 1))
1128                    byte = byte >> 1
1129                row.extend(bit * 255 for bit in reversed(bits))
1130            data.append(row[:width])
1131
1132        np_image = np.asarray(data, dtype=np.uint8)
1133        assert np_image.shape == (height, width)
1134
1135        return build_char_glyph(run_config, char, glyph, np_image)
1136
1137    def run(self, run_config: FontEngineRunConfig, rng: RandomGenerator) -> Optional[TextLine]:
1138        font_face = load_freetype_font_face(run_config)
1139        return render_text_line_meta(
1140            run_config=run_config,
1141            font_face=font_face,
1142            func_render_char_glyph=self.render_char_glyph,
1143            rng=rng,
1144            cv_resize_interpolation_enlarge=cv.INTER_NEAREST_EXACT,
1145            cv_resize_interpolation_shrink=cv.INTER_NEAREST_EXACT,
1146        )
1147
1148
1149font_freetype_monochrome_engine_executor_factory = EngineExecutorFactory(
1150    FontFreetypeMonochromeEngine
1151)
def estimate_font_size(config: vkit.engine.font.type.FontEngineRunConfig):
42def estimate_font_size(config: FontEngineRunConfig):
43    style = config.style
44
45    # Estimate the font size based on height or width.
46    if config.glyph_sequence == FontEngineRunConfigGlyphSequence.HORI_DEFAULT:
47        font_size = round(config.height * style.font_size_ratio)
48    elif config.glyph_sequence == FontEngineRunConfigGlyphSequence.VERT_DEFAULT:
49        font_size = round(config.width * style.font_size_ratio)
50    else:
51        raise NotImplementedError()
52
53    font_size = int(np.clip(font_size, style.font_size_min, style.font_size_max))
54    return font_size
def load_freetype_font_face( run_config: vkit.engine.font.type.FontEngineRunConfig, lcd_compression_factor: Union[int, NoneType] = None):
57def load_freetype_font_face(
58    run_config: FontEngineRunConfig,
59    lcd_compression_factor: Optional[int] = None,
60):
61    font_variant = run_config.font_variant
62    if font_variant.is_ttc:
63        assert font_variant.ttc_font_index is not None
64        font_face = freetype.Face(str(font_variant.font_file), index=font_variant.ttc_font_index)
65    else:
66        font_face = freetype.Face(str(font_variant.font_file))
67
68    font_size = estimate_font_size(run_config)
69
70    # 1. "The nominal width, in 26.6 fractional points" and "The 26.6 fixed float format
71    #    used to define fractional pixel coordinates. Here, 1 unit = 1/64 pixel", hence
72    #    we need to multiple 64 here.
73    # 2. `height` defaults to 0 and "If either the character width or
74    #    height is zero, it is set equal to the other value",
75    #    hence no need to set the `height` parameter.
76    # 3. `vres` for vertical resolution, and defaults to 72.
77    #    Since "pixel_size = point_size * resolution / 72", setting resolution to 72 equalize
78    #    point_size and pixel_size. Since "If either the horizontal or vertical resolution is zero,
79    #    it is set equal to the other value" and `vres` defaults to 72, we don't need to set the
80    #    `vres` parameter.
81    # 4. `hres` for horizontal resolution, and defaults to 72. If `lcd_compression_factor` is set,
82    #    we need to cancel the sub-pixel rendering effect, by scaling up the same factor to `hres`.
83    hres = 72
84    if lcd_compression_factor is not None:
85        hres *= lcd_compression_factor
86    font_face.set_char_size(width=font_size * 64, hres=hres)
87
88    return font_face
def build_freetype_font_face_lcd_hc_matrix(lcd_compression_factor: int):
91def build_freetype_font_face_lcd_hc_matrix(lcd_compression_factor: int):
92    # "hc" stands for "horizontal compression".
93    return freetype.Matrix(
94        int((1 / lcd_compression_factor) * 0x10000),
95        int(0.0 * 0x10000),
96        int(0.0 * 0x10000),
97        int(1.0 * 0x10000),
98    )
def trim_char_np_image_vert(np_image: numpy.ndarray):
101def trim_char_np_image_vert(np_image: np.ndarray):
102    # (H, W) or (H, W, 3)
103    np_vert_max: np.ndarray = np.amax(np_image, axis=1)
104    if np_vert_max.ndim == 2:
105        np_vert_max = np.amax(np_vert_max, axis=1)
106    assert np_vert_max.ndim == 1
107
108    np_vert_nonzero = np.nonzero(np_vert_max)[0]
109    if len(np_vert_nonzero) == 0:
110        raise RuntimeError('trim_char_np_image_vert: empty np_image.')
111
112    up = int(np_vert_nonzero[0])
113    down = int(np_vert_nonzero[-1])
114
115    height = np_image.shape[0]
116    return np_image[up:down + 1], up, height - 1 - down
def trim_char_np_image_hori(np_image: numpy.ndarray):
119def trim_char_np_image_hori(np_image: np.ndarray):
120    # (H, W) or (H, W, 3)
121    np_hori_max: np.ndarray = np.amax(np_image, axis=0)
122    if np_hori_max.ndim == 2:
123        np_hori_max = np.amax(np_hori_max, axis=1)
124    assert np_hori_max.ndim == 1
125
126    np_hori_nonzero = np.nonzero(np_hori_max)[0]
127    if len(np_hori_nonzero) == 0:
128        raise RuntimeError('trim_char_np_image_hori: empty np_image.')
129
130    left = int(np_hori_nonzero[0])
131    right = int(np_hori_nonzero[-1])
132
133    width = np_image.shape[1]
134    return np_image[:, left:right + 1], left, width - 1 - right
def build_char_glyph( config: vkit.engine.font.type.FontEngineRunConfig, char: str, glyph: Any, np_image: numpy.ndarray):
137def build_char_glyph(
138    config: FontEngineRunConfig,
139    char: str,
140    glyph: Any,
141    np_image: np.ndarray,
142):
143    assert not char.isspace()
144
145    # References:
146    # https://freetype.org/freetype2/docs/tutorial/step2.html
147    # https://freetype-py.readthedocs.io/en/latest/glyph_slot.html
148
149    # "the distance from the baseline to the top-most glyph scanline"
150    ascent = glyph.bitmap_top
151
152    # "It is always zero for horizontal layouts, and positive for vertical layouts."
153    assert glyph.advance.y == 0
154
155    # Trim vertically to get pad_up & pad_down.
156    np_image, pad_up, pad_down = trim_char_np_image_vert(np_image)
157    ascent -= pad_up
158
159    # "bitmap’s left bearing"
160    # NOTE: `bitmap_left` could be negative, we simply ignore the negative value.
161    pad_left = max(0, glyph.bitmap_left)
162
163    # "It is always zero for horizontal layouts, and zero for vertical ones."
164    assert glyph.advance.x > 0
165    width = np_image.shape[1]
166    # Aforementioned, 1 unit = 1/64 pixel.
167    pad_right = round(glyph.advance.x / 64) - pad_left - width
168    # Apply defensive clipping.
169    pad_right = max(0, pad_right)
170
171    # Trim horizontally to increase pad_left & pad_right.
172    np_image, pad_left_inc, pad_right_inc = trim_char_np_image_hori(np_image)
173    pad_left += pad_left_inc
174    pad_right += pad_right_inc
175
176    score_map = None
177    if np_image.ndim == 2:
178        # Apply gamma correction.
179        np_alpha = np.power(
180            np_image.astype(np.float32) / 255.0,
181            config.style.glyph_color_gamma,
182        )
183        score_map = ScoreMap(mat=np_alpha)
184
185    # Reference statistics.
186    font_variant = config.font_variant
187    tag_to_font_glyph_info = font_variant.font_glyph_info_collection.tag_to_font_glyph_info
188
189    assert char in font_variant.char_to_tags
190    tags = font_variant.char_to_tags[char]
191
192    font_glyph_info = None
193    for tag in tags:
194        assert tag in tag_to_font_glyph_info
195        cur_font_glyph_info = tag_to_font_glyph_info[tag]
196        if font_glyph_info is None:
197            font_glyph_info = cur_font_glyph_info
198        else:
199            assert font_glyph_info == cur_font_glyph_info
200
201    assert font_glyph_info is not None
202
203    font_size = estimate_font_size(config)
204    ref_ascent_plus_pad_up = round(
205        font_glyph_info.ascent_plus_pad_up_min_to_font_size_ratio * font_size
206    )
207    ref_char_height = round(font_glyph_info.height_min_to_font_size_ratio * font_size)
208    ref_char_width = round(font_glyph_info.width_min_to_font_size_ratio * font_size)
209
210    return CharGlyph(
211        char=char,
212        image=Image(mat=np_image),
213        score_map=score_map,
214        ascent=ascent,
215        pad_up=pad_up,
216        pad_down=pad_down,
217        pad_left=pad_left,
218        pad_right=pad_right,
219        ref_ascent_plus_pad_up=ref_ascent_plus_pad_up,
220        ref_char_height=ref_char_height,
221        ref_char_width=ref_char_width,
222    )
def render_char_glyphs_from_text( run_config: vkit.engine.font.type.FontEngineRunConfig, font_face: freetype.Face, func_render_char_glyph: Callable[[vkit.engine.font.type.FontEngineRunConfig, freetype.Face, str], vkit.engine.font.type.CharGlyph], chars: Sequence[str]):
225def render_char_glyphs_from_text(
226    run_config: FontEngineRunConfig,
227    font_face: freetype.Face,
228    func_render_char_glyph: Callable[[FontEngineRunConfig, freetype.Face, str], CharGlyph],
229    chars: Sequence[str],
230):
231    char_glyphs: List[CharGlyph] = []
232    prev_num_spaces_for_char_glyphs: List[int] = []
233    num_spaces = 0
234    for idx, char in enumerate(chars):
235        if char.isspace():
236            num_spaces += 1
237            continue
238
239        char_glyphs.append(func_render_char_glyph(run_config, font_face, char))
240
241        if idx == 0 and num_spaces > 0:
242            raise RuntimeError('Leading space(s) detected.')
243        prev_num_spaces_for_char_glyphs.append(num_spaces)
244        num_spaces = 0
245
246    if num_spaces > 0:
247        raise RuntimeError('Trailing space(s) detected.')
248
249    return char_glyphs, prev_num_spaces_for_char_glyphs
def get_kerning_limits_hori_default( char_glyphs: Sequence[vkit.engine.font.type.CharGlyph], prev_num_spaces_for_char_glyphs: Sequence[int]):
252def get_kerning_limits_hori_default(
253    char_glyphs: Sequence[CharGlyph],
254    prev_num_spaces_for_char_glyphs: Sequence[int],
255):
256    assert char_glyphs
257    ascent_max = max(char_glyph.ascent for char_glyph in char_glyphs)
258
259    kerning_limits: List[int] = []
260
261    prev_glyph_mask = None
262    prev_np_glyph_mask = None
263    prev_glyph_mask_up = None
264    prev_glyph_mask_down = None
265    for char_glyph, prev_num_spaces in zip(char_glyphs, prev_num_spaces_for_char_glyphs):
266        glyph_mask = char_glyph.get_glyph_mask()
267        np_glyph_mask = glyph_mask.mat
268        glyph_mask_up = ascent_max - char_glyph.ascent
269        glyph_mask_down = glyph_mask_up + np_glyph_mask.shape[0] - 1
270
271        if prev_num_spaces == 0 and prev_np_glyph_mask is not None:
272            assert prev_glyph_mask is not None
273            assert prev_glyph_mask_up is not None
274            assert prev_glyph_mask_down is not None
275
276            overlap_up = max(prev_glyph_mask_up, glyph_mask_up)
277            overlap_down = min(prev_glyph_mask_down, glyph_mask_down)
278            if overlap_up <= overlap_down:
279                overlap_prev_np_glyph_mask = \
280                    prev_np_glyph_mask[overlap_up - prev_glyph_mask_up:
281                                       overlap_down - prev_glyph_mask_up + 1]
282                overlap_np_glyph_mask = \
283                    np_glyph_mask[overlap_up - glyph_mask_up:
284                                  overlap_down - glyph_mask_up + 1]
285
286                kerning_limit = 1
287                while kerning_limit < prev_glyph_mask.width / 2 \
288                        and kerning_limit < glyph_mask.width / 2:
289                    prev_np_glyph_mask_tail = overlap_prev_np_glyph_mask[:, -kerning_limit:]
290                    np_glyph_mask_head = overlap_np_glyph_mask[:, :kerning_limit]
291                    if (prev_np_glyph_mask_tail & np_glyph_mask_head).any():
292                        # Intersection detected.
293                        kerning_limit -= 1
294                        break
295                    kerning_limit += 1
296
297                kerning_limits.append(kerning_limit)
298
299            else:
300                # Not overlapped.
301                kerning_limits.append(0)
302
303        else:
304            # Isolated by word space or being the first glyph, skip.
305            kerning_limits.append(0)
306
307        prev_glyph_mask = glyph_mask
308        prev_np_glyph_mask = np_glyph_mask
309        prev_glyph_mask_up = glyph_mask_up
310        prev_glyph_mask_down = glyph_mask_down
311
312    return kerning_limits
def render_char_glyphs_in_text_line( style: vkit.engine.font.type.FontEngineRunConfigStyle, text_line_height: int, text_line_width: int, char_glyphs: Sequence[vkit.engine.font.type.CharGlyph], char_boxes: Sequence[vkit.engine.font.type.CharBox]):
315def render_char_glyphs_in_text_line(
316    style: FontEngineRunConfigStyle,
317    text_line_height: int,
318    text_line_width: int,
319    char_glyphs: Sequence[CharGlyph],
320    char_boxes: Sequence[CharBox],
321):
322    # Genrate text line image.
323    np_image = np.full((text_line_height, text_line_width, 3), 255, dtype=np.uint8)
324    np_mask = np.zeros((text_line_height, text_line_width), dtype=np.uint8)
325    score_map = None
326
327    if char_glyphs[0].image.mat.ndim == 2:
328        score_map = ScoreMap.from_shape((text_line_height, text_line_width))
329
330        # Default or monochrome.
331        for char_glyph, char_box in zip(char_glyphs, char_boxes):
332            assert char_glyph.score_map
333
334            char_glyph_mask = char_glyph.get_glyph_mask(box=char_box.box)
335
336            # Fill color based on RGBA & alpha.
337            np_char_image = np.full(
338                (char_glyph.height, char_glyph.width, 4),
339                (*style.glyph_color, 0),
340                dtype=np.uint8,
341            )
342            np_char_image[:, :, 3] = (char_glyph.score_map.mat * 255).astype(np.uint8)
343
344            # To RGB.
345            np_char_image = cv.cvtColor(np_char_image, cv.COLOR_RGBA2RGB)
346
347            # Paste to text line.
348            char_glyph_mask.fill_np_array(np_image, np_char_image)
349            char_glyph_mask.fill_np_array(np_mask, 1)
350            char_box.box.fill_score_map(
351                score_map,
352                char_glyph.score_map,
353                keep_max_value=True,
354            )
355
356    elif char_glyphs[0].image.mat.ndim == 3:
357        # LCD.
358        for char_glyph, char_box in zip(char_glyphs, char_boxes):
359            char_glyph_mask = char_glyph.get_glyph_mask(box=char_box.box)
360
361            # NOTE: the `glyph_color` option is ignored in LCD mode.
362            # Gamma correction.
363            np_char_image = np.power(
364                char_glyph.image.mat / 255.0,
365                style.glyph_color_gamma,
366            )
367            np_char_image = ((1 - np_char_image) * 255).astype(np.uint8)  # type: ignore
368
369            # Paste to text line.
370            char_glyph_mask.fill_np_array(np_image, np_char_image)
371            char_glyph_mask.fill_np_array(np_mask, 1)
372
373    else:
374        raise NotImplementedError()
375
376    return (
377        Image(mat=np_image),
378        Mask(mat=np_mask),
379        score_map,
380        char_boxes,
381    )
def place_char_glyphs_in_text_line_hori_default( run_config: vkit.engine.font.type.FontEngineRunConfig, char_glyphs: Sequence[vkit.engine.font.type.CharGlyph], prev_num_spaces_for_char_glyphs: Sequence[int], kerning_limits: Sequence[int], rng: numpy.random._generator.Generator):
384def place_char_glyphs_in_text_line_hori_default(
385    run_config: FontEngineRunConfig,
386    char_glyphs: Sequence[CharGlyph],
387    prev_num_spaces_for_char_glyphs: Sequence[int],
388    kerning_limits: Sequence[int],
389    rng: RandomGenerator,
390):
391    style = run_config.style
392
393    assert char_glyphs
394    char_widths_avg = np.mean([char_glyph.width for char_glyph in char_glyphs])
395
396    char_space_min = char_widths_avg * style.char_space_min
397    char_space_max = char_widths_avg * style.char_space_max
398    char_space_mean = char_widths_avg * style.char_space_mean
399    char_space_std = char_widths_avg * style.char_space_std
400
401    word_space_min = char_widths_avg * style.word_space_min
402    word_space_max = char_widths_avg * style.word_space_max
403    word_space_mean = char_widths_avg * style.word_space_mean
404    word_space_std = char_widths_avg * style.word_space_std
405
406    ascent_plus_pad_up_max = max(
407        itertools.chain.from_iterable(
408            (char_glyph.ascent + char_glyph.pad_up, char_glyph.ref_ascent_plus_pad_up)
409            for char_glyph in char_glyphs
410        )
411    )
412
413    text_line_height = max(char_glyph.ref_char_height for char_glyph in char_glyphs)
414
415    char_boxes: List[CharBox] = []
416    hori_offset = 0
417    for char_idx, (char_glyph, prev_num_spaces, kerning_limit) in enumerate(
418        zip(
419            char_glyphs,
420            prev_num_spaces_for_char_glyphs,
421            kerning_limits,
422        )
423    ):
424        # "Stick" chars together.
425        hori_offset -= kerning_limit
426
427        # Shift by space.
428        if prev_num_spaces > 0:
429            # Random word space(s).
430            space = 0
431            for _ in range(prev_num_spaces):
432                space += round(
433                    np.clip(
434                        rng.normal(loc=word_space_mean, scale=word_space_std),
435                        word_space_min,
436                        word_space_max,
437                    )  # type: ignore
438                )
439
440        else:
441            # Random char space.
442            space = round(
443                np.clip(
444                    rng.normal(loc=char_space_mean, scale=char_space_std),
445                    char_space_min,
446                    char_space_max,
447                )  # type: ignore
448            )
449
450        hori_offset += space
451
452        # Place char box.
453        up = ascent_plus_pad_up_max - char_glyph.ascent
454        down = up + char_glyph.height - 1
455
456        left = hori_offset + char_glyph.pad_left
457        if char_idx == 0:
458            # Ignore the leading padding.
459            left = 0
460        right = left + char_glyph.width - 1
461
462        assert not char_glyph.char.isspace()
463        char_boxes.append(
464            CharBox(
465                char=char_glyph.char,
466                box=Box(
467                    up=up,
468                    down=down,
469                    left=left,
470                    right=right,
471                ),
472            )
473        )
474
475        # Update the height of text line.
476        text_line_height = max(text_line_height, down + 1 + char_glyph.pad_down)
477
478        # Move offset.
479        hori_offset = right + 1
480        if char_idx < len(char_glyphs) - 1:
481            hori_offset += char_glyph.pad_right
482
483    text_line_width = hori_offset
484
485    return render_char_glyphs_in_text_line(
486        style=style,
487        text_line_height=text_line_height,
488        text_line_width=text_line_width,
489        char_glyphs=char_glyphs,
490        char_boxes=char_boxes,
491    )
def place_char_glyphs_in_text_line_vert_default( run_config: vkit.engine.font.type.FontEngineRunConfig, char_glyphs: Sequence[vkit.engine.font.type.CharGlyph], prev_num_spaces_for_char_glyphs: Sequence[int], rng: numpy.random._generator.Generator):
494def place_char_glyphs_in_text_line_vert_default(
495    run_config: FontEngineRunConfig,
496    char_glyphs: Sequence[CharGlyph],
497    prev_num_spaces_for_char_glyphs: Sequence[int],
498    rng: RandomGenerator,
499):
500    style = run_config.style
501
502    assert char_glyphs
503    char_widths_avg = np.mean([char_glyph.width for char_glyph in char_glyphs])
504
505    char_space_min = char_widths_avg * style.char_space_min
506    char_space_max = char_widths_avg * style.char_space_max
507    char_space_mean = char_widths_avg * style.char_space_mean
508    char_space_std = char_widths_avg * style.char_space_std
509
510    word_space_min = char_widths_avg * style.word_space_min
511    word_space_max = char_widths_avg * style.word_space_max
512    word_space_mean = char_widths_avg * style.word_space_mean
513    word_space_std = char_widths_avg * style.word_space_std
514
515    text_line_width = max(
516        itertools.chain.from_iterable((
517            char_glyph.pad_left + char_glyph.width + char_glyph.pad_right,
518            char_glyph.ref_char_width,
519        ) for char_glyph in char_glyphs)
520    )
521
522    text_line_width_mid = text_line_width // 2
523
524    char_boxes: List[CharBox] = []
525    vert_offset = 0
526    for char_idx, (char_glyph, prev_num_spaces) in enumerate(
527        zip(char_glyphs, prev_num_spaces_for_char_glyphs)
528    ):
529        # Shift by space.
530        if prev_num_spaces > 0:
531            # Random word space(s).
532            space = 0
533            for _ in range(prev_num_spaces):
534                space += round(
535                    np.clip(
536                        rng.normal(loc=word_space_mean, scale=word_space_std),
537                        word_space_min,
538                        word_space_max,
539                    )  # type: ignore
540                )
541
542        else:
543            # Random char space.
544            space = round(
545                np.clip(
546                    rng.normal(loc=char_space_mean, scale=char_space_std),
547                    char_space_min,
548                    char_space_max,
549                )  # type: ignore
550            )
551
552        vert_offset += space
553
554        # Place char box.
555        up = vert_offset + char_glyph.pad_up
556        if char_idx == 0:
557            # Ignore the leading padding.
558            up = 0
559
560        down = up + char_glyph.height - 1
561
562        # Vertical align in middle.
563        left = text_line_width_mid - char_glyph.width // 2
564        right = left + char_glyph.width - 1
565
566        assert not char_glyph.char.isspace()
567        char_boxes.append(
568            CharBox(
569                char=char_glyph.char,
570                box=Box(
571                    up=up,
572                    down=down,
573                    left=left,
574                    right=right,
575                ),
576            )
577        )
578
579        # Move offset.
580        vert_offset = down + 1
581        if char_idx < len(char_glyphs) - 1:
582            vert_offset += char_glyph.pad_down
583
584    text_line_height = vert_offset
585
586    return render_char_glyphs_in_text_line(
587        style=style,
588        text_line_height=text_line_height,
589        text_line_width=text_line_width,
590        char_glyphs=char_glyphs,
591        char_boxes=char_boxes,
592    )
def resize_and_trim_text_line_hori_default( run_config: vkit.engine.font.type.FontEngineRunConfig, cv_resize_interpolation_enlarge: int, cv_resize_interpolation_shrink: int, image: vkit.element.image.Image, mask: vkit.element.mask.Mask, score_map: Union[vkit.element.score_map.ScoreMap, NoneType], char_boxes: Sequence[vkit.engine.font.type.CharBox], char_glyphs: Sequence[vkit.engine.font.type.CharGlyph]):
595def resize_and_trim_text_line_hori_default(
596    run_config: FontEngineRunConfig,
597    cv_resize_interpolation_enlarge: int,
598    cv_resize_interpolation_shrink: int,
599    image: Image,
600    mask: Mask,
601    score_map: Optional[ScoreMap],
602    char_boxes: Sequence[CharBox],
603    char_glyphs: Sequence[CharGlyph],
604):
605    # Resize if image height too small or too large.
606    is_too_small = (image.height / run_config.height < 0.8)
607    is_too_large = (image.height > run_config.height)
608
609    cv_resize_interpolation = cv_resize_interpolation_enlarge
610    if is_too_large:
611        cv_resize_interpolation = cv_resize_interpolation_shrink
612
613    if is_too_small or is_too_large:
614        resized_image = image.to_resized_image(
615            resized_height=run_config.height,
616            cv_resize_interpolation=cv_resize_interpolation,
617        )
618        resized_mask = mask.to_resized_mask(
619            resized_height=run_config.height,
620            cv_resize_interpolation=cv_resize_interpolation,
621        )
622        resized_char_boxes = [
623            char_box.to_conducted_resized_char_box(
624                shapable_or_shape=image,
625                resized_height=run_config.height,
626            ) for char_box in char_boxes
627        ]
628
629        image = resized_image
630        mask = resized_mask
631        char_boxes = resized_char_boxes
632
633        if score_map:
634            score_map = score_map.to_resized_score_map(
635                resized_height=run_config.height,
636                cv_resize_interpolation=cv_resize_interpolation,
637            )
638
639    # Pad vertically.
640    if image.height != run_config.height:
641        pad_vert = run_config.height - image.height
642        assert pad_vert > 0
643        pad_up = pad_vert // 2
644        pad_down = pad_vert - pad_up
645
646        np_image = np.full((run_config.height, image.width, 3), 255, dtype=np.uint8)
647        np_image[pad_up:-pad_down] = image.mat
648        image.assign_mat(np_image)
649
650        np_mask = np.zeros((run_config.height, image.width), dtype=np.uint8)
651        np_mask[pad_up:-pad_down] = mask.mat
652        mask.assign_mat(np_mask)
653
654        padded_char_boxes = []
655        for char_box in char_boxes:
656            box = attrs.evolve(
657                char_box.box,
658                up=char_box.up + pad_up,
659                down=char_box.down + pad_up,
660            )
661            padded_char_boxes.append(attrs.evolve(char_box, box=box))
662        char_boxes = padded_char_boxes
663
664        if score_map:
665            padded_score_map = ScoreMap.from_shape((run_config.height, image.width))
666            with padded_score_map.writable_context:
667                padded_score_map.mat[pad_up:-pad_down] = score_map.mat
668            score_map = padded_score_map
669
670    # Trim.
671    if image.width > run_config.width:
672        last_char_box_idx = len(char_boxes) - 1
673        while last_char_box_idx >= 0 and char_boxes[last_char_box_idx].right >= run_config.width:
674            last_char_box_idx -= 1
675
676        if last_char_box_idx == len(char_boxes) - 1:
677            # Corner case: char_boxes[-1].right < config.width but mage.width > config.width.
678            # This is caused by glyph padding. The solution is to drop this char.
679            last_char_box_idx -= 1
680
681        if last_char_box_idx < 0 or char_boxes[last_char_box_idx].right >= run_config.width:
682            # Cannot trim.
683            return None, None, None, None, -1
684
685        last_char_box = char_boxes[last_char_box_idx]
686        last_char_box_right = last_char_box.right
687
688        # Clean up residual pixels.
689        first_trimed_char_box = char_boxes[last_char_box_idx + 1]
690        if first_trimed_char_box.left <= last_char_box_right:
691            first_trimed_char_glyph = char_glyphs[last_char_box_idx + 1]
692
693            first_trimed_char_glyph_mask = first_trimed_char_glyph.get_glyph_mask(
694                box=first_trimed_char_box.box,
695                enable_resize=True,
696                cv_resize_interpolation=cv_resize_interpolation,
697            )
698            first_trimed_char_glyph_mask.fill_image(image, (255, 255, 255))
699            first_trimed_char_glyph_mask.fill_mask(mask, 0)
700
701            if first_trimed_char_glyph.score_map:
702                assert score_map
703
704                first_trimed_char_score_map = first_trimed_char_glyph.score_map
705                if first_trimed_char_score_map.shape != first_trimed_char_box.shape:
706                    first_trimed_char_score_map = first_trimed_char_score_map.to_resized_score_map(
707                        resized_height=first_trimed_char_box.height,
708                        resized_width=first_trimed_char_box.width,
709                        cv_resize_interpolation=cv_resize_interpolation,
710                    )
711
712                last_char_score_map = char_glyphs[last_char_box_idx].score_map
713                assert last_char_score_map
714                if last_char_score_map.shape != last_char_box.shape:
715                    last_char_score_map = last_char_score_map.to_resized_score_map(
716                        resized_height=last_char_box.height,
717                        resized_width=last_char_box.width,
718                        cv_resize_interpolation=cv_resize_interpolation,
719                    )
720
721                first_trimed_char_box.box.fill_score_map(score_map, 0)
722                last_char_box.box.fill_score_map(
723                    score_map,
724                    last_char_score_map,
725                    keep_max_value=True,
726                )
727
728        char_boxes = char_boxes[:last_char_box_idx + 1]
729        image.assign_mat(image.mat[:, :last_char_box_right + 1])
730        mask.assign_mat(mask.mat[:, :last_char_box_right + 1])
731
732        if score_map:
733            score_map.assign_mat(score_map.mat[:, :last_char_box_right + 1])
734
735    return image, mask, score_map, char_boxes, cv_resize_interpolation
def resize_and_trim_text_line_vert_default( run_config: vkit.engine.font.type.FontEngineRunConfig, cv_resize_interpolation_enlarge: int, cv_resize_interpolation_shrink: int, image: vkit.element.image.Image, mask: vkit.element.mask.Mask, score_map: Union[vkit.element.score_map.ScoreMap, NoneType], char_boxes: Sequence[vkit.engine.font.type.CharBox]):
738def resize_and_trim_text_line_vert_default(
739    run_config: FontEngineRunConfig,
740    cv_resize_interpolation_enlarge: int,
741    cv_resize_interpolation_shrink: int,
742    image: Image,
743    mask: Mask,
744    score_map: Optional[ScoreMap],
745    char_boxes: Sequence[CharBox],
746):
747    # Resize if image width too small or too large.
748    is_too_small = (image.width / run_config.width < 0.8)
749    is_too_large = (image.width > run_config.width)
750
751    cv_resize_interpolation = cv_resize_interpolation_enlarge
752    if is_too_large:
753        cv_resize_interpolation = cv_resize_interpolation_shrink
754
755    if is_too_small or is_too_large:
756        resized_image = image.to_resized_image(
757            resized_width=run_config.width,
758            cv_resize_interpolation=cv_resize_interpolation,
759        )
760        resized_mask = mask.to_resized_mask(
761            resized_width=run_config.width,
762            cv_resize_interpolation=cv_resize_interpolation,
763        )
764        resized_char_boxes = [
765            char_box.to_conducted_resized_char_box(
766                shapable_or_shape=image,
767                resized_width=run_config.width,
768            ) for char_box in char_boxes
769        ]
770
771        image = resized_image
772        mask = resized_mask
773        char_boxes = resized_char_boxes
774
775        if score_map:
776            score_map = score_map.to_resized_score_map(
777                resized_width=run_config.width,
778                cv_resize_interpolation=cv_resize_interpolation,
779            )
780
781    # Pad horizontally.
782    if image.width != run_config.width:
783        pad_hori = run_config.width - image.width
784        assert pad_hori > 0
785        pad_left = pad_hori // 2
786        pad_right = pad_hori - pad_left
787
788        np_image = np.full((image.height, run_config.width, 3), 255, dtype=np.uint8)
789        np_image[:, pad_left:-pad_right] = image.mat
790        image.assign_mat(np_image)
791
792        np_mask = np.zeros((image.height, run_config.width), dtype=np.uint8)
793        np_mask[:, pad_left:-pad_right] = mask.mat
794        mask.assign_mat(np_mask)
795
796        padded_char_boxes = []
797        for char_box in char_boxes:
798            box = attrs.evolve(
799                char_box.box,
800                left=char_box.left + pad_left,
801                right=char_box.right + pad_right,
802            )
803            padded_char_boxes.append(attrs.evolve(char_box, box=box))
804        char_boxes = padded_char_boxes
805
806        if score_map:
807            padded_score_map = ScoreMap.from_shape((image.height, run_config.width))
808            padded_score_map.mat[:, pad_left:-pad_right] = score_map.mat
809            score_map = padded_score_map
810
811    # Trim.
812    if image.height > run_config.height:
813        last_char_box_idx = len(char_boxes) - 1
814        while last_char_box_idx >= 0 and char_boxes[last_char_box_idx].down >= run_config.height:
815            last_char_box_idx -= 1
816
817        if last_char_box_idx == len(char_boxes) - 1:
818            last_char_box_idx -= 1
819
820        if last_char_box_idx < 0 or char_boxes[last_char_box_idx].down >= run_config.height:
821            # Cannot trim.
822            return None, None, None, None, -1
823
824        last_char_box_down = char_boxes[last_char_box_idx].down
825        char_boxes = char_boxes[:last_char_box_idx + 1]
826        image.assign_mat(image.mat[:last_char_box_down + 1])
827        mask.assign_mat(mask.mat[:last_char_box_down + 1])
828
829        if score_map:
830            score_map.assign_mat(score_map.mat[:last_char_box_down + 1])
831
832    return image, mask, score_map, char_boxes, cv_resize_interpolation
def render_text_line_meta( run_config: vkit.engine.font.type.FontEngineRunConfig, font_face: freetype.Face, func_render_char_glyph: Callable[[vkit.engine.font.type.FontEngineRunConfig, freetype.Face, str], vkit.engine.font.type.CharGlyph], rng: numpy.random._generator.Generator, cv_resize_interpolation_enlarge: int = 2, cv_resize_interpolation_shrink: int = 3):
835def render_text_line_meta(
836    run_config: FontEngineRunConfig,
837    font_face: freetype.Face,
838    func_render_char_glyph: Callable[[FontEngineRunConfig, freetype.Face, str], CharGlyph],
839    rng: RandomGenerator,
840    cv_resize_interpolation_enlarge: int = cv.INTER_CUBIC,
841    cv_resize_interpolation_shrink: int = cv.INTER_AREA,
842):
843    (
844        char_glyphs,
845        prev_num_spaces_for_char_glyphs,
846    ) = render_char_glyphs_from_text(
847        run_config=run_config,
848        font_face=font_face,
849        func_render_char_glyph=func_render_char_glyph,
850        chars=run_config.chars,
851    )
852    if not char_glyphs:
853        return None
854
855    if run_config.glyph_sequence == FontEngineRunConfigGlyphSequence.HORI_DEFAULT:
856        kerning_limits = get_kerning_limits_hori_default(
857            char_glyphs,
858            prev_num_spaces_for_char_glyphs,
859        )
860        (
861            image,
862            mask,
863            score_map,
864            char_boxes,
865        ) = place_char_glyphs_in_text_line_hori_default(
866            run_config=run_config,
867            char_glyphs=char_glyphs,
868            prev_num_spaces_for_char_glyphs=prev_num_spaces_for_char_glyphs,
869            kerning_limits=kerning_limits,
870            rng=rng,
871        )
872        (
873            image,
874            mask,
875            score_map,
876            char_boxes,
877            cv_resize_interpolation,
878        ) = resize_and_trim_text_line_hori_default(
879            run_config=run_config,
880            cv_resize_interpolation_enlarge=cv_resize_interpolation_enlarge,
881            cv_resize_interpolation_shrink=cv_resize_interpolation_shrink,
882            image=image,
883            mask=mask,
884            score_map=score_map,
885            char_boxes=char_boxes,
886            char_glyphs=char_glyphs,
887        )
888        is_hori = True
889
890    elif run_config.glyph_sequence == FontEngineRunConfigGlyphSequence.VERT_DEFAULT:
891        # NOTE: No kerning limit detection for VERT_DEFAULT mode.
892        (
893            image,
894            mask,
895            score_map,
896            char_boxes,
897        ) = place_char_glyphs_in_text_line_vert_default(
898            run_config=run_config,
899            char_glyphs=char_glyphs,
900            prev_num_spaces_for_char_glyphs=prev_num_spaces_for_char_glyphs,
901            rng=rng,
902        )
903        (
904            image,
905            mask,
906            score_map,
907            char_boxes,
908            cv_resize_interpolation,
909        ) = resize_and_trim_text_line_vert_default(
910            run_config=run_config,
911            cv_resize_interpolation_enlarge=cv_resize_interpolation_enlarge,
912            cv_resize_interpolation_shrink=cv_resize_interpolation_shrink,
913            image=image,
914            mask=mask,
915            score_map=score_map,
916            char_boxes=char_boxes,
917        )
918        is_hori = False
919
920    else:
921        raise NotImplementedError()
922
923    if image is None:
924        return None
925    else:
926        assert mask is not None
927        assert char_boxes is not None
928
929        char_idx = 0
930        non_space_count = 0
931        while char_idx < len(run_config.chars) and non_space_count < len(char_boxes):
932            if not run_config.chars[char_idx].isspace():
933                non_space_count += 1
934            char_idx += 1
935        assert non_space_count == len(char_boxes)
936
937        box = Box.from_shapable(image)
938        image = image.to_box_attached(box)
939        mask = mask.to_box_attached(box)
940        if score_map:
941            score_map = score_map.to_box_attached(box)
942
943        return TextLine(
944            image=image,
945            mask=mask,
946            score_map=score_map,
947            char_boxes=char_boxes,
948            char_glyphs=char_glyphs[:len(char_boxes)],
949            cv_resize_interpolation=cv_resize_interpolation,
950            font_size=estimate_font_size(run_config),
951            style=run_config.style,
952            text=''.join(run_config.chars[:char_idx]),
953            is_hori=is_hori,
954            font_variant=run_config.font_variant if run_config.return_font_variant else None,
955        )
 958class FontFreetypeDefaultEngine(
 959    Engine[
 960        NoneTypeEngineInitConfig,
 961        NoneTypeEngineInitResource,
 962        FontEngineRunConfig,
 963        Optional[TextLine],
 964    ]
 965):  # yapf: disable
 966
 967    @classmethod
 968    def get_type_name(cls) -> str:
 969        return 'freetype_default'
 970
 971    @classmethod
 972    def render_char_glyph(
 973        cls,
 974        run_config: FontEngineRunConfig,
 975        font_face: freetype.Face,
 976        char: str,
 977    ):
 978        load_char_flags = freetype.FT_LOAD_RENDER  # type: ignore
 979        if run_config.style.freetype_force_autohint:
 980            load_char_flags |= freetype.FT_LOAD_FORCE_AUTOHINT  # type: ignore
 981        font_face.load_char(char, load_char_flags)
 982
 983        glyph = font_face.glyph
 984        bitmap = glyph.bitmap
 985
 986        height = bitmap.rows
 987        width = bitmap.width
 988        assert width == bitmap.pitch
 989
 990        # (H, W), [0, 255]
 991        np_image = np.asarray(bitmap.buffer, dtype=np.uint8).reshape(height, width)
 992
 993        return build_char_glyph(run_config, char, glyph, np_image)
 994
 995    def run(self, run_config: FontEngineRunConfig, rng: RandomGenerator) -> Optional[TextLine]:
 996        font_face = load_freetype_font_face(run_config)
 997        return render_text_line_meta(
 998            run_config=run_config,
 999            font_face=font_face,
1000            func_render_char_glyph=self.render_char_glyph,
1001            rng=rng,
1002            cv_resize_interpolation_enlarge=sample_cv_resize_interpolation(rng),
1003            cv_resize_interpolation_shrink=sample_cv_resize_interpolation(
1004                rng,
1005                include_cv_inter_area=True,
1006            ),
1007        )

Abstract base class for generic types.

A generic type is typically declared by inheriting from this class parameterized with one or more type variables. For example, a generic mapping type might be defined as::

class Mapping(Generic[KT, VT]): def __getitem__(self, key: KT) -> VT: ... # Etc.

This class can then be used as follows::

def lookup_name(mapping: Mapping[KT, VT], key: KT, default: VT) -> VT: try: return mapping[key] except KeyError: return default

@classmethod
def get_type_name(cls) -> str:
967    @classmethod
968    def get_type_name(cls) -> str:
969        return 'freetype_default'
@classmethod
def render_char_glyph( cls, run_config: vkit.engine.font.type.FontEngineRunConfig, font_face: freetype.Face, char: str):
971    @classmethod
972    def render_char_glyph(
973        cls,
974        run_config: FontEngineRunConfig,
975        font_face: freetype.Face,
976        char: str,
977    ):
978        load_char_flags = freetype.FT_LOAD_RENDER  # type: ignore
979        if run_config.style.freetype_force_autohint:
980            load_char_flags |= freetype.FT_LOAD_FORCE_AUTOHINT  # type: ignore
981        font_face.load_char(char, load_char_flags)
982
983        glyph = font_face.glyph
984        bitmap = glyph.bitmap
985
986        height = bitmap.rows
987        width = bitmap.width
988        assert width == bitmap.pitch
989
990        # (H, W), [0, 255]
991        np_image = np.asarray(bitmap.buffer, dtype=np.uint8).reshape(height, width)
992
993        return build_char_glyph(run_config, char, glyph, np_image)
def run( self, run_config: vkit.engine.font.type.FontEngineRunConfig, rng: numpy.random._generator.Generator) -> Union[vkit.engine.font.type.TextLine, NoneType]:
 995    def run(self, run_config: FontEngineRunConfig, rng: RandomGenerator) -> Optional[TextLine]:
 996        font_face = load_freetype_font_face(run_config)
 997        return render_text_line_meta(
 998            run_config=run_config,
 999            font_face=font_face,
1000            func_render_char_glyph=self.render_char_glyph,
1001            rng=rng,
1002            cv_resize_interpolation_enlarge=sample_cv_resize_interpolation(rng),
1003            cv_resize_interpolation_shrink=sample_cv_resize_interpolation(
1004                rng,
1005                include_cv_inter_area=True,
1006            ),
1007        )
1013class FontFreetypeLcdEngine(
1014    Engine[
1015        NoneTypeEngineInitConfig,
1016        NoneTypeEngineInitResource,
1017        FontEngineRunConfig,
1018        Optional[TextLine],
1019    ]
1020):  # yapf: disable
1021
1022    @classmethod
1023    def get_type_name(cls) -> str:
1024        return 'freetype_lcd'
1025
1026    @classmethod
1027    def render_char_glyph(
1028        cls,
1029        run_config: FontEngineRunConfig,
1030        font_face: freetype.Face,
1031        lcd_hc_matrix: freetype.Matrix,
1032        char: str,
1033    ):
1034        load_char_flags = freetype.FT_LOAD_RENDER | freetype.FT_LOAD_TARGET_LCD  # type: ignore
1035        if run_config.style.freetype_force_autohint:
1036            load_char_flags |= freetype.FT_LOAD_FORCE_AUTOHINT  # type: ignore
1037        font_face.set_transform(lcd_hc_matrix, freetype.Vector(0, 0))
1038        font_face.load_char(char, load_char_flags)
1039
1040        glyph = font_face.glyph
1041        bitmap = glyph.bitmap
1042
1043        height = bitmap.rows
1044        pitch = bitmap.pitch
1045        flatten_width = bitmap.width
1046        width = flatten_width // 3
1047
1048        # (H, W, 3), [0, 255]
1049        np_image = np.asarray(bitmap.buffer, dtype=np.uint8).reshape(height, pitch)
1050        np_image = np_image[:, :width * 3].reshape(height, width, 3)
1051
1052        return build_char_glyph(run_config, char, glyph, np_image)
1053
1054    @classmethod
1055    def bind_render_char_glyph(cls, lcd_hc_matrix: freetype.Matrix):
1056        return lambda config, font_face, char: cls.render_char_glyph(
1057            config,
1058            font_face,
1059            lcd_hc_matrix,
1060            char,
1061        )
1062
1063    def run(self, run_config: FontEngineRunConfig, rng: RandomGenerator) -> Optional[TextLine]:
1064        lcd_compression_factor = 10
1065        font_face = load_freetype_font_face(
1066            run_config,
1067            lcd_compression_factor=lcd_compression_factor,
1068        )
1069        lcd_hc_matrix = build_freetype_font_face_lcd_hc_matrix(lcd_compression_factor)
1070        return render_text_line_meta(
1071            run_config=run_config,
1072            font_face=font_face,
1073            func_render_char_glyph=self.bind_render_char_glyph(lcd_hc_matrix),
1074            rng=rng,
1075            cv_resize_interpolation_enlarge=sample_cv_resize_interpolation(rng),
1076            cv_resize_interpolation_shrink=sample_cv_resize_interpolation(
1077                rng,
1078                include_cv_inter_area=True,
1079            ),
1080        )

Abstract base class for generic types.

A generic type is typically declared by inheriting from this class parameterized with one or more type variables. For example, a generic mapping type might be defined as::

class Mapping(Generic[KT, VT]): def __getitem__(self, key: KT) -> VT: ... # Etc.

This class can then be used as follows::

def lookup_name(mapping: Mapping[KT, VT], key: KT, default: VT) -> VT: try: return mapping[key] except KeyError: return default

@classmethod
def get_type_name(cls) -> str:
1022    @classmethod
1023    def get_type_name(cls) -> str:
1024        return 'freetype_lcd'
@classmethod
def render_char_glyph( cls, run_config: vkit.engine.font.type.FontEngineRunConfig, font_face: freetype.Face, lcd_hc_matrix: freetype.ft_structs.FT_Matrix, char: str):
1026    @classmethod
1027    def render_char_glyph(
1028        cls,
1029        run_config: FontEngineRunConfig,
1030        font_face: freetype.Face,
1031        lcd_hc_matrix: freetype.Matrix,
1032        char: str,
1033    ):
1034        load_char_flags = freetype.FT_LOAD_RENDER | freetype.FT_LOAD_TARGET_LCD  # type: ignore
1035        if run_config.style.freetype_force_autohint:
1036            load_char_flags |= freetype.FT_LOAD_FORCE_AUTOHINT  # type: ignore
1037        font_face.set_transform(lcd_hc_matrix, freetype.Vector(0, 0))
1038        font_face.load_char(char, load_char_flags)
1039
1040        glyph = font_face.glyph
1041        bitmap = glyph.bitmap
1042
1043        height = bitmap.rows
1044        pitch = bitmap.pitch
1045        flatten_width = bitmap.width
1046        width = flatten_width // 3
1047
1048        # (H, W, 3), [0, 255]
1049        np_image = np.asarray(bitmap.buffer, dtype=np.uint8).reshape(height, pitch)
1050        np_image = np_image[:, :width * 3].reshape(height, width, 3)
1051
1052        return build_char_glyph(run_config, char, glyph, np_image)
@classmethod
def bind_render_char_glyph(cls, lcd_hc_matrix: freetype.ft_structs.FT_Matrix):
1054    @classmethod
1055    def bind_render_char_glyph(cls, lcd_hc_matrix: freetype.Matrix):
1056        return lambda config, font_face, char: cls.render_char_glyph(
1057            config,
1058            font_face,
1059            lcd_hc_matrix,
1060            char,
1061        )
def run( self, run_config: vkit.engine.font.type.FontEngineRunConfig, rng: numpy.random._generator.Generator) -> Union[vkit.engine.font.type.TextLine, NoneType]:
1063    def run(self, run_config: FontEngineRunConfig, rng: RandomGenerator) -> Optional[TextLine]:
1064        lcd_compression_factor = 10
1065        font_face = load_freetype_font_face(
1066            run_config,
1067            lcd_compression_factor=lcd_compression_factor,
1068        )
1069        lcd_hc_matrix = build_freetype_font_face_lcd_hc_matrix(lcd_compression_factor)
1070        return render_text_line_meta(
1071            run_config=run_config,
1072            font_face=font_face,
1073            func_render_char_glyph=self.bind_render_char_glyph(lcd_hc_matrix),
1074            rng=rng,
1075            cv_resize_interpolation_enlarge=sample_cv_resize_interpolation(rng),
1076            cv_resize_interpolation_shrink=sample_cv_resize_interpolation(
1077                rng,
1078                include_cv_inter_area=True,
1079            ),
1080        )
1086class FontFreetypeMonochromeEngine(
1087    Engine[
1088        NoneTypeEngineInitConfig,
1089        NoneTypeEngineInitResource,
1090        FontEngineRunConfig,
1091        Optional[TextLine],
1092    ]
1093):  # yapf: disable
1094
1095    @classmethod
1096    def get_type_name(cls) -> str:
1097        return 'freetype_monochrome'
1098
1099    @classmethod
1100    def render_char_glyph(
1101        cls,
1102        run_config: FontEngineRunConfig,
1103        font_face: freetype.Face,
1104        char: str,
1105    ):
1106        load_char_flags = freetype.FT_LOAD_RENDER | freetype.FT_LOAD_TARGET_MONO  # type: ignore
1107        if run_config.style.freetype_force_autohint:
1108            load_char_flags |= freetype.FT_LOAD_FORCE_AUTOHINT  # type: ignore
1109        font_face.load_char(char, load_char_flags)
1110
1111        glyph = font_face.glyph
1112        bitmap = glyph.bitmap
1113
1114        height = bitmap.rows
1115        width = bitmap.width
1116        pitch = bitmap.pitch
1117
1118        # Performance optimization.
1119        bitmap_buffer = bitmap.buffer
1120
1121        data = []
1122        for row_idx in range(height):
1123            row = []
1124            for pitch_idx in range(pitch):
1125                byte = bitmap_buffer[row_idx * pitch + pitch_idx]
1126                bits = []
1127                for _ in range(8):
1128                    bits.append(int((byte & 1) == 1))
1129                    byte = byte >> 1
1130                row.extend(bit * 255 for bit in reversed(bits))
1131            data.append(row[:width])
1132
1133        np_image = np.asarray(data, dtype=np.uint8)
1134        assert np_image.shape == (height, width)
1135
1136        return build_char_glyph(run_config, char, glyph, np_image)
1137
1138    def run(self, run_config: FontEngineRunConfig, rng: RandomGenerator) -> Optional[TextLine]:
1139        font_face = load_freetype_font_face(run_config)
1140        return render_text_line_meta(
1141            run_config=run_config,
1142            font_face=font_face,
1143            func_render_char_glyph=self.render_char_glyph,
1144            rng=rng,
1145            cv_resize_interpolation_enlarge=cv.INTER_NEAREST_EXACT,
1146            cv_resize_interpolation_shrink=cv.INTER_NEAREST_EXACT,
1147        )

Abstract base class for generic types.

A generic type is typically declared by inheriting from this class parameterized with one or more type variables. For example, a generic mapping type might be defined as::

class Mapping(Generic[KT, VT]): def __getitem__(self, key: KT) -> VT: ... # Etc.

This class can then be used as follows::

def lookup_name(mapping: Mapping[KT, VT], key: KT, default: VT) -> VT: try: return mapping[key] except KeyError: return default

@classmethod
def get_type_name(cls) -> str:
1095    @classmethod
1096    def get_type_name(cls) -> str:
1097        return 'freetype_monochrome'
@classmethod
def render_char_glyph( cls, run_config: vkit.engine.font.type.FontEngineRunConfig, font_face: freetype.Face, char: str):
1099    @classmethod
1100    def render_char_glyph(
1101        cls,
1102        run_config: FontEngineRunConfig,
1103        font_face: freetype.Face,
1104        char: str,
1105    ):
1106        load_char_flags = freetype.FT_LOAD_RENDER | freetype.FT_LOAD_TARGET_MONO  # type: ignore
1107        if run_config.style.freetype_force_autohint:
1108            load_char_flags |= freetype.FT_LOAD_FORCE_AUTOHINT  # type: ignore
1109        font_face.load_char(char, load_char_flags)
1110
1111        glyph = font_face.glyph
1112        bitmap = glyph.bitmap
1113
1114        height = bitmap.rows
1115        width = bitmap.width
1116        pitch = bitmap.pitch
1117
1118        # Performance optimization.
1119        bitmap_buffer = bitmap.buffer
1120
1121        data = []
1122        for row_idx in range(height):
1123            row = []
1124            for pitch_idx in range(pitch):
1125                byte = bitmap_buffer[row_idx * pitch + pitch_idx]
1126                bits = []
1127                for _ in range(8):
1128                    bits.append(int((byte & 1) == 1))
1129                    byte = byte >> 1
1130                row.extend(bit * 255 for bit in reversed(bits))
1131            data.append(row[:width])
1132
1133        np_image = np.asarray(data, dtype=np.uint8)
1134        assert np_image.shape == (height, width)
1135
1136        return build_char_glyph(run_config, char, glyph, np_image)
def run( self, run_config: vkit.engine.font.type.FontEngineRunConfig, rng: numpy.random._generator.Generator) -> Union[vkit.engine.font.type.TextLine, NoneType]:
1138    def run(self, run_config: FontEngineRunConfig, rng: RandomGenerator) -> Optional[TextLine]:
1139        font_face = load_freetype_font_face(run_config)
1140        return render_text_line_meta(
1141            run_config=run_config,
1142            font_face=font_face,
1143            func_render_char_glyph=self.render_char_glyph,
1144            rng=rng,
1145            cv_resize_interpolation_enlarge=cv.INTER_NEAREST_EXACT,
1146            cv_resize_interpolation_shrink=cv.INTER_NEAREST_EXACT,
1147        )