kukalend commited on
Commit
006e5e9
·
verified ·
1 Parent(s): a784f38

init commit

Browse files
README.md CHANGED
@@ -1,12 +1,158 @@
1
- ---
2
- title: Computer Vision Classification
3
- emoji: 🏃
4
- colorFrom: gray
5
- colorTo: yellow
6
- sdk: gradio
7
- sdk_version: 6.12.0
8
- app_file: app.py
9
- pinned: false
10
- ---
11
-
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Exercise - Computer Vision Classification & Model Comparison
2
+
3
+ ## 1. Project Overview
4
+
5
+ This project implements a full image classification pipeline and web application that compares three model types on a custom dataset:
6
+
7
+ 1. Custom transfer learning model (ResNet18 fine-tuned on custom data)
8
+ 2. Open-source model (CLIP: `openai/clip-vit-base-patch32`)
9
+ 3. Closed-source model (OpenAI Vision API)
10
+
11
+ The app supports image upload, example images, and side-by-side predictions from all three models.
12
+
13
+ ## 2. Dataset Description
14
+
15
+ ### Dataset
16
+
17
+ Custom image dataset based on Pokemon classes from the course materials (week 8 style transfer learning setup):
18
+
19
+ - Classes: `charizard`, `charmander`, `charmeleon`, `ditto`, `eevee`, `ekans`
20
+ - Train split: `data/pokemon/train/<class>`
21
+ - Test split: `data/pokemon/test/<class>`
22
+
23
+ ### Dataset size used in evaluation
24
+
25
+ - Number of classes: 6
26
+ - Test images: 25
27
+
28
+ ## 3. Preprocessing Steps
29
+
30
+ For transfer learning training and inference:
31
+
32
+ - Convert to RGB
33
+ - Train transforms:
34
+ - `RandomResizedCrop(224)`
35
+ - `RandomHorizontalFlip()`
36
+ - `ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2)`
37
+ - ImageNet normalization
38
+ - Eval transforms:
39
+ - `Resize(256)`
40
+ - `CenterCrop(224)`
41
+ - ImageNet normalization
42
+
43
+ CLIP uses its own processor and tokenizer.
44
+
45
+ OpenAI Vision receives the image as base64-encoded JPEG together with a strict label selection prompt.
46
+
47
+ ## 4. Model and Training
48
+
49
+ ### Custom Transfer Learning Model
50
+
51
+ - Backbone: `ResNet18` with ImageNet pretrained weights
52
+ - Final layer replaced to output 6 classes
53
+ - Loss: CrossEntropyLoss
54
+ - Optimizer: Adam
55
+ - Learning rate: `1e-4`
56
+ - Weight decay: `1e-4`
57
+ - Epochs run: `4`
58
+ - Device used: CPU
59
+
60
+ ### Training Output Artifacts
61
+
62
+ - Model checkpoint: `models/custom_resnet18.pth`
63
+ - Training metrics: `reports/custom_model_metrics.json`
64
+
65
+ ## 5. Evaluation and Comparison Results
66
+
67
+ ### Summary Accuracy
68
+
69
+ - Custom transfer learning model: **0.80**
70
+ - CLIP (open-source): **0.72**
71
+ - OpenAI Vision (closed-source): not executed locally in this run because `OPENAI_API_KEY` was not set in the local environment
72
+
73
+ ### Interpretation
74
+
75
+ - The custom fine-tuned model performs best on this domain-specific dataset.
76
+ - CLIP performs reasonably well in zero-shot mode without task-specific training.
77
+ - OpenAI Vision integration is fully implemented in the app and evaluation pipeline and becomes active once `OPENAI_API_KEY` is configured.
78
+
79
+ ## 6. Application (Hugging Face Space Ready)
80
+
81
+ The Gradio app provides:
82
+
83
+ - image upload
84
+ - predictions from custom model, CLIP, and OpenAI Vision
85
+ - built-in example images (`app/examples/*.png`)
86
+
87
+ ### App files
88
+
89
+ - Main HF entrypoint: `app.py`
90
+ - App implementation: `app/app.py`
91
+ - Space dependencies: `app/requirements.txt`
92
+
93
+ ## 7. Links (to fill after publishing)
94
+
95
+ - Hugging Face Space: `https://huggingface.co/spaces/<your-username>/<your-space-name>`
96
+ - Hugging Face model repo: `https://huggingface.co/<your-username>/<your-model-repo>`
97
+
98
+ ## 8. OpenAI Key Setup (for Hugging Face Space)
99
+
100
+ In your Hugging Face Space:
101
+
102
+ 1. Open `Settings`
103
+ 2. Go to `Variables and secrets`
104
+ 3. Add new secret:
105
+ - Name: `OPENAI_API_KEY`
106
+ - Value: use the key provided in the exercise sheet
107
+
108
+ Do not hardcode the API key in repository files.
109
+
110
+ ## 9. Reproducibility: Local Run Commands
111
+
112
+ ```bash
113
+ # from project root
114
+ python -m venv .venv
115
+ .venv\\Scripts\\activate
116
+ pip install -r requirements.txt
117
+
118
+ # train custom model
119
+ python -m src.train_custom_model --epochs 4 --batch-size 16
120
+
121
+ # evaluate and compare models
122
+ python -m src.evaluate_models --openai-max-samples 24
123
+
124
+ # launch app
125
+ python app.py
126
+ ```
127
+
128
+ ## 10. Hugging Face Model Upload
129
+
130
+ A helper script is included:
131
+
132
+ ```bash
133
+ python -m src.upload_to_hf --repo-id <your-username>/<your-model-repo>
134
+ ```
135
+
136
+ This uploads `models/custom_resnet18.pth` to your Hugging Face model repository.
137
+
138
+ ## 11. Notes
139
+
140
+ - This implementation follows the mandatory exercise requirements end-to-end (training, comparison, app, example images, documentation).
141
+ - If you want to switch from Pokemon to a car dataset, you can keep the same pipeline and replace `data/pokemon/*` with your car class folders.
142
+ - On Windows with very long folder paths, package installation can fail with path-length errors. In that case, create a short-path venv (example: `C:\cv-modelcmp-venv`) and run the same commands with that Python executable.
143
+ # Computer Vision Classification and Model Comparison
144
+
145
+ Project implementation for the mandatory exercise.
146
+
147
+ The submission-specific documentation is in [readme.md](readme.md), following the requested structure.
148
+
149
+ ## Quick start
150
+
151
+ ```bash
152
+ python -m venv .venv
153
+ .venv\\Scripts\\activate
154
+ pip install -r requirements.txt
155
+ python -m src.train_custom_model --epochs 4
156
+ python -m src.evaluate_models
157
+ python app.py
158
+ ```
app.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from functools import lru_cache
5
+ from pathlib import Path
6
+ from typing import Dict, List
7
+
8
+ import gradio as gr
9
+ from PIL import Image
10
+
11
+ ROOT = Path(__file__).resolve().parent
12
+ if str(ROOT) not in sys.path:
13
+ sys.path.insert(0, str(ROOT))
14
+
15
+ from src.inference import ClipPredictor, CustomModelPredictor, OpenAIVisionPredictor
16
+
17
+
18
+ labels = ["charizard", "charmander", "charmeleon", "ditto", "eevee", "ekans"]
19
+
20
+
21
+ @lru_cache(maxsize=1)
22
+ def get_predictors() -> tuple[CustomModelPredictor, ClipPredictor, OpenAIVisionPredictor]:
23
+ custom = CustomModelPredictor(str(ROOT / "models" / "custom_resnet18.pth"))
24
+ predictor_labels = custom.labels if custom.available() else labels
25
+ clip_model = ClipPredictor(predictor_labels)
26
+ openai_model = OpenAIVisionPredictor(predictor_labels)
27
+ return custom, clip_model, openai_model
28
+
29
+
30
+ def _format_preds(result: Dict[str, object]) -> str:
31
+ if not result.get("available", False):
32
+ return f"Unavailable: {result.get('error', 'unknown error')}"
33
+
34
+ lines: List[str] = []
35
+ top = result.get("top_prediction", {})
36
+ label = top.get("label", "-")
37
+ confidence = float(top.get("confidence", 0.0))
38
+ lines.append(f"Top prediction: {label} ({confidence:.2%})")
39
+
40
+ for pred in result.get("predictions", []):
41
+ lines.append(f"- {pred['label']}: {pred['confidence']:.2%}")
42
+
43
+ raw_response = result.get("raw_response")
44
+ if isinstance(raw_response, dict) and raw_response.get("reason"):
45
+ lines.append(f"Reason: {raw_response['reason']}")
46
+
47
+ return "\n".join(lines)
48
+
49
+
50
+ def classify_image(image: Image.Image):
51
+ if image is None:
52
+ return "No image provided.", "No image provided.", "No image provided."
53
+
54
+ custom, clip_model, openai_model = get_predictors()
55
+ custom_pred = custom.predict(image)
56
+ clip_pred = clip_model.predict(image)
57
+ openai_pred = openai_model.predict(image)
58
+
59
+ return _format_preds(custom_pred), _format_preds(clip_pred), _format_preds(openai_pred)
60
+
61
+
62
+ def get_examples() -> List[List[str]]:
63
+ examples_dir = ROOT / "app" / "examples"
64
+ if not examples_dir.exists():
65
+ return []
66
+ image_paths = sorted(
67
+ [p for p in examples_dir.iterdir() if p.suffix.lower() in {".jpg", ".jpeg", ".png", ".webp"}]
68
+ )
69
+ return [[str(p)] for p in image_paths]
70
+
71
+
72
+ description = """
73
+ Upload an image and compare predictions from three models:
74
+ 1) Custom transfer learning model (ResNet18)
75
+ 2) Open-source CLIP model
76
+ 3) Closed-source OpenAI vision model
77
+
78
+ If OPENAI_API_KEY is not set, OpenAI predictions are shown as unavailable.
79
+ """
80
+
81
+ with gr.Blocks(title="Computer Vision Model Comparison") as demo:
82
+ gr.Markdown("# Computer Vision Classification & Model Comparison")
83
+ gr.Markdown(description)
84
+
85
+ with gr.Row():
86
+ image_input = gr.Image(type="pil", label="Upload image")
87
+
88
+ classify_button = gr.Button("Classify")
89
+
90
+ with gr.Row():
91
+ custom_output = gr.Textbox(label="Custom Transfer Learning", lines=8)
92
+ clip_output = gr.Textbox(label="Open-Source CLIP", lines=8)
93
+ openai_output = gr.Textbox(label="Closed-Source OpenAI Vision", lines=8)
94
+
95
+ classify_button.click(classify_image, inputs=[image_input], outputs=[custom_output, clip_output, openai_output])
96
+
97
+ gr.Examples(examples=get_examples(), inputs=image_input, label="Example images")
98
+
99
+ if __name__ == "__main__":
100
+ print("Launching local app on http://127.0.0.1:7860")
101
+ demo.launch(server_name="127.0.0.1", server_port=7860, share=False)
app/app.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from pathlib import Path
5
+ from typing import Dict, List
6
+
7
+ import gradio as gr
8
+ from PIL import Image
9
+
10
+ ROOT = Path(__file__).resolve().parents[1]
11
+ if str(ROOT) not in sys.path:
12
+ sys.path.insert(0, str(ROOT))
13
+
14
+ from src.inference import ClipPredictor, CustomModelPredictor, OpenAIVisionPredictor
15
+
16
+
17
+ custom = CustomModelPredictor(str(ROOT / "models" / "custom_resnet18.pth"))
18
+ if custom.available():
19
+ labels = custom.labels
20
+ else:
21
+ labels = ["charizard", "charmander", "charmeleon", "ditto", "eevee", "ekans"]
22
+
23
+ clip_model = ClipPredictor(labels)
24
+ openai_model = OpenAIVisionPredictor(labels)
25
+
26
+
27
+ def _format_preds(result: Dict[str, object]) -> str:
28
+ if not result.get("available", False):
29
+ return f"Unavailable: {result.get('error', 'unknown error')}"
30
+
31
+ lines: List[str] = []
32
+ top = result.get("top_prediction", {})
33
+ label = top.get("label", "-")
34
+ confidence = float(top.get("confidence", 0.0))
35
+ lines.append(f"Top prediction: {label} ({confidence:.2%})")
36
+
37
+ for pred in result.get("predictions", []):
38
+ lines.append(f"- {pred['label']}: {pred['confidence']:.2%}")
39
+
40
+ raw_response = result.get("raw_response")
41
+ if isinstance(raw_response, dict) and raw_response.get("reason"):
42
+ lines.append(f"Reason: {raw_response['reason']}")
43
+
44
+ return "\n".join(lines)
45
+
46
+
47
+ def classify_image(image: Image.Image):
48
+ if image is None:
49
+ return "No image provided.", "No image provided.", "No image provided."
50
+
51
+ custom_pred = custom.predict(image)
52
+ clip_pred = clip_model.predict(image)
53
+ openai_pred = openai_model.predict(image)
54
+
55
+ return _format_preds(custom_pred), _format_preds(clip_pred), _format_preds(openai_pred)
56
+
57
+
58
+ def get_examples() -> List[List[str]]:
59
+ examples_dir = ROOT / "app" / "examples"
60
+ if not examples_dir.exists():
61
+ return []
62
+ image_paths = sorted(
63
+ [p for p in examples_dir.iterdir() if p.suffix.lower() in {".jpg", ".jpeg", ".png", ".webp"}]
64
+ )
65
+ return [[str(p)] for p in image_paths]
66
+
67
+
68
+ description = """
69
+ Upload an image and compare predictions from three models:
70
+ 1) Custom transfer learning model (ResNet18)
71
+ 2) Open-source CLIP model
72
+ 3) Closed-source OpenAI vision model
73
+
74
+ If OPENAI_API_KEY is not set, OpenAI predictions are shown as unavailable.
75
+ """
76
+
77
+ with gr.Blocks(title="Computer Vision Model Comparison") as demo:
78
+ gr.Markdown("# Computer Vision Classification & Model Comparison")
79
+ gr.Markdown(description)
80
+
81
+ with gr.Row():
82
+ image_input = gr.Image(type="pil", label="Upload image")
83
+
84
+ classify_button = gr.Button("Classify")
85
+
86
+ with gr.Row():
87
+ custom_output = gr.Textbox(label="Custom Transfer Learning", lines=8)
88
+ clip_output = gr.Textbox(label="Open-Source CLIP", lines=8)
89
+ openai_output = gr.Textbox(label="Closed-Source OpenAI Vision", lines=8)
90
+
91
+ classify_button.click(classify_image, inputs=[image_input], outputs=[custom_output, clip_output, openai_output])
92
+
93
+ gr.Examples(examples=get_examples(), inputs=image_input, label="Example images")
94
+
95
+ if __name__ == "__main__":
96
+ demo.launch()
app/examples/charizard.png ADDED
app/examples/charmander.png ADDED
app/examples/charmeleon.png ADDED
app/examples/ditto.png ADDED
app/examples/eevee.png ADDED
app/examples/ekans.png ADDED
app/requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ gradio==4.44.1
2
+ pillow>=10.0.0
3
+ torch>=2.3.0
4
+ torchvision>=0.18.0
5
+ transformers>=4.40.0
6
+ openai>=1.30.0
models/custom_resnet18.pth ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:c9805b9724998a575eb468422eeda02f0a368c5b1fd456ba4f340691e4d3e237
3
+ size 44797131
requirements.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ torch>=2.3.0
2
+ torchvision>=0.18.0
3
+ transformers>=4.40.0
4
+ gradio>=4.44.0
5
+ pillow>=10.0.0
6
+ numpy>=1.26.0
7
+ scikit-learn>=1.5.0
8
+ openai>=1.30.0
9
+ huggingface_hub>=0.24.0,<1.0.0
10
+ python-dotenv>=1.0.1
11
+ matplotlib>=3.8.0
src/__init__.py ADDED
File without changes
src/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (253 Bytes). View file
 
