|
| 1 | +""" pyplots.ai |
| 2 | +chernoff-basic: Chernoff Faces for Multivariate Data |
| 3 | +Library: highcharts unknown | Python 3.13.11 |
| 4 | +Quality: 91/100 | Created: 2025-12-31 |
| 5 | +""" |
| 6 | + |
| 7 | +import base64 |
| 8 | +import tempfile |
| 9 | +import time |
| 10 | +from pathlib import Path |
| 11 | + |
| 12 | +import numpy as np |
| 13 | +from selenium import webdriver |
| 14 | +from selenium.webdriver.chrome.options import Options |
| 15 | +from sklearn.datasets import load_iris |
| 16 | + |
| 17 | + |
| 18 | +# Data - Using Iris dataset (4 variables per flower) |
| 19 | +np.random.seed(42) |
| 20 | +iris = load_iris() |
| 21 | +X = iris.data |
| 22 | +y = iris.target |
| 23 | +species_names = ["Setosa", "Versicolor", "Virginica"] |
| 24 | + |
| 25 | +# Take 3 samples from each species for 9 total faces |
| 26 | +sample_indices = [] |
| 27 | +for species_id in [0, 1, 2]: |
| 28 | + species_indices = np.where(y == species_id)[0] |
| 29 | + sample_indices.extend(species_indices[:3]) |
| 30 | + |
| 31 | +X_sample = X[sample_indices] |
| 32 | +y_sample = y[sample_indices] |
| 33 | + |
| 34 | +# Normalize data to 0-1 range for each feature |
| 35 | +X_min = X_sample.min(axis=0) |
| 36 | +X_max = X_sample.max(axis=0) |
| 37 | +X_norm = (X_sample - X_min) / (X_max - X_min + 1e-8) |
| 38 | + |
| 39 | +# Colors for species - colorblind-safe |
| 40 | +species_colors = ["#306998", "#FFD43B", "#9467BD"] |
| 41 | + |
| 42 | +# SVG dimensions |
| 43 | +svg_width = 4800 |
| 44 | +svg_height = 2700 |
| 45 | + |
| 46 | +# Build custom Chernoff faces HTML/SVG |
| 47 | +# Feature mappings: |
| 48 | +# - Variable 0 (sepal length): face width |
| 49 | +# - Variable 1 (sepal width): eye size |
| 50 | +# - Variable 2 (petal length): mouth curvature |
| 51 | +# - Variable 3 (petal width): eyebrow slant |
| 52 | + |
| 53 | + |
| 54 | +def create_face_svg(values, color, label, x_pos, y_pos, size=450): |
| 55 | + """Create SVG for a single Chernoff face.""" |
| 56 | + # Extract normalized values (0-1) |
| 57 | + face_width = 0.7 + values[0] * 0.3 # 0.7 to 1.0 multiplier |
| 58 | + eye_size = 0.7 + values[1] * 0.5 # 0.7 to 1.2 multiplier |
| 59 | + mouth_curve = values[2] * 2 - 1 # -1 to 1 (sad to happy) |
| 60 | + eyebrow_slant = (values[3] - 0.5) * 30 # -15 to +15 degrees |
| 61 | + |
| 62 | + cx = x_pos + size // 2 |
| 63 | + cy = y_pos + size // 2 |
| 64 | + face_rx = int(size * 0.4 * face_width) |
| 65 | + face_ry = int(size * 0.45) |
| 66 | + |
| 67 | + # Eye positions and sizes |
| 68 | + eye_cx_left = cx - int(size * 0.15) |
| 69 | + eye_cx_right = cx + int(size * 0.15) |
| 70 | + eye_cy = cy - int(size * 0.08) |
| 71 | + eye_r = int(18 * eye_size) |
| 72 | + pupil_r = int(9 * eye_size) |
| 73 | + |
| 74 | + # Mouth (cubic bezier curve) |
| 75 | + mouth_y = cy + int(size * 0.2) |
| 76 | + mouth_width = int(size * 0.25) |
| 77 | + mouth_curve_offset = int(mouth_curve * size * 0.12) |
| 78 | + |
| 79 | + # Eyebrows |
| 80 | + brow_y = eye_cy - int(size * 0.12) |
| 81 | + brow_len = int(size * 0.12) |
| 82 | + |
| 83 | + svg = f""" |
| 84 | + <!-- Face {label} --> |
| 85 | + <ellipse cx="{cx}" cy="{cy}" rx="{face_rx}" ry="{face_ry}" |
| 86 | + fill="{color}" stroke="#333333" stroke-width="4"/> |
| 87 | +
|
| 88 | + <!-- Left eye --> |
| 89 | + <circle cx="{eye_cx_left}" cy="{eye_cy}" r="{eye_r}" fill="white" stroke="#333333" stroke-width="3"/> |
| 90 | + <circle cx="{eye_cx_left}" cy="{eye_cy}" r="{pupil_r}" fill="#333333"/> |
| 91 | +
|
| 92 | + <!-- Right eye --> |
| 93 | + <circle cx="{eye_cx_right}" cy="{eye_cy}" r="{eye_r}" fill="white" stroke="#333333" stroke-width="3"/> |
| 94 | + <circle cx="{eye_cx_right}" cy="{eye_cy}" r="{pupil_r}" fill="#333333"/> |
| 95 | +
|
| 96 | + <!-- Left eyebrow --> |
| 97 | + <line x1="{eye_cx_left - brow_len}" y1="{brow_y + int(eyebrow_slant)}" |
| 98 | + x2="{eye_cx_left + brow_len}" y2="{brow_y - int(eyebrow_slant)}" |
| 99 | + stroke="#333333" stroke-width="5" stroke-linecap="round"/> |
| 100 | +
|
| 101 | + <!-- Right eyebrow --> |
| 102 | + <line x1="{eye_cx_right - brow_len}" y1="{brow_y - int(eyebrow_slant)}" |
| 103 | + x2="{eye_cx_right + brow_len}" y2="{brow_y + int(eyebrow_slant)}" |
| 104 | + stroke="#333333" stroke-width="5" stroke-linecap="round"/> |
| 105 | +
|
| 106 | + <!-- Nose --> |
| 107 | + <line x1="{cx}" y1="{cy - int(size * 0.02)}" x2="{cx}" y2="{cy + int(size * 0.1)}" |
| 108 | + stroke="#333333" stroke-width="4" stroke-linecap="round"/> |
| 109 | +
|
| 110 | + <!-- Mouth --> |
| 111 | + <path d="M {cx - mouth_width} {mouth_y} Q {cx} {mouth_y + mouth_curve_offset} {cx + mouth_width} {mouth_y}" |
| 112 | + fill="none" stroke="#333333" stroke-width="5" stroke-linecap="round"/> |
| 113 | +
|
| 114 | + <!-- Label --> |
| 115 | + <text x="{cx}" y="{y_pos + size + 50}" text-anchor="middle" |
| 116 | + font-size="36" font-family="Arial, sans-serif" font-weight="bold">{label}</text> |
| 117 | + """ |
| 118 | + return svg |
| 119 | + |
| 120 | + |
| 121 | +# Create the complete HTML with embedded SVG |
| 122 | +faces_svg = "" |
| 123 | +face_size = 580 |
| 124 | +cols = 3 |
| 125 | +rows = 3 |
| 126 | + |
| 127 | +# Calculate grid to center faces properly across the canvas |
| 128 | +# Leave space for legends on right (about 600px) and title at top (about 220px) |
| 129 | +grid_left = 100 |
| 130 | +grid_right = 3350 |
| 131 | +grid_top = 250 |
| 132 | +grid_bottom = svg_height - 100 |
| 133 | + |
| 134 | +grid_width = grid_right - grid_left |
| 135 | +grid_height = grid_bottom - grid_top |
| 136 | + |
| 137 | +# Calculate cell size for even distribution |
| 138 | +cell_width = grid_width // cols |
| 139 | +cell_height = grid_height // rows |
| 140 | + |
| 141 | +for idx in range(9): |
| 142 | + row = idx // cols |
| 143 | + col = idx % cols |
| 144 | + |
| 145 | + # Center face within its cell |
| 146 | + cell_x = grid_left + col * cell_width |
| 147 | + cell_y = grid_top + row * cell_height |
| 148 | + x_pos = cell_x + (cell_width - face_size) // 2 |
| 149 | + y_pos = cell_y + (cell_height - face_size - 60) // 2 # -60 for label space |
| 150 | + |
| 151 | + species_idx = y_sample[idx] |
| 152 | + color = species_colors[species_idx] |
| 153 | + label = f"{species_names[species_idx]} #{(idx % 3) + 1}" |
| 154 | + |
| 155 | + faces_svg += create_face_svg(X_norm[idx], color, label, x_pos, y_pos, face_size) |
| 156 | + |
| 157 | +# Create legend - positioned in right column, vertically centered |
| 158 | +legend_x = 3550 |
| 159 | +legend_y = 450 |
| 160 | +legend_svg = f""" |
| 161 | +<rect x="{legend_x}" y="{legend_y}" width="550" height="380" fill="#f8f8f8" stroke="#333333" stroke-width="3" rx="15"/> |
| 162 | +<text x="{legend_x + 35}" y="{legend_y + 60}" font-size="44" font-family="Arial, sans-serif" font-weight="bold">Species Legend</text> |
| 163 | +""" |
| 164 | + |
| 165 | +for i, (species, color) in enumerate(zip(species_names, species_colors, strict=True)): |
| 166 | + ly_item = legend_y + 130 + i * 80 |
| 167 | + legend_svg += f""" |
| 168 | + <circle cx="{legend_x + 60}" cy="{ly_item}" r="30" fill="{color}" stroke="#333333" stroke-width="3"/> |
| 169 | + <text x="{legend_x + 110}" y="{ly_item + 14}" font-size="38" font-family="Arial, sans-serif">{species}</text> |
| 170 | + """ |
| 171 | + |
| 172 | +# Feature mapping legend |
| 173 | +feature_legend_y = legend_y + 450 |
| 174 | +feature_legend_svg = f""" |
| 175 | +<rect x="{legend_x}" y="{feature_legend_y}" width="550" height="480" fill="#f8f8f8" stroke="#333333" stroke-width="3" rx="15"/> |
| 176 | +<text x="{legend_x + 35}" y="{feature_legend_y + 60}" font-size="40" font-family="Arial, sans-serif" font-weight="bold">Feature Mapping</text> |
| 177 | +<text x="{legend_x + 35}" y="{feature_legend_y + 130}" font-size="30" font-family="Arial, sans-serif">Face Width → Sepal Length</text> |
| 178 | +<text x="{legend_x + 35}" y="{feature_legend_y + 190}" font-size="30" font-family="Arial, sans-serif">Eye Size → Sepal Width</text> |
| 179 | +<text x="{legend_x + 35}" y="{feature_legend_y + 250}" font-size="30" font-family="Arial, sans-serif">Mouth Curve → Petal Length</text> |
| 180 | +<text x="{legend_x + 35}" y="{feature_legend_y + 310}" font-size="30" font-family="Arial, sans-serif">Eyebrow Slant → Petal Width</text> |
| 181 | +<line x1="{legend_x + 35}" y1="{feature_legend_y + 355}" x2="{legend_x + 515}" y2="{feature_legend_y + 355}" stroke="#cccccc" stroke-width="2"/> |
| 182 | +<text x="{legend_x + 35}" y="{feature_legend_y + 410}" font-size="26" font-family="Arial, sans-serif" fill="#666666">All values normalized to 0-1 range</text> |
| 183 | +""" |
| 184 | + |
| 185 | +# Complete HTML |
| 186 | +html_content = f"""<!DOCTYPE html> |
| 187 | +<html> |
| 188 | +<head> |
| 189 | + <meta charset="utf-8"> |
| 190 | + <style> |
| 191 | + body {{ margin: 0; padding: 0; background: #ffffff; }} |
| 192 | + </style> |
| 193 | +</head> |
| 194 | +<body> |
| 195 | + <svg width="{svg_width}" height="{svg_height}" xmlns="http://www.w3.org/2000/svg"> |
| 196 | + <!-- Background --> |
| 197 | + <rect width="100%" height="100%" fill="#ffffff"/> |
| 198 | +
|
| 199 | + <!-- Title --> |
| 200 | + <text x="{svg_width // 2}" y="100" text-anchor="middle" |
| 201 | + font-size="64" font-family="Arial, sans-serif" font-weight="bold"> |
| 202 | + chernoff-basic · highcharts · pyplots.ai |
| 203 | + </text> |
| 204 | +
|
| 205 | + <!-- Subtitle --> |
| 206 | + <text x="{svg_width // 2}" y="175" text-anchor="middle" |
| 207 | + font-size="40" font-family="Arial, sans-serif" fill="#666666"> |
| 208 | + Iris Dataset: 4 Variables Mapped to Facial Features (9 Samples, 3 Per Species) |
| 209 | + </text> |
| 210 | +
|
| 211 | + <!-- Faces Grid --> |
| 212 | + {faces_svg} |
| 213 | +
|
| 214 | + <!-- Species Legend --> |
| 215 | + {legend_svg} |
| 216 | +
|
| 217 | + <!-- Feature Mapping Legend --> |
| 218 | + {feature_legend_svg} |
| 219 | + </svg> |
| 220 | +</body> |
| 221 | +</html>""" |
| 222 | + |
| 223 | +# Save HTML version |
| 224 | +with open("plot.html", "w", encoding="utf-8") as f: |
| 225 | + f.write(html_content) |
| 226 | + |
| 227 | +# Export to PNG via Selenium |
| 228 | +with tempfile.NamedTemporaryFile(mode="w", suffix=".html", delete=False, encoding="utf-8") as f: |
| 229 | + f.write(html_content) |
| 230 | + temp_path = f.name |
| 231 | + |
| 232 | +chrome_options = Options() |
| 233 | +chrome_options.add_argument("--headless=new") |
| 234 | +chrome_options.add_argument("--no-sandbox") |
| 235 | +chrome_options.add_argument("--disable-dev-shm-usage") |
| 236 | +chrome_options.add_argument("--disable-gpu") |
| 237 | +chrome_options.add_argument("--window-size=4800,2700") |
| 238 | +chrome_options.add_argument("--force-device-scale-factor=1") |
| 239 | + |
| 240 | +driver = webdriver.Chrome(options=chrome_options) |
| 241 | +driver.get(f"file://{temp_path}") |
| 242 | +time.sleep(3) |
| 243 | + |
| 244 | +# Use CDP to capture full page at exact dimensions |
| 245 | +driver.execute_cdp_cmd( |
| 246 | + "Emulation.setDeviceMetricsOverride", {"width": 4800, "height": 2700, "deviceScaleFactor": 1, "mobile": False} |
| 247 | +) |
| 248 | +time.sleep(1) |
| 249 | + |
| 250 | +# Take screenshot with clip to exact dimensions |
| 251 | +result = driver.execute_cdp_cmd( |
| 252 | + "Page.captureScreenshot", |
| 253 | + { |
| 254 | + "format": "png", |
| 255 | + "captureBeyondViewport": True, |
| 256 | + "clip": {"x": 0, "y": 0, "width": 4800, "height": 2700, "scale": 1}, |
| 257 | + }, |
| 258 | +) |
| 259 | + |
| 260 | +with open("plot.png", "wb") as f: |
| 261 | + f.write(base64.b64decode(result["data"])) |
| 262 | + |
| 263 | +driver.quit() |
| 264 | + |
| 265 | +Path(temp_path).unlink() |
0 commit comments