photosearch 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- photosearch-0.1.0/.gitignore +8 -0
- photosearch-0.1.0/FINDINGS.md +377 -0
- photosearch-0.1.0/LICENSE +21 -0
- photosearch-0.1.0/PKG-INFO +676 -0
- photosearch-0.1.0/README.md +642 -0
- photosearch-0.1.0/ROADMAP.md +46 -0
- photosearch-0.1.0/pyproject.toml +78 -0
- photosearch-0.1.0/src/photosearch/__init__.py +1 -0
- photosearch-0.1.0/src/photosearch/__main__.py +3 -0
- photosearch-0.1.0/src/photosearch/albums.py +155 -0
- photosearch-0.1.0/src/photosearch/checks.py +83 -0
- photosearch-0.1.0/src/photosearch/cli.py +188 -0
- photosearch-0.1.0/src/photosearch/clutter.py +110 -0
- photosearch-0.1.0/src/photosearch/config.py +20 -0
- photosearch-0.1.0/src/photosearch/dates.py +252 -0
- photosearch-0.1.0/src/photosearch/demo.py +158 -0
- photosearch-0.1.0/src/photosearch/dupes.py +192 -0
- photosearch-0.1.0/src/photosearch/embed.py +38 -0
- photosearch-0.1.0/src/photosearch/eval_queries.txt +50 -0
- photosearch-0.1.0/src/photosearch/evals.py +367 -0
- photosearch-0.1.0/src/photosearch/explore.py +159 -0
- photosearch-0.1.0/src/photosearch/images.py +47 -0
- photosearch-0.1.0/src/photosearch/index.py +191 -0
- photosearch-0.1.0/src/photosearch/lan.py +80 -0
- photosearch-0.1.0/src/photosearch/mcp_server.py +202 -0
- photosearch-0.1.0/src/photosearch/people.py +117 -0
- photosearch-0.1.0/src/photosearch/photos_app.py +170 -0
- photosearch-0.1.0/src/photosearch/picks.py +44 -0
- photosearch-0.1.0/src/photosearch/places.py +129 -0
- photosearch-0.1.0/src/photosearch/recreate.py +130 -0
- photosearch-0.1.0/src/photosearch/search.py +132 -0
- photosearch-0.1.0/src/photosearch/server.py +344 -0
- photosearch-0.1.0/src/photosearch/smartbox.py +88 -0
- photosearch-0.1.0/src/photosearch/store.py +134 -0
- photosearch-0.1.0/src/photosearch/stories.py +257 -0
- photosearch-0.1.0/src/photosearch/text.py +88 -0
- photosearch-0.1.0/src/photosearch/thennow.py +120 -0
- photosearch-0.1.0/src/photosearch/update.py +65 -0
- photosearch-0.1.0/src/photosearch/videos.py +232 -0
- photosearch-0.1.0/src/photosearch/web/app.css +683 -0
- photosearch-0.1.0/src/photosearch/web/index.html +299 -0
- photosearch-0.1.0/src/photosearch/web/js/albums.js +235 -0
- photosearch-0.1.0/src/photosearch/web/js/core.js +327 -0
- photosearch-0.1.0/src/photosearch/web/js/frame.js +113 -0
- photosearch-0.1.0/src/photosearch/web/js/galaxy.js +203 -0
- photosearch-0.1.0/src/photosearch/web/js/home.js +106 -0
- photosearch-0.1.0/src/photosearch/web/js/judge.js +96 -0
- photosearch-0.1.0/src/photosearch/web/js/map.js +80 -0
- photosearch-0.1.0/src/photosearch/web/js/people.js +85 -0
- photosearch-0.1.0/src/photosearch/web/js/phone.js +27 -0
- photosearch-0.1.0/src/photosearch/web/js/play.js +375 -0
- photosearch-0.1.0/src/photosearch/web/js/search.js +188 -0
- photosearch-0.1.0/src/photosearch/web/js/stories.js +250 -0
- photosearch-0.1.0/src/photosearch/web/js/time.js +81 -0
- photosearch-0.1.0/src/photosearch/web/review.html +102 -0
- photosearch-0.1.0/src/photosearch/why.py +147 -0
- photosearch-0.1.0/tests/conftest.py +99 -0
- photosearch-0.1.0/tests/library.py +152 -0
- photosearch-0.1.0/tests/server.py +45 -0
- photosearch-0.1.0/tests/test_albums.py +144 -0
- photosearch-0.1.0/tests/test_browser.py +210 -0
- photosearch-0.1.0/tests/test_checks.py +62 -0
- photosearch-0.1.0/tests/test_cli.py +36 -0
- photosearch-0.1.0/tests/test_clutter.py +63 -0
- photosearch-0.1.0/tests/test_config.py +39 -0
- photosearch-0.1.0/tests/test_dates.py +118 -0
- photosearch-0.1.0/tests/test_demo.py +53 -0
- photosearch-0.1.0/tests/test_dupes.py +99 -0
- photosearch-0.1.0/tests/test_evals.py +149 -0
- photosearch-0.1.0/tests/test_index.py +77 -0
- photosearch-0.1.0/tests/test_lan.py +179 -0
- photosearch-0.1.0/tests/test_people.py +49 -0
- photosearch-0.1.0/tests/test_photos_app.py +96 -0
- photosearch-0.1.0/tests/test_picks.py +56 -0
- photosearch-0.1.0/tests/test_presenter.py +60 -0
- photosearch-0.1.0/tests/test_recreate.py +79 -0
- photosearch-0.1.0/tests/test_search.py +174 -0
- photosearch-0.1.0/tests/test_server.py +240 -0
- photosearch-0.1.0/tests/test_stories.py +90 -0
- photosearch-0.1.0/tests/test_text.py +36 -0
- photosearch-0.1.0/tests/test_thennow.py +106 -0
- photosearch-0.1.0/tests/test_update.py +67 -0
- photosearch-0.1.0/tests/test_videos.py +117 -0
- photosearch-0.1.0/tests/test_why.py +70 -0
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
# Findings and learnings
|
|
2
|
+
|
|
3
|
+
What the experiments on photosearch found, how each setting in the code was chosen, and when to measure again.
|
|
4
|
+
[README.md](README.md) says how to use the app; this file says why it works the way it does.
|
|
5
|
+
|
|
6
|
+
Everything here was measured on one library: about 17,000 items (16,200 photos, 750 videos), on an M3 MacBook. Most
|
|
7
|
+
judging by eye was done blind by one judge (Claude), so small differences are within what a second judging would
|
|
8
|
+
move. New experiments go through `photosearch eval` (README → Experiments) and get a section here. A second judge
|
|
9
|
+
can now judge the same sheets in the page (Play → Judge a Test), and the report says how often two judges agree.
|
|
10
|
+
|
|
11
|
+
## Lessons that keep coming back
|
|
12
|
+
|
|
13
|
+
1. **Agreement isn't quality.** A change can reorder results a lot and still find photos that fit as well. 70
|
|
14
|
+
vision tokens changed the first photo in more than half of searches but was only slightly worse by eye. Searches
|
|
15
|
+
in Hindi shared only 10 to 16 of the first 24 with English, yet fit better. So judge the photos, blind, as well
|
|
16
|
+
as counting overlap.
|
|
17
|
+
2. **Some things score high against everything. Subtract a baseline.** A loud video clip, a patch of sky and a quick
|
|
18
|
+
vector all sit close to *any* words, so their raw scores flatter them. The fix that worked each time: score by how
|
|
19
|
+
much closer something is to the search than to everyday descriptions (`SOUND_BASE` for sound, `BASE` for Why, the
|
|
20
|
+
app's own search words for quick vectors). For photos that match everything, a CSLS penalty on every photo made
|
|
21
|
+
things worse, but a small one only for the photos that crowd many first pages helped (see Where search breaks down).
|
|
22
|
+
3. **Each search's scores sit at their own level.** No single score splits right from wrong across searches: a wrong
|
|
23
|
+
sound for "a baby crying" scored higher than a right one for "music playing". Cut-offs are compromises, set where
|
|
24
|
+
most right ones stay in the first page.
|
|
25
|
+
4. **Set cut-offs from marked examples, not from 0.5.** `SOUND_AT` was set by ear, `FOCUS` and Then and Now's
|
|
26
|
+
thresholds by eye, and the Jev model's yes/no cut-offs (0.7 to 0.85) on labelled queries.
|
|
27
|
+
5. **Brute force is fine at this size.** All vectors are 52 MB and a search over them takes 0.6 ms in NumPy. No
|
|
28
|
+
vector database, no compression.
|
|
29
|
+
6. **Vectors from different settings don't mix.** The index refuses other models and vision tokens. The one exception,
|
|
30
|
+
the quick first index, needs a correction to mix fairly.
|
|
31
|
+
7. **Check what the model already does before adding a signal.** Reading the words in photos looked like a fix for
|
|
32
|
+
text searches, but the model already read big, clear text, and mixing the read words in made search worse. They
|
|
33
|
+
help only when asked for (Words in photos).
|
|
34
|
+
|
|
35
|
+
## Speed
|
|
36
|
+
|
|
37
|
+
| Setup | Photos/s | Full library |
|
|
38
|
+
|---|---|---|
|
|
39
|
+
| PyTorch MPS bf16, 280 vision tokens (default) | ~2.0 | 2 h 20 min (16,927 photos, measured) |
|
|
40
|
+
| PyTorch MPS bf16, 70 vision tokens | ~9 | ~30 min (estimated from 2,000 photos) |
|
|
41
|
+
| MLX (`mlx-community/embeddinggemma-2-bf16` via mlx-vlm) | ~1.3 | no gain, dropped |
|
|
42
|
+
| CPU fp32 | ~0.3 | — |
|
|
43
|
+
|
|
44
|
+
Videos take about 6 s each, mostly for the frames.
|
|
45
|
+
|
|
46
|
+
## Vision tokens (70 against 280)
|
|
47
|
+
|
|
48
|
+
The model cuts a photo into squares ("vision tokens"). 280 is the default; 70 is 4.7× faster.
|
|
49
|
+
|
|
50
|
+
On 2,000 random photos, the 70-token vectors have mean cosine 0.95 to the 280-token ones (5% are below 0.91, the
|
|
51
|
+
lowest 0.73). Over 50 searches of those 2,000:
|
|
52
|
+
|
|
53
|
+
| 70 tokens vs. 280 | Result |
|
|
54
|
+
|---|---|
|
|
55
|
+
| Same photo first | 22 of 50 searches |
|
|
56
|
+
| 280's first photo still in 70's top 5 | 45 of 50 |
|
|
57
|
+
| Top 10 in common | 6.4 on average |
|
|
58
|
+
| First 24 (one page) in common | 16.5 on average |
|
|
59
|
+
|
|
60
|
+
Judged by eye, blind: at 280 tokens, 194 of the 250 photos fit the search; at 70, 187. Where the first photo
|
|
61
|
+
differed (28 searches), it was about as good in 22, better at 280 in 4 and better at 70 in 2. Judging the same
|
|
62
|
+
sample again in a new blind order gave 192 and 185, and the same 4, 2 and 22.
|
|
63
|
+
|
|
64
|
+
**Decision:** 280 stays the default. 70 mostly reorders results and is only slightly worse, which is why the quick
|
|
65
|
+
first index uses it.
|
|
66
|
+
|
|
67
|
+
## Quick first index (`index --quick`)
|
|
68
|
+
|
|
69
|
+
`index --quick` reads photos at 70 tokens (about 9 a second against 2), then the next plain `index` redoes them at
|
|
70
|
+
280, newest first. Until then, the index holds a mix.
|
|
71
|
+
|
|
72
|
+
**The problem.** A 70-token vector scores 0.019 higher against words than the same photo's 280-token one. Small, but
|
|
73
|
+
search ranks by small differences: with a quarter of photos still quick, they took half of each search's first 24.
|
|
74
|
+
|
|
75
|
+
**The fix.** Each quick vector is moved back along the direction the app's own words share (`quick_shift()` in
|
|
76
|
+
`index.py`, from the interest words plus a few everyday ones), by the 0.019 gap. Measured on 2,000 photos, with the
|
|
77
|
+
fix set on 1,000 photos and 25 searches and tested on the rest:
|
|
78
|
+
|
|
79
|
+
| Still quick | First 24 the same as an all-280 index, with the fix | without |
|
|
80
|
+
|---|---|---|
|
|
81
|
+
| a quarter | 21.8 | 18.4 |
|
|
82
|
+
| half | 19.5 | 15.8 |
|
|
83
|
+
| three quarters | 18.2 | 15.6 |
|
|
84
|
+
|
|
85
|
+
With the fix, quick photos take their fair share of the first 24.
|
|
86
|
+
|
|
87
|
+
**What didn't win.** Subtracting the gap from search scores worked as well for search, but not for features that
|
|
88
|
+
compare photos with photos (the time machine, smart albums, look-alikes). Fixing the vectors covers all of them. A
|
|
89
|
+
least-squares map from 70 to 280 overfit and made things worse.
|
|
90
|
+
|
|
91
|
+
**Measure again** with a new model, or if `QUICK_TOKENS` changes.
|
|
92
|
+
|
|
93
|
+
## Shorter or compressed vectors
|
|
94
|
+
|
|
95
|
+
Same sample and searches as vision tokens:
|
|
96
|
+
|
|
97
|
+
| Variant | Same first photo | First 24 in common |
|
|
98
|
+
|---|---|---|
|
|
99
|
+
| int8 | 49 of 50 | 23.1 |
|
|
100
|
+
| 256 numbers | 27 of 50 | 18.4 |
|
|
101
|
+
| 1 bit per number | 24 of 50 | 14.4 |
|
|
102
|
+
| 128 numbers | 21 of 50 | 11.6 |
|
|
103
|
+
| 1 bit, then the best 100 rescored with full vectors | 49 of 50 | |
|
|
104
|
+
|
|
105
|
+
**Decision:** none is used, since search is already fast (lesson 5). int8, or 1 bit with rescoring, is the path if a
|
|
106
|
+
library ever gets 10× bigger. The "photo anchor" published by pastel-labs on Hugging Face only matters for 1- or
|
|
107
|
+
4-bit vectors.
|
|
108
|
+
|
|
109
|
+
## Searches in other languages
|
|
110
|
+
|
|
111
|
+
24 searches in Spanish, French, Hindi, Chinese, Arabic and Bengali, over the whole library, judged blind against the
|
|
112
|
+
English ones (the top 5 of all seven in random order). Photos that fit, of 120:
|
|
113
|
+
|
|
114
|
+
| Hindi | Chinese | English | French | Spanish | Arabic | Bengali |
|
|
115
|
+
|---|---|---|---|---|---|---|
|
|
116
|
+
| 104 | 102 | 94 | 93 | 92 | 91 | 88 |
|
|
117
|
+
|
|
118
|
+
So search works in any of them, with no change. The results differ from English (10 to 16 of the first 24 in common)
|
|
119
|
+
but fit as well. Bengali was the weakest, on mountains, group photos, trains and babies. The gaps between the first
|
|
120
|
+
six are within what a second judging would move.
|
|
121
|
+
|
|
122
|
+
## Sound in videos (`SOUND_BASE` and `SOUND_AT`)
|
|
123
|
+
|
|
124
|
+
`photosearch videos` cuts each video's sound into 10-second pieces, and the model turns each piece into a vector in
|
|
125
|
+
the same space as text. A video ranks by its best moment: its poster, a later frame, or a piece of sound. Frames
|
|
126
|
+
score exactly like photos. Sound needs two corrections.
|
|
127
|
+
|
|
128
|
+
**`SOUND_BASE`: take away the head start of loud clips.** A trampoline park or a stadium scores fairly high against
|
|
129
|
+
*every* search, so raw sound scores put noisy videos everywhere. `SOUND_BASE` is a list of everyday home-video
|
|
130
|
+
sounds ("people talking", "background noise", "voices", "a room"…). Each piece of sound gets a baseline, its average
|
|
131
|
+
closeness to that list, and scores by how far above it your search is:
|
|
132
|
+
|
|
133
|
+
> margin = (sound vs. search) − (sound vs. everyday sounds)
|
|
134
|
+
|
|
135
|
+
A clip now scores well only when it sounds like *your* words. On 50 videos, real matches stood out by +0.09 to +0.19
|
|
136
|
+
(Happy Birthday over a cake, laughing on a couch), and the rest by about 0.
|
|
137
|
+
|
|
138
|
+
**`SOUND_AT`: put sound on the photos' scale.** The margin is about 0.1, while good photo matches score 0.65 to
|
|
139
|
+
0.80. To rank them together, the app adds a constant:
|
|
140
|
+
|
|
141
|
+
> sound score = margin + `SOUND_AT` (0.59)
|
|
142
|
+
|
|
143
|
+
So `SOUND_AT` is the dial for how much sound shows up. Higher brings more videos by sound, wrong ones too; lower lets
|
|
144
|
+
only the clearest sounds in among the photos. For "people laughing", the 24th result scores 0.697, so a sound must
|
|
145
|
+
stand out by about 0.11 to reach the first page.
|
|
146
|
+
|
|
147
|
+
**How `SOUND_AT` was set.** By ear. For 12 searches ("people laughing", "a dog barking", "waves crashing on a beach"),
|
|
148
|
+
the sound matches ranked 1 to 4, 8 and 16 were played back in random order, without their scores, and each marked as
|
|
149
|
+
the searched sound, something else, or can't tell (`data/sound-marks.json`). Then each value of `SOUND_AT` was tried,
|
|
150
|
+
counting the right and wrong heard videos in each search's first 24:
|
|
151
|
+
|
|
152
|
+
| `SOUND_AT` | Right | Wrong |
|
|
153
|
+
|---|---|---|
|
|
154
|
+
| 0.59 (chosen) | 19 | 6, all "a dog barking" bringing up kids shouting |
|
|
155
|
+
| 0.62 (first guess) | 24 | 18 |
|
|
156
|
+
|
|
157
|
+
**What it hears, and what it doesn't.** Of 72 marked clips: 38 right, 33 wrong, 1 can't tell. Happy Birthday,
|
|
158
|
+
laughing, clapping, cheering and music come up well. The model hears the *kind* of sound more than its source: kids
|
|
159
|
+
shouting matched "a dog barking", singing matched "birds singing", a fairground ride matched "fireworks". The margin
|
|
160
|
+
told right from wrong only a little (0.62, where 0.5 is chance), because of lesson 3: "a baby crying" got six wrong
|
|
161
|
+
matches at +0.10 to +0.12, and "music playing" six right ones at +0.03 to +0.05. Scoring each sound against all sounds
|
|
162
|
+
for that search, or adding "children playing" to `SOUND_BASE`, didn't help.
|
|
163
|
+
|
|
164
|
+
**Other checks.** Spoken and made-up test sounds (a weather forecast, "happy birthday", beeps, noise) each scored
|
|
165
|
+
highest on their own description. Text and image vectors are the same with the audio encoder loaded or not. ✦ Looks
|
|
166
|
+
like this uses only frames, since a photo has no sound.
|
|
167
|
+
|
|
168
|
+
**Measure again** with a new model or a change to `SOUND_BASE`, since both move the margins. More videos don't.
|
|
169
|
+
|
|
170
|
+
## Why did this match (`why.py`)
|
|
171
|
+
|
|
172
|
+
**Where.** A photo's vector is the mean of its squares' vectors (a grid of about 19 × 14), so each square's score is
|
|
173
|
+
its exact share of the photo's score, from one pass of the model (0.5 s). As with sound, a square counts by how much
|
|
174
|
+
closer it is to the search than to everyday descriptions (`BASE`), because stray squares of sky scored high for
|
|
175
|
+
anything. Tested by covering the hottest quarter of 17 photos (6 searches) and measuring how far the score fell:
|
|
176
|
+
|
|
177
|
+
| Which quarter covered | Score fell by |
|
|
178
|
+
|---|---|
|
|
179
|
+
| hottest, against everyday descriptions (used) | 0.032 |
|
|
180
|
+
| hottest, by covering squares one at a time (16 s a photo) | 0.027 |
|
|
181
|
+
| hottest, by plain score | 0.025 |
|
|
182
|
+
| random | 0.002 |
|
|
183
|
+
|
|
184
|
+
**Whole scenes.** A search for a whole scene (a backyard, a pool) doesn't sit in any one part: 64-74% of the lit part
|
|
185
|
+
sits in the hottest quarter, against 80-92% for a dog or a balloon. So below `FOCUS` = 0.77 it says the whole scene
|
|
186
|
+
matches. Judged by eye on 20 searches: small, clear things light up well (a soccer ball, a lighthouse, a bowl of
|
|
187
|
+
noodles); a small dog in a group photo or an airplane wing don't.
|
|
188
|
+
|
|
189
|
+
**Which words.** Each word is left out in turn and the score compared. It was right each time it said a word didn't
|
|
190
|
+
fit (a balloon that was the sun, fishing with nobody fishing). A word counts as not fitting only when leaving it out raises the score by more
|
|
191
|
+
than 0.005 (`MISFIT`); smaller changes are noise.
|
|
192
|
+
|
|
193
|
+
**What it sees.** Photos' labels as words (those on 3+ photos, without the 40 most common, which fit everything): on
|
|
194
|
+
1,500 photos, one of its top five was among Photos' own labels for 71%, against 5% for the five most used labels.
|
|
195
|
+
|
|
196
|
+
## Then and Now
|
|
197
|
+
|
|
198
|
+
Judged by eye on sheets of the best pairs.
|
|
199
|
+
|
|
200
|
+
- **Same spot.** With GPS (within 150 m, 4+ years apart), pairs above 0.8 were nearly all the same spot. Matching by
|
|
201
|
+
look alone found places that look alike instead (Angkor Wat and Teotihuacán, two different aquariums), so both
|
|
202
|
+
photos need GPS. Above 0.97 a pair is one photo saved twice. Clutter (an ID shot twice, floors, price tags) is left
|
|
203
|
+
out. 32 pairs on 16,000 photos; only photos from 2016 on have GPS here. The scene decides, not who's in it, so many
|
|
204
|
+
are one child, then a younger sibling in the same spot.
|
|
205
|
+
- **Same person.** Pairs above 0.88, in photos with at most 2 named people, were nearly all good (45 pairs, from 2009
|
|
206
|
+
on). Below that, or with more people named, they were parties: one face among many in a dining room that looks like
|
|
207
|
+
every other.
|
|
208
|
+
|
|
209
|
+
## Recreate This
|
|
210
|
+
|
|
211
|
+
Ranking old photos of one or two named people by a few posing descriptions ("a child posing for the camera", "a
|
|
212
|
+
parent holding a baby, posing for a photo") found kid portraits and parent-with-baby shots. The general interest score
|
|
213
|
+
found busy scenes (a trampoline, a party) that can't be redone. Matching the new shot (0.80, then you confirm) isn't
|
|
214
|
+
measured yet: it needs a real recreation.
|
|
215
|
+
|
|
216
|
+
## Where search breaks down
|
|
217
|
+
|
|
218
|
+
Which kinds of search the model gets wrong, and which photos it gets wrong for every search.
|
|
219
|
+
|
|
220
|
+
**Searches by kind.** 65 searches in 10 groups, over the whole library, each first 10 judged by eye (650 photos):
|
|
221
|
+
|
|
222
|
+
| Group | Photos that fit | Weakest |
|
|
223
|
+
|---|---|---|
|
|
224
|
+
| Activities (swimming, martial arts, a trampoline) | 67 of 70 | |
|
|
225
|
+
| Rooms (a kitchen, a bathroom, a classroom) | 54 of 60 | a living room, 8 |
|
|
226
|
+
| Events (a birthday cake, a wedding, a stage performance) | 54 of 60 | a Christmas tree, an amusement park, 8 |
|
|
227
|
+
| Big scenes (snowy mountains, a lake, a city skyline) | 60 of 70 | a sunset, 6 |
|
|
228
|
+
| People (a baby, a crowd, a man in a suit) | 60 of 70 | a selfie, 6 |
|
|
229
|
+
| Animals (a parrot, a shark, a dinosaur) | 59 of 70 | a cat, 1 (there are hardly any cats) |
|
|
230
|
+
| Counting and mood (three people, a happy moment) | 50 of 60 | one person alone, 4 |
|
|
231
|
+
| Small objects (a candle, a vase, a ball) | 57 of 70 | a bicycle, a candle, a vase, a ball, 7 |
|
|
232
|
+
| Text and documents (a receipt, a whiteboard) | 47 of 60 | a printed page, 3; a chart, 6 |
|
|
233
|
+
| Light and style (dusk, fog, black and white) | 38 of 60 | a close-up, 3; black and white, dusk, 5 |
|
|
234
|
+
|
|
235
|
+
546 of 650 in all (84%). What went wrong:
|
|
236
|
+
- **Searches for how a photo looks, not what's in it.** "A close-up" found accidental shots of skin and fingers.
|
|
237
|
+
"Black and white" and "one person alone" found black frames. "Dusk" found a red sun in midday wildfire smoke.
|
|
238
|
+
"Fog" and "a very dark photo" were fine.
|
|
239
|
+
- **Printed pages and charts** brought up whiteboards, screenshots and blank paper.
|
|
240
|
+
- **Small things** do worse than scenes, as Why found: a bicycle in a garage, a candle on a lamp.
|
|
241
|
+
- **Counting** did better than expected ("three people" 10 of 10, "exactly two children" 9 of 10), but "one person
|
|
242
|
+
alone" didn't.
|
|
243
|
+
- **"A cat" and "a dog"** fail because the library has almost none (Photos labels 1 photo "Canine"; its 31
|
|
244
|
+
"Feline" are tigers). Earlier low scores for fireworks, a church interior and a picnic were the same: nothing to
|
|
245
|
+
find. So a low score alone doesn't mean the model failed.
|
|
246
|
+
- **Photos' labels can't stand in for judging.** "People eating" was 10 of 10 by eye, yet held only 1 of the 34
|
|
247
|
+
photos Photos labels "Eating" in its first 100; "a graduation or ceremony" had 8 of 10, and 11% of its label.
|
|
248
|
+
|
|
249
|
+
**Photos that match everything.** Searching for each of Photos' 639 label words (`why.vocab`), a photo would be in
|
|
250
|
+
0.9 first pages (24 photos) by chance. A few are in 20 to 42, and 62% of the library is in none. The worst, by eye:
|
|
251
|
+
accidental shots (a finger over the lens, a pocket, orange blur, a floor), close textures with no subject (rock
|
|
252
|
+
pools, leaves, dry grass, ground), and small animals far off in dry fields. A model like this puts such photos near
|
|
253
|
+
the middle of the space, close to everything.
|
|
254
|
+
|
|
255
|
+
**The fix (`hub_penalty` in `search.py`).** For a search by words, a photo loses up to 0.04 by how many of those
|
|
256
|
+
first pages it's in (up to 40). Judged blind, on photos only, the photos it changes in the first 10:
|
|
257
|
+
|
|
258
|
+
| Searches | Photos changed | Fit, of those leaving | Fit, of those coming in |
|
|
259
|
+
|---|---|---|---|
|
|
260
|
+
| 50 from `photosearch eval` | 41 | 25 | 30 |
|
|
261
|
+
| the 65 above, new to it | 74 | 50 | 68 |
|
|
262
|
+
|
|
263
|
+
On the 65, it helped most where search was weakest: in light and style, 1 of the 10 photos leaving fit and 9 of
|
|
264
|
+
those coming in; text, 2 against 5; small objects, 6 against 9. A gentler 0.02 changed half as many and was even
|
|
265
|
+
(15 against 15). The counts take 0.12 s, once per index change. Looks like this (photo to photo) is unchanged.
|
|
266
|
+
Needs 100+ label words, so a new library's searches stay plain.
|
|
267
|
+
|
|
268
|
+
**Not used: leaving accidental shots out of search** (the 30 from Tidy up). It helped where they crowded in (black
|
|
269
|
+
frames for "a street at night") but changed only 12 photos over 115 searches, would hide them from "a very dark
|
|
270
|
+
photo", and the hub penalty already pushes them down.
|
|
271
|
+
|
|
272
|
+
**Measure again** with a new model, or if `why.vocab`'s words change a lot.
|
|
273
|
+
|
|
274
|
+
## Words in photos (`text.py`)
|
|
275
|
+
|
|
276
|
+
Text and documents were among the weakest searches above, so the Mac's own text reader (Apple's Vision framework)
|
|
277
|
+
now reads every photo once. The question was how to use what it reads.
|
|
278
|
+
|
|
279
|
+
**Reading.** The accurate level takes 0.05 s a photo after a slow first call, so all 16,200 photos took 14 minutes;
|
|
280
|
+
2,600 (16%) have words in them. The fast level is 3× quicker but misreads most text, and missed a third of the photos
|
|
281
|
+
the accurate one read (a passport, a door sign). Lines below 0.5 confidence are dropped: they're mostly guesses at a
|
|
282
|
+
texture.
|
|
283
|
+
|
|
284
|
+
**The model already reads big, clear text.** Before any change, 34 searches for words that are in the library (a
|
|
285
|
+
greeting on a banner, a garden's entrance sign, a game, a stock app, a store, a pharmacy label) found 189 fitting
|
|
286
|
+
photos in their first 10s, judged blind by eye. Several were 10 of 10 with no help.
|
|
287
|
+
|
|
288
|
+
**Adding a boost for read words made search worse.** Each photo got up to W more for the share of the search's
|
|
289
|
+
words written in it (rare words counting for more). Judged blind, all photos in the first 10s pooled:
|
|
290
|
+
|
|
291
|
+
| W | 34 searches for words: fits | of them, better / worse | 115 everyday searches: worse |
|
|
292
|
+
|---|---|---|---|
|
|
293
|
+
| 0 (now) | 189 | | |
|
|
294
|
+
| 0.03 | 187 | 3 / 3 | 7 |
|
|
295
|
+
| 0.06 | 179 | 4 / 9 | 21 |
|
|
296
|
+
| 0.1 | 173 | 4 / 10 | 40 |
|
|
297
|
+
|
|
298
|
+
It helped where the word was on paper (a store's name on receipts, an invoice) and hurt where the word is also
|
|
299
|
+
somewhere else: a city's name brought up passports that give it as a birthplace (9 fits to 0), "a marathon race" an
|
|
300
|
+
electric motor's label, "a cat" whiteboards and part numbers, "a happy moment" birthday banners, "a realtor for sale
|
|
301
|
+
sign" every listing photo with a REALTOR watermark. On the everyday searches it never helped.
|
|
302
|
+
|
|
303
|
+
**Small print is where the model can't read.** For 60 random words that are in 3 to 30 photos, the model's first 50
|
|
304
|
+
for the bare word held only 31% of the photos with that word written in them. So reading the text finds what
|
|
305
|
+
search can't, but only when asked for.
|
|
306
|
+
|
|
307
|
+
**What's used:** words in "quotes" must be written in the photo, and the rest of the search ranks those photos
|
|
308
|
+
(`"Costco" receipt`). Searches without quotes are unchanged.
|
|
309
|
+
|
|
310
|
+
**Measure again** with a new model (it may read more), or before trying a boost again: start from the cases above.
|
|
311
|
+
|
|
312
|
+
## Checking the first results with Jev
|
|
313
|
+
|
|
314
|
+
Jev (TypeSafe's text-only decision model) can't see a photo, so each of a search's first 40 photos was described
|
|
315
|
+
in words: the month and time it was taken, Photos' labels, what the search model sees (`why.sees`), how many named
|
|
316
|
+
people are in it, and its brightness, colour and detail measured from the thumbnail. Jev then answered "is this
|
|
317
|
+
likely a good result for the search?", and that answer, times W, was added to the photo's score.
|
|
318
|
+
|
|
319
|
+
**Measured on 16 of the weakest searches** (how a photo looks, counting and mood, small objects): all 640 photos
|
|
320
|
+
labelled by eye before Jev was asked, then the first 10 counted.
|
|
321
|
+
|
|
322
|
+
| How Jev was asked | AUC | Fits in the first 10s at W = 0 / 0.02 / 0.04 / 0.08 (of 160) |
|
|
323
|
+
|---|---|---|
|
|
324
|
+
| one photo per call | 0.71 | 121 / 129 / 128 / 133 |
|
|
325
|
+
| one photo per call, no image line | 0.70 | 121 / 128 / 129 / 130 |
|
|
326
|
+
| all 40 photos in one call | 0.75 | 121 / 132 / **136** / 135 |
|
|
327
|
+
| all 40 in one call, no image line | 0.72 | 121 / 127 / 133 / 136 |
|
|
328
|
+
|
|
329
|
+
Asking about all 40 photos in one call was better and much faster (0.1 to 0.2 s for a search, against 40 calls of
|
|
330
|
+
0.13 s). At 0.04 it helped most for "a black and white photo" (6 to 10), "a storm" (7 to 10), "a happy moment" and
|
|
331
|
+
"a ball" (7 to 9). It did nothing for "a close-up" or "one person alone", where the words can't tell skin from a face
|
|
332
|
+
or a lone person from an empty room, and "a vase" lost one.
|
|
333
|
+
|
|
334
|
+
**On 20 everyday searches** (a waterfall, a pool, a group photo), only the photos that move in or out of the first
|
|
335
|
+
10 were judged, blind. With all 40 in one call: W = 0.02 gained 6 fitting photos (5 searches better, 1 worse), 0.04
|
|
336
|
+
gained 9 (8 better, 2 worse), 0.08 gained 11 (8 better, 2 worse).
|
|
337
|
+
|
|
338
|
+
**What goes to TypeSafe:** the search and those descriptions, never the photo, names or places. An early run also
|
|
339
|
+
sent up to 12 words read from each photo (text.py). They're left out, since document photos hold IDs and forms, and
|
|
340
|
+
the numbers above are without them.
|
|
341
|
+
|
|
342
|
+
**Caveats:** one judge, and W was picked on the same searches it was measured on. 0.04 is the middle of a flat range.
|
|
343
|
+
|
|
344
|
+
**What's used** (`checks.py`): with ✨ smart search on, the first 40 photos are described, all in one call, at W =
|
|
345
|
+
0.04. It adds about 0.35 s to a search (0.25 s Jev, 0.1 s describing). Videos keep their place: they weren't measured.
|
|
346
|
+
|
|
347
|
+
**Measure again** with a new Jev model, or before using it for more than the first 40.
|
|
348
|
+
|
|
349
|
+
## Search, negation and the search box
|
|
350
|
+
|
|
351
|
+
- **Photos that match everything.** A CSLS-style penalty on every photo, using Apple's labels as a query bank, was
|
|
352
|
+
tried on ~5k photos and made results worse (it boosted documents and handwriting). A smaller penalty for only the
|
|
353
|
+
photos that crowd many first pages works; see Where search breaks down.
|
|
354
|
+
- **Negation.** The model is weak at "no selfies" inside a query. `exclude` subtracts 0.5× the excluded text's vector
|
|
355
|
+
from the query instead (as Photo Math does). On "group photos at a party" minus "a selfie" it removed the selfies;
|
|
356
|
+
for "no people" in landscapes the plain query already worked.
|
|
357
|
+
- **Laya** (an open "System One" decision model) for parsing the search box, on 20 labelled queries: 20/20 on "wants no
|
|
358
|
+
people" and "no selfies", but 12/20 photo-vs-video, 14/20 time period, 17/20 season, ~430 ms a query on CPU and
|
|
359
|
+
~1.5 GB. Not adopted: keyword rules beat it on the choice questions, and the **without…** field covers negation.
|
|
360
|
+
- **Jev** (TypeSafe's hosted System One model) on the same 20: 19/20 photo-vs-video, 20/20 time period, 18/20
|
|
361
|
+
season, ~115 ms. Its yes/no answers needed cut-offs of 0.7 ("no people") and 0.85 ("no selfies"), not 0.5; on 20
|
|
362
|
+
fresh queries with those it got 95/100. It powers the optional ✨ smart search. Choices under 60% confidence are
|
|
363
|
+
ignored.
|
|
364
|
+
|
|
365
|
+
## Face crops for portraits
|
|
366
|
+
|
|
367
|
+
Photos keeps each named face's centre and size. osxphotos turns the centre by the photo's orientation, and crops
|
|
368
|
+
made that way missed the face on most rotated photos (a gown, a shirt, grass). Checked against Apple's own face
|
|
369
|
+
detector on thumbnails, 40 photos with one face for each orientation: Photos' centre is already the way the photo is
|
|
370
|
+
shown, counted from the bottom, so only the height is flipped. That lands within 1 to 2% of the detected face for
|
|
371
|
+
every orientation (0, 1, 3, 6 and 8); turning it was 15 to 21% off. The crop is 2.4 times the face, so hair and chin
|
|
372
|
+
fit in a circle.
|
|
373
|
+
|
|
374
|
+
## Galaxy
|
|
375
|
+
|
|
376
|
+
UMAP with `min_dist=0.6`, so near-duplicates spread out instead of piling up, and k-means regions named by the Apple
|
|
377
|
+
labels most distinctive for each region.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mumit Khan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|