天天看點

Qt 地震剖面圖(或者叫地震擺動圖,波形變面積圖)

Qt 地震剖面圖(或者叫地震擺動圖,波形變面積圖)

0: 項目需求

近期項目有了新的需求, 需要根據地震資料繪制出對應圖表, 關于這種圖的資料比較少, 翻了不少網站, 也沒找到太多有用的資料, 而關于Qt的, 更是隻有一篇論文. 不過搜這麼多資料也不是一無所獲, 最起碼知道了這種圖的名字. 如标題所示, 下文統一稱其為地震剖面圖.

1: 圖形分析: 

上圖是我查資料時找到的一張地震剖面圖的圖檔, 可以看出,橫軸代表通道, 縱軸代表時間, 圖表中的折線按照則代表了震動的強度和方向(這一點說的可能不準确),  震動起來的部分,超出某個值的, 則将波峰染成黑色, 波谷則不做處理

2: Qt效果展示

在繼續分析之前, 先看下用Qt實作的效果

Qt 地震剖面圖(或者叫地震擺動圖,波形變面積圖)

3: 圖形分解

如第一張圖所示,   圖中的折線, 坐标軸等, 可以用QCustomplot來實作. 關于波峰染色,  QCustomPlot在使用時, 如果設定了Brush筆刷, 那麼其實就自帶染色, 隻不過效果可能與我們的需求有差異.

下面是一個簡單的折線圖, 然後加上筆刷 截圖, 可以看到, 箭頭指向的部分, 就是我們需要的染色效果. 但是這和我們的需求其實還是有些差距

Qt 地震剖面圖(或者叫地震擺動圖,波形變面積圖)

 主要有2點

1)  這裡的染色是以0為基礎,  顔色值從0到波形的折線圖中進行填充, 并不能控制 "振幅超出某個值以後, 進行填充的效果,  并且, 地震剖面圖是有很多條曲線的, 不可能都以0為起點,必然要加上偏移量顯示"

2) 圖形是橫着的, 地震剖面圖一般都是豎着, 是以這一點也需要做點調整 

4: 首先嘗試現有方案

QCustomPlot中, QGraph類有一個setChannelFillGraph函數, 可以将2個圖層之間的部分進行填充, 下邊進行測試

1) 先建立2個圖層, 并設定資料看看

QVector<double> x, y, y2;
    for(int i=0; i<11; i++)
    {
        x.push_back(i);

        if(i%3 == 0 || i%4 == 0 || i%8 == 0)
        {
            y.push_back(25);
        }
        else
        {
            y.push_back(10);
        }

        y2.push_back(20);

    }

    //設定互動屬性, 可拖動,可縮放
    customPlot->setInteractions(QCP::iRangeDrag | QCP::iRangeZoom);

    //添加圖層, 并設定畫筆和筆刷顔色
    customPlot->addGraph();
    customPlot->graph(0)->setPen(QPen(Qt::black));
    customPlot->graph(0)->setBrush(QColor(255, 0, 0, 50));

    //設定資料進去
    customPlot->graph(0)->setData(x, y);


    //添加圖層2, 并設定畫筆和筆刷顔色
    customPlot->addGraph();
    customPlot->graph(1)->setPen(QPen(Qt::red));
    customPlot->graph(1)->setBrush(QColor(0, 0, 255, 50));

    //設定資料進去
    customPlot->graph(1)->setData(x, y2);

    //圖層間填充
    customPlot->graph(0)->setChannelFillGraph(customPlot->graph(1));
    
    //設定坐标軸範圍
    customPlot->xAxis->setRange(0, 10);
    customPlot->yAxis->setRange(-10, 30);

    //重繪
    customPlot->replot();
           

運作起來, 結果是這樣的

Qt 地震剖面圖(或者叫地震擺動圖,波形變面積圖)

 如圖所示, 比起直接設定預設筆刷, 圖層間填充确實可以實作以某條線為分界, 然後以該線為中心, 進行填充( 或許這樣看和直接設定筆刷沒啥差別, 但是入如果把紅線的筆刷透明, 或者直接把設定圖層1筆刷的代碼注釋掉的話, 就能看出來差別了 )

