Привет, Хабр!
Делали ли вы электронную визу в Индию? А, может, в Южную Корею? Или подавались на лотерею 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 для поиска маски заднего фона.

Моделька довольно большая и на 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 - абсолютно бесплатно и без регистрации, для хабровчан и остальных.

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

whocoulditbe
28.10.2025 16:06А где полные исходники? Мне, например, совершенно не хочется позволять кому попало связывать мой аккаунт в Телеграме с моим фото.

MrSmitix
28.10.2025 16:06Человек сделал удобный и бесплатный сервис, который лучше некоторых платных аналогов, а вам и этого мало...

Wesha
28.10.2025 16:06не хочется позволять кому попало связывать мой аккаунт в Телеграме с моим фото.
Тащмаёр, кажется, этот гражданин раскусил вашу гениальную идею!

RalphMirebs
28.10.2025 16:06Я может отстал от жизни, но разве в фотоателье не печатают фотки под заданные размеры? Откуда взялись сайты с услугами за 5-12 долларов? Или они высылают бумажные фотки на адрес?

AndreasCag Автор
28.10.2025 16:06Кейс такой:
Вы заполняете форму на электронную визу и вам надо прикрепить фото - вы дома, а фотоателье далеко.Сайт предлагает вам сделать фото дома, отправить им - они обрежут задний фон, выравняют как надо и вы получите себе фото которое можно прикрепить.

Kapsa-sa
28.10.2025 16:06Нет, бумагу не шлют. Просто готовый файл под нужные требования. По сути, платишь за обрезку и фон

autoanswerzzz
28.10.2025 16:06делал визу - требовали загрузить 480*640 фотку и как я запарился уменьшать не имея доп софта (тупо на смарте скринел уменьшеную selfie фотку пытаясь попасть в размер но всё не получалось во вьюере уменьшить до нужного даже уже в уменьшеной) и оказалось потом что на наземной границе стоит автомат который это всё делает заодно печатая pdf главное паспорт приложи да карту прокати (возможно у него ещё приоритет на выдачу и через минуту готово - в дистанционке до трёх дней но зато до поездки): больше всего гадал кому нафиг нужна такая стрёмная фотка и в pdf с печатью она почти квадратиками - для распознавания наверное достаточно ... куда-то по wa/tg или веб слать не было никакого желания - про ращрешение в последний момент вылезло припятствие

anoldman25
28.10.2025 16:06А я пользуюсь вот этой программой. Ну давно не пользовался, но все же. https://www.fourmilab.ch/netpbm/passport_photo/ Эта программа разработана бывшим владельцем AUTOCAD. https://en.wikipedia.org/wiki/John_Walker_(programmer)
У меня есть старенький цветной принтер Canon Selphy CP900. Он очень качественно печатает маленькие фотки 148 x 100 мм. Принтер рекомендую. Качество высше всяких похвал. Печатает на специальной бумаге.
Короче, я фотографирую на карманный фотоаппарат, обрабатываю этой программой, затем печатаю на принтере и разрезаю. Программа работает на Linux по крайней мере. Не знаю как с Windows .
Вообще замечательный дядька. Рекомендую ознакомиться с сайтом. Много интересного.

SquareRootOfZero
28.10.2025 16:06Недавно делал для себя, чтобы обрезанную в нужных пропорциях цифровую фотографию распечатать в нужном размере на фотопринтере. На скорую руку, багов особо не ловил, но "у меня всё работает". На Linux работает, на Windows не проверял, но тоже должно. Нужны Python 3.x, OpenCV и NumPy (всё ставится моментально из репозиториев или через pip, Python на Linux обычно и так есть уже).
Код
#!/usr/bin/env python3 import cv2 import numpy as np import sys def tile(img_in, img_size_mm, paper_size, paper_size_unit = "in", bgvalue = 255): img_size = np.array(img_size_mm, dtype=np.float64) # w, h paper_size = np.array(paper_size, dtype=np.float64) # w, h if paper_size_unit == "in": paper_size *= 25.4 elif paper_size_unit != "mm": raise Exception("Unknown paper unit size, use in or mm") n_cols_rows = (paper_size // img_size).astype(np.uint64) gaps = (paper_size % img_size) / (n_cols_rows + 1) # w_gap, h_gap h, w, _ = img_in.shape # h, w mm2px = h / img_size[1] paper_size_px = (paper_size * mm2px).astype(np.uint64) gaps_px = (gaps * mm2px).astype(int) img_out = np.full(shape=(paper_size_px[1], paper_size_px[0], 3), fill_value = bgvalue, dtype = np.uint8) cur_top = gaps_px[1] for row in range(n_cols_rows[1]): cur_left = gaps_px[0] for col in range(n_cols_rows[0]): img_out[cur_top : int(cur_top + h), cur_left : int(cur_left + w)] = img_in cur_left += int(w + gaps_px[0]) cur_top += int(h + gaps_px[1]) return img_out if __name__ == '__main__': try: input_name = sys.argv[1] output_name = sys.argv[2] input_size = sys.argv[3:5] paper_size = sys.argv[5:7] paper_size_unit = sys.argv[7] if len(sys.argv) > 7 else "in" paper_bgvalue = sys.argv[8] if len(sys.argv) > 8 else 255 except Exception as e: print(f"{sys.argv[0]} input.jpg output.jpg <input width mm> <input height mm> <paper width> <paper height> <paper unit (in or mm)> <fill value (0~255)>") sys.exit(0) img = cv2.imread(input_name) img_t = tile(img, input_size, paper_size, paper_size_unit, paper_bgvalue) cv2.imwrite(output_name, img_t)Чтобы разложить нижеприведённую фотографию на лист размером 4 на 6 дюймов (варварство, но что я с ним могу сделать, разве что каждый раз умножать на 25.4... зато размеры исходника задаются только в миллиметрах!), и каждая напечатанная фотография была 30 на 40 мм, на чёрном фоне:
./tile.py input.jpg output.jpg 30 40 4 6 in 0Результат

