diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..75a7044 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,6 @@ +# AI-Fibrosis Project Instructions + +## Project Structure +- **Frontend:** Located in `/NAFLD/javascript/nafld-app/`. Built with React. +- **Backend:** Located in `/NAFLD/src/py-src/`. Built with Flask. +- **AI Models:** Located in `/NAFLD/src/py-src/models/`. \ No newline at end of file diff --git a/.gitignore b/.gitignore index 0248d26..dad7ce2 100644 --- a/.gitignore +++ b/.gitignore @@ -169,4 +169,11 @@ cython_debug/ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ \ No newline at end of file +#.idea/ + +# Project-specific ignores +Reduc/ +PCAtests/ +ClusterDiagnostic/ +NAFLD/src/py-src/users.db +NAFLD/src/py-src/.jwt_secret \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..6f3a291 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "liveServer.settings.port": 5501 +} \ No newline at end of file diff --git a/NAFLD/javascript/nafld-app/public/faviconliver.png b/NAFLD/javascript/nafld-app/public/faviconliver.png new file mode 100644 index 0000000..13aee99 Binary files /dev/null and b/NAFLD/javascript/nafld-app/public/faviconliver.png differ diff --git a/NAFLD/javascript/nafld-app/public/index.html b/NAFLD/javascript/nafld-app/public/index.html index 8d1e67f..b7cc747 100644 --- a/NAFLD/javascript/nafld-app/public/index.html +++ b/NAFLD/javascript/nafld-app/public/index.html @@ -2,14 +2,14 @@ - + - + - FibroAi + fibrosisai diff --git a/NAFLD/javascript/nafld-app/public/manifest.json b/NAFLD/javascript/nafld-app/public/manifest.json index 080d6c7..4cfa098 100644 --- a/NAFLD/javascript/nafld-app/public/manifest.json +++ b/NAFLD/javascript/nafld-app/public/manifest.json @@ -1,21 +1,11 @@ { - "short_name": "React App", - "name": "Create React App Sample", + "short_name": "fibrosisai", + "name": "fibrosisai", "icons": [ { - "src": "favicon.ico", + "src": "faviconliver.png", "sizes": "64x64 32x32 24x24 16x16", - "type": "image/x-icon" - }, - { - "src": "logo192.png", - "type": "image/png", - "sizes": "192x192" - }, - { - "src": "logo512.png", - "type": "image/png", - "sizes": "512x512" + "type": "image/png" } ], "start_url": ".", diff --git a/NAFLD/javascript/nafld-app/src/App.js b/NAFLD/javascript/nafld-app/src/App.js index c00c46a..77313f3 100644 --- a/NAFLD/javascript/nafld-app/src/App.js +++ b/NAFLD/javascript/nafld-app/src/App.js @@ -1,39 +1,86 @@ -import { useEffect, useState } from "react"; +import { useState, useCallback } from "react"; import ImageSubmission from "./ImageSubmission"; -import FileUploader from "./FileUploader"; +import Login from "./Login"; function App() { - const [data, setData] = useState({}) - - useEffect(() => { - fetch("http://127.0.0.1:5000/home").then( - res => { - console.log(res) - return res.json() - } - ) - .then( - data => { - setData(data) - console.log(data) - } - ).catch(err => console.log(err)) + const [user, setUser] = useState(() => sessionStorage.getItem("username")); + + const handleLogin = useCallback((username) => setUser(username), []); + + const handleLogout = useCallback(() => { + sessionStorage.removeItem("access_token"); + sessionStorage.removeItem("refresh_token"); + sessionStorage.removeItem("username"); + setUser(null); }, []); + if (!user) { + return ; + } + return ( -
-
- Logo Left - Logo middle - Logo Right - {/* */} +
+ {/* ── Top bar ── */} +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + fibrosisai +
+ + AI-BASED UNSUPERVISED CLASSIFICATION AND QUANTIFICATION OF LIVER FIBROSIS + + + VGG16 FEATURE EXTRACTION → FCM CLUSTERING + +
+ + {user} + +
-

- FibroAi -

