计算机图形学|多边形填充算法

多边形填充是计算机图形学中的基础问题,涉及将多边形内部区域用特定颜色或图案填充。本次实验实现了两种经典的多边形填充算法:扫描线填充算法和种子填充算法

一、扫描线填充算法

算法原理

扫描线填充算法基于以下核心思想:从多边形的最小y坐标到最大y坐标,逐条水平扫描线与多边形相交,计算交点并配对,在配对的交点之间进行填充。

算法步骤:

  1. 确定多边形的y坐标范围
  2. 对每条扫描线,计算与多边形各边的交点
  3. 将交点按x坐标排序
  4. 将交点两两配对,在每对交点之间填充像素

关键实现

def scanline_fill(buf, poly, spacing=1):
    h, w, _ = buf.shape
    pts = [(int(round(x)), int(round(y))) for (x, y) in poly]
    n = len(pts)
    
    for y in range(0, h, spacing):  # 按指定间距遍历扫描线
        inters = []  # 存储交点列表
        
        for i in range(n):  # 遍历多边形的每条边
            x0, y0 = pts[i]
            x1, y1 = pts[(i+1) % n]
            
            if y0 == y1:  # 水平边,跳过
                continue
            
            # 确保扫描线在边的有效范围内
            ymin = min(y0, y1)
            ymax = max(y0, y1)
            if y < ymin or y >= ymax:
                continue
            
            # 使用线性插值计算交点x坐标
            x = x0 + (y - y0) * (x1 - x0) / (y1 - y0)
            inters.append(x)
        
        if not inters:
            continue
            
        inters.sort()  # 对交点按x坐标排序
        
        # 成对填充
        for k in range(0, len(inters), 2):
            if k+1 >= len(inters):
                break
                
            xstart = int(round(np.ceil(inters[k])))
            xend = int(round(np.floor(inters[k+1])))
            
            if xend < xstart:
                continue
                
            xstart = clamp(xstart, 0, w-1)
            xend = clamp(xend, 0, w-1)
            
            # 在扫描线上填充像素
            buf[y, xstart:xend+1] = POLY_COLOR

算法特点

  1. 通过扫描线的方式减少了填充区域的计算复杂度
  2. 使用线性插值确保边界像素的精确计算
  3. 可通过spacing参数控制扫描线密度,灵活实现不同填充效果

二、种子填充算法

算法原理

种子填充算法是一种基于区域生长的填充方法。从给定的种子点开始,向四个方向(上下左右)递归或迭代地填充像素,直到遇到边界为止。

算法步骤:

  1. 将种子点压入栈中
  2. 当栈非空时,弹出栈顶像素
  3. 如果该像素是目标颜色(未填充),则填充并检查四个相邻像素
  4. 如果相邻像素在边界内且未填充,则将其压入栈中
  5. 重复直到栈为空

关键实现

def seed_fill(buf, seed):
    h, w, _ = buf.shape
    sx, sy = seed
    
    # 坐标转换:从窗口坐标转换到缓冲区坐标
    sy_buf = h - 1 - sy
    
    target = tuple(buf[sy_buf, sx])  # 获取种子点颜色
    fillc = tuple(POLY_COLOR.tolist())  # 填充颜色
    
    if target == fillc:
        return  # 已经填充过,直接返回
    
    stack = [(sx, sy_buf)]  # 使用栈而非递归,避免深度过大
    
    while stack:
        x, y = stack.pop()
        
        # 边界检查
        if x < 0 or x >= w or y < 0 or y >= h:
            continue
        
        # 检查是否需要填充
        if tuple(buf[y, x]) != target:
            continue
        
        # 填充当前像素
        buf[y, x] = POLY_COLOR
        
        # 将四个相邻像素压入栈中
        stack.append((x+1, y))
        stack.append((x-1, y))
        stack.append((x, y+1))
        stack.append((x, y-1))

算法特点

  1. 区域连续性:适用于任意形状的闭合区域
  2. 递归深度过大会导致的栈溢出
  3. 边界敏感,精确识别边界像素,确保填充不越界

三、算法比较与应用场景

扫描线填充算法

  • 优点
    • 对于规则多边形填充效率高
    • 内存占用小,适合硬件实现
    • 填充结果均匀一致
  • 缺点
    • 对于复杂边界的多边形,交点计算可能复杂
    • 需要预处理确定多边形的边