//    customPlot->graph(1)->setBrush(QColor(0, 0, 255, 50));
           
Qt 地震剖面圖(或者叫地震擺動圖,波形變面積圖)

那麼,  問題來了, 是不是按照這個思路下去, 把紅線下邊的填充去掉, 隻把紅線上方的部分進行填充. 就完成任務了?

我認為是不行的,  使用這種方式, 顯示一條曲線, 需要2個圖層, 并且資料量也是要翻倍的. 如果隻顯示一條線還好說, 顯示的線條數量很多的話, 必然會導緻卡頓.

 5: 源碼分析

現有代碼無法滿足需求, 那麼就隻能對QCustomPlot進行二次開發了. 想要解決這個問題的第一步, 就是要找到, 筆刷填充部分的繪制邏輯

進入到QCustomplot.cpp源碼中, 查找QCPGraph的 draw函數

void QCPGraph::draw(QCPPainter *painter)
{
    if (!mKeyAxis || !mValueAxis) { qDebug() << Q_FUNC_INFO << "invalid key or value axis"; return; }
    if (mKeyAxis.data()->range().size() <= 0 || mDataContainer->isEmpty()) return;
    if (mLineStyle == lsNone && mScatterStyle.isNone()) return;

    QVector<QPointF> lines, scatters; // line and (if necessary) scatter pixel coordinates will be stored here while iterating over segments

    // loop over and draw segments of unselected/selected data:
    QList<QCPDataRange> selectedSegments, unselectedSegments, allSegments;
    getDataSegments(selectedSegments, unselectedSegments);
    allSegments << unselectedSegments << selectedSegments;
    for (int i=0; i<allSegments.size(); ++i)
    {
        bool isSelectedSegment = i >= unselectedSegments.size();
        // get line pixel points appropriate to line style:
        QCPDataRange lineDataRange = isSelectedSegment ? allSegments.at(i) : allSegments.at(i).adjusted(-1, 1); // unselected segments extend lines to bordering selected data point (safe to exceed total data bounds in first/last segment, getLines takes care)
        getLines(&lines, lineDataRange);

        // check data validity if flag set:
#ifdef QCUSTOMPLOT_CHECK_DATA
        QCPGraphDataContainer::const_iterator it;
        for (it = mDataContainer->constBegin(); it != mDataContainer->constEnd(); ++it)
        {
            if (QCP::isInvalidData(it->key, it->value))
                qDebug() << Q_FUNC_INFO << "Data point at" << it->key << "invalid." << "Plottable name:" << name();
        }
#endif
        //注釋的還是比較清除的, 在這裡進行了圖形填充的繪制
        // draw fill of graph:
        if (isSelectedSegment && mSelectionDecorator)
            mSelectionDecorator->applyBrush(painter);
        else
            painter->setBrush(mBrush);
        painter->setPen(Qt::NoPen);
        drawFill(painter, &lines);

        // draw line:
        if (mLineStyle != lsNone)
        {
            if (isSelectedSegment && mSelectionDecorator)
                mSelectionDecorator->applyPen(painter);
            else
                painter->setPen(mPen);
            painter->setBrush(Qt::NoBrush);
            if (mLineStyle == lsImpulse)
                drawImpulsePlot(painter, lines);
            else
                drawLinePlot(painter, lines); // also step plots can be drawn as a line plot
        }

        // draw scatters:
        QCPScatterStyle finalScatterStyle = mScatterStyle;
        if (isSelectedSegment && mSelectionDecorator)
            finalScatterStyle = mSelectionDecorator->getFinalScatterStyle(mScatterStyle);
        if (!finalScatterStyle.isNone())
        {
            getScatters(&scatters, allSegments.at(i));
            drawScatterPlot(painter, scatters, finalScatterStyle);
        }
    }

    // draw other selection decoration that isn't just line/scatter pens and brushes:
    if (mSelectionDecorator)
        mSelectionDecorator->drawDecoration(painter, selection());
}
           

