Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,168 changes: 1,168 additions & 0 deletions docs/superpowers/plans/2026-05-25-graded-presence-alert-implementation.md

Large diffs are not rendered by default.

166 changes: 166 additions & 0 deletions docs/superpowers/specs/2026-05-25-graded-presence-alert-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
# Graded presence alert - design spec

**Status:** Approved (2026-05-25)
**Product:** Argus - lightweight rear-facing presence alerts.
**Builds on:** `docs/superpowers/specs/2026-05-15-presence-alert-design.md`

## Goals

- Keep Argus a lightweight reminder tool, not a monitoring dashboard.
- Split the current single `有人` alert into factual severity levels.
- Use different sound patterns for `有人` and `偵測到臉`.
- Let users choose simple sensitivity presets in the main UI.
- Let advanced users tune thresholds without exposing that complexity by default.
- Keep all logic browser-only and compatible with the current person and face pipelines.

## Non-goals

- True gaze or eye-contact estimation.
- Screenshots, video recording, event history, replay, or identity recognition.
- Push notifications after the browser tab is closed.
- External apps, desktop agents, or server-side processing.
- Showing person count in the main alert UI.

## Event model

The UI should name states by observed facts. It should express risk through sound, color,
and priority rather than by overclaiming what the model knows.

| Priority | State label | Meaning |
|----------|-------------|---------|
| 0 | `無人` | No person meets the configured person threshold. |
| 1 | `有人` | At least one person meets the person threshold, but no qualifying face is detected. |
| 2 | `偵測到臉` | A face is detected inside a qualifying person track. |

If more than one state qualifies in the same tick, the coordinator emits only the highest
priority state. For example, if a person enters the frame and a face is already detected,
Argus should play only the `偵測到臉` sound, not `有人` followed by `偵測到臉`.

## Sensitivity UI

The main settings panel should stay simple:

- Notification channels: `頁內提示音`, `系統通知`, `頁內燈號`.
- `提醒模式`: `安靜`, `標準`, `敏感`, `自訂`.
- Sound volume and test controls.

Advanced settings should hold detailed thresholds:

- Person consecutive frames.
- Minimum person count.
- Person minimum confidence.
- Leave hysteresis frames.
- Face sample time window.
- Required face hits in the time window.
- Shared repeat interval.

Changing any advanced value should switch `提醒模式` to `自訂`.

## Presets

Presets should map to explicit settings so the UI stays predictable:

| Mode | Person frames | Min people | Min score | Face window | Face hits | Leave frames | Repeat interval |
|------|---------------|------------|-----------|-------------|-----------|--------------|-----------------|
| `安靜` | 5 | 1 | 0.40 | 3000 ms | 2 | 8 | 30 sec |
| `標準` | 2 | 1 | 0.35 | 2000 ms | 2 | 4 | 10 sec |
| `敏感` | 1 | 1 | 0.30 | 1500 ms | 1 | 2 | 5 sec |

All presets should keep `useConfirmedOnly` off by default. The user can still enable it from
advanced settings.

## Detection architecture

Keep the existing person state machine for `有人`:

- Input: tracks from `PersonPipeline.detect()`.
- Existing controls: consecutive frames, minimum person count, minimum score, confirmed-only,
repeat interval, and leave hysteresis.
- Output: whether person presence is currently active.

Add a face presence state for `偵測到臉`:

- Input: faces from `FacePipeline.detect()` and their `trackId`.
- Store recent face detector samples by `trackId`.
- Qualify `偵測到臉` when the configured sample window contains enough face hits.
- Expire old samples so a stale cached face does not keep the state active forever.

This should use a time window or sampled-detection window, not pure consecutive video frames.
The current face pipeline is throttled, so frame-by-frame face streaks would behave differently
on fast and slow devices. Count only fresh face detector samples, not repeated frames that reuse
cached face boxes.

## Coordinator behavior

The coordinator should produce a single alert state per tick:

1. Evaluate person presence.
2. Evaluate face presence only for qualifying person tracks.
3. Pick the highest qualifying state.
4. Update visual state.
5. Fire enabled channels only when:
- state enters a higher alert level,
- state changes from `無人` to an alert state, or
- the repeat interval for the current level has elapsed.

Downgrades should update the visual state without playing a sound by default.

## Channel behavior

### Sound

Sound should distinguish severity without becoming noisy:

- `有人`: soft short single tone.
- `偵測到臉`: clearer double tone or higher-pitched pattern.

The test control should let the user test each event sound. Volume stays shared across
event sounds.

### Visual

The small status lamp should show three states:

- `無人`
- `有人`
- `偵測到臉`

Visual emphasis should remain subtle. The UI should not imply eye-contact detection.

### System notification

System notifications should stay minimal:

- Keep the notification title as `Argus`.
- Use the event label as the short notification body.
- Do not include screenshots, person counts, or long explanatory text.
- Respect browser permission and existing denied-permission behavior.

## Copy guidelines

- Use factual labels: `無人`, `有人`, `偵測到臉`.
- Avoid labels like `可能被看見`, `正在看你`, or `被注視`.
- Explain the face level with helper text when needed: `臉部進入畫面,已提高提醒等級`.
- Keep advanced setting labels concrete and short.

## Lifecycle

| Event | Behavior |
|-------|----------|
| Start | Unlock audio and load the current alert settings. |
| Each detection tick | Evaluate person and face state, update visual state, and fire one alert when trigger conditions match. |
| Settings change | Persist settings and apply them on the next tick. |
| Stop | Reset person and face alert state, clear visual state, and stop firing alerts. |

## Manual test scenarios

1. Defaults: no one in frame shows `無人` and does not fire.
2. Person enters without visible face: state becomes `有人` and plays the person sound once.
3. Person enters with visible face: state becomes `偵測到臉` and plays only the face sound.
4. Person first appears without a face, then turns toward the camera: state upgrades to `偵測到臉`.
5. Person leaves: state returns to `無人` after leave hysteresis, with no exit sound.
6. `安靜`, `標準`, and `敏感` presets produce noticeably different sensitivity.
7. Changing advanced settings switches the mode label to `自訂`.
8. Disabling sound keeps visual and notification behavior intact.
9. Stopping the session prevents further alerts.
10. `npm run build && npm run verify:pages` still passes.
50 changes: 40 additions & 10 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,9 @@
.stage.presence-present {
box-shadow: 0 0 0 2px var(--accent), 0 0 24px rgba(34, 211, 238, 0.25);
}
.stage.presence-face {
box-shadow: 0 0 0 2px var(--danger), 0 0 24px rgba(248, 113, 113, 0.25);
}

