Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add random avatar generation to backend #327

Closed
wants to merge 16 commits into from
Closed
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 56 additions & 2 deletions backend/siarnaq/api/user/models.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import posixpath
import random
import uuid

import google.cloud.storage as storage
import numpy as np
apollo1291 marked this conversation as resolved.
Show resolved Hide resolved
from django.conf import settings
from django.contrib.auth.models import AbstractUser
from django.db import models, transaction
from django.utils.translation import gettext_lazy as _
from django_countries.fields import CountryField
from PIL import Image

from siarnaq.gcloud import titan


class User(AbstractUser):
Expand Down Expand Up @@ -102,14 +107,63 @@ def get_avatar_url(self):
"""Return a cache-safe URL to the avatar."""
# To circumvent caching of old avatars, we append a UUID that changes on each
# update.

if not self.has_avatar:
return None

client = storage.Client.create_anonymous_client()
def get_gradient_3d(
width, height, start_list, stop_list, is_horizontal_list
):
"""Generate a gradient image as a numpy array"""

def get_gradient_2d(start, stop, width, height, is_horizontal):
if is_horizontal:
return np.tile(np.linspace(start, stop, width), (height, 1))
else:
return np.tile(np.linspace(start, stop, height), (width, 1)).T

result = np.zeros((height, width, len(start_list)), dtype=np.float)

for i, (start, stop, is_horizontal) in enumerate(
zip(start_list, stop_list, is_horizontal_list)
):
result[:, :, i] = get_gradient_2d(
start, stop, width, height, is_horizontal
)

return result

# generate a random avatar
self.has_avatar = True
self.avatar_uuid = uuid.uuid4()
self.save()

# generate a unique seed
random.seed(self.avatar_uuid.int)

# generate unique rgb
rgb1 = (
int(random.random() * 255),
apollo1291 marked this conversation as resolved.
Show resolved Hide resolved
int(random.random() * 255),
int(random.random() * 255),
)
rgb2 = (
int(random.random() * 255),
int(random.random() * 255),
int(random.random() * 255),
)

array = get_gradient_3d(512, 256, rgb1, rgb2, (True, True, True))
avatar = Image.fromarray(np.uint8(array), mode="RGB")

titan.upload_image(avatar, self.get_avatar_path())

# store it in cloud for future use.
apollo1291 marked this conversation as resolved.
Show resolved Hide resolved
client = storage.Client()
apollo1291 marked this conversation as resolved.
Show resolved Hide resolved
public_url = (
client.bucket(settings.GCLOUD_BUCKET_PUBLIC)
.blob(self.get_avatar_path())
.public_url
)

# Append UUID to public URL to prevent caching on avatar update
return f"{public_url}?{self.avatar_uuid}"
6 changes: 5 additions & 1 deletion backend/siarnaq/gcloud/titan.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,11 @@ def get_object(bucket: str, name: str, check_safety: bool) -> dict[str, str | bo


def upload_image(raw_image, image_path):
img = Image.open(raw_image)
try:
img = Image.open(raw_image)
apollo1291 marked this conversation as resolved.
Show resolved Hide resolved
except AttributeError:
img = raw_image

img.thumbnail(settings.GCLOUD_MAX_AVATAR_SIZE)

# Prepare image bytes for upload to Google Cloud
Expand Down
2 changes: 2 additions & 0 deletions environment-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ dependencies:
- isort=5.10.1
- mypy=0.942
- nodejs=18.11.0
- numpy-base=1.23.4
apollo1291 marked this conversation as resolved.
Show resolved Hide resolved
- numpy=1.23.4
- openssl=3.0.5
- pillow=9.0.1
- pip=22.3
Expand Down
73 changes: 0 additions & 73 deletions frontend/src/components/avatar.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,44 +4,6 @@ import React, { Component } from "react";
* props: data — either user or team, used to get avatar or seed for random generation.
* if data does not have either name or username defined, empty avatar will be returned */
class Avatar extends Component {
// seeded random numbers bc this isn't part of math.random in js
seededRNG(seed, min = 0, max = 1, depth = 0) {
//hashing seed to try to remove correlation
const hashSeed = (seed * 2654435761) % Math.pow(2, 32);
// see softwareengineering.stackexchange.com/questions/260969
const modSeed = (hashSeed * 9301 + 49297) % 233280;
const rand = modSeed / 233280;
const randBound = min + rand * (max - min);

// run this 3 times to remove correlation!
if (depth === 2) {
return min + rand * (max - min);
} else {
return this.seededRNG(randBound, min, max, depth + 1);
}
}

// converts colors from HSV to RGB encoding
// adapted from https://en.wikipedia.org/wiki/HSL_and_HSV#HSV_to_RGB_alternative
HSVtoRGB(hsv) {
const func = (n, hsv) => {
let k = (n + hsv[0] / 60) % 6;
return Math.round(
(hsv[2] - hsv[2] * hsv[1] * Math.max(Math.min(k, 4 - k, 1), 0)) * 255
);
};
return [func(5, hsv), func(3, hsv), func(1, hsv)];
}

// converts colors from RGB to hex string ('#xxxxxx')
RGBtoHex(rgb) {
const hex = (comp) => {
var str = comp.toString(16);
return str.length === 1 ? "0" + str : str;
};
return `#${hex(rgb[0])}${hex(rgb[1])}${hex(rgb[2])}`;
}

render() {
const data = this.props.data;
const has_avatar = data.profile.has_avatar;
Expand All @@ -59,43 +21,8 @@ class Avatar extends Component {
></div>
);
}

// no avatar, create a random one. which must always be same for this entity
// random number derived from hash of str defines HSV color (rand°, 100%, 100%)
// second random number is transparent "accent color"

const seedStr = this.stringHash(data.name ? data.name : data.username);
const num = Math.floor(this.seededRNG(seedStr, 0, 361));
const colorStr = this.RGBtoHex(this.HSVtoRGB([num, 1, 1]));
const num2 = Math.floor(this.seededRNG(data.id, 0, 361));
const colorStr2 = this.RGBtoHex(this.HSVtoRGB([num2, 1, 1])) + "50";

const gradStr = `linear-gradient(45deg, ${colorStr}, ${colorStr2})`;

return (
<div
className="avatar border-gray"
style={{ background: gradStr, display: "inline-block" }}
></div>
);
}
}

// gives numerical hash for string (stackoverflow.com/questions/7616461/)
stringHash(str) {
apollo1291 marked this conversation as resolved.
Show resolved Hide resolved
let hash = 0,
chr;

if (str.length === 0) return hash;

for (let i = 0; i < str.length; i++) {
chr = str.charCodeAt(i);
hash = (hash << 5) - hash + chr;
hash |= 0; // Convert to 32bit integer
}

return Math.abs(hash);
}
}

export default Avatar;