欢迎关注『动手学 AI』系列



1. AI 正在改变编程方式

在进行 ROP(早产儿视网膜病变,Retinopathy of Prematurity)相关研究时,真实的数据集通常包含来源不同、尺寸不统一的视网膜图像,以及与之对应的分割掩模图像,需要对图像进行预处理。

在传统科研编程流程中,这类工作往往需要研究者从零开始编写脚本:读取数据、调试处理流程、反复修改结构,代码随着实验推进不断堆积,最终变得难以维护和复用。

本文的重点并不在于介绍具体的图像处理算法,而是记录一个更有意义的过程:*如何通过 AI 辅助编程,将一个模糊的研究需求逐步发展为结构清晰、可复用的工程项目。

在整个过程中,AI 并不是简单地“替代程序员写代码”,而是参与需求讨论、结构设计、代码重构和工程优化,成为类似“结对程序员(pair programmer)”的协作伙伴。通过这一实践,可以看到 AI 在科研编程中的真正价值——帮助研究者把精力从重复编码转移到问题建模与系统设计本身。


2. AI 辅助编程的整体流程

在本项目中,AI 参与了从想法到工程实现的逐步演进过程。整个开发过程可以概括为一个持续迭代的流程:首先由研究者提出实际需求,例如如何读取图像、如何统一尺寸、如何保证图像与掩模同步处理;随后通过与 AI 讨论实现思路,将问题拆解为若干可独立完成的小任务;再由 AI 协助生成初始代码,并在运行与测试过程中不断修改、优化和重构。随着功能增加,原本的单一脚本逐渐发展为支持批处理、参数控制和模块复用的完整预处理流程。

需求提出
   ↓
AI 讨论设计方案
   ↓
最小测试程序
   ↓
功能拆分
   ↓
工程重构
   ↓
模块化
   ↓
可复用Pipeline

这一过程体现了一种新的编程模式:人负责定义目标与判断方向,AI 负责提供实现建议与代码原型,二者通过反复交互不断完善系统结构。从最初的测试程序,到批量数据处理,再到公共模块的抽取与工程化重构,每一步都不是预先规划完成的,而是在 AI 辅助下逐步形成。实践表明,与其要求 AI “直接写好程序”,更有效的方法是让 AI 参与设计与迭代过程,使代码自然演化为一个清晰、稳定且可扩展的科研工具。


3. test1:从一个最小测试程序开始

我们从一个非常简单的问题出发:确认数据能够被正确读取与显示。在实际科研编程中,这是一个常被忽视却极其重要的步骤。如果基础的数据读取都没有验证,后续所有算法调试都会变得困难。因此,本项目的第一步,仅仅是建立一个最小测试程序:读取 raw 文件夹中的一张视网膜图像,并显示其尺寸信息。

在这一阶段,人机协作的方式非常典型。

  1. 研究者首先提出明确而具体的需求,而 AI 根据需求生成可运行的代码框架。例如最初的对话如下:

用户:
我要求先建立一个 Python 项目 ROP_preprocess。
我打算将 python 程序放在 .\ROP_preprocess 目录,子目录 .\ROP_preprocess\raw 保存待处理的图片, .\ROP_preprocess\run1 保存处理后的图片。
处理后的图片,每次运行结果保存在 run1,run2,… 目录下,依次类推。这是否合理,请给出目录结构。

AI 并没有直接生成程序,而是首先给出了工程结构建议,并指出如何组织目录以保证实验可复现。

ChatGPT:
推荐项目总体目录结构
ROP_preprocess/

├── main.py # 主入口程序
├── config.py # 预处理参数配置
├── utils.py # 通用工具函数
├── preprocess.py # 核心预处理流程

├── raw/ # 原始图像(永不修改)
│ ├── img001.jpg
│ ├── img002.jpg
│ └── …

├── runs/ # ⭐ 所有运行结果
│ ├── run1/
│ ├── run2/
│ ├── run3/
│ └── …

├── logs/ # 日志信息
│ └── preprocess.log

└── README.md

  1. 随后,需求进一步细化为一个最小功能验证程序:

用户:
请先写第一个测试程序 test1.py:读取一张 raw 目录下的视网膜图片,并用 matplotlib 库显示该图片,在图片下方显示:图片名称+size(h*w),例如 : 001.jpg(640x640)。

AI 根据这一需求生成了 test1.py,完成图像读取与显示功能。

  1. 程序运行成功后,又继续进行工程规范优化:

用户:
程序运行正常,请:

  • 在程序头部增加中文注释;
  • 对每个子程序增加中文注释;
  • 在 show_image 子程序中,增加将 title 同时 print 到终端。

可以看到,此时 AI 的角色已经从“代码生成器”转变为“代码整理与规范助手”。通过几轮简短交互,一个原本临时性质的测试脚本被逐步规范化,具备了清晰注释、函数划分和可扩展结构。

这一阶段的重要经验是:AI 辅助编程应从最小可运行程序(Minimum Working Example)开始。与其让 AI 一次性生成复杂系统,不如先完成一个简单且可验证的步骤,再在此基础上逐步扩展功能。这样的过程不仅降低错误率,也使整个工程的发展路径保持清晰可控。

本阶段的完整例程如下:

# ==============================================================
# test1.py
#
# 功能:
# 从 raw 目录读取一张视网膜眼底图像(Retinal Fundus Photograph),
# 使用 matplotlib 显示该图像,并显示图片标题:"图片名称 + 图像尺寸(HxW)
#
# 本程序用于:
# 1. 验证 Python 图像读取环境是否正常
# 2. 验证 raw 数据目录结构
# 3. 作为 ROP_preprocess 项目的第一个测试程序
#
# 本程序由 youcans@qq.com 在 ChatGPT 辅助下实现。
# Implemented by youcans@qq.com with ChatGPT assistance.
# ==============================================================


import os
import cv2
import matplotlib.pyplot as plt


# ==============================================================
# 配置参数
# ==============================================================
RAW_DIR = "./raw"   # 原始视网膜图像所在目录


# ==============================================================
# 函数:load_first_image
#
# 功能:
# 从 raw 目录中读取第一张图像文件。
#
# 输入:
#   raw_dir : 图像目录路径
#
# 输出:
#   img       : 读取到的图像(OpenCV格式)
#   filename  : 图像文件名
#
# 说明:
# 自动筛选常见图像格式,避免手动指定文件名。
# ==============================================================
def load_first_image(raw_dir):

    files = sorted(os.listdir(raw_dir))

    # 仅保留图像文件
    image_files = [
        f for f in files
        if f.lower().endswith((".jpg", ".jpeg", ".png", ".bmp"))
    ]

    if len(image_files) == 0:
        raise RuntimeError("raw 目录中未找到图像文件!")

    filename = image_files[0]
    filepath = os.path.join(raw_dir, filename)

    img = cv2.imread(filepath)

    if img is None:
        raise RuntimeError(f"图像读取失败: {filename}")

    return img, filename


# ==============================================================
# 函数:show_image
#
# 功能:
# 使用 matplotlib 显示图像,并在标题中显示:
#       文件名 + 图像尺寸(HxW)
#
# 同时:
#   将标题信息输出(print)到终端。
#
# 输入:
#   img       : OpenCV读取的图像(BGR格式)
#   filename  : 文件名
#
# 注意:
# OpenCV 默认使用 BGR,
# matplotlib 使用 RGB,因此需要颜色空间转换。
# ==============================================================
def show_image(img, filename):

    # OpenCV(BGR) → matplotlib(RGB)
    img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)

    # 获取图像尺寸
    h, w = img_rgb.shape[:2]

    title_text = f"{filename} ({h}x{w})"

    # ===== 在终端打印 =====
    print(title_text)

    # ===== matplotlib显示 =====
    plt.figure(figsize=(6, 6))
    plt.imshow(img_rgb)
    plt.axis("off")

    plt.title(title_text, fontsize=12)

    plt.tight_layout()
    plt.show()


