javascript

Делаем самые лучшие фото для документов

  • среда, 29 октября 2025 г. в 00:00:04
https://habr.com/ru/articles/960714/

Привет, Хабр!

Делали ли вы электронную визу в Индию? А, может, в Южную Корею? Или подавались на лотерею Green Card в США? Если да, то вы точно знаете, что для заявки на все эти документы надо прикрепить фотографию определённого размерас целым набором требований...

А такое ну просто необходимо автоматизировать!

И, как можно догадаться, сайтов для автоматизации фотографий на документы просто куча. Только вот есть одна проблема: все эти сайты хотят много денег - от 5 до 12 долларов за приведение фото к нужным требованиям.

Терпеть такое я, конечно же, не стал и написал своего бота в Телеграме, который делает это всё бесплатно.

TL;DR

@photovisa_bot - бесплатный бот для генерации фото на документы.

С чего все началось

Сидел я одним зимним вечером на диване. А моя девушка планировала поезду с подругами в Китай и в это время заполняла анкету на визу (да-да, тогда еще нужны были визы!).

Когда она всё заполнила, я подошёл поинтересоваться, как все прошло. И вдруг обнаружил, что для подачи заявки она с подругами купила создание фото на визу за 400 рублей. Тогда я подумал про себя: "А отличный бизнес можно сделать - удаляем задний фон, выравниваем лицо на фото - и ко мне потекут горы денег. Богатство и слава! АХАХАХАХАХ!" И благополучно забыл об этом.

И вот, спустя почти год, мне на работе стало скучно, и я решил, что пора приниматься за дело (естественно, в рабочее время). Получилось сделать бота быстро и хорошо, поэтому решил оставить его бесплатным для всех.

Я после продажи фото на визу (на самом деле бот бесплатный!)
Я после продажи фото на визу (на самом деле бот бесплатный!)

Придумываем архитектуру

Делаем мы приложение для себя, поэтому самое главное - чтобы его было приятно разрабатывать. Поэтому в выборе архитектуры я полагался только на свои специфичные вкусы.

Я люблю Телеграм-ботов - их просто и быстро делать, поэтому будем делать свой собственный Телеграм-миниапп для обработки фото на визы.

Вот такая будет архитектура
Вот такая будет архитектура

Чтобы сделать Телеграм-миниапп, у нас будут следующие компоненты:

Bot Server

Обычный сервер на питоне, который:

  • обрабатывает сообщения от пользователя в чате

  • дает ему ссылку на фронтенд

  • принимает от фронтенда запрос на обработку фото и кладет его в очередь

  • когда фото готово, отправляет его пользователю в чат

Frontend miniapp

А вот это у нас будет сайт на React!
Если давать юзеру интерфейс через чат Телеграма, то это неудобно (как из огромного списка стран для документов найти нужную?) и не очень красиво (хочется показать приятный лоадер, пока генерируем фото). А миниаппы в Телеграме позволяют нам открыть свой сайт и избавиться от всех этих проблем.
Этот сайт будет:

  • давать пользователю возможность выбрать страну, выбрать тип документа и загрузить фото

  • показывать красивый лоадер, пока мы генерируем для пользователя фото

Photo processing worker

Это будет простенький сервис на питоне, который:

  • принимает фото из очереди

  • обрабатывает его и передает нашему Bot Server'у

Что ж, архитектура спланирована, пора приступать к реализации.

Займемся скраппингом данных

У каждой страны и у каждого документа есть свои требования к:

  • размерам (в пикселях, мм или дюймах)

  • расположению лица на фото (какой размер лицо занимает по высоте, размер отступа от верха фото до макушки и т.д.)

  • размеру файла (у некоторых документов в Китае максимальный размер 40кб)

  • цвету заднего фона (некоторые просят голубой фон вместо белого)

Так как же нам их получить? Все просто - украсть с других сайтов. Вместо тысячи слов вот вам ссылка на репозиторий со скриптами для скраппинга и нормализации данных с одного известного сайта для виз + готовые JSON-файлы.

Создаем worker для обработки фото

Для обработки фото нам понадобится удаление заднего фона, поиск лица и его ключевых точек на картинке.

Удаление заднего фона - задача очень известная и решений опубликовано куча. Но не все они нам подойдут - тот же rembg довольно старый и плохо работает с волосами (а у нас их планируется много). Я решил взять свежую модельку ZhengPeng7/BiRefNet для поиска маски заднего фона.

Как видно, rembg оставляет артефакты на волосах Роберта Патиссона
Как видно, rembg оставляет артефакты на волосах Роберта Патиссона

Моделька довольно большая и на CPU работает медленно, поэтому пришлось взять тачку с RTX 3060 чтобы работало быстрее. А вот так эту модельку можно использовать в коде:

import torch
import numpy as np
from PIL import Image
from transformers import AutoModelForImageSegmentation
from torchvision import transforms as T

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Using device: {device}")

