Vision框架详细解析(十六) —— 基于Vision的轮廓检测(一)

版本记录

版本号 时间
V1.0 2022.06.01 星期三

前言

iOS 11+macOS 10.13+ 新出了Vision框架,提供了人脸识别、物体检测、物体跟踪等技术,它是基于Core ML的。可以说是人工智能的一部分,接下来几篇我们就详细的解析一下Vision框架。感兴趣的看下面几篇文章。
1. Vision框架详细解析(一) —— 基本概览(一)
2. Vision框架详细解析(二) —— 基于Vision的人脸识别(一)
3. Vision框架详细解析(三) —— 基于Vision的人脸识别(二)
4. Vision框架详细解析(四) —— 在iOS中使用Vision和Metal进行照片堆叠(一)
5. Vision框架详细解析(五) —— 在iOS中使用Vision和Metal进行照片堆叠(二)
6. Vision框架详细解析(六) —— 基于Vision的显著性分析(一)
7. Vision框架详细解析(七) —— 基于Vision的显著性分析(二)
8. Vision框架详细解析(八) —— 基于Vision的QR扫描(一)
9. Vision框架详细解析(九) —— 基于Vision的QR扫描(二)
10. Vision框架详细解析(十) —— 基于Vision的Body Detect和Hand Pose(一)
11. Vision框架详细解析(十一) —— 基于Vision的Body Detect和Hand Pose(二)
12. Vision框架详细解析(十二) —— 基于Vision的Face Detection新特性(一)
13. Vision框架详细解析(十三) —— 基于Vision的Face Detection新特性(二)
14. Vision框架详细解析(十四) —— 基于Vision的人员分割(一)
15. Vision框架详细解析(十五) —— 基于Vision的人员分割(二)

开始

首先看下主要内容:

了解如何使用 Vision 框架以有趣且艺术的方式检测和修改 SwiftUI iOS 应用程序中的图像轮廓。内容来自翻译

接着看下写作环境

Swift 5.5, iOS 15, Xcode 13

接着就是正文了。

艺术是一个非常主观的东西。但是,编码不是。当然,开发人员有时可能会固执己见,但是计算机如何解释您的代码在很大程度上不是见仁见智的问题。

那么,作为开发人员,您如何使用代码来创作艺术呢?也许这不是你如何编码,而是你选择用它做什么。使用可用的工具获得创意可以显着影响输出。

想想苹果是如何推动计算摄影的极限的。大多数数码摄影都是关于对来自传感器的像素进行后处理。不同的算法集会改变最终输出的外观和感觉。那是艺术!

您甚至可以使用计算机视觉算法为图像和照片创建令人兴奋的滤镜和效果。例如,如果您检测到图像中的所有轮廓,您可能有一些很酷的材料来制作具有艺术感的图像绘图。这就是本教程的全部内容!

在本教程中,您将学习如何使用 Vision 框架:

  • 创建请求以执行轮廓检测。
  • 调整设置以获得不同的轮廓。
  • 简化轮廓以创造艺术效果。

听起来很有趣,对吧?没错……艺术应该很有趣!

打开起始项目,启动项目包括一些扩展、模型文件和 UI。

如果您现在构建并运行,您将看到点击屏幕和功能设置图标的说明。

您可能会注意到现在点击屏幕不会做任何事情,但在您进入本教程的Vision部分之前,您需要在屏幕上显示图像。


Displaying an Image

在浏览启动项目时,您可能已经注意到一个 ImageView 连接到 ContentViewModelimage 属性。 如果您打开 ContentViewModel.swift,您会看到该image是一个已发布的属性,但没有为其分配(assigned)任何内容。

你需要做的第一件事就是改变它!

首先在 ContentViewModel.swift 中定义的三个已发布属性之后直接添加以下代码:

init() {
  let uiImage = UIImage(named: "sample")
  let cgImage = uiImage?.cgImage
  self.image = cgImage  
}

此代码从资产目录加载名为 sample.png 的图像并为其获取 CGImage,然后将其分配给image发布属性。

通过这个小改动,继续构建并重新运行应用程序,您将在屏幕上看到下面的图像:

现在,当您点击屏幕时,它应该在上面的图像和您最初看到的空白屏幕之间切换。

空白屏幕最终将包含您使用 Vision 框架检测到的轮廓。


Vision API Pipeline

在开始编写一些代码来检测轮廓之前,了解 Vision API 管道会很有帮助。 一旦你知道它是如何工作的,你就可以很容易地在你未来的项目中包含任何视觉算法; 这很漂亮。

