How to turn text into colors (without AI) The article explains a method to generate a color palette from text without using AI, based on the HSL color model. It involves analyzing character frequency in the text, mapping each unique character to a specific hue on the color wheel, and using the character's frequency to determine saturation, with lightness set to a default 50%. The process includes cleaning and normalizing the text data, such as optionally excluding whitespace and making characters case-insensitive, before converting the frequency data into HSL values. You definitely know that you can generate an image from text by simply feeding the prompt to Nano Banana or an equivalent. What if I tell you that you can generate a color palette out of your text, without any help from AI? My idea is nothing complex. Just look at the HSL color model https://giggster.com/guide/basics/hue-saturation-lightness/ . As you can see, to represent color, we need three numbers - hue degrees, saturation percentages, and lightness percentages. All we have to do is get those numbers out of text. There are a few ways to do so, but one of the most common, logical, and foolproof is to create an alphabet first and then analyze character frequency second. Let me show you. For starters, we need a text. I took Christmas https://lingua.com/english/reading/christmas/ from Lingua.com. If we ran character frequency analysis as is , we would end up with something like this: {"character": " ", "count": 307}, {"character": "e", "count": 195}, {"character": "a", "count": 137}, {"character": "t", "count": 134}, {"character": "i", "count": 120}, {"character": "s", "count": 115}, {"character": "o", "count": 97}, {"character": "h", "count": 94}, {"character": "r", "count": 92}, {"character": "n", "count": 91}, {"character": "l", "count": 86}, {"character": "d", "count": 79}, {"character": "c", "count": 49}, {"character": "m", "count": 43}, {"character": "y", "count": 39}, {"character": "p", "count": 35}, {"character": ",", "count": 31}, {"character": "g", "count": 28}, {"character": "f", "count": 27}, {"character": "u", "count": 21}, {"character": "b", "count": 20}, {"character": "w", "count": 20}, {"character": "v", "count": 11}, {"character": "C", "count": 10}, {"character": ".", "count": 9}, {"character": "S", "count": 7}, {"character": "k", "count": 7}, {"character": "-", "count": 6}, {"character": "A", "count": 5}, {"character": " ", "count": 5}, {"character": " ", "count": 5}, {"character": "'", "count": 5}, {"character": "\n", "count": 4}, {"character": "j", "count": 4}, {"character": "J", "count": 3}, {"character": "T", "count": 2}, {"character": "N", "count": 2}, {"character": "x", "count": 2}, {"character": "G", "count": 2}, {"character": "L", "count": 2}, {"character": "\"", "count": 2}, {"character": "B", "count": 2}, {"character": "U", "count": 1}, {"character": "2", "count": 1}, {"character": "5", "count": 1}, {"character": "D", "count": 1}, {"character": "P", "count": 1}, {"character": "q", "count": 1}, {"character": "/", "count": 1} Unsurprisingly, the whitespace is the most frequent symbol - it's a common thing for texts in the English language and other Western languages. If we were to process letters only case-insensitive , the array would look like this: { "character": "e", "count": 195 }, { "character": "a", "count": 142 }, { "character": "t", "count": 136 }, { "character": "s", "count": 122 }, { "character": "i", "count": 120 }, { "character": "o", "count": 97 }, { "character": "h", "count": 94 }, { "character": "n", "count": 93 }, { "character": "r", "count": 92 }, { "character": "l", "count": 88 }, { "character": "d", "count": 80 }, { "character": "c", "count": 59 }, { "character": "m", "count": 43 }, { "character": "y", "count": 39 }, { "character": "p", "count": 36 }, { "character": "g", "count": 30 }, { "character": "f", "count": 27 }, { "character": "b", "count": 22 }, { "character": "u", "count": 22 }, { "character": "w", "count": 20 }, { "character": "v", "count": 11 }, { "character": "j", "count": 7 }, { "character": "k", "count": 7 }, { "character": "x", "count": 2 }, { "character": "q", "count": 1 } Our next task is to take the HSL color wheel https://www.js-craft.io/blog/an-introduction-to-hsl-colors-in-css/ Form a "string" out of symbols and "wrap" it around the wheel By dividing the wheel into equal parts according to our "alphabet," we got the hues . The frequency of symbols got us saturation . And luminosity has a default value - 50% for pure colors. Of course, nothing stops you from making luminosity a variable. All right, let's go to code: First step, cleaning up and normalising text data according to our preferences - include or not whitespaces, numbers, etc. js let rawChars = text.split '' ; const filteredChars = rawChars.filter char = { // any whitespace const isSpace = /\p{Separator}/u.test char ; // punctuation in any script const isPunct = /\p{P}/u.test char ; // any numeric digit in any language const isDigit = /\p{N}/u.test char ; // any letter const isLetter = /\p{L}/u.test char ; // any symbol const isSymbol = /\p{S}/u.test char ; if settings.includeWhitespace && isSpace return false; if settings.includePunctuation && isPunct return false; if settings.includeDigits && isDigit return false; if settings.includeSymbols && isSymbol return false; return true; } ; const workingText = filteredChars.join '' ; Second is making a frequency table js const charMap = {}; filteredChars.forEach char = { const key = settings.caseSensitive ? char : char.toLowerCase ; charMap key = charMap key || 0 + 1; } ; Next is making auxiliary processing js let minCodePoint = Infinity; let maxCodePoint = -Infinity; let maxFreq = 0; const data = entries.map char, count = { const preparedChar = settings.caseSensitive ? char : char.toLowerCase ; const codepoint = preparedChar.codePointAt 0 ; if codepoint < minCodePoint minCodePoint = codepoint; if codepoint maxCodePoint maxCodePoint = codepoint; if count maxFreq maxFreq = count; return { char, count, codepoint }; } ; return { data, minCodePoint, maxCodePoint, maxFreq, range: maxCodePoint - minCodePoint || 1 }; Finally, HSL calculations js export const getPalette = context = { if context return ; return context.data.map { char, count, codepoint } = { const h = Math.round codepoint - context.minCodePoint / context.range 360 ; const s = Math.round count / context.maxFreq 100 ; const l = 50; return { char, count, h, s, l, hsl: hsl ${h}, ${s}%, ${l}% }; } ; }; Anyway, let's see what the palette for our Christmas text will look like. I made two types of data visualization for that - Tile Chart and Bar Chart https://datavizcatalogue.com/methods/bar chart.html . A lot of interesting stuff is going on here. We can go the full naive approach, collect all the highest-scoring colors, and call it a day. But here is the problem - it's way too "loud", too vivid and intense https://coolors.co/visualizer/dd2222-8e7871-a68059-b49a4b-ffff00 to be a useful color palette. One way to overcome it will be to use the weighted clustering https://en.wikipedia.org/wiki/Cluster-weighted modeling algorithm. Long story short: 1 we split the 360° color wheel into 30° sectors buckets , since the standard color wheel has 12 colors, and 360/12 is 30. 2 we push each color into the corresponding bucket 3 we calculate the weight of each bucket high saturation is high weight 4 we calculate an average hue and average saturation for the sum of colors in each bucket 5 ??? 6 PROFIT there are no guarantees it will make a suitable palette, though, so proceed with caution Straight to the code: export default function getWeightedClusters palette: PaletteItem : ColorCluster { const buckets: Bucket = Array.from { length: 12 }, = { totalWeight: 0, sumX: 0, sumY: 0, count: 0, originalColors: } ; palette.forEach item = { // 1. Parse HSL const {h, s, l, char} = item; // 2. Calculate Weight Favor high saturation const weight = Math.pow s, 2 + 1; // +1 to give very desaturated colors a tiny vote // 3. Determine Bucket 0-11 // We shift by 15 degrees so the primary colors are in the center of buckets const bucketIndex = Math.floor h + 15 % 360 / 30 ; // 4. Convert Hue to Vector Radians const rad = h Math.PI / 180; const x = Math.cos rad weight; const y = Math.sin rad weight; // 5. Accumulate const b = buckets bucketIndex ; b.totalWeight += weight; b.sumX += x; b.sumY += y; b.count++; b.originalColors.push { h, s, l, weight, char } ; } ; // 6. Finalize Clusters return buckets .map b, index = { if b.count === 0 return null; // Convert vector average back to Hue let avgHue = Math.atan2 b.sumY, b.sumX 180 / Math.PI; if avgHue < 0 avgHue += 360; // Weighted average Saturation const avgSat = b.originalColors.reduce acc, c = acc + c.s c.weight , 0 / b.totalWeight; const bucketChars = b.originalColors.map item = {return {char: item.char, color: hsl ${item.h}, ${item.s}%, 50% }} ; return { id: index, representativeHue: Math.round avgHue , representativeSat: Math.round avgSat , strength: b.totalWeight, density: b.count, chars: bucketChars } as ColorCluster; } .filter c : c is ColorCluster = c == null ; } And visualization I've made a color wheel and pack of cards : Cool, huh? But it works not only with Latin alphabet-based languages, but theoretically supports everything. Here is text in Japanese I took from this site, Reading Passage 3 https://japanesetest4you.com/japanese-language-proficiency-test-jlpt-n5-reading-exercise-2/ That's actually only a small part of what could be done. We can calculate the average hue and saturation of the text and generate a palette from just one color https://mycolor.space/?hex=%23A79F32&sub=1 , for example. You can look at the code here https://github.com/al3xsus/text-to-palette You can look and play with the tool here https://al3xsus.github.io/text-to-palette/ Thanks for coming to my TED talk. Feel free to reach me in comments or anywhere. P.S. The cover image was created using a color palette derived from this post. R is for recursion.