A place to cache linked articles (think custom and personal wayback machine)
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

index.md 77KB

title: Representing SHA-256 Hashes As Avatars url: https://francoisbest.com/posts/2021/hashvatars hash_url: be7ca57289

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.

  1. 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.
  2. 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.
More details

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
315f5bdb76d078c43b8ac0064e4a0164612b1fce77c869345bfc94c75894edd3

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 `M ${x} ${y}`
}

function lineTo({ x, y }: Point) {
  return `L ${x} ${y}`
}

function arcTo({ x, y }: Point, radius: number) {
  return `A ${radius} ${radius} 0 0 0 ${x} ${y}`
}

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 `hsl(${h}, ${s}%, ${l}%)`
}

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.

  1. We XOR all the bytes of the hash together to compute the hash soul
  2. 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 `hsl(${h}, ${s}%, ${l}%)` }

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.