种子填充算法

  • 优点
    • 适用于任意形状的闭合区域
    • 算法简单直观,易于实现
    • 适用于交互式填充
  • 缺点
    • 需要指定种子点
    • 可能产生递归深度过大的问题
    • 对于大区域填充可能较慢

四、实验实现中的关键技术

1. 坐标系统转换

实验中存在两个坐标系统:窗口坐标(左下角为原点)和缓冲区坐标(左上角为原点)。通过np.flipud()函数进行转换,确保显示正确:

# 将缓冲区坐标转换为窗口坐标
img = np.flipud(buffer_img)
glDrawPixels(WIN_W, WIN_H, GL_RGB, GL_UNSIGNED_BYTE, img)

2. 边界处理

使用clamp()函数确保像素坐标在有效范围内:

def clamp(v, a, b):
    return max(a, min(b, v))

3. 多边形边绘制

实现Bresenham画线算法绘制多边形边界:

def draw_polygon_edges(buf, poly):

五、总结

本次实验深入实现了两种经典的多边形填充算法。扫描线填充算法通过计算扫描线与多边形的交点并进行配对填充,适合规则多边形的快速填充。种子填充算法基于区域生长原理,从种子点开始向四周扩散填充,适合任意形状的闭合区域。

两种算法各有优缺点,在实际应用中应根据具体需求选择。扫描线填充更适合批量处理和硬件加速,而种子填充更适合交互式应用和复杂区域的填充。

通过本实验的实现,我们不仅掌握了多边形填充的基本原理,还学习了图形编程中的坐标转换、边界处理等关键技术

代码

填充算法.py
# 使用 PyOpenGL + GLUT 实现扫描线填充和种子填充多边形
# 在终端输入多边形顶点(像素坐标),选择算法并输入对应参数
import sys
import os
import json
from OpenGL.GL import *
from OpenGL.GLUT import *
import numpy as np
import tkinter as tk
from tkinter import ttk
WIN_W = 800
WIN_H = 600

BG_COLOR = np.array([255, 255, 255], dtype=np.uint8)
POLY_COLOR = np.array([200, 40, 40], dtype=np.uint8)
EDGE_COLOR = np.array([0, 0, 0], dtype=np.uint8)

buffer_img = None
polygon = []
algorithm = 'scanline'  # or 'seed'
scan_spacing = 1
seed_point = (0, 0)


def clamp(v, a, b):
    return max(a, min(b, v))


def parse_input():
    global polygon, algorithm, scan_spacing, seed_point
    print("实验二多边形填充")
    mode = input("输入 'T' 使用内置测试用例,按回车手动输入顶点: ").strip().lower()
    if mode == 't':
        tests = load_tests()
        if not tests:
            print("未找到测试用例文件,进入手动输入模式。")
        else:
            print("可用测试用例:")
            for i, t in enumerate(tests):
                print(f"  {i}: {t.get('name','case'+str(i))} - alg={t.get('algorithm')}")
            while True:
                sel = input("选择测试用例编号 (回车取消): ").strip()
                if sel == '':
                    break
                try:
                    idx = int(sel)
                    if 0 <= idx < len(tests):
                        case = tests[idx]
                        polygon = [(int(x), int(y)) for x, y in case['polygon']]
                        algorithm = case.get('algorithm', 'scanline')
                        if algorithm == 'seed':
                            seed_point = tuple(case.get('seed', (0, 0)))
                        else:
                            scan_spacing = int(case.get('spacing', 1))
                        print(f"已加载测试用例: {case.get('name','#'+str(idx))}")
                        return
                except Exception:
                    pass
                print("请输入有效的编号")

    # 手动输入模式
    try:
        n = int(input("输入多边形顶点数 (整数): "))
    except Exception:
        print("输入不合法")
        sys.exit(1)
    polygon = []
    print("请输入每个顶点的 x y(像素坐标,相对于窗口左下角,范围 0..{} 0..{})".format(WIN_W, WIN_H))
    for i in range(n):
        while True:
            s = input(f"顶点 {i+1} (x y): ").strip()
            parts = s.split()
            if len(parts) >= 2:
                try:
                    x = float(parts[0]); y = float(parts[1])
                    # clamp
                    x = clamp(int(round(x)), 0, WIN_W-1)
                    y = clamp(int(round(y)), 0, WIN_H-1)
                    polygon.append((x, y))
                    break
                except Exception:
                    pass
            print("输入格式: x y ")

    alg = input("选择算法: 输入 's' 表示扫描线(fill by scanline),'f' 表示种子填充(flood fill). (默认 s): ").strip().lower()
    if alg == 'f' or alg == 'seed':
        algorithm = 'seed'
        while True:
            s = input("输入种子像素坐标 x y: ").strip().split()
            if len(s) >= 2:
                try:
                    sx = clamp(int(round(float(s[0]))), 0, WIN_W-1)
                    sy = clamp(int(round(float(s[1]))), 0, WIN_H-1)
                    seed_point = (sx, sy)
                    break
                except Exception:
                    pass
            print("示例输入: 120 130")
    else:
        algorithm = 'scanline'
        s = input("输入扫描线间距(整数,默认 1): ").strip()
        try:
            scan_spacing = max(1, int(s))
        except Exception:
            scan_spacing = 1