title 
title

Ninil
28.10.2025 16:06Минутка паранои: отправлять фото, да еще в формате "как на документы" непонятному телеграмм-боту ... На выходе владелец бота получает базу данных "эккаунт телеграмм - фотография", ну а дельше... сами додумайте кому и зачем может пригодится такая база)

randomsimplenumber
28.10.2025 16:06Нужно отослать штук 500 фотографий с каждого аккаунта. Пусть сами потом решают, зачем может пригодиться такая база ;)

sijokun
28.10.2025 16:06try:
auth_data = validateand_extract_auth(Authorization, BOT_TOKEN)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=f"Invalid auth data: {e}")У FastApi есть механизм Depends, который решает эту задачу заметно удобнее – один раз прописываешь для роутера и дальше в каждой ручке внутри роутера будет проверка токена и уже готовый объект инит даты
Скрытый текст
def parse_init_data(init_data: str) -> TelegramInitData: parsed_query = urllib.parse.parse_qs(init_data) data = {} for key in parsed_query: data[key] = parsed_query[key][0] data["user"] = json.loads(data["user"]) return TelegramInitData.validate(data) def validate_telegram_data(query_string: str) -> bool: data = dict(urllib.parse.parse_qsl(query_string)) received_hash = data.pop("hash", None) if received_hash is None: return False data_check_string = "\n".join( f"{key}={urllib.parse.unquote(value)}" for key, value in sorted(data.items()) ) secret_key = hmac.new( key="WebAppData".encode("utf-8"), msg=BOT_TOKEN.encode("utf-8"), digestmod=hashlib.sha256, ).digest() check_hash = hmac.new( key=secret_key, msg=data_check_string.encode("utf-8"), digestmod=hashlib.sha256 ).hexdigest() return hmac.compare_digest(check_hash, received_hash)async def decode_auth_header(request: Request): expected_header = "authorization" if expected_header not in request.headers: raise HTTPException(status_code=401, detail="Authorization header is required") token_encoded = request.headers[expected_header].removeprefix("TMA ") token = base64.b64decode(token_encoded).decode() if not validate_telegram_data(token): raise HTTPException(status_code=401, detail="Invalid initData") init_data = parse_init_data(token) request.state.data = init_data return init_dataМой пример инит дату в Base64 в Authorization: TMA .... передает, но думаю концепт понятен.
И пример испоьзования:app.include_router(router, dependencies=[Depends(decode_auth_header)]) @router.post("/") async def something(request: Request): user_id = request.state.data.user.id
Wesha
А теперь внимание, вопрос на миллион: если всё так тривиально, то почему аналогичную нейросеточку тупо не сделают на сайте, который требует эти фотографии? Ну, чтобы убрать из процесса вот таких вот посредников?
P.S.
Хоть бы самыми ржачными фоточками «до» поделились!
urvanov
Там же одно из основных требований, что фотография должна быть НЕ обработанная.
AndreasCag Автор
Это где так? Оо
Фото ателье когда делают фото на визу её ретушируют, и никаких проблем не возникает
plFlok
"а
консулумаме мы ничего не скажем".Что на незначительные массовые нарушения закрывают глаза - ещё не оправдание этих нарушений. И да, иногда всё-таки могут забраковать.
Wesha
Чуть менее, чем везде.
(Но, как всегда — требование есть, а проверки на соответствие ему — нет.)
SquareRootOfZero
Ну вот, например, для визы в Японию в требованиях к фотографии прямо указано: "Do NOT use filters". Мне казалось, для визы в США я тоже аналогичное требование видел, но чё-то на их сайте не найду - может, и впрямь, показалось. Разве вот в разделе FAQ есть про красные глаза:
Во всяком случае, обработка типа "resize, rotate and crop" в явном виде позволяется. Что до удаления фона - я не осилил понять, вроде, это "digitally enhance", но не "change your appearance". Ну и отписка "а мы рассмотрим, и, может, отвергнем".
AndreasCag Автор
Спасибо за идею :)
Фоточки до
Wesha
Сразу вижу брак: на лице тени.
SquareRootOfZero
Примитивный редактор "обрезать/подогнать под нужный размер", вроде, делают, а удаление фона, да чтоб корректно обработало всю волосню по краям - на хера? Так трудно встать на фоне белой стены и щёлкнуть смартфоном физиономию?
denisemenov
Не только лишь все имеют белую стену с птдходящим освещением. Мало кто может, чтобы белая стена была белым фоном, а не серым с тенью, например.
randomsimplenumber
Кто не может сам - обращается к специалистам.