Fixing “ODM Cannot Find Images” in the Project Path

The run ends in seconds with some form of “Not enough supported images in /datasets/site-42/images”, and ls shows 640 JPEGs in exactly that directory. The frustration is well earned: the message names a path that exists on your machine and does not exist, or does not contain what you think, inside the process that reported it.

Four causes produce this message and they are cleanly separable. This page distinguishes them with a single diagnostic run and fixes each.

Why the path ODM sees is not the path you typed

The container has its own filesystem namespace. ODM normally runs inside a container with the project directory bind-mounted at a different path. --project-path /srv/surveys names a host path that the container has never heard of; what it needs is the mount point. The message is accurate — the container really does see nothing at that path — and misleading, because the path it prints looks like yours.

The subdirectory name is fixed. ODM looks for a directory called exactly images inside the project. imgs, IMAGES, photos and Images all fail, and on a case-insensitive filesystem the last one works locally and fails in a Linux container, which is the most confusing variant of all.

The extension filter is narrow. The supported set covers the common photographic extensions, and it is case-sensitive in practice on Linux. A card written with .jpeg where the pipeline expects .JPG, or a TIFF conversion that produced .tif where .tiff was assumed, yields a directory full of files and zero supported images.

Permissions. A container running as a different user than the one that owns the files may be able to see the directory and not read its contents, which surfaces as zero supported images rather than as a permission error.

Four reasons a full directory reads as empty Four panels, each showing a directory that exists on the host and appears empty to ODM. The first shows a host path passed to a container that has the directory mounted elsewhere, so the path resolves to nothing. The second shows the images subdirectory misnamed, which fails on a case-sensitive filesystem even when it works locally. The third shows files whose extensions are outside the supported set, so the directory is populated and the supported count is zero. The fourth shows a permission mismatch where the container user can list the directory but not read the files. Each panel names the single command that confirms it. host path passed in --project-path names a host directory the container mounted it somewhere else confirm with docker run … ls -la the mount point by far the most common subdirectory misnamed the name must be exactly "images" works on a case-insensitive host, fails in the container confirm with ls the project root and compare byte-wise the confusing one extensions unsupported files present, supported count is zero case matters on Linux: .jpeg vs .JPG confirm with a suffix histogram of the directory common after a conversion permissions directory listable, files unreadable surfaces as zero images, not as a permission error confirm with head -c1 one file as the container user after a --user change

Figure 1 — Four causes, one message. Each has a one-line confirmation, and running all four takes less time than reading the ODM log.

Minimal reproducible solution

The diagnostic runs inside the same container with the same mount arguments, which is what makes it authoritative. Anything checked on the host is checking a different filesystem view.

import subprocess
from pathlib import Path

SUPPORTED = {".jpg", ".jpeg", ".png", ".tif", ".tiff"}


def diagnose(host_project: Path, container_root: str, image: str) -> None:
    """Run the checks from inside the container, with the real mount."""
    probe = (
        f'echo "--- project root ---"; ls -la {container_root} || true; '
        f'echo "--- images dir ---"; ls -la {container_root}/images | head -5 || true; '
        f'echo "--- suffix histogram ---"; '
        f'ls {container_root}/images 2>/dev/null '
        f'| sed -n "s/.*\\(\\.[^.]*\\)$/\\1/p" | sort | uniq -c; '
        f'echo "--- readable? ---"; '
        f'head -c 2 "$(ls -d {container_root}/images/* 2>/dev/null | head -1)" '
        f'>/dev/null && echo readable || echo NOT-READABLE'
    )
    subprocess.run(
        ["docker", "run", "--rm",
         "-v", f"{host_project}:{container_root}",
         "--user", f"{os_uid()}:{os_gid()}",
         "--entrypoint", "/bin/sh", image, "-c", probe],
        check=False,
    )


def os_uid() -> int:
    import os
    return os.getuid()


def os_gid() -> int:
    import os
    return os.getgid()

The output identifies the cause without ambiguity. An empty project-root listing is the mount; a listing without an images entry is the name; a suffix histogram with no supported extension is the filter; NOT-READABLE is permissions.

With the cause known, the invocation that works looks like this — note that the path handed to ODM is the container path, and the project name is a directory beneath it:

def run_odm(host_project: Path, image: str, project_name: str) -> None:
    subprocess.run(
        ["docker", "run", "--rm",
         "-v", f"{host_project.parent}:/datasets",     # mount the PARENT
         "--user", f"{os_uid()}:{os_gid()}",           # outputs stay yours
         image,
         "--project-path", "/datasets",                 # container path
         project_name],                                 # subdirectory name
        check=True,
    )

Mounting the parent rather than the project itself is the arrangement ODM expects: --project-path names a directory containing projects, and the final positional argument names one of them. Passing the project directory as --project-path and omitting the name is the second most common form of this bug.

Edge-case matrix

