Spaces:
Sleeping
Sleeping
Commit ·
8f0f112
1
Parent(s): b67be67
feat: upgrade to BacSense v2 with dynamic batch dashboard and UI improvements
Browse files- BacSense_v2_Technical_Documentation.docx.txt +628 -0
- bacsense_v2_package/class_names.pkl +3 -0
- bacsense_v2_package/example_usage.py +48 -0
- bacsense_v2_package/inference.py +268 -0
- bacsense_v2_package/pca_model.pkl +3 -0
- bacsense_v2_package/requirements.txt +7 -0
- bacsense_v2_package/specialist_metadata.json +38 -0
- bacsense_v2_package/specialist_pca.pkl +3 -0
- bacsense_v2_package/specialist_scaler.pkl +3 -0
- bacsense_v2_package/specialist_svm.pkl +3 -0
- bacsense_v2_package/standard_scaler.pkl +3 -0
- bacsense_v2_package/svm_classifier.pkl +3 -0
- bacsense_v2_package/vgg16_feature_extractor.keras +3 -0
- frontend/.gitignore +1 -0
- frontend/run_frontend.ps1 +2 -0
- frontend/src/App.tsx +6 -2
- frontend/src/components/Results.tsx +32 -37
- frontend/src/components/UploadZone.tsx +196 -63
- frontend/vercel.json +8 -0
BacSense_v2_Technical_Documentation.docx.txt
ADDED
|
@@ -0,0 +1,628 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
BacSense v2 | Technical Documentation
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
BacSense
|
| 5 |
+
Version 2.0
|
| 6 |
+
Technical Architecture & Model Documentation
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
Cascaded Hybrid Classifier for Waterborne Bacterial Identification
|
| 10 |
+
VGG16 Transfer Learning + Hand-Crafted Feature Engineering + RBF-SVM
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
Author
|
| 14 |
+
Alapan Sen
|
| 15 |
+
Guide
|
| 16 |
+
Dr. Nilanjana Dutta Roy
|
| 17 |
+
Institution
|
| 18 |
+
Amity University Kolkata
|
| 19 |
+
Deployed
|
| 20 |
+
bacsense.streamlit.app
|
| 21 |
+
Version
|
| 22 |
+
2.0 — Cascaded Specialist
|
| 23 |
+
Date
|
| 24 |
+
March 2026
|
| 25 |
+
________________
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
1. Executive Summary
|
| 29 |
+
BacSense v2 is a two-stage cascaded hybrid classifier for the identification of five waterborne bacterial species from Gram-stained microscopy images. The system combines deep transfer learning (VGG16) with rich hand-crafted feature engineering and a binary specialist Support Vector Machine to resolve a critical confusion pair that the original single-stage model completely failed on.
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
Problem: The original BacSense v1 achieved 95.83% overall accuracy but had near-zero F1 scores for Escherichia coli and Pseudomonas aeruginosa — two morphologically similar Gram-negative rods that VGG16 FC features alone could not separate.
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
Solution: A cascaded architecture that routes ambiguous predictions to a specialist binary SVM trained on a 683-dimensional feature vector combining VGG16 deep features with seven hand-crafted descriptors targeting the discriminative signals invisible to the main model.
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
Metric
|
| 39 |
+
v1 (Main SVM only)
|
| 40 |
+
v2 (Cascaded)
|
| 41 |
+
Overall Accuracy
|
| 42 |
+
95.83%
|
| 43 |
+
95.65%
|
| 44 |
+
E. coli F1
|
| 45 |
+
~0.00 (failing)
|
| 46 |
+
0.9568
|
| 47 |
+
P. aeruginosa F1
|
| 48 |
+
~0.00 (failing)
|
| 49 |
+
0.9563
|
| 50 |
+
P. aeruginosa Recall
|
| 51 |
+
~0.00
|
| 52 |
+
0.9480
|
| 53 |
+
Specialist ROC-AUC
|
| 54 |
+
—
|
| 55 |
+
0.9863
|
| 56 |
+
CV F1 (Specialist)
|
| 57 |
+
—
|
| 58 |
+
0.9498
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
________________
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
2. Dataset
|
| 65 |
+
2.1 Source: DIBaS (Digital Image of Bacteria Species)
|
| 66 |
+
The DIBaS dataset, introduced by Zielinski et al. (2017) in PLOS ONE, is the primary source of microscopy images. It contains 660 images across 33 bacterial species captured at 100x magnification with oil immersion. BacSense uses a 5-class subset of 307 original images corresponding to clinically relevant waterborne pathogens.
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
Species
|
| 70 |
+
Gram
|
| 71 |
+
Shape
|
| 72 |
+
Original Images
|
| 73 |
+
Risk Level
|
| 74 |
+
Escherichia coli
|
| 75 |
+
Negative
|
| 76 |
+
Rod
|
| 77 |
+
59
|
| 78 |
+
High
|
| 79 |
+
Pseudomonas aeruginosa
|
| 80 |
+
Negative
|
| 81 |
+
Rod
|
| 82 |
+
63
|
| 83 |
+
High
|
| 84 |
+
Enterococcus faecalis
|
| 85 |
+
Positive
|
| 86 |
+
Coccus
|
| 87 |
+
62
|
| 88 |
+
Medium
|
| 89 |
+
Clostridium perfringens
|
| 90 |
+
Positive
|
| 91 |
+
Rod
|
| 92 |
+
61
|
| 93 |
+
High
|
| 94 |
+
Listeria monocytogenes
|
| 95 |
+
Positive
|
| 96 |
+
Rod
|
| 97 |
+
62
|
| 98 |
+
High
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
2.2 Data Augmentation
|
| 102 |
+
The original 307 images are insufficient for robust deep feature training. An aggressive augmentation pipeline expands the dataset 18.7x to approximately 5,000 images for main model training. For specialist training, each of the two confusion classes is independently augmented to 800 images.
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
Parameter
|
| 106 |
+
Value
|
| 107 |
+
Rotation range
|
| 108 |
+
360 degrees (full circular)
|
| 109 |
+
Width / height shift
|
| 110 |
+
10%
|
| 111 |
+
Horizontal / vertical flip
|
| 112 |
+
Enabled
|
| 113 |
+
Zoom range
|
| 114 |
+
15%
|
| 115 |
+
Brightness range
|
| 116 |
+
0.85 – 1.15
|
| 117 |
+
Fill mode
|
| 118 |
+
Reflect
|
| 119 |
+
Target per class (specialist)
|
| 120 |
+
800 augmented + original PNG
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
For the specialist, original PNG images (59 E. coli + 63 P. aeruginosa) are combined with augmented JPG images during training to prevent domain shift artifacts between file formats. Total specialist training data: 1,722 images.
|
| 124 |
+
________________
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
3. System Architecture
|
| 128 |
+
3.1 Overview: Two-Stage Cascaded Design
|
| 129 |
+
BacSense v2 uses a cascaded architecture where a fast 5-class main SVM provides an initial prediction. If that prediction falls within the known confusion pair (E. coli or P. aeruginosa), the image is routed to a dedicated specialist binary SVM that uses a much richer feature representation to make the final call.
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
Architecture Flow: Input Image → VGG16 Feature Extractor → PCA(94) → Main SVM → [if ambiguous] → 683-dim Feature Extraction → PCA(243) → Specialist SVM → Final Prediction
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
Stage 1: Main SVM
|
| 138 |
+
If Ambiguous
|
| 139 |
+
Stage 2: Specialist
|
| 140 |
+
Final Output
|
| 141 |
+
Input
|
| 142 |
+
Raw image
|
| 143 |
+
Features
|
| 144 |
+
VGG16 (512-dim) → PCA (94-dim)
|
| 145 |
+
Trigger
|
| 146 |
+
E. coli or P. aeruginosa predicted
|
| 147 |
+
Features
|
| 148 |
+
683-dim rich vector → PCA (243-dim)
|
| 149 |
+
Output
|
| 150 |
+
5-class or binary species label
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
3.2 Stage 1: Main Classifier
|
| 154 |
+
3.2.1 Feature Extractor — VGG16
|
| 155 |
+
VGG16 is used as a frozen feature extractor with ImageNet pre-trained weights. The top classification layer is removed, and the output of the last global average pooling layer provides a 512-dimensional feature vector for each input image. Images are resized to 128 x 128 pixels and normalized to [0, 1] before passing through the network.
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
Component
|
| 159 |
+
Detail
|
| 160 |
+
Base architecture
|
| 161 |
+
VGG16 (Simonyan & Zisserman, 2014)
|
| 162 |
+
Pre-training
|
| 163 |
+
ImageNet (1.2M images, 1000 classes)
|
| 164 |
+
Fine-tuning
|
| 165 |
+
None — weights fully frozen
|
| 166 |
+
Input size
|
| 167 |
+
128 x 128 x 3 (RGB)
|
| 168 |
+
Output
|
| 169 |
+
512-dimensional feature vector
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
3.2.2 Dimensionality Reduction — PCA
|
| 173 |
+
Principal Component Analysis reduces the 512-dimensional VGG16 output to 94 components while retaining 95% of explained variance. This reduces overfitting risk, lowers SVM training time, and removes correlated feature dimensions that introduce noise into the decision boundary.
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
3.2.3 Main Classifier — RBF-SVM
|
| 177 |
+
A Radial Basis Function Support Vector Machine is trained on the PCA-reduced features for 5-class classification. The SVM uses a one-vs-one decision strategy with class-balanced weighting to handle slight class imbalance in the original dataset.
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
Hyperparameter
|
| 181 |
+
Value
|
| 182 |
+
Selection Method
|
| 183 |
+
Kernel
|
| 184 |
+
RBF
|
| 185 |
+
Fixed — standard for high-dim features
|
| 186 |
+
C (regularization)
|
| 187 |
+
10
|
| 188 |
+
GridSearchCV, 3-fold CV
|
| 189 |
+
Gamma
|
| 190 |
+
0.01
|
| 191 |
+
GridSearchCV, 3-fold CV
|
| 192 |
+
Class weight
|
| 193 |
+
Balanced
|
| 194 |
+
Fixed — handles class imbalance
|
| 195 |
+
Decision function
|
| 196 |
+
OVO (one-vs-one)
|
| 197 |
+
Default for multi-class SVC
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
________________
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
4. Stage 2: Specialist Classifier
|
| 204 |
+
4.1 Motivation — Why a Specialist?
|
| 205 |
+
E. coli and P. aeruginosa are both Gram-negative, rod-shaped bacteria with similar cell dimensions and staining characteristics. The standard VGG16 FC feature vector collapses their representations into overlapping regions in feature space, producing near-zero F1 scores for both species in the main model. Three peer-reviewed papers converge on the same solution: richer feature concatenation targeting color distribution, texture micropatterns, and spatial arrangement.
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
Paper
|
| 209 |
+
Key Finding
|
| 210 |
+
Feature Applied in BacSense
|
| 211 |
+
Zielinski et al., 2017 (PLOS ONE)
|
| 212 |
+
Color distribution features explicitly recommended for morphologically similar species
|
| 213 |
+
HSV histogram (48-dim)
|
| 214 |
+
Wahid et al. (Inception-v3 + SVM)
|
| 215 |
+
Feature concatenation is the principled fix for intra-species confusion
|
| 216 |
+
Full 683-dim concatenated vector
|
| 217 |
+
Rachmad et al., 2020
|
| 218 |
+
Binary CNN+SVM specialist viable for rod-shaped bacteria
|
| 219 |
+
Cascaded binary specialist SVM
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
4.2 Feature Vector — 683 Dimensions
|
| 223 |
+
The specialist uses a 683-dimensional feature vector formed by concatenating VGG16 deep features with seven hand-crafted descriptors. Each descriptor targets a specific visual property that differentiates E. coli from P. aeruginosa in Gram-stained images.
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
Feature Group
|
| 227 |
+
Dims
|
| 228 |
+
What It Captures
|
| 229 |
+
Target Signal
|
| 230 |
+
VGG16 FC Features
|
| 231 |
+
512
|
| 232 |
+
Deep semantic representation from ImageNet
|
| 233 |
+
General shape, high-level patterns
|
| 234 |
+
HSV Histogram
|
| 235 |
+
48
|
| 236 |
+
Color distribution across Hue/Sat/Value bins (4x4x3)
|
| 237 |
+
Stain intensity differences
|
| 238 |
+
Morphological
|
| 239 |
+
5
|
| 240 |
+
Area, perimeter, circularity, aspect ratio, solidity
|
| 241 |
+
Cell shape and size
|
| 242 |
+
LBP Histogram
|
| 243 |
+
59
|
| 244 |
+
Local Binary Patterns, P=8, R=1, uniform
|
| 245 |
+
Texture micropatterns, biofilm
|
| 246 |
+
GLCM Descriptors
|
| 247 |
+
24
|
| 248 |
+
Contrast, dissimilarity, homogeneity, energy, correlation, ASM (4 angles)
|
| 249 |
+
Spatial gray-level co-occurrence
|
| 250 |
+
Channel Stats
|
| 251 |
+
9
|
| 252 |
+
Mean, std, skewness per H/S/V channel
|
| 253 |
+
Fine-grained color statistics
|
| 254 |
+
Density Grid
|
| 255 |
+
16
|
| 256 |
+
4x4 spatial grid of foreground pixel density
|
| 257 |
+
Clustering vs dispersal pattern
|
| 258 |
+
Hu Moments
|
| 259 |
+
7
|
| 260 |
+
7 rotation-invariant moment descriptors (log-transformed)
|
| 261 |
+
Global shape invariants
|
| 262 |
+
Curvature
|
| 263 |
+
3
|
| 264 |
+
Mean, std, max curvature across all contour points
|
| 265 |
+
Rod bending — key P. aeruginosa signal
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
4.3 Why Each Feature Matters for This Pair
|
| 269 |
+
LBP (Local Binary Patterns)
|
| 270 |
+
P. aeruginosa is known to form loose biofilm-like clusters under microscopy. LBP captures the local texture microstructure created by these aggregations — a signal completely absent from VGG16 FC features which operate at a semantic rather than textural level.
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
GLCM (Gray-Level Co-occurrence Matrix)
|
| 274 |
+
GLCM descriptors computed at four angles (0, 45, 90, 135 degrees) capture the spatial relationship between intensity values across the image. P. aeruginosa typically shows higher local contrast and dissimilarity values due to its clustering behavior and slightly different staining depth.
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
Curvature Statistics
|
| 278 |
+
P. aeruginosa rods tend to exhibit more curvature and bending than E. coli rods, which are typically straighter. Curvature is computed as the cross product of consecutive edge vectors along detected contours, summarized as mean, standard deviation, and maximum curvature values across all contours in the image.
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
Spatial Density Grid
|
| 282 |
+
A 4x4 grid divides the image into 16 cells and computes foreground pixel density in each cell. This captures the spatial distribution and clustering pattern of bacteria across the slide — P. aeruginosa tends to form denser local aggregations while E. coli distributes more uniformly.
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
4.4 Specialist Pipeline
|
| 286 |
+
Step
|
| 287 |
+
Operation
|
| 288 |
+
Output Dims
|
| 289 |
+
1. VGG16 extraction
|
| 290 |
+
Forward pass through frozen VGG16
|
| 291 |
+
512
|
| 292 |
+
2. Hand-craft extraction
|
| 293 |
+
HSV + Morph + LBP + GLCM + ChannelStats + Density + Hu + Curvature
|
| 294 |
+
171
|
| 295 |
+
3. Concatenation
|
| 296 |
+
np.concatenate([vgg, rich])
|
| 297 |
+
683
|
| 298 |
+
4. Standardization
|
| 299 |
+
StandardScaler (zero mean, unit variance)
|
| 300 |
+
683
|
| 301 |
+
5. PCA
|
| 302 |
+
Retain 95% variance
|
| 303 |
+
243
|
| 304 |
+
6. SVM prediction
|
| 305 |
+
RBF-SVM, C=100, gamma=0.001, probability=True
|
| 306 |
+
Binary (0=E.coli, 1=P.aeru)
|
| 307 |
+
7. Confidence gate
|
| 308 |
+
Accept specialist if prob >= 0.90, else fallback to Stage 1
|
| 309 |
+
Final label
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
4.5 Confidence Threshold Gate
|
| 313 |
+
A confidence gate prevents the specialist from overriding the main SVM when it is uncertain. If the specialist's maximum class probability is below 0.90, the main SVM's prediction is used as the final answer. This is a practical engineering decision that prevents low-confidence specialist predictions from overriding a correct main SVM result, at the cost of occasionally retaining main SVM errors in the E. coli / P. aeruginosa pair.
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
Design Note: The 0.90 threshold was chosen based on observed confidence distributions. Future work should calibrate this threshold using a held-out validation set to maximize the F1 score of the gated system.
|
| 317 |
+
________________
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
5. Training Details
|
| 321 |
+
5.1 Main Model Training
|
| 322 |
+
Component
|
| 323 |
+
Configuration
|
| 324 |
+
Training images
|
| 325 |
+
~5,000 (307 original × 18.7x augmentation)
|
| 326 |
+
PCA components
|
| 327 |
+
94 (95% variance retained from 512-dim input)
|
| 328 |
+
SVM kernel
|
| 329 |
+
RBF
|
| 330 |
+
Hyperparameter search
|
| 331 |
+
GridSearchCV, 3-fold cross-validation
|
| 332 |
+
Search space C
|
| 333 |
+
[0.1, 1, 10, 100]
|
| 334 |
+
Search space gamma
|
| 335 |
+
["scale", 0.1, 0.01, 0.001]
|
| 336 |
+
Best params
|
| 337 |
+
C=10, gamma=0.01
|
| 338 |
+
Scoring metric
|
| 339 |
+
F1 (macro)
|
| 340 |
+
Test accuracy
|
| 341 |
+
95.83%
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
5.2 Specialist Model Training
|
| 345 |
+
Component
|
| 346 |
+
Configuration
|
| 347 |
+
Training images
|
| 348 |
+
1,722 total (800 aug E.coli + 800 aug P.aeru + 122 original PNG)
|
| 349 |
+
Feature vector
|
| 350 |
+
683-dim (512 VGG16 + 171 hand-crafted)
|
| 351 |
+
PCA components
|
| 352 |
+
243 (95% variance retained from 683-dim input)
|
| 353 |
+
SVM kernel
|
| 354 |
+
RBF
|
| 355 |
+
Hyperparameter search
|
| 356 |
+
GridSearchCV, 3-fold cross-validation
|
| 357 |
+
Search space C
|
| 358 |
+
[1, 10, 100]
|
| 359 |
+
Search space gamma
|
| 360 |
+
["scale", 0.01, 0.001]
|
| 361 |
+
Best params
|
| 362 |
+
C=100, gamma=0.001
|
| 363 |
+
Class weight
|
| 364 |
+
Balanced
|
| 365 |
+
Scoring metric
|
| 366 |
+
F1 (binary)
|
| 367 |
+
CV F1
|
| 368 |
+
0.9498
|
| 369 |
+
Test accuracy
|
| 370 |
+
95.65%
|
| 371 |
+
|
| 372 |
+
|
| 373 |
+
5.3 Domain Shift Handling
|
| 374 |
+
The augmented training images were saved as JPEG files, while the original DIBaS images are PNG. JPEG compression introduces subtle color and texture artifacts that shift the feature distribution, causing the specialist (trained on augmented JPEGs) to misclassify original PNG images at inference. This was resolved by mixing all original PNG images into the specialist training set, forcing the model to learn features robust to both formats.
|
| 375 |
+
________________
|
| 376 |
+
|
| 377 |
+
|
| 378 |
+
6. Evaluation Results
|
| 379 |
+
6.1 Specialist Classifier Performance
|
| 380 |
+
Metric
|
| 381 |
+
E. coli
|
| 382 |
+
P. aeruginosa
|
| 383 |
+
Macro Avg
|
| 384 |
+
Precision
|
| 385 |
+
0.9486
|
| 386 |
+
0.9647
|
| 387 |
+
0.9566
|
| 388 |
+
Recall
|
| 389 |
+
0.9651
|
| 390 |
+
0.9480
|
| 391 |
+
0.9565
|
| 392 |
+
F1-Score
|
| 393 |
+
0.9568
|
| 394 |
+
0.9563
|
| 395 |
+
0.9565
|
| 396 |
+
Support
|
| 397 |
+
172
|
| 398 |
+
173
|
| 399 |
+
345
|
| 400 |
+
|
| 401 |
+
|
| 402 |
+
Overall test accuracy: 95.65% on 345 held-out images. ROC-AUC: 0.9863, indicating strong probability calibration across the decision boundary.
|
| 403 |
+
|
| 404 |
+
|
| 405 |
+
6.2 Improvement Over v1
|
| 406 |
+
Metric
|
| 407 |
+
v1 Main Model
|
| 408 |
+
v2 Specialist
|
| 409 |
+
Improvement
|
| 410 |
+
E. coli F1
|
| 411 |
+
~0.00
|
| 412 |
+
0.9568
|
| 413 |
+
+0.9568
|
| 414 |
+
P. aeruginosa F1
|
| 415 |
+
~0.00
|
| 416 |
+
0.9563
|
| 417 |
+
+0.9563
|
| 418 |
+
P. aeruginosa Recall
|
| 419 |
+
~0.00
|
| 420 |
+
0.9480
|
| 421 |
+
+0.9480
|
| 422 |
+
CV F1
|
| 423 |
+
N/A
|
| 424 |
+
0.9498
|
| 425 |
+
—
|
| 426 |
+
ROC-AUC
|
| 427 |
+
N/A
|
| 428 |
+
0.9863
|
| 429 |
+
—
|
| 430 |
+
|
| 431 |
+
|
| 432 |
+
6.3 Integration Test
|
| 433 |
+
End-to-end testing of the full cascaded pipeline on original held-out images (not seen during training) produced the following results:
|
| 434 |
+
|
| 435 |
+
|
| 436 |
+
Test
|
| 437 |
+
Correct / Total
|
| 438 |
+
Notes
|
| 439 |
+
E. coli (original PNG)
|
| 440 |
+
5 / 5
|
| 441 |
+
All routed to specialist, correctly classified
|
| 442 |
+
P. aeruginosa (original PNG)
|
| 443 |
+
5 / 5
|
| 444 |
+
All routed to specialist, correctly classified
|
| 445 |
+
Total
|
| 446 |
+
10 / 10
|
| 447 |
+
100% on held-out originals
|
| 448 |
+
________________
|
| 449 |
+
|
| 450 |
+
|
| 451 |
+
7. Technical Stack
|
| 452 |
+
Component
|
| 453 |
+
Library / Version
|
| 454 |
+
Purpose
|
| 455 |
+
Deep feature extractor
|
| 456 |
+
TensorFlow / Keras + VGG16
|
| 457 |
+
ImageNet transfer learning
|
| 458 |
+
Dimensionality reduction
|
| 459 |
+
scikit-learn PCA
|
| 460 |
+
Variance-preserving compression
|
| 461 |
+
Main classifier
|
| 462 |
+
scikit-learn SVC (RBF)
|
| 463 |
+
5-class prediction
|
| 464 |
+
Specialist classifier
|
| 465 |
+
scikit-learn SVC (RBF, probability=True)
|
| 466 |
+
Binary E.coli / P.aeru
|
| 467 |
+
LBP features
|
| 468 |
+
scikit-image local_binary_pattern
|
| 469 |
+
Texture micropatterns
|
| 470 |
+
GLCM features
|
| 471 |
+
scikit-image graycomatrix / graycoprops
|
| 472 |
+
Spatial co-occurrence
|
| 473 |
+
Color / morphology
|
| 474 |
+
OpenCV (cv2)
|
| 475 |
+
HSV histogram, contours, moments
|
| 476 |
+
Statistical features
|
| 477 |
+
scipy.stats.skew
|
| 478 |
+
Channel skewness
|
| 479 |
+
Image I/O
|
| 480 |
+
Pillow (PIL)
|
| 481 |
+
Image loading and resizing
|
| 482 |
+
Deployment
|
| 483 |
+
Streamlit
|
| 484 |
+
Web interface at bacsense.streamlit.app
|
| 485 |
+
|
| 486 |
+
|
| 487 |
+
8. Saved Artifacts
|
| 488 |
+
All model artifacts are saved to Google Drive and packaged into bacsense_v2_package.zip for local deployment.
|
| 489 |
+
|
| 490 |
+
|
| 491 |
+
File
|
| 492 |
+
Size
|
| 493 |
+
Description
|
| 494 |
+
vgg16_feature_extractor.keras
|
| 495 |
+
~55 MB
|
| 496 |
+
Frozen VGG16 feature extractor (no top layer)
|
| 497 |
+
pca_model.pkl
|
| 498 |
+
~2 MB
|
| 499 |
+
PCA model for main pipeline (512 → 94 dims)
|
| 500 |
+
standard_scaler.pkl
|
| 501 |
+
~0.1 MB
|
| 502 |
+
Scaler for main SVM input
|
| 503 |
+
svm_classifier.pkl
|
| 504 |
+
Variable
|
| 505 |
+
Trained 5-class RBF-SVM
|
| 506 |
+
class_names.pkl
|
| 507 |
+
<1 KB
|
| 508 |
+
Ordered list of 5 species names
|
| 509 |
+
specialist_svm.pkl
|
| 510 |
+
~712 KB
|
| 511 |
+
Binary specialist RBF-SVM (prob=True)
|
| 512 |
+
specialist_scaler.pkl
|
| 513 |
+
~13 KB
|
| 514 |
+
Scaler for 683-dim specialist input
|
| 515 |
+
specialist_pca.pkl
|
| 516 |
+
~1 MB
|
| 517 |
+
PCA for specialist (683 → 243 dims)
|
| 518 |
+
specialist_metadata.json
|
| 519 |
+
<1 KB
|
| 520 |
+
Training config, metrics, timestamps
|
| 521 |
+
inference.py
|
| 522 |
+
~15 KB
|
| 523 |
+
Standalone BacSense class for deployment
|
| 524 |
+
requirements.txt
|
| 525 |
+
<1 KB
|
| 526 |
+
Python dependency list
|
| 527 |
+
example_usage.py
|
| 528 |
+
~3 KB
|
| 529 |
+
Quickstart and Streamlit integration code
|
| 530 |
+
________________
|
| 531 |
+
|
| 532 |
+
|
| 533 |
+
9. Inference API
|
| 534 |
+
9.1 Installation
|
| 535 |
+
Install dependencies and point the BacSense class at the unpacked package directory:
|
| 536 |
+
|
| 537 |
+
|
| 538 |
+
Install: pip install tensorflow scikit-learn scikit-image opencv-python scipy Pillow
|
| 539 |
+
|
| 540 |
+
|
| 541 |
+
9.2 Usage
|
| 542 |
+
Method
|
| 543 |
+
Signature
|
| 544 |
+
Returns
|
| 545 |
+
Constructor
|
| 546 |
+
BacSense(model_dir, specialist_threshold=0.90)
|
| 547 |
+
BacSense instance
|
| 548 |
+
warmup()
|
| 549 |
+
Pre-loads all models into memory
|
| 550 |
+
None
|
| 551 |
+
predict()
|
| 552 |
+
predict(img_path, verbose=False)
|
| 553 |
+
Result dict (see below)
|
| 554 |
+
predict_batch()
|
| 555 |
+
predict_batch(img_paths, verbose=False)
|
| 556 |
+
List of result dicts
|
| 557 |
+
|
| 558 |
+
|
| 559 |
+
9.3 Result Dictionary
|
| 560 |
+
Key
|
| 561 |
+
Type
|
| 562 |
+
Example Value
|
| 563 |
+
prediction
|
| 564 |
+
str
|
| 565 |
+
Escherichia coli
|
| 566 |
+
confidence
|
| 567 |
+
float
|
| 568 |
+
0.9621
|
| 569 |
+
routed_to_specialist
|
| 570 |
+
bool
|
| 571 |
+
True
|
| 572 |
+
specialist_accepted
|
| 573 |
+
bool
|
| 574 |
+
True
|
| 575 |
+
main_prediction
|
| 576 |
+
str
|
| 577 |
+
Escherichia coli
|
| 578 |
+
gram
|
| 579 |
+
str
|
| 580 |
+
Negative
|
| 581 |
+
shape
|
| 582 |
+
str
|
| 583 |
+
Rod
|
| 584 |
+
risk
|
| 585 |
+
str
|
| 586 |
+
High
|
| 587 |
+
________________
|
| 588 |
+
|
| 589 |
+
|
| 590 |
+
10. Limitations & Future Work
|
| 591 |
+
10.1 Current Limitations
|
| 592 |
+
* P. aeruginosa recall is 94.80% — approximately 5.2% of P. aeruginosa images are still misclassified.
|
| 593 |
+
* The confidence threshold (0.90) was chosen empirically, not calibrated on a held-out validation set.
|
| 594 |
+
* The dataset is small (307 original images across 5 classes). Models trained on small datasets may not generalize to different microscopes, staining protocols, or magnifications.
|
| 595 |
+
* JPEG augmentation introduces domain shift relative to original PNG images; mitigated by mixing formats in training, but not fully eliminated.
|
| 596 |
+
* Inference speed is slower than single-stage models due to sequential feature extraction for routed images.
|
| 597 |
+
|
| 598 |
+
|
| 599 |
+
10.2 Future Work
|
| 600 |
+
* Collect 20-30 more original P. aeruginosa images to improve specialist recall beyond 0.95.
|
| 601 |
+
* Add Gabor filter features (5 scales x 8 orientations = 40 dims) to further capture P. aeruginosa biofilm texture.
|
| 602 |
+
* Calibrate confidence threshold using cross-validated F1 optimization on a dedicated validation set.
|
| 603 |
+
* Extend specialist to handle all morphologically similar pairs, not just E. coli / P. aeruginosa.
|
| 604 |
+
* Evaluate on images from a different microscopy system to assess generalization.
|
| 605 |
+
* Replace binary specialist with a soft-label ensemble combining main SVM and specialist votes.
|
| 606 |
+
________________
|
| 607 |
+
|
| 608 |
+
|
| 609 |
+
11. References
|
| 610 |
+
|
| 611 |
+
|
| 612 |
+
[1] Zielinski, B., Plichta, A., Misztal, K., Spurek, P., Brzychczy-Wloch, M., & Ochonska, D. (2017). Deep learning approach to bacterial colony classification. PLOS ONE, 12(9), e0184554.
|
| 613 |
+
|
| 614 |
+
|
| 615 |
+
[2] Rachmad, A., et al. (2020). Comparison of CNN-Based methods for tuberculosis bacteria classification using ResNet-101 and Support Vector Machine. Journal of Physics: Conference Series.
|
| 616 |
+
|
| 617 |
+
|
| 618 |
+
[3] Wahid, A., et al. CNN-based bacterial image classification using Inception-v3 with SVM, KNN, and Naive Bayes classifiers. Applied Sciences.
|
| 619 |
+
|
| 620 |
+
|
| 621 |
+
[4] Simonyan, K., & Zisserman, A. (2014). Very deep convolutional networks for large-scale image recognition. arXiv:1409.1556.
|
| 622 |
+
|
| 623 |
+
|
| 624 |
+
[5] Ojala, T., Pietikainen, M., & Maenpaa, T. (2002). Multiresolution gray-scale and rotation invariant texture classification with local binary patterns. IEEE TPAMI, 24(7), 971-987.
|
| 625 |
+
|
| 626 |
+
|
| 627 |
+
[6] Haralick, R. M., Shanmugam, K., & Dinstein, I. (1973). Textural features for image classification. IEEE Transactions on Systems, Man, and Cybernetics, 3(6), 610-621.
|
| 628 |
+
Amity University Kolkata | Alapan SenPage
|
bacsense_v2_package/class_names.pkl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:5acfb42b5a3aa9da5d6a4479ca3ef12d737a42923c4205fa51645dcfdb4bb635
|
| 3 |
+
size 135
|
bacsense_v2_package/example_usage.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# =============================================================================
|
| 2 |
+
# BacSense v2 — Usage Examples
|
| 3 |
+
# pip install -r requirements.txt
|
| 4 |
+
# =============================================================================
|
| 5 |
+
|
| 6 |
+
from inference import BacSense
|
| 7 |
+
|
| 8 |
+
# ── Basic usage ───────────────────────────────────────────────────
|
| 9 |
+
model = BacSense("bacsense_v2_package")
|
| 10 |
+
model.warmup() # pre-load at startup (optional but recommended)
|
| 11 |
+
|
| 12 |
+
result = model.predict("image.png", verbose=True)
|
| 13 |
+
print(result["prediction"]) # Escherichia coli
|
| 14 |
+
print(result["confidence"]) # 0.9621
|
| 15 |
+
print(result["gram"]) # Negative
|
| 16 |
+
print(result["shape"]) # Rod
|
| 17 |
+
print(result["risk"]) # High
|
| 18 |
+
|
| 19 |
+
# ── Batch prediction ──────────────────────────────────────────────
|
| 20 |
+
results = model.predict_batch(["img1.png", "img2.png", "img3.png"])
|
| 21 |
+
for r in results:
|
| 22 |
+
print(r["prediction"], r["confidence"])
|
| 23 |
+
|
| 24 |
+
# ── Streamlit app ─────────────────────────────────────────────────
|
| 25 |
+
# import streamlit as st
|
| 26 |
+
# from inference import BacSense
|
| 27 |
+
#
|
| 28 |
+
# @st.cache_resource
|
| 29 |
+
# def load_model():
|
| 30 |
+
# m = BacSense("bacsense_v2_package")
|
| 31 |
+
# m.warmup()
|
| 32 |
+
# return m
|
| 33 |
+
#
|
| 34 |
+
# model = load_model()
|
| 35 |
+
# st.title("BacSense v2 — Bacterial Classifier")
|
| 36 |
+
# uploaded = st.file_uploader("Upload microscopy image", type=["png","jpg","jpeg"])
|
| 37 |
+
# if uploaded:
|
| 38 |
+
# with open("temp_input.png", "wb") as f:
|
| 39 |
+
# f.write(uploaded.read())
|
| 40 |
+
# result = model.predict("temp_input.png", verbose=True)
|
| 41 |
+
# st.success(f"Prediction: {result['prediction']}")
|
| 42 |
+
# st.metric("Confidence", f"{result['confidence']:.2%}")
|
| 43 |
+
# col1, col2, col3 = st.columns(3)
|
| 44 |
+
# col1.metric("Gram Stain", result["gram"])
|
| 45 |
+
# col2.metric("Shape", result["shape"])
|
| 46 |
+
# col3.metric("Risk Level", result["risk"])
|
| 47 |
+
# if result["routed_to_specialist"]:
|
| 48 |
+
# st.info("Specialist classifier was used for E.coli / P.aeruginosa disambiguation")
|
bacsense_v2_package/inference.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
# =============================================================================
|
| 3 |
+
# BacSense v2 — Standalone Inference Module
|
| 4 |
+
# =============================================================================
|
| 5 |
+
# Usage:
|
| 6 |
+
# from inference import BacSense
|
| 7 |
+
# model = BacSense("path/to/bacsense_v2_package")
|
| 8 |
+
# result = model.predict("image.png")
|
| 9 |
+
# print(result["prediction"])
|
| 10 |
+
# =============================================================================
|
| 11 |
+
|
| 12 |
+
import pickle, cv2, warnings
|
| 13 |
+
import numpy as np
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
from PIL import Image
|
| 16 |
+
|
| 17 |
+
warnings.filterwarnings("ignore")
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _lazy_imports():
|
| 21 |
+
from scipy.stats import skew
|
| 22 |
+
from skimage.feature import local_binary_pattern, graycomatrix, graycoprops
|
| 23 |
+
from tensorflow.keras.models import load_model
|
| 24 |
+
return skew, local_binary_pattern, graycomatrix, graycoprops, load_model
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class BacSense:
|
| 28 |
+
"""
|
| 29 |
+
BacSense v2 — Two-stage cascaded bacterial classifier.
|
| 30 |
+
|
| 31 |
+
Stage 1 : VGG16 + PCA(94) + RBF-SVM → 5-class prediction
|
| 32 |
+
Stage 2 : VGG16 + 171-dim handcrafted features + PCA(243) + RBF-SVM
|
| 33 |
+
(only for E. coli / P. aeruginosa confusion pair)
|
| 34 |
+
|
| 35 |
+
Feature vector (Stage 2): 683-dim
|
| 36 |
+
VGG16(512) + HSV histogram(48) + Morphological(5) + LBP(59)
|
| 37 |
+
+ GLCM(24) + Channel stats(9) + Density grid(16)
|
| 38 |
+
+ Hu moments(7) + Curvature(3)
|
| 39 |
+
|
| 40 |
+
Performance:
|
| 41 |
+
Overall accuracy : 95.65%
|
| 42 |
+
E. coli F1 : 0.9568
|
| 43 |
+
P. aeruginosa F1 : 0.9563
|
| 44 |
+
ROC-AUC : 0.9863
|
| 45 |
+
"""
|
| 46 |
+
|
| 47 |
+
SPECIES_INFO = {
|
| 48 |
+
"Escherichia coli": {"gram": "Negative", "shape": "Rod", "risk": "High"},
|
| 49 |
+
"Pseudomonas aeruginosa": {"gram": "Negative", "shape": "Rod", "risk": "High"},
|
| 50 |
+
"Enterococcus faecalis": {"gram": "Positive", "shape": "Coccus", "risk": "Medium"},
|
| 51 |
+
"Clostridium perfringens": {"gram": "Positive", "shape": "Rod", "risk": "High"},
|
| 52 |
+
"Listeria monocytogenes": {"gram": "Positive", "shape": "Rod", "risk": "High"},
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
AMBIGUOUS = ["Escherichia coli", "Pseudomonas aeruginosa"]
|
| 56 |
+
|
| 57 |
+
def __init__(self, model_dir: str, specialist_threshold: float = 0.90):
|
| 58 |
+
"""
|
| 59 |
+
Args:
|
| 60 |
+
model_dir : path to folder containing all model files
|
| 61 |
+
specialist_threshold : min specialist confidence to accept (default 0.90)
|
| 62 |
+
"""
|
| 63 |
+
self.model_dir = Path(model_dir)
|
| 64 |
+
self.threshold = specialist_threshold
|
| 65 |
+
self._loaded = False
|
| 66 |
+
|
| 67 |
+
def _load(self):
|
| 68 |
+
if self._loaded:
|
| 69 |
+
return
|
| 70 |
+
print("Loading BacSense v2 models...")
|
| 71 |
+
skew, lbp_fn, graycomatrix, graycoprops, load_model = _lazy_imports()
|
| 72 |
+
self._skew = skew
|
| 73 |
+
self._lbp_fn = lbp_fn
|
| 74 |
+
self._graycomatrix = graycomatrix
|
| 75 |
+
self._graycoprops = graycoprops
|
| 76 |
+
|
| 77 |
+
d = self.model_dir
|
| 78 |
+
self.feature_extractor = load_model(str(d / "vgg16_feature_extractor.keras"))
|
| 79 |
+
with open(d / "pca_model.pkl", "rb") as f: self.pca = pickle.load(f)
|
| 80 |
+
with open(d / "standard_scaler.pkl", "rb") as f: self.scaler = pickle.load(f)
|
| 81 |
+
with open(d / "svm_classifier.pkl", "rb") as f: self.main_svm = pickle.load(f)
|
| 82 |
+
with open(d / "class_names.pkl", "rb") as f: self.class_names = pickle.load(f)
|
| 83 |
+
with open(d / "specialist_svm.pkl", "rb") as f: self.spec_svm = pickle.load(f)
|
| 84 |
+
with open(d / "specialist_scaler.pkl", "rb") as f: self.spec_scaler = pickle.load(f)
|
| 85 |
+
with open(d / "specialist_pca.pkl", "rb") as f: self.spec_pca = pickle.load(f)
|
| 86 |
+
self._loaded = True
|
| 87 |
+
print(" All models loaded ✅")
|
| 88 |
+
|
| 89 |
+
# ── Feature extractors ─────────────────────────────────────────
|
| 90 |
+
|
| 91 |
+
def _hsv_histogram(self, img_path, bins=(4, 4, 3)):
|
| 92 |
+
img = cv2.imread(str(img_path))
|
| 93 |
+
hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
|
| 94 |
+
hist = cv2.calcHist([hsv], [0,1,2], None, list(bins), [0,180,0,256,0,256])
|
| 95 |
+
return cv2.normalize(hist, hist).flatten() # 48
|
| 96 |
+
|
| 97 |
+
def _morphological(self, img_path):
|
| 98 |
+
img = cv2.imread(str(img_path), cv2.IMREAD_GRAYSCALE)
|
| 99 |
+
blur = cv2.GaussianBlur(img, (5,5), 0)
|
| 100 |
+
_,thresh = cv2.threshold(blur, 0, 255, cv2.THRESH_BINARY+cv2.THRESH_OTSU)
|
| 101 |
+
contours,_ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
| 102 |
+
if not contours: return np.zeros(5)
|
| 103 |
+
c = max(contours, key=cv2.contourArea)
|
| 104 |
+
area = cv2.contourArea(c)
|
| 105 |
+
perim = cv2.arcLength(c, True)
|
| 106 |
+
circ = 4*np.pi*area / (perim**2+1e-6)
|
| 107 |
+
x,y,w,h = cv2.boundingRect(c)
|
| 108 |
+
aspect = w / (h+1e-6)
|
| 109 |
+
solidity = area / (cv2.contourArea(cv2.convexHull(c))+1e-6)
|
| 110 |
+
return np.array([area, perim, circ, aspect, solidity]) # 5
|
| 111 |
+
|
| 112 |
+
def _lbp(self, img_path, P=8, R=1, n_bins=59):
|
| 113 |
+
img = cv2.imread(str(img_path), cv2.IMREAD_GRAYSCALE)
|
| 114 |
+
img = cv2.resize(img, (128,128))
|
| 115 |
+
lbp = self._lbp_fn(img, P, R, method="uniform")
|
| 116 |
+
hist,_ = np.histogram(lbp.ravel(), bins=n_bins, range=(0,n_bins))
|
| 117 |
+
hist = hist.astype(float); hist /= (hist.sum()+1e-6)
|
| 118 |
+
return hist # 59
|
| 119 |
+
|
| 120 |
+
def _glcm(self, img_path):
|
| 121 |
+
img = cv2.imread(str(img_path), cv2.IMREAD_GRAYSCALE)
|
| 122 |
+
img = cv2.resize(img, (128,128))
|
| 123 |
+
img = (img//4).astype(np.uint8)
|
| 124 |
+
angles = [0, np.pi/4, np.pi/2, 3*np.pi/4]
|
| 125 |
+
glcm = self._graycomatrix(img, distances=[1], angles=angles,
|
| 126 |
+
levels=64, symmetric=True, normed=True)
|
| 127 |
+
feats = []
|
| 128 |
+
for prop in ["contrast","dissimilarity","homogeneity",
|
| 129 |
+
"energy","correlation","ASM"]:
|
| 130 |
+
feats.extend(self._graycoprops(glcm, prop).flatten())
|
| 131 |
+
return np.array(feats) # 24
|
| 132 |
+
|
| 133 |
+
def _channel_stats(self, img_path):
|
| 134 |
+
img = cv2.imread(str(img_path))
|
| 135 |
+
hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV).astype(float)
|
| 136 |
+
feats = []
|
| 137 |
+
for ch in range(3):
|
| 138 |
+
d = hsv[:,:,ch].ravel()
|
| 139 |
+
feats.extend([d.mean(), d.std(), self._skew(d)])
|
| 140 |
+
return np.array(feats) # 9
|
| 141 |
+
|
| 142 |
+
def _density_grid(self, img_path, grid=4):
|
| 143 |
+
img = cv2.imread(str(img_path), cv2.IMREAD_GRAYSCALE)
|
| 144 |
+
img = cv2.resize(img, (128,128))
|
| 145 |
+
_,thresh = cv2.threshold(
|
| 146 |
+
cv2.GaussianBlur(img,(5,5),0), 0, 255,
|
| 147 |
+
cv2.THRESH_BINARY+cv2.THRESH_OTSU)
|
| 148 |
+
h,w = thresh.shape; gh,gw = h//grid, w//grid
|
| 149 |
+
return np.array([
|
| 150 |
+
thresh[i*gh:(i+1)*gh, j*gw:(j+1)*gw].mean()/255.0
|
| 151 |
+
for i in range(grid) for j in range(grid)
|
| 152 |
+
]) # 16
|
| 153 |
+
|
| 154 |
+
def _hu_moments(self, img_path):
|
| 155 |
+
img = cv2.imread(str(img_path), cv2.IMREAD_GRAYSCALE)
|
| 156 |
+
img = cv2.resize(img, (128,128))
|
| 157 |
+
_,thresh = cv2.threshold(
|
| 158 |
+
cv2.GaussianBlur(img,(5,5),0), 0, 255,
|
| 159 |
+
cv2.THRESH_BINARY+cv2.THRESH_OTSU)
|
| 160 |
+
hu = cv2.HuMoments(cv2.moments(thresh)).flatten()
|
| 161 |
+
return -np.sign(hu) * np.log10(np.abs(hu)+1e-10) # 7
|
| 162 |
+
|
| 163 |
+
def _curvature(self, img_path):
|
| 164 |
+
img = cv2.imread(str(img_path), cv2.IMREAD_GRAYSCALE)
|
| 165 |
+
_,thresh = cv2.threshold(
|
| 166 |
+
cv2.GaussianBlur(img,(5,5),0), 0, 255,
|
| 167 |
+
cv2.THRESH_BINARY+cv2.THRESH_OTSU)
|
| 168 |
+
contours,_ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)
|
| 169 |
+
if not contours: return np.zeros(3)
|
| 170 |
+
curvatures = []
|
| 171 |
+
for c in contours:
|
| 172 |
+
if len(c) < 5: continue
|
| 173 |
+
pts = c[:,0,:].astype(float)
|
| 174 |
+
for i in range(1, len(pts)-1):
|
| 175 |
+
v1 = pts[i]-pts[i-1]; v2 = pts[i+1]-pts[i]
|
| 176 |
+
cross = abs(v1[0]*v2[1]-v1[1]*v2[0])
|
| 177 |
+
curvatures.append(cross/(np.linalg.norm(v1)*np.linalg.norm(v2)+1e-6))
|
| 178 |
+
if not curvatures: return np.zeros(3)
|
| 179 |
+
curv = np.array(curvatures)
|
| 180 |
+
return np.array([curv.mean(), curv.std(), curv.max()]) # 3
|
| 181 |
+
|
| 182 |
+
def _rich_features(self, img_path):
|
| 183 |
+
return np.concatenate([
|
| 184 |
+
self._hsv_histogram(img_path), # 48
|
| 185 |
+
self._morphological(img_path), # 5
|
| 186 |
+
self._lbp(img_path), # 59
|
| 187 |
+
self._glcm(img_path), # 24
|
| 188 |
+
self._channel_stats(img_path), # 9
|
| 189 |
+
self._density_grid(img_path), # 16
|
| 190 |
+
self._hu_moments(img_path), # 7
|
| 191 |
+
self._curvature(img_path), # 3
|
| 192 |
+
]) # 171 total
|
| 193 |
+
|
| 194 |
+
# ── Public API ─────────────────────────────────────────────────
|
| 195 |
+
|
| 196 |
+
def predict(self, img_path: str, verbose: bool = False) -> dict:
|
| 197 |
+
"""
|
| 198 |
+
Classify a bacterial microscopy image.
|
| 199 |
+
|
| 200 |
+
Returns dict:
|
| 201 |
+
prediction : str species name
|
| 202 |
+
confidence : float 0-1
|
| 203 |
+
routed_to_specialist : bool
|
| 204 |
+
specialist_accepted : bool
|
| 205 |
+
main_prediction : str
|
| 206 |
+
gram : str Positive / Negative
|
| 207 |
+
shape : str Rod / Coccus
|
| 208 |
+
risk : str High / Medium / Low
|
| 209 |
+
"""
|
| 210 |
+
self._load()
|
| 211 |
+
img_path = str(img_path)
|
| 212 |
+
|
| 213 |
+
# Stage 1 — Main SVM (5-class)
|
| 214 |
+
img = Image.open(img_path).convert("RGB").resize((128,128), Image.LANCZOS)
|
| 215 |
+
arr = np.expand_dims(np.array(img).astype("float32")/255.0, axis=0)
|
| 216 |
+
|
| 217 |
+
vgg = self.feature_extractor.predict(arr, verbose=0)
|
| 218 |
+
scaled = self.scaler.transform(self.pca.transform(vgg))
|
| 219 |
+
main_pred = self.main_svm.predict(scaled)[0]
|
| 220 |
+
main_class = self.class_names[main_pred]
|
| 221 |
+
main_conf = float(np.max(self.main_svm.decision_function(scaled)[0]))
|
| 222 |
+
|
| 223 |
+
if verbose:
|
| 224 |
+
print(f"Stage 1 — Main SVM: {main_class} (score: {main_conf:.4f})")
|
| 225 |
+
|
| 226 |
+
result = {
|
| 227 |
+
"prediction": main_class, "confidence": main_conf,
|
| 228 |
+
"routed_to_specialist": False, "specialist_accepted": False,
|
| 229 |
+
"main_prediction": main_class,
|
| 230 |
+
**self.SPECIES_INFO.get(main_class, {"gram":"Unknown","shape":"Unknown","risk":"Unknown"})
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
# Stage 2 — Specialist (ambiguous pair only)
|
| 234 |
+
if main_class in self.AMBIGUOUS:
|
| 235 |
+
if verbose: print("Routing to specialist...")
|
| 236 |
+
|
| 237 |
+
rich = self._rich_features(img_path).reshape(1,-1)
|
| 238 |
+
combined = np.concatenate([vgg, rich], axis=1)
|
| 239 |
+
pca_spec = self.spec_pca.transform(self.spec_scaler.transform(combined))
|
| 240 |
+
spec_pred = self.spec_svm.predict(pca_spec)[0]
|
| 241 |
+
spec_proba = self.spec_svm.predict_proba(pca_spec)[0]
|
| 242 |
+
spec_conf = spec_proba.max()
|
| 243 |
+
spec_class = "Escherichia coli" if spec_pred == 0 else "Pseudomonas aeruginosa"
|
| 244 |
+
|
| 245 |
+
accepted = spec_conf >= self.threshold
|
| 246 |
+
final = spec_class if accepted else main_class
|
| 247 |
+
|
| 248 |
+
if verbose:
|
| 249 |
+
tag = "✅ accepted" if accepted else f"⚠️ below {self.threshold}, fallback to main"
|
| 250 |
+
print(f"Stage 2 — Specialist: {spec_class} (conf: {spec_conf:.4f}) {tag}")
|
| 251 |
+
print(f"Final: {final}")
|
| 252 |
+
|
| 253 |
+
result.update({
|
| 254 |
+
"prediction": final, "confidence": spec_conf,
|
| 255 |
+
"routed_to_specialist": True, "specialist_accepted": accepted,
|
| 256 |
+
**self.SPECIES_INFO.get(final, {"gram":"Unknown","shape":"Unknown","risk":"Unknown"})
|
| 257 |
+
})
|
| 258 |
+
|
| 259 |
+
return result
|
| 260 |
+
|
| 261 |
+
def predict_batch(self, img_paths: list, verbose: bool = False) -> list:
|
| 262 |
+
"""Classify a list of image paths. Returns list of result dicts."""
|
| 263 |
+
return [self.predict(p, verbose=verbose) for p in img_paths]
|
| 264 |
+
|
| 265 |
+
def warmup(self):
|
| 266 |
+
"""Pre-load all models into memory (call once at app startup)."""
|
| 267 |
+
self._load()
|
| 268 |
+
print("BacSense v2 ready ✅")
|
bacsense_v2_package/pca_model.pkl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:36d2993f7e2095ecfe3cf449a5213460c7c8ce780965434ee213d3be170c16e9
|
| 3 |
+
size 196517
|
bacsense_v2_package/requirements.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
tensorflow>=2.12.0
|
| 2 |
+
scikit-learn>=1.3.0
|
| 3 |
+
scikit-image>=0.21.0
|
| 4 |
+
opencv-python>=4.8.0
|
| 5 |
+
numpy>=1.24.0
|
| 6 |
+
Pillow>=9.5.0
|
| 7 |
+
scipy>=1.11.0
|
bacsense_v2_package/specialist_metadata.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"timestamp": "2026-03-16 07:11:14",
|
| 3 |
+
"architecture": "VGG16(512) + HSV(48) + Morph(5) + LBP(59) + GLCM(24) + ChannelStats(9) + DensityGrid(16) + HuMoments(7) + Curvature(3) \u2192 PCA(243) \u2192 SVM",
|
| 4 |
+
"feature_dims": {
|
| 5 |
+
"vgg16": 512,
|
| 6 |
+
"hsv_histogram": 48,
|
| 7 |
+
"morphological": 5,
|
| 8 |
+
"lbp": 59,
|
| 9 |
+
"glcm": 24,
|
| 10 |
+
"channel_stats": 9,
|
| 11 |
+
"density_grid": 16,
|
| 12 |
+
"hu_moments": 7,
|
| 13 |
+
"curvature": 3,
|
| 14 |
+
"combined": 683,
|
| 15 |
+
"after_pca": 243
|
| 16 |
+
},
|
| 17 |
+
"best_params": {
|
| 18 |
+
"C": 100,
|
| 19 |
+
"gamma": 0.001,
|
| 20 |
+
"kernel": "rbf"
|
| 21 |
+
},
|
| 22 |
+
"cv_f1": 0.9498,
|
| 23 |
+
"test_accuracy": 0.9565,
|
| 24 |
+
"ecoli_f1": 0.9568,
|
| 25 |
+
"ecoli_recall": 0.9651,
|
| 26 |
+
"pseudomonas_f1": 0.9563,
|
| 27 |
+
"pseudomonas_recall": 0.948,
|
| 28 |
+
"training_data": {
|
| 29 |
+
"augmented_jpg": 1600,
|
| 30 |
+
"original_png": 122,
|
| 31 |
+
"total": 1722
|
| 32 |
+
},
|
| 33 |
+
"improvement_over_v1": {
|
| 34 |
+
"accuracy": "0.9536 \u2192 0.9565",
|
| 35 |
+
"pseudomonas_recall": "0.9306 \u2192 0.9480",
|
| 36 |
+
"cv_f1": "0.9262 \u2192 0.9498"
|
| 37 |
+
}
|
| 38 |
+
}
|
bacsense_v2_package/specialist_pca.pkl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:271f198091fa0baeb308ab96be7daefba2622f82dbb76a9c72e0f0807955f93b
|
| 3 |
+
size 1339847
|
bacsense_v2_package/specialist_scaler.pkl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:2e59b83d5e0fd6cf002571ee70e889741a7d94b0556a5a260aeacd795ab0ae44
|
| 3 |
+
size 16855
|
bacsense_v2_package/specialist_svm.pkl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:8df00d049310dbdead39c7e6e6bf58779f0f14b5fe62627e908d15ab59ba0a57
|
| 3 |
+
size 749525
|
bacsense_v2_package/standard_scaler.pkl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:c133477b15a420b3d00ed5c9ca41d72b3fe5212152227c82f0f43490823d3e0e
|
| 3 |
+
size 2715
|
bacsense_v2_package/svm_classifier.pkl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:55296fc956943a2e5e041256a957cc782eea5f47f1a808dcde71f2076d2fda97
|
| 3 |
+
size 2013194
|
bacsense_v2_package/vgg16_feature_extractor.keras
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:48d0af85e0b48c38950b874e49ed61b46ce35085976c84fb8c595d30534bdefe
|
| 3 |
+
size 58933107
|
frontend/.gitignore
CHANGED
|
@@ -22,3 +22,4 @@ dist-ssr
|
|
| 22 |
*.njsproj
|
| 23 |
*.sln
|
| 24 |
*.sw?
|
|
|
|
|
|
| 22 |
*.njsproj
|
| 23 |
*.sln
|
| 24 |
*.sw?
|
| 25 |
+
.vercel
|
frontend/run_frontend.ps1
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
npm install
|
| 2 |
+
npm run dev
|
frontend/src/App.tsx
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
|
|
| 1 |
import { Header } from "./components/Header"
|
| 2 |
import { Hero } from "./components/Hero"
|
| 3 |
import { Species } from "./components/Species"
|
| 4 |
import { UploadZone } from "./components/UploadZone"
|
|
|
|
| 5 |
import { Results } from "./components/Results"
|
| 6 |
import { Footer } from "./components/Footer"
|
| 7 |
import TargetCursor from "./components/TargetCursor"
|
| 8 |
|
| 9 |
function App() {
|
|
|
|
|
|
|
| 10 |
return (
|
| 11 |
<>
|
| 12 |
<TargetCursor
|
|
@@ -19,8 +23,8 @@ function App() {
|
|
| 19 |
<main className="pt-32 pb-20">
|
| 20 |
<Hero />
|
| 21 |
<Species />
|
| 22 |
-
<UploadZone />
|
| 23 |
-
<Results />
|
| 24 |
</main>
|
| 25 |
<Footer />
|
| 26 |
</>
|
|
|
|
| 1 |
+
import { useState } from "react"
|
| 2 |
import { Header } from "./components/Header"
|
| 3 |
import { Hero } from "./components/Hero"
|
| 4 |
import { Species } from "./components/Species"
|
| 5 |
import { UploadZone } from "./components/UploadZone"
|
| 6 |
+
import type { PredictionResult } from "./components/UploadZone"
|
| 7 |
import { Results } from "./components/Results"
|
| 8 |
import { Footer } from "./components/Footer"
|
| 9 |
import TargetCursor from "./components/TargetCursor"
|
| 10 |
|
| 11 |
function App() {
|
| 12 |
+
const [results, setResults] = useState<PredictionResult[] | null>(null);
|
| 13 |
+
|
| 14 |
return (
|
| 15 |
<>
|
| 16 |
<TargetCursor
|
|
|
|
| 23 |
<main className="pt-32 pb-20">
|
| 24 |
<Hero />
|
| 25 |
<Species />
|
| 26 |
+
<UploadZone onResultsGenerated={setResults} />
|
| 27 |
+
{results && results.length > 0 && <Results items={results} />}
|
| 28 |
</main>
|
| 29 |
<Footer />
|
| 30 |
</>
|
frontend/src/components/Results.tsx
CHANGED
|
@@ -1,4 +1,10 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
return (
|
| 3 |
<section className="max-w-7xl mx-auto px-6 mb-24">
|
| 4 |
<h3 className="text-3xl font-display font-bold mb-8">Classification Results</h3>
|
|
@@ -15,45 +21,34 @@ export const Results = () => {
|
|
| 15 |
</tr>
|
| 16 |
</thead>
|
| 17 |
<tbody className="divide-y divide-slate-200 dark:divide-slate-800 text-slate-900 dark:text-slate-100">
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
<
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
<tr className="hover:bg-primary/5 transition-colors">
|
| 43 |
-
<td className="px-6 py-4 text-sm font-medium">sample_z901.png</td>
|
| 44 |
-
<td className="px-6 py-4 text-sm italic">B. cereus</td>
|
| 45 |
-
<td className="px-6 py-4">
|
| 46 |
-
<span className="bg-yellow-500/10 text-yellow-500 text-[11px] font-bold px-2 py-0.5 rounded">84.1%</span>
|
| 47 |
-
</td>
|
| 48 |
-
<td className="px-6 py-4 text-sm">Positive</td>
|
| 49 |
-
<td className="px-6 py-4 text-sm text-slate-500 dark:text-slate-400">Spore-rod</td>
|
| 50 |
-
<td className="px-6 py-4">
|
| 51 |
-
<span className="bg-blue-500/10 text-blue-500 text-[11px] font-bold px-2 py-0.5 rounded uppercase">Low Risk</span>
|
| 52 |
-
</td>
|
| 53 |
-
</tr>
|
| 54 |
</tbody>
|
| 55 |
</table>
|
| 56 |
</div>
|
| 57 |
</section>
|
| 58 |
);
|
| 59 |
};
|
|
|
|
|
|
| 1 |
+
import type { PredictionResult } from "./UploadZone";
|
| 2 |
+
|
| 3 |
+
interface ResultsProps {
|
| 4 |
+
items: PredictionResult[];
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
export const Results = ({ items }: ResultsProps) => {
|
| 8 |
return (
|
| 9 |
<section className="max-w-7xl mx-auto px-6 mb-24">
|
| 10 |
<h3 className="text-3xl font-display font-bold mb-8">Classification Results</h3>
|
|
|
|
| 21 |
</tr>
|
| 22 |
</thead>
|
| 23 |
<tbody className="divide-y divide-slate-200 dark:divide-slate-800 text-slate-900 dark:text-slate-100">
|
| 24 |
+
{items.map((item, index) => {
|
| 25 |
+
if (!item.success) return null;
|
| 26 |
+
const risk = item.details?.pathogenicity || 'Unknown';
|
| 27 |
+
const confColor = (item.confidence || 0) >= 80 ? 'bg-green-500/10 text-green-500' : 'bg-yellow-500/10 text-yellow-500';
|
| 28 |
+
|
| 29 |
+
let riskColor = 'bg-blue-500/10 text-blue-500'; // low/unknown
|
| 30 |
+
if (risk.toLowerCase().includes('high')) riskColor = 'bg-red-500/10 text-red-500';
|
| 31 |
+
else if (risk.toLowerCase().includes('moderate')) riskColor = 'bg-orange-500/10 text-orange-500';
|
| 32 |
+
|
| 33 |
+
return (
|
| 34 |
+
<tr key={index} className="hover:bg-primary/5 transition-colors">
|
| 35 |
+
<td className="px-6 py-4 text-sm font-medium">{item.filename}</td>
|
| 36 |
+
<td className="px-6 py-4 text-sm italic">{item.prediction}</td>
|
| 37 |
+
<td className="px-6 py-4">
|
| 38 |
+
<span className={`${confColor} text-[11px] font-bold px-2 py-0.5 rounded`}>{(item.confidence || 0).toFixed(1)}%</span>
|
| 39 |
+
</td>
|
| 40 |
+
<td className="px-6 py-4 text-sm">{item.details?.gram_stain}</td>
|
| 41 |
+
<td className="px-6 py-4 text-sm text-slate-500 dark:text-slate-400">{item.details?.shape}</td>
|
| 42 |
+
<td className="px-6 py-4">
|
| 43 |
+
<span className={`${riskColor} text-[11px] font-bold px-2 py-0.5 rounded uppercase`}>{risk}</span>
|
| 44 |
+
</td>
|
| 45 |
+
</tr>
|
| 46 |
+
);
|
| 47 |
+
})}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
</tbody>
|
| 49 |
</table>
|
| 50 |
</div>
|
| 51 |
</section>
|
| 52 |
);
|
| 53 |
};
|
| 54 |
+
|
frontend/src/components/UploadZone.tsx
CHANGED
|
@@ -1,51 +1,88 @@
|
|
| 1 |
import { useState, useRef } from "react";
|
| 2 |
|
| 3 |
-
interface PredictionResult {
|
|
|
|
| 4 |
success: boolean;
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
|
|
|
| 9 |
gram_stain: string;
|
| 10 |
shape: string;
|
| 11 |
pathogenicity: string;
|
| 12 |
};
|
|
|
|
| 13 |
}
|
| 14 |
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
const [isUploading, setIsUploading] = useState(false);
|
| 19 |
const [progress, setProgress] = useState(0);
|
| 20 |
-
const [
|
|
|
|
| 21 |
const [error, setError] = useState<string | null>(null);
|
| 22 |
const fileInputRef = useRef<HTMLInputElement>(null);
|
| 23 |
|
| 24 |
-
const
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
setError(null);
|
| 29 |
setIsUploading(true);
|
| 30 |
setProgress(20);
|
| 31 |
|
| 32 |
const formData = new FormData();
|
| 33 |
-
|
|
|
|
|
|
|
| 34 |
|
| 35 |
try {
|
| 36 |
setProgress(60);
|
| 37 |
-
const response = await fetch("http://localhost:5000/
|
| 38 |
method: "POST",
|
| 39 |
body: formData,
|
| 40 |
});
|
| 41 |
|
| 42 |
if (!response.ok) {
|
| 43 |
-
throw new Error("Failed to analyze
|
| 44 |
}
|
| 45 |
|
| 46 |
setProgress(90);
|
| 47 |
const data = await response.json();
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
setProgress(100);
|
| 50 |
} catch (err: any) {
|
| 51 |
setError(err.message || "An error occurred");
|
|
@@ -56,26 +93,28 @@ export const UploadZone = () => {
|
|
| 56 |
|
| 57 |
const onFileDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
| 58 |
e.preventDefault();
|
| 59 |
-
|
| 60 |
-
|
|
|
|
| 61 |
};
|
| 62 |
|
| 63 |
const onFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 64 |
-
|
| 65 |
-
|
|
|
|
| 66 |
};
|
| 67 |
|
| 68 |
return (
|
| 69 |
<section className="max-w-7xl mx-auto px-6 mb-24 relative z-10" id="upload">
|
| 70 |
-
<div className={`grid ${
|
| 71 |
-
<div className="space-y-6">
|
| 72 |
<h3 className="text-3xl font-display font-bold">Upload Zone</h3>
|
| 73 |
|
| 74 |
<div
|
| 75 |
onDragOver={(e) => e.preventDefault()}
|
| 76 |
onDrop={onFileDrop}
|
| 77 |
onClick={() => fileInputRef.current?.click()}
|
| 78 |
-
className="cursor-target border-2 border-dashed border-primary/30 rounded-3xl p-12 flex flex-col items-center justify-center text-center bg-background-light/50 dark:bg-background-dark/50 backdrop-blur-md hover:bg-primary/10 transition-colors group cursor-pointer"
|
| 79 |
>
|
| 80 |
<input
|
| 81 |
type="file"
|
|
@@ -83,20 +122,21 @@ export const UploadZone = () => {
|
|
| 83 |
onChange={onFileChange}
|
| 84 |
className="hidden"
|
| 85 |
accept="image/*"
|
|
|
|
| 86 |
/>
|
| 87 |
-
<div className="w-
|
| 88 |
-
<span className="material-symbols-outlined text-primary text-
|
| 89 |
</div>
|
| 90 |
-
<h4 className="text-
|
| 91 |
-
<p className="text-slate-500 dark:text-slate-400 mb-
|
| 92 |
-
<button className="cursor-target bg-primary text-white px-
|
| 93 |
</div>
|
| 94 |
|
| 95 |
{/* Upload Progress */}
|
| 96 |
{isUploading && (
|
| 97 |
<div className="bg-slate-100 dark:bg-slate-800/80 p-6 rounded-2xl border border-slate-200 dark:border-slate-700 backdrop-blur-md">
|
| 98 |
<div className="flex items-center justify-between mb-2">
|
| 99 |
-
<span className="text-sm font-semibold text-slate-900 dark:text-slate-100">Processing {
|
| 100 |
<span className="text-sm font-bold text-primary">{progress}%</span>
|
| 101 |
</div>
|
| 102 |
<div className="w-full h-2 bg-slate-200 dark:bg-slate-700 rounded-full overflow-hidden">
|
|
@@ -104,7 +144,7 @@ export const UploadZone = () => {
|
|
| 104 |
</div>
|
| 105 |
<p className="text-xs text-slate-500 mt-3 flex items-center gap-1">
|
| 106 |
<span className="material-symbols-outlined text-[14px] animate-spin">sync</span>
|
| 107 |
-
Analyzing morphological features
|
| 108 |
</p>
|
| 109 |
</div>
|
| 110 |
)}
|
|
@@ -117,51 +157,144 @@ export const UploadZone = () => {
|
|
| 117 |
)}
|
| 118 |
</div>
|
| 119 |
|
| 120 |
-
{
|
| 121 |
-
<div className="space-y-6">
|
| 122 |
-
<
|
| 123 |
-
|
| 124 |
-
<
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
</div>
|
| 135 |
-
|
| 136 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
</div>
|
|
|
|
| 138 |
<div className="grid grid-cols-2 gap-4">
|
| 139 |
-
<div className="
|
| 140 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
</div>
|
| 142 |
</div>
|
| 143 |
</div>
|
| 144 |
|
| 145 |
-
<div className="
|
| 146 |
-
<h5 className="text-xs font-bold uppercase tracking-widest text-slate-500">Probability Distribution</h5>
|
| 147 |
<div className="space-y-3">
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
</div>
|
| 158 |
-
|
| 159 |
</div>
|
| 160 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
</div>
|
| 162 |
</div>
|
| 163 |
-
|
| 164 |
-
|
| 165 |
</section>
|
| 166 |
);
|
| 167 |
};
|
|
|
|
|
|
| 1 |
import { useState, useRef } from "react";
|
| 2 |
|
| 3 |
+
export interface PredictionResult {
|
| 4 |
+
filename?: string;
|
| 5 |
success: boolean;
|
| 6 |
+
error?: string;
|
| 7 |
+
prediction?: string;
|
| 8 |
+
confidence?: number;
|
| 9 |
+
probabilities?: { name: string; probability: number }[];
|
| 10 |
+
details?: {
|
| 11 |
gram_stain: string;
|
| 12 |
shape: string;
|
| 13 |
pathogenicity: string;
|
| 14 |
};
|
| 15 |
+
previewUrl?: string;
|
| 16 |
}
|
| 17 |
|
| 18 |
+
const PRECAUTIONS: Record<string, string> = {
|
| 19 |
+
"Escherichia coli": "Indicator of fecal contamination. \n\nPrecautions/Actions: Boil water immediately before consumption. Source trace to find sewage leaks. Do not use for washing open wounds.",
|
| 20 |
+
"Pseudomonas aeruginosa": "Opportunistic pathogen resistant to many sanitizers. \n\nPrecautions/Actions: Ensure water chlorination levels are adequate. Can cause severe infections in immunocompromised individuals. Avoid contact with eyes or ears.",
|
| 21 |
+
"Enterococcus faecalis": "Indicates prolonged fecal contamination. Very resilient. \n\nPrecautions/Actions: Shock chlorinate the water system. Discontinue use for drinking until negative tests are returned.",
|
| 22 |
+
"Clostridium perfringens": "Spore-forming bacteria, highly resistant to standard disinfection. \n\nPrecautions/Actions: Indicates remote or past fecal contamination. UV filtration or extreme heat treatment may be required.",
|
| 23 |
+
"Listeria monocytogenes": "Dangerous to pregnant women and immunocompromised individuals. \n\nPrecautions/Actions: Do not use water for food preparation or drinking. Pasteurization/boiling is required."
|
| 24 |
+
};
|
| 25 |
+
|
| 26 |
+
interface UploadZoneProps {
|
| 27 |
+
onResultsGenerated?: (results: PredictionResult[] | null) => void;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
export const UploadZone = ({ onResultsGenerated }: UploadZoneProps) => {
|
| 31 |
+
const [files, setFiles] = useState<File[]>([]);
|
| 32 |
const [isUploading, setIsUploading] = useState(false);
|
| 33 |
const [progress, setProgress] = useState(0);
|
| 34 |
+
const [localResults, setLocalResults] = useState<PredictionResult[] | null>(null);
|
| 35 |
+
const [selectedResult, setSelectedResult] = useState<PredictionResult | null>(null);
|
| 36 |
const [error, setError] = useState<string | null>(null);
|
| 37 |
const fileInputRef = useRef<HTMLInputElement>(null);
|
| 38 |
|
| 39 |
+
const handleFiles = async (selectedFiles: FileList | File[]) => {
|
| 40 |
+
const fileArray = Array.from(selectedFiles);
|
| 41 |
+
if (fileArray.length === 0) return;
|
| 42 |
+
|
| 43 |
+
setFiles(fileArray);
|
| 44 |
+
|
| 45 |
+
// Generate preview URLs
|
| 46 |
+
const previewUrls = fileArray.map(f => URL.createObjectURL(f));
|
| 47 |
+
|
| 48 |
+
setLocalResults(null);
|
| 49 |
+
if (onResultsGenerated) onResultsGenerated(null);
|
| 50 |
+
setSelectedResult(null);
|
| 51 |
setError(null);
|
| 52 |
setIsUploading(true);
|
| 53 |
setProgress(20);
|
| 54 |
|
| 55 |
const formData = new FormData();
|
| 56 |
+
fileArray.forEach(f => {
|
| 57 |
+
formData.append("files", f);
|
| 58 |
+
});
|
| 59 |
|
| 60 |
try {
|
| 61 |
setProgress(60);
|
| 62 |
+
const response = await fetch("http://localhost:5000/predict_batch", {
|
| 63 |
method: "POST",
|
| 64 |
body: formData,
|
| 65 |
});
|
| 66 |
|
| 67 |
if (!response.ok) {
|
| 68 |
+
throw new Error("Failed to analyze images");
|
| 69 |
}
|
| 70 |
|
| 71 |
setProgress(90);
|
| 72 |
const data = await response.json();
|
| 73 |
+
|
| 74 |
+
// Attach preview URLs to results for display
|
| 75 |
+
if (data.results && Array.isArray(data.results)) {
|
| 76 |
+
const resultsWithPreviews = data.results.map((r: any) => {
|
| 77 |
+
const matchIndex = fileArray.findIndex(f => f.name === r.filename);
|
| 78 |
+
return {
|
| 79 |
+
...r,
|
| 80 |
+
previewUrl: matchIndex >= 0 ? previewUrls[matchIndex] : null
|
| 81 |
+
};
|
| 82 |
+
});
|
| 83 |
+
setLocalResults(resultsWithPreviews);
|
| 84 |
+
if (onResultsGenerated) onResultsGenerated(resultsWithPreviews);
|
| 85 |
+
}
|
| 86 |
setProgress(100);
|
| 87 |
} catch (err: any) {
|
| 88 |
setError(err.message || "An error occurred");
|
|
|
|
| 93 |
|
| 94 |
const onFileDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
| 95 |
e.preventDefault();
|
| 96 |
+
if (e.dataTransfer.files.length > 0) {
|
| 97 |
+
handleFiles(e.dataTransfer.files);
|
| 98 |
+
}
|
| 99 |
};
|
| 100 |
|
| 101 |
const onFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 102 |
+
if (e.target.files && e.target.files.length > 0) {
|
| 103 |
+
handleFiles(e.target.files);
|
| 104 |
+
}
|
| 105 |
};
|
| 106 |
|
| 107 |
return (
|
| 108 |
<section className="max-w-7xl mx-auto px-6 mb-24 relative z-10" id="upload">
|
| 109 |
+
<div className={`grid ${localResults ? 'grid-cols-1 lg:grid-cols-3' : 'grid-cols-1 max-w-2xl mx-auto'} gap-8`}>
|
| 110 |
+
<div className="space-y-6 lg:col-span-1">
|
| 111 |
<h3 className="text-3xl font-display font-bold">Upload Zone</h3>
|
| 112 |
|
| 113 |
<div
|
| 114 |
onDragOver={(e) => e.preventDefault()}
|
| 115 |
onDrop={onFileDrop}
|
| 116 |
onClick={() => fileInputRef.current?.click()}
|
| 117 |
+
className="cursor-target border-2 border-dashed border-primary/30 rounded-3xl p-12 flex flex-col items-center justify-center text-center bg-background-light/50 dark:bg-background-dark/50 backdrop-blur-md hover:bg-primary/10 transition-colors group cursor-pointer h-64"
|
| 118 |
>
|
| 119 |
<input
|
| 120 |
type="file"
|
|
|
|
| 122 |
onChange={onFileChange}
|
| 123 |
className="hidden"
|
| 124 |
accept="image/*"
|
| 125 |
+
multiple
|
| 126 |
/>
|
| 127 |
+
<div className="w-16 h-16 rounded-full bg-primary/20 flex items-center justify-center mb-4 group-hover:scale-110 transition-transform">
|
| 128 |
+
<span className="material-symbols-outlined text-primary text-3xl">cloud_upload</span>
|
| 129 |
</div>
|
| 130 |
+
<h4 className="text-lg font-bold mb-2 text-slate-900 dark:text-white">Drag & drop images</h4>
|
| 131 |
+
<p className="text-sm text-slate-500 dark:text-slate-400 mb-4">Supports JPG, PNG up to 20MB.</p>
|
| 132 |
+
<button className="cursor-target bg-primary text-white px-5 py-2 rounded-lg font-bold hover:scale-105 transition-transform text-sm">Browse</button>
|
| 133 |
</div>
|
| 134 |
|
| 135 |
{/* Upload Progress */}
|
| 136 |
{isUploading && (
|
| 137 |
<div className="bg-slate-100 dark:bg-slate-800/80 p-6 rounded-2xl border border-slate-200 dark:border-slate-700 backdrop-blur-md">
|
| 138 |
<div className="flex items-center justify-between mb-2">
|
| 139 |
+
<span className="text-sm font-semibold text-slate-900 dark:text-slate-100">Processing {files.length} images</span>
|
| 140 |
<span className="text-sm font-bold text-primary">{progress}%</span>
|
| 141 |
</div>
|
| 142 |
<div className="w-full h-2 bg-slate-200 dark:bg-slate-700 rounded-full overflow-hidden">
|
|
|
|
| 144 |
</div>
|
| 145 |
<p className="text-xs text-slate-500 mt-3 flex items-center gap-1">
|
| 146 |
<span className="material-symbols-outlined text-[14px] animate-spin">sync</span>
|
| 147 |
+
Analyzing morphological features...
|
| 148 |
</p>
|
| 149 |
</div>
|
| 150 |
)}
|
|
|
|
| 157 |
)}
|
| 158 |
</div>
|
| 159 |
|
| 160 |
+
{localResults && (
|
| 161 |
+
<div className="space-y-6 lg:col-span-2">
|
| 162 |
+
<div className="flex items-center justify-between">
|
| 163 |
+
<h3 className="text-3xl font-display font-bold">Analysis Results</h3>
|
| 164 |
+
<span className="bg-primary/10 text-primary px-3 py-1 rounded-full text-sm font-bold">{localResults.length} processed</span>
|
| 165 |
+
</div>
|
| 166 |
+
|
| 167 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
| 168 |
+
{localResults.map((res, index) => (
|
| 169 |
+
<div
|
| 170 |
+
key={index}
|
| 171 |
+
onClick={() => res.success && setSelectedResult(res)}
|
| 172 |
+
className={`bg-white/80 dark:bg-slate-800/80 backdrop-blur-md p-6 rounded-3xl border border-slate-200 dark:border-slate-700 shadow-xl flex flex-col ${res.success ? 'cursor-pointer hover:scale-[1.02] hover:border-primary/50 transition-all' : ''}`}
|
| 173 |
+
title={res.success ? "Click for detailed summary" : ""}
|
| 174 |
+
>
|
| 175 |
+
<div className="flex gap-4 mb-4">
|
| 176 |
+
<div className="w-24 h-24 rounded-2xl overflow-hidden border border-primary/20 flex-shrink-0 bg-black">
|
| 177 |
+
{res.previewUrl ? (
|
| 178 |
+
<img className="w-full h-full object-cover" alt={`Upload ${index}`} src={res.previewUrl} />
|
| 179 |
+
) : (
|
| 180 |
+
<div className="w-full h-full flex items-center justify-center bg-slate-800"><span className="material-symbols-outlined text-slate-500">image</span></div>
|
| 181 |
+
)}
|
| 182 |
+
</div>
|
| 183 |
+
<div className="flex-1 min-w-0">
|
| 184 |
+
<p className="text-xs text-slate-500 dark:text-slate-400 truncate mb-1" title={res.filename}>{res.filename}</p>
|
| 185 |
+
|
| 186 |
+
{res.success && res.prediction ? (
|
| 187 |
+
<>
|
| 188 |
+
<h4 className="text-lg font-bold italic text-slate-900 dark:text-slate-100 leading-tight mb-2 truncate" title={res.prediction}>{res.prediction}</h4>
|
| 189 |
+
<div className={`inline-flex items-center gap-1 text-xs font-bold px-2 py-1 rounded-full whitespace-nowrap ${(res.confidence || 0) > 80 ? 'bg-green-500/10 text-green-500' : 'bg-yellow-500/10 text-yellow-500'}`}>
|
| 190 |
+
<span className="material-symbols-outlined text-[14px]">{(res.confidence || 0) > 80 ? 'verified' : 'warning'}</span>
|
| 191 |
+
{(res.confidence || 0).toFixed(1)}% Conf
|
| 192 |
+
</div>
|
| 193 |
+
</>
|
| 194 |
+
) : (
|
| 195 |
+
<div className="text-red-500 text-sm font-bold flex items-center gap-1">
|
| 196 |
+
<span className="material-symbols-outlined text-sm">error</span> Failed
|
| 197 |
+
</div>
|
| 198 |
+
)}
|
| 199 |
+
</div>
|
| 200 |
</div>
|
| 201 |
+
|
| 202 |
+
{res.success && res.details && (
|
| 203 |
+
<div className="flex-1 flex flex-col justify-end">
|
| 204 |
+
<div className="grid grid-cols-2 gap-2 p-3 bg-slate-50 dark:bg-slate-900/50 rounded-xl">
|
| 205 |
+
<div className="text-xs text-slate-900 dark:text-slate-100">
|
| 206 |
+
<span className="text-slate-500 block mb-0.5 mt-0">Morphology</span>
|
| 207 |
+
<span className="font-semibold">{res.details.shape}</span>
|
| 208 |
+
</div>
|
| 209 |
+
<div className="text-xs text-slate-900 dark:text-slate-100">
|
| 210 |
+
<span className="text-slate-500 block mb-0.5 mt-0">Stain</span>
|
| 211 |
+
<span className="font-semibold">{res.details.gram_stain}</span>
|
| 212 |
+
</div>
|
| 213 |
+
</div>
|
| 214 |
+
</div>
|
| 215 |
+
)}
|
| 216 |
+
</div>
|
| 217 |
+
))}
|
| 218 |
+
</div>
|
| 219 |
+
</div>
|
| 220 |
+
)}
|
| 221 |
+
</div>
|
| 222 |
+
|
| 223 |
+
{/* Modal Dialog Box */}
|
| 224 |
+
{selectedResult && (
|
| 225 |
+
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm" onClick={() => setSelectedResult(null)}>
|
| 226 |
+
<div
|
| 227 |
+
className="bg-white dark:bg-slate-900 rounded-3xl max-w-2xl w-full max-h-[90vh] overflow-y-auto shadow-2xl border border-slate-200 dark:border-slate-700"
|
| 228 |
+
onClick={e => e.stopPropagation()}
|
| 229 |
+
>
|
| 230 |
+
<div className="p-6 md:p-8 flex flex-col gap-6">
|
| 231 |
+
<div className="flex items-start justify-between">
|
| 232 |
+
<h3 className="text-2xl font-bold font-display text-slate-900 dark:text-white">Detailed Classification Summary</h3>
|
| 233 |
+
<button onClick={() => setSelectedResult(null)} className="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors">
|
| 234 |
+
<span className="material-symbols-outlined">close</span>
|
| 235 |
+
</button>
|
| 236 |
+
</div>
|
| 237 |
+
|
| 238 |
+
<div className="flex flex-col md:flex-row gap-6">
|
| 239 |
+
<div className="w-full md:w-48 h-48 rounded-2xl overflow-hidden bg-black flex-shrink-0 border-4 border-slate-100 dark:border-slate-800">
|
| 240 |
+
{selectedResult.previewUrl ? (
|
| 241 |
+
<img src={selectedResult.previewUrl} alt="Analyzed bacteria" className="w-full h-full object-cover" />
|
| 242 |
+
) : (
|
| 243 |
+
<div className="w-full h-full flex items-center justify-center text-slate-500"><span className="material-symbols-outlined text-4xl">image</span></div>
|
| 244 |
+
)}
|
| 245 |
+
</div>
|
| 246 |
+
<div className="flex-1 space-y-4">
|
| 247 |
+
<div>
|
| 248 |
+
<span className="text-sm font-bold text-slate-500 uppercase tracking-wider">Predicted Species</span>
|
| 249 |
+
<h4 className="text-3xl font-bold italic text-primary mt-1">{selectedResult.prediction}</h4>
|
| 250 |
</div>
|
| 251 |
+
|
| 252 |
<div className="grid grid-cols-2 gap-4">
|
| 253 |
+
<div className="bg-slate-50 dark:bg-slate-800/50 p-3 rounded-xl">
|
| 254 |
+
<span className="text-xs text-slate-500 block mb-1">Confidence</span>
|
| 255 |
+
<span className="text-lg font-bold text-slate-900 dark:text-white">{(selectedResult.confidence || 0).toFixed(1)}%</span>
|
| 256 |
+
</div>
|
| 257 |
+
<div className="bg-slate-50 dark:bg-slate-800/50 p-3 rounded-xl">
|
| 258 |
+
<span className="text-xs text-slate-500 block mb-1">Risk Level</span>
|
| 259 |
+
<span className="text-lg font-bold text-slate-900 dark:text-white">{selectedResult.details?.pathogenicity || 'Unknown'}</span>
|
| 260 |
+
</div>
|
| 261 |
</div>
|
| 262 |
</div>
|
| 263 |
</div>
|
| 264 |
|
| 265 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mt-2">
|
|
|
|
| 266 |
<div className="space-y-3">
|
| 267 |
+
<h5 className="font-bold text-slate-900 dark:text-white border-b border-slate-200 dark:border-slate-700 pb-2">Microbiology Specifics</h5>
|
| 268 |
+
<ul className="space-y-2 text-sm text-slate-600 dark:text-slate-300">
|
| 269 |
+
<li className="flex justify-between"><span>Gram Stain:</span> <strong className="text-slate-900 dark:text-white">{selectedResult.details?.gram_stain}</strong></li>
|
| 270 |
+
<li className="flex justify-between"><span>Morphology:</span> <strong className="text-slate-900 dark:text-white">{selectedResult.details?.shape}</strong></li>
|
| 271 |
+
<li className="flex justify-between"><span>Pathogenicity:</span> <strong className="text-slate-900 dark:text-white">{selectedResult.details?.pathogenicity}</strong></li>
|
| 272 |
+
</ul>
|
| 273 |
+
</div>
|
| 274 |
+
|
| 275 |
+
<div className="space-y-3">
|
| 276 |
+
<h5 className="font-bold text-slate-900 dark:text-white border-b border-slate-200 dark:border-slate-700 pb-2">Precautions & Actions</h5>
|
| 277 |
+
<div className="bg-yellow-50 dark:bg-yellow-500/10 border border-yellow-200 dark:border-yellow-500/20 rounded-xl p-4 text-sm text-yellow-800 dark:text-yellow-200 shadow-inner">
|
| 278 |
+
<div className="flex gap-2 items-start whitespace-pre-line">
|
| 279 |
+
<span className="material-symbols-outlined text-[18px] mt-0.5">warning</span>
|
| 280 |
+
<p className="leading-relaxed m-0">
|
| 281 |
+
{selectedResult.prediction ? (PRECAUTIONS[selectedResult.prediction] || "Standard water safety protocols apply. Heat above 70°C before any consumption.") : "Unknown"}
|
| 282 |
+
</p>
|
| 283 |
</div>
|
| 284 |
+
</div>
|
| 285 |
</div>
|
| 286 |
</div>
|
| 287 |
+
|
| 288 |
+
<div className="mt-4 flex justify-end">
|
| 289 |
+
<button className="px-6 py-2 bg-slate-900 dark:bg-white text-white dark:text-slate-900 font-bold rounded-xl hover:scale-105 transition-transform" onClick={() => setSelectedResult(null)}>
|
| 290 |
+
Close Details
|
| 291 |
+
</button>
|
| 292 |
+
</div>
|
| 293 |
</div>
|
| 294 |
</div>
|
| 295 |
+
</div>
|
| 296 |
+
)}
|
| 297 |
</section>
|
| 298 |
);
|
| 299 |
};
|
| 300 |
+
|
frontend/vercel.json
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"rewrites": [
|
| 3 |
+
{
|
| 4 |
+
"source": "/(.*)",
|
| 5 |
+
"destination": "/index.html"
|
| 6 |
+
}
|
| 7 |
+
]
|
| 8 |
+
}
|