Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .agents/skills/convert-heic-to-jpeg/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
---
name: convert-heic-to-jpeg
description: Convert HEIC or HEIF images to JPEG with `uv run` using inline script dependencies. Use this when new phone photos are added under `pictures/` and need to be converted before indexing or upload.
argument-hint: "[path] [--quality 90] [--overwrite]"
---

# Convert HEIC To JPEG

Use this skill when you need to convert `.heic` or `.heif` images into `.jpg` files for local development workflows.

The script for this skill lives at [./convert_heic_to_jpeg.py](./convert_heic_to_jpeg.py) and is designed to be run with `uv run`.

## What this skill does

- Converts a single HEIC or HEIF file to JPEG.
- Recursively scans a directory for HEIC or HEIF files.
- Writes JPEG files next to the source images by default.
- Can write output into a separate directory.
- Skips existing JPEG files unless `--overwrite` is passed.

## How to run it

Run the script with `uv run` so the inline PEP 723 dependencies are installed automatically:

```bash
uv run .agents/skills/convert-heic-to-jpeg/convert_heic_to_jpeg.py pictures
```

Convert one file:

```bash
uv run .agents/skills/convert-heic-to-jpeg/convert_heic_to_jpeg.py pictures/clothes/IMG_3233.HEIC
```

Convert into a separate output directory:

```bash
uv run .agents/skills/convert-heic-to-jpeg/convert_heic_to_jpeg.py pictures --output-dir converted-pictures
```

Overwrite existing JPEG files:

```bash
uv run .agents/skills/convert-heic-to-jpeg/convert_heic_to_jpeg.py pictures --overwrite
```

## Recommended Workflow

1. Point the script at `pictures/` or a subdirectory such as `pictures/clothes/`.
2. Review the summary output to confirm how many files were converted or skipped.
3. Re-run with `--overwrite` only if you intentionally want to replace existing JPEG outputs.

## Notes

- This skill is stored in `.agents/skills`, which VS Code documents as a default project skill location.
- The script depends on Pillow and `pillow-heif`, declared inline in the script so they can be resolved by `uv run`.
162 changes: 162 additions & 0 deletions .agents/skills/convert-heic-to-jpeg/convert_heic_to_jpeg.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "pillow>=11.0.0",
# "pillow-heif>=0.22.0",
# ]
# ///

"""Convert HEIC and HEIF images to JPEG format."""

from __future__ import annotations

import argparse
from dataclasses import dataclass
from pathlib import Path

from PIL import Image
import pillow_heif

# Register HEIF support with Pillow before opening files.
pillow_heif.register_heif_opener()

SUPPORTED_EXTENSIONS = {".heic", ".heif"}


@dataclass(frozen=True)
class ConversionResult:
"""Capture the outcome for a single source image."""

source_path: Path
output_path: Path
status: str


def is_heif_file(path: Path) -> bool:
"""Return whether the path has a supported HEIF extension."""
return path.suffix.lower() in SUPPORTED_EXTENSIONS


def build_output_path(
source_path: Path, input_root: Path, output_dir: Path | None
) -> Path:
"""Build the destination JPEG path for a source HEIF image."""
if output_dir is None:
return source_path.with_suffix(".jpg")

if input_root.is_file():
return output_dir / f"{source_path.stem}.jpg"

relative_path = source_path.relative_to(input_root).with_suffix(".jpg")
return output_dir / relative_path


def save_as_jpeg(source_path: Path, output_path: Path, quality: int) -> None:
"""Open a HEIF image and save it as JPEG."""
output_path.parent.mkdir(parents=True, exist_ok=True)

with Image.open(source_path) as image:
if image.mode not in ("RGB", "L"):
image = image.convert("RGB")
image.save(output_path, "JPEG", quality=quality)


def convert_heic_to_jpeg(
source_path: Path,
input_root: Path,
output_dir: Path | None = None,
quality: int = 90,
overwrite: bool = False,
) -> ConversionResult:
"""Convert one HEIF image to JPEG."""
output_path = build_output_path(source_path, input_root, output_dir)

if output_path.exists() and not overwrite:
return ConversionResult(
source_path=source_path, output_path=output_path, status="skipped"
)

save_as_jpeg(source_path, output_path, quality)
return ConversionResult(
source_path=source_path, output_path=output_path, status="converted"
)