print("Loading BiRefNet model...")
model = AutoModelForImageSegmentation.from_pretrained("ZhengPeng7/BiRefNet", trust_remote_code=True)
model.to(device)
model.eval()

preprocess = T.Compose([
    T.Resize((1024, 1024)),
    T.ToTensor(),
    T.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]),
])

def remove_background(pil_image: Image.Image) -> np.ndarray:
    image = pil_image.convert("RGB")
    original_size = image.size
    
    input_tensor = preprocess(image).unsqueeze(0).to(device)
    
    with torch.no_grad():
        predictions = model(input_tensor)[-1].sigmoid().cpu()
    
    pred = predictions[0].squeeze()
    mask_pil = T.ToPILImage()(pred)
    mask_resized = mask_pil.resize(original_size, Image.BILINEAR)
    
    mask_array = np.array(mask_resized, dtype=np.uint8)
    
    image_array = np.array(image)
    rgba_array = np.dstack((image_array, mask_array))
    
    return rgba_array

Для поиска лица и ключевых точек мы возьмем dlib. Он старенький, но в этой задаче нам ювелирная точность не понадобится.

Будем использовать его так:

import cv2
import dlib

cnn_detector = dlib.cnn_face_detection_model_v1("mmod_human_face_detector.dat")
predictor = dlib.shape_predictor("shape_predictor_68_face_landmarks.dat")

def detect_face_and_landmarks(img):
    gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)
    
    detections = cnn_detector(img, 1)
    
    if len(detections) == 0:
        return None, None
    
    face_rect = detections[0].rect
    
    landmarks = predictor(gray, face_rect)
    
    return face_rect, landmarks
Работает просто прекрасно
Работает просто прекрасно

Как только готовы удаление заднего фона и поиск ключевых точек лица, остальное сделать тривиально: нужно добавить еще получение данных из очереди, расположить лицо по требованиям документа и отправить результат на наш сервер. Чтобы не перегружать вас, дорогой читатель, оставлю это все за кадром.

Создаем сервер для нашего бота

Мы сделали worker, но чтобы отпралять туда запросы нам нужно обрабатывать запросы от пользователей в чате Телеграма. Давайте напишем бота!

Для начала сделаем простого бота, который будет отображать нам кнопку для открытия миниаппа прямо в чате!

import os
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup, WebAppInfo
from telegram.ext import Application, CommandHandler, ContextTypes

BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN")
# Адрес нашего будущего миниаппа
WEB_URL = os.getenv("WEBAPP_URL", "https://127.0.0.1:3000/")

async def start(update: Update, context: ContextTypes.DEFAULT_TYPE):
    keyboard = InlineKeyboardMarkup([
        [InlineKeyboardButton("Открыть miniapp", web_app=WebAppInfo(url=WEB_URL))],
    ])

    if update.message:
        await update.message.reply_text(
            "Нажмите ниже, чтобы открыть miniapp",
            reply_markup=keyboard,
        )

def main():
    app = Application.builder().token(BOT_TOKEN).build()
    app.add_handler(CommandHandler("start", start))
    app.run_polling(allowed_updates=Update.ALL_TYPES)

Отлично - что-то готово! Теперь добавим функцию для авторизации пользователя, когда он будет слать нам запросы из миниаппа на бэкенд. Телеграм отправляет внутрь миниаппа специальный payload, который миниаппу надо будет отправлять нам на бекенд. Я написал функцию для валидации этого payload на бекенде и положил ее сюда под спойлер (она довольно магическая):

Функция _validate_and_extract_auth для валидации payload
import hashlib
import hmac
import time


def _parse_auth_data(raw: str):
    data = {}
    for pair in raw.split("&"):
        if not pair or "=" not in pair:
            continue
        k, v = pair.split("=", 1)
        data[unquote_plus(k)] = unquote_plus(v)
    return data


def _compute_secret_key(bot_token: str):
    return hmac.new(key=b"WebAppData", msg=bot_token.encode(), digestmod=hashlib.sha256).digest()


def _validate_and_extract_auth(raw: str, bot_token: str, max_age_seconds: int = 3000):
    data = _parse_auth_data(raw)
    if "hash" not in data:
        raise ValueError("hash field missing")
    if "auth_date" not in data:
        raise ValueError("auth_date missing")
    try:
        auth_date = int(data.get("auth_date", "0"))
    except ValueError:
        raise ValueError("auth_date invalid")
    now = int(time.time())
    if now - auth_date > max_age_seconds:
        raise ValueError("auth data too old")
    received_hash = data.pop("hash")
    data_check_pairs = [f"{k}={data[k]}" for k in sorted(data.keys())]
    data_check_string = "\n".join(data_check_pairs)
    secret_key = _compute_secret_key(bot_token)
    calculated_hash = hmac.new(secret_key, data_check_string.encode(), hashlib.sha256).hexdigest()
    if calculated_hash != received_hash:
        raise ValueError("hash mismatch")
    return data

