jebin2 commited on
Commit
05be5a5
Β·
1 Parent(s): f9b5ba3

add yolo trainer and inference

Browse files
.gitignore CHANGED
@@ -208,5 +208,13 @@ __marimo__/
208
  temp_dir
209
  input.jpg
210
  comic_panel_extractor/api_outputs/
 
 
 
 
 
211
  temp.py
212
- test*.jpg
 
 
 
 
208
  temp_dir
209
  input.jpg
210
  comic_panel_extractor/api_outputs/
211
+ comic_panel_extractor/dataset/
212
+ comic_panel_extractor/images/
213
+ comic_panel_extractor/image_labels/
214
+ comic_panel_extractor/runs/
215
+ comic_panel_extractor/temp_dir/
216
  temp.py
217
+ test*.jpg
218
+ yolo_output/
219
+ *:Zone.Identifier
220
+ comic_panel_extractor/best.pt
comic_panel_extractor/annorator_server.py ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, HTTPException, UploadFile, File
2
+ from fastapi.responses import FileResponse
3
+ from pydantic import BaseModel, field_validator
4
+ from typing import List
5
+ from PIL import Image
6
+ import os
7
+ import base64
8
+ from io import BytesIO
9
+ import shutil
10
+
11
+ current_path = os.path.abspath(os.path.join(os.path.dirname(__file__)))
12
+
13
+ app = APIRouter()
14
+
15
+ # === Configuration ===
16
+ IMAGE_ROOT = os.path.join(current_path, "dataset/images")
17
+ LABEL_ROOT = os.path.join(current_path, "dataset/labels")
18
+ IMAGE_LABEL_ROOT = os.path.join(current_path, "image_labels")
19
+
20
+ CLASS_ID = 0
21
+
22
+ # === Pydantic Models ===
23
+ class Box(BaseModel):
24
+ left: int
25
+ top: int
26
+ width: int
27
+ height: int
28
+ type: str = "rect"
29
+ stroke: str = "#00ff00"
30
+ strokeWidth: int = 3
31
+ fill: str = "rgba(0, 255, 0, 0.2)"
32
+ saved: bool = True
33
+
34
+ @field_validator("left", "top", "width", "height", mode="before")
35
+ def round_floats(cls, v):
36
+ return round(v)
37
+
38
+ class SaveAnnotationsRequest(BaseModel):
39
+ boxes: List[Box]
40
+ image_name: str # Relative path like train/image1.jpg
41
+ original_width: int
42
+ original_height: int
43
+
44
+ class ImageInfo(BaseModel):
45
+ name: str # Relative path like train/image1.jpg
46
+ width: int
47
+ height: int
48
+ has_annotations: bool
49
+
50
+ # === Helpers ===
51
+ def get_image_path(image_name: str) -> str:
52
+ return os.path.join(IMAGE_ROOT, image_name)
53
+
54
+ def get_label_path(image_name: str) -> str:
55
+ return os.path.join(LABEL_ROOT, os.path.splitext(image_name)[0] + ".txt")
56
+
57
+ # === Core Functions ===
58
+ def load_yolo_boxes(image_path: str, label_path: str):
59
+ try:
60
+ img = Image.open(image_path)
61
+ w, h = img.size
62
+ boxes = []
63
+ if os.path.exists(label_path):
64
+ with open(label_path, "r") as f:
65
+ for line in f:
66
+ parts = list(map(float, line.strip().split()))
67
+ if len(parts) != 5:
68
+ continue
69
+ _, xc, yc, bw, bh = parts
70
+ left = int((xc - bw / 2) * w)
71
+ top = int((yc - bh / 2) * h)
72
+ width = int(bw * w)
73
+ height = int(bh * h)
74
+ boxes.append({
75
+ "type": "rect",
76
+ "left": left,
77
+ "top": top,
78
+ "width": width,
79
+ "height": height,
80
+ "stroke": "#00ff00",
81
+ "strokeWidth": 3,
82
+ "fill": "rgba(0, 255, 0, 0.2)",
83
+ "saved": True
84
+ })
85
+ return boxes, (w, h)
86
+ except Exception as e:
87
+ raise HTTPException(status_code=500, detail=f"Error loading data: {str(e)}")
88
+
89
+ def save_yolo_annotations(boxes: List[Box], original_size: tuple, label_path: str):
90
+ os.makedirs(os.path.dirname(label_path), exist_ok=True)
91
+ w, h = original_size
92
+ try:
93
+ with open(label_path, "w") as f:
94
+ for box in boxes:
95
+ left, top, width, height = box.left, box.top, box.width, box.height
96
+ xc = (left + width / 2) / w
97
+ yc = (top + height / 2) / h
98
+ bw = width / w
99
+ bh = height / h
100
+ f.write(f"{CLASS_ID} {xc:.6f} {yc:.6f} {bw:.6f} {bh:.6f}\n")
101
+
102
+ shutil.copy2(label_path, f"{IMAGE_LABEL_ROOT}/{os.path.basename(label_path)}")
103
+ return True
104
+ except Exception as e:
105
+ raise HTTPException(status_code=500, detail=f"Error saving annotations: {str(e)}")
106
+
107
+ # === API Routes ===
108
+
109
+ @app.get("/api/annotate/images", response_model=List[ImageInfo])
110
+ async def list_all_images():
111
+ image_info_list = []
112
+ for root, _, files in os.walk(IMAGE_ROOT):
113
+ for file in files:
114
+ if file.lower().endswith((".jpg", ".jpeg", ".png")):
115
+ image_path = os.path.join(root, file)
116
+ rel_path = os.path.relpath(image_path, IMAGE_ROOT)
117
+ label_path = get_label_path(rel_path)
118
+
119
+ img = Image.open(image_path)
120
+ width, height = img.size
121
+
122
+ image_info_list.append(ImageInfo(
123
+ name=rel_path.replace("\\", "/"),
124
+ width=width,
125
+ height=height,
126
+ has_annotations=os.path.exists(label_path)
127
+ ))
128
+ return image_info_list
129
+
130
+ @app.get("/api/annotate/image/{image_name:path}")
131
+ async def get_image(image_name: str):
132
+ image_path = get_image_path(image_name)
133
+ if not os.path.exists(image_path):
134
+ raise HTTPException(status_code=404, detail="Image not found")
135
+
136
+ with Image.open(image_path) as img:
137
+ if img.mode != "RGB":
138
+ img = img.convert("RGB")
139
+ buffer = BytesIO()
140
+ img.save(buffer, format="JPEG")
141
+ img_data = base64.b64encode(buffer.getvalue()).decode()
142
+ return {
143
+ "image_data": f"data:image/jpeg;base64,{img_data}",
144
+ "width": img.width,
145
+ "height": img.height
146
+ }
147
+
148
+ @app.get("/api/annotate/annotations/{image_name:path}")
149
+ async def get_annotations(image_name: str):
150
+ image_path = get_image_path(image_name)
151
+ label_path = get_label_path(image_name)
152
+
153
+ if not os.path.exists(image_path):
154
+ raise HTTPException(status_code=404, detail="Image not found")
155
+
156
+ boxes, (width, height) = load_yolo_boxes(image_path, label_path)
157
+ return {
158
+ "boxes": boxes,
159
+ "original_width": width,
160
+ "original_height": height
161
+ }
162
+
163
+ @app.post("/api/annotate/annotations")
164
+ async def save_annotations(request: SaveAnnotationsRequest):
165
+ label_path = get_label_path(request.image_name)
166
+ success = save_yolo_annotations(
167
+ request.boxes,
168
+ (request.original_width, request.original_height),
169
+ label_path
170
+ )
171
+ return {"message": f"Saved {len(request.boxes)} annotations successfully"}
172
+
173
+ @app.delete("/api/annotate/annotations/{image_name:path}")
174
+ async def delete_annotations(image_name: str):
175
+ label_path = get_label_path(image_name)
176
+ if os.path.exists(label_path):
177
+ os.remove(label_path)
178
+ return {"message": "Annotations deleted"}
179
+ return {"message": "No annotations to delete"}
180
+
181
+ @app.get("/api/annotate/annotations/{image_name:path}/download")
182
+ async def download_annotations(image_name: str):
183
+ label_path = get_label_path(image_name)
184
+ if not os.path.exists(label_path):
185
+ raise HTTPException(status_code=404, detail="Annotations not found")
186
+ return FileResponse(
187
+ label_path,
188
+ media_type="text/plain",
189
+ filename=os.path.basename(label_path)
190
+ )
191
+
192
+ @app.post("/api/annotate/upload")
193
+ async def upload_image(file: UploadFile = File(...)):
194
+ if not file.content_type.startswith("image/"):
195
+ raise HTTPException(status_code=400, detail="File must be an image")
196
+
197
+ file_path = os.path.join(IMAGE_ROOT, "train", file.filename)
198
+ with open(file_path, "wb") as f:
199
+ f.write(await file.read())
200
+ return {"message": f"Uploaded {file.filename} to train set"}
comic_panel_extractor/comic.yaml ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ path: /home/jebineinstein/git/comic-panel-extractor/comic_panel_extractor/dataset
2
+ train: /home/jebineinstein/git/comic-panel-extractor/comic_panel_extractor/dataset/images/train
3
+ val: /home/jebineinstein/git/comic-panel-extractor/comic_panel_extractor/dataset/images/val
4
+ nc: 1
5
+ names: ['panel']
comic_panel_extractor/create_dataset.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import shutil
3
+ import random
4
+ from pathlib import Path
5
+ from dotenv import load_dotenv
6
+ from tqdm import tqdm # <-- import tqdm
7
+
8
+ load_dotenv()
9
+ SOURCE_PATHS = os.getenv('SOURCE_PATH')
10
+
11
+ if not SOURCE_PATHS:
12
+ raise ValueError("SOURCE_PATH not set")
13
+
14
+ # Split by comma and strip whitespace
15
+ source_paths = [Path(p.strip()) for p in SOURCE_PATHS.split(',')]
16
+
17
+ images_dir = Path('images')
18
+ dataset_dir = Path('dataset')
19
+
20
+ image_exts = {'.jpg', '.jpeg', '.png', '.bmp', '.gif', '.tiff', '.webp'}
21
+ label_exts = {'.txt'}
22
+
23
+ # Clear images_dir
24
+ if images_dir.exists():
25
+ shutil.rmtree(images_dir)
26
+ images_dir.mkdir(parents=True)
27
+
28
+ # Copy images from all source paths with tqdm progress
29
+ for source_path in source_paths:
30
+ if not source_path.exists():
31
+ print(f"Warning: source path {source_path} does not exist, skipping.")
32
+ continue
33
+
34
+ # Count total image files first for progress bar
35
+ total_files = 0
36
+ for root, dirs, files in os.walk(source_path):
37
+ total_files += sum(1 for f in files if Path(f).suffix.lower() in image_exts)
38
+
39
+ with tqdm(total=total_files, desc=f"Copying images from {source_path}", unit="img") as pbar:
40
+ for root, dirs, files in os.walk(source_path):
41
+ root_path = Path(root)
42
+ if root_path == source_path:
43
+ prefix = 'root'
44
+ else:
45
+ rel_path = root_path.relative_to(source_path)
46
+ prefix = '_'.join(rel_path.parts)
47
+ for file in files:
48
+ if Path(file).suffix.lower() in image_exts:
49
+ src_file = root_path / file
50
+ dst_file = images_dir / f"{prefix}_{file}"
51
+ shutil.copy2(src_file, dst_file)
52
+ pbar.update(1)
53
+
54
+ # Delete old dataset if exists
55
+ if dataset_dir.exists():
56
+ shutil.rmtree(dataset_dir)
57
+
58
+ # Create dataset folders for images and labels splits
59
+ for split in ['train', 'val', 'test']:
60
+ (dataset_dir / 'images' / split).mkdir(parents=True, exist_ok=True)
61
+ (dataset_dir / 'labels' / split).mkdir(parents=True, exist_ok=True)
62
+
63
+ # List all images in images_dir
64
+ all_images = [f for f in images_dir.iterdir() if f.suffix.lower() in image_exts]
65
+
66
+ # Shuffle and split (80% train, 10% val, 10% test)
67
+ random.seed(42)
68
+ random.shuffle(all_images)
69
+ n = len(all_images)
70
+ train_end = int(0.8 * n)
71
+ val_end = train_end + int(0.1 * n)
72
+
73
+ splits = {
74
+ 'train': all_images[:train_end],
75
+ 'val': all_images[train_end:val_end],
76
+ 'test': all_images[val_end:]
77
+ }
78
+
79
+ label_src_dir = Path('image_labels')
80
+
81
+ # Move/copy images and labels to their split folders with tqdm
82
+ for split, files in splits.items():
83
+ print(f"Processing split '{split}' with {len(files)} images...")
84
+ for img_path in tqdm(files, desc=f"Copying {split}", unit="img"):
85
+ # Copy image
86
+ dst_img_path = dataset_dir / 'images' / split / img_path.name
87
+ shutil.copy2(img_path, dst_img_path)
88
+
89
+ # Copy label if exists
90
+ stem = img_path.stem
91
+ for ext in label_exts:
92
+ label_file = label_src_dir / f"{stem}{ext}"
93
+ if label_file.exists():
94
+ dst_label_path = dataset_dir / 'labels' / split / label_file.name
95
+ shutil.copy2(label_file, dst_label_path)
96
+ break
97
+
98
+ print("Done!")
comic_panel_extractor/extractor_server.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, File, UploadFile, HTTPException
2
+ from fastapi.responses import FileResponse
3
+ import os
4
+ from .config import Config
5
+ from .main import ComicPanelExtractor
6
+ import traceback
7
+ from pathlib import Path
8
+ import shutil
9
+ import time
10
+ import mimetypes
11
+
12
+ current_path = os.path.abspath(os.path.join(os.path.dirname(__file__)))
13
+
14
+ base_output_folder = "api_outputs"
15
+ output_folder = os.path.join(current_path, base_output_folder)
16
+
17
+ app = APIRouter()
18
+
19
+ def delete_folder_if_old_or_empty(parent_folder, age_days=1):
20
+ """
21
+ Delete subfolders inside `parent_folder` if they are empty
22
+ or older than `age_days`.
23
+
24
+ Args:
25
+ parent_folder (str): Path to the parent directory.
26
+ age_days (int): Number of days before a folder is considered old.
27
+ """
28
+ try:
29
+ current_time = time.time()
30
+ age_seconds = age_days * 24 * 60 * 60
31
+
32
+ # Loop through all items in the parent folder
33
+ for entry in os.scandir(parent_folder):
34
+ if entry.is_dir():
35
+ folder_path = entry.path
36
+ # Check if folder is empty
37
+ if not os.listdir(folder_path):
38
+ shutil.rmtree(folder_path)
39
+ print(f"Deleted empty folder: {folder_path}")
40
+ continue
41
+
42
+ # Check if folder is older than age_days
43
+ folder_mtime = os.path.getmtime(folder_path)
44
+ if current_time - folder_mtime > age_seconds:
45
+ shutil.rmtree(folder_path)
46
+ print(f"Deleted old folder (>{age_days} day): {folder_path}")
47
+
48
+ except Exception as e:
49
+ print(f"Error cleaning subfolders in {parent_folder}: {e}")
50
+
51
+ @app.post("/api/extract/convert")
52
+ async def convert_comic(file: UploadFile = File(...)):
53
+ """
54
+ Upload a comic page and extract panels
55
+ """
56
+ # Generate unique filename
57
+ file_id = os.path.splitext(file.filename)[0]
58
+ specific_output_folder = f'{output_folder}/{file_id}'
59
+
60
+ shutil.rmtree(specific_output_folder, ignore_errors=True)
61
+ Path(specific_output_folder).mkdir(exist_ok=True)
62
+ file_path = f'{specific_output_folder}/{file.filename}'
63
+
64
+ # Save uploaded file
65
+ try:
66
+ content = await file.read()
67
+ with open(file_path, "wb") as f:
68
+ f.write(content)
69
+
70
+ # πŸ” DEBUG: Log file info
71
+ print("======== DEBUG: Upload Info ========")
72
+ print(f"Working Dir: {os.getcwd()}")
73
+ print(f"Saved file path: {file_path}")
74
+ print(f"Output folder: {specific_output_folder}")
75
+ print(f"List of files in output folder: {os.listdir(specific_output_folder)}")
76
+ print("====================================")
77
+
78
+ # Extract panels
79
+ config = Config()
80
+ config.input_path = file_path
81
+ config.output_folder = specific_output_folder
82
+
83
+ print(f"[DEBUG] Setting config.input_path to: {config.input_path}")
84
+ print(f"[DEBUG] Setting config.output_folder to: {config.output_folder}")
85
+
86
+ _, _, all_panel_path = ComicPanelExtractor(config, reset=False).extract_panels_from_comic()
87
+ all_panel_path = [f'/api/extract/{base_output_folder}/{file_id}/{os.path.basename(path)}' for path in all_panel_path]
88
+
89
+ return {
90
+ "success": True,
91
+ "message": f"Extracted {len(all_panel_path)} panels",
92
+ "panels": all_panel_path
93
+ }
94
+
95
+ except Exception as e:
96
+ print(f"Error processing image: {str(e)} {traceback.format_exc()}")
97
+ raise HTTPException(status_code=500, detail=f"Error processing image: {str(e)} {traceback.format_exc()}")
98
+
99
+ @app.get("/api/extract/api_outputs/{folder}/{filename}")
100
+ async def get_output_file(folder: str, filename: str):
101
+ file_path = f'{output_folder}/{folder}/{filename}'
102
+ if not os.path.exists(file_path):
103
+ raise HTTPException(status_code=404, detail="File not found")
104
+
105
+ mime_type, _ = mimetypes.guess_type(file_path)
106
+ return FileResponse(file_path, media_type=mime_type, filename=filename)
comic_panel_extractor/inference.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # inference.py
2
+ from yolo_manager import YOLOManager
3
+ from utils import Config, get_abs_path, get_image_paths
4
+ import os
5
+
6
+ def run_inference(weights_path: str, images_dirs, output_dir: str = 'temp_dir') -> None:
7
+ """
8
+ Run inference on images using trained model.
9
+
10
+ Args:
11
+ weights_path: Path to model weights
12
+ images_dirs: Directory or list of directories containing images
13
+ output_dir: Directory to save annotated results
14
+ """
15
+ try:
16
+ # Validate weights file
17
+ weights_path = get_abs_path(weights_path)
18
+ if not os.path.isfile(weights_path):
19
+ raise FileNotFoundError(f"❌ Weights file not found: {weights_path}")
20
+
21
+ # Get image paths
22
+ image_paths = get_image_paths(images_dirs)
23
+ if not image_paths:
24
+ raise ValueError("❌ No images found in the provided directories.")
25
+
26
+ print(f"πŸ” Found {len(image_paths)} images for inference")
27
+
28
+ # Initialize YOLO manager and load model
29
+ yolo_manager = YOLOManager()
30
+ yolo_manager.load_model(weights_path)
31
+
32
+ # Run inference
33
+ yolo_manager.annotate_images(image_paths, output_dir)
34
+
35
+ print("πŸŽ‰ Inference completed successfully!")
36
+
37
+ except Exception as e:
38
+ print(f"❌ Inference failed: {str(e)}")
39
+ raise
40
+
41
+ def main():
42
+ """Main inference function."""
43
+ weights_path = f'{Config.YOLO_MODEL_NAME}.pt'
44
+ images_dirs = [
45
+ './dataset/images/train',
46
+ './dataset/images/val',
47
+ './dataset/images/test'
48
+ ]
49
+
50
+ run_inference(weights_path, images_dirs, './temp_dir')
51
+
52
+ if __name__ == "__main__":
53
+ main()
comic_panel_extractor/server.py CHANGED
@@ -1,33 +1,24 @@
1
- from fastapi import FastAPI, Request, File, UploadFile, HTTPException, Form, Query
2
- from fastapi.responses import HTMLResponse, FileResponse
 
 
 
 
 
 
3
  from fastapi.staticfiles import StaticFiles
