sayantan47 commited on
Commit
c72ead4
·
1 Parent(s): 97c315c
Files changed (4) hide show
  1. .gitignore +4 -0
  2. README.md +98 -13
  3. app.py +14 -178
  4. core.py +311 -0
.gitignore ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ hf_cache
2
+ app-local.py
3
+ __pycache__
4
+ .vscode
README.md CHANGED
@@ -1,13 +1,98 @@
1
- ---
2
- title: HotorNot
3
- emoji: 🏢
4
- colorFrom: gray
5
- colorTo: blue
6
- sdk: gradio
7
- sdk_version: 5.38.2
8
- app_file: app.py
9
- pinned: false
10
- license: mit
11
- ---
12
-
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Hot or Not - CLIP ONNX Implementation
2
+
3
+ A modular Hot or Not application using CLIP ONNX models with automatic gender and age detection.
4
+
5
+ ## 🏗️ Architecture
6
+
7
+ The codebase has been refactored into a modular structure for better maintainability:
8
+
9
+ ### Core Components
10
+
11
+ - **`core.py`** - Contains all the core logic for hot-or-not scoring
12
+ - Abstract model interface (`ModelInterface`)
13
+ - HuggingFace model implementation (`HuggingFaceModel`)
14
+ - Local model implementation (`LocalModel`)
15
+ - Core scoring logic (`HotOrNotScorer`)
16
+ - Utility functions and configuration
17
+
18
+ - **`app.py`** - Gradio UI using HuggingFace Hub model
19
+ - Downloads and uses models from HuggingFace Hub
20
+ - Default repo: `sayantan47/clip-vit-b32-onnx`
21
+
22
+ - **`app-local.py`** - Gradio UI using local model files
23
+ - Uses locally stored ONNX model files
24
+ - Configurable model and processor paths
25
+
26
+ ## 🚀 Usage
27
+
28
+ ### Running with HuggingFace Model
29
+
30
+ ```bash
31
+ python app.py
32
+ ```
33
+
34
+ This will automatically download the model from HuggingFace Hub and start the Gradio interface.
35
+
36
+ ### Running with Local Model
37
+
38
+ 1. Place your ONNX model file in the expected location (default: `models/model.onnx`)
39
+ 2. Update the `MODEL_PATH` in `app-local.py` if needed
40
+ 3. Run:
41
+
42
+ ```bash
43
+ python app-local.py
44
+ ```
45
+
46
+ ### Customizing Model Paths
47
+
48
+ For local models, edit the configuration in `app-local.py`:
49
+
50
+ ```python
51
+ MODEL_PATH = "path/to/your/model.onnx"
52
+ PROCESSOR_PATH = "path/to/your/processor" # Optional
53
+ ```
54
+
55
+ ## 🔧 Configuration
56
+
57
+ The `Config` class in `core.py` contains shared configuration:
58
+
59
+ - `FIXED_IMG_W`, `FIXED_IMG_H`: Image display dimensions (300x300)
60
+ - `DEFAULT_OUTPUT`: Fallback values when model fails
61
+ - `PROVIDERS`: ONNX execution providers (CPU by default)
62
+
63
+ ## 📦 Dependencies
64
+
65
+ Install required packages:
66
+
67
+ ```bash
68
+ pip install -r requirements.txt
69
+ ```
70
+
71
+ Required packages:
72
+ - numpy
73
+ - onnxruntime
74
+ - huggingface_hub
75
+ - transformers
76
+ - Pillow
77
+ - gradio
78
+
79
+ ## 🧠 How It Works
80
+
81
+ 1. **Image Analysis**: Uses CLIP to analyze uploaded images
82
+ 2. **Gender Detection**: Classifies between "man", "woman", or "unknown"
83
+ 3. **Age Detection**: Categorizes as "young", "middle-aged", or "old"
84
+ 4. **Attractiveness Scoring**: Uses gender-specific positive/negative prompts
85
+ 5. **Score Calculation**: Generates composite scores and individual metrics
86
+
87
+ ## 🏗️ Extending the System
88
+
89
+ The modular design makes it easy to:
90
+
91
+ - Add new model implementations by extending `ModelInterface`
92
+ - Create different UI frontends using the core `HotOrNotScorer`
93
+ - Modify scoring algorithms in the core module
94
+ - Add new model sources (local files, different hubs, etc.)
95
+
96
+ ## 📄 License
97
+
98
+ MIT License
app.py CHANGED
@@ -1,192 +1,28 @@
1
- import os
2
- import sys
3
- import traceback
4
- import numpy as np
5
- import onnxruntime as ort
6
- from huggingface_hub import hf_hub_download
7
- from transformers import CLIPProcessor
8
- from PIL import Image
9
  import gradio as gr
 