Vision API 管道由三部分组成:

  • 第一个是request,它是 VNRequest 的子类——所有分析请求的基类。 然后将此请求传递给handler
  • 处理程序handler可以是两种类型之一,VNImageRequestHandlerVNSequenceRequestHandler
  • 最后,结果resultVNObservation 的子类,作为原始request对象的属性返回。

通常,很容易判断哪种结果类型与哪种请求类型对应,因为它们的名称相似。例如,如果您的请求是 VNDetectFaceRectanglesRequest,则返回的结果将是 VNFaceObservation

对于这个项目,请求将是一个 VNDetectContoursRequest,它将以 VNContoursObservation 的形式返回结果。

无论何时处理单个图像,而不是图像序列中的帧,您都将使用 VNImageRequestHandlerVNSequenceRequestHandler 在处理图像序列时使用,您希望将请求应用于一系列相关图像,例如来自视频流的帧。在这个项目中,您将使用前者来处理单个图像请求。

现在您已经掌握了背景理论,是时候将其付诸实践了!


Contour Detection

为了使项目井井有条,在项目导航器中右键单击 Contour Art 组并选择 New Group。将新组命名为 Vision

右键单击新的 Vision 组并选择 New File…。选择
Swift File并将其命名为 ContourDetector

用以下代码替换文件的内容:

import Vision

class ContourDetector {
  static let shared = ContourDetector()
  
  private init() {}
}

这段代码所做的只是将一个新的 ContourDetector 类设置为 Singleton。 单例模式并不是绝对必要的,但它可以确保您只有一个 ContourDetector 实例在应用程序周围运行。

1. Performing Vision Requests

现在是时候让检测器类做点什么了。

将以下属性添加到 ContourDetector 类:

private lazy var request: VNDetectContoursRequest = {
  let req = VNDetectContoursRequest()
  return req
}()

这将在您第一次需要时懒惰地创建一个 VNDetectContoursRequestSingleton 结构还确保只有一个 Vision 请求,可以在应用程序的整个生命周期中重复使用。

现在添加以下方法:

private func perform(request: VNRequest,
                     on image: CGImage) throws -> VNRequest {
  // 1
  let requestHandler = VNImageRequestHandler(cgImage: image, options: [:])
  
  // 2
  try requestHandler.perform([request])
  
  // 3
  return request
}

这种方法简单但功能强大。 你在这里:

  • 1) 创建request handler并将提供的 CGImage 传递给它。
  • 2) 使用handler执行request
  • 3) 返回请求,现在已附加结果。

为了使用请求的结果,您需要进行一些处理。 在上一个方法下面,添加以下方法来处理返回的请求:

private func postProcess(request: VNRequest) -> [Contour] {
  // 1
  guard let results = request.results as? [VNContoursObservation] else {
    return []
  }
    
  // 2
  let vnContours = results.flatMap { contour in
    (0..<contour.contourCount).compactMap { try? contour.contour(at: $0) }
  }
      
  // 3
  return vnContours.map { Contour(vnContour: $0) }
}

在这种方法中,您:

  • 1) 检查 results 是否为 VNContoursObservation 对象数组。
  • 2) 将每个结果转换为 VNContours 数组。
    • flatMap 将结果转换为单个扁平数组。
    • 使用 compactMap 遍历contour中的contours,以确保仅保留非nil值。
    • 使用 contour(at:)检索指定索引处的轮廓对象。
  • 3) 将 VNContours 数组映射到自定义 Contour 模型数组中。

注意:从 VNContour 转换为 Contour 的原因是为了简化一些 SwiftUI 代码。 Contour 遵循 Identifiable,因此很容易遍历它们的数组。 查看 ContoursView.swift 以查看实际情况。

2. Processing Images in the Detector

现在您只需要将这两个私有方法绑定到某个可从类外部调用的地方。 仍然在 ContourDetector.swift 中,添加以下方法:

func process(image: CGImage?) throws -> [Contour] {
  guard let image = image else {
    return []
  }
    
  let contourRequest = try perform(request: request, on: image)
  return postProcess(request: contourRequest)
}

在这里,您正在检查是否有图像,然后使用 perform(request:on:) 创建请求,最后使用 postProcess(request:) 返回结果。 这将是您的view model将调用以检测图像轮廓的方法,这正是您接下来要做的。

打开 ContentViewModel.swift 并将以下方法添加到类的末尾:

func asyncUpdateContours() async -> [Contour] {
  let detector = ContourDetector.shared
  return (try? detector.process(image: self.image)) ?? []
}

