esandorfi commited on
Commit
d547fdb
Β·
1 Parent(s): 6f7642d

Change to API default directory

Browse files
Files changed (45) hide show
  1. Dockerfile +1 -1
  2. Makefile +43 -6
  3. README.md +37 -27
  4. app.py +3 -1
  5. pyproject.toml +1 -0
  6. src/{app β†’ api}/__init__.py +0 -0
  7. src/{app β†’ api}/app_factory.py +1 -1
  8. src/{app β†’ api}/banks.py +0 -0
  9. src/{app β†’ api}/clip_service.py +3 -3
  10. src/{app β†’ api}/clip_store.py +4 -4
  11. src/{app β†’ api}/deps.py +2 -2
  12. src/{app β†’ api}/image_io.py +0 -0
  13. src/{app β†’ api}/label_hash.py +0 -0
  14. src/{app β†’ api}/logging_utils.py +0 -0
  15. src/{app β†’ api}/main.py +101 -11
  16. src/{app β†’ api}/middleware.py +0 -0
  17. src/{app β†’ api}/registry.py +1 -1
  18. src/{app β†’ api}/results.py +0 -0
  19. src/{app β†’ api}/schemas.py +0 -0
  20. src/{app β†’ api}/settings.py +0 -0
  21. src/api/splash.html +77 -0
  22. src/app/__pycache__/__init__.cpython-312.pyc +0 -0
  23. src/app/__pycache__/banks.cpython-312.pyc +0 -0
  24. src/app/__pycache__/clip_service.cpython-312.pyc +0 -0
  25. src/app/__pycache__/clip_store.cpython-312.pyc +0 -0
  26. src/app/__pycache__/deps.cpython-312.pyc +0 -0
  27. src/app/__pycache__/image_io.cpython-312.pyc +0 -0
  28. src/app/__pycache__/label_hash.cpython-312.pyc +0 -0
  29. src/app/__pycache__/logging_utils.cpython-312.pyc +0 -0
  30. src/app/__pycache__/main.cpython-312.pyc +0 -0
  31. src/app/__pycache__/middleware.cpython-312.pyc +0 -0
  32. src/app/__pycache__/registry.cpython-312.pyc +0 -0
  33. src/app/__pycache__/results.cpython-312.pyc +0 -0
  34. src/app/__pycache__/schemas.cpython-312.pyc +0 -0
  35. src/app/__pycache__/settings.cpython-312.pyc +0 -0
  36. src/eval/classify_dataset.py +6 -5
  37. src/eval/common.py +17 -0
  38. src/eval/eval_matrix.py +6 -5
  39. tests/__pycache__/conftest.cpython-312-pytest-8.3.2.pyc +0 -0
  40. tests/__pycache__/fakes.cpython-312.pyc +0 -0
  41. tests/__pycache__/test_integration_real_clip.cpython-312-pytest-8.3.2.pyc +0 -0
  42. tests/conftest.py +1 -1
  43. tests/fakes.py +4 -4
  44. tests/test_integration_real_clip.py +3 -3
  45. uv.lock +11 -0
Dockerfile CHANGED
@@ -24,4 +24,4 @@ RUN uv sync --no-dev
24
 
25
  ENV PATH="$HOME/app/.venv/bin:$PATH"
26
 
27
- CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "1"]
 
24
 
25
  ENV PATH="$HOME/app/.venv/bin:$PATH"
26
 
27
+ CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "1"]
Makefile CHANGED
@@ -1,18 +1,28 @@
1
- .PHONY: help docker-build docker-run local-install local-run local-test local-test-integration eval-photo eval-dance eval-photo-matrix eval-dance-matrix data-photos data-dance
2
 
3
  help:
4
  @echo "---------------------------------------------------"
5
  @echo "Targets:"
 
6
  @echo " docker-build Build Docker image"
7
  @echo " docker-run Run Docker image on :7860"
 
 
8
  @echo " local-install Sync deps with uv"
9
  @echo " local-run Run API locally on :7860"
10
  @echo " local-test Run unit tests"
11
  @echo " local-test-integration Run integration tests"
 
12
  @echo " eval-photo Run eval on personal-photos-lite-v1"
13
  @echo " eval-dance Run eval on scene-dance-formation-group-v1"
14
  @echo " eval-photo-matrix Run eval for all personal-photos label sets"
15
  @echo " eval-dance-matrix Run eval for all dance label sets"
 
 
 
 
 
 
16
  @echo " data-photos Download + normalize photo eval dataset"
17
  @echo " data-dance Download + normalize dance eval dataset"
18
 
@@ -22,11 +32,14 @@ docker-build:
22
  docker-run:
23
  docker run --rm -p 7860:7860 photo-classification
24
 
 
 
 
25
  local-install:
26
  uv sync --extra dev --python 3.12
27
 
28
  local-run:
29
- uv run uvicorn app.main:app --host 0.0.0.0 --port 7860
30
 
31
  local-test:
32
  uv run pytest -q
@@ -38,28 +51,52 @@ eval-photo:
38
  uv run photo-eval single \
39
  --label-set label-dataset/personal-photos-lite-v1.json \
40
  --images data_eval/photos/normalized \
41
- --out-dir data_results \
42
  --summary
43
 
44
  eval-dance:
45
  uv run photo-eval single \
46
  --label-set label-dataset/scene-dance-formation-group-v1.json \
47
  --images data_eval/dance/normalized \
48
- --out-dir data_results \
49
  --summary
50
 
51
  eval-photo-matrix:
52
  uv run photo-eval matrix \
