Dates: exiftool GPS hemisphere refs ignored ("West" != "W") — western photos resolve to UTC+8 #163

Closed
opened 2026-10-11 02:55:05 +00:00 by cameron · 0 comments
Owner

Problem

The exiftool branch of the date resolver reads the GPS hemisphere refs with an exact one-letter match (src/date_resolver.rs:843-847):

"GPSLatitudeRef"  => data.evidence.gps_lat_south = v.trim().eq_ignore_ascii_case("S"),
"GPSLongitudeRef" => data.evidence.gps_lon_west  = v.trim().eq_ignore_ascii_case("W"),

With print conversion on (as it must be, since parse_gps_dms parses "74 deg 0' 21.60\""), exiftool renders the refs as "West" / "South", not "W" / "S". So the flags are never set, every western longitude and southern latitude comes out positive, and the camera-offset cascade's GPS step looks up the mirrored point. Northeast Ohio (41.4, −81.8) becomes (41.4, +81.8) in China, and the row gets UTC+8.

The existing test parse_payload_groups_dates_and_evidence feeds "GPS:GPSLongitudeRef": "W", which isn't what exiftool prints, so it can't catch this. The kamadak-exif path (exif.rs::extract_gps_coordinate) checks starts_with('S' | 'W') and is correct; only the exiftool path is wrong. That's also why the stored gps_latitude / gps_longitude columns are correctly signed: only the offset evidence is affected.

Evidence

exiftool output (12.76, ImageApi's flags -j -q -G1, on a JPEG written with -GPSLongitudeRef=W -GPSLatitudeRef=N):

"GPS:GPSLongitudeRef": "West",
"GPS:GPSLatitudeRef": "North",
"GPS:GPSLongitude": "81 deg 50' 34.80\""

Production (2026-10-10): all 280 rows with date_taken_tz_source = 'gps' and a negative gps_longitude have camera_offset_minutes = 480 (NSFW 62, archive 78, Phone Backup 140). The one eastern-hemisphere gps row is also 480 and may be genuine.

Against the location track: for GPS-tagged photos, the track position at the photo's true_instant should sit near its EXIF GPS. For these rows it's a median 1,020 m away, versus 38 m when the same wall clock is read in America/New_York; the old reading is closer for 114 of 165 comparable rows. Other sources agree with the track (assumed_server: 5 m vs 6 m). The cameras are phones that follow local time (LG VS980, iPhone 6, OnePlus A6013), so this isn't a camera left on home time.

Impact

These rows' date_utc is 12–13 h off, so photo↔SMS / photo↔location matching and day grouping are wrong for them. Apollo #37 (matching on true_instant) is blocked on this: today's Apollo ignores the offset and pins them correctly by accident; after the switch they'd move 12–13 h.

Fix

  • Hemisphere check on the first letter, case-insensitive, accepting both "W" and "West" (same rule as exif.rs).
  • Test with real exiftool strings ("West" / "South"), including a southern-hemisphere case.

Re-resolving existing rows

The drain only picks date_utc IS NULL rows. Its cursor wraps to 0 at the end of the table, which happens every tick once only floor rows remain. So after deploying the fix:

UPDATE image_exif
SET date_utc = NULL, camera_offset_minutes = NULL, date_taken_tz_source = NULL
WHERE date_taken_tz_source = 'gps';

All gps rows went through the same cascade step, so resetting all of them is the simplest safe set. The next ticks re-resolve them with the fixed parser. Verify: gps rows with negative longitude no longer carry +480, and the track-vs-EXIF-GPS check above drops to the same tens of metres as the other sources.

Related: #113 / ADR-0001, #120, Apollo #37.

🤖 Generated with Claude Code

## Problem The exiftool branch of the date resolver reads the GPS hemisphere refs with an exact one-letter match (`src/date_resolver.rs:843-847`): ```rust "GPSLatitudeRef" => data.evidence.gps_lat_south = v.trim().eq_ignore_ascii_case("S"), "GPSLongitudeRef" => data.evidence.gps_lon_west = v.trim().eq_ignore_ascii_case("W"), ``` With print conversion on (as it must be, since `parse_gps_dms` parses `"74 deg 0' 21.60\""`), exiftool renders the refs as **`"West"` / `"South"`**, not `"W"` / `"S"`. So the flags are never set, every western longitude and southern latitude comes out positive, and the camera-offset cascade's GPS step looks up the mirrored point. Northeast Ohio (41.4, −81.8) becomes (41.4, +81.8) in China, and the row gets **UTC+8**. The existing test `parse_payload_groups_dates_and_evidence` feeds `"GPS:GPSLongitudeRef": "W"`, which isn't what exiftool prints, so it can't catch this. The kamadak-exif path (`exif.rs::extract_gps_coordinate`) checks `starts_with('S' | 'W')` and is correct; only the exiftool path is wrong. That's also why the stored `gps_latitude` / `gps_longitude` columns are correctly signed: only the offset evidence is affected. ## Evidence **exiftool output** (12.76, ImageApi's flags `-j -q -G1`, on a JPEG written with `-GPSLongitudeRef=W -GPSLatitudeRef=N`): ```json "GPS:GPSLongitudeRef": "West", "GPS:GPSLatitudeRef": "North", "GPS:GPSLongitude": "81 deg 50' 34.80\"" ``` **Production (2026-10-10):** all 280 rows with `date_taken_tz_source = 'gps'` and a negative `gps_longitude` have `camera_offset_minutes = 480` (NSFW 62, archive 78, Phone Backup 140). The one eastern-hemisphere `gps` row is also 480 and may be genuine. **Against the location track:** for GPS-tagged photos, the track position at the photo's `true_instant` should sit near its EXIF GPS. For these rows it's a median **1,020 m** away, versus **38 m** when the same wall clock is read in America/New_York; the old reading is closer for 114 of 165 comparable rows. Other sources agree with the track (`assumed_server`: 5 m vs 6 m). The cameras are phones that follow local time (LG VS980, iPhone 6, OnePlus A6013), so this isn't a camera left on home time. ## Impact These rows' `date_utc` is 12–13 h off, so photo↔SMS / photo↔location matching and day grouping are wrong for them. Apollo #37 (matching on `true_instant`) is blocked on this: today's Apollo ignores the offset and pins them correctly by accident; after the switch they'd move 12–13 h. ## Fix - Hemisphere check on the first letter, case-insensitive, accepting both `"W"` and `"West"` (same rule as `exif.rs`). - Test with real exiftool strings (`"West"` / `"South"`), including a southern-hemisphere case. ## Re-resolving existing rows The drain only picks `date_utc IS NULL` rows. Its cursor wraps to 0 at the end of the table, which happens every tick once only floor rows remain. So after deploying the fix: ```sql UPDATE image_exif SET date_utc = NULL, camera_offset_minutes = NULL, date_taken_tz_source = NULL WHERE date_taken_tz_source = 'gps'; ``` All `gps` rows went through the same cascade step, so resetting all of them is the simplest safe set. The next ticks re-resolve them with the fixed parser. Verify: `gps` rows with negative longitude no longer carry +480, and the track-vs-EXIF-GPS check above drops to the same tens of metres as the other sources. Related: #113 / ADR-0001, #120, Apollo #37. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
cameron added the needs-triage label 2026-10-11 02:55:05 +00:00
Sign in to join this conversation.