def gather_source_files(input_path: Path) -> list[Path]:
"""Return all HEIF images under the given file or directory."""
if input_path.is_file():
if not is_heif_file(input_path):
raise ValueError(f"Unsupported input file: {input_path}")
return [input_path]

return sorted(
path for path in input_path.rglob("*") if path.is_file() and is_heif_file(path)
)


def parse_args() -> argparse.Namespace:
"""Parse command-line arguments."""
parser = argparse.ArgumentParser(description="Convert HEIC or HEIF images to JPEG")
parser.add_argument("input", type=Path, help="Input HEIC/HEIF file or directory")
parser.add_argument(
"-o",
"--output-dir",
type=Path,
default=None,
help="Optional output directory for JPEG files",
)
parser.add_argument(
"-q",
"--quality",
type=int,
default=90,
help="JPEG quality from 1 to 100 (default: 90)",
)
parser.add_argument(
"--overwrite",
action="store_true",
help="Replace existing JPEG files instead of skipping them",
)
return parser.parse_args()


def main() -> int:
"""Run the CLI conversion workflow."""
args = parse_args()

if not args.input.exists():
raise FileNotFoundError(f"Input path does not exist: {args.input}")

if not 1 <= args.quality <= 100:
raise ValueError("Quality must be between 1 and 100")

source_files = gather_source_files(args.input)
if not source_files:
print("No HEIC or HEIF files found.")
return 0

converted_count = 0
skipped_count = 0

for source_path in source_files:
result = convert_heic_to_jpeg(
source_path=source_path,
input_root=args.input,
output_dir=args.output_dir,
quality=args.quality,
overwrite=args.overwrite,
)
print(f"{result.status:9} {result.source_path} -> {result.output_path}")
if result.status == "converted":
converted_count += 1
else:
skipped_count += 1

print(
f"Summary: converted={converted_count} skipped={skipped_count} total={len(source_files)}"
)
return 0


if __name__ == "__main__":
raise SystemExit(main())
2 changes: 1 addition & 1 deletion .github/workflows/python.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ jobs:
fail-fast: false
matrix:
os: ["ubuntu-latest"]
python_version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
python_version: ["3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v5
- name: Install uv
Expand Down
12 changes: 12 additions & 0 deletions .vscode/mcp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"servers": {
"image-search-local": {
"type": "http",
"url": "http://localhost:8001/mcp"
},
"image-search-azure": {
"type": "http",
"url": "https://<your-mcp-container-app>/mcp"
}
Comment thread
pamelafox marked this conversation as resolved.
}
}
43 changes: 41 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Image search with Azure AI Search

