Low-Poly 风格化照片

偶然看到 Low-Poly Image and Video Processing,觉得很有意思,于是找到 Python 版本的简化 Low-Poly 风格化代码,并将原先较为混乱的代码整理如下:

low_poly_art.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
import collections
import os
import typing as t

import pygame
import pygame.gfxdraw

import numpy as np

from scipy.ndimage import gaussian_filter
from scipy.spatial import Delaunay


class LowPolyArt:
    '''
    - References:
        - https://github.com/Ovilia/Polyvia
        - https://cosmiccoding.com.au/tutorials/lowpoly
    '''
    AnyPath = t.Union[str, bytes, os.PathLike]

    def __init__(self, image: np.ndarray, scale: int = 2) -> None:
        perceptual_weight = np.array([0.2126, 0.7152, 0.0722])
        #
        self._image, self._scale = image, scale
        self._gray = (image*perceptual_weight).sum(axis=-1)
        #
        self._diff = gaussian_filter(self._gray, 2, mode='reflect') - \
            gaussian_filter(self._gray, 30, mode='reflect')
        self._diff[self._diff < 0] /= 10
        self._diff = np.sqrt(np.abs(self._diff) / self._diff.max())
        #
        self._screen = pygame.Surface((self.width*scale, self.height*scale))
        self._screen.fill(image.mean(axis=(0, 1)))
        #
        corners = np.array([
            (0, 0), (0, self.height-1), (self.width-1, 0), (self.width-1, self.height-1)
        ])
        self._points = np.concatenate((corners, self._samples()))

    @classmethod
    def from_file(cls, path: AnyPath, **kwargs) -> 'LowPolyArt':
        image = pygame.surfarray.pixels3d(pygame.image.load(path))
        return cls(image, **kwargs)

    @property
    def width(self) -> int:
        return self._image.shape[0]

    @property
    def height(self) -> int:
        return self._image.shape[1]

    def to_surface(self, n: int) -> pygame.Surface:
        triangles = Delaunay(self._points[:n, :])
        screen = self._draw(triangles, self._colors(triangles))
        return pygame.transform.smoothscale(screen, (self.width, self.height))

    def to_image(self, n: int, path: AnyPath) -> None:
        pygame.image.save(self.to_surface(n), path)

    def _samples(self, n: int = 1_000_000) -> np.ndarray:
        # np.random.seed(0)
        xs = np.random.randint(0, self.width, size=n)
        ys = np.random.randint(0, self.height, size=n)
        accept = np.random.random(size=n) < self._diff[xs, ys]
        return np.array([xs[accept], ys[accept]]).T

    def _colors(self, triangles: Delaunay) -> t.Dict[int, np.ndarray]:
        colors = collections.defaultdict(list)
        for ith in range(self.width):
            for jth in range(self.height):
                # Gets the index of the triangle the point is in
                index = triangles.find_simplex((ith, jth))
                colors[int(index)].append(self._image[ith, jth, :])
        # For each triangle, find the average colour
        return {
            index: np.mean(array, axis=0)
            for index, array in colors.items()
        }

    def _draw(
        self,
        triangles: Delaunay, colors: t.Dict[int, np.ndarray],
    ) -> pygame.Surface:
        screen = self._screen.copy()
        for key, color in colors.items():
            t = triangles.points[triangles.simplices[key]]
            pygame.gfxdraw.filled_polygon(screen, t*self._scale, color)
            pygame.gfxdraw.polygon(screen, t*self._scale, color)
        return screen


if __name__ == '__main__':
    import pathlib

    input = 'ddlc.jpg'
    output = 'output'

    lpa = LowPolyArt.from_file(input, scale=2)
    directory = pathlib.Path(output)
    directory.mkdir(parents=True, exist_ok=True)
    for ith in range(128):
        path = directory / f'{ith:03d}.png'
        if not path.exists():
            n = 2*ith**2 + ith + 5
            lpa.to_image(n, path)