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());