整合计算机视觉与生成式艺术

使用 Python 和 OpenCV 进行照片点彩处理

整合计算机视觉与生成式艺术
梯形图转SCL | AI模型价格对比 | AI工具导航 | ONNX模型库 | Vibe Coding教程 | PLC在线仿真器 | Tripo 3D | Meshy AI | ElevenLabs | KlingAI | ArtSpace | Phot.AI | InVideo

最近在 LinkedIn 上无意识地刷屏时,一张图片从嘈杂的信息中脱颖而出:一只充满活力、风格化的蚊子,仿佛要从屏幕上振动出来。尽管高度抽象,但它仍然立即可辨,具有惊人的动感。

一张 LinkedIn 帖子,展示了一只风格化的红色蚊子剪影,背景为粉色。蚊子由紧密和稀疏排列的像素组成。

与传统的像素艺术或固定半调网格不同,这张图片似乎依赖于随机点彩技术,这是一种使用随机抖动来传达纹理、阴影和运动的技术。认识到它在视觉新闻和数据叙事中的潜力,我构建了一个程序化管道来自动生成类似的图像(我决定允许更多的阴影)。以下是输出的一些示例;你觉得怎么样?

飞行中鸟类的并排比较。左侧是在蓝天和柔和白云背景下展翅飞翔的鸟的黑色剪影。右侧是同一只鸟的简化图形版本,显示为淡蓝色背景上的黑色剪影,翅膀和身体上散布着小的浅色斑点。
一张三面板图像,展示了自由女神像的不同视觉风格。左侧面板是雕像站在基座上的全彩照片,背景是多云的天空。中间面板显示了同一雕像在亮绿色背景上的简化绿色调像素化渲染。右侧面板呈现了进一步简化的版本,雕像被描绘为白色背景上的浅绿色点状剪影,突出了从照片到抽象图形的演变过程。
蚊子图像与其简化图形版本的并排比较。左侧的特写照片显示了一只蚊子,身体纤细,腿很长,透明的翅膀在柔和模糊的绿色背景上盘旋。右侧,同一只蚊子被渲染为淡绿色背景上的简化黑色和浅绿色剪影,保留了昆虫的整体形状和翅膀结构,同时减少了细节。
战斗机照片和风格化图形艺术版本的并排比较。照片显示灰色喷气式飞机在多云的蓝色背景上。风格化版本显示深灰色点彩版本在红色背景上。

在这个快速成功的数据科学项目中,我们将结合 PythonOpenCVPillow面向对象编程来构建一个点彩生成器。你将学习如何提取前景特征,将图像亮度映射到概率分布,并使用 Canny 边缘检测将照片转换为高对比度点彩画

1、代码

以下代码在 JupyterLab 中编写,通过分析图像阴影、轮廓和主体边界,在深色调和锐利边缘处散布彩色像素,将标准照片转换为点彩(点画/点彩画)数字插图。

流程步骤如下:

None

1) 配置(StippleConfig):建立渲染参数,如画布颜色、网格间距、密度缩放、边缘阈值、点大小和随机位置偏移(jitter)。

2) 主体分割(segment_subject):使用 OpenCV 的 GrabCut 迭代算法以及形态学滤波(开运算/闭运算)来隔离主要前景主体与其背景,确保点彩点针对主要焦点。

3) 特征图生成:从前景图像中提取两个主要层的结构细节:

  • 色调图:通过反转灰度强度结合高斯模糊计算局部暗度。
  • 边缘图:使用 Canny 边缘检测识别细线和高对比度边界。

4) 概率场计算:将前景蒙版、暗度图和边缘图组合成单个矩阵。深色区域和锐利边缘获得更高的概率值,增加在该处绘制点彩点的可能性。

5) 网格采样和抖动:在 spacing 定义的固定间隔处评估概率场:

  • 使用伪随机采样(rng.random() < chances)确定哪些网格交叉点接收点。
  • 应用(可选)受控空间位移(jitter)到坐标位置,以消除刚性网格对齐并创建手绘、有机的外观。

6) 可变大小渲染:在指定的背景颜色中初始化新的 PIL(Pillow)图像画布,并遍历有效点。位于强烈深色区域的点以额外像素大小(pixel_size + 1)渲染,以增强对比度和色调深度。

