医学图像超分辨率:如何构建“教科书级”的模型评测与交互式可视化流水线?

摘要: 在超分辨率(SR)任务中,尤其是引入了感知损失(Perceptual Loss)后,PSNR 不再是衡量视觉质量的唯一真理。为了高效对比多个微调 epoch 的模型视觉效果,我从零编写了一个全自动的模型推理与可视化脚本。该脚本支持:多模型批量推理、保留复杂子目录结构、局部 ROI 裁剪放大、OpenCV 误差热力图生成,并能一键导出支持"原位拉片"和"盲测模式"的本地交互式 HTML 网页。

一、 为什么写这个脚本?(核心痛点)

在最近的内窥镜图像超分辨率(ESPCN-RDB 结合 EndoMamba 感知损失)项目中,我采用了"广撒网"式的参数定期存档策略,保存了包括 latestbest 以及多个 epoch_xxx 在内的多组权重。但在最终挑选模型时,我遇到了极其痛苦的工程阻力:

  1. 4K 图像的"反人类"对比:测试集输出是 3840x2160 分辨率。简单地把多张输出拼成网格图(Grid),缩小看毫无区别,放大又无法在几张子图间精准对齐坐标(极难观察微血管和息肉纹理的恢复情况)。
  2. 主观先入为主的偏见:带着"这个是最新模型"的潜意识去看图,往往会影响对真实视觉伪影的判断。
  3. 数据集目录结构复杂 :医学数据集往往按 patient_idvideo_clip 划分子文件夹,常规的平铺测试脚本会导致同名图像互相覆盖。

为了彻底解决这些问题,我决定花时间打造一个一劳永逸的 test_and_visualize.py 自动化流水线。


二、 核心内容与实现步骤

整个评测流水线分为三个核心模块:批量推理引擎高级对比图生成器交互式 HTML 生成器

1. 批量推理引擎:相对路径映射与缓存优化

  • 正则解析权重 :通过 re.search 直接从 .pth 文件名中自动提取 growth_channelsattention_type 等结构超参数,动态实例化模型,免去手动传参的繁琐。
  • 目录结构完美复刻 :放弃简单的 glob,使用 pathlib.Path.rglob 递归扫描输入目录。通过 os.path.relpath 计算相对路径,确保输出结果完美复现测试集的子文件夹树(如 model_A/patient_1/01.png)。
  • Bicubic 内存缓存 :因为基线图像(Bicubic)对于不同模型是一样的,我引入了 bicubic_cache 字典进行缓存,避免了对每一张图重复执行耗时的双三次插值计算。

2. 高级对比图生成器:ROI 裁剪与误差热力图

为了直观暴露模型的生成伪影,我引入了 OpenCV 和 Matplotlib,抛弃了简单的整图拼接:

  • ROI 局部精准裁剪 :脚本支持传入 crop_box=(x, y, w, h),自动从几十组 4K 结果中"抠"出病灶区域进行并排展示。
  • 误差热力图 (Error Heatmap) :利用 cv2.absdiff 计算生成图与 HR (Ground Truth) 的绝对像素误差,并应用 cv2.COLORMAP_JET 将其转换为热力图。深红代表误差大/伪影重,深蓝代表恢复完美,优劣一目了然。

3. 本地零依赖 HTML 交互盲测工具

这是整个脚本最"极客"的部分。我用一段原生的 HTML+JS 代码,配合 Python 的 jsonshutil 模块,自动生成了一个静态网页打包:

  • 原位拉片切换 :将 Bicubic、HR 和所有微调 epoch 的输出图堆叠在同一坐标,通过键盘的 方向键瞬间切换。因为像素绝对对齐,任何多余的伪影在人眼中都会像"闪烁"一样刺眼。
  • 一键盲测 (Blind Test) :加入了一个 Checkbox,勾选后,所有模型名称被隐藏替换为 Model 1Model 2,强迫自己只关注图像本身的医学真实度进行客观挑选。

三、 踩坑记录与解决方法

