Skip to content

Commit c21c341

Browse files
feat(plotnine): implement chernoff-basic (#6832)
## Implementation: `chernoff-basic` - python/plotnine Implements the **python/plotnine** version of `chernoff-basic`. **File:** `plots/chernoff-basic/implementations/python/plotnine.py` **Parent Issue:** #3003 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/anyplot/actions/runs/25925227381)* --------- 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 c5376f2 commit c21c341

2 files changed

Lines changed: 288 additions & 280 deletions

File tree

Lines changed: 112 additions & 141 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
1-
""" pyplots.ai
1+
""" anyplot.ai
22
chernoff-basic: Chernoff Faces for Multivariate Data
3-
Library: plotnine 0.15.2 | Python 3.13.11
4-
Quality: 91/100 | Created: 2025-12-31
3+
Library: plotnine 0.15.4 | Python 3.13.13
4+
Quality: 91/100 | Updated: 2026-05-15
55
"""
66

7+
import os
8+
79
import numpy as np
810
import pandas as pd
911
from plotnine import (
1012
aes,
1113
coord_fixed,
14+
element_rect,
1215
element_text,
1316
facet_wrap,
1417
geom_path,
@@ -22,70 +25,35 @@
2225
)
2326

2427

28+
THEME = os.getenv("ANYPLOT_THEME", "light")
29+
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
30+
ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420"
31+
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
32+
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
33+
2534
np.random.seed(42)
2635

27-
# Data: Car performance metrics (6 cars with 4 metrics each)
28-
# Metrics: Engine Power, Fuel Efficiency, Safety Rating, Comfort Score
36+
# Data: Car performance metrics with more extreme outliers
2937
car_data = {
3038
"observation_id": ["Compact A", "Compact B", "Sedan A", "Sedan B", "SUV A", "SUV B"],
3139
"category": ["Compact", "Compact", "Sedan", "Sedan", "SUV", "SUV"],
32-
"engine_power": [120, 140, 180, 200, 250, 280], # HP
33-
"fuel_efficiency": [35, 32, 28, 25, 22, 18], # MPG
34-
"safety_rating": [4.2, 4.5, 4.8, 4.6, 4.4, 4.7], # 1-5 scale
35-
"comfort_score": [3.5, 3.8, 4.2, 4.5, 4.0, 4.3], # 1-5 scale
40+
"engine_power": [100, 150, 180, 220, 280, 320],
41+
"fuel_efficiency": [38, 30, 26, 22, 18, 15],
42+
"safety_rating": [3.9, 4.5, 4.8, 4.2, 4.6, 4.9],
43+
"comfort_score": [3.0, 3.6, 4.5, 4.1, 4.0, 4.4],
3644
}
3745
sample_df = pd.DataFrame(car_data)
3846

39-
4047
# Normalize data to 0-1 range for facial feature mapping
41-
def normalize_column(col):
42-
return (col - col.min()) / (col.max() - col.min() + 1e-10)
43-
44-
45-
normalized = sample_df[["engine_power", "fuel_efficiency", "safety_rating", "comfort_score"]].apply(normalize_column)
46-
47-
# Feature mappings:
48-
# engine_power -> face width (0.6 to 1.0)
49-
# fuel_efficiency -> face height (0.8 to 1.2)
50-
# safety_rating -> eye size (0.08 to 0.18)
51-
# comfort_score -> mouth curvature (-0.3 to 0.3)
48+
normalized = sample_df[["engine_power", "fuel_efficiency", "safety_rating", "comfort_score"]].apply(
49+
lambda col: (col - col.min()) / (col.max() - col.min() + 1e-10)
50+
)
5251

5352
face_widths = 0.6 + normalized["engine_power"] * 0.4
5453
face_heights = 0.8 + normalized["fuel_efficiency"] * 0.4
5554
eye_sizes = 0.08 + normalized["safety_rating"] * 0.1
5655
mouth_curvatures = -0.3 + normalized["comfort_score"] * 0.6
5756

58-
59-
# Generate face polygon vertices (ellipse approximation)
60-
def make_ellipse(cx, cy, rx, ry, n_points=50):
61-
theta = np.linspace(0, 2 * np.pi, n_points)
62-
x = cx + rx * np.cos(theta)
63-
y = cy + ry * np.sin(theta)
64-
return x, y
65-
66-
67-
# Generate eye vertices
68-
def make_eye(cx, cy, r, n_points=20):
69-
theta = np.linspace(0, 2 * np.pi, n_points)
70-
x = cx + r * np.cos(theta)
71-
y = cy + r * np.sin(theta)
72-
return x, y
73-
74-
75-
# Generate mouth curve
76-
def make_mouth(cx, cy, width, curvature, n_points=20):
77-
x = np.linspace(cx - width / 2, cx + width / 2, n_points)
78-
y = cy + curvature * (((x - cx) / (width / 2)) ** 2 - 1)
79-
return x, y
80-
81-
82-
# Generate eyebrow
83-
def make_eyebrow(cx, cy, width, slant, n_points=10):
84-
x = np.linspace(cx - width / 2, cx + width / 2, n_points)
85-
y = cy + slant * (x - cx) / (width / 2)
86-
return x, y
87-
88-
8957
# Build data for all faces
9058
all_data = []
9159

@@ -99,146 +67,149 @@ def make_eyebrow(cx, cy, width, slant, n_points=10):
9967
mc = mouth_curvatures.iloc[idx]
10068

10169
# Face outline
102-
fx, fy = make_ellipse(0, 0, fw, fh)
70+
theta = np.linspace(0, 2 * np.pi, 50)
71+
fx = fw * np.cos(theta)
72+
fy = fh * np.sin(theta)
10373
for i in range(len(fx)):
104-
all_data.append(
105-
{"observation_id": obs_id, "category": category, "part": "face", "x": fx[i], "y": fy[i], "order": i}
106-
)
74+
all_data.append({"observation_id": obs_id, "category": category, "part": "face", "x": fx[i], "y": fy[i]})
10775

10876
# Left eye
109-
ex, ey = make_eye(-fw * 0.35, fh * 0.25, es)
77+
theta = np.linspace(0, 2 * np.pi, 20)
78+
ex = -fw * 0.35 + es * np.cos(theta)
79+
ey = fh * 0.25 + es * np.sin(theta)
11080
for i in range(len(ex)):
111-
all_data.append(
112-
{"observation_id": obs_id, "category": category, "part": "left_eye", "x": ex[i], "y": ey[i], "order": i}
113-
)
81+
all_data.append({"observation_id": obs_id, "category": category, "part": "left_eye", "x": ex[i], "y": ey[i]})
11482

11583
# Right eye
116-
ex, ey = make_eye(fw * 0.35, fh * 0.25, es)
84+
ex = fw * 0.35 + es * np.cos(theta)
85+
ey = fh * 0.25 + es * np.sin(theta)
11786
for i in range(len(ex)):
118-
all_data.append(
119-
{"observation_id": obs_id, "category": category, "part": "right_eye", "x": ex[i], "y": ey[i], "order": i}
120-
)
87+
all_data.append({"observation_id": obs_id, "category": category, "part": "right_eye", "x": ex[i], "y": ey[i]})
12188

122-
# Left pupil (point)
89+
# Left pupil (larger for visibility)
12390
all_data.append(
124-
{
125-
"observation_id": obs_id,
126-
"category": category,
127-
"part": "left_pupil",
128-
"x": -fw * 0.35,
129-
"y": fh * 0.25,
130-
"order": 0,
131-
}
91+
{"observation_id": obs_id, "category": category, "part": "left_pupil", "x": -fw * 0.35, "y": fh * 0.25}
13292
)
13393

134-
# Right pupil (point)
94+
# Right pupil (larger for visibility)
13595
all_data.append(
136-
{
137-
"observation_id": obs_id,
138-
"category": category,
139-
"part": "right_pupil",
140-
"x": fw * 0.35,
141-
"y": fh * 0.25,
142-
"order": 0,
143-
}
96+
{"observation_id": obs_id, "category": category, "part": "right_pupil", "x": fw * 0.35, "y": fh * 0.25}
14497
)
14598

14699
# Mouth
147-
mx, my = make_mouth(0, -fh * 0.35, fw * 0.5, mc)
148-
for i in range(len(mx)):
100+
x_mouth = np.linspace(-fw * 0.25, fw * 0.25, 20)
101+
y_mouth = -fh * 0.35 + mc * (((x_mouth) / (fw * 0.25)) ** 2 - 1)
102+
for i in range(len(x_mouth)):
149103
all_data.append(
150-
{"observation_id": obs_id, "category": category, "part": "mouth", "x": mx[i], "y": my[i], "order": i}
104+
{"observation_id": obs_id, "category": category, "part": "mouth", "x": x_mouth[i], "y": y_mouth[i]}
151105
)
152106

153-
# Nose (simple vertical line)
154-
all_data.append({"observation_id": obs_id, "category": category, "part": "nose", "x": 0, "y": fh * 0.1, "order": 0})
155-
all_data.append(
156-
{"observation_id": obs_id, "category": category, "part": "nose", "x": 0, "y": -fh * 0.1, "order": 1}
157-
)
107+
# Nose
108+
all_data.append({"observation_id": obs_id, "category": category, "part": "nose", "x": 0, "y": fh * 0.1})
109+
all_data.append({"observation_id": obs_id, "category": category, "part": "nose", "x": 0, "y": -fh * 0.1})
158110

159111
# Left eyebrow
160-
bx, by = make_eyebrow(-fw * 0.35, fh * 0.45, es * 2.5, 0.05)
161-
for i in range(len(bx)):
112+
x_brow = np.linspace(-fw * 0.35 - es, -fw * 0.35 + es, 10)
113+
y_brow = fh * 0.45 + 0.05 * (x_brow + fw * 0.35) / es
114+
for i in range(len(x_brow)):
162115
all_data.append(
163-
{"observation_id": obs_id, "category": category, "part": "left_eyebrow", "x": bx[i], "y": by[i], "order": i}
116+
{"observation_id": obs_id, "category": category, "part": "left_eyebrow", "x": x_brow[i], "y": y_brow[i]}
164117
)
165118

166119
# Right eyebrow
167-
bx, by = make_eyebrow(fw * 0.35, fh * 0.45, es * 2.5, -0.05)
168-
for i in range(len(bx)):
120+
x_brow = np.linspace(fw * 0.35 - es, fw * 0.35 + es, 10)
121+
y_brow = fh * 0.45 - 0.05 * (x_brow - fw * 0.35) / es
122+
for i in range(len(x_brow)):
169123
all_data.append(
170-
{
171-
"observation_id": obs_id,
172-
"category": category,
173-
"part": "right_eyebrow",
174-
"x": bx[i],
175-
"y": by[i],
176-
"order": i,
177-
}
124+
{"observation_id": obs_id, "category": category, "part": "right_eyebrow", "x": x_brow[i], "y": y_brow[i]}
178125
)
179126

180127
plot_df = pd.DataFrame(all_data)
181128

182-
# Separate dataframes for different geoms
183-
face_df = plot_df[plot_df["part"] == "face"].copy()
184-
left_eye_df = plot_df[plot_df["part"] == "left_eye"].copy()
185-
right_eye_df = plot_df[plot_df["part"] == "right_eye"].copy()
186-
left_pupil_df = plot_df[plot_df["part"] == "left_pupil"].copy()
187-
right_pupil_df = plot_df[plot_df["part"] == "right_pupil"].copy()
188-
mouth_df = plot_df[plot_df["part"] == "mouth"].copy()
189-
nose_df = plot_df[plot_df["part"] == "nose"].copy()
190-
left_eyebrow_df = plot_df[plot_df["part"] == "left_eyebrow"].copy()
191-
right_eyebrow_df = plot_df[plot_df["part"] == "right_eyebrow"].copy()
192-
193-
# Category colors
194-
category_colors = {"Compact": "#306998", "Sedan": "#FFD43B", "SUV": "#4B8BBE"}
195-
196-
# Create the plot using native plotnine geoms
129+
# Okabe-Ito palette
130+
category_colors = {"Compact": "#009E73", "Sedan": "#D55E00", "SUV": "#0072B2"}
131+
132+
anyplot_theme = theme(
133+
figure_size=(16, 9),
134+
plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),
135+
panel_background=element_rect(fill=PAGE_BG, color=PAGE_BG),
136+
plot_title=element_text(size=24, weight="bold", color=INK, ha="center"),
137+
plot_subtitle=element_text(size=16, color=INK_SOFT, ha="center"),
138+
legend_position="bottom",
139+
legend_background=element_rect(fill=ELEVATED_BG, color=INK_SOFT),
140+
legend_title=element_text(size=16, color=INK),
141+
legend_text=element_text(size=14, color=INK_SOFT),
142+
strip_text=element_text(size=14, color=INK, weight="bold"),
143+
)
144+
197145
plot = (
198146
ggplot()
199147
# Face outline (filled polygon)
200148
+ geom_polygon(
201-
data=face_df, mapping=aes(x="x", y="y", group="observation_id", fill="category"), color="#333333", size=1.5
149+
data=plot_df[plot_df["part"] == "face"],
150+
mapping=aes(x="x", y="y", group="observation_id", fill="category"),
151+
color=INK_SOFT,
152+
size=1.5,
202153
)
203-
# Eyes (white filled)
154+
# Eyes (white/elevated filled)
204155
+ geom_polygon(
205-
data=left_eye_df, mapping=aes(x="x", y="y", group="observation_id"), fill="white", color="#333333", size=0.8
156+
data=plot_df[plot_df["part"] == "left_eye"],
157+
mapping=aes(x="x", y="y", group="observation_id"),
158+
fill=ELEVATED_BG,
159+
color=INK_SOFT,
160+
size=0.8,
206161
)
207162
+ geom_polygon(
208-
data=right_eye_df, mapping=aes(x="x", y="y", group="observation_id"), fill="white", color="#333333", size=0.8
163+
data=plot_df[plot_df["part"] == "right_eye"],
164+
mapping=aes(x="x", y="y", group="observation_id"),
165+
fill=ELEVATED_BG,
166+
color=INK_SOFT,
167+
size=0.8,
209168
)
210-
# Pupils
211-
+ geom_point(data=left_pupil_df, mapping=aes(x="x", y="y"), color="#333333", size=3)
212-
+ geom_point(data=right_pupil_df, mapping=aes(x="x", y="y"), color="#333333", size=3)
169+
# Pupils (larger for visibility)
170+
+ geom_point(data=plot_df[plot_df["part"] == "left_pupil"], mapping=aes(x="x", y="y"), color=INK_SOFT, size=5)
171+
+ geom_point(data=plot_df[plot_df["part"] == "right_pupil"], mapping=aes(x="x", y="y"), color=INK_SOFT, size=5)
213172
# Mouth
214-
+ geom_path(data=mouth_df, mapping=aes(x="x", y="y", group="observation_id"), color="#333333", size=1.2)
173+
+ geom_path(
174+
data=plot_df[plot_df["part"] == "mouth"],
175+
mapping=aes(x="x", y="y", group="observation_id"),
176+
color=INK_SOFT,
177+
size=1.2,
178+
)
215179
# Nose
216-
+ geom_path(data=nose_df, mapping=aes(x="x", y="y", group="observation_id"), color="#333333", size=1)
180+
+ geom_path(
181+
data=plot_df[plot_df["part"] == "nose"],
182+
mapping=aes(x="x", y="y", group="observation_id"),
183+
color=INK_SOFT,
184+
size=1,
185+
)
217186
# Eyebrows
218-
+ geom_path(data=left_eyebrow_df, mapping=aes(x="x", y="y", group="observation_id"), color="#333333", size=1.2)
219-
+ geom_path(data=right_eyebrow_df, mapping=aes(x="x", y="y", group="observation_id"), color="#333333", size=1.2)
187+
+ geom_path(
188+
data=plot_df[plot_df["part"] == "left_eyebrow"],
189+
mapping=aes(x="x", y="y", group="observation_id"),
190+
color=INK_SOFT,
191+
size=1.2,
192+
)
193+
+ geom_path(
194+
data=plot_df[plot_df["part"] == "right_eyebrow"],
195+
mapping=aes(x="x", y="y", group="observation_id"),
196+
color=INK_SOFT,
197+
size=1.2,
198+
)
220199
# Facet by observation
221200
+ facet_wrap("~observation_id", ncol=3)
222-
# Colors
201+
# Colors (Okabe-Ito palette)
223202
+ scale_fill_manual(values=category_colors)
224203
# Labels
225204
+ labs(
226-
title="chernoff-basic · plotnine · pyplots.ai",
205+
title="chernoff-basic · plotnine · anyplot.ai",
227206
subtitle="Car Performance: Power/Efficiency/Safety/Comfort mapped to facial features",
228207
fill="Category",
229208
)
230209
# Theme
231210
+ theme_void()
232-
+ theme(
233-
figure_size=(16, 9),
234-
plot_title=element_text(size=24, ha="center", weight="bold"),
235-
plot_subtitle=element_text(size=16, ha="center"),
236-
legend_title=element_text(size=16),
237-
legend_text=element_text(size=14),
238-
strip_text=element_text(size=14, weight="bold"),
239-
legend_position="bottom",
240-
)
211+
+ anyplot_theme
241212
+ coord_fixed(ratio=1)
242213
)
243214

244-
plot.save("plot.png", dpi=300, width=16, height=9)
215+
plot.save(f"plot-{THEME}.png", dpi=300)

0 commit comments

Comments
 (0)