Sbboss commited on
Commit
5d36f24
·
1 Parent(s): 6125d04

Deploy Docker Streamlit app to HF Space

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +28 -0
  2. .env.example +30 -0
  3. .gitignore +1 -0
  4. .streamlit/config.toml +7 -0
  5. Dockerfile +24 -0
  6. README.md +226 -3
  7. app.py +351 -0
  8. photo_editor/__init__.py +14 -0
  9. photo_editor/__pycache__/__init__.cpython-313.pyc +0 -0
  10. photo_editor/config/__init__.py +3 -0
  11. photo_editor/config/__pycache__/__init__.cpython-313.pyc +0 -0
  12. photo_editor/config/__pycache__/settings.cpython-313.pyc +0 -0
  13. photo_editor/config/settings.py +110 -0
  14. photo_editor/dataset/__init__.py +4 -0
  15. photo_editor/dataset/__pycache__/__init__.cpython-313.pyc +0 -0
  16. photo_editor/dataset/__pycache__/paths.cpython-313.pyc +0 -0
  17. photo_editor/dataset/__pycache__/subset.cpython-313.pyc +0 -0
  18. photo_editor/dataset/paths.py +53 -0
  19. photo_editor/dataset/subset.py +19 -0
  20. photo_editor/embeddings/__init__.py +3 -0
  21. photo_editor/embeddings/__pycache__/__init__.cpython-313.pyc +0 -0
  22. photo_editor/embeddings/__pycache__/embedder.cpython-313.pyc +0 -0
  23. photo_editor/embeddings/embedder.py +208 -0
  24. photo_editor/images/__init__.py +13 -0
  25. photo_editor/images/__pycache__/__init__.cpython-313.pyc +0 -0
  26. photo_editor/images/__pycache__/apply_recipe.cpython-313.pyc +0 -0
  27. photo_editor/images/__pycache__/apply_recipe_linear.cpython-313.pyc +0 -0
  28. photo_editor/images/__pycache__/dng_to_rgb.cpython-313.pyc +0 -0
  29. photo_editor/images/__pycache__/estimate_current_recipe.cpython-313.pyc +0 -0
  30. photo_editor/images/__pycache__/image_stats.cpython-313.pyc +0 -0
  31. photo_editor/images/apply_recipe.py +169 -0
  32. photo_editor/images/apply_recipe_linear.py +101 -0
  33. photo_editor/images/dng_to_rgb.py +103 -0
  34. photo_editor/images/estimate_current_recipe.py +124 -0
  35. photo_editor/images/image_stats.py +92 -0
  36. photo_editor/lrcat/__init__.py +13 -0
  37. photo_editor/lrcat/__pycache__/__init__.cpython-313.pyc +0 -0
  38. photo_editor/lrcat/__pycache__/extract.cpython-313.pyc +0 -0
  39. photo_editor/lrcat/__pycache__/schema.cpython-313.pyc +0 -0
  40. photo_editor/lrcat/extract.py +190 -0
  41. photo_editor/lrcat/schema.py +97 -0
  42. photo_editor/pipeline/__init__.py +16 -0
  43. photo_editor/pipeline/__pycache__/__init__.cpython-313.pyc +0 -0
  44. photo_editor/pipeline/__pycache__/run.cpython-313.pyc +0 -0
  45. photo_editor/pipeline/run.py +446 -0
  46. photo_editor/vector_store/__init__.py +8 -0
  47. photo_editor/vector_store/__pycache__/__init__.cpython-313.pyc +0 -0
  48. photo_editor/vector_store/__pycache__/azure_search.cpython-313.pyc +0 -0
  49. photo_editor/vector_store/azure_search.py +217 -0
  50. requirements.txt +26 -0