Input variant Symptom Handling
Host path as --project-path Zero images, path looks right Pass the container mount point
Project mounted instead of its parent Zero images Mount the parent, name the project
Images/ on a macOS host Works locally, fails in the container Rename to lowercase images
.jpeg where .JPG expected Files present, none supported Normalise suffixes at ingest
Symlinked images directory Empty inside the container Mount the target, not the link
Files owned by root after a prior run Unreadable, or undeletable Run with --user, fix ownership once
Nested images/images Zero at the outer level Flatten; ODM does not recurse
Hidden macOS resource files Counted, then rejected Filter ._* at ingest

The symlink row catches people with otherwise careful setups: a bind mount resolves the link on the host side and the target is not mounted, so the container sees a dangling link and an empty directory.

Mount the parent, name the project Two mount arrangements compared. In the wrong one, the project directory itself is mounted at the container's datasets path and no project name is given, so ODM looks for an images directory one level too high and finds nothing. In the right one, the parent of the project is mounted at datasets and the project name is supplied as a positional argument, so ODM resolves the images directory correctly. A note observes that both invocations are syntactically valid and only one of them can find the imagery. project mounted directly -v /srv/site-42:/datasets --project-path /datasets (no project name) looks for /datasets/<name>/images actual layout inside: /datasets/images one level too high — zero images parent mounted -v /srv:/datasets --project-path /datasets site-42 looks for /datasets/site-42/images actual layout inside: /datasets/site-42/images match — 640 images found Both commands are valid; only one resolves to the imagery. The error message names the path it searched, which is why it reads as correct — it is describing the container's view, not yours.

Figure 2 — The arrangement ODM expects. --project-path names a directory of projects; the positional argument names one, and the imagery lives one level below that.

Verification snippet

Rather than diagnosing after a failure, assert the layout before launching. The check costs milliseconds and it is the same check ODM will make.

from pathlib import Path

SUPPORTED = {".jpg", ".jpeg", ".png", ".tif", ".tiff"}


def assert_project_ready(project_dir: Path, min_images: int = 20) -> None:
    """Everything ODM requires, checked on the host before the container runs."""
    assert project_dir.is_dir(), f"project directory missing: {project_dir}"

    images = project_dir / "images"
    # Exact-name check that survives a case-insensitive host filesystem.
    entries = {p.name for p in project_dir.iterdir()}
    assert "images" in entries, (
        f"no directory named exactly 'images' in {project_dir} — found {sorted(entries)}")
    assert images.is_dir(), f"'images' exists but is not a directory"

    files = [p for p in images.iterdir() if p.is_file() and not p.name.startswith("._")]
    supported = [p for p in files if p.suffix.lower() in SUPPORTED]
    assert supported, (
        f"{len(files)} files, none with a supported extension — "
        f"suffixes present: {sorted({p.suffix for p in files})}")
    assert len(supported) >= min_images, (
        f"only {len(supported)} supported images; ODM needs a workable block")

    with supported[0].open("rb") as fh:      # readability, not just presence
        assert fh.read(2), f"cannot read {supported[0]}"

Comparing against the directory listing rather than calling images.is_dir() alone is what makes the case check work: on macOS (project_dir / "images").is_dir() returns True for a directory actually named Images, and the container will not.

One organisational habit removes this failure class entirely: never construct the container invocation by hand at the call site. A single function that takes a host project path and derives the mount, the container path and the project name from it cannot produce a mismatched pair, because there is only one place the derivation happens. Every occurrence of this bug I have seen came from a second code path — a debugging one-liner, a colleague’s script, a scheduled job written months later — that reimplemented the mapping and got it subtly different from the working one.

When to escalate

  • The layout asserts clean and ODM still reports zero. The container is mounting something other than what you think — most often because a relative host path was resolved against a different working directory. Print the absolute path you are mounting and check it against docker inspect on the running container.
  • It works interactively and fails from the scheduler. The scheduler runs as a different user, so this is the permissions row; run the container with an explicit --user matching the file owner rather than adjusting file modes, which the orchestration layer should be doing anyway.
  • Images are found and immediately rejected as unsupported. That is a different failure: the files are readable and not decodable, usually a truncated download or a conversion that wrote a header and no data. The repair is in repairing corrupt or truncated EXIF headers.

Setting Up OpenDroneMap with Python

Suffix histogram of a directory that reports zero supported images A bar chart of file counts by extension for a project images directory. Six hundred and forty files carry a lowercase dot-jpeg extension, twelve carry a macOS resource-fork prefix, and four are text files. None carries an extension in the supported set, so the supported count is zero while the directory clearly contains images. A note observes that the histogram distinguishes this case from a mount problem instantly, because a mount problem shows an empty histogram rather than a populated one. .jpeg ._DS (resource) .txt 640 12 4 supported extensions present: none A populated histogram with no supported suffix is the extension case; an empty histogram is the mount case.

Figure 3 — The histogram that separates two of the four causes in one glance. A directory full of .jpeg files is not a mount problem, and a mount problem never produces a histogram at all.