# ==============================================================
# 主程序入口
#
# 程序执行流程:
#   1. 从 raw 目录读取图像
#   2. 显示图像及尺寸信息
# ==============================================================
if __name__ == "__main__":

    img, filename = load_first_image(RAW_DIR)

    show_image(img, filename)

4. test2:从单一功能到可组合的预处理流程

在完成 test1.py 之后,项目已经能够正确读取并显示视网膜图像,但这仍然只是数据验证阶段。真正的需求开始出现:如何对图像进行预处理,并方便比较不同处理方法的效果。在传统编程方式中,人们往往直接在一个脚本中连续叠加各种操作,例如裁剪、滤波、增强等,代码虽然可以运行,但很快会变得难以修改和复用。因此,这一阶段的核心目标并不是实现某一种图像增强方法,而是建立可实验、可比较的处理结构。

这一设计思路同样是在与 AI 的对话过程中逐渐明确的。例如在提出需求时:

用户:
很好,现在我们准备对这张照片进行处理。
首先,请考虑如下几个处理方法:
(1)尺寸调整:裁剪掉图像的黑色边缘,然后创建一个圆形遮罩,半径为裁剪图像宽度或高度的最小值的一半(保证裁剪后的图像始终是正方形)。并将图像尺寸统一调整为 hw=640640。
(2)高斯滤波,在 X 轴应用高斯滤波,标准差作为函数输入值,再与裁剪图像通过加权和的方式进行图像融合,高斯标准差作为函数输入值,缺失值设为 15。
(3)伽马校正:增加/减少亮度值(东亚人、印度雅利安人、亚洲人和非裔美国人的视网膜图像更暗)。通过几何平均值计算每个像素的平均亮度(不包括遮罩),定义了阈值 bmin,如果图像的平均亮度低于 bmin,则通过简单的线性图像转换来增加亮度。bmin 作为函数输入值,缺失值设为 80。
(4)自适应直方图均衡(CLAHE):clipLimit=2.2 , tileGridSize=(8, 8) 作为函数输入值。
(5)HSV 自适应直方图均衡(CLAHE):图像首先被转换为HSV色彩空间,仅对通道 V 应用 CLAHE算法,以增强图像的亮度,最后将处理后的图像转换回RGB格式。
请注意将每个处理步骤写成一个单独的函数,以便比较和组合使用。

这一句话实际上决定了后续整个工程的结构。AI 随后给出的实现建议是,将所有预处理操作拆分为独立模块,例如 M1(尺寸统一)、M2(高斯融合)、M3(亮度校正)、M4(CLAHE 增强),并分别封装为函数,而不是顺序写在同一流程中。这样做的直接结果是,每一种方法都可以单独调用、对比或重新组合。

随着需求继续推进,人机协作开始进入更典型的“共同设计”阶段。例如:

用户:
在本程序中,上述预处理是分别进行的,不是顺序进行。将原始图像和 5种预处理图像以 matplotlib 按 2行 3列显示。

AI 据此调整程序结构,使所有处理方法基于同一输入图像独立执行,并统一进行可视化展示。

ChatGPT:
下面给你一份可直接运行的 test2.py,满足你提出的全部要求:

  • 每个预处理步骤单独封装成函数(便于对比/组合/消融实验)
  • 预处理是分别进行(不是顺序串联);但为了公平对比,(2)-(5) 默认以 (1) 的“裁剪+圆形遮罩+统一尺寸 640×640”的结果作>为输入基准(这也是工程实践里最常见的做法)
  • matplotlib 以 2 行 3 列显示:原图 + 5种预处理图
  • 将 5种预处理结果保存到 runs/run1/,PNG 格式(我也顺手把原图一起保存,便于复现对照)

此时,代码的重点已经从“处理图像”转变为“支持实验比较”。随后又进一步提出改进需求:

用户:
设置开关,可以按现有方式分别进行,也可以通过开关选择顺序串联进行。

AI 将程序升级为同时支持 separate(分别处理) 与 sequential(顺序串联) 两种模式,使研究者能够直观比较不同 preprocessing pipeline 对结果的影响。

这一阶段最重要的收获,并不是实现了多少图像处理算法,而是通过 AI 辅助完成了一次关键的工程转变:

  • 从 单脚本处理
  • 转变为 Pipeline 化设计
  • 再发展为 可组合实验框架

可以说,test2.py 标志着项目从“能运行的代码”迈向“可用于科研实验的工具”。AI 在这一过程中发挥的作用,并非替代算法设计,而是不断提醒并推动代码向模块化和可扩展方向演进,使后续的数据集处理与工程重构成为可能。

本阶段的完整例程如下:

# ==============================================================
# test2.py
#
# 功能:
# 读取 raw/ 目录下的一张视网膜眼底图像(Retinal Fundus Photograph),
# 对同一张图像同时生成两类结果:
#   A) 分别处理(separate):显示 M1~M4 各自单独作用的结果
#   B) 顺序串联(sequential):显示 M1→M2→M3→M4 依次处理后的结果
#
# 预处理方法:
#   (1) 尺寸调整:裁剪黑边 + 正方形裁剪 + 圆形遮罩 + resize到 640x640
#   (2) 高斯滤波 + 加权融合(sigmaX 可设,默认 15)
#   (3) 亮度校正:遮罩内几何平均亮度低于 bmin 则线性增亮(bmin 默认 80)
#   (4) CLAHE(带模式开关):
#       mode1: 转 HSV,仅对 V 通道做 CLAHE,再转换回 RGB/BGR
#       mode2: 拆分 B,G,R,仅对 G 通道做 CLAHE,再合并回 RGB/BGR
#
# 显示:
#   2行3列:图1 原图;图2-5 为 M1~M4(separate);图6 为 sequential(M1→M2→M3→M4)
#
# 保存:
#   保存 M1~M4(separate)以及 sequential 结果到 runs/run1/(PNG)
#
# 本程序由 youcans@qq.com 在 ChatGPT 辅助下实现。
# Implemented by youcans@qq.com with ChatGPT assistance.
# ==============================================================

import os
import cv2
import numpy as np
import matplotlib.pyplot as plt


# ==============================================================
# 配置参数
# ==============================================================
RAW_DIR = "./raw"
RUN_DIR = "./runs/run1"
OUT_SIZE = (640, 640)  # (W, H) for OpenCV


# ==============================================================
# 工具函数:创建目录
# ==============================================================
def ensure_dir(path: str) -> None:
    """确保目录存在;不存在则创建。"""
    os.makedirs(path, exist_ok=True)


# ==============================================================
# 工具函数:读取 raw/ 中第一张图
# ==============================================================
def load_first_image(raw_dir: str):
    """
    从 raw 目录读取第一张图像。
    返回:
        img_bgr: OpenCV BGR 图像
        filename: 文件名
    """
    files = sorted(os.listdir(raw_dir))
    image_files = [f for f in files if f.lower().endswith((".jpg", ".jpeg", ".png", ".bmp", ".tif", ".tiff"))]
    if not image_files:
        raise RuntimeError("raw 目录中未找到图像文件!")

    filename = image_files[0]
    path = os.path.join(raw_dir, filename)
    img_bgr = cv2.imread(path, cv2.IMREAD_COLOR)
    if img_bgr is None:
        raise RuntimeError(f"图像读取失败: {filename}")
    return img_bgr, filename