src/__pycache__/evaluate_models.cpython-313.pyc ADDED
Binary file (5.96 kB). View file
 
src/__pycache__/inference.cpython-313.pyc ADDED
Binary file (12.8 kB). View file
 
src/__pycache__/train_custom_model.cpython-313.pyc ADDED
Binary file (13.2 kB). View file
 
src/evaluate_models.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Dict, List
7
+
8
+ from PIL import Image
9
+ from sklearn.metrics import accuracy_score, classification_report
10
+
11
+ from src.inference import ClipPredictor, CustomModelPredictor, OpenAIVisionPredictor
12
+
13
+
14
+ def iter_test_images(data_dir: Path, labels: List[str]) -> List[Dict[str, str]]:
15
+ items: List[Dict[str, str]] = []
16
+ test_root = data_dir / "test"
17
+ for label in labels:
18
+ class_dir = test_root / label
19
+ if not class_dir.exists():
20
+ continue
21
+ for p in class_dir.iterdir():
22
+ if p.is_file() and p.suffix.lower() in {".jpg", ".jpeg", ".png", ".webp"}:
23
+ items.append({"path": str(p), "label": label})
24
+ return items
25
+
26
+
27
+ def evaluate_custom(custom: CustomModelPredictor, samples: List[Dict[str, str]]) -> Dict[str, object]:
28
+ y_true = []
29
+ y_pred = []
30
+ for item in samples:
31
+ image = Image.open(item["path"])
32
+ pred = custom.predict(image)
33
+ y_true.append(item["label"])
34
+ y_pred.append(pred["top_prediction"]["label"])
35
+
36
+ acc = accuracy_score(y_true, y_pred)
37
+ report = classification_report(y_true, y_pred, output_dict=True, zero_division=0)
38
+ return {"accuracy": float(acc), "classification_report": report}
39
+
40
+
41
+ def evaluate_clip(clip: ClipPredictor, samples: List[Dict[str, str]]) -> Dict[str, object]:
42
+ if not clip.available():
43
+ return {"available": False, "error": "Could not load CLIP model."}
44
+
45
+ y_true = []
46
+ y_pred = []
47
+ for item in samples:
48
+ image = Image.open(item["path"])
49
+ pred = clip.predict(image)
50
+ y_true.append(item["label"])
51
+ y_pred.append(pred["top_prediction"]["label"])
52
+
53
+ acc = accuracy_score(y_true, y_pred)
54
+ report = classification_report(y_true, y_pred, output_dict=True, zero_division=0)
55
+ return {"available": True, "accuracy": float(acc), "classification_report": report}
56
+
57
+
58
+ def evaluate_openai(openai_model: OpenAIVisionPredictor, samples: List[Dict[str, str]], max_samples: int) -> Dict[str, object]:
59
+ if not openai_model.available():
60
+ return {"available": False, "error": "OPENAI_API_KEY missing."}
61
+
62
+ subset = samples[:max_samples]
63
+ y_true = []
64
+ y_pred = []
65
+ for item in subset:
66
+ image = Image.open(item["path"])
67
+ pred = openai_model.predict(image)
68
+ y_true.append(item["label"])
69
+ y_pred.append(pred["top_prediction"]["label"])
70
+
71
+ acc = accuracy_score(y_true, y_pred)
72
+ report = classification_report(y_true, y_pred, output_dict=True, zero_division=0)
73
+ return {
74
+ "available": True,
75
+ "evaluated_samples": len(subset),
76
+ "accuracy": float(acc),
77
+ "classification_report": report,
78
+ }
79
+
80
+
81
+ def main() -> None:
82
+ parser = argparse.ArgumentParser(description="Compare custom model vs CLIP vs OpenAI Vision.")
83
+ parser.add_argument("--data-dir", default="data/pokemon")
84
+ parser.add_argument("--model-path", default="models/custom_resnet18.pth")
85
+ parser.add_argument("--output", default="reports/model_comparison.json")
86
+ parser.add_argument("--openai-max-samples", type=int, default=24)
87
+ args = parser.parse_args()
88
+
89
+ data_dir = Path(args.data_dir)
90
+ output_path = Path(args.output)
91
+ output_path.parent.mkdir(parents=True, exist_ok=True)
92
+
93
+ custom = CustomModelPredictor(args.model_path)
94
+ if not custom.available():
95
+ raise FileNotFoundError(
96
+ "Custom model checkpoint not found. Train model first with src/train_custom_model.py"
97
+ )
98
+
99
+ labels = custom.labels
100
+ samples = iter_test_images(data_dir, labels)
101
+
102
+ clip = ClipPredictor(labels)
103
+ openai_model = OpenAIVisionPredictor(labels)
104
+
105
+ result = {
106
+ "dataset": str(data_dir),
107
+ "num_test_samples": len(samples),
108
+ "labels": labels,
109
+ "custom_model": evaluate_custom(custom, samples),
110
+ "clip_model": evaluate_clip(clip, samples),
111
+ "openai_model": evaluate_openai(openai_model, samples, args.openai_max_samples),
112
+ }
113
+
114
+ output_path.write_text(json.dumps(result, indent=2), encoding="utf-8")
115
+ print(f"Saved comparison report to: {output_path}")
116
+
117
+
118
+ if __name__ == "__main__":
119
+ main()
src/inference.py ADDED
@@ -0,0 +1,234 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import io
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ from typing import Dict, List, Optional
9
+
10
+ from openai import OpenAI
11
+ from PIL import Image
12
+
13
+ TORCH_IMPORT_ERROR: Optional[str] = None
14
+ try:
15
+ import torch
16
+ import torch.nn.functional as F
17
+ from torchvision import models, transforms
18
+ except Exception as exc: # pragma: no cover - defensive import guard
19
+ torch = None
20
+ F = None
21
+ models = None
22
+ transforms = None
23
+ TORCH_IMPORT_ERROR = str(exc)
24
+
25
+ TRANSFORMERS_IMPORT_ERROR: Optional[str] = None
26
+ try:
27
+ from transformers import CLIPModel, CLIPProcessor
28
+ except Exception as exc: # pragma: no cover - defensive import guard
29
+ CLIPModel = None
30
+ CLIPProcessor = None
31
+ TRANSFORMERS_IMPORT_ERROR = str(exc)
32
+
33
+
34
+ class CustomModelPredictor:
35
+ def __init__(self, checkpoint_path: str = "models/custom_resnet18.pth") -> None:
36
+ self.checkpoint_path = Path(checkpoint_path)
37
+ self.error: Optional[str] = None
38
+ self.model = None
39
+ self.labels: List[str] = []
40
+ self.image_size = 224
41
+ self.eval_transform = None
42
+
43
+ if torch is None or transforms is None:
44
+ self.device = None
45
+ self.error = (
46
+ "Custom model unavailable because torch/torchvision is missing. "
47
+ f"Import error: {TORCH_IMPORT_ERROR}"
48
+ )
49
+ return
50
+
51
+ self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
52
+
53
+ self.eval_transform = transforms.Compose(
54
+ [
55
+ transforms.Resize(256),
56
+ transforms.CenterCrop(224),
57
+ transforms.ToTensor(),
58
+ transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]),
59
+ ]
60
+ )
61
+
62
+ if self.checkpoint_path.exists():
63
+ self._load()
64
+
65
+ def _load(self) -> None:
66
+ checkpoint = torch.load(self.checkpoint_path, map_location=self.device)
67
+ self.labels = checkpoint["labels"]
68
+ self.image_size = int(checkpoint.get("image_size", 224))
69
+
70
+ model = models.resnet18(weights=None)
71
+ model.fc = torch.nn.Linear(model.fc.in_features, len(self.labels))
72
+ model.load_state_dict(checkpoint["state_dict"])
73
+ model.to(self.device)
74
+ model.eval()
75
+ self.model = model
76
+
77
+ def available(self) -> bool:
78
+ return self.model is not None and self.error is None
79
+
80
+ def predict(self, image: Image.Image, top_k: int = 3) -> Dict[str, object]:
81
+ if not self.available():
82
+ return {
83
+ "model": "custom-transfer-learning",
84
+ "available": False,
85
+ "error": self.error or f"Model not found at {self.checkpoint_path}",
86
+ }
87
+
88
+ image = image.convert("RGB")
89
+ tensor = self.eval_transform(image).unsqueeze(0).to(self.device)
90
+
91
+ with torch.no_grad():
92
+ logits = self.model(tensor)
93
+ probs = F.softmax(logits, dim=1).squeeze(0)
94
+
95
+ top_probs, top_idx = torch.topk(probs, k=min(top_k, len(self.labels)))
96
+ predictions = [
97
+ {"label": self.labels[idx], "confidence": float(prob)}
98
+ for prob, idx in zip(top_probs.cpu().tolist(), top_idx.cpu().tolist())
99
+ ]
100
+
101
+ return {
102
+ "model": "custom-transfer-learning",
103
+ "available": True,
104
+ "top_prediction": predictions[0],
105
+ "predictions": predictions,
106
+ }
107
+
108
+
109
+ class ClipPredictor:
110
+ def __init__(self, labels: List[str], model_name: str = "openai/clip-vit-base-patch32") -> None:
111
+ self.labels = labels
112
+ self.model_name = model_name
113
+ self.error: Optional[str] = None
114
+ self.device = torch.device("cuda" if torch is not None and torch.cuda.is_available() else "cpu") if torch is not None else None
115
+ self.available_flag = False
116
+ self.processor = None
117
+ self.model = None
118
+
119
+ if torch is None or CLIPModel is None or CLIPProcessor is None:
120
+ self.error = (
121
+ "CLIP unavailable because required dependencies are missing. "
122
+ f"torch error: {TORCH_IMPORT_ERROR}; transformers error: {TRANSFORMERS_IMPORT_ERROR}"
123
+ )
124
+ return
125
+
126
+ if labels:
127
+ self._load()
128
+
129
+ def _load(self) -> None:
130
+ try:
131
+ self.processor = CLIPProcessor.from_pretrained(self.model_name)
132
+ self.model = CLIPModel.from_pretrained(self.model_name).to(self.device)
133
+ self.model.eval()
134
+ self.available_flag = True
135
+ except Exception:
136
+ self.available_flag = False
137
+
138
+ def available(self) -> bool:
139
+ return self.available_flag
140
+
141
+ def predict(self, image: Image.Image, top_k: int = 3) -> Dict[str, object]:
142
+ if not self.available():
143
+ return {
144
+ "model": "clip-open-source",
145
+ "available": False,
146
+ "error": self.error or "CLIP model could not be loaded.",
147
+ }
148
+
149
+ prompts = [f"a sprite or photo of a pokemon named {label}" for label in self.labels]
150
+ inputs = self.processor(text=prompts, images=image.convert("RGB"), return_tensors="pt", padding=True)
151
+ inputs = {k: v.to(self.device) for k, v in inputs.items()}
152
+
153
+ with torch.no_grad():
154
+ outputs = self.model(**inputs)
155
+ logits_per_image = outputs.logits_per_image
156
+ probs = logits_per_image.softmax(dim=1).squeeze(0)
157
+
158
+ top_probs, top_idx = torch.topk(probs, k=min(top_k, len(self.labels)))
159
+ predictions = [
160
+ {"label": self.labels[idx], "confidence": float(prob)}
161
+ for prob, idx in zip(top_probs.cpu().tolist(), top_idx.cpu().tolist())
162
+ ]
163
+
164
+ return {
165
+ "model": "clip-open-source",
166
+ "available": True,
167
+ "top_prediction": predictions[0],
168
+ "predictions": predictions,
169
+ }
170
+
171
+
172
+ class OpenAIVisionPredictor:
173
+ def __init__(self, labels: List[str], model_name: str = "gpt-4.1-mini") -> None:
174
+ self.labels = labels
175
+ self.model_name = model_name
176
+ self.api_key = os.getenv("OPENAI_API_KEY", "")
177
+ self.client: Optional[OpenAI] = None
178
+ if self.api_key:
179
+ self.client = OpenAI(api_key=self.api_key)
180
+
181
+ def available(self) -> bool:
182
+ return self.client is not None
183
+
184
+ def predict(self, image: Image.Image) -> Dict[str, object]:
185
+ if not self.available():
186
+ return {
187
+ "model": "openai-vision",
188
+ "available": False,
189
+ "error": "OPENAI_API_KEY is not set.",
190
+ }
191
+
192
+ buffered = io.BytesIO()
193
+ image.convert("RGB").save(buffered, format="JPEG")
194
+ b64_image = base64.b64encode(buffered.getvalue()).decode("utf-8")
195
+
196
+ prompt = (
197
+ "You are an image classifier. "
198
+ f"Choose exactly one label from this list: {', '.join(self.labels)}. "
199
+ "Return strict JSON with keys: label, confidence, reason. "
200
+ "label must be one of the provided labels. confidence must be in [0,1]."
201
+ )
202
+
203
+ response = self.client.responses.create(
204
+ model=self.model_name,
205
+ input=[
206
+ {
207
+ "role": "user",
208
+ "content": [
209
+ {"type": "input_text", "text": prompt},
210
+ {"type": "input_image", "image_url": f"data:image/jpeg;base64,{b64_image}"},
211
+ ],
212
+ }
213
+ ],
214
+ temperature=0,
215
+ )
216
+
217
+ text = response.output_text.strip()
218
+ parsed = self._safe_parse(text)
219
+ return {
220
+ "model": "openai-vision",
221
+ "available": True,
222
+ "top_prediction": {
223
+ "label": parsed.get("label", "unknown"),
224
+ "confidence": float(parsed.get("confidence", 0.0)),
225
+ },
226
+ "raw_response": parsed,
227
+ }
228
+
229
+ @staticmethod
230
+ def _safe_parse(text: str) -> Dict[str, object]:
231
+ try:
232
+ return json.loads(text)
233
+ except json.JSONDecodeError:
234
+ return {"label": "unknown", "confidence": 0.0, "reason": text}
src/train_custom_model.py ADDED
@@ -0,0 +1,268 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import random
6
+ from dataclasses import asdict, dataclass
7
+ from pathlib import Path
8
+ from typing import Dict, List, Tuple
9
+
10
+ import numpy as np
11
+ import torch
12
+ import torch.nn as nn
13
+ import torch.optim as optim
14
+ from sklearn.metrics import classification_report
15
+ from torch.utils.data import DataLoader, random_split
16
+ from torchvision import datasets, models, transforms
17
+
18
+
19
+ @dataclass
20
+ class TrainConfig:
21
+ data_dir: str = "data/pokemon"
22
+ output_model_path: str = "models/custom_resnet18.pth"
23
+ output_metrics_path: str = "reports/custom_model_metrics.json"
24
+ batch_size: int = 16
25
+ num_epochs: int = 8
26
+ learning_rate: float = 1e-4
27
+ weight_decay: float = 1e-4
28
+ val_split: float = 0.2
29
+ image_size: int = 224
30
+ seed: int = 42
31
+
32
+
33
+ def set_seed(seed: int) -> None:
34
+ random.seed(seed)
35
+ np.random.seed(seed)
36
+ torch.manual_seed(seed)
37
+ torch.cuda.manual_seed_all(seed)
38
+
39
+
40
+ def get_device() -> torch.device:
41
+ return torch.device("cuda" if torch.cuda.is_available() else "cpu")
42
+
43
+
44
+ def build_transforms(image_size: int) -> Tuple[transforms.Compose, transforms.Compose]:
45
+ train_tfms = transforms.Compose(
46
+ [
47
+ transforms.RandomResizedCrop(image_size),
48
+ transforms.RandomHorizontalFlip(),
49
+ transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2),
50
+ transforms.ToTensor(),
51
+ transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
52
+ ]
53
+ )
54
+ eval_tfms = transforms.Compose(
55
+ [
56
+ transforms.Resize(int(image_size * 1.14)),
57
+ transforms.CenterCrop(image_size),
58
+ transforms.ToTensor(),
59
+ transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
60
+ ]
61
+ )
62
+ return train_tfms, eval_tfms
63
+
64
+
65
+ def build_dataloaders(config: TrainConfig) -> Tuple[DataLoader, DataLoader, DataLoader, List[str]]:
66
+ train_tfms, eval_tfms = build_transforms(config.image_size)
67
+
68
+ train_root = Path(config.data_dir) / "train"
69
+ test_root = Path(config.data_dir) / "test"
70
+
71
+ full_train_dataset = datasets.ImageFolder(train_root, transform=train_tfms)
72
+ eval_train_dataset = datasets.ImageFolder(train_root, transform=eval_tfms)
73
+ test_dataset = datasets.ImageFolder(test_root, transform=eval_tfms)
74
+
75
+ num_train = len(full_train_dataset)
76
+ num_val = int(num_train * config.val_split)
77
+ num_train_final = num_train - num_val
78
+
79
+ train_subset, val_subset_indices = random_split(
80
+ full_train_dataset,
81
+ [num_train_final, num_val],
82
+ generator=torch.Generator().manual_seed(config.seed),
83
+ )
84
+
85
+ # Recreate val subset with eval transforms by reusing indices from split.
86
+ val_indices = val_subset_indices.indices
87
+ val_subset = torch.utils.data.Subset(eval_train_dataset, val_indices)
88
+
89
+ train_loader = DataLoader(train_subset, batch_size=config.batch_size, shuffle=True, num_workers=0)
90
+ val_loader = DataLoader(val_subset, batch_size=config.batch_size, shuffle=False, num_workers=0)
91
+ test_loader = DataLoader(test_dataset, batch_size=config.batch_size, shuffle=False, num_workers=0)
92
+
93
+ classes = full_train_dataset.classes
94
+ return train_loader, val_loader, test_loader, classes
95
+
96
+
97
+ def create_model(num_classes: int) -> nn.Module:
98
+ model = models.resnet18(weights=models.ResNet18_Weights.DEFAULT)
99
+ in_features = model.fc.in_features
100
+ model.fc = nn.Linear(in_features, num_classes)
101
+ return model
102
+
103
+
104
+ def evaluate(model: nn.Module, loader: DataLoader, device: torch.device) -> Tuple[float, List[int], List[int]]:
105
+ model.eval()
106
+ correct = 0
107
+ total = 0
108
+ preds_all: List[int] = []
109
+ labels_all: List[int] = []
110
+
111
+ with torch.no_grad():
112
+ for images, labels in loader:
113
+ images = images.to(device)
114
+ labels = labels.to(device)
115
+ outputs = model(images)
116
+ _, preds = torch.max(outputs, dim=1)
117
+
118
+ total += labels.size(0)
119
+ correct += (preds == labels).sum().item()
120
+ preds_all.extend(preds.cpu().numpy().tolist())
121
+ labels_all.extend(labels.cpu().numpy().tolist())
122
+
123
+ acc = correct / total if total > 0 else 0.0
124
+ return acc, preds_all, labels_all
125
+
126
+
127
+ def train(config: TrainConfig) -> Dict[str, object]:
128
+ set_seed(config.seed)
129
+ device = get_device()
130
+
131
+ train_loader, val_loader, test_loader, classes = build_dataloaders(config)
132
+
133
+ model = create_model(num_classes=len(classes)).to(device)
134
+ criterion = nn.CrossEntropyLoss()
135
+ optimizer = optim.Adam(model.parameters(), lr=config.learning_rate, weight_decay=config.weight_decay)
136
+ scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=4, gamma=0.5)
137
+
138
+ best_val_acc = 0.0
139
+ best_state = None
140
+ history = []
141
+
142
+ for epoch in range(config.num_epochs):
143
+ model.train()
144
+ running_loss = 0.0
145
+ running_correct = 0
146
+ running_total = 0
147
+
148
+ for images, labels in train_loader:
149
+ images = images.to(device)
150
+ labels = labels.to(device)
151
+
152
+ optimizer.zero_grad()
153
+ outputs = model(images)
154
+ loss = criterion(outputs, labels)
155
+ loss.backward()
156
+ optimizer.step()
157
+
158
+ running_loss += loss.item() * labels.size(0)
159
+ _, preds = torch.max(outputs, dim=1)
160
+ running_correct += (preds == labels).sum().item()
161
+ running_total += labels.size(0)
162
+
163
+ scheduler.step()
164
+
165
+ train_loss = running_loss / max(1, running_total)
166
+ train_acc = running_correct / max(1, running_total)
167
+ val_acc, _, _ = evaluate(model, val_loader, device)
168
+
169
+ history.append(
170
+ {
171
+ "epoch": epoch + 1,
172
+ "train_loss": round(train_loss, 5),
173
+ "train_acc": round(train_acc, 5),
174
+ "val_acc": round(val_acc, 5),
175
+ }
176
+ )
177
+
178
+ print(
179
+ f"Epoch {epoch + 1}/{config.num_epochs} "
180
+ f"- train_loss: {train_loss:.4f} "
181
+ f"- train_acc: {train_acc:.4f} "
182
+ f"- val_acc: {val_acc:.4f}"
183
+ )
184
+
185
+ if val_acc > best_val_acc:
186
+ best_val_acc = val_acc
187
+ best_state = {k: v.cpu().clone() for k, v in model.state_dict().items()}
188
+
189
+ if best_state is not None:
190
+ model.load_state_dict(best_state)
191
+
192
+ test_acc, test_preds, test_labels = evaluate(model, test_loader, device)
193
+
194
+ target_names = classes
195
+ cls_report = classification_report(
196
+ test_labels,
197
+ test_preds,
198
+ target_names=target_names,
199
+ output_dict=True,
200
+ zero_division=0,
201
+ )
202
+
203
+ output_model = Path(config.output_model_path)
204
+ output_model.parent.mkdir(parents=True, exist_ok=True)
205
+ torch.save(
206
+ {
207
+ "state_dict": model.state_dict(),
208
+ "labels": classes,
209
+ "image_size": config.image_size,
210
+ "architecture": "resnet18",
211
+ },
212
+ output_model,
213
+ )
214
+
215
+ result = {
216
+ "config": asdict(config),
217
+ "device": str(device),
218
+ "num_classes": len(classes),
219
+ "labels": classes,
220
+ "best_val_acc": round(best_val_acc, 5),
221
+ "test_acc": round(test_acc, 5),
222
+ "history": history,
223
+ "classification_report": cls_report,
224
+ "model_path": str(output_model),
225
+ }
226
+
227
+ output_metrics = Path(config.output_metrics_path)
228
+ output_metrics.parent.mkdir(parents=True, exist_ok=True)
229
+ output_metrics.write_text(json.dumps(result, indent=2), encoding="utf-8")
230
+
231
+ print(f"Saved model to: {output_model}")
232
+ print(f"Saved metrics to: {output_metrics}")
233
+ print(f"Final test accuracy: {test_acc:.4f}")
234
+
235
+ return result
236
+
237
+
238
+ def parse_args() -> TrainConfig:
239
+ parser = argparse.ArgumentParser(description="Train transfer learning classifier on custom image data.")
240
+ parser.add_argument("--data-dir", default="data/pokemon")
241
+ parser.add_argument("--output-model", default="models/custom_resnet18.pth")
242
+ parser.add_argument("--output-metrics", default="reports/custom_model_metrics.json")
243
+ parser.add_argument("--batch-size", type=int, default=16)
244
+ parser.add_argument("--epochs", type=int, default=8)
245
+ parser.add_argument("--lr", type=float, default=1e-4)
246
+ parser.add_argument("--weight-decay", type=float, default=1e-4)
247
+ parser.add_argument("--val-split", type=float, default=0.2)
248
+ parser.add_argument("--image-size", type=int, default=224)
249
+ parser.add_argument("--seed", type=int, default=42)
250
+ args = parser.parse_args()
251
+
252
+ return TrainConfig(
253
+ data_dir=args.data_dir,
254
+ output_model_path=args.output_model,
255
+ output_metrics_path=args.output_metrics,
256
+ batch_size=args.batch_size,
257
+ num_epochs=args.epochs,
258
+ learning_rate=args.lr,
259
+ weight_decay=args.weight_decay,
260
+ val_split=args.val_split,
261
+ image_size=args.image_size,
262
+ seed=args.seed,
263
+ )
264
+
265
+
266
+ if __name__ == "__main__":
267
+ cfg = parse_args()
268
+ train(cfg)
src/upload_to_hf.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from pathlib import Path
5
+
6
+ from huggingface_hub import HfApi, upload_file
7
+
8
+
9
+ def main() -> None:
10
+ parser = argparse.ArgumentParser(description="Upload custom model checkpoint to Hugging Face model repo.")
11
+ parser.add_argument("--repo-id", required=True, help="e.g., username/pokemon-transfer-resnet18")
12
+ parser.add_argument("--model-path", default="models/custom_resnet18.pth")
13
+ parser.add_argument("--private", action="store_true")
14
+ args = parser.parse_args()
15
+
16
+ model_path = Path(args.model_path)
17
+ if not model_path.exists():
18
+ raise FileNotFoundError(f"Model file not found: {model_path}")
19
+
20
+ api = HfApi()
21
+ api.create_repo(repo_id=args.repo_id, repo_type="model", private=args.private, exist_ok=True)
22
+
23
+ upload_file(
24
+ path_or_fileobj=str(model_path),
25
+ path_in_repo=model_path.name,
26
+ repo_id=args.repo_id,
27
+ repo_type="model",
28
+ )
29
+
30
+ print(f"Uploaded {model_path} to https://huggingface.co/{args.repo_id}")
31
+
32
+
33
+ if __name__ == "__main__":
34
+ main()