在此代码中,您将创建一个异步方法来检测轮廓。 为什么是异步的? 虽然检测轮廓通常相对较快,但您仍然不想在等待 API 调用结果时占用 UI。 如果检测器未找到任何轮廓,则异步方法返回一个空数组。 此外,spoiler alert,稍后您将在此处添加更多逻辑,这将对您的设备处理器造成负担。

但是,您仍然需要从某个地方调用此方法。 找到 updateContours 的方法存根,并用以下代码填充它:

func updateContours() {
  // 1
  guard !calculating else { return }
  calculating = true
  
  // 2
  Task {
    // 3
    let contours = await asyncUpdateContours()
    
    // 4
    DispatchQueue.main.async {
      self.contours = contours
      self.calculating = false
    }
  }
}

使用此代码,您可以:

  • 1) 如果我们已经在计算轮廓,什么也不做。 否则设置一个标志以指示您正在计算轮廓。 然后,用户界面将能够通知用户,因此他们保持耐心。
  • 2) 创建一个异步上下文,从中运行轮廓检测器。 这对于异步工作是必要的。
  • 3) 启动轮廓检测方法并等待其结果。
  • 4) 将结果设置回主线程并清除calculating标志位。 由于contourscalculating都是published properties,因此只能在主线程上assigned它们。

这个更新方法需要从某个地方调用,init 的底部比任何地方都好! 找到 init 并将以下行添加到底部:

updateContours()

现在是构建和运行您的应用程序的时候了。 应用程序加载并看到图像后,点击屏幕以使用默认设置显示其检测到的轮廓。

很棒!


VNContoursObservation and VNContour

在撰写本文时,VNDetectContoursObservation 似乎永远不会在结果数组中返回多个 VNContoursObservation。相反,您看到的所有contours(在上一个屏幕截图中共有 43 个)都由单个 VNContoursObservation 引用。

注意:您编写的代码处理多个 VNContoursObservation 结果,以防 Apple 决定更改其工作方式。

每个单独的contourVNContour 描述并按层次组织。 VNContour 可以有子contour。要访问它们,您有两种选择:

  • 1) 索引 childContours 属性,它是一个 VNContours 数组。
  • 2) 结合使用 childContourCount 整数属性和 childContour(at: Int) 方法来循环访问每个子contour

由于任何 VNContour 都可以有一个子 VNContour,因此如果您需要保留层次结构信息,则必须递归访问它们。

如果您不关心层次结构,VNContoursObservation 为您提供了一种以简单方式访问所有轮廓的简单方法。 VNContoursObservation 有一个 contourCount 整数属性和一个 contour(at: Int) 方法来访问所有轮廓,就好像它们是一个平面数据结构一样。

但是,如果层次结构对您很重要,则需要访问 topLevelContours 属性,它是 VNContours 数组。从那里,您可以访问每个轮廓的子轮廓(contours)

如果您要编写一些简单的代码来计算顶级轮廓和子轮廓,您会发现示例图像在默认设置下具有 4 个顶级轮廓和 39 个子轮廓,总共 43 个。


VNDetectContoursRequest Settings

到目前为止,您已经创建了一个 VNDetectContoursRequest,而没有尝试各种可用的设置。目前,您可以更改四个属性以实现不同的结果-

  • 1) contrastAdjustment:该算法具有在执行轮廓检测之前调整图像对比度的内置方法。调整对比度会尝试使图像的暗部变暗并减轻亮部以增大它们的差异。此浮点属性的范围从 0.03.0,默认值为 2.0。该值越高,对图像应用的对比度就越高,从而更容易检测到一些轮廓。
  • 2) contrastPivot:算法如何知道图像的哪个部分应该被视为暗与亮?这就是对比度枢轴(contrast pivot)的用武之地。它是一个可选的 NSNumber 属性,范围从 0.01.0,默认值为 0.5。低于此值的任何像素都将变暗,而高于此值的任何像素都将变亮。您还可以将此属性设置为 nil 以使 Vision 框架自动检测该值“应该”是什么。
  • 3) detectDarkOnLight:此布尔属性是对轮廓检测算法的提示。默认设置为 true,这意味着它应该在浅色背景上寻找深色物体。
  • 4) maximumImageDimension:由于您可以将任何尺寸的图像传递给请求处理程序(request handler),因此此整数属性允许您设置要使用的最大图像尺寸。如果您的图像尺寸大于此值,API 会缩放图像,以使两个尺寸中较大的一个等于 maximumImageDimension。此属性的默认值为 512。为什么要更改它?轮廓检测(Contour detection)需要相当多的处理能力——图像越大,需要的越多。但是,图像越大,它就越准确。此属性允许您根据需要微调。