10
 
11
  # ============================================================
12
- # Config
13
  # ============================================================
14
- REPO_ID = "sayantan47/clip-vit-b32-onnx" # <-- change this
15
  MODEL_FILENAME = "onnx/model.onnx"
16
- PROVIDERS = ["CPUExecutionProvider"] # keep CPU to avoid CUDA DLL issues
17
- DEFAULT_OUTPUT = (0.0, 0.0, 0.0, 0.0, "unknown", "unknown")
18
- FIXED_IMG_W = 300
19
- FIXED_IMG_H = 300
20
-
21
-
22
- # ============================================================
23
- # Utils
24
- # ============================================================
25
- def _print_exc(prefix: str):
26
- print(prefix, file=sys.stderr)
27
- traceback.print_exc()
28
-
29
-
30
- def _softmax_safe(x: np.ndarray, axis: int = -1) -> np.ndarray:
31
- try:
32
- x = x - np.max(x, axis=axis, keepdims=True)
33
- ex = np.exp(x)
34
- denom = np.sum(ex, axis=axis, keepdims=True)
35
- denom = np.where(denom == 0, 1.0, denom)
36
- return ex / denom
37
- except Exception:
38
- _print_exc("[_softmax_safe] failed")
39
- return np.ones_like(x) / x.shape[-1]
40
-
41
-
42
- def _ensure_int64(feed_dict):
43
- out = {}
44
- for k, v in feed_dict.items():
45
- if isinstance(v, np.ndarray) and v.dtype == np.int32:
46
- out[k] = v.astype(np.int64)
47
- else:
48
- out[k] = v
49
- return out
50
-
51
-
52
- def _dummy_image(width=FIXED_IMG_W, height=FIXED_IMG_H):
53
- return Image.fromarray(np.full((height, width, 3), 127, dtype=np.uint8), "RGB")
54
-
55
-
56
- # ============================================================
57
- # Load from HF Hub
58
- # ============================================================
59
- def load_from_hub():
60
- # download model.onnx
61
- model_path = hf_hub_download(
62
- repo_id=REPO_ID,
63
- filename=MODEL_FILENAME,
64
- local_dir="hf_cache",
65
- local_dir_use_symlinks=False,
66
- resume_download=True,
67
- )
68
- # load processor (tokenizer + preproc files) from the same repo
69
- proc = CLIPProcessor.from_pretrained(REPO_ID)
70
- sess = ort.InferenceSession(model_path, providers=PROVIDERS)
71
- return proc, sess
72
-
73
-
74
- try:
75
- processor, session = load_from_hub()
76
- except Exception:
77
- _print_exc("[GLOBAL INIT] Failed to download/load model from HF Hub.")
78
- processor, session = None, None
79
-
80
 
81
  # ============================================================
82
- # Core helpers
83
  # ============================================================