53
  --label-sets "label-dataset/personal-photos-*.json" \
54
  --images data_eval/photos/normalized \
55
- --out-dir data_results \
56
  --summary
57
 
58
  eval-dance-matrix:
59
  uv run photo-eval matrix \
60
  --label-sets "label-dataset/scene-dance-*.json" \
61
  --images data_eval/dance/normalized \
62
- --out-dir data_results \
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  --summary
64
 
65
  data-photos:
 
1
+ .PHONY: help docker-build docker-run local-install local-run local-test local-test-integration eval-photo eval-dance eval-photo-matrix eval-dance-matrix eval-photo-hf eval-dance-hf eval-photo-matrix-hf eval-dance-matrix-hf data-photos data-dance
2
 
3
  help:
4
  @echo "---------------------------------------------------"
5
  @echo "Targets:"
6
+ @echo " "
7
  @echo " docker-build Build Docker image"
8
  @echo " docker-run Run Docker image on :7860"
9
+ @echo " docker-stop Stop Docker image"
10
+ @echo " "
11
  @echo " local-install Sync deps with uv"
12
  @echo " local-run Run API locally on :7860"
13
  @echo " local-test Run unit tests"
14
  @echo " local-test-integration Run integration tests"
15
+ @echo " "
16
  @echo " eval-photo Run eval on personal-photos-lite-v1"
17
  @echo " eval-dance Run eval on scene-dance-formation-group-v1"
18
  @echo " eval-photo-matrix Run eval for all personal-photos label sets"
19
  @echo " eval-dance-matrix Run eval for all dance label sets"
20
+ @echo " "
21
+ @echo " eval-photo-hf Run eval on HF Space (photo)"
22
+ @echo " eval-dance-hf Run eval on HF Space (dance)"
23
+ @echo " eval-photo-matrix-hf Run matrix eval on HF Space (photo)"
24
+ @echo " eval-dance-matrix-hf Run matrix eval on HF Space (dance)"
25
+ @echo " "
26
  @echo " data-photos Download + normalize photo eval dataset"
27
  @echo " data-dance Download + normalize dance eval dataset"
28
 
 
32
  docker-run:
33
  docker run --rm -p 7860:7860 photo-classification
34
 
35
+ docker-stop:
36
+ docker stop photo-classification
37
+
38
  local-install:
39
  uv sync --extra dev --python 3.12
40
 
41
  local-run:
42
+ uv run uvicorn api.main:app --host 0.0.0.0 --port 7860 --reload
43
 
44
  local-test:
45
  uv run pytest -q
 
51
  uv run photo-eval single \
52
  --label-set label-dataset/personal-photos-lite-v1.json \
53
  --images data_eval/photos/normalized \
 
54
  --summary
55
 
56
  eval-dance:
57
  uv run photo-eval single \
58
  --label-set label-dataset/scene-dance-formation-group-v1.json \
59
  --images data_eval/dance/normalized \
 
60
  --summary
61
 
62
  eval-photo-matrix:
63
  uv run photo-eval matrix \
64
  --label-sets "label-dataset/personal-photos-*.json" \
65
  --images data_eval/photos/normalized \
 
66
  --summary
67
 
68
  eval-dance-matrix:
69
  uv run photo-eval matrix \
70
  --label-sets "label-dataset/scene-dance-*.json" \
71
  --images data_eval/dance/normalized \
72
+ --summary
73
+
74
+ eval-hf-photo:
75
+ uv run photo-eval single \
76
+ --api https://esandorfi-photo-classification.hf.space \
77
+ --label-set label-dataset/personal-photos-lite-v1.json \
78
+ --images data_eval/photos/normalized \
79
+ --summary
80
+
81
+ eval-hf-dance:
82
+ uv run photo-eval single \
83
+ --api https://esandorfi-photo-classification.hf.space \
84
+ --label-set label-dataset/scene-dance-formation-group-v1.json \
85
+ --images data_eval/dance/normalized \
86
+ --summary
87
+
88
+ eval-hf-photo-matrix:
89
+ uv run photo-eval matrix \
90
+ --api https://esandorfi-photo-classification.hf.space \
91
+ --label-sets "label-dataset/personal-photos-*.json" \
92
+ --images data_eval/photos/normalized \
93
+ --summary
94
+
95
+ eval-hf-dance-matrix:
96
+ uv run photo-eval matrix \
97
+ --api https://esandorfi-photo-classification.hf.space \
98
+ --label-sets "label-dataset/scene-dance-*.json" \
99
+ --images data_eval/dance/normalized \
100
  --summary
101
 
102
  data-photos:
README.md CHANGED
@@ -9,13 +9,6 @@ app_port: 7860
9
 
10
  # Photo Classification API
11
 