上邊的代碼段是QCPGraph的draw函數内容, 如注釋所示, 繪制填充的地方, 在drawFill函數中

Qt 地震剖面圖(或者叫地震擺動圖,波形變面積圖)

那麼我們進入到drawFill函數中看一下 

void QCPGraph::drawFill(QCPPainter *painter, QVector<QPointF> *lines) const
{
    if (mLineStyle == lsImpulse) return; // fill doesn't make sense for impulse plot
    if (painter->brush().style() == Qt::NoBrush || painter->brush().color().alpha() == 0) return;

    applyFillAntialiasingHint(painter);
    const QVector<QCPDataRange> segments = getNonNanSegments(lines, keyAxis()->orientation());
    //如果沒有設定與目标圖層繪圖的話, 就繪制一個從曲線到坐标軸的0位置的閉合圖形
    if (!mChannelFillGraph)
    {
        // draw base fill under graph, fill goes all the way to the zero-value-line:
        foreach (QCPDataRange segment, segments)
            painter->drawPolygon(getFillPolygon(lines, segment));

    }
    else         //如果設定了目标圖層填充, 那就繪制從目前圖層到目标圖層填充的閉合圖形
    {
        // draw fill between this graph and mChannelFillGraph:
        QVector<QPointF> otherLines;
        mChannelFillGraph->getLines(&otherLines, QCPDataRange(0, mChannelFillGraph->dataCount()));
        if (!otherLines.isEmpty())
        {
            QVector<QCPDataRange> otherSegments = getNonNanSegments(&otherLines, mChannelFillGraph->keyAxis()->orientation());
            QVector<QPair<QCPDataRange, QCPDataRange> > segmentPairs = getOverlappingSegments(segments, lines, otherSegments, &otherLines);
            for (int i=0; i<segmentPairs.size(); ++i)
                painter->drawPolygon(getChannelFillPolygon(lines, segmentPairs.at(i).first, &otherLines, segmentPairs.at(i).second));
        }
    }
}
           

上邊的代碼段我加了注釋

mChannelFillGraph 變量是從哪來的, 我們可以看一下

void QCPGraph::setChannelFillGraph(QCPGraph *targetGraph)
{
    // prevent setting channel target to this graph itself:
    if (targetGraph == this)
    {
        qDebug() << Q_FUNC_INFO << "targetGraph is this graph itself";
        mChannelFillGraph = nullptr;
        return;
    }
    // prevent setting channel target to a graph not in the plot:
    if (targetGraph && targetGraph->mParentPlot != mParentPlot)
    {
        qDebug() << Q_FUNC_INFO << "targetGraph not in same plot";
        mChannelFillGraph = nullptr;
        return;
    }
    
    /* 
        沒錯, 我們之前測試的時候, 進行圖層間填充, 調用了 setChannelFillGraph 函數, 而這個函數
        最終會修改 mChannelFillGraph 的值
    */ 
    mChannelFillGraph = targetGraph;
}
           

如圖所示, 如果我們調用了圖層間填充的設定函數,  drawFill函數, 就會進入到 else 選項中, 否則就是繪制從曲線到坐标軸0線的填充, painter->drawPolygon(), 這個函數是QPainter對象的繪制多邊形的函數, 在這裡我們暫且跳過, 我們需要關注的 是 getFillPolygon, 從名字上可以看出, 這個函數傳回一個将要被填充的多邊形範圍

Qt 地震剖面圖(或者叫地震擺動圖,波形變面積圖)

那麼我們進入到 getFillPolygon 函數看看

//此函數傳回一個形狀, 依靠這個形狀進行繪圖
const QPolygonF QCPGraph::getFillPolygon(const QVector<QPointF> *lineData, QCPDataRange segment) const
{
    if (segment.size() < 2)
        return QPolygonF();
      QPolygonF result(segment.size()+2);
      result[0] = getFillBasePoint(lineData->at(segment.begin()));
      std::copy(lineData->constBegin()+segment.begin(), lineData->constBegin()+segment.end(), result.begin()+1);
      result.push_back(getFillBasePoint(lineData->at(segment.end()-1)));
      result[result.size()-1] = getFillBasePoint(lineData->at(segment.end()-1));
      return result;
}
           

