多相机协同拍照原理底层刨析

逻辑部分

该章针对支持Gige协议的相机进行多相机控制分析,例如海康,康耐视等相机的高速拍照控制

关于相机拍照的触发形式以及代码层参考

Gige多相机高速拍照模式补充-CSDN博客

++该章主要讲述工作流方面++

++环境概述:多相机对应多网卡,多网段++

++工作目的:周期性同时(相对)控制相机高速拍照++

++结果需求:多相机同时反馈相关节点图象,要求不丢图++

以上工作均在PC端完成,建议相关项目后面均用PLC进行电信号控制,UDP进行传图

下发指令:电脑端->相机端

关键点:Gige 相机的相关控制指令为串行指令,一次只能处理一个控制指令。PC 通过 UDP 向相机发送拍照指令,相机必须处理完成,并通过 UDP 回馈 ACK 给 PC 后,才允许接收下一条控制指令。否则会报 "拒绝写入寄存器",新来的指令会被相机直接丢弃。例如:28 台相机同时下发拍照指令,每台相机内部依然是串行执行。这是【相机硬件 + GigE 协议】限制,和 PCIe 无关。

电脑端通过28个线程向28个相机通过软触发模式发送拍照指令

流程

上位机 CPU 发 GVCP/UDP 控制指令→网线到相机嵌入式 CPU 解析→回 UDP-ACK 确认→相机感光采图→FPGA 打包 GVSP 图像 UDP 流→网卡 DMA 直传→写入电脑内存供算法取用

相机的交互控制采取软触发控制,Demo前台触发连续拍照指令,通过CameraManager管理,统一向预设好的相机发送广播拍照,我们是定制的服务器,每个相机均为直连网口,每个单独的独立相机均可看作为一个单独的相机。

单独相机的控制由CameraWork来实现具体的工作流,对流数据进行Frameid的比较,以确保为最新的图像,而不是缓存帧

将相机传输的数据进行多线程入栈,入栈后将相机图像进行删除,将入栈后的图像进行落盘,入站的图像进行清理释放内存。来实现自动化流程

代码实现部分

CameraManager

连接+打开部分

++本文主要刨析拍照部分,连接部分暂且略过,详细解析打开部分,因为打开部分集合了相机的注册回调,打开流,设置软件触发等关键操作++

代码部分

cpp 复制代码
void GigeCameraManager::openPreset(const QList<int>& CAslots)
{
    // 清空在线相机列表
    m_activeSlots.clear();

    // 遍历要打开的相机槽位
    for (int Cameraslot : CAslots) {

        // 越界检查,防止崩溃
        if (Cameraslot < 0 || Cameraslot >= 32) continue;

        //拿到这个相机的IP,检查是否有效
        const char* ipC = kCameraIPs[Cameraslot];
        if (!ipC || strlen(ipC) < 7) continue;

        //跨线程调用,让 worker 打开相机
        QMetaObject::invokeMethod(
            m_nodes[Cameraslot].worker,
            "open",
            Qt::QueuedConnection//队列异步调用
        );


        QThread::msleep(100);
    }
}

通过Qt的函数 Qt::QueuedConnection来向预设好的相机地址发送打开相机的操作,其中具体实现是在Work里面,其中注意的是

顺序必须固定

注册回调 → 打开流 → 设置软件触发

注册回调的目的在于当相机底层自动触发后自动执行处理规则

SetFrameReadyCallback/SetFrameFailCallback

其中SetFrameFailCallback反馈的Status表

代码如下

