Created
September 12, 2018 18:08
-
-
Save fernandoc1/d2ac43aa8691348ec2babd13982398ac to your computer and use it in GitHub Desktop.
Save JPG image from baumer camera
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #include "baumer_camera.hpp" | |
| #include <stdio.h> | |
| #include <iostream> | |
| #include <iomanip> | |
| #include <boost/asio.hpp> | |
| #include <opencv2/highgui/highgui.hpp> | |
| float interpolate(float begin, float end, float value) | |
| { | |
| return (value - begin) / (end - begin); | |
| } | |
| void fitMatrix(const cv::Mat& input, cv::Mat1b& output) | |
| { | |
| double minValue, maxValue; | |
| cv::minMaxLoc(input, &minValue, &maxValue); | |
| std::cout << "BaumerCamera::getImage: Min: " << minValue << " Max: " << maxValue << std::endl; | |
| if((output.cols != input.cols) && (output.rows != input.rows)) | |
| { | |
| std::cout << "Allocating cv mat" << std::endl; | |
| cv::Mat1b newImage(input.rows, input.cols); | |
| output = newImage; | |
| } | |
| int matSize = input.cols * input.rows; | |
| for(int i = 0; i < matSize; i++) | |
| { | |
| uint16_t* inputData = (uint16_t*)input.data; | |
| output.data[i] = interpolate(minValue, maxValue, inputData[i]) * 256; | |
| } | |
| } | |
| void BaumerCamera::openSystem() | |
| try | |
| { | |
| this->systemList = BGAPI2::SystemList::GetInstance(); | |
| this->systemList->Refresh(); | |
| std::cout << "5.1.2 Detected systems: " << this->systemList->size() << std::endl; | |
| //SYSTEM DEVICE INFORMATION | |
| for (BGAPI2::SystemList::iterator sysIterator = systemList->begin(); sysIterator != systemList->end(); sysIterator++) | |
| { | |
| std::cout << " 5.2.1 System Name: " << sysIterator->second->GetFileName() << std::endl; | |
| std::cout << " System Type: " << sysIterator->second->GetTLType() << std::endl; | |
| std::cout << " System Version: " << sysIterator->second->GetVersion() << std::endl; | |
| std::cout << " System PathName: " << sysIterator->second->GetPathName() << std::endl << std::endl; | |
| if(sysIterator->second->GetFileName() == "libbgapi2_gige.cti") | |
| { | |
| this->sSystemID = sysIterator->first; | |
| this->pSystem = sysIterator->second; | |
| this->pSystem->Open(); | |
| } | |
| } | |
| } | |
| catch (BGAPI2::Exceptions::IException& ex) | |
| { | |
| this->handleException(ex); | |
| } | |
| void BaumerCamera::openInterface(std::string ifname) | |
| try | |
| { | |
| this->interfaceList = this->pSystem->GetInterfaces(); | |
| this->interfaceList->Refresh(100); | |
| for (BGAPI2::InterfaceList::iterator ifIterator = interfaceList->begin(); ifIterator != interfaceList->end(); ifIterator++) | |
| { | |
| std::cout << " 5.2.2 Interface ID: " << ifIterator->first << std::endl; | |
| std::cout << " Interface Type: " << ifIterator->second->GetTLType() << std::endl; | |
| std::cout << " Interface Name: " << ifIterator->second->GetDisplayName() << std::endl << std::endl; | |
| std::string ifDisplayName(ifIterator->second->GetDisplayName()); | |
| if(ifDisplayName == ifname) | |
| { | |
| this->sInterfaceID = ifIterator->first; | |
| this->pInterface = ifIterator->second; | |
| this->pInterface->Open(); | |
| } | |
| } | |
| } | |
| catch (BGAPI2::Exceptions::IException& ex) | |
| { | |
| this->handleException(ex); | |
| } | |
| void BaumerCamera::openDevice(std::string deviceIP) | |
| try | |
| { | |
| unsigned long hexIPValue = boost::asio::ip::address_v4::from_string(deviceIP).to_ulong(); | |
| this->deviceList = this->pInterface->GetDevices(); | |
| this->deviceList->Refresh(100); | |
| for(BGAPI2::DeviceList::iterator devIterator = deviceList->begin(); devIterator != deviceList->end(); devIterator++) | |
| { | |
| std::cout << " 5.2.3 Device DeviceID: " << devIterator->first << std::endl; | |
| std::cout << " Device Model: " << devIterator->second->GetModel() << std::endl; | |
| std::cout << " Device SerialNumber: " << devIterator->second->GetSerialNumber() << std::endl; | |
| std::cout << " Device Vendor: " << devIterator->second->GetVendor() << std::endl; | |
| std::cout << " Device TLType: " << devIterator->second->GetTLType() << std::endl; | |
| std::cout << " Device AccessStatus: " << devIterator->second->GetAccessStatus() << std::endl; | |
| std::cout << " Device UserID: " << devIterator->second->GetDisplayName() << std::endl << std::endl; | |
| this->pDevice = devIterator->second; | |
| this->pDevice->Open(); | |
| std::string deviceGevCurrentIPAddress(devIterator->second->GetRemoteNode("GevCurrentIPAddress")->GetValue()); | |
| std::cout << "BaumerCamera::openDevice: GevCurrentIPAddress " << deviceGevCurrentIPAddress << std::endl; | |
| char bufferStr[32]; | |
| unsigned long deviceIPValue = strtoul(deviceGevCurrentIPAddress.c_str(), NULL, 16); | |
| std::cout << "BaumerCamera::openDevice: deviceIPValue " << deviceIPValue << std::endl; | |
| std::cout << "BaumerCamera::openDevice: hexIPValue " << hexIPValue << std::endl; | |
| if(deviceIPValue == hexIPValue) | |
| { | |
| this->sDeviceID = devIterator->first; | |
| this->pDevice = devIterator->second; | |
| break; | |
| } | |
| else | |
| { | |
| this->pDevice->Close(); | |
| } | |
| } | |
| } | |
| catch (BGAPI2::Exceptions::IException& ex) | |
| { | |
| this->handleException(ex); | |
| } | |
| void BaumerCamera::openDataStream() | |
| try | |
| { | |
| //COUNTING AVAILABLE DATASTREAMS | |
| this->datastreamList = pDevice->GetDataStreams(); | |
| this->datastreamList->Refresh(); | |
| std::cout << "5.1.8 Detected datastreams: " << datastreamList->size() << std::endl; | |
| //DATASTREAM INFORMATION BEFORE OPENING | |
| for (BGAPI2::DataStreamList::iterator dstIterator = datastreamList->begin(); dstIterator != datastreamList->end(); dstIterator++) | |
| { | |
| std::cout << " 5.2.4 DataStream ID: " << dstIterator->first << std::endl << std::endl; | |
| try | |
| { | |
| this->sDataStreamID = dstIterator->first; | |
| this->pDataStream = dstIterator->second; | |
| this->pDataStream->Open(); | |
| break; | |
| } | |
| catch(BGAPI2::Exceptions::IException& ex) | |
| { | |
| std::cout << "ExceptionType: " << ex.GetType() << std::endl; | |
| std::cout << "ErrorDescription: " << ex.GetErrorDescription() << std::endl; | |
| std::cout << "in function: " << ex.GetFunctionName() << std::endl; | |
| } | |
| } | |
| } | |
| catch (BGAPI2::Exceptions::IException& ex) | |
| { | |
| this->handleException(ex); | |
| } | |
| void BaumerCamera::openBufferList() | |
| try | |
| { | |
| //BufferList | |
| bufferList = pDataStream->GetBufferList(); | |
| // 4 buffers using internal buffer mode | |
| for(int i=0; i<4; i++) | |
| { | |
| pBuffer = new BGAPI2::Buffer(); | |
| bufferList->Add(pBuffer); | |
| } | |
| std::cout << "5.1.10 Announced buffers: " << bufferList->GetAnnouncedCount() << " using " << pBuffer->GetMemSize() * bufferList->GetAnnouncedCount() << " [bytes]" << std::endl; | |
| for (BGAPI2::BufferList::iterator bufIterator = bufferList->begin(); bufIterator != bufferList->end(); bufIterator++) | |
| { | |
| bufIterator->second->QueueBuffer(); | |
| } | |
| std::cout << "5.1.11 Queued buffers: " << bufferList->GetQueuedCount() << std::endl; | |
| } | |
| catch (BGAPI2::Exceptions::IException& ex) | |
| { | |
| this->handleException(ex); | |
| } | |
| void BaumerCamera::startDataStreamAcquisition() | |
| try | |
| { | |
| pDataStream->StartAcquisitionContinuous(); | |
| std::cout << "5.1.12 DataStream started " << std::endl; | |
| } | |
| catch (BGAPI2::Exceptions::IException& ex) | |
| { | |
| this->handleException(ex); | |
| } | |
| void BaumerCamera::startCamera() | |
| try | |
| { | |
| std::cout << "5.1.12 " << pDevice->GetModel() << " started " << std::endl; | |
| pDevice->GetRemoteNode("AcquisitionStart")->Execute(); | |
| } | |
| catch (BGAPI2::Exceptions::IException& ex) | |
| { | |
| this->handleException(ex); | |
| } | |
| int BaumerCamera::getHeight() | |
| { | |
| BGAPI2::Node* node = this->pDevice->GetRemoteNode("ImageFormatControl"); | |
| BGAPI2::NodeMap* nodeMap = node->GetNodeList(); | |
| BGAPI2::Node* heightNode = nodeMap->GetNode("Height"); | |
| return heightNode->GetInt(); | |
| } | |
| int BaumerCamera::getWidth() | |
| { | |
| BGAPI2::Node* node = this->pDevice->GetRemoteNode("ImageFormatControl"); | |
| BGAPI2::NodeMap* nodeMap = node->GetNodeList(); | |
| BGAPI2::Node* widthNode = nodeMap->GetNode("Width"); | |
| return widthNode->GetInt(); | |
| } | |
| void BaumerCamera::set14BitMono() | |
| { | |
| BGAPI2::Node* node = this->pDevice->GetRemoteNode("ImageFormatControl"); | |
| BGAPI2::NodeMap* nodeMap = node->GetNodeList(); | |
| BGAPI2::Node* pixelFormatNode = nodeMap->GetNode("PixelFormat"); | |
| std::cout << "BaumerCamera::set14BitMono: " << pixelFormatNode->GetValue() << std::endl; | |
| std::cout << "BaumerCamera::set14BitMono: IsWriteable " << pixelFormatNode->IsWriteable() << std::endl; | |
| BGAPI2::String mono14Str("Mono14"); | |
| pixelFormatNode->SetValue(mono14Str); | |
| std::cout << "BaumerCamera::set14BitMono: " << pixelFormatNode->GetValue() << std::endl; | |
| } | |
| void BaumerCamera::getImage(cv::Mat1b& image) | |
| { | |
| cv::Mat img16Bit; | |
| this->getImage(img16Bit); | |
| fitMatrix(img16Bit, image); | |
| return; | |
| } | |
| void BaumerCamera::getImage(cv::Mat& image) | |
| { | |
| std::cout << " " << std::endl; | |
| std::cout << "CAPTURE IMAGE BY IMAGE POLLING" << std::endl; | |
| std::cout << "##############################" << std::endl << std::endl; | |
| BGAPI2::Buffer * pBufferFilled = NULL; | |
| try | |
| { | |
| pBufferFilled = pDataStream->GetFilledBuffer(1000); //timeout 1000 msec | |
| if((image.total() * image.elemSize()) != pBufferFilled->GetMemSize()) | |
| { | |
| std::cout << "BaumerCamera::getImage: filled buffer" << std::endl; | |
| int imgHeight = this->getHeight(); | |
| int imgWidth = this->getWidth(); | |
| image = cv::Mat(imgHeight, imgWidth, CV_16UC1); | |
| } | |
| memcpy(image.data, pBufferFilled->GetMemPtr(), pBufferFilled->GetMemSize()); | |
| if(pBufferFilled == NULL) | |
| { | |
| std::cout << "Error: Buffer Timeout after 1000 msec" << std::endl; | |
| } | |
| else if(pBufferFilled->GetIsIncomplete() == true) | |
| { | |
| std::cout << "Error: Image is incomplete" << std::endl; | |
| // queue buffer again | |
| pBufferFilled->QueueBuffer(); | |
| } | |
| else | |
| { | |
| std::cout << " Image " << std::setw(5) << pBufferFilled->GetFrameID() << " received in memory address " << std::hex << pBufferFilled->GetMemPtr() << std::dec << std::endl; | |
| // queue buffer again | |
| pBufferFilled->QueueBuffer(); | |
| } | |
| } | |
| catch (BGAPI2::Exceptions::IException& ex) | |
| { | |
| this->handleException(ex); | |
| } | |
| std::cout << " " << std::endl; | |
| } | |
| void BaumerCamera::saveRawImage(std::string filepath) | |
| { | |
| std::cout << " " << std::endl; | |
| std::cout << "CAPTURE IMAGE BY IMAGE POLLING" << std::endl; | |
| std::cout << "##############################" << std::endl << std::endl; | |
| BGAPI2::Buffer * pBufferFilled = NULL; | |
| try | |
| { | |
| pBufferFilled = pDataStream->GetFilledBuffer(1000); //timeout 1000 msec | |
| FILE* fp = fopen(filepath.c_str(), "w"); | |
| fwrite(pBufferFilled->GetMemPtr(), pBufferFilled->GetMemSize(), 1, fp); | |
| fclose(fp); | |
| if(pBufferFilled == NULL) | |
| { | |
| std::cout << "Error: Buffer Timeout after 1000 msec" << std::endl; | |
| } | |
| else if(pBufferFilled->GetIsIncomplete() == true) | |
| { | |
| std::cout << "Error: Image is incomplete" << std::endl; | |
| // queue buffer again | |
| pBufferFilled->QueueBuffer(); | |
| } | |
| else | |
| { | |
| std::cout << " Image " << std::setw(5) << pBufferFilled->GetFrameID() << " received in memory address " << std::hex << pBufferFilled->GetMemPtr() << std::dec << std::endl; | |
| // queue buffer again | |
| pBufferFilled->QueueBuffer(); | |
| } | |
| } | |
| catch (BGAPI2::Exceptions::IException& ex) | |
| { | |
| this->handleException(ex); | |
| } | |
| std::cout << " " << std::endl; | |
| } | |
| void BaumerCamera::stopCamera() | |
| try | |
| { | |
| //SEARCH FOR 'AcquisitionAbort' | |
| if(pDevice->GetRemoteNodeList()->GetNodePresent("AcquisitionAbort")) | |
| { | |
| pDevice->GetRemoteNode("AcquisitionAbort")->Execute(); | |
| std::cout << "5.1.12 " << pDevice->GetModel() << " aborted " << std::endl; | |
| } | |
| pDevice->GetRemoteNode("AcquisitionStop")->Execute(); | |
| std::cout << "5.1.12 " << pDevice->GetModel() << " stopped " << std::endl; | |
| std::cout << std::endl; | |
| BGAPI2::String sExposureNodeName = ""; | |
| if (pDevice->GetRemoteNodeList()->GetNodePresent("ExposureTime")) { | |
| sExposureNodeName = "ExposureTime"; | |
| } | |
| else if (pDevice->GetRemoteNodeList()->GetNodePresent("ExposureTimeAbs")) { | |
| sExposureNodeName = "ExposureTimeAbs"; | |
| } | |
| std::cout << " ExposureTime: " << std::fixed << std::setprecision(0) << pDevice->GetRemoteNode(sExposureNodeName)->GetDouble() << " [" << pDevice->GetRemoteNode(sExposureNodeName)->GetUnit() << "]" << std::endl; | |
| if( pDevice->GetTLType() == "GEV" ) | |
| { | |
| if(pDevice->GetRemoteNodeList()->GetNodePresent("DeviceStreamChannelPacketSize")) | |
| std::cout << " DeviceStreamChannelPacketSize: " << pDevice->GetRemoteNode("DeviceStreamChannelPacketSize")->GetInt() << " [bytes]" << std::endl; | |
| else | |
| std::cout << " GevSCPSPacketSize: " << pDevice->GetRemoteNode("GevSCPSPacketSize")->GetInt() << " [bytes]" << std::endl; | |
| std::cout << " GevSCPD (PacketDelay): " << pDevice->GetRemoteNode("GevSCPD")->GetInt() << " [tics]" << std::endl; | |
| } | |
| std::cout << std::endl; | |
| } | |
| catch (BGAPI2::Exceptions::IException& ex) | |
| { | |
| this->handleException(ex); | |
| } | |
| void BaumerCamera::stopDataStream() | |
| try | |
| { | |
| if( pDataStream->GetTLType() == "GEV" ) | |
| { | |
| //DataStream Statistic | |
| std::cout << " DataStream Statistics " << std::endl; | |
| std::cout << " DataBlockComplete: " << pDataStream->GetNodeList()->GetNode("DataBlockComplete")->GetInt() << std::endl; | |
| std::cout << " DataBlockInComplete: " << pDataStream->GetNodeList()->GetNode("DataBlockInComplete")->GetInt() << std::endl; | |
| std::cout << " DataBlockMissing: " << pDataStream->GetNodeList()->GetNode("DataBlockMissing")->GetInt() << std::endl; | |
| std::cout << " PacketResendRequestSingle: " << pDataStream->GetNodeList()->GetNode("PacketResendRequestSingle")->GetInt() << std::endl; | |
| std::cout << " PacketResendRequestRange: " << pDataStream->GetNodeList()->GetNode("PacketResendRequestRange")->GetInt() << std::endl; | |
| std::cout << " PacketResendReceive: " << pDataStream->GetNodeList()->GetNode("PacketResendReceive")->GetInt() << std::endl; | |
| std::cout << " DataBlockDroppedBufferUnderrun: " << pDataStream->GetNodeList()->GetNode("DataBlockDroppedBufferUnderrun")->GetInt() << std::endl; | |
| std::cout << " Bitrate: " << pDataStream->GetNodeList()->GetNode("Bitrate")->GetDouble() << std::endl; | |
| std::cout << " Throughput: " << pDataStream->GetNodeList()->GetNode("Throughput")->GetDouble() << std::endl; | |
| std::cout << std::endl; | |
| } | |
| if( pDataStream->GetTLType() == "U3V" ) | |
| { | |
| //DataStream Statistic | |
| std::cout << " DataStream Statistics " << std::endl; | |
| std::cout << " GoodFrames: " << pDataStream->GetNodeList()->GetNode("GoodFrames")->GetInt() << std::endl; | |
| std::cout << " CorruptedFrames: " << pDataStream->GetNodeList()->GetNode("CorruptedFrames")->GetInt() << std::endl; | |
| std::cout << " LostFrames: " << pDataStream->GetNodeList()->GetNode("LostFrames")->GetInt() << std::endl; | |
| std::cout << std::endl; | |
| } | |
| //BufferList Information | |
| std::cout << " BufferList Information " << std::endl; | |
| std::cout << " DeliveredCount: " << bufferList->GetDeliveredCount() << std::endl; | |
| std::cout << " UnderrunCount: " << bufferList->GetUnderrunCount() << std::endl; | |
| std::cout << std::endl; | |
| pDataStream->StopAcquisition(); | |
| std::cout << "5.1.12 DataStream stopped " << std::endl; | |
| bufferList->DiscardAllBuffers(); | |
| } | |
| catch (BGAPI2::Exceptions::IException& ex) | |
| { | |
| this->handleException(ex); | |
| } | |
| void BaumerCamera::releaseBuffers() | |
| try | |
| { | |
| while( bufferList->size() > 0) | |
| { | |
| pBuffer = bufferList->begin()->second; | |
| bufferList->RevokeBuffer(pBuffer); | |
| delete pBuffer; | |
| } | |
| std::cout << " buffers after revoke: " << bufferList->size() << std::endl; | |
| pDataStream->Close(); | |
| pDevice->Close(); | |
| pInterface->Close(); | |
| pSystem->Close(); | |
| BGAPI2::SystemList::ReleaseInstance(); | |
| } | |
| catch (BGAPI2::Exceptions::IException& ex) | |
| { | |
| this->handleException(ex); | |
| } | |
| void BaumerCamera::handleException(BGAPI2::Exceptions::IException& ex) | |
| { | |
| std::cout << "ExceptionType: " << ex.GetType() << std::endl; | |
| std::cout << "ErrorDescription: " << ex.GetErrorDescription() << std::endl; | |
| std::cout << "in function: " << ex.GetFunctionName() << std::endl; | |
| } | |
| BaumerCamera::~BaumerCamera() | |
| { | |
| BGAPI2::SystemList::ReleaseInstance(); | |
| } | |
| BaumerCamera::BaumerCamera() | |
| { | |
| } | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #ifndef __BAUMER_CAMERA_HPP__ | |
| #define __BAUMER_CAMERA_HPP__ | |
| #include "bgapi2_genicam/bgapi2_genicam.hpp" | |
| #include <opencv2/highgui/highgui.hpp> | |
| class BaumerCamera | |
| { | |
| BGAPI2::SystemList* systemList; | |
| BGAPI2::System* pSystem; | |
| BGAPI2::String sSystemID; | |
| BGAPI2::InterfaceList* interfaceList; | |
| BGAPI2::Interface* pInterface; | |
| BGAPI2::String sInterfaceID; | |
| BGAPI2::DeviceList* deviceList; | |
| BGAPI2::Device* pDevice; | |
| BGAPI2::String sDeviceID; | |
| BGAPI2::DataStreamList* datastreamList; | |
| BGAPI2::DataStream* pDataStream; | |
| BGAPI2::String sDataStreamID; | |
| BGAPI2::BufferList* bufferList; | |
| BGAPI2::Buffer* pBuffer; | |
| BGAPI2::String sBufferID; | |
| void handleException(BGAPI2::Exceptions::IException& ex); | |
| public: | |
| void openSystem(); | |
| void openInterface(std::string ifname); | |
| void openDevice(std::string deviceIP); | |
| void openDataStream(); | |
| void openBufferList(); | |
| void startDataStreamAcquisition(); | |
| void startCamera(); | |
| int getHeight(); | |
| int getWidth(); | |
| void set14BitMono(); | |
| void getImage(cv::Mat1b& image); | |
| void getImage(cv::Mat& image); | |
| void saveRawImage(std::string filepath); | |
| void stopCamera(); | |
| void stopDataStream(); | |
| void releaseBuffers(); | |
| ~BaumerCamera(); | |
| BaumerCamera(); | |
| }; | |
| #endif | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| cmake_minimum_required(VERSION 2.8.3) | |
| include_directories(/usr/local/src/baumer/inc) | |
| set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -D_GNULINUX -g3") | |
| link_directories( | |
| /usr/local/lib/baumer/ | |
| ${CMAKE_CURRENT_SOURCE_DIR}/src/ | |
| ) | |
| link_libraries( | |
| bgapi2_genicam | |
| boost_system | |
| opencv_core | |
| opencv_highgui | |
| ) | |
| file(GLOB TEST_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/src/baumer_camera.cpp) | |
| add_executable(SaveJPGImage ${TEST_SOURCES} ${CMAKE_CURRENT_SOURCE_DIR}/src/save_jpg_image.cpp) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #include "baumer_camera.hpp" | |
| #include <opencv2/highgui/highgui.hpp> | |
| int main(int argc, char** argv) | |
| { | |
| std::string iface(argv[1]); | |
| std::string devIP(argv[2]); | |
| std::string filepath(argv[3]); | |
| BaumerCamera camera; | |
| camera.openSystem(); | |
| camera.openInterface(iface); | |
| camera.openDevice(devIP); | |
| camera.set14BitMono(); | |
| camera.openDataStream(); | |
| camera.openBufferList(); | |
| camera.startDataStreamAcquisition(); | |
| camera.startCamera(); | |
| cv::Mat1b image; | |
| camera.getImage(image); | |
| cv::imwrite(filepath, image); | |
| camera.stopCamera(); | |
| camera.stopDataStream(); | |
| camera.releaseBuffers(); | |
| return 0; | |
| } | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment