开始讲解之前推荐一下我的专栏,本专栏的内容支持(分类、检测、分割、追踪、关键点检测),专栏目前为限时折扣,欢迎大家订阅本专栏,本专栏每周更新5-7篇最新机制,更有包含我所有改进的文件和交流群提供给大家,本人定期在群内分享发表论文方法和经验。


一、本文介绍

本文给大家带来的最新改进机制是CVPR顶会中的一种新型颜色空间HVI机制针对低照度图像增强任务中的红色区域断裂和暗区噪声问题。HVI通过极化映射重构色相表示,解决HSV中红色不连续问题,并引入可学习的强度塌缩机制稳定暗区几何分布。核心设计包括:1) 极坐标化HS平面消除红色断裂;2) 自适应暗区压缩抑制噪声。本文内容可以无缝插入到任何的机制中实现二次创新,同时本文将其集成在YOLOv26的Neck中实现有效涨点。

欢迎大家订阅我的专栏一起学习YOLO,购买专栏读者联系读者入群获取进阶项目文件!

(文字学不会的读者,作者可提供视频学习方法.)   

专栏链接:YOLOv26有效涨点专栏包含:Conv、注意力机制、主干/Backbone、损失函数、优化器、后处理等改进机制


目录

一、本文介绍

二、原理介绍

三、核心代码

四、添加方法

4.1 修改一

4.2 修改二 

4.3 修改三 

4.4 修改四 

五、正式训练

5.1 yaml文件

5.2 训练代码 

5.3 训练过程截图 

五、本文总结


二、原理介绍

论文链接:官方论文链接点击此处即可跳转

代码链接:官方代码链接点击此处即可跳转

HVI 不是一个普通的网络层,而是文章最核心的“颜色空间模块”。它的目标不是直接堆叠更复杂的网络,而是先从表示空间上解决低照度增强里最棘手的两个问题:红色区域的不连续噪声极暗区域的黑平面噪声。作者认为,很多方法在 sRGB 空间里做增强时,亮度和颜色强耦合,容易出现颜色偏移;即便换到 HSV,虽然亮度与颜色解耦了,但又会引入新的伪影。因此,HVI 的本质就是:在 HSV 的基础上重新组织色彩表示,让“相似颜色在空间里更接近”,尤其让红色更连续、暗区更稳定。

从文章的逻辑来看,HVI 可以概括为两个关键设计:极坐标化的 HS 平面可学习的强度塌缩机制。作者先沿用 HSV 的优点,把亮度信息从颜色中分离出来,其中亮度并不是靠网络去猜,而是依据 Max-RGB 理论直接由输入图像得到强度图(I_{\max}(x)=\max_{c\in{R,G,B}} I_c(x))。随后,作者指出 HSV 的色相轴在红色处存在首尾断裂:红色既出现在 (h=0) 也出现在 (h=6),这会让原本相近的红色点在颜色空间里被拉得很远,增强后就容易在红色区域产生斑点和断裂伪影。为了解决这个问题,文章把 Hue 进行极化映射,用正交的水平/垂直坐标来表示色相方向,从而把原先“断开的红色两端”重新接起来。

与红色断裂问题对应的第二个核心设计,是 HVI 对极暗区域的处理。作者发现,HSV 在非常暗的区域会形成所谓的 black plane noise,也就是黑色附近的颜色点分布很散,增强时特别容易把暗区噪声当成真实纹理一起放大。为此,HVI 引入了一个可训练的 darkness density 参数 (k),并通过一个基于亮度的 intensity collapse function (C_k)去自适应压缩低亮度区域的半径。直观理解就是:亮度很低时,把这些颜色点往中心“收拢”;亮度逐渐升高时,再让颜色分布逐渐展开。这样一来,暗区中的杂乱噪声不会被无约束地放大,而是先被压缩到更稳定、更紧凑的表示区域里。最终,作者用 (\hat H=C_k\odot S\odot H)(\hat V=C_k\odot S\odot V),再与(I_{\max})组合成 HVI 表示。也就是说,HVI 最终由三个部分组成:横向色彩分量、纵向色彩分量和强度分量。

