Initial Bioportal Lab prototype
This commit is contained in:
+18
@@ -0,0 +1,18 @@
|
||||
# Python environments and generated files
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
|
||||
# CV training artifacts and locally supplied data
|
||||
models/
|
||||
AGAR_representative/
|
||||
|
||||
# OS and editor files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# Local runtime output
|
||||
*.log
|
||||
@@ -0,0 +1,57 @@
|
||||
# Биопортал Лаб
|
||||
|
||||
Русскоязычный frontend-прототип платформы для лабораторных расчётов и анализа микробиологических экспериментов.
|
||||
|
||||
Текущий фокус — демонстрационный сценарий PetriCount: загрузка фотографии чашки Петри, поиск кандидатов на колонии CV-baseline, ручная коррекция и передача результата в расчёт КОЕ/мл.
|
||||
|
||||
> Важно: текущий PetriCount — экспериментальный интерфейс и baseline-модель. Результаты не являются лабораторно валидированным измерением и требуют ручной проверки.
|
||||
|
||||
## Что есть в проекте
|
||||
|
||||
- адаптивный дашборд Биопортал Лаб;
|
||||
- экран PetriCount с загрузкой JPG, PNG и WEBP;
|
||||
- добавление и удаление маркеров вручную;
|
||||
- локальный Flask API для анализа изображений;
|
||||
- обучаемый CV-baseline: генерация многомасштабных кандидатов + Random Forest, обучаемый на JSON-разметке AGAR;
|
||||
- скрипт повторного обучения модели.
|
||||
|
||||
## Быстрый запуск
|
||||
|
||||
Требуется Python 3.13 или новее.
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
.venv/bin/python -m pip install -r requirements.txt
|
||||
.venv/bin/python server.py
|
||||
```
|
||||
|
||||
Откройте [http://127.0.0.1:8000](http://127.0.0.1:8000) и перейдите в карточку PetriCount.
|
||||
|
||||
## Повторное обучение CV-baseline
|
||||
|
||||
Чтобы переобучить модель, положите пары `*.jpg` и `*.json` в `AGAR_representative/`, сохраняя относительную структуру каталогов, затем выполните:
|
||||
|
||||
```bash
|
||||
.venv/bin/python train_petri_model.py
|
||||
```
|
||||
|
||||
Разметка должна содержать поля `x`, `y`, `width`, `height` и `class` для каждой колонии. Полученная модель записывается в `models/petri_candidate_classifier.joblib`.
|
||||
|
||||
Каталоги с датасетом, моделью и виртуальным окружением исключены из Git: они весят около 400 МБ и создаются или поставляются локально.
|
||||
|
||||
## Структура
|
||||
|
||||
```text
|
||||
index.html / styles.css / app.js Главный дашборд
|
||||
petricount.* Интерфейс PetriCount
|
||||
server.py Локальный Flask API и раздача frontend
|
||||
petri_cv.py Признаки и генерация CV-кандидатов
|
||||
train_petri_model.py Обучение baseline-модели
|
||||
requirements.txt Версии Python-зависимостей
|
||||
```
|
||||
|
||||
## Ограничения текущего прототипа
|
||||
|
||||
- модель обучена на небольшой выборке и не имеет независимой валидации;
|
||||
- API предназначен для локального запуска, без авторизации и хранения данных;
|
||||
- экспорт, расчёт КОЕ/мл и сохранение экспериментов пока являются интерфейсными сценариями, а не полноценным backend-функционалом.
|
||||
@@ -0,0 +1,34 @@
|
||||
const toast = document.querySelector('.toast');
|
||||
let timer;
|
||||
|
||||
function showToast(message) {
|
||||
toast.textContent = message;
|
||||
toast.classList.add('show');
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => toast.classList.remove('show'), 2600);
|
||||
}
|
||||
|
||||
document.querySelectorAll('[data-toast]').forEach((button) => {
|
||||
button.addEventListener('click', () => showToast(button.dataset.toast));
|
||||
});
|
||||
|
||||
const modal = document.querySelector('#experiment-modal');
|
||||
document.querySelectorAll('[data-modal="experiment"]').forEach((button) => {
|
||||
button.addEventListener('click', () => modal.showModal());
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-tool]').forEach((button) => {
|
||||
button.addEventListener('click', () => showToast(`Демо «${button.dataset.tool}» будет доступно в следующем экране.`));
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-route]').forEach((button) => {
|
||||
button.addEventListener('click', () => { window.location.href = button.dataset.route; });
|
||||
});
|
||||
|
||||
modal.addEventListener('close', () => {
|
||||
if (modal.returnValue === 'default') showToast('Эксперимент создан в демо-режиме.');
|
||||
});
|
||||
|
||||
document.querySelector('.mobile-menu').addEventListener('click', () => {
|
||||
showToast('Мобильное меню будет добавлено вместе с внутренними страницами.');
|
||||
});
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#18b88d" />
|
||||
<title>Биопортал Лаб</title>
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<header class="topbar">
|
||||
<a class="brand" href="#home" aria-label="Биопортал Лаб, главная">
|
||||
<span class="brand-mark"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 3h6M10 3v6.2L5.5 18a2 2 0 0 0 1.7 3h9.6a2 2 0 0 0 1.7-3L14 9.2V3"/><path d="M8.3 16.2h7.4M10.1 12.8h3.8"/></svg></span>
|
||||
<span><strong>Биопортал Лаб</strong><small>Инструменты для лабораторных расчётов и анализа</small></span>
|
||||
</a>
|
||||
<nav class="top-nav" aria-label="Служебная навигация">
|
||||
<a href="#reference"><svg viewBox="0 0 24 24"><path d="M4 5.5A2.5 2.5 0 0 1 6.5 3H11v17H6.5A2.5 2.5 0 0 0 4 22.5zM20 5.5A2.5 2.5 0 0 0 17.5 3H13v17h4.5a2.5 2.5 0 0 1 2 1.5z"/></svg>Справочник</a>
|
||||
<a href="#docs"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M9.7 9a2.5 2.5 0 1 1 4.4 1.65c-.9.91-2.1 1.3-2.1 2.85M12 16.8h.01"/></svg>Документация</a>
|
||||
<button class="icon-button" data-toast="Новых уведомлений нет" aria-label="Уведомления"><svg viewBox="0 0 24 24"><path d="M18 9a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9M10 21h4"/></svg></button>
|
||||
<button class="profile" data-toast="Открыт профиль Анны Ивановой"><span>АИ</span><b>Анна Иванова</b><svg viewBox="0 0 24 24"><path d="m7 10 5 5 5-5"/></svg></button>
|
||||
</nav>
|
||||
<button class="mobile-menu" aria-label="Открыть меню"><span></span><span></span><span></span></button>
|
||||
</header>
|
||||
|
||||
<aside class="sidebar" aria-label="Основная навигация">
|
||||
<button class="create-button" data-modal="experiment"><span>+</span>Создать эксперимент</button>
|
||||
<nav class="side-nav">
|
||||
<a class="active" href="#home"><i class="icon home"></i>Главная</a>
|
||||
<p>Инструменты</p>
|
||||
<a href="#tool-dilution"><i class="icon drop blue"></i>Разбавление растворов</a>
|
||||
<a href="#tool-prep"><i class="icon flask purple"></i>Приготовление раствора</a>
|
||||
<a href="#tool-series"><i class="icon tubes green"></i>Серийные разведения</a>
|
||||
<a href="#tool-petri"><i class="icon petri orange"></i>PetriCount</a>
|
||||
<a href="#tool-cfu"><i class="icon bars red"></i>CFU / мл</a>
|
||||
<a href="#tool-growth"><i class="icon chart teal"></i>Рост культуры</a>
|
||||
<hr />
|
||||
<a href="#experiments"><i class="icon document"></i>Мои эксперименты</a>
|
||||
<a href="#templates"><i class="icon document"></i>Шаблоны</a>
|
||||
<a href="#reference"><i class="icon book"></i>Справочник веществ</a>
|
||||
</nav>
|
||||
<div class="plan-card"><b>Тариф: <span>Бесплатный</span></b><small>Осталось анализов: 10</small><div class="progress"><span></span></div><button data-toast="Тарифы будут доступны позднее">Улучшить тариф</button></div>
|
||||
</aside>
|
||||
|
||||
<main id="home" class="main-content">
|
||||
<section class="welcome-banner">
|
||||
<div><h1>Добро пожаловать в Биопортал Лаб!</h1><p>Всё для ваших экспериментов: расчёты, анализ<br />изображений и отчёты.</p></div>
|
||||
<div class="lab-illustration" aria-hidden="true">
|
||||
<div class="petri-dish"><span></span><span></span><span></span><span></span><span></span><span></span></div>
|
||||
<div class="beaker"><i></i></div>
|
||||
<div class="rack"><b></b><b></b><b></b></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section tools-section"><div class="section-heading"><h2>Инструменты</h2><button class="text-button" data-toast="Каталог инструментов скоро появится">Все инструменты <span>→</span></button></div>
|
||||
<div class="tool-grid">
|
||||
<article class="tool-card blue"><div class="tool-icon"><i class="icon drop blue"></i></div><h3>Разбавление<br />растворов</h3><p>Рассчитайте неизвестные параметры разбавления.</p><button data-tool="Разбавление растворов">Открыть</button></article>
|
||||
<article class="tool-card purple"><div class="tool-icon"><i class="icon flask purple"></i></div><h3>Приготовление<br />раствора</h3><p>Рассчитайте массу вещества для нужной концентрации.</p><button data-tool="Приготовление раствора">Открыть</button></article>
|
||||
<article class="tool-card green"><div class="tool-icon"><i class="icon tubes green"></i></div><h3>Серийные<br />разведения</h3><p>Постройте схему разведений и получите объёмы.</p><button data-tool="Серийные разведения">Открыть</button></article>
|
||||
<article class="tool-card orange"><div class="tool-icon"><i class="icon petri orange"></i></div><h3>PetriCount</h3><p>Загрузите фото чашки Петри и посчитайте колонии.</p><button data-route="./petricount.html">Открыть</button></article>
|
||||
<article class="tool-card red"><div class="tool-icon"><i class="icon bars red"></i></div><h3>CFU / мл</h3><p>Рассчитайте КОЕ/мл по данным подсчёта колоний.</p><button data-tool="CFU / мл">Открыть</button></article>
|
||||
<article class="tool-card teal"><div class="tool-icon"><i class="icon chart teal"></i></div><h3>Рост культуры</h3><p>Анализ кривой роста и расчёт времени удвоения.</p><button data-tool="Рост культуры">Открыть</button></article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="lower-grid">
|
||||
<section class="recent section" id="experiments"><div class="section-heading"><h2>Недавние эксперименты</h2><button class="text-button" data-toast="Открыт полный список экспериментов">Все эксперименты <span>→</span></button></div>
|
||||
<div class="table-card"><table><thead><tr><th>Название</th><th>Дата</th><th>Инструменты</th><th>Статус</th><th></th></tr></thead><tbody>
|
||||
<tr><td><b>E.coli K12, оценка роста</b></td><td>24.05.2024</td><td><span class="tag teal">Рост культуры</span><span class="tag red">CFU / мл</span></td><td><span class="status done">✓</span> Завершён</td><td><button class="more" data-toast="Действия с экспериментом">⋮</button></td></tr>
|
||||
<tr><td><b>Разведение образца №12</b></td><td>22.05.2024</td><td><span class="tag green">Серийные разведения</span><span class="tag orange">PetriCount</span></td><td><span class="status done">✓</span> Завершён</td><td><button class="more" data-toast="Действия с экспериментом">⋮</button></td></tr>
|
||||
<tr><td><b>Приготовление среды LB</b></td><td>20.05.2024</td><td><span class="tag purple">Приготовление раствора</span></td><td><span class="status done">✓</span> Завершён</td><td><button class="more" data-toast="Действия с экспериментом">⋮</button></td></tr>
|
||||
<tr><td><b>Тест образца воды</b></td><td>18.05.2024</td><td><span class="tag orange">PetriCount</span><span class="tag red">CFU / мл</span></td><td><span class="status draft">⌄</span> Черновик</td><td><button class="more" data-toast="Действия с экспериментом">⋮</button></td></tr>
|
||||
</tbody></table></div>
|
||||
</section>
|
||||
<aside class="tips"><div class="tips-title"><span>💡</span><h2>Полезно знать</h2></div><article><i class="tip-petri">◌</i><p>Оптимальный диапазон для подсчёта колоний: <b>30–300 КОЕ на чашку.</b></p></article><article><i>🧪</i><p>При серийных разведениях используйте новые наконечники на каждом шаге.</p></article><article><i>▣</i><p>Все расчёты сохраняются в ваших экспериментах и доступны для отчётов.</p></article><button data-toast="Открыт раздел полезных советов">Смотреть все советы <span>→</span></button></aside>
|
||||
</section>
|
||||
</main>
|
||||
<footer><span>© 2024 Биопортал Лаб. Все права защищены.</span><div><a href="#about">О нас</a><a href="#contacts">Контакты</a><a href="#privacy">Политика конфиденциальности</a></div></footer>
|
||||
</div>
|
||||
|
||||
<dialog id="experiment-modal"><form method="dialog"><button class="close" value="cancel" aria-label="Закрыть">×</button><span class="modal-icon">⚗</span><h2>Новый эксперимент</h2><p>Создайте рабочее пространство для расчётов, фотографий и результатов.</p><label>Название эксперимента<input autofocus placeholder="Например, Подсчёт КОЕ образца воды" /></label><label>Дата<input type="date" value="2024-05-25" /></label><button class="submit" value="default">Создать эксперимент</button></form></dialog>
|
||||
<div class="toast" role="status" aria-live="polite"></div>
|
||||
<script src="./app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
"""Small, locally trainable colony-candidate detector for the PetriCount demo.
|
||||
|
||||
It deliberately uses only the annotated AGAR images that ship with this project.
|
||||
The model ranks multiscale bright/dark blob candidates; it is a baseline for UI
|
||||
integration, not a validated microbiology measurement method.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
from scipy.ndimage import gaussian_filter, maximum_filter
|
||||
|
||||
MAX_SIDE = 720
|
||||
|
||||
|
||||
@dataclass
|
||||
class PreparedImage:
|
||||
rgb: np.ndarray
|
||||
gray: np.ndarray
|
||||
scale: float
|
||||
original_size: tuple[int, int]
|
||||
|
||||
|
||||
def prepare_image(path_or_file) -> PreparedImage:
|
||||
image = Image.open(path_or_file).convert("RGB")
|
||||
original_size = image.size
|
||||
scale = min(1.0, MAX_SIDE / max(image.size))
|
||||
if scale < 1:
|
||||
image = image.resize((round(image.width * scale), round(image.height * scale)), Image.Resampling.LANCZOS)
|
||||
rgb = np.asarray(image, dtype=np.float32) / 255.0
|
||||
gray = rgb[..., 0] * 0.2126 + rgb[..., 1] * 0.7152 + rgb[..., 2] * 0.0722
|
||||
return PreparedImage(rgb, gray, scale, original_size)
|
||||
|
||||
|
||||
def _near_edge(x: int, y: int, width: int, height: int, margin: int = 13) -> bool:
|
||||
return x < margin or y < margin or x >= width - margin or y >= height - margin
|
||||
|
||||
|
||||
def candidate_points(prepared: PreparedImage) -> list[tuple[int, int]]:
|
||||
"""Find potential round colonies as local extrema at several blob scales."""
|
||||
gray = prepared.gray
|
||||
height, width = gray.shape
|
||||
candidates: list[tuple[float, int, int]] = []
|
||||
# Colonies can be lighter or darker than the surrounding agar.
|
||||
for sigma in (1.5, 2.5, 4.0, 6.0):
|
||||
local = gaussian_filter(gray, sigma) - gaussian_filter(gray, sigma * 3.2)
|
||||
radius = max(4, round(sigma * 2.5))
|
||||
for response in (local, -local):
|
||||
maxima = response == maximum_filter(response, size=radius * 2 + 1)
|
||||
threshold = np.percentile(response, 97.2)
|
||||
ys, xs = np.where(maxima & (response >= threshold))
|
||||
for y, x in zip(ys, xs):
|
||||
if _near_edge(int(x), int(y), width, height):
|
||||
continue
|
||||
# Suppress the metal/background outside the central dish region.
|
||||
if np.hypot(x - width / 2, y - height / 2) > min(width, height) * 0.48:
|
||||
continue
|
||||
candidates.append((float(response[y, x]), int(x), int(y)))
|
||||
candidates.sort(reverse=True)
|
||||
selected: list[tuple[int, int]] = []
|
||||
for _, x, y in candidates:
|
||||
if all((x - px) ** 2 + (y - py) ** 2 > 13 ** 2 for px, py in selected):
|
||||
selected.append((x, y))
|
||||
if len(selected) >= 450:
|
||||
break
|
||||
# Diffuse colonies often have no sharp local maximum. Add a coarse candidate
|
||||
# grid over visually changing parts of the dish and let the learned ranker
|
||||
# decide whether each patch is a colony or background.
|
||||
local_change = np.abs(gaussian_filter(gray, 2) - gaussian_filter(gray, 15))
|
||||
change_threshold = np.percentile(local_change, 58)
|
||||
for y in range(14, height - 14, 16):
|
||||
for x in range(14, width - 14, 16):
|
||||
if np.hypot(x - width / 2, y - height / 2) > min(width, height) * 0.46:
|
||||
continue
|
||||
if local_change[y, x] < change_threshold:
|
||||
continue
|
||||
if all((x - px) ** 2 + (y - py) ** 2 > 8 ** 2 for px, py in selected):
|
||||
selected.append((x, y))
|
||||
return selected
|
||||
|
||||
|
||||
def _patch(array: np.ndarray, x: int, y: int, radius: int) -> np.ndarray:
|
||||
return array[max(0, y - radius):y + radius + 1, max(0, x - radius):x + radius + 1]
|
||||
|
||||
|
||||
def features_for_points(prepared: PreparedImage, points: Iterable[tuple[int, int]]) -> np.ndarray:
|
||||
rows = []
|
||||
smooth_small = gaussian_filter(prepared.gray, 2)
|
||||
smooth_large = gaussian_filter(prepared.gray, 12)
|
||||
contrast = smooth_small - smooth_large
|
||||
gradient_y, gradient_x = np.gradient(prepared.gray)
|
||||
gradient = np.hypot(gradient_x, gradient_y)
|
||||
for x, y in points:
|
||||
inner_gray = _patch(prepared.gray, x, y, 4)
|
||||
outer_gray = _patch(prepared.gray, x, y, 12)
|
||||
inner_rgb = _patch(prepared.rgb, x, y, 4).reshape(-1, 3)
|
||||
outer_rgb = _patch(prepared.rgb, x, y, 12)
|
||||
inner_gradient = _patch(gradient, x, y, 8)
|
||||
rows.append([
|
||||
*inner_rgb.mean(axis=0), *inner_rgb.std(axis=0),
|
||||
float(inner_gray.mean()), float(inner_gray.std()),
|
||||
float(outer_gray.mean()), float(outer_gray.std()),
|
||||
float(contrast[y, x]), float(abs(contrast[y, x])),
|
||||
float(inner_gradient.mean()), float(inner_gradient.std()),
|
||||
x / prepared.gray.shape[1], y / prepared.gray.shape[0],
|
||||
])
|
||||
return np.asarray(rows, dtype=np.float32)
|
||||
|
||||
|
||||
def label_centers(labels: list[dict], scale: float) -> list[tuple[int, int, float]]:
|
||||
return [((item["x"] + item["width"] / 2) * scale, (item["y"] + item["height"] / 2) * scale,
|
||||
max(item["width"], item["height"]) * scale / 2) for item in labels]
|
||||
|
||||
|
||||
def label_candidates(points: list[tuple[int, int]], labels: list[dict], scale: float) -> np.ndarray:
|
||||
centers = label_centers(labels, scale)
|
||||
labels_for_points = []
|
||||
for x, y in points:
|
||||
labels_for_points.append(any((x - cx) ** 2 + (y - cy) ** 2 <= max(7, radius * .78) ** 2 for cx, cy, radius in centers))
|
||||
return np.asarray(labels_for_points, dtype=np.int8)
|
||||
|
||||
|
||||
def add_annotation_centers(points: list[tuple[int, int]], labels: list[dict], scale: float) -> list[tuple[int, int]]:
|
||||
result = list(points)
|
||||
for center_x, center_y, _ in label_centers(labels, scale):
|
||||
point = (round(center_x), round(center_y))
|
||||
if all((point[0] - px) ** 2 + (point[1] - py) ** 2 > 5 ** 2 for px, py in result):
|
||||
result.append(point)
|
||||
return result
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,40 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#18b88d" />
|
||||
<title>PetriCount — Биопортал Лаб</title>
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
<link rel="stylesheet" href="./petricount.css" />
|
||||
</head>
|
||||
<body class="petri-page">
|
||||
<header class="petri-header">
|
||||
<a class="brand" href="./index.html"><span class="brand-mark"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 3h6M10 3v6.2L5.5 18a2 2 0 0 0 1.7 3h9.6a2 2 0 0 0 1.7-3L14 9.2V3"/><path d="M8.3 16.2h7.4M10.1 12.8h3.8"/></svg></span><span><strong>Биопортал Лаб</strong><small>PetriCount</small></span></a>
|
||||
<div class="crumbs"><a href="./index.html">Главная</a><span>/</span><b>PetriCount</b></div>
|
||||
<button class="header-action" id="save-result">Сохранить результат</button>
|
||||
</header>
|
||||
<main class="petri-layout">
|
||||
<section class="workspace">
|
||||
<div class="page-heading"><div><span class="eyebrow">Демо-инструмент</span><h1>Подсчёт колоний на чашке Петри</h1><p>Загрузите фотографию, проверьте найденные объекты и скорректируйте результат вручную.</p></div><span class="demo-badge">Демо</span></div>
|
||||
<div id="drop-zone" class="drop-zone">
|
||||
<input id="image-input" type="file" accept="image/png,image/jpeg,image/webp" />
|
||||
<div class="drop-icon"><i class="icon petri orange"></i></div><h2>Загрузите фото чашки Петри</h2><p>Перетащите файл сюда или <button id="choose-file">выберите на компьютере</button></p><small>PNG, JPG или WEBP · до 10 МБ</small>
|
||||
</div>
|
||||
<div id="analysis-area" class="analysis-area is-hidden">
|
||||
<div class="canvas-toolbar"><div class="tool-mode"><button class="mode active" data-mode="add">+ Добавить</button><button class="mode" data-mode="remove">− Удалить</button><button class="mode" data-mode="pan">⌖ Обзор</button></div><div class="zoom-controls"><button id="zoom-out" aria-label="Уменьшить">−</button><span id="zoom-label">100%</span><button id="zoom-in" aria-label="Увеличить">+</button></div></div>
|
||||
<div class="canvas-wrap"><canvas id="petri-canvas" aria-label="Изображение чашки Петри с отмеченными колониями"></canvas><div id="canvas-empty" class="canvas-empty">Подготовка изображения…</div></div>
|
||||
<div class="canvas-note"><span class="dot"></span>Нажмите на изображение, чтобы добавить колонию. В режиме удаления нажмите на маркер.</div>
|
||||
</div>
|
||||
</section>
|
||||
<aside class="petri-panel">
|
||||
<section class="panel-card upload-status"><div class="panel-title"><h2>Изображение</h2><span id="image-status" class="status-chip neutral">Не загружено</span></div><div class="image-meta"><span>Файл</span><b id="file-name">—</b><span>Размер</span><b id="file-size">—</b><span>Размеры</span><b id="file-dimensions">—</b></div><button id="replace-image" class="outline-button" disabled>Заменить изображение</button></section>
|
||||
<section class="panel-card result-card"><div class="panel-title"><h2>Результат подсчёта</h2><button id="reset-markers" class="link-button" disabled>Сбросить</button></div><div class="count"><strong id="colony-count">0</strong><span>колоний</span></div><p id="count-hint">Загрузите изображение, чтобы начать подсчёт.</p><div class="quality"><div><span>Диапазон для подсчёта</span><b>30–300 КОЕ</b></div><i id="quality-dot"></i></div></section>
|
||||
<section class="panel-card settings-card"><div class="panel-title"><h2>Параметры анализа</h2><span class="auto-label">CV baseline</span></div><label class="switch-row"><span>Показать номера колоний</span><input id="show-labels" type="checkbox" checked /><i></i></label><label class="switch-row"><span>Запустить CV-модель</span><input id="auto-detect" type="checkbox" checked /><i></i></label><p>Модель обучена на включённой выборке AGAR. Всегда проверьте и скорректируйте результат перед использованием в расчёте.</p></section>
|
||||
<section class="formula-card"><span>Следующий шаг</span><h3>Передать результат в CFU / мл</h3><p>Добавьте коэффициент разведения и объём посева для расчёта концентрации.</p><button id="to-cfu" disabled>Перейти к расчёту <span>→</span></button></section>
|
||||
</aside>
|
||||
</main>
|
||||
<div class="toast" role="status" aria-live="polite"></div>
|
||||
<script src="./petricount.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
const input = document.querySelector('#image-input');
|
||||
const dropZone = document.querySelector('#drop-zone');
|
||||
const analysis = document.querySelector('#analysis-area');
|
||||
const canvas = document.querySelector('#petri-canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
const empty = document.querySelector('#canvas-empty');
|
||||
const countElement = document.querySelector('#colony-count');
|
||||
const hint = document.querySelector('#count-hint');
|
||||
const quality = document.querySelector('#quality-dot');
|
||||
const toast = document.querySelector('.toast');
|
||||
let source = null;
|
||||
let markers = [];
|
||||
let mode = 'add';
|
||||
let zoom = 1;
|
||||
let labels = true;
|
||||
let noticeTimer;
|
||||
let currentFile = null;
|
||||
|
||||
function notify(message) { toast.textContent = message; toast.classList.add('show'); clearTimeout(noticeTimer); noticeTimer = setTimeout(() => toast.classList.remove('show'), 2800); }
|
||||
function formatSize(bytes) { return bytes < 1024 * 1024 ? `${Math.round(bytes / 1024)} КБ` : `${(bytes / 1024 / 1024).toFixed(1)} МБ`; }
|
||||
function updateResult() {
|
||||
const count = markers.length;
|
||||
countElement.textContent = count;
|
||||
document.querySelector('#reset-markers').disabled = !count;
|
||||
document.querySelector('#to-cfu').disabled = !source;
|
||||
if (!source) return;
|
||||
if (count < 30) { hint.textContent = 'Колоний меньше 30: результат может быть статистически ненадёжным.'; quality.className = 'warn'; }
|
||||
else if (count <= 300) { hint.textContent = 'Количество колоний находится в рекомендуемом диапазоне.'; quality.className = 'good'; }
|
||||
else { hint.textContent = 'Колоний больше 300: рассмотрите большее разведение образца.'; quality.className = 'bad'; }
|
||||
}
|
||||
function draw() {
|
||||
if (!source) return;
|
||||
const parent = canvas.parentElement;
|
||||
const maxWidth = parent.clientWidth - 16;
|
||||
const maxHeight = parent.clientHeight - 16;
|
||||
const baseScale = Math.min(maxWidth / source.width, maxHeight / source.height, 1);
|
||||
const scale = baseScale * zoom;
|
||||
canvas.width = Math.round(source.width * scale);
|
||||
canvas.height = Math.round(source.height * scale);
|
||||
context.drawImage(source, 0, 0, canvas.width, canvas.height);
|
||||
markers.forEach((marker, index) => {
|
||||
const x = marker.x * scale, y = marker.y * scale;
|
||||
context.beginPath(); context.arc(x, y, Math.max(6, 9 * scale), 0, Math.PI * 2); context.fillStyle = 'rgba(255,153,55,.25)'; context.fill();
|
||||
context.lineWidth = 2; context.strokeStyle = '#fb922f'; context.stroke();
|
||||
if (labels) { context.fillStyle = '#fff'; context.beginPath(); context.arc(x + 8, y - 8, 8, 0, Math.PI * 2); context.fill(); context.fillStyle = '#d77317'; context.font = 'bold 9px system-ui'; context.textAlign = 'center'; context.textBaseline = 'middle'; context.fillText(index + 1, x + 8, y - 8); }
|
||||
});
|
||||
document.querySelector('#zoom-label').textContent = `${Math.round(zoom * 100)}%`;
|
||||
}
|
||||
function addMarker(event) {
|
||||
if (!source || mode === 'pan') return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scale = canvas.width / source.width;
|
||||
const x = (event.clientX - rect.left) / scale;
|
||||
const y = (event.clientY - rect.top) / scale;
|
||||
if (mode === 'remove') { const closest = markers.reduce((best, item, index) => { const distance = Math.hypot(item.x - x, item.y - y); return distance < best.distance ? { index, distance } : best; }, { index: -1, distance: 20 / scale }); if (closest.index >= 0) markers.splice(closest.index, 1); }
|
||||
else markers.push({ x, y });
|
||||
updateResult(); draw();
|
||||
}
|
||||
async function autoDetect() {
|
||||
if (!source || !currentFile || !document.querySelector('#auto-detect').checked) return;
|
||||
const chip = document.querySelector('#image-status');
|
||||
chip.textContent = 'CV-анализ…'; chip.className = 'status-chip neutral';
|
||||
hint.textContent = 'Модель ищет и ранжирует кандидаты на колонии…';
|
||||
try {
|
||||
const payload = new FormData(); payload.append('image', currentFile);
|
||||
const response = await fetch('/api/analyze', { method: 'POST', body: payload });
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || 'Ошибка CV-анализа');
|
||||
markers = data.colonies.map(item => ({ x: item.x * source.width, y: item.y * source.height, score: item.score }));
|
||||
chip.textContent = 'CV готов'; chip.className = 'status-chip ready';
|
||||
updateResult(); draw();
|
||||
notify(`CV-модель нашла ${markers.length} кандидатов из ${data.candidates}. Проверьте разметку.`);
|
||||
} catch (error) {
|
||||
chip.textContent = 'Ручной режим'; chip.className = 'status-chip neutral';
|
||||
hint.textContent = 'CV-сервер недоступен. Можно добавить колонии вручную.';
|
||||
notify(error.message.includes('fetch') ? 'Запустите локальный сервер: .venv/bin/python server.py' : error.message);
|
||||
}
|
||||
}
|
||||
function load(file) {
|
||||
if (!file) return;
|
||||
if (!file.type.startsWith('image/')) return notify('Выберите изображение в формате PNG, JPG или WEBP.');
|
||||
if (file.size > 10 * 1024 * 1024) return notify('Файл больше допустимого размера — 10 МБ.');
|
||||
currentFile = file;
|
||||
const reader = new FileReader(); empty.textContent = 'Подготовка изображения…';
|
||||
reader.onload = () => { const image = new Image(); image.onload = () => { source = image; zoom = 1; autoDetect(); dropZone.classList.add('is-hidden'); analysis.classList.remove('is-hidden'); empty.remove(); document.querySelector('#file-name').textContent = file.name; document.querySelector('#file-size').textContent = formatSize(file.size); document.querySelector('#file-dimensions').textContent = `${image.width} × ${image.height}`; const chip = document.querySelector('#image-status'); chip.textContent = 'Загружено'; chip.className = 'status-chip ready'; document.querySelector('#replace-image').disabled = false; updateResult(); draw(); notify(markers.length ? `Найдено ${markers.length} контрастных объектов. Проверьте разметку.` : 'Изображение загружено. Добавьте колонии вручную.'); }; image.src = reader.result; }; reader.readAsDataURL(file);
|
||||
}
|
||||
document.querySelector('#choose-file').addEventListener('click', () => input.click());
|
||||
document.querySelector('#replace-image').addEventListener('click', () => input.click());
|
||||
input.addEventListener('change', event => load(event.target.files[0]));
|
||||
['dragenter','dragover'].forEach(type => dropZone.addEventListener(type, event => { event.preventDefault(); dropZone.classList.add('dragover'); }));
|
||||
['dragleave','drop'].forEach(type => dropZone.addEventListener(type, event => { event.preventDefault(); dropZone.classList.remove('dragover'); }));
|
||||
dropZone.addEventListener('drop', event => load(event.dataTransfer.files[0]));
|
||||
canvas.addEventListener('click', addMarker);
|
||||
document.querySelectorAll('.mode').forEach(button => button.addEventListener('click', () => { mode = button.dataset.mode; document.querySelectorAll('.mode').forEach(item => item.classList.toggle('active', item === button)); canvas.style.cursor = mode === 'remove' ? 'not-allowed' : mode === 'pan' ? 'grab' : 'crosshair'; }));
|
||||
document.querySelector('#zoom-in').addEventListener('click', () => { zoom = Math.min(2, zoom + .2); draw(); });
|
||||
document.querySelector('#zoom-out').addEventListener('click', () => { zoom = Math.max(.6, zoom - .2); draw(); });
|
||||
document.querySelector('#show-labels').addEventListener('change', event => { labels = event.target.checked; draw(); });
|
||||
document.querySelector('#auto-detect').addEventListener('change', event => { if (event.target.checked && source) autoDetect(); });
|
||||
document.querySelector('#reset-markers').addEventListener('click', () => { markers = []; updateResult(); draw(); notify('Все маркеры удалены.'); });
|
||||
document.querySelector('#save-result').addEventListener('click', () => notify(source ? `Результат: ${markers.length} колоний сохранён в демо-режиме.` : 'Сначала загрузите изображение чашки.'));
|
||||
document.querySelector('#to-cfu').addEventListener('click', () => notify(`В CFU / мл передано значение: ${markers.length} колоний.`));
|
||||
window.addEventListener('resize', () => draw());
|
||||
@@ -0,0 +1,6 @@
|
||||
Flask==3.1.3
|
||||
joblib==1.5.3
|
||||
numpy==2.5.1
|
||||
Pillow==12.3.0
|
||||
scikit-learn==1.9.0
|
||||
scipy==1.18.0
|
||||
@@ -0,0 +1,61 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from pathlib import Path
|
||||
|
||||
import joblib
|
||||
import numpy as np
|
||||
from flask import Flask, jsonify, request, send_from_directory
|
||||
|
||||
from petri_cv import candidate_points, features_for_points, prepare_image
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
MODEL_PATH = ROOT / "models/petri_candidate_classifier.joblib"
|
||||
app = Flask(__name__)
|
||||
model_bundle = joblib.load(MODEL_PATH) if MODEL_PATH.exists() else None
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def index():
|
||||
return send_from_directory(ROOT, "index.html")
|
||||
|
||||
|
||||
@app.get("/<path:path>")
|
||||
def assets(path: str):
|
||||
return send_from_directory(ROOT, path)
|
||||
|
||||
|
||||
@app.post("/api/analyze")
|
||||
def analyze():
|
||||
if model_bundle is None:
|
||||
return jsonify(error="Модель не обучена. Запустите train_petri_model.py."), 503
|
||||
uploaded = request.files.get("image")
|
||||
if uploaded is None or not uploaded.mimetype.startswith("image/"):
|
||||
return jsonify(error="Передайте изображение PNG, JPG или WEBP."), 400
|
||||
try:
|
||||
prepared = prepare_image(io.BytesIO(uploaded.read()))
|
||||
except Exception:
|
||||
return jsonify(error="Не удалось прочитать изображение."), 400
|
||||
points = candidate_points(prepared)
|
||||
if not points:
|
||||
return jsonify(colonies=[], candidates=0, model="RandomForest candidate ranker")
|
||||
probabilities = model_bundle["model"].predict_proba(features_for_points(prepared, points))[:, 1]
|
||||
ranked = sorted(zip(probabilities, points), reverse=True)
|
||||
selected = []
|
||||
# Non-maximum suppression avoids several markers on a single colony.
|
||||
for score, (x, y) in ranked:
|
||||
if score < .54:
|
||||
continue
|
||||
if all((x - candidate["x_px"]) ** 2 + (y - candidate["y_px"]) ** 2 > 36 ** 2 for candidate in selected):
|
||||
selected.append({"x": round(x / prepared.gray.shape[1], 5), "y": round(y / prepared.gray.shape[0], 5),
|
||||
"x_px": x, "y_px": y, "score": round(float(score), 3)})
|
||||
if len(selected) >= 300:
|
||||
break
|
||||
for item in selected:
|
||||
item.pop("x_px")
|
||||
item.pop("y_px")
|
||||
return jsonify(colonies=selected, candidates=len(points), model="RandomForest candidate ranker")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="127.0.0.1", port=8000, debug=False)
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import joblib
|
||||
import numpy as np
|
||||
from sklearn.ensemble import RandomForestClassifier
|
||||
from sklearn.metrics import precision_recall_fscore_support
|
||||
|
||||
from petri_cv import add_annotation_centers, candidate_points, features_for_points, label_candidates, prepare_image
|
||||
|
||||
DATASET = Path("AGAR_representative")
|
||||
MODEL_PATH = Path("models/petri_candidate_classifier.joblib")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
features, targets = [], []
|
||||
annotations = sorted(DATASET.rglob("*.json"))
|
||||
for annotation_path in annotations:
|
||||
data = json.loads(annotation_path.read_text())
|
||||
image_path = annotation_path.with_suffix(".jpg")
|
||||
if not image_path.exists():
|
||||
continue
|
||||
image = prepare_image(image_path)
|
||||
points = add_annotation_centers(candidate_points(image), data["labels"], image.scale)
|
||||
labels = label_candidates(points, data["labels"], image.scale)
|
||||
# keep a useful but not overwhelming background-to-colony ratio
|
||||
positive = np.where(labels == 1)[0]
|
||||
negative = np.where(labels == 0)[0]
|
||||
rng = np.random.default_rng(abs(hash(annotation_path.stem)) % (2**32))
|
||||
negative = rng.choice(negative, size=min(len(negative), max(50, len(positive) * 5)), replace=False)
|
||||
selected = np.concatenate([positive, negative])
|
||||
matrix = features_for_points(image, [points[index] for index in selected])
|
||||
features.append(matrix)
|
||||
targets.append(labels[selected])
|
||||
print(f"{image_path.name}: {len(positive)} positives, {len(negative)} negatives")
|
||||
x = np.vstack(features)
|
||||
y = np.concatenate(targets)
|
||||
model = RandomForestClassifier(
|
||||
n_estimators=280, max_depth=14, min_samples_leaf=2, class_weight="balanced_subsample", n_jobs=-1, random_state=42
|
||||
)
|
||||
model.fit(x, y)
|
||||
predicted = model.predict(x)
|
||||
precision, recall, f1, _ = precision_recall_fscore_support(y, predicted, average="binary", zero_division=0)
|
||||
MODEL_PATH.parent.mkdir(exist_ok=True)
|
||||
joblib.dump({"model": model, "feature_version": 1}, MODEL_PATH)
|
||||
print(f"Saved {MODEL_PATH}; training candidate precision={precision:.3f}, recall={recall:.3f}, f1={f1:.3f}; rows={len(y)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user