Subh775 commited on
Commit
60d4c0d
·
1 Parent(s): 94bf9c7

clean working UI

Browse files
.gitignore ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ # local env
2
+ .env
Dockerfile ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Use an official Python runtime as a parent image
2
+ FROM python:3.10-slim
3
+
4
+ # Set the working directory in the container
5
+ WORKDIR /code
6
+
7
+ # Copy the requirements file into the container at /code
8
+ COPY ./requirements.txt /code/requirements.txt
9
+
10
+ # Install any needed packages specified in requirements.txt
11
+ # Use --no-cache-dir to reduce image size
12
+ # Install libgl1 for OpenCV headless dependencies
13
+ RUN apt-get update && apt-get install -y libgl1 \
14
+ && pip install --no-cache-dir --upgrade pip \
15
+ && pip install --no-cache-dir -r /code/requirements.txt
16
+
17
+ # Copy the rest of the application code into the container at /code
18
+ COPY ./app /code/app
19
+
20
+ # Command to run the application when the container launches
21
+ # Binds to 0.0.0.0 to be accessible from outside the container
22
+ # The port 7860 is the standard port for Hugging Face Spaces
23
+ CMD ["uvicorn", "app.backend.main:app", "--host", "0.0.0.0", "--port", "7860"]
app/backend/hub.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # /app/backend/hub.py
2
+ import os
3
+ import logging
4
+ from typing import Optional, Dict
5
+ from pathlib import Path
6
+
7
+ from huggingface_hub import HfApi, upload_folder, Repository, create_repo
8
+
9
+ logger = logging.getLogger(__name__)
10
+ logger.addHandler(logging.NullHandler())
11
+
12
+ def get_hf_api(token: Optional[str] = None) -> Optional[HfApi]:
13
+ """Initializes HfApi using a provided token or the environment secret."""
14
+ token_to_use = token or os.getenv("HF_TOKEN")
15
+ if token_to_use:
16
+ return HfApi(token=token_to_use)
17
+ return None
18
+
19
+ def get_user_info(token: Optional[str] = None) -> Optional[Dict]:
20
+ """Fetches user info from the Hub (returns None if unauthenticated)."""
21
+ api = get_hf_api(token)
22
+ if api:
23
+ try:
24
+ return api.whoami()
25
+ except Exception as e:
26
+ # print(f"Failed to authenticate with Hugging Face Hub: {e}")
27
+ logger.warning("Failed to authenticate with Hugging Face Hub: %s", e)
28
+ return None
29
+ return None
30
+
31
+
32
+ def push_dataset_to_hub(folder_path: str, repo_name: str, namespace: str, private: bool, commit_message: str, token: Optional[str] = None):
33
+ """
34
+ Push the contents of folder_path to the Hugging Face datasets repo `namespace/repo_name`.
35
+ Uses token param if provided, otherwise falls back to HF_TOKEN from environment/.env.
36
+ """
37
+ api = get_hf_api(token)
38
+ if not api:
39
+ raise ConnectionError("Hugging Face token not found. Provide token or set HF_TOKEN in environment or .env file.")
40
+
41
+ repo_id = f"{namespace}/{repo_name}"
42
+
43
+ # create repo if not exists
44
+ try:
45
+ api.create_repo(name=repo_name, token=token or os.getenv("HF_TOKEN"), repo_type="dataset", private=private, namespace=namespace)
46
+ logger.info(f"Created repo {repo_id} (or it already exists)")
47
+ except Exception as e:
48
+ logger.info(f"Repo create warning (may already exist): {e}")
49
+
50
+ # upload entire folder (recursively) using upload_folder helper
51
+ try:
52
+ upload_folder(
53
+ folder_path=folder_path,
54
+ path_in_repo="",
55
+ repo_id=repo_id,
56
+ token=token or os.getenv("HF_TOKEN"),
57
+ repo_type="dataset",
58
+ commit_message=commit_message,
59
+ )
60
+ logger.info(f"Uploaded folder to {repo_id} from {folder_path}")
61
+ except Exception as e:
62
+ logger.exception(f"upload_folder failed for {folder_path} -> {repo_id}: {e}")
63
+ raise
app/backend/main.py ADDED
@@ -0,0 +1,854 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # /app/backend/main.py
2
+ import os
3
+ import cv2
4
+ import base64
5
+ from pathlib import Path
6
+ import aiofiles
7
+ import shutil
8
+ import logging
9
+ import tempfile
10
+ import json
11
+ import csv
12
+ from pydantic import BaseModel # especially for DeleteImageRequest
13
+
14
+ from fastapi import FastAPI, File, UploadFile, HTTPException, Request
15
+ from fastapi.responses import FileResponse, JSONResponse
16
+ from fastapi.middleware.cors import CORSMiddleware
17
+ from starlette.staticfiles import StaticFiles
18
+ from fastapi.exceptions import RequestValidationError
19
+
20
+ from .schemas import *
21
+ from .storage import (
22
+ create_new_session, get_session_path, save_session_config,
23
+ load_session_config, extract_zip, move_labeled_file,
24
+ create_dataset_readme, create_dataset_zip
25
+ )
26
+ from .processing import crop_image #segment_with_grabcut
27
+ from .hub import get_user_info, push_dataset_to_hub
28
+
29
+ # Configure logging
30
+ logging.basicConfig(level=logging.INFO)
31
+ logger = logging.getLogger(__name__)
32
+
33
+ app = FastAPI(
34
+ title="Tulasi Data Curator",
35
+ description="Interactive tool for curating and labeling Tulasi leaf disease datasets",
36
+ version="1.0.0"
37
+ )
38
+
39
+ # Add CORS middleware
40
+ app.add_middleware(
41
+ CORSMiddleware,
42
+ allow_origins=["*"],
43
+ allow_credentials=True,
44
+ allow_methods=["*"],
45
+ allow_headers=["*"],
46
+ )
47
+
48
+ # Default labeling schema
49
+ DEFAULT_SCHEMA = {
50
+ "Sri/Green Tulasi": [
51
+ "Healthy", "Leaf Spot", "Powdery Mildew", "Downy Mildew",
52
+ "Bacterial Blight", "Nutrient Deficiency", "Insect Damage",
53
+ "Drought/Scorch", "Mechanical Damage", "Unknown"
54
+ ],
55
+ "Krishna/Black Tulasi": [
56
+ "Healthy", "Leaf Spot", "Powdery Mildew", "Downy Mildew",
57
+ "Bacterial Blight", "Nutrient Deficiency", "Insect Damage",
58
+ "Drought/Scorch", "Mechanical Damage", "Unknown"
59
+ ],
60
+ "Unknown/Other": [
61
+ "Healthy", "Leaf Spot", "Powdery Mildew", "Downy Mildew",
62
+ "Bacterial Blight", "Nutrient Deficiency", "Insect Damage",
63
+ "Drought/Scorch", "Mechanical Damage", "Unknown"
64
+ ],
65
+ }
66
+
67
+ # Exception handlers
68
+ @app.exception_handler(RequestValidationError)
69
+ async def validation_exception_handler(request: Request, exc: RequestValidationError):
70
+ logger.error(f"Validation error: {exc}")
71
+ return JSONResponse(
72
+ status_code=422,
73
+ content={"detail": f"Validation error: {str(exc)}"}
74
+ )
75
+
76
+ @app.exception_handler(Exception)
77
+ async def general_exception_handler(request: Request, exc: Exception):
78
+ logger.error(f"Unexpected error: {exc}")
79
+ return JSONResponse(
80
+ status_code=500,
81
+ content={"detail": f"Internal server error: {str(exc)}"}
82
+ )
83
+
84
+ @app.post("/api/upload_zip")
85
+ async def upload_zip(file: UploadFile = File(...)):
86
+ """Upload and extract a ZIP file containing images."""
87
+ logger.info(f"Uploading ZIP file: {file.filename}")
88
+
89
+ # Validate file
90
+ if not file.filename.lower().endswith('.zip'):
91
+ raise HTTPException(status_code=400, detail="File must be a ZIP archive")
92
+
93
+ # Check file size (10GB limit)
94
+ max_size = 10 * 1024 * 1024 * 1024 # 10GB
95
+ if hasattr(file, 'size') and file.size > max_size:
96
+ raise HTTPException(status_code=413, detail="File too large. Maximum size is 10GB.")
97
+
98
+ session_id, session_path = create_new_session()
99
+ zip_path = session_path / file.filename
100
+
101
+ try:
102
+ # Save uploaded file
103
+ async with aiofiles.open(zip_path, 'wb') as out_file:
104
+ content = await file.read()
105
+ await out_file.write(content)
106
+
107
+ logger.info(f"Saved ZIP file to: {zip_path}")
108
+
109
+ # Extract images
110
+ originals_path = session_path / "originals"
111
+ image_ids = extract_zip(zip_path, originals_path)
112
+
113
+ if not image_ids:
114
+ raise HTTPException(status_code=400, detail="No valid images found in ZIP file.")
115
+
116
+ logger.info(f"Extracted {len(image_ids)} images")
117
+
118
+ # Create session configuration
119
+ config = SessionConfig(
120
+ varieties=DEFAULT_SCHEMA.copy(),
121
+ unlabeled_images=image_ids,
122
+ image_metadata={
123
+ img_id: ImageMetadata(
124
+ original_path=str(originals_path / img_id)
125
+ ) for img_id in image_ids
126
+ }
127
+ )
128
+
129
+ save_session_config(session_id, config)
130
+
131
+ return {
132
+ "session_id": session_id,
133
+ "image_count": len(image_ids),
134
+ "image_ids": image_ids,
135
+ "config": config.model_dump()
136
+ }
137
+
138
+ except Exception as e:
139
+ logger.error(f"Error processing ZIP file: {e}")
140
+ # Cleanup on error
141
+ if session_path.exists():
142
+ shutil.rmtree(session_path, ignore_errors=True)
143
+ raise HTTPException(status_code=500, detail=f"Failed to process ZIP file: {str(e)}")
144
+ finally:
145
+ # Clean up uploaded ZIP file
146
+ if zip_path.exists():
147
+ os.remove(zip_path)
148
+
149
+ @app.get("/api/image/{session_id}/{image_id}")
150
+ async def get_image(session_id: str, image_id: str):
151
+ """Serve an image file (resolved relative to session if necessary)."""
152
+ try:
153
+ config = load_session_config(session_id)
154
+ metadata = config.image_metadata.get(image_id)
155
+
156
+ if not metadata:
157
+ raise HTTPException(status_code=404, detail="Image not found")
158
+
159
+ image_path = Path(metadata.processed_path or metadata.original_path)
160
+
161
+ # If path is not absolute, resolve it relative to session directory
162
+ if not image_path.is_absolute():
163
+ image_path = get_session_path(session_id) / image_path
164
+
165
+ if not image_path.exists():
166
+ raise HTTPException(status_code=404, detail="Image file not found on disk")
167
+
168
+ # set media type based on file extension
169
+ ext = image_path.suffix.lower()
170
+ if ext == ".png":
171
+ media_type = "image/png"
172
+ elif ext in (".jpg", ".jpeg"):
173
+ media_type = "image/jpeg"
174
+ else:
175
+ media_type = "application/octet-stream"
176
+
177
+ return FileResponse(
178
+ path=image_path,
179
+ media_type=media_type,
180
+ headers={"Cache-Control": "max-age=3600"}
181
+ )
182
+
183
+
184
+ except FileNotFoundError:
185
+ raise HTTPException(status_code=404, detail="Session not found")
186
+ except HTTPException:
187
+ raise
188
+ except Exception as e:
189
+ logger.error(f"Error serving image: {e}")
190
+ raise HTTPException(status_code=500, detail="Failed to serve image")
191
+
192
+ # from pydantic import BaseModel
193
+
194
+ class DeleteImageRequest(BaseModel):
195
+ session_id: str
196
+ image_id: str
197
+
198
+ @app.post("/api/delete_image")
199
+ async def delete_image(req: DeleteImageRequest):
200
+ """Delete a single image from the session (original, processed, mask) and update session config."""
201
+ try:
202
+ config = load_session_config(req.session_id)
203
+ metadata = config.image_metadata.get(req.image_id)
204
+ if not metadata:
205
+ raise HTTPException(status_code=404, detail="Image not found in session")
206
+
207
+ session_path = get_session_path(req.session_id)
208
+
209
+ # remove original
210
+ try:
211
+ orig = Path(metadata.original_path)
212
+ if not orig.is_absolute():
213
+ orig = session_path / orig
214
+ if orig.exists():
215
+ orig.unlink()
216
+ except Exception:
217
+ pass
218
+
219
+ # remove processed
220
+ try:
221
+ proc = Path(metadata.processed_path) if metadata.processed_path else None
222
+ if proc:
223
+ if not proc.is_absolute():
224
+ proc = session_path / proc
225
+ if proc.exists():
226
+ proc.unlink()
227
+ except Exception:
228
+ pass
229
+
230
+ # remove mask if any
231
+ try:
232
+ mask = Path(metadata.mask_path) if metadata.mask_path else None
233
+ if mask:
234
+ if not mask.is_absolute():
235
+ mask = session_path / mask
236
+ if mask.exists():
237
+ mask.unlink()
238
+ except Exception:
239
+ pass
240
+
241
+ # remove from lists
242
+ if req.image_id in config.unlabeled_images:
243
+ config.unlabeled_images.remove(req.image_id)
244
+ if req.image_id in config.image_metadata:
245
+ del config.image_metadata[req.image_id]
246
+
247
+ save_session_config(req.session_id, config)
248
+
249
+ return {"status": "success", "session_id": req.session_id, "removed": req.image_id}
250
+ except FileNotFoundError:
251
+ raise HTTPException(status_code=404, detail="Session not found")
252
+ except Exception as e:
253
+ logger.error(f"Error deleting image: {e}")
254
+ raise HTTPException(status_code=500, detail=f"Failed to delete image: {str(e)}")
255
+
256
+
257
+ @app.post("/api/apply_changes")
258
+ async def apply_changes(request: ApplyChangesRequest):
259
+ """Apply cropping and/or mask changes to an image, Supports optionally creating a new sample from a crop."""
260
+ try:
261
+ config = load_session_config(request.session_id)
262
+ session_path = get_session_path(request.session_id) # <--- ensure we have session path early
263
+ metadata = config.image_metadata.get(request.image_id)
264
+
265
+ if not metadata:
266
+ raise HTTPException(status_code=404, detail="Image not found")
267
+
268
+ returned = {"status": "success"}
269
+
270
+ # Apply cropping if specified
271
+ if request.crop_details:
272
+ # Resolve original path (it may be absolute or relative)
273
+ original_path = Path(metadata.original_path)
274
+ if not original_path.is_absolute():
275
+ original_path = session_path / original_path
276
+
277
+ img = cv2.imread(str(original_path))
278
+ if img is None:
279
+ raise HTTPException(status_code=500, detail="Could not read original image")
280
+
281
+ # Validate crop bounds
282
+ h, w = img.shape[:2]
283
+ crop = request.crop_details
284
+ if (crop.x < 0 or crop.y < 0 or
285
+ crop.x + crop.width > w or crop.y + crop.height > h or
286
+ crop.width <= 0 or crop.height <= 0):
287
+ raise HTTPException(status_code=400, detail="Invalid crop bounds")
288
+
289
+ # Crop image
290
+ # cropped_img = crop_image(img, **crop.model_dump())
291
+ cropped_img = crop_image(img, int(crop.x), int(crop.y), int(crop.width), int(crop.height))
292
+
293
+ # Save processed image(s)
294
+ processed_dir = session_path / "processed"
295
+ processed_dir.mkdir(exist_ok=True, parents=True)
296
+
297
+ # If caller requested a new sample from this crop, generate a unique filename and metadata
298
+ if getattr(request, "create_new_sample", False):
299
+ base_stem = Path(request.image_id).stem
300
+ suffix = Path(request.image_id).suffix or ".jpg"
301
+ # find unique name
302
+ idx = 1
303
+ while True:
304
+ new_name = f"{base_stem}__crop{idx}{suffix}"
305
+ new_path = processed_dir / new_name
306
+ if not new_path.exists():
307
+ break
308
+ idx += 1
309
+
310
+ success = cv2.imwrite(str(new_path), cropped_img)
311
+ if not success:
312
+ raise HTTPException(status_code=500, detail="Failed to save cropped image (new sample)")
313
+
314
+ # Prepare new metadata object (use same schema as ImageMetadata)
315
+ new_meta = ImageMetadata(
316
+ original_path=str(original_path),
317
+ processed_path=str(new_path),
318
+ crop_details=request.crop_details,
319
+ status="unlabeled",
320
+ mask_path=None,
321
+ variety=None,
322
+ disease=None
323
+ )
324
+
325
+ # Add new image id into config
326
+ config.image_metadata[new_name] = new_meta
327
+ config.unlabeled_images.append(new_name)
328
+
329
+ # Persist
330
+ save_session_config(request.session_id, config)
331
+
332
+ returned["new_image_id"] = new_name
333
+ returned["metadata"] = new_meta.model_dump()
334
+ return returned
335
+
336
+ else:
337
+ # Overwrite (or create) processed image for this image id
338
+ processed_path = processed_dir / request.image_id
339
+ success = cv2.imwrite(str(processed_path), cropped_img)
340
+ if not success:
341
+ raise HTTPException(status_code=500, detail="Failed to save cropped image")
342
+
343
+ # Update metadata for same image id
344
+ metadata.processed_path = str(processed_path)
345
+ metadata.crop_details = request.crop_details
346
+ # leave status unchanged here (label step will set status to 'labeled')
347
+ save_session_config(request.session_id, config)
348
+ returned["metadata"] = metadata.model_dump()
349
+ return returned
350
+
351
+ return returned
352
+
353
+ except FileNotFoundError:
354
+ raise HTTPException(status_code=404, detail="Session not found")
355
+ except HTTPException:
356
+ raise
357
+ except Exception as e:
358
+ logger.error(f"Error applying changes: {e}")
359
+ raise HTTPException(status_code=500, detail=f"Failed to apply changes: {str(e)}")
360
+
361
+ @app.post("/api/label")
362
+ async def label_image(request: LabelRequest):
363
+ """Label an image with variety and disease information."""
364
+ try:
365
+ config = load_session_config(request.session_id)
366
+
367
+ if request.image_id not in config.image_metadata:
368
+ raise HTTPException(status_code=404, detail="Image not found")
369
+
370
+ # Validate variety and disease
371
+ if request.variety not in config.varieties:
372
+ raise HTTPException(status_code=400, detail="Invalid variety")
373
+
374
+ if request.disease not in config.varieties[request.variety]:
375
+ raise HTTPException(status_code=400, detail="Invalid disease for selected variety")
376
+
377
+ # Update metadata
378
+ metadata = config.image_metadata[request.image_id]
379
+ metadata.variety = request.variety
380
+ metadata.disease = request.disease
381
+ metadata.status = "labeled"
382
+
383
+ # Move to final dataset structure
384
+ try:
385
+ final_base_path, _ = move_labeled_file(
386
+ request.session_id, request.image_id,
387
+ request.variety, request.disease
388
+ )
389
+
390
+ # Generate/update README
391
+ create_dataset_readme(final_base_path, config)
392
+
393
+ except Exception as e:
394
+ logger.error(f"Error moving labeled file: {e}")
395
+ # Continue even if file move fails, just log the error
396
+
397
+ # Remove from unlabeled list
398
+ if request.image_id in config.unlabeled_images:
399
+ config.unlabeled_images.remove(request.image_id)
400
+
401
+ # Save configuration
402
+ save_session_config(request.session_id, config)
403
+
404
+ return {
405
+ "status": "success",
406
+ "config": config.model_dump()
407
+ }
408
+
409
+ except FileNotFoundError:
410
+ raise HTTPException(status_code=404, detail="Session not found")
411
+ except Exception as e:
412
+ logger.error(f"Error labeling image: {e}")
413
+ raise HTTPException(status_code=500, detail=f"Failed to label image: {str(e)}")
414
+
415
+ @app.get("/api/summary/{session_id}")
416
+ async def get_summary(session_id: str):
417
+ """Get a summary of the current labeling progress."""
418
+ try:
419
+ config = load_session_config(session_id)
420
+
421
+ counts = {}
422
+ total_labeled = 0
423
+
424
+ for metadata in config.image_metadata.values():
425
+ if metadata.status == "labeled":
426
+ total_labeled += 1
427
+ key = f"{metadata.variety} -> {metadata.disease}"
428
+ counts[key] = counts.get(key, 0) + 1
429
+
430
+ return {
431
+ "unlabeled_count": len(config.unlabeled_images),
432
+ "labeled_count": total_labeled,
433
+ "class_counts": counts
434
+ }
435
+
436
+ except FileNotFoundError:
437
+ raise HTTPException(status_code=404, detail="Session not found")
438
+ except Exception as e:
439
+ logger.error(f"Error getting summary: {e}")
440
+ raise HTTPException(status_code=500, detail="Failed to get summary")
441
+
442
+
443
+ @app.get("/api/export_zip/{session_id}")
444
+ async def export_dataset_as_zip(session_id: str):
445
+ """Create and download a ZIP of the curated dataset (labeled images only)."""
446
+ try:
447
+ config = load_session_config(session_id)
448
+
449
+ # Build a temporary dataset folder that contains only labeled items
450
+ with tempfile.TemporaryDirectory() as tmpdir:
451
+ tmp_base = Path(tmpdir)
452
+ final_images_dir = tmp_base / "images"
453
+ final_annotations_dir = tmp_base / "annotations"
454
+ final_images_dir.mkdir(parents=True, exist_ok=True)
455
+ final_annotations_dir.mkdir(parents=True, exist_ok=True)
456
+
457
+ labeled_any = False
458
+
459
+ def _meta_to_dict(meta):
460
+ if hasattr(meta, "model_dump"):
461
+ d = meta.model_dump()
462
+ elif hasattr(meta, "dict"):
463
+ d = meta.dict()
464
+ else:
465
+ d = dict(meta)
466
+ # take processed_path (or fallback to original), rename to "image"
467
+ proc = d.pop("processed_path", None) or d.pop("original_path", None)
468
+ d["image"] = str(Path(proc)) if proc else ""
469
+ # drop unwanted keys
470
+ d.pop("original_path", None)
471
+ d.pop("mask_path", None)
472
+ d.pop("status", None)
473
+ return d
474
+
475
+ for image_id, metadata in config.image_metadata.items():
476
+ if getattr(metadata, "status", None) != "labeled":
477
+ continue
478
+
479
+ source_path_str = metadata.processed_path or metadata.original_path
480
+ source_path = Path(source_path_str)
481
+ if not source_path.is_absolute():
482
+ source_path = get_session_path(session_id) / source_path
483
+
484
+ dest_var = metadata.variety or "Unknown"
485
+ dest_dis = metadata.disease or "Unknown"
486
+ dest_dir = final_images_dir / dest_var / dest_dis
487
+ dest_dir.mkdir(parents=True, exist_ok=True)
488
+ dest_img_path = dest_dir / image_id
489
+
490
+ if source_path.exists():
491
+ try:
492
+ shutil.copy2(source_path, dest_img_path)
493
+ metadata.processed_path = str(Path("images") / dest_var / dest_dis / image_id)
494
+ except Exception as e:
495
+ logger.warning(f"Failed to copy {source_path} -> {dest_img_path}: {e}")
496
+ continue
497
+ else:
498
+ logger.warning(f"Source image missing for export: {image_id} -> {source_path}")
499
+ continue
500
+
501
+ # write cleaned annotation JSON (image key + others)
502
+ try:
503
+ ann_path = final_annotations_dir / f"{Path(image_id).stem}.json"
504
+ with open(ann_path, "w", encoding="utf-8") as f:
505
+ json.dump(_meta_to_dict(metadata), f, indent=2, ensure_ascii=False)
506
+ except Exception as e:
507
+ logger.warning(f"Failed to write annotation for {image_id}: {e}")
508
+
509
+ labeled_any = True
510
+
511
+ if not labeled_any:
512
+ raise HTTPException(status_code=400, detail="No images have been labeled yet. Please label at least one image before exporting.")
513
+
514
+ # generate README at dataset root
515
+ try:
516
+ create_dataset_readme(tmp_base, config)
517
+ except Exception as e:
518
+ logger.warning(f"Failed to create README in export staging: {e}")
519
+
520
+ # Create a small CSV manifest for easier viewing; first column named "image"
521
+ manifest_path = tmp_base / "manifest.csv"
522
+ with open(manifest_path, "w", newline="", encoding="utf-8") as mf:
523
+ writer = csv.writer(mf)
524
+ writer.writerow(["image", "variety", "disease", "annotation_path"])
525
+ for image_id, metadata in config.image_metadata.items():
526
+ if getattr(metadata, "status", None) != "labeled":
527
+ continue
528
+ img_rel = metadata.processed_path or str(Path("images") / (metadata.variety or "Unknown") / (metadata.disease or "Unknown") / image_id)
529
+ ann_rel = str(Path("annotations") / f"{Path(image_id).stem}.json")
530
+ writer.writerow([img_rel, metadata.variety, metadata.disease, ann_rel])
531
+
532
+ # create zip from tmp_base
533
+ zip_name = f"tulasi_curated_{session_id}"
534
+ zip_path = Path("/tmp") / f"{zip_name}.zip"
535
+ if zip_path.exists():
536
+ zip_path.unlink()
537
+ shutil.make_archive(str(zip_path.with_suffix('')), 'zip', str(tmp_base))
538
+
539
+ return FileResponse(
540
+ path=zip_path,
541
+ media_type='application/zip',
542
+ filename=f"{zip_name}.zip",
543
+ headers={"Content-Disposition": f"attachment; filename={zip_name}.zip"}
544
+ )
545
+
546
+ except FileNotFoundError:
547
+ raise HTTPException(status_code=404, detail="Session not found")
548
+ except HTTPException:
549
+ raise
550
+ except Exception as e:
551
+ logger.error(f"Error creating ZIP: {e}")
552
+ raise HTTPException(status_code=500, detail=f"Failed to create ZIP file: {str(e)}")
553
+
554
+
555
+
556
+
557
+ @app.post("/api/push_to_hub")
558
+ async def push_to_hub_endpoint(request: HubPushRequest):
559
+ """
560
+ Push the curated dataset to Hugging Face Hub (only labeled images).
561
+ This implementation:
562
+ - copies only labeled images into a staging folder images/<variety>/<disease>/
563
+ - writes cleaned annotations under annotations/ where each JSON contains:
564
+ { "image": "images/variety/disease/imagename.jpg", "variety": "...", "disease": "...", "crop_details": "..." }
565
+ (no original_path, mask_path, status)
566
+ - creates manifest.csv with first column 'image'
567
+ - creates a README.md at dataset root (create_dataset_readme)
568
+ - builds a datasets.Dataset with features:
569
+ image (Image), variety (string), disease (string), crop_details (string)
570
+ and pushes it via Dataset.push_to_hub so HF shows thumbnails and preserves column typing & order.
571
+ """
572
+ try:
573
+ # validate HF auth / inputs
574
+ user_info = get_user_info(request.token)
575
+ if not user_info:
576
+ raise HTTPException(status_code=401, detail="Hugging Face authentication required. Please set HF_TOKEN in Space settings.")
577
+ if not request.repo_name or not request.repo_name.strip():
578
+ raise HTTPException(status_code=400, detail="Repository name is required")
579
+
580
+ # staging directories
581
+ tmpdir = tempfile.mkdtemp(prefix="tulasi_push_")
582
+ tmp_base = Path(tmpdir)
583
+ logger.info(f"Created staging folder: {tmp_base}")
584
+ archive_base = None
585
+ archive_path = None
586
+
587
+ try:
588
+ final_images_dir = tmp_base / "images"
589
+ final_annotations_dir = tmp_base / "annotations"
590
+ final_images_dir.mkdir(parents=True, exist_ok=True)
591
+ final_annotations_dir.mkdir(parents=True, exist_ok=True)
592
+
593
+ config = load_session_config(request.session_id)
594
+ logger.info(
595
+ f"Preparing staging for push: session={request.session_id} metadata_entries={len(config.image_metadata)} "
596
+ f"labeled_count={sum(1 for m in config.image_metadata.values() if getattr(m, 'status', None)=='labeled')}"
597
+ )
598
+
599
+ # collect dataset records (image path, variety, disease, crop_details)
600
+ records = []
601
+ labeled_any = False
602
+
603
+ for image_id, metadata in config.image_metadata.items():
604
+ # include only labeled images
605
+ if getattr(metadata, "status", None) != "labeled":
606
+ continue
607
+
608
+ # resolve source (processed preferred)
609
+ source_path = Path(metadata.processed_path or metadata.original_path)
610
+ if not source_path.is_absolute():
611
+ source_path = get_session_path(request.session_id) / source_path
612
+
613
+ if not source_path.exists():
614
+ logger.warning(f"Source image missing, skipping: {source_path}")
615
+ continue
616
+
617
+ dest_var = metadata.variety or "Unknown"
618
+ dest_dis = metadata.disease or "Unknown"
619
+ dest_dir = final_images_dir / dest_var / dest_dis
620
+ dest_dir.mkdir(parents=True, exist_ok=True)
621
+ dest_img_path = dest_dir / image_id
622
+
623
+ try:
624
+ shutil.copy2(source_path, dest_img_path)
625
+ except Exception as e:
626
+ logger.warning(f"Failed to copy {source_path} -> {dest_img_path}: {e}")
627
+ continue
628
+
629
+ # relative path in dataset for annotation + manifest
630
+ rel_img_path = str(Path("images") / dest_var / dest_dis / image_id)
631
+ metadata.processed_path = rel_img_path # update config snapshot for manifest/annotations
632
+
633
+ # normalize crop_details to something JSON-serializable
634
+ cd_raw = getattr(metadata, "crop_details", None)
635
+
636
+ if cd_raw is None:
637
+ cd_serialized = ""
638
+ else:
639
+ # if it's a Pydantic model (v2 -> model_dump), convert to dict
640
+ if hasattr(cd_raw, "model_dump"):
641
+ try:
642
+ cd_obj = cd_raw.model_dump()
643
+ except Exception:
644
+ # fallback to dict()
645
+ try:
646
+ cd_obj = dict(cd_raw)
647
+ except Exception:
648
+ cd_obj = str(cd_raw)
649
+ elif isinstance(cd_raw, dict):
650
+ cd_obj = cd_raw
651
+ else:
652
+ # try __dict__ or str fallback
653
+ cd_obj = getattr(cd_raw, "__dict__", str(cd_raw))
654
+
655
+ try:
656
+ cd_serialized = json.dumps(cd_obj, ensure_ascii=False)
657
+ except Exception:
658
+ # final fallback to string
659
+ cd_serialized = str(cd_obj)
660
+
661
+ # build cleaned annotation dict and write it
662
+ ann = {
663
+ "image": rel_img_path,
664
+ "variety": metadata.variety,
665
+ "disease": metadata.disease,
666
+ # keep crop_details as JSON string (so viewer stores it as a string column)
667
+ # "crop_details": json.dumps(metadata.crop_details) if getattr(metadata, "crop_details", None) is not None else ""
668
+ "crop_details": cd_serialized
669
+ }
670
+ try:
671
+ ann_path = final_annotations_dir / f"{Path(image_id).stem}.json"
672
+ with open(ann_path, "w", encoding="utf-8") as f:
673
+ json.dump(ann, f, indent=2, ensure_ascii=False)
674
+ except Exception as e:
675
+ logger.warning(f"Failed to write annotation for {image_id} at {final_annotations_dir}: {e}")
676
+
677
+ # add record for the Dataset
678
+ # NOTE: use the actual file path on disk so Dataset/Image can load it before push_to_hub
679
+ records.append({
680
+ "image": str(dest_img_path),
681
+ "variety": metadata.variety,
682
+ "disease": metadata.disease,
683
+ # "crop_details": ann["crop_details"]
684
+ "crop_details": cd_serialized
685
+ })
686
+
687
+ labeled_any = True
688
+
689
+ if not labeled_any:
690
+ raise HTTPException(status_code=400, detail="No labeled images to push. Label at least one image before pushing.")
691
+
692
+ # create manifest.csv with first column named "image"
693
+ manifest_path = tmp_base / "manifest.csv"
694
+ with open(manifest_path, "w", newline="", encoding="utf-8") as mf:
695
+ writer = csv.writer(mf)
696
+ writer.writerow(["image", "variety", "disease", "annotation_path"])
697
+ for image_id, metadata in config.image_metadata.items():
698
+ if getattr(metadata, "status", None) != "labeled":
699
+ continue
700
+ img_rel = metadata.processed_path or str(Path("images") / (metadata.variety or "Unknown") / (metadata.disease or "Unknown") / image_id)
701
+ ann_rel = str(Path("annotations") / f"{Path(image_id).stem}.json")
702
+ writer.writerow([img_rel, metadata.variety, metadata.disease, ann_rel])
703
+
704
+ # generate README in staging (ensure it exists at tmp_base/README.md)
705
+ readme_path = tmp_base / "README.md"
706
+ try:
707
+ create_dataset_readme(tmp_base, config)
708
+ # double-check and log existence
709
+ if not readme_path.exists():
710
+ logger.warning(f"create_dataset_readme ran but README missing at {readme_path}; creating fallback README.")
711
+ raise RuntimeError("README missing after create_dataset_readme")
712
+ logger.info(f"README created at staging: {readme_path}")
713
+ except Exception as e:
714
+ # fallback: write a minimal README so Hub has something to display
715
+ logger.warning(f"Failed to create README in push staging with helper: {e}. Writing fallback README.md.")
716
+ try:
717
+ fallback = f"# Tulasi Curated Dataset\n\nThis dataset was created by Tulasi Data Curator.\n\nGenerated files: images/, annotations/, manifest.csv\n\nVarieties: {', '.join(config.varieties.keys())}\n"
718
+ readme_path.write_text(fallback, encoding="utf-8")
719
+ logger.info(f"Wrote fallback README.md at {readme_path}")
720
+ except Exception as e2:
721
+ logger.warning(f"Failed to write fallback README.md at {readme_path}: {e2}")
722
+
723
+
724
+ # Build datasets.Dataset with typed Image column and push to hub
725
+ try:
726
+ from datasets import Dataset, Features, Image, Value
727
+ except Exception:
728
+ logger.exception("The 'datasets' library is required for pushing with typed 'image' column.")
729
+ raise HTTPException(status_code=500, detail="Server missing 'datasets' dependency. Install 'datasets' and 'pyarrow'.")
730
+
731
+ # transpose records into columns in developer-defined order: image, variety, disease, crop_details
732
+ col_image = [r["image"] for r in records]
733
+ col_variety = [r["variety"] for r in records]
734
+ col_disease = [r["disease"] for r in records]
735
+ col_crop = [r["crop_details"] for r in records]
736
+
737
+ data_dict = {
738
+ "image": col_image,
739
+ "variety": col_variety,
740
+ "disease": col_disease,
741
+ "crop_details": col_crop
742
+ }
743
+
744
+ features = Features({
745
+ "image": Image(),
746
+ "variety": Value("string"),
747
+ "disease": Value("string"),
748
+ "crop_details": Value("string")
749
+ })
750
+
751
+ ds = Dataset.from_dict(data_dict)
752
+ ds = ds.cast(features)
753
+
754
+ repo_id = f"{request.namespace}/{request.repo_name}"
755
+ try:
756
+ ds.push_to_hub(repo_id, private=request.private, token=request.token)
757
+ # --- ensure READMe + annotations + manifest are uploaded so Hub displays them ---
758
+ try:
759
+ # make a small upload folder that contains only README, manifest.csv and annotations/
760
+ upload_extra = tmp_base / "hub_upload_extra"
761
+ if upload_extra.exists():
762
+ shutil.rmtree(upload_extra)
763
+ upload_extra.mkdir(parents=True, exist_ok=True)
764
+
765
+ # copy README (already ensured earlier)
766
+ if readme_path.exists():
767
+ shutil.copy2(readme_path, upload_extra / "README.md")
768
+ # copy manifest.csv
769
+ if manifest_path.exists():
770
+ shutil.copy2(manifest_path, upload_extra / "manifest.csv")
771
+
772
+ # copy annotations/ directory (if present) preserving filenames
773
+ if final_annotations_dir.exists():
774
+ (upload_extra / "annotations").mkdir(parents=True, exist_ok=True)
775
+ for ann_file in final_annotations_dir.iterdir():
776
+ if ann_file.is_file():
777
+ shutil.copy2(ann_file, upload_extra / "annotations" / ann_file.name)
778
+
779
+ # call helper to upload just these files (this will add README & annotation files into the dataset repo)
780
+ try:
781
+ push_dataset_to_hub(
782
+ folder_path=str(upload_extra),
783
+ repo_name=request.repo_name,
784
+ namespace=request.namespace,
785
+ private=request.private,
786
+ commit_message=(request.commit_message or "Add README + annotations"),
787
+ token=request.token
788
+ )
789
+ logger.info("Uploaded README/annotations to Hub via push_dataset_to_hub")
790
+ except Exception as e:
791
+ logger.warning("Failed to upload README/annotations via push_dataset_to_hub: %s", e)
792
+
793
+ except Exception as e:
794
+ logger.warning("Error while preparing/uploading extra hub files (README/annotations): %s", e)
795
+
796
+ except Exception as e:
797
+ logger.exception(f"Failed to push dataset via datasets.push_to_hub: {e}")
798
+ raise HTTPException(status_code=500, detail=f"Failed to push to Hub: {str(e)}")
799
+
800
+ repo_url = f"https://huggingface.co/datasets/{request.namespace}/{request.repo_name}"
801
+ return {"status": "success", "repo_url": repo_url}
802
+
803
+ finally:
804
+ # cleanup staging + archive
805
+ try:
806
+ if tmp_base.exists():
807
+ shutil.rmtree(tmp_base)
808
+ logger.info(f"Removed staging folder: {tmp_base}")
809
+ except Exception as e:
810
+ logger.warning(f"Failed to remove staging folder {tmp_base}: {e}")
811
+ try:
812
+ if archive_path is not None and archive_path.exists():
813
+ archive_path.unlink()
814
+ if archive_base and archive_base.exists():
815
+ shutil.rmtree(archive_base, ignore_errors=True)
816
+ except Exception:
817
+ pass
818
+
819
+ except FileNotFoundError:
820
+ raise HTTPException(status_code=404, detail="Session not found")
821
+ except HTTPException:
822
+ raise
823
+ except Exception as e:
824
+ logger.exception(f"Error pushing to hub: {e}")
825
+ raise HTTPException(status_code=500, detail=f"Failed to push to Hub: {str(e)}")
826
+
827
+
828
+ @app.get("/api/whoami")
829
+ async def whoami():
830
+ """Get current user information from Hugging Face."""
831
+ try:
832
+ user_info = get_user_info()
833
+ return {
834
+ "namespace": user_info.get("name") if user_info else None,
835
+ "authenticated": user_info is not None
836
+ }
837
+ except Exception as e:
838
+ logger.error(f"Error getting user info: {e}")
839
+ return {
840
+ "namespace": None,
841
+ "authenticated": False
842
+ }
843
+
844
+ @app.get("/api/health")
845
+ async def health_check():
846
+ """Health check endpoint."""
847
+ return {"status": "healthy", "service": "Tulasi Data Curator"}
848
+
849
+ # Serve static files (frontend)
850
+ app.mount("/", StaticFiles(directory="app/frontend", html=True), name="static")
851
+
852
+ if __name__ == "__main__":
853
+ import uvicorn
854
+ uvicorn.run(app, host="0.0.0.0", port=7860)
app/backend/processing.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # /app/backend/processing.py
2
+ # Simplified and fast image processing logic.
3
+
4
+ import cv2
5
+ import numpy as np
6
+
7
+ def crop_image(img: np.ndarray, x: int, y: int, w: int, h: int) -> np.ndarray:
8
+ """Crops an image using the given coordinates (safe)."""
9
+ # return img[y:y+h, x:x+w]
10
+ h_img, w_img = img.shape[:2]
11
+ # clamp bounds (defensive)
12
+ x0 = max(0, int(x))
13
+ y0 = max(0, int(y))
14
+ x1 = min(w_img, x0 + max(0, int(w)))
15
+ y1 = min(h_img, y0 + max(0, int(h)))
16
+ if x1 <= x0 or y1 <= y0:
17
+ raise ValueError("Invalid crop area")
18
+ return img[y0:y1, x0:x1].copy()
19
+
20
+ def segment_with_grabcut(img: np.ndarray, rect: tuple) -> np.ndarray:
21
+ """
22
+ Performs GrabCut segmentation using a user-provided rectangle.
23
+ Returns a binary mask.
24
+ """
25
+ mask = np.zeros(img.shape[:2], np.uint8)
26
+ bgdModel = np.zeros((1, 65), np.float64)
27
+ fgdModel = np.zeros((1, 65), np.float64)
28
+
29
+ # The user's rectangle is a strong hint for the foreground
30
+ cv2.grabCut(img, mask, rect, bgdModel, fgdModel, 5, cv2.GC_INIT_WITH_RECT)
31
+
32
+ # The mask will have values 0,1,2,3. We want definite and probable foreground.
33
+ final_mask = np.where((mask == 2) | (mask == 0), 0, 1).astype('uint8')
34
+
35
+ return final_mask * 255 # Return as a black and white image
app/backend/schemas.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # /app/backend/schemas.py
2
+ from pydantic import BaseModel, Field
3
+ from typing import List, Dict, Any, Optional
4
+
5
+ class CropDetails(BaseModel):
6
+ x: int
7
+ y: int
8
+ width: int
9
+ height: int
10
+
11
+ class SegmentationRequest(BaseModel):
12
+ session_id: str
13
+ image_id: str
14
+ rect: CropDetails
15
+
16
+ class ApplyChangesRequest(BaseModel):
17
+ session_id: str
18
+ image_id: str
19
+ crop_details: Optional[CropDetails] = None
20
+ mask_path: Optional[str] = None # Path to the generated mask
21
+ create_new_sample: Optional[bool] = False # <-- add this
22
+
23
+ class LabelRequest(BaseModel):
24
+ session_id: str
25
+ image_id: str
26
+ variety: str
27
+ disease: str
28
+
29
+ class HubPushRequest(BaseModel):
30
+ session_id: str
31
+ repo_name: str
32
+ namespace: str
33
+ private: bool = True
34
+ commit_message: str = "Publish curated dataset"
35
+ token: Optional[str] = None # For fallback if secret is not set
36
+
37
+ class ImageMetadata(BaseModel):
38
+ original_path: str
39
+ processed_path: Optional[str] = None
40
+ mask_path: Optional[str] = None
41
+ variety: Optional[str] = None
42
+ disease: Optional[str] = None
43
+ crop_details: Optional[CropDetails] = None
44
+ status: str = "unlabeled"
45
+
46
+ class SessionConfig(BaseModel):
47
+ varieties: Dict[str, List[str]]
48
+ unlabeled_images: List[str]
49
+ image_metadata: Dict[str, ImageMetadata] = Field(default_factory=dict)
app/backend/storage.py ADDED
@@ -0,0 +1,268 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # /app/backend/storage.py
2
+ # Handles file system operations, ZIP extraction, and session management.
3
+
4
+ import os
5
+ import uuid
6
+ import zipfile
7
+ import json
8
+ from pathlib import Path
9
+ import shutil
10
+ from typing import Dict, List, Tuple
11
+
12
+ from .schemas import SessionConfig, ImageMetadata
13
+
14
+ TEMP_DIR = Path("/tmp/dataset_session")
15
+ OUTPUT_DIR = Path("/tmp/clean_dataset")
16
+
17
+ # ensure these base dirs exist
18
+ TEMP_DIR.mkdir(parents=True, exist_ok=True)
19
+ OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
20
+ ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png"}
21
+
22
+ def create_new_session() -> Tuple[str, Path]:
23
+ """Creates a new session directory and returns its ID and path."""
24
+ session_id = str(uuid.uuid4())
25
+ session_path = TEMP_DIR / session_id
26
+ session_path.mkdir(parents=True, exist_ok=True)
27
+ (session_path / "originals").mkdir(parents=True, exist_ok=True)
28
+ # initialize an empty session_config.json (optional)
29
+ # This helps other code that expects a config file to exist after extraction/save
30
+ # but main.py will save a config after extraction so this is optional.
31
+ return session_id, session_path
32
+
33
+ def get_session_path(session_id: str) -> Path:
34
+ """Gets the path for a given session ID, ensuring it exists."""
35
+ path = TEMP_DIR / session_id
36
+ if not path.is_dir():
37
+ # use FileNotFoundError so callers that catch FileNotFoundError behave correctly
38
+ raise FileNotFoundError("Session not found")
39
+ return path
40
+
41
+ def save_session_config(session_id: str, config: SessionConfig):
42
+ """Saves the session's configuration to a JSON file."""
43
+ session_path = get_session_path(session_id)
44
+ with open(session_path / "session_config.json", "w") as f:
45
+ f.write(config.model_dump_json(indent=2))
46
+
47
+ def load_session_config(session_id: str) -> SessionConfig:
48
+ """Loads the session's configuration from a JSON file."""
49
+ session_path = get_session_path(session_id)
50
+ config_path = session_path / "session_config.json"
51
+ if not config_path.exists():
52
+ raise FileNotFoundError("Session config not found")
53
+ with open(config_path, "r") as f:
54
+ data = json.load(f)
55
+ return SessionConfig(**data)
56
+
57
+ def extract_zip(zip_file_path: Path, extract_to: Path) -> List[str]:
58
+ """Extracts images from a ZIP file, sanitizing filenames."""
59
+ image_filenames = []
60
+ # make sure extract_to exists
61
+ extract_to.mkdir(parents=True, exist_ok=True)
62
+
63
+ with zipfile.ZipFile(zip_file_path, 'r') as zip_ref:
64
+ for member in zip_ref.infolist():
65
+ # skip directories and non-image files
66
+ if member.is_dir() or not any(member.filename.lower().endswith(ext) for ext in ALLOWED_EXTENSIONS):
67
+ continue
68
+
69
+ # sanitize the name (strip directories)
70
+ original_filename = Path(member.filename).name
71
+ sanitized_id = f"{uuid.uuid4().hex[:8]}_{original_filename}"
72
+
73
+ target_path = extract_to / sanitized_id
74
+ # ensure parent dir exists (should already)
75
+ target_path.parent.mkdir(parents=True, exist_ok=True)
76
+
77
+ with zip_ref.open(member) as source, open(target_path, "wb") as target:
78
+ shutil.copyfileobj(source, target)
79
+ image_filenames.append(sanitized_id)
80
+ return image_filenames
81
+
82
+ # def move_labeled_file(session_id: str, image_id: str, variety: str, disease: str) -> Tuple[Path, Path]:
83
+ # """Moves a labeled image to its final structured directory and returns the new paths.
84
+ #
85
+ # NOTE: this function updates metadata.processed_path to a RELATIVE path inside the final dataset
86
+ # (images/<variety>/<disease>/<image_id>) and writes a per-image annotation JSON that excludes
87
+ # original_path, mask_path and status (as requested).
88
+ # """
89
+ # config = load_session_config(session_id)
90
+ # if image_id not in config.image_metadata:
91
+ # raise FileNotFoundError(f"Image id {image_id} not found in session config")
92
+ # metadata = config.image_metadata[image_id]
93
+ #
94
+ # final_dataset_slug = "tulasi-curated-dataset"
95
+ # final_base_path = OUTPUT_DIR / final_dataset_slug
96
+ # final_image_dir = final_base_path / "images" / variety / disease
97
+ # final_mask_dir = final_base_path / "masks"
98
+ # final_annotations_dir = final_base_path / "annotations"
99
+ #
100
+ # final_image_dir.mkdir(parents=True, exist_ok=True)
101
+ # final_annotations_dir.mkdir(parents=True, exist_ok=True)
102
+ #
103
+ # Prefer processed image if present, otherwise fall back to original
104
+ # source_path_str = metadata.processed_path or metadata.original_path
105
+ # source_path = Path(source_path_str)
106
+ #
107
+ # Resolve relative paths (relative to session)
108
+ # if not source_path.is_absolute():
109
+ # source_path = get_session_path(session_id) / source_path
110
+ #
111
+ # if not source_path.exists():
112
+ # raise FileNotFoundError(f"Source image file not found: {source_path}")
113
+ #
114
+ # final_image_path = final_image_dir / image_id
115
+ # use copy2 to preserve timestamps/metadata
116
+ # shutil.copy2(source_path, final_image_path)
117
+ #
118
+ # Update metadata to point to relative paths inside final dataset
119
+ # metadata.processed_path = str(final_image_path)
120
+ # Update metadata to point to relative path inside final dataset (so HF can resolve it)
121
+ # metadata.processed_path = str(Path("images") / variety / disease / image_id)
122
+ #
123
+ # save_session_config(session_id, config)
124
+ #
125
+ # Write a per-image annotation sidecar in final dataset WITHOUT original_path/mask_path/status
126
+ # try:
127
+ # produce a plain dict (compat for pydantic v2 / v1)
128
+ # if hasattr(metadata, "model_dump"):
129
+ # meta_dict = metadata.model_dump()
130
+ # elif hasattr(metadata, "dict"):
131
+ # meta_dict = metadata.dict()
132
+ # else:
133
+ # meta_dict = dict(metadata)
134
+ #
135
+ # remove unwanted fields
136
+ # meta_dict.pop("original_path", None)
137
+ # meta_dict.pop("mask_path", None)
138
+ # meta_dict.pop("status", None)
139
+ #
140
+ # write a per-image annotation sidecar in final dataset
141
+ # with open(final_annotations_dir / f"{Path(image_id).stem}.json", "w", encoding="utf-8") as f:
142
+ # f.write(metadata.model_dump_json(indent=2))
143
+ # json.dump(meta_dict, f, indent=2, ensure_ascii=False)
144
+ # except Exception:
145
+ # best-effort writing annotation — don't fail the labeling step if this fails
146
+ # pass
147
+ #
148
+ # return final_base_path, final_image_path
149
+
150
+
151
+ def move_labeled_file(session_id: str, image_id: str, variety: str, disease: str) -> Tuple[Path, Path]:
152
+ """Moves a labeled image to its final structured directory and returns the new paths.
153
+
154
+ The annotation sidecar will include:
155
+ - image: relative path inside dataset (images/<variety>/<disease>/<image_id>)
156
+ - variety, disease, crop_details (if present)
157
+ and will NOT include original_path, mask_path or status.
158
+ """
159
+ config = load_session_config(session_id)
160
+ if image_id not in config.image_metadata:
161
+ raise FileNotFoundError(f"Image id {image_id} not found in session config")
162
+ metadata = config.image_metadata[image_id]
163
+
164
+ final_dataset_slug = "tulasi-curated-dataset"
165
+ final_base_path = OUTPUT_DIR / final_dataset_slug
166
+ final_image_dir = final_base_path / "images" / variety / disease
167
+ final_annotations_dir = final_base_path / "annotations"
168
+
169
+ final_image_dir.mkdir(parents=True, exist_ok=True)
170
+ final_annotations_dir.mkdir(parents=True, exist_ok=True)
171
+
172
+ # Prefer processed image if present, otherwise fall back to original
173
+ source_path_str = metadata.processed_path or metadata.original_path
174
+ source_path = Path(source_path_str)
175
+
176
+ # Resolve relative paths (relative to session)
177
+ if not source_path.is_absolute():
178
+ source_path = get_session_path(session_id) / source_path
179
+
180
+ if not source_path.exists():
181
+ raise FileNotFoundError(f"Source image file not found: {source_path}")
182
+
183
+ final_image_path = final_image_dir / image_id
184
+ shutil.copy2(source_path, final_image_path)
185
+
186
+ # Set processed_path in session config to RELATIVE dataset path so UI sees it
187
+ metadata.processed_path = str(Path("images") / variety / disease / image_id)
188
+ save_session_config(session_id, config)
189
+
190
+ # Build cleaned annotation dict (rename processed_path -> image, drop unwanted fields)
191
+ try:
192
+ if hasattr(metadata, "model_dump"):
193
+ meta_dict = metadata.model_dump()
194
+ elif hasattr(metadata, "dict"):
195
+ meta_dict = metadata.dict()
196
+ else:
197
+ meta_dict = dict(metadata)
198
+
199
+ # rename processed_path -> image
200
+ proc = meta_dict.pop("processed_path", None)
201
+ if proc is None:
202
+ proc = meta_dict.pop("original_path", None)
203
+ meta_dict["image"] = str(Path(proc) if proc is not None else Path(""))
204
+
205
+ # drop fields we don't want
206
+ meta_dict.pop("original_path", None)
207
+ meta_dict.pop("mask_path", None)
208
+ meta_dict.pop("status", None)
209
+
210
+ # write annotation JSON
211
+ ann_path = final_annotations_dir / f"{Path(image_id).stem}.json"
212
+ with open(ann_path, "w", encoding="utf-8") as f:
213
+ json.dump(meta_dict, f, indent=2, ensure_ascii=False)
214
+ except Exception:
215
+ # annotation writing is best-effort — don't break labeling on failure
216
+ pass
217
+
218
+ return final_base_path, final_image_path
219
+
220
+
221
+ def create_dataset_readme(output_path: Path, config: SessionConfig):
222
+ """Generates a README.md for the dataset card in output_path (writes README.md)."""
223
+ output_path.mkdir(parents=True, exist_ok=True)
224
+ readme_content = f"""
225
+ ---
226
+ license: apache-2.0
227
+ tags:
228
+ - image-classification
229
+ - image-segmentation
230
+ - agriculture
231
+ - tulasi
232
+ task_categories:
233
+ - image-classification
234
+ language:
235
+ - en
236
+ ---
237
+ # Tulasi Leaf Health Dataset 🌿
238
+ This dataset was curated using the Interactive **Tulasi Data Curator** application.
239
+
240
+ ## Label Schema
241
+
242
+ ### Varieties
243
+ - {', '.join(config.varieties.keys())}
244
+
245
+ ### Diseases
246
+ """
247
+ for variety, diseases in config.varieties.items():
248
+ readme_content += f"\n#### {variety}\n- {', '.join(diseases)}\n"
249
+
250
+ # write README.md at root of output_path
251
+ readme_file = output_path / "README.md"
252
+ with open(readme_file, "w", encoding="utf-8") as f:
253
+ f.write(readme_content)
254
+
255
+ # --- THIS IS THE MISSING FUNCTION ---
256
+ def create_dataset_zip(session_id: str, dataset_slug: str) -> Path:
257
+ """Creates a ZIP archive of the final curated dataset."""
258
+ final_base_path = OUTPUT_DIR / dataset_slug
259
+ if not final_base_path.exists() or not final_base_path.is_dir():
260
+ raise FileNotFoundError(f"Dataset folder does not exist: {final_base_path}")
261
+
262
+ zip_output_path = OUTPUT_DIR / f"{dataset_slug}" # Path without extension for make_archive
263
+
264
+ # shutil.make_archive returns the full path to the created archive
265
+ archive_path_str = shutil.make_archive(str(zip_output_path), 'zip', str(final_base_path))
266
+
267
+ return Path(archive_path_str)
268
+
app/frontend/app.js ADDED
@@ -0,0 +1,748 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // /app/frontend/app.js
2
+ document.addEventListener('DOMContentLoaded', () => {
3
+ // --- State Management ---
4
+ const state = {
5
+ sessionId: null,
6
+ imageIds: [],
7
+ currentIndex: -1,
8
+ config: null,
9
+ cropper: null,
10
+ // currentMask: { path: null, data: null },
11
+ applyCropToAll: false,
12
+ lastCropData: null,
13
+ activeTab: 'tools',
14
+
15
+ // --- NEW: persist last used labels across images ---
16
+ lastSelectedVariety: null,
17
+ lastSelectedDisease: null,
18
+ };
19
+
20
+ // --- DOM Element Cache ---
21
+ const dom = {
22
+ uploadScreen: document.getElementById('upload-screen'),
23
+ workspaceScreen: document.getElementById('workspace-screen'),
24
+ uploadBtn: document.getElementById('upload-btn'),
25
+ zipUploadInput: document.getElementById('zip-upload-input'),
26
+ uploadProgress: document.getElementById('upload-progress'),
27
+ thumbnailGrid: document.getElementById('thumbnail-grid'),
28
+ imageViewer: document.getElementById('image-viewer'),
29
+ currentImageName: document.getElementById('current-image-name'),
30
+ prevBtn: document.getElementById('prev-btn'),
31
+ nextBtn: document.getElementById('next-btn'),
32
+ progressText: document.getElementById('progress-text'),
33
+ varietySelect: document.getElementById('variety-select'),
34
+ diseaseSelect: document.getElementById('disease-select'),
35
+ saveLabelBtn: document.getElementById('save-label-btn'),
36
+ aspectRatioLock: document.getElementById('aspect-ratio-lock'),
37
+ applyCropAllToggle: document.getElementById('apply-crop-all-toggle'),
38
+ applyChangesBtn: document.getElementById('apply-changes-btn'),
39
+ resetImageBtn: document.getElementById('reset-image-btn'),
40
+ saveCropAsSampleBtn: document.getElementById('save-crop-as-sample-btn'),
41
+ deleteImageBtn: document.getElementById('delete-image-btn'),
42
+ exportBtn: document.getElementById('export-btn'),
43
+ exportModal: document.getElementById('export-modal'),
44
+ exportSummary: document.getElementById('export-summary'),
45
+ downloadZipBtn: document.getElementById('download-zip-btn'),
46
+ hubRepoName: document.getElementById('hub-repo-name'),
47
+ hubPrivateRepo: document.getElementById('hub-private-repo'),
48
+ pushToHubBtn: document.getElementById('push-to-hub-btn'),
49
+ pushStatus: document.getElementById('push-status'),
50
+ toast: document.getElementById('toast'),
51
+ helpBtn: document.getElementById('help-btn'),
52
+ helpModal: document.getElementById('help-modal'),
53
+ toolsTab: document.getElementById('tools-tab'),
54
+ labelTab: document.getElementById('label-tab'),
55
+ toolsContent: document.getElementById('tools-content'),
56
+ labelContent: document.getElementById('label-content'),
57
+ maskPreviewContainer: document.getElementById('mask-preview-container')
58
+ };
59
+
60
+ // --- API Helper (improved) ---
61
+ const api = {
62
+ async _readResponse(response) {
63
+ // debug helpful info
64
+ console.debug(`[api] ${response.url} -> status=${response.status}`, {
65
+ headers: Object.fromEntries(response.headers.entries())
66
+ });
67
+
68
+ const ct = (response.headers.get('content-type') || '').toLowerCase();
69
+ try {
70
+ if (ct.includes('application/json')) {
71
+ return await response.json();
72
+ } else {
73
+ // fallback: return text for non-json content (e.g. file downloads, empty bodies)
74
+ const txt = await response.text();
75
+ return txt;
76
+ }
77
+ } catch (err) {
78
+ // parsing error
79
+ console.warn('[api] failed to parse response body as JSON/text', err);
80
+ // attempt text fallback
81
+ try {
82
+ return await response.text();
83
+ } catch (_) {
84
+ return null;
85
+ }
86
+ }
87
+ },
88
+
89
+ async post(endpoint, body) {
90
+ const url = `/api${endpoint}`;
91
+ const response = await fetch(url, {
92
+ method: 'POST',
93
+ headers: { 'Content-Type': 'application/json' },
94
+ body: JSON.stringify(body),
95
+ });
96
+ const payload = await this._readResponse(response);
97
+
98
+ if (!response.ok) {
99
+ // If backend returned JSON error with { detail }, propagate meaningful message
100
+ const msg = (payload && payload.detail) ? payload.detail : `HTTP ${response.status}`;
101
+ console.error(`[api.post] ${url} failed:`, msg);
102
+ throw new Error(msg);
103
+ }
104
+ return payload;
105
+ },
106
+
107
+ async get(endpoint) {
108
+ const url = `/api${endpoint}`;
109
+ const response = await fetch(url);
110
+ const payload = await this._readResponse(response);
111
+
112
+ if (!response.ok) {
113
+ const msg = (payload && payload.detail) ? payload.detail : `HTTP ${response.status}`;
114
+ console.error(`[api.get] ${url} failed:`, msg);
115
+ throw new Error(msg);
116
+ }
117
+ return payload;
118
+ },
119
+
120
+ async postForm(endpoint, formData) {
121
+ const url = `/api${endpoint}`;
122
+ const response = await fetch(url, {
123
+ method: 'POST',
124
+ body: formData,
125
+ });
126
+
127
+ const payload = await this._readResponse(response);
128
+
129
+ if (!response.ok) {
130
+ const msg = (payload && payload.detail) ? payload.detail : `HTTP ${response.status}`;
131
+ console.error(`[api.postForm] ${url} failed:`, msg);
132
+ throw new Error(msg);
133
+ }
134
+ return payload;
135
+ }
136
+ };
137
+
138
+ // --- UI Helper Functions ---
139
+ function showToast(message, type = 'info') {
140
+ dom.toast.textContent = message;
141
+ dom.toast.className = `show ${type}`;
142
+ setTimeout(() => {
143
+ dom.toast.className = 'hidden';
144
+ }, 3000);
145
+ }
146
+
147
+ function updateProgress() {
148
+ if (!state.config) return;
149
+ const total = state.imageIds.length;
150
+ const labeled = total - state.config.unlabeled_images.length;
151
+ dom.progressText.textContent = `${labeled}/${total} images labeled`;
152
+
153
+ // Enable export button if at least one image is labeled
154
+ dom.exportBtn.disabled = labeled === 0;
155
+ }
156
+
157
+ function populateThumbnails() {
158
+ if (!state.config) return;
159
+
160
+ dom.thumbnailGrid.innerHTML = '';
161
+ state.imageIds.forEach((imageId, index) => {
162
+ const img = document.createElement('img');
163
+ img.src = `/api/image/${state.sessionId}/${imageId}`;
164
+ img.className = 'thumbnail';
165
+ img.alt = imageId;
166
+
167
+ const metadata = state.config.image_metadata[imageId];
168
+ if (metadata && metadata.status === 'labeled') {
169
+ img.classList.add('labeled');
170
+ }
171
+
172
+ if (index === state.currentIndex) {
173
+ img.classList.add('active');
174
+ }
175
+
176
+ img.addEventListener('click', () => loadImage(index));
177
+ dom.thumbnailGrid.appendChild(img);
178
+ });
179
+ }
180
+
181
+ function updateLabelingUI() {
182
+ if (!state.config || state.currentIndex < 0) return;
183
+
184
+ const imageId = state.imageIds[state.currentIndex];
185
+ const metadata = state.config.image_metadata[imageId] || {};
186
+
187
+ // Populate variety options
188
+ dom.varietySelect.innerHTML = '<option value="">Select Variety</option>';
189
+ Object.keys(state.config.varieties).forEach(variety => {
190
+ const option = document.createElement('option');
191
+ option.value = variety;
192
+ option.textContent = variety;
193
+ dom.varietySelect.appendChild(option);
194
+ });
195
+
196
+ // Decide which variety to select:
197
+ // 1. image metadata (already labeled)
198
+ // 2. last selected variety (from previous image)
199
+ // 3. none
200
+ let toSelectVariety = metadata.variety || state.lastSelectedVariety || '';
201
+ if (toSelectVariety && !state.config.varieties.hasOwnProperty(toSelectVariety)) {
202
+ // invalid (e.g. schema changed) -> clear it
203
+ toSelectVariety = '';
204
+ }
205
+ dom.varietySelect.value = toSelectVariety;
206
+
207
+
208
+ // Update disease options based on selected variety
209
+ updateDiseaseOptions();
210
+
211
+
212
+ // Decide which disease to select:
213
+ // 1. image metadata
214
+ // 2. last selected disease (only if it exists in the current variety)
215
+ // 3. none
216
+ let toSelectDisease = metadata.disease || state.lastSelectedDisease || '';
217
+ // ensure the disease exists in the currently-selected variety list
218
+ const currentVar = dom.varietySelect.value;
219
+ if (currentVar && state.config.varieties[currentVar]) {
220
+ const diseases = state.config.varieties[currentVar];
221
+ if (!diseases.includes(toSelectDisease)) {
222
+ // If metadata.disease is valid use it, otherwise clear
223
+ toSelectDisease = metadata.disease && diseases.includes(metadata.disease) ? metadata.disease : '';
224
+ // if (toSelectDisease && !diseases.includes(toSelectDisease)) toSelectDisease = '';
225
+ }
226
+ } else {
227
+ // no variety selected -> clear disease
228
+ toSelectDisease = '';
229
+ }
230
+
231
+ dom.diseaseSelect.value = toSelectDisease;
232
+ }
233
+
234
+ function updateDiseaseOptions() {
235
+ const selectedVariety = dom.varietySelect.value;
236
+ dom.diseaseSelect.innerHTML = '<option value="">Select Disease</option>';
237
+
238
+ if (selectedVariety && state.config.varieties[selectedVariety]) {
239
+ state.config.varieties[selectedVariety].forEach(disease => {
240
+ const option = document.createElement('option');
241
+ option.value = disease;
242
+ option.textContent = disease;
243
+ dom.diseaseSelect.appendChild(option);
244
+ });
245
+
246
+ // If lastSelectedDisease exists and is valid for this variety, select it
247
+ if (state.lastSelectedDisease && state.config.varieties[selectedVariety].includes(state.lastSelectedDisease)) {
248
+ dom.diseaseSelect.value = state.lastSelectedDisease;
249
+ }
250
+ }
251
+ }
252
+
253
+ function switchTab(tabName) {
254
+ state.activeTab = tabName;
255
+
256
+ // Update tab buttons
257
+ dom.toolsTab.classList.toggle('active', tabName === 'tools');
258
+ dom.labelTab.classList.toggle('active', tabName === 'label');
259
+
260
+ // Update tab content
261
+ dom.toolsContent.classList.toggle('hidden', tabName !== 'tools');
262
+ dom.labelContent.classList.toggle('hidden', tabName !== 'label');
263
+ }
264
+
265
+ function initCropper(imageUrl) {
266
+ const imageElement = document.createElement('img');
267
+ imageElement.src = imageUrl;
268
+ imageElement.style.maxWidth = '100%';
269
+ imageElement.style.height = 'auto';
270
+
271
+ dom.imageViewer.innerHTML = '';
272
+ dom.imageViewer.appendChild(imageElement);
273
+
274
+ // Initialize Cropper.js
275
+ if (state.cropper) {
276
+ state.cropper.destroy();
277
+ }
278
+
279
+ state.cropper = new Cropper(imageElement, {
280
+ viewMode: 1,
281
+ dragMode: 'move',
282
+ aspectRatio: NaN,
283
+ autoCropArea: 0.8,
284
+ restore: false,
285
+ guides: false,
286
+ center: false,
287
+ highlight: false,
288
+ cropBoxMovable: true,
289
+ cropBoxResizable: true,
290
+ toggleDragModeOnDblclick: false,
291
+ ready: function () {
292
+ // Apply last crop data if available and toggle is on
293
+ if (state.applyCropToAll && state.lastCropData) {
294
+ this.cropper.setData(state.lastCropData);
295
+ }
296
+ }
297
+ });
298
+ }
299
+
300
+ async function loadImage(index) {
301
+ if (index < 0 || index >= state.imageIds.length) return;
302
+
303
+ state.currentIndex = index;
304
+ const imageId = state.imageIds[index];
305
+
306
+ // Update current image name
307
+ dom.currentImageName.textContent = imageId;
308
+
309
+ // Update navigation buttons
310
+ dom.prevBtn.disabled = index === 0;
311
+ dom.nextBtn.disabled = index === state.imageIds.length - 1;
312
+
313
+ // Load image in cropper
314
+ const imageUrl = `/api/image/${state.sessionId}/${imageId}`;
315
+ initCropper(imageUrl);
316
+
317
+ // Update thumbnails
318
+ populateThumbnails();
319
+
320
+ // Update labeling UI
321
+ updateLabelingUI();
322
+
323
+ // Clear mask preview
324
+ // dom.maskPreviewContainer.innerHTML = '';
325
+ if (dom.maskPreviewContainer) dom.maskPreviewContainer.innerHTML = '';
326
+ state.currentMask = { path: null, data: null };
327
+ }
328
+
329
+ // --- Event Handlers ---
330
+ async function handleZipUpload(file) {
331
+ const maxSize = 10 * 1024 * 1024 * 1024; // 10GB
332
+ if (file.size > maxSize) {
333
+ showToast('File too large. Maximum size is 10GB.', 'error');
334
+ return;
335
+ }
336
+
337
+ // Show upload progress
338
+ dom.uploadProgress.innerHTML = '<div class="spinner"></div><p>Uploading and extracting...</p>';
339
+ dom.uploadProgress.classList.remove('hidden');
340
+
341
+ try {
342
+ const formData = new FormData();
343
+ formData.append('file', file);
344
+
345
+ const result = await api.postForm('/upload_zip', formData);
346
+
347
+ // Some backends might return a plain string on success (rare), so guard:
348
+ if (!result || !result.session_id) {
349
+ console.warn('upload_zip result unexpected:', result);
350
+ showToast('Upload succeeded but server returned unexpected response', 'warning');
351
+ // You can still attempt to parse what you got. If it's text maybe the server printed logs.
352
+ return;
353
+ }
354
+
355
+ state.sessionId = result.session_id;
356
+ state.imageIds = result.image_ids || [];
357
+ state.config = result.config || {};
358
+
359
+ // Switch to workspace
360
+ dom.uploadScreen.classList.add('hidden');
361
+ dom.workspaceScreen.classList.remove('hidden');
362
+
363
+ // Load first image
364
+ if (state.imageIds.length > 0) {
365
+ await loadImage(0);
366
+ }
367
+
368
+ updateProgress();
369
+ showToast(`Successfully loaded ${result.image_count} images!`, 'success');
370
+ } catch (error) {
371
+ showToast('Failed to upload ZIP file', 'error');
372
+ } finally {
373
+ dom.uploadProgress.classList.add('hidden');
374
+ }
375
+ }
376
+
377
+ async function handleSaveLabel() {
378
+ if (!state.sessionId || state.currentIndex < 0) return;
379
+
380
+ const variety = dom.varietySelect.value;
381
+ const disease = dom.diseaseSelect.value;
382
+
383
+ console.log('Saving label', { session_id: state.sessionId, image_index: state.currentIndex, variety, disease });
384
+
385
+ if (!variety || !disease) {
386
+ showToast('Please select both variety and disease', 'error');
387
+ return;
388
+ }
389
+
390
+ try {
391
+ const imageId = state.imageIds[state.currentIndex];
392
+
393
+ // If no processed image exists for this image and we have a crop, apply crop first
394
+ const metadata = (state.config.image_metadata && state.config.image_metadata[imageId]) || {};
395
+ const hasProcessedPath = metadata.processed_path && metadata.processed_path.length > 0;
396
+
397
+ // If no processed image but user drew a crop, auto apply changes
398
+ if (!hasProcessedPath && state.cropper) {
399
+ const cropData = state.cropper.getData();
400
+ if (cropData && cropData.width > 0 && cropData.height > 0) {
401
+ // apply changes first and update local metadata
402
+ await handleApplyChanges();
403
+ }
404
+ }
405
+
406
+ // now call label
407
+ const result = await api.post('/label', {
408
+ session_id: state.sessionId,
409
+ image_id: imageId,
410
+ variety: variety,
411
+ disease: disease
412
+ });
413
+
414
+ // persist last-used labels for next image prefills
415
+ state.lastSelectedVariety = variety;
416
+ state.lastSelectedDisease = disease;
417
+
418
+ // update config from server response
419
+ if (result && result.config) {
420
+ state.config = result.config;
421
+ }
422
+
423
+ updateProgress();
424
+ populateThumbnails();
425
+
426
+ showToast('Label saved successfully!', 'success');
427
+
428
+ // Move to next unlabeled image
429
+ const nextUnlabeledIndex = state.imageIds.findIndex((id, idx) =>
430
+ idx > state.currentIndex && state.config.unlabeled_images.includes(id)
431
+ );
432
+
433
+ if (nextUnlabeledIndex !== -1) {
434
+ await loadImage(nextUnlabeledIndex);
435
+ } else {
436
+ // If no more unlabeled images after current, find first unlabeled
437
+ const firstUnlabeledIndex = state.imageIds.findIndex(id =>
438
+ state.config.unlabeled_images.includes(id)
439
+ );
440
+ if (firstUnlabeledIndex !== -1) {
441
+ await loadImage(firstUnlabeledIndex);
442
+ }
443
+ }
444
+ } catch (error) {
445
+ showToast('Failed to save label', 'error');
446
+ console.error('SaveLabel error:', error);
447
+ }
448
+ }
449
+
450
+ async function handleApplyChanges() {
451
+ if (!state.cropper || !state.sessionId || state.currentIndex < 0) return;
452
+
453
+ const cropData = state.cropper.getData();
454
+ const cropDetails = {
455
+ x: Math.round(cropData.x),
456
+ y: Math.round(cropData.y),
457
+ width: Math.round(cropData.width),
458
+ height: Math.round(cropData.height)
459
+ };
460
+
461
+ try {
462
+ const imageId = state.imageIds[state.currentIndex];
463
+ const result = await api.post('/apply_changes', {
464
+ session_id: state.sessionId,
465
+ image_id: imageId,
466
+ crop_details: cropDetails,
467
+ // mask_path: state.currentMask.path
468
+ });
469
+
470
+ // result.metadata contains updated ImageMetadata from server
471
+ // Update state.config with new metadata for this image
472
+ if (result && result.metadata && state.config && state.config.image_metadata) {
473
+ state.config.image_metadata[imageId] = result.metadata;
474
+ // If it was previously unlabeled, keep it as unlabeled until label step
475
+ // Persist last crop so we can reapply for "apply to all" if needed
476
+ state.lastCropData = cropData;
477
+ }
478
+
479
+ showToast('Changes applied successfully!', 'success');
480
+ } catch (error) {
481
+ showToast('Failed to apply changes', 'error');
482
+ }
483
+ }
484
+
485
+ async function handleResetImage() {
486
+ if (!state.sessionId || state.currentIndex < 0) return;
487
+
488
+ // Reset cropper to full image
489
+ if (state.cropper) {
490
+ state.cropper.reset();
491
+ }
492
+
493
+ // Clear mask
494
+ // dom.maskPreviewContainer.innerHTML = '';
495
+ if (dom.maskPreviewContainer) dom.maskPreviewContainer.innerHTML = '';
496
+ state.currentMask = { path: null, data: null };
497
+
498
+ showToast('Image reset to original', 'success');
499
+ }
500
+
501
+ async function handleDownloadZip() {
502
+ if (!state.sessionId) return;
503
+
504
+ try {
505
+ showToast('Preparing dataset ZIP...', 'info');
506
+
507
+ // Create a download link
508
+ const link = document.createElement('a');
509
+ link.href = `/api/export_zip/${state.sessionId}`;
510
+ link.download = 'tulasi-curated-dataset.zip';
511
+ document.body.appendChild(link);
512
+ link.click();
513
+ document.body.removeChild(link);
514
+
515
+ showToast('Download started!', 'success');
516
+ } catch (error) {
517
+ showToast('Failed to download dataset', 'error');
518
+ }
519
+ }
520
+
521
+ async function handlePushToHub() {
522
+ const repoName = dom.hubRepoName.value.trim();
523
+ if (!repoName) {
524
+ showToast('Repository Name is required.', 'error');
525
+ return;
526
+ }
527
+
528
+ dom.pushStatus.classList.remove('hidden');
529
+ dom.pushStatus.textContent = 'Connecting to Hugging Face...';
530
+
531
+ try {
532
+ // Get current user namespace
533
+ const userInfo = await api.get('/whoami');
534
+ if (!userInfo.namespace) {
535
+ throw new Error("Hugging Face authentication not available. Please check HF_TOKEN.");
536
+ }
537
+
538
+ const namespace = userInfo.namespace;
539
+ dom.pushStatus.textContent = `Pushing dataset to ${namespace}/${repoName}...`;
540
+
541
+ // Push to hub
542
+ const response = await api.post('/push_to_hub', {
543
+ session_id: state.sessionId,
544
+ repo_name: repoName,
545
+ namespace: namespace,
546
+ private: dom.hubPrivateRepo.checked,
547
+ commit_message: "Curated Tulasi leaf dataset"
548
+ });
549
+
550
+ dom.pushStatus.innerHTML = `
551
+ <p style="color: var(--primary-color);">✅ Dataset published successfully!</p>
552
+ <p><a href="${response.repo_url}" target="_blank" rel="noopener noreferrer">${response.repo_url}</a></p>
553
+ `;
554
+
555
+ } catch (error) {
556
+ dom.pushStatus.innerHTML = `<p style="color: #ff6b6b;">❌ Error: ${error.message}</p>`;
557
+ showToast(error.message, 'error');
558
+ }
559
+ }
560
+
561
+ // --- Initialize Application ---
562
+ function init() {
563
+ // Upload handlers
564
+ dom.uploadBtn.addEventListener('click', () => dom.zipUploadInput.click());
565
+ dom.zipUploadInput.addEventListener('change', (e) => {
566
+ if (e.target.files.length) {
567
+ handleZipUpload(e.target.files[0]);
568
+ }
569
+ });
570
+
571
+ // Navigation handlers
572
+ dom.prevBtn.addEventListener('click', () => {
573
+ if (state.currentIndex > 0) {
574
+ loadImage(state.currentIndex - 1);
575
+ }
576
+ });
577
+
578
+ dom.nextBtn.addEventListener('click', () => {
579
+ if (state.currentIndex < state.imageIds.length - 1) {
580
+ loadImage(state.currentIndex + 1);
581
+ }
582
+ });
583
+
584
+ // Tab switching
585
+ dom.toolsTab.addEventListener('click', () => switchTab('tools'));
586
+ dom.labelTab.addEventListener('click', () => switchTab('label'));
587
+
588
+ // Tool handlers
589
+ // dom.segmentBtn.addEventListener('click', handleGenerateMask);
590
+ dom.applyChangesBtn.addEventListener('click', handleApplyChanges);
591
+ dom.resetImageBtn.addEventListener('click', handleResetImage);
592
+
593
+ // Delete image handler
594
+ if (dom.deleteImageBtn) {
595
+ dom.deleteImageBtn.addEventListener('click', async () => {
596
+ if (!state.sessionId || state.currentIndex < 0) return;
597
+ const imageId = state.imageIds[state.currentIndex];
598
+ if (!confirm('Delete this image from session? This cannot be undone.')) return;
599
+
600
+ try {
601
+ const res = await api.post('/delete_image', {
602
+ session_id: state.sessionId,
603
+ image_id: imageId
604
+ });
605
+ // remove from local state
606
+ state.imageIds.splice(state.currentIndex, 1);
607
+ if (state.config && state.config.image_metadata) {
608
+ delete state.config.image_metadata[imageId];
609
+ const uiIndex = state.config.unlabeled_images.indexOf(imageId);
610
+ if (uiIndex !== -1) state.config.unlabeled_images.splice(uiIndex, 1);
611
+ }
612
+
613
+ // load next or previous
614
+ const nextIndex = Math.min(state.currentIndex, state.imageIds.length - 1);
615
+ if (state.imageIds.length > 0 && nextIndex >= 0) {
616
+ await loadImage(nextIndex);
617
+ } else {
618
+ // no images left -> show upload screen
619
+ dom.workspaceScreen.classList.add('hidden');
620
+ dom.uploadScreen.classList.remove('hidden');
621
+ }
622
+ populateThumbnails();
623
+ updateProgress();
624
+ showToast('Image deleted from session', 'success');
625
+ } catch (error) {
626
+ console.error('Delete image error', error);
627
+ showToast('Failed to delete image', 'error');
628
+ }
629
+ });
630
+ }
631
+
632
+ dom.saveCropAsSampleBtn.addEventListener('click', async () => {
633
+ if (!state.cropper || !state.sessionId || state.currentIndex < 0) return;
634
+ const cropData = state.cropper.getData(true);
635
+ try {
636
+ const imageId = state.imageIds[state.currentIndex];
637
+ const result = await api.post('/apply_changes', {
638
+ session_id: state.sessionId,
639
+ image_id: imageId,
640
+ crop_details: cropData,
641
+ create_new_sample: true
642
+ });
643
+ if (result && result.new_image_id) {
644
+ // Add new sample to local state + UI
645
+ state.imageIds.push(result.new_image_id);
646
+ state.config.image_metadata[result.new_image_id] = result.metadata;
647
+ state.config.unlabeled_images = state.config.unlabeled_images || [];
648
+ state.config.unlabeled_images.push(result.new_image_id);
649
+ populateThumbnails();
650
+ updateProgress();
651
+ showToast('Saved cropped sample', 'success');
652
+ }
653
+ } catch (err) {
654
+ showToast('Failed to save crop as new sample', 'error');
655
+ }
656
+ });
657
+
658
+
659
+
660
+
661
+ // Aspect ratio lock toggle
662
+ dom.aspectRatioLock.addEventListener('change', (e) => {
663
+ if (state.cropper) {
664
+ state.cropper.setAspectRatio(e.target.checked ? 1 : NaN);
665
+ }
666
+ });
667
+
668
+ // Apply crop to all toggle
669
+ dom.applyCropAllToggle.addEventListener('change', (e) => {
670
+ state.applyCropToAll = e.target.checked;
671
+ });
672
+
673
+ // Labeling handlers
674
+ dom.varietySelect.addEventListener('change', updateDiseaseOptions);
675
+ dom.saveLabelBtn.addEventListener('click', handleSaveLabel);
676
+
677
+ // Export handlers
678
+ dom.exportBtn.addEventListener('click', async () => {
679
+ try {
680
+ const summary = await api.get(`/summary/${state.sessionId}`);
681
+ dom.exportSummary.innerHTML = `
682
+ <h4>Dataset Summary</h4>
683
+ <p><strong>Total Images:</strong> ${summary.labeled_count + summary.unlabeled_count}</p>
684
+ <p><strong>Labeled:</strong> ${summary.labeled_count}</p>
685
+ <p><strong>Unlabeled:</strong> ${summary.unlabeled_count}</p>
686
+ ${Object.keys(summary.class_counts).length > 0 ?
687
+ '<h5>Class Distribution:</h5><ul>' +
688
+ Object.entries(summary.class_counts).map(([key, count]) =>
689
+ `<li>${key}: ${count}</li>`).join('') +
690
+ '</ul>' : ''}
691
+ `;
692
+ dom.exportModal.classList.remove('hidden');
693
+ } catch (error) {
694
+ showToast('Failed to load summary', 'error');
695
+ }
696
+ });
697
+
698
+ dom.downloadZipBtn.addEventListener('click', handleDownloadZip);
699
+ dom.pushToHubBtn.addEventListener('click', handlePushToHub);
700
+
701
+ // Modal handlers
702
+ dom.exportModal.querySelector('.close-btn').addEventListener('click', () => {
703
+ dom.exportModal.classList.add('hidden');
704
+ });
705
+
706
+ // Help modal (if exists)
707
+ if (dom.helpBtn && dom.helpModal) {
708
+ dom.helpBtn.addEventListener('click', () => dom.helpModal.classList.remove('hidden'));
709
+ dom.helpModal.querySelector('.close-btn')?.addEventListener('click', () => {
710
+ dom.helpModal.classList.add('hidden');
711
+ });
712
+ }
713
+
714
+ // Keyboard shortcuts
715
+ document.addEventListener('keydown', (e) => {
716
+ if (dom.workspaceScreen && !dom.workspaceScreen.classList.contains('hidden')) {
717
+ switch(e.key) {
718
+ case 'ArrowLeft':
719
+ if (state.currentIndex > 0) {
720
+ e.preventDefault();
721
+ loadImage(state.currentIndex - 1);
722
+ }
723
+ break;
724
+ case 'ArrowRight':
725
+ if (state.currentIndex < state.imageIds.length - 1) {
726
+ e.preventDefault();
727
+ loadImage(state.currentIndex + 1);
728
+ }
729
+ break;
730
+ case 'Enter':
731
+ if (state.activeTab === 'label') {
732
+ e.preventDefault();
733
+ handleSaveLabel();
734
+ }
735
+ break;
736
+ }
737
+ }
738
+ });
739
+
740
+ // Initialize with tools tab
741
+ switchTab('tools');
742
+
743
+ console.log('Tulasi Data Curator initialized successfully');
744
+ }
745
+
746
+ // Start the application
747
+ init();
748
+ });
app/frontend/index.html ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Tulasi Data Curator</title>
7
+ <link rel="stylesheet" href="styles.css">
8
+ <link href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.1/cropper.min.css" rel="stylesheet">
9
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.1/cropper.min.js"></script>
10
+ </head>
11
+ <body>
12
+ <div id="app-container">
13
+ <!-- Header -->
14
+ <header id="app-header">
15
+ <h1>🌿 Tulasi Data Curator</h1>
16
+ <div id="progress-indicator">
17
+ <span id="progress-text">Upload a ZIP to begin</span>
18
+ </div>
19
+ <div class="header-actions">
20
+ <button id="help-btn">Disease Guide</button>
21
+ <button id="export-btn" class="primary-btn" disabled>Export & Push</button>
22
+ </div>
23
+ </header>
24
+
25
+ <!-- Main Content -->
26
+ <main id="main-content">
27
+ <!-- Upload Screen -->
28
+ <div id="upload-screen" class="screen">
29
+ <div class="upload-box">
30
+ <h2>Upload Your Image Dataset</h2>
31
+ <p>Select a ZIP file containing your Tulasi leaf images (up to 10GB)</p>
32
+ <button id="upload-btn" class="primary-btn">Choose ZIP File</button>
33
+ <input type="file" id="zip-upload-input" accept=".zip" hidden>
34
+ <div id="upload-progress" class="hidden">
35
+ <div class="spinner"></div>
36
+ <p>Processing your images...</p>
37
+ </div>
38
+ </div>
39
+ </div>
40
+
41
+ <!-- Workspace Screen -->
42
+ <div id="workspace-screen" class="screen hidden">
43
+ <!-- Left Panel - Thumbnails -->
44
+ <div id="left-panel" class="panel">
45
+ <h3>Images</h3>
46
+ <div id="thumbnail-grid"></div>
47
+ </div>
48
+
49
+ <!-- Center Panel - Image Viewer -->
50
+ <div id="center-panel" class="panel">
51
+ <div id="image-viewer"></div>
52
+ <div id="image-toolbar">
53
+ <div id="current-image-name">No image selected</div>
54
+ <div class="toolbar-buttons">
55
+ <button id="prev-btn" disabled>◀ Previous</button>
56
+ <button id="next-btn" disabled>Next ▶</button>
57
+ </div>
58
+ </div>
59
+ </div>
60
+
61
+ <!-- Right Panel - Tools & Labels -->
62
+ <div id="right-panel" class="panel">
63
+ <div id="tools-label-tabs">
64
+ <button id="tools-tab" class="tab-btn active">Tools</button>
65
+ <button id="label-tab" class="tab-btn">Label</button>
66
+ </div>
67
+
68
+ <!-- Tools Tab Content -->
69
+ <div id="tools-content" class="tab-content">
70
+ <div class="tool-group">
71
+ <h3>Cropping & resizing</h3>
72
+ <p>Draw a bounding box around the leaf area to make the image more clear.</p>
73
+
74
+ <div class="checkbox-group">
75
+ <input type="checkbox" id="aspect-ratio-lock">
76
+ <label for="aspect-ratio-lock">Lock aspect ratio (1:1)</label>
77
+ </div>
78
+
79
+ <div class="checkbox-group">
80
+ <input type="checkbox" id="apply-crop-all-toggle">
81
+ <label for="apply-crop-all-toggle">Apply crop to all images</label>
82
+ </div>
83
+
84
+ <div class="button-group">
85
+ <!-- <button id="segment-btn" class="primary-btn">Generate Mask</button> -->
86
+ <button id="apply-changes-btn">Apply Changes</button>
87
+ <button id="reset-image-btn">Reset Image</button>
88
+ <button id="save-crop-as-sample-btn" class="primary-btn">Save Crop as New Sample</button>
89
+ <button id="delete-image-btn" class="secondary-btn" style="background: #a94442; border-color:#a94442;">Delete Image</button>
90
+
91
+ </div>
92
+
93
+ <div id="mask-preview-container"></div>
94
+ </div>
95
+ </div>
96
+
97
+ <!-- Label Tab Content -->
98
+ <div id="label-content" class="tab-content hidden">
99
+ <h3>Label Current Image</h3>
100
+
101
+ <div class="form-group">
102
+ <label for="variety-select">Variety:</label>
103
+ <select id="variety-select">
104
+ <option value="">Select Variety</option>
105
+ </select>
106
+ </div>
107
+
108
+ <div class="form-group">
109
+ <label for="disease-select">Disease/Condition:</label>
110
+ <select id="disease-select">
111
+ <option value="">Select Disease</option>
112
+ </select>
113
+ </div>
114
+
115
+ <button id="save-label-btn" class="primary-btn">Save Label</button>
116
+
117
+ <div class="label-info">
118
+ <h4>Quick Reference</h4>
119
+ <p><strong>Healthy:</strong> Green, vibrant leaves</p>
120
+ <p><strong>Leaf Spot:</strong> Dark spots or patches</p>
121
+ <p><strong>Powdery Mildew:</strong> White powdery coating</p>
122
+ <p><strong>Bacterial Blight:</strong> Water-soaked lesions</p>
123
+ <p><strong>Nutrient Deficiency:</strong> Yellowing or discoloration</p>
124
+ </div>
125
+ </div>
126
+ </div>
127
+ </div>
128
+ </main>
129
+ </div>
130
+
131
+ <!-- Export & Push Modal -->
132
+ <div id="export-modal" class="modal hidden">
133
+ <div class="modal-content">
134
+ <span class="close-btn">&times;</span>
135
+ <h2>Export & Push to Hub</h2>
136
+ <div id="export-summary"></div>
137
+
138
+ <hr>
139
+
140
+ <div class="export-section">
141
+ <h3>1. Download Your Dataset</h3>
142
+ <p>Download the prepared dataset as a ZIP file to your local machine.</p>
143
+ <button id="download-zip-btn" class="secondary-btn">Download Dataset ZIP</button>
144
+ </div>
145
+
146
+ <hr>
147
+
148
+ <div class="export-section">
149
+ <h3>2. Publish to Hugging Face Hub</h3>
150
+ <div class="warning-banner">
151
+ <strong>Note:</strong> This requires HF_TOKEN to be set in the Space settings.
152
+ </div>
153
+
154
+ <div class="form-group">
155
+ <label for="hub-repo-name">Repository Name</label>
156
+ <input type="text" id="hub-repo-name" placeholder="e.g., tulasi-leaf-dataset">
157
+ </div>
158
+
159
+ <div class="checkbox-group">
160
+ <input type="checkbox" id="hub-private-repo" checked>
161
+ <label for="hub-private-repo">Make repository private</label>
162
+ </div>
163
+
164
+ <button id="push-to-hub-btn" class="primary-btn">Push to Hub</button>
165
+ <div id="push-status" class="hidden"></div>
166
+ </div>
167
+ </div>
168
+ </div>
169
+
170
+ <!-- Help Modal -->
171
+ <div id="help-modal" class="modal hidden">
172
+ <div class="modal-content">
173
+ <span class="close-btn">&times;</span>
174
+ <h2>Disease Guide</h2>
175
+
176
+ <div class="disease-guide">
177
+ <h3>Common Tulasi Leaf Diseases</h3>
178
+
179
+ <div class="disease-item">
180
+ <h4>Healthy</h4>
181
+ <p>Vibrant green leaves with no visible damage or discoloration.</p>
182
+ </div>
183
+
184
+ <div class="disease-item">
185
+ <h4>Leaf Spot</h4>
186
+ <p>Dark brown or black circular spots on leaves, often with yellow halos.</p>
187
+ </div>
188
+
189
+ <div class="disease-item">
190
+ <h4>Powdery Mildew</h4>
191
+ <p>White powdery coating on leaf surfaces, usually on upper side.</p>
192
+ </div>
193
+
194
+ <div class="disease-item">
195
+ <h4>Downy Mildew</h4>
196
+ <p>Yellow patches on upper leaf surface with fuzzy growth underneath.</p>
197
+ </div>
198
+
199
+ <div class="disease-item">
200
+ <h4>Bacterial Blight</h4>
201
+ <p>Water-soaked lesions that turn brown/black, often with yellow margins.</p>
202
+ </div>
203
+
204
+ <div class="disease-item">
205
+ <h4>Nutrient Deficiency</h4>
206
+ <p>Yellowing leaves, stunted growth, or unusual discoloration patterns.</p>
207
+ </div>
208
+
209
+ <div class="disease-item">
210
+ <h4>Insect Damage</h4>
211
+ <p>Holes, chewed edges, or visible insect feeding marks on leaves.</p>
212
+ </div>
213
+
214
+ <div class="disease-item">
215
+ <h4>Drought/Scorch</h4>
216
+ <p>Brown, crispy edges or wilted appearance due to water stress.</p>
217
+ </div>
218
+
219
+ <div class="disease-item">
220
+ <h4>Mechanical Damage</h4>
221
+ <p>Physical tears, cuts, or bruising from handling or environmental factors.</p>
222
+ </div>
223
+
224
+ <div class="disease-item">
225
+ <h4>Unknown</h4>
226
+ <p>Symptoms that don't clearly match other categories or unclear conditions.</p>
227
+ </div>
228
+ </div>
229
+ </div>
230
+ </div>
231
+
232
+ <!-- Toast Notification -->
233
+ <div id="toast" class="hidden"></div>
234
+
235
+ <script src="app.js"></script>
236
+ </body>
237
+ </html>
app/frontend/styles.css ADDED
@@ -0,0 +1,820 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* /app/frontend/styles.css - Dark Mode Theme */
2
+ :root {
3
+ --primary-color: #4CAF50;
4
+ --primary-hover: #66bb6a;
5
+ --secondary-color: #2196F3;
6
+ --secondary-hover: #42A5F5;
7
+ --bg-color: #121212;
8
+ --surface-color: #1e1e1e;
9
+ --panel-color: #2a2a2a;
10
+ --border-color: #444;
11
+ --text-color: #e0e0e0;
12
+ --text-muted: #888;
13
+ --success-color: #4CAF50;
14
+ --warning-color: #FF9800;
15
+ --error-color: #F44336;
16
+ --font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
17
+ }
18
+
19
+ * {
20
+ box-sizing: border-box;
21
+ }
22
+
23
+ /* Utility: hide/show toggled by JS */
24
+ .hidden {
25
+ display: none !important;
26
+ }
27
+
28
+
29
+ img {
30
+ max-width: 100%;
31
+ height: auto;
32
+ }
33
+
34
+ canvas, .cropper-container {
35
+ max-width: 100%;
36
+ }
37
+
38
+ body {
39
+ font-family: var(--font-family);
40
+ margin: 0;
41
+ background-color: var(--bg-color);
42
+ color: var(--text-color);
43
+ display: flex;
44
+ justify-content: center;
45
+ align-items: center;
46
+ min-height: 100vh;
47
+ font-size: 14px;
48
+ }
49
+
50
+ #app-container {
51
+ width: 100%;
52
+ max-width: 1600px;
53
+ height: 95vh;
54
+ background: var(--surface-color);
55
+ border-radius: 8px;
56
+ border: 1px solid var(--border-color);
57
+ display: flex;
58
+ flex-direction: column;
59
+ overflow: hidden;
60
+ }
61
+
62
+ /* Header */
63
+ #app-header {
64
+ display: flex;
65
+ align-items: center;
66
+ justify-content: space-between;
67
+ padding: 10px 20px;
68
+ border-bottom: 1px solid var(--border-color);
69
+ background: var(--surface-color);
70
+ min-height: 60px;
71
+ }
72
+
73
+ #app-header h1 {
74
+ font-size: 1.4em;
75
+ margin: 0;
76
+ color: var(--text-color);
77
+ font-weight: 600;
78
+ }
79
+
80
+ #progress-indicator {
81
+ font-weight: 500;
82
+ background: var(--panel-color);
83
+ padding: 8px 20px;
84
+ border-radius: 20px;
85
+ color: var(--text-muted);
86
+ border: 1px solid var(--border-color);
87
+ }
88
+
89
+ .header-actions {
90
+ display: flex;
91
+ gap: 10px;
92
+ }
93
+
94
+ /* Main Content & Panels */
95
+ #main-content {
96
+ flex-grow: 1;
97
+ display: flex;
98
+ overflow: hidden;
99
+ }
100
+
101
+ .screen {
102
+ width: 100%;
103
+ height: 100%;
104
+ display: flex;
105
+ }
106
+
107
+ .screen.hidden {
108
+ display: none;
109
+ }
110
+
111
+ #workspace-screen {
112
+ padding: 10px;
113
+ gap: 10px;
114
+ }
115
+
116
+ .panel {
117
+ background: var(--surface-color);
118
+ border: 1px solid var(--border-color);
119
+ border-radius: 6px;
120
+ display: flex;
121
+ flex-direction: column;
122
+ overflow: hidden;
123
+ }
124
+
125
+ #left-panel {
126
+ flex: 0 0 280px;
127
+ }
128
+
129
+ #center-panel {
130
+ flex: 1 1 auto;
131
+ background: var(--bg-color);
132
+ min-width: 400px;
133
+ }
134
+
135
+ #right-panel {
136
+ flex: 0 0 380px;
137
+ }
138
+
139
+ /* Upload Screen */
140
+ #upload-screen {
141
+ flex-direction: column;
142
+ justify-content: center;
143
+ align-items: center;
144
+ background: var(--bg-color);
145
+ }
146
+
147
+ .upload-box {
148
+ text-align: center;
149
+ border: 2px dashed var(--border-color);
150
+ padding: 60px 40px;
151
+ border-radius: 12px;
152
+ background: var(--surface-color);
153
+ max-width: 600px;
154
+ transition: border-color 0.3s ease;
155
+ }
156
+
157
+ .upload-box:hover {
158
+ border-color: var(--primary-color);
159
+ }
160
+
161
+ .upload-box h2 {
162
+ margin: 0 0 15px 0;
163
+ color: var(--text-color);
164
+ font-size: 1.8em;
165
+ }
166
+
167
+ .upload-box p {
168
+ margin: 0 0 25px 0;
169
+ color: var(--text-muted);
170
+ font-size: 1.1em;
171
+ }
172
+
173
+ /* Left Panel - Thumbnails */
174
+ #left-panel h3 {
175
+ margin: 0;
176
+ padding: 15px 20px;
177
+ font-size: 1.1em;
178
+ border-bottom: 1px solid var(--border-color);
179
+ background: var(--panel-color);
180
+ font-weight: 600;
181
+ }
182
+
183
+ #thumbnail-grid {
184
+ padding: 15px;
185
+ display: grid;
186
+ grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
187
+ gap: 12px;
188
+ overflow-y: auto;
189
+ flex-grow: 1;
190
+ }
191
+
192
+ .thumbnail {
193
+ width: 100%;
194
+ aspect-ratio: 1 / 1;
195
+ object-fit: cover;
196
+ border-radius: 6px;
197
+ cursor: pointer;
198
+ border: 2px solid transparent;
199
+ transition: all 0.2s ease;
200
+ }
201
+
202
+ .thumbnail:hover {
203
+ border-color: var(--text-muted);
204
+ transform: scale(1.02);
205
+ }
206
+
207
+ .thumbnail.active {
208
+ border-color: var(--primary-color);
209
+ transform: scale(1.05);
210
+ box-shadow: 0 0 15px rgba(76, 175, 80, 0.3);
211
+ }
212
+
213
+ .thumbnail.labeled {
214
+ opacity: 0.5;
215
+ position: relative;
216
+ }
217
+
218
+ .thumbnail.labeled::after {
219
+ content: "✓";
220
+ position: absolute;
221
+ top: 5px;
222
+ right: 5px;
223
+ background: var(--success-color);
224
+ color: white;
225
+ border-radius: 50%;
226
+ width: 20px;
227
+ height: 20px;
228
+ display: flex;
229
+ align-items: center;
230
+ justify-content: center;
231
+ font-size: 12px;
232
+ font-weight: bold;
233
+ }
234
+
235
+ /* Center Panel - Image Viewer */
236
+ #image-viewer {
237
+ flex-grow: 1;
238
+ display: flex;
239
+ justify-content: center;
240
+ align-items: center;
241
+ overflow: hidden;
242
+ padding: 10px;
243
+ background: var(--bg-color);
244
+ }
245
+
246
+ #image-viewer img {
247
+ display: block;
248
+ max-width: 100%;
249
+ max-height: 100%;
250
+ border-radius: 4px;
251
+ }
252
+
253
+ #image-toolbar {
254
+ display: flex;
255
+ justify-content: space-between;
256
+ align-items: center;
257
+ padding: 12px 15px;
258
+ border-top: 1px solid var(--border-color);
259
+ background: var(--panel-color);
260
+ }
261
+
262
+ #current-image-name {
263
+ font-size: 0.95em;
264
+ color: var(--text-color);
265
+ font-weight: 500;
266
+ }
267
+
268
+ .toolbar-buttons {
269
+ display: flex;
270
+ gap: 10px;
271
+ }
272
+
273
+ /* Right Panel - Tabs */
274
+ #tools-label-tabs {
275
+ display: flex;
276
+ border-bottom: 1px solid var(--border-color);
277
+ }
278
+
279
+ .tab-btn {
280
+ flex: 1;
281
+ padding: 15px 12px;
282
+ background: var(--surface-color);
283
+ border: none;
284
+ cursor: pointer;
285
+ font-size: 1em;
286
+ color: var(--text-muted);
287
+ border-bottom: 3px solid transparent;
288
+ transition: all 0.2s ease;
289
+ font-weight: 500;
290
+ }
291
+
292
+ .tab-btn:hover {
293
+ background: var(--panel-color);
294
+ color: var(--text-color);
295
+ }
296
+
297
+ .tab-btn.active {
298
+ color: var(--primary-color);
299
+ font-weight: 600;
300
+ border-bottom-color: var(--primary-color);
301
+ background: var(--panel-color);
302
+ }
303
+
304
+ .tab-content {
305
+ padding: 20px;
306
+ overflow-y: auto;
307
+ flex-grow: 1;
308
+ }
309
+
310
+ .tab-content.hidden {
311
+ display: none;
312
+ }
313
+
314
+ .tab-content h3 {
315
+ margin-top: 0;
316
+ font-size: 1.2em;
317
+ color: var(--text-color);
318
+ margin-bottom: 20px;
319
+ font-weight: 600;
320
+ }
321
+
322
+ /* Form Elements */
323
+ .form-group {
324
+ margin-bottom: 20px;
325
+ }
326
+
327
+ .form-group label {
328
+ display: block;
329
+ margin-bottom: 8px;
330
+ font-weight: 600;
331
+ color: var(--text-color);
332
+ }
333
+
334
+ select, input[type="text"] {
335
+ width: 100%;
336
+ padding: 12px;
337
+ border: 1px solid var(--border-color);
338
+ border-radius: 6px;
339
+ background: var(--panel-color);
340
+ color: var(--text-color);
341
+ font-size: 14px;
342
+ transition: border-color 0.2s ease;
343
+ }
344
+
345
+ select:focus, input[type="text"]:focus {
346
+ outline: none;
347
+ border-color: var(--primary-color);
348
+ box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.2);
349
+ }
350
+
351
+ .checkbox-group {
352
+ display: flex;
353
+ align-items: center;
354
+ margin: 15px 0;
355
+ }
356
+
357
+ .checkbox-group input[type="checkbox"] {
358
+ width: auto;
359
+ margin-right: 10px;
360
+ accent-color: var(--primary-color);
361
+ }
362
+
363
+ .checkbox-group label {
364
+ margin-bottom: 0;
365
+ font-weight: normal;
366
+ cursor: pointer;
367
+ }
368
+
369
+ /* Tool Groups */
370
+ .tool-group {
371
+ margin-bottom: 25px;
372
+ border: 1px solid var(--border-color);
373
+ border-radius: 8px;
374
+ padding: 20px;
375
+ background: var(--bg-color);
376
+ }
377
+
378
+ .tool-group h3 {
379
+ margin-top: 0;
380
+ margin-bottom: 15px;
381
+ color: var(--primary-color);
382
+ font-size: 1.1em;
383
+ }
384
+
385
+ .tool-group p {
386
+ color: var(--text-muted);
387
+ margin-bottom: 20px;
388
+ line-height: 1.5;
389
+ }
390
+
391
+ .button-group {
392
+ display: flex;
393
+ flex-direction: column;
394
+ gap: 10px;
395
+ margin-top: 15px;
396
+ }
397
+
398
+ #mask-preview-container {
399
+ margin-top: 20px;
400
+ padding: 15px;
401
+ background: var(--surface-color);
402
+ border-radius: 6px;
403
+ border: 1px solid var(--border-color);
404
+ }
405
+
406
+ #mask-preview-container h4 {
407
+ margin: 0 0 10px 0;
408
+ color: var(--text-color);
409
+ }
410
+
411
+ #mask-preview-container img {
412
+ max-width: 100%;
413
+ border-radius: 4px;
414
+ border: 1px solid var(--border-color);
415
+ }
416
+
417
+ /* Label Info */
418
+ .label-info {
419
+ background: var(--bg-color);
420
+ padding: 20px;
421
+ border-radius: 8px;
422
+ border: 1px solid var(--border-color);
423
+ margin-top: 25px;
424
+ }
425
+
426
+ .label-info h4 {
427
+ margin: 0 0 15px 0;
428
+ color: var(--primary-color);
429
+ }
430
+
431
+ .label-info p {
432
+ margin: 8px 0;
433
+ font-size: 13px;
434
+ line-height: 1.4;
435
+ }
436
+
437
+ .label-info strong {
438
+ color: var(--text-color);
439
+ }
440
+
441
+ /* Buttons */
442
+ button {
443
+ padding: 12px 18px;
444
+ border: 1px solid var(--border-color);
445
+ border-radius: 6px;
446
+ background: var(--panel-color);
447
+ cursor: pointer;
448
+ font-size: 14px;
449
+ color: var(--text-color);
450
+ transition: all 0.2s ease;
451
+ font-weight: 500;
452
+ font-family: inherit;
453
+ }
454
+
455
+ button:hover {
456
+ background-color: #3a3a3a;
457
+ border-color: var(--text-muted);
458
+ transform: translateY(-1px);
459
+ }
460
+
461
+ button:active {
462
+ transform: translateY(0);
463
+ }
464
+
465
+ button:disabled {
466
+ cursor: not-allowed;
467
+ opacity: 0.5;
468
+ transform: none;
469
+ }
470
+
471
+ .primary-btn {
472
+ background: var(--primary-color);
473
+ border-color: var(--primary-color);
474
+ color: white;
475
+ font-weight: 600;
476
+ }
477
+
478
+ .primary-btn:hover:not(:disabled) {
479
+ background: var(--primary-hover);
480
+ border-color: var(--primary-hover);
481
+ }
482
+
483
+ .secondary-btn {
484
+ background: var(--secondary-color);
485
+ border-color: var(--secondary-color);
486
+ color: white;
487
+ font-weight: 600;
488
+ }
489
+
490
+ .secondary-btn:hover:not(:disabled) {
491
+ background: var(--secondary-hover);
492
+ border-color: var(--secondary-hover);
493
+ }
494
+
495
+ /* Modals */
496
+ .modal {
497
+ position: fixed;
498
+ z-index: 1000;
499
+ left: 0;
500
+ top: 0;
501
+ width: 100%;
502
+ height: 100%;
503
+ background-color: rgba(0,0,0,0.8);
504
+ display: flex;
505
+ justify-content: center;
506
+ align-items: center;
507
+ }
508
+
509
+ .modal.hidden {
510
+ display: none;
511
+ }
512
+
513
+ .modal-content {
514
+ background-color: var(--surface-color);
515
+ padding: 30px;
516
+ border-radius: 12px;
517
+ width: 90%;
518
+ max-width: 600px;
519
+ max-height: 80vh;
520
+ overflow-y: auto;
521
+ border: 1px solid var(--border-color);
522
+ box-shadow: 0 10px 30px rgba(0,0,0,0.5);
523
+ }
524
+
525
+ .close-btn {
526
+ color: var(--text-muted);
527
+ float: right;
528
+ font-size: 28px;
529
+ font-weight: bold;
530
+ cursor: pointer;
531
+ line-height: 1;
532
+ margin-top: -10px;
533
+ margin-right: -10px;
534
+ transition: color 0.2s ease;
535
+ }
536
+
537
+ .close-btn:hover {
538
+ color: var(--text-color);
539
+ }
540
+
541
+ .modal h2 {
542
+ margin: 0 0 25px 0;
543
+ color: var(--text-color);
544
+ font-size: 1.5em;
545
+ font-weight: 600;
546
+ }
547
+
548
+ .modal hr {
549
+ border: none;
550
+ border-top: 1px solid var(--border-color);
551
+ margin: 25px 0;
552
+ }
553
+
554
+ /* Export Modal */
555
+ .export-section {
556
+ margin: 20px 0;
557
+ }
558
+
559
+ .export-section h3 {
560
+ color: var(--primary-color);
561
+ margin-bottom: 10px;
562
+ font-size: 1.2em;
563
+ }
564
+
565
+ .export-section p {
566
+ color: var(--text-muted);
567
+ margin-bottom: 15px;
568
+ line-height: 1.5;
569
+ }
570
+
571
+ .warning-banner {
572
+ background-color: rgba(255, 152, 0, 0.1);
573
+ border: 1px solid var(--warning-color);
574
+ padding: 15px;
575
+ border-radius: 6px;
576
+ margin-bottom: 20px;
577
+ font-size: 14px;
578
+ }
579
+
580
+ .warning-banner strong {
581
+ color: var(--warning-color);
582
+ }
583
+
584
+ #export-summary {
585
+ background: var(--bg-color);
586
+ padding: 20px;
587
+ border-radius: 8px;
588
+ border: 1px solid var(--border-color);
589
+ margin-bottom: 20px;
590
+ }
591
+
592
+ #export-summary h4 {
593
+ margin: 0 0 15px 0;
594
+ color: var(--primary-color);
595
+ }
596
+
597
+ #export-summary p {
598
+ margin: 8px 0;
599
+ color: var(--text-color);
600
+ }
601
+
602
+ #export-summary h5 {
603
+ margin: 15px 0 10px 0;
604
+ color: var(--text-color);
605
+ }
606
+
607
+ #export-summary ul {
608
+ margin: 0;
609
+ padding-left: 20px;
610
+ }
611
+
612
+ #export-summary li {
613
+ color: var(--text-muted);
614
+ margin: 5px 0;
615
+ }
616
+
617
+ #push-status {
618
+ margin-top: 15px;
619
+ padding: 15px;
620
+ background: var(--bg-color);
621
+ border-radius: 6px;
622
+ border: 1px solid var(--border-color);
623
+ font-size: 14px;
624
+ }
625
+
626
+ #push-status.hidden {
627
+ display: none;
628
+ }
629
+
630
+ #push-status a {
631
+ color: var(--primary-color);
632
+ text-decoration: none;
633
+ }
634
+
635
+ #push-status a:hover {
636
+ text-decoration: underline;
637
+ }
638
+
639
+ /* Disease Guide */
640
+ .disease-guide {
641
+ max-height: 400px;
642
+ overflow-y: auto;
643
+ }
644
+
645
+ .disease-item {
646
+ margin-bottom: 20px;
647
+ padding: 15px;
648
+ background: var(--bg-color);
649
+ border-radius: 6px;
650
+ border: 1px solid var(--border-color);
651
+ }
652
+
653
+ .disease-item h4 {
654
+ margin: 0 0 8px 0;
655
+ color: var(--primary-color);
656
+ font-size: 1.1em;
657
+ }
658
+
659
+ .disease-item p {
660
+ margin: 0;
661
+ color: var(--text-muted);
662
+ line-height: 1.5;
663
+ }
664
+
665
+ /* Toast Notification */
666
+ #toast {
667
+ position: fixed;
668
+ bottom: 20px;
669
+ left: 50%;
670
+ transform: translateX(-50%);
671
+ padding: 15px 25px;
672
+ border-radius: 25px;
673
+ background-color: var(--panel-color);
674
+ color: var(--text-color);
675
+ z-index: 2000;
676
+ opacity: 0;
677
+ transition: all 0.3s ease;
678
+ border: 1px solid var(--border-color);
679
+ font-weight: 500;
680
+ box-shadow: 0 5px 15px rgba(0,0,0,0.3);
681
+ }
682
+
683
+ #toast.show {
684
+ opacity: 1;
685
+ bottom: 30px;
686
+ }
687
+
688
+ #toast.success {
689
+ background-color: var(--success-color);
690
+ color: white;
691
+ border-color: var(--success-color);
692
+ }
693
+
694
+ #toast.error {
695
+ background-color: var(--error-color);
696
+ color: white;
697
+ border-color: var(--error-color);
698
+ }
699
+
700
+ /* Spinner */
701
+ .spinner {
702
+ border: 4px solid var(--border-color);
703
+ border-top: 4px solid var(--primary-color);
704
+ border-radius: 50%;
705
+ width: 40px;
706
+ height: 40px;
707
+ animation: spin 1s linear infinite;
708
+ margin: 0 auto 15px;
709
+ }
710
+
711
+ @keyframes spin {
712
+ 0% { transform: rotate(0deg); }
713
+ 100% { transform: rotate(360deg); }
714
+ }
715
+
716
+ /* Cropper.js Customizations */
717
+ .cropper-view-box {
718
+ outline: 2px solid var(--primary-color) !important;
719
+ }
720
+
721
+ .cropper-face {
722
+ background-color: rgba(76, 175, 80, 0.1) !important;
723
+ }
724
+
725
+ .cropper-line,
726
+ .cropper-point {
727
+ background-color: var(--primary-color) !important;
728
+ }
729
+
730
+ .cropper-point.point-se {
731
+ width: 8px !important;
732
+ height: 8px !important;
733
+ }
734
+
735
+ /* Responsive Design */
736
+ @media (max-width: 1200px) {
737
+ #left-panel {
738
+ flex: 0 0 220px;
739
+ }
740
+
741
+ #right-panel {
742
+ flex: 0 0 320px;
743
+ }
744
+ }
745
+
746
+ @media (max-width: 900px) {
747
+ #workspace-screen {
748
+ flex-direction: column;
749
+ }
750
+
751
+ #left-panel,
752
+ #right-panel {
753
+ flex: 0 0 200px;
754
+ }
755
+
756
+ #center-panel {
757
+ flex: 1 1 300px;
758
+ }
759
+
760
+ #thumbnail-grid {
761
+ grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
762
+ gap: 8px;
763
+ padding: 10px;
764
+ }
765
+ }
766
+
767
+ @media (max-width: 600px) {
768
+ #app-container {
769
+ height: 100vh;
770
+ border-radius: 0;
771
+ }
772
+
773
+ #app-header {
774
+ flex-direction: column;
775
+ gap: 10px;
776
+ padding: 15px;
777
+ }
778
+
779
+ #app-header h1 {
780
+ font-size: 1.2em;
781
+ }
782
+
783
+ .upload-box {
784
+ padding: 40px 20px;
785
+ margin: 20px;
786
+ }
787
+
788
+ .modal-content {
789
+ padding: 20px;
790
+ margin: 20px;
791
+ width: calc(100% - 40px);
792
+ }
793
+ }
794
+
795
+ /* Scrollbar Styling */
796
+ ::-webkit-scrollbar {
797
+ width: 8px;
798
+ height: 8px;
799
+ }
800
+
801
+ ::-webkit-scrollbar-track {
802
+ background: var(--bg-color);
803
+ }
804
+
805
+ ::-webkit-scrollbar-thumb {
806
+ background: var(--border-color);
807
+ border-radius: 4px;
808
+ }
809
+
810
+ ::-webkit-scrollbar-thumb:hover {
811
+ background: var(--text-muted);
812
+ }
813
+
814
+ /* Focus States */
815
+ button:focus,
816
+ select:focus,
817
+ input:focus {
818
+ outline: 2px solid var(--primary-color);
819
+ outline-offset: 2px;
820
+ }
requirements.txt ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.104.1
2
+ uvicorn[standard]==0.24.0
3
+ python-multipart==0.0.6
4
+ aiofiles==23.2.1
5
+ opencv-python-headless==4.8.1.78
6
+ numpy==1.24.3
7
+ Pillow==10.1.0
8
+ pydantic==2.5.0
9
+ huggingface-hub==0.19.4
10
+ pathlib==1.0.1
11
+ datasets
12
+ pyarrow
13
+ python-dotenv