如果配合图片来理解,图1(第2页) 是最重要的总览图。图中上排展示了从 sRGB → HSV → HVI 的变化过程,下排则展示了相应增强结果。作者想表达的非常清楚:在 sRGB 中,亮度与颜色耦合太强,所以增强后容易整体偏色;换到 HSV 后,亮度恢复变得更自然,但红色区域会出现明显的不连续噪声,黑色附近也会产生伪影;进一步变成 HVI 后,红色区域的连续性恢复了,黑色平面也被压缩,最终输出图像在颜色自然性和亮度稳定性上都更好。图1其实就是整篇论文的核心思想图:HVI 不是简单换颜色空间,而是在 HSV 的“色相结构”和“暗区几何”上做了针对低照度任务的重构。

图2(第5页) 进一步说明了 HVI 在整个方法中的位置。这里可以看出,HVI 不是独立存在的,它是后续 CIDNet 的前端表示基础。作者先做 HVI Transformation,把输入图像分解成 HV color mapintensity map;然后送入一个双分支增强网络,其中 HV-branch 负责暗区去噪与色彩恢复,I-branch 负责整体照明估计与亮度提升;最后再通过 Perceptual-inverse HVI Transformation (PHVIT) 映射回 sRGB。换句话说,HVI 的价值不只是“表示更好”,而是它把低照度增强自然拆成了两件事:颜色/结构处理和亮度处理。 这也是为什么文章后面专门设计双分支网络去匹配 HVI 的解耦特性。

图5(第8页) 是最能说明 HVI 两个子设计分别在做什么的消融图。作者分别比较了 sRGB、HSV、只加极化、只加(C_k)、完整 HVI 的结果。图中可以看到:只用 HSV 时,红色区域会出现明显黑点和断裂;只加 polarization 时,红色连续性有所改善,但暗区问题仍未彻底解决;只加时,亮度关系更稳,但颜色仍可能混淆,甚至在其他区域引入色偏;而当 polarization 和(C_k)同时使用时,红色斑点和暗区伪影都得到明显抑制。对应的消融结果里,完整 HVI-CIDNet 在 LOLv2-Real 上取得了 24.111 PSNR / 0.871 SSIM / 0.108 LPIPS,优于 sRGB、HSV 以及各单独组件版本。这说明 HVI 的两个设计并不是可替代关系,而是互补关系:一个负责“修色相拓扑”,一个负责“稳暗区几何”。

补充材料里的 图7、图8、图9(第12页) 对参数 (k) 的作用解释得很直观。图7画出了不同 (k) 取值下 (C_k) 随强度变化的曲线,说明 (C_k) 本质上是一个从低亮度到高亮度的重映射函数;图8展示了不同 (k) 下 HVI 空间外形的变化,可以看到随着 (k) 增大,低亮度部分的“底部几何”逐渐变宽;图9则直接给出低照度图像在不同 (k) 下的 HV-map,可见 (k) 越大,暗区噪声越容易被放大,细节与噪声之间的冲突也越明显。作者因此把 (k) 视为一个在不同数据集、不同网络条件下调节暗区信噪比的重要参数。也就是说,HVI 不是固定死的颜色空间,它带有一定的可学习、自适应属性

如果从“模块创新点”角度来总结,HVI 的关键贡献可以概括成三句话。第一,它抓住了低照度增强里一个常被忽略的问题:颜色空间本身也会制造噪声,尤其是 HSV 的红色断裂和黑平面问题。第二,它不是靠更深的网络硬学,而是先通过 polarized HS + learnable intensity collapse 让输入表示更适合增强。第三,它不仅自己有效,而且还能作为 plug-and-play 的颜色空间变换 提升其他 LLIE 方法,论文表3显示,把 HVI 变换接到其他方法前后,多个模型的 PSNR、SSIM、LPIPS 都得到改善,其中 GSAD 的 PSNR 提升达 3.562 dB。这说明 HVI 的价值具有一定通用性,而不只是绑定 CIDNet 才有效。

