From b6aba06baa67f3b5fc843091f8ecc34cca967013 Mon Sep 17 00:00:00 2001 From: administrator Date: Thu, 21 May 2026 07:11:54 +0200 Subject: [PATCH] notebookVb --- NAVRH.md | 377 +++++++++++++++++ README.md | 153 +++++++ explore_photos.py | 398 ++++++++++++++++++ photo_exploration.json | 906 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 1834 insertions(+) create mode 100644 NAVRH.md create mode 100644 README.md create mode 100644 explore_photos.py create mode 100644 photo_exploration.json diff --git a/NAVRH.md b/NAVRH.md new file mode 100644 index 0000000..0ab2e8a --- /dev/null +++ b/NAVRH.md @@ -0,0 +1,377 @@ +# 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 + +- **`pgvector`** extension 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:** +```javascript +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:** +```sql +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 + +1. **JOINy** s tabulkami tagů a kamer — v Mongo by to vyžadovalo `$lookup` (pomalé) +2. **Transakce** napříč JSONB a relačními tabulkami +3. **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" + +```python +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`](https://pypi.org/project/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 `pgvector` extension + +### 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 + +1. **Perceptual hash funguje** — fotky [4] a [5] (pořízené 4 sekundy po sobě) mají Hamming distance pouze **4** = klasický burst snapshot. + +2. **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. + +3. **Screenshot rozpoznán** — foto [3] má v XMP `description: "Screenshot"` → automatická kategorizace. + +4. **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á.** + +5. **GPS jen u 1/7 fotek** — u většiny iPhone fotek máte vypnuté lokační služby. + +6. **Time zone** — fotky mají `OffsetTime: +02:00`. **Datum pořízení ukládat jako `TIMESTAMPTZ`** (s timezone). + +7. **ExifRead > Pillow** — Pillow má GPS bug (`'int' object has no attribute 'items'`). **Primární parser bude ExifRead.** + +8. **MakerNote (Apple binary blob)** — obsahuje burst ID, HDR příznak, focus distance. Pro rozluštění potřeba [`exiftool`](https://exiftool.org/) (volat přes `pyexiftool`). + +--- + +## 7. Návrh databázového schématu (draft) + +```sql +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_at` z `mtime`) +- **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:Regions` jdou čí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 syntaxe `INDEX` uvnitř `CREATE TABLE` v 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](https://pillow.readthedocs.io/) — Python Imaging Library +- [ExifRead](https://github.com/ianare/exif-py) — EXIF parser +- [imagehash](https://github.com/JohannesBuchner/imagehash) — perceptuální hashe +- [exiftool](https://exiftool.org/) — gold standard pro metadata (Perl, ale `pyexiftool` wrapper) +- [pgvector](https://github.com/pgvector/pgvector) — vektory v PostgreSQL pro sémantické hledání +- [PostgreSQL JSONB docs](https://www.postgresql.org/docs/current/datatype-json.html) +- [IPTC Photo Metadata Standard](https://www.iptc.org/std/photometadata/specification/IPTC-PhotoMetadata) +- [XMP Specification (Adobe)](https://www.adobe.com/devnet/xmp.html) diff --git a/README.md b/README.md new file mode 100644 index 0000000..fd62a16 --- /dev/null +++ b/README.md @@ -0,0 +1,153 @@ +# FotkyBuzalkovi + +Systém pro zpracování, ukládání a vyhledávání rodinných fotografií s automatickou extrakcí EXIF metadat. + +## Architektura + +Projekt používá kombinaci tří databází, kde každá řeší specifickou úlohu: + +``` +┌─────────────────────────────────────────────────────────┐ +│ Python aplikace │ +│ (zpracování fotek, EXIF, hash) │ +└────────────┬──────────────┬──────────────┬───────────────┘ + │ │ │ + ▼ ▼ ▼ + ┌────────────┐ ┌────────────┐ ┌────────────┐ + │ PostgreSQL │ │ MongoDB │ │ Redis │ + │ (relační) │ │ (EXIF/doc) │ │ (cache) │ + └────────────┘ └────────────┘ └────────────┘ + 192.168.1.76 192.168.1.76 localhost + :5432 :27017 :6379 +``` + +### Proč tři databáze? + +| Databáze | Role | Co ukládá | +|----------|------|-----------| +| **PostgreSQL** | Strukturovaná data, relace | `photos`, `cameras`, `photo_tags` - ID, cesty, hashe, FK | +| **MongoDB** | Flexibilní dokumenty | Plná EXIF metadata (různé fotoaparáty = různá pole) | +| **Redis** | Cache + fronty | Miniatury, výsledky vyhledávání, fronta zpracování | + +## Datové úložiště + +### PostgreSQL - `fotky_buzalkovi` + +- **cameras** - seznam fotoaparátů (model, vyrobce) +- **photos** - hlavní tabulka (file_name, file_path, file_hash, taken_at, rozměry, FK na camera) +- **photo_tags** - tagy ke každé fotce (many-to-many) + +### MongoDB - `fotky_buzalkovi` + +- **photos** kolekce - kompletní EXIF data, GPS souřadnice, nastavení clony, ISO, atd. + +### Redis (plánováno) + +- **cache:thumb:{photo_id}** - cached miniatury (TTL 1h) +- **queue:process** - fronta nezpracovaných fotek +- **session:{user_id}** - session data + +## K čemu Redis + +1. **Cache miniatur** - generování miniatur je drahé, Redis je drží v RAM (rychlost ~0.1ms vs ~50ms z disku) +2. **Cache vyhledávání** - "fotky z dovolené 2025" se může opakovat, výsledek se cachuje +3. **Fronta zpracování** - když nahrajete 1000 fotek, Redis funguje jako worker queue +4. **Deduplikace** - rychlá kontrola, zda hash fotky už existuje +5. **Rate limiting** - omezení uploadů + +## Instalace + +### 1. Python prostředí + +```powershell +python -m venv .venv +.venv\Scripts\Activate.ps1 +pip install psycopg2-binary pymongo redis pillow exifread python-dotenv +``` + +### 2. PostgreSQL + +Předpoklad: PostgreSQL běží na `192.168.1.76:5432`. + +```powershell +python create_schema.py +``` + +### 3. MongoDB + +Předpoklad: MongoDB běží na `192.168.1.76:27017`. + +```powershell +python test_mongo.py +``` + +### 4. Redis (Windows) + +Redis oficiálně Windows nepodporuje. Tři možnosti: + +**Možnost A - WSL2 (doporučeno):** +```powershell +wsl --install +# v WSL: +sudo apt update +sudo apt install redis-server +sudo service redis-server start +``` + +**Možnost B - Docker:** +```powershell +docker run -d --name redis -p 6379:6379 redis:latest +``` + +**Možnost C - Memurai (Windows-native Redis-kompatibilní):** +- Stáhnout z https://www.memurai.com/ + +Test: +```powershell +python test_db_connection.py +``` + +## Konfigurace + +**⚠️ Hesla v současných skriptech jsou v plain textu - před nasazením přesunout do `.env`:** + +```env +PG_HOST=192.168.1.76 +PG_PORT=5432 +PG_USER=vladimir.buzalka +PG_PASSWORD=... +PG_DB=fotky_buzalkovi + +MONGO_URI=mongodb://192.168.1.76:27017/ +MONGO_DB=fotky_buzalkovi + +REDIS_HOST=localhost +REDIS_PORT=6379 +``` + +## Struktura projektu + +``` +FotkyBuzalkovi/ +├── demo_fotky/ # Testovací fotografie +├── create_schema.py # Vytvoření PostgreSQL schématu +├── test_db_connection.py # Test všech tří databází +├── test_mongo.py # Test MongoDB + vytvoření kolekcí +├── .gitignore +└── README.md +``` + +## Známé problémy + +- `create_schema.py` používá MySQL syntaxi `INDEX idx_x` uvnitř `CREATE TABLE` - v PostgreSQL je potřeba `CREATE INDEX` zvlášť po `CREATE TABLE` +- Hesla jsou hardcodovaná v Python souborech - migrovat do `.env` + `python-dotenv` + +## Roadmap + +- [ ] Opravit PostgreSQL schéma (INDEX syntaxe) +- [ ] Migrace hesel do `.env` +- [ ] Instalace Redis +- [ ] Skript pro hromadný import fotek z `demo_fotky/` +- [ ] EXIF parser (pillow + exifread) +- [ ] Generování miniatur s Redis cache +- [ ] Web UI pro prohlížení galerie diff --git a/explore_photos.py b/explore_photos.py new file mode 100644 index 0000000..c10ee6d --- /dev/null +++ b/explore_photos.py @@ -0,0 +1,398 @@ +""" +Explorační skript: projde všechny fotky v demo_fotky/ a vytáhne maximum dat. +Výstup do konzole + JSON soubor pro detailní analýzu. +""" +import hashlib +import json +import os +import re +import sys +from datetime import datetime +from pathlib import Path + +# Windows konzole - vynutit UTF-8 +if sys.stdout.encoding.lower() != "utf-8": + sys.stdout.reconfigure(encoding="utf-8") + sys.stderr.reconfigure(encoding="utf-8") + +import exifread +import imagehash +from PIL import Image, ImageOps, IptcImagePlugin +from PIL.ExifTags import TAGS, GPSTAGS + +PHOTOS_DIR = Path(__file__).parent / "demo_fotky" +OUTPUT_JSON = Path(__file__).parent / "photo_exploration.json" + + +def file_hash_sha256(path: Path, chunk_size: int = 65536) -> str: + """Hash celého souboru - detekce přesné kopie.""" + h = hashlib.sha256() + with open(path, "rb") as f: + while chunk := f.read(chunk_size): + h.update(chunk) + return h.hexdigest() + + +def pixel_hash_sha256(path: Path) -> str | None: + """Hash dekódovaných pixelů - identita fotky nezávisle na metadatech. + Aplikuje EXIF orientation pro konzistenci.""" + try: + with Image.open(path) as img: + img = ImageOps.exif_transpose(img) + if img.mode != "RGB": + img = img.convert("RGB") + return hashlib.sha256(img.tobytes()).hexdigest() + except Exception as e: + return None + + +def perceptual_hashes(path: Path) -> dict: + """Perceptuální hashe - detekce vizuálně podobných fotek. + Každý hash je 64-bit, porovnává se Hamming distance.""" + out = {} + try: + with Image.open(path) as img: + img = ImageOps.exif_transpose(img) + out["phash"] = str(imagehash.phash(img)) + out["dhash"] = str(imagehash.dhash(img)) + out["ahash"] = str(imagehash.average_hash(img)) + out["whash"] = str(imagehash.whash(img)) + except Exception as e: + out["_error"] = str(e) + return out + + +def iptc_info(path: Path) -> dict: + """IPTC metadata - keywords, title, description, author atd.""" + out = {} + # Mapování IPTC numerických tagů na čitelné názvy + iptc_names = { + (2, 5): "ObjectName", # Title + (2, 10): "Urgency", + (2, 15): "Category", + (2, 20): "SupplementalCategories", + (2, 25): "Keywords", + (2, 40): "SpecialInstructions", + (2, 55): "DateCreated", + (2, 60): "TimeCreated", + (2, 80): "Byline", # Creator/Author + (2, 85): "BylineTitle", + (2, 90): "City", + (2, 92): "SubLocation", + (2, 95): "ProvinceState", + (2, 100): "CountryCode", + (2, 101): "CountryName", + (2, 103): "OriginalTransmissionReference", + (2, 105): "Headline", + (2, 110): "Credit", + (2, 115): "Source", + (2, 116): "Copyright", + (2, 118): "Contact", + (2, 120): "Caption", # Description + (2, 122): "WriterEditor", + } + try: + with Image.open(path) as img: + raw = IptcImagePlugin.getiptcinfo(img) + if not raw: + return {} + for key, value in raw.items(): + name = iptc_names.get(key, f"IPTC{key}") + if isinstance(value, bytes): + value = value.decode("utf-8", errors="replace") + elif isinstance(value, list): + value = [v.decode("utf-8", errors="replace") if isinstance(v, bytes) else v for v in value] + out[name] = value + except Exception as e: + out["_error"] = str(e) + return out + + +def xmp_info(path: Path) -> dict: + """XMP metadata - moderní alternativa IPTC, často keywords/rating/regions.""" + out = {} + try: + with Image.open(path) as img: + xmp_raw = img.info.get("xmp") + if not xmp_raw: + return {} + if isinstance(xmp_raw, bytes): + xmp_raw = xmp_raw.decode("utf-8", errors="replace") + + # Velmi jednoduchý parser - vytáhne nejčastější pole regexem + patterns = { + "creator_tool": r'xmp:CreatorTool="([^"]+)"', + "create_date": r'xmp:CreateDate="([^"]+)"', + "modify_date": r'xmp:ModifyDate="([^"]+)"', + "rating": r'xmp:Rating="([^"]+)"', + "label": r'xmp:Label="([^"]+)"', + "title": r']*>.*?]*>([^<]+)', + "description": r']*>.*?]*>([^<]+)', + "creator": r']*>.*?]*>([^<]+)', + "subject_keywords": r']*>(.*?)', + } + for name, pat in patterns.items(): + m = re.search(pat, xmp_raw, re.DOTALL) + if m: + out[name] = m.group(1).strip() + + # Keywords z dc:subject - vytáhnout jednotlivé rdf:li + if "subject_keywords" in out: + kws = re.findall(r']*>([^<]+)', out["subject_keywords"]) + out["subject_keywords"] = kws + + # Apple regions (rozpoznané obličeje s pozicí) + face_count = len(re.findall(r'mwg-rs:Type="Face"', xmp_raw)) + if face_count: + out["face_regions_count"] = face_count + + # Délka raw XMP pro představu + out["_xmp_length_bytes"] = len(xmp_raw) + except Exception as e: + out["_error"] = str(e) + return out + + +def filesystem_info(path: Path) -> dict: + stat = path.stat() + return { + "file_name": path.name, + "file_path": str(path), + "file_size_bytes": stat.st_size, + "file_size_mb": round(stat.st_size / 1024 / 1024, 2), + "mtime": datetime.fromtimestamp(stat.st_mtime).isoformat(), + "ctime": datetime.fromtimestamp(stat.st_ctime).isoformat(), + "extension": path.suffix.lower(), + } + + +def pillow_info(path: Path) -> dict: + info = {} + try: + with Image.open(path) as img: + info["format"] = img.format + info["mode"] = img.mode + info["width"] = img.width + info["height"] = img.height + info["megapixels"] = round((img.width * img.height) / 1_000_000, 2) + info["has_transparency"] = img.mode in ("RGBA", "LA") or "transparency" in img.info + info["dpi"] = img.info.get("dpi") + info["icc_profile_present"] = "icc_profile" in img.info + info["exif_present"] = bool(img.getexif()) + + # XMP (často v JPG od Adobe) + if "xmp" in img.info: + xmp_raw = img.info["xmp"] + if isinstance(xmp_raw, bytes): + xmp_raw = xmp_raw[:500].decode("utf-8", errors="ignore") + info["xmp_snippet"] = str(xmp_raw)[:500] + + # Thumbnail embedded? + info["has_embedded_thumbnail"] = "thumbnail" in img.info + except Exception as e: + info["error"] = str(e) + return info + + +def pillow_exif(path: Path) -> dict: + """Pillow EXIF — čitelné názvy.""" + out = {} + try: + with Image.open(path) as img: + exif = img.getexif() + if not exif: + return {} + for tag_id, value in exif.items(): + tag = TAGS.get(tag_id, f"Tag{tag_id}") + # GPS info jako vnořený dict + if tag == "GPSInfo": + gps = {} + for gps_tag_id, gps_value in value.items(): + gps_tag = GPSTAGS.get(gps_tag_id, f"GPSTag{gps_tag_id}") + gps[gps_tag] = _serializable(gps_value) + out[tag] = gps + else: + out[tag] = _serializable(value) + except Exception as e: + out["_error"] = str(e) + return out + + +def exifread_tags(path: Path) -> dict: + """ExifRead — často víc tagů než Pillow, mj. detailní MakerNote.""" + out = {} + try: + with open(path, "rb") as f: + tags = exifread.process_file(f, details=True) + for k, v in tags.items(): + # přeskočit binární thumbnail + if "Thumbnail" in k and "JPEGInterchangeFormat" not in k: + continue + out[k] = str(v) + except Exception as e: + out["_error"] = str(e) + return out + + +def _serializable(v): + """Pillow vrací občas IFDRational, bytes apod. → převést na JSON-friendly.""" + if isinstance(v, bytes): + return v[:200].decode("utf-8", errors="replace") + if isinstance(v, (tuple, list)): + return [_serializable(x) for x in v] + if isinstance(v, dict): + return {str(k): _serializable(val) for k, val in v.items()} + if hasattr(v, "numerator") and hasattr(v, "denominator"): + try: + return float(v) + except Exception: + return str(v) + try: + json.dumps(v) + return v + except (TypeError, ValueError): + return str(v) + + +def explore_photo(path: Path) -> dict: + return { + "filesystem": filesystem_info(path), + "hashes": { + "sha256_file": file_hash_sha256(path), + "sha256_pixels": pixel_hash_sha256(path), + **perceptual_hashes(path), + }, + "pillow": pillow_info(path), + "exif_pillow": pillow_exif(path), + "exif_exifread": exifread_tags(path), + "iptc": iptc_info(path), + "xmp": xmp_info(path), + } + + +def hamming_distance(h1: str, h2: str) -> int: + """Hamming distance mezi dvěma hex perceptual hashes.""" + return bin(int(h1, 16) ^ int(h2, 16)).count("1") + + +def print_summary(photos: list[dict]) -> None: + print(f"\n{'=' * 70}") + print(f"PŘEHLED: {len(photos)} fotek") + print(f"{'=' * 70}\n") + + # Které EXIF tagy existují napříč fotkami? + all_pillow_keys = set() + all_exifread_keys = set() + for p in photos: + all_pillow_keys.update(p["exif_pillow"].keys()) + all_exifread_keys.update(p["exif_exifread"].keys()) + + print(f"Unikátní EXIF tagy (Pillow): {len(all_pillow_keys)}") + print(f"Unikátní EXIF tagy (ExifRead): {len(all_exifread_keys)}") + print() + + for i, p in enumerate(photos, 1): + fs = p["filesystem"] + pi = p["pillow"] + h = p["hashes"] + er = p["exif_exifread"] + print(f"[{i}] {fs['file_name']}") + print(f" Velikost: {fs['file_size_mb']} MB ({pi.get('width')}x{pi.get('height')}, {pi.get('megapixels')} Mpx)") + print(f" Formát: {pi.get('format')} / mode={pi.get('mode')}") + print(f" sha256_file: {h['sha256_file'][:16]}...") + print(f" sha256_pixels: {(h.get('sha256_pixels') or 'N/A')[:16]}...") + print(f" phash: {h.get('phash')} (perceptual)") + print(f" EXIF tagů: ExifRead={len(er)}, Pillow={len(p['exif_pillow'])}") + print(f" IPTC polí: {len([k for k in p['iptc'] if not k.startswith('_')])}") + print(f" XMP polí: {len([k for k in p['xmp'] if not k.startswith('_')])}") + + # ExifRead je spolehlivější (Pillow má GPS bug) + interesting = { + "Kamera": f"{er.get('Image Make', '')} {er.get('Image Model', '')}".strip(), + "Objektiv": er.get("EXIF LensModel"), + "Datum": er.get("EXIF DateTimeOriginal") or er.get("Image DateTime"), + "TZ offset": er.get("EXIF OffsetTimeOriginal") or er.get("EXIF OffsetTime"), + "Clona": er.get("EXIF FNumber"), + "ISO": er.get("EXIF ISOSpeedRatings"), + "Expozice": er.get("EXIF ExposureTime"), + "Ohnisko mm": er.get("EXIF FocalLength"), + "Flash": er.get("EXIF Flash"), + "GPS lat": er.get("GPS GPSLatitude"), + "GPS lon": er.get("GPS GPSLongitude"), + "Software": er.get("Image Software"), + } + for k, v in interesting.items(): + if v and str(v).strip(): + print(f" {k:12s}: {v}") + + # IPTC / XMP — vypsat všechno, co je + if p["iptc"]: + for k, v in p["iptc"].items(): + if not k.startswith("_"): + print(f" IPTC.{k:8s}: {v}") + if p["xmp"]: + for k, v in p["xmp"].items(): + if not k.startswith("_"): + print(f" XMP.{k:9s}: {v}") + print() + + # Tabulka perceptuálních podobností (Hamming distance phash) + print(f"{'=' * 70}") + print("PERCEPTUÁLNÍ PODOBNOST (phash Hamming distance)") + print("Hodnota 0-10 = vizuálně velmi podobné, >20 = odlišné") + print(f"{'=' * 70}") + n = len(photos) + header = " " + "".join(f" [{i+1}]" for i in range(n)) + print(header) + for i in range(n): + row = f" [{i+1}] " + for j in range(n): + if i == j: + row += " -" + else: + h1 = photos[i]["hashes"].get("phash") + h2 = photos[j]["hashes"].get("phash") + if h1 and h2: + d = hamming_distance(h1, h2) + marker = "*" if d <= 10 and i != j else " " + row += f" {d:3d}{marker}" + else: + row += " N/A " + print(row) + print("\n * = vizuálně podobné fotky (možná duplikát po editaci)") + print() + + +def main(): + if not PHOTOS_DIR.exists(): + print(f"[ERROR] Složka neexistuje: {PHOTOS_DIR}") + return + + files = sorted([p for p in PHOTOS_DIR.iterdir() + if p.is_file() and p.suffix.lower() in {".jpg", ".jpeg", ".png", ".heic", ".tiff", ".tif", ".webp"}]) + + if not files: + print(f"[WARN] Žádné fotky v {PHOTOS_DIR}") + return + + print(f"Nalezeno {len(files)} fotek v {PHOTOS_DIR}\n") + + photos = [] + for f in files: + print(f" zpracovávám: {f.name} ...", end=" ", flush=True) + try: + photos.append(explore_photo(f)) + print("OK") + except Exception as e: + print(f"FAIL: {e}") + + print_summary(photos) + + # Uložit do JSON pro detailní analýzu + with open(OUTPUT_JSON, "w", encoding="utf-8") as f: + json.dump(photos, f, indent=2, ensure_ascii=False, default=str) + print(f"\n[OK] Detailní data uložena: {OUTPUT_JSON}") + + +if __name__ == "__main__": + main() diff --git a/photo_exploration.json b/photo_exploration.json new file mode 100644 index 0000000..21470e4 --- /dev/null +++ b/photo_exploration.json @@ -0,0 +1,906 @@ +[ + { + "filesystem": { + "file_name": "2026-04-22 09.05.08.jpg", + "file_path": "U:\\PycharmProjects\\FotkyBuzalkovi\\demo_fotky\\2026-04-22 09.05.08.jpg", + "file_size_bytes": 10186179, + "file_size_mb": 9.71, + "mtime": "2026-04-22T09:05:08", + "ctime": "2026-05-19T06:22:12.659966", + "extension": ".jpg" + }, + "hashes": { + "sha256_file": "4ea38e22f815c4512e6bfe06d9203180e5920ac7324d2e0e9271e918d53adff5", + "sha256_pixels": "b95b72d63a300cde544091c11f8e884896d583afa0dc380cdebc47b3ade8d05f", + "phash": "f7a2c8606d4c76d4", + "dhash": "c4884c4c4c4cc010", + "ahash": "feffe6a702246098", + "whash": "feffe7a702006098" + }, + "pillow": { + "format": "JPEG", + "mode": "RGB", + "width": 5712, + "height": 4284, + "megapixels": 24.47, + "has_transparency": false, + "dpi": [ + "72.0", + "72.0" + ], + "icc_profile_present": true, + "exif_present": true, + "xmp_snippet": " \n \n \n Screenshot