定焦镜头的对焦原理是什么?

九、结合代码分析

CDAF 分三个核心环节:

  1. 离焦成像模型:怎么模拟"镜头在不同位置拍到的图"
  2. 聚焦评价函数(FV, Focus Value):怎么给一张图打"清晰度分数"
  3. 搜索策略:怎么用尽量少的镜头移动次数找到 FV 最大的位置,并精细化定位

下面按代码顺序对应这三块逐一讲述。

1. 离焦成像模型 —— LensSimulator

真实世界里,离焦程度可以用高斯模糊来近似:镜头位置 pos 离真正合焦位置 best_pos 越远,图像越模糊,模糊半径(高斯 \sigma )越大。

sigma = self.k * abs(pos - self.best_pos)
img = gaussian_filter(self.sharp, sigma)

这里 k=0.012 是"模糊系数",控制离焦速度有多快。best_pos=623 是"上帝视角"才知道的真实合焦位置——算法本身并不知道这个值,只能通过 capture(pos) 一次次"拍照"来试探。

noise_std 模拟低照度下的传感器噪声——因为暗光下噪声会污染高频信息,让评价函数更难判断真正的清晰边缘(这也是低光 AF 更难做的原因)。

move_count 统计镜头移动了多少次,这是评估算法效率的关键指标:真实相机中每次移动镜头都要花时间(几十毫秒级),所以移动次数越少,对焦速度越快。

2. 聚焦评价函数(FV)—— 四种实现

清晰图像的共同特点是:边缘锐利、高频细节丰富。四种 FV 本质上都是在衡量图像的"高频能量",只是计算方式不同:

  • Tenengradfv_tenengrad):用 Sobel 算子求水平和垂直方向梯度,平方后求均值。是业界最常用的方案,因为 Sobel 卷积在硬件(ISP/DSP)上非常容易实现。
  • Laplacianfv_laplacian):用拉普拉斯算子(二阶导数)度量。对高频最敏感,峰值最尖锐(定位精度高),但因为是二阶导数,对噪声也最敏感。
  • Brennerfv_brenner):只算水平方向隔两像素的差分平方和 img[:, 2:] - img[:, :-2],计算量最小,适合算力有限的嵌入式平台。
  • Variancefv_variance):直接算灰度方差。抗噪性好,但峰形平缓,对"哪个位置最清晰"的分辨力较差。
def crop_window(img, ratio=0.5):
    ...

这个函数只取图像中央 50% 的区域来计算 FV——这对应真实 AF 中的"对焦窗口"(AF window),因为通常只关心中央主体是否清晰,而非整幅画面(比如边角虚化的背景不该干扰对焦判断)。

demo_fv_curves 这部分是把镜头从 0 扫到 1023(全程扫描),画出四种 FV 随位置变化的曲线,一份是亮光、一份是模拟噪声的暗光。这一步不是实际对焦算法,只是用来"看图说话",直观对比四种评价函数的形状差异(哪个峰更尖、哪个更抗噪)。

3. 搜索策略 —— 爬山法 + 抛物线精细拟合

如果每次都全局扫描(比如每隔 16 步测一次,共 64 次),虽然稳妥但太慢,实际产品做不到。所以真实 AF 用爬山法(Hill Climbing)

for step in (coarse_step, fine_step):   # 先大步长(64),后小步长(8)
    while True:
        nxt = pos + direction * step
        ...
        if fv > best_fv:
            best_fv, best_pos = fv, pos   # FV 变大,继续朝同方向走
            drops = 0
        else:
            drops += 1
            if drops >= drop_confirm:     # 连续下降2次,确认已经越过峰值
                break
    direction = -direction   # 掉头,用更小步长逼近峰值

逻辑分两个阶段:

  • 粗搜阶段coarse_step=64):以较大步长快速前进,只要 FV 还在增大就继续走;一旦连续 drop_confirm=2 次发现 FV 变小,说明已经"冲过了峰值",粗略定位到峰值附近区间。
  • 细搜阶段fine_step=8):从粗搜得到的最佳位置出发,掉头用更小步长重复同样过程,进一步逼近真实峰值。

这样比全局扫描省很多次镜头移动(代码里打印出的 move_count 会明显小于 64)。

代码里还有个细节:if not (lo <= nxt <= hi): direction = -direction,处理镜头走到物理行程边界(比如 0 或 1023)时自动掉头,避免越界。

4. 抛物线拟合亚步长精度 —— parabola_refine

爬山法找到的 best_pos 只是采样点中 FV 最大的那个位置,精度受步长限制(比如步长是 8,最终结果只能精确到 8 的整数倍)。真实峰值很可能在两个采样点之间。

解决办法:取峰值点及其左右各一个采样点,共 3 个点 (x, FV),用二次曲线 y = ax² + bx + c 拟合,因为二次曲线的顶点公式是 x_peak = -b/(2a),可以算出比采样步长更精细的估计位置:

a, b, _ = np.polyfit(x3, y3, 2)
if a < 0:              # 确认是"开口向下"的峰(不是伪峰)
    return -b / (2 * a)

