parth036 commited on
Commit
403e5fc
·
verified ·
1 Parent(s): 956b162

Upload folder using huggingface_hub

Browse files
anxiety_app/services/face_emotion.py CHANGED
@@ -1,12 +1,14 @@
1
- """Facial emotion recognition from a live webcam feed.
2
 
3
  Loads the FER2013 CNN once (lazily) and classifies each detected face. Emotions
4
- are tallied across the session; the dominant affective signal is returned as a
5
  supplementary cue (Anxious / Depressed / Normal) -- NOT a clinical diagnosis.
6
 
7
- Preprocessing: per-image standardization (subtract the face's mean, divide by
8
- its std) to match how the model was trained. This fixes a train/serve skew in
9
- the original code (which divided by 255) and roughly doubles accuracy.
 
 
10
  """
11
  import logging
12
 
@@ -19,11 +21,17 @@ logger = logging.getLogger(__name__)
19
  # Heavy deps (tf_keras, cv2) are imported lazily so the package can be imported
20
  # (e.g. for fast route tests) without TensorFlow/OpenCV present.
21
  _model = None
22
- _cascade = None
 
 
 
 
 
 
23
 
24
 
25
  def _load():
26
- global _model, _cascade
27
  if _model is None:
28
  import cv2
29
  from tf_keras.models import model_from_json
@@ -31,8 +39,42 @@ def _load():
31
  logger.info("Loading face model from %s", config.FACE_MODEL_JSON)
32
  _model = model_from_json(config.FACE_MODEL_JSON.read_text())
33
  _model.load_weights(str(config.FACE_MODEL_WEIGHTS))
34
- _cascade = cv2.CascadeClassifier(str(config.HAAR_CASCADE))
35
- return _model, _cascade
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
 
37
 
38
  def preprocess(face_gray):
@@ -46,7 +88,7 @@ def preprocess(face_gray):
46
 
47
  def classify_face(face_gray):
48
  """Return the predicted FER2013 emotion label for one grayscale face crop."""
49
- model, _ = _load()
50
  preds = model.predict(preprocess(face_gray), verbose=0)
51
  return config.FACE_EMOTIONS[int(np.argmax(preds[0]))]
52
 
@@ -63,21 +105,20 @@ def classify_frame(image_bytes):
63
  """Detect faces in an encoded image (e.g. a browser JPEG) and classify each.
64
 
65
  Returns a list of {"box": [x, y, w, h], "emotion": str, "signal": str}.
66
- Used by the browser-capture API so no server-side camera is required.
67
  """
68
  import cv2
69
 
70
- _, cascade = _load()
71
  arr = np.frombuffer(image_bytes, np.uint8)
72
  img = cv2.imdecode(arr, cv2.IMREAD_COLOR)
73
  if img is None:
74
  return []
75
  gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
76
  results = []
77
- for (x, y, w, h) in cascade.detectMultiScale(gray, 1.32, 5):
78
  emotion = classify_face(gray[y:y + h, x:x + w])
79
  results.append({
80
- "box": [int(x), int(y), int(w), int(h)],
81
  "emotion": emotion,
82
  "signal": emotion_to_signal(emotion),
83
  })
@@ -101,7 +142,7 @@ def detect_emotion(camera_index=0):
101
  """
102
  import cv2
103
 
104
- model, cascade = _load()
105
  cap = cv2.VideoCapture(camera_index)
106
  if not cap.isOpened():
107
  raise RuntimeError("Could not open the webcam (camera index %s)." % camera_index)
@@ -113,7 +154,7 @@ def detect_emotion(camera_index=0):
113
  if not ok:
114
  continue
115
  gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
116
- for (x, y, w, h) in cascade.detectMultiScale(gray, 1.32, 5):
117
  cv2.rectangle(frame, (x, y), (x + w, y + h), (255, 0, 0), 3)
118
  emotion = classify_face(gray[y:y + h, x:x + w])
119
  counts[emotion] = counts.get(emotion, 0) + 1
 
1
+ """Facial emotion recognition.
2
 
3
  Loads the FER2013 CNN once (lazily) and classifies each detected face. Emotions
4
+ are tallied across a session; the dominant affective signal is returned as a
5
  supplementary cue (Anxious / Depressed / Normal) -- NOT a clinical diagnosis.