注意:如果你想使用我在这里使用的相同图像,只需复制或截取此鸟图像的屏幕截图,并将其存储在与 Python 脚本或笔记本相同的文件夹中:
蓝天和柔和白云背景下的鸟的黑色剪影,翅膀完全展开。

以下是完整代码;我们将在后续部分查看各个代码块:

from dataclasses import dataclass
from IPython.display import display
import numpy as np
import matplotlib.colors as mcolors
from PIL import Image, ImageDraw
import cv2

# 用户设置
# =============================================================================
INPUT_IMAGE = "bird.png"
OUTPUT_IMAGE = "output.png"

@dataclass
class StippleConfig:
    """点彩艺术生成的配置选项。"""
    pixel_size: int = 1
    spacing: int = 2
    density: float = 1.5
    valid_pct: float = 0.70
    seed: int = 12
    jitter: int = 0
    black_background: bool = False
    fg_color: str = "crimson"
    bg_color: str = "mistyrose"
# =============================================================================

class StippleGenerator:
    """图像加载、主体分割和随机点彩渲染。"""
    def __init__(self, config: StippleConfig | None = None):
        self.config = config or StippleConfig()

    @staticmethod
    def _color_to_rgb(color_name: str) -> tuple[int, int, int]:
        """将 Matplotlib 颜色字符串转换为缩放到 0-255 的 (RGB) 元组。"""
        return tuple(int(c * 255) for c in mcolors.to_rgb(color_name))

    def load_image(self, image_path: str) -> np.ndarray:
        """从磁盘加载 BGR 格式的图像。"""
        image_bgr = cv2.imread(image_path)
        if image_bgr is None:
            raise FileNotFoundError(f"无法读取路径为 {image_path} 的图像")
        return image_bgr

    def segment_subject(self, image_bgr: np.ndarray) -> np.ndarray:
        """使用 GrabCut 估计主要前景对象蒙版。"""
        h, w = image_bgr.shape[:2]
        mx, my = max(2, int(w * 0.04)), max(2, int(h * 0.04))
        rect = (mx, my, w - 2 * mx, h - 2 * my)

        mask = np.zeros((h, w), np.uint8)
        bgd = np.zeros((1, 65), np.float64)
        fgd = np.zeros((1, 65), np.float64)

        cv2.grabCut(image_bgr, mask, rect, bgd, fgd, 5, cv2.GC_INIT_WITH_RECT)
        subject = np.where((mask == cv2.GC_FGD) | (mask == cv2.GC_PR_FGD),
                           255, 0).astype(np.uint8)

        kernel = np.ones((3, 3), np.uint8)
        return cv2.morphologyEx(cv2.morphologyEx(subject, cv2.MORPH_OPEN,
                                                 kernel), cv2.MORPH_CLOSE, kernel)

    def _render_stipple(self, image_bgr: np.ndarray,
                        subject_mask: np.ndarray) -> Image.Image:
        """使用密度图概率采样的核心渲染逻辑。"""
        cfg = self.config
        rng = np.random.default_rng(cfg.seed)
        h, w = image_bgr.shape[:2]

        fg_rgb = self._color_to_rgb(cfg.fg_color)
        bg_rgb = self._color_to_rgb(cfg.bg_color)

        # 色调和边缘图:
        gray = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2GRAY)
        darkness = gray.astype(np.float32) / 255.0
        if not cfg.black_background:
            darkness = 1.0 - darkness
        darkness = cv2.GaussianBlur(darkness, (0, 0), 1.0)

        edges = cv2.Canny(gray, 45, 130).astype(np.float32) / 255.0
        edges = cv2.GaussianBlur(edges, (0, 0), 0.8)
        fg = subject_mask.astype(np.float32) / 255.0

        # 概率场:
        prob = np.clip(fg * (0.16 + 0.78 * darkness + 0.40 * edges) *
                       cfg.density, 0.0, 0.97)

        # 向量块采样:
        prob_mean = cv2.blur(prob, (cfg.spacing, cfg.spacing), anchor=(0, 0))
        dark_mean = cv2.blur(darkness, (cfg.spacing, cfg.spacing), anchor=(0, 0))

        Y, X = np.mgrid[0:h:cfg.spacing, 0:w:cfg.spacing]
        chances = prob_mean[Y, X]
        local_darks = dark_mean[Y, X]

        # 过滤和抖动点:
        valid_mask = rng.random(chances.shape) < chances
        valid_X, valid_Y = X[valid_mask], Y[valid_mask]
        valid_darks = local_darks[valid_mask]

        if cfg.jitter > 0:
            valid_X = np.clip(valid_X + rng.integers(-cfg.jitter, cfg.jitter
                                                     + 1, size=valid_X.shape),
                                                     0, w - 1)
            valid_Y = np.clip(valid_Y + rng.integers(-cfg.jitter, cfg.jitter
                                                     + 1, size=valid_Y.shape),
                                                     0, h - 1)

        # 绘制画布:
        output = Image.new("RGB", (w, h), bg_rgb)
        draw = ImageDraw.Draw(output)
        sizes = cfg.pixel_size + (valid_darks > cfg.valid_pct).astype(int)

        for px, py, size in zip(valid_X, valid_Y, sizes):
            draw.rectangle([px, py, min(px + size - 1, w - 1),
                            min(py + size - 1, h - 1)], fill=fg_rgb)

        return output

    def generate(
        self,
        image_source: str | np.ndarray,
        subject_mask: np.ndarray | None = None,
        ) -> Image.Image:
        """执行从输入源到最终 PIL 图像的完整管道。"""
        if isinstance(image_source, str):
            image_bgr = self.load_image(image_source)
        else:
            image_bgr = image_source

        if subject_mask is None:
            subject_mask = self.segment_subject(image_bgr)

        return self._render_stipple(image_bgr, subject_mask)

