Compare commits

...
Author SHA1 Message Date
apuc 13809d20a5 PDF: поиск шрифтов и в Windows-путях (запасной Arial для локального теста) 2026-09-22 13:57:36 +03:00
apuc 25ed2e3899 Удалено скачивание видео (видео не сохраняется, кнопка убрана); исправлено перекрытие строк в PDF (ячейки-Paragraph + семейство шрифтов) 2026-09-22 13:46:07 +03:00
apuc ce67122035 Поддержка .env файла (python-dotenv) для BASE_URL и других переменных 2026-09-21 22:21:23 +03:00
apuc 61149c3b48 Видео от неудачных анализов тоже истекают через 30 минут (по времени создания файла) 2026-09-21 15:11:04 +03:00
apuc d81d5a4672 Результаты доступны 30 минут, затем становятся недоступными (не удаляются) 2026-09-21 15:08:09 +03:00
apuc 6ea71e1864 share_id теперь UUID вместо последовательного номера (непредсказуемые ссылки) 2026-09-21 15:04:08 +03:00
apuc c6f658fb2f Уровень стресса: только интерпретация + 'по Баевскому'; удалена Моргания 2026-09-20 09:00:55 +03:00
apuc ea87de7d94 Исправлена логика ИМТ (недостаточный вес), удалена частота дыхания 2026-09-19 14:07:16 +03:00
apuc 0cea5b8d0a Кнопка Сканировать теперь видна на всех устройствах (не только мобильных) 2026-09-18 13:26:18 +03:00
apuc dc2cf17f42 Добавлен лоадер обработки после сканирования (спиннер + 'Обработка видео...') 2026-09-18 11:59:55 +03:00
apuc 572ed6bd5e Убраны этнос и эмоция из результатов анализа 2026-09-18 11:42:15 +03:00
apuc e269598077 Закомментирован блок загрузки видео (оставлено только сканирование с камеры) 2026-09-18 11:32:19 +03:00
apuc 2c753f3d40 Дружественная формулировка: 'запишем видео' вместо '300 кадров' 2026-09-18 11:24:36 +03:00
8 changed files with 167 additions and 148 deletions

No files matched your search