cpp 复制代码
void GigeCameraWorker::open()
{
    QMutexLocker lk(&m_lock);

    if (m_opened) {
        emit sigLog(m_slot, "已连接,忽略重复打开");
        emit sigConnected(m_slot, true);
        return;
    }

    m_GigeMan.Close();
    m_cam = nullptr;

    tagGigeIP ipBuf = { 0 };
    QByteArray ipBytes = m_ip.toLatin1();
    strncpy_s(ipBuf, sizeof(ipBuf), ipBytes.constData(), _TRUNCATE);

    if (m_GigeMan.AddDeviceByIP(ipBuf) != COMMON_OK) {
        emit sigLog(m_slot, "AddDeviceByIP 失败");
        emit sigConnected(m_slot, false);
        return;
    }

    if (m_GigeMan.GetDeviceList() <= 0) {
        emit sigLog(m_slot, "GetDeviceList 返回0");
        emit sigConnected(m_slot, false);
        return;
    }

    //Gige已经推流1~2帧率
    m_cam = m_GigeMan.GetGige(0);
    if (!m_cam) {
        emit sigLog(m_slot, "GetGige(0) 返回空指针");
        emit sigConnected(m_slot, false);
        return;
    }

    tagErrMsg errMsg = { 0 };
    if (!COMMON_SUCCEEDED(m_cam->Open(errMsg))) {
        emit sigLog(m_slot, QString("Open 失败: %1").arg(QString::fromLocal8Bit(errMsg)));
        emit sigConnected(m_slot, false);
        return;
    }


    QThread::msleep(100);
    //在打开流之前标记初始化状态并注册回调
    m_isInitializing = true;//防止初始状态垃圾帧

    m_cam->SetFrameReadyCallback([this](int /*slot*/, uint64_t frameId, bool ready) {
        // 初始化期间忽略所有回调
        if (m_isInitializing) {
            return;
        }

        //记录 ready=false 的情况,但不处理
        if (!ready) {
            return; 
        }
        {
            std::lock_guard<std::mutex> lk(m_frameMutex);
            m_lastReadyFrameId = frameId;
        }
        m_frameCv.notify_one();
        });


    m_cam->SetFrameFailCallback([this](int status, uint64_t frameId) {
        //  初始化期间忽略所有失败
        if (m_isInitializing) {

            return;
        }

        m_packetLossStats.totalFrames++;

        if (status != 0) {
            {
                std::lock_guard<std::mutex> lk(m_frameMutex);
                m_lastFailFrameId = frameId;
                m_lastFailStatus = status;
            }
            m_frameCv.notify_one();
          }
        });

    //  重置回调状态(确保第一帧能被检测到)
    {
        std::lock_guard<std::mutex> lk(m_frameMutex);
        m_lastReadyFrameId = 0;
        m_lastFailFrameId = 0;
        m_lastFailStatus = 0;
    }


 
    // 现在才打开流(回调已就绪),第二次推流.stream_callback
    m_nErrCode = m_cam->OpenStream(m_szErrMsg);
    if (m_nErrCode != 0) {
        emit sigLog(m_slot, QString("OpenStream 失败: %1").arg(QString::fromLocal8Bit(m_szErrMsg)));
        emit sigConnected(m_slot, false);
        m_isInitializing = false;  // 失败时重置状态
        return;
    }

 

    // 启用软件触发
    m_nErrCode = m_cam->SetSoftwareTrigger(true, m_szErrMsg);
    if (m_nErrCode != 0) {
        emit sigLog(m_slot, QString("软件触发模式失败: %1").arg(QString::fromLocal8Bit(m_szErrMsg)));
        emit sigConnected(m_slot, false);
        m_isInitializing = false;
        return;
    }

    QThread::msleep(100);

    // 重置回调时间
    m_cam->ResetCallbackTime();

    // 【新增调试】打开相机成功后清零计数器
    if (m_cam) m_cam->ResetDebugPhotoCounters();


    // 提升线程优先级
    HANDLE hThread = GetCurrentThread();
    if (!SetThreadPriority(hThread, THREAD_PRIORITY_HIGHEST)) {
        emit sigLog(m_slot, QString("设置优先级失败 err=%1").arg(GetLastError()));
    }


    m_opened = true;
    {
        std::lock_guard<std::mutex> frameLk(m_frameMutex);
        uint64_t baseFid = m_cam->GetLastFrameId();  // 不再依赖预热,通常此时为0或当前已有FID
        m_lastReadyFrameId = baseFid;
        m_lastFailFrameId = baseFid;
        m_lastFailStatus = 0;
        m_lastAcceptedFrameId = baseFid;
    }
    m_isInitializing = false;

}

拍照部分

初始部分:清除残留信息,绑定CPU线程

绑定CPU线程目的在于增加cpu亲和性与稳定性,避免高速拍照时由于CPU负载切换核导致系统的不稳定,相机丢帧等问题

cpp 复制代码
void GigeCameraManager::startBurstAll(int intervalMs, const QString& /*saveDir*/)
{
   

    //清零所有相机的计数器
    for (int slot : m_activeSlots) {
        QMetaObject::invokeMethod(
            m_nodes[slot].worker,
            "resetBurstCounters",
            Qt::QueuedConnection
        );
    }

    //为所有相机绑定CPU核心
    bindAllCamerasToCPU();

    QThread::msleep(50);
    m_sched->startSchedule(intervalMs);
    m_isBursting = true;

}