5. 主流程串联(main()

  1. make_sharp_scene():合成一张"完全清晰"的测试图,四个象限分别是棋盘格、同心圆、变频条纹、随机纹理,模拟不同类型的场景内容(强边缘/多方向边缘/高频细节/自然纹理),这样能更全面地测试各 FV 函数的表现。
  2. demo_fv_curves():全局扫描画 FV 曲线对比图(存成 af_fv_curves.png)。
  3. 用 Tenengrad 做一次真实的爬山法 AF:
  • 打印真实合焦位置、爬山法结果、抛物线拟合后结果,以及各自的误差
  • 打印马达移动次数,和"全局扫描需要 64 次"做对比,体现爬山法的效率优势

4. 画出爬山法的搜索轨迹图,以及"远离焦"和"AF 结果"两张示例图(af_hill_climb.png),直观展示搜索路径怎么一步步逼近峰值。

十、完整代码

"""
AF (Auto Focus) CDAF 仿真
=========================
【给不懂Python的读者的说明】
这份代码除了讲算法原理,还会解释Python语法本身。
Python的基本规则先说三条,后面注释里会反复用到:
  1. Python不用大括号{}分代码块,而是用"缩进"(行首的空格)来表示"属于哪一层"。
     缩进对齐的几行代码,就是"同一层级",比如同属于一个for循环、一个函数内部。
  2. # 后面的内容是注释,不是代码,Python运行时会完全忽略它。
  3. 三个引号 \"\"\" ... \"\"\" 包起来的一大段文字,是"文档字符串",也是注释的一种,
     通常写在文件开头或函数开头,用来说明这个文件/函数是干什么的。

仿真内容:
1. 用高斯模糊模拟离焦: 镜头位置 p 距离最佳合焦位置 p* 越远, 模糊 sigma 越大
2. 实现多种聚焦评价函数 (FV): Tenengrad / Laplacian / Brenner / Variance
3. 全局扫描: 画出各 FV 随镜头位置变化的曲线 (含低光噪声对比)
4. 爬山法 (Hill Climbing): 粗步长搜索 + 反向细步长收敛
5. 抛物线拟合: 峰值附近 3 点拟合, 获得亚步长精度

依赖: numpy, scipy, matplotlib
"""


import numpy as np
# gaussian_filter: 对图像做"高斯模糊"的现成函数(本代码用它来模拟镜头离焦)。
# convolve: 对图像做"卷积运算"的现成函数(本代码用它来实现Sobel、Laplacian这些边缘检测算子)。
from scipy.ndimage import gaussian_filter, convolve

import matplotlib
# matplotlib 是Python最常用的"画图"库。这一行是在设置画图的"后端模式"为"Agg",
# 意思是"只把图画到内存里、存成图片文件,不要试图弹出一个图形窗口显示出来"——
# 因为这份代码可能跑在没有显示器的服务器/云端环境里,不设置这个的话程序可能会报错。
matplotlib.use("Agg")
# 这里再引入matplotlib库里专门负责"画图表"的这部分功能,起个短名字叫plt,
# 后面所有画图的代码都是 plt.xxx 或者 fig.xxx / ax.xxx 这种写法。
import matplotlib.pyplot as plt

# rng 是"random number generator"(随机数生成器)的缩写。
# np.random.default_rng(42) 的意思是:创建一个随机数生成器,并且给它一个固定的"种子"(42)。
# 【为什么要固定种子】计算机产生的"随机数"其实是按照某种算法伪造出来的,
# 只要"种子"一样,每次运行程序生成出来的"随机数序列"就会完全一样——
# 这样可以保证你每次运行这份代码,结果都完全相同、可以复现,方便调试和对比。
# 后面代码里所有需要"随机"的地方(比如生成噪声、生成随机纹理),都会调用这个rng对象。
rng = np.random.default_rng(42)

# ----------------------------------------------------------------------
# 1. 生成合焦的"理想清晰图" (含边缘/纹理/细节, 模拟真实场景)
# ----------------------------------------------------------------------
def make_sharp_scene(h=256, w=256):
    """
    构造一张256x256的合成测试图, 分成四个象限, 每个象限用不同类型的图案,
    这样能更全面地测试各FV函数在不同内容(强边缘/多方向边缘/渐变纹理/随机纹理)下的表现。
    像素值范围归一化到[0,1], 代表灰度图。
    """
    # np.full((h, w), 0.35, ...) 的意思是:"创建一个h行w列的二维数组(在这里就是一张图片),
    # 数组里每一个格子(每一个像素)的初始值都填成0.35"。
    # 一张灰度图,本质上就是一个二维数字表格,每个数字代表这个位置的亮暗程度,
    # 这里用0(纯黑)到1(纯白)之间的小数来表示灰度,0.35就是一种偏暗的灰色,作为背景底色。
    # dtype=np.float64 表示"这些数字用64位浮点数精度存储",是比较高精度的小数类型。
    img = np.full((h, w), 0.35, dtype=np.float64)

    # ---- 左上象限: 棋盘格 (强边缘, 灰度在两个值之间硬切换, 适合测试高频响应) ----
    # np.indices((h//2, w//2)) 会生成"这块区域里每个像素的行号和列号"这样一套坐标网格。
    # "//"是Python里的"整除"符号,比如 h//2 表示h除以2、然后只保留整数部分(去掉小数)。
    # .sum(axis=0) 表示把"行号数组"和"列号数组"对应位置加起来,得到"行号+列号"这样一个数。
    # //16 再对这个和做整除16,等于每16个像素为一组切换一次数值。
    # %16 后面又对2取余数(%是"取余数"符号),结果只会是0或1,这样就把画面划分成了16x16像素一格的棋盘。
    cb = (np.indices((h // 2, w // 2)).sum(axis=0) // 16) % 2
    # img[:h//2, :w//2] 是"数组切片",表示"选中img这张图里,行范围是从开头到h//2、
    # 列范围是从开头到w//2的这一块区域",也就是整张图的左上角四分之一。
    # 这一行代码把这块区域的像素值,按cb是0还是1,分别赋值成0.2(暗格)或0.8(亮格)。
    # 0.2 + 0.6*cb:当cb=0时结果是0.2,当cb=1时结果是0.2+0.6=0.8——这就是棋盘的黑白两色。
    img[:h // 2, :w // 2] = 0.2 + 0.6 * cb

    # ---- 右上象限: 同心圆环 (各方向都有边缘, 不像棋盘格只有水平/垂直边缘) ----
    # np.mgrid[0:h//2, 0:w//2] 也是生成这块区域每个像素的(行坐标, 列坐标)网格,
    # 只是写法和np.indices不同,效果类似:yy存的是每个像素的行号,xx存的是每个像素的列号。
    yy, xx = np.mgrid[0:h // 2, 0:w // 2]
    # np.hypot(a, b) 计算的是"直角三角形斜边长度",也就是 sqrt(a^2+b^2),
    # 这里用来计算每个像素到"圆心(h//4, w//4)"的欧式距离r。
    r = np.hypot(yy - h // 4, xx - w // 4)
    # 分母(这里是6)越小,r 只需要变化很小一点,r/6.0 就已经跑完一圈了,波纹会非常密集、圆环一圈套一圈地挤在一起
    # 分母越大,r 需要变化很大一段距离,r/6.0 才能跑完一圈,波纹就变得稀疏、圆环之间间距很大
    # np.sign(...):把这个正弦值"二值化",正数变成+1,负数变成-1,0还是0——
    # 这样连续的波浪就变成了黑白分明的同心圆环。
    # 0.5 + 0.4*(±1):最终结果要么是0.5+0.4=0.9(亮环),要么是0.5-0.4=0.1(暗环)。
    #sin(x)=0 只在 x 恰好等于 0, π, 2π, 3π...(也就是 π 的整数倍)这些精确的数学值时才成立。
    #而代码里的 x 是 r/6.0,r 是"像素到圆心的距离",用 np.hypot 算出来的,几乎必然是一个带很多位小数的浮点数(比如 37.482913...),
    #要让 r/6.0 精确撞上 π 的整数倍这种无理数,概率极低——几乎不会发生。
    img[:h // 2, w // 2:] = 0.5 + 0.4 * np.sign(np.sin(r / 6.0))

    # ---- 左下象限: 竖条纹, 频率随位置渐变 (类似分辨率测试卡, 用来看不同空间频率下的响应) ----
    # np.arange(w//2) 生成一个从0数到(w//2 - 1)的整数序列,比如[0,1,2,...,127],代表每一列的横坐标x。
    x = np.arange(w // 2)
    # x ** 1.5 表示"x的1.5次方"(**是Python里"乘方/幂运算"的符号)。
    # 让sin函数的输入随x的1.5次方增长(而不是简单地随x线性增长),
    # 效果是:条纹的"疏密程度"(频率)会随位置x的增大而逐渐变化,模拟"分辨率渐变测试卡"的效果。
    stripes = 0.5 + 0.4 * np.sin((x ** 1.5) / 20.0)
    # 此时stripes只是"一行"128个数字。np.tile(stripes, (h-h//2, 1)) 的意思是:
    # 把这一行数据,在"纵向"重复复制(h-h//2)次、在"横向"重复1次(即不重复),
    # 这样就把这一行条纹图案"贴满"了整个下半区域的高度,形成从上到下都一样的竖条纹。
    img[h // 2:, :w // 2] = np.tile(stripes, (h - h // 2, 1))

    # ---- 右下象限: 随机纹理 (模拟自然场景里不规则的细节, 比如草地/织物) ----
    # rng.standard_normal((h-h//2, w-w//2)):用前面创建的随机数生成器rng,
    # 生成一块指定大小、服从"标准正态分布"(均值0、标准差1)的随机数矩阵,也就是纯随机噪声。
    # gaussian_filter(..., 1.2):对这个随机噪声做一次轻微的高斯模糊(sigma=1.2),
    # 让原本"完全杂乱无章、每个像素独立随机"的噪声,变得带有一点"斑块感",更像自然纹理(比如草地、云朵)。
    tex = gaussian_filter(rng.standard_normal((h - h // 2, w - w // 2)), 1.2)
    # tex.min()和tex.max()分别取这块数据里的最小值和最大值。
    # (tex - tex.min()) / (tex.max() - tex.min()):这是标准的"归一化"公式,
    # 效果是把tex里所有数值,不管原来分布在什么范围,统一线性映射到[0,1]区间内。
    tex = (tex - tex.min()) / (tex.max() - tex.min())
    img[h // 2:, w // 2:] = 0.25 + 0.5 * tex

    # np.clip(img, 0, 1) 表示"把img里所有小于0的数字强制改成0,所有大于1的数字强制改成1",
    # 中间范围内的数字保持不变——这是为了保证最终图像的所有像素值都合法地落在[0,1]这个灰度范围内。
    # return 是"把这个函数的计算结果返回给调用它的地方",函数执行到return就结束了。
    return np.clip(img, 0, 1)


# ----------------------------------------------------------------------
# 2. 镜头/离焦模型: sigma = k * |pos - best_pos|, 合焦时 sigma=0
# ----------------------------------------------------------------------
# 定义好类之后,可以用它来创建一个个具体的"实例"(对象),
# 每个实例都拥有自己独立的一份数据,但共享同一套操作方法。
# 这里定义了一个叫LensSimulator(镜头仿真器)的类,用来模拟一个虚拟的对焦镜头。
class LensSimulator:
    """模拟"移动镜头 -> 采到一帧图"的过程。
    离焦程度用高斯模糊的sigma来近似(sigma与离焦量成正比), 这是CDAF仿真的核心物理假设:
    镜头偏离合焦位置越远, 点光源在像面上扩散得越厉害(等效于用更大的高斯核卷积清晰图)。
    """

    # __init__ 是一个"特殊方法",专门用来在"创建这个类的一个新实例"时自动执行,
    # 相当于"初始化/开箱设置":你新建一个LensSimulator的时候,要传入哪些参数、
    # 这些参数怎么存到这个实例自己身上,都是在这里定义的。
    # self 是Python里的一个约定写法,代表"这个实例本身"——
    # 你可以理解成"当前正在被创建/操作的这一台具体的镜头仿真器"。
    # 类里定义的每个方法(函数),第一个参数几乎都要写self,这样方法内部才能访问和修改
    # "这个实例自己的数据",而不会影响到其他用同一个类创建出来的实例。
    def __init__(self, sharp_img, best_pos=623, k=0.012,
                 noise_std=0.0, motor_range=(0, 1023)):
        # self.sharp = sharp_img:把传进来的清晰图,存成"这个实例自己的属性",
        # 之后在这个类的其他方法里,都可以通过self.sharp重新拿到这张图。
        self.sharp = sharp_img            # 预先准备好的"理想清晰图"
        self.best_pos = best_pos          # 真实合焦马达位置 (对算法而言是未知量, 只有仿真环境自己知道, 用来生成图像和最后验证误差)
        self.k = k                        # 离焦模糊系数, 越大表示镜头对失焦越敏感(同样的位置偏差会产生更强的模糊)
        self.noise_std = noise_std        # 传感器噪声标准差 (模拟低照度下ISO升高带来的噪声, 0表示理想亮光无噪声环境)
        self.motor_range = motor_range    # 马达物理行程范围, 是一个"元组"(0, 1023),表示镜头不能移动到这个区间之外
        self.move_count = 0               # 统计马达移动(即调用capture)的总次数, 用来衡量AF算法的搜索效率, 初始值为0

    # 这是定义在类里面的另一个方法(函数),同样第一个参数是self,
    # 表示"调用这个方法时,是让某一台具体的镜头仿真器去执行'拍照'这个动作"。
    # pos 是第二个参数,表示"要把镜头移动到哪个位置",调用时需要外部传入。
    def capture(self, pos):
        """把镜头移动到pos, 返回该位置采集到的图像(相当于"拍一张照片")"""
        # np.clip(pos, *self.motor_range):*self.motor_range是Python的"解包"写法,
        # 因为self.motor_range是(0, 1023)这样一个"元组"(两个数字打包在一起),
        # 加个星号*就相当于把它拆开变成两个独立的参数传给np.clip,
        # 等价于写 np.clip(pos, 0, 1023)——把pos限制在[0,1023]范围内,超出的部分强制拉回边界。
        pos = np.clip(pos, *self.motor_range)   # 越界则夹紧到合法行程内
        # self.move_count += 1 等价于 self.move_count = self.move_count + 1,
        # 表示"这个实例自己的move_count属性加1",用来累计一共调用了多少次capture(即拍了多少次照/移动了多少次镜头)。
        self.move_count += 1                     # 每调用一次capture就算一次镜头移动/一次曝光采样
        # abs(x) 是Python内置函数,表示求x的绝对值(去掉正负号)。
        # 这一行计算的是"当前离焦模糊程度sigma":镜头位置离真实合焦位置越远,sigma越大。
        sigma = self.k * abs(pos - self.best_pos)  # 离焦量与马达位置和最佳位置的距离成正比
        # 这是一个"条件表达式"(三元表达式),写法是: 值A if 条件 else 值B,
        # 意思是"如果条件成立,结果取值A;否则取值B"。
        # 这里意思是:如果sigma大于一个极小值(1e-6,即0.000001,用来避免浮点数误差把本该是0的sigma误判为非0),
        # 就对清晰图做真正的高斯模糊;否则(基本等于完全合焦),直接复制一份清晰图,不做模糊处理(节省计算量)。
        img = gaussian_filter(self.sharp, sigma) if sigma > 1e-6 else self.sharp.copy()
        # if self.noise_std > 0: 是Python的"条件判断"语句,
        # 冒号:后面缩进的代码块,只有当条件为真时才会执行。
        if self.noise_std > 0:
            # rng.normal(0, self.noise_std, img.shape):生成一块和img形状一样的随机噪声矩阵,
            # 噪声服从"均值为0、标准差为self.noise_std"的正态分布,模拟传感器的随机读出噪声。
            # img + ... 把这块噪声直接叠加到图像上(逐像素相加)。
            img = img + rng.normal(0, self.noise_std, img.shape)  # 叠加高斯白噪声, 模拟传感器读出噪声
        return np.clip(img, 0, 1)  # 裁剪回合法灰度范围(噪声可能导致超出[0,1])


# ----------------------------------------------------------------------
# 3. 聚焦评价函数 (FV) —— 均在 AF 窗口内计算
# ----------------------------------------------------------------------

def crop_window(img, ratio=0.5):
    """取画面中央ratio比例的AF窗口。
    真实相机只关心中央主体是否清晰, 而非整幅画面(比如边角虚化的背景不该干扰对焦判断),
    这里默认取中央50%区域参与FV计算。
    """
    # img.shape 是numpy数组自带的一个属性(不是函数,不用加括号),
    # 返回这个数组的形状,对二维图像来说就是(高度, 宽度)。
    # h, w = img.shape 这种写法叫"解包赋值",把返回的两个数字分别赋给h和w两个变量。
    h, w = img.shape
    # int(...) 是Python内置函数,把括号里的数字强制转换成整数(去掉小数部分)。
    # 这里算出的是"窗口的半高dh"和"半宽dw"。
    dh, dw = int(h * ratio / 2), int(w * ratio / 2)  # 窗口半高/半宽
    # 这行是"二维数组切片",从img这张完整图片里,抠出以图像中心为中心、
    # 上下各dh行、左右各dw列的这一块中央区域,然后返回。
    return img[h // 2 - dh:h // 2 + dh, w // 2 - dw:w // 2 + dw]

def fv_tenengrad(img):
    """Tenengrad: Sobel梯度平方和 (最常用, 硬件友好)。
    Sobel核 = 差分方向(检测边缘) x 平滑方向(抑制噪声) 的组合, 是一阶导数算子。
    """
    # np.array([[...], [...], [...]], float) 是在创建一个numpy的二维数组(也就是"矩阵"),
    # 每个中括号[]里是一行数字,多行数字堆叠起来就是一个3行3列的矩阵,float表示元素类型是浮点数。
    # 这个3x3矩阵就是Sobel算子的"卷积核"(可以理解成一个用来"扫描"图像的小模板)。
    kx = np.array([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], float)  # 水平方向Sobel核: 横向做中心差分求梯度, 纵向做[1,2,1]平滑抑噪
    # convolve(img, kx) 调用前面从scipy引入的卷积函数,
    # 效果是:拿kx这个3x3小矩阵,在img这张图上逐像素滑动,每滑到一个位置就做"对应元素相乘再求和",
    # 得到一张新的、体现"该方向梯度强弱"的图。
    gx = convolve(img, kx)     # x方向梯度(对垂直边缘敏感)
    # kx.T 表示"把kx这个矩阵转置"(行和列互换位置),效果是把"水平方向"的核变成"垂直方向"的核。
    gy = convolve(img, kx.T)   # 转置核得到y方向梯度(对水平边缘敏感)
    # gx ** 2 表示"gx里每一个数字都求平方"(**是乘方运算符)。
    # gx**2 + gy**2 是把x方向和y方向的梯度平方,逐像素相加,得到"梯度能量图"。
    # np.mean(...) 是求整个数组所有数字的平均值,最终返回一个单独的数字,
    # 代表"这张图整体的清晰度打分"——分数越高说明图像里边缘/细节越多、越清晰。
    return np.mean(gx ** 2 + gy ** 2)  # 梯度幅值的平方和(能量), 均值化使其与图像尺寸无关, 清晰度越高该值越大

def fv_laplacian(img):
    """Laplacian能量: 对高频最敏感, 峰尖锐但易受噪声影响。
    这是二阶导数算子, 离散近似为"上下左右四邻域之和减4倍中心值",
    对应二维拉普拉斯算子 ∇²f = ∂²f/∂x² + ∂²f/∂y² 的标准离散形式。
    """
    # 这是拉普拉斯算子的3x3卷积核:中心格子是-4,正上正下正左正右是1,四个斜角是0。
    lap = np.array([[0, 1, 0], [1, -4, 1], [0, 1, 0]], float)  # 中心-4, 上下左右各1, 四角为0(各向同性, 不需要额外加平滑核)
    return np.mean(convolve(img, lap) ** 2)  # 响应平方后取均值, 边缘处会产生"正负波峰波谷", 平方后都计入能量

def fv_brenner(img):
    """Brenner: 隔两像素差分平方和, 计算量极小。
    只做水平方向、间隔2像素的一阶差分, 没有平滑项, 是四种FV里计算量最省的,
    适合算力紧张的嵌入式平台; 隔2像素(而非隔1像素)的设计能让最敏感的响应频段
    从"极限逐像素跳变(常是噪声)"稍微下移到"较低一档的细节频率", 有一定的天然抗单像素噪声效果。
    """
    # img[:, 2:] 表示"取所有行,列从第2列开始一直取到最后",相当于把整幅图向左错位2列。
    # img[:, :-2] 表示"取所有行,列从第0列取到倒数第3列",相当于去掉图像最右边2列。
    # 两者形状完全一样,相减后得到的就是"每个像素和它右边隔2个位置的像素"的差值。
    d = img[:, 2:] - img[:, :-2]  # 每个像素与其右侧隔2像素处的差值(数组整体错位相减, 等效于逐点做 f(x+2)-f(x))
    return np.mean(d ** 2)

def fv_variance(img):
    """灰度方差: 抗噪但峰平缓, 定位精度差。
    不是显式的微分/边缘算子, 而是从图像整体对比度/离散程度出发的统计量:
    模糊(低通)会让灰度值都往局部均值靠拢, 因此方差会随离焦程度增大而单调下降。
    """
    # np.var(img) 是numpy自带的"求方差"函数,直接对整张图所有像素值求统计方差。
    # 方差衡量的是"这堆数字整体上离散/波动的程度":数值忽高忽低方差就大,数值都差不多方差就小。
    return np.var(img)

# 这是Python的"字典"(dict)数据结构,写法是 {键: 值, 键: 值, ...}。
# 字典可以理解成一个"查表工具":给一个"名字"(键),能立刻查到对应的"东西"(值)。
# 这里建了一个字典,键是字符串(函数的名字,方便打印/画图时显示),值是前面定义好的四个函数本身
# (注意这里写的是函数名Tenengrad对应fv_tenengrad,没有加括号——不加括号表示"这个函数本身",
#  而不是"调用这个函数并取它的返回结果")。
FV_FUNCS = {
    "Tenengrad": fv_tenengrad,
    "Laplacian": fv_laplacian,
    "Brenner": fv_brenner,
    "Variance": fv_variance,
}


# ----------------------------------------------------------------------
# 4. 全局扫描: 画出 FV 曲线 (亮光 vs 低光噪声)
# ----------------------------------------------------------------------

def global_scan(lens, positions, fv_func):
    """在给定的一组马达位置上逐个拍照并计算FV, 得到FV随位置变化的完整曲线。
    这是"暴力"的全局扫描方式, 仅用于可视化对比, 不是实际产品里高效的AF搜索策略。
    """
    # 这一整行是Python的"列表推导式"(list comprehension),是一种很紧凑的写循环的方式。
    # 完整展开理解的话,它等价于下面这样一段普通for循环:
    #     result = []                              # 先建一个空列表
    #     for p in positions:                       # 依次取出positions里的每一个位置p
    #         img = lens.capture(p)                 # 把镜头移到p, 拍一张照片
    #         cropped = crop_window(img)             # 截取中央AF窗口
    #         fv = fv_func(cropped)                  # 计算这张图的FV打分
    #         result.append(fv)                      # 把这个分数加进列表末尾
    #     return result
    # 而 [fv_func(crop_window(lens.capture(p))) for p in positions] 这一行代码,
    # 就是把"对positions里每一个p做同样一套处理,收集所有结果"这件事,压缩写成了一行。
    # 最外层np.array(...)是把得到的Python列表转换成numpy数组,方便后续做数学运算(比如画图、除以最大值等)。
    return np.array([fv_func(crop_window(lens.capture(p))) for p in positions])

def demo_fv_curves(sharp):
    """画出四种FV函数在"亮光无噪声"和"低光有噪声"两种条件下, 随镜头位置变化的曲线对比图。
    直观展示: 亮光下各FV峰值都比较干净; 一旦引入噪声, Laplacian等对噪声敏感的曲线会明显变毛糙。
    """
    # np.arange(0, 1024, 16) 生成一个从0开始、每次加16、直到(但不包括)1024为止的数字序列,
    # 即 [0, 16, 32, ..., 1008],一共64个数字,代表要测试的马达位置。
    positions = np.arange(0, 1024, 16)  # 每隔16步采样一次, 覆盖整个马达行程(0~1023), 共64个采样点
    # plt.subplots(1, 2, figsize=(13, 4.5)):创建一个画布(fig),
    # 里面包含1行2列、总共2个并排的子图区域(axes),figsize设置整体画布的宽高(单位是英寸)。
    # fig, axes = ... 是把返回的两个东西分别存到fig和axes变量里。axes会是一个包含2个子图对象的数组。
    fig, axes = plt.subplots(1, 2, figsize=(13, 4.5))
    # for ax, noise, title in [...]: 这是Python的for循环写法,
    # 后面中括号里是一个"列表",列表的每一个元素又是一个"元组"(三个值打包在一起):
    #   第一次循环: ax=axes[0], noise=0.0, title="Bright (no noise)"
    #   第二次循环: ax=axes[1], noise=0.02, title="Low light (noise_std=0.02)"
    # 这种写法能让循环体里同时用到三个相关联的变量,不用另外写三个独立的列表再一一对应。
    for ax, noise, title in [
        (axes[0], 0.0, "Bright (no noise)"),
        (axes[1], 0.02, "Low light (noise_std=0.02)"),
    ]:
        # 每次循环都新建一台镜头仿真器:一台代表"亮光无噪声",一台代表"低光有噪声"(best_pos用默认值623)。
        lens = LensSimulator(sharp, noise_std=noise)  # 分别构造无噪声/有噪声两种镜头仿真环境(best_pos用默认值623)
        # for name, f in FV_FUNCS.items(): 遍历前面定义的字典FV_FUNCS,
        # .items()是字典自带的方法,会把字典里"每一对(键,值)"都取出来,
        # 这里name依次是"Tenengrad"/"Laplacian"/"Brenner"/"Variance"这些字符串,
        # f依次是对应的那个具体函数(fv_tenengrad等)。
        for name, f in FV_FUNCS.items():
            # 对当前这种FV函数f,在64个位置上做一次全局扫描,得到完整的FV曲线数组。
            fv = global_scan(lens, positions, f)
            # ax.plot(x, y, label=...):在子图ax上画一条曲线,x轴是positions,y轴是fv值。
            # fv / fv.max():把fv数组里每个数字都除以这个数组的最大值,
            # 效果是把曲线整体缩放到"最高点正好是1.0",这样不同FV函数(数值范围本来天差地别)才能画在同一张图上比较形状。
            # label=name 给这条曲线起个图例名字(就是name这个字符串,比如"Tenengrad"),方便图上区分。
            ax.plot(positions, fv / fv.max(), label=name)  # 各曲线除以自身最大值归一化, 便于在同一张图上比较形状(不比较绝对数值大小)
        # ax.axvline(...):在子图上画一条"竖直参考线",位置在lens.best_pos这个x坐标处,
        # color="k"是黑色(k=black的缩写),ls="--"表示线型是虚线,lw=1是线宽。
        ax.axvline(lens.best_pos, color="k", ls="--", lw=1, label="true focus")  # 画一条竖线标出真实合焦位置作为参考
        # 下面几行都是给这张子图设置标题、坐标轴标签、图例、网格线等外观细节。
        # f"FV curves - {title}" 是Python的"f-string"(格式化字符串)写法:
        # 字符串前面加个f,字符串内部用大括号{}包住一个变量名(这里是title),
        # Python运行时会自动把{title}替换成title这个变量实际的内容,拼接成一整句话。
        ax.set_title(f"FV curves - {title}")
        ax.set_xlabel("motor position")
        ax.set_ylabel("normalized FV")
        ax.legend(fontsize=8)   # 显示图例(就是右上角标注每条线颜色对应哪个名字的小方框)
        ax.grid(alpha=0.3)      # 显示网格线,alpha是透明度(0.3表示比较淡)
    fig.tight_layout()  # 自动调整子图之间的间距,避免文字/标题互相重叠
    fig.savefig("af_fv_curves.png", dpi=130)  # 把整个画布保存成一张图片文件,dpi是图片清晰度(每英寸像素数)
    print("[saved] af_fv_curves.png")  # print是Python内置函数,把括号里的内容打印显示在终端上


# ----------------------------------------------------------------------
# 5. 爬山法: 粗搜 (大步长) + 越峰后反向细搜 (小步长)
# ----------------------------------------------------------------------

def hill_climb_af(lens, fv_func, start=0,
                  coarse_step=64, fine_step=8, drop_confirm=2, verbose=True):
    """
    经典两段式爬山:
    阶段1: 以 coarse_step 前进, FV 连续下降 drop_confirm 次 => 已越过峰
    阶段2: 反向, 以 fine_step 重复同样过程
    返回: (最终位置, 搜索轨迹 [(pos, fv), ...])

    核心思想: 相比全局扫描(每个位置都测), 爬山法只在"FV还在变好"的方向持续走,
    一旦确认越过峰值(FV连续下降多次)就立刻掉头, 用更小步长精细逼近, 大幅减少镜头移动次数。
    """
    # trace = [] 创建一个空的"列表"(Python里最常用的"可变长度的一串数据"的容器)。
    # 之后每测量一次,就往这个列表里追加一个(位置, FV值)的记录,
    # 相当于给整个搜索过程"拍了一路的照片记录",方便之后回看整个轨迹、画图、做拟合。
    trace = []  # 记录整个搜索过程中每一次"移动到某位置+测得的FV值", 用于事后画轨迹图和抛物线拟合

    # 这是在函数内部又定义了一个"小函数"(嵌套函数),只在hill_climb_af内部使用,
    # 外部代码访问不到它。这样写是为了把"移动镜头+计算FV+记录轨迹"这一整套动作打包成一步,
    # 避免在下面的代码里反复重复写这三行。
    def measure(pos):
        """移动镜头到pos, 计算FV并记入轨迹"""
        fv = fv_func(crop_window(lens.capture(pos)))
        # trace.append(...):append是列表自带的方法,表示"往这个列表末尾追加一个新元素"。
        # (pos, fv) 是一个"元组",把位置和对应的FV值打包成一对,一起存进trace列表。
        trace.append((pos, fv))
        return fv

    # pos, direction = start, +1 是"同时给两个变量赋值"的写法,
    # 等价于分开写 pos = start 和 direction = 1。
    # pos记录"当前镜头所在的位置",direction记录"当前搜索方向"(+1表示朝数字增大方向走,-1表示反向)。
    pos, direction = start, +1  # 从起始位置出发, 初始搜索方向设为+1(正向)
    # 先在起点位置测一次FV,作为目前为止的"最优记录"初始值。
    best_pos, best_fv = pos, measure(pos)  # 先测一次起点作为当前最优记录

    # for step in (coarse_step, fine_step): 这个循环只会执行两次:
    # 第一次step等于coarse_step(64,粗搜步长),第二次step等于fine_step(8,细搜步长)。
    # 依次执行"粗搜"和"细搜"两个阶段, 循环体完全相同, 只是step大小不同
    for step in (coarse_step, fine_step):
        drops = 0  # 连续未创新高(FV变小)的计数器,每进入一个新阶段就重新从0开始计数
        # while True: 表示"一直循环下去,除非代码内部主动用break跳出",是"无限循环"的写法,
        # 常用在"不知道具体要循环多少次,得根据运行过程中的情况来决定什么时候停"的场景。
        while True:
            nxt = pos + direction * step  # 按当前方向和步长, 计算下一个要探测的位置
            # lo, hi = lens.motor_range:把镜头的行程范围(0, 1023)拆开赋值给lo(下界)和hi(上界)。
            lo, hi = lens.motor_range
            # not (lo <= nxt <= hi):lo <= nxt <= hi 是Python支持的"链式比较"写法,
            # 意思是"nxt同时满足大于等于lo,并且小于等于hi",即"nxt在合法范围内"。
            # 前面加个not表示"取反",也就是"如果nxt不在合法范围内"(超出了镜头能移动的范围)。
            if not (lo <= nxt <= hi):          # 碰到行程边界, 掉头
                direction = -direction
                drops = 0                      # 掉头后重新开始计数
                # continue 是Python的关键字,意思是"跳过本次循环剩下的代码,直接回到while开头重新判断,
                # 进入下一轮循环"。这里的作用是:掉头之后不要往下执行移动/测量的代码,
                # 而是马上用刚刚更新过的新方向,重新计算一次nxt。
                continue                        # 注意: 这里continue回到while开头, 用新方向重新计算nxt, 不消耗一次measure
            pos = nxt
            fv = measure(pos)
            if fv > best_fv:
                best_fv, best_pos = fv, pos    # 发现更优位置, 更新最优记录并清零连续下降计数
                drops = 0
            else:
                drops += 1
                if drops >= drop_confirm:      # 确认越峰: 连续drop_confirm次都没有再创新高, 认为峰值已经跨过去了
                    # break 是Python的关键字,意思是"立刻跳出当前所在的这个循环",
                    # 这里跳出的是while True这个循环,程序会继续往下执行while循环之后的代码。
                    break
        direction = -direction                 # 细搜阶段反向逼近: 既然已经越过峰值, 掉头往回走去精细定位峰值
        pos = best_pos                          # 从当前找到的最优位置重新出发, 而不是从越界的那个点继续
        # if verbose: 这里verbose是函数一开始的参数,默认值是True,表示"是否打印详细过程"。
        # 如果verbose是True(真),就执行下面缩进的打印语句;如果是False,就跳过不打印。
        if verbose:
            # f"...{best_pos}...{best_fv:.5f}...":同样是f-string格式化字符串。
            # {step:>3} 表示把step这个数字格式化成"至少占3个字符宽度、右对齐",让输出对齐更整齐。
            # {best_fv:.5f} 表示把best_fv这个数字格式化成"保留5位小数的浮点数"显示。
            print(f"  step={step:>3}: peak so far pos={best_pos}, fv={best_fv:.5f}")

    # 循环全部结束后,把最终找到的最优位置best_pos,以及完整的搜索轨迹trace列表,一起返回。
    return best_pos, trace


# ----------------------------------------------------------------------
# 6. 抛物线拟合: 峰附近 3 点 => 亚步长精度峰值位置
# ----------------------------------------------------------------------

def parabola_refine(trace, best_pos):
    """在轨迹中找best_pos及其两侧最近的采样点, 拟合y=ax^2+bx+c求顶点。
    爬山法得到的best_pos只是"采样点里FV最大的那个", 精度受步长限制(比如细搜步长是8,
    结果只能精确到8的整数倍)。真实峰值大概率落在两个采样点之间, 用峰值附近3个点做二次曲线拟合,
    可以在几乎不增加镜头移动次数的前提下获得比采样步长更精细的位置估计。
    """
    # set(trace):set是Python的"集合"数据类型,特点是"自动去除重复的元素"。
    # 因为爬山法搜索过程中,同一个位置可能被反复测量到(比如掉头后经过之前走过的点),
    # 用set可以自动把重复的(位置,FV)记录去掉,只保留不重复的。
    # sorted(...):把去重后的这些(位置,FV)元组,按照"位置从小到大"的顺序重新排列成一个列表
    # (元组之间比较大小时,Python默认先比第一个元素,即位置pos)。
    pts = sorted(set(trace))                     # (pos, fv), 去重(爬山法轨迹里同一位置可能被测量多次)并按位置排序
    # 下面两行又是"列表推导式":
    # [p for p, _ in pts] 表示"遍历pts里的每一个(p, fv)元组,只把p(位置)取出来,组成一个新列表"。
    # 这里的下划线 _ 是Python里的一个习惯写法,意思是"这个位置上的值我不需要用到,随便起个名占位就行",
    # 也就是fv这个值在这一行里被忽略了,只要p。
    xs = np.array([p for p, _ in pts], float)
    # 同理,这一行是"只取fv(FV值),忽略p(位置)",这次下划线占的是p的位置。
    ys = np.array([v for _, v in pts], float)
    # np.argmin(...):返回数组里"最小值所在的位置下标"(不是最小值本身,是它排第几个)。
    # np.abs(xs - best_pos):xs这个数组里每个位置都减去best_pos再取绝对值,
    # 也就是"每个采样位置,离爬山法得到的best_pos有多远"。
    # 整体意思是:在轨迹里,找到"离best_pos最近"的那个采样点,是第几个(下标i)。
    i = int(np.argmin(np.abs(xs - best_pos)))     # 找到轨迹中离best_pos最近的采样点索引
    # if 0 < i < len(xs) - 1: 表示"i这个下标,既不是列表的第一个(0),也不是列表的最后一个",
    # 也就是"这个点左右两边都还有别的采样点"——只有这种情况下才能取到"左中右"3个点来做拟合。
    # len(xs) 是Python内置函数,返回xs这个数组/列表里一共有多少个元素。
    if 0 < i < len(xs) - 1:                       # 只有当这个点左右都还有邻居时才能取3点拟合(排除在轨迹边界的情况)
        # xs[i-1:i+2]:切片写法,取下标从i-1到i+1(不包括i+2)的三个元素,也就是"目标点及左右各一个邻居"共3个点。
        x3, y3 = xs[i - 1:i + 2], ys[i - 1:i + 2]  # 取该点及左右各一个邻居, 共3个(位置,FV)点
        # np.polyfit(x3, y3, 2):这是numpy自带的"多项式拟合"函数,
        # 意思是"用x3, y3这些点,去拟合一个2次多项式(即y=ax^2+bx+c这样的抛物线),
        # 让这条曲线尽可能贴近这几个点",返回值是拟合出来的系数a,b,c(按从高次到低次的顺序)。
        a, b, _ = np.polyfit(x3, y3, 2)            # 用二次多项式拟合这3个点, 得到y=ax^2+bx+c的系数a,b,c(这里c不用)
        if a < 0:                                   # a<0表示开口向下, 确实是一个峰(而不是一个谷或异常形状), 顶点才有意义
            return -b / (2 * a)                     # 二次函数顶点公式: x_peak = -b/(2a)
    # 如果前面if条件不满足(比如目标点在轨迹的最边上,没法取到左右邻居),
    # 就直接返回best_pos本身作为退化结果,不做精细化。
    # float(best_pos) 是把best_pos转换成标准的浮点数类型,保证返回值类型统一。
    return float(best_pos)                          # 无法拟合时(比如在边界), 退化为直接返回爬山法的结果


# ----------------------------------------------------------------------
# 7. 主流程
# ----------------------------------------------------------------------

def main():
    sharp = make_sharp_scene()  # 生成"理想清晰图"作为整个仿真的基准场景

    print("=" * 60)  # "=" * 60 表示把字符串"="重复60次拼接起来,打印出一条分隔线
    print("Step 1: 全局扫描, 对比各 FV 函数曲线 (含低光情形)")
    demo_fv_curves(sharp)  # 纯可视化对比, 不代表实际AF流程, 用于直观理解各FV函数的形状特性

    print()  # 不带参数的print(),作用是打印一个空行,让输出显示更清晰
    print("Step 2: 爬山法 AF (Tenengrad)")
    # 构造一个带轻微噪声(模拟接近亮光但非理想的实拍环境)的镜头仿真, 真实合焦位置623对算法本身是未知的
    lens = LensSimulator(sharp, best_pos=623, noise_std=0.005)
    # hill_climb_af返回两个值,用逗号分开的写法同时接收,final_pos接住最终位置,trace接住整个搜索轨迹
    final_pos, trace = hill_climb_af(lens, fv_tenengrad, start=0)  # 用Tenengrad作为FV函数执行爬山法搜索, 从马达位置0开始
    refined = parabola_refine(trace, final_pos)  # 对爬山法结果做抛物线亚步长精细化
    print(f"  真实合焦位置 : {lens.best_pos}")
    print(f"  爬山法结果   : {final_pos}  (误差 {abs(final_pos - lens.best_pos)})")
    print(f"  抛物线拟合后 : {refined:.1f}  (误差 {abs(refined - lens.best_pos):.1f})")
    print(f"  马达移动次数 : {lens.move_count} (对比全局扫描需 64 次)")  # 体现爬山法相比全局扫描的效率优势

    # ---- 可视化搜索轨迹 ----
    positions = np.arange(0, 1024, 8)
    ref_lens = LensSimulator(sharp, best_pos=623)  # 额外构造一个无噪声的参考镜头, 只用来画出"真实、干净的FV曲线"作为背景参考
    curve = global_scan(ref_lens, positions, fv_tenengrad)

    # 这次创建1行3列共3个子图的画布,用来分别展示"搜索轨迹曲线图"、"远离焦示例图"、"AF结果示例图"
    fig, axes = plt.subplots(1, 3, figsize=(16, 4.5))
    ax = axes[0]  # axes是一个数组,axes[0]表示取出第一个(最左边)子图,之后专门在这个子图上画轨迹曲线
    ax.plot(positions, curve, color="lightgray", label="true FV curve")  # 灰色背景线: 完整的真实FV曲线, 仅作对比参考(实际AF不会去扫描它)
    # tp = np.array([p for p, _ in trace]):从trace(一串(位置,FV)元组)里,只把所有位置提取出来组成数组tp。
    # 注意这里一行写了两条语句,用分号;隔开——Python允许把多条语句写在同一行,用分号分隔,但通常不推荐,容易读起来费劲,这里只是原作者图简洁。
    tp = np.array([p for p, _ in trace]); tf = np.array([v for _, v in trace])  # 把爬山法的搜索轨迹拆成位置数组和FV值数组
    # "o-"是matplotlib里表示"线型+点型"的简写符号:o表示每个数据点画一个小圆圈,-表示点与点之间用实线连接。
    # ms=4是圆圈的大小(marker size),lw=1是线的粗细(line width)。
    ax.plot(tp, tf, "o-", ms=4, lw=1, color="tab:blue", label="search trace")  # 蓝色折线: 爬山法实际走过的路径
    # enumerate(trace):enumerate是Python内置函数,作用是"给一个列表里的每个元素,自动配上一个从0开始的序号"。
    # for n, (p, v) in enumerate(trace): 这里n是自动生成的序号(第0个、第1个...),(p,v)是trace里对应的那个元组,自动拆开成p和v两个变量。
    for n, (p, v) in enumerate(trace):
        # ax.annotate(...):在图上某个坐标位置添加一段文字标注。
        # str(n):把数字n转换成字符串类型(annotate需要文字内容是字符串)。
        # (p, v):文字标注要贴着显示的坐标点。
        # textcoords="offset points", xytext=(2, 4):表示文字相对于这个坐标点,再往右偏移2个点、往上偏移4个点显示,避免文字和点重叠在一起看不清。
        ax.annotate(str(n), (p, v), fontsize=6, textcoords="offset points", xytext=(2, 4))  # 给每个采样点标上顺序编号, 方便看清搜索的先后过程
    ax.axvline(lens.best_pos, color="k", ls="--", lw=1)          # 黑色虚线: 真实合焦位置
    ax.axvline(refined, color="r", ls=":", lw=1.5, label=f"result={refined:.0f}")  # 红色点线: 抛物线精细化后的最终估计位置
    ax.set_title("Hill climbing trace (coarse -> fine)")
    # 这一行同样用分号写了两条语句:先设置x轴标签,再设置y轴标签。
    ax.set_xlabel("motor position"); ax.set_ylabel("FV (Tenengrad)")
    ax.legend(fontsize=8); ax.grid(alpha=0.3)

    # ---- 展示离焦 / 合焦图像 ----
    # for ax, p, t in [...]: 又是"同时遍历三个配套变量"的写法,
    # 第一次: ax=axes[1](中间子图), p=100(远离焦位置), t="far defocus"(标题文字)
    # 第二次: ax=axes[2](最右子图), p=int(round(refined))(把抛物线精细化结果四舍五入取整后的AF最终结果位置), t="AF result"
    for ax, p, t in [(axes[1], 100, "far defocus"), (axes[2], int(round(refined)), "AF result")]:
        # 用一个全新的、无噪声的镜头实例分别在"远离焦位置100"和"AF算法给出的最终结果位置"各拍一张图, 直观对比清晰度差异
        # ax.imshow(...):把一个二维数组当作灰度图片显示出来。
        # cmap="gray"表示用"灰度色板"(数值大显示为白,数值小显示为黑)。
        # vmin=0, vmax=1 表示"显示时把0对应到最暗、1对应到最亮",保证不同图片之间亮度对比标准一致。
        ax.imshow(LensSimulator(sharp, best_pos=623).capture(p), cmap="gray", vmin=0, vmax=1)
        ax.set_title(f"pos={p} ({t})"); ax.axis("off")  # axis("off")表示不显示坐标轴刻度线, 让图片显示更干净
    fig.tight_layout()
    fig.savefig("af_hill_climb.png", dpi=130)
    print("[saved] af_hill_climb.png")


# if __name__ == "__main__": 是Python脚本的一个标准写法/习惯用法。
# __name__ 是Python自动给每个文件设置的一个内置变量:
#   - 如果这个.py文件是"被直接运行"的(比如你在命令行敲 python af_simulation.py),
#     那么__name__会被自动设置成字符串"__main__",这个if条件就成立,里面的代码就会执行。
#   - 如果这个.py文件是"被其他代码当作模块import进去、间接调用里面的函数",
#     __name__就不等于"__main__",这个if里的代码就不会自动执行。
# 简单理解:这是在说"只有当这个文件被直接运行时,才自动调用main()函数开始执行整个流程"。
if __name__ == "__main__":
    main()

编辑于 2026-07-31 · 著作权归作者所有
相关文章
拍人像选85 1.4还是135 1.8?外出拍照,10个小动作,跟她学,出片阳光漂亮,明媚出片如何科学高效地学习摄影?从白天到黑夜,三星S26 Ultra全场景手机影像实测商场室内拍照,8个动作姿势,气质漂亮又显身材,性感又迷人耗资百亿的登月任务,偏偏选台10年老单反?2026.6:近期七款AR眼镜的使用体验:xreal xbx a01+、雷鸟GTMAX、雷鸟GT、viture beast、雷鸟air4pro、xreal 1s、viture luma镜头喂不饱高像素机身?两个阵营吵了五年,其实都没错肉肉女生拍照,9个动作不呆板,拍照出片大方、漂亮、身材好周末外出拍照,几个动作+微笑,温柔大方很漂亮,松弛好看很出片街景日常拍照,7个动作,漂亮好看又显瘦,清爽甜酷又上镜如何看待小米 17ultra 变焦环只是电子环?这真徕卡吗?为什么现在追求「胶片感」和 「噪点」的年轻人,反而比追求画质的人更多?如何将摄影技术真的提高一个水平呢?公园长椅、花墙拍照,这些姿势,优雅气质又上镜,出片好看不僵硬你用镜头记录过哪些「耳畔的治愈瞬间」?哪些声音的画面,让你拍出了生活的松弛感?如何评价韩国网友把「vivo增距镜」加进中国旅行购物清单?增距镜为何成为韩国人眼中的中国特产?如何看待杨长顺拆机直播被他人切片称小米17ultra徕卡大师变焦环不是机械变焦?2026旗舰手机夜拍天花板:三星S26 Ultra等三部手机深度测评本人高二,两年12万次快门,感觉拍得不错,求星巴克老师锐评?