-
- -
+ + {/* ── Main content ── */} + + + {/* ── Footer logos ── */} +
+ McMaster + ICE Lab + Heersink +
); } diff --git a/NAFLD/javascript/nafld-app/src/FileUploader.js b/NAFLD/javascript/nafld-app/src/FileUploader.js index 18db903..abb64a8 100644 --- a/NAFLD/javascript/nafld-app/src/FileUploader.js +++ b/NAFLD/javascript/nafld-app/src/FileUploader.js @@ -212,7 +212,7 @@ const FileUploader = () => { diff --git a/NAFLD/javascript/nafld-app/src/ImageSubmission.js b/NAFLD/javascript/nafld-app/src/ImageSubmission.js index 3666235..8a0cf34 100644 --- a/NAFLD/javascript/nafld-app/src/ImageSubmission.js +++ b/NAFLD/javascript/nafld-app/src/ImageSubmission.js @@ -1,95 +1,2291 @@ -import { useState } from "react"; +import { useState, useRef, useCallback, useEffect } from "react"; +import { createPortal } from "react-dom"; + +const API_BASE = process.env.REACT_APP_API_URL || "http://127.0.0.1:5000"; +const RESULT_BANK_KEY = "fibrosisai_session_result_bank"; +const MAX_RESULT_BANK_SIZE = 10; + +const CLASS_SCORE_COLUMNS = [ + { key: 'None', label: 'None (F0)' }, + { key: 'Perisinusoidal', label: 'Periportal (F1)' }, + { key: 'Bridging', label: 'Bridging (F3)' }, + { key: 'Cirrosis', label: 'Cirrhosis (F4)' }, +]; + +const csvEscape = (value) => { + const text = value === null || value === undefined ? '' : String(value); + return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text; +}; + +const formatPercent = (value, digits = 2) => { + const number = Number(value); + return Number.isFinite(number) ? `${number.toFixed(digits)}%` : ''; +}; + +const formatThreshold = (value) => { + const number = Number(value); + return Number.isFinite(number) ? number.toFixed(3) : ''; +}; + +const getDominantClassification = (scores) => { + if (!scores) return ''; + let best = null; + CLASS_SCORE_COLUMNS.forEach((column) => { + const score = Number(scores[column.key] || 0); + if (!best || score > best.score) best = { ...column, score }; + }); + return best ? best.label : ''; +}; + +const getResultIdentity = (record) => record?.uploaded_filename || record?.image_name || ''; + +const buildResultsCsv = (records) => { + const headers = [ + 'image_name', + 'uploaded_filename', + 'saved_at', + 'extent_percentage', + 'current_threshold', + 'ai_threshold', + 'local_area_edits', + 'primary_classification', + ...CLASS_SCORE_COLUMNS.map(column => column.label), + ]; + + const rows = records.map((record) => [ + record.image_name, + record.uploaded_filename, + record.saved_at, + formatPercent(record.extent_percentage), + formatThreshold(record.current_threshold), + formatThreshold(record.ai_threshold), + record.has_local_edits ? 'yes' : 'no', + record.primary_classification, + ...CLASS_SCORE_COLUMNS.map(column => formatPercent((record.membership_scores?.[column.key] || 0) * 100, 0)), + ]); + + return [headers, ...rows].map(row => row.map(csvEscape).join(',')).join('\n'); +}; + +const downloadCsv = (filename, records) => { + const blob = new Blob([buildResultsCsv(records)], { type: 'text/csv;charset=utf-8;' }); + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', filename); + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(url); +}; + +const loadStoredResultBank = () => { + try { + const parsed = JSON.parse(sessionStorage.getItem(RESULT_BANK_KEY) || '[]'); + return Array.isArray(parsed) ? parsed.slice(0, MAX_RESULT_BANK_SIZE) : []; + } catch { + return []; + } +}; + +// ── Auth: transparent access-token refresh ────────────────────────── +// The backend issues short-lived (30 min) access tokens and longer-lived +// (7 day) refresh tokens. Previously the app would log out on the first +// 401 after expiry; now we transparently call /refresh and retry. + +const _decodeJwt = (token) => { + try { + const payload = token.split('.')[1]; + const json = atob(payload.replace(/-/g, '+').replace(/_/g, '/')); + return JSON.parse(json); + } catch { return null; } +}; + +const _msUntilExpiry = (token) => { + const p = _decodeJwt(token); + if (!p || !p.exp) return 0; + return p.exp * 1000 - Date.now(); +}; + +// In-flight refresh promise so concurrent requests share one /refresh call. +let _refreshPromise = null; + +const refreshAccessToken = () => { + if (_refreshPromise) return _refreshPromise; + const rt = sessionStorage.getItem("refresh_token"); + if (!rt) return Promise.resolve(null); + _refreshPromise = fetch(`${API_BASE}/refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh_token: rt }), + }) + .then(async (r) => { + if (!r.ok) return null; + const data = await r.json().catch(() => null); + if (data && data.access_token) { + sessionStorage.setItem("access_token", data.access_token); + if (data.username) sessionStorage.setItem("username", data.username); + return data.access_token; + } + return null; + }) + .catch(() => null) + .finally(() => { _refreshPromise = null; }); + return _refreshPromise; +}; + +/** Return a usable access token, refreshing first if it expires within minMs. */ +const ensureFreshToken = async (minMs = 60000) => { + const t = sessionStorage.getItem("access_token"); + if (t && _msUntilExpiry(t) > minMs) return t; + const refreshed = await refreshAccessToken(); + return refreshed || t || null; +}; + +const _sessionExpired = () => { + sessionStorage.removeItem("access_token"); + sessionStorage.removeItem("refresh_token"); + sessionStorage.removeItem("username"); + window.location.reload(); +}; + +/** Authenticated fetch with one-shot refresh + retry on 401. */ +const apiFetch = async (url, options = {}) => { + const doFetch = (token) => { + const headers = { ...(options.headers || {}) }; + if (token) headers.Authorization = `Bearer ${token}`; + return fetch(url, { ...options, headers }); + }; + let token = await ensureFreshToken(); + let res = await doFetch(token); + if (res.status === 401) { + const newToken = await refreshAccessToken(); + if (!newToken) { _sessionExpired(); throw new Error('Session expired'); } + res = await doFetch(newToken); + if (res.status === 401) { _sessionExpired(); throw new Error('Session expired'); } + } + return res; +}; + +/** Build an authenticated SSE URL (refreshing token first if needed). */ +const buildSseUrl = async (path) => { + // Ensure plenty of margin (2 min) since SSE streams are long-lived. + const token = await ensureFreshToken(120000); + if (!token) { _sessionExpired(); throw new Error('Session expired'); } + const sep = path.includes('?') ? '&' : '?'; + return `${API_BASE}${path}${sep}token=${encodeURIComponent(token)}`; +}; + const ImageSubmission = () => { const [image, setSelectedImage] = useState(null); - const handleSubmit = async (event) => { - event.preventDefault(); - console.log("SUBMITTED") - if (!image) { - alert("Please select an image to upload."); + const [uploadedFilename, setUploadedFilename] = useState(""); + const [previewResult, setPreviewResult] = useState(null); + const [analysisResult, setAnalysisResult] = useState(null); + const [unetImage, setUnetImage] = useState(null); + const [unetUploadedFilename, setUnetUploadedFilename] = useState(""); + const [unetPreviewResult, setUnetPreviewResult] = useState(null); + const [isUploading, setIsUploading] = useState(false); + const [isAnalyzing, setIsAnalyzing] = useState(false); + const [errorMessage, setErrorMessage] = useState(""); + const [isDragging, setIsDragging] = useState(false); + const [uploadProgress, setUploadProgress] = useState(0); + const [patchProgress, setPatchProgress] = useState(null); // { current, total, tissue_patches } + const fileInputRef = useRef(null); + const originalImgRef = useRef(null); + const filteredImgRef = useRef(null); + const [imgContentRect, setImgContentRect] = useState(null); // { left, top, width, height } + + // Threshold slider state + const [autoThreshold, setAutoThreshold] = useState(null); // original AI threshold + const [userThreshold, setUserThreshold] = useState(null); // current slider value + const [adjustedRatio, setAdjustedRatio] = useState(null); // ratio from rethreshold + const [adjustedMask, setAdjustedMask] = useState(null); // base64 mask from rethreshold + const [isRethresholding, setIsRethresholding] = useState(false); + const [thresholdDraft, setThresholdDraft] = useState(""); + const rethresholdTimer = useRef(null); + + const [resultBank, setResultBank] = useState(loadStoredResultBank); + const [showResultBank, setShowResultBank] = useState(false); + const [bankMessage, setBankMessage] = useState(""); + + // Magnifier state + const [magnifier, setMagnifier] = useState(null); // { x, y } normalised 0-1 + const [magnifierZoom, setMagnifierZoom] = useState(1); // default: no zoom + const MIN_ZOOM = 1; + const MAX_ZOOM = 6; + const ZOOM_STEP = 0.5; + const [hasLocalEdits, setHasLocalEdits] = useState(false); + const [showResetConfirm, setShowResetConfirm] = useState(false); + const pendingResetAction = useRef(null); + const areaDeltaTimer = useRef(null); + const pendingAreaDelta = useRef(0); + const [deltaMap, setDeltaMap] = useState(null); // base64 PNG of modified areas + const [showHelp, setShowHelp] = useState(false); + + // Classification from refined mask — independent per algorithm tab. + // The two pipelines (classic threshold + U-Net Round 1) each carry + // their own classification result, progress, worst-patch overlay, etc. + const [classifyByAlgo, setClassifyByAlgo] = useState({ + classic: { result: null, isClassifying: false, progress: null, tileGrid: null, worstPatches: null, showWorst: false, maskChanged: false, error: '' }, + unet: { result: null, isClassifying: false, progress: null, tileGrid: null, worstPatches: null, showWorst: false, maskChanged: false, error: '' }, + }); + const updateClassifyAlgo = useCallback((algo, patchOrFn) => { + setClassifyByAlgo(prev => { + const cur = prev[algo]; + const patch = typeof patchOrFn === 'function' ? patchOrFn(cur) : patchOrFn; + return { ...prev, [algo]: { ...cur, ...patch } }; + }); + }, []); + const [filteredImgContentRect, setFilteredImgContentRect] = useState(null); + const classifyGridInfo = useRef({ classic: null, unet: null }); + + // Excluded-pixels overlay (shows what is removed from the extent denominator) + const [showExcluded, setShowExcluded] = useState(false); + const [excludedOverlay, setExcludedOverlay] = useState(null); + const [isLoadingExcluded, setIsLoadingExcluded] = useState(false); + + // Algorithm toggle: 'classic' = legacy threshold pipeline, 'unet' = Tiny U-Net (Round 1) + const [algorithm, setAlgorithm] = useState('classic'); + const [unetResult, setUnetResult] = useState(null); // { fibrosis_ratio, heatmap_image } + const [isAnalyzingUnet, setIsAnalyzingUnet] = useState(false); + const [unetPatchProgress, setUnetPatchProgress] = useState(null); + const [unetError, setUnetError] = useState(''); + // U-Net threshold adjustment (parallel to classic auto/userThreshold). + // The U-Net baseline is fixed at 0.5 (matches inference.run_inference default). + const UNET_BASELINE_THRESHOLD = 0.5; + const [unetUserThreshold, setUnetUserThreshold] = useState(null); + const [unetAdjustedRatio, setUnetAdjustedRatio] = useState(null); + const [unetThresholdDraft, setUnetThresholdDraft] = useState(''); + const [isRethresholdingUnet, setIsRethresholdingUnet] = useState(false); + const unetRethresholdTimer = useRef(null); + + // Area inspection (Q key while magnifier active): kicks off a fast + // extent + classify on the region under the lens. Auto-cancels + // when the cursor leaves the locked region. + const [areaExtent, setAreaExtent] = useState(null); // { state, ratio } + const [areaClassify, setAreaClassify] = useState(null); // { state, scores, label } + const areaInspectRef = useRef({ region: null, abort: null, panelPos: null }); + + const cancelAreaInspect = useCallback(() => { + const cur = areaInspectRef.current; + if (cur.abort) { try { cur.abort.abort(); } catch (e) {} } + areaInspectRef.current = { region: null, abort: null, panelPos: null }; + setAreaExtent(null); + setAreaClassify(null); + }, []); + + const activeImage = algorithm === 'unet' ? unetImage : image; + const activeUploadedFilename = algorithm === 'unet' ? unetUploadedFilename : uploadedFilename; + const displayedResult = algorithm === 'unet' ? unetPreviewResult : (analysisResult || previewResult); + + // Active classification slice for the currently-selected algorithm tab. + // Bare-name aliases keep the rest of the JSX/callbacks readable. + const _curCls = classifyByAlgo[algorithm]; + const classificationResult = _curCls.result; + const isClassifying = _curCls.isClassifying; + const classifyProgress = _curCls.progress; + const classifyTileGrid = _curCls.tileGrid; + const worstPatchCoords = _curCls.worstPatches; + const showWorstPatches = _curCls.showWorst; + const maskChangedSinceClassify = _curCls.maskChanged; + const classificationError = _curCls.error; + + // Compute where the object-fit:contain image actually renders inside the panel + const computeImgRect = useCallback(() => { + const img = originalImgRef.current; + if (!img || !img.naturalWidth) return; + const container = img.parentElement; + const cw = container.clientWidth; + const ch = container.clientHeight; + const nw = img.naturalWidth; + const nh = img.naturalHeight; + const scale = Math.min(cw / nw, ch / nh); + const rw = nw * scale; + const rh = nh * scale; + setImgContentRect({ + left: (cw - rw) / 2, + top: (ch - rh) / 2, + width: rw, + height: rh, + }); + }, []); + + // Compute where the filtered image renders inside its panel + const computeFilteredImgRect = useCallback(() => { + const img = filteredImgRef.current; + if (!img || !img.naturalWidth) return; + const container = img.parentElement; + const cw = container.clientWidth; + const ch = container.clientHeight; + const nw = img.naturalWidth; + const nh = img.naturalHeight; + const scale = Math.min(cw / nw, ch / nh); + const rw = nw * scale; + const rh = nh * scale; + setFilteredImgContentRect({ + left: (cw - rw) / 2, + top: (ch - rh) / 2, + width: rw, + height: rh, + }); + }, []); + + // Recompute on resize + useEffect(() => { + const img = originalImgRef.current; + if (!img) return; + const ro = new ResizeObserver(computeImgRect); + ro.observe(img.parentElement); + return () => ro.disconnect(); + }, [computeImgRect, displayedResult]); + + // Recompute filtered image rect on resize + useEffect(() => { + const img = filteredImgRef.current; + if (!img) return; + const ro = new ResizeObserver(computeFilteredImgRect); + ro.observe(img.parentElement); + return () => ro.disconnect(); + }, [computeFilteredImgRect, displayedResult, adjustedMask]); + + useEffect(() => { + if (userThreshold === null || userThreshold === undefined) { + setThresholdDraft(""); return; } - console.log(image) - const formData = new FormData(); - formData.append('file', image); - // formData.append('upload_preset', "default-preset"); + setThresholdDraft(formatThreshold(userThreshold)); + }, [userThreshold]); + + useEffect(() => { + sessionStorage.setItem(RESULT_BANK_KEY, JSON.stringify(resultBank)); + }, [resultBank]); + // When analysis completes, pick up the auto threshold + useEffect(() => { + if (analysisResult?.threshold !== undefined) { + setAutoThreshold(analysisResult.threshold); + setUserThreshold(analysisResult.threshold); + setAdjustedRatio(null); + setAdjustedMask(null); + setHasLocalEdits(false); + setDeltaMap(null); + } + }, [analysisResult]); + + // Debounced rethreshold call (global — resets local edits) + const handleThresholdChange = useCallback((value) => { + const nextThreshold = Math.max(0, Number(value)); + if (!Number.isFinite(nextThreshold)) return; + setUserThreshold(nextThreshold); + if (rethresholdTimer.current) clearTimeout(rethresholdTimer.current); + rethresholdTimer.current = setTimeout(async () => { + if (!uploadedFilename) return; + setIsRethresholding(true); + try { + const res = await apiFetch( + `${API_BASE}/rethreshold/${encodeURIComponent(uploadedFilename)}?threshold=${nextThreshold}` + ); + if (res.ok) { + const data = await res.json(); + setAdjustedRatio(data.fibrosis_ratio); + setAdjustedMask(data.filtered_image); + setHasLocalEdits(false); + setDeltaMap(null); + updateClassifyAlgo('classic', { maskChanged: true, showWorst: false, error: '' }); + } + } catch (e) { + console.error('Rethreshold error:', e); + } finally { + setIsRethresholding(false); + } + }, 150); + }, [uploadedFilename]); + + // U-Net threshold draft <-> userThreshold sync + useEffect(() => { + if (unetUserThreshold === null || unetUserThreshold === undefined) { + setUnetThresholdDraft(''); + return; + } + setUnetThresholdDraft(formatThreshold(unetUserThreshold)); + }, [unetUserThreshold]); + + // Initialise U-Net user threshold once a result is available + useEffect(() => { + if (unetResult && unetUserThreshold === null) { + const baseline = unetResult.baseline_threshold ?? UNET_BASELINE_THRESHOLD; + setUnetUserThreshold(baseline); + } + }, [unetResult, unetUserThreshold]); + + // Debounced re-threshold for the U-Net pipeline. Re-evaluates the + // cached probability map at a new threshold without re-running inference. + const handleUnetThresholdChange = useCallback((value) => { + const nextThreshold = Math.max(0, Number(value)); + if (!Number.isFinite(nextThreshold)) return; + setUnetUserThreshold(nextThreshold); + if (unetRethresholdTimer.current) clearTimeout(unetRethresholdTimer.current); + unetRethresholdTimer.current = setTimeout(async () => { + if (!unetUploadedFilename) return; + setIsRethresholdingUnet(true); + try { + const res = await apiFetch( + `${API_BASE}/rethreshold-unet/${encodeURIComponent(unetUploadedFilename)}?threshold=${nextThreshold}` + ); + if (res.ok) { + const data = await res.json(); + setUnetAdjustedRatio(data.fibrosis_ratio); + setUnetResult(prev => prev ? { ...prev, heatmap_image: data.heatmap_image } : prev); + updateClassifyAlgo('unet', { maskChanged: true, showWorst: false, error: '' }); + } + } catch (e) { + console.error('U-Net rethreshold error:', e); + } finally { + setIsRethresholdingUnet(false); + } + }, 150); + }, [unetUploadedFilename, updateClassifyAlgo]); + + const handleResetUnetThreshold = useCallback(() => { + if (!unetUploadedFilename) return; + if (unetRethresholdTimer.current) clearTimeout(unetRethresholdTimer.current); + setUnetUserThreshold(UNET_BASELINE_THRESHOLD); + setUnetAdjustedRatio(null); + setIsRethresholdingUnet(true); + apiFetch(`${API_BASE}/rethreshold-unet/${encodeURIComponent(unetUploadedFilename)}?threshold=${UNET_BASELINE_THRESHOLD}`) + .then(res => res.ok ? res.json() : Promise.reject(new Error(`HTTP ${res.status}`))) + .then(data => { + setUnetResult(prev => prev ? { ...prev, heatmap_image: data.heatmap_image, fibrosis_ratio: data.fibrosis_ratio } : prev); + updateClassifyAlgo('unet', { maskChanged: true, showWorst: false, error: '' }); + }) + .catch(e => console.error('U-Net threshold reset error:', e)) + .finally(() => setIsRethresholdingUnet(false)); + }, [unetUploadedFilename, updateClassifyAlgo]); + + // Magnifier: compute normalised position from mouse event on an img-panel + // Only activates when cursor is over the actual rendered image content + const handlePanelMouseMove = useCallback((e) => { + const panel = e.currentTarget; + const rect = panel.getBoundingClientRect(); + const img = panel.querySelector('img.preview-image'); + if (img && img.naturalWidth) { + const cw = rect.width; + const ch = rect.height; + const nw = img.naturalWidth; + const nh = img.naturalHeight; + const scale = Math.min(cw / nw, ch / nh); + const rw = nw * scale; + const rh = nh * scale; + const offsetX = (cw - rw) / 2; + const offsetY = (ch - rh) / 2; + const mouseX = e.clientX - rect.left; + const mouseY = e.clientY - rect.top; + if (mouseX < offsetX || mouseX > offsetX + rw || mouseY < offsetY || mouseY > offsetY + rh) { + setMagnifier(null); + return; + } + } + const x = (e.clientX - rect.left) / rect.width; + const y = (e.clientY - rect.top) / rect.height; + const newPos = { x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) }; + // While an area inspection is locked, freeze the lens at the pinned + // position. Only cancel (and resume tracking) once the cursor + // wanders well outside the lens footprint. + const inspect = areaInspectRef.current; + if (inspect.panelPos) { + const dx = Math.abs(newPos.x - inspect.panelPos.x); + const dy = Math.abs(newPos.y - inspect.panelPos.y); + if (dx > 0.08 || dy > 0.08) { + cancelAreaInspect(); + setMagnifier(newPos); + } + return; + } + setMagnifier(newPos); + }, [cancelAreaInspect]); + const handlePanelMouseLeave = useCallback(() => { + cancelAreaInspect(); + setMagnifier(null); + }, [cancelAreaInspect]); + + // Compute the normalised region visible in the magnifier + const MAGNIFIER_SIZE = 160; + const getMagnifierRegion = useCallback(() => { + if (!magnifier) return null; + const img = originalImgRef.current; + if (!img) return null; + const container = img.parentElement; + if (!container) return null; + const cw = container.clientWidth; + const ch = container.clientHeight; + const nw = img.naturalWidth || cw; + const nh = img.naturalHeight || ch; + const scale = Math.min(cw / nw, ch / nh); + const rw = nw * scale; + const rh = nh * scale; + const posX = magnifier.x * cw; + const posY = magnifier.y * ch; + const offsetX = (cw - rw) / 2; + const offsetY = (ch - rh) / 2; + const imgX = (posX - offsetX) / rw; + const imgY = (posY - offsetY) / rh; + if (imgX < 0 || imgX > 1 || imgY < 0 || imgY > 1) return null; + const halfW = MAGNIFIER_SIZE / (2 * rw * magnifierZoom); + const halfH = MAGNIFIER_SIZE / (2 * rh * magnifierZoom); + return { + x1: Math.max(0, imgX - halfW), + y1: Math.max(0, imgY - halfH), + x2: Math.min(1, imgX + halfW), + y2: Math.min(1, imgY + halfH), + }; + }, [magnifier, magnifierZoom]); + + // Debounced local area delta rethreshold (per-pixel) + const handleLocalDeltaChange = useCallback((region) => { + if (algorithm !== 'classic') return; + if (areaDeltaTimer.current) clearTimeout(areaDeltaTimer.current); + areaDeltaTimer.current = setTimeout(async () => { + if (!uploadedFilename || !region) return; + const delta = pendingAreaDelta.current; + pendingAreaDelta.current = 0; + if (delta === 0) return; + setIsRethresholding(true); + try { + const params = new URLSearchParams({ + delta, + x1: region.x1, y1: region.y1, + x2: region.x2, y2: region.y2, + }); + const res = await apiFetch( + `${API_BASE}/rethreshold-area/${encodeURIComponent(uploadedFilename)}?${params}` + ); + if (res.ok) { + const data = await res.json(); + setAdjustedRatio(data.fibrosis_ratio); + setAdjustedMask(data.filtered_image); + setHasLocalEdits(true); + if (data.delta_map) setDeltaMap(data.delta_map); + updateClassifyAlgo('classic', { maskChanged: true, showWorst: false, error: '' }); + } + } catch (e) { + console.error('Rethreshold area error:', e); + } finally { + setIsRethresholding(false); + } + }, 150); + }, [algorithm, uploadedFilename]); + + // Confirmation overlay helpers + const requestConfirmReset = useCallback((action) => { + pendingResetAction.current = action; + setShowResetConfirm(true); + }, []); + const handleConfirmYes = useCallback(() => { + setShowResetConfirm(false); + if (pendingResetAction.current) { + pendingResetAction.current(); + pendingResetAction.current = null; + } + }, []); + const handleConfirmNo = useCallback(() => { + setShowResetConfirm(false); + pendingResetAction.current = null; + }, []); + + const commitThresholdValue = useCallback((rawValue) => { + const parsed = Number(rawValue); + if (!Number.isFinite(parsed)) { + setThresholdDraft(userThreshold === null || userThreshold === undefined ? "" : formatThreshold(userThreshold)); + return; + } + const nextThreshold = Math.max(0, parsed); + if (userThreshold !== null && Math.abs(nextThreshold - Number(userThreshold)) < 1e-9) { + setThresholdDraft(formatThreshold(userThreshold)); + return; + } + const applyThreshold = () => handleThresholdChange(nextThreshold); + if (hasLocalEdits) { + requestConfirmReset(applyThreshold); + } else { + applyThreshold(); + } + }, [handleThresholdChange, hasLocalEdits, requestConfirmReset, userThreshold]); + + // Reset area under observation to original AI threshold (Ctrl+R) + const handleResetArea = useCallback(async () => { + if (algorithm !== 'classic') return; + const region = getMagnifierRegion(); + if (!region || !uploadedFilename) return; + setIsRethresholding(true); + try { + const params = new URLSearchParams({ + x1: region.x1, y1: region.y1, + x2: region.x2, y2: region.y2, + }); + const res = await apiFetch( + `${API_BASE}/reset-area/${encodeURIComponent(uploadedFilename)}?${params}` + ); + if (res.ok) { + const data = await res.json(); + setAdjustedRatio(data.fibrosis_ratio); + setAdjustedMask(data.filtered_image); + setHasLocalEdits(data.has_local_edits); + setDeltaMap(data.delta_map); + updateClassifyAlgo('classic', { maskChanged: true, showWorst: false, error: '' }); + } + } catch (e) { + console.error('Reset area error:', e); + } finally { + setIsRethresholding(false); + } + }, [algorithm, uploadedFilename, getMagnifierRegion]); + + // Undo last modified square (Ctrl+Z) + const handleUndoArea = useCallback(async () => { + if (algorithm !== 'classic') return; + if (!uploadedFilename) return; + setIsRethresholding(true); + try { + const res = await apiFetch( + `${API_BASE}/undo-area/${encodeURIComponent(uploadedFilename)}` + ); + if (res.ok) { + const data = await res.json(); + setAdjustedRatio(data.fibrosis_ratio); + setAdjustedMask(data.filtered_image); + setHasLocalEdits(data.has_local_edits); + setDeltaMap(data.delta_map); + updateClassifyAlgo('classic', { maskChanged: true, showWorst: false, error: '' }); + } + } catch (e) { + console.error('Undo area error:', e); + } finally { + setIsRethresholding(false); + } + }, [algorithm, uploadedFilename]); + + // Classify from refined mask (SSE streaming with tile progress). + // Algorithm-aware: classic uses /classify-mask/, U-Net uses /classify-mask-unet/. + // All state writes are scoped to the active algorithm tab so the two + // pipelines remain truly independent. + const handleClassifyMask = useCallback(async () => { + const algo = algorithm; + const filename = algo === 'unet' ? unetUploadedFilename : uploadedFilename; + if (!filename) { + updateClassifyAlgo(algo, { error: `Upload an image on the ${algo === 'unet' ? 'U-Net' : 'Classic'} page before diagnosing.` }); + return; + } + const endpoint = algo === 'unet' ? '/classify-mask-unet/' : '/classify-mask/'; + updateClassifyAlgo(algo, { + isClassifying: true, + result: null, + tileGrid: null, + progress: null, + worstPatches: null, + showWorst: false, + error: '', + }); + classifyGridInfo.current[algo] = null; + + const maxRetries = 3; + let lastError = null; + + for (let attempt = 0; attempt < maxRetries; attempt++) { + if (attempt > 0) { + await new Promise(r => setTimeout(r, 1000)); + updateClassifyAlgo(algo, { tileGrid: null, progress: null }); + } + try { + const result = await new Promise((resolve, reject) => { + let settled = false; + let evtSource = null; + buildSseUrl(`${endpoint}${encodeURIComponent(filename)}`) + .then((url) => { + evtSource = new EventSource(url); + evtSource.onmessage = (event) => { + try { + const msg = JSON.parse(event.data); + if (msg.type === 'progress') { + updateClassifyAlgo(algo, cur => { + const patch = { progress: { current: msg.current, total: msg.total, tissue_patches: msg.tissue_patches } }; + if (msg.grid_rows !== undefined) { + const r = msg.grid_rows, c = msg.grid_cols; + const prev = cur.tileGrid; + const tiles = prev && prev.tiles.length === r * c ? [...prev.tiles] : new Array(r * c).fill(0); + tiles[msg.tile_row * c + msg.tile_col] = msg.is_tissue ? 2 : 1; + patch.tileGrid = { rows: r, cols: c, tiles }; + } + return patch; + }); + } else if (msg.type === 'result') { + settled = true; + evtSource.close(); + resolve(msg.data); + } else if (msg.type === 'error') { + settled = true; + evtSource.close(); + reject(new Error(msg.error || 'Classification failed')); + } + } catch (e) { + if (!settled) { settled = true; evtSource.close(); reject(e); } + } + }; + evtSource.onerror = () => { + if (!settled) { settled = true; evtSource.close(); reject(new Error('Connection lost')); } + }; + }) + .catch((e) => { if (!settled) { settled = true; reject(e); } }); + }); + if (!result || result.status !== 'success') { + throw new Error(result?.message || result?.error || 'Classification failed'); + } + const patch = { result, maskChanged: false }; + if (result && result.worst_patches) { + patch.worstPatches = result.worst_patches; + classifyGridInfo.current[algo] = { + rows: result.grid_rows, cols: result.grid_cols, + imgH: result.img_h, imgW: result.img_w, + patchSize: result.patch_size || 512, + }; + } + updateClassifyAlgo(algo, patch); + lastError = null; + break; + } catch (e) { + lastError = e; + console.warn(`Classify attempt ${attempt + 1}/${maxRetries} failed:`, e.message); + } + } + if (lastError) { + console.error('Classify mask error after retries:', lastError); + setErrorMessage('Classification failed or connection lost.'); + updateClassifyAlgo(algo, { result: null, error: lastError.message || 'Classification failed or connection lost.' }); + } + updateClassifyAlgo(algo, { isClassifying: false, tileGrid: null, progress: null }); + }, [uploadedFilename, unetUploadedFilename, algorithm, updateClassifyAlgo]); + + // Toggle the green "excluded pixels" overlay on the original image. + // The overlay paints exactly the pixels that were dropped from the + // extent denominator (too dark or too white to be tissue). + const handleToggleExcluded = useCallback(async () => { + if (!uploadedFilename) return; + if (showExcluded) { + setShowExcluded(false); + return; + } + if (excludedOverlay) { + setShowExcluded(true); + return; + } + setIsLoadingExcluded(true); try { - console.log(formData); - const response = await fetch('http://127.0.0.1:5000/upload', { - method: 'POST', - body: formData + const res = await apiFetch(`${API_BASE}/preview-excluded/${encodeURIComponent(uploadedFilename)}`); + if (res.ok) { + const data = await res.json(); + setExcludedOverlay(data.overlay); + setShowExcluded(true); + } + } catch (e) { + console.error('Excluded-mask fetch error:', e); + } finally { + setIsLoadingExcluded(false); + } + }, [uploadedFilename, showExcluded, excludedOverlay]); + + // Press Q with magnifier active to run a fast extent + classify on the + // area under the lens. Both calls share an AbortController so a mouse + // move (handled in handlePanelMouseMove) cancels them in flight. + const handleAreaInspect = useCallback(() => { + if (algorithm !== 'classic') return; + if (!uploadedFilename || !magnifier) return; + const region = getMagnifierRegion(); + if (!region) return; + // Re-entry: cancel any in-flight inspection first + const prev = areaInspectRef.current; + if (prev.abort) { try { prev.abort.abort(); } catch (e) {} } + + const ctrl = new AbortController(); + areaInspectRef.current = { + panelPos: { x: magnifier.x, y: magnifier.y }, // pin the lens in panel space + region: region, + abort: ctrl, + }; + setAreaExtent({ state: 'loading' }); + setAreaClassify({ state: 'loading' }); + + const params = new URLSearchParams({ + x1: region.x1, y1: region.y1, x2: region.x2, y2: region.y2, + }); + + // Extent — fast, runs against cached red_stain. + apiFetch(`${API_BASE}/analyze-area/${encodeURIComponent(uploadedFilename)}?${params}`, + { signal: ctrl.signal }) + .then(r => r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))) + .then(data => { + if (areaInspectRef.current.abort !== ctrl) return; // stale + setAreaExtent({ state: 'done', ratio: data.fibrosis_ratio }); + }) + .catch(e => { + if (e.name === 'AbortError') return; + if (areaInspectRef.current.abort !== ctrl) return; + setAreaExtent({ state: 'error' }); + }); + + // Classify — VGG16 + PCA + FCM on the cropped mask region. + apiFetch(`${API_BASE}/classify-mask-area/${encodeURIComponent(uploadedFilename)}?${params}`, + { signal: ctrl.signal }) + .then(r => r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))) + .then(data => { + if (areaInspectRef.current.abort !== ctrl) return; + if (data.status === 'success') { + setAreaClassify({ + state: 'done', + scores: data.membership_scores, + label: data.cluster_label, + }); + } else { + setAreaClassify({ state: 'background' }); + } + }) + .catch(e => { + if (e.name === 'AbortError') return; + if (areaInspectRef.current.abort !== ctrl) return; + setAreaClassify({ state: 'error' }); }); + }, [algorithm, uploadedFilename, magnifier, getMagnifierRegion]); + + // Key handler: Ctrl+Z = undo (anytime), Ctrl+R = reset area (magnifier active), + // arrows = zoom & area delta (magnifier active), Escape = close overlays + useEffect(() => { + const handler = (e) => { + // Escape closes help or confirm overlays + if (e.key === 'Escape') { + if (showHelp) { setShowHelp(false); return; } + if (showResultBank) { setShowResultBank(false); return; } + if (showResetConfirm) { handleConfirmNo(); return; } + } + // Ctrl+Z: undo last modified square (works anytime) + if (e.ctrlKey && e.key === 'z') { + e.preventDefault(); + handleUndoArea(); + return; + } + // Everything below requires active magnifier + if (!magnifier) return; + // Q: instant area inspection (extent + classify) of region under lens + if (e.key === 'q' || e.key === 'Q') { + e.preventDefault(); + handleAreaInspect(); + return; + } + // Ctrl+R: reset area under observation + if (e.ctrlKey && e.key === 'r') { + e.preventDefault(); + handleResetArea(); + return; + } + if (e.key === 'ArrowUp') { + e.preventDefault(); + e.stopPropagation(); + setMagnifierZoom(z => Math.min(z + ZOOM_STEP, MAX_ZOOM)); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + e.stopPropagation(); + setMagnifierZoom(z => Math.max(z - ZOOM_STEP, MIN_ZOOM)); + } else if (e.key === 'ArrowLeft' && autoThreshold !== null) { + e.preventDefault(); + e.stopPropagation(); + const region = getMagnifierRegion(); + if (region) { + pendingAreaDelta.current -= 0.05; + handleLocalDeltaChange(region); + } + } else if (e.key === 'ArrowRight' && autoThreshold !== null) { + e.preventDefault(); + e.stopPropagation(); + const region = getMagnifierRegion(); + if (region) { + pendingAreaDelta.current += 0.05; + handleLocalDeltaChange(region); + } + } + }; + window.addEventListener('keydown', handler, true); + return () => window.removeEventListener('keydown', handler, true); + }, [magnifier, autoThreshold, getMagnifierRegion, handleLocalDeltaChange, handleResetArea, handleUndoArea, showHelp, showResultBank, showResetConfirm, handleConfirmNo, handleAreaInspect]); + + const CHUNK_SIZE = 5 * 1024 * 1024; // 5 MB per chunk + const LARGE_FILE_THRESHOLD = 10 * 1024 * 1024; // 10 MB -- use chunking above this + + // ---- Chunked upload for large files (SVS, big TIF) ---- + const uploadChunked = async (file) => { + const totalChunks = Math.ceil(file.size / CHUNK_SIZE); + let serverFilename = null; + + for (let i = 0; i < totalChunks; i++) { + const start = i * CHUNK_SIZE; + const end = Math.min(start + CHUNK_SIZE, file.size); + const blob = file.slice(start, end); + + const form = new FormData(); + form.append('file', blob, file.name); + form.append('resumableFilename', file.name); + form.append('resumableChunkNumber', String(i + 1)); + form.append('resumableTotalChunks', String(totalChunks)); + + const res = await apiFetch(`${API_BASE}/largefile`, { method: 'POST', body: form }); + if (!res.ok) throw new Error(`Chunk ${i + 1}/${totalChunks} failed: ${await res.text()}`); + + const data = await res.json(); + setUploadProgress(Math.round(((i + 1) / totalChunks) * 100)); + + // Last chunk returns the filename + if (data.filename) serverFilename = data.filename; + } + + if (!serverFilename) throw new Error('Chunked upload completed but no filename returned.'); + return serverFilename; + }; + + // ---- Simple upload for small files ---- + const uploadSimple = async (file) => { + const formData = new FormData(); + formData.append('file', file); + const res = await apiFetch(`${API_BASE}/upload`, { method: 'POST', body: formData }); + if (!res.ok) throw new Error(`Upload failed: ${await res.text()}`); + const data = await res.json(); + if (!data.filename) throw new Error('Upload succeeded but no filename returned.'); + setUploadProgress(100); + return data.filename; + }; + + const MAX_FLAT_IMAGE_SIZE = 50 * 1024 * 1024; // 50 MB for JPG/PNG/BMP + + const handleFile = useCallback(async (file) => { + if (!file) return; - const result = await response.json(); - console.log(result) + // Enforce 50MB limit on standard flat images — large slides must use SVS or TIF + const isFlatImage = file.name.match(/\.(jpe?g|png|bmp)$/i); + if (isFlatImage && file.size > MAX_FLAT_IMAGE_SIZE) { + setErrorMessage(`Standard images (JPG/PNG/BMP) must be under 50 MB. For whole-slide images, use SVS or TIF format.`); + return; + } + + const isUnetUpload = algorithm === 'unet'; + const setActiveImage = isUnetUpload ? setUnetImage : setSelectedImage; + const setActiveUploadedFilename = isUnetUpload ? setUnetUploadedFilename : setUploadedFilename; + + setActiveImage(file); + setErrorMessage(""); + setBankMessage(""); + setActiveUploadedFilename(""); + updateClassifyAlgo(algorithm, { result: null, isClassifying: false, progress: null, tileGrid: null, worstPatches: null, showWorst: false, maskChanged: false, error: '' }); + classifyGridInfo.current[algorithm] = null; + setFilteredImgContentRect(null); + setUploadProgress(0); + setMagnifier(null); + setShowResetConfirm(false); + setShowExcluded(false); + setExcludedOverlay(null); + + if (isUnetUpload) { + setUnetPreviewResult(null); + setUnetResult(null); + setUnetPatchProgress(null); + setUnetError(''); + setUnetUserThreshold(null); + setUnetAdjustedRatio(null); + setUnetThresholdDraft(''); + } else { + setPreviewResult(null); + setAnalysisResult(null); + setAutoThreshold(null); + setUserThreshold(null); + setAdjustedRatio(null); + setAdjustedMask(null); + setHasLocalEdits(false); + setDeltaMap(null); + } + + try { + setIsUploading(true); + + // Choose upload strategy based on file size + const filename = file.size > LARGE_FILE_THRESHOLD + ? await uploadChunked(file) + : await uploadSimple(file); + + setActiveUploadedFilename(filename); + + const isSvsTif = file.name.match(/\.(svs|tif|tiff)$/i); + + if (isUnetUpload) { + const previewResponse = await apiFetch(`${API_BASE}/preview/${encodeURIComponent(filename)}`); + if (previewResponse.ok) { + setUnetPreviewResult(await previewResponse.json()); + } + setIsUploading(false); + await runUnetAnalysis(filename); + return; + } + + if (isSvsTif) { + // Large slides: fetch a quick preview so the user sees something while patches process + const previewResponse = await apiFetch(`${API_BASE}/preview/${encodeURIComponent(filename)}`); + if (previewResponse.ok) { + setPreviewResult(await previewResponse.json()); + } + + // Stream patch progress via SSE + setIsUploading(false); + setIsAnalyzing(true); + setPatchProgress(null); + + const analyzeResult = await new Promise((resolve, reject) => { + let settled = false; + let evtSource = null; + buildSseUrl(`/analyze-stream/${encodeURIComponent(filename)}`) + .then((url) => { + evtSource = new EventSource(url); + evtSource.onmessage = (event) => { + try { + const msg = JSON.parse(event.data); + if (msg.type === 'progress') { + // If the backend sent an analysis-level preview, swap to it + // so the tile overlay aligns with the actual image being tiled. + if (msg.analysis_preview) { + setPreviewResult(prev => ({ + ...prev, + original_image: msg.analysis_preview, + })); + } + setPatchProgress({ current: msg.current, total: msg.total, tissue_patches: msg.tissue_patches }); + } else if (msg.type === 'result') { + settled = true; + evtSource.close(); + resolve(msg.data); + } + } catch (e) { + if (!settled) { settled = true; evtSource.close(); reject(e); } + } + }; + evtSource.onerror = () => { + if (!settled) { settled = true; evtSource.close(); reject(new Error('Analysis stream connection lost')); } + }; + }) + .catch((e) => { if (!settled) { settled = true; reject(e); } }); + }); + setAnalysisResult(analyzeResult); + } else { + // Small images (JPG/PNG/BMP): skip preview, single analysis call returns everything + setIsUploading(false); + setIsAnalyzing(true); + setPatchProgress(null); + + const analyzeResponse = await apiFetch(`${API_BASE}/analyze/${encodeURIComponent(filename)}`); + if (!analyzeResponse.ok) throw new Error(`Analyze failed: ${await analyzeResponse.text()}`); + const result = await analyzeResponse.json(); + setPreviewResult(result); // Images appear via displayedResult + setAnalysisResult(result); // Extent + classification ready simultaneously + } } catch (error) { - console.error('Error during upload:', error); + console.error("Pipeline error:", error); + setErrorMessage(error.message || "An error occurred during processing."); + } finally { + setIsUploading(false); + setIsAnalyzing(false); + setPatchProgress(null); + } + }, [algorithm, updateClassifyAlgo]); + + const isBusy = isUploading || isAnalyzing || isAnalyzingUnet; + const classicFibrosisRatio = analysisResult?.fibrosis_ratio; + const fibrosisRatio = algorithm === 'unet' + ? unetResult?.fibrosis_ratio + : classicFibrosisRatio; + + // U-Net inference runs only after an upload made while the U-Net page is active. + const runUnetAnalysis = useCallback(async (filename) => { + if (!filename) return; + setIsAnalyzingUnet(true); + setUnetPatchProgress({ current: 0, total: 0, tissue_patches: 0 }); + setUnetError(''); + setUnetUserThreshold(null); + setUnetAdjustedRatio(null); + updateClassifyAlgo('unet', { result: null, tileGrid: null, progress: null, worstPatches: null, showWorst: false, maskChanged: false, error: '' }); + try { + const data = await new Promise((resolve, reject) => { + let settled = false; + let evtSource = null; + buildSseUrl(`/analyze-unet-stream/${encodeURIComponent(filename)}`) + .then((url) => { + evtSource = new EventSource(url); + evtSource.onmessage = (event) => { + try { + const msg = JSON.parse(event.data); + if (msg.type === 'progress') { + setUnetPatchProgress({ + current: msg.current, + total: msg.total, + tissue_patches: msg.tissue_patches, + }); + } else if (msg.type === 'result') { + settled = true; + evtSource.close(); + resolve(msg.data); + } else if (msg.type === 'error') { + settled = true; + evtSource.close(); + reject(new Error(msg.error || 'U-Net analysis failed.')); + } + } catch (err) { + if (!settled) { settled = true; evtSource.close(); reject(err); } + } + }; + evtSource.onerror = () => { + if (!settled) { settled = true; evtSource.close(); reject(new Error('U-Net analysis stream connection lost')); } + }; + }) + .catch((e) => { if (!settled) { settled = true; reject(e); } }); + }); + setUnetResult(data); + } catch (e) { + console.error('U-Net analysis error:', e); + setUnetError(e.message || 'U-Net analysis failed.'); + setUnetResult(null); + } finally { + setIsAnalyzingUnet(false); + setUnetPatchProgress(null); } + }, [updateClassifyAlgo]); + + // Note: switching tabs never reuses the other algorithm's uploaded file. + + const getCurrentResultRecord = () => { + let extent; + if (algorithm === 'unet') { + extent = unetAdjustedRatio !== null ? unetAdjustedRatio : fibrosisRatio; + } else { + extent = adjustedRatio !== null ? adjustedRatio : fibrosisRatio; + } + const scores = classificationResult?.membership_scores; + if (extent === undefined || classificationResult?.status !== 'success' || !scores) return null; + return { + image_name: activeImage?.name || activeUploadedFilename || 'unknown_image', + uploaded_filename: activeUploadedFilename || '', + saved_at: new Date().toISOString(), + extent_percentage: Number(extent), + current_threshold: algorithm === 'unet' ? unetUserThreshold : userThreshold, + ai_threshold: algorithm === 'unet' ? UNET_BASELINE_THRESHOLD : autoThreshold, + has_local_edits: algorithm === 'classic' ? Boolean(hasLocalEdits) : false, + algorithm, + primary_classification: classificationResult.cluster_label || getDominantClassification(scores), + membership_scores: { + None: Number(scores.None || 0), + Perisinusoidal: Number(scores.Perisinusoidal || 0), + Bridging: Number(scores.Bridging || 0), + Cirrosis: Number(scores.Cirrosis || 0), + }, + }; }; - /* THIS IS a FAKE DATA PING*/ - // const fakeData = { - // "userId": 1, - // "id": 1, - // "title": "delectus aut autem", - // "completed": false - // } - - // const handleSubmit = async (event) => { - // event.preventDefault(); - // console.log("SUBMITTED") - // if (!image) { - // alert("Please select an image to upload."); - // return; - // } - - // const formData = new FormData(); - // formData.append('file', image); - - // try { - // const response = await fetch('http://127.0.0.1:5000/fake', { - // method: 'POST', - // headers:{"Content-Type":"application/json"}, - // body: JSON.stringify(fakeData), - // }); - - // const result = await response.json(); - // console.log(result) - // } catch (error) { - // console.error('Error during upload:', error); - // } - // }; - - return ( - -
- {image &&
Image Selection - not found -
} - - -
handleSubmit(e)}> - { - console.log("file changed"); - // only hook the first image for now - setSelectedImage(event.target.files[0]) - }} - /> - - {/* handleSubmit(e)} /> */} - -
-
+ const currentResultRecord = getCurrentResultRecord(); + const currentResultIdentity = getResultIdentity(currentResultRecord); + const savedCurrentIndex = currentResultIdentity + ? resultBank.findIndex(record => getResultIdentity(record) === currentResultIdentity) + : -1; + const canSaveCurrentResult = Boolean( + currentResultRecord && + !maskChangedSinceClassify && + !isBusy && + !isClassifying && + (savedCurrentIndex !== -1 || resultBank.length < MAX_RESULT_BANK_SIZE) + ); + const saveCurrentLabel = !currentResultRecord + ? 'Save Current Result' + : maskChangedSinceClassify + ? 'Re-diagnose to Save' + : savedCurrentIndex !== -1 + ? 'Update Saved Result' + : resultBank.length >= MAX_RESULT_BANK_SIZE + ? 'Result Bank Full' + : 'Save Current Result'; + + const handleDragOver = (e) => { e.preventDefault(); if (!isBusy) setIsDragging(true); }; + const handleDragLeave = (e) => { e.preventDefault(); setIsDragging(false); }; + const handleDrop = (e) => { + e.preventDefault(); + setIsDragging(false); + if (isBusy) return; + const files = e.dataTransfer.files; + if (files && files.length > 0) { + handleFile(files[0]); + } + }; + const handleClick = () => { if (!isBusy) fileInputRef.current?.click(); }; + + const handleDownloadCsv = () => { + if (!currentResultRecord || maskChangedSinceClassify) { + setErrorMessage("Complete an up-to-date diagnosis before downloading CSV."); + return; + } + const baseName = activeImage?.name ? activeImage.name.replace(/\.[^.]+$/, '') : 'fibrosis'; + downloadCsv(`${baseName}_results.csv`, [currentResultRecord]); + }; + + const handleAddCurrentToBank = () => { + if (!currentResultRecord) { + setBankMessage('Complete diagnosis first.'); + return; + } + if (maskChangedSinceClassify) { + setBankMessage('Re-diagnose before saving.'); + return; + } + + const identity = getResultIdentity(currentResultRecord); + const existingIndex = resultBank.findIndex(record => getResultIdentity(record) === identity); + if (existingIndex !== -1) { + setResultBank(previous => previous.map(record => getResultIdentity(record) === identity ? currentResultRecord : record)); + setBankMessage('Saved result updated.'); + return; + } + if (resultBank.length >= MAX_RESULT_BANK_SIZE) { + setBankMessage(`Result bank is full (${MAX_RESULT_BANK_SIZE}/${MAX_RESULT_BANK_SIZE}).`); + return; + } + setResultBank(previous => [...previous, currentResultRecord]); + setBankMessage('Result saved.'); + }; + + const handleDownloadBankCsv = () => { + if (resultBank.length === 0) return; + downloadCsv('fibrosisai_session_results.csv', resultBank); + }; + + const handleRemoveBankResult = (identity) => { + setResultBank(previous => previous.filter(record => getResultIdentity(record) !== identity)); + setBankMessage('Saved result removed.'); + }; + + const handleClearResultBank = () => { + setResultBank([]); + setBankMessage('Result bank cleared.'); + }; + + const handleDownloadMask = () => { + const maskSrc = algorithm === 'unet' + ? unetResult?.heatmap_image + : (adjustedMask || displayedResult?.filtered_image); + if (!maskSrc) return; + const link = document.createElement('a'); + link.href = maskSrc; + const baseName = activeImage?.name ? activeImage.name.replace(/\.[^.]+$/, '') : 'fibrosis'; + link.setAttribute('download', `${baseName}_mask.jpg`); + document.body.appendChild(link); + link.click(); + link.remove(); + }; + + const thresholdSliderValue = Number(userThreshold ?? autoThreshold ?? 0); + const thresholdSliderMin = autoThreshold !== null + ? Math.max(0, Math.min(autoThreshold - 5, thresholdSliderValue)) + : 0; + const thresholdSliderMax = autoThreshold !== null + ? Math.max(autoThreshold + 5, thresholdSliderValue) + : 10; + const renderRadarChart = (data) => { + if (!data) return null; + const cx = 170, cy = 150, R = 80; + const N = 4; + const cats = [ + { key: 'None', label: 'None', sub: 'F0' }, + { key: 'Perisinusoidal', label: 'Periportal', sub: 'F1' }, + { key: 'Bridging', label: 'Bridging', sub: 'F3' }, + { key: 'Cirrosis', label: 'Cirrhosis', sub: 'F4' }, + ]; + const angleOf = (i) => -Math.PI / 2 + (2 * Math.PI * i) / N; + const pts = cats.map((c, i) => { + const a = angleOf(i); + const v = data[c.key] || 0; + return { + x: cx + R * v * Math.cos(a), + y: cy + R * v * Math.sin(a), + ex: cx + R * Math.cos(a), + ey: cy + R * Math.sin(a), + v, label: c.label, sub: c.sub, angle: a, idx: i, + }; + }); + // Find the dominant (highest-membership) category + const maxV = Math.max(...pts.map(p => p.v)); + const dominantIdx = pts.findIndex(p => p.v === maxV); + const poly = pts.map(p => `${p.x},${p.y}`).join(' '); + // web lines (pentagons at each ring) + const rings = [0.25, 0.5, 0.75, 1.0]; + const webLines = rings.map(f => { + const webPts = cats.map((_, i) => { + const a = angleOf(i); + return `${cx + R * f * Math.cos(a)},${cy + R * f * Math.sin(a)}`; + }).join(' '); + return webPts; + }); + const dominant = pts[dominantIdx]; + return ( + <> + {/* Dominant-class banner */} + {dominant && ( +
+ PRIMARY CLASSIFICATION +
+ {dominant.label} + ({dominant.sub}) + {(dominant.v * 100).toFixed(0)}% +
+
+ )} + + + + + + + + {webLines.map((w, i) => ( + + ))} + {pts.map((p, i) => ( + + ))} + + {/* Dominant-vertex glow */} + {dominant && dominant.v > 0 && ( + + )} + {pts.map((p, i) => ( + + ))} + {pts.map((p) => { + const a = p.angle; + const cos = Math.cos(a), sin = Math.sin(a); + const isTop = sin < -0.3, isBot = sin > 0.3, isLeft = cos < -0.3, isRight = cos > 0.3; + const anchor = isRight ? 'start' : isLeft ? 'end' : 'middle'; + const dx = isRight ? 14 : isLeft ? -14 : 0; + const dy1 = isTop ? -22 : isBot ? 18 : -6; + const dy2 = dy1 + 12; + const isDom = p.idx === dominantIdx; + return ( + + {`${(p.v * 100).toFixed(0)}%`} + {p.label} + + ); + })} + + + ); + }; + + // The mask to display: use adjusted mask if user moved slider, else the original. + // When the U-Net algorithm is selected, swap in its heatmap image instead. + const displayedMaskSrc = algorithm === 'unet' + ? (unetResult?.heatmap_image || null) + : (adjustedMask || displayedResult?.filtered_image); + + // (Tile overlay during analyze removed — the analyze pass no longer + // does per-tile work, so there's nothing to visualise. The classify + // step still shows its tile scan on the right panel.) + + // Magnifier rendering helper + const zoomFraction = (magnifierZoom - MIN_ZOOM) / (MAX_ZOOM - MIN_ZOOM); + const renderMagnifier = (imgRef, side = 'left') => { + if (!magnifier || !imgRef.current) return null; + const img = imgRef.current; + const container = img.parentElement; + if (!container) return null; + const cw = container.clientWidth; + const ch = container.clientHeight; + const posX = magnifier.x * cw; + const posY = magnifier.y * ch; + const nw = img.naturalWidth || cw; + const nh = img.naturalHeight || ch; + const scale = Math.min(cw / nw, ch / nh); + const rw = nw * scale; + const rh = nh * scale; + const offsetX = (cw - rw) / 2; + const offsetY = (ch - rh) / 2; + const imgX = (posX - offsetX) / rw; + const imgY = (posY - offsetY) / rh; + if (imgX < 0 || imgX > 1 || imgY < 0 || imgY > 1) return null; + const effectiveZoom = Math.max(magnifierZoom, 1.001); + const bgW = nw * effectiveZoom * scale; + const bgH = nh * effectiveZoom * scale; + const bgX = -(imgX * bgW - MAGNIFIER_SIZE / 2); + const bgY = -(imgY * bgH - MAGNIFIER_SIZE / 2); + + // Convert panel-local coords to viewport coords so we can portal + // the magnifier out of any clipping ancestors (e.g. .img-panel + // overflow:hidden) and have it float over the rest of the UI. + const panelRect = container.getBoundingClientRect(); + const vpLeft = panelRect.left + posX - MAGNIFIER_SIZE / 2; + const vpTop = panelRect.top + posY - MAGNIFIER_SIZE / 2; + + // Inspection attachment for this magnifier + let attachment = null; + if (side === 'left' && areaExtent) { + attachment = ( +
+ Area Extent + {areaExtent.state === 'loading' && } + {areaExtent.state === 'done' && ( + <> + {areaExtent.ratio.toFixed(2)}% +
+
+
+ + )} + {areaExtent.state === 'error' && err} +
+ ); + } else if (side === 'right' && areaClassify) { + attachment = ( +
+ Area Stage + {areaClassify.state === 'loading' && } + {areaClassify.state === 'done' && areaClassify.scores && ( + <> + {areaClassify.label} +
+ {Object.entries(areaClassify.scores).map(([k, v]) => ( +
+ {k.slice(0, 4)} +
+ {Math.round(v * 100)} +
+ ))} +
+ + )} + {areaClassify.state === 'background' && background} + {areaClassify.state === 'error' && err} +
+ ); + } + + // "Press Q" hint only when no inspection is active + const showQHint = algorithm === 'classic' && !areaExtent && !areaClassify && analysisResult; + + return createPortal(( +
+
+
+
+ {showQHint && Q inspect} +
+ {/* Right sidebar: zoom */} +
+ Zoom +
+ +
+
+
+ +
+
+
+ {/* Bottom bar: area adjustment */} +
+ +
+
+
+ + Area Adj. +
+ {attachment} +
+
+ ), document.body); + }; + + return ( +
+ {/* Help button — fixed top-right */} + + + + {/* ── Left: images + extent description ── */} +
+
+ {/* Original — with tile overlay during analysis */} +
+ Original PSR Staining + {displayedResult?.original_image ? ( + Original PSR e.preventDefault()} /> + ) : activeImage ? ( + activeImage.name.match(/\.(tif|tiff|svs)$/i) ? ( +
+ + {activeImage.name} + Generating preview... +
+ ) : ( + Selected e.preventDefault()} + /> + ) + ) : ( +
+
Click or Drop to Upload
+
SVS / TIF — any size  ·  JPG / PNG / BMP — max 50 MB
+
+ )} + + {/* Magnifier overlay */} + {magnifier && displayedResult?.original_image && renderMagnifier(originalImgRef, 'left')} + {magnifier && displayedResult?.original_image &&
} + + {/* Filename & change message at bottom of frame */} + {activeImage && ( +
+ {activeImage.name} +
+ {!isBusy && Click or drop to change} + {algorithm === 'classic' && displayedResult?.original_image && uploadedFilename && !isBusy && ( + + )} + {(isAnalyzing || isAnalyzingUnet) && } +
+
+ )} + + {/* Tile grid overlay removed — analyze no longer scans tile-by-tile. */} + + {/* Excluded-pixels overlay (green = not counted in extent denominator) */} + {algorithm === 'classic' && showExcluded && excludedOverlay && imgContentRect && ( + Excluded pixels + )} + + + {/* Green progress bar at bottom of upload image */} + {(isUploading || isAnalyzing || isAnalyzingUnet) && ( +
+
+
+ )} + + { + const file = e.target.files[0]; + e.target.value = ''; + handleFile(file); + }} + /> +
+ + {/* Fibrosis mask */} +
+ {algorithm === 'unet' ? 'U-Net (Round 1) Heatmap' : 'fibrosisai Mask'} + {displayedMaskSrc ? ( + Fibrosis mask e.preventDefault()} /> + ) : ( +
+ {algorithm === 'unet' + ? (isAnalyzingUnet ? 'U-Net heatmap appears after patch inference' : 'U-Net heatmap appears after analysis') + : 'Fibrosis mask appears after analysis'} +
+ )} + {magnifier && displayedMaskSrc && renderMagnifier(filteredImgRef, 'right')} + {magnifier && displayedMaskSrc &&
} + {displayedMaskSrc && ( + !(algorithm === 'unet' ? unetResult : analysisResult) ? ( +
setMagnifier(null)} + onMouseMove={(e) => { e.stopPropagation(); setMagnifier(null); }} + > + preview only +
+ ) : ( + + ) + )} + + {/* Classify scan tile overlay — on filtered image during classification */} + {classifyTileGrid && isClassifying && filteredImgContentRect && ( +
+ {classifyTileGrid.tiles.map((s, i) => ( +
+ ))} +
+ )} + + {/* Classify progress bar */} + {isClassifying && classifyProgress && ( +
+
+
+ )} + + {/* Red outlines for worst patches — pixel-accurate positioning */} + {showWorstPatches && worstPatchCoords && filteredImgContentRect && classifyGridInfo.current?.[algorithm] && ( +
+ {worstPatchCoords.map((p, i) => { + const gi = classifyGridInfo.current[algorithm]; + // Map pixel coords to display coords using actual image dimensions + const scaleX = filteredImgContentRect.width / gi.imgW; + const scaleY = filteredImgContentRect.height / gi.imgH; + const patchW = Math.min(gi.patchSize, gi.imgW - p.px) * scaleX; + const patchH = Math.min(gi.patchSize, gi.imgH - p.py) * scaleY; + return ( +
+ {i + 1} +
+ ); + })} +
+ )} +
+
+ + {/* Area-only adjustment message + binding indicators (replaces extent description when magnifier active) */} +
+ {algorithm === 'classic' && magnifier && displayedResult?.original_image ? ( +
+

◀ ▶ magnifying glass threshold adjustments apply to the area under observation only

+
+
+ + Zoom In +
+
+ + Zoom Out +
+
+ + − Threshold +
+
+ + + Threshold +
+
+ Ctrl+Z + Undo +
+
+ Ctrl+R + Reset Area +
+
+
+ ) : ( +
+ {algorithm === 'unet' ? ( + <> +

+ Visualization & Extent: The heatmap shows Tiny U-Net Round 1 fibrosis probability across the uploaded image. +

+

+ Staging: Disease category is classified from the U-Net mask using the same VGG16, PCA, and Fuzzy C-Means classifier. +

+ + ) : ( + <> +

+ Visualization & Extent: White pixels indicate detected collagen fibers + isolated via colour deconvolution and adaptive thresholding. +

+

+ Staging: Disease category is classified by analyzing the + architectural patterns of these fibers using a VGG16 neural network and Fuzzy C-Means clustering. +

+ + )} + {algorithm === 'classic' && analysisResult?.patch_count && ( +

+ Patch-based analysis: {analysisResult.patch_count} tissue patches processed +

+ )} +
+ )} +
+ + {errorMessage &&

{errorMessage}

} +
+ + {/* ── Right: diagnosis report ── */} + + + {showResultBank && ( +
setShowResultBank(false)}> +
e.stopPropagation()}> +
+
+

Session Results

+

{resultBank.length}/{MAX_RESULT_BANK_SIZE} saved

+
+ +
+ +
+ + +
+ + {resultBank.length === 0 ? ( +
No saved results yet
+ ) : ( +
+ + + + + + + + + + + + {resultBank.map((record) => { + const identity = getResultIdentity(record); + return ( + + + + + + + + ); + })} + +
ImageExtentThresholdDiagnosis
+ {record.image_name} + {record.saved_at ? new Date(record.saved_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''} + {formatPercent(record.extent_percentage)}{formatThreshold(record.current_threshold)}{record.primary_classification} + +
+
+ )} + + {bankMessage &&

{bankMessage}

} +
+
+ )} + + {showResetConfirm && ( +
+
e.stopPropagation()}> +

Reset Adjustments?

+

+ You have made fine area adjustments that will be + reset to a general threshold. This cannot be undone. +

+
Press Esc to cancel
+
+ + +
+
+
+ )} + + {showHelp && ( +
setShowHelp(false)}> +
e.stopPropagation()}> + +

How to Use fibrosisai

+ +
+

Analysis

+

Upload an image (SVS, TIF, JPG, PNG, BMP) by clicking or dragging into the left panel. The AI pipeline will automatically extract collagen fibers via colour deconvolution and compute a fibrosis extent percentage. The original image appears on the left and the fibrosis mask on the right.

+
+ +
+

Baseline Threshold

+