CPU绑定代码

cpp 复制代码
void GigeCameraManager::bindAllCamerasToCPU()
{

    //获取系统 CPU 核心数
    SYSTEM_INFO sysInfo;
    GetSystemInfo(&sysInfo);
    int numCpus = sysInfo.dwNumberOfProcessors;

 
    //获取当前在线相机列表
    QList<int> slotList = m_activeSlots.values();
    std::sort(slotList.begin(), slotList.end());


    std::vector<int> availableCpus;

    // NUMA Node 0: CPU 24-63
    for (int i = 24; i <= 63; i++) {
        availableCpus.push_back(i);
    }

    // NUMA Node 1: CPU 88-127
    if (slotList.size() > availableCpus.size()) {
        for (int i = 88; i <= 127; i++) {
            availableCpus.push_back(i);
        }
    }

    for (int i = 0; i < slotList.size(); i++) {
        int slot = slotList[i];
        int cpuId = availableCpus[i % availableCpus.size()];

        QMetaObject::invokeMethod(
            m_nodes[slot].worker,
            "setCpuAffinity",
            Qt::QueuedConnection,
            Q_ARG(int, cpuId)
        );
        int numaNode = (cpuId < 64) ? 0 : 1;
    }
}

下面是Work中继续拍照,其中为防止相机在UDP->ACK的过程中接收控制性指令,导致命令被T掉,进行入队式处理

代码如下

cpp 复制代码
void GigeCameraWorker::photoBurst(qint64 seq, qint64 tickTime)
{
    // 收到拍照指令计数
    if (m_cam) {
        m_cam->IncrementPhotoCommandReceived();
    }

    //加锁防止Manage与woker混乱
    {
        QMutexLocker lk(&m_taskMutex);

        // 防止同一序号入队
        //QTcontains有无
        if (m_pendingSeqs.contains(seq)) {
            return;
        }

        //将任务放进队列
        // enqueue入队
        //先进先出
        m_pendingSeqs.enqueue(seq);

        //相机正在拍照,直接返回,m_isBusy为判定
        if (m_isBusy.load()) {
            emit sigLog(m_slot, QString("[队列] seq=%1 已加入队列,当前队列长度=%2")
                .arg(seq).arg(m_pendingSeqs.size()));
            return;
        }
    }

    // 如果空闲,立即开始处理
    processPendingTasks();
}

若该条不忙则进入实际拍照

代码如下

cpp 复制代码
void GigeCameraWorker::processPendingTasks()
{
    while (true)
    {
        qint64 currentSeq = -1;

        //从队列取任务
        {
            QMutexLocker lk(&m_taskMutex);
            if (m_pendingSeqs.isEmpty()) {
                m_isBusy.store(false, std::memory_order_release);
                return;
            }
            currentSeq = m_pendingSeqs.dequeue();
            m_isBusy.store(true, std::memory_order_release);
        }

        // 正常拍一次
        int waitMs = (currentSeq == 1) ? 3000 : 1800;

        bool timeoutOnly = false;
        bool success = captureOneFrameInternal(currentSeq, waitMs, timeoutOnly);

        //如果是"本轮第一拍"且失败原因是 TIMEOUT,则自动补拍一次
        if (!success && currentSeq == 1 && timeoutOnly) {
            emit sigLog(m_slot, QString("seq=1 首拍TIMEOUT,自动补拍一次"));

            QThread::msleep(10);  // 小间隔,避免紧贴上一拍

            bool timeoutOnly2 = false;
            success = captureOneFrameInternal(currentSeq, waitMs, timeoutOnly2);

            if (success) {
                emit sigLog(m_slot, QString("seq=1 首拍补拍成功"));
            }
            else {
                emit sigLog(m_slot, QString(" seq=1 首拍补拍仍失败"));
            }
        }

        // 统一发送完成信号
        emit sigPhotoDone(m_slot, currentSeq, success);
    }
}

其中第一帧大概率会出现丢帧是因为初始流需要稳定相机相关参数,已得到官方确认。对于第一帧采取多于措施,其余均正常captureOneFrameInternal。++前台work把图像推入批次队列BatchBuffer::pushFrame(...),再将 图像放入FrameStack 内存队列,之后Batchreader去读FrameStack内部的图像,FrameSaver::writeFrame写入硬盘++