坑 1:多子文件夹下同名图片的覆盖灾难

  • 问题描述 :早期测试脚本使用平铺的 os.listdir,导致 folder_A/001.png 会覆盖掉 folder_B/001.png 的推理结果。同时,Matplotlib 在保存名称包含 / 的路径时会报错。
  • 解决方法 :在生成 HTML 时,利用相对路径 rel_path 重建物理文件夹结构;在生成平面对比 Grid 图时,使用 safe_name = rel_path.replace(os.sep, '__') 将路径安全地转化为单层文件名(如 folder_A__001.png)。

坑 2:HTML 跨域加载本地图片失败

  • 问题描述:如果 HTML 中的 JavaScript 直接读取外部的绝对路径图片,在某些现代浏览器中会触发本地 CORS 安全限制。
  • 解决方法 :在 Python 端不仅生成 HTML 代码,还通过 shutil.copy 将本次测试涉及的所有图片(含 HR 和各模型输出)统一物理拷贝到 Interactive_Viewer 子目录下。这样整个文件夹就是一个便携的、绿色的静态网站,可以直接打包发给医生或导师评估。

四、 收获与总结

这次造轮子的经历让我深刻体会到:在算法工程中,"怎么评估模型"往往比"怎么训练模型"更重要。

  1. 摆脱了"PSNR 唯分数论" :在引入领域基础模型做感知损失后,PSNR 必然会下降。这套可视化流水线给了我通过肉眼"盲测"寻找最佳模型的底气,不再盲目追求指标上的最高点。
  2. 工程化的思维转变 :从写出乱糟糟的面条代码,到引入 rel_path 映射、bicubic_cache 优化显存、自动化网页打包,这是一个从"跑通即可"向"生产力工具"思维的转变。

目前这套工具已经完美适配了我的医学图像超分项目,下一阶段,我计划在 HTML 网页中加入局部放大镜功能,进一步压榨前端交互的潜力。


代码附录

less 复制代码
import argparse
import time
import os
import glob
import re
import math
import json
import shutil
from pathlib import Path

import torch
import torch.backends.cudnn as cudnn
import numpy as np
import PIL.Image as pil_image
import matplotlib.pyplot as plt
import cv2  # 用于热力图

# 引入自定义的模型结构和工具函数
from models import ESPCN_RDB
from utils import convert_ycbcr_to_rgb, preprocess


def parse_model_kwargs(model_name):
    """从模型文件名中提取超参数"""
    parsed_kwargs = {
        'growth_channels': 16,
        'rdb_layers': 3,
        'activation': 'LeakyReLU',
        'attention_type': 'pixel'
    }

    match_gc = re.search(r'growth_channels_(\d+)', model_name)
    if match_gc:
        parsed_kwargs['growth_channels'] = int(match_gc.group(1))

    match_rdb = re.search(r'RDB_(\d+)', model_name)
    if match_rdb:
        parsed_kwargs['rdb_layers'] = int(match_rdb.group(1))

    match_attn = re.search(r'Attn_(pixel|weakened_pixel|none)', model_name)
    if match_attn:
        parsed_kwargs['attention_type'] = match_attn.group(1)

    activation_types = ['ReLU', 'LeakyReLU', 'PReLU', 'Tanh', 'Sigmoid', 'GELU']
    for act in activation_types:
        if f"_{act}_" in model_name or model_name.endswith(f"_{act}"):
            parsed_kwargs['activation'] = act
            break

    return parsed_kwargs


