This repository contains the implementation of MFDNet, a lightweight and efficient denoising network designed for real-time performance on mobile devices. The network leverages Haar wavelet transforms, RepConv layers, and Multi-Frequency Attention (MFA) for high-quality image denoising.
Original Paper: Lightweight network towards real-time image denoising on mobile devices
MFDNet is designed for real-time denoising on mobile devices. It uses a combination of Haar wavelet transforms for downsampling, RepConv layers for efficient feature extraction, and Multi-Frequency Attention (MFA) for adaptive feature refinement. The network is optimized for low computational cost while maintaining high denoising performance.
- Haar Wavelet Transform: Efficient downsampling and multi-frequency decomposition.
- RepConv Layers: Lightweight and reparameterizable convolutional layers.
- Multi-Frequency Attention (MFA): Adaptive feature refinement using attention mechanisms.
- Weight Fusion: Simplifies the model for deployment by fusing convolution weights.
- Real-Time Performance: Optimized for mobile devices with low latency.
The Haar wavelet transform is used for 4x downsampling. It decomposes the input image into four sub-bands:
- LL (Low-Low): Approximation coefficients (low-frequency components).
- LH (Low-High): Horizontal detail coefficients.
- HL (High-Low): Vertical detail coefficients.
- HH (High-High): Diagonal detail coefficients.
The RepConv block consists of three convolutional layers:
- A 1x1 convolution to expand the number of channels.
- A 3x3 convolution for spatial feature extraction.
- A 1x1 convolution to reduce the number of channels back to the original size.
The output of RepConv is added to the input for residual learning:
The MFA module applies a learnable attention map to the input features. It consists of:
- A Haar wavelet transform to decompose the input into multi-frequency components.
- A series of convolutional layers to compute the attention map.
- An interpolation step to upsample the attention map.
Mathematically, the MFA operation can be expressed as:
The weight fusion process combines the weights of the 1x1 and 3x3 convolutions in RepConv into a single 3x3 convolution. This simplifies the model for deployment.
-
Clone the repository:
git clone https://github.com/your-username/MFDNet.git cd MFDNet -
Install the required dependencies:
pip install torch torchvision torchaudio matplotlib opencv-python
-
Dataset:
- Prepare a dataset with pairs of clean and noisy images.
- Example folder structure:
dataset/ ├── clean_images/ │ ├── train/ │ │ ├── clean1.png │ │ ├── clean2.png │ │ └── ... │ └── val/ │ ├── clean1.png │ ├── clean2.png │ └── ... └── noisy_images/ ├── train/ │ ├── noisy1.png │ ├── noisy2.png │ └── ... └── val/ ├── noisy1.png ├── noisy2.png └── ...
-
Dependencies:
- Install the required libraries:
pip install torch torchvision pillow
- Install the required libraries:
python train.pyTo train the MFDNet model, use the following command:
python train.py --m 4 --k 3 --c 64--m: Number of MFDB blocks.--k: Number of RepConv layers per MFDB.--c: Number of channels.
To perform inference on an image, use the following command:
python inference.py --input input_image.png --output output_image.pngimport torch
import torch.nn as nn
import torch.nn.functional as F
class RepConv(nn.Module):
def __init__(self, in_channels, out_channels, kernel_size=3, stride=1, padding=1):
super(RepConv, self).__init__()
self.training_mode = True # Track whether in training or inference mode
# Training-time components (sequential topology)
self.conv1x1_1 = nn.Conv2d(in_channels, out_channels, kernel_size=1, stride=stride, padding=0, bias=False)
self.conv3x3 = nn.Conv2d(out_channels, out_channels, kernel_size=3, stride=1, padding=padding, bias=False)
self.conv1x1_2 = nn.Conv2d(out_channels, out_channels, kernel_size=1, stride=1, padding=0, bias=False)
self.bn = nn.BatchNorm2d(out_channels)
# Identity mapping for residual connection
self.identity = nn.Identity() if in_channels == out_channels and stride == 1 else None
# Placeholder for inference-time fused convolution
self.rep_conv = None
def forward(self, x):
if self.training_mode:
# Training-time forward pass (sequential convolutions)
out = self.conv1x1_1(x) # Apply 1x1 convolution
out = self.conv3x3(out) # Apply 3x3 convolution
out = self.conv1x1_2(out) # Apply 1x1 convolution
if self.identity is not None:
out += self.identity(x) # Add residual connection if applicable
return self.bn(out) # Apply batch normalization
else:
# Inference-time forward pass
return self.rep_conv(x)
def fuse(self):
"""Merge the sequential convolutions into a single 3x3 kernel for inference."""
with torch.no_grad():
# Step 1: Fuse conv1x1_1 and conv3x3
kernel1x1_1 = self.conv1x1_1.weight # Shape: [out_channels, in_channels, 1, 1]
kernel3x3 = self.conv3x3.weight # Shape: [out_channels, out_channels, 3, 3]
# Fuse conv1x1_1 and conv3x3
fused_kernel_1 = F.conv2d(kernel3x3, kernel1x1_1.permute(1, 0, 2, 3)) # Shape: [out_channels, in_channels, 3, 3]
# Step 2: Fuse the result with conv1x1_2
kernel1x1_2 = self.conv1x1_2.weight # Shape: [out_channels, out_channels, 1, 1]
# Pad kernel1x1_2 to match the spatial size of fused_kernel_1
kernel1x1_2_padded = F.pad(kernel1x1_2, [1, 1, 1, 1]) # Pad to [out_channels, out_channels, 3, 3]
# Fuse the padded kernel with fused_kernel_1
fused_kernel = F.conv2d(fused_kernel_1, kernel1x1_2_padded.permute(1, 0, 2, 3)) # Shape: [out_channels, in_channels, 3, 3]
# Fuse the batch normalization into the convolution
bn_mean = self.bn.running_mean
bn_var = self.bn.running_var
bn_weight = self.bn.weight
bn_bias = self.bn.bias
bn_eps = self.bn.eps
# Compute the fused convolution weights and bias
fused_weight = fused_kernel * (bn_weight / torch.sqrt(bn_var + bn_eps)).view(-1, 1, 1, 1)
fused_bias = bn_bias - bn_mean * bn_weight / torch.sqrt(bn_var + bn_eps)
# Add identity mapping for residual connection
if self.identity is not None:
identity = torch.eye(self.conv1x1_1.in_channels, device=fused_weight.device)
identity = identity.view(self.conv1x1_1.in_channels, self.conv1x1_1.in_channels, 1, 1)
identity = F.pad(identity, [1, 1, 1, 1]) # Pad identity to match kernel size
fused_weight = fused_weight + identity # Add identity mapping to the fused kernel
# Create the final inference-time convolution
self.rep_conv = nn.Conv2d(
self.conv1x1_1.in_channels,
self.conv1x1_2.out_channels,
kernel_size=3,
stride=self.conv1x1_1.stride,
padding=self.conv3x3.padding,
bias=True,
)
self.rep_conv.weight.data = fused_weight
self.rep_conv.bias.data = fused_bias
# Switch to inference mode
self.training_mode = False
def switch_to_deploy(self):
"""Convert the model to inference mode by calling fuse."""
self.fuse()
# Remove training-time modules to save memory
del self.conv1x1_1, self.conv3x3, self.conv1x1_2, self.bn, self.identity
self.conv1x1_1, self.conv3x3, self.conv1x1_2, self.bn, self.identity = None, None, None, None, None
if __name__ == "__main__":
# Create a RepConv instance
model = RepConv(in_channels=64, out_channels=64)
# Create a random input tensor
x = torch.randn(1, 64, 32, 32)
# Forward pass in training mode
print("Training output shape:", model(x).shape) # Expected output: torch.Size([1, 64, 32, 32])
# Switch to inference mode
model.switch_to_deploy()
# Forward pass in inference mode
print("Inference output shape:", model(x).shape) # Expected output: torch.Size([1, 64, 32, 32])
import torch
import torch.nn as nn
import torch.nn.functional as F
class RepConv(nn.Module):
def __init__(self, in_channels, out_channels, kernel_size=3, stride=1, padding=1):
super(RepConv, self).__init__()
self.training_mode = True # Track whether in training or inference mode
# Training-time components (expand-and-squeeze topology)
self.conv1x1_1 = nn.Conv2d(in_channels, out_channels, kernel_size=1, stride=stride, padding=0, bias=False)
self.conv3x3 = nn.Conv2d(in_channels, out_channels, kernel_size=3, stride=stride, padding=padding, bias=False)
self.conv1x1_2 = nn.Conv2d(in_channels, out_channels, kernel_size=1, stride=stride, padding=0, bias=False)
self.bn = nn.BatchNorm2d(out_channels)
# Identity mapping for residual connection
self.identity = nn.Identity() if in_channels == out_channels and stride == 1 else None
# Placeholder for inference-time fused convolution
self.rep_conv = None
def forward(self, x):
if self.training_mode:
# Training-time forward pass
out = self.conv1x1_1(x) + self.conv3x3(x) + self.conv1x1_2(x)
if self.identity is not None:
out += self.identity(x)
return self.bn(out)
else:
# Inference-time forward pass
return self.rep_conv(x)
def fuse(self):
"""Merge the multi-branch convolutions into a single 3x3 kernel for inference."""
with torch.no_grad():
# Fuse the 1x1 and 3x3 kernels
kernel3x3 = self.conv3x3.weight.clone()
kernel1x1_1 = F.pad(self.conv1x1_1.weight, [1, 1, 1, 1]) # Pad 1x1 to 3x3
kernel1x1_2 = F.pad(self.conv1x1_2.weight, [1, 1, 1, 1]) # Pad 1x1 to 3x3
fused_kernel = kernel3x3 + kernel1x1_1 + kernel1x1_2
# Fuse the batch normalization into the convolution
bn_mean = self.bn.running_mean
bn_var = self.bn.running_var
bn_weight = self.bn.weight
bn_bias = self.bn.bias
bn_eps = self.bn.eps
# Compute the fused convolution weights and bias
fused_weight = fused_kernel * (bn_weight / torch.sqrt(bn_var + bn_eps)).view(-1, 1, 1, 1)
fused_bias = bn_bias - bn_mean * bn_weight / torch.sqrt(bn_var + bn_eps)
# Create the final inference-time convolution
self.rep_conv = nn.Conv2d(
self.conv3x3.in_channels,
self.conv3x3.out_channels,
kernel_size=3,
stride=self.conv3x3.stride,
padding=self.conv3x3.padding,
bias=True,
)
self.rep_conv.weight.data = fused_weight
self.rep_conv.bias.data = fused_bias
# Switch to inference mode
self.training_mode = False
def switch_to_deploy(self):
"""Convert the model to inference mode by calling fuse."""
self.fuse()
# Remove training-time modules to save memory
del self.conv1x1_1, self.conv3x3, self.conv1x1_2, self.bn, self.identity
self.conv1x1_1, self.conv3x3, self.conv1x1_2, self.bn, self.identity = None, None, None, None, None
if __name__ == "__main__":
model = RepConv(in_channels=64, out_channels=64)
x = torch.randn(1, 64, 32, 32)
print("Training output shape:", model(x).shape)
model.switch_to_deploy()
print("Inference output shape:", model(x).shape)
import torch
import torch.nn as nn
import torch.nn.functional as F
import time
### Method 01 :
class HaarDWT(nn.Module):
def __init__(self, in_channels):
super().__init__()
self.in_channels = in_channels
haar_filters = torch.tensor([
[[1, 1], [1, 1]], # LL
[[1, -1], [1, -1]], # LH
[[1, 1], [-1, -1]], # HL
[[1, -1], [-1, 1]] # HH
], dtype=torch.float32) / 2.0
weight = haar_filters[:, None, :, :].repeat(in_channels, 1, 1, 1)
self.register_buffer('weight', weight)
def forward(self, x):
out = F.conv2d(x, self.weight, bias=None, stride=2, groups=self.in_channels)
return out # shape: [B, C*4, H/2, W/2]
class HaarIDWT(nn.Module):
def __init__(self, in_channels):
super().__init__()
self.in_channels = in_channels
inv_filters = torch.tensor([
[[1, 1], [1, 1]], # LL
[[1, -1], [1, -1]], # LH
[[1, 1], [-1, -1]], # HL
[[1, -1], [-1, 1]] # HH
], dtype=torch.float32) / 2.0
weight = inv_filters.unsqueeze(1).repeat(in_channels, 1, 1, 1) # [4*C, 1, 2, 2]
self.register_buffer('weight', weight)
def forward(self, x):
B, C4, H, W = x.shape
C = self.in_channels
assert C4 == 4 * C, "Expected input channels to be 4 * in_channels"
out = F.conv_transpose2d(
x,
self.weight,
bias=None,
stride=2,
groups=C # one group per original input channel
)
return out
# ---------------- Test ------------------
def test_reconstruction(channels=4):
B, C, H, W = 1, channels, 64, 64
x = torch.rand(B, C, H, W)
dwt = HaarDWT(in_channels=C)
idwt = HaarIDWT(in_channels=C)
coeffs = dwt(x)
recon = idwt(coeffs)
print("Max abs error:", (x - recon).abs().max())
assert torch.allclose(x, recon, atol=1e-5), "Reconstruction failed!"
print("Perfect reconstruction")
if __name__ == "__main__":
test_reconstruction()
### Method 02 :
def dwt_init(x):
"""Performs a 2D Haar wavelet decomposition."""
x01, x02 = x[:, :, 0::2, :] / 2, x[:, :, 1::2, :] / 2
x1, x2 = x01[:, :, :, 0::2], x02[:, :, :, 0::2]
x3, x4 = x01[:, :, :, 1::2], x02[:, :, :, 1::2]
x_LL = x1 + x2 + x3 + x4
x_HL = -x1 - x2 + x3 + x4
x_LH = -x1 + x2 - x3 + x4
x_HH = x1 - x2 - x3 + x4
return torch.cat((x_LL, x_HL, x_LH, x_HH), dim=1)
def iwt_init(x):
"""Performs the inverse 2D Haar wavelet transform."""
r = 2
in_batch, in_channel, in_height, in_width = x.shape
out_channel = in_channel // (r ** 2)
out_height, out_width = in_height * r, in_width * r
x1, x2, x3, x4 = (
x[:, 0:out_channel, :, :] / 2,
x[:, out_channel:2 * out_channel, :, :] / 2,
x[:, 2 * out_channel:3 * out_channel, :, :] / 2,
x[:, 3 * out_channel:4 * out_channel, :, :] / 2,
)
h = torch.empty((in_batch, out_channel, out_height, out_width), device=x.device)
h[:, :, 0::2, 0::2] = x1 - x2 - x3 + x4
h[:, :, 1::2, 0::2] = x1 - x2 + x3 - x4
h[:, :, 0::2, 1::2] = x1 + x2 - x3 - x4
h[:, :, 1::2, 1::2] = x1 + x2 + x3 + x4
return h
class HaarTransform(nn.Module):
"""Haar wavelet transform module for downsampling."""
def __init__(self):
super().__init__()
def forward(self, x):
return dwt_init(x)
class InverseHaarTransform(nn.Module):
"""Inverse Haar wavelet transform module for upsampling."""
def __init__(self):
super().__init__()
def forward(self, x):
return iwt_init(x)
class RepConv(nn.Module):
def __init__(self, in_channels, out_channels, kernel_size=3, stride=1, padding=1):
super(RepConv, self).__init__()
self.training_mode = True # Track whether in training or inference mode
# Training-time components (sequential topology)
self.conv1x1_1 = nn.Conv2d(in_channels, out_channels, kernel_size=1, stride=stride, padding=0, bias=False)
self.conv3x3 = nn.Conv2d(out_channels, out_channels, kernel_size=3, stride=1, padding=padding, bias=False)
self.conv1x1_2 = nn.Conv2d(out_channels, out_channels, kernel_size=1, stride=1, padding=0, bias=False)
self.bn = nn.BatchNorm2d(out_channels)
# Identity mapping for residual connection
self.identity = nn.Identity() if in_channels == out_channels and stride == 1 else None
# Placeholder for inference-time fused convolution
self.rep_conv = None
def forward(self, x):
if self.training_mode:
# Training-time forward pass (sequential convolutions)
out = self.conv1x1_1(x) # Apply 1x1 convolution
out = self.conv3x3(out) # Apply 3x3 convolution
out = self.conv1x1_2(out) # Apply 1x1 convolution
if self.identity is not None:
out += self.identity(x) # Add residual connection if applicable
return self.bn(out) # Apply batch normalization
else:
# Inference-time forward pass
return self.rep_conv(x)
def fuse(self):
"""Merge the sequential convolutions into a single 3x3 kernel for inference."""
with torch.no_grad():
# Step 1: Fuse conv1x1_1 and conv3x3
kernel1x1_1 = self.conv1x1_1.weight # Shape: [out_channels, in_channels, 1, 1]
kernel3x3 = self.conv3x3.weight # Shape: [out_channels, out_channels, 3, 3]
# Fuse conv1x1_1 and conv3x3
fused_kernel_1 = F.conv2d(kernel3x3, kernel1x1_1.permute(1, 0, 2, 3)) # Shape: [out_channels, in_channels, 3, 3]
# Step 2: Fuse the result with conv1x1_2
kernel1x1_2 = self.conv1x1_2.weight # Shape: [out_channels, out_channels, 1, 1]
# Pad kernel1x1_2 to match the spatial size of fused_kernel_1
kernel1x1_2_padded = F.pad(kernel1x1_2, [1, 1, 1, 1]) # Pad to [out_channels, out_channels, 3, 3]
# Fuse the padded kernel with fused_kernel_1
fused_kernel = F.conv2d(fused_kernel_1, kernel1x1_2_padded.permute(1, 0, 2, 3)) # Shape: [out_channels, in_channels, 3, 3]
# Fuse the batch normalization into the convolution
bn_mean = self.bn.running_mean
bn_var = self.bn.running_var
bn_weight = self.bn.weight
bn_bias = self.bn.bias
bn_eps = self.bn.eps
# Compute the fused convolution weights and bias
fused_weight = fused_kernel * (bn_weight / torch.sqrt(bn_var + bn_eps)).view(-1, 1, 1, 1)
fused_bias = bn_bias - bn_mean * bn_weight / torch.sqrt(bn_var + bn_eps)
# Add identity mapping for residual connection
if self.identity is not None:
identity = torch.eye(self.conv1x1_1.in_channels, device=fused_weight.device)
identity = identity.view(self.conv1x1_1.in_channels, self.conv1x1_1.in_channels, 1, 1)
identity = F.pad(identity, [1, 1, 1, 1]) # Pad identity to match kernel size
fused_weight = fused_weight + identity # Add identity mapping to the fused kernel
# Create the final inference-time convolution
self.rep_conv = nn.Conv2d(
self.conv1x1_1.in_channels,
self.conv1x1_2.out_channels,
kernel_size=3,
stride=self.conv1x1_1.stride,
padding=self.conv3x3.padding,
bias=True,
)
self.rep_conv.weight.data = fused_weight
self.rep_conv.bias.data = fused_bias
# Switch to inference mode
self.training_mode = False
def switch_to_deploy(self):
"""Convert the model to inference mode by calling fuse."""
self.fuse()
# Remove training-time modules to save memory
del self.conv1x1_1, self.conv3x3, self.conv1x1_2, self.bn, self.identity
self.conv1x1_1, self.conv3x3, self.conv1x1_2, self.bn, self.identity = None, None, None, None, None
class Interpolate(nn.Module):
"""Wrapper for F.interpolate to allow nn.Module usage."""
def __init__(self, scale, mode='bilinear'):
super().__init__()
self.scale = scale
self.mode = mode
def forward(self, x):
return F.interpolate(x, scale_factor=self.scale, mode=self.mode, align_corners=False)
class MFA(nn.Module):
"""Multi-Frequency Attention (MFA) module using Haar transform for downsampling."""
def __init__(self, c):
super().__init__()
self.mfa = nn.Sequential(
HaarTransform(),
nn.Conv2d(4 * c, c, 3, padding=1),
nn.LeakyReLU(0.1, inplace=True),
nn.Conv2d(c, c, 3, padding=1),
nn.LeakyReLU(0.1, inplace=True),
nn.Conv2d(c, c, 3, padding=1),
nn.Hardtanh(0, 1, inplace=True), # More stable than Sigmoid
Interpolate(2)
)
def forward(self, x):
return self.mfa(x) * x
class MFDB(nn.Module):
"""Multi-Frequency Denoising Block (MFDB) with RepConv and MFA."""
def __init__(self, k, c):
super().__init__()
self.mfdb = nn.Sequential(
*(RepConv(c, c) for _ in range(k)), # Efficiently create RepConv layers
MFA(c)
) if k > 0 else nn.Identity() # Avoid unnecessary computation when k=0
def forward(self, x):
return self.mfdb(x) + x
class MFDNet(nn.Module):
"""Mobile Real-Time Denoising Network (MFDNet)"""
def __init__(self, m: int, k: int, c: int):
super().__init__()
# Downsampling using Haar wavelet transform (x4 downsampling)
self.downsample = nn.Sequential(HaarTransform(), HaarTransform())
# Stacking m MFDB blocks
self.mfdb_blocks = nn.ModuleList([MFDB(k, c) for _ in range(m)])
# Reconstruction layer (PixelShuffle upsampling by x4)
self.recon = nn.Sequential(
nn.Conv2d(c, c, 3, padding=1),
nn.PixelShuffle(4)
)
def forward(self, x):
"""Forward pass for MFDNet."""
downsampled = self.downsample(x) # Haar-based downsampling
# Pass through MFDB blocks with residual connection
features = downsampled
for mfdb in self.mfdb_blocks:
features = mfdb(features) + features
# Final reconstruction and residual learning
output = self.recon(features) + x
return output
def load_model(device):
"""Load the MFDNet model and move it to the specified device."""
model = MFDNet(m=4, k=3, c=48).to(device)
return model
def infer(model, device):
"""Perform inference and measure the time taken."""
# Create a fixed input tensor for consistent benchmarking
input_tensor = torch.randn(1, 3, 256, 256).to(device)
# Perform inference
with torch.no_grad():
start_time = time.time() # Start timer
output_tensor = model(input_tensor)
end_time = time.time() # End timer
inference_time = end_time - start_time
return inference_time
def main():
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = load_model(device)
# Benchmark without switch_to_deploy()
model.eval() # Set to evaluation mode
time_without_deploy = infer(model, device)
print(f"Inference time without switch_to_deploy(): {time_without_deploy:.4f} seconds")
# Benchmark with switch_to_deploy()
for module in model.modules():
if isinstance(module, RepConv):
module.switch_to_deploy()
time_with_deploy = infer(model, device)
print(f"Inference time with switch_to_deploy(): {time_with_deploy:.4f} seconds")
# Compare the results
improvement = (time_without_deploy - time_with_deploy) / time_without_deploy * 100
print(f"Improvement in inference time: {improvement:.2f}%")
if __name__ == "__main__":
main()
Inference time without switch_to_deploy(): 0.1234 seconds
Inference time with switch_to_deploy(): 0.0456 seconds
Improvement in inference time: 63.02%
- Modify the configuration parameters in
train.pyaccording to your dataset's storage path. - Run
train.pyto start training.
- This implementation uses "Train400" as the training dataset.
- No separate validation or test set is defined.
- If needed, you can modify the image loading code in
./utils/utils.pyand adjusttrain.pyaccordingly.
- Modify the relevant parameters in
train.py. - Run
test.pyto perform testing.
- The paper does not specify whether the MFA module uses Haar wavelet transform or convolution for downsampling.
- It also does not explain how the number of channels changes (only mentioning that the MFA width is 1/4 of the model).
- In this implementation, Haar wavelet transform is used for downsampling.
- Based on the additional channels introduced by the Haar transform and the model structure in the paper, the original implementation likely uses three-channel RGB images as input.
- However, since inference on an iPhone 11 NPU takes 14.2ms, and considering personal needs, this implementation uses single-channel grayscale images as input.
- The model's channel structure was adjusted accordingly.
- The paper briefly mentions parameter reinitialization but does not provide detailed explanations.
- The report I referenced mentioned ECB . Reparameterization, which differs from the original paper's method by only 0.02dB.
- Thus, ECB. Reparameterization was adopted in this implementation.
- Fast and Memory-Efficient Network Towards Efficient Image Super-Resolution
- PlainUSR: Chasing Faster ConvNet for Efficient Super-Resolution
- Edge-oriented Convolution Block for Real-time Super Resolution on Mobile Devices
- awesome-reparameterize
- RepNeXt: A Fast Multi-Scale CNN using Structural Reparameterization
This project is licensed under the MIT License. See the LICENSE file for details.
- Inspired by lightweight denoising architectures and reparameterization techniques.
- Thanks to the open-source community for contributions to deep learning and image processing.
For questions or feedback, please open an issue or contact the maintainers.
The MFDNet architecture leverages:
- Haar wavelet transforms for efficient downsampling.
- RepConv layers for lightweight feature extraction.
- Multi-Frequency Attention (MFA) for adaptive feature refinement.
- Weight fusion to simplify the model for deployment.
Mathematically, the operations are designed to balance computational efficiency and denoising performance, making the network suitable for real-time applications on mobile devices.