.dockerignore ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .git
2
+ .gitignore
3
+ .venv
4
+ venv
5
+ __pycache__
6
+ *.pyc
7
+ *.pyo
8
+ *.pyd
9
+
10
+ # Secrets / local config
11
+ .env
12
+ .env.local
13
+
14
+ # Large or non-runtime artifacts
15
+ fivek_dataset
16
+ renders
17
+ result.jpg
18
+ image_edits.html
19
+ lrcat_inspect.html
20
+ streamlit_output.jpg
21
+ streamlit_output.png
22
+ _streamlit_input.jpg
23
+ _streamlit_input.png
24
+ _streamlit_input.dng
25
+ _streamlit_input.heic
26
+ _streamlit_input.heif
27
+ llm_recipe_*.json
28
+ my_recipe.json
.env.example ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # FiveK dataset subset size (number of images to index)
2
+ FIVEK_SUBSET_SIZE=500
3
+
4
+ # Paths (optional; defaults relative to project root)
5
+ # FIVEK_DATASET_DIR=./fivek_dataset
6
+ # FIVEK_LRCAT_PATH=./fivek_dataset/raw_photos/fivek.lrcat
7
+ # FIVEK_RAW_PHOTOS_DIR=./fivek_dataset/raw_photos
8
+
9
+ # Azure AI Search (for vector index)
10
+ # AZURE_SEARCH_ENDPOINT=https://<your-service>.search.windows.net
11
+ # AZURE_SEARCH_KEY=<your-admin-key>
12
+ # AZURE_SEARCH_INDEX_NAME=fivek-vectors
13
+
14
+ # Embedding: local CLIP (uses Mac MPS / CUDA when available) or Azure Vision
15
+ # EMBEDDING_MODEL=openai/clip-vit-base-patch32
16
+ # EMBEDDING_DIM=512
17
+
18
+ # Optional: Azure AI Vision multimodal embeddings (skips local CLIP; no GPU needed)
19
+ # AZURE_VISION_ENDPOINT=https://<your-vision>.cognitiveservices.azure.com
20
+ # AZURE_VISION_KEY=<your-key>
21
+ # AZURE_VISION_MODEL_VERSION=2023-04-15
22
+
23
+ # Azure OpenAI (LLM for pipeline: analyze image + expert recipe → suggested edits)
24
+ # AZURE_OPENAI_ENDPOINT=https://<your-resource>.cognitiveservices.azure.com/
25
+ # AZURE_OPENAI_KEY=<your-key>
26
+ # AZURE_OPENAI_DEPLOYMENT=gpt-4o
27
+ # AZURE_OPENAI_API_VERSION=2024-12-01-preview
28
+
29
+ # Optional: external editing API (if set, run_pipeline.py --api calls it; else edits applied locally)
30
+ # EDITING_API_URL=https://photo-editing-xxx.azurewebsites.net/api/apply-edits
.gitignore ADDED
@@ -0,0 +1 @@
 
 
1
+ test_input.jpg
.streamlit/config.toml ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ [theme]
2
+ base = "dark"
3
+ primaryColor = "#60A5FA"
4
+ backgroundColor = "#0F172A"
5
+ secondaryBackgroundColor = "#111827"
6
+ textColor = "#F1F5F9"
7
+ font = "sans serif"
Dockerfile ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ ENV PYTHONDONTWRITEBYTECODE=1 \
4
+ PYTHONUNBUFFERED=1 \
5
+ PIP_NO_CACHE_DIR=1 \
6
+ PORT=7860
7
+
8
+ WORKDIR /app
9
+
10
+ # Runtime libraries commonly needed by image stacks (rawpy/Pillow/scikit-image).
11
+ RUN apt-get update && apt-get install -y --no-install-recommends \
12
+ libgl1 \
13
+ libglib2.0-0 \
14
+ libgomp1 \
15
+ && rm -rf /var/lib/apt/lists/*
16
+
17
+ COPY requirements.txt .
18
+ RUN pip install --upgrade pip && pip install -r requirements.txt
19
+
20
+ COPY . .
21
+
22
+ EXPOSE 7860
23
+
24
+ CMD ["streamlit", "run", "app.py", "--server.address=0.0.0.0", "--server.port=7860"]
README.md CHANGED
@@ -1,10 +1,233 @@
1
  ---
2
  title: PixelPilotAI
3
- emoji: 📈
4
  colorFrom: purple
5
- colorTo: purple
6
  sdk: docker
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: PixelPilotAI
3
+ emoji: 📷
4
  colorFrom: purple
5
+ colorTo: blue
6
  sdk: docker
7
  pinned: false
8
  ---
9
 
10
+ # Photo Editing Recommendation Agent
11
+
12
+ Recommend global photo edits by retrieving similar expert-edited examples (MIT–Adobe FiveK), aggregating Expert A recipes, and applying them deterministically. This repo is structured so **dataset → embeddings → vector DB** and the **inference API + LLM** can be developed in parallel and merged cleanly.
13
+
14
+ ## Project layout (merge-ready)
15
+
16
+ ```
17
+ PhotoEditor/
18
+ ├── .env # Copy from .env.example; set FIVEK_SUBSET_SIZE, Azure Search, etc.
19
+ ├── .env.example
20
+ ├── requirements.txt
21
+ ├── photo_editor/ # Core package (shared by pipeline and future API)
22
+ │ ├── config/ # Settings from env (paths, Azure, subset size)
23
+ │ ├── dataset/ # FiveK paths, subset selection (filesAdobe.txt)
24
+ │ ├── lrcat/ # Lightroom catalog: Expert A recipe extraction
25
+ │ ├── images/ # DNG → RGB (rawpy, neutral development)
26
+ │ ├── embeddings/ # CLIP image embeddings (index + query)
27
+ │ └── vector_store/ # Azure AI Search index (upload + search)
28
+ ├── scripts/
29
+ │ └── build_vector_index.py # Build vector index: subset → embed → push to Azure
30
+ ├── fivek_dataset/ # MIT–Adobe FiveK (file lists, raw_photos/, fivek.lrcat)
31
+ ├── LLM.py # Existing Azure GPT-4o explanation layer (to be wired to RAG)
32
+ └── api/ # (Future) FastAPI: /analyze-image, /apply-edits, /edit-and-explain
33
+ ```
34
+
35
+ - **Inference merge**: The API will use `photo_editor.vector_store.AzureSearchVectorStore` for retrieval, `photo_editor.embeddings` for query embedding, and `LLM.py` (or a moved `photo_editor.llm`) for explanations. Apply-edits will use a separate editing engine (OpenCV/Pillow) consuming `EditRecipe` from `photo_editor.lrcat.schema`.
36
+
37
+ ## Dataset → Vector DB (this slice)
38
+
39
+ 1. **Subset**: First `FIVEK_SUBSET_SIZE` images from `fivek_dataset/filesAdobe.txt` (default 500; set in `.env`).
40
+ 2. **Edits**: Expert A only; recipes read from `fivek.lrcat` (virtual copy "Copy 1").
41
+ 3. **Embeddings**: Original DNG → neutral development → RGB → CLIP (`openai/clip-vit-base-patch32`).
42
+ 4. **Vector DB**: Azure AI Search index (created if missing); each document = `id`, `image_id`, `embedding`, `recipe` (JSON).
43
+
44
+ ### Setup
45
+
46
+ ```bash
47
+ cp .env.example .env
48
+ # Edit .env: FIVEK_SUBSET_SIZE (e.g. 500), AZURE_SEARCH_*, optional paths
49
+ pip install -r requirements.txt
50
+ ```
51
+
52
+ ### Build the index
53
+
54
+ From the project root:
55
+
56
+ ```bash
57
+ PYTHONPATH=. python scripts/build_vector_index.py
58
+ ```
59
+
60
+ - Requires the FiveK `raw_photos` folder (DNGs + `fivek.lrcat`) under `fivek_dataset/`.
61
+ - If Azure Search is not configured in `.env`, the script still runs and skips upload (prints a reminder).
62
+
63
+ ## How to run things
64
+
65
+ All commands below assume you are in the **project root** (`PhotoEditor/`) and have:
66
+
67
+ - created and edited `.env` (see config table below), and
68
+ - installed dependencies:
69
+
70
+ ```bash
71
+ pip install -r requirements.txt
72
+ ```
73
+
74
+ ## Deploy (Streamlit Cloud + Hugging Face Spaces)
75
+
76
+ For cloud deploy, keep the repo minimal and include only runtime files:
77
+
78
+ - `app.py`
79
+ - `photo_editor/`
80
+ - `requirements.txt`
81
+ - `.streamlit/config.toml`
82
+ - `.env.example` (template only, no secrets)
83
+
84
+ Do not commit local artifacts or large datasets (`fivek_dataset/`, `renders/`, generated images/html/json, `.env`).
85
+
86
+ ### Streamlit Community Cloud
87
+
88
+ 1. Push this repo to GitHub.
89
+ 2. In Streamlit Cloud, create a new app from the repo.
90
+ 3. Set the app file path to `app.py`.
91
+ 4. Add required secrets in the app settings (same keys as in `.env.example`, e.g. `AZURE_SEARCH_*`, `AZURE_OPENAI_*`).
92
+ 5. Deploy.
93
+
94
+ ### Hugging Face Spaces (Streamlit SDK)
95
+
96
+ 1. Create a new Space and choose **Streamlit** SDK.
97
+ 2. Point it to this repository (or push these files to the Space repo).
98
+ 3. Ensure `app.py` is at repo root and `requirements.txt` is present.
99
+ 4. Add secrets in Space Settings (same variables as `.env.example`).
100
+ 5. Launch the Space.
101
+
102
+ Optional automation: sync supported secrets from local `.env` directly to your Space:
103
+
104
+ ```bash
105
+ pip install huggingface_hub
106
+ HF_TOKEN=hf_xxx python scripts/sync_hf_secrets.py --space-id <username/space-name>
107
+ ```
108
+
109
+ ### Hugging Face Spaces (Docker SDK)
110
+
111
+ This repo now includes a production-ready `Dockerfile` that serves the app on port `7860`.
112
+
113
+ 1. Create a new Space and choose **Docker** SDK.
114
+ 2. Push this repository to that Space.
115
+ 3. In Space Settings, add secrets (or sync them later with `scripts/sync_hf_secrets.py`).
116
+ 4. Build and launch the Space.
117
+
118
+ Local Docker test:
119
+
120
+ ```bash
121
+ docker build -t lumigrade-ai .
122
+ docker run --rm -p 7860:7860 --env-file .env lumigrade-ai
123
+ ```
124
+
125
+ ### 1. Run the Streamlit UI (full app)
126
+
127
+ Interactive app to upload an image (JPEG/PNG) or point to a DNG on disk, then run the full pipeline and see **original vs edited** plus the suggested edit parameters.
128
+
129
+ ```bash
130
+ streamlit run app.py
131
+ ```
132
+
133
+ This will:
134
+ - Check Azure Search + Azure OpenAI config from `.env`.
135
+ - For each run: retrieve similar experts → call LLM for summary + suggested edits → apply edits (locally or via external API) → show before/after.
136
+
137
+ ### 2. Run the full pipeline from the terminal
138
+
139
+ Run the same pipeline as the UI, but from the CLI for a single image:
140
+
141
+ ```bash
142
+ python scripts/run_pipeline.py <image_path> [--out output.jpg] [--api] [-v]
143
+ ```
144
+
145
+ Examples:
146
+
147
+ ```bash
148
+ # Run pipeline locally, save to result.jpg, print summary + suggested edits
149
+ python scripts/run_pipeline.py photo.jpg --out result.jpg -v
150
+
151
+ # Run pipeline but use an external editing API (requires EDITING_API_URL in .env)
152
+ python scripts/run_pipeline.py photo.jpg --out result.jpg --api -v
153
+ ```
154
+
155
+ What `-v` prints:
156
+ - 📋 **Summary** of what the LLM thinks should be done.
157
+ - 📐 **Suggested edits**: the numeric recipe (exposure, contrast, temp, etc.) coming from Azure OpenAI for that image.
158
+ - 📎 **Expert used**: which FiveK expert image/recipe was used as reference.
159
+
160
+ ### 3. Just retrieve similar experts (no LLM / no edits)
161
+
162
+ If you only want to see which FiveK images are closest to a given photo and inspect their stored recipes:
163
+
164
+ ```bash
165
+ python scripts/query_similar.py <image_path> [--top-k 50] [--top-n 5]
166
+ ```
167
+
168
+ Examples:
169
+
170
+ ```bash
171
+ # Show the best 5 expert matches (default top-k=50 search space)
172
+ python scripts/query_similar.py photo.jpg --top-n 5
173
+
174
+ # Show only the single best match
175
+ python scripts/query_similar.py photo.jpg --top-n 1
176
+ ```
177
+
178
+ Output:
179
+ - Ranks (`1.`, `2.`, …), image_ids, rerank scores.
180
+ - The stored **Expert A recipe** JSON for each match.
181
+
182
+ ### 4. Get the exact Expert A recipe for a FiveK image
183
+
184
+ Given a FiveK `image_id` (with or without extension), extract the Expert A recipe directly from the Lightroom catalog:
185
+
186
+ ```bash
187
+ python scripts/get_recipe_for_image.py <image_name> [-o recipe.json]
188
+ ```
189
+
190
+ Examples:
191
+
192
+ ```bash
193
+ # Print the recipe as JSON
194
+ python scripts/get_recipe_for_image.py a0001-jmac_DSC1459
195
+
196
+ # Save the recipe to a file
197
+ python scripts/get_recipe_for_image.py a0001-jmac_DSC1459 -o my_recipe.json
198
+ ```
199
+
200
+ ### 5. Apply a custom (LLM) recipe to a FiveK image
201
+
202
+ If you already have a JSON recipe (for example, something you crafted or got from the LLM) and want to apply it to a FiveK RAW image using the same rendering pipeline:
203
+
204
+ ```bash
205
+ python scripts/apply_llm_recipe.py <image_id> <recipe.json> [--out path.jpg]
206
+ ```
207
+
208
+ Example:
209
+
210
+ ```bash
211
+ python scripts/apply_llm_recipe.py a0059-JI2E5556 llm_recipe_a0059.json --out renders/a0059-JI2E5556_LLM.jpg
212
+ ```
213
+
214
+ This will:
215
+ - Load the DNG for `<image_id>`.
216
+ - Use `dng_to_rgb_normalized` to bake in exposure/brightness from the recipe.
217
+ - Apply the rest of the recipe (contrast, temperature, etc.) on top of the original Expert A baseline.
218
+ - Save the rendered JPEG.
219
+
220
+ ## Config (.env)
221
+
222
+ | Variable | Description |
223
+ |--------|-------------|
224
+ | `FIVEK_SUBSET_SIZE` | Number of images to index (default 500). |
225
+ | `FIVEK_LRCAT_PATH` | Path to `fivek.lrcat` (default: `fivek_dataset/raw_photos/fivek.lrcat`). |
226
+ | `FIVEK_RAW_PHOTOS_DIR` | Root of range folders (e.g. `HQa1to700`, …). |
227
+ | `AZURE_SEARCH_ENDPOINT` | Azure AI Search endpoint URL. |
228
+ | `AZURE_SEARCH_KEY` | Azure AI Search admin key. |
229
+ | `AZURE_SEARCH_INDEX_NAME` | Index name (default `fivek-vectors`). |
230
+
231
+ ## License / data
232
+
233
+ See `fivek_dataset/LICENSE.txt` and related notices for the MIT–Adobe FiveK dataset.
app.py ADDED
@@ -0,0 +1,351 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Streamlit UI for the photo editing pipeline.
4
+ Upload an image (or use a file path for DNG), run retrieve → LLM → apply, view result.
5
+
6
+ Run from project root:
7
+ streamlit run app.py
8
+ """
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ _PROJECT_ROOT = Path(__file__).resolve().parent
13
+ if str(_PROJECT_ROOT) not in sys.path:
14
+ sys.path.insert(0, str(_PROJECT_ROOT))
15
+
16
+ import numpy as np
17
+ import streamlit as st
18
+
19
+ from photo_editor.config import get_settings
20
+ from photo_editor.images import dng_to_rgb
21
+ from photo_editor.images.estimate_current_recipe import estimate_current_parameters
22
+ from photo_editor.pipeline.run import run_pipeline
23
+
24
+ # Fixed paths so "last result" matches across reruns (upload overwrites same file)
25
+ _STREAMLIT_INPUT_JPG_PATH = _PROJECT_ROOT / "_streamlit_input.jpg"
26
+ _STREAMLIT_INPUT_PNG_PATH = _PROJECT_ROOT / "_streamlit_input.png"
27
+ _STREAMLIT_INPUT_DNG_PATH = _PROJECT_ROOT / "_streamlit_input.dng"
28
+ _STREAMLIT_INPUT_HEIC_PATH = _PROJECT_ROOT / "_streamlit_input.heic"
29
+ _STREAMLIT_INPUT_HEIF_PATH = _PROJECT_ROOT / "_streamlit_input.heif"
30
+ # Use PNG output for UI preview to avoid JPEG quality loss.
31
+ _STREAMLIT_OUTPUT_PATH = _PROJECT_ROOT / "streamlit_output.png"
32
+ # Reversible toggle: set to False to restore top-1-only expert context.
33
+ _USE_MULTI_EXPERT_CONTEXT = True
34
+ _MULTI_EXPERT_CONTEXT_TOP_N = 1
35
+ _USE_BRIGHTNESS_GUARDRAIL = True
36
+
37
+
38
+ def _load_original_for_display(image_path: Path):
39
+ """Load image for display. Use rawpy for DNG so 'Original' matches pipeline quality."""
40
+ path = Path(image_path)
41
+ if path.suffix.lower() == ".dng":
42
+ rgb = dng_to_rgb(path, output_size=None) # full resolution, same develop as pipeline
43
+ rgb_u8 = (np.clip(rgb, 0, 1) * 255).astype(np.uint8)
44
+ return rgb_u8
45
+ # JPEG/PNG/HEIC/HEIF: Streamlit/Pillow can show from path (with plugin support).
46
+ return str(path)
47
+
48
+
49
+
50
+ def main() -> None:
51
+ st.set_page_config(page_title="LumiGrade AI", page_icon="📷", layout="wide")
52
+ st.markdown(
53
+ """
54
+ <style>
55
+ /* Keep custom styling minimal; rely on Streamlit theme config for core colors. */
56
+ [data-testid="stSidebar"] {
57
+ background: #0b1220 !important;
58
+ border-right: 1px solid rgba(148, 163, 184, 0.28);
59
+ }
60
+ [data-testid="stSidebar"] > div:first-child {
61
+ background: #0b1220 !important;
62
+ }
63
+ [data-testid="stSidebar"] [data-testid="stVerticalBlock"] > div {
64
+ box-shadow: inset -1px 0 0 rgba(148, 163, 184, 0.12);
65
+ }
66
+ .muted { color: #a8b3c7; font-size: 0.95rem; }
67
+ .section-title { font-size: 1.15rem; font-weight: 700; margin-bottom: 0.35rem; }
68
+ .action-card {
69
+ border: 1px solid rgba(148, 163, 184, 0.22);
70
+ border-radius: 10px;
71
+ padding: 0.7rem 0.85rem;
72
+ margin-bottom: 0.45rem;
73
+ background: rgba(30, 41, 59, 0.28);
74
+ }
75
+ .json-box {
76
+ border: 1px solid rgba(148, 163, 184, 0.2);
77
+ border-radius: 10px;
78
+ padding: 0.5rem 0.65rem;
79
+ background: rgba(30, 41, 59, 0.2);
80
+ }
81
+ .loading-wrap {
82
+ border: 1px solid rgba(148, 163, 184, 0.28);
83
+ border-radius: 12px;
84
+ padding: 0.9rem 1rem;
85
+ background: rgba(30, 41, 59, 0.32);
86
+ margin: 0.4rem 0 0.8rem 0;
87
+ }
88
+ .loading-head {
89
+ display: flex;
90
+ align-items: center;
91
+ gap: 0.6rem;
92
+ margin-bottom: 0.55rem;
93
+ font-weight: 600;
94
+ }
95
+ .loader-spinner {
96
+ width: 16px;
97
+ height: 16px;
98
+ border: 2px solid rgba(148, 163, 184, 0.25);
99
+ border-top-color: #60A5FA;
100
+ border-radius: 50%;
101
+ animation: spin 0.8s linear infinite;
102
+ }
103
+ @keyframes spin {
104
+ to { transform: rotate(360deg); }
105
+ }
106
+ .step-line {
107
+ padding: 0.22rem 0;
108
+ font-size: 0.94rem;
109
+ }
110
+
111
+ /* Move main title area slightly up */
112
+ [data-testid="stAppViewContainer"] .main .block-container {
113
+ padding-top: 00.6rem !important;
114
+ }
115
+ h1 {
116
+ margin-top: -0.25rem !important;
117
+ }
118
+
119
+ /* Push sidebar inputs a bit lower under the title */
120
+ [data-testid="stSidebar"] [data-testid="stSidebarContent"] {
121
+ padding-top: 0 !important;
122
+ }
123
+ </style>
124
+ """,
125
+ unsafe_allow_html=True,
126
+ )
127
+
128
+ st.title("📷 LumiGrade AI")
129
+ st.caption("Upload an image to get expert-informed edit recommendations and an instant enhanced result.")
130
+
131
+ # Config check
132
+ s = get_settings()
133
+ if not s.azure_search_configured():
134
+ st.error("Azure AI Search not configured. Set AZURE_SEARCH_ENDPOINT and AZURE_SEARCH_KEY in .env")
135
+ st.stop()
136
+ if not s.azure_openai_configured():
137
+ st.error("Azure OpenAI not configured. Set AZURE_OPENAI_* in .env")
138
+ st.stop()
139
+
140
+ # External editing API toggle has been removed from the UI for simplicity.
141
+ # If you want to use the external API again, you can reintroduce a sidebar
142
+ # control and wire it to this flag.
143
+ use_editing_api = False
144
+
145
+ image_path = None
146
+
147
+ with st.sidebar:
148
+ # Reliable spacing so only the Pipeline Inputs card moves down.
149
+ st.markdown('<div style="height: 8.1rem;"></div>', unsafe_allow_html=True)
150
+ with st.container(border=True):
151
+ st.markdown('<div class="section-title">Pipeline Inputs</div>', unsafe_allow_html=True)
152
+ uploaded = st.file_uploader(
153
+ "Upload JPEG, PNG, DNG, HEIC, or HEIF",
154
+ type=["jpg", "jpeg", "png", "dng", "heic", "heif"],
155
+ help="You can upload DNG/HEIC directly, or use a local path below.",
156
+ )
157
+ if uploaded is not None:
158
+ suffix = Path(uploaded.name).suffix.lower()
159
+ if suffix == ".dng":
160
+ target = _STREAMLIT_INPUT_DNG_PATH
161
+ elif suffix == ".heic":
162
+ target = _STREAMLIT_INPUT_HEIC_PATH
163
+ elif suffix == ".heif":
164
+ target = _STREAMLIT_INPUT_HEIF_PATH
165
+ elif suffix == ".png":
166
+ target = _STREAMLIT_INPUT_PNG_PATH
167
+ else:
168
+ target = _STREAMLIT_INPUT_JPG_PATH
169
+ target.write_bytes(uploaded.getvalue())
170
+ image_path = target
171
+ else:
172
+ path_str = st.text_input(
173
+ "Or enter path to image (e.g. for DNG)",
174
+ placeholder="/path/to/image.dng or image.jpg",
175
+ )
176
+ path_str = (path_str or "").strip()
177
+ if path_str:
178
+ p = Path(path_str)
179
+ if p.exists():
180
+ image_path = p
181
+ else:
182
+ st.warning("File not found. Use a full path that exists, or upload a file.")
183
+
184
+ run_clicked = st.button("▶ Run Pipeline", type="primary", use_container_width=True)
185
+ status = st.empty()
186
+ if image_path is None:
187
+ status.info("Provide an image to run.")
188
+
189
+ if run_clicked and image_path is not None:
190
+ loading_box = st.empty()
191
+
192
+ def _render_loading(current_stage: str, state: str = "running") -> None:
193
+ stage_order = ["retrieving", "consulting", "applying"]
194
+ stage_labels = {
195
+ "retrieving": "Analyzing similar expert edits",
196
+ "consulting": "Generating personalized recommendations",
197
+ "applying": "Rendering your enhanced preview",
198
+ }
199
+ current_idx = stage_order.index(current_stage) if current_stage in stage_order else 0
200
+
201
+ if state == "done":
202
+ title = "Done"
203
+ spinner_html = ""
204
+ elif state == "failed":
205
+ title = "Pipeline failed"
206
+ spinner_html = ""
207
+ else:
208
+ title = "Running pipeline"
209
+ spinner_html = '<span class="loader-spinner"></span>'
210
+
211
+ lines = []
212
+ for i, key in enumerate(stage_order):
213
+ if state == "done":
214
+ icon = "✅"
215
+ elif state == "failed" and i > current_idx:
216
+ icon = "⏳"
217
+ else:
218
+ icon = "✅" if i < current_idx else ("🔄" if i == current_idx and state == "running" else "⏳")
219
+ lines.append(f'<div class="step-line">{icon} {stage_labels[key]}</div>')
220
+
221
+ loading_box.markdown(
222
+ f"""
223
+ <div class="loading-wrap">
224
+ <div class="loading-head">{spinner_html}<span>{title}</span></div>
225
+ {''.join(lines)}
226
+ </div>
227
+ """,
228
+ unsafe_allow_html=True,
229
+ )
230
+
231
+ _render_loading("retrieving", "running")
232
+ try:
233
+ current_params = estimate_current_parameters(image_path)
234
+ result = run_pipeline(
235
+ image_path,
236
+ _STREAMLIT_OUTPUT_PATH,
237
+ top_k=50,
238
+ top_n=1,
239
+ use_editing_api=use_editing_api,
240
+ use_multi_expert_context=_USE_MULTI_EXPERT_CONTEXT,
241
+ context_top_n=_MULTI_EXPERT_CONTEXT_TOP_N,
242
+ use_brightness_guardrail=_USE_BRIGHTNESS_GUARDRAIL,
243
+ progress_callback=lambda stage: _render_loading(stage, "running"),
244
+ )
245
+ if result.get("success"):
246
+ st.session_state["pipeline_result"] = result
247
+ st.session_state["pipeline_output_path"] = _STREAMLIT_OUTPUT_PATH
248
+ st.session_state["pipeline_input_path"] = str(image_path)
249
+ st.session_state["pipeline_current_params"] = current_params
250
+ status.success("Done!")
251
+ _render_loading("applying", "done")
252
+ else:
253
+ st.session_state.pop("pipeline_result", None)
254
+ st.session_state.pop("pipeline_output_path", None)
255
+ st.session_state.pop("pipeline_input_path", None)
256
+ st.session_state.pop("pipeline_current_params", None)
257
+ status.error("Editing step failed.")
258
+ _render_loading("applying", "failed")
259
+ except Exception as e:
260
+ status.error("Pipeline failed.")
261
+ st.exception(e)
262
+ st.session_state.pop("pipeline_result", None)
263
+ st.session_state.pop("pipeline_output_path", None)
264
+ st.session_state.pop("pipeline_input_path", None)
265
+ st.session_state.pop("pipeline_current_params", None)
266
+ _render_loading("consulting", "failed")
267
+
268
+ with st.container(border=True):
269
+ st.subheader("Results Dashboard")
270
+ st.markdown("### 📊 Pipeline Analysis & Recommendations")
271
+
272
+ # Show result if available
273
+ if (
274
+ image_path is not None
275
+ and st.session_state.get("pipeline_result")
276
+ and st.session_state.get("pipeline_input_path") == str(image_path)
277
+ ):
278
+ result = st.session_state["pipeline_result"]
279
+ out_path = st.session_state["pipeline_output_path"]
280
+
281
+ if out_path.exists():
282
+ summary = result.get("summary", "")
283
+ suggested = result.get("suggested_edits", {})
284
+ expert_id = result.get("expert_image_id", "")
285
+ current_params = st.session_state.get("pipeline_current_params") or {}
286
+
287
+ with st.expander("AI Analysis Summary", expanded=True):
288
+ st.markdown(summary)
289
+
290
+ with st.expander("Parameters: Details", expanded=True):
291
+ st.markdown("#### Parameters: Current vs Suggested vs Delta")
292
+ keys = [
293
+ "exposure",
294
+ "contrast",
295
+ "highlights",
296
+ "shadows",
297
+ "whites",
298
+ "blacks",
299
+ "temperature",
300
+ "tint",
301
+ "vibrance",
302
+ "saturation",
303
+ ]
304
+ rows = []
305
+ for k in keys:
306
+ cur = current_params.get(k, None)
307
+ sug = suggested.get(k, None)
308
+ try:
309
+ cur_f = float(cur) if cur is not None else None
310
+ except Exception:
311
+ cur_f = None
312
+ try:
313
+ sug_f = float(sug) if sug is not None else None
314
+ except Exception:
315
+ sug_f = None
316
+ delta = (sug_f - cur_f) if (sug_f is not None and cur_f is not None) else None
317
+ rows.append(
318
+ {
319
+ "parameter": k,
320
+ "current_estimated": cur_f,
321
+ "suggested": sug_f,
322
+ "delta": delta,
323
+ }
324
+ )
325
+ st.dataframe(rows, use_container_width=True, hide_index=True)
326
+ st.caption('“Current” values are estimated from pixels (not true Lightroom sliders).')
327
+ else:
328
+ st.info("Run the pipeline to populate results.")
329
+ else:
330
+ st.info("Run the pipeline from the left pane to view analysis and recommendations.")
331
+
332
+ # Keep this full-width and at the bottom, per request.
333
+ if (
334
+ image_path is not None
335
+ and st.session_state.get("pipeline_result")
336
+ and st.session_state.get("pipeline_input_path") == str(image_path)
337
+ ):
338
+ result = st.session_state["pipeline_result"]
339
+ out_path = st.session_state["pipeline_output_path"]
340
+ if out_path.exists():
341
+ st.markdown("---")
342
+ st.subheader("Original vs Result")
343
+ col_orig, col_result = st.columns(2)
344
+ with col_orig:
345
+ st.image(_load_original_for_display(image_path), caption="Original", use_container_width=True)
346
+ with col_result:
347
+ st.image(str(out_path), caption="Edited", use_container_width=True)
348
+
349
+
350
+ if __name__ == "__main__":
351
+ main()
photo_editor/__init__.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Photo Editing Recommendation Agent – core package.
3
+
4
+ Subpackages:
5
+ - config: settings from env (paths, Azure, subset size)
6
+ - dataset: FiveK paths and subset selection
7
+ - lrcat: Lightroom catalog extraction (Expert A recipes)
8
+ - images: DNG → RGB development
9
+ - embeddings: image embedding (CLIP)
10
+ - vector_store: Azure AI Search index (for RAG retrieval)
11
+ - pipeline: end-to-end run (retrieve → LLM → apply)
12
+ """
13
+
14
+ __version__ = "0.1.0"
photo_editor/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (658 Bytes). View file
 
photo_editor/config/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from .settings import get_settings, settings
2
+
3
+ __all__ = ["get_settings", "settings"]
photo_editor/config/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (298 Bytes). View file
 
photo_editor/config/__pycache__/settings.cpython-313.pyc ADDED
Binary file (5.66 kB). View file
 
photo_editor/config/settings.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Application settings loaded from environment.
3
+ Shared by: dataset pipeline, vector index build, and future API/LLM.
4
+ """
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ from dotenv import load_dotenv
10
+
11
+ # Load .env from project root (parent of photo_editor)
12
+ _PROJECT_ROOT = Path(__file__).resolve().parents[2]
13
+ load_dotenv(_PROJECT_ROOT / ".env")
14
+
15
+
16
+ def _str(name: str, default: Optional[str] = None) -> str:
17
+ v = os.getenv(name, default)
18
+ return v.strip() if v else (default or "")
19
+
20
+
21
+ def _int(name: str, default: int = 0) -> int:
22
+ v = os.getenv(name)
23
+ if v is None or v.strip() == "":
24
+ return default
25
+ try:
26
+ return int(v.strip())
27
+ except ValueError:
28
+ return default
29
+
30
+
31
+ def _path(name: str, default: Optional[Path] = None) -> Path:
32
+ v = _str(name)
33
+ if not v:
34
+ return default or _PROJECT_ROOT
35
+ p = Path(v)
36
+ if not p.is_absolute():
37
+ p = _PROJECT_ROOT / p
38
+ return p
39
+
40
+
41
+ class Settings:
42
+ """Central settings; use get_settings() for a singleton."""
43
+
44
+ def __init__(self) -> None:
45
+ self.project_root = _PROJECT_ROOT
46
+
47
+ # FiveK
48
+ self.fivek_subset_size = _int("FIVEK_SUBSET_SIZE", 500)
49
+ self.fivek_dataset_dir = _path("FIVEK_DATASET_DIR", _PROJECT_ROOT / "fivek_dataset")
50
+ self.fivek_lrcat_path = _path(
51
+ "FIVEK_LRCAT_PATH",
52
+ _PROJECT_ROOT / "fivek_dataset" / "raw_photos" / "fivek.lrcat",
53
+ )
54
+ self.fivek_raw_photos_dir = _path(
55
+ "FIVEK_RAW_PHOTOS_DIR",
56
+ _PROJECT_ROOT / "fivek_dataset" / "raw_photos",
57
+ )
58
+ self.fivek_file_list = self.fivek_dataset_dir / "filesAdobe.txt"
59
+
60
+ # Azure AI Search
61
+ self.azure_search_endpoint = _str("AZURE_SEARCH_ENDPOINT")
62
+ self.azure_search_key = _str("AZURE_SEARCH_KEY")
63
+ self.azure_search_index_name = _str("AZURE_SEARCH_INDEX_NAME", "fivek-vectors")
64
+
65
+ # Embedding (model name and dimension for index schema)
66
+ self.embedding_model = _str(
67
+ "EMBEDDING_MODEL",
68
+ "openai/clip-vit-base-patch32",
69
+ )
70
+ self.embedding_dim = _int("EMBEDDING_DIM", 512)
71
+
72
+ # Optional: Azure AI Vision multimodal embeddings (use instead of local CLIP)
73
+ self.azure_vision_endpoint = _str("AZURE_VISION_ENDPOINT")
74
+ self.azure_vision_key = _str("AZURE_VISION_KEY")
75
+ self.azure_vision_model_version = _str("AZURE_VISION_MODEL_VERSION", "2023-04-15")
76
+
77
+ # Azure OpenAI (LLM for pipeline)
78
+ self.azure_openai_endpoint = _str("AZURE_OPENAI_ENDPOINT")
79
+ self.azure_openai_key = _str("AZURE_OPENAI_KEY")
80
+ self.azure_openai_deployment = _str("AZURE_OPENAI_DEPLOYMENT", "gpt-4o")
81
+ self.azure_openai_api_version = _str("AZURE_OPENAI_API_VERSION", "2024-12-01-preview")
82
+
83
+ # Optional: external editing API (if set, pipeline calls it; else uses local apply)
84
+ self.editing_api_url = _str("EDITING_API_URL")
85
+
86
+ def azure_search_configured(self) -> bool:
87
+ return bool(self.azure_search_endpoint and self.azure_search_key)
88
+
89
+ def azure_vision_configured(self) -> bool:
90
+ return bool(self.azure_vision_endpoint and self.azure_vision_key)
91
+
92
+ def azure_openai_configured(self) -> bool:
93
+ return bool(self.azure_openai_endpoint and self.azure_openai_key)
94
+
95
+ def editing_api_configured(self) -> bool:
96
+ return bool(self.editing_api_url)
97
+
98
+
99
+ _settings: Optional[Settings] = None
100
+
101
+
102
+ def get_settings() -> Settings:
103
+ global _settings
104
+ if _settings is None:
105
+ _settings = Settings()
106
+ return _settings
107
+
108
+
109
+ # Convenience singleton
110
+ settings = get_settings()
photo_editor/dataset/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ from .paths import image_id_to_dng_path, list_all_image_ids
2
+ from .subset import get_subset_image_ids
3
+
4
+ __all__ = ["image_id_to_dng_path", "list_all_image_ids", "get_subset_image_ids"]
photo_editor/dataset/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (378 Bytes). View file
 
photo_editor/dataset/__pycache__/paths.cpython-313.pyc ADDED
Binary file (2.47 kB). View file
 
photo_editor/dataset/__pycache__/subset.cpython-313.pyc ADDED
Binary file (1.05 kB). View file
 
photo_editor/dataset/paths.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FiveK dataset path resolution.
3
+ Maps image ID (e.g. a2621-_DSC5468) to DNG path using range folders.
4
+ """
5
+ from pathlib import Path
6
+ from typing import List
7
+
8
+ # Range folders under raw_photos (inclusive low–high)
9
+ _RANGES = [
10
+ (1, 700, "HQa1to700"),
11
+ (701, 1400, "HQa701to1400"),
12
+ (1401, 2100, "HQa1400to2100"),
13
+ (2101, 2800, "HQa2101to2800"),
14
+ (2801, 3500, "HQa2801to3500"),
15
+ (3501, 4200, "HQa3501to4200"),
16
+ (4201, 5000, "HQa4201to5000"),
17
+ ]
18
+
19
+
20
+ def _id_to_number(image_id: str) -> int:
21
+ """Extract numeric part from id, e.g. a2621-_DSC5468 -> 2621."""
22
+ prefix = image_id.split("-")[0]
23
+ if prefix.startswith("a"):
24
+ try:
25
+ return int(prefix[1:])
26
+ except ValueError:
27
+ pass
28
+ return 0
29
+
30
+
31
+ def image_id_to_dng_path(image_id: str, raw_photos_dir: Path) -> Path:
32
+ """
33
+ Resolve image ID to DNG file path under raw_photos_dir.
34
+ Returns path like raw_photos_dir/HQa2101to2800/photos/a2621-_DSC5468.dng
35
+ """
36
+ num = _id_to_number(image_id)
37
+ for low, high, folder_name in _RANGES:
38
+ if low <= num <= high:
39
+ return raw_photos_dir / folder_name / "photos" / f"{image_id}.dng"
40
+ return raw_photos_dir / "HQa1to700" / "photos" / f"{image_id}.dng"
41
+
42
+
43
+ def list_all_image_ids(file_list_path: Path) -> List[str]:
44
+ """Read ordered list of image IDs from filesAdobe.txt (or similar)."""
45
+ if not file_list_path.exists():
46
+ return []
47
+ ids = []
48
+ with open(file_list_path, "r", encoding="utf-8") as f:
49
+ for line in f:
50
+ line = line.strip()
51
+ if line and not line.startswith("#"):
52
+ ids.append(line)
53
+ return ids
photo_editor/dataset/subset.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Subset selection: first N image IDs from the canonical list.
3
+ N comes from settings (FIVEK_SUBSET_SIZE).
4
+ """
5
+ from typing import List
6
+
7
+ from photo_editor.config import get_settings
8
+ from photo_editor.dataset.paths import list_all_image_ids
9
+
10
+
11
+ def get_subset_image_ids() -> List[str]:
12
+ """
13
+ Return the first FIVEK_SUBSET_SIZE image IDs from filesAdobe.txt.
14
+ Order is deterministic for reproducibility.
15
+ """
16
+ s = get_settings()
17
+ all_ids = list_all_image_ids(s.fivek_file_list)
18
+ n = min(max(1, s.fivek_subset_size), len(all_ids))
19
+ return all_ids[:n]
photo_editor/embeddings/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from .embedder import AzureVisionEmbedder, ImageEmbedder, get_embedder
2
+
3
+ __all__ = ["AzureVisionEmbedder", "ImageEmbedder", "get_embedder"]
photo_editor/embeddings/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (335 Bytes). View file
 
photo_editor/embeddings/__pycache__/embedder.cpython-313.pyc ADDED
Binary file (11.8 kB). View file
 
photo_editor/embeddings/embedder.py ADDED
@@ -0,0 +1,208 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Image embedding: local CLIP (with Mac MPS / CUDA) or Azure AI Vision multimodal.
3
+ Same model must be used at index time and query time for retrieval.
4
+ """
5
+ import logging
6
+ from pathlib import Path
7
+ from typing import List, Union
8
+
9
+ import numpy as np
10
+
11
+ from photo_editor.config import get_settings
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class AzureVisionEmbedder:
17
+ """Encode images via Azure AI Vision retrieval:vectorizeImage API."""
18
+
19
+ def __init__(
20
+ self,
21
+ endpoint: str,
22
+ key: str,
23
+ model_version: str = "2023-04-15",
24
+ ):
25
+ self.endpoint = endpoint.rstrip("/")
26
+ self.key = key
27
+ self.model_version = model_version
28
+ self._dim: Union[int, None] = None
29
+
30
+ @property
31
+ def dimension(self) -> int:
32
+ if self._dim is not None:
33
+ return self._dim
34
+ # Get dimension from one dummy call (or set from known model: 1024 for 2023-04-15)
35
+ import io
36
+ from PIL import Image
37
+ dummy = np.zeros((224, 224, 3), dtype=np.uint8)
38
+ pil = Image.fromarray(dummy)
39
+ buf = io.BytesIO()
40
+ pil.save(buf, format="JPEG")
41
+ v = self._vectorize_image_bytes(buf.getvalue())
42
+ self._dim = len(v)
43
+ return self._dim
44
+
45
+ def _vectorize_image_bytes(self, image_bytes: bytes) -> List[float]:
46
+ import json
47
+ import urllib.error
48
+ import urllib.request
49
+
50
+ # Production API only. 2023-02-01-preview returns 410 Gone (deprecated).
51
+ # Docs: https://learn.microsoft.com/en-us/rest/api/computervision/vectorize/image-stream
52
+ # Path: POST <endpoint>/computervision/retrieval:vectorizeImage?overload=stream&model-version=...&api-version=2024-02-01
53
+ url = (
54
+ f"{self.endpoint}/computervision/retrieval:vectorizeImage"
55
+ f"?overload=stream&model-version={self.model_version}&api-version=2024-02-01"
56
+ )
57
+ req = urllib.request.Request(url, data=image_bytes, method="POST")
58
+ req.add_header("Ocp-Apim-Subscription-Key", self.key)
59
+ req.add_header("Content-Type", "image/jpeg")
60
+ try:
61
+ with urllib.request.urlopen(req) as resp:
62
+ data = json.loads(resp.read().decode())
63
+ return data["vector"]
64
+ except urllib.error.HTTPError as e:
65
+ try:
66
+ body = e.fp.read().decode() if e.fp else "(no body)"
67
+ except Exception:
68
+ body = "(could not read body)"
69
+ logger.error(
70
+ "Azure Vision vectorizeImage failed: HTTP %s %s. %s",
71
+ e.code,
72
+ e.reason,
73
+ body,
74
+ exc_info=False,
75
+ )
76
+ raise RuntimeError(
77
+ f"Azure Vision vectorizeImage failed: HTTP {e.code} {e.reason}. {body}"
78
+ ) from e
79
+
80
+ def encode_images(self, images: List[np.ndarray]) -> np.ndarray:
81
+ import io
82
+ from PIL import Image
83
+ out = []
84
+ for im in images:
85
+ pil = Image.fromarray((np.clip(im, 0, 1) * 255).astype(np.uint8))
86
+ buf = io.BytesIO()
87
+ pil.save(buf, format="JPEG")
88
+ vec = self._vectorize_image_bytes(buf.getvalue())
89
+ out.append(vec)
90
+ return np.array(out, dtype=np.float32)
91
+
92
+ def encode_image(self, image: np.ndarray) -> np.ndarray:
93
+ vecs = self.encode_images([image])
94
+ return vecs[0]
95
+
96
+
97
+ class ImageEmbedder:
98
+ """Encode images to fixed-size vectors for vector search."""
99
+
100
+ def __init__(
101
+ self,
102
+ model_name: str = "openai/clip-vit-base-patch32",
103
+ device: str = "cpu",
104
+ ):
105
+ self.model_name = model_name
106
+ self.device = device
107
+ self._model = None
108
+ self._processor = None
109
+
110
+ def _load(self) -> None:
111
+ if self._model is not None:
112
+ return
113
+ try:
114
+ from transformers import CLIPModel, CLIPProcessor
115
+ except ImportError as e:
116
+ raise ImportError(
117
+ "transformers and torch required for CLIP. "
118
+ "Install with: pip install transformers torch"
119
+ ) from e
120
+ self._processor = CLIPProcessor.from_pretrained(self.model_name)
121
+ self._model = CLIPModel.from_pretrained(self.model_name)
122
+ self._model.to(self.device)
123
+ self._model.eval()
124
+
125
+ @property
126
+ def dimension(self) -> int:
127
+ self._load()
128
+ return self._model.config.projection_dim
129
+
130
+ def encode_images(self, images: List[np.ndarray]) -> np.ndarray:
131
+ """
132
+ images: list of HWC float32 [0,1] RGB arrays (e.g. from dng_to_rgb).
133
+ Returns (N, dim) float32 numpy.
134
+ """
135
+ import torch
136
+ from PIL import Image
137
+ self._load()
138
+ # CLIPProcessor expects PIL Images
139
+ pil_list = [
140
+ Image.fromarray((np.clip(im, 0, 1) * 255).astype(np.uint8))
141
+ for im in images
142
+ ]
143
+ inputs = self._processor(images=pil_list, return_tensors="pt", padding=True)
144
+ inputs = {k: v.to(self.device) for k, v in inputs.items()}
145
+ with torch.no_grad():
146
+ out = self._model.get_image_features(**inputs)
147
+ # Newer transformers return BaseModelOutputWithPooling; use pooled tensor
148
+ t = getattr(out, "pooler_output", None) if hasattr(out, "pooler_output") else None
149
+ if t is None and hasattr(out, "last_hidden_state"):
150
+ t = out.last_hidden_state[:, 0]
151
+ elif t is None:
152
+ t = out
153
+ return t.detach().cpu().float().numpy()
154
+
155
+ def encode_image(self, image: np.ndarray) -> np.ndarray:
156
+ """Single image -> (dim,) vector."""
157
+ vecs = self.encode_images([image])
158
+ return vecs[0]
159
+
160
+
161
+ def _default_device() -> str:
162
+ import torch
163
+ if torch.cuda.is_available():
164
+ return "cuda"
165
+ if getattr(torch.backends, "mps", None) and torch.backends.mps.is_available() and torch.backends.mps.is_built():
166
+ return "mps" # Mac GPU (Apple Silicon)
167
+ return "cpu"
168
+
169
+
170
+ def get_embedder():
171
+ """Return Azure Vision embedder if configured and available in region; else local CLIP."""
172
+ s = get_settings()
173
+ if s.azure_vision_configured():
174
+ try:
175
+ emb = AzureVisionEmbedder(
176
+ endpoint=s.azure_vision_endpoint,
177
+ key=s.azure_vision_key,
178
+ model_version=s.azure_vision_model_version or "2023-04-15",
179
+ )
180
+ _ = emb.dimension # one call to verify region supports the API
181
+ logger.info(
182
+ "Using Azure Vision embedder (endpoint=%s, model_version=%s)",
183
+ s.azure_vision_endpoint,
184
+ s.azure_vision_model_version or "2023-04-15",
185
+ )
186
+ return emb
187
+ except RuntimeError as e:
188
+ err = str(e)
189
+ if "not enabled in this region" in err or "InvalidRequest" in err:
190
+ logger.warning(
191
+ "Azure Vision retrieval/vectorize not available (region/InvalidRequest). "
192
+ "Falling back to local CLIP (Mac MPS/CUDA/CPU). Error: %s",
193
+ err,
194
+ )
195
+ return ImageEmbedder(
196
+ model_name=s.embedding_model,
197
+ device=_default_device(),
198
+ )
199
+ logger.error("Azure Vision embedder failed: %s", err)
200
+ raise
201
+ logger.info(
202
+ "Azure Vision not configured; using local CLIP (model=%s)",
203
+ s.embedding_model,
204
+ )
205
+ return ImageEmbedder(
206
+ model_name=s.embedding_model,
207
+ device=_default_device(),
208
+ )
photo_editor/images/__init__.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from .apply_recipe import apply_recipe, match_to_reference
2
+ from .dng_to_rgb import dng_to_rgb
3
+ from .apply_recipe_linear import apply_recipe_linear
4
+ from .image_stats import compute_image_stats, rerank_score
5
+
6
+ __all__ = [
7
+ "apply_recipe",
8
+ "dng_to_rgb",
9
+ "apply_recipe_linear",
10
+ "match_to_reference",
11
+ "compute_image_stats",
12
+ "rerank_score",
13
+ ]
photo_editor/images/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (483 Bytes). View file
 
photo_editor/images/__pycache__/apply_recipe.cpython-313.pyc ADDED
Binary file (9.79 kB). View file
 
photo_editor/images/__pycache__/apply_recipe_linear.cpython-313.pyc ADDED
Binary file (5.2 kB). View file
 
photo_editor/images/__pycache__/dng_to_rgb.cpython-313.pyc ADDED
Binary file (5.23 kB). View file
 
photo_editor/images/__pycache__/estimate_current_recipe.cpython-313.pyc ADDED
Binary file (6.17 kB). View file
 
photo_editor/images/__pycache__/image_stats.cpython-313.pyc ADDED
Binary file (4.65 kB). View file
 
photo_editor/images/apply_recipe.py ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Apply EditRecipe (global sliders) to an RGB image.
3
+ Uses scikit-image for tone curves (exposure, contrast, black/white point) and
4
+ numpy for shadows/highlights, white balance, saturation. Optional histogram
5
+ matching to a reference image (e.g. Expert A TIF) for close visual match.
6
+ Input/output: float32 RGB in [0, 1], HWC.
7
+ """
8
+ import numpy as np
9
+ from typing import TYPE_CHECKING
10
+
11
+ if TYPE_CHECKING:
12
+ from photo_editor.lrcat.schema import EditRecipe
13
+
14
+ # White balance: neutral daylight (no warm/cool shift). Don't apply if within epsilon.
15
+ TEMP_NEUTRAL_K = 5500.0
16
+ TEMP_MIN_K = 2000.0
17
+ TEMP_MAX_K = 12000.0
18
+ TEMP_EPSILON_K = 10.0
19
+
20
+ # Clamp exposure (EV) so 2.0**ev doesn't overflow (gamma in ~1e-3 to 1e3).
21
+ EV_MIN = -10.0
22
+ EV_MAX = 10.0
23
+
24
+
25
+ def _luminance(rgb: np.ndarray) -> np.ndarray:
26
+ """Rec. 709 luma."""
27
+ return (0.2126 * rgb[..., 0] + 0.7152 * rgb[..., 1] + 0.0722 * rgb[..., 2]).astype(np.float32)
28
+
29
+
30
+ def apply_recipe(image: np.ndarray, recipe: "EditRecipe") -> np.ndarray:
31
+ """
32
+ Apply expert edit recipe. image: float32 [0,1] HWC RGB.
33
+ Uses skimage for exposure/contrast/black/white; numpy for shadows/highlights/WB/saturation.
34
+ """
35
+ from skimage.exposure import adjust_gamma, adjust_sigmoid, rescale_intensity
36
+
37
+ out = np.clip(image.astype(np.float32), 0.0, 1.0).copy()
38
+
39
+ # 1) Exposure (EV) via gamma: 2^EV ~ brightness. gamma < 1 brightens.
40
+ try:
41
+ ev = float(getattr(recipe, "exposure", 0.0) or 0.0)
42
+ except (TypeError, ValueError):
43
+ ev = 0.0
44
+ if ev != 0:
45
+ ev = max(EV_MIN, min(EV_MAX, ev))
46
+ gamma = 2.0 ** (-ev)
47
+ out = adjust_gamma(out, gamma=gamma)
48
+ out = np.clip(out.astype(np.float32), 0.0, 1.0)
49
+
50
+ # 1b) Brightness (LR 0-100). Apply as offset scaled to [0,1].
51
+ bright = getattr(recipe, "brightness", 0.0) or 0.0
52
+ if bright != 0:
53
+ offset = float(bright) / 100.0 * 0.2
54
+ out = np.clip(out + offset, 0.0, 1.0)
55
+
56
+ # 2) Contrast via sigmoid (S-curve). gain > 1 = more contrast.
57
+ c = getattr(recipe, "contrast", 0.0) or 0.0
58
+ if c != 0:
59
+ gain = 1.0 + float(c) / 100.0
60
+ # cutoff 0.5 = midtone; increase gain for stronger S-curve
61
+ out = adjust_sigmoid(out, cutoff=0.5, gain=gain)
62
+ out = np.clip(out.astype(np.float32), 0.0, 1.0)
63
+
64
+ # 3) Blacks / Whites: rescale intensity range (LR ParametricDarks/Lights).
65
+ blk = getattr(recipe, "blacks", 0.0) or 0.0
66
+ wht = getattr(recipe, "whites", 0.0) or 0.0
67
+ if blk != 0 or wht != 0:
68
+ # in_range: shrink input range -> darker blacks / brighter whites
69
+ in_low, in_high = 0.0, 1.0
70
+ in_low += float(blk) / 100.0 * 0.1
71
+ in_high -= float(wht) / 100.0 * 0.05
72
+ in_high = max(in_high, in_low + 0.01)
73
+ out = rescale_intensity(out, in_range=(in_low, in_high), out_range=(0.0, 1.0))
74
+ out = np.clip(out.astype(np.float32), 0.0, 1.0)
75
+
76
+ # 4) Shadows: lift dark tones (luminance-weighted).
77
+ sh = getattr(recipe, "shadows", 0.0) or 0.0
78
+ if sh != 0:
79
+ L = _luminance(out)
80
+ weight = 1.0 - np.clip(L, 0, 1) ** 0.5
81
+ lift = float(sh) / 100.0 * 0.4
82
+ out = out + lift * np.broadcast_to(weight[..., np.newaxis], out.shape)
83
+ out = np.clip(out, 0.0, 1.0)
84
+
85
+ # 5) Highlights: pull down brights (luminance-weighted).
86
+ hi = getattr(recipe, "highlights", 0.0) or 0.0
87
+ if hi != 0:
88
+ L = _luminance(out)
89
+ weight = np.clip(L, 0, 1) ** 1.5
90
+ amount = -float(hi) / 100.0 * 0.45
91
+ out = out + amount * np.broadcast_to(weight[..., np.newaxis], out.shape)
92
+ out = np.clip(out, 0.0, 1.0)
93
+
94
+ # 6) Temperature (Kelvin). Only apply if recipe differs from neutral.
95
+ # Lower K = warmer (more red); higher K = cooler (more blue). So t > 0 when temp < neutral = more red.
96
+ temp = getattr(recipe, "temperature", TEMP_NEUTRAL_K) or TEMP_NEUTRAL_K
97
+ if TEMP_MIN_K < temp < TEMP_MAX_K and abs(temp - TEMP_NEUTRAL_K) > TEMP_EPSILON_K:
98
+ t = (TEMP_NEUTRAL_K - float(temp)) / TEMP_NEUTRAL_K # warm (low K) -> t > 0 -> more red
99
+ out[..., 0] = np.clip(out[..., 0] * (1.0 + t * 0.5), 0, 1)
100
+ out[..., 2] = np.clip(out[..., 2] * (1.0 - t * 0.5), 0, 1)
101
+
102
+ # 7) Tint (green-magenta).
103
+ tint = getattr(recipe, "tint", 0.0) or 0.0
104
+ if tint != 0:
105
+ t = float(tint) / 100.0
106
+ out[..., 0] = np.clip(out[..., 0] * (1.0 + t * 0.2), 0, 1)
107
+ out[..., 1] = np.clip(out[..., 1] * (1.0 - t * 0.25), 0, 1)
108
+ out[..., 2] = np.clip(out[..., 2] * (1.0 + t * 0.2), 0, 1)
109
+
110
+ # 8) Saturation (luminance-preserving).
111
+ sat = getattr(recipe, "saturation", 0.0) or 0.0
112
+ vib = getattr(recipe, "vibrance", 0.0) or 0.0
113
+ s = (float(sat) + float(vib)) / 100.0
114
+ if s != 0:
115
+ L = _luminance(out)
116
+ L = np.broadcast_to(L[..., np.newaxis], out.shape)
117
+ out = L + (1.0 + s) * (out - L)
118
+ out = np.clip(out, 0.0, 1.0)
119
+
120
+ # 9) Tone curve (parametric 4-point). Remap [0,1] via piecewise linear.
121
+ tc = getattr(recipe, "tone_curve", None)
122
+ if tc and len(tc) >= 4:
123
+ # tc = [v0,v1,v2,v3] LR style; map 0->v0/255, 0.33->v1/255, 0.67->v2/255, 1->v3/255
124
+ xs = np.array([0.0, 1.0 / 3.0, 2.0 / 3.0, 1.0], dtype=np.float32)
125
+ ys = np.clip(np.array(tc[:4], dtype=np.float32) / 255.0, 0.0, 1.0)
126
+ if np.any(ys != np.array([0, 0, 1, 1])): # not identity
127
+ L = _luminance(out)
128
+ L_flat = np.clip(L.ravel(), 0, 1)
129
+ mapped = np.interp(L_flat, xs, ys).reshape(L.shape)
130
+ out = out + np.broadcast_to((mapped - L)[..., np.newaxis], out.shape)
131
+ out = np.clip(out, 0.0, 1.0)
132
+
133
+ # 10) Sharpening (unsharp mask).
134
+ sd = getattr(recipe, "sharpen_detail", 0.0) or 0.0
135
+ sr = getattr(recipe, "sharpen_radius", 0.0) or 0.0
136
+ if sd != 0 and sr >= 0:
137
+ try:
138
+ from skimage.filters import unsharp_mask
139
+ radius = max(0.5, float(sr)) if sr > 0 else 1.0
140
+ amount = float(sd) / 100.0 * 0.5
141
+ out = unsharp_mask(out, radius=radius, amount=amount, channel_axis=-1)
142
+ out = np.clip(out.astype(np.float32), 0.0, 1.0)
143
+ except Exception:
144
+ pass
145
+
146
+ return out.astype(np.float32)
147
+
148
+
149
+ def match_to_reference(image: np.ndarray, reference: np.ndarray) -> np.ndarray:
150
+ """
151
+ Match image's per-channel histograms to reference (e.g. Expert A TIF).
152
+ image, reference: float32 [0,1] HWC. Reference is resized to image shape if needed.
153
+ """
154
+ from skimage.exposure import match_histograms
155
+ from skimage.transform import resize
156
+
157
+ ref = np.clip(reference.astype(np.float32), 0.0, 1.0)
158
+ if ref.shape[:2] != image.shape[:2]:
159
+ ref = resize(
160
+ ref,
161
+ (image.shape[0], image.shape[1]),
162
+ order=1,
163
+ anti_aliasing=True,
164
+ preserve_range=True,
165
+ ).astype(np.float32)
166
+ ref = np.clip(ref, 0.0, 1.0)
167
+
168
+ matched = match_histograms(image, ref, channel_axis=-1)
169
+ return np.clip(matched.astype(np.float32), 0.0, 1.0)
photo_editor/images/apply_recipe_linear.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Alternative apply: linear exposure, simple temp/brightness/shadows/saturation.
3
+ Baseline (e.g. Original) used only for brightness/shadows/contrast/saturation deltas;
4
+ temperature and tint use fixed neutral (camera WB from rawpy). Input/output: float32 [0,1] HWC.
5
+ """
6
+ import numpy as np
7
+ from typing import TYPE_CHECKING, Optional
8
+
9
+ if TYPE_CHECKING:
10
+ from photo_editor.lrcat.schema import EditRecipe
11
+
12
+ TEMP_NEUTRAL = 5229.0 # fixed neutral for WB (baseline not used for temp/tint; image is at camera WB)
13
+ EV_MIN, EV_MAX = -10.0, 10.0
14
+
15
+
16
+ def _luminance(rgb: np.ndarray) -> np.ndarray:
17
+ return (0.2126 * rgb[..., 0] + 0.7152 * rgb[..., 1] + 0.0722 * rgb[..., 2]).astype(np.float32)
18
+
19
+
20
+ def _val(r: "EditRecipe", key: str, default: float = 0.0) -> float:
21
+ v = getattr(r, key, default)
22
+ if v is None:
23
+ return default
24
+ try:
25
+ return float(v)
26
+ except (TypeError, ValueError):
27
+ return default
28
+
29
+
30
+ def apply_recipe_linear(
31
+ image: np.ndarray,
32
+ recipe: "EditRecipe",
33
+ baseline_recipe: Optional["EditRecipe"] = None,
34
+ ) -> np.ndarray:
35
+ """
36
+ Apply recipe. Baseline (e.g. Original) is used only for brightness/shadows/
37
+ contrast/saturation deltas. Temperature and tint use fixed neutral (image
38
+ is at camera WB from rawpy).
39
+ """
40
+ out = np.clip(image.astype(np.float32), 0.0, 1.0).copy()
41
+ base = baseline_recipe
42
+
43
+ # 1) Temperature: fixed neutral (no baseline); higher K = cooler = more blue
44
+ temp = _val(recipe, "temperature", TEMP_NEUTRAL) or TEMP_NEUTRAL
45
+ temp_factor = temp / TEMP_NEUTRAL
46
+ out[..., 0] *= 1.0 - (temp_factor - 1.0) * 0.2 # red: decrease when cooler
47
+ out[..., 2] *= 1.0 + (temp_factor - 1.0) * 0.8 # blue: increase when cooler
48
+
49
+ # 1b) Tint: absolute (no baseline)
50
+ tint = _val(recipe, "tint", 0.0) / 100.0
51
+ if tint != 0:
52
+ out[..., 0] *= 1.0 + tint * 0.2
53
+ out[..., 1] *= 1.0 - tint * 0.25
54
+ out[..., 2] *= 1.0 + tint * 0.2
55
+ out = np.clip(out, 0.0, 1.0)
56
+
57
+ # 2) Exposure: linear multiplier 2^ev (no delta when baseline; recipe is absolute or already baked)
58
+ try:
59
+ ev = float(getattr(recipe, "exposure", 0.0) or 0.0)
60
+ except (TypeError, ValueError):
61
+ ev = 0.0
62
+ ev = max(EV_MIN, min(EV_MAX, ev))
63
+ out *= 2.0 ** ev
64
+
65
+ # 3) Brightness
66
+ bright = _val(recipe, "brightness", 0.0) / 100.0
67
+ if base:
68
+ bright -= _val(base, "brightness", 0.0) / 100.0
69
+ if bright != 0:
70
+ out += bright
71
+ out = np.clip(out, 0.0, 1.0)
72
+
73
+ # 4) Shadows (delta when baseline)
74
+ sh = _val(recipe, "shadows", 0.0) / 100.0
75
+ if base:
76
+ sh -= _val(base, "shadows", 0.0) / 100.0
77
+ if sh != 0:
78
+ mask = np.clip(1.0 - np.mean(out, axis=-1) * 2.0, 0, 1)
79
+ lift = (sh * 0.2) * mask
80
+ out += np.broadcast_to(lift[..., np.newaxis], out.shape)
81
+ out = np.clip(out, 0.0, 1.0)
82
+
83
+ # 5) Contrast (delta when baseline)
84
+ c = _val(recipe, "contrast", 0.0) / 100.0
85
+ if base:
86
+ c -= _val(base, "contrast", 0.0) / 100.0
87
+ if c != 0:
88
+ out = (out - 0.5) * (1.0 + c) + 0.5
89
+ out = np.clip(out, 0.0, 1.0)
90
+
91
+ # 6) Saturation (delta when baseline)
92
+ sat = _val(recipe, "saturation", 0.0) / 100.0 + _val(recipe, "vibrance", 0.0) / 100.0
93
+ if base:
94
+ sat -= _val(base, "saturation", 0.0) / 100.0 + _val(base, "vibrance", 0.0) / 100.0
95
+ if sat != 0:
96
+ L = _luminance(out)
97
+ L = np.broadcast_to(L[..., np.newaxis], out.shape)
98
+ out = L + (1.0 + sat) * (out - L)
99
+ out = np.clip(out, 0.0, 1.0)
100
+
101
+ return out.astype(np.float32)
photo_editor/images/dng_to_rgb.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ DNG to RGB using rawpy (default/neutral development).
3
+ Output is suitable for embedding (e.g. CLIP expects RGB images).
4
+
5
+ Normalized development: use_camera_wb + exp_shift/bright from recipe so
6
+ exposure and brightness are applied at raw develop time (closer to LR).
7
+ """
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING, Union
10
+
11
+ import numpy as np
12
+
13
+ try:
14
+ import rawpy
15
+ except ImportError:
16
+ rawpy = None
17
+
18
+ if TYPE_CHECKING:
19
+ from photo_editor.lrcat.schema import EditRecipe
20
+
21
+ # rawpy exp_shift usable range ~0.25 (2-stop darken) to 8.0 (3-stop lighter)
22
+ _EXP_SHIFT_MIN, _EXP_SHIFT_MAX = 0.25, 8.0
23
+
24
+
25
+ def dng_to_rgb(
26
+ path: Union[str, Path],
27
+ output_size: tuple = (224, 224),
28
+ ) -> np.ndarray:
29
+ """
30
+ Load DNG and develop to sRGB with default settings; resize to output_size.
31
+ Returns float32 array (0..1) HWC RGB for embedding models.
32
+ """
33
+ if rawpy is None:
34
+ raise ImportError("rawpy is required for DNG support. Install with: pip install rawpy")
35
+ path = Path(path)
36
+ if not path.exists():
37
+ raise FileNotFoundError(str(path))
38
+
39
+ with rawpy.imread(str(path)) as raw:
40
+ rgb = raw.postprocess(
41
+ use_camera_wb=True,
42
+ half_size=False,
43
+ no_auto_bright=True,
44
+ output_bps=16,
45
+ )
46
+ # rgb: HWC uint16
47
+ rgb = rgb.astype(np.float32) / 65535.0
48
+ # Resize if needed (CLIP often uses 224x224)
49
+ if output_size and (rgb.shape[0] != output_size[0] or rgb.shape[1] != output_size[1]):
50
+ try:
51
+ from PIL import Image
52
+ pil = Image.fromarray((rgb * 255).astype(np.uint8))
53
+ pil = pil.resize((output_size[1], output_size[0]), Image.BILINEAR)
54
+ rgb = np.array(pil).astype(np.float32) / 255.0
55
+ except ImportError:
56
+ pass # keep original size if no PIL
57
+ return rgb
58
+
59
+
60
+ def dng_to_rgb_normalized(
61
+ path: Union[str, Path],
62
+ recipe: "EditRecipe",
63
+ output_size: tuple = None,
64
+ ) -> np.ndarray:
65
+ """
66
+ Develop DNG with camera white balance and bake in recipe exposure + brightness
67
+ via rawpy (exp_shift, bright). Use with apply_recipe_linear on a copy of
68
+ recipe with exposure=0, brightness=0 for the rest (temp, tint, shadows, etc.).
69
+ Returns float32 [0, 1] HWC RGB.
70
+ """
71
+ if rawpy is None:
72
+ raise ImportError("rawpy is required for DNG support. Install with: pip install rawpy")
73
+ path = Path(path)
74
+ if not path.exists():
75
+ raise FileNotFoundError(str(path))
76
+
77
+ ev = float(getattr(recipe, "exposure", 0.0) or 0.0)
78
+ ev = max(-2.0, min(3.0, ev)) # keep 2**ev in rawpy range
79
+ exp_shift = 2.0 ** ev
80
+ exp_shift = max(_EXP_SHIFT_MIN, min(_EXP_SHIFT_MAX, exp_shift))
81
+
82
+ bright_val = float(getattr(recipe, "brightness", 0.0) or 0.0)
83
+ bright = 1.0 + (bright_val / 100.0)
84
+
85
+ with rawpy.imread(str(path)) as raw:
86
+ rgb = raw.postprocess(
87
+ use_camera_wb=True,
88
+ half_size=False,
89
+ no_auto_bright=True,
90
+ output_bps=16,
91
+ exp_shift=exp_shift,
92
+ bright=bright,
93
+ )
94
+ rgb = rgb.astype(np.float32) / 65535.0
95
+ if output_size and (rgb.shape[0] != output_size[0] or rgb.shape[1] != output_size[1]):
96
+ try:
97
+ from PIL import Image
98
+ pil = Image.fromarray((np.clip(rgb, 0, 1) * 255).astype(np.uint8))
99
+ pil = pil.resize((output_size[1], output_size[0]), Image.BILINEAR)
100
+ rgb = np.array(pil).astype(np.float32) / 255.0
101
+ except ImportError:
102
+ pass
103
+ return rgb
photo_editor/images/estimate_current_recipe.py ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Heuristically estimate \"current\" global edit parameters from an image.
3
+
4
+ These are NOT true Lightroom sliders; they are best-effort values in the same
5
+ units as our pipeline so we can show Current vs Suggested vs Delta.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+ from typing import Dict, Tuple
12
+
13
+ import numpy as np
14
+ from PIL import Image, UnidentifiedImageError
15
+
16
+ # Optional HEIC/HEIF decoding support for Pillow.
17
+ try:
18
+ import pillow_heif
19
+
20
+ pillow_heif.register_heif_opener()
21
+ except Exception:
22
+ pillow_heif = None
23
+
24
+ from photo_editor.images.dng_to_rgb import dng_to_rgb
25
+ from photo_editor.images.image_stats import compute_image_stats, luminance
26
+
27
+
28
+ def _load_rgb_full(path: Path) -> np.ndarray:
29
+ path = Path(path)
30
+ if path.suffix.lower() == ".dng":
31
+ return dng_to_rgb(path, output_size=None)
32
+ try:
33
+ img = Image.open(path).convert("RGB")
34
+ except UnidentifiedImageError as e:
35
+ suffix = path.suffix.lower()
36
+ if suffix in {".heic", ".heif"}:
37
+ raise RuntimeError(
38
+ "HEIC/HEIF decoding is unavailable in this environment. "
39
+ "Install pillow-heif (`pip install pillow-heif`) and restart Streamlit."
40
+ ) from e
41
+ raise
42
+ return np.asarray(img, dtype=np.float32) / 255.0
43
+
44
+
45
+ def _channel_means_midtones(rgb: np.ndarray) -> Tuple[float, float, float]:
46
+ rgb = np.clip(rgb.astype(np.float32), 0.0, 1.0)
47
+ lum = luminance(rgb)
48
+ mask = (lum >= 0.2) & (lum <= 0.85)
49
+ if not np.any(mask):
50
+ m = rgb.reshape(-1, 3).mean(axis=0)
51
+ return float(m[0]), float(m[1]), float(m[2])
52
+ m = rgb[mask].reshape(-1, 3).mean(axis=0)
53
+ return float(m[0]), float(m[1]), float(m[2])
54
+
55
+
56
+ def estimate_current_parameters(image_path: Path) -> Dict[str, float]:
57
+ """
58
+ Return a dict with keys:
59
+ exposure, contrast, highlights, shadows, whites, blacks,
60
+ temperature, tint, vibrance, saturation
61
+ based on simple brightness / saturation heuristics.
62
+ """
63
+ rgb = _load_rgb_full(image_path)
64
+ stats = compute_image_stats(rgb)
65
+
66
+ p10 = float(stats["brightness_p10"])
67
+ p50 = float(stats["brightness_p50"])
68
+ p90 = float(stats["brightness_p90"])
69
+ sat_mean = float(stats["saturation_mean"]) # 0..100
70
+
71
+ # Exposure: map median luminance to ~0.35
72
+ target_mid = 0.35
73
+ ev = np.log2(max(1e-6, target_mid) / max(1e-6, p50))
74
+ ev = float(np.clip(ev, -2.0, 2.0))
75
+
76
+ # Contrast: based on percentile spread
77
+ spread = max(1e-6, p90 - p10)
78
+ target_spread = 0.6
79
+ contrast = (spread / target_spread - 1.0) * 50.0
80
+ contrast = float(np.clip(contrast, -50.0, 50.0))
81
+
82
+ # Temperature / tint: from midtone channel balance
83
+ r_m, g_m, b_m = _channel_means_midtones(rgb)
84
+ rb_sum = max(1e-6, r_m + b_m)
85
+ coolness = (b_m - r_m) / rb_sum # + cooler
86
+ temperature = 5500.0 * (1.0 + coolness * 1.5)
87
+ temperature = float(np.clip(temperature, 2500.0, 12000.0))
88
+
89
+ green_ref = 0.5 * (r_m + b_m)
90
+ denom = max(1e-6, g_m + green_ref)
91
+ green_bias = (g_m - green_ref) / denom # + greener
92
+ tint = float(np.clip(green_bias * 120.0, -50.0, 50.0))
93
+
94
+ # Saturation / vibrance
95
+ saturation = (sat_mean - 30.0) * 2.0
96
+ saturation = float(np.clip(saturation, -50.0, 50.0))
97
+ vibrance = float(np.clip(saturation * 0.6, -50.0, 50.0))
98
+
99
+ # Highlights / shadows / whites / blacks (very rough)
100
+ shadows = (p10 - 0.15) * 200.0
101
+ shadows = float(np.clip(shadows, -60.0, 60.0))
102
+
103
+ highlights = (0.85 - p90) * -180.0
104
+ highlights = float(np.clip(highlights, -60.0, 60.0))
105
+
106
+ whites = (p90 - 0.9) * 250.0
107
+ whites = float(np.clip(whites, -60.0, 60.0))
108
+
109
+ blacks = (0.1 - p10) * -250.0
110
+ blacks = float(np.clip(blacks, -60.0, 60.0))
111
+
112
+ return {
113
+ "exposure": round(ev, 4),
114
+ "contrast": round(contrast, 4),
115
+ "highlights": round(highlights, 4),
116
+ "shadows": round(shadows, 4),
117
+ "whites": round(whites, 4),
118
+ "blacks": round(blacks, 4),
119
+ "temperature": round(temperature, 2),
120
+ "tint": round(tint, 4),
121
+ "vibrance": round(vibrance, 4),
122
+ "saturation": round(saturation, 4),
123
+ }
124
+
photo_editor/images/image_stats.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Lighting and color statistics for reranking (brightness percentiles,
3
+ highlight/shadow clipping, saturation). Used at index time and query time.
4
+ Input: RGB float [0, 1] HWC.
5
+ """
6
+ from typing import Any, Dict
7
+
8
+ import numpy as np
9
+
10
+ # Luminance weights (Rec. 709)
11
+ _LUM_R, _LUM_G, _LUM_B = 0.2126, 0.7152, 0.0722
12
+
13
+
14
+ def luminance(rgb: np.ndarray) -> np.ndarray:
15
+ """Return luminance shape (H,W) from RGB (H,W,3) float [0,1]."""
16
+ r, g, b = rgb[..., 0], rgb[..., 1], rgb[..., 2]
17
+ return (_LUM_R * r + _LUM_G * g + _LUM_B * b).astype(np.float32)
18
+
19
+
20
+ def saturation_per_pixel(rgb: np.ndarray) -> np.ndarray:
21
+ """Per-pixel saturation: (max(R,G,B)-min)/max when max>0 else 0. Shape (H,W)."""
22
+ mx = np.max(rgb, axis=-1)
23
+ mn = np.min(rgb, axis=-1)
24
+ sat = np.zeros_like(mx, dtype=np.float32)
25
+ # Use np.divide with where= to avoid warnings when mx == 0.
26
+ np.divide(mx - mn, mx, out=sat, where=mx > 1e-6)
27
+ return sat.astype(np.float32)
28
+
29
+
30
+ def compute_image_stats(rgb: np.ndarray) -> Dict[str, float]:
31
+ """
32
+ Compute lighting and color statistics for reranking.
33
+ rgb: float32/64 [0, 1] HWC.
34
+ Returns dict: brightness_p10, brightness_p50, brightness_p90,
35
+ highlight_clip, shadow_clip, saturation_mean, saturation_std.
36
+ """
37
+ rgb = np.clip(rgb.astype(np.float64), 0.0, 1.0)
38
+ lum = luminance(rgb)
39
+ sat = saturation_per_pixel(rgb)
40
+
41
+ # Brightness percentiles (0–1)
42
+ flat_lum = lum.ravel()
43
+ p10 = float(np.percentile(flat_lum, 10))
44
+ p50 = float(np.percentile(flat_lum, 50))
45
+ p90 = float(np.percentile(flat_lum, 90))
46
+
47
+ # Highlight clipping: % of pixels with luminance or max(R,G,B) >= 0.99
48
+ high = (lum >= 0.99) | (np.max(rgb, axis=-1) >= 0.99)
49
+ highlight_clip = float(np.mean(high) * 100.0)
50
+
51
+ # Shadow clipping: % with luminance or min(R,G,B) <= 0.01
52
+ low = (lum <= 0.01) | (np.min(rgb, axis=-1) <= 0.01)
53
+ shadow_clip = float(np.mean(low) * 100.0)
54
+
55
+ # Saturation
56
+ flat_sat = sat.ravel()
57
+ saturation_mean = float(np.mean(flat_sat) * 100.0)
58
+ saturation_std = float(np.std(flat_sat) * 100.0)
59
+
60
+ return {
61
+ "brightness_p10": round(p10, 6),
62
+ "brightness_p50": round(p50, 6),
63
+ "brightness_p90": round(p90, 6),
64
+ "highlight_clip": round(highlight_clip, 4),
65
+ "shadow_clip": round(shadow_clip, 4),
66
+ "saturation_mean": round(saturation_mean, 4),
67
+ "saturation_std": round(saturation_std, 4),
68
+ }
69
+
70
+
71
+ def rerank_score(user_stats: Dict[str, float], candidate_stats: Dict[str, float]) -> float:
72
+ """
73
+ Higher = better alignment. Uses L1 distance on normalized stats, then 1/(1+d).
74
+ Weights: brightness and clipping matter most for editing intent.
75
+ """
76
+ keys = [
77
+ "brightness_p10", "brightness_p50", "brightness_p90",
78
+ "highlight_clip", "shadow_clip",
79
+ "saturation_mean", "saturation_std",
80
+ ]
81
+ # Scale to similar magnitude (percentiles 0–1, clip/sat in 0–100)
82
+ scales = {
83
+ "brightness_p10": 1.0, "brightness_p50": 1.0, "brightness_p90": 1.0,
84
+ "highlight_clip": 0.01, "shadow_clip": 0.01,
85
+ "saturation_mean": 0.01, "saturation_std": 0.01,
86
+ }
87
+ d = 0.0
88
+ for k in keys:
89
+ u = user_stats.get(k, 0.0)
90
+ c = candidate_stats.get(k, 0.0)
91
+ d += abs(u - c) * scales[k]
92
+ return 1.0 / (1.0 + d)
photo_editor/lrcat/__init__.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from .schema import EditRecipe
2
+ from .extract import (
3
+ extract_expert_a_recipes,
4
+ get_recipe_for_image,
5
+ get_recipe_for_image_and_copy,
6
+ )
7
+
8
+ __all__ = [
9
+ "EditRecipe",
10
+ "extract_expert_a_recipes",
11
+ "get_recipe_for_image",
12
+ "get_recipe_for_image_and_copy",
13
+ ]
photo_editor/lrcat/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (428 Bytes). View file
 
photo_editor/lrcat/__pycache__/extract.cpython-313.pyc ADDED
Binary file (9.72 kB). View file
 
photo_editor/lrcat/__pycache__/schema.cpython-313.pyc ADDED
Binary file (5.43 kB). View file
 
photo_editor/lrcat/extract.py ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Extract Expert A develop settings from fivek.lrcat.
3
+ Expert A = Copy 8 in this catalog (configurable via EXPERT_A_COPY_NAME).
4
+ """
5
+ import re
6
+ import sqlite3
7
+ from pathlib import Path
8
+ from typing import Any, Dict, Optional
9
+
10
+ from photo_editor.lrcat.schema import EditRecipe
11
+
12
+ # Expert A = Copy 8 in this catalog (try Copy 8 if Copy 1 had no develop data)
13
+ EXPERT_A_COPY_NAME = "Copy 3"
14
+
15
+
16
+ def _parse_lr_develop_text(text: str) -> Dict[str, Any]:
17
+ """
18
+ Parse Lightroom develop settings string (Lua-like s = { key = value }).
19
+ Extracts numeric key = value and ToneCurve = { n1, n2, n3, n4 }.
20
+ """
21
+ out: Dict[str, Any] = {}
22
+ # Match key = number (int or float)
23
+ for m in re.finditer(r"(\w+)\s*=\s*([+-]?\d+(?:\.\d+)?)", text):
24
+ key, val = m.group(1), m.group(2)
25
+ try:
26
+ if "." in val:
27
+ out[key] = float(val)
28
+ else:
29
+ out[key] = float(int(val))
30
+ except ValueError:
31
+ continue
32
+ # ToneCurve = { 0, 0, 255, 255 }
33
+ tone_curve_m = re.search(r"ToneCurve\s*=\s*\{\s*([^}]+)\}", text)
34
+ if tone_curve_m:
35
+ parts = re.findall(r"[+-]?\d+(?:\.\d+)?", tone_curve_m.group(1))
36
+ if len(parts) >= 4:
37
+ try:
38
+ out["ToneCurve"] = [float(x) for x in parts[:4]]
39
+ except ValueError:
40
+ pass
41
+ return out
42
+
43
+
44
+ def _lr_to_recipe(lr: Dict[str, Any]) -> EditRecipe:
45
+ """Map Lightroom keys to our EditRecipe schema. Use Custom* when present.
46
+ Highlights/Shadows: use ParametricHighlights/ParametricShadows (tone sliders);
47
+ HighlightRecovery is a different control (recovery of blown highlights).
48
+ Sign: LR ParametricHighlights negative = darken highlights; our apply uses
49
+ positive 'highlights' = darken, so we store -ParametricHighlights.
50
+ """
51
+ temp = lr.get("Temperature", lr.get("CustomTemperature", 0.0))
52
+ tint = lr.get("Tint", lr.get("CustomTint", 0.0))
53
+ tc = lr.get("ToneCurve")
54
+ # ParametricHighlights = tone slider; negate so our apply (positive = darken) matches LR.
55
+ parametric_high = float(lr.get("ParametricHighlights", 0))
56
+ shadows_val = float(lr.get("ParametricShadows", lr.get("Shadows", 0)))
57
+ return EditRecipe(
58
+ exposure=float(lr.get("Exposure", 0)),
59
+ brightness=float(lr.get("Brightness", 0)),
60
+ contrast=float(lr.get("Contrast", 0)),
61
+ highlights=float(-parametric_high), # LR neg = darken; we store so positive = darken
62
+ shadows=float(shadows_val),
63
+ whites=float(lr.get("ParametricLights", 0)),
64
+ blacks=float(lr.get("ParametricDarks", 0)),
65
+ temperature=float(temp),
66
+ tint=float(tint),
67
+ vibrance=float(lr.get("Vibrance", 0)),
68
+ saturation=float(lr.get("Saturation", 0)),
69
+ tone_curve=tc if isinstance(tc, (list, tuple)) and len(tc) >= 4 else None,
70
+ sharpen_detail=float(lr.get("SharpenDetail", 0)),
71
+ sharpen_radius=float(lr.get("SharpenRadius", 0)),
72
+ sharpen_edge_masking=float(lr.get("SharpenEdgeMasking", 0)),
73
+ perspective_horizontal=float(lr.get("PerspectiveHorizontal", 0)),
74
+ perspective_vertical=float(lr.get("PerspectiveVertical", 0)),
75
+ perspective_rotate=float(lr.get("PerspectiveRotate", 0)),
76
+ perspective_scale=float(lr.get("PerspectiveScale", 100)),
77
+ lens_distortion=float(lr.get("LensManualDistortionAmount", 0)),
78
+ )
79
+
80
+
81
+ def extract_expert_a_recipes(lrcat_path: Path) -> Dict[str, EditRecipe]:
82
+ """
83
+ Load catalog and return dict image_id -> EditRecipe for Expert A (Copy 8).
84
+ image_id = baseName from AgLibraryFile (e.g. a0001-jmac_DSC1459).
85
+ """
86
+ conn = sqlite3.connect(str(lrcat_path))
87
+ conn.row_factory = sqlite3.Row
88
+ cur = conn.cursor()
89
+
90
+ # Expert A = Copy 8. Get (image id_local, rootFile) for that copy only.
91
+ cur.execute("""
92
+ SELECT i.id_local AS image_id, i.rootFile
93
+ FROM Adobe_images i
94
+ WHERE i.copyName = ?
95
+ """, (EXPERT_A_COPY_NAME,))
96
+ copy1_rows = {r["image_id"]: r["rootFile"] for r in cur.fetchall()}
97
+
98
+ # rootFile -> baseName (image id string)
99
+ cur.execute("SELECT id_local, baseName FROM AgLibraryFile")
100
+ file_id_to_base = {r["id_local"]: r["baseName"] for r in cur.fetchall()}
101
+
102
+ # Develop settings: image id_local -> text (prefer first non-empty per image)
103
+ cur.execute("""
104
+ SELECT image, text FROM Adobe_imageDevelopSettings
105
+ WHERE text IS NOT NULL AND text != ''
106
+ """)
107
+ image_to_text: Dict[int, str] = {}
108
+ for r in cur.fetchall():
109
+ img_id, text = r["image"], (r["text"] or "").strip()
110
+ if img_id not in image_to_text and text:
111
+ image_to_text[img_id] = r["text"]
112
+
113
+ conn.close()
114
+
115
+ result: Dict[str, EditRecipe] = {}
116
+ for img_id, root_file_id in copy1_rows.items():
117
+ base_name = file_id_to_base.get(root_file_id)
118
+ if not base_name:
119
+ continue
120
+ text = image_to_text.get(img_id)
121
+ if not text:
122
+ continue
123
+ lr = _parse_lr_develop_text(text)
124
+ result[base_name] = _lr_to_recipe(lr)
125
+ return result
126
+
127
+
128
+ def get_recipe_for_image(
129
+ image_id: str,
130
+ recipes: Dict[str, EditRecipe],
131
+ ) -> Optional[EditRecipe]:
132
+ """Return recipe for image_id if present."""
133
+ return recipes.get(image_id)
134
+
135
+
136
+ def get_recipe_for_image_and_copy(
137
+ lrcat_path: Path,
138
+ image_id: str,
139
+ copy_name: Optional[str],
140
+ ) -> Optional[EditRecipe]:
141
+ """
142
+ Get develop settings for one image and one copy (e.g. "Copy 1", "Copy 8").
143
+ Pass copy_name=None to get the Original (as-shot) recipe (copyName IS NULL).
144
+ Returns EditRecipe or None if that copy has no (or no non-empty) develop settings.
145
+ """
146
+ conn = sqlite3.connect(str(lrcat_path))
147
+ conn.row_factory = sqlite3.Row
148
+ cur = conn.cursor()
149
+
150
+ cur.execute("SELECT id_local FROM AgLibraryFile WHERE baseName = ?", (image_id,))
151
+ row = cur.fetchone()
152
+ if not row:
153
+ conn.close()
154
+ return None
155
+ file_id = row["id_local"]
156
+
157
+ if copy_name is None:
158
+ cur.execute(
159
+ "SELECT id_local FROM Adobe_images WHERE rootFile = ? AND copyName IS NULL",
160
+ (file_id,),
161
+ )
162
+ else:
163
+ cur.execute(
164
+ "SELECT id_local FROM Adobe_images WHERE rootFile = ? AND copyName = ?",
165
+ (file_id, copy_name),
166
+ )
167
+ row = cur.fetchone()
168
+ if not row:
169
+ conn.close()
170
+ return None
171
+ img_id = row["id_local"]
172
+
173
+ cur.execute("SELECT text FROM Adobe_imageDevelopSettings WHERE image = ?", (img_id,))
174
+ dev_rows = cur.fetchall()
175
+ conn.close()
176
+
177
+ text = ""
178
+ if dev_rows:
179
+ for dev in dev_rows:
180
+ t = (dev["text"] or "").strip() if dev["text"] is not None else ""
181
+ if t:
182
+ text = dev["text"] if dev["text"] is not None else ""
183
+ break
184
+ if not text and dev_rows:
185
+ text = dev_rows[0]["text"] or ""
186
+
187
+ if not text.strip():
188
+ return None
189
+ lr = _parse_lr_develop_text(text)
190
+ return _lr_to_recipe(lr)
photo_editor/lrcat/schema.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Edit recipe schema aligned with Lightroom develop settings we can apply in Python.
3
+ All numeric; used as payload in vector DB and for apply-edits.
4
+ """
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Dict, List, Optional
7
+
8
+
9
+ @dataclass
10
+ class EditRecipe:
11
+ """
12
+ Expert edit parameters (from FiveK / Lightroom develop settings).
13
+ Includes all numeric sliders we can parse and apply in Python.
14
+ """
15
+ # Tone
16
+ exposure: float = 0.0
17
+ brightness: float = 0.0 # LR Brightness 0-100
18
+ contrast: float = 0.0
19
+ highlights: float = 0.0 # from -ParametricHighlights (tone slider; LR neg = darken)
20
+ shadows: float = 0.0
21
+ whites: float = 0.0 # ParametricLights
22
+ blacks: float = 0.0 # ParametricDarks
23
+ # White balance
24
+ temperature: float = 0.0 # Kelvin; LR uses Temperature or CustomTemperature
25
+ tint: float = 0.0
26
+ # Color
27
+ vibrance: float = 0.0
28
+ saturation: float = 0.0
29
+ # Tone curve (parametric): 4 points as shadows, darks, lights, highlights (0-255)
30
+ tone_curve: Optional[List[float]] = None # [v1, v2, v3, v4] or None
31
+ # Sharpening
32
+ sharpen_detail: float = 0.0 # SharpenDetail
33
+ sharpen_radius: float = 0.0 # SharpenRadius
34
+ sharpen_edge_masking: float = 0.0 # SharpenEdgeMasking
35
+ # Perspective (LR Perspective*)
36
+ perspective_horizontal: float = 0.0
37
+ perspective_vertical: float = 0.0
38
+ perspective_rotate: float = 0.0
39
+ perspective_scale: float = 100.0
40
+ # Lens
41
+ lens_distortion: float = 0.0 # LensManualDistortionAmount
42
+
43
+ def to_dict(self) -> Dict[str, Any]:
44
+ d = {
45
+ "exposure": self.exposure,
46
+ "brightness": self.brightness,
47
+ "contrast": self.contrast,
48
+ "highlights": self.highlights,
49
+ "shadows": self.shadows,
50
+ "whites": self.whites,
51
+ "blacks": self.blacks,
52
+ "temperature": self.temperature,
53
+ "tint": self.tint,
54
+ "vibrance": self.vibrance,
55
+ "saturation": self.saturation,
56
+ "sharpen_detail": self.sharpen_detail,
57
+ "sharpen_radius": self.sharpen_radius,
58
+ "sharpen_edge_masking": self.sharpen_edge_masking,
59
+ "perspective_horizontal": self.perspective_horizontal,
60
+ "perspective_vertical": self.perspective_vertical,
61
+ "perspective_rotate": self.perspective_rotate,
62
+ "perspective_scale": self.perspective_scale,
63
+ "lens_distortion": self.lens_distortion,
64
+ }
65
+ if self.tone_curve is not None:
66
+ d["tone_curve"] = list(self.tone_curve)
67
+ return d
68
+
69
+ @classmethod
70
+ def from_dict(cls, d: Dict[str, Any]) -> "EditRecipe":
71
+ tc = d.get("tone_curve")
72
+ if isinstance(tc, (list, tuple)) and len(tc) >= 4:
73
+ tc = [float(x) for x in tc[:4]]
74
+ else:
75
+ tc = None
76
+ return cls(
77
+ exposure=float(d.get("exposure", 0)),
78
+ brightness=float(d.get("brightness", 0)),
79
+ contrast=float(d.get("contrast", 0)),
80
+ highlights=float(d.get("highlights", 0)),
81
+ shadows=float(d.get("shadows", 0)),
82
+ whites=float(d.get("whites", 0)),
83
+ blacks=float(d.get("blacks", 0)),
84
+ temperature=float(d.get("temperature", 0)),
85
+ tint=float(d.get("tint", 0)),
86
+ vibrance=float(d.get("vibrance", 0)),
87
+ saturation=float(d.get("saturation", 0)),
88
+ tone_curve=tc,
89
+ sharpen_detail=float(d.get("sharpen_detail", 0)),
90
+ sharpen_radius=float(d.get("sharpen_radius", 0)),
91
+ sharpen_edge_masking=float(d.get("sharpen_edge_masking", 0)),
92
+ perspective_horizontal=float(d.get("perspective_horizontal", 0)),
93
+ perspective_vertical=float(d.get("perspective_vertical", 0)),
94
+ perspective_rotate=float(d.get("perspective_rotate", 0)),
95
+ perspective_scale=float(d.get("perspective_scale", 100)),
96
+ lens_distortion=float(d.get("lens_distortion", 0)),
97
+ )
photo_editor/pipeline/__init__.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ End-to-end pipeline: user image → retrieve + rerank → LLM → apply edits → output.
3
+ """
4
+ from .run import (
5
+ retrieve_similar,
6
+ get_llm_suggestions,
7
+ apply_edits,
8
+ run_pipeline,
9
+ )
10
+
11
+ __all__ = [
12
+ "retrieve_similar",
13
+ "get_llm_suggestions",
14
+ "apply_edits",
15
+ "run_pipeline",
16
+ ]
photo_editor/pipeline/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (473 Bytes). View file
 
photo_editor/pipeline/__pycache__/run.cpython-313.pyc ADDED
Binary file (21.2 kB). View file
 
photo_editor/pipeline/run.py ADDED
@@ -0,0 +1,446 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Pipeline: retrieve similar experts → LLM analyzes and suggests edits → apply (API or local).
3
+ Flow: User image → embed → vector search → rerank by stats → top recipe → LLM (image + recipe) → edits → apply.
4
+ """
5
+ import base64
6
+ import json
7
+ from pathlib import Path
8
+ from typing import Any, Callable, Dict, List, Optional, Tuple
9
+
10
+ import numpy as np
11
+ from PIL import Image, UnidentifiedImageError
12
+
13
+ # Optional HEIC/HEIF decoding support for Pillow.
14
+ try:
15
+ import pillow_heif
16
+
17
+ pillow_heif.register_heif_opener()
18
+ except Exception:
19
+ pillow_heif = None
20
+
21
+ from photo_editor.config import get_settings
22
+ from photo_editor.embeddings import get_embedder
23
+ from photo_editor.images import (
24
+ dng_to_rgb,
25
+ compute_image_stats,
26
+ rerank_score,
27
+ apply_recipe_linear,
28
+ )
29
+ from photo_editor.images.estimate_current_recipe import estimate_current_parameters
30
+ from photo_editor.lrcat.schema import EditRecipe
31
+ from photo_editor.vector_store import AzureSearchVectorStore
32
+
33
+
34
+ def _open_rgb_image(path: Path) -> Image.Image:
35
+ """
36
+ Open an image with Pillow and return RGB. Provide a clear error message
37
+ when HEIC/HEIF support is missing.
38
+ """
39
+ path = Path(path)
40
+ try:
41
+ return Image.open(path).convert("RGB")
42
+ except UnidentifiedImageError as e:
43
+ if path.suffix.lower() in {".heic", ".heif"}:
44
+ raise RuntimeError(
45
+ "HEIC/HEIF decoding is unavailable in this environment. "
46
+ "Install pillow-heif (`pip install pillow-heif`) and restart."
47
+ ) from e
48
+ raise
49
+
50
+
51
+ def load_image_rgb_224(path: Path) -> np.ndarray:
52
+ """Load as float32 RGB [0, 1] HWC at 224x224 (same as index)."""
53
+ path = Path(path)
54
+ if path.suffix.lower() == ".dng":
55
+ return dng_to_rgb(path, output_size=(224, 224))
56
+ img = _open_rgb_image(path)
57
+ img = img.resize((224, 224), Image.Resampling.BILINEAR)
58
+ return np.array(img, dtype=np.float32) / 255.0
59
+
60
+
61
+ def load_image_rgb_full(path: Path) -> np.ndarray:
62
+ """Load full-resolution RGB float32 [0, 1] for editing."""
63
+ path = Path(path)
64
+ if path.suffix.lower() == ".dng":
65
+ return dng_to_rgb(path, output_size=None)
66
+ img = _open_rgb_image(path)
67
+ return np.array(img, dtype=np.float32) / 255.0
68
+
69
+
70
+ def image_to_base64(path: Path, max_size: Optional[Tuple[int, int]] = None) -> str:
71
+ """Encode image to base64 for LLM. Optionally resize to max_size to limit payload."""
72
+ path = Path(path)
73
+ img = _open_rgb_image(path)
74
+ if max_size:
75
+ img.thumbnail(max_size, Image.Resampling.LANCZOS)
76
+ import io
77
+ buf = io.BytesIO()
78
+ img.save(buf, format="JPEG", quality=90)
79
+ return base64.b64encode(buf.getvalue()).decode("utf-8")
80
+
81
+
82
+ def retrieve_similar(
83
+ image_path: Path,
84
+ top_k: int = 50,
85
+ top_n: int = 5,
86
+ ) -> List[Dict[str, Any]]:
87
+ """
88
+ Load image, embed, search Azure AI Search, rerank by lighting/color stats.
89
+ Returns list of {image_id, recipe (dict), rerank_score}.
90
+ """
91
+ s = get_settings()
92
+ if not s.azure_search_configured():
93
+ raise RuntimeError("Azure AI Search not configured. Set AZURE_SEARCH_* in .env")
94
+
95
+ rgb = load_image_rgb_224(image_path)
96
+ user_stats = compute_image_stats(rgb)
97
+
98
+ embedder = get_embedder()
99
+ vector = embedder.encode_image(rgb).tolist()
100
+ store = AzureSearchVectorStore()
101
+ hits = store.search(vector, top_k=top_k)
102
+
103
+ if not hits:
104
+ return []
105
+
106
+ for h in hits:
107
+ raw = h.get("image_stats")
108
+ if isinstance(raw, str):
109
+ try:
110
+ h["_stats"] = json.loads(raw)
111
+ except json.JSONDecodeError:
112
+ h["_stats"] = {}
113
+ else:
114
+ h["_stats"] = raw or {}
115
+
116
+ scored: List[Tuple[float, Dict]] = [
117
+ (rerank_score(user_stats, h["_stats"]), h)
118
+ for h in hits
119
+ ]
120
+ scored.sort(key=lambda x: -x[0])
121
+ top = scored[:top_n]
122
+
123
+ out = []
124
+ for score, h in top:
125
+ recipe_str = h.get("recipe") or "{}"
126
+ try:
127
+ recipe = json.loads(recipe_str)
128
+ except json.JSONDecodeError:
129
+ recipe = {}
130
+ out.append({
131
+ "image_id": h.get("image_id", ""),
132
+ "recipe": recipe,
133
+ "rerank_score": score,
134
+ })
135
+ return out
136
+
137
+
138
+ def get_llm_suggestions(
139
+ base64_img: str,
140
+ expert_recipe: Dict[str, Any],
141
+ current_parameters: Optional[Dict[str, Any]] = None,
142
+ image_stats: Optional[Dict[str, Any]] = None,
143
+ ) -> Dict[str, Any]:
144
+ """
145
+ Call Azure OpenAI with user image + expert recipe; return JSON with
146
+ summary and suggested_edits (keys expected by editing API or local apply).
147
+ """
148
+ from openai import AzureOpenAI
149
+
150
+ s = get_settings()
151
+ if not s.azure_openai_configured():
152
+ raise RuntimeError("Azure OpenAI not configured. Set AZURE_OPENAI_* in .env")
153
+
154
+ required_keys = [
155
+ "exposure", "contrast", "highlights", "shadows",
156
+ "whites", "blacks", "temperature", "tint",
157
+ "vibrance", "saturation",
158
+ ]
159
+
160
+ system_prompt = f"""
161
+ You are a professional photo editing agent.
162
+ Analyze the user image together with the provided expert recipe and decide on global edits
163
+ that will make the photo look more polished and visually pleasing.
164
+
165
+ Write the "summary" as a clear, photographer‑friendly explanation formatted as **Markdown**:
166
+ - Focus on **reasoning** (why each change helps visually), not just repeating numbers.
167
+ - Start with 1–2 normal sentences diagnosing the image (exposure, contrast, color balance, mood, subject separation, etc.).
168
+ - Then include **3–6 Markdown bullet points**, each in this pattern:
169
+ `- **Adjustment name**: visual problem -> reasoning -> expected visual impact.`
170
+ - Use **bold** to emphasize key visual issues and adjustment names.
171
+ - End with 1 concise sentence describing the final look/mood.
172
+ Do NOT use code fences in the summary.
173
+
174
+ IMPORTANT: You must output a JSON object with exactly two fields:
175
+ 1. "summary": The Markdown explanation described above. Keep it rationale-first and avoid listing current/suggested/delta values line-by-line.
176
+ 2. "suggested_edits": A dictionary containing ALL of these specific keys: {required_keys}. You may use the expert recipe and the image itself to choose these values; the current parameters are only for explanation, not constraints.
177
+ Use numeric values appropriate for a photo editing API (e.g. exposure in EV or scale, temperature in Kelvin).
178
+ """
179
+
180
+ user_prompt = f"""
181
+ Reference Expert Recipe (single aggregated guidance recipe): {json.dumps(expert_recipe)}
182
+
183
+ Approximate current global parameters for the uploaded image (may be noisy but in the same units as your sliders):
184
+ {json.dumps(current_parameters or {}, ensure_ascii=False)}
185
+
186
+ Technical image statistics (brightness/clipping/saturation):
187
+ {json.dumps(image_stats or {}, ensure_ascii=False)}
188
+
189
+ Please analyze my image and provide the JSON output described above.
190
+ """
191
+
192
+ client = AzureOpenAI(
193
+ azure_endpoint=s.azure_openai_endpoint,
194
+ api_key=s.azure_openai_key,
195
+ api_version=s.azure_openai_api_version,
196
+ )
197
+
198
+ response = client.chat.completions.create(
199
+ model=s.azure_openai_deployment,
200
+ messages=[
201
+ {"role": "system", "content": system_prompt},
202
+ {
203
+ "role": "user",
204
+ "content": [
205
+ {"type": "text", "text": user_prompt},
206
+ {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{base64_img}"}},
207
+ ],
208
+ },
209
+ ],
210
+ response_format={"type": "json_object"},
211
+ )
212
+
213
+ result = json.loads(response.choices[0].message.content)
214
+ return result
215
+
216
+
217
+ def _llm_edits_to_recipe(edits: Dict[str, Any]) -> EditRecipe:
218
+ """Map LLM suggested_edits to EditRecipe. Normalize keys and types."""
219
+ # LLM may use different scales; we map to our schema (exposure in EV, temperature in K, etc.)
220
+ return EditRecipe(
221
+ exposure=float(edits.get("exposure", 0)),
222
+ brightness=float(edits.get("brightness", 0)),
223
+ contrast=float(edits.get("contrast", 0)),
224
+ highlights=float(edits.get("highlights", 0)),
225
+ shadows=float(edits.get("shadows", 0)),
226
+ whites=float(edits.get("whites", 0)),
227
+ blacks=float(edits.get("blacks", 0)),
228
+ temperature=float(edits.get("temperature", 0)),
229
+ tint=float(edits.get("tint", 0)),
230
+ vibrance=float(edits.get("vibrance", 0)),
231
+ saturation=float(edits.get("saturation", 0)),
232
+ )
233
+
234
+
235
+ def _safe_float(v: Any, default: float = 0.0) -> float:
236
+ try:
237
+ return float(v)
238
+ except (TypeError, ValueError):
239
+ return default
240
+
241
+
242
+ def _apply_brightness_guardrails(
243
+ suggested_edits: Dict[str, Any],
244
+ current_parameters: Optional[Dict[str, Any]],
245
+ ) -> Dict[str, Any]:
246
+ """
247
+ Prevent overly bright outputs by constraining key tone sliders relative
248
+ to estimated current values. This keeps edits natural while still allowing
249
+ meaningful changes.
250
+ """
251
+ cur = current_parameters or {}
252
+ out: Dict[str, Any] = dict(suggested_edits or {})
253
+
254
+ # Exposure: cap brightening jump and keep within sane absolute EV range.
255
+ cur_exp = _safe_float(cur.get("exposure", 0.0), 0.0)
256
+ sug_exp = _safe_float(out.get("exposure", cur_exp), cur_exp)
257
+ sug_exp = float(np.clip(sug_exp, cur_exp - 0.7, cur_exp + 0.6))
258
+ sug_exp = float(np.clip(sug_exp, -2.0, 2.0))
259
+ out["exposure"] = round(sug_exp, 4)
260
+
261
+ def clamp_relative(
262
+ key: str,
263
+ up: float,
264
+ down: float,
265
+ abs_min: float = -100.0,
266
+ abs_max: float = 100.0,
267
+ ) -> None:
268
+ cur_v = _safe_float(cur.get(key, 0.0), 0.0)
269
+ sug_v = _safe_float(out.get(key, cur_v), cur_v)
270
+ sug_v = float(np.clip(sug_v, cur_v - down, cur_v + up))
271
+ sug_v = float(np.clip(sug_v, abs_min, abs_max))
272
+ out[key] = round(sug_v, 4)
273
+
274
+ # Tone sliders most responsible for over-bright output.
275
+ clamp_relative("whites", up=15.0, down=25.0, abs_min=-60.0, abs_max=60.0)
276
+ clamp_relative("highlights", up=12.0, down=35.0, abs_min=-60.0, abs_max=60.0)
277
+ clamp_relative("shadows", up=25.0, down=20.0, abs_min=-60.0, abs_max=60.0)
278
+ clamp_relative("contrast", up=20.0, down=25.0, abs_min=-60.0, abs_max=60.0)
279
+ clamp_relative("vibrance", up=18.0, down=20.0, abs_min=-50.0, abs_max=50.0)
280
+ clamp_relative("saturation", up=15.0, down=20.0, abs_min=-50.0, abs_max=50.0)
281
+
282
+ # Temperature: avoid excessive warming/cooling jumps.
283
+ cur_temp = _safe_float(cur.get("temperature", 5500.0), 5500.0)
284
+ sug_temp = _safe_float(out.get("temperature", cur_temp), cur_temp)
285
+ sug_temp = float(np.clip(sug_temp, cur_temp - 1200.0, cur_temp + 1200.0))
286
+ sug_temp = float(np.clip(sug_temp, 2500.0, 12000.0))
287
+ out["temperature"] = round(sug_temp, 2)
288
+
289
+ return out
290
+
291
+
292
+ def _mean_recipe_from_candidates(candidates: List[Dict[str, Any]]) -> Dict[str, Any]:
293
+ """
294
+ Build one aggregated recipe by averaging numeric fields across candidates.
295
+ Non-numeric fields are ignored.
296
+ """
297
+ sums: Dict[str, float] = {}
298
+ counts: Dict[str, int] = {}
299
+
300
+ for c in candidates:
301
+ recipe = c.get("recipe", {}) or {}
302
+ if not isinstance(recipe, dict):
303
+ continue
304
+ for k, v in recipe.items():
305
+ try:
306
+ fv = float(v)
307
+ except (TypeError, ValueError):
308
+ continue
309
+ sums[k] = sums.get(k, 0.0) + fv
310
+ counts[k] = counts.get(k, 0) + 1
311
+
312
+ out: Dict[str, Any] = {}
313
+ for k, total in sums.items():
314
+ cnt = counts.get(k, 0)
315
+ if cnt > 0:
316
+ out[k] = total / cnt
317
+ return out
318
+
319
+
320
+ def apply_edits(
321
+ image_path: Path,
322
+ edit_parameters: Dict[str, Any],
323
+ output_path: Path,
324
+ use_api: bool = False,
325
+ ) -> bool:
326
+ """
327
+ Apply edits to image and save. If use_api and EDITING_API_URL set, call external API;
328
+ else apply locally with apply_recipe_linear (no baseline).
329
+ """
330
+ if use_api:
331
+ s = get_settings()
332
+ if not s.editing_api_configured():
333
+ raise RuntimeError("EDITING_API_URL not set. Use --local to apply edits locally.")
334
+ import requests
335
+ with open(image_path, "rb") as f:
336
+ base64_img = base64.b64encode(f.read()).decode("utf-8")
337
+ payload = {"image": base64_img, "recipe": edit_parameters}
338
+ try:
339
+ resp = requests.post(s.editing_api_url, json=payload, timeout=60)
340
+ if resp.status_code != 200:
341
+ return False
342
+ result = resp.json()
343
+ edited_b64 = result.get("edited_image")
344
+ if not edited_b64:
345
+ return False
346
+ img_data = base64.b64decode(edited_b64)
347
+ output_path.parent.mkdir(parents=True, exist_ok=True)
348
+ with open(output_path, "wb") as f:
349
+ f.write(img_data)
350
+ return True
351
+ except Exception:
352
+ return False
353
+
354
+ # Local apply
355
+ rgb = load_image_rgb_full(image_path)
356
+ recipe = _llm_edits_to_recipe(edit_parameters)
357
+ edited = apply_recipe_linear(rgb, recipe, baseline_recipe=None)
358
+ output_path.parent.mkdir(parents=True, exist_ok=True)
359
+ out_u8 = (np.clip(edited, 0, 1) * 255).astype(np.uint8)
360
+ out_img = Image.fromarray(out_u8)
361
+ suffix = output_path.suffix.lower()
362
+ if suffix in {".jpg", ".jpeg"}:
363
+ # Save JPEG with higher quality to reduce compression artifacts.
364
+ out_img.save(output_path, quality=95, subsampling=0, optimize=True)
365
+ else:
366
+ out_img.save(output_path)
367
+ return True
368
+
369
+
370
+ def run_pipeline(
371
+ image_path: Path,
372
+ output_path: Path,
373
+ top_k: int = 50,
374
+ top_n: int = 1,
375
+ use_editing_api: bool = False,
376
+ max_image_size_llm: Optional[Tuple[int, int]] = (1024, 1024),
377
+ use_multi_expert_context: bool = False,
378
+ context_top_n: int = 5,
379
+ use_brightness_guardrail: bool = True,
380
+ progress_callback: Optional[Callable[[str], None]] = None,
381
+ ) -> Dict[str, Any]:
382
+ """
383
+ Full pipeline: retrieve similar → LLM → apply → save.
384
+ Returns dict with summary, suggested_edits, expert_recipe_used, success.
385
+ """
386
+ image_path = Path(image_path)
387
+ output_path = Path(output_path)
388
+ if not image_path.exists():
389
+ raise FileNotFoundError(f"Image not found: {image_path}")
390
+
391
+ if progress_callback:
392
+ progress_callback("retrieving")
393
+
394
+ # 1) Retrieve + rerank
395
+ effective_top_n = max(top_n, context_top_n if use_multi_expert_context else top_n)
396
+ results = retrieve_similar(image_path, top_k=top_k, top_n=effective_top_n)
397
+ if not results:
398
+ raise RuntimeError("No similar images found in index. Run build_vector_index.py and ensure Azure Search is configured.")
399
+
400
+ expert = results[0]
401
+ expert_recipe = expert["recipe"]
402
+ expert_candidates_used = results[: max(1, context_top_n)] if use_multi_expert_context else [expert]
403
+ recipe_for_llm = (
404
+ _mean_recipe_from_candidates(expert_candidates_used)
405
+ if use_multi_expert_context and len(expert_candidates_used) > 1
406
+ else expert_recipe
407
+ )
408
+
409
+ if progress_callback:
410
+ progress_callback("consulting")
411
+
412
+ # 2) LLM
413
+ base64_img = image_to_base64(image_path, max_size=max_image_size_llm)
414
+ # Estimate current parameters for explanation only; suggested_edits still come
415
+ # from the expert recipe + image analysis.
416
+ current_params = estimate_current_parameters(image_path)
417
+ user_stats = compute_image_stats(load_image_rgb_224(image_path))
418
+ llm_out = get_llm_suggestions(
419
+ base64_img,
420
+ recipe_for_llm,
421
+ current_parameters=current_params,
422
+ image_stats=user_stats,
423
+ )
424
+ summary = llm_out.get("summary", "")
425
+ suggested_edits = llm_out.get("suggested_edits", {})
426
+ if use_brightness_guardrail:
427
+ suggested_edits = _apply_brightness_guardrails(suggested_edits, current_params)
428
+
429
+ if progress_callback:
430
+ progress_callback("applying")
431
+
432
+ # 3) Apply and save
433
+ success = apply_edits(image_path, suggested_edits, output_path, use_api=use_editing_api)
434
+
435
+ if progress_callback:
436
+ progress_callback("done")
437
+
438
+ return {
439
+ "summary": summary,
440
+ "suggested_edits": suggested_edits,
441
+ "expert_recipe_used": recipe_for_llm,
442
+ "expert_candidates_used": expert_candidates_used,
443
+ "expert_image_id": expert.get("image_id", ""),
444
+ "success": success,
445
+ "output_path": str(output_path) if success else None,
446
+ }
photo_editor/vector_store/__init__.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ from .azure_search import (
2
+ AzureSearchVectorStore,
3
+ ensure_index,
4
+ get_index_stats,
5
+ upload_documents,
6
+ )
7
+
8
+ __all__ = ["AzureSearchVectorStore", "ensure_index", "get_index_stats", "upload_documents"]
photo_editor/vector_store/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (380 Bytes). View file
 
photo_editor/vector_store/__pycache__/azure_search.cpython-313.pyc ADDED
Binary file (10.2 kB). View file
 
photo_editor/vector_store/azure_search.py ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Azure AI Search vector index for FiveK embeddings + recipe payload.
3
+ Used by: build script (upload), future API (query for RAG).
4
+ """
5
+ import base64
6
+ import json
7
+ import logging
8
+ from typing import Any, Dict, List, Optional
9
+
10
+ from photo_editor.config import get_settings
11
+ from photo_editor.lrcat.schema import EditRecipe
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ def _safe_document_key(image_id: str) -> str:
17
+ """Azure Search keys allow only letters, digits, _, -, =. Use URL-safe base64."""
18
+ return base64.urlsafe_b64encode(image_id.encode("utf-8")).decode("ascii").rstrip("=")
19
+
20
+
21
+ def _get_client():
22
+ from azure.core.credentials import AzureKeyCredential
23
+ from azure.search.documents import SearchClient
24
+ s = get_settings()
25
+ if not s.azure_search_configured():
26
+ raise RuntimeError(
27
+ "Azure AI Search not configured. Set AZURE_SEARCH_ENDPOINT and AZURE_SEARCH_KEY in .env"
28
+ )
29
+ return SearchClient(
30
+ endpoint=s.azure_search_endpoint,
31
+ index_name=s.azure_search_index_name,
32
+ credential=AzureKeyCredential(s.azure_search_key),
33
+ )
34
+
35
+
36
+ def ensure_index(dimension: int) -> None:
37
+ """
38
+ Create the index if it does not exist, or recreate it if the embedding dimension
39
+ does not match (e.g. switched from CLIP 512 to Azure Vision 1024).
40
+ Schema: id (key), image_id, embedding (vector), recipe (JSON string), optional metadata.
41
+ """
42
+ from azure.core.credentials import AzureKeyCredential
43
+ from azure.search.documents.indexes import SearchIndexClient
44
+ from azure.search.documents.indexes.models import (
45
+ SearchField,
46
+ SearchFieldDataType,
47
+ SearchIndex,
48
+ VectorSearch,
49
+ HnswAlgorithmConfiguration,
50
+ VectorSearchProfile,
51
+ )
52
+ s = get_settings()
53
+ if not s.azure_search_configured():
54
+ raise RuntimeError("Set AZURE_SEARCH_ENDPOINT and AZURE_SEARCH_KEY")
55
+ client = SearchIndexClient(
56
+ endpoint=s.azure_search_endpoint,
57
+ credential=AzureKeyCredential(s.azure_search_key),
58
+ )
59
+ index_name = s.azure_search_index_name
60
+ existing_names = [idx.name for idx in client.list_indexes()]
61
+
62
+ if index_name in existing_names:
63
+ existing = client.get_index(index_name)
64
+ current_dim: Optional[int] = None
65
+ for f in existing.fields:
66
+ if f.name == "embedding" and getattr(f, "vector_search_dimensions", None) is not None:
67
+ current_dim = f.vector_search_dimensions
68
+ break
69
+ if current_dim == dimension:
70
+ field_names = [f.name for f in existing.fields]
71
+ if "image_stats" not in field_names:
72
+ # Add lighting/color stats field for reranking
73
+ new_fields = list(existing.fields) + [
74
+ SearchField(name="image_stats", type=SearchFieldDataType.String),
75
+ ]
76
+ updated = SearchIndex(
77
+ name=existing.name,
78
+ fields=new_fields,
79
+ vector_search=existing.vector_search,
80
+ )
81
+ client.create_or_update_index(updated)
82
+ logger.info("Added image_stats field to existing index '%s'.", index_name)
83
+ else:
84
+ logger.info("Index '%s' already exists with embedding dimension %s.", index_name, dimension)
85
+ return
86
+ if current_dim is not None:
87
+ logger.warning(
88
+ "Index '%s' has embedding dimension %s but embedder has dimension %s (e.g. switched CLIP↔Azure Vision). Recreating index.",
89
+ index_name,
90
+ current_dim,
91
+ dimension,
92
+ )
93
+ else:
94
+ logger.warning("Index '%s' exists but embedding dimension could not be read. Recreating index.", index_name)
95
+ client.delete_index(index_name)
96
+
97
+ fields = [
98
+ SearchField(name="id", type=SearchFieldDataType.String, key=True),
99
+ SearchField(name="image_id", type=SearchFieldDataType.String, filterable=True),
100
+ SearchField(
101
+ name="embedding",
102
+ type=SearchFieldDataType.Collection(SearchFieldDataType.Single),
103
+ searchable=True,
104
+ vector_search_dimensions=dimension,
105
+ vector_search_profile_name="default-vector-profile",
106
+ ),
107
+ SearchField(name="recipe", type=SearchFieldDataType.String), # JSON
108
+ SearchField(name="image_stats", type=SearchFieldDataType.String), # JSON: brightness/clip/saturation
109
+ ]
110
+ vector_search = VectorSearch(
111
+ algorithms=[HnswAlgorithmConfiguration(name="hnsw")],
112
+ profiles=[VectorSearchProfile(name="default-vector-profile", algorithm_configuration_name="hnsw")],
113
+ )
114
+ index = SearchIndex(name=index_name, fields=fields, vector_search=vector_search)
115
+ client.create_index(index)
116
+ logger.info("Created index '%s' with embedding dimension %s.", index_name, dimension)
117
+
118
+
119
+ def _doc(
120
+ image_id: str,
121
+ embedding: List[float],
122
+ recipe: EditRecipe,
123
+ image_stats: Optional[Dict[str, Any]] = None,
124
+ ) -> Dict[str, Any]:
125
+ doc: Dict[str, Any] = {
126
+ "id": _safe_document_key(image_id),
127
+ "image_id": image_id,
128
+ "embedding": embedding,
129
+ "recipe": json.dumps(recipe.to_dict()),
130
+ }
131
+ if image_stats is not None:
132
+ doc["image_stats"] = json.dumps(image_stats)
133
+ return doc
134
+
135
+
136
+ def get_index_stats() -> Dict[str, Any]:
137
+ """
138
+ Return document count and a sample of document IDs from the index.
139
+ Use to verify that uploads reached Azure AI Search.
140
+ """
141
+ client = _get_client()
142
+ results = client.search(
143
+ search_text="*",
144
+ select=["id", "image_id"],
145
+ include_total_count=True,
146
+ top=100,
147
+ )
148
+ total = results.get_count()
149
+ sample = [dict(r) for r in results]
150
+ return {
151
+ "total_documents": total,
152
+ "sample": sample[:10],
153
+ }
154
+
155
+
156
+ def upload_documents(
157
+ image_ids: List[str],
158
+ embeddings: List[List[float]],
159
+ recipes: List[EditRecipe],
160
+ stats_list: Optional[List[Dict[str, Any]]] = None,
161
+ batch_size: int = 100,
162
+ ) -> None:
163
+ """Upload documents to the index. Lengths of image_ids, embeddings, recipes must match.
164
+ If stats_list is provided, its length must match; each element is stored as image_stats (JSON)."""
165
+ if not (len(image_ids) == len(embeddings) == len(recipes)):
166
+ raise ValueError("image_ids, embeddings, recipes must have same length")
167
+ if stats_list is not None and len(stats_list) != len(image_ids):
168
+ raise ValueError("stats_list length must match image_ids when provided")
169
+ client = _get_client()
170
+ for i in range(0, len(image_ids), batch_size):
171
+ batch_ids = image_ids[i : i + batch_size]
172
+ batch_emb = embeddings[i : i + batch_size]
173
+ batch_rec = recipes[i : i + batch_size]
174
+ batch_stats = (stats_list[i : i + batch_size]) if stats_list else None
175
+ docs = [
176
+ _doc(
177
+ imid, emb, rec,
178
+ image_stats=batch_stats[j] if batch_stats else None,
179
+ )
180
+ for j, (imid, emb, rec) in enumerate(zip(batch_ids, batch_emb, batch_rec))
181
+ ]
182
+ client.upload_documents(documents=docs)
183
+
184
+
185
+ class AzureSearchVectorStore:
186
+ """Query-side helper for the inference pipeline (retrieve similar images by embedding)."""
187
+
188
+ def __init__(self) -> None:
189
+ self._client: Optional[Any] = None
190
+
191
+ def _client_get(self):
192
+ if self._client is None:
193
+ self._client = _get_client()
194
+ return self._client
195
+
196
+ def search(
197
+ self,
198
+ vector: List[float],
199
+ top_k: int = 10,
200
+ filter_expr: Optional[str] = None,
201
+ ) -> List[Dict[str, Any]]:
202
+ """Return top_k nearest documents; each has image_id, recipe (JSON string)."""
203
+ from azure.search.documents.models import VectorizedQuery
204
+ client = self._client_get()
205
+ results = client.search(
206
+ search_text=None,
207
+ vector_queries=[
208
+ VectorizedQuery(
209
+ vector=vector,
210
+ k_nearest_neighbors=top_k,
211
+ fields="embedding",
212
+ )
213
+ ],
214
+ filter=filter_expr,
215
+ select=["image_id", "recipe", "image_stats"],
216
+ )
217
+ return [dict(r) for r in results]
requirements.txt ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Config and env
2
+ python-dotenv>=1.0.0
3
+ tqdm>=4.65.0
4
+
5
+ # DNG / raw development
6
+ rawpy>=0.19.0
7
+ numpy>=1.24.0
8
+
9
+ # Image handling
10
+ Pillow>=10.0.0
11
+ pillow-heif>=0.16.0
12
+ scikit-image>=0.21.0
13
+
14
+ # Embeddings (CLIP via transformers)
15
+ torch>=2.0.0
16
+ transformers>=4.30.0
17
+
18
+ # Azure AI Search
19
+ azure-search-documents>=11.4.0
20
+
21
+ # Pipeline: Azure OpenAI (LLM) + optional editing API
22
+ openai>=1.0.0
23
+ requests>=2.28.0
24
+
25
+ # Streamlit UI
26
+ streamlit>=1.28.0