Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13809d20a5 | ||
|
|
25ed2e3899 | ||
|
|
ce67122035 | ||
|
|
61149c3b48 | ||
|
|
d81d5a4672 | ||
|
|
6ea71e1864 | ||
|
|
c6f658fb2f | ||
|
|
ea87de7d94 | ||
|
|
0cea5b8d0a | ||
|
|
dc2cf17f42 | ||
|
|
572ed6bd5e | ||
|
|
e269598077 | ||
|
|
2c753f3d40 |
No files matched your search
@@ -7,6 +7,7 @@ videos/
|
|||||||
venv/
|
venv/
|
||||||
bin/
|
bin/
|
||||||
certs/
|
certs/
|
||||||
|
fonts/
|
||||||
|
|
||||||
# Логи (могут содержать токены SDK)
|
# Логи (могут содержать токены SDK)
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import time
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
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__)))
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
@@ -31,6 +31,14 @@ from interpret import interpret_result
|
|||||||
import storage
|
import storage
|
||||||
import export
|
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=).
|
# На сервере задаются в /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")
|
VIDEO_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "videos")
|
||||||
os.makedirs(VIDEO_DIR, exist_ok=True)
|
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 = Flask(__name__)
|
||||||
app.config["MAX_CONTENT_LENGTH"] = 100 * 1024 * 1024 # 100 МБ
|
app.config["MAX_CONTENT_LENGTH"] = 100 * 1024 * 1024 # 100 МБ
|
||||||
|
|
||||||
@@ -404,15 +402,21 @@ def shared_result(share_id):
|
|||||||
"""Публичная страница результата по ссылке (с OG-тегами для соцсетей)."""
|
"""Публичная страница результата по ссылке (с OG-тегами для соцсетей)."""
|
||||||
result = storage.get_result(share_id)
|
result = storage.get_result(share_id)
|
||||||
if result is None:
|
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, base_url=BASE_URL)
|
||||||
return render_template("result.html", result=result, error=None, video_exists=video_exists, base_url=BASE_URL)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/download/<share_id>/pdf")
|
@app.route("/download/<share_id>/pdf")
|
||||||
def download_pdf(share_id):
|
def download_pdf(share_id):
|
||||||
"""Скачать результат в PDF."""
|
"""Скачать результат в PDF."""
|
||||||
|
result = storage.get_result(share_id)
|
||||||
|
if result is None:
|
||||||
|
return "Результат не найден", 404
|
||||||
|
if storage.is_expired(result["created_at"]):
|
||||||
|
return "Ссылка устарела. Результат доступен в течение 30 минут.", 410
|
||||||
try:
|
try:
|
||||||
data = export.generate_pdf(share_id)
|
data = export.generate_pdf(share_id)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -431,6 +435,11 @@ def download_pdf(share_id):
|
|||||||
@app.route("/download/<share_id>/docx")
|
@app.route("/download/<share_id>/docx")
|
||||||
def download_docx(share_id):
|
def download_docx(share_id):
|
||||||
"""Скачать результат в DOCX."""
|
"""Скачать результат в DOCX."""
|
||||||
|
result = storage.get_result(share_id)
|
||||||
|
if result is None:
|
||||||
|
return "Результат не найден", 404
|
||||||
|
if storage.is_expired(result["created_at"]):
|
||||||
|
return "Ссылка устарела. Результат доступен в течение 30 минут.", 410
|
||||||
try:
|
try:
|
||||||
data = export.generate_docx(share_id)
|
data = export.generate_docx(share_id)
|
||||||
except ValueError:
|
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"])
|
@app.route("/analyze", methods=["POST", "OPTIONS"])
|
||||||
def analyze():
|
def analyze():
|
||||||
# Preflight-запрос CORS (браузер шлёт OPTIONS перед кросс-доменным POST)
|
# Preflight-запрос CORS (браузер шлёт OPTIONS перед кросс-доменным POST)
|
||||||
@@ -494,14 +486,6 @@ def analyze():
|
|||||||
logger.error(f"[{job_id}] Ошибка сохранения видео: {e}")
|
logger.error(f"[{job_id}] Ошибка сохранения видео: {e}")
|
||||||
return jsonify({"error": f"Не удалось сохранить видео: {e}"}), 500
|
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
|
token = None
|
||||||
uid = None
|
uid = None
|
||||||
try:
|
try:
|
||||||
@@ -523,10 +507,8 @@ def analyze():
|
|||||||
result = asyncio.run(ws_stream(token, uid, frames))
|
result = asyncio.run(ws_stream(token, uid, frames))
|
||||||
|
|
||||||
if not result:
|
if not result:
|
||||||
video_url = f"/download/{job_id}/video" if saved_video else None
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"error": "Не удалось получить результат. Убедитесь, что на видео чётко видно лицо.",
|
"error": "Не удалось получить результат. Убедитесь, что на видео чётко видно лицо.",
|
||||||
"video_url": video_url,
|
|
||||||
}), 500
|
}), 500
|
||||||
|
|
||||||
# 5. Интерпретация
|
# 5. Интерпретация
|
||||||
@@ -539,25 +521,14 @@ def analyze():
|
|||||||
share_url = f"{BASE_URL}/r/{share_id}"
|
share_url = f"{BASE_URL}/r/{share_id}"
|
||||||
logger.info(f"[{job_id}] Результат сохранён: {share_url}")
|
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_id"] = share_id
|
||||||
interpretation["share_url"] = share_url
|
interpretation["share_url"] = share_url
|
||||||
interpretation["video_url"] = f"/download/{share_id}/video" if saved_video else None
|
|
||||||
|
|
||||||
return jsonify(interpretation)
|
return jsonify(interpretation)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[{job_id}] Ошибка: {e}")
|
logger.error(f"[{job_id}] Ошибка: {e}")
|
||||||
video_url = f"/download/{job_id}/video" if saved_video else None
|
return jsonify({"error": str(e)}), 500
|
||||||
return jsonify({"error": str(e), "video_url": video_url}), 500
|
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
# 6. Удаление сессии
|
# 6. Удаление сессии
|
||||||
|
|||||||
@@ -21,15 +21,46 @@ from docx.shared import Pt, Cm, RGBColor
|
|||||||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||||
|
|
||||||
# --- Шрифты для кириллицы в PDF ---
|
# --- Шрифты для кириллицы в PDF ---
|
||||||
|
# Ищем шрифты в нескольких местах: Linux (сервер) и Windows (локально).
|
||||||
|
import os as _os
|
||||||
|
|
||||||
|
_FONT_DIR = _os.path.join(_os.path.dirname(_os.path.abspath(__file__)), "fonts")
|
||||||
FONT_PATHS = {
|
FONT_PATHS = {
|
||||||
"DejaVuSans": "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
"DejaVuSans": [
|
||||||
"DejaVuSans-Bold": "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||||
"DejaVuSans-Oblique": "/usr/share/fonts/truetype/dejavu/DejaVuSans-Oblique.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():
|
for name, paths in FONT_PATHS.items():
|
||||||
|
for path in paths:
|
||||||
try:
|
try:
|
||||||
pdfmetrics.registerFont(TTFont(name, path))
|
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:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -109,6 +140,15 @@ def generate_pdf(share_id) -> bytes:
|
|||||||
name="SmallRu", fontName="DejaVuSans", fontSize=8.5,
|
name="SmallRu", fontName="DejaVuSans", fontSize=8.5,
|
||||||
leading=12, textColor=colors.grey,
|
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 = []
|
story = []
|
||||||
|
|
||||||
@@ -135,7 +175,9 @@ def generate_pdf(share_id) -> bytes:
|
|||||||
metrics = _parse_json_list(result["metrics"])
|
metrics = _parse_json_list(result["metrics"])
|
||||||
if metrics:
|
if metrics:
|
||||||
story.append(Paragraph("Показатели", styles["H2Ru"]))
|
story.append(Paragraph("Показатели", styles["H2Ru"]))
|
||||||
data = [["Показатель", "Значение", "Оценка"]]
|
data = [[Paragraph("Показатель", styles["CellRuBold"]),
|
||||||
|
Paragraph("Значение", styles["CellRuBold"]),
|
||||||
|
Paragraph("Оценка", styles["CellRuBold"])]]
|
||||||
for m in metrics:
|
for m in metrics:
|
||||||
value = m.get("value", "")
|
value = m.get("value", "")
|
||||||
comment = m.get("comment", "")
|
comment = m.get("comment", "")
|
||||||
@@ -143,9 +185,9 @@ def generate_pdf(share_id) -> bytes:
|
|||||||
value = f"{value} ({comment})"
|
value = f"{value} ({comment})"
|
||||||
status = m.get("status", "info")
|
status = m.get("status", "info")
|
||||||
data.append([
|
data.append([
|
||||||
m.get("name", ""),
|
Paragraph(m.get("name", ""), styles["CellRu"]),
|
||||||
value,
|
Paragraph(value, styles["CellRu"]),
|
||||||
STATUS_TEXT.get(status, status),
|
Paragraph(STATUS_TEXT.get(status, status), styles["CellRu"]),
|
||||||
])
|
])
|
||||||
table = Table(data, colWidths=[55 * mm, 75 * mm, 50 * mm])
|
table = Table(data, colWidths=[55 * mm, 75 * mm, 50 * mm])
|
||||||
table.setStyle(TableStyle([
|
table.setStyle(TableStyle([
|
||||||
|
|||||||
+11
-61
@@ -30,25 +30,6 @@ def interpret_result(data: dict) -> dict:
|
|||||||
# ── Демография ─────────────────────────────────────────────
|
# ── Демография ─────────────────────────────────────────────
|
||||||
gender = "мужчина" if data.get("is_male") else "женщина"
|
gender = "мужчина" if data.get("is_male") else "женщина"
|
||||||
age = data.get("age", 0)
|
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({
|
metrics.append({
|
||||||
"name": "Пол",
|
"name": "Пол",
|
||||||
@@ -62,18 +43,6 @@ def interpret_result(data: dict) -> dict:
|
|||||||
"status": "info",
|
"status": "info",
|
||||||
"comment": "Оценка по лицу",
|
"comment": "Оценка по лицу",
|
||||||
})
|
})
|
||||||
metrics.append({
|
|
||||||
"name": "Этнос",
|
|
||||||
"value": ethnicity.capitalize(),
|
|
||||||
"status": "info",
|
|
||||||
"comment": "",
|
|
||||||
})
|
|
||||||
metrics.append({
|
|
||||||
"name": "Эмоция",
|
|
||||||
"value": emotion.capitalize(),
|
|
||||||
"status": "info",
|
|
||||||
"comment": "Эмоция в момент съёмки",
|
|
||||||
})
|
|
||||||
|
|
||||||
# ── ИМТ ────────────────────────────────────────────────────
|
# ── ИМТ ────────────────────────────────────────────────────
|
||||||
bmi = data.get("bmi", 0)
|
bmi = data.get("bmi", 0)
|
||||||
@@ -92,12 +61,16 @@ def interpret_result(data: dict) -> dict:
|
|||||||
"status": bmi_status,
|
"status": bmi_status,
|
||||||
"comment": bmi_comment,
|
"comment": bmi_comment,
|
||||||
})
|
})
|
||||||
if bmi_status == "warning":
|
if bmi_status == "danger":
|
||||||
warnings.append(f"Избыточный вес (ИМТ {bmi:.1f}) — рекомендуется контроль питания и физическая активность")
|
|
||||||
advice.append("Снизить вес до нормы (ИМТ 18.5–24.9): сбалансированное питание, регулярные нагрузки")
|
|
||||||
elif bmi_status == "danger":
|
|
||||||
warnings.append(f"Ожирение (ИМТ {bmi:.1f}) — рекомендуется консультация врача")
|
warnings.append(f"Ожирение (ИМТ {bmi:.1f}) — рекомендуется консультация врача")
|
||||||
advice.append("Обратиться к врачу для разработки программы снижения веса")
|
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")
|
diabet = data.get("diabet", "unknown")
|
||||||
@@ -277,20 +250,6 @@ def interpret_result(data: dict) -> dict:
|
|||||||
warnings.append("Повышенный уровень липопротеина (а) — фактор риска сердечно-сосудистых заболеваний")
|
warnings.append("Повышенный уровень липопротеина (а) — фактор риска сердечно-сосудистых заболеваний")
|
||||||
advice.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)
|
stress = data.get("stress", 0)
|
||||||
if stress > 0:
|
if stress > 0:
|
||||||
@@ -304,9 +263,9 @@ def interpret_result(data: dict) -> dict:
|
|||||||
stress_status, stress_comment = "danger", "Высокий уровень стресса"
|
stress_status, stress_comment = "danger", "Высокий уровень стресса"
|
||||||
metrics.append({
|
metrics.append({
|
||||||
"name": "Уровень стресса",
|
"name": "Уровень стресса",
|
||||||
"value": f"{stress}",
|
"value": stress_comment,
|
||||||
"status": stress_status,
|
"status": stress_status,
|
||||||
"comment": stress_comment,
|
"comment": "Оценка по Баевскому",
|
||||||
})
|
})
|
||||||
if stress_status == "warning":
|
if stress_status == "warning":
|
||||||
warnings.append("Повышенный уровень стресса")
|
warnings.append("Повышенный уровень стресса")
|
||||||
@@ -331,17 +290,8 @@ def interpret_result(data: dict) -> dict:
|
|||||||
"comment": rig_comment,
|
"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")]
|
problems = [m for m in metrics if m["status"] in ("warning", "danger")]
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ websockets==10.4
|
|||||||
requests==2.28.2
|
requests==2.28.2
|
||||||
reportlab==5.0.1
|
reportlab==5.0.1
|
||||||
python-docx==1.2.0
|
python-docx==1.2.0
|
||||||
|
python-dotenv==1.0.1
|
||||||
|
|
||||||
# Только для утилиты gigadoc_ws.py (не нужен для веб-приложения):
|
# Только для утилиты gigadoc_ws.py (не нужен для веб-приложения):
|
||||||
# aiohttp
|
# aiohttp
|
||||||
+20
-12
@@ -16,13 +16,26 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sqlite3
|
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")
|
DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "results.db")
|
||||||
|
|
||||||
# Первый ID результата
|
# Первый ID результата
|
||||||
FIRST_ID = 10005
|
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():
|
def _connect():
|
||||||
conn = sqlite3.connect(DB_PATH)
|
conn = sqlite3.connect(DB_PATH)
|
||||||
@@ -51,27 +64,22 @@ def init_db():
|
|||||||
|
|
||||||
def save_result(interpretation: dict, raw_result: dict) -> str:
|
def save_result(interpretation: dict, raw_result: dict) -> str:
|
||||||
"""
|
"""
|
||||||
Сохранить результат анализа. Возвращает share_id (числовой, начиная с 10005).
|
Сохранить результат анализа. Возвращает share_id — случайный UUID
|
||||||
|
(непредсказуемый, чтобы ссылки нельзя было перебирать).
|
||||||
"""
|
"""
|
||||||
init_db()
|
init_db()
|
||||||
|
|
||||||
conn = _connect()
|
conn = _connect()
|
||||||
|
|
||||||
# Следующий ID: MAX(id) + 1, но не меньше FIRST_ID
|
# Случайный UUID для публичной ссылки (id остаётся автоинкрементным для внутренней сортировки).
|
||||||
row = conn.execute("SELECT MAX(id) AS max_id FROM results").fetchone()
|
share_id = uuid.uuid4().hex
|
||||||
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)
|
|
||||||
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO results (id, share_id, created_at, raw_json, summary, warnings, metrics, advice)
|
INSERT INTO results (share_id, created_at, raw_json, summary, warnings, metrics, advice)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
next_id,
|
|
||||||
share_id,
|
share_id,
|
||||||
datetime.now().isoformat(timespec="seconds"),
|
datetime.now().isoformat(timespec="seconds"),
|
||||||
json.dumps(raw_result, ensure_ascii=False),
|
json.dumps(raw_result, ensure_ascii=False),
|
||||||
|
|||||||
+55
-6
@@ -347,6 +347,42 @@
|
|||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
.hidden { display: none; }
|
.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 {
|
.result-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -387,10 +423,11 @@
|
|||||||
|
|
||||||
<div class="card scan-section" id="scanSection">
|
<div class="card scan-section" id="scanSection">
|
||||||
<h2>📷 Сканирование</h2>
|
<h2>📷 Сканирование</h2>
|
||||||
<p class="scan-desc">Наведите камеру на лицо — мы сделаем 300 кадров и проведём анализ</p>
|
<p class="scan-desc">Наведите камеру на лицо — мы запишем видео и проведём анализ</p>
|
||||||
<button class="scan-btn" id="scanBtn">📷 Сканировать</button>
|
<button class="scan-btn" id="scanBtn">📷 Сканировать</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!--
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Загрузка видео</h2>
|
<h2>Загрузка видео</h2>
|
||||||
<div class="upload-area" id="uploadArea">
|
<div class="upload-area" id="uploadArea">
|
||||||
@@ -410,6 +447,7 @@
|
|||||||
<div class="progress-text" id="progressText">Подготовка...</div>
|
<div class="progress-text" id="progressText">Подготовка...</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
-->
|
||||||
|
|
||||||
<div class="card result" id="resultCard">
|
<div class="card result" id="resultCard">
|
||||||
<div class="result-header">
|
<div class="result-header">
|
||||||
@@ -427,7 +465,7 @@
|
|||||||
<div class="scan-oval"></div>
|
<div class="scan-oval"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="scan-progress">
|
<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-bar">
|
||||||
<div class="scan-progress-fill" id="scanProgressFill"></div>
|
<div class="scan-progress-fill" id="scanProgressFill"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -436,6 +474,15 @@
|
|||||||
<div class="scan-hint">Поместите лицо в овал целиком: лоб, глаза и подбородок — с запасом. Если голова не помещается — отодвиньте телефон</div>
|
<div class="scan-hint">Поместите лицо в овал целиком: лоб, глаза и подбородок — с запасом. Если голова не помещается — отодвиньте телефон</div>
|
||||||
</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>
|
<script>
|
||||||
const uploadArea = document.getElementById('uploadArea');
|
const uploadArea = document.getElementById('uploadArea');
|
||||||
const fileInput = document.getElementById('fileInput');
|
const fileInput = document.getElementById('fileInput');
|
||||||
@@ -522,6 +569,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
setProgress(100, 'Готово!');
|
setProgress(100, 'Готово!');
|
||||||
|
document.getElementById('scanLoader').classList.remove('open');
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
progressContainer.style.display = 'none';
|
progressContainer.style.display = 'none';
|
||||||
renderResult(data);
|
renderResult(data);
|
||||||
@@ -550,6 +598,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function showError(msg, videoUrl) {
|
function showError(msg, videoUrl) {
|
||||||
|
document.getElementById('scanLoader').classList.remove('open');
|
||||||
progressContainer.style.display = 'none';
|
progressContainer.style.display = 'none';
|
||||||
resultCard.style.display = 'block';
|
resultCard.style.display = 'block';
|
||||||
let html = `<div class="error-box"><strong>Ошибка:</strong> ${msg}</div>`;
|
let html = `<div class="error-box"><strong>Ошибка:</strong> ${msg}</div>`;
|
||||||
@@ -563,6 +612,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderResult(data) {
|
function renderResult(data) {
|
||||||
|
document.getElementById('scanLoader').classList.remove('open');
|
||||||
resultCard.style.display = 'block';
|
resultCard.style.display = 'block';
|
||||||
|
|
||||||
let html = '';
|
let html = '';
|
||||||
@@ -658,11 +708,8 @@
|
|||||||
let scanChunks = [];
|
let scanChunks = [];
|
||||||
let scanActive = false;
|
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);
|
scanBtn.addEventListener('click', startScan);
|
||||||
scanCancelBtn.addEventListener('click', cancelScan);
|
scanCancelBtn.addEventListener('click', cancelScan);
|
||||||
@@ -727,6 +774,8 @@
|
|||||||
stopCamera();
|
stopCamera();
|
||||||
scanActive = false;
|
scanActive = false;
|
||||||
scanBtn.disabled = false;
|
scanBtn.disabled = false;
|
||||||
|
// Показываем лоадер обработки
|
||||||
|
document.getElementById('scanLoader').classList.add('open');
|
||||||
sendVideo(blob, `scan.${ext}`);
|
sendVideo(blob, `scan.${ext}`);
|
||||||
};
|
};
|
||||||
mediaRecorder.start();
|
mediaRecorder.start();
|
||||||
|
|||||||
@@ -257,9 +257,6 @@
|
|||||||
<div class="download-buttons">
|
<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 }}/pdf">📄 Скачать PDF</a>
|
||||||
<a class="btn-download" href="/download/{{ result.share_id }}/docx">📝 Скачать DOCX</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>
|
<a class="btn-download" href="https://max.ru/miac_dnr_bot" target="_blank" rel="noopener">🏥 Запись на диспанцеризацию</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user