def create_empty_buffer():
    global buffer_img
    buffer_img = np.full((WIN_H, WIN_W, 3), BG_COLOR, dtype=np.uint8)


def load_tests():
    """polygon_tests.json 加载测试用例列表"""
    try:
        base = os.path.dirname(os.path.abspath(__file__))
        path = os.path.join(base, 'polygon_tests.json')
        if not os.path.exists(path):
            return []
        with open(path, 'r', encoding='utf-8') as f:
            data = json.load(f)
            if isinstance(data, list):
                return data
            return []
    except Exception:
        return []


def draw_polygon_edges(buf, poly):
    # 简单的 Bresenham 画线到 buffer 中,用黑色画边
    h, w, _ = buf.shape
    def plot(x, y, color):
        if 0 <= x < w and 0 <= y < h:
            buf[y, x] = color
    for i in range(len(poly)):
        x0, y0 = poly[i]
        x1, y1 = poly[(i+1) % len(poly)]
        x0 = int(round(x0)); y0 = int(round(y0))
        x1 = int(round(x1)); y1 = int(round(y1))
        dx = abs(x1 - x0); sx = 1 if x0 < x1 else -1
        dy = -abs(y1 - y0); sy = 1 if y0 < y1 else -1
        err = dx + dy
        while True:
            plot(x0, y0, EDGE_COLOR)
            if x0 == x1 and y0 == y1:
                break
            e2 = 2 * err
            if e2 >= dy:
                err += dy
                x0 += sx
            if e2 <= dx:
                err += dx
                y0 += sy


def scanline_fill(buf, poly, spacing=1):
    h, w, _ = buf.shape
    # polygon: list of (x,y)整数坐标
    # 对每个 y (扫描线) 计算交点
    pts = [(int(round(x)), int(round(y))) for (x, y) in poly]
    n = len(pts)
    for y in range(0, h, spacing):
        inters = []
        for i in range(n):
            x0, y0 = pts[i]
            x1, y1 = pts[(i+1) % n]
            if y0 == y1:
                continue
            # 确保 y 在半开区间 [min(y0,y1), max(y0,y1)) 来避免重复
            ymin = min(y0, y1)
            ymax = max(y0, y1)
            if y < ymin or y >= ymax:
                continue
            # 线性插值求 x
            x = x0 + (y - y0) * (x1 - x0) / (y1 - y0)
            inters.append(x)
        if not inters:
            continue
        inters.sort()
        # 成对填充
        for k in range(0, len(inters), 2):
            if k+1 >= len(inters):
                break
            xstart = int(round(np.ceil(inters[k])))
            xend = int(round(np.floor(inters[k+1])))
            if xend < xstart:
                continue
            xstart = clamp(xstart, 0, w-1)
            xend = clamp(xend, 0, w-1)
            # 注意 buffer 的 y 轴 0 在顶部,所以我们直接使用 y 作为行索引
            buf[y, xstart:xend+1] = POLY_COLOR


def seed_fill(buf, seed):
    h, w, _ = buf.shape
    sx, sy = seed
    # buffer 的行索引 0 在顶部,但我们在输入时约定 y 从底部,之前输入是相对于左下角
    # parse_input 时我们已经使用了左下角坐标, 但 buffer 的原点是顶部(0) -> 转换:
    sy_buf = sy  # since we consistently used y as row index (y from 0 to h-1) assuming bottom-left
    # 为了安全,翻转一下:在本程序我们要求输入坐标相对于窗口左下, 但 buffer 行0为顶部, 所以映射:
    sy_buf = h - 1 - sy
    target = tuple(buf[sy_buf, sx])
    fillc = tuple(POLY_COLOR.tolist())
    if target == fillc:
        return
    stack = [(sx, sy_buf)]
    while stack:
        x, y = stack.pop()
        if x < 0 or x >= w or y < 0 or y >= h:
            continue
        if tuple(buf[y, x]) != target:
            continue
        buf[y, x] = POLY_COLOR
        stack.append((x+1, y))
        stack.append((x-1, y))
        stack.append((x, y+1))
        stack.append((x, y-1))


