Fadhili Sumaye commited on
Commit
23f3fa4
·
1 Parent(s): 28d7022

Add unrecognized pest reporting system and admin dashboard

Browse files
.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
- resultText.setText(pest + " (" + (int) (confidence * 100) + "%)");
347
- treatmentText.setText(treatment);
 
 
 
 
 
 
 
 
 
 
 
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/tvAppSubtitle"
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>&copy; 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
- ip = request.client.host if request.client else "unknown"
 
 
 
 
 
 
 
251
 
252
  # Rate limiting check (excludes development environments)
253
- if ip not in ("127.0.0.1", "10.0.2.2", "localhost", "unknown"):
 
 
 
 
 
 
 
 
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