这篇文章详述用Python爬取该训练集,提供了训练集地址,此外还提供了多个可用的google street view static api key。链接如下:https://zhuanlan.zhihu.com/p/34967038
下载好文中所述的训练集文件之后,仔细查看votes.csv及readme.txt文件。写的很清楚,需要对应votes.csv中的每一条数据,拼接街景图片下载的url。vote.csv文件内容如下,一行记录中有两个坐标,通过进一步观察发现,里面也有重复的坐标(街景ID)。因此我们在真正下载图片或拼接url之前还需做一次去重。
当然在这之前还需要申请 google 云控制台的street view static api key,我们也可以直接采用上述文中api.txt文件中的key,但其中大多已不能使用。毕竟是公开的资源,大家都在用,很容易被限制,最好自己和团队成员多申请几个,申请时需要用到VISA信用卡。申请链接如下:https://developers.google.com/maps/documentation/streetview/get-api-key
在有可用key的情况下,我们就可以通过发送GET请求的方式获取街景图片,对应的url如下:
https://maps.googleapis.com/maps/api/streetview?size=400x300&location=39.737314,-104.87407400000001&key=YOUR_API_KEY
业务逻辑流程梳理大致如下:
可用于csv文件解析的工具有很多,如:javacsv、Inputstream等,强烈建议使用现成的优秀工具,不建议自己编写解析逻辑。更不建议一次性读入文件再进行解析。这里采用了一个号称是目前为止最高效的解析工具:univocity-parser,可采用迭代(行扫描)的方式读取每一条记录,详见:https://github.com/uniVocity/univocity-parsers。
univocity-parser使用方法参考:https://blog.csdn.net/qq_21101587/article/details/79803582, 这里不再赘述。
这里需要注意的是,vote.csv文件有将近123万行数据记录,也就是近246万个坐标(含重复),如果一次性读入文件,并存入HashSet的话可能会引起OOM,如果该文件有上亿条数据记录,此方法更不可取。笔者采用的是redis去重,结合redis近乎O(1)的复杂度,能够处理数据量较大情况下的去重。但本训练集数量还远没有达到海量级别,用File类中的exists方法也可以去重。
关于街景坐标去重逻辑主要运用了以下几个命令:
//当redis中存在该key时,跳过;不含该key时,则存入该键值数据
jedis.setnx(key,value);
//检查该key是否存在
jedis.exists(key);
//获取以spider-hgg-googlemap:为正则前缀的所有key集合,返回set
jedis.keys("spider-hgg-googlemap:*");
//删除该key
jedis.del(key);
笔者原本以为new File(“文件路径”).exists()方法会随着本地文件中的图片越来越多而查询变慢,但在实际使用过程中发现该方法在本地图片达到6万多张的时候,执行时间也是毫、微秒级,因此也能高效完成去重。底层原理可能得益于文件索引也是用的B树或哈希索引的方式(本人自己猜测的,没有深入研究)
去重代码就很简单了,传入参数ID,拼接图片路径即可:
private boolean isPicExists(String panoId){
String path = "E:\\temp\\hgg-googlemap\\safety\\"+panoId+".jpg";
File file = new File(path);
return file.exists();
}
这里要注意一点的是,一个key每天的请求上限是2万次(本人亲测是低于2万次/天,不稳定),超过之后就会被限制访问,所以尽量获取更多的key,在拼接url的时候也尽量在有效的key集合中随机选择使用(为确保快速并可靠的下载,及时剔除无效的key),尽可能减少同一key频繁访问的次数。另外一点需要注意的是,需加一个判断图片是否下载成功的逻辑,若下载成功就存储,若不成功还要重新拼接url进行再次下载,直至成功为止。
这一块涉及线程池的使用及线程数合理配置,不熟悉的童鞋可参阅:https://www.cnblogs.com/dolphin0520/p/3932921.html
一般需要根据任务的类型来配置线程池大小:
dependencies {
compile 'com.squareup.okhttp3:okhttp:3.11.0'
compile 'com.demo.ddc:ddc-core:0.1.11-alpha6'
compile 'redis.clients:jedis:2.9.0'
compile 'org.apache.logging.log4j:log4j-core:2.8.2'
compile 'org.apache.commons:commons-pool2:2.4.2'
compile 'com.univocity:univocity-parsers:2.8.2'
}
将vote.csv文件改名为googlemapvotes.csv,并将其置于资源目录下。
先定义一个csv行数据的java bean类:
public class CsvPanoBean {
private String panoId;
private double lati;
private double lonti;
public CsvPanoBean(String panoId,double lati, double lonti){
this.panoId = panoId;
this.lati = lati;
this.lonti = lonti;
}
public String getPanoId() {
return panoId;
}
public void setPanoId(String panoId) {
this.panoId = panoId;
}
public double getLati() {
return lati;
}
public void setLati(double lati) {
this.lati = lati;
}
public double getLonti() {
return lonti;
}
public void setLonti(double lonti) {
this.lonti = lonti;
}
}
编写核心代码,含义详见注释:
protected boolean process() {
String filePath = "/googlemapvotes.csv";
// 创建csv解析器settings配置对象
CsvParserSettings settings = new CsvParserSettings();
// 文件中使用 '\n' 作为行分隔符
// 确保像MacOS和Windows这样的系统
// 也可以正确处理(MacOS使用'\r';Windows使用'\r\n')
settings.getFormat().setLineSeparator("\n");
// 考虑文件中的第一行内容解析为列标题,跳过第一行
settings.setHeaderExtractionEnabled(true);
// 创建CSV解析器(将分隔符传入对象)
CsvParser parser = new CsvParser(settings);
// 调用beginParsing逐个读取记录,使用迭代器iterator
parser.beginParsing(getReader(filePath));
String[] row;
//图片下载工具类
PicLoadUtils picLoadUtils = new PicLoadUtils();
//创建线程池,由于本地机器为8核CPU,故定义10个核心线程,最大线程数为16,且自定义线程工厂类和饱和策略
ThreadPoolExecutor executor = new ThreadPoolExecutor(10, 16, 100, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<>(1024), new MyTreadFactory(), new MyIgnorePolicy());
//预启动所有核心线程
executor.prestartAllCoreThreads();
//解析csv文件并迭代每行记录
while ((row = parser.parseNext()) != null) {
String category = row[7];
//这里根据需求,优先下载safety类型的训练集街景图片
if ("safety".equals(category)){
String leftPanoId = row[0];
String rightPanoId = row[1];
double leftLati = Double.parseDouble(row[3]);
double leftLonti = Double.parseDouble(row[4]);
double rightLati = Double.parseDouble(row[5]);
double rightLonti = Double.parseDouble(row[6]);
CsvPanoBean leftPanoBean = new CsvPanoBean(leftPanoId,leftLati,leftLonti);
CsvPanoBean rightPanoBean = new CsvPanoBean(rightPanoId,rightLati,rightLonti);
CsvPanoBean[] csvPanoBeans = {leftPanoBean,rightPanoBean};
for (CsvPanoBean element:csvPanoBeans){
//判断redis中或本地是否有该街景ID
String panoId = element.getPanoId();
//boolean isExists = isPicExists(panoId);
boolean isExists = redisUtils.isPanoIDExists(panoId);
if (!isExists){
redisUtils.panoIdPush(panoId);
DownloadPicTask task = new DownloadPicTask(picLoadUtils,element);
executor.execute(task);
}else{
logger.info(panoId + " is exist");
}
}
try {
// 这里主线程需要睡一会,否则容易引起多线程下载时的读超时
Thread.sleep(400L);
logger.info("The queue size of Thread Pool is "+ executor.getQueue().size());
}catch (InterruptedException e){
e.printStackTrace();
}
}
}
logger.info("--------------------------crawl finished!--------------------------");
// 在读取结束时自动关闭所有资源,或者当错误发生时,可以在任何使用调用stopParsing()
// 只有在不是读取所有内容的情况下调用下面方法,但如果不调用也没有非常严重的问题
parser.stopParsing();
isComplete = true;
return true;
}
//读文件时定义编码格式
private Reader getReader(String relativePath) {
try {
return new InputStreamReader(this.getClass().getResourceAsStream(relativePath), "UTF-8");
} catch (UnsupportedEncodingException e) {
throw new IllegalStateException("Unable to read input", e);
}
}
//判断本地是否已存在
private boolean isPicExists(String panoId){
String path = "E:\\temp\\hgg-googlemap\\safety\\"+panoId+".jpg";
File file = new File(path);
return file.exists();
}
该工具作用:主要是下载路径的设置及下载图片时的检测
/**
* @author Huigen Zhang
* @since 2018-10-19 18:53
**/
public class PicLoadUtils {
private final static String WINDOWS_DISK_SYMBOL = ":";
private final static String WINDOWS_PATH_SYMBOL = "\\";
private final static int STATUS_CODE = 200;
private String localLocation;
{
//要下载到本地的路径
localLocation = this.getFileLocation("googlepano");
}
private String getFileLocation(String storeDirName){
String separator = "/";
ConfigParser parser = ConfigParser.getInstance();
String spiderId = "spider-googlemap";
SpiderConfig spiderConfig = new SpiderConfig(spiderId);
Map<String,Object> storageConfig = (Map<String, Object>) parser.assertKey(spiderConfig.getSpiderConfig(),"storage", spiderConfig.getConfigPath());
String fileLocation = (String) parser.getValue(storageConfig,"piclocation",null,spiderConfig.getConfigPath()+".storage");
String pathSeparator = getSeparator();
String location;
if(fileLocation!=null){
//先区分系统环境,再判断是否为绝对路径
if (separator.equals(pathSeparator)){
//linux
if(fileLocation.startsWith(separator)){
location = fileLocation + pathSeparator + "data";
}else {
location = System.getProperty("user.dir") + pathSeparator + fileLocation;
}
location = location.replace("//", pathSeparator);
return location;
}else {
//windows
if (fileLocation.contains(WINDOWS_DISK_SYMBOL)){
//绝对路径
location = fileLocation + pathSeparator + "data";
}else {
//相对路径
location = System.getProperty("user.dir") + pathSeparator + fileLocation;
}
location = location.replace("\\\\",pathSeparator);
}
}else{
//默认地址
location = System.getProperty("user.dir") + pathSeparator + storeDirName;
}
return location;
}
private String getSeparator(){
String pathSeparator = File.separator;
if(!WINDOWS_PATH_SYMBOL.equals(File.separator)){
pathSeparator = "/";
}
return pathSeparator;
}
private void mkDir(File file){
String directory = file.getParent();
File myDirectory = new File(directory);
if (!myDirectory.exists()) {
myDirectory.mkdirs();
}
}
public boolean downloadPic(String url, String panoId){
okhttp3.Request request = new okhttp3.Request.Builder()
.url(url)
.build();
Response response = null;
InputStream inputStream = null;
FileOutputStream out = null;
String relativePath;
try {
response = OkHttpUtils.getInstance().newCall(request).execute();
if (response.code()!=STATUS_CODE){
return false;
}
//将响应数据转化为输入流数据
inputStream = response.body().byteStream();
byte[] buffer = new byte[2048];
relativePath = panoId + ".jpg";
File myPath = new File(localLocation + File.separator + relativePath);
this.mkDir(myPath);
out = new FileOutputStream(myPath);
int len;
while ((len = inputStream.read(buffer)) != -1){
out.write(buffer,0,len);
}
//刷新文件流
out.flush();
} catch (IOException e) {
e.printStackTrace();
}finally {
if (inputStream!=null){
try {
inputStream.close();
}catch (IOException e){
e.printStackTrace();
}
}
if (null!=out){
try {
out.close();
}catch (IOException e){
e.printStackTrace();
}
}
if (null!=response){
response.body().close();
}
}
return true;
}
}
主要还是运用了上述redis命令,在这基础上做一层封装:
/**
* @author zhanghuigen
* @since 0.1.0
**/
public class RedisUtils {
private JedisPool pool;
private String spiderUUID;
private static Logger logger = Logger.getLogger(RedisUtils.class);
public RedisUtils(String host, int port, String password, String spiderUUID) {
this(new JedisPool(new JedisPoolConfig(), host, port, 2000, password));
this.spiderUUID = spiderUUID;
}
public RedisUtils(JedisPool pool) {
this.pool = pool;
}
public synchronized Boolean isPanoIDExists(String panoId) {
Jedis jedis = null;
Boolean exists;
try {
jedis = this.pool.getResource();
exists = jedis.exists(this.spiderUUID + ":" + panoId);
return exists;
}finally {
if (jedis!=null){
jedis.close();
}
}
}
public synchronized boolean removeKeys(){
Jedis jedis = this.pool.getResource();
try {
Set<String> keys = jedis.keys(this.spiderUUID + ":*" );
if(keys != null && !keys.isEmpty()) {
logger.info("redis has stored " + keys.size() + " keys, now ready to remove them all!");
String[] array = new String[keys.size()];
jedis.del(keys.toArray(array));
}
return true;
}catch (Exception e){
e.printStackTrace();
}finally {
if (jedis!=null){
jedis.close();
}
}
return true;
}
public synchronized boolean panoIdPush(String panoId) {
Jedis jedis = this.pool.getResource();
try {
long num = jedis.setnx(this.spiderUUID + ":" + panoId, String.valueOf(1));
return num==1;
} finally {
if (jedis!=null){
jedis.close();
}
}
}
}
这里其实也可以运用Callable+Future的模式定义下载任务,详见: https://www.cnblogs.com/hapjin/p/7599189.html 或 https://www.cnblogs.com/myxcf/p/9959870.html
class DownloadPicTask implements Runnable {
private CsvPanoBean taskBean;
private PicLoadUtils picLoadUtils;
private String panoId;
private DownloadPicTask(PicLoadUtils picLoadUtils,CsvPanoBean bean) {
this.picLoadUtils = picLoadUtils;
this.taskBean = bean;
this.panoId = taskBean.getPanoId();
}
@Override
public void run() {
logger.info("正在执行task "+panoId);
String url;
String key;
boolean successDownload;
do {
//拼接街景图片url
String[] urlWithKey = getUrlWithKey(taskBean);
url = urlWithKey[0];
key = urlWithKey[1];
//发送请求,下载图片,直到本图片下载成功为止
successDownload = picLoadUtils.downloadPic(url,panoId);
}while (!successDownload);
logger.info(panoId + " downloaded succeed with " + key);
}
@Override
public String toString(){
return panoId;
}
private String[] getUrlWithKey(){
String requestPrefix = "https://maps.googleapis.com/maps/api/streetview?size=400x300&location=";
String url = requestPrefix + taskBean.getLati() + "," + taskBean.getLonti() + "&key=";
Random random = new Random();
//这里需确保可用的key已经配置在配置文件中,并已读取至一个List----googleKeys中
int index = random.nextInt(5);
String key = googleKeys.get(index);
return new String[]{url+key,key};
}
}
class MyTreadFactory implements ThreadFactory {
private final AtomicInteger mThreadNum = new AtomicInteger(1);
@Override
public Thread newThread(Runnable r) {
Thread t = new Thread(r, "my-thread-" + mThreadNum.getAndIncrement());
logger.info(t.getName() + " has been created");
return t;
}
}
class MyIgnorePolicy implements RejectedExecutionHandler {
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
doLog(r, e);
}
private void doLog(Runnable r, ThreadPoolExecutor e) {
// 将拒绝执行的街景ID写入日志
logger.warn( r.toString() + " rejected");
}
}
若用单线程下载,差不多1秒一张图片,相对低效:
采用线程池后,刚开始线程数量设的较高,也没有在主线程中加入睡眠时间,易出现读超时现象,原因是使用公司代理访问google时,多线程下载使得带宽受限。引起线程迟迟读不到数据后报异常,如下图所示:
通过在主线程添加睡眠时间后,读超时现象消失,可以顺利下载:
在满足带宽条件下,下载速度约5张/秒,