12
- ```
13
- ____ _ _ ____ _
14
- | _ \| |__ ___ | |_ ___ / ___| | __ _ ___ ___
15
- | |_) | '_ \ / _ \| __/ _ \ | | | |/ _` / __/ __|
16
- | __/| | | | (_) | || (_) | | |___| | (_| \__ \__ \
17
- |_| |_| |_|\___/ \__\___/ \____|_|\__,_|___/___/
18
- ```
19
 
20
  A small, prompt-driven photo classification API built on CLIP. You upload a label set
21
  (domains + labels with prompts), then classify images against that taxonomy without
@@ -51,7 +44,7 @@ Use these as starting points or create your own taxonomy.
51
 
52
  ## API quickstart
53
 
54
- 1) Start the service (Docker or `uvicorn app.main:app`).
55
  2) Upload a label set.
56
  3) Optionally activate a label set.
57
  4) Classify images.
@@ -94,11 +87,11 @@ Guard policy example:
94
 
95
  ## Architecture
96
 
97
- - API layer: FastAPI endpoints and request/response schemas (`src/app/main.py`, `src/app/schemas.py`).
98
- - Use-case layer: two-stage classification (domain -> labels) (`src/app/clip_service.py`).
99
- - Model layer: CLIP model + processor + embedding banks (`src/app/clip_store.py`, `src/app/banks.py`).
100
- - Runtime support: registry, settings, logging, middleware (`src/app/registry.py`, `src/app/settings.py`,
101
- `src/app/logging_utils.py`, `src/app/middleware.py`).
102
 
103
  ## Coding rules (deeper)
104
 
@@ -167,6 +160,16 @@ uv run photo-eval single \
167
 
168
  Output CSV files are timestamped (UTC) in `data_results/`.
169
 
 
 
 
 
 
 
 
 
 
 
170
  Makefile shortcuts:
171
 
172
  - `make eval-photo`
@@ -226,18 +229,25 @@ uv run photo-eval prep --normalize-only --in-dir /path/to/images --out data_eval
226
  β”œβ”€β”€ Dockerfile
227
  β”œβ”€β”€ requirements.txt
228
  └── src
229
- └── app
230
- β”œβ”€β”€ banks.py
231
- β”œβ”€β”€ clip_service.py
232
- β”œβ”€β”€ clip_store.py
233
- β”œβ”€β”€ deps.py
234
- β”œβ”€β”€ image_io.py
235
- β”œβ”€β”€ label_hash.py
236
- β”œβ”€β”€ logging_utils.py
237
- β”œβ”€β”€ main.py
238
- β”œβ”€β”€ middleware.py
239
- β”œβ”€β”€ registry.py
240
- β”œβ”€β”€ results.py
241
- β”œβ”€β”€ schemas.py
242
- └── settings.py
 
 
 
 
 
 
 
243
  ```
 
9
 
10
  # Photo Classification API
11
 
 
 
 
 
 
 
 
12
 
13
  A small, prompt-driven photo classification API built on CLIP. You upload a label set
14
  (domains + labels with prompts), then classify images against that taxonomy without
 
44
 
45
  ## API quickstart
46
 
47
+ 1) Start the service (Docker or `uvicorn app:app`).
48
  2) Upload a label set.
49
  3) Optionally activate a label set.
50
  4) Classify images.
 
87
 
88
  ## Architecture
89
 
90
+ - API layer: FastAPI endpoints and request/response schemas (`src/api/main.py`, `src/api/schemas.py`).
91
+ - Use-case layer: two-stage classification (domain -> labels) (`src/api/clip_service.py`).
92
+ - Model layer: CLIP model + processor + embedding banks (`src/api/clip_store.py`, `src/api/banks.py`).
93
+ - Runtime support: registry, settings, logging, middleware (`src/api/registry.py`, `src/api/settings.py`,
94
+ `src/api/logging_utils.py`, `src/api/middleware.py`).
95
 
96
  ## Coding rules (deeper)
97
 
 
160
 
161
  Output CSV files are timestamped (UTC) in `data_results/`.
162
 
163
+ Run evals against a remote Space by setting `--api`:
164
+
165
+ ```bash
166
+ uv run photo-eval single \
167
+ --api https://esandorfi-photoclassification.hf.space \
168
+ --label-set label-dataset/personal-photos-lite-v1.json \
169
+ --images /path/to/images \
170
+ --summary
171
+ ```
172
+
173
  Makefile shortcuts:
174
 
175
  - `make eval-photo`
 
229
  β”œβ”€β”€ Dockerfile
230
  β”œβ”€β”€ requirements.txt
231
  └── src
