move_detect/server/public/assets/package.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

240 lines
11 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.

/**
* 사고조사 패키지.
*
* 사고가 난 다음 날 요구되는 것은 "그 사건 하나"가 아니라 그 사건의 맥락 전부다:
* 무슨 일이 있었는지, 그 전후에 같은 카메라가 무엇을 봤는지, 누구에게 언제 알려졌는지,
* 이미 어떤 조치가 있었는지, 그 위험요인을 무엇으로 통제하고 있었는지. 손으로 모으면
* 반나절이고, 사고 직후에 반나절을 쓸 수 있는 사람은 없다.
*
* 인쇄 CSS 가 앱 크롬을 걷어내므로 이 화면 그대로 PDF 로 저장해 제출할 수 있다.
*
* XSS-safe: 모든 동적 값은 textContent.
*/
(function () {
'use strict';
var B = window.APP_BASE || '';
var root = document.getElementById('package-root');
if (!root) return;
var m = location.pathname.match(/\/events\/(\d+)\/package/);
if (!m) { root.textContent = '잘못된 주소입니다.'; return; }
var id = m[1];
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: '화재·연기'
};
var SEV_LABELS = { critical: '위급', high: '높음', warning: '경고', info: '정보' };
var ALERT_STATUS = {
sent: '발송됨', pending: '발송 대기', failed: '발송 실패', suppressed: '억제됨 (정비 중)'
};
function el(tag, cls, text) {
var n = document.createElement(tag);
if (cls) n.className = cls;
if (text !== undefined && text !== null) n.textContent = text;
return n;
}
function pad2(n) { return n < 10 ? '0' + n : String(n); }
function stamp(ts) {
var d = new Date(Number(ts) * 1000);
if (isNaN(d.getTime())) return '-';
return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate())
+ ' ' + pad2(d.getHours()) + ':' + pad2(d.getMinutes()) + ':' + pad2(d.getSeconds());
}
function clockOnly(ts) {
var d = new Date(Number(ts) * 1000);
return pad2(d.getHours()) + ':' + pad2(d.getMinutes()) + ':' + pad2(d.getSeconds());
}
function section(title) {
var s = el('section', 'pkg-section');
s.appendChild(el('h2', 'pkg-h2', title));
return s;
}
function kv(parent, label, value) {
var row = el('div', 'pkg-kv');
row.appendChild(el('dt', null, label));
row.appendChild(el('dd', null, value === null || value === undefined || value === '' ? '-' : String(value)));
parent.appendChild(row);
}
function render(d) {
var ev = d.event || {};
root.textContent = '';
// ---- 표지 -----------------------------------------------------------------
var head = el('header', 'pkg-head');
head.appendChild(el('p', 'pkg-doc-kind', '작업장 안전 사고조사 패키지'));
head.appendChild(el('h1', 'pkg-title', TYPE_LABELS[ev.event_type] || ev.event_type));
var sub = el('p', 'pkg-sub');
sub.textContent = [SEV_LABELS[ev.severity] || ev.severity, DeviceScope.label(ev.camera_id), stamp(ev.occurred_at)]
.filter(Boolean).join(' · ');
head.appendChild(sub);
root.appendChild(head);
// ---- 사건 개요 -------------------------------------------------------------
var facts = section('사건 개요');
var dl = el('dl', 'pkg-facts');
kv(dl, '사건 번호', ev.event_id);
kv(dl, '유형', TYPE_LABELS[ev.event_type] || ev.event_type);
kv(dl, '심각도', SEV_LABELS[ev.severity] || ev.severity);
kv(dl, '장치', ev.device_id);
kv(dl, '카메라', DeviceScope.label(ev.camera_id));
kv(dl, '현장', ev.site);
kv(dl, '발생 시각', stamp(ev.occurred_at));
kv(dl, '지속', (ev.duration_s || 0) + '초');
kv(dl, '처리 상태', ev.false_positive_by ? ('오탐 판정 · ' + ev.false_positive_by)
: (ev.acknowledged_by ? ('확인 · ' + ev.acknowledged_by + ' · ' + stamp(ev.acknowledged_at))
: '미확인'));
// 대응 시간은 조사에서 가장 먼저 묻는 숫자다.
if (ev.acknowledged_at) {
var tta = Math.max(0, Math.round(ev.acknowledged_at - ev.occurred_at));
kv(dl, '대응 소요', tta < 60 ? tta + '초' : Math.floor(tta / 60) + '분 ' + (tta % 60) + '초');
}
facts.appendChild(dl);
root.appendChild(facts);
// ---- 증거 프레임 -----------------------------------------------------------
var evidence = section('증거 프레임');
var snaps = (d.snapshots || []).filter(function (s) { return s && s.file_uuid; });
if (!snaps.length) {
evidence.appendChild(el('p', 'pkg-empty',
'저장된 스냅샷이 없습니다 (미촬영이거나 개인정보 보존기간이 지나 삭제됨).'));
} else {
var strip = el('div', 'pkg-frames');
var CAPTIONS = ['이전', '직전', '발생 시점'];
snaps.forEach(function (s, i) {
var fig = el('figure', 'pkg-frame');
var img = document.createElement('img');
img.src = B + '/snap/' + encodeURIComponent(s.file_uuid);
img.alt = '사고 스냅샷';
img.loading = 'lazy';
fig.appendChild(img);
fig.appendChild(el('figcaption', null,
snaps.length > 1 ? CAPTIONS[Math.max(0, CAPTIONS.length - snaps.length + i)] : '발생 시점'));
strip.appendChild(fig);
});
evidence.appendChild(strip);
evidence.appendChild(el('p', 'pkg-note',
'얼굴은 개인정보 보호를 위해 촬영 시점에 블러 처리되어 저장됩니다. 프레임 간격은 엣지의 스냅샷 주기 설정을 따릅니다.'));
}
root.appendChild(evidence);
// ---- 전후 타임라인 ---------------------------------------------------------
var win = Math.round((d.context_window_s || 1800) / 60);
var tl = section('같은 카메라의 전후 ' + win + '분');
var rows = d.timeline || [];
if (!rows.length) {
tl.appendChild(el('p', 'pkg-empty', '해당 구간에 다른 감지가 없습니다.'));
} else {
var list = el('ol', 'pkg-timeline');
rows.forEach(function (r) {
var li = el('li', 'pkg-tl-row' + (r.event_id === ev.event_id ? ' is-subject' : ''));
li.appendChild(el('span', 'pkg-tl-time', clockOnly(r.occurred_at)));
li.appendChild(el('span', 'pkg-tl-sev sev-' + r.severity, SEV_LABELS[r.severity] || r.severity));
li.appendChild(el('span', 'pkg-tl-type', TYPE_LABELS[r.event_type] || r.event_type));
li.appendChild(el('span', 'pkg-tl-state',
r.false_positive_by ? '오탐' : (r.acknowledged_by ? '확인됨' : '미확인')));
list.appendChild(li);
});
tl.appendChild(list);
}
root.appendChild(tl);
// ---- 통보 이력 -------------------------------------------------------------
// 시스템이 "봤다"는 것만 증명하고 사람에게 "닿았는가"를 비워두면 조사의 절반이 빈다.
var notif = section('통보 이력');
var alerts = d.notifications || [];
if (!alerts.length) {
notif.appendChild(el('p', 'pkg-empty',
'이 사건에 대한 통보 기록이 없습니다. 통보 경로가 설정되지 않았거나, 심각도가 발송 기준에 못 미쳤습니다.'));
} else {
var nt = el('ul', 'pkg-alerts');
alerts.forEach(function (a) {
var li = el('li', 'pkg-alert is-' + a.status);
li.appendChild(el('span', 'pkg-alert-ch', (a.channel_name || '삭제된 채널')));
li.appendChild(el('span', 'pkg-alert-status', ALERT_STATUS[a.status] || a.status));
li.appendChild(el('span', 'pkg-alert-when',
a.sent_at ? stamp(a.sent_at) : ('접수 ' + stamp(a.queued_at))));
if (a.last_error) li.appendChild(el('span', 'pkg-alert-err', a.last_error));
nt.appendChild(li);
});
notif.appendChild(nt);
}
root.appendChild(notif);
// ---- 위험성평가 -------------------------------------------------------------
var risk = section('해당 위험요인 · 통제 대책');
if (!d.risk_control) {
risk.appendChild(el('p', 'pkg-empty',
'이 감지 유형에 등록된 위험요인이 없습니다. [안전 관리] 화면에서 등록하면 다음 조사부터 함께 실립니다.'));
} else {
var rc = d.risk_control;
var rdl = el('dl', 'pkg-facts');
kv(rdl, '위험요인', rc.hazard);
kv(rdl, '현재 대책', rc.control);
kv(rdl, '위험도', rc.risk_score + ' (강도 ' + rc.severity_rating + ' × 빈도 ' + rc.likelihood_rating + ')');
kv(rdl, '최종 검토', rc.reviewed_by ? (rc.reviewed_by + ' · ' + stamp(rc.reviewed_at)) : '-');
risk.appendChild(rdl);
}
root.appendChild(risk);
// ---- 조치 이력 -------------------------------------------------------------
var acts = section('이 카메라의 개선 조치 이력');
var actions = d.actions || [];
if (!actions.length) {
acts.appendChild(el('p', 'pkg-empty', '기록된 조치가 없습니다.'));
} else {
var al = el('ul', 'pkg-actions');
actions.forEach(function (a) {
var li = el('li', null);
li.appendChild(el('span', 'pkg-act-date', stamp(a.taken_at).slice(0, 10)));
li.appendChild(el('span', 'pkg-act-note', a.note));
li.appendChild(el('span', 'pkg-act-by', a.created_by));
al.appendChild(li);
});
acts.appendChild(al);
}
root.appendChild(acts);
// ---- 서명 -----------------------------------------------------------------
var sign = section('확인');
var grid = el('div', 'pkg-sign');
['조사자', '안전관리자', '사업주 · 대리인'].forEach(function (role) {
var box = el('div', 'pkg-sign-box');
box.appendChild(el('span', 'pkg-sign-role', role));
box.appendChild(el('span', 'pkg-sign-line', ''));
grid.appendChild(box);
});
sign.appendChild(grid);
root.appendChild(sign);
var foot = el('footer', 'pkg-foot');
foot.textContent = '생성 ' + stamp(d.generated_at) + ' · 작성 ' + (d.generated_by || '-')
+ ' · 본 문서는 AI 감지 기록에 근거한 보조 자료이며 공식 재해조사 결과를 대체하지 않습니다.';
root.appendChild(foot);
document.title = '사고조사 #' + ev.event_id + ' · AI SAFETY CCTV';
}
var printBtn = document.getElementById('package-print');
if (printBtn) printBtn.addEventListener('click', function () { window.print(); });
fetch(B + '/api/events/' + encodeURIComponent(id) + '/package', { headers: { Accept: 'application/json' } })
.then(function (r) {
if (r.status === 401) { window.location.href = B + '/login'; return null; }
if (r.status === 404) { root.textContent = '해당 사건을 찾을 수 없습니다.'; return null; }
if (!r.ok) throw new Error('package_failed');
return r.json();
})
.then(function (d) { if (d) render(d); })
.catch(function () { root.textContent = '패키지를 불러오지 못했습니다.'; });
})();