|
| 1 | +""" pyplots.ai |
| 2 | +chernoff-basic: Chernoff Faces for Multivariate Data |
| 3 | +Library: pygal 3.1.0 | Python 3.13.11 |
| 4 | +Quality: 90/100 | Created: 2025-12-31 |
| 5 | +""" |
| 6 | + |
| 7 | +import xml.etree.ElementTree as ET |
| 8 | + |
| 9 | +import cairosvg |
| 10 | +import numpy as np |
| 11 | +import pygal |
| 12 | +from pygal.style import Style |
| 13 | + |
| 14 | + |
| 15 | +# Set seed for reproducibility |
| 16 | +np.random.seed(42) |
| 17 | + |
| 18 | +# Data - Car performance metrics (5 cars, 7 attributes each) |
| 19 | +# Attributes: Engine Power, Fuel Efficiency, Safety Rating, Comfort, Reliability, Price Value, Handling |
| 20 | +car_names = ["Sedan A", "SUV B", "Sports C", "Compact D", "Luxury E"] |
| 21 | + |
| 22 | +# Normalized data (0-1 scale) for each car's attributes |
| 23 | +# Each row: [face_width, face_height, eye_size, eye_spacing, mouth_curve, nose_length, eyebrow_slant] |
| 24 | +car_data = np.array( |
| 25 | + [ |
| 26 | + [0.6, 0.5, 0.7, 0.5, 0.8, 0.4, 0.5], # Sedan A - balanced, happy |
| 27 | + [0.8, 0.7, 0.5, 0.6, 0.4, 0.7, 0.3], # SUV B - large, serious |
| 28 | + [0.4, 0.6, 0.9, 0.4, 0.9, 0.3, 0.7], # Sports C - narrow, excited |
| 29 | + [0.5, 0.4, 0.6, 0.5, 0.6, 0.5, 0.5], # Compact D - small, neutral |
| 30 | + [0.7, 0.8, 0.8, 0.7, 0.7, 0.6, 0.6], # Luxury E - large, pleasant |
| 31 | + ] |
| 32 | +) |
| 33 | + |
| 34 | +# Group colors for cars (colorblind-safe palette) |
| 35 | +face_colors = ["#306998", "#FFD43B", "#4ECDC4", "#FF7043", "#9C88FF"] |
| 36 | + |
| 37 | +# SVG namespace |
| 38 | +SVG_NS = "http://www.w3.org/2000/svg" |
| 39 | +ET.register_namespace("", SVG_NS) |
| 40 | + |
| 41 | +# Custom style for pygal |
| 42 | +custom_style = Style( |
| 43 | + background="white", |
| 44 | + plot_background="white", |
| 45 | + foreground="#333333", |
| 46 | + foreground_strong="#333333", |
| 47 | + foreground_subtle="#666666", |
| 48 | + colors=tuple(face_colors), |
| 49 | + title_font_size=72, |
| 50 | + label_font_size=36, |
| 51 | + major_label_font_size=32, |
| 52 | + legend_font_size=36, |
| 53 | + value_font_size=28, |
| 54 | +) |
| 55 | + |
| 56 | +# Create a base pygal XY chart to leverage its SVG rendering infrastructure |
| 57 | +chart = pygal.XY( |
| 58 | + width=4800, |
| 59 | + height=2700, |
| 60 | + style=custom_style, |
| 61 | + show_legend=False, |
| 62 | + show_x_guides=False, |
| 63 | + show_y_guides=False, |
| 64 | + show_x_labels=False, |
| 65 | + show_y_labels=False, |
| 66 | + show_dots=False, |
| 67 | + margin=50, |
| 68 | + no_data_text="", |
| 69 | +) |
| 70 | + |
| 71 | +# Add dummy data to create valid chart structure (hidden) |
| 72 | +chart.add("", []) |
| 73 | + |
| 74 | +# Render to SVG string |
| 75 | +svg_string = chart.render().decode("utf-8") |
| 76 | + |
| 77 | +# Parse SVG and add custom Chernoff faces |
| 78 | +svg_tree = ET.fromstring(svg_string) |
| 79 | + |
| 80 | +# Create faces group |
| 81 | +faces_group = ET.SubElement(svg_tree, f"{{{SVG_NS}}}g") |
| 82 | +faces_group.set("id", "chernoff-faces") |
| 83 | + |
| 84 | +# Calculate face positions in a grid (5 faces in a row) |
| 85 | +cols = 5 |
| 86 | +face_size = 520 |
| 87 | +margin_x = 500 |
| 88 | +spacing_x = (4800 - 2 * margin_x) / (cols - 1) if cols > 1 else 0 |
| 89 | +base_cy = 950 # Center faces vertically for better canvas utilization |
| 90 | + |
| 91 | +# Draw each Chernoff face inline (KISS structure - no functions) |
| 92 | +for i, (name, data, color) in enumerate(zip(car_names, car_data, face_colors, strict=True)): |
| 93 | + col = i % cols |
| 94 | + cx = margin_x + col * spacing_x |
| 95 | + cy = base_cy |
| 96 | + |
| 97 | + # Calculate facial feature parameters from data |
| 98 | + scale = face_size / 200 |
| 99 | + face_width_factor = 0.6 + data[0] * 0.4 |
| 100 | + face_height_factor = 0.6 + data[1] * 0.4 |
| 101 | + eye_size = (12 + data[2] * 28) * scale |
| 102 | + eye_spacing = (30 + data[3] * 40) * scale |
| 103 | + mouth_curve = (-50 + data[4] * 100) * scale |
| 104 | + nose_length = (20 + data[5] * 35) * scale |
| 105 | + eyebrow_slant = -20 + data[6] * 40 |
| 106 | + |
| 107 | + face_width = face_size * face_width_factor * 0.45 |
| 108 | + face_height = face_size * face_height_factor * 0.55 |
| 109 | + |
| 110 | + # Create group for this face |
| 111 | + face_group = ET.SubElement(faces_group, f"{{{SVG_NS}}}g") |
| 112 | + face_group.set("id", f"face-{name.replace(' ', '-')}") |
| 113 | + |
| 114 | + # Face outline (ellipse) |
| 115 | + face_ellipse = ET.SubElement(face_group, f"{{{SVG_NS}}}ellipse") |
| 116 | + face_ellipse.set("cx", str(cx)) |
| 117 | + face_ellipse.set("cy", str(cy)) |
| 118 | + face_ellipse.set("rx", str(face_width)) |
| 119 | + face_ellipse.set("ry", str(face_height)) |
| 120 | + face_ellipse.set("fill", color) |
| 121 | + face_ellipse.set("fill-opacity", "0.3") |
| 122 | + face_ellipse.set("stroke", color) |
| 123 | + face_ellipse.set("stroke-width", str(max(4, 3 * scale))) |
| 124 | + |
| 125 | + # Left eye |
| 126 | + left_eye_x = cx - eye_spacing * 0.5 |
| 127 | + left_eye_y = cy - face_height * 0.2 |
| 128 | + left_eye = ET.SubElement(face_group, f"{{{SVG_NS}}}circle") |
| 129 | + left_eye.set("cx", str(left_eye_x)) |
| 130 | + left_eye.set("cy", str(left_eye_y)) |
| 131 | + left_eye.set("r", str(eye_size)) |
| 132 | + left_eye.set("fill", "white") |
| 133 | + left_eye.set("stroke", "#333") |
| 134 | + left_eye.set("stroke-width", str(max(3, 2.5 * scale))) |
| 135 | + |
| 136 | + # Left pupil |
| 137 | + left_pupil = ET.SubElement(face_group, f"{{{SVG_NS}}}circle") |
| 138 | + left_pupil.set("cx", str(left_eye_x)) |
| 139 | + left_pupil.set("cy", str(left_eye_y)) |
| 140 | + left_pupil.set("r", str(eye_size * 0.4)) |
| 141 | + left_pupil.set("fill", "#333") |
| 142 | + |
| 143 | + # Right eye |
| 144 | + right_eye_x = cx + eye_spacing * 0.5 |
| 145 | + right_eye_y = cy - face_height * 0.2 |
| 146 | + right_eye = ET.SubElement(face_group, f"{{{SVG_NS}}}circle") |
| 147 | + right_eye.set("cx", str(right_eye_x)) |
| 148 | + right_eye.set("cy", str(right_eye_y)) |
| 149 | + right_eye.set("r", str(eye_size)) |
| 150 | + right_eye.set("fill", "white") |
| 151 | + right_eye.set("stroke", "#333") |
| 152 | + right_eye.set("stroke-width", str(max(3, 2.5 * scale))) |
| 153 | + |
| 154 | + # Right pupil |
| 155 | + right_pupil = ET.SubElement(face_group, f"{{{SVG_NS}}}circle") |
| 156 | + right_pupil.set("cx", str(right_eye_x)) |
| 157 | + right_pupil.set("cy", str(right_eye_y)) |
| 158 | + right_pupil.set("r", str(eye_size * 0.4)) |
| 159 | + right_pupil.set("fill", "#333") |
| 160 | + |
| 161 | + # Eyebrows |
| 162 | + brow_length = eye_size * 2.2 |
| 163 | + brow_y_offset = eye_size + 20 * scale |
| 164 | + slant_offset = np.tan(np.radians(eyebrow_slant)) * brow_length * 0.5 |
| 165 | + |
| 166 | + # Left eyebrow |
| 167 | + left_brow = ET.SubElement(face_group, f"{{{SVG_NS}}}line") |
| 168 | + left_brow.set("x1", str(left_eye_x - brow_length * 0.5)) |
| 169 | + left_brow.set("y1", str(left_eye_y - brow_y_offset + slant_offset)) |
| 170 | + left_brow.set("x2", str(left_eye_x + brow_length * 0.5)) |
| 171 | + left_brow.set("y2", str(left_eye_y - brow_y_offset - slant_offset)) |
| 172 | + left_brow.set("stroke", "#333") |
| 173 | + left_brow.set("stroke-width", str(max(5, 4 * scale))) |
| 174 | + left_brow.set("stroke-linecap", "round") |
| 175 | + |
| 176 | + # Right eyebrow (mirrored slant) |
| 177 | + right_brow = ET.SubElement(face_group, f"{{{SVG_NS}}}line") |
| 178 | + right_brow.set("x1", str(right_eye_x - brow_length * 0.5)) |
| 179 | + right_brow.set("y1", str(right_eye_y - brow_y_offset - slant_offset)) |
| 180 | + right_brow.set("x2", str(right_eye_x + brow_length * 0.5)) |
| 181 | + right_brow.set("y2", str(right_eye_y - brow_y_offset + slant_offset)) |
| 182 | + right_brow.set("stroke", "#333") |
| 183 | + right_brow.set("stroke-width", str(max(5, 4 * scale))) |
| 184 | + right_brow.set("stroke-linecap", "round") |
| 185 | + |
| 186 | + # Nose (vertical line) |
| 187 | + nose = ET.SubElement(face_group, f"{{{SVG_NS}}}line") |
| 188 | + nose.set("x1", str(cx)) |
| 189 | + nose.set("y1", str(cy - nose_length * 0.3)) |
| 190 | + nose.set("x2", str(cx)) |
| 191 | + nose.set("y2", str(cy + nose_length * 0.5)) |
| 192 | + nose.set("stroke", "#333") |
| 193 | + nose.set("stroke-width", str(max(4, 3.5 * scale))) |
| 194 | + nose.set("stroke-linecap", "round") |
| 195 | + |
| 196 | + # Mouth (quadratic bezier curve) |
| 197 | + mouth_y = cy + face_height * 0.45 |
| 198 | + mouth_width = face_width * 0.55 |
| 199 | + mouth_path = f"M {cx - mouth_width} {mouth_y} Q {cx} {mouth_y + mouth_curve} {cx + mouth_width} {mouth_y}" |
| 200 | + mouth = ET.SubElement(face_group, f"{{{SVG_NS}}}path") |
| 201 | + mouth.set("d", mouth_path) |
| 202 | + mouth.set("fill", "none") |
| 203 | + mouth.set("stroke", "#333") |
| 204 | + mouth.set("stroke-width", str(max(5, 4 * scale))) |
| 205 | + mouth.set("stroke-linecap", "round") |
| 206 | + |
| 207 | + # Label below face |
| 208 | + label_elem = ET.SubElement(face_group, f"{{{SVG_NS}}}text") |
| 209 | + label_elem.set("x", str(cx)) |
| 210 | + label_elem.set("y", str(cy + face_height + 65)) |
| 211 | + label_elem.set("text-anchor", "middle") |
| 212 | + label_elem.set("font-family", "sans-serif") |
| 213 | + label_elem.set("font-size", str(max(36, 28 * scale))) |
| 214 | + label_elem.set("font-weight", "bold") |
| 215 | + label_elem.set("fill", "#333") |
| 216 | + label_elem.text = name |
| 217 | + |
| 218 | +# Add title |
| 219 | +title_elem = ET.SubElement(svg_tree, f"{{{SVG_NS}}}text") |
| 220 | +title_elem.set("x", "2400") |
| 221 | +title_elem.set("y", "100") |
| 222 | +title_elem.set("text-anchor", "middle") |
| 223 | +title_elem.set("font-family", "sans-serif") |
| 224 | +title_elem.set("font-size", "72") |
| 225 | +title_elem.set("font-weight", "bold") |
| 226 | +title_elem.set("fill", "#333") |
| 227 | +title_elem.text = "Car Performance Comparison · chernoff-basic · pygal · pyplots.ai" |
| 228 | + |
| 229 | +# Add legend for attributes - positioned closer to faces |
| 230 | +legend_y = 1680 |
| 231 | +legend_x_start = 400 |
| 232 | + |
| 233 | +legend_title = ET.SubElement(svg_tree, f"{{{SVG_NS}}}text") |
| 234 | +legend_title.set("x", str(legend_x_start)) |
| 235 | +legend_title.set("y", str(legend_y)) |
| 236 | +legend_title.set("font-family", "sans-serif") |
| 237 | +legend_title.set("font-size", "44") |
| 238 | +legend_title.set("font-weight", "bold") |
| 239 | +legend_title.set("fill", "#333") |
| 240 | +legend_title.text = "Feature Mappings:" |
| 241 | + |
| 242 | +feature_mappings = [ |
| 243 | + "Face Width = Engine Power", |
| 244 | + "Face Height = Fuel Efficiency", |
| 245 | + "Eye Size = Safety Rating", |
| 246 | + "Eye Spacing = Comfort", |
| 247 | + "Mouth Curve = Reliability", |
| 248 | + "Nose Length = Price Value", |
| 249 | + "Eyebrow Slant = Handling", |
| 250 | +] |
| 251 | + |
| 252 | +# Add feature mappings as legend items (two rows) |
| 253 | +for i, mapping in enumerate(feature_mappings): |
| 254 | + row = i // 4 |
| 255 | + col = i % 4 |
| 256 | + text_elem = ET.SubElement(svg_tree, f"{{{SVG_NS}}}text") |
| 257 | + text_elem.set("x", str(legend_x_start + col * 1150)) |
| 258 | + text_elem.set("y", str(legend_y + 80 + row * 70)) |
| 259 | + text_elem.set("font-family", "sans-serif") |
| 260 | + text_elem.set("font-size", "36") |
| 261 | + text_elem.set("fill", "#555") |
| 262 | + text_elem.text = mapping |
| 263 | + |
| 264 | +# Add color legend for car identification |
| 265 | +color_legend_y = legend_y + 230 |
| 266 | +color_legend_x = 400 |
| 267 | + |
| 268 | +color_title = ET.SubElement(svg_tree, f"{{{SVG_NS}}}text") |
| 269 | +color_title.set("x", str(color_legend_x)) |
| 270 | +color_title.set("y", str(color_legend_y)) |
| 271 | +color_title.set("font-family", "sans-serif") |
| 272 | +color_title.set("font-size", "44") |
| 273 | +color_title.set("font-weight", "bold") |
| 274 | +color_title.set("fill", "#333") |
| 275 | +color_title.text = "Cars:" |
| 276 | + |
| 277 | +for i, (name, color) in enumerate(zip(car_names, face_colors, strict=True)): |
| 278 | + # Color swatch |
| 279 | + swatch = ET.SubElement(svg_tree, f"{{{SVG_NS}}}rect") |
| 280 | + swatch.set("x", str(color_legend_x + 150 + i * 850)) |
| 281 | + swatch.set("y", str(color_legend_y - 30)) |
| 282 | + swatch.set("width", "40") |
| 283 | + swatch.set("height", "40") |
| 284 | + swatch.set("fill", color) |
| 285 | + swatch.set("rx", "5") |
| 286 | + |
| 287 | + # Car name |
| 288 | + name_elem = ET.SubElement(svg_tree, f"{{{SVG_NS}}}text") |
| 289 | + name_elem.set("x", str(color_legend_x + 200 + i * 850)) |
| 290 | + name_elem.set("y", str(color_legend_y)) |
| 291 | + name_elem.set("font-family", "sans-serif") |
| 292 | + name_elem.set("font-size", "36") |
| 293 | + name_elem.set("fill", "#555") |
| 294 | + name_elem.text = name |
| 295 | + |
| 296 | +# Convert back to string |
| 297 | +final_svg = ET.tostring(svg_tree, encoding="unicode") |
| 298 | + |
| 299 | +# Add XML declaration |
| 300 | +final_svg = '<?xml version="1.0" encoding="UTF-8"?>\n' + final_svg |
| 301 | + |
| 302 | +# Save as SVG file |
| 303 | +with open("plot.svg", "w") as f: |
| 304 | + f.write(final_svg) |
| 305 | + |
| 306 | +# Use cairosvg to convert to PNG |
| 307 | +cairosvg.svg2png(bytestring=final_svg.encode(), write_to="plot.png", output_width=4800, output_height=2700) |
| 308 | + |
| 309 | +# Save HTML for interactive version |
| 310 | +with open("plot.html", "w") as f: |
| 311 | + f.write( |
| 312 | + """<!DOCTYPE html> |
| 313 | +<html> |
| 314 | +<head> |
| 315 | + <title>chernoff-basic · pygal · pyplots.ai</title> |
| 316 | + <style> |
| 317 | + body { margin: 0; padding: 20px; background: #f5f5f5; font-family: sans-serif; } |
| 318 | + .container { max-width: 100%; margin: 0 auto; } |
| 319 | + h1 { text-align: center; color: #333; } |
| 320 | + object { width: 100%; height: auto; } |
| 321 | + </style> |
| 322 | +</head> |
| 323 | +<body> |
| 324 | + <div class="container"> |
| 325 | + <object type="image/svg+xml" data="plot.svg"> |
| 326 | + Chernoff faces visualization not supported |
| 327 | + </object> |
| 328 | + </div> |
| 329 | +</body> |
| 330 | +</html>""" |
| 331 | + ) |
0 commit comments