# 运行程序
# =============================================================================

stipple_config = StippleConfig()

# 实例化生成器并渲染:
generator = StippleGenerator(config=stipple_config)
result = generator.generate(INPUT_IMAGE)

result.save(OUTPUT_IMAGE)
display(result)

2、导入库

该代码使用 Python 数据类进行面向对象编程,IPython 用于显示输出图像,NumPy 用于裁剪、蒙版、网格化和随机数生成等任务,Matplotlib 用于颜色选择,Pillow(PIL)和 **OpenCV(cv2)**用于图像处理。

使用的版本是:Python 3.11.16IPython 9.15NumPy 2.4.6Matplotlib 3.11Pillow 12.3OpenCV 5.0.0.93

from dataclasses import dataclass
from IPython.display import display
import numpy as np
import matplotlib.colors as mcolors
from PIL import Image, ImageDraw
import cv2

你可以在前面的链接中找到各种库的安装说明,OpenCV 的安装说明可以在这里找到。

作为 Anaconda 用户,我使用 conda 安装 OpenCV 时总是遇到困难。它似乎安装正确,但当我尝试将库导入脚本时,总是会遇到错误。为了避免这种情况,我创建一个新的 conda 环境,使用 conda 安装我需要的所有内容除了 OpenCV,然后作为最后一步,使用 pip 安装 OpenCV:

pip install opencv-python

注意:如果你对混合使用 conda 和 pip 感兴趣,请参阅我文章末尾的附录,介绍 Conda 环境

3、用户设置 / 定义 StippleConfig 类

下一个代码块是你输入输入和输出图像的名称和路径,并定义用于点彩图像的数据类的地方。在这种情况下,我将图像存储在与笔记本相同的文件夹中,因此无需指定路径。

如果你使用我的鸟图像但将其保存为 PNG 以外的格式,请确保更新第一行中的文件名扩展名:

INPUT_IMAGE = "bird.png"

如果你不熟悉 Python 类和数据类,请查看此介绍:

介绍 Python 类和数据类初学者的实践指南

# 用户设置
# =============================================================================
INPUT_IMAGE = "bird.png"
OUTPUT_IMAGE = "output.png"

@dataclass
class StippleConfig:
    """点彩艺术生成的配置选项。"""
    pixel_size: int = 1
    spacing: int = 2
    density: float = 1.5
    valid_pct: float = 0.70  # 阈值(0.0-1.0);值越高点彩越浅
    seed: int = 12
    jitter: int = 0
    black_background: bool = False
    fg_color: str = "crimson"
    bg_color: str = "mistyrose"
