diff --git a/include/libcamera/camera_manager.h b/include/libcamera/camera_manager.h
index 27835500f781..5e0b95ef1aab 100644
--- a/include/libcamera/camera_manager.h
+++ b/include/libcamera/camera_manager.h
@@ -20,6 +20,7 @@
 namespace libcamera {
 
 class Camera;
+class CameraDescriptor;
 
 class CameraManager : public Object, public Extensible
 {
@@ -31,6 +32,8 @@ public:
 	int start();
 	void stop();
 
+	std::vector<std::shared_ptr<CameraDescriptor>> enumerate();
+
 	std::vector<std::shared_ptr<Camera>> cameras() const;
 	std::shared_ptr<Camera> get(std::string_view id);
 
diff --git a/include/libcamera/internal/camera_manager.h b/include/libcamera/internal/camera_manager.h
index ed90d1d471a0..10e7797c4a29 100644
--- a/include/libcamera/internal/camera_manager.h
+++ b/include/libcamera/internal/camera_manager.h
@@ -25,6 +25,7 @@
 namespace libcamera {
 
 class Camera;
+class CameraDescriptor;
 class DeviceEnumerator;
 class IPAManager;
 class MediaDevice;
@@ -39,6 +40,7 @@ public:
 	Private();
 
 	int start();
+	std::vector<std::shared_ptr<CameraDescriptor>> enumerate();
 	void addCamera(std::shared_ptr<Camera> camera) LIBCAMERA_TSA_EXCLUDES(mutex_);
 	void removeCamera(std::shared_ptr<Camera> camera) LIBCAMERA_TSA_EXCLUDES(mutex_);
 
@@ -59,6 +61,11 @@ private:
 	void createPipelineHandlers();
 	void pipelineFactoryMatch(const PipelineHandlerFactoryBase *factory);
 	std::shared_ptr<PipelineHandler> findMatchingHandler(const MediaDevice *media);
+
+	std::vector<std::shared_ptr<CameraDescriptor>> surveyThread() LIBCAMERA_TSA_EXCLUDES(mutex_);
+	void surveyFactory(const PipelineHandlerFactoryBase *factory) LIBCAMERA_TSA_EXCLUDES(mutex_);
+	std::shared_ptr<Camera> initializeThread(std::shared_ptr<CameraDescriptor> descriptor);
+
 	void cleanup() LIBCAMERA_TSA_EXCLUDES(mutex_);
 
 	/*
@@ -75,6 +82,8 @@ private:
 	int status_ LIBCAMERA_TSA_GUARDED_BY(mutex_);
 
 	bool started_ LIBCAMERA_TSA_GUARDED_BY(mutex_) = false;
+	/* Known camera descriptors, accessed from the CameraManager thread only. */
+	std::vector<std::shared_ptr<CameraDescriptor>> descriptors_;
 
 	std::unique_ptr<DeviceEnumerator> enumerator_;
 
diff --git a/src/libcamera/camera_manager.cpp b/src/libcamera/camera_manager.cpp
index 55532179a61c..5cd0f50de12a 100644
--- a/src/libcamera/camera_manager.cpp
+++ b/src/libcamera/camera_manager.cpp
@@ -7,13 +7,17 @@
 
 #include "libcamera/internal/camera_manager.h"
 
+#include <algorithm>
+
 #include <libcamera/base/log.h>
 #include <libcamera/base/utils.h>
 
 #include <libcamera/camera.h>
+#include <libcamera/camera_descriptor.h>
 #include <libcamera/property_ids.h>
 
 #include "libcamera/internal/camera.h"
+#include "libcamera/internal/camera_descriptor.h"
 #include "libcamera/internal/device_enumerator.h"
 #include "libcamera/internal/global_configuration.h"
 #include "libcamera/internal/ipa_manager.h"
@@ -99,6 +103,19 @@ int CameraManager::Private::startThread()
 	return 0;
 }
 
+/*
+ * Enumerate the cameras in the system without initialising them, starting the
+ * camera manager thread on first use. Called from the application thread.
+ */
+std::vector<std::shared_ptr<CameraDescriptor>> CameraManager::Private::enumerate()
+{
+	int ret = startThread();
+	if (ret)
+		return {};
+
+	return invokeMethod(&Private::surveyThread, ConnectionTypeBlocking);
+}
+
 void CameraManager::Private::run()
 {
 	LOG(Camera, Debug) << "Starting camera manager";
@@ -255,6 +272,62 @@ CameraManager::Private::findMatchingHandler(const MediaDevice *media)
 	return match;
 }
 
+/*
+ * Survey all pipeline handler factories and return the known camera
+ * descriptors. Descriptors are cached because later surveys skip the media
+ * devices in use by live pipeline handler instances, and would otherwise drop
+ * the cameras already initialised. Called on the CM thread.
+ */
+std::vector<std::shared_ptr<CameraDescriptor>> CameraManager::Private::surveyThread()
+{
+	ASSERT(Thread::current() == this);
+
+	for (const PipelineHandlerFactoryBase *factory : pipelineFactories())
+		surveyFactory(factory);
+
+	return descriptors_;
+}
+
+/*
+ * Survey the cameras of a single pipeline handler factory and cache their
+ * descriptors. Called on the CM thread.
+ */
+void CameraManager::Private::surveyFactory(const PipelineHandlerFactoryBase *factory)
+{
+	ASSERT(Thread::current() == this);
+
+	CameraManager *const o = LIBCAMERA_O_PTR();
+
+	std::shared_ptr<PipelineHandler> pipe = factory->create(o);
+	std::vector<std::shared_ptr<CameraDescriptor>> descriptors;
+	int ret = pipe->survey(enumerator_.get(), &descriptors);
+	if (ret == -ENOTSUP) {
+		/* The pipeline handler does not support surveying. */
+		return;
+	} else if (ret < 0) {
+		LOG(Camera, Error)
+			<< "Failed to survey cameras for pipeline handler "
+			<< factory->name() << ": " << strerror(-ret);
+		return;
+	}
+
+	for (std::shared_ptr<CameraDescriptor> &descriptor : descriptors) {
+		/*
+		 * Record the factory that produced the descriptor, so that
+		 * initialize() can create a pipeline handler for it.
+		 */
+		descriptor->_d()->factory_ = factory;
+
+		auto match = [&](const auto &d) {
+			return d->id() == descriptor->id();
+		};
+		if (std::any_of(descriptors_.begin(), descriptors_.end(), match))
+			continue;
+
+		descriptors_.push_back(std::move(descriptor));
+	}
+}
+
 void CameraManager::Private::cleanup()
 {
 	enumerator_->devicesAdded.disconnect(this);
@@ -264,6 +337,8 @@ void CameraManager::Private::cleanup()
 		started_ = false;
 	}
 
+	descriptors_.clear();
+
 	/*
 	 * Release all references to cameras to ensure they all get destroyed
 	 * before the device enumerator deletes the media devices. Cameras are
@@ -457,6 +532,29 @@ void CameraManager::stop()
 	d->wait();
 }
 
+/**
+ * \brief Enumerate the cameras in the system without initialising them
+ *
+ * Enumerate the devices in the system and return a descriptor for every
+ * camera found, without initialising any camera.
+ *
+ * Only cameras of pipeline handlers that support surveying are reported.
+ * Cameras of other pipeline handlers are created by start() and reported by
+ * cameras() as before.
+ *
+ * Descriptors are reported in match order, grouped by pipeline handler.
+ * Descriptors of cameras removed from the system remain listed until the
+ * camera manager is stopped.
+ *
+ * This function starts the camera manager if it is not yet running.
+ *
+ * \return A list of descriptors for the cameras found in the system
+ */
+std::vector<std::shared_ptr<CameraDescriptor>> CameraManager::enumerate()
+{
+	return _d()->enumerate();
+}
+
 /**
  * \fn CameraManager::cameras()
  * \brief Retrieve all available cameras