Changing the Contrast

现在您了解了可用的设置,您将编写一些代码来更改两个对比度设置的值。在本教程中,您将不理会detectDarkOnLightmaximumImageDimension 属性,只使用它们的默认值。

打开 ContourDetector.swift 并将以下方法添加到 ContourDetector 的底部:

func set(contrastPivot: CGFloat?) {
  request.contrastPivot = contrastPivot.map {
    NSNumber(value: $0)
  }
}

func set(contrastAdjustment: CGFloat) {
  request.contrastAdjustment = Float(contrastAdjustment)
}

这些方法分别更改 VNDetectContoursRequest 上的 contrastPivotcontrastAdjustment,并使用一些额外的逻辑来允许您将 contrastPivot 设置为 nil

您会记得 request 是一个lazy var,这意味着如果在您调用这些方法之一时它还没有被实例化,它将现在实例化。

接下来,打开 ContentViewModel.swift并找到 asyncUpdateContours。 更新方法,使其看起来像这样:

func asyncUpdateContours() async -> [Contour] {
  let detector = ContourDetector.shared

  // New logic    
  detector.set(contrastPivot: 0.5)
  detector.set(contrastAdjustment: 2.0)
    
  return (try? detector.process(image: self.image)) ?? []
}

这两行新代码为 contrastPivotcontrastAdjustment 赋值。

构建并运行应用程序并为这些设置尝试不同的值(您需要更改这些值,然后再次构建并运行)。 以下是不同值的一些截图:

好的,现在你得到了一些有趣的结果。但是,有点烦人的是,没有神奇的设置可以从图像中获取所有轮廓并将它们组合成一个结果。

但是……有一个解决方案。

在探索入门项目时,您可能已经点击了右下角的设置图标。如果您点击它,您会看到用于最小和最大对比度枢轴和调整(contrast pivot and adjustment)的滑块。

您将使用这些滑块为这些设置创建范围并循环访问它们。然后,您将组合每个设置对中的所有轮廓,为图像创建更完整的轮廓集。

注意:每个设置的范围越大,您运行的Vision请求就越多。这可能是一个缓慢的过程,除非您非常有耐心,否则不建议在旧设备上使用。它在较新的 iPhone、iPad 和基于 M1 的 Mac 上运行良好。

如果您还没有打开 ContentViewModel.swift,请继续打开它。删除 asyncUpdateContours 的全部内容,并用以下代码替换:

// 1
var contours: [Contour] = []

// 2
let pivotStride = stride(
  from: UserDefaults.standard.minPivot,
  to: UserDefaults.standard.maxPivot,
  by: 0.1)
let adjustStride = stride(
  from: UserDefaults.standard.minAdjust,
  to: UserDefaults.standard.maxAdjust,
  by: 0.2)

// 3
let detector = ContourDetector.shared

// 4
for pivot in pivotStride {
  for adjustment in adjustStride {
    
    // 5
    detector.set(contrastPivot: pivot)
    detector.set(contrastAdjustment: adjustment)
    
    // 6
    let newContours = (try? detector.process(image: self.image)) ?? []
    
    // 7
    contours.append(contentsOf: newContours)
  }
}

// 8
return contours

在这个新版本的 asyncUpdateContours 中,您:

  • 1) 创建一个空的轮廓Contours数组来存储所有轮廓。
  • 2) 设置要循环遍历的 contourPivotcontourAdjustment 值的步幅。
  • 3) 获取对 ContourDetector 单例的引用。
  • 4) 循环通过两个步骤。 请注意,这是一个嵌套循环,因此每个 contourPivot 值都将与每个 contourAdjustment 值配对。
  • 5) 使用您创建的访问器方法更改 VNDetectContoursRequest 的设置。
  • 6) 通过 Vision 轮廓检测器 API运行图像。
  • 7) 将结果附加到轮廓Contours列表并...
  • 8) 返回此轮廓列表。

已经太多了,但它会是值得的。 继续构建并运行应用程序并更改设置菜单中的滑块。 通过向下滑动或在其外部点击关闭设置菜单后,它将开始重新计算轮廓。

以下屏幕截图中使用的范围是:

  • Contrast Pivot: 0.2 - 0.7
  • Contrast Adjustment: 0.5 - 3.0

真的很酷!


Thinning the Contours

这是一个很酷的效果,但你可以做得更好!

