coderuday21 commited on
Commit
c2dff01
·
1 Parent(s): 2b175bf

Detection tuning controls + threshold diagnostics in API response

Browse files
Files changed (4) hide show
  1. app/detection_engine.py +92 -22
  2. app/main.py +15 -1
  3. static/js/app.js +26 -1
  4. templates/index.html +10 -0
app/detection_engine.py CHANGED
@@ -201,7 +201,27 @@ def compute_edge_change(img1, img2):
201
  # 7. Improved detection methods
202
  # ---------------------------------------------------------------------------
203
 
204
- def image_difference_method(img1, img2, threshold=0.25, blur_size=5):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
205
  """Improved image difference with multi-channel analysis and adaptive threshold."""
206
  if img1.shape != img2.shape:
207
  img2 = cv2.resize(img2, (img1.shape[1], img1.shape[0]))
@@ -223,13 +243,19 @@ def image_difference_method(img1, img2, threshold=0.25, blur_size=5):
223
  delta_e = delta_e / delta_e.max() if delta_e.max() > 0 else delta_e
224
 
225
  delta_uint8 = (delta_e * 255).astype(np.uint8)
226
- otsu_val, change_mask = cv2.threshold(delta_uint8, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
227
- # Floor: if Otsu picks a very low threshold the mask is mostly noise
228
- if otsu_val < 30:
229
- _, change_mask = cv2.threshold(delta_uint8, 30, 255, cv2.THRESH_BINARY)
230
 
231
  change_mask = _clean_mask(change_mask)
232
- return change_mask
 
 
 
 
 
 
 
233
 
234
 
235
  def feature_based_method(img1, img2, num_clusters=4, sensitivity=0.5):
@@ -288,7 +314,7 @@ def feature_based_method(img1, img2, num_clusters=4, sensitivity=0.5):
288
  return change_mask
289
 
290
 
291
- def ai_deep_learning_method(img1, img2):
292
  """
293
  Advanced multi-signal fusion:
294
  - Multi-scale color difference (LAB)
@@ -358,10 +384,10 @@ def ai_deep_learning_method(img1, img2):
358
  fused = fused / (fused.max() + 1e-8)
359
  fused_uint8 = (fused * 255).astype(np.uint8)
360
 
361
- # Otsu with a minimum floor to reject near-zero thresholds on similar images
362
- otsu_val, change_mask = cv2.threshold(fused_uint8, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
363
- if otsu_val < 25:
364
- _, change_mask = cv2.threshold(fused_uint8, 25, 255, cv2.THRESH_BINARY)
365
 
366
  change_mask = _clean_mask(change_mask)
367
 
@@ -369,17 +395,24 @@ def ai_deep_learning_method(img1, img2):
369
  change_mask = cv2.bilateralFilter(change_mask, 9, 75, 75)
370
  _, change_mask = cv2.threshold(change_mask, 127, 255, cv2.THRESH_BINARY)
371
 
372
- return change_mask
 
 
 
 
 
 
 
373
 
374
 
375
- def hybrid_method(img1, img2):
376
  """Hybrid: weighted fusion of all methods with confidence-based merging."""
377
  if img1.shape != img2.shape:
378
  img2 = cv2.resize(img2, (img1.shape[1], img1.shape[0]))
379
 
380
- diff_mask = image_difference_method(img1, img2)
381
  feature_mask = feature_based_method(img1, img2)
382
- ai_mask = ai_deep_learning_method(img1, img2)
383
 
384
  # Weighted combination: AI method gets most weight
385
  combined = (
@@ -389,9 +422,21 @@ def hybrid_method(img1, img2):
389
  )
390
 
391
  # Use a higher threshold: a pixel must be flagged by multiple methods
392
- _, final_mask = cv2.threshold(combined.astype(np.uint8), 140, 255, cv2.THRESH_BINARY)
 
 
 
393
  final_mask = _clean_mask(final_mask)
394
- return final_mask
 
 
 
 
 
 
 
 
 
395
 
396
 
397
  # ---------------------------------------------------------------------------
@@ -1470,6 +1515,11 @@ def analyze_change_regions(change_mask, image, min_area=400, use_ensemble=True,
1470
 
1471
  img_h, img_w = change_mask.shape[:2]
1472
  img_area = img_h * img_w
 
 
 
 
 
1473
 
1474
  for i in range(1, num_labels):
1475
  raw_area = stats[i, cv2.CC_STAT_AREA]
@@ -1559,7 +1609,8 @@ def analyze_change_regions(change_mask, image, min_area=400, use_ensemble=True,
1559
  # ---------------------------------------------------------------------------
1560
 
1561
  def run_detection(before_pil, after_pil, method="AI-Based Deep Learning",
1562
- enable_registration=True, enable_normalization=True):
 
1563
  """Run full detection pipeline; returns change_mask, result_image, stats, regions."""
1564
  before_array = preprocess_image(before_pil)
1565
  after_array = preprocess_image(after_pil)
@@ -1570,16 +1621,28 @@ def run_detection(before_pil, after_pil, method="AI-Based Deep Learning",
1570
  before_array, after_array = normalize_radiometry(before_array, after_array)
1571
 
1572
  if method == "AI-Based Deep Learning":
1573
- change_mask = ai_deep_learning_method(before_array, after_array)
 
 
1574
  elif method == "Image Difference":
1575
- change_mask = image_difference_method(before_array, after_array)
 
 
1576
  elif method == "Feature-Based":
1577
  change_mask = feature_based_method(before_array, after_array)
 
 
 
 
 
 
1578
  else:
1579
- change_mask = hybrid_method(before_array, after_array)
 
 
1580
 
1581
  change_regions = analyze_change_regions(
1582
- change_mask, after_array, min_area=400, before_img=before_array
1583
  )
1584
 
1585
  total_pixels = int(change_mask.shape[0] * change_mask.shape[1])
@@ -1597,6 +1660,13 @@ def run_detection(before_pil, after_pil, method="AI-Based Deep Learning",
1597
  "change_percentage": change_pct,
1598
  "image_width": change_mask.shape[1],
1599
  "image_height": change_mask.shape[0],
 
 
 
 
 
 
 
1600
  }
1601
 
1602
  return change_mask, result_image, stats, change_regions
 
201
  # 7. Improved detection methods
202
  # ---------------------------------------------------------------------------
203
 
204
+ def _adaptive_binary_threshold(score_uint8, min_floor=25, sensitivity=0.5):
205
+ """
206
+ Robust thresholding for noisy scenes.
207
+ Uses max(Otsu, noise-floor, fixed floor) where noise-floor is median + 3*MAD.
208
+ """
209
+ otsu_val, _ = cv2.threshold(
210
+ score_uint8, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU
211
+ )
212
+ median = float(np.median(score_uint8))
213
+ mad = float(np.median(np.abs(score_uint8.astype(np.float32) - median)))
214
+ noise_floor = median + 3.0 * mad
215
+ # Higher sensitivity => lower threshold (detect more), lower sensitivity => stricter
216
+ sens = float(np.clip(sensitivity, 0.0, 1.0))
217
+ sens_shift = int((0.5 - sens) * 24) # approx -12..+12 around baseline
218
+ thr = int(max(min_floor, otsu_val, noise_floor) + sens_shift)
219
+ thr = max(0, min(255, thr))
220
+ _, mask = cv2.threshold(score_uint8, thr, 255, cv2.THRESH_BINARY)
221
+ return mask, thr, float(otsu_val), float(noise_floor)
222
+
223
+
224
+ def image_difference_method(img1, img2, threshold=0.25, blur_size=5, sensitivity=0.5):
225
  """Improved image difference with multi-channel analysis and adaptive threshold."""
226
  if img1.shape != img2.shape:
227
  img2 = cv2.resize(img2, (img1.shape[1], img1.shape[0]))
 
243
  delta_e = delta_e / delta_e.max() if delta_e.max() > 0 else delta_e
244
 
245
  delta_uint8 = (delta_e * 255).astype(np.uint8)
246
+ change_mask, used_thr, otsu_val, noise_floor = _adaptive_binary_threshold(
247
+ delta_uint8, min_floor=30, sensitivity=sensitivity
248
+ )
 
249
 
250
  change_mask = _clean_mask(change_mask)
251
+ debug = {
252
+ "method": "Image Difference",
253
+ "threshold_used": int(used_thr),
254
+ "otsu": float(otsu_val),
255
+ "noise_floor": float(noise_floor),
256
+ "sensitivity": float(sensitivity),
257
+ }
258
+ return change_mask, debug
259
 
260
 
261
  def feature_based_method(img1, img2, num_clusters=4, sensitivity=0.5):
 
314
  return change_mask
315
 
316
 
317
+ def ai_deep_learning_method(img1, img2, sensitivity=0.5):
318
  """
319
  Advanced multi-signal fusion:
320
  - Multi-scale color difference (LAB)
 
384
  fused = fused / (fused.max() + 1e-8)
385
  fused_uint8 = (fused * 255).astype(np.uint8)
386
 
387
+ # Robust thresholding: handles low-contrast and noisy scenes better than Otsu-only.
388
+ change_mask, used_thr, otsu_val, noise_floor = _adaptive_binary_threshold(
389
+ fused_uint8, min_floor=25, sensitivity=sensitivity
390
+ )
391
 
392
  change_mask = _clean_mask(change_mask)
393
 
 
395
  change_mask = cv2.bilateralFilter(change_mask, 9, 75, 75)
396
  _, change_mask = cv2.threshold(change_mask, 127, 255, cv2.THRESH_BINARY)
397
 
398
+ debug = {
399
+ "method": "AI-Based Deep Learning",
400
+ "threshold_used": int(used_thr),
401
+ "otsu": float(otsu_val),
402
+ "noise_floor": float(noise_floor),
403
+ "sensitivity": float(sensitivity),
404
+ }
405
+ return change_mask, debug
406
 
407
 
408
+ def hybrid_method(img1, img2, sensitivity=0.5):
409
  """Hybrid: weighted fusion of all methods with confidence-based merging."""
410
  if img1.shape != img2.shape:
411
  img2 = cv2.resize(img2, (img1.shape[1], img1.shape[0]))
412
 
413
+ diff_mask, diff_debug = image_difference_method(img1, img2, sensitivity=sensitivity)
414
  feature_mask = feature_based_method(img1, img2)
415
+ ai_mask, ai_debug = ai_deep_learning_method(img1, img2, sensitivity=sensitivity)
416
 
417
  # Weighted combination: AI method gets most weight
418
  combined = (
 
422
  )
423
 
424
  # Use a higher threshold: a pixel must be flagged by multiple methods
425
+ base_thr = 140
426
+ sens = float(np.clip(sensitivity, 0.0, 1.0))
427
+ hybrid_thr = int(np.clip(base_thr + int((0.5 - sens) * 24), 90, 180))
428
+ _, final_mask = cv2.threshold(combined.astype(np.uint8), hybrid_thr, 255, cv2.THRESH_BINARY)
429
  final_mask = _clean_mask(final_mask)
430
+ debug = {
431
+ "method": "Hybrid Approach",
432
+ "threshold_used": int(hybrid_thr),
433
+ "sensitivity": float(sensitivity),
434
+ "sub_methods": {
435
+ "image_difference": diff_debug,
436
+ "ai_deep_learning": ai_debug,
437
+ },
438
+ }
439
+ return final_mask, debug
440
 
441
 
442
  # ---------------------------------------------------------------------------
 
1515
 
1516
  img_h, img_w = change_mask.shape[:2]
1517
  img_area = img_h * img_w
1518
+ # Adaptive minimum region size:
1519
+ # - keeps sensitivity on smaller images
1520
+ # - suppresses speckle noise on larger images
1521
+ if min_area is None:
1522
+ min_area = int(max(250, min(1200, img_area * 0.00008)))
1523
 
1524
  for i in range(1, num_labels):
1525
  raw_area = stats[i, cv2.CC_STAT_AREA]
 
1609
  # ---------------------------------------------------------------------------
1610
 
1611
  def run_detection(before_pil, after_pil, method="AI-Based Deep Learning",
1612
+ enable_registration=True, enable_normalization=True,
1613
+ detection_sensitivity=0.5, min_region_area=None):
1614
  """Run full detection pipeline; returns change_mask, result_image, stats, regions."""
1615
  before_array = preprocess_image(before_pil)
1616
  after_array = preprocess_image(after_pil)
 
1621
  before_array, after_array = normalize_radiometry(before_array, after_array)
1622
 
1623
  if method == "AI-Based Deep Learning":
1624
+ change_mask, threshold_debug = ai_deep_learning_method(
1625
+ before_array, after_array, sensitivity=detection_sensitivity
1626
+ )
1627
  elif method == "Image Difference":
1628
+ change_mask, threshold_debug = image_difference_method(
1629
+ before_array, after_array, sensitivity=detection_sensitivity
1630
+ )
1631
  elif method == "Feature-Based":
1632
  change_mask = feature_based_method(before_array, after_array)
1633
+ threshold_debug = {
1634
+ "method": "Feature-Based",
1635
+ "threshold_used": None,
1636
+ "note": "KMeans clustering path does not use binary threshold.",
1637
+ "sensitivity": float(detection_sensitivity),
1638
+ }
1639
  else:
1640
+ change_mask, threshold_debug = hybrid_method(
1641
+ before_array, after_array, sensitivity=detection_sensitivity
1642
+ )
1643
 
1644
  change_regions = analyze_change_regions(
1645
+ change_mask, after_array, min_area=min_region_area, before_img=before_array
1646
  )
1647
 
1648
  total_pixels = int(change_mask.shape[0] * change_mask.shape[1])
 
1660
  "change_percentage": change_pct,
1661
  "image_width": change_mask.shape[1],
1662
  "image_height": change_mask.shape[0],
1663
+ "threshold_debug": threshold_debug,
1664
+ "params": {
1665
+ "detection_sensitivity": float(detection_sensitivity),
1666
+ "min_region_area": min_region_area,
1667
+ "enable_registration": bool(enable_registration),
1668
+ "enable_normalization": bool(enable_normalization),
1669
+ },
1670
  }
