Skip to content

Commit bcc2ce8

Browse files
committed
Improved LLM query. Optimized user interface
1 parent 5a899a6 commit bcc2ce8

File tree

5 files changed

+181
-129
lines changed

5 files changed

+181
-129
lines changed
Lines changed: 122 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import json
2-
from typing import Dict, List, Tuple, Literal
2+
from typing import Dict, List, Tuple, Literal, Optional
33
import pandas as pd
4+
import numpy as np
45
from openai import OpenAI
56
from huggingface_hub import InferenceClient
67
from particleanalyzer.core.language_context import LanguageContext
@@ -17,8 +18,7 @@ def __init__(
1718
):
1819
self.provider = provider
1920
self.api_key = api_key
20-
self.stats: Dict[str, Dict[str, float]] = {}
21-
21+
2222
if provider == "openrouter":
2323
self.client = OpenAI(
2424
base_url="https://openrouter.ai/api/v1",
@@ -30,83 +30,138 @@ def __init__(
3030
self.model = huggingface_model
3131
else:
3232
raise ValueError("Неизвестный провайдер. Доступные варианты: 'openrouter', 'huggingface'")
33-
34-
def load_data(self, df: pd.DataFrame) -> None:
35-
required_columns = {
36-
self._get_translation("Параметр"),
37-
self._get_translation("Среднее"),
38-
self._get_translation("Медиана"),
39-
self._get_translation("Максимум"),
40-
self._get_translation("Минимум"),
41-
self._get_translation("Среднее"),
33+
34+
def _calculate_stats(self, df: pd.DataFrame, num_bins: int = 5) -> Dict[str, Dict]:
35+
"""Вычисляет статистику, сохраняя оригинальные названия столбцов"""
36+
stats = {
37+
"particles_count": len(df),
38+
"parameters": {}
4239
}
43-
if not required_columns.issubset(df.columns):
44-
raise ValueError(f"DataFrame должен содержать колонки: {required_columns}")
45-
46-
self.stats = {
47-
row[self._get_translation("Параметр")]: {
48-
self._get_translation("Среднее"): row[self._get_translation("Среднее")],
49-
self._get_translation("Медиана"): row[self._get_translation("Медиана")],
50-
self._get_translation("Максимум"): row[self._get_translation("Максимум")],
51-
self._get_translation("Минимум"): row[self._get_translation("Минимум")],
52-
self._get_translation("Среднее"): row[self._get_translation("Среднее")],
40+
41+
numeric_cols = df.select_dtypes(include=[np.number]).columns
42+
43+
for col in numeric_cols:
44+
# Базовые статистики
45+
stats["parameters"][col] = {
46+
"mean": float(df[col].mean()),
47+
"median": float(df[col].median()),
48+
"std": float(df[col].std()),
49+
"min": float(df[col].min()),
50+
"max": float(df[col].max()),
51+
"q1": df[col].quantile(0.25),
52+
"q3": df[col].quantile(0.75),
53+
"skewness": df[col].skew(),
54+
"kurtosis": df[col].kurtosis(),
55+
"histogram": self._create_histogram(df[col], num_bins)
5356
}
54-
for _, row in df.iterrows()
57+
58+
# Специальные расчеты (по оригинальным названиям)
59+
if "Dₘₐₓ" in col and any("Dₘᵢₙ" in c for c in df.columns):
60+
stats["parameters"]["aspect_ratio"] = self._calc_aspect_ratio(df, num_bins)
61+
62+
if "P [" in col and any("S [" in c for c in df.columns):
63+
stats["parameters"]["circularity"] = self._calc_circularity(df, num_bins)
64+
return stats
65+
66+
def _create_histogram(self, data, num_bins):
67+
"""Создает гистограмму"""
68+
counts, bins = np.histogram(data, bins=num_bins)
69+
return {
70+
"bins": [float(x) for x in bins],
71+
"counts": [int(x) for x in counts]
5572
}
5673

57-
def analyze(self, df: pd.DataFrame, count_particles: int) -> List[Tuple[None, str]]:
58-
self.count_particles = count_particles
59-
if df.empty:
60-
return [(None, None)]
74+
def _calc_aspect_ratio(self, df, num_bins):
75+
"""Вычисляет аспектное соотношение"""
76+
dmax_col = [c for c in df.columns if "Dₘₐₓ" in c][0]
77+
dmin_col = [c for c in df.columns if "Dₘᵢₙ" in c][0]
78+
ar = df[dmax_col] / df[dmin_col]
79+
return {
80+
"mean": float(ar.mean()),
81+
"median": float(ar.median()),
82+
"histogram": self._create_histogram(ar, num_bins)
83+
}
84+
85+
def _calc_circularity(self, df, num_bins):
86+
"""Вычисляет округлость"""
87+
p_col = [c for c in df.columns if "P [" in c][0]
88+
s_col = [c for c in df.columns if "S [" in c][0]
89+
circ = 4 * np.pi * df[s_col] / (df[p_col] ** 2)
90+
return {
91+
"mean": float(circ.mean()),
92+
"median": float(circ.median()),
93+
"histogram": self._create_histogram(circ, num_bins)
94+
}
95+
96+
def analyze(self, df: pd.DataFrame) -> List[Tuple[None, str]]:
97+
"""Анализирует DataFrame с частицами и возвращает результаты LLM"""
6198
self.lang = LanguageContext.get_language()
62-
self.load_data(df)
99+
100+
if df.empty:
101+
return [(None, "No particles detected")]
102+
103+
# Вычисляем статистику
104+
stats = self._calculate_stats(df)
105+
count_particles = len(df)
106+
63107
try:
64-
prompt = self._build_prompt()
108+
prompt = self._build_prompt(stats, count_particles)
65109
response = self._get_llm_response(prompt)
66110
return self._format_response(response)
67111
except Exception as e:
68112
print(f"LLM analysis failed: {str(e)}")
69-
return [(None, None)]
113+
return [(None, f"Analysis error: {str(e)}")]
70114

71-
def _build_prompt(self) -> str:
115+
def _build_prompt(self, stats, count_particles) -> str:
72116
return f"""
73-
Ты — эксперт в области материаловедения и сканирующей электронной микроскопии с 10 летним опытом.
74-
Проведи интерпретацию изображения СЭМ на основе статистических данных о размерных характеристиках частиц.
75-
В таблице представлены следующие характеристики:
76-
- Геометрические параметры: площадь, периметр, эквивалентный диаметр, диаметр Ферета (min, max, mean)
77-
- Морфологические параметры: эксцентриситет
78-
- Ориентация: максимальный/минимальный угол диаметра Ферета
79-
- Средняя интенсивность пикселей частиц
80-
При анализе больше обращай внимание на среднее и медианное значение, а не на максимальное и минимальное значение, так туда могут попасть обрезанные частицы.
81-
82-
🔍 **Сфокусируйся на следующих аспектах:**
83-
1. **Размерное распределение** — структура распределения, наличие преобладающих фракций, разброс (SD), соотношение Max/Min.
84-
Обрати внимания что результаты могут быть представлены как в мкм так и в пикселях.
85-
2. **Морфология** — эксцентриситет, округлость, вытянутость (Dₘₐₓ / Dₘᵢₙ)
86-
3. **Ориентация частиц** — есть ли выраженное предпочтение в ориентации.
87-
88-
📋 **Полная таблица данных:**
89-
Всего на изображении обнаружено {self.count_particles} частиц. Со следующими характеристиками:
90-
{json.dumps(self.stats, indent=2)}
91-
92-
✍️ **Сформулируй анализ в следующем формате:**
93-
94-
🔬 **Микроструктурный анализ**:
95-
- Размер частиц: <анализ по D, SD, разброс, количество частиц>
96-
- Форма: <анализ по e, аспектному отношению>
97-
- Ориентация: <анализ распределения углов>
98-
99-
🧪 **Материаловедческие выводы**:
100-
- <влияние морфологии и распределения на свойства материала, возможное происхождение>
101-
102-
💡 **Рекомендации**:
103-
- <предложения по улучшению технологии, методы контроля качества, возможные причины аномалий>
104-
105-
Отвечай на следующем языке {self.lang}
117+
Ты — ведущий эксперт в материаловедении и сканирующей электронной микроскопии (СЭМ) с 15-летним стажем.
118+
Твоя задача — подготовить развёрнутый экспертный отчёт по характеристикам {count_particles} частиц.
119+
120+
📌 **Контекст**:
121+
- Данные представлены в агрегированном виде (средние значения, медианы, стандартные отклонения и др.).
122+
- Размеры могут быть в микрометрах или пикселях.
123+
- Возможны аномально низкие значения из-за обрезки частиц по краям изображения.
124+
- **Важно**: Крупные частицы считаются достоверными — **не упоминай влияние обрезки в их отношении**.
125+
- Не пересказывай входные данные — **только интерпретируй**
126+
- Опирайся на количественные показатели и сравнение между параметрами. Избегай общих фраз без числовой поддержки. Анализ должен быть научно обоснованным и лаконичным.
127+
128+
🔬 **Задачи анализа**:
129+
1. **Распределение размеров** — тип, фракции, аномалии, полидисперсность
130+
2. **Морфология** — форма, вытянутость, однородность
131+
3. **Ориентация** — направленность
132+
4. **Проблемные зоны** — участки с сомнительными измерениями
133+
134+
✍️ **Формат отчёта (язык: {self.lang})** — без лишних отступов, используй маркированные списки и смайлики-иконки:
135+
136+
🌡️ **Размерные характеристики**:
137+
- **Размер**: <диаметр, диапазоны, преобладающие фракции>
138+
- **Тип распределения**: <нормальное / бимодальное / асимметричное>
139+
- **Аномалии**: <выбросы, артефакты, влияние обрезки>
140+
- **Полидисперсность**: <низкая / умеренная / высока
141+
142+
🔵 **Морфология**:
143+
- **Преобладающая форма**: <основано на circularity, aspect_ratio, эксцентриситете e>
144+
- **Однородность**: <оценка схожести форм>
145+
- **Особенности**: <необычные формы, группы, дефекты>
146+
147+
🧭 **Ориентация**:
148+
- **Тип распределения углов**: <равномерное / направленное>
149+
- **Предпочтительные направления**: <если есть>
150+
151+
⚠️ **Проблемные зоны**:
152+
- <опиши участки с сомнительными или недостоверными измерениями>
153+
154+
📊 Выводы:
155+
- Используй числовые значения с точностью до 2 знаков после запятой
156+
- Выводы должны быть лаконичными и научно обоснованными
157+
- Рекомендации не требуются
158+
159+
📁 **Данные для анализа** (агрегированные показатели):
160+
{json.dumps(stats, indent=2)}
106161
"""
107162

108163
def _get_llm_response(self, prompt: str):
109-
"""Отправка запроса в зависимости от провайдера"""
164+
"""Отправка запроса в LLM"""
110165
if self.provider == "openrouter":
111166
completion = self.client.chat.completions.create(
112167
extra_headers={
@@ -126,12 +181,9 @@ def _get_llm_response(self, prompt: str):
126181
)
127182

128183
def _format_response(self, response) -> List[Tuple[None, str]]:
129-
"""Форматирование ответа для обоих провайдеров"""
184+
"""Форматирование ответа LLM"""
130185
if self.provider == "openrouter":
131186
analysis = response.choices[0].message.content
132187
elif self.provider == "huggingface":
133188
analysis = response.choices[0].message.content
134-
return [(None, analysis)]
135-
136-
def _get_translation(self, text):
137-
return translations.get(self.lang, {}).get(text, text)
189+
return [(None, analysis)]

particleanalyzer/core/ParticleAnalyzer.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -613,16 +613,6 @@ def get_feret(contour, angles=np.arange(0, 180, 1)):
613613
"№": round(particle_counter, round_value),
614614
"centroid_x": round(centroid_x, round_value),
615615
"centroid_y": round(centroid_y, round_value),
616-
(
617-
self._get_translation("S [мкм²]")
618-
if scale_selector == self._get_translation("Instrument scale in µm")
619-
else self._get_translation("S [пикс²]")
620-
): round(area * scale_area, round_value),
621-
(
622-
self._get_translation("P [мкм]")
623-
if scale_selector == self._get_translation("Instrument scale in µm")
624-
else self._get_translation("P [пикс]")
625-
): round(perimeter * scale_factor, round_value),
626616
(
627617
self._get_translation("D [мкм]")
628618
if scale_selector == self._get_translation("Instrument scale in µm")
@@ -653,6 +643,16 @@ def get_feret(contour, angles=np.arange(0, 180, 1)):
653643
if scale_selector == self._get_translation("Instrument scale in µm")
654644
else self._get_translation("θₘᵢₙ [°]")
655645
): round(angle_min, round_value),
646+
(
647+
self._get_translation("S [мкм²]")
648+
if scale_selector == self._get_translation("Instrument scale in µm")
649+
else self._get_translation("S [пикс²]")
650+
): round(area * scale_area, round_value),
651+
(
652+
self._get_translation("P [мкм]")
653+
if scale_selector == self._get_translation("Instrument scale in µm")
654+
else self._get_translation("P [пикс]")
655+
): round(perimeter * scale_factor, round_value),
656656
"e": round(eccentricity, round_value),
657657
self._get_translation("I [ед.]"): round(mean_intensity, round_value),
658658
}

0 commit comments

Comments
 (0)