15 KiB
FotkyBuzalkovi — návrh systému a poznámky z analýzy
Pracovní dokument shrnující rozhodnutí a poznatky z prvotní analýzy projektu. Datum diskuze: 2026-05-21
1. Cíl projektu
- Organizovat a tagovat cca 200 000 rodinných fotografií
- Lokální nasazení (žádný cloud, žádní externí uživatelé)
- Současný stav: prázdný projekt s ukázkovými fotkami v
demo_fotky/
2. Architektonické rozhodnutí
Finální stack: jen PostgreSQL + filesystem
┌─────────────────────┐
│ Python aplikace │
└──────────┬──────────┘
│
┌──────┴──────┐
▼ ▼
┌─────────┐ ┌──────────┐
│PostgreSQL│ │Filesystem│
│ metadata │ │ .jpg/.png│
└─────────┘ └──────────┘
Co bylo zvažováno a zavrženo
| Technologie | Verdikt | Důvod |
|---|---|---|
| MongoDB | ❌ vynecháno | JSONB v PostgreSQL nahradí veškerou potřebnou funkcionalitu |
| Redis | ❌ vynecháno (zatím) | Pro lokální 200k fotek bez webových uživatelů zbytečný |
| MySQL | ❌ vynecháno | Projekt používá PostgreSQL (jiná databáze byla v původním plánu) |
Kdy přidat Redis (pozdější fáze)
- Web UI s rychlým vyhledáváním (cache výsledků)
- Paralelní workery pro import / generování thumbnailů
- Background fronty (zpracování AI tagů)
Kdy přidat něco dalšího
pgvectorextension pro PostgreSQL — až budeme chtít sémantické hledání (CLIP embeddings)- Elasticsearch — kdyby PostgreSQL fulltext nestačil (u 200k řádků nestane)
3. JSONB v PostgreSQL vs MongoDB BSON
Terminologie
- BSON = binární JSON od MongoDB (s typy jako
ObjectId,Date,Decimal128) - JSONB = binární JSON v PostgreSQL (vlastní formát, ne BSON, ale funkčně podobný)
Srovnání vyhledávání
MongoDB:
db.photos.find({ "exif.camera": "Canon EOS 5D" })
db.photos.find({ "exif.iso": { $gte: 800 } })
db.photos.find({ "tags": { $in: ["dovolená", "moře"] } })
PostgreSQL JSONB:
SELECT * FROM photos WHERE exif_data->>'camera' = 'Canon EOS 5D';
SELECT * FROM photos WHERE (exif_data->>'iso')::int >= 800;
SELECT * FROM photos WHERE exif_data @> '{"camera": "Canon EOS 5D"}';
Co umí stejně
| Funkce | MongoDB | PostgreSQL JSONB |
|---|---|---|
| Filtr podle pole v JSON | ano | ano (->>, @>) |
| Vnořené cesty | "a.b.c" |
data#>>'{a,b,c}' |
| Existence klíče | $exists |
? operátor |
| Index na JSON cestu | ano | ano (GIN) |
| Fulltext | ano | ano |
Výhody PostgreSQL JSONB pro náš případ
- JOINy s tabulkami tagů a kamer — v Mongo by to vyžadovalo
$lookup(pomalé) - Transakce napříč JSONB a relačními tabulkami
- Jeden backup nástroj (
pg_dump)
4. Identita fotky — 4 úrovně hashů
Klíčový problém: Když změníte byť jediný keyword v IPTC, SHA256 souboru se kompletně změní. Jak detekovat, že je to stále ta samá fotka?
┌─────────────────────────────────────────────────────────────┐
│ Stejná identita Stejný obsah │
│ ←─────────────────────────────────────────────────────→ │
│ │
│ SHA256 Pixel hash Perceptual Embedding │
│ souboru (jen pixely) hash (pHash) (CLIP/DINO) │
│ │
│ byte-by-byte metadata +recomprese +sémantika │
│ identické ignorováno +resize podobnost │
│ +crop │
└─────────────────────────────────────────────────────────────┘
1. SHA256 souboru (sha256_file)
- Detekuje přesnou kopii souboru
- Změna IPTC/EXIF, otočení, znovuuložení → jiný hash
2. Pixel hash (sha256_pixels)
- SHA256 dekódovaných pixelů (po aplikaci EXIF orientation)
- Stejná fotka po změně metadat → identický hash
- Toto je odpověď na otázku "jak detekovat, že je to ta samá fotka po změně keywords"
with Image.open(path) as img:
img = ImageOps.exif_transpose(img)
if img.mode != "RGB":
img = img.convert("RGB")
pixel_hash = hashlib.sha256(img.tobytes()).hexdigest()
3. Perceptual hash (pHash, dHash, wHash)
- 64-bit "otisk obsahu" — vizuálně podobné fotky → blízké hashe
- Porovnává se přes Hamming distance (
bin(h1 ^ h2).count("1")) - < 10 = velmi podobné, > 20 = odlišné
- Detekuje: recompresi, resize, drobné úpravy, watermark, crop
- Knihovna:
imagehash
4. Deep learning embedding (budoucnost)
- Vektor 512–1024 dimenzí z CLIP / DINOv2
- Sémantická podobnost ("dvě fotky stejné scény z různých úhlů")
- Uložení v PostgreSQL přes
pgvectorextension
Workflow při importu nové fotky
1. Spočítej sha256_file
↓
2. Najdi v DB stejný sha256_file?
ANO → identická kopie, přeskočit
NE → ↓
3. Spočítej sha256_pixels
↓
4. Najdi v DB stejný sha256_pixels?
ANO → stejná fotka, jen jiná metadata → updatuj, nepřidávej duplikát
NE → ↓
5. Spočítej phash, ulož do DB
↓
6. (Volitelně) najdi podobné: WHERE hamming(phash, ?) < 10
Poznámka k JPEG
- Před hashingem vždy aplikovat EXIF orientation (
ImageOps.exif_transpose) - Konvertovat do RGB pro konzistenci
- Jinak by se rotovaná fotka vs nerotovaná lišila
5. Tři vrstvy metadat ve fotce
| Standard | Co tam je | Kdo to vyplňuje |
|---|---|---|
| EXIF | Technická data (clona, ISO, čas, GPS, model kamery) | Kamera automaticky |
| IPTC | Popisná data (titulek, popis, klíčová slova, autor, copyright) | Člověk / software |
| XMP | Modernější nástupce IPTC od Adobe, často duplikuje + edits, ratings, regions | Software (Lightroom, Apple Photos) |
Pro náš případ
- iPhone vyplňuje hlavně EXIF a XMP (ne IPTC)
- Apple Photos do XMP ukládá rozpoznané obličeje (
mwg-rs:Regions) — i se jmény, pokud je pojmenuješ! - iOS screenshoty mají v XMP
description: "Screenshot"→ automatická detekce - Pokud budeme tagovat, je dobré tagy ukládat i do IPTC
Keywords— přežijí export do jiné aplikace
6. Výsledky exploration na 7 demo fotkách
Souhrn dat z explore_photos.py
| # | Soubor | Mpx | EXIF tagů | IPTC | XMP | GPS | Kamera |
|---|---|---|---|---|---|---|---|
| 1 | 2026-04-22 09.05.08.jpg | 24.47 | 121 | 0 | 4 | ano | iPhone 16 Pro Max |
| 2 | 2026-04-24 15.15.47.jpg | 12.19 | 107 | 0 | 0 | ne | iPhone 13 Pro Max |
| 3 | 2026-05-02 10.04.44.png | 3.57 | 12 | 0 | 1 | ne | Screenshot |
| 4 | 2026-05-18 06.22.37.jpg | 12.19 | 105 | 0 | 4 | ne | iPhone 13 Pro Max |
| 5 | 2026-05-18 06.22.41.jpg | 12.19 | 105 | 0 | 4 | ne | iPhone 13 Pro Max |
| 6 | 2026-05-18 13.54.47.jpg | 12.19 | 98 | 6 | 0 | ne | iPhone 13 Pro Max |
| 7 | 2026-05-18 14.10.59.jpg | 2.36 | 0 | 0 | 0 | ne | (sirotek) |
Klíčové objevy
-
Perceptual hash funguje — fotky [4] a [5] (pořízené 4 sekundy po sobě) mají Hamming distance pouze 4 = klasický burst snapshot.
-
Apple face detection v XMP — fotky 1, 4, 5 mají
face_regions_count: 1. iPhone už detekoval obličej a uložil to do XMP. Můžeme číst přímo, bez vlastní AI. -
Screenshot rozpoznán — foto [3] má v XMP
description: "Screenshot"→ automatická kategorizace. -
Foto [7] = sirotek — žádné metadata. Pravděpodobně přeposlané přes WhatsApp/Messenger, kde se EXIF maže. Pro 200k fotek bude tato kategorie významná.
-
GPS jen u 1/7 fotek — u většiny iPhone fotek máte vypnuté lokační služby.
-
Time zone — fotky mají
OffsetTime: +02:00. Datum pořízení ukládat jakoTIMESTAMPTZ(s timezone). -
ExifRead > Pillow — Pillow má GPS bug (
'int' object has no attribute 'items'). Primární parser bude ExifRead. -
MakerNote (Apple binary blob) — obsahuje burst ID, HDR příznak, focus distance. Pro rozluštění potřeba
exiftool(volat přespyexiftool).
7. Návrh databázového schématu (draft)
CREATE TABLE photos (
id BIGSERIAL PRIMARY KEY,
-- identita (3 úrovně)
sha256_file CHAR(64) UNIQUE NOT NULL, -- byte identita
sha256_pixels CHAR(64), -- pixel identita
phash BIGINT, -- vizuální podobnost
-- soubor
file_path VARCHAR(1000) NOT NULL,
file_name VARCHAR(255) NOT NULL,
file_size BIGINT,
mime_type VARCHAR(50),
format VARCHAR(20), -- JPEG, PNG, HEIC, ...
width INT,
height INT,
-- pořízení
taken_at TIMESTAMPTZ, -- s timezone (máme OffsetTime!)
taken_at_source VARCHAR(20), -- 'exif' / 'mtime' / 'iptc' / 'unknown'
-- technika (z EXIF)
camera_make VARCHAR(100),
camera_model VARCHAR(255),
lens_model VARCHAR(255),
iso INT,
aperture NUMERIC(4,2),
exposure_time VARCHAR(20), -- "1/500"
focal_length_mm NUMERIC(5,2),
-- GPS (NULL pokud chybí)
gps_lat NUMERIC(10,7),
gps_lon NUMERIC(10,7),
gps_altitude NUMERIC(7,2),
-- klasifikace
is_screenshot BOOLEAN DEFAULT FALSE,
face_count INT, -- z XMP, rozšířit AI
-- flexibilní JSONB pro celý dump
exif_raw JSONB,
iptc_raw JSONB,
xmp_raw JSONB,
-- import / zpracování
imported_at TIMESTAMPTZ DEFAULT NOW(),
processed_at TIMESTAMPTZ,
processing_status VARCHAR(50) DEFAULT 'pending'
);
CREATE INDEX idx_photos_sha256_pixels ON photos(sha256_pixels);
CREATE INDEX idx_photos_phash ON photos(phash);
CREATE INDEX idx_photos_taken_at ON photos(taken_at);
CREATE INDEX idx_photos_camera_model ON photos(camera_model);
CREATE INDEX idx_photos_exif_gin ON photos USING GIN (exif_raw);
CREATE TABLE tags (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
parent_tag_id INT REFERENCES tags(id), -- hierarchie "místo > Praha > Karlův most"
UNIQUE(name, parent_tag_id)
);
CREATE TABLE photo_tags (
photo_id BIGINT REFERENCES photos(id) ON DELETE CASCADE,
tag_id INT REFERENCES tags(id) ON DELETE CASCADE,
source VARCHAR(20), -- 'manual' / 'iptc' / 'xmp' / 'auto'
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (photo_id, tag_id)
);
8. Otevřené otázky pro příště
Foto bez EXIF (sirotek typu [7])
- a) Importovat (s
taken_atzmtime) - b) Odmítnout jako "nezpracovatelnou"
- c) Importovat, ale označit
processing_status = 'no_metadata'
Detekce duplikátů (shoda sha256_pixels)
- a) Přeskočit (nepřidávat duplicitu)
- b) Sloučit (aktualizovat metadata u existujícího záznamu)
- c) Uložit oba, jen označit jako související
Storage layout fotek
- a) Necháme v aktuálním umístění, do DB jen cestu
- b) Kopie do
archiv/YYYY/MM/původní_název.jpg - c) Kopie do
archiv/{sha[:2]}/{sha}.jpg(content-addressable = auto-dedup na FS)
9. Co dál — nápady k prozkoumání
exiftool— rozluští Apple MakerNote (burst ID by potvrdilo, že [4] a [5] patří k sobě)- Embedded thumbnail z EXIF — telefon ukládá malou náhledovku přímo v souboru → rychlejší galerie bez generování
- CLIP embeddings + pgvector — sémantické vyhledávání ("ukaž fotky pejsků na pláži")
- Reverse geocoding — z GPS souřadnic na čitelné místo (Nominatim / Photon, lokálně)
- Apple Photos jména osob — z XMP
mwg-rs:Regionsjdou číst i jména, pokud jsou v Photos pojmenovaná - Datum z názvu souboru — fallback když chybí EXIF i sensible
mtime(regex z "2026-05-18 13.54.47.jpg")
10. Aktuální stav projektu (k 2026-05-21)
Soubory v projektu
FotkyBuzalkovi/
├── demo_fotky/ # 7 ukázkových fotek
├── explore_photos.py # Explorační skript (hashe, EXIF, IPTC, XMP)
├── photo_exploration.json # Výstup exploreru
├── create_schema.py # ZASTARALÉ - obsahuje MySQL syntaxi a hardcoded hesla
├── test_db_connection.py # Test PG + Mongo + Redis (Mongo/Redis nebudou potřeba)
├── test_mongo.py # ZASTARALÉ - Mongo nepoužijeme
├── README.md
└── NAVRH.md # Tento dokument
Co bude potřeba udělat
- Smazat nebo přepsat
create_schema.py(MySQL syntaxeINDEXuvnitřCREATE TABLEv PG nefunguje) - Migrovat hesla do
.env+python-dotenv - Smazat / archivovat
test_mongo.py - Vytvořit migraci podle schématu v sekci 7
- Skript pro import fotek s deduplikací (workflow v sekci 4)
- Rozhodnout otevřené otázky ze sekce 8
Nainstalované Python balíčky
psycopg2-binary 2.9.12 # PostgreSQL driver
pymongo 4.17.0 # (nepotřebujeme)
redis 7.4.0 # (zatím nepotřebujeme)
pillow 12.2.0 # Základní práce s obrázky
ExifRead 3.5.1 # Primární EXIF parser (lepší než Pillow)
imagehash 4.3.2 # Perceptuální hashe (pHash, dHash, wHash)
+ numpy, scipy, PyWavelets (závislosti imagehash)
11. Užitečné odkazy
- Pillow — Python Imaging Library
- ExifRead — EXIF parser
- imagehash — perceptuální hashe
- exiftool — gold standard pro metadata (Perl, ale
pyexiftoolwrapper) - pgvector — vektory v PostgreSQL pro sémantické hledání
- PostgreSQL JSONB docs
- IPTC Photo Metadata Standard
- XMP Specification (Adobe)