232
+ β”œβ”€β”€ api
233
+ β”‚ β”œβ”€β”€ banks.py
234
+ β”‚ β”œβ”€β”€ clip_service.py
235
+ β”‚ β”œβ”€β”€ clip_store.py
236
+ β”‚ β”œβ”€β”€ deps.py
237
+ β”‚ β”œβ”€β”€ image_io.py
238
+ β”‚ β”œβ”€β”€ label_hash.py
239
+ β”‚ β”œβ”€β”€ logging_utils.py
240
+ β”‚ β”œβ”€β”€ main.py
241
+ β”‚ β”œβ”€β”€ middleware.py
242
+ β”‚ β”œβ”€β”€ registry.py
243
+ β”‚ β”œβ”€β”€ results.py
244
+ β”‚ β”œβ”€β”€ schemas.py
245
+ β”‚ └── settings.py
246
+ └── eval
247
+ β”œβ”€β”€ README.md
248
+ β”œβ”€β”€ cli.py
249
+ β”œβ”€β”€ classify_dataset.py
250
+ β”œβ”€β”€ common.py
251
+ β”œβ”€β”€ dataset_prep.py
252
+ └── eval_matrix.py
253
  ```
app.py CHANGED
@@ -1 +1,3 @@
1
- print('Fake app for HF Spaces')
 
 
 
1
+ from api.main import app
2
+
3
+ __all__ = ["app"]
pyproject.toml CHANGED
@@ -10,6 +10,7 @@ readme = "README.md"
10
  requires-python = ">=3.12,<3.13"
11
  dependencies = [
12
  "fastapi==0.115.6",
 
13
  "pillow==10.4.0",
14
  "pydantic==2.8.2",
15
  "torch==2.3.1",
 
10
  requires-python = ">=3.12,<3.13"
11
  dependencies = [
12
  "fastapi==0.115.6",
13
+ "markdown==3.7",
14
  "pillow==10.4.0",
15
  "pydantic==2.8.2",
16
  "torch==2.3.1",
src/{app β†’ api}/__init__.py RENAMED
File without changes
src/{app β†’ api}/app_factory.py RENAMED
@@ -1,7 +1,7 @@
1
  from __future__ import annotations
2
 
3
  from fastapi import FastAPI
4
- from app.main import build_app # we'll define build_app in main.py
5
 
6
  def create_app() -> FastAPI:
7
  return build_app()
 
1
  from __future__ import annotations
2
 
3
  from fastapi import FastAPI
4
+ from api.main import build_app # we'll define build_app in main.py
5
 
6
  def create_app() -> FastAPI:
7
  return build_app()
src/{app β†’ api}/banks.py RENAMED
File without changes
src/{app β†’ api}/clip_service.py RENAMED
@@ -5,9 +5,9 @@ from dataclasses import dataclass
5
 
6
  import torch
7
 
8
- from app.banks import EmbeddingBank, LabelSetBank
9
- from app.clip_store import ClipStore
10
- from app.results import ClassificationResult, StageTimings
11
 
12
 
13
  @dataclass(slots=True)
 
5
 
6
  import torch
7
 
8
+ from api.banks import EmbeddingBank, LabelSetBank
9
+ from api.clip_store import ClipStore
10
+ from api.results import ClassificationResult, StageTimings
11
 
12
 
13
  @dataclass(slots=True)
src/{app β†’ api}/clip_store.py RENAMED
@@ -5,10 +5,10 @@ import warnings
5
  import torch
6
  from transformers import CLIPModel, CLIPProcessor
7
 
8
- from app.banks import EmbeddingBank, LabelSetBank
9
- from app.label_hash import stable_hash
10
- from app.schemas import LabelSet
11
- from app.settings import settings
12
 
13
 
14
  class ClipStore:
 
5
  import torch
6
  from transformers import CLIPModel, CLIPProcessor
7
 
8
+ from api.banks import EmbeddingBank, LabelSetBank
9
+ from api.label_hash import stable_hash
10
+ from api.schemas import LabelSet
11
+ from api.settings import settings
12
 
13
 
14
  class ClipStore:
src/{app β†’ api}/deps.py RENAMED
@@ -4,8 +4,8 @@ from typing import Optional
4
 
5
  from fastapi import Depends, HTTPException, Query, Request
6
 
7
- from app.banks import LabelSetBank
8
- from app.registry import LabelSetRegistry
9
 
10
 
11
  def get_request_id(request: Request) -> str:
 
4
 
5
  from fastapi import Depends, HTTPException, Query, Request
6
 
7
+ from api.banks import LabelSetBank
8
+ from api.registry import LabelSetRegistry
9
 
10
 
11
  def get_request_id(request: Request) -> str:
src/{app β†’ api}/image_io.py RENAMED
File without changes
src/{app β†’ api}/label_hash.py RENAMED
File without changes
src/{app β†’ api}/logging_utils.py RENAMED
File without changes
src/{app β†’ api}/main.py RENAMED
@@ -5,16 +5,19 @@ from dataclasses import dataclass
5
  from typing import Optional
6
 
7
  from fastapi import Depends, FastAPI, HTTPException, Query, Request, Response
8
- from fastapi.responses import JSONResponse
9
-
10
- from app.clip_store import ClipStore
11
- from app.clip_service import TwoStageClassifier
12
- from app.deps import get_request_id, resolve_bank
13
- from app.image_io import load_image_from_base64
14
- from app.logging_utils import setup_logging, log_json
15
- from app.middleware import RequestIdMiddleware
16
- from app.registry import LabelSetRegistry
17
- from app.schemas import (
 
 
 
18
  ActivateResponse,
19
  ClassifyRequest,
20
  ClassifyResponse,
@@ -23,7 +26,7 @@ from app.schemas import (
23
  LabelSetCreateResponse,
24
  LabelSetInfo,
25
  )
26
- from app.settings import settings
27
 
28
 
29
  logger = setup_logging()
@@ -124,6 +127,93 @@ def create_app(*, resources: Resources | None = None) -> FastAPI:
124
  )
125
  return Response(content=svg, media_type="image/svg+xml")
126
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  @app.exception_handler(Exception)
128
  async def unhandled_exception_handler(request: Request, exc: Exception):
129
  rid = getattr(request.state, "request_id", None)
 
5
  from typing import Optional
6
 
7
  from fastapi import Depends, FastAPI, HTTPException, Query, Request, Response
8
+ from fastapi.responses import HTMLResponse, JSONResponse
9
+ from pathlib import Path
10
+
11
+ import markdown
12
+
13
+ from api.clip_store import ClipStore
14
+ from api.clip_service import TwoStageClassifier
15
+ from api.deps import get_request_id, resolve_bank
16
+ from api.image_io import load_image_from_base64
17
+ from api.logging_utils import setup_logging, log_json
18
+ from api.middleware import RequestIdMiddleware
19
+ from api.registry import LabelSetRegistry
20
+ from api.schemas import (
21
  ActivateResponse,
22
  ClassifyRequest,
23
  ClassifyResponse,
 
26
  LabelSetCreateResponse,
27
  LabelSetInfo,
28
  )
29
+ from api.settings import settings
30
 
31
 
32
  logger = setup_logging()
 
127
  )
128
  return Response(content=svg, media_type="image/svg+xml")
129
 
130
+ @app.get("/", include_in_schema=False)
131
+ def home() -> HTMLResponse:
132
+ splash_path = Path(__file__).with_name("splash.html")
133
+ try:
134
+ html = splash_path.read_text(encoding="utf-8")
135
+ except Exception:
136
+ html = "<h1>Photo Class</h1><p>Missing splash.html</p>"
137
+ return HTMLResponse(content=html)
138
+
139
+ @app.get("/readme", include_in_schema=False)
140
+ def readme() -> HTMLResponse:
141
+ readme_path = Path(__file__).resolve().parents[2] / "README.md"
142
+ try:
143
+ md_text = readme_path.read_text(encoding="utf-8")
144
+ except Exception:
145
+ md_text = "# Photo Classification API\n\nREADME.md not found."
146
+
147
+ if md_text.lstrip().startswith("---"):
148
+ parts = md_text.split("---", 2)
149
+ if len(parts) == 3:
150
+ md_text = parts[2].lstrip()
151
+
152
+ content_html = markdown.markdown(
153
+ md_text,
154
+ extensions=["fenced_code", "tables"],
155
+ output_format="html5",
156
+ )
157
+
158
+ html = f"""<!doctype html>
159
+ <html>
160
+ <head>
161
+ <meta charset="utf-8" />
162
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
163
+ <title>README</title>
164
+ <style>
165
+ :root {{
166
+ --bg: #f8fafc;
167
+ --card: #ffffff;
168
+ --ink: #0f172a;
169
+ --muted: #475569;
170
+ --line: #e2e8f0;
171
+ }}
172
+ * {{ box-sizing: border-box; }}
173
+ body {{
174
+ margin: 0;
175
+ font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
176
+ color: var(--ink);
177
+ background: var(--bg);
178
+ }}
179
+ header {{
180
+ padding: 16px 20px;
181
+ border-bottom: 1px solid var(--line);
182
+ background: #fff;
183
+ }}
184
+ main {{
185
+ max-width: 980px;
186
+ margin: 0 auto;
187
+ padding: 24px 20px 40px;
188
+ }}
189
+ pre {{
190
+ background: #f1f5f9;
191
+ color: #0f172a;
192
+ padding: 12px;
193
+ border-radius: 10px;
194
+ overflow-x: auto;
195
+ }}
196
+ code {{
197
+ background: #e2e8f0;
198
+ padding: 2px 6px;
199
+ border-radius: 6px;
200
+ }}
201
+ a {{
202
+ color: #2563eb;
203
+ }}
204
+ </style>
205
+ </head>
206
+ <body>
207
+ <header>
208
+ <a href="/">Back</a>
209
+ </header>
210
+ <main>
211
+ {content_html}
212
+ </main>
213
+ </body>
214
+ </html>"""
215
+ return HTMLResponse(content=html)
216
+
217
  @app.exception_handler(Exception)
218
  async def unhandled_exception_handler(request: Request, exc: Exception):
219
  rid = getattr(request.state, "request_id", None)
src/{app β†’ api}/middleware.py RENAMED
File without changes
src/{app β†’ api}/registry.py RENAMED
@@ -2,7 +2,7 @@ from __future__ import annotations
2
 
3
  from dataclasses import dataclass
4
  from typing import Optional
5
- from app.banks import LabelSetBank
6
 
7
 
8
  @dataclass(slots=True)
 
2
 
3
  from dataclasses import dataclass
4
  from typing import Optional
5
+ from api.banks import LabelSetBank
6
 
7
 
8
  @dataclass(slots=True)
src/{app β†’ api}/results.py RENAMED
File without changes
src/{app β†’ api}/schemas.py RENAMED
File without changes
src/{app β†’ api}/settings.py RENAMED
File without changes
src/api/splash.html ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Photo Class</title>
7
+ <style>
8
+ :root {
9
+ --bg: #f8fafc;
10
+ --card: #ffffff;
11
+ --ink: #0f172a;
12
+ --muted: #64748b;
13
+ --accent: #2563eb;
14
+ --line: #e2e8f0;
15
+ }
16
+ * { box-sizing: border-box; }
17
+ body {
18
+ margin: 0;
19
+ font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
20
+ color: var(--ink);
21
+ background: radial-gradient(circle at 10% 0%, #dbeafe, transparent 35%),
22
+ radial-gradient(circle at 90% 20%, #fee2e2, transparent 40%),
23
+ var(--bg);
24
+ min-height: 100vh;
25
+ display: grid;
26
+ place-items: center;
27
+ padding: 24px;
28
+ }
29
+ .card {
30
+ width: min(820px, 100%);
31
+ background: var(--card);
32
+ border: 1px solid var(--line);
33
+ border-radius: 20px;
34
+ padding: 28px;
35
+ box-shadow: 0 20px 40px rgba(15, 23, 42, 0.08);
36
+ }
37
+ h1 {
38
+ margin: 0 0 8px;
39
+ font-size: clamp(24px, 3vw, 34px);
40
+ letter-spacing: 0.04em;
41
+ text-transform: uppercase;
42
+ }
43
+ p {
44
+ margin: 0 0 20px;
45
+ color: var(--muted);
46
+ }
47
+ .actions {
48
+ display: flex;
49
+ flex-wrap: wrap;
50
+ gap: 12px;
51
+ }
52
+ a.button {
53
+ text-decoration: none;
54
+ padding: 10px 16px;
55
+ border-radius: 999px;
56
+ border: 1px solid var(--line);
57
+ color: var(--ink);
58
+ font-weight: 600;
59
+ background: #fff;
60
+ }
61
+ a.button.primary {
62
+ border-color: var(--accent);
63
+ color: var(--accent);
64
+ }
65
+ </style>
66
+ </head>
67
+ <body>
68
+ <div class="card">
69
+ <h1>Photo Class</h1>
70
+ <p>Prompt-driven photo classification with CLIP. Upload a label set, classify images, and inspect timings.</p>
71
+ <div class="actions">
72
+ <a class="button primary" href="/docs">API Docs</a>
73
+ <a class="button" href="/readme">README</a>
74
+ </div>
75
+ </div>
76
+ </body>
77
+ </html>
src/app/__pycache__/__init__.cpython-312.pyc DELETED
Binary file (149 Bytes)
 
src/app/__pycache__/banks.cpython-312.pyc DELETED
Binary file (964 Bytes)
 
src/app/__pycache__/clip_service.cpython-312.pyc DELETED
Binary file (4.86 kB)
 
src/app/__pycache__/clip_store.cpython-312.pyc DELETED
Binary file (5.74 kB)
 
src/app/__pycache__/deps.cpython-312.pyc DELETED
Binary file (1.74 kB)
 
src/app/__pycache__/image_io.cpython-312.pyc DELETED
Binary file (1.17 kB)
 
src/app/__pycache__/label_hash.cpython-312.pyc DELETED
Binary file (737 Bytes)
 
src/app/__pycache__/logging_utils.cpython-312.pyc DELETED
Binary file (1.57 kB)
 
src/app/__pycache__/main.cpython-312.pyc DELETED
Binary file (14 kB)
 
src/app/__pycache__/middleware.cpython-312.pyc DELETED
Binary file (1.09 kB)
 
src/app/__pycache__/registry.cpython-312.pyc DELETED
Binary file (1.67 kB)
 
src/app/__pycache__/results.cpython-312.pyc DELETED
Binary file (938 Bytes)
 
src/app/__pycache__/schemas.cpython-312.pyc DELETED
Binary file (3.27 kB)
 
src/app/__pycache__/settings.cpython-312.pyc DELETED
Binary file (705 Bytes)
 
src/eval/classify_dataset.py CHANGED
@@ -20,7 +20,7 @@ class Config:
20
  top_k: int
21
  activate: bool
22
  limit: int
23
- out_dir: Path
24
  csv_path: Path | None
25
  summary: bool
26
  select_domain_n: int | None
@@ -109,15 +109,16 @@ def run(cfg: Config) -> int:
109
  "elapsed_labels_ms",
110
  ]
111
 
 
112
  if cfg.csv_path and rows:
113
  common.write_csv(cfg.csv_path, rows, fieldnames)
114
  elif rows:
115
- out_path = cfg.out_dir / f"{cfg.label_set.stem}_{common.timestamp()}.csv"
116
  common.write_csv(out_path, rows, fieldnames)
117
 
118
  if cfg.summary:
119
  summary = common.summarize_latency(rows)
120
- summary_path = cfg.out_dir / f"{cfg.label_set.stem}_summary_{common.timestamp()}.csv"
121
  common.write_csv(summary_path, [summary], list(summary.keys()))
122
 
123
  return 0
@@ -131,7 +132,7 @@ def run(cfg: Config) -> int:
131
  @click.option("--top-k", default=5, show_default=True, type=int)
132
  @click.option("--activate", is_flag=True, default=False)
133
  @click.option("--limit", default=0, show_default=True, type=int)
134
- @click.option("--out-dir", default="data_results", show_default=True, type=click.Path(path_type=Path))
135
  @click.option("--csv", "csv_path", type=click.Path(path_type=Path))
136
  @click.option("--summary", is_flag=True, default=False)
137
  @click.option("--select-domain-n", type=int, default=None)
@@ -146,7 +147,7 @@ def cli(
146
  top_k: int,
147
  activate: bool,
148
  limit: int,
149
- out_dir: Path,
150
  csv_path: Path | None,
151
  summary: bool,
152
  select_domain_n: int | None,
 
20
  top_k: int
21
  activate: bool
22
  limit: int
23
+ out_dir: Path | None
24
  csv_path: Path | None
25
  summary: bool
26
  select_domain_n: int | None
 
109
  "elapsed_labels_ms",
110
  ]
111
 
112
+ out_dir = common.resolve_out_dir(cfg.api, cfg.out_dir)
113
  if cfg.csv_path and rows:
114
  common.write_csv(cfg.csv_path, rows, fieldnames)
115
  elif rows:
116
+ out_path = out_dir / f"{cfg.label_set.stem}_{common.timestamp()}.csv"
117
  common.write_csv(out_path, rows, fieldnames)
118
 
119
  if cfg.summary:
120
  summary = common.summarize_latency(rows)
121
+ summary_path = out_dir / f"{cfg.label_set.stem}_summary_{common.timestamp()}.csv"
122
  common.write_csv(summary_path, [summary], list(summary.keys()))
123
 
124
  return 0
 
132
  @click.option("--top-k", default=5, show_default=True, type=int)
133
  @click.option("--activate", is_flag=True, default=False)
134
  @click.option("--limit", default=0, show_default=True, type=int)
135
+ @click.option("--out-dir", type=click.Path(path_type=Path))
136
  @click.option("--csv", "csv_path", type=click.Path(path_type=Path))
137
  @click.option("--summary", is_flag=True, default=False)
138
  @click.option("--select-domain-n", type=int, default=None)
 
147
  top_k: int,
148
  activate: bool,
149
  limit: int,
150
+ out_dir: Path | None,
151
  csv_path: Path | None,
152
  summary: bool,
153
  select_domain_n: int | None,
src/eval/common.py CHANGED
@@ -7,6 +7,7 @@ from dataclasses import dataclass
7
  from datetime import datetime, timezone
8
  from pathlib import Path
9
  from typing import Iterable
 
10
 
11
  import httpx
12
 
@@ -101,6 +102,22 @@ def timestamp() -> str:
101
  return datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
102
 
103
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  def write_csv(path: Path, rows: list[dict[str, str]], fieldnames: list[str]) -> None:
105
  path.parent.mkdir(parents=True, exist_ok=True)
106
  with path.open("w", newline="", encoding="utf-8") as f:
 
7
  from datetime import datetime, timezone
8
  from pathlib import Path
9
  from typing import Iterable
10
+ from urllib.parse import urlparse
11
 
12
  import httpx
13
 
 
102
  return datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
103
 
104
 
105
+ def api_slug(api: str) -> str:
106
+ parsed = urlparse(api)
107
+ host = parsed.netloc or parsed.path
108
+ host = host.replace("http://", "").replace("https://", "")
109
+ host = host.strip("/")
110
+ if host in {"localhost:7860", "localhost", "127.0.0.1:7860", "127.0.0.1"}:
111
+ return "local"
112
+ return "".join(ch if ch.isalnum() or ch in {"-", "."} else "-" for ch in host)
113
+
114
+
115
+ def resolve_out_dir(api: str, out_dir: Path | None) -> Path:
116
+ if out_dir is not None:
117
+ return out_dir
118
+ return Path("data_results") / api_slug(api)
119
+
120
+
121
  def write_csv(path: Path, rows: list[dict[str, str]], fieldnames: list[str]) -> None:
122
  path.parent.mkdir(parents=True, exist_ok=True)
123
  with path.open("w", newline="", encoding="utf-8") as f:
src/eval/eval_matrix.py CHANGED
@@ -19,7 +19,7 @@ class Config:
19
  images: list[Path]
20
  domain_top_n: int
21
  top_k: int
22
- out_dir: Path
23
  summary: bool
24
  select_domain_n: int | None
25
  select_label_n: int | None
@@ -140,12 +140,13 @@ def run(cfg: Config) -> None:
140
  "elapsed_labels_ms",
141
  ]
142
 
143
- out_path = cfg.out_dir / f"eval_matrix_{common.timestamp()}.csv"
 
144
  common.write_csv(out_path, rows, fieldnames)
145
 
146
  if cfg.summary:
147
  summary_rows = summarize_by_label_set(rows)
148
- summary_path = cfg.out_dir / f"eval_matrix_summary_{common.timestamp()}.csv"
149
  common.write_csv(summary_path, summary_rows, ["label_set", "count", "avg_elapsed_ms", "p50_elapsed_ms", "p95_elapsed_ms"])
150
 
151
 
@@ -155,7 +156,7 @@ def run(cfg: Config) -> None:
155
  @click.option("--images", multiple=True, required=True, type=click.Path(path_type=Path))
156
  @click.option("--domain-top-n", default=2, show_default=True, type=int)
157
  @click.option("--top-k", default=5, show_default=True, type=int)
158
- @click.option("--out-dir", default="data_results", show_default=True, type=click.Path(path_type=Path))
159
  @click.option("--summary", is_flag=True, default=False)
160
  @click.option("--select-domain-n", type=int, default=None)
161
  @click.option("--select-label-n", type=int, default=None)
@@ -167,7 +168,7 @@ def cli(
167
  images: tuple[Path, ...],
168
  domain_top_n: int,
169
  top_k: int,
170
- out_dir: Path,
171
  summary: bool,
172
  select_domain_n: int | None,
173
  select_label_n: int | None,
 
19
  images: list[Path]
20
  domain_top_n: int
21
  top_k: int
22
+ out_dir: Path | None
23
  summary: bool
24
  select_domain_n: int | None
25
  select_label_n: int | None
 
140
  "elapsed_labels_ms",
141
  ]
142
 
143
+ out_dir = common.resolve_out_dir(cfg.api, cfg.out_dir)
144
+ out_path = out_dir / f"eval_matrix_{common.timestamp()}.csv"
145
  common.write_csv(out_path, rows, fieldnames)
146
 
147
  if cfg.summary:
148
  summary_rows = summarize_by_label_set(rows)
149
+ summary_path = out_dir / f"eval_matrix_summary_{common.timestamp()}.csv"
150
  common.write_csv(summary_path, summary_rows, ["label_set", "count", "avg_elapsed_ms", "p50_elapsed_ms", "p95_elapsed_ms"])
151
 
152
 
 
156
  @click.option("--images", multiple=True, required=True, type=click.Path(path_type=Path))
157
  @click.option("--domain-top-n", default=2, show_default=True, type=int)
158
  @click.option("--top-k", default=5, show_default=True, type=int)
159
+ @click.option("--out-dir", type=click.Path(path_type=Path))
160
  @click.option("--summary", is_flag=True, default=False)
161
  @click.option("--select-domain-n", type=int, default=None)
162
  @click.option("--select-label-n", type=int, default=None)
 
168
  images: tuple[Path, ...],
169
  domain_top_n: int,
170
  top_k: int,
171
+ out_dir: Path | None,
172
  summary: bool,
173
  select_domain_n: int | None,
174
  select_label_n: int | None,
tests/__pycache__/conftest.cpython-312-pytest-8.3.2.pyc CHANGED
Binary files a/tests/__pycache__/conftest.cpython-312-pytest-8.3.2.pyc and b/tests/__pycache__/conftest.cpython-312-pytest-8.3.2.pyc differ
 
tests/__pycache__/fakes.cpython-312.pyc CHANGED
Binary files a/tests/__pycache__/fakes.cpython-312.pyc and b/tests/__pycache__/fakes.cpython-312.pyc differ
 
tests/__pycache__/test_integration_real_clip.cpython-312-pytest-8.3.2.pyc CHANGED
Binary files a/tests/__pycache__/test_integration_real_clip.cpython-312-pytest-8.3.2.pyc and b/tests/__pycache__/test_integration_real_clip.cpython-312-pytest-8.3.2.pyc differ
 
tests/conftest.py CHANGED
@@ -6,7 +6,7 @@ import pytest
6
  from PIL import Image
7
  from fastapi.testclient import TestClient
8
 
9
- from app.main import build_app
10
  from tests.fakes import FakeClipStore, FakeTwoStageClassifier
11
 
12
 
 
6
  from PIL import Image
7
  from fastapi.testclient import TestClient
8
 
9
+ from api.main import build_app
10
  from tests.fakes import FakeClipStore, FakeTwoStageClassifier
11
 
12
 
tests/fakes.py CHANGED
@@ -2,10 +2,10 @@ from __future__ import annotations
2
 
3
  from dataclasses import dataclass
4
 
5
- from app.banks import EmbeddingBank, LabelSetBank
6
- from app.label_hash import stable_hash
7
- from app.results import ClassificationResult, StageTimings
8
- from app.schemas import LabelSet
9
 
10
 
11
  class FakeClipStore:
 
2
 
3
  from dataclasses import dataclass
4
 
5
+ from api.banks import EmbeddingBank, LabelSetBank
6
+ from api.label_hash import stable_hash
7
+ from api.results import ClassificationResult, StageTimings
8
+ from api.schemas import LabelSet
9
 
10
 
11
  class FakeClipStore:
tests/test_integration_real_clip.py CHANGED
@@ -6,9 +6,9 @@ import pytest
6
  from PIL import Image
7
  from fastapi.testclient import TestClient
8
 
9
- from app.main import build_app
10
- from app.clip_store import ClipStore
11
- from app.clip_service import TwoStageClassifier
12
 
13
 
14
  @pytest.mark.integration
 
6
  from PIL import Image
7
  from fastapi.testclient import TestClient
8
 
9
+ from api.main import build_app
10
+ from api.clip_store import ClipStore
11
+ from api.clip_service import TwoStageClassifier
12
 
13
 
14
  @pytest.mark.integration
uv.lock CHANGED
@@ -366,6 +366,15 @@ wheels = [
366
  { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
367
  ]
368
 
 
 
 
 
 
 
 
 
 
369
  [[package]]
370
  name = "markupsafe"
371
  version = "3.0.3"
@@ -621,6 +630,7 @@ version = "0.1.0"
621
  source = { editable = "." }
622
  dependencies = [
623
  { name = "fastapi" },
 
624
  { name = "pillow" },
625
  { name = "pydantic" },
626
  { name = "torch" },
@@ -644,6 +654,7 @@ requires-dist = [
644
  { name = "datasets", marker = "extra == 'dev'", specifier = "==2.21.0" },
645
  { name = "fastapi", specifier = "==0.115.6" },
646
  { name = "httpx", marker = "extra == 'dev'", specifier = "==0.27.2" },
 
647
  { name = "pillow", specifier = "==10.4.0" },
648
  { name = "pydantic", specifier = "==2.8.2" },
649
  { name = "pytest", marker = "extra == 'dev'", specifier = "==8.3.2" },
 
366
  { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
367
  ]
368
 
369
+ [[package]]
370
+ name = "markdown"
371
+ version = "3.7"
372
+ source = { registry = "https://pypi.org/simple" }
373
+ sdist = { url = "https://files.pythonhosted.org/packages/54/28/3af612670f82f4c056911fbbbb42760255801b3068c48de792d354ff4472/markdown-3.7.tar.gz", hash = "sha256:2ae2471477cfd02dbbf038d5d9bc226d40def84b4fe2986e49b59b6b472bbed2", size = 357086, upload-time = "2024-08-16T15:55:17.812Z" }
374
+ wheels = [
375
+ { url = "https://files.pythonhosted.org/packages/3f/08/83871f3c50fc983b88547c196d11cf8c3340e37c32d2e9d6152abe2c61f7/Markdown-3.7-py3-none-any.whl", hash = "sha256:7eb6df5690b81a1d7942992c97fad2938e956e79df20cbc6186e9c3a77b1c803", size = 106349, upload-time = "2024-08-16T15:55:16.176Z" },
376
+ ]
377
+
378
  [[package]]
379
  name = "markupsafe"
380
  version = "3.0.3"
 
630
  source = { editable = "." }
631
  dependencies = [
632
  { name = "fastapi" },
633
+ { name = "markdown" },
634
  { name = "pillow" },
635
  { name = "pydantic" },
636
  { name = "torch" },
 
654
  { name = "datasets", marker = "extra == 'dev'", specifier = "==2.21.0" },
655
  { name = "fastapi", specifier = "==0.115.6" },
656
  { name = "httpx", marker = "extra == 'dev'", specifier = "==0.27.2" },
657
+ { name = "markdown", specifier = "==3.7" },
658
  { name = "pillow", specifier = "==10.4.0" },
659
  { name = "pydantic", specifier = "==2.8.2" },
660
  { name = "pytest", marker = "extra == 'dev'", specifier = "==8.3.2" },