move_detect/server/public/assets/detail.js
유창욱 4bfb7e0274
Some checks are pending
verify / edge (push) Waiting to run
verify / server (push) Waiting to run
feat: 추락 · 동시 다발 쓰러짐 · 구역 프리셋 (1.2.0)
AI CCTV 목적을 원론부터 재점검한 결과. 셋 다 새 추론 모델 없이 지금 있는
자세추정·구역·트랙 이력만으로 구현했다.

■ 추락 (fall_from_height)

잡고 있던 것은 같은 높이의 '넘어짐'이었고, 사망 1위 유형인 '떨어짐'은 못
잡았다. GhostIncidentDetector 가 트랙이 이미 위험 자세일 때만 무장하므로
(_DANGEROUS), 발판에 서 있다가 떨어지는 사람은 소실 순간 STANDING 이라
아무것도 안 걸렸다.

판정의 핵심은 bbox 중심이 아니라 하단(y2)이다. 쭈그려 앉으면 중심은
내려가도 발은 그 자리이고, 추락은 발 밑선이 통째로 내려간다 — 중심으로
재면 쭈그려 앉아 작업하는 사람이 전부 추락이 된다. 원근 접근은 y2 도
내려가므로 높이 증가 가드로 따로 막는다. Track.frames 가 90프레임을 들고
있어 별도 버퍼가 없다.

발보는 두 갈래로 나뉘되 한 사고는 한 건이다: 프레임 안에서 넘어짐이
확정되면 유형을 승격하고, 화각 밖으로 사라지면 ghost 무장을 넓혀 그쪽으로
낸다. 심각도 CRITICAL — 척추·두부 손상 주의가 통보에 실리려면 넘어짐(HIGH)과
등급이 갈라져야 한다. 고소 구역은 rules_json 의 elevated 키로 붙어 스키마
변경이 없고, 구역이 없어도 하강 신호만으로 동작한다.

■ 동시 다발 쓰러짐 (multi_down)

같은 구역에서 짧은 시간에 둘이 쓰러진 것은 넘어짐 두 건이 아니라 질식·
가스·폭발이다. 이 기능의 본체는 감지가 아니라 통보 문구다 — 밀폐공간
사망자의 상당수가 구조하러 들어간 사람이므로, 링크를 열기 전에 메일 본문에서
"공기호흡기 없이 진입하지 마십시오"가 읽혀야 한다. Channels::guidance 로
유형별 안내를 붙였고 문구를 테스트로 고정했다(문구가 사라지면 기능이 사라진
것과 같다).

서버에 둔 이유: 가스는 카메라 화각을 존중하지 않아 노드를 넘는 판단이
필요하고, 현장 즉시 대응은 첫 쓰러짐의 경광등이 이미 확보한다. 오탐 방어의
핵심은 근접성 — 넓은 공장에서 2분 안의 무관한 넘어짐 두 건은 실제로
일어나므로 같은 구역이거나 좌표가 가까워야 묶는다. 한 사람이 두 번 넘어진
것은 한 명으로 센다.

■ 구역 프리셋 6종 + max_workers:0 수정

밀폐공간 감시에 필요한 것(제한구역+2인1조+체류상한)은 이미 전부 있었는데
화면이 "최소 인원을 넣으세요"라고만 물어 아무도 그렇게 쓰지 못했다.
안전관리자는 min_workers 가 아니라 밀폐공간을 생각한다.

그 과정에서 잠복 결함 하나: max_workers:0 이 falsy 로 걸러져 "아무도 있으면
안 되는 구역"(크레인 짐 아래·굴착기 선회 반경 = 깔림 사고의 전형)이 통째로
꺼져 있었다. 운영자가 저장해 놓고도 아무 일이 안 일어났다. 감지기·입력란
(min=1)·저장 직렬화(>0)·목록 표시 네 곳에서 막혀 있었다.

■ 검증

pytest 322 passed / phpunit 263 passed. 신규 테스트 30건.
이벤트 유형 2종 추가는 test_event_type_parity 가 강제하는 5곳(엣지 enum ·
EventValidator · schema CHECK · 프런트 라벨 5파일 · 필터 옵션) 전부 동기화했고
기존 설치용 마이그레이션을 추가했다.

■ Pi 번들

