Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
__pycache__/
*.pyc
16 changes: 16 additions & 0 deletions РукосуеваЕД/backend/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
FROM python:3.12-slim

RUN apt-get update \
&& apt-get install -y --no-install-recommends fonts-dejavu-core \
&& rm -rf /var/lib/apt/lists/*

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

RUN mkdir -p /app/media

EXPOSE 5000
47 changes: 47 additions & 0 deletions РукосуеваЕД/backend/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import os

from celery.result import AsyncResult
from flask import Flask, jsonify, request, send_from_directory

from celery_app import celery_app
from tasks import generate_avatar

MEDIA_DIR = os.environ.get("MEDIA_DIR", "/app/media")

app = Flask(__name__)


@app.post("/api/avatars")
def create_avatar():
data = request.get_json(silent=True) or {}
name = (data.get("name") or "").strip()
if not name:
return jsonify({"error": "Поле 'name' обязательно"}), 400

task = generate_avatar.delay(name)
return jsonify({"task_id": task.id}), 202


@app.get("/api/avatars/<task_id>")
def get_avatar_status(task_id):
result = AsyncResult(task_id, app=celery_app)

if result.state in ("PENDING", "STARTED"):
return jsonify({"status": "pending"})
if result.state == "FAILURE":
return jsonify({"status": "failure", "error": str(result.result)}), 500
if result.state == "SUCCESS":
filename = result.result["filename"]
return jsonify({"status": "success", "avatar_url": f"/api/media/{filename}"})

return jsonify({"status": result.state.lower()})


@app.get("/api/media/<path:filename>")
def get_media(filename):
return send_from_directory(MEDIA_DIR, filename)


@app.get("/api/health")
def health():
return jsonify({"status": "ok"})
19 changes: 19 additions & 0 deletions РукосуеваЕД/backend/celery_app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import os

from celery import Celery

REDIS_URL = os.environ.get("REDIS_URL", "redis://redis:6379/0")

celery_app = Celery(
"avatars",
broker=REDIS_URL,
backend=REDIS_URL,
include=["tasks"],
)

celery_app.conf.update(
task_serializer="json",
result_serializer="json",
accept_content=["json"],
result_expires=3600,
)
5 changes: 5 additions & 0 deletions РукосуеваЕД/backend/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
flask==3.0.3
gunicorn==22.0.0
celery==5.4.0
redis==5.0.8
Pillow==10.4.0
127 changes: 127 additions & 0 deletions РукосуеваЕД/backend/tasks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import colorsys
import hashlib
import os
import time
import uuid

from PIL import Image, ImageDraw, ImageFont

from celery_app import celery_app

MEDIA_DIR = os.environ.get("MEDIA_DIR", "/app/media")
AVATAR_SIZE = 256

FONT_CANDIDATES = [
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
]


def _initials(name: str) -> str:
parts = [p for p in name.strip().split() if p]
if not parts:
return "?"
if len(parts) == 1:
return parts[0][0].upper()
return (parts[0][0] + parts[1][0]).upper()


def _hsl_to_rgb(hue_deg: float, saturation: float, lightness: float) -> tuple:
r, g, b = colorsys.hls_to_rgb((hue_deg % 360) / 360, lightness, saturation)
return (int(r * 255), int(g * 255), int(b * 255))


def _gradient_colors(digest: str) -> tuple:
hue1 = int(digest[0:4], 16) % 360
hue2 = int(digest[4:8], 16) % 360
if abs(hue1 - hue2) < 40:
hue2 = (hue2 + 90) % 360

color1 = _hsl_to_rgb(hue1, 0.65, 0.55)
color2 = _hsl_to_rgb(hue2, 0.65, 0.40)
return color1, color2


def _diagonal_gradient(size: int, color1: tuple, color2: tuple) -> Image.Image:
base = Image.new("RGB", (size, size), color1)
top = Image.new("RGB", (size, size), color2)

mask = Image.new("L", (size, size))
mask_data = [
int((x + y) / (2 * (size - 1)) * 255)
for y in range(size)
for x in range(size)
]
mask.putdata(mask_data)

return Image.composite(top, base, mask)


def _add_pattern(image: Image.Image, digest: str) -> Image.Image:
size = image.size[0]
values = [int(digest[i : i + 2], 16) for i in range(8, 24, 2)]

overlay = Image.new("RGBA", image.size, (0, 0, 0, 0))
draw = ImageDraw.Draw(overlay)

for i in range(3):
vx = values[i % len(values)]
vy = values[(i + 1) % len(values)]
vr = values[(i + 2) % len(values)]
valpha = values[(i + 3) % len(values)]

cx = int(vx / 255 * size)
cy = int(vy / 255 * size)
radius = int(size * 0.15 + (vr / 255) * size * 0.2)
alpha = 25 + int(valpha / 255 * 35)

draw.ellipse(
[cx - radius, cy - radius, cx + radius, cy + radius],
fill=(255, 255, 255, alpha),
)

return Image.alpha_composite(image.convert("RGBA"), overlay)


def _load_font(size: int):
for path in FONT_CANDIDATES:
if os.path.exists(path):
return ImageFont.truetype(path, size)
return ImageFont.load_default()


@celery_app.task(name="tasks.generate_avatar")
def generate_avatar(name: str) -> dict:
# Имитация длительной обработки — генерация вынесена в отдельный worker-сервис.
time.sleep(2)

os.makedirs(MEDIA_DIR, exist_ok=True)

digest = hashlib.md5(name.encode("utf-8")).hexdigest()
initials = _initials(name)
color1, color2 = _gradient_colors(digest)

image = _diagonal_gradient(AVATAR_SIZE, color1, color2)
image = _add_pattern(image, digest)

draw = ImageDraw.Draw(image)
font = _load_font(AVATAR_SIZE // 2)
bbox = draw.textbbox((0, 0), initials, font=font)
text_w, text_h = bbox[2] - bbox[0], bbox[3] - bbox[1]
position = (
(AVATAR_SIZE - text_w) / 2 - bbox[0],
(AVATAR_SIZE - text_h) / 2 - bbox[1],
)
shadow_offset = 2
draw.text(
(position[0] + shadow_offset, position[1] + shadow_offset),
initials,
fill=(0, 0, 0, 90),
font=font,
)
draw.text(position, initials, fill="white", font=font)

filename = f"{uuid.uuid4().hex}.png"
filepath = os.path.join(MEDIA_DIR, filename)
image.convert("RGB").save(filepath, format="PNG")

return {"filename": filename}
4 changes: 4 additions & 0 deletions РукосуеваЕД/backend/wsgi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from app import app

if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)
48 changes: 48 additions & 0 deletions РукосуеваЕД/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
name: avatar-generator

services:
redis:
image: redis:7-alpine
restart: unless-stopped

backend:
build: ./backend
command: gunicorn wsgi:app --bind 0.0.0.0:5000 --workers 2
environment:
REDIS_URL: redis://redis:6379/0
MEDIA_DIR: /app/media
volumes:
- avatars_data:/app/media
depends_on:
- redis
restart: unless-stopped

worker:
build: ./backend
command: celery -A celery_app worker --loglevel=info
environment:
REDIS_URL: redis://redis:6379/0
MEDIA_DIR: /app/media
volumes:
- avatars_data:/app/media
depends_on:
- redis
restart: unless-stopped

frontend:
build: ./frontend
restart: unless-stopped

nginx:
image: nginx:1.27-alpine
volumes:
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
ports:
- "8080:80"
depends_on:
- backend
- frontend
restart: unless-stopped

volumes:
avatars_data:
5 changes: 5 additions & 0 deletions РукосуеваЕД/frontend/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
FROM nginx:1.27-alpine

COPY index.html style.css app.js /usr/share/nginx/html/

EXPOSE 80
55 changes: 55 additions & 0 deletions РукосуеваЕД/frontend/app.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
const form = document.getElementById("avatar-form");
const nameInput = document.getElementById("name-input");
const statusEl = document.getElementById("status");
const resultEl = document.getElementById("result");

form.addEventListener("submit", async (event) => {
event.preventDefault();
const name = nameInput.value.trim();
if (!name) return;

resultEl.innerHTML = "";
statusEl.textContent = "Отправка задачи...";

try {
const response = await fetch("/api/avatars", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});

if (!response.ok) {
const err = await response.json();
throw new Error(err.error || "Ошибка сервера");
}

const { task_id } = await response.json();
statusEl.textContent = "Аватар генерируется...";
pollStatus(task_id);
} catch (error) {
statusEl.textContent = `Ошибка: ${error.message}`;
}
});

function pollStatus(taskId) {
const interval = setInterval(async () => {
try {
const response = await fetch(`/api/avatars/${taskId}`);
const data = await response.json();

if (data.status === "success") {
clearInterval(interval);
statusEl.textContent = "Готово!";
resultEl.innerHTML = `<img src="${data.avatar_url}" alt="Аватар" class="avatar" />`;
} else if (data.status === "failure") {
clearInterval(interval);
statusEl.textContent = `Ошибка генерации: ${data.error || ""}`;
} else {
statusEl.textContent = "Аватар генерируется...";
}
} catch (error) {
clearInterval(interval);
statusEl.textContent = `Ошибка: ${error.message}`;
}
}, 1000);
}
25 changes: 25 additions & 0 deletions РукосуеваЕД/frontend/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Генератор аватарок по имени</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="card">
<h1>Генератор аватарок</h1>
<p>Введите имя — и получите аватар с инициалами на цветном фоне.</p>

<form id="avatar-form">
<input type="text" id="name-input" placeholder="Например: Екатерина Рукосуева" required />
<button type="submit">Сгенерировать</button>
</form>

<div id="status" class="status"></div>
<div id="result"></div>
</main>

<script src="app.js"></script>
</body>
</html>
Loading