diff --git a/CMakeLists.txt b/CMakeLists.txt
index 43877941d5464c35f5ef1c0fa15dad60b2f81ddb..cd1041f6ef30396fb5e856b9468091c13532932a 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -139,6 +139,7 @@ add_executable(${PROJECT_NAME}_dummy_pickplace_provider src/dummy_pickplace_prov
 add_executable(${PROJECT_NAME}_dummy_sorting_controller src/dummy_sorting_controller.cpp ${PROTO_SRCS} ${PROTO_HDRS})
 add_executable(${PROJECT_NAME}_main_controller src/main_controller.cpp ${PROTO_SRCS} ${PROTO_HDRS} src/cell.cpp src/action.cpp src/task.cpp src/cell_controller.cpp include/cell_controller.h)
 add_executable(${PROJECT_NAME}_moveit_sorting_controller src/moveit_sorting_controller.cpp ${PROTO_SRCS} ${PROTO_HDRS})
+add_executable(${PROJECT_NAME}_object_locator src/object_locator.cpp ${PROTO_SRCS} ${PROTO_HDRS})
 
 
 ## Rename C++ executable without prefix
@@ -150,6 +151,7 @@ set_target_properties(${PROJECT_NAME}_dummy_pickplace_provider PROPERTIES OUTPUT
 set_target_properties(${PROJECT_NAME}_dummy_sorting_controller PROPERTIES OUTPUT_NAME dummy_sorting_controller PREFIX "")
 set_target_properties(${PROJECT_NAME}_main_controller PROPERTIES OUTPUT_NAME main_controller PREFIX "")
 set_target_properties(${PROJECT_NAME}_moveit_sorting_controller PROPERTIES OUTPUT_NAME moveit_sorting_controller PREFIX "")
+set_target_properties(${PROJECT_NAME}_object_locator PROPERTIES OUTPUT_NAME object_locator PREFIX "")
 
 ## Add cmake target dependencies of the executable
 ## same as for the library above
@@ -158,6 +160,7 @@ add_dependencies(${PROJECT_NAME}_dummy_pickplace_provider ${${PROJECT_NAME}_EXPO
 add_dependencies(${PROJECT_NAME}_dummy_sorting_controller ${${PROJECT_NAME}_EXPORTED_TARGETS} ${catkin_EXPORTED_TARGETS})
 add_dependencies(${PROJECT_NAME}_main_controller ${${PROJECT_NAME}_EXPORTED_TARGETS} ${catkin_EXPORTED_TARGETS})
 add_dependencies(${PROJECT_NAME}_moveit_sorting_controller ${${PROJECT_NAME}_EXPORTED_TARGETS} ${catkin_EXPORTED_TARGETS})
+add_dependencies(${PROJECT_NAME}_object_locator ${${PROJECT_NAME}_EXPORTED_TARGETS} ${catkin_EXPORTED_TARGETS})
 
 ## Specify libraries to link a library or executable target against
 target_link_libraries(${PROJECT_NAME}_dummy_sorting_controller
@@ -176,7 +179,9 @@ target_link_libraries(${PROJECT_NAME}_dummy_selection_provider
 target_link_libraries(${PROJECT_NAME}_dummy_pickplace_provider
         ${catkin_LIBRARIES}
         )
-
+target_link_libraries(${PROJECT_NAME}_object_locator
+        ${catkin_LIBRARIES}
+        )
 
 #############
 ## Install ##
diff --git a/launch/noop-sim-cell_ceti-table-1-emptyworld.launch b/launch/noop-sim-cell_ceti-table-1-emptyworld.launch
new file mode 100644
index 0000000000000000000000000000000000000000..e2f3c349a50116cada51a2f43856672c7a7ee223
--- /dev/null
+++ b/launch/noop-sim-cell_ceti-table-1-emptyworld.launch
@@ -0,0 +1,17 @@
+<launch>
+
+    <!-- MQTT server for communication with client cells -->
+    <arg name="mqtt_server" default="tcp://localhost:1883" doc="address of the mqtt server for the connection to the controller"/>
+
+    <include file="$(find ccf)/launch/noop-sim_setup.launch"/>
+
+    <param name="/connector_node_ros_ccf/topics/command" value="/ceti_cell_placeworld/command"/>
+
+    <node pkg="ccf_immersive_sorting" type="moveit_sorting_controller" name="ceti_cell_empty" output="screen">
+        <param name="arm" type="string" value="arm"/>
+        <param name="scene_observer" type="string" value="object_locator/scene/update"/>
+        <param name="mqtt_server" type="yaml" value="$(arg mqtt_server)"/>
+        <param name="scene" type="string" value="$(find ccf_immersive_sorting)/config/config_scene_ceti-table-1-empty.json"/>
+    </node>
+
+</launch>
diff --git a/launch/object_locator.launch b/launch/object_locator.launch
new file mode 100644
index 0000000000000000000000000000000000000000..6ef056b5303160e623aff6c62bf535393ed7f6bb
--- /dev/null
+++ b/launch/object_locator.launch
@@ -0,0 +1,28 @@
+<launch>
+  <arg name="launch_prefix" default="" /> <!-- set to value="gdbserver localhost:10000" for remote debugging -->
+  <arg name="node_namespace" default="apriltag_ros_continuous_node" />
+  <arg name="camera_name" default="/usb_cam" />
+  <arg name="image_topic" default="image_rect" />
+
+  <!-- Set parameters -->
+  <rosparam command="load" file="$(find ccf_immersive_sorting)/config/settings.yaml" ns="$(arg node_namespace)" />
+  <rosparam command="load" file="$(find ccf_immersive_sorting)/config/tags.yaml" ns="$(arg node_namespace)" />
+
+  <node name="usb_cam" pkg="usb_cam" type="usb_cam_node" output="log">
+    <param name="video_device" value="/dev/video2" />
+    <param name="image_width" value="2560" />
+    <param name="image_height" value="1440" />
+    <param name="autofocus" value="0" />
+  </node> 
+
+
+  <node name="image_proc" pkg="image_proc" type="image_proc" ns="usb_cam"/>
+ 
+  <node pkg="apriltag_ros" type="apriltag_ros_continuous_node" name="$(arg node_namespace)" clear_params="true" output="screen" launch-prefix="$(arg launch_prefix)" >
+    <!-- Remap topics from those used in code to those on the ROS network -->
+    <remap from="image_rect" to="$(arg camera_name)/$(arg image_topic)" />
+    <remap from="camera_info" to="$(arg camera_name)/camera_info" />
+
+    <param name="publish_tag_detections_image" type="bool" value="true" />      <!-- default: false -->
+  </node>
+</launch>
diff --git a/src/object_locator.cpp b/src/object_locator.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..ce893025183cb5f438563f48b6536825cad6fc2e
--- /dev/null
+++ b/src/object_locator.cpp
@@ -0,0 +1,218 @@
+/*! \file object_locator.cpp
+
+    A ROS node that detects objects
+
+    \author Johannes Mey
+    \date 15.06.22
+*/
+
+
+#include <ros/ros.h>
+#include <ros/package.h>
+#include <apriltag_ros/AprilTagDetectionArray.h>
+
+#include "std_msgs/String.h"
+
+#include "tf2/LinearMath/Transform.h"
+
+#include "ccf/util/NodeUtil.h"
+
+#include "connector.pb.h"
+#include "google/protobuf/text_format.h"
+
+#include <google/protobuf/util/json_util.h>
+#include <google/protobuf/message.h>
+#include <ccf/controller/DummyRobotArmController.h>
+#include <ccf/connection/MqttConnection.h>
+
+using CetiRosToolbox::getParameter;
+using CetiRosToolbox::getPrivateParameter;
+
+const std::string CELL_BUNDLE = "CETI_TABLE_ONE";
+const double TABLE_HEIGHT = 0.89;
+const double GRID_SIZE = 0.05;
+
+geometry_msgs::Point closestGridPoint(geometry_msgs::Point point, double grid_size)
+{
+  geometry_msgs::Point grid_point;
+  grid_point.x = (std::round(point.x / grid_size)) * grid_size;
+  grid_point.y = (std::round(point.y / grid_size)) * grid_size;
+  grid_point.z = point.z;
+  return grid_point;
+}
+
+int main(int argc, char** argv)
+{
+  GOOGLE_PROTOBUF_VERIFY_VERSION;
+
+  ros::init(argc, argv, "object_locator");
+
+  ros::NodeHandle n("ccf");
+
+  ros::Rate loop_rate(200);
+  ros::Rate pause_rate(ros::Duration(2));  // seconds
+
+  pause_rate.sleep();
+
+  DummyRobotArmController controller(n, "object_locator", "nobot");
+
+  auto mqttServer = getPrivateParameter<std::string>("mqtt_server", "localhost:1883");
+  std::unique_ptr<MqttConnection> mqtt_connection =
+      std::make_unique<MqttConnection>(mqttServer, ros::this_node::getName());
+  mqtt_connection->listen(getParameter<std::string>(n, "topics/selection", "selection"));
+  auto other_cell_scene_listener_topic = getPrivateParameter<std::string>("other_cell_listener", "/ceti_cell_empty/scene/update");
+  auto other_cell_delta_update_topic = getPrivateParameter<std::string>("other_cell_delta_updates", "object_locator/scene/update");
+  mqtt_connection->listen(other_cell_scene_listener_topic);
+  controller.addConnection(std::move(mqtt_connection));
+
+  // subscribe to a scene
+  controller.addCallback(other_cell_scene_listener_topic, [&controller](auto msg) {
+    ROS_INFO_STREAM("Updating scene from other cell.");
+    Scene scene;
+    scene.ParseFromString(msg);
+    controller.initScene(scene, false);
+  });
+
+  controller.initScene({}, false);
+
+  Scene objectLibrary = RobotArmController::loadSceneFromFile(getPrivateParameter<std::string>("object_library", ros::package::getPath("ccf_immersive_sorting") +
+                                                                      "/config/objects.json"));
+
+  ROS_ERROR_STREAM("object library contains " << objectLibrary.objects_size() << " objects");
+
+  std::map<std::string, geometry_msgs::Pose> located_poses;
+
+  // read the bundles and extract the base pose
+  std::map<int, std::string> tagBundleTags;
+  auto tagBundles = CetiRosToolbox::getParameter<XmlRpc::XmlRpcValue>(n, "/apriltag_ros_continuous_node/tag_bundles");
+  for (int bundle_index = 0; bundle_index < tagBundles.size(); bundle_index++)
+  {
+    auto& bundle = tagBundles[bundle_index];
+    for (int tag_index = 0; tag_index < bundle["layout"].size(); tag_index++)
+    {
+      auto& tag = bundle["layout"][tag_index];
+      tagBundleTags[tag["id"]] = std::string(bundle["name"]);
+    }
+  }
+
+  ros::Subscriber sub_tages = n.subscribe<apriltag_ros::AprilTagDetectionArray>(
+      getPrivateParameter<std::string>("tag_topic", "/tag_detections"), 1000, [&](auto& m) {
+        tf2::Transform base_pose;  // pose of the origin relative to the camera
+        std::map<std::string, geometry_msgs::Pose> allObjectPoses;
+
+        // check if the base frame was detected
+        bool base_tag_found = false;
+        for (const auto& detection : m->detections)
+        {
+          if (tagBundleTags[detection.id[0]] == CELL_BUNDLE)
+          {  // a base tag was found if an id of the cell bundle was found
+            base_tag_found = true;
+            base_pose.setOrigin({ detection.pose.pose.pose.position.x, detection.pose.pose.pose.position.y,
+                                  detection.pose.pose.pose.position.z });
+            base_pose.setRotation({ detection.pose.pose.pose.orientation.x, detection.pose.pose.pose.orientation.y,
+                                    detection.pose.pose.pose.orientation.z, detection.pose.pose.pose.orientation.w });
+          }
+          else
+          {
+            allObjectPoses[tagBundleTags[detection.id[0]]] = detection.pose.pose.pose;
+          }
+        }
+        if (!base_tag_found)
+        {
+          ROS_WARN_STREAM("Skipping tag detection because no base tag was found");
+          return;
+        }
+
+        // make pose relative to origin instead of camera
+        for (auto& [name, pose] : allObjectPoses)
+        {
+          tf2::Transform object({ pose.orientation.x, pose.orientation.y, pose.orientation.z, pose.orientation.w },
+                                { pose.position.x, pose.position.y, pose.position.z });
+          tf2::Transform result = base_pose.inverseTimes(object);
+          pose.position.x = result.getOrigin().x();
+          pose.position.y = result.getOrigin().y();
+          pose.position.z = result.getOrigin().z();
+          pose.orientation.x = result.getRotation().x();
+          pose.orientation.y = result.getRotation().y();
+          pose.orientation.z = result.getRotation().z();
+          pose.orientation.w = result.getRotation().w();
+        }
+
+        // create a delta scene to send later (if not empty)
+        Scene updateScene;
+        updateScene.set_is_delta(true);
+
+        // filter object poses
+        std::map<std::string, geometry_msgs::Pose> relevant_poses;
+        for (const auto& [name, pose] : allObjectPoses)
+        {
+          tf2::Quaternion q{ pose.orientation.x, pose.orientation.y, pose.orientation.z, pose.orientation.w };
+          std::cout.precision(3);
+
+          if (controller.resolveObject(name)) {
+            ROS_DEBUG_STREAM("skipping pose for " << name << " the object is already in the scene.");
+            continue;
+          }
+          if (pose.orientation.w < 0.99)
+          {
+            ROS_WARN_STREAM("skipping pose for " << name << " because the object is rotated " << pose.orientation << " "
+                                                 << q.getAxis() << " " << q.angleShortestPath({ 0, 0, 0, 1 }));
+            continue;
+          }
+          if (std::abs(pose.position.z - TABLE_HEIGHT) > 0.02)
+          {
+            ROS_WARN_STREAM("skipping pose for " << name << " because the object is not on table level "
+                                                 << pose.position.z - TABLE_HEIGHT);
+            continue;
+          }
+
+          // sanitize pose
+          relevant_poses[name] = pose;
+          relevant_poses[name].position.z = TABLE_HEIGHT;
+          relevant_poses[name].position = closestGridPoint(relevant_poses[name].position, GRID_SIZE);
+          relevant_poses[name].orientation.x = 0;
+          relevant_poses[name].orientation.y = 0;
+          relevant_poses[name].orientation.z = 0;
+          relevant_poses[name].orientation.w = 1;
+
+          if (located_poses[name] == relevant_poses[name]) {
+            ROS_DEBUG_STREAM("found the same pose for " + name + " two times in a row: " << relevant_poses[name].position.x << ", "
+                                                                                         << relevant_poses[name].position.y);
+            auto object = DummyRobotArmController::resolveObject(name, objectLibrary);
+            if (!object) {
+              ROS_WARN_STREAM("skipping pose for " << name << " because the object is not in the object library.");
+              continue;
+            }
+            auto newObject = updateScene.add_objects();
+            *newObject = *object;
+            newObject->mutable_pos()->set_x(located_poses[name].position.x);
+            newObject->mutable_pos()->set_y(located_poses[name].position.y);
+            newObject->mutable_pos()->set_z(located_poses[name].position.z + newObject->size().height()/2);
+            newObject->set_mode(Object_Mode_MODE_ADD);
+            newObject->set_state(Object_State_STATE_STATIONARY);
+          }
+        }
+
+        if (updateScene.objects_size() > 0) {
+          ROS_ERROR_STREAM("Sending scene with " << updateScene.objects_size() << " newly recognized objects.");
+          controller.sendToAll(other_cell_delta_update_topic, updateScene.SerializeAsString());
+        }
+        located_poses = relevant_poses;
+
+      });
+
+  // receive a tag position
+  // if no "base tag" is contained, abort
+  // if the contained objects are already in the robot scene, abort (we only detect new objects)
+  // if the object tag is not in the base scene, abort
+  // find the drop-of location in the scene that is closest to the
+
+  while (ros::ok())
+  {
+    ros::spinOnce();
+
+    loop_rate.sleep();
+  }
+
+  return 0;
+}
\ No newline at end of file