4
- import cv2
5
- import numpy as np
6
- from PIL import Image
7
- import io
8
- import base64
9
  import os
10
- from typing import List
11
- import uuid
12
- from .config import Config
13
- from .main import ComicPanelExtractor
14
  from jinja2 import Environment, FileSystemLoader, select_autoescape
15
- import traceback
16
- from pathlib import Path
17
- import shutil
18
- import time
19
- import mimetypes
20
 
21
- current_path = os.path.abspath(os.path.join(os.path.dirname(__file__)))
22
 
23
- base_output_folder = "api_outputs"
24
- static_folder = "./static"
25
- output_folder = os.path.join(current_path, base_output_folder)
26
- static_folder = os.path.join(current_path, static_folder)
27
 
28
- # Create directories for uploads and outputs
29
- os.makedirs(output_folder, exist_ok=True)
30
- os.makedirs(static_folder, exist_ok=True)
31
 
32
  # Templates
33
  template_dirs = [static_folder]
@@ -36,112 +27,28 @@ env = Environment(
36
  autoescape=select_autoescape(['html', 'xml'])
37
  )
38
 
39
- app = FastAPI(title="Comic Panel Extractor", version="1.0.0")
40
-
41
- # Mount static files
42
- app.mount(static_folder, StaticFiles(directory=static_folder), name="static")
43
- # app.mount(output_folder, StaticFiles(directory=output_folder), name="api_outputs")
44
-
45
- def delete_folder_if_old_or_empty(parent_folder, age_days=1):
46
- """
47
- Delete subfolders inside `parent_folder` if they are empty
48
- or older than `age_days`.
49
-
50
- Args:
51
- parent_folder (str): Path to the parent directory.
52
- age_days (int): Number of days before a folder is considered old.
53
- """
54
- try:
55
- current_time = time.time()
56
- age_seconds = age_days * 24 * 60 * 60
57
-
58
- # Loop through all items in the parent folder
59
- for entry in os.scandir(parent_folder):
60
- if entry.is_dir():
61
- folder_path = entry.path
62
- # Check if folder is empty
63
- if not os.listdir(folder_path):
64
- shutil.rmtree(folder_path)
65
- print(f"Deleted empty folder: {folder_path}")
66
- continue
67
-
68
- # Check if folder is older than age_days
69
- folder_mtime = os.path.getmtime(folder_path)
70
- if current_time - folder_mtime > age_seconds:
71
- shutil.rmtree(folder_path)
72
- print(f"Deleted old folder (>{age_days} day): {folder_path}")
73
-
74
- except Exception as e:
75
- print(f"Error cleaning subfolders in {parent_folder}: {e}")
76
-
77
  # Routes