# =============================================================================

数据类的实例属性决定最终图像的外观。你可以通过覆盖当前分配的值来更改它们。以下是每个属性的功能摘要和推荐值范围:

None

为方便起见,该程序使用预定义的 Matplotlib 颜色:

彩色矩形列描述了 Matplotlib 库中可用的预设颜色。
**注意:**如果你想微调 Matplotlib 中可用的颜色之外的颜色,请将代码重构为使用十六进制颜色代码或 RGB 元组(例如 (225, 48, 49))。

4、定义颜色和图像加载的类和方法

下一个代码块定义了 StippleGenerator 类,它完成了所有繁重的工作。它还定义了加载图像和将 Matplotlib 颜色名称(对人类很方便)转换为图像处理工具首选的 RGB 元组的方法。

使用像 StippleConfig 这样的专用类与 StippleGenerator 一起封装配置状态在一个单一的内聚对象中,防止函数(方法)参数膨胀并支持执行前的错误检测。

class StippleGenerator:
    """图像加载、主体分割和随机点彩渲染。"""
    def __init__(self, config: StippleConfig | None = None):
        self.config = config or StippleConfig()

    @staticmethod
    def _color_to_rgb(color_name: str) -> tuple[int, int, int]:
        """将 Matplotlib 颜色字符串转换为缩放到 0-255 的 (RGB) 元组。"""
        return tuple(int(c * 255) for c in mcolors.to_rgb(color_name))

    def load_image(self, image_path: str) -> np.ndarray:
        """从磁盘加载 BGR 格式的图像。"""
        image_bgr = cv2.imread(image_path)
        if image_bgr is None:
            raise FileNotFoundError(f"无法读取路径为 {image_path} 的图像")
        return image_bgr

此代码片段管理点彩管道的初始状态设置和输入准备:

  • __init__:存储 StippleConfig 实例,如果未提供则默认为全新配置。
  • _color_to_rgb:将 Matplotlib 颜色字符串(例如 "white""k")转换为 8 位 RGB 元组 (0–255)。前导下划线表示该方法 intended 用于类内部使用
  • load_image:将图像文件读取到 OpenCV NumPy BGR 数组中,如果加载失败则引发 FileNotFoundError

5、隔离前景图像

以下方法自动将主要主体与其背景隔离,产生干净的二进制蒙版(255 表示前景,0 表示背景)。

**注意:**为了使此方法正常工作,请选择你要点彩的特征位于中心且背景不太复杂或与目标特征不太相似的图像。
def segment_subject(self, image_bgr: np.ndarray) -> np.ndarray:
        """使用 GrabCut 估计主要前景对象蒙版。"""
        h, w = image_bgr.shape[:2]
        mx, my = max(2, int(w * 0.04)), max(2, int(h * 0.04))
        rect = (mx, my, w - 2 * mx, h - 2 * my)

        mask = np.zeros((h, w), np.uint8)
        bgd = np.zeros((1, 65), np.float64)
        fgd = np.zeros((1, 65), np.float64)

        cv2.grabCut(image_bgr, mask, rect, bgd, fgd, 5, cv2.GC_INIT_WITH_RECT)
        subject = np.where((mask == cv2.GC_FGD) | (mask == cv2.GC_PR_FGD),
                           255, 0).astype(np.uint8)

        kernel = np.ones((3, 3), np.uint8)
        return cv2.morphologyEx(cv2.morphologyEx(subject, cv2.MORPH_OPEN,
                                                 kernel), cv2.MORPH_CLOSE, kernel)

此方法的关键部分是:

  • 边界框设置:定义一个带有 4% 边距的居中矩形(rect),假设主要主体位于帧的中心区域。
  • GrabCut 分割:运行 OpenCV 的 grabCut 算法 5 次迭代,使用颜色分布模型将前景对象与背景分离。
  • 二进制蒙版提取:过滤 resulting 像素分类,将确定和可能的前景像素(GC_FGDGC_PR_FGD)转换为二进制 8 位蒙版。
  • 形态学清理:使用 3x3 核将蒙版传递给顺序形态学开运算(去除杂散背景噪声)和形态学闭运算(填充内部孔洞),以获得平滑的主体边缘。