84
- def _run_clip(image_pil: Image.Image, texts):
85
- if processor is None or session is None:
86
- return None
87
- try:
88
- inputs = processor(
89
- text=texts, images=image_pil, return_tensors="np", padding=True
90
- )
91
- ort_inputs = _ensure_int64(inputs)
92
- outputs = session.run(None, ort_inputs)
93
- logits_per_image = outputs[0] # (1, n_texts)
94
- probs = _softmax_safe(logits_per_image, axis=-1)[0]
95
- return probs
96
- except Exception:
97
- _print_exc("[_run_clip] Inference failed")
98
- return None
99
-
100
-
101
- def detect_gender(image_pil: Image.Image) -> str:
102
- texts = ["a man", "a woman"]
103
- probs = _run_clip(image_pil, texts)
104
- if probs is None:
105
- return "unknown"
106
- return "man" if int(np.argmax(probs)) == 0 else "woman"
107
-
108
-
109
- def detect_age_group(image_pil: Image.Image) -> str:
110
- texts = ["a young person", "a middle-aged person", "an old person"]
111
- probs = _run_clip(image_pil, texts)
112
- if probs is None:
113
- return "unknown"
114
- return ["young", "middle-aged", "old"][int(np.argmax(probs))]
115
-
116
-
117
- def score_with_terms(image_pil: Image.Image, positive_terms, negative_terms):
118
- probs_all = []
119
- for pos, neg in zip(positive_terms, negative_terms):
120
- probs = _run_clip(image_pil, [pos, neg])
121
- if probs is None or len(probs) != 2:
122
- return (
123
- DEFAULT_OUTPUT[0],
124
- DEFAULT_OUTPUT[1],
125
- DEFAULT_OUTPUT[2],
126
- DEFAULT_OUTPUT[3],
127
- )
128
- probs_all.append(probs)
129
-
130
- positive_probs = [p[0] for p in probs_all]
131
- negative_probs = [p[1] for p in probs_all]
132
 
133
- s1 = round((probs_all[0][0] - probs_all[0][1] + 1) * 50, 2)
134
- s2 = round((probs_all[1][0] - probs_all[1][1] + 1) * 50, 2)
135
- s3 = round((probs_all[2][0] - probs_all[2][1] + 1) * 50, 2)
136
-
137
- hot_score = float(np.mean(positive_probs))
138
- ugly_score = float(np.mean(negative_probs))
139
- composite = round(((hot_score - ugly_score) + 1) * 50, 2)
140
-
141
- return composite, s1, s2, s3
142
 
143
 
144
  # ============================================================
145
  # Gradio callback
146
  # ============================================================
147
  def hotornot(image):
148
- if processor is None or session is None:
149
- return DEFAULT_OUTPUT
150
-
151
- if image is None:
152
- image_pil = _dummy_image()
153
- else:
154
- try:
155
- image_pil = Image.fromarray(image.astype("uint8"), "RGB")
156
- except Exception:
157
- _print_exc("[hotornot] Failed to convert input to PIL. Using dummy image.")
158
- image_pil = _dummy_image()
159
-
160
- try:
161
- gender = detect_gender(image_pil)
162
- age_group = detect_age_group(image_pil)
163
-
164
- if gender == "man":
165
- positive_terms = ["a handsome man", "a charming man", "an attractive man"]
166
- negative_terms = ["an ugly man", "a gross man", "a hideous man"]
167
- elif gender == "woman":
168
- positive_terms = [
169
- "a beautiful woman",
170
- "a cute woman",
171
- "an attractive woman",
172
- ]
173
- negative_terms = ["an ugly woman", "a gross woman", "a hideous woman"]
174
- else:
175
- positive_terms = [
176
- "a hot person",
177
- "a beautiful person",
178
- "an attractive person",
179
- ]
180
- negative_terms = ["an ugly person", "a gross person", "a hideous person"]
181
-
182
- composite, hotness, second, attractiveness = score_with_terms(
183
- image_pil, positive_terms, negative_terms
184
- )
185
- return composite, hotness, second, attractiveness, gender, age_group
186
-
187
- except Exception:
188
- _print_exc("[hotornot] Unexpected error")
189
- return DEFAULT_OUTPUT
190
 