78
- @app.get("/", response_class=HTMLResponse)
79
  async def index(request: Request):
80
  delete_folder_if_old_or_empty(output_folder)
81
  template = env.get_template("index.html") # From tool/
82
  html_content = template.render(request=request)
83
  return HTMLResponse(content=html_content)
84
 
85
- @app.post("/convert")
86
- async def convert_comic(file: UploadFile = File(...)):
87
- """
88
- Upload a comic page and extract panels
89
- """
90
- # Generate unique filename
91
- file_id = os.path.splitext(file.filename)[0]
92
- specific_output_folder = f'{output_folder}/{file_id}'
93
-
94
- shutil.rmtree(specific_output_folder, ignore_errors=True)
95
- Path(specific_output_folder).mkdir(exist_ok=True)
96
- file_path = f'{specific_output_folder}/{file.filename}'
97
-
98
- # Save uploaded file
99
- try:
100
- content = await file.read()
101
- with open(file_path, "wb") as f:
102
- f.write(content)
103
-
104
- # πŸ” DEBUG: Log file info
105
- print("======== DEBUG: Upload Info ========")
106
- print(f"Working Dir: {os.getcwd()}")
107
- print(f"Saved file path: {file_path}")
108
- print(f"Output folder: {specific_output_folder}")
109
- print(f"List of files in output folder: {os.listdir(specific_output_folder)}")
110
- print("====================================")
111
-
112
- # Extract panels
113
- config = Config()
114
- config.input_path = file_path
115
- config.output_folder = specific_output_folder
116
-
117
- print(f"[DEBUG] Setting config.input_path to: {config.input_path}")
118
- print(f"[DEBUG] Setting config.output_folder to: {config.output_folder}")
119
-
120
- _, _, all_panel_path = ComicPanelExtractor(config, reset=False).extract_panels_from_comic()
121
- all_panel_path = [f'/{base_output_folder}/{file_id}/{os.path.basename(path)}' for path in all_panel_path]
122
-
123
- return {
124
- "success": True,
125
- "message": f"Extracted {len(all_panel_path)} panels",
126
- "panels": all_panel_path
127
- }
128
-
129
- except Exception as e:
130
- print(f"Error processing image: {str(e)} {traceback.format_exc()}")
131
- raise HTTPException(status_code=500, detail=f"Error processing image: {str(e)} {traceback.format_exc()}")
132
-
133
- @app.get("/api_outputs/{folder}/{filename}")
134
- async def get_output_file(folder: str, filename: str):
135
- file_path = f'{output_folder}/{folder}/{filename}'
136
- if not os.path.exists(file_path):
137
- raise HTTPException(status_code=404, detail="File not found")
138
-
139
- mime_type, _ = mimetypes.guess_type(file_path)
140
- return FileResponse(file_path, media_type=mime_type, filename=filename)
141
 
142
  def main():
143
  import uvicorn
144
- uvicorn.run(app, host="0.0.0.0", port=7860)
 
 
 
 
 
145
 
146
  if __name__ == "__main__":
147
- main()
 
1
+ from fastapi import FastAPI
2
+ from fastapi.staticfiles import StaticFiles
3
+ from .extractor_server import app as extractor_app, delete_folder_if_old_or_empty, output_folder
4
+ from .annorator_server import app as annotator_app
5
+ import os
6
+
7
+ from fastapi import Request
8
+ from fastapi.responses import HTMLResponse
9
  from fastapi.staticfiles import StaticFiles
 
 
 
 
 
10
  import os
 
 
 
 
11
  from jinja2 import Environment, FileSystemLoader, select_autoescape
 
 
 
 
 
12
 
13
+ fast_api = FastAPI()
14
 
15
+ # Mount static files ONCE
16
+ current_path = os.path.abspath(os.path.dirname(__file__))
17
+ static_folder = os.path.join(current_path, "static")
18
+ fast_api.mount("/static", StaticFiles(directory=static_folder), name="static")
19
 
20
+ fast_api.include_router(extractor_app)
21
+ fast_api.include_router(annotator_app)
 
22
 
23
  # Templates
24
  template_dirs = [static_folder]
 
27
  autoescape=select_autoescape(['html', 'xml'])
28
  )
29
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  # Routes
31
+ @fast_api.get("/", response_class=HTMLResponse)
32
  async def index(request: Request):
33
  delete_folder_if_old_or_empty(output_folder)
34
  template = env.get_template("index.html") # From tool/
35
  html_content = template.render(request=request)
36
  return HTMLResponse(content=html_content)
37
 
38
+ @fast_api.get("/annotate", response_class=HTMLResponse)
39
+ async def index(request: Request):
40
+ template = env.get_template("annotator.html") # From tool/
41
+ html_content = template.render(request=request)
42
+ return HTMLResponse(content=html_content)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
 
44
  def main():
45
  import uvicorn
46
+ uvicorn.run(
47
+ fast_api,
48
+ host="0.0.0.0", # Or "0.0.0.0" to allow access from other machines
49
+ port=7860, # Change to any available port, e.g., 8080
50
+ # reload=True # Enables auto-reload for development (like --reload in CLI)
51
+ )
52
 
53
  if __name__ == "__main__":
