103 lines
8.3 KiB
JavaScript
103 lines
8.3 KiB
JavaScript
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());
|