代码如下

其中通过FramePacket包的形式将图像的相关数据打包给下载线程工作

下载线程是根据建立新批次时自动启动的代码------ensureBatchReadersStarted();

cpp 复制代码
bool GigeCameraManager::newBatch(const QString& saveDir)
{
    if (m_batchOpen) {
        endBatch();
    }

    if (m_activeSlots.isEmpty()) {
        emit sigLog(-1, "newBatch: 无在线相机");
        return false;
    }

    QString base = QDir(saveDir.isEmpty()
        ? QString("../autoimage") : saveDir).absolutePath();

    QString dirName = "output_"
        + QDateTime::currentDateTime().toString("yyyyMMdd_hhmmss");
    m_batchRoot = QDir(base).filePath(dirName);

    QDir root(m_batchRoot);
    if (!root.mkpath(".")) {
        return false;
    }

    for (int slot : m_activeSlots) {
        root.mkpath(QString("cam_%1").arg(slot));
    }

    ensureBatchBuffer();
    ensureBatchReadersStarted();

    m_batchSegmentId = m_batchBuffer->beginSegment(
        dirName,
        m_batchRoot,
        QString()
    );

    if (m_batchSegmentId < 0) {
        emit sigLog(-1, "newBatch: beginSegment 失败");
        return false;
    }

    m_batchSeq = 0;
    //记录本批次开始时的基准,用差值隔离批次
    {
        QMutexLocker lk(&m_batchDoneMutex);
        m_batchBaseIssuedCommands = m_batchIssuedCommands;
        m_batchBaseReceivedDones = m_batchReceivedDones;

    }
    m_batchReadyToEndNotified = false;
    m_batchOpen = true;
    return true;
}
cpp 复制代码
void GigeCameraManager::ensureBatchReadersStarted()
{
    if (m_batchReadersStarted) return;

    createSaverReaders();

    m_batchReadersStarted = true;
}

createSaverReaders();此时下载线程绑定批次,下载信息以及线程启动

cpp 复制代码
void GigeCameraManager::createSaverReaders()
{
    QList<int> slotList = m_activeSlots.values();
    std::sort(slotList.begin(), slotList.end());

    int readerId = 0;

    for (int slot : slotList) {
        QList<int> mySlots;
        mySlots.append(slot);

        auto* reader = new BatchStackReader(
            "saver",
            readerId++,
            m_batchBuffer,
            mySlots,
            [this](int segmentId, const QString& batchId, const FramePacket& pkt) -> bool {
                Q_UNUSED(batchId);

                auto meta = m_batchBuffer->segmentMeta(segmentId);
                if (meta.rawDir.isEmpty()) {
                    return false;
                }

                bool ok = FrameSaver::writeFrame(pkt, meta.rawDir);

                if (ok) {
                    m_batchBuffer->markSaved(segmentId, pkt.slot);
                }

                return ok;
            },
            this
        );

        connect(reader, &BatchStackReader::sigLog,
            this, &GigeCameraManager::sigLog);

        connect(reader, &QThread::finished,
            reader, &QObject::deleteLater);

        reader->start();
        m_batchReaders.append(reader);
    }

}

取图代码,将Pkt传入下载线程

