Skip to content

Capture date and place, with a map picker - #905

Merged
marcinz606 merged 4 commits into
mainfrom
feat/metadata
Aug 19, 2026
Merged

Capture date and place, with a map picker#905
marcinz606 merged 4 commits into
mainfrom
feat/metadata

Conversation

@marcinz606

Copy link
Copy Markdown
Owner

Closes #899.

The Metadata panel authored the gear and the process, but not when or where the frame was shot. Worse, the export inherited the scan file's DateTimeOriginal, so a 1998 negative sorted in Lightroom by its 2026 scan date.

Capture date

A truncated ISO-8601 string, not a date widget: a film date is usually partial, and QDateTimeEdit would invent a day.

1998 · 1998-07 · 1998-07-14 · 1998-07-14 16:30 · with an optional +02:00. The string's length is the precision. An impossible date turns the field red and is not saved.

  • EXIF DateTimeOriginal pads the missing parts (plus OffsetTimeOriginal when given).
  • XMP photoshop:DateCreated keeps the truncated form; negpy:CaptureDatePrecision records which it was, so a round-trip knows 1998 was a year and not January 1st.
  • The scan's own timestamp moves to DateTimeDigitized — which is what it recorded — and to negpy:ScanDateTime.
  • Filename templates gain {{ capture_date }} and {{ capture_year }}; {{ date }} is still the export date.

Capture place

A WGS-84 position plus city, state and country, set in a modal picker.

  • slippy_map.py — a pan/zoom OpenStreetMap tile view: paintEvent plus urllib on a QThreadPool, disk tile cache under cache/map_tiles, identifying User-Agent, ODbL attribution painted, a missing tile paints flat and nothing blocks. No new dependency; QtWebEngine would have put a browser in the PyInstaller bundle for one dialog.
  • location_picker_dialog.py — Nominatim search, reverse lookup on each pin drop, editable city/state/country (the geocoder proposes, the user decides), and a coordinate field that takes a pasted pair or an OSM/Google Maps link. Every path except the tiles themselves works offline.
  • EXIF GPS IFD and XMP exif:GPS* for the position, photoshop:City/State/Country for the names.

Closing the picker used to hang the app. A QThreadPool owned by a widget joins its queue from the C++ destructor with the GIL held, which a Python QRunnable needs to return — a permanent freeze with nothing in the log. Fixed by joining explicitly (waitForDone releases the GIL), a stopped flag checked at run() entry, and a cap on pending tiles. Measured: a stubbed 3 s tile fetch turned a hang into a 3.00 s close; against live OSM, 0.06 s.

Source GPS

  • Setting a place now clears the source GPS block whole, so a picked position cannot ship with the scan's altitude, heading or timestamp beside it.
  • A geotagged source with no place set keeps its own coordinates on export, unchanged.
  • That position is read back: it shows in the preview as Scan place and frames the picker's opening view, but is never adopted as the capture place — where a frame was digitized is not where it was shot.

Search

shot: and place: join the existing facets, in the one parser both the filter box and the library search use. shot: is ordered by the existing prefix comparison, so shot:>=1998-07 and shot:<2000 need no date parsing, and a year-only date deliberately does not match a month bound. place:tokyo and place:japan hit the same frame.

Fixed in passing

_exif_bytes_to_extratags iterated ("0th", "Exif", "GPS") and emitted GPS tags as top-level TIFF tags 1–4, which are not TIFF tags. Already wrong for any geotagged source; writing our own GPS would have made it routine. TIFF now carries the location in XMP, which is what DAMs read anyway.

Verification

make all — 4481 passed, lint, format and ty clean.

New tests cover the date table (accept, reject, pad, truncate), coordinate and map-link parsing, GPS DMS round-trip through both writer and reader, tile math, the writer's date demotion and GPS strip (and byte-identical behaviour when the date is unset), the picker and map widget offscreen with every request patched, the sidebar's Capture card, the config JSON round-trip, and the two search facets.

Live-checked against OpenStreetMap: tiles load, Nominatim returns hits. Not run: a manual GUI pass, and the exiftool read-back — exiftool is not installed here, so the tags are asserted with piexif instead.

The Metadata panel authored the gear and the process but not when or where
the frame was shot, and the export inherited the scan file's
DateTimeOriginal, so a 1998 negative sorted by its 2026 scan date.

A Capture card adds both. The date is a truncated ISO-8601 string, because
a film date is usually partial: 1998, 1998-07, a full date, or a date and
time with an optional offset. EXIF pads the parts the user left out, XMP
photoshop:DateCreated keeps the truncated form, and
negpy:CaptureDatePrecision says which it was. The scan's own timestamp
moves to DateTimeDigitized, which is what it recorded.

The place is a WGS-84 position plus city, state and country, picked in a
map dialog: a hand-rolled OpenStreetMap tile widget (paintEvent plus
urllib, no new dependency and no browser in the bundle), Nominatim search
and reverse lookup, and a field that also takes a pasted coordinate pair
or map link. Everything works with no network except the map itself.
Coordinates go to the EXIF GPS IFD and XMP exif:GPS*, the names to
photoshop:City/State/Country.

Setting a place now clears the source GPS block whole, so a picked
position cannot ship with the scan's altitude or heading beside it. The
source position is read back too: it shows in the preview as Scan place
and frames the picker's opening view, but is never adopted as the capture
place, because where a frame was digitized is not where it was shot.

Both facts are searchable as shot: and place:. shot: is ordered by the
existing prefix comparison, so shot:>=1998-07 needs no date parsing, and
a year-only date deliberately does not match a month bound.

Dropping GPS from the TIFF extratags loop fixes a latent bug: a source
GPS IFD was written as top-level TIFF tags 1-4, which are not TIFF tags.
TIFF now carries the location in XMP, which is what DAMs read.

Closes #899
The search hits filled a list under the field, which took layout space from
the map and needed a button press to appear.

They are now a QCompleter dropdown on the search field, filled from the
geocoder and shown unfiltered, because filtering the server's hits again
locally would hide "Tokio" for "tokyo". A 500 ms timer holds the request
until typing pauses, and a query under three characters never searches,
which keeps the app inside Nominatim's one-request-a-second policy. Enter
still searches at once.

The lookups also ask for the user's language now: without it Nominatim
answers in the local script, and that name goes into XMP.
@marcinz606
marcinz606 marked this pull request as ready for review August 19, 2026 15:59
It applies to every field in the panel, not to scanning, and it was folded
away in a collapsed card. It now sits under Protect original metadata,
where the other panel-wide switch is, and greys out with the rest when
Protect is on, which makes the panel's fields moot anyway.

The sync path itself needed nothing: all three call sites hand over the
active frame's MetadataConfig whole, so the capture date and place ride
along with the gear. A test pins that, since a future narrowing to a field
list would silently drop whatever was added last.
"Map…" and "Clear" took most of the Place row, leaving the field it
belongs to too narrow to read a coordinate pair. Both are _icon_action
buttons now, the sidebar's helper for one-shot icon buttons, with the
label moved into the tooltip.
@marcinz606
marcinz606 merged commit a1652a8 into main Aug 19, 2026
1 check was pending
@marcinz606
marcinz606 deleted the feat/metadata branch August 19, 2026 17:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] Richer metadata with original capture time & location

1 participant