6
 
7
+ Detection robustness: faces are found on a CLAHE contrast-equalized image (helps
8
+ low light) using two Haar cascades -- the bundled frontal-face plus OpenCV's
9
+ ``alt2`` cascade (better with glasses / slight angles). Boxes from both are
10
+ merged. Classification still uses the raw crop with per-image standardization to
11
+ match how the model was trained (fixes the original /255 train/serve skew).
12
  """
13
  import logging
14
 
 
21
  # Heavy deps (tf_keras, cv2) are imported lazily so the package can be imported
22
  # (e.g. for fast route tests) without TensorFlow/OpenCV present.
23
  _model = None
24
+ _cascades = None
25
+ _clahe = None
26
+
27
+ # Detection tuning
28
+ _SCALE_FACTOR = 1.1 # finer pyramid -> catches more faces (glasses/angles)
29
+ _MIN_NEIGHBORS = 5
30
+ _MIN_SIZE = (60, 60)
31
 
32
 
33
  def _load():
34
+ global _model, _cascades, _clahe
35
  if _model is None:
36
  import cv2
37
  from tf_keras.models import model_from_json
 
39
  logger.info("Loading face model from %s", config.FACE_MODEL_JSON)
40
  _model = model_from_json(config.FACE_MODEL_JSON.read_text())
41
  _model.load_weights(str(config.FACE_MODEL_WEIGHTS))
42
+
43
+ # Our shipped cascade + OpenCV's bundled alt2 (more robust to glasses).
44
+ paths = [str(config.HAAR_CASCADE),
45
+ cv2.data.haarcascades + "haarcascade_frontalface_alt2.xml"]
46
+ _cascades = [c for c in (cv2.CascadeClassifier(p) for p in paths) if not c.empty()]
47
+ _clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
48
+ return _model
49
+
50
+
51
+ def _iou(a, b):
52
+ ax, ay, aw, ah = a
53
+ bx, by, bw, bh = b
54
+ ix, iy = max(ax, bx), max(ay, by)
55
+ iw = max(0, min(ax + aw, bx + bw) - ix)
56
+ ih = max(0, min(ay + ah, by + bh) - iy)
57
+ inter = iw * ih
58
+ if inter == 0:
59
+ return 0.0
60
+ return inter / float(aw * ah + bw * bh - inter)
61
+
62
+
63
+ def _detect(gray):
64
+ """Return de-duplicated face boxes from CLAHE-equalized grayscale."""
65
+ eq = _clahe.apply(gray)
66
+ raw = []
67
+ for cascade in _cascades:
68
+ for box in cascade.detectMultiScale(eq, scaleFactor=_SCALE_FACTOR,
69
+ minNeighbors=_MIN_NEIGHBORS, minSize=_MIN_SIZE):
70
+ raw.append(tuple(int(v) for v in box))
71
+ # Keep the largest boxes first; drop ones that overlap an already-kept box.
72
+ raw.sort(key=lambda b: b[2] * b[3], reverse=True)
73
+ kept = []
74
+ for box in raw:
75
+ if all(_iou(box, k) < 0.4 for k in kept):
76
+ kept.append(box)
77
+ return kept
78
 
79
 
80
  def preprocess(face_gray):
 
88
 
89
  def classify_face(face_gray):
90
  """Return the predicted FER2013 emotion label for one grayscale face crop."""
91
+ model = _load()
92
  preds = model.predict(preprocess(face_gray), verbose=0)
93
  return config.FACE_EMOTIONS[int(np.argmax(preds[0]))]
94
 
 
105
  """Detect faces in an encoded image (e.g. a browser JPEG) and classify each.
106
 
107
  Returns a list of {"box": [x, y, w, h], "emotion": str, "signal": str}.
 
108
  """
109
  import cv2
110
 
111
+ _load()
112
  arr = np.frombuffer(image_bytes, np.uint8)
113
  img = cv2.imdecode(arr, cv2.IMREAD_COLOR)
114
  if img is None:
115
  return []
116
  gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
117
  results = []
118
+ for (x, y, w, h) in _detect(gray):
119
  emotion = classify_face(gray[y:y + h, x:x + w])
120
  results.append({
121
+ "box": [x, y, w, h],
122
  "emotion": emotion,
123
  "signal": emotion_to_signal(emotion),
124
  })
 
142
  """
