Skip to content

Commit 24bfe41

Browse files
feat(letsplot): implement chernoff-basic (#6835)
## Implementation: `chernoff-basic` - python/letsplot Implements the **python/letsplot** version of `chernoff-basic`. **File:** `plots/chernoff-basic/implementations/python/letsplot.py` **Parent Issue:** #3003 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/anyplot/actions/runs/25925524639)* --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com>
1 parent dfe5d23 commit 24bfe41

2 files changed

Lines changed: 231 additions & 204 deletions

File tree

Lines changed: 75 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
1-
""" pyplots.ai
1+
""" anyplot.ai
22
chernoff-basic: Chernoff Faces for Multivariate Data
3-
Library: letsplot 4.8.2 | Python 3.13.11
4-
Quality: 91/100 | Created: 2025-12-31
3+
Library: letsplot 4.9.0 | Python 3.13.13
4+
Quality: 80/100 | Updated: 2026-05-15
55
"""
66

7+
import os
8+
79
import numpy as np
810
import pandas as pd
911
from lets_plot import (
1012
LetsPlot,
1113
aes,
1214
element_blank,
15+
element_rect,
1316
element_text,
1417
geom_path,
1518
geom_polygon,
@@ -26,48 +29,65 @@
2629

2730
LetsPlot.setup_html()
2831

29-
# Data - Iris dataset (4 measurements per flower, 3 species)
32+
# Theme tokens
33+
THEME = os.getenv("ANYPLOT_THEME", "light")
34+
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
35+
ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420"
36+
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
37+
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
38+
39+
# Data - Iris dataset
3040
np.random.seed(42)
3141
iris = load_iris()
3242
df = pd.DataFrame(iris.data, columns=["sepal_length", "sepal_width", "petal_length", "petal_width"])
3343
df["species"] = [iris.target_names[i] for i in iris.target]
3444

35-
# Sample 12 flowers (4 per species) for clear visualization
45+
# Sample 12 flowers (4 per species)
3646
sample_idx = []
3747
for species in range(3):
3848
species_idx = np.where(iris.target == species)[0]
3949
sample_idx.extend(np.random.choice(species_idx, 4, replace=False))
4050
df_sample = df.iloc[sample_idx].reset_index(drop=True)
4151

42-
# Normalize data to 0-1 range for facial feature mapping
52+
# Normalize data to 0-1 range
4353
features = ["sepal_length", "sepal_width", "petal_length", "petal_width"]
4454
for col in features:
4555
min_val = df_sample[col].min()
4656
max_val = df_sample[col].max()
4757
df_sample[col + "_norm"] = (df_sample[col] - min_val) / (max_val - min_val)
4858

59+
# Generate faces in a grid
60+
grid_rows = 3
61+
grid_cols = 4
62+
all_face_data = []
63+
label_data = []
64+
species_colors = {"setosa": "#009E73", "versicolor": "#D55E00", "virginica": "#0072B2"}
65+
66+
for idx, row in df_sample.iterrows():
67+
col = idx % grid_cols
68+
row_pos = idx // grid_cols
69+
center_x = col + 0.5
70+
center_y = (grid_rows - 1 - row_pos) + 0.5
4971

50-
# Chernoff face generator - maps 4 variables to facial features
51-
def create_face(row_data, center_x, center_y, scale=0.4):
52-
"""Generate face components based on normalized data values."""
53-
sepal_len = row_data["sepal_length_norm"] # Face width
54-
sepal_wid = row_data["sepal_width_norm"] # Eye size
55-
petal_len = row_data["petal_length_norm"] # Mouth curvature
56-
petal_wid = row_data["petal_width_norm"] # Eyebrow slant
72+
sepal_len = row["sepal_length_norm"]
73+
sepal_wid = row["sepal_width_norm"]
74+
petal_len = row["petal_length_norm"]
75+
petal_wid = row["petal_width_norm"]
5776

77+
scale = 0.42
5878
face_data = []
5979

60-
# Face outline (ellipse) - face width controlled by sepal_length
61-
face_width = 0.35 + 0.2 * sepal_len # Range: 0.35 to 0.55
80+
# Face outline (ellipse)
81+
face_width = 0.35 + 0.2 * sepal_len
6282
face_height = 0.45
6383
theta = np.linspace(0, 2 * np.pi, 50)
6484
face_x = center_x + scale * face_width * np.cos(theta)
6585
face_y = center_y + scale * face_height * np.sin(theta)
6686
for i in range(len(theta)):
6787
face_data.append({"x": face_x[i], "y": face_y[i], "part": "face", "order": i})
6888

69-
# Eyes - eye size controlled by sepal_width
70-
eye_size = 0.03 + 0.04 * sepal_wid # Range: 0.03 to 0.07
89+
# Eyes
90+
eye_size = 0.03 + 0.04 * sepal_wid
7191
eye_y = center_y + scale * 0.12
7292
eye_spacing = 0.12
7393

@@ -96,19 +116,19 @@ def create_face(row_data, center_x, center_y, scale=0.4):
96116
for i in range(len(theta_eye)):
97117
face_data.append({"x": right_pupil_x[i], "y": right_pupil_y[i], "part": "right_pupil", "order": i})
98118

99-
# Mouth - curvature controlled by petal_length
119+
# Mouth
100120
mouth_y = center_y - scale * 0.15
101121
mouth_width = 0.12
102-
curvature = -0.08 + 0.16 * petal_len # Range: -0.08 (sad) to 0.08 (happy)
122+
curvature = -0.08 + 0.16 * petal_len
103123
mouth_x = np.linspace(-mouth_width, mouth_width, 20)
104124
mouth_curve_y = mouth_y + scale * curvature * (1 - (mouth_x / mouth_width) ** 2)
105125
mouth_curve_x = center_x + scale * mouth_x
106126
for i in range(len(mouth_x)):
107127
face_data.append({"x": mouth_curve_x[i], "y": mouth_curve_y[i], "part": "mouth", "order": i})
108128

109-
# Eyebrows - slant controlled by petal_width
129+
# Eyebrows
110130
brow_y = center_y + scale * 0.22
111-
brow_slant = -0.03 + 0.06 * petal_wid # Range: -0.03 (angry) to 0.03 (surprised)
131+
brow_slant = -0.03 + 0.06 * petal_wid
112132
brow_length = 0.06
113133

114134
# Left eyebrow
@@ -147,87 +167,66 @@ def create_face(row_data, center_x, center_y, scale=0.4):
147167
}
148168
)
149169

150-
# Nose - simple vertical line
170+
# Nose
151171
nose_top = center_y + scale * 0.02
152172
nose_bottom = center_y - scale * 0.08
153173
face_data.append({"x": center_x, "y": nose_top, "part": "nose", "order": 0})
154174
face_data.append({"x": center_x, "y": nose_bottom, "part": "nose", "order": 1})
155175

156-
return pd.DataFrame(face_data)
157-
158-
159-
# Generate faces in a grid (3 rows x 4 columns = 12 faces)
160-
grid_rows = 3
161-
grid_cols = 4
162-
all_face_data = []
163-
label_data = []
164-
species_colors = {"setosa": "#306998", "versicolor": "#FFD43B", "virginica": "#DC2626"}
165-
166-
for idx, row in df_sample.iterrows():
167-
col = idx % grid_cols
168-
row_pos = idx // grid_cols
169-
center_x = col + 0.5
170-
center_y = (grid_rows - 1 - row_pos) + 0.5 # Flip y so row 0 is at top
171-
172-
face_df = create_face(row, center_x, center_y, scale=0.42)
176+
# Convert to DataFrame and add metadata
177+
face_df = pd.DataFrame(face_data)
173178
face_df["face_id"] = idx
174179
face_df["species"] = row["species"]
175180
all_face_data.append(face_df)
176181

177-
# Add label
182+
# Add species label
178183
label_data.append({"x": center_x, "y": center_y - 0.45, "label": row["species"].title(), "species": row["species"]})
179184

185+
# Combine all face data
180186
faces_df = pd.concat(all_face_data, ignore_index=True)
181187
labels_df = pd.DataFrame(label_data)
182188

183-
# Separate dataframes for different face parts
189+
# Separate face parts for layering
184190
face_outline = faces_df[faces_df["part"] == "face"]
185191
eyes = faces_df[faces_df["part"].isin(["left_eye", "right_eye"])]
186192
pupils = faces_df[faces_df["part"].isin(["left_pupil", "right_pupil"])]
187193
mouth = faces_df[faces_df["part"] == "mouth"]
188194
brows = faces_df[faces_df["part"].isin(["left_brow", "right_brow"])]
189195
nose = faces_df[faces_df["part"] == "nose"]
190196

191-
# Create the plot
197+
# Create plot with theme-adaptive styling
198+
anyplot_theme = theme(
199+
plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),
200+
panel_background=element_rect(fill=PAGE_BG, color=PAGE_BG),
201+
panel_grid=element_blank(),
202+
axis_title=element_blank(),
203+
axis_text=element_blank(),
204+
axis_ticks=element_blank(),
205+
plot_title=element_text(size=24, face="bold", color=INK),
206+
legend_background=element_rect(fill=ELEVATED_BG, color=INK_SOFT),
207+
legend_title=element_text(size=18, color=INK),
208+
legend_text=element_text(size=16, color=INK_SOFT),
209+
legend_position="right",
210+
plot_margin=[40, 20, 20, 20],
211+
)
212+
192213
plot = (
193214
ggplot()
194-
# Face outlines (colored by species)
195215
+ geom_polygon(
196-
aes(x="x", y="y", group="face_id", fill="species"), data=face_outline, color="#333333", size=1.5, alpha=0.3
216+
aes(x="x", y="y", group="face_id", fill="species"), data=face_outline, color=INK_SOFT, size=1.5, alpha=0.3
197217
)
198-
# Eyes (white fill)
199-
+ geom_polygon(aes(x="x", y="y", group=["face_id", "part"]), data=eyes, fill="white", color="#333333", size=1.0)
200-
# Pupils (black fill)
201-
+ geom_polygon(aes(x="x", y="y", group=["face_id", "part"]), data=pupils, fill="#333333", color="#333333", size=0.5)
202-
# Mouth (line)
203-
+ geom_path(aes(x="x", y="y", group="face_id"), data=mouth, color="#333333", size=2.0)
204-
# Eyebrows (lines)
205-
+ geom_path(aes(x="x", y="y", group=["face_id", "part"]), data=brows, color="#333333", size=2.5)
206-
# Nose (line)
207-
+ geom_path(aes(x="x", y="y", group="face_id"), data=nose, color="#333333", size=1.5)
208-
# Species labels (no legend for text color)
209-
+ geom_text(aes(x="x", y="y", label="label"), data=labels_df, color="#333333", size=12, fontface="bold")
210-
# Color scale
218+
+ geom_polygon(aes(x="x", y="y", group=["face_id", "part"]), data=eyes, fill="white", color=INK_SOFT, size=1.0)
219+
+ geom_polygon(aes(x="x", y="y", group=["face_id", "part"]), data=pupils, fill=INK, color=INK, size=0.5)
220+
+ geom_path(aes(x="x", y="y", group="face_id"), data=mouth, color=INK, size=2.0)
221+
+ geom_path(aes(x="x", y="y", group=["face_id", "part"]), data=brows, color=INK, size=2.5)
222+
+ geom_path(aes(x="x", y="y", group="face_id"), data=nose, color=INK, size=1.5)
223+
+ geom_text(aes(x="x", y="y", label="label"), data=labels_df, color=INK_SOFT, size=12, fontface="bold")
211224
+ scale_fill_manual(values=species_colors)
212-
# Labels
213-
+ labs(title="Iris Species Comparison · chernoff-basic · lets-plot · pyplots.ai", fill="Species")
214-
# Theme
215-
+ theme(
216-
plot_title=element_text(size=24, face="bold"),
217-
axis_title=element_blank(),
218-
axis_text=element_blank(),
219-
axis_ticks=element_blank(),
220-
panel_grid=element_blank(),
221-
legend_title=element_text(size=16),
222-
legend_text=element_text(size=14),
223-
legend_position="right",
224-
plot_margin=[40, 20, 20, 20], # top, right, bottom, left
225-
)
225+
+ labs(title="chernoff-basic · letsplot · anyplot.ai", fill="Species")
226+
+ anyplot_theme
226227
+ ggsize(1600, 900)
227228
)
228229

229-
# Save as PNG (use path parameter to avoid subdirectory creation)
230-
ggsave(plot, "plot.png", scale=3, path=".")
231-
232-
# Save as HTML for interactivity
233-
ggsave(plot, "plot.html", path=".")
230+
# Save outputs
231+
ggsave(plot, f"plot-{THEME}.png", scale=3, path=".")
232+
ggsave(plot, f"plot-{THEME}.html", path=".")

0 commit comments

Comments
 (0)