您可能会注意到一些轮廓现在看起来很厚,而另一些则很薄。 “厚”轮廓实际上是同一区域的多个轮廓,但由于对比度的调整方式而彼此略有偏移。

如果您可以检测到重复的轮廓,您就可以删除它们,这应该会使线条看起来更细。

确定两个轮廓是否相同的一种简单方法是查看它们有多少重叠。 它并不完全是 100% 准确的,但它是一个相对较快的近似值。 要确定重叠,您可以计算它们的边界框的交集。

Intersection over unionIoU 是两个边界框的交集面积除以它们的并集面积。

IoU1.0 时,边界框完全相同。 如果 IoU0.0,则两个边界框之间没有重叠。

您可以将其用作阈值来过滤掉看起来“足够接近”的边界框。

回到 ContentViewModel.swift 中的 asyncUpdateContours,在 return 语句之前添加以下代码:

// 1
if contours.count < 9000 {
  // 2
  let iouThreshold = UserDefaults.standard.iouThresh
  
  // 3
  var pos = 0
  while pos < contours.count {
    // 4
    let contour = contours[pos]
    // 5
    contours = contours[0...pos] + contours[(pos+1)...].filter {
      contour.intersectionOverUnion(with: $0) < iouThreshold
    }
    // 6
    pos += 1
  }
}

使用此代码,您可以:

  • 1) 仅当轮廓数少于 9,000 时运行。 这可能是整个函数中最慢的部分,所以尽量限制它何时可以使用。
  • 2) 抓取 IoU阈值设置,可以在设置屏幕中更改。
  • 3) 循环遍历每个轮廓。 您在此处使用 while 循环,因为您将动态更改轮廓数组。 您不希望意外地在数组大小之外进行索引!
  • 4) 索引轮廓数组以获取当前轮廓。
  • 5) 只保留当前轮廓之后的轮廓,其 IoU 小于阈值。 请记住,如果 IoU 大于或等于阈值,则您已确定它与当前轮廓相似,应将其删除。
  • 6) 增加索引位置。

注意:可能有一种更有效的方法来完成此操作,但这是解释该概念的最简单方法。

继续构建并运行应用程序。

注意有多少厚轮廓现在明显变薄了!


Simplifying the Contours

您可以使用另一种技巧为您的轮廓艺术添加艺术感。 您可以简化轮廓。

VNContour 有一个名为 polygonApproximation(epsilon:) 的成员方法,它就是这样做的。 该方法的目的是返回具有较少点的相似轮廓。 这是轮廓的近似值。

epsilon 的选择将决定返回轮廓的简化程度。 较大的 epsilon 将导致具有较少点的更简单的轮廓,而较小的 epsilon 将返回更接近原始轮廓的轮廓。

打开 ContourDetector.swift。 在 ContourDetector 的顶部,添加以下属性:

private var epsilon: Float = 0.001

接下来,在 ContourDetector 的底部,添加以下方法:

func set(epsilon: CGFloat) {
  self.epsilon = Float(epsilon)
}

仍然在同一个类中,找到 postProcess(request:) 并将方法底部的 return 语句替换为以下代码:

let simplifiedContours = vnContours.compactMap {
  try? $0.polygonApproximation(epsilon: self.epsilon)
}
        
return simplifiedContours.map { Contour(vnContour: $0) }

此代码在返回之前根据 epsilon 的当前值简化每个检测到的轮廓。

在尝试这个新功能之前,您需要将 epsilon 设置连接到 ContourDetector。 您只需从设置屏幕写入的 UserDefaults 中读取它。

打开 ContentViewModel.swift 并再次找到 asyncUpdateContours。 然后,在定义detector常数的行下方,添加以下行:

detector.set(epsilon: UserDefaults.standard.epsilon)

这将确保检测器在每次需要更新显示的轮廓时获得最新的 epsilon 值。

最后一次,继续构建并运行!

此示例将值 0.01 用于多边形近似 Epsilon (Polygon Approximation Epsilon)设置。

现在,这是具有风格的轮廓艺术。

通过了解 Vision API 管道的工作原理,您现在可以在 Vision 框架中使用 Apple 提供的任何其他算法。想想可能性!

如果您对有关 Vision API 的更多教程感兴趣,我们会提供这些东西;查看:

后记

本篇主要讲述了基于Vision的轮廓检测,感兴趣的给个赞或者关注~~~

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,736评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,167评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,442评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,902评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,302评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,573评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,847评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,562评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,260评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,531评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,021评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,367评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,016评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,068评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,827评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,610评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,514评论 2 269

推荐阅读更多精彩内容