如果你想写成一段比较适合博客或论文模块介绍的表述,可以直接用下面这段:

HVI 是针对低照度图像增强任务设计的一种新型颜色空间,其核心思想是在 HSV 的基础上进一步重构色彩表示方式,以同时抑制红色不连续噪声和极暗区域黑平面噪声。具体而言,HVI 通过对 Hue 进行极化映射,将原本在 HSV 中首尾断裂的红色区域投影到连续的水平—垂直平面中,从而缩小相似红色之间的欧氏距离;同时,引入由可训练参数 (k) 控制的强度塌缩函数,对低亮度区域的颜色半径进行自适应压缩,使暗区噪声在增强前得到更稳定的几何约束。最终,HVI 以 (\hat H)、(\hat V) 和 (I) 三个分量共同表征图像,不仅保留了亮度与颜色解耦的优点,还显著提升了低照度增强过程中的颜色自然性、暗区稳定性和整体视觉质量。


三、核心代码

核心代码的使用方式看章节四

from einops import rearrange
import torch
import torch.nn as nn
import torch.nn.functional as F
import math
__all__ = ['HVI']

class LayerNorm(nn.Module):
    r""" LayerNorm that supports two data formats: channels_last (default) or channels_first.
    The ordering of the dimensions in the inputs. channels_last corresponds to inputs with
    shape (batch_size, height, width, channels) while channels_first corresponds to inputs
    with shape (batch_size, channels, height, width).
    """

    def __init__(self, normalized_shape, eps=1e-6, data_format="channels_first"):
        super().__init__()
        self.weight = nn.Parameter(torch.ones(normalized_shape))
        self.bias = nn.Parameter(torch.zeros(normalized_shape))
        self.eps = eps
        self.data_format = data_format
        if self.data_format not in ["channels_last", "channels_first"]:
            raise NotImplementedError
        self.normalized_shape = (normalized_shape,)

    def forward(self, x):
        if self.data_format == "channels_last":
            return F.layer_norm(x, self.normalized_shape, self.weight, self.bias, self.eps)
        elif self.data_format == "channels_first":
            u = x.mean(1, keepdim=True)
            s = (x - u).pow(2).mean(1, keepdim=True)
            x = (x - u) / torch.sqrt(s + self.eps)
            x = self.weight[:, None, None] * x + self.bias[:, None, None]
            return x


class NormDownsample(nn.Module):
    def __init__(self, in_ch, out_ch, scale=0.5, use_norm=False):
        super(NormDownsample, self).__init__()
        self.use_norm = use_norm
        if self.use_norm:
            self.norm = LayerNorm(out_ch)
        self.prelu = nn.PReLU()
        self.down = nn.Sequential(
            nn.Conv2d(in_ch, out_ch, kernel_size=3, stride=1, padding=1, bias=False),
            nn.UpsamplingBilinear2d(scale_factor=scale))

    def forward(self, x):
        x = self.down(x)
        x = self.prelu(x)
        if self.use_norm:
            x = self.norm(x)
            return x
        else:
            return x


class NormUpsample(nn.Module):
    def __init__(self, in_ch, out_ch, scale=2, use_norm=False):
        super(NormUpsample, self).__init__()
        self.use_norm = use_norm
        if self.use_norm:
            self.norm = LayerNorm(out_ch)
        self.prelu = nn.PReLU()
        self.up_scale = nn.Sequential(
            nn.Conv2d(in_ch, out_ch, kernel_size=3, stride=1, padding=1, bias=False),
            nn.UpsamplingBilinear2d(scale_factor=scale))
        self.up = nn.Conv2d(out_ch * 2, out_ch, kernel_size=1, stride=1, padding=0, bias=False)

    def forward(self, x, y):
        x = self.up_scale(x)
        x = torch.cat([x, y], dim=1)
        x = self.up(x)
        x = self.prelu(x)
        if self.use_norm:
            return self.norm(x)
        else:
            return x