Теперь добавим несколько роутов FastAPI:

  • один - чтобы миниапп мог отправить запрос на генерацию фото

  • второй - чтобы при получении ответа от worker'а мы отправили сообщение пользователю в чат

import io
import json
from typing import Optional
from telegram import Bot
from fastapi import FastAPI, File, Form, UploadFile, Header, HTTPException, status
from fastapi.responses import JSONResponse
from helpers import _validate_and_extract_auth

app = FastAPI(title="Test TG FastAPI")


@app.post("/request_photo_generation")
async def request_photo_generation(
    Authorization: Optional[str] = Header(default=None),
    doc_id: str = Form(...),
    photo: UploadFile = File(...),
):
    try:
        auth_data = _validate_and_extract_auth(Authorization, BOT_TOKEN)
    except ValueError as e:
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=f"Invalid auth data: {e}")

    user_obj = json.loads(auth_data["user"])
    user_id = int(user_obj.get("id"))

    # а затем пишем эти данные в очередь для worker'а
    whatewer_queue_you_want_to_use.put({
        "user_id": user_id,
        "doc_id": doc_id,
        "photo": await photo.read(),
        "filename": photo.filename,
    })

    return JSONResponse(status_code=200, content={
        "status": "queued",
        "user_id": user_id,
        "doc_id": doc_id,
        "filename": photo.filename,
    })


@app.post("/receive_photo_result")
async def receive_photo_result(
    user_id: int = Form(...),
    photo: UploadFile = File(...),
    caption: str = Form(default=""),
):
    content = await photo.read()
    bio = io.BytesIO(content)
    bio.name = photo.filename or "photo.jpg"
    await BOT.send_document(chat_id=user_id, document=bio, caption=caption)
    return {"status": "sent", "user_id": user_id}

И вот таким образом наш бекенд уже готов принимать картинку на вход и отправлять пользователю готовое фото на документ.

Готовим фронтенд для миниаппа

Для frontend'а мы делаем все максимально просто - берем create-react-app и копируем дизайн текстов и кнопок как у бота @BotFather. И все, наш фронтенд готов! (Правда, тут вообще ничего интересного не было...)

Для получения данных авторизации используем готовую библиотечку от Телеграма:

import { retrieveRawInitData } from '@telegram-apps/sdk';

const initDataRaw = retrieveRawInitData();
// А потом вставляем эти данные в заголовок Authorization при запросе /request_photo_generation

Единственное, с чем могут возникнуть сложности - это локальная отладка миниаппа на десктопном сайте телеграма. Он работает по HTTPS, а это значит что все сайты, которые будут открыты с помощью iframe, тоже должны его поддерживать.

Чтобы наше приложение можно было открыть локально по https, давайте сгенерируем приватный ключик и соответствующий сертификат:

openssl req -x509 -nodes -newkey rsa:2048 \
  -keyout localhost-key.pem -out localhost.pem \
  -days 825 -subj "/CN=localhost" \
  -addext "subjectAltName=DNS:localhost,IP:127.0.0.1"

Добавим их в параметры запуска нашего приложения:

# package.json
{
    ...
    "scripts": {
        "start": "HTTPS=true SSL_CRT_FILE=localhost.pem SSL_KEY_FILE=localhost-key.pem react-scripts start",
        ...
    }
}

И конечно же добавим сертификат в Хром. Переходим по ссылке chrome://certificate-manager/localcerts/usercerts и в Trusted Certificates добавляем наш новосозданный localhost.pem.
Вуаля! Теперь наш локально запущенный миниапп можно открыть на сайте Телеграма.

Обходим ограничения Роскомнадзора

Наши три компонента системы готовы, и если мы их успешно задеплоили, то приложение должно работать без проблем. Так ведь?

Если вы, как и я, выбрали для деплоя фронтенда AWS, то у вас возникнут некоторые сложности... У ребят из России, когда они пользовались мобильным интернетом от МТС, фронтенд моего миниаппа просто не открывался (получается МТС блочат весь трафик на AWS?).

У меня как раз был сервер в Германии на хостинге hosting-russia.ru, к которому Роскомнадзор, похоже, относится более лояльно. Сделаем его прокси-сервером! Добавим следующие строки в конфиг NGINX:

server {
    listen 443 ssl;
    server_name yourdomain.com;

    # SSL сертификаты
    ssl_certificate     /etc/ssl/certs/yourdomain.crt;
    ssl_certificate_key /etc/ssl/private/yourdomain.key;

    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_ciphers         HIGH:!aNULL:!MD5;

    location / {
        proxy_pass http://any_address_on_aws;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Теперь Роскомнадзор нам не страшен, и пользователи из России спокойно смогут сгенерировать себе любые фотографии для виз!

Финальный результат

Посмотреть результат и сгенерировать себе фото на документы можно в @photovisa_bot - абсолютно бесплатно и без регистрации, для хабровчан и остальных.

Примеры работ
Примеры работ

Спасибо, что дочитали статью до конца! Думаю, теперь у вас не возникнет никаких проблем с созданием фото себе на визу :)