cpp 复制代码
bool GigeCameraWorker::captureOneFrameInternal(qint64 currentSeq, int waitMs, bool& timeoutOnly)
{
    timeoutOnly = false;

    QElapsedTimer timer;
    timer.start();

    QMutexLocker lk(&m_lock);

    //检查相机状态
    if (!m_opened || !m_cam) {
        emit sigLog(m_slot, QString("相机未打开 seq=%1").arg(currentSeq));
        emit sigGrabStat(m_slot, -1, false, (int)CamGrabMode::Auto);
        return false;
    }

    int w = m_cam->GetWidth();
    int h = m_cam->GetHeight();
    if (w <= 0 || h <= 0) {
        emit sigLog(m_slot, QString("尺寸无效 w=%1 h=%2 seq=%3")
            .arg(w).arg(h).arg(currentSeq));
        emit sigGrabStat(m_slot, (int)timer.elapsed(), false, (int)CamGrabMode::Auto);
        return false;
    }

    //记录触发前 frameId
    uint64_t oldFrameId = m_cam->GetLastFrameId();
    {
        std::lock_guard<std::mutex> frameLk(m_frameMutex);
        m_lastReadyFrameId = oldFrameId;
        m_lastFailFrameId = oldFrameId;
        m_lastFailStatus = 0;
    }
    lk.unlock();  // 解锁,避免阻塞回调线程

    //触发拍照
    m_nErrCode = m_cam->ExecuteSoftwareTrigger(m_szErrMsg);
    if (m_nErrCode != 0) {
        emit sigLog(m_slot, QString("触发失败 err=%1 重试中... seq=%2")
            .arg(m_nErrCode).arg(currentSeq));

        for (int retry = 0; retry < 2; retry++) {
            QThread::msleep(20);
            m_nErrCode = m_cam->ExecuteSoftwareTrigger(m_szErrMsg);
            if (m_nErrCode == 0) break;
        }

        if (m_nErrCode != 0) {
            lk.relock();
            m_burstTriggerFailCount++;
            lk.unlock();

            emit sigLog(m_slot, QString("触发失败3次 err=%1 msg=%2 seq=%3")
                .arg(m_nErrCode)
                .arg(QString::fromLocal8Bit(m_szErrMsg))
                .arg(currentSeq));

            emit sigGrabStat(m_slot, (int)timer.elapsed(), false, (int)CamGrabMode::Auto);
            return false;
        }
    }

    QThread::msleep(5);

    //等待 ready/fail 回调
    bool gotReadyFrame = false;
    bool gotFailFrame = false;
    int  failStatus = 0;

    {
        std::unique_lock<std::mutex> frameLk(m_frameMutex);
        bool signaled = m_frameCv.wait_for(
            frameLk,
            std::chrono::milliseconds(waitMs),
            [&] {
                return (m_lastReadyFrameId > oldFrameId) ||
                    (m_lastFailFrameId > oldFrameId);
            }
        );

        if (signaled) {
            if (m_lastReadyFrameId > oldFrameId) {
                gotReadyFrame = true;
            }
            else {
                gotFailFrame = true;
                failStatus = m_lastFailStatus;
            }
        }
    }

    // 5. fail callback 分支
    if (gotFailFrame) {
        QString statusText;
        switch (failStatus) {
        case 0: statusText = "SUCCESS"; break;
        case 1: statusText = "CLEARED"; break;
        case 2: statusText = "TIMEOUT"; break;
        case 3: statusText = "MISSING_PACKETS"; break;
        case 4: statusText = "WRONG_PACKET_ID"; break;
        case 5: statusText = "SIZE_MISMATCH"; break;
        case 6: statusText = "FILLING"; break;
        case 7: statusText = "ABORTED"; break;
        case 8: statusText = "PAYLOAD_NOT_SUPPORTED"; break;
        default: statusText = "UNKNOWN"; break;
        }

        emit sigLog(m_slot, QString("seq=%1 帧失败 status=%2(%3),跳过本帧")
            .arg(currentSeq).arg(failStatus).arg(statusText));

        if (failStatus == 2) {
            timeoutOnly = true;
        }

        emit sigGrabStat(m_slot, (int)timer.elapsed(), false, (int)CamGrabMode::Auto);
        return false;
    }
    // 6. wait_for 超时分支
    if (!gotReadyFrame) {
        lk.relock();
        m_burstTimeoutCount++;

        uint64_t completed = 0, failed = 0, underrun = 0;
        m_cam->GetStreamStats(completed, failed, underrun);
        double callbackIntervalMs = m_cam->GetLastCallbackIntervalMs();

        LARGE_INTEGER now, freq, lastCallback;
        QueryPerformanceCounter(&now);
        QueryPerformanceFrequency(&freq);
        lastCallback = m_cam->GetLastCallbackTime();

        double sinceLastCallbackMs = 0.0;
        if (lastCallback.QuadPart > 0) {
            sinceLastCallbackMs = (double)(now.QuadPart - lastCallback.QuadPart)
                * 1000.0 / freq.QuadPart;
        }

        emit sigLog(m_slot, QString(
            "【超时详情】seq=%1 wait=%2ms\n"
            "  触发前FID=%3 → 最后readyFID=%4\n"
            "  SDK统计: ok=%5 fail=%6 underrun=%7\n"
            "  lastInterval=%8ms\n"
            "  距上次callback=%9ms\n"
            "   差异=%10ms")
            .arg(currentSeq)
            .arg(waitMs)
            .arg((qulonglong)oldFrameId)
            .arg((qulonglong)m_lastReadyFrameId)
            .arg((qulonglong)completed)
            .arg((qulonglong)failed)
            .arg((qulonglong)underrun)
            .arg(callbackIntervalMs, 0, 'f', 1)
            .arg(sinceLastCallbackMs, 0, 'f', 1)
            .arg(sinceLastCallbackMs - callbackIntervalMs, 0, 'f', 1));

        lk.unlock();

        timeoutOnly = true;
        emit sigGrabStat(m_slot, (int)timer.elapsed(), false, (int)CamGrabMode::Auto);
        return false;
    }
    lk.relock();

    //获取图像
    FrameSnapshot frame;
    if (!m_cam->GetFrameSnapshot(frame) || !frame.valid) {
        emit sigLog(m_slot, QString("获取帧快照失败 seq=%1").arg(currentSeq));
        lk.unlock();
        emit sigGrabStat(m_slot, (int)timer.elapsed(), false, (int)CamGrabMode::Auto);
        return false;
    }

    if (frame.frameId <= m_lastAcceptedFrameId) {
        emit sigLog(m_slot, QString("仍是旧帧 seq=%1 frameId=%2 lastAccepted=%3")
            .arg(currentSeq)
            .arg((qulonglong)frame.frameId)
            .arg((qulonglong)m_lastAcceptedFrameId));
        lk.unlock();
        emit sigGrabStat(m_slot, (int)timer.elapsed(), false, (int)CamGrabMode::Auto);
        return false;
    }

    m_lastAcceptedFrameId = frame.frameId;
    lk.unlock();

    //组包并写入 BatchBuffer
    FramePacket pkt;
    pkt.slot = m_slot;
    pkt.seq = currentSeq;
    pkt.w = frame.width;
    pkt.h = frame.height;
    pkt.stride = frame.stride;
    pkt.frameId = frame.frameId;
    pkt.systemTimestamp = frame.systemTimestamp;
    pkt.cameraTimestamp = frame.cameraTimestamp;
    pkt.raw = std::make_shared<std::vector<uint8_t>>(std::move(frame.raw));

    if (!m_manager) {
        emit sigLog(m_slot, QString("BatchManager为空 seq=%1").arg(currentSeq));
        emit sigGrabStat(m_slot, (int)timer.elapsed(), false, (int)CamGrabMode::Auto);
        return false;
    }

    BatchBuffer* batch = m_manager->currentBatch();
    if (!batch) {
        emit sigLog(m_slot, QString("BatchBuffer为空 seq=%1").arg(currentSeq));
        emit sigGrabStat(m_slot, (int)timer.elapsed(), false, (int)CamGrabMode::Auto);
        return false;
    }

    batch->pushFrame(m_slot, pkt);

    lk.relock();
    m_burstSuccessCount++;
    lk.unlock();


    return true;
}

