An open-source toolkit for triaging and analyzing large sets of images and video in a digital-forensics workflow.
GLEAPP ingests media from folders (or a JSON job file), hashes and de-duplicates it, pulls metadata, extracts video key frames, runs lightweight visual screening, matches against known-hash lists, and gives you a fast local review gallery with right-click find-similar and cursor-scrub video preview.
Scope / intended use. GLEAPP is a defensive investigative tool for authorized examiners. It does not ship, download, or connect to any illegal-content hash database and it performs no content classification: categorization is always a human decision. Every case is seeded with the locked Project VIC 2.0 (US) category scheme (codes 0–5); the examiner adds their own categories (code 6+) as needed.
A full manual is built into the app (the ? Help button, or press ?)
and mirrored at docs/MANUAL.md.
| Area | What GLEAPP does |
|---|---|
| Ingestion | Recursive scan of folders / mounted evidence, or a ingest.json job spec listing multiple named sources (size caps, symlink policy per source) |
| Extractions and acquisitions | A full-file-system extraction (zip, or tar plain or compressed) is read in place, its media registered by device path. A disk image, EnCase/EWF (.E01 and its segments) or raw (one .img/.dd file, or a numbered split set) has its filesystems walked file by file, so each file keeps the name, path and dates the filesystem recorded: ext2/3/4, F2FS, FAT32, FAT16, FAT12, exFAT, NTFS, HFS+, HFSX, APFS, QNX4, QNX EFS, QNX ETFS, QNX IFS, SquashFS, JFFS2, UBI, UBIFS, YAFFS1, YAFFS2, XFS (v4 and v5), Btrfs and ReFS 3.x up to 3.14, including inside the linear, striped, RAID1 and mirror logical volumes of an LVM2 physical volume (one split into runs, or striped, is read through a map of its runs; a RAID1 or mirror one from its first copy that is all in the image). A LUKS container opens with its passphrase (AES; Argon2 and PBKDF2 key slots), asked for like a BitLocker or APFS volume's, and what it holds is walked; one left locked is listed with why. The member images of a RAID set (Linux md, or an LVM2 volume group over several disks) are one source: pick them together, or pick one and GLEAPP finds the others beside it by what each records about itself and asks before using them; each array is walked like any volume, and a RAID4/5/6 array missing what its parity can rebuild is read from parity. Recovering deleted media is optional and separate: first from deleted records (the NTFS MFT, and FAT32 and exFAT directory entries), which bring a deleted file back with its real name and the times its filesystem recorded while its clusters are still free, and on NTFS reach files whose data was resident (which a carve cannot), then by carving the free space for signatures. A launcher checkbox does it during ingest, or a Source-panel button (and gleapp source carve) later |
| Archives inside a source | A .zip / .7z / .tar / .tar.gz / .gz / .bz2 / .xz found in a folder or on a walked E01 is opened at ingest when Expand archives found inside the sources is ticked (off by default; Expand archives in the sidebar does it later): its image and video members (and any archives nested inside) are extracted to extracted/ and registered as ordinary rows linked to the container. The container file itself is kept for its hashes but hidden from the gallery and reports (Type = archive (container) to list them). RAR is recognized but not opened (no bundled RAR reader). Re-run on an existing case with Expand archives |
| App video caches | Android apps that play video through ExoPlayer cache it split into .exo pieces with a separate index; GLEAPP joins each item's pieces back into one file, names the app folder and the key the index recorded (usually the address it came from), and says whether the join is complete, partial or stops at a gap. DASH streams are joined from the manifest the app cached, and a video is put in one file with its sound without re-encoding |
| Hashing | MD5 / SHA-1 / SHA-256 in one pass, plus aHash / pHash / dHash perceptual hashes |
| Deduplication | Three tiers: exact-file stacking (same hash), visual stacking ("same picture to the eye": pHash and dHash agree; collapses like a stack, badged ≈ N), and a looser browsable similar-group cluster. Featureless images (gradients, flat screenshots) are excluded from perceptual grouping |
| Project VIC | Import a Project VIC 2.0 (US) case file directly: registers every Media entry, resolves the media folder, keeps MD5 / MediaID / original name & path / MIME / victim-offender flags; export back to VIC JSON with your categories filled in |
| Known-hash matching | Import Project VIC JSON, CAID-style or plain CSV/text lists into a per-case set or the shared global store, and a SQLite database (incl. the NSRL RDS, delta merge built in) into the global store; match on crypto hash then pHash. A known-good (NSRL) hit auto-categorizes an uncategorized file Non-pertinent and can be hidden with Hide known-NSRL. A match against a Project VIC hash set carries that record's MediaID, series, flags, tags and Exif (as text) into the gallery, the HTML, CSV and JSON reports, and LAVA |
| Robustness at scale | Tens of thousands of files: LSH-banded near-dup clustering (not O(n²)); video and GPU-texture decode isolated in child processes (a corrupt clip or a texture-decoder segfault can't crash the run: video batches are bisected and retried); HEIC/HEIF/TIFF/RAW decoded for viewing; KTX GPU textures (ASTC/PVRTC/ETC/BC) decoded to images; Apple's proprietary LZFSE assets labeled honestly rather than shown broken; Retry failed files re-runs just the errored ones |
| Metadata | EXIF capture time, camera make/model, GPS → decimal degrees; filesystem-time fallback; capture timeline via the list view's Captured (EXIF) column sort |
| Video | Dimensions/duration (OpenCV, no ffmpeg needed), evenly-spaced key-frame extraction, per-frame pHash so a still can find its source video |
| Visual screening | YuNet DNN face detection + broad skin-tone ratio as triage aids (pluggable: swap in another model behind the same functions) |
| Similarity search | Right-click → Find similar across stills and video key frames; also on the CLI |
| Categories | Locked Project VIC 2.0 (US) presets (codes 0–5) in every case; examiner adds their own (code 6+: rename / delete / reorder, auto colors), and can save them, plus flags if they choose, as a set that new cases start with; shown as a color bar + name on every tile |
| Flags | Independent of category: a file can carry any number (Evidence, Bondage, whatever the case calls for); examiner-defined, none preseeded or locked; searchable, filterable, and shown as colored labels in every report |
| Saving | Every action commits to case.gleapp immediately (SQLite WAL); notes autosave; header shows save status; timestamped snapshots in <case>/backups/ on a timer, on close, and on demand |
| Review workflow | Filter to Uncategorized and work the backlog: categorizing is the review step, cursor auto-advances, ↻ Refresh clears done files; flags, per-file notes, audit log of every action |
| Reporting | HTML contact-sheet, CSV, JSON, KMZ of geolocated media (thumbnails embedded, for Google Earth), MD5 list, Project VIC round-trip, and a LAVA project the LEAPP family's viewer opens |
| Web gallery | Filter sidebar, multi-select, keyboard categorization, docked metadata pane (single-click), filmstrip + duplicate stack, one-click export, built-in manual |
Builds for Windows, macOS and Linux are on the releases page. Nothing to install for Python, and no toolchain.
| Platform | File |
|---|---|
| Windows 10 or 11, 64-bit | -windows-x64-portable.zip, which holds the whole program as one file, GLEAPP.exe |
| macOS, Apple silicon | -macos-arm64.dmg |
| macOS, Intel | -macos-x64.dmg |
| Linux, 64-bit | -linux-x64.tar.gz, built on Ubuntu 24.04 so it needs glibc 2.39 or newer |
The macOS disk images are signed with a Developer ID and notarised by Apple, so they open without a warning.
The Windows binaries are not signed yet, so the first launch warns. On Windows, SmartScreen says "Windows protected your PC". Choose More info, then Run anyway. Releases will be signed through SignPath once its certificate is in place; see Code signing policy. Running from source, below, needs no warning cleared.
There is no Windows installer any more. If an earlier release's installer put GLEAPP in
Program Files, uninstall it from Settings, Apps; cases and settings live elsewhere and
stay. GLEAPP.exe unpacks itself to a temporary folder each time it starts, so it is slower
to open than an installed copy was, and it does not run where a policy (AppLocker, WDAC)
forbids running programs from the temporary folder; run from source there.
If the portable folder of an earlier release stops at start with "Failed to resolve
Python.Runtime.Loader.Initialize", Windows has marked the extracted files as downloaded from
the internet and .NET will not load them. Folders after v2026.5.1 clear that mark
themselves unless the folder is read-only. Otherwise delete the extracted folder,
right-click the zip, choose Properties, tick Unblock, click OK, and extract it again, or
unblock the extracted folder in PowerShell: Get-ChildItem -Recurse <folder> | Unblock-File.
Every release carries a SHA256SUMS.txt. To check what you downloaded:
grep <the file you downloaded> SHA256SUMS.txt | shasum -a 256 -c -Use sha256sum in place of shasum -a 256 on Linux, or
(Get-FileHash -Algorithm SHA256 .\<the file>).Hash in PowerShell on Windows.
The same steps as the other LEAPPs: clone it, make a venv, install, run.
git clone https://github.com/abrignoni/GLEAPP.git
cd GLEAPP
python -m venv .venv
.venv\Scripts\activate # Windows; source .venv/bin/activate on macOS and Linux
pip install -r requirements.txt
python gleapp.py web # opens the review gallery in your browserpython gleappGUI.py opens the same interface in a native desktop window
instead of a browser tab; requirements.txt installs what that needs on Windows
and macOS. Python 3.10 or newer. No external binaries are required, OpenCV
handles video.
Everything installs from prebuilt wheels on Windows x64 with Python 3.10 through
3.14, and on macOS with Python 3.10 through 3.13 (macOS 13 or newer on Apple
silicon, 14 or newer on Intel, which is where OpenCV's wheels start), with two
exceptions. proxy_tools, a small pure-Python package pywebview depends on, is
published only as a source archive, and pip builds it with no compiler involved.
pyliblzfse has no prebuilt wheel for Linux or for Python 3.14 on macOS, so
those setups need a C compiler on the machine and pip builds it from source on
its own; it decodes Apple's LZFSE-compressed GPU textures and is reached only for
that format, and its Windows x64 wheels are vendored in whl_files/ so Windows
x64 never needs a compiler. Windows on ARM is a different case: on every one of
these Python versions opencv-python-headless, brotli and pyliblzfse have no
wheel for it (and more packages on 3.10 and 3.11), so it needs a full build
toolchain and builds them from source. Measured against PyPI on 2026-09-15.
GLEAPP also runs as a native window: the same interface, no browser, fully offline. The window is the OS webview (Edge WebView2 on Windows, present by default on Windows 10 21H2+ and Windows 11; WebKit on macOS); Flask runs in-process on a random localhost port.
python gleappGUI.py # opens the launcher window
python gleapp.py -c mycase desktop # open straight into a caserequirements.txt carries pywebview, the library that opens the window, and pip
brings its platform bindings with it (pythonnet on Windows, PyObjC on macOS).
Do not install the PyPI package named webview: that is a different project,
with no prebuilt wheels, and it is not what GLEAPP uses. On Linux pywebview needs
a GUI toolkit that pip does not install by default: either
pip install "pywebview[qt]" (Qt wheels, about 200 MB), or the distribution's
PyGObject and WebKitGTK packages, which pywebview's installation guide lists.
Without one, python gleappGUI.py says so and opens the same interface in your
browser instead, as python gleapp.py web does. Installing a toolkit this way helps a
source install only: the release binaries carry their own Python and never see
packages installed on the system.
pip install -e . adds the gleapp and gleapp-desktop console commands.
pip install -e .[build]
python packaging/build.py exe # phase 1 -> dist/GLEAPP/ (one-folder; GLEAPP.exe on Windows)
python packaging/build.py exe --onefile # phase 1 -> one file in dist/ (what Windows releases ship; no installer from this)
python packaging/build.py installer # phase 2, Windows -> dist/GLEAPP-Setup-<version>.exe (needs Inno Setup)
python packaging/build.py installer # phase 2, macOS -> dist/GLEAPP-<version>.dmg around dist/GLEAPP.app
python packaging/build.py all # both phases, for unsigned local buildsSigning goes between the two phases: sign dist/GLEAPP/GLEAPP.exe after phase 1 and
before phase 2, or the installer ships an unsigned exe inside a signed wrapper. Pass
installer --sign-tool <name> and Inno Setup signs the installer and its uninstaller
with the Sign Tool configured under that name. python packaging/build.py verify <file>
checks a signature is present and valid before anything is uploaded. The installer
version is read from gleapp/__init__.py, so it cannot drift from the app. Phase 1 on
macOS also produces dist/GLEAPP.app, unsigned; sign it with codesign before phase 2.
A Linux AppImage is not wired up yet. Windows releases ship neither the folder nor the
installer but the --onefile build, signed by SignPath in release.yml; the installer
remains for local builds.
The Linux build is made on Ubuntu 24.04, so it needs glibc 2.39 or newer and will not
start on an older distribution; build it there yourself if you need one. The Linux
binary bundles neither GTK nor Qt, so it cannot open the desktop window. Started from a
terminal it opens the interface in your browser instead, and ./GLEAPP web does that
directly (./GLEAPP -c <case> web for a case). CI smoke-tests that build headlessly:
--version, a decode, and web serving the interface.
The opencv-python-headless wheels for Apple Silicon Macs vendor a Homebrew FFmpeg
built --enable-gpl, which puts x264, x265, xvid, rubberband, vidstab and frei0r in the
bundle. GLEAPP cannot ship those. The build also carries Apache-1.1 code
(gleapp/vendor/impacket_ese.py), which the FSF's licence list calls incompatible with
the GPL, so the build cannot be relicensed as GPL to accommodate them. All ten Apple
Silicon releases from 4.8.1.78 to 5.0.0.93 carry them, so pinning an older version does
not help. Intel Mac wheels are different: they carried them through 4.12, and from 4.13
on they bundle no FFmpeg at all.
Build the macOS app in an environment where OpenCV comes from conda-forge, with FFmpeg pinned to its LGPL build:
micromamba create -p ./build-env -c conda-forge python=3.12 \
"py-opencv[version='>=5',build='headless*']" "ffmpeg=*=lgpl*"
grep -v opencv requirements.txt > /tmp/req.txt
./build-env/bin/python -m pip install -r /tmp/req.txt -e .[build]FFmpeg support is kept: cv2.getBuildInformation() still reports FFMPEG: YES, and
no GPL codec is in the tree. packaging/gleapp.spec refuses to build if one appears.
Windows needs none of this. Its OpenCV wheel ships one FFmpeg DLL built without
--enable-gpl, whose only external codecs are libopenh264, libvpx and libaom.
The build bundles Python, OpenCV, Pillow, NumPy, SciPy, Flask and pywebview:
~110–140 MB one-folder, ~90 MB one-file. It does not bundle the WebView2
runtime; see packaging/installer.iss for how to chain the Evergreen
bootstrapper if you need to support machines without it.
Why not Electron? The analysis backend is Python (OpenCV/Pillow/NumPy) and has to be bundled either way. pywebview reuses the same UI with one runtime instead of adding a second ~150 MB Chromium+Node layer. Swap in Electron/Tauri only if you plan to rewrite the backend off Python.
# 0. (optional) build a synthetic evidence set to play with
python tools/make_sample_evidence.py sample_evidence
# 1. ingest + process a folder ...
python -m gleapp --case mycase ingest C:\evidence\usb1
# ... or a JSON job describing several sources
python -m gleapp --case mycase ingest sample_evidence\ingest.json
# ... or a full-file-system extraction archive, zip or tar (Cellebrite, GrayKey,
# Magnet). Its media is registered by device path and read from the archive on
# demand, so the case stays small and the archive has to stay where it is. Add
# --stage to copy the media into the case instead (self-contained, and as large as
# the media). A compressed tar (.tar.gz) is always copied out, since it cannot be
# read on demand. A disk image (an .E01, .s01 or .Ex01 with its numbered segments
# beside it, an .aff or any .aff of an .afd folder, an .afm, an .aff4, an Apple
# .dmg (with any .dmgpart files beside it), .sparseimage or .sparsebundle folder,
# a virtual machine disk (.vhd, .vhdx, .vmdk, .qcow2), or a raw image: one file, or any
# segment of a numbered .001 split set) is
# a source too: its filesystems are walked file by file, so each file keeps the name,
# path and dates the filesystem recorded. Recovering deleted media (from deleted
# NTFS, FAT32 and exFAT records, then by carving the free space) is optional and
# separate: a launcher checkbox at ingest, or the Source panel / "gleapp source
# carve" later. A .zip / .tar / .gz sitting in any source is opened
# automatically and its media registered as rows linked to the container. An Android
# image that
# carries one file under several storage views (data/data, data/user/0, data_mirror,
# storage/emulated) registers it once; the other paths show in the details pane as
# "Also under" and are searchable.
python gleapp.py -c mycase ingest /path/to/EXTRACTION_FFS.zip
python gleapp.py -c mycase source list # is the zip still where the case expects it?
python gleapp.py -c mycase source relink EXTRACTION_FFS.zip /new/place/EXTRACTION_FFS.zip
python gleapp.py -c mycase source stage EXTRACTION_FFS.zip # copy it in later; 'unstage' reverses
# 2. (optional) load a known-hash list
python -m gleapp --case mycase hashset known_hashes.csv --name "Op-Foo known" --kind known
# 3. review in the browser
python -m gleapp --case mycase web
# -> http://127.0.0.1:8749
# 4. export a report
python -m gleapp --case mycase report --format html csv json kmlEverything lives in the case directory (mycase/):
mycase/
case.gleapp SQLite database (all analysis state; resumable)
thumbs/ generated thumbnails + video key frames
reports/ exported reports
backups/ timestamped case snapshots (auto + manual)
staged/ media copied out of an extraction archive or acquisition
(--stage, or a compressed tar)
cache/ full-size copies the viewer pulled from an archive on demand (bounded)
Re-running ingest / process skips already-processed files; add --force to redo.
Nothing needs an explicit save. Every categorize / flag / rename commits
to case.gleapp the instant you do it (SQLite in WAL mode: survives a hard
kill), and notes autosave a second after you stop typing. The header shows
"All changes saved · HH:MM", or "Saving…" / "⚠ Save failed".
On top of that, GLEAPP writes snapshots (full standalone copies of the case) to <case>/backups/:
- automatically every 10 minutes if anything changed, and when the case closes
- on demand via the Save snapshot button (optional label)
The most recent 20 are kept. To roll back, close GLEAPP and copy a snapshot over
case.gleapp. Interval/retention are AUTO_INTERVAL_MIN / KEEP in
gleapp/backup.py.
A path in sources may also be a full-file-system extraction archive (a zip or a tar,
plain or compressed), a disk acquisition (EnCase/EWF .E01, SMART .s01, EWF2 .Ex01,
AFF .aff, any .aff of an AFD folder, AFM .afm, AFF4 .aff4, or an Apple .dmg
with any .dmgpart files beside it, .sparseimage or .sparsebundle folder), a
virtual machine disk (.vhd, .vhdx, .vmdk, .qcow2) or a raw disk
image (one file, or any segment of
a numbered split set); it is detected by its bytes and ingested as an archive
source. Its media is read from the archive on demand unless the
entry sets "stage": true, which copies it under the case; a compressed tar is always
copied out.
Pass a folder path or a .json file. Accepted shapes (keys case-insensitive):
Relative paths are resolved against the JSON file's location.
Pass a Project VIC 2.0 (US) .json file wherever you'd pass a folder: the
launcher's "Browse for JSON", the typed-path box, or gleapp ingest FILE.
GLEAPP auto-detects it (by the @odata.context / value[].Media shape),
registers every Media entry, and resolves each RelativeFilePath against the
media folder sitting next to the JSON. Missing files are registered with an
error flag so the counts still line up for export.
Each file carries its VIC MD5 (processing trusts it, skips re-hashing), MediaID,
original filename and device path, MIME type, victim/offender/distributed flags,
and the Series and Tags the record carried, kept apart from the examiner's own flags. Existing Category values are imported; GLEAPP category codes map 1:1 to
VIC codes (0 = Non-pertinent; 5 = Uncategorized, written back as null): the
codes 0–5 GLEAPP seeds are the Project VIC scheme, so an imported category
lands on the matching locked preset.
Export back: the Export Project VIC button (or gleapp report --format vic)
re-reads the original file and writes reports/projectvic_export.json with each
entry's Category (and Comments from your notes) updated, ready to load back
into Project VIC. You choose all-media or categorized-only.
Pass a C4All C4P Index.xml or C4M Index.xml the same way. GLEAPP detects it
(a ReportIndex root), registers every Image / Movie entry from the folders
beside the index, and carries the MD5, SHA-1, size, times and original path. A
category of 0 is left uncategorized; any other value is kept as the same code.
Processing 30k+ files takes a while (thumbnails, perceptual hashes, optional
face/skin screening) but is resumable: re-run process / reopen the case to
continue. Turning off screening in the launcher roughly halves the time.
gleapp report --format lava writes reports/lava/, a project the
LAVA viewer opens, so a case can be handed to
an examiner who already reviews iLEAPP and ALEAPP output there. Thirteen artifacts
are considered: every media file, the ones an examiner categorized, the ones
carrying coordinates, a location overview, the frames extracted from each video, all
three grouping tiers (exact-duplicate stacks, visually similar groups and the looser
clusters), known-hash-set hits, the lists those hits were checked against, Project
VIC records with the flags, series and tags they carried, what the category names
mean, and the case audit log.
Pictures and video show inline in LAVA and play from the report.
An artifact with no rows is left out rather than written empty, because an empty table reads as an answer to a question the case never asked. The run log lists all thirteen with their counts and says which of them reached the report, so nothing is dropped silently.
The video frames GLEAPP already extracts go in with their time offsets, so a clip can
be read without playing it, and they survive the evidence moving because they are
thumbnails the case holds. --no-keyframes skips them.
The hash-set hits artifact is the one exception to leaving an empty artifact out. Where the hash-set listing names lists that were checked, an empty hits table is kept, because "checked against these and nothing matched" is a result and its absence would read as no check having been made. With no lists anywhere, both are left out.
A geolocated file also carries a locator map, drawn from the offline basemap you
imported and marked at the file's own coordinates, with the street, water and place
names the basemap carries. A Location Overview artifact puts every file it could
map on one map. Nothing is fetched. A map is drawn only where the basemap actually
holds tiles for those coordinates, because a point outside its coverage renders as an
empty background with a mark on it, and the run log counts every file that got no map
and why. --no-maps skips them.
The case name, examiner, sources and counts go to LAVA's Device Info and Screen Output tabs. No path from the machine the case was made on is written anywhere in it, the same rule the other exports follow.
Media is copied into the report by default, so the report is self-contained and
nothing in it can write back to the evidence. --link hardlinks it instead, which
is worth having only while the report stays on the volume it was built on: a
hardlinked report shares an inode with the original file, and copying it elsewhere
dereferences to roughly twice the size its own folder reported.
--lava-thumbs puts GLEAPP's thumbnail in the Media column rather than the file.
Use it for a report meant to travel: LAVA's tagged-rows HTML digest embeds every
media cell as base64 at full size, so 100 tagged 3.5 MB photographs is about 467 MB
in one file against 3.8 MB from thumbnails.
An examiner can tag rows in LAVA and cut a subset project from the tags. That works on a GLEAPP report unchanged, and the subset records the source database's SHA-256, the tags, per-artifact counts, and whether the tagged rows still hash to what they did when they were tagged.
Import into this case (sidebar Known hashes → Import hash set…, or
gleapp -c CASE hashset FILE) or the global store shared by every case
(sidebar Known hashes → Reference data → Add a set, or
gleapp hashset --global FILE). Formats auto-detected:
- Project VIC JSON – objects with
MD5/SHA1/SHA256and optionalCategory. - CAID / other JSON – same idea; a
PHashfield becomes aphashentry and aPhotoDNA/PDNAfield aphotodnaentry. PhotoDNA is stored, never matched (see below). - CSV / text – the first field of each line, taken as a hash at 32, 40 or 64 hex
characters (32→md5, 40→sha1, 64→sha256), with an optional
,categoryafter it. No other column is read, so a delimited list's PhotoDNA or pHash column does not come in this way; use the JSON form for those. UTF-8 and ASCII are read, and UTF-16 that starts with a byte-order mark, which Windows PowerShell 5.1 writes with>andOut-File. - SQLite database, global store only – any table/view with
md5/sha1/sha256columns. A case import refuses one and names the global store, which holds a reference set once for every case rather than a copy in eachcase.gleappand in each of its backup snapshots.--table,--algosand the NSRL options below need--global.--tableand--algoschoose what to read from a SQLite database, and are refused for any other file. For the NSRL RDS: import the yearly full.dbdirectly; a quarterly_delta.sqlmerges onto the previous full.dbbefore importing (the merge uses thesqlite3CLI if present, otherwise a built-in fallback, so it works from the frozen app).--schema/--fullbuilds a.dbfrom a schema script and a SQL data dump, for a set that comes that way; NIST publishes a full NSRL release as a.db, which is imported directly. Limit which hashes to store (MD5 alone roughly halves the store).--list/--rm ID(or the sidebar list) manage the global store.
A file with no hash to import is refused, whether it goes into a case or the global
store: a picture or other binary file, a CSV whose first column holds no hash, an empty file,
a JSON list whose records carry none, a list holding only the hashes of an empty file, or
a SQL script, such as the .schema.sql in an NSRL full or delta zip. The command
exits 2 with the reason, the dialogs show it, and nothing is
written: no empty set is left behind, and a set already stored under that name stays
as it was. When only some lines (or JSON records) hold no hash, the rest are imported
and the result says how many were skipped; a header row counts as one. The count an
import reports is the entries it stored: a hash listed twice counts once, and a case
import into a set of the same name counts only the hashes the set did not hold.
--kind known flags matches as notable; --kind known-good marks benign files
(NSRL etc.): a hit auto-categorizes an uncategorized file Non-pertinent and
is hidden by the sidebar's Hide known-NSRL. Re-check known hashes in the
gallery re-runs matching without a full reprocess.
The hashes of an empty file are left out of every list, whether it goes into a case or the global store, and never matched: every zero-byte file has the same MD5, SHA-1 and SHA-256, so they identify none of them.
A Project VIC or CAID list often carries a PhotoDNA value beside the cryptographic hashes. PhotoDNA is a 144-byte robust hash and is not the 64-bit perceptual hash GLEAPP computes: the two cannot be compared, and comparing two PhotoDNA values needs a licensed PhotoDNA implementation, which GLEAPP does not ship.
Those entries are kept under their own photodna algo so a set's total says what the
list held, and the matching pass reads phash entries only, so nothing tries to
compare them. A PhotoDNA entry can never flag a file. The count is stated where
the set is: after a gleapp hashset import and in gleapp hashset --list, on the
set's row in the sidebar, in the case audit log, and as the PhotoDNA (not matched)
column of the Known Hash Sets artifact in the LAVA export. Read a set's entry
count against that column: the hashes a list could match against is its entry count
minus its PhotoDNA count.
A set imported before this separation existed holds those values labeled phash.
They were inert either way, since a PhotoDNA value never matched a perceptual hash,
so this only affects the count. To relabel a case set, remove it and import the
list again: a case import adds to a set of the same name rather than replacing it,
so importing over the top leaves the old phash rows in place (measured). A
global set of the same name is replaced on import, so re-importing is enough
there.
GLEAPP ships no map data and never fetches any: a review page must not hand a subject's coordinates to a server somebody else runs. Instead you import a basemap file once, GLEAPP serves it from the local server, the gallery draws the map with MapLibre, and the report names the file (and its SHA-256) the maps were drawn on, so a reader can obtain the same file and see the same map years later.
Get a region as a .pmtiles file (recommended). The simplest way is
GLEAPP Map Downloader, a separate
program for the computer with internet access, not the review workstation: pick an area
and a detail level in its window and it saves the .pmtiles file, reading only the tiles
inside the area from the Protomaps planet build. Its releases carry builds for Windows,
macOS and Linux that need nothing else installed. Like the pmtiles tool below, it reads
Protomaps' server, which sees the area you ask for.
Or cut the file yourself with Protomaps' own tool. Install pmtiles from
https://github.com/protomaps/go-pmtiles/releases, pick a recent Protomaps planet build
(https://build.protomaps.com/YYYYMMDD.pmtiles, a date within the last few days), and cut
your area with a bounding box in decimal degrees, west, south, east, north:
pmtiles extract https://build.protomaps.com/20260902.pmtiles dc.pmtiles --bbox=-77.12,38.79,-76.90,38.99That reads only the tiles inside the box, at every zoom level from 0 to 15. Measured on
2026-09-04 against the 137.7 GB planet build: the Washington DC metro box above came out
at 28 MB in 8 s, and all of Puerto Rico (--bbox=-67.30,17.85,-65.20,18.55) at 70 MB in
11 s. Add --maxzoom=13 for a smaller file when street-level detail is not needed.
Write the box with =, since a western longitude starts with a minus sign.
Import it: the Maps button in the gallery header, or gleapp maps import area.pmtiles. The file is copied under GLEAPP's data folder and hashed; the first one
imported becomes the active basemap. The details pane then shows a map for any file
with GPS, and Show current filter on the map plots every geolocated file matching
your filters, with a popup thumbnail that opens the file.
In the report: the HTML report draws its own maps from the active basemap and embeds
them in the file, so a saved report is self-contained and still fetches nothing. A
"Locations" overview near the top plots the geolocated files it could map, and each
of those files carries a small locator map with a marker. As in the LAVA report, a map
is drawn only where the basemap holds tiles for those coordinates, and the note under
the overview counts the files that got none and why. A raster basemap is drawn by
compositing its tiles; a vector .pmtiles is drawn by decoding its tiles and filling
land, water and land use and stroking roads, in a flat print-friendly palette. The
place, water and street names the tiles carry are drawn over that, where Pillow can
supply a scalable font; without one the map is drawn with no labels rather than failing.
A raster basemap carries its labels in its tiles already. Untick Draw location maps
in the Export dialog, or pass --no-maps to gleapp report, to leave them out.
Raster MBTiles also work, as a fallback for a map you already have: a .mbtiles
of image tiles made with QGIS, MapTiler Desktop or a GIS shop's own tooling. GLEAPP
serves its tiles one query at a time. Vector MBTiles are not accepted.
Licenses. The Protomaps builds are OpenStreetMap data under the ODbL, distributed as
a produced work, and the map shows "© OpenStreetMap contributors" as that license asks
(as plain text, because a link would be the one outbound address on the page). MapLibre
GL JS and PMTiles are BSD-3-Clause; the PMTiles specification is public domain; the
Noto Sans glyphs are under the SIL Open Font License. All of it is vendored under
gleapp/web/static/maps/ with its license texts, and the page loads nothing else.
gleapp -c CASE init [--name NAME]
gleapp -c CASE ingest SOURCE [--no-process] [--stage] [--force] [--workers N]
[--keyframes N] [--no-screen] [--cluster-threshold N]
gleapp -c CASE process [same processing flags]
gleapp -c CASE source list | relink NAME PATH | stage NAME | unstage NAME
# extraction archives and acquisitions: where they are, move
# the record when one moved, copy one into the case, or drop
# the copies
gleapp maps list | import FILE [--name N] | remove NAME | use NAME
# offline basemaps for the gallery map (see Maps above)
gleapp -c CASE hashset FILE [--name NAME] [--kind known|known-good|other]
gleapp hashset [FILE] --global [--kind …] [--table T] [--algos a,b]
[--schema S --full F] [--base B.db --delta D.sql]
[--list] [--rm ID]
gleapp -c CASE similar FILE_ID [--threshold N] [--limit N]
gleapp -c CASE stats
gleapp -c CASE report [--format {csv,json,html,kml,md5,vic,lava} ...]
[--scope {all,categorized,uncategorized}] [--where "SQL"]
[--thumbs-only] [--no-media] [--no-maps] [--lava-thumbs] [--link]
gleapp -c CASE web [--host H] [--port P] [--no-browser]
gleapp -c CASE desktop # native window (offline)
web and desktop both start at a launcher if the case doesn't exist yet:
pick a recent case, open an existing one, or create a new case and add evidence
folders / a JSON job, then watch processing progress, no CLI needed.
--where takes a raw SQL predicate on the files table, e.g.
--where "category IN (1,2) AND faces > 0".
Each tile shows a color bar with the category name along its bottom, tinted to the category's auto color, with a matching border. A tile standing in for a group gets a layered-page shadow and a corner badge: ⬚ N gray = N byte-identical copies; ≈ N blue = N visually-matching files (hover says "visually similar"). The details pane shows a preview, filename, full path, source, size, dimensions, dates, camera, GPS (with map link), every hash, category, flags, editable notes, and duplicate-stack / cluster info.
gleapp/
ingest.py folder / file discovery + type classification
hashing.py crypto + perceptual hashes, Hamming distance
metadata.py EXIF / GPS / capture-time extraction
media.py thumbnails, video probe + key-frame extraction (OpenCV)
imaging.py decode/transcode: Pillow + HEIF, iOS KTX textures (LZFSE/AAPL)
lzc.py Snapchat LZC (Zstandard) container unpacking
nested.py expand a .zip / .tar / .gz found inside a source
detect.py YuNet face detection + skin-tone screening (pluggable)
hashdb.py known-hash list import + matching (case sets)
hashstore.py shared global known-hash store (NSRL RDS); SQLite/delta import
dedupe.py exact stacking + near-dup union-find clustering
similar.py similarity search (stills ⇄ video key frames)
pipeline.py orchestration: threaded workers, single DB writer
report.py CSV / JSON / HTML / KMZ export
projectvic.py Project VIC 2.0 import + round-trip export
_vidworker.py / _texworker.py / _edbworker.py isolated decode and read subprocesses
workers.py starts them, from a frozen build or from source
backup.py case snapshots (auto + manual), pruning
db.py SQLite schema + helpers (one case = one file)
case.py case open/create + ingest-source spec parsing
archive.py extraction zip/tar and disk image (E01, Ex01, AFF, AFF4, DMG, VHD, VHDX, VMDK,
QCOW, raw, split raw) sources:
enumerate, walk or carve, register, read back on demand
vendor/ qnxprobe (filesystem reader), ewfprobe (E01/Ex01/AFF/AFF4/DMG and virtual
disk reader), mediacarve
(signature carver) and exoprobe (ExoPlayer cache reader), copied in
verbatim with their provenance in vendored.json
appconfig.py per-user config (recent cases) in %APPDATA%\GLEAPP
cli.py argparse CLI
desktop.py pywebview shell (the PyInstaller entry point)
web/app.py Flask gallery + case-management API (templates/ + static/)
packaging/
gleapp.spec PyInstaller spec
build.py two-phase build driver, any OS (exe, then installer)
installer.iss Inno Setup installer script
Processing is a resumable pipeline writing straight to SQLite:
ingest → hash → metadata → thumbnails/keyframes → visual screen
→ known-hash match (case + global) → exact stack → visual stack
→ near-dup cluster
- Better face/scene detection – replace the functions in
detect.pywith another model; the pipeline and UI already carryfaces/skin_ratiocolumns and filters. - Matching PhotoDNA / other robust hashes –
photodnaentries are already imported and stored; matching them needs a licensed PhotoDNA implementation plus a branch inhashdb.match_filethat compares with it instead ofhashing.hamming. - New report formats – add a function to
report.pyand wire it intocli.cmd_report/ the/api/reportroute.
pip install pytest piexif
pytest -qtests/test_pipeline.py: unit coverage of ingestion, hashing, exact/visual stacking, clustering, similarity search, hash-set matching, categories, Project VIC import/export, the web API, snapshots.tests/test_regression.py: drives a full pipeline run over the collection fromtools/make_test_media.py(30-odd deliberately-varied files + amanifest.jsonof expectations) and checks the scenarios that have bitten us: native decoder crashes on corrupt video, featureless-image false grouping, HEIC/TIFF/KTX handling, EXIF date + GPS, screening, VIC round-trip, search over the original device path.
Regenerate the collection to eyeball it:
python tools/make_test_media.py test_mediaFree code signing provided by SignPath.io, certificate by SignPath Foundation.
The Windows builds are not signed yet. GLEAPP is applying to SignPath Foundation's free code signing program for open source projects, and this policy applies to every Windows release signed under it.
The Windows executables attached to
GitHub Releases are built from this
repository by GitHub Actions, on GitHub-hosted runners
(release.yml). Under this policy, SignPath signs only
what that workflow built. The macOS disk images are signed separately, with an Apple
Developer ID, and notarised by Apple.
SignPath signs GLEAPP.exe, the single-file program in the portable zip. Third-party files
packed inside it are not signed on their own.
- Committers and reviewers: @abrignoni, @charpy4n6, @JamesHabben, @Johann-PLW, @stark4n6
- Approvers: @abrignoni, @Johann-PLW
Changes proposed by people who are not committers are reviewed by a committer before they are merged. Every release signing request is approved by an approver.
This program will not transfer any information to other networked systems unless specifically requested by the user or the person installing or operating it.
GLEAPP reads and reviews files on the machine it runs on. Its interface is a local server,
bound to 127.0.0.1 unless you give --host another address. It has no command that
uses the network and it does not fetch map data: a basemap is a file you import. See
Maps.
On Windows the desktop window is drawn by Microsoft's WebView2 Runtime, a Windows
component that GLEAPP does not ship. GLEAPP turns off its SmartScreen checks and keeps its
crash reports on the machine. The runtime still collects diagnostic data as a Windows
component, which an application cannot turn off; the Windows Diagnostic data setting
governs the optional part. See Microsoft's
Data and privacy in WebView2
and privacy statement. gleapp web
shows the same interface in your own browser and does not use WebView2.
The same policy covers the other LEAPPs: leapps.org/releases#code-signing-policy.
GLEAPP logo courtesy of Kevin Pagano.
GLEAPP is built on Pillow, OpenCV, NumPy, ImageHash, Flask, SQLite, the YuNet
face detector and the SFace face-recognition model (both OpenCV Zoo; YuNet is
MIT and SFace is Apache-2.0, their licenses shipped at
gleapp/models/LICENSE-yunet and gleapp/models/LICENSE-sface),
pi-heif/libheif, texture2ddecoder, LZFSE, Zstd, tzdata and more. Every bundled
package's licence text travels inside the build, assembled by
tools/make_notices.py and reachable from Help or at /notices. This product
includes software developed by SecureAuth Corporation
(https://www.secureauth.com/) and Fortra (https://www.fortra.com). Disk
images are walked with qnxprobe,
which also joins a split raw set; an E01, Ex01, AFF, AFF4 or Apple disk image, or a
virtual machine disk, is read with
ewfprobe; both are carved with
mediacarve; Android apps' ExoPlayer
caches are rejoined with exoprobe; all
four MIT and vendored under gleapp/vendor/. GLEAPP is part of the LEAPP family
(ALEAPP / iLEAPP / RLEAPP …), the project started by Alexis Brignoni &
contributors, and its Android storage-view table is ported from ALEAPP. It
reads the Project VIC data model and the NSRL RDS (NIST). Full
attributions and licenses: section 20 of the manual (docs/MANUAL.md, or
? Help in the app).
MIT. See LICENSE. Bundled third-party components keep their own licenses;
see the manual's credits section.


{ "folder": "C:/evidence/usb1" } { "folders": ["C:/evidence/usb1", "C:/evidence/usb2"] } { "case": "Operation Example", "examiner": "H. Charpentier", "sources": [ { "name": "USB-1", "path": "C:/evidence/usb1" }, { "name": "Phone", "path": "C:/evidence/android/DCIM", "include_other": false, "follow_symlinks": false, "max_mb": 500 } ] }