1671
 
1672
  return change_mask, result_image, stats, change_regions
app/main.py CHANGED
@@ -230,6 +230,8 @@ async def detect(
230
  village: str = Form(""),
231
  enable_registration: bool = Form(True),
232
  enable_normalization: bool = Form(True),
 
 
233
  notify_email: Optional[str] = Form(None),
234
  access_token: Optional[str] = Form(None),
235
  db: Session = Depends(get_db),
@@ -259,9 +261,19 @@ async def detect(
259
  raise
260
  except Exception as e:
261
  raise HTTPException(status_code=400, detail=f"Invalid image: {e}")
 
 
 
 
262
  from .detection_engine import run_detection
263
  change_mask, result_image, stats, change_regions = run_detection(
264
- before_pil, after_pil, method=method, enable_registration=enable_registration, enable_normalization=enable_normalization
 
 
 
 
 
 
265
  )
266
  # Save overlay and thumbnails for history table view
267
  base_name = f"{user.id}_{uuid.uuid4().hex}"
@@ -370,6 +382,8 @@ async def detect(
370
  "changedPixels": changed_px,
371
  "unchangedPixels": unchanged_px,
372
  "changePercentage": change_pct,
 
 
373
  },
374
  "regions": regions_serializable,
375
  "overlayBase64Png": overlay_b64,
 
230
  village: str = Form(""),
231
  enable_registration: bool = Form(True),
232
  enable_normalization: bool = Form(True),
233
+ detection_sensitivity: float = Form(0.5),
234
+ min_region_area: Optional[int] = Form(None),
235
  notify_email: Optional[str] = Form(None),
236
  access_token: Optional[str] = Form(None),
237
  db: Session = Depends(get_db),
 
261
  raise
262
  except Exception as e:
263
  raise HTTPException(status_code=400, detail=f"Invalid image: {e}")
264
+ detection_sensitivity = max(0.0, min(1.0, float(detection_sensitivity)))
265
+ if min_region_area is not None:
266
+ min_region_area = int(max(50, min(10000, min_region_area)))
267
+
268
  from .detection_engine import run_detection
269
  change_mask, result_image, stats, change_regions = run_detection(
270
+ before_pil,
271
+ after_pil,
272
+ method=method,
273
+ enable_registration=enable_registration,
274
+ enable_normalization=enable_normalization,
275
+ detection_sensitivity=detection_sensitivity,
276
+ min_region_area=min_region_area,
277
  )
278
  # Save overlay and thumbnails for history table view
279
  base_name = f"{user.id}_{uuid.uuid4().hex}"
 
382
  "changedPixels": changed_px,
383
  "unchangedPixels": unchanged_px,
384
  "changePercentage": change_pct,
385
+ "thresholdDebug": stats.get("threshold_debug", {}),
386
+ "params": stats.get("params", {}),
387
  },
388
  "regions": regions_serializable,
389
  "overlayBase64Png": overlay_b64,
static/js/app.js CHANGED
@@ -416,6 +416,29 @@ document.getElementById('form-detect')?.addEventListener('submit', async (e) =>
416
  form.append('village', document.getElementById('detect-village').value || '');
417
  form.append('enable_registration', document.getElementById('detect-registration').checked);
418
  form.append('enable_normalization', document.getElementById('detect-normalization').checked);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
419
 
420
  // Notify: validate and attach email if checkbox is checked
421
  const notifyCb = document.getElementById('detect-notify');
@@ -449,7 +472,9 @@ document.getElementById('form-detect')?.addEventListener('submit', async (e) =>
449
  ? ' Notification email sent.'
450
  : ` Email notification failed${data.notificationError ? `: ${data.notificationError}` : '.'}`;
451
  }
452
- showSuccess('dashboard-success', 'Detection complete!' + notifyMsg);
 
 
453
  loadHistory();
454
  } catch (err) {
455
  showError('dashboard-error', err.message);
 
416
  form.append('village', document.getElementById('detect-village').value || '');
417
  form.append('enable_registration', document.getElementById('detect-registration').checked);
418
  form.append('enable_normalization', document.getElementById('detect-normalization').checked);
419
+ const sensitivityInput = document.getElementById('detect-sensitivity');
420
+ const minAreaInput = document.getElementById('detect-min-area');
421
+ const sensitivity = Number(sensitivityInput?.value ?? 0.5);
422
+ if (Number.isNaN(sensitivity) || sensitivity < 0 || sensitivity > 1) {
423
+ showError('dashboard-error', 'Detection sensitivity must be between 0 and 1.');
424
+ btn.disabled = false;
425
+ loading.classList.add('hidden');
426
+ stopDetectionProgress(false);
427
+ return;
428
+ }
429
+ form.append('detection_sensitivity', String(sensitivity));
430
+ const minAreaRaw = (minAreaInput?.value || '').trim();
431
+ if (minAreaRaw) {
432
+ const minArea = Number(minAreaRaw);
433
+ if (Number.isNaN(minArea) || minArea < 50) {
434
+ showError('dashboard-error', 'Min region area must be at least 50.');
435
+ btn.disabled = false;
436
+ loading.classList.add('hidden');
437
+ stopDetectionProgress(false);
438
+ return;
439
+ }
440
+ form.append('min_region_area', String(Math.round(minArea)));
441
+ }
442
 
443
  // Notify: validate and attach email if checkbox is checked
444
  const notifyCb = document.getElementById('detect-notify');
 
472
  ? ' Notification email sent.'
473
  : ` Email notification failed${data.notificationError ? `: ${data.notificationError}` : '.'}`;
474
  }
475
+ const thrInfo = data?.statistics?.thresholdDebug?.threshold_used;
476
+ const thrMsg = typeof thrInfo === 'number' ? ` Threshold: ${thrInfo}.` : '';
477
+ showSuccess('dashboard-success', 'Detection complete!' + thrMsg + notifyMsg);
478
  loadHistory();
479
  } catch (err) {
480
  showError('dashboard-error', err.message);
templates/index.html CHANGED
@@ -219,6 +219,16 @@
219
  <label><input type="checkbox" id="detect-normalization" checked /> Radiometric Normalization</label>
220
  </div>
221
  </div>
 
 
 
 
 
 
 
 
 
 
222
  <div class="notify-row">
223
  <div class="form-group checkbox-group">
224
  <label><input type="checkbox" id="detect-notify" /> Notify via Email</label>
 
219
  <label><input type="checkbox" id="detect-normalization" checked /> Radiometric Normalization</label>
220
  </div>
221
  </div>
222
+ <div class="options-row">
223
+ <div class="form-group">
224
+ <label for="detect-sensitivity">Detection Sensitivity (0-1)</label>
225
+ <input type="number" id="detect-sensitivity" min="0" max="1" step="0.05" value="0.5" />
226
+ </div>
227
+ <div class="form-group">
228
+ <label for="detect-min-area">Min Region Area (px, optional)</label>
229
+ <input type="number" id="detect-min-area" min="50" max="10000" step="50" placeholder="Auto" />
230
+ </div>
231
+ </div>
232
  <div class="notify-row">
233
  <div class="form-group checkbox-group">
234
  <label><input type="checkbox" id="detect-notify" /> Notify via Email</label>