After analysis, the right panel provides a Baseline Threshold slider and number field. This controls the global sensitivity for detecting fibrosis. Once you set a baseline you are happy with, stick to fine area adjustments rather than changing the baseline again. Changing the baseline resets all area edits. Use Reset to baseline AI estimate only for a full reset.

+
+ +
+

Magnifying Glass & Fine Adjustments

+

Hover over the original image to activate the magnifying glass. Use arrow keys to zoom and adjust the threshold for just the area under observation:

+
    +
  • Zoom in / out
  • +
  • Decrease / increase area threshold
  • +
  • Q Inspect area and instantly compute extent (left lens) and stage classification (right lens) for the region under the magnifier. Auto-cancels when the cursor moves.
  • +
  • Ctrl+R Reset observed area to original threshold
  • +
  • Ctrl+Z Undo all increments made to the last modified area
  • +
  • Esc Close overlays
  • +
+
+ +
+

Mini-Map

+

When area adjustments are made, a Modified Areas Map appears in the diagnosis panel showing which regions have been modified (green) vs. the original AI threshold (white). The map updates live as you undo or reset regions.

+
+ +
+

Diagnose

+

Classification is a separate step from analysis. Once you have refined the fibrosis mask to your satisfaction, click Diagnose to run VGG16 feature extraction and Fuzzy C-Means clustering on the refined mask. For large images, each tile is classified individually and results are averaged from the most severe patches.

+

After diagnosis, a radar chart shows probabilistic membership across four fibrosis stages. Use Re-diagnose after making further threshold adjustments. Analyze Patch Results highlights the five most severe tiles on the mask image with red outlines.

+
+ +
+

Exports

+

Download CSV exports the fibrosis percentage and classification scores (when available). Save Mask downloads the current fibrosis mask image (including any threshold adjustments).

