Files

103 lines
8.3 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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());