143
  import cv2
144
 
145
+ _load()
146
  cap = cv2.VideoCapture(camera_index)
147
  if not cap.isOpened():
148
  raise RuntimeError("Could not open the webcam (camera index %s)." % camera_index)
 
154
  if not ok:
155
  continue
156
  gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
157
+ for (x, y, w, h) in _detect(gray):
158
  cv2.rectangle(frame, (x, y), (x + w, y + h), (255, 0, 0), 3)
159
  emotion = classify_face(gray[y:y + h, x:x + w])
160
  counts[emotion] = counts.get(emotion, 0) + 1
anxiety_app/static/app.css CHANGED
@@ -156,11 +156,52 @@ audio { width: 100%; max-width: 420px; margin-top: 16px; }
156
  /* ---------- misc ---------- */
157
  .note { color: var(--muted); font-size: 0.92rem; max-width: 64ch; margin: 22px auto 0; }
158
  .note.center { margin-left: auto; margin-right: auto; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
  .site-footer { border-top: 1px solid var(--line); padding: 26px 0; color: var(--muted); font-size: 0.9rem; }
160
  .site-footer p { margin: 0 auto; text-align: center; max-width: var(--maxw); padding: 0 24px; }
161
 
 
 
 
 
 
 
 
 
 
 
 
162
  @media (max-width: 520px) {
163
- .opts { grid-template-columns: repeat(2, 1fr); }
164
- .nav { gap: 16px; }
165
  body { font-size: 16px; }
 
 
 
 
 
 
 
 
 
 
166
  }
 
156
  /* ---------- misc ---------- */
157
  .note { color: var(--muted); font-size: 0.92rem; max-width: 64ch; margin: 22px auto 0; }
158
  .note.center { margin-left: auto; margin-right: auto; }
159
+
160
+ /* ---------- use-case / explainer ---------- */
161
+ .section { margin-top: 64px; padding-top: 40px; border-top: 1px solid var(--line); }
162
+ .section-eyebrow { font-family: var(--display); font-size: 0.76rem; letter-spacing: 0.2em; text-transform: uppercase; color: var(--amber); font-weight: 600; margin: 0 0 10px; }
163
+ .section h2 { font-family: var(--display); font-weight: 600; font-size: 1.8rem; letter-spacing: -0.02em; margin: 0 0 24px; }
164
+ .two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 40px; }
165
+ .two-col h3 { font-family: var(--display); font-weight: 600; font-size: 1.15rem; margin: 0 0 14px; }
166
+ .ticks, .steps { margin: 0; padding: 0; list-style: none; }
167
+ .ticks li { position: relative; padding-left: 26px; margin-bottom: 12px; color: var(--text); }
168
+ .ticks li::before { content: "\2713"; position: absolute; left: 0; top: 0; color: var(--amber); font-weight: 700; }
169
+ .ticks li span { color: var(--muted); }
170
+ .steps { counter-reset: step; }
171
+ .steps li { position: relative; padding-left: 42px; margin-bottom: 16px; min-height: 30px; }
172
+ .steps li::before {
173
+ counter-increment: step; content: counter(step); position: absolute; left: 0; top: -2px;
174
+ width: 30px; height: 30px; border-radius: 50%; display: grid; place-items: center;
175
+ background: rgba(45, 212, 191, 0.14); border: 1px solid var(--amber); color: var(--amber);
176
+ font-family: var(--display); font-weight: 600; font-size: 0.9rem;
177
+ }
178
+ .steps li b { display: block; }
179
+ .steps li span { color: var(--muted); }
180
+ @media (max-width: 640px) { .two-col { grid-template-columns: 1fr; gap: 28px; } }
181
  .site-footer { border-top: 1px solid var(--line); padding: 26px 0; color: var(--muted); font-size: 0.9rem; }
182
  .site-footer p { margin: 0 auto; text-align: center; max-width: var(--maxw); padding: 0 24px; }
183
 