# Cross Attention Block
class CAB(nn.Module):
    def __init__(self, dim, num_heads, bias):
        super(CAB, self).__init__()
        self.num_heads = num_heads
        self.temperature = nn.Parameter(torch.ones(num_heads, 1, 1))

        self.q = nn.Conv2d(dim, dim, kernel_size=1, bias=bias)
        self.q_dwconv = nn.Conv2d(dim, dim, kernel_size=3, stride=1, padding=1, groups=dim, bias=bias)
        self.kv = nn.Conv2d(dim, dim * 2, kernel_size=1, bias=bias)
        self.kv_dwconv = nn.Conv2d(dim * 2, dim * 2, kernel_size=3, stride=1, padding=1, groups=dim * 2, bias=bias)
        self.project_out = nn.Conv2d(dim, dim, kernel_size=1, bias=bias)

    def forward(self, x, y):
        b, c, h, w = x.shape

        q = self.q_dwconv(self.q(x))
        kv = self.kv_dwconv(self.kv(y))
        k, v = kv.chunk(2, dim=1)

        q = rearrange(q, 'b (head c) h w -> b head c (h w)', head=self.num_heads)
        k = rearrange(k, 'b (head c) h w -> b head c (h w)', head=self.num_heads)
        v = rearrange(v, 'b (head c) h w -> b head c (h w)', head=self.num_heads)

        q = torch.nn.functional.normalize(q, dim=-1)
        k = torch.nn.functional.normalize(k, dim=-1)

        attn = (q @ k.transpose(-2, -1)) * self.temperature
        attn = nn.functional.softmax(attn, dim=-1)

        out = (attn @ v)

        out = rearrange(out, 'b head c (h w) -> b (head c) h w', head=self.num_heads, h=h, w=w)

        out = self.project_out(out)
        return out

def autopad(k, p=None, d=1):  # kernel, padding, dilation
    """Pad to 'same' shape outputs."""
    if d > 1:
        k = d * (k - 1) + 1 if isinstance(k, int) else [d * (x - 1) + 1 for x in k]  # actual kernel-size
    if p is None:
        p = k // 2 if isinstance(k, int) else [x // 2 for x in k]  # auto-pad
    return p


class Conv(nn.Module):
    """Standard convolution with args(ch_in, ch_out, kernel, stride, padding, groups, dilation, activation)."""

    default_act = nn.SiLU()  # default activation

    def __init__(self, c1, c2, k=1, s=1, p=None, g=1, d=1, act=True):
        """Initialize Conv layer with given arguments including activation."""
        super().__init__()
        self.conv = nn.Conv2d(c1, c2, k, s, autopad(k, p, d), groups=g, dilation=d, bias=False)
        self.bn = nn.BatchNorm2d(c2)
        self.act = self.default_act if act is True else act if isinstance(act, nn.Module) else nn.Identity()

    def forward(self, x):
        """Apply convolution, batch normalization and activation to input tensor."""
        return self.act(self.bn(self.conv(x)))

    def forward_fuse(self, x):
        """Perform transposed convolution of 2D data."""
        return self.act(self.conv(x))

# Intensity Enhancement Layer
class IEL(nn.Module):
    def __init__(self, dim, ffn_expansion_factor=2.66, bias=False):
        super(IEL, self).__init__()

        hidden_features = int(dim * ffn_expansion_factor)

        self.project_in = nn.Conv2d(dim, hidden_features * 2, kernel_size=1, bias=bias)

        self.dwconv = nn.Conv2d(hidden_features * 2, hidden_features * 2, kernel_size=3, stride=1, padding=1,
                                groups=hidden_features * 2, bias=bias)
        self.dwconv1 = nn.Conv2d(hidden_features, hidden_features, kernel_size=3, stride=1, padding=1,
                                 groups=hidden_features, bias=bias)
        self.dwconv2 = nn.Conv2d(hidden_features, hidden_features, kernel_size=3, stride=1, padding=1,
                                 groups=hidden_features, bias=bias)

        self.project_out = nn.Conv2d(hidden_features, dim, kernel_size=1, bias=bias)

        self.Tanh = nn.Tanh()

    def forward(self, x):
        x = self.project_in(x)
        x1, x2 = self.dwconv(x).chunk(2, dim=1)
        x1 = self.Tanh(self.dwconv1(x1)) + x1
        x2 = self.Tanh(self.dwconv2(x2)) + x2
        x = x1 * x2
        x = self.project_out(x)
        return x



