Compare commits
No commits in common. "master" and "be/develop" have entirely different histories.
master
...
be/develop
42
ai/.gitignore
vendored
@ -1,42 +0,0 @@
|
||||
# Python 기본
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyo
|
||||
*.pyd
|
||||
|
||||
# 환경 설정 파일
|
||||
.env
|
||||
*.env
|
||||
|
||||
# 패키지 디렉토리
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
|
||||
# 빌드 디렉토리
|
||||
build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
|
||||
# 로그 파일
|
||||
*.log
|
||||
|
||||
# Jupyter Notebook 체크포인트
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IDE 관련 파일
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# MacOS 관련 파일
|
||||
.DS_Store
|
||||
|
||||
# 테스트 파일
|
||||
test-data/
|
||||
|
||||
# 리소스
|
||||
resources/
|
||||
datasets/
|
||||
*.pt
|
||||
|
||||
*.jpg
|
30
ai/README.md
@ -1,30 +0,0 @@
|
||||
# FastAPI를 이용한 AI 모델 관련 API
|
||||
|
||||
## conda 환경 세팅
|
||||
```bash
|
||||
conda env create -f environment.yml
|
||||
conda activate worlabel_ai_env
|
||||
```
|
||||
|
||||
## FastAPI Project 구조
|
||||
|
||||
### app/api
|
||||
- api 호출 라우터 정의
|
||||
|
||||
### app/schemas
|
||||
- api의 request/response 등 Pydantic 모델 정의
|
||||
|
||||
### app/services
|
||||
- AI 관련 패키지를 이용하는 메서드 정의
|
||||
|
||||
### app/utils
|
||||
- 프로젝트 전역에서 이용하는 formatter 등 정의
|
||||
|
||||
### resources/models
|
||||
- yolo 기본 모델 6종(default/pretrained, det/seg/cls) 저장
|
||||
|
||||
### resources/projects/{project_id}/models
|
||||
- 프로젝트별 ai 모델 저장
|
||||
|
||||
### resources/datasets
|
||||
- 훈련 데이터셋 저장
|
@ -1,199 +0,0 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from api.yolo.detection import run_predictions, get_random_color, split_data
|
||||
from schemas.predict_request import PredictRequest
|
||||
from schemas.train_request import TrainRequest, TrainDataInfo
|
||||
from schemas.predict_response import PredictResponse, LabelData, Shape
|
||||
from schemas.train_report_data import ReportData
|
||||
from schemas.train_response import TrainResponse
|
||||
from services.load_model import load_classification_model
|
||||
from services.create_model import save_model
|
||||
from utils.file_utils import get_dataset_root_path, process_directories_in_cls, process_image_and_label_in_cls, join_path
|
||||
from utils.slackMessage import send_slack_message
|
||||
from utils.api_utils import send_data_call_api
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/predict")
|
||||
async def classification_predict(request: PredictRequest):
|
||||
|
||||
send_slack_message(f"predict 요청: {request}", status="success")
|
||||
|
||||
# 모델 로드
|
||||
model = get_model(request.project_id, request.m_key)
|
||||
|
||||
# 이미지 데이터 정리
|
||||
url_list = list(map(lambda x:x.image_url, request.image_list))
|
||||
|
||||
# 추론
|
||||
results = run_predictions(model, url_list, request, classes=[]) # classification은 classes를 무시함
|
||||
|
||||
# 추론 결과 변환
|
||||
response = [process_prediction_result(result, image, request.label_map) for result, image in zip(results,request.image_list)]
|
||||
send_slack_message(f"predict 성공{response}", status="success")
|
||||
return response
|
||||
|
||||
# 모델 로드
|
||||
def get_model(project_id:int, model_key:str):
|
||||
try:
|
||||
return load_classification_model(project_id, model_key)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail="exception in get_model(): " + str(e))
|
||||
|
||||
# 추론 결과 처리 함수
|
||||
def process_prediction_result(result, image, label_map):
|
||||
try:
|
||||
shapes = []
|
||||
# top 5에 해당하는 class id 순회
|
||||
for class_id in result.probs.top5:
|
||||
label_name = result.names[class_id] # class id에 해당하는 label_name
|
||||
if label_name in label_map: # name이 사용자 레이블 카테고리에 있을 경우
|
||||
shapes = [
|
||||
Shape(
|
||||
label=label_name,
|
||||
color=get_random_color(),
|
||||
points=[[0.0, 0.0]],
|
||||
group_id=label_map[label_name],
|
||||
shape_type='point',
|
||||
flags={}
|
||||
)
|
||||
] # label_name 설정
|
||||
break
|
||||
|
||||
label_data = LabelData(
|
||||
version="0.0.0",
|
||||
task_type="cls",
|
||||
shapes=shapes,
|
||||
split="none",
|
||||
imageHeight=result.orig_img.shape[0],
|
||||
imageWidth=result.orig_img.shape[1],
|
||||
imageDepth=result.orig_img.shape[2]
|
||||
)
|
||||
|
||||
return PredictResponse(
|
||||
image_id=image.image_id,
|
||||
data=label_data.model_dump_json()
|
||||
)
|
||||
|
||||
except KeyError as e:
|
||||
raise HTTPException(status_code=500, detail="KeyError: " + str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail="exception in process_prediction_result(): " + str(e))
|
||||
|
||||
|
||||
@router.post("/train")
|
||||
async def classification_train(request: TrainRequest):
|
||||
|
||||
send_slack_message(f"train 요청{request}", status="success")
|
||||
|
||||
# 데이터셋 루트 경로 얻기 (프로젝트 id 기반)
|
||||
dataset_root_path = get_dataset_root_path(request.project_id)
|
||||
|
||||
# 모델 로드
|
||||
model = get_model(request.project_id, request.m_key)
|
||||
|
||||
# 이 값을 학습할때 넣으면 이 카테고리들이 학습됨
|
||||
names = list(request.label_map)
|
||||
|
||||
# 데이터 전처리: 학습할 디렉토리 & 데이터셋 설정 파일을 생성
|
||||
process_directories_in_cls(dataset_root_path, names)
|
||||
|
||||
# 데이터 전처리: 데이터를 학습데이터와 테스트 데이터로 분류
|
||||
train_data, test_data = split_data(request.data, request.ratio)
|
||||
|
||||
# 데이터 전처리: 데이터 이미지 및 레이블 다운로드
|
||||
download_data(train_data, test_data, dataset_root_path)
|
||||
|
||||
# 학습
|
||||
results = run_train(request, model,dataset_root_path)
|
||||
|
||||
# best 모델 저장
|
||||
model_key = save_model(project_id=request.project_id, path=join_path(dataset_root_path, "result", "weights", "best.pt"))
|
||||
|
||||
result = results.results_dict
|
||||
|
||||
response = TrainResponse(
|
||||
modelKey=model_key,
|
||||
precision= 0,
|
||||
recall= 0,
|
||||
mAP50= 0,
|
||||
mAP5095= 0,
|
||||
accuracy=result["metrics/accuracy_top1"],
|
||||
fitness= result["fitness"]
|
||||
)
|
||||
|
||||
send_slack_message(f"train 성공{response}", status="success")
|
||||
|
||||
return response
|
||||
|
||||
def download_data(train_data:list[TrainDataInfo], test_data:list[TrainDataInfo], dataset_root_path:str):
|
||||
try:
|
||||
for data in train_data:
|
||||
process_image_and_label_in_cls(data, dataset_root_path, "train")
|
||||
|
||||
for data in test_data:
|
||||
process_image_and_label_in_cls(data, dataset_root_path, "test")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail="exception in download_data(): " + str(e))
|
||||
|
||||
|
||||
def run_train(request, model, dataset_root_path):
|
||||
try:
|
||||
# 데이터 전송 콜백함수
|
||||
def send_data(trainer):
|
||||
try:
|
||||
# 첫번째 epoch는 스킵
|
||||
if trainer.epoch == 0:
|
||||
return
|
||||
|
||||
# 남은 시간 계산(초)
|
||||
left_epochs = trainer.epochs - trainer.epoch
|
||||
left_seconds = left_epochs * trainer.epoch_time
|
||||
|
||||
# 로스 box_loss, cls_loss, dfl_loss
|
||||
loss = trainer.label_loss_items(loss_items=trainer.loss_items)
|
||||
data = ReportData(
|
||||
epoch=trainer.epoch, # 현재 에포크
|
||||
total_epochs=trainer.epochs, # 전체 에포크
|
||||
seg_loss=0, # seg loss
|
||||
box_loss=0, # box loss
|
||||
cls_loss=loss["train/loss"], # cls loss
|
||||
dfl_loss=0, # dfl loss
|
||||
fitness=trainer.fitness, # 적합도
|
||||
epoch_time=trainer.epoch_time, # 지난 에포크 걸린 시간 (에포크 시작 기준으로 결정)
|
||||
left_seconds=left_seconds # 남은 시간(초)
|
||||
)
|
||||
# 데이터 전송
|
||||
send_data_call_api(request.project_id, request.m_id, data)
|
||||
except Exception as e:
|
||||
print(f"Exception in send_data(): {e}")
|
||||
|
||||
# 콜백 등록
|
||||
model.add_callback("on_train_epoch_start", send_data)
|
||||
|
||||
# 학습 실행
|
||||
try:
|
||||
results = model.train(
|
||||
data=dataset_root_path,
|
||||
name=join_path(dataset_root_path, "result"),
|
||||
epochs=request.epochs,
|
||||
batch=request.batch,
|
||||
lr0=request.lr0,
|
||||
lrf=request.lrf,
|
||||
optimizer=request.optimizer,
|
||||
patience=0
|
||||
)
|
||||
finally:
|
||||
# 콜백 해제 및 자원 해제
|
||||
model.reset_callbacks()
|
||||
# 마지막 에포크 전송
|
||||
model.trainer.epoch += 1
|
||||
send_data(model.trainer)
|
||||
|
||||
return results
|
||||
|
||||
except HTTPException as e:
|
||||
raise e # HTTP 예외를 다시 발생
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail="exception in run_train(): "+str(e))
|
||||
|
||||
|
@ -1,304 +0,0 @@
|
||||
import os
|
||||
import time
|
||||
|
||||
import psutil
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from schemas.predict_request import PredictRequest
|
||||
from schemas.train_request import TrainRequest, TrainDataInfo
|
||||
from schemas.predict_response import PredictResponse, LabelData, Shape
|
||||
from schemas.train_report_data import ReportData
|
||||
from schemas.train_response import TrainResponse
|
||||
from services.load_model import load_detection_model
|
||||
from services.create_model import save_model
|
||||
from utils.file_utils import get_dataset_root_path, process_directories, join_path, process_image_and_label
|
||||
from utils.slackMessage import send_slack_message
|
||||
from utils.api_utils import send_data_call_api
|
||||
import random, torch
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/predict")
|
||||
async def detection_predict(request: PredictRequest):
|
||||
project_id = request.project_id
|
||||
send_slack_message(f"Detection predict 요청 (projectId: {project_id})", status="success")
|
||||
|
||||
# 모델 로드
|
||||
start_time = time.time()
|
||||
send_slack_message(f"모델 로드 중 (projectId: {project_id})...", status="success")
|
||||
model = get_model(request.project_id, request.m_key)
|
||||
send_slack_message(f"모델 로드 완료 (projectId: {project_id}). 걸린 시간: {time.time() - start_time:.2f} 초", status="success")
|
||||
|
||||
# 이미지 데이터 정리
|
||||
start_time = time.time()
|
||||
url_list = list(map(lambda x: x.image_url, request.image_list))
|
||||
send_slack_message(f"이미지 데이터 정리 완료 (projectId: {project_id}). 걸린 시간: {time.time() - start_time:.2f} 초",
|
||||
status="success")
|
||||
|
||||
# 이 값을 모델에 입력하면 해당하는 클래스 id만 출력됨
|
||||
classes = get_classes(request.label_map, model.names)
|
||||
|
||||
# 추론
|
||||
start_time = time.time()
|
||||
send_slack_message(f"추론 시작 (projectId: {project_id})...", status="success")
|
||||
results = run_predictions(model, url_list, request, classes)
|
||||
send_slack_message(f"추론 완료 (projectId: {project_id}). 걸린 시간: {time.time() - start_time:.2f} 초", status="success")
|
||||
|
||||
# 추론 결과 변환
|
||||
start_time = time.time()
|
||||
response = [process_prediction_result(result, image, request.label_map) for result, image in
|
||||
zip(results, request.image_list)]
|
||||
send_slack_message(f"추론 결과 변환 완료 (projectId: {project_id}). 걸린 시간: {time.time() - start_time:.2f} 초",
|
||||
status="success")
|
||||
|
||||
send_slack_message(f"Detection predict 성공 (projectId: {project_id}) {len(response)}", status="success")
|
||||
|
||||
return response
|
||||
|
||||
# 모델 로드
|
||||
def get_model(project_id, model_key):
|
||||
try:
|
||||
return load_detection_model(project_id, model_key)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail="exception in get_model(): " + str(e))
|
||||
|
||||
# 모델의 레이블로부터 label_map의 key에 존재하는 값의 id만 가져오기
|
||||
def get_classes(label_map:dict[str: int], model_names: dict[int, str]):
|
||||
try:
|
||||
return [id for id, name in model_names.items() if name in label_map]
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail="exception in get_classes(): " + str(e))
|
||||
|
||||
# 추론 실행 함수
|
||||
def run_predictions(model, image, request, classes):
|
||||
try:
|
||||
with torch.no_grad():
|
||||
results = []
|
||||
for img in image:
|
||||
result = model.predict(
|
||||
source=[img],
|
||||
iou=request.iou_threshold,
|
||||
conf=request.conf_threshold,
|
||||
classes=classes
|
||||
)
|
||||
results += result
|
||||
return results
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail="exception in run_predictions: " + str(e))
|
||||
|
||||
# 추론 결과 처리 함수
|
||||
def process_prediction_result(result, image, label_map):
|
||||
try:
|
||||
label_data = LabelData(
|
||||
version="0.0.0",
|
||||
task_type="det",
|
||||
shapes=[
|
||||
Shape(
|
||||
label= summary['name'],
|
||||
color= get_random_color(),
|
||||
points= [
|
||||
[summary['box']['x1'], summary['box']['y1']],
|
||||
[summary['box']['x2'], summary['box']['y2']]
|
||||
],
|
||||
group_id= label_map[summary['name']],
|
||||
shape_type= "rectangle",
|
||||
flags= {}
|
||||
)
|
||||
for summary in result.summary()
|
||||
],
|
||||
split="none",
|
||||
imageHeight=result.orig_img.shape[0],
|
||||
imageWidth=result.orig_img.shape[1],
|
||||
imageDepth=result.orig_img.shape[2]
|
||||
)
|
||||
except KeyError as e:
|
||||
raise HTTPException(status_code=500, detail="KeyError: " + str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail="exception in process_prediction_result(): " + str(e))
|
||||
|
||||
return PredictResponse(
|
||||
image_id=image.image_id,
|
||||
data=label_data.model_dump_json()
|
||||
)
|
||||
|
||||
def get_random_color():
|
||||
random_number = random.randint(0, 0xFFFFFF)
|
||||
return f"#{random_number:06X}"
|
||||
|
||||
@router.post("/train", response_model=TrainResponse)
|
||||
async def detection_train(request: TrainRequest):
|
||||
|
||||
send_slack_message(f"Detection train 요청 projectId : {request.project_id}, 이미지 개수:{len(request.data)}", status="success")
|
||||
|
||||
# 데이터셋 루트 경로 얻기 (프로젝트 id 기반)
|
||||
|
||||
dataset_root_path = get_dataset_root_path(request.project_id)
|
||||
|
||||
# 모델 로드
|
||||
project_id = request.project_id
|
||||
start_time = time.time()
|
||||
send_slack_message(f"모델 로드 중 (projectId: {project_id})...", status="success")
|
||||
model = get_model(project_id, request.m_key)
|
||||
send_slack_message(f"모델 로드 완료 (projectId: {project_id}). 걸린 시간: {time.time() - start_time:.2f} 초", status="success")
|
||||
|
||||
# 이 값을 학습할때 넣으면 이 카테고리들이 학습됨
|
||||
names = list(request.label_map)
|
||||
|
||||
# 레이블 변환기 (file_util.py/create_detection_train_label() 에 쓰임)
|
||||
label_converter = {request.label_map[key]:idx for idx, key in enumerate(request.label_map)}
|
||||
# key : 데이터에 저장된 프로젝트 카테고리 id
|
||||
# value : 모델에 저장될 카테고리 id (모델에는 key의 idx 순서대로 저장될 것임)
|
||||
|
||||
# 데이터 전처리: 학습할 디렉토리 & 데이터셋 설정 파일을 생성
|
||||
start_time = time.time()
|
||||
send_slack_message(f"데이터 전처리 시작: 학습 디렉토리 및 설정 파일 생성 중 (projectId: {project_id})...", status="success")
|
||||
process_directories(dataset_root_path, names)
|
||||
send_slack_message(f"데이터 전처리 완료 (projectId: {project_id}). 걸린 시간: {time.time() - start_time:.2f} 초",
|
||||
status="success")
|
||||
|
||||
# 데이터 전처리: 데이터를 학습데이터와 검증데이터로 분류
|
||||
start_time = time.time()
|
||||
send_slack_message(f"데이터 분류 중 (projectId: {project_id})...", status="success")
|
||||
train_data, val_data = split_data(request.data, request.ratio)
|
||||
send_slack_message(f"데이터 분류 완료 (projectId: {project_id}). 걸린 시간: {time.time() - start_time:.2f} 초",
|
||||
status="success")
|
||||
|
||||
# 데이터 전처리: 데이터 이미지 및 레이블 다운로드
|
||||
start_time = time.time()
|
||||
send_slack_message(f"데이터 다운로드 중 (projectId: {project_id})...", status="success")
|
||||
download_data(train_data, val_data, dataset_root_path, label_converter)
|
||||
send_slack_message(f"데이터 다운로드 완료 (projectId: {project_id}). 걸린 시간: {time.time() - start_time:.2f} 초",
|
||||
status="success")
|
||||
|
||||
# 학습 시작
|
||||
start_time = time.time()
|
||||
send_slack_message(f"학습 시작 (projectId: {project_id})...", status="success")
|
||||
results = run_train(request, model, dataset_root_path)
|
||||
send_slack_message(f"학습 완료 (projectId: {project_id}). 걸린 시간: {time.time() - start_time:.2f} 초", status="success")
|
||||
|
||||
# best 모델 저장
|
||||
start_time = time.time()
|
||||
send_slack_message(f"모델 저장 중 (projectId: {project_id})...", status="success")
|
||||
model_key = save_model(project_id=project_id, path=join_path(dataset_root_path, "result", "weights", "best.pt"))
|
||||
send_slack_message(f"모델 저장 완료 (projectId: {project_id}). 걸린 시간: {time.time() - start_time:.2f} 초", status="success")
|
||||
|
||||
result = results.results_dict
|
||||
|
||||
response = TrainResponse(
|
||||
modelKey=model_key,
|
||||
precision= result["metrics/precision(B)"],
|
||||
recall= result["metrics/recall(B)"],
|
||||
mAP50= result["metrics/mAP50(B)"],
|
||||
mAP5095= result["metrics/mAP50-95(B)"],
|
||||
accuracy=0,
|
||||
fitness= result["fitness"]
|
||||
)
|
||||
send_slack_message(f"Detection train 성공 (projectId: {project_id}) {response}", status="success")
|
||||
|
||||
return response
|
||||
|
||||
def split_data(data:list[TrainDataInfo], ratio:float):
|
||||
try:
|
||||
train_size = int(ratio * len(data))
|
||||
random.shuffle(data)
|
||||
train_data = data[:train_size]
|
||||
val_data = data[train_size:]
|
||||
|
||||
if not train_data or not val_data:
|
||||
raise Exception("data size is too small")
|
||||
return train_data, val_data
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail="exception in split_data(): " + str(e))
|
||||
|
||||
def download_data(train_data:list[TrainDataInfo], val_data:list[TrainDataInfo], dataset_root_path:str, label_converter:dict[int, int]):
|
||||
try:
|
||||
for data in train_data:
|
||||
process_image_and_label(data, dataset_root_path, "train", label_converter)
|
||||
|
||||
for data in val_data:
|
||||
process_image_and_label(data, dataset_root_path, "val", label_converter)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail="exception in download_data(): " + str(e))
|
||||
|
||||
def run_train(request, model, dataset_root_path):
|
||||
try:
|
||||
# 콜백 함수 정의
|
||||
def send_data(trainer):
|
||||
try:
|
||||
# 첫번째 epoch는 스킵
|
||||
if trainer.epoch == 0:
|
||||
return
|
||||
|
||||
# 남은 시간 계산(초)
|
||||
left_epochs = trainer.epochs - trainer.epoch
|
||||
left_seconds = left_epochs * trainer.epoch_time
|
||||
|
||||
# 로스 box_loss, cls_loss, dfl_loss
|
||||
loss = trainer.label_loss_items(loss_items=trainer.loss_items)
|
||||
data = ReportData(
|
||||
epoch=trainer.epoch, # 현재 에포크
|
||||
total_epochs=trainer.epochs, # 전체 에포크
|
||||
seg_loss=0, # seg_loss
|
||||
box_loss=loss["train/box_loss"], # box loss
|
||||
cls_loss=loss["train/cls_loss"], # cls loss
|
||||
dfl_loss=loss["train/dfl_loss"], # dfl loss
|
||||
fitness=trainer.fitness, # 적합도
|
||||
epoch_time=trainer.epoch_time, # 지난 에포크 걸린 시간 (에포크 시작 기준으로 결정)
|
||||
left_seconds=left_seconds # 남은 시간(초)
|
||||
)
|
||||
# 데이터 전송
|
||||
send_data_call_api(request.project_id, request.m_id, data)
|
||||
except Exception as e:
|
||||
# 예외 처리
|
||||
print(f"Exception in send_data(): {e}")
|
||||
|
||||
# 콜백 등록
|
||||
model.add_callback("on_train_epoch_start", send_data)
|
||||
|
||||
try:
|
||||
# 비동기 함수로 학습 실행
|
||||
results = model.train(
|
||||
data=join_path(dataset_root_path, "dataset.yaml"),
|
||||
name=join_path(dataset_root_path, "result"),
|
||||
epochs=request.epochs,
|
||||
batch=request.batch,
|
||||
lr0=request.lr0,
|
||||
lrf=request.lrf,
|
||||
optimizer=request.optimizer,
|
||||
patience=0
|
||||
)
|
||||
finally:
|
||||
# 콜백 해제 및 자원 해제
|
||||
model.reset_callbacks()
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
# 마지막 에포크 전송
|
||||
model.trainer.epoch += 1
|
||||
send_data(model.trainer)
|
||||
return results
|
||||
except HTTPException as e:
|
||||
raise e
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"exception in run_train(): {e}")
|
||||
|
||||
@router.get("/memory")
|
||||
async def get_memory_status():
|
||||
# GPU 메모리 정보 가져오기 (torch.cuda 사용)
|
||||
if torch.cuda.is_available():
|
||||
# 현재 활성화된 CUDA 디바이스 번호 확인
|
||||
current_device = torch.cuda.current_device()
|
||||
|
||||
total_gpu_memory = torch.cuda.get_device_properties(current_device).total_memory
|
||||
allocated_gpu_memory = torch.cuda.memory_allocated(current_device)
|
||||
reserved_gpu_memory = torch.cuda.memory_reserved(current_device)
|
||||
|
||||
gpu_memory = {
|
||||
"current_device" : current_device,
|
||||
"total": total_gpu_memory / (1024 ** 3), # 전체 GPU 메모리 (GB 단위)
|
||||
"allocated": allocated_gpu_memory / (1024 ** 3), # 현재 사용 중인 GPU 메모리 (GB 단위)
|
||||
"reserved": reserved_gpu_memory / (1024 ** 3), # 예약된 GPU 메모리 (GB 단위)
|
||||
"free": (total_gpu_memory - reserved_gpu_memory) / (1024 ** 3) # 사용 가능한 GPU 메모리 (GB 단위)
|
||||
}
|
||||
return gpu_memory
|
||||
else:
|
||||
raise HTTPException(status_code=404, detail="GPU가 사용 가능하지 않습니다.")
|
@ -1,81 +0,0 @@
|
||||
from fastapi import APIRouter, HTTPException, File, UploadFile
|
||||
from schemas.model_create_request import ModelCreateRequest
|
||||
from services.create_model import create_new_model, save_model
|
||||
from services.load_model import load_model
|
||||
from utils.file_utils import get_model_keys, delete_file, join_path, save_file, get_file_name
|
||||
import re
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/info/projects/{project_id}/models/{model_key}", summary= "모델 관련 정보 반환")
|
||||
def get_model_info(project_id:int, model_key:str):
|
||||
model_path = join_path("resources","projects", str(project_id), "models", model_key)
|
||||
try:
|
||||
model = load_model(model_path=model_path)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404,
|
||||
detail= "모델을 찾을 수 없습니다.")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail="model load exception: " + str(e))
|
||||
# TODO: 학습치 등등 추가 예정
|
||||
return {"type": model.task, "labelCategories":model.names}
|
||||
|
||||
# project_id => model path 리스트 를 가져오는 함수
|
||||
@router.get("/projects/{project_id}", summary="project id 에 해당하는 모델 id 리스트")
|
||||
def get_model_list(project_id:int):
|
||||
try:
|
||||
return get_model_keys(project_id)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404,
|
||||
detail= "프로젝트가 찾을 수 없거나 생성된 모델이 없습니다.")
|
||||
|
||||
@router.post("/projects/{project_id}", status_code=201)
|
||||
def create_model(project_id: int, request: ModelCreateRequest):
|
||||
model_key = create_new_model(project_id, request.project_type, request.pretrained)
|
||||
return {"model_key": model_key}
|
||||
|
||||
@router.delete("/projects/{project_id}/models/{model_key}", status_code=204)
|
||||
def delete_model(project_id:int, model_key:str):
|
||||
model_path = join_path("resources", "projects", str(project_id), "models", model_key)
|
||||
try:
|
||||
delete_file(model_path)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404,
|
||||
detail= "모델을 찾을 수 없습니다.")
|
||||
|
||||
@router.post("/upload/projects/{project_id}")
|
||||
def upload_model(project_id:int, file: UploadFile = File(...)):
|
||||
# 확장자 확인 확장자 새로 추가한다면 여기에 추가
|
||||
if not file.filename.endswith(".pt"):
|
||||
raise HTTPException(status_code=400, detail="Only .pt files are allowed.")
|
||||
|
||||
tmp_path = join_path("resources", "models", "tmp-"+file.filename)
|
||||
|
||||
# 임시로 파일 저장
|
||||
try:
|
||||
save_file(tmp_path, file)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail="file save exception: "+str(e))
|
||||
|
||||
# YOLO 모델 변환 및 저장
|
||||
try:
|
||||
model_path = save_model(project_id, tmp_path)
|
||||
return {"model_path": model_path}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail="file save exception: "+str(e))
|
||||
finally:
|
||||
# 임시파일 삭제
|
||||
delete_file(tmp_path)
|
||||
|
||||
|
||||
@router.get("/download/projects/{project_id}/models/{model_key}")
|
||||
def download_model(project_id:int, model_key:str):
|
||||
model_path = join_path("resources", "projects", str(project_id), "models", model_key)
|
||||
try:
|
||||
filename = get_file_name(model_path)
|
||||
# 파일 응답 반환
|
||||
return FileResponse(model_path, media_type='application/octet-stream', filename=filename)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404,
|
||||
detail= "모델을 찾을 수 없습니다.")
|
@ -1,230 +0,0 @@
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from api.yolo.detection import get_classes, run_predictions, get_random_color, split_data, download_data
|
||||
from schemas.predict_request import PredictRequest
|
||||
from schemas.train_request import TrainRequest
|
||||
from schemas.predict_response import PredictResponse, LabelData
|
||||
from schemas.train_report_data import ReportData
|
||||
from schemas.train_response import TrainResponse
|
||||
from services.load_model import load_segmentation_model
|
||||
from services.create_model import save_model
|
||||
from utils.file_utils import get_dataset_root_path, process_directories, join_path
|
||||
from utils.slackMessage import send_slack_message
|
||||
from utils.api_utils import send_data_call_api
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/predict")
|
||||
async def segmentation_predict(request: PredictRequest):
|
||||
project_id = request.project_id
|
||||
send_slack_message(f"Segmentation predict 요청 (projectId: {project_id}, 이미지 개수: {len(request.image_list)})",
|
||||
status="success")
|
||||
|
||||
# 모델 로드
|
||||
start_time = time.time()
|
||||
send_slack_message(f"모델 로드 중 (projectId: {project_id})...", status="success")
|
||||
model = get_model(project_id, request.m_key)
|
||||
send_slack_message(f"모델 로드 완료 (projectId: {project_id}). 걸린 시간: {time.time() - start_time:.2f} 초", status="success")
|
||||
|
||||
# 이미지 데이터 정리
|
||||
start_time = time.time()
|
||||
url_list = list(map(lambda x: x.image_url, request.image_list))
|
||||
send_slack_message(f"이미지 데이터 정리 완료 (projectId: {project_id}). 걸린 시간: {time.time() - start_time:.2f} 초",
|
||||
status="success")
|
||||
|
||||
# 이 값을 모델에 입력하면 해당하는 클래스 id만 출력됨
|
||||
classes = get_classes(request.label_map, model.names)
|
||||
|
||||
# 추론
|
||||
start_time = time.time()
|
||||
send_slack_message(f"Segmentation 추론 시작 (projectId: {project_id})...", status="success")
|
||||
results = run_predictions(model, url_list, request, classes)
|
||||
send_slack_message(f"Segmentation 추론 완료 (projectId: {project_id}). 걸린 시간: {time.time() - start_time:.2f} 초",
|
||||
status="success")
|
||||
|
||||
# 추론 결과 변환
|
||||
start_time = time.time()
|
||||
response = [process_prediction_result(result, image, request.label_map) for result, image in
|
||||
zip(results, request.image_list)]
|
||||
send_slack_message(f"Segmentation predict 성공 (projectId: {project_id}). 걸린 시간: {time.time() - start_time:.2f} 초",
|
||||
status="success")
|
||||
|
||||
return response
|
||||
|
||||
# 모델 로드
|
||||
def get_model(project_id:int, model_key:str):
|
||||
try:
|
||||
return load_segmentation_model(project_id, model_key)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail="load model exception: " + str(e))
|
||||
|
||||
# 추론 결과 처리 함수
|
||||
def process_prediction_result(result, image, label_map):
|
||||
try:
|
||||
label_data = LabelData(
|
||||
version="0.0.0",
|
||||
task_type="seg",
|
||||
shapes=[
|
||||
{
|
||||
"label": summary['name'],
|
||||
"color": get_random_color(),
|
||||
"points": list(zip(summary['segments']['x'], summary['segments']['y'])),
|
||||
"group_id": label_map[summary['name']],
|
||||
"shape_type": "polygon",
|
||||
"flags": {}
|
||||
}
|
||||
for summary in result.summary()
|
||||
],
|
||||
split="none",
|
||||
imageHeight=result.orig_img.shape[0],
|
||||
imageWidth=result.orig_img.shape[1],
|
||||
imageDepth=result.orig_img.shape[2]
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail="model predict exception: " + str(e))
|
||||
|
||||
return PredictResponse(
|
||||
image_id=image.image_id,
|
||||
data=label_data.model_dump_json()
|
||||
)
|
||||
|
||||
|
||||
@router.post("/train")
|
||||
async def segmentation_train(request: TrainRequest):
|
||||
project_id = request.project_id
|
||||
|
||||
send_slack_message(f"Segmentation train 요청 (projectId: {project_id} 이미지 개수: {len(request.data)})", status="success")
|
||||
|
||||
# 데이터셋 루트 경로 얻기 (프로젝트 id 기반)
|
||||
dataset_root_path = get_dataset_root_path(project_id)
|
||||
|
||||
# 모델 로드
|
||||
start_time = time.time()
|
||||
send_slack_message(f"모델 로드 중 (projectId: {project_id})...", status="success")
|
||||
model = get_model(project_id, request.m_key)
|
||||
send_slack_message(f"모델 로드 완료 (projectId: {project_id}). 걸린 시간: {time.time() - start_time:.2f} 초", status="success")
|
||||
|
||||
# 이 값을 학습할때 넣으면 이 카테고리들이 학습됨
|
||||
names = list(request.label_map)
|
||||
|
||||
# 레이블 변환기
|
||||
start_time = time.time()
|
||||
label_converter = {request.label_map[key]: idx for idx, key in enumerate(request.label_map)}
|
||||
send_slack_message(f"레이블 변환기 생성 완료 (projectId: {project_id}). 걸린 시간: {time.time() - start_time:.2f} 초",
|
||||
status="success")
|
||||
|
||||
# 데이터 전처리: 학습할 디렉토리 및 설정 파일 생성
|
||||
start_time = time.time()
|
||||
send_slack_message(f"데이터 전처리 중 (projectId: {project_id})...", status="success")
|
||||
process_directories(dataset_root_path, names)
|
||||
send_slack_message(f"데이터 전처리 완료 (projectId: {project_id}). 걸린 시간: {time.time() - start_time:.2f} 초",
|
||||
status="success")
|
||||
|
||||
# 데이터 전처리: 데이터를 학습데이터와 검증데이터로 분류
|
||||
start_time = time.time()
|
||||
train_data, val_data = split_data(request.data, request.ratio)
|
||||
send_slack_message(f"데이터 분류 완료 (projectId: {project_id}). 걸린 시간: {time.time() - start_time:.2f} 초",
|
||||
status="success")
|
||||
|
||||
# 데이터 전처리: 데이터 이미지 및 레이블 다운로드
|
||||
start_time = time.time()
|
||||
send_slack_message(f"데이터 다운로드 중 (projectId: {project_id})...", status="success")
|
||||
download_data(train_data, val_data, dataset_root_path, label_converter)
|
||||
send_slack_message(f"데이터 다운로드 완료 (projectId: {project_id}). 걸린 시간: {time.time() - start_time:.2f} 초",
|
||||
status="success")
|
||||
|
||||
# 학습 시작
|
||||
start_time = time.time()
|
||||
send_slack_message(f"Segmentation 학습 시작 (projectId: {project_id})...", status="success")
|
||||
results = run_train(request, model, dataset_root_path)
|
||||
send_slack_message(f"Segmentation 학습 완료 (projectId: {project_id}). 걸린 시간: {time.time() - start_time:.2f} 초",
|
||||
status="success")
|
||||
|
||||
# best 모델 저장
|
||||
start_time = time.time()
|
||||
send_slack_message(f"모델 저장 중 (projectId: {project_id})...", status="success")
|
||||
model_key = save_model(project_id=project_id, path=join_path(dataset_root_path, "result", "weights", "best.pt"))
|
||||
send_slack_message(f"모델 저장 완료 (projectId: {project_id}). 걸린 시간: {time.time() - start_time:.2f} 초", status="success")
|
||||
|
||||
result = results.results_dict
|
||||
|
||||
response = TrainResponse(
|
||||
modelKey=model_key,
|
||||
precision=result["metrics/precision(M)"],
|
||||
recall=result["metrics/recall(M)"],
|
||||
mAP50=result["metrics/mAP50(M)"],
|
||||
mAP5095=result["metrics/mAP50-95(M)"],
|
||||
accuracy=0,
|
||||
fitness=result["fitness"]
|
||||
)
|
||||
send_slack_message(f"Segmentation train 성공 (projectId: {project_id}) {response}", status="success")
|
||||
|
||||
return response
|
||||
|
||||
def run_train(request, model, dataset_root_path):
|
||||
try:
|
||||
# 데이터 전송 콜백함수
|
||||
def send_data(trainer):
|
||||
try:
|
||||
# 첫번째 epoch는 스킵
|
||||
if trainer.epoch == 0:
|
||||
return
|
||||
|
||||
# 남은 시간 계산(초)
|
||||
left_epochs = trainer.epochs - trainer.epoch
|
||||
left_seconds = left_epochs * trainer.epoch_time
|
||||
|
||||
# 로스 box_loss, cls_loss, dfl_loss
|
||||
loss = trainer.label_loss_items(loss_items=trainer.loss_items)
|
||||
data = ReportData(
|
||||
epoch=trainer.epoch, # 현재 에포크
|
||||
total_epochs=trainer.epochs, # 전체 에포크
|
||||
seg_loss=loss["train/seg_loss"], # seg_loss
|
||||
box_loss=0, # box loss
|
||||
cls_loss=loss["train/cls_loss"], # cls loss
|
||||
dfl_loss=0, # dfl loss
|
||||
fitness=trainer.fitness, # 적합도
|
||||
epoch_time=trainer.epoch_time, # 지난 에포크 걸린 시간 (에포크 시작 기준으로 결정)
|
||||
left_seconds=left_seconds # 남은 시간(초)
|
||||
)
|
||||
# 데이터 전송
|
||||
send_data_call_api(request.project_id, request.m_id, data)
|
||||
except Exception as e:
|
||||
print(f"Exception in send_data(): {e}")
|
||||
|
||||
# 콜백 등록
|
||||
model.add_callback("on_train_epoch_start", send_data)
|
||||
|
||||
try:
|
||||
# 비동기 함수로 학습 실행
|
||||
results = model.train(
|
||||
data=join_path(dataset_root_path, "dataset.yaml"),
|
||||
name=join_path(dataset_root_path, "result"),
|
||||
epochs=request.epochs,
|
||||
batch=request.batch,
|
||||
lr0=request.lr0,
|
||||
lrf=request.lrf,
|
||||
optimizer=request.optimizer,
|
||||
patience=0
|
||||
)
|
||||
finally:
|
||||
# 콜백 해제 및 자원 해제
|
||||
model.reset_callbacks()
|
||||
|
||||
# 마지막 에포크 전송
|
||||
model.trainer.epoch += 1
|
||||
send_data(model.trainer)
|
||||
return results
|
||||
|
||||
except HTTPException as e:
|
||||
raise e # HTTP 예외를 다시 발생
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"run_train exception: {e}")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
@ -1,53 +0,0 @@
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.exception_handlers import http_exception_handler, request_validation_exception_handler
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from starlette.exceptions import HTTPException
|
||||
from api.yolo.detection import router as yolo_detection_router
|
||||
from api.yolo.segmentation import router as yolo_segmentation_router
|
||||
from api.yolo.classfication import router as yolo_classification_router
|
||||
from api.yolo.model import router as yolo_model_router
|
||||
from utils.slackMessage import send_slack_message
|
||||
import time, torch, gc
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
# 각 기능별 라우터를 애플리케이션에 등록
|
||||
app.include_router(yolo_detection_router, prefix="/api/detection", tags=["Detection"])
|
||||
app.include_router(yolo_segmentation_router, prefix="/api/segmentation", tags=["Segmentation"])
|
||||
app.include_router(yolo_classification_router, prefix="/api/classification", tags=["Classification"])
|
||||
app.include_router(yolo_model_router, prefix="/api/model", tags=["Model"])
|
||||
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def resource_cleaner_middleware(request: Request, call_next):
|
||||
start_time = time.time()
|
||||
try:
|
||||
response = await call_next(request)
|
||||
return response
|
||||
except Exception as exc:
|
||||
raise exc
|
||||
finally:
|
||||
process_time = time.time() - start_time
|
||||
if request.method != "GET":
|
||||
send_slack_message(f"처리 시간: {process_time}초")
|
||||
# gc.collect()
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
|
||||
# 예외 처리기
|
||||
@app.exception_handler(HTTPException)
|
||||
async def custom_http_exception_handler(request:Request, exc):
|
||||
body = await request.json()
|
||||
send_slack_message(f"프로젝트 ID: {body['project_id']} - 실패! 에러: {str(exc)}", status="error")
|
||||
return await http_exception_handler(request, exc)
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_exception_handler(request:Request, exc):
|
||||
send_slack_message(f"{request.url} - 요청 실패! 에러: {str(exc)}", status="error")
|
||||
return await request_validation_exception_handler(request, exc)
|
||||
|
||||
# # 애플리케이션 실행
|
||||
# if __name__ == "__main__":
|
||||
# import uvicorn
|
||||
# uvicorn.run("main:app", reload=True)
|
@ -1,6 +0,0 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Literal
|
||||
|
||||
class ModelCreateRequest(BaseModel):
|
||||
project_type: Literal["segmentation", "detection", "classification"]
|
||||
pretrained:bool = True
|
@ -1,14 +0,0 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
class ImageInfo(BaseModel):
|
||||
image_id: int
|
||||
image_url: str
|
||||
|
||||
|
||||
class PredictRequest(BaseModel):
|
||||
project_id: int
|
||||
m_key: str = Field("yolo8", alias="model_key") # model_ 로 시작하는 변수를 BaseModel의 변수로 만들경우 Warning 떠서 m_key로 대체
|
||||
label_map: dict[str, int] = Field(..., description="프로젝트 레이블 이름: 프로젝트 레이블 pk")
|
||||
image_list: list[ImageInfo] # 이미지 리스트
|
||||
conf_threshold: float = Field(0.25, gt=0, lt= 1)
|
||||
iou_threshold: float = Field(0.45, gt=0, lt= 1)
|
@ -1,23 +0,0 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional, Tuple, Dict
|
||||
|
||||
class Shape(BaseModel):
|
||||
label: str
|
||||
color: str
|
||||
points: List[Tuple[float, float]]
|
||||
group_id: Optional[int] = None
|
||||
shape_type: str
|
||||
flags: Dict[str, Optional[bool]] = {}
|
||||
|
||||
class LabelData(BaseModel):
|
||||
version: str
|
||||
task_type: str
|
||||
shapes: List[Shape]
|
||||
split: str
|
||||
imageHeight: int
|
||||
imageWidth: int
|
||||
imageDepth: int
|
||||
|
||||
class PredictResponse(BaseModel):
|
||||
image_id: int
|
||||
data: str
|
@ -1,28 +0,0 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class Segment(BaseModel):
|
||||
x: float = Field(..., ge=0, le=1)
|
||||
y: float = Field(..., ge=0, le=1)
|
||||
|
||||
def to_string(self) -> str:
|
||||
return f"{self.x} {self.y}"
|
||||
|
||||
class DetectionLabelData(BaseModel):
|
||||
label_id: int = Field(..., ge=0)
|
||||
center_x: float = Field(..., ge=0, le=1)
|
||||
center_y: float = Field(..., ge=0, le=1)
|
||||
width: float = Field(..., ge=0, le=1)
|
||||
height: float = Field(..., ge=0, le=1)
|
||||
|
||||
def to_string(self) -> str:
|
||||
return f"{self.label_id} {self.center_x} {self.center_y} {self.width} {self.height}"
|
||||
|
||||
|
||||
class SegmentationLabelData(BaseModel):
|
||||
label_id: int
|
||||
segments: list[Segment]
|
||||
|
||||
def to_string(self) -> str:
|
||||
points_str = " ".join([segment.to_string() for segment in self.segments])
|
||||
return f"{self.label_id} {points_str}"
|
@ -1,12 +0,0 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
class ReportData(BaseModel):
|
||||
epoch: int # 현재 에포크
|
||||
total_epochs: int # 전체 에포크
|
||||
seg_loss: float # seg_loss
|
||||
box_loss: float # box loss
|
||||
cls_loss: float # cls loss
|
||||
dfl_loss: float # dfl loss
|
||||
fitness: float # 적합도
|
||||
epoch_time: float # 지난 에포크 걸린 시간 (에포크 시작 기준으로 결정)
|
||||
left_seconds: float # 남은 시간(초)
|
@ -1,22 +0,0 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Literal
|
||||
|
||||
class TrainDataInfo(BaseModel):
|
||||
image_url: str
|
||||
data_url: str
|
||||
|
||||
class TrainRequest(BaseModel):
|
||||
project_id: int = Field(..., gt= 0)
|
||||
m_key: str = Field("yolo8", alias="model_key")
|
||||
m_id: int = Field(..., alias="model_id", gt= 0) # 학습 중 에포크 결과를 보낼때 model_id를 보냄
|
||||
label_map: dict[str, int] = Field(..., description="프로젝트 레이블 이름: 프로젝트 레이블 pk")
|
||||
data: list[TrainDataInfo]
|
||||
ratio: float = Field(0.8, gt=0, lt=1) # 훈련/검증 분할 비율
|
||||
|
||||
# 학습 파라미터
|
||||
epochs: int = Field(50, gt= 0, lt = 1000) # 훈련 반복 횟수
|
||||
batch: int = Field(16, gt=0, le = 10000) # 훈련 batch 수[int] or GPU의 사용률 자동[float] default(-1): gpu의 60% 사용 유지
|
||||
lr0: float = Field(0.01, gt= 0, lt= 1) # 초기 학습 가중치
|
||||
lrf: float = Field(0.01, gt= 0, lt= 1) # lr0 기준으로 학습 가중치의 최종 수렴치 (ex lr0의 0.01배)
|
||||
optimizer: Literal['auto', 'SGD', 'Adam', 'AdamW', 'NAdam', 'RAdam', 'RMSProp'] = 'auto'
|
||||
|
@ -1,10 +0,0 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
class TrainResponse(BaseModel):
|
||||
modelKey:str
|
||||
precision: float
|
||||
recall: float
|
||||
mAP50: float
|
||||
mAP5095: float
|
||||
accuracy: float
|
||||
fitness: float
|
@ -1,51 +0,0 @@
|
||||
from ultralytics import YOLO # Ultralytics YOLO 모델을 가져오기
|
||||
import os
|
||||
import uuid
|
||||
from services.load_model import load_model
|
||||
|
||||
def create_new_model(project_id: int, type:str, pretrained:bool):
|
||||
suffix = ""
|
||||
type_list = {"segmentation": "seg", "classification": "cls"}
|
||||
if type in type_list:
|
||||
suffix = "-"+type_list[type]
|
||||
# 학습된 기본 모델 로드
|
||||
if pretrained:
|
||||
suffix += ".pt"
|
||||
else:
|
||||
suffix += ".yaml"
|
||||
model = YOLO(os.path.join("resources", "models" ,f"yolov8n{suffix}"))
|
||||
|
||||
# 모델을 저장할 폴더 경로
|
||||
base_path = os.path.join("resources","projects",str(project_id),"models")
|
||||
os.makedirs(base_path, exist_ok=True)
|
||||
|
||||
# 고유값 id 생성
|
||||
unique_id = uuid.uuid4()
|
||||
while os.path.exists(os.path.join(base_path, f"{unique_id}.pt")):
|
||||
unique_id = uuid.uuid4()
|
||||
model_path = os.path.join(base_path, f"{unique_id}.pt")
|
||||
|
||||
# 기본 모델 저장
|
||||
model.save(filename=model_path)
|
||||
|
||||
return f"{unique_id}.pt"
|
||||
|
||||
def save_model(project_id: int, path:str):
|
||||
# 모델 불러오기
|
||||
model = load_model(path)
|
||||
|
||||
# 모델을 저장할 폴더 경로
|
||||
base_path = os.path.join("resources","projects",str(project_id),"models")
|
||||
os.makedirs(base_path, exist_ok=True)
|
||||
|
||||
# 고유값 id 생성
|
||||
unique_id = uuid.uuid4()
|
||||
while os.path.exists(os.path.join(base_path, f"{unique_id}.pt")):
|
||||
unique_id = uuid.uuid4()
|
||||
model_path = os.path.join(base_path, f"{unique_id}.pt")
|
||||
|
||||
# 기본 모델 저장
|
||||
model.save(filename=model_path)
|
||||
|
||||
return f"{unique_id}.pt"
|
||||
|
@ -1,59 +0,0 @@
|
||||
# ai_service.py
|
||||
|
||||
from ultralytics import YOLO # Ultralytics YOLO 모델을 가져오기
|
||||
import os
|
||||
import torch
|
||||
|
||||
def load_detection_model(project_id:int, model_key:str):
|
||||
default_model_map = {"yolo8": os.path.join("resources","models","yolov8n.pt")}
|
||||
# 디폴트 모델 확인
|
||||
if model_key in default_model_map:
|
||||
model = YOLO(default_model_map[model_key])
|
||||
else:
|
||||
model = load_model(model_path=os.path.join("resources", "projects",str(project_id),"models", model_key))
|
||||
|
||||
# Detection 모델인지 검증
|
||||
if model.task != "detect":
|
||||
raise TypeError(f"Invalid model type: {model.task}. Expected a DetectionModel.")
|
||||
return model
|
||||
|
||||
def load_segmentation_model(project_id:int, model_key:str):
|
||||
default_model_map = {"yolo8": os.path.join("resources","models","yolov8n-seg.pt")}
|
||||
# 디폴트 모델 확인
|
||||
if model_key in default_model_map:
|
||||
model = YOLO(default_model_map[model_key])
|
||||
else:
|
||||
model = load_model(model_path=os.path.join("resources", "projects",str(project_id),"models",model_key))
|
||||
|
||||
# Segmentation 모델인지 검증
|
||||
if model.task != "segment":
|
||||
raise TypeError(f"Invalid model type: {model.task}. Expected a SegmentationModel.")
|
||||
return model
|
||||
|
||||
def load_classification_model(project_id:int, model_key:str):
|
||||
default_model_map = {"yolo8": os.path.join("resources","models","yolov8n-cls.pt")}
|
||||
# 디폴트 모델 확인
|
||||
if model_key in default_model_map:
|
||||
model = YOLO(default_model_map[model_key])
|
||||
else:
|
||||
model = load_model(model_path=os.path.join("resources", "projects",str(project_id),"models",model_key))
|
||||
|
||||
# Segmentation 모델인지 검증
|
||||
if model.task != "classify":
|
||||
raise TypeError(f"Invalid model type: {model.task}. Expected a ClassificationModel.")
|
||||
return model
|
||||
|
||||
def load_model(model_path: str):
|
||||
if not os.path.exists(model_path):
|
||||
raise FileNotFoundError(f"Model file not found at path: {model_path}")
|
||||
|
||||
try:
|
||||
model = YOLO(model_path)
|
||||
if (torch.cuda.is_available()):
|
||||
model.to("cuda")
|
||||
print("gpu 활성화")
|
||||
else:
|
||||
model.to("cpu")
|
||||
return model
|
||||
except:
|
||||
raise Exception("YOLO model conversion failed: Unsupported architecture or invalid configuration.")
|
@ -1,32 +0,0 @@
|
||||
from schemas.train_report_data import ReportData
|
||||
from dotenv import load_dotenv
|
||||
import os, httpx
|
||||
|
||||
|
||||
def send_data_call_api(project_id:int, model_id:int, data:ReportData):
|
||||
try:
|
||||
load_dotenv()
|
||||
base_url = os.getenv("API_BASE_URL")
|
||||
# main.py와 같은 디렉토리에 .env 파일 생성해서 따옴표 없이 아래 데이터를 입력
|
||||
# API_BASE_URL = {url}
|
||||
# API_KEY = {key}
|
||||
|
||||
# 하드코딩으로 대체
|
||||
if not base_url:
|
||||
base_url = "http://127.0.0.1:8080"
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
response = httpx.request(
|
||||
method="POST",
|
||||
url=base_url+f"/api/projects/{project_id}/reports/models/{model_id}",
|
||||
json=data.model_dump(),
|
||||
headers=headers,
|
||||
timeout=10
|
||||
)
|
||||
# status에 따라 예외 발생
|
||||
response.raise_for_status()
|
||||
except Exception as e:
|
||||
print("report data failed: "+str(e))
|
@ -1,161 +0,0 @@
|
||||
import os
|
||||
import shutil
|
||||
import yaml
|
||||
from PIL import Image
|
||||
from schemas.train_request import TrainDataInfo
|
||||
from schemas.train_label_data import DetectionLabelData, SegmentationLabelData, Segment
|
||||
import urllib
|
||||
import json
|
||||
|
||||
def get_dataset_root_path(project_id):
|
||||
"""데이터셋 루트 절대 경로 반환"""
|
||||
return os.path.join(os.getcwd(), 'resources', 'projects', str(project_id), "train")
|
||||
|
||||
def make_dir(path:str, init: bool):
|
||||
"""
|
||||
path : 디렉토리 경로
|
||||
init : 폴더를 초기화 할지 여부
|
||||
"""
|
||||
if (os.path.exists(path) and init):
|
||||
shutil.rmtree(path)
|
||||
os.makedirs(path, exist_ok=True)
|
||||
|
||||
def make_yml(path:str, model_categories):
|
||||
data = {
|
||||
"train": f"{path}/train",
|
||||
"val": f"{path}/val",
|
||||
"nc": len(model_categories),
|
||||
"names": model_categories
|
||||
}
|
||||
with open(os.path.join(path, "dataset.yaml"), 'w') as f:
|
||||
yaml.dump(data, f)
|
||||
|
||||
def process_directories(dataset_root_path:str, model_categories:list[str]):
|
||||
"""학습을 위한 디렉토리 생성"""
|
||||
make_dir(dataset_root_path, init=False)
|
||||
make_dir(os.path.join(dataset_root_path, "train"), init=True)
|
||||
make_dir(os.path.join(dataset_root_path, "val"), init=True)
|
||||
if os.path.exists(os.path.join(dataset_root_path, "result")):
|
||||
shutil.rmtree(os.path.join(dataset_root_path, "result"))
|
||||
make_yml(dataset_root_path, model_categories)
|
||||
|
||||
def process_image_and_label(data:TrainDataInfo, dataset_root_path:str, child_path:str, label_converter:dict[int,int]):
|
||||
"""이미지 저장 및 레이블 파일 생성"""
|
||||
# 이미지 url로부터 파일명 분리
|
||||
img_name = data.image_url.split('/')[-1]
|
||||
img_path = os.path.join(dataset_root_path,child_path,img_name)
|
||||
|
||||
# url로부터 이미지 다운로드
|
||||
urllib.request.urlretrieve(data.image_url, img_path)
|
||||
|
||||
# 파일명에서 확장자를 제거하여 img_title을 얻는다
|
||||
img_title = os.path.splitext(os.path.basename(img_path))[0]
|
||||
|
||||
# 레이블 파일 경로
|
||||
label_path = os.path.join(dataset_root_path, child_path, f"{img_title}.txt")
|
||||
|
||||
# 레이블 객체 불러오기
|
||||
with urllib.request.urlopen(data.data_url) as response:
|
||||
label = json.loads(response.read())
|
||||
|
||||
# 레이블 -> 학습용 레이블 데이터 파싱 후 생성
|
||||
if label['task_type'] == "det":
|
||||
create_detection_train_label(label, label_path, label_converter)
|
||||
elif label["task_type"] == "seg":
|
||||
create_segmentation_train_label(label, label_path, label_converter)
|
||||
|
||||
def create_detection_train_label(label:dict, label_path:str, label_converter:dict[int, int]):
|
||||
with open(label_path, "w") as train_label_txt:
|
||||
for shape in label["shapes"]:
|
||||
x1 = shape["points"][0][0]
|
||||
y1 = shape["points"][0][1]
|
||||
x2 = shape["points"][1][0]
|
||||
y2 = shape["points"][1][1]
|
||||
detection_label = DetectionLabelData(
|
||||
label_id= label_converter[shape["group_id"]], # 모델의 id (converter : pjt category pk -> model category id)
|
||||
center_x= (x1 + x2) / 2 / label["imageWidth"], # 중심 x 좌표
|
||||
center_y= (y1 + y2) / 2 / label["imageHeight"], # 중심 y 좌표
|
||||
width= (x2 - x1) / label["imageWidth"], # 너비
|
||||
height= (y2 - y1) / label["imageHeight"] # 높이
|
||||
)
|
||||
|
||||
train_label_txt.write(detection_label.to_string()+"\n") # str변환 후 txt에 쓰기
|
||||
|
||||
def create_segmentation_train_label(label:dict, label_path:str, label_converter:dict[int, int]):
|
||||
with open(label_path, "w") as train_label_txt:
|
||||
for shape in label["shapes"]:
|
||||
segmentation_label = SegmentationLabelData(
|
||||
label_id = label_converter[shape["group_id"]], # label Id
|
||||
segments = [
|
||||
Segment(
|
||||
x=x / label["imageWidth"], # shapes의 points 갯수만큼 x, y 반복
|
||||
y=y / label["imageHeight"]
|
||||
) for x, y in shape["points"]
|
||||
]
|
||||
)
|
||||
train_label_txt.write(segmentation_label.to_string()+"\n")
|
||||
|
||||
def join_path(path, *paths):
|
||||
"""os.path.join()과 같은 기능, os import 하기 싫어서 만듦"""
|
||||
return os.path.join(path, *paths)
|
||||
|
||||
def get_model_keys(project_id:int):
|
||||
path = os.path.join("resources","projects",str(project_id), "models")
|
||||
if not os.path.exists(path):
|
||||
raise FileNotFoundError()
|
||||
files = os.listdir(path)
|
||||
return files
|
||||
|
||||
def delete_file(path):
|
||||
if not os.path.exists(path):
|
||||
raise FileNotFoundError()
|
||||
os.remove(path)
|
||||
|
||||
def save_file(path, file):
|
||||
# 경로에서 디렉토리 부분만 추출 (파일명을 제외한 경로)
|
||||
dir_path = os.path.dirname(path)
|
||||
os.makedirs(dir_path, exist_ok=True)
|
||||
|
||||
with open(path, "wb") as buffer:
|
||||
shutil.copyfileobj(file.file, buffer)
|
||||
|
||||
def get_file_name(path):
|
||||
if not os.path.exists(path):
|
||||
raise FileNotFoundError()
|
||||
return os.path.basename(path)
|
||||
|
||||
def process_directories_in_cls(dataset_root_path:str, model_categories:list[str]):
|
||||
"""classification 학습을 위한 디렉토리 생성"""
|
||||
make_dir(dataset_root_path, init=False)
|
||||
for category in model_categories:
|
||||
make_dir(os.path.join(dataset_root_path, "train", category), init=True)
|
||||
make_dir(os.path.join(dataset_root_path, "test", category), init=True)
|
||||
if os.path.exists(os.path.join(dataset_root_path, "result")):
|
||||
shutil.rmtree(os.path.join(dataset_root_path, "result"))
|
||||
|
||||
def process_image_and_label_in_cls(data:TrainDataInfo, dataset_root_path:str, child_path:str):
|
||||
"""이미지 저장 및 레이블 파일 생성"""
|
||||
# 이미지 url로부터 파일명 분리
|
||||
img_name = data.image_url.split('/')[-1]
|
||||
|
||||
# 레이블 객체 불러오기
|
||||
with urllib.request.urlopen(data.data_url) as response:
|
||||
label = json.loads(response.read())
|
||||
|
||||
if not label["shapes"]:
|
||||
# assert label["shapes"], No Label. Failed Download" # AssertionError 발생
|
||||
print("No Label. Failed Download")
|
||||
return
|
||||
label_name = label["shapes"][0]["label"]
|
||||
|
||||
label_path = os.path.join(dataset_root_path,child_path,label_name)
|
||||
|
||||
# url로부터 이미지 다운로드
|
||||
if os.path.exists(label_path):
|
||||
urllib.request.urlretrieve(data.image_url, os.path.join(label_path, img_name))
|
||||
else:
|
||||
# raise FileNotFoundError("No Label Category. Failed Download")
|
||||
print("No Label Category. Failed Download")
|
||||
# 레이블 데이터 중에서 프로젝트 카테고리에 해당되지않는 데이터가 있는 경우 처리 1. 에러 raise 2. 무시(+ warning)
|
||||
|
||||
|
@ -1,27 +0,0 @@
|
||||
import httpx
|
||||
import os
|
||||
|
||||
SLACK_WEBHOOK_URL = "https://hooks.slack.com/services/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"
|
||||
|
||||
def send_slack_message(message: str, status: str = "info"):
|
||||
headers = {"Content-Type": "application/json"}
|
||||
|
||||
# 상태에 따라 다른 메시지 형식 적용 (성공, 에러)
|
||||
if status == "error":
|
||||
formatted_message = f":x: 에러 발생: {message}"
|
||||
elif status == "success":
|
||||
formatted_message = f":white_check_mark: {message}"
|
||||
else:
|
||||
formatted_message = message
|
||||
|
||||
# Slack에 전송할 페이로드
|
||||
payload = {
|
||||
"text": formatted_message
|
||||
}
|
||||
|
||||
response = httpx.post(SLACK_WEBHOOK_URL, json=payload, headers=headers)
|
||||
|
||||
if response.status_code == 200:
|
||||
return "Message sent successfully"
|
||||
else:
|
||||
return f"Failed to send message. Status code: {response.status_code}"
|
@ -1,41 +0,0 @@
|
||||
import websockets
|
||||
from websockets import WebSocketException
|
||||
|
||||
class WebSocketClient:
|
||||
def __init__(self, url: str):
|
||||
self.url = url
|
||||
self.websocket = None
|
||||
|
||||
async def connect(self):
|
||||
try:
|
||||
self.websocket = await websockets.connect(self.url)
|
||||
print(f"Connected to WebSocket at {self.url}")
|
||||
except Exception as e:
|
||||
print(f"Failed to connect to WebSocket: {str(e)}")
|
||||
|
||||
async def send_message(self, destination: str, message: str):
|
||||
try:
|
||||
if self.websocket is not None:
|
||||
# STOMP 형식의 메시지를 전송
|
||||
await self.websocket.send(f"SEND\ndestination:{destination}\n\n{message}\u0000")
|
||||
print(f"Sent message to {destination}: {message}")
|
||||
else:
|
||||
print("WebSocket is not connected. Unable to send message.")
|
||||
except Exception as e:
|
||||
print(f"Failed to send message: {str(e)}")
|
||||
return
|
||||
|
||||
async def close(self):
|
||||
try:
|
||||
if self.websocket is not None:
|
||||
await self.websocket.close()
|
||||
print("WebSocket connection closed.")
|
||||
except Exception as e:
|
||||
print(f"Failed to close WebSocket connection: {str(e)}")
|
||||
|
||||
def is_connected(self):
|
||||
return self.websocket is not None and self.websocket.open
|
||||
|
||||
class WebSocketConnectionException(WebSocketException):
|
||||
def __init__(self, message="Failed to connect to WebSocket"):
|
||||
super().__init__(message)
|
@ -1,22 +0,0 @@
|
||||
name: worlabel_ai_env
|
||||
channels:
|
||||
- conda-forge
|
||||
- pytorch
|
||||
- nvidia
|
||||
- defaults
|
||||
dependencies:
|
||||
- python=3.10.10
|
||||
- pytorch=2.3.1
|
||||
- torchvision=0.18.1
|
||||
- torchaudio=2.3.1
|
||||
- pytorch-cuda=12.1
|
||||
- fastapi
|
||||
- uvicorn
|
||||
- ultralytics
|
||||
- dill
|
||||
- boto3
|
||||
- python-dotenv
|
||||
- locust
|
||||
- websockets
|
||||
- httpx
|
||||
- psutil
|
@ -1,36 +0,0 @@
|
||||
from locust import HttpUser, TaskSet, task, between
|
||||
|
||||
class AIBehavior(TaskSet):
|
||||
@task(weight = 1) # weight: 해당 task의 빈도수
|
||||
def predict(self):
|
||||
data = {
|
||||
"project_id": 0,
|
||||
"image_list": [
|
||||
{
|
||||
"image_id": 12,
|
||||
"image_url": "test-data/images/image_000000001_jpg.rf.02ab6664294833e5f0e89130ecded0b8.jpg"
|
||||
},
|
||||
{
|
||||
"image_id": 23,
|
||||
"image_url": "test-data/images/image_000000002_jpg.rf.8270179e3cd29b97cf502622b381861e.jpg"
|
||||
},
|
||||
{
|
||||
"image_id": 47,
|
||||
"image_url": "test-data/images/image_000000003_jpg.rf.db8fd4730b031e35a60e0a60e17a0691.jpg"
|
||||
}
|
||||
]
|
||||
}
|
||||
self.client.post("/api/detection", json=data)
|
||||
|
||||
# 앞으로 다른 API 또는 다른 data에 대해서 task 추가 가능
|
||||
|
||||
class MyLocustUser(HttpUser):
|
||||
wait_time = between(1,3)
|
||||
tasks = [AIBehavior.predict]
|
||||
host = "http://127.0.0.1:8000"
|
||||
|
||||
# shell에 아래 명령어를 입력하여 실행(ai폴더 기준)
|
||||
# locust -f locust/locustfile.py
|
||||
# 또는
|
||||
# cd locust
|
||||
# locust
|
@ -1,12 +0,0 @@
|
||||
fastapi==0.104.1
|
||||
uvicorn==0.30.6
|
||||
ultralytics==8.2.82
|
||||
ultralytics-thop==2.0.5
|
||||
|
||||
--extra-index-url https://download.pytorch.org/whl/cu121
|
||||
torch==2.4.0+cu121
|
||||
--extra-index-url https://download.pytorch.org/whl/cu121
|
||||
torchaudio==2.4.0+cu121
|
||||
--extra-index-url https://download.pytorch.org/whl/cu121
|
||||
torchvision==0.19.0+cu121
|
||||
|
@ -1,17 +0,0 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
'prettier',
|
||||
'plugin:storybook/recommended',
|
||||
],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['react-refresh'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
||||
},
|
||||
};
|
26
frontend/.gitignore
vendored
@ -1,26 +0,0 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*storybook.log
|
@ -1,5 +0,0 @@
|
||||
**/.git
|
||||
**/.svn
|
||||
**/.hg
|
||||
**/.dist
|
||||
**/node_modules
|
@ -1,16 +0,0 @@
|
||||
{
|
||||
"bracketSameLine": false,
|
||||
"bracketSpacing": true,
|
||||
"endOfLine": "lf",
|
||||
"jsxSingleQuote": false,
|
||||
"printWidth": 120,
|
||||
"proseWrap": "preserve",
|
||||
"quoteProps": "as-needed",
|
||||
"semi": true,
|
||||
"singleAttributePerLine": true,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5",
|
||||
"useTabs": false,
|
||||
"plugins": ["prettier-plugin-tailwindcss"]
|
||||
}
|
@ -1,17 +0,0 @@
|
||||
import type { StorybookConfig } from '@storybook/react-vite';
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
|
||||
addons: [
|
||||
'@storybook/addon-onboarding',
|
||||
'@storybook/addon-links',
|
||||
'@storybook/addon-essentials',
|
||||
'@chromatic-com/storybook',
|
||||
'@storybook/addon-interactions',
|
||||
],
|
||||
framework: {
|
||||
name: '@storybook/react-vite',
|
||||
options: {},
|
||||
},
|
||||
};
|
||||
export default config;
|
@ -1,30 +0,0 @@
|
||||
import React from 'react';
|
||||
import type { Preview } from '@storybook/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import '../src/index.css';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const preview: Preview = {
|
||||
parameters: {
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/i,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const decorators = [
|
||||
(Story) => (
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Story />
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>
|
||||
),
|
||||
];
|
||||
|
||||
export default preview;
|
@ -1 +0,0 @@
|
||||
# WorLabel
|
@ -1,17 +0,0 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "gray",
|
||||
"cssVariables": false,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils"
|
||||
}
|
||||
}
|
@ -1,43 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="180x180"
|
||||
href="/apple-touch-icon.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="32x32"
|
||||
href="/favicon-32x32.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="16x16"
|
||||
href="/favicon-16x16.png"
|
||||
/>
|
||||
<link
|
||||
rel="manifest"
|
||||
href="/site.webmanifest"
|
||||
/>
|
||||
<title>WorLabel</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script
|
||||
type="module"
|
||||
src="/serviceWorkerRegistration.js"
|
||||
></script>
|
||||
<script
|
||||
type="module"
|
||||
src="/src/main.tsx"
|
||||
></script>
|
||||
</body>
|
||||
</html>
|
15833
frontend/package-lock.json
generated
@ -1,102 +0,0 @@
|
||||
{
|
||||
"name": "worlabel",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest --run",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@radix-ui/react-dialog": "^1.1.1",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-popover": "^1.1.1",
|
||||
"@radix-ui/react-radio-group": "^1.2.0",
|
||||
"@radix-ui/react-scroll-area": "^1.1.0",
|
||||
"@radix-ui/react-select": "^2.1.1",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-tabs": "^1.1.0",
|
||||
"@radix-ui/react-toast": "^1.2.1",
|
||||
"@radix-ui/react-toggle": "^1.1.0",
|
||||
"@radix-ui/react-tooltip": "^1.1.2",
|
||||
"@tanstack/react-query": "^5.52.1",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"axios": "^1.7.5",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"firebase": "^10.13.2",
|
||||
"jszip": "^3.10.1",
|
||||
"konva": "^9.3.14",
|
||||
"lucide-react": "^0.436.0",
|
||||
"react": "^18.3.1",
|
||||
"react-contexify": "^6.0.0",
|
||||
"react-dnd": "^16.0.1",
|
||||
"react-dnd-html5-backend": "^16.0.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.53.0",
|
||||
"react-konva": "^18.2.10",
|
||||
"react-resizable-panels": "^2.1.1",
|
||||
"react-router-dom": "^6.26.1",
|
||||
"react-slick": "^0.30.2",
|
||||
"react-treebeard": "^3.2.4",
|
||||
"react-virtualized-auto-sizer": "^1.0.24",
|
||||
"react-window": "^1.8.10",
|
||||
"recharts": "^2.12.7",
|
||||
"slick-carousel": "^1.8.1",
|
||||
"sweetalert2": "^11.14.1",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"use-image": "^1.1.1",
|
||||
"vaul": "^0.9.4",
|
||||
"zod": "^3.23.8",
|
||||
"zustand": "^4.5.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "^1.7.0",
|
||||
"@storybook/addon-essentials": "^8.2.9",
|
||||
"@storybook/addon-interactions": "^8.2.9",
|
||||
"@storybook/addon-links": "^8.2.9",
|
||||
"@storybook/addon-onboarding": "^8.2.9",
|
||||
"@storybook/blocks": "^8.2.9",
|
||||
"@storybook/react": "^8.2.9",
|
||||
"@storybook/react-vite": "^8.2.9",
|
||||
"@storybook/test": "^8.2.9",
|
||||
"@types/node": "^22.5.0",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-beautiful-dnd": "^13.1.8",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.13.1",
|
||||
"@typescript-eslint/parser": "^7.13.1",
|
||||
"@vitejs/plugin-react-swc": "^3.5.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"eslint-plugin-react-refresh": "^0.4.7",
|
||||
"eslint-plugin-storybook": "^0.8.0",
|
||||
"msw": "^2.4.4",
|
||||
"postcss": "^8.4.41",
|
||||
"prettier": "^3.3.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.6",
|
||||
"storybook": "^8.2.9",
|
||||
"tailwindcss": "^3.4.10",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "~5.3.1",
|
||||
"vite-plugin-svgr": "^4.2.0",
|
||||
"vite-tsconfig-paths": "^5.0.1",
|
||||
"vitest": "^2.0.5"
|
||||
},
|
||||
"msw": {
|
||||
"workerDirectory": [
|
||||
"public"
|
||||
]
|
||||
}
|
||||
}
|
@ -1,6 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
Before Width: | Height: | Size: 6.3 KiB |
Before Width: | Height: | Size: 17 KiB |
Before Width: | Height: | Size: 6.2 KiB |
Before Width: | Height: | Size: 442 B |
Before Width: | Height: | Size: 955 B |
Before Width: | Height: | Size: 15 KiB |
@ -1,49 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
/* eslint-disable no-undef */
|
||||
importScripts('https://www.gstatic.com/firebasejs/8.7.1/firebase-app.js');
|
||||
importScripts('https://www.gstatic.com/firebasejs/8.7.1/firebase-messaging.js');
|
||||
|
||||
const firebaseConfig = {
|
||||
apiKey: 'AIzaSyBQx50AsrS3K687cGbFDh1908ClCLFmnhA',
|
||||
authDomain: 'worlabel-6de69.firebaseapp.com',
|
||||
projectId: 'worlabel-6de69',
|
||||
storageBucket: 'worlabel-6de69.appspot.com',
|
||||
messagingSenderId: '124097400880',
|
||||
appId: '1:124097400880:web:022db3cdc0bdea750c5df5',
|
||||
measurementId: 'G-KW02YRYF5H',
|
||||
};
|
||||
|
||||
self.addEventListener('install', (_) => {
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (_) => {
|
||||
console.log('FCM 서비스 워커가 실행되었습니다.');
|
||||
});
|
||||
|
||||
firebase.initializeApp(firebaseConfig);
|
||||
const messaging = firebase.messaging();
|
||||
|
||||
messaging.onBackgroundMessage((payload) => {
|
||||
const notificationTitle = payload.data.title;
|
||||
const notificationOptions = {
|
||||
body: payload.data.body,
|
||||
icon: payload.data.image,
|
||||
data: {
|
||||
url: payload.data.url, // 알림 클릭시 이동할 URL
|
||||
},
|
||||
};
|
||||
self.registration.showNotification(notificationTitle, notificationOptions);
|
||||
});
|
||||
|
||||
// 알림 클릭 이벤트 처리
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close(); // 알림 닫기
|
||||
|
||||
// 알림에서 설정한 URL로 이동
|
||||
const clickActionUrl = event.notification.data.url;
|
||||
|
||||
if (clickActionUrl) {
|
||||
event.waitUntil(clients.openWindow(clickActionUrl));
|
||||
}
|
||||
});
|
@ -1,284 +0,0 @@
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
|
||||
/**
|
||||
* Mock Service Worker.
|
||||
* @see https://github.com/mswjs/msw
|
||||
* - Please do NOT modify this file.
|
||||
* - Please do NOT serve this file on production.
|
||||
*/
|
||||
|
||||
const PACKAGE_VERSION = '2.4.4'
|
||||
const INTEGRITY_CHECKSUM = '26357c79639bfa20d64c0efca2a87423'
|
||||
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
||||
const activeClientIds = new Set()
|
||||
|
||||
self.addEventListener('install', function () {
|
||||
self.skipWaiting()
|
||||
})
|
||||
|
||||
self.addEventListener('activate', function (event) {
|
||||
event.waitUntil(self.clients.claim())
|
||||
})
|
||||
|
||||
self.addEventListener('message', async function (event) {
|
||||
const clientId = event.source.id
|
||||
|
||||
if (!clientId || !self.clients) {
|
||||
return
|
||||
}
|
||||
|
||||
const client = await self.clients.get(clientId)
|
||||
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
switch (event.data) {
|
||||
case 'KEEPALIVE_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'KEEPALIVE_RESPONSE',
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'INTEGRITY_CHECK_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'INTEGRITY_CHECK_RESPONSE',
|
||||
payload: {
|
||||
packageVersion: PACKAGE_VERSION,
|
||||
checksum: INTEGRITY_CHECKSUM,
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'MOCK_ACTIVATE': {
|
||||
activeClientIds.add(clientId)
|
||||
|
||||
sendToClient(client, {
|
||||
type: 'MOCKING_ENABLED',
|
||||
payload: true,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'MOCK_DEACTIVATE': {
|
||||
activeClientIds.delete(clientId)
|
||||
break
|
||||
}
|
||||
|
||||
case 'CLIENT_CLOSED': {
|
||||
activeClientIds.delete(clientId)
|
||||
|
||||
const remainingClients = allClients.filter((client) => {
|
||||
return client.id !== clientId
|
||||
})
|
||||
|
||||
// Unregister itself when there are no more clients
|
||||
if (remainingClients.length === 0) {
|
||||
self.registration.unregister()
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
self.addEventListener('fetch', function (event) {
|
||||
const { request } = event
|
||||
|
||||
// Bypass navigation requests.
|
||||
if (request.mode === 'navigate') {
|
||||
return
|
||||
}
|
||||
|
||||
// Opening the DevTools triggers the "only-if-cached" request
|
||||
// that cannot be handled by the worker. Bypass such requests.
|
||||
if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') {
|
||||
return
|
||||
}
|
||||
|
||||
// Bypass all requests when there are no active clients.
|
||||
// Prevents the self-unregistered worked from handling requests
|
||||
// after it's been deleted (still remains active until the next reload).
|
||||
if (activeClientIds.size === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Generate unique request ID.
|
||||
const requestId = crypto.randomUUID()
|
||||
event.respondWith(handleRequest(event, requestId))
|
||||
})
|
||||
|
||||
async function handleRequest(event, requestId) {
|
||||
const client = await resolveMainClient(event)
|
||||
const response = await getResponse(event, client, requestId)
|
||||
|
||||
// Send back the response clone for the "response:*" life-cycle events.
|
||||
// Ensure MSW is active and ready to handle the message, otherwise
|
||||
// this message will pend indefinitely.
|
||||
if (client && activeClientIds.has(client.id)) {
|
||||
;(async function () {
|
||||
const responseClone = response.clone()
|
||||
|
||||
sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'RESPONSE',
|
||||
payload: {
|
||||
requestId,
|
||||
isMockedResponse: IS_MOCKED_RESPONSE in response,
|
||||
type: responseClone.type,
|
||||
status: responseClone.status,
|
||||
statusText: responseClone.statusText,
|
||||
body: responseClone.body,
|
||||
headers: Object.fromEntries(responseClone.headers.entries()),
|
||||
},
|
||||
},
|
||||
[responseClone.body],
|
||||
)
|
||||
})()
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
// Resolve the main client for the given event.
|
||||
// Client that issues a request doesn't necessarily equal the client
|
||||
// that registered the worker. It's with the latter the worker should
|
||||
// communicate with during the response resolving phase.
|
||||
async function resolveMainClient(event) {
|
||||
const client = await self.clients.get(event.clientId)
|
||||
|
||||
if (client?.frameType === 'top-level') {
|
||||
return client
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
return allClients
|
||||
.filter((client) => {
|
||||
// Get only those clients that are currently visible.
|
||||
return client.visibilityState === 'visible'
|
||||
})
|
||||
.find((client) => {
|
||||
// Find the client ID that's recorded in the
|
||||
// set of clients that have registered the worker.
|
||||
return activeClientIds.has(client.id)
|
||||
})
|
||||
}
|
||||
|
||||
async function getResponse(event, client, requestId) {
|
||||
const { request } = event
|
||||
|
||||
// Clone the request because it might've been already used
|
||||
// (i.e. its body has been read and sent to the client).
|
||||
const requestClone = request.clone()
|
||||
|
||||
function passthrough() {
|
||||
const headers = Object.fromEntries(requestClone.headers.entries())
|
||||
|
||||
// Remove internal MSW request header so the passthrough request
|
||||
// complies with any potential CORS preflight checks on the server.
|
||||
// Some servers forbid unknown request headers.
|
||||
delete headers['x-msw-intention']
|
||||
|
||||
return fetch(requestClone, { headers })
|
||||
}
|
||||
|
||||
// Bypass mocking when the client is not active.
|
||||
if (!client) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Bypass initial page load requests (i.e. static assets).
|
||||
// The absence of the immediate/parent client in the map of the active clients
|
||||
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
|
||||
// and is not ready to handle requests.
|
||||
if (!activeClientIds.has(client.id)) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Notify the client that a request has been intercepted.
|
||||
const requestBuffer = await request.arrayBuffer()
|
||||
const clientMessage = await sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'REQUEST',
|
||||
payload: {
|
||||
id: requestId,
|
||||
url: request.url,
|
||||
mode: request.mode,
|
||||
method: request.method,
|
||||
headers: Object.fromEntries(request.headers.entries()),
|
||||
cache: request.cache,
|
||||
credentials: request.credentials,
|
||||
destination: request.destination,
|
||||
integrity: request.integrity,
|
||||
redirect: request.redirect,
|
||||
referrer: request.referrer,
|
||||
referrerPolicy: request.referrerPolicy,
|
||||
body: requestBuffer,
|
||||
keepalive: request.keepalive,
|
||||
},
|
||||
},
|
||||
[requestBuffer],
|
||||
)
|
||||
|
||||
switch (clientMessage.type) {
|
||||
case 'MOCK_RESPONSE': {
|
||||
return respondWithMock(clientMessage.data)
|
||||
}
|
||||
|
||||
case 'PASSTHROUGH': {
|
||||
return passthrough()
|
||||
}
|
||||
}
|
||||
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
function sendToClient(client, message, transferrables = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const channel = new MessageChannel()
|
||||
|
||||
channel.port1.onmessage = (event) => {
|
||||
if (event.data && event.data.error) {
|
||||
return reject(event.data.error)
|
||||
}
|
||||
|
||||
resolve(event.data)
|
||||
}
|
||||
|
||||
client.postMessage(
|
||||
message,
|
||||
[channel.port2].concat(transferrables.filter(Boolean)),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async function respondWithMock(response) {
|
||||
// Setting response status code to 0 is a no-op.
|
||||
// However, when responding with a "Response.error()", the produced Response
|
||||
// instance will have status code set to 0. Since it's not possible to create
|
||||
// a Response instance with status code 0, handle that use-case separately.
|
||||
if (response.status === 0) {
|
||||
return Response.error()
|
||||
}
|
||||
|
||||
const mockedResponse = new Response(response.body, response)
|
||||
|
||||
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
|
||||
value: true,
|
||||
enumerable: true,
|
||||
})
|
||||
|
||||
return mockedResponse
|
||||
}
|
@ -1,61 +0,0 @@
|
||||
/* eslint-disable no-undef */
|
||||
import { initializeApp } from 'https://www.gstatic.com/firebasejs/9.1.3/firebase-app.js';
|
||||
import { getMessaging, getToken } from 'https://www.gstatic.com/firebasejs/9.1.3/firebase-messaging.js';
|
||||
|
||||
const firebaseConfig = {
|
||||
apiKey: 'AIzaSyBQx50AsrS3K687cGbFDh1908ClCLFmnhA',
|
||||
authDomain: 'worlabel-6de69.firebaseapp.com',
|
||||
projectId: 'worlabel-6de69',
|
||||
storageBucket: 'worlabel-6de69.appspot.com',
|
||||
messagingSenderId: '124097400880',
|
||||
appId: '1:124097400880:web:022db3cdc0bdea750c5df5',
|
||||
measurementId: 'G-KW02YRYF5H',
|
||||
};
|
||||
|
||||
const firebaseApp = initializeApp(firebaseConfig);
|
||||
const messaging = getMessaging(firebaseApp);
|
||||
|
||||
const registerServiceWorker = async () => {
|
||||
if (!('serviceWorker' in navigator)) {
|
||||
console.warn('현재 브라우저에서 서비스 워커를 지원하지 않습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('FCM 서비스 워커 등록 중...');
|
||||
const firebaseRegistration = await navigator.serviceWorker.register('/firebase-messaging-sw.js');
|
||||
console.log('FCM 서비스 워커 등록 성공');
|
||||
|
||||
console.log('FCM 서비스 워커 활성화 중...');
|
||||
const serviceWorker = await navigator.serviceWorker.ready;
|
||||
console.log('FCM 서비스 워커 활성화 성공');
|
||||
|
||||
if (serviceWorker && !sessionStorage.getItem('fcmToken')) {
|
||||
const permission = await Notification.requestPermission();
|
||||
|
||||
if (permission === 'granted') {
|
||||
console.log('알림 권한이 허용되었습니다.');
|
||||
|
||||
console.log('FCM 토큰 발급 중...');
|
||||
const currentToken = await getToken(messaging, {
|
||||
vapidKey: 'BApIruZrx83suCd09dnDCkFSP_Ts08q38trrIL6GHpChtbjQHTHk_38_JRyTiKLqciHxLQ8iXtie3lvgyb4Iphg',
|
||||
serviceWorkerRegistration: firebaseRegistration,
|
||||
});
|
||||
console.log('FCM 토큰 발급 성공');
|
||||
|
||||
if (currentToken) {
|
||||
sessionStorage.setItem('fcmToken', currentToken);
|
||||
} else {
|
||||
console.warn('FCM 토큰을 가져올 수 없습니다.');
|
||||
}
|
||||
} else {
|
||||
console.log('알림 권한이 거부되었습니다.');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('FCM 서비스 워커 등록에 실패했습니다. : ', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 서비스 워커 등록 함수 호출
|
||||
registerServiceWorker();
|
@ -1 +0,0 @@
|
||||
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
@ -1,3 +0,0 @@
|
||||
<svg width="129" height="30" viewBox="0 0 129 30" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0.6 0.999998H4.84L6.8 22.52H6.88L8.96 0.999998H13.76L15.84 22.52H15.92L17.88 0.999998H21.68L18.84 29H13.36L11.36 10.12H11.28L9.28 29H3.44L0.6 0.999998ZM30.2656 29.4C28.1056 29.4 26.4523 28.7867 25.3056 27.56C24.159 26.3333 23.5856 24.6 23.5856 22.36V7.64C23.5856 5.4 24.159 3.66667 25.3056 2.44C26.4523 1.21333 28.1056 0.599998 30.2656 0.599998C32.4256 0.599998 34.079 1.21333 35.2256 2.44C36.3723 3.66667 36.9456 5.4 36.9456 7.64V22.36C36.9456 24.6 36.3723 26.3333 35.2256 27.56C34.079 28.7867 32.4256 29.4 30.2656 29.4ZM30.2656 25.4C31.7856 25.4 32.5456 24.48 32.5456 22.64V7.36C32.5456 5.52 31.7856 4.6 30.2656 4.6C28.7456 4.6 27.9856 5.52 27.9856 7.36V22.64C27.9856 24.48 28.7456 25.4 30.2656 25.4ZM39.9213 0.999998H46.4413C48.7079 0.999998 50.3613 1.53333 51.4013 2.6C52.4413 3.64 52.9613 5.25333 52.9613 7.44V9.16C52.9613 12.0667 52.0013 13.9067 50.0813 14.68V14.76C51.1479 15.08 51.8946 15.7333 52.3213 16.72C52.7746 17.7067 53.0013 19.0267 53.0013 20.68V25.6C53.0013 26.4 53.0279 27.0533 53.0813 27.56C53.1346 28.04 53.2679 28.52 53.4813 29H49.0013C48.8413 28.5467 48.7346 28.12 48.6813 27.72C48.6279 27.32 48.6013 26.6 48.6013 25.56V20.44C48.6013 19.16 48.3879 18.2667 47.9613 17.76C47.5613 17.2533 46.8546 17 45.8413 17H44.3213V29H39.9213V0.999998ZM45.9213 13C46.8013 13 47.4546 12.7733 47.8813 12.32C48.3346 11.8667 48.5612 11.1067 48.5612 10.04V7.88C48.5612 6.86667 48.3746 6.13333 48.0013 5.68C47.6546 5.22667 47.0946 5 46.3213 5H44.3213V13H45.9213ZM56.0541 0.999998H60.4541V25H67.6941V29H56.0541V0.999998ZM73.3603 0.999998H79.3203L83.8803 29H79.4803L78.6803 23.44V23.52H73.6803L72.8803 29H68.8003L73.3603 0.999998ZM78.1603 19.72L76.2003 5.88H76.1203L74.2003 19.72H78.1603ZM86.015 0.999998H92.655C94.9217 0.999998 96.575 1.53333 97.615 2.6C98.655 3.64 99.175 5.25333 99.175 7.44V8.56C99.175 10 98.935 11.1733 98.455 12.08C98.0017 12.9867 97.295 13.64 96.335 14.04V14.12C98.5217 14.8667 99.615 16.8133 99.615 19.96V22.36C99.615 24.52 99.0417 26.1733 97.895 27.32C96.775 28.44 95.1217 29 92.935 29H86.015V0.999998ZM92.135 12.4C93.015 12.4 93.6683 12.1733 94.095 11.72C94.5483 11.2667 94.775 10.5067 94.775 9.44V7.88C94.775 6.86667 94.5883 6.13333 94.215 5.68C93.8683 5.22667 93.3083 5 92.535 5H90.415V12.4H92.135ZM92.935 25C93.7083 25 94.2817 24.8 94.655 24.4C95.0283 23.9733 95.215 23.2533 95.215 22.24V19.8C95.215 18.52 94.9883 17.64 94.535 17.16C94.1083 16.6533 93.3883 16.4 92.375 16.4H90.415V25H92.935ZM102.187 0.999998H114.187V5H106.587V12.4H112.627V16.4H106.587V25H114.187V29H102.187V0.999998ZM116.718 0.999998H121.118V25H128.358V29H116.718V0.999998Z" fill="#1E1E1E"/>
|
||||
</svg>
|
Before Width: | Height: | Size: 2.6 KiB |
4
frontend/react-slick.d.ts
vendored
@ -1,4 +0,0 @@
|
||||
declare module 'react-slick' {
|
||||
const Slider: T;
|
||||
export default Slider;
|
||||
}
|
@ -1,21 +0,0 @@
|
||||
import { RouterProvider } from 'react-router-dom';
|
||||
import router from './router';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { Toaster } from './components/ui/toaster';
|
||||
import { DndProvider } from 'react-dnd';
|
||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<RouterProvider router={router} />
|
||||
<Toaster />
|
||||
</DndProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
@ -1,22 +0,0 @@
|
||||
import api from '@/api/axiosConfig';
|
||||
import { AlarmResponse } from '@/types';
|
||||
|
||||
export async function getAlarmList() {
|
||||
return api.get<AlarmResponse[]>('/alarm').then(({ data }) => data);
|
||||
}
|
||||
|
||||
export async function createAlarmTest() {
|
||||
return api.post('/alarm/test').then(({ data }) => data);
|
||||
}
|
||||
|
||||
export async function readAlarm(alarmId: number) {
|
||||
return api.put(`/alarm/${alarmId}`).then(({ data }) => data);
|
||||
}
|
||||
|
||||
export async function deleteAlarm(alarmId: number) {
|
||||
return api.delete(`/alarm/${alarmId}`).then(({ data }) => data);
|
||||
}
|
||||
|
||||
export async function deleteAllAlarm() {
|
||||
return api.delete('/alarm').then(({ data }) => data);
|
||||
}
|
@ -1,24 +0,0 @@
|
||||
import api from '@/api/axiosConfig';
|
||||
import { MemberResponse, RefreshTokenResponse } from '@/types';
|
||||
import { getFcmToken } from './firebaseConfig';
|
||||
|
||||
export async function reissueToken() {
|
||||
return api.post<RefreshTokenResponse>('/auth/reissue', null, { withCredentials: true }).then(({ data }) => data);
|
||||
}
|
||||
|
||||
export async function getProfile() {
|
||||
return api.get<MemberResponse>('/auth/profile').then(({ data }) => data);
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
return api.post('/auth/logout').then(({ data }) => data);
|
||||
}
|
||||
|
||||
export async function getAndSaveFcmToken() {
|
||||
const fcmToken = await getFcmToken();
|
||||
return api.post('/auth/fcm', { token: fcmToken }).then(() => fcmToken);
|
||||
}
|
||||
|
||||
export async function createFcmNotification() {
|
||||
return api.post('/auth/test').then(({ data }) => data);
|
||||
}
|
@ -1,45 +0,0 @@
|
||||
import axios, { AxiosError, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
|
||||
import useAuthStore from '@/stores/useAuthStore';
|
||||
import { RefreshTokenResponse } from '@/types';
|
||||
|
||||
const REFRESH_URL = '/auth/reissue';
|
||||
const api = axios.create({
|
||||
baseURL: `${import.meta.env.VITE_API_URL}`,
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
api.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||
const accessToken = useAuthStore.getState().accessToken;
|
||||
|
||||
if (accessToken) {
|
||||
config.headers.Authorization = `Bearer ${accessToken}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response: AxiosResponse) => response,
|
||||
(error: AxiosError) => {
|
||||
if (error.response?.status !== 401 || error.request.responseURL?.includes(REFRESH_URL)) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
return api
|
||||
.post<RefreshTokenResponse>(REFRESH_URL)
|
||||
.then(({ data }) => {
|
||||
const { accessToken } = data;
|
||||
useAuthStore.getState().setToken(accessToken);
|
||||
if (error.config) {
|
||||
return api(error.config);
|
||||
}
|
||||
return Promise.reject(error);
|
||||
})
|
||||
.catch((error) => {
|
||||
useAuthStore.getState().clearAuth();
|
||||
window.location.href = '/';
|
||||
return Promise.reject(error);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export default api;
|
@ -1,31 +0,0 @@
|
||||
import api from '@/api/axiosConfig';
|
||||
import { LabelCategoryRequest, LabelCategoryResponse } from '@/types';
|
||||
|
||||
// 레이블 카테고리 리스트 조회
|
||||
export async function getProjectCategories(projectId: number) {
|
||||
return api.get<LabelCategoryResponse[]>(`/projects/${projectId}/categories`).then(({ data }) => data);
|
||||
}
|
||||
|
||||
// 레이블 카테고리 추가
|
||||
export async function addProjectCategories(projectId: number, categoryData: LabelCategoryRequest) {
|
||||
return api.post(`/projects/${projectId}/categories`, categoryData).then(({ data }) => data);
|
||||
}
|
||||
|
||||
// 레이블 카테고리 단일 조회
|
||||
export async function getCategoryById(projectId: number, categoryId: number) {
|
||||
return api.get<LabelCategoryResponse>(`/projects/${projectId}/categories/${categoryId}`).then(({ data }) => data);
|
||||
}
|
||||
|
||||
// 레이블 카테고리 삭제
|
||||
export async function deleteCategory(projectId: number, categoryId: number) {
|
||||
return api.delete(`/projects/${projectId}/categories/${categoryId}`).then(({ data }) => data);
|
||||
}
|
||||
|
||||
// 레이블 카테고리 존재 여부 조회
|
||||
export async function checkCategoryExists(projectId: number, categoryName: string) {
|
||||
return api
|
||||
.get<boolean>(`/projects/${projectId}/categories/exist`, {
|
||||
params: { categoryName },
|
||||
})
|
||||
.then(({ data }) => data);
|
||||
}
|
@ -1,24 +0,0 @@
|
||||
import api from '@/api/axiosConfig';
|
||||
import { CommentRequest, CommentResponse } from '@/types';
|
||||
|
||||
export async function getComment(projectId: number, commentId: number) {
|
||||
return api.get<CommentResponse>(`/projects/${projectId}/comments/${commentId}`).then(({ data }) => data);
|
||||
}
|
||||
|
||||
export async function updateComment(projectId: number, commentId: number, commentData: CommentRequest) {
|
||||
return api.put<CommentResponse>(`/projects/${projectId}/comments/${commentId}`, commentData).then(({ data }) => data);
|
||||
}
|
||||
|
||||
export async function deleteComment(projectId: number, commentId: number) {
|
||||
return api.delete(`/projects/${projectId}/comments/${commentId}`).then(({ data }) => data);
|
||||
}
|
||||
|
||||
export async function createComment(projectId: number, imageId: number, commentData: CommentRequest) {
|
||||
return api
|
||||
.post<CommentResponse>(`/projects/${projectId}/comments/images/${imageId}`, commentData)
|
||||
.then(({ data }) => data);
|
||||
}
|
||||
|
||||
export async function getCommentList(projectId: number, imageId: number) {
|
||||
return api.get<CommentResponse[]>(`/projects/${projectId}/comments/images/${imageId}`).then(({ data }) => data);
|
||||
}
|
@ -1,60 +0,0 @@
|
||||
import { initializeApp } from 'firebase/app';
|
||||
import { getMessaging, onMessage } from 'firebase/messaging';
|
||||
|
||||
const firebaseConfig = {
|
||||
apiKey: String(import.meta.env.VITE_FIREBASE_API_KEY),
|
||||
authDomain: String(import.meta.env.VITE_FIREBASE_AUTH_DOMAIN),
|
||||
projectId: String(import.meta.env.VITE_FIREBASE_PROJECT_ID),
|
||||
storageBucket: String(import.meta.env.VITE_FIREBASE_STORAGE_BUCKET),
|
||||
messagingSenderId: String(import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID),
|
||||
appId: String(import.meta.env.VITE_FIREBASE_APP_ID),
|
||||
measurementId: String(import.meta.env.VITE_FIREBASE_MEASUREMENT_ID),
|
||||
};
|
||||
|
||||
const firebaseApp = initializeApp(firebaseConfig);
|
||||
const messaging = getMessaging(firebaseApp);
|
||||
|
||||
const getFcmToken = async () => {
|
||||
const existingToken = sessionStorage.getItem('fcmToken');
|
||||
|
||||
if (existingToken) {
|
||||
// 이미 토큰이 있는 경우, 해당 토큰을 반환한다.
|
||||
return existingToken;
|
||||
}
|
||||
|
||||
// try {
|
||||
// const permission = await Notification.requestPermission();
|
||||
|
||||
// if (permission === 'granted') {
|
||||
// console.log('알림 권한이 허용되었습니다.');
|
||||
|
||||
// console.log('FCM 토큰 발급 중...');
|
||||
// const currentToken = await getToken(messaging, {
|
||||
// vapidKey: 'BApIruZrx83suCd09dnDCkFSP_Ts08q38trrIL6GHpChtbjQHTHk_x38_JRyTiKLqciHxLQ8iXtie3lvgyb4Iphg',
|
||||
// });
|
||||
// console.log('FCM 토큰 발급 성공');
|
||||
|
||||
// if (currentToken) {
|
||||
// sessionStorage.setItem('fcmToken', currentToken);
|
||||
// return currentToken;
|
||||
// }
|
||||
|
||||
// console.warn('FCM 토큰을 가져올 수 없습니다.');
|
||||
// } else {
|
||||
// console.log('알림 권한이 거부되었습니다.');
|
||||
// }
|
||||
// } catch (error) {
|
||||
// console.error('FCM 토큰을 가져오는 중 오류가 발생했습니다. : ', error);
|
||||
// }
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleForegroundMessages = () => {
|
||||
onMessage(messaging, (payload) => {
|
||||
if (!payload.data) return;
|
||||
|
||||
console.log(payload.data);
|
||||
});
|
||||
};
|
||||
|
||||
export { getFcmToken, handleForegroundMessages, messaging };
|
@ -1,28 +0,0 @@
|
||||
import api from '@/api/axiosConfig';
|
||||
import { FolderRequest, FolderResponse } from '@/types';
|
||||
|
||||
export async function getFolder(projectId: string, folderId: number) {
|
||||
return api.get<FolderResponse>(`/projects/${projectId}/folders/${folderId}`).then(({ data }) => data);
|
||||
}
|
||||
export async function updateFolder(projectId: number, folderId: number, folderData: FolderRequest) {
|
||||
return api.put(`/projects/${projectId}/folders/${folderId}`, folderData).then(({ data }) => data);
|
||||
}
|
||||
|
||||
export async function deleteFolder(projectId: number, folderId: number, memberId: number) {
|
||||
return api
|
||||
.delete(`/projects/${projectId}/folders/${folderId}`, {
|
||||
params: { memberId },
|
||||
})
|
||||
.then(({ data }) => data);
|
||||
}
|
||||
|
||||
export async function createFolder(projectId: number, folderData: FolderRequest) {
|
||||
return api.post(`/projects/${projectId}/folders`, folderData).then(({ data }) => data);
|
||||
}
|
||||
export async function getFolderReviewList(projectId: number, folderId: number, memberId: number) {
|
||||
return api
|
||||
.get(`/projects/${projectId}/folders/${folderId}/review`, {
|
||||
params: { memberId },
|
||||
})
|
||||
.then(({ data }) => data);
|
||||
}
|
@ -1,179 +0,0 @@
|
||||
import api from '@/api/axiosConfig';
|
||||
import { ImageMoveRequest, ImageStatusChangeRequest, ImagePresignedUrlResponse } from '@/types';
|
||||
import axios from 'axios';
|
||||
|
||||
export async function getImage(projectId: number, folderId: number, imageId: number) {
|
||||
return api.get(`/api/projects/${projectId}/folders/${folderId}/images/${imageId}`);
|
||||
}
|
||||
|
||||
export async function moveImage(projectId: number, folderId: number, imageId: number, moveRequest: ImageMoveRequest) {
|
||||
return api.put(`/projects/${projectId}/folders/${folderId}/images/${imageId}`, moveRequest);
|
||||
}
|
||||
|
||||
export async function deleteImage(projectId: number, folderId: number, imageId: number) {
|
||||
return api.delete(`/projects/${projectId}/folders/${folderId}/images/${imageId}`);
|
||||
}
|
||||
|
||||
export async function changeImageStatus(
|
||||
imageId: number,
|
||||
memberId: number,
|
||||
statusChangeRequest: ImageStatusChangeRequest
|
||||
) {
|
||||
return api
|
||||
.put(`/images/${imageId}/status`, statusChangeRequest, {
|
||||
params: { memberId },
|
||||
})
|
||||
.then(({ data }) => data);
|
||||
}
|
||||
|
||||
export async function uploadImageFile(
|
||||
memberId: number,
|
||||
projectId: number,
|
||||
folderId: number,
|
||||
files: File[],
|
||||
processCallback: (progress: number) => void
|
||||
) {
|
||||
const formData = new FormData();
|
||||
files.forEach((file) => {
|
||||
formData.append('imageList', file);
|
||||
});
|
||||
|
||||
return api
|
||||
.post(`/projects/${projectId}/folders/${folderId}/images/file`, formData, {
|
||||
params: { memberId },
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (progressEvent.total) {
|
||||
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total);
|
||||
processCallback(progress);
|
||||
}
|
||||
},
|
||||
})
|
||||
.then(({ data }) => data);
|
||||
}
|
||||
|
||||
export async function uploadImageFolderFile(
|
||||
memberId: number,
|
||||
projectId: number,
|
||||
folderId: number,
|
||||
files: File[],
|
||||
processCallback: (progress: number) => void
|
||||
) {
|
||||
const formData = new FormData();
|
||||
files.forEach((file) => {
|
||||
formData.append('imageList', file);
|
||||
});
|
||||
|
||||
return api
|
||||
.post(`/projects/${projectId}/folders/${folderId}/images/file`, formData, {
|
||||
params: { memberId },
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (progressEvent.total) {
|
||||
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total);
|
||||
processCallback(progress);
|
||||
}
|
||||
},
|
||||
})
|
||||
.then(({ data }) => data);
|
||||
}
|
||||
|
||||
export async function uploadImageFolder(
|
||||
memberId: number,
|
||||
projectId: number,
|
||||
folderId: number,
|
||||
files: File[],
|
||||
processCallback: (progress: number) => void
|
||||
) {
|
||||
const formData = new FormData();
|
||||
files.forEach((file) => {
|
||||
formData.append('imageList', file);
|
||||
});
|
||||
|
||||
return api
|
||||
.post(`/projects/${projectId}/folders/${folderId}/images/folder`, formData, {
|
||||
params: { memberId },
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (progressEvent.total) {
|
||||
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total);
|
||||
processCallback(progress);
|
||||
}
|
||||
},
|
||||
})
|
||||
.then(({ data }) => data);
|
||||
}
|
||||
|
||||
export async function uploadImageZip(
|
||||
memberId: number,
|
||||
projectId: number,
|
||||
folderId: number,
|
||||
file: File,
|
||||
processCallback: (progress: number) => void
|
||||
) {
|
||||
const formData = new FormData();
|
||||
formData.append('folderZip', file);
|
||||
|
||||
return api
|
||||
.post(`/projects/${projectId}/folders/${folderId}/images/zip`, formData, {
|
||||
params: { memberId },
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (progressEvent.total) {
|
||||
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total);
|
||||
processCallback(progress);
|
||||
}
|
||||
},
|
||||
})
|
||||
.then(({ data }) => data);
|
||||
}
|
||||
|
||||
export async function uploadImagePresigned(
|
||||
memberId: number,
|
||||
projectId: number,
|
||||
folderId: number,
|
||||
files: File[],
|
||||
processCallback: (index: number) => void
|
||||
) {
|
||||
// 업로드 시작 시간 기록
|
||||
const startTime = new Date().getTime();
|
||||
|
||||
// 파일 메타데이터 생성
|
||||
const imageMetaList = files.map((file: File, index: number) => ({
|
||||
id: index,
|
||||
fileName: file.name,
|
||||
}));
|
||||
|
||||
// 서버로부터 presigned URL 리스트 받아옴
|
||||
const { data: presignedUrlList }: { data: ImagePresignedUrlResponse[] } = await api.post(
|
||||
`/projects/${projectId}/folders/${folderId}/images/presigned`,
|
||||
imageMetaList,
|
||||
{
|
||||
params: { memberId },
|
||||
}
|
||||
);
|
||||
|
||||
for (const presignedUrlInfo of presignedUrlList) {
|
||||
const file = files[presignedUrlInfo.id];
|
||||
|
||||
try {
|
||||
// S3 presigned URL로 개별 파일 업로드
|
||||
await axios.put(presignedUrlInfo.presignedUrl, file, {
|
||||
headers: {
|
||||
'Content-Type': file.type, // 파일의 타입 설정
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (progressEvent.total) {
|
||||
processCallback(presignedUrlInfo.id); // 성공 시 진행 상황 업데이트
|
||||
}
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
// 업로드 실패 시 로그 출력
|
||||
console.error(`업로드 실패: ${file.name}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// 업로드 완료 시간 기록
|
||||
const endTime = new Date().getTime();
|
||||
// 소요 시간 계산 (초 단위로 변환)
|
||||
const durationInSeconds = (endTime - startTime) / 1000;
|
||||
// 소요 시간 콘솔 출력
|
||||
console.log(`모든 파일 업로드 완료. 총 소요 시간: ${durationInSeconds}초`);
|
||||
}
|
@ -1,13 +0,0 @@
|
||||
import { LabelJson } from '@/types';
|
||||
import axios from 'axios';
|
||||
|
||||
export async function getLabelJson(jsonPath: string) {
|
||||
return axios
|
||||
.get<LabelJson>(jsonPath, {
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache',
|
||||
},
|
||||
})
|
||||
.then(({ data }) => data)
|
||||
.catch(() => ({}) as LabelJson);
|
||||
}
|
@ -1,15 +0,0 @@
|
||||
import api from '@/api/axiosConfig';
|
||||
|
||||
export async function saveImageLabels(
|
||||
projectId: number,
|
||||
imageId: number,
|
||||
data: {
|
||||
data: string;
|
||||
}
|
||||
) {
|
||||
return api.post(`/projects/${projectId}/images/${imageId}/label`, data).then(({ data }) => data);
|
||||
}
|
||||
|
||||
export async function runAutoLabel(projectId: number, modelId = 1) {
|
||||
return api.post(`/projects/${projectId}/auto`, { modelId }, { timeout: 0 }).then(({ data }) => data);
|
||||
}
|
@ -1,11 +0,0 @@
|
||||
import api from '@/api/axiosConfig';
|
||||
import { MemberResponse } from '@/types';
|
||||
|
||||
export async function searchMembersByEmail(keyword: string) {
|
||||
return api
|
||||
.get<MemberResponse[]>(`/members`, {
|
||||
params: { keyword },
|
||||
withCredentials: true,
|
||||
})
|
||||
.then(({ data }) => data);
|
||||
}
|
@ -1,22 +0,0 @@
|
||||
import api from '@/api/axiosConfig';
|
||||
import { ModelRequest, ModelResponse, ProjectModelsResponse, ModelCategoryResponse, ModelTrainRequest } from '@/types';
|
||||
|
||||
export async function updateModelName(projectId: number, modelId: number, modelData: ModelRequest) {
|
||||
return api.put<ModelResponse>(`/projects/${projectId}/models/${modelId}`, modelData).then(({ data }) => data);
|
||||
}
|
||||
|
||||
export async function trainModel(projectId: number, trainData: ModelTrainRequest) {
|
||||
return api.post(`/projects/${projectId}/train`, trainData).then(({ data }) => data);
|
||||
}
|
||||
|
||||
export async function getProjectModels(projectId: number) {
|
||||
return api.get<ProjectModelsResponse>(`/projects/${projectId}/models`).then(({ data }) => data);
|
||||
}
|
||||
|
||||
export async function addProjectModel(projectId: number, modelData: ModelRequest) {
|
||||
return api.post<ModelResponse>(`/projects/${projectId}/models`, modelData).then(({ data }) => data);
|
||||
}
|
||||
|
||||
export async function getModelCategories(modelId: number) {
|
||||
return api.get<ModelCategoryResponse[]>(`/models/${modelId}/categories`).then(({ data }) => data);
|
||||
}
|
@ -1,103 +0,0 @@
|
||||
import api from '@/api/axiosConfig';
|
||||
import { ProjectResponse, ProjectMemberRequest, ProjectMemberResponse } from '@/types';
|
||||
|
||||
export async function getProjectList(
|
||||
workspaceId: number,
|
||||
memberId: number,
|
||||
lastProjectId?: number,
|
||||
limitPage: number = 50
|
||||
) {
|
||||
return api
|
||||
.get<ProjectResponse[]>(`/workspaces/${workspaceId}/projects`, {
|
||||
params: {
|
||||
memberId,
|
||||
lastProjectId,
|
||||
limitPage,
|
||||
},
|
||||
})
|
||||
.then(({ data }) => data);
|
||||
}
|
||||
|
||||
export async function getProject(projectId: number, memberId: number) {
|
||||
return api
|
||||
.get<ProjectResponse>(`/projects/${projectId}`, {
|
||||
params: { memberId },
|
||||
})
|
||||
.then(({ data }) => data);
|
||||
}
|
||||
|
||||
export async function updateProject(
|
||||
projectId: number,
|
||||
memberId: number,
|
||||
data: { title: string; projectType: 'classification' | 'detection' | 'segmentation' }
|
||||
) {
|
||||
return api
|
||||
.put(`/projects/${projectId}`, data, {
|
||||
params: { memberId },
|
||||
})
|
||||
.then(({ data }) => data);
|
||||
}
|
||||
|
||||
export async function deleteProject(projectId: number, memberId: number) {
|
||||
return api
|
||||
.delete(`/projects/${projectId}`, {
|
||||
params: { memberId },
|
||||
})
|
||||
.then(({ data }) => data);
|
||||
}
|
||||
|
||||
export async function createProject(
|
||||
workspaceId: number,
|
||||
memberId: number,
|
||||
data: { title: string; projectType: 'classification' | 'detection' | 'segmentation' }
|
||||
) {
|
||||
return api
|
||||
.post(`/workspaces/${workspaceId}/projects`, data, {
|
||||
params: { memberId },
|
||||
})
|
||||
.then(({ data }) => data);
|
||||
}
|
||||
|
||||
// 프로젝트 멤버 조회
|
||||
export async function getProjectMembers(projectId: number, memberId: number) {
|
||||
return api
|
||||
.get<ProjectMemberResponse[]>(`/projects/${projectId}/members`, {
|
||||
params: { memberId },
|
||||
})
|
||||
.then(({ data }) => data);
|
||||
}
|
||||
|
||||
// 프로젝트 멤버 추가
|
||||
export async function addProjectMember(projectId: number, memberId: number, newMember: ProjectMemberRequest) {
|
||||
return api
|
||||
.post<ProjectMemberResponse>(`/projects/${projectId}/members`, newMember, {
|
||||
params: { memberId },
|
||||
})
|
||||
.then(({ data }) => data);
|
||||
}
|
||||
|
||||
// 프로젝트 멤버 권한 수정
|
||||
export async function updateProjectMemberPrivilege(
|
||||
projectId: number,
|
||||
memberId: number,
|
||||
privilegeType: ProjectMemberResponse['privilegeType'] // 수정 가능한 권한 타입으로 변경
|
||||
) {
|
||||
const privilegeData = {
|
||||
memberId,
|
||||
privilegeType,
|
||||
};
|
||||
|
||||
return api.put<ProjectMemberResponse>(`/projects/${projectId}/members`, privilegeData).then(({ data }) => data);
|
||||
}
|
||||
|
||||
// 프로젝트 멤버 삭제
|
||||
export async function removeProjectMember(projectId: number, targetMemberId: number) {
|
||||
return api
|
||||
.delete(`/projects/${projectId}/members`, {
|
||||
data: targetMemberId,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
.then(({ data }) => data);
|
||||
}
|
@ -1,12 +0,0 @@
|
||||
import api from '@/api/axiosConfig';
|
||||
import { ReportResponse } from '@/types';
|
||||
|
||||
export async function getCompletedModelReport(projectId: number, modelId: number) {
|
||||
return api.get<ReportResponse[]>(`/projects/${projectId}/reports/models/${modelId}`).then(({ data }) => data);
|
||||
}
|
||||
|
||||
export async function getTrainingModelReport(projectId: number, modelId: number) {
|
||||
return api
|
||||
.get<ReportResponse[]>(`/projects/${projectId}/reports/models/${modelId}/progress`)
|
||||
.then(({ data }) => data);
|
||||
}
|
@ -1,6 +0,0 @@
|
||||
import api from '@/api/axiosConfig';
|
||||
import { ResultResponse } from '@/types';
|
||||
|
||||
export async function getModelResult(modelId: number) {
|
||||
return api.get<ResultResponse[]>(`/results/model/${modelId}`).then(({ data }) => data);
|
||||
}
|
@ -1,67 +0,0 @@
|
||||
import api from '@/api/axiosConfig';
|
||||
import { ReviewDetailResponse, ReviewRequest, ReviewResponse, ReviewStatus } from '@/types';
|
||||
|
||||
// 리뷰 단건 조회
|
||||
export async function getReviewDetail(projectId: number, reviewId: number, memberId: number) {
|
||||
return api
|
||||
.get<ReviewDetailResponse>(`/projects/${projectId}/reviews/${reviewId}`, {
|
||||
params: { memberId },
|
||||
})
|
||||
.then(({ data }) => data);
|
||||
}
|
||||
|
||||
// 리뷰 생성
|
||||
export async function createReview(projectId: number, memberId: number, reviewData: ReviewRequest) {
|
||||
return api
|
||||
.post<ReviewResponse>(`/projects/${projectId}/reviews`, reviewData, {
|
||||
params: { memberId },
|
||||
})
|
||||
.then(({ data }) => data);
|
||||
}
|
||||
|
||||
// 리뷰 수정
|
||||
export async function updateReview(projectId: number, reviewId: number, memberId: number, reviewData: ReviewRequest) {
|
||||
return api
|
||||
.put<ReviewResponse>(`/projects/${projectId}/reviews/${reviewId}`, reviewData, {
|
||||
params: { memberId },
|
||||
})
|
||||
.then(({ data }) => data);
|
||||
}
|
||||
|
||||
// 리뷰 삭제
|
||||
export async function deleteReview(projectId: number, reviewId: number, memberId: number) {
|
||||
return api
|
||||
.delete(`/projects/${projectId}/reviews/${reviewId}`, {
|
||||
params: { memberId },
|
||||
})
|
||||
.then(({ data }) => data);
|
||||
}
|
||||
|
||||
export async function approveReview(projectId: number, reviewId: number) {
|
||||
return api.put(`/projects/${projectId}/reviews/${reviewId}/approve`);
|
||||
}
|
||||
|
||||
export async function rejectReview(projectId: number, reviewId: number) {
|
||||
return api.put(`/projects/${projectId}/reviews/${reviewId}/reject`);
|
||||
}
|
||||
|
||||
export async function getReviewByStatus(
|
||||
projectId: number,
|
||||
memberId: number,
|
||||
sortDirection: number,
|
||||
reviewStatus?: ReviewStatus,
|
||||
lastReviewId?: number,
|
||||
limitPage: number = 10
|
||||
) {
|
||||
return api
|
||||
.get<ReviewResponse[]>(`/projects/${projectId}/reviews`, {
|
||||
params: {
|
||||
memberId,
|
||||
limitPage,
|
||||
sortDirection,
|
||||
...(reviewStatus ? { reviewStatus } : {}),
|
||||
...(lastReviewId ? { lastReviewId } : {}),
|
||||
},
|
||||
})
|
||||
.then(({ data }) => data);
|
||||
}
|
@ -1,90 +0,0 @@
|
||||
import api from '@/api/axiosConfig';
|
||||
import {
|
||||
WorkspaceListResponse,
|
||||
WorkspaceRequest,
|
||||
WorkspaceResponse,
|
||||
ReviewResponse,
|
||||
WorkspaceMemberResponse,
|
||||
ReviewStatus,
|
||||
} from '@/types';
|
||||
|
||||
export async function getWorkspaceList(memberId: number, lastWorkspaceId?: number, limitPage: number = 30) {
|
||||
return api
|
||||
.get<WorkspaceListResponse>('/workspaces', {
|
||||
params: { memberId, lastWorkspaceId, limitPage },
|
||||
})
|
||||
.then(({ data }) => data);
|
||||
}
|
||||
|
||||
export async function getWorkspace(workspaceId: number, memberId: number) {
|
||||
return api
|
||||
.get<WorkspaceResponse>(`/workspaces/${workspaceId}`, {
|
||||
params: { memberId },
|
||||
})
|
||||
.then(({ data }) => data);
|
||||
}
|
||||
|
||||
export async function updateWorkspace(workspaceId: number, memberId: number, data: WorkspaceRequest) {
|
||||
return api
|
||||
.put(`/workspaces/${workspaceId}`, data, {
|
||||
params: { memberId },
|
||||
})
|
||||
.then(({ data }) => data);
|
||||
}
|
||||
|
||||
export async function deleteWorkspace(workspaceId: number, memberId: number) {
|
||||
return api
|
||||
.delete(`/workspaces/${workspaceId}`, {
|
||||
params: { memberId },
|
||||
})
|
||||
.then(({ data }) => data);
|
||||
}
|
||||
|
||||
export async function createWorkspace(memberId: number, data: WorkspaceRequest) {
|
||||
return api
|
||||
.post('/workspaces', data, {
|
||||
params: { memberId },
|
||||
})
|
||||
.then(({ data }) => data);
|
||||
}
|
||||
|
||||
export async function addWorkspaceMember(workspaceId: number, memberId: number, newMemberId: number) {
|
||||
return api
|
||||
.post(`/workspaces/${workspaceId}/members/${newMemberId}`, null, {
|
||||
params: { memberId },
|
||||
})
|
||||
.then(({ data }) => data);
|
||||
}
|
||||
|
||||
export async function getWorkspaceReviews(
|
||||
workspaceId: number,
|
||||
memberId: number,
|
||||
sortDirection: number,
|
||||
reviewStatus?: ReviewStatus,
|
||||
lastReviewId?: number,
|
||||
limitPage: number = 10
|
||||
) {
|
||||
return api
|
||||
.get<ReviewResponse[]>(`/workspaces/${workspaceId}/reviews`, {
|
||||
params: {
|
||||
memberId,
|
||||
limitPage,
|
||||
sortDirection,
|
||||
...(reviewStatus ? { reviewStatus } : {}),
|
||||
...(lastReviewId ? { lastReviewId } : {}),
|
||||
},
|
||||
})
|
||||
.then(({ data }) => data);
|
||||
}
|
||||
|
||||
export async function removeWorkspaceMember(workspaceId: number, memberId: number, targetMemberId: number) {
|
||||
return api
|
||||
.delete(`/workspaces/${workspaceId}/members/${targetMemberId}`, {
|
||||
params: { memberId },
|
||||
})
|
||||
.then(({ data }) => data);
|
||||
}
|
||||
|
||||
export async function getWorkspaceMembers(workspaceId: number) {
|
||||
return api.get<WorkspaceMemberResponse[]>(`/workspaces/${workspaceId}/members`).then(({ data }) => data);
|
||||
}
|
@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6 9L12 15L18 9" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
Before Width: | Height: | Size: 336 B |
@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6 15L12 9L18 15" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
Before Width: | Height: | Size: 337 B |
@ -1,19 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 -0.5 21 21" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
|
||||
<title>delete [#1487]</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<defs>
|
||||
|
||||
</defs>
|
||||
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="Dribbble-Light-Preview" transform="translate(-179.000000, -360.000000)" fill="#000000">
|
||||
<g id="icons" transform="translate(56.000000, 160.000000)">
|
||||
<path d="M130.35,216 L132.45,216 L132.45,208 L130.35,208 L130.35,216 Z M134.55,216 L136.65,216 L136.65,208 L134.55,208 L134.55,216 Z M128.25,218 L138.75,218 L138.75,206 L128.25,206 L128.25,218 Z M130.35,204 L136.65,204 L136.65,202 L130.35,202 L130.35,204 Z M138.75,204 L138.75,200 L128.25,200 L128.25,204 L123,204 L123,206 L126.15,206 L126.15,220 L140.85,220 L140.85,206 L144,206 L144,204 L138.75,204 Z" id="delete-[#1487]">
|
||||
|
||||
</path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
Before Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 622 KiB |
Before Width: | Height: | Size: 3.1 KiB |
Before Width: | Height: | Size: 2.7 KiB |
@ -1,5 +0,0 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="X">
|
||||
<path id="Icon" d="M24 8L8 24M8 8L24 24" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
Before Width: | Height: | Size: 226 B |
@ -1,46 +0,0 @@
|
||||
import { Link, useLocation, useParams } from 'react-router-dom';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export default function AdminMenuSidebar() {
|
||||
const location = useLocation();
|
||||
const { workspaceId, projectId } = useParams<{ workspaceId: string; projectId?: string }>();
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
label: '멤버 관리',
|
||||
path: projectId ? `/admin/${workspaceId}/members/${projectId}` : `/admin/${workspaceId}/members`,
|
||||
},
|
||||
{
|
||||
label: '모델 관리',
|
||||
path: projectId ? `/admin/${workspaceId}/models/${projectId}` : `/admin/${workspaceId}/models`,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-[280px] flex-col justify-between border-l border-gray-300 bg-gray-100">
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<header className="subheading flex w-full items-center gap-2 px-5 py-2.5">
|
||||
<h2 className="w-full overflow-hidden text-ellipsis whitespace-nowrap">메뉴</h2>
|
||||
</header>
|
||||
<div className="flex flex-col gap-1 px-2.5">
|
||||
{menuItems.map((item) => {
|
||||
const isActive = location.pathname.startsWith(item.path);
|
||||
return (
|
||||
<Link
|
||||
key={item.label}
|
||||
to={item.path}
|
||||
className={cn(
|
||||
'body cursor-pointer rounded-md px-3 py-2 text-left text-gray-800 hover:bg-gray-200',
|
||||
'transition-colors focus:bg-gray-300 focus:outline-none',
|
||||
isActive ? 'bg-gray-300 font-semibold' : ''
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,73 +0,0 @@
|
||||
import { ResizablePanel, ResizableHandle } from '../ui/resizable';
|
||||
import { Link, useLocation, useParams } from 'react-router-dom';
|
||||
import { SquarePen } from 'lucide-react';
|
||||
import useProjectListQuery from '@/queries/projects/useProjectListQuery';
|
||||
import useWorkspaceQuery from '@/queries/workspaces/useWorkspaceQuery';
|
||||
import useAuthStore from '@/stores/useAuthStore';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export default function AdminProjectSidebar(): JSX.Element {
|
||||
const location = useLocation();
|
||||
const { workspaceId, projectId } = useParams<{ workspaceId: string; projectId?: string }>();
|
||||
const profile = useAuthStore((state) => state.profile);
|
||||
const memberId = profile?.id || 0;
|
||||
|
||||
const { data: workspaceData } = useWorkspaceQuery(Number(workspaceId), memberId);
|
||||
const workspaceTitle = workspaceData?.title || `Workspace-${workspaceId}`;
|
||||
|
||||
const { data: projects } = useProjectListQuery(Number(workspaceId), memberId);
|
||||
|
||||
const getNewPath = (newProjectId: string) => {
|
||||
if (location.pathname.includes('members')) {
|
||||
return `/admin/${workspaceId}/members/${newProjectId}`;
|
||||
}
|
||||
if (location.pathname.includes('models')) {
|
||||
return `/admin/${workspaceId}/models/${newProjectId}`;
|
||||
}
|
||||
return location.pathname;
|
||||
};
|
||||
|
||||
const basePath = location.pathname.split('/')[3];
|
||||
const basePathWithoutProjectId = `/admin/${workspaceId}/${basePath}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ResizablePanel
|
||||
minSize={15}
|
||||
maxSize={35}
|
||||
defaultSize={20}
|
||||
className="flex h-full flex-col border-r border-gray-200 bg-gray-100"
|
||||
>
|
||||
<header className="flex w-full items-center justify-between gap-2 border-b border-gray-200 p-4">
|
||||
<Link
|
||||
to={basePathWithoutProjectId}
|
||||
className="heading w-full overflow-hidden text-ellipsis whitespace-nowrap text-xl font-bold text-gray-900"
|
||||
>
|
||||
{workspaceTitle}
|
||||
</Link>
|
||||
<button className="p-2">
|
||||
<SquarePen size={16} />
|
||||
</button>
|
||||
</header>
|
||||
<div className="flex flex-col gap-2 p-4">
|
||||
{projects.map((project) => {
|
||||
const isActive = projectId === String(project.id);
|
||||
return (
|
||||
<Link
|
||||
key={project.id}
|
||||
to={getNewPath(String(project.id))}
|
||||
className={cn(
|
||||
'body cursor-pointer rounded-md px-3 py-2 text-left hover:bg-gray-200',
|
||||
isActive ? 'bg-gray-300 font-semibold' : ''
|
||||
)}
|
||||
>
|
||||
{project.title}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle className="bg-gray-300" />
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,20 +0,0 @@
|
||||
import '@/index.css';
|
||||
import CanvasControlBar from '.';
|
||||
|
||||
export default {
|
||||
title: 'Components/CanvasControlBar',
|
||||
component: CanvasControlBar,
|
||||
};
|
||||
|
||||
export const Default = () => (
|
||||
<CanvasControlBar
|
||||
saveJson={() => {}}
|
||||
projectType="segmentation"
|
||||
categories={[
|
||||
{
|
||||
id: 1,
|
||||
labelName: 'label',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
@ -1,81 +0,0 @@
|
||||
import useCanvasStore from '@/stores/useCanvasStore';
|
||||
import { BookmarkPlus, LucideIcon, MousePointer2, PenTool, Save, Square } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { MessageSquare } from 'lucide-react';
|
||||
import { LabelCategoryResponse } from '@/types';
|
||||
|
||||
export default function CanvasControlBar({
|
||||
saveJson,
|
||||
projectType,
|
||||
categories,
|
||||
}: {
|
||||
saveJson: () => void;
|
||||
projectType: 'classification' | 'detection' | 'segmentation';
|
||||
categories: LabelCategoryResponse[];
|
||||
}) {
|
||||
const { drawState, setDrawState, setLabels, labels } = useCanvasStore();
|
||||
const buttonBaseClassName = 'rounded-lg p-2 transition-colors';
|
||||
const buttonClassName = 'hover:bg-gray-100';
|
||||
const activeButtonClassName = 'bg-primary stroke-white';
|
||||
|
||||
const controls: { [key: string]: LucideIcon } = {
|
||||
pointer: MousePointer2,
|
||||
...(projectType === 'segmentation' ? { pen: PenTool } : projectType === 'detection' ? { rect: Square } : null),
|
||||
comment: MessageSquare,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-10 left-[50%] flex translate-x-[-50%] items-center gap-2 rounded-xl bg-white p-1 shadow-xl">
|
||||
{Object.keys(controls).map((control) => {
|
||||
const Icon = controls[control];
|
||||
return (
|
||||
<button
|
||||
key={control}
|
||||
className={cn(drawState === control ? activeButtonClassName : buttonClassName, buttonBaseClassName)}
|
||||
onClick={() => setDrawState(control as typeof drawState)}
|
||||
>
|
||||
<Icon
|
||||
size={20}
|
||||
color={drawState === control ? 'white' : 'black'}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{projectType === 'classification' && (
|
||||
<button
|
||||
className={cn(labels.length === 0 ? buttonClassName : '', buttonBaseClassName)}
|
||||
onClick={() => {
|
||||
setLabels([
|
||||
{
|
||||
id: 0,
|
||||
categoryId: categories[0]!.id,
|
||||
color: '#1177ff',
|
||||
type: 'point',
|
||||
coordinates: [[0, 0]],
|
||||
},
|
||||
]);
|
||||
}}
|
||||
disabled={labels.length !== 0}
|
||||
>
|
||||
<BookmarkPlus
|
||||
size={20}
|
||||
color={labels.length === 0 ? 'black' : 'gray'}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="h-5 w-0.5 rounded bg-gray-400" />
|
||||
|
||||
<button
|
||||
className={cn(buttonClassName, buttonBaseClassName)}
|
||||
onClick={saveJson}
|
||||
>
|
||||
<Save
|
||||
size={20}
|
||||
color="black"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,31 +0,0 @@
|
||||
import { Dialog, DialogContent, DialogOverlay, DialogTitle, DialogClose } from '@radix-ui/react-dialog';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface ModalProps {
|
||||
title: string;
|
||||
content: React.ReactNode;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function Modal({ title, content, open, onClose }: ModalProps) {
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={onClose}
|
||||
>
|
||||
<DialogOverlay className="fixed inset-0 bg-black/50" />
|
||||
<DialogContent className="fixed left-1/2 top-1/2 w-[90%] max-w-lg -translate-x-1/2 -translate-y-1/2 transform rounded-lg bg-white p-6 shadow-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle className="text-xl font-bold">{title}</DialogTitle>
|
||||
<DialogClose asChild>
|
||||
<button className="text-gray-500 hover:text-black">
|
||||
<X size={24} />
|
||||
</button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
<div className="mt-4">{content}</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
@ -1,13 +0,0 @@
|
||||
import '@/index.css';
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import Footer from './index';
|
||||
|
||||
const meta: Meta<typeof Footer> = {
|
||||
title: 'Components/Footer',
|
||||
component: Footer,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Footer>;
|
||||
|
||||
export const Default: Story = {};
|
@ -1,131 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import Modal from './Modal';
|
||||
|
||||
export default function Footer() {
|
||||
const [modalState, setModalState] = React.useState<'terms' | 'privacy' | null>(null);
|
||||
|
||||
return (
|
||||
<footer className="select-none bg-gray-100">
|
||||
<div className="container py-8 text-gray-400">
|
||||
<div className="body-small flex flex-col items-start gap-5">
|
||||
<div>
|
||||
<div className="heading">WorLabel</div>
|
||||
<span>Copyright © 2024 WorLabel All rights reserved</span>
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<button onClick={() => setModalState('terms')}>서비스 이용약관</button>
|
||||
<span className="h-3 w-px rounded bg-gray-400" />
|
||||
<button onClick={() => setModalState('privacy')}>개인정보 처리방침</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Modal
|
||||
title="서비스 이용약관"
|
||||
content={
|
||||
<>
|
||||
<p>
|
||||
<strong>제1조 목적</strong>
|
||||
</p>
|
||||
<p>
|
||||
이 약관은 WorLabel(이하 "회사")이 제공하는 모든 서비스(이하 "서비스")의 이용과 관련된 사항을 규정하는 것을
|
||||
목적으로 합니다.
|
||||
</p>
|
||||
<br />
|
||||
<p>
|
||||
<strong>제2조 정의</strong>
|
||||
</p>
|
||||
<p>
|
||||
1. "서비스"란 회사가 제공하는 모든 온라인 콘텐츠와 기능을 의미합니다.
|
||||
<br />
|
||||
2. "회원"이란 회사의 서비스에 접속하여 본 약관에 동의한 자를 말합니다.
|
||||
</p>
|
||||
<br />
|
||||
<p>
|
||||
<strong>제3조 약관의 효력 및 변경</strong>
|
||||
</p>
|
||||
<p>
|
||||
1. 본 약관은 서비스 화면에 공지되며, 회원이 약관에 동의한 시점부터 효력이 발생합니다.
|
||||
<br />
|
||||
2. 회사는 필요에 따라 본 약관을 변경할 수 있으며, 변경된 약관은 서비스 화면에 공지됩니다.
|
||||
</p>
|
||||
<br />
|
||||
<p>
|
||||
<strong>제4조 서비스 이용</strong>
|
||||
</p>
|
||||
<p>
|
||||
1. 회원은 회사가 제공하는 서비스를 본 약관에 따라 이용할 수 있습니다.
|
||||
<br />
|
||||
2. 회사는 서비스의 운영 또는 기술적 필요에 따라 서비스의 전부 또는 일부를 변경할 수 있습니다.
|
||||
</p>
|
||||
<br />
|
||||
<p>
|
||||
<strong>제5조 회원의 의무</strong>
|
||||
</p>
|
||||
<p>
|
||||
1. 회원은 서비스 이용 시 본 약관을 준수해야 하며, 법령을 위반하는 행위를 해서는 안 됩니다.
|
||||
<br />
|
||||
2. 회원은 타인의 개인정보를 침해하거나, 서비스의 안정적 운영을 방해하는 행위를 해서는 안 됩니다.
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
open={modalState === 'terms'}
|
||||
onClose={() => setModalState(null)}
|
||||
/>
|
||||
<Modal
|
||||
title="개인정보 처리방침"
|
||||
content={
|
||||
<>
|
||||
<p>
|
||||
<strong>제1조 수집하는 개인정보의 항목</strong>
|
||||
</p>
|
||||
<p>
|
||||
1. 회사는 서비스 제공을 위해 필요한 최소한의 개인정보를 수집합니다.
|
||||
<br />
|
||||
2. 수집하는 개인정보 항목은 다음과 같습니다: 이름, 이메일, 서비스 이용 기록, 접속 로그, 쿠키 등.
|
||||
</p>
|
||||
<br />
|
||||
<p>
|
||||
<strong>제2조 개인정보의 수집 및 이용 목적</strong>
|
||||
</p>
|
||||
<p>
|
||||
1. 회사는 다음의 목적을 위해 개인정보를 수집 및 이용합니다:
|
||||
<br />
|
||||
- 회원관리, 서비스 제공, 계약 이행 및 요금 정산.
|
||||
<br />
|
||||
2. 서비스 개선 및 맞춤형 서비스 제공을 위해 활용될 수 있습니다.
|
||||
</p>
|
||||
<br />
|
||||
<p>
|
||||
<strong>제3조 개인정보의 보유 및 이용 기간</strong>
|
||||
</p>
|
||||
<p>
|
||||
1. 회원의 개인정보는 회원 탈퇴 시 지체 없이 파기됩니다.
|
||||
<br />
|
||||
2. 단, 관계 법령에 따라 일정 기간 보관해야 하는 경우 해당 기간 동안 보유합니다.
|
||||
</p>
|
||||
<br />
|
||||
<p>
|
||||
<strong>제4조 개인정보의 제3자 제공</strong>
|
||||
</p>
|
||||
<p>
|
||||
1. 회사는 원칙적으로 회원의 동의 없이 개인정보를 외부에 제공하지 않습니다.
|
||||
<br />
|
||||
2. 다만, 법령에 의해 요구되는 경우나 회원의 사전 동의를 받은 경우에 한해 제공됩니다.
|
||||
</p>
|
||||
<br />
|
||||
<p>
|
||||
<strong>제5조 회원의 권리와 행사 방법</strong>
|
||||
</p>
|
||||
<p>
|
||||
1. 회원은 언제든지 자신의 개인정보를 조회하거나 수정할 수 있으며, 개인정보의 삭제를 요청할 수 있습니다.
|
||||
<br />
|
||||
2. 회원은 개인정보의 처리에 관한 동의를 철회할 수 있습니다.
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
open={modalState === 'privacy'}
|
||||
onClose={() => setModalState(null)}
|
||||
/>
|
||||
</footer>
|
||||
);
|
||||
}
|
@ -1,82 +0,0 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import timeAgo from '@/utils/timeAgo';
|
||||
import { AlarmResponse } from '@/types';
|
||||
import { Mail, MailOpen, Trash2 } from 'lucide-react';
|
||||
|
||||
export default function AlarmItem({
|
||||
alarm,
|
||||
onRead,
|
||||
onDelete,
|
||||
}: {
|
||||
alarm: AlarmResponse;
|
||||
onRead: (alarmId: number) => void;
|
||||
onDelete: (alarmId: number) => void;
|
||||
}) {
|
||||
const alarmTypeToMessage = (alarmType: string) => {
|
||||
switch (alarmType) {
|
||||
case 'PREDICT':
|
||||
return '오토 레이블링이 완료되었습니다.';
|
||||
case 'TRAIN':
|
||||
return '학습이 완료되었습니다.';
|
||||
case 'IMAGE':
|
||||
return '이미지 업로드가 완료되었습니다.';
|
||||
case 'COMMENT':
|
||||
return '새로운 댓글이 추가되었습니다.';
|
||||
case 'REVIEW_RESULT':
|
||||
return '요청한 리뷰에 대한 결과가 등록되었습니다.';
|
||||
case 'REVIEW_REQUEST':
|
||||
return '새로운 리뷰 요청을 받았습니다.';
|
||||
default:
|
||||
return '새로운 알림입니다.';
|
||||
}
|
||||
};
|
||||
|
||||
const handleRead = () => {
|
||||
onRead(alarm.id);
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
onDelete(alarm.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center bg-white p-3 duration-150 hover:bg-gray-200">
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="flex items-center">
|
||||
{!alarm.isRead && <div className="mr-1.5 h-1.5 w-1.5 rounded-full bg-orange-500"></div>}
|
||||
<p className={cn('body-small', alarm.isRead ? 'text-gray-400' : 'text-black')}>
|
||||
{alarmTypeToMessage(alarm.type)}
|
||||
</p>
|
||||
</div>
|
||||
<p className={cn('caption', alarm.isRead ? 'text-gray-400' : 'text-gray-500')}>{timeAgo(alarm.createdAt)}</p>
|
||||
</div>
|
||||
{alarm.isRead ? (
|
||||
<button
|
||||
className="p-1"
|
||||
disabled
|
||||
>
|
||||
<MailOpen
|
||||
size={16}
|
||||
className="stroke-gray-400"
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="p-1"
|
||||
onClick={handleRead}
|
||||
>
|
||||
<Mail size={16} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="p-1"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<Trash2
|
||||
size={16}
|
||||
className="stroke-red-500"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,153 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Bell } from 'lucide-react';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
|
||||
import { onMessage } from 'firebase/messaging';
|
||||
import { messaging } from '@/api/firebaseConfig';
|
||||
import AlarmItem from './AlarmItem';
|
||||
import useGetAndSaveFcmTokenQuery from '@/queries/auth/useGetAndSaveFcmTokenQuery';
|
||||
import useResetFcmTokenQuery from '@/queries/auth/useResetFcmTokenQuery';
|
||||
import useGetAlarmListQuery from '@/queries/alarms/useGetAlarmListQuery';
|
||||
import useResetAlarmListQuery from '@/queries/alarms/useResetAlarmListQuery';
|
||||
// import useCreateAlarmTestQuery from '@/queries/alarms/useCreateAlarmTestQuery';
|
||||
import useReadAlarmQuery from '@/queries/alarms/useReadAlarmQuery';
|
||||
import useDeleteAlarmQuery from '@/queries/alarms/useDeleteAlarmQuery';
|
||||
import useDeleteAllAlarmQuery from '@/queries/alarms/useDeleteAllAlarmQuery';
|
||||
|
||||
export default function AlarmPopover() {
|
||||
const [unread, setUnread] = useState<boolean>(false);
|
||||
|
||||
const resetFcmToken = useResetFcmTokenQuery();
|
||||
const resetAlarmList = useResetAlarmListQuery();
|
||||
// const createAlarmTest = useCreateAlarmTestQuery();
|
||||
const readAlarm = useReadAlarmQuery();
|
||||
const deleteAlarm = useDeleteAlarmQuery();
|
||||
const deleteAllAlarm = useDeleteAllAlarmQuery();
|
||||
|
||||
// const handleCreateAlarmTest = () => {
|
||||
// createAlarmTest.mutate();
|
||||
// };
|
||||
|
||||
const handleReadAlarm = (alarmId: number) => {
|
||||
readAlarm.mutate(alarmId);
|
||||
};
|
||||
|
||||
const handleDeleteAlarm = (alarmId: number) => {
|
||||
deleteAlarm.mutate(alarmId);
|
||||
};
|
||||
|
||||
const handleDeleteAllAlarm = () => {
|
||||
deleteAllAlarm.mutate();
|
||||
};
|
||||
|
||||
useGetAndSaveFcmTokenQuery();
|
||||
const { data: alarmList } = useGetAlarmListQuery();
|
||||
|
||||
onMessage(messaging, (payload) => {
|
||||
if (!payload.data) return;
|
||||
|
||||
console.log('new message arrived');
|
||||
resetAlarmList.mutate();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const unreadCnt = alarmList.filter((alarm) => !alarm.isRead).length;
|
||||
|
||||
if (unreadCnt > 0) {
|
||||
setUnread(true);
|
||||
} else {
|
||||
setUnread(false);
|
||||
}
|
||||
}, [alarmList]);
|
||||
|
||||
useEffect(() => {
|
||||
// 현재 창에 포커스 시 실행할 메서드
|
||||
const handleFocus = () => {
|
||||
resetFcmToken.mutate();
|
||||
resetAlarmList.mutate();
|
||||
};
|
||||
|
||||
// 현재 창에 포커스 해제 시 실행할 메서드
|
||||
// const handleBlur = () => {};
|
||||
|
||||
// window에 focus와 blur 이벤트 리스너 등록
|
||||
window.addEventListener('focus', handleFocus);
|
||||
// window.addEventListener('blur', handleBlur);
|
||||
|
||||
// 컴포넌트가 언마운트될 때 이벤트 리스너 제거
|
||||
return () => {
|
||||
window.removeEventListener('focus', handleFocus);
|
||||
// window.removeEventListener('blur', handleBlur);
|
||||
};
|
||||
}, [resetAlarmList, resetFcmToken]);
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button className="flex items-center justify-center p-2 pr-0.5">
|
||||
<Bell className="h-4 w-4 cursor-pointer text-black sm:h-5 sm:w-5" />
|
||||
<div className={cn('mt-[14px] h-1.5 w-1.5 rounded-full', unread ? 'bg-orange-500' : 'bg-transparent')}></div>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent
|
||||
className="w-[360px] overflow-hidden rounded-lg p-0"
|
||||
align="end"
|
||||
sideOffset={14}
|
||||
alignOffset={0}
|
||||
>
|
||||
<div className="flex w-full items-center p-3">
|
||||
<h2 className="body-strong flex-1">알림</h2>
|
||||
{/* <button
|
||||
className="body-small p-1 text-gray-400"
|
||||
onClick={handleCreateAlarmTest}
|
||||
>
|
||||
테스트
|
||||
</button> */}
|
||||
{/* {unread ? (
|
||||
<button
|
||||
className="body-small p-1"
|
||||
onClick={() => {}}
|
||||
>
|
||||
모두 읽음
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="body-small p-1"
|
||||
onClick={() => {}}
|
||||
>
|
||||
모두 읽지 않음
|
||||
</button>
|
||||
)} */}
|
||||
<button
|
||||
className="body-small-strong p-1 text-red-500"
|
||||
onClick={handleDeleteAllAlarm}
|
||||
>
|
||||
모두 삭제
|
||||
</button>
|
||||
</div>
|
||||
<hr />
|
||||
|
||||
{alarmList.length === 0 ? (
|
||||
<div className="flex w-full items-center p-3 duration-150">
|
||||
<p className="body-small text-gray-500">알림이 없습니다.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex max-h-[500px] w-full flex-col items-center overflow-y-auto">
|
||||
{alarmList
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((alarm) => (
|
||||
<AlarmItem
|
||||
key={alarm.id}
|
||||
alarm={alarm}
|
||||
onRead={handleReadAlarm}
|
||||
onDelete={handleDeleteAlarm}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
@ -1,64 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { User } from 'lucide-react';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
|
||||
import useAuthStore from '@/stores/useAuthStore';
|
||||
import useLogoutQuery from '@/queries/auth/useLogoutQuery';
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
export default function ProfilePopover() {
|
||||
const profile = useAuthStore((state) => state.profile);
|
||||
const { nickname, profileImage } = profile || { nickname: '', profileImage: '' };
|
||||
|
||||
const logoutMutation = useLogoutQuery();
|
||||
const [isLoggingOut, setIsLoggingOut] = useState<boolean>(false);
|
||||
|
||||
const handleLogout = async () => {
|
||||
setIsLoggingOut(true);
|
||||
logoutMutation.mutate(undefined, {
|
||||
// onSuccess: () => {
|
||||
// onClose();
|
||||
// },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button className="flex items-center justify-center p-2">
|
||||
<User className="h-4 w-4 cursor-pointer text-black sm:h-5 sm:w-5" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-80 overflow-hidden rounded-lg p-0"
|
||||
align="end"
|
||||
sideOffset={14}
|
||||
alignOffset={0}
|
||||
>
|
||||
<div className="m-4 flex flex-col gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{profileImage ? (
|
||||
<img
|
||||
src={profileImage}
|
||||
alt={`${nickname}'s profile`}
|
||||
className="h-12 w-12 rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-12 w-12 rounded-full bg-gray-300"></div>
|
||||
)}
|
||||
<div className="subheading">{nickname || 'Guest'}</div>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<Button
|
||||
variant="blue"
|
||||
className="w-full"
|
||||
onClick={handleLogout}
|
||||
disabled={isLoggingOut}
|
||||
>
|
||||
{isLoggingOut ? '로그아웃 중...' : '로그아웃'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
@ -1,46 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '../ui/button';
|
||||
import useAuthStore from '@/stores/useAuthStore';
|
||||
import useLogoutQuery from '@/queries/auth/useLogoutQuery';
|
||||
export default function UserProfileForm({ onClose }: { onClose: () => void }) {
|
||||
const profile = useAuthStore((state) => state.profile);
|
||||
const { nickname, profileImage } = profile || { nickname: '', profileImage: '' };
|
||||
|
||||
const logoutMutation = useLogoutQuery();
|
||||
const [isLoggingOut, setIsLoggingOut] = useState<boolean>(false);
|
||||
|
||||
const handleLogout = async () => {
|
||||
setIsLoggingOut(true);
|
||||
logoutMutation.mutate(undefined, {
|
||||
onSuccess: () => {
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
};
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex items-center gap-4">
|
||||
{profileImage ? (
|
||||
<img
|
||||
src={profileImage}
|
||||
alt={`${nickname}'s profile`}
|
||||
className="h-16 w-16 rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-16 w-16 rounded-full bg-gray-300"></div>
|
||||
)}
|
||||
|
||||
<div className="text-lg font-bold">{nickname || 'Guest'}</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleLogout}
|
||||
variant="blue"
|
||||
className="mt-4"
|
||||
disabled={isLoggingOut}
|
||||
>
|
||||
{isLoggingOut ? '로그아웃 중...' : '로그아웃'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,31 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from '../ui/dialogCustom';
|
||||
import { User } from 'lucide-react';
|
||||
import UserProfileForm from './UserProfileForm';
|
||||
|
||||
export default function UserProfileModal() {
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
|
||||
const handleOpen = () => setIsOpen(true);
|
||||
const handleClose = () => setIsOpen(false);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<button
|
||||
className="flex items-center justify-center p-2"
|
||||
onClick={handleOpen}
|
||||
>
|
||||
<User className="h-4 w-4 text-black sm:h-5 sm:w-5" />
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader title="프로필" />
|
||||
<UserProfileForm onClose={handleClose} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
@ -1,78 +0,0 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Link, useLocation, useParams } from 'react-router-dom';
|
||||
import useAuthStore from '@/stores/useAuthStore';
|
||||
import useWorkspaceListQuery from '@/queries/workspaces/useWorkspaceListQuery';
|
||||
|
||||
export default function WorkspaceNavigation() {
|
||||
const location = useLocation();
|
||||
const isBrowsePage = location.pathname.startsWith('/browse');
|
||||
const isWorkspacePage = location.pathname.startsWith('/workspace');
|
||||
const isReviewPage = location.pathname.startsWith('/review');
|
||||
const isModelPage = location.pathname.startsWith('/model');
|
||||
const isMemberPage = location.pathname.startsWith('/member');
|
||||
|
||||
const { workspaceId } = useParams<{ workspaceId: string }>();
|
||||
const profile = useAuthStore((state) => state.profile);
|
||||
const memberId = profile?.id;
|
||||
|
||||
const { data: workspacesResponse } = useWorkspaceListQuery(memberId ?? 0);
|
||||
const workspaces = workspacesResponse?.workspaceResponses || [];
|
||||
|
||||
const activeWorkspaceId = workspaceId ?? workspaces[0]?.id;
|
||||
const activeProjectId: number = location.pathname.includes('/request')
|
||||
? 0
|
||||
: Number(location.pathname.split('/')[3] || '0');
|
||||
|
||||
if (workspaces.length === 0) {
|
||||
return <div></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="hidden items-center gap-5 md:flex">
|
||||
<Link
|
||||
to={activeWorkspaceId ? `/browse/${activeWorkspaceId}` : '/browse'}
|
||||
className={cn('', isBrowsePage ? 'body-strong' : 'body')}
|
||||
>
|
||||
workspace
|
||||
</Link>
|
||||
{activeWorkspaceId && (
|
||||
<>
|
||||
<Link
|
||||
to={
|
||||
activeProjectId === 0
|
||||
? `/workspace/${activeWorkspaceId}`
|
||||
: `/workspace/${activeWorkspaceId}/${activeProjectId}`
|
||||
}
|
||||
className={isWorkspacePage ? 'body-strong' : 'body'}
|
||||
>
|
||||
labeling
|
||||
</Link>
|
||||
<Link
|
||||
to={
|
||||
activeProjectId === 0 ? `/review/${activeWorkspaceId}` : `/review/${activeWorkspaceId}/${activeProjectId}`
|
||||
}
|
||||
className={isReviewPage ? 'body-strong' : 'body'}
|
||||
>
|
||||
review
|
||||
</Link>
|
||||
<Link
|
||||
to={
|
||||
activeProjectId === 0 ? `/model/${activeWorkspaceId}` : `/model/${activeWorkspaceId}/${activeProjectId}`
|
||||
}
|
||||
className={isModelPage ? 'body-strong' : 'body'}
|
||||
>
|
||||
model
|
||||
</Link>
|
||||
<Link
|
||||
to={
|
||||
activeProjectId === 0 ? `/member/${activeWorkspaceId}` : `/member/${activeWorkspaceId}/${activeProjectId}`
|
||||
}
|
||||
className={isMemberPage ? 'body-strong' : 'body'}
|
||||
>
|
||||
member
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
@ -1,13 +0,0 @@
|
||||
import '@/index.css';
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import Header from './index';
|
||||
|
||||
const meta: Meta<typeof Header> = {
|
||||
title: 'Components/Header',
|
||||
component: Header,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Header>;
|
||||
|
||||
export const Default: Story = {};
|
@ -1,44 +0,0 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useLocation, Link } from 'react-router-dom';
|
||||
import WorkspaceNavigation from './WorkspaceNavigation';
|
||||
import useAuthStore from '@/stores/useAuthStore';
|
||||
import { Suspense } from 'react';
|
||||
import AlarmPopover from './AlarmPopover';
|
||||
import ProfilePopover from './ProfilePopover';
|
||||
|
||||
export default function Header() {
|
||||
const location = useLocation();
|
||||
const isHomePage = location.pathname === '/';
|
||||
|
||||
const profile = useAuthStore((state) => state.profile);
|
||||
|
||||
return (
|
||||
<header className="fixed left-0 top-0 z-40 flex h-16 w-full items-center justify-between border-b border-gray-200 bg-white px-4 sm:px-6 md:px-8 lg:px-10">
|
||||
<div className="flex items-center gap-4 md:gap-10">
|
||||
<Link
|
||||
to="/"
|
||||
className={cn('text-[20px] font-normal tracking-[-1.60px] text-black sm:text-[24px] md:text-[32px]')}
|
||||
style={{ fontFamily: "'Offside-Regular', Helvetica" }}
|
||||
>
|
||||
<img
|
||||
src="/worlabel.svg"
|
||||
alt="WorLabel"
|
||||
/>
|
||||
</Link>
|
||||
|
||||
{!isHomePage && profile && (
|
||||
<Suspense fallback={<div></div>}>
|
||||
<WorkspaceNavigation />
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isHomePage && profile && (
|
||||
<div className="flex items-center gap-2">
|
||||
<AlarmPopover />
|
||||
<ProfilePopover />
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
@ -1,124 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Group, Rect, Text, Image } from 'react-konva';
|
||||
import { CommentResponse } from '@/types';
|
||||
import useImage from 'use-image';
|
||||
|
||||
import deleteIconSrc from '@/assets/icons/delete.svg';
|
||||
import toggleUpIconSrc from '@/assets/icons/chevron-up.svg';
|
||||
import toggleDownIconSrc from '@/assets/icons/chevron-down.svg';
|
||||
import Konva from 'konva';
|
||||
import { TRANSFORM_CHANGE_STR } from '@/constants';
|
||||
|
||||
interface CommentLabelProps {
|
||||
stage: Konva.Stage;
|
||||
comment: CommentResponse & { isOpen?: boolean };
|
||||
updateComment: (comment: CommentResponse) => void;
|
||||
deleteComment: (commentId: number) => void;
|
||||
toggleComment: (commentId: number) => void;
|
||||
}
|
||||
|
||||
export default function CommentLabel({
|
||||
stage,
|
||||
comment,
|
||||
updateComment,
|
||||
deleteComment,
|
||||
toggleComment,
|
||||
}: CommentLabelProps) {
|
||||
const groupRef = useRef<Konva.Group>(null);
|
||||
// const stage = groupRef.current?.getStage();
|
||||
const [content, setContent] = useState(comment.content);
|
||||
const [deleteIcon] = useImage(deleteIconSrc);
|
||||
const [toggleUpIcon] = useImage(toggleUpIconSrc);
|
||||
const [toggleDownIcon] = useImage(toggleDownIconSrc);
|
||||
|
||||
const handleEdit = () => {
|
||||
const newContent = prompt('댓글을 입력하세요', content);
|
||||
if (newContent !== null) {
|
||||
setContent(newContent);
|
||||
updateComment({ ...comment, content: newContent });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||
e.cancelBubble = true;
|
||||
confirm('정말 삭제하시겠습니까?') && deleteComment(comment.id);
|
||||
};
|
||||
|
||||
const handleToggle = (e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||
e.cancelBubble = true;
|
||||
toggleComment(comment.id);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const transformEvents = TRANSFORM_CHANGE_STR.join(' ');
|
||||
|
||||
stage?.on(transformEvents, () => {
|
||||
if (!groupRef.current) return;
|
||||
|
||||
groupRef.current?.scale({
|
||||
x: 1 / stage.getAbsoluteScale().x,
|
||||
y: 1 / stage.getAbsoluteScale().y,
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
stage?.off(transformEvents);
|
||||
};
|
||||
}, [stage]);
|
||||
|
||||
return (
|
||||
stage && (
|
||||
<Group
|
||||
ref={groupRef}
|
||||
x={comment.positionX}
|
||||
y={comment.positionY}
|
||||
draggable
|
||||
onDragEnd={(e) => {
|
||||
const newX = e.target.x();
|
||||
const newY = e.target.y();
|
||||
updateComment({ ...comment, positionX: newX, positionY: newY });
|
||||
}}
|
||||
strokeScaleEnabled={false}
|
||||
scale={{ x: 1 / stage.getAbsoluteScale().x, y: 1 / stage.getAbsoluteScale().y }}
|
||||
perfectDrawEnabled={false}
|
||||
shadowForStrokeEnabled={false}
|
||||
>
|
||||
<Rect
|
||||
width={comment.isOpen ? 200 : 60}
|
||||
height={comment.isOpen ? 100 : 30}
|
||||
fill="white"
|
||||
stroke="#080808"
|
||||
strokeWidth={1}
|
||||
cornerRadius={5}
|
||||
/>
|
||||
{comment.isOpen && (
|
||||
<Text
|
||||
x={10}
|
||||
y={35}
|
||||
width={190}
|
||||
text={content || '내용 없음'}
|
||||
fontSize={16}
|
||||
fill="#080808"
|
||||
onClick={handleEdit}
|
||||
/>
|
||||
)}
|
||||
<Image
|
||||
image={comment.isOpen ? toggleUpIcon : toggleDownIcon}
|
||||
x={5}
|
||||
y={5}
|
||||
width={20}
|
||||
height={20}
|
||||
onClick={handleToggle}
|
||||
/>
|
||||
<Image
|
||||
image={deleteIcon}
|
||||
x={35}
|
||||
y={5}
|
||||
width={20}
|
||||
height={20}
|
||||
onClick={handleDelete}
|
||||
/>
|
||||
</Group>
|
||||
)
|
||||
);
|
||||
}
|
@ -1,47 +0,0 @@
|
||||
import { Label } from '@/types';
|
||||
import Konva from 'konva';
|
||||
import { Line } from 'react-konva';
|
||||
import PolygonTransformer from './PolygonTransformer';
|
||||
|
||||
export default function LabelPolygon({
|
||||
isSelected,
|
||||
onSelect,
|
||||
info,
|
||||
setLabel,
|
||||
stage,
|
||||
dragLayer,
|
||||
}: {
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
info: Label;
|
||||
setLabel: (coordinate: [number, number][]) => void;
|
||||
stage: Konva.Stage;
|
||||
dragLayer: Konva.Layer;
|
||||
}) {
|
||||
const handleChange = (coordinates: [number, number][]) => {
|
||||
setLabel(coordinates);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Line
|
||||
points={info.coordinates.flat()}
|
||||
stroke={info.color}
|
||||
strokeWidth={1}
|
||||
onMouseDown={onSelect}
|
||||
onTouchStart={onSelect}
|
||||
strokeScaleEnabled={false}
|
||||
fill={`${info.color}33`}
|
||||
closed
|
||||
/>
|
||||
{isSelected && (
|
||||
<PolygonTransformer
|
||||
coordinates={info.coordinates}
|
||||
setCoordinates={handleChange}
|
||||
stage={stage}
|
||||
dragLayer={dragLayer}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,90 +0,0 @@
|
||||
import { Label } from '@/types';
|
||||
import rectSort from '@/utils/rectSort';
|
||||
import Konva from 'konva';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Line, Transformer } from 'react-konva';
|
||||
|
||||
export default function LabelRect({
|
||||
isSelected,
|
||||
onSelect,
|
||||
info,
|
||||
setLabel,
|
||||
dragLayer,
|
||||
}: {
|
||||
isSelected: boolean;
|
||||
onSelect: (evt: Konva.KonvaEventObject<TouchEvent | MouseEvent>) => void;
|
||||
info: Label;
|
||||
setLabel: (coordinate: [number, number][]) => void;
|
||||
dragLayer: Konva.Layer;
|
||||
}) {
|
||||
const rectRef = useRef<Konva.Line>(null);
|
||||
const trRef = useRef<Konva.Transformer>(null);
|
||||
const coordinates = [
|
||||
info.coordinates[0],
|
||||
[info.coordinates[0][0], info.coordinates[1][1]],
|
||||
info.coordinates[1],
|
||||
[info.coordinates[1][0], info.coordinates[0][1]],
|
||||
].flat();
|
||||
const handleSelect = (evt: Konva.KonvaEventObject<TouchEvent | MouseEvent>): void => {
|
||||
onSelect(evt);
|
||||
rectRef.current?.moveToTop();
|
||||
trRef.current?.moveToTop();
|
||||
};
|
||||
const handleMoveEnd = () => {
|
||||
const rect = rectRef.current?.getPosition();
|
||||
const scale = rectRef.current?.scale();
|
||||
|
||||
if (!rect || !scale) return;
|
||||
|
||||
const points: [number, number][] = [
|
||||
[info.coordinates[0][0] * scale.x + rect.x, info.coordinates[0][1] * scale.y + rect.y],
|
||||
[info.coordinates[1][0] * scale.x + rect.x, info.coordinates[1][1] * scale.y + rect.y],
|
||||
];
|
||||
const sortedPoints = rectSort(points as [[number, number], [number, number]]);
|
||||
|
||||
setLabel(sortedPoints);
|
||||
rectRef.current?.setPosition({ x: 0, y: 0 });
|
||||
rectRef.current?.scale({ x: 1, y: 1 });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isSelected) {
|
||||
trRef.current?.nodes([rectRef.current as Konva.Node]);
|
||||
trRef.current?.moveTo(dragLayer);
|
||||
trRef.current?.getLayer()?.batchDraw();
|
||||
}
|
||||
}, [dragLayer, isSelected]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Line
|
||||
points={coordinates}
|
||||
stroke={info.color}
|
||||
strokeWidth={1}
|
||||
ref={rectRef}
|
||||
onMouseDown={handleSelect}
|
||||
onTouchStart={handleSelect}
|
||||
strokeScaleEnabled={false}
|
||||
fillAfterStrokeEnabled={false}
|
||||
fill={`${info.color}33`}
|
||||
onDragEnd={handleMoveEnd}
|
||||
shadowForStrokeEnabled={false}
|
||||
closed
|
||||
draggable
|
||||
/>
|
||||
{isSelected && (
|
||||
<Transformer
|
||||
keepRatio={false}
|
||||
ref={trRef}
|
||||
rotateEnabled={false}
|
||||
anchorSize={8}
|
||||
rotateAnchorCursor="pointer"
|
||||
rotateAnchorOffset={20}
|
||||
ignoreStroke={true}
|
||||
flipEnabled={false}
|
||||
onTransformEnd={handleMoveEnd}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,119 +0,0 @@
|
||||
import { TRANSFORM_CHANGE_STR } from '@/constants';
|
||||
import Konva from 'konva';
|
||||
import { Vector2d } from 'konva/lib/types';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Circle, Group, Line } from 'react-konva';
|
||||
|
||||
interface PolygonTransformerProps {
|
||||
coordinates: Array<[number, number]>;
|
||||
setCoordinates: (coordinates: Array<[number, number]>) => void;
|
||||
stage: Konva.Stage;
|
||||
dragLayer: Konva.Layer;
|
||||
}
|
||||
|
||||
export default function PolygonTransformer({ coordinates, setCoordinates, stage, dragLayer }: PolygonTransformerProps) {
|
||||
const anchorsRef = useRef<Konva.Group>(null);
|
||||
const scale: Vector2d = { x: 1 / stage.getAbsoluteScale().x, y: 1 / stage.getAbsoluteScale().y };
|
||||
const handleClick = (index: number) => (e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||
if (e.evt.button === 0 && e.evt.detail === 2) {
|
||||
const pos = stage.getRelativePointerPosition()!;
|
||||
const newCoordinates: [number, number][] = [
|
||||
...coordinates.slice(0, index + 1),
|
||||
[pos.x, pos.y],
|
||||
...coordinates.slice(index + 1),
|
||||
];
|
||||
setCoordinates(newCoordinates);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.evt.button !== 2) return;
|
||||
|
||||
const newCoordinates = [...coordinates.slice(0, index), ...coordinates.slice(index + 1)];
|
||||
setCoordinates(newCoordinates);
|
||||
};
|
||||
const handleDragMove = (index: number) => (e: Konva.KonvaEventObject<DragEvent>) => {
|
||||
const circle = e.target as Konva.Circle;
|
||||
const pos = circle.position();
|
||||
const newCoordinates = [...coordinates];
|
||||
|
||||
newCoordinates[index] = [pos.x, pos.y];
|
||||
setCoordinates(newCoordinates);
|
||||
// stage.batchDraw();
|
||||
};
|
||||
const handleMouseOver = (e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||
const circle = e.target as Konva.Circle;
|
||||
|
||||
circle.radius(7);
|
||||
stage.batchDraw();
|
||||
};
|
||||
const handleMouseOut = (e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||
const circle = e.target as Konva.Circle;
|
||||
|
||||
circle.radius(5);
|
||||
stage?.batchDraw();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const transformEvents = TRANSFORM_CHANGE_STR.join(' ');
|
||||
anchorsRef.current?.moveTo(dragLayer);
|
||||
|
||||
stage.on(transformEvents, () => {
|
||||
if (!anchorsRef.current) return;
|
||||
|
||||
const anchors = anchorsRef.current!.children as Konva.Shape[];
|
||||
|
||||
anchors.forEach((anchor) => {
|
||||
anchor.scale({
|
||||
x: 1 / stage.getAbsoluteScale().x,
|
||||
y: 1 / stage.getAbsoluteScale().y,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
stage.off(transformEvents);
|
||||
};
|
||||
}, [dragLayer, stage]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Line
|
||||
points={coordinates.flat()}
|
||||
stroke="#00a1ff"
|
||||
strokeWidth={1}
|
||||
strokeScaleEnabled={false}
|
||||
closed
|
||||
perfectDrawEnabled={false}
|
||||
shadowForStrokeEnabled={false}
|
||||
listening={false}
|
||||
/>
|
||||
<Group
|
||||
ref={anchorsRef}
|
||||
// scale={scale}
|
||||
>
|
||||
{coordinates.map((point, index) => {
|
||||
return (
|
||||
<Circle
|
||||
key={index}
|
||||
x={point[0]}
|
||||
y={point[1]}
|
||||
radius={5}
|
||||
stroke="#00a1ff"
|
||||
strokeWidth={1}
|
||||
fill="white"
|
||||
draggable
|
||||
strokeScaleEnabled={false}
|
||||
onClick={handleClick(index)}
|
||||
onDragMove={handleDragMove(index)}
|
||||
onMouseOver={handleMouseOver}
|
||||
onMouseOut={handleMouseOut}
|
||||
scale={scale}
|
||||
perfectDrawEnabled={false}
|
||||
shadowForStrokeEnabled={false}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,15 +0,0 @@
|
||||
import '@/index.css';
|
||||
import { Meta } from '@storybook/react';
|
||||
import ImageCanvas from '.';
|
||||
|
||||
const meta: Meta<typeof ImageCanvas> = {
|
||||
title: 'Components/ImageCanvas',
|
||||
component: ImageCanvas,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export const Default = () => <ImageCanvas />;
|
@ -1,453 +0,0 @@
|
||||
import useCanvasStore from '@/stores/useCanvasStore';
|
||||
import useCommentStore from '@/stores/useCommentStore';
|
||||
import useCommentListQuery from '@/queries/comments/useCommentListQuery';
|
||||
import useCreateCommentQuery from '@/queries/comments/useCreateCommentQuery';
|
||||
import useUpdateCommentQuery from '@/queries/comments/useUpdateCommentQuery';
|
||||
import useDeleteCommentQuery from '@/queries/comments/useDeleteCommentQuery';
|
||||
import Konva from 'konva';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Circle, Image, Layer, Line, Rect, Stage } from 'react-konva';
|
||||
import useImage from 'use-image';
|
||||
import LabelRect from './LabelRect';
|
||||
import { Vector2d } from 'konva/lib/types';
|
||||
import LabelPolygon from './LabelPolygon';
|
||||
import CanvasControlBar from '../CanvasControlBar';
|
||||
import { Label } from '@/types';
|
||||
import useLabelJson from '@/hooks/useLabelJson';
|
||||
import useProjectStore from '@/stores/useProjectStore';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import useSaveImageLabelsQuery from '@/queries/projects/useSaveImageLabelsQuery';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import CommentLabel from './CommentLabel';
|
||||
import rectSort from '@/utils/rectSort';
|
||||
|
||||
export default function ImageCanvas() {
|
||||
const { project, folderId, categories } = useProjectStore();
|
||||
const { id: imageId, imagePath, dataPath } = useCanvasStore((state) => state.image)!;
|
||||
const { data: labelData } = useLabelJson(dataPath, project!);
|
||||
const { labels, drawState, setDrawState, addLabel, setLabels, selectedLabelId, setSelectedLabelId, sidebarSize } =
|
||||
useCanvasStore();
|
||||
const { shapes } = labelData || [];
|
||||
const stageWidth = window.innerWidth * ((100 - sidebarSize) / 100) - 201;
|
||||
const stageHeight = window.innerHeight - 64;
|
||||
const stageRef = useRef<Konva.Stage>(null);
|
||||
const dragLayerRef = useRef<Konva.Layer>(null);
|
||||
const scale = useRef<number>(0);
|
||||
const [image] = useImage(imagePath);
|
||||
const [rectPoints, setRectPoints] = useState<[number, number][]>([]);
|
||||
const [polygonPoints, setPolygonPoints] = useState<[number, number][]>([]);
|
||||
const saveImageLabelsMutation = useSaveImageLabelsQuery();
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { comments, setComments } = useCommentStore();
|
||||
const { data: commentList } = useCommentListQuery(project!.id, imageId);
|
||||
const createCommentMutation = useCreateCommentQuery(project!.id, imageId);
|
||||
const updateCommentMutation = useUpdateCommentQuery(project!.id);
|
||||
const deleteCommentMutation = useDeleteCommentQuery(project!.id);
|
||||
|
||||
useEffect(() => {
|
||||
setLabels(
|
||||
shapes.map<Label>(({ group_id, color, points, shape_type }, index) => ({
|
||||
id: index,
|
||||
categoryId: group_id,
|
||||
color,
|
||||
type: shape_type,
|
||||
coordinates: points,
|
||||
}))
|
||||
);
|
||||
}, [setLabels, shapes]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedLabelId(null);
|
||||
}, [image, setSelectedLabelId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (commentList) {
|
||||
setComments(
|
||||
commentList.map((comment) => ({
|
||||
...comment,
|
||||
isOpen: false,
|
||||
}))
|
||||
);
|
||||
}
|
||||
}, [commentList, setComments]);
|
||||
|
||||
const setLabel = (index: number) => (coordinates: [number, number][]) => {
|
||||
const newLabels = [...labels];
|
||||
newLabels[index].coordinates = coordinates;
|
||||
setLabels(newLabels);
|
||||
};
|
||||
|
||||
const saveJson = () => {
|
||||
const json = JSON.stringify({
|
||||
...labelData,
|
||||
shapes: labels.map(({ categoryId, color, coordinates, type }) => ({
|
||||
label: categories.find((category) => category.id === categoryId)!.labelName,
|
||||
color,
|
||||
points: coordinates,
|
||||
group_id: categoryId,
|
||||
shape_type: type === 'polygon' ? 'polygon' : 'rectangle',
|
||||
flags: {},
|
||||
})),
|
||||
imageWidth: image!.width,
|
||||
imageHeight: image!.height,
|
||||
});
|
||||
|
||||
saveImageLabelsMutation.mutate(
|
||||
{ projectId: project!.id, imageId: imageId, data: { data: json } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['folder', project!.id.toString(), folderId] });
|
||||
toast({
|
||||
title: '저장 성공',
|
||||
duration: 1500,
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: '저장 실패',
|
||||
duration: 1500,
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const startDrawRect = () => {
|
||||
const { x, y } = stageRef.current!.getRelativePointerPosition()!;
|
||||
setRectPoints([
|
||||
[x, y],
|
||||
[x, y],
|
||||
]);
|
||||
};
|
||||
|
||||
const addPointToPolygon = () => {
|
||||
const { x, y } = stageRef.current!.getRelativePointerPosition()!;
|
||||
if (polygonPoints.length === 0) {
|
||||
setPolygonPoints([
|
||||
[x, y],
|
||||
[x, y],
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
const diff = Math.max(Math.abs(x - polygonPoints[0][0]), Math.abs(y - polygonPoints[0][1]));
|
||||
|
||||
if (diff === 0) return;
|
||||
|
||||
const scale = stageRef.current!.getAbsoluteScale().x;
|
||||
const clickedFirstPoint = polygonPoints.length > 1 && diff * scale < 5;
|
||||
|
||||
if (clickedFirstPoint) {
|
||||
endDrawPolygon();
|
||||
return;
|
||||
}
|
||||
setPolygonPoints([...polygonPoints, [x, y]]);
|
||||
};
|
||||
|
||||
const removeLastPointOfPolygon = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
if (polygonPoints.length === 0) return;
|
||||
setPolygonPoints(polygonPoints.slice(0, -1));
|
||||
};
|
||||
|
||||
const moveLastPointOfPolygon = () => {
|
||||
if (polygonPoints.length < 2) return;
|
||||
const { x, y } = stageRef.current!.getRelativePointerPosition()!;
|
||||
setPolygonPoints([...polygonPoints.slice(0, -1), [x, y]]);
|
||||
};
|
||||
|
||||
const endDrawPolygon = () => {
|
||||
if (drawState !== 'pen' || polygonPoints.length === 0) return;
|
||||
setDrawState('pointer');
|
||||
setPolygonPoints([]);
|
||||
|
||||
if (polygonPoints.length < 4) return;
|
||||
|
||||
const color = Math.floor(Math.random() * 0xffffff)
|
||||
.toString(16)
|
||||
.padStart(6, '0');
|
||||
const id = labels.length;
|
||||
addLabel({
|
||||
id: id,
|
||||
categoryId: categories[0]!.id,
|
||||
type: 'polygon',
|
||||
color: `#${color}`,
|
||||
coordinates: polygonPoints.slice(0, -1),
|
||||
});
|
||||
setDrawState('pointer');
|
||||
setSelectedLabelId(id);
|
||||
};
|
||||
|
||||
const updateDrawingRect = () => {
|
||||
if (rectPoints.length === 0) return;
|
||||
|
||||
const { x, y } = stageRef.current!.getRelativePointerPosition()!;
|
||||
setRectPoints([rectPoints[0], [x, y]]);
|
||||
};
|
||||
|
||||
const endDrawRect = () => {
|
||||
if (drawState !== 'rect' || rectPoints.length === 0) return;
|
||||
setRectPoints([]);
|
||||
if (rectPoints[0][0] === rectPoints[1][0] && rectPoints[0][1] === rectPoints[1][1]) {
|
||||
return;
|
||||
}
|
||||
const sortedPoints = rectSort(rectPoints as [[number, number], [number, number]]);
|
||||
|
||||
const color = Math.floor(Math.random() * 0xffffff)
|
||||
.toString(16)
|
||||
.padStart(6, '0');
|
||||
const id = labels.length;
|
||||
addLabel({
|
||||
id: id,
|
||||
categoryId: categories[0]!.id,
|
||||
type: 'rectangle',
|
||||
color: `#${color}`,
|
||||
coordinates: sortedPoints,
|
||||
});
|
||||
setDrawState('pointer');
|
||||
setSelectedLabelId(id);
|
||||
};
|
||||
const addComment = () => {
|
||||
const { x, y } = stageRef.current!.getRelativePointerPosition()!;
|
||||
|
||||
createCommentMutation.mutate({
|
||||
content: '',
|
||||
positionX: x,
|
||||
positionY: y,
|
||||
});
|
||||
setDrawState('pointer');
|
||||
};
|
||||
|
||||
const handleClick = (e: Konva.KonvaEventObject<MouseEvent | TouchEvent>) => {
|
||||
e.evt.preventDefault();
|
||||
e.evt.stopPropagation();
|
||||
const isLeftClicked = e.evt.type === 'mousedown' && (e.evt as MouseEvent).button === 0;
|
||||
const isRightClicked = e.evt.type === 'mousedown' && (e.evt as MouseEvent).button === 2;
|
||||
|
||||
if (drawState === 'comment' && isLeftClicked) {
|
||||
addComment();
|
||||
}
|
||||
|
||||
if (drawState !== 'pointer' && (isLeftClicked || isRightClicked)) {
|
||||
stageRef.current?.stopDrag();
|
||||
if (drawState === 'rect') {
|
||||
startDrawRect();
|
||||
}
|
||||
if (drawState === 'pen') {
|
||||
isRightClicked ? removeLastPointOfPolygon(e.evt as MouseEvent) : addPointToPolygon();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (selectedLabelId === null) return;
|
||||
if (e.target === e.target.getStage() || e.target.getClassName() === 'Image') {
|
||||
setSelectedLabelId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseMove = () => {
|
||||
if (drawState === 'rect' && rectPoints.length) {
|
||||
updateDrawingRect();
|
||||
}
|
||||
if (drawState === 'pen' && polygonPoints.length) {
|
||||
moveLastPointOfPolygon();
|
||||
}
|
||||
};
|
||||
|
||||
const handleZoom = (e: Konva.KonvaEventObject<WheelEvent>) => {
|
||||
const scaleBy = 1.05;
|
||||
const oldScale = scale.current;
|
||||
const mousePointTo = {
|
||||
x: (stageRef.current?.getPointerPosition()?.x ?? 0) / oldScale - stageRef.current!.x() / oldScale,
|
||||
y: (stageRef.current?.getPointerPosition()?.y ?? 0) / oldScale - stageRef.current!.y() / oldScale,
|
||||
};
|
||||
const newScale = e.evt.deltaY < 0 ? oldScale * scaleBy : oldScale / scaleBy;
|
||||
scale.current = newScale;
|
||||
|
||||
stageRef.current?.scale({ x: newScale, y: newScale });
|
||||
const newPos = {
|
||||
x: -(mousePointTo.x - (stageRef.current?.getPointerPosition()?.x ?? 0) / newScale) * newScale,
|
||||
y: -(mousePointTo.y - (stageRef.current?.getPointerPosition()?.y ?? 0) / newScale) * newScale,
|
||||
};
|
||||
stageRef.current?.position(newPos);
|
||||
stageRef.current?.batchDraw();
|
||||
};
|
||||
|
||||
const handleScroll = (e: Konva.KonvaEventObject<WheelEvent>) => {
|
||||
const delta = -e.evt.deltaY;
|
||||
const x = stageRef.current?.x();
|
||||
const y = stageRef.current?.y();
|
||||
const newX = e.evt.shiftKey ? x! + delta : x!;
|
||||
const newY = e.evt.shiftKey ? y! : y! + delta;
|
||||
stageRef.current?.position({ x: newX, y: newY });
|
||||
stageRef.current?.batchDraw();
|
||||
};
|
||||
|
||||
const handleWheel = (e: Konva.KonvaEventObject<WheelEvent>) => {
|
||||
if (stageRef.current === null) return;
|
||||
e.evt.preventDefault();
|
||||
|
||||
e.evt.ctrlKey ? handleZoom(e) : handleScroll(e);
|
||||
};
|
||||
|
||||
const getScale = (): Vector2d => {
|
||||
if (scale.current) return { x: scale.current, y: scale.current };
|
||||
const widthRatio = stageWidth / image!.width;
|
||||
const heightRatio = stageHeight / image!.height;
|
||||
scale.current = Math.min(widthRatio, heightRatio);
|
||||
|
||||
return { x: scale.current, y: scale.current };
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!image) {
|
||||
scale.current = 0;
|
||||
return;
|
||||
}
|
||||
const widthRatio = stageWidth / image!.width;
|
||||
const heightRatio = stageHeight / image!.height;
|
||||
|
||||
scale.current = Math.min(widthRatio, heightRatio);
|
||||
}, [image, stageHeight, stageWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!stageRef.current) return;
|
||||
stageRef.current.container().style.cursor = drawState === 'pointer' ? 'default' : 'crosshair';
|
||||
|
||||
if (drawState !== 'pointer') {
|
||||
setSelectedLabelId(null);
|
||||
}
|
||||
}, [drawState, setSelectedLabelId]);
|
||||
|
||||
return image ? (
|
||||
<div>
|
||||
<Stage
|
||||
ref={stageRef}
|
||||
width={stageWidth}
|
||||
height={stageHeight}
|
||||
className="overflow-hidden bg-gray-200"
|
||||
draggable={drawState !== 'comment'}
|
||||
onWheel={handleWheel}
|
||||
onMouseDown={handleClick}
|
||||
onTouchStart={handleClick}
|
||||
onMouseMove={handleMouseMove}
|
||||
onTouchMove={handleMouseMove}
|
||||
onMouseUp={endDrawRect}
|
||||
onTouchEnd={endDrawRect}
|
||||
scale={getScale()}
|
||||
onContextMenu={(e) => e.evt.preventDefault()}
|
||||
>
|
||||
<Layer>
|
||||
<Image image={image} />
|
||||
</Layer>
|
||||
|
||||
{project?.type !== 'classification' && (
|
||||
<Layer listening={drawState === 'pointer'}>
|
||||
{labels.map((label) =>
|
||||
label.type === 'rectangle' ? (
|
||||
<LabelRect
|
||||
key={label.id}
|
||||
isSelected={label.id === selectedLabelId}
|
||||
onSelect={() => setSelectedLabelId(label.id)}
|
||||
info={label}
|
||||
setLabel={setLabel(label.id)}
|
||||
dragLayer={dragLayerRef.current as Konva.Layer}
|
||||
/>
|
||||
) : (
|
||||
<LabelPolygon
|
||||
key={label.id}
|
||||
isSelected={label.id === selectedLabelId}
|
||||
onSelect={() => setSelectedLabelId(label.id)}
|
||||
info={label}
|
||||
setLabel={setLabel(label.id)}
|
||||
stage={stageRef.current as Konva.Stage}
|
||||
dragLayer={dragLayerRef.current as Konva.Layer}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{rectPoints.length ? (
|
||||
<Rect
|
||||
x={rectPoints[0][0]}
|
||||
y={rectPoints[0][1]}
|
||||
width={rectPoints[1][0] - rectPoints[0][0]}
|
||||
height={rectPoints[1][1] - rectPoints[0][1]}
|
||||
stroke={'#00a1ff'}
|
||||
strokeWidth={1}
|
||||
strokeScaleEnabled={false}
|
||||
fillAfterStrokeEnabled={false}
|
||||
fill="#00a1ff33"
|
||||
shadowForStrokeEnabled={false}
|
||||
listening={false}
|
||||
/>
|
||||
) : null}
|
||||
{polygonPoints.length ? (
|
||||
<>
|
||||
<Line
|
||||
points={polygonPoints.flat()}
|
||||
stroke={'#00a1ff'}
|
||||
strokeWidth={1}
|
||||
strokeScaleEnabled={false}
|
||||
listening={false}
|
||||
/>
|
||||
{polygonPoints.map((point, index) => (
|
||||
<Circle
|
||||
key={index}
|
||||
x={point[0]}
|
||||
y={point[1]}
|
||||
radius={5}
|
||||
stroke="#00a1ff"
|
||||
strokeWidth={1}
|
||||
fill="white"
|
||||
strokeScaleEnabled={false}
|
||||
listening={false}
|
||||
scale={{
|
||||
x: 1 / stageRef.current!.getAbsoluteScale().x,
|
||||
y: 1 / stageRef.current!.getAbsoluteScale().y,
|
||||
}}
|
||||
perfectDrawEnabled={false}
|
||||
shadowForStrokeEnabled={false}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
</Layer>
|
||||
)}
|
||||
|
||||
<Layer>
|
||||
{comments.map((comment) => (
|
||||
<CommentLabel
|
||||
key={comment.id}
|
||||
stage={stageRef.current as Konva.Stage}
|
||||
comment={comment}
|
||||
updateComment={(updatedComment) => {
|
||||
updateCommentMutation.mutate({
|
||||
commentId: comment.id,
|
||||
commentData: {
|
||||
content: updatedComment.content,
|
||||
positionX: updatedComment.positionX,
|
||||
positionY: updatedComment.positionY,
|
||||
},
|
||||
});
|
||||
}}
|
||||
deleteComment={(commentId) => {
|
||||
deleteCommentMutation.mutate(commentId);
|
||||
}}
|
||||
toggleComment={(commentId) => {
|
||||
useCommentStore.getState().toggleComment(commentId);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Layer>
|
||||
<Layer ref={dragLayerRef} />
|
||||
</Stage>
|
||||
<CanvasControlBar
|
||||
saveJson={saveJson}
|
||||
projectType={project!.type}
|
||||
categories={categories}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div></div>
|
||||
);
|
||||
}
|