SwikarG commited on
Commit
4a02afe
·
verified ·
1 Parent(s): 39e8c6b

upload files

Browse files
.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: ShutterSearch
3
- emoji:
4
- colorFrom: indigo
5
- colorTo: purple
6
- sdk: gradio
7
- sdk_version: 6.18.0
8
- python_version: '3.13'
9
- app_file: app.py
10
- pinned: false
11
- license: apache-2.0
12
- short_description: A semantic search platform built for photographers
13
- ---
14
-
15
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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

  • SHA256: c160ad923b69233aed27bc3df2184ebdc87318951aea86afa0d7b8dc8c378135
  • Pointer size: 131 Bytes
  • Size of remote file: 180 kB
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 (&nbsp;) 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