54
+ main
comic_panel_extractor/static/annotator.html ADDED
@@ -0,0 +1,1388 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>πŸ“Έ Comic Panel Annotator</title>
8
+ <style>
9
+ * {
10
+ margin: 0;
11
+ padding: 0;
12
+ box-sizing: border-box;
13
+ }
14
+
15
+ body {
16
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
17
+ background: #f8fafc;
18
+ min-height: 100vh;
19
+ color: #1a202c;
20
+ }
21
+
22
+ /* Top Navigation Bar */
23
+ .top-nav {
24
+ background: white;
25
+ border-bottom: 1px solid #e2e8f0;
26
+ padding: 16px 24px;
27
+ display: flex;
28
+ align-items: center;
29
+ justify-content: space-between;
30
+ position: sticky;
31
+ top: 0;
32
+ z-index: 100;
33
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
34
+ }
35
+
36
+ .logo {
37
+ font-size: 20px;
38
+ font-weight: 700;
39
+ color: #2d3748;
40
+ display: flex;
41
+ align-items: center;
42
+ gap: 8px;
43
+ }
44
+
45
+ .nav-actions {
46
+ display: flex;
47
+ gap: 12px;
48
+ align-items: center;
49
+ }
50
+
51
+ /* Main Layout */
52
+ .main-container {
53
+ display: flex;
54
+ height: calc(100vh - 72px);
55
+ overflow: hidden;
56
+ }
57
+
58
+ /* Left Sidebar */
59
+ .sidebar {
60
+ width: 320px;
61
+ background: white;
62
+ border-right: 1px solid #e2e8f0;
63
+ display: flex;
64
+ flex-direction: column;
65
+ overflow-y: auto;
66
+ }
67
+
68
+ .sidebar-section {
69
+ padding: 20px;
70
+ border-bottom: 1px solid #f1f5f9;
71
+ }
72
+
73
+ .sidebar-section:last-child {
74
+ border-bottom: none;
75
+ }
76
+
77
+ .section-title {
78
+ font-size: 14px;
79
+ font-weight: 600;
80
+ color: #4a5568;
81
+ margin-bottom: 12px;
82
+ text-transform: uppercase;
83
+ letter-spacing: 0.5px;
84
+ }
85
+
86
+ /* Canvas Area */
87
+ .canvas-area {
88
+ flex: 1;
89
+ display: flex;
90
+ flex-direction: column;
91
+ background: #f8fafc;
92
+ position: relative;
93
+ }
94
+
95
+ .canvas-toolbar {
96
+ background: white;
97
+ border-bottom: 1px solid #e2e8f0;
98
+ padding: 12px 20px;
99
+ display: flex;
100
+ align-items: center;
101
+ gap: 16px;
102
+ }
103
+
104
+ .canvas-container {
105
+ flex: 1;
106
+ overflow: auto;
107
+ display: flex;
108
+ /* justify-content: center; */
109
+ align-items: flex-start;
110
+ padding: 20px;
111
+ width: fit-content;
112
+ }
113
+
114
+ canvas {
115
+ border: 2px solid #e2e8f0;
116
+ border-radius: 8px;
117
+ cursor: crosshair;
118
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
119
+ background: white;
120
+ width: 100%;
121
+ }
122
+
123
+ canvas:hover {
124
+ border-color: #4299e1;
125
+ }
126
+
127
+ .canvas-placeholder {
128
+ text-align: center;
129
+ padding: 60px 40px;
130
+ color: #a0aec0;
131
+ background: white;
132
+ border-radius: 12px;
133
+ border: 2px dashed #e2e8f0;
134
+ }
135
+
136
+ /* Form Elements */
137
+ /* .form-field {
138
+ margin-bottom: 16px;
139
+ } */
140
+
141
+ .form-label {
142
+ display: block;
143
+ font-size: 13px;
144
+ font-weight: 500;
145
+ color: #4a5568;
146
+ margin-bottom: 6px;
147
+ }
148
+
149
+ .form-select, .form-input {
150
+ width: 100%;
151
+ padding: 10px 12px;
152
+ border: 1px solid #d1d5db;
153
+ border-radius: 6px;
154
+ font-size: 14px;
155
+ background: white;
156
+ transition: all 0.2s;
157
+ }
158
+
159
+ .form-select:focus, .form-input:focus {
160
+ outline: none;
161
+ border-color: #4299e1;
162
+ box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.1);
163
+ }
164
+
165
+ /* Buttons */
166
+ .btn {
167
+ display: inline-flex;
168
+ align-items: center;
169
+ gap: 6px;
170
+ padding: 8px 16px;
171
+ border: none;
172
+ border-radius: 6px;
173
+ font-size: 13px;
174
+ font-weight: 500;
175
+ cursor: pointer;
176
+ transition: all 0.2s;
177
+ text-decoration: none;
178
+ justify-content: center;
179
+ }
180
+
181
+ .btn:disabled {
182
+ opacity: 0.5;
183
+ cursor: not-allowed;
184
+ }
185
+
186
+ .btn-primary {
187
+ background: #4299e1;
188
+ color: white;
189
+ }
190
+
191
+ .btn-primary:hover:not(:disabled) {
192
+ background: #3182ce;
193
+ }
194
+
195
+ .btn-secondary {
196
+ background: #718096;
197
+ color: white;
198
+ }
199
+
200
+ .btn-secondary:hover:not(:disabled) {
201
+ background: #4a5568;
202
+ }
203
+
204
+ .btn-success {
205
+ background: #48bb78;
206
+ color: white;
207
+ }
208
+
209
+ .btn-success:hover:not(:disabled) {
210
+ background: #38a169;
211
+ }
212
+
213
+ .btn-danger {
214
+ background: #f56565;
215
+ color: white;
216
+ }
217
+
218
+ .btn-danger:hover:not(:disabled) {
219
+ background: #e53e3e;
220
+ }
221
+
222
+ .btn-ghost {
223
+ background: transparent;
224
+ color: #4a5568;
225
+ border: 1px solid #e2e8f0;
226
+ }
227
+
228
+ .btn-ghost:hover:not(:disabled) {
229
+ background: #f7fafc;
230
+ border-color: #cbd5e0;
231
+ }
232
+
233
+ .btn-sm {
234
+ padding: 6px 12px;
235
+ font-size: 12px;
236
+ }
237
+
238
+ .btn-block {
239
+ width: 100%;
240
+ }
241
+
242
+ /* Navigation Controls */
243
+ .image-nav {
244
+ display: flex;
245
+ align-items: center;
246
+ gap: 12px;
247
+ background: #f7fafc;
248
+ padding: 12px;
249
+ border-radius: 8px;
250
+ }
251
+
252
+ .nav-counter {
253
+ font-size: 13px;
254
+ font-weight: 500;
255
+ color: #4a5568;
256
+ min-width: 80px;
257
+ text-align: center;
258
+ word-break: break-word;
259
+ }
260
+
261
+ /* Progress Indicator */
262
+ .progress-section {
263
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
264
+ color: white;
265
+ padding: 20px;
266
+ border-radius: 8px;
267
+ margin-bottom: 16px;
268
+ }
269
+
270
+ .progress-stat {
271
+ display: flex;
272
+ justify-content: space-between;
273
+ margin-bottom: 8px;
274
+ font-size: 14px;
275
+ }
276
+
277
+ .progress-bar {
278
+ width: 100%;
279
+ height: 6px;
280
+ background: rgba(255, 255, 255, 0.3);
281
+ border-radius: 3px;
282
+ overflow: hidden;
283
+ margin-top: 12px;
284
+ }
285
+
286
+ .progress-fill {
287
+ height: 100%;
288
+ background: #48bb78;
289
+ transition: width 0.3s ease;
290
+ }
291
+
292
+ /* Info Cards */
293
+ .info-card {
294
+ background: #f7fafc;
295
+ border: 1px solid #e2e8f0;
296
+ border-radius: 8px;
297
+ padding: 16px;
298
+ }
299
+
300
+ .info-row {
301
+ display: flex;
302
+ justify-content: space-between;
303
+ align-items: center;
304
+ padding: 8px 0;
305
+ border-bottom: 1px solid #e2e8f0;
306
+ }
307
+
308
+ .info-row:last-child {
309
+ border-bottom: none;
310
+ }
311
+
312
+ .info-label {
313
+ font-size: 13px;
314
+ color: #4a5568;
315
+ font-weight: 500;
316
+ }
317
+
318
+ .info-value {
319
+ font-size: 13px;
320
+ color: #1a202c;
321
+ font-weight: 600;
322
+ }
323
+
324
+ /* File Upload */
325
+ .file-upload {
326
+ position: relative;
327
+ overflow: hidden;
328
+ }
329
+
330
+ .file-upload input[type=file] {
331
+ position: absolute;
332
+ opacity: 0;
333
+ left: -9999px;
334
+ }
335
+
336
+ .file-upload-label {
337
+ display: flex;
338
+ align-items: center;
339
+ justify-content: center;
340
+ gap: 8px;
341
+ padding: 32px 20px;
342
+ border: 2px dashed #cbd5e0;
343
+ border-radius: 8px;
344
+ cursor: pointer;
345
+ transition: all 0.2s;
346
+ color: #4a5568;
347
+ }
348
+
349
+ .file-upload-label:hover {
350
+ border-color: #4299e1;
351
+ background: #f7fafc;
352
+ }
353
+
354
+ /* Alerts */
355
+ .alerts {
356
+ position: fixed;
357
+ top: 80px;
358
+ right: 20px;
359
+ z-index: 1000;
360
+ }
361
+
362
+ .alert {
363
+ padding: 12px 16px;
364
+ border-radius: 6px;
365
+ margin-bottom: 8px;
366
+ font-size: 14px;
367
+ font-weight: 500;
368
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
369
+ animation: slideIn 0.3s ease;
370
+ }
371
+
372
+ .alert-success {
373
+ background: #c6f6d5;
374
+ color: #22543d;
375
+ border: 1px solid #9ae6b4;
376
+ }
377
+
378
+ .alert-error {
379
+ background: #fed7d7;
380
+ color: #742a2a;
381
+ border: 1px solid #fc8181;
382
+ }
383
+
384
+ .alert-info {
385
+ background: #bee3f8;
386
+ color: #2a4365;
387
+ border: 1px solid #90cdf4;
388
+ }
389
+
390
+ @keyframes slideIn {
391
+ from {
392
+ transform: translateX(100%);
393
+ opacity: 0;
394
+ }
395
+ to {
396
+ transform: translateX(0);
397
+ opacity: 1;
398
+ }
399
+ }
400
+
401
+ /* Quick Help */
402
+ .help-section {
403
+ background: #fffbf0;
404
+ border: 1px solid #fbd38d;
405
+ border-radius: 8px;
406
+ padding: 16px;
407
+ }
408
+
409
+ .help-item {
410
+ display: flex;
411
+ align-items: center;
412
+ gap: 8px;
413
+ margin-bottom: 8px;
414
+ font-size: 12px;
415
+ color: #744210;
416
+ }
417
+
418
+ .help-item:last-child {
419
+ margin-bottom: 0;
420
+ }
421
+
422
+ .kbd {
423
+ background: #2d3748;
424
+ color: white;
425
+ padding: 2px 6px;
426
+ border-radius: 3px;
427
+ font-family: monospace;
428
+ font-size: 11px;
429
+ }
430
+
431
+ /* Responsive */
432
+ @media (max-width: 1024px) {
433
+ .sidebar {
434
+ width: 280px;
435
+ }
436
+ }
437
+
438
+ @media (max-width: 768px) {
439
+ .main-container {
440
+ flex-direction: column;
441
+ }
442
+
443
+ .sidebar {
444
+ width: 100%;
445
+ height: auto;
446
+ max-height: 300px;
447
+ }
448
+
449
+ .canvas-area {
450
+ height: calc(100vh - 372px);
451
+ }
452
+ }
453
+ </style>
454
+ </head>
455
+
456
+ <body>
457
+ <!-- Top Navigation -->
458
+ <div class="top-nav">
459
+ <div class="logo">
460
+ πŸ“Έ Comic Panel Annotator
461
+ </div>
462
+ <div class="nav-actions">
463
+ <button class="btn btn-success btn-sm" id="saveBtn">
464
+ πŸ’Ύ Save
465
+ </button>
466
+ <button class="btn btn-secondary btn-sm" id="undoBtn">
467
+ ↩️ Undo
468
+ </button>
469
+ <button class="btn btn-danger btn-sm" id="clearBtn">
470
+ πŸ—‘οΈ Clear All
471
+ </button>
472
+ </div>
473
+ </div>
474
+
475
+ <div class="main-container">
476
+ <!-- Left Sidebar -->
477
+ <div class="sidebar">
478
+ <!-- Image Selection -->
479
+ <div class="sidebar-section">
480
+ <div class="section-title">Image Selection</div>
481
+
482
+ <div class="image-nav">
483
+ <button class="btn btn-ghost btn-sm" id="prevBtn" disabled>
484
+ ← Prev
485
+ </button>
486
+ <!-- <div class="nav-counter" id="currentImageDisplay">
487
+ No image
488
+ </div> -->
489
+
490
+ <div class="form-field">
491
+ <select class="form-select" id="imageSelect">
492
+ <option value="">Choose an image...</option>
493
+ </select>
494
+ </div>
495
+ <button class="btn btn-ghost btn-sm" id="nextBtn" disabled>
496
+ Next β†’
497
+ </button>
498
+ </div>
499
+
500
+ <div class="file-upload">
501
+ <input type="file" id="uploadFile" accept="image/*">
502
+ <label for="uploadFile" class="file-upload-label">
503
+ πŸ“€ Drop or click to upload
504
+ </label>
505
+ </div>
506
+ </div>
507
+
508
+ <!-- Progress -->
509
+ <div class="sidebar-section">
510
+ <div class="progress-section">
511
+ <h3 style="margin-bottom: 12px; font-size: 16px;">Progress</h3>
512
+ <div class="progress-stat">
513
+ <span>Annotated</span>
514
+ <span><span id="annotatedImages">0</span>/<span id="totalImages">0</span></span>
515
+ </div>
516
+ <div class="progress-bar">
517
+ <div class="progress-fill" id="progressFill" style="width: 0%"></div>
518
+ </div>
519
+ </div>
520
+ </div>
521
+
522
+ <!-- Current Image Info -->
523
+ <div class="sidebar-section" id="currentImageInfo" style="display: none;">
524
+ <div class="section-title">Current Image</div>
525
+ <div class="info-card">
526
+ <div class="info-row">
527
+ <span class="info-label">Boxes</span>
528
+ <span class="info-value" id="boxCount">0</span>
529
+ </div>
530
+ <div class="info-row">
531
+ <span class="info-label">Size</span>
532
+ <span class="info-value" id="imageSize">-</span>
533
+ </div>
534
+ <div class="info-row">
535
+ <span class="info-label">Selected</span>
536
+ <span class="info-value" id="selectedBoxInfo">None</span>
537
+ </div>
538
+ </div>
539
+ </div>
540
+
541
+ <!-- Quick Actions -->
542
+ <div class="sidebar-section">
543
+ <div class="section-title">Actions</div>
544
+ <button class="btn btn-primary btn-block" id="reloadBtn">
545
+ πŸ”„ Reload Annotations
546
+ </button>
547
+ <button class="btn btn-secondary btn-block" id="downloadBtn" style="display: none; margin-top: 8px;">
548
+ πŸ“₯ Download
549
+ </button>
550
+ </div>
551
+
552
+ <!-- Quick Help -->
553
+ <div class="sidebar-section">
554
+ <div class="section-title">Quick Help</div>
555
+ <div class="help-section">
556
+ <div class="help-item">
557
+ <strong>Draw:</strong> Click & drag on image
558
+ </div>
559
+ <div class="help-item">
560
+ <strong>Move:</strong> <span class="kbd">↑↓←→</span> keys
561
+ </div>
562
+ <div class="help-item">
563
+ <strong>Resize:</strong> <span class="kbd">Shift</span> + arrows
564
+ </div>
565
+ <div class="help-item">
566
+ <strong>Delete:</strong> <span class="kbd">Del</span> key
567
+ </div>
568
+ <div class="help-item">
569
+ <strong>Colors:</strong> 🟒 Saved, πŸ”΄ New, πŸ”΅ Selected
570
+ </div>
571
+ </div>
572
+ </div>
573
+ </div>
574
+
575
+ <!-- Canvas Area -->
576
+ <div class="canvas-area">
577
+ <div class="canvas-toolbar">
578
+ <span id="file_name" style="font-size: 13px; color: #4a5568;">
579
+ Click and drag to create annotation boxes β€’ Select boxes to move or resize
580
+ </span>
581
+ </div>
582
+
583
+ <div class="canvas-container">
584
+ <canvas id="annotationCanvas" style="display: none;"></canvas>
585
+ <div id="canvasPlaceholder" class="canvas-placeholder">
586
+ <h3 style="margin-bottom: 8px;">Select an image to start annotating</h3>
587
+ <p>Choose from the dropdown or upload a new image</p>
588
+ </div>
589
+ </div>
590
+ </div>
591
+ </div>
592
+
593
+ <!-- Alerts Container -->
594
+ <div class="alerts" id="alerts"></div>
595
+
596
+ <script>
597
+ class ComicAnnotator {
598
+ constructor() {
599
+ this.canvas = document.getElementById('annotationCanvas');
600
+ this.ctx = this.canvas.getContext('2d');
601
+ this.boxes = [];
602
+ this.images = [];
603
+ this.currentImageIndex = -1;
604
+ this.currentImage = null;
605
+ this.backgroundImage = null;
606
+ this.originalWidth = 0;
607
+ this.originalHeight = 0;
608
+
609
+ // Drawing state
610
+ this.isDrawing = false;
611
+ this.startX = 0;
612
+ this.startY = 0;
613
+ this.currentBox = null;
614
+
615
+ // Box editing state
616
+ this.selectedBoxIndex = -1;
617
+ this.isDragging = false;
618
+ this.isResizing = false;
619
+ this.dragStartX = 0;
620
+ this.dragStartY = 0;
621
+ this.resizeHandle = null;
622
+ this.lastMouseX = 0;
623
+ this.lastMouseY = 0;
624
+
625
+ this.init();
626
+ }
627
+
628
+ init() {
629
+ this.setupEventListeners();
630
+ this.loadImages();
631
+ }
632
+
633
+ setupEventListeners() {
634
+ // Image selection
635
+ document.getElementById('imageSelect').addEventListener('change', (e) => {
636
+ if (e.target.value) {
637
+ const index = this.images.findIndex(img => img.name === e.target.value);
638
+ if (index >= 0) {
639
+ this.currentImageIndex = index;
640
+ this.loadImage(e.target.value);
641
+ }
642
+ }
643
+ });
644
+
645
+ // Navigation buttons
646
+ document.getElementById('prevBtn').addEventListener('click', () => this.navigatePrevious());
647
+ document.getElementById('nextBtn').addEventListener('click', () => this.navigateNext());
648
+
649
+ // File upload
650
+ document.getElementById('uploadFile').addEventListener('change', (e) => {
651
+ if (e.target.files[0]) {
652
+ this.uploadImage(e.target.files[0]);
653
+ }
654
+ });
655
+
656
+ // Action buttons
657
+ document.getElementById('saveBtn').addEventListener('click', () => this.saveAnnotations());
658
+ document.getElementById('undoBtn').addEventListener('click', () => this.undoLastBox());
659
+ document.getElementById('clearBtn').addEventListener('click', () => this.clearAllBoxes());
660
+ document.getElementById('reloadBtn').addEventListener('click', () => this.reloadAnnotations());
661
+ document.getElementById('downloadBtn').addEventListener('click', () => this.downloadAnnotations());
662
+
663
+ // Canvas events
664
+ this.canvas.addEventListener('mousedown', (e) => this.onMouseDown(e));
665
+ this.canvas.addEventListener('mousemove', (e) => this.onMouseMove(e));
666
+ this.canvas.addEventListener('mouseup', () => this.onMouseUp());
667
+ this.canvas.addEventListener('mouseleave', () => this.onMouseUp());
668
+
669
+ // Keyboard events
670
+ document.addEventListener('keydown', (e) => this.onKeyDown(e));
671
+
672
+ // Make canvas focusable for keyboard events
673
+ this.canvas.tabIndex = 0;
674
+ }
675
+
676
+ async loadImages() {
677
+ try {
678
+ const response = await fetch('/api/annotate/images');
679
+ this.images = await response.json();
680
+
681
+ const select = document.getElementById('imageSelect');
682
+ select.innerHTML = '<option value="">Choose an image...</option>';
683
+
684
+ this.images.forEach(img => {
685
+ const option = document.createElement('option');
686
+ option.value = img.name;
687
+ option.textContent = `${img.name} ${img.has_annotations ? 'βœ“' : ''}`;
688
+ select.appendChild(option);
689
+ });
690
+
691
+ // Update progress
692
+ const annotated = this.images.filter(img => img.has_annotations).length;
693
+ document.getElementById('totalImages').textContent = this.images.length;
694
+ document.getElementById('annotatedImages').textContent = annotated;
695
+ const progress = this.images.length > 0 ? (annotated / this.images.length) * 100 : 0;
696
+ document.getElementById('progressFill').style.width = progress + '%';
697
+
698
+ this.updateNavigationButtons();
699
+
700
+ } catch (error) {
701
+ this.showAlert('Error loading images: ' + error.message, 'error');
702
+ }
703
+ }
704
+
705
+ updateNavigationButtons() {
706
+ const prevBtn = document.getElementById('prevBtn');
707
+ const nextBtn = document.getElementById('nextBtn');
708
+
709
+ prevBtn.disabled = this.currentImageIndex <= 0;
710
+ nextBtn.disabled = this.currentImageIndex >= this.images.length - 1;
711
+
712
+ // if (this.currentImageIndex >= 0) {
713
+ // const currentImageName = this.images[this.currentImageIndex].name;
714
+ // document.getElementById('currentImageDisplay').textContent = `${this.currentImageIndex + 1}/${this.images.length}: ${currentImageName}`;
715
+ // } else {
716
+ // document.getElementById('currentImageDisplay').textContent = 'No image selected';
717
+ // }
718
+ }
719
+
720
+ navigatePrevious() {
721
+ if (this.currentImageIndex > 0) {
722
+ this.currentImageIndex--;
723
+ const imageName = this.images[this.currentImageIndex].name;
724
+ document.getElementById('imageSelect').value = imageName;
725
+ document.getElementById('file_name').innerText = imageName;
726
+ this.loadImage(imageName);
727
+ }
728
+ }
729
+
730
+ navigateNext() {
731
+ if (this.currentImageIndex < this.images.length - 1) {
732
+ this.currentImageIndex++;
733
+ const imageName = this.images[this.currentImageIndex].name;
734
+ document.getElementById('imageSelect').value = imageName;
735
+ document.getElementById('file_name').innerText = imageName;
736
+ this.loadImage(imageName);
737
+ }
738
+ }
739
+
740
+ async loadImage(imageName) {
741
+ try {
742
+ // Load image data
743
+ const imageResponse = await fetch(`/api/annotate/image/${encodeURIComponent(imageName)}`);
744
+ const imageData = await imageResponse.json();
745
+
746
+ // Load annotations
747
+ const annotationsResponse = await fetch(`/api/annotate/annotations/${encodeURIComponent(imageName)}`);
748
+ const annotationsData = await annotationsResponse.json();
749
+
750
+ this.currentImage = imageName;
751
+ this.originalWidth = imageData.width;
752
+ this.originalHeight = imageData.height;
753
+ this.boxes = annotationsData.boxes || [];
754
+ this.selectedBoxIndex = -1;
755
+
756
+ // Load and draw image
757
+ const img = new Image();
758
+ img.onload = () => {
759
+ this.backgroundImage = img;
760
+
761
+ // Set canvas size to match image
762
+ this.canvas.width = img.width;
763
+ this.canvas.height = img.height;
764
+
765
+ this.drawCanvas();
766
+ document.getElementById('canvasPlaceholder').style.display = 'none';
767
+ this.canvas.style.display = 'block';
768
+ document.getElementById('downloadBtn').style.display = 'block';
769
+ };
770
+ img.src = imageData.image_data;
771
+
772
+ // Update info panel
773
+ document.getElementById('currentImageInfo').style.display = 'block';
774
+ document.getElementById('boxCount').textContent = this.boxes.length;
775
+ document.getElementById('imageSize').textContent = `${imageData.width}Γ—${imageData.height}`;
776
+ document.getElementById('selectedBoxInfo').textContent = 'None';
777
+
778
+ this.updateNavigationButtons();
779
+
780
+ } catch (error) {
781
+ this.showAlert('Error loading image: ' + error.message, 'error');
782
+ }
783
+ }
784
+
785
+ drawCanvas() {
786
+ this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
787
+
788
+ // Draw background image
789
+ if (this.backgroundImage) {
790
+ this.ctx.drawImage(this.backgroundImage, 0, 0);
791
+ }
792
+
793
+ // Draw existing boxes
794
+ this.boxes.forEach((box, index) => {
795
+ let strokeColor = '#00ff00';
796
+ let fillColor = 'rgba(0, 255, 0, 0.2)';
797
+
798
+ if (index === this.selectedBoxIndex) {
799
+ strokeColor = '#0066ff';
800
+ fillColor = 'rgba(0, 102, 255, 0.3)';
801
+ } else if (!box.saved) {
802
+ strokeColor = '#ff0000';
803
+ fillColor = 'rgba(255, 0, 0, 0.2)';
804
+ }
805
+
806
+ this.drawBox(box.left, box.top, box.width, box.height, strokeColor, fillColor);
807
+
808
+ // Draw resize handles for selected box
809
+ if (index === this.selectedBoxIndex) {
810
+ this.drawResizeHandles(box);
811
+ }
812
+ });
813
+
814
+ // Draw current box being drawn
815
+ if (this.currentBox) {
816
+ this.drawBox(this.currentBox.left, this.currentBox.top,
817
+ this.currentBox.width, this.currentBox.height, '#ff0000', 'rgba(255, 0, 0, 0.3)');
818
+ }
819
+ }
820
+
821
+ drawBox(x, y, width, height, strokeColor, fillColor) {
822
+ this.ctx.strokeStyle = strokeColor;
823
+ this.ctx.fillStyle = fillColor;
824
+ this.ctx.lineWidth = 3;
825
+ this.ctx.fillRect(x, y, width, height);
826
+ this.ctx.strokeRect(x, y, width, height);
827
+ }
828
+
829
+ drawResizeHandles(box) {
830
+ const handleSize = 10;
831
+ const handles = [
832
+ { x: box.left - handleSize / 2, y: box.top - handleSize / 2, cursor: 'nw-resize' },
833
+ { x: box.left + box.width - handleSize / 2, y: box.top - handleSize / 2, cursor: 'ne-resize' },
834
+ { x: box.left - handleSize / 2, y: box.top + box.height - handleSize / 2, cursor: 'sw-resize' },
835
+ { x: box.left + box.width - handleSize / 2, y: box.top + box.height - handleSize / 2, cursor: 'se-resize' },
836
+ { x: box.left + box.width / 2 - handleSize / 2, y: box.top - handleSize / 2, cursor: 'n-resize' },
837
+ { x: box.left + box.width / 2 - handleSize / 2, y: box.top + box.height - handleSize / 2, cursor: 's-resize' },
838
+ { x: box.left - handleSize / 2, y: box.top + box.height / 2 - handleSize / 2, cursor: 'w-resize' },
839
+ { x: box.left + box.width - handleSize / 2, y: box.top + box.height / 2 - handleSize / 2, cursor: 'e-resize' }
840
+ ];
841
+
842
+ this.ctx.fillStyle = '#0066ff';
843
+ this.ctx.strokeStyle = '#ffffff';
844
+ this.ctx.lineWidth = 4;
845
+
846
+ handles.forEach(handle => {
847
+ this.ctx.fillRect(handle.x, handle.y, handleSize, handleSize);
848
+ this.ctx.strokeRect(handle.x, handle.y, handleSize, handleSize);
849
+ });
850
+ }
851
+
852
+ onMouseDown(e) {
853
+ if (!this.currentImage) return;
854
+
855
+ const pos = this.getMousePos(e);
856
+ this.lastMouseX = pos.x;
857
+ this.lastMouseY = pos.y;
858
+
859
+ // Check if clicking on a resize handle
860
+ if (this.selectedBoxIndex >= 0) {
861
+ const handle = this.getResizeHandle(pos.x, pos.y);
862
+ if (handle) {
863
+ this.isResizing = true;
864
+ this.resizeHandle = handle;
865
+ this.canvas.style.cursor = handle.cursor;
866
+ return;
867
+ }
868
+ }
869
+
870
+ // Check if clicking on an existing box
871
+ const clickedBoxIndex = this.getBoxAtPosition(pos.x, pos.y);
872
+ if (clickedBoxIndex >= 0) {
873
+ this.selectedBoxIndex = clickedBoxIndex;
874
+ this.isDragging = true;
875
+ this.dragStartX = pos.x;
876
+ this.dragStartY = pos.y;
877
+ this.canvas.style.cursor = 'move';
878
+ this.updateSelectedBoxInfo();
879
+ this.drawCanvas();
880
+ return;
881
+ }
882
+
883
+ // Start drawing new box
884
+ this.selectedBoxIndex = -1;
885
+ this.startX = pos.x;
886
+ this.startY = pos.y;
887
+ this.isDrawing = true;
888
+ this.currentBox = { left: this.startX, top: this.startY, width: 0, height: 0 };
889
+ this.updateSelectedBoxInfo();
890
+ }
891
+
892
+ onMouseMove(e) {
893
+ if (!this.currentImage) return;
894
+
895
+ const pos = this.getMousePos(e);
896
+
897
+ if (this.isResizing && this.selectedBoxIndex >= 0) {
898
+ this.resizeBox(pos.x, pos.y);
899
+ this.drawCanvas();
900
+ } else if (this.isDragging && this.selectedBoxIndex >= 0) {
901
+ const deltaX = pos.x - this.lastMouseX;
902
+ const deltaY = pos.y - this.lastMouseY;
903
+ this.moveBox(this.selectedBoxIndex, deltaX, deltaY);
904
+ this.lastMouseX = pos.x;
905
+ this.lastMouseY = pos.y;
906
+ this.drawCanvas();
907
+ } else if (this.isDrawing) {
908
+ this.currentBox.width = pos.x - this.startX;
909
+ this.currentBox.height = pos.y - this.startY;
910
+ this.drawCanvas();
911
+ } else {
912
+ // Update cursor based on what's under mouse
913
+ this.updateCursor(pos.x, pos.y);
914
+ }
915
+ }
916
+
917
+ onMouseUp() {
918
+ if (this.isDrawing && this.currentBox) {
919
+ // Only add box if it has meaningful size
920
+ if (Math.abs(this.currentBox.width) > 10 && Math.abs(this.currentBox.height) > 10) {
921
+ const box = {
922
+ left: Math.min(this.startX, this.startX + this.currentBox.width),
923
+ top: Math.min(this.startY, this.startY + this.currentBox.height),
924
+ width: Math.abs(this.currentBox.width),
925
+ height: Math.abs(this.currentBox.height),
926
+ type: 'rect',
927
+ stroke: '#ff0000',
928
+ strokeWidth: 3,
929
+ fill: 'rgba(255, 0, 0, 0.3)',
930
+ saved: false
931
+ };
932
+
933
+ this.boxes.push(box);
934
+ this.selectedBoxIndex = this.boxes.length - 1;
935
+ document.getElementById('boxCount').textContent = this.boxes.length;
936
+ this.updateSelectedBoxInfo();
937
+ }
938
+ this.currentBox = null;
939
+ }
940
+
941
+ this.isDrawing = false;
942
+ this.isDragging = false;
943
+ this.isResizing = false;
944
+ this.resizeHandle = null;
945
+ this.canvas.style.cursor = 'crosshair';
946
+ this.drawCanvas();
947
+ }
948
+
949
+ updateCursor(x, y) {
950
+ if (this.selectedBoxIndex >= 0) {
951
+ const handle = this.getResizeHandle(x, y);
952
+ if (handle) {
953
+ this.canvas.style.cursor = handle.cursor;
954
+ return;
955
+ }
956
+ }
957
+
958
+ const boxIndex = this.getBoxAtPosition(x, y);
959
+ if (boxIndex >= 0) {
960
+ this.canvas.style.cursor = 'pointer';
961
+ } else {
962
+ this.canvas.style.cursor = 'crosshair';
963
+ }
964
+ }
965
+
966
+ getResizeHandle(x, y) {
967
+ if (this.selectedBoxIndex < 0) return null;
968
+
969
+ const box = this.boxes[this.selectedBoxIndex];
970
+ const handleSize = 8;
971
+ const tolerance = 5;
972
+
973
+ const handles = [
974
+ { x: box.left, y: box.top, cursor: 'nw-resize', type: 'nw' },
975
+ { x: box.left + box.width, y: box.top, cursor: 'ne-resize', type: 'ne' },
976
+ { x: box.left, y: box.top + box.height, cursor: 'sw-resize', type: 'sw' },
977
+ { x: box.left + box.width, y: box.top + box.height, cursor: 'se-resize', type: 'se' },
978
+ { x: box.left + box.width / 2, y: box.top, cursor: 'n-resize', type: 'n' },
979
+ { x: box.left + box.width / 2, y: box.top + box.height, cursor: 's-resize', type: 's' },
980
+ { x: box.left, y: box.top + box.height / 2, cursor: 'w-resize', type: 'w' },
981
+ { x: box.left + box.width, y: box.top + box.height / 2, cursor: 'e-resize', type: 'e' }
982
+ ];
983
+
984
+ for (let handle of handles) {
985
+ if (Math.abs(x - handle.x) <= tolerance && Math.abs(y - handle.y) <= tolerance) {
986
+ return handle;
987
+ }
988
+ }
989
+ return null;
990
+ }
991
+
992
+ getBoxAtPosition(x, y) {
993
+ for (let i = this.boxes.length - 1; i >= 0; i--) {
994
+ const box = this.boxes[i];
995
+ if (x >= box.left && x <= box.left + box.width &&
996
+ y >= box.top && y <= box.top + box.height) {
997
+ return i;
998
+ }
999
+ }
1000
+ return -1;
1001
+ }
1002
+
1003
+ resizeBox(x, y) {
1004
+ if (this.selectedBoxIndex < 0 || !this.resizeHandle) return;
1005
+
1006
+ const box = this.boxes[this.selectedBoxIndex];
1007
+ const handle = this.resizeHandle;
1008
+
1009
+ switch (handle.type) {
1010
+ case 'nw':
1011
+ const newWidth = box.width + (box.left - x);
1012
+ const newHeight = box.height + (box.top - y);
1013
+ if (newWidth > 10 && newHeight > 10) {
1014
+ box.width = newWidth;
1015
+ box.height = newHeight;
1016
+ box.left = x;
1017
+ box.top = y;
1018
+ }
1019
+ break;
1020
+ case 'ne':
1021
+ const neWidth = x - box.left;
1022
+ const neHeight = box.height + (box.top - y);
1023
+ if (neWidth > 10 && neHeight > 10) {
1024
+ box.width = neWidth;
1025
+ box.height = neHeight;
1026
+ box.top = y;
1027
+ }
1028
+ break;
1029
+ case 'sw':
1030
+ const swWidth = box.width + (box.left - x);
1031
+ const swHeight = y - box.top;
1032
+ if (swWidth > 10 && swHeight > 10) {
1033
+ box.width = swWidth;
1034
+ box.height = swHeight;
1035
+ box.left = x;
1036
+ }
1037
+ break;
1038
+ case 'se':
1039
+ const seWidth = x - box.left;
1040
+ const seHeight = y - box.top;
1041
+ if (seWidth > 10 && seHeight > 10) {
1042
+ box.width = seWidth;
1043
+ box.height = seHeight;
1044
+ }
1045
+ break;
1046
+ case 'n':
1047
+ const nHeight = box.height + (box.top - y);
1048
+ if (nHeight > 10) {
1049
+ box.height = nHeight;
1050
+ box.top = y;
1051
+ }
1052
+ break;
1053
+ case 's':
1054
+ const sHeight = y - box.top;
1055
+ if (sHeight > 10) {
1056
+ box.height = sHeight;
1057
+ }
1058
+ break;
1059
+ case 'w':
1060
+ const wWidth = box.width + (box.left - x);
1061
+ if (wWidth > 10) {
1062
+ box.width = wWidth;
1063
+ box.left = x;
1064
+ }
1065
+ break;
1066
+ case 'e':
1067
+ const eWidth = x - box.left;
1068
+ if (eWidth > 10) {
1069
+ box.width = eWidth;
1070
+ }
1071
+ break;
1072
+ }
1073
+
1074
+ box.saved = false;
1075
+ this.updateSelectedBoxInfo();
1076
+ }
1077
+
1078
+ moveBox(boxIndex, deltaX, deltaY) {
1079
+ if (boxIndex < 0 || boxIndex >= this.boxes.length) return;
1080
+
1081
+ const box = this.boxes[boxIndex];
1082
+ box.left += deltaX;
1083
+ box.top += deltaY;
1084
+
1085
+ // Keep box within canvas bounds
1086
+ box.left = Math.max(0, Math.min(this.canvas.width - box.width, box.left));
1087
+ box.top = Math.max(0, Math.min(this.canvas.height - box.height, box.top));
1088
+
1089
+ box.saved = false;
1090
+ this.updateSelectedBoxInfo();
1091
+ }
1092
+
1093
+ // Modified onKeyDown method with new resize functionality
1094
+ onKeyDown(e) {
1095
+ if (!this.currentImage || this.selectedBoxIndex < 0) return;
1096
+
1097
+ const resizeDistance = 5; // Fixed resize distance
1098
+ const moveDistance = e.shiftKey ? 10 : 1;
1099
+
1100
+ if (e.shiftKey) {
1101
+ // Shift + arrow keys for resizing
1102
+ switch (e.key) {
1103
+ case 'ArrowLeft':
1104
+ e.preventDefault();
1105
+ this.resizeSelectedBox(-resizeDistance, 0);
1106
+ break;
1107
+ case 'ArrowRight':
1108
+ e.preventDefault();
1109
+ this.resizeSelectedBox(resizeDistance, 0);
1110
+ break;
1111
+ case 'ArrowUp':
1112
+ e.preventDefault();
1113
+ this.resizeSelectedBox(0, -resizeDistance);
1114
+ break;
1115
+ case 'ArrowDown':
1116
+ e.preventDefault();
1117
+ this.resizeSelectedBox(0, resizeDistance);
1118
+ break;
1119
+ }
1120
+ } else {
1121
+ // Regular arrow keys for moving
1122
+ switch (e.key) {
1123
+ case 'ArrowLeft':
1124
+ e.preventDefault();
1125
+ this.moveBox(this.selectedBoxIndex, -moveDistance, 0);
1126
+ this.drawCanvas();
1127
+ break;
1128
+ case 'ArrowRight':
1129
+ e.preventDefault();
1130
+ this.moveBox(this.selectedBoxIndex, moveDistance, 0);
1131
+ this.drawCanvas();
1132
+ break;
1133
+ case 'ArrowUp':
1134
+ e.preventDefault();
1135
+ this.moveBox(this.selectedBoxIndex, 0, -moveDistance);
1136
+ this.drawCanvas();
1137
+ break;
1138
+ case 'ArrowDown':
1139
+ e.preventDefault();
1140
+ this.moveBox(this.selectedBoxIndex, 0, moveDistance);
1141
+ this.drawCanvas();
1142
+ break;
1143
+ }
1144
+ }
1145
+
1146
+ // Other key handlers
1147
+ switch (e.key) {
1148
+ case 'Delete':
1149
+ case 'Backspace':
1150
+ e.preventDefault();
1151
+ this.deleteSelectedBox();
1152
+ break;
1153
+ case 'Escape':
1154
+ e.preventDefault();
1155
+ this.selectedBoxIndex = -1;
1156
+ this.updateSelectedBoxInfo();
1157
+ this.drawCanvas();
1158
+ break;
1159
+ }
1160
+ }
1161
+
1162
+ // New method to resize selected box
1163
+ resizeSelectedBox(deltaWidth, deltaHeight) {
1164
+ if (this.selectedBoxIndex < 0) return;
1165
+
1166
+ const box = this.boxes[this.selectedBoxIndex];
1167
+
1168
+ // Calculate new dimensions
1169
+ const newWidth = box.width + deltaWidth;
1170
+ const newHeight = box.height + deltaHeight;
1171
+
1172
+ // Only resize if the new dimensions are reasonable (minimum 10px)
1173
+ if (newWidth >= 10) {
1174
+ box.width = newWidth;
1175
+ // Keep box within canvas bounds
1176
+ if (box.left + box.width > this.canvas.width) {
1177
+ box.left = this.canvas.width - box.width;
1178
+ }
1179
+ }
1180
+
1181
+ if (newHeight >= 10) {
1182
+ box.height = newHeight;
1183
+ // Keep box within canvas bounds
1184
+ if (box.top + box.height > this.canvas.height) {
1185
+ box.top = this.canvas.height - box.height;
1186
+ }
1187
+ }
1188
+
1189
+ box.saved = false;
1190
+ this.updateSelectedBoxInfo();
1191
+ this.drawCanvas();
1192
+ }
1193
+
1194
+ deleteSelectedBox() {
1195
+ if (this.selectedBoxIndex >= 0) {
1196
+ this.boxes.splice(this.selectedBoxIndex, 1);
1197
+ this.selectedBoxIndex = -1;
1198
+ document.getElementById('boxCount').textContent = this.boxes.length;
1199
+ this.updateSelectedBoxInfo();
1200
+ this.drawCanvas();
1201
+ // this.showAlert('Box deleted', 'info');
1202
+ }
1203
+ }
1204
+
1205
+ updateSelectedBoxInfo() {
1206
+ const selectedBoxInfo = document.getElementById('selectedBoxInfo');
1207
+ if (this.selectedBoxIndex >= 0) {
1208
+ const box = this.boxes[this.selectedBoxIndex];
1209
+ selectedBoxInfo.textContent = `#${this.selectedBoxIndex + 1} (${Math.round(box.left)}, ${Math.round(box.top)}, ${Math.round(box.width)}Γ—${Math.round(box.height)})`;
1210
+ } else {
1211
+ selectedBoxInfo.textContent = 'None';
1212
+ }
1213
+ }
1214
+
1215
+ getMousePos(e) {
1216
+ const rect = this.canvas.getBoundingClientRect();
1217
+ const scaleX = this.canvas.width / rect.width;
1218
+ const scaleY = this.canvas.height / rect.height;
1219
+
1220
+ return {
1221
+ x: (e.clientX - rect.left) * scaleX,
1222
+ y: (e.clientY - rect.top) * scaleY
1223
+ };
1224
+ }
1225
+
1226
+ async saveAnnotations() {
1227
+ if (!this.currentImage || this.boxes.length === 0) {
1228
+ this.showAlert('No annotations to save', 'info');
1229
+ return;
1230
+ }
1231
+
1232
+ try {
1233
+ const response = await fetch('/api/annotate/annotations', {
1234
+ method: 'POST',
1235
+ headers: {
1236
+ 'Content-Type': 'application/json',
1237
+ },
1238
+ body: JSON.stringify({
1239
+ boxes: this.boxes.map(box => ({
1240
+ ...box,
1241
+ saved: true
1242
+ })),
1243
+ image_name: this.currentImage,
1244
+ original_width: this.originalWidth,
1245
+ original_height: this.originalHeight
1246
+ })
1247
+ });
1248
+
1249
+ if (response.ok) {
1250
+ const result = await response.json();
1251
+ this.boxes.forEach(box => box.saved = true);
1252
+ this.drawCanvas();
1253
+ this.showAlert(result.message, 'success');
1254
+ this.loadImages(); // Refresh image list to update progress
1255
+ } else {
1256
+ throw new Error('Failed to save annotations');
1257
+ }
1258
+ } catch (error) {
1259
+ this.showAlert('Error saving annotations: ' + error.message, 'error');
1260
+ }
1261
+ }
1262
+
1263
+ undoLastBox() {
1264
+ if (this.boxes.length > 0) {
1265
+ this.boxes.pop();
1266
+ this.selectedBoxIndex = -1;
1267
+ document.getElementById('boxCount').textContent = this.boxes.length;
1268
+ this.updateSelectedBoxInfo();
1269
+ this.drawCanvas();
1270
+ this.showAlert('Last box removed', 'info');
1271
+ } else {
1272
+ this.showAlert('No boxes to undo', 'info');
1273
+ }
1274
+ }
1275
+
1276
+ async clearAllBoxes() {
1277
+ if (!this.currentImage) return;
1278
+
1279
+ if (confirm('Are you sure you want to clear all boxes and delete the annotation file?')) {
1280
+ try {
1281
+ // Delete annotations from server
1282
+ await fetch(`/api/annotate/annotations/${encodeURIComponent(this.currentImage)}`, {
1283
+ method: 'DELETE'
1284
+ });
1285
+
1286
+ this.boxes = [];
1287
+ this.selectedBoxIndex = -1;
1288
+ document.getElementById('boxCount').textContent = '0';
1289
+ this.updateSelectedBoxInfo();
1290
+ this.drawCanvas();
1291
+ this.showAlert('All boxes cleared', 'success');
1292
+ this.loadImages(); // Refresh progress
1293
+ } catch (error) {
1294
+ this.showAlert('Error clearing annotations: ' + error.message, 'error');
1295
+ }
1296
+ }
1297
+ }
1298
+
1299
+ async reloadAnnotations() {
1300
+ if (!this.currentImage) return;
1301
+
1302
+ try {
1303
+ const response = await fetch(`/api/annotate/annotations/${encodeURIComponent(this.currentImage)}`);
1304
+ const data = await response.json();
1305
+
1306
+ this.boxes = (data.boxes || []).map(box => ({
1307
+ ...box,
1308
+ saved: true
1309
+ }));
1310
+ this.selectedBoxIndex = -1;
1311
+ document.getElementById('boxCount').textContent = this.boxes.length;
1312
+ this.updateSelectedBoxInfo();
1313
+ this.drawCanvas();
1314
+ this.showAlert('Annotations reloaded from file', 'success');
1315
+ } catch (error) {
1316
+ this.showAlert('Error reloading annotations: ' + error.message, 'error');
1317
+ }
1318
+ }
1319
+
1320
+ downloadAnnotations() {
1321
+ if (!this.currentImage) return;
1322
+
1323
+ const link = document.createElement('a');
1324
+ link.href = `//annotate/annotations/${encodeURIComponent(this.currentImage)}/download`;
1325
+ link.download = `${this.currentImage.split('.')[0]}.txt`;
1326
+ document.body.appendChild(link);
1327
+ link.click();
1328
+ document.body.removeChild(link);
1329
+ }
1330
+
1331
+ async uploadImage(file) {
1332
+ const formData = new FormData();
1333
+ formData.append('file', file);
1334
+
1335
+ try {
1336
+ const response = await fetch('/api/annotate/upload', {
1337
+ method: 'POST',
1338
+ body: formData
1339
+ });
1340
+
1341
+ if (response.ok) {
1342
+ const result = await response.json();
1343
+ this.showAlert(result.message, 'success');
1344
+ await this.loadImages();
1345
+
1346
+ // Auto-select the uploaded image
1347
+ const index = this.images.findIndex(img => img.name === file.name);
1348
+ if (index >= 0) {
1349
+ this.currentImageIndex = index;
1350
+ document.getElementById('imageSelect').value = file.name;
1351
+ this.loadImage(file.name);
1352
+ }
1353
+ } else {
1354
+ throw new Error('Upload failed');
1355
+ }
1356
+ } catch (error) {
1357
+ this.showAlert('Error uploading image: ' + error.message, 'error');
1358
+ }
1359
+
1360
+ // Reset file input
1361
+ document.getElementById('uploadFile').value = '';
1362
+ }
1363
+
1364
+ showAlert(message, type) {
1365
+ const alertsContainer = document.getElementById('alerts');
1366
+ const alert = document.createElement('div');
1367
+ alert.className = `alert alert-${type}`;
1368
+ alert.textContent = message;
1369
+
1370
+ alertsContainer.appendChild(alert);
1371
+
1372
+ // Auto-remove alert after 5 seconds
1373
+ setTimeout(() => {
1374
+ if (alert.parentNode) {
1375
+ alert.parentNode.removeChild(alert);
1376
+ }
1377
+ }, 5000);
1378
+ }
1379
+ }
1380
+
1381
+ // Initialize the application when the page loads
1382
+ document.addEventListener('DOMContentLoaded', () => {
1383
+ new ComicAnnotator();
1384
+ });
1385
+ </script>
1386
+ </body>
1387
+
1388
+ </html>
comic_panel_extractor/static/index.html CHANGED
@@ -418,7 +418,7 @@
418
  loading.style.display = 'block';
