Spaces:
Running
Running
Deploy Docker Streamlit app to HF Space
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +28 -0
- .env.example +30 -0
- .gitignore +1 -0
- .streamlit/config.toml +7 -0
- Dockerfile +24 -0
- README.md +226 -3
- app.py +351 -0
- photo_editor/__init__.py +14 -0
- photo_editor/__pycache__/__init__.cpython-313.pyc +0 -0
- photo_editor/config/__init__.py +3 -0
- photo_editor/config/__pycache__/__init__.cpython-313.pyc +0 -0
- photo_editor/config/__pycache__/settings.cpython-313.pyc +0 -0
- photo_editor/config/settings.py +110 -0
- photo_editor/dataset/__init__.py +4 -0
- photo_editor/dataset/__pycache__/__init__.cpython-313.pyc +0 -0
- photo_editor/dataset/__pycache__/paths.cpython-313.pyc +0 -0
- photo_editor/dataset/__pycache__/subset.cpython-313.pyc +0 -0
- photo_editor/dataset/paths.py +53 -0
- photo_editor/dataset/subset.py +19 -0
- photo_editor/embeddings/__init__.py +3 -0
- photo_editor/embeddings/__pycache__/__init__.cpython-313.pyc +0 -0
- photo_editor/embeddings/__pycache__/embedder.cpython-313.pyc +0 -0
- photo_editor/embeddings/embedder.py +208 -0
- photo_editor/images/__init__.py +13 -0
- photo_editor/images/__pycache__/__init__.cpython-313.pyc +0 -0
- photo_editor/images/__pycache__/apply_recipe.cpython-313.pyc +0 -0
- photo_editor/images/__pycache__/apply_recipe_linear.cpython-313.pyc +0 -0
- photo_editor/images/__pycache__/dng_to_rgb.cpython-313.pyc +0 -0
- photo_editor/images/__pycache__/estimate_current_recipe.cpython-313.pyc +0 -0
- photo_editor/images/__pycache__/image_stats.cpython-313.pyc +0 -0
- photo_editor/images/apply_recipe.py +169 -0
- photo_editor/images/apply_recipe_linear.py +101 -0
- photo_editor/images/dng_to_rgb.py +103 -0
- photo_editor/images/estimate_current_recipe.py +124 -0
- photo_editor/images/image_stats.py +92 -0
- photo_editor/lrcat/__init__.py +13 -0
- photo_editor/lrcat/__pycache__/__init__.cpython-313.pyc +0 -0
- photo_editor/lrcat/__pycache__/extract.cpython-313.pyc +0 -0
- photo_editor/lrcat/__pycache__/schema.cpython-313.pyc +0 -0
- photo_editor/lrcat/extract.py +190 -0
- photo_editor/lrcat/schema.py +97 -0
- photo_editor/pipeline/__init__.py +16 -0
- photo_editor/pipeline/__pycache__/__init__.cpython-313.pyc +0 -0
- photo_editor/pipeline/__pycache__/run.cpython-313.pyc +0 -0
- photo_editor/pipeline/run.py +446 -0
- photo_editor/vector_store/__init__.py +8 -0
- photo_editor/vector_store/__pycache__/__init__.cpython-313.pyc +0 -0
- photo_editor/vector_store/__pycache__/azure_search.cpython-313.pyc +0 -0
- photo_editor/vector_store/azure_search.py +217 -0
- 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:
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|