diff --git a/src/main.py b/src/main.py index 2b9d04b..ddc3485 100644 --- a/src/main.py +++ b/src/main.py @@ -1,4 +1,5 @@ import streamlit as st +import streamlit.components.v1 as components import sys import os import subprocess @@ -8,7 +9,7 @@ from tabs.tab_dataset import render_dataset_tab from tabs.tab_live import render_live_tab # ---------------------------- -# 1️⃣ Запуск приложения +# Запуск приложения # ---------------------------- if __name__ == "__main__": if "STREAMLIT_RUN" not in os.environ: @@ -17,27 +18,77 @@ if __name__ == "__main__": subprocess.run(cmd) sys.exit() -st.set_page_config(page_title="Thesis Demo", layout="wide") +# Автоматическое определение типа устройства через URL query parameters +# Считывание происходит до set_page_config, что позволяет динамически менять layout +viewport = st.query_params.get("viewport", "desktop") +layout_mode = "centered" if viewport == "mobile" else "wide" + +st.set_page_config(page_title="Thesis Demo", layout=layout_mode) + +# Внедрение легковесного JavaScript-детектора для определения ширины экрана +# Перезагружает контекст Streamlit один раз при инициализации сессии, исключая циклическую перезагрузку +components.html( + """ + + """, + height=0, + width=0, +) + +# Глобальная инъекция базовых CSS-стилей для адаптации медиаконтента +st.markdown( + """ + + """, + unsafe_allow_html=True +) # ---------------------------- -# 2️⃣ Инициализация движка и данных +# Инициализация движка и данных # ---------------------------- matcher = load_music_engine() image_processor = load_image_processor() image_files, embeddings, labels_array, images_path = load_emoset_data() # ---------------------------- -# 3️⃣ Интерфейс и Вкладки +# Интерфейс и Вкладки # ---------------------------- -st.title("🖼️ Генератор саундтреков (Research Demo)") +st.title("Генератор саундтреков (Research Demo)") -tab1, tab2 = st.tabs(["📊 Отладка (Датасет EmoSet)", "📸 Анализ событий (Свои фото)"]) +# Изменен порядок: Анализ событий стал первой активной вкладкой +tab1, tab2 = st.tabs(["Анализ событий (Свои фото)", "Отладка (Датасет EmoSet)"]) with tab1: - render_dataset_tab(matcher, image_files, embeddings, labels_array, images_path) - -with tab2: if image_processor: render_live_tab(matcher, image_processor) else: - st.error("Система обработки изображений недоступна (не найдены веса ResNet).") \ No newline at end of file + st.error("Система обработки изображений недоступна (не найдены веса ResNet).") + +with tab2: + render_dataset_tab(matcher, image_files, embeddings, labels_array, images_path) \ No newline at end of file diff --git a/src/tabs/tab_live.py b/src/tabs/tab_live.py index c1a87da..bd82540 100644 --- a/src/tabs/tab_live.py +++ b/src/tabs/tab_live.py @@ -1,82 +1,208 @@ import streamlit as st +import streamlit.components.v1 as components import numpy as np from PIL import Image -import matplotlib.pyplot as plt -from music_engine.llm_bridge import LLMAcousticBridge # ИМПОРТИРУЕМ МОСТ +import base64 +from io import BytesIO +from music_engine.llm_bridge import LLMAcousticBridge + +# Вспомогательная функция для крохотного предпросмотра +def get_thumbnail_html(images, max_display=12): + html_images = "" + for file in images[:max_display]: + img = Image.open(file) + img.thumbnail((100, 100)) # Сжимаем картинку + if img.mode != "RGB": + img = img.convert("RGB") + + buffered = BytesIO() + img.save(buffered, format="JPEG") + b64_str = base64.b64encode(buffered.getvalue()).decode() + + # Строгие стили для квадратных миниатюр + html_images += f'' + + # Индикатор оставшихся фото, если их много + if len(images) > max_display: + html_images += f'+{len(images) - max_display}' + + return f'
{html_images}
' def render_live_tab(matcher, image_processor): - st.write("Загрузите фотографии с вашего устройства. Система проанализирует эмоции и семантику кадра.") - - uploaded_files = st.file_uploader( - "Перетащите изображения сюда", - type=['png', 'jpg', 'jpeg'], - accept_multiple_files=True - ) - - if uploaded_files: - st.subheader("Анализ визуальных признаков:") + if "live_state" not in st.session_state: + st.session_state.live_state = "upload" + if "result_data" not in st.session_state: + st.session_state.result_data = None + + viewport = st.query_params.get("viewport", "desktop") + + # ========================================== + # CSS ИНЪЕКЦИИ + # ========================================== + st.markdown(""" + + """, unsafe_allow_html=True) + + # ========================================== + # ЭКРАН 1: ЗАГРУЗКА + # ========================================== + if st.session_state.live_state == "upload": + + upload_placeholder = st.empty() + with upload_placeholder.container(): + st.write("Загрузите фотографии с вашего устройства. Система проанализирует эмоции и семантику кадра.") + + if viewport == "mobile": + st.markdown("
", unsafe_allow_html=True) + + uploaded_files = st.file_uploader( + "Перетащите изображения сюда", + type=['png', 'jpg', 'jpeg'], + accept_multiple_files=True, + label_visibility="collapsed" if viewport == "mobile" else "visible" + ) + + if uploaded_files: + # 1. КНОПКА СРАЗУ ПОСЛЕ ЗАГРУЗКИ (Не нужно скроллить вниз) + st.markdown("
", unsafe_allow_html=True) + if st.button("Сгенерировать саундтрек", type="primary", use_container_width=True): + st.session_state.uploaded_images = uploaded_files + st.session_state.live_state = "processing" + upload_placeholder.empty() + st.rerun() + + # 2. МИНИАТЮРЫ ПОД КНОПКОЙ + st.markdown("
", unsafe_allow_html=True) + st.caption("Выбранные кадры:") + # Генерируем компактный блок миниатюр + st.markdown(get_thumbnail_html(uploaded_files), unsafe_allow_html=True) + + # ========================================== + # ЭКРАН 2: АНАЛИЗ (СПИННЕР) + # ========================================== + elif st.session_state.live_state == "processing": + + components.html("", height=0, width=0) + + files = st.session_state.get("uploaded_images", []) + st.markdown('
', unsafe_allow_html=True) + status_text = st.empty() - cols = st.columns(min(len(uploaded_files), 5)) images = [] all_objects = [] + all_v, all_a = [], [] - for i, file in enumerate(uploaded_files): + for i, file in enumerate(files): + status_text.markdown(f"