该方法可以实现多相机同时拍照,接近少量延时。设备配置

Cpu208核,32个网口,千兆网络避免带宽风险

相关推荐
一直在想名1 天前
Flutter 框架跨平台鸿蒙开发 - 胶片相机模拟
数码相机·flutter·华为·harmonyos
CoderIsArt1 天前
半导体加工平台的视觉定位方案
数码相机
格林威1 天前
GigE Vision 多相机同步终极检查清单(可直接用于项目部署)
开发语言·人工智能·数码相机·机器学习·计算机视觉·视觉检测·工业相机
Utopia^1 天前
Flutter 框架跨平台鸿蒙开发 - 闪回相机
数码相机·flutter·华为·harmonyos
宇卿.1 天前
机器视觉硬件【相机篇】
数码相机·计算机视觉
格林威2 天前
SSD 写入速度测试命令(Linux)(基于工业相机高速存储)
linux·运维·开发语言·人工智能·数码相机·计算机视觉·工业相机
格林威3 天前
工业相机异常处理实战:断连重连、丢帧检测、超时恢复状态机
开发语言·人工智能·数码相机·计算机视觉·视觉检测·机器视觉·工业相机
_李小白4 天前
【OSG学习笔记】Day 31: 渲染到纹理(RTT)
笔记·数码相机·学习
双翌视觉5 天前
基于机器视觉实现开口卡簧自动化装配
运维·数码相机·自动化