// Copyright (c) French Touch Factory. All Rights Reserved. // // Ithaca RGBD preview server — Jetson. // // Serves every camera on this node over one TCP connection: colour as the // sensor's own JPEG, forwarded untouched, and depth as the sensor's own 16-bit // millimetres, losslessly compressed. // // Why not the H.264/WebRTC path it replaces: // // Depth is data, not a picture. Squeezing it into 8 bits for a video codec put // a floor of 19.6 mm on precision before compression even began, and H.264's // smoothing then bled object edges into the background — a geometric error, // not a cosmetic one. Sent as-is it is exact, and LZ4 over separated byte // planes costs 3.35 ms a frame for a 2.95x ratio (measured, 640x576). // // Colour is already JPEG when it leaves the sensor. Decoding it to re-encode // as H.264 spent NVDEC and NVENC, added a generation of loss, and made the two // cameras contend for one decoder — which is where their differing latency // came from. Forwarded as-is it costs nothing. // // Colour and depth of one frameset leave in the same message, under the same // lock. They cannot drift apart. Over WebRTC they were two sessions with two // jitter buffers and no shared clock, and MediaMTX's WHEP serves only one video // track per session, so putting them in one was not available either. // // And nothing here buffers: no rate-smoothing window, no group of pictures, no // jitter buffer. When the link falls behind, whole frames are dropped instead, // which is safe because every frame stands alone. // // The cost of all this is bandwidth — roughly 55 Mbit/s per camera binned — so // it is a local-network path. WebRTC remains the answer for anything leaving the // site. // // Usage: // ithaca_rgbd_server --map :[,:...] // // --map is both the numbering AND the selection: a camera named there is // served under that index, and a camera absent from a NON-EMPTY map is not // streamed. An empty (or missing) map serves every camera, numbered by // enumeration order — what a run by hand relies on. // // --map-file names a file holding that same text, re-read once a second, so the // composition can change WITHOUT restarting: a camera dropped from it has its // streams stopped, one added has them started, and the others keep running // throughout. That is how the operator's per-camera checkbox reaches the sensor // while a preview is live — restarting instead took every camera of the node // down for about ten seconds. // // A SECOND line of that file, if present, is the alignment: "raw" or "compare". // Those two share their stream configuration, so the node can be told to start // or stop producing the SDK's own transform at any moment. The other modes are // fixed for the life of the process, needing a different colour format or a // different target, and are computed by the viewer anyway. // // Stopping the streams is what saves the USB bandwidth, the depth engine and the // heat. The device handle stays open, so turning the camera back on is immediate; // Record does not care, since it stops this server before opening its own // recorders. // [--depth-mode M] [--color-mode M] [--rate N] // [--align raw|d2c|d2c-fov|c2d|compare] [--port P] #include // Not pulled in by ObSensor.hpp, and this is where the ray table lives. #include #include #include // jpeglib.h needs FILE declared before it #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace { std::atomic g_running{true}; void onSignal(int) { g_running = false; } uint64_t nowUs() { return std::chrono::duration_cast( std::chrono::steady_clock::now().time_since_epoch()).count(); } // Interleaved 16-bit samples compress poorly: the high bytes are smooth and // repetitive, the low bytes are mostly sensor noise, and mixed together they // spoil each other's matches. Separated, each compresses on its own terms — // measured 2.95x against 2.27x, and faster with it. void splitPlanes(const uint8_t *src, size_t bytes, std::vector &out) { const size_t n = bytes / 2; out.resize(bytes); for(size_t i = 0; i < n; i++) { out[i] = src[2 * i + 1]; out[n + i] = src[2 * i]; } } // Re-encodes a warped colour frame, which only C2D needs: in every other mode // the colour that arrives is already a JPEG and is forwarded as it is. // // One of these per camera, kept alive across frames — jpeg_create_compress // allocates its tables and its Huffman state, and building them thirty times a // second per camera would cost more than the encode. class JpegEncoder { public: JpegEncoder() { info_.err = jpeg_std_error(&err_); jpeg_create_compress(&info_); } ~JpegEncoder() { jpeg_destroy_compress(&info_); if(buffer_ != nullptr) free(buffer_); } JpegEncoder(const JpegEncoder &) = delete; JpegEncoder &operator=(const JpegEncoder &) = delete; /// Encodes packed 24-bit RGB. Returns false and leaves size at 0 on failure. /// The returned pointer belongs to this encoder and is valid until the next /// call, which is enough: the frame is sent before the next one is encoded. bool encode(const uint8_t *rgb, int width, int height, int quality, const uint8_t *&data, size_t &size) { data = nullptr; size = 0; // jpeg_mem_dest grows this itself and hands back the grown pointer, so it // is kept between calls and settles at the largest frame seen. jpeg_mem_dest(&info_, &buffer_, &capacity_); info_.image_width = static_cast(width); info_.image_height = static_cast(height); info_.input_components = 3; info_.in_color_space = JCS_RGB; jpeg_set_defaults(&info_); jpeg_set_quality(&info_, quality, TRUE); jpeg_start_compress(&info_, TRUE); const int stride = width * 3; while(info_.next_scanline < info_.image_height) { JSAMPROW row = const_cast(rgb + info_.next_scanline * stride); jpeg_write_scanlines(&info_, &row, 1); } jpeg_finish_compress(&info_); if(buffer_ == nullptr || capacity_ == 0) return false; data = buffer_; size = capacity_; return true; } private: jpeg_compress_struct info_{}; jpeg_error_mgr err_{}; uint8_t *buffer_ = nullptr; unsigned long capacity_ = 0; }; bool sendAll(int fd, const void *data, size_t size) { const uint8_t *p = static_cast(data); while(size > 0) { const ssize_t n = ::send(fd, p, size, MSG_NOSIGNAL); if(n <= 0) return false; p += n; size -= static_cast(n); } return true; } // Bytes the kernel has still to put on the wire. On a link that cannot keep up // this is what grows, and with it the delay: TCP queues frames happily until the // picture is seconds behind. A preview must drop instead. bool backedUp(int fd, int limitBytes) { int pending = 0; if(::ioctl(fd, TIOCOUTQ, &pending) != 0) return false; return pending > limitBytes; } // A camera's calibration, as it goes on the wire: enough for the receiver to put // a depth pixel on the colour image itself, and nothing more. // // What it holds, and why exactly this: // // ray table two floats per depth pixel, the direction that pixel looks in. // The depth camera's distortion is already solved into it. It has // to be, because that model cannot be inverted at the far end — // k1 = 17.4 on this sensor, strong enough that an iterative // inverse was 10 pixels out at the edges while looking perfect in // the middle, and the SDK itself refuses to evaluate the corners. // // colour intrinsics and the rigid transform. No distortion coefficients: the // SDK's own projection applies none, and reproducing it exactly — // 0.000 pixel over 123 points across the whole image at three // distances — means a plain pinhole. Applying the stored colour // coefficients, or inverting them, was 9 pixels out. // // The receiver then does, per depth pixel: P = ray * millimetres, P' = R P + T, // and u = P'.x / P'.z * fx + cx. That is a texture read and a few multiplies — // which is the whole point of sending this instead of transforming here. #pragma pack(push, 1) struct WireCalibrationHeader { int32_t depthWidth, depthHeight; int32_t colourWidth, colourHeight; float colourFx, colourFy, colourCx, colourCy; float rot[9]; // depth -> colour, row major float trans[3]; // depth -> colour, in millimetres int32_t rayTableBytes; // LZ4 payload that follows this header int32_t rayTablePlainBytes; // what it expands to: depth pixels * 2 floats }; #pragma pack(pop) // The viewers currently connected. A frameset is written to each in turn under // one lock, so no two cameras can interleave their messages on any of them. struct Viewers { std::mutex gate; std::vector fds; void add(int fd) { std::lock_guard guard(gate); fds.push_back(fd); } std::vector snapshot() { std::lock_guard guard(gate); return fds; } void drop(int fd) { std::lock_guard guard(gate); for(size_t i = 0; i < fds.size(); i++) { if(fds[i] == fd) { fds.erase(fds.begin() + static_cast(i)); ::close(fd); return; } } } void closeAll() { std::lock_guard guard(gate); for(int fd : fds) ::close(fd); fds.clear(); } size_t count() { std::lock_guard guard(gate); return fds.size(); } }; #pragma pack(push, 1) struct Header { uint32_t magic; // 'ITHD' uint16_t camera; // camIndex, as the Recorder panel numbers it uint16_t kind; // 0 = depth (LZ4, split planes), 1 = colour (JPEG), // 2 = calibration (once per viewer), 3 = the SDK's own // depth-to-colour result (LZ4, split planes) uint16_t width; uint16_t height; uint32_t payload; // bytes that follow uint32_t ageUs; // age of the frame when it was sent uint32_t sequence; // shared by the colour and depth of one frameset }; #pragma pack(pop) const uint32_t kMagic = 0x44485449; // 'ITHD' little-endian // Only C2D re-encodes, and this is the one place a loss is introduced anywhere // in this server. High enough that the artefacts stay below what the sensor's // own JPEG already has, since a second generation is what is being paid for. const int kJpegQuality = 90; struct Mode { int width, height; }; bool depthMode(const std::string &name, Mode &out) { if(name == "NFOV_2X2BINNED") { out = {320, 288}; return true; } if(name == "NFOV_UNBINNED") { out = {640, 576}; return true; } if(name == "WFOV_2X2BINNED") { out = {512, 512}; return true; } if(name == "WFOV_UNBINNED") { out = {1024, 1024}; return true; } return false; } // Which grid the two images are put on before they leave. // // Raw each sensor's own view. The two sit a couple of centimetres apart, // so a given object falls on different pixels in the two images and // nothing downstream can pair them without the calibration. Costs // nothing, and the colour is the sensor's own JPEG untouched. // // D2C the depth resampled into the colour camera's view, at the colour // resolution: pixel (x, y) is the same point of the world in both. // This is k4a_transformation_depth_image_to_color_camera — a mesh warp // rather than a per-pixel reprojection, which is what keeps holes out // of the result and resolves occlusions with a z-buffer. The colour // still leaves untouched; only the depth is transformed, and it grows // to the colour resolution (4 MB a frame at 1080p, before compression). // // D2CFov the same view and the same aspect, kept at the depth sensor's own // resolution. K4A has no such variant; this one is the v2 SDK's own // (Align::setMatchTargetResolution). Texture coordinates still line up, // which is what a shader or a point cloud actually samples with; only // per-pixel identity is given up, and with it most of the bandwidth. // // C2D the colour resampled into the depth camera's view, at the depth // resolution — k4a_transformation_color_image_to_depth_camera. This is // the expensive one and unavoidably so: the sensor puts MJPG on the // USB, so the frame has to be decoded to be warped, and then encoded // again to be sent. Raw and D2C both avoid touching the colour at all. // Compare the raw depth as in Raw, plus the SDK's own depth-to-colour result // alongside it, so a viewer can overlay its own transform on the // reference and see where the two place their points differently. // Costs what the transform costs — 22 frames a second instead of 29, // and a second depth map on the wire. For diagnosis, not capture. enum class Align { Raw, D2C, D2CFov, C2D, Compare }; bool alignMode(const std::string &name, Align &out) { if(name == "raw") { out = Align::Raw; return true; } if(name == "d2c") { out = Align::D2C; return true; } if(name == "d2c-fov") { out = Align::D2CFov; return true; } if(name == "c2d") { out = Align::C2D; return true; } if(name == "compare") { out = Align::Compare; return true; } return false; } // For messages about the mode in force NOW, which is not necessarily the one named // on the command line. const char *alignText(Align a) { switch(a) { case Align::Raw: return "raw"; case Align::D2C: return "d2c"; case Align::D2CFov: return "d2c-fov"; case Align::C2D: return "c2d"; case Align::Compare: return "compare"; } return "?"; } bool colorMode(const std::string &name, Mode &out) { if(name == "720p") { out = {1280, 720}; return true; } if(name == "1080p") { out = {1920, 1080}; return true; } if(name == "1440p") { out = {2560, 1440}; return true; } if(name == "1536p") { out = {2048, 1536}; return true; } if(name == "2160p") { out = {3840, 2160}; return true; } return false; } // "serial:index,serial:index" — the bridge owns these numbers, and they name the // slot each stream occupies in the mosaic. std::map parseMap(const std::string &text) { std::map out; size_t start = 0; while(start < text.size()) { const size_t comma = text.find(',', start); const std::string item = text.substr(start, comma - start); const size_t colon = item.find(':'); if(colon != std::string::npos) { out[item.substr(0, colon)] = static_cast(std::atoi(item.substr(colon + 1).c_str())); } if(comma == std::string::npos) break; start = comma + 1; } return out; } // What the node should be doing right now: which cameras, and in which mode. // // Read on a timer rather than pushed, so nothing has to be added to the viewer // protocol and a crashed writer cannot leave the server waiting on a command that // never comes. Both live in ONE file so a single atomic rename changes them // together — they can never be read half-updated against each other. struct Composition { std::map indices; Align align = Align::Raw; bool hasAlign = false; }; Composition readComposition(const std::string &path) { Composition out; std::ifstream in(path); if(!in) return out; std::string line; std::getline(in, line); out.indices = parseMap(line); // Second line optional: a writer that does not know about it simply leaves the // mode as the command line set it. if(std::getline(in, line)) { while(!line.empty() && (line.back() == '\r' || line.back() == ' ')) line.pop_back(); Align parsed{}; if(!line.empty() && alignMode(line, parsed)) { out.align = parsed; out.hasAlign = true; } } return out; } // One camera the server can turn on and off in place. The frame callback is kept // here because start() needs it again on every restart of this one pipeline. struct Cam { std::string serial; uint16_t camIndex = 0; std::shared_ptr pipeline; std::shared_ptr config; std::function)> callback; bool running = false; }; } // namespace int main(int argc, char **argv) { std::string mapText, mapFile, depthName = "NFOV_2X2BINNED", colorName = "720p", alignName = "none"; int port = 5020, rate = 30, backlogLimit = 1 << 20; for(int i = 1; i < argc; i++) { const std::string arg = argv[i]; if(arg == "--map" && i + 1 < argc) mapText = argv[++i]; else if(arg == "--map-file" && i + 1 < argc) mapFile = argv[++i]; else if(arg == "--depth-mode" && i + 1 < argc) depthName = argv[++i]; else if(arg == "--color-mode" && i + 1 < argc) colorName = argv[++i]; else if(arg == "--align" && i + 1 < argc) alignName = argv[++i]; else if(arg == "--rate" && i + 1 < argc) rate = std::atoi(argv[++i]); else if(arg == "--port" && i + 1 < argc) port = std::atoi(argv[++i]); else if(arg == "--backlog" && i + 1 < argc) backlogLimit = std::atoi(argv[++i]); else { std::cerr << "unknown option: " << arg << std::endl; return 2; } } Mode depth{}, colour{}; if(!depthMode(depthName, depth)) { std::cerr << "unknown depth mode: " << depthName << std::endl; return 2; } if(!colorMode(colorName, colour)) { std::cerr << "unknown colour mode: " << colorName << std::endl; return 2; } Align align{}; if(!alignMode(alignName, align)) { std::cerr << "unknown alignment: " << alignName << " (expected none, color or color-fov)" << std::endl; return 2; } std::map indices = parseMap(mapText); // The file wins when it is there and readable: it is the live truth, and --map // is then only the starting point the bridge passed on the command line. if(!mapFile.empty()) { const Composition start = readComposition(mapFile); if(!start.indices.empty()) indices = start.indices; if(start.hasAlign) align = start.align; } // Only these two can be switched under way: they share the stream // configuration, so nothing has to be reopened. Fixed otherwise. const bool liveSwitch = (align == Align::Raw || align == Align::Compare); std::atomic liveAlign{static_cast(align)}; std::signal(SIGINT, onSignal); std::signal(SIGTERM, onSignal); // ---- listening socket --------------------------------------------------- const int listenFd = ::socket(AF_INET, SOCK_STREAM, 0); int yes = 1; ::setsockopt(listenFd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes)); sockaddr_in addr{}; addr.sin_family = AF_INET; addr.sin_addr.s_addr = INADDR_ANY; addr.sin_port = htons(static_cast(port)); if(::bind(listenFd, reinterpret_cast(&addr), sizeof(addr)) != 0) { std::cerr << "cannot bind port " << port << std::endl; return 1; } ::listen(listenFd, 2); Viewers viewers; std::thread accepter([&]() { while(g_running) { const int fd = ::accept(listenFd, nullptr, nullptr); if(fd < 0) break; // Send at once rather than let Nagle group small writes: every // millisecond held here is a millisecond of preview delay. ::setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &yes, sizeof(yes)); viewers.add(fd); std::cout << "Viewer connected (" << viewers.count() << " watching)." << std::endl; } }); // ---- cameras ------------------------------------------------------------ ob::Context::setExtensionsDirectory(OB_EXTENSIONS_DIR); ob::Context ctx; auto list = ctx.queryDeviceList(); if(list->getCount() == 0) { std::cerr << "no camera found" << std::endl; // The accepter is still joinable, and letting a joinable thread reach its // destructor calls std::terminate — an abort on the ordinary path where // nothing is plugged in. ::shutdown(listenFd, SHUT_RDWR); ::close(listenFd); accepter.detach(); return 1; } std::mutex sendLock; std::vector cams; std::vector>> dropCounters; for(uint32_t i = 0; i < list->getCount(); i++) { auto dev = list->getDevice(i); const std::string serial = list->getSerialNumber(i); // Fall back to enumeration order only if the bridge did not say: its // numbering is what the operator sees and edits. uint16_t camIndex = static_cast(i); auto known = indices.find(serial); if(known != indices.end()) camIndex = known->second; // A non-empty map is the list to STREAM. The pipeline is still built for // the others, so that a camera ticked back on starts at once instead of // paying for a device open; what an unticked camera must not do is stream, // which is where the USB bandwidth, the depth engine and the heat go. const bool wanted = indices.empty() || known != indices.end(); auto pipeline = std::make_shared(dev); auto config = std::make_shared(); std::shared_ptr colourProfile, depthProfile; try { depthProfile = pipeline->getStreamProfileList(OB_SENSOR_DEPTH) ->getVideoStreamProfile(depth.width, depth.height, OB_FORMAT_Y16, rate); config->enableStream(depthProfile); // MJPG everywhere except C2D: the sensor puts JPEG on the USB and // that is exactly what we want to forward. C2D has to warp the // colour, so it needs pixels, and asking the SDK for RGB is what // makes it decode — there is no way to warp a JPEG. const OBFormat colourFormat = (align == Align::C2D) ? OB_FORMAT_RGB : OB_FORMAT_MJPG; colourProfile = pipeline->getStreamProfileList(OB_SENSOR_COLOR) ->getVideoStreamProfile(colour.width, colour.height, colourFormat, rate); config->enableStream(colourProfile); } catch(const ob::Error &e) { std::cerr << "camera " << serial << ": " << e.what() << std::endl; return 1; } // One filter per camera, not one shared: it holds the calibration of the // pair it is aligning, and a filter is stateful. // Built for Raw too when the mode can change: a server started in Raw must // be able to produce the reference a moment later, and the filter is only // ever RUN when the live mode asks for it (see the callback). const Align filterFor = liveSwitch ? Align::Compare : align; std::shared_ptr aligner; if(filterFor != Align::Raw) { try { aligner = std::make_shared( filterFor == Align::C2D ? OB_STREAM_DEPTH : OB_STREAM_COLOR); aligner->setMatchTargetResolution(filterFor != Align::D2CFov); // Told the target profile explicitly rather than left to infer it // from the first frame of the other stream: the intrinsics and // extrinsics are what the transform needs, and this way the very // first frame is aligned like all the others. aligner->setAlignToStreamProfile( filterFor == Align::C2D ? depthProfile : colourProfile); } catch(const ob::Error &e) { std::cerr << "camera " << serial << ": cannot set up the " << alignText(filterFor) << " transform: " << e.what() << std::endl; return 1; } } // Read once, here: it depends on the profiles just enabled, and the // intrinsics are resolution-dependent — asking before configuring would // return numbers for the wrong image size. // // Built as the bytes that go on the wire, header then compressed table, // so the sending path has nothing left to assemble. auto calibration = std::make_shared>(); try { OBCalibrationParam param = pipeline->getCalibrationParam(config); const size_t rays = static_cast(depth.width) * depth.height * 2; std::vector table(rays); // Sized from the profile rather than queried: passing a null buffer // to ask for the size throws. Two floats per depth pixel is what the // table is by definition. The SDK reports the count back in floats. uint32_t reported = static_cast(rays); OBXYTables tables{}; if(!ob::CoordinateTransformHelper::transformationInitXYTables( param, OB_SENSOR_DEPTH, table.data(), &reported, &tables)) { throw std::runtime_error("the SDK would not build the ray table"); } // The two tables come back as separate arrays; interleaved they are // one texture on the far end, read in a single fetch. std::vector interleaved(rays); const size_t pixels = static_cast(tables.width) * tables.height; for(size_t i = 0; i < pixels; i++) { interleaved[2 * i] = tables.xTable[i]; interleaved[2 * i + 1] = tables.yTable[i]; } const int plainBytes = static_cast(interleaved.size() * sizeof(float)); std::vector packed( static_cast(LZ4_compressBound(plainBytes))); const int packedBytes = LZ4_compress_default( reinterpret_cast(interleaved.data()), reinterpret_cast(packed.data()), plainBytes, static_cast(packed.size())); if(packedBytes <= 0) throw std::runtime_error("could not compress the ray table"); WireCalibrationHeader head{}; head.depthWidth = tables.width; head.depthHeight = tables.height; head.colourWidth = param.intrinsics[OB_SENSOR_COLOR].width; head.colourHeight = param.intrinsics[OB_SENSOR_COLOR].height; head.colourFx = param.intrinsics[OB_SENSOR_COLOR].fx; head.colourFy = param.intrinsics[OB_SENSOR_COLOR].fy; head.colourCx = param.intrinsics[OB_SENSOR_COLOR].cx; head.colourCy = param.intrinsics[OB_SENSOR_COLOR].cy; const OBExtrinsic &d2c = param.extrinsics[OB_SENSOR_DEPTH][OB_SENSOR_COLOR]; std::memcpy(head.rot, d2c.rot, sizeof(head.rot)); std::memcpy(head.trans, d2c.trans, sizeof(head.trans)); head.rayTableBytes = packedBytes; head.rayTablePlainBytes = plainBytes; calibration->resize(sizeof(head) + static_cast(packedBytes)); std::memcpy(calibration->data(), &head, sizeof(head)); std::memcpy(calibration->data() + sizeof(head), packed.data(), static_cast(packedBytes)); std::cout << " camera " << camIndex << " calibration: ray table " << tables.width << "x" << tables.height << ", " << (plainBytes / 1024) << " Ko -> " << (packedBytes / 1024) << " Ko" << std::endl; } catch(const std::exception &e) { // Not fatal: without it a receiver simply cannot align anything // itself, which is the state everything was in until now. calibration->clear(); std::cerr << "camera " << serial << ": no calibration available: " << e.what() << std::endl; } // Which sockets already have it. Erased when a viewer goes, so a // reconnection on the same descriptor number is sent it again. auto calibrationSent = std::make_shared>(); // Per-camera buffers: the callbacks run on their own threads and would // otherwise overwrite each other between the split and the compress. auto planes = std::make_shared>(); auto comp = std::make_shared>(); // A second pair, for the reference map in Compare: the first is still // holding the frame being sent. auto refPlanes = std::make_shared>(); auto refComp = std::make_shared>(); auto counter = std::make_shared(0); auto dropped = std::make_shared>(0); auto warned = std::make_shared(false); // The size the depth comes out at is only known once a frame has been // through the transform, so it is announced from there rather than guessed. auto announced = std::make_shared(false); // Built only where it is used: in Raw and D2C no colour byte is ever // re-encoded, so there is nothing for it to do. auto encoder = (align == Align::C2D) ? std::make_shared() : nullptr; // Held rather than handed straight to start(): the same callback is given // again every time this one camera is turned back on. std::function)> callback = [&, planes, comp, counter, dropped, camIndex, aligner, warned, announced, encoder, calibration, calibrationSent, refPlanes, refComp]( std::shared_ptr fs) { if(!g_running || fs == nullptr) return; // Read once for this whole frameset: it can change between framesets, // and a frame decided half one way and half the other would be worse // than either answer. const Align mode = static_cast(liveAlign.load()); // Who gets this frameset: everyone connected whose link is keeping // up. Decided once, here, and never revisited while the messages are // being written — a viewer skipped halfway through would be left // reading a payload against the wrong header. std::vector targets; for(int fd : viewers.snapshot()) { if(backedUp(fd, backlogLimit)) { // behind: drop, do not queue dropped->fetch_add(1); continue; } targets.push_back(fd); } if(targets.empty()) return; // nobody watching, or all behind // The calibration goes to a viewer once, before any frame it could // be needed for. Tracked per camera and per socket, so a new viewer // gets it without the others being sent it again. if(!calibration->empty()) { Header kh{}; kh.magic = kMagic; kh.camera = camIndex; kh.kind = 2; kh.payload = static_cast(calibration->size()); std::lock_guard guard(sendLock); for(int fd : targets) { if(calibrationSent->count(fd) != 0) continue; if(sendAll(fd, &kh, sizeof(kh)) && sendAll(fd, calibration->data(), calibration->size())) { calibrationSent->insert(fd); } } } const uint64_t captured = nowUs(); // Which frameset each half is taken from is the whole difference // between the modes. In Raw there is no transform at all. In D2C only // the depth is transformed, and the colour is still the sensor's own // JPEG, forwarded untouched. In C2D it is the colour that moves, so // both halves come from the result — and that colour is pixels now, // which is why it has to be encoded again below. // // process() runs here, on the thread the camera delivers on. Handing it // to the thread inside the filter instead was measured and changed // nothing: 22 frames a second in D2C either way. What limits it is the // transform, not this thread, and the filter also discards silently // when its queue is full, which cost the age its meaning. std::shared_ptr source = fs; std::shared_ptr aligned; if(aligner != nullptr && mode != Align::Raw) { try { auto result = aligner->process(fs); if(result == nullptr) return; aligned = result->as(); source = aligned; } catch(const ob::Error &e) { if(!*warned) { // once, not thirty times a second *warned = true; std::cerr << "camera " << camIndex << ": the " << alignText(mode) << " transform failed: " << e.what() << std::endl; } return; } } // In Compare the frames sent are the camera's own; the transform's // result travels beside them rather than replacing them. if(mode == Align::Compare) source = fs; auto depthFrame = source->getFrame(OB_FRAME_DEPTH); // Depth from the result, colour from whichever frameset actually holds // it: in D2C the transform returns the depth alone about a third of the // time, and taking the colour from there cost one camera almost all of // its colour frames. Only C2D has a transformed colour to take. auto colourFrame = (mode == Align::C2D ? source : fs)->getFrame(OB_FRAME_COLOR); if(depthFrame == nullptr) return; const uint32_t stamp = (*counter)++; auto df = depthFrame->as(); const size_t bytes = df->getDataSize(); if(!*announced) { *announced = true; std::cout << " camera " << camIndex << " depth " << df->getWidth() << "x" << df->getHeight() << ", " << (bytes / 1024) << " Ko per frame before compression" << std::endl; } // The protocol says millimetres, and the receiver reads the 16-bit // values as such. The transform is not supposed to change that, but a // silent change of unit would look like a room the wrong size rather // than like an error, so it is worth one line to notice. if(!*warned && df->getValueScale() != 1.0f) { *warned = true; std::cerr << "camera " << camIndex << ": depth value scale is " << df->getValueScale() << ", not 1 — the receiver reads " << "millimetres and will be wrong by that factor." << std::endl; } splitPlanes(static_cast(df->getData()), bytes, *planes); comp->resize(static_cast(LZ4_compressBound(static_cast(bytes)))); const int packed = LZ4_compress_default( reinterpret_cast(planes->data()), reinterpret_cast(comp->data()), static_cast(bytes), static_cast(comp->size())); if(packed <= 0) return; Header hd{}; hd.magic = kMagic; hd.camera = camIndex; hd.kind = 0; hd.width = static_cast(df->getWidth()); hd.height = static_cast(df->getHeight()); hd.payload = static_cast(packed); hd.ageUs = static_cast(nowUs() - captured); hd.sequence = stamp; // The colour to send, and its size. In every mode but C2D these are // the sensor's own JPEG bytes; the receiver cannot tell the difference // and does not need to, which is what keeps the protocol the same // across all three modes. const uint8_t *colourData = nullptr; size_t colourSize = 0; int colourW = 0, colourH = 0; if(colourFrame != nullptr) { auto cf = colourFrame->as(); colourW = static_cast(cf->getWidth()); colourH = static_cast(cf->getHeight()); if(align == Align::C2D) { if(cf->getFormat() != OB_FORMAT_RGB) { if(!*warned) { *warned = true; std::cerr << "camera " << camIndex << ": the transform " << "returned colour in an unexpected format; " << "sending depth only." << std::endl; } } else if(!encoder->encode(static_cast(cf->getData()), colourW, colourH, kJpegQuality, colourData, colourSize)) { colourData = nullptr; } } else { colourData = static_cast(cf->getData()); colourSize = cf->getDataSize(); } } // The SDK's own result, in Compare only. Same compression as the // depth: split planes then LZ4, so the receiver has one routine. const uint8_t *refData = nullptr; size_t refSize = 0; int refW = 0, refH = 0; if(mode == Align::Compare && aligned != nullptr) { auto refFrame = aligned->getFrame(OB_FRAME_DEPTH); if(refFrame != nullptr) { auto rf = refFrame->as(); const size_t refBytes = rf->getDataSize(); refW = static_cast(rf->getWidth()); refH = static_cast(rf->getHeight()); splitPlanes(static_cast(rf->getData()), refBytes, *refPlanes); refComp->resize(static_cast( LZ4_compressBound(static_cast(refBytes)))); const int packedRef = LZ4_compress_default( reinterpret_cast(refPlanes->data()), reinterpret_cast(refComp->data()), static_cast(refBytes), static_cast(refComp->size())); if(packedRef > 0) { refData = refComp->data(); refSize = static_cast(packedRef); } } } Header ch = hd; ch.kind = 1; ch.width = static_cast(colourW); ch.height = static_cast(colourH); ch.payload = static_cast(colourSize); // One lock for the whole frameset: two cameras must never interleave // their messages, since a reader takes a header then exactly its // payload. Sending depth and colour together under it is also what // makes the pair synchronised by construction rather than by // negotiation. std::vector lost; { std::lock_guard guard(sendLock); for(int fd : targets) { hd.ageUs = static_cast(nowUs() - captured); bool ok = sendAll(fd, &hd, sizeof(hd)) && sendAll(fd, comp->data(), static_cast(packed)); if(ok && colourData != nullptr) { ch.ageUs = static_cast(nowUs() - captured); ok = sendAll(fd, &ch, sizeof(ch)) && sendAll(fd, colourData, colourSize); } if(ok && refData != nullptr) { // Under the same lock as the pair, so all three belong to // one frameset on the wire and cannot be interleaved. Header rh = hd; rh.kind = 3; rh.width = static_cast(refW); rh.height = static_cast(refH); rh.payload = static_cast(refSize); rh.ageUs = static_cast(nowUs() - captured); ok = sendAll(fd, &rh, sizeof(rh)) && sendAll(fd, refData, refSize); } if(!ok) lost.push_back(fd); } } for(int fd : lost) { calibrationSent->erase(fd); viewers.drop(fd); std::cout << "Viewer gone (" << viewers.count() << " watching)." << std::endl; } }; Cam cam; cam.serial = serial; cam.camIndex = camIndex; cam.pipeline = pipeline; cam.config = config; cam.callback = callback; if(wanted) { pipeline->start(config, callback); cam.running = true; } cams.push_back(cam); dropCounters.push_back(dropped); std::cout << " camera " << camIndex << " : " << serial << (wanted ? "" : " (built, not streaming)") << std::endl; } size_t streaming = 0; for(const auto &c : cams) if(c.running) streaming++; // With a composition file there is nothing wrong with starting empty — it can // be filled a moment later. Without one, an empty selection is a mistake worth // failing on rather than holding the port and sending nothing. if(streaming == 0 && mapFile.empty()) { std::cerr << "nothing to serve: every camera is excluded by --map" << std::endl; // Same teardown as the no-camera path above, and for the same reason. ::shutdown(listenFd, SHUT_RDWR); ::close(listenFd); accepter.detach(); return 1; } std::cout << "Serving " << streaming << " camera(s) on port " << port << " — colour " << colour.width << "x" << colour.height << " JPEG, depth " << depth.width << "x" << depth.height << " 16-bit lossless, " << rate << " fps, alignment " << alignName << std::endl; uint32_t reported = 0; int ticks = 0; while(g_running) { std::this_thread::sleep_for(std::chrono::seconds(1)); ticks++; // Bring the running set in line with the file. A camera leaving it is // stopped and the others carry on: that is the whole reason this exists // rather than a restart. if(!mapFile.empty()) { const Composition now = readComposition(mapFile); const std::map &wantedNow = now.indices; // The mode first, and independently of the camera list: it costs // nothing but a flag, and the next frameset picks it up. if(now.hasAlign && liveSwitch) { const int wantMode = static_cast(now.align); if(wantMode != liveAlign.load() && (now.align == Align::Raw || now.align == Align::Compare)) { liveAlign.store(wantMode); std::cout << "alignment now " << alignText(now.align) << " (no restart)." << std::endl; } } // An unreadable or empty file is far more likely to be a half-written // one than a real request to stop everything, and acting on it would be // a spectacular way to lose a take. So it is ignored until it reads. if(!wantedNow.empty()) { for(auto &cam : cams) { const bool want = wantedNow.find(cam.serial) != wantedNow.end(); if(want == cam.running) continue; try { if(want) { cam.pipeline->start(cam.config, cam.callback); cam.running = true; std::cout << "camera " << cam.camIndex << " (" << cam.serial << ") streaming again." << std::endl; } else { cam.pipeline->stop(); cam.running = false; std::cout << "camera " << cam.camIndex << " (" << cam.serial << ") stopped, it left the composition." << std::endl; } } catch(const ob::Error &e) { // Leaves the flag alone so the next pass tries again, and // says which camera: one sensor refusing to stop must not // take the others down with it. std::cerr << "camera " << cam.camIndex << ": " << (want ? "start" : "stop") << " failed: " << e.what() << std::endl; } } } } if(ticks % 5 != 0) continue; uint32_t total = 0; for(auto &d : dropCounters) total += d->load(); if(total != reported) { std::cout << " dropped " << (total - reported) << " frame(s): the link is behind, or the transform is" << std::endl; reported = total; } } // Stopping the pipelines can block in the SDK while it releases its EGL // contexts, so shut the socket first: a viewer sees the end immediately // instead of waiting on a teardown it has no interest in. ::shutdown(listenFd, SHUT_RDWR); ::close(listenFd); accepter.detach(); viewers.closeAll(); for(auto &cam : cams) if(cam.running) cam.pipeline->stop(); std::cout << "Stopped." << std::endl; return 0; }