class HVI(nn.Module):
    def __init__(self, channels_in, channels_mid, heads=8, use_bias=False):
        super().__init__()

        c_a, c_b = channels_in

        self.align_a = self._make_proj(c_a, channels_mid, use_bias)
        self.align_b = self._make_proj(c_b, channels_mid, use_bias)

        self.pre_norm = LayerNorm(channels_mid)
        self.cross_interact = CAB(channels_mid, heads, bias=use_bias)
        self.detail_refine = IEL(channels_mid)

    @staticmethod
    def _make_proj(cin, cout, use_bias):
        if cin == cout:
            return nn.Identity()
        return Conv(cin, cout, 1)

    def forward(self, feats):
        feat_a, feat_b = feats

        feat_a = self.align_a(feat_a)
        feat_b = self.align_b(feat_b)

        norm_a = self.pre_norm(feat_a)
        norm_b = self.pre_norm(feat_b)

        feat_a = feat_a + self.cross_interact(norm_a, norm_b)
        feat_a = feat_a + self.detail_refine(self.pre_norm(feat_a))

        return feat_a


四、添加方法

4.1 修改一

第一还是建立文件,我们找到如下ultralytics/nn文件夹下建立一个目录名字呢就是'Addmodules'文件夹(用群内的文件的话已经有了无需新建)!然后在其内部建立一个新的py文件将核心代码复制粘贴进去即可。


4.2 修改二 

第二步我们在该目录下创建一个新的py文件名字为'__init__.py'(用群内的文件的话已经有了无需新建),然后在其内部导入我们的检测头如下图所示。

  


4.3 修改三 

第三步找到如下文件'ultralytics/nn/tasks.py'进行导入和注册我们的模块(用群内的文件的话已经有了无需重新导入直接开始第四步即可)


4.4 修改四 

找到文件到如下文件'ultralytics/nn/tasks.py',在其中的parse_model方法中添加即可(根据周围代码进行定位即可,如果不会入群内有视频讲解)。

        # ------------------------------HVI--------------------------------
        elif m is HVI:
            c1 = [ch[x] for x in f]
            if c2 != nc:
                c2 = make_divisible(min(args[0],max_channels)*width, 8)
            args = [c1, c2]
        # ------------------------------HVI--------------------------------

  ​​​


到此就修改完成了,大家可以复制下面的yaml文件运行,

如果不会添加可联系作者入群观看视频教程。


五、正式训练


5.1 yaml文件

训练信息:YOLO26-Neck-HVI summary: 318 layers, 3,808,932 parameters, 3,808,932 gradients, 8.6 GFLOPs

使用方式:在代码中进行了注释大家需要仔细观看.

# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license

# Ultralytics YOLO26 object detection model with P3/8 - P5/32 outputs
# Model docs: https://docs.ultralytics.com/models/yolo26
# Task docs: https://docs.ultralytics.com/tasks/detect

# Parameters
nc: 80 # number of classes
end2end: True # whether to use end-to-end mode
reg_max: 1 # DFL bins
scales: # model compound scaling constants, i.e. 'model=yolo26n.yaml' will call yolo26.yaml with scale 'n'
  # [depth, width, max_channels]
  n: [0.50, 0.25, 1024] # summary: 260 layers, 2,572,280 parameters, 2,572,280 gradients, 6.1 GFLOPs
  s: [0.50, 0.50, 1024] # summary: 260 layers, 10,009,784 parameters, 10,009,784 gradients, 22.8 GFLOPs
  m: [0.50, 1.00, 512] # summary: 280 layers, 21,896,248 parameters, 21,896,248 gradients, 75.4 GFLOPs
  l: [1.00, 1.00, 512] # summary: 392 layers, 26,299,704 parameters, 26,299,704 gradients, 93.8 GFLOPs
  x: [1.00, 1.50, 512] # summary: 392 layers, 58,993,368 parameters, 58,993,368 gradients, 209.5 GFLOPs

