Representing SHA-256 Hashes As Avatars
If you follow me on Twitter, you may be aware of my weird weekend projects.
They are little challenges I give myself, usually without too many stakes involved, and with small enough a scope so that I can ship it in a day or two, while keeping spare family time.
This weekend's project is to build a transaction explorer for the Centralized Coin experiment. Something like a blockchain explorer, but without the crypto overhead.
Each node in the system is represented by a hash. Because humans are terrible at reading and quickly identifying large numbers (other than by their first or last few digits), a visual representation is needed.
There are many solutions out there: WordPress and GitHub identicons, robohash, monsterID etc..
I wanted one that still looks abstract (not as opinionated as monsters or robots), and that plays nice in a rounded avatar UI component.
This is what I ended up with:
Change the input below to see how the SHA-256 hash changes the render
If you are allergic to maths and trigonometry, feel free to play with the more detailed interactive example here. Otherwise, let's dive into how it's done.
Space Partitioning#
Many of the existing solutions produce square images, yet avatars are often displayed as circles. They would lose information on the corners when rounded, so instead of a cartesian (x-y) approach, we're going to use polar (angle-radius) coordinates instead.
A grid in cartesian space maps to concentric circles and pie-like cuts in polar space.
SHA-256 hashes have 256 bits of information that we need to represent. Dividing a circle into 256 sections would make each section too small to be visually useful, and would only leave 1 bit of "value" to represent in each section (0 or 1, black or white).
Instead, we're going to divide the circle into 32 sections:
- 4 concentric rings
- 8 pie-like cuts
The resulting SVG code for such a grid looks like this (in a React component):
export const SHA256Avatar = () => {
const r1 = 1
const r2 = r1 * 0.75
const r3 = r1 * 0.5
const r4 = r1 * 0.25
return (
<svg viewBox="-1 -1 2 2">
<circle cx={0} cy={0} r={r1} />
<circle cx={0} cy={0} r={r2} />
<circle cx={0} cy={0} r={r3} />
<circle cx={0} cy={0} r={r4} />
<line x1={-r1} x2={r1} y1={0} y2={0} />
<line y1={-r1} y2={r1} x1={0} x2={0} />
<line
x1={-r1 * Math.SQRT1_2}
x2={r1 * Math.SQRT1_2}
y1={-r1 * Math.SQRT1_2}
y2={r1 * Math.SQRT1_2}
/>
<line
x1={r1 * Math.SQRT1_2}
x2={-r1 * Math.SQRT1_2}
y1={-r1 * Math.SQRT1_2}
y2={r1 * Math.SQRT1_2}
/>
</svg>
)
}
Doing this naively, with each concentric ring's radius being 1/4th of the outermost/largest one gives us this:
There are some issues: the innermost ring sections are tiny compared to the outermost.
If we calculate the radii so that each section has an equal area, we get the following result:
Equal areas are calculated by solving a system of equations.
- Each section area is 1/32nd of the area of the whole circle. Assuming the outer circle has a radius of 1, that's an area of
π/32
. - To compute the associated radius for a ring, we express the pie slice area with the outer radius R and subtract the pie slice area with the inner radius r:
Pi R^2 - Pi r^2
, then we iterate from the outside in.
Not very pleasing either. How about a mix of both ?
(Play with the slider to blend between equal radii and equal areas)
I don't know about you, but 0.42
hits the ballpark both in aesthetics and
nerd-sniping satisfaction, so let's go for that.
Section Mapping#
Now that we have 32 nice looking sections on our circle, we can map each section to an 8-bit value in the hash.
As an example, let's take the following hash, the output of sha256("Hello, world!")
:
315f5bdb76d078c43b8ac0064e4a0164612b1fce77c869345bfc94c75894edd3
We can split it in 32 blocks of 8 bits (2 hexadecimal digits), and organise them by 4 blocks of 8 to map to the rings:
12 o'clock -> clockwise
31 5f 5b db 76 d0 78 c4 Outer ring
3b 8a c0 06 4e 4a 01 64 Middle-outer ring
61 2b 1f ce 77 c8 69 34 Middle-inner ring
5b fc 94 c7 58 94 ed d3 Inner ring
In order to fill each section with a different colour, we generate an SVG <path>
polygon.
Each section resembles a pie/pizza slice, going from the center
of the circle to a given radius, and covering 1/8th of the circle.
The reason we can get away with all sections going to the center is because of our mapping order: by laying out from the outside in, the inner sections will be overlaid on top of the outer ones in z-index.
Since SVG uses cartesian coordinates, we'll have to convert our polar logic into cartesian SVG path commands:
interface Point {
x: number
y: number
}
function polarPoint(radius: number, angle: number): Point {
return {
x: radius Math.cos(2 Math.PI angle - Math.PI / 2),
y: radius Math.sin(2 Math.PI angle - Math.PI / 2)
}
}
function moveTo({ x, y }: Point) {
return </span><span class="token string">M </span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span>x<span class="token interpolation-punctuation punctuation">}</span></span><span class="token string"> </span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span>y<span class="token interpolation-punctuation punctuation">}</span></span><span class="token template-punctuation string">
}
function lineTo({ x, y }: Point) {
return </span><span class="token string">L </span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span>x<span class="token interpolation-punctuation punctuation">}</span></span><span class="token string"> </span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span>y<span class="token interpolation-punctuation punctuation">}</span></span><span class="token template-punctuation string">
}
function arcTo({ x, y }: Point, radius: number) {
return </span><span class="token string">A </span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span>radius<span class="token interpolation-punctuation punctuation">}</span></span><span class="token string"> </span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span>radius<span class="token interpolation-punctuation punctuation">}</span></span><span class="token string"> 0 0 0 </span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span>x<span class="token interpolation-punctuation punctuation">}</span></span><span class="token string"> </span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span>y<span class="token interpolation-punctuation punctuation">}</span></span><span class="token template-punctuation string">
}
const Section = ({ index, radius }) => {
const angleA = index / 8
const angleB = (index + 1) / 8
const path = [
moveTo({ x: 0, y: 0 }),
lineTo(polarPoint(radius, angleA)),
arcTo(polarPoint(radius, angleB), radius),
'Z'
].join(' ')
return <path d={path} />
}
Colour Mapping#
Now we can turn each section's byte value into a colour.
For that, we have many options. 8 bits could map directly to 256 colours like the
old Windows systems, but that would require a lookup table. Instead, we can
generate colours using the hsl()
CSS function.
Hue is the component that has the most visual impact, while Saturation and Lightness can be used to add little variants to each section.
To map our 8 bit value to 3 components, we can divide the byte into:
- 4 bits for the Hue (16 values)
- 2 bits for the Saturation (4 values)
- 2 bits for the Lightness (4 values)
function mapValueToColor(value) {
const colorH = value >> 4
const colorS = (value >> 2) & 0x03
const colorL = value & 0x03
const normalizedH = colorH / 16
const normalizedS = colorS / 4
const normalizedL = colorL / 4
const h = 360 normalizedH
const s = 50 + 50 normalizedS
const l = 40 + 30 normalizedL
return </span><span class="token string">hsl(</span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span>h<span class="token interpolation-punctuation punctuation">}</span></span><span class="token string">, </span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span>s<span class="token interpolation-punctuation punctuation">}</span></span><span class="token string">%, </span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span>l<span class="token interpolation-punctuation punctuation">}</span></span><span class="token string">%)</span><span class="token template-punctuation string">
}
We can adjust the range for each component to get nice results:
The colour mapping function could use a high-contrast version that focuses on the Luminosity channel rather than the Hue.
Order, Chaos & Soul#
Our colour encoding suffers from a flaw: two hashes can look very similar, but have a few bits of difference here and there. They can go unnoticed especially if differences occur in the LSBs of hue/saturation/lightness components.
Also, the sections look random in colour, and the whole avatar lacks coherence.
It would be nice if there was some pattern to a hash that makes it random enough to be distinguished yet coherent enough within itself. A balance between order and chaos.
In order to fix that, we compute the soul of the hash, using XOR operations.
- We XOR all the bytes of the hash together to compute the hash soul
- For each ring, we XOR the bytes that form this ring's section to compute the ring's soul. (horcruxes?)
function computeSouls(bytes: string[]) {
const ringLength = Math.round(bytes.length / 4)
const rings = [
bytes.slice(0, ringLength),
bytes.slice(1 ringLength, 2 ringLength),
bytes.slice(2 ringLength, 3 ringLength),
bytes.slice(3 ringLength, 4 ringLength)
]
const xorReducer = (xor: number, byte: string) => xor ^ parseInt(byte, 16)
return {
hashSoul: (bytes.reduce(xorReducer, 0) / 0xff) 2 - 1,
ringSouls: rings.map(ring => (ring.reduce(xorReducer, 0) / 0xff) * 2 - 1)
}
}
These values give us additional parameters to play with in the colour calculation.
Notably, we can "seed" the Hue with the hash soul, and introduce hue varitions per-ring with each ring soul, and with the value itself.
export function mapValueToColor({ value, hashSoul, ringSoul }) {
const colorH = value >> 4
const colorS = (value >> 2) & 0x03
const colorL = value & 0x03
const normalizedH = colorH / 16
const normalizedS = colorS / 4
const normalizedL = colorL / 4
const h = 360 hashSoul
+ 120 ringSoul
+ 30 normalizedH
const s = 50 + 50 normalizedS
const l = 40 + 30 * normalizedL
return </span><span class="token string">hsl(</span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span>h<span class="token interpolation-punctuation punctuation">}</span></span><span class="token string">, </span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span>s<span class="token interpolation-punctuation punctuation">}</span></span><span class="token string">%, </span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span>l<span class="token interpolation-punctuation punctuation">}</span></span><span class="token string">%)</span><span class="token template-punctuation string">
}
We can also introduce structural variations by changing each ring's starting angle based on the ring soul, to create a staggering effect:
Without souls
With souls
With souls & staggering
Not only does this help give a bit more uniqueness to the avatar, it also helps with accessibility for colour-blind people
A Bit Of Fun#
If we change the radius and flags for the arc part of the section paths and play with each ring's starting angle, we can obtain interesting variations:
Going Further#
With a bit of tweaking in the colour mapping value, we can easily extend this technique to arbitrary hash lengths (as long as said length is divisible by 32).
It so happens that when I started this project, Centralized Coin was using SHA-256, but later on switched to SHA-384, which gives 12 bits per section.
Conclusion#
You can see the hashvatars (thanks to @wzulfikar for the name) in action in the Centralized Coin Explorer, or play with the variants yourself on the playground.
I will publish the code as an NPM package later, in the mean time the source code for this article is on GitHub, as well as the component itself.
Follow me on Twitter for updates and more weekend projects.