diff --git a/src/music_engine/image_processor.py b/src/music_engine/image_processor.py index bc47b2c..fc147f5 100644 --- a/src/music_engine/image_processor.py +++ b/src/music_engine/image_processor.py @@ -5,41 +5,53 @@ import timm from pathlib import Path import numpy as np +# НОВЫЙ ИМПОРТ ДЛЯ VLM +from transformers import BlipProcessor, BlipForConditionalGeneration + class ImageProcessor: def __init__(self, model_path: Path | str): self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') - # Загружаем базовую архитектуру, как при обучении EmoSet - self.model = timm.create_model('resnet50', pretrained=False, num_classes=8) - - # Подгружаем обученные веса + # --- ПОТОК 1: ЭМОЦИИ (ResNet-50) --- + print("⏳ Загрузка эмоционального модуля (ResNet-50)...") + self.emo_model = timm.create_model('resnet50', pretrained=False, num_classes=8) if Path(model_path).exists(): - # map_location позволяет загрузить модель на CPU, если нет видеокарты - self.model.load_state_dict(torch.load(model_path, map_location=self.device)) - print(f"✅ Веса ResNet-50 успешно загружены из {model_path}") - else: - print(f"⚠️ ОШИБКА: Файл весов {model_path} не найден! Модель будет выдавать случайный шум.") - - # Удаляем последний слой (классификатор на 8 эмоций), - # чтобы на выходе получать сырой вектор (embedding) на 2048 чисел - self.model.fc = torch.nn.Identity() - - self.model.to(self.device) - self.model.eval() + self.emo_model.load_state_dict(torch.load(model_path, map_location=self.device)) + self.emo_model.fc = torch.nn.Identity() + self.emo_model.to(self.device).eval() - # Стандартные трансформации ImageNet (строго как при обучении) - self.transform = T.Compose([ + self.emo_transform = T.Compose([ T.Resize((224, 224)), T.ToTensor(), T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) + # --- ПОТОК 2: СЕМАНТИКА И КОНТЕКСТ (BLIP Large) --- + print("⏳ Загрузка мощной VLM модели (BLIP) для описания сцен...") + # Используем версию Large, так как позволяет железо V100 + self.blip_processor = BlipProcessor.from_pretrained("Salesforce/blip-image-captioning-large") + self.blip_model = BlipForConditionalGeneration.from_pretrained("Salesforce/blip-image-captioning-large").to(self.device) + + print("✅ Обе нейросети визуального анализа успешно загружены на V100!") + @torch.no_grad() def extract_embedding(self, image: Image.Image) -> np.ndarray: - """Принимает PIL Image, возвращает numpy-вектор.""" - # Переводим в RGB (на случай если загрузят PNG с прозрачностью или ЧБ) + """Извлекает 2048-мерный вектор эмоций.""" img_rgb = image.convert('RGB') - img_tensor = self.transform(img_rgb).unsqueeze(0).to(self.device) + img_tensor = self.emo_transform(img_rgb).unsqueeze(0).to(self.device) + return self.emo_model(img_tensor).cpu().numpy().flatten() - embedding = self.model(img_tensor) - return embedding.cpu().numpy().flatten() \ No newline at end of file + @torch.no_grad() + def describe_scene(self, image: Image.Image) -> str: + """Генерирует текстовое описание картинки (Captioning) для LLM.""" + img_rgb = image.convert('RGB') + + # Готовим картинку для BLIP + inputs = self.blip_processor(img_rgb, return_tensors="pt").to(self.device) + + # Генерируем описание (max_new_tokens ограничим, чтобы было лаконично) + out = self.blip_model.generate(**inputs, max_new_tokens=30) + + # Декодируем тензор в строку + caption = self.blip_processor.decode(out[0], skip_special_tokens=True) + return caption \ No newline at end of file diff --git a/src/music_engine/llm_bridge.py b/src/music_engine/llm_bridge.py new file mode 100644 index 0000000..96bbea8 --- /dev/null +++ b/src/music_engine/llm_bridge.py @@ -0,0 +1,60 @@ +import requests +import json +import re + +class LLMAcousticBridge: + def __init__(self, model_name="phi3", host="http://localhost:11434"): + self.model_name = model_name + self.api_url = f"{host}/api/generate" + + def _clean_json(self, text): + """Вытаскивает чистый JSON из ответа нейросети.""" + try: + match = re.search(r'\{.*\}', text, re.DOTALL) + if match: + return json.loads(match.group(0)) + return json.loads(text) + except: + return None + + def get_acoustic_profile(self, valence, arousal, scene_descriptions): + """Просит LLM сгенерировать идеальный звук под описание.""" + # Объединяем описания, если загружено несколько фото + context_str = " | ".join(scene_descriptions) if scene_descriptions else "abstract scene" + + prompt = f"""You are an expert music producer and acoustic engineer. +Analyze the visual context and emotions to determine the ideal background music properties. +Emotions: Valence {valence:.1f}/9.0 (Positivity), Arousal {arousal:.1f}/9.0 (Energy). +Visual Context: {context_str}. + +Map this scene to exactly 6 acoustic features. Values MUST be floats between 0.0 and 1.0. +1. "energy": (Loudness/Density. High for massive/busy scenes, Low for calm) +2. "flux": (Rhythmic sharpness/Beat. High for action/people/cars, Low for static nature) +3. "centroid": (Brightness: 0=Dark/Bass/Massive, 1=Bright/Treble/Light) +4. "pitch": (Fundamental frequency: 0=Low pitch/Huge objects, 1=High pitch/Small objects) +5. "hnr": (Harmonics-to-Noise: 0=Noisy/Distorted textures, 1=Clear/Melodic/Smooth textures) +6. "zcr": (Percussiveness. High for detailed noise like leaves/rain, Low for solid blocks) + +Return ONLY a valid JSON object. Do not add any text or explanation. +Example: {{"energy": 0.5, "flux": 0.2, "centroid": 0.4, "pitch": 0.3, "hnr": 0.8, "zcr": 0.1}}""" + + try: + response = requests.post(self.api_url, json={ + "model": self.model_name, + "prompt": prompt, + "stream": False, + "format": "json" + }, timeout=30) + response.raise_for_status() + + result_text = response.json().get("response", "") + profile = self._clean_json(result_text) + + # Проверяем, что все нужные ключи есть + required_keys = ['energy', 'flux', 'centroid', 'pitch', 'hnr', 'zcr'] + if profile and all(k in profile for k in required_keys): + return profile + return None + except Exception as e: + print(f"⚠️ Ошибка связи с локальной LLM: {e}") + return None \ No newline at end of file diff --git a/src/music_engine/matcher.py b/src/music_engine/matcher.py index 9a6e6ea..a5917ff 100644 --- a/src/music_engine/matcher.py +++ b/src/music_engine/matcher.py @@ -5,64 +5,63 @@ import joblib class MusicMatcher: def __init__(self, db_path: Path | str, model_path: Path | str): - """ - Инициализация движка сопоставления музыки. - """ + # Загружаем твою новую, обогащенную базу self.music_db = pd.read_csv(db_path) - self.music_db['valence'] = pd.to_numeric(self.music_db['valence'], errors='coerce') - self.music_db['arousal'] = pd.to_numeric(self.music_db['arousal'], errors='coerce') - self.music_db = self.music_db.dropna() + self.acoustic_features = ['energy', 'flux', 'centroid', 'pitch', 'hnr', 'zcr'] + # Удаляем строки, где нет акустических фич + self.music_db = self.music_db.dropna(subset=['valence', 'arousal'] + self.acoustic_features) + + # Нормализуем акустику от 0 до 1, чтобы сравнивать с ответом LLM + self.norm_db = self.music_db.copy() + for feat in self.acoustic_features: + f_min, f_max = self.norm_db[feat].min(), self.norm_db[feat].max() + if f_max > f_min: + self.norm_db[f"norm_{feat}"] = (self.norm_db[feat] - f_min) / (f_max - f_min) + else: + self.norm_db[f"norm_{feat}"] = 0.0 + self.audio_dir = Path(db_path).parent / "DEAM_audio" / "MEMD_audio" - - if self.audio_dir.exists(): - print(f"✅ Музыкальный архив найден: {self.audio_dir}") - else: - print(f"⚠️ ПРЕДУПРЕЖДЕНИЕ: Папка {self.audio_dir} не найдена!") - - if Path(model_path).exists(): - self.regressor = joblib.load(model_path) - print("✅ ML-регрессор загружен.") - else: - self.regressor = None - print("⚠️ Файл модели .pkl не найден.") + self.regressor = joblib.load(model_path) if Path(model_path).exists() else None def predict_va(self, embedding: np.ndarray): - """Честный прогноз координат Valence-Arousal.""" - if self.regressor is not None: - emb_2d = embedding.reshape(1, -1) - prediction = self.regressor.predict(emb_2d)[0] + if self.regressor: + prediction = self.regressor.predict(embedding.reshape(1, -1))[0] return np.clip(prediction[0], 1.0, 9.0), np.clip(prediction[1], 1.0, 9.0) return 5.0, 5.0 def get_audio_path(self, song_id): - """Поиск mp3 файла по его номеру.""" - if not self.audio_dir.exists(): - return None - + if not self.audio_dir.exists(): return None clean_id = str(int(float(song_id))) for ext in ['.mp3', '.wav']: - file_path = self.audio_dir / f"{clean_id}{ext}" - if file_path.exists(): - return file_path + path = self.audio_dir / f"{clean_id}{ext}" + if path.exists(): return path return None - def find_nearest_tracks(self, target_v: float, target_a: float, top_k: int = 5): - """ - Поиск с использованием Взвешенного Евклидова расстояния (Weighted KNN). - Энергия (Arousal) получает больший вес, так как она сильнее - определяет жанр и ритм композиции. - """ - # Вес для Arousal = 2.0, для Valence = 1.0 - # Это не позволит спокойным трекам (A < 4) попадать в выдачу - # для энергичных запросов (A > 6). - distances = np.sqrt( - 1.0 * (self.music_db['valence'] - target_v)**2 + - 2.5 * (self.music_db['arousal'] - target_a)**2 # Жесткий штраф за разницу в энергии + def find_nearest_tracks(self, target_v: float, target_a: float, llm_profile: dict = None, top_k: int = 5): + # 1. Эмоциональная дистанция (как и раньше) + emo_dist = np.sqrt( + 1.0 * (self.norm_db['valence'] - target_v)**2 + + 2.5 * (self.norm_db['arousal'] - target_a)**2 ) + self.norm_db['emo_distance'] = emo_dist - df_result = self.music_db.copy() - df_result['distance'] = distances - - # Сортируем по расстоянию и берем топ-K - return df_result.sort_values(by='distance').head(top_k) \ No newline at end of file + # Если LLM не дала ответ, сортируем только по эмоциям + if not llm_profile: + self.norm_db['final_score'] = self.norm_db['emo_distance'] + return self.norm_db.sort_values(by='final_score').head(top_k) + + # 2. Акустическая дистанция (сравниваем треки с запросом LLM) + acoustic_penalty = np.zeros(len(self.norm_db)) + for feat in self.acoustic_features: + if feat in llm_profile: + target_val = llm_profile[feat] + acoustic_penalty += np.abs(self.norm_db[f"norm_{feat}"] - target_val) + + # Усредняем штраф + self.norm_db['acoustic_distance'] = acoustic_penalty / len(self.acoustic_features) + + # 3. Финальный Score (Смесь Эмоций и Акустики). Коэф 4.0 делает акустику важной! + self.norm_db['final_score'] = self.norm_db['emo_distance'] + (self.norm_db['acoustic_distance'] * 4.0) + + return self.norm_db.sort_values(by='final_score').head(top_k) \ No newline at end of file diff --git a/src/scripts/aggregate_features.ipynb b/src/scripts/aggregate_features.ipynb new file mode 100644 index 0000000..8b179c8 --- /dev/null +++ b/src/scripts/aggregate_features.ipynb @@ -0,0 +1,125 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 2, + "id": "0336fd0c", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "✅ База загружена. Треков: 1744\n", + "🔍 Собираем акустические признаки...\n", + "\n", + "🚀 ГОТОВО! Обогащенная база сохранена: ../../dataset/DEAM/music_db_enriched.csv\n", + "Собрано фичей для 1744 из 1744 треков.\n", + " song_id valence arousal energy flux centroid pitch \\\n", + "0 2 3.1 3.0 0.097268 0.846947 483.421751 93.884056 \n", + "1 3 3.5 3.3 0.126809 0.959460 173.219616 62.682589 \n", + "2 4 5.7 5.5 0.156699 1.333944 466.434797 92.850316 \n", + "3 5 4.4 5.3 0.126455 1.009927 546.152506 158.673853 \n", + "4 7 5.8 6.4 0.268180 1.589191 175.369162 83.823484 \n", + "\n", + " hnr zcr entropy sharpness \n", + "0 3.615380 0.034270 3.299075 0.426490 \n", + "1 -2.600122 0.017893 2.294971 0.165583 \n", + "2 -0.579130 0.042936 3.258138 0.395410 \n", + "3 1.751148 0.043781 3.514585 0.494367 \n", + "4 12.006770 0.014783 2.177862 0.170058 \n" + ] + } + ], + "source": [ + "import pandas as pd\n", + "import numpy as np\n", + "from pathlib import Path\n", + "from tqdm import tqdm # для красивого прогресс-бара, если не установлен - убери\n", + "\n", + "# 1. Пути к файлам\n", + "base_dir = Path(\"../../dataset/DEAM\") # Поправь, если запускаешь из другого места\n", + "music_db_path = base_dir / \"music_db.csv\"\n", + "features_dir = base_dir / \"features\" / \"features\"\n", + "output_path = base_dir / \"music_db_enriched.csv\"\n", + "\n", + "# 2. Наш \"Золотой список\" (8 признаков)\n", + "target_columns = {\n", + " 'pcm_RMSenergy_sma_amean': 'energy',\n", + " 'pcm_fftMag_spectralFlux_sma_amean': 'flux',\n", + " 'pcm_fftMag_spectralCentroid_sma_amean': 'centroid',\n", + " 'F0final_sma_amean': 'pitch',\n", + " 'logHNR_sma_amean': 'hnr',\n", + " 'pcm_zcr_sma_amean': 'zcr',\n", + " 'pcm_fftMag_spectralEntropy_sma_amean': 'entropy',\n", + " 'pcm_fftMag_psySharpness_sma_amean': 'sharpness'\n", + "}\n", + "\n", + "# 3. Загружаем текущую базу с V/A\n", + "if not music_db_path.exists():\n", + " print(f\"❌ ОШИБКА: Не найден файл {music_db_path}\")\n", + "else:\n", + " df_main = pd.read_csv(music_db_path)\n", + " print(f\"✅ База загружена. Треков: {len(df_main)}\")\n", + "\n", + " # Подготавливаем новые колонки\n", + " for col_name in target_columns.values():\n", + " df_main[col_name] = np.nan\n", + "\n", + " # 4. Проходимся по всем трекам и ищем их акустические CSV\n", + " print(\"🔍 Собираем акустические признаки...\")\n", + " found_count = 0\n", + " \n", + " for index, row in df_main.iterrows():\n", + " song_id = int(row['song_id'])\n", + " feature_file = features_dir / f\"{song_id}.csv\"\n", + " \n", + " if feature_file.exists():\n", + " try:\n", + " # Читаем CSV с признаками (разделитель там обычно точка с запятой)\n", + " df_feat = pd.read_csv(feature_file, sep=';')\n", + " \n", + " # Усредняем значения по всем фреймам (одна песня разбита на сотни строк-фреймов)\n", + " mean_features = df_feat[list(target_columns.keys())].mean()\n", + " \n", + " # Записываем в главную базу\n", + " for orig_col, new_col in target_columns.items():\n", + " df_main.at[index, new_col] = mean_features[orig_col]\n", + " \n", + " found_count += 1\n", + " except Exception as e:\n", + " print(f\"Ошибка чтения {feature_file}: {e}\")\n", + " \n", + " # 5. Сохраняем результат\n", + " # Удаляем треки, для которых не нашлось фичей (если такие есть)\n", + " df_main = df_main.dropna(subset=list(target_columns.values()))\n", + " \n", + " df_main.to_csv(output_path, index=False)\n", + " print(f\"\\n🚀 ГОТОВО! Обогащенная база сохранена: {output_path}\")\n", + " print(f\"Собрано фичей для {found_count} из {len(df_main)} треков.\")\n", + " print(df_main.head())" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python (thesis)", + "language": "python", + "name": "thesis" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.7" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/src/tabs/tab_live.py b/src/tabs/tab_live.py index 4e30c34..193bf1b 100644 --- a/src/tabs/tab_live.py +++ b/src/tabs/tab_live.py @@ -2,11 +2,11 @@ import streamlit as st import numpy as np from PIL import Image import matplotlib.pyplot as plt +from music_engine.llm_bridge import LLMAcousticBridge # ИМПОРТИРУЕМ МОСТ def render_live_tab(matcher, image_processor): - st.write("Загрузите фотографии с вашего устройства (например, снимки с недавней поездки или прогулки). Система проанализирует их эмоциональный фон и подберет подходящий саундтрек.") + st.write("Загрузите фотографии с вашего устройства. Система проанализирует эмоции и семантику кадра.") - # Drag-and-drop интерфейс для загрузки нескольких файлов uploaded_files = st.file_uploader( "Перетащите изображения сюда", type=['png', 'jpg', 'jpeg'], @@ -14,48 +14,57 @@ def render_live_tab(matcher, image_processor): ) if uploaded_files: - st.subheader("Загруженные образы:") + st.subheader("Анализ визуальных признаков:") - # Показываем миниатюры загруженных фото cols = st.columns(min(len(uploaded_files), 5)) images = [] + all_objects = [] + for i, file in enumerate(uploaded_files): 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): - with st.spinner("Анализируем визуальные признаки нейросетью..."): - all_v, all_a = [], [] - - # Прогоняем каждое фото через пайплайн: ResNet -> Ridge Regressor - for img in images: - embedding = image_processor.extract_embedding(img) - v, a = matcher.predict_va(embedding) - all_v.append(v) - all_a.append(a) - - # Late Fusion: усредняем результаты - target_v, target_a = np.mean(all_v), np.mean(all_a) - playlist = matcher.find_nearest_tracks(target_v, target_a, top_k=5) - - st.success("✅ Саундтрек сформирован!") + 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) + + target_v, target_a = np.mean(all_v), np.mean(all_a) + + # 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]) with col_left: - st.header("📊 Профиль события") - st.metric("Позитивность (Valence)", f"{target_v:.2f}") - st.metric("Энергия (Arousal)", f"{target_a:.2f}") + st.header("📊 Профиль") + st.metric("Valence (Настроение)", f"{target_v:.2f}") + st.metric("Arousal (Энергия)", f"{target_a:.2f}") - fig, ax = plt.subplots(figsize=(4, 4)) - ax.set_xlim(1, 9); ax.set_ylim(1, 9) - ax.axhline(5, color='gray', lw=1, ls='--'); ax.axvline(5, color='gray', lw=1, ls='--') - ax.scatter(target_v, target_a, color='green', s=150, edgecolors='white', zorder=5) - ax.set_xlabel("Valence"); ax.set_ylabel("Arousal") - st.pyplot(fig) + if llm_profile: + st.write("**Требования LLM к звуку:**") + for k, v in llm_profile.items(): + st.caption(f"- {k}: {v:.2f}") with col_right: st.header("🎵 Плейлист") @@ -63,11 +72,11 @@ def render_live_tab(matcher, image_processor): with st.container(border=True): c1, c2 = st.columns([1, 3]) with c1: - st.write(f"**ID:** {int(row['song_id'])}") - st.caption(f"L2 Dist: {row['distance']:.2f}") + 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(f"Файл {int(row['song_id'])}.mp3 не найден") \ No newline at end of file + st.warning("Файл не найден") \ No newline at end of file