Compare commits

..

No commits in common. "master" and "be/develop" have entirely different histories.

346 changed files with 0 additions and 34081 deletions

42
ai/.gitignore vendored
View File

@ -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

View File

@ -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
- 훈련 데이터셋 저장

View File

View File

View File

@ -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))

View File

@ -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가 사용 가능하지 않습니다.")

View File

@ -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= "모델을 찾을 수 없습니다.")

View File

@ -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}")

View File

@ -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)

View File

@ -1,6 +0,0 @@
from pydantic import BaseModel
from typing import Literal
class ModelCreateRequest(BaseModel):
project_type: Literal["segmentation", "detection", "classification"]
pretrained:bool = True

View File

@ -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)

View File

@ -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

View File

@ -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}"

View File

@ -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 # 남은 시간(초)

View File

@ -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'

View File

@ -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

View File

@ -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"

View File

@ -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.")

View File

@ -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))

View File

@ -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)

View File

@ -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}"

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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
View File

@ -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

View File

@ -1,5 +0,0 @@
**/.git
**/.svn
**/.hg
**/.dist
**/node_modules

View File

@ -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"]
}

View File

@ -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;

View File

@ -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;

View File

@ -1 +0,0 @@
# WorLabel

View File

@ -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"
}
}

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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"
]
}
}

View File

@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 955 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@ -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));
}
});

View File

@ -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
}

View File

@ -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();

View File

@ -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"}

View File

@ -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

View File

@ -1,4 +0,0 @@
declare module 'react-slick' {
const Slider: T;
export default Slider;
}

View File

@ -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;

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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;

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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 };

View File

@ -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);
}

View File

@ -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}`);
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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

View File

@ -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

View File

@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 622 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

View File

@ -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

View File

@ -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>
);
}

View File

@ -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" />
</>
);
}

View File

@ -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',
},
]}
/>
);

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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 = {};

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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 = {};

View File

@ -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>
);
}

View File

@ -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>
)
);
}

View File

@ -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}
/>
)}
</>
);
}

View File

@ -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}
/>
)}
</>
);
}

View File

@ -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>
</>
);
}

View File

@ -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 />;

View File

@ -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>
);
}

Some files were not shown because too many files have changed in this diff Show More