提取前景对象无需干预即可正常工作。但是,如果你遇到问题,请将图像更紧密地裁剪到前景主体,或使用照片编辑器(如 Microsoft Paint)手动移除背景。

6、定义渲染点彩的方法

这是程序的核心。它构建色调和边缘图,建立像素放置的概率场,然后过滤和(可选地)抖动像素。最后绘制画布。

def _render_stipple(self, image_bgr: np.ndarray,
                        subject_mask: np.ndarray) -> Image.Image:
        """使用密度图概率采样的核心渲染逻辑。"""
        cfg = self.config
        rng = np.random.default_rng(cfg.seed)
        h, w = image_bgr.shape[:2]

        fg_rgb = self._color_to_rgb(cfg.fg_color)
        bg_rgb = self._color_to_rgb(cfg.bg_color)

        # 色调和边缘图:
        gray = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2GRAY)
        darkness = gray.astype(np.float32) / 255.0
        if not cfg.black_background:
            darkness = 1.0 - darkness
        darkness = cv2.GaussianBlur(darkness, (0, 0), 1.0)

        edges = cv2.Canny(gray, 45, 130).astype(np.float32) / 255.0
        edges = cv2.GaussianBlur(edges, (0, 0), 0.8)
        fg = subject_mask.astype(np.float32) / 255.0

        # 概率场:
        prob = np.clip(fg * (0.16 + 0.78 * darkness + 0.40 * edges) *
                       cfg.density, 0.0, 0.97)

        # 向量块采样:
        prob_mean = cv2.blur(prob, (cfg.spacing, cfg.spacing), anchor=(0, 0))
        dark_mean = cv2.blur(darkness, (cfg.spacing, cfg.spacing), anchor=(0, 0))

        Y, X = np.mgrid[0:h:cfg.spacing, 0:w:cfg.spacing]
        chances = prob_mean[Y, X]
        local_darks = dark_mean[Y, X]

        # 过滤和抖动点:
        valid_mask = rng.random(chances.shape) < chances
        valid_X, valid_Y = X[valid_mask], Y[valid_mask]
        valid_darks = local_darks[valid_mask]

        if cfg.jitter > 0:
            valid_X = np.clip(valid_X + rng.integers(-cfg.jitter, cfg.jitter
                                                     + 1, size=valid_X.shape),
                                                     0, w - 1)
            valid_Y = np.clip(valid_Y + rng.integers(-cfg.jitter, cfg.jitter
                                                     + 1, size=valid_Y.shape),
                                                     0, h - 1)

        # 绘制画布:
        output = Image.new("RGB", (w, h), bg_rgb)
        draw = ImageDraw.Draw(output)
        sizes = cfg.pixel_size + (valid_darks > cfg.valid_pct).astype(int)

        for px, py, size in zip(valid_X, valid_Y, sizes):
            draw.rectangle([px, py, min(px + size - 1, w - 1),
                            min(py + size - 1, h - 1)], fill=fg_rgb)

        return output

简而言之,此方法将图像色调边缘转换为随机分布的点彩点,并通过四个主要步骤将它们渲染到 Pillow 画布上:

  • 特征图提取:将图像转换为灰度,计算归一化的暗度图,使用 Canny 边缘检测提取结构轮廓,并应用高斯模糊平滑过渡。
  • 概率场计算:将主体蒙版、暗度图和边缘图组合成由 density 缩放的统一度矩阵。较暗的区域和锐利的边缘获得更高的概率。
  • 网格采样和空间抖动:使用 cv2.blurspacing 块之间平均概率,通过随机阈值(rng.random() < chances)采样点,并应用随机坐标偏移(jitter)打破刚性网格对齐。
在计算机图形学中,通过改变像素密度仅使用两种颜色来近似阴影和渐变称为"抖动"。因为我们使用的是随机数生成器,所以这是随机 1 位抖动的一种形式。
  • 画布绘制:使用 bg_color 初始化 PIL 画布,计算点大小(向超过 valid_pct 阴影阈值的点添加 +1 像素),并在每个选定点绘制 fg_color 矩形。"有效点"只是通过概率测试的坐标。

关于 jitter 属性的说明:此参数打破像素的规则网格以创建更有机的外观。例如,这是之前 jitter = 1 的鸟图像:

淡蓝色背景上展翅飞翔的鸟的蓝色剪影。组成鸟的点彩像素具有蠕虫状、有机的外观,而不是规则的网格外观。

将其与标题图像进行比较。"抖动"图像具有蠕虫状、斑驳的外观,看起来很有机。

7、定义生成图像的方法

下一个方法作为公共入口点,协调从原始输入到最终输出的整个点彩管道。

def generate(
        self,
        image_source: str | np.ndarray,
        subject_mask: np.ndarray | None = None,
        ) -> Image.Image:
        """执行从输入源到最终 PIL 图像的完整管道。"""
        if isinstance(image_source, str):
            image_bgr = self.load_image(image_source)
        else:
            image_bgr = image_source

        if subject_mask is None:
            subject_mask = self.segment_subject(image_bgr)

        return self._render_stipple(image_bgr, subject_mask)

主要组件是:

  • 灵活的输入处理:接受 image_source 作为文件路径字符串(通过 load_image 加载)或已加载的 BGR NumPy 数组。
  • 条件蒙版:检查是否提供了自定义 subject_mask。如果为 None,则通过调用 segment_subject 方法自动隔离主要主体。
  • 管道执行和返回:将 BGR 图像数组和前景蒙版传递给 _render_stipple 以生成特征图、采样点并渲染画布,将完成的艺术品作为 PIL Image.Image 返回。
如果你曾经想知道为什么像 OpenCV 这样的计算机视觉库使用 BGR(蓝-绿-红)而不是 RGB(红-绿-蓝),这是由于 1999 年 Intel 创建该库时所做的历史硬件标准和内存表示选择。早期的 Windows 图形系统(如 GDI 和设备无关位图)以反转字节顺序(BGR)在内存中存储图像像素。使用 BGR 允许 OpenCV 将图像缓冲区直接传递给显示硬件,而无需花费 CPU 周期交换字节。此时,将默认格式更改为 RGB 会破坏全球遗留软件,因此我们只能使用 BGR。

8、运行程序

最后一个代码块充当执行脚本,调用点彩管道并处理输出。

stipple_config = StippleConfig()

# 实例化生成器并渲染:
generator = StippleGenerator(config=stipple_config)
result = generator.generate(INPUT_IMAGE)

result.save(OUTPUT_IMAGE)
display(result)

具体来说,此代码片段执行:

  • 配置和设置:使用定义 class StippleConfig 时指定的设置实例化 StippleConfig。将其注入新的 StippleGenerator 实例。
  • 图像生成:在指定的 INPUT_IMAGE 文件路径上调用 generator.generate() 以运行完整的分割和渲染过程。
  • 保存和显示:将生成的艺术品保存为 OUTPUT_IMAGE 名称的磁盘,并在 Jupyter/IPython 笔记本环境中内联显示最终的 PIL 图像。

以下是输出结果:

一只风格化的红色飞鸟,从下方展示,翅膀向上展开,尾羽扇形散开。剪影填充了小白色斑点,设置在淡紫色背景上,创建了简单的高对比度点彩图形设计。

9、结束语

在这个项目中,我们构建了一个生成器,它接受输入图像并创建有艺术感的点彩数字表示。我们使用固定网格(由 SPACING 定义),但使用随机概率来决定是否绘制像素。较暗的区域和边缘具有高概率(密集聚类),而较亮的区域具有低概率(稀疏聚类)。使用两种颜色,一种用于前景,一种用于背景。这种"随机点彩"技术模仿了钢笔和墨水艺术家点彩绘画的方式。

这种方法与传统像素化不同,传统像素化将像素分组为更大的块,同时保留原始颜色(像 8 位视频游戏)。它也与传统半调技术不同,传统半调技术使用严格的均匀网格,在每个交叉点放置一个点,但点大小增长或缩小以模拟深色和浅色调(像报纸印刷)。

我们的方法产生引人注目的双色数字插图。通过抽象细节并专注于单个主体,这些图像特别适用于缩略图、帖子、标题图像和波普艺术。

为了获得最佳效果,请选择高对比度图像,其中前景主体居中,背景不太复杂或与前景对象不太相似。


原文链接:Computer Vision Meets Generative Art

汇智网翻译整理,转载请标明出处