Spaces:
Sleeping
Sleeping
Fadhili Sumaye commited on
Commit ·
23f3fa4
1
Parent(s): 28d7022
Add unrecognized pest reporting system and admin dashboard
Browse files- .idea/markdown.xml +8 -0
- app.py +17 -0
- app/src/main/java/com/example/pestdetection/MainActivity.java +212 -28
- app/src/main/java/com/example/pestdetection/PestApiClient.java +75 -0
- app/src/main/res/layout/activity_main.xml +29 -1
- backend/admin.html +555 -0
- backend/app.py +247 -7
- backend/pest_detection.db +0 -0
.idea/markdown.xml
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?xml version="1.0" encoding="UTF-8"?>
|
| 2 |
+
<project version="4">
|
| 3 |
+
<component name="MarkdownSettings">
|
| 4 |
+
<option name="previewPanelProviderInfo">
|
| 5 |
+
<ProviderInfo name="Compose (experimental)" className="com.intellij.markdown.compose.preview.ComposePanelProvider" />
|
| 6 |
+
</option>
|
| 7 |
+
</component>
|
| 8 |
+
</project>
|
app.py
CHANGED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
# Add backend directory to sys.path
|
| 6 |
+
backend_dir = Path(__file__).resolve().parent / "backend"
|
| 7 |
+
sys.path.insert(0, str(backend_dir))
|
| 8 |
+
|
| 9 |
+
# Change directory to backend so that databases, models and uploads are created in the right folder
|
| 10 |
+
os.chdir(str(backend_dir))
|
| 11 |
+
|
| 12 |
+
if __name__ == "__main__":
|
| 13 |
+
import uvicorn
|
| 14 |
+
# Import the FastAPI instance from app.py in backend
|
| 15 |
+
from app import app
|
| 16 |
+
port = int(os.environ.get("PORT", 5000))
|
| 17 |
+
uvicorn.run(app, host="0.0.0.0", port=port)
|
app/src/main/java/com/example/pestdetection/MainActivity.java
CHANGED
|
@@ -8,7 +8,12 @@ import android.net.NetworkRequest;
|
|
| 8 |
import android.net.Uri;
|
| 9 |
import android.os.Bundle;
|
| 10 |
import android.provider.MediaStore;
|
|
|
|
|
|
|
| 11 |
import android.widget.*;
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
import androidx.activity.result.ActivityResultLauncher;
|
| 14 |
import androidx.activity.result.contract.ActivityResultContracts;
|
|
@@ -37,6 +42,7 @@ public class MainActivity extends AppCompatActivity {
|
|
| 37 |
View loadingOverlay;
|
| 38 |
ProgressBar progressBar;
|
| 39 |
Button btnSelect, btnCamera, btnUpload;
|
|
|
|
| 40 |
View resultCard;
|
| 41 |
View settingsCard;
|
| 42 |
int titleClickCount = 0;
|
|
@@ -86,6 +92,8 @@ public class MainActivity extends AppCompatActivity {
|
|
| 86 |
btnSelect = findViewById(R.id.btnSelect);
|
| 87 |
btnCamera = findViewById(R.id.btnCamera);
|
| 88 |
btnUpload = findViewById(R.id.btnUpload);
|
|
|
|
|
|
|
| 89 |
resultCard = findViewById(R.id.resultCard);
|
| 90 |
settingsCard = findViewById(R.id.settingsCard);
|
| 91 |
|
|
@@ -140,9 +148,40 @@ public class MainActivity extends AppCompatActivity {
|
|
| 140 |
btnSelect.setOnClickListener(v -> openGallery());
|
| 141 |
btnCamera.setOnClickListener(v -> openCamera());
|
| 142 |
btnUpload.setOnClickListener(v -> uploadImage());
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
|
| 144 |
registerNetworkMonitor();
|
| 145 |
checkBackendConnection();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
}
|
| 147 |
|
| 148 |
@Override
|
|
@@ -151,32 +190,6 @@ public class MainActivity extends AppCompatActivity {
|
|
| 151 |
checkBackendConnection();
|
| 152 |
}
|
| 153 |
|
| 154 |
-
@Override
|
| 155 |
-
protected void onStop() {
|
| 156 |
-
super.onStop();
|
| 157 |
-
if (imageView != null) {
|
| 158 |
-
imageView.setImageResource(R.mipmap.ic_launcher);
|
| 159 |
-
}
|
| 160 |
-
if (btnUpload != null) {
|
| 161 |
-
btnUpload.setEnabled(false);
|
| 162 |
-
}
|
| 163 |
-
if (progressBar != null) {
|
| 164 |
-
progressBar.setVisibility(View.GONE);
|
| 165 |
-
}
|
| 166 |
-
if (loadingOverlay != null) {
|
| 167 |
-
loadingOverlay.setVisibility(View.GONE);
|
| 168 |
-
}
|
| 169 |
-
if (resultCard != null) {
|
| 170 |
-
resultCard.setVisibility(View.GONE);
|
| 171 |
-
}
|
| 172 |
-
if (resultText != null) {
|
| 173 |
-
resultText.setText("Waiting for scan...");
|
| 174 |
-
}
|
| 175 |
-
if (treatmentText != null) {
|
| 176 |
-
treatmentText.setText("N/A");
|
| 177 |
-
}
|
| 178 |
-
imageUri = null;
|
| 179 |
-
}
|
| 180 |
|
| 181 |
@Override
|
| 182 |
protected void onDestroy() {
|
|
@@ -343,8 +356,19 @@ public class MainActivity extends AppCompatActivity {
|
|
| 343 |
PestApiClient.getInstance().predict(serverUrl, imageBytes, new PestApiClient.PredictCallback() {
|
| 344 |
@Override
|
| 345 |
public void onSuccess(String pest, double confidence, String treatment) {
|
| 346 |
-
|
| 347 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 348 |
updateConnectionStatus(true, "Connected");
|
| 349 |
|
| 350 |
// Hide loaders and re-enable buttons
|
|
@@ -430,4 +454,164 @@ public class MainActivity extends AppCompatActivity {
|
|
| 430 |
bitmap.recycle();
|
| 431 |
return bytes;
|
| 432 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 433 |
}
|
|
|
|
| 8 |
import android.net.Uri;
|
| 9 |
import android.os.Bundle;
|
| 10 |
import android.provider.MediaStore;
|
| 11 |
+
import android.provider.Settings;
|
| 12 |
+
import android.text.Html;
|
| 13 |
import android.widget.*;
|
| 14 |
+
import androidx.appcompat.app.AlertDialog;
|
| 15 |
+
import org.json.JSONArray;
|
| 16 |
+
import org.json.JSONObject;
|
| 17 |
|
| 18 |
import androidx.activity.result.ActivityResultLauncher;
|
| 19 |
import androidx.activity.result.contract.ActivityResultContracts;
|
|
|
|
| 42 |
View loadingOverlay;
|
| 43 |
ProgressBar progressBar;
|
| 44 |
Button btnSelect, btnCamera, btnUpload;
|
| 45 |
+
Button btnMySubmissions, btnReportUnrecognized;
|
| 46 |
View resultCard;
|
| 47 |
View settingsCard;
|
| 48 |
int titleClickCount = 0;
|
|
|
|
| 92 |
btnSelect = findViewById(R.id.btnSelect);
|
| 93 |
btnCamera = findViewById(R.id.btnCamera);
|
| 94 |
btnUpload = findViewById(R.id.btnUpload);
|
| 95 |
+
btnMySubmissions = findViewById(R.id.btnMySubmissions);
|
| 96 |
+
btnReportUnrecognized = findViewById(R.id.btnReportUnrecognized);
|
| 97 |
resultCard = findViewById(R.id.resultCard);
|
| 98 |
settingsCard = findViewById(R.id.settingsCard);
|
| 99 |
|
|
|
|
| 148 |
btnSelect.setOnClickListener(v -> openGallery());
|
| 149 |
btnCamera.setOnClickListener(v -> openCamera());
|
| 150 |
btnUpload.setOnClickListener(v -> uploadImage());
|
| 151 |
+
if (btnMySubmissions != null) {
|
| 152 |
+
btnMySubmissions.setOnClickListener(v -> showMySubmissions());
|
| 153 |
+
}
|
| 154 |
+
if (btnReportUnrecognized != null) {
|
| 155 |
+
btnReportUnrecognized.setOnClickListener(v -> reportUnrecognizedPest());
|
| 156 |
+
}
|
| 157 |
|
| 158 |
registerNetworkMonitor();
|
| 159 |
checkBackendConnection();
|
| 160 |
+
|
| 161 |
+
if (savedInstanceState != null) {
|
| 162 |
+
String uriStr = savedInstanceState.getString("image_uri");
|
| 163 |
+
if (uriStr != null) {
|
| 164 |
+
imageUri = Uri.parse(uriStr);
|
| 165 |
+
if (imageView != null) {
|
| 166 |
+
imageView.setImageURI(imageUri);
|
| 167 |
+
}
|
| 168 |
+
if (btnUpload != null) {
|
| 169 |
+
btnUpload.setEnabled(true);
|
| 170 |
+
}
|
| 171 |
+
}
|
| 172 |
+
String resText = savedInstanceState.getString("result_text");
|
| 173 |
+
if (resText != null && resultText != null) {
|
| 174 |
+
resultText.setText(resText);
|
| 175 |
+
}
|
| 176 |
+
String treatText = savedInstanceState.getString("treatment_text");
|
| 177 |
+
if (treatText != null && treatmentText != null) {
|
| 178 |
+
treatmentText.setText(treatText);
|
| 179 |
+
}
|
| 180 |
+
int cardVis = savedInstanceState.getInt("result_card_visibility", View.GONE);
|
| 181 |
+
if (resultCard != null) {
|
| 182 |
+
resultCard.setVisibility(cardVis);
|
| 183 |
+
}
|
| 184 |
+
}
|
| 185 |
}
|
| 186 |
|
| 187 |
@Override
|
|
|
|
| 190 |
checkBackendConnection();
|
| 191 |
}
|
| 192 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
|
| 194 |
@Override
|
| 195 |
protected void onDestroy() {
|
|
|
|
| 356 |
PestApiClient.getInstance().predict(serverUrl, imageBytes, new PestApiClient.PredictCallback() {
|
| 357 |
@Override
|
| 358 |
public void onSuccess(String pest, double confidence, String treatment) {
|
| 359 |
+
if ("Invalid Image".equalsIgnoreCase(pest)) {
|
| 360 |
+
resultText.setText("Invalid Image");
|
| 361 |
+
treatmentText.setText("This is not a pest. Please select or capture a crop leaf or pest for detection.");
|
| 362 |
+
if (btnReportUnrecognized != null) btnReportUnrecognized.setVisibility(View.GONE);
|
| 363 |
+
} else if ("None".equalsIgnoreCase(pest)) {
|
| 364 |
+
resultText.setText("No Pests Detected");
|
| 365 |
+
treatmentText.setText(treatment);
|
| 366 |
+
if (btnReportUnrecognized != null) btnReportUnrecognized.setVisibility(View.VISIBLE);
|
| 367 |
+
} else {
|
| 368 |
+
resultText.setText(pest + " (" + (int) (confidence * 100) + "%)");
|
| 369 |
+
treatmentText.setText(treatment);
|
| 370 |
+
if (btnReportUnrecognized != null) btnReportUnrecognized.setVisibility(View.VISIBLE);
|
| 371 |
+
}
|
| 372 |
updateConnectionStatus(true, "Connected");
|
| 373 |
|
| 374 |
// Hide loaders and re-enable buttons
|
|
|
|
| 454 |
bitmap.recycle();
|
| 455 |
return bytes;
|
| 456 |
}
|
| 457 |
+
|
| 458 |
+
@Override
|
| 459 |
+
protected void onSaveInstanceState(android.os.Bundle outState) {
|
| 460 |
+
super.onSaveInstanceState(outState);
|
| 461 |
+
if (imageUri != null) {
|
| 462 |
+
outState.putString("image_uri", imageUri.toString());
|
| 463 |
+
}
|
| 464 |
+
if (resultText != null && !resultText.getText().toString().equals("Waiting for scan...")) {
|
| 465 |
+
outState.putString("result_text", resultText.getText().toString());
|
| 466 |
+
}
|
| 467 |
+
if (treatmentText != null && !treatmentText.getText().toString().equals("N/A")) {
|
| 468 |
+
outState.putString("treatment_text", treatmentText.getText().toString());
|
| 469 |
+
}
|
| 470 |
+
if (resultCard != null) {
|
| 471 |
+
outState.putInt("result_card_visibility", resultCard.getVisibility());
|
| 472 |
+
}
|
| 473 |
+
}
|
| 474 |
+
|
| 475 |
+
private void reportUnrecognizedPest() {
|
| 476 |
+
if (imageUri == null) {
|
| 477 |
+
Toast.makeText(this, "Please capture or select an image first", Toast.LENGTH_SHORT).show();
|
| 478 |
+
return;
|
| 479 |
+
}
|
| 480 |
+
|
| 481 |
+
String serverUrl = etServerUrl.getText().toString().trim();
|
| 482 |
+
String reportUrl;
|
| 483 |
+
if (serverUrl.endsWith("/predict")) {
|
| 484 |
+
reportUrl = serverUrl.replace("/predict", "/predict/report-unrecognized");
|
| 485 |
+
} else {
|
| 486 |
+
reportUrl = serverUrl + "/predict/report-unrecognized";
|
| 487 |
+
}
|
| 488 |
+
|
| 489 |
+
byte[] imageBytes;
|
| 490 |
+
try {
|
| 491 |
+
imageBytes = getScaledAndCompressedImage(imageUri);
|
| 492 |
+
} catch (Exception e) {
|
| 493 |
+
Toast.makeText(this, "Failed to process image: " + e.getMessage(), Toast.LENGTH_SHORT).show();
|
| 494 |
+
return;
|
| 495 |
+
}
|
| 496 |
+
|
| 497 |
+
if (progressBar != null) progressBar.setVisibility(View.VISIBLE);
|
| 498 |
+
if (loadingOverlay != null) loadingOverlay.setVisibility(View.VISIBLE);
|
| 499 |
+
if (btnReportUnrecognized != null) btnReportUnrecognized.setEnabled(false);
|
| 500 |
+
|
| 501 |
+
String deviceId = Settings.Secure.getString(getContentResolver(), Settings.Secure.ANDROID_ID);
|
| 502 |
+
|
| 503 |
+
PestApiClient.getInstance().reportUnrecognized(reportUrl, deviceId, imageBytes, new PestApiClient.PredictCallback() {
|
| 504 |
+
@Override
|
| 505 |
+
public void onSuccess(String pest, double confidence, String message) {
|
| 506 |
+
if (progressBar != null) progressBar.setVisibility(View.GONE);
|
| 507 |
+
if (loadingOverlay != null) loadingOverlay.setVisibility(View.GONE);
|
| 508 |
+
if (btnReportUnrecognized != null) {
|
| 509 |
+
btnReportUnrecognized.setEnabled(true);
|
| 510 |
+
btnReportUnrecognized.setVisibility(View.GONE);
|
| 511 |
+
}
|
| 512 |
+
|
| 513 |
+
new AlertDialog.Builder(MainActivity.this)
|
| 514 |
+
.setTitle("Report Submitted!")
|
| 515 |
+
.setMessage("Thank you! Our agricultural experts will analyze this unrecognized pest and update the diagnostic console.")
|
| 516 |
+
.setPositiveButton("OK", null)
|
| 517 |
+
.show();
|
| 518 |
+
}
|
| 519 |
+
|
| 520 |
+
@Override
|
| 521 |
+
public void onError(String error) {
|
| 522 |
+
if (progressBar != null) progressBar.setVisibility(View.GONE);
|
| 523 |
+
if (loadingOverlay != null) loadingOverlay.setVisibility(View.GONE);
|
| 524 |
+
if (btnReportUnrecognized != null) btnReportUnrecognized.setEnabled(true);
|
| 525 |
+
|
| 526 |
+
Toast.makeText(MainActivity.this, "Submission failed: " + error, Toast.LENGTH_LONG).show();
|
| 527 |
+
}
|
| 528 |
+
});
|
| 529 |
+
}
|
| 530 |
+
|
| 531 |
+
private void showMySubmissions() {
|
| 532 |
+
String serverUrl = etServerUrl.getText().toString().trim();
|
| 533 |
+
String statusUrl;
|
| 534 |
+
if (serverUrl.endsWith("/predict")) {
|
| 535 |
+
statusUrl = serverUrl.replace("/predict", "/reports/status");
|
| 536 |
+
} else {
|
| 537 |
+
statusUrl = serverUrl + "/reports/status";
|
| 538 |
+
}
|
| 539 |
+
|
| 540 |
+
if (progressBar != null) progressBar.setVisibility(View.VISIBLE);
|
| 541 |
+
if (loadingOverlay != null) loadingOverlay.setVisibility(View.VISIBLE);
|
| 542 |
+
|
| 543 |
+
String deviceId = Settings.Secure.getString(getContentResolver(), Settings.Secure.ANDROID_ID);
|
| 544 |
+
|
| 545 |
+
PestApiClient.getInstance().getReportsStatus(statusUrl, deviceId, new PestApiClient.ReportsCallback() {
|
| 546 |
+
@Override
|
| 547 |
+
public void onSuccess(String jsonResult) {
|
| 548 |
+
if (progressBar != null) progressBar.setVisibility(View.GONE);
|
| 549 |
+
if (loadingOverlay != null) loadingOverlay.setVisibility(View.GONE);
|
| 550 |
+
|
| 551 |
+
try {
|
| 552 |
+
JSONObject json = new JSONObject(jsonResult);
|
| 553 |
+
JSONArray reports = json.optJSONArray("reports");
|
| 554 |
+
if (reports == null || reports.length() == 0) {
|
| 555 |
+
new AlertDialog.Builder(MainActivity.this)
|
| 556 |
+
.setTitle("My Submissions")
|
| 557 |
+
.setMessage("You have not submitted any unrecognized pests yet.")
|
| 558 |
+
.setPositiveButton("Close", null)
|
| 559 |
+
.show();
|
| 560 |
+
return;
|
| 561 |
+
}
|
| 562 |
+
|
| 563 |
+
StringBuilder sb = new StringBuilder();
|
| 564 |
+
for (int i = 0; i < reports.length(); i++) {
|
| 565 |
+
JSONObject r = reports.getJSONObject(i);
|
| 566 |
+
String status = r.optString("status", "pending");
|
| 567 |
+
String date = r.optString("created_at", "");
|
| 568 |
+
|
| 569 |
+
sb.append("<font color='#2E7D32'><b>Submission #").append(r.optInt("id")).append("</b></font><br/>");
|
| 570 |
+
sb.append("<b>Date:</b> ").append(date).append("<br/>");
|
| 571 |
+
|
| 572 |
+
if ("resolved".equalsIgnoreCase(status)) {
|
| 573 |
+
sb.append("<b>Status:</b> <font color='#10b981'><b>RESOLVED</b></font><br/>");
|
| 574 |
+
sb.append("<b>Identified Pest:</b> ").append(r.optString("pest_name")).append("<br/>");
|
| 575 |
+
sb.append("<b>Treatment Advice:</b> ").append(r.optString("treatment")).append("<br/>");
|
| 576 |
+
} else {
|
| 577 |
+
sb.append("<b>Status:</b> <font color='#f59e0b'><b>PENDING REVIEW</b></font><br/>");
|
| 578 |
+
sb.append("Our experts are currently identifying this pest. Check back later.<br/>");
|
| 579 |
+
}
|
| 580 |
+
sb.append("<br/>----------------------------------------<br/><br/>");
|
| 581 |
+
}
|
| 582 |
+
|
| 583 |
+
TextView tv = new TextView(MainActivity.this);
|
| 584 |
+
tv.setPadding(40, 40, 40, 40);
|
| 585 |
+
tv.setTextSize(14);
|
| 586 |
+
tv.setTextColor(getResources().getColor(android.R.color.black));
|
| 587 |
+
|
| 588 |
+
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) {
|
| 589 |
+
tv.setText(Html.fromHtml(sb.toString(), Html.FROM_HTML_MODE_LEGACY));
|
| 590 |
+
} else {
|
| 591 |
+
tv.setText(Html.fromHtml(sb.toString()));
|
| 592 |
+
}
|
| 593 |
+
|
| 594 |
+
ScrollView sv = new ScrollView(MainActivity.this);
|
| 595 |
+
sv.addView(tv);
|
| 596 |
+
|
| 597 |
+
new AlertDialog.Builder(MainActivity.this)
|
| 598 |
+
.setTitle("My Submissions")
|
| 599 |
+
.setView(sv)
|
| 600 |
+
.setPositiveButton("Close", null)
|
| 601 |
+
.show();
|
| 602 |
+
|
| 603 |
+
} catch (Exception e) {
|
| 604 |
+
Toast.makeText(MainActivity.this, "Failed to parse history: " + e.getMessage(), Toast.LENGTH_SHORT).show();
|
| 605 |
+
}
|
| 606 |
+
}
|
| 607 |
+
|
| 608 |
+
@Override
|
| 609 |
+
public void onError(String error) {
|
| 610 |
+
if (progressBar != null) progressBar.setVisibility(View.GONE);
|
| 611 |
+
if (loadingOverlay != null) loadingOverlay.setVisibility(View.GONE);
|
| 612 |
+
|
| 613 |
+
Toast.makeText(MainActivity.this, "Failed to fetch history: " + error, Toast.LENGTH_LONG).show();
|
| 614 |
+
}
|
| 615 |
+
});
|
| 616 |
+
}
|
| 617 |
}
|
app/src/main/java/com/example/pestdetection/PestApiClient.java
CHANGED
|
@@ -32,6 +32,11 @@ public final class PestApiClient {
|
|
| 32 |
void onError(String message);
|
| 33 |
}
|
| 34 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
private static final MediaType IMAGE_MEDIA_TYPE = MediaType.parse("image/*");
|
| 36 |
private static final Handler MAIN_HANDLER = new Handler(Looper.getMainLooper());
|
| 37 |
private static PestApiClient instance;
|
|
@@ -127,6 +132,76 @@ public final class PestApiClient {
|
|
| 127 |
});
|
| 128 |
}
|
| 129 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
private static void post(Runnable runnable) {
|
| 131 |
MAIN_HANDLER.post(runnable);
|
| 132 |
}
|
|
|
|
| 32 |
void onError(String message);
|
| 33 |
}
|
| 34 |
|
| 35 |
+
public interface ReportsCallback {
|
| 36 |
+
void onSuccess(String jsonResult);
|
| 37 |
+
void onError(String message);
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
private static final MediaType IMAGE_MEDIA_TYPE = MediaType.parse("image/*");
|
| 41 |
private static final Handler MAIN_HANDLER = new Handler(Looper.getMainLooper());
|
| 42 |
private static PestApiClient instance;
|
|
|
|
| 132 |
});
|
| 133 |
}
|
| 134 |
|
| 135 |
+
public void reportUnrecognized(String reportUrl, String deviceId, byte[] imageBytes, PredictCallback callback) {
|
| 136 |
+
RequestBody requestBody = new MultipartBody.Builder()
|
| 137 |
+
.setType(MultipartBody.FORM)
|
| 138 |
+
.addFormDataPart("device_id", deviceId)
|
| 139 |
+
.addFormDataPart("image", "unrecognized.jpg", RequestBody.create(imageBytes, IMAGE_MEDIA_TYPE))
|
| 140 |
+
.build();
|
| 141 |
+
|
| 142 |
+
Request request = new Request.Builder()
|
| 143 |
+
.url(reportUrl)
|
| 144 |
+
.post(requestBody)
|
| 145 |
+
.build();
|
| 146 |
+
|
| 147 |
+
client.newCall(request).enqueue(new Callback() {
|
| 148 |
+
@Override
|
| 149 |
+
public void onFailure(Call call, IOException e) {
|
| 150 |
+
final String message = e.getMessage() != null ? e.getMessage() : "Report submission failed";
|
| 151 |
+
post(() -> callback.onError(message));
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
@Override
|
| 155 |
+
public void onResponse(Call call, Response response) throws IOException {
|
| 156 |
+
String body = response.body() != null ? response.body().string() : "";
|
| 157 |
+
|
| 158 |
+
if (!response.isSuccessful()) {
|
| 159 |
+
final String error = "Server error " + response.code() + (body.isEmpty() ? "" : ": " + body);
|
| 160 |
+
post(() -> callback.onError(error));
|
| 161 |
+
return;
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
try {
|
| 165 |
+
JSONObject json = new JSONObject(body);
|
| 166 |
+
String message = json.optString("message", "Success");
|
| 167 |
+
post(() -> callback.onSuccess("Success", 0.0, message));
|
| 168 |
+
} catch (Exception e) {
|
| 169 |
+
post(() -> callback.onError("Invalid response: " + body));
|
| 170 |
+
}
|
| 171 |
+
}
|
| 172 |
+
});
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
public void getReportsStatus(String statusUrl, String deviceId, ReportsCallback callback) {
|
| 176 |
+
String url = statusUrl + "?device_id=" + deviceId;
|
| 177 |
+
|
| 178 |
+
Request request = new Request.Builder()
|
| 179 |
+
.url(url)
|
| 180 |
+
.get()
|
| 181 |
+
.build();
|
| 182 |
+
|
| 183 |
+
client.newCall(request).enqueue(new Callback() {
|
| 184 |
+
@Override
|
| 185 |
+
public void onFailure(Call call, IOException e) {
|
| 186 |
+
final String message = e.getMessage() != null ? e.getMessage() : "Failed to fetch status";
|
| 187 |
+
post(() -> callback.onError(message));
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
@Override
|
| 191 |
+
public void onResponse(Call call, Response response) throws IOException {
|
| 192 |
+
String body = response.body() != null ? response.body().string() : "";
|
| 193 |
+
|
| 194 |
+
if (!response.isSuccessful()) {
|
| 195 |
+
final String error = "Server error " + response.code();
|
| 196 |
+
post(() -> callback.onError(error));
|
| 197 |
+
return;
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
post(() -> callback.onSuccess(body));
|
| 201 |
+
}
|
| 202 |
+
});
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
private static void post(Runnable runnable) {
|
| 206 |
MAIN_HANDLER.post(runnable);
|
| 207 |
}
|
app/src/main/res/layout/activity_main.xml
CHANGED
|
@@ -71,6 +71,20 @@
|
|
| 71 |
android:letterSpacing="0.05" />
|
| 72 |
</LinearLayout>
|
| 73 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
<!-- Server Connection Settings Card -->
|
| 75 |
<com.google.android.material.card.MaterialCardView
|
| 76 |
android:id="@+id/settingsCard"
|
|
@@ -83,7 +97,7 @@
|
|
| 83 |
app:cardBackgroundColor="@color/surface_card"
|
| 84 |
app:strokeColor="@color/primary_green_light"
|
| 85 |
app:strokeWidth="1dp"
|
| 86 |
-
app:layout_constraintTop_toBottomOf="@id/
|
| 87 |
app:layout_constraintStart_toStartOf="parent"
|
| 88 |
app:layout_constraintEnd_toEndOf="parent">
|
| 89 |
|
|
@@ -339,6 +353,20 @@
|
|
| 339 |
android:textSize="14sp"
|
| 340 |
android:lineSpacingExtra="4dp" />
|
| 341 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 342 |
</LinearLayout>
|
| 343 |
</com.google.android.material.card.MaterialCardView>
|
| 344 |
|
|
|
|
| 71 |
android:letterSpacing="0.05" />
|
| 72 |
</LinearLayout>
|
| 73 |
|
| 74 |
+
<com.google.android.material.button.MaterialButton
|
| 75 |
+
android:id="@+id/btnMySubmissions"
|
| 76 |
+
style="@style/Widget.Material3.Button.TextButton"
|
| 77 |
+
android:layout_width="wrap_content"
|
| 78 |
+
android:layout_height="wrap_content"
|
| 79 |
+
android:layout_marginTop="8dp"
|
| 80 |
+
android:text="My Submissions"
|
| 81 |
+
android:textColor="@color/primary_green"
|
| 82 |
+
app:icon="@android:drawable/ic_menu_myplaces"
|
| 83 |
+
app:iconSize="16dp"
|
| 84 |
+
app:iconTint="@color/primary_green"
|
| 85 |
+
app:layout_constraintTop_toBottomOf="@id/tvAppSubtitle"
|
| 86 |
+
app:layout_constraintStart_toStartOf="parent" />
|
| 87 |
+
|
| 88 |
<!-- Server Connection Settings Card -->
|
| 89 |
<com.google.android.material.card.MaterialCardView
|
| 90 |
android:id="@+id/settingsCard"
|
|
|
|
| 97 |
app:cardBackgroundColor="@color/surface_card"
|
| 98 |
app:strokeColor="@color/primary_green_light"
|
| 99 |
app:strokeWidth="1dp"
|
| 100 |
+
app:layout_constraintTop_toBottomOf="@id/btnMySubmissions"
|
| 101 |
app:layout_constraintStart_toStartOf="parent"
|
| 102 |
app:layout_constraintEnd_toEndOf="parent">
|
| 103 |
|
|
|
|
| 353 |
android:textSize="14sp"
|
| 354 |
android:lineSpacingExtra="4dp" />
|
| 355 |
|
| 356 |
+
<com.google.android.material.button.MaterialButton
|
| 357 |
+
android:id="@+id/btnReportUnrecognized"
|
| 358 |
+
style="@style/Widget.Material3.Button.OutlinedButton"
|
| 359 |
+
android:layout_width="match_parent"
|
| 360 |
+
android:layout_height="wrap_content"
|
| 361 |
+
android:layout_marginTop="16dp"
|
| 362 |
+
android:text="Report Unrecognized Pest"
|
| 363 |
+
android:visibility="gone"
|
| 364 |
+
app:strokeColor="@color/status_red_text"
|
| 365 |
+
android:textColor="@color/status_red_text"
|
| 366 |
+
app:icon="@android:drawable/stat_notify_error"
|
| 367 |
+
app:iconTint="@color/status_red_text"
|
| 368 |
+
app:cornerRadius="12dp" />
|
| 369 |
+
|
| 370 |
</LinearLayout>
|
| 371 |
</com.google.android.material.card.MaterialCardView>
|
| 372 |
|
backend/admin.html
ADDED
|
@@ -0,0 +1,555 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Pest Detection Admin Panel</title>
|
| 7 |
+
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
| 8 |
+
<style>
|
| 9 |
+
:root {
|
| 10 |
+
--bg-color: #0b0f19;
|
| 11 |
+
--card-bg: #151d30;
|
| 12 |
+
--card-border: #232d45;
|
| 13 |
+
--text-main: #f3f4f6;
|
| 14 |
+
--text-muted: #9ca3af;
|
| 15 |
+
--accent-primary: #10b981;
|
| 16 |
+
--accent-secondary: #059669;
|
| 17 |
+
--accent-glow: rgba(16, 185, 129, 0.15);
|
| 18 |
+
--danger: #ef4444;
|
| 19 |
+
--warning: #f59e0b;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
* {
|
| 23 |
+
box-sizing: border-box;
|
| 24 |
+
margin: 0;
|
| 25 |
+
padding: 0;
|
| 26 |
+
font-family: 'Outfit', sans-serif;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
body {
|
| 30 |
+
background-color: var(--bg-color);
|
| 31 |
+
color: var(--text-main);
|
| 32 |
+
min-height: 100vh;
|
| 33 |
+
display: flex;
|
| 34 |
+
flex-direction: column;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
header {
|
| 38 |
+
background: linear-gradient(135deg, #111827 0%, #1f2937 100%);
|
| 39 |
+
border-bottom: 1px solid var(--card-border);
|
| 40 |
+
padding: 1.5rem 2rem;
|
| 41 |
+
display: flex;
|
| 42 |
+
justify-content: space-between;
|
| 43 |
+
align-items: center;
|
| 44 |
+
position: sticky;
|
| 45 |
+
top: 0;
|
| 46 |
+
z-index: 100;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
.logo-container {
|
| 50 |
+
display: flex;
|
| 51 |
+
align-items: center;
|
| 52 |
+
gap: 0.75rem;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
.logo-icon {
|
| 56 |
+
font-size: 2rem;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
h1 {
|
| 60 |
+
font-size: 1.5rem;
|
| 61 |
+
font-weight: 700;
|
| 62 |
+
background: linear-gradient(to right, #10b981, #34d399);
|
| 63 |
+
-webkit-background-clip: text;
|
| 64 |
+
-webkit-text-fill-color: transparent;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
.nav-stats {
|
| 68 |
+
display: flex;
|
| 69 |
+
gap: 1.5rem;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
.stat-badge {
|
| 73 |
+
background-color: var(--card-bg);
|
| 74 |
+
border: 1px solid var(--card-border);
|
| 75 |
+
padding: 0.5rem 1rem;
|
| 76 |
+
border-radius: 9999px;
|
| 77 |
+
font-size: 0.875rem;
|
| 78 |
+
display: flex;
|
| 79 |
+
align-items: center;
|
| 80 |
+
gap: 0.5rem;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
.stat-count {
|
| 84 |
+
font-weight: 700;
|
| 85 |
+
color: var(--accent-primary);
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
.stat-count.pending {
|
| 89 |
+
color: var(--warning);
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
main {
|
| 93 |
+
flex: 1;
|
| 94 |
+
max-width: 1400px;
|
| 95 |
+
width: 100%;
|
| 96 |
+
margin: 0 auto;
|
| 97 |
+
padding: 2rem;
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
.notification-banner {
|
| 101 |
+
background: rgba(16, 185, 129, 0.1);
|
| 102 |
+
border: 1px solid rgba(16, 185, 129, 0.2);
|
| 103 |
+
padding: 1rem;
|
| 104 |
+
border-radius: 0.75rem;
|
| 105 |
+
margin-bottom: 2rem;
|
| 106 |
+
display: flex;
|
| 107 |
+
justify-content: space-between;
|
| 108 |
+
align-items: center;
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
.notification-banner button {
|
| 112 |
+
background-color: var(--accent-primary);
|
| 113 |
+
color: white;
|
| 114 |
+
border: none;
|
| 115 |
+
padding: 0.5rem 1rem;
|
| 116 |
+
border-radius: 0.5rem;
|
| 117 |
+
cursor: pointer;
|
| 118 |
+
font-weight: 500;
|
| 119 |
+
transition: all 0.2s ease;
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
.notification-banner button:hover {
|
| 123 |
+
background-color: var(--accent-secondary);
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
.section-title {
|
| 127 |
+
font-size: 1.25rem;
|
| 128 |
+
font-weight: 600;
|
| 129 |
+
margin-bottom: 1.5rem;
|
| 130 |
+
display: flex;
|
| 131 |
+
align-items: center;
|
| 132 |
+
gap: 0.5rem;
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
.reports-grid {
|
| 136 |
+
display: grid;
|
| 137 |
+
grid-template-columns: repeat(auto-fill, minmax(600px, 1fr));
|
| 138 |
+
gap: 2rem;
|
| 139 |
+
margin-bottom: 3rem;
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
@media (max-width: 768px) {
|
| 143 |
+
.reports-grid {
|
| 144 |
+
grid-template-columns: 1fr;
|
| 145 |
+
}
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
.report-card {
|
| 149 |
+
background-color: var(--card-bg);
|
| 150 |
+
border: 1px solid var(--card-border);
|
| 151 |
+
border-radius: 1rem;
|
| 152 |
+
overflow: hidden;
|
| 153 |
+
display: flex;
|
| 154 |
+
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
| 155 |
+
position: relative;
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
.report-card:hover {
|
| 159 |
+
transform: translateY(-4px);
|
| 160 |
+
box-shadow: 0 10px 20px rgba(0,0,0,0.3), 0 0 15px var(--accent-glow);
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
.report-card.resolved-card {
|
| 164 |
+
opacity: 0.85;
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
.report-image-container {
|
| 168 |
+
width: 45%;
|
| 169 |
+
position: relative;
|
| 170 |
+
background-color: #0d121f;
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
.report-image {
|
| 174 |
+
width: 100%;
|
| 175 |
+
height: 100%;
|
| 176 |
+
object-fit: cover;
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
.report-details {
|
| 180 |
+
width: 55%;
|
| 181 |
+
padding: 1.5rem;
|
| 182 |
+
display: flex;
|
| 183 |
+
flex-direction: column;
|
| 184 |
+
justify-content: space-between;
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
.meta-info {
|
| 188 |
+
font-size: 0.8rem;
|
| 189 |
+
color: var(--text-muted);
|
| 190 |
+
margin-bottom: 0.75rem;
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
.meta-item {
|
| 194 |
+
margin-bottom: 0.25rem;
|
| 195 |
+
word-break: break-all;
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
.meta-label {
|
| 199 |
+
font-weight: 500;
|
| 200 |
+
color: var(--text-main);
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
.report-form {
|
| 204 |
+
display: flex;
|
| 205 |
+
flex-direction: column;
|
| 206 |
+
gap: 0.75rem;
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
.form-group {
|
| 210 |
+
display: flex;
|
| 211 |
+
flex-direction: column;
|
| 212 |
+
gap: 0.25rem;
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
label {
|
| 216 |
+
font-size: 0.8rem;
|
| 217 |
+
font-weight: 500;
|
| 218 |
+
color: var(--text-muted);
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
input[type="text"], textarea {
|
| 222 |
+
background-color: #0b0f19;
|
| 223 |
+
border: 1px solid var(--card-border);
|
| 224 |
+
border-radius: 0.375rem;
|
| 225 |
+
padding: 0.5rem 0.75rem;
|
| 226 |
+
color: var(--text-main);
|
| 227 |
+
font-size: 0.875rem;
|
| 228 |
+
outline: none;
|
| 229 |
+
transition: border-color 0.2s ease;
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
input[type="text"]:focus, textarea:focus {
|
| 233 |
+
border-color: var(--accent-primary);
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
textarea {
|
| 237 |
+
resize: none;
|
| 238 |
+
height: 80px;
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
.btn-resolve {
|
| 242 |
+
background-color: var(--accent-primary);
|
| 243 |
+
color: white;
|
| 244 |
+
border: none;
|
| 245 |
+
padding: 0.5rem 1rem;
|
| 246 |
+
border-radius: 0.375rem;
|
| 247 |
+
cursor: pointer;
|
| 248 |
+
font-weight: 600;
|
| 249 |
+
font-size: 0.875rem;
|
| 250 |
+
transition: all 0.2s ease;
|
| 251 |
+
margin-top: 0.25rem;
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
.btn-resolve:hover {
|
| 255 |
+
background-color: var(--accent-secondary);
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
.resolved-display {
|
| 259 |
+
border-top: 1px solid var(--card-border);
|
| 260 |
+
padding-top: 1rem;
|
| 261 |
+
margin-top: 1rem;
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
.resolved-badge {
|
| 265 |
+
background-color: rgba(16, 185, 129, 0.15);
|
| 266 |
+
color: var(--accent-primary);
|
| 267 |
+
border: 1px solid rgba(16, 185, 129, 0.3);
|
| 268 |
+
padding: 0.25rem 0.5rem;
|
| 269 |
+
border-radius: 0.25rem;
|
| 270 |
+
font-size: 0.75rem;
|
| 271 |
+
font-weight: 600;
|
| 272 |
+
display: inline-block;
|
| 273 |
+
margin-bottom: 0.5rem;
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
.resolved-title {
|
| 277 |
+
font-weight: 600;
|
| 278 |
+
margin-bottom: 0.25rem;
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
.resolved-treatment {
|
| 282 |
+
font-size: 0.875rem;
|
| 283 |
+
color: var(--text-muted);
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
.empty-state {
|
| 287 |
+
grid-column: 1 / -1;
|
| 288 |
+
text-align: center;
|
| 289 |
+
padding: 4rem 2rem;
|
| 290 |
+
background-color: var(--card-bg);
|
| 291 |
+
border: 1px dashed var(--card-border);
|
| 292 |
+
border-radius: 1rem;
|
| 293 |
+
color: var(--text-muted);
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
.empty-icon {
|
| 297 |
+
font-size: 3rem;
|
| 298 |
+
margin-bottom: 1rem;
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
footer {
|
| 302 |
+
text-align: center;
|
| 303 |
+
padding: 2rem;
|
| 304 |
+
color: var(--text-muted);
|
| 305 |
+
font-size: 0.875rem;
|
| 306 |
+
border-top: 1px solid var(--card-border);
|
| 307 |
+
}
|
| 308 |
+
</style>
|
| 309 |
+
</head>
|
| 310 |
+
<body>
|
| 311 |
+
<header>
|
| 312 |
+
<div class="logo-container">
|
| 313 |
+
<span class="logo-icon">🌾</span>
|
| 314 |
+
<div>
|
| 315 |
+
<h1>Pest Detection Admin</h1>
|
| 316 |
+
<p style="font-size: 0.75rem; color: var(--text-muted);">Real-time Crop Diagnostics Console</p>
|
| 317 |
+
</div>
|
| 318 |
+
</div>
|
| 319 |
+
<div class="nav-stats">
|
| 320 |
+
<div class="stat-badge">
|
| 321 |
+
<span>Pending Review:</span>
|
| 322 |
+
<span class="stat-count pending" id="pending-count">0</span>
|
| 323 |
+
</div>
|
| 324 |
+
<div class="stat-badge">
|
| 325 |
+
<span>Resolved:</span>
|
| 326 |
+
<span class="stat-count" id="resolved-count">0</span>
|
| 327 |
+
</div>
|
| 328 |
+
</div>
|
| 329 |
+
</header>
|
| 330 |
+
|
| 331 |
+
<main>
|
| 332 |
+
<div class="notification-banner" id="notif-banner" style="display: none;">
|
| 333 |
+
<p>🔔 Enable desktop notifications to receive instant audio and visual alerts when users report unrecognized pests.</p>
|
| 334 |
+
<button onclick="requestNotificationPermission()">Enable Notifications</button>
|
| 335 |
+
</div>
|
| 336 |
+
|
| 337 |
+
<section>
|
| 338 |
+
<h2 class="section-title">🕒 Pending User Submissions</h2>
|
| 339 |
+
<div class="reports-grid" id="pending-reports-grid">
|
| 340 |
+
<div class="empty-state">
|
| 341 |
+
<div class="empty-icon">🎉</div>
|
| 342 |
+
<p>All clear! There are no pending unrecognized pest reports.</p>
|
| 343 |
+
</div>
|
| 344 |
+
</div>
|
| 345 |
+
</section>
|
| 346 |
+
|
| 347 |
+
<section style="margin-top: 4rem;">
|
| 348 |
+
<h2 class="section-title">✅ Resolved Submissions</h2>
|
| 349 |
+
<div class="reports-grid" id="resolved-reports-grid">
|
| 350 |
+
<div class="empty-state">
|
| 351 |
+
<p>No reports resolved yet.</p>
|
| 352 |
+
</div>
|
| 353 |
+
</div>
|
| 354 |
+
</section>
|
| 355 |
+
</main>
|
| 356 |
+
|
| 357 |
+
<footer>
|
| 358 |
+
<p>© 2026 Pest Detection API. Built with FastAPI.</p>
|
| 359 |
+
</footer>
|
| 360 |
+
|
| 361 |
+
<audio id="alert-sound" src="https://assets.mixkit.co/active_storage/sfx/911/911-500.wav" preload="auto"></audio>
|
| 362 |
+
|
| 363 |
+
<script>
|
| 364 |
+
let previousPendingIds = new Set();
|
| 365 |
+
let isInitialLoad = true;
|
| 366 |
+
|
| 367 |
+
async function fetchReports() {
|
| 368 |
+
try {
|
| 369 |
+
const response = await fetch('/admin/api/reports');
|
| 370 |
+
const data = await response.json();
|
| 371 |
+
if (data.status === 'success') {
|
| 372 |
+
renderReports(data.reports);
|
| 373 |
+
}
|
| 374 |
+
} catch (err) {
|
| 375 |
+
console.error("Failed to fetch reports:", err);
|
| 376 |
+
}
|
| 377 |
+
}
|
| 378 |
+
|
| 379 |
+
function renderReports(reports) {
|
| 380 |
+
const pendingGrid = document.getElementById('pending-reports-grid');
|
| 381 |
+
const resolvedGrid = document.getElementById('resolved-reports-grid');
|
| 382 |
+
|
| 383 |
+
const pendingReports = reports.filter(r => r.status === 'pending');
|
| 384 |
+
const resolvedReports = reports.filter(r => r.status === 'resolved');
|
| 385 |
+
|
| 386 |
+
// Update stats in header
|
| 387 |
+
document.getElementById('pending-count').textContent = pendingReports.length;
|
| 388 |
+
document.getElementById('resolved-count').textContent = resolvedReports.length;
|
| 389 |
+
|
| 390 |
+
// Update page tab title badge count
|
| 391 |
+
if (pendingReports.length > 0) {
|
| 392 |
+
document.title = `(${pendingReports.length}) Pest Detection Admin`;
|
| 393 |
+
} else {
|
| 394 |
+
document.title = "Pest Detection Admin Panel";
|
| 395 |
+
}
|
| 396 |
+
|
| 397 |
+
// Check for new pending reports to trigger alerts
|
| 398 |
+
const currentPendingIds = new Set(pendingReports.map(r => r.id));
|
| 399 |
+
if (!isInitialLoad) {
|
| 400 |
+
let hasNew = false;
|
| 401 |
+
for (let id of currentPendingIds) {
|
| 402 |
+
if (!previousPendingIds.has(id)) {
|
| 403 |
+
hasNew = true;
|
| 404 |
+
break;
|
| 405 |
+
}
|
| 406 |
+
}
|
| 407 |
+
if (hasNew) {
|
| 408 |
+
playAlert();
|
| 409 |
+
triggerNotification(pendingReports.length);
|
| 410 |
+
}
|
| 411 |
+
}
|
| 412 |
+
previousPendingIds = currentPendingIds;
|
| 413 |
+
isInitialLoad = false;
|
| 414 |
+
|
| 415 |
+
// Render Pending Grid
|
| 416 |
+
if (pendingReports.length === 0) {
|
| 417 |
+
pendingGrid.innerHTML = `
|
| 418 |
+
<div class="empty-state">
|
| 419 |
+
<div class="empty-icon">🎉</div>
|
| 420 |
+
<p>All clear! There are no pending unrecognized pest reports.</p>
|
| 421 |
+
</div>
|
| 422 |
+
`;
|
| 423 |
+
} else {
|
| 424 |
+
pendingGrid.innerHTML = pendingReports.map(r => `
|
| 425 |
+
<div class="report-card" id="card-${r.id}">
|
| 426 |
+
<div class="report-image-container">
|
| 427 |
+
<img class="report-image" src="/admin/image/${r.image_path}" alt="Unrecognized pest">
|
| 428 |
+
</div>
|
| 429 |
+
<div class="report-details">
|
| 430 |
+
<div>
|
| 431 |
+
<div class="meta-info">
|
| 432 |
+
<div class="meta-item"><span class="meta-label">ID:</span> #${r.id}</div>
|
| 433 |
+
<div class="meta-item"><span class="meta-label">Device:</span> ${r.device_id}</div>
|
| 434 |
+
<div class="meta-item"><span class="meta-label">Date:</span> ${new Date(r.created_at).toLocaleString()}</div>
|
| 435 |
+
</div>
|
| 436 |
+
</div>
|
| 437 |
+
<form class="report-form" onsubmit="resolveReport(event, ${r.id})">
|
| 438 |
+
<div class="form-group">
|
| 439 |
+
<label for="pest-${r.id}">Identified Pest Name</label>
|
| 440 |
+
<input type="text" id="pest-${r.id}" required placeholder="e.g. Stem Borer / Maize Weevil">
|
| 441 |
+
</div>
|
| 442 |
+
<div class="form-group">
|
| 443 |
+
<label for="treat-${r.id}">Treatment Recommendation Plan</label>
|
| 444 |
+
<textarea id="treat-${r.id}" required placeholder="Provide step-by-step chemical/biological treatment plan..."></textarea>
|
| 445 |
+
</div>
|
| 446 |
+
<button type="submit" class="btn-resolve">Resolve & Notify Farmer</button>
|
| 447 |
+
</form>
|
| 448 |
+
</div>
|
| 449 |
+
</div>
|
| 450 |
+
`).join('');
|
| 451 |
+
}
|
| 452 |
+
|
| 453 |
+
// Render Resolved Grid
|
| 454 |
+
if (resolvedReports.length === 0) {
|
| 455 |
+
resolvedGrid.innerHTML = `
|
| 456 |
+
<div class="empty-state">
|
| 457 |
+
<p>No reports resolved yet.</p>
|
| 458 |
+
</div>
|
| 459 |
+
`;
|
| 460 |
+
} else {
|
| 461 |
+
resolvedGrid.innerHTML = resolvedReports.map(r => `
|
| 462 |
+
<div class="report-card resolved-card">
|
| 463 |
+
<div class="report-image-container">
|
| 464 |
+
<img class="report-image" src="/admin/image/${r.image_path}" alt="Resolved pest">
|
| 465 |
+
</div>
|
| 466 |
+
<div class="report-details">
|
| 467 |
+
<div>
|
| 468 |
+
<span class="resolved-badge">Resolved</span>
|
| 469 |
+
<div class="meta-info">
|
| 470 |
+
<div class="meta-item"><span class="meta-label">ID:</span> #${r.id}</div>
|
| 471 |
+
<div class="meta-item"><span class="meta-label">Device:</span> ${r.device_id}</div>
|
| 472 |
+
<div class="meta-item"><span class="meta-label">Date:</span> ${new Date(r.created_at).toLocaleString()}</div>
|
| 473 |
+
</div>
|
| 474 |
+
<div class="resolved-display">
|
| 475 |
+
<div class="resolved-title">${r.pest_name}</div>
|
| 476 |
+
<div class="resolved-treatment">${r.treatment}</div>
|
| 477 |
+
</div>
|
| 478 |
+
</div>
|
| 479 |
+
</div>
|
| 480 |
+
</div>
|
| 481 |
+
`).join('');
|
| 482 |
+
}
|
| 483 |
+
}
|
| 484 |
+
|
| 485 |
+
async function resolveReport(event, id) {
|
| 486 |
+
event.preventDefault();
|
| 487 |
+
const pestName = document.getElementById(`pest-${id}`).value;
|
| 488 |
+
const treatment = document.getElementById(`treat-${id}`).value;
|
| 489 |
+
|
| 490 |
+
const formData = new FormData();
|
| 491 |
+
formData.append('pest_name', pestName);
|
| 492 |
+
formData.append('treatment', treatment);
|
| 493 |
+
|
| 494 |
+
try {
|
| 495 |
+
const response = await fetch(`/admin/api/resolve/${id}`, {
|
| 496 |
+
method: 'POST',
|
| 497 |
+
body: formData
|
| 498 |
+
});
|
| 499 |
+
const data = await response.json();
|
| 500 |
+
if (data.status === 'success') {
|
| 501 |
+
// Remove card with visual fade effect
|
| 502 |
+
const card = document.getElementById(`card-${id}`);
|
| 503 |
+
card.style.transition = "opacity 0.5s ease, transform 0.5s ease";
|
| 504 |
+
card.style.opacity = 0;
|
| 505 |
+
card.style.transform = "scale(0.9)";
|
| 506 |
+
setTimeout(() => {
|
| 507 |
+
fetchReports();
|
| 508 |
+
}, 500);
|
| 509 |
+
}
|
| 510 |
+
} catch (err) {
|
| 511 |
+
alert("Failed to resolve report: " + err);
|
| 512 |
+
}
|
| 513 |
+
}
|
| 514 |
+
|
| 515 |
+
function playAlert() {
|
| 516 |
+
const sound = document.getElementById('alert-sound');
|
| 517 |
+
if (sound) {
|
| 518 |
+
sound.play().catch(e => console.log("Audio play blocked by browser."));
|
| 519 |
+
}
|
| 520 |
+
}
|
| 521 |
+
|
| 522 |
+
function requestNotificationPermission() {
|
| 523 |
+
Notification.requestPermission().then(permission => {
|
| 524 |
+
if (permission === 'granted') {
|
| 525 |
+
document.getElementById('notif-banner').style.display = 'none';
|
| 526 |
+
new Notification("Notifications Enabled!", {
|
| 527 |
+
body: "You will now receive alerts for new unrecognized pest scans.",
|
| 528 |
+
icon: "🌾"
|
| 529 |
+
});
|
| 530 |
+
}
|
| 531 |
+
});
|
| 532 |
+
}
|
| 533 |
+
|
| 534 |
+
function checkNotificationPermission() {
|
| 535 |
+
if (Notification.permission === 'default') {
|
| 536 |
+
document.getElementById('notif-banner').style.display = 'flex';
|
| 537 |
+
}
|
| 538 |
+
}
|
| 539 |
+
|
| 540 |
+
function triggerNotification(pendingCount) {
|
| 541 |
+
if (Notification.permission === 'granted') {
|
| 542 |
+
new Notification("New Unrecognized Pest Scan!", {
|
| 543 |
+
body: `There are currently ${pendingCount} pending reports waiting for identification.`,
|
| 544 |
+
icon: "🌾"
|
| 545 |
+
});
|
| 546 |
+
}
|
| 547 |
+
}
|
| 548 |
+
|
| 549 |
+
// Initialize and poll
|
| 550 |
+
checkNotificationPermission();
|
| 551 |
+
fetchReports();
|
| 552 |
+
setInterval(fetchReports, 10000);
|
| 553 |
+
</script>
|
| 554 |
+
</body>
|
| 555 |
+
</html>
|
backend/app.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
-
from fastapi import FastAPI, File, UploadFile, HTTPException, Request
|
| 2 |
-
from fastapi.responses import PlainTextResponse
|
| 3 |
from fastapi.middleware.cors import CORSMiddleware
|
| 4 |
import os
|
| 5 |
from pathlib import Path
|
|
@@ -7,6 +7,7 @@ import threading
|
|
| 7 |
import time
|
| 8 |
import urllib.request
|
| 9 |
import sqlite3
|
|
|
|
| 10 |
import json
|
| 11 |
import io
|
| 12 |
from PIL import Image
|
|
@@ -20,8 +21,12 @@ MODEL_IP102_FILE = BASE_DIR / "best_ip102.pt"
|
|
| 20 |
IP102_URL = "https://huggingface.co/underdogquality/yolo11s-pest-detection/resolve/main/best.pt"
|
| 21 |
|
| 22 |
def init_db():
|
| 23 |
-
conn = sqlite3.connect(str(DB_FILE))
|
| 24 |
cursor = conn.cursor()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
cursor.execute("""
|
| 26 |
CREATE TABLE IF NOT EXISTS audit_logs (
|
| 27 |
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
@@ -33,6 +38,17 @@ def init_db():
|
|
| 33 |
ip_address TEXT
|
| 34 |
)
|
| 35 |
""")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
conn.commit()
|
| 37 |
|
| 38 |
# Prune audit logs older than 30 days
|
|
@@ -49,7 +65,7 @@ def init_db():
|
|
| 49 |
|
| 50 |
def log_audit(username: Optional[str], endpoint: str, status: str, details: str, ip_address: str):
|
| 51 |
try:
|
| 52 |
-
conn = sqlite3.connect(str(DB_FILE))
|
| 53 |
cursor = conn.cursor()
|
| 54 |
cursor.execute(
|
| 55 |
"INSERT INTO audit_logs (username, endpoint, status, details, ip_address) VALUES (?, ?, ?, ?, ?)",
|
|
@@ -82,6 +98,11 @@ model = None
|
|
| 82 |
HAS_YOLO = False
|
| 83 |
is_downloading = False
|
| 84 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
def download_ip102_model():
|
| 86 |
global is_downloading, model, HAS_YOLO
|
| 87 |
if is_downloading:
|
|
@@ -132,7 +153,22 @@ def download_ip102_model():
|
|
| 132 |
is_downloading = False
|
| 133 |
|
| 134 |
def init_model():
|
| 135 |
-
global model, HAS_YOLO
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
try:
|
| 137 |
from ultralytics import YOLO
|
| 138 |
|
|
@@ -247,10 +283,25 @@ async def predict(
|
|
| 247 |
request: Request,
|
| 248 |
image: UploadFile = File(...)
|
| 249 |
):
|
| 250 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
|
| 252 |
# Rate limiting check (excludes development environments)
|
| 253 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
now = time.time()
|
| 255 |
request_history[ip] = [t for t in request_history[ip] if now - t < RATE_LIMIT_WINDOW]
|
| 256 |
if len(request_history[ip]) >= RATE_LIMIT_MAX_REQUESTS:
|
|
@@ -280,6 +331,53 @@ async def predict(
|
|
| 280 |
log_audit("anonymous", "/predict", "error", f"Image parsing error: {str(e)}", ip)
|
| 281 |
raise HTTPException(status_code=500, detail=f"Failed to process image: {str(e)}")
|
| 282 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 283 |
if HAS_YOLO:
|
| 284 |
try:
|
| 285 |
results = model.predict(source=img, save=False, conf=0.4)
|
|
@@ -346,6 +444,148 @@ async def predict(
|
|
| 346 |
"message": "Detection completed successfully (Mock Mode)",
|
| 347 |
}
|
| 348 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 349 |
if __name__ == "__main__":
|
| 350 |
import uvicorn
|
| 351 |
port = int(os.environ.get("PORT", 5000))
|
|
|
|
| 1 |
+
from fastapi import FastAPI, File, UploadFile, HTTPException, Request, Form
|
| 2 |
+
from fastapi.responses import PlainTextResponse, HTMLResponse, FileResponse
|
| 3 |
from fastapi.middleware.cors import CORSMiddleware
|
| 4 |
import os
|
| 5 |
from pathlib import Path
|
|
|
|
| 7 |
import time
|
| 8 |
import urllib.request
|
| 9 |
import sqlite3
|
| 10 |
+
import ipaddress
|
| 11 |
import json
|
| 12 |
import io
|
| 13 |
from PIL import Image
|
|
|
|
| 21 |
IP102_URL = "https://huggingface.co/underdogquality/yolo11s-pest-detection/resolve/main/best.pt"
|
| 22 |
|
| 23 |
def init_db():
|
| 24 |
+
conn = sqlite3.connect(str(DB_FILE), timeout=30.0)
|
| 25 |
cursor = conn.cursor()
|
| 26 |
+
try:
|
| 27 |
+
cursor.execute("PRAGMA journal_mode=WAL;")
|
| 28 |
+
except Exception as e:
|
| 29 |
+
print(f"[DB] Warning: Failed to set WAL mode: {e}")
|
| 30 |
cursor.execute("""
|
| 31 |
CREATE TABLE IF NOT EXISTS audit_logs (
|
| 32 |
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
|
| 38 |
ip_address TEXT
|
| 39 |
)
|
| 40 |
""")
|
| 41 |
+
cursor.execute("""
|
| 42 |
+
CREATE TABLE IF NOT EXISTS unrecognized_reports (
|
| 43 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 44 |
+
device_id TEXT NOT NULL,
|
| 45 |
+
image_path TEXT NOT NULL,
|
| 46 |
+
status TEXT DEFAULT 'pending',
|
| 47 |
+
pest_name TEXT,
|
| 48 |
+
treatment TEXT,
|
| 49 |
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
| 50 |
+
)
|
| 51 |
+
""")
|
| 52 |
conn.commit()
|
| 53 |
|
| 54 |
# Prune audit logs older than 30 days
|
|
|
|
| 65 |
|
| 66 |
def log_audit(username: Optional[str], endpoint: str, status: str, details: str, ip_address: str):
|
| 67 |
try:
|
| 68 |
+
conn = sqlite3.connect(str(DB_FILE), timeout=30.0)
|
| 69 |
cursor = conn.cursor()
|
| 70 |
cursor.execute(
|
| 71 |
"INSERT INTO audit_logs (username, endpoint, status, details, ip_address) VALUES (?, ?, ?, ?, ?)",
|
|
|
|
| 98 |
HAS_YOLO = False
|
| 99 |
is_downloading = False
|
| 100 |
|
| 101 |
+
classifier_model = None
|
| 102 |
+
preprocess = None
|
| 103 |
+
categories = None
|
| 104 |
+
HAS_CLASSIFIER = False
|
| 105 |
+
|
| 106 |
def download_ip102_model():
|
| 107 |
global is_downloading, model, HAS_YOLO
|
| 108 |
if is_downloading:
|
|
|
|
| 153 |
is_downloading = False
|
| 154 |
|
| 155 |
def init_model():
|
| 156 |
+
global model, HAS_YOLO, classifier_model, preprocess, categories, HAS_CLASSIFIER
|
| 157 |
+
|
| 158 |
+
# Initialize MobileNetV3 for out-of-domain checking
|
| 159 |
+
try:
|
| 160 |
+
from torchvision.models import mobilenet_v3_small, MobileNet_V3_Small_Weights
|
| 161 |
+
weights = MobileNet_V3_Small_Weights.DEFAULT
|
| 162 |
+
classifier_model = mobilenet_v3_small(weights=weights)
|
| 163 |
+
classifier_model.eval()
|
| 164 |
+
preprocess = weights.transforms()
|
| 165 |
+
categories = weights.meta["categories"]
|
| 166 |
+
HAS_CLASSIFIER = True
|
| 167 |
+
print("Success: Loaded MobileNetV3 classifier for unrelated image detection.")
|
| 168 |
+
except Exception as e:
|
| 169 |
+
HAS_CLASSIFIER = False
|
| 170 |
+
print(f"Warning: Failed to load MobileNetV3 classifier: {e}")
|
| 171 |
+
|
| 172 |
try:
|
| 173 |
from ultralytics import YOLO
|
| 174 |
|
|
|
|
| 283 |
request: Request,
|
| 284 |
image: UploadFile = File(...)
|
| 285 |
):
|
| 286 |
+
# Resolve real client IP address behind reverse proxies
|
| 287 |
+
ip = "unknown"
|
| 288 |
+
if request.client:
|
| 289 |
+
ip = request.client.host
|
| 290 |
+
|
| 291 |
+
forwarded_for = request.headers.get("x-forwarded-for")
|
| 292 |
+
if forwarded_for:
|
| 293 |
+
ip = forwarded_for.split(",")[0].strip()
|
| 294 |
|
| 295 |
# Rate limiting check (excludes development environments)
|
| 296 |
+
is_local = ip in ("localhost", "unknown")
|
| 297 |
+
if not is_local:
|
| 298 |
+
try:
|
| 299 |
+
ip_obj = ipaddress.ip_address(ip)
|
| 300 |
+
is_local = ip_obj.is_private or ip_obj.is_loopback or ip_obj.is_link_local
|
| 301 |
+
except ValueError:
|
| 302 |
+
pass
|
| 303 |
+
|
| 304 |
+
if not is_local:
|
| 305 |
now = time.time()
|
| 306 |
request_history[ip] = [t for t in request_history[ip] if now - t < RATE_LIMIT_WINDOW]
|
| 307 |
if len(request_history[ip]) >= RATE_LIMIT_MAX_REQUESTS:
|
|
|
|
| 331 |
log_audit("anonymous", "/predict", "error", f"Image parsing error: {str(e)}", ip)
|
| 332 |
raise HTTPException(status_code=500, detail=f"Failed to process image: {str(e)}")
|
| 333 |
|
| 334 |
+
# Check if the image is related to crops/plants/pests unconditionally
|
| 335 |
+
if HAS_CLASSIFIER and classifier_model:
|
| 336 |
+
try:
|
| 337 |
+
import torch
|
| 338 |
+
img_tensor = preprocess(img).unsqueeze(0)
|
| 339 |
+
with torch.no_grad():
|
| 340 |
+
outputs = classifier_model(img_tensor)
|
| 341 |
+
probs = torch.nn.functional.softmax(outputs[0], dim=0)
|
| 342 |
+
top5_indices = torch.topk(probs, 5).indices.tolist()
|
| 343 |
+
top5_names = [categories[idx] for idx in top5_indices]
|
| 344 |
+
|
| 345 |
+
related_keywords = {
|
| 346 |
+
'leaf', 'plant', 'tree', 'flower', 'crop', 'insect', 'bug', 'spider', 'caterpillar',
|
| 347 |
+
'moth', 'butterfly', 'grasshopper', 'beetle', 'cricket', 'ant', 'fly', 'wasp', 'bee',
|
| 348 |
+
'aphid', 'weevil', 'locust', 'cicada', 'mantis', 'ladybug', 'mite', 'slug', 'snail',
|
| 349 |
+
'worm', 'larva', 'pupa', 'grub', 'vegetable', 'fruit', 'cereal', 'grass', 'grain',
|
| 350 |
+
'corn', 'maize', 'rice', 'wheat', 'barley', 'soil', 'ground', 'earth', 'dirt',
|
| 351 |
+
'nature', 'field', 'farm', 'garden', 'agriculture', 'pest', 'fungus', 'rust',
|
| 352 |
+
'rot', 'mildew', 'spot', 'blight', 'mold', 'banana', 'apple', 'orange', 'broccoli',
|
| 353 |
+
'cabbage', 'tomato', 'potato', 'seed', 'sprout', 'stem', 'branch', 'root', 'wood',
|
| 354 |
+
'bark', 'forest', 'jungle', 'meadow', 'vegetation', 'flora', 'fauna', 'herb',
|
| 355 |
+
'conifer', 'fern', 'moss', 'lichen', 'shrub', 'bush', 'vine', 'foliage',
|
| 356 |
+
'lizard', 'snake', 'chameleon', 'gecko', 'walking stick', 'frog', 'toad', 'salamander',
|
| 357 |
+
'iguana', 'anole', 'dragon'
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
is_related = False
|
| 361 |
+
for name in top5_names:
|
| 362 |
+
name_lower = name.lower()
|
| 363 |
+
if any(kw in name_lower for kw in related_keywords):
|
| 364 |
+
is_related = True
|
| 365 |
+
break
|
| 366 |
+
|
| 367 |
+
if not is_related:
|
| 368 |
+
detected_obj = top5_names[0].replace("_", " ").title()
|
| 369 |
+
log_details = json.dumps({"pest": "Invalid Image", "detected": detected_obj, "filename": image.filename})
|
| 370 |
+
log_audit("anonymous", "/predict", "invalid-image", log_details, ip)
|
| 371 |
+
return {
|
| 372 |
+
"status": "success",
|
| 373 |
+
"pest_detected": "Invalid Image",
|
| 374 |
+
"confidence": 0.0,
|
| 375 |
+
"treatment": "Please upload a clear image of a crop leaf or pest.",
|
| 376 |
+
"message": f"This does not look like a crop or pest image (detected: {detected_obj}).",
|
| 377 |
+
}
|
| 378 |
+
except Exception as ex:
|
| 379 |
+
print(f"Error running unrelated image validation: {ex}")
|
| 380 |
+
|
| 381 |
if HAS_YOLO:
|
| 382 |
try:
|
| 383 |
results = model.predict(source=img, save=False, conf=0.4)
|
|
|
|
| 444 |
"message": "Detection completed successfully (Mock Mode)",
|
| 445 |
}
|
| 446 |
|
| 447 |
+
@app.post("/predict/report-unrecognized")
|
| 448 |
+
async def report_unrecognized(
|
| 449 |
+
request: Request,
|
| 450 |
+
device_id: str = Form(...),
|
| 451 |
+
image: UploadFile = File(...)
|
| 452 |
+
):
|
| 453 |
+
ip = "unknown"
|
| 454 |
+
if request.client:
|
| 455 |
+
ip = request.client.host
|
| 456 |
+
|
| 457 |
+
forwarded_for = request.headers.get("x-forwarded-for")
|
| 458 |
+
if forwarded_for:
|
| 459 |
+
ip = forwarded_for.split(",")[0].strip()
|
| 460 |
+
|
| 461 |
+
if not image.filename or not allowed_file(image.filename):
|
| 462 |
+
log_audit("anonymous", "/predict/report-unrecognized", "failed", "Invalid file type", ip)
|
| 463 |
+
raise HTTPException(status_code=400, detail="Invalid file type")
|
| 464 |
+
|
| 465 |
+
try:
|
| 466 |
+
contents = await image.read()
|
| 467 |
+
if len(contents) > 10 * 1024 * 1024:
|
| 468 |
+
log_audit("anonymous", "/predict/report-unrecognized", "failed", "File too large", ip)
|
| 469 |
+
raise HTTPException(status_code=413, detail="File size exceeds the 10MB limit.")
|
| 470 |
+
|
| 471 |
+
unrecognized_dir = BASE_DIR / "uploads" / "unrecognized"
|
| 472 |
+
unrecognized_dir.mkdir(parents=True, exist_ok=True)
|
| 473 |
+
|
| 474 |
+
ext = image.filename.rsplit(".", 1)[1].lower()
|
| 475 |
+
filename = f"{device_id}_{int(time.time())}.{ext}"
|
| 476 |
+
file_path = unrecognized_dir / filename
|
| 477 |
+
|
| 478 |
+
with open(file_path, "wb") as f:
|
| 479 |
+
f.write(contents)
|
| 480 |
+
except HTTPException as he:
|
| 481 |
+
raise he
|
| 482 |
+
except Exception as e:
|
| 483 |
+
log_audit("anonymous", "/predict/report-unrecognized", "error", f"Save error: {e}", ip)
|
| 484 |
+
raise HTTPException(status_code=500, detail=f"Failed to save image: {e}")
|
| 485 |
+
|
| 486 |
+
try:
|
| 487 |
+
conn = sqlite3.connect(str(DB_FILE), timeout=30.0)
|
| 488 |
+
cursor = conn.cursor()
|
| 489 |
+
cursor.execute(
|
| 490 |
+
"INSERT INTO unrecognized_reports (device_id, image_path) VALUES (?, ?)",
|
| 491 |
+
(device_id, filename)
|
| 492 |
+
)
|
| 493 |
+
conn.commit()
|
| 494 |
+
conn.close()
|
| 495 |
+
|
| 496 |
+
log_audit("anonymous", "/predict/report-unrecognized", "success", f"Report created: {filename}", ip)
|
| 497 |
+
return {"status": "success", "message": "Report submitted successfully"}
|
| 498 |
+
except Exception as e:
|
| 499 |
+
log_audit("anonymous", "/predict/report-unrecognized", "error", f"Database error: {e}", ip)
|
| 500 |
+
raise HTTPException(status_code=500, detail=f"Database error: {e}")
|
| 501 |
+
|
| 502 |
+
@app.get("/reports/status")
|
| 503 |
+
def get_reports_status(device_id: str):
|
| 504 |
+
try:
|
| 505 |
+
conn = sqlite3.connect(str(DB_FILE), timeout=30.0)
|
| 506 |
+
conn.row_factory = sqlite3.Row
|
| 507 |
+
cursor = conn.cursor()
|
| 508 |
+
cursor.execute(
|
| 509 |
+
"SELECT id, image_path, status, pest_name, treatment, created_at FROM unrecognized_reports WHERE device_id = ? ORDER BY created_at DESC",
|
| 510 |
+
(device_id,)
|
| 511 |
+
)
|
| 512 |
+
rows = cursor.fetchall()
|
| 513 |
+
conn.close()
|
| 514 |
+
|
| 515 |
+
reports = []
|
| 516 |
+
for r in rows:
|
| 517 |
+
reports.append({
|
| 518 |
+
"id": r["id"],
|
| 519 |
+
"image_path": r["image_path"],
|
| 520 |
+
"status": r["status"],
|
| 521 |
+
"pest_name": r["pest_name"] if r["pest_name"] else "",
|
| 522 |
+
"treatment": r["treatment"] if r["treatment"] else "",
|
| 523 |
+
"created_at": r["created_at"]
|
| 524 |
+
})
|
| 525 |
+
return {"status": "success", "reports": reports}
|
| 526 |
+
except Exception as e:
|
| 527 |
+
raise HTTPException(status_code=500, detail=f"Database error: {e}")
|
| 528 |
+
|
| 529 |
+
@app.get("/admin", response_class=HTMLResponse)
|
| 530 |
+
def admin_dashboard():
|
| 531 |
+
admin_html_file = BASE_DIR / "admin.html"
|
| 532 |
+
if admin_html_file.exists():
|
| 533 |
+
with open(admin_html_file, "r", encoding="utf-8") as f:
|
| 534 |
+
return HTMLResponse(content=f.read())
|
| 535 |
+
return HTMLResponse(content="<h1>Admin Dashboard Template Missing</h1>")
|
| 536 |
+
|
| 537 |
+
@app.get("/admin/api/reports")
|
| 538 |
+
def admin_get_reports():
|
| 539 |
+
try:
|
| 540 |
+
conn = sqlite3.connect(str(DB_FILE), timeout=30.0)
|
| 541 |
+
conn.row_factory = sqlite3.Row
|
| 542 |
+
cursor = conn.cursor()
|
| 543 |
+
cursor.execute("SELECT id, device_id, image_path, status, pest_name, treatment, created_at FROM unrecognized_reports ORDER BY created_at DESC")
|
| 544 |
+
rows = cursor.fetchall()
|
| 545 |
+
conn.close()
|
| 546 |
+
|
| 547 |
+
reports = []
|
| 548 |
+
for r in rows:
|
| 549 |
+
reports.append({
|
| 550 |
+
"id": r["id"],
|
| 551 |
+
"device_id": r["device_id"],
|
| 552 |
+
"image_path": r["image_path"],
|
| 553 |
+
"status": r["status"],
|
| 554 |
+
"pest_name": r["pest_name"] if r["pest_name"] else "",
|
| 555 |
+
"treatment": r["treatment"] if r["treatment"] else "",
|
| 556 |
+
"created_at": r["created_at"]
|
| 557 |
+
})
|
| 558 |
+
return {"status": "success", "reports": reports}
|
| 559 |
+
except Exception as e:
|
| 560 |
+
raise HTTPException(status_code=500, detail=f"Database error: {e}")
|
| 561 |
+
|
| 562 |
+
@app.post("/admin/api/resolve/{report_id}")
|
| 563 |
+
def admin_resolve_report(
|
| 564 |
+
report_id: int,
|
| 565 |
+
pest_name: str = Form(...),
|
| 566 |
+
treatment: str = Form(...)
|
| 567 |
+
):
|
| 568 |
+
try:
|
| 569 |
+
conn = sqlite3.connect(str(DB_FILE), timeout=30.0)
|
| 570 |
+
cursor = conn.cursor()
|
| 571 |
+
cursor.execute(
|
| 572 |
+
"UPDATE unrecognized_reports SET status = 'resolved', pest_name = ?, treatment = ? WHERE id = ?",
|
| 573 |
+
(pest_name, treatment, report_id)
|
| 574 |
+
)
|
| 575 |
+
conn.commit()
|
| 576 |
+
conn.close()
|
| 577 |
+
return {"status": "success", "message": f"Report {report_id} resolved successfully"}
|
| 578 |
+
except Exception as e:
|
| 579 |
+
raise HTTPException(status_code=500, detail=f"Database error: {e}")
|
| 580 |
+
|
| 581 |
+
@app.get("/admin/image/{filename}")
|
| 582 |
+
def admin_serve_image(filename: str):
|
| 583 |
+
file_path = BASE_DIR / "uploads" / "unrecognized" / filename
|
| 584 |
+
if file_path.exists():
|
| 585 |
+
return FileResponse(str(file_path))
|
| 586 |
+
raise HTTPException(status_code=404, detail="Image not found")
|
| 587 |
+
|
| 588 |
+
|
| 589 |
if __name__ == "__main__":
|
| 590 |
import uvicorn
|
| 591 |
port = int(os.environ.get("PORT", 5000))
|
backend/pest_detection.db
ADDED
|
Binary file (12.3 kB). View file
|
|
|