191
 
192
  # ============================================================
@@ -195,8 +31,8 @@ def hotornot(image):
195
  CSS = f"""
196
  #fixed_img_component img,
197
  #fixed_img_component canvas {{
198
- width: {FIXED_IMG_W}px !important;
199
- height: {FIXED_IMG_H}px !important;
200
  object-fit: contain !important;
201
  }}
202
  """
@@ -212,8 +48,8 @@ with gr.Blocks(css=CSS) as demo:
212
  label="Upload Image",
213
  type="numpy",
214
  image_mode="RGB",
215
- height=FIXED_IMG_H,
216
- width=FIXED_IMG_W,
217
  elem_id="fixed_img_component",
218
  )
219
 
 
 
 
 
 
 
 
 
 
1
  import gradio as gr
2
+ from core import create_huggingface_scorer, Config
3
 
4
  # ============================================================
5
+ # Configuration
6
  # ============================================================
7
+ REPO_ID = "sayantan47/clip-vit-b32-onnx" # <-- change this if needed
8
  MODEL_FILENAME = "onnx/model.onnx"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
 
10
  # ============================================================
11
+ # Initialize Model
12
  # ============================================================
13
+ print("Loading HuggingFace model...")
14
+ scorer = create_huggingface_scorer(REPO_ID, MODEL_FILENAME)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
+ if not scorer.model.is_loaded():
17
+ print("WARNING: Model failed to load. App will return default values.")
 
 
 
 
 
 
 
18
 
19
 
20
  # ============================================================
21
  # Gradio callback
22
  # ============================================================
23
  def hotornot(image):
24
+ """Main Gradio callback function."""
25
+ return scorer.evaluate_image(image)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
 
27
 
28
  # ============================================================
 
31
  CSS = f"""
32
  #fixed_img_component img,
33
  #fixed_img_component canvas {{
34
+ width: {Config.FIXED_IMG_W}px !important;
35
+ height: {Config.FIXED_IMG_H}px !important;
36
  object-fit: contain !important;
37
  }}
38
  """
 
48
  label="Upload Image",
49
  type="numpy",
50
  image_mode="RGB",
51
+ height=Config.FIXED_IMG_H,
52
+ width=Config.FIXED_IMG_W,
53
  elem_id="fixed_img_component",
54
  )
55
 
