EVMS 완전 자동화: - 공기 지연 AI 예측 (SPI 기반 준공일 예측) - 기성청구 가능 금액 자동 산출 - 매일 자정 EVMS 스냅샷 자동 생성 (APScheduler) - 매일 07:00 GONGSA 아침 브리핑 자동 생성 준공도서 패키지: - 준공 요약 + 품질시험 목록 + 검측 이력 + 인허가 현황 → ZIP 번들 - 준공 준비 체크리스트 API - 4종 HTML 템플릿 (WeasyPrint PDF 출력) Vision AI Level 3: - 설계 도면 vs 현장 사진 비교 보조 판독 (Claude Vision) - 철근 배근, 거푸집 치수 1차 분석 설계도서 파싱: - PDF 이미지/텍스트에서 공종·수량·규격 자동 추출 - Pandoc HWP 출력 지원 발주처 전용 포털: - 토큰 기반 읽기 전용 API - 공사 현황 대시보드, 공정률 추이 차트 에이전트 협업 고도화: - 협업 시나리오 (concrete_pour, excavation, weekly_report) - GONGSA→PUMJIL→ANJEON 순차 처리 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
156 lines
5.7 KiB
Python
156 lines
5.7 KiB
Python
"""
|
|
Vision AI API — Level 1 (사진 분류) + Level 2 (안전장비 감지)
|
|
"""
|
|
import uuid
|
|
from fastapi import APIRouter, UploadFile, File, HTTPException, Form
|
|
from fastapi.responses import JSONResponse
|
|
from sqlalchemy import select
|
|
|
|
from app.deps import CurrentUser, DB
|
|
from app.models.project import Project
|
|
from app.models.daily_report import DailyReport, DailyReportPhoto
|
|
from app.services.vision_service import classify_photo, analyze_safety, compare_with_drawing
|
|
|
|
router = APIRouter(prefix="/projects/{project_id}/vision", tags=["Vision AI"])
|
|
|
|
COMPARISON_TYPES = {"rebar": "철근 배근", "formwork": "거푸집", "general": "일반 비교"}
|
|
|
|
ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp", "image/heic"}
|
|
MAX_SIZE_MB = 10
|
|
|
|
|
|
async def _get_project_or_404(project_id: uuid.UUID, db: DB) -> Project:
|
|
r = await db.execute(select(Project).where(Project.id == project_id))
|
|
p = r.scalar_one_or_none()
|
|
if not p:
|
|
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다")
|
|
return p
|
|
|
|
|
|
@router.post("/classify")
|
|
async def classify_field_photo(
|
|
project_id: uuid.UUID,
|
|
db: DB,
|
|
current_user: CurrentUser,
|
|
file: UploadFile = File(...),
|
|
location_hint: str | None = Form(None),
|
|
daily_report_id: uuid.UUID | None = Form(None),
|
|
):
|
|
"""
|
|
현장 사진 분류 (Vision AI Level 1)
|
|
- 공종 자동 분류
|
|
- 안전장비 착용 여부 1차 확인
|
|
- 작업일보 캡션 자동 생성
|
|
- daily_report_id 제공 시 해당 일보에 사진 자동 첨부
|
|
"""
|
|
await _get_project_or_404(project_id, db)
|
|
|
|
# 파일 검증
|
|
content_type = file.content_type or "image/jpeg"
|
|
if content_type not in ALLOWED_TYPES:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"지원하지 않는 파일 형식입니다. 허용: {', '.join(ALLOWED_TYPES)}"
|
|
)
|
|
|
|
image_data = await file.read()
|
|
if len(image_data) > MAX_SIZE_MB * 1024 * 1024:
|
|
raise HTTPException(status_code=400, detail=f"파일 크기가 {MAX_SIZE_MB}MB를 초과합니다")
|
|
|
|
# Vision AI 분류
|
|
result = await classify_photo(
|
|
image_data=image_data,
|
|
media_type=content_type,
|
|
location_hint=location_hint,
|
|
)
|
|
|
|
# 작업일보에 사진 첨부 (daily_report_id 제공 시)
|
|
if daily_report_id:
|
|
dr = await db.execute(
|
|
select(DailyReport).where(
|
|
DailyReport.id == daily_report_id,
|
|
DailyReport.project_id == project_id,
|
|
)
|
|
)
|
|
report = dr.scalar_one_or_none()
|
|
if report:
|
|
# 사진 수 카운트
|
|
photos_q = await db.execute(
|
|
select(DailyReportPhoto).where(DailyReportPhoto.daily_report_id == daily_report_id)
|
|
)
|
|
existing = photos_q.scalars().all()
|
|
|
|
photo = DailyReportPhoto(
|
|
daily_report_id=daily_report_id,
|
|
s3_key=f"vision/{project_id}/{daily_report_id}/{file.filename or 'photo.jpg'}",
|
|
caption=result.get("caption", ""),
|
|
sort_order=len(existing),
|
|
)
|
|
db.add(photo)
|
|
await db.commit()
|
|
result["attached_to_report"] = str(daily_report_id)
|
|
|
|
return JSONResponse(content=result)
|
|
|
|
|
|
@router.post("/compare-drawing")
|
|
async def compare_drawing(
|
|
project_id: uuid.UUID,
|
|
db: DB,
|
|
current_user: CurrentUser,
|
|
field_photo: UploadFile = File(..., description="현장 사진"),
|
|
drawing: UploadFile = File(..., description="설계 도면 이미지"),
|
|
comparison_type: str = Form("rebar", description="rebar/formwork/general"),
|
|
):
|
|
"""
|
|
Vision AI Level 3 — 설계 도면 vs 현장 사진 비교 보조 판독
|
|
철근 배근, 거푸집 치수 등을 도면과 1차 비교합니다.
|
|
⚠️ 최종 합격/불합격 판정은 현장 책임자가 합니다.
|
|
"""
|
|
await _get_project_or_404(project_id, db)
|
|
|
|
if comparison_type not in COMPARISON_TYPES:
|
|
raise HTTPException(status_code=400, detail=f"comparison_type은 {list(COMPARISON_TYPES.keys())} 중 하나여야 합니다")
|
|
|
|
field_data = await field_photo.read()
|
|
drawing_data = await drawing.read()
|
|
|
|
if len(field_data) > MAX_SIZE_MB * 1024 * 1024 or len(drawing_data) > MAX_SIZE_MB * 1024 * 1024:
|
|
raise HTTPException(status_code=400, detail=f"파일 크기가 {MAX_SIZE_MB}MB를 초과합니다")
|
|
|
|
result = await compare_with_drawing(
|
|
field_photo=field_data,
|
|
drawing_image=drawing_data,
|
|
comparison_type=comparison_type,
|
|
field_media_type=field_photo.content_type or "image/jpeg",
|
|
drawing_media_type=drawing.content_type or "image/jpeg",
|
|
)
|
|
return JSONResponse(content=result)
|
|
|
|
|
|
@router.post("/safety-check")
|
|
async def safety_check(
|
|
project_id: uuid.UUID,
|
|
db: DB,
|
|
current_user: CurrentUser,
|
|
file: UploadFile = File(...),
|
|
):
|
|
"""
|
|
안전장비 착용 감지 (Vision AI Level 2)
|
|
안전모/안전조끼 착용 여부를 분석하고 위반 사항을 반환합니다.
|
|
최종 판정은 현장 책임자가 합니다.
|
|
"""
|
|
await _get_project_or_404(project_id, db)
|
|
|
|
content_type = file.content_type or "image/jpeg"
|
|
if content_type not in ALLOWED_TYPES:
|
|
raise HTTPException(status_code=400, detail="지원하지 않는 파일 형식입니다")
|
|
|
|
image_data = await file.read()
|
|
if len(image_data) > MAX_SIZE_MB * 1024 * 1024:
|
|
raise HTTPException(status_code=400, detail=f"파일 크기가 {MAX_SIZE_MB}MB를 초과합니다")
|
|
|
|
result = await analyze_safety(image_data=image_data, media_type=content_type)
|
|
result["disclaimer"] = "이 결과는 AI 1차 분석이며, 최종 판정은 현장 책임자가 합니다."
|
|
return JSONResponse(content=result)
|