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.
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.
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 inspecton 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
--usermatching 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.
Related
- Setting up OpenDroneMap with Python
- Resolving PyODM connection refused to NodeODM
- Structuring drone imagery for batch processing
← Setting Up OpenDroneMap with Python
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.