fontmap / src /typography /new-pipe /4-generate-umap.mjs
tfrere's picture
tfrere HF Staff
first commit
eebc40f
#!/usr/bin/env node
import fs from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';
import sharp from 'sharp';
import { UMAP } from 'umap-js';
import { Matrix } from 'ml-matrix';
import cliProgress from 'cli-progress';
import chalk from 'chalk';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Configuration
const PNGS_DIR = path.join(__dirname, 'output', 'pngs');
const DATA_DIR = path.join(__dirname, 'output', 'data');
const FONT_INDEX_PATH = path.join(__dirname, 'input', 'font-index.json');
const OUTPUT_FILENAME = 'typography_data.json';
const FULL_OUTPUT_PATH = path.join(DATA_DIR, OUTPUT_FILENAME);
// Paramètres UMAP
const UMAP_PARAMS = {
nComponents: 2,
nNeighbors: 15,
minDist: 1.0,
metric: 'euclidean',
random: Math.random
};
// Configuration de pondération pour l'influence catégorielle
const CATEGORY_WEIGHT = 0.3; // 30% d'influence de la catégorie
const PIXEL_WEIGHT = 0.7; // 70% d'influence des pixels
// Configuration de fusion des familles
const ENABLE_FONT_FUSION = true; // Activer la fusion des familles de polices
const FUSION_PREFIX_LENGTH = 2; // Nombre de tirets pour définir le préfixe de fusion
// Configuration de la barre de progression
const progressBar = new cliProgress.SingleBar({
format: chalk.cyan('{bar}') + ' | {percentage}% | {value}/{total} | {fontName}',
barCompleteChar: '\u2588',
barIncompleteChar: '\u2591',
hideCursor: true
});
/**
* Analyse la qualité d'un PNG pour détecter les polices problématiques
*/
async function analyzePNGQuality(pngPath) {
try {
// Charger l'image en niveaux de gris
const image = sharp(pngPath);
const { data, info } = await image.grayscale().raw().toBuffer({ resolveWithObject: true });
const { width, height } = info;
const totalPixels = width * height;
let blackPixels = 0;
let whitePixels = 0;
let grayPixels = 0;
// Analyser chaque pixel
for (let i = 0; i < data.length; i++) {
const pixel = data[i];
if (pixel < 50) {
blackPixels++;
} else if (pixel > 200) {
whitePixels++;
} else {
grayPixels++;
}
}
const blackPercentage = (blackPixels / totalPixels) * 100;
const whitePercentage = (whitePixels / totalPixels) * 100;
const grayPercentage = (grayPixels / totalPixels) * 100;
// Vérifications de qualité
const hasContent = blackPixels > 0;
const isMostlyWhite = whitePercentage > 90;
const hasReasonableContent = 0.1 <= blackPercentage && blackPercentage <= 50;
return {
totalPixels,
blackPixels,
whitePixels,
grayPixels,
blackPercentage,
whitePercentage,
grayPercentage,
hasContent,
isMostlyWhite,
hasReasonableContent,
qualityScore: hasReasonableContent ? blackPercentage : 0
};
} catch (error) {
console.error(`❌ Error during l'analyse de ${pngPath}:`, error.message);
return null;
}
}
/**
* Charge un PNG et le convertit en matrice de pixels normalisée
* Effectue aussi une validation de qualité
*/
async function loadPNGAsMatrix(pngPath) {
try {
// Charger l'image en niveaux de gris
const image = sharp(pngPath);
const { data, info } = await image.grayscale().raw().toBuffer({ resolveWithObject: true });
// Vérifier les dimensions
if (info.width !== 40 || info.height !== 40) {
console.warn(`⚠️ Taille inattendue pour ${pngPath}: ${info.width}x${info.height}`);
// Redimensionner si nécessaire
const resized = await sharp(pngPath)
.resize(40, 40)
.grayscale()
.raw()
.toBuffer({ resolveWithObject: true });
data = resized.data;
}
// Analyze quality
const quality = await analyzePNGQuality(pngPath);
if (!quality) {
return { pixelVector: null, quality: null };
}
// Vérifier si le PNG a un contenu raisonnable
if (!quality.hasReasonableContent) {
console.warn(`⚠️ PNG de mauvaise qualité: ${pngPath} (${quality.blackPercentage.toFixed(1)}% noir)`);
return { pixelVector: null, quality: null };
}
// Convertir en tableau et normaliser (0-255 → 0-1)
const pixelMatrix = Array.from(data).map(pixel => pixel / 255.0);
return { pixelVector: pixelMatrix, quality };
} catch (error) {
console.error(`❌ Erreur lors du chargement de ${pngPath}:`, error.message);
return { pixelVector: null, quality: null };
}
}
/**
* Extrait le préfixe d'un nom de police pour la fusion
*/
function extractFusionPrefix(fontId, fontData, maxDashes = FUSION_PREFIX_LENGTH) {
const parts = fontId.split('-');
if (parts.length <= 1) {
return fontId; // Pas de tirets, garder le nom complet
}
// NOUVELLE LOGIQUE : Basée sur les subsets
if (fontData && fontData.subsets && Array.isArray(fontData.subsets)) {
// Chercher un subset qui correspond à une partie de l'ID
for (const subset of fontData.subsets) {
// Ignorer les subsets génériques
if (['latin', 'latin-ext', 'cyrillic', 'cyrillic-ext', 'greek', 'greek-ext'].includes(subset)) {
continue;
}
// Vérifier si le subset est présent dans l'ID
if (fontId.includes(subset)) {
// Soustraire le subset de l'ID pour obtenir le nom de base
const baseName = fontId.replace(`-${subset}`, '').replace(subset, '');
if (baseName && baseName !== fontId) {
console.log(chalk.yellow(` 🔍 ${fontId}${baseName} (subset: ${subset})`));
return baseName;
}
}
}
}
// FALLBACK : Logique originale pour les cas non couverts
// Cas spéciaux pour les familles connues
const specialCases = {
'baloo': ['baloo-2', 'baloo-bhai-2', 'baloo-bhaijaan-2', 'baloo-bhaina-2', 'baloo-chettan-2', 'baloo-da-2', 'baloo-paaji-2', 'baloo-tamma-2', 'baloo-tammudu-2', 'baloo-thambi-2'],
'ibm-plex': ['ibm-plex'],
'playwrite': ['playwrite']
};
// Vérifier les cas spéciaux
for (const [familyPrefix, patterns] of Object.entries(specialCases)) {
for (const pattern of patterns) {
if (fontId.startsWith(pattern)) {
return familyPrefix;
}
}
}
// Cas spéciaux avec regex plus permissifs
if (fontId.startsWith('noto-serif-')) {
return 'noto-serif';
}
if (fontId.startsWith('noto-')) {
return 'noto';
}
// Logique intelligente : si le 2ème mot est "sans", "serif", ou "plex", c'est une famille principale
const secondWord = parts[1];
if (secondWord === 'sans' || secondWord === 'serif' || secondWord === 'plex') {
return parts.slice(0, 2).join('-'); // Ex: "playwright-sans" → "playwright-sans"
}
// Sinon, tout ce qui vient après le premier mot sont des variantes
return parts[0]; // Ex: "playwright-sk" → "playwright"
}
/**
* Fusionne les familles de polices basées sur leur préfixe
*/
function mergeFontFamilies(fontDataList, pixelMatrices) {
if (!ENABLE_FONT_FUSION) {
return { fontDataList, pixelMatrices };
}
console.log(chalk.blue('🔄 Fusion des familles de polices...'));
const prefixGroups = {};
const prefixPixelGroups = {};
// Grouper les polices par préfixe
for (let i = 0; i < fontDataList.length; i++) {
const font = fontDataList[i];
const prefix = extractFusionPrefix(font.id, font);
if (!prefixGroups[prefix]) {
prefixGroups[prefix] = [];
prefixPixelGroups[prefix] = [];
}
prefixGroups[prefix].push(font);
prefixPixelGroups[prefix].push(pixelMatrices[i]);
}
// Fusionner les groupes avec plus d'une police
const mergedFonts = [];
const mergedPixels = [];
let fusionCount = 0;
let totalReduction = 0;
for (const [prefix, fonts] of Object.entries(prefixGroups)) {
if (fonts.length > 1) {
// Grouper les polices
const allPixels = prefixPixelGroups[prefix];
// Choisir une police représentative au lieu de calculer la moyenne
let representativePixels;
let representativeFont;
// Logique pour choisir la police représentative
if (prefix === 'noto') {
// Pour Noto, privilégier noto-sans-arabic (plus représentative) ou noto-sans-latin
representativeFont = fonts.find(f => f.id === 'noto-sans-arabic') ||
fonts.find(f => f.id === 'noto-sans-latin') ||
fonts.find(f => f.id === 'noto-sans') ||
fonts[0];
} else if (prefix === 'noto-serif') {
// Pour Noto Serif, privilégier noto-serif-latin ou noto-serif
representativeFont = fonts.find(f => f.id === 'noto-serif-latin') ||
fonts.find(f => f.id === 'noto-serif') ||
fonts[0];
} else if (prefix === 'ibm-plex') {
// Pour IBM Plex, privilégier ibm-plex-sans
representativeFont = fonts.find(f => f.id === 'ibm-plex-sans') ||
fonts.find(f => f.id === 'ibm-plex') ||
fonts[0];
} else if (prefix === 'baloo') {
// Pour Baloo, privilégier baloo-2 (la version principale)
representativeFont = fonts.find(f => f.id === 'baloo-2') || fonts[0];
} else {
// Pour les autres familles, prendre la première ou chercher une variante "standard"
representativeFont = fonts.find(f => f.id.includes('-regular') || f.id.includes('-normal')) ||
fonts.find(f => !f.id.includes('-italic') && !f.id.includes('-bold')) ||
fonts[0];
}
// Trouver les pixels correspondants à la police représentative
const representativeIndex = fonts.findIndex(f => f.id === representativeFont.id);
representativePixels = allPixels[representativeIndex];
// Agréger les informations de poids et styles
const allWeights = [...new Set(fonts.flatMap(f => f.weights || []))].sort((a, b) => a - b);
const allStyles = [...new Set(fonts.flatMap(f => f.styles || []))].sort();
const allSubsets = [...new Set(fonts.flatMap(f => f.subsets || []))].sort();
// Créer la police fusionnée
const mergedFont = {
...representativeFont,
id: prefix, // Utiliser le préfixe comme ID
name: prefix.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase()), // Nom formaté
// Utiliser le nom de la police représentative pour les images
imageName: representativeFont.id, // Nom pour les images (ex: "noto-sans-arabic")
weights: allWeights,
styles: allStyles,
subsets: allSubsets,
originalVariants: fonts.map(f => ({
id: f.id,
name: f.name,
google_fonts_url: f.google_fonts_url,
weights: f.weights || [],
styles: f.styles || []
})),
variantCount: fonts.length,
fusionInfo: {
merged: true,
originalCount: fonts.length,
representative: representativeFont.id,
representativeName: representativeFont.name,
variants: fonts.map(f => f.id),
selectionMethod: prefix === 'noto' ? 'noto-sans-latin-priority' :
prefix === 'noto-serif' ? 'noto-serif-latin-priority' :
prefix === 'ibm-plex' ? 'ibm-plex-sans-priority' :
prefix === 'baloo' ? 'baloo-2-priority' : 'standard-variant-priority'
}
};
mergedFonts.push(mergedFont);
mergedPixels.push(representativePixels);
fusionCount++;
totalReduction += fonts.length - 1;
console.log(chalk.green(` ✓ ${prefix}: ${fonts.length} variantes → 1 famille`));
} else {
// Police unique, pas de fusion
const singleFont = {
...fonts[0],
imageName: fonts[0].id // Ajouter imageName pour la cohérence
};
mergedFonts.push(singleFont);
mergedPixels.push(prefixPixelGroups[prefix][0]);
}
}
console.log(chalk.green(`✅ Fusion terminée: ${fusionCount} familles fusionnées, ${totalReduction} polices supprimées`));
console.log(chalk.cyan(`📊 Résultat: ${fontDataList.length}${mergedFonts.length} polices (${((totalReduction / fontDataList.length) * 100).toFixed(1)}% de réduction)`));
return {
fontDataList: mergedFonts,
pixelMatrices: mergedPixels
};
}
/**
* Extrait les informations de police à partir du nom de fichier et du fichier d'index
*/
function extractFontInfoFromFilename(filename, fontIndexData) {
// Supprimer l'extension et le suffixe "_a"
const fontId = filename.replace('.png', '').replace('_a', '');
// Chercher la police dans l'index
const fontData = fontIndexData[fontId];
if (!fontData) {
console.warn(`⚠️ Police non trouvée dans l'index: ${fontId}`);
// Fallback avec classification par nom
const fontName = fontId.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
const googleFontsUrl = `https://fonts.google.com/specimen/${fontName.replace(/\s+/g, '+')}`;
return {
name: fontName,
id: fontId,
imageName: fontId, // Ajouter imageName pour la cohérence
family: "sans-serif", // par défaut
google_fonts_url: googleFontsUrl
};
}
// Utiliser l'ID comme nom pour la cohérence avec les fichiers
const fontName = fontId; // Utiliser l'ID au lieu de family
const category = fontData.category;
const googleFontsUrl = `https://fonts.google.com/specimen/${fontData.family.replace(/\s+/g, '+')}`;
return {
name: fontName, // Maintenant c'est l'ID (ex: "abeezee")
id: fontId,
imageName: fontId, // Ajouter imageName pour la cohérence
family: category,
google_fonts_url: googleFontsUrl,
// Extraire les informations de poids et styles
weights: fontData.weights || [],
styles: fontData.styles || [],
subsets: fontData.subsets || [],
unicodeRange: fontData.unicodeRange || {}
};
}
/**
* Normalise les données (équivalent de StandardScaler)
*/
function normalizeData(data) {
const matrix = new Matrix(data);
const means = matrix.mean('column');
const stds = matrix.standardDeviation('column');
// Éviter la division par zéro
for (let i = 0; i < stds.length; i++) {
if (stds[i] === 0) {
stds[i] = 1;
}
}
// Normaliser chaque colonne
const normalized = matrix.clone();
for (let i = 0; i < normalized.rows; i++) {
for (let j = 0; j < normalized.columns; j++) {
normalized.set(i, j, (normalized.get(i, j) - means[j]) / stds[j]);
}
}
return normalized.to2DArray();
}
/**
* Encode les catégories en vecteurs numériques
*/
function encodeCategories(fontDataList) {
console.log(chalk.blue('🔢 Encoding categories to numerical vectors...'));
// Obtenir toutes les catégories uniques
const categories = [...new Set(fontDataList.map(font => font.family))];
console.log(chalk.cyan(`📊 Found ${categories.length} unique categories: ${categories.join(', ')}`));
// Créer un mapping catégorie -> index
const categoryToIndex = {};
categories.forEach((category, index) => {
categoryToIndex[category] = index;
});
// Encoder chaque police avec un one-hot vector
const encodedCategories = fontDataList.map(font => {
const vector = new Array(categories.length).fill(0);
const categoryIndex = categoryToIndex[font.family];
vector[categoryIndex] = 1;
return vector;
});
console.log(chalk.green(`✅ Encoded ${encodedCategories.length} fonts with ${categories.length} category dimensions`));
return {
encodedCategories,
categories,
categoryToIndex
};
}
/**
* Combine pixel matrices with category encodings
*/
function combinePixelAndCategoryData(pixelMatrices, encodedCategories) {
console.log(chalk.blue('🔗 Combining pixel data with category encodings...'));
const combinedData = [];
for (let i = 0; i < pixelMatrices.length; i++) {
const pixelVector = pixelMatrices[i];
const categoryVector = encodedCategories[i];
// Normaliser les pixels (0-1)
const normalizedPixels = pixelVector.map(pixel => pixel / 255);
// Combiner avec pondération
const combinedVector = [
...normalizedPixels.map(p => p * PIXEL_WEIGHT), // Pixels pondérés
...categoryVector.map(c => c * CATEGORY_WEIGHT) // Catégories pondérées
];
combinedData.push(combinedVector);
}
console.log(chalk.green(`✅ Combined data: ${pixelMatrices[0].length} pixels + ${encodedCategories[0].length} categories = ${combinedData[0].length} total dimensions`));
console.log(chalk.cyan(`📊 Weights: ${(PIXEL_WEIGHT * 100).toFixed(0)}% pixels, ${(CATEGORY_WEIGHT * 100).toFixed(0)}% categories`));
return combinedData;
}
/**
* Charge toutes les données de polices depuis les PNGs
*/
async function loadAllFontData() {
console.log(chalk.blue('🔄 Chargement des données de polices depuis les PNGs...'));
// Create directory data si nécessaire
await fs.mkdir(DATA_DIR, { recursive: true });
// Charger l'index des polices
console.log(chalk.yellow('📖 Chargement de l\'index des polices...'));
const fontIndexData = JSON.parse(await fs.readFile(FONT_INDEX_PATH, 'utf8'));
console.log(chalk.green(`✅ Index chargé: ${Object.keys(fontIndexData).length} polices`));
// Trouver tous les fichiers PNG
const files = await fs.readdir(PNGS_DIR);
const pngFiles = files.filter(file => file.endsWith('_a.png'));
if (pngFiles.length === 0) {
throw new Error(`Aucun fichier PNG trouvé dans ${PNGS_DIR}`);
}
console.log(chalk.cyan(`📁 ${pngFiles.length} fichiers PNG trouvés`));
const fontDataList = [];
const pixelMatrices = [];
let rejectedCount = 0;
// Traiter chaque fichier PNG
for (let i = 0; i < pngFiles.length; i++) {
const filename = pngFiles[i];
const pngPath = path.join(PNGS_DIR, filename);
// Extract information de police depuis l'index
const fontInfo = extractFontInfoFromFilename(filename, fontIndexData);
// Charger la matrice de pixels avec validation de qualité
const { pixelVector, quality } = await loadPNGAsMatrix(pngPath);
if (pixelVector && quality) {
fontDataList.push(fontInfo);
pixelMatrices.push(pixelVector);
// Ajouter les informations de qualité aux données de police
fontInfo.quality = quality;
if ((i + 1) % 50 === 0) {
console.log(chalk.yellow(`⚡ ${i + 1}/${pngFiles.length} polices traitées...`));
}
} else {
rejectedCount++;
console.log(chalk.red(`❌ Rejeté: ${filename} (qualité insuffisante)`));
}
}
console.log(chalk.green(`✅ ${fontDataList.length} polices chargées avec succès`));
console.log(chalk.red(`❌ ${rejectedCount} polices rejetées pour problèmes de qualité`));
console.log(chalk.blue(`📊 Matrice finale: ${pixelMatrices.length} polices × ${pixelMatrices[0]?.length || 0} pixels`));
return { fontDataList, pixelMatrices };
}
/**
* Génère les embeddings UMAP à partir des matrices de pixels
*/
function generateUMAPEmbedding(pixelMatrices) {
console.log(chalk.blue('🔄 Génération des embeddings UMAP...'));
// Normalize data (important pour UMAP)
console.log(chalk.yellow('📊 Normalisation des données...'));
const normalizedData = normalizeData(pixelMatrices);
// Appliquer UMAP
console.log(chalk.cyan(`🗺️ Application d'UMAP avec paramètres:`, UMAP_PARAMS));
const umap = new UMAP(UMAP_PARAMS);
const embedding = umap.fit(normalizedData);
console.log(chalk.green(`✅ UMAP terminé - Forme de l'embedding: ${embedding.length} × ${embedding[0]?.length || 0}`));
if (embedding.length > 0) {
const xValues = embedding.map(row => row[0]);
const yValues = embedding.map(row => row[1]);
console.log(chalk.blue(`📊 Plage X: [${Math.min(...xValues).toFixed(2)}, ${Math.max(...xValues).toFixed(2)}]`));
console.log(chalk.blue(`📊 Plage Y: [${Math.min(...yValues).toFixed(2)}, ${Math.max(...yValues).toFixed(2)}]`));
}
return embedding;
}
/**
* Sauvegarde les données finales au format JSON
*/
async function saveTypographyData(fontDataList, embedding, categories) {
console.log(chalk.blue('💾 Sauvegarde des données...'));
// Combiner les données de polices et les coordonnées UMAP
const finalData = [];
for (let i = 0; i < fontDataList.length; i++) {
const fontInfo = fontDataList[i];
const fontData = {
...fontInfo,
x: embedding[i][0],
y: embedding[i][1]
};
finalData.push(fontData);
}
// Métadonnées
const metadata = {
generated_at: new Date().toISOString(),
method: "umap_from_png_pixels_with_category_influence_and_font_fusion",
total_fonts: finalData.length,
font_fusion: {
enabled: ENABLE_FONT_FUSION,
prefix_length: FUSION_PREFIX_LENGTH,
fusion_method: "average_pixels_with_metadata_aggregation"
},
umap_params: UMAP_PARAMS,
category_weights: {
pixel_weight: PIXEL_WEIGHT,
category_weight: CATEGORY_WEIGHT
},
categories: categories,
category_count: categories.length,
data_source: "PNG pixel matrices (40x40) + category encoding + font family fusion"
};
// Structure finale
const outputData = {
metadata,
fonts: finalData
};
// Sauvegarder
await fs.writeFile(FULL_OUTPUT_PATH, JSON.stringify(outputData, null, 2), 'utf8');
console.log(chalk.green(`✅ Données sauvegardées dans ${FULL_OUTPUT_PATH}`));
// Statistics par catégorie
const categoryStats = {};
for (const font of finalData) {
const cat = font.family;
categoryStats[cat] = (categoryStats[cat] || 0) + 1;
}
console.log(chalk.cyan('\n📊 Distribution par catégorie:'));
for (const [cat, count] of Object.entries(categoryStats).sort(([,a], [,b]) => b - a)) {
const percentage = ((count / finalData.length) * 100).toFixed(1);
console.log(chalk.white(` ${cat}: ${count} polices (${percentage}%)`));
}
}
/**
* Fonction principale
*/
async function main() {
try {
console.log(chalk.blue.bold('🎨 Génération UMAP pour la typographie à partir des matrices de pixels\n'));
// 1. Load data de polices
const { fontDataList, pixelMatrices } = await loadAllFontData();
if (fontDataList.length === 0) {
throw new Error('Aucune donnée de police valide chargée');
}
// 2. Fusionner les familles de polices
const { fontDataList: mergedFontDataList, pixelMatrices: mergedPixelMatrices } = mergeFontFamilies(fontDataList, pixelMatrices);
// 3. Encoder les catégories
const { encodedCategories, categories, categoryToIndex } = encodeCategories(mergedFontDataList);
// 4. Combiner pixels et catégories
const combinedData = combinePixelAndCategoryData(mergedPixelMatrices, encodedCategories);
// 5. Normaliser les données combinées
const normalizedData = normalizeData(combinedData);
// 6. Générer l'embedding UMAP
const embedding = generateUMAPEmbedding(normalizedData);
// 7. Sauvegarder les résultats
await saveTypographyData(mergedFontDataList, embedding, categories);
console.log(chalk.green.bold('\n🎉 Génération UMAP terminée avec succès !'));
} catch (error) {
console.error(chalk.red('💥 Erreur fatale:'), error.message);
process.exit(1);
}
}
// Lancer le script
main();