getFillPolygon 函數, 通過傳遞進來的折線圖的頂點清單, 再結合 getFillBasePoint 函數擷取基線坐标(基線坐标指的是 X軸為水準坐标軸的情況下,  坐标軸 Y軸為0, X軸最左邊和X軸最右邊, 擷取到的2個坐标). 這也就解釋了為什麼圖形填充為什麼總是填充到0

那麼我們需要動的地方, 就在這裡了, 有折線圖的頂點坐标清單, 我們就能根據自己的需求, 實作一個我們想要的填充多邊形

為了驗證我們的猜想, 這裡先進行一下測試, 直接傳回一個固定形狀, 看下是否會繪制出來

注意, 需要把 之前設定的圖層間填充的代碼屏蔽掉

// customPlot->graph(0)->setChannelFillGraph(customPlot->graph(1));
           

修改getFillPolygon函數如下

//此函數傳回一個形狀, 依靠這個形狀進行繪圖
const QPolygonF QCPGraph::getFillPolygon(const QVector<QPointF> *lineData, QCPDataRange segment) const
{
    //        if (segment.size() < 2)
//            return QPolygonF();
//        QPolygonF result(segment.size()+2);
//        result[0] = getFillBasePoint(lineData->at(segment.begin()));


//        std::copy(lineData->constBegin()+segment.begin(), lineData->constBegin()+segment.end(), result.begin()+1);


//        result.push_back(getFillBasePoint(lineData->at(segment.end()-1)));
//        result[result.size()-1] = getFillBasePoint(lineData->at(segment.end()-1));
//        return result;

        //這裡我們直接傳回一個三角形
        QPolygonF result;
        result.append(QPointF(150, 50));
        result.append(QPointF(50, 150));
        result.append(QPointF(250, 150));
        return result;


}
           

然後編譯運作, 結果如下所示

Qt 地震剖面圖(或者叫地震擺動圖,波形變面積圖)

 可以看到, 和我們預想的一緻, 确實繪制出來了一個三角形.

既然這樣, 那我們完全可以按照頂點資料, 重新組組裝出來一個多邊形結構, 讓它将超出我們設定的門檻值的資料部分進行填充, 低于該值的則不填充.  

理論可行, 接下來進行修改

再次回到源碼部分, 

//此函數傳回一個形狀, 依靠這個形狀進行繪圖
const QPolygonF QCPGraph::getFillPolygon(const QVector<QPointF> *lineData, QCPDataRange segment) const
{
    if (segment.size() < 2)
        return QPolygonF();
      QPolygonF result(segment.size()+2);
      result[0] = getFillBasePoint(lineData->at(segment.begin()));
      
      //這裡把頂點坐标複制到了多邊形頂點坐标中, 這裡我們做點處理
      std::copy(lineData->constBegin()+segment.begin(), lineData->constBegin()+segment.end(), result.begin()+1);
      
result.push_back(getFillBasePoint(lineData->at(segment.end()-1)));
      result[result.size()-1] = getFillBasePoint(lineData->at(segment.end()-1));
      return result;
}
           

代碼中的std::copy那一句, 把折線圖的頂點坐标複制到了result容器中, result容器,正是儲存填充色的容器, 我們就在複制這一步, 做點東西.

既然是超出門檻值的才進行染色, 那麼不難想到, 我們把資料低于門檻值的, 全部設定成和門檻值相等,  然後把基線也提升到和門檻值相等,  是不是就行了? 

事實上,  如果隻這麼操作的話,  是會有點問題的, 先看下效果

