Skip to content

Commit 7619da1

Browse files
committed
Add source code
0 parents  commit 7619da1

17 files changed

+1396
-0
lines changed

README.md

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# EXIF Data Extractor
2+
3+
An application that extracts and displays EXIF metadata from image files. It focuses on **when and where** photos were taken and **device identifiers**, and ignores exposure settings and technical image metadata.
4+
5+
This application will identifed image files within folders and automatically extract relevant exif/metadata info for use in determinng location, time/date, and device information.
6+
7+
## Features
8+
9+
- **Folder scanning** — Recursively scan a folder for image files (JPEG, PNG, TIFF, HEIC, RAW, and more)
10+
- **Metadata extraction** — Only date/time, GPS, and device info:
11+
- **Timestamp** (EXIF encoded date: DateTimeOriginal, DateTimeDigitized, or DateTime)
12+
- **GPS** — Latitude, longitude, altitude
13+
- **Device** — Make, model, serial number, software
14+
- **Optional thumbnails** — Generate previews during import; show in table and in PDF/KMZ exports
15+
- **View on map** — Open GPS locations in your browser (Google Maps or OpenStreetMap)
16+
- **Export to KMZ** — Export locations to a KMZ file for Google Earth (placemarks with thumbnails and metadata)
17+
- **Export to CSV, JSON, PDF** — PDF is landscape with all columns and optional thumbnails; GPS in PDF is clickable (opens map in browser)
18+
- **View all EXIF** — Right‑click a row → “View all EXIF” to see every metadata tag in a popup
19+
- **Adjustable columns** — Resize column widths in the table
20+
- **Progress feedback** — Status bar and progress bar during scan and PDF export
21+
22+
23+
24+
25+
## Installation
26+
27+
1. Download the .exe from the "releases" page
28+
29+
## Usage
30+
31+
32+
1. Click **Select Folder** and choose a folder of images.
33+
2. When prompted, choose whether to **generate thumbnails** (slower, but enables previews in the table and in PDF/KMZ).
34+
3. Wait for the scan; results appear in the table.
35+
36+
### Main actions
37+
38+
| Action | How |
39+
|--------|-----|
40+
| **View on map** | Ensure at least one image has GPS; click **View on Map** (uses selected row or first with GPS). |
41+
| **View all EXIF** | Right‑click any cell in a row → **View all EXIF** to see full metadata in a popup. |
42+
| **Export CSV/JSON** | Click **Export to CSV** or **Export to JSON**; choose save path. |
43+
| **Export PDF** | Click **Export to PDF**; choose path. Progress bar runs in background. PDF is landscape; GPS values are clickable links. |
44+
| **Export KMZ** | Click **Export to KMZ** (enabled when any image has GPS). Open the `.kmz` in Google Earth to see placemarks with thumbnails and metadata. |
45+
| **Resize columns** | Drag column borders in the table header. |
46+
47+
## Supported image formats
48+
49+
- JPEG, PNG, TIFF, HEIC/HEIF, BMP, GIF, WebP
50+
- RAW: CR2, NEF, ARW, DNG, ORF, RAF, RW2, PEF, SRW, X3F
51+
52+
## Data extracted (and excluded)
53+
54+
**Included:** File name, timestamp (EXIF encoded date only), latitude, longitude, altitude, make, model, serial number, software.
55+
56+
**Excluded:** Exposure (ISO, aperture, shutter, focal length, etc.), orientation, dimensions, compression, resolution, and other technical metadata.
57+
58+
59+
## Troubleshooting
60+
61+
- **No images found** — Check that the folder contains supported image extensions and the path is correct.
62+
- **No metadata** — Many images have no or stripped EXIF; timestamp and device fields may be empty.
63+
- **View on Map / Export KMZ disabled** — At least one loaded image must have GPS coordinates.
64+
- **PDF/export fails** — Ensure reportlab is installed for PDF; check write permissions and disk space.
65+
66+
## License
67+
68+
Permission is hereby granted to law-enforcement agencies, digital-forensic analysts, and authorized investigative personnel ("Authorized Users") to use and copy this software for the purpose of criminal investigations, evidence review, training, or internal operational use.
69+
70+
The following conditions apply:
71+
72+
Redistribution: This software may not be sold, published, or redistributed to the general public. Redistribution outside an authorized agency requires written permission from the developer.
73+
74+
No Warranty: This software is provided "AS IS," without warranty of any kind, express or implied, including but not limited to the warranties of accuracy, completeness, performance, non-infringement, or fitness for a particular purpose. The developer shall not be liable for any claim, damages, or other liability arising from the use of this software, including the handling of digital evidence.
75+
76+
Evidence Integrity: Users are responsible for maintaining forensic integrity and chain of custody when handling evidence. This software does not alter source evidence files and is intended only for analysis and review.
77+
78+
Modifications: Agencies and investigators may modify the software for internal purposes. Modified versions may not be publicly distributed without permission from the developer.
79+
80+
Privacy: Users are responsible for controlling files and output generated during use of the software to prevent unauthorized disclosure of sensitive or personally identifiable information.
81+
82+
Compliance: Users agree to comply with all applicable laws, departmental policies, and legal requirements when using the software.
83+
84+
By using this software, the user acknowledges that they have read, understood, and agreed to the above terms.
2.21 KB
Binary file not shown.
11.8 KB
Binary file not shown.
15.5 KB
Binary file not shown.
1.89 KB
Binary file not shown.
1.21 KB
Binary file not shown.
2.41 KB
Binary file not shown.