1.1.0 과 달리 이번에는 edge/ 가 바뀌었으므로 Pi 번들 재빌드가 필수다.
낡은 1.0.0 번들은 dist/ 에서 제거했다 — 두면 추락 감지가 빠진 노드가 그대로
납품된다. arm64 + 네트워크 빌드 머신에서 재빌드해야 Pi 납품이 가능하다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBjuR7DW6PNNMows4KnbYU
2026-09-05 12:21:50 +09:00

409 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Incident detail view: parses the event id from /events/{id}/view, fetches
* GET /events/{id} (JSON {event, snapshots}), and renders the event metadata
* plus each snapshot as an <img src="/snap/{file_uuid}">.
*
* This is where an incident is JUDGED, so it is also where it is RESOLVED: 확인/오탐 post to
* the same routes the stream uses. Previously the operator had to read the evidence here,
* navigate back, and find the row again — the deepest screen had no way to act on what it
* showed, which is exactly backwards.
*
* XSS-safe: every dynamic value is written via textContent or a validated URL
* attribute, never innerHTML. Severity is shown as icon + text (never color
* alone, per NF9). On 401 the browser is redirected to /login, mirroring app.js.
*/
(function () {
'use strict';
// Mount prefix: '' at the web root, '/move_detect' when the app shares a www/ with another
// site. Injected by src/View/layout.php from the server's SCRIPT_NAME.
var B = window.APP_BASE || '';
var SEVERITY_LABELS = {
critical: { icon: '⛔', text: '위급', cls: 'sev-critical' },
high: { icon: '⚠', text: '높음', cls: 'sev-high' },
warning: { icon: '△', text: '경고', cls: 'sev-warning' },
info: { icon: 'ℹ', text: '정보', cls: 'sev-info' }
};
// Same wire-code -> Korean map as app.js (the two pages share no module system);
// an unknown code falls through as the raw code — never blank.
var TYPE_LABELS = {
fall: '넘어짐', fall_from_height: '추락', no_motion: '넘어짐 후 미동 없음', immobility: '장시간 부동',
ghost: '위험 상태 소실', zone_warning: '경고구역 진입', zone_restricted: '제한구역 침입',
pinch: '협착구역 침입', monitoring_degraded: '감시 성능 저하', device_fault: '장치 이상',
lone_worker: '위험구역 단독 작업', overcapacity: '정원 초과', overstay: '체류 상한 초과',
near_fall: '넘어질 뻔함 (니어미스)', ergo_report: '작업자세 부담 리포트',
// 감시 자체의 건전성 — 존 판정이 무의미해졌음을 알리는 유형들.
camera_moved: '카메라 화각 변경', camera_obstructed: '카메라 가림', low_light: '조도 저하',
unacked_escalation: '미확인 경보 에스컬레이션', multi_down: '동시 다발 쓰러짐',
// 모델 확보 후 활성화 (엣지는 아직 발화하지 않음).
ppe_missing: '보호구 미착용', proximity: '중장비 근접', fire_smoke: '화재·연기'
};
// Metric keys -> Korean, so the detail view can print what the edge measured (the ergo
// report's whole value IS its metrics). Unknown keys fall through as the raw key — which is
// why `confidence` had been rendering as a bare uppercase "CONFIDENCE" in an otherwise
// Korean column. Anything the edge can attach belongs in this table.
var METRIC_LABELS = {
confidence: '검출 신뢰도', score: '검출 점수',
observed_s: '관측 시간', trunk_high_s: '허리 고위험(60°+) 노출',
trunk_mod_s: '허리 중위험(20~60°) 노출', arm_high_s: '팔 거상(90°+) 노출',
squat_s: '웅크림 노출', prone_s: '엎드림 지속', immobility_s: '부동 지속',
waited_s: '재출현 대기', dwell_s: '체류 시간', limit_s: '체류 상한',
alone_s: '단독 작업 지속', workers: '감지 인원', required: '최소 인원', limit: '정원',
torso_angle: '몸통 각도(°)', aspect_ratio: '박스 종횡비', height_remaining: '잔여 높이 비율',
last_state: '소실 시 상태'
};
var STATE_LABELS = { FALLING: '넘어지는 중', FALLEN: '넘어짐', NO_MOTION: '미동 없음', UPRIGHT: '기립' };
function metricValue(key, v) {
if (/_s$/.test(key) && typeof v === 'number') {
if (v >= 90) return Math.round(v / 60) + '분 ' + Math.round(v % 60) + '초';
return v + '초';
}
// A 0..1 confidence reads as a percentage everywhere else a human meets one.
if ((key === 'confidence' || key === 'score') && typeof v === 'number' && v <= 1) {
return Math.round(v * 100) + '%';
}
if (key === 'last_state') return STATE_LABELS[v] || String(v);
return String(v);
}
var meta = document.getElementById('event-detail-meta');
var snapBox = document.getElementById('event-detail-snapshots');
var actionsBox = document.getElementById('detail-actions');
var titleEl = document.getElementById('detail-title');
var whenEl = document.getElementById('detail-when');
if (!meta) return;
var m = location.pathname.match(/\/events\/(\d+)\/view/);
if (!m) {
meta.textContent = '잘못된 주소입니다.';
meta.removeAttribute('aria-busy');
return;
}
var id = m[1];
var currentEvent = null;
var csrfToken = null;
var deviceNames = {};
function severityInfo(sev) {
return SEVERITY_LABELS[sev] || { icon: 'ℹ', text: sev || '알수없음', cls: 'sev-info' };
}
function pad2(n) { return n < 10 ? '0' + n : String(n); }
// One stamp format across the product. This page used to print toLocaleString('ko-KR')
// ("2026. 8. 8. 오후 4:01:50") while the stream printed "08-08 16:01:50" for the same
// instant — the same event looked like two different records depending on the screen.
var DOW = ['일', '월', '화', '수', '목', '금', '토'];
function formatTime(occurredAt) {
if (!occurredAt && occurredAt !== 0) return '-';
var d = new Date(Number(occurredAt) * 1000);
if (isNaN(d.getTime())) return '-';
return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate())
+ ' (' + DOW[d.getDay()] + ') '
+ pad2(d.getHours()) + ':' + pad2(d.getMinutes()) + ':' + pad2(d.getSeconds());
}
function fmtDuration(s) {
var v = Number(s);
if (!isFinite(v)) return '-';
if (v < 60) return (Math.round(v * 10) / 10) + '초';
return Math.floor(v / 60) + '분 ' + Math.round(v % 60) + '초';
}
function buildBadge(sev) {
var info = severityInfo(sev);
var badge = document.createElement('span');
badge.className = 'severity-badge ' + info.cls;
var icon = document.createElement('span');
icon.className = 'icon';
icon.setAttribute('aria-hidden', 'true');
icon.textContent = info.icon;
var label = document.createElement('span');
label.className = 'label';
label.textContent = info.text;
badge.appendChild(icon);
badge.appendChild(label);
return badge;
}
function textNode(value, cls) {
var span = document.createElement('span');
if (cls) span.className = cls;
span.textContent = (value === null || value === undefined || value === '') ? '-' : String(value);
return span;
}
function addRow(label, valueNode) {
var row = document.createElement('div');
row.className = 'detail-row';
var k = document.createElement('dt');
k.textContent = label;
var v = document.createElement('dd');
v.appendChild(valueNode);
row.appendChild(k);
row.appendChild(v);
meta.appendChild(row);
}
// --- resolution actions --------------------------------------------------------------
function loadCsrf() {
return fetch(B + '/api/csrf', { headers: { Accept: 'application/json' } })
.then(function (res) { return res.ok ? res.json() : null; })
.then(function (d) { if (d && d.token) csrfToken = d.token; })
.catch(function () { /* the POST surfaces it as a 403 */ });
}
function post(path) {
return fetch(B + '/events/' + encodeURIComponent(id) + path, {
method: 'POST',
headers: { 'X-Csrf-Token': csrfToken || '' }
}).then(function (res) {
if (res.status === 401) { window.location.href = B + '/login'; return null; }
if (!res.ok) throw new Error('action_failed:' + res.status);
return res;
});
}
function setNote(text, isError) {
var note = document.createElement('p');
note.className = 'detail-action-note' + (isError ? ' is-error' : '');
note.textContent = text;
actionsBox.appendChild(note);
}
// 오탐 is two-step here for the same reason as in the stream: it asserts the detector was
// wrong, it moves a per-camera statistic, and there is no undo.
var fpArmed = false;
function renderActions() {
if (!actionsBox || !currentEvent) return;
actionsBox.hidden = false;
actionsBox.textContent = '';
fpArmed = false;
if (currentEvent.false_positive_by) {
setNote('오탐으로 판정됨 · ' + currentEvent.false_positive_by);
return;
}
if (currentEvent.acknowledged_by) {
setNote('확인 완료 · ' + currentEvent.acknowledged_by);
return;
}
var ack = document.createElement('button');
ack.type = 'button';
ack.className = 'btn-primary';
ack.textContent = '확인 처리';
ack.addEventListener('click', function () {
ack.disabled = true;
post('/ack')
.then(function (res) {
if (!res) return;
currentEvent.acknowledged_by = window.APP_USER || '확인';
renderActions();
renderMeta();
})
.catch(function () { ack.disabled = false; setNote('확인 처리에 실패했습니다.', true); });
});
actionsBox.appendChild(ack);
var fp = document.createElement('button');
fp.type = 'button';
fp.className = 'fp-btn';
fp.textContent = '오탐';
fp.setAttribute('aria-label', '오탐으로 표시 (확인 처리됨, 오탐 통계에 집계)');
fp.addEventListener('click', function () {
if (!fpArmed) {
fpArmed = true;
fp.classList.add('is-armed');
fp.textContent = '오탐 확정?';
setTimeout(function () {
if (!fpArmed) return;
fpArmed = false; fp.classList.remove('is-armed'); fp.textContent = '오탐';
}, 4000);
return;
}
fp.disabled = true; ack.disabled = true;
post('/false-positive')
.then(function (res) {
if (!res) return;
currentEvent.acknowledged_by = window.APP_USER || '확인';
currentEvent.false_positive_by = window.APP_USER || '오탐';
renderActions();
renderMeta();
})
.catch(function () { fp.disabled = false; ack.disabled = false; setNote('오탐 처리에 실패했습니다.', true); });
});
actionsBox.appendChild(fp);
}
function renderMeta() {
var ev = currentEvent || {};
meta.textContent = '';
meta.removeAttribute('aria-busy');
addRow('심각도', buildBadge(ev.severity));
addRow('유형', textNode(TYPE_LABELS[ev.event_type] || ev.event_type));
addRow('장치', textNode(deviceNames[ev.device_id] || ev.device_id));
addRow('카메라', textNode(DeviceScope.label(ev.camera_id), 'mono'));
addRow('발생 시각', textNode(formatTime(ev.occurred_at), 'mono'));
addRow('지속', textNode(fmtDuration(ev.duration_s)));
if (ev.track_id !== null && ev.track_id !== undefined) addRow('추적 ID', textNode(ev.track_id, 'mono'));
if (ev.zone_id) addRow('구역', textNode(ev.zone_id, 'mono'));
var status = ev.false_positive_by
? '오탐 (' + ev.false_positive_by + ')'
: (ev.acknowledged_by ? '확인됨 (' + ev.acknowledged_by + ')' : '미확인');
addRow('처리 상태', textNode(status));
// 측정 지표: 엣지가 이벤트에 실은 수치 전부. ergo_report는 이 블록이 본문이다.
var metrics = ev.metrics || {};
Object.keys(metrics).forEach(function (key) {
addRow(METRIC_LABELS[key] || key, textNode(metricValue(key, metrics[key])));
});
}
function renderHead() {
var ev = currentEvent || {};
if (titleEl) titleEl.textContent = TYPE_LABELS[ev.event_type] || String(ev.event_type || '이벤트 상세');
if (whenEl) {
whenEl.textContent = '';
whenEl.appendChild(textNode(deviceNames[ev.device_id] || ev.device_id, 'ev-src'));
whenEl.appendChild(textNode(ev.camera_id, 'ev-cam'));
whenEl.appendChild(textNode(formatTime(ev.occurred_at), 'ev-at'));
}
}
function renderSnapshots(snaps) {
if (!snapBox) return;
snapBox.textContent = '';
var kept = (snaps || []).filter(function (s) { return s && s.file_uuid; });
if (kept.length === 0) {
var none = document.createElement('p');
none.className = 'detail-empty';
none.textContent = '이 이벤트에는 저장된 스냅샷이 없습니다 (미촬영이거나 PIPA 보존기간이 지나 삭제됨).';
snapBox.appendChild(none);
return;
}
// The edge sends an evidence TIMELINE (oldest → the event moment), so caption by
// position from the end — exact offsets are edge config the server doesn't know, and
// inventing seconds here would be a fabricated measurement on an accident record.
var CAPTIONS = ['이전', '직전', '발생 시점'];
kept.forEach(function (s, i) {
var fig = document.createElement('figure');
fig.className = 'detail-snap';
var img = document.createElement('img');
img.src = B + '/snap/' + encodeURIComponent(s.file_uuid);
var cap = kept.length > 1
? CAPTIONS[Math.max(0, CAPTIONS.length - kept.length + i)] : '발생 시점';
img.alt = '사고 스냅샷 — ' + cap;
img.className = 'detail-snapshot';
img.loading = 'lazy';
fig.appendChild(img);
var fc = document.createElement('figcaption');
fc.textContent = cap;
fig.appendChild(fc);
snapBox.appendChild(fig);
});
}
// --- outbound links + improvement action ---------------------------------------------
function renderLinks() {
var ev = currentEvent || {};
var actionCard = document.getElementById('detail-action-card')
|| document.getElementById('detail-action-form');
if (actionCard) actionCard.hidden = !isCameraEvent(ev);
var pkg = document.getElementById('detail-package');
if (pkg) pkg.href = B + '/events/' + encodeURIComponent(id) + '/package/view';
var zones = document.getElementById('detail-zones');
if (zones) {
// Deep-link the zone editor straight at this camera. Reading an incident and then having
// to re-select the camera by hand on the next screen is the same "the system knows this
// and asks you anyway" problem the editor's own picker exists to remove.
zones.href = B + '/zones?device=' + encodeURIComponent(ev.device_id || '')
+ '&camera=' + encodeURIComponent(ev.camera_id || '');
// 노드 단위 사건(장치 이상·감시 성능 저하)에는 카메라가 없다. 링크를 남겨 두면
// ?camera=__device__ 로 편집기가 열리고, 그 값이 그대로 저장되면 어떤 카메라와도
// 매칭되지 않는 구역이 '활성'으로 남는다. 서버도 막지만 여기서도 내보내지 않는다.
zones.hidden = !ev.camera_id || DeviceScope.is(ev.camera_id);
}
}
/** 이 사건이 카메라에 대한 것인가. 개선 조치는 카메라 단위 개념이라 노드 사건에는 없다. */
function isCameraEvent(ev) {
return !!(ev && ev.camera_id) && !DeviceScope.is(ev.camera_id);
}
function wireActionForm() {
var form = document.getElementById('detail-action-form');
var note = document.getElementById('detail-action-note');
var status = document.getElementById('detail-action-status');
if (!form) return;
form.addEventListener('submit', function (e) {
e.preventDefault();
var text = (note.value || '').trim();
if (!text || !currentEvent) return;
status.textContent = '기록 중…';
fetch(B + '/api/actions', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Csrf-Token': csrfToken || '' },
body: JSON.stringify({
camera_id: currentEvent.camera_id,
note: text,
event_id: currentEvent.event_id
})
})
.then(function (r) {
if (r.status === 401) { window.location.href = B + '/login'; return; }
if (!r.ok) throw new Error('action_failed');
note.value = '';
status.textContent = '기록되었습니다. 안전 리포트의 [개선 조치 효과]에서 전후 비교를 볼 수 있습니다.';
})
.catch(function () { status.textContent = '기록에 실패했습니다. 권한을 확인하세요.'; });
});
}
function render(data) {
currentEvent = data.event || {};
renderHead();
renderMeta();
renderActions();
renderLinks();
renderSnapshots(data.snapshots);
}
// Device NAMES, so the incident record says "1공장 프레스라인" and not "pi-a1" — the same
// provenance the stream row shows. A failure here is non-fatal: the raw id stands in.
function loadDevices() {
return fetch(B + '/api/devices', { headers: { Accept: 'application/json' } })
.then(function (res) { return res.ok ? res.json() : null; })
.then(function (d) {
if (!d || !Array.isArray(d.devices)) return;
d.devices.forEach(function (dev) { if (dev.name) deviceNames[dev.device_id] = dev.name; });
})
.catch(function () { /* raw id stands in */ });
}
loadCsrf();
wireActionForm();
// Names first so the record never paints an id and then swap it for a name a beat later.
loadDevices().then(function () {
return fetch(B + '/events/' + encodeURIComponent(id), { headers: { Accept: 'application/json' } })
.then(function (res) {
if (res.status === 401) { window.location.href = B + '/login'; return null; }
if (!res.ok) throw new Error('detail_failed:' + res.status);
return res.json();
})
.then(function (data) { if (data) render(data); })
.catch(function () {
meta.textContent = '불러오기 실패';
meta.removeAttribute('aria-busy');
});
});
})();