core.py ADDED
@@ -0,0 +1,311 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ import traceback
4
+ import numpy as np
5
+ import onnxruntime as ort
6
+ from transformers import CLIPProcessor
7
+ from PIL import Image
8
+ from typing import Optional, List, Tuple, Union
9
+ from abc import ABC, abstractmethod
10
+
11
+
12
+ # ============================================================
13
+ # Configuration
14
+ # ============================================================
15
+ class Config:
16
+ DEFAULT_OUTPUT = (0.0, 0.0, 0.0, 0.0, "unknown", "unknown")
17
+ FIXED_IMG_W = 300
18
+ FIXED_IMG_H = 300
19
+ PROVIDERS = ["CPUExecutionProvider"] # keep CPU to avoid CUDA DLL issues
20
+
21
+
22
+ # ============================================================
23
+ # Utilities
24
+ # ============================================================
25
+ def print_exc(prefix: str):
26
+ """Print exception with prefix to stderr."""
27
+ print(prefix, file=sys.stderr)
28
+ traceback.print_exc()
29
+
30
+
31
+ def softmax_safe(x: np.ndarray, axis: int = -1) -> np.ndarray:
32
+ """Safe softmax implementation that handles edge cases."""
33
+ try:
34
+ x = x - np.max(x, axis=axis, keepdims=True)
35
+ ex = np.exp(x)
36
+ denom = np.sum(ex, axis=axis, keepdims=True)
37
+ denom = np.where(denom == 0, 1.0, denom)
38
+ return ex / denom
39
+ except Exception:
40
+ print_exc("[softmax_safe] failed")
41
+ return np.ones_like(x) / x.shape[-1]
42
+
43
+
44
+ def ensure_int64(feed_dict: dict) -> dict:
45
+ """Convert int32 arrays to int64 for ONNX compatibility."""
46
+ out = {}
47
+ for k, v in feed_dict.items():
48
+ if isinstance(v, np.ndarray) and v.dtype == np.int32:
49
+ out[k] = v.astype(np.int64)
50
+ else:
51
+ out[k] = v
52
+ return out
53
+
54
+
55
+ def create_dummy_image(width: int = Config.FIXED_IMG_W, height: int = Config.FIXED_IMG_H) -> Image.Image:
56
+ """Create a dummy gray image for fallback cases."""
57
+ return Image.fromarray(np.full((height, width, 3), 127, dtype=np.uint8), "RGB")
58
+
59
+
60
+ # ============================================================
61
+ # Abstract Model Interface
62
+ # ============================================================
63
+ class ModelInterface(ABC):
64
+ """Abstract interface for CLIP models."""
65
+
66
+ @abstractmethod
67
+ def is_loaded(self) -> bool:
68
+ """Check if model is properly loaded."""
69
+ pass
70
+
71
+ @abstractmethod
72
+ def run_inference(self, image_pil: Image.Image, texts: List[str]) -> Optional[np.ndarray]:
73
+ """Run CLIP inference on image and texts."""
74
+ pass
75
+
76
+
77
+ # ============================================================
78
+ # Model Implementations
79
+ # ============================================================
80
+ class HuggingFaceModel(ModelInterface):
81
+ """CLIP model loaded from Hugging Face Hub."""
82
+
83
+ def __init__(self, repo_id: str, model_filename: str):
84
+ self.repo_id = repo_id
85
+ self.model_filename = model_filename
86
+ self.processor = None
87
+ self.session = None
88
+ self._load_model()
89
+
90
+ def _load_model(self):
91
+ """Load model and processor from Hugging Face Hub."""
92
+ try:
93
+ from huggingface_hub import hf_hub_download
94
+
95
+ # Download model.onnx
96
+ model_path = hf_hub_download(
97
+ repo_id=self.repo_id,
98
+ filename=self.model_filename,
99
+ local_dir="hf_cache",
100
+ local_dir_use_symlinks=False,
101
+ resume_download=True,
102
+ )
103
+
104
+ # Load processor (tokenizer + preproc files) from the same repo
105
+ self.processor = CLIPProcessor.from_pretrained(self.repo_id)
106
+ self.session = ort.InferenceSession(model_path, providers=Config.PROVIDERS)
107
+
108
+ except Exception:
109
+ print_exc("[HuggingFaceModel] Failed to download/load model from HF Hub.")
110
+ self.processor, self.session = None, None
111
+
112
+ def is_loaded(self) -> bool:
113
+ """Check if model is properly loaded."""
114
+ return self.processor is not None and self.session is not None
115
+
116
+ def run_inference(self, image_pil: Image.Image, texts: List[str]) -> Optional[np.ndarray]:
117
+ """Run CLIP inference on image and texts."""
118
+ if not self.is_loaded():
119
+ return None
120
+
121
+ try:
122
+ inputs = self.processor(
123
+ text=texts, images=image_pil, return_tensors="np", padding=True
124
+ )
125
+ ort_inputs = ensure_int64(inputs)
126
+ outputs = self.session.run(None, ort_inputs)
127
+ logits_per_image = outputs[0] # (1, n_texts)
128
+ probs = softmax_safe(logits_per_image, axis=-1)[0]
129
+ return probs
130
+ except Exception:
131
+ print_exc("[HuggingFaceModel] Inference failed")
132
+ return None
133
+
134
+
135
+ class LocalModel(ModelInterface):
136
+ """CLIP model loaded from local files."""
137
+
138
+ def __init__(self, model_path: str, processor_path: Optional[str] = None):
139
+ self.model_path = model_path
140
+ self.processor_path = processor_path
141
+ self.processor = None
142
+ self.session = None
143
+ self._load_model()
144
+
145
+ def _load_model(self):
146
+ """Load model and processor from local files."""
147
+ try:
148
+ # Load ONNX model
149
+ if not os.path.exists(self.model_path):
150
+ raise FileNotFoundError(f"Model file not found: {self.model_path}")
151
+
152
+ self.session = ort.InferenceSession(self.model_path, providers=Config.PROVIDERS)
153
+
154
+ # Load processor
155
+ if self.processor_path and os.path.exists(self.processor_path):
156
+ self.processor = CLIPProcessor.from_pretrained(self.processor_path)
157
+ else:
158
+ # Fallback to a default processor if local processor not available
159
+ print("[LocalModel] Using default CLIP processor")
160
+ self.processor = CLIPProcessor.from_pretrained("openai/clip-vit-base-patch32")
161
+
162
+ except Exception:
163
+ print_exc("[LocalModel] Failed to load local model.")
164
+ self.processor, self.session = None, None
165
+
166
+ def is_loaded(self) -> bool:
167
+ """Check if model is properly loaded."""
168
+ return self.processor is not None and self.session is not None
169
+
170
+ def run_inference(self, image_pil: Image.Image, texts: List[str]) -> Optional[np.ndarray]:
171
+ """Run CLIP inference on image and texts."""
172
+ if not self.is_loaded():
173
+ return None
174
+
175
+ try:
176
+ inputs = self.processor(
177
+ text=texts, images=image_pil, return_tensors="np", padding=True
178
+ )
179
+ ort_inputs = ensure_int64(inputs)
180
+ outputs = self.session.run(None, ort_inputs)
181
+ logits_per_image = outputs[0] # (1, n_texts)
182
+ probs = softmax_safe(logits_per_image, axis=-1)[0]
183
+ return probs
184
+ except Exception:
185
+ print_exc("[LocalModel] Inference failed")
186
+ return None
187
+
188
+
189
+ # ============================================================
190
+ # Core Scoring Logic
191
+ # ============================================================
192
+ class HotOrNotScorer:
193
+ """Core logic for hot-or-not scoring using CLIP models."""
194
+
195
+ def __init__(self, model: ModelInterface):
196
+ self.model = model
197
+
198
+ def _run_clip(self, image_pil: Image.Image, texts: List[str]) -> Optional[np.ndarray]:
199
+ """Run CLIP inference wrapper."""
200
+ return self.model.run_inference(image_pil, texts)
201
+
202
+ def detect_gender(self, image_pil: Image.Image) -> str:
203
+ """Detect gender from image."""
204
+ texts = ["a man", "a woman"]
205
+ probs = self._run_clip(image_pil, texts)
206
+ if probs is None:
207
+ return "unknown"
208
+ return "man" if int(np.argmax(probs)) == 0 else "woman"
209
+
210
+ def detect_age_group(self, image_pil: Image.Image) -> str:
211
+ """Detect age group from image."""
212
+ texts = ["a young person", "a middle-aged person", "an old person"]
213
+ probs = self._run_clip(image_pil, texts)
214
+ if probs is None:
215
+ return "unknown"
216
+ return ["young", "middle-aged", "old"][int(np.argmax(probs))]
217
+
218
+ def score_with_terms(self, image_pil: Image.Image, positive_terms: List[str], negative_terms: List[str]) -> Tuple[float, float, float, float]:
219
+ """Score image with positive and negative terms."""
220
+ probs_all = []
221
+ for pos, neg in zip(positive_terms, negative_terms):
222
+ probs = self._run_clip(image_pil, [pos, neg])
223
+ if probs is None or len(probs) != 2:
224
+ return (
225
+ Config.DEFAULT_OUTPUT[0],
226
+ Config.DEFAULT_OUTPUT[1],
227
+ Config.DEFAULT_OUTPUT[2],
228
+ Config.DEFAULT_OUTPUT[3],
229
+ )
230
+ probs_all.append(probs)
231
+
232
+ s1 = round((probs_all[0][0] - probs_all[0][1] + 1) * 50, 2)
233
+ s2 = round((probs_all[1][0] - probs_all[1][1] + 1) * 50, 2)
234
+ s3 = round((probs_all[2][0] - probs_all[2][1] + 1) * 50, 2)
235
+
236
+ positive_probs = [p[0] for p in probs_all]
237
+ negative_probs = [p[1] for p in probs_all]
238
+ hot_score = float(np.mean(positive_probs))
239
+ ugly_score = float(np.mean(negative_probs))
240
+ composite = round(((hot_score - ugly_score) + 1) * 50, 2)
241
+
242
+ return composite, s1, s2, s3
243
+
244
+ def evaluate_image(self, image: Union[np.ndarray, Image.Image, None]) -> Tuple[float, float, float, float, str, str]:
245
+ """Main evaluation function that returns complete scoring."""
246
+ if not self.model.is_loaded():
247
+ return Config.DEFAULT_OUTPUT
248
+
249
+ # Handle input image
250
+ if image is None:
251
+ image_pil = create_dummy_image()
252
+ else:
253
+ try:
254
+ if isinstance(image, np.ndarray):
255
+ image_pil = Image.fromarray(image.astype("uint8"), "RGB")
256
+ elif isinstance(image, Image.Image):
257
+ image_pil = image
258
+ else:
259
+ raise ValueError("Unsupported image type")
260
+ except Exception:
261
+ print_exc("[evaluate_image] Failed to convert input to PIL. Using dummy image.")
262
+ image_pil = create_dummy_image()
263
+
264
+ try:
265
+ # Detect attributes
266
+ gender = self.detect_gender(image_pil)
267
+ age_group = self.detect_age_group(image_pil)
268
+
269
+ # Define terms based on detected gender
270
+ if gender == "man":
271
+ positive_terms = ["a handsome man", "a charming man", "an attractive man"]
272
+ negative_terms = ["an ugly man", "a gross man", "a hideous man"]
273
+ elif gender == "woman":
274
+ positive_terms = [
275
+ "a beautiful woman",
276
+ "a cute woman",
277
+ "an attractive woman",
278
+ ]
279
+ negative_terms = ["an ugly woman", "a gross woman", "a hideous woman"]
280
+ else:
281
+ positive_terms = [
282
+ "a hot person",
283
+ "a beautiful person",
284
+ "an attractive person",
285
+ ]
286
+ negative_terms = ["an ugly person", "a gross person", "a hideous person"]
287
+
288
+ # Calculate scores
289
+ composite, hotness, second, attractiveness = self.score_with_terms(
290
+ image_pil, positive_terms, negative_terms
291
+ )
292
+ return composite, hotness, second, attractiveness, gender, age_group
293
+
294
+ except Exception:
295
+ print_exc("[evaluate_image] Unexpected error")
296
+ return Config.DEFAULT_OUTPUT
297
+
298
+
299
+ # ============================================================
300
+ # Factory Functions
301
+ # ============================================================
302
+ def create_huggingface_scorer(repo_id: str = "sayantan47/clip-vit-b32-onnx", model_filename: str = "onnx/model.onnx") -> HotOrNotScorer:
303
+ """Create a scorer using HuggingFace model."""
304
+ model = HuggingFaceModel(repo_id, model_filename)
305
+ return HotOrNotScorer(model)
306
+
307
+
308
+ def create_local_scorer(model_path: str, processor_path: Optional[str] = None) -> HotOrNotScorer:
309
+ """Create a scorer using local model."""
310
+ model = LocalModel(model_path, processor_path)
311
+ return HotOrNotScorer(model)