Update app.py
Browse files
app.py
CHANGED
|
@@ -634,59 +634,69 @@ def analyze_audio_segments(y, sr, model, segment_duration=10):
|
|
| 634 |
return segment_results
|
| 635 |
|
| 636 |
|
| 637 |
-
# ========================================
|
| 638 |
-
# HYBRID APNEA DETECTION - NEW!
|
| 639 |
-
# ========================================
|
| 640 |
# ========================================
|
| 641 |
# HYBRID APNEA DETECTION - RELAXED VALIDATION
|
| 642 |
# ========================================
|
| 643 |
-
def count_precise_apneas(y, sr, segment_results, min_apnea=
|
| 644 |
"""
|
| 645 |
HYBRID APPROACH - Detects apnea ONLY between snoring periods
|
| 646 |
-
|
| 647 |
-
|
|
|
|
|
|
|
| 648 |
Risk Levels:
|
| 649 |
- 10-15 seconds: LOW
|
| 650 |
- 15-20 seconds: MEDIUM
|
| 651 |
- 20+ seconds: HIGH
|
| 652 |
-
|
| 653 |
Returns: (apnea_count, apnea_events_for_annotations)
|
| 654 |
"""
|
| 655 |
print("\n🎯 APNEA DETECTION (Only between SNORING)")
|
| 656 |
print(" Stage 1: Identifying confirmed snoring periods...")
|
| 657 |
-
|
| 658 |
# STAGE 1: Get all confirmed snoring segments from segment_results
|
|
|
|
|
|
|
|
|
|
|
|
|
| 659 |
snoring_periods = []
|
| 660 |
-
|
| 661 |
-
for seg in segment_results:
|
| 662 |
if seg['is_snoring']: # Only HIGH and MEDIUM confidence snores
|
| 663 |
snoring_periods.append({
|
| 664 |
'start': seg['start_time'],
|
| 665 |
'end': seg['end_time'],
|
| 666 |
'confidence': seg['confidence_level']
|
| 667 |
})
|
| 668 |
-
|
| 669 |
print(f" Found {len(snoring_periods)} snoring periods")
|
| 670 |
-
|
| 671 |
if len(snoring_periods) < 2:
|
| 672 |
print(" ⚠️ Need at least 2 snoring periods to detect apnea")
|
| 673 |
return 0, []
|
| 674 |
-
|
| 675 |
# STAGE 2: Find gaps BETWEEN snoring periods
|
| 676 |
-
|
| 677 |
-
|
|
|
|
|
|
|
|
|
|
| 678 |
apnea_candidates = []
|
| 679 |
-
|
| 680 |
for i in range(len(snoring_periods) - 1):
|
| 681 |
current_snore = snoring_periods[i]
|
| 682 |
next_snore = snoring_periods[i + 1]
|
| 683 |
-
|
| 684 |
# Calculate gap between end of current snore and start of next snore
|
| 685 |
gap_start = current_snore['end']
|
| 686 |
gap_end = next_snore['start']
|
| 687 |
gap_duration = gap_end - gap_start
|
| 688 |
-
|
| 689 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 690 |
if min_apnea <= gap_duration <= max_apnea:
|
| 691 |
apnea_candidates.append({
|
| 692 |
'start': gap_start,
|
|
@@ -696,45 +706,48 @@ def count_precise_apneas(y, sr, segment_results, min_apnea=10, max_apnea=90):
|
|
| 696 |
'after_snore': next_snore
|
| 697 |
})
|
| 698 |
print(f" Found gap: {gap_start:.1f}s → {gap_end:.1f}s (duration: {gap_duration:.1f}s)")
|
| 699 |
-
|
|
|
|
|
|
|
|
|
|
| 700 |
print(f" Found {len(apnea_candidates)} potential apnea candidates")
|
| 701 |
-
|
| 702 |
# STAGE 3: Validate with ML model - RELAXED THRESHOLDS
|
| 703 |
validated_apneas = []
|
| 704 |
-
|
| 705 |
if len(apnea_candidates) > 0:
|
| 706 |
if finetuned_snore_model is not None:
|
| 707 |
print(" Stage 3: ML validation (RELAXED - confirming reduced activity in gap)...")
|
| 708 |
-
|
| 709 |
for idx, candidate in enumerate(apnea_candidates):
|
| 710 |
# Extract gap audio
|
| 711 |
gap_start_sample = int(candidate['start'] * sr)
|
| 712 |
gap_end_sample = int(candidate['end'] * sr)
|
| 713 |
gap_audio = y[gap_start_sample:gap_end_sample]
|
| 714 |
-
|
| 715 |
try:
|
| 716 |
# Resample to 22050 for model
|
| 717 |
if sr != SAMPLE_RATE:
|
| 718 |
gap_audio = librosa.resample(gap_audio, orig_sr=sr, target_sr=SAMPLE_RATE)
|
| 719 |
-
|
| 720 |
# Validate that the gap has REDUCED snoring activity
|
| 721 |
gap_prob = predict_snoring_segment(gap_audio, SAMPLE_RATE, finetuned_snore_model) if len(gap_audio) > sr else 0
|
| 722 |
-
|
| 723 |
# RELAXED VALIDATION - Three levels of acceptance
|
| 724 |
-
|
| 725 |
# Level 1: Very silent gap (HIGH confidence apnea)
|
| 726 |
very_silent = gap_prob < 0.40
|
| 727 |
-
|
| 728 |
# Level 2: Moderately quiet gap (MEDIUM confidence apnea)
|
| 729 |
moderately_quiet = gap_prob < 0.65
|
| 730 |
-
|
| 731 |
# Level 3: Less snoring than before/after (LOW confidence apnea)
|
| 732 |
# Accept if gap has less activity than surrounding snoring
|
| 733 |
less_activity = gap_prob < 0.75
|
| 734 |
-
|
| 735 |
# Accept if ANY level matches
|
| 736 |
is_valid = very_silent or moderately_quiet or less_activity
|
| 737 |
-
|
| 738 |
if is_valid:
|
| 739 |
# Determine confidence based on gap silence
|
| 740 |
if very_silent:
|
|
@@ -743,7 +756,7 @@ def count_precise_apneas(y, sr, segment_results, min_apnea=10, max_apnea=90):
|
|
| 743 |
validation_confidence = "MEDIUM"
|
| 744 |
else:
|
| 745 |
validation_confidence = "LOW"
|
| 746 |
-
|
| 747 |
# Determine risk level based on duration
|
| 748 |
duration = candidate['duration']
|
| 749 |
if duration < 15:
|
|
@@ -752,7 +765,7 @@ def count_precise_apneas(y, sr, segment_results, min_apnea=10, max_apnea=90):
|
|
| 752 |
risk_level = "MEDIUM"
|
| 753 |
else: # >= 20 seconds
|
| 754 |
risk_level = "HIGH"
|
| 755 |
-
|
| 756 |
validated_apneas.append({
|
| 757 |
'label': 'APNEA',
|
| 758 |
'start_sec': round(candidate['start'], 2),
|
|
@@ -771,10 +784,10 @@ def count_precise_apneas(y, sr, segment_results, min_apnea=10, max_apnea=90):
|
|
| 771 |
else:
|
| 772 |
print(f" ❌ REJECTED: {candidate['start']:.1f}s → {candidate['end']:.1f}s "
|
| 773 |
f"(Gap has too much snoring: {gap_prob:.2f})")
|
| 774 |
-
|
| 775 |
except Exception as e:
|
| 776 |
print(f" ⚠️ Validation error for candidate {idx+1}: {e}")
|
| 777 |
-
|
| 778 |
# On error, accept gaps >= 12 seconds
|
| 779 |
if candidate['duration'] >= 12:
|
| 780 |
duration = candidate['duration']
|
|
@@ -784,7 +797,7 @@ def count_precise_apneas(y, sr, segment_results, min_apnea=10, max_apnea=90):
|
|
| 784 |
risk_level = "MEDIUM"
|
| 785 |
else:
|
| 786 |
risk_level = "HIGH"
|
| 787 |
-
|
| 788 |
validated_apneas.append({
|
| 789 |
'label': 'APNEA',
|
| 790 |
'start_sec': round(candidate['start'], 2),
|
|
@@ -805,7 +818,7 @@ def count_precise_apneas(y, sr, segment_results, min_apnea=10, max_apnea=90):
|
|
| 805 |
risk_level = "MEDIUM"
|
| 806 |
else:
|
| 807 |
risk_level = "HIGH"
|
| 808 |
-
|
| 809 |
validated_apneas.append({
|
| 810 |
'label': 'APNEA',
|
| 811 |
'start_sec': round(candidate['start'], 2),
|
|
@@ -813,9 +826,9 @@ def count_precise_apneas(y, sr, segment_results, min_apnea=10, max_apnea=90):
|
|
| 813 |
'duration': round(candidate['duration'], 2),
|
| 814 |
'risk_level': risk_level
|
| 815 |
})
|
| 816 |
-
|
| 817 |
print(f" ✅ Final: {len(validated_apneas)} validated apnea events\n")
|
| 818 |
-
|
| 819 |
return len(validated_apneas), validated_apneas
|
| 820 |
|
| 821 |
|
|
@@ -983,13 +996,14 @@ async def analyze_audio(file: UploadFile = File(...)):
|
|
| 983 |
|
| 984 |
annotations.append(annotation)
|
| 985 |
|
| 986 |
-
# NEW: HYBRID APNEA DETECTION
|
|
|
|
| 987 |
apnea_count, apnea_events = count_precise_apneas(
|
| 988 |
y_clean,
|
| 989 |
sr,
|
| 990 |
segment_results,
|
| 991 |
-
min_apnea=
|
| 992 |
-
max_apnea=
|
| 993 |
)
|
| 994 |
|
| 995 |
# Add apnea events to annotations
|
|
|
|
| 634 |
return segment_results
|
| 635 |
|
| 636 |
|
|
|
|
|
|
|
|
|
|
| 637 |
# ========================================
|
| 638 |
# HYBRID APNEA DETECTION - RELAXED VALIDATION
|
| 639 |
# ========================================
|
| 640 |
+
def count_precise_apneas(y, sr, segment_results, min_apnea=1, max_apnea=60):
|
| 641 |
"""
|
| 642 |
HYBRID APPROACH - Detects apnea ONLY between snoring periods
|
| 643 |
+
FIXED: Apnea is only valid when there is confirmed snoring IMMEDIATELY
|
| 644 |
+
before AND immediately after the gap (consecutive snoring periods).
|
| 645 |
+
Gap silence range: 1 to 60 seconds.
|
| 646 |
+
|
| 647 |
Risk Levels:
|
| 648 |
- 10-15 seconds: LOW
|
| 649 |
- 15-20 seconds: MEDIUM
|
| 650 |
- 20+ seconds: HIGH
|
| 651 |
+
|
| 652 |
Returns: (apnea_count, apnea_events_for_annotations)
|
| 653 |
"""
|
| 654 |
print("\n🎯 APNEA DETECTION (Only between SNORING)")
|
| 655 |
print(" Stage 1: Identifying confirmed snoring periods...")
|
| 656 |
+
|
| 657 |
# STAGE 1: Get all confirmed snoring segments from segment_results
|
| 658 |
+
# Build a full ordered list of all segments with their snoring status
|
| 659 |
+
# so we can check immediate neighbors
|
| 660 |
+
all_segments_ordered = sorted(segment_results, key=lambda s: s['start_time'])
|
| 661 |
+
|
| 662 |
snoring_periods = []
|
| 663 |
+
for seg in all_segments_ordered:
|
|
|
|
| 664 |
if seg['is_snoring']: # Only HIGH and MEDIUM confidence snores
|
| 665 |
snoring_periods.append({
|
| 666 |
'start': seg['start_time'],
|
| 667 |
'end': seg['end_time'],
|
| 668 |
'confidence': seg['confidence_level']
|
| 669 |
})
|
| 670 |
+
|
| 671 |
print(f" Found {len(snoring_periods)} snoring periods")
|
| 672 |
+
|
| 673 |
if len(snoring_periods) < 2:
|
| 674 |
print(" ⚠️ Need at least 2 snoring periods to detect apnea")
|
| 675 |
return 0, []
|
| 676 |
+
|
| 677 |
# STAGE 2: Find gaps BETWEEN snoring periods
|
| 678 |
+
# FIXED: Only consider CONSECUTIVE snoring periods (i and i+1 in the
|
| 679 |
+
# snoring_periods list). This guarantees that the segment immediately
|
| 680 |
+
# before the gap is snoring AND the segment immediately after is snoring.
|
| 681 |
+
print(" Stage 2: Finding gaps between consecutive snoring periods...")
|
| 682 |
+
|
| 683 |
apnea_candidates = []
|
| 684 |
+
|
| 685 |
for i in range(len(snoring_periods) - 1):
|
| 686 |
current_snore = snoring_periods[i]
|
| 687 |
next_snore = snoring_periods[i + 1]
|
| 688 |
+
|
| 689 |
# Calculate gap between end of current snore and start of next snore
|
| 690 |
gap_start = current_snore['end']
|
| 691 |
gap_end = next_snore['start']
|
| 692 |
gap_duration = gap_end - gap_start
|
| 693 |
+
|
| 694 |
+
# FIXED: The gap must be > 0 (there must actually be a silent gap
|
| 695 |
+
# between two snoring segments that are adjacent in the snoring list)
|
| 696 |
+
if gap_duration <= 0:
|
| 697 |
+
continue
|
| 698 |
+
|
| 699 |
+
# FIXED: Only consider gaps in the 1–60 second range
|
| 700 |
if min_apnea <= gap_duration <= max_apnea:
|
| 701 |
apnea_candidates.append({
|
| 702 |
'start': gap_start,
|
|
|
|
| 706 |
'after_snore': next_snore
|
| 707 |
})
|
| 708 |
print(f" Found gap: {gap_start:.1f}s → {gap_end:.1f}s (duration: {gap_duration:.1f}s)")
|
| 709 |
+
else:
|
| 710 |
+
print(f" Skipping gap: {gap_start:.1f}s → {gap_end:.1f}s "
|
| 711 |
+
f"(duration: {gap_duration:.1f}s) - outside 1-60s range")
|
| 712 |
+
|
| 713 |
print(f" Found {len(apnea_candidates)} potential apnea candidates")
|
| 714 |
+
|
| 715 |
# STAGE 3: Validate with ML model - RELAXED THRESHOLDS
|
| 716 |
validated_apneas = []
|
| 717 |
+
|
| 718 |
if len(apnea_candidates) > 0:
|
| 719 |
if finetuned_snore_model is not None:
|
| 720 |
print(" Stage 3: ML validation (RELAXED - confirming reduced activity in gap)...")
|
| 721 |
+
|
| 722 |
for idx, candidate in enumerate(apnea_candidates):
|
| 723 |
# Extract gap audio
|
| 724 |
gap_start_sample = int(candidate['start'] * sr)
|
| 725 |
gap_end_sample = int(candidate['end'] * sr)
|
| 726 |
gap_audio = y[gap_start_sample:gap_end_sample]
|
| 727 |
+
|
| 728 |
try:
|
| 729 |
# Resample to 22050 for model
|
| 730 |
if sr != SAMPLE_RATE:
|
| 731 |
gap_audio = librosa.resample(gap_audio, orig_sr=sr, target_sr=SAMPLE_RATE)
|
| 732 |
+
|
| 733 |
# Validate that the gap has REDUCED snoring activity
|
| 734 |
gap_prob = predict_snoring_segment(gap_audio, SAMPLE_RATE, finetuned_snore_model) if len(gap_audio) > sr else 0
|
| 735 |
+
|
| 736 |
# RELAXED VALIDATION - Three levels of acceptance
|
| 737 |
+
|
| 738 |
# Level 1: Very silent gap (HIGH confidence apnea)
|
| 739 |
very_silent = gap_prob < 0.40
|
| 740 |
+
|
| 741 |
# Level 2: Moderately quiet gap (MEDIUM confidence apnea)
|
| 742 |
moderately_quiet = gap_prob < 0.65
|
| 743 |
+
|
| 744 |
# Level 3: Less snoring than before/after (LOW confidence apnea)
|
| 745 |
# Accept if gap has less activity than surrounding snoring
|
| 746 |
less_activity = gap_prob < 0.75
|
| 747 |
+
|
| 748 |
# Accept if ANY level matches
|
| 749 |
is_valid = very_silent or moderately_quiet or less_activity
|
| 750 |
+
|
| 751 |
if is_valid:
|
| 752 |
# Determine confidence based on gap silence
|
| 753 |
if very_silent:
|
|
|
|
| 756 |
validation_confidence = "MEDIUM"
|
| 757 |
else:
|
| 758 |
validation_confidence = "LOW"
|
| 759 |
+
|
| 760 |
# Determine risk level based on duration
|
| 761 |
duration = candidate['duration']
|
| 762 |
if duration < 15:
|
|
|
|
| 765 |
risk_level = "MEDIUM"
|
| 766 |
else: # >= 20 seconds
|
| 767 |
risk_level = "HIGH"
|
| 768 |
+
|
| 769 |
validated_apneas.append({
|
| 770 |
'label': 'APNEA',
|
| 771 |
'start_sec': round(candidate['start'], 2),
|
|
|
|
| 784 |
else:
|
| 785 |
print(f" ❌ REJECTED: {candidate['start']:.1f}s → {candidate['end']:.1f}s "
|
| 786 |
f"(Gap has too much snoring: {gap_prob:.2f})")
|
| 787 |
+
|
| 788 |
except Exception as e:
|
| 789 |
print(f" ⚠️ Validation error for candidate {idx+1}: {e}")
|
| 790 |
+
|
| 791 |
# On error, accept gaps >= 12 seconds
|
| 792 |
if candidate['duration'] >= 12:
|
| 793 |
duration = candidate['duration']
|
|
|
|
| 797 |
risk_level = "MEDIUM"
|
| 798 |
else:
|
| 799 |
risk_level = "HIGH"
|
| 800 |
+
|
| 801 |
validated_apneas.append({
|
| 802 |
'label': 'APNEA',
|
| 803 |
'start_sec': round(candidate['start'], 2),
|
|
|
|
| 818 |
risk_level = "MEDIUM"
|
| 819 |
else:
|
| 820 |
risk_level = "HIGH"
|
| 821 |
+
|
| 822 |
validated_apneas.append({
|
| 823 |
'label': 'APNEA',
|
| 824 |
'start_sec': round(candidate['start'], 2),
|
|
|
|
| 826 |
'duration': round(candidate['duration'], 2),
|
| 827 |
'risk_level': risk_level
|
| 828 |
})
|
| 829 |
+
|
| 830 |
print(f" ✅ Final: {len(validated_apneas)} validated apnea events\n")
|
| 831 |
+
|
| 832 |
return len(validated_apneas), validated_apneas
|
| 833 |
|
| 834 |
|
|
|
|
| 996 |
|
| 997 |
annotations.append(annotation)
|
| 998 |
|
| 999 |
+
# NEW: HYBRID APNEA DETECTION
|
| 1000 |
+
# FIXED: min_apnea=1, max_apnea=60 (1 to 60 second gaps only)
|
| 1001 |
apnea_count, apnea_events = count_precise_apneas(
|
| 1002 |
y_clean,
|
| 1003 |
sr,
|
| 1004 |
segment_results,
|
| 1005 |
+
min_apnea=1,
|
| 1006 |
+
max_apnea=60
|
| 1007 |
)
|
| 1008 |
|
| 1009 |
# Add apnea events to annotations
|