.presence-lamp {
position: absolute;
Expand All @@ -175,12 +178,17 @@
background: var(--muted);
box-shadow: 0 0 0 2px rgba(154, 160, 166, 0.35);
}
.presence-lamp[data-state="present"] .dot {
.presence-lamp[data-state="person"] .dot {
background: var(--accent);
box-shadow: 0 0 8px rgba(34, 211, 238, 0.7);
}
.presence-lamp[data-state="face"] .dot {
background: var(--danger);
box-shadow: 0 0 8px rgba(248, 113, 113, 0.7);
}
.presence-lamp[data-state="present"] .label::after { content: '有人'; }
.presence-lamp[data-state="absent"] .label::after { content: '無人'; }
.presence-lamp[data-state="none"] .label::after { content: '無人'; }
.presence-lamp[data-state="person"] .label::after { content: '有人'; }
.presence-lamp[data-state="face"] .label::after { content: '偵測到臉'; }

.alert-settings {
max-width: 1280px;
Expand Down Expand Up @@ -209,15 +217,16 @@
font-size: 0.88rem;
cursor: pointer;
}
.alert-row input[type="number"] {
width: 4.5rem;
.alert-row input[type="number"],
.alert-row select {
padding: 4px 8px;
border-radius: 4px;
border: 1px solid #2a3140;
background: var(--bg);
color: var(--fg);
font: inherit;
}
.alert-row input[type="number"] { width: 4.5rem; }
.alert-row input[type="range"] { width: 120px; }
.alert-muted {
color: var(--muted);
Expand Down Expand Up @@ -282,7 +291,7 @@ <h1>Argus</h1>
<span class="k">active tracks</span> <span class="v" id="hud-tracks">0</span>
<span class="k">backend</span> <span class="v" id="hud-backend">—</span>
</div>
<div id="presence-lamp" class="presence-lamp" data-state="absent" aria-label="無人">
<div id="presence-lamp" class="presence-lamp" data-state="none" aria-label="無人">
<span class="dot" aria-hidden="true"></span>
<span class="label" aria-hidden="true"></span>
</div>
Expand All @@ -303,23 +312,44 @@ <h2 id="alert-settings-title">通知設定</h2>
<label><input type="checkbox" id="alert-ch-visual" checked> 頁內燈號</label>
</div>
<div class="alert-row">
<label>連續幀數 <input type="number" id="alert-frames" min="1" max="30" value="1"></label>
<label>至少人數 <input type="number" id="alert-min-persons" min="1" max="20" value="1"></label>
<label>間隔秒數 <input type="number" id="alert-interval" min="0" max="300" value="10"></label>
<span class="alert-muted">至少人數 = 畫面上達標才通知;間隔 0 = 僅在無→有時提醒一次</span>
<label>
提醒模式
<select id="alert-mode">
<option value="quiet">安靜</option>
<option value="standard">標準</option>
<option value="sensitive">敏感</option>
<option value="custom">自訂</option>
</select>
</label>
<span class="alert-muted">一般使用建議選「標準」;細項調整後會變成「自訂」。</span>
</div>
<button type="button" id="alert-advanced-toggle" aria-expanded="false" aria-controls="alert-advanced">
進階設定
</button>
<div id="alert-advanced" class="alert-advanced" hidden>
<div class="alert-row">
<label>連續幀數 <input type="number" id="alert-frames" min="1" max="30" value="1"></label>
<label>至少人數 <input type="number" id="alert-min-persons" min="1" max="20" value="1"></label>
<label>間隔秒數 <input type="number" id="alert-interval" min="0" max="300" value="10"></label>
<span class="alert-muted">至少人數 = 畫面上達標才通知;間隔 0 = 僅在無→有時提醒一次</span>
</div>
<div class="alert-row">
<label><input type="checkbox" id="alert-confirmed-only"> 僅使用已確認追蹤</label>
<label>最低信心 <input type="number" id="alert-min-score" min="0" max="1" step="0.05" value="0.35"></label>
<label>離開遲滯幀數 <input type="number" id="alert-leave-frames" min="1" max="60" placeholder="同連續幀數"></label>
<label>臉部時間窗 <input type="number" id="alert-face-window" min="250" max="10000" step="250" value="2000"> ms</label>
<label>臉部命中次數 <input type="number" id="alert-face-hits" min="1" max="20" value="2"></label>
</div>
</div>
<div id="alert-sound-extras" class="alert-row">
<label>音量 <input type="range" id="alert-volume" min="0" max="1" step="0.05" value="0.7"></label>
<label>
測試事件
<select id="alert-test-event">
<option value="person">有人</option>
<option value="face">偵測到臉</option>
</select>
</label>
<button type="button" id="alert-test-sound">測試音</button>
</div>
<p id="alert-notif-hint" class="alert-hint" hidden>
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"type": "module",
"scripts": {
"dev": "node server.mjs",
"test": "node --test tests/*.test.js",
"vendor:ort": "node scripts/vendor-ort.mjs",
"vendor:human": "node scripts/vendor-human.mjs",
"verify:vendor": "node scripts/verify-vendor.mjs",
Expand Down
3 changes: 2 additions & 1 deletion src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -191,12 +191,13 @@ async function onFrame(now /* , metadata */) {
state.frameIdx += 1;
} catch (err) {
console.error('[app] detect failed:', err);
state.lastFaces = [];
} finally {
state.inflight = false;
}
}

if (state.running) presence.tick(state.lastTracks);
if (state.running) presence.tick(state.lastTracks, state.lastFaces);

if (isVideoPreviewVisible()) {
state.overlay?.drawTracks(state.lastTracks, state.lastFaces);
Expand Down
6 changes: 5 additions & 1 deletion src/pipeline/face.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
* @property {number} y2
* @property {number} score
* @property {number} trackId
* @property {boolean} fresh true when produced by a detector run in this frame
*/

export class FacePipeline {
Expand Down Expand Up @@ -126,7 +127,9 @@ export class FacePipeline {
const lastIdx = track.lastFaceFrameIdx ?? -10_000_000;
const due = frameIdx - lastIdx >= periodFrames;
if (!due) {
if (track.lastFaces?.length) out.push(...track.lastFaces);
if (track.lastFaces?.length) {
out.push(...track.lastFaces.map((face) => ({ ...face, fresh: false })));
}
continue;
}

Expand All @@ -153,6 +156,7 @@ export class FacePipeline {
y2: by + bh + sy,
score: f.score ?? f.boxScore ?? 0,
trackId: track.id,
fresh: true,
});
}
}
Expand Down
73 changes: 73 additions & 0 deletions src/presence/alert-level.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* Alert levels and firing rules for graded presence alerts.
*/

export const ALERT_NONE = 'none';
export const ALERT_PERSON = 'person';
export const ALERT_FACE = 'face';

export const ALERT_LABELS = {
[ALERT_NONE]: '無人',
[ALERT_PERSON]: '有人',
[ALERT_FACE]: '偵測到臉',
};

const PRIORITY = {
[ALERT_NONE]: 0,
[ALERT_PERSON]: 1,
[ALERT_FACE]: 2,
};

export class AlertLevelState {
constructor() {
this.level = ALERT_NONE;
this.lastFireMs = 0;
}

reset() {
this.level = ALERT_NONE;
this.lastFireMs = 0;
}

/**
* @param {'none' | 'person' | 'face'} nextLevel
* @param {{ repeatIntervalSec: number }} settings
* @param {number} nowMs
* @returns {{ fire: boolean, level: 'none' | 'person' | 'face', label: string }}
*/
tick(nextLevel, settings, nowMs) {
const previous = this.level;
this.level = nextLevel;

let fire = false;
if (nextLevel === ALERT_NONE) {
this.lastFireMs = 0;
return { fire, level: this.level, label: ALERT_LABELS[this.level] };
}

const enteredFromNone = previous === ALERT_NONE;
const upgraded = PRIORITY[nextLevel] > PRIORITY[previous];
const downgraded = PRIORITY[nextLevel] < PRIORITY[previous];
if (enteredFromNone || upgraded) {
fire = true;
this.lastFireMs = nowMs;
return { fire, level: this.level, label: ALERT_LABELS[this.level] };
}
if (downgraded) {
this.lastFireMs = nowMs;
return { fire, level: this.level, label: ALERT_LABELS[this.level] };
}

const intervalMs = settings.repeatIntervalSec * 1000;
if (intervalMs > 0 && this.lastFireMs > 0 && nowMs - this.lastFireMs >= intervalMs) {
fire = true;
this.lastFireMs = nowMs;
}

return { fire, level: this.level, label: ALERT_LABELS[this.level] };
}
}

export function shouldSuppressPersonAlert(currentLevel, actualLevel, hasRecentFaceSample) {
return currentLevel === ALERT_NONE && actualLevel === ALERT_PERSON && hasRecentFaceSample;
}
Loading
Loading