wing-ops/backend/src/aerial/aerialService.ts
Nan Kyung Lee 626fea4c75 feat(aerial): CCTV 오일 감지 GPU 추론 연동 및 HNS 초기 핀 제거
CCTV 오일 유출 감지:
- GPU 추론 서버 FastAPI 서비스 (oil_inference_server.py)
- Express 프록시 엔드포인트 (POST /api/aerial/oil-detect)
- 프론트엔드 API 연동 (oilDetection.ts, useOilDetection.ts)
- 4종 유류 클래스별 색상 오버레이 (OilDetectionOverlay.tsx)
- 캡처 기능 (비디오+오버레이 합성 PNG 다운로드)
- Rate limit HLS 스트리밍 skip + 한도 500 상향

HNS 대기확산:
- 초기 핀 포인트 제거 (지도 클릭으로 선택)
- 좌표 미선택 시 안내 메시지 표시

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 13:31:02 +09:00

401 lines
11 KiB
TypeScript

import { wingPool } from '../db/wingDb.js';
// ============================================================
// AERIAL_MEDIA
// ============================================================
interface AerialMediaItem {
aerialMediaSn: number;
acdntSn: number | null;
fileNm: string;
orgnlNm: string | null;
filePath: string | null;
lon: number | null;
lat: number | null;
locDc: string | null;
equipTpCd: string | null;
equipNm: string | null;
mediaTpCd: string | null;
takngDtm: string | null;
fileSz: string | null;
resolution: string | null;
regDtm: string;
}
interface ListMediaInput {
equipType?: string;
mediaType?: string;
acdntSn?: number;
search?: string;
}
function rowToMedia(r: Record<string, unknown>): AerialMediaItem {
return {
aerialMediaSn: r.aerial_media_sn as number,
acdntSn: r.acdnt_sn as number | null,
fileNm: r.file_nm as string,
orgnlNm: r.orgnl_nm as string | null,
filePath: r.file_path as string | null,
lon: r.lon ? parseFloat(r.lon as string) : null,
lat: r.lat ? parseFloat(r.lat as string) : null,
locDc: r.loc_dc as string | null,
equipTpCd: r.equip_tp_cd as string | null,
equipNm: r.equip_nm as string | null,
mediaTpCd: r.media_tp_cd as string | null,
takngDtm: r.takng_dtm as string | null,
fileSz: r.file_sz as string | null,
resolution: r.resolution as string | null,
regDtm: r.reg_dtm as string,
};
}
export async function listMedia(input: ListMediaInput): Promise<AerialMediaItem[]> {
const conditions: string[] = ["USE_YN = 'Y'"];
const params: (string | number)[] = [];
let idx = 1;
if (input.equipType) {
conditions.push(`EQUIP_TP_CD = $${idx++}`);
params.push(input.equipType);
}
if (input.mediaType) {
conditions.push(`MEDIA_TP_CD = $${idx++}`);
params.push(input.mediaType);
}
if (input.acdntSn) {
conditions.push(`ACDNT_SN = $${idx++}`);
params.push(input.acdntSn);
}
if (input.search) {
conditions.push(`(FILE_NM ILIKE '%' || $${idx} || '%' OR EQUIP_NM ILIKE '%' || $${idx} || '%')`);
params.push(input.search);
idx++;
}
const { rows } = await wingPool.query(
`SELECT AERIAL_MEDIA_SN, ACDNT_SN, FILE_NM, ORGNL_NM, FILE_PATH,
LON, LAT, LOC_DC, EQUIP_TP_CD, EQUIP_NM, MEDIA_TP_CD,
TAKNG_DTM, FILE_SZ, RESOLUTION, REG_DTM
FROM AERIAL_MEDIA
WHERE ${conditions.join(' AND ')}
ORDER BY TAKNG_DTM DESC NULLS LAST`,
params
);
return rows.map((r: Record<string, unknown>) => rowToMedia(r));
}
export async function createMedia(input: {
acdntSn?: number;
fileNm: string;
orgnlNm?: string;
filePath?: string;
lon?: number;
lat?: number;
locDc?: string;
equipTpCd?: string;
equipNm?: string;
mediaTpCd?: string;
takngDtm?: string;
fileSz?: string;
resolution?: string;
}): Promise<{ aerialMediaSn: number }> {
const { rows } = await wingPool.query(
`INSERT INTO AERIAL_MEDIA (
ACDNT_SN, FILE_NM, ORGNL_NM, FILE_PATH,
LON, LAT,
GEOM,
LOC_DC, EQUIP_TP_CD, EQUIP_NM, MEDIA_TP_CD,
TAKNG_DTM, FILE_SZ, RESOLUTION
) VALUES (
$1, $2, $3, $4,
$5, $6,
CASE WHEN $5 IS NOT NULL AND $6 IS NOT NULL THEN ST_SetSRID(ST_MakePoint($5::float, $6::float), 4326) END,
$7, $8, $9, $10,
$11, $12, $13
) RETURNING AERIAL_MEDIA_SN`,
[
input.acdntSn || null,
input.fileNm,
input.orgnlNm || null,
input.filePath || null,
input.lon || null,
input.lat || null,
input.locDc || null,
input.equipTpCd || null,
input.equipNm || null,
input.mediaTpCd || null,
input.takngDtm || null,
input.fileSz || null,
input.resolution || null,
]
);
return { aerialMediaSn: rows[0].aerial_media_sn };
}
// ============================================================
// CCTV_CAMERA
// ============================================================
interface CctvCameraItem {
cctvSn: number;
cameraNm: string;
regionNm: string | null;
lon: number | null;
lat: number | null;
locDc: string | null;
coordDc: string | null;
sttsCd: string;
ptzYn: string;
sourceNm: string | null;
streamUrl: string | null;
regDtm: string;
}
interface ListCctvInput {
region?: string;
status?: string;
}
function rowToCctv(r: Record<string, unknown>): CctvCameraItem {
return {
cctvSn: r.cctv_sn as number,
cameraNm: r.camera_nm as string,
regionNm: r.region_nm as string | null,
lon: r.lon ? parseFloat(r.lon as string) : null,
lat: r.lat ? parseFloat(r.lat as string) : null,
locDc: r.loc_dc as string | null,
coordDc: r.coord_dc as string | null,
sttsCd: r.stts_cd as string,
ptzYn: r.ptz_yn as string,
sourceNm: r.source_nm as string | null,
streamUrl: r.stream_url as string | null,
regDtm: r.reg_dtm as string,
};
}
export async function listCctv(input: ListCctvInput): Promise<CctvCameraItem[]> {
const conditions: string[] = ["USE_YN = 'Y'"];
const params: string[] = [];
let idx = 1;
if (input.region) {
conditions.push(`REGION_NM = $${idx++}`);
params.push(input.region);
}
if (input.status) {
conditions.push(`STTS_CD = $${idx++}`);
params.push(input.status);
}
const { rows } = await wingPool.query(
`SELECT CCTV_SN, CAMERA_NM, REGION_NM, LON, LAT,
LOC_DC, COORD_DC, STTS_CD, PTZ_YN, SOURCE_NM, STREAM_URL, REG_DTM
FROM CCTV_CAMERA
WHERE ${conditions.join(' AND ')}
ORDER BY REGION_NM, CAMERA_NM`,
params
);
return rows.map((r: Record<string, unknown>) => rowToCctv(r));
}
// ============================================================
// SAT_REQUEST
// ============================================================
interface SatRequestItem {
satReqSn: number;
reqCd: string;
acdntSn: number | null;
lon: number | null;
lat: number | null;
zoneDc: string | null;
coordDc: string | null;
zoneAreaKm2: number | null;
satNm: string | null;
providerNm: string | null;
resolution: string | null;
purposeDc: string | null;
reqstrNm: string | null;
reqDtm: string | null;
expectedRcvDtm: string | null;
sttsCd: string;
regDtm: string;
}
interface ListSatRequestsInput {
status?: string;
}
function rowToSatRequest(r: Record<string, unknown>): SatRequestItem {
return {
satReqSn: r.sat_req_sn as number,
reqCd: r.req_cd as string,
acdntSn: r.acdnt_sn as number | null,
lon: r.lon ? parseFloat(r.lon as string) : null,
lat: r.lat ? parseFloat(r.lat as string) : null,
zoneDc: r.zone_dc as string | null,
coordDc: r.coord_dc as string | null,
zoneAreaKm2: r.zone_area_km2 ? parseFloat(r.zone_area_km2 as string) : null,
satNm: r.sat_nm as string | null,
providerNm: r.provider_nm as string | null,
resolution: r.resolution as string | null,
purposeDc: r.purpose_dc as string | null,
reqstrNm: r.reqstr_nm as string | null,
reqDtm: r.req_dtm as string | null,
expectedRcvDtm: r.expected_rcv_dtm as string | null,
sttsCd: r.stts_cd as string,
regDtm: r.reg_dtm as string,
};
}
export async function listSatRequests(input: ListSatRequestsInput): Promise<SatRequestItem[]> {
const conditions: string[] = ["USE_YN = 'Y'"];
const params: string[] = [];
let idx = 1;
if (input.status) {
conditions.push(`STTS_CD = $${idx++}`);
params.push(input.status);
}
const { rows } = await wingPool.query(
`SELECT SAT_REQ_SN, REQ_CD, ACDNT_SN, LON, LAT,
ZONE_DC, COORD_DC, ZONE_AREA_KM2, SAT_NM, PROVIDER_NM,
RESOLUTION, PURPOSE_DC, REQSTR_NM,
REQ_DTM, EXPECTED_RCV_DTM, STTS_CD, REG_DTM
FROM SAT_REQUEST
WHERE ${conditions.join(' AND ')}
ORDER BY REQ_DTM DESC NULLS LAST`,
params
);
return rows.map((r: Record<string, unknown>) => rowToSatRequest(r));
}
export async function createSatRequest(input: {
reqCd: string;
acdntSn?: number;
lon?: number;
lat?: number;
zoneDc?: string;
coordDc?: string;
zoneAreaKm2?: number;
satNm?: string;
providerNm?: string;
resolution?: string;
purposeDc?: string;
reqstrNm?: string;
reqDtm?: string;
expectedRcvDtm?: string;
}): Promise<{ satReqSn: number }> {
const { rows } = await wingPool.query(
`INSERT INTO SAT_REQUEST (
REQ_CD, ACDNT_SN, LON, LAT,
GEOM,
ZONE_DC, COORD_DC, ZONE_AREA_KM2,
SAT_NM, PROVIDER_NM, RESOLUTION,
PURPOSE_DC, REQSTR_NM, REQ_DTM, EXPECTED_RCV_DTM
) VALUES (
$1, $2, $3, $4,
CASE WHEN $3 IS NOT NULL AND $4 IS NOT NULL THEN ST_SetSRID(ST_MakePoint($3::float, $4::float), 4326) END,
$5, $6, $7,
$8, $9, $10,
$11, $12, $13, $14
) RETURNING SAT_REQ_SN`,
[
input.reqCd,
input.acdntSn || null,
input.lon || null,
input.lat || null,
input.zoneDc || null,
input.coordDc || null,
input.zoneAreaKm2 || null,
input.satNm || null,
input.providerNm || null,
input.resolution || null,
input.purposeDc || null,
input.reqstrNm || null,
input.reqDtm || null,
input.expectedRcvDtm || null,
]
);
return { satReqSn: rows[0].sat_req_sn };
}
const VALID_SAT_STATUSES = ['PENDING', 'SHOOTING', 'COMPLETED', 'CANCELLED'] as const;
type SatStatus = typeof VALID_SAT_STATUSES[number];
export function isValidSatStatus(value: string): value is SatStatus {
return (VALID_SAT_STATUSES as readonly string[]).includes(value);
}
export async function updateSatRequestStatus(sn: number, sttsCd: string): Promise<void> {
await wingPool.query(
`UPDATE SAT_REQUEST SET STTS_CD = $1 WHERE SAT_REQ_SN = $2 AND USE_YN = 'Y'`,
[sttsCd, sn]
);
}
// ============================================================
// OIL INFERENCE (GPU 서버 프록시)
// ============================================================
const OIL_INFERENCE_URL = process.env.OIL_INFERENCE_URL || 'http://localhost:8090';
const INFERENCE_TIMEOUT_MS = 10_000;
export interface OilInferenceRegion {
classId: number;
className: string;
pixelCount: number;
percentage: number;
thicknessMm: number;
}
export interface OilInferenceResult {
mask: string; // base64 uint8 array (values 0-4)
width: number;
height: number;
regions: OilInferenceRegion[];
}
/** GPU 추론 서버에 이미지를 전송하고 세그멘테이션 결과를 반환한다. */
export async function requestOilInference(imageBase64: string): Promise<OilInferenceResult> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), INFERENCE_TIMEOUT_MS);
try {
const response = await fetch(`${OIL_INFERENCE_URL}/inference`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image: imageBase64 }),
signal: controller.signal,
});
if (!response.ok) {
const detail = await response.text().catch(() => '');
throw new Error(`Inference server responded ${response.status}: ${detail}`);
}
return await response.json() as OilInferenceResult;
} finally {
clearTimeout(timeout);
}
}
/** GPU 추론 서버 헬스체크 */
export async function checkInferenceHealth(): Promise<{ status: string; device?: string }> {
try {
const response = await fetch(`${OIL_INFERENCE_URL}/health`, {
signal: AbortSignal.timeout(3000),
});
if (!response.ok) throw new Error(`status ${response.status}`);
return await response.json() as { status: string; device?: string };
} catch {
return { status: 'unavailable' };
}
}