hassanshka commited on
Commit
989ec3c
·
1 Parent(s): 0517961

Add missing important files: _app_.py, utils/, CVAT_download/, manifest.json, and documentation

Browse files
CVAT_download/download.py ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from cvat_sdk import make_client
2
+ from cvat_sdk.core.client import Config
3
+ import os
4
+ from pathlib import Path
5
+ import urllib3
6
+
7
+ # Disable SSL warnings for self-signed certificates
8
+ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
9
+
10
+ HOST = "http://134.76.21.30:8080"
11
+ USERNAME = "XXXXXX"
12
+ PASSWORD = "XXXXXXX"
13
+ PROJECT_ID = 7
14
+
15
+ # Base output directory
16
+ OUTPUT_ROOT = Path(f"cvat_project_{PROJECT_ID}_export")
17
+
18
+ def main():
19
+ OUTPUT_ROOT.mkdir(parents=True, exist_ok=True)
20
+
21
+ # Connect to CVAT
22
+ with make_client(HOST, credentials=(USERNAME, PASSWORD)) as client:
23
+ # Disable SSL verification - CVAT returns HTTPS URLs for downloads even when connecting via HTTP
24
+ client.config.verify_ssl = False
25
+ # Optional: if you use organizations, set it here:
26
+ # client.config.org_slug = "eManusKript"
27
+
28
+ project = client.projects.retrieve(PROJECT_ID)
29
+ print(f"Project: {project.name} (ID={project.id})")
30
+
31
+ # Get all tasks belonging to this project
32
+ tasks = project.get_tasks()
33
+ print(f"Found {len(tasks)} tasks in project {PROJECT_ID}")
34
+
35
+ for t in tasks:
36
+ task_id = t.id
37
+ task_name = t.name
38
+ task_name_sanitized = "".join(c if c.isalnum() or c in "-_ " else "_" for c in task_name)
39
+ task_dir = OUTPUT_ROOT / f"task_{task_id}_{task_name_sanitized}"
40
+ task_dir.mkdir(parents=True, exist_ok=True)
41
+
42
+ print(f"\n=== Task {task_id}: {task_name} ===")
43
+
44
+ # Retrieve the full Task proxy object (not just TaskRead model)
45
+ task = client.tasks.retrieve(task_id)
46
+
47
+ # 1) Download images with original filenames
48
+ images_dir = task_dir / "images"
49
+ images_dir.mkdir(exist_ok=True)
50
+
51
+ from PIL import Image
52
+ from io import BytesIO
53
+
54
+ # Get frames info
55
+ frames_info = task.get_frames_info()
56
+ if not frames_info:
57
+ print(f" No frames found in task {task_id}")
58
+ else:
59
+ # Check if images already downloaded
60
+ existing_images = list(images_dir.glob("*"))
61
+ if len(existing_images) == len(frames_info):
62
+ print(f" Images already exist in {images_dir} ({len(frames_info)} images)")
63
+ else:
64
+ print(f" Downloading {len(frames_info)} images to {images_dir} ...")
65
+
66
+ for idx, frame_info in enumerate(frames_info):
67
+ frame_id = idx # Frame IDs are 0-indexed
68
+ # frame_info is a dict with 'name', 'height', 'width', etc.
69
+ original_name = frame_info.get('name', f'frame_{frame_id:06d}.jpg')
70
+ # Ensure we have an extension
71
+ if '.' not in original_name:
72
+ original_name += '.jpg'
73
+
74
+ output_path = images_dir / original_name
75
+ if output_path.exists():
76
+ continue
77
+
78
+ try:
79
+ frame_bytes = task.get_frame(frame_id, quality="original")
80
+ # get_frame returns a response object, read it
81
+ img_data = frame_bytes.read()
82
+ img = Image.open(BytesIO(img_data))
83
+ img.save(output_path)
84
+ if (idx + 1) % 10 == 0 or (idx + 1) == len(frames_info):
85
+ print(f" Downloaded {idx + 1}/{len(frames_info)} images...")
86
+ except Exception as e:
87
+ print(f" Error downloading frame {frame_id} ({original_name}): {e}")
88
+
89
+ # 2) Export annotations in COCO 1.0 (without images since we download them separately)
90
+ anno_zip = task_dir / f"task_{task_id}_coco1.0.zip"
91
+ if not anno_zip.exists():
92
+ print(f" Exporting COCO 1.0 annotations to {anno_zip} ...")
93
+ # Replace pool manager BEFORE export_dataset call to handle HTTPS downloads
94
+ import ssl
95
+ from urllib3.poolmanager import PoolManager
96
+ from cvat_sdk.core.downloading import Downloader
97
+
98
+ old_pool = client.api_client.rest_client.pool_manager
99
+ # Replace pool manager to disable SSL verification for HTTPS downloads
100
+ client.api_client.rest_client.pool_manager = PoolManager(
101
+ cert_reqs=ssl.CERT_NONE
102
+ )
103
+ try:
104
+ # Use the downloader directly to have more control
105
+ downloader = Downloader(client)
106
+
107
+ # Prepare the export using the same endpoint as export_dataset
108
+ print(f" Preparing export...")
109
+ export_request = downloader.prepare_file(
110
+ task.api.create_dataset_export_endpoint,
111
+ url_params={"id": task_id},
112
+ query_params={
113
+ "format": "COCO 1.0",
114
+ "save_images": "false"
115
+ }
116
+ )
117
+
118
+ if not export_request.result_url:
119
+ raise Exception("Export completed but no result URL returned")
120
+
121
+ # Convert HTTPS URL to HTTP if needed
122
+ result_url = export_request.result_url
123
+ if result_url.startswith("https://"):
124
+ result_url = result_url.replace("https://", "http://", 1)
125
+ print(f" Converted HTTPS URL to HTTP: {result_url[:80]}...")
126
+
127
+ # Download the file
128
+ print(f" Downloading from result URL...")
129
+ downloader.download_file(result_url, output_path=Path(anno_zip))
130
+ print(f" Successfully downloaded annotations")
131
+
132
+ except Exception as e:
133
+ print(f" Error exporting annotations: {e}")
134
+ import traceback
135
+ traceback.print_exc()
136
+ # Try with images included as fallback
137
+ print(f" Retrying with images included...")
138
+ try:
139
+ export_request = downloader.prepare_file(
140
+ task.api.create_dataset_export_endpoint,
141
+ url_params={"id": task_id},
142
+ query_params={
143
+ "format": "COCO 1.0",
144
+ "save_images": "true"
145
+ }
146
+ )
147
+ result_url = export_request.result_url
148
+ if result_url and result_url.startswith("https://"):
149
+ result_url = result_url.replace("https://", "http://", 1)
150
+ downloader.download_file(result_url, output_path=Path(anno_zip))
151
+ print(f" Successfully downloaded annotations with images")
152
+ except Exception as e2:
153
+ print(f" Failed again: {e2}")
154
+ raise
155
+ finally:
156
+ # Restore original pool manager after export completes
157
+ client.api_client.rest_client.pool_manager = old_pool
158
+ else:
159
+ print(f" Annotations already exist: {anno_zip}")
160
+
161
+ print(f"\nDone. All data saved under: {OUTPUT_ROOT.resolve()}")
162
+
163
+ if __name__ == "__main__":
164
+ main()
CVAT_download/unzip.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import zipfile
3
+
4
+ def unzip_all(directory):
5
+ """
6
+ Recursively finds all .zip files in the directory and unzips them
7
+ in the same location as the zip file.
8
+ """
9
+ for root, dirs, files in os.walk(directory):
10
+ for filename in files:
11
+ if filename.lower().endswith('.zip'):
12
+ zip_path = os.path.join(root, filename)
13
+ # Unzip in the same directory as the zip file
14
+ print(f"Unzipping {zip_path}...")
15
+ try:
16
+ with zipfile.ZipFile(zip_path, 'r') as zip_ref:
17
+ zip_ref.extractall(root)
18
+ print(f"Done unzipping {zip_path}")
19
+ except zipfile.BadZipFile:
20
+ print(f"Warning: {zip_path} is not a valid zip file, skipping...")
21
+ except Exception as e:
22
+ print(f"Error unzipping {zip_path}: {e}")
23
+
24
+ if __name__ == "__main__":
25
+ import sys
26
+ if len(sys.argv) < 2:
27
+ print("Usage: python unzip.py <directory>")
28
+ else:
29
+ unzip_all(sys.argv[1])
MODEL_COMBINATION_GUIDE.md ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Model Combination Guide
2
+
3
+ ## Overview
4
+
5
+ This guide explains how to combine predictions from three YOLO models to produce a unified COCO-format output with only the classes defined in `coco_class_mapping`.
6
+
7
+ ## The Three Models
8
+
9
+ ### 1. **best_emanuskript_segmentation.pt**
10
+ - **Type**: Segmentation model
11
+ - **Classes**: 21 classes including:
12
+ - Border, Table, Diagram, Music
13
+ - Main script black/coloured
14
+ - Variant script black/coloured
15
+ - Plain initial (coloured/highlighted/black)
16
+ - Historiated, Inhabited, Embellished
17
+ - Page Number, Quire Mark, Running header, Catchword, Gloss, Illustrations
18
+
19
+ ### 2. **best_catmus.pt**
20
+ - **Type**: Segmentation model
21
+ - **Classes**: 19 classes including:
22
+ - DefaultLine, InterlinearLine
23
+ - MainZone, MarginTextZone
24
+ - DropCapitalZone, GraphicZone, MusicZone
25
+ - NumberingZone, QuireMarksZone, RunningTitleZone
26
+ - StampZone, TitlePageZone
27
+
28
+ ### 3. **best_zone_detection.pt**
29
+ - **Type**: Detection model
30
+ - **Classes**: 11 zone classes:
31
+ - MainZone, MarginTextZone
32
+ - DropCapitalZone, GraphicZone, MusicZone
33
+ - NumberingZone, QuireMarksZone, RunningTitleZone
34
+ - StampZone, TitlePageZone, DigitizationArtefactZone
35
+
36
+ ## How It Works
37
+
38
+ ### Step 1: Run Model Predictions
39
+ Each model is run independently on the input image:
40
+ ```python
41
+ # Emanuskript model
42
+ emanuskript_results = model.predict(image_path, classes=[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,20])
43
+
44
+ # Catmus model
45
+ catmus_results = model.predict(image_path, classes=[1,7]) # DefaultLine and InterlinearLine
46
+
47
+ # Zone model
48
+ zone_results = model.predict(image_path) # All classes
49
+ ```
50
+
51
+ Predictions are saved to JSON files in separate folders.
52
+
53
+ ### Step 2: Combine Predictions (ImageBatch Class)
54
+
55
+ The `ImageBatch` class (`utils/image_batch_classes.py`) handles:
56
+
57
+ 1. **Loading Images**: Loads the image and gets dimensions
58
+ 2. **Loading Annotations**: Loads predictions from all 3 JSON files
59
+ 3. **Unifying Names**: Maps class names using `catmus_zones_mapping`:
60
+ - `DefaultLine` → `Main script black`
61
+ - `InterlinearLine` → `Gloss`
62
+ - `MainZone` → `Column`
63
+ - `DropCapitalZone` → `Plain initial- coloured`
64
+ - etc.
65
+
66
+ 4. **Filtering Annotations**:
67
+ - Removes overlapping annotations based on spatial indexing
68
+ - Uses overlap thresholds (0.3-0.8 depending on class)
69
+ - Handles conflicts between different model predictions
70
+
71
+ 5. **COCO Format Conversion**: Converts to COCO JSON format
72
+
73
+ ### Step 3: Filter to coco_class_mapping
74
+
75
+ Only annotations with classes in `coco_class_mapping` are kept (25 classes total).
76
+
77
+ ## Key Functions
78
+
79
+ ### `predict_annotations()` (in `utils/data.py`)
80
+ - Runs a single model on an image
81
+ - Saves predictions to JSON
82
+ - Used by Celery tasks for async processing
83
+
84
+ ### `unify_predictions()` (in `utils/data.py`)
85
+ - Combines predictions from all three models
86
+ - Uses `ImageBatch` to process and filter
87
+ - Returns COCO format JSON
88
+ - Imports annotations into database
89
+
90
+ ### `ImageBatch` class (in `utils/image_batch_classes.py`)
91
+ - Main class for combining predictions
92
+ - Methods:
93
+ - `load_images()`: Load image files
94
+ - `load_annotations()`: Load predictions from JSON files
95
+ - `unify_names()`: Map class names to coco_class_mapping
96
+ - `filter_annotations()`: Remove overlapping annotations
97
+ - `return_coco_file()`: Generate COCO JSON
98
+
99
+ ## Usage Example
100
+
101
+ ```python
102
+ from ultralytics import YOLO
103
+ from utils.image_batch_classes import ImageBatch
104
+
105
+ # 1. Run models (or use predict_annotations function)
106
+ # ... save predictions to JSON files ...
107
+
108
+ # 2. Combine predictions
109
+ image_batch = ImageBatch(
110
+ image_folder="path/to/images",
111
+ catmus_labels_folder="path/to/catmus/predictions",
112
+ emanuskript_labels_folder="path/to/emanuskript/predictions",
113
+ zone_labels_folder="path/to/zone/predictions"
114
+ )
115
+
116
+ image_batch.load_images()
117
+ image_batch.load_annotations()
118
+ image_batch.unify_names()
119
+
120
+ # 3. Get COCO format
121
+ coco_json = image_batch.return_coco_file()
122
+ ```
123
+
124
+ ## Running the Test Script
125
+
126
+ ```bash
127
+ python3 test_combined_models.py
128
+ ```
129
+
130
+ This will:
131
+ 1. Run all three models on `bnf-naf-10039__page-001-of-004.jpg`
132
+ 2. Combine and filter predictions
133
+ 3. Save results to `combined_predictions.json`
134
+ 4. Print a summary of detected classes
135
+
136
+ ## Output Format
137
+
138
+ The final output is a COCO-format JSON file with:
139
+ - **images**: Image metadata (id, width, height, filename)
140
+ - **categories**: List of category definitions (25 classes from coco_class_mapping)
141
+ - **annotations**: List of annotations with:
142
+ - `id`: Annotation ID
143
+ - `image_id`: Associated image ID
144
+ - `category_id`: Class ID from coco_class_mapping
145
+ - `segmentation`: Polygon coordinates
146
+ - `bbox`: Bounding box [x, y, width, height]
147
+ - `area`: Polygon area
148
+
149
+ ## Class Mapping
150
+
151
+ The `catmus_zones_mapping` in `image_batch_classes.py` maps:
152
+ - Catmus/Zone model classes → coco_class_mapping classes
153
+ - Example: `DefaultLine` → `Main script black`
154
+ - Example: `MainZone` → `Column`
155
+
156
+ Only classes that map to `coco_class_mapping` are included in the final output.
157
+
_app_.py ADDED
@@ -0,0 +1,1543 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Tuple, Dict, List, Union
2
+ import gradio as gr
3
+ import supervision as sv
4
+ import numpy as np
5
+ from PIL import Image, ImageDraw, ImageFont
6
+ from ultralytics import YOLO, YOLOE
7
+ import zipfile
8
+ import os
9
+ import tempfile
10
+ import cv2
11
+ import json
12
+ from datetime import datetime
13
+ import io
14
+ import pandas as pd
15
+ import matplotlib.pyplot as plt
16
+ import matplotlib
17
+ matplotlib.use('Agg') # Use non-interactive backend
18
+
19
+ # Define custom models
20
+ MODEL_FILES = {
21
+ "Line Detection": "best_line_detection_yoloe (1).pt", # Use YOLOE for this
22
+ "Border Detection": "border_model_weights.pt", # Still YOLO
23
+ "Zones Detection": "zones_model_weights.pt" # Still YOLO
24
+ }
25
+
26
+ # Dictionary to store loaded models
27
+ models: Dict[str, Union[YOLO, YOLOE]] = {}
28
+
29
+ # Model class definitions - Expected/desired classes
30
+ EXPECTED_MODEL_CLASSES = {
31
+ "Line Detection": [
32
+ "line"
33
+ ],
34
+ "Border Detection": [
35
+ "border",
36
+ "decorated_initial",
37
+ "historiated_initial",
38
+ "illustration",
39
+ "page",
40
+ "simple_initial"
41
+ ],
42
+ "Zones Detection": [
43
+ "CustomZone-PageHeight",
44
+ "CustomZone-PageWidth",
45
+ "DamageZone",
46
+ "DigitizationArtefactZone",
47
+ "DropCapitalZone",
48
+ "GraphicZone",
49
+ "MainZone",
50
+ "MarginTextZone",
51
+ "MusicZone",
52
+ "NumberingZone",
53
+ "PageZone",
54
+ "QuireMarksZone",
55
+ "RunningTitleZone",
56
+ "StampZone",
57
+ "TitlePageZone"
58
+ ]
59
+ }
60
+
61
+ # Model class definitions - will be populated dynamically from actual models
62
+ MODEL_CLASSES = {}
63
+
64
+ # Global variables to store results for download
65
+ current_results = []
66
+ current_images = []
67
+
68
+ # Load all custom models
69
+ # Get the directory where this script is located
70
+ script_dir = os.path.dirname(os.path.abspath(__file__))
71
+
72
+ for name, model_file in MODEL_FILES.items():
73
+ model_path = os.path.join(script_dir, model_file)
74
+ if os.path.exists(model_path):
75
+ try:
76
+ if name == "Line Detection":
77
+ # Load YOLOE for line detection
78
+ models[name] = YOLOE(model_path)
79
+ else:
80
+ # Load YOLO for other tasks
81
+ models[name] = YOLO(model_path)
82
+
83
+ # Read actual classes from the model
84
+ if models[name] is not None:
85
+ # Read classes from model
86
+ actual_classes = list(models[name].names.values())
87
+
88
+ # Map "object" to "line" for Line Detection model in MODEL_CLASSES
89
+ if name == "Line Detection" and "object" in actual_classes:
90
+ actual_classes = ["line" if c == "object" else c for c in actual_classes]
91
+ print(f" Mapped class 'object' to 'line' in Line Detection model for UI")
92
+
93
+ MODEL_CLASSES[name] = actual_classes
94
+
95
+ # Check for mismatch with expected classes
96
+ if name in EXPECTED_MODEL_CLASSES:
97
+ expected = set(EXPECTED_MODEL_CLASSES[name])
98
+ actual = set(actual_classes)
99
+ if expected != actual:
100
+ print(f"⚠️ WARNING: {name} model class mismatch!")
101
+ print(f" Expected: {sorted(expected)}")
102
+ print(f" Actual: {sorted(actual)}")
103
+ print(f" Missing in model: {sorted(expected - actual)}")
104
+ print(f" Extra in model: {sorted(actual - expected)}")
105
+ print(f" ⚠️ Using ACTUAL classes from model: {sorted(actual)}")
106
+
107
+ print(f"✓ Loaded {name} model from {model_path}")
108
+ print(f" Classes available: {MODEL_CLASSES.get(name, 'Unknown')}")
109
+ except Exception as e:
110
+ print(f"✗ Error loading {name} model: {e}")
111
+ models[name] = None
112
+ # Fallback to expected classes if model fails to load
113
+ MODEL_CLASSES[name] = EXPECTED_MODEL_CLASSES.get(name, [])
114
+ else:
115
+ print(f"✗ Warning: Model file {model_path} not found")
116
+ models[name] = None
117
+ # Fallback to expected classes if model file not found
118
+ MODEL_CLASSES[name] = EXPECTED_MODEL_CLASSES.get(name, [])
119
+
120
+
121
+ # Create annotators
122
+ LABEL_ANNOTATOR = sv.LabelAnnotator(text_color=sv.Color.BLACK)
123
+ BOX_ANNOTATOR = sv.BoxAnnotator()
124
+ MASK_ANNOTATOR = sv.MaskAnnotator()
125
+
126
+ def detect_and_annotate_combined(
127
+ image: np.ndarray,
128
+ conf_threshold: float,
129
+ iou_threshold: float,
130
+ return_annotations: bool = False,
131
+ selected_classes: Dict[str, List[str]] = None
132
+ ) -> Union[np.ndarray, Tuple[np.ndarray, Dict]]:
133
+ """Run all three models and combine their outputs in a single annotated image"""
134
+ print(f"🔍 Starting detection on image shape: {image.shape}")
135
+
136
+ # Colors for different models - more distinct colors
137
+ colors = {
138
+ "Line Detection": sv.Color.from_hex("#FF0000"), # Bright Red
139
+ "Border Detection": sv.Color.from_hex("#00FF00"), # Bright Green
140
+ "Zones Detection": sv.Color.from_hex("#0080FF") # Bright Blue
141
+ }
142
+
143
+ # Model prefixes for clear labeling
144
+ model_prefixes = {
145
+ "Line Detection": "[LINE]",
146
+ "Border Detection": "[BORDER]",
147
+ "Zones Detection": "[ZONE]"
148
+ }
149
+
150
+ annotated_image = image.copy()
151
+ total_detections = 0
152
+ detections_data = {}
153
+
154
+ # Run each model and annotate with different colors
155
+ for model_name, model in models.items():
156
+ if model is None:
157
+ print(f"⏭️ Skipping {model_name} (model not loaded)")
158
+ detections_data[model_name] = []
159
+ continue
160
+
161
+ # Check if any classes are selected for this model BEFORE running inference
162
+ if selected_classes and model_name in selected_classes:
163
+ selected_class_names = selected_classes[model_name]
164
+ # If no classes selected for this model, skip it entirely (don't run inference)
165
+ if not selected_class_names:
166
+ print(f"⏭️ Skipping {model_name} (no classes selected)")
167
+ detections_data[model_name] = []
168
+ continue
169
+ elif selected_classes is not None:
170
+ # If selected_classes is provided but this model not in it, skip it
171
+ print(f"⏭️ Skipping {model_name} (model not in selected classes)")
172
+ detections_data[model_name] = []
173
+ continue
174
+
175
+ print(f"🤖 Running {model_name} model...")
176
+
177
+ # Perform inference (guard against per-model failures)
178
+ try:
179
+ results = model.predict(
180
+ image,
181
+ conf=conf_threshold,
182
+ iou=iou_threshold
183
+ )[0]
184
+ except Exception as e:
185
+ print(f"✗ {model_name} inference failed: {e}")
186
+ detections_data[model_name] = []
187
+ continue
188
+
189
+ model_detections = []
190
+
191
+ if len(results.boxes) > 0:
192
+ # Convert results to supervision Detections
193
+ boxes = results.boxes.xyxy.cpu().numpy()
194
+ confidence = results.boxes.conf.cpu().numpy()
195
+ class_ids = results.boxes.cls.cpu().numpy().astype(int)
196
+
197
+ # Filter by selected classes - only show selected classes
198
+ if selected_classes and model_name in selected_classes:
199
+ selected_class_names = selected_classes[model_name]
200
+
201
+ # Get class names for this model
202
+ model_class_names = results.names
203
+ # Find class IDs that match selected class names
204
+ selected_class_ids = []
205
+ for class_id, class_name in model_class_names.items():
206
+ # For Line Detection: also match "object" when user selects "line"
207
+ if model_name == "Line Detection" and class_name == "object" and "line" in selected_class_names:
208
+ selected_class_ids.append(class_id)
209
+ elif class_name in selected_class_names:
210
+ selected_class_ids.append(class_id)
211
+
212
+ # Filter detections to only show selected classes
213
+ mask = np.isin(class_ids, selected_class_ids)
214
+ if not np.any(mask):
215
+ print(f" No detections match selected classes for {model_name}")
216
+ detections_data[model_name] = []
217
+ continue
218
+
219
+ boxes = boxes[mask]
220
+ confidence = confidence[mask]
221
+ class_ids = class_ids[mask]
222
+ print(f" Filtered to {len(boxes)} detections matching selected classes: {selected_class_names}")
223
+
224
+ total_detections += len(boxes)
225
+
226
+ # Store detection data for COCO format
227
+ for i, (box, conf, class_id) in enumerate(zip(boxes, confidence, class_ids)):
228
+ x1, y1, x2, y2 = box
229
+ width = x2 - x1
230
+ height = y2 - y1
231
+
232
+ class_name = results.names[class_id]
233
+ # Map "object" to "line" for Line Detection model
234
+ if model_name == "Line Detection" and class_name == "object":
235
+ class_name = "line"
236
+
237
+ model_detections.append({
238
+ "bbox": [float(x1), float(y1), float(width), float(height)], # COCO format: [x, y, width, height]
239
+ "class_name": class_name,
240
+ "confidence": float(conf)
241
+ })
242
+
243
+
244
+ # Create Detections object for visualization
245
+ detections = sv.Detections(
246
+ xyxy=boxes,
247
+ confidence=confidence,
248
+ mask=results.masks.data.cpu().numpy() if results.masks is not None else None,
249
+ class_id=class_ids
250
+ )
251
+
252
+ # Create labels with clear model prefixes and confidence scores
253
+ model_prefix = model_prefixes[model_name]
254
+ labels = []
255
+ for class_id, conf in zip(class_ids, confidence):
256
+ class_name = results.names[class_id]
257
+ # Map "object" to "line" for Line Detection model
258
+ if model_name == "Line Detection" and class_name == "object":
259
+ class_name = "line"
260
+ labels.append(f"{model_prefix} {class_name} ({conf:.2f})")
261
+
262
+ # Create annotators with specific colors and improved styling
263
+ box_annotator = sv.BoxAnnotator(
264
+ color=colors[model_name],
265
+ thickness=3 # Thicker boxes for better visibility
266
+ )
267
+ label_annotator = sv.LabelAnnotator(
268
+ text_color=sv.Color.WHITE,
269
+ color=colors[model_name],
270
+ text_thickness=2,
271
+ text_scale=0.6,
272
+ text_padding=8
273
+ )
274
+
275
+ # Replace the "annotate image" block inside detect_and_annotate_combined with this
276
+
277
+ # Annotate image depending on model type
278
+ if model_name == "Line Detection" and results.masks is not None:
279
+
280
+ original_h, original_w = annotated_image.shape[:2]
281
+
282
+ if detections.mask is not None:
283
+ all_resized_masks = []
284
+ for i, mask in enumerate(detections.mask):
285
+ # ensure binary mask
286
+ mask_np = (mask > 0).astype(np.uint8)
287
+ resized_mask = cv2.resize(
288
+ mask_np,
289
+ (original_w, original_h),
290
+ interpolation=cv2.INTER_NEAREST
291
+ )
292
+ resized_mask = resized_mask.astype(bool) # <- important
293
+ all_resized_masks.append(resized_mask)
294
+
295
+ all_resized_masks = np.stack(all_resized_masks, axis=0) # (N, H, W)
296
+ detections.mask = all_resized_masks # overwrite with clean boolean masks
297
+ print("Resized masks:", detections.mask.shape, detections.mask.dtype)
298
+ else:
299
+ detections.mask = None
300
+
301
+
302
+ # Use MaskAnnotator for line detection
303
+ mask_annotator = sv.MaskAnnotator(
304
+ color=colors[model_name],
305
+ opacity=0.6
306
+ )
307
+ annotated_image = mask_annotator.annotate(scene=annotated_image, detections=detections)
308
+
309
+ # Add labels on top of masks
310
+ annotated_image = label_annotator.annotate(
311
+ scene=annotated_image,
312
+ detections=detections,
313
+ labels=labels
314
+ )
315
+ else:
316
+ # Use BoxAnnotator for Border and Zones
317
+ annotated_image = box_annotator.annotate(scene=annotated_image, detections=detections)
318
+ annotated_image = label_annotator.annotate(scene=annotated_image, detections=detections, labels=labels)
319
+
320
+ else:
321
+ print(f" No detections found for {model_name}")
322
+
323
+ detections_data[model_name] = model_detections
324
+
325
+ print(f"🎯 Detection completed. Total detections: {total_detections}")
326
+
327
+ if return_annotations:
328
+ return annotated_image, detections_data
329
+ else:
330
+ return annotated_image
331
+
332
+ def process_zip_file(zip_file_path: str, conf_threshold: float, iou_threshold: float, selected_classes: Dict[str, List[str]] = None) -> Tuple[List[Tuple[str, np.ndarray]], List[Tuple[str, Dict]], Dict]:
333
+ """Process all images in a zip file and return annotated images, detection data, and image info"""
334
+ print(f"📁 Opening ZIP file: {zip_file_path}")
335
+ results = []
336
+ annotations_data = []
337
+ image_info = {}
338
+
339
+ try:
340
+ with zipfile.ZipFile(zip_file_path, 'r') as zip_ref:
341
+ print(f"📋 ZIP file contents: {zip_ref.namelist()}")
342
+
343
+ # Create temporary directory to extract files
344
+ with tempfile.TemporaryDirectory() as temp_dir:
345
+ print(f"📂 Extracting to temporary directory: {temp_dir}")
346
+ zip_ref.extractall(temp_dir)
347
+
348
+ # List all files in temp directory
349
+ all_files = os.listdir(temp_dir)
350
+ print(f"📄 Files extracted: {all_files}")
351
+
352
+ # Process each image file (recursively search through folders)
353
+ image_count = 0
354
+
355
+ # Walk through all directories and subdirectories
356
+ for root, dirs, files in os.walk(temp_dir):
357
+ print(f"📂 Searching in directory: {root}")
358
+
359
+ for filename in files:
360
+ # Skip macOS hidden files
361
+ if filename.startswith('._') or filename.startswith('.DS_Store'):
362
+ print(f"⏭️ Skipping system file: {filename}")
363
+ continue
364
+
365
+ if filename.lower().endswith(('.png', '.jpg', '.jpeg', '.bmp', '.tiff')):
366
+ image_count += 1
367
+ image_path = os.path.join(root, filename)
368
+ print(f"🖼️ Processing image {image_count}: {filename} (from {os.path.relpath(root, temp_dir)})")
369
+
370
+ # Load image
371
+ image = cv2.imread(image_path)
372
+ if image is not None:
373
+ print(f"✅ Image loaded successfully: {image.shape}")
374
+ image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
375
+
376
+ # Store image info
377
+ height, width = image.shape[:2]
378
+ image_info[filename] = (height, width)
379
+
380
+ # Process with all models and get annotation data
381
+ print(f"🔍 Running detection models on {filename}...")
382
+ annotated_image, detections_data = detect_and_annotate_combined(
383
+ image, conf_threshold, iou_threshold, return_annotations=True, selected_classes=selected_classes
384
+ )
385
+ print(f"✅ Detection completed for {filename}")
386
+
387
+ results.append((filename, annotated_image))
388
+ annotations_data.append((filename, detections_data))
389
+ else:
390
+ print(f"❌ Failed to load image: {filename}")
391
+ else:
392
+ print(f"⏭️ Skipping non-image file: {filename}")
393
+
394
+ print(f"📊 Total images processed: {len(results)} out of {image_count} image files found")
395
+ print(f"📁 Searched through all subdirectories recursively")
396
+
397
+ print(f"🎉 ZIP processing completed successfully! Processed {len(results)} images")
398
+ return results, annotations_data, image_info
399
+
400
+ except Exception as e:
401
+ print(f"💥 ERROR in process_zip_file: {str(e)}")
402
+ import traceback
403
+ traceback.print_exc()
404
+ return [], [], {}
405
+
406
+ def create_coco_annotations(results_data: List, image_info: Dict) -> Dict:
407
+ """Convert detection results to COCO JSON format"""
408
+ coco_data = {
409
+ "info": {
410
+ "description": "Medieval Manuscript Detection Results",
411
+ "version": "1.0",
412
+ "year": datetime.now().year,
413
+ "contributor": "Medieval YOLO Models",
414
+ "date_created": datetime.now().isoformat()
415
+ },
416
+ "licenses": [
417
+ {
418
+ "id": 1,
419
+ "name": "Custom License",
420
+ "url": ""
421
+ }
422
+ ],
423
+ "images": [],
424
+ "annotations": [],
425
+ "categories": []
426
+ }
427
+
428
+ # Create categories from all models
429
+ category_id = 1
430
+ category_map = {}
431
+
432
+ # Add categories for each model type
433
+ for model_name in ["Line Detection", "Border Detection", "Zones Detection"]:
434
+ if model_name in models and models[model_name] is not None:
435
+ model = models[model_name]
436
+ for class_id, class_name in model.names.items():
437
+ full_name = f"{model_name}_{class_name}"
438
+ if full_name not in category_map:
439
+ category_map[full_name] = category_id
440
+ coco_data["categories"].append({
441
+ "id": category_id,
442
+ "name": full_name,
443
+ "supercategory": model_name
444
+ })
445
+ category_id += 1
446
+
447
+ annotation_id = 1
448
+
449
+ for image_idx, (filename, detections_by_model) in enumerate(results_data):
450
+ # Add image info
451
+ image_id = image_idx + 1
452
+ img_height, img_width = image_info.get(filename, (0, 0))
453
+
454
+ coco_data["images"].append({
455
+ "id": image_id,
456
+ "file_name": filename,
457
+ "width": img_width,
458
+ "height": img_height,
459
+ "license": 1
460
+ })
461
+
462
+ # Add annotations for each model
463
+ for model_name, detections in detections_by_model.items():
464
+ if detections:
465
+ for detection in detections:
466
+ bbox = detection["bbox"] # [x, y, width, height]
467
+ class_name = detection["class_name"]
468
+ confidence = detection["confidence"]
469
+
470
+ full_category_name = f"{model_name}_{class_name}"
471
+ category_id = category_map.get(full_category_name, 1)
472
+
473
+ coco_data["annotations"].append({
474
+ "id": annotation_id,
475
+ "image_id": image_id,
476
+ "category_id": category_id,
477
+ "bbox": bbox,
478
+ "area": bbox[2] * bbox[3],
479
+ "iscrowd": 0,
480
+ "score": confidence
481
+ })
482
+ annotation_id += 1
483
+
484
+ return coco_data
485
+
486
+ def create_download_zip(images: List[Tuple[str, np.ndarray]], annotations: Dict) -> str:
487
+ """Create a ZIP file with images and annotations"""
488
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
489
+ zip_filename = f"medieval_detection_results_{timestamp}.zip"
490
+ zip_path = os.path.join(tempfile.gettempdir(), zip_filename)
491
+
492
+ with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
493
+ # Add images
494
+ for filename, image_array in images:
495
+ # Convert numpy array to PIL Image and save as bytes
496
+ pil_image = Image.fromarray(image_array.astype('uint8'))
497
+ img_bytes = io.BytesIO()
498
+
499
+ # Determine format from filename
500
+ if filename.lower().endswith('.png'):
501
+ pil_image.save(img_bytes, format='PNG')
502
+ else:
503
+ pil_image.save(img_bytes, format='JPEG')
504
+
505
+ # Add to ZIP
506
+ zipf.writestr(f"images/{filename}", img_bytes.getvalue())
507
+
508
+ # Add annotations
509
+ annotations_json = json.dumps(annotations, indent=2)
510
+ zipf.writestr("annotations.json", annotations_json)
511
+
512
+ # Add README
513
+ readme_content = f"""Medieval Manuscript Detection Results
514
+ =============================================
515
+
516
+ Generated on: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
517
+
518
+ Contents:
519
+ - images/: Annotated images with detection results
520
+ - annotations.json: COCO format annotations
521
+
522
+ Models and Color Coding:
523
+ - Line Detection (Red boxes with [LINE] prefix)
524
+ - Border Detection (Green boxes with [BORDER] prefix)
525
+ - Zones Detection (Blue boxes with [ZONE] prefix)
526
+
527
+ Label format: [MODEL] class_name (confidence_score)
528
+ Annotation format: COCO JSON
529
+ For more info: https://cocodataset.org/#format-data
530
+ """
531
+ zipf.writestr("README.txt", readme_content)
532
+
533
+ return zip_path
534
+
535
+ def calculate_statistics(detections_data: Dict, selected_classes: Dict[str, List[str]] = None) -> Dict[str, int]:
536
+ """Calculate statistics (count per class) from detections_data"""
537
+ stats = {}
538
+
539
+ for model_name, detections in detections_data.items():
540
+ if not detections:
541
+ continue
542
+
543
+ # Filter by selected classes if provided
544
+ for detection in detections:
545
+ class_name = detection["class_name"]
546
+
547
+ # Only count if class is in selected classes (if selected_classes is provided)
548
+ if selected_classes:
549
+ if model_name not in selected_classes:
550
+ continue
551
+ if class_name not in selected_classes[model_name]:
552
+ continue
553
+
554
+ # Create full class identifier (model_name + class_name)
555
+ full_class_name = f"{model_name} - {class_name}"
556
+
557
+ if full_class_name not in stats:
558
+ stats[full_class_name] = 0
559
+ stats[full_class_name] += 1
560
+
561
+ return stats
562
+
563
+ def create_statistics_table(stats: Dict[str, int], image_name: str = None) -> pd.DataFrame:
564
+ """Create a pandas DataFrame table from statistics"""
565
+ if not stats:
566
+ return pd.DataFrame(columns=["Class", "Count"])
567
+
568
+ data = []
569
+ for class_name, count in sorted(stats.items()):
570
+ data.append({"Class": class_name, "Count": count})
571
+
572
+ df = pd.DataFrame(data)
573
+ if image_name:
574
+ df.insert(0, "Image", image_name)
575
+
576
+ return df
577
+
578
+ def create_statistics_graph(stats: Dict[str, int], image_name: str = None) -> str:
579
+ """Create a bar chart from statistics and return as image path"""
580
+ if not stats:
581
+ # Return empty graph
582
+ fig, ax = plt.subplots(figsize=(10, 6))
583
+ ax.text(0.5, 0.5, "No detections found", ha='center', va='center', fontsize=14)
584
+ ax.set_xticks([])
585
+ ax.set_yticks([])
586
+ else:
587
+ classes = sorted(stats.keys())
588
+ counts = [stats[c] for c in classes]
589
+
590
+ fig, ax = plt.subplots(figsize=(12, 6))
591
+ bars = ax.bar(range(len(classes)), counts, color='steelblue')
592
+ ax.set_xlabel('Class', fontsize=12)
593
+ ax.set_ylabel('Count', fontsize=12)
594
+ ax.set_title(f'Detection Statistics{(" - " + image_name) if image_name else ""}', fontsize=14, fontweight='bold')
595
+ ax.set_xticks(range(len(classes)))
596
+ ax.set_xticklabels(classes, rotation=45, ha='right')
597
+
598
+ # Add count labels on bars
599
+ for bar, count in zip(bars, counts):
600
+ height = bar.get_height()
601
+ ax.text(bar.get_x() + bar.get_width()/2., height,
602
+ f'{count}',
603
+ ha='center', va='bottom', fontsize=10)
604
+
605
+ plt.tight_layout()
606
+
607
+ # Save to temporary file
608
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
609
+ graph_path = os.path.join(tempfile.gettempdir(), f"statistics_graph_{timestamp}.png")
610
+ fig.savefig(graph_path, dpi=150, bbox_inches='tight')
611
+ plt.close(fig)
612
+
613
+ return graph_path
614
+
615
+ def create_statistics_csv(stats: Dict[str, int], image_name: str = None) -> str:
616
+ """Create CSV file from statistics"""
617
+ df = create_statistics_table(stats, image_name)
618
+
619
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
620
+ csv_path = os.path.join(tempfile.gettempdir(), f"statistics_{timestamp}.csv")
621
+ df.to_csv(csv_path, index=False)
622
+
623
+ return csv_path
624
+
625
+ def create_statistics_json(stats: Dict[str, int], image_name: str = None) -> str:
626
+ """Create JSON file from statistics"""
627
+ data = {
628
+ "image": image_name,
629
+ "timestamp": datetime.now().isoformat(),
630
+ "statistics": stats
631
+ }
632
+
633
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
634
+ json_path = os.path.join(tempfile.gettempdir(), f"statistics_{timestamp}.json")
635
+
636
+ with open(json_path, 'w') as f:
637
+ json.dump(data, f, indent=2)
638
+
639
+ return json_path
640
+
641
+ def calculate_batch_statistics(results_data: List[Tuple[str, Dict]], selected_classes: Dict[str, List[str]] = None) -> pd.DataFrame:
642
+ """Calculate statistics for all images in batch processing - per image"""
643
+ all_stats = []
644
+
645
+ for filename, detections_by_model in results_data:
646
+ stats = calculate_statistics(detections_by_model, selected_classes)
647
+ df = create_statistics_table(stats, filename)
648
+ if not df.empty:
649
+ all_stats.append(df)
650
+
651
+ if all_stats:
652
+ combined_df = pd.concat(all_stats, ignore_index=True)
653
+ return combined_df
654
+ else:
655
+ return pd.DataFrame(columns=["Image", "Class", "Count"])
656
+
657
+ def calculate_batch_statistics_summary(results_data: List[Tuple[str, Dict]], selected_classes: Dict[str, List[str]] = None) -> pd.DataFrame:
658
+ """Calculate overall aggregated statistics for all images in batch"""
659
+ # Aggregate statistics across all images
660
+ all_stats = {}
661
+
662
+ for filename, detections_by_model in results_data:
663
+ stats = calculate_statistics(detections_by_model, selected_classes)
664
+ for class_name, count in stats.items():
665
+ if class_name not in all_stats:
666
+ all_stats[class_name] = 0
667
+ all_stats[class_name] += count
668
+
669
+ # Create summary table
670
+ if not all_stats:
671
+ return pd.DataFrame(columns=["Class", "Total Count"])
672
+
673
+ data = []
674
+ for class_name, count in sorted(all_stats.items()):
675
+ data.append({"Class": class_name, "Total Count": count})
676
+
677
+ return pd.DataFrame(data)
678
+
679
+ def create_batch_statistics_graph(results_data: List[Tuple[str, Dict]], selected_classes: Dict[str, List[str]] = None) -> str:
680
+ """Create a graph showing statistics across all images in batch"""
681
+ # Aggregate statistics across all images
682
+ all_stats = {}
683
+
684
+ for filename, detections_by_model in results_data:
685
+ stats = calculate_statistics(detections_by_model, selected_classes)
686
+ for class_name, count in stats.items():
687
+ if class_name not in all_stats:
688
+ all_stats[class_name] = 0
689
+ all_stats[class_name] += count
690
+
691
+ return create_statistics_graph(all_stats, "Batch Processing")
692
+
693
+ def create_batch_statistics_csv(results_data: List[Tuple[str, Dict]], selected_classes: Dict[str, List[str]] = None) -> str:
694
+ """Create CSV file from batch statistics - includes both per-image and summary"""
695
+ # Get per-image statistics
696
+ per_image_df = calculate_batch_statistics(results_data, selected_classes)
697
+ # Get summary statistics
698
+ summary_df = calculate_batch_statistics_summary(results_data, selected_classes)
699
+
700
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
701
+ csv_path = os.path.join(tempfile.gettempdir(), f"batch_statistics_{timestamp}.csv")
702
+
703
+ # Write both to CSV with separator
704
+ with open(csv_path, 'w') as f:
705
+ # Write per-image statistics
706
+ f.write("=== PER IMAGE STATISTICS ===\n")
707
+ per_image_df.to_csv(f, index=False)
708
+ f.write("\n\n=== OVERALL SUMMARY STATISTICS ===\n")
709
+ summary_df.to_csv(f, index=False)
710
+
711
+ return csv_path
712
+
713
+ def create_batch_statistics_json(results_data: List[Tuple[str, Dict]], selected_classes: Dict[str, List[str]] = None) -> str:
714
+ """Create JSON file from batch statistics - includes both per-image and summary"""
715
+ # Calculate summary statistics
716
+ summary_stats = {}
717
+ for filename, detections_by_model in results_data:
718
+ stats = calculate_statistics(detections_by_model, selected_classes)
719
+ for class_name, count in stats.items():
720
+ if class_name not in summary_stats:
721
+ summary_stats[class_name] = 0
722
+ summary_stats[class_name] += count
723
+
724
+ data = {
725
+ "batch_processing": True,
726
+ "timestamp": datetime.now().isoformat(),
727
+ "total_images": len(results_data),
728
+ "per_image_statistics": [],
729
+ "overall_summary": summary_stats
730
+ }
731
+
732
+ for filename, detections_by_model in results_data:
733
+ stats = calculate_statistics(detections_by_model, selected_classes)
734
+ data["per_image_statistics"].append({
735
+ "filename": filename,
736
+ "statistics": stats
737
+ })
738
+
739
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
740
+ json_path = os.path.join(tempfile.gettempdir(), f"batch_statistics_{timestamp}.json")
741
+
742
+ with open(json_path, 'w') as f:
743
+ json.dump(data, f, indent=2)
744
+
745
+ return json_path
746
+
747
+ # Create Gradio interface
748
+ with gr.Blocks() as demo:
749
+ gr.Markdown("# Medieval Manuscript Detection with Custom YOLO Models")
750
+ gr.Markdown("""
751
+ **Models and Color Coding:**
752
+ - 🔵**Line Detection** - Red boxes with [LINE] prefix
753
+ - 🟢 **Border Detection** - Green boxes with [BORDER] prefix
754
+ - 🟠 **Zones Detection** - Blue boxes with [ZONE] prefix
755
+
756
+ Each detection shows: **[MODEL] class_name (confidence_score)**
757
+ """)
758
+
759
+ with gr.Tabs():
760
+ # Single Image Tab
761
+ with gr.TabItem("Single Image"):
762
+ with gr.Row():
763
+ with gr.Column():
764
+ input_image = gr.Image(
765
+ label="Input Image",
766
+ type='numpy'
767
+ )
768
+ with gr.Accordion("Detection Settings", open=True):
769
+ with gr.Row():
770
+ conf_threshold = gr.Slider(
771
+ label="Confidence Threshold",
772
+ minimum=0.0,
773
+ maximum=1.0,
774
+ step=0.05,
775
+ value=0.25,
776
+ )
777
+ iou_threshold = gr.Slider(
778
+ label="IoU Threshold",
779
+ minimum=0.0,
780
+ maximum=1.0,
781
+ step=0.05,
782
+ value=0.45,
783
+ info="Decrease for stricter detection, increase for more overlapping boxes"
784
+ )
785
+
786
+ with gr.Accordion("Class Selection", open=False):
787
+ gr.Markdown("**Select which classes to detect for each model:**")
788
+ with gr.Row():
789
+ with gr.Column():
790
+ line_classes = gr.CheckboxGroup(
791
+ label="Line Detection Classes",
792
+ choices=MODEL_CLASSES["Line Detection"],
793
+ value=MODEL_CLASSES["Line Detection"], # All selected by default
794
+ info="Select at least one class for detection"
795
+ )
796
+ with gr.Row():
797
+ line_select_all = gr.Button("Select All", size="sm")
798
+ line_unselect_all = gr.Button("Unselect All", size="sm")
799
+ with gr.Column():
800
+ border_classes = gr.CheckboxGroup(
801
+ label="Border Detection Classes",
802
+ choices=MODEL_CLASSES["Border Detection"],
803
+ value=MODEL_CLASSES["Border Detection"], # All selected by default
804
+ info="Select at least one class for detection"
805
+ )
806
+ with gr.Row():
807
+ border_select_all = gr.Button("Select All", size="sm")
808
+ border_unselect_all = gr.Button("Unselect All", size="sm")
809
+ with gr.Row():
810
+ with gr.Column():
811
+ zones_classes = gr.CheckboxGroup(
812
+ label="Zones Detection Classes",
813
+ choices=MODEL_CLASSES["Zones Detection"],
814
+ value=MODEL_CLASSES["Zones Detection"], # All selected by default
815
+ info="Select at least one class for detection"
816
+ )
817
+ with gr.Row():
818
+ zones_select_all = gr.Button("Select All", size="sm")
819
+ zones_unselect_all = gr.Button("Unselect All", size="sm")
820
+ with gr.Row():
821
+ clear_btn = gr.Button("Clear")
822
+ detect_btn = gr.Button("Detect with All Models", variant="primary")
823
+
824
+ with gr.Column():
825
+ output_image = gr.Image(
826
+ label="Combined Detection Result",
827
+ type='numpy'
828
+ )
829
+
830
+ # Single image download buttons
831
+ with gr.Row():
832
+ single_download_json_btn = gr.Button(
833
+ "📄 Download Annotations (JSON)",
834
+ variant="secondary",
835
+ size="sm"
836
+ )
837
+ single_download_image_btn = gr.Button(
838
+ "🖼️ Download Image",
839
+ variant="secondary",
840
+ size="sm"
841
+ )
842
+
843
+ # Single image file outputs
844
+ single_json_output = gr.File(
845
+ label="📄 JSON Download",
846
+ visible=True,
847
+ height=50
848
+ )
849
+ single_image_output = gr.File(
850
+ label="🖼️ Image Download",
851
+ visible=True,
852
+ height=50
853
+ )
854
+
855
+ # Statistics section for single image
856
+ with gr.Accordion("📊 Statistics", open=False):
857
+ with gr.Tabs():
858
+ with gr.TabItem("Table"):
859
+ single_stats_table = gr.Dataframe(
860
+ label="Detection Statistics",
861
+ headers=["Class", "Count"],
862
+ wrap=True
863
+ )
864
+ with gr.TabItem("Graph"):
865
+ single_stats_graph = gr.Image(
866
+ label="Detection Statistics Graph",
867
+ type='filepath'
868
+ )
869
+
870
+ # Statistics download buttons
871
+ with gr.Row():
872
+ single_download_stats_csv_btn = gr.Button(
873
+ "📊 Download Statistics (CSV)",
874
+ variant="secondary",
875
+ size="sm"
876
+ )
877
+ single_download_stats_json_btn = gr.Button(
878
+ "📊 Download Statistics (JSON)",
879
+ variant="secondary",
880
+ size="sm"
881
+ )
882
+
883
+ single_stats_csv_output = gr.File(
884
+ label="📊 Statistics CSV Download",
885
+ visible=False,
886
+ height=50
887
+ )
888
+ single_stats_json_output = gr.File(
889
+ label="📊 Statistics JSON Download",
890
+ visible=False,
891
+ height=50
892
+ )
893
+
894
+ # Batch Processing Tab
895
+ with gr.TabItem("Batch Processing (ZIP)"):
896
+ with gr.Row():
897
+ with gr.Column():
898
+ zip_file = gr.File(
899
+ label="Upload ZIP file with images",
900
+ file_types=[".zip"]
901
+ )
902
+ with gr.Accordion("Detection Settings", open=True):
903
+ with gr.Row():
904
+ batch_conf_threshold = gr.Slider(
905
+ label="Confidence Threshold",
906
+ minimum=0.0,
907
+ maximum=1.0,
908
+ step=0.05,
909
+ value=0.25,
910
+ )
911
+ batch_iou_threshold = gr.Slider(
912
+ label="IoU Threshold",
913
+ minimum=0.0,
914
+ maximum=1.0,
915
+ step=0.05,
916
+ value=0.45,
917
+ )
918
+
919
+ with gr.Accordion("Class Selection", open=False):
920
+ gr.Markdown("**Select which classes to detect for each model:**")
921
+ with gr.Row():
922
+ with gr.Column():
923
+ batch_line_classes = gr.CheckboxGroup(
924
+ label="Line Detection Classes",
925
+ choices=MODEL_CLASSES["Line Detection"],
926
+ value=MODEL_CLASSES["Line Detection"], # All selected by default
927
+ info="Select at least one class for detection"
928
+ )
929
+ with gr.Row():
930
+ batch_line_select_all = gr.Button("Select All", size="sm")
931
+ batch_line_unselect_all = gr.Button("Unselect All", size="sm")
932
+ with gr.Column():
933
+ batch_border_classes = gr.CheckboxGroup(
934
+ label="Border Detection Classes",
935
+ choices=MODEL_CLASSES["Border Detection"],
936
+ value=MODEL_CLASSES["Border Detection"], # All selected by default
937
+ info="Select at least one class for detection"
938
+ )
939
+ with gr.Row():
940
+ batch_border_select_all = gr.Button("Select All", size="sm")
941
+ batch_border_unselect_all = gr.Button("Unselect All", size="sm")
942
+ with gr.Row():
943
+ with gr.Column():
944
+ batch_zones_classes = gr.CheckboxGroup(
945
+ label="Zones Detection Classes",
946
+ choices=MODEL_CLASSES["Zones Detection"],
947
+ value=MODEL_CLASSES["Zones Detection"], # All selected by default
948
+ info="Select at least one class for detection"
949
+ )
950
+ with gr.Row():
951
+ batch_zones_select_all = gr.Button("Select All", size="sm")
952
+ batch_zones_unselect_all = gr.Button("Unselect All", size="sm")
953
+
954
+ # Add status message box
955
+ batch_status = gr.Textbox(
956
+ label="Processing Status",
957
+ value="Ready to process ZIP file...",
958
+ interactive=False,
959
+ max_lines=3
960
+ )
961
+
962
+ with gr.Row():
963
+ clear_batch_btn = gr.Button("Clear")
964
+ process_batch_btn = gr.Button("Process ZIP", variant="primary")
965
+
966
+ with gr.Column():
967
+ batch_gallery = gr.Gallery(
968
+ label="Batch Processing Results",
969
+ show_label=True,
970
+ elem_id="gallery",
971
+ columns=2,
972
+ rows=2,
973
+ height="auto",
974
+ type="numpy" # Explicitly handle numpy arrays
975
+ )
976
+
977
+ # Download buttons
978
+ with gr.Row():
979
+ download_json_btn = gr.Button(
980
+ "📄 Download COCO Annotations (JSON)",
981
+ variant="secondary"
982
+ )
983
+ download_zip_btn = gr.Button(
984
+ "📦 Download Results (ZIP)",
985
+ variant="secondary"
986
+ )
987
+
988
+ # File outputs for downloads
989
+ json_file_output = gr.File(
990
+ label="📄 JSON Download",
991
+ visible=True,
992
+ height=50
993
+ )
994
+ zip_file_output = gr.File(
995
+ label="📦 ZIP Download",
996
+ visible=True,
997
+ height=50
998
+ )
999
+
1000
+ # Statistics section for batch processing
1001
+ with gr.Accordion("📊 Statistics", open=False):
1002
+ with gr.Tabs():
1003
+ with gr.TabItem("Per Image"):
1004
+ batch_stats_table = gr.Dataframe(
1005
+ label="Detection Statistics Per Image",
1006
+ wrap=True
1007
+ )
1008
+ with gr.TabItem("Overall Summary"):
1009
+ batch_stats_summary_table = gr.Dataframe(
1010
+ label="Overall Statistics Summary (All Images Combined)",
1011
+ wrap=True
1012
+ )
1013
+ with gr.TabItem("Graph"):
1014
+ batch_stats_graph = gr.Image(
1015
+ label="Detection Statistics Graph (Aggregated)",
1016
+ type='filepath'
1017
+ )
1018
+
1019
+ # Statistics download buttons
1020
+ with gr.Row():
1021
+ batch_download_stats_csv_btn = gr.Button(
1022
+ "📊 Download Statistics (CSV)",
1023
+ variant="secondary",
1024
+ size="sm"
1025
+ )
1026
+ batch_download_stats_json_btn = gr.Button(
1027
+ "📊 Download Statistics (JSON)",
1028
+ variant="secondary",
1029
+ size="sm"
1030
+ )
1031
+
1032
+ batch_stats_csv_output = gr.File(
1033
+ label="📊 Statistics CSV Download",
1034
+ visible=False,
1035
+ height=50
1036
+ )
1037
+ batch_stats_json_output = gr.File(
1038
+ label="📊 Statistics JSON Download",
1039
+ visible=False,
1040
+ height=50
1041
+ )
1042
+
1043
+ # Global variables for single image results
1044
+ single_image_result = None
1045
+ single_image_annotations = None
1046
+ single_image_filename = None
1047
+ single_image_selected_classes = None
1048
+
1049
+ def process_single_image(
1050
+ image: np.ndarray,
1051
+ conf_threshold: float,
1052
+ iou_threshold: float,
1053
+ line_classes: List[str],
1054
+ border_classes: List[str],
1055
+ zones_classes: List[str]
1056
+ ) -> Tuple[np.ndarray, np.ndarray, pd.DataFrame, str]:
1057
+ global single_image_result, single_image_annotations, single_image_filename, single_image_selected_classes
1058
+
1059
+ if image is None:
1060
+ single_image_result = None
1061
+ single_image_annotations = None
1062
+ single_image_filename = None
1063
+ single_image_selected_classes = None
1064
+ return None, None, pd.DataFrame(columns=["Class", "Count"]), None
1065
+
1066
+ # Validate that at least one class is selected
1067
+ all_selected = (line_classes or []) + (border_classes or []) + (zones_classes or [])
1068
+ if not all_selected:
1069
+ raise gr.Error("⚠️ Please select at least one class for detection!")
1070
+
1071
+ # Prepare selected classes dictionary
1072
+ selected_classes = {
1073
+ "Line Detection": line_classes or [],
1074
+ "Border Detection": border_classes or [],
1075
+ "Zones Detection": zones_classes or []
1076
+ }
1077
+
1078
+ # Process with annotations
1079
+ try:
1080
+ annotated_image, detections_data = detect_and_annotate_combined(
1081
+ image, conf_threshold, iou_threshold, return_annotations=True, selected_classes=selected_classes
1082
+ )
1083
+ except Exception as e:
1084
+ # Surface a nice error to the UI without crashing the app
1085
+ raise gr.Error(f"Detection failed: {str(e)}")
1086
+
1087
+ # Calculate statistics
1088
+ stats = calculate_statistics(detections_data, selected_classes)
1089
+ stats_table = create_statistics_table(stats, single_image_filename)
1090
+ stats_graph_path = create_statistics_graph(stats, single_image_filename)
1091
+
1092
+ # Store results globally for download
1093
+ single_image_result = annotated_image
1094
+ single_image_annotations = detections_data
1095
+ single_image_selected_classes = selected_classes
1096
+ single_image_filename = f"detection_result_{datetime.now().strftime('%Y%m%d_%H%M%S')}.jpg"
1097
+
1098
+ return image, annotated_image, stats_table, stats_graph_path
1099
+
1100
+ # Global variables for batch results
1101
+ current_batch_results = []
1102
+ current_batch_selected_classes = None
1103
+
1104
+ def process_batch_images_with_status(
1105
+ zip_file,
1106
+ conf_threshold: float,
1107
+ iou_threshold: float,
1108
+ line_classes: List[str],
1109
+ border_classes: List[str],
1110
+ zones_classes: List[str]
1111
+ ):
1112
+ global current_batch_results, current_batch_selected_classes
1113
+
1114
+ print("🚀 ========== BATCH PROCESSING STARTED ==========")
1115
+
1116
+ if zip_file is None:
1117
+ print("❌ No ZIP file provided")
1118
+ return [], "Please upload a ZIP file first.", pd.DataFrame(columns=["Image", "Class", "Count"]), pd.DataFrame(columns=["Class", "Total Count"]), None
1119
+
1120
+ print(f"📁 ZIP file received: {zip_file.name}")
1121
+ print(f"⚙️ Settings: conf_threshold={conf_threshold}, iou_threshold={iou_threshold}")
1122
+
1123
+ try:
1124
+ # Validate that at least one class is selected
1125
+ all_selected = (line_classes or []) + (border_classes or []) + (zones_classes or [])
1126
+ if not all_selected:
1127
+ raise gr.Error("⚠️ Please select at least one class for detection!")
1128
+
1129
+ # Prepare selected classes dictionary
1130
+ selected_classes = {
1131
+ "Line Detection": line_classes or [],
1132
+ "Border Detection": border_classes or [],
1133
+ "Zones Detection": zones_classes or []
1134
+ }
1135
+ current_batch_selected_classes = selected_classes
1136
+
1137
+ # Process zip file
1138
+ print("🔄 Starting ZIP file processing...")
1139
+ results, annotations_data, image_info = process_zip_file(zip_file.name, conf_threshold, iou_threshold, selected_classes)
1140
+
1141
+ # Store batch results globally
1142
+ current_batch_results = results
1143
+
1144
+ if not results:
1145
+ error_msg = "No valid images found in ZIP file."
1146
+ print(f"❌ {error_msg}")
1147
+ return [], error_msg
1148
+
1149
+ # Store data globally for download
1150
+ global current_results, current_images
1151
+ current_images = results
1152
+ current_results = annotations_data
1153
+
1154
+ print(f"📊 ZIP processing returned {len(results)} results")
1155
+
1156
+ # Convert results to format expected by Gallery
1157
+ print("🔄 Converting results for Gradio Gallery...")
1158
+ gallery_images = []
1159
+
1160
+ for i, (filename, annotated_image) in enumerate(results):
1161
+ print(f"🖼️ Converting image {i+1}/{len(results)}: {filename}")
1162
+ print(f" Image shape: {annotated_image.shape}, dtype: {annotated_image.dtype}")
1163
+
1164
+ # Ensure the image is in the right format and range
1165
+ if annotated_image.dtype != 'uint8':
1166
+ print(f" Converting dtype from {annotated_image.dtype} to uint8")
1167
+ # Normalize if needed
1168
+ if annotated_image.max() <= 1.0:
1169
+ annotated_image = (annotated_image * 255).astype('uint8')
1170
+ print(f" Normalized from [0,1] to [0,255]")
1171
+ else:
1172
+ annotated_image = annotated_image.astype('uint8')
1173
+ print(f" Cast to uint8")
1174
+
1175
+ print(f" Final image shape: {annotated_image.shape}, dtype: {annotated_image.dtype}")
1176
+
1177
+ # For Gradio gallery, we can pass numpy arrays directly
1178
+ # Format: (image_data, caption)
1179
+ gallery_images.append((annotated_image, filename))
1180
+ print(f" ✅ Added {filename} to gallery")
1181
+
1182
+ # Calculate statistics (use annotations_data, not results)
1183
+ stats_table = calculate_batch_statistics(annotations_data, selected_classes)
1184
+ stats_summary_table = calculate_batch_statistics_summary(annotations_data, selected_classes)
1185
+ stats_graph_path = create_batch_statistics_graph(annotations_data, selected_classes)
1186
+
1187
+ success_msg = f"✅ Successfully processed {len(gallery_images)} images!"
1188
+ print(f"🎉 {success_msg}")
1189
+ print(f"📋 Gallery contains {len(gallery_images)} items")
1190
+ print("🏁 ========== BATCH PROCESSING COMPLETED ==========\n")
1191
+
1192
+ return gallery_images, success_msg, stats_table, stats_summary_table, stats_graph_path
1193
+
1194
+ except Exception as e:
1195
+ error_msg = f"❌ Error: {str(e)}"
1196
+ print(f"💥 EXCEPTION in process_batch_images_with_status: {error_msg}")
1197
+ import traceback
1198
+ traceback.print_exc()
1199
+ print("💀 ========== BATCH PROCESSING FAILED ==========\n")
1200
+ return [], error_msg, pd.DataFrame(columns=["Image", "Class", "Count"]), pd.DataFrame(columns=["Class", "Total Count"]), None
1201
+
1202
+ def clear_single():
1203
+ global single_image_result, single_image_annotations, single_image_filename, single_image_selected_classes
1204
+ single_image_result = None
1205
+ single_image_annotations = None
1206
+ single_image_filename = None
1207
+ single_image_selected_classes = None
1208
+ return None, None, pd.DataFrame(columns=["Class", "Count"]), None
1209
+
1210
+ def clear_batch():
1211
+ global current_results, current_images
1212
+ current_results = []
1213
+ current_images = []
1214
+ return None, [], "Ready to process ZIP file..."
1215
+
1216
+ def download_annotations():
1217
+ """Create and return COCO JSON annotations file"""
1218
+ global current_results, current_images
1219
+
1220
+ if not current_results:
1221
+ print("❌ No annotation data available for download")
1222
+ return None
1223
+
1224
+ try:
1225
+ # Create image info dictionary
1226
+ image_info = {}
1227
+ for filename, image_array in current_images:
1228
+ height, width = image_array.shape[:2]
1229
+ image_info[filename] = (height, width)
1230
+
1231
+ # Create COCO annotations
1232
+ coco_data = create_coco_annotations(current_results, image_info)
1233
+
1234
+ # Save to temporary file with proper name
1235
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
1236
+ json_filename = f"medieval_annotations_{timestamp}.json"
1237
+ json_path = os.path.join(tempfile.gettempdir(), json_filename)
1238
+
1239
+ with open(json_path, 'w') as f:
1240
+ json.dump(coco_data, f, indent=2)
1241
+
1242
+ print(f"💾 Created annotations file: {json_path}")
1243
+ print(f"📁 File size: {os.path.getsize(json_path)} bytes")
1244
+
1245
+ # Verify file exists and is readable
1246
+ if os.path.exists(json_path) and os.path.getsize(json_path) > 0:
1247
+ return json_path
1248
+ else:
1249
+ print(f"❌ File verification failed: {json_path}")
1250
+ return None
1251
+
1252
+ except Exception as e:
1253
+ print(f"❌ Error creating annotations: {e}")
1254
+ import traceback
1255
+ traceback.print_exc()
1256
+ return None
1257
+
1258
+ def download_results_zip():
1259
+ """Create and return ZIP file with images and annotations"""
1260
+ global current_results, current_images
1261
+
1262
+ if not current_results or not current_images:
1263
+ print("❌ No results data available for ZIP download")
1264
+ return None
1265
+
1266
+ try:
1267
+ # Create image info dictionary
1268
+ image_info = {}
1269
+ for filename, image_array in current_images:
1270
+ height, width = image_array.shape[:2]
1271
+ image_info[filename] = (height, width)
1272
+
1273
+ # Create COCO annotations
1274
+ coco_data = create_coco_annotations(current_results, image_info)
1275
+
1276
+ # Create ZIP file
1277
+ zip_path = create_download_zip(current_images, coco_data)
1278
+
1279
+ print(f"💾 Created results ZIP: {zip_path}")
1280
+ print(f"📁 ZIP file size: {os.path.getsize(zip_path)} bytes")
1281
+
1282
+ # Verify file exists and is readable
1283
+ if os.path.exists(zip_path) and os.path.getsize(zip_path) > 0:
1284
+ return zip_path
1285
+ else:
1286
+ print(f"❌ ZIP file verification failed: {zip_path}")
1287
+ return None
1288
+
1289
+ except Exception as e:
1290
+ print(f"❌ Error creating ZIP file: {e}")
1291
+ import traceback
1292
+ traceback.print_exc()
1293
+ return None
1294
+
1295
+ def download_single_annotations():
1296
+ """Download COCO annotations for single image"""
1297
+ global single_image_annotations, single_image_result, single_image_filename
1298
+
1299
+ if single_image_annotations is None or single_image_result is None:
1300
+ print("❌ No single image annotation data available")
1301
+ return None
1302
+
1303
+ try:
1304
+ # Create image info
1305
+ height, width = single_image_result.shape[:2]
1306
+ image_info = {single_image_filename: (height, width)}
1307
+
1308
+ # Create annotations data in the expected format
1309
+ annotations_data = [(single_image_filename, single_image_annotations)]
1310
+
1311
+ # Create COCO annotations
1312
+ coco_data = create_coco_annotations(annotations_data, image_info)
1313
+
1314
+ # Save to temporary file
1315
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
1316
+ json_filename = f"single_image_annotations_{timestamp}.json"
1317
+ json_path = os.path.join(tempfile.gettempdir(), json_filename)
1318
+
1319
+ with open(json_path, 'w') as f:
1320
+ json.dump(coco_data, f, indent=2)
1321
+
1322
+ print(f"💾 Created single image annotations: {json_path}")
1323
+ print(f"📁 File size: {os.path.getsize(json_path)} bytes")
1324
+
1325
+ # Verify file exists
1326
+ if os.path.exists(json_path) and os.path.getsize(json_path) > 0:
1327
+ return json_path
1328
+ else:
1329
+ print(f"❌ Single image file verification failed: {json_path}")
1330
+ return None
1331
+
1332
+ except Exception as e:
1333
+ print(f"❌ Error creating single image annotations: {e}")
1334
+ import traceback
1335
+ traceback.print_exc()
1336
+ return None
1337
+
1338
+ def download_single_image():
1339
+ """Download processed single image"""
1340
+ global single_image_result, single_image_filename
1341
+
1342
+ if single_image_result is None:
1343
+ print("❌ No single image result available")
1344
+ return None
1345
+
1346
+ try:
1347
+ # Convert to PIL and save
1348
+ pil_image = Image.fromarray(single_image_result.astype('uint8'))
1349
+
1350
+ # Save to temporary file
1351
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
1352
+ img_filename = f"processed_image_{timestamp}.jpg"
1353
+ img_path = os.path.join(tempfile.gettempdir(), img_filename)
1354
+
1355
+ pil_image.save(img_path, 'JPEG', quality=95)
1356
+
1357
+ print(f"💾 Created single image file: {img_path}")
1358
+ print(f"📁 Image file size: {os.path.getsize(img_path)} bytes")
1359
+
1360
+ # Verify file exists
1361
+ if os.path.exists(img_path) and os.path.getsize(img_path) > 0:
1362
+ return img_path
1363
+ else:
1364
+ print(f"❌ Single image file verification failed: {img_path}")
1365
+ return None
1366
+
1367
+ except Exception as e:
1368
+ print(f"❌ Error creating single image file: {e}")
1369
+ import traceback
1370
+ traceback.print_exc()
1371
+ return None
1372
+
1373
+ # Connect buttons to functions for single image
1374
+ detect_btn.click(
1375
+ process_single_image,
1376
+ inputs=[input_image, conf_threshold, iou_threshold, line_classes, border_classes, zones_classes],
1377
+ outputs=[input_image, output_image, single_stats_table, single_stats_graph]
1378
+ )
1379
+ clear_btn.click(
1380
+ clear_single,
1381
+ inputs=None,
1382
+ outputs=[input_image, output_image, single_stats_table, single_stats_graph]
1383
+ )
1384
+
1385
+ # Select All/Unselect All handlers for single image
1386
+ line_select_all.click(
1387
+ fn=lambda: MODEL_CLASSES["Line Detection"],
1388
+ outputs=[line_classes]
1389
+ )
1390
+ line_unselect_all.click(
1391
+ fn=lambda: [],
1392
+ outputs=[line_classes]
1393
+ )
1394
+ border_select_all.click(
1395
+ fn=lambda: MODEL_CLASSES["Border Detection"],
1396
+ outputs=[border_classes]
1397
+ )
1398
+ border_unselect_all.click(
1399
+ fn=lambda: [],
1400
+ outputs=[border_classes]
1401
+ )
1402
+ zones_select_all.click(
1403
+ fn=lambda: MODEL_CLASSES["Zones Detection"],
1404
+ outputs=[zones_classes]
1405
+ )
1406
+ zones_unselect_all.click(
1407
+ fn=lambda: [],
1408
+ outputs=[zones_classes]
1409
+ )
1410
+
1411
+ # Connect buttons to functions for batch processing
1412
+ process_batch_btn.click(
1413
+ process_batch_images_with_status,
1414
+ inputs=[zip_file, batch_conf_threshold, batch_iou_threshold, batch_line_classes, batch_border_classes, batch_zones_classes],
1415
+ outputs=[batch_gallery, batch_status, batch_stats_table, batch_stats_summary_table, batch_stats_graph]
1416
+ )
1417
+ clear_batch_btn.click(
1418
+ clear_batch,
1419
+ inputs=None,
1420
+ outputs=[zip_file, batch_gallery, batch_status]
1421
+ )
1422
+
1423
+ # Select All/Unselect All handlers for batch processing
1424
+ batch_line_select_all.click(
1425
+ fn=lambda: MODEL_CLASSES["Line Detection"],
1426
+ outputs=[batch_line_classes]
1427
+ )
1428
+ batch_line_unselect_all.click(
1429
+ fn=lambda: [],
1430
+ outputs=[batch_line_classes]
1431
+ )
1432
+ batch_border_select_all.click(
1433
+ fn=lambda: MODEL_CLASSES["Border Detection"],
1434
+ outputs=[batch_border_classes]
1435
+ )
1436
+ batch_border_unselect_all.click(
1437
+ fn=lambda: [],
1438
+ outputs=[batch_border_classes]
1439
+ )
1440
+ batch_zones_select_all.click(
1441
+ fn=lambda: MODEL_CLASSES["Zones Detection"],
1442
+ outputs=[batch_zones_classes]
1443
+ )
1444
+ batch_zones_unselect_all.click(
1445
+ fn=lambda: [],
1446
+ outputs=[batch_zones_classes]
1447
+ )
1448
+
1449
+ # Connect download buttons
1450
+ download_json_btn.click(
1451
+ fn=download_annotations,
1452
+ inputs=[],
1453
+ outputs=[json_file_output]
1454
+ )
1455
+ download_zip_btn.click(
1456
+ fn=download_results_zip,
1457
+ inputs=[],
1458
+ outputs=[zip_file_output]
1459
+ )
1460
+
1461
+ # Connect single image download buttons
1462
+ single_download_json_btn.click(
1463
+ fn=download_single_annotations,
1464
+ inputs=[],
1465
+ outputs=[single_json_output]
1466
+ )
1467
+ single_download_image_btn.click(
1468
+ fn=download_single_image,
1469
+ inputs=[],
1470
+ outputs=[single_image_output]
1471
+ )
1472
+
1473
+ # Statistics download handlers for single image
1474
+ def download_single_stats_csv():
1475
+ global single_image_annotations, single_image_filename, single_image_selected_classes
1476
+ if single_image_annotations is None:
1477
+ return None
1478
+ stats = calculate_statistics(single_image_annotations, single_image_selected_classes)
1479
+ csv_path = create_statistics_csv(stats, single_image_filename)
1480
+ return csv_path
1481
+
1482
+ def download_single_stats_json():
1483
+ global single_image_annotations, single_image_filename, single_image_selected_classes
1484
+ if single_image_annotations is None:
1485
+ return None
1486
+ stats = calculate_statistics(single_image_annotations, single_image_selected_classes)
1487
+ json_path = create_statistics_json(stats, single_image_filename)
1488
+ return json_path
1489
+
1490
+ single_download_stats_csv_btn.click(
1491
+ fn=download_single_stats_csv,
1492
+ inputs=[],
1493
+ outputs=[single_stats_csv_output]
1494
+ )
1495
+ single_download_stats_json_btn.click(
1496
+ fn=download_single_stats_json,
1497
+ inputs=[],
1498
+ outputs=[single_stats_json_output]
1499
+ )
1500
+
1501
+ # Statistics download handlers for batch processing
1502
+ def download_batch_stats_csv():
1503
+ global current_results, current_batch_selected_classes
1504
+ if not current_results:
1505
+ return None
1506
+ csv_path = create_batch_statistics_csv(current_results, current_batch_selected_classes)
1507
+ return csv_path
1508
+
1509
+ def download_batch_stats_json():
1510
+ global current_results, current_batch_selected_classes
1511
+ if not current_results:
1512
+ return None
1513
+ json_path = create_batch_statistics_json(current_results, current_batch_selected_classes)
1514
+ return json_path
1515
+
1516
+ batch_download_stats_csv_btn.click(
1517
+ fn=download_batch_stats_csv,
1518
+ inputs=[],
1519
+ outputs=[batch_stats_csv_output]
1520
+ )
1521
+ batch_download_stats_json_btn.click(
1522
+ fn=download_batch_stats_json,
1523
+ inputs=[],
1524
+ outputs=[batch_stats_json_output]
1525
+ )
1526
+
1527
+ if __name__ == "__main__":
1528
+ # Configure launch settings for better stability
1529
+ # Enable Gradio queue for more robust concurrency and error isolation
1530
+ demo.queue()
1531
+ demo.launch(
1532
+ debug=False, # Disable debug mode for production
1533
+ show_error=True,
1534
+ server_name="0.0.0.0",
1535
+ server_port=8000,
1536
+ share=False,
1537
+ max_threads=4, # Limit concurrent requests
1538
+ auth=None,
1539
+ inbrowser=False,
1540
+ favicon_path=None,
1541
+ ssl_verify=True,
1542
+ quiet=False
1543
+ )
manifest.json ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "Medieval Manuscript Detection",
3
+ "short_name": "Manuscript Detection",
4
+ "description": "Medieval Manuscript Detection with Custom YOLO Models",
5
+ "start_url": "/",
6
+ "display": "standalone",
7
+ "background_color": "#ffffff",
8
+ "theme_color": "#000000",
9
+ "icons": []
10
+ }
11
+
utils/data.py ADDED
@@ -0,0 +1,417 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from database import (
2
+ fix_ids,
3
+ ImageModel,
4
+ CategoryModel,
5
+ AnnotationModel,
6
+ DatasetModel,
7
+ TaskModel,
8
+ ExportModel
9
+ )
10
+
11
+ # import pycocotools.mask as mask
12
+ import numpy as np
13
+ import time
14
+ import json
15
+ import os
16
+ import gc
17
+
18
+
19
+ from celery import shared_task
20
+ from ..socket import create_socket
21
+ from mongoengine import Q
22
+
23
+
24
+
25
+ @shared_task
26
+ def export_annotations(task_id, dataset_id, categories, with_empty_images=False):
27
+
28
+ task = TaskModel.objects.get(id=task_id)
29
+ dataset = DatasetModel.objects.get(id=dataset_id)
30
+
31
+ task.update(status="PROGRESS")
32
+ socket = create_socket()
33
+
34
+ task.info("Beginning Export (COCO Format)")
35
+
36
+ db_categories = CategoryModel.objects(id__in=categories, deleted=False) \
37
+ .only(*CategoryModel.COCO_PROPERTIES)
38
+ db_images = ImageModel.objects(
39
+ deleted=False, dataset_id=dataset.id).only(
40
+ *ImageModel.COCO_PROPERTIES)
41
+ db_annotations = AnnotationModel.objects(
42
+ deleted=False, category_id__in=categories)
43
+
44
+ total_items = db_categories.count()
45
+
46
+ coco = {
47
+ 'images': [],
48
+ 'categories': [],
49
+ 'annotations': []
50
+ }
51
+
52
+ total_items += db_images.count()
53
+ progress = 0
54
+
55
+ # iterate though all ccategories
56
+ category_names = []
57
+ for category in fix_ids(db_categories):
58
+
59
+ if len(category.get('keypoint_labels', [])) > 0:
60
+ category['keypoints'] = category.pop('keypoint_labels', [])
61
+ category['skeleton'] = category.pop('keypoint_edges', [])
62
+ else:
63
+ if 'keypoint_edges' in category:
64
+ del category['keypoint_edges']
65
+ if 'keypoint_labels' in category:
66
+ del category['keypoint_labels']
67
+
68
+ task.info(f"Adding category: {category.get('name')}")
69
+ coco.get('categories').append(category)
70
+ category_names.append(category.get('name'))
71
+
72
+ progress += 1
73
+ task.set_progress((progress / total_items) * 100, socket=socket)
74
+
75
+ total_annotations = db_annotations.count()
76
+ total_images = db_images.count()
77
+ for image in db_images:
78
+ image = fix_ids(image)
79
+
80
+ progress += 1
81
+ task.set_progress((progress / total_items) * 100, socket=socket)
82
+
83
+ annotations = db_annotations.filter(image_id=image.get('id'))\
84
+ .only(*AnnotationModel.COCO_PROPERTIES)
85
+ annotations = fix_ids(annotations)
86
+
87
+ if len(annotations) == 0:
88
+ if with_empty_images:
89
+ coco.get('images').append(image)
90
+ continue
91
+
92
+ num_annotations = 0
93
+ for annotation in annotations:
94
+
95
+ has_keypoints = len(annotation.get('keypoints', [])) > 0
96
+ has_segmentation = len(annotation.get('segmentation', [])) > 0
97
+
98
+ if has_keypoints or has_segmentation:
99
+
100
+ if not has_keypoints:
101
+ if 'keypoints' in annotation:
102
+ del annotation['keypoints']
103
+ else:
104
+ arr = np.array(annotation.get('keypoints', []))
105
+ arr = arr[2::3]
106
+ annotation['num_keypoints'] = len(arr[arr > 0])
107
+
108
+ num_annotations += 1
109
+ coco.get('annotations').append(annotation)
110
+
111
+ task.info(
112
+ f"Exporting {num_annotations} annotations for image {image.get('id')}")
113
+ coco.get('images').append(image)
114
+
115
+ task.info(
116
+ f"Done export {total_annotations} annotations and {total_images} images from {dataset.name}")
117
+
118
+ timestamp = time.time()
119
+ directory = f"{dataset.directory}.exports/"
120
+ file_path = f"{directory}coco-{timestamp}.json"
121
+
122
+ if not os.path.exists(directory):
123
+ os.makedirs(directory)
124
+
125
+ task.info(f"Writing export to file {file_path}")
126
+ with open(file_path, 'w') as fp:
127
+ json.dump(coco, fp)
128
+
129
+ task.info("Creating export object")
130
+ export = ExportModel(dataset_id=dataset.id, path=file_path, tags=[
131
+ "COCO", *category_names])
132
+ export.save()
133
+
134
+ task.set_progress(100, socket=socket)
135
+
136
+
137
+ def process_coco_file(coco_json,task,socket,dataset,images,categories):
138
+ coco_images = coco_json.get('images', [])
139
+ coco_annotations = coco_json.get('annotations', [])
140
+ coco_categories = coco_json.get('categories', [])
141
+
142
+ task.info(f"Importing {len(coco_categories)} categories, "
143
+ f"{len(coco_images)} images, and "
144
+ f"{len(coco_annotations)} annotations")
145
+
146
+ total_items = sum([
147
+ len(coco_categories),
148
+ len(coco_annotations),
149
+ len(coco_images)
150
+ ])
151
+ progress = 0
152
+
153
+ task.info("===== Importing Categories =====")
154
+ # category id mapping ( file : database )
155
+ categories_id = {}
156
+
157
+ # Create any missing categories
158
+ for category in coco_categories:
159
+
160
+ category_name = category.get('name')
161
+ category_id = category.get('id')
162
+ category_model = categories.filter(name__iexact=category_name).first()
163
+
164
+ if category_model is None:
165
+ task.warning(
166
+ f"{category_name} category not found (creating a new one)")
167
+
168
+ new_category = CategoryModel(
169
+ name=category_name,
170
+ keypoint_edges=category.get('skeleton', []),
171
+ keypoint_labels=category.get('keypoints', [])
172
+ )
173
+ new_category.save()
174
+
175
+ category_model = new_category
176
+ dataset.categories.append(new_category.id)
177
+
178
+ task.info(f"{category_name} category found")
179
+ # map category ids
180
+ categories_id[category_id] = category_model.id
181
+
182
+ # update progress
183
+ progress += 1
184
+ task.set_progress((progress / total_items) * 100, socket=socket)
185
+
186
+ dataset.update(set__categories=dataset.categories)
187
+
188
+ task.info("===== Loading Images =====")
189
+ # image id mapping ( file: database )
190
+ images_id = {}
191
+ categories_by_image = {}
192
+
193
+ # Find all images
194
+ for image in coco_images:
195
+ image_id = image.get('id')
196
+ image_filename = image.get('file_name')
197
+
198
+ # update progress
199
+ progress += 1
200
+ task.set_progress((progress / total_items) * 100, socket=socket)
201
+
202
+ image_model = images.filter(file_name__exact=image_filename).all()
203
+
204
+ if len(image_model) == 0:
205
+ task.warning(f"Could not find image {image_filename}")
206
+ continue
207
+
208
+ if len(image_model) > 1:
209
+ task.error(
210
+ f"Too many images found with the same file name: {image_filename}")
211
+ continue
212
+
213
+ task.info(f"Image {image_filename} found")
214
+ image_model = image_model[0]
215
+ images_id[image_id] = image_model
216
+ categories_by_image[image_id] = list()
217
+
218
+ task.info("===== Import Annotations =====")
219
+ for annotation in coco_annotations:
220
+
221
+ image_id = annotation.get('image_id')
222
+ category_id = annotation.get('category_id')
223
+ segmentation = annotation.get('segmentation', [])
224
+ keypoints = annotation.get('keypoints', [])
225
+ # is_crowd = annotation.get('iscrowed', False)
226
+ area = annotation.get('area', 0)
227
+ bbox = annotation.get('bbox', [0, 0, 0, 0])
228
+ isbbox = annotation.get('isbbox', False)
229
+
230
+ progress += 1
231
+ task.set_progress((progress / total_items) * 100, socket=socket)
232
+
233
+ has_segmentation = len(segmentation) > 0
234
+ has_keypoints = len(keypoints) > 0
235
+ if not has_segmentation and not has_keypoints:
236
+ task.warning(
237
+ f"Annotation {annotation.get('id')} has no segmentation or keypoints, but bbox {bbox}")
238
+ #continue
239
+
240
+ try:
241
+ image_model = images_id[image_id]
242
+ category_model_id = categories_id[category_id]
243
+ image_categories = categories_by_image[image_id]
244
+ except KeyError:
245
+ task.warning(
246
+ f"Could not find image assoicated with annotation {annotation.get('id')}")
247
+ continue
248
+
249
+ annotation_model = AnnotationModel.objects(
250
+ image_id=image_model.id,
251
+ category_id=category_model_id,
252
+ segmentation=segmentation,
253
+ keypoints=keypoints
254
+ ).first()
255
+
256
+ if annotation_model is None:
257
+ task.info(f"Creating annotation data ({image_id}, {category_id})")
258
+
259
+ annotation_model = AnnotationModel(image_id=image_model.id)
260
+ annotation_model.category_id = category_model_id
261
+
262
+ annotation_model.color = annotation.get('color')
263
+ annotation_model.metadata = annotation.get('metadata', {})
264
+ annotation_model.area = area
265
+ annotation_model.bbox = bbox
266
+
267
+ if has_segmentation:
268
+ annotation_model.segmentation = segmentation
269
+ else:
270
+ task.warning(
271
+ f"Annotation {annotation.get('id')} has no segmentation. Creating one from bbox {bbox}")
272
+
273
+ x_min, y_min, width, height = bbox
274
+ x_max = x_min + width
275
+ y_max = y_min + height
276
+ segments = [
277
+ x_max, y_min, # Top-right corner
278
+ x_max, y_max, # Bottom-right corner
279
+ x_min, y_max, # Bottom-left corner
280
+ x_min, y_min # Top-left corner
281
+ ]
282
+
283
+ annotation_model.segmentation = segments
284
+
285
+ if has_keypoints:
286
+ annotation_model.keypoints = keypoints
287
+
288
+ annotation_model.isbbox = isbbox
289
+ annotation_model.save()
290
+
291
+ image_categories.append(category_id)
292
+ else:
293
+ annotation_model.update(deleted=False, isbbox=isbbox)
294
+ task.info(
295
+ f"Annotation already exists (i:{image_id}, c:{category_id})")
296
+
297
+ for image_id in images_id:
298
+ image_model = images_id[image_id]
299
+ category_ids = categories_by_image[image_id]
300
+ all_category_ids = list(image_model.category_ids)
301
+ all_category_ids += category_ids
302
+
303
+ num_annotations = AnnotationModel.objects(
304
+ Q(image_id=image_id) & Q(deleted=False) &
305
+ (Q(area__gt=0) | Q(keypoints__size__gt=0))
306
+ ).count()
307
+
308
+ image_model.update(
309
+ set__annotated=True,
310
+ set__category_ids=list(set(all_category_ids)),
311
+ set__num_annotations=num_annotations
312
+ )
313
+
314
+ task.set_progress(100, socket=socket)
315
+
316
+
317
+ @shared_task
318
+ def import_annotations(task_id, dataset_id, coco_json):
319
+
320
+ task = TaskModel.objects.get(id=task_id)
321
+ dataset = DatasetModel.objects.get(id=dataset_id)
322
+
323
+ task.update(status="PROGRESS")
324
+ socket = create_socket()
325
+
326
+ task.info("Beginning Import")
327
+
328
+ images = ImageModel.objects(dataset_id=dataset.id)
329
+ categories = CategoryModel.objects
330
+
331
+ process_coco_file(coco_json,task,socket,dataset,images,categories)
332
+
333
+
334
+ @shared_task
335
+ def predict_annotations(task_id, model_name, image_path,image_id,dict_labels_folders):
336
+ from ultralytics import YOLO
337
+
338
+ if model_name=='emanuskript':
339
+ emanuskript_classes = [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,20]
340
+ model = YOLO("workers/best_emanuskript_segmentation.onnx",task='segment')
341
+ results = model.predict(image_path,classes = emanuskript_classes,
342
+ iou=0.3,device='cpu',augment=False,stream=False)
343
+
344
+ elif model_name=='catmus':
345
+ catmus_classes=[1,7]
346
+ model = YOLO("workers/best_catmus.onnx",task='segment')
347
+ results = model.predict(image_path,classes = catmus_classes,
348
+ iou=0.3,device='cpu',augment=False,stream=False)
349
+ elif model_name=='zone':
350
+ model = YOLO("workers/best_zone_detection.pt")
351
+ results = model.predict(image_path,device='cpu',
352
+ iou=0.3,
353
+ augment=False,stream=False)
354
+ else:
355
+ raise Exception('Model name must be one of emanuskript, catmus or zone')
356
+
357
+ # get the images to apply the model
358
+ task = TaskModel.objects.get(id=task_id)
359
+
360
+ # Save labels
361
+ result = results[0]
362
+ prediction_path = f'{dict_labels_folders[model_name]}/{image_id}.json'
363
+ with open(prediction_path,'w') as f:
364
+ f.write(result.tojson())
365
+
366
+ task.info(f'Labels predicted in : {prediction_path}')
367
+ task.update(status="COMPLETED")
368
+ del model
369
+ del result
370
+ del results
371
+ gc.collect()
372
+ return 1
373
+
374
+
375
+
376
+ @shared_task
377
+ def unify_predictions(results, task_id, dataset_id, images_path,dict_labels_folders):
378
+
379
+ #Results is unused by necessary for Celery Chord
380
+ from .image_batch_classes import ImageBatch
381
+
382
+ task = TaskModel.objects.get(id=task_id)
383
+ task.info(f'Starts prediction unification')
384
+ dataset = DatasetModel.objects.get(id=dataset_id)
385
+
386
+ image_batch = ImageBatch(
387
+ image_folder=images_path,
388
+ catmus_labels_folder=dict_labels_folders['catmus'],
389
+ emanuskript_labels_folder=dict_labels_folders['emanuskript'],
390
+ zone_labels_folder=dict_labels_folders['zone']
391
+ )
392
+ image_batch.load_images()
393
+ image_batch.load_annotations()
394
+ image_batch.unify_names()
395
+ coco_json = image_batch.return_coco_file()
396
+ task.info(f'COCO Json file created')
397
+
398
+ # Update task status
399
+ task.update(status="PROGRESS")
400
+ socket = create_socket()
401
+
402
+ images = ImageModel.objects(dataset_id=dataset_id)
403
+ categories = CategoryModel.objects
404
+
405
+ total_images = images.count()
406
+ task.info(f"Found {total_images} images to process")
407
+
408
+
409
+ process_coco_file(coco_json,task,socket,dataset,images,categories)
410
+
411
+
412
+
413
+
414
+
415
+
416
+
417
+ __all__ = ["export_annotations", "import_annotations","predict_annotations","unify_predictions"]
utils/database/__init__.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from mongoengine import connect
2
+ from config import Config
3
+
4
+ from .annotations import *
5
+ from .categories import *
6
+ from .datasets import *
7
+ from .lisence import *
8
+ from .exports import *
9
+ from .images import *
10
+ from .events import *
11
+ from .users import *
12
+ from .tasks import *
13
+
14
+ import json
15
+
16
+
17
+ def connect_mongo(name, host=None):
18
+ if host is None:
19
+ host = Config.MONGODB_HOST
20
+ connect(name, host=host)
21
+
22
+
23
+ # https://github.com/MongoEngine/mongoengine/issues/1171
24
+ # Use this methods until a solution is found
25
+ def upsert(model, query=None, update=None):
26
+
27
+ if not update:
28
+ update = query
29
+
30
+ if not query:
31
+ return None
32
+
33
+ found = model.objects(**query)
34
+
35
+ if found.first():
36
+ return found.modify(new=True, **update)
37
+
38
+ new_model = model(**update)
39
+ new_model.save()
40
+
41
+ return new_model
42
+
43
+
44
+ def fix_ids(q):
45
+ json_obj = json.loads(q.to_json().replace('\"_id\"', '\"id\"'))
46
+ return json_obj
47
+
48
+
49
+ def create_from_json(json_file):
50
+
51
+ with open(json_file) as file:
52
+
53
+ data_json = json.load(file)
54
+ for category in data_json.get('categories', []):
55
+ name = category.get('name')
56
+ if name is not None:
57
+ upsert(CategoryModel, query={"name": name}, update=category)
58
+
59
+ for dataset_json in data_json.get('datasets', []):
60
+ name = dataset_json.get('name')
61
+ if name:
62
+ # map category names to ids; create as needed
63
+ category_ids = []
64
+ for category in dataset_json.get('categories', []):
65
+ category_obj = {"name": category}
66
+
67
+ category_model = upsert(CategoryModel, query=category_obj)
68
+ category_ids.append(category_model.id)
69
+
70
+ dataset_json['categories'] = category_ids
71
+ upsert(DatasetModel, query={ "name": name}, update=dataset_json)
72
+
73
+
utils/database/annotations.py ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import imantics as im
2
+ import json
3
+
4
+ from mongoengine import *
5
+
6
+ from .datasets import DatasetModel
7
+ from .categories import CategoryModel
8
+ from .events import Event
9
+ from flask_login import current_user
10
+ import numpy as np
11
+ import cv2
12
+
13
+
14
+ class AnnotationModel(DynamicDocument):
15
+
16
+ COCO_PROPERTIES = ["id", "image_id", "category_id", "segmentation",
17
+ "iscrowd", "color", "area", "bbox", "metadata",
18
+ "keypoints", "isbbox"]
19
+
20
+ id = SequenceField(primary_key=True)
21
+ image_id = IntField(required=True)
22
+ category_id = IntField(required=True)
23
+ dataset_id = IntField()
24
+
25
+ segmentation = ListField(default=[])
26
+ area = IntField(default=0)
27
+ bbox = ListField(default=[0, 0, 0, 0])
28
+ iscrowd = BooleanField(default=False)
29
+ isbbox = BooleanField(default=False)
30
+
31
+ creator = StringField(required=True)
32
+ width = IntField()
33
+ height = IntField()
34
+
35
+ color = StringField()
36
+
37
+ keypoints = ListField(default=[])
38
+
39
+ metadata = DictField(default={})
40
+ paper_object = ListField(default=[])
41
+
42
+ deleted = BooleanField(default=False)
43
+ deleted_date = DateTimeField()
44
+
45
+ milliseconds = IntField(default=0)
46
+ events = EmbeddedDocumentListField(Event)
47
+
48
+ def __init__(self, image_id=None, **data):
49
+
50
+ from .images import ImageModel
51
+
52
+ if image_id is not None:
53
+ image = ImageModel.objects(id=image_id).first()
54
+
55
+ if image is not None:
56
+ data['image_id'] = image_id
57
+ data['width'] = image.width
58
+ data['height'] = image.height
59
+ data['dataset_id'] = image.dataset_id
60
+
61
+ super(AnnotationModel, self).__init__(**data)
62
+
63
+ def save(self, copy=False, *args, **kwargs):
64
+
65
+ if self.dataset_id and not copy:
66
+ dataset = DatasetModel.objects(id=self.dataset_id).first()
67
+
68
+ if dataset is not None:
69
+ self.metadata = dataset.default_annotation_metadata.copy()
70
+
71
+ if self.color is None:
72
+ self.color = im.Color.random().hex
73
+
74
+ if current_user:
75
+ self.creator = current_user.username
76
+ else:
77
+ self.creator = 'system'
78
+
79
+ return super(AnnotationModel, self).save(*args, **kwargs)
80
+
81
+ def is_empty(self):
82
+ return len(self.segmentation) == 0 or self.area == 0
83
+
84
+ def mask(self):
85
+ """ Returns binary mask of annotation """
86
+ mask = np.zeros((self.height, self.width))
87
+ pts = [
88
+ np.array(anno).reshape(-1, 2).round().astype(int)
89
+ for anno in self.segmentation
90
+ ]
91
+ mask = cv2.fillPoly(mask, pts, 1)
92
+ return mask
93
+
94
+ def clone(self):
95
+ """ Creates a clone """
96
+ create = json.loads(self.to_json())
97
+ del create['_id']
98
+
99
+ return AnnotationModel(**create)
100
+
101
+ def __call__(self):
102
+
103
+ category = CategoryModel.objects(id=self.category_id).first()
104
+ if category:
105
+ category = category()
106
+
107
+ data = {
108
+ 'image': None,
109
+ 'category': category,
110
+ 'color': self.color,
111
+ 'polygons': self.segmentation,
112
+ 'width': self.width,
113
+ 'height': self.height,
114
+ 'metadata': self.metadata
115
+ }
116
+
117
+ return im.Annotation(**data)
118
+
119
+ def add_event(self, e):
120
+ self.update(push__events=e)
121
+
122
+
123
+ __all__ = ["AnnotationModel"]
utils/database/categories.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ from flask_login import current_user
3
+ from mongoengine import *
4
+
5
+ import imantics as im
6
+
7
+
8
+ class CategoryModel(DynamicDocument):
9
+
10
+ COCO_PROPERTIES = ["id", "name", "supercategory", "color", "metadata",\
11
+ "keypoint_edges", "keypoint_labels", "keypoint_colors"]
12
+
13
+ id = SequenceField(primary_key=True)
14
+ name = StringField(required=True, unique_with=['creator'])
15
+ supercategory = StringField(default='')
16
+ color = StringField(default=None)
17
+ metadata = DictField(default={})
18
+
19
+ creator = StringField(default='unknown')
20
+ deleted = BooleanField(default=False)
21
+ deleted_date = DateTimeField()
22
+
23
+ keypoint_edges = ListField(default=[])
24
+ keypoint_labels = ListField(default=[])
25
+ keypoint_colors = ListField(default=[])
26
+
27
+ @classmethod
28
+ def bulk_create(cls, categories):
29
+
30
+ if not categories:
31
+ return []
32
+
33
+ category_ids = []
34
+ for category in categories:
35
+ category_model = CategoryModel.objects(name=category).first()
36
+
37
+ if category_model is None:
38
+ new_category = CategoryModel(name=category)
39
+ new_category.save()
40
+ category_ids.append(new_category.id)
41
+ else:
42
+ category_ids.append(category_model.id)
43
+
44
+ return category_ids
45
+
46
+ def save(self, *args, **kwargs):
47
+
48
+ if not self.color:
49
+ self.color = im.Color.random().hex
50
+
51
+ if current_user:
52
+ self.creator = current_user.username
53
+ else:
54
+ self.creator = 'system'
55
+
56
+ return super(CategoryModel, self).save(*args, **kwargs)
57
+
58
+ def __call__(self):
59
+ """ Generates imantics category object """
60
+ data = {
61
+ 'name': self.name,
62
+ 'color': self.color,
63
+ 'parent': self.supercategory,
64
+ 'metadata': self.metadata,
65
+ 'id': self.id
66
+ }
67
+ return im.Category(**data)
68
+
69
+ def is_owner(self, user):
70
+
71
+ if user.is_admin:
72
+ return True
73
+
74
+ return user.username.lower() == self.creator.lower()
75
+
76
+ def can_edit(self, user):
77
+ return self.is_owner(user)
78
+
79
+ def can_delete(self, user):
80
+ return self.is_owner(user)
81
+
82
+
83
+ __all__ = ["CategoryModel"]
utils/database/datasets.py ADDED
@@ -0,0 +1,204 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ from flask_login import current_user
3
+ from mongoengine import *
4
+ from config import Config
5
+
6
+ from .tasks import TaskModel
7
+
8
+ import os
9
+
10
+
11
+ class DatasetModel(DynamicDocument):
12
+
13
+ id = SequenceField(primary_key=True)
14
+ name = StringField(required=True, unique=True)
15
+ directory = StringField()
16
+ thumbnails = StringField()
17
+ categories = ListField(default=[])
18
+
19
+ owner = StringField(required=True)
20
+ users = ListField(default=[])
21
+
22
+ annotate_url = StringField(default="")
23
+
24
+ default_annotation_metadata = DictField(default={})
25
+
26
+ deleted = BooleanField(default=False)
27
+ deleted_date = DateTimeField()
28
+
29
+ def save(self, *args, **kwargs):
30
+
31
+ directory = os.path.join(Config.DATASET_DIRECTORY, self.name + '/')
32
+ os.makedirs(directory, mode=0o777, exist_ok=True)
33
+
34
+ self.directory = directory
35
+ self.owner = current_user.username if current_user else 'system'
36
+
37
+ return super(DatasetModel, self).save(*args, **kwargs)
38
+
39
+ def get_users(self):
40
+ from .users import UserModel
41
+
42
+ members = self.users
43
+ members.append(self.owner)
44
+
45
+ return UserModel.objects(username__in=members)\
46
+ .exclude('password', 'id', 'preferences')
47
+
48
+ def import_coco(self, coco_json):
49
+
50
+ from workers.tasks import import_annotations
51
+
52
+ task = TaskModel(
53
+ name="Import COCO format into {}".format(self.name),
54
+ dataset_id=self.id,
55
+ group="Annotation Import"
56
+ )
57
+ task.save()
58
+
59
+ cel_task = import_annotations.delay(task.id, self.id, coco_json)
60
+
61
+ return {
62
+ "celery_id": cel_task.id,
63
+ "id": task.id,
64
+ "name": task.name
65
+ }
66
+
67
+
68
+ def predict_coco(self):
69
+
70
+ from workers.tasks import predict_annotations,unify_predictions
71
+ from celery import chord
72
+
73
+ # Setup
74
+ #TODO Get images from the image model
75
+ images_path = self.directory
76
+
77
+ catmus_labels_folder = os.path.join(images_path, 'labels', 'catmus')
78
+ emanuskript_labels_folder = os.path.join(images_path, 'labels', 'emanuskript')
79
+ zone_detection_labels_folder = os.path.join(images_path, 'labels', 'zone_detection')
80
+
81
+ dict_labels_folders = {'catmus':catmus_labels_folder,
82
+ 'emanuskript':emanuskript_labels_folder,
83
+ 'zone':zone_detection_labels_folder}
84
+
85
+ for label_path in [dict_labels_folders['catmus'],dict_labels_folders['emanuskript'],dict_labels_folders['zone']]:
86
+ os.makedirs(label_path,exist_ok=True)
87
+
88
+ #Predict
89
+
90
+ image_files = [f for f in os.listdir(images_path) if f.lower().endswith(('.jpg', '.jpeg', '.png'))]
91
+
92
+ prediction_tasks = []
93
+
94
+ for image_path in image_files:
95
+ image_id = os.path.splitext(os.path.basename(image_path))[0]
96
+ image_full_path = os.path.join(images_path, image_path)
97
+ for model in dict_labels_folders.keys():
98
+
99
+ task = TaskModel(
100
+ name=f"Predicting {model} annotations for {image_id}",
101
+ dataset_id=self.id,
102
+ group="Annotation Prediction"
103
+ )
104
+ task.save()
105
+ prediction_tasks.append(predict_annotations.s(task.id, model, image_full_path,image_id,dict_labels_folders))
106
+
107
+ # List to hold the task details for each image
108
+
109
+ unify_task = TaskModel(
110
+ name=f"Unifying annotations for dataset {self.name}",
111
+ dataset_id=self.id,
112
+ group="Annotation Prediction"
113
+ )
114
+ unify_task.save()
115
+
116
+ # This task will be triggered after all image predictions are completed
117
+ unify_task_signature = unify_predictions.s(unify_task.id, self.id, images_path, dict_labels_folders)
118
+
119
+ # Use Celery `chord` to handle the parallel predictions and trigger unification
120
+
121
+ chord(prediction_tasks)(unify_task_signature)
122
+
123
+ return {
124
+ "unify_task_id": unify_task.id,
125
+
126
+ }
127
+
128
+
129
+
130
+ def export_coco(self, categories=None, style="COCO", with_empty_images=False):
131
+
132
+ from workers.tasks import export_annotations
133
+
134
+ if categories is None or len(categories) == 0:
135
+ categories = self.categories
136
+
137
+ task = TaskModel(
138
+ name=f"Exporting {self.name} into {style} format",
139
+ dataset_id=self.id,
140
+ group="Annotation Export"
141
+ )
142
+ task.save()
143
+
144
+ cel_task = export_annotations.delay(task.id, self.id, categories, with_empty_images)
145
+
146
+ return {
147
+ "celery_id": cel_task.id,
148
+ "id": task.id,
149
+ "name": task.name
150
+ }
151
+
152
+ def scan(self):
153
+
154
+ from workers.tasks import scan_dataset
155
+
156
+ task = TaskModel(
157
+ name=f"Scanning {self.name} for new images",
158
+ dataset_id=self.id,
159
+ group="Directory Image Scan"
160
+ )
161
+ task.save()
162
+
163
+ cel_task = scan_dataset.delay(task.id, self.id)
164
+
165
+ return {
166
+ "celery_id": cel_task.id,
167
+ "id": task.id,
168
+ "name": task.name
169
+ }
170
+
171
+ def is_owner(self, user):
172
+
173
+ if user.is_admin:
174
+ return True
175
+
176
+ return user.username.lower() == self.owner.lower()
177
+
178
+ def can_download(self, user):
179
+ return self.is_owner(user)
180
+
181
+ def can_delete(self, user):
182
+ return self.is_owner(user)
183
+
184
+ def can_share(self, user):
185
+ return self.is_owner(user)
186
+
187
+ def can_generate(self, user):
188
+ return self.is_owner(user)
189
+
190
+ def can_edit(self, user):
191
+ return user.username in self.users or self.is_owner(user)
192
+
193
+ def permissions(self, user):
194
+ return {
195
+ 'owner': self.is_owner(user),
196
+ 'edit': self.can_edit(user),
197
+ 'share': self.can_share(user),
198
+ 'generate': self.can_generate(user),
199
+ 'delete': self.can_delete(user),
200
+ 'download': self.can_download(user)
201
+ }
202
+
203
+
204
+ __all__ = ["DatasetModel"]
utils/database/events.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from mongoengine import *
2
+
3
+ import datetime
4
+ import time
5
+
6
+
7
+ class Event(EmbeddedDocument):
8
+
9
+ name = StringField()
10
+ created_at = DateTimeField()
11
+
12
+ meta = {'allow_inheritance': True}
13
+
14
+ def now(self, event):
15
+ self.created_at = datetime.datetime.now()
16
+
17
+
18
+ class SessionEvent(Event):
19
+
20
+ user = StringField(required=True)
21
+ milliseconds = IntField(default=0, min_value=0)
22
+ tools_used = ListField(default=[])
23
+
24
+ @classmethod
25
+ def create(self, start, user, end=None, tools=[]):
26
+
27
+ if end is None:
28
+ end = time.time()
29
+
30
+ return SessionEvent(
31
+ user=user.username,
32
+ milliseconds=int((end-start)*1000)
33
+ )
34
+
35
+
36
+ __all__ = ["Event", "SessionEvent"]
utils/database/exports.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from mongoengine import *
2
+
3
+ import datetime
4
+ import time
5
+
6
+
7
+ class ExportModel(DynamicDocument):
8
+
9
+ id = SequenceField(primary_key=True)
10
+ dataset_id = IntField(required=True)
11
+ path = StringField(required=True)
12
+ tags = ListField(default=[])
13
+ categories = ListField(default=[])
14
+ created_at = DateTimeField(default=datetime.datetime.utcnow)
15
+
16
+ def get_file(self):
17
+ return
18
+
19
+
20
+ __all__ = ["ExportModel"]
utils/database/images.py ADDED
@@ -0,0 +1,248 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import numpy as np
3
+ import cv2
4
+ import imantics as im
5
+
6
+
7
+ from PIL import Image, ImageFile
8
+ from mongoengine import *
9
+
10
+ from .events import Event, SessionEvent
11
+ from .datasets import DatasetModel
12
+ from .annotations import AnnotationModel
13
+
14
+
15
+ ImageFile.LOAD_TRUNCATED_IMAGES = True
16
+
17
+
18
+ class ImageModel(DynamicDocument):
19
+
20
+ COCO_PROPERTIES = ["id", "width", "height", "file_name", "path", "license",\
21
+ "flickr_url", "coco_url", "date_captured", "dataset_id"]
22
+
23
+ # -- Contants
24
+ THUMBNAIL_DIRECTORY = '.thumbnail'
25
+ PATTERN = (".gif", ".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff", ".GIF", ".PNG", ".JPG", ".JPEG", ".BMP", ".TIF", ".TIFF")
26
+
27
+ # Set maximum thumbnail size (h x w) to use on dataset page
28
+ MAX_THUMBNAIL_DIM = (1024, 1024)
29
+
30
+ # -- Private
31
+ _dataset = None
32
+
33
+ # -- Database
34
+ id = SequenceField(primary_key=True)
35
+ dataset_id = IntField(required=True)
36
+ category_ids = ListField(default=[])
37
+
38
+ # Absolute path to image file
39
+ path = StringField(required=True, unique=True)
40
+ width = IntField(required=True)
41
+ height = IntField(required=True)
42
+ file_name = StringField()
43
+
44
+ # True if the image is annotated
45
+ annotated = BooleanField(default=False)
46
+ # Poeple currently annotation the image
47
+ annotating = ListField(default=[])
48
+ num_annotations = IntField(default=0)
49
+
50
+ thumbnail_url = StringField()
51
+ image_url = StringField()
52
+ coco_url = StringField()
53
+ date_captured = DateTimeField()
54
+
55
+ metadata = DictField()
56
+ license = IntField()
57
+
58
+ deleted = BooleanField(default=False)
59
+ deleted_date = DateTimeField()
60
+
61
+ milliseconds = IntField(default=0)
62
+ events = EmbeddedDocumentListField(Event)
63
+ regenerate_thumbnail = BooleanField(default=False)
64
+
65
+ @classmethod
66
+ def create_from_path(cls, path, dataset_id=None):
67
+
68
+ pil_image = Image.open(path)
69
+
70
+ image = cls()
71
+ image.file_name = os.path.basename(path)
72
+ image.path = path
73
+ image.width = pil_image.size[0]
74
+ image.height = pil_image.size[1]
75
+ image.regenerate_thumbnail = True
76
+
77
+ if dataset_id is not None:
78
+ image.dataset_id = dataset_id
79
+ else:
80
+ # Get dataset name from path
81
+ folders = path.split('/')
82
+ i = folders.index("datasets")
83
+ dataset_name = folders[i+1]
84
+
85
+ dataset = DatasetModel.objects(name=dataset_name).first()
86
+ if dataset is not None:
87
+ image.dataset_id = dataset.id
88
+
89
+ pil_image.close()
90
+
91
+ return image
92
+
93
+ def delete(self, *args, **kwargs):
94
+ self.thumbnail_delete()
95
+ AnnotationModel.objects(image_id=self.id).delete()
96
+ return super(ImageModel, self).delete(*args, **kwargs)
97
+
98
+ def thumbnail(self):
99
+ """
100
+ Generates (if required) thumbnail
101
+ """
102
+
103
+ thumbnail_path = self.thumbnail_path()
104
+
105
+ if self.regenerate_thumbnail:
106
+
107
+ pil_image = self.generate_thumbnail()
108
+ pil_image = pil_image.convert("RGB")
109
+
110
+ # Resize image to fit in MAX_THUMBNAIL_DIM envelope as necessary
111
+ pil_image.thumbnail((self.MAX_THUMBNAIL_DIM[1], self.MAX_THUMBNAIL_DIM[0]))
112
+
113
+ # Save as a jpeg to improve loading time
114
+ # (note file extension will not match but allows for backwards compatibility)
115
+ pil_image.save(thumbnail_path, "JPEG", quality=80, optimize=True, progressive=True)
116
+
117
+ self.update(is_modified=False)
118
+ return pil_image
119
+
120
+ def open_thumbnail(self):
121
+ """
122
+ Return thumbnail
123
+ """
124
+ thumbnail_path = self.thumbnail_path()
125
+ return Image.open(thumbnail_path)
126
+
127
+ def thumbnail_path(self):
128
+ folders = self.path.split('/')
129
+ folders.insert(len(folders)-1, self.THUMBNAIL_DIRECTORY)
130
+
131
+ path = '/' + os.path.join(*folders)
132
+ directory = os.path.dirname(path)
133
+
134
+ if not os.path.exists(directory):
135
+ os.makedirs(directory)
136
+
137
+ return path
138
+
139
+ def thumbnail_delete(self):
140
+ path = self.thumbnail_path()
141
+ if os.path.isfile(path):
142
+ os.remove(path)
143
+
144
+ def generate_thumbnail(self):
145
+ # Get the image
146
+ image = self()
147
+
148
+ # Check if the image has a 'draw' method
149
+ if hasattr(image, 'draw'):
150
+ # Call the 'draw' method if it exists
151
+ image = image.draw(color_by_category=True, bbox=False)
152
+
153
+ # Check if the image is already a NumPy array
154
+ if isinstance(image, np.ndarray):
155
+ # Convert NumPy array to PIL image
156
+ return Image.fromarray(image)
157
+ else:
158
+ # If the image is not a NumPy array, return it as is (assuming it's already a PIL Image object)
159
+ print("Returning the original image as it is not a NumPy array.")
160
+ return image
161
+
162
+ def flag_thumbnail(self, flag=True):
163
+ """
164
+ Toggles values to regenerate thumbnail on next thumbnail request
165
+ """
166
+ if self.regenerate_thumbnail != flag:
167
+ self.update(regenerate_thumbnail=flag)
168
+
169
+ def copy_annotations(self, annotations):
170
+ """
171
+ Creates a copy of the annotations for this image
172
+ :param annotations: QuerySet of annotation models
173
+ :return: number of annotations
174
+ """
175
+ annotations = annotations.filter(
176
+ width=self.width, height=self.height).exclude('events')
177
+
178
+ for annotation in annotations:
179
+ if annotation.area > 0 or len(annotation.keypoints) > 0:
180
+ clone = annotation.clone()
181
+
182
+ clone.dataset_id = self.dataset_id
183
+ clone.image_id = self.id
184
+
185
+ clone.save(copy=True)
186
+
187
+ return annotations.count()
188
+
189
+ @property
190
+ def dataset(self):
191
+ if self._dataset is None:
192
+ self._dataset = DatasetModel.objects(id=self.dataset_id).first()
193
+ return self._dataset
194
+
195
+
196
+
197
+ def __call__(self):
198
+ print('ENTERS HERE for this path:', self.path)
199
+
200
+ # Check if the file exists before trying to load it
201
+ if os.path.exists(self.path):
202
+ # Try to load the image using OpenCV
203
+ brg = cv2.imread(self.path)
204
+
205
+ if brg is not None:
206
+ # If the image is successfully loaded, proceed with annotations
207
+ image = im.Image.from_path(self.path)
208
+
209
+ for annotation in AnnotationModel.objects(image_id=self.id, deleted=False).all():
210
+ if not annotation.is_empty():
211
+ image.add(annotation())
212
+
213
+ else:
214
+ # Handle the case where the file exists but cannot be loaded (e.g., unsupported format)
215
+ print(f"File at path {self.path} cannot be loaded. Returning a blank image.")
216
+ image = Image.new("RGB", (512, 512), (255, 255, 255)) # Modify size/color as needed
217
+ else:
218
+ # Handle the case where the file does not exist
219
+ print(f"No image found at path: {self.path}. Returning a blank image.")
220
+ image = Image.new("RGB", (512, 512), (255, 255, 255)) # Modify size/color as needed
221
+
222
+ return image
223
+
224
+
225
+ def can_delete(self, user):
226
+ return user.can_delete(self.dataset)
227
+
228
+ def can_download(self, user):
229
+ return user.can_download(self.dataset)
230
+
231
+ # TODO: Fix why using the functions throws an error
232
+ def permissions(self, user):
233
+ return {
234
+ 'delete': True,
235
+ 'download': True
236
+ }
237
+
238
+ def add_event(self, e):
239
+ u = {
240
+ 'push__events': e,
241
+ }
242
+ if isinstance(e, SessionEvent):
243
+ u['inc__milliseconds'] = e.milliseconds
244
+
245
+ self.update(**u)
246
+
247
+
248
+ __all__ = ["ImageModel"]
utils/database/lisence.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ from mongoengine import *
2
+
3
+
4
+ class LicenseModel(DynamicDocument):
5
+ id = SequenceField(primary_key=True)
6
+ name = StringField()
7
+ url = StringField()
8
+
9
+
10
+ __all__ = ["LicenseModel"]
utils/database/tasks.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from mongoengine import *
2
+
3
+ import datetime
4
+
5
+
6
+ class TaskModel(DynamicDocument):
7
+ id = SequenceField(primary_key=True)
8
+
9
+ # Type of task: Importer, Exporter, Scanner, etc.
10
+ group = StringField(required=True)
11
+ name = StringField(required=True)
12
+ desciption = StringField()
13
+ status = StringField(default="PENDING")
14
+ creator = StringField()
15
+
16
+ #: Start date of the executor
17
+ start_date = DateTimeField()
18
+ #: End date of the executor
19
+ end_date = DateTimeField()
20
+ completed = BooleanField(default=False)
21
+ failed = BooleanField(default=False)
22
+ has_download = BooleanField(default=False)
23
+
24
+ # If any of the information is relevant to the task
25
+ # it should be added
26
+ dataset_id = IntField()
27
+ image_id = IntField()
28
+ category_id = IntField()
29
+
30
+ progress = FloatField(default=0, min_value=0, max_value=100)
31
+
32
+ logs = ListField(default=[])
33
+ errors = IntField(default=0)
34
+ warnings = IntField(default=0)
35
+
36
+ priority = IntField()
37
+
38
+ metadata = DictField(default={})
39
+
40
+ _update_every = 10
41
+ _progress_update = 0
42
+
43
+ def error(self, string):
44
+ self._log(string, level="ERROR")
45
+
46
+ def warning(self, string):
47
+ self._log(string, level="WARNING")
48
+
49
+ def info(self, string):
50
+ self._log(string, level="INFO")
51
+
52
+ def _log(self, string, level):
53
+
54
+ level = level.upper()
55
+ date = datetime.datetime.now().strftime("%d-%m-%Y %H:%M:%S")
56
+
57
+ message = f"[{date}] [{level}] {string}"
58
+
59
+ statment = {
60
+ 'push__logs': message
61
+ }
62
+
63
+ if level == "ERROR":
64
+ statment['inc__errors'] = 1
65
+ self.errors += 1
66
+
67
+ if level == "WARNING":
68
+ statment['inc__warnings'] = 1
69
+ self.warnings += 1
70
+
71
+ self.update(**statment)
72
+
73
+ def set_progress(self, percent, socket=None):
74
+
75
+ self.update(progress=int(percent), completed=(percent >= 100))
76
+
77
+ # Send socket update every 10%
78
+ if self._progress_update < percent or percent >= 100:
79
+
80
+ if socket is not None:
81
+ # logger.debug(f"Emitting {percent} progress update for task {self.id}")
82
+
83
+ socket.emit('taskProgress', {
84
+ 'id': self.id,
85
+ 'progress': percent,
86
+ 'errors': self.errors,
87
+ 'warnings': self.warnings
88
+ }, broadcast=True)
89
+
90
+ self._progress_update += self._update_every
91
+
92
+ def api_json(self):
93
+ return {
94
+ "id": self.id,
95
+ "name": self.name
96
+ }
97
+
98
+
99
+ __all__ = ["TaskModel"]
utils/database/users.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import datetime
2
+
3
+ from mongoengine import *
4
+ from flask_login import UserMixin
5
+
6
+ from .annotations import AnnotationModel
7
+ from .categories import CategoryModel
8
+ from .datasets import DatasetModel
9
+ from .images import ImageModel
10
+
11
+
12
+ class UserModel(DynamicDocument, UserMixin):
13
+
14
+ password = StringField(required=True)
15
+ username = StringField(max_length=25, required=True, unique=True)
16
+ email = StringField(max_length=30)
17
+
18
+ name = StringField()
19
+ online = BooleanField(default=False)
20
+ last_seen = DateTimeField()
21
+
22
+ is_admin = BooleanField(default=False)
23
+
24
+ preferences = DictField(default={})
25
+ permissions = ListField(defualt=[])
26
+
27
+ # meta = {'allow_inheritance': True}
28
+
29
+ @property
30
+ def datasets(self):
31
+ self._update_last_seen()
32
+
33
+ if self.is_admin:
34
+ return DatasetModel.objects
35
+
36
+ return DatasetModel.objects(Q(owner=self.username) | Q(users__contains=self.username))
37
+
38
+ @property
39
+ def categories(self):
40
+ self._update_last_seen()
41
+
42
+ if self.is_admin:
43
+ return CategoryModel.objects
44
+
45
+ dataset_ids = self.datasets.distinct('categories')
46
+ return CategoryModel.objects(Q(id__in=dataset_ids) | Q(creator=self.username))
47
+
48
+ @property
49
+ def images(self):
50
+ self._update_last_seen()
51
+
52
+ if self.is_admin:
53
+ return ImageModel.objects
54
+
55
+ dataset_ids = self.datasets.distinct('id')
56
+ return ImageModel.objects(dataset_id__in=dataset_ids)
57
+
58
+ @property
59
+ def annotations(self):
60
+ self._update_last_seen()
61
+
62
+ if self.is_admin:
63
+ return AnnotationModel.objects
64
+
65
+ image_ids = self.images.distinct('id')
66
+ return AnnotationModel.objects(image_id__in=image_ids)
67
+
68
+ def can_view(self, model):
69
+ if model is None:
70
+ return False
71
+
72
+ return model.can_view(self)
73
+
74
+ def can_download(self, model):
75
+ if model is None:
76
+ return False
77
+
78
+ return model.can_download(self)
79
+
80
+ def can_delete(self, model):
81
+ if model is None:
82
+ return False
83
+ return model.can_delete(self)
84
+
85
+ def can_edit(self, model):
86
+ if model is None:
87
+ return False
88
+
89
+ return model.can_edit(self)
90
+
91
+ def _update_last_seen(self):
92
+ self.update(last_seen=datetime.datetime.utcnow())
93
+
94
+
95
+
96
+ __all__ = ["UserModel"]
utils/image_batch_classes.py ADDED
@@ -0,0 +1,417 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ import json
3
+ import numpy as np
4
+ from PIL import Image as PILImage
5
+ import os
6
+ from rtree import index
7
+ from shapely.geometry import box
8
+
9
+ import matplotlib.pyplot as plt
10
+ import matplotlib.patches as patches
11
+
12
+
13
+ # Constants for category mappings
14
+ catmus_zones_mapping = {
15
+ 'DefaultLine': 'Main script black',
16
+ 'InterlinearLine': 'Gloss',
17
+ 'MainZone': 'Column',
18
+ 'DropCapitalZone': 'Plain initial- coloured',
19
+ 'StampZone': 'Illustrations',
20
+ 'GraphicZone': 'Illustrations',
21
+ 'MarginTextZone': 'Gloss',
22
+ 'MusicZone': 'Music',
23
+ 'NumberingZone': 'Page Number',
24
+ 'QuireMarksZone': 'Quire Mark',
25
+ 'RunningTitleZone': 'Running header',
26
+ 'TitlePageZone': 'Column'
27
+ }
28
+
29
+ coco_class_mapping = {
30
+ 'Border': 1,
31
+ 'Table': 2,
32
+ 'Diagram': 3,
33
+ 'Main script black': 4,
34
+ 'Main script coloured': 5,
35
+ 'Variant script black': 6,
36
+ 'Variant script coloured': 7,
37
+ 'Historiated': 8,
38
+ 'Inhabited': 9,
39
+ 'Zoo - Anthropomorphic': 10,
40
+ 'Embellished': 11,
41
+ 'Plain initial- coloured': 12,
42
+ 'Plain initial - Highlighted': 13,
43
+ 'Plain initial - Black': 14,
44
+ 'Page Number': 15,
45
+ 'Quire Mark': 16,
46
+ 'Running header': 17,
47
+ 'Catchword': 18,
48
+ 'Gloss': 19,
49
+ 'Illustrations': 20,
50
+ 'Column': 21,
51
+ 'GraphicZone': 22,
52
+ 'MusicLine': 23,
53
+ 'MusicZone': 24,
54
+ 'Music': 25
55
+ }
56
+
57
+
58
+ class Annotation:
59
+ def __init__(self, annotation, image):
60
+ self.name = annotation['name']
61
+ self.cls = annotation['class']
62
+ self.confidence = annotation['confidence']
63
+ self.bbox = annotation['box']
64
+ self.segments = annotation['segments'] if 'segments' in annotation else None
65
+ #Annotation contains name, class, confidence, bbox and segments
66
+ self.image = image
67
+
68
+ def set_id(self, id):
69
+ self.id = id
70
+
71
+ def fix_empty_segments(self,x_coords,y_coords):
72
+ self.segments = {'x': x_coords, 'y': y_coords}
73
+
74
+ def segments_to_coco_format(self, segment_dict):
75
+ coco_segment = []
76
+ for x, y in zip(segment_dict['x'], segment_dict['y']):
77
+ coco_segment.append(x)
78
+ coco_segment.append(y)
79
+ return [coco_segment]
80
+
81
+ def bbox_to_coco_format(self, box):
82
+ x = box['x1']
83
+ y = box['y1']
84
+ width = box['x2'] - box['x1']
85
+ height = box['y2'] - box['y1']
86
+ return [x, y, width, height]
87
+
88
+ def polygon_area(self, segment_dict):
89
+ #Showlace formula for area of polygon
90
+ x = segment_dict['x']
91
+ y = segment_dict['y']
92
+ return 0.5 * np.abs(np.dot(x, np.roll(y, 1)) - np.dot(y, np.roll(x, 1)))
93
+
94
+ def unify_names(self):
95
+ self.name = catmus_zones_mapping.get(self.name, self.name)
96
+
97
+ def to_coco_format(self, current_annotation_id):
98
+ cls_string = catmus_zones_mapping.get(self.name, self.name)
99
+ cls_int = coco_class_mapping[cls_string]
100
+
101
+ if self.segments:
102
+ segmentation = self.segments_to_coco_format(self.segments)
103
+ area = self.polygon_area(self.segments)
104
+
105
+ else:
106
+ segmentation = []
107
+ width = self.bbox['x2'] - self.bbox['x1']
108
+ height = self.bbox['y2'] - self.bbox['y1']
109
+ area = width * height
110
+
111
+ annotation_dict = {
112
+ "id": current_annotation_id,
113
+ "image_id": self.image.id,
114
+ "category_id": cls_int,
115
+ "segmentation": segmentation,
116
+ "area": area,
117
+ "bbox": self.bbox_to_coco_format(self.bbox),
118
+ "iscrowd": 0,
119
+ "attributes": {"occluded": False}
120
+ }
121
+ return annotation_dict
122
+
123
+
124
+ class Image:
125
+ def __init__(self, image_path, image_id):
126
+ self.path = image_path
127
+ self.id = image_id
128
+ self.filename = os.path.basename(image_path)
129
+ self.width, self.height = self._get_image_dimensions()
130
+ self.annotations = []
131
+ self.spatial_index = index.Index()
132
+ self.deleted_indices = []
133
+ self.annotations_dict = {}
134
+
135
+ def _get_image_dimensions(self):
136
+ with PILImage.open(self.path) as img:
137
+ return img.size
138
+
139
+
140
+ def process_intersection(self, new_box, relevant_classes, overlap_threshold, percentage_dividend, index_to_remove=-1):
141
+ """
142
+ Processes intersection of a new bounding box with existing bounding boxes in the spatial index.
143
+
144
+ :param new_box: The new bounding box to check for intersections.
145
+ :param relevant_classes: List of relevant classes to consider for processing.
146
+ :param overlap_threshold: Minimum overlap percentage threshold to consider an intersection.
147
+ :param percentage_dividend: Criterion for calculating percentage overlap ('new_box', 'match_bbox', 'symmetric').
148
+ :param index_to_remove: Index to remove from self.deleted_indices; if -1, remove the intersecting box.
149
+ """
150
+ # Find possible matches using spatial index
151
+ possible_matches = self.spatial_index.intersection(new_box.bounds, objects=True)
152
+
153
+ # Iterate over possible matches
154
+ for match in possible_matches:
155
+ # Filter matches based on relevant classes
156
+ if match.object['class'] not in relevant_classes:
157
+ continue
158
+
159
+ # Create bounding box for the matched object
160
+ match_bbox = box(*match.bbox)
161
+
162
+ # Calculate the intersection area
163
+ intersection_area = new_box.intersection(match_bbox).area
164
+
165
+ # Calculate percentage intersection based on the specified dividend
166
+ if percentage_dividend == 'new_box':
167
+ percentage_intersection = intersection_area / new_box.area
168
+ elif percentage_dividend == 'match_bbox':
169
+ percentage_intersection = intersection_area / match_bbox.area
170
+ elif percentage_dividend == 'symmetric':
171
+ # Ensure that both percentages meet the threshold
172
+ percentage_intersection = min(intersection_area / new_box.area, intersection_area / match_bbox.area)
173
+ else:
174
+ raise ValueError("Invalid percentage_dividend value. Must be 'new_box', 'match_bbox', or 'symmetric'.")
175
+
176
+ # Append to deleted indices if conditions are met and avoid duplicates
177
+ if percentage_intersection > overlap_threshold:
178
+ to_remove = index_to_remove if index_to_remove != -1 else match.id
179
+ if to_remove not in self.deleted_indices:
180
+ self.deleted_indices.append(to_remove)
181
+
182
+
183
+ def process_defaultline(self,new_box,index):
184
+
185
+ possible_matches = list(self.spatial_index.intersection(new_box.bounds, objects=True))
186
+ #Remove default line if it intersects with any of the following
187
+ variant_colored_matches = [match for match in possible_matches if match.object['class'] in ['Variant script coloured',
188
+ 'Variant script black','Main script coloured','NumberingZone','Diagram','MarginTextZone','RunningTitleZone','Table',
189
+ 'Quire Mark']]
190
+
191
+ if variant_colored_matches:
192
+ self.deleted_indices.append(index)
193
+ else:
194
+ for match in possible_matches:
195
+ #Remove Main Script Black if its area overlaps with the default line
196
+ if match.object['class']=='Main script black':
197
+ match_bbox= box(*match.bbox)
198
+ intersection_area = new_box.intersection(match_bbox).area
199
+ percentage_intersection = (intersection_area / match_bbox.area)
200
+ if percentage_intersection > 0.6:
201
+ self.deleted_indices.append(match.id)
202
+
203
+
204
+ def add_annotation(self, annotation):
205
+ #Store indices to remove to remove them at the end
206
+ pos = len(self.annotations)
207
+ #Correct annotations with segments with empty coordinates
208
+ minx,miny,maxx,maxy=annotation.bbox['x1'],annotation.bbox['y1'],annotation.bbox['x2'],annotation.bbox['y2']
209
+ new_box = box(minx,miny,maxx,maxy)
210
+
211
+ if annotation.segments: # Execute validations for segmentation models
212
+
213
+ if not annotation.segments['x']:
214
+ x_coords = [minx, minx, maxx, maxx, minx]
215
+ y_coords = [miny, maxy, maxy, miny, miny]
216
+ annotation.fix_empty_segments(x_coords, y_coords)
217
+
218
+ if annotation.name in ['Main script black','Main script coloured','Variant script black','Variant script coloured','Plain initial- coloured','Plain initial - Highlighted','Plain initial - Black']:
219
+ self.process_intersection(new_box,['MarginTextZone','NumberingZone'],0.7,'new_box',pos)
220
+
221
+ if annotation.name in ['Embellished','Plain initial- coloured','Plain initial - Highlighted','Plain initial - Black','Inhabited']:
222
+ self.process_intersection(new_box,['DropCapitalZone','GraphicZone'],0.4,'symmetric')
223
+
224
+ if annotation.name=='Page Number':
225
+ self.process_intersection(new_box,['NumberingZone'],0.8,'new_box',pos)
226
+
227
+ if annotation.name=='Music':
228
+ self.process_intersection(new_box,['MusicZone','GraphicZone'],0.7,'new_box')
229
+
230
+ if annotation.name=='Table':
231
+ self.process_intersection(new_box,['MainZone','MarginTextZone'],0.4,'match_bbox')
232
+
233
+ if annotation.name in ['Diagram','Illustrations']:
234
+ self.process_intersection(new_box,['GraphicZone'],0.5,'new_box')
235
+
236
+ if annotation.name=='DefaultLine':
237
+
238
+ self.process_defaultline(new_box,pos)
239
+
240
+
241
+ self.annotations.append(annotation)
242
+
243
+ annotation.set_id(pos)
244
+ self.spatial_index.insert(pos, new_box.bounds,obj={'class':annotation.name})
245
+
246
+ def filter_annotations(self):
247
+ # Convert delete_indices to a set for faster lookup
248
+ delete_indices_set = set(self.deleted_indices)
249
+ filtered_annotations = [item for index, item in enumerate(self.annotations) if index not in delete_indices_set]
250
+ return filtered_annotations
251
+
252
+ def unify_names(self):
253
+ overlapping_classes = ['MainZone','MarginTextZone']
254
+ for index, annotation in enumerate(self.annotations):
255
+ if index not in self.deleted_indices and annotation.name in overlapping_classes:
256
+ minx,miny,maxx,maxy=annotation.bbox['x1'],annotation.bbox['y1'],annotation.bbox['x2'],annotation.bbox['y2']
257
+ new_box = box(minx,miny,maxx,maxy)
258
+
259
+ possible_matches = self.spatial_index.intersection(new_box.bounds, objects=True)
260
+
261
+ for match in possible_matches:
262
+
263
+ if match.id not in self.deleted_indices and match.object['class']==annotation.name and match.id!=index:
264
+ match_bbox= box(*match.bbox)
265
+
266
+
267
+ # Calculate the intersection area as a percentage of the smaller box area
268
+ if new_box.area > match_bbox.area:
269
+ intersection_area = new_box.intersection(match_bbox).area / match_bbox.area
270
+ else:
271
+ intersection_area = match_bbox.intersection(new_box).area / new_box.area
272
+
273
+ if intersection_area > 0.80:
274
+ delete_index = index if new_box.area < match_bbox.area else match.id
275
+ self.deleted_indices.append(delete_index)
276
+
277
+ annotation.unify_names()
278
+
279
+
280
+
281
+
282
+
283
+ def to_coco_image_dict(self):
284
+ return {
285
+ "id": self.id,
286
+ "width": self.width,
287
+ "height": self.height,
288
+ "file_name": self.filename,
289
+ "license": 0,
290
+ "flickr_url": "",
291
+ "coco_url": "",
292
+ "date_captured": 0
293
+ }
294
+
295
+ def plot_annotations(self):
296
+ # Load the image
297
+ with PILImage.open(self.path) as img:
298
+ fig, ax = plt.subplots(1, figsize=(self.width / 100, self.height / 100), dpi=100)
299
+ ax.imshow(img)
300
+
301
+ for annotation in self.filter_annotations():
302
+ if annotation.segments:
303
+
304
+ # Plot polygon segments
305
+ x = annotation.segments['x']
306
+ y = annotation.segments['y']
307
+ # Close the polygon by appending the first point to the end
308
+ x.append(x[0])
309
+ y.append(y[0])
310
+
311
+ polygon = patches.Polygon(xy=list(zip(x, y)), closed=True, edgecolor='r', facecolor='none')
312
+ ax.add_patch(polygon)
313
+ # Annotate the polygon with the name
314
+ plt.text(x[0], y[0], annotation.name, color='red', fontsize=25, verticalalignment='top')
315
+ else:
316
+ # Plot bounding box if no segments
317
+ bbox = annotation.bbox
318
+ x1, y1 = bbox['x1'], bbox['y1']
319
+ x2, y2 = bbox['x2'], bbox['y2']
320
+ rect = patches.Rectangle(
321
+ (x1, y1),
322
+ x2 - x1,
323
+ y2 - y1,
324
+ linewidth=1,
325
+ edgecolor='r',
326
+ facecolor='none'
327
+ )
328
+ ax.add_patch(rect)
329
+ # Annotate the bounding box with the name
330
+ plt.text(x1, y1, annotation.name, color='red', fontsize=25, verticalalignment='top')
331
+
332
+ plt.title(f"Image ID: {self.id} - {self.filename}")
333
+ plt.axis('off') # Hide axes
334
+ plt.show()
335
+
336
+
337
+
338
+ class ImageBatch:
339
+ def __init__(self, image_folder, catmus_labels_folder, emanuskript_labels_folder,zone_labels_folder):
340
+ self.image_folder = image_folder
341
+ self.catmus_labels_folder = catmus_labels_folder
342
+ self.emanuskript_labels_folder = emanuskript_labels_folder
343
+ self.zone_labels_folder = zone_labels_folder
344
+ self.images = []
345
+
346
+
347
+
348
+ def load_images(self):
349
+ image_paths = [
350
+ str(path).replace('\\', '/')
351
+ for path in Path(self.image_folder).glob('*')
352
+ if path.is_file() # Ensure only files are processed
353
+ ]
354
+ image_paths = sorted(image_paths)
355
+
356
+ for image_id, image_path in enumerate(image_paths, start=1):
357
+ print(f"Processing image: {image_path}") # Print the image path
358
+ self.images.append(Image(image_path, image_id))
359
+
360
+
361
+ def load_annotations(self):
362
+ for image in self.images:
363
+ image_basename = os.path.splitext(image.filename)[0]
364
+
365
+ catmus_json_path = f'{self.catmus_labels_folder}/{image_basename}.json'
366
+ emanuskript_json_path = f'{self.emanuskript_labels_folder}/{image_basename}.json'
367
+ zone_json_path = f'{self.zone_labels_folder}/{image_basename}.json'
368
+
369
+ with open(catmus_json_path) as f:
370
+ catmus_predictions = json.load(f)
371
+
372
+ with open(emanuskript_json_path) as f:
373
+ emanuskripts_predictions = json.load(f)
374
+
375
+ with open(zone_json_path) as f:
376
+ zone_predictions = json.load(f)
377
+
378
+ for annotation_data in zone_predictions + emanuskripts_predictions + catmus_predictions :
379
+
380
+ if annotation_data['name'] =='Variant script black' and len(annotation_data['segments']['x'])<3:
381
+ pass
382
+ else:
383
+ annotation = Annotation(annotation_data, image)
384
+ image.add_annotation(annotation)
385
+
386
+ def unify_names(self):
387
+ for image in self.images:
388
+ image.unify_names()
389
+
390
+ def create_coco_dict(self):
391
+ coco_dict = {
392
+ "licenses": [{"name": "", "id": 0, "url": ""}],
393
+ "info": {
394
+ "contributor": "",
395
+ "date_created": "",
396
+ "description": "",
397
+ "url": "",
398
+ "version": "",
399
+ "year": ""
400
+ },
401
+ "categories": [
402
+ {"id": coco_id, "name": cls_name, "supercategory": ""}
403
+ for cls_name, coco_id in coco_class_mapping.items()
404
+ ],
405
+ "annotations": [annotation.to_coco_format(annotation_id) for image in self.images for annotation_id, annotation in enumerate(image.filter_annotations(), start=1)],
406
+ "images": [image.to_coco_image_dict() for image in self.images]
407
+ }
408
+ return coco_dict
409
+
410
+ def save_coco_file(self, output_file):
411
+ coco_dict = self.create_coco_dict()
412
+ with open(output_file, 'w') as f:
413
+ json.dump(coco_dict, f, indent=4)
414
+
415
+ def return_coco_file(self):
416
+ coco_dict = self.create_coco_dict()
417
+ return coco_dict