//此函數傳回一個形狀, 依靠這個形狀進行繪圖
const QPolygonF QCPGraph::getFillPolygon(const QVector<QPointF> *lineData, QCPDataRange segment) const
{
    if (segment.size() < 2)
            return QPolygonF();
        QPolygonF result(segment.size()+2);
//        result[0] = getFillBasePoint(lineData->at(segment.begin()));


//        std::copy(lineData->constBegin()+segment.begin(), lineData->constBegin()+segment.end(), result.begin()+1);

        //計算出染色基準線, 這裡把門檻值設定為20, 也就是說, Y軸資料超出20的部分進行染色
        double divding = 20;
        QPointF start((*lineData)[0].x(), mValueAxis->coordToPixel(divding));
        QPointF end((*lineData).last().x(), mValueAxis->coordToPixel(divding));

        //基線起點
        result[0] = start;

        double divdingPix = mValueAxis->coordToPixel(divding);
        for(int i=0; i<lineData->size(); i++)
        {
            if((*lineData)[i].y() <= divdingPix)            //注意, lineData裡邊儲存的是折線圖的圖像坐标, 左上角是0,0, 而不是我們圖形中的左下角是 0,0
            {
                result.push_back((*lineData)[i]);
            }
            else
            {
                //所有低于門檻值的, 都設定成門檻值
                result.push_back(QPointF((*lineData)[i].x(), divdingPix));
            }
        }

        //基線終點
        result[result.size()-1] = end;



//        result.push_back(getFillBasePoint(lineData->at(segment.end()-1)));
//        result[result.size()-1] = getFillBasePoint(lineData->at(segment.end()-1));
        return result;
}
           

将基線位置, 和多邊形生成邏輯進行修改, 最終效果如下圖

Qt 地震剖面圖(或者叫地震擺動圖,波形變面積圖)

可以看到, 低于門檻值的資料确實沒了,  但是圖好像塌下來了, 其實這個也好了解, 我們把低于門檻值的資料全部設定的和門檻值相等,  是以隻有Y軸被提上去了,  但是折線從門檻值線上跨切過去的點并沒有被添加到多邊形頂點坐标中

Qt 地震剖面圖(或者叫地震擺動圖,波形變面積圖)

是以在這之前, 還需要有一步, 就是把X軸的位置找出來, 也就是從門檻值線上跨切過去的坐标

Qt 地震剖面圖(或者叫地震擺動圖,波形變面積圖)

分析出來了原因, 那就繼續往下走,  把這些跨切坐标找到, 并添加到多邊形頂點坐标中

修改之後的代碼如下所示

//此函數傳回一個形狀, 依靠這個形狀進行繪圖
const QPolygonF QCPGraph::getFillPolygon(const QVector<QPointF> *lineData, QCPDataRange segment) const
{
    //閉合區間第一個點: QPointF(40.2451,542)

            //使用交點檢測方式插入資料
            if (segment.size() < 1)
                return QPolygonF();
            QPolygonF result(1);

            //計算出染色基準線
            double  divding = 20;     
            QPointF start((*lineData)[0].x(), mValueAxis->coordToPixel(divding));
            QPointF end((*lineData).last().x(), mValueAxis->coordToPixel(divding));
                   

            //閉合區間第一個點
            result[0] = QPointF(start.x(), mValueAxis->coordToPixel(divding));

            //門檻值線
            QLineF line1(start, end);
            QPointF po;
            int pointCount = 0;

            //生成一個帶有跨切點的頂點坐标清單
            QVector<QPointF> out;
            for(int i=0; i<lineData->size()-1; i++)
            {
                //使用QPointF類的判斷線段交點的函數尋找交點
                QLineF line2((*lineData)[i], (*lineData)[i+1]);

                out.push_back((*lineData)[i]);

                if(line2.intersects(line1, &po))
                {
                    out.push_back(po);
                    ++pointCount;
                }
            }
            out.push_back(lineData->last());          //把缺失的最後一個點補上


            //這裡周遊帶有跨切點的像素坐标清單, 同時對資料大于限定值的資料進行限制
            for(int i=0; i<out.size(); i++)
            {
                //這裡比較的是像素, 是以坐标軸上小的值,在這裡反而會比較大
                if(out[i].y() > start.y())
                {
                    //如果資料值小于門檻值線, 那就設定其和門檻值線相等
                    result.push_back(QPointF(out[i].x(), start.y()));
                }
                else
                {
                    result.push_back(out[i]);
                }

            }
                
            //列印一下頂點數量和交點數量
            qDebug() << u8"總的點數:" << result.size() << u8"交點數量:" << pointCount;


            //閉合區間最後一個點
            result.push_back(end);
            return result;
}
           