Анализ кадра {i + 1} из {len(files)}...

", unsafe_allow_html=True) + img = Image.open(file) images.append(img) - with cols[i % 5]: - st.image(img, use_container_width=True) - with st.spinner("VLM Анализ..."): - caption = image_processor.describe_scene(img) - st.caption(f"*{caption.capitalize()}*") - all_objects.append(caption) - - if st.button("Сгенерировать саундтрек", type="primary", use_container_width=True): - # 1. Извлекаем эмоции - all_v, all_a = [], [] - for img in images: - embedding = image_processor.extract_embedding(img) - v, a = matcher.predict_va(embedding) - all_v.append(v) - all_a.append(a) + embedding = image_processor.extract_embedding(img) + v, a = matcher.predict_va(embedding) + all_v.append(v) + all_a.append(a) - target_v, target_a = np.mean(all_v), np.mean(all_a) + caption = image_processor.describe_scene(img) + all_objects.append(caption) - # 2. Переводим Объекты -> Акустику через LLM - with st.spinner("Phi-3 генерирует акустический профиль..."): - llm = LLMAcousticBridge() - llm_profile = llm.get_acoustic_profile(target_v, target_a, list(set(all_objects))) - - # 3. Ищем треки - with st.spinner("Поиск треков в базе DEAM..."): - playlist = matcher.find_nearest_tracks(target_v, target_a, llm_profile=llm_profile, top_k=5) - - st.success("Кросс-модальный анализ завершен!") - - # ВЫВОД РЕЗУЛЬТАТОВ - col_left, col_right = st.columns([1, 2]) + target_v, target_a = np.mean(all_v), np.mean(all_a) + + status_text.markdown("

