Skip to content

Commit b985b8b

Browse files
lsteinclaude
andauthored
refactor(metadata): finish wiring invoke formatter to pydantic union (#216)
* refactor(metadata): start to replace ad-hoc metadata parsing with structured parsing via pydantic * feature(backend): continue implementation of pydantic parsing of invoke metadata * refactor(metadata): add support for v5 metadata and v2 canbas * fix(metadata): change opacity from int to float * fix(metadata): change more opacity to float * fix(metadata): handle version 3 postprocessed images * refactor(metadata): finish wiring invoke formatter to pydantic union The invoke metadata refactor left `invoke_formatter.py` pointing at classes that no longer existed, so opening the drawer on an InvokeAI-generated image would raise ImportError. This change completes the wiring and locks in the behavior with regression tests. - Add `InvokeMetadataView`, a version-agnostic facade over the `GenerationMetadata2/3/5` discriminated union, so the formatter doesn't sprinkle `isinstance` checks across extraction logic. - Rewrite `invoke_formatter.py` as a thin HTML renderer on top of the view, preserving the existing drawer table layout. - Move `_normalize_ref_images` out of `GenerationMetadataAdapter.parse` and into `GenerationMetadata5` as a `@model_validator(mode="before")`, where it belongs. - Re-enable `metadata_modules/__init__.py` re-exports that had been commented out during the refactor. - Delete the now-unused `invoke/invoke_metadata_abc.py`. - Drawer HTML tweaks: suppress empty negative-prompt row; keep positive prompt row but drop copy icon when empty; per-column suppression in tuple tables; fall back to `1.0` when a surviving weight column has gaps; surface Flux Redux `imageInfluence` (e.g. "Medium") in the weight column when no numeric weight is present. - Add 33 regression tests in `tests/backend/test_invoke_metadata.py` covering view-level extraction and end-to-end HTML rendering for v2/v3/v5 (both `ref_images` and `canvas_v2_metadata` paths). - Run ruff across `invoke/` to modernize `typing.Optional`/`Union`/ `List`/`Dict` to PEP 604 / PEP 585 forms. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 36a474c commit b985b8b

21 files changed

Lines changed: 2476 additions & 105 deletions

CLAUDE.md

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Project
6+
7+
PhotoMapAI is a local-first image browser for large photo collections. It uses CLIP embeddings to power semantic text/image search and builds a UMAP "semantic map" that clusters images by content. The backend is FastAPI; the frontend is vanilla ES6 modules (no framework) using Swiper.js and Plotly.js. All processing is local — nothing is sent to external services.
8+
9+
## Common Commands
10+
11+
```bash
12+
# Install for development (Python 3.10–3.13)
13+
pip install -e .[testing,development]
14+
npm install
15+
16+
# Run the server (entry point defined in pyproject.toml)
17+
start_photomap # http://localhost:8050
18+
19+
# Tests
20+
make test # runs npm test + pytest
21+
pytest tests # backend only
22+
pytest tests/backend/test_search.py::test_text_search # single test
23+
npm test # frontend Jest only
24+
NODE_OPTIONS='--experimental-vm-modules' jest tests/frontend/search.test.js # single JS test
25+
26+
# Linting / formatting (CI enforces both)
27+
make lint # runs backend-lint + frontend-lint
28+
ruff check photomap tests --fix
29+
npm run lint:fix
30+
npm run format # prettier write
31+
npm run format:check # CI check
32+
33+
# Docs
34+
make docs # mkdocs serve on :8000
35+
```
36+
37+
Ruff is configured for line-length 120, target py310, rules E/W/F/I/UP/B (see pyproject.toml). Jest runs in jsdom with experimental ESM (the project is `"type": "module"`).
38+
39+
## Architecture
40+
41+
### Backend layout (`photomap/backend/`)
42+
43+
- `photomap_server.py` — FastAPI app entry point. Wires up routers, mounts `/static` and Jinja2 templates, and defines the top-level `/` route. `start_photomap` from `pyproject.toml` runs `main()` here.
44+
- `routers/` — one router per API surface: `album`, `search`, `umap`, `index`, `curation`, `filetree`, `upgrade`. Routers are included in `photomap_server.py`; `curation_router` is mounted with an explicit `/api/curation` prefix while the others set their own prefixes.
45+
- `config.py` — YAML-backed album config. Access via the `get_config_manager()` singleton (lru_cached). `Album` is a Pydantic model that expands `~` in image paths. Config lives in a platformdirs user config directory.
46+
- `embeddings.py` — CLIP embedding generation and persistence (`.npz`).
47+
- `imagetool.py` — shared CLI entry point for `index_images`, `update_images`, `search_images`, `search_text`, `find_duplicate_images` (all registered as scripts in `pyproject.toml`).
48+
- `metadata_extraction.py` / `metadata_formatting.py` — pulls EXIF + generator metadata (InvokeAI) out of images and formats for the UI.
49+
50+
### Metadata subsystem (`photomap/backend/metadata_modules/`)
51+
52+
This is the area under active refactor (current branch: `lstein/feature/refactor-invoke-metadata`). InvokeAI writes several incompatible metadata schemas into PNG tEXt chunks; the parser must auto-detect and upgrade.
53+
54+
- `invokemetadata.py` defines `GenerationMetadata` as a Pydantic `Annotated[Union[…], Field(discriminator="metadata_version")]` over `GenerationMetadata2`, `GenerationMetadata3`, and `GenerationMetadata5`. `GenerationMetadataAdapter.parse()` inspects fields like `canvas_v2_metadata`, `app_version`, and `model_weights` to inject the correct `metadata_version` when the source JSON predates the discriminator.
55+
- `invoke/` holds the per-version schemas: `invoke2metadata.py`, `invoke3metadata.py`, `invoke5metadata.py`, plus `canvas2metadata.py` and `common_metadata_elements.py` for shared types. `invoke_metadata_view.py` is the version-agnostic facade consumed by `invoke_formatter.py`. When adding support for a new InvokeAI version, add a new `invokeNmetadata.py`, extend the Union in `invokemetadata.py`, teach `parse()` how to recognize legacy payloads that lack a `metadata_version` field, and extend `InvokeMetadataView`'s `isinstance` dispatch.
56+
- `invoke_formatter.py` / `exif_formatter.py` render parsed metadata for the drawer UI; `slide_summary.py` produces the compact slideshow caption.
57+
- `invoke-DELETE/` is a holdover from the refactor — leave it alone unless cleaning up.
58+
59+
### Frontend layout (`photomap/frontend/`)
60+
61+
- `static/javascript/` — one ES6 module per feature. No build step; modules are served directly and imported from `main.js` / `index.js`.
62+
- `state.js` is the centralized application state. Prefer extending it over adding new globals.
63+
- `events.js` owns global keyboard shortcuts; register new ones there rather than scattering listeners.
64+
- `localStorage` is used for persisted user preferences, `sessionStorage` for per-navigation state.
65+
- `templates/` — Jinja2 templates rendered by FastAPI.
66+
67+
### Tests
68+
69+
- `tests/backend/` — pytest. `conftest.py` + `fixtures.py` set up shared fixtures (test images in `tests/backend/test_images/`). Use the FastAPI `TestClient` for router tests; see `test_search.py`, `test_albums.py`, `test_curation.py` as templates.
70+
- `tests/frontend/` — Jest with jsdom. `setup.js` provides DOM fixtures. See `tests/frontend/README.md` for setup notes.
71+
72+
## Conventions to follow
73+
74+
From `.github/copilot-instructions.md` — the parts that actually affect how you write code here:
75+
76+
- **Python:** type hints on public functions, `pathlib.Path` (not `os.path`) for file operations, f-strings, imports ordered stdlib → third-party → local (`photomap` is first-party to isort). Code must pass `ruff check photomap tests`.
77+
- **Pinned quirk:** `setuptools<67` is intentional — avoids a deprecation warning from the CLIP dependency. Don't "fix" it.
78+
- **New API endpoints:** add/extend a router under `photomap/backend/routers/`, use Pydantic models for request/response, include the router in `photomap_server.py`, add a `tests/backend/test_<name>.py`.
79+
- **New frontend features:** create a module in `static/javascript/`, wire shared state through `state.js`, register shortcuts in `events.js`, add a Jest test.
80+
- **JavaScript:** ES6 modules only, `const`/`let`, must pass `npm run lint` and `npm run format:check`.

photomap/backend/metadata_modules/__init__.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
21
from .exif_formatter import format_exif_metadata
32
from .invoke_formatter import format_invoke_metadata
43
from .slide_summary import SlideSummary
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# from .legacy import InvokeLegacyMetadata
2+
# from .v3 import Invoke3Metadata
3+
# from .v5 import Invoke5Metadata
4+
5+
# # reexport the main classes
6+
# __all__ = [
7+
# "InvokeLegacyMetadata",
8+
# "Invoke3Metadata",
9+
# "Invoke5Metadata",
10+
# ]

photomap/backend/metadata_modules/invoke/invoke_metadata_abc.py renamed to photomap/backend/metadata_modules/invoke-DELETE/invoke_metadata_abc.py

File renamed without changes.

photomap/backend/metadata_modules/invoke/legacy.py renamed to photomap/backend/metadata_modules/invoke-DELETE/legacy.py

File renamed without changes.
File renamed without changes.
File renamed without changes.

photomap/backend/metadata_modules/invoke/__init__.py

Lines changed: 0 additions & 11 deletions
This file was deleted.
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
from typing import Any
2+
3+
from pydantic import BaseModel, ConfigDict, Field, model_serializer, model_validator
4+
5+
from photomap.backend.metadata_modules.invoke.common_metadata_elements import (
6+
ControlAdapter,
7+
Fill,
8+
Object,
9+
Position,
10+
ReferenceImage,
11+
RegionalGuidance,
12+
tag_reference_images,
13+
)
14+
15+
16+
class Clip(BaseModel):
17+
height: float
18+
width: float
19+
x: float
20+
y: float
21+
22+
23+
class Inpaintmask(BaseModel):
24+
model_config = ConfigDict(populate_by_name=True)
25+
fill: Fill
26+
id: str
27+
is_enabled: bool = Field(alias="isEnabled")
28+
is_locked: bool = Field(alias="isLocked")
29+
name: Any | None
30+
objects: list[Object]
31+
opacity: float
32+
position: Position
33+
type: str
34+
35+
36+
class Rasterlayer(BaseModel):
37+
model_config = ConfigDict(populate_by_name=True)
38+
id: str
39+
is_enabled: bool = Field(alias="isEnabled")
40+
is_locked: bool = Field(alias="isLocked")
41+
name: Any | None
42+
objects: list[Object]
43+
opacity: float
44+
position: Position
45+
type: str
46+
47+
48+
class ControlLayer(BaseModel):
49+
model_config = ConfigDict(populate_by_name=True)
50+
control_adapter: ControlAdapter = Field(alias="controlAdapter")
51+
id: str
52+
is_enabled: bool = Field(alias="isEnabled")
53+
is_locked: bool = Field(alias="isLocked")
54+
name: Any | None
55+
objects: list[Object]
56+
opacity: float
57+
position: Position
58+
type: str
59+
with_transparency_effect: bool = Field(alias="withTransparencyEffect")
60+
61+
62+
class CanvasV2Metadata(BaseModel):
63+
model_config = ConfigDict(populate_by_name=True)
64+
raster_layers: list[Rasterlayer] | None = Field(None, alias="rasterLayers")
65+
control_layers: list[ControlLayer] | None = Field(None, alias="controlLayers")
66+
inpaint_masks: list[Inpaintmask] | None = Field(None, alias="inpaintMasks")
67+
reference_images: list[ReferenceImage] | None = Field(
68+
None, alias="referenceImages"
69+
)
70+
regional_guidance: list[RegionalGuidance] | None = Field(
71+
None, alias="regionalGuidance"
72+
)
73+
74+
@model_validator(mode="before")
75+
@classmethod
76+
def _preprocess_canvas_metadata(
77+
cls, canvas_metadata: dict[str, Any]
78+
) -> dict[str, Any]:
79+
"""Preprocess canvas metadata to add type discriminators to image objects."""
80+
81+
def process_image_in_dict(obj: dict[str, Any], key: str = "image") -> None:
82+
"""Add type discriminator to an image object if it exists."""
83+
if key in obj and obj[key]:
84+
tag_reference_images(obj[key])
85+
86+
def process_objects(objects: list[dict[str, Any]]) -> None:
87+
"""Process a list of objects that may contain images."""
88+
for obj in objects:
89+
process_image_in_dict(obj)
90+
91+
def process_reference_images(ref_images: list[dict[str, Any]]) -> None:
92+
"""Process reference images with ipAdapter."""
93+
for ref_image in ref_images:
94+
if "ipAdapter" in ref_image and ref_image["ipAdapter"]:
95+
process_image_in_dict(ref_image["ipAdapter"])
96+
97+
# Process layers with objects (rasterLayers, inpaintMasks, controlLayers)
98+
for layer_key in ["rasterLayers", "inpaintMasks", "controlLayers"]:
99+
if layer_key in canvas_metadata and canvas_metadata[layer_key]:
100+
for layer in canvas_metadata[layer_key]:
101+
if "objects" in layer and layer["objects"]:
102+
process_objects(layer["objects"])
103+
104+
# Process top-level referenceImages
105+
if "referenceImages" in canvas_metadata and canvas_metadata["referenceImages"]:
106+
process_reference_images(canvas_metadata["referenceImages"])
107+
108+
# Process regionalGuidance with objects and referenceImages
109+
if (
110+
"regionalGuidance" in canvas_metadata
111+
and canvas_metadata["regionalGuidance"]
112+
):
113+
for region in canvas_metadata["regionalGuidance"]:
114+
if "objects" in region and region["objects"]:
115+
process_objects(region["objects"])
116+
if "referenceImages" in region and region["referenceImages"]:
117+
process_reference_images(region["referenceImages"])
118+
119+
return canvas_metadata
120+
121+
@model_serializer(mode="wrap")
122+
def serialize_model(self, serializer, info):
123+
"""Exclude None values when serializing."""
124+
data = serializer(self)
125+
return {k: v for k, v in data.items() if v is not None}

0 commit comments

Comments
 (0)