上邊的代碼片中, 使用QPointF類的 intersects 函數, 找到了線段的交點, 然後将這個交點也加入到臨時頂點坐标中. 然後對臨時頂點坐标進行周遊, 并将小于門檻值的資料修正到和門檻值相等.

進行完了這一步之後,  輸出就比較接近我們的需求了

Qt 地震剖面圖(或者叫地震擺動圖,波形變面積圖)

調試輸出列印:

總的點數: 19 交點數量: 7
           

 不考慮調試輸出的話, 效果似乎還不錯, 但是, 調試輸出顯示, 頂點數量有19個, 但我們的折線圖中, 其實是沒這麼多頂點的,  而導緻頂點數增加的原因, 就在于小于門檻值的點, 我們把它移動到了門檻值的Y軸位置,  但這一步其實可以省略. 因為有了最左側和最右側的藍色圈位置的頂點, 就已經可以決定填充色多邊形的走向了. 是以, 可以把上邊代碼中的小于門檻值部分, 移動到門檻值部分的代碼注釋掉, 直接丢棄這個頂點坐标

//這裡比較的是像素, 是以坐标軸上小的值,在這裡反而會比較大
if(out[i].y() > start.y())
{
     //資料值小于門檻值線, 那就完全可以丢棄了, 這裡就不往頂點資料裡邊加了, 直接注釋掉
     //result.push_back(QPointF(out[i].x(), start.y()));
}
else
{
    result.push_back(out[i]);
}
           

再次運作一下代碼,  顯示的結果一樣, 但是頂點數量就少了

Qt 地震剖面圖(或者叫地震擺動圖,波形變面積圖)

調試輸出列印

總的點數: 14 交點數量: 7
           

可以看到, 相比原來的19個頂點, 現在變成了14個頂點,  這少掉的5個頂點, 其實就是小于門檻值的點

Qt 地震剖面圖(或者叫地震擺動圖,波形變面積圖)

 這5個頂點, 從填充多邊形的頂點中删除掉了, 但是由于跨切交點的存在, 是以對圖形展現并沒有影響, 等到資料量多起來的時候, 這一操作可以有效的省略掉大量的點.

比如我們把點數增加到100看看

總的點數: 168 交點數量: 66
           
總的點數: 118 交點數量: 66
           

僅僅100個資料點的情況下, 就少掉了50個點, 如果每一條線的點數達到幾十K的時候, 這些點數對速度和記憶體的影響就會大起來.

寫到這裡, 後邊其實也就沒什麼好說的了

把圖形豎起來的話,  隻需要把X軸設定成Y軸, Y軸設定成X軸就行了

customPlot->graph(0)->setKeyAxis(ui->customPlot->yAxis);
customPlot->graph(0)->setValueAxis(ui->customPlot->xAxis);
           

然後再加個門檻值變量, 基本上就完事了

6: 效果展示

最後, 在前邊的代碼基礎上, 添加幾個圖層, 加一些資料看下效果

Qt 地震剖面圖(或者叫地震擺動圖,波形變面積圖)
Qt 地震剖面圖(或者叫地震擺動圖,波形變面積圖)
Qt 地震剖面圖(或者叫地震擺動圖,波形變面積圖)
Qt 地震剖面圖(或者叫地震擺動圖,波形變面積圖)

 改個顔色, 就得到了标題圖

Qt 地震剖面圖(或者叫地震擺動圖,波形變面積圖)

繼續閱讀