System designTroubleshooting

CascadeClassifier load returns empty: working directory fix

CascadeClassifier load returns empty=1 when the XML path resolves against the working directory. Print it, pass an absolute path, and check the return value.

LumaSpec Vision8 min read Share on LinkedIn

On this page 8 sections
  1. 01What empty() == 1 is telling you
  2. 02The cause: a relative name resolves against the working directory
  3. 03Diagnose it in one run
  4. 04Fix: anchor the path so the launch cannot move it
  5. 05When exists is 1 and load still returns false
  6. 06Prevention: make a bad path stop the program, not the shift
  7. 07Around a vision cell: the same rule on the line PC
  8. 08Questions

A CascadeClassifier that prints empty() == 1 right after load() has almost always failed to open its XML, and the reason is usually not the library but the path. A relative file name is resolved against the working directory of the process, and that directory changes with the way you start the program: the same exe finds haarcascade_frontalface_default.xml when the IDE launches it and loses it when you double-click the exe, a scheduler starts it or a service runs it. Here you get the one-run diagnosis, the three fixes that make the path independent of the launch, and the checks that keep it working on the line.

What empty() == 1 is telling you

The two calls have documented meanings. CascadeClassifier::load(const String& filename) reads a classifier from a file and returns bool: true when the file opened and parsed into the classifier, false when it did not. CascadeClassifier::empty() returns true while no classifier is loaded. The pair you are seeing — load() followed by empty() == 1 — is one statement: the file that process tried to open never opened.

A missing file does not interrupt the program either. load() reports failure through its return value and lets execution continue, which is why the bug surfaces as a silent empty() == 1 at the first detectMultiScale() call instead of a crash at startup. Decide now that load()'s return value is the guard: false means stop and print the path you tried. Everything below is about making that path the right one.

The cause: a relative name resolves against the working directory

When load() receives a bare name such as "haarcascade_frontalface_default.xml", the operating system resolves it as working directory plus name. Nothing searches your source tree, your project folder or PATH; the resolution is plain concatenation:

resolved path = current working directory + separator + relative file name

The working directory belongs to the process launch, not to the exe or the source file. The paths in the table are illustrative; substitute your own project layout, the pattern is what matters.

Launch Working directory The bare name resolves to File found
F5 in the IDE C:\vision\proj C:\vision\proj\haarcascade_frontalface_default.xml yes
Double-click the exe in x64\Debug C:\vision\proj\x64\Debug C:\vision\proj\x64\Debug\haarcascade_frontalface_default.xml no
Terminal sitting in another folder wherever the shell is that folder plus the name usually no
Service or scheduled task C:\Windows\system32 C:\Windows\system32\haarcascade_frontalface_default.xml no

The IDE row follows the debugger's default working directory, the project folder; the double-click row follows the exe's own folder. Same code, same exe, opposite results, because the launch decided the directory before your first line ran.

Diagnose it in one run

Print three things before you touch the detector, then let load() speak through its bool:

  1. Print fs::current_path() — the working directory this launch received.
  2. Print fs::absolute(name) — exactly what load() will try to open.
  3. Print fs::exists(name) — this splits path problems from file problems.
  4. Call cascade.load() into a bool — never discard the return value.
  5. Print cascade.empty() — 0 confirms a usable classifier.
#include <filesystem>
#include <iostream>
#include <opencv2/objdetect.hpp>

int main()
{
    namespace fs = std::filesystem;

    // Illustrative variable: the bare file name your code uses today.
    const std::string cascadeName = "haarcascade_frontalface_default.xml";

    std::cout << "working dir: " << fs::current_path().string() << '\n'
              << "resolves to: " << fs::absolute(cascadeName).string() << '\n'
              << "exists:      " << fs::exists(cascadeName) << '\n';

    cv::CascadeClassifier cascade;                  // starts empty
    const bool loaded = cascade.load(cascadeName);  // documented bool return
    std::cout << "load: " << loaded
              << ", empty(): " << cascade.empty() << '\n';
    return loaded ? 0 : 1;
}

Build with C++17 enabled, because std::filesystem needs it. Run 1 is the failing launch, the exe double-clicked in x64\Debug with the XML one level up:

working dir: C:\vision\proj\x64\Debug
resolves to: C:\vision\proj\x64\Debug\haarcascade_frontalface_default.xml
exists:      0
load: 0, empty(): 1

Run 2 is the same program after the fix below hands load() an absolute path:

working dir: C:\vision\proj\x64\Debug
resolves to: C:\vision\data\haarcascade_frontalface_default.xml
exists:      1
load: 1, empty(): 0

The working directory never changed; the anchored path made the result independent of it. The exists number sorts every case: 0 means fix the anchor, 1 with load 0 means the file itself is the problem.

Fix: anchor the path so the launch cannot move it

Pick one anchor and use it for every model file.

  1. An absolute path from configuration. Line PCs are stable: put the cascade in one fixed place, for example C:\vision\data, and read that location at startup from your own settings file, the command line or an environment variable. One config entry per machine beats a compile-time constant buried in a binary.

  2. The exe's own folder. Resolve the file relative to the executable, which travels with the deployment. On Windows, GetModuleFileNameW returns the path of the running exe; on Linux, read the /proc/self/exe symlink with std::filesystem::read_symlink. Take parent_path() and append your data folder:

#include <cstdlib>
#include <filesystem>
#include <iostream>
#include <windows.h>