419
  results.style.display = 'none';
420
 
421
- fetch('/convert', {
422
  method: 'POST',
423
  body: formData
424
  })
 
418
  loading.style.display = 'block';
419
  results.style.display = 'none';
420
 
421
+ fetch('/api/extract/convert', {
422
  method: 'POST',
423
  body: formData
424
  })
comic_panel_extractor/train.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # train.py
2
+ from yolo_manager import YOLOManager
3
+ from utils import Config, get_abs_path, backup_file
4
+ import os
5
+
6
+ def main():
7
+ """Main training function."""
8
+ try:
9
+ # Initialize YOLO manager
10
+ yolo_manager = YOLOManager()
11
+
12
+ # Configuration
13
+ data_yaml_path = get_abs_path('./comic.yaml')
14
+
15
+ if not os.path.isfile(data_yaml_path):
16
+ raise FileNotFoundError(f"❌ Dataset YAML not found: {data_yaml_path}")
17
+
18
+ print(f"🎯 Training model: {Config.YOLO_MODEL_NAME}")
19
+
20
+ # Train model
21
+ model = yolo_manager.train(
22
+ data_yaml_path=data_yaml_path,
23
+ run_name=Config.YOLO_MODEL_NAME
24
+ )
25
+
26
+ # Validate model
27
+ metrics = yolo_manager.validate()
28
+
29
+ # Backup best weights
30
+ weights_path = yolo_manager.get_best_weights_path()
31
+ backup_path = f'{Config.YOLO_MODEL_NAME}.pt'
32
+ backup_file(weights_path, backup_path)
33
+
34
+ print("πŸŽ‰ Training completed successfully!")
35
+
36
+ except Exception as e:
37
+ print(f"❌ Training failed: {str(e)}")
38
+ raise
39
+
40
+ if __name__ == "__main__":
41
+ main()
comic_panel_extractor/utils.py CHANGED
@@ -3,6 +3,12 @@ import imageio.v2 as imageio
3
  import cv2