def create_advanced_comparison(rel_path, models_list, output_root, hr_path=None, bicubic_img=None, crop_box=None):
    """高级对比图生成器:支持局部裁剪放大和误差热力图"""
    print(f"🎨 正在绘制可视化对比图: {rel_path} ...")

    images_dict = {}
    if bicubic_img is not None:
        images_dict['Bicubic'] = np.array(bicubic_img)

    for model_name in models_list:
        img_path = os.path.join(output_root, model_name, rel_path)
        if os.path.exists(img_path):
            images_dict[model_name] = np.array(pil_image.open(img_path).convert('RGB'))

    hr_img_np = None
    if hr_path and os.path.exists(hr_path):
        hr_img_np = np.array(pil_image.open(hr_path).convert('RGB'))
        images_dict['HR (GT)'] = hr_img_np

    # 1. 局部裁剪处理
    if crop_box is not None:
        x, y, w, h = crop_box
        for key in images_dict.keys():
            images_dict[key] = images_dict[key][y:y + h, x:x + w, :]
        if hr_img_np is not None:
            hr_crop = hr_img_np[y:y + h, x:x + w, :]

    # 2. 生成误差热力图
    error_maps = {}
    if hr_img_np is not None:
        hr_gray = cv2.cvtColor(images_dict['HR (GT)'], cv2.COLOR_RGB2GRAY).astype(np.float32)
        for key, img_arr in images_dict.items():
            if key == 'HR (GT)': continue
            pred_gray = cv2.cvtColor(img_arr, cv2.COLOR_RGB2GRAY).astype(np.float32)
            diff = np.abs(hr_gray - pred_gray)
            diff_norm = np.clip(diff / 50.0 * 255.0, 0, 255).astype(np.uint8)
            heatmap = cv2.applyColorMap(diff_norm, cv2.COLORMAP_JET)
            heatmap = cv2.cvtColor(heatmap, cv2.COLOR_BGR2RGB)
            error_maps[key + "\nError Map"] = heatmap

    final_plot_dict = {**images_dict, **error_maps}

    # 3. 绘制并排网格
    cols = len(images_dict)
    rows = 2 if len(error_maps) > 0 else 1

    fig, axes = plt.subplots(rows, cols, figsize=(5 * cols, 5 * rows))
    if rows == 1: axes = np.array([axes])
    if cols == 1: axes = axes.T

    plot_keys = list(images_dict.keys())
    for col_idx, key in enumerate(plot_keys):
        axes[0, col_idx].imshow(images_dict[key])
        short_title = key.replace('growth_channels_16_RDB_3_LeakyReLU_Attn_pixel_', '')
        axes[0, col_idx].set_title(short_title, fontsize=14, fontweight='bold')
        axes[0, col_idx].axis('off')

    if rows == 2:
        for col_idx, key in enumerate(plot_keys):
            if key == 'HR (GT)':
                axes[1, col_idx].axis('off')
                continue
            error_key = key + "\nError Map"
            axes[1, col_idx].imshow(final_plot_dict[error_key])
            axes[1, col_idx].set_title("Error Heatmap", fontsize=12, color='red')
            axes[1, col_idx].axis('off')

    plt.tight_layout()

    # 将具有子文件夹属性的路径转化为下划线形式防止报错,比如 folder/01.png -> folder__01.png
    safe_name = rel_path.replace(os.sep, '__').replace('/', '__')
    prefix = "Crop_" if crop_box else "Full_"

    vis_dir = os.path.join(output_root, "Visual_Comparisons")
    os.makedirs(vis_dir, exist_ok=True)
    save_path = os.path.join(vis_dir, f"{prefix}{safe_name}")

    plt.savefig(save_path, dpi=200, bbox_inches='tight')
    plt.close(fig)


