Initial commit

This commit is contained in:
Bouletto
2025-08-27 10:45:51 +02:00
commit 0e359b8070
10 changed files with 560 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto
+46
View File
@@ -0,0 +1,46 @@
# SharpnessPro/Clarity.py
import torch
from .utils import ensure_tensor_bchw, to_bhwc, clamp01, gaussian_blur, rgb_to_luma, luma_to_rgb, midtone_mask
class ClarityNode:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"image": ("IMAGE",),
"radius": ("FLOAT", {"default": 24.0, "min": 1.0, "max": 200.0, "step": 1.0}),
"strength": ("FLOAT", {"default": 0.35, "min": -1.0, "max": 1.0, "step": 0.01}),
"midtone_softness": ("FLOAT", {"default": 0.35, "min": 0.05, "max": 0.5, "step": 0.01}),
"luma_only": (["Yes","No"],),
}
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "apply"
CATEGORY = "SharpnessPro/Tone"
def apply(self, image, radius, strength, midtone_softness, luma_only):
x = ensure_tensor_bchw(image).to(torch.float32)
# Local contrast via unsharp on a big radius, constrained to midtones
low = gaussian_blur(x, radius)
detail = x - low
mask = midtone_mask(x, softness=midtone_softness) # B,1,H,W
if luma_only == "Yes":
L = rgb_to_luma(x)
L_low = gaussian_blur(x, radius=radius)
L_low = rgb_to_luma(L_low)
dL = (L - L_low) * mask
L_out = (L + strength * dL).clamp(0,1)
# Recombine
eps = 1e-6
baseL = L + eps
scale = (L_out + eps) / baseL
out = clamp01(x * scale)
else:
out = clamp01(x + strength * (detail * mask))
return (to_bhwc(out),)
NODE_CLASS_MAPPINGS = {"Clarity": ClarityNode}
NODE_DISPLAY_NAME_MAPPINGS = {"Clarity": "SharpnessPro · Clarté"}
+57
View File
@@ -0,0 +1,57 @@
# SharpnessPro/HighPassSharpen.py
import torch
from .utils import ensure_tensor_bchw, to_bhwc, gaussian_blur, clamp01, rgb_to_luma, luma_to_rgb, blend_overlay, blend_soft_light, apply_opacity
class HighPassSharpenNode:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"image": ("IMAGE",),
"radius": ("FLOAT", {"default": 3.0, "min": 0.0, "max": 100.0, "step": 0.1}),
"amount": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 3.0, "step": 0.05}),
"blend_mode": (["Overlay","SoftLight"],),
"opacity": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}),
"work_on_luma": (["Yes","No"],),
}
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "apply"
CATEGORY = "SharpnessPro/Sharpen"
def apply(self, image, radius, amount, blend_mode, opacity, work_on_luma):
x = ensure_tensor_bchw(image).to(torch.float32)
base = x
if work_on_luma == "Yes":
# high-pass on luminance (more PS-like)
L = rgb_to_luma(base)
low = gaussian_blur(base, radius=radius)
L_low = rgb_to_luma(low)
detail = (L - L_low) # [-?, ?]
hp_layer = (0.5 + amount * detail).clamp(0.0, 1.0).repeat(1,3,1,1)
# Blend on RGB but create from luma details
if blend_mode == "Overlay":
blended = blend_overlay(base, hp_layer)
else:
blended = blend_soft_light(base, hp_layer)
out = apply_opacity(base, blended, opacity)
else:
low = gaussian_blur(base, radius=radius)
detail = (base - low)
hp_layer = (0.5 + amount * detail).clamp(0.0, 1.0)
if blend_mode == "Overlay":
blended = blend_overlay(base, hp_layer)
else:
blended = blend_soft_light(base, hp_layer)
out = apply_opacity(base, blended, opacity)
return (to_bhwc(clamp01(out)),)
NODE_CLASS_MAPPINGS = {
"HighPassSharpen": HighPassSharpenNode
}
NODE_DISPLAY_NAME_MAPPINGS = {
"HighPassSharpen": "SharpnessPro · Passe-haut"
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Philippe Joye (Orion4D)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+111
View File
@@ -0,0 +1,111 @@
# SharpnessPro pour ComfyUI
Une collection de nœuds de haute qualité pour [ComfyUI](https://github.com/comfyanonymous/ComfyUI), dédiée à l'amélioration de la netteté, de la clarté et de la texture de vos images. Inspirés par les outils professionnels de logiciels comme Photoshop ou Lightroom, ces nœuds offrent un contrôle précis pour des résultats nets et sans artefacts.
Le pack est divisé en deux catégories :
* **SharpnessPro/Sharpen (Netteté)** : Pour l'accentuation des détails fins.
* **SharpnessPro/Tone (Tonalité)** : Pour l'ajustement du contraste local (Clarté, Texture).
*(Remplacez cette image par une capture d'écran de votre workflow)*
## ✨ Installation
### Méthode 1 : Via le ComfyUI-Manager (Recommandé)
1. Installez le [ComfyUI-Manager](https://github.com/ltdrdata/ComfyUI-Manager) si ce n'est pas déjà fait.
2. Dans ComfyUI, cliquez sur le bouton "Manager".
3. Cliquez sur "Install Custom Nodes".
4. Recherchez "SharpnessPro" et cliquez sur "Install".
5. Redémarrez ComfyUI.
### Méthode 2 : Manuellement (Git Clone)
1. Ouvrez un terminal ou une invite de commande.
2. Naviguez jusqu'au répertoire d'installation de ComfyUI.
3. Clonez le dépôt dans le dossier `custom_nodes` :
```bash
cd ComfyUI/custom_nodes/
git clone https://github.com/VOTRE_NOM_UTILISATEUR/SharpnessPro.git
```
*(N'oubliez pas de remplacer `VOTRE_NOM_UTILISATEUR/SharpnessPro.git` par l'URL réelle de votre dépôt)*
4. Redémarrez ComfyUI.
---
## 🎨 Nœuds Incluídos
### Catégorie `SharpnessPro/Sharpen`
#### 🧠 SmartSharpen (Netteté optimisée)
Le nœud le plus avancé du pack. Il applique une netteté intelligente en se basant sur un filtre "edge-aware" (qui préserve les contours) plutôt qu'un simple flou gaussien. Idéal pour un résultat de haute qualité avec un minimum d'artefacts.
- **`image`** : L'image d'entrée.
- **`radius`** : Définit la taille des détails à accentuer. Un faible rayon cible les détails très fins.
- **`amount`** : L'intensité de la netteté.
- **`reduce_noise`** : Réduit l'amplification du bruit et des micro-détails indésirables. Très utile pour les images bruitées.
- **`fade_shadows`** : Protège les zones sombres de l'image, évitant le "clipping" (perte de détails) et l'apparition de bruit dans les ombres.
- **`fade_highlights`** : Protège les zones claires de l'image, évitant les halos et la perte de détails dans les hautes lumières.
**Conseil** : C'est le nœud de netteté à utiliser par défaut pour la plupart des images.
#### 🎭 UnsharpMaskSharpen (Masque flou - USM)
Implémentation du grand classique "Unsharp Mask" (Masque Flou). C'est un algorithme puissant et rapide, qui offre un excellent contrôle.
- **`radius`** : Rayon du flou utilisé pour détecter les détails.
- **`amount`** : Intensité de l'accentuation.
- **`threshold`** : Seuil de contraste. Les détails dont le contraste est inférieur à ce seuil ne seront pas accentués. Augmentez cette valeur pour éviter de rendre le bruit ou le grain de l'image plus visible.
- **`luma_only`** : Si "Yes", la netteté n'est appliquée qu'à la luminance, ce qui empêche l'apparition de franges colorées sur les contours.
**Conseil** : Utilisez ce nœud lorsque vous avez besoin d'un contrôle précis via le seuil (`threshold`).
#### LAYER HighPassSharpen (Passe-haut)
Une autre technique classique, très populaire dans le monde de la retouche photo. Elle isole les détails de l'image sur une couche grise (passe-haut) et la fusionne avec l'image originale.
- **`radius`** : Définit la finesse des détails à isoler.
- **`amount`** : Contrôle le contraste de la couche de détails avant la fusion.
- **`blend_mode`** :
- `Overlay` : Mode de fusion standard, plus contrasté et puissant.
- `SoftLight` : Mode de fusion plus doux et subtil.
- **`opacity`** : Opacité globale de l'effet.
- **`work_on_luma`** : Si "Yes", la couche de détails est calculée à partir de la luminance uniquement, pour un résultat plus propre.
**Conseil** : Excellent pour un look "photographique" et pour contrôler l'effet via les modes de fusion.
### Catégorie `SharpnessPro/Tone`
#### 💪 Clarity (Clarté)
Ce nœud augmente le **contraste local** dans les tons moyens de l'image, donnant plus de "punch" et de présence à l'image sans affecter les ombres et les hautes lumières. C'est l'équivalent du curseur "Clarté" dans Lightroom.
- **`radius`** : Utilise un rayon **large** pour cibler les variations de tons locales plutôt que les détails fins.
- **`strength`** : Intensité de l'effet. Peut être négatif pour adoucir l'image.
- **`midtone_softness`** : Contrôle la largeur de la plage des tons moyens ciblée. Une valeur plus faible resserre l'effet sur les tons moyens stricts.
- **`luma_only`** : Applique l'effet sur la luminance pour préserver les couleurs.
**Conseil** : Ne pas confondre avec la netteté. La clarté donne une impression de relief et de profondeur.
#### 🕸️ Texture
Ce nœud accentue ou atténue les **détails très fins** et les textures de surface. C'est l'équivalent du curseur "Texture" dans Lightroom.
- **`radius`** : Utilise un rayon **faible** pour cibler le micro-contraste.
- **`strength`** : Intensité de l'effet. Une valeur positive accentue la texture, une valeur négative l'adoucit (effet de lissage de peau léger).
- **`luma_only`** : Applique l'effet sur la luminance.
**Conseil** : Utilisez une valeur positive pour faire ressortir les détails du tissu, du bois ou de la roche. Utilisez une valeur négative pour adoucir subtilement la peau sur les portraits.
---
## 💡 Philosophie et Conseils d'Utilisation
- **Différence entre Netteté et Tonalité** : Les nœuds de *Netteté* (`Sharpen`) sont conçus pour l'étape finale et affectent les contours et les détails les plus fins. Les nœuds de *Tonalité* (`Tone`) comme `Clarity` et `Texture` modifient le contraste local et peuvent être utilisés plus tôt dans le workflow pour sculpter la lumière et la matière.
- **Ordre d'application** : Il est généralement recommandé d'appliquer la netteté (`SmartSharpen`, `USM`, etc.) en toute fin de workflow, juste avant la sauvegarde, pour éviter que les effets ultérieurs n'altèrent le résultat.
- **Commencez doucement** : Les valeurs par défaut sont un bon point de départ. Il est facile d'en faire trop avec la netteté. Zoomez à 100% pour bien juger de l'effet.
## Licence
Ce projet est distribué sous la licence MIT. Voir le fichier `LICENSE` pour plus de détails.
+58
View File
@@ -0,0 +1,58 @@
# SharpnessPro/SmartSharpen.py
import torch
from .utils import (ensure_tensor_bchw, to_bhwc, clamp01, gaussian_blur,
guided_smooth, rgb_to_luma)
class SmartSharpenNode:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"image": ("IMAGE",),
"radius": ("FLOAT", {"default": 1.8, "min": 0.0, "max": 50.0, "step": 0.1}),
"amount": ("FLOAT", {"default": 1.2, "min": 0.0, "max": 5.0, "step": 0.05}),
"reduce_noise": ("FLOAT", {"default": 0.15, "min": 0.0, "max": 1.0, "step": 0.01}),
"fade_shadows": ("FLOAT", {"default": 0.2, "min": 0.0, "max": 1.0, "step": 0.01}),
"fade_highlights": ("FLOAT", {"default": 0.2, "min": 0.0, "max": 1.0, "step": 0.01}),
}
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "apply"
CATEGORY = "SharpnessPro/Sharpen"
def apply(self, image, radius, amount, reduce_noise, fade_shadows, fade_highlights):
x = ensure_tensor_bchw(image).to(torch.float32)
# Edge-aware "blur" estimation on luma:
L = rgb_to_luma(x)
smooth_L = guided_smooth(x, radius=max(1.0, radius*2), eps=1e-3)
# Detail mostly on luma to avoid color halos:
dL = L - smooth_L
# Optional noise reduction: shrink very small details
if reduce_noise > 0:
shrink = (dL.abs() / (dL.abs() + 0.03)).pow(1.0 - reduce_noise) # compress micro noise
dL = dL * shrink
# Shadow/Highlight fade masks:
shadows = (1.0 - L).clamp(0,1)
highlights = L.clamp(0,1)
dL = dL * (1.0 - fade_shadows*shadows) * (1.0 - fade_highlights*highlights)
L_out = (L + amount * dL).clamp(0.0, 1.0)
# Recombine to RGB preserving chroma
eps = 1e-6
baseL = L + eps
scale = (L_out + eps) / baseL
out = clamp01(x * scale)
return (to_bhwc(out),)
NODE_CLASS_MAPPINGS = {
"SmartSharpen": SmartSharpenNode
}
NODE_DISPLAY_NAME_MAPPINGS = {
"SmartSharpen": "SharpnessPro · Netteté optimisée"
}
+41
View File
@@ -0,0 +1,41 @@
# SharpnessPro/Texture.py
import torch
from .utils import ensure_tensor_bchw, to_bhwc, clamp01, gaussian_blur, rgb_to_luma
class TextureNode:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"image": ("IMAGE",),
"radius": ("FLOAT", {"default": 2.0, "min": 0.3, "max": 10.0, "step": 0.1}),
"strength": ("FLOAT", {"default": 0.4, "min": -1.0, "max": 1.0, "step": 0.01}),
"luma_only": (["Yes","No"],),
}
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "apply"
CATEGORY = "SharpnessPro/Tone"
def apply(self, image, radius, strength, luma_only):
x = ensure_tensor_bchw(image).to(torch.float32)
# Micro-contrast = high frequency boost with small radius, symmetric, gentle
low = gaussian_blur(x, radius=radius)
hf = x - low # high frequencies
if luma_only == "Yes":
L = rgb_to_luma(x)
L_low = rgb_to_luma(low)
dL = L - L_low
L_out = (L + strength * dL).clamp(0,1)
eps = 1e-6
scale = (L_out + eps) / (L + eps)
out = clamp01(x * scale)
else:
out = clamp01(x + strength * hf)
return (to_bhwc(out),)
NODE_CLASS_MAPPINGS = {"Texture": TextureNode}
NODE_DISPLAY_NAME_MAPPINGS = {"Texture": "SharpnessPro · Texture"}
+52
View File
@@ -0,0 +1,52 @@
# SharpnessPro/UnsharpMaskSharpen.py
import torch
from .utils import ensure_tensor_bchw, to_bhwc, gaussian_blur, clamp01, rgb_to_luma, luma_to_rgb
class UnsharpMaskNode:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"image": ("IMAGE",),
"radius": ("FLOAT", {"default": 2.0, "min": 0.0, "max": 100.0, "step": 0.1}),
"amount": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 5.0, "step": 0.05}),
"threshold": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 0.2, "step": 0.005}),
"luma_only": (["Yes","No"],),
}
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "apply"
CATEGORY = "SharpnessPro/Sharpen"
def apply(self, image, radius, amount, threshold, luma_only):
x = ensure_tensor_bchw(image).to(torch.float32)
low = gaussian_blur(x, radius)
detail = x - low # high freq
if threshold > 0:
# Suppress tiny differences
mask = (detail.abs() > threshold).float()
detail = detail * mask
if luma_only == "Yes":
L = rgb_to_luma(x)
L_low = rgb_to_luma(low)
dL = L - L_low
if threshold > 0:
m = (dL.abs() > threshold).float()
dL = dL * m
L_out = (L + amount * dL).clamp(0.0, 1.0)
out = x * 0 # placeholder shape
out = luma_to_rgb(L_out, x)
else:
out = (x + amount * detail).clamp(0.0, 1.0)
return (to_bhwc(clamp01(out)),)
NODE_CLASS_MAPPINGS = {
"UnsharpMaskSharpen": UnsharpMaskNode
}
NODE_DISPLAY_NAME_MAPPINGS = {
"UnsharpMaskSharpen": "SharpnessPro · Masque flou (USM)"
}
+20
View File
@@ -0,0 +1,20 @@
# SharpnessPro/__init__.py
from .HighPassSharpen import NODE_CLASS_MAPPINGS as HP_MAP, NODE_DISPLAY_NAME_MAPPINGS as HP_NAME
from .UnsharpMaskSharpen import NODE_CLASS_MAPPINGS as USM_MAP, NODE_DISPLAY_NAME_MAPPINGS as USM_NAME
from .SmartSharpen import NODE_CLASS_MAPPINGS as SS_MAP, NODE_DISPLAY_NAME_MAPPINGS as SS_NAME
from .Clarity import NODE_CLASS_MAPPINGS as CL_MAP, NODE_DISPLAY_NAME_MAPPINGS as CL_NAME
from .Texture import NODE_CLASS_MAPPINGS as TX_MAP, NODE_DISPLAY_NAME_MAPPINGS as TX_NAME
NODE_CLASS_MAPPINGS = {}
NODE_CLASS_MAPPINGS.update(HP_MAP)
NODE_CLASS_MAPPINGS.update(USM_MAP)
NODE_CLASS_MAPPINGS.update(SS_MAP)
NODE_CLASS_MAPPINGS.update(CL_MAP)
NODE_CLASS_MAPPINGS.update(TX_MAP)
NODE_DISPLAY_NAME_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS.update(HP_NAME)
NODE_DISPLAY_NAME_MAPPINGS.update(USM_NAME)
NODE_DISPLAY_NAME_MAPPINGS.update(SS_NAME)
NODE_DISPLAY_NAME_MAPPINGS.update(CL_NAME)
NODE_DISPLAY_NAME_MAPPINGS.update(TX_NAME)
+152
View File
@@ -0,0 +1,152 @@
# SharpnessPro/utils.py
# Helpers communs pour les filtres de netteté.
import math
import torch
import torch.nn.functional as F
EPS = 1e-6
# ---------- Conversions / clamps ----------
def ensure_tensor_bchw(img: torch.Tensor) -> torch.Tensor:
# ComfyUI: images en [B,H,W,C] float32 [0..1]
if not isinstance(img, torch.Tensor):
raise TypeError("Expected torch.Tensor image")
if img.ndim != 4:
raise ValueError("Expected image with shape [B,H,W,C]")
if img.shape[-1] == 3: # B,H,W,C -> B,C,H,W
img = img.permute(0, 3, 1, 2).contiguous()
return img
def to_bhwc(img_bchw: torch.Tensor) -> torch.Tensor:
# B,C,H,W -> B,H,W,C
return img_bchw.permute(0, 2, 3, 1).contiguous()
def clamp01(x: torch.Tensor) -> torch.Tensor:
return x.clamp(0.0, 1.0)
# ---------- Flou Gaussien (propre & rapide) ----------
def get_sigma_from_radius(radius: float) -> float:
# Heuristique "douce" proche Photoshop
return max(0.15, radius * 0.5 + 0.25)
def gaussian_kernel1d(sigma: float, truncate: float = 3.0,
device=None, dtype=None) -> torch.Tensor:
if sigma <= 0:
k = torch.tensor([1.0], device=device, dtype=dtype or torch.float32)
return k / k.sum()
radius = int(truncate * sigma + 0.5)
x = torch.arange(-radius, radius + 1, device=device, dtype=dtype or torch.float32)
kernel = torch.exp(-(x ** 2) / (2 * sigma * sigma))
kernel /= kernel.sum()
return kernel
def gaussian_blur(img_bchw: torch.Tensor, radius: float) -> torch.Tensor:
if radius <= 0:
return img_bchw
sigma = get_sigma_from_radius(radius)
device, dtype = img_bchw.device, img_bchw.dtype
k1d = gaussian_kernel1d(sigma, device=device, dtype=dtype)
kH = k1d.view(1, 1, -1, 1)
kW = k1d.view(1, 1, 1, -1)
C = img_bchw.shape[1]
pad_h = (kH.shape[2] - 1) // 2
pad_w = (kW.shape[3] - 1) // 2
x = F.pad(img_bchw, (0, 0, pad_h, pad_h), mode='reflect')
x = F.conv2d(x, kH.expand(C, 1, -1, 1), groups=C)
x = F.pad(x, (pad_w, pad_w, 0, 0), mode='reflect')
x = F.conv2d(x, kW.expand(C, 1, 1, -1), groups=C)
return x
# ---------- Luma / recomposition ----------
def rgb_to_luma(rgb_bchw: torch.Tensor) -> torch.Tensor:
# BT.709
r = rgb_bchw[:, 0:1]
g = rgb_bchw[:, 1:2]
b = rgb_bchw[:, 2:3]
return 0.2126 * r + 0.7152 * g + 0.0722 * b
def luma_to_rgb(luma_bchw: torch.Tensor, rgb_base_bchw: torch.Tensor) -> torch.Tensor:
# Remplacement de luma en conservant la chroma via un scale simple
baseL = rgb_to_luma(rgb_base_bchw)
scale = (luma_bchw + EPS) / (baseL + EPS)
return clamp01(rgb_base_bchw * scale)
# ---------- Blend modes simplifiés ----------
def blend_overlay(base: torch.Tensor, blend: torch.Tensor) -> torch.Tensor:
res = torch.where(base <= 0.5, 2 * base * blend, 1 - 2 * (1 - base) * (1 - blend))
return clamp01(res)
def blend_soft_light(base: torch.Tensor, blend: torch.Tensor) -> torch.Tensor:
# Approx W3C
res = (1 - 2 * blend) * base * base + 2 * blend * base
return clamp01(res)
def apply_opacity(base: torch.Tensor, effect: torch.Tensor, opacity: float) -> torch.Tensor:
return clamp01(base * (1 - opacity) + effect * opacity)
# ---------- Détection de contours ----------
def sobel_edges(img_bchw: torch.Tensor) -> torch.Tensor:
# Magnitude Sobel sur luma, normalisée [0..1]
gray = rgb_to_luma(img_bchw)
kx = torch.tensor(
[[-1, 0, 1],
[-2, 0, 2],
[-1, 0, 1]], dtype=img_bchw.dtype, device=img_bchw.device
).view(1, 1, 3, 3)
ky = torch.tensor(
[[-1, -2, -1],
[ 0, 0, 0],
[ 1, 2, 1]], dtype=img_bchw.dtype, device=img_bchw.device
).view(1, 1, 3, 3)
gx = F.conv2d(F.pad(gray, (1, 1, 1, 1), mode='reflect'), kx)
gy = F.conv2d(F.pad(gray, (1, 1, 1, 1), mode='reflect'), ky)
mag = torch.sqrt(gx * gx + gy * gy)
mag = mag / (mag.amax(dim=[2, 3], keepdim=True) + EPS)
return mag # B,1,H,W
# ---------- Filtre guidé (approx) edge-aware ----------
def guided_smooth(img_bchw: torch.Tensor, radius: float, eps: float = 1e-4) -> torch.Tensor:
"""
Smoothing edge-aware très léger (approx guided filter) appliqué sur la luma.
Patch important : imposer un noyau IMPAIR pour conserver exactement H, W.
"""
if radius <= 0:
return rgb_to_luma(img_bchw)
k = int(max(1, round(radius)))
if k % 2 == 0:
k += 1 # noyau impair indispensable
device, dtype = img_bchw.device, img_bchw.dtype
kernel = torch.ones(1, 1, k, k, device=device, dtype=dtype) / (k * k)
pad = (k // 2, k // 2, k // 2, k // 2) # L,R,T,B
L = rgb_to_luma(img_bchw)
mean_L = F.conv2d(F.pad(L, pad, mode="reflect"), kernel)
mean_L2 = F.conv2d(F.pad(L*L, pad, mode="reflect"), kernel)
var_L = mean_L2 - mean_L * mean_L
a = var_L / (var_L + eps)
b = (1 - a) * mean_L
mean_a = F.conv2d(F.pad(a, pad, mode="reflect"), kernel)
mean_b = F.conv2d(F.pad(b, pad, mode="reflect"), kernel)
return mean_a * L + mean_b # B,1,H,W
# ---------- Masque de tons moyens (Clarity) ----------
def midtone_mask(img_bchw: torch.Tensor, softness: float = 0.35) -> torch.Tensor:
"""
Masque ~1 autour des midtones (luma≈0.5), 0 vers ombres/hautes lumières.
softness ∈ (0, 0.5] contrôle la largeur du plateau.
"""
L = rgb_to_luma(img_bchw) # B,1,H,W
s = max(1e-6, min(0.5, float(softness)))
m = torch.cos((L - 0.5) * math.pi / (2 * s)).clamp(-1, 1)
m = (m + 1) / 2.0
m = torch.where((L > 0.5 + s) | (L < 0.5 - s), torch.zeros_like(m), m)
return m # B,1,H,W