data_model.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""
2+
Data model for storing EXIF information extracted from images.
3+
Only includes date/time, GPS coordinates, and device identifiers.
4+
"""
5+
6+
from dataclasses import dataclass
7+
from typing import Optional, Any
8+
from datetime import datetime
9+
10+
11+
@dataclass
12+
class ExifData:
13+
"""Data structure to hold extracted EXIF information."""
14+
15+
file_path: str
16+
file_name: str
17+
date_taken: Optional[datetime] = None
18+
latitude: Optional[float] = None
19+
longitude: Optional[float] = None
20+
altitude: Optional[float] = None
21+
make: Optional[str] = None
22+
model: Optional[str] = None
23+
serial_number: Optional[str] = None
24+
software: Optional[str] = None
25+
thumbnail: Optional[Any] = None # PIL Image object for thumbnail
26+
27+
def has_gps(self) -> bool:
28+
"""Check if GPS coordinates are available."""
29+
return self.latitude is not None and self.longitude is not None
30+
31+
def to_dict(self) -> dict:
32+
"""Convert to dictionary for export."""
33+
return {
34+
'file_path': self.file_path,
35+
'file_name': self.file_name,
36+
'date_taken': self.date_taken.isoformat() if self.date_taken else None,
37+
'latitude': self.latitude,
38+
'longitude': self.longitude,
39+
'altitude': self.altitude,
40+
'make': self.make,
41+
'model': self.model,
42+
'serial_number': self.serial_number,
43+
'software': self.software
44+
}
45+

exif_extractor.py

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
"""
2+
EXIF data extraction module for image files.
3+
Extracts only date/time, GPS coordinates, and device identifiers.
4+
Excludes exposure settings and image metadata.
5+
"""
6+
7+
import exifread
8+
from PIL import Image
9+
from PIL.ExifTags import TAGS
10+
from datetime import datetime
11+
from typing import List, Optional, Tuple
12+
from data_model import ExifData
13+
import os
14+
import warnings
15+
from pathlib import Path
16+
from contextlib import redirect_stderr
17+
from io import StringIO
18+
19+
# Increase PIL image size limit to handle large images (we only read EXIF, not the full image)
20+
Image.MAX_IMAGE_PIXELS = None # Disable decompression bomb check
21+
warnings.filterwarnings('ignore', category=Image.DecompressionBombWarning)
22+
23+
24+
def _convert_to_decimal(degrees, minutes, seconds, ref):
25+
"""
26+
Convert GPS coordinates from degrees/minutes/seconds to decimal.
27+
"""
28+
decimal = float(degrees) + float(minutes) / 60.0 + float(seconds) / 3600.0
29+
if ref in ['S', 'W']:
30+
decimal = -decimal
31+
return decimal
32+
33+
34+
def _get_gps_data(exif_data):
35+
"""
36+
Extract GPS coordinates from EXIF data (PIL format).
37+
"""
38+
if 'GPSInfo' not in exif_data:
39+
return None, None, None
40+
41+
gps_info = exif_data['GPSInfo']
42+
lat = lon = alt = None
43+
44+
if 2 in gps_info and 3 in gps_info:
45+
lat_deg, lat_min, lat_sec = gps_info[2][0], gps_info[2][1], gps_info[2][2]
46+
lat_ref = gps_info[3]
47+
lat = _convert_to_decimal(lat_deg, lat_min, lat_sec, lat_ref)
48+
49+
if 4 in gps_info and 5 in gps_info:
50+
lon_deg, lon_min, lon_sec = gps_info[4][0], gps_info[4][1], gps_info[4][2]
51+
lon_ref = gps_info[5]
52+
lon = _convert_to_decimal(lon_deg, lon_min, lon_sec, lon_ref)
53+
54+
if 6 in gps_info:
55+
alt = float(gps_info[6])
56+
57+
return lat, lon, alt
58+
59+
60+
def _parse_datetime(date_str: str) -> Optional[datetime]:
61+
"""
62+
Parse EXIF datetime string to datetime object.
63+
"""
64+
if not date_str or not isinstance(date_str, str):
65+
return None
66+
date_str = date_str.strip()
67+
if not date_str:
68+
return None
69+
70+
try:
71+
return datetime.strptime(date_str, "%Y:%m:%d %H:%M:%S")
72+
except (ValueError, TypeError):
73+
pass
74+
75+
try:
76+
from dateutil import parser as dateutil_parser
77+
return dateutil_parser.parse(date_str)
78+
except Exception:
79+
pass
80+
81+
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M:%SZ"):
82+
try:
83+
return datetime.strptime(date_str.replace('Z', '').split('.')[0].strip(), fmt)
84+
except (ValueError, TypeError):
85+
continue
86+
return None
87+
88+
89+
# Tag keys that contain binary data; show a short summary instead of raw bytes
90+
_BINARY_TAGS = frozenset({
91+
'JPEGThumbnail', 'TIFFThumbnail', 'EXIF MakerNote', 'EXIF Makernote',
92+
'InteroperabilityTag', 'Image Tag 0x927C', 'Image Tag 0x9C9B', 'Image Tag 0x9C9C',
93+
})
94+
95+
96+
def get_all_exif_tags(file_path: str) -> List[Tuple[str, str]]:
97+
"""
98+
Read all EXIF/metadata tags from an image file for display.
99+
Returns a sorted list of (tag_name, value_str). Binary tags are summarized as "(binary, N bytes)".
100+
On error returns an empty list.
101+
"""
102+
result: List[Tuple[str, str]] = []
103+
seen: set = set()
104+
105+
def add_tag(name: str, value_str: str) -> None:
106+
if name in seen:
107+
return
108+
seen.add(name)
109+
result.append((name, value_str))
110+
111+
try:
112+
with open(file_path, 'rb') as f:
113+
tags = exifread.process_file(f)
114+
except (OSError, IOError, ValueError):
115+
return []
116+
117+
for tag_name, value in tags.items():
118+
if tag_name in _BINARY_TAGS or isinstance(value, (bytes, bytearray)):
119+
try:
120+
size = len(value)
121+
except (TypeError, AttributeError):
122+
size = 0
123+
add_tag(tag_name, f"(binary, {size} bytes)")
124+
else:
125+
try:
126+
add_tag(tag_name, str(value).strip())
127+
except Exception:
128+
add_tag(tag_name, "(unable to convert)")
129+
130+
if not result:
131+
try:
132+
with warnings.catch_warnings():
133+
warnings.filterwarnings('ignore')
134+
with redirect_stderr(StringIO()):
135+
with Image.open(file_path) as img:
136+
if hasattr(img, '_getexif') and img._getexif() is not None:
137+
for tag_id, value in img._getexif().items():
138+
name = TAGS.get(tag_id, f"Tag {tag_id}")
139+
if name in seen:
140+
continue
141+
seen.add(name)
142+
try:
143+
result.append((name, str(value).strip()))
144+
except Exception:
145+
result.append((name, "(unable to convert)"))
146+
except Exception:
147+
pass
148+
149+
return sorted(result, key=lambda x: x[0])
150+
151+
152+
def extract_exif_data(file_path: str) -> ExifData:
153+
"""
154+
Extract EXIF data from an image file.
155+
Only extracts date/time, GPS coordinates, and device identifiers.
156+
"""
157+
file_name = os.path.basename(file_path)
158+
exif_data = ExifData(file_path=file_path, file_name=file_name)
159+
160+
try:
161+
stderr_capture = StringIO()
162+
with redirect_stderr(stderr_capture):
163+
with open(file_path, 'rb') as f:
164+
tags = exifread.process_file(f, details=False)
165+
166+
for date_tag in ['EXIF DateTimeOriginal', 'Image DateTime', 'EXIF DateTimeDigitized']:
167+
if date_tag in tags:
168+
date_str = str(tags[date_tag])
169+
exif_data.date_taken = _parse_datetime(date_str)
170+
if exif_data.date_taken:
171+
break
172+
173+
if 'Image Make' in tags:
174+
exif_data.make = str(tags['Image Make']).strip()
175+
if 'Image Model' in tags:
176+
exif_data.model = str(tags['Image Model']).strip()
177+
if 'EXIF BodySerialNumber' in tags:
178+
exif_data.serial_number = str(tags['EXIF BodySerialNumber']).strip()
179+
elif 'Image SerialNumber' in tags:
180+
exif_data.serial_number = str(tags['Image SerialNumber']).strip()
181+
if 'Image Software' in tags:
182+
exif_data.software = str(tags['Image Software']).strip()
183+
184+
if 'GPS GPSLatitude' in tags and 'GPS GPSLatitudeRef' in tags:
185+
lat_deg = tags['GPS GPSLatitude'].values[0]
186+
lat_min = tags['GPS GPSLatitude'].values[1]
187+
lat_sec = tags['GPS GPSLatitude'].values[2]
188+
lat_ref = str(tags['GPS GPSLatitudeRef'])
189+
exif_data.latitude = _convert_to_decimal(lat_deg, lat_min, lat_sec, lat_ref)
190+
191+
if 'GPS GPSLongitude' in tags and 'GPS GPSLongitudeRef' in tags:
192+
lon_deg = tags['GPS GPSLongitude'].values[0]
193+
lon_min = tags['GPS GPSLongitude'].values[1]
194+
lon_sec = tags['GPS GPSLongitude'].values[2]
195+
lon_ref = str(tags['GPS GPSLongitudeRef'])
196+
exif_data.longitude = _convert_to_decimal(lon_deg, lon_min, lon_sec, lon_ref)
197+
198+
if 'GPS GPSAltitude' in tags:
199+
alt = tags['GPS GPSAltitude']
200+
exif_data.altitude = float(alt.values[0])
201+
if 'GPS GPSAltitudeRef' in tags and str(tags['GPS GPSAltitudeRef']) == '1':
202+
exif_data.altitude = -exif_data.altitude
203+
204+
if not exif_data.has_gps():
205+
try:
206+
with warnings.catch_warnings():
207+
warnings.filterwarnings('ignore')
208+
try:
209+
with redirect_stderr(StringIO()):
210+
with Image.open(file_path) as img:
211+
if hasattr(img, '_getexif') and img._getexif() is not None:
212+
exif_dict = img._getexif()
213+
lat, lon, alt = _get_gps_data(exif_dict)
214+
if lat is not None:
215+
exif_data.latitude = lat
216+
if lon is not None:
217+
exif_data.longitude = lon
218+
if alt is not None:
219+
exif_data.altitude = alt
220+
if exif_data.date_taken is None:
221+
for tag_id, value in exif_dict.items():
222+
tag = TAGS.get(tag_id, tag_id)
223+
if tag in ['DateTime', 'DateTimeOriginal', 'DateTimeDigitized']:
224+
exif_data.date_taken = _parse_datetime(str(value))
225+
if exif_data.date_taken:
226+
break
227+
if not exif_data.make:
228+
for tag_id, value in exif_dict.items():
229+
tag = TAGS.get(tag_id, tag_id)
230+
if tag == 'Make':
231+
exif_data.make = str(value).strip()
232+
elif tag == 'Model':
233+
exif_data.model = str(value).strip()
234+
elif tag == 'Software':
235+
exif_data.software = str(value).strip()
236+
except (Exception, IOError, OSError):
237+
pass
238+
except Exception:
239+
pass
240+
241+
except Exception:
242+
pass
243+
244+
return exif_data

0 commit comments

Comments
 (0)