# ==============================================================
# (1) 尺寸调整:裁剪黑边 + 正方形中心裁剪 + 圆形遮罩 + resize到 640x640
# ==============================================================
def preprocess_crop_square_circle_resize(img_bgr: np.ndarray, out_size=(640, 640), thresh=10):
    """
    (1) 裁剪黑色边缘,保证裁剪后图像为正方形,并创建圆形遮罩(半径=正方形边长/2),
        最后统一 resize 到 out_size (W,H)。

    返回:
        out_bgr: 处理后的 BGR 图像(640x640)
        out_mask: 圆形mask(uint8, 0/255),与 out_bgr 同尺寸
    """
    h, w = img_bgr.shape[:2]

    # 1) 找到非黑区域(简单阈值,适用于大多数眼底黑背景)
    gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY)
    mask_nonblack = (gray > thresh).astype(np.uint8) * 255

    ys, xs = np.where(mask_nonblack > 0)
    if len(xs) == 0 or len(ys) == 0:
        # 极端情况:整张图都很暗/几乎黑,直接使用原图
        crop = img_bgr.copy()
    else:
        x1, x2 = xs.min(), xs.max()
        y1, y2 = ys.min(), ys.max()
        crop = img_bgr[y1:y2 + 1, x1:x2 + 1]

    # 2) 正方形中心裁剪(保证最终圆形遮罩稳定)
    ch, cw = crop.shape[:2]
    side = min(ch, cw)
    cy, cx = ch // 2, cw // 2
    y1 = max(cy - side // 2, 0)
    x1 = max(cx - side // 2, 0)
    square = crop[y1:y1 + side, x1:x1 + side]

    # 3) 创建圆形遮罩(半径=side/2)
    circle_mask = np.zeros((side, side), dtype=np.uint8)
    center = (side // 2, side // 2)
    radius = side // 2
    cv2.circle(circle_mask, center, radius, 255, thickness=-1)

    # 4) 应用遮罩:遮罩外置黑
    square_masked = square.copy()
    square_masked[circle_mask == 0] = 0

    # 5) resize 到 640x640(BGR)
    out_bgr = cv2.resize(square_masked, out_size, interpolation=cv2.INTER_AREA)
    out_mask = cv2.resize(circle_mask, out_size, interpolation=cv2.INTER_NEAREST)

    return out_bgr, out_mask


# ==============================================================
# (2) 高斯滤波 + 加权融合(默认 sigma=15)
# ==============================================================
def preprocess_gaussian_x_fusion(img_bgr: np.ndarray, sigma_x: float = 15.0, alpha: float = 1.5, beta: float = -0.5):
    """
    (2) 在 X 轴应用高斯滤波(sigma_x),再与原图通过加权和融合。
    默认参数 alpha=1.5, beta=-0.5 类似“锐化增强”的融合方式。

    返回:
        out_bgr: 处理后的 BGR 图像
    """
    # ksize=(0,0) 表示由 sigma 自动推断核大小;sigmaY=0 表示与 sigmaX 相同/自动
    blurred = cv2.GaussianBlur(img_bgr, ksize=(0, 0), sigmaX=sigma_x, sigmaY=0)
    out = cv2.addWeighted(img_bgr, alpha, blurred, beta, 0)
    return out


# ==============================================================
# (3) 伽马/亮度校正(基于几何平均亮度;默认 bmin=80)
# ==============================================================
def preprocess_brightness_by_geomean(img_bgr: np.ndarray, circle_mask: np.ndarray, bmin: float = 80.0, eps: float = 1e-6):
    """
    (3) 根据“遮罩内像素”的几何平均亮度(geometric mean brightness)判断是否偏暗;
        若低于 bmin,则做简单线性增强(提高亮度)。
    注:这里用 HSV 的 V 通道衡量亮度(0~255)。

    返回:
        out_bgr: 处理后的 BGR 图像
        gm: 几何平均亮度(用于调试/日志)
    """
    hsv = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HSV)
    v = hsv[:, :, 2].astype(np.float32)

    # 仅统计遮罩内亮度
    valid = (circle_mask > 0)
    v_valid = v[valid]
    if v_valid.size == 0:
        return img_bgr.copy(), float("nan")

    # 几何平均:exp(mean(log(v+eps)))
    gm = float(np.exp(np.mean(np.log(v_valid + eps))))

    out = img_bgr.copy()
    if gm < bmin:
        # 线性增强:将整体亮度按比例拉升,比例 = bmin/gm(并限制上限,避免过曝)
        scale = bmin / max(gm, eps)
        scale = min(scale, 2.0)  # 防止极端过亮
        out = cv2.convertScaleAbs(out, alpha=scale, beta=0)

    return out, gm


# ==============================================================
# (4) CLAHE(带模式开关)
# ==============================================================
def preprocess_clahe(img_bgr: np.ndarray,
                     clip_limit: float = 2.2,
                     tile_grid_size=(8, 8),
                     mode: str = "mode1"):
    """
    (4) CLAHE(对比度/亮度增强),提供两种模式:
    mode1:
        转换为 HSV 色彩空间,仅对 V 通道应用 CLAHE,
        然后转换回 BGR(matplotlib 显示时再转 RGB)。
    mode2:
        拆分 B,G,R 三通道,仅对 G(绿色)通道应用 CLAHE,
        然后合并回 BGR。
    返回:
        out_bgr: 处理后的 BGR 图像
    """
    clahe = cv2.createCLAHE(clipLimit=clip_limit, tileGridSize=tile_grid_size)

    if mode == "mode1":
        hsv = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HSV)
        h, s, v = cv2.split(hsv)
        v2 = clahe.apply(v)
        hsv2 = cv2.merge([h, s, v2])
        out = cv2.cvtColor(hsv2, cv2.COLOR_HSV2BGR)
        return out

    elif mode == "mode2":
        b, g, r = cv2.split(img_bgr)
        g2 = clahe.apply(g)
        out = cv2.merge([b, g2, r])
        return out

    else:
        raise ValueError('mode must be "mode1" or "mode2".')


# ==============================================================
# 可视化:2行3列显示(原图 + 5种预处理)
# ==============================================================
def show_2x3(original_bgr: np.ndarray, images_bgr: dict):
    """
    将 原图 + 5张处理图 按 2x3 显示。
    images_bgr: dict{name -> bgr_image}
    """
    # BGR -> RGB 用于 matplotlib
    def bgr2rgb(img):
        return cv2.cvtColor(img, cv2.COLOR_BGR2RGB)

    fig, axes = plt.subplots(2, 3, figsize=(12, 8))
    axes = axes.flatten()

    # 0: 原图
    h0, w0 = original_bgr.shape[:2]
    axes[0].imshow(bgr2rgb(original_bgr))
    axes[0].set_title(f"Original ({h0}x{w0})")
    axes[0].axis("off")

    # 1~5: 处理图
    for i, (name, img) in enumerate(images_bgr.items(), start=1):
        h, w = img.shape[:2]
        axes[i].imshow(bgr2rgb(img))
        axes[i].set_title(f"{name} ({h}x{w})")
        axes[i].axis("off")

    plt.tight_layout()
    plt.show()


# ==============================================================
# 保存:将处理结果保存到 runs/run1(png)
# ==============================================================
def save_results(run_dir: str, base_filename: str, images_bgr: dict):
    """
    将 images_bgr 中的各图像保存为 png。
    文件名格式:<原名去扩展名>__<处理名>.png
    """
    ensure_dir(run_dir)
    stem = os.path.splitext(base_filename)[0]

    for name, img in images_bgr.items():
        out_name = f"{stem}__{name}.png"
        out_path = os.path.join(run_dir, out_name)
        cv2.imwrite(out_path, img)


# ==============================================================
# 主程序
# ==============================================================
if __name__ == "__main__":

    # ---------- M4 模式选择 ----------
    # mode1: HSV 的 V 通道 CLAHE
    # mode2: Green 通道 CLAHE
    M4_MODE = "mode2"  # 可改为 "mode2"

    # 1) 读取原图
    img0_bgr, fname = load_first_image(RAW_DIR)

    # 2) M1:裁剪+遮罩+统一尺寸(作为基准)
    img1_bgr, mask1 = preprocess_crop_square_circle_resize(img0_bgr, out_size=OUT_SIZE)

    # ======================================================
    # A) separate:M2~M4 都以 img1_bgr 作为输入基准
    # ======================================================
    img2_bgr = preprocess_gaussian_x_fusion(img1_bgr, sigma_x=15.0)
    img3_bgr, gm_sep = preprocess_brightness_by_geomean(img1_bgr, mask1, bmin=80.0)
    img4_bgr = preprocess_clahe(img1_bgr, clip_limit=2.2, tile_grid_size=(8, 8), mode=M4_MODE)

    # ======================================================
    # B) sequential:M1→M2→M3→M4 依次处理
    # ======================================================
    seq2 = preprocess_gaussian_x_fusion(img1_bgr, sigma_x=15.0)
    seq3, gm_seq = preprocess_brightness_by_geomean(seq2, mask1, bmin=80.0)
    seq4 = preprocess_clahe(seq3, clip_limit=2.2, tile_grid_size=(8, 8), mode=M4_MODE)

    # 4) 组织用于显示/保存的结果(图2-5为 M1~M4;图6为 sequential)
    results = {
        "M1_crop_mask_resize": img1_bgr,
        "M2_gauss_fusion": img2_bgr,
        "M3_brightness_bmin80": img3_bgr,
        f"M4_CLAHE_{M4_MODE}": img4_bgr,
        "Sequential_M1_M2_M3_M4": seq4,
    }

    # 5) 终端输出关键调试信息
    print(f"Input image: {fname}, size={img0_bgr.shape[0]}x{img0_bgr.shape[1]}")
    print(f"M4_MODE: {M4_MODE}")
    print(f"M3 geometric mean brightness (masked) - separate:   {gm_sep:.2f}")
    print(f"M3 geometric mean brightness (masked) - sequential: {gm_seq:.2f}")

    # 6) 显示:原图 + 5张结果(2x3)
    show_2x3(img0_bgr, results)

    # 7) 保存:M1~M4(separate)+ sequential 结果到 runs/run1(png)
    save_results(RUN_DIR, fname, results)

    print(f"Saved to: {RUN_DIR}")

运行结果如下:

请添加图片描述


5. test3:从从单张图像到数据集批处理

在 test2.py 中,图像预处理流程已经被拆分为多个独立步骤,并能够直观比较不同处理方法的效果。但在真实科研任务中,一个关键问题很快出现:实验对象不再是一张图像,而是整个数据集。手动逐张处理显然不可行,因此下一步的目标变为——将已经验证正确的处理流程扩展为稳定的批处理程序。

这一阶段的需求同样来源于实际使用场景,而不是算法本身。例如:

用户:
本程序的任务是,读取指定文件夹 RAW_DIR 中的原始图像,将其逐一处理,处理规则为 M1→M2→M3→M4(mode2),处理后的图片保存到文件夹 RUN_DIR 中。

可以看到,此时研究者已经不再关注单个函数如何实现,而是关注如何组织整个处理流程。AI 在这一阶段的主要作用,是帮助将已有代码进行“结构迁移”:保留 test2.py 中已经验证过的函数,同时增加目录遍历、批量读取、自动命名与统一输出机制,使程序能够直接作用于完整数据集。

在人机交互过程中,一个重要原则逐渐形成:已经验证正确的代码尽量复用,而不是重新生成。

AI 根据这一思路,将单图处理逻辑封装为标准流水线,并在循环中依次执行,从而实现:

  • 自动遍历数据目录
  • 统一执行 M1→M4 处理流程
  • 自动生成输出文件
  • 保持文件名与原数据一致

ChatGPT:
下面给你一个可直接运行的 test3.py(尽可能继承 test2.py 的结构与函数),实现:

  • 从 RAW_DIR 读取所有原始图像
  • 对每张图按 M1→M2→M3→M4(mode2) 顺序处理
  • 输出保存到 RUN_DIR,PNG 格式
  • 每个步骤仍是独立函数,方便后续消融与组合

与此同时,研究者又提出了一个典型的数据集管理需求:

处理后的文件名,在原文件名前添加前缀,例如 g1.jpg → img_g1.png。

AI 仅通过修改保存函数的一行代码即可完成需求,而无需改动整体结构。这种“小范围修改即可完成新需求”的能力,正是前一阶段模块化设计带来的直接收益。

因此,test3.py 的意义并不仅仅是实现批处理,而是验证了一件重要事实:当代码结构合理时,AI 可以非常高效地协助扩展功能,而不会破坏已有系统。项目也由此从实验验证脚本,进一步发展为能够直接用于数据集准备的实际工具,为后续分割任务的数据处理奠定了基础。

本阶段的完整例程如下:

# ==============================================================
# test3.py
#
# 功能:
# 批量读取 RAW_DIR 中的原始视网膜眼底图像(Retinal Fundus Photographs),
# 对每张图像按如下顺序进行预处理:
#     M1 → M2 → M3 → M4(mode2)
#
# 处理后图像保存到 RUN_DIR 中,保存为 PNG 格式。
#
# 本程序由 youcans@qq.com 在 ChatGPT 辅助下实现。
# Implemented by youcans@qq.com with ChatGPT assistance.
# ==============================================================

import os
import cv2
import numpy as np


# ==============================================================
# 配置参数
# ==============================================================
RAW_DIR = "./GAMMA_task3"
RUN_DIR = "./runs/GAMMA_task3"      # 你也可以改成 run2/run3...
OUT_SIZE = (640, 640)        # OpenCV: (W, H)

# M2 参数
M2_SIGMA_X = 15.0
M2_ALPHA = 1.5
M2_BETA = -0.5

# M3 参数
M3_BMIN = 80.0

# M4 参数(固定 mode2:仅绿色通道 CLAHE)
M4_MODE = "mode2"
M4_CLIP_LIMIT = 2.2
M4_TILE_GRID = (8, 8)


# ==============================================================
# 工具函数:创建目录
# ==============================================================
def ensure_dir(path: str) -> None:
    """确保目录存在;不存在则创建。"""
    os.makedirs(path, exist_ok=True)


# ==============================================================
# 工具函数:列出 RAW_DIR 中全部图像文件(按文件名排序)
# ==============================================================
def list_images(raw_dir: str):
    """返回 raw_dir 下的图像文件名列表(排序后)。"""
    if not os.path.isdir(raw_dir):
        raise RuntimeError(f"RAW_DIR 不存在: {raw_dir}")

    files = sorted(os.listdir(raw_dir))
    image_files = [
        f for f in files
        if f.lower().endswith((".jpg", ".jpeg", ".png", ".bmp", ".tif", ".tiff"))
    ]
    return image_files


# ==============================================================
# 工具函数:读取图像(BGR)
# ==============================================================
def load_image(path: str) -> np.ndarray:
    """读取一张图像为 OpenCV BGR 格式。"""
    img = cv2.imread(path, cv2.IMREAD_COLOR)
    if img is None:
        raise RuntimeError(f"图像读取失败: {path}")
    return img


# ==============================================================
# (1) 尺寸调整:裁剪黑边 + 正方形中心裁剪 + 圆形遮罩 + resize到 640x640
# ==============================================================
def preprocess_crop_square_circle_resize(img_bgr: np.ndarray, out_size=(640, 640), thresh=10):
    """
    M1: 裁剪黑色边缘,保证裁剪后图像为正方形,并创建圆形遮罩(半径=边长/2),
        最后 resize 到 out_size (W,H)。

    返回:
        out_bgr: 处理后的 BGR 图像(640x640)
        out_mask: 圆形mask(uint8, 0/255),与 out_bgr 同尺寸
    """
    gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY)
    mask_nonblack = (gray > thresh).astype(np.uint8) * 255

    ys, xs = np.where(mask_nonblack > 0)
    if len(xs) == 0 or len(ys) == 0:
        crop = img_bgr.copy()
    else:
        x1, x2 = xs.min(), xs.max()
        y1, y2 = ys.min(), ys.max()
        crop = img_bgr[y1:y2 + 1, x1:x2 + 1]

    ch, cw = crop.shape[:2]
    side = min(ch, cw)
    cy, cx = ch // 2, cw // 2
    y1 = max(cy - side // 2, 0)
    x1 = max(cx - side // 2, 0)
    square = crop[y1:y1 + side, x1:x1 + side]

    circle_mask = np.zeros((side, side), dtype=np.uint8)
    center = (side // 2, side // 2)
    radius = side // 2
    cv2.circle(circle_mask, center, radius, 255, thickness=-1)

    square_masked = square.copy()
    square_masked[circle_mask == 0] = 0

    out_bgr = cv2.resize(square_masked, out_size, interpolation=cv2.INTER_AREA)
    out_mask = cv2.resize(circle_mask, out_size, interpolation=cv2.INTER_NEAREST)

    return out_bgr, out_mask


# ==============================================================
# (2) 高斯滤波 + 加权融合(sigmaX 默认 15)
# ==============================================================
def preprocess_gaussian_x_fusion(img_bgr: np.ndarray, sigma_x: float = 15.0, alpha: float = 1.5, beta: float = -0.5):
    """
    M2: 在 X 轴应用高斯滤波(sigma_x),再与原图通过加权和融合。
    """
    blurred = cv2.GaussianBlur(img_bgr, ksize=(0, 0), sigmaX=sigma_x, sigmaY=0)
    out = cv2.addWeighted(img_bgr, alpha, blurred, beta, 0)
    return out


# ==============================================================
# (3) 亮度校正(基于遮罩内几何平均亮度;默认 bmin=80)
# ==============================================================
def preprocess_brightness_by_geomean(img_bgr: np.ndarray, circle_mask: np.ndarray, bmin: float = 80.0, eps: float = 1e-6):
    """
    M3: 计算遮罩内像素亮度(HSV的V通道)的几何平均值 gm;
        若 gm < bmin,则用线性缩放提高亮度。
    返回:
        out_bgr, gm
    """
    hsv = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HSV)
    v = hsv[:, :, 2].astype(np.float32)

    valid = (circle_mask > 0)
    v_valid = v[valid]
    if v_valid.size == 0:
        return img_bgr.copy(), float("nan")

    gm = float(np.exp(np.mean(np.log(v_valid + eps))))

    out = img_bgr.copy()
    if gm < bmin:
        scale = bmin / max(gm, eps)
        scale = min(scale, 2.0)  # 防止极端过曝
        out = cv2.convertScaleAbs(out, alpha=scale, beta=0)

    return out, gm


# ==============================================================
# (4) CLAHE(mode2:仅绿色通道 CLAHE)
# ==============================================================
def preprocess_clahe(img_bgr: np.ndarray,
                     clip_limit: float = 2.2,
                     tile_grid_size=(8, 8),
                     mode: str = "mode2"):
    """
    M4:
    mode1: HSV-V 通道 CLAHE
    mode2: 仅绿色通道 CLAHE(本程序固定使用 mode2)
    """
    clahe = cv2.createCLAHE(clipLimit=clip_limit, tileGridSize=tile_grid_size)

    if mode == "mode1":
        hsv = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HSV)
        h, s, v = cv2.split(hsv)
        v2 = clahe.apply(v)
        hsv2 = cv2.merge([h, s, v2])
        out = cv2.cvtColor(hsv2, cv2.COLOR_HSV2BGR)
        return out

    elif mode == "mode2":
        b, g, r = cv2.split(img_bgr)
        g2 = clahe.apply(g)
        out = cv2.merge([b, g2, r])
        return out

    else:
        raise ValueError('mode must be "mode1" or "mode2".')


# ==============================================================
# 保存:保存为 PNG(与原名同 stem)
# ==============================================================
def save_png(run_dir: str, filename: str, img_bgr: np.ndarray) -> str:
    """
    保存图像为 PNG:
        <stem>.png
    返回保存路径
    """
    ensure_dir(run_dir)
    stem = os.path.splitext(filename)[0]
    # out_path = os.path.join(run_dir, f"{stem}.png")
    out_path = os.path.join(run_dir, f"img_{stem}.png")
    ok = cv2.imwrite(out_path, img_bgr)
    if not ok:
        raise RuntimeError(f"保存失败: {out_path}")
    return out_path


# ==============================================================
# 主程序:批量处理
# ==============================================================
if __name__ == "__main__":

    ensure_dir(RUN_DIR)

    img_files = list_images(RAW_DIR)
    if not img_files:
        raise RuntimeError("RAW_DIR 中未找到任何图像文件。")

    print(f"RAW_DIR = {RAW_DIR}")
    print(f"RUN_DIR = {RUN_DIR}")
    print(f"Total images = {len(img_files)}")
    print("Pipeline: M1 -> M2 -> M3 -> M4(mode2)")

    for idx, fname in enumerate(img_files, start=1):
        in_path = os.path.join(RAW_DIR, fname)

        # 读取原图
        img0 = load_image(in_path)

        # M1
        img1, mask1 = preprocess_crop_square_circle_resize(img0, out_size=OUT_SIZE)

        # M2
        img2 = preprocess_gaussian_x_fusion(img1, sigma_x=M2_SIGMA_X, alpha=M2_ALPHA, beta=M2_BETA)

        # M3
        img3, gm = preprocess_brightness_by_geomean(img2, mask1, bmin=M3_BMIN)

        # M4 (mode2)
        img4 = preprocess_clahe(img3, clip_limit=M4_CLIP_LIMIT, tile_grid_size=M4_TILE_GRID, mode=M4_MODE)

        # 保存
        out_path = save_png(RUN_DIR, fname, img4)

        print(f"[{idx:03d}/{len(img_files):03d}] {fname}  gm={gm:.2f}  ->  {out_path}")

    print("Done.")

运行结果如下:

在这里插入图片描述


6. test4:图像与掩模的同步处理

当预处理流程能够稳定地批量处理原始图像之后,项目进入了一个真正具有工程难度的阶段——分割任务中的掩模(mask)同步处理问题。在医学图像分割数据集中,除了原始图像外,通常还会提供对应的标注掩模,两者文件名一致但存放在不同目录中。然而,在前面的预处理中,图像已经经历了裁剪、正方形化以及缩放等操作,如果只处理原始图像而忽略掩模,就会导致训练数据与标签空间位置不一致,从而使后续模型训练完全失效。

这一问题并不能简单通过“对掩模重复执行相同代码”来解决,因为关键在于:掩模必须使用与原图完全一致的几何变换参数。因此,在与 AI 的讨论过程中,需求被重新表述为:

用户:
在以上预处理过程中,可能对原始图像进行了裁剪或缩放,因此要对掩模图像进行相同的处理。

AI 在这里给出的核心思路并不是继续修改处理流程,而是引入一个更工程化的概念——先计算变换参数,再应用变换。具体而言,程序首先从原始图像计算裁剪区域、正方形位置以及圆形遮罩等几何参数(M1),然后将这些参数保存下来,并用于对应掩模的处理。这样,掩模并不需要重新“判断如何裁剪”,而是直接复用原图的空间变换结果。

这一设计带来了两个重要变化。首先,图像处理从“像素操作”升级为“几何变换管理”,程序结构更加清晰可靠;其次,AI 协助识别并避免了一个常见错误——对掩模使用双线性插值缩放。通过人机讨论,最终采用最近邻插值(INTER_NEAREST),从而保证分割标签不会被破坏。

ChatGPT:
下面是基于 test3.py 扩展得到的 test4.py(完整可运行)。它实现:

  • 读取 RAW_DIR 中所有原始图像
  • 从 MASK_DIR 中按 同名(stem 相同,后缀可不同) 找到对应掩模
  • 对每对(图像、掩模):
    原始图像按 M1→M2→M3→M4(mode2) 处理,保存到 RUN_DIR
    掩模图像按与该原图 完全一致的 M1(裁剪+正方形+圆形遮罩+resize) 处理,保存到 RUN_MASK_DIR
  • 输出文件名统一加前缀:img_.png(如 g1.jpg → img_g1.png)

可以说,test4.py 是整个项目中最具工程意义的一步。AI 在这一阶段的价值,不再体现在生成代码,而体现在帮助研究者重新抽象问题本身:从“如何处理两张图”转变为“如何保证两类数据共享同一空间变换”。这一步的完成,使得预处理流程真正具备了面向分割任务的数据一致性基础。

本阶段的完整例程如下:

# ==============================================================
# test4.py
#
# 功能:
# 批量读取 RAW_DIR 中的原始视网膜眼底图像,同时读取 MASK_DIR 中对应掩模图像(同名stem,后缀可不同)。
# 对原图执行预处理流水线:
#     M1 → M2 → M3 → M4(mode2)
# 并保存到 RUN_DIR(PNG)。
#
# 对掩模图像执行与对应原图完全一致的 “尺寸处理 M1”(裁剪/正方形/圆形遮罩/resize),
# 并保存到 RUN_MASK_DIR(PNG),文件名前缀同样添加 "img_"。
#
# 本程序由 youcans@qq.com 在 ChatGPT 辅助下实现。
# Implemented by youcans@qq.com with ChatGPT assistance.
# ==============================================================

import os
import cv2
import numpy as np


# ==============================================================
# 配置参数
# ==============================================================
RAW_DIR = "./GAMMA_task3/images"   # 原图所在目录(请按实际修改)
MASK_DIR = "./GAMMA_task3/mask"   # 掩模所在目录(请按实际修改)
RUN_DIR = "./runs/GAMMA_task3/images"     # 原图处理结果输出目录(你可按需改)
RUN_MASK_DIR = "./runs/GAMMA_task3/mask"  # 掩模处理结果输出目录(你可按需改)

OUT_SIZE = (640, 640)  # OpenCV: (W, H)

# M2 参数
M2_SIGMA_X = 15.0
M2_ALPHA = 1.5
M2_BETA = -0.5

# M3 参数
M3_BMIN = 80.0

# M4 参数(固定 mode2:仅绿色通道 CLAHE)
M4_MODE = "mode2"
M4_CLIP_LIMIT = 2.2
M4_TILE_GRID = (8, 8)

# M1 黑边阈值
M1_THRESH = 10


# ==============================================================
# 工具函数:创建目录
# ==============================================================
def ensure_dir(path: str) -> None:
    """确保目录存在;不存在则创建。"""
    os.makedirs(path, exist_ok=True)


# ==============================================================
# 工具函数:列出目录中全部图像文件(按文件名排序)
# ==============================================================
def list_images(dir_path: str):
    """返回 dir_path 下的图像文件名列表(排序后)。"""
    if not os.path.isdir(dir_path):
        raise RuntimeError(f"目录不存在: {dir_path}")

    files = sorted(os.listdir(dir_path))
    image_files = [
        f for f in files
        if f.lower().endswith((".jpg", ".jpeg", ".png", ".bmp", ".tif", ".tiff"))
    ]
    return image_files


# ==============================================================
# 工具函数:读取图像(BGR)
# ==============================================================
def load_image_bgr(path: str) -> np.ndarray:
    """读取一张图像为 OpenCV BGR 格式。"""
    img = cv2.imread(path, cv2.IMREAD_COLOR)
    if img is None:
        raise RuntimeError(f"图像读取失败: {path}")
    return img


# ==============================================================
# 工具函数:读取掩模(尽量保留原值)
# ==============================================================
def load_mask(path: str) -> np.ndarray:
    """
    读取掩模图像为单通道(uint8)。
    注:多数分割mask为0/255或类别ID(0..K),灰度读入更稳。
    """
    m = cv2.imread(path, cv2.IMREAD_GRAYSCALE)
    if m is None:
        raise RuntimeError(f"掩模读取失败: {path}")
    return m


# ==============================================================
# 工具函数:根据 stem 匹配掩模文件(后缀可不同)
# ==============================================================
def build_mask_index(mask_dir: str):
    """
    建立 {stem: mask_filename} 索引。
    若同stem出现多个文件,默认取排序后的第一个,并给出提示。
    """
    idx = {}
    files = list_images(mask_dir)
    buckets = {}
    for f in files:
        stem = os.path.splitext(f)[0]
        buckets.setdefault(stem, []).append(f)

    for stem, flist in buckets.items():
        flist_sorted = sorted(flist)
        idx[stem] = flist_sorted[0]
        if len(flist_sorted) > 1:
            print(f"[WARN] mask stem='{stem}' has multiple files: {flist_sorted}. Use: {flist_sorted[0]}")
    return idx


# ==============================================================
# M1 参数计算:从原图得到裁剪/正方形裁剪的几何参数
# ==============================================================
def compute_m1_params(img_bgr: np.ndarray, thresh: int = 10):
    """
    从原始图像计算 M1 的几何参数,使 mask 可复用同一套裁剪/缩放。
    返回 dict:
        bbox: (x1, y1, x2, y2) 在原图坐标中(闭区间)
        square_in_crop: (sx1, sy1, side) 在 crop 图坐标中
        circle: radius, center(在 square 图坐标中)
    """
    gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY)
    mask_nonblack = (gray > thresh).astype(np.uint8)

    ys, xs = np.where(mask_nonblack > 0)
    if xs.size == 0 or ys.size == 0:
        # 极端情况:找不到有效区域,使用整图
        x1, y1, x2, y2 = 0, 0, img_bgr.shape[1] - 1, img_bgr.shape[0] - 1
    else:
        x1, x2 = int(xs.min()), int(xs.max())
        y1, y2 = int(ys.min()), int(ys.max())

    crop_w = x2 - x1 + 1
    crop_h = y2 - y1 + 1

    side = int(min(crop_h, crop_w))
    cy = crop_h // 2
    cx = crop_w // 2
    sy1 = max(cy - side // 2, 0)
    sx1 = max(cx - side // 2, 0)

    radius = side // 2
    center = (side // 2, side // 2)

    return {
        "bbox": (x1, y1, x2, y2),
        "square_in_crop": (sx1, sy1, side),
        "circle": {"center": center, "radius": radius},
    }


# ==============================================================
# M1 应用到原图:裁剪黑边 + 正方形中心裁剪 + 圆形遮罩 + resize
# ==============================================================
def apply_m1_to_image(img_bgr: np.ndarray, params: dict, out_size=(640, 640)):
    """
    返回:
        out_bgr: 处理后的 BGR 图像(640x640)
        out_mask: 圆形mask(uint8, 0/255),与 out_bgr 同尺寸
    """
    x1, y1, x2, y2 = params["bbox"]
    sx1, sy1, side = params["square_in_crop"]

    crop = img_bgr[y1:y2 + 1, x1:x2 + 1]
    square = crop[sy1:sy1 + side, sx1:sx1 + side]

    circle_mask = np.zeros((side, side), dtype=np.uint8)
    center = params["circle"]["center"]
    radius = params["circle"]["radius"]
    cv2.circle(circle_mask, center, radius, 255, thickness=-1)

    square_masked = square.copy()
    square_masked[circle_mask == 0] = 0

    out_bgr = cv2.resize(square_masked, out_size, interpolation=cv2.INTER_AREA)
    out_mask = cv2.resize(circle_mask, out_size, interpolation=cv2.INTER_NEAREST)
    return out_bgr, out_mask


# ==============================================================
# M1 应用到掩模:使用同一几何参数裁剪/正方形裁剪/圆形遮罩/resize
# ==============================================================
def apply_m1_to_mask(mask_gray: np.ndarray, params: dict, out_size=(640, 640)):
    """
    对掩模做与原图一致的几何变换(裁剪/正方形裁剪/圆形遮罩/resize)。
    关键点:
      - resize 用 INTER_NEAREST,避免插值破坏标签
      - 圆形遮罩外置 0,保持与图像一致
    返回:
        out_mask_gray: (640x640) uint8
    """
    x1, y1, x2, y2 = params["bbox"]
    sx1, sy1, side = params["square_in_crop"]

    crop = mask_gray[y1:y2 + 1, x1:x2 + 1]
    square = crop[sy1:sy1 + side, sx1:sx1 + side]

    circle_mask = np.zeros((side, side), dtype=np.uint8)
    center = params["circle"]["center"]
    radius = params["circle"]["radius"]
    cv2.circle(circle_mask, center, radius, 255, thickness=-1)

    # 遮罩外置 0(注意:mask是类别/二值,不做颜色操作)
    square2 = square.copy()
    square2[circle_mask == 0] = 0

    out_mask_gray = cv2.resize(square2, out_size, interpolation=cv2.INTER_NEAREST)
    return out_mask_gray


# ==============================================================
# M2:高斯滤波 + 加权融合
# ==============================================================
def preprocess_gaussian_x_fusion(img_bgr: np.ndarray, sigma_x: float = 15.0, alpha: float = 1.5, beta: float = -0.5):
    blurred = cv2.GaussianBlur(img_bgr, ksize=(0, 0), sigmaX=sigma_x, sigmaY=0)
    out = cv2.addWeighted(img_bgr, alpha, blurred, beta, 0)
    return out


# ==============================================================
# M3:亮度校正(基于遮罩内几何平均亮度)
# ==============================================================
def preprocess_brightness_by_geomean(img_bgr: np.ndarray, circle_mask: np.ndarray, bmin: float = 80.0, eps: float = 1e-6):
    hsv = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HSV)
    v = hsv[:, :, 2].astype(np.float32)

    valid = (circle_mask > 0)
    v_valid = v[valid]
    if v_valid.size == 0:
        return img_bgr.copy(), float("nan")

    gm = float(np.exp(np.mean(np.log(v_valid + eps))))

    out = img_bgr.copy()
    if gm < bmin:
        scale = bmin / max(gm, eps)
        scale = min(scale, 2.0)
        out = cv2.convertScaleAbs(out, alpha=scale, beta=0)

    return out, gm


# ==============================================================
# M4:CLAHE(mode2:仅绿色通道)
# ==============================================================
def preprocess_clahe(img_bgr: np.ndarray, clip_limit: float = 2.2, tile_grid_size=(8, 8), mode: str = "mode2"):
    clahe = cv2.createCLAHE(clipLimit=clip_limit, tileGridSize=tile_grid_size)

    if mode == "mode1":
        hsv = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HSV)
        h, s, v = cv2.split(hsv)
        v2 = clahe.apply(v)
        hsv2 = cv2.merge([h, s, v2])
        out = cv2.cvtColor(hsv2, cv2.COLOR_HSV2BGR)
        return out

    elif mode == "mode2":
        b, g, r = cv2.split(img_bgr)
        g2 = clahe.apply(g)
        out = cv2.merge([b, g2, r])
        return out

    else:
        raise ValueError('mode must be "mode1" or "mode2".')


# ==============================================================
# 保存:保存图像/掩模为 PNG,并加前缀 img_
# ==============================================================
def save_png_prefixed(run_dir: str, filename: str, img: np.ndarray) -> str:
    """
    保存为 PNG:img_<stem>.png
    """
    ensure_dir(run_dir)
    stem = os.path.splitext(filename)[0]
    out_path = os.path.join(run_dir, f"img_{stem}.png")
    ok = cv2.imwrite(out_path, img)
    if not ok:
        raise RuntimeError(f"保存失败: {out_path}")
    return out_path


# ==============================================================
# 主程序:批量处理图像 + 对齐处理掩模
# ==============================================================
if __name__ == "__main__":

    ensure_dir(RUN_DIR)
    ensure_dir(RUN_MASK_DIR)

    img_files = list_images(RAW_DIR)
    if not img_files:
        raise RuntimeError("RAW_DIR 中未找到任何图像文件。")

    mask_index = build_mask_index(MASK_DIR)

    print(f"RAW_DIR      = {RAW_DIR}")
    print(f"MASK_DIR     = {MASK_DIR}")
    print(f"RUN_DIR      = {RUN_DIR}")
    print(f"RUN_MASK_DIR = {RUN_MASK_DIR}")
    print(f"Total images = {len(img_files)}")
    print("Image pipeline: M1 -> M2 -> M3 -> M4(mode2)")
    print("Mask  pipeline: Apply M1 geometry (same as corresponding image)")

    for idx, fname in enumerate(img_files, start=1):
        stem = os.path.splitext(fname)[0]
        in_img_path = os.path.join(RAW_DIR, fname)

        # ---- 读取原图 ----
        img0 = load_image_bgr(in_img_path)

        # ---- 找对应掩模 ----
        if stem not in mask_index:
            print(f"[WARN] No mask for image '{fname}' (stem='{stem}'). Skip mask.")
            mask_path = None
            mask0 = None
        else:
            mask_fname = mask_index[stem]
            mask_path = os.path.join(MASK_DIR, mask_fname)
            mask0 = load_mask(mask_path)

        # ---- 计算 M1 几何参数(只基于原图)----
        m1_params = compute_m1_params(img0, thresh=M1_THRESH)

        # ---- M1 应用于原图(得到统一尺寸图 + 圆形mask)----
        img1, circle_mask_640 = apply_m1_to_image(img0, m1_params, out_size=OUT_SIZE)

        # ---- M1 同步应用于掩模 ----
        if mask0 is not None:
            mask1_640 = apply_m1_to_mask(mask0, m1_params, out_size=OUT_SIZE)
            out_mask_path = save_png_prefixed(RUN_MASK_DIR, fname, mask1_640)
        else:
            out_mask_path = None

        # ---- 原图继续执行 M2/M3/M4 ----
        img2 = preprocess_gaussian_x_fusion(img1, sigma_x=M2_SIGMA_X, alpha=M2_ALPHA, beta=M2_BETA)
        img3, gm = preprocess_brightness_by_geomean(img2, circle_mask_640, bmin=M3_BMIN)
        img4 = preprocess_clahe(img3, clip_limit=M4_CLIP_LIMIT, tile_grid_size=M4_TILE_GRID, mode=M4_MODE)

        out_img_path = save_png_prefixed(RUN_DIR, fname, img4)

        msg = f"[{idx:03d}/{len(img_files):03d}] {fname} gm={gm:.2f} -> {out_img_path}"
        if out_mask_path is not None:
            msg += f" | mask -> {out_mask_path}"
        print(msg)

    print("Done.")

运行结果如下:

在这里插入图片描述


7. test5:从脚本集合到可复用工程(test5)

随着 test4.py 的完成,项目已经能够稳定地处理整套数据集,并保证原始图像与掩模之间的空间一致性。然而,一个新的问题随之出现:代码虽然能够运行,但大量函数被分散在不同测试程序中,重复定义开始增多。例如创建目录、读取图像、保存文件以及 M1~M4 等预处理函数,在多个脚本中反复出现。这正是许多科研代码在发展过程中常见的阶段——功能可用,但结构尚未工程化。

此时,人机协作进入了一个新的方向。研究者提出的需求不再是增加功能,而是提高复用性:

用户:
为了便于复用,我希望把相关函数或类定义放在公共的文件中,其它程序可以导入这些函数来实现。是否需要在本项目下建立一个文件夹来放这些文件?

AI 给出的建议是,将公共功能抽取为独立模块,并在项目中建立一个专门的包目录,例如 rop/。随后,将代码按职责进行拆分:与文件操作相关的函数(创建目录、读取图像、匹配掩模、保存结果)集中到 io_utils.py,而所有预处理方法(M1~M4 及其几何参数计算)统一放入 preprocess.py。通过增加 init.py,该目录被组织为一个标准 Python 包,使得其它程序可以通过简单的 import 语句直接调用这些功能。

可以,而且强烈建议这么做:把“通用IO工具”和“预处理算法”拆成独立模块,其他脚本(test2/test3/test4/以后训练脚本)直接 import 复用。最佳实践是:在项目下建立一个包目录(例如 rop/ 或 src/rop/)来放这些公共文件。
推荐做法:建立一个 Python 包目录(最简单、最稳)

  1. 目录结构建议
    rop/ # ⭐ 新增:公共模块包
    ├── init.py # 让 rop 成为可导入的包
    ├── io_utils.py # 创建目录/列图/读图/读mask/保存等
    └── preprocess.py # M1~M4、参数计算与应用等
  2. 文件拆分原则
    A. rop/io_utils.py(通用IO与文件匹配)

    B. rop/preprocess.py(预处理与几何一致性) 放这些(M1~M4及其辅助函数):
  3. 其他脚本如何导入复用 以 test4.py 为例,拆分后你会写成:

经过这一重构,原本不断增长的测试脚本被明显简化。例如新的 test5.py 中,不再包含具体算法实现,而只负责三件事情:定义参数、组织处理流程以及调用公共模块完成任务。这种变化标志着项目从“多个实验脚本”升级为结构清晰的科研工程框架。更重要的是,当后续需要新增处理方法或调整算法时,只需修改模块内部代码,而无需逐个修改所有脚本。

在这一阶段,AI 的作用已经从代码生成进一步转变为工程重构顾问。通过持续对话,AI 引导项目完成模块化拆分,使代码具备长期维护与扩展能力。test5.py 的意义因此不仅在于功能实现,而在于确立了一种可持续发展的编程方式——研究脚本负责实验,公共模块负责能力,这也是现代科研软件逐步走向工程化的重要一步。

本阶段的完整例程如下:

# rop/__init__.py
# --------------------------------------------------------------
# ROP_preprocess 公共模块包
#
# 本程序由 youcans@qq.com 在 ChatGPT 辅助下实现。
# Implemented by youcans@qq.com with ChatGPT assistance.
# --------------------------------------------------------------

from .io_utils import (
    ensure_dir,
    list_images,
    load_image_bgr,
    load_mask,
    build_mask_index,
    save_png_prefixed,
)

from .preprocess import (
    compute_m1_params,
    apply_m1_to_image,
    apply_m1_to_mask,
    preprocess_gaussian_x_fusion,
    preprocess_brightness_by_geomean,
    preprocess_clahe,
)

__all__ = [
    # io_utils
    "ensure_dir",
    "list_images",
    "load_image_bgr",
    "load_mask",
    "build_mask_index",
    "save_png_prefixed",
    # preprocess
    "compute_m1_params",
    "apply_m1_to_image",
    "apply_m1_to_mask",
    "preprocess_gaussian_x_fusion",
    "preprocess_brightness_by_geomean",
    "preprocess_clahe",
]

运行结果如下:

在这里插入图片描述


8. AI 辅助编程的实践经验

通过从 test1 到 test5 的逐步演进,可以看到,本项目的真正成果并不仅是完成了一套 ROP 图像预处理程序,而是形成了一种 AI 辅助科研编程的实践路径。在整个过程中,AI 并没有一次性“写出系统”,而是通过持续交互参与问题分析、结构设计与代码重构。结合这一实践,可以总结出若干具有普遍意义的经验。


8.1 不要让 AI 直接写完整系统

在实际使用中,一个常见误区是直接向 AI 提出类似“帮我写一个完整的数据处理程序”的请求。这类需求往往会得到结构复杂但难以维护的代码,因为 AI 缺乏真实运行环境与长期演进背景。

本项目采用的方式恰恰相反:
从最小任务开始——读取一张图像(test1),再逐步增加功能(test2)、扩展到批处理(test3)、解决数据一致性问题(test4),最终完成模块化重构(test5)。每一步都建立在前一步验证成功的基础之上。

实践表明:

AI 更适合参与“逐步构建”,而不是“一次生成”。


8.2 人与 AI 的职责分工

在人机协作过程中,一个清晰的分工逐渐形成:

研究者负责:

  • 提出真实问题
  • 判断结果是否正确
  • 决定系统结构方向

AI 负责:

  • 提供实现方案
  • 生成代码原型
  • 优化函数结构
  • 协助重构与修改

换句话说,人仍然是系统设计者,而 AI 更像一个反应迅速、知识广泛的技术助手。


8.3 AI 在本项目中的角色分析

回顾整个开发过程,可以发现 AI 在不同阶段承担了不同角色。

  1. 架构讨论者(Architecture Partner)
    在项目初期,AI 并未立即生成代码,而是首先参与目录结构与流程设计,例如建议建立 runs/ 目录、拆分处理步骤函数等。这些建议直接影响了后续工程可扩展性。

  2. 代码生成器(Code Generator)
    在明确需求之后,AI 能够快速生成标准化代码,显著减少重复性编程工作,如批量读取、异常处理与参数化接口等。

  3. 重构顾问(Refactoring Advisor)
    当代码开始重复时,AI 主动建议将公共函数抽取为模块,并建立 rop/ 包结构,使项目完成从脚本集合到工程框架的转变。

  4. 技术解释者(Technical Explainer)
    在关键问题(如掩模同步变换、最近邻插值选择)上,AI 不仅给出代码,还解释背后的工程原因,帮助研究者理解设计逻辑。

实践经验显示,AI 在以下任务中表现尤为突出:

  • 模板代码生成
  • 重复逻辑实现
  • 结构优化与重构
  • 多文件组织建议
  • Bug 定位与修改建议

而在系统目标定义与结果判断方面,仍然需要研究者主导。

一个值得注意的变化是:随着 AI 的参与,研究者在项目中的角色发生了转变。编程工作的重点逐渐从逐行实现算法,转向流程设计与结构决策。

在本项目的后期阶段,大部分新增功能已经不再需要重新编写大量代码,而是通过重新组合已有模块即可完成。这种变化说明:

AI 辅助编程的真正意义,是让研究者从代码实现者转变为系统设计者。

通过这一实践可以看到,AI 并没有降低编程门槛,而是改变了编程方式。有效的人机协作,使科研编程从零散脚本逐步演化为结构清晰、可维护且可扩展的工程体系,这也正在成为未来科研软件开发的一种新常态。


结语

本文记录的并不仅是一套 ROP 图像预处理程序的实现过程,而是一次真实的 AI 辅助编程实践。从最初的单个测试脚本,到批量处理、掩模同步,再到模块化重构,整个项目是在与 AI 的持续对话中逐步演化完成的。实践表明,AI 的价值并不在于替代程序员编写代码,而在于帮助研究者更高效地思考问题、组织结构并推进工程实现。

随着 AI 工具逐渐成为科研工作的一部分,编程方式正在发生变化:研究者不再需要把主要精力投入重复实现,而可以更多关注问题建模与系统设计。如何与 AI 高效协作,正在成为一种新的科研能力。或许未来真正的差异,不在于是否使用 AI,而在于能否把 AI 转化为可靠的研究伙伴。

【本节完】


版权声明:
转发必须注明原文链接:
【AI辅助编程】ROP 图像预处理
Copyright by youcans@qq.com 2026
Crated:2026-02


Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