diff --git a/backend/routes/clustering.py b/backend/routes/clustering.py index 35b9969..881ca19 100644 --- a/backend/routes/clustering.py +++ b/backend/routes/clustering.py @@ -24,7 +24,7 @@ from ..services.clustering_engine import ( FEATURE_KEYS, FEATURE_NAMES, FEATURE_NORMS, N_FEATURES, build_feature_vector, kmeans_pp, pca_2d, compute_hulls, name_cluster, risk_score_from_centroid, standardize, - cluster_color, spread_clusters, + risk_to_gradient_color, ) log = logging.getLogger(__name__) @@ -196,11 +196,7 @@ def _run_clustering_job(k: int, hours: int, sensitivity: float = 1.0) -> None: # ── 5. PCA-2D sur les features ORIGINALES (normalisées [0,1]) ──── coords = pca_2d(X64) # (n, 2), normalisé [0,1] - # ── 5b. Dispersion — repousse les clusters trop proches ────────── - coords = spread_clusters(coords, km.labels, k_actual, - n_iter=60, min_dist=0.16) - - # ── 5c. Enveloppes convexes par cluster ────────────────────────── + # ── 5b. Enveloppes convexes par cluster ────────────────────────── hulls = compute_hulls(coords, km.labels, k_actual) # ── 6. Agrégation par cluster ───────────────────────────────────── @@ -237,7 +233,7 @@ def _run_clustering_job(k: int, hours: int, sensitivity: float = 1.0) -> None: raw_stats = {"mean_ttl": mean_ttl, "mean_mss": mean_mss, "mean_scale": mean_scale} label_name = name_cluster(centroids_orig[j], raw_stats) risk = float(risk_score_from_centroid(centroids_orig[j])) - color = cluster_color(j) + color = risk_to_gradient_color(risk) # Centroïde 2D = moyenne des coords du cluster cxy = np.mean(cluster_coords[j], axis=0).tolist() if cluster_coords[j] else [0.5, 0.5] diff --git a/backend/services/clustering_engine.py b/backend/services/clustering_engine.py index 2d20ea8..e24fe47 100644 --- a/backend/services/clustering_engine.py +++ b/backend/services/clustering_engine.py @@ -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) diff --git a/frontend/src/components/ClusteringView.tsx b/frontend/src/components/ClusteringView.tsx index 960f85a..c5851f5 100644 --- a/frontend/src/components/ClusteringView.tsx +++ b/frontend/src/components/ClusteringView.tsx @@ -519,20 +519,19 @@ export default function ClusteringView() { style={{ width: '100%', height: '100%' }} controller={true} > - {/* Légende overlay */} -