feat(clustering): dégradé HSL multi-stop basé sur le score de non-humanité
Remplace la palette par index par un dégradé continu HSL : - risk=0.0 → hue 220° (bleu froid — humain légitime) - risk=0.25 → hue 165° (cyan-vert — légèrement suspect) - risk=0.50 → hue 110° (vert-jaune — comportement mixte) - risk=0.75 → hue 55° (jaune-orange — probable bot) - risk=1.0 → hue 0° (rouge vif — bot confirmé) Saturation : 70%→90%, Lightness : 58%→48% (couleur plus intense = plus alarmant) Légende : barre de dégradé 'Humain ← → Bot' avec stops HSL alignés Suppression de spread_clusters (chevauchement des zones autorisé) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@ -452,96 +452,42 @@ def risk_score_from_centroid(centroid: np.ndarray) -> float:
|
||||
))
|
||||
|
||||
|
||||
# ─── Palette de couleurs diversifiée (non liée au risque) ────────────────────
|
||||
# 24 couleurs couvrant tout le spectre HSL pour distinguer les clusters visuellement.
|
||||
# Choix: teintes espacées de ~15° avec alternance de saturation/luminosité.
|
||||
# ─── Gradient de couleur basé sur le score de non-humanité ──────────────────
|
||||
# Le score [0,1] est mappé sur un dégradé HSL traversant tout le spectre :
|
||||
# bleu (humain) → cyan → vert → jaune-vert → jaune → orange → rouge (bot pur)
|
||||
# Hue : 220° (bleu froid) → 0° (rouge vif) en passant par tout l'arc chromatique.
|
||||
|
||||
_CLUSTER_PALETTE: list[str] = [
|
||||
"#3b82f6", # blue
|
||||
"#8b5cf6", # violet
|
||||
"#ec4899", # pink
|
||||
"#14b8a6", # teal
|
||||
"#f59e0b", # amber
|
||||
"#06b6d4", # cyan
|
||||
"#a3e635", # lime
|
||||
"#f97316", # orange
|
||||
"#6366f1", # indigo
|
||||
"#10b981", # emerald
|
||||
"#e879f9", # fuchsia
|
||||
"#fbbf24", # yellow
|
||||
"#60a5fa", # light blue
|
||||
"#c084fc", # light purple
|
||||
"#fb7185", # rose
|
||||
"#34d399", # light green
|
||||
"#38bdf8", # sky
|
||||
"#a78bfa", # lavender
|
||||
"#fdba74", # peach
|
||||
"#4ade80", # green
|
||||
"#f472b6", # light pink
|
||||
"#67e8f9", # light cyan
|
||||
"#d97706", # dark amber
|
||||
"#7c3aed", # dark violet
|
||||
]
|
||||
def _hsl_to_hex(h: float, s: float, l: float) -> str:
|
||||
"""Convertit HSL (h:0-360, s:0-100, l:0-100) en chaîne '#rrggbb'."""
|
||||
s /= 100.0
|
||||
l /= 100.0
|
||||
c = (1.0 - abs(2.0 * l - 1.0)) * s
|
||||
x = c * (1.0 - abs((h / 60.0) % 2.0 - 1.0))
|
||||
m = l - c / 2.0
|
||||
if h < 60: r, g, b = c, x, 0.0
|
||||
elif h < 120: r, g, b = x, c, 0.0
|
||||
elif h < 180: r, g, b = 0.0, c, x
|
||||
elif h < 240: r, g, b = 0.0, x, c
|
||||
elif h < 300: r, g, b = x, 0.0, c
|
||||
else: r, g, b = c, 0.0, x
|
||||
ri, gi, bi = int((r + m) * 255), int((g + m) * 255), int((b + m) * 255)
|
||||
return f"#{ri:02x}{gi:02x}{bi:02x}"
|
||||
|
||||
|
||||
def cluster_color(cluster_idx: int) -> str:
|
||||
"""Couleur distinctive pour un cluster, cyclique sur la palette."""
|
||||
return _CLUSTER_PALETTE[cluster_idx % len(_CLUSTER_PALETTE)]
|
||||
|
||||
|
||||
# ─── Dispersion des clusters dans l'espace 2D ────────────────────────────────
|
||||
|
||||
def spread_clusters(coords_2d: np.ndarray, labels: np.ndarray, k: int,
|
||||
n_iter: int = 50, min_dist: float = 0.14) -> np.ndarray:
|
||||
def risk_to_gradient_color(risk: float) -> str:
|
||||
"""
|
||||
Repousse les centroïdes trop proches par répulsion itérative (spring repulsion).
|
||||
Chaque point suit le déplacement de son centroïde.
|
||||
Mappe un score de non-humanité [0,1] sur un dégradé HSL continu multi-stop.
|
||||
|
||||
Paramètres
|
||||
----------
|
||||
min_dist : distance minimale souhaitée entre centroïdes (espace [0,1]).
|
||||
Augmenter pour plus d'éclatement.
|
||||
n_iter : nombre d'itérations de la physique de répulsion.
|
||||
risk = 0.0 → hue 220° (bleu froid — trafic humain légitime)
|
||||
risk = 0.25 → hue 165° (cyan-vert — léger signal suspect)
|
||||
risk = 0.50 → hue 110° (vert-jaune — comportement mixte)
|
||||
risk = 0.75 → hue 55° (jaune-orange — probable bot)
|
||||
risk = 1.0 → hue 0° (rouge vif — bot confirmé)
|
||||
|
||||
La saturation monte légèrement avec le risque pour accentuer la lisibilité.
|
||||
"""
|
||||
rng = np.random.default_rng(0)
|
||||
centroids = np.zeros((k, 2))
|
||||
counts = np.zeros(k, dtype=int)
|
||||
for j in range(k):
|
||||
mask = labels == j
|
||||
if mask.any():
|
||||
centroids[j] = coords_2d[mask].mean(axis=0)
|
||||
counts[j] = int(mask.sum())
|
||||
|
||||
orig = centroids.copy()
|
||||
|
||||
for _ in range(n_iter):
|
||||
forces = np.zeros_like(centroids)
|
||||
for i in range(k):
|
||||
if counts[i] == 0:
|
||||
continue
|
||||
for j in range(k):
|
||||
if i == j or counts[j] == 0:
|
||||
continue
|
||||
delta = centroids[i] - centroids[j]
|
||||
dist = float(np.linalg.norm(delta))
|
||||
if dist < 1e-8:
|
||||
delta = rng.uniform(-0.02, 0.02, size=2)
|
||||
dist = float(np.linalg.norm(delta)) + 1e-8
|
||||
if dist < min_dist:
|
||||
# Force inversement proportionnelle à l'écart
|
||||
magnitude = (min_dist - dist) / min_dist
|
||||
forces[i] += magnitude * (delta / dist)
|
||||
centroids += forces * 0.10
|
||||
|
||||
# Déplace chaque point par le delta de son centroïde
|
||||
displaced = coords_2d.copy()
|
||||
for j in range(k):
|
||||
if counts[j] == 0:
|
||||
continue
|
||||
displaced[labels == j] += centroids[j] - orig[j]
|
||||
|
||||
# Re-normalisation [0, 1]
|
||||
mn, mx = displaced.min(axis=0), displaced.max(axis=0)
|
||||
rng_ = mx - mn
|
||||
rng_[rng_ < 1e-8] = 1.0
|
||||
return (displaced - mn) / rng_
|
||||
r = float(np.clip(risk, 0.0, 1.0))
|
||||
hue = (1.0 - r) * 220.0 # 220° → 0°
|
||||
saturation = 70.0 + r * 20.0 # 70% → 90%
|
||||
lightness = 58.0 - r * 10.0 # 58% → 48% (plus sombre = plus alarmant)
|
||||
return _hsl_to_hex(hue, saturation, lightness)
|
||||
|
||||
Reference in New Issue
Block a user