def prepare_image():
    create_empty_buffer()
    # draw polygon edges
    draw_polygon_edges(buffer_img, polygon)
    if algorithm == 'scanline':
        # scanline algorithm expects y as row index with 0 at bottom, but our buffer row 0 is top
        # So we'll adapt: create a temporary copy where row0 is bottom
        temp = np.flipud(buffer_img.copy())
        scanline_fill(temp, polygon, spacing=scan_spacing)
        # flip back
        np.copyto(buffer_img, np.flipud(temp))
    else:
        # seed fill: do mapping inside function
        seed_fill(buffer_img, seed_point)


def display():
    glClearColor(1.0, 1.0, 1.0, 1.0)
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
    glPixelZoom(1, 1)
    # glDrawPixels expects data with origin at bottom-left; our buffer is top-down, flip vertically
    img = np.flipud(buffer_img)
    glDrawPixels(WIN_W, WIN_H, GL_RGB, GL_UNSIGNED_BYTE, img)
    glutSwapBuffers()


def keyboard(key, x, y):
    k = key.decode() if isinstance(key, bytes) else key
    if k == '\x1b' or k == 'q':
        print("退出")
        sys.exit(0)
    if k == 'r':
        print("重新渲染")
        prepare_image()
        glutPostRedisplay()


def start_glut_display_from_gui():
    """在 GUI 按下 Render 后调用:启动 GLUT 窗口显示已经准备好的 buffer_img"""
    # 初始化 GLUT 并进入主循环
    glutInit(sys.argv)
    glutInitDisplayMode(GLUT_DOUBLE | GLUT_RGB)
    glutInitWindowSize(WIN_W, WIN_H)
    glutInitWindowPosition(100, 100)
    glutCreateWindow(b"Polygon Fill - Preview")
    glutDisplayFunc(display)
    glutKeyboardFunc(keyboard)
    glutReshapeFunc(reshape)
    print("按 q 或 ESC 退出, 按 r 重新渲染")
    glutMainLoop()