+
+ +
Press Esc to close
+
+
+ )} +
); -} - -export default ImageSubmission; \ No newline at end of file +}; + +export default ImageSubmission; diff --git a/NAFLD/javascript/nafld-app/src/Login.js b/NAFLD/javascript/nafld-app/src/Login.js new file mode 100644 index 0000000..1bbf461 --- /dev/null +++ b/NAFLD/javascript/nafld-app/src/Login.js @@ -0,0 +1,299 @@ +import { useState } from "react"; + +const API_BASE = process.env.REACT_APP_API_URL || "http://127.0.0.1:5000"; + +const FEATURES = [ + { title: "Colour Deconvolution", desc: "Isolates collagen fibers from PSR-stained tissue via optical density separation" }, + { title: "Adaptive Thresholding", desc: "Automatically determines the optimal binary threshold to quantify fibrosis extent" }, + { title: "Interactive Refinement", desc: "Fine-tune the fibrosis mask with a magnifying glass, per-area threshold adjustments, and live mini-map" }, + { title: "VGG16 Deep Features", desc: "Extracts high-level architectural patterns from tissue patches using a pretrained CNN" }, + { title: "Fuzzy C-Means Staging", desc: "Classifies the refined mask into probabilistic stages with worst-patch analysis" }, +]; + +const Login = ({ onLogin }) => { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [isLoading, setIsLoading] = useState(false); + + const handleSubmit = async (e) => { + e.preventDefault(); + setError(""); + + if (!username.trim() || !password) { + setError("Username and password are required."); + return; + } + + setIsLoading(true); + try { + const res = await fetch(`${API_BASE}/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username: username.trim(), password }), + }); + const data = await res.json(); + if (!res.ok) { + setError(data.error || "Login failed."); + return; + } + sessionStorage.setItem("access_token", data.access_token); + sessionStorage.setItem("refresh_token", data.refresh_token); + sessionStorage.setItem("username", data.username); + onLogin(data.username); + } catch (err) { + setError("Unable to connect to the server."); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ {/* Network constellation background */} + + + + + {/* Edges */} + + + + + + + + + + + + + + + {/* Nodes */} + + {[[120,80],[250,140],[400,90],[520,180],[680,120],[800,200],[900,150], + [60,350],[180,280],[320,340],[480,300],[600,380],[750,320],[880,400], + [150,480],[300,520],[500,470],[700,530],[850,480]].map(([cx,cy],i) => ( + + ))} + + + +
+ {/* Left panel -- feature showcase */} +
+
+

fibrosisai

+

AI-powered unsupervised classification and quantification of liver fibrosis

+ +
+ {FEATURES.map((f, i) => ( +
+ {String(i + 1).padStart(2, '0')} +
+
{f.title}
+
{f.desc}
+
+
+ ))} +
+ +
+ PSR Staining + + Deconvolution + + Refinement + + VGG16 + + FCM Staging +
+
+
+ + {/* Right panel -- login form */} +
+
+
+ + {/* Logo + title row */} +
+
+ + + + + + + + + + + + + + + + + + + + + + + + {/* Anatomical liver shape - large left lobe, smaller right lobe, with inferior notch */} + + {/* Connective tissue / gallbladder notch detail */} + + {/* Left lobe fill + outline */} + + + + {/* Sparse network - larger nodes, fewer connections */} + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* Right lobe fill + outline */} + + + + {/* Dense mesh network */} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* Center transition glow */} + + +
+

Sign In

+
+

Access the fibrosis analysis platform

+ + + + + + {error &&

{error}

} + + + +

Whole-slide SVS/TIF support · 256×256 patch analysis · Interactive threshold tuning

+ +
+
+
+ ); +}; + +export default Login; diff --git a/NAFLD/javascript/nafld-app/src/index.css b/NAFLD/javascript/nafld-app/src/index.css index cabe6e3..4848ce8 100644 --- a/NAFLD/javascript/nafld-app/src/index.css +++ b/NAFLD/javascript/nafld-app/src/index.css @@ -1,271 +1,1980 @@ -/* General Reset */ +/* ── Reset ── */ +*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; } -.centered-header { +html, body, #root { + height: 100vh; + overflow: hidden; + font-family: 'Segoe UI', 'Roboto', Arial, sans-serif; + background: #0f1923; + color: #d0d6db; +} + +/* ── App root: full-viewport flex column ── */ +.app-root { display: flex; - justify-content: center; - align-items: center; - text-align: center; - padding: 1rem 0.5rem; - background-color: #dbdbdd; - color: #7A003C; - width: 100%; - font-family: "poppins",Arial, Helvetica, sans-serif; + flex-direction: column; + height: 100vh; + overflow: hidden; } -.app h1 { - margin: 0; - font-size: 5rem; - font-weight: bold; +/* ── Top bar ── */ +.top-bar { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.5rem 6.6rem 0.5rem 1.25rem; + background: #16202b; + border-bottom: 3px solid rgba(255,255,255,0.85); + flex-shrink: 0; } -* { - margin: 0; - padding: 0; - box-sizing: border-box; +.top-bar-left { + display: flex; + align-items: center; + gap: 0.5rem; +} +.brand-icon { font-size: 1.25rem; } +.brand-name { + font-size: 1.15rem; + font-weight: 700; + letter-spacing: 2px; + color: #fff; +} +.top-bar-subtitle { + font-size: 0.7rem; + letter-spacing: 1px; + color: #fff; + border-left: 1px solid #3a4f63; + padding-left: 1rem; +} +.top-bar-pipeline { + font-size: 0.7rem; + letter-spacing: 1.5px; + color: #4ecdc4; + border-left: 1px solid #3a4f63; + padding-left: 1rem; + white-space: nowrap; +} + +/* ── Pipeline label ── */ +.pipeline-label { + text-align: center; + font-size: 0.7rem; + letter-spacing: 1.5px; + color: #4ecdc4; + padding: 0.35rem 0; + background: #121c26; + border-bottom: 1px solid #1e3040; + flex-shrink: 0; } -html, body { - font-family: 'Roboto', Arial, sans-serif; - font-size: 16px; - color: #333; - background-color: #f9f9f9; - line-height: 1.6; +/* ── Main grid ── */ +.main-grid { + flex: 1; + display: grid; + grid-template-columns: 1fr 340px; + gap: 0 0.5rem; + padding: 0 1rem 0.3rem; + min-height: 0; + overflow: hidden; } -body { +/* ── Images column ── */ +.images-col { display: flex; flex-direction: column; - min-height: 100vh; + min-height: 0; + gap: 0.35rem; + padding-top: 0.4rem; } -/* Utility Classes */ -.container { - max-width: 1200px; - margin: 0 auto; - padding: 1rem; +.comparison-grid { + flex: 1; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.5rem; + min-height: 0; } -.flex { - display: flex; - gap: 1rem; +/* ── Extent description spanning under both images ── */ +.extent-description { + flex-shrink: 0; } -.flex-center { +/* ── Image panel (outlined upload zone) ── */ +.img-panel { + position: relative; + border: 2px solid #fff; + border-radius: 0.5rem; + background: #16202b; display: flex; - justify-content: center; align-items: center; + justify-content: center; + overflow: hidden; + min-height: 0; } - -.grid { - display: grid; - gap: 1rem; +.img-panel.drop-zone { + cursor: pointer; + transition: border-color 0.2s, box-shadow 0.2s; +} +.img-panel.drop-zone:not(.busy):hover, +.img-panel.drag-over { + border-color: #4ecdc4; + box-shadow: 0 0 12px rgba(78, 205, 196, 0.25); +} +.img-panel.drop-zone.busy { + cursor: default; } -.hidden { - display: none !important; +.img-label { + position: absolute; + top: 0.4rem; + left: 0.5rem; + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.5px; + background: rgba(15, 25, 35, 0.85); + padding: 0.15rem 0.5rem; + border-radius: 0.25rem; + z-index: 2; + color: #fff; } +.img-label.accent { color: #4ecdc4; border: 1px solid #4ecdc4; } -/* Buttons */ -button { - padding: 0.9rem 1.8rem; - border: none; - border-radius: 0.5rem; - background-color: #7A003C; +/* Filename footer inside the image upload panel */ +.img-panel-footer { + position: absolute; + bottom: 0; + left: 0; + right: 0; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.3rem 0.6rem; + background: rgba(15, 25, 35, 0.88); + z-index: 4; + font-size: 0.72rem; +} +.img-panel-filename { + color: #fff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 60%; +} +.img-panel-change { color: #fff; - font-size: 1rem; - font-weight: bold; cursor: pointer; - transition: background-color 0.3s ease; + font-size: 0.68rem; + white-space: nowrap; + transition: color 0.2s; } +.img-panel-change:hover { color: #4ecdc4; } -button:hover { - background-color: #495965; +.cancel-diagnosis-btn { + background: none; + border: 1px solid #e74c3c; + color: #e74c3c; + font-size: 0.65rem; + padding: 0.15rem 0.5rem; + border-radius: 3px; + cursor: pointer; + transition: background 0.2s; + white-space: nowrap; +} +.cancel-diagnosis-btn:hover { + background: rgba(231, 76, 60, 0.15); } -button:disabled { - background-color: #ccc; - cursor: not-allowed; +/* Green progress bar at bottom of upload panel */ +.img-progress-bar-track { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 5px; + background: rgba(30,45,61,0.6); + z-index: 5; +} +.img-progress-bar-fill { + height: 100%; + background: #4ecdc4; + border-radius: 0 2px 2px 0; + transition: width 0.35s ease; + box-shadow: 0 0 8px rgba(78,205,196,0.5); } -/* Inputs */ -input, textarea, select { - width: 100%; - padding: 0.6rem; - border: 1px solid #ddd; - border-radius: 0.5rem; - font-size: 1rem; - outline: none; - transition: border-color 0.3s ease; +.download-mask-btn { + position: absolute; + bottom: 0.5rem; + right: 0.5rem; + padding: 0.3rem 0.7rem; + border: 1px solid #3a4f63; + border-radius: 0.35rem; + background: rgba(15, 25, 35, 0.85); + color: #fff; + font-size: 0.72rem; + font-weight: 600; + cursor: pointer; + z-index: 2; + transition: background 0.2s, border-color 0.2s; +} +.download-mask-btn:hover { + background: #253545; + border-color: #4ecdc4; + color: #4ecdc4; } -input:focus, textarea:focus, select:focus { - border-color: #007bff; - box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25); +.excluded-toggle-btn { + background: none; + border: 1px solid #4cd964; + color: #4cd964; + font-size: 0.65rem; + padding: 0.15rem 0.5rem; + border-radius: 3px; + cursor: pointer; + white-space: nowrap; + transition: background 0.2s, color 0.2s; } +.excluded-toggle-btn:hover:not(:disabled) { + background: rgba(76, 217, 100, 0.15); +} +.excluded-toggle-btn.active { + background: rgba(76, 217, 100, 0.2); +} +.excluded-toggle-btn:disabled { opacity: 0.6; cursor: wait; } -/* Headings */ -h1, h2, h3, h4, h5, h6 { - font-weight: bold; - color: #222; +/* Indeterminate scanning bar for the extent-calculation card */ +.extent-scan-track { + position: relative; + height: 6px; margin-bottom: 0.5rem; + background: rgba(78, 205, 196, 0.15); + border-radius: 3px; + overflow: hidden; } - -h1 { font-size: 2.027rem; } -h2 { font-size: 1.802rem; } -h3 { font-size: 1.602rem; } -h4 { font-size: 1.424rem; } -h5 { font-size: 1.266rem; } -h6 { font-size: 1.125rem; } - -/* Links */ -a { - color: #007bff; - text-decoration: none; - transition: color 0.3s ease; +.extent-scan-fill { + position: absolute; + top: 0; + left: 0; + height: 100%; + width: 35%; + background: linear-gradient(90deg, transparent, #4ecdc4, transparent); + animation: extent-scan-slide 1.4s ease-in-out infinite; } - -a:hover { - color: #0056b3; - text-decoration: underline; +@keyframes extent-scan-slide { + 0% { left: -35%; } + 100% { left: 100%; } } - -/* Cards */ -.card { - background: #fff; - border: 1px solid #ddd; - border-radius: 0.5rem; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); - padding: 1rem; +.extent-dots::after { + content: ''; + display: inline-block; + width: 1.2em; + text-align: left; + animation: extent-dots 1.2s steps(4, end) infinite; +} +@keyframes extent-dots { + 0% { content: ''; } + 25% { content: '.'; } + 50% { content: '..'; } + 75% { content: '...'; } + 100% { content: ''; } } -.card:hover { - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); +/* ── Magnifier inspection attachments (Q key) ── */ +.mag-q-hint { + position: absolute; + bottom: 4px; + right: 4px; + background: rgba(15, 25, 35, 0.85); + color: #4ecdc4; + font-size: 0.6rem; + padding: 2px 5px; + border-radius: 3px; + border: 1px solid rgba(78,205,196,0.4); + display: flex; + align-items: center; + gap: 3px; + pointer-events: none; + letter-spacing: 0.3px; +} +.mag-q-hint kbd { + background: #4ecdc4; + color: #0e1a26; + padding: 0 4px; + border-radius: 2px; + font-family: inherit; + font-weight: 700; + font-size: 0.6rem; +} +.mag-attach { + margin-top: 4px; + background: rgba(15, 25, 35, 0.92); + border: 1px solid #4ecdc4; + border-radius: 4px; + padding: 5px 7px; + min-width: 130px; + max-width: 180px; + display: flex; + flex-direction: column; + gap: 3px; + font-size: 0.65rem; + color: #fff; + box-shadow: 0 2px 8px rgba(0,0,0,0.5); +} +.mag-attach-label { + font-size: 0.55rem; + color: #4ecdc4; + text-transform: uppercase; + letter-spacing: 0.7px; + font-weight: 700; +} +.mag-attach-value { + font-size: 0.95rem; + font-weight: 700; + color: #b2f0eb; + font-variant-numeric: tabular-nums; +} +.mag-attach-label-text { + font-size: 0.7rem !important; + color: #fff !important; +} +.mag-attach-bar { + height: 4px; + background: #253545; + border-radius: 2px; + overflow: hidden; } +.mag-attach-bar-fill { + height: 100%; + background: #4ecdc4; + transition: width 0.3s ease; +} +.mag-attach-spinner { + width: 14px; + height: 14px; + border: 2px solid rgba(78,205,196,0.25); + border-top-color: #4ecdc4; + border-radius: 50%; + animation: mag-spin 0.8s linear infinite; +} +@keyframes mag-spin { + to { transform: rotate(360deg); } +} +.mag-attach-err { color: #e8735a; font-size: 0.65rem; } +.mag-attach-stages { display: flex; flex-direction: column; gap: 2px; } +.mag-stage-row { + display: grid; + grid-template-columns: 28px 1fr 22px; + align-items: center; + gap: 4px; + font-size: 0.55rem; +} +.mag-stage-name { color: #9ab; text-transform: uppercase; letter-spacing: 0.3px; } +.mag-stage-bar { height: 3px; background: #253545; border-radius: 2px; overflow: hidden; } +.mag-stage-fill { height: 100%; background: #4ecdc4; border-radius: 2px; } +.mag-stage-pct { color: #fff; text-align: right; font-variant-numeric: tabular-nums; } -/* Navbar */ -.navbar { - background-color: #007bff; - padding: 0.8rem 1rem; - color: white; +.preview-image { + width: 100%; + height: 100%; + object-fit: contain; + -webkit-user-drag: none; + user-select: none; } -.navbar a { - color: white; - margin-right: 1rem; +.placeholder-text { + color: #fff; + font-size: 0.85rem; } -/* Footer */ -.footer { - background-color: #222; +/* ── Bottom bar ── */ +.bottom-bar { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 0.25rem; + flex-shrink: 0; + gap: 0.75rem; +} +.file-info { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.82rem; color: #fff; - padding: 1rem; - text-align: center; - margin-top: auto; } +.file-icon { font-size: 1rem; } +.file-name { color: #fff; } +.change-link { + cursor: pointer; + color: #4ecdc4; + border: 1px dashed #4ecdc4; + border-radius: 1rem; + padding: 0.2rem 0.75rem; + font-size: 0.75rem; + transition: background 0.2s; +} +.change-link:hover { background: rgba(78,205,196,0.1); } -/* Responsive Typography */ -@media (max-width: 768px) { - h1 { font-size: 1.75rem; } - h2 { font-size: 1.5rem; } - h3 { font-size: 1.25rem; } - body { - font-size: 14px; - } +.run-btn { + padding: 0.55rem 1.5rem; + border: 2px solid #4ecdc4; + border-radius: 2rem; + background: transparent; + color: #4ecdc4; + font-weight: 600; + font-size: 0.82rem; + cursor: pointer; + transition: background 0.25s, color 0.25s; + white-space: nowrap; } +.run-btn:hover:not(:disabled) { background: #4ecdc4; color: #0f1923; } +.run-btn:disabled { opacity: 0.4; cursor: not-allowed; } -/* Table */ -table { - width: 100%; - border-collapse: collapse; - margin: 1rem 0; +/* ── Report column ── */ +.report-col { + display: flex; + flex-direction: column; + gap: 0.5rem; + min-height: 0; + overflow-y: auto; + border-left: 3px solid rgba(255,255,255,0.85); + padding: 0.5rem 0 0 0.75rem; + margin-top: -0px; } -table th, table td { - padding: 0.75rem; - text-align: left; - border: 1px solid #ddd; +/* ── Tile overlay on original image ── */ +.tile-overlay { + position: absolute; + display: grid; + z-index: 3; + pointer-events: none; +} +.tile-cell { + position: relative; + overflow: hidden; +} +/* All tiles get diagonal hatching */ +.tile-cell::before { + content: ''; + position: absolute; + inset: 0; + background: repeating-linear-gradient( + -45deg, + transparent, + transparent 3px, + rgba(78,205,196,0.18) 3px, + rgba(78,205,196,0.18) 4px + ); +} +/* Shimmer sweep — only applied to tissue tiles (see .tile-tissue::after) */ +.tile-cell::after { + content: none; +} +/* Tissue tiles: brighter green tint */ +.tile-tissue { + background: rgba(78,205,196,0.15); + border: 1px solid rgba(78,205,196,0.35); +} +.tile-tissue::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient( + -45deg, + transparent 30%, + rgba(78,205,196,0.5) 50%, + transparent 70% + ); + background-size: 300% 300%; + animation: tile-shimmer 2.4s ease-in-out infinite; +} +/* Background tiles: dim */ +.tile-bg { + background: rgba(30,45,61,0.3); + border: 1px solid rgba(37,53,69,0.4); +} +.tile-bg::after { + content: none; +} +/* Pending tiles: very dark */ +.tile-pending { + background: rgba(20,30,40,0.2); + border: 1px solid rgba(37,53,69,0.2); +} +.tile-pending::after { + content: none; } -table th { - background-color: #f1f1f1; +@keyframes tile-shimmer { + 0% { background-position: 100% 100%; } + 100% { background-position: 0% 0%; } } -/* Scrollbar Customization */ -::-webkit-scrollbar { - width: 8px; +.report-title { + font-size: 1rem; + font-weight: 700; + letter-spacing: 2px; + color: #fff; +} +.report-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} +.csv-btn-inline { + padding: 0.3rem 0.7rem; + border: 1px solid #3a4f63; + border-radius: 0.35rem; + background: #1a2a3a; + color: #fff; + font-size: 0.72rem; + font-weight: 600; + cursor: pointer; + white-space: nowrap; + transition: background 0.2s; } +.csv-btn-inline:hover:not(:disabled) { background: #253545; color: #4ecdc4; } +.csv-btn-inline:disabled { opacity: 0.35; cursor: not-allowed; } -::-webkit-scrollbar-thumb { - background: #ccc; - border-radius: 4px; +.report-card { + background: #16202b; + border: 1px solid #253545; + border-radius: 0.5rem; + padding: 0.65rem 0.85rem; } -::-webkit-scrollbar-thumb:hover { - background: #999; +/* Dominant-class banner above the radar chart */ +.dominant-class-banner { + background: linear-gradient(135deg, rgba(247, 183, 49, 0.18) 0%, rgba(247, 183, 49, 0.06) 100%); + border: 1px solid rgba(247, 183, 49, 0.55); + border-radius: 0.5rem; + padding: 0.55rem 0.85rem; + margin: 0.25rem 0 0.6rem; + box-shadow: 0 0 12px rgba(247, 183, 49, 0.18); +} +.dominant-class-tag { + display: block; + font-size: 0.65rem; + letter-spacing: 0.12em; + font-weight: 700; + color: #f7b731; + opacity: 0.85; + margin-bottom: 0.2rem; +} +.dominant-class-row { + display: flex; + align-items: baseline; + gap: 0.5rem; +} +.dominant-class-label { + font-size: 1.25rem; + font-weight: 700; + color: #fff; + letter-spacing: 0.01em; +} +.dominant-class-stage { + font-size: 0.95rem; + color: #f7b731; + font-weight: 600; +} +.dominant-class-pct { + margin-left: auto; + font-size: 1.4rem; + font-weight: 700; + color: #f7b731; + font-variant-numeric: tabular-nums; +} +.report-label { + font-size: 0.85rem; + color: #fff; + margin-bottom: 0.25rem; +} +.report-value { + font-size: 1.5rem; + font-weight: 700; + color: #fff; + line-height: 1.15; + text-align: right; +} +.report-class { + font-size: 1.05rem; + font-weight: 700; + color: #4ecdc4; + text-align: right; } -/* Progress bar */ -.progress-container { +/* Extent bar */ +.extent-bar-track { width: 100%; - height: 20px; - background-color: #e0e0e0; - border-radius: 10px; + height: 5px; + background: #253545; + border-radius: 3px; + margin-bottom: 0.35rem; overflow: hidden; } +.extent-bar-fill { + height: 100%; + background: #b2f0eb; + border-radius: 3px; + transition: width 0.4s ease; +} + +.info-card { + font-size: 0.78rem; + color: #fff; + line-height: 1.5; + border-left: 3px solid #4ecdc4; +} -.progress-bar-container { +.csv-btn { width: 100%; - height: 20px; - padding: 0 2rem; + padding: 0.5rem; + border: 1px solid #3a4f63; + border-radius: 0.4rem; + background: #1a2a3a; + color: #fff; + font-size: 0.82rem; + font-weight: 600; + cursor: pointer; + transition: background 0.2s; +} +.csv-btn:hover:not(:disabled) { background: #253545; } +.csv-btn:disabled { opacity: 0.35; cursor: not-allowed; } + +/* ── Footer logos ── */ +.logo-footer { + display: flex; + justify-content: center; align-items: center; + gap: 2rem; + padding: 0.5rem 1rem; + background: #eef2f5; + border-top: 1px solid #d0d6db; + flex-shrink: 0; +} +.footer-logo { + height: 40px; + object-fit: contain; + opacity: 1; + filter: none; } -.progress-bar { - height: 100%; - background-color: #4caf50; - transition: width 0.3s ease-in-out; +/* ── Error text ── */ +.error-text { + margin-top: 0.35rem; + color: #ff6b6b; + font-weight: 600; + font-size: 0.82rem; } -/* Form */ -.form-container { +/* ── Threshold slider ── */ +.threshold-slider-wrapper { + margin-top: 0.5rem; + padding-top: 0.4rem; + border-top: 1px solid #253545; +} +.threshold-label { display: flex; - justify-content: center; /* Center horizontally */ - align-items: center; /* Center vertically */ - height: 15vh; /* Full height of the viewport */ + align-items: center; + gap: 0.5rem; + font-size: 0.78rem; + color: #fff; + margin-bottom: 0.25rem; } - -form { +.threshold-value { + color: #fff; + font-weight: 600; + font-family: 'Consolas', 'Courier New', monospace; +} +.threshold-loading { + color: #4ecdc4; + animation: spin 0.8s linear infinite; + display: inline-block; +} +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} +.threshold-control-row { display: flex; - flex-direction: column; /* Stack input and button vertically */ - gap: 10px; /* Add spacing between elements */ + align-items: center; + gap: 0.55rem; } - -.csv-container { - display: grid; - place-items: center; /* Center both horizontally and vertically */ - height: 15vh; /* Full height of the viewport */ - text-align: center; /* Center-align text */ +.threshold-slider { + -webkit-appearance: none; + appearance: none; + flex: 1; + min-width: 0; + height: 4px; + background: #4a6478; + border-radius: 2px; + outline: none; + cursor: pointer; } - -.csvDownload { +.threshold-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 14px; + height: 14px; + border-radius: 50%; + background: #4ecdc4; + border: 2px solid #0f1923; + cursor: pointer; + box-shadow: 0 0 6px rgba(78,205,196,0.4); +} +.threshold-slider::-moz-range-thumb { + width: 14px; + height: 14px; + border-radius: 50%; + background: #4ecdc4; + border: 2px solid #0f1923; + cursor: pointer; +} +.threshold-number-input { + width: 5.6rem; + height: 1.8rem; + border: 1px solid #3a4f63; + border-radius: 0.35rem; + background: #0f1923; + color: #fff; + font-size: 0.78rem; + font-family: 'Consolas', 'Courier New', monospace; + text-align: right; + padding: 0 0.45rem; + outline: none; +} +.threshold-number-input:focus { + border-color: #4ecdc4; + box-shadow: 0 0 0 2px rgba(78, 205, 196, 0.15); +} +.threshold-hint { display: flex; - flex-direction: column; /* Stack text and button */ - gap: 10px; /* Add space between elements */ + justify-content: space-between; + font-size: 0.65rem; + color: #fff; + margin-top: 0.15rem; +} +.threshold-reset-btn { + margin-top: 0.4rem; + padding: 0.2rem 0.5rem; + border: 1px solid #3a4f63; + border-radius: 0.3rem; + background: transparent; + color: #fff; + font-size: 0.68rem; + cursor: pointer; + transition: color 0.2s, border-color 0.2s; +} +.threshold-reset-btn:hover { + color: #4ecdc4; + border-color: #4ecdc4; +} +.adjusted-ratio { + color: #22c55e; + font-size: 1.1rem; + font-weight: 600; } +/* ── Magnifier ── */ +.img-panel.magnifier-active { + cursor: none; +} +.magnifier-group { + position: absolute; + pointer-events: none; + z-index: 10; + will-change: left, top; +} +.mag-main-col { + display: flex; + flex-direction: column; + gap: 0; +} +.mag-top-row { + display: flex; + align-items: flex-start; + gap: 0; +} +.magnifier-lens { + pointer-events: none; + background-repeat: no-repeat; + border: 2px solid #4ecdc4; + box-shadow: 0 0 10px rgba(78,205,196,0.35), 0 0 4px rgba(0,0,0,0.5); + flex-shrink: 0; + position: relative; +} +.magnifier-sidebar { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + margin-left: 4px; + padding: 4px 4px; + background: rgba(15, 25, 35, 0.88); + border: 1px solid #3a4f63; + border-radius: 4px; + min-width: 28px; + align-self: stretch; + justify-content: center; +} +.mag-sidebar-label { + font-size: 7px; + color: #fff; + text-transform: uppercase; + letter-spacing: 0.5px; + user-select: none; +} +.mag-zoom-bar { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; +} +.mag-arrow-hint { + font-size: 8px; + color: #fff; + line-height: 1; + user-select: none; +} +.mag-zoom-track { + width: 4px; + height: 48px; + background: #253545; + border-radius: 2px; + position: relative; + overflow: hidden; +} +.mag-zoom-fill { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + background: #4ecdc4; + border-radius: 2px; + transition: height 0.15s ease; +} +.mag-bottom-bar { + display: flex; + align-items: center; + gap: 4px; + margin-top: 4px; + padding: 3px 6px; + background: rgba(15, 25, 35, 0.88); + border: 1px solid #3a4f63; + border-radius: 4px; + align-self: flex-start; +} +.mag-adj-track { + width: 48px; + height: 4px; + background: #253545; + border-radius: 2px; + position: relative; +} +.mag-adj-center { + position: absolute; + left: 50%; + top: -1px; + width: 2px; + height: 6px; + background: #4ecdc4; + transform: translateX(-50%); + border-radius: 1px; +} +.mag-bottom-label { + font-size: 7px; + color: #fff; + text-transform: uppercase; + letter-spacing: 0.5px; + user-select: none; + margin-left: 2px; +} +.mag-hint-label { + font-size: 7px; + color: #fff; + text-transform: uppercase; + letter-spacing: 0.5px; + user-select: none; +} +.mag-hint-value { + font-size: 8px; + color: #f7b731; + font-weight: 600; + user-select: none; +} +.local-edits-badge { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.68rem; + color: #22c55e; + margin-top: 0.2rem; +} +.undo-area-btn { + background: none; + border: 1px solid #22c55e; + color: #22c55e; + font-size: 0.62rem; + padding: 0.1rem 0.4rem; + border-radius: 3px; + cursor: pointer; + transition: background 0.2s; +} +.undo-area-btn:hover { + background: rgba(34, 197, 94, 0.15); +} -.bar-graph { - width: 48%; /* Make each graph take up almost half of the available width */ - margin: 10px; /* Add space between the charts */ +/* ── Delta mini-map (in diagnosis panel) ── */ +.delta-minimap-panel { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.4rem; + padding: 0.3rem 0; +} +.delta-minimap-panel img { + display: block; + width: 100%; + max-width: 200px; + height: auto; + image-rendering: pixelated; + border: 1px solid rgba(78, 205, 196, 0.4); + border-radius: 4px; +} +.delta-minimap-legend { + display: flex; + gap: 1rem; + font-size: 0.68rem; + color: #fff; +} +.legend-swatch { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 2px; + margin-right: 4px; + vertical-align: middle; +} +.legend-green { background: #4ecdc4; } +.legend-white { background: #fff; } +.magnifier-dim { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.45); + pointer-events: none; + z-index: 9; +} +.magnifier-area-msg { + text-align: center; + padding: 0.35rem 0; + font-size: 0.78rem; + font-weight: 600; + color: #d946ef; + letter-spacing: 0.3px; + user-select: none; } -.bar-graph-container { +/* ── Confirmation Overlay ── */ +.confirm-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} +.confirm-modal { + background: #1a2a3a; + border: 1px solid #3a4f63; + border-radius: 12px; + padding: 2rem 2.5rem; + max-width: 380px; + text-align: center; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5); + animation: modalFadeIn 0.2s ease; +} +@keyframes modalFadeIn { + from { opacity: 0; transform: scale(0.92); } + to { opacity: 1; transform: scale(1); } +} +.confirm-title { + color: #f7b731; + font-size: 1.1rem; + font-weight: 700; + margin: 0 0 0.75rem; +} +.confirm-text { + color: #fff; + font-size: 0.85rem; + line-height: 1.5; + margin: 0 0 1.5rem; +} +.confirm-hint { + font-size: 0.68rem; + color: #8a9bae; + margin-bottom: 0.75rem; +} +.confirm-actions { display: flex; - flex-wrap: wrap; /* Allow graphs to wrap to the next line if necessary */ - justify-content: space-between; /* Distribute space evenly between the charts */ - width: 100%; /* Ensure the container takes full width */ + gap: 0.75rem; + justify-content: center; +} +.confirm-btn { + padding: 0.5rem 1.25rem; + border-radius: 6px; + font-size: 0.82rem; + font-weight: 600; + cursor: pointer; + border: none; + transition: background 0.2s; +} +.confirm-btn-cancel { + background: #253545; + color: #fff; +} +.confirm-btn-cancel:hover { + background: #3a4f63; +} +.confirm-btn-proceed { + background: #e74c3c; + color: #fff; +} +.confirm-btn-proceed:hover { + background: #c0392b; +} + +/* ── Responsive ── */ +@media (max-width: 900px) { + .main-grid { grid-template-columns: 1fr; } + .comparison-grid { grid-template-columns: 1fr; } +} + +/* ── Binding Indicators ── */ +.binding-indicators { + padding: 0.3rem 0.5rem; +} +.binding-row { + display: flex; + align-items: center; + justify-content: center; + gap: 1rem; + flex-wrap: wrap; + padding: 0.25rem 0; +} +.binding-group { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; +} +.binding-key { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 26px; + height: 26px; + padding: 0 4px; + border: 1px solid #4ecdc4; + border-radius: 4px; + background: rgba(78, 205, 196, 0.1); + color: #4ecdc4; + font-size: 13px; + font-weight: 700; + user-select: none; +} +.binding-key-yellow { + border-color: #f7b731; + background: rgba(247, 183, 49, 0.1); + color: #f7b731; +} +.binding-key-sm { + font-size: 9px; + min-width: 42px; + height: 22px; + border-color: #3b82f6; + color: #3b82f6; + background: rgba(59, 130, 246, 0.1); + transition: border-color 0.2s, color 0.2s, background 0.2s; +} +.binding-key-sm:hover { + border-color: #60a5fa; + color: #60a5fa; + background: rgba(96, 165, 250, 0.18); +} +.binding-label { + font-size: 0.6rem; + color: #fff; + user-select: none; +} + +/* ── Help Button ── */ +.help-btn { + position: fixed; + top: 0.5rem; + right: 0.65rem; + z-index: 100; + width: 28px; + height: 28px; + border-radius: 50%; + border: 2px solid #4ecdc4; + background: #4ecdc4; + color: #0f1923; + font-size: 0.9rem; + font-weight: 800; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.2s, transform 0.15s; + box-shadow: 0 2px 8px rgba(78, 205, 196, 0.3); +} +.help-btn:hover { + background: #3dbdb5; + border-color: #3dbdb5; + transform: scale(1.1); +} + +.result-bank-btn { + position: fixed; + top: 0.5rem; + right: 3.4rem; + z-index: 100; + width: 32px; + height: 28px; + border-radius: 0.4rem; + border: 1px solid #4ecdc4; + background: #16202b; + color: #4ecdc4; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.2s, transform 0.15s; +} +.result-bank-btn:hover { + background: #253545; + transform: translateY(-1px); +} +.result-bank-btn svg { + width: 17px; + height: 17px; + fill: none; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; +} +.result-bank-count { + position: absolute; + top: -6px; + right: -6px; + min-width: 16px; + height: 16px; + padding: 0 4px; + border-radius: 8px; + background: #f7b731; + color: #0a0f16; + font-size: 0.62rem; + font-weight: 800; + line-height: 16px; +} + +/* ── Help Overlay ── */ +.help-modal { + position: relative; + background: #1a2a3a; + border: 1px solid #3a4f63; + border-radius: 12px; + padding: 2rem 2.5rem; + max-width: 780px; + width: 90vw; + max-height: 90vh; + overflow-y: auto; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5); + animation: modalFadeIn 0.2s ease; + text-align: left; +} +.help-close { + position: absolute; + top: 0.75rem; + right: 0.75rem; + background: none; + border: none; + color: #8a9bae; + font-size: 1.1rem; + cursor: pointer; + transition: color 0.2s; +} +.help-close:hover { color: #fff; } +.help-title { + color: #4ecdc4; + font-size: 1.15rem; + font-weight: 700; + margin: 0 0 1rem; +} +.help-section { + margin-bottom: 1rem; +} +.help-section h3 { + color: #f7b731; + font-size: 0.88rem; + font-weight: 700; + margin: 0 0 0.3rem; +} +.help-section p { + color: #d0d6db; + font-size: 0.8rem; + line-height: 1.5; + margin: 0; +} +.help-bindings { + list-style: none; + padding: 0; + margin: 0.4rem 0 0; +} +.help-bindings li { + font-size: 0.78rem; + color: #d0d6db; + margin-bottom: 0.25rem; +} +.help-bindings kbd { + display: inline-block; + padding: 1px 5px; + border: 1px solid #4ecdc4; + border-radius: 3px; + background: rgba(78, 205, 196, 0.1); + color: #4ecdc4; + font-size: 0.72rem; + font-family: inherit; + margin-right: 2px; +} +.help-esc { + text-align: center; + font-size: 0.68rem; + color: #8a9bae; + margin-top: 0.75rem; + padding-top: 0.5rem; + border-top: 1px solid #253545; +} + +.result-bank-modal { + background: #1a2a3a; + border: 1px solid #3a4f63; + border-radius: 0.5rem; + padding: 1.25rem; + width: min(760px, 92vw); + max-height: 88vh; + overflow: hidden; + display: flex; + flex-direction: column; + gap: 0.8rem; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5); + animation: modalFadeIn 0.2s ease; +} +.result-bank-modal-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} +.result-bank-title { + color: #4ecdc4; + font-size: 1rem; + font-weight: 700; + margin: 0; +} +.result-bank-subtitle { + color: #8a9bae; + font-size: 0.72rem; + margin-top: 0.15rem; +} +.result-bank-close { + width: 28px; + height: 28px; + border: 1px solid #3a4f63; + border-radius: 0.35rem; + background: transparent; + color: #8a9bae; + font-size: 1rem; + cursor: pointer; +} +.result-bank-close:hover { + color: #fff; + border-color: #4ecdc4; +} +.result-bank-actions { + display: flex; + gap: 0.5rem; +} +.result-bank-action { + padding: 0.45rem 0.85rem; + border: 1px solid #4ecdc4; + border-radius: 0.35rem; + background: rgba(78, 205, 196, 0.12); + color: #4ecdc4; + font-size: 0.76rem; + font-weight: 700; + cursor: pointer; +} +.result-bank-action:hover:not(:disabled) { + background: #4ecdc4; + color: #0a0f16; +} +.result-bank-action-muted { + border-color: #3a4f63; + background: transparent; + color: #8a9bae; +} +.result-bank-action-muted:hover:not(:disabled) { + background: #253545; + color: #fff; +} +.result-bank-action:disabled { + opacity: 0.4; + cursor: not-allowed; +} +.result-bank-empty { + border: 1px dashed #3a4f63; + border-radius: 0.5rem; + padding: 2rem 1rem; + text-align: center; + color: #8a9bae; + font-size: 0.82rem; +} +.result-bank-table-wrap { + overflow: auto; + border: 1px solid #253545; + border-radius: 0.5rem; +} +.result-bank-table { + width: 100%; + border-collapse: collapse; + min-width: 620px; +} +.result-bank-table th, +.result-bank-table td { + padding: 0.55rem 0.65rem; + border-bottom: 1px solid #253545; + text-align: left; + font-size: 0.76rem; + vertical-align: middle; +} +.result-bank-table th { + position: sticky; + top: 0; + background: #16202b; + color: #8a9bae; + font-weight: 700; +} +.result-bank-table tr:last-child td { + border-bottom: none; +} +.result-bank-image { + display: block; + max-width: 220px; + color: #fff; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.result-bank-saved-at { + display: block; + color: #8a9bae; + font-size: 0.65rem; + margin-top: 0.1rem; +} +.result-bank-remove { + width: 24px; + height: 24px; + border: 1px solid #3a4f63; + border-radius: 0.3rem; + background: transparent; + color: #8a9bae; + cursor: pointer; + font-size: 0.95rem; + line-height: 1; +} +.result-bank-remove:hover { + color: #e74c3c; + border-color: #e74c3c; +} +.bank-message-modal { + text-align: left; +} + +/* ── Classify / Diagnose button ── */ +.classify-prompt { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.7rem; + padding: 0.5rem 0; +} +.classify-hint { + font-size: 0.75rem; + color: #8a9bae; + line-height: 1.5; + text-align: center; +} +.classify-btn { + width: 100%; + padding: 0.65rem 1rem; + border: none; + border-radius: 0.5rem; + background: linear-gradient(135deg, #4ecdc4 0%, #3ab8b0 100%); + color: #0a0f16; + font-size: 0.92rem; + font-weight: 700; + cursor: pointer; + letter-spacing: 0.5px; + transition: box-shadow 0.3s, transform 0.2s; +} +.classify-btn:hover { + box-shadow: 0 4px 20px rgba(78, 205, 196, 0.35); + transform: translateY(-1px); +} +.classify-loading { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.8rem 0; + font-size: 0.8rem; + color: #8a9bae; +} +.classify-spinner { + display: inline-block; + width: 16px; + height: 16px; + border: 2px solid rgba(78, 205, 196, 0.2); + border-top-color: #4ecdc4; + border-radius: 50%; + animation: loadingSpin 0.8s linear infinite; +} +.reclassify-btn { + margin-top: 0.5rem; + padding: 0.45rem 0.8rem; + border: 1px solid #3a4f63; + border-radius: 0.4rem; + background: transparent; + color: #8a9bae; + font-size: 0.72rem; + cursor: pointer; + transition: color 0.2s, border-color 0.2s, opacity 0.2s; +} +.reclassify-btn:hover:not(:disabled) { + color: #4ecdc4; + border-color: #4ecdc4; +} +.reclassify-btn:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +/* Row for re-diagnose + analyze patches buttons */ +.classify-actions-row { + display: flex; + gap: 0.5rem; + margin-top: 0.5rem; +} +.classify-actions-row .save-result-btn, +.classify-actions-row .reclassify-btn, +.classify-actions-row .analyze-patches-btn { + flex: 1; +} +.classify-actions-row .reclassify-btn { + margin-top: 0; +} +.save-result-btn { + padding: 0.45rem 0.8rem; + border: 1px solid #4ecdc4; + border-radius: 0.4rem; + background: rgba(78, 205, 196, 0.12); + color: #4ecdc4; + font-size: 0.72rem; + font-weight: 700; + cursor: pointer; + transition: background 0.2s, color 0.2s, opacity 0.2s; +} +.save-result-btn:hover:not(:disabled) { + background: #4ecdc4; + color: #0a0f16; +} +.save-result-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} +.bank-message { + margin: 0.45rem 0 0; + color: #f7b731; + font-size: 0.72rem; + text-align: center; +} + +/* Analyze Patch Results button */ +.analyze-patches-btn { + padding: 0.45rem 0.8rem; + border: 1px solid #e74c3c; + border-radius: 0.4rem; + background: transparent; + color: #e74c3c; + font-size: 0.72rem; + cursor: pointer; + transition: background 0.2s, color 0.2s; +} +.analyze-patches-btn:hover { + background: rgba(231, 76, 60, 0.12); + color: #ff6b5a; +} + +/* Worst patches overlay on filtered image */ +.worst-patches-overlay { + position: absolute; + pointer-events: none; + z-index: 4; +} +.worst-patch-rect { + position: absolute; + border: 2.5px solid #e74c3c; + box-shadow: 0 0 6px rgba(231, 76, 60, 0.5); + box-sizing: border-box; +} +.worst-patch-rank { + position: absolute; + top: 2px; + left: 2px; + background: #e74c3c; + color: #fff; + font-size: 0.6rem; + font-weight: 700; + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 3px; + line-height: 1; +} + +/* ── Login screen ── */ +.login-backdrop { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(160deg, #060a10 0%, #0a1020 40%, #0d1528 70%, #080e18 100%); + z-index: 9999; + overflow: hidden; +} + +/* Network constellation background */ +.login-constellation { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + pointer-events: none; + opacity: 0.6; + animation: constellationFadeIn 2s ease-out; +} +@keyframes constellationFadeIn { + from { opacity: 0; } + to { opacity: 0.6; } +} + +/* Split layout */ +.login-split { + display: flex; + width: min(1050px, 92vw); + min-height: 560px; + border-radius: 1.2rem; + overflow: hidden; + box-shadow: 0 25px 80px rgba(0, 0, 0, 0.6), 0 0 120px rgba(78, 205, 196, 0.06); + position: relative; + z-index: 1; + border: 1px solid rgba(78, 205, 196, 0.12); +} + +/* Left hero panel */ +.login-hero { + flex: 1.15; + background: linear-gradient(160deg, #0d1a28 0%, #122035 50%, #0a1520 100%); + padding: 2.5rem 2.2rem; + display: flex; + flex-direction: column; + justify-content: center; + position: relative; + overflow: hidden; +} +.login-hero::before { + content: ''; + position: absolute; + inset: 0; + background: + radial-gradient(ellipse at 20% 80%, rgba(78, 205, 196, 0.08) 0%, transparent 60%), + radial-gradient(ellipse at 80% 20%, rgba(78, 205, 196, 0.05) 0%, transparent 50%); + pointer-events: none; +} +.login-hero-content { + position: relative; + z-index: 1; +} +.login-brand { + font-size: 2rem; + font-weight: 800; + letter-spacing: 3px; + color: #fff; + margin: 0 0 0.6rem; + background: linear-gradient(135deg, #fff 30%, #4ecdc4 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} +.login-tagline { + font-size: 0.85rem; + color: #8a9bae; + line-height: 1.55; + margin: 0 0 2rem; + max-width: 340px; +} + +/* Feature cards */ +.login-features { + display: flex; + flex-direction: column; + gap: 0.55rem; + margin-bottom: 1.8rem; +} +.login-feature { + display: flex; + align-items: flex-start; + gap: 0.8rem; + padding: 0.7rem 0.85rem; + border-radius: 0.6rem; + border: 1px solid rgba(78, 205, 196, 0.1); + background: rgba(78, 205, 196, 0.03); + position: relative; + transition: background 0.3s, border-color 0.3s; +} +.login-feature:hover { + background: rgba(78, 205, 196, 0.06); + border-color: rgba(78, 205, 196, 0.2); +} +.login-feature::before { + content: ''; + position: absolute; + left: 0; + top: 50%; + transform: translateY(-50%); + width: 2px; + height: 50%; + background: #4ecdc4; + border-radius: 1px; + opacity: 0.4; +} +.login-feature-num { + font-size: 0.7rem; + font-weight: 700; + color: #4ecdc4; + opacity: 0.6; + flex-shrink: 0; + margin-top: 0.15rem; + font-family: 'Courier New', monospace; +} +.login-feature-title { + font-size: 0.78rem; + font-weight: 700; + color: #b8c8d8; +} +.login-feature-desc { + font-size: 0.68rem; + color: #6b7f94; + line-height: 1.45; + margin-top: 0.15rem; +} + +/* Pipeline strip */ +.login-pipeline-strip { + display: flex; + align-items: center; + gap: 0.45rem; + font-size: 0.62rem; + font-weight: 600; + color: #4a5d70; + letter-spacing: 0.5px; + text-transform: uppercase; + padding-top: 0.4rem; + border-top: 1px solid rgba(78, 205, 196, 0.08); +} +.login-pipe-arrow { + color: #4ecdc4; + font-size: 0.72rem; +} + +/* Right form panel */ +.login-form-panel { + flex: 0.85; + background: linear-gradient(180deg, #141e2a 0%, #111a24 100%); + display: flex; + align-items: center; + justify-content: center; + padding: 2rem; + position: relative; +} +.login-card { + width: 100%; + max-width: 320px; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.85rem; + position: relative; +} +.login-card-glow { + position: absolute; + top: -40px; + left: 50%; + transform: translateX(-50%); + width: 180px; + height: 180px; + background: radial-gradient(circle, rgba(78, 205, 196, 0.08) 0%, transparent 70%); + pointer-events: none; +} +.login-header-row { + display: flex; + align-items: center; + gap: 0.7rem; + margin-bottom: 0.1rem; +} +.login-icon { + width: 52px; + height: 52px; + flex-shrink: 0; + animation: loginIconPulse 3s ease-in-out infinite; +} +.liver-svg { + width: 100%; + height: 100%; + filter: drop-shadow(0 0 10px rgba(78, 205, 196, 0.25)) drop-shadow(0 0 20px rgba(232, 115, 90, 0.15)); +} +@keyframes loginIconPulse { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.06); } +} +.login-title { + font-size: 1.2rem; + font-weight: 700; + color: #fff; + margin: 0; + letter-spacing: -0.2px; +} +.login-subtitle { + font-size: 0.75rem; + color: #56687a; + margin-bottom: 0.5rem; + text-align: center; +} +.login-label { + width: 100%; + display: flex; + flex-direction: column; + gap: 0.3rem; +} +.login-label-text { + font-size: 0.68rem; + font-weight: 600; + color: #6b7f94; + letter-spacing: 0.8px; + text-transform: uppercase; +} +.login-input-wrap { + position: relative; + display: flex; + align-items: center; +} +.login-input-icon { + position: absolute; + left: 0.75rem; + width: 16px; + height: 16px; + color: #3a4f63; + pointer-events: none; + transition: color 0.2s; +} +.login-input-wrap:focus-within .login-input-icon { + color: #4ecdc4; +} +.login-input { + width: 100%; + padding: 0.65rem 0.75rem 0.65rem 2.4rem; + border: 1px solid #1e2d3d; + border-radius: 0.5rem; + background: #0c1219; + color: #fff; + font-size: 0.85rem; + outline: none; + transition: border-color 0.3s, box-shadow 0.3s; +} +.login-input::placeholder { + color: #2d3f50; + font-size: 0.78rem; +} +.login-input:focus { + border-color: #4ecdc4; + box-shadow: 0 0 0 3px rgba(78, 205, 196, 0.1); +} +.login-error { + width: 100%; + font-size: 0.75rem; + color: #e74c3c; + text-align: center; + padding: 0.4rem 0.6rem; + background: rgba(231, 76, 60, 0.08); + border-radius: 0.35rem; + border: 1px solid rgba(231, 76, 60, 0.15); +} +.login-btn { + width: 100%; + padding: 0.7rem; + border: none; + border-radius: 0.5rem; + background: linear-gradient(135deg, #4ecdc4 0%, #3ab8b0 100%); + color: #0a0f16; + font-size: 0.88rem; + font-weight: 700; + cursor: pointer; + transition: all 0.3s; + margin-top: 0.3rem; + position: relative; + overflow: hidden; + letter-spacing: 0.3px; +} +.login-btn::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(135deg, transparent 40%, rgba(255,255,255,0.15) 50%, transparent 60%); + transform: translateX(-100%); + transition: transform 0.6s; +} +.login-btn:hover:not(:disabled)::after { + transform: translateX(100%); +} +.login-btn:hover:not(:disabled) { + box-shadow: 0 4px 20px rgba(78, 205, 196, 0.3); + transform: translateY(-1px); +} +.login-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.login-btn-loading { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; +} +.login-spinner { + width: 16px; + height: 16px; + border: 2px solid rgba(10, 15, 22, 0.3); + border-top-color: #0a0f16; + border-radius: 50%; + animation: loginSpin 0.7s linear infinite; +} +@keyframes loginSpin { + to { transform: rotate(360deg); } +} +.login-footer { + font-size: 0.6rem; + color: #3a4f63; + text-align: center; + letter-spacing: 0.3px; + margin-top: 0.6rem; + line-height: 1.5; +} + +/* Responsive — stack on narrow screens */ +@media (max-width: 720px) { + .login-split { + flex-direction: column; + min-height: auto; + max-height: 95vh; + } + .login-hero { + padding: 1.5rem 1.5rem 1rem; + } + .login-features { + display: none; + } + .login-pipeline-strip { + display: none; + } + .login-form-panel { + padding: 1.5rem; + } +} +.logout-btn { + background: rgba(255,255,255,0.06); + border: 1px solid rgba(255,255,255,0.12); + border-radius: 1rem; + color: #8a9bae; + font-size: 0.7rem; + font-weight: 600; + padding: 0.22rem 0.65rem; + cursor: pointer; + letter-spacing: 0.3px; + transition: color 0.2s, border-color 0.2s, background 0.2s; +} +.logout-btn:hover { + color: #e74c3c; + border-color: #e74c3c; + background: rgba(231,76,60,0.1); +} +.user-badge { + display: flex; + align-items: center; + gap: 0.55rem; + margin-left: auto; + border-left: 1px solid #3a4f63; + padding-left: 1rem; +} +.user-badge-name { + font-size: 0.78rem; + font-weight: 600; + color: #cdd6de; + letter-spacing: 0.3px; +} + +/* SVG icons for top bar */ +.brand-liver-svg { + width: 32px; + height: 26px; + flex-shrink: 0; +} +.user-icon-svg { + width: 15px; + height: 15px; + color: #4ecdc4; + flex-shrink: 0; } -.logo { - width: 33%; /* Ensure the container takes full width */ - padding: 1rem 2rem; +/* Loading hourglass replacement */ +.loading-hourglass { + display: inline-block; + width: 24px; + height: 24px; + border: 2.5px solid rgba(78, 205, 196, 0.2); + border-top-color: #4ecdc4; + border-radius: 50%; + animation: loadingSpin 0.8s linear infinite; } +@keyframes loadingSpin { + to { transform: rotate(360deg); } +} \ No newline at end of file diff --git a/NAFLD/src/py-src/auth.py b/NAFLD/src/py-src/auth.py new file mode 100644 index 0000000..c8ced9c --- /dev/null +++ b/NAFLD/src/py-src/auth.py @@ -0,0 +1,218 @@ +""" +Authentication module for AI-Fibrosis. + +- SQLite user store with bcrypt-hashed passwords +- JWT access tokens (short-lived) + refresh tokens (longer-lived) +- Flask blueprint with /login, /refresh, /me endpoints +- CLI helper to add users: python auth.py add +""" + +import os +import sys +import sqlite3 +import secrets +from datetime import datetime, timedelta, timezone +from functools import wraps + +import bcrypt +import jwt as pyjwt +from flask import Blueprint, request, jsonify, g, current_app + +# ── Config ────────────────────────────────────────────────────────────── +DB_PATH = os.path.join(os.path.dirname(__file__), 'users.db') + +# Generate a persistent secret key file so it survives restarts +_SECRET_KEY_PATH = os.path.join(os.path.dirname(__file__), '.jwt_secret') + +def _load_or_create_secret(): + if os.path.exists(_SECRET_KEY_PATH): + with open(_SECRET_KEY_PATH, 'r') as f: + return f.read().strip() + key = secrets.token_hex(64) + with open(_SECRET_KEY_PATH, 'w') as f: + f.write(key) + return key + +JWT_SECRET = _load_or_create_secret() +JWT_ALGORITHM = 'HS256' +ACCESS_TOKEN_MINUTES = 30 +REFRESH_TOKEN_DAYS = 7 + +# ── Database helpers ──────────────────────────────────────────────────── + +def _get_db(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +def init_db(): + """Create users table if it doesn't exist.""" + conn = _get_db() + conn.execute(''' + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE COLLATE NOCASE, + password TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + ''') + conn.commit() + conn.close() + + +def add_user(username: str, password: str): + """Hash the password with bcrypt and insert a new user.""" + if len(password) < 8: + raise ValueError('Password must be at least 8 characters') + hashed = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt(rounds=12)) + conn = _get_db() + try: + conn.execute( + 'INSERT INTO users (username, password) VALUES (?, ?)', + (username, hashed.decode('utf-8')), + ) + conn.commit() + except sqlite3.IntegrityError: + conn.close() + raise ValueError(f'User "{username}" already exists') + conn.close() + + +def verify_user(username: str, password: str): + """Return the user row if credentials are valid, else None.""" + conn = _get_db() + row = conn.execute( + 'SELECT * FROM users WHERE username = ?', (username,) + ).fetchone() + conn.close() + if row is None: + return None + if bcrypt.checkpw(password.encode('utf-8'), row['password'].encode('utf-8')): + return dict(row) + return None + + +# ── Token helpers ─────────────────────────────────────────────────────── + +def _create_token(user_id: int, username: str, token_type: str, lifetime: timedelta): + now = datetime.now(timezone.utc) + payload = { + 'sub': str(user_id), + 'username': username, + 'type': token_type, + 'iat': now, + 'exp': now + lifetime, + } + return pyjwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM) + + +def create_access_token(user_id: int, username: str): + return _create_token(user_id, username, 'access', timedelta(minutes=ACCESS_TOKEN_MINUTES)) + + +def create_refresh_token(user_id: int, username: str): + return _create_token(user_id, username, 'refresh', timedelta(days=REFRESH_TOKEN_DAYS)) + + +def decode_token(token: str, expected_type: str = 'access'): + """Decode and validate a JWT. Returns the payload dict or None.""" + try: + payload = pyjwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) + if payload.get('type') != expected_type: + return None + return payload + except (pyjwt.ExpiredSignatureError, pyjwt.InvalidTokenError): + return None + + +# ── Flask decorator ───────────────────────────────────────────────────── + +def login_required(f): + """Decorator that protects a route with JWT access-token auth. + Accepts token from Authorization header OR ?token= query param (for EventSource/SSE).""" + @wraps(f) + def decorated(*args, **kwargs): + token = None + auth_header = request.headers.get('Authorization', '') + if auth_header.startswith('Bearer '): + token = auth_header[7:] + else: + token = request.args.get('token') + if not token: + return jsonify({'error': 'Missing or malformed Authorization header'}), 401 + payload = decode_token(token, expected_type='access') + if payload is None: + return jsonify({'error': 'Invalid or expired token'}), 401 + g.user_id = int(payload['sub']) + g.username = payload['username'] + return f(*args, **kwargs) + return decorated + + +# ── Blueprint ─────────────────────────────────────────────────────────── + +auth_bp = Blueprint('auth', __name__) + + +@auth_bp.route('/login', methods=['POST']) +def login(): + data = request.get_json(silent=True) + if not data: + return jsonify({'error': 'Request body must be JSON'}), 400 + + username = (data.get('username') or '').strip() + password = data.get('password') or '' + + if not username or not password: + return jsonify({'error': 'Username and password are required'}), 400 + + user = verify_user(username, password) + if user is None: + return jsonify({'error': 'Invalid credentials'}), 401 + + return jsonify({ + 'access_token': create_access_token(user['id'], user['username']), + 'refresh_token': create_refresh_token(user['id'], user['username']), + 'username': user['username'], + }), 200 + + +@auth_bp.route('/refresh', methods=['POST']) +def refresh(): + data = request.get_json(silent=True) + if not data or not data.get('refresh_token'): + return jsonify({'error': 'refresh_token is required'}), 400 + + payload = decode_token(data['refresh_token'], expected_type='refresh') + if payload is None: + return jsonify({'error': 'Invalid or expired refresh token'}), 401 + + return jsonify({ + 'access_token': create_access_token(payload['sub'], payload['username']), + 'username': payload['username'], + }), 200 + + +@auth_bp.route('/me', methods=['GET']) +@login_required +def me(): + return jsonify({'user_id': g.user_id, 'username': g.username}), 200 + + +# ── CLI: python auth.py add ────────────────────── + +if __name__ == '__main__': + init_db() + if len(sys.argv) >= 4 and sys.argv[1] == 'add': + username = sys.argv[2] + password = sys.argv[3] + try: + add_user(username, password) + print(f'User "{username}" created successfully.') + except ValueError as e: + print(f'Error: {e}', file=sys.stderr) + sys.exit(1) + else: + print('Usage: python auth.py add ') + sys.exit(1) diff --git a/NAFLD/src/py-src/inference.py b/NAFLD/src/py-src/inference.py new file mode 100644 index 0000000..71980d9 --- /dev/null +++ b/NAFLD/src/py-src/inference.py @@ -0,0 +1,486 @@ +""" +Tiny U-Net PSR fibrosis inference module. + +Self-contained inference code extracted from +psr_pseudolabel_tiny_unet_v14OnlyTrain.py. Importable on CPU-only +machines. Provides: + + - TinyUNet model + - Pseudo-label / multichannel input helpers required by the model + - load_model(checkpoint_path, device=None) -> TinyUNet + - run_inference(model, img_rgb, device=None, threshold=0.5) -> dict +""" + +from pathlib import Path +from typing import Dict, List, Optional, Tuple + +import cv2 +import numpy as np +import torch +import torch.nn as nn +from PIL import Image + +from skimage.filters import frangi +from skimage.measure import label, regionprops + + +FRANGI_WEIGHT = 0.01 + + +# ── Basic helpers ──────────────────────────────────────────────────────── + +def normalize01(x: np.ndarray) -> np.ndarray: + x = x.astype(np.float32) + xmin, xmax = x.min(), x.max() + if xmax - xmin < 1e-8: + return np.zeros_like(x, dtype=np.float32) + return (x - xmin) / (xmax - xmin) + + +def remove_small_components(mask_bool: np.ndarray, min_size: int = 40) -> np.ndarray: + lbl = label(mask_bool) + out = np.zeros_like(mask_bool, dtype=bool) + for r in regionprops(lbl): + if r.area >= min_size: + out[lbl == r.label] = True + return out + + +def filter_fibrous_components(mask_bool: np.ndarray, + min_area: int = 20, + min_eccentricity: float = 0.85, + min_aspect_ratio: float = 2.0, + keep_large: bool = False) -> np.ndarray: + lbl = label(mask_bool) + out = np.zeros_like(mask_bool, dtype=bool) + for r in regionprops(lbl): + if r.area < min_area: + continue + + major = max(float(r.major_axis_length), 1.0) + minor = max(float(r.minor_axis_length), 1.0) + aspect_ratio = major / minor + ecc = float(r.eccentricity) + + keep = (ecc >= min_eccentricity) or (aspect_ratio >= min_aspect_ratio) + if keep_large and r.area >= 400: + keep = True + + if keep: + out[lbl == r.label] = True + return out + + +def get_tissue_mask( + img_array, + gray_thresh: int = 238, + min_sum: int = 25, + white_thr: float = 0.92, + sat_thr: float = 0.05, + min_obj_size: int = 64, + use_morphology: bool = True, +): + """ + Robust tissue mask for regular RGB images and SVS patches. + Returns boolean mask of shape [H, W]. + """ + img = img_array.astype(np.float32) + + if img.max() <= 1.5: + rgb01 = img + img255 = img * 255.0 + else: + rgb01 = img / 255.0 + img255 = img + + gray01 = rgb01.mean(axis=2) + gray255 = img255.mean(axis=2) + + maxc = rgb01.max(axis=2) + minc = rgb01.min(axis=2) + sat = (maxc - minc) / (maxc + 1e-8) + + bg_white = (gray01 > white_thr) & (sat < sat_thr) + bg_simple = (img255.sum(axis=-1) <= min_sum) | (gray255 >= gray_thresh) + + tissue_mask = ~(bg_white | bg_simple) + tissue_mask = tissue_mask.astype(np.uint8) + + if use_morphology: + try: + kernel = np.ones((3, 3), np.uint8) + tissue_mask = cv2.morphologyEx(tissue_mask, cv2.MORPH_OPEN, kernel) + tissue_mask = cv2.morphologyEx(tissue_mask, cv2.MORPH_CLOSE, kernel) + + num_labels, labels, stats, _ = cv2.connectedComponentsWithStats( + tissue_mask, + connectivity=8 + ) + + clean = np.zeros_like(tissue_mask, dtype=np.uint8) + for i in range(1, num_labels): + if stats[i, cv2.CC_STAT_AREA] >= min_obj_size: + clean[labels == i] = 1 + + tissue_mask = clean + + except ImportError: + pass + + return tissue_mask.astype(bool) + + +def extract_psr_color_candidates(img_rgb: np.ndarray, + tissue_gray_thresh: int = 240, + min_saturation: int = 60, + min_value: int = 40, + min_a_channel: int = 140, + red_hue_ranges=((0, 20), (160, 179)), + min_size: int = 30) -> Dict[str, np.ndarray]: + gray = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2GRAY) + tissue_mask = gray < tissue_gray_thresh + + hsv = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2HSV) + h, s, v = cv2.split(hsv) + + red_hue_mask = np.zeros_like(gray, dtype=bool) + for hmin, hmax in red_hue_ranges: + red_hue_mask |= ((h >= hmin) & (h <= hmax)) + + red_hsv = red_hue_mask & (s >= min_saturation) & (v >= min_value) + + lab = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2LAB) + a = lab[:, :, 1] + red_lab = a >= min_a_channel + + color_mask = tissue_mask & red_hsv & red_lab + color_mask = remove_small_components(color_mask, min_size=min_size) + + return { + 'tissue_mask': tissue_mask, + 'red_hsv': red_hsv, + 'red_lab': red_lab, + 'color_mask': color_mask, + 'lab_a_norm': normalize01(a.astype(np.float32)) + } + + +def compute_frangi_response(img_rgb: np.ndarray, + color_mask: Optional[np.ndarray] = None, + use_lab_a: bool = True, + sigmas=(1, 2, 3), + beta=0.5, + gamma=15) -> np.ndarray: + if use_lab_a: + lab = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2LAB) + work = lab[:, :, 1].astype(np.float32) + else: + hsv = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2HSV) + work = hsv[:, :, 1].astype(np.float32) + + work = normalize01(work) + response = frangi(work, sigmas=sigmas, black_ridges=False, beta=beta, gamma=gamma) + response = normalize01(response) + + if color_mask is not None: + response = response * color_mask.astype(np.float32) + + return response.astype(np.float32) + + +def build_pseudo_labels(img_rgb: np.ndarray, + min_saturation: int = 65, + min_value: int = 45, + min_a_channel: int = 145, + frangi_high: float = 0.05, + frangi_low: float = 0.005, + min_size: int = 8, + min_area: int = 8, + min_eccentricity: float = 0.45, + min_aspect_ratio: float = 1.4, + weak_positive_from_color: bool = True, + weak_lab_a_thr: float = 0.58, + neg_lab_a_thr: float = 0.50, + strong_pos_weight: float = 1.0, + weak_pos_weight: float = 0.25, + neg_weight: float = 0.70) -> Dict[str, np.ndarray]: + + parts = extract_psr_color_candidates( + img_rgb, + min_saturation=min_saturation, + min_value=min_value, + min_a_channel=min_a_channel, + min_size=min_size + ) + + tissue_mask = get_tissue_mask(img_rgb).astype(bool) + + color_mask_raw = parts["color_mask"].astype(bool) + color_mask = color_mask_raw & tissue_mask + + lab_a_norm = parts["lab_a_norm"] + + frangi_resp = compute_frangi_response( + img_rgb, + color_mask=color_mask, + use_lab_a=True + ) + + frangi_resp = frangi_resp * tissue_mask.astype(np.float32) + + line_mask = (frangi_resp >= frangi_high) & tissue_mask + + fibrous_mask = filter_fibrous_components( + line_mask, + min_area=min_area, + min_eccentricity=min_eccentricity, + min_aspect_ratio=min_aspect_ratio, + keep_large=False + ) + + strong_pos = color_mask & fibrous_mask & tissue_mask + strong_pos = remove_small_components(strong_pos, min_size=max(6, min_size)) + strong_pos = strong_pos & tissue_mask + + if weak_positive_from_color: + weak_pos = ( + color_mask + & (~strong_pos) + & (lab_a_norm >= weak_lab_a_thr) + & tissue_mask + ) + else: + weak_pos = np.zeros_like(strong_pos, dtype=bool) + + pseudo_neg = ( + tissue_mask + & (~color_mask) + & (~strong_pos) + & (~weak_pos) + & (frangi_resp < frangi_low) + & (lab_a_norm < neg_lab_a_thr) + ) + + ignore = ~tissue_mask + + pseudo_label = np.full(img_rgb.shape[:2], 255, dtype=np.uint8) + pseudo_label[pseudo_neg] = 0 + pseudo_label[weak_pos] = 1 + pseudo_label[strong_pos] = 1 + + supervision_weight = np.zeros(img_rgb.shape[:2], dtype=np.float32) + supervision_weight[pseudo_neg] = float(neg_weight) + supervision_weight[weak_pos] = float(weak_pos_weight) + supervision_weight[strong_pos] = float(strong_pos_weight) + + return { + "tissue_mask": tissue_mask, + "color_mask_raw": color_mask_raw, + "color_mask": color_mask, + "lab_a_norm": lab_a_norm, + "frangi_response": frangi_resp, + "line_mask": line_mask, + "fibrous_mask": fibrous_mask, + "strong_pos": strong_pos, + "weak_pos": weak_pos, + "pseudo_pos": strong_pos | weak_pos, + "pseudo_neg": pseudo_neg, + "ignore": ignore, + "pseudo_label": pseudo_label, + "supervision_weight": supervision_weight, + } + + +def build_multichannel_input( + img_rgb: np.ndarray, + lab_a_norm: np.ndarray, + frangi_response: np.ndarray, + frangi_weight: float = 0.01 +) -> np.ndarray: + """ + Build 5-channel model input: RGB (3) + Lab-a norm (1) + Frangi response (1). + Returns array of shape [5, H, W], dtype float32. + """ + img_rgb = img_rgb.astype(np.float32) / 255.0 + + lab_a_norm = lab_a_norm.astype(np.float32) + if lab_a_norm.max() > 1.0 or lab_a_norm.min() < 0.0: + lab_a_norm = (lab_a_norm - lab_a_norm.min()) / ( + lab_a_norm.max() - lab_a_norm.min() + 1e-8 + ) + + frangi_response = frangi_response.astype(np.float32) + frangi_response = normalize01(frangi_response) + frangi_response = frangi_response * frangi_weight + + x = np.concatenate( + [ + img_rgb, + lab_a_norm[..., None], + frangi_response[..., None] + ], + axis=-1 + ) + + x = np.transpose(x, (2, 0, 1)).astype(np.float32) + return x + + +# ── Tiny U-Net architecture ────────────────────────────────────────────── + +class DoubleConv(nn.Module): + def __init__(self, in_ch, out_ch): + super().__init__() + self.block = nn.Sequential( + nn.Conv2d(in_ch, out_ch, 3, padding=1), + nn.BatchNorm2d(out_ch), + nn.ReLU(inplace=True), + nn.Conv2d(out_ch, out_ch, 3, padding=1), + nn.BatchNorm2d(out_ch), + nn.ReLU(inplace=True) + ) + + def forward(self, x): + return self.block(x) + + +class TinyUNet(nn.Module): + def __init__(self, in_channels=5, out_channels=1, base_ch=16): + super().__init__() + self.enc1 = DoubleConv(in_channels, base_ch) + self.pool1 = nn.MaxPool2d(2) + + self.enc2 = DoubleConv(base_ch, base_ch * 2) + self.pool2 = nn.MaxPool2d(2) + + self.bottleneck = DoubleConv(base_ch * 2, base_ch * 4) + + self.up2 = nn.ConvTranspose2d(base_ch * 4, base_ch * 2, 2, stride=2) + self.dec2 = DoubleConv(base_ch * 4, base_ch * 2) + + self.up1 = nn.ConvTranspose2d(base_ch * 2, base_ch, 2, stride=2) + self.dec1 = DoubleConv(base_ch * 2, base_ch) + + self.out_conv = nn.Conv2d(base_ch, out_channels, 1) + + def forward(self, x): + e1 = self.enc1(x) + e2 = self.enc2(self.pool1(e1)) + b = self.bottleneck(self.pool2(e2)) + + d2 = self.up2(b) + d2 = torch.cat([d2, e2], dim=1) + d2 = self.dec2(d2) + + d1 = self.up1(d2) + d1 = torch.cat([d1, e1], dim=1) + d1 = self.dec1(d1) + + return self.out_conv(d1) + + +# ── Tiled inference ────────────────────────────────────────────────────── + +def _grid_coords(width: int, height: int, patch_size: int, stride: int) -> List[Tuple[int, int]]: + xs = list(range(0, max(1, width - patch_size + 1), stride)) + ys = list(range(0, max(1, height - patch_size + 1), stride)) + if len(xs) == 0: + xs = [0] + if len(ys) == 0: + ys = [0] + if xs[-1] != max(0, width - patch_size): + xs.append(max(0, width - patch_size)) + if ys[-1] != max(0, height - patch_size): + ys.append(max(0, height - patch_size)) + return [(x, y) for y in ys for x in xs] + + +def predict_image_probability(model: nn.Module, + img_rgb: np.ndarray, + tile_size: int = 256, + overlap: int = 32, + device=None, + progress_callback=None) -> np.ndarray: + device = device or ("cuda" if torch.cuda.is_available() else "cpu") + model.eval() + h, w = img_rgb.shape[:2] + stride = tile_size - overlap + + prob_sum = np.zeros((h, w), dtype=np.float32) + count_sum = np.zeros((h, w), dtype=np.float32) + + coords = _grid_coords(w, h, patch_size=tile_size, stride=stride) + + with torch.no_grad(): + total = len(coords) + for idx, (x, y) in enumerate(coords, start=1): + patch = img_rgb[y:y + tile_size, x:x + tile_size] + ph, pw = patch.shape[:2] + + if ph != tile_size or pw != tile_size: + canvas = np.full((tile_size, tile_size, 3), 255, dtype=np.uint8) + canvas[:ph, :pw] = patch + patch = canvas + + pseudo_info = build_pseudo_labels(patch) + xin = build_multichannel_input( + patch, + lab_a_norm=pseudo_info['lab_a_norm'], + frangi_response=pseudo_info['frangi_response'] + ) + xt = torch.from_numpy(xin).unsqueeze(0).float().to(device) + probs = torch.sigmoid(model(xt))[0, 0].cpu().numpy() + probs = probs[:ph, :pw] + + prob_sum[y:y + ph, x:x + pw] += probs + count_sum[y:y + ph, x:x + pw] += 1.0 + + if progress_callback is not None: + progress_callback(idx, total, x=int(x), y=int(y)) + + return prob_sum / np.maximum(count_sum, 1e-8) + + +# ── Public API ─────────────────────────────────────────────────────────── + +def load_model(checkpoint_path: str, device=None) -> TinyUNet: + """ + Load a trained TinyUNet checkpoint. + The checkpoint is expected to be a dict with a "model" key containing + the state dict (matches the format saved by the training pipeline). + """ + device = device or ("cuda" if torch.cuda.is_available() else "cpu") + model = TinyUNet(in_channels=5, out_channels=1, base_ch=16) + checkpoint = torch.load(checkpoint_path, map_location=device, weights_only=False) + model.load_state_dict(checkpoint["model"]) + model.eval() + model.to(device) + return model + + +def run_inference(model, img_rgb: np.ndarray, device=None, threshold: float = 0.5, progress_callback=None): + """ + Run U-Net inference on an RGB image and return fibrosis prediction. + + Returns dict with keys: + prob_map [H, W] float32 probability map + fibrosis_mask [H, W] bool mask of pixels above threshold within tissue + fibrosis_fraction float, fibrosis_mask.sum() / tissue_mask.sum() + tissue_mask [H, W] bool tissue mask + """ + prob_map = predict_image_probability( + model, img_rgb, tile_size=256, overlap=32, device=device, progress_callback=progress_callback + ) + tissue_mask = get_tissue_mask(img_rgb) + fibrosis_mask = (prob_map >= threshold) & tissue_mask + tissue_pixel_count = int(tissue_mask.sum()) + if tissue_pixel_count > 0: + fibrosis_fraction = float(fibrosis_mask.sum()) / float(tissue_pixel_count) + else: + fibrosis_fraction = 0.0 + return { + "prob_map": prob_map, + "fibrosis_mask": fibrosis_mask, + "fibrosis_fraction": fibrosis_fraction, + "tissue_mask": tissue_mask, + } diff --git a/NAFLD/src/py-src/main.py b/NAFLD/src/py-src/main.py index 0229cfc..4ba9c8e 100644 --- a/NAFLD/src/py-src/main.py +++ b/NAFLD/src/py-src/main.py @@ -1,32 +1,772 @@ # pip freeze the erqs from the backend and just integrate it here # add new gitignores here as well from the other project -# - # python -m pip install -r ./setup.txt # flask --app .\NAFLD\src\py-src\main.py run import os -from flask import Flask,jsonify,send_file, request +from flask import Flask,jsonify,send_file, request, Response from datetime import datetime from flask_cors import CORS import sys import zipfile +import io +import csv +from werkzeug.utils import secure_filename +from nafld import analyze_single_file, preview_single_file, analyze_single_file_patched, rethreshold, rethreshold_area, reset_area, undo_area, get_delta_map, get_excluded_mask, pil_to_b64, classify_from_mask, classify_mask_array, analyze_area, classify_area # sys.path.append("C:\\Projects\\Machine Learning\\NAFLD\\NAFLD-project\\NAFLD\\src\\py-src\\nafld.py") from nafld import process_all_images -app = Flask(__name__) -CORS(app, expose_headers=['Content-Disposition']) -# 'C:\\Projects\\NAFLD\\NAFLD-project\\NAFLD\\Images' -# C:\Projects\Machine Learning\NAFLD\NAFLD-project\NAFLD\Images -UPLOAD_FOLDER = 'C:\\Projects\\Machine Learning\\NAFLD\\NAFLD-project\\NAFLD\\Images' -ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg'} +import json +import queue +import threading + +from auth import auth_bp, init_db, login_required + +# U-Net (Round 1) inference — loaded once at startup, see UNET_MODEL below +from inference import load_model as _unet_load_model, run_inference as _unet_run_inference +import base64 +import numpy as np +from PIL import Image as _PILImage +try: + import matplotlib + matplotlib.use('Agg') + from matplotlib import cm as _mpl_cm + _HAS_MPL = True +except Exception: + _HAS_MPL = False + +# Serve the React build in production when NAFLD_STATIC_DIR is set +_static_dir = os.environ.get('NAFLD_STATIC_DIR') +if _static_dir: + app = Flask(__name__, static_folder=_static_dir, static_url_path='') +else: + app = Flask(__name__) +CORS(app, expose_headers=['Content-Disposition'], + allow_headers=['Content-Type', 'Authorization']) + +# Initialise user database on startup +init_db() + +# ── U-Net (Round 1) model: load once at startup ──────────────────────── +def _resolve_unet_checkpoint_path(): + configured = os.environ.get('UNET_CHECKPOINT_PATH') + if configured: + return configured + base_dir = os.path.dirname(__file__) + candidates = [ + os.path.join(base_dir, 'tiny_unet_round1_best_v1__1_.pth'), + os.path.join(base_dir, 'tiny_unet_round1_best_v1.pth'), + ] + for candidate in candidates: + if os.path.exists(candidate): + return candidate + return candidates[0] + + +UNET_CHECKPOINT_PATH = _resolve_unet_checkpoint_path() +try: + UNET_MODEL = _unet_load_model(UNET_CHECKPOINT_PATH) + print(f"[unet] Loaded checkpoint: {UNET_CHECKPOINT_PATH}") +except Exception as _e: + print(f"[unet] WARNING: failed to load checkpoint '{UNET_CHECKPOINT_PATH}': {_e}") + UNET_MODEL = None + +# Register auth blueprint (provides /login, /refresh, /me) +app.register_blueprint(auth_bp) + +# Allow uploads up to 2 GB (chunked uploads bypass this but regular /upload needs it) +app.config['MAX_CONTENT_LENGTH'] = 2 * 1024 * 1024 * 1024 # 2 GB + +UPLOAD_FOLDER = os.environ.get('NAFLD_UPLOAD_FOLDER', 'C:\\Users\\alexg\\Documents\\NAFLDimages') +ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'bmp', 'tif', 'tiff', 'svs'} + +os.makedirs(UPLOAD_FOLDER, exist_ok=True) upload_file_dict = {} # upload_file_list = [] +# Cache analysis results so CSV download doesn't re-run the full pipeline. +# Keyed by resolved file path → result dict. +_analysis_cache = {} + +# Disk-backed metadata directory for CSV downloads (survives across workers/restarts) +_META_DIR = os.path.join(UPLOAD_FOLDER, '.meta') +os.makedirs(_META_DIR, exist_ok=True) + +def _save_result_meta(filename, result): + """Persist the CSV-relevant fields to a small JSON sidecar on disk.""" + meta = { + 'status': result.get('status'), + 'fibrosis_ratio': result.get('fibrosis_ratio'), + 'membership_scores': result.get('membership_scores'), + } + path = os.path.join(_META_DIR, f"{filename}.json") + with open(path, 'w') as f: + json.dump(meta, f) + +def _load_result_meta(filename): + """Load CSV fields from disk sidecar. Returns dict or None.""" + path = os.path.join(_META_DIR, f"{filename}.json") + if os.path.exists(path): + with open(path, 'r') as f: + return json.load(f) + return None + # Look into restricting access from other endpoints than arent localhost? # CORS(app, resources={r"/home": {"origins": "localhost:3000"}}) +@app.route("/analyze/", methods=['GET']) +@login_required +def analyze_file(filename): + print(f"Analyzing {filename}...") + + file_path = resolve_uploaded_file_path(filename) + + if not file_path: + return jsonify({'error': 'File not found'}), 404 + + # 2. Call the brain + result = analyze_single_file(file_path) + + # Cache the result so CSV download can reuse it + _analysis_cache[file_path] = result + _save_result_meta(filename, result) + + return jsonify(result), 200 + + +def _prob_map_to_inferno_b64(prob_map: np.ndarray) -> str: + """Render a [H, W] probability map as a base64-encoded inferno PNG.""" + arr = np.clip(prob_map.astype(np.float32), 0.0, 1.0) + if _HAS_MPL: + rgba = (_mpl_cm.get_cmap('inferno')(arr) * 255).astype(np.uint8) + img = _PILImage.fromarray(rgba, mode='RGBA') + else: + # Fallback: simple red ramp if matplotlib is unavailable + gray = (arr * 255).astype(np.uint8) + rgb = np.stack([gray, np.zeros_like(gray), np.zeros_like(gray)], axis=-1) + img = _PILImage.fromarray(rgb, mode='RGB') + buf = io.BytesIO() + img.save(buf, format='PNG') + return base64.b64encode(buf.getvalue()).decode('ascii') + + +def _read_rgb_for_unet(file_path: str, target_max_dim: int = 4096) -> np.ndarray: + """Read an uploaded file as an RGB numpy array. SVS slides are read at the + pyramid level whose largest dimension is closest to (but not below) target_max_dim, + falling back to the lowest level if no level is large enough.""" + ext = os.path.splitext(file_path)[1].lower() + if ext == '.svs': + try: + import openslide + slide = openslide.OpenSlide(file_path) + try: + # Pick the highest-resolution level whose largest dimension is <= target_max_dim. + # If even level_count-1 (smallest) is larger than target_max_dim, use level_count-1. + chosen_level = slide.level_count - 1 + for lvl in range(slide.level_count): + w, h = slide.level_dimensions[lvl] + if max(w, h) <= target_max_dim: + chosen_level = lvl + break + w, h = slide.level_dimensions[chosen_level] + region = slide.read_region((0, 0), chosen_level, (w, h)).convert('RGB') + return np.array(region) + finally: + slide.close() + except Exception as e: + raise RuntimeError(f"Failed to read SVS file: {e}") + return np.array(_PILImage.open(file_path).convert('RGB')) + + +# Per-filename cache for U-Net inference results so we can re-threshold and +# re-classify without re-running the model. Keyed by the basename used in URLs. +_unet_cache = {} +UNET_BASELINE_THRESHOLD = 0.5 + + +def _format_unet_result_payload(result, threshold: float = UNET_BASELINE_THRESHOLD): + """Build the JSON payload returned to the frontend. + The heatmap is the inferno-colored prob_map (independent of threshold). + fibrosis_ratio is computed at the supplied threshold. + """ + prob_map = result['prob_map'] + tissue_mask = result.get('tissue_mask') + if tissue_mask is not None: + fibrosis_mask = (prob_map >= threshold) & tissue_mask + tissue_count = int(tissue_mask.sum()) + else: + fibrosis_mask = (prob_map >= threshold) + tissue_count = int(prob_map.size) + if tissue_count > 0: + fibrosis_fraction = float(fibrosis_mask.sum()) / float(tissue_count) + else: + fibrosis_fraction = 0.0 + fibrosis_pct = round(fibrosis_fraction * 100.0, 2) + heatmap_b64 = _prob_map_to_inferno_b64(prob_map) + return { + 'status': 'success', + 'algorithm': 'unet_round1', + 'fibrosis_ratio': fibrosis_pct, + 'fibrosis_percentage': fibrosis_pct, + 'threshold': float(threshold), + 'baseline_threshold': UNET_BASELINE_THRESHOLD, + 'heatmap_image': f"data:image/png;base64,{heatmap_b64}", + } + + +@app.route("/analyze-unet/", methods=['GET']) +@login_required +def analyze_file_unet(filename): + """Run the Tiny U-Net (Round 1) fibrosis segmentation on an uploaded file.""" + if UNET_MODEL is None: + return jsonify({ + 'status': 'error', + 'error': 'U-Net model checkpoint was not found on the server.', + }), 503 + + file_path = resolve_uploaded_file_path(filename) + if not file_path: + return jsonify({'error': 'File not found'}), 404 + + try: + img_rgb = _read_rgb_for_unet(file_path) + result = _unet_run_inference(UNET_MODEL, img_rgb) + except Exception as e: + return jsonify({'status': 'error', 'error': str(e)}), 500 + + _unet_cache[filename] = { + 'prob_map': result['prob_map'], + 'tissue_mask': result['tissue_mask'], + 'threshold': UNET_BASELINE_THRESHOLD, + } + return jsonify(_format_unet_result_payload(result, UNET_BASELINE_THRESHOLD)), 200 + + +@app.route("/analyze-unet-stream/", methods=['GET']) +@login_required +def analyze_file_unet_stream(filename): + """SSE endpoint for Tiny U-Net patch progress and final heatmap result.""" + def single_event(msg): + return Response( + (f"data: {json.dumps(msg)}\n\n" for _ in [0]), + mimetype='text/event-stream', + headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'} + ) + + if UNET_MODEL is None: + return single_event({ + 'type': 'error', + 'error': 'U-Net model checkpoint was not found on the server.', + }) + + file_path = resolve_uploaded_file_path(filename) + if not file_path: + return single_event({'type': 'error', 'error': 'File not found'}) + + progress_queue = queue.Queue() + result_holder = [None] + error_holder = [None] + + def on_progress(current, total, **kwargs): + msg = { + 'type': 'progress', + 'current': current, + 'total': total, + 'tissue_patches': current, + } + msg.update(kwargs) + progress_queue.put(msg) + + def worker(): + try: + img_rgb = _read_rgb_for_unet(file_path) + result = _unet_run_inference( + UNET_MODEL, + img_rgb, + progress_callback=on_progress + ) + _unet_cache[filename] = { + 'prob_map': result['prob_map'], + 'tissue_mask': result['tissue_mask'], + 'threshold': UNET_BASELINE_THRESHOLD, + } + result_holder[0] = _format_unet_result_payload(result, UNET_BASELINE_THRESHOLD) + except Exception as e: + error_holder[0] = str(e) + finally: + progress_queue.put({'type': 'done'}) + + t = threading.Thread(target=worker, daemon=True) + t.start() + + def generate(): + while True: + try: + msg = progress_queue.get(timeout=300) + except queue.Empty: + yield f"data: {json.dumps({'type': 'error', 'error': 'U-Net analysis timed out'})}\n\n" + break + if msg['type'] == 'done': + if error_holder[0]: + yield f"data: {json.dumps({'type': 'error', 'error': error_holder[0]})}\n\n" + else: + yield f"data: {json.dumps({'type': 'result', 'data': result_holder[0]})}\n\n" + break + yield f"data: {json.dumps(msg)}\n\n" + + return Response(generate(), mimetype='text/event-stream', + headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'}) + + +@app.route("/rethreshold-unet/", methods=['GET']) +@login_required +def rethreshold_unet(filename): + """Re-evaluate the cached U-Net probability map at a new threshold. + Returns updated fibrosis_ratio. Heatmap stays the same (it visualises + raw probabilities, not the threshold).""" + thresh_str = request.args.get('threshold') + if thresh_str is None: + return jsonify({'error': 'Missing threshold parameter'}), 400 + try: + new_thresh = float(thresh_str) + except ValueError: + return jsonify({'error': 'Invalid threshold value'}), 400 + + entry = _unet_cache.get(filename) + if entry is None: + return jsonify({'error': 'No cached U-Net data. Run U-Net analysis first.'}), 404 + + entry['threshold'] = new_thresh + payload = _format_unet_result_payload( + {'prob_map': entry['prob_map'], 'tissue_mask': entry['tissue_mask']}, + threshold=new_thresh, + ) + return jsonify(payload), 200 + + +@app.route("/classify-mask-unet/", methods=['GET']) +@login_required +def classify_mask_unet(filename): + """SSE endpoint — runs VGG16+PCA+FCM classification on the U-Net binary mask + at the currently cached threshold.""" + entry = _unet_cache.get(filename) + if entry is None: + def err(): + yield f"data: {json.dumps({'type': 'error', 'error': 'No cached U-Net data. Run U-Net analysis first.'})}\n\n" + return Response(err(), mimetype='text/event-stream', + headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'}) + + prob_map = entry['prob_map'] + tissue_mask = entry['tissue_mask'] + threshold = entry.get('threshold', UNET_BASELINE_THRESHOLD) + binary_mask = ((prob_map >= threshold) & tissue_mask).astype(np.uint8) * 255 + + progress_queue = queue.Queue() + result_holder = [None] + + def on_progress(current, total, tissue_count, **kwargs): + msg = { + 'type': 'progress', + 'current': current, + 'total': total, + 'tissue_patches': tissue_count, + } + msg.update(kwargs) + progress_queue.put(msg) + + def worker(): + try: + result_holder[0] = classify_mask_array( + binary_mask, tissue_mask, progress_callback=on_progress + ) + except Exception as e: + result_holder[0] = {'status': 'error', 'message': str(e)} + finally: + progress_queue.put({'type': 'done'}) + + t = threading.Thread(target=worker, daemon=True) + t.start() + + def generate(): + while True: + try: + msg = progress_queue.get(timeout=300) + except queue.Empty: + break + if msg['type'] == 'done': + yield f"data: {json.dumps({'type': 'result', 'data': result_holder[0]})}\n\n" + break + else: + yield f"data: {json.dumps(msg)}\n\n" + + return Response(generate(), mimetype='text/event-stream', + headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'}) + + +def _is_patchable(file_path): + """Return True if the file should use patch-based SSE analysis.""" + if file_path.lower().endswith('.svs'): + return True + if file_path.lower().endswith(('.tif', '.tiff')): + return os.path.getsize(file_path) > 50 * 1024 * 1024 + return False + + +@app.route("/analyze-stream/", methods=['GET']) +@login_required +def analyze_file_stream(filename): + """SSE endpoint — streams patch progress then the final result.""" + file_path = resolve_uploaded_file_path(filename) + if not file_path: + return jsonify({'error': 'File not found'}), 404 + + if not _is_patchable(file_path): + # Not a patch candidate: run normal analysis and return as a single SSE result event + result = analyze_single_file(file_path) + _analysis_cache[file_path] = result + _save_result_meta(filename, result) + def single_event(): + yield f"data: {json.dumps({'type': 'result', 'data': result})}\n\n" + return Response(single_event(), mimetype='text/event-stream', + headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'}) + + progress_queue = queue.Queue() + + def on_progress(current, total, tissue_count, **kwargs): + msg = { + 'type': 'progress', + 'current': current, + 'total': total, + 'tissue_patches': tissue_count, + } + msg.update(kwargs) + progress_queue.put(msg) + + result_holder = [None] + + def worker(): + result_holder[0] = analyze_single_file_patched(file_path, progress_callback=on_progress) + _analysis_cache[file_path] = result_holder[0] + _save_result_meta(filename, result_holder[0]) + progress_queue.put({'type': 'done'}) + + t = threading.Thread(target=worker, daemon=True) + t.start() + + def generate(): + while True: + try: + msg = progress_queue.get(timeout=120) + except queue.Empty: + break + if msg['type'] == 'done': + yield f"data: {json.dumps({'type': 'result', 'data': result_holder[0]})}\n\n" + break + else: + yield f"data: {json.dumps(msg)}\n\n" + + return Response(generate(), mimetype='text/event-stream', + headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'}) + + +@app.route("/preview/", methods=['GET']) +@login_required +def preview_file(filename): + print(f"Previewing {filename}...") + + file_path = resolve_uploaded_file_path(filename) + + if not file_path: + return jsonify({'error': 'File not found'}), 404 + + result = preview_single_file(file_path) + return jsonify(result), 200 + + +@app.route("/rethreshold/", methods=['GET']) +@login_required +def rethreshold_file(filename): + """Apply a user-chosen threshold to cached deconvolution data.""" + thresh_str = request.args.get('threshold') + if thresh_str is None: + return jsonify({'error': 'Missing threshold parameter'}), 400 + try: + new_thresh = float(thresh_str) + except ValueError: + return jsonify({'error': 'Invalid threshold value'}), 400 + + # The cache key is the basename used during analysis + result = rethreshold(filename, new_thresh) + if result is None: + return jsonify({'error': 'No cached data for this file. Re-run analysis first.'}), 404 + + mask_pil, total_px, ratio, tissue_count = result + return jsonify({ + 'status': 'success', + 'filtered_image': f"data:image/jpeg;base64,{pil_to_b64(mask_pil)}", + 'fibrosis_ratio': float(ratio), + 'threshold': new_thresh, + }), 200 + + +@app.route("/rethreshold-area/", methods=['GET']) +@login_required +def rethreshold_area_file(filename): + """Apply a relative threshold delta to a specific region.""" + try: + delta = float(request.args.get('delta', '')) + x1 = float(request.args.get('x1', '')) + y1 = float(request.args.get('y1', '')) + x2 = float(request.args.get('x2', '')) + y2 = float(request.args.get('y2', '')) + except (ValueError, TypeError): + return jsonify({'error': 'Missing or invalid parameters (delta, x1, y1, x2, y2)'}), 400 + + result = rethreshold_area(filename, delta, x1, y1, x2, y2) + if result is None: + return jsonify({'error': 'No cached data or invalid region'}), 404 + + mask_pil, total_px, ratio, tissue_count = result + delta_map_b64 = get_delta_map(filename) + return jsonify({ + 'status': 'success', + 'filtered_image': f"data:image/jpeg;base64,{pil_to_b64(mask_pil)}", + 'fibrosis_ratio': float(ratio), + 'delta_map': f"data:image/png;base64,{delta_map_b64}" if delta_map_b64 else None, + 'has_local_edits': True, + }), 200 + + +@app.route("/reset-area/", methods=['GET']) +@login_required +def reset_area_file(filename): + """Reset threshold delta to zero in a specific region.""" + try: + x1 = float(request.args.get('x1', '')) + y1 = float(request.args.get('y1', '')) + x2 = float(request.args.get('x2', '')) + y2 = float(request.args.get('y2', '')) + except (ValueError, TypeError): + return jsonify({'error': 'Missing or invalid parameters (x1, y1, x2, y2)'}), 400 + + result = reset_area(filename, x1, y1, x2, y2) + if result is None: + return jsonify({'error': 'No cached data or invalid region'}), 404 + + mask_pil, total_px, ratio, tissue_count = result + delta_map_b64 = get_delta_map(filename) + from nafld import _deconv_cache + has_edits = _deconv_cache.get(filename, {}).get('has_local_edits', False) + return jsonify({ + 'status': 'success', + 'filtered_image': f"data:image/jpeg;base64,{pil_to_b64(mask_pil)}", + 'fibrosis_ratio': float(ratio), + 'delta_map': f"data:image/png;base64,{delta_map_b64}" if delta_map_b64 else None, + 'has_local_edits': has_edits, + }), 200 + + +@app.route("/undo-area/", methods=['GET']) +@login_required +def undo_area_file(filename): + """Undo the last area modification.""" + result = undo_area(filename) + if result is None: + return jsonify({'error': 'Nothing to undo'}), 404 + + mask_pil, total_px, ratio, tissue_count = result + delta_map_b64 = get_delta_map(filename) + from nafld import _deconv_cache + has_edits = _deconv_cache.get(filename, {}).get('has_local_edits', False) + return jsonify({ + 'status': 'success', + 'filtered_image': f"data:image/jpeg;base64,{pil_to_b64(mask_pil)}", + 'fibrosis_ratio': float(ratio), + 'delta_map': f"data:image/png;base64,{delta_map_b64}" if delta_map_b64 else None, + 'has_local_edits': has_edits, + }), 200 + + +@app.route("/excluded-mask/", methods=['GET']) +@app.route("/preview-excluded/", methods=['GET']) +@login_required +def excluded_mask_file(filename): + """Return a base64 RGBA PNG that paints excluded (non-tissue) pixels + in green. These are the exact pixels removed from the extent denominator.""" + overlay_b64 = get_excluded_mask(filename) + if overlay_b64 is None: + return jsonify({'error': 'No cached data for this file. Re-run analysis first.'}), 404 + return jsonify({ + 'status': 'success', + 'overlay': f"data:image/png;base64,{overlay_b64}", + }), 200 + + +def _parse_region_args(): + """Parse normalised x1,y1,x2,y2 query params. Returns tuple or None on error.""" + try: + return (float(request.args.get('x1', '')), + float(request.args.get('y1', '')), + float(request.args.get('x2', '')), + float(request.args.get('y2', ''))) + except (ValueError, TypeError): + return None + +@app.route("/analyze-area/", methods=['GET']) +@login_required +def analyze_area_file(filename): + """Return fibrosis extent for a normalised region under the magnifier.""" + region = _parse_region_args() + if region is None: + return jsonify({'error': 'Missing or invalid x1/y1/x2/y2'}), 400 + result = analyze_area(filename, *region) + if result is None: + return jsonify({'error': 'No cached data or invalid region'}), 404 + result['status'] = 'success' + return jsonify(result), 200 + +@app.route("/classify-area/", methods=['GET']) +@app.route("/classify-mask-area/", methods=['GET']) +@login_required +def classify_area_file(filename): + """Run VGG16+PCA+FCM on the magnifier region's mask. Returns membership scores.""" + region = _parse_region_args() + if region is None: + return jsonify({'error': 'Missing or invalid x1/y1/x2/y2'}), 400 + result = classify_area(filename, *region) + if result is None: + return jsonify({'error': 'No cached data or invalid region'}), 404 + return jsonify(result), 200 + + +@app.route("/classify-mask/", methods=['GET']) +@login_required +def classify_mask_file(filename): + """SSE endpoint — streams patch scanning progress then the final classification result.""" + from nafld import _deconv_cache + entry = _deconv_cache.get(filename) + if entry is None: + return jsonify({'error': 'No cached analysis data. Analyze the image first.'}), 404 + + progress_queue = queue.Queue() + + def on_progress(current, total, tissue_count, **kwargs): + msg = { + 'type': 'progress', + 'current': current, + 'total': total, + 'tissue_patches': tissue_count, + } + msg.update(kwargs) + progress_queue.put(msg) + + result_holder = [None] + + def worker(): + result_holder[0] = classify_from_mask(filename, progress_callback=on_progress) + progress_queue.put({'type': 'done'}) + + t = threading.Thread(target=worker, daemon=True) + t.start() + + def generate(): + while True: + try: + msg = progress_queue.get(timeout=300) + except queue.Empty: + break + if msg['type'] == 'done': + yield f"data: {json.dumps({'type': 'result', 'data': result_holder[0]})}\n\n" + break + else: + yield f"data: {json.dumps(msg)}\n\n" + + return Response(generate(), mimetype='text/event-stream', + headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'}) + + +@app.route("/download-single/", methods=['GET']) +@login_required +def download_single_file_csv(filename): + # Try sources in order: in-memory cache → disk sidecar → re-analyze + result = None + for cached_path, cached_result in _analysis_cache.items(): + if os.path.basename(cached_path) == filename or cached_path.endswith(filename): + result = cached_result + break + + if result is None: + result = _load_result_meta(filename) + + if result is None: + file_path = resolve_uploaded_file_path(filename) + if not file_path: + return jsonify({'error': 'File not found and no cached analysis available'}), 404 + result = analyze_single_file(file_path) + _analysis_cache[file_path] = result + _save_result_meta(filename, result) + + if result.get('status') != 'success': + return jsonify({'error': result.get('message', 'Analysis failed')}), 500 + + # If the user adjusted the threshold, use that ratio in the CSV + override_ratio = request.args.get('fibrosis_ratio') + fibrosis_ratio = float(override_ratio) if override_ratio is not None else result.get('fibrosis_ratio', '') + + # Prefer classification result from Diagnose workflow if available + classify_scores = request.args.get('classify_scores') + if classify_scores: + try: + membership_scores = json.loads(classify_scores) + except (json.JSONDecodeError, TypeError): + membership_scores = result.get('membership_scores') or {} + else: + membership_scores = result.get('membership_scores') or {} + + csv_buffer = io.StringIO() + writer = csv.writer(csv_buffer) + writer.writerow(['image_name', 'extent_percentage', 'None', 'Perisinusoidal', 'Bridging', 'Cirrosis']) + writer.writerow([ + filename, + fibrosis_ratio, + membership_scores.get('None', ''), + membership_scores.get('Perisinusoidal', ''), + membership_scores.get('Bridging', ''), + membership_scores.get('Cirrosis', ''), + ]) + + download_name = f"result_{filename}.csv" + response = Response(csv_buffer.getvalue(), mimetype='text/csv') + response.headers['Content-Disposition'] = f'attachment; filename="{download_name}"' + return response + + +def resolve_uploaded_file_path(filename): + + # 1. Find the file + # Check if it's in the dict (from your upload logic) or just in the folder + file_path = os.path.join(UPLOAD_FOLDER, filename) + if not os.path.exists(file_path) and filename in upload_file_dict: + # Logic for folders/zips if you kept that structure + folder = upload_file_dict[filename] + file_path = os.path.join(folder, filename) + elif not os.path.exists(file_path): + # Fallback to simple lookup in UPLOAD_FOLDER + # Note: Your upload adds a timestamp like _34:12 to filenames. + # For simplicity in testing, we might need to exact match. + # Let's assume for now you are testing with a file you just put there manually + # or the upload logic is simplified. + for f in os.listdir(UPLOAD_FOLDER): + if f.startswith(filename): + file_path = os.path.join(UPLOAD_FOLDER, f) + break + + if not os.path.exists(file_path): + return None + + return file_path + @app.route("/home") def home(): return { @@ -48,61 +788,70 @@ def home(): @app.route("/upload", methods =['POST']) +@login_required def upload_file(): + if 'file' not in request.files: + return jsonify({'error': 'No file part in request'}), 400 + file = request.files['file'] print(f'received: {file}') - if file: - print(f'{file.filename}_{datetime.now().minute}:{datetime.now().second}') - file.save(os.path.join(UPLOAD_FOLDER, f'{file.filename}_{datetime.now().minute}_{datetime.now().second}')) - return jsonify({'message': 'File successfully uploaded'}), 200 + if not file or file.filename == '': + return jsonify({'error': 'No file selected'}), 400 + + if not allowed_file(file.filename): + return jsonify({'error': f'Unsupported file type. Allowed: {sorted(ALLOWED_EXTENSIONS)}'}), 400 + + original_filename = secure_filename(file.filename) + root, ext = os.path.splitext(original_filename) + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S_%f') + safe_filename = f"{root}_{timestamp}{ext.lower()}" + + save_path = os.path.join(UPLOAD_FOLDER, safe_filename) + file.save(save_path) + + return jsonify({ + 'message': 'File successfully uploaded', + 'filename': safe_filename + }), 200 - return jsonify({'error': 'WHY NO WORK :( '}), 400 +# Dict to track chunked uploads: maps original filename -> timestamped safe name +_chunked_upload_names = {} -@app.route("/largefile", methods =['POST']) +@app.route("/largefile", methods=['POST']) +@login_required def upload_largefile(): - # print(f"\n \n \n \n STARTING UPLOAD \n \n \n \n") - # Get the file chunk from the request - chunk = request.files['file'] # 'file' is the field name used by Resumable.js - resumable_filename = request.form['resumableFilename'] # Original file name - resumable_chunk_number = request.form['resumableChunkNumber'] # Chunk index (1-based) + chunk = request.files['file'] + resumable_filename = request.form['resumableFilename'] + resumable_chunk_number = int(request.form['resumableChunkNumber']) total_chunks = int(request.form['resumableTotalChunks']) - full_file_path = os.path.join(UPLOAD_FOLDER, f'{resumable_filename}') - print(full_file_path) + # Build a deterministic safe name from the original filename + total chunks + # so that every gunicorn worker resolves the same path for a given upload. + import hashlib + original = secure_filename(resumable_filename) + root, ext = os.path.splitext(original) + tag = hashlib.md5(f"{resumable_filename}:{total_chunks}".encode()).hexdigest()[:8] + safe_name = f"{root}_{tag}{ext.lower()}" + full_file_path = os.path.join(UPLOAD_FOLDER, safe_name) + + # On first chunk, start fresh in case of re-upload + if resumable_chunk_number == 1 and os.path.exists(full_file_path): + os.remove(full_file_path) + try: - with open(full_file_path,'ab') as chunked_file: - chunked_file.write(chunk.read()) - except: - jsonify({"status": "Error writing chunk to file"}), 400 - - if(resumable_chunk_number == total_chunks): - return jsonify({"status": "File upload complete"}), 200 + with open(full_file_path, 'ab') as f: + f.write(chunk.read()) + except Exception as e: + print(f"Chunk write error: {e}") + return jsonify({"status": "Error writing chunk to file"}), 400 - # final_file.write(chunk_file.read()) - # # Create the file path for the chunks - # chunk_folder = os.path.join(UPLOAD_FOLDER, resumable_filename) - # os.makedirs(chunk_folder, exist_ok=True) - - # # Save the chunk to the folder - # chunk_filename = f"{resumable_filename}.part{resumable_chunk_number}" - # chunk.save(os.path.join(chunk_folder, chunk_filename)) - - # # Check if all chunks have been uploaded - # total_chunks = int(request.form['resumableTotalChunks']) - # if len(os.listdir(chunk_folder)) == total_chunks: - # # Assemble all chunks into the final file - # with open(os.path.join(UPLOAD_FOLDER, resumable_filename), 'wb') as final_file: - # for i in range(1, total_chunks + 1): - # chunk_path = os.path.join(chunk_folder, f"{resumable_filename}.part{i}") - # with open(chunk_path, 'rb') as chunk_file: - # final_file.write(chunk_file.read()) - - # # Optionally, remove the chunk files after assembling - # for filename in os.listdir(chunk_folder): - # os.remove(os.path.join(chunk_folder, filename)) - # os.rmdir(chunk_folder) - - # return jsonify({"status": "File upload complete"}), 200 + print(f"Chunk {resumable_chunk_number}/{total_chunks} for {safe_name}") + + if resumable_chunk_number == total_chunks: + return jsonify({ + "status": "File upload complete", + "filename": safe_name + }), 200 return jsonify({"status": "Chunk upload successful"}), 200 @@ -184,8 +933,8 @@ def full_file_upload(): try: with open(full_file_path,'ab') as chunked_file: chunked_file.write(chunk.read()) - except: - jsonify({"status": "Error writing chunk to file"}), 400 + except Exception: + return jsonify({"status": "Error writing chunk to file"}), 400 if(int(resumable_chunk_number) == total_chunks): print("Returned file") @@ -196,7 +945,16 @@ def full_file_upload(): return jsonify({"status": "Chunk upload successful"}), 200 +# Catch-all: serve React index.html for client-side routing (production only) +if _static_dir: + @app.route('/', defaults={'path': ''}) + @app.route('/') + def serve_react(path): + if path and os.path.exists(os.path.join(app.static_folder, path)): + return app.send_static_file(path) + return app.send_static_file('index.html') + if __name__ == "__main__": - app.run(debug=True) + app.run(host=os.environ.get('FLASK_HOST', '127.0.0.1'), debug=True) diff --git a/NAFLD/src/py-src/models/final_merged_params.pkl b/NAFLD/src/py-src/models/final_merged_params.pkl new file mode 100644 index 0000000..14f6975 Binary files /dev/null and b/NAFLD/src/py-src/models/final_merged_params.pkl differ diff --git a/NAFLD/src/py-src/models/pca_model.pkl b/NAFLD/src/py-src/models/pca_model.pkl new file mode 100644 index 0000000..5c210e4 Binary files /dev/null and b/NAFLD/src/py-src/models/pca_model.pkl differ diff --git a/NAFLD/src/py-src/nafld.py b/NAFLD/src/py-src/nafld.py index 4f3ff2b..5b7b123 100644 --- a/NAFLD/src/py-src/nafld.py +++ b/NAFLD/src/py-src/nafld.py @@ -5,7 +5,7 @@ # The path can also be read from a config file, etc. # Home and lab path happen to be the same -OPENSLIDE_PATH = r'C:\\openslide\\openslide-bin-4.0.0.6-windows-x64\\bin' +OPENSLIDE_PATH = r'C:\\Program Files\\openslide-bin-4.0.0.11-windows-x64\\bin' import os @@ -36,6 +36,7 @@ import tensorflow as tf #from tqdm import tqdm +import gc import cv2 from PIL import Image import ipywidgets as widgets @@ -47,7 +48,74 @@ # drive.mount('/content/drive', force_remount=True) psr_results = [] -model_save_path = 'C:\\Projects\\Machine Learning\\nafld backend\\nafld_back\\Model\\PCA' +# Cache for deconvolution data so rethresholding is instant. +# Keyed by cache_key (string) -> { red_stain, tissue_mask, img_shape } +# WORKER-SHARED via on-disk pickle: gunicorn workers each have their own +# in-memory dict, so we transparently fall through to a shared file when +# the local in-memory copy is empty (e.g. analysis ran in worker A but +# Q-area-inspect lands on worker B). Only one entry is cached at a time +# (see cache_deconv_data) so disk usage stays bounded. +import pickle as _pickle +import tempfile as _tempfile +_DECONV_CACHE_FILE = os.path.join(_tempfile.gettempdir(), 'nafld_deconv_cache.pkl') + + +class _SharedDeconvCache: + def __init__(self): + self._mem = {} + + def _load_from_disk(self): + try: + if os.path.exists(_DECONV_CACHE_FILE): + with open(_DECONV_CACHE_FILE, 'rb') as f: + return _pickle.load(f) + except Exception as e: + print(f"[Cache] disk load failed: {e}") + return None + + def _save_to_disk(self, key, entry): + try: + with open(_DECONV_CACHE_FILE, 'wb') as f: + _pickle.dump({'key': key, 'entry': entry}, f, protocol=_pickle.HIGHEST_PROTOCOL) + except Exception as e: + print(f"[Cache] disk save failed: {e}") + + def get(self, key): + if key in self._mem: + return self._mem[key] + # Cache miss in this worker — try the shared disk copy + disk = self._load_from_disk() + if disk is not None and disk.get('key') == key: + self._mem[key] = disk['entry'] + return disk['entry'] + return None + + def __setitem__(self, key, entry): + self._mem[key] = entry + self._save_to_disk(key, entry) + + def __contains__(self, key): + return self.get(key) is not None + + def clear(self): + self._mem.clear() + try: + if os.path.exists(_DECONV_CACHE_FILE): + os.remove(_DECONV_CACHE_FILE) + except Exception: + pass + + def flush_to_disk(self, key): + """Re-persist the (mutated) entry currently held in memory under `key`.""" + entry = self._mem.get(key) + if entry is not None: + self._save_to_disk(key, entry) + + +_deconv_cache = _SharedDeconvCache() + +# Point to the 'models' subfolder +model_save_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'models') data_path = './data' ''' @@ -60,28 +128,22 @@ with open(os.path.join(model_save_path,'pca_model.pkl'), 'rb') as file: pca_model = pickle.load(file) -with open(os.path.join(model_save_path, 'model_params.pkl'), 'rb') as file: +with open(os.path.join(model_save_path, 'final_merged_params.pkl'), 'rb') as file: model_params = pickle.load(file) centroids = model_params['centroids'] -u = model_params['u'] -u0 = model_params['u0'] -d = model_params['d'] -jm = model_params['jm'] -p = model_params['p'] -fpc = model_params['fpc'] -best_m = model_params['best_m'] - +best_m = model_params.get('best_m', 1.15) # K-Means-initialized FCM with strict fuzziness +cluster_map = model_params.get('cluster_map', None) # e.g. {0:0, 1:1, 2:2, 3:3, 4:3} #8f7972 -> 143,121,114 # 0.560,0.474,0.447 stain_matrix = np.array([[0.148, 0.722, 0.618], - [0.462, 0.602, 0.651], - [0.187, 0.523, 0.831]]) + [0.462, 0.602, 0.651], + [0.187, 0.523, 0.831]]) -stain_matrix = np.array([[0.39, 0.39, 0.39], - [0.560, 0.474, 0.447], - [0.29, 0.33, 0.29]]) +# stain_matrix = np.array([[0.39, 0.39, 0.39], +# [0.560, 0.474, 0.447], +# [0.29, 0.33, 0.29]]) # stain_matrix = np.array([[0.3, 0.3, 0.3], # [0.560, 0.474, 0.447], @@ -93,11 +155,13 @@ cluster_lookup_table = { 0: 'Category A: None', - 1: 'Category B: Perisinusoidal/Portal', - 2: 'Category C: Bridging', - 3: 'Category D: Cirrosis', + 1: 'Category C: Bridging', + 2: 'Category D: Cirrosis', + 3: 'Category B: Perisinusoidal/Portal', } +membership_column_names = ['None', 'Bridging', 'Cirrosis', 'Perisinusoidal'] + #Helper functions def extract_features(pil_img): @@ -109,36 +173,865 @@ def extract_features(pil_img): predictions = predictions.flatten() return predictions -def fibrosis_filter(window, stain_matrix = stain_matrix): +def _find_threshold_by_descent(red_stain, tissue_mask, num_steps=100, jump_factor=2.0): + """ + Descending threshold sweep with jump detection. + + Treats the red-stain channel as a topographical map. Starts at the + ceiling (max red value) and steps the threshold downward. At each + step, measures the extent (% tissue pixels selected). When the + rate of change in extent jumps significantly (> jump_factor times + the running average rate), we've crossed from true collagen into + brown parenchyma — so we snap back to the threshold just before + the jump. + + Uses a pre-sorted array + searchsorted so each step is O(log n) + instead of O(n), giving O(n log n) total (dominated by the sort). + + Parameters + ---------- + red_stain : 2D array of red-stain deconvolution values + tissue_mask : boolean 2D array (True = tissue pixel) + num_steps : how many threshold levels to test between max and min + jump_factor : a jump is detected when the rate exceeds this many + times the running average rate + + Returns + ------- + float : the chosen threshold + """ + tissue_vals = red_stain[tissue_mask] + if tissue_vals.size < 200: + return 2.4 # fallback + + ceiling = float(np.percentile(tissue_vals, 99.5)) # ignore extreme outliers + floor = float(np.percentile(tissue_vals, 5)) # don't go below 5th pctl + if ceiling - floor < 0.05: + return 2.4 + + # Sort once — extent lookups become O(log n) via searchsorted + sorted_vals = np.sort(tissue_vals) + n = sorted_vals.size + step_size = (ceiling - floor) / num_steps + + prev_extent = 0.0 + rates = [] # extent increase per step + thresholds = [] # threshold at each step + + # Sweep from ceiling downward + for i in range(num_steps + 1): + t = ceiling - i * step_size + # O(log n) instead of O(n) + idx = np.searchsorted(sorted_vals, t, side='right') + extent = (n - idx) / n * 100.0 + rate = extent - prev_extent + + thresholds.append(t) + rates.append(rate) + prev_extent = extent + + # Find the jump: where rate suddenly spikes relative to running avg + # Skip the first few steps (often noisy at the very top) + min_steps = 5 + best_thresh = floor # default to floor if no jump found + + for i in range(min_steps, len(rates)): + # Running average of rates so far (excluding current) + avg_rate = np.mean(rates[min_steps:i]) if i > min_steps else rates[i] + if avg_rate < 1e-6: + continue + + if rates[i] > jump_factor * avg_rate and rates[i] > 0.5: + # Jump detected — use the threshold from one step before + best_thresh = thresholds[max(i - 1, 0)] + print(f"[Threshold] Jump at step {i}: rate={rates[i]:.2f}% vs avg={avg_rate:.2f}% -> thresh={best_thresh:.3f}") + break + else: + # No clear jump found — use the point where rate is highest + # (the biggest single-step increase = boundary between red & brown) + peak_idx = np.argmax(rates[min_steps:]) + min_steps + best_thresh = thresholds[max(peak_idx - 1, 0)] + print(f"[Threshold] No jump, using peak-rate at step {peak_idx}: thresh={best_thresh:.3f}") + + print(f"[Threshold] Sweep: ceiling={ceiling:.2f} floor={floor:.2f} chosen={best_thresh:.3f}") + return best_thresh + + +def fibrosis_filter(window, stain_matrix=None): + # Always use the adaptive descending-threshold method. + return _fibrosis_filter_original(window, stain_matrix) + + +def _fibrosis_filter_simple(window, stain_matrix=None): + """Simple fixed-threshold method (kept for reference / fallback).""" + _temp_stain_matrix = np.array([[0.39, 0.39, 0.39], + [0.560, 0.474, 0.447], + [0.29, 0.33, 0.29]]) + img_array = np.array(window) img_float = img_array.astype(np.float32) / 255.0 - #perform color deconv - stains = np.dot(-np.log(img_float + np.finfo(float).eps), stain_matrix.T) - #Select the stain for red regions + stains = np.dot(-np.log(img_float + np.finfo(float).eps), _temp_stain_matrix.T) red_stain = stains[:, :, 0] - hsv_img = cv2.cvtColor(img_array, cv2.COLOR_RGB2HSV) mask = (red_stain > 0.9) & (img_array.sum(axis=-1) > 50) + + binary_mask = np.zeros(img_array.shape[:2], dtype=np.uint8) + binary_mask[mask] = 255 + + total_selected_pixels = int(np.sum(binary_mask == 255)) + tissue_mask = img_array.sum(axis=-1) > 50 + tissue_pixel_count = int(np.sum(tissue_mask)) + if tissue_pixel_count > 0: + selected_ratio = (total_selected_pixels / tissue_pixel_count) * 100 + else: + selected_ratio = 0.0 + + result_image = np.zeros_like(img_array) + result_image[binary_mask == 255] = [255, 255, 255] + result_image_pil = Image.fromarray(result_image) + + thresh = 0.9 + return result_image_pil, total_selected_pixels, selected_ratio, tissue_pixel_count, thresh + + +def _fibrosis_filter_original(window, stain_matrix=None): + """ORIGINAL METHOD — commented out temporarily, kept for easy restore.""" + if stain_matrix is None: + stain_matrix = globals()['stain_matrix'] + + img_array = np.array(window) + + # ── Brightness normalization ─────────────────────────────── + tissue_px = img_array[(img_array.sum(axis=-1) > 50) & (np.mean(img_array, axis=2) < 240)] + if tissue_px.size > 0: + current_median = float(np.median(tissue_px)) + if current_median > 10: + target_median = 160.0 + scale = target_median / current_median + img_norm = np.clip(img_array.astype(np.float32) * scale, 0, 255).astype(np.uint8) + print(f"[Brightness] median {current_median:.0f} -> scaled by {scale:.2f}") + else: + img_norm = img_array + else: + img_norm = img_array + + img_float = img_norm.astype(np.float32) / 255.0 + stains = np.dot(-np.log(img_float + np.finfo(float).eps), stain_matrix.T) + red_stain = stains[:, :, 0] + + # Tissue mask MUST be derived from the ORIGINAL image so that + # white/near-white background is reliably excluded regardless of + # the brightness-normalisation scale factor. When a slide is + # already bright (median > 160) the normalisation scales pixels + # DOWN, which used to push 255-white background below the 220 + # cut-off and incorrectly count it as tissue. + tissue_mask = (img_array.sum(axis=-1) > 50) & (np.mean(img_array, axis=2) < 220) + + thresh = _find_threshold_by_descent(red_stain, tissue_mask) + + mask = (red_stain > thresh) & tissue_mask mask = mask.astype(np.uint8) * 255 + + kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)) + mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel) + total_selected_pixels = np.sum(mask == 255) - total_pixels = mask.size - selected_ratio = (total_selected_pixels / total_pixels) * 100 + tissue_pixel_count = int(np.sum(tissue_mask)) + if tissue_pixel_count > 0: + selected_ratio = (total_selected_pixels / tissue_pixel_count) * 100 + else: + selected_ratio = 0.0 result_image = np.zeros_like(img_array) result_image[mask == 255] = [255, 255, 255] result_image_pil = Image.fromarray(result_image) - return result_image_pil, total_selected_pixels, selected_ratio + return result_image_pil, total_selected_pixels, selected_ratio, tissue_pixel_count, thresh + -def predict_cluster_pil(pil_image): +def cache_deconv_data(cache_key, red_stain, tissue_mask, img_shape, auto_threshold=None): + """Store deconvolution intermediates so rethreshold is instant.""" + _deconv_cache.clear() # only keep one cached image at a time + _deconv_cache[cache_key] = { + 'red_stain': red_stain, + 'tissue_mask': tissue_mask, + 'img_shape': img_shape, + 'auto_threshold': auto_threshold, + } + + +def rethreshold(cache_key, new_threshold): + """Apply a new global threshold to cached deconvolution data. + Resets any per-pixel area deltas. Returns (mask_pil, total_pixels, ratio, tissue_count) or None.""" + entry = _deconv_cache.get(cache_key) + if entry is None: + return None + + red_stain = entry['red_stain'] + tissue_mask = entry['tissue_mask'] + h, w = red_stain.shape + + raw = (red_stain > new_threshold) & tissue_mask + entry['raw_mask'] = raw.copy() + entry['has_local_edits'] = False + entry['current_threshold'] = new_threshold + entry['threshold_delta'] = np.zeros((h, w), dtype=np.float32) + entry['undo_stack'] = [] + entry['_last_edit_region'] = None + + mask = raw.astype(np.uint8) * 255 + kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)) + mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel) + + total_selected = int(np.sum(mask == 255)) + tissue_count = int(np.sum(tissue_mask)) + ratio = (total_selected / tissue_count * 100) if tissue_count > 0 else 0.0 + + result_image = np.zeros((h, w, 3), dtype=np.uint8) + result_image[mask == 255] = [255, 255, 255] + mask_pil = Image.fromarray(result_image) + + _deconv_cache.flush_to_disk(cache_key) + return mask_pil, total_selected, ratio, tissue_count + + +def _regions_similar(r1, r2, tol=5): + """Check if two pixel regions are close enough to be the same magnifier area.""" + return (abs(r1[0] - r2[0]) <= tol and abs(r1[1] - r2[1]) <= tol and + abs(r1[2] - r2[2]) <= tol and abs(r1[3] - r2[3]) <= tol) + + +def _recompute_mask(entry): + """Recompute fibrosis mask from current threshold_delta. Returns (mask_pil, total, ratio, tissue_count).""" + red_stain = entry['red_stain'] + tissue_mask = entry['tissue_mask'] + h, w = red_stain.shape + base_thresh = entry.get('current_threshold', entry.get('auto_threshold', 2.4)) + td = entry.get('threshold_delta') + if td is not None and np.any(td != 0): + effective = base_thresh + td + else: + effective = base_thresh + raw = (red_stain > effective) & tissue_mask + entry['raw_mask'] = raw + mask = raw.astype(np.uint8) * 255 + kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)) + mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel) + total_selected = int(np.sum(mask == 255)) + tissue_count = int(np.sum(tissue_mask)) + ratio = (total_selected / tissue_count * 100) if tissue_count > 0 else 0.0 + result_image = np.zeros((h, w, 3), dtype=np.uint8) + result_image[mask == 255] = [255, 255, 255] + mask_pil = Image.fromarray(result_image) + return mask_pil, total_selected, ratio, tissue_count + + +def _get_tissue_threshold_range(entry): + if 'tissue_threshold_range' in entry: + return entry['tissue_threshold_range'] + + red_stain = entry['red_stain'] + tissue_mask = entry['tissue_mask'] + tissue_vals = red_stain[tissue_mask] + tissue_vals = tissue_vals[np.isfinite(tissue_vals)] + + if tissue_vals.size == 0: + entry['tissue_threshold_range'] = None + else: + entry['tissue_threshold_range'] = ( + float(np.nextafter(np.min(tissue_vals), -np.inf)), + float(np.nextafter(np.max(tissue_vals), np.inf)), + ) + + return entry['tissue_threshold_range'] + + +def rethreshold_area(cache_key, delta, x1, y1, x2, y2): + """Apply a relative threshold delta to a specific normalised region (0-1 coords). + Each pixel accumulates its own offset, limited to the useful tissue stain range. + Returns (mask_pil, total_pixels, ratio, tissue_count) or None.""" + entry = _deconv_cache.get(cache_key) + if entry is None: + return None + + red_stain = entry['red_stain'] + tissue_mask = entry['tissue_mask'] + h, w = red_stain.shape + + if 'threshold_delta' not in entry: + entry['threshold_delta'] = np.zeros((h, w), dtype=np.float32) + if 'undo_stack' not in entry: + entry['undo_stack'] = [] + + base_thresh = entry.get('current_threshold', entry.get('auto_threshold', 2.4)) + + px1 = max(0, int(x1 * w)) + py1 = max(0, int(y1 * h)) + px2 = min(w, int(x2 * w)) + py2 = min(h, int(y2 * h)) + + if px2 <= px1 or py2 <= py1: + return None + + # Push undo snapshot when editing a new region + current_region = (px1, py1, px2, py2) + last_region = entry.get('_last_edit_region') + if last_region is None or not _regions_similar(last_region, current_region): + entry['undo_stack'].append(entry['threshold_delta'].copy()) + entry['_last_edit_region'] = current_region + + region = entry['threshold_delta'][py1:py2, px1:px2] + region += delta + + threshold_range = _get_tissue_threshold_range(entry) + if threshold_range is not None: + min_threshold, max_threshold = threshold_range + min_delta = min(0.0, min_threshold - base_thresh) + max_delta = max(0.0, max_threshold - base_thresh) + np.clip(region, min_delta, max_delta, out=region) + + entry['has_local_edits'] = True + + out = _recompute_mask(entry) + _deconv_cache.flush_to_disk(cache_key) + return out + + +def reset_area(cache_key, x1, y1, x2, y2): + """Reset threshold delta to zero in a specific normalised region. + Pushes current state to undo stack.""" + entry = _deconv_cache.get(cache_key) + if entry is None: + return None + + h, w = entry['red_stain'].shape + + if 'threshold_delta' not in entry: + entry['threshold_delta'] = np.zeros((h, w), dtype=np.float32) + if 'undo_stack' not in entry: + entry['undo_stack'] = [] + + px1 = max(0, int(x1 * w)) + py1 = max(0, int(y1 * h)) + px2 = min(w, int(x2 * w)) + py2 = min(h, int(y2 * h)) + + if px2 <= px1 or py2 <= py1: + return None + + entry['undo_stack'].append(entry['threshold_delta'].copy()) + entry['_last_edit_region'] = None + entry['threshold_delta'][py1:py2, px1:px2] = 0.0 + entry['has_local_edits'] = bool(np.any(entry['threshold_delta'] != 0)) + + out = _recompute_mask(entry) + _deconv_cache.flush_to_disk(cache_key) + return out + + +def undo_area(cache_key): + """Undo the last area modification by restoring from the undo stack.""" + entry = _deconv_cache.get(cache_key) + if entry is None: + return None + + undo_stack = entry.get('undo_stack', []) + if not undo_stack: + return None + + entry['threshold_delta'] = undo_stack.pop() + entry['_last_edit_region'] = None + entry['has_local_edits'] = bool(np.any(entry['threshold_delta'] != 0)) + + out = _recompute_mask(entry) + _deconv_cache.flush_to_disk(cache_key) + return out + + +def get_delta_map(cache_key): + """Return a small base64 PNG showing modified regions in green, untouched in white.""" + entry = _deconv_cache.get(cache_key) + if entry is None: + return None + td = entry.get('threshold_delta') + if td is None or not np.any(td != 0): + return None + h, w = td.shape + modified = (td != 0).astype(np.uint8) + max_side = 100 + scale = max_side / max(h, w) + map_h = max(1, int(h * scale)) + map_w = max(1, int(w * scale)) + modified_small = cv2.resize(modified, (map_w, map_h), interpolation=cv2.INTER_NEAREST) + img = np.full((map_h, map_w, 4), [255, 255, 255, 255], dtype=np.uint8) + img[modified_small > 0] = [78, 205, 196, 255] + pil_img = Image.fromarray(img, 'RGBA') + buf = BytesIO() + pil_img.save(buf, format='PNG') + return base64.b64encode(buf.getvalue()).decode('utf-8') + + +def get_excluded_mask(cache_key, max_side=2048): + """Return a base64 RGBA PNG that paints excluded (non-tissue) pixels + in green and leaves tissue pixels transparent. + + Excluded pixels are exactly the ones removed from the extent + denominator (too dark or too light to be tissue), so the overlay + matches the percentage calculation pixel-for-pixel. + """ + entry = _deconv_cache.get(cache_key) + if entry is None: + return None + tissue_mask = entry['tissue_mask'] + h, w = tissue_mask.shape + + # Downsample for fast transmission while staying nearest-neighbour + # so the boundary stays crisp. + if max(h, w) > max_side: + scale = max_side / max(h, w) + target_h = max(1, int(h * scale)) + target_w = max(1, int(w * scale)) + excluded = (~tissue_mask).astype(np.uint8) + excluded = cv2.resize(excluded, (target_w, target_h), + interpolation=cv2.INTER_NEAREST) + else: + target_h, target_w = h, w + excluded = (~tissue_mask).astype(np.uint8) + + img = np.zeros((target_h, target_w, 4), dtype=np.uint8) + # Bright green, semi-transparent so the original image shows through. + img[excluded > 0] = [76, 217, 100, 140] + pil_img = Image.fromarray(img, 'RGBA') + buf = BytesIO() + pil_img.save(buf, format='PNG') + return base64.b64encode(buf.getvalue()).decode('utf-8') + + +def _normalised_region_to_pixels(entry, x1, y1, x2, y2): + """Convert (x1,y1,x2,y2) normalised 0-1 coords to integer pixel + bounds inside the cached image. Returns None when invalid.""" + h, w = entry['red_stain'].shape + px1 = max(0, int(x1 * w)) + py1 = max(0, int(y1 * h)) + px2 = min(w, int(x2 * w)) + py2 = min(h, int(y2 * h)) + if px2 <= px1 or py2 <= py1: + return None + return px1, py1, px2, py2 + + +def analyze_area(cache_key, x1, y1, x2, y2): + """Compute fibrosis extent for a specific normalised region using the + same cached red_stain / tissue_mask / threshold as the global mask. + + Honours any threshold deltas applied by the user. Returns + {ratio, fibrosis_pixels, tissue_pixels} or None if no cache. + """ + entry = _deconv_cache.get(cache_key) + if entry is None: + return None + bounds = _normalised_region_to_pixels(entry, x1, y1, x2, y2) + if bounds is None: + return None + px1, py1, px2, py2 = bounds + + red_stain = entry['red_stain'][py1:py2, px1:px2] + tissue_mask = entry['tissue_mask'][py1:py2, px1:px2] + base_thresh = entry.get('current_threshold', entry.get('auto_threshold', 2.4)) + td = entry.get('threshold_delta') + if td is not None and np.any(td != 0): + effective = base_thresh + td[py1:py2, px1:px2] + else: + effective = base_thresh + + raw = (red_stain > effective) & tissue_mask + mask = raw.astype(np.uint8) * 255 + kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)) + mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel) + + fibrosis_pixels = int(np.sum(mask == 255)) + tissue_pixels = int(np.sum(tissue_mask)) + ratio = (fibrosis_pixels / tissue_pixels * 100) if tissue_pixels > 0 else 0.0 + return { + 'fibrosis_ratio': float(ratio), + 'fibrosis_pixels': fibrosis_pixels, + 'tissue_pixels': tissue_pixels, + } + + +def classify_area(cache_key, x1, y1, x2, y2): + """Run VGG16 + PCA + FCM on a single cropped region of the current + fibrosis mask. Fast (single-tile path). Returns membership scores + or None when the cache is missing / region is empty / region is + almost entirely background. + """ + entry = _deconv_cache.get(cache_key) + if entry is None: + return None + bounds = _normalised_region_to_pixels(entry, x1, y1, x2, y2) + if bounds is None: + return None + px1, py1, px2, py2 = bounds + + # Same exclusion criterion as the global classifier + tissue_frac = float(np.mean(entry['tissue_mask'][py1:py2, px1:px2])) + if tissue_frac < 0.10: + return {'status': 'background', 'tissue_frac': tissue_frac} + + mask_pil, _, _, _ = _recompute_mask(entry) + mask_np = np.array(mask_pil) + tile = mask_np[py1:py2, px1:px2] + if tile.shape[0] < 32 or tile.shape[1] < 32: + return {'status': 'too_small'} + + tile_pil = Image.fromarray(tile) + try: + features = extract_features(tile_pil) + reduc_features = pca_model.transform([features]) + u, _, _, _, _, _ = cmeans_predict(reduc_features.T, centroids, + m=best_m, error=0.1, maxiter=100) + if cluster_map is not None: + n_groups = max(cluster_map.values()) + 1 + u_merged = np.zeros((n_groups, u.shape[1])) + for src, dst in cluster_map.items(): + u_merged[dst] += u[src] + u = u_merged + label_idx = int(np.argmax(u, axis=0)) + label_text = cluster_lookup_table.get(label_idx, f"Unknown Cluster {label_idx}") + scores = {membership_column_names[i]: float(u[i][0]) for i in range(4)} + return { + 'status': 'success', + 'cluster_label': label_text, + 'membership_scores': scores, + 'tissue_frac': tissue_frac, + } + finally: + del tile_pil + + +def classify_mask_array(mask_np, tissue_mask, patch_size=512, top_n=5, + progress_callback=None): + """ + Generic version of classify_from_mask that operates on raw arrays + (no _deconv_cache lookup). Used by the U-Net pipeline which has its + own per-filename cache. + + mask_np : 2D uint8/bool array — binary fibrosis mask + tissue_mask : 2D bool array — tissue inclusion mask (for tile gating) + """ + if mask_np is None: + return None + if mask_np.ndim == 3: + mask_np = mask_np[..., 0] + h, w = mask_np.shape[:2] + + # Match large-image heuristic from analyze_single_file_patched + is_large = max(h, w) > 2048 + TISSUE_FRAC_THRESHOLD = 0.25 + + if not is_large: + mask_uint = mask_np.astype(np.uint8) * (255 if mask_np.dtype == bool else 1) + if mask_uint.ndim == 2: + mask_uint = np.stack([mask_uint] * 3, axis=-1) + mask_pil = Image.fromarray(mask_uint).convert('RGB') + features = extract_features(mask_pil) + reduc_features = pca_model.transform([features]) + u, _, _, _, _, _ = cmeans_predict(reduc_features.T, centroids, m=best_m, error=0.1, maxiter=100) + if cluster_map is not None: + n_groups = max(cluster_map.values()) + 1 + u_merged = np.zeros((n_groups, u.shape[1])) + for src, dst in cluster_map.items(): + u_merged[dst] += u[src] + u = u_merged + label_idx = int(np.argmax(u, axis=0)) + label_text = cluster_lookup_table.get(label_idx, f"Unknown Cluster {label_idx}") + scores = {membership_column_names[i]: float(u[i][0]) for i in range(4)} + return { + "status": "success", + "cluster_label": label_text, + "membership_scores": scores, + "patch_count": 1, + **scores, + } + + rows_range = list(range(0, h, patch_size)) + cols_range = list(range(0, w, patch_size)) + num_rows = len(rows_range) + num_cols = len(cols_range) + total_tiles = num_rows * num_cols + current_tile = 0 + + patch_memberships = [] + patch_coords = [] + + for ri, py in enumerate(rows_range): + for ci, px in enumerate(cols_range): + current_tile += 1 + tile = mask_np[py:py + patch_size, px:px + patch_size] + th, tw = tile.shape[:2] + if th < 32 or tw < 32: + continue + tile_frac = float(np.mean(tissue_mask[py:py + th, px:px + tw])) if tissue_mask is not None else 1.0 + if tile_frac < TISSUE_FRAC_THRESHOLD: + if progress_callback: + progress_callback(current_tile, total_tiles, len(patch_memberships), + grid_rows=num_rows, grid_cols=num_cols, + tile_row=ri, tile_col=ci, is_tissue=False) + continue + tile_arr = tile.astype(np.uint8) + if tile_arr.dtype == bool or tile_arr.max() <= 1: + tile_arr = tile_arr * 255 + if tile_arr.ndim == 2: + tile_arr = np.stack([tile_arr] * 3, axis=-1) + tile_pil = Image.fromarray(tile_arr).convert('RGB') + try: + features = extract_features(tile_pil) + reduc_features = pca_model.transform([features]) + u, _, _, _, _, _ = cmeans_predict(reduc_features.T, centroids, m=best_m, error=0.1, maxiter=100) + if cluster_map is not None: + n_groups = max(cluster_map.values()) + 1 + u_merged = np.zeros((n_groups, u.shape[1])) + for src, dst in cluster_map.items(): + u_merged[dst] += u[src] + u = u_merged + patch_memberships.append([float(u[i][0]) for i in range(4)]) + patch_coords.append({'row': ri, 'col': ci, 'py': py, 'px': px}) + except Exception as e: + print(f"[classify_mask_array] Patch ({py},{px}) failed: {e}") + finally: + del tile_pil + + if len(patch_memberships) % 10 == 0: + gc.collect() + + if progress_callback: + progress_callback(current_tile, total_tiles, len(patch_memberships), + grid_rows=num_rows, grid_cols=num_cols, + tile_row=ri, tile_col=ci, is_tissue=True) + + if not patch_memberships: + return {"status": "error", "message": "No classifiable tiles found in mask"} + + severity = [m[1] + m[2] for m in patch_memberships] + ranked_indices = sorted(range(len(severity)), key=lambda i: severity[i], reverse=True) + top_indices = ranked_indices[:min(top_n, len(ranked_indices))] + top_memberships = [patch_memberships[i] for i in top_indices] + avg_memberships = np.mean(top_memberships, axis=0) + + label_idx = int(np.argmax(avg_memberships)) + label_text = cluster_lookup_table.get(label_idx, f"Unknown Cluster {label_idx}") + scores = {membership_column_names[i]: float(avg_memberships[i]) for i in range(4)} + worst_patches = [patch_coords[i] for i in top_indices] + + return { + "status": "success", + "cluster_label": label_text, + "membership_scores": scores, + "patch_count": len(patch_memberships), + "top_n_used": min(top_n, len(patch_memberships)), + "worst_patches": worst_patches, + "grid_rows": num_rows, + "grid_cols": num_cols, + "img_h": h, + "img_w": w, + "patch_size": patch_size, + **scores, + } + + +def classify_from_mask(cache_key, patch_size=512, top_n=5, progress_callback=None): + """ + Run VGG16 + PCA + FCM classification on the current refined B&W mask. + + For large (patchable) images the mask is tiled into patch_size squares + and each tissue tile is classified independently. The final score is + the average of the *top_n* worst (most-fibrotic) tiles — i.e. the tiles + whose membership leans most toward higher-stage disease. + + For small images the whole mask is classified in a single pass. + + The tissue_mask from the original analysis (left image) is used to + determine which tiles to skip — carrying the exclusion principle from + the original PSR staining over to the mask classification. + + progress_callback(current_tile, total_tiles, tissue_count, + grid_rows, grid_cols, tile_row, tile_col, is_tissue) + is called after every tile so the frontend can show live scanning. + + Returns a dict with cluster_label, membership_scores, patch_count, + worst_patches (list of {row,col,py,px} dicts for the top-N tiles), + or None when no cached data is available. + """ + entry = _deconv_cache.get(cache_key) + if entry is None: + return None + + # Rebuild the current mask from cache (honours all threshold edits) + mask_pil, _, _, _ = _recompute_mask(entry) + mask_np = np.array(mask_pil) + h, w = mask_np.shape[:2] + + # Inclusion criterion = same one used by analyze_single_file_patched + # and by the extent denominator: the cached tissue_mask (left panel). + # No more relying on a separate cached patched grid — we recompute + # tile inclusion on the fly from the source of truth. + cached_tissue_mask = entry['tissue_mask'] + TISSUE_FRAC_THRESHOLD = 0.25 + + # Determine if we should tile (same heuristic as patched analysis) + is_large = max(h, w) > 2048 + + if not is_large: + # ── Single-image classification: feed the B&W mask to VGG16 ── + features = extract_features(mask_pil) + reduc_features = pca_model.transform([features]) + u, _, _, _, _, _ = cmeans_predict(reduc_features.T, centroids, m=best_m, error=0.1, maxiter=100) + if cluster_map is not None: + n_groups = max(cluster_map.values()) + 1 + u_merged = np.zeros((n_groups, u.shape[1])) + for src, dst in cluster_map.items(): + u_merged[dst] += u[src] + u = u_merged + label_idx = int(np.argmax(u, axis=0)) + label_text = cluster_lookup_table.get(label_idx, f"Unknown Cluster {label_idx}") + scores = {membership_column_names[i]: float(u[i][0]) for i in range(4)} + return { + "status": "success", + "cluster_label": label_text, + "membership_scores": scores, + "patch_count": 1, + **scores, + } + + # ── Patch-based classification for large images ── + rows_range = list(range(0, h, patch_size)) + cols_range = list(range(0, w, patch_size)) + num_rows = len(rows_range) + num_cols = len(cols_range) + total_tiles = num_rows * num_cols + current_tile = 0 + + patch_memberships = [] + patch_coords = [] # (row_idx, col_idx, py, px) for each classified tile + + for ri, py in enumerate(rows_range): + for ci, px in enumerate(cols_range): + current_tile += 1 + tile = mask_np[py:py + patch_size, px:px + patch_size] + th, tw = tile.shape[:2] + if th < 32 or tw < 32: + continue + + # ── Exclusion: same criterion as analyze_single_file_patched. + # Compute on-the-fly from the cached tissue_mask (left panel) + # rather than relying on a separately cached tile grid. + tile_frac = float(np.mean( + cached_tissue_mask[py:py + th, px:px + tw] + )) + if tile_frac < TISSUE_FRAC_THRESHOLD: + if progress_callback: + progress_callback(current_tile, total_tiles, len(patch_memberships), + grid_rows=num_rows, grid_cols=num_cols, + tile_row=ri, tile_col=ci, is_tissue=False) + continue + + tile_pil = Image.fromarray(tile) + try: + features = extract_features(tile_pil) + reduc_features = pca_model.transform([features]) + u, _, _, _, _, _ = cmeans_predict(reduc_features.T, centroids, m=best_m, error=0.1, maxiter=100) + if cluster_map is not None: + n_groups = max(cluster_map.values()) + 1 + u_merged = np.zeros((n_groups, u.shape[1])) + for src, dst in cluster_map.items(): + u_merged[dst] += u[src] + u = u_merged + patch_memberships.append([float(u[i][0]) for i in range(4)]) + patch_coords.append({'row': ri, 'col': ci, 'py': py, 'px': px}) + except Exception as e: + print(f"[classify_from_mask] Patch ({py},{px}) failed: {e}") + finally: + del tile_pil + + if len(patch_memberships) % 10 == 0: + gc.collect() + + if progress_callback: + progress_callback(current_tile, total_tiles, len(patch_memberships), + grid_rows=num_rows, grid_cols=num_cols, + tile_row=ri, tile_col=ci, is_tissue=True) + + if not patch_memberships: + return {"status": "error", "message": "No classifiable tiles found in mask"} + + # Rank tiles by severity: higher-stage membership (Bridging + Cirrosis) + # indices: 1=Bridging, 2=Cirrosis + severity = [m[1] + m[2] for m in patch_memberships] + ranked_indices = sorted(range(len(severity)), key=lambda i: severity[i], reverse=True) + top_indices = ranked_indices[:min(top_n, len(ranked_indices))] + top_memberships = [patch_memberships[i] for i in top_indices] + avg_memberships = np.mean(top_memberships, axis=0) + + label_idx = int(np.argmax(avg_memberships)) + label_text = cluster_lookup_table.get(label_idx, f"Unknown Cluster {label_idx}") + scores = {membership_column_names[i]: float(avg_memberships[i]) for i in range(4)} + + # Collect worst patch coordinates for frontend overlay + worst_patches = [patch_coords[i] for i in top_indices] + + print(f"[classify_from_mask] {len(patch_memberships)} tiles classified, " + f"top-{min(top_n, len(patch_memberships))} worst averaged → {label_text}") + + return { + "status": "success", + "cluster_label": label_text, + "membership_scores": scores, + "patch_count": len(patch_memberships), + "top_n_used": min(top_n, len(patch_memberships)), + "worst_patches": worst_patches, + "grid_rows": num_rows, + "grid_cols": num_cols, + "img_h": h, + "img_w": w, + "patch_size": patch_size, + **scores, + } + + +def predict_cluster_pil(pil_image, already_filtered=False, original_pil=None): + """ + Classify an image using VGG16 + PCA + Fuzzy C-Means. + + VGG16 needs the original RGB tissue image for meaningful features. + The B&W mask from fibrosis_filter is only used for the fibrosis percentage. + + Parameters + ---------- + pil_image : PIL image (filtered mask if already_filtered=True, else raw RGB) + already_filtered : if True, skip fibrosis_filter (percentage will be None) + original_pil : the original RGB image for VGG16 feature extraction. + If None, falls back to pil_image (legacy behaviour). + """ image = np.array(pil_image) - image = cv2.cvtColor(image, cv2.COLOR_RGB2BGR) - image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) - img, pxl, percentage = fibrosis_filter(image) - features = extract_features(img) + + if already_filtered: + percentage = None + else: + _, _, percentage, _, _ = fibrosis_filter(image) + + # VGG16 must see the original RGB tissue, NOT the B&W mask + feature_source = original_pil if original_pil is not None else pil_image + features = extract_features(feature_source) reduc_features = pca_model.transform([features]) u, _, _, _, _, _ = cmeans_predict(reduc_features.T, centroids, m=best_m, error=0.1, maxiter=100) + # Merge memberships according to cluster_map (e.g. fold cluster 4 into 3) + if cluster_map is not None: + n_groups = max(cluster_map.values()) + 1 + u_merged = np.zeros((n_groups, u.shape[1])) + for src, dst in cluster_map.items(): + u_merged[dst] += u[src] + u = u_merged cluster_label = np.argmax(u, axis=0) return u, cluster_label, percentage @@ -161,8 +1054,8 @@ def update_filter_slide_legacy(change): region = slide.read_region((x, y), level, (window_width, window_height)) region = region.convert("RGB") # Convert to RGB - result_image_pil, total_selected_pixels, selected_ratio = fibrosis_filter(region) - u, cluster_label, _ = predict_cluster_pil(result_image_pil) + result_image_pil, total_selected_pixels, selected_ratio, _, _ = fibrosis_filter(region) + u, cluster_label, _ = predict_cluster_pil(result_image_pil, already_filtered=True, original_pil=region) with output: output.clear_output(wait=True) @@ -195,8 +1088,8 @@ def update_filter_slide(image_folder,file,level_slider,x_slider,y_slider,window_ region = slide.read_region((x, y), level, (window_width, window_height)) region = region.convert("RGB") # Convert to RGB - result_image_pil, total_selected_pixels, selected_ratio = fibrosis_filter(region) - u, cluster_label, _ = predict_cluster_pil(result_image_pil) + result_image_pil, total_selected_pixels, selected_ratio, _, _ = fibrosis_filter(region) + u, cluster_label, _ = predict_cluster_pil(result_image_pil, already_filtered=True, original_pil=region) output = widgets.Output() @@ -230,8 +1123,9 @@ def update_filter_image(change): original_image = cv2.imread(original_image_path) original_image = cv2.cvtColor(original_image, cv2.COLOR_BGR2RGB) output = widgets.Output() - result_image_pil, total_selected_pixels, selected_ratio = fibrosis_filter(original_image) - u, cluster_label, _ = predict_cluster_pil(result_image_pil) + original_pil = Image.fromarray(original_image) + result_image_pil, total_selected_pixels, selected_ratio, _, _ = fibrosis_filter(original_image) + u, cluster_label, _ = predict_cluster_pil(result_image_pil, already_filtered=True, original_pil=original_pil) with output: output.clear_output(wait=True) plt.figure(figsize=(30, 20)) @@ -256,15 +1150,26 @@ def update_filter_image(change): print(f"Cluster Label: {cluster_lookup_table[cluster_label.item()]} \n \n") -def predict_cluster(image_path, stain_matrix = stain_matrix): - image = cv2.imread(image_path) - image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) - img, pxl, percentage = fibrosis_filter(image, stain_matrix) - features = extract_features(img) - reduc_features = pca_model.transform([features]) - u, _, _, _, _, _ = cmeans_predict(reduc_features.T, centroids, m=best_m, error=0.1, maxiter=100) - cluster_label = np.argmax(u, axis=0) - return u, cluster_label, percentage +def predict_cluster(image_path, stain_matrix=None): + if stain_matrix is None: + stain_matrix = globals()['stain_matrix'] + + image = cv2.imread(image_path) + image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) + img, pxl, percentage, _, _ = fibrosis_filter(image, stain_matrix) + # Pass the original RGB image to VGG16, not the B&W mask + original_pil = Image.fromarray(image) + features = extract_features(original_pil) + reduc_features = pca_model.transform([features]) + u, _, _, _, _, _ = cmeans_predict(reduc_features.T, centroids, m=best_m, error=0.1, maxiter=100) + if cluster_map is not None: + n_groups = max(cluster_map.values()) + 1 + u_merged = np.zeros((n_groups, u.shape[1])) + for src, dst in cluster_map.items(): + u_merged[dst] += u[src] + u = u_merged + cluster_label = np.argmax(u, axis=0) + return u, cluster_label, percentage def process_images_in_folder(folder_path, predict_cluster_func): results = [] @@ -279,7 +1184,7 @@ def process_images_in_folder(folder_path, predict_cluster_func): # results.append(["16E.tif",1.472829861111111,"Category C: Bridging",0.03421226000923161,0.10285556764417932,0.7324887669357235,0.1304434054108655]) # results.append(["61.7.tif",0.0,"Category A: None",0.86083308458,0.0418876622792983,4.121940722792983e-31,0.0998885441976844]) - df = pd.DataFrame(results, columns=['image_name', 'percentage', 'cluster_label','None','Perisinusoidal','Bridging','Cirrosis']) + df = pd.DataFrame(results, columns=['image_name', 'percentage', 'cluster_label','None','Bridging','Cirrosis','Perisinusoidal']) # df = pd.DataFrame(results, columns=['image_name', 'percentage', 'cluster_label') return df @@ -321,6 +1226,308 @@ def process_all_images(folder_path): return pd.concat([df_non_svs, df_svs], ignore_index=True) +import base64 +from io import BytesIO + + +def pil_to_b64(img): + buffered = BytesIO() + img.save(buffered, format="JPEG", quality=95) + return base64.b64encode(buffered.getvalue()).decode("utf-8") + + +def open_image_for_processing(file_path, max_side=1024): + if file_path.lower().endswith('.svs'): + slide = OpenSlide(file_path) + try: + img_pil = slide.get_thumbnail((max_side, max_side)).convert("RGB") + finally: + slide.close() + else: + img_pil = Image.open(file_path).convert("RGB") + img_pil.thumbnail((max_side, max_side), Image.LANCZOS) + return img_pil + + +# ── Patch helpers ────────────────────────────────────────────────── + +def _is_tissue_patch(patch_np): + """Return True unless the patch is almost entirely white (≥95% blank).""" + gray = np.mean(patch_np.astype(np.float32), axis=2) + return (np.sum(gray > 220) / gray.size) < 0.95 + + +def _select_slide_level(slide, max_dim=8192): + """Pick the highest-resolution level whose longest side fits in *max_dim*.""" + for level, (w, h) in enumerate(slide.level_dimensions): + if max(w, h) <= max_dim: + return level + return len(slide.level_dimensions) - 1 + + +def analyze_single_file_patched(file_path, patch_size=512, max_processing_dim=8192, progress_callback=None): + """ + Patch-based analysis for SVS (and large TIF) files. + + 1. Opens the image at a resolution level ≤ max_processing_dim. + 2. Tiles it into patch_size × patch_size squares. + 3. Skips background patches (blank white areas). + 4. Runs fibrosis_filter on every tissue patch, stitches the mask. + 5. Aggregates fibrosis ratio from *all* tissue patches. + 6. Classification still uses a thumbnail so the trained PCA/FCM + model (which was trained on whole-tissue views) gets compatible input. + + progress_callback(current_patch, total_candidates, tissue_count) is called + after every tile so the frontend can show live updates. + """ + try: + import traceback as _tb + is_svs = file_path.lower().endswith('.svs') + + # ── Load the image at a workable resolution ── + if is_svs: + slide = OpenSlide(file_path) + level = _select_slide_level(slide, max_processing_dim) + level_w, level_h = slide.level_dimensions[level] + full_pil = slide.read_region((0, 0), level, (level_w, level_h)).convert("RGB") + full_image = np.array(full_pil) + del full_pil + slide.close() + try: + os.remove(file_path) + print(f"[GC] Deleted uploaded file: {file_path}") + except Exception: + pass + print(f"[Patch] SVS opened at level {level} → {level_w}×{level_h}") + else: + try: + # Try OpenSlide first (works for TIFs with pyramids) + slide = OpenSlide(file_path) + level = _select_slide_level(slide, max_processing_dim) + level_w, level_h = slide.level_dimensions[level] + full_pil = slide.read_region((0, 0), level, (level_w, level_h)).convert("RGB") + full_image = np.array(full_pil) + del full_pil + slide.close() + try: + os.remove(file_path) + print(f"[GC] Deleted uploaded file: {file_path}") + except Exception: + pass + print(f"[Patch] Large TIFF opened via OpenSlide at level {level}") + except Exception: + # Fallback to Pillow if OpenSlide fails (e.g. standard TIFF without pyramid) + print("[Patch] Fallback to Pillow for image load") + img = Image.open(file_path).convert("RGB") + if max(img.size) > max_processing_dim: + img.thumbnail((max_processing_dim, max_processing_dim), Image.LANCZOS) + full_image = np.array(img) + del img + try: + os.remove(file_path) + print(f"[GC] Deleted uploaded file: {file_path}") + except Exception: + pass + + h, w = full_image.shape[:2] + + # Send an early preview from the SAME image being tiled so the + # frontend overlay aligns perfectly with the displayed image. + if progress_callback: + preview_pil = Image.fromarray(full_image) + preview_pil.thumbnail((2048, 2048), Image.LANCZOS) + progress_callback(0, 1, 0, + analysis_preview=f"data:image/jpeg;base64,{pil_to_b64(preview_pil)}") + del preview_pil + + # ── Run fibrosis_filter on the FULL image (no seams) ── + full_mask_pil, total_fibrosis_pixels, overall_ratio, total_tissue_pixels, auto_thresh = fibrosis_filter(full_image) + full_mask = np.array(full_mask_pil) + del full_mask_pil + + # Cache deconvolution data for interactive rethresholding + _sm = globals()['stain_matrix'] + _tpx = full_image[(full_image.sum(axis=-1) > 50) & (np.mean(full_image, axis=2) < 240)] + if _tpx.size > 0: + _cm = float(np.median(_tpx)) + if _cm > 10: + _img_n = np.clip(full_image.astype(np.float32) * (160.0 / _cm), 0, 255).astype(np.uint8) + else: + _img_n = full_image + else: + _img_n = full_image + _img_f = _img_n.astype(np.float32) / 255.0 + _stains = np.dot(-np.log(_img_f + np.finfo(float).eps), _sm.T) + cache_deconv_data( + os.path.basename(file_path), + _stains[:, :, 0], + (full_image.sum(axis=-1) > 50) & (np.mean(full_image, axis=2) < 220), + full_image.shape, + auto_threshold=auto_thresh, + ) + del _tpx, _img_n, _img_f, _stains + + print(f"[Patch] Full-image fibrosis filter: {overall_ratio:.2f}% tissue_px={total_tissue_pixels}") + + # Per-tile work was removed — VGG16/FCM only happens during the + # Diagnose step (classify_from_mask). The left-panel scanning grid + # served as a visual progress indicator for that dead pass and is + # therefore no longer streamed. patch_count is reported as the + # number of tiles that meet the >=25% tissue inclusion criterion. + patch_size_count = 512 + rows = list(range(0, h, patch_size_count)) + cols = list(range(0, w, patch_size_count)) + cached_entry = _deconv_cache.get(os.path.basename(file_path)) + cached_tissue_mask = cached_entry['tissue_mask'] if cached_entry else None + patch_count = 0 + if cached_tissue_mask is not None: + for py in rows: + for px in cols: + ph = min(patch_size_count, h - py) + pw_actual = min(patch_size_count, w - px) + if ph < 32 or pw_actual < 32: + continue + tile_frac = float(np.mean( + cached_tissue_mask[py:py + ph, px:px + pw_actual] + )) + if tile_frac >= 0.25: + patch_count += 1 + + print(f"[Patch] {patch_count} tissue tiles flagged — fibrosis {overall_ratio:.2f}%") + + # ── Resize for frontend display, then free large arrays ── + display_orig = Image.fromarray(full_image) + display_orig.thumbnail((8192, 8192), Image.LANCZOS) + + display_mask = Image.fromarray(full_mask) + display_mask.thumbnail((8192, 8192), Image.LANCZOS) + + del full_image, full_mask + gc.collect() + print("[GC] Released full_image and full_mask") + + return { + "status": "success", + "original_image": f"data:image/jpeg;base64,{pil_to_b64(display_orig)}", + "filtered_image": f"data:image/jpeg;base64,{pil_to_b64(display_mask)}", + "fibrosis_ratio": float(overall_ratio), + "threshold": float(auto_thresh), + "patch_count": patch_count, + } + except Exception as e: + print(f"Error in patched analysis: {e}") + import traceback; traceback.print_exc() + return {"status": "error", "message": str(e)} + + +def preview_single_file(file_path, preview_size=1536): + """ + Fast preview path: generates original + fibrosis mask on downsampled image. + """ + try: + img_pil = open_image_for_processing(file_path, max_side=preview_size) + img_np = np.array(img_pil) + filtered_pil, total_pixels, ratio, _, _ = fibrosis_filter(img_np) + + return { + "status": "success", + "is_preview": True, + "original_image": f"data:image/jpeg;base64,{pil_to_b64(img_pil)}", + "filtered_image": f"data:image/jpeg;base64,{pil_to_b64(filtered_pil)}", + "fibrosis_ratio": float(ratio), + } + except Exception as e: + print(f"Error in preview: {e}") + return {"status": "error", "message": str(e)} + +def analyze_single_file(file_path): + """ + Called by main.py. Routes to patch-based analysis for SVS / large TIF, + or the fast thumbnail path for small images (JPG, PNG, small TIF). + """ + is_svs = file_path.lower().endswith('.svs') + is_large_tif = ( + file_path.lower().endswith(('.tif', '.tiff')) + and os.path.getsize(file_path) > 50 * 1024 * 1024 # > 50 MB + ) + + if is_svs or is_large_tif: + print(f"[analyze] Using PATCH-BASED pipeline for {os.path.basename(file_path)}") + return analyze_single_file_patched(file_path) + + # ── Fast thumbnail path for small images ── + print(f"[analyze] Using THUMBNAIL pipeline for {os.path.basename(file_path)}") + try: + # Guard against oversized flat images that would exhaust RAM + MAX_FLAT_BYTES = 50 * 1024 * 1024 # 50 MB + if os.path.getsize(file_path) > MAX_FLAT_BYTES: + return { + "status": "error", + "message": "File too large for standard image analysis (max 50 MB). Use SVS or TIF format for whole-slide images." + } + + # 1. Open the image + img_pil = open_image_for_processing(file_path, max_side=2048) + try: + os.remove(file_path) + print(f"[GC] Deleted uploaded file: {file_path}") + except Exception: + pass + + # 2. Run your existing Fibrosis Filter + # Note: We convert PIL -> Numpy for your filter + img_np = np.array(img_pil) + filtered_pil, total_pixels, ratio, _, auto_thresh = fibrosis_filter(img_np) + + # Cache deconvolution data for rethresholding + cache_key = os.path.basename(file_path) + # Recompute the intermediates for caching (lightweight) + _sm = globals()['stain_matrix'] + tissue_px = img_np[(img_np.sum(axis=-1) > 50) & (np.mean(img_np, axis=2) < 240)] + if tissue_px.size > 0: + cm = float(np.median(tissue_px)) + if cm > 10: + img_n = np.clip(img_np.astype(np.float32) * (160.0 / cm), 0, 255).astype(np.uint8) + else: + img_n = img_np + else: + img_n = img_np + img_f = img_n.astype(np.float32) / 255.0 + stains = np.dot(-np.log(img_f + np.finfo(float).eps), _sm.T) + cache_deconv_data(cache_key, stains[:, :, 0], + (img_np.sum(axis=-1) > 50) & (np.mean(img_np, axis=2) < 220), + img_np.shape, auto_threshold=auto_thresh) + + # 3. Run your existing Cluster Prediction + # Pass original RGB to VGG16 for meaningful features + u, cluster_label, _ = predict_cluster_pil(filtered_pil, already_filtered=True, original_pil=img_pil) + + # Handle the label lookup safely + label_idx = cluster_label.item() + label_text = cluster_lookup_table.get(label_idx, f"Unknown Cluster {label_idx}") + membership_scores = { + membership_column_names[0]: float(u[0][0]), + membership_column_names[1]: float(u[1][0]), + membership_column_names[2]: float(u[2][0]), + membership_column_names[3]: float(u[3][0]), + } + + return { + "status": "success", + "original_image": f"data:image/jpeg;base64,{pil_to_b64(img_pil)}", + "filtered_image": f"data:image/jpeg;base64,{pil_to_b64(filtered_pil)}", + "fibrosis_ratio": float(ratio), + "cluster_label": label_text, + "membership_scores": membership_scores, + "threshold": float(auto_thresh), + "None": membership_scores["None"], + "Perisinusoidal": membership_scores["Perisinusoidal"], + "Bridging": membership_scores["Bridging"], + "Cirrosis": membership_scores["Cirrosis"], + } + except Exception as e: + print(f"Error in analysis: {e}") + return {"status": "error", "message": str(e)} # ------------ image_folder = './data/kidney' diff --git a/NAFLD/src/py-src/requirements.txt b/NAFLD/src/py-src/requirements.txt index 3b760f4..63aaa48 100644 Binary files a/NAFLD/src/py-src/requirements.txt and b/NAFLD/src/py-src/requirements.txt differ diff --git a/NAFLD/src/py-src/threshold_diagnostic.png b/NAFLD/src/py-src/threshold_diagnostic.png new file mode 100644 index 0000000..3f7a5a4 Binary files /dev/null and b/NAFLD/src/py-src/threshold_diagnostic.png differ diff --git a/NAFLD/src/py-src/tiny_unet_round1_best_v1.pth b/NAFLD/src/py-src/tiny_unet_round1_best_v1.pth new file mode 100644 index 0000000..91077bb Binary files /dev/null and b/NAFLD/src/py-src/tiny_unet_round1_best_v1.pth differ diff --git a/_adduser.py b/_adduser.py new file mode 100644 index 0000000..c29852e --- /dev/null +++ b/_adduser.py @@ -0,0 +1,18 @@ +import paramiko + +ssh = paramiko.SSHClient() +ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +ssh.connect('217.77.7.228', username='alex', password='123456') + +def run(cmd): + stdin, stdout, stderr = ssh.exec_command(cmd) + exit_code = stdout.channel.recv_exit_status() + return stdout.read().decode().strip(), stderr.read().decode().strip(), exit_code + +out, err, rc = run('cd ~/NAFLD-project/NAFLD/src/py-src && ~/nafld-venv/bin/python auth.py add wanglab wanglab2026 2>&1') +print(f"RC={rc}") +print(out) +if err: + print(f"STDERR: {err}") + +ssh.close() diff --git a/_check_nginx.py b/_check_nginx.py new file mode 100644 index 0000000..280bbba --- /dev/null +++ b/_check_nginx.py @@ -0,0 +1,10 @@ +import paramiko +s = paramiko.SSHClient() +s.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +s.connect('217.77.7.228', username='alex', password='123456') +stdin, stdout, stderr = s.exec_command('sudo -S -p "" cat /var/log/nginx/access.log | grep -E "analyze|classify|preview" | tail -30', get_pty=True) +stdin.write('123456\n') +stdin.flush() +print(stdout.read().decode()) +print(stderr.read().decode()) +s.close() \ No newline at end of file diff --git a/_check_status.py b/_check_status.py new file mode 100644 index 0000000..39c5bf2 --- /dev/null +++ b/_check_status.py @@ -0,0 +1,29 @@ +import paramiko + +ssh = paramiko.SSHClient() +ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +ssh.connect('217.77.7.228', username='alex', password='123456') + +def sudo_run(cmd, password='123456'): + full_cmd = f"echo '{password}' | sudo -S bash -c \"{cmd}\"" + stdin, stdout, stderr = ssh.exec_command(full_cmd, get_pty=True) + exit_code = stdout.channel.recv_exit_status() + out = stdout.read().decode() + lines = [l for l in out.split('\n') if not l.strip().startswith('[sudo]')] + return '\n'.join(lines).strip(), stderr.read().decode().strip(), exit_code + +# Check if certbot is already installed +stdin, stdout, stderr = ssh.exec_command("certbot --version 2>&1") +stdout.channel.recv_exit_status() +ver = stdout.read().decode().strip() +print(f"certbot version: {ver}") + +# Check current nginx config +out, err, rc = sudo_run("grep server_name /etc/nginx/sites-available/nafld") +print(f"nginx server_name: {out}") + +# Check if SSL cert exists +out, err, rc = sudo_run("ls /etc/letsencrypt/live/ 2>&1") +print(f"certs: {out}") + +ssh.close() diff --git a/_deploy.py b/_deploy.py new file mode 100644 index 0000000..4a421f9 --- /dev/null +++ b/_deploy.py @@ -0,0 +1,36 @@ +import paramiko + +ssh = paramiko.SSHClient() +ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +ssh.connect('217.77.7.228', username='alex', password='123456') + +def sudo_run(cmd, password='123456'): + full_cmd = f"echo '{password}' | sudo -S bash -c '{cmd}'" + stdin, stdout, stderr = ssh.exec_command(full_cmd, get_pty=True) + exit_code = stdout.channel.recv_exit_status() + out = stdout.read().decode() + lines = [l for l in out.split('\n') if not l.strip().startswith('[sudo]')] + return '\n'.join(lines).strip(), exit_code + +def run(cmd): + stdin, stdout, stderr = ssh.exec_command(cmd) + exit_code = stdout.channel.recv_exit_status() + return stdout.read().decode().strip(), exit_code + +# Pull +print("=== git pull ===") +out, rc = run('cd ~/NAFLD-project && git pull') +print(f" RC={rc}, {out.split(chr(10))[-1]}") + +# Rebuild React +print("=== npm run build ===") +out, rc = run('cd ~/NAFLD-project/NAFLD/javascript/nafld-app && npm run build 2>&1 | tail -3') +print(f" RC={rc}, {out}") + +# Restart services +print("=== restart services ===") +out, rc = sudo_run('systemctl restart nafld && systemctl reload nginx') +print(f" RC={rc}") + +print("Done!") +ssh.close() \ No newline at end of file diff --git a/_deploy_hup.py b/_deploy_hup.py new file mode 100644 index 0000000..26dd1f8 --- /dev/null +++ b/_deploy_hup.py @@ -0,0 +1,35 @@ +"""Deploy on server: git pull, npm build, SIGHUP gunicorn (no sudo needed).""" +import paramiko, time + +ssh = paramiko.SSHClient() +ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +ssh.connect('217.77.7.228', username='alex', password='123456') + +def run(cmd, timeout=600): + _, o, e = ssh.exec_command(cmd, timeout=timeout) + out = o.read().decode() + err = e.read().decode() + return out.strip(), err.strip(), o.channel.recv_exit_status() + +print("=== git pull ===") +out, err, rc = run("cd ~/NAFLD-project && git pull") +print(f"RC={rc}\n{out}\n{err}\n") + +print("=== npm run build ===") +out, err, rc = run("cd ~/NAFLD-project/NAFLD/javascript/nafld-app && npm run build 2>&1 | tail -10") +print(f"RC={rc}\n{out}\n") + +print("=== find & SIGHUP gunicorn master ===") +out, err, rc = run("MASTER=$(ps -eo pid,ppid,cmd | awk '/gunicorn.*main:app/ && !/awk/ && $2==1 {print $1; exit}'); " + "echo MASTER=$MASTER; " + "if [ -n \"$MASTER\" ]; then kill -HUP $MASTER && echo 'HUP sent'; fi") +print(out) +print(err) + +time.sleep(4) +print("\n=== verify new workers ===") +out, _, _ = run("ps -eo pid,etime,cmd | grep gunicorn | grep -v grep") +print(out) + +ssh.close() +print("\nDone.") diff --git a/_deploy_now.py b/_deploy_now.py new file mode 100644 index 0000000..8ded220 --- /dev/null +++ b/_deploy_now.py @@ -0,0 +1,25 @@ +import paramiko +ssh = paramiko.SSHClient() +ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +ssh.connect('217.77.7.228', username='alex', password='123456') + +cmds = [ + 'cd ~/NAFLD-project && git pull', + 'cd ~/NAFLD-project/NAFLD/javascript/nafld-app && npm run build 2>&1 | tail -5', + 'echo 123456 | sudo -S systemctl restart nafld.service', +] +for cmd in cmds: + print(">>>", cmd[:60]) + stdin, stdout, stderr = ssh.exec_command(cmd, timeout=300) + out = stdout.read().decode().strip() + err = stderr.read().decode().strip() + if out: + print(out) + if err and 'password' not in err.lower(): + print(err) + print() + +stdin2, stdout2, _ = ssh.exec_command('echo 123456 | sudo -S systemctl status nafld.service --no-pager -l 2>/dev/null | head -5', timeout=15) +print(stdout2.read().decode().strip()) +ssh.close() +print("Deploy complete.") diff --git a/_finalize.py b/_finalize.py new file mode 100644 index 0000000..aed3b81 --- /dev/null +++ b/_finalize.py @@ -0,0 +1,53 @@ +import paramiko + +ssh = paramiko.SSHClient() +ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +ssh.connect('217.77.7.228', username='alex', password='123456') +sftp = ssh.open_sftp() + +def sudo_run(cmd, password='123456'): + stdin, stdout, stderr = ssh.exec_command(f"echo '{password}' | sudo -S {cmd}", get_pty=True) + exit_code = stdout.channel.recv_exit_status() + out = stdout.read().decode() + lines = [l for l in out.split('\n') if not l.strip().startswith('[sudo]')] + return '\n'.join(lines).strip(), exit_code + +def run(cmd): + stdin, stdout, stderr = ssh.exec_command(cmd) + exit_code = stdout.channel.recv_exit_status() + return stdout.read().decode().strip(), exit_code + +# Step 1: Update React .env to use HTTPS domain +print("=== Updating React .env ===") +env_path = '/home/alex/NAFLD-project/NAFLD/javascript/nafld-app/.env' +with sftp.open(env_path, 'w') as f: + f.write('REACT_APP_API_URL=https://fibrosisai.org\n') +out, rc = run(f'cat {env_path}') +print(f" .env: {out}") + +# Step 2: Rebuild React +print("\n=== Rebuilding React ===") +out, rc = run('cd ~/NAFLD-project/NAFLD/javascript/nafld-app && npm run build 2>&1 | tail -3') +print(f" build RC={rc}") +print(f" {out}") + +# Step 3: Restart gunicorn +print("\n=== Restarting gunicorn ===") +out, rc = sudo_run('systemctl restart nafld') +print(f" RC={rc}") + +# Step 4: Reload nginx +out, rc = sudo_run('systemctl reload nginx') +print(f" nginx reload RC={rc}") + +# Step 5: Test HTTPS +print("\n=== Testing ===") +out, rc = sudo_run('curl -sI https://fibrosisai.org 2>&1 | head -3') +print(f" HTTPS: {out}") + +out, rc = sudo_run('curl -sI http://fibrosisai.org 2>&1 | head -3') +print(f" HTTP redirect: {out}") + +sftp.close() +ssh.close() +print("\nDone!") diff --git a/_fix_ssl.py b/_fix_ssl.py new file mode 100644 index 0000000..a2d4fb2 --- /dev/null +++ b/_fix_ssl.py @@ -0,0 +1,55 @@ +import paramiko + +ssh = paramiko.SSHClient() +ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +ssh.connect('217.77.7.228', username='alex', password='123456') + +def sudo_run(cmd, password='123456'): + full_cmd = f"echo '{password}' | sudo -S bash -c \"{cmd}\"" + stdin, stdout, stderr = ssh.exec_command(full_cmd, get_pty=True) + exit_code = stdout.channel.recv_exit_status() + out = stdout.read().decode() + lines = [l for l in out.split('\n') if not l.strip().startswith('[sudo]')] + return '\n'.join(lines).strip(), exit_code + +# Step 1: Fix nginx server_name using python on the server +print("=== Fixing nginx server_name ===") +fix_cmd = """python3 -c " +p='/etc/nginx/sites-available/nafld' +t=open(p).read() +t=t.replace('server_name 217.77.7.228 aifibrosis.ca www.aifibrosis.ca;','server_name 217.77.7.228 fibrosisai.org www.fibrosisai.org;') +open(p,'w').write(t) +print('Updated') +" """ +out, rc = sudo_run(fix_cmd) +print(f" RC={rc}, {out}") + +# Verify +out, rc = sudo_run("grep server_name /etc/nginx/sites-available/nafld") +print(f" server_name: {out}") + +# Test nginx +out, rc = sudo_run("nginx -t 2>&1") +print(f" nginx -t: {out}") + +# Step 2: Install the cert that certbot already obtained +print("\n=== Installing certificate ===") +out, rc = sudo_run("certbot install --cert-name fibrosisai.org --nginx --redirect --non-interactive 2>&1") +print(f" certbot install RC={rc}") +for line in out.split('\n'): + line = line.strip() + if line and not line.startswith('[sudo]'): + print(f" {line}") + +# Step 3: Show final config +print("\n=== Final nginx config ===") +out, rc = sudo_run("cat /etc/nginx/sites-available/nafld") +print(out) + +# Step 4: Test HTTPS +print("\n=== Testing ===") +out, rc = sudo_run("curl -sI https://fibrosisai.org 2>&1 | head -5") +print(out) + +ssh.close() +print("\nDone!") diff --git a/_fix_ssl2.py b/_fix_ssl2.py new file mode 100644 index 0000000..5acafd2 --- /dev/null +++ b/_fix_ssl2.py @@ -0,0 +1,77 @@ +import paramiko + +ssh = paramiko.SSHClient() +ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +ssh.connect('217.77.7.228', username='alex', password='123456') +sftp = ssh.open_sftp() + +def sudo_run(cmd, password='123456'): + stdin, stdout, stderr = ssh.exec_command(f"echo '{password}' | sudo -S {cmd}", get_pty=True) + exit_code = stdout.channel.recv_exit_status() + out = stdout.read().decode() + lines = [l for l in out.split('\n') if not l.strip().startswith('[sudo]')] + return '\n'.join(lines).strip(), exit_code + +# Step 1: Write updated nginx config to a temp file, then sudo move it +nginx_conf = """server { + listen 80; + server_name 217.77.7.228 fibrosisai.org www.fibrosisai.org; + client_max_body_size 2G; + root /home/alex/NAFLD-project/NAFLD/javascript/nafld-app/build; + index index.html; + + location ~ ^/(analyze|analyze-stream|preview|rethreshold|rethreshold-area|reset-area|undo-area|download-single|upload|largefile|login|refresh|me|home|fake|download|fullFileUpload) { + proxy_pass http://127.0.0.1:5000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 600s; + proxy_send_timeout 600s; + proxy_buffering off; + } + + location / { + try_files $uri $uri/ /index.html; + } +} +""" + +print("=== Writing nginx config ===") +with sftp.open('/tmp/nafld_nginx', 'w') as f: + f.write(nginx_conf) +out, rc = sudo_run('cp /tmp/nafld_nginx /etc/nginx/sites-available/nafld') +print(f" copy RC={rc}") + +out, rc = sudo_run('nginx -t 2>&1') +print(f" nginx -t: {out}") + +out, rc = sudo_run('systemctl reload nginx') +print(f" reload RC={rc}") + +# Step 2: Install the SSL cert +print("\n=== Installing SSL certificate ===") +out, rc = sudo_run('certbot install --cert-name fibrosisai.org --nginx --redirect --non-interactive 2>&1') +print(f" certbot install RC={rc}") +for line in out.split('\n'): + line = line.strip() + if line and not line.startswith('[sudo]'): + print(f" {line}") + +# Step 3: Reload nginx again +out, rc = sudo_run('systemctl reload nginx') +print(f"\n final reload RC={rc}") + +# Step 4: Show final config +print("\n=== Final nginx config ===") +out, rc = sudo_run('cat /etc/nginx/sites-available/nafld') +print(out) + +# Step 5: Test +print("\n=== Testing HTTPS ===") +out, rc = sudo_run('curl -sI https://fibrosisai.org 2>&1 | head -5') +print(out) + +sftp.close() +ssh.close() +print("\nDone!") diff --git a/_force_restart.py b/_force_restart.py new file mode 100644 index 0000000..21bc885 --- /dev/null +++ b/_force_restart.py @@ -0,0 +1,20 @@ +import paramiko +ssh = paramiko.SSHClient() +ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +ssh.connect('217.77.7.228', username='alex', password='123456') + +cmds = [ + 'find ~/NAFLD-project/NAFLD/src/py-src/__pycache__ -name "*.pyc" -delete 2>/dev/null; echo "pyc cleared"', + 'echo 123456 | sudo -S systemctl restart nafld.service', + 'sleep 2', + 'echo 123456 | sudo -S systemctl status nafld.service --no-pager -l 2>/dev/null | head -10', +] +for cmd in cmds: + print(">>>", cmd[:60]) + stdin, stdout, stderr = ssh.exec_command(cmd, timeout=30) + out = stdout.read().decode().strip() + if out: + print(out) + +ssh.close() +print("Done.") diff --git a/_logs.py b/_logs.py new file mode 100644 index 0000000..87bb182 --- /dev/null +++ b/_logs.py @@ -0,0 +1,7 @@ +import paramiko +s = paramiko.SSHClient() +s.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +s.connect('217.77.7.228', username='alex', password='123456') +_, o, e = s.exec_command("journalctl -u nafld --no-pager -n 80 --since '5 minutes ago'") +print(o.read().decode()) +s.close() diff --git a/_quick_deploy.py b/_quick_deploy.py new file mode 100644 index 0000000..dd428fe --- /dev/null +++ b/_quick_deploy.py @@ -0,0 +1,32 @@ +import paramiko + +ssh = paramiko.SSHClient() +ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +ssh.connect('217.77.7.228', username='alex', password='123456') + +def sudo_run(cmd, password='123456'): + stdin, stdout, stderr = ssh.exec_command(f"echo '{password}' | sudo -S {cmd}", get_pty=True) + exit_code = stdout.channel.recv_exit_status() + out = stdout.read().decode() + lines = [l for l in out.split('\n') if not l.strip().startswith('[sudo]')] + return '\n'.join(lines).strip(), exit_code + +def run(cmd): + stdin, stdout, stderr = ssh.exec_command(cmd) + exit_code = stdout.channel.recv_exit_status() + return stdout.read().decode().strip(), exit_code + +print("=== git pull ===") +out, rc = run('cd ~/NAFLD-project && git pull') +print(f" RC={rc}") + +print("=== npm run build ===") +out, rc = run('cd ~/NAFLD-project/NAFLD/javascript/nafld-app && npm run build 2>&1 | tail -3') +print(f" RC={rc}, {out}") + +print("=== restart ===") +out, rc = sudo_run('systemctl restart nafld && systemctl reload nginx') +print(f" RC={rc}") + +print("Done!") +ssh.close() diff --git a/_rebuild.py b/_rebuild.py new file mode 100644 index 0000000..ca7d592 --- /dev/null +++ b/_rebuild.py @@ -0,0 +1,46 @@ +import paramiko + +ssh = paramiko.SSHClient() +ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +ssh.connect('217.77.7.228', username='alex', password='123456') + +def sudo_run(cmd, password='123456'): + full_cmd = f"echo '{password}' | sudo -S bash -c '{cmd}'" + stdin, stdout, stderr = ssh.exec_command(full_cmd, get_pty=True) + exit_code = stdout.channel.recv_exit_status() + out = stdout.read().decode() + lines = [l for l in out.split('\n') if not l.strip().startswith('[sudo]')] + return '\n'.join(lines).strip(), exit_code + +def run(cmd): + stdin, stdout, stderr = ssh.exec_command(cmd) + exit_code = stdout.channel.recv_exit_status() + return stdout.read().decode().strip(), exit_code + +# Step 1: Rebuild React frontend +print("=== Building React app ===") +out, rc = run('cd ~/NAFLD-project/NAFLD/javascript/nafld-app && npm run build 2>&1 | tail -5') +print(f" RC={rc}") +print(f" {out}") + +# Step 2: Restart gunicorn +print("\n=== Restarting gunicorn ===") +out, rc = sudo_run('systemctl restart nafld') +print(f" RC={rc}") + +# Step 3: Reload nginx +print("\n=== Reloading nginx ===") +out, rc = sudo_run('systemctl reload nginx') +print(f" RC={rc}") + +# Step 4: Verify +out, rc = sudo_run('systemctl status nafld --no-pager -l 2>&1 | head -10') +print(f"\n=== nafld service ===") +print(out) + +out, rc = sudo_run('systemctl status nginx --no-pager -l 2>&1 | head -5') +print(f"\n=== nginx ===") +print(out) + +ssh.close() +print("\nDone!") diff --git a/_reload_gunicorn.py b/_reload_gunicorn.py new file mode 100644 index 0000000..227f295 --- /dev/null +++ b/_reload_gunicorn.py @@ -0,0 +1,34 @@ +"""Reload gunicorn workers via SIGHUP (no sudo needed since user alex owns it).""" +import paramiko + +ssh = paramiko.SSHClient() +ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +ssh.connect('217.77.7.228', username='alex', password='123456') + +def run(c): + _, o, e = ssh.exec_command(c) + return o.read().decode().strip(), e.read().decode().strip(), o.channel.recv_exit_status() + +print("--- gunicorn processes ---") +out, err, rc = run("ps -eo pid,ppid,user,cmd --sort=pid | grep 'gunicorn.*main:app' | grep -v grep") +print(out) + +# Master = the one with lowest pid AND ppid is not another gunicorn (typically ppid=1 under systemd) +print("\n--- Sending SIGHUP to gunicorn master (graceful reload, re-imports app) ---") +out, err, rc = run("pkill -HUP -f 'gunicorn.*main:app' -n -u alex 2>&1 || true; " + "MASTER=$(ps -eo pid,ppid,cmd | awk '/gunicorn.*main:app/ && !/awk/ && $2==1 {print $1; exit}'); " + "echo MASTER_PID=$MASTER; " + "if [ -n \"$MASTER\" ]; then kill -HUP $MASTER && echo 'HUP sent OK'; fi") +print(out) +print('stderr:', err, 'rc:', rc) + +print("\n--- Wait a moment, then check status ---") +import time; time.sleep(3) +out, err, rc = run("systemctl status nafld --no-pager -n 6 | head -20") +print(out) + +print("\n--- Confirm workers restarted (look for fresh 'Booting worker' log) ---") +out, err, rc = run("journalctl -u nafld --no-pager -n 20 --since '2 minutes ago' 2>/dev/null || journalctl -u nafld --no-pager -n 20") +print(out) + +ssh.close() diff --git a/_restart.py b/_restart.py new file mode 100644 index 0000000..3a3569c --- /dev/null +++ b/_restart.py @@ -0,0 +1,23 @@ +import paramiko + +ssh = paramiko.SSHClient() +ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +ssh.connect('217.77.7.228', username='alex', password='123456') + +def sudo_run(cmd, password='123456'): + stdin, stdout, stderr = ssh.exec_command(f"echo '{password}' | sudo -S {cmd}", get_pty=True) + exit_code = stdout.channel.recv_exit_status() + out = stdout.read().decode() + lines = [l for l in out.split('\n') if not l.strip().startswith('[sudo]')] + return '\n'.join(lines).strip(), exit_code + +out, rc = sudo_run('systemctl restart nafld 2>&1') +print(f"nafld restart: RC={rc}, {out}") +out, rc = sudo_run('systemctl status nafld --no-pager 2>&1 | head -5') +print(f"nafld status: {out}") +out, rc = sudo_run('systemctl reload nginx 2>&1') +print(f"nginx reload: RC={rc}, {out}") +out, rc = sudo_run('systemctl status nginx --no-pager 2>&1 | head -5') +print(f"nginx status: {out}") + +ssh.close() diff --git a/_restart_now.py b/_restart_now.py new file mode 100644 index 0000000..f426427 --- /dev/null +++ b/_restart_now.py @@ -0,0 +1,68 @@ +"""Restart nafld service via interactive shell (handles sudo password reliably).""" +import paramiko +import time +import re + +HOST = '217.77.7.228' +USER = 'alex' +PASS = '123456' + +ssh = paramiko.SSHClient() +ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +ssh.connect(HOST, username=USER, password=PASS) + +chan = ssh.invoke_shell() +chan.settimeout(30) + +# Wait for initial prompt +time.sleep(1.5) +while chan.recv_ready(): + chan.recv(65535) + time.sleep(0.2) + +MARK = '__DONE_TAG_9472__' + +def run(cmd, timeout=60): + full = f"{cmd}; echo {MARK}$?\n" + chan.send(full) + out = '' + sent_pw = False + end = time.time() + timeout + while time.time() < end: + if chan.recv_ready(): + chunk = chan.recv(65535).decode(errors='replace') + out += chunk + if not sent_pw and '[sudo] password' in out: + chan.send(PASS + '\n') + sent_pw = True + if MARK in out: + # also drain a tiny bit more + time.sleep(0.2) + while chan.recv_ready(): + out += chan.recv(65535).decode(errors='replace') + break + else: + time.sleep(0.1) + m = re.search(re.escape(MARK) + r'(\d+)', out) + rc = int(m.group(1)) if m else -1 + cleaned = re.sub(re.escape(MARK) + r'\d+', '', out) + return cleaned.strip(), rc + +print("=== restart nafld ===") +out, rc = run('sudo -S systemctl restart nafld', timeout=90) +print(f"RC={rc}\n{out}\n") + +print("=== reload nginx ===") +out, rc = run('sudo -S systemctl reload nginx', timeout=30) +print(f"RC={rc}\n{out}\n") + +print("=== nafld is-active ===") +out, rc = run('sudo -S systemctl is-active nafld', timeout=15) +print(f"RC={rc}\n{out}\n") + +print("=== nafld status ===") +out, rc = run('sudo -S systemctl status nafld --no-pager -n 12', timeout=20) +print(out) + +chan.close() +ssh.close() diff --git a/_run_certbot.py b/_run_certbot.py new file mode 100644 index 0000000..629bc44 --- /dev/null +++ b/_run_certbot.py @@ -0,0 +1,31 @@ +import paramiko + +ssh = paramiko.SSHClient() +ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +ssh.connect('217.77.7.228', username='alex', password='123456') + +def sudo_run(cmd, password='123456'): + full_cmd = f"echo '{password}' | sudo -S bash -c '{cmd}'" + stdin, stdout, stderr = ssh.exec_command(full_cmd, get_pty=True) + exit_code = stdout.channel.recv_exit_status() + out = stdout.read().decode() + lines = [l for l in out.split('\n') if not l.strip().startswith('[sudo]')] + return '\n'.join(lines).strip(), exit_code + +# Check nginx config +out, rc = sudo_run('cat /etc/nginx/sites-available/nafld') +print(f"=== nginx config ===") +print(out) + +# Check certs +out, rc = sudo_run('ls -la /etc/letsencrypt/live/ 2>&1 || echo NO_CERTS') +print(f"\n=== certs ===") +print(out) + +# Try certbot +print(f"\n=== Running certbot ===") +out, rc = sudo_run('certbot --nginx -d aifibrosis.ca -d www.aifibrosis.ca --non-interactive --agree-tos -m alexgaffen@gmail.com --redirect 2>&1') +print(f"RC={rc}") +print(out) + +ssh.close() diff --git a/_setup_certbot.py b/_setup_certbot.py new file mode 100644 index 0000000..10e0b5c --- /dev/null +++ b/_setup_certbot.py @@ -0,0 +1,46 @@ +import paramiko + +ssh = paramiko.SSHClient() +ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +ssh.connect('217.77.7.228', username='alex', password='123456') + +def sudo_run(cmd, password='123456'): + full_cmd = f"echo '{password}' | sudo -S bash -c '{cmd}'" + stdin, stdout, stderr = ssh.exec_command(full_cmd, get_pty=True) + exit_code = stdout.channel.recv_exit_status() + out = stdout.read().decode() + lines = [l for l in out.split('\n') if not l.strip().startswith('[sudo]')] + return '\n'.join(lines).strip(), stderr.read().decode().strip(), exit_code + +# Check if certbot is installed +print("=== Checking certbot ===") +stdin, stdout, stderr = ssh.exec_command("which certbot 2>&1 || echo 'NOT FOUND'") +stdout.channel.recv_exit_status() +print(stdout.read().decode().strip()) + +# Try installing with snap instead (Ubuntu 24.04 prefers snap) +print("\n=== Installing certbot via snap ===") +out, err, rc = sudo_run("snap install --classic certbot 2>&1") +print(f" RC={rc}") +print(f" Output: {out[-800:]}") + +# Link certbot +out, err, rc = sudo_run("ln -sf /snap/bin/certbot /usr/bin/certbot 2>&1") +print(f" Link RC={rc}") + +# Check certbot version +stdin, stdout, stderr = ssh.exec_command("certbot --version 2>&1") +stdout.channel.recv_exit_status() +print(f"\n Version: {stdout.read().decode().strip()}") + +# Now try getting the certificate +print("\n=== Obtaining SSL certificate ===") +out, err, rc = sudo_run("certbot --nginx -d aifibrosis.ca -d www.aifibrosis.ca --non-interactive --agree-tos -m alexgaffen@gmail.com --redirect 2>&1") +print(f" certbot RC={rc}") +# Print full output for debugging +for line in out.split('\n'): + line = line.strip() + if line and not line.startswith('[sudo]'): + print(f" {line}") + +ssh.close() diff --git a/_setup_https.py b/_setup_https.py new file mode 100644 index 0000000..e7f7492 --- /dev/null +++ b/_setup_https.py @@ -0,0 +1,53 @@ +import paramiko, sys + +ssh = paramiko.SSHClient() +ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +ssh.connect('217.77.7.228', username='alex', password='123456') + +def sudo_run(cmd, password='123456'): + """Run a command with sudo, feeding password via stdin.""" + full_cmd = f"echo '{password}' | sudo -S {cmd}" + stdin, stdout, stderr = ssh.exec_command(full_cmd, get_pty=True) + exit_code = stdout.channel.recv_exit_status() + out = stdout.read().decode() + err = stderr.read().decode() + # Filter out the password prompt from output + lines = [l for l in out.split('\n') if not l.strip().startswith('[sudo]')] + return '\n'.join(lines).strip(), err.strip(), exit_code + +def run(cmd): + stdin, stdout, stderr = ssh.exec_command(cmd) + exit_code = stdout.channel.recv_exit_status() + return stdout.read().decode().strip(), stderr.read().decode().strip(), exit_code + +# Step 1: Update nginx config — add domain names +print("=== Updating nginx server_name ===") +out, err, rc = sudo_run("sed -i 's/server_name 217.77.7.228;/server_name 217.77.7.228 aifibrosis.ca www.aifibrosis.ca;/' /etc/nginx/sites-available/nafld") +print(f" RC={rc}") + +# Verify +out, err, rc = sudo_run("nginx -t") +print(f" nginx -t: {out}") + +# Step 2: Install certbot +print("\n=== Installing certbot ===") +out, err, rc = sudo_run("apt-get update -qq && apt-get install -y -qq certbot python3-certbot-nginx") +print(f" Install RC={rc}") + +# Step 3: Obtain SSL certificate +print("\n=== Obtaining SSL certificate ===") +out, err, rc = sudo_run("certbot --nginx -d aifibrosis.ca -d www.aifibrosis.ca --non-interactive --agree-tos -m alexgaffen@gmail.com --redirect") +print(f" certbot RC={rc}") +print(f" Output: {out[-500:] if len(out) > 500 else out}") + +# Step 4: Reload nginx +print("\n=== Reloading nginx ===") +out, err, rc = sudo_run("systemctl reload nginx") +print(f" RC={rc}") + +# Step 5: Check nginx config +out, err, rc = sudo_run("cat /etc/nginx/sites-available/nafld") +print(f"\n=== Final nginx config ===\n{out}") + +ssh.close() +print("\nDone!") diff --git a/_setup_ssl.py b/_setup_ssl.py new file mode 100644 index 0000000..62cc41f --- /dev/null +++ b/_setup_ssl.py @@ -0,0 +1,46 @@ +import paramiko + +ssh = paramiko.SSHClient() +ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +ssh.connect('217.77.7.228', username='alex', password='123456') + +def sudo_run(cmd, password='123456'): + full_cmd = f"echo '{password}' | sudo -S bash -c '{cmd}'" + stdin, stdout, stderr = ssh.exec_command(full_cmd, get_pty=True) + exit_code = stdout.channel.recv_exit_status() + out = stdout.read().decode() + lines = [l for l in out.split('\n') if not l.strip().startswith('[sudo]')] + return '\n'.join(lines).strip(), exit_code + +def run(cmd): + stdin, stdout, stderr = ssh.exec_command(cmd) + exit_code = stdout.channel.recv_exit_status() + return stdout.read().decode().strip(), exit_code + +# Step 1: Update nginx server_name to use fibrosisai.org instead of aifibrosis.ca +print("=== Updating nginx server_name ===") +out, rc = sudo_run("sed -i 's/server_name .*/server_name 217.77.7.228 fibrosisai.org www.fibrosisai.org;/' /etc/nginx/sites-available/nafld") +print(f" sed RC={rc}") + +out, rc = sudo_run('nginx -t 2>&1') +print(f" nginx -t: {out}") + +out, rc = sudo_run('systemctl reload nginx') +print(f" reload RC={rc}") + +# Step 2: Run certbot for fibrosisai.org only (www not yet in DNS) +print("\n=== Running certbot ===") +out, rc = sudo_run('certbot --nginx -d fibrosisai.org --non-interactive --agree-tos -m alexgaffen@gmail.com --redirect 2>&1') +print(f" certbot RC={rc}") +for line in out.split('\n'): + line = line.strip() + if line and not line.startswith('[sudo]'): + print(f" {line}") + +# Step 3: Show final nginx config +print("\n=== Final nginx config ===") +out, rc = sudo_run('cat /etc/nginx/sites-available/nafld') +print(out) + +ssh.close() +print("\nDone!") diff --git a/_ssh_setup.py b/_ssh_setup.py new file mode 100644 index 0000000..e28e6d9 --- /dev/null +++ b/_ssh_setup.py @@ -0,0 +1,17 @@ +import paramiko +ssh = paramiko.SSHClient() +ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +ssh.connect('217.77.7.228', username='alex', password='123456') +pubkey = open(r'C:\Users\alexg\.ssh\id_rsa.pub').read().strip() +for cmd in ['mkdir -p ~/.ssh', 'chmod 700 ~/.ssh', 'touch ~/.ssh/authorized_keys', 'chmod 600 ~/.ssh/authorized_keys']: + stdin, stdout, stderr = ssh.exec_command(cmd) + stdout.channel.recv_exit_status() +stdin, stdout, stderr = ssh.exec_command('cat ~/.ssh/authorized_keys') +existing = stdout.read().decode() +if pubkey[:50] not in existing: + stdin, stdout, stderr = ssh.exec_command(f'echo "{pubkey}" >> ~/.ssh/authorized_keys') + stdout.channel.recv_exit_status() + print('SSH key installed') +else: + print('SSH key already present') +ssh.close() diff --git a/deploy/check_deploy.py b/deploy/check_deploy.py new file mode 100644 index 0000000..15be0bb --- /dev/null +++ b/deploy/check_deploy.py @@ -0,0 +1,36 @@ +import subprocess, re + +# Check baked URLs in JS bundle +result = subprocess.run( + ["grep", "-roh", r"http://[0-9.]*[:/0-9]*", "/home/alex/NAFLD-project/NAFLD/javascript/nafld-app/build/static/js/"], + capture_output=True, text=True +) +urls = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set() +print("URLs in JS bundle:", urls) + +# Check .env +with open("/home/alex/NAFLD-project/NAFLD/javascript/nafld-app/.env") as f: + print(".env:", f.read().strip()) + +# Test login directly +import urllib.request, json +try: + data = json.dumps({"username": "alexg", "password": "nafld2026"}).encode() + req = urllib.request.Request( + "http://127.0.0.1:5000/login", + data=data, + headers={"Content-Type": "application/json"}, + method="POST" + ) + resp = urllib.request.urlopen(req) + body = json.loads(resp.read().decode()) + print("Login OK, got token:", body.get("access_token", "")[:20] + "...") +except Exception as e: + print("Login FAILED:", e) + +# Check users in DB +import sqlite3 +conn = sqlite3.connect("/home/alex/NAFLD-project/NAFLD/src/py-src/users.db") +users = conn.execute("SELECT id, username FROM users").fetchall() +print("Users in DB:", users) +conn.close() diff --git a/deploy/nafld_nginx.conf b/deploy/nafld_nginx.conf new file mode 100644 index 0000000..316b28b --- /dev/null +++ b/deploy/nafld_nginx.conf @@ -0,0 +1,22 @@ +server { + listen 80; + server_name 217.77.7.228; + client_max_body_size 2G; + root /home/alex/NAFLD-project/NAFLD/javascript/nafld-app/build; + index index.html; + + location ~ ^/(analyze|analyze-stream|preview|rethreshold|rethreshold-area|reset-area|undo-area|download-single|upload|largefile|login|refresh|me|home|fake|download|fullFileUpload) { + proxy_pass http://127.0.0.1:5000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 600s; + proxy_send_timeout 600s; + proxy_buffering off; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/deploy/test_login.py b/deploy/test_login.py new file mode 100644 index 0000000..dc0e2df --- /dev/null +++ b/deploy/test_login.py @@ -0,0 +1,11 @@ +import urllib.request, json + +data = json.dumps({"username": "alexg", "password": "nafld2026"}).encode() +req = urllib.request.Request( + "http://127.0.0.1:5000/login", + data=data, + headers={"Content-Type": "application/json"}, + method="POST" +) +resp = urllib.request.urlopen(req) +print(resp.read().decode()) diff --git a/deploy/test_nginx_login.py b/deploy/test_nginx_login.py new file mode 100644 index 0000000..6c32ac6 --- /dev/null +++ b/deploy/test_nginx_login.py @@ -0,0 +1,16 @@ +import urllib.request, json + +# Test login through NGINX (port 80) +data = json.dumps({"username": "alexg", "password": "nafld2026"}).encode() +req = urllib.request.Request( + "http://127.0.0.1/login", # through nginx + data=data, + headers={"Content-Type": "application/json"}, + method="POST" +) +try: + resp = urllib.request.urlopen(req) + body = json.loads(resp.read().decode()) + print("Login via NGINX OK:", list(body.keys())) +except urllib.error.HTTPError as e: + print(f"Login via NGINX FAILED: {e.code} {e.read().decode()}") diff --git a/deploy/test_openslide.py b/deploy/test_openslide.py new file mode 100644 index 0000000..262ab0e --- /dev/null +++ b/deploy/test_openslide.py @@ -0,0 +1,12 @@ +import openslide, os, glob + +upload_dir = "/home/alex/nafld-uploads" +for f in sorted(glob.glob(os.path.join(upload_dir, "*.svs"))): + size_mb = os.path.getsize(f) / (1024*1024) + try: + slide = openslide.OpenSlide(f) + dims = slide.dimensions + slide.close() + print(f"OK: {os.path.basename(f)} ({size_mb:.0f}MB) -> {dims}") + except Exception as e: + print(f"FAIL: {os.path.basename(f)} ({size_mb:.0f}MB) -> {e}")