From 39e01e3858e21e2d660293122ef206d143e3280d Mon Sep 17 00:00:00 2001 From: 4elodoy-Molovek Date: Thu, 25 Jun 2026 18:10:56 +0300 Subject: [PATCH] add PySide6/QML desktop UI for synthetic EM image generator Three-column layout (parameters | visualizer+log | histograms), vertical action buttons, mask ComboBox with 7 options, histogram ring buffer panel, generation log that captures generator stdout, dataset mode with directory pre-creation fix. Co-Authored-By: Claude Sonnet 4.6 --- UI/README.md | 122 +++++++++++ UI/backend.py | 437 +++++++++++++++++++++++++++++++++++++ UI/main_ui.py | 50 +++++ UI/qml/GenerationPanel.qml | 270 +++++++++++++++++++++++ UI/qml/HistSlot.qml | 67 ++++++ UI/qml/HistogramPanel.qml | 122 +++++++++++ UI/qml/HistogramView.qml | 62 ++++++ UI/qml/ParameterPanel.qml | 114 ++++++++++ UI/qml/SliderRow.qml | 129 +++++++++++ UI/qml/Snackbar.qml | 70 ++++++ UI/qml/Visualizer.qml | 178 +++++++++++++++ UI/qml/main.qml | 382 ++++++++++++++++++++++++++++++++ 12 files changed, 2003 insertions(+) create mode 100644 UI/README.md create mode 100644 UI/backend.py create mode 100644 UI/main_ui.py create mode 100644 UI/qml/GenerationPanel.qml create mode 100644 UI/qml/HistSlot.qml create mode 100644 UI/qml/HistogramPanel.qml create mode 100644 UI/qml/HistogramView.qml create mode 100644 UI/qml/ParameterPanel.qml create mode 100644 UI/qml/SliderRow.qml create mode 100644 UI/qml/Snackbar.qml create mode 100644 UI/qml/Visualizer.qml create mode 100644 UI/qml/main.qml diff --git a/UI/README.md b/UI/README.md new file mode 100644 index 0000000..815c140 --- /dev/null +++ b/UI/README.md @@ -0,0 +1,122 @@ +# Synthetics Generator UI + +Интерактивный интерфейс для подбора параметров раскраски 3D-генератора синтетических EM-изображений. + +## Установка зависимостей + +```bash +pip install PySide6 matplotlib scikit-image opencv-python +``` + +## Запуск + +```bash +# из корня проекта: +python UI/main_ui.py +``` + +## Структура окна + +``` +┌─ Toolbar ────────────────────────────────────────────────────────────────────┐ +│ Synthetics Generator [Конфиг: setting.json Открыть] [☀/🌙] │ +├─ Левая панель (380px) ──────┬─── Центральная панель ───┬─ Правая панель (380px) ─┤ +│ │ │ │ +│ [Параметры] [Настройки] │ [Визуализация] [Журнал] │ Гистограммы │ +│ ───────────────────────── │ ────────────────────── │ ──────────────────── │ +│ ┌─ Фон ─────────────────┐ │ ┌────────────────────┐ │ Слотов: [5] [+] │ +│ │ Color Mean ────● 42 │ │ │ │ │ ┌────────────────┐ │ +│ │ Sigma Blur ──● 1.2 │ │ │ сгенерированное │ │ │ #1 (новейший)│ │ +│ └───────────────────────┘ │ │ изображение │ │ └────────────────┘ │ +│ ┌─ Аксон ───────────────┐ │ │ │ │ ┌────────────────┐ │ +│ │ ... │ │ └────────────────────┘ │ │ #2 │ │ +│ └───────────────────────┘ │ Слой: [──●──] 0/1 │ └────────────────┘ │ +│ │ Вид: [Оригинал ▼] │ ... │ +│ [Сгенерировать основу ] │ │ │ +│ [Перерисовать ] │ │ │ +│ [Сохранить ▾ ] │ │ │ +└─────────────────────────────┴──────────────────────────┴────────────────────────┘ +``` + +## Левая панель + +| Вкладка | Содержимое | +|---------|-----------| +| **Параметры** | Ползунки для всех ключей из JSON-конфига, сгруппированные по органеллам. Каждый ползунок имеет поле ввода для точного значения. | +| **Настройки** | Режим генерации (1 слой / датасет), количество органелл, размер изображения, папка и индекс датасета. | + +### Кнопки (вертикально, снизу левой панели) + +| Кнопка | Действие | +|--------|---------| +| **Сгенерировать основу** | Заглушка для ветки `generator_3d`. В ветке `main` выполняет полную генерацию слоя. | +| **Перерисовать** | Генерирует 1 слой (режим «Один слой») или полный датасет (режим «Датасет»). | +| **Сохранить ▾** | Раскрывает меню: «тот же файл» или «сохранить как…». | + +## Центральная панель + +| Вкладка | Содержимое | +|---------|-----------| +| **Визуализация** | Последнее сгенерированное изображение. Переключатель «Вид» позволяет показать любой из 7 слоёв: Оригинал, PSD, Аксон, Мембраны, Митохондрии, Границы мито, Везикулы. | +| **Журнал генерации** | Текстовый лог прогресса. Автоматически открывается при запуске генерации и возвращается к визуализации по завершении. | + +## Правая панель — Гистограммы + +- Кнопка **+** добавляет гистограмму текущего изображения в начало списка. +- Поле **Слотов** задаёт максимальное количество хранимых гистограмм (кольцевой буфер, по умолчанию 5). +- Список прокручивается; самая новая гистограмма всегда сверху. +- Требует `matplotlib`. + +## Панель инструментов + +| Элемент | Действие | +|---------|---------| +| **Открыть** | Загрузить другой JSON-конфиг; ползунки пересоздаются автоматически. | +| **☀ / 🌙** | Переключение светлой / тёмной темы. | + +## Добавление новых параметров + +Добавьте ключ в JSON-файл и откройте его через «Открыть» — ползунок появится автоматически. +Диапазоны определяются по суффиксу имени ключа: + +| Суффикс | Диапазон | +|---------|---------| +| `_color_mean` | 0 – 255 | +| `_color_std` | 0 – 60 | +| `_sigma` | 0.05 – 6.0 | +| `_radius` | 1 – 15 | +| `_noise_w` | 1 – 15 | + +--- + +## FAQ + +**Q: Приложение не запускается — ошибка `No module named 'PySide6'`** +A: Установите зависимости: `pip install PySide6 matplotlib scikit-image opencv-python` + +**Q: Параметры не появляются (левая панель пуста)** +A: Убедитесь, что запуск выполняется из корня проекта (`python UI/main_ui.py`), а файл `setting.json` существует рядом. Если открываете другой конфиг — убедитесь, что в нём есть числовые ключи. + +**Q: Кнопка «Перерисовать» не реагирует** +A: Скорее всего, генерация ещё выполняется (кнопка отключена). Дождитесь завершения или проверьте вкладку «Журнал генерации» на предмет ошибок. + +**Q: Ошибка `ModuleNotFoundError: No module named 'skimage'`** +A: `pip install scikit-image` + +**Q: Ошибка `ModuleNotFoundError: No module named 'cv2'`** +A: `pip install opencv-python` + +**Q: Гистограмма не строится** +A: Нужен matplotlib (`pip install matplotlib`) и хотя бы одно сгенерированное изображение. + +**Q: Что такое «Сгенерировать основу»?** +A: Кнопка-заглушка для будущей функции ветки `generator_3d`, которая будет создавать медленную 3D-структуру до раскраски. В текущей ветке `main` она запускает обычную генерацию и логирует пояснение. + +**Q: Как добавить свои параметры?** +A: Добавьте числовой ключ в `setting.json` или в `EPFL.json` и откройте файл через кнопку «Открыть» в тулбаре. Строковые ключи (`"comment1": "..."`) игнорируются автоматически. + +**Q: Изображение не обновляется после «Перерисовать»** +A: Это может быть кэш Qt. Попробуйте нажать ещё раз — URL изображения содержит счётчик, который принудительно сбрасывает кэш при каждой генерации. + +**Q: Можно ли запустить на Linux / macOS?** +A: Да, PySide6 кроссплатформенен. Убедитесь, что установлен `python3-pyside6` или эквивалент для вашей системы. diff --git a/UI/backend.py b/UI/backend.py new file mode 100644 index 0000000..3543c7d --- /dev/null +++ b/UI/backend.py @@ -0,0 +1,437 @@ +import sys +import os +import json +import copy +import numpy as np +from pathlib import Path + +from PySide6.QtCore import QObject, Signal, Slot, Property, QThread, QMutex +from PySide6.QtGui import QImage, QColor +from PySide6.QtQuick import QQuickImageProvider + +PROJECT_ROOT = str(Path(__file__).parent.parent) +if PROJECT_ROOT not in sys.path: + sys.path.insert(0, PROJECT_ROOT) + + +# ── parameter metadata ───────────────────────────────────────────────────────── + +def _param_meta(key: str, value) -> dict: + k = key.lower() + is_int = isinstance(value, int) and not isinstance(value, bool) + if any(x in k for x in ['color_mean', 'addcolor_mean', 'centerline_color_mean', 'spam_color_mean']): + return {'min': 0.0, 'max': 255.0, 'step': 1.0 if is_int else 0.5, 'decimals': 0 if is_int else 1} + if any(x in k for x in ['color_std', 'addcolor_std', 'centerline_color_std', 'spam_color_std']): + return {'min': 0.0, 'max': 60.0, 'step': 0.5, 'decimals': 1} + if 'color_diff_mean' in k: + return {'min': 0.0, 'max': 150.0, 'step': 0.5, 'decimals': 1} + if 'color_diff_std' in k: + return {'min': 0.0, 'max': 50.0, 'step': 0.5, 'decimals': 1} + if 'radius' in k: + return {'min': 1.0, 'max': 15.0, 'step': 1.0, 'decimals': 0} + if 'sigma' in k: + return {'min': 0.05, 'max': 6.0, 'step': 0.05, 'decimals': 2} + if 'gausse_noise_value' in k: + return {'min': 0.0, 'max': 100.0, 'step': 1.0, 'decimals': 0} + if 'noise_w' in k: + return {'min': 1.0, 'max': 15.0, 'step': 1.0, 'decimals': 0} + if 'space_thickness_max' in k: + return {'min': 0.0, 'max': 10.0, 'step': 0.5, 'decimals': 1} + if 'thickness' in k: + return {'min': 0.0, 'max': 10.0, 'step': 0.5, 'decimals': 1} + if 'border_w' in k: + return {'min': 0.0, 'max': 10.0, 'step': 0.5, 'decimals': 1} + if 'mit_len' in k: + return {'min': 0.0, 'max': 500.0, 'step': 1.0, 'decimals': 0} + if 'poisson_noise' in k: + return {'min': 0.0, 'max': 200.0, 'step': 1.0, 'decimals': 0} + if is_int: + return {'min': 0.0, 'max': float(max(255, value * 3)), 'step': 1.0, 'decimals': 0} + return {'min': 0.0, 'max': max(255.0, float(value) * 3 if value else 10.0), 'step': 0.5, 'decimals': 1} + + +_GROUP_ORDER = ['main','axon','membrane','mitohondrion','mit','psd','vesicles','spam','poisson','other'] +_GROUP_LABELS = { + 'main':'Фон', 'axon':'Аксон', 'membrane':'Мембраны', + 'mitohondrion':'Митохондрии', 'mit':'Митохондрии (длина)', + 'psd':'PSD', 'vesicles':'Везикулы', + 'spam':'Спам-компоненты', 'poisson':'Шум', 'other':'Прочее', +} + +def _get_group(key: str) -> str: + k = key.lower() + for prefix in ['mitohondrion','membrane','vesicles','axon','psd','main','spam']: + if k.startswith(prefix): + return prefix + if k.startswith('mit_'): return 'mit' + if 'poisson' in k: return 'poisson' + return 'other' + +def _fmt_label(key: str) -> str: + label = key + for p in ['main_','axon_','membrane_','mitohondrion_','mit_','psd_','vesicles_','spam_']: + if label.startswith(p): + label = label[len(p):] + break + return label.replace('_',' ').title() + +def build_param_groups(params: dict) -> list: + groups: dict[str, list] = {} + for key, value in params.items(): + g = _get_group(key) + if g not in groups: + groups[g] = [] + meta = _param_meta(key, value) + groups[g].append({'key': key, 'label': _fmt_label(key), 'value': float(value), **meta}) + return [{'name': _GROUP_LABELS.get(g, g.title()), 'params': groups[g]} + for g in _GROUP_ORDER if g in groups] + + +# ── mask constants (order matches DrawsLayerAndMask return tuple) ─────────────── + +MASK_PROVIDER_KEYS = ['layer','mask_psd','mask_axon','mask_mem','mask_mito','mask_mito_b','mask_ves'] +MASK_DISPLAY_LABELS = ['Оригинал','PSD','Аксон','Мембраны','Митохондрии','Границы мито','Везикулы'] + + +# ── image provider ───────────────────────────────────────────────────────────── + +class SyntheticsImageProvider(QQuickImageProvider): + def __init__(self): + super().__init__(QQuickImageProvider.ImageType.Pixmap) + self._images: dict[str, object] = {} + self._mutex = QMutex() + + def requestPixmap(self, image_id: str, size, requestedSize): + from PySide6.QtGui import QPixmap + key = image_id.split('/')[0] + self._mutex.lock() + img = self._images.get(key) + self._mutex.unlock() + if img is None: + pix = QPixmap(512, 512) + pix.fill(QColor(40, 40, 50)) + return pix + return img + + def set_array(self, key: str, arr): + if arr is None: + return + from PySide6.QtGui import QPixmap + arr = np.ascontiguousarray(arr) + if arr.ndim == 3 and arr.shape[2] >= 3: + h, w = arr.shape[:2] + qimg = QImage(arr.data, w, h, w * 3, QImage.Format.Format_RGB888) + elif arr.ndim == 2: + h, w = arr.shape + u8 = arr.astype(np.uint8) + qimg = QImage(u8.data, w, h, w, QImage.Format.Format_Grayscale8) + else: + return + pix = QPixmap.fromImage(qimg.copy()) + self._mutex.lock() + self._images[key] = pix + self._mutex.unlock() + + def set_png_bytes(self, key: str, data: bytes): + from PySide6.QtGui import QPixmap + pix = QPixmap() + pix.loadFromData(data) + self._mutex.lock() + self._images[key] = pix + self._mutex.unlock() + + +# ── stdout/stderr → logMessage forwarder ─────────────────────────────────────── + +class _StreamForwarder: + """Line-buffered wrapper that forwards print() output to a Qt signal.""" + def __init__(self, emit_fn): + self._emit = emit_fn + self._buf = "" + + def write(self, text: str): + self._buf += text + while '\n' in self._buf: + line, self._buf = self._buf.split('\n', 1) + if line: # skip blank lines + self._emit(line) + + def flush(self): + if self._buf.strip(): + self._emit(self._buf) + self._buf = "" + + # make it look like a real file so cv2/numpy don't complain + fileno = lambda self: -1 + isatty = lambda self: False + readable = lambda self: False + writable = lambda self: True + + +# ── worker thread ────────────────────────────────────────────────────────────── + +class GenerationWorker(QThread): + # all 7 outputs: layer + 6 masks + finished = Signal(object, object, object, object, object, object, object) + progress = Signal(int, int) + logMessage = Signal(str) + error = Signal(str) + + def __init__(self, mode: str, params: dict, gen_params: dict): + super().__init__() + self._mode = mode + self._params = params + self._gen_params = gen_params + + def run(self): + import contextlib + log = self.logMessage.emit + forwarder = _StreamForwarder(log) + with contextlib.redirect_stdout(forwarder), contextlib.redirect_stderr(forwarder): + self._run_inner() + + def _run_inner(self): + log = self.logMessage.emit + try: + log("▶ Установка параметров...") + import settings + settings.PARAM.update(self._params) + + log("▶ Загрузка генератора...") + from src.container.main_field import Form + + sz = self._gen_params.get('size', 256) + gp = self._gen_params + form = Form((sz, sz)) + + if self._mode == 'single': + log("▶ Генерация органелл...") + lst = form.createListGeneration( + gp.get('count_psd', 3), gp.get('count_axon', 1), + gp.get('count_vesicles', 3), gp.get('count_mito', 3), + gp.get('count_spam', 5)) + log(f"▶ Отрисовка {sz}×{sz}...") + results = form.DrawsLayerAndMask(lst) + log(f"✓ Готово {sz}×{sz} px") + self.finished.emit(*results) + + else: + from src.container.output import SaveGeneration + n = gp.get('count', 10) + dir_save = gp.get('dir_save', 'dataset/ui_dataset') + start_i = gp.get('start_index', 0) + results = None + # output.py uses os.mkdir (one level only) — pre-create tree + os.makedirs(dir_save, exist_ok=True) + for i in range(n): + self.progress.emit(i + 1, n) + log(f"▶ Кадр {i + 1}/{n}...") + import settings as s + color = s.normal_randint(s.PARAM['main_color_mean'], s.PARAM['main_color_std']) + form.backgroundСolor = (color, color, color) + lst = form.createListGeneration( + gp.get('count_psd', 3), gp.get('count_axon', 1), + gp.get('count_vesicles', 3), gp.get('count_mito', 3), + gp.get('count_spam', 5)) + results = form.DrawsLayerAndMask(lst) + SaveGeneration(*results, i, dir_save, start_i) + log(f"✓ Датасет ({n} кадров) → {dir_save}") + self.finished.emit(*results) + + except Exception: + import traceback + self.error.emit(traceback.format_exc()) + + +# ── backend QObject ──────────────────────────────────────────────────────────── + +class Backend(QObject): + paramsChanged = Signal(str) + imageReady = Signal(str) + generationProgress = Signal(int, int) + generationDone = Signal() + generationBusy = Signal(bool) + errorOccurred = Signal(str) + configPathChanged = Signal(str) + modeChanged = Signal(str) + logMessage = Signal(str) + histListChanged = Signal(str) # JSON list of provider keys, newest first + + def __init__(self, provider: SyntheticsImageProvider, parent=None): + super().__init__(parent) + self._provider = provider + self._params: dict = {} + self._config_path = '' + self._mode = 'single' + self._gen_params = { + 'count_psd': 3, 'count_axon': 1, 'count_vesicles': 3, + 'count_mito': 3, 'count_spam': 5, + 'size': 256, 'count': 10, + 'dir_save': 'dataset/ui_dataset', 'start_index': 0, + } + self._worker = None + self._counter = 0 + self._last_layer = None + self._hist_keys: list[str] = [] + self._max_hists = 5 + + # ── config ───────────────────────────────────────────────────────────────── + + @Slot(str) + def loadConfig(self, path: str): + path = _fix_path(path) + try: + with open(path, 'r', encoding='utf-8') as f: + raw = json.load(f) + # strip comment keys (non-numeric values) + self._params = {k: v for k, v in raw.items() if isinstance(v, (int, float))} + self._config_path = path + self.configPathChanged.emit(os.path.basename(path)) + self.paramsChanged.emit(json.dumps(build_param_groups(self._params), ensure_ascii=False)) + except Exception as e: + self.errorOccurred.emit(f"Ошибка загрузки конфига: {e}") + + @Slot(str) + def saveConfig(self, path: str): + path = _fix_path(path) + try: + with open(path, 'w', encoding='utf-8') as f: + json.dump(self._params, f, indent=4, ensure_ascii=False) + except Exception as e: + self.errorOccurred.emit(f"Ошибка сохранения: {e}") + + @Slot() + def saveConfigInPlace(self): + if self._config_path: + self.saveConfig(self._config_path) + + # ── params ───────────────────────────────────────────────────────────────── + + @Slot(str, float) + def updateParam(self, key: str, value: float): + if key in self._params: + orig = self._params[key] + self._params[key] = int(round(value)) if isinstance(orig, int) and not isinstance(orig, bool) else value + + # ── generation ───────────────────────────────────────────────────────────── + + @Slot() + def generateStructure(self): + """Stub for generator_3d. On main branch runs normal generation.""" + self.logMessage.emit("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + self.logMessage.emit("ℹ «Сгенерировать основу» — функция ветки generator_3d.") + self.logMessage.emit(" В ветке main выполняется полная генерация слоя.") + self.logMessage.emit("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + self.redraw() + + @Slot() + def redraw(self): + if self._worker and self._worker.isRunning(): + return + if not self._params: + self.errorOccurred.emit("Сначала загрузите конфиг-файл") + return + self.generationBusy.emit(True) + self._worker = GenerationWorker( + self._mode, copy.deepcopy(self._params), self._gen_params.copy()) + self._worker.finished.connect(self._on_done) + self._worker.error.connect(self._on_error) + self._worker.progress.connect(self.generationProgress) + self._worker.logMessage.connect(self.logMessage) + self._worker.start() + + def _on_done(self, layer, mask_psd, mask_axon, mask_mem, mask_mito, mask_mito_b, mask_ves): + self._last_layer = layer + self._counter += 1 + def _g(m): # 3-ch mask → 2D grayscale + return np.ascontiguousarray(m[:, :, 0]) if m is not None and m.ndim == 3 else m + self._provider.set_array('layer', layer) + self._provider.set_array('mask_psd', _g(mask_psd)) + self._provider.set_array('mask_axon', _g(mask_axon)) + self._provider.set_array('mask_mem', _g(mask_mem)) + self._provider.set_array('mask_mito', _g(mask_mito)) + self._provider.set_array('mask_mito_b',_g(mask_mito_b)) + self._provider.set_array('mask_ves', _g(mask_ves)) + self.imageReady.emit(str(self._counter)) + self.generationDone.emit() + self.generationBusy.emit(False) + + def _on_error(self, msg: str): + self.errorOccurred.emit(msg) + self.generationBusy.emit(False) + + # ── histograms ───────────────────────────────────────────────────────────── + + @Slot() + def buildHistograms(self): + if self._last_layer is None: + self.errorOccurred.emit("Сначала сгенерируйте изображение") + return + try: + import matplotlib + matplotlib.use('Agg') + import matplotlib.pyplot as plt + import io + + gray = self._last_layer[:,:,0] if self._last_layer.ndim == 3 else self._last_layer + n = len(self._hist_keys) + 1 + + fig, ax = plt.subplots(figsize=(5.8, 2.6), facecolor='#1e1e2e') + ax.set_facecolor('#2a2a3e') + ax.hist(gray.flatten(), bins=128, color='#7aa2f7', alpha=0.88, edgecolor='none') + ax.set_xlabel('Интенсивность', color='#c0caf5', fontsize=7) + ax.set_ylabel('Пиксели', color='#c0caf5', fontsize=7) + ax.tick_params(colors='#a9b1d6', labelsize=6) + for sp in ax.spines.values(): sp.set_color('#3b3b5e') + ax.set_title(f'#{n}', color='#9aa5ce', fontsize=8, loc='right') + plt.tight_layout(pad=0.4) + + buf = io.BytesIO() + plt.savefig(buf, format='png', dpi=100, bbox_inches='tight') + plt.close(fig) + buf.seek(0) + + self._counter += 1 + key = f'hist_{self._counter}' + self._provider.set_png_bytes(key, buf.read()) + self._hist_keys.insert(0, key) + self._hist_keys = self._hist_keys[:self._max_hists] + self.histListChanged.emit(json.dumps(self._hist_keys)) + + except ImportError: + self.errorOccurred.emit("Установите matplotlib: pip install matplotlib") + except Exception: + import traceback + self.errorOccurred.emit(traceback.format_exc()) + + @Slot(int) + def setMaxHistograms(self, n: int): + self._max_hists = max(1, n) + self._hist_keys = self._hist_keys[:self._max_hists] + self.histListChanged.emit(json.dumps(self._hist_keys)) + + # ── generation settings ──────────────────────────────────────────────────── + + @Slot(str) + def setMode(self, mode: str): + self._mode = mode + self.modeChanged.emit(mode) + + @Slot(str, 'QVariant') + def setGenParam(self, key: str, value): + self._gen_params[key] = value + + @Property(str, notify=configPathChanged) + def configPath(self): + return self._config_path + + @Property(str, notify=modeChanged) + def mode(self): + return self._mode + + +def _fix_path(path: str) -> str: + path = path.replace('file:///', '').replace('file://', '') + if os.name == 'nt' and path.startswith('/') and len(path) > 2 and path[2] == ':': + path = path[1:] + return path diff --git a/UI/main_ui.py b/UI/main_ui.py new file mode 100644 index 0000000..e4de63c --- /dev/null +++ b/UI/main_ui.py @@ -0,0 +1,50 @@ +import sys +import os +from pathlib import Path + +SCRIPT_DIR = Path(__file__).parent +PROJECT_ROOT = SCRIPT_DIR.parent + +# Must be set before other Qt imports +os.chdir(str(PROJECT_ROOT)) +sys.path.insert(0, str(PROJECT_ROOT)) + +from PySide6.QtGui import QGuiApplication, QIcon +from PySide6.QtQml import QQmlApplicationEngine +from PySide6.QtCore import QUrl +from PySide6.QtQuickControls2 import QQuickStyle + +from backend import Backend, SyntheticsImageProvider + + +def main(): + QQuickStyle.setStyle("Material") + + app = QGuiApplication(sys.argv) + app.setApplicationName("Synthetics Generator UI") + app.setOrganizationName("GraphLabEM") + + provider = SyntheticsImageProvider() + backend = Backend(provider) + + engine = QQmlApplicationEngine() + engine.addImageProvider("synthetics", provider) + engine.rootContext().setContextProperty("backend", backend) + + qml_file = str(SCRIPT_DIR / "qml" / "main.qml") + engine.load(QUrl.fromLocalFile(qml_file)) + + if not engine.rootObjects(): + sys.exit(-1) + + # Load config AFTER QML is ready so ParameterPanel's Connections can catch the signal + from PySide6.QtCore import QTimer + default_config = str(PROJECT_ROOT / "setting.json") + if os.path.exists(default_config): + QTimer.singleShot(100, lambda: backend.loadConfig(default_config)) + + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() diff --git a/UI/qml/GenerationPanel.qml b/UI/qml/GenerationPanel.qml new file mode 100644 index 0000000..8380b00 --- /dev/null +++ b/UI/qml/GenerationPanel.qml @@ -0,0 +1,270 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Controls.Material +import QtQuick.Layouts + +Item { + id: root + + ScrollView { + anchors.fill: parent + contentWidth: availableWidth + clip: true + + ColumnLayout { + width: parent.width + spacing: 0 + + // ── mode ────────────────────────────────────────────────────────── + SectionHeader { title: "Режим генерации" } + + ColumnLayout { + Layout.fillWidth: true + Layout.margins: 12 + spacing: 4 + + RadioButton { + id: rbSingle + text: "Один слой (быстрый превью)" + checked: true + font.pixelSize: 12 + onCheckedChanged: if (checked) backend.setMode("single") + } + RadioButton { + id: rbDataset + text: "Полный датасет" + font.pixelSize: 12 + onCheckedChanged: if (checked) backend.setMode("dataset") + } + } + + // ── organelle counts ─────────────────────────────────────────────── + SectionHeader { title: "Количество органелл" } + + Column { + Layout.fillWidth: true + Layout.leftMargin: 12 + Layout.rightMargin: 12 + Layout.topMargin: 6 + Layout.bottomMargin: 6 + spacing: 6 + + CountRow { label: "PSD"; genKey: "count_psd"; defVal: 3; maxVal: 10 } + CountRow { label: "Аксон"; genKey: "count_axon"; defVal: 1; maxVal: 5 } + CountRow { label: "Везикулы"; genKey: "count_vesicles"; defVal: 3; maxVal: 10 } + CountRow { label: "Митохондрии"; genKey: "count_mito"; defVal: 3; maxVal: 10 } + CountRow { label: "Спам"; genKey: "count_spam"; defVal: 5; maxVal: 20 } + } + + // ── image size ───────────────────────────────────────────────────── + SectionHeader { title: "Размер изображения" } + + RowLayout { + Layout.fillWidth: true + Layout.margins: 12 + spacing: 8 + + Label { text: "Размер (px):"; font.pixelSize: 12; opacity: 0.87 } + + ComboBox { + model: [128, 256, 512] + currentIndex: 1 + font.pixelSize: 12 + implicitWidth: 100 + onCurrentValueChanged: backend.setGenParam("size", currentValue) + } + } + + // ── dataset settings ─────────────────────────────────────────────── + SectionHeader { + title: "Настройки датасета" + visible: rbDataset.checked + } + + ColumnLayout { + visible: rbDataset.checked + Layout.fillWidth: true + Layout.margins: 12 + spacing: 8 + + RowLayout { + Layout.fillWidth: true + spacing: 8 + Label { + text: "Кол-во кадров:" + font.pixelSize: 12; opacity: 0.87 + Layout.preferredWidth: 140 + } + SpinBox { + from: 1; to: 10000; value: 10 + font.pixelSize: 12 + Layout.preferredWidth: 130 + onValueChanged: backend.setGenParam("count", value) + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + Label { + text: "Стартовый индекс:" + font.pixelSize: 12; opacity: 0.87 + Layout.preferredWidth: 140 + } + SpinBox { + from: 0; to: 100000; value: 0 + font.pixelSize: 12 + Layout.preferredWidth: 130 + onValueChanged: backend.setGenParam("start_index", value) + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + Label { + text: "Папка сохранения:" + font.pixelSize: 12; opacity: 0.87 + Layout.preferredWidth: 140 + } + TextField { + Layout.fillWidth: true + text: "dataset/ui_dataset" + font.pixelSize: 11 + padding: 4 + onTextChanged: backend.setGenParam("dir_save", text) + } + } + } + + Item { height: 16; Layout.fillWidth: true } + } + } + + // ── CountRow: label + [−] value [+] in one line ────────────────────────── + component CountRow: RowLayout { + id: crow + property string label: "" + property string genKey: "" + property int defVal: 1 + property int maxVal: 10 + property int val: defVal + + width: parent ? parent.width : 300 + spacing: 0 + + Label { + text: crow.label + font.pixelSize: 12 + opacity: 0.87 + Layout.fillWidth: true + elide: Text.ElideRight + } + + // [−] button + RoundButton { + implicitWidth: 30; implicitHeight: 30 + radius: 4 + text: "−" + font.pixelSize: 16 + flat: true + enabled: crow.val > 0 + onClicked: { + crow.val = Math.max(0, crow.val - 1) + backend.setGenParam(crow.genKey, crow.val) + } + contentItem: Label { + text: parent.text + font: parent.font + color: parent.enabled + ? (Material.theme === Material.Dark ? "#e0e0ff" : "#1a1a2e") + : (Material.theme === Material.Dark ? "#44445e" : "#bbb") + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + radius: 4 + color: parent.hovered + ? (Material.theme === Material.Dark ? Qt.rgba(1,1,1,0.10) : Qt.rgba(0,0,0,0.06)) + : "transparent" + border.color: Material.theme === Material.Dark ? "#3a3a5a" : "#c0c0d8" + border.width: 1 + } + } + + // value label + Rectangle { + implicitWidth: 44; implicitHeight: 30 + radius: 4 + color: Material.theme === Material.Dark ? "#22223a" : "#f0f0f8" + border.color: Material.theme === Material.Dark ? "#3a3a5a" : "#c0c0d8" + border.width: 1 + + Label { + anchors.centerIn: parent + text: crow.val + font.pixelSize: 13 + font.weight: Font.Medium + color: Material.theme === Material.Dark ? "#e0e0ff" : "#1a1a2e" + } + } + + // [+] button + RoundButton { + implicitWidth: 30; implicitHeight: 30 + radius: 4 + text: "+" + font.pixelSize: 16 + flat: true + enabled: crow.val < crow.maxVal + onClicked: { + crow.val = Math.min(crow.maxVal, crow.val + 1) + backend.setGenParam(crow.genKey, crow.val) + } + contentItem: Label { + text: parent.text + font: parent.font + color: parent.enabled + ? (Material.theme === Material.Dark ? "#e0e0ff" : "#1a1a2e") + : (Material.theme === Material.Dark ? "#44445e" : "#bbb") + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + radius: 4 + color: parent.hovered + ? (Material.theme === Material.Dark ? Qt.rgba(1,1,1,0.10) : Qt.rgba(0,0,0,0.06)) + : "transparent" + border.color: Material.theme === Material.Dark ? "#3a3a5a" : "#c0c0d8" + border.width: 1 + } + } + } + + // ── section header ──────────────────────────────────────────────────────── + component SectionHeader: Rectangle { + property string title: "" + Layout.fillWidth: true + height: 30 + color: Material.theme === Material.Dark ? "#2d2d4a" : "#e8eaf6" + + Label { + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: 12 + text: parent.title + font.pixelSize: 11 + font.weight: Font.Medium + font.letterSpacing: 0.8 + opacity: 0.72 + color: Material.theme === Material.Dark ? "#7b8cde" : "#3949ab" + } + + Rectangle { + anchors.bottom: parent.bottom + width: parent.width + height: 1 + color: Material.theme === Material.Dark ? "#3a3a58" : "#c5cae9" + } + } +} diff --git a/UI/qml/HistSlot.qml b/UI/qml/HistSlot.qml new file mode 100644 index 0000000..dd2aa73 --- /dev/null +++ b/UI/qml/HistSlot.qml @@ -0,0 +1,67 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Controls.Material +import QtQuick.Layouts + +Rectangle { + id: root + + property int slotIndex: 0 + property string providerKey: "" + property bool darkMode: true + + height: providerKey !== "" ? 190 : 52 + color: "transparent" + + // separator + Rectangle { + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + height: 1 + color: root.darkMode ? "#25253a" : "#d8d8ec" + } + + // ── filled slot: histogram image ───────────────────────────────────── + ColumnLayout { + visible: root.providerKey !== "" + anchors.fill: parent + anchors.margins: 6 + spacing: 4 + + // slot label + Label { + text: "#" + (root.slotIndex + 1) + font.pixelSize: 9 + opacity: 0.45 + color: root.darkMode ? "white" : "black" + } + + Image { + Layout.fillWidth: true + Layout.fillHeight: true + fillMode: Image.PreserveAspectFit + smooth: true + asynchronous: true + cache: false + // providerKey doubles as cache-buster suffix after "/" + source: root.providerKey !== "" + ? "image://synthetics/" + root.providerKey + "/" + root.providerKey + : "" + } + } + + // ── empty slot ──────────────────────────────────────────────────────── + Item { + visible: root.providerKey === "" + anchors.fill: parent + + Label { + anchors.centerIn: parent + text: "#" + (root.slotIndex + 1) + " / пусто" + font.pixelSize: 10 + opacity: 0.25 + color: root.darkMode ? "white" : "black" + } + } +} diff --git a/UI/qml/HistogramPanel.qml b/UI/qml/HistogramPanel.qml new file mode 100644 index 0000000..090aa4c --- /dev/null +++ b/UI/qml/HistogramPanel.qml @@ -0,0 +1,122 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Controls.Material +import QtQuick.Layouts + +Rectangle { + id: root + + property bool darkMode: true + property int maxHists: 5 + property var histKeys: [] // list of provider keys, newest first + + color: darkMode ? "#1a1a30" : "#f0f0f8" + + Connections { + target: backend + function onHistListChanged(jsonStr) { + root.histKeys = JSON.parse(jsonStr) + } + } + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + // ── header ──────────────────────────────────────────────────────── + Rectangle { + Layout.fillWidth: true + height: 40 + color: root.darkMode ? "#16162a" : "#e8e8f8" + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 8 + spacing: 6 + + Label { + text: "Гистограммы" + font.pixelSize: 12 + font.weight: Font.Medium + opacity: 0.9 + Layout.fillWidth: true + } + + Label { + text: "Слотов:" + font.pixelSize: 11 + opacity: 0.6 + } + + SpinBox { + id: slotSpin + from: 1; to: 20; value: root.maxHists + implicitWidth: 72 + implicitHeight: 28 + font.pixelSize: 11 + onValueChanged: { + root.maxHists = value + backend.setMaxHistograms(value) + } + } + + // "Добавить" button — append newest histogram + Button { + text: "+" + implicitWidth: 28 + implicitHeight: 28 + font.pixelSize: 14 + Material.background: Material.Blue + Material.foreground: "white" + ToolTip.visible: hovered + ToolTip.text: "Добавить гистограмму текущего слоя" + onClicked: backend.buildHistograms() + } + } + } + + Rectangle { + Layout.fillWidth: true + height: 1 + color: root.darkMode ? "#2a2a48" : "#d0d0e8" + } + + // ── histogram list ──────────────────────────────────────────────── + ScrollView { + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + + Column { + id: histColumn + width: root.width + spacing: 0 + + Repeater { + model: root.maxHists + delegate: HistSlot { + width: root.width + slotIndex: index + providerKey: index < root.histKeys.length ? root.histKeys[index] : "" + darkMode: root.darkMode + } + } + + // empty-state hint + Label { + visible: root.histKeys.length === 0 + width: root.width + height: 80 + text: "Нажмите «+» чтобы\nдобавить гистограмму" + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + font.pixelSize: 11 + opacity: 0.35 + color: root.darkMode ? "white" : "black" + wrapMode: Text.WordWrap + } + } + } + } +} diff --git a/UI/qml/HistogramView.qml b/UI/qml/HistogramView.qml new file mode 100644 index 0000000..4e0f063 --- /dev/null +++ b/UI/qml/HistogramView.qml @@ -0,0 +1,62 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Controls.Material +import QtQuick.Layouts + +Item { + id: root + + property string histCounter: "0" + + Connections { + target: backend + function onHistogramReady(counter) { root.histCounter = counter } + } + + ColumnLayout { + anchors.fill: parent + spacing: 8 + + // ── histogram image ───────────────────────────────────────────────── + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + color: Material.theme === Material.Dark ? "#12121f" : "#f5f5f5" + radius: 6 + + Image { + id: histImg + anchors.fill: parent + anchors.margins: 4 + fillMode: Image.PreserveAspectFit + smooth: true + asynchronous: true + cache: false + + source: root.histCounter === "0" + ? "" + : "image://synthetics/histogram/" + root.histCounter + + Label { + visible: histImg.status !== Image.Ready + anchors.centerIn: parent + text: "Нажмите «Построить гистограммы»" + opacity: 0.35 + font.pixelSize: 13 + color: Material.theme === Material.Dark ? "white" : "#333" + horizontalAlignment: Text.AlignHCenter + } + } + } + + // ── build button ──────────────────────────────────────────────────── + Button { + Layout.alignment: Qt.AlignHCenter + text: "Построить гистограммы" + Material.background: Material.Blue + Material.foreground: "white" + font.pixelSize: 12 + onClicked: backend.buildHistograms() + } + } +} diff --git a/UI/qml/ParameterPanel.qml b/UI/qml/ParameterPanel.qml new file mode 100644 index 0000000..5805930 --- /dev/null +++ b/UI/qml/ParameterPanel.qml @@ -0,0 +1,114 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Controls.Material +import QtQuick.Layouts + +// Scrollable panel with auto-generated slider groups from JSON config +Item { + id: root + + property var groups: [] // parsed from backend.paramsChanged JSON + + Connections { + target: backend + function onParamsChanged(jsonStr) { + root.groups = JSON.parse(jsonStr) + } + } + + ScrollView { + anchors.fill: parent + contentWidth: availableWidth + clip: true + + Column { + id: content + width: parent.width + spacing: 0 + + Repeater { + model: root.groups + + delegate: Column { + id: groupCol + width: content.width + spacing: 0 + + property var groupData: modelData + + // ── group header ──────────────────────────────────────── + Rectangle { + width: groupCol.width + height: 32 + color: Material.theme === Material.Dark ? "#2d2d4a" : "#e8eaf6" + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 8 + + Label { + text: groupData.name + font.pixelSize: 11 + font.weight: Font.Medium + font.letterSpacing: 0.8 + opacity: 0.72 + color: Material.theme === Material.Dark ? "#7b8cde" : "#3949ab" + Layout.fillWidth: true + } + + Label { + text: groupData.params.length + " param" + font.pixelSize: 9 + opacity: 0.45 + } + } + + Rectangle { + anchors.bottom: parent.bottom + width: parent.width + height: 1 + color: Material.theme === Material.Dark ? "#3a3a58" : "#c5cae9" + } + } + + // ── sliders ───────────────────────────────────────────── + Repeater { + model: groupData.params + + delegate: Column { + width: groupCol.width + spacing: 0 + + SliderRow { + id: slRow + width: groupCol.width + paramKey: modelData.key + label: modelData.label + value: modelData.value + minVal: modelData.min + maxVal: modelData.max + step: modelData.step + decimals: modelData.decimals + + onValueEdited: (key, val) => backend.updateParam(key, val) + } + + Rectangle { + width: groupCol.width + height: 1 + color: Material.theme === Material.Dark ? "#28283e" : "#eeeeee" + } + } + } + + // spacing between groups + Item { width: 1; height: 4 } + } + } + + // bottom padding + Item { width: 1; height: 16 } + } + } +} diff --git a/UI/qml/SliderRow.qml b/UI/qml/SliderRow.qml new file mode 100644 index 0000000..364371c --- /dev/null +++ b/UI/qml/SliderRow.qml @@ -0,0 +1,129 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Controls.Material +import QtQuick.Layouts + +// Single parameter row: [Label] [Slider ─────●─────] [TextField] +Item { + id: root + + property string paramKey: "" + property string label: "" + property real value: 0 + property real minVal: 0 + property real maxVal: 255 + property real step: 1 + property int decimals: 1 + + signal valueEdited(string key, real newValue) + + implicitHeight: 36 + implicitWidth: parent ? parent.width : 400 + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 8 + anchors.rightMargin: 8 + spacing: 6 + + Label { + id: lbl + text: root.label + Layout.preferredWidth: 160 + Layout.minimumWidth: 110 + elide: Text.ElideRight + font.pixelSize: 11 + opacity: 0.87 + + MouseArea { id: lblHover; anchors.fill: parent; hoverEnabled: true } + ToolTip.visible: lblHover.containsMouse && lbl.truncated + ToolTip.text: root.label + ToolTip.delay: 600 + } + + Slider { + id: slider + Layout.fillWidth: true + from: root.minVal + to: root.maxVal + stepSize: root.step + // declarative binding — only changed by root.value externally + value: root.value + + onMoved: { + root.value = value + root.valueEdited(root.paramKey, value) + } + + background: Rectangle { + x: slider.leftPadding + y: slider.topPadding + slider.availableHeight / 2 - height / 2 + implicitWidth: 200; implicitHeight: 4 + width: slider.availableWidth; height: implicitHeight + radius: 2 + color: Material.theme === Material.Dark ? "#3d3d5c" : "#c0c0d0" + + Rectangle { + width: slider.visualPosition * parent.width + height: parent.height + color: Material.accent + radius: 2 + } + } + + handle: Rectangle { + x: slider.leftPadding + slider.visualPosition * (slider.availableWidth - width) + y: slider.topPadding + slider.availableHeight / 2 - height / 2 + implicitWidth: 16; implicitHeight: 16 + radius: 8 + color: slider.pressed ? Qt.lighter(Material.accent, 1.2) : Material.accent + border.color: Qt.darker(Material.accent, 1.3) + border.width: 1 + } + } + + TextField { + id: field + Layout.preferredWidth: 64 + // only update text when not being edited + text: root.value.toFixed(root.decimals) + font.pixelSize: 11 + horizontalAlignment: TextInput.AlignRight + padding: 3; leftPadding: 5; rightPadding: 5 + + background: Rectangle { + radius: 4 + color: Material.theme === Material.Dark ? "#2a2a3e" : "#f5f5f5" + border.color: field.activeFocus + ? Material.accent + : (Material.theme === Material.Dark ? "#44445e" : "#c0c0c0") + border.width: field.activeFocus ? 2 : 1 + } + + validator: DoubleValidator { + bottom: root.minVal; top: root.maxVal + notation: DoubleValidator.StandardNotation + } + + onEditingFinished: { + var v = parseFloat(text) + if (!isNaN(v)) { + v = Math.max(root.minVal, Math.min(root.maxVal, v)) + if (root.step > 0) + v = Math.round(v / root.step) * root.step + // only set root.value; the slider binding 'value: root.value' handles sync + root.value = v + text = v.toFixed(root.decimals) + root.valueEdited(root.paramKey, v) + } + } + } + } + + // Sync field text when root.value is changed externally (not via this component) + // Do NOT set slider.value here — it would break the declarative binding above + onValueChanged: { + if (!field.activeFocus) + field.text = root.value.toFixed(root.decimals) + } +} diff --git a/UI/qml/Snackbar.qml b/UI/qml/Snackbar.qml new file mode 100644 index 0000000..2275102 --- /dev/null +++ b/UI/qml/Snackbar.qml @@ -0,0 +1,70 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Controls.Material +import QtQuick.Layouts + +// Bottom-anchored error/info snackbar (Material Design) +Item { + id: root + anchors.fill: parent + anchors.bottomMargin: 16 + + function show(message, durationMs) { + msgLabel.text = message + bar.opacity = 1 + hideTimer.interval = durationMs || 5000 + hideTimer.restart() + } + + Timer { + id: hideTimer + onTriggered: bar.opacity = 0 + } + + Rectangle { + id: bar + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + width: Math.min(parent.width - 32, msgLabel.implicitWidth + 48) + height: 44 + radius: 6 + color: "#323248" + opacity: 0 + z: 999 + + Behavior on opacity { NumberAnimation { duration: 200 } } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 16 + anchors.rightMargin: 8 + spacing: 8 + + Label { + id: msgLabel + Layout.fillWidth: true + font.pixelSize: 12 + color: "#e0e0ff" + wrapMode: Text.WordWrap + maximumLineCount: 3 + elide: Text.ElideRight + } + + ToolButton { + text: "✕" + implicitWidth: 32 + implicitHeight: 32 + font.pixelSize: 11 + contentItem: Label { + text: parent.text + color: "#a0a0cc" + font: parent.font + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + background: Item {} + onClicked: { hideTimer.stop(); bar.opacity = 0 } + } + } + } +} diff --git a/UI/qml/Visualizer.qml b/UI/qml/Visualizer.qml new file mode 100644 index 0000000..42587ed --- /dev/null +++ b/UI/qml/Visualizer.qml @@ -0,0 +1,178 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Controls.Material +import QtQuick.Layouts + +Item { + id: root + + property string imageCounter: "0" + property int maskIndex: 0 // 0=Оригинал … 6=Везикулы + property bool busy: false + + // Provider key for the selected mask/layer + readonly property var maskKeys: [ + "layer", "mask_psd", "mask_axon", "mask_mem", + "mask_mito", "mask_mito_b", "mask_ves" + ] + property string currentKey: root.maskKeys[root.maskIndex] ?? "layer" + + Connections { + target: backend + function onImageReady(counter) { root.imageCounter = counter } + function onGenerationBusy(b) { root.busy = b } + } + + ColumnLayout { + anchors.fill: parent + spacing: 8 + + // ── image area ──────────────────────────────────────────────────── + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + color: Material.theme === Material.Dark ? "#12121f" : "#1a1a2e" + radius: 6 + clip: true + + Image { + id: img + anchors.fill: parent + anchors.margins: 4 + fillMode: Image.PreserveAspectFit + smooth: true + asynchronous: true + cache: false + + source: root.imageCounter === "0" + ? "" + : "image://synthetics/" + root.currentKey + "/" + root.imageCounter + + Label { + visible: img.status !== Image.Ready && !root.busy + anchors.centerIn: parent + text: "Нажмите «Перерисовать»\nдля генерации изображения" + horizontalAlignment: Text.AlignHCenter + opacity: 0.35 + font.pixelSize: 13 + color: "white" + } + + BusyIndicator { + visible: root.busy + anchors.centerIn: parent + running: root.busy + Material.accent: Material.Blue + } + } + + // resolution badge + Label { + visible: img.status === Image.Ready + anchors.bottom: parent.bottom + anchors.right: parent.right + anchors.margins: 6 + text: img.sourceSize.width + "×" + img.sourceSize.height + font.pixelSize: 9 + opacity: 0.45 + color: "white" + } + } + + // ── controls row ────────────────────────────────────────────────── + RowLayout { + Layout.fillWidth: true + Layout.leftMargin: 4 + Layout.rightMargin: 4 + spacing: 8 + + // Layer slider — only shown for 3D stacks (to > 0). + // Current generator produces 2D images, so this stays hidden. + Label { + visible: layerSlider.to > 0 + text: "Слой:" + font.pixelSize: 11 + opacity: 0.75 + } + + Slider { + id: layerSlider + visible: to > 0 + Layout.fillWidth: true + from: 0; to: 0; stepSize: 1 + ToolTip.visible: pressed + ToolTip.text: "Слой " + Math.round(value) + " / " + (to + 1) + } + + Label { + visible: layerSlider.to > 0 + text: Math.round(layerSlider.value) + " / " + (layerSlider.to + 1) + font.pixelSize: 11 + opacity: 0.75 + Layout.preferredWidth: 46 + } + + // mask selector ComboBox — 7 options + Label { + text: "Вид:" + font.pixelSize: 11 + opacity: 0.75 + } + + ComboBox { + id: maskCombo + font.pixelSize: 11 + implicitHeight: 32 + model: ["Оригинал", "PSD", "Аксон", "Мембраны", + "Митохондрии", "Границы мито", "Везикулы"] + onCurrentIndexChanged: root.maskIndex = currentIndex + + background: Rectangle { + color: Material.theme === Material.Dark + ? Qt.rgba(1,1,1,0.08) : Qt.rgba(0,0,0,0.06) + radius: 4 + border.color: Qt.rgba(1,1,1,0.15) + border.width: 1 + } + + contentItem: Label { + leftPadding: 8 + rightPadding: maskCombo.indicator.width + 4 + text: maskCombo.displayText + font: maskCombo.font + color: Material.foreground + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + } + } + + // ── dataset progress bar ────────────────────────────────────────── + RowLayout { + id: progressRow + Layout.fillWidth: true + visible: false + spacing: 8 + + Label { id: progressLabel; text: "0 / 0"; font.pixelSize: 11; opacity: 0.75 } + ProgressBar { + id: progressBar + Layout.fillWidth: true + from: 0; to: 1; value: 0 + } + } + + Connections { + target: backend + function onGenerationProgress(current, total) { + progressRow.visible = true + progressLabel.text = current + " / " + total + progressBar.value = current / total + } + function onGenerationDone() { + progressRow.visible = false + progressBar.value = 0 + } + } + } +} diff --git a/UI/qml/main.qml b/UI/qml/main.qml new file mode 100644 index 0000000..c06ead1 --- /dev/null +++ b/UI/qml/main.qml @@ -0,0 +1,382 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Controls.Material +import QtQuick.Layouts +import QtQuick.Dialogs + +ApplicationWindow { + id: root + visible: true + width: 1460 + height: 880 + minimumWidth: 1000 + minimumHeight: 640 + title: "Synthetics Generator UI — " + configNameLabel.text + + // ── theme ──────────────────────────────────────────────────────────────── + property bool darkMode: true + Material.theme: darkMode ? Material.Dark : Material.Light + Material.primary: darkMode ? "#3d5afe" : "#1565c0" + Material.accent: darkMode ? "#40c4ff" : "#0288d1" + Material.background: darkMode ? "#1a1a2e" : "#f5f5f5" + Material.foreground: darkMode ? "#e0e0ff" : "#1a1a2e" + + // ── state ──────────────────────────────────────────────────────────────── + property bool generationRunning: false + + // ── signals from backend ───────────────────────────────────────────────── + Connections { + target: backend + function onErrorOccurred(msg) { snackbar.show(msg) } + function onConfigPathChanged(name) { configNameLabel.text = name } + function onGenerationBusy(b) { root.generationRunning = b } + function onLogMessage(msg) { + logArea.append(msg) + centerTabs.currentIndex = 1 // switch to log during generation + } + function onGenerationDone() { + centerTabs.currentIndex = 0 // switch back to visualizer when done + } + } + + // ── file dialogs ───────────────────────────────────────────────────────── + FileDialog { + id: openDialog + title: "Открыть конфиг" + nameFilters: ["JSON files (*.json)", "All files (*)"] + onAccepted: backend.loadConfig(selectedFile.toString()) + } + + FileDialog { + id: saveDialog + title: "Сохранить конфиг" + fileMode: FileDialog.SaveFile + nameFilters: ["JSON files (*.json)"] + defaultSuffix: "json" + onAccepted: backend.saveConfig(selectedFile.toString()) + } + + // ── toolbar ────────────────────────────────────────────────────────────── + header: ToolBar { + height: 52 + Material.background: root.darkMode ? "#12122a" : "#1565c0" + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + spacing: 8 + + Label { + text: "Synthetics Generator" + font.pixelSize: 16 + font.weight: Font.Medium + color: "white" + opacity: 0.95 + } + + Item { Layout.fillWidth: true } + + RowLayout { + spacing: 6 + Label { + text: "Конфиг:" + font.pixelSize: 11 + color: "white" + opacity: 0.7 + } + Rectangle { + height: 24 + width: configNameLabel.implicitWidth + 16 + radius: 12 + color: Qt.rgba(1, 1, 1, 0.15) + Label { + id: configNameLabel + anchors.centerIn: parent + text: "setting.json" + font.pixelSize: 11 + color: "white" + opacity: 0.9 + } + } + ToolButton { + text: "Открыть" + font.pixelSize: 11 + display: AbstractButton.TextOnly + onClicked: openDialog.open() + contentItem: Label { + text: parent.text + color: "#80d8ff" + font: parent.font + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + color: parent.hovered ? Qt.rgba(1,1,1,0.12) : "transparent" + radius: 4 + } + } + } + + ToolButton { + id: themeBtn + implicitWidth: 40 + implicitHeight: 40 + onClicked: root.darkMode = !root.darkMode + contentItem: Label { + text: root.darkMode ? "☀" : "🌙" + font.pixelSize: 18 + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + radius: 20 + color: themeBtn.hovered ? Qt.rgba(1,1,1,0.15) : "transparent" + } + ToolTip.visible: hovered + ToolTip.text: root.darkMode ? "Светлая тема" : "Тёмная тема" + } + } + } + + // ── main three-column layout ───────────────────────────────────────────── + RowLayout { + anchors.fill: parent + spacing: 0 + + // ════════════════════════════════════════════════════════════════════ + // LEFT PANEL — parameters + action buttons (380 px) + // ════════════════════════════════════════════════════════════════════ + Rectangle { + Layout.preferredWidth: 380 + Layout.minimumWidth: 320 + Layout.fillHeight: true + color: root.darkMode ? "#1e1e32" : "#ffffff" + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + TabBar { + id: leftTabs + Layout.fillWidth: true + Material.accent: root.Material.accent + + TabButton { + text: "Параметры" + font.pixelSize: 12 + implicitHeight: 40 + } + TabButton { + text: "Настройки" + font.pixelSize: 12 + implicitHeight: 40 + } + } + + Rectangle { + Layout.fillWidth: true + height: 1 + color: root.darkMode ? "#2a2a48" : "#e0e0e0" + } + + StackLayout { + Layout.fillWidth: true + Layout.fillHeight: true + currentIndex: leftTabs.currentIndex + + ParameterPanel { id: paramPanel } + GenerationPanel {} + } + + // ── vertical action buttons ─────────────────────────────── + Rectangle { + Layout.fillWidth: true + height: 1 + color: root.darkMode ? "#2a2a48" : "#e0e0e0" + } + + Column { + Layout.fillWidth: true + Layout.margins: 10 + spacing: 6 + + // Сгенерировать основу + Button { + width: parent.width + text: root.generationRunning ? "Генерация…" : "Сгенерировать основу" + Material.background: root.darkMode ? "#1a3a5c" : "#bbdefb" + Material.foreground: root.darkMode ? "#64b5f6" : "#0d47a1" + font.pixelSize: 12 + font.weight: Font.Medium + enabled: !root.generationRunning + onClicked: backend.generateStructure() + ToolTip.visible: hovered + ToolTip.text: "Заглушка ветки generator_3d — выполняет полную генерацию" + } + + // Перерисовать + Button { + width: parent.width + text: root.generationRunning ? "Генерация…" : "Перерисовать" + Material.background: Material.Blue + Material.foreground: "white" + font.pixelSize: 12 + font.weight: Font.Medium + enabled: !root.generationRunning + onClicked: backend.redraw() + } + + // Сохранить ▼ (dropdown) + Button { + width: parent.width + text: "Сохранить ▾" + Material.background: root.darkMode ? "#2d2d4a" : "#e8eaf6" + font.pixelSize: 12 + enabled: !root.generationRunning + onClicked: saveMenu.open() + + Menu { + id: saveMenu + y: -height + + MenuItem { + text: "Сохранить конфиг (тот же файл)" + onTriggered: backend.saveConfigInPlace() + } + MenuItem { + text: "Сохранить конфиг как…" + onTriggered: saveDialog.open() + } + } + } + } + } + } + + // vertical divider + Rectangle { + Layout.fillHeight: true + width: 1 + color: root.darkMode ? "#2a2a48" : "#e0e0e0" + } + + // ════════════════════════════════════════════════════════════════════ + // CENTER PANEL — Visualizer / Log (fills remaining space) + // ════════════════════════════════════════════════════════════════════ + ColumnLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 0 + + TabBar { + id: centerTabs + Layout.fillWidth: true + Material.accent: root.Material.accent + + TabButton { + text: "Визуализация" + font.pixelSize: 12 + implicitHeight: 40 + } + TabButton { + text: "Журнал генерации" + font.pixelSize: 12 + implicitHeight: 40 + } + } + + Rectangle { + Layout.fillWidth: true + height: 1 + color: root.darkMode ? "#2a2a48" : "#e0e0e0" + } + + StackLayout { + Layout.fillWidth: true + Layout.fillHeight: true + currentIndex: centerTabs.currentIndex + + // — Visualizer tab — + Visualizer { + Layout.fillWidth: true + Layout.fillHeight: true + } + + // — Log tab — + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + color: root.darkMode ? "#0f0f1e" : "#fafafa" + + ScrollView { + anchors.fill: parent + anchors.margins: 8 + clip: true + + TextArea { + id: logArea + readOnly: true + wrapMode: TextArea.Wrap + font.family: "Consolas, monospace" + font.pixelSize: 11 + color: root.darkMode ? "#a9b1d6" : "#1a1a2e" + background: null + text: "[ Журнал генерации ]\n" + + function append(msg) { + logArea.text += msg + "\n" + // auto-scroll to bottom + logArea.cursorPosition = logArea.length + } + } + } + + // clear button + ToolButton { + anchors.top: parent.top + anchors.right: parent.right + anchors.margins: 4 + implicitWidth: 28 + implicitHeight: 28 + text: "✕" + font.pixelSize: 11 + onClicked: logArea.text = "[ Журнал генерации ]\n" + ToolTip.visible: hovered + ToolTip.text: "Очистить журнал" + contentItem: Label { + text: parent.text + color: root.darkMode ? "#6a6a8a" : "#888" + font: parent.font + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + radius: 4 + color: parent.hovered ? Qt.rgba(1,1,1,0.1) : "transparent" + } + } + } + } + } + + // vertical divider + Rectangle { + Layout.fillHeight: true + width: 1 + color: root.darkMode ? "#2a2a48" : "#e0e0e0" + } + + // ════════════════════════════════════════════════════════════════════ + // RIGHT PANEL — Histograms (380 px) + // ════════════════════════════════════════════════════════════════════ + HistogramPanel { + Layout.preferredWidth: 380 + Layout.minimumWidth: 300 + Layout.fillHeight: true + darkMode: root.darkMode + } + } + + // ── snackbar ────────────────────────────────────────────────────────────── + Snackbar { id: snackbar } +}