def gui_main():
    """简单的 Tkinter GUI:选择测试用例、手动输入顶点、选择算法和参数,然后渲染"""
    if tk is None:
        print("Tkinter 不可用,无法启动 GUI。请安装或使用命令行模式。")
        return
    root = tk.Tk()
    root.title("Polygon Fill - Simple GUI")

    tests = load_tests()

    # left: test list
    lf = ttk.Frame(root)
    lf.grid(row=0, column=0, sticky='ns', padx=6, pady=6)
    ttk.Label(lf, text='Tests').pack()
    listbox = tk.Listbox(lf, height=8)
    for i, t in enumerate(tests):
        listbox.insert(tk.END, f"{i}: {t.get('name','case'+str(i))} ({t.get('algorithm')})")
    listbox.pack()

    def load_selected():
        sel = listbox.curselection()
        if not sel:
            return
        case = tests[sel[0]]
        # fill fields
        alg_var.set(case.get('algorithm','scanline'))
        verts = '\n'.join(f"{x} {y}" for x, y in case['polygon'])
        vert_text.delete('1.0', tk.END)
        vert_text.insert(tk.END, verts)
        if case.get('algorithm') == 'seed':
            sx, sy = case.get('seed', (0,0))
            seed_entry.delete(0, tk.END)
            seed_entry.insert(0, f"{sx} {sy}")
        else:
            spacing_entry.delete(0, tk.END)
            spacing_entry.insert(0, str(case.get('spacing', 1)))

    ttk.Button(lf, text='Load Selected', command=load_selected).pack(pady=4)

    # right: controls and vertex editor
    rf = ttk.Frame(root)
    rf.grid(row=0, column=1, sticky='nsew', padx=6, pady=6)
    ttk.Label(rf, text='Vertices (x y per line)').grid(row=0, column=0, sticky='w')
    vert_text = tk.Text(rf, width=40, height=12)
    vert_text.grid(row=1, column=0, columnspan=2)

    alg_var = tk.StringVar(value='scanline')
    ttk.Label(rf, text='Algorithm:').grid(row=2, column=0, sticky='w')
    ttk.Radiobutton(rf, text='Scanline', variable=alg_var, value='scanline').grid(row=3, column=0, sticky='w')
    ttk.Radiobutton(rf, text='Seed Fill', variable=alg_var, value='seed').grid(row=3, column=1, sticky='w')

    ttk.Label(rf, text='Scan spacing:').grid(row=4, column=0, sticky='w')
    spacing_entry = ttk.Entry(rf)
    spacing_entry.insert(0, '1')
    spacing_entry.grid(row=4, column=1, sticky='w')

    ttk.Label(rf, text='Seed x y:').grid(row=5, column=0, sticky='w')
    seed_entry = ttk.Entry(rf)
    seed_entry.insert(0, '400 300')
    seed_entry.grid(row=5, column=1, sticky='w')

    status = ttk.Label(rf, text='')
    status.grid(row=6, column=0, columnspan=2, sticky='w')

    def on_render():
        global polygon, algorithm, scan_spacing, seed_point
        # parse vertices
        txt = vert_text.get('1.0', tk.END).strip()
        lines = [l.strip() for l in txt.splitlines() if l.strip()]
        poly = []
        try:
            for ln in lines:
                a = ln.split()
                if len(a) < 2:
                    raise ValueError('格式错误')
                x = clamp(int(round(float(a[0]))), 0, WIN_W-1)
                y = clamp(int(round(float(a[1]))), 0, WIN_H-1)
                poly.append((x,y))
            if len(poly) < 3:
                status.config(text='至少需要3个顶点')
                return
        except Exception:
            status.config(text='顶点格式错误: 每行 "x y"')
            return
        polygon = poly
        algorithm = alg_var.get()
        try:
            scan_spacing = max(1, int(spacing_entry.get()))
        except Exception:
            scan_spacing = 1
        try:
            svals = seed_entry.get().split()
            if len(svals) >= 2:
                sx = clamp(int(round(float(svals[0]))), 0, WIN_W-1)
                sy = clamp(int(round(float(svals[1]))), 0, WIN_H-1)
                seed_point = (sx, sy)
        except Exception:
            pass
        status.config(text='准备渲染... 窗口打开后按 q 退出')
        # prepare image
        prepare_image()
        root.destroy()
        start_glut_display_from_gui()

    ttk.Button(rf, text='Render', command=on_render).grid(row=7, column=0, pady=8)
    ttk.Button(rf, text='Quit', command=root.destroy).grid(row=7, column=1, pady=8)

    root.mainloop()


def reshape(w, h):
    global WIN_W, WIN_H, buffer_img
    WIN_W = w; WIN_H = h
    glViewport(0, 0, w, h)


def main():
    parse_input()
    prepare_image()
    # 初始化 GLUT
    glutInit(sys.argv)
    glutInitDisplayMode(GLUT_DOUBLE | GLUT_RGB)
    glutInitWindowSize(WIN_W, WIN_H)
    glutInitWindowPosition(100, 100)
    glutCreateWindow(b"Polygon Fill - Scanline and Seed Fill")
    glutDisplayFunc(display)
    glutKeyboardFunc(keyboard)
    glutReshapeFunc(reshape)
    print("按 q 或 ESC 退出, 按 r 重新渲染")
    glutMainLoop()

if __name__ == '__main__':
    main()

样例:polygon_tests.json
[
  {
    "name": "Triangle (scanline)",
    "algorithm": "scanline",
    "spacing": 1,
    "polygon": [[100,100],[700,150],[400,500]]
  },
  {
    "name": "Square (scanline coarse)",
    "algorithm": "scanline",
    "spacing": 4,
    "polygon": [[200,150],[600,150],[600,450],[200,450]]
  },
  {
    "name": "Concave Arrow (scanline)",
    "algorithm": "scanline",
    "spacing": 1,
    "polygon": [[150,300],[350,500],[450,400],[350,300],[450,200],[350,150],[150,300]]
  },
  {
    "name": "Star (seed fill)",
    "algorithm": "seed",
    "seed": [400,300],
    "polygon": [[400,520],[440,360],[580,360],[460,280],[520,140],[400,220],[280,140],[340,280],[220,360],[360,360]]
  },
  {
    "name": "Complex Convex (seed fill)",
    "algorithm": "seed",
    "seed": [450,350],
    "polygon": [[300,200],[600,220],[700,350],[600,480],[300,500],[220,350]]
  }
]

posted @ 2025-12-17 19:28  lumiere_cloud  阅读(2)  评论(0)    收藏  举报