Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files
anxiety_app/services/face_emotion.py
CHANGED
|
@@ -1,12 +1,14 @@
|
|
| 1 |
-
"""Facial emotion recognition
|
| 2 |
|
| 3 |
Loads the FER2013 CNN once (lazily) and classifies each detected face. Emotions
|
| 4 |
-
are tallied across
|
| 5 |
supplementary cue (Anxious / Depressed / Normal) -- NOT a clinical diagnosis.
|
| 6 |
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
|
| 25 |
def _load():
|
| 26 |
-
global _model,
|
| 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 |
-
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 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
|
| 78 |
emotion = classify_face(gray[y:y + h, x:x + w])
|
| 79 |
results.append({
|
| 80 |
-
"box": [
|
| 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 |
-
|
| 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
|
| 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 |
-
<
|
| 41 |
-
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 & 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 & 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 %}
|