Skip to content

Commit 32c93d1

Browse files
feat(seaborn): implement chernoff-basic (#6828)
## Implementation: `chernoff-basic` - python/seaborn Implements the **python/seaborn** version of `chernoff-basic`. **File:** `plots/chernoff-basic/implementations/python/seaborn.py` **Parent Issue:** #3003 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/anyplot/actions/runs/25924826760)* --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent 6f2a4ad commit 32c93d1

2 files changed

Lines changed: 344 additions & 135 deletions

File tree

Lines changed: 111 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -1,127 +1,113 @@
1-
""" pyplots.ai
1+
""" anyplot.ai
22
chernoff-basic: Chernoff Faces for Multivariate Data
3-
Library: seaborn 0.13.2 | Python 3.13.11
4-
Quality: 82/100 | Created: 2025-12-31
3+
Library: seaborn 0.13.2 | Python 3.13.13
4+
Quality: 93/100 | Updated: 2026-05-15
55
"""
66

7+
import os
8+
79
import matplotlib.patches as mpatches
810
import matplotlib.pyplot as plt
911
import numpy as np
1012
import seaborn as sns
13+
from sklearn.datasets import load_wine
14+
1115

16+
THEME = os.getenv("ANYPLOT_THEME", "light")
17+
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
18+
ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420"
19+
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
20+
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
21+
BRAND = "#009E73"
1222

13-
# Load and prepare data (Iris dataset - 4 measurements per flower)
14-
iris_df = sns.load_dataset("iris")
15-
feature_cols = ["sepal_length", "sepal_width", "petal_length", "petal_width"]
16-
data = iris_df[feature_cols].values
17-
species_map = {"setosa": 0, "versicolor": 1, "virginica": 2}
18-
target = iris_df["species"].map(species_map).values
23+
# Load wine dataset (13 chemical measurements per wine sample)
24+
wine = load_wine()
25+
feature_names = wine.feature_names
26+
data = wine.data
27+
target = wine.target
1928

20-
# Select subset of samples with maximum variation within each species (5 per species = 15 total)
29+
# Select 5 wines from each class (3 classes = 15 total)
2130
np.random.seed(42)
2231
indices = []
23-
for species in range(3):
24-
species_indices = np.where(target == species)[0]
25-
species_data = data[species_indices]
26-
# For each feature, find samples with min and max values to maximize visible variation
27-
# Then add centroid sample
28-
selected = set()
29-
for feat_idx in range(4):
30-
feat_values = species_data[:, feat_idx]
31-
min_idx = species_indices[np.argmin(feat_values)]
32-
max_idx = species_indices[np.argmax(feat_values)]
33-
selected.add(min_idx)
34-
selected.add(max_idx)
35-
# Add sample closest to mean
36-
mean_vals = species_data.mean(axis=0)
37-
distances = np.sum((species_data - mean_vals) ** 2, axis=1)
38-
selected.add(species_indices[np.argmin(distances)])
39-
# Convert to list and take first 5
40-
selected = list(selected)[:5]
41-
while len(selected) < 5:
42-
for idx in species_indices:
43-
if idx not in selected:
44-
selected.append(idx)
45-
break
46-
indices.extend(selected[:5])
32+
for cls in range(3):
33+
class_indices = np.where(target == cls)[0]
34+
selected = np.random.choice(class_indices, size=5, replace=False)
35+
indices.extend(selected)
4736
indices = np.array(indices)
4837

4938
subset_data = data[indices]
5039
subset_target = target[indices]
51-
species_names = ["Setosa", "Versicolor", "Virginica"]
52-
53-
# Global normalization for heatmap (shows species differences)
54-
data_min = data.min(axis=0)
55-
data_max = data.max(axis=0)
56-
heatmap_normalized = (subset_data - data_min) / (data_max - data_min + 1e-8)
57-
58-
# WITHIN-species normalization for faces (maximizes visible variation within each species)
59-
# This ensures each species shows its full range of variation in facial features
60-
normalized_data = np.zeros_like(subset_data)
61-
for species in range(3):
62-
species_mask = subset_target == species
63-
species_subset = subset_data[species_mask]
40+
class_names = ["Class 1", "Class 2", "Class 3"]
41+
42+
# Select 4 key features for face mapping
43+
selected_features = [0, 6, 9, 12] # Alcohol, phenols, malic acid, proline
44+
selected_feature_names = [feature_names[i].replace(" ", "\n") for i in selected_features]
45+
data_subset = subset_data[:, selected_features]
46+
47+
# Within-species normalization for faces
48+
normalized_data = np.zeros((15, 4))
49+
for cls in range(3):
50+
class_mask = subset_target == cls
51+
class_subset = data_subset[class_mask]
6452
for feat_idx in range(4):
65-
feat_min = species_subset[:, feat_idx].min()
66-
feat_max = species_subset[:, feat_idx].max()
53+
feat_min = class_subset[:, feat_idx].min()
54+
feat_max = class_subset[:, feat_idx].max()
6755
feat_range = feat_max - feat_min if feat_max > feat_min else 1.0
68-
normalized_data[species_mask, feat_idx] = (species_subset[:, feat_idx] - feat_min) / feat_range
56+
normalized_data[class_mask, feat_idx] = (class_subset[:, feat_idx] - feat_min) / feat_range
6957

70-
# Set seaborn style
58+
# Set seaborn theme
7159
sns.set_style("white")
7260
sns.set_context("poster", font_scale=1.0)
73-
74-
# Color palette for species (using seaborn palette)
75-
palette = sns.color_palette("Set2", 3)
76-
face_colors = [palette[t] for t in subset_target]
77-
78-
# Create figure - 16:9 aspect ratio for 4800x2700 at 300dpi
79-
fig = plt.figure(figsize=(16, 9))
80-
81-
# Create grid layout: heatmap on left, faces on right
82-
gs = fig.add_gridspec(3, 7, width_ratios=[1.5, 1, 1, 1, 1, 1, 0.1], hspace=0.25, wspace=0.15)
83-
84-
# Add heatmap showing feature values on left side using seaborn (global normalization)
85-
ax_heatmap = fig.add_subplot(gs[:, 0])
86-
sns.heatmap(
87-
heatmap_normalized,
88-
ax=ax_heatmap,
89-
cmap="YlOrRd",
90-
cbar=False,
91-
xticklabels=["Sepal\nLength", "Sepal\nWidth", "Petal\nLength", "Petal\nWidth"],
92-
yticklabels=[f"{species_names[subset_target[i]][0]}{(i % 5) + 1}" for i in range(15)],
93-
linewidths=1,
94-
linecolor="white",
95-
annot=True,
96-
fmt=".2f",
97-
annot_kws={"size": 9},
61+
sns.set_theme(
62+
rc={
63+
"figure.facecolor": PAGE_BG,
64+
"axes.facecolor": PAGE_BG,
65+
"text.color": INK,
66+
"axes.labelcolor": INK,
67+
"xtick.color": INK_SOFT,
68+
"ytick.color": INK_SOFT,
69+
}
9870
)
99-
ax_heatmap.set_title("Feature Values\n(Normalized)", fontsize=16, fontweight="bold", pad=10)
100-
ax_heatmap.tick_params(axis="x", labelsize=12, rotation=0)
101-
ax_heatmap.tick_params(axis="y", labelsize=11)
10271

103-
# Draw Chernoff faces in grid (3 rows x 5 cols on the right side)
72+
# Okabe-Ito palette - first series is always brand green
73+
okabe_ito = [BRAND, "#D55E00", "#0072B2"]
74+
face_colors = [okabe_ito[t] for t in subset_target]
75+
76+
# Create figure
77+
fig = plt.figure(figsize=(16, 9), facecolor=PAGE_BG)
78+
79+
# Grid layout: faces only (3 rows x 5 cols)
80+
gs = fig.add_gridspec(3, 5, hspace=0.35, wspace=0.25)
81+
82+
83+
def hex_to_rgb(h):
84+
return tuple(int(h.lstrip("#")[i : i + 2], 16) / 255.0 for i in (0, 2, 4))
85+
86+
87+
# Draw Chernoff faces
10488
for idx in range(15):
105-
row = idx // 5 # Rows 0, 1, 2
106-
col = idx % 5 + 1 # Columns 1-5 (column 0 is heatmap)
89+
row = idx // 5
90+
col = idx % 5
10791
ax = fig.add_subplot(gs[row, col])
10892

10993
features = normalized_data[idx]
11094
color = face_colors[idx]
11195

112-
# Map features to facial characteristics with WIDER ranges for visible variation
113-
# Feature 0: sepal_length -> face width (0.45 to 1.0)
114-
# Feature 1: sepal_width -> face height (0.55 to 1.1)
115-
# Feature 2: petal_length -> eye size (0.04 to 0.16)
116-
# Feature 3: petal_width -> mouth curvature (-0.35 to 0.35)
96+
# Map features to facial characteristics
97+
# Feature 0 (alcohol): face width
98+
# Feature 1 (phenols): face height
99+
# Feature 2 (malic acid): eye size
100+
# Feature 3 (proline): mouth curvature
117101

118102
face_width = 0.45 + features[0] * 0.55
119103
face_height = 0.55 + features[1] * 0.55
120104
eye_size = 0.04 + features[2] * 0.12
121105
mouth_curve = -0.35 + features[3] * 0.7
122106

123-
# Draw face outline (ellipse)
124-
face = mpatches.Ellipse((0.5, 0.5), face_width, face_height, facecolor=color, edgecolor="black", linewidth=2.5)
107+
# Draw face outline
108+
face = mpatches.Ellipse(
109+
(0.5, 0.5), face_width, face_height, facecolor=color, edgecolor=INK, linewidth=2.5, alpha=0.9
110+
)
125111
ax.add_patch(face)
126112

127113
# Draw eyes
@@ -130,18 +116,18 @@
130116

131117
# Left eye
132118
left_eye = mpatches.Ellipse(
133-
(0.5 - eye_spacing, eye_y), eye_size * 1.6, eye_size, facecolor="white", edgecolor="black", linewidth=2
119+
(0.5 - eye_spacing, eye_y), eye_size * 1.6, eye_size, facecolor=ELEVATED_BG, edgecolor=INK, linewidth=2
134120
)
135121
ax.add_patch(left_eye)
136-
left_pupil = mpatches.Circle((0.5 - eye_spacing, eye_y), eye_size * 0.35, facecolor="black")
122+
left_pupil = mpatches.Circle((0.5 - eye_spacing, eye_y), eye_size * 0.35, facecolor=INK)
137123
ax.add_patch(left_pupil)
138124

139125
# Right eye
140126
right_eye = mpatches.Ellipse(
141-
(0.5 + eye_spacing, eye_y), eye_size * 1.6, eye_size, facecolor="white", edgecolor="black", linewidth=2
127+
(0.5 + eye_spacing, eye_y), eye_size * 1.6, eye_size, facecolor=ELEVATED_BG, edgecolor=INK, linewidth=2
142128
)
143129
ax.add_patch(right_eye)
144-
right_pupil = mpatches.Circle((0.5 + eye_spacing, eye_y), eye_size * 0.35, facecolor="black")
130+
right_pupil = mpatches.Circle((0.5 + eye_spacing, eye_y), eye_size * 0.35, facecolor=INK)
145131
ax.add_patch(right_pupil)
146132

147133
# Draw eyebrows
@@ -151,24 +137,26 @@
151137
ax.plot(
152138
[0.5 - eye_spacing - 0.05, 0.5 - eye_spacing + 0.05],
153139
[eyebrow_y + eyebrow_angle, eyebrow_y - eyebrow_angle],
154-
color="black",
140+
color=INK,
155141
linewidth=3.5,
156142
solid_capstyle="round",
157143
)
158144
ax.plot(
159145
[0.5 + eye_spacing - 0.05, 0.5 + eye_spacing + 0.05],
160146
[eyebrow_y - eyebrow_angle, eyebrow_y + eyebrow_angle],
161-
color="black",
147+
color=INK,
162148
linewidth=3.5,
163149
solid_capstyle="round",
164150
)
165151

166152
# Draw nose
167153
nose_size = 0.03 + features[0] * 0.025
154+
color_rgb = hex_to_rgb(color)
155+
nose_color = tuple(c * 0.7 for c in color_rgb)
168156
nose = mpatches.Polygon(
169157
[[0.5, 0.50], [0.5 - nose_size, 0.40], [0.5 + nose_size, 0.40]],
170-
facecolor=tuple(c * 0.85 for c in color[:3]),
171-
edgecolor="black",
158+
facecolor=nose_color,
159+
edgecolor=INK,
172160
linewidth=1.5,
173161
)
174162
ax.add_patch(nose)
@@ -177,7 +165,7 @@
177165
mouth_width = 0.08 + features[0] * 0.05
178166
mouth_x = np.linspace(0.5 - mouth_width, 0.5 + mouth_width, 25)
179167
mouth_y = 0.30 + mouth_curve * ((mouth_x - 0.5) ** 2) * 18
180-
ax.plot(mouth_x, mouth_y, color="#8B0000", linewidth=3.5, solid_capstyle="round")
168+
ax.plot(mouth_x, mouth_y, color=INK_SOFT, linewidth=3.5, solid_capstyle="round")
181169

182170
# Set axis properties
183171
ax.set_xlim(0, 1)
@@ -186,25 +174,38 @@
186174
ax.axis("off")
187175

188176
# Add label below face
189-
species_idx = subset_target[idx]
177+
class_idx = subset_target[idx]
190178
sample_num = (idx % 5) + 1
191179
ax.text(
192-
0.5, -0.02, f"{species_names[species_idx]} #{sample_num}", ha="center", va="top", fontsize=11, fontweight="bold"
180+
0.5,
181+
-0.05,
182+
f"{class_names[class_idx]} #{sample_num}",
183+
ha="center",
184+
va="top",
185+
fontsize=11,
186+
fontweight="bold",
187+
color=INK,
193188
)
194189

195190
# Add overall title
196-
fig.suptitle("chernoff-basic · seaborn · pyplots.ai", fontsize=24, fontweight="bold", y=0.98)
191+
fig.suptitle("chernoff-basic · seaborn · anyplot.ai", fontsize=24, fontweight="medium", y=0.98, color=INK)
197192

198-
# Add legend for species (positioned to the right)
199-
legend_handles = [mpatches.Patch(color=palette[i], label=species_names[i], ec="black", lw=1.5) for i in range(3)]
193+
# Add legend for classes
194+
legend_handles = [mpatches.Patch(color=okabe_ito[i], label=class_names[i], ec=INK, lw=1.5) for i in range(3)]
200195
fig.legend(
201-
handles=legend_handles, loc="center right", fontsize=14, frameon=True, bbox_to_anchor=(0.99, 0.5), title="Species"
196+
handles=legend_handles,
197+
loc="lower right",
198+
fontsize=14,
199+
frameon=True,
200+
bbox_to_anchor=(0.98, 0.02),
201+
title="Wine Class",
202+
facecolor=ELEVATED_BG,
203+
edgecolor=INK_SOFT,
204+
title_fontsize=14,
202205
)
203206

204-
# Add feature mapping explanation at bottom
205-
feature_text = (
206-
"Face Width ← Sepal Length | Face Height ← Sepal Width | Eye Size ← Petal Length | Mouth Curve ← Petal Width"
207-
)
208-
fig.text(0.55, 0.02, feature_text, ha="center", fontsize=12, style="italic")
207+
# Add feature mapping explanation
208+
feature_text = "Face Width ← Alcohol | Face Height ← Phenols | Eye Size ← Malic Acid | Mouth Curve ← Proline"
209+
fig.text(0.5, 0.01, feature_text, ha="center", fontsize=12, style="italic", color=INK_SOFT)
209210

210-
plt.savefig("plot.png", dpi=300, bbox_inches="tight", facecolor="white")
211+
plt.savefig(f"plot-{THEME}.png", dpi=300, bbox_inches="tight", facecolor=PAGE_BG)

0 commit comments

Comments
 (0)