+1
View File
@@ -7,6 +7,7 @@ videos/
venv/
bin/
certs/
fonts/
# Логи (могут содержать токены SDK)
*.log
+24 -53
View File
@@ -23,7 +23,7 @@ import time
import uuid
from datetime import datetime
from flask import Flask, Response, jsonify, render_template, request, send_file
from flask import Flask, Response, jsonify, render_template, request
# Импортируем модули интерпретации и хранения
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
@@ -31,6 +31,14 @@ from interpret import interpret_result
import storage
import export
# Загружаем переменные из .env (если файл есть рядом с app.py).
# Значения из реального окружения (systemd Environment=) имеют приоритет.
try:
from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env"))
except ImportError:
pass
# ─── Конфигурация ─────────────────────────────────────────────
# Секреты читаются из переменных окружения (не хранятся в репозитории).
# На сервере задаются в /etc/systemd/system/med.service (Environment=).
@@ -86,16 +94,6 @@ os.makedirs(UPLOAD_DIR, exist_ok=True)
VIDEO_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "videos")
os.makedirs(VIDEO_DIR, exist_ok=True)
# MIME-типы для скачивания видео
VIDEO_MIME = {
".mp4": "video/mp4",
".webm": "video/webm",
".mov": "video/quicktime",
".mkv": "video/x-matroska",
".avi": "video/x-msvideo",
".m4v": "video/mp4",
}
app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = 100 * 1024 * 1024 # 100 МБ
@@ -404,15 +402,21 @@ def shared_result(share_id):
"""Публичная страница результата по ссылке (с OG-тегами для соцсетей)."""
result = storage.get_result(share_id)
if result is None:
return render_template("result.html", result=None, error="Результат не найден", video_exists=False, base_url=BASE_URL), 404
return render_template("result.html", result=None, error="Результат не найден", base_url=BASE_URL), 404
if storage.is_expired(result["created_at"]):
return render_template("result.html", result=None, error="Ссылка устарела. Результат доступен в течение 30 минут после сканирования.", base_url=BASE_URL), 410
video_exists = bool(glob.glob(os.path.join(VIDEO_DIR, f"{share_id}.*")))
return render_template("result.html", result=result, error=None, video_exists=video_exists, base_url=BASE_URL)
return render_template("result.html", result=result, error=None, base_url=BASE_URL)
@app.route("/download/<share_id>/pdf")
def download_pdf(share_id):
"""Скачать результат в PDF."""
result = storage.get_result(share_id)
if result is None:
return "Результат не найден", 404
if storage.is_expired(result["created_at"]):
return "Ссылка устарела. Результат доступен в течение 30 минут.", 410
try:
data = export.generate_pdf(share_id)
except ValueError:
@@ -431,6 +435,11 @@ def download_pdf(share_id):
@app.route("/download/<share_id>/docx")
def download_docx(share_id):
"""Скачать результат в DOCX."""
result = storage.get_result(share_id)
if result is None:
return "Результат не найден", 404
if storage.is_expired(result["created_at"]):
return "Ссылка устарела. Результат доступен в течение 30 минут.", 410
try:
data = export.generate_docx(share_id)
except ValueError:
@@ -446,23 +455,6 @@ def download_docx(share_id):
)
@app.route("/download/<video_id>/video")
def download_video(video_id):
"""Скачать видео, записанное при сканировании (то, что было в кружке)."""
matches = glob.glob(os.path.join(VIDEO_DIR, f"{video_id}.*"))
if not matches:
return "Видео не найдено", 404
video_path = matches[0]
ext = os.path.splitext(video_path)[1].lower()
mime = VIDEO_MIME.get(ext, "application/octet-stream")
return send_file(
video_path,
mimetype=mime,
as_attachment=True,
download_name=f"scan_{video_id}{ext}",
)
@app.route("/analyze", methods=["POST", "OPTIONS"])
def analyze():
# Preflight-запрос CORS (браузер шлёт OPTIONS перед кросс-доменным POST)
@@ -494,14 +486,6 @@ def analyze():
logger.error(f"[{job_id}] Ошибка сохранения видео: {e}")
return jsonify({"error": f"Не удалось сохранить видео: {e}"}), 500
# Сохраняем копию записанного видео (то, что было в кружке) — даже если анализ упадёт
saved_video = os.path.join(VIDEO_DIR, f"{job_id}{ext}")
try:
shutil.copy2(video_path, saved_video)
except Exception as e:
logger.warning(f"[{job_id}] Не удалось сохранить видео: {e}")
saved_video = None
token = None
uid = None
try:
@@ -523,10 +507,8 @@ def analyze():
result = asyncio.run(ws_stream(token, uid, frames))
if not result:
video_url = f"/download/{job_id}/video" if saved_video else None
return jsonify({
"error": "Не удалось получить результат. Убедитесь, что на видео чётко видно лицо.",
"video_url": video_url,
}), 500
# 5. Интерпретация
@@ -539,25 +521,14 @@ def analyze():
share_url = f"{BASE_URL}/r/{share_id}"
logger.info(f"[{job_id}] Результат сохранён: {share_url}")
# Переименовываем видео под share_id (чтобы ссылка была стабильной)
if saved_video:
final_video = os.path.join(VIDEO_DIR, f"{share_id}{ext}")
try:
os.rename(saved_video, final_video)
saved_video = final_video
except Exception as e:
logger.warning(f"[{job_id}] Не удалось переименовать видео: {e}")
interpretation["share_id"] = share_id
interpretation["share_url"] = share_url
interpretation["video_url"] = f"/download/{share_id}/video" if saved_video else None
return jsonify(interpretation)
except Exception as e:
logger.error(f"[{job_id}] Ошибка: {e}")
video_url = f"/download/{job_id}/video" if saved_video else None
return jsonify({"error": str(e), "video_url": video_url}), 500
return jsonify({"error": str(e)}), 500
finally:
# 6. Удаление сессии
+54 -12
View File
@@ -21,17 +21,48 @@ from docx.shared import Pt, Cm, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
# --- Шрифты для кириллицы в PDF ---
# Ищем шрифты в нескольких местах: Linux (сервер) и Windows (локально).
import os as _os
_FONT_DIR = _os.path.join(_os.path.dirname(_os.path.abspath(__file__)), "fonts")
FONT_PATHS = {
"DejaVuSans": "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"DejaVuSans-Bold": "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"DejaVuSans-Oblique": "/usr/share/fonts/truetype/dejavu/DejaVuSans-Oblique.ttf",
"DejaVuSans": [
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
_os.path.join(_FONT_DIR, "DejaVuSans.ttf"),
"C:/Windows/Fonts/arial.ttf", # запасной на Windows (поддерживает кириллицу)
],
"DejaVuSans-Bold": [
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
_os.path.join(_FONT_DIR, "DejaVuSans-Bold.ttf"),
"C:/Windows/Fonts/arialbd.ttf",
],
"DejaVuSans-Oblique": [
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Oblique.ttf",
_os.path.join(_FONT_DIR, "DejaVuSans-Oblique.ttf"),
"C:/Windows/Fonts/ariali.ttf",
],
}
for name, path in FONT_PATHS.items():
try:
pdfmetrics.registerFont(TTFont(name, path))
except Exception:
pass
for name, paths in FONT_PATHS.items():
for path in paths:
try:
pdfmetrics.registerFont(TTFont(name, path))
break
except Exception:
continue
# Регистрируем семейство, чтобы reportlab понимал, что DejaVuSans-Bold —
# это жирный вариант DejaVuSans (иначе Paragraph падает с "Can't map family/bold/italic").
try:
pdfmetrics.registerFontFamily(
'DejaVuSans',
normal='DejaVuSans',
bold='DejaVuSans-Bold',
italic='DejaVuSans',
boldItalic='DejaVuSans-Bold',
)
except Exception:
pass
STATUS_TEXT = {
"ok": "В норме",
@@ -109,6 +140,15 @@ def generate_pdf(share_id) -> bytes:
name="SmallRu", fontName="DejaVuSans", fontSize=8.5,
leading=12, textColor=colors.grey,
))
# Стили ячеек таблицы (Paragraph, чтобы текст переносился и не перекрывался)
styles.add(ParagraphStyle(
name="CellRu", fontName="DejaVuSans", fontSize=9.5,
leading=13, spaceAfter=0,
))
styles.add(ParagraphStyle(
name="CellRuBold", fontName="DejaVuSans-Bold", fontSize=9.5,
leading=13, spaceAfter=0, textColor=colors.white,
))
story = []
@@ -135,7 +175,9 @@ def generate_pdf(share_id) -> bytes:
metrics = _parse_json_list(result["metrics"])
if metrics:
story.append(Paragraph("Показатели", styles["H2Ru"]))
data = [["Показатель", "Значение", "Оценка"]]
data = [[Paragraph("Показатель", styles["CellRuBold"]),
Paragraph("Значение", styles["CellRuBold"]),
Paragraph("Оценка", styles["CellRuBold"])]]
for m in metrics:
value = m.get("value", "")
comment = m.get("comment", "")
@@ -143,9 +185,9 @@ def generate_pdf(share_id) -> bytes:
value = f"{value} ({comment})"
status = m.get("status", "info")
data.append([
m.get("name", ""),
value,
STATUS_TEXT.get(status, status),
Paragraph(m.get("name", ""), styles["CellRu"]),
Paragraph(value, styles["CellRu"]),
Paragraph(STATUS_TEXT.get(status, status), styles["CellRu"]),
])
table = Table(data, colWidths=[55 * mm, 75 * mm, 50 * mm])
table.setStyle(TableStyle([
+11 -61
View File
@@ -30,25 +30,6 @@ def interpret_result(data: dict) -> dict:
# ── Демография ─────────────────────────────────────────────
gender = "мужчина" if data.get("is_male") else "женщина"
age = data.get("age", 0)
ethnicity_map = {
"white": "европеоид",
"black": "негроид",
"asian": "монголоид",
"unknown": "не определён",
}
ethnicity = ethnicity_map.get(data.get("ethnicity", ""), data.get("ethnicity", "не определён"))
emotion_map = {
"happiness": "радость",
"sadness": "грусть",
"neutral": "спокойствие",
"anger": "злость",
"surprise": "удивление",
"fear": "страх",
"disgust": "отвращение",
"contempt": "презрение",
}
emotion = emotion_map.get(data.get("emotion", ""), data.get("emotion", "не определена"))
metrics.append({
"name": "Пол",
@@ -62,18 +43,6 @@ def interpret_result(data: dict) -> dict:
"status": "info",
"comment": "Оценка по лицу",
})
metrics.append({
"name": "Этнос",
"value": ethnicity.capitalize(),
"status": "info",
"comment": "",
})
metrics.append({
"name": "Эмоция",
"value": emotion.capitalize(),
"status": "info",
"comment": "Эмоция в момент съёмки",
})
# ── ИМТ ────────────────────────────────────────────────────
bmi = data.get("bmi", 0)
@@ -92,12 +61,16 @@ def interpret_result(data: dict) -> dict:
"status": bmi_status,
"comment": bmi_comment,
})
if bmi_status == "warning":
warnings.append(f"Избыточный вес (ИМТ {bmi:.1f}) — рекомендуется контроль питания и физическая активность")
advice.append("Снизить вес до нормы (ИМТ 18.5–24.9): сбалансированное питание, регулярные нагрузки")
elif bmi_status == "danger":
if bmi_status == "danger":
warnings.append(f"Ожирение (ИМТ {bmi:.1f}) — рекомендуется консультация врача")
advice.append("Обратиться к врачу для разработки программы снижения веса")
elif bmi_status == "warning":
if bmi < 18.5:
warnings.append(f"Недостаточный вес (ИМТ {bmi:.1f}) — рекомендуется сбалансированное питание")
advice.append("Набрать вес до нормы (ИМТ 18.5–24.9): полноценное питание, при необходимости консультация врача")
else:
warnings.append(f"Избыточный вес (ИМТ {bmi:.1f}) — рекомендуется контроль питания и физическая активность")
advice.append("Снизить вес до нормы (ИМТ 18.5–24.9): сбалансированное питание, регулярные нагрузки")
# ── Диабет ─────────────────────────────────────────────────
diabet = data.get("diabet", "unknown")
@@ -277,20 +250,6 @@ def interpret_result(data: dict) -> dict:
warnings.append("Повышенный уровень липопротеина (а) — фактор риска сердечно-сосудистых заболеваний")
advice.append("Проконсультироваться с кардиологом")
# ── Дыхание ────────────────────────────────────────────────
resp = data.get("respiratory", 0)
if resp > 0:
if 12 <= resp <= 20:
resp_status, resp_comment = "ok", "Норма"
else:
resp_status, resp_comment = "warning", "Вне нормы"
metrics.append({
"name": "Частота дыхания",
"value": f"{resp:.1f} вдохов/мин",
"status": resp_status,
"comment": resp_comment,
})
# ── Стресс ─────────────────────────────────────────────────
stress = data.get("stress", 0)
if stress > 0:
@@ -304,9 +263,9 @@ def interpret_result(data: dict) -> dict:
stress_status, stress_comment = "danger", "Высокий уровень стресса"
metrics.append({
"name": "Уровень стресса",
"value": f"{stress}",
"value": stress_comment,
"status": stress_status,
"comment": stress_comment,
"comment": "Оценка по Баевскому",
})
if stress_status == "warning":
warnings.append("Повышенный уровень стресса")
@@ -331,17 +290,8 @@ def interpret_result(data: dict) -> dict:
"comment": rig_comment,
})
# ── Моргания ───────────────────────────────────────────────
blinks = data.get("blinks", 0)
metrics.append({
"name": "Моргания",
"value": f"{blinks}",
"status": "info",
"comment": "За время сканирования",
})
# ── Сводка ─────────────────────────────────────────────────
summary = f"{gender.capitalize()}, {age} лет, {ethnicity}. Эмоция: {emotion}."
summary = f"{gender.capitalize()}, {age} лет."
# Считаем проблемы
problems = [m for m in metrics if m["status"] in ("warning", "danger")]
+1
View File
@@ -5,6 +5,7 @@ websockets==10.4
requests==2.28.2
reportlab==5.0.1
python-docx==1.2.0
python-dotenv==1.0.1
# Только для утилиты gigadoc_ws.py (не нужен для веб-приложения):
# aiohttp
+20 -12
View File
@@ -16,13 +16,26 @@
import json
import os
import sqlite3
from datetime import datetime
import uuid
from datetime import datetime, timedelta
DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "results.db")
# Первый ID результата
FIRST_ID = 10005
# Срок жизни результата: доступен 30 минут, затем становится недоступным (не удаляется).
RESULT_TTL = timedelta(minutes=30)
def is_expired(created_at: str) -> bool:
"""True, если результат старше RESULT_TTL (30 минут)."""
try:
created = datetime.fromisoformat(created_at)
except (ValueError, TypeError):
return False
return datetime.now() - created > RESULT_TTL
def _connect():
conn = sqlite3.connect(DB_PATH)
@@ -51,27 +64,22 @@ def init_db():
def save_result(interpretation: dict, raw_result: dict) -> str:
"""
Сохранить результат анализа. Возвращает share_id (числовой, начиная с 10005).
Сохранить результат анализа. Возвращает share_id — случайный UUID
(непредсказуемый, чтобы ссылки нельзя было перебирать).
"""
init_db()
conn = _connect()
# Следующий ID: MAX(id) + 1, но не меньше FIRST_ID
row = conn.execute("SELECT MAX(id) AS max_id FROM results").fetchone()
next_id = (row["max_id"] + 1) if row["max_id"] is not None else FIRST_ID
if next_id < FIRST_ID:
next_id = FIRST_ID
share_id = str(next_id)
# Случайный UUID для публичной ссылки (id остаётся автоинкрементным для внутренней сортировки).
share_id = uuid.uuid4().hex
conn.execute(
"""
INSERT INTO results (id, share_id, created_at, raw_json, summary, warnings, metrics, advice)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO results (share_id, created_at, raw_json, summary, warnings, metrics, advice)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
next_id,
share_id,
datetime.now().isoformat(timespec="seconds"),
json.dumps(raw_result, ensure_ascii=False),
+56 -7
View File
@@ -347,6 +347,42 @@
@keyframes spin { to { transform: rotate(360deg); } }
.hidden { display: none; }
/* Лоадер обработки (после сканирования) */
.scan-loader {
display: none;
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(0, 0, 0, 0.7);
align-items: center;
justify-content: center;
}
.scan-loader.open {
display: flex;
}
.scan-loader-content {
text-align: center;
color: #fff;
}
.scan-loader-spinner {
width: 48px;
height: 48px;
border: 4px solid rgba(255,255,255,0.3);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 0 auto 20px;
}
.scan-loader-text {
font-size: 20px;
font-weight: 600;
margin-bottom: 8px;
}
.scan-loader-sub {
font-size: 14px;
opacity: 0.8;
}
/* Заголовок результата */
.result-header {
display: flex;
@@ -387,10 +423,11 @@
<div class="card scan-section" id="scanSection">
<h2>📷 Сканирование</h2>
<p class="scan-desc">Наведите камеру на лицо — мы сделаем 300 кадров и проведём анализ</p>
<p class="scan-desc">Наведите камеру на лицо — мы запишем видео и проведём анализ</p>
<button class="scan-btn" id="scanBtn">📷 Сканировать</button>
</div>
<!--
<div class="card">
<h2>Загрузка видео</h2>
<div class="upload-area" id="uploadArea">
@@ -410,6 +447,7 @@
<div class="progress-text" id="progressText">Подготовка...</div>
</div>
</div>
-->
<div class="card result" id="resultCard">
<div class="result-header">
@@ -427,7 +465,7 @@
<div class="scan-oval"></div>
</div>
<div class="scan-progress">
<div id="scanProgressText">Кадров: 0 / 300 (0%)</div>
<div id="scanProgressText">Прогресс: 0 / 300 (0%)</div>
<div class="scan-progress-bar">
<div class="scan-progress-fill" id="scanProgressFill"></div>
</div>
@@ -436,6 +474,15 @@
<div class="scan-hint">Поместите лицо в овал целиком: лоб, глаза и подбородок — с запасом. Если голова не помещается — отодвиньте телефон</div>
</div>
<!-- Лоадер обработки -->
<div class="scan-loader" id="scanLoader">
<div class="scan-loader-content">
<div class="scan-loader-spinner"></div>
<div class="scan-loader-text">Обработка видео...</div>
<div class="scan-loader-sub">Идёт анализ, это займёт несколько секунд</div>
</div>
</div>
<script>
const uploadArea = document.getElementById('uploadArea');
const fileInput = document.getElementById('fileInput');
@@ -522,6 +569,7 @@
}
setProgress(100, 'Готово!');
document.getElementById('scanLoader').classList.remove('open');
setTimeout(() => {
progressContainer.style.display = 'none';
renderResult(data);
@@ -550,6 +598,7 @@
}
function showError(msg, videoUrl) {
document.getElementById('scanLoader').classList.remove('open');
progressContainer.style.display = 'none';
resultCard.style.display = 'block';
let html = `<div class="error-box"><strong>Ошибка:</strong> ${msg}</div>`;
@@ -563,6 +612,7 @@
}
function renderResult(data) {
document.getElementById('scanLoader').classList.remove('open');
resultCard.style.display = 'block';
let html = '';
@@ -658,11 +708,8 @@
let scanChunks = [];
let scanActive = false;
// Показываем кнопку сканирования только на мобильных устройствах
const isMobile = window.matchMedia('(max-width: 768px)').matches || ('ontouchstart' in window);
if (isMobile) {
scanSection.style.display = 'block';
}
// Показываем кнопку сканирования на всех устройствах
scanSection.style.display = 'block';
scanBtn.addEventListener('click', startScan);
scanCancelBtn.addEventListener('click', cancelScan);
@@ -727,6 +774,8 @@
stopCamera();
scanActive = false;
scanBtn.disabled = false;
// Показываем лоадер обработки
document.getElementById('scanLoader').classList.add('open');
sendVideo(blob, `scan.${ext}`);
};
mediaRecorder.start();
-3
View File
@@ -257,9 +257,6 @@
<div class="download-buttons">
<a class="btn-download" href="/download/{{ result.share_id }}/pdf">📄 Скачать PDF</a>
<a class="btn-download" href="/download/{{ result.share_id }}/docx">📝 Скачать DOCX</a>
{% if video_exists %}
<a class="btn-download" href="/download/{{ result.share_id }}/video">🎬 Скачать видео</a>
{% endif %}
<a class="btn-download" href="https://max.ru/miac_dnr_bot" target="_blank" rel="noopener">🏥 Запись на диспанцеризацию</a>
</div>