Трансляция семантики в аудиопрофиль...

", unsafe_allow_html=True) + llm = LLMAcousticBridge() + llm_profile = llm.get_acoustic_profile(target_v, target_a, list(set(all_objects))) + + status_text.markdown("

Поиск идеальных композиций...

", unsafe_allow_html=True) + playlist = matcher.find_nearest_tracks(target_v, target_a, llm_profile=llm_profile, top_k=15) + + st.session_state.result_data = { + "target_v": target_v, + "target_a": target_a, + "llm_profile": llm_profile, + "playlist": playlist, + "semantics": list(set(all_objects)) + } + st.session_state.live_state = "result" + st.rerun() + + # ========================================== + # ЭКРАН 3: РЕЗУЛЬТАТЫ + # ========================================== + elif st.session_state.live_state == "result": + + components.html("", height=0, width=0) + + data = st.session_state.result_data + st.header("Рекомендованный плейлист") + + for _, row in data["playlist"].iterrows(): + with st.container(border=True): + if viewport == "desktop": + c1, c2 = st.columns([1, 3]) + with c1: + st.write(f"**Track:** {int(row['song_id'])}") + st.caption(f"Score: {row['final_score']:.2f}") + with c2: + audio_path = matcher.get_audio_path(row['song_id']) + if audio_path: + st.audio(str(audio_path)) + else: + st.warning("Файл не найден") + else: + st.write(f"**Track:** {int(row['song_id'])} (Score: {row['final_score']:.2f})") + audio_path = matcher.get_audio_path(row['song_id']) + if audio_path: + st.audio(str(audio_path)) + else: + st.warning("Файл не найден") - with col_left: - st.header("Профиль") - st.metric("Valence (Настроение)", f"{target_v:.2f}") - st.metric("Arousal (Энергия)", f"{target_a:.2f}") + st.markdown("
", unsafe_allow_html=True) + + with st.expander("Технические параметры анализа"): + c_v, c_a = st.columns(2) + c_v.metric("Valence (Настроение)", f"{data['target_v']:.2f}") + c_a.metric("Arousal (Энергия)", f"{data['target_a']:.2f}") + + st.markdown("---") + st.write("**Акустические таргеты (LLM):**") + if data["llm_profile"]: + cols_per_row = 2 if viewport == "mobile" else 3 + llm_items = list(data["llm_profile"].items()) - if llm_profile: - st.write("**Требования LLM к звуку:**") - for k, v in llm_profile.items(): - st.caption(f"- {k}: {v:.2f}") + for i in range(0, len(llm_items), cols_per_row): + cols = st.columns(cols_per_row) + for j in range(cols_per_row): + if i + j < len(llm_items): + k, v = llm_items[i + j] + cols[j].metric(k, f"{v:.2f}") + + st.markdown("---") + st.write("**Обнаруженная семантика:**") + st.write(", ".join([str(c).capitalize() for c in data["semantics"]])) - with col_right: - st.header("Плейлист") - for _, row in playlist.iterrows(): - with st.container(border=True): - c1, c2 = st.columns([1, 3]) - with c1: - st.write(f"**Track:** {int(row['song_id'])}") - st.caption(f"Score: {row['final_score']:.2f}") - with c2: - audio_path = matcher.get_audio_path(row['song_id']) - if audio_path: - st.audio(str(audio_path)) - else: - st.warning("Файл не найден") \ No newline at end of file + st.markdown("
", unsafe_allow_html=True) + + if st.button("Новый анализ", use_container_width=True): + st.session_state.live_state = "upload" + st.session_state.result_data = None + st.session_state.pop("uploaded_images", None) + st.rerun() \ No newline at end of file