std::filesystem::path exe_dir()                    // folder the exe lives in
{
    wchar_t buf[MAX_PATH];                         // MAX_PATH is 260
    const DWORD len = GetModuleFileNameW(nullptr, buf, MAX_PATH);
    return std::filesystem::path(std::wstring(buf, len)).parent_path();
}

cv::CascadeClassifier load_cascade(const std::filesystem::path& file)
{
    cv::CascadeClassifier cascade;
    if (!std::filesystem::exists(file) || !cascade.load(file.string()))
    {
        std::cerr << "cascade unavailable: " << file.string() << '\n';
        std::exit(1);                              // fail at startup
    }
    return cascade;
}

// at startup:
// cv::CascadeClassifier faces = load_cascade(
//     exe_dir() / "data" / "haarcascade_frontalface_default.xml");
  1. Copy the file next to the exe at build time. A post-build copy step puts the XML into the output folder with the exe; in .NET projects the same job is the file's "Copy to Output Directory" property set to "Copy if newer". Combined with anchor 2, the bare-name trap disappears: the exe folder is where the file is, whatever the launch did to the working directory.

std::filesystem::path keeps the separators right between Windows and Linux builds. For the folder-shaped mistakes that survive in string handling and scripts, the guide on image file path errors across operating systems is the companion read.

When exists is 1 and load still returns false

An existing file that FileStorage rejects is a different fault with three shapes, in the order to check them:

  1. The file is not a cascade. A copy saved from a browser tab is an HTML page. Open it in a text editor and read the first lines: a real cascade is XML under an opencv_storage element, with a type_id of opencv-haar-classifier. Ten seconds, and the question is settled.

  2. This OpenCV install has no cascade files. From OpenCV 5 the Haar cascades count as legacy and moved to the opencv_contrib package, so a plain main-package install can contain none of them. Take the XML from the opencv_contrib data folder and ship it with your program instead of reading it out of an installed SDK tree.

  3. A sandbox hides the file. Packaged runtimes mount their own view of storage: the same failure on an embedded camera arrives as an ENOENT from the loader, and the fix is the documented deployment path — see Haar cascade file not found on an embedded camera. Browser and Node builds add one more twist: OpenCV.js under Node cannot see your disk until the XML is mounted into its virtual filesystem, which the OpenCV.js cascade load guide walks through.

If a verified, valid XML still fails in every path form, run the same file against a plain CPU build of the library before touching your code. A build with extra modules enabled is the remaining variable, and that test isolates it.

Prevention: make a bad path stop the program, not the shift

The guard function above already fails fast. Wrap every model load in it and keep the discipline with a short pre-ship checklist:

  • Print the resolved absolute cascade path at startup — one log line answers "which file did it actually open" from any machine.
  • Assert load() returned true and empty() is 0 before the camera starts — the failure becomes a startup message instead of a midnight call about missed detections.
  • Deploy the XML with your installer and version it with the code — a cascade quietly replaced on one PC cannot drift from the rest.
  • Anchor every file the process reads the same way — calibration, class lists and exported settings all resolve against the same working directory.

Around a vision cell: the same rule on the line PC

A desktop inspection program that pulls frames from area scan cameras over GigE or USB3 usually starts from an autostart entry, a shortcut or a scheduled task on the line PC — exactly the launches that rewrite the working directory. Anchor model and calibration files first, then bring the camera up; the same rule covers saving and loading a camera settings file. If the inspection later moves on-device to smart cameras, the model lives in the device's own storage and its SDK documents how to address it; the print, exists, load discipline stays with your PC-side code either way.

Anchor the path, check the bool, and load() returns 1 on every launch. When you are sizing the hardware side of the same station, the camera finder and the camera configurator are the starting points; the contact page handles quote questions. More system-side fixes sit on the systems topic page.

Questions

Does CascadeClassifier load throw when the XML file is missing?

No. For a missing file, load() returns false and empty() stays true, and your program carries on — that silence is why you meet the bug as empty()==1 instead of a startup crash. exists() proves the file is visible, not valid; if load() still returns false with the file present, inspect the file content itself before blaming the build.

Where does OpenCV put the Haar cascade XML files?

In OpenCV 4.x the cascades ship with the source under its data/haarcascades folder, and the Python bindings expose the installed location through cv2.data.haarcascades. Copy the XML you need into your project instead of reaching into the installed tree at runtime.

Why does the cascade load by hand but not from the task scheduler?

A scheduled task inherits a system folder as its working directory, not your user profile, and it runs under a different account. Two extra traps follow: mapped drive letters such as Z:\ are restored per user, so the task sees no Z:\ at all, and a relative path resolves into the system folder. Give the scheduler an absolute path in UNC form, such as \\server\share\cascades\face.xml, or a local path.

What is the Python equivalent of this fix?

Print os.getcwd() first. Then anchor the file to the script folder: base = Path(__file__).resolve().parent, and cascade = cv2.CascadeClassifier(str(base / 'haarcascade_frontalface_default.xml')). The class exposes the same empty() check in Python, so read it right after loading and stop if it is True. Anchoring to __file__ removes the dependence on wherever the interpreter was started from, which is exactly what an IDE, a notebook and a terminal set differently.

Does the working-directory trap affect other files my program loads?

Yes. Any call that opens a file by a relative name — cascade XMLs, calibration files, YOLO class lists, a camera settings file you exported earlier — resolves against the process working directory at the moment of the call. The remedy is identical: anchor each file to an absolute path at startup and assert it exists before the first frame, not after an operator reports missed detections.