# YOLO26n backbone
backbone:
  # [from, repeats, module, args]
  - [-1, 1, Conv, [64, 3, 2]] # 0-P1/2
  - [-1, 1, Conv, [128, 3, 2]] # 1-P2/4
  - [-1, 2, C3k2, [256, False, 0.25]]
  - [-1, 1, Conv, [256, 3, 2]] # 3-P3/8
  - [-1, 2, C3k2, [512, False, 0.25]]
  - [-1, 1, Conv, [512, 3, 2]] # 5-P4/16
  - [-1, 2, C3k2, [512, True]]
  - [-1, 1, Conv, [1024, 3, 2]] # 7-P5/32
  - [-1, 2, C3k2, [1024, True]]
  - [-1, 1, SPPF, [1024, 5, 3, True]] # 9
  - [-1, 2, C2PSA, [1024]] # 10

# YOLO26n head
head:
  - [-1, 1, nn.Upsample, [None, 2, "nearest"]]
  - [[-1, 6], 1, HVI, [512]] # cat backbone P4
  - [-1, 2, C3k2, [512, True]] # 13

  - [-1, 1, nn.Upsample, [None, 2, "nearest"]]
  - [[-1, 4], 1, HVI, [256]] # cat backbone P3
  - [-1, 2, C3k2, [256, True]] # 16 (P3/8-small)

  - [-1, 1, Conv, [256, 3, 2]]
  - [[-1, 13], 1, HVI, [512]] # cat head P4
  - [-1, 2, C3k2, [512, True]] # 19 (P4/16-medium)

  - [-1, 1, Conv, [512, 3, 2]]
  - [[-1, 10], 1, HVI, [1024]] # cat head P5
  - [-1, 2, C3k2, [1024, True, 0.5, True]] # 22 (P5/32-large)

  - [[16, 19, 22], 1, Detect, [nc]] # Detect(P3, P4, P5)

5.2 训练代码 

大家可以创建一个py文件将我给的代码复制粘贴进去,配置好自己的文件路径即可运行。

import warnings
warnings.filterwarnings('ignore')
from ultralytics import YOLO

if __name__ == '__main__':
    model = YOLO('替换你的模型配置文件yaml文件地址')
    # 如何切换模型版本, 上面的ymal文件可以改为 yolov11s.yaml就是使用的v11s,
    # 类似某个改进的yaml文件名称为yolov11-XXX.yaml那么如果想使用其它版本就把上面的名称改为yolov11l-XXX.yaml即可(改的是上面YOLO中间的名字不是配置文件的)!
    # model.load('yolo11n.pt') # 是否加载预训练权重,科研不建议大家加载否则很难提升精度
    model.train(data=r"替换你的数据集配置文件地址",
                # 如果大家任务是其它的'ultralytics/cfg/default.yaml'找到这里修改task可以改成detect, segment, classify, pose
                cache=False,
                imgsz=640,
                epochs=150,
                single_cls=False,  # 是否是单类别检测
                batch=16,
                close_mosaic=0,
                workers=0,
                device='0',
                optimizer='SGD', # using SGD
                # resume='runs/train/exp21/weights/last.pt', # 如过想续训就设置last.pt的地址
                amp=False,  # 如果出现训练损失为Nan可以关闭amp
                project='runs/train',
                name='exp',
                )


5.3 训练过程截图 

  ​​


五、本文总结

到此本文的正式分享内容就结束了,在这里给大家推荐我的YOLOv26改进有效涨点专栏,本专栏目前为新开的平均质量分98分,后期我会根据各种最新的前沿顶会进行论文复现,也会对一些老的改进机制进行补充,如果大家觉得本文帮助到你了,订阅本专栏,关注后续更多的更新~

专栏链接:

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