def create_html_viewer(rel_paths, models_list, output_root, hr_dir=None, bicubic_cache=None):
    print("\n🌐 正在生成交互式 HTML 盲测网页...")

    html_dir = os.path.join(output_root, "Interactive_Viewer")
    os.makedirs(html_dir, exist_ok=True)

    final_models = []

    if bicubic_cache:
        final_models.append("Bicubic (Baseline)")
        for rel_path, img_obj in bicubic_cache.items():
            dst_path = os.path.join(html_dir, "Bicubic (Baseline)", rel_path)
            os.makedirs(os.path.dirname(dst_path), exist_ok=True)  # 保持子目录结构
            img_obj.save(dst_path)

    final_models.extend(models_list)

    if hr_dir:
        final_models.append("HR (Ground Truth)")

    data = {"images": rel_paths, "models": final_models, "paths": {}}

    for rel_path in rel_paths:
        data["paths"][rel_path] = []
        for model in final_models:
            if model == "Bicubic (Baseline)":
                web_path = f"Bicubic (Baseline)/{rel_path}".replace('\', '/')
            elif model == "HR (Ground Truth)":
                src_hr = os.path.join(hr_dir, rel_path)
                dst_hr = os.path.join(html_dir, "HR (Ground Truth)", rel_path)
                if os.path.exists(src_hr):
                    os.makedirs(os.path.dirname(dst_hr), exist_ok=True)
                    shutil.copy(src_hr, dst_hr)
                web_path = f"HR (Ground Truth)/{rel_path}".replace('\', '/')
            else:
                src_model_img = os.path.join(output_root, model, rel_path)
                dst_model_img = os.path.join(html_dir, model, rel_path)
                if os.path.exists(src_model_img):
                    os.makedirs(os.path.dirname(dst_model_img), exist_ok=True)
                    shutil.copy(src_model_img, dst_model_img)
                web_path = f"{model}/{rel_path}".replace('\', '/')

            data["paths"][rel_path].append(web_path)

    html_content = """
    <!DOCTYPE html>
    <html lang="zh-CN">
    <head>
        <meta charset="UTF-8">
        <title>EndoMamba SR 交互式拉片对比系统</title>
        <style>
            body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background-color: #121212; color: #ffffff; text-align: center; margin: 0; padding: 0; overflow-x: hidden; }
            #toolbar { padding: 15px 30px; background-color: #1e1e1e; display: flex; justify-content: space-between; align-items: center; box-shadow: 0 4px 6px rgba(0,0,0,0.3); position: relative; z-index: 10; }
            .tools-left, .tools-center, .tools-right { display: flex; align-items: center; gap: 15px; }
            #image-container { position: relative; display: inline-block; margin-top: 20px; max-width: 98vw; height: 85vh; }
            img { max-width: 100%; max-height: 100%; object-fit: contain; }
            select { padding: 8px; border-radius: 4px; background: #333; color: white; border: 1px solid #555; outline: none; font-size: 14px;}
            .btn { background: #007acc; color: white; border: none; padding: 10px 20px; cursor: pointer; font-size: 14px; border-radius: 4px; font-weight: bold; transition: background 0.2s;}
            .btn:hover { background: #005f9e; }
            #model-badge { position: absolute; top: 15px; left: 15px; background: rgba(0, 0, 0, 0.7); padding: 10px 20px; font-size: 24px; font-weight: bold; border-radius: 6px; color: #00ffcc; pointer-events: none; border: 1px solid rgba(255,255,255,0.2); backdrop-filter: blur(4px); }
            .switch-container { display: flex; align-items: center; font-size: 14px; font-weight: bold; color: #e74c3c; background: rgba(231, 76, 60, 0.1); padding: 8px 15px; border-radius: 20px; border: 1px solid #e74c3c;}
            input[type="checkbox"] { width: 18px; height: 18px; cursor: pointer; margin-right: 8px; }
            #tips { font-size: 12px; color: #888; margin-top: 10px; }
        </style>
    </head>
    <body>
        <div id="toolbar">
            <div class="tools-left">
                <label style="font-weight: bold;">🎯 目标图像: </label>
                <select id="image-select" onchange="changeImage()"></select>
            </div>

            <div class="tools-center">
                <button class="btn" onclick="prevModel()">⬅ 上一模型 (Left)</button>
                <span id="model-counter" style="font-size: 18px; font-weight: bold; min-width: 80px;"></span>
                <button class="btn" onclick="nextModel()">下一模型 (Right) ➡</button>
            </div>

            <div class="tools-right">
                <div class="switch-container">
                    <input type="checkbox" id="blind-mode" onchange="toggleBlindMode()">
                    <label for="blind-mode" style="cursor: pointer;">开启盲测模式</label>
                </div>
            </div>
        </div>

        <div id="image-container">
            <img id="display-img" src="" alt="SR Image">
            <div id="model-badge">Model Name</div>
        </div>

        <div id="tips">💡 快捷操作:使用键盘左右方向键 [←] [→] 切换模型,上下方向键 [↑] [↓] 切换图像</div>

        <script>
            const data = {DATA_JSON};
            let currentImgIdx = 0;
            let currentModIdx = 0;
            let isBlindMode = false;

            function init() {
                const select = document.getElementById('image-select');
                data.images.forEach((img, i) => {
                    let opt = document.createElement('option');
                    opt.value = i;
                    opt.innerHTML = img; // img 这里现在是包含子文件夹的相对路径
                    select.appendChild(opt);
                });
                updateView();
            }

            function changeImage() { currentImgIdx = parseInt(document.getElementById('image-select').value); updateView(); }
            function prevModel() { currentModIdx = (currentModIdx - 1 + data.models.length) % data.models.length; updateView(); }
            function nextModel() { currentModIdx = (currentModIdx + 1) % data.models.length; updateView(); }
            function toggleBlindMode() { isBlindMode = document.getElementById('blind-mode').checked; updateView(); }

            function updateView() {
                const imgName = data.images[currentImgIdx];
                const rawModelName = data.models[currentModIdx];
                const imgPath = data.paths[imgName][currentModIdx];

                document.getElementById('display-img').src = imgPath;

                const badge = document.getElementById('model-badge');
                if (isBlindMode) {
                    badge.innerHTML = "Model " + (currentModIdx + 1);
                    badge.style.color = "#ffeb3b";
                } else {
                    let shortName = rawModelName.replace('growth_channels_16_RDB_3_LeakyReLU_Attn_pixel_', '');
                    badge.innerHTML = shortName;
                    badge.style.color = "#00ffcc";
                }

                document.getElementById('model-counter').innerHTML = (currentModIdx + 1) + " / " + data.models.length;
            }

            document.addEventListener('keydown', (e) => {
                if (e.key === "ArrowLeft") { e.preventDefault(); prevModel(); }
                else if (e.key === "ArrowRight") { e.preventDefault(); nextModel(); }
                else if (e.key === "ArrowUp") { e.preventDefault(); currentImgIdx = Math.max(0, currentImgIdx - 1); document.getElementById('image-select').value = currentImgIdx; updateView(); }
                else if (e.key === "ArrowDown") { e.preventDefault(); currentImgIdx = Math.min(data.images.length - 1, currentImgIdx + 1); document.getElementById('image-select').value = currentImgIdx; updateView(); }
            });

            window.onload = init;
        </script>
    </body>
    </html>
    """

    html_path = os.path.join(html_dir, "index.html")
    with open(html_path, "w", encoding="utf-8") as f:
        f.write(html_content.replace("{DATA_JSON}", json.dumps(data)))

    print(f"✅ HTML 网页生成完毕!请双击查阅: {html_path}")


if __name__ == '__main__':
    parser = argparse.ArgumentParser(description="Batch Test and Visualize SR Models")

    # 填入你之前的微调权重文件夹路径
    parser.add_argument('--weights-dir', type=str,
                        default='D:/super-resolution/ESPCN_RDB_Encoscope/output/x2/growth_channels_16_RDB_3_LeakyReLU_Attn_pixel_finetuned',
                        help='包含多个 .pth 模型的文件夹路径')

    # 填入验证集低分辨率图路径
    parser.add_argument('--input-dir', type=str,
                        default='D:/super-resolution/datasets/SurgiSR4K/data/images_crop/test/1920x1080p',
                        help='低分辨率 (LR) 测试图路径')

    # 填入验证集高分辨率真实图路径
    parser.add_argument('--hr-dir', type=str,
                        default='D:/super-resolution/datasets/SurgiSR4K/data/images_crop/test/3840x2160p',
                        help='高分辨率 (HR) 测试图路径 (可选,用于对比)')

    # 填入结果输出路径
    parser.add_argument('--output-dir', type=str,
                        default='D:/super-resolution/ESPCN_RDB_Encoscope/x2/growth_channels_16_RDB_3_LeakyReLU_Attn_pixel_finetuned/Visual_Results',
                        help='测试结果和对比图的输出根目录')

    parser.add_argument('--scale', type=int, default=2, help='放大倍数')

    # 支持通过相对路径或文件名过滤 (例如 --target-images patient1/01.png)
    parser.add_argument('--target-images', nargs='+', default=['a (33).jpg','a (27).jpg'],
                        help='指定要测试的图片名或相对路径。默认 all 为全部。')


    args = parser.parse_args()

    # 1. 环境设置
    cudnn.benchmark = False
    device = torch.device('cuda:0' if torch.cuda.is_available() else 'cpu')

    # 2. 搜集所有模型
    weight_paths = sorted(glob.glob(os.path.join(args.weights_dir, '*.pth')))
    if not weight_paths:
        raise ValueError(f"在 {args.weights_dir} 下没有找到任何 .pth 文件!")

    # ==========================================
    # ✨ 核心升级 1:递归扫描输入目录下的所有图片
    # ==========================================
    all_image_paths = []
    valid_extensions = {'.png', '.jpg', '.jpeg', '.bmp', '.tif', '.tiff'}
    for p in Path(args.input_dir).rglob('*'):
        if p.is_file() and p.suffix.lower() in valid_extensions:
            all_image_paths.append(str(p))

    all_image_paths = sorted(all_image_paths)

    # 过滤指定的图像(支持按 basename 或 rel_path 过滤)
    if 'all' not in args.target_images:
        image_paths = []
        for p in all_image_paths:
            basename = os.path.basename(p)
            rel_path = os.path.relpath(p, args.input_dir).replace('\', '/')
            if basename in args.target_images or rel_path in args.target_images:
                image_paths.append(p)
    else:
        image_paths = all_image_paths

    if not image_paths:
        raise ValueError(f"未找到指定的测试图像,请检查 input-dir 路径。")

    print(f"==> 共找到 {len(weight_paths)} 个待测模型。")
    print(f"==> 共指定 {len(image_paths)} 张测试图像。")

    models_list = []
    bicubic_cache = {}

    # ===================================================================
    # 阶段一:遍历所有模型,进行推理
    # ===================================================================
    for w_idx, weight_path in enumerate(weight_paths, 1):
        model_name = os.path.splitext(os.path.basename(weight_path))[0]
        models_list.append(model_name)
        model_out_dir = os.path.join(args.output_dir, model_name)

        print(f"\n[{w_idx}/{len(weight_paths)}] 🚀 正在加载并测试模型: {model_name}")

        parsed_kwargs = parse_model_kwargs(model_name)
        model = ESPCN_RDB(scale_factor=args.scale, num_channels=1, **parsed_kwargs).to(device)

        checkpoint = torch.load(weight_path, map_location=device)
        state_dict = checkpoint["model_state_dict"] if "model_state_dict" in checkpoint else checkpoint
        clean_state_dict = {k: v for k, v in state_dict.items() if
                            not (k.endswith('total_ops') or k.endswith('total_params'))}
        model.load_state_dict(clean_state_dict, strict=True)
        model.eval()

        for img_path in image_paths:
            # ✨ 核心升级 2:计算相对路径,完美保持目录结构!
            rel_path = os.path.relpath(img_path, args.input_dir)
            save_path = os.path.join(model_out_dir, rel_path)

            # 如果目录不存在,则自动创建对应深度的子目录
            os.makedirs(os.path.dirname(save_path), exist_ok=True)

            if os.path.exists(save_path):
                # 记录进 bicubic_cache 以防万一是之前的断点恢复
                if rel_path not in bicubic_cache:
                    lr_img = pil_image.open(img_path).convert('RGB')
                    bicubic_cache[rel_path] = lr_img.resize((lr_img.width * args.scale, lr_img.height * args.scale),
                                                            resample=pil_image.BICUBIC)
                continue

            lr_img = pil_image.open(img_path).convert('RGB')
            bicubic = lr_img.resize((lr_img.width * args.scale, lr_img.height * args.scale), resample=pil_image.BICUBIC)

            if rel_path not in bicubic_cache:
                bicubic_cache[rel_path] = bicubic

            lr_tensor, _ = preprocess(lr_img, device)
            _, ycbcr = preprocess(bicubic, device)

            with torch.no_grad():
                preds = model(lr_tensor).clamp(0.0, 1.0)

            preds = preds.mul(255.0).cpu().numpy().squeeze(0).squeeze(0)
            output = np.array([preds, ycbcr[..., 1], ycbcr[..., 2]]).transpose([1, 2, 0])
            output = np.clip(convert_ycbcr_to_rgb(output), 0.0, 255.0).astype(np.uint8)
            output_img = pil_image.fromarray(output)
            output_img.save(save_path)

        del model
        torch.cuda.empty_cache()

    # ===================================================================
    # 阶段二:聚合生成对比视图和 HTML
    # ===================================================================
    print("\n" + "=" * 50)
    print("✨ 开始生成全目录的可视化对比图...")
    print("=" * 50)

    rel_paths_list = []

    for img_path in image_paths:
        rel_path = os.path.relpath(img_path, args.input_dir)
        rel_paths_list.append(rel_path)

        hr_path = os.path.join(args.hr_dir, rel_path) if args.hr_dir else None

        # 1. 绘制完整大图的对比 (传的都是相对路径 rel_path)
        create_advanced_comparison(
            rel_path=rel_path,
            models_list=models_list,
            output_root=args.output_dir,
            hr_path=hr_path,
            bicubic_img=bicubic_cache.get(rel_path),
            crop_box=None
        )

        # 2. 提取重点区域放大对比(默认在 x=1500, y=1000 大小400x400,可自行修改)
        # create_advanced_comparison(
        #     rel_path=rel_path,
        #     models_list=models_list,
        #     output_root=args.output_dir,
        #     hr_path=hr_path,
        #     bicubic_img=bicubic_cache.get(rel_path),
        #     crop_box=(1500, 1000, 400, 400)
        # )

    # 调用 HTML 生成器,传入带子文件夹结构的相对路径列表
    create_html_viewer(rel_paths_list, models_list, args.output_dir, args.hr_dir, bicubic_cache)
相关推荐
格林威4 小时前
工业相机参数解析:曝光时间与运动模糊的“生死博弈”
c++·人工智能·数码相机·opencv·算法·计算机视觉·工业相机
AI科技星19 小时前
全尺度角速度统一:基于 v ≡ c 的纯推导与验证
c语言·开发语言·人工智能·opencv·算法·机器学习·数据挖掘
格林威1 天前
C++ 工业视觉实战:Bayer 图转 RGB 的 3 种核心算法(邻域平均、双线性、OpenCV 源码级优化)
开发语言·c++·人工智能·opencv·算法·计算机视觉·工业相机
格林威1 天前
工业相机图像高速存储(C++版):RAID 0 NVMe SSD 阵列方法,附堡盟相机实战代码!
开发语言·c++·人工智能·数码相机·opencv·计算机视觉·视觉检测
容沁风1 天前
用opencv和yolov5su定位二维码
opencv·yolo·二维码
追烽少年x1 天前
在Python中学习OpenCV - ROI(region of interest)
python·opencv
液态不合群2 天前
OpenCV多线程编程:从单线程到多线程的视频处理
人工智能·opencv·音视频
万物得其道者成2 天前
uni-app Android 离线打包:多环境(prod/dev)配置
android·opencv·uni-app
kkoral2 天前
OpenCV 与 FFmpeg 的关系
opencv·ffmpeg