# lenslab

lenslab reads the EXIF of your photo library and tells you what you actually shoot: which focal lengths you use, whether your shutter speeds risk blur, how high you push ISO for your sensor size, when you shoot relative to the sun, and which lens is gathering dust. It ends with plain-English gear advice such as "60% of zoom shots at 24-28mm, consider a 28mm prime", and writes a self-contained HTML report with SVG charts.

## Features

- Scanner for JPEG, TIFF-based RAW (CR2, NEF, ARW, DNG), Canon CR3 and HEIC (with the optional `pillow-heif`). A built-in TIFF IFD parser does the work, so there is no EXIF library dependency.
- Parallel scan with a SQLite cache keyed by path, mtime, size and XMP sidecar mtime. Re-runs on an unchanged library read nothing from the images.
- Fields: camera, lens, focal length, 35mm equivalent (EXIF value, else a crop-factor table, else sensor size from focal-plane resolution), aperture, shutter, ISO, exposure compensation, flash, date/time and UTC offset, GPS, orientation, rating (EXIF `Rating` tag, overridden by `.xmp` sidecars).
- Analytics: focal histogram and per-camera prime recommendations; aperture usage; reciprocal-rule shake flags with stabilisation allowance; ISO against per-sensor comfort limits; time-of-day, month and season patterns; golden/blue-hour share from computed sun position; keeper rate by camera, lens and focal band; lens/camera usage over time with dormant-lens detection; trip clustering by time gaps.
- Outputs: rich terminal summary, HTML report, CSV export (one row per photo), `compare` for cameras side by side.

## Install

```bash
uv venv .venv && uv pip install -p .venv/bin/python -e '.[dev]'   # dev adds pytest and piexif (sample generator)
# optional HEIC support: uv pip install -p .venv/bin/python -e '.[heic]'
```

## Quickstart

```bash
.venv/bin/python examples/generate_library.py            # 500 synthetic photos in examples/library
.venv/bin/lenslab summary examples/library
.venv/bin/lenslab report examples/library -o out/report.html
.venv/bin/lenslab export examples/library -o out/shots.csv
.venv/bin/lenslab compare examples/library --camera "6D" --camera "X-E4"
```

The generated library has two cameras (Canon EOS 6D Mark II full frame, Fujifilm X-E4 APS-C), four lenses (two zooms, two primes; the Fuji 35mm is deliberately abandoned after Sept 2025), six trips plus home shooting, GPS on most trip shots, ratings through XMP sidecars, and about 8% RAW-like TIFF files (`.cr2`, `.dng`). A pre-rendered report is in `examples/sample-report.html`.

## Usage

All commands take `LIBRARY` (a directory, scanned recursively, or a single file) and these options: `--cache FILE` (default `~/.cache/lenslab/cache.sqlite`), `--no-cache`, `--workers N`, `--stab-stops N` (default 3), `--fallback-location LAT,LON` (sun position for photos without GPS).

| Command | Purpose |
| --- | --- |
| `scan LIBRARY` | Fill the cache, list photos per camera |
| `summary LIBRARY [--gap-hours H] [--unused-days D]` | Terminal summary and advice |
| `report LIBRARY -o FILE [--title T]` | Self-contained HTML report |
| `export LIBRARY -o FILE` | CSV with one row per photo (adds shake flag and light phase) |
| `compare LIBRARY --camera A --camera B` | Side-by-side metrics; names are case-insensitive substrings |
| `sample DIR [--count N] [--seed S]` | Generate a synthetic library |

Real output of `lenslab summary examples/library`:

```
500 photos (500 from cache, 0 scanned, 0 unreadable)
───────────────── lenslab  500 photos, 2024-10-09 → 2026-08-31 ─────────────────
Cameras: Canon EOS 6D Mark II, Fujifilm X-E4
           Focal length (35mm equiv.)            
┏━━━━━━━━┳━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ mm     ┃ share ┃                              ┃
┡━━━━━━━━╇━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│ 24–28  │   28% │ ███████████████████          │
│ 28–35  │   17% │ ████████████                 │
│ 35–50  │    4% │ ███                          │
│ 50–70  │   40% │ ████████████████████████████ │
│ 70–100 │   10% │ ███████                      │
└────────┴───────┴──────────────────────────────┘
Aperture  median f/5.6, most used f/4, 8% at f/2 or wider, 12% at f/11+
Shake risk  15/481 handheld shots flagged (3%; 9% ignoring stabilisation)
  20250405_0301.jpg 52mm @ 1/4 (3.7 stops too slow)
  20241022_0045.jpg 50mm @ 1/4 (3.6 stops too slow)
  20250408_0346.jpg 50mm @ 1/4 (3.6 stops too slow)
                          ISO vs sensor size                           
┏━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━┳━━━━━━┳━━━━━━━┓
┃ Camera               ┃ Sensor     ┃ Comfort ┃ Median ┃ P90  ┃ Above ┃
┡━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━╇━━━━━━╇━━━━━━━┩
│ Canon EOS 6D Mark II │ full frame │ 6400    │ 400    │ 3200 │ 3%    │
│ Fujifilm X-E4        │ APS-C      │ 3200    │ 400    │ 3200 │ 8%    │
└──────────────────────┴────────────┴─────────┴────────┴──────┴───────┘
Light  golden hour 14%, blue hour 5%, night 17%, daylight 64% (349 located 
shots)
Keepers  146/294 rated shots are 4+ stars (50%); 59% of the library is rated
                             Lens usage                              
┏━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━┳━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━┓
┃ Lens                     ┃ Shots ┃ Share ┃ Last used  ┃ Status    ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━╇━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━┩
│ EF24-70mm f/4L IS USM    │ 225   │ 45%   │ 2026-08-31 │ active    │
│ XF18-55mmF2.8-4 R LM OIS │ 167   │ 33%   │ 2026-08-31 │ active    │
│ XF35mmF1.4 R             │ 55    │ 11%   │ 2025-08-29 │ idle 367d │
│ EF50mm f/1.8 STM         │ 53    │ 11%   │ 2026-08-31 │ active    │
└──────────────────────────┴───────┴───────┴────────────┴───────────┘
                   Trips (43 sessions, gap > 36h)                    
┏━━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━┳━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ From       ┃ To         ┃ Days ┃ Shots ┃ Top lens                 ┃
┡━━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━╇━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│ 2024-11-08 │ 2024-11-11 │ 4    │ 36    │ EF24-70mm f/4L IS USM    │
│ 2025-04-03 │ 2025-04-08 │ 6    │ 60    │ EF24-70mm f/4L IS USM    │
│ 2025-07-12 │ 2025-07-18 │ 7    │ 81    │ EF24-70mm f/4L IS USM    │
│ 2025-10-04 │ 2025-10-07 │ 4    │ 47    │ EF24-70mm f/4L IS USM    │
│ 2025-12-07 │ 2025-12-08 │ 2    │ 14    │ XF18-55mmF2.8-4 R LM OIS │
│ 2026-02-20 │ 2026-02-24 │ 5    │ 53    │ XF18-55mmF2.8-4 R LM OIS │
│ 2026-07-02 │ 2026-07-03 │ 2    │ 10    │ XF18-55mmF2.8-4 R LM OIS │
│ 2026-08-14 │ 2026-08-16 │ 3    │ 50    │ XF18-55mmF2.8-4 R LM OIS │
└────────────┴────────────┴──────┴───────┴──────────────────────────┘
──────────────────────────────────── Advice ────────────────────────────────────
• 60% of Canon EOS 6D Mark II zoom shots at 24–28mm (35mm equiv.) → consider a 
28mm prime
• 54% of Fujifilm X-E4 zoom shots at 27–30mm (35mm equiv.) → consider a 28mm 
prime (about 19mm on this camera)
• 3% of handheld shots may show camera shake after allowing 3 stops for 
stabilisation (9% by the plain 1/focal rule)
• 8% of Fujifilm X-E4 shots are above ISO 3200 (APS-C comfort limit)
• 14% of 349 located shots were taken in golden hour
• XF35mmF1.4 R has sat unused for 367 days (last used 2025-08-29)
• Keeper rate (rating >= 4): 50% of 294 rated shots
```

Real output of `lenslab compare examples/library --camera "6D" --camera "X-E4"` (first part):

```
500 photos (500 from cache, 0 scanned, 0 unreadable)
                              Camera comparison                               
┏━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                       ┃    Canon EOS 6D Mark II ┃            Fujifilm X-E4 ┃
┡━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│ Sensor                │      full frame (1.00x) │            APS-C (1.50x) │
│ Shots                 │                     278 │                      222 │
│ Active                │ 2024-10-09 → 2026-08-31 │  2024-10-09 → 2026-08-31 │
│ Lenses                │                       2 │                        2 │
│ Top lens              │   EF24-70mm f/4L IS USM │ XF18-55mmF2.8-4 R LM OIS │
│ Median focal (equiv.) │                    35mm │                     52mm │
│ Busiest band          │                 50–70mm │                  50–70mm │
│ Median aperture       │                   f/5.6 │                      f/4 │
│ Wide open (<= f/2)    │                      4% │                      14% │
│ Median ISO            │                     400 │                      400 │
│ ISO above comfort     │                      3% │                       8% │
│ Shake risk            │                      3% │                       3% │
│ Keeper rate           │                     56% │                      41% │
│ Golden-hour share     │                     12% │                      15% │
```

![report](docs/img/report.png)

## Configuration

There are no config files. Tunables are command-line flags (above). Environment: `XDG_CACHE_HOME` moves the default cache. Crop factors, sensor classes, ISO comfort limits and stabilisation patterns live in `lenslab/gear.py`; extend `CROP_TABLE` for cameras it does not know (unknown bodies are assumed full frame and `crop_source` says `assumed` in the CSV).

## Architecture

| Module | Role |
| --- | --- |
| `exif.py` | TIFF IFD parser, JPEG/TIFF/CR3/HEIC containers, flat field extraction |
| `gear.py` | Crop-factor table, sensor classes, ISO limits, stabilisation heuristics |
| `models.py` | `Shot` record and construction (35mm equivalent resolution) |
| `scanner.py` | File discovery, process pool, SQLite cache |
| `xmp.py` | Sidecar ratings |
| `sun.py` | Sun elevation (NOAA algorithm), light phases |
| `analytics.py` | All statistics, recommendations, trips, comparison, CSV rows |
| `report.py` | HTML and SVG rendering |
| `cli.py` | Click commands and rich terminal output |
| `samplegen.py` | Synthetic library generator |

Algorithms and worked examples are in [docs/algorithms.md](docs/algorithms.md).

## Tests

```bash
.venv/bin/pytest -q
```

## Limitations

- Brand-specific RAW formats not built on TIFF (RAF, ORF, RW2) are reported as unreadable. CR3 parsing is tested on synthetic boxes only.
- The crop-factor table covers common bodies, not all; the stabilisation check is name-based (lens "IS/OIS/VR/OSS/VC", known IBIS bodies).
- Shake flags are statistical hints: the rule ignores subject motion, technique and tripods (exposures of 1 s or longer and flash shots are skipped).
- Sun position ignores atmospheric refraction and terrain. Without an EXIF offset, the UTC offset is estimated from longitude, which can be an hour off near time-zone borders.
- Prime advice treats all zoom shots of a camera together and does not know lenses you own but never photographed with.
