Spaces:
Runtime error
Runtime error
upload files
Browse files- .gitattributes +1 -0
- .gitignore +4 -0
- README.md +56 -15
- __pycache__/caption_store.cpython-310.pyc +0 -0
- __pycache__/ingest.cpython-310.pyc +0 -0
- __pycache__/logic.cpython-310.pyc +0 -0
- __pycache__/modal_caption.cpython-310.pyc +0 -0
- __pycache__/search.cpython-310.pyc +0 -0
- app.py +380 -0
- app_old.py +296 -0
- caption_store.py +182 -0
- captions.json +32 -0
- ingest.py +74 -0
- logic.py +376 -0
- modal_caption.py +167 -0
- public/heroImage.png +3 -0
- requirements.txt +5 -0
- search.py +122 -0
- style.css +269 -0
- ui/__init__.py +0 -0
- ui/__pycache__/__init__.cpython-310.pyc +0 -0
- ui/__pycache__/about.cpython-310.pyc +0 -0
- ui/__pycache__/captions.cpython-310.pyc +0 -0
- ui/__pycache__/collections.cpython-310.pyc +0 -0
- ui/__pycache__/home.cpython-310.pyc +0 -0
- ui/__pycache__/ingest.cpython-310.pyc +0 -0
- ui/__pycache__/search.cpython-310.pyc +0 -0
- ui/__pycache__/sidebar.cpython-310.pyc +0 -0
- ui/about.py +64 -0
- ui/captions.py +16 -0
- ui/collections.py +88 -0
- ui/home.py +157 -0
- ui/ingest.py +71 -0
- ui/search.py +74 -0
- ui/sidebar.py +27 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
public/heroImage.png filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.kiro
|
| 2 |
+
.venv
|
| 3 |
+
__pycache__
|
| 4 |
+
captions.json
|
README.md
CHANGED
|
@@ -1,15 +1,56 @@
|
|
| 1 |
-
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom: indigo
|
| 5 |
-
colorTo: purple
|
| 6 |
-
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
--
|
| 14 |
-
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Photographer's Archive
|
| 3 |
+
emoji: 📷
|
| 4 |
+
colorFrom: indigo
|
| 5 |
+
colorTo: purple
|
| 6 |
+
sdk: gradio
|
| 7 |
+
sdk_version: "5"
|
| 8 |
+
app_file: app.py
|
| 9 |
+
pinned: false
|
| 10 |
+
license: apache-2.0
|
| 11 |
+
tags:
|
| 12 |
+
- backyard-ai
|
| 13 |
+
- build-small
|
| 14 |
+
- vision-language-model
|
| 15 |
+
- image-search
|
| 16 |
+
- local-first
|
| 17 |
+
short_description: Local-first photo search powered by MiniCPM-V-4.6
|
| 18 |
+
---
|
| 19 |
+
|
| 20 |
+
# Photographer's Archive
|
| 21 |
+
|
| 22 |
+
A local-first, privacy-preserving photo search app for photographers. Built for the Hugging Face [Build Small](https://huggingface.co/build-small) hackathon — **Backyard AI** track.
|
| 23 |
+
|
| 24 |
+
## What it does
|
| 25 |
+
|
| 26 |
+
1. **Ingest** — Point the app at a local folder of photos. A small Vision-Language Model (MiniCPM-V-4.6, ≤7B params) runs entirely on your machine and generates rich captions describing objects, lighting, mood, and composition.
|
| 27 |
+
2. **Search** — Type a plain-English query (e.g. "golden hour portrait with soft bokeh") and instantly retrieve the most relevant photos ranked by semantic similarity.
|
| 28 |
+
|
| 29 |
+
No images are uploaded to any external service. Everything stays on your hardware.
|
| 30 |
+
|
| 31 |
+
## Demo
|
| 32 |
+
|
| 33 |
+
> 📹 Demo video: _link TBD_
|
| 34 |
+
> 🐦 Social post: _link TBD_
|
| 35 |
+
|
| 36 |
+
## Running locally
|
| 37 |
+
|
| 38 |
+
```bash
|
| 39 |
+
pip install -r requirements.txt
|
| 40 |
+
python app.py
|
| 41 |
+
```
|
| 42 |
+
|
| 43 |
+
The first run will download MiniCPM-V-4.6 (~8 GB). Subsequent runs use cached weights and work fully offline.
|
| 44 |
+
|
| 45 |
+
## Tech stack
|
| 46 |
+
|
| 47 |
+
| Component | Library |
|
| 48 |
+
|-----------|---------|
|
| 49 |
+
| VLM | `openbmb/MiniCPM-V-4.6` via 🤗 Transformers |
|
| 50 |
+
| Semantic search | `sentence-transformers/all-MiniLM-L6-v2` |
|
| 51 |
+
| UI | Gradio 5 |
|
| 52 |
+
| Caption store | Local `captions.json` |
|
| 53 |
+
|
| 54 |
+
## Privacy
|
| 55 |
+
|
| 56 |
+
All model inference happens locally. No image pixels, captions, or metadata leave your machine.
|
__pycache__/caption_store.cpython-310.pyc
ADDED
|
Binary file (5.38 kB). View file
|
|
|
__pycache__/ingest.cpython-310.pyc
ADDED
|
Binary file (2.61 kB). View file
|
|
|
__pycache__/logic.cpython-310.pyc
ADDED
|
Binary file (6.43 kB). View file
|
|
|
__pycache__/modal_caption.cpython-310.pyc
ADDED
|
Binary file (4.4 kB). View file
|
|
|
__pycache__/search.cpython-310.pyc
ADDED
|
Binary file (4.06 kB). View file
|
|
|
app.py
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# import gradio as gr
|
| 2 |
+
|
| 3 |
+
# # Ensure the new dropdown helpers are imported
|
| 4 |
+
# from logic import (
|
| 5 |
+
# run_ingest, run_search, load_caption_browser, load_collections_view,
|
| 6 |
+
# update_collections_dropdown, update_search_dropdown
|
| 7 |
+
# )
|
| 8 |
+
# from caption_store import entry_count, get_all_collections
|
| 9 |
+
|
| 10 |
+
# # Import UI Views
|
| 11 |
+
# from ui.sidebar import render_sidebar
|
| 12 |
+
# from ui.home import render_home
|
| 13 |
+
# from ui.ingest import render_ingest
|
| 14 |
+
# from ui.search import render_search
|
| 15 |
+
# from ui.collections import render_collections
|
| 16 |
+
# from ui.captions import render_captions
|
| 17 |
+
# from ui.about import render_about
|
| 18 |
+
|
| 19 |
+
# # Define explicit Dark Mode theme variables
|
| 20 |
+
# # dark_theme = gr.themes.Soft(
|
| 21 |
+
# # primary_hue="green",
|
| 22 |
+
# # secondary_hue="emerald"
|
| 23 |
+
# # ).set(
|
| 24 |
+
# # body_background_fill="#0b0f19",
|
| 25 |
+
# # body_background_fill_dark="#0b0f19",
|
| 26 |
+
# # block_background_fill="#1e293b",
|
| 27 |
+
# # block_background_fill_dark="#1e293b",
|
| 28 |
+
# # block_border_color="#334155",
|
| 29 |
+
# # block_border_color_dark="#334155",
|
| 30 |
+
# # input_background_fill="#0f172a",
|
| 31 |
+
# # input_background_fill_dark="#0f172a",
|
| 32 |
+
# # text_color="#f1f5f9",
|
| 33 |
+
# # text_color_dark="#f1f5f9",
|
| 34 |
+
# # )
|
| 35 |
+
|
| 36 |
+
# # Load Custom Stylesheet
|
| 37 |
+
# with open("style.css", "r") as f:
|
| 38 |
+
# custom_css = f.read()
|
| 39 |
+
|
| 40 |
+
# with gr.Blocks(css=custom_css) as demo:
|
| 41 |
+
# # 1. Render Navigation Sidebar
|
| 42 |
+
# nav_btns, stats_label = render_sidebar()
|
| 43 |
+
|
| 44 |
+
# # 2. Render Main Content Container Pages
|
| 45 |
+
# with gr.Column(elem_classes="main-content"):
|
| 46 |
+
# home_page, start_btn = render_home()
|
| 47 |
+
# ingest_page, upload, coll_dropdown, use_new_coll, new_coll_name, ingest_btn, ingest_status = render_ingest()
|
| 48 |
+
# search_page, search_query, search_col_filter, search_btn, search_status_msg, search_gallery = render_search()
|
| 49 |
+
# colls_page, view_coll_selector, refresh_coll_btn, coll_status, coll_gallery = render_collections()
|
| 50 |
+
# caps_page, refresh_cap_btn, cap_status, caption_table = render_captions()
|
| 51 |
+
# about_page = render_about()
|
| 52 |
+
|
| 53 |
+
# # Routing definitions
|
| 54 |
+
# pages = [home_page, ingest_page, search_page, colls_page, caps_page, about_page]
|
| 55 |
+
# page_names = ["home", "ingest", "search", "collections", "captions", "about"]
|
| 56 |
+
|
| 57 |
+
# def switch_page(page_name):
|
| 58 |
+
# return [gr.update(visible=(name == page_name)) for name in page_names]
|
| 59 |
+
|
| 60 |
+
# # --- Page Routing Event Handlers ---
|
| 61 |
+
# nav_btns["home"].click(fn=lambda: switch_page("home"), outputs=pages)
|
| 62 |
+
# nav_btns["ingest"].click(fn=lambda: switch_page("ingest"), outputs=pages)
|
| 63 |
+
|
| 64 |
+
# # Use logic helpers to keep dropdown selections safe when changing tabs
|
| 65 |
+
# nav_btns["search"].click(
|
| 66 |
+
# fn=lambda: switch_page("search") + [update_search_dropdown()],
|
| 67 |
+
# outputs=pages + [search_col_filter]
|
| 68 |
+
# )
|
| 69 |
+
# nav_btns["collections"].click(
|
| 70 |
+
# fn=lambda: switch_page("collections") + [update_collections_dropdown()],
|
| 71 |
+
# outputs=pages + [view_coll_selector]
|
| 72 |
+
# )
|
| 73 |
+
|
| 74 |
+
# nav_btns["captions"].click(fn=lambda: switch_page("captions"), outputs=pages)
|
| 75 |
+
# nav_btns["about"].click(fn=lambda: switch_page("about"), outputs=pages)
|
| 76 |
+
|
| 77 |
+
# start_btn.click(fn=lambda: switch_page("ingest"), outputs=pages)
|
| 78 |
+
|
| 79 |
+
# # --- Component Value Interaction Bindings ---
|
| 80 |
+
|
| 81 |
+
# # Toggle dropdown/text field depending on toggle state
|
| 82 |
+
# def toggle_collection_inputs(is_new):
|
| 83 |
+
# return (
|
| 84 |
+
# gr.update(visible=not is_new), # Hide selector if creating new
|
| 85 |
+
# gr.update(visible=is_new) # Show text box if creating new
|
| 86 |
+
# )
|
| 87 |
+
|
| 88 |
+
# use_new_coll.change(
|
| 89 |
+
# fn=toggle_collection_inputs,
|
| 90 |
+
# inputs=use_new_coll,
|
| 91 |
+
# outputs=[coll_dropdown, new_coll_name]
|
| 92 |
+
# )
|
| 93 |
+
|
| 94 |
+
# ingest_btn.click(
|
| 95 |
+
# fn=run_ingest,
|
| 96 |
+
# inputs=[upload, coll_dropdown, use_new_coll, new_coll_name],
|
| 97 |
+
# outputs=[ingest_status, caption_table, coll_dropdown]
|
| 98 |
+
# ).then(
|
| 99 |
+
# fn=lambda: f"Total Photos: {entry_count()}",
|
| 100 |
+
# outputs=stats_label
|
| 101 |
+
# )
|
| 102 |
+
|
| 103 |
+
# # Search Logic Action triggers
|
| 104 |
+
# search_btn.click(fn=run_search, inputs=[search_query, search_col_filter], outputs=[search_gallery, search_status_msg])
|
| 105 |
+
# search_query.submit(fn=run_search, inputs=[search_query, search_col_filter], outputs=[search_gallery, search_status_msg])
|
| 106 |
+
|
| 107 |
+
# # Gallery view loaders
|
| 108 |
+
# view_coll_selector.change(fn=load_collections_view, inputs=view_coll_selector, outputs=[coll_gallery, coll_status])
|
| 109 |
+
# refresh_coll_btn.click(fn=load_collections_view, inputs=view_coll_selector, outputs=[coll_gallery, coll_status])
|
| 110 |
+
|
| 111 |
+
# # Captions refresh actions
|
| 112 |
+
# refresh_cap_btn.click(fn=load_caption_browser, outputs=[caption_table, cap_status])
|
| 113 |
+
|
| 114 |
+
# # Initial startup data population
|
| 115 |
+
# demo.load(fn=load_caption_browser, outputs=[caption_table, cap_status])
|
| 116 |
+
# demo.load(
|
| 117 |
+
# fn=lambda: load_collections_view(get_all_collections()[0] if get_all_collections() else "General"),
|
| 118 |
+
# outputs=[coll_gallery, coll_status]
|
| 119 |
+
# )
|
| 120 |
+
|
| 121 |
+
# if __name__ == "__main__":
|
| 122 |
+
# demo.launch()
|
| 123 |
+
|
| 124 |
+
import os
|
| 125 |
+
import gradio as gr
|
| 126 |
+
|
| 127 |
+
# Import robust business logic operations
|
| 128 |
+
from logic import (
|
| 129 |
+
run_ingest, run_search, load_caption_browser, load_collections_view,
|
| 130 |
+
update_collections_dropdown, update_search_dropdown, zip_selected_files
|
| 131 |
+
)
|
| 132 |
+
from caption_store import entry_count, get_all_collections
|
| 133 |
+
|
| 134 |
+
# Import modular UI Page builders
|
| 135 |
+
from ui.sidebar import render_sidebar
|
| 136 |
+
from ui.home import render_home
|
| 137 |
+
from ui.ingest import render_ingest
|
| 138 |
+
from ui.search import render_search
|
| 139 |
+
from ui.collections import render_collections
|
| 140 |
+
from ui.captions import render_captions
|
| 141 |
+
from ui.about import render_about
|
| 142 |
+
|
| 143 |
+
# Load layout parameters from global stylesheet
|
| 144 |
+
with open("style.css", "r") as f:
|
| 145 |
+
custom_css = f.read()
|
| 146 |
+
|
| 147 |
+
with gr.Blocks(css=custom_css) as demo:
|
| 148 |
+
# 1. Render Navigation Sidebar
|
| 149 |
+
nav_btns, stats_label = render_sidebar()
|
| 150 |
+
|
| 151 |
+
# 2. Render Main Content Container Pages
|
| 152 |
+
with gr.Column(elem_classes="main-content"):
|
| 153 |
+
home_page, start_btn = render_home()
|
| 154 |
+
ingest_page, upload, coll_dropdown, use_new_coll, new_coll_name, ingest_btn, ingest_status = render_ingest()
|
| 155 |
+
|
| 156 |
+
# Search layout with selection returns
|
| 157 |
+
(
|
| 158 |
+
search_page, search_query, search_col_filter, search_btn, search_status_msg, search_gallery,
|
| 159 |
+
selected_search_paths, loaded_search_paths, search_selection_status, search_download_btn,
|
| 160 |
+
search_download_file, search_clear_selection_btn, search_select_all_btn
|
| 161 |
+
) = render_search()
|
| 162 |
+
|
| 163 |
+
# Collections layout with selection returns
|
| 164 |
+
(
|
| 165 |
+
colls_page, view_coll_selector, refresh_coll_btn, coll_status, coll_gallery,
|
| 166 |
+
selected_paths, loaded_original_paths, selection_status, download_btn,
|
| 167 |
+
download_file, clear_selection_btn, select_all_btn
|
| 168 |
+
) = render_collections()
|
| 169 |
+
|
| 170 |
+
caps_page, refresh_cap_btn, cap_status, caption_table = render_captions()
|
| 171 |
+
about_page = render_about()
|
| 172 |
+
|
| 173 |
+
# Define page indexes for visibility toggles
|
| 174 |
+
pages = [home_page, ingest_page, search_page, colls_page, caps_page, about_page]
|
| 175 |
+
page_names = ["home", "ingest", "search", "collections", "captions", "about"]
|
| 176 |
+
|
| 177 |
+
def switch_page(page_name):
|
| 178 |
+
"""Generates dynamic updates to manage current visible pages."""
|
| 179 |
+
return [gr.update(visible=(name == page_name)) for name in page_names]
|
| 180 |
+
|
| 181 |
+
# --- Sidebar Navigation Interactions ---
|
| 182 |
+
nav_btns["home"].click(fn=lambda: switch_page("home"), outputs=pages)
|
| 183 |
+
nav_btns["ingest"].click(fn=lambda: switch_page("ingest"), outputs=pages)
|
| 184 |
+
|
| 185 |
+
# Safe helpers are invoked here to avoid component value schema mismatch issues
|
| 186 |
+
nav_btns["search"].click(
|
| 187 |
+
fn=lambda: switch_page("search") + [update_search_dropdown()],
|
| 188 |
+
outputs=pages + [search_col_filter]
|
| 189 |
+
)
|
| 190 |
+
nav_btns["collections"].click(
|
| 191 |
+
fn=lambda: switch_page("collections") + [update_collections_dropdown()],
|
| 192 |
+
outputs=pages + [view_coll_selector]
|
| 193 |
+
)
|
| 194 |
+
|
| 195 |
+
nav_btns["captions"].click(fn=lambda: switch_page("captions"), outputs=pages)
|
| 196 |
+
nav_btns["about"].click(fn=lambda: switch_page("about"), outputs=pages)
|
| 197 |
+
|
| 198 |
+
start_btn.click(fn=lambda: switch_page("ingest"), outputs=pages)
|
| 199 |
+
|
| 200 |
+
# --- Ingestion View Handlers ---
|
| 201 |
+
def toggle_collection_inputs(is_new):
|
| 202 |
+
"""Toggles rendering states of manual and standard collections selectors."""
|
| 203 |
+
return (
|
| 204 |
+
gr.update(visible=not is_new), # Hide selector if creating new
|
| 205 |
+
gr.update(visible=is_new) # Show text box if creating new
|
| 206 |
+
)
|
| 207 |
+
|
| 208 |
+
use_new_coll.change(
|
| 209 |
+
fn=toggle_collection_inputs,
|
| 210 |
+
inputs=use_new_coll,
|
| 211 |
+
outputs=[coll_dropdown, new_coll_name]
|
| 212 |
+
)
|
| 213 |
+
|
| 214 |
+
ingest_btn.click(
|
| 215 |
+
fn=run_ingest,
|
| 216 |
+
inputs=[upload, coll_dropdown, use_new_coll, new_coll_name],
|
| 217 |
+
outputs=[ingest_status, caption_table, coll_dropdown]
|
| 218 |
+
).then(
|
| 219 |
+
fn=lambda: f"Total Photos: {entry_count()}",
|
| 220 |
+
outputs=stats_label
|
| 221 |
+
)
|
| 222 |
+
|
| 223 |
+
# --- Shared Selection Utility Functions ---
|
| 224 |
+
def format_selection_status(count, filenames):
|
| 225 |
+
"""Generates double-linebreak formatted selection statuses to divide output lines."""
|
| 226 |
+
if count == 0:
|
| 227 |
+
return "**0** image(s) selected for download packaging.\n\n*No files currently selected.*"
|
| 228 |
+
|
| 229 |
+
name_string = ", ".join(filenames)
|
| 230 |
+
if len(name_string) > 150:
|
| 231 |
+
name_string = name_string[:150] + "..."
|
| 232 |
+
# Double linebreak forces a paragraph divide onto a separate line
|
| 233 |
+
return f"**{count}** image(s) selected for download packaging.\n\n📋 **Selected Files:** `{name_string}`"
|
| 234 |
+
|
| 235 |
+
def handle_gallery_selection(evt: gr.SelectData, current_selections, raw_paths, gallery_data):
|
| 236 |
+
"""Toggles visual labels dynamically and outputs double-linebreak reports."""
|
| 237 |
+
clicked_file = raw_paths[evt.index]
|
| 238 |
+
if clicked_file in current_selections:
|
| 239 |
+
current_selections.remove(clicked_file)
|
| 240 |
+
else:
|
| 241 |
+
current_selections.append(clicked_file)
|
| 242 |
+
|
| 243 |
+
updated_gallery = []
|
| 244 |
+
for idx, path in enumerate(raw_paths):
|
| 245 |
+
thumb_path = gallery_data[idx][0]
|
| 246 |
+
basename = os.path.basename(path)
|
| 247 |
+
label = f"✅ {basename}" if path in current_selections else basename
|
| 248 |
+
updated_gallery.append((thumb_path, label))
|
| 249 |
+
|
| 250 |
+
filenames = [os.path.basename(f) for f in current_selections]
|
| 251 |
+
summary = format_selection_status(len(current_selections), filenames)
|
| 252 |
+
return current_selections, summary, updated_gallery
|
| 253 |
+
|
| 254 |
+
def handle_select_all(raw_paths, gallery_data):
|
| 255 |
+
"""Packages all source paths inside current array list and displays checkmarks."""
|
| 256 |
+
if not raw_paths:
|
| 257 |
+
return [], format_selection_status(0, []), []
|
| 258 |
+
|
| 259 |
+
current_selections = list(raw_paths)
|
| 260 |
+
updated_gallery = []
|
| 261 |
+
for idx, path in enumerate(raw_paths):
|
| 262 |
+
thumb_path = gallery_data[idx][0]
|
| 263 |
+
basename = os.path.basename(path)
|
| 264 |
+
updated_gallery.append((thumb_path, f"✅ {basename}"))
|
| 265 |
+
|
| 266 |
+
filenames = [os.path.basename(f) for f in current_selections]
|
| 267 |
+
summary = format_selection_status(len(current_selections), filenames)
|
| 268 |
+
return current_selections, summary, updated_gallery
|
| 269 |
+
|
| 270 |
+
def clear_selection(raw_paths, gallery_data):
|
| 271 |
+
"""Flushes storage state lists and clears visual checkmarks from labels."""
|
| 272 |
+
updated_gallery = []
|
| 273 |
+
for idx, path in enumerate(raw_paths):
|
| 274 |
+
thumb_path = gallery_data[idx][0]
|
| 275 |
+
basename = os.path.basename(path)
|
| 276 |
+
updated_gallery.append((thumb_path, basename))
|
| 277 |
+
|
| 278 |
+
return [], format_selection_status(0, []), updated_gallery, gr.update(visible=False)
|
| 279 |
+
|
| 280 |
+
def handle_selected_download(selected_list):
|
| 281 |
+
"""Compiles specified absolute paths into a downloadable archive container."""
|
| 282 |
+
if not selected_list:
|
| 283 |
+
return gr.update(visible=False), "⚠️ Download failure: No selections marked."
|
| 284 |
+
file_path, status_msg = zip_selected_files(selected_list)
|
| 285 |
+
if file_path:
|
| 286 |
+
return gr.update(value=file_path, visible=True), status_msg
|
| 287 |
+
return gr.update(visible=False), status_msg
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
# --- Search View Bindings ---
|
| 291 |
+
def trigger_search_load(query, col_filter):
|
| 292 |
+
images, original_paths, status = run_search(query, col_filter)
|
| 293 |
+
return images, original_paths, [], format_selection_status(0, []), status, gr.update(visible=False)
|
| 294 |
+
|
| 295 |
+
search_btn.click(
|
| 296 |
+
fn=trigger_search_load,
|
| 297 |
+
inputs=[search_query, search_col_filter],
|
| 298 |
+
outputs=[search_gallery, loaded_search_paths, selected_search_paths, search_selection_status, search_status_msg, search_download_file]
|
| 299 |
+
)
|
| 300 |
+
search_query.submit(
|
| 301 |
+
fn=trigger_search_load,
|
| 302 |
+
inputs=[search_query, search_col_filter],
|
| 303 |
+
outputs=[search_gallery, loaded_search_paths, selected_search_paths, search_selection_status, search_status_msg, search_download_file]
|
| 304 |
+
)
|
| 305 |
+
|
| 306 |
+
# Search Interactive Select Hooks
|
| 307 |
+
search_gallery.select(
|
| 308 |
+
fn=handle_gallery_selection,
|
| 309 |
+
inputs=[selected_search_paths, loaded_search_paths, search_gallery],
|
| 310 |
+
outputs=[selected_search_paths, search_selection_status, search_gallery]
|
| 311 |
+
)
|
| 312 |
+
search_select_all_btn.click(
|
| 313 |
+
fn=handle_select_all,
|
| 314 |
+
inputs=[loaded_search_paths, search_gallery],
|
| 315 |
+
outputs=[selected_search_paths, search_selection_status, search_gallery]
|
| 316 |
+
)
|
| 317 |
+
search_clear_selection_btn.click(
|
| 318 |
+
fn=clear_selection,
|
| 319 |
+
inputs=[loaded_search_paths, search_gallery],
|
| 320 |
+
outputs=[selected_search_paths, search_selection_status, search_gallery, search_download_file]
|
| 321 |
+
)
|
| 322 |
+
search_download_btn.click(
|
| 323 |
+
fn=handle_selected_download,
|
| 324 |
+
inputs=selected_search_paths,
|
| 325 |
+
outputs=[search_download_file, search_selection_status]
|
| 326 |
+
)
|
| 327 |
+
|
| 328 |
+
|
| 329 |
+
# --- Collections View Bindings ---
|
| 330 |
+
def trigger_collection_load(selected_collection):
|
| 331 |
+
images, original_paths, status = load_collections_view(selected_collection)
|
| 332 |
+
return images, original_paths, [], format_selection_status(0, []), gr.update(visible=False)
|
| 333 |
+
|
| 334 |
+
view_coll_selector.change(
|
| 335 |
+
fn=trigger_collection_load,
|
| 336 |
+
inputs=view_coll_selector,
|
| 337 |
+
outputs=[coll_gallery, loaded_original_paths, selected_paths, selection_status, download_file]
|
| 338 |
+
)
|
| 339 |
+
refresh_coll_btn.click(
|
| 340 |
+
fn=trigger_collection_load,
|
| 341 |
+
inputs=view_coll_selector,
|
| 342 |
+
outputs=[coll_gallery, loaded_original_paths, selected_paths, selection_status, download_file]
|
| 343 |
+
)
|
| 344 |
+
|
| 345 |
+
# Collections Interactive Select Hooks
|
| 346 |
+
coll_gallery.select(
|
| 347 |
+
fn=handle_gallery_selection,
|
| 348 |
+
inputs=[selected_paths, loaded_original_paths, coll_gallery],
|
| 349 |
+
outputs=[selected_paths, selection_status, coll_gallery]
|
| 350 |
+
)
|
| 351 |
+
select_all_btn.click(
|
| 352 |
+
fn=handle_select_all,
|
| 353 |
+
inputs=[loaded_original_paths, coll_gallery],
|
| 354 |
+
outputs=[selected_paths, selection_status, coll_gallery]
|
| 355 |
+
)
|
| 356 |
+
clear_selection_btn.click(
|
| 357 |
+
fn=clear_selection,
|
| 358 |
+
inputs=[loaded_original_paths, coll_gallery],
|
| 359 |
+
outputs=[selected_paths, selection_status, coll_gallery, download_file]
|
| 360 |
+
)
|
| 361 |
+
download_btn.click(
|
| 362 |
+
fn=handle_selected_download,
|
| 363 |
+
inputs=selected_paths,
|
| 364 |
+
outputs=[download_file, selection_status]
|
| 365 |
+
)
|
| 366 |
+
|
| 367 |
+
|
| 368 |
+
# --- Caption Browser Refresh Bindings ---
|
| 369 |
+
refresh_cap_btn.click(fn=load_caption_browser, outputs=[caption_table, cap_status])
|
| 370 |
+
|
| 371 |
+
# Initial startup payload loaders
|
| 372 |
+
demo.load(fn=load_caption_browser, outputs=[caption_table, cap_status])
|
| 373 |
+
demo.load(
|
| 374 |
+
fn=trigger_collection_load,
|
| 375 |
+
inputs=view_coll_selector,
|
| 376 |
+
outputs=[coll_gallery, loaded_original_paths, selected_paths, selection_status, download_file]
|
| 377 |
+
)
|
| 378 |
+
|
| 379 |
+
if __name__ == "__main__":
|
| 380 |
+
demo.launch()
|
app_old.py
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Photographer's Archive — Gradio app entry point."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import logging
|
| 5 |
+
import os
|
| 6 |
+
import shutil
|
| 7 |
+
import tempfile
|
| 8 |
+
|
| 9 |
+
import gradio as gr
|
| 10 |
+
|
| 11 |
+
from caption_store import all_entries, entry_count, get_all_collections, get_entries_by_collection
|
| 12 |
+
from ingest import ingest_folder
|
| 13 |
+
from search import MIN_RELEVANCE, search
|
| 14 |
+
|
| 15 |
+
logging.basicConfig(level=logging.INFO)
|
| 16 |
+
|
| 17 |
+
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".tiff"}
|
| 18 |
+
|
| 19 |
+
STAGING_DIR = os.path.join(tempfile.gettempdir(), "photographers_archive_uploads")
|
| 20 |
+
os.makedirs(STAGING_DIR, exist_ok=True)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def run_ingest(uploaded_files, collection_name, is_new_collection, new_collection_name):
|
| 24 |
+
if not uploaded_files:
|
| 25 |
+
yield "Please upload at least one image or a folder of images.", gr.update(), gr.update()
|
| 26 |
+
return
|
| 27 |
+
|
| 28 |
+
# Determine collection name
|
| 29 |
+
final_collection = new_collection_name.strip() if is_new_collection else collection_name
|
| 30 |
+
if not final_collection:
|
| 31 |
+
final_collection = "General"
|
| 32 |
+
|
| 33 |
+
staged = []
|
| 34 |
+
for file_path in uploaded_files:
|
| 35 |
+
ext = os.path.splitext(file_path)[-1].lower()
|
| 36 |
+
if ext in IMAGE_EXTENSIONS:
|
| 37 |
+
dest = os.path.join(STAGING_DIR, os.path.basename(file_path))
|
| 38 |
+
shutil.copy2(file_path, dest)
|
| 39 |
+
staged.append(dest)
|
| 40 |
+
|
| 41 |
+
if not staged:
|
| 42 |
+
yield "No supported images found in upload (jpg, jpeg, png, webp, tiff).", gr.update(), gr.update()
|
| 43 |
+
return
|
| 44 |
+
|
| 45 |
+
yield f"Staging images to collection '{final_collection}'...", gr.update(), gr.update()
|
| 46 |
+
|
| 47 |
+
try:
|
| 48 |
+
for processed, total, msg in ingest_folder(STAGING_DIR, collection=final_collection):
|
| 49 |
+
if total > 0:
|
| 50 |
+
pct = int(processed / total * 100)
|
| 51 |
+
yield f"[{processed}/{total}] ({pct}%) {msg}", gr.update(), gr.update()
|
| 52 |
+
else:
|
| 53 |
+
yield msg, gr.update(), gr.update()
|
| 54 |
+
|
| 55 |
+
rows, _ = load_caption_browser()
|
| 56 |
+
cols = gr.Dropdown(choices=get_all_collections(), value=final_collection)
|
| 57 |
+
yield f"✅ Done. Ingested to '{final_collection}'. Store has {entry_count()} images.", rows, cols
|
| 58 |
+
except ValueError as e:
|
| 59 |
+
yield f"Error: {e}", gr.update(), gr.update()
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _parse_meta(raw: str) -> dict | None:
|
| 63 |
+
"""Try to parse raw caption as JSON, with comma-fix fallback."""
|
| 64 |
+
import re
|
| 65 |
+
try:
|
| 66 |
+
return json.loads(raw)
|
| 67 |
+
except (json.JSONDecodeError, TypeError):
|
| 68 |
+
pass
|
| 69 |
+
try:
|
| 70 |
+
fixed = re.sub(r'"\s*\n(\s*")', r'",\n\1', raw)
|
| 71 |
+
return json.loads(fixed)
|
| 72 |
+
except (json.JSONDecodeError, TypeError):
|
| 73 |
+
return None
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def load_caption_browser():
|
| 77 |
+
entries = all_entries()
|
| 78 |
+
if not entries:
|
| 79 |
+
return [], "No captions yet."
|
| 80 |
+
rows = []
|
| 81 |
+
for path, data in entries.items():
|
| 82 |
+
raw = data["caption"]
|
| 83 |
+
meta = _parse_meta(raw)
|
| 84 |
+
if meta:
|
| 85 |
+
summary = meta.get("summary") or "—"
|
| 86 |
+
subj = meta.get("subjects", {})
|
| 87 |
+
attire = ", ".join(subj.get("attire", [])) or "—"
|
| 88 |
+
tags = ", ".join(meta.get("search_tags", [])) or "—"
|
| 89 |
+
else:
|
| 90 |
+
summary = raw[:300] if raw else "—"
|
| 91 |
+
attire = "—"
|
| 92 |
+
tags = "—"
|
| 93 |
+
rows.append([os.path.basename(path), summary, attire, tags])
|
| 94 |
+
return rows, f"{len(rows)} caption(s) in store."
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def run_search(query: str, collection: str = "All"):
|
| 98 |
+
if not query or not query.strip():
|
| 99 |
+
return [], "Please enter a search query."
|
| 100 |
+
if entry_count() == 0:
|
| 101 |
+
return [], "No images indexed yet. Upload and ingest some photos first."
|
| 102 |
+
|
| 103 |
+
col_filter = None if collection == "All" else collection
|
| 104 |
+
results = search(query.strip(), collection=col_filter)
|
| 105 |
+
|
| 106 |
+
if not results:
|
| 107 |
+
return [], f"No matching photos found in {collection} (threshold: {MIN_RELEVANCE})."
|
| 108 |
+
gallery_items = [
|
| 109 |
+
(r["path"], f"Score: {r['score']:.2f} — {r['caption'][:120]}…")
|
| 110 |
+
for r in results
|
| 111 |
+
]
|
| 112 |
+
return gallery_items, f"{len(results)} result(s) found."
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def load_collections_view(collection_name):
|
| 116 |
+
if not collection_name or collection_name == "All":
|
| 117 |
+
entries = all_entries()
|
| 118 |
+
else:
|
| 119 |
+
entries = get_entries_by_collection(collection_name)
|
| 120 |
+
|
| 121 |
+
if not entries:
|
| 122 |
+
return [], f"No images in collection '{collection_name}'."
|
| 123 |
+
|
| 124 |
+
gallery_items = [(path, os.path.basename(path)) for path in entries.keys()]
|
| 125 |
+
return gallery_items, f"{len(entries)} image(s) in '{collection_name}'."
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
ABOUT_TEXT = """
|
| 129 |
+
## ShutterSearch
|
| 130 |
+
|
| 131 |
+
A local-first photo search tool powered by **MiniCPM-V-4.6** (≤7B VLM).
|
| 132 |
+
|
| 133 |
+
**How it works:**
|
| 134 |
+
1. Upload photos using the Ingest page — select an existing collection or create a new one.
|
| 135 |
+
The VLM runs on Modal's GPU and generates rich captions. Captions are cached locally.
|
| 136 |
+
2. Use the Search page to find photos by content, mood, or composition using natural language.
|
| 137 |
+
3. Browse your organized archive in the Collections page.
|
| 138 |
+
|
| 139 |
+
Built for the Hugging Face [Build Small](https://huggingface.co/build-small) hackathon.
|
| 140 |
+
"""
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
# --- UI Layout ---
|
| 144 |
+
with gr.Blocks() as demo:
|
| 145 |
+
# Navigation state
|
| 146 |
+
current_page = gr.State("home")
|
| 147 |
+
|
| 148 |
+
with gr.Sidebar():
|
| 149 |
+
gr.Markdown("# 📷 ShutterSearch")
|
| 150 |
+
gr.Markdown("Modern Photo Archive")
|
| 151 |
+
nav_home = gr.Button("🏠 Home", variant="ghost", size="lg")
|
| 152 |
+
nav_ingest = gr.Button("📥 Ingest", variant="ghost", size="lg")
|
| 153 |
+
nav_search = gr.Button("🔍 Search", variant="ghost", size="lg")
|
| 154 |
+
nav_collections = gr.Button("📁 Collections", variant="ghost", size="lg")
|
| 155 |
+
nav_captions = gr.Button("📝 Captions", variant="ghost", size="lg")
|
| 156 |
+
nav_about = gr.Button("ℹ️ About", variant="ghost", size="lg")
|
| 157 |
+
|
| 158 |
+
gr.HTML("<hr>")
|
| 159 |
+
stats = gr.Label(value=f"Total Photos: {entry_count()}", label="Archive Stats")
|
| 160 |
+
|
| 161 |
+
# --- Pages ---
|
| 162 |
+
with gr.Column() as home_page:
|
| 163 |
+
with gr.Row():
|
| 164 |
+
with gr.Column(scale=2):
|
| 165 |
+
gr.Markdown("""
|
| 166 |
+
# Welcome to ShutterSearch
|
| 167 |
+
### Your intelligent local-first photo archive.
|
| 168 |
+
|
| 169 |
+
ShutterSearch uses state-of-the-art vision models to understand your photography.
|
| 170 |
+
Keep your photos organized in collections and find them instantly with natural language search.
|
| 171 |
+
|
| 172 |
+
- **Semantic Search**: Find "sunset on a beach" even if you didn't tag it.
|
| 173 |
+
- **Automatic Captioning**: Powered by MiniCPM-V-4.6.
|
| 174 |
+
- **Privacy First**: Everything runs on your terms.
|
| 175 |
+
""")
|
| 176 |
+
start_btn = gr.Button("Start Ingesting", variant="primary", size="lg")
|
| 177 |
+
with gr.Column(scale=1):
|
| 178 |
+
# Placeholder for random image
|
| 179 |
+
gr.Image("https://images.unsplash.com/photo-1542038784456-1ea8e935640e?q=80&w=1000&auto=format&fit=crop",
|
| 180 |
+
label="Photography", show_label=False, interactive=False)
|
| 181 |
+
|
| 182 |
+
with gr.Column(visible=False) as ingest_page:
|
| 183 |
+
gr.Markdown("# 📥 Ingest Photos")
|
| 184 |
+
with gr.Row():
|
| 185 |
+
with gr.Column(scale=2):
|
| 186 |
+
upload = gr.File(
|
| 187 |
+
label="Upload Images",
|
| 188 |
+
file_count="multiple",
|
| 189 |
+
file_types=[".jpg", ".jpeg", ".png", ".webp", ".tiff"],
|
| 190 |
+
height=300,
|
| 191 |
+
)
|
| 192 |
+
with gr.Column(scale=1):
|
| 193 |
+
gr.Markdown("### Collection Settings")
|
| 194 |
+
coll_dropdown = gr.Dropdown(
|
| 195 |
+
choices=get_all_collections(),
|
| 196 |
+
label="Select Existing Collection",
|
| 197 |
+
value="General"
|
| 198 |
+
)
|
| 199 |
+
use_new_coll = gr.Checkbox(label="Create New Collection", value=False)
|
| 200 |
+
new_coll_name = gr.Textbox(label="New Collection Name", visible=False)
|
| 201 |
+
|
| 202 |
+
ingest_btn = gr.Button("🚀 Start Ingestion", variant="primary", size="lg")
|
| 203 |
+
|
| 204 |
+
ingest_status = gr.Textbox(label="Status", interactive=False, lines=5)
|
| 205 |
+
|
| 206 |
+
with gr.Column(visible=False) as search_page:
|
| 207 |
+
gr.Markdown("# 🔍 AI Search")
|
| 208 |
+
with gr.Row():
|
| 209 |
+
search_query = gr.Textbox(
|
| 210 |
+
placeholder="Describe what you're looking for... (e.g. 'moody forest portrait')",
|
| 211 |
+
label="Search Query",
|
| 212 |
+
scale=4
|
| 213 |
+
)
|
| 214 |
+
search_col_filter = gr.Dropdown(
|
| 215 |
+
choices=["All"] + get_all_collections(),
|
| 216 |
+
value="All",
|
| 217 |
+
label="Filter by Collection",
|
| 218 |
+
scale=1
|
| 219 |
+
)
|
| 220 |
+
search_btn = gr.Button("Search", variant="primary", scale=1)
|
| 221 |
+
|
| 222 |
+
search_status_msg = gr.Markdown("Enter a query to start searching.")
|
| 223 |
+
search_gallery = gr.Gallery(label="Search Results", columns=4, height="auto")
|
| 224 |
+
|
| 225 |
+
with gr.Column(visible=False) as collections_page:
|
| 226 |
+
gr.Markdown("# 📁 Collections")
|
| 227 |
+
with gr.Row():
|
| 228 |
+
view_coll_selector = gr.Dropdown(
|
| 229 |
+
choices=get_all_collections(),
|
| 230 |
+
value=get_all_collections()[0] if get_all_collections() else "General",
|
| 231 |
+
label="Select Collection to Browse",
|
| 232 |
+
scale=4
|
| 233 |
+
)
|
| 234 |
+
refresh_coll_btn = gr.Button("🔄 Refresh", scale=1)
|
| 235 |
+
|
| 236 |
+
coll_status = gr.Markdown("Browse your organized photos.")
|
| 237 |
+
coll_gallery = gr.Gallery(label="Collection Photos", columns=5, height="auto")
|
| 238 |
+
|
| 239 |
+
with gr.Column(visible=False) as captions_page:
|
| 240 |
+
gr.Markdown("# 📝 Caption Browser")
|
| 241 |
+
with gr.Row():
|
| 242 |
+
refresh_cap_btn = gr.Button("🔄 Refresh Data", variant="secondary")
|
| 243 |
+
cap_status = gr.Textbox(interactive=False, show_label=False, scale=4)
|
| 244 |
+
|
| 245 |
+
caption_table = gr.Dataframe(
|
| 246 |
+
headers=["File", "Summary", "Attire", "Tags"],
|
| 247 |
+
datatype=["str", "str", "str", "str"],
|
| 248 |
+
wrap=True,
|
| 249 |
+
interactive=False,
|
| 250 |
+
column_widths=["15%", "45%", "20%", "20%"],
|
| 251 |
+
)
|
| 252 |
+
|
| 253 |
+
with gr.Column(visible=False) as about_page:
|
| 254 |
+
gr.Markdown("# ℹ️ About ShutterSearch")
|
| 255 |
+
gr.Markdown(ABOUT_TEXT)
|
| 256 |
+
|
| 257 |
+
# --- Navigation Logic ---
|
| 258 |
+
pages = [home_page, ingest_page, search_page, collections_page, captions_page, about_page]
|
| 259 |
+
|
| 260 |
+
def switch_page(page_name):
|
| 261 |
+
updates = [gr.update(visible=(name == page_name)) for name in ["home", "ingest", "search", "collections", "captions", "about"]]
|
| 262 |
+
return updates
|
| 263 |
+
|
| 264 |
+
nav_home.click(fn=lambda: switch_page("home"), outputs=pages)
|
| 265 |
+
nav_ingest.click(fn=lambda: switch_page("ingest"), outputs=pages)
|
| 266 |
+
nav_search.click(fn=lambda: switch_page("search") + [gr.update(choices=["All"] + get_all_collections())], outputs=pages + [search_col_filter])
|
| 267 |
+
nav_collections.click(fn=lambda: switch_page("collections") + [gr.update(choices=get_all_collections())], outputs=pages + [view_coll_selector])
|
| 268 |
+
nav_captions.click(fn=lambda: switch_page("captions"), outputs=pages)
|
| 269 |
+
nav_about.click(fn=lambda: switch_page("about"), outputs=pages)
|
| 270 |
+
|
| 271 |
+
start_btn.click(fn=lambda: switch_page("ingest"), outputs=pages)
|
| 272 |
+
|
| 273 |
+
# --- Page Interactions ---
|
| 274 |
+
use_new_coll.change(fn=lambda x: gr.update(visible=x), inputs=use_new_coll, outputs=new_coll_name)
|
| 275 |
+
|
| 276 |
+
ingest_btn.click(
|
| 277 |
+
fn=run_ingest,
|
| 278 |
+
inputs=[upload, coll_dropdown, use_new_coll, new_coll_name],
|
| 279 |
+
outputs=[ingest_status, caption_table, coll_dropdown]
|
| 280 |
+
).then(
|
| 281 |
+
fn=lambda: f"Total Photos: {entry_count()}", outputs=stats
|
| 282 |
+
)
|
| 283 |
+
|
| 284 |
+
search_btn.click(fn=run_search, inputs=[search_query, search_col_filter], outputs=[search_gallery, search_status_msg])
|
| 285 |
+
search_query.submit(fn=run_search, inputs=[search_query, search_col_filter], outputs=[search_gallery, search_status_msg])
|
| 286 |
+
|
| 287 |
+
view_coll_selector.change(fn=load_collections_view, inputs=view_coll_selector, outputs=[coll_gallery, coll_status])
|
| 288 |
+
refresh_coll_btn.click(fn=load_collections_view, inputs=view_coll_selector, outputs=[coll_gallery, coll_status])
|
| 289 |
+
|
| 290 |
+
refresh_cap_btn.click(fn=load_caption_browser, outputs=[caption_table, cap_status])
|
| 291 |
+
|
| 292 |
+
demo.load(fn=load_caption_browser, outputs=[caption_table, cap_status])
|
| 293 |
+
demo.load(fn=lambda: load_collections_view(get_all_collections()[0] if get_all_collections() else "General"), outputs=[coll_gallery, coll_status])
|
| 294 |
+
|
| 295 |
+
if __name__ == "__main__":
|
| 296 |
+
demo.launch(theme=gr.themes.Soft(primary_hue="green", secondary_hue="emerald"))
|
caption_store.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Persistent caption store backed by a local JSON file."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import os
|
| 5 |
+
import time
|
| 6 |
+
from typing import Optional
|
| 7 |
+
|
| 8 |
+
CAPTION_STORE_PATH = "captions.json"
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def _load() -> dict:
|
| 12 |
+
if not os.path.exists(CAPTION_STORE_PATH):
|
| 13 |
+
return {}
|
| 14 |
+
with open(CAPTION_STORE_PATH, "r", encoding="utf-8") as f:
|
| 15 |
+
return json.load(f)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _save(store: dict) -> None:
|
| 19 |
+
with open(CAPTION_STORE_PATH, "w", encoding="utf-8") as f:
|
| 20 |
+
json.dump(store, f, indent=2, ensure_ascii=False)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _as_list(val) -> list:
|
| 24 |
+
"""Ensure a value is always a list — handles string/list/None from model output."""
|
| 25 |
+
if not val:
|
| 26 |
+
return []
|
| 27 |
+
if isinstance(val, list):
|
| 28 |
+
return val
|
| 29 |
+
return [val] # single string → wrap
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _try_parse_json(raw: str) -> dict | None:
|
| 33 |
+
"""Parse JSON, with a fallback that inserts missing commas between fields."""
|
| 34 |
+
try:
|
| 35 |
+
return json.loads(raw)
|
| 36 |
+
except json.JSONDecodeError:
|
| 37 |
+
pass
|
| 38 |
+
# Common model mistake: missing comma after a string value before next key
|
| 39 |
+
import re
|
| 40 |
+
fixed = re.sub(r'"\s*\n(\s*")', r'",\n\1', raw)
|
| 41 |
+
try:
|
| 42 |
+
return json.loads(fixed)
|
| 43 |
+
except json.JSONDecodeError:
|
| 44 |
+
return None
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def flatten_metadata(meta: dict) -> str:
|
| 48 |
+
parts = []
|
| 49 |
+
|
| 50 |
+
if meta.get("summary"):
|
| 51 |
+
parts.append(meta["summary"])
|
| 52 |
+
|
| 53 |
+
subj = meta.get("subjects", {})
|
| 54 |
+
# NEW: Include headcount explicitly for numerical search queries (e.g., "two people")
|
| 55 |
+
if subj.get("people_count") is not None:
|
| 56 |
+
parts.append(f"People count: {subj['people_count']}")
|
| 57 |
+
if _as_list(subj.get("attire")):
|
| 58 |
+
parts.append("Attire: " + ", ".join(_as_list(subj["attire"])))
|
| 59 |
+
if _as_list(subj.get("primary_subjects")):
|
| 60 |
+
parts.append("Subjects: " + ", ".join(_as_list(subj["primary_subjects"])))
|
| 61 |
+
if _as_list(subj.get("relationships")):
|
| 62 |
+
parts.append("Relationships: " + ", ".join(_as_list(subj["relationships"])))
|
| 63 |
+
|
| 64 |
+
scene = meta.get("scene", {})
|
| 65 |
+
if scene.get("location_type"):
|
| 66 |
+
parts.append("Location: " + scene["location_type"])
|
| 67 |
+
if scene.get("environment"):
|
| 68 |
+
parts.append("Environment: " + scene["environment"])
|
| 69 |
+
if _as_list(scene.get("setting_details")):
|
| 70 |
+
parts.append("Setting: " + ", ".join(_as_list(scene["setting_details"])))
|
| 71 |
+
|
| 72 |
+
actions = meta.get("actions", {})
|
| 73 |
+
if actions.get("primary_action"):
|
| 74 |
+
parts.append("Action: " + actions["primary_action"])
|
| 75 |
+
if _as_list(actions.get("body_language")):
|
| 76 |
+
parts.append("Body language: " + ", ".join(_as_list(actions["body_language"])))
|
| 77 |
+
|
| 78 |
+
lighting = meta.get("lighting", {})
|
| 79 |
+
if lighting.get("lighting_style"):
|
| 80 |
+
parts.append("Lighting: " + lighting["lighting_style"])
|
| 81 |
+
if lighting.get("time_of_day_estimate"):
|
| 82 |
+
parts.append("Time of day: " + lighting["time_of_day_estimate"])
|
| 83 |
+
|
| 84 |
+
mood = meta.get("mood", {})
|
| 85 |
+
if _as_list(mood.get("primary_emotions")):
|
| 86 |
+
parts.append("Emotions: " + ", ".join(_as_list(mood["primary_emotions"])))
|
| 87 |
+
if mood.get("atmosphere"):
|
| 88 |
+
parts.append("Atmosphere: " + mood["atmosphere"])
|
| 89 |
+
|
| 90 |
+
comp = meta.get("composition", {})
|
| 91 |
+
if comp.get("shot_type"):
|
| 92 |
+
parts.append("Shot: " + comp["shot_type"])
|
| 93 |
+
if comp.get("camera_angle"):
|
| 94 |
+
parts.append("Angle: " + comp["camera_angle"])
|
| 95 |
+
|
| 96 |
+
tech = meta.get("technical_cues", {})
|
| 97 |
+
if _as_list(tech.get("color_palette")):
|
| 98 |
+
parts.append("Colors: " + ", ".join(_as_list(tech["color_palette"])))
|
| 99 |
+
# NEW: Missing depth of field (crucial for portrait photography search!)
|
| 100 |
+
if tech.get("depth_of_field"):
|
| 101 |
+
parts.append("Depth of field: " + tech["depth_of_field"])
|
| 102 |
+
|
| 103 |
+
if _as_list(meta.get("search_tags")):
|
| 104 |
+
parts.append("Tags: " + ", ".join(_as_list(meta["search_tags"])))
|
| 105 |
+
if _as_list(meta.get("archive_keywords")):
|
| 106 |
+
parts.append("Keywords: " + ", ".join(_as_list(meta["archive_keywords"])))
|
| 107 |
+
|
| 108 |
+
return " | ".join(parts)
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def get_entry(image_path: str) -> Optional[dict]:
|
| 112 |
+
return _load().get(image_path)
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def upsert_entry(image_path: str, caption: str, mtime: float, collection: str = "General") -> None:
|
| 116 |
+
"""
|
| 117 |
+
caption may be raw JSON string (new structured format) or plain text (legacy).
|
| 118 |
+
We store both the raw string and a flattened search_text.
|
| 119 |
+
"""
|
| 120 |
+
store = _load()
|
| 121 |
+
|
| 122 |
+
# Try to parse as structured JSON (with comma-fix fallback)
|
| 123 |
+
search_text = caption
|
| 124 |
+
meta = _try_parse_json(caption)
|
| 125 |
+
if meta:
|
| 126 |
+
search_text = flatten_metadata(meta)
|
| 127 |
+
|
| 128 |
+
store[image_path] = {
|
| 129 |
+
"caption": caption, # raw (JSON string or plain text)
|
| 130 |
+
"search_text": search_text, # flattened for embedding
|
| 131 |
+
"mtime": mtime,
|
| 132 |
+
"collection": collection,
|
| 133 |
+
"ingested_at": time.time(),
|
| 134 |
+
}
|
| 135 |
+
_save(store)
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def mark_error(image_path: str, error: str, collection: str = "General") -> None:
|
| 139 |
+
store = _load()
|
| 140 |
+
store[image_path] = {
|
| 141 |
+
"caption": None,
|
| 142 |
+
"error": error,
|
| 143 |
+
"mtime": os.path.getmtime(image_path) if os.path.exists(image_path) else 0,
|
| 144 |
+
"collection": collection,
|
| 145 |
+
"ingested_at": time.time(),
|
| 146 |
+
}
|
| 147 |
+
_save(store)
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def all_entries() -> dict:
|
| 151 |
+
"""Return only entries that have a valid caption."""
|
| 152 |
+
return {
|
| 153 |
+
path: data
|
| 154 |
+
for path, data in _load().items()
|
| 155 |
+
if data.get("caption")
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def entry_count() -> int:
|
| 160 |
+
return len(all_entries())
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def get_all_collections() -> list[str]:
|
| 164 |
+
"""Return a sorted list of all unique collection names."""
|
| 165 |
+
store = _load()
|
| 166 |
+
collections = set()
|
| 167 |
+
for entry in store.values():
|
| 168 |
+
coll = entry.get("collection")
|
| 169 |
+
if coll:
|
| 170 |
+
collections.add(coll)
|
| 171 |
+
if not collections:
|
| 172 |
+
return ["General"]
|
| 173 |
+
return sorted(list(collections))
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def get_entries_by_collection(collection: str) -> dict:
|
| 177 |
+
"""Return all valid entries belonging to a specific collection."""
|
| 178 |
+
return {
|
| 179 |
+
path: data
|
| 180 |
+
for path, data in all_entries().items()
|
| 181 |
+
if data.get("collection") == collection
|
| 182 |
+
}
|
captions.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"C:\\Users\\swika\\AppData\\Local\\Temp\\photographers_archive_uploads\\aastha-bansal-W1wkx5kcaBk-unsplash.jpg": {
|
| 3 |
+
"caption": "{\n \"summary\": \"A couple dressed in traditional wedding attire is posing closely together, with the man in a richly embroidered robe and turban and the woman in elaborate jewelry and a detailed lehenga. The scene is illuminated with focused lighting against a dark background.\",\n \"subjects\": {\n \"people_count\": 2,\n \"primary_subjects\": [\n \"groom\",\n \"bride\"\n ],\n \"relationships\": [\n \"couple\"\n ]\n },\n \"attire\": [\n \"richly embroidered maroon and cream robe with gold detailing\",\n \"gold and black embellished lehenga with sheer pink overlay\",\n \"red turban with white feather and ornate jewelry\",\n \"gold headpiece and heavy jewelry on the bride\"\n ],\n \"scene\": {\n \"location_type\": \"outdoor event\",\n \"environment\": \"nighttime with dark background and artificial lighting\",\n \"setting_details\": []\n },\n \"actions\": {\n \"primary_action\": \"posing and embracing\",\n \"body_language\": [\n \"close embrace\",\n \"smiling\",\n \"hand on shoulder\"\n ]\n },\n \"lighting\": {\n \"lighting_style\": \"focused spotlight with dark background\",\n \"time_of_day_estimate\": \"night\"\n },\n \"composition\": {\n \"shot_type\": \"portrait\",\n \"camera_angle\": \"medium shot, slightly angled\"\n },\n \"mood\": {\n \"primary_emotions\": [\n \"love\",\n \"joy\",\n \"intimacy\"\n ],\n \"atmosphere\": \"festive and elegant\"\n },\n \"technical_cues\": {\n \"color_palette\": [\n \"rich reds\",\n \"gold accents\",\n \"dark backgrounds\",\n \"soft pinks and whites\"\n ],\n \"depth_of_field\": \"deep focus on the subjects with blurred background\"\n },\n \"search_tags\": [\"wedding\", \"marriage\", \"traditional attire\", \"couple\", \"portrait\"],\n \"archive_keywords\": [\"wedding photos\", \"traditional ceremony\", \"marital celebration\", \"formal attire\"]\n}",
|
| 4 |
+
"search_text": "A couple dressed in traditional wedding attire is posing closely together, with the man in a richly embroidered robe and turban and the woman in elaborate jewelry and a detailed lehenga. The scene is illuminated with focused lighting against a dark background. | People count: 2 | Subjects: groom, bride | Relationships: couple | Location: outdoor event | Environment: nighttime with dark background and artificial lighting | Action: posing and embracing | Body language: close embrace, smiling, hand on shoulder | Lighting: focused spotlight with dark background | Time of day: night | Emotions: love, joy, intimacy | Atmosphere: festive and elegant | Shot: portrait | Angle: medium shot, slightly angled | Colors: rich reds, gold accents, dark backgrounds, soft pinks and whites | Depth of field: deep focus on the subjects with blurred background | Tags: wedding, marriage, traditional attire, couple, portrait | Keywords: wedding photos, traditional ceremony, marital celebration, formal attire",
|
| 5 |
+
"mtime": 1781539262.2399652,
|
| 6 |
+
"ingested_at": 1781539311.3692434
|
| 7 |
+
},
|
| 8 |
+
"C:\\Users\\swika\\AppData\\Local\\Temp\\photographers_archive_uploads\\amish-thakkar-EiGfP6DxgN8-unsplash.jpg": {
|
| 9 |
+
"caption": "{\n \"summary\": \"The image captures a traditional wedding ritual featuring hands adorned with ornate bangles and henna, holding a flower and leaf arrangement. The focus is on cultural attire and symbolic gestures.\",\n \"subjects\": {\n \"people_count\": 2,\n \"primary_subjects\": [\n \"groom\",\n \"bride\"\n ],\n \"relationships\": [\n \"exchange of ritual items\"\n ]\n },\n \"attire\": [\n \"embroidered traditional clothing with intricate patterns and gold detailing\",\n \"henna-decorated hands with haki patterns\",\n \"richly decorated bangles in red, green, and gold\"\n ],\n \"scene\": {\n \"location_type\": \"outdoor wedding setting\",\n \"environment\": \"green grassy area with soft background\",\n \"setting_details\": []\n },\n \"actions\": {\n \"primary_action\": \"holding a flower and leaf arrangement\",\n \"body_language\": [\n \"gentle offering gesture\",\n \"hands positioned closely\"\n ]\n },\n \"lighting\": {\n \"lighting_style\": \"soft natural light\",\n \"time_of_day_estimate\": \"daytime\"\n },\n \"composition\": {\n \"shot_type\": \"close-up ritual\",\n \"camera_angle\": \"overhead and slightly angled\"\n },\n \"mood\": {\n \"primary_emotions\": [\n \"ceremonious\",\n \"warm\",\n \"special\"\n ],\n \"atmosphere\": \"festive and traditional\"\n },\n \"technical_cues\": {\n \"color_palette\": [\n \"red, green, gold, and beige\",\n \"soft green background\"\n ],\n \"depth_of_field\": \"shallow depth of field highlighting the hands and flowers\"\n },\n \"search_tags\": [\"wedding\", \"tradition\", \"ritual\", \"henna\", \"bangles\", \"couple\"],\n \"archive_keywords\": [\"marriage\", \"cultural ceremony\", \"traditional attire\", \"wedding ritual\"]\n}",
|
| 10 |
+
"search_text": "The image captures a traditional wedding ritual featuring hands adorned with ornate bangles and henna, holding a flower and leaf arrangement. The focus is on cultural attire and symbolic gestures. | People count: 2 | Subjects: groom, bride | Relationships: exchange of ritual items | Location: outdoor wedding setting | Environment: green grassy area with soft background | Action: holding a flower and leaf arrangement | Body language: gentle offering gesture, hands positioned closely | Lighting: soft natural light | Time of day: daytime | Emotions: ceremonious, warm, special | Atmosphere: festive and traditional | Shot: close-up ritual | Angle: overhead and slightly angled | Colors: red, green, gold, and beige, soft green background | Depth of field: shallow depth of field highlighting the hands and flowers | Tags: wedding, tradition, ritual, henna, bangles, couple | Keywords: marriage, cultural ceremony, traditional attire, wedding ritual",
|
| 11 |
+
"mtime": 1781537948.9068933,
|
| 12 |
+
"ingested_at": 1781539332.2572837
|
| 13 |
+
},
|
| 14 |
+
"C:\\Users\\swika\\AppData\\Local\\Temp\\photographers_archive_uploads\\amish-thakkar-lAY2TAhN06k-unsplash.jpg": {
|
| 15 |
+
"caption": "{\n \"summary\": \"A bride in traditional red and gold attire is receiving a decorative element from an adult, with floral and ornate background elements enhancing the ceremonial setting.\"\n \"subjects\": {\n \"people_count\": 2,\n \"primary_subjects\": [\n \"bride\",\n \"adult assisting\"\n ],\n \"relationships\": [\n \"bride receiving a decorative item\"\n ]\n },\n \"attire\": [\n \"rich red bridal gown with gold embroidery\",\n \"delicate sheer veil with red and gold patterns\",\n \"gold jewelry including necklace, earrings, and headpiece\",\n \"pink rose and baby's breath garland\"\n ],\n \"scene\": {\n \"location_type\": \"wedding venue\",\n \"environment\": \"festive and ornate\",\n \"setting_details\": [\"white floral arrangements\", \"gold decorative backdrop\"]\n },\n \"actions\": {\n \"primary_action\": \"adjusting or placing a decorative element on the bride's head\",\n \"body_language\": [\n \"bride smiling, closed eyes\",\n \"adult hand gently placing item\"\n ]\n },\n \"lighting\": {\n \"lighting_style\": \"soft and warm\",\n \"time_of_day_estimate\": \"indoor celebration\"\n },\n \"composition\": {\n \"shot_type\": \"close-up portrait\",\n \"camera_angle\": \"low angle, focusing on the bride's face and attire\"\n },\n \"mood\": {\n \"primary_emotions\": [\n \"joy\",\n \"delicacy\",\n \"celebration\"\n ],\n \"atmosphere\": \"festive and elegant\"\n },\n \"technical_cues\": {\n \"color_palette\": [\n \"red and gold\",\n \"white and pink\",\n \"gold accents\"\n ],\n \"depth_of_field\": \"shallow depth of field with blurred floral background\"\n },\n \"search_tags\": [\"bride\", \"wedding\", \"traditional\", \"jewelry\", \"flowers\"],\n \"archive_keywords\": [\"marriage\", \"cultural attire\", \"wedding ceremony\", \"bride's dress\", \"traditional decor\"]\n}",
|
| 16 |
+
"search_text": "A bride in traditional red and gold attire is receiving a decorative element from an adult, with floral and ornate background elements enhancing the ceremonial setting. | People count: 2 | Subjects: bride, adult assisting | Relationships: bride receiving a decorative item | Location: wedding venue | Environment: festive and ornate | Setting: white floral arrangements, gold decorative backdrop | Action: adjusting or placing a decorative element on the bride's head | Body language: bride smiling, closed eyes, adult hand gently placing item | Lighting: soft and warm | Time of day: indoor celebration | Emotions: joy, delicacy, celebration | Atmosphere: festive and elegant | Shot: close-up portrait | Angle: low angle, focusing on the bride's face and attire | Colors: red and gold, white and pink, gold accents | Depth of field: shallow depth of field with blurred floral background | Tags: bride, wedding, traditional, jewelry, flowers | Keywords: marriage, cultural attire, wedding ceremony, bride's dress, traditional decor",
|
| 17 |
+
"mtime": 1781539262.1515195,
|
| 18 |
+
"ingested_at": 1781539353.038621
|
| 19 |
+
},
|
| 20 |
+
"C:\\Users\\swika\\AppData\\Local\\Temp\\photographers_archive_uploads\\amish-thakkkar-EiGfP6DxgN8-unsplash.jpg": {
|
| 21 |
+
"caption": "{\n \"summary\": \"The image captures a traditional wedding ritual featuring hands adorned with ornate bangles and henna, holding a flower and leaf arrangement. The focus is on cultural attire and symbolic gestures.\",\n \"subjects\": {\n \"people_count\": 2,\n \"primary_subjects\": [\n \"groom\",\n \"bride\"\n ],\n \"relationships\": [\n \"exchange of ritual items\"\n ]\n },\n \"attire\": [\n \"embroidered traditional clothing with intricate patterns and gold detailing\",\n \"henna-decorated hands with haki patterns\",\n \"richly decorated bangles in red, green, and gold\"\n ],\n \"scene\": {\n \"location_type\": \"outdoor wedding setting\",\n \"environment\": \"green grassy area with soft background\",\n \"setting_details\": []\n },\n \"actions\": {\n \"primary_action\": \"holding a flower and leaf arrangement\",\n \"body_language\": [\n \"gentle offering gesture\",\n \"hands positioned closely\"\n ]\n },\n \"lighting\": {\n \"lighting_style\": \"soft natural light\",\n \"time_of_day_estimate\": \"daytime\"\n },\n \"composition\": {\n \"shot_type\": \"close-up ritual\",\n \"camera_angle\": \"overhead and slightly angled\"\n },\n \"mood\": {\n \"primary_emotions\": [\n \"ceremonious\",\n \"warm\",\n \"special\"\n ],\n \"atmosphere\": \"festive and traditional\"\n },\n \"technical_cues\": {\n \"color_palette\": [\n \"red, green, gold, and beige\",\n \"soft green background\"\n ],\n \"depth_of_field\": \"shallow depth of field highlighting the hands and flowers\"\n },\n \"search_tags\": [\"wedding\", \"tradition\", \"ritual\", \"henna\", \"bangles\", \"couple\"],\n \"archive_keywords\": [\"marriage\", \"cultural ceremony\", \"traditional attire\", \"wedding ritual\"]\n}",
|
| 22 |
+
"search_text": "The image captures a traditional wedding ritual featuring hands adorned with ornate bangles and henna, holding a flower and leaf arrangement. The focus is on cultural attire and symbolic gestures. | People count: 2 | Subjects: groom, bride | Relationships: exchange of ritual items | Location: outdoor wedding setting | Environment: green grassy area with soft background | Action: holding a flower and leaf arrangement | Body language: gentle offering gesture, hands positioned closely | Lighting: soft natural light | Time of day: daytime | Emotions: ceremonious, warm, special | Atmosphere: festive and traditional | Shot: close-up ritual | Angle: overhead and slightly angled | Colors: red, green, gold, and beige, soft green background | Depth of field: shallow depth of field highlighting the hands and flowers | Tags: wedding, tradition, ritual, henna, bangles, couple | Keywords: marriage, cultural ceremony, traditional attire, wedding ritual",
|
| 23 |
+
"mtime": 1781539262.1897466,
|
| 24 |
+
"ingested_at": 1781539373.021067
|
| 25 |
+
},
|
| 26 |
+
"C:\\Users\\swika\\AppData\\Local\\Temp\\photographers_archive_uploads\\arto-suraj-Y3QEAct9JT4-unsplash.jpg": {
|
| 27 |
+
"caption": "{\n \"summary\": \"A couple is posing for a wedding portrait, with the woman in a rich red embroidered gown and the man in a light pink traditional suit. The background is adorned with warm bokeh lights, suggesting an evening event.\"\n \"subjects\": {\n \"people_count\": 2,\n \"primary_subjects\": [\n \"bride\",\n \"groom\"\n ],\n \"relationships\": [\n \"couple\"\n ]\n },\n \"attire\": [\n \"bride: red embroidered lehenga with gold embellishments and jewelry\",\n \"groom: light pink textured sherwani with pearl necklaces and a pink turban\"\n ],\n \"scene\": {\n \"location_type\": \"outdoor or event space\",\n \"environment\": \"nighttime with decorative string lights\",\n \"setting_details\": []\n },\n \"actions\": {\n \"primary_action\": \"posing for a wedding portrait\",\n \"body_language\": [\n \"smiling\",\n \"hands clasped\",\n \"touching each other's shoulders\"\n ]\n },\n \"lighting\": {\n \"lighting_style\": \"soft artificial lighting with bokeh background\",\n \"time_of_day_estimate\": \"night\"\n },\n \"composition\": {\n \"shot_type\": \"portrait\",\n \"camera_angle\": \"eye level\"\n },\n \"mood\": {\n \"primary_emotions\": [\n \"happy\",\n \"celebratory\"\n ],\n \"atmosphere\": \"festive and elegant\"\n },\n \"technical_cues\": {\n \"color_palette\": [\n \"red and pink\",\n \"gold and white\",\n \"dark background with warm lights\"\n ],\n \"depth_of_field\": \"shallow depth of field highlighting subjects\"\n },\n \"search_tags\": [\"wedding\", \"bride\", \"groom\", \"tradition\", \"event\"],\n \"archive_keywords\": [\"marriage\", \"couple\", \"wedding portrait\", \"traditional attire\", \"nighttime\"]\n}",
|
| 28 |
+
"search_text": "A couple is posing for a wedding portrait, with the woman in a rich red embroidered gown and the man in a light pink traditional suit. The background is adorned with warm bokeh lights, suggesting an evening event. | People count: 2 | Subjects: bride, groom | Relationships: couple | Location: outdoor or event space | Environment: nighttime with decorative string lights | Action: posing for a wedding portrait | Body language: smiling, hands clasped, touching each other's shoulders | Lighting: soft artificial lighting with bokeh background | Time of day: night | Emotions: happy, celebratory | Atmosphere: festive and elegant | Shot: portrait | Angle: eye level | Colors: red and pink, gold and white, dark background with warm lights | Depth of field: shallow depth of field highlighting subjects | Tags: wedding, bride, groom, tradition, event | Keywords: marriage, couple, wedding portrait, traditional attire, nighttime",
|
| 29 |
+
"mtime": 1781539262.1016433,
|
| 30 |
+
"ingested_at": 1781539392.780786
|
| 31 |
+
}
|
| 32 |
+
}
|
ingest.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Photo ingestion: scan folder, caption new/changed images via Modal."""
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
import os
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Generator
|
| 7 |
+
|
| 8 |
+
from caption_store import get_entry, mark_error, upsert_entry
|
| 9 |
+
|
| 10 |
+
logger = logging.getLogger(__name__)
|
| 11 |
+
|
| 12 |
+
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".tiff"}
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def scan_images(folder: str) -> list[str]:
|
| 16 |
+
folder_path = Path(folder)
|
| 17 |
+
if not folder_path.exists() or not folder_path.is_dir():
|
| 18 |
+
raise ValueError(f"Folder not found or not readable: {folder}")
|
| 19 |
+
return [
|
| 20 |
+
str(p)
|
| 21 |
+
for p in folder_path.rglob("*")
|
| 22 |
+
if p.suffix.lower() in IMAGE_EXTENSIONS and p.is_file()
|
| 23 |
+
]
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _needs_captioning(image_path: str) -> bool:
|
| 27 |
+
entry = get_entry(image_path)
|
| 28 |
+
if entry is None:
|
| 29 |
+
return True
|
| 30 |
+
return os.path.getmtime(image_path) != entry.get("mtime", -1)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _get_captioner():
|
| 34 |
+
"""Return a handle to the deployed Modal Captioner."""
|
| 35 |
+
import modal
|
| 36 |
+
Captioner = modal.Cls.from_name("photographers-archive", "Captioner")
|
| 37 |
+
return Captioner()
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def ingest_folder(folder: str, collection: str = "General") -> Generator[tuple[int, int, str], None, None]:
|
| 41 |
+
"""
|
| 42 |
+
Yields (processed_count, total_count, status_message) during ingestion.
|
| 43 |
+
Raises ValueError for invalid folder paths.
|
| 44 |
+
"""
|
| 45 |
+
images = scan_images(folder)
|
| 46 |
+
total = len(images)
|
| 47 |
+
|
| 48 |
+
if total == 0:
|
| 49 |
+
yield (0, 0, "No supported images found in the selected folder.")
|
| 50 |
+
return
|
| 51 |
+
|
| 52 |
+
captioner = _get_captioner()
|
| 53 |
+
processed = 0
|
| 54 |
+
|
| 55 |
+
for image_path in images:
|
| 56 |
+
if not _needs_captioning(image_path):
|
| 57 |
+
processed += 1
|
| 58 |
+
yield (processed, total, f"Skipped (cached): {os.path.basename(image_path)}")
|
| 59 |
+
continue
|
| 60 |
+
|
| 61 |
+
try:
|
| 62 |
+
with open(image_path, "rb") as f:
|
| 63 |
+
image_bytes = f.read()
|
| 64 |
+
caption = captioner.caption.remote(image_bytes, os.path.basename(image_path))
|
| 65 |
+
upsert_entry(image_path, caption, os.path.getmtime(image_path), collection=collection)
|
| 66 |
+
processed += 1
|
| 67 |
+
yield (processed, total, f"Captioned: {os.path.basename(image_path)}")
|
| 68 |
+
except Exception as e:
|
| 69 |
+
logger.error("Failed to caption %s: %s", image_path, e)
|
| 70 |
+
mark_error(image_path, str(e), collection=collection)
|
| 71 |
+
processed += 1
|
| 72 |
+
yield (processed, total, f"Error: {os.path.basename(image_path)}")
|
| 73 |
+
|
| 74 |
+
yield (total, total, f"Done. {total} images processed.")
|
logic.py
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# import json
|
| 2 |
+
# import logging
|
| 3 |
+
# import os
|
| 4 |
+
# import re
|
| 5 |
+
# import shutil
|
| 6 |
+
# import tempfile
|
| 7 |
+
# import gradio as gr
|
| 8 |
+
|
| 9 |
+
# from caption_store import all_entries, entry_count, get_all_collections, get_entries_by_collection
|
| 10 |
+
# from ingest import ingest_folder
|
| 11 |
+
# from search import MIN_RELEVANCE, search
|
| 12 |
+
|
| 13 |
+
# logging.basicConfig(level=logging.INFO)
|
| 14 |
+
|
| 15 |
+
# IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".tiff"}
|
| 16 |
+
# STAGING_DIR = os.path.join(tempfile.gettempdir(), "photographers_archive_uploads")
|
| 17 |
+
# os.makedirs(STAGING_DIR, exist_ok=True)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
# def run_ingest(uploaded_files, collection_name, is_new_collection, new_collection_name):
|
| 21 |
+
# """
|
| 22 |
+
# Clears the staging directory, moves uploaded images, validates the destination
|
| 23 |
+
# collection, and runs the vision-model ingestion process.
|
| 24 |
+
# """
|
| 25 |
+
# if not uploaded_files:
|
| 26 |
+
# yield "⚠️ Please upload at least one image to begin.", gr.update(), gr.update()
|
| 27 |
+
# return
|
| 28 |
+
|
| 29 |
+
# # 1. Determine and validate collection selection context
|
| 30 |
+
# if is_new_collection:
|
| 31 |
+
# final_collection = new_collection_name.strip()
|
| 32 |
+
# if not final_collection:
|
| 33 |
+
# yield "⚠️ Ingestion halted: Please specify a valid name for the new collection.", gr.update(), gr.update()
|
| 34 |
+
# return
|
| 35 |
+
# else:
|
| 36 |
+
# final_collection = collection_name
|
| 37 |
+
# if not final_collection:
|
| 38 |
+
# final_collection = "General"
|
| 39 |
+
|
| 40 |
+
# # 2. Housekeep staging directory (clear residuals from prior sessions)
|
| 41 |
+
# try:
|
| 42 |
+
# for filename in os.listdir(STAGING_DIR):
|
| 43 |
+
# file_path = os.path.join(STAGING_DIR, filename)
|
| 44 |
+
# if os.path.isfile(file_path) or os.path.islink(file_path):
|
| 45 |
+
# os.unlink(file_path)
|
| 46 |
+
# elif os.path.isdir(file_path):
|
| 47 |
+
# shutil.rmtree(file_path)
|
| 48 |
+
# except Exception as e:
|
| 49 |
+
# logging.warning(f"Could not clear staging directory fully: {e}")
|
| 50 |
+
|
| 51 |
+
# # 3. Stage files
|
| 52 |
+
# staged = []
|
| 53 |
+
# for file_path in uploaded_files:
|
| 54 |
+
# ext = os.path.splitext(file_path)[-1].lower()
|
| 55 |
+
# if ext in IMAGE_EXTENSIONS:
|
| 56 |
+
# dest = os.path.join(STAGING_DIR, os.path.basename(file_path))
|
| 57 |
+
# shutil.copy2(file_path, dest)
|
| 58 |
+
# staged.append(dest)
|
| 59 |
+
|
| 60 |
+
# if not staged:
|
| 61 |
+
# yield "⚠️ Format error: No supported images (jpg, jpeg, png, webp, tiff) found.", gr.update(), gr.update()
|
| 62 |
+
# return
|
| 63 |
+
|
| 64 |
+
# yield f"Staging completed. Initializing pipeline for: '{final_collection}'...", gr.update(), gr.update()
|
| 65 |
+
|
| 66 |
+
# # 4. Ingest and track progress
|
| 67 |
+
# try:
|
| 68 |
+
# for processed, total, msg in ingest_folder(STAGING_DIR, collection=final_collection):
|
| 69 |
+
# if total > 0:
|
| 70 |
+
# pct = int(processed / total * 100)
|
| 71 |
+
# yield f">> Progress: [{processed}/{total}] ({pct}%) - {msg}", gr.update(), gr.update()
|
| 72 |
+
# else:
|
| 73 |
+
# yield f">> {msg}", gr.update(), gr.update()
|
| 74 |
+
|
| 75 |
+
# # 5. Fetch updated data and regenerate dropdown choices safely
|
| 76 |
+
# rows, _ = load_caption_browser()
|
| 77 |
+
# choices = get_all_collections()
|
| 78 |
+
# if not choices:
|
| 79 |
+
# choices = ["General"]
|
| 80 |
+
|
| 81 |
+
# dropdown_val = final_collection if final_collection in choices else choices[0]
|
| 82 |
+
# cols = gr.Dropdown(choices=choices, value=dropdown_val)
|
| 83 |
+
|
| 84 |
+
# yield f"System Log: Done. Ingested to '{final_collection}'. Store has {entry_count()} images.", rows, cols
|
| 85 |
+
# except ValueError as e:
|
| 86 |
+
# yield f"Pipeline Failure: {e}", gr.update(), gr.update()
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
# def _parse_meta(raw: str) -> dict | None:
|
| 90 |
+
# """Attempts to parse raw metadata caption as JSON with syntax fallback support."""
|
| 91 |
+
# try:
|
| 92 |
+
# return json.loads(raw)
|
| 93 |
+
# except (json.JSONDecodeError, TypeError):
|
| 94 |
+
# pass
|
| 95 |
+
# try:
|
| 96 |
+
# # Fallback fix for missing trailing commas in lists
|
| 97 |
+
# fixed = re.sub(r'"\s*\n(\s*")', r'",\n\1', raw)
|
| 98 |
+
# return json.loads(fixed)
|
| 99 |
+
# except (json.JSONDecodeError, TypeError):
|
| 100 |
+
# return None
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
# def load_caption_browser():
|
| 104 |
+
# """Loads all caption entries structured for tabular visualization."""
|
| 105 |
+
# entries = all_entries()
|
| 106 |
+
# if not entries:
|
| 107 |
+
# return [], "No captions index exists yet."
|
| 108 |
+
|
| 109 |
+
# rows = []
|
| 110 |
+
# for path, data in entries.items():
|
| 111 |
+
# raw = data["caption"]
|
| 112 |
+
# meta = _parse_meta(raw)
|
| 113 |
+
# if meta:
|
| 114 |
+
# summary = meta.get("summary") or "—"
|
| 115 |
+
# subj = meta.get("subjects", {})
|
| 116 |
+
# attire = ", ".join(subj.get("attire", [])) or "—"
|
| 117 |
+
# tags = ", ".join(meta.get("search_tags", [])) or "—"
|
| 118 |
+
# else:
|
| 119 |
+
# summary = raw[:300] if raw else "—"
|
| 120 |
+
# attire = "—"
|
| 121 |
+
# tags = "—"
|
| 122 |
+
# rows.append([os.path.basename(path), summary, attire, tags])
|
| 123 |
+
|
| 124 |
+
# return rows, f"{len(rows)} caption record(s) loaded."
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
# def run_search(query: str, collection: str = "All"):
|
| 128 |
+
# """Searches indexed captions using natural language match thresholding."""
|
| 129 |
+
# if not query or not query.strip():
|
| 130 |
+
# return [], "Please enter a valid search parameter."
|
| 131 |
+
# if entry_count() == 0:
|
| 132 |
+
# return [], "No images indexed yet. Please ingest photos first."
|
| 133 |
+
|
| 134 |
+
# col_filter = None if collection == "All" else collection
|
| 135 |
+
# results = search(query.strip(), collection=col_filter)
|
| 136 |
+
|
| 137 |
+
# if not results:
|
| 138 |
+
# return [], f"Zero matches in database for target {collection} (Threshold constraint: {MIN_RELEVANCE})."
|
| 139 |
+
|
| 140 |
+
# gallery_items = [
|
| 141 |
+
# (r["path"], f"Confidence: {r['score']:.2f} | {r['caption'][:120]}…")
|
| 142 |
+
# for r in results
|
| 143 |
+
# ]
|
| 144 |
+
# return gallery_items, f"{len(results)} query match(es) located."
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
# def load_collections_view(collection_name):
|
| 148 |
+
# """Fetches list of path references sorted within specific collection targets."""
|
| 149 |
+
# if not collection_name or collection_name == "All":
|
| 150 |
+
# entries = all_entries()
|
| 151 |
+
# else:
|
| 152 |
+
# entries = get_entries_by_collection(collection_name)
|
| 153 |
+
|
| 154 |
+
# if not entries:
|
| 155 |
+
# return [], f"No stored assets in target collection: '{collection_name}'."
|
| 156 |
+
|
| 157 |
+
# gallery_items = [(path, os.path.basename(path)) for path in entries.keys()]
|
| 158 |
+
# return gallery_items, f"Found {len(entries)} file reference(s) within '{collection_name}'."
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
# def update_collections_dropdown():
|
| 162 |
+
# """Returns a safe state package configuration payload for collection selectors."""
|
| 163 |
+
# choices = get_all_collections()
|
| 164 |
+
# if not choices:
|
| 165 |
+
# choices = ["General"]
|
| 166 |
+
# val = "General" if "General" in choices else choices[0]
|
| 167 |
+
# return gr.update(choices=choices, value=val)
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
# def update_search_dropdown():
|
| 171 |
+
# """Returns a safe state package configuration payload for search filter dropdowns."""
|
| 172 |
+
# choices = ["All"] + get_all_collections()
|
| 173 |
+
# return gr.update(choices=choices, value="All")
|
| 174 |
+
import hashlib
|
| 175 |
+
import json
|
| 176 |
+
import logging
|
| 177 |
+
import os
|
| 178 |
+
import re
|
| 179 |
+
import shutil
|
| 180 |
+
import tempfile
|
| 181 |
+
import zipfile
|
| 182 |
+
import gradio as gr
|
| 183 |
+
from PIL import Image
|
| 184 |
+
|
| 185 |
+
from caption_store import all_entries, entry_count, get_all_collections, get_entries_by_collection
|
| 186 |
+
from ingest import ingest_folder
|
| 187 |
+
from search import MIN_RELEVANCE, search
|
| 188 |
+
|
| 189 |
+
logging.basicConfig(level=logging.INFO)
|
| 190 |
+
|
| 191 |
+
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".tiff"}
|
| 192 |
+
STAGING_DIR = os.path.join(tempfile.gettempdir(), "photographers_archive_uploads")
|
| 193 |
+
THUMBNAIL_DIR = os.path.join(tempfile.gettempdir(), "photographers_archive_thumbnails")
|
| 194 |
+
|
| 195 |
+
os.makedirs(STAGING_DIR, exist_ok=True)
|
| 196 |
+
os.makedirs(THUMBNAIL_DIR, exist_ok=True)
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def get_thumbnail_path(original_path):
|
| 200 |
+
"""Generates and returns a cached lightweight WebP thumbnail path."""
|
| 201 |
+
path_hash = hashlib.md5(original_path.encode('utf-8')).hexdigest()
|
| 202 |
+
thumb_path = os.path.join(THUMBNAIL_DIR, f"{path_hash}.webp")
|
| 203 |
+
|
| 204 |
+
if os.path.exists(thumb_path):
|
| 205 |
+
return thumb_path
|
| 206 |
+
|
| 207 |
+
try:
|
| 208 |
+
with Image.open(original_path) as img:
|
| 209 |
+
img.thumbnail((300, 300))
|
| 210 |
+
img.save(thumb_path, "WEBP", quality=70)
|
| 211 |
+
return thumb_path
|
| 212 |
+
except Exception as e:
|
| 213 |
+
logging.warning(f"Could not render thumbnail for {original_path}: {e}")
|
| 214 |
+
return original_path
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def run_ingest(uploaded_files, collection_name, is_new_collection, new_collection_name):
|
| 218 |
+
if not uploaded_files:
|
| 219 |
+
yield "⚠️ Please upload at least one image to begin.", gr.update(), gr.update()
|
| 220 |
+
return
|
| 221 |
+
|
| 222 |
+
if is_new_collection:
|
| 223 |
+
final_collection = new_collection_name.strip()
|
| 224 |
+
if not final_collection:
|
| 225 |
+
yield "⚠️ Ingestion halted: Please specify a valid name for the new collection.", gr.update(), gr.update()
|
| 226 |
+
return
|
| 227 |
+
else:
|
| 228 |
+
final_collection = collection_name
|
| 229 |
+
if not final_collection:
|
| 230 |
+
final_collection = "General"
|
| 231 |
+
|
| 232 |
+
try:
|
| 233 |
+
for filename in os.listdir(STAGING_DIR):
|
| 234 |
+
file_path = os.path.join(STAGING_DIR, filename)
|
| 235 |
+
if os.path.isfile(file_path) or os.path.islink(file_path):
|
| 236 |
+
os.unlink(file_path)
|
| 237 |
+
except Exception as e:
|
| 238 |
+
logging.warning(f"Could not clear staging directory fully: {e}")
|
| 239 |
+
|
| 240 |
+
staged = []
|
| 241 |
+
for file_path in uploaded_files:
|
| 242 |
+
ext = os.path.splitext(file_path)[-1].lower()
|
| 243 |
+
if ext in IMAGE_EXTENSIONS:
|
| 244 |
+
dest = os.path.join(STAGING_DIR, os.path.basename(file_path))
|
| 245 |
+
shutil.copy2(file_path, dest)
|
| 246 |
+
staged.append(dest)
|
| 247 |
+
|
| 248 |
+
if not staged:
|
| 249 |
+
yield "⚠️ Format error: No supported images (jpg, jpeg, png, webp, tiff) found.", gr.update(), gr.update()
|
| 250 |
+
return
|
| 251 |
+
|
| 252 |
+
yield f"Staging completed. Initializing pipeline for: '{final_collection}'...", gr.update(), gr.update()
|
| 253 |
+
|
| 254 |
+
try:
|
| 255 |
+
for processed, total, msg in ingest_folder(STAGING_DIR, collection=final_collection):
|
| 256 |
+
if total > 0:
|
| 257 |
+
pct = int(processed / total * 100)
|
| 258 |
+
yield f">> Progress: [{processed}/{total}] ({pct}%) - {msg}", gr.update(), gr.update()
|
| 259 |
+
else:
|
| 260 |
+
yield f">> {msg}", gr.update(), gr.update()
|
| 261 |
+
|
| 262 |
+
rows, _ = load_caption_browser()
|
| 263 |
+
choices = get_all_collections()
|
| 264 |
+
if not choices:
|
| 265 |
+
choices = ["General"]
|
| 266 |
+
|
| 267 |
+
dropdown_val = final_collection if final_collection in choices else choices[0]
|
| 268 |
+
cols = gr.Dropdown(choices=choices, value=dropdown_val)
|
| 269 |
+
|
| 270 |
+
yield f"System Log: Done. Ingested to '{final_collection}'. Store has {entry_count()} images.", rows, cols
|
| 271 |
+
except ValueError as e:
|
| 272 |
+
yield f"Pipeline Failure: {e}", gr.update(), gr.update()
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
def _parse_meta(raw: str) -> dict | None:
|
| 276 |
+
try:
|
| 277 |
+
return json.loads(raw)
|
| 278 |
+
except (json.JSONDecodeError, TypeError):
|
| 279 |
+
pass
|
| 280 |
+
try:
|
| 281 |
+
fixed = re.sub(r'"\s*\n(\s*")', r'",\n\1', raw)
|
| 282 |
+
return json.loads(fixed)
|
| 283 |
+
except (json.JSONDecodeError, TypeError):
|
| 284 |
+
return None
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
def load_caption_browser():
|
| 288 |
+
entries = all_entries()
|
| 289 |
+
if not entries:
|
| 290 |
+
return [], "No captions index exists yet."
|
| 291 |
+
|
| 292 |
+
rows = []
|
| 293 |
+
for path, data in entries.items():
|
| 294 |
+
raw = data["caption"]
|
| 295 |
+
meta = _parse_meta(raw)
|
| 296 |
+
if meta:
|
| 297 |
+
summary = meta.get("summary") or "—"
|
| 298 |
+
subj = meta.get("subjects", {})
|
| 299 |
+
attire = ", ".join(subj.get("attire", [])) or "—"
|
| 300 |
+
tags = ", ".join(meta.get("search_tags", [])) or "—"
|
| 301 |
+
else:
|
| 302 |
+
summary = raw[:300] if raw else "—"
|
| 303 |
+
attire = "—"
|
| 304 |
+
tags = "—"
|
| 305 |
+
rows.append([os.path.basename(path), summary, attire, tags])
|
| 306 |
+
|
| 307 |
+
return rows, f"{len(rows)} caption record(s) loaded."
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
def run_search(query: str, collection: str = "All"):
|
| 311 |
+
"""Performs search and outputs thumbnail images, absolute original files, and logs."""
|
| 312 |
+
if not query or not query.strip():
|
| 313 |
+
return [], [], "Please enter a valid search parameter."
|
| 314 |
+
if entry_count() == 0:
|
| 315 |
+
return [], [], "No images indexed yet. Please ingest photos first."
|
| 316 |
+
|
| 317 |
+
col_filter = None if collection == "All" else collection
|
| 318 |
+
results = search(query.strip(), collection=col_filter)
|
| 319 |
+
|
| 320 |
+
if not results:
|
| 321 |
+
return [], [], f"Zero matches in database for target {collection} (Threshold constraint: {MIN_RELEVANCE})."
|
| 322 |
+
|
| 323 |
+
original_paths = [r["path"] for r in results]
|
| 324 |
+
gallery_items = []
|
| 325 |
+
for r in results:
|
| 326 |
+
thumb = get_thumbnail_path(r["path"])
|
| 327 |
+
gallery_items.append((thumb, os.path.basename(r["path"])))
|
| 328 |
+
|
| 329 |
+
return gallery_items, original_paths, f"Found {len(results)} search matches."
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
def load_collections_view(collection_name):
|
| 333 |
+
if not collection_name or collection_name == "All":
|
| 334 |
+
entries = all_entries()
|
| 335 |
+
else:
|
| 336 |
+
entries = get_entries_by_collection(collection_name)
|
| 337 |
+
|
| 338 |
+
if not entries:
|
| 339 |
+
return [], [], f"No stored assets in target collection: '{collection_name}'."
|
| 340 |
+
|
| 341 |
+
original_paths = list(entries.keys())
|
| 342 |
+
gallery_items = []
|
| 343 |
+
|
| 344 |
+
for path in original_paths:
|
| 345 |
+
thumb = get_thumbnail_path(path)
|
| 346 |
+
gallery_items.append((thumb, os.path.basename(path)))
|
| 347 |
+
|
| 348 |
+
return gallery_items, original_paths, f"Found {len(entries)} image(s) within '{collection_name}'."
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
def zip_selected_files(selected_list):
|
| 352 |
+
if not selected_list:
|
| 353 |
+
return None, "⚠️ Downloader: Zero images selected."
|
| 354 |
+
|
| 355 |
+
try:
|
| 356 |
+
temp_zip = tempfile.NamedTemporaryFile(delete=False, suffix=".zip")
|
| 357 |
+
with zipfile.ZipFile(temp_zip.name, 'w', zipfile.ZIP_DEFLATED) as zipf:
|
| 358 |
+
for file_path in selected_list:
|
| 359 |
+
if os.path.exists(file_path):
|
| 360 |
+
zipf.write(file_path, os.path.basename(file_path))
|
| 361 |
+
return temp_zip.name, f"✅ Zip file ready with {len(selected_list)} source file(s)."
|
| 362 |
+
except Exception as e:
|
| 363 |
+
return None, f"⚠️ Compression failure: {e}"
|
| 364 |
+
|
| 365 |
+
|
| 366 |
+
def update_collections_dropdown():
|
| 367 |
+
choices = get_all_collections()
|
| 368 |
+
if not choices:
|
| 369 |
+
choices = ["General"]
|
| 370 |
+
val = "General" if "General" in choices else choices[0]
|
| 371 |
+
return gr.update(choices=choices, value=val)
|
| 372 |
+
|
| 373 |
+
|
| 374 |
+
def update_search_dropdown():
|
| 375 |
+
choices = ["All"] + get_all_collections()
|
| 376 |
+
return gr.update(choices=choices, value="All")
|
modal_caption.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Modal app: runs MiniCPM-V-4.6 captioning on a remote GPU."""
|
| 2 |
+
|
| 3 |
+
import modal
|
| 4 |
+
|
| 5 |
+
app = modal.App("photographers-archive")
|
| 6 |
+
|
| 7 |
+
image = (
|
| 8 |
+
modal.Image.debian_slim(python_version="3.12")
|
| 9 |
+
.pip_install(
|
| 10 |
+
"transformers[torch]>=5.7.0",
|
| 11 |
+
"torchvision",
|
| 12 |
+
"av",
|
| 13 |
+
"Pillow",
|
| 14 |
+
"torch>=2.1.0",
|
| 15 |
+
"accelerate",
|
| 16 |
+
)
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
MODEL_ID = "openbmb/MiniCPM-V-4.6"
|
| 20 |
+
|
| 21 |
+
CAPTION_PROMPT = """You are a wedding and portrait photo archivist. Analyze this image and return ONLY a valid JSON object — no markdown, no explanation, no code fences.
|
| 22 |
+
|
| 23 |
+
Crucial Prompting Guidelines:
|
| 24 |
+
1. Be highly specific with textures, fabrics, and patterns in "attire" (e.g., "lace wedding gown", "black velvet tuxedo", "pinstripe suit").
|
| 25 |
+
2. Avoid generic descriptions in "summary". Focus on explicit visual facts (who, what, where, explicit actions).
|
| 26 |
+
3. Under "primary_subjects", explicitly label roles if evident (e.g., "bride", "groom", "bridesmaid", "groomsman", "mother of the bride").
|
| 27 |
+
4. For "depth_of_field", specify features like "shallow depth of field", "bokeh background", or "deep focus".
|
| 28 |
+
|
| 29 |
+
Use this exact schema:
|
| 30 |
+
{
|
| 31 |
+
"summary": "2-3 sentence description of the scene",
|
| 32 |
+
"subjects": {
|
| 33 |
+
"people_count": 0,
|
| 34 |
+
"primary_subjects": [],
|
| 35 |
+
"relationships": [],
|
| 36 |
+
"attire": []
|
| 37 |
+
},
|
| 38 |
+
"scene": {
|
| 39 |
+
"location_type": "",
|
| 40 |
+
"environment": "",
|
| 41 |
+
"setting_details": []
|
| 42 |
+
},
|
| 43 |
+
"actions": {
|
| 44 |
+
"primary_action": "",
|
| 45 |
+
"body_language": []
|
| 46 |
+
},
|
| 47 |
+
"lighting": {
|
| 48 |
+
"lighting_style": "",
|
| 49 |
+
"time_of_day_estimate": ""
|
| 50 |
+
},
|
| 51 |
+
"composition": {
|
| 52 |
+
"shot_type": "",
|
| 53 |
+
"camera_angle": ""
|
| 54 |
+
},
|
| 55 |
+
"mood": {
|
| 56 |
+
"primary_emotions": [],
|
| 57 |
+
"atmosphere": ""
|
| 58 |
+
},
|
| 59 |
+
"technical_cues": {
|
| 60 |
+
"color_palette": [],
|
| 61 |
+
"depth_of_field": ""
|
| 62 |
+
},
|
| 63 |
+
"search_tags": [],
|
| 64 |
+
"archive_keywords": []
|
| 65 |
+
}"""
|
| 66 |
+
|
| 67 |
+
# Cache model weights in a Modal Volume so they persist across cold starts
|
| 68 |
+
model_volume = modal.Volume.from_name("minicpm-weights", create_if_missing=True)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
@app.cls(
|
| 72 |
+
image=image,
|
| 73 |
+
gpu="A10G",
|
| 74 |
+
volumes={"/model-cache": model_volume},
|
| 75 |
+
timeout=300,
|
| 76 |
+
)
|
| 77 |
+
class Captioner:
|
| 78 |
+
@modal.enter()
|
| 79 |
+
def load(self):
|
| 80 |
+
import torch
|
| 81 |
+
from transformers import AutoModelForImageTextToText, AutoProcessor
|
| 82 |
+
|
| 83 |
+
self.processor = AutoProcessor.from_pretrained(
|
| 84 |
+
MODEL_ID, cache_dir="/model-cache"
|
| 85 |
+
)
|
| 86 |
+
self.model = AutoModelForImageTextToText.from_pretrained(
|
| 87 |
+
MODEL_ID,
|
| 88 |
+
torch_dtype=torch.bfloat16,
|
| 89 |
+
device_map="auto",
|
| 90 |
+
cache_dir="/model-cache",
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
@modal.method()
|
| 94 |
+
def caption(self, image_bytes: bytes, filename: str = "image.jpg") -> str:
|
| 95 |
+
import json
|
| 96 |
+
import os
|
| 97 |
+
import re
|
| 98 |
+
import tempfile
|
| 99 |
+
import torch
|
| 100 |
+
|
| 101 |
+
suffix = os.path.splitext(filename)[-1] or ".jpg"
|
| 102 |
+
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as f:
|
| 103 |
+
f.write(image_bytes)
|
| 104 |
+
tmp_path = f.name
|
| 105 |
+
|
| 106 |
+
try:
|
| 107 |
+
messages = [
|
| 108 |
+
{
|
| 109 |
+
"role": "user",
|
| 110 |
+
"content": [
|
| 111 |
+
{"type": "image", "url": tmp_path},
|
| 112 |
+
{"type": "text", "text": CAPTION_PROMPT},
|
| 113 |
+
],
|
| 114 |
+
}
|
| 115 |
+
]
|
| 116 |
+
|
| 117 |
+
inputs = self.processor.apply_chat_template(
|
| 118 |
+
messages,
|
| 119 |
+
tokenize=True,
|
| 120 |
+
add_generation_prompt=True,
|
| 121 |
+
return_dict=True,
|
| 122 |
+
return_tensors="pt",
|
| 123 |
+
downsample_mode="16x",
|
| 124 |
+
max_slice_nums=9,
|
| 125 |
+
).to(self.model.device)
|
| 126 |
+
|
| 127 |
+
with torch.inference_mode():
|
| 128 |
+
generated_ids = self.model.generate(
|
| 129 |
+
**inputs,
|
| 130 |
+
downsample_mode="16x",
|
| 131 |
+
max_new_tokens=2048,
|
| 132 |
+
do_sample=False,
|
| 133 |
+
)
|
| 134 |
+
|
| 135 |
+
trimmed = [
|
| 136 |
+
out_ids[len(in_ids):]
|
| 137 |
+
for in_ids, out_ids in zip(inputs.input_ids, generated_ids)
|
| 138 |
+
]
|
| 139 |
+
raw = self.processor.batch_decode(
|
| 140 |
+
trimmed,
|
| 141 |
+
skip_special_tokens=True,
|
| 142 |
+
clean_up_tokenization_spaces=False,
|
| 143 |
+
)[0].strip()
|
| 144 |
+
|
| 145 |
+
# Strip markdown code fences if model added them anyway
|
| 146 |
+
raw = re.sub(r"^```(?:json)?\s*", "", raw)
|
| 147 |
+
raw = re.sub(r"\s*```$", "", raw)
|
| 148 |
+
|
| 149 |
+
# Validate JSON — if broken, return as plain text so it still gets stored
|
| 150 |
+
try:
|
| 151 |
+
json.loads(raw)
|
| 152 |
+
except json.JSONDecodeError:
|
| 153 |
+
# Attempt to salvage by extracting the outermost {...} block
|
| 154 |
+
match = re.search(r"\{.*\}", raw, re.DOTALL)
|
| 155 |
+
if match:
|
| 156 |
+
candidate = match.group(0)
|
| 157 |
+
try:
|
| 158 |
+
json.loads(candidate)
|
| 159 |
+
return candidate
|
| 160 |
+
except json.JSONDecodeError:
|
| 161 |
+
pass
|
| 162 |
+
# Give up and return raw — caption_store will treat it as plain text
|
| 163 |
+
return raw
|
| 164 |
+
|
| 165 |
+
return raw
|
| 166 |
+
finally:
|
| 167 |
+
os.unlink(tmp_path)
|
public/heroImage.png
ADDED
|
Git LFS Details
|
requirements.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio>=5.0.0
|
| 2 |
+
sentence-transformers>=3.0.0
|
| 3 |
+
modal>=0.73.0
|
| 4 |
+
numpy
|
| 5 |
+
Pillow
|
search.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Semantic search with keyword boosting over structured captions."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import re
|
| 7 |
+
|
| 8 |
+
import numpy as np
|
| 9 |
+
from sentence_transformers import SentenceTransformer
|
| 10 |
+
|
| 11 |
+
from caption_store import all_entries
|
| 12 |
+
|
| 13 |
+
_embed_model: SentenceTransformer | None = None
|
| 14 |
+
_MODEL_NAME = "BAAI/bge-base-en-v1.5" # stronger than MiniLM
|
| 15 |
+
|
| 16 |
+
MIN_RELEVANCE = 0.55
|
| 17 |
+
TOP_K = 20
|
| 18 |
+
|
| 19 |
+
# Weight given to keyword boost relative to semantic score (0–1 additive)
|
| 20 |
+
KEYWORD_BOOST = 0.3
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _get_embed_model() -> SentenceTransformer:
|
| 24 |
+
global _embed_model
|
| 25 |
+
if _embed_model is None:
|
| 26 |
+
_embed_model = SentenceTransformer(_MODEL_NAME)
|
| 27 |
+
return _embed_model
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _query_tokens(query: str) -> list[str]:
|
| 31 |
+
"""Lowercase words from query, 3+ chars."""
|
| 32 |
+
return [w for w in re.findall(r"\b\w+\b", query.lower()) if len(w) >= 3]
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _keyword_score(query_tokens: list[str], search_text: str, raw_caption: str) -> float:
|
| 36 |
+
if not query_tokens:
|
| 37 |
+
return 0.0
|
| 38 |
+
|
| 39 |
+
# Tokenize the target text strictly by word boundaries to avoid partial substring hits
|
| 40 |
+
target_words = set(re.findall(r"\b\w+\b", search_text.lower()))
|
| 41 |
+
|
| 42 |
+
# Calculate regular hits
|
| 43 |
+
hits = sum(1 for token in query_tokens if token in target_words)
|
| 44 |
+
|
| 45 |
+
# Extra weight for specific high-signal fields (attire, keywords, tags)
|
| 46 |
+
high_signal_words = set()
|
| 47 |
+
try:
|
| 48 |
+
meta = json.loads(raw_caption)
|
| 49 |
+
# Gather arrays that explicitly hold high-intent search terms
|
| 50 |
+
for field in [meta.get("search_tags", []), meta.get("archive_keywords", []), meta.get("subjects", {}).get("attire", [])]:
|
| 51 |
+
if isinstance(field, list):
|
| 52 |
+
for item in field:
|
| 53 |
+
high_signal_words.update(re.findall(r"\b\w+\b", str(item).lower()))
|
| 54 |
+
elif isinstance(field, str):
|
| 55 |
+
high_signal_words.update(re.findall(r"\b\w+\b", field.lower()))
|
| 56 |
+
except (json.JSONDecodeError, TypeError):
|
| 57 |
+
pass
|
| 58 |
+
|
| 59 |
+
high_signal_hits = sum(1 for token in query_tokens if token in high_signal_words)
|
| 60 |
+
|
| 61 |
+
# Instead of penalizing long queries, reward ANY valid keyword match heavily
|
| 62 |
+
if hits == 0:
|
| 63 |
+
return 0.0
|
| 64 |
+
|
| 65 |
+
base_score = hits / len(query_tokens)
|
| 66 |
+
boost_bonus = 0.5 if high_signal_hits > 0 else 0.0
|
| 67 |
+
|
| 68 |
+
return min(base_score + boost_bonus, 1.0)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def search(query: str, collection: str | None = None) -> list[dict]:
|
| 72 |
+
"""
|
| 73 |
+
Hybrid search: semantic similarity + keyword boost on structured fields.
|
| 74 |
+
Returns up to TOP_K results with score >= MIN_RELEVANCE.
|
| 75 |
+
"""
|
| 76 |
+
all_indexed = all_entries()
|
| 77 |
+
if not all_indexed:
|
| 78 |
+
return []
|
| 79 |
+
|
| 80 |
+
# Filter by collection if specified
|
| 81 |
+
if collection and collection != "All":
|
| 82 |
+
entries = {
|
| 83 |
+
p: data for p, data in all_indexed.items()
|
| 84 |
+
if data.get("collection") == collection
|
| 85 |
+
}
|
| 86 |
+
else:
|
| 87 |
+
entries = all_indexed
|
| 88 |
+
|
| 89 |
+
if not entries:
|
| 90 |
+
return []
|
| 91 |
+
|
| 92 |
+
model = _get_embed_model()
|
| 93 |
+
query_tokens = _query_tokens(query)
|
| 94 |
+
|
| 95 |
+
paths = list(entries.keys())
|
| 96 |
+
search_texts = [entries[p].get("search_text") or entries[p]["caption"] for p in paths]
|
| 97 |
+
raw_captions = [entries[p]["caption"] for p in paths]
|
| 98 |
+
|
| 99 |
+
# Query side gets an explicit instruction prefix
|
| 100 |
+
query_text = f"Represent this sentence for searching relevant passages: {query}"
|
| 101 |
+
query_vec = model.encode([query_text], normalize_embeddings=True)
|
| 102 |
+
|
| 103 |
+
# Document side stays normal
|
| 104 |
+
caption_vecs = model.encode(search_texts, normalize_embeddings=True)
|
| 105 |
+
semantic_scores = (caption_vecs @ query_vec.T).flatten()
|
| 106 |
+
|
| 107 |
+
final_scores = []
|
| 108 |
+
for i, (sem, st, rc) in enumerate(zip(semantic_scores, search_texts, raw_captions)):
|
| 109 |
+
kw = _keyword_score(query_tokens, st, rc)
|
| 110 |
+
final_scores.append(float(sem) + KEYWORD_BOOST * kw)
|
| 111 |
+
|
| 112 |
+
ranked = sorted(
|
| 113 |
+
zip(paths, search_texts, final_scores),
|
| 114 |
+
key=lambda x: x[2],
|
| 115 |
+
reverse=True,
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
return [
|
| 119 |
+
{"path": p, "caption": c, "score": round(s, 4)}
|
| 120 |
+
for p, c, s in ranked[:TOP_K]
|
| 121 |
+
if s >= MIN_RELEVANCE
|
| 122 |
+
]
|
style.css
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Sidebar Container */
|
| 2 |
+
#custom-sidebar {
|
| 3 |
+
background-color: #0f172a !important;
|
| 4 |
+
/* Deep Slate 900 */
|
| 5 |
+
border-right: 1px solid #1e293b !important;
|
| 6 |
+
/* Slate 800 */
|
| 7 |
+
padding: 24px 16px !important;
|
| 8 |
+
display: flex;
|
| 9 |
+
flex-direction: column;
|
| 10 |
+
height: 100vh !important;
|
| 11 |
+
/* Ensures it spans full viewport height */
|
| 12 |
+
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.3) !important;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
/* App Brand Header */
|
| 16 |
+
.brand-header {
|
| 17 |
+
margin-bottom: 32px;
|
| 18 |
+
padding-left: 12px;
|
| 19 |
+
border-bottom: 1px solid #1e293b;
|
| 20 |
+
padding-bottom: 20px;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
.brand-header h1 {
|
| 24 |
+
font-size: 1.6rem !important;
|
| 25 |
+
font-weight: 800 !important;
|
| 26 |
+
background: linear-gradient(135deg, #f8fafc 0%, #94a3b8 100%);
|
| 27 |
+
/* Subtle gradient */
|
| 28 |
+
-webkit-background-clip: text;
|
| 29 |
+
-webkit-text-fill-color: transparent;
|
| 30 |
+
margin-bottom: 6px !important;
|
| 31 |
+
letter-spacing: -0.5px;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
.brand-header p {
|
| 35 |
+
font-size: 0.7rem;
|
| 36 |
+
color: #64748b;
|
| 37 |
+
/* Slate 500 */
|
| 38 |
+
text-transform: uppercase;
|
| 39 |
+
letter-spacing: 1.5px;
|
| 40 |
+
font-weight: 600;
|
| 41 |
+
margin: 0;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
/* Modern Navigation Buttons */
|
| 45 |
+
.nav-btn {
|
| 46 |
+
text-align: left !important;
|
| 47 |
+
justify-content: flex-start !important;
|
| 48 |
+
background: transparent !important;
|
| 49 |
+
border: 1px solid transparent !important;
|
| 50 |
+
box-shadow: none !important;
|
| 51 |
+
color: #94a3b8 !important;
|
| 52 |
+
/* Slate 400 */
|
| 53 |
+
font-weight: 500 !important;
|
| 54 |
+
font-size: 0.95rem !important;
|
| 55 |
+
padding: 10px 16px !important;
|
| 56 |
+
border-radius: 10px !important;
|
| 57 |
+
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1) !important;
|
| 58 |
+
/* Smooth easing */
|
| 59 |
+
margin-bottom: 8px !important;
|
| 60 |
+
display: flex !important;
|
| 61 |
+
align-items: center !important;
|
| 62 |
+
gap: 10px !important;
|
| 63 |
+
/* Perfect emoji/text alignment */
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
.nav-btn:hover {
|
| 67 |
+
background-color: #1e293b !important;
|
| 68 |
+
/* Slate 800 */
|
| 69 |
+
color: #f8fafc !important;
|
| 70 |
+
/* Slate 50 */
|
| 71 |
+
border-color: #334155 !important;
|
| 72 |
+
/* Slate 700 */
|
| 73 |
+
transform: translateX(4px);
|
| 74 |
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2) !important;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
.nav-btn:active {
|
| 78 |
+
transform: translateX(2px) scale(0.98);
|
| 79 |
+
/* Subtle click feedback */
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
.stats-container .svelte-1b6s6s {
|
| 83 |
+
background: transparent !important;
|
| 84 |
+
border: none !important;
|
| 85 |
+
box-shadow: none !important;
|
| 86 |
+
padding: 0 !important;
|
| 87 |
+
margin: 0 !important;
|
| 88 |
+
font-size: 1rem !important;
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
/* Main Content Area (Dark Theme Match) */
|
| 92 |
+
.main-content {
|
| 93 |
+
padding: 32px 48px;
|
| 94 |
+
max-width: 1200px;
|
| 95 |
+
margin: 0 auto;
|
| 96 |
+
background-color: #020617 !important;
|
| 97 |
+
/* Slate 950 (slightly darker than sidebar) */
|
| 98 |
+
color: #e2e8f0 !important;
|
| 99 |
+
min-height: 100vh;
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
/* Dark Settings Card */
|
| 105 |
+
.settings-card {
|
| 106 |
+
background-color: #1e293b !important;
|
| 107 |
+
/* Surface level 1 */
|
| 108 |
+
border: 1px solid #334155 !important;
|
| 109 |
+
/* Dark Slate Border */
|
| 110 |
+
border-radius: 12px !important;
|
| 111 |
+
padding: 24px !important;
|
| 112 |
+
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3) !important;
|
| 113 |
+
display: flex;
|
| 114 |
+
flex-direction: column;
|
| 115 |
+
justify-content: space-between;
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
.settings-card h3 {
|
| 119 |
+
margin-top: 0 !important;
|
| 120 |
+
font-size: 1.1rem !important;
|
| 121 |
+
color: #f8fafc !important;
|
| 122 |
+
font-weight: 600 !important;
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
/* Customize Upload Area */
|
| 126 |
+
.upload-zone {
|
| 127 |
+
border: 2px dashed #475569 !important;
|
| 128 |
+
border-radius: 12px !important;
|
| 129 |
+
background-color: #0f172a !important;
|
| 130 |
+
/* Surface level 2 */
|
| 131 |
+
transition: border-color 0.2s ease-in-out !important;
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
.upload-zone:hover {
|
| 135 |
+
border-color: #10b981 !important;
|
| 136 |
+
/* Emerald on hover */
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
/* Ingest Console Log */
|
| 140 |
+
.console-wrapper {
|
| 141 |
+
margin-top: 25px;
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
.console-wrapper h3 {
|
| 145 |
+
font-size: 1rem !important;
|
| 146 |
+
color: #94a3b8 !important;
|
| 147 |
+
font-weight: 600 !important;
|
| 148 |
+
margin-bottom: 8px !important;
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
.status-console textarea {
|
| 152 |
+
font-family: 'Fira Code', 'Courier New', Courier, monospace !important;
|
| 153 |
+
background-color: #020617 !important;
|
| 154 |
+
/* Near Pitch Black */
|
| 155 |
+
color: #34d399 !important;
|
| 156 |
+
/* Terminal green */
|
| 157 |
+
border: 1px solid #1e293b !important;
|
| 158 |
+
border-radius: 8px !important;
|
| 159 |
+
padding: 12px !important;
|
| 160 |
+
font-size: 0.9rem !important;
|
| 161 |
+
line-height: 1.5 !important;
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
.selection-status-box {
|
| 165 |
+
background-color: #1e293b !important;
|
| 166 |
+
/* Surface Level 1 Dark background */
|
| 167 |
+
border: 1px solid #334155 !important;
|
| 168 |
+
/* Medium slate outline */
|
| 169 |
+
border-radius: 8px !important;
|
| 170 |
+
padding: 16px 20px !important;
|
| 171 |
+
width: 100% !important;
|
| 172 |
+
/* Forces component to span full screen width */
|
| 173 |
+
margin-bottom: 12px !important;
|
| 174 |
+
box-sizing: border-box !important;
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
/* Cleaner, separate horizontal row for action buttons */
|
| 178 |
+
.selection-buttons-row {
|
| 179 |
+
gap: 10px !important;
|
| 180 |
+
margin-bottom: 15px !important;
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
.about-hero {
|
| 184 |
+
background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%) !important;
|
| 185 |
+
border: 1px solid #334155 !important;
|
| 186 |
+
border-radius: 12px !important;
|
| 187 |
+
padding: 30px !important;
|
| 188 |
+
margin-bottom: 25px !important;
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
.about-hero h1 {
|
| 192 |
+
font-size: 2.2rem !important;
|
| 193 |
+
font-weight: 800 !important;
|
| 194 |
+
color: #f8fafc !important;
|
| 195 |
+
margin-bottom: 6px !important;
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
.about-hero h3 {
|
| 199 |
+
font-size: 1.1rem !important;
|
| 200 |
+
font-weight: 500 !important;
|
| 201 |
+
color: #94a3b8 !important;
|
| 202 |
+
margin-top: 0 !important;
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
/* Technology Badges */
|
| 206 |
+
.tech-badge {
|
| 207 |
+
display: inline-block;
|
| 208 |
+
background-color: #064e3b !important;
|
| 209 |
+
/* Dark forest green background */
|
| 210 |
+
color: #34d399 !important;
|
| 211 |
+
/* Light mint text */
|
| 212 |
+
padding: 4px 12px;
|
| 213 |
+
border-radius: 9999px;
|
| 214 |
+
font-size: 0.75rem;
|
| 215 |
+
font-weight: 700;
|
| 216 |
+
margin-right: 6px;
|
| 217 |
+
margin-bottom: 10px;
|
| 218 |
+
border: 1px solid #047857;
|
| 219 |
+
letter-spacing: 0.5px;
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
/* Steps Grid Cards */
|
| 223 |
+
.about-card {
|
| 224 |
+
background-color: #1e293b !important;
|
| 225 |
+
border: 1px solid #334155 !important;
|
| 226 |
+
border-radius: 10px !important;
|
| 227 |
+
padding: 24px !important;
|
| 228 |
+
height: 100%;
|
| 229 |
+
transition: transform 0.2s ease, border-color 0.2s ease !important;
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
.about-card:hover {
|
| 233 |
+
transform: translateY(-3px);
|
| 234 |
+
border-color: #10b981 !important;
|
| 235 |
+
/* Emerald highlight on hover */
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
.about-card-icon {
|
| 239 |
+
font-size: 2rem;
|
| 240 |
+
margin-bottom: 12px;
|
| 241 |
+
line-height: 1;
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
/* Targets Markdown inside cards to avoid global conflicts */
|
| 245 |
+
.about-card h3 {
|
| 246 |
+
color: #f8fafc !important;
|
| 247 |
+
font-size: 1.15rem !important;
|
| 248 |
+
font-weight: 700 !important;
|
| 249 |
+
margin-top: 0 !important;
|
| 250 |
+
margin-bottom: 8px !important;
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
.about-card p {
|
| 254 |
+
color: #cbd5e1 !important;
|
| 255 |
+
font-size: 0.9rem !important;
|
| 256 |
+
line-height: 1.5 !important;
|
| 257 |
+
margin: 0 !important;
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
/* Hackathon Announcement Footer */
|
| 261 |
+
.hackathon-footer {
|
| 262 |
+
margin-top: 35px !important;
|
| 263 |
+
background-color: #020617 !important;
|
| 264 |
+
border: 1px solid #1e293b !important;
|
| 265 |
+
padding: 16px 24px !important;
|
| 266 |
+
border-radius: 8px !important;
|
| 267 |
+
align-items: center !important;
|
| 268 |
+
gap: 15px !important;
|
| 269 |
+
}
|
ui/__init__.py
ADDED
|
File without changes
|
ui/__pycache__/__init__.cpython-310.pyc
ADDED
|
Binary file (145 Bytes). View file
|
|
|
ui/__pycache__/about.cpython-310.pyc
ADDED
|
Binary file (3 kB). View file
|
|
|
ui/__pycache__/captions.cpython-310.pyc
ADDED
|
Binary file (812 Bytes). View file
|
|
|
ui/__pycache__/collections.cpython-310.pyc
ADDED
|
Binary file (1.95 kB). View file
|
|
|
ui/__pycache__/home.cpython-310.pyc
ADDED
|
Binary file (4.88 kB). View file
|
|
|
ui/__pycache__/ingest.cpython-310.pyc
ADDED
|
Binary file (2.01 kB). View file
|
|
|
ui/__pycache__/search.cpython-310.pyc
ADDED
|
Binary file (2.01 kB). View file
|
|
|
ui/__pycache__/sidebar.cpython-310.pyc
ADDED
|
Binary file (1.05 kB). View file
|
|
|
ui/about.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import gradio as gr
|
| 2 |
+
|
| 3 |
+
def render_about():
|
| 4 |
+
with gr.Column(visible=False, elem_classes="page-container") as page:
|
| 5 |
+
# Hero Banner with Tech Badges
|
| 6 |
+
with gr.Column(elem_classes="about-hero"):
|
| 7 |
+
gr.HTML("""
|
| 8 |
+
<div style="margin-bottom: 10px;">
|
| 9 |
+
<span class="tech-badge">MiniCPM-V-4.6</span>
|
| 10 |
+
<span class="tech-badge">Modal Serverless</span>
|
| 11 |
+
<span class="tech-badge">Local-First Cache</span>
|
| 12 |
+
<span class="tech-badge">Vector Retrieval</span>
|
| 13 |
+
</div>
|
| 14 |
+
""")
|
| 15 |
+
gr.Markdown("""
|
| 16 |
+
# ShutterSearch
|
| 17 |
+
### An intelligent, local-first photo archive designed to analyze and search photograph semantics.
|
| 18 |
+
""")
|
| 19 |
+
|
| 20 |
+
# Corrected: Replaced inline-styled gr.Markdown with styled gr.HTML
|
| 21 |
+
gr.HTML("""
|
| 22 |
+
<h2 style="margin-top: 25px; margin-bottom: 15px; font-size: 1.5rem; font-weight: 700; color: #f8fafc;">
|
| 23 |
+
⚙️ How it Works
|
| 24 |
+
</h2>
|
| 25 |
+
""")
|
| 26 |
+
|
| 27 |
+
# Step Columns
|
| 28 |
+
with gr.Row():
|
| 29 |
+
with gr.Column(elem_classes="about-card", scale=1):
|
| 30 |
+
gr.HTML('<div class="about-card-icon">📥</div>')
|
| 31 |
+
gr.Markdown("""
|
| 32 |
+
### 1. Ingest & Analyze
|
| 33 |
+
Select your folders. The archive safely processes visual metadata using a serverless Vision Language Model (VLM) running on high-end GPUs.
|
| 34 |
+
""")
|
| 35 |
+
|
| 36 |
+
with gr.Column(elem_classes="about-card", scale=1):
|
| 37 |
+
gr.HTML('<div class="about-card-icon">🔍</div>')
|
| 38 |
+
gr.Markdown("""
|
| 39 |
+
### 2. Semantic Search
|
| 40 |
+
Skip manual tags. Search your directory using natural descriptive phrases (such as *"cinematic lighting on trees"* or *"overcast street portrait"*).
|
| 41 |
+
""")
|
| 42 |
+
|
| 43 |
+
with gr.Column(elem_classes="about-card", scale=1):
|
| 44 |
+
gr.HTML('<div class="about-card-icon">📁</div>')
|
| 45 |
+
gr.Markdown("""
|
| 46 |
+
### 3. Bulk Export
|
| 47 |
+
Organize images within local collections. Select what you need directly from search results to export them in high-fidelity ZIP archives.
|
| 48 |
+
""")
|
| 49 |
+
|
| 50 |
+
# Footer hackathon card block
|
| 51 |
+
with gr.Row(elem_classes="hackathon-footer"):
|
| 52 |
+
gr.HTML("""
|
| 53 |
+
<div style="display: flex; align-items: center; gap: 15px;">
|
| 54 |
+
<div style="font-size: 1.8rem; line-height: 1;">🏆</div>
|
| 55 |
+
<div>
|
| 56 |
+
<h4 style="margin: 0 0 3px 0; color: #f8fafc; font-size: 0.95rem; font-weight: 600;">Hugging Face "Build Small" Hackathon</h4>
|
| 57 |
+
<p style="margin: 0; color: #94a3b8; font-size: 0.85rem; line-height: 1.4;">
|
| 58 |
+
ShutterSearch was designed to fulfill constraints on building lightweight, local-first applications using edge AI concepts.
|
| 59 |
+
</p>
|
| 60 |
+
</div>
|
| 61 |
+
</div>
|
| 62 |
+
""")
|
| 63 |
+
|
| 64 |
+
return page
|
ui/captions.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import gradio as gr
|
| 2 |
+
|
| 3 |
+
def render_captions():
|
| 4 |
+
with gr.Column(visible=False) as page:
|
| 5 |
+
gr.Markdown("# 📝 Caption Browser")
|
| 6 |
+
with gr.Row():
|
| 7 |
+
refresh_cap_btn = gr.Button("🔄 Refresh Data", variant="secondary")
|
| 8 |
+
cap_status = gr.Textbox(interactive=False, show_label=False, scale=4)
|
| 9 |
+
|
| 10 |
+
caption_table = gr.Dataframe(
|
| 11 |
+
headers=["File", "Summary", "Attire", "Tags"],
|
| 12 |
+
datatype=["str", "str", "str", "str"],
|
| 13 |
+
wrap=True, interactive=False,
|
| 14 |
+
column_widths=["15%", "45%", "20%", "20%"],
|
| 15 |
+
)
|
| 16 |
+
return page, refresh_cap_btn, cap_status, caption_table
|
ui/collections.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# import gradio as gr
|
| 2 |
+
# from caption_store import get_all_collections
|
| 3 |
+
|
| 4 |
+
# def render_collections():
|
| 5 |
+
# with gr.Column(visible=False) as page:
|
| 6 |
+
# gr.Markdown("# 📁 Collections")
|
| 7 |
+
# with gr.Row():
|
| 8 |
+
# # Dynamically determine valid initial choices
|
| 9 |
+
# choices = get_all_collections()
|
| 10 |
+
# if not choices:
|
| 11 |
+
# choices = ["General"]
|
| 12 |
+
# val = "General" if "General" in choices else choices[0]
|
| 13 |
+
|
| 14 |
+
# view_coll_selector = gr.Dropdown(
|
| 15 |
+
# choices=choices,
|
| 16 |
+
# value=val,
|
| 17 |
+
# label="Select Collection to Browse",
|
| 18 |
+
# scale=4
|
| 19 |
+
# )
|
| 20 |
+
# refresh_coll_btn = gr.Button("🔄 Refresh", scale=1)
|
| 21 |
+
|
| 22 |
+
# coll_status = gr.Markdown("Browse your organized photos.")
|
| 23 |
+
# coll_gallery = gr.Gallery(label="Collection Photos", columns=5, height="auto")
|
| 24 |
+
|
| 25 |
+
# return page, view_coll_selector, refresh_coll_btn, coll_status, coll_gallery
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
import os
|
| 29 |
+
import gradio as gr
|
| 30 |
+
from caption_store import get_all_collections
|
| 31 |
+
|
| 32 |
+
def render_collections():
|
| 33 |
+
# Gradio selection tracking states
|
| 34 |
+
selected_paths = gr.State([])
|
| 35 |
+
loaded_original_paths = gr.State([])
|
| 36 |
+
|
| 37 |
+
with gr.Column(visible=False, elem_classes="page-container") as page:
|
| 38 |
+
gr.HTML("""
|
| 39 |
+
<div style='margin-bottom: 20px;'>
|
| 40 |
+
<h1 style='font-size: 2rem; font-weight: 700; color: #f8fafc;'>📁 Browse Collections</h1>
|
| 41 |
+
<p style='color: #94a3b8;'>Select images by clicking them in the gallery to package and download.</p>
|
| 42 |
+
</div>
|
| 43 |
+
""")
|
| 44 |
+
|
| 45 |
+
with gr.Row():
|
| 46 |
+
choices = get_all_collections()
|
| 47 |
+
if not choices:
|
| 48 |
+
choices = ["General"]
|
| 49 |
+
val = "General" if "General" in choices else choices[0]
|
| 50 |
+
|
| 51 |
+
view_coll_selector = gr.Dropdown(
|
| 52 |
+
choices=choices,
|
| 53 |
+
value=val,
|
| 54 |
+
label="Select Collection to Browse",
|
| 55 |
+
scale=4
|
| 56 |
+
)
|
| 57 |
+
refresh_coll_btn = gr.Button("🔄 Refresh list", scale=1)
|
| 58 |
+
|
| 59 |
+
coll_status = gr.Markdown("Browse your organized photos.")
|
| 60 |
+
|
| 61 |
+
# Full-width panel for selected files output
|
| 62 |
+
selection_status = gr.Markdown(
|
| 63 |
+
value="**0** image(s) selected for download packaging.\n\n*No files currently selected.*",
|
| 64 |
+
elem_classes="selection-status-box"
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
# Standalone row for action buttons
|
| 68 |
+
with gr.Row(elem_classes="selection-buttons-row"):
|
| 69 |
+
select_all_btn = gr.Button("✅ Select All", size="sm", variant="secondary")
|
| 70 |
+
clear_selection_btn = gr.Button("🧹 Clear Selection", size="sm", variant="secondary")
|
| 71 |
+
download_btn = gr.Button("📦 Zip & Download Selected", variant="primary", size="sm")
|
| 72 |
+
|
| 73 |
+
# Dynamic hidden archive download trigger
|
| 74 |
+
download_file = gr.File(label="Source Quality ZIP Archive", visible=False)
|
| 75 |
+
|
| 76 |
+
coll_gallery = gr.Gallery(
|
| 77 |
+
label="Collection Photos",
|
| 78 |
+
columns=5,
|
| 79 |
+
height="auto",
|
| 80 |
+
elem_classes="custom-gallery",
|
| 81 |
+
allow_preview=False
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
return (
|
| 85 |
+
page, view_coll_selector, refresh_coll_btn, coll_status, coll_gallery,
|
| 86 |
+
selected_paths, loaded_original_paths, selection_status, download_btn,
|
| 87 |
+
download_file, clear_selection_btn, select_all_btn
|
| 88 |
+
)
|
ui/home.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# import gradio as gr
|
| 2 |
+
|
| 3 |
+
# def render_home():
|
| 4 |
+
# with gr.Column(visible=True) as page:
|
| 5 |
+
# with gr.Row():
|
| 6 |
+
# with gr.Column(scale=2):
|
| 7 |
+
# gr.Markdown("""
|
| 8 |
+
# # Welcome to ShutterSearch
|
| 9 |
+
# ### Your intelligent local-first photo archive.
|
| 10 |
+
|
| 11 |
+
# ShutterSearch uses state-of-the-art vision models to understand your photography.
|
| 12 |
+
# Keep your photos organized in collections and find them instantly with natural language search.
|
| 13 |
+
|
| 14 |
+
# - **Semantic Search**: Find "sunset on a beach" even if you didn't tag it.
|
| 15 |
+
# - **Automatic Captioning**: Powered by MiniCPM-V-4.6.
|
| 16 |
+
# - **Privacy First**: Everything runs on your terms.
|
| 17 |
+
# """)
|
| 18 |
+
# start_btn = gr.Button("Start Ingesting", variant="primary", size="lg")
|
| 19 |
+
# with gr.Column(scale=1):
|
| 20 |
+
# gr.Image("https://images.unsplash.com/photo-1542038784456-1ea8e935640e?q=80&w=1000&auto=format&fit=crop",
|
| 21 |
+
# label="Photography", show_label=False, interactive=False)
|
| 22 |
+
# return page, start_btn
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
import gradio as gr
|
| 26 |
+
import os
|
| 27 |
+
|
| 28 |
+
def render_home():
|
| 29 |
+
root_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 30 |
+
img_path = os.path.join(root_dir, "public", "heroImage.png")
|
| 31 |
+
|
| 32 |
+
print(f"Root directory: {root_dir}")
|
| 33 |
+
print(f"Image path: {img_path}")
|
| 34 |
+
print(f"File exists: {os.path.exists(img_path)}")
|
| 35 |
+
with gr.Column(visible=True) as page:
|
| 36 |
+
gr.HTML("""
|
| 37 |
+
<style>
|
| 38 |
+
.hero-container {
|
| 39 |
+
display: flex;
|
| 40 |
+
align-items: center;
|
| 41 |
+
justify-content: space-between;
|
| 42 |
+
padding: 60px 40px;
|
| 43 |
+
min-height: 80vh;
|
| 44 |
+
gap: 60px;
|
| 45 |
+
}
|
| 46 |
+
.hero-content {
|
| 47 |
+
flex: 1;
|
| 48 |
+
max-width: 600px;
|
| 49 |
+
}
|
| 50 |
+
.hero-title {
|
| 51 |
+
font-size: 3.5rem;
|
| 52 |
+
font-weight: 800;
|
| 53 |
+
background: linear-gradient(135deg, #f8fafc 0%, #94a3b8 100%);
|
| 54 |
+
-webkit-background-clip: text;
|
| 55 |
+
-webkit-text-fill-color: transparent;
|
| 56 |
+
margin-bottom: 20px;
|
| 57 |
+
line-height: 1.2;
|
| 58 |
+
}
|
| 59 |
+
.hero-subtitle {
|
| 60 |
+
font-size: 1.25rem;
|
| 61 |
+
color: #64748b;
|
| 62 |
+
margin-bottom: 30px;
|
| 63 |
+
line-height: 1.6;
|
| 64 |
+
}
|
| 65 |
+
.hero-description {
|
| 66 |
+
font-size: 1rem;
|
| 67 |
+
color: #94a3b8;
|
| 68 |
+
line-height: 1.8;
|
| 69 |
+
margin-bottom: 40px;
|
| 70 |
+
}
|
| 71 |
+
.feature-list {
|
| 72 |
+
list-style: none;
|
| 73 |
+
padding: 0;
|
| 74 |
+
margin: 0 0 40px 0;
|
| 75 |
+
}
|
| 76 |
+
.feature-list li {
|
| 77 |
+
padding: 4px 0;
|
| 78 |
+
color: #cbd5e1;
|
| 79 |
+
font-size: 0.95rem;
|
| 80 |
+
display: flex;
|
| 81 |
+
align-items: center;
|
| 82 |
+
gap: 4px;
|
| 83 |
+
}
|
| 84 |
+
.feature-list li::before {
|
| 85 |
+
content: "✓";
|
| 86 |
+
color: #10b981;
|
| 87 |
+
font-weight: bold;
|
| 88 |
+
font-size: 1.2rem;
|
| 89 |
+
}
|
| 90 |
+
.hero-image-container {
|
| 91 |
+
flex: 1;
|
| 92 |
+
display: flex;
|
| 93 |
+
justify-content: center;
|
| 94 |
+
align-items: center;
|
| 95 |
+
}
|
| 96 |
+
.hero-image {
|
| 97 |
+
max-width: 500px;
|
| 98 |
+
height: auto;
|
| 99 |
+
filter: drop-shadow(0 20px 40px rgba(0, 0, 0, 0.4));
|
| 100 |
+
}
|
| 101 |
+
.cta-button {
|
| 102 |
+
background: linear-gradient(135deg, #10b981 0%, #059669 100%) !important;
|
| 103 |
+
color: white !important;
|
| 104 |
+
border: none !important;
|
| 105 |
+
padding: 16px 40px !important;
|
| 106 |
+
font-size: 1.1rem !important;
|
| 107 |
+
font-weight: 600 !important;
|
| 108 |
+
border-radius: 12px !important;
|
| 109 |
+
cursor: pointer !important;
|
| 110 |
+
transition: all 0.3s ease !important;
|
| 111 |
+
box-shadow: 0 4px 15px rgba(16, 185, 129, 0.3) !important;
|
| 112 |
+
}
|
| 113 |
+
.cta-button:hover {
|
| 114 |
+
transform: translateY(-2px) !important;
|
| 115 |
+
box-shadow: 0 8px 25px rgba(16, 185, 129, 0.4) !important;
|
| 116 |
+
}
|
| 117 |
+
</style>
|
| 118 |
+
|
| 119 |
+
<div class="hero-container">
|
| 120 |
+
<div class="hero-content">
|
| 121 |
+
<h1 class="hero-title">Preserve Your Precious Moments</h1>
|
| 122 |
+
<p class="hero-subtitle">Your intelligent, privacy-first photo archive</p>
|
| 123 |
+
|
| 124 |
+
<ul class="feature-list">
|
| 125 |
+
<li>AI-powered semantic search - find photos naturally</li>
|
| 126 |
+
<li>Automatic captioning with MiniCPM-V-4.6</li>
|
| 127 |
+
</ul>
|
| 128 |
+
|
| 129 |
+
<button class="cta-button" id="start-ingest-btn">Get Started</button>
|
| 130 |
+
</div>
|
| 131 |
+
|
| 132 |
+
<div class="hero-image-container">
|
| 133 |
+
<img src="https://i.postimg.cc/zBKzF24k/hero-Image.png" alt="Precious Moments" class="hero-image">
|
| 134 |
+
</div>
|
| 135 |
+
</div>
|
| 136 |
+
""")
|
| 137 |
+
|
| 138 |
+
# Hidden button for Gradio event handling
|
| 139 |
+
start_btn = gr.Button("Start Ingesting", variant="primary", size="lg", visible=False)
|
| 140 |
+
|
| 141 |
+
# JavaScript to trigger the hidden button when CTA is clicked
|
| 142 |
+
gr.HTML("""
|
| 143 |
+
<script>
|
| 144 |
+
document.addEventListener('DOMContentLoaded', function() {
|
| 145 |
+
const ctaBtn = document.getElementById('start-ingest-btn');
|
| 146 |
+
const gradioBtn = document.querySelector('button[data-testid="Start Ingesting"]');
|
| 147 |
+
|
| 148 |
+
if (ctaBtn && gradioBtn) {
|
| 149 |
+
ctaBtn.addEventListener('click', function() {
|
| 150 |
+
gradioBtn.click();
|
| 151 |
+
});
|
| 152 |
+
}
|
| 153 |
+
});
|
| 154 |
+
</script>
|
| 155 |
+
""")
|
| 156 |
+
|
| 157 |
+
return page, start_btn
|
ui/ingest.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import gradio as gr
|
| 2 |
+
from caption_store import get_all_collections
|
| 3 |
+
|
| 4 |
+
def render_ingest():
|
| 5 |
+
with gr.Column(visible=False, elem_classes="page-container") as page:
|
| 6 |
+
gr.HTML("""
|
| 7 |
+
<div style='margin-bottom: 20px;'>
|
| 8 |
+
<h1 style='font-size: 2rem; font-weight: 700; color: #f8fafc;'>📥 Ingest Photos</h1>
|
| 9 |
+
<p style='color: #94a3b8;'>Upload images to your archive and generate AI descriptions.</p>
|
| 10 |
+
</div>
|
| 11 |
+
""")
|
| 12 |
+
|
| 13 |
+
with gr.Row(equal_height=True):
|
| 14 |
+
# Left: File Uploader
|
| 15 |
+
with gr.Column(scale=3):
|
| 16 |
+
upload = gr.File(
|
| 17 |
+
label="Drag & Drop Images Here",
|
| 18 |
+
file_count="multiple",
|
| 19 |
+
file_types=[".jpg", ".jpeg", ".png", ".webp", ".tiff"],
|
| 20 |
+
height=280,
|
| 21 |
+
elem_classes="upload-zone"
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
# Right: Destination & Collection Controls
|
| 25 |
+
with gr.Column(scale=2, elem_classes="settings-card"):
|
| 26 |
+
gr.Markdown("### ⚙️ Collection Destination")
|
| 27 |
+
|
| 28 |
+
use_new_coll = gr.Checkbox(
|
| 29 |
+
label="Create a brand new collection",
|
| 30 |
+
value=False,
|
| 31 |
+
elem_classes="custom-checkbox"
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
# Safely parse collection values on first load
|
| 35 |
+
choices = get_all_collections()
|
| 36 |
+
if not choices:
|
| 37 |
+
choices = ["General"]
|
| 38 |
+
default_val = "General" if "General" in choices else choices[0]
|
| 39 |
+
|
| 40 |
+
coll_dropdown = gr.Dropdown(
|
| 41 |
+
choices=choices,
|
| 42 |
+
label="Target Collection",
|
| 43 |
+
value=default_val,
|
| 44 |
+
interactive=True,
|
| 45 |
+
visible=True
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
new_coll_name = gr.Textbox(
|
| 49 |
+
label="New Collection Name",
|
| 50 |
+
placeholder="e.g. Summer Vacation 2024",
|
| 51 |
+
visible=False
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
ingest_btn = gr.Button(
|
| 55 |
+
"🚀 Start Processing",
|
| 56 |
+
variant="primary",
|
| 57 |
+
size="lg"
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
# Bottom: Terminal-style status log
|
| 61 |
+
with gr.Column(elem_classes="console-wrapper"):
|
| 62 |
+
gr.Markdown("### 🖥️ Ingestion Logs")
|
| 63 |
+
ingest_status = gr.Textbox(
|
| 64 |
+
value="System ready. Awaiting file upload...",
|
| 65 |
+
show_label=False,
|
| 66 |
+
interactive=False,
|
| 67 |
+
lines=6,
|
| 68 |
+
elem_classes="status-console"
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
return page, upload, coll_dropdown, use_new_coll, new_coll_name, ingest_btn, ingest_status
|
ui/search.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# import gradio as gr
|
| 2 |
+
# from caption_store import get_all_collections
|
| 3 |
+
|
| 4 |
+
# def render_search():
|
| 5 |
+
# with gr.Column(visible=False) as page:
|
| 6 |
+
# gr.Markdown("# 🔍 AI Search")
|
| 7 |
+
# with gr.Row():
|
| 8 |
+
# search_query = gr.Textbox(
|
| 9 |
+
# placeholder="Describe what you're looking for... (e.g. 'moody forest portrait')",
|
| 10 |
+
# label="Search Query", scale=4
|
| 11 |
+
# )
|
| 12 |
+
# search_col_filter = gr.Dropdown(
|
| 13 |
+
# choices=["All"] + get_all_collections(), value="All", label="Filter by Collection", scale=1
|
| 14 |
+
# )
|
| 15 |
+
# search_btn = gr.Button("Search", variant="primary", scale=1)
|
| 16 |
+
|
| 17 |
+
# search_status_msg = gr.Markdown("Enter a query to start searching.")
|
| 18 |
+
# search_gallery = gr.Gallery(label="Search Results", columns=4, height="auto")
|
| 19 |
+
# return page, search_query, search_col_filter, search_btn, search_status_msg, search_gallery
|
| 20 |
+
import gradio as gr
|
| 21 |
+
from caption_store import get_all_collections
|
| 22 |
+
|
| 23 |
+
def render_search():
|
| 24 |
+
# Selection and path tracking states for search
|
| 25 |
+
selected_paths = gr.State([])
|
| 26 |
+
loaded_original_paths = gr.State([])
|
| 27 |
+
|
| 28 |
+
with gr.Column(visible=False, elem_classes="page-container") as page:
|
| 29 |
+
gr.HTML("""
|
| 30 |
+
<div style='margin-bottom: 20px;'>
|
| 31 |
+
<h1 style='font-size: 2rem; font-weight: 700; color: #f8fafc;'>🔍 AI Search</h1>
|
| 32 |
+
<p style='color: #94a3b8;'>Search your archive using natural language, select results, and bulk download.</p>
|
| 33 |
+
</div>
|
| 34 |
+
""")
|
| 35 |
+
with gr.Row():
|
| 36 |
+
search_query = gr.Textbox(
|
| 37 |
+
placeholder="Describe what you're looking for... (e.g. 'moody forest portrait')",
|
| 38 |
+
label="Search Query", scale=4
|
| 39 |
+
)
|
| 40 |
+
search_col_filter = gr.Dropdown(
|
| 41 |
+
choices=["All"] + get_all_collections(), value="All", label="Filter by Collection", scale=1
|
| 42 |
+
)
|
| 43 |
+
search_btn = gr.Button("Search", variant="primary", scale=1)
|
| 44 |
+
|
| 45 |
+
search_status_msg = gr.Markdown("Enter a query to start searching.")
|
| 46 |
+
|
| 47 |
+
# Full-width panel for selected files output
|
| 48 |
+
selection_status = gr.Markdown(
|
| 49 |
+
value="**0** image(s) selected for download packaging.\n\n*No files currently selected.*",
|
| 50 |
+
elem_classes="selection-status-box"
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
# Standalone row for action buttons
|
| 54 |
+
with gr.Row(elem_classes="selection-buttons-row"):
|
| 55 |
+
select_all_btn = gr.Button("✅ Select All Results", size="sm", variant="secondary")
|
| 56 |
+
clear_selection_btn = gr.Button("🧹 Clear Selection", size="sm", variant="secondary")
|
| 57 |
+
download_btn = gr.Button("📦 Zip & Download Selected", variant="primary", size="sm")
|
| 58 |
+
|
| 59 |
+
# Hidden file output for compilation download
|
| 60 |
+
download_file = gr.File(label="Source Quality ZIP Archive", visible=False)
|
| 61 |
+
|
| 62 |
+
search_gallery = gr.Gallery(
|
| 63 |
+
label="Search Results",
|
| 64 |
+
columns=4,
|
| 65 |
+
height="auto",
|
| 66 |
+
elem_classes="custom-gallery",
|
| 67 |
+
allow_preview=False
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
return (
|
| 71 |
+
page, search_query, search_col_filter, search_btn, search_status_msg, search_gallery,
|
| 72 |
+
selected_paths, loaded_original_paths, selection_status, download_btn,
|
| 73 |
+
download_file, clear_selection_btn, select_all_btn
|
| 74 |
+
)
|
ui/sidebar.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import gradio as gr
|
| 2 |
+
from caption_store import entry_count
|
| 3 |
+
|
| 4 |
+
def render_sidebar():
|
| 5 |
+
with gr.Sidebar(elem_id="custom-sidebar"):
|
| 6 |
+
# Custom HTML Header
|
| 7 |
+
gr.HTML("""
|
| 8 |
+
<div class="brand-header">
|
| 9 |
+
<h1>ShutterSearch</h1>
|
| 10 |
+
<p>Modern Photo Archive</p>
|
| 11 |
+
</div>
|
| 12 |
+
""")
|
| 13 |
+
|
| 14 |
+
# Added non-breaking spaces ( ) after emojis for visual breathing room
|
| 15 |
+
btns = {
|
| 16 |
+
"home": gr.Button("🏠Home", elem_classes="nav-btn"),
|
| 17 |
+
"ingest": gr.Button("📥Ingest", elem_classes="nav-btn"),
|
| 18 |
+
"search": gr.Button("🔍Search", elem_classes="nav-btn"),
|
| 19 |
+
"collections": gr.Button("📁Collections", elem_classes="nav-btn"),
|
| 20 |
+
"captions": gr.Button("📝Captions", elem_classes="nav-btn"),
|
| 21 |
+
"about": gr.Button("ℹ️About", elem_classes="nav-btn"),
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
with gr.Column(elem_classes="stats-container"):
|
| 25 |
+
stats = gr.Label(value=f"Total Photos: {entry_count()}", label="Archive Stats")
|
| 26 |
+
|
| 27 |
+
return btns, stats
|