|
1 | | -""" pyplots.ai |
| 1 | +""" anyplot.ai |
2 | 2 | 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 |
5 | 5 | """ |
6 | 6 |
|
| 7 | +import os |
| 8 | + |
7 | 9 | import numpy as np |
8 | 10 | import pandas as pd |
9 | 11 | from lets_plot import ( |
10 | 12 | LetsPlot, |
11 | 13 | aes, |
12 | 14 | element_blank, |
| 15 | + element_rect, |
13 | 16 | element_text, |
14 | 17 | geom_path, |
15 | 18 | geom_polygon, |
|
26 | 29 |
|
27 | 30 | LetsPlot.setup_html() |
28 | 31 |
|
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 |
30 | 40 | np.random.seed(42) |
31 | 41 | iris = load_iris() |
32 | 42 | df = pd.DataFrame(iris.data, columns=["sepal_length", "sepal_width", "petal_length", "petal_width"]) |
33 | 43 | df["species"] = [iris.target_names[i] for i in iris.target] |
34 | 44 |
|
35 | | -# Sample 12 flowers (4 per species) for clear visualization |
| 45 | +# Sample 12 flowers (4 per species) |
36 | 46 | sample_idx = [] |
37 | 47 | for species in range(3): |
38 | 48 | species_idx = np.where(iris.target == species)[0] |
39 | 49 | sample_idx.extend(np.random.choice(species_idx, 4, replace=False)) |
40 | 50 | df_sample = df.iloc[sample_idx].reset_index(drop=True) |
41 | 51 |
|
42 | | -# Normalize data to 0-1 range for facial feature mapping |
| 52 | +# Normalize data to 0-1 range |
43 | 53 | features = ["sepal_length", "sepal_width", "petal_length", "petal_width"] |
44 | 54 | for col in features: |
45 | 55 | min_val = df_sample[col].min() |
46 | 56 | max_val = df_sample[col].max() |
47 | 57 | df_sample[col + "_norm"] = (df_sample[col] - min_val) / (max_val - min_val) |
48 | 58 |
|
| 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 |
49 | 71 |
|
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"] |
57 | 76 |
|
| 77 | + scale = 0.42 |
58 | 78 | face_data = [] |
59 | 79 |
|
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 |
62 | 82 | face_height = 0.45 |
63 | 83 | theta = np.linspace(0, 2 * np.pi, 50) |
64 | 84 | face_x = center_x + scale * face_width * np.cos(theta) |
65 | 85 | face_y = center_y + scale * face_height * np.sin(theta) |
66 | 86 | for i in range(len(theta)): |
67 | 87 | face_data.append({"x": face_x[i], "y": face_y[i], "part": "face", "order": i}) |
68 | 88 |
|
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 |
71 | 91 | eye_y = center_y + scale * 0.12 |
72 | 92 | eye_spacing = 0.12 |
73 | 93 |
|
@@ -96,19 +116,19 @@ def create_face(row_data, center_x, center_y, scale=0.4): |
96 | 116 | for i in range(len(theta_eye)): |
97 | 117 | face_data.append({"x": right_pupil_x[i], "y": right_pupil_y[i], "part": "right_pupil", "order": i}) |
98 | 118 |
|
99 | | - # Mouth - curvature controlled by petal_length |
| 119 | + # Mouth |
100 | 120 | mouth_y = center_y - scale * 0.15 |
101 | 121 | 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 |
103 | 123 | mouth_x = np.linspace(-mouth_width, mouth_width, 20) |
104 | 124 | mouth_curve_y = mouth_y + scale * curvature * (1 - (mouth_x / mouth_width) ** 2) |
105 | 125 | mouth_curve_x = center_x + scale * mouth_x |
106 | 126 | for i in range(len(mouth_x)): |
107 | 127 | face_data.append({"x": mouth_curve_x[i], "y": mouth_curve_y[i], "part": "mouth", "order": i}) |
108 | 128 |
|
109 | | - # Eyebrows - slant controlled by petal_width |
| 129 | + # Eyebrows |
110 | 130 | 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 |
112 | 132 | brow_length = 0.06 |
113 | 133 |
|
114 | 134 | # Left eyebrow |
@@ -147,87 +167,66 @@ def create_face(row_data, center_x, center_y, scale=0.4): |
147 | 167 | } |
148 | 168 | ) |
149 | 169 |
|
150 | | - # Nose - simple vertical line |
| 170 | + # Nose |
151 | 171 | nose_top = center_y + scale * 0.02 |
152 | 172 | nose_bottom = center_y - scale * 0.08 |
153 | 173 | face_data.append({"x": center_x, "y": nose_top, "part": "nose", "order": 0}) |
154 | 174 | face_data.append({"x": center_x, "y": nose_bottom, "part": "nose", "order": 1}) |
155 | 175 |
|
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) |
173 | 178 | face_df["face_id"] = idx |
174 | 179 | face_df["species"] = row["species"] |
175 | 180 | all_face_data.append(face_df) |
176 | 181 |
|
177 | | - # Add label |
| 182 | + # Add species label |
178 | 183 | label_data.append({"x": center_x, "y": center_y - 0.45, "label": row["species"].title(), "species": row["species"]}) |
179 | 184 |
|
| 185 | +# Combine all face data |
180 | 186 | faces_df = pd.concat(all_face_data, ignore_index=True) |
181 | 187 | labels_df = pd.DataFrame(label_data) |
182 | 188 |
|
183 | | -# Separate dataframes for different face parts |
| 189 | +# Separate face parts for layering |
184 | 190 | face_outline = faces_df[faces_df["part"] == "face"] |
185 | 191 | eyes = faces_df[faces_df["part"].isin(["left_eye", "right_eye"])] |
186 | 192 | pupils = faces_df[faces_df["part"].isin(["left_pupil", "right_pupil"])] |
187 | 193 | mouth = faces_df[faces_df["part"] == "mouth"] |
188 | 194 | brows = faces_df[faces_df["part"].isin(["left_brow", "right_brow"])] |
189 | 195 | nose = faces_df[faces_df["part"] == "nose"] |
190 | 196 |
|
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 | + |
192 | 213 | plot = ( |
193 | 214 | ggplot() |
194 | | - # Face outlines (colored by species) |
195 | 215 | + 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 |
197 | 217 | ) |
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") |
211 | 224 | + 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 |
226 | 227 | + ggsize(1600, 900) |
227 | 228 | ) |
228 | 229 |
|
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