4
  import numpy as np
5
  from sklearn.cluster import KMeans
 
 
 
 
 
 
6
 
7
  def remove_duplicate_boxes(boxes, compare_single=None, iou_threshold=0.7):
8
  """
@@ -475,4 +481,59 @@ def is_valid_panel(
475
  if is_valid:
476
  validity.append((x1, y1, x2, y2))
477
 
478
- return validity
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  import cv2
4
  import numpy as np
5
  from sklearn.cluster import KMeans
6
+ import os
7
+ import shutil
8
+ from glob import glob
9
+ from typing import List, Union
10
+ from dotenv import load_dotenv
11
+ load_dotenv()
12
 
13
  def remove_duplicate_boxes(boxes, compare_single=None, iou_threshold=0.7):
14
  """
 
481
  if is_valid:
482
  validity.append((x1, y1, x2, y2))
483
 
484
+ return validity
485
+
486
+
487
+ class Config:
488
+ """Configuration class to manage environment variables and paths."""
489
+ YOLO_MODEL_NAME = os.getenv('YOLO_MODEL_NAME', 'default_model')
490
+ DEFAULT_IMAGE_SIZE = 640
491
+ SUPPORTED_EXTENSIONS = ['jpg', 'jpeg', 'png', 'JPG', 'JPEG', 'PNG']
492
+
493
+ def get_abs_path(relative_path: str) -> str:
494
+ """Convert relative path to absolute path."""
495
+ return os.path.abspath(relative_path)
496
+
497
+ def get_image_paths(directories: Union[str, List[str]]) -> List[str]:
498
+ """
499
+ Get all image paths from given directories.
500
+
501
+ Args:
502
+ directories: Single directory path or list of directory paths
503
+
504
+ Returns:
505
+ List of image file paths
506
+ """
507
+ if isinstance(directories, str):
508
+ directories = [directories]
509
+
510
+ all_images = []
511
+ for directory in directories:
512
+ abs_dir = get_abs_path(directory)
513
+ if not os.path.isdir(abs_dir):
514
+ print(f"⚠️ Warning: Skipping non-directory {abs_dir}")
515
+ continue
516
+
517
+ # Support multiple image extensions
518
+ for ext in Config.SUPPORTED_EXTENSIONS:
519
+ pattern = os.path.join(abs_dir, f'*.{ext}')
520
+ images = sorted(glob(pattern))
521
+ all_images.extend(images)
522
+
523
+ return list(set(all_images)) # Remove duplicates
524
+
525
+ def clean_directory(directory: str, create_if_not_exists: bool = True) -> None:
526
+ """Clean directory contents or create if it doesn't exist."""
527
+ if os.path.exists(directory):
528
+ shutil.rmtree(directory)
529
+
530
+ if create_if_not_exists:
531
+ os.makedirs(directory, exist_ok=True)
532
+
533
+ def backup_file(source_path: str, backup_path: str) -> str:
534
+ """Backup a file to specified location."""
535
+ backup_path = get_abs_path(backup_path)
536
+ os.makedirs(os.path.dirname(backup_path), exist_ok=True)
537
+ shutil.copy(source_path, backup_path)
538
+ print(f"βœ… File backed up to: {backup_path}")
539
+ return backup_path
comic_panel_extractor/yolo_manager.py ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # utils.py
2
+ import os
3
+ import shutil
4
+ from glob import glob
5
+ from typing import List, Union
6
+ from dotenv import load_dotenv
7
+
8
+ load_dotenv()
9
+
10
+ class Config:
11
+ """Configuration class to manage environment variables and paths."""
12
+ YOLO_MODEL_NAME = os.getenv('YOLO_MODEL_NAME', 'default_model')
13
+ DEFAULT_IMAGE_SIZE = 640
14
+ SUPPORTED_EXTENSIONS = ['jpg', 'jpeg', 'png', 'JPG', 'JPEG', 'PNG']
15
+
16
+ def get_abs_path(relative_path: str) -> str:
17
+ """Convert relative path to absolute path."""
18
+ return os.path.abspath(relative_path)
19
+
20
+ def get_image_paths(directories: Union[str, List[str]]) -> List[str]:
21
+ """
22
+ Get all image paths from given directories.
23
+
24
+ Args:
25
+ directories: Single directory path or list of directory paths
26
+
27
+ Returns:
28
+ List of image file paths
29
+ """
30
+ if isinstance(directories, str):
31
+ directories = [directories]
32
+
33
+ all_images = []
34
+ for directory in directories:
35
+ abs_dir = get_abs_path(directory)
36
+ if not os.path.isdir(abs_dir):
37
+ print(f"⚠️ Warning: Skipping non-directory {abs_dir}")
38
+ continue
39
+
40
+ # Support multiple image extensions
41
+ for ext in Config.SUPPORTED_EXTENSIONS:
42
+ pattern = os.path.join(abs_dir, f'*.{ext}')
43
+ images = sorted(glob(pattern))
44
+ all_images.extend(images)
45
+
46
+ return list(set(all_images)) # Remove duplicates
47
+
48
+ def clean_directory(directory: str, create_if_not_exists: bool = True) -> None:
49
+ """Clean directory contents or create if it doesn't exist."""
50
+ if os.path.exists(directory):
51
+ shutil.rmtree(directory)
52
+
53
+ if create_if_not_exists:
54
+ os.makedirs(directory, exist_ok=True)
55
+
56
+ def backup_file(source_path: str, backup_path: str) -> str:
57
+ """Backup a file to specified location."""
58
+ backup_path = get_abs_path(backup_path)
59
+ os.makedirs(os.path.dirname(backup_path), exist_ok=True)
60
+ shutil.copy(source_path, backup_path)
61
+ print(f"βœ… File backed up to: {backup_path}")
62
+ return backup_path
63
+
64
+ # yolo_manager.py
65
+ import os
66
+ import cv2
67
+ from ultralytics import YOLO
68
+ from typing import List, Optional, Dict, Any
69
+ from utils import Config, get_abs_path, clean_directory
70
+
71
+ class YOLOManager:
72
+ """Manages YOLO model training and inference operations."""
73
+
74
+ def __init__(self, model_name: Optional[str] = None):
75
+ self.model_name = model_name or Config.YOLO_MODEL_NAME
76
+ self.model = None
77
+
78
+ def load_model(self, weights_path: Optional[str] = None) -> YOLO:
79
+ """Load YOLO model from weights or pretrained model."""
80
+ if weights_path and os.path.isfile(weights_path):
81
+ print(f"πŸ“¦ Loading model from: {weights_path}")
82
+ self.model = YOLO(weights_path)
83
+ else:
84
+ print("✨ Loading pretrained model 'yolo11s.pt'")
85
+ self.model = YOLO("yolo11s.pt")
86
+ return self.model
87
+
88
+ def train(self,
89
+ data_yaml_path: str,
90
+ run_name: Optional[str] = None,
91
+ device: int = 0,
92
+ resume: bool = True,
93
+ **kwargs) -> YOLO:
94
+ """
95
+ Train YOLO model with given parameters.
96
+
97
+ Args:
98
+ data_yaml_path: Path to dataset YAML file
99
+ run_name: Name for the training run
100
+ device: Device to use for training
101
+ resume: Whether to resume from checkpoint if available
102
+ **kwargs: Additional training parameters
103
+ """
104
+ run_name = run_name or self.model_name
105
+ checkpoint_path = f"runs/detect/{run_name}/weights/last.pt"
106
+
107
+ # Check for existing checkpoint
108
+ if resume and os.path.isfile(checkpoint_path):
109
+ print(f"πŸ”„ Resuming training from checkpoint: {checkpoint_path}")
110
+ self.model = YOLO(checkpoint_path)
111
+ resume_flag = True
112
+ else:
113
+ self.load_model()
114
+ resume_flag = False
115
+
116
+ # Default training parameters
117
+ train_params = {
118
+ 'data': data_yaml_path,
119
+ 'imgsz': Config.DEFAULT_IMAGE_SIZE,
120
+ 'epochs': 200,
121
+ 'batch': 10,
122
+ 'name': run_name,
123
+ 'device': device,
124
+ 'cache': True,
125
+ 'project': 'runs/detect',
126
+ 'exist_ok': True,
127
+ 'resume': resume_flag
128
+ }
129
+
130
+ # Update with custom parameters
131
+ train_params.update(kwargs)
132
+
133
+ print(f"πŸš€ Starting training with parameters: {train_params}")
134
+ self.model.train(**train_params)
135
+ return self.model
136
+
137
+ def validate(self) -> Dict[str, Any]:
138
+ """Validate the model and return metrics."""
139
+ if not self.model:
140
+ raise ValueError("❌ No model loaded. Please train or load a model first.")
141
+
142
+ metrics = self.model.val()
143
+ print("πŸ“Š Validation Metrics:", metrics)
144
+ return metrics
145
+
146
+ def get_best_weights_path(self, run_name: Optional[str] = None) -> str:
147
+ """Get path to best trained weights."""
148
+ run_name = run_name or self.model_name
149
+ weights_path = os.path.join('runs', 'detect', run_name, 'weights', 'best.pt')
150
+
151
+ if not os.path.isfile(weights_path):
152
+ raise FileNotFoundError(f"❌ Trained weights not found at: {weights_path}")
153
+
154
+ return weights_path
155
+
156
+ def annotate_images(self, image_paths: List[str], output_dir: str = 'temp_dir', image_size: int = None) -> None:
157
+ """
158
+ Annotate images with model predictions.
159
+
160
+ Args:
161
+ image_paths: List of image file paths
162
+ output_dir: Directory to save annotated images
163
+ image_size: Size for inference
164
+ """
165
+ if not self.model:
166
+ raise ValueError("❌ No model loaded. Please load a model first.")
167
+
168
+ if not image_paths:
169
+ raise ValueError("❌ No images provided for annotation.")
170
+
171
+ image_size = image_size or Config.DEFAULT_IMAGE_SIZE
172
+ clean_directory(output_dir)
173
+
174
+ print(f"🎨 Annotating {len(image_paths)} images...")
175
+
176
+ for idx, image_path in enumerate(image_paths):
177
+ if not os.path.isfile(image_path):
178
+ print(f"⚠️ Warning: Skipping non-existent file {image_path}")
179
+ continue
180
+
181
+ print(f'πŸ” Processing ({idx+1}/{len(image_paths)}): {os.path.basename(image_path)}')
182
+
183
+ try:
184
+ results = self.model(image_path, imgsz=image_size)
185
+ annotated_frame = results[0].plot()
186
+
187
+ # Use original filename with prefix
188
+ original_name = os.path.basename(image_path)
189
+ name, ext = os.path.splitext(original_name)
190
+ save_path = os.path.join(output_dir, f'annotated_{name}{ext}')
191
+
192
+ cv2.imwrite(save_path, annotated_frame)
193
+ print(f'βœ… Saved: {save_path}')
194
+
195
+ except Exception as e:
196
+ print(f"❌ Error processing {image_path}: {str(e)}")
197
+
198
+ print(f"πŸŽ‰ Annotation complete! Results saved to: {output_dir}")
requirements.txt CHANGED
@@ -11,4 +11,6 @@ imagehash
11
  scikit-learn
12
  ultralytics
13
  Pillow
14
- opencv-contrib-python
 
 
 
11
  scikit-learn
12
  ultralytics
13
  Pillow
14
+ opencv-contrib-python
15
+ dotenv
16
+ tqdm