This project creates a simple image search application using Azure AI Search. The application allows you to search for images with a textual query, and searches using a multimodal embedding from the Azure AI Vision API. The frontend is built with TypeScript/React and the backend is built with Python/Quart.
This project creates an image search web application and an [MCP (Model Context Protocol)](https://modelcontextprotocol.io) server, both backed by [Azure AI Search](https://learn.microsoft.com/azure/search/search-what-is-azure-search). They allow you to search for images using natural language queries, leveraging multimodal embeddings from the Azure AI Vision API and image descriptions generated by the Azure OpenAI Service.

The web app frontend is built with TypeScript/React and the backend with Python/Quart. The MCP server is built with Python/FastMCP, and includes two tools that can be used by any MCP client, such as VS Code GitHub Copilot. The `image_search` tool returns a list of search results with thumbnail previews and descriptions, and the `display_image_files` tool renders images using [MCP apps](https://apps.extensions.modelcontextprotocol.io/api/).

This project uses the sample nature data set from [Vision Studio](https://learn.microsoft.com/azure/ai-services/computer-vision/overview-vision-studio).
Comment thread
pamelafox marked this conversation as resolved.

Expand Down Expand Up @@ -37,7 +39,7 @@ either by deleting the resource group in the Portal or running `azd down`.
First install the required tools:

* [Azure Developer CLI](https://aka.ms/azure-dev/install)
* [Python 3.9, 3.10, or 3.11](https://www.python.org/downloads/)
* [Python 3.10 or later](https://www.python.org/downloads/)
* **Important**: Python and the pip package manager must be in the path in Windows for the setup scripts to work.
* **Important**: Ensure you can run `python --version` from console. On Ubuntu, you might need to run `sudo apt install python-is-python3` to link `python` to `python3`.
* [Node.js 14+](https://nodejs.org/en/download/)
Expand All @@ -60,14 +62,51 @@ Execute the following command, if you don't have any pre-existing Azure services
* **Important**: Beware that the resources created by this command will incur immediate costs, primarily from the AI Search resource. These resources may accrue costs even if you interrupt the command before it is fully executed. You can run `azd down` or delete the resources manually to avoid unnecessary spending.
1. After the application has been successfully deployed you will see a URL printed to the console. Click that URL to interact with the application in your browser.

## How the indexer works

When `setup_search_service.py` runs (automatically during `azd up`, or manually), it sets up an Azure AI Search indexing pipeline:

1. **Blob upload** – Images from the `pictures/` folder are uploaded to an Azure Blob Storage container.
2. **Data source** – A Search data source is configured to point at that blob container.
3. **Skillset** – Two skills are applied to each image:
- [`VisionVectorizeSkill`](https://learn.microsoft.com/azure/search/cognitive-search-skill-vision-vectorize) generates a 1024-dimensional multimodal embedding using Azure AI Vision.
- [`ChatCompletionSkill`](https://learn.microsoft.com/azure/search/chat-completion-skill-example-usage) (optional) produces a natural-language description of each image, stored as `verbalized_image`.
4. **Index** – Results are written to a Search index with an `embedding` vector field backed by an HNSW algorithm, plus a built-in AI Vision vectorizer so that query text is embedded at search time without extra code.
5. **Indexer** – Runs the pipeline: blob → normalized images → skills → index projections. Each image within a blob becomes its own indexed document.

At query time, the search service vectorizes the text query using the same AI Vision model and performs an approximate nearest-neighbor search over the stored embeddings.

## Running locally

You can only run locally **after** having successfully run the `azd up` command. If you haven't yet, follow the steps in [Azure deployment](#azure-deployment) above.

### Web app

1. Run `azd auth login`
2. Change dir to `app` and run `./start.ps1` or `./start.sh` depending on your OS.
3. Open a browser and navigate to `http://localhost:50505`

### MCP server

1. Run `azd auth login`
2. Run `python app/backend/mcp_server.py`
3. The server starts on `http://localhost:8001`. Add it to your MCP client configuration (e.g. VS Code or Claude Desktop) pointing at that URL.

To connect to a deployed MCP server instead, update `.vscode/mcp.json` with your deployed Container App hostname and append `/mcp` (for example, using the `SERVICE_MCP_URI` output from `azd up`).

## Adding new images

To add new images to the search index after the initial deployment:

1. Place new JPEG image files under `pictures/` folder. Convert to JPEG first if needed.
2. Run the setup script inside the Python environment to upload and re-index:

```bash
python ./app/backend/setup_search_service.py
```

The script skips blobs that already exist, so only new files will be uploaded. The indexer will then vectorize and index the new images.

## Clean up

To clean up all the resources created by this sample:
Expand Down
34 changes: 34 additions & 0 deletions app/backend/Dockerfile.mcp
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# ------------------- Stage 0: Base Stage ------------------------------
FROM python:3.11-alpine AS base

WORKDIR /code

# Install tini, a tiny init for containers
RUN apk add --update --no-cache tini

# Install required packages for cryptography package
# https://cryptography.io/en/latest/installation/#building-cryptography-on-linux
RUN apk add gcc musl-dev python3-dev libffi-dev openssl-dev cargo pkgconfig jpeg-dev zlib-dev libwebp-dev

Comment thread
pamelafox marked this conversation as resolved.
# ------------------- Stage 1: Build Stage ------------------------------
FROM base AS build

COPY requirements.txt .

RUN pip3 install -r requirements.txt

COPY . .
# ------------------- Stage 2: Final Stage ------------------------------
FROM base AS final

RUN addgroup -S app && adduser -S app -G app

COPY --from=build --chown=app:app /usr/local/lib/python3.11 /usr/local/lib/python3.11
COPY --from=build --chown=app:app /usr/local/bin /usr/local/bin
COPY --from=build --chown=app:app /code /code

USER app

EXPOSE 8001

ENTRYPOINT ["tini", "python", "mcp_server.py"]
4 changes: 3 additions & 1 deletion app/backend/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ async def search():
search_text=None,
top=size,
vector_queries=[
VectorizableTextQuery(k=size, fields="embedding", text=search_text)
VectorizableTextQuery(
k_nearest_neighbors=size, fields="embedding", text=search_text
)
],
select="metadata_storage_path",
)
Expand Down
Loading