184
+ /* ---------- responsive ---------- */
185
+ @media (max-width: 760px) {
186
+ .container, .header-inner, .main, .site-footer p { padding-left: 18px; padding-right: 18px; }
187
+ .main { padding-top: 36px; padding-bottom: 56px; }
188
+ .header-inner { height: auto; min-height: 60px; flex-wrap: wrap; gap: 8px 16px; padding-top: 10px; padding-bottom: 10px; }
189
+ .nav { gap: 16px; flex-wrap: wrap; }
190
+ .panel { padding: 22px 18px; }
191
+ .page-title { font-size: 2rem; }
192
+ .hero { padding: 14px 0 30px; }
193
+ }
194
+
195
  @media (max-width: 520px) {
 
 
196
  body { font-size: 16px; }
197
+ .brand-accent { display: none; }
198
+ .nav { width: 100%; justify-content: space-between; gap: 10px; }
199
+ .nav a { font-size: 0.74rem; letter-spacing: 0.08em; }
200
+ .opts { grid-template-columns: repeat(2, 1fr); }
201
+ .hero h1 { font-size: clamp(2rem, 9vw, 2.6rem); }
202
+ .hero p { font-size: 1.05rem; }
203
+ /* full-width, comfortable tap targets */
204
+ .hero-cta, .controls { flex-direction: column; align-items: stretch; }
205
+ .hero-cta .btn, .controls .btn { width: 100%; padding: 14px 20px; }
206
+ .score-num { font-size: 2.4rem; }
207
  }
anxiety_app/templates/face.html CHANGED
@@ -17,6 +17,8 @@
17
  </div>
18
 
19
  <p id="status" class="status">Click “Start camera” and allow webcam access.</p>
 
 
20
 
21
  <div class="chips">
22
  <span class="chip">Anxious <b id="c-anx">0</b></span>
 
17
  </div>
18
 
19
  <p id="status" class="status">Click “Start camera” and allow webcam access.</p>
20
+ <p class="note center" style="margin-top:0;">Tip: face the camera with even lighting for best
21
+ detection — strong backlight or very dim rooms reduce accuracy.</p>
22
 
23
  <div class="chips">
24
  <span class="chip">Anxious <b id="c-anx">0</b></span>
anxiety_app/templates/index.html CHANGED
@@ -37,7 +37,31 @@
37
  </a>
38
  </div>
39
 
40
- <p class="note">Emotion recognition is a supplementary signal, <strong>not a diagnosis</strong>.
41
- The PHQ-9 is the only clinically validated component, and it is a screening aid — not a substitute
42
- for a clinician.</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  {% endblock %}
 
37
  </a>
38
  </div>
39
 
40
+ <section class="section">
41
+ <p class="section-eyebrow">What this is</p>
42
+ <h2>A demo of multimodal well-being screening</h2>
43
+ <div class="two-col">
44
+ <div>
45
+ <h3>What it's for</h3>
46
+ <p style="color:var(--muted); margin-top:0;">A hands-on demonstration of how machine
47
+ learning can read emotional cues, paired with a validated questionnaire. Useful for:</p>
48
+ <ul class="ticks">
49
+ <li>Quick self-reflection &amp; mood check-ins <span>— gauge how you're presenting right now.</span></li>
50
+ <li>Showcasing an end-to-end ML pipeline <span>— vision + audio models behind a web app.</span></li>
51
+ <li>Education &amp; awareness <span>— see what affect-recognition can (and can't) do.</span></li>
52
+ </ul>
53
+ </div>
54
+ <div>
55
+ <h3>How to use it</h3>
56
+ <ol class="steps">
57
+ <li><b>Pick a modality</b><span>Face (webcam), Voice (mic), or the PHQ-9 form.</span></li>
58
+ <li><b>Run it in your browser</b><span>Grant camera/mic access — nothing is uploaded or stored.</span></li>
59
+ <li><b>Read the result</b><span>An emotion cue, or a scored PHQ-9 severity band with guidance.</span></li>
60
+ </ol>
61
+ </div>
62
+ </div>
63
+ <p class="note">Emotion recognition is a supplementary signal, <strong>not a diagnosis</strong>.
64
+ The PHQ-9 is the only clinically validated component, and it is a screening aid — not a
65
+ substitute for a clinician.</p>
66
+ </section>
67
  {% endblock %}