全景图在iOS应用里常以等距柱状投影图像呈现,开发者拿到的是宽高比为2:1的图片。用户拖动画面时,我们希望某些位置出现可点击的热点,点击后弹出信息卡或跳转到详情页。实现这个需求的关键不在某个滤镜,而在于球面坐标、屏幕坐标与纹理坐标之间的双向映射。Core Image可以承担图像合成、高亮标记和局部模糊等工作,配合UIKit的手势识别就能组合出一套轻量级全景图热点交互方案。

一、先把坐标模型理清:从等距柱状图到球面
等距柱状投影全景图每个像素的横向位置对应经度,纵向位置对应纬度。宽度360度、高度180度。假设图像尺寸为width和height,纹理坐标u=px/width,v=py/height。经度lon=(u-0.5)*360,纬度lat=(0.5-v)*180。这样就能把热点保存为经纬度。也可以转换为三维单位向量,方便后续做球面距离计算。
热点模型建议存储三个字段:经度、纬度、热点ID。如果项目已经使用SceneKit,也可以存储simd_float3单位向量。这里采用经纬度是为了让接口更通用。将经纬度转纹理坐标的公式反过来即可:u=lon/360+0.5,v=0.5-lat/180。需要注意的是纬度方向容易搞反,等距柱状图上方是北纬,对应v值较小,所以用0.5-lat/180来换算。
这个映射公式会用在两个地方:渲染热点时把经纬度转成图像坐标;点击检测时把图像坐标转回经纬度。一旦公式正确,热点偏离的问题基本不会出现。
二、使用Core Image合成热点标记
Core Image处理高分辨率全景图时要注意性能。加载原图后不要立即创建CIContext重复渲染,可以在应用启动时复用同一个CIContext。热点标记可以使用CISourceOverCompositing将一个小图标叠加到全景图上,也可以使用CIColorMatrix调整图标颜色。更简单的做法是把热点图标先绘制成CIImage,再用CISourceOverCompositing与全景图合并。
代码示例展示了怎样把热点图标放到图像指定位置。这里省略了图标加载细节,直接生成一个红色圆形CIImage。实际项目中可以用UIImage初始化CIImage。
import UIKit
import CoreImage
struct Hotspot {
let id: String
let longitude: Double
let latitude: Double
}
func hotspotImage(size: CGSize) -> CIImage? {
let renderer = UIGraphicsImageRenderer(size: size)
let image = renderer.image { context in
UIColor.red.setFill()
context.cgContext.fillEllipse(in: CGRect(origin: .zero, size: size))
}
return CIImage(image: image)
}
func composeHotspot(on panorama: CIImage, hotspot: Hotspot, panoramaWidth: CGFloat, panoramaHeight: CGFloat, iconSize: CGSize) -> CIImage? {
let u = CGFloat(hotspot.longitude / 360.0 + 0.5)
let v = CGFloat(0.5 - hotspot.latitude / 180.0)
let imageX = u * panoramaWidth - iconSize.width / 2
let imageY = v * panoramaHeight - iconSize.height / 2
guard let icon = hotspotImage(size: iconSize) else { return nil }
let translate = CGAffineTransform(translationX: imageX, y: imageY)
let placedIcon = icon.transformed(by: translate)
let sourceOver = CIFilter(name: "CISourceOverCompositing")!
sourceOver.setValue(placedIcon, forKey: kCIInputImageKey)
sourceOver.setValue(panorama, forKey: kCIInputBackgroundImageKey)
return sourceOver.outputImage
}
这段代码只是把图标平铺到等距柱状图上,并不包含球面投影修正。如果全景图直接用UIImageView展示,且用户只能平移不能旋转,这种合成方式足够。若需要真正球面渲染,Core Image就不适合做主要渲染器,更适合在渲染后的画面上做后期标记。
还可利用Core Image的CIBumpDistortion对热点附近进行轻微隆起,模拟球面上的视觉焦点,但效果有限,不建议在交互场景中过度使用。核心工作是保证热点位置准确,而不是追求夸张动画。
三、点击检测与坐标逆向映射
用户在屏幕上点击时,需要把点击坐标转换为纹理坐标。如果全景图放在UIScrollView中,要扣除contentOffset和zoomScale的影响。如果使用CGPoint直接落在UIImageView上,先计算图片实际显示区域,再换算到图像像素坐标。计算图像坐标后,根据等距柱状投影公式求出经纬度。
func mapTouchPoint(_ point: CGPoint, imageView: UIImageView, imageSize: CGSize) -> (u: Double, v: Double)? {
guard let image = imageView.image else { return nil }
let imageAspect = imageSize.width / imageSize.height
let viewAspect = imageView.bounds.width / imageView.bounds.height
var drawRect = CGRect.zero
if imageAspect > viewAspect {
let height = imageView.bounds.width / imageAspect
drawRect = CGRect(x: 0, y: (imageView.bounds.height - height) / 2, width: imageView.bounds.width, height: height)
} else {
let width = imageView.bounds.height * imageAspect
drawRect = CGRect(x: (imageView.bounds.width - width) / 2, y: 0, width: width, height: imageView.bounds.height)
}
guard drawRect.contains(point) else { return nil }
let u = Double((point.x - drawRect.minX) / drawRect.width)
let v = Double((point.y - drawRect.minY) / drawRect.height)
return (u, v)
}
func hotspot(from touchPoint: CGPoint, imageView: UIImageView, imageSize: CGSize) -> Hotspot? {
guard let (u, v) = mapTouchPoint(touchPoint, imageView: imageView, imageSize: imageSize) else { return nil }
let longitude = (u - 0.5) * 360.0
let latitude = (0.5 - v) * 180.0
return Hotspot(id: "temporary", longitude: longitude, latitude: latitude)
}
得到点击位置的经纬度后,需要和预先配置的热点数组做匹配。球面上常用的距离计算可以用球面余弦公式,或者将经纬度转成三维向量后计算点积。推荐后者,因为计算简单且容易控制阈值。把经纬度转三维向量,两个向量点积越接近1表示越近。设定点积阈值如0.9998,约等于1度球面距离,可根据热点大小调整。
在实际开发中,因为全景图像素较宽,用户点击的像素误差会带来几度的经纬度偏差,所以阈值不宜过小。建议给每个热点设置半径参数,点击在半径内即命中,并优先选择距离最近的热点。
四、信息展示与跳转交互
命中热点后,常见做法是在屏幕上弹出一个信息卡片,展示热点名称、摘要和缩略图。可以用自定义UIView添加到当前视图,也可以直接使用UIAlertController。卡片出现的位置可以固定在屏幕底部或点击点附近。如果热点关联了详情页,点击卡片上的按钮或直接点击卡片跳转到指定页面。
建议设计一个HotspotDetailDelegate协议,把热点ID传给控制器。导航方式使用UINavigationController pushViewController。若热点信息需要从服务端异步获取,卡片先展示占位图,数据返回后再刷新。注意在快速点击多个热点时,要取消上一次未完成的请求,避免卡片内容错乱。
protocol HotspotDetailDelegate: AnyObject {
func hotspotDetailDidRequestOpen(_ hotspot: Hotspot)
}
func handleTap(_ gesture: UITapGestureRecognizer) {
guard let imageView = gesture.view as? UIImageView else { return }
let point = gesture.location(in: imageView)
let imageSize = imageView.image?.size ?? .zero
guard let tapped = hotspot(from: point, imageView: imageView, imageSize: imageSize) else { return }
let matched = hotspots.min { left, right in
let leftDistance = sphericalDistance(left, tapped)
let rightDistance = sphericalDistance(right, tapped)
return leftDistance < rightDistance
}
guard let selected = matched else { return }
showHotspotCard(for: selected)
}
这段伪代码展示了点击匹配的流程。sphericalDistance函数可以用点积实现。showHotspotCard需要根据实际UI结构封装。若需要跳转,可以在卡片回调中调用代理方法,由当前控制器执行push。
信息展示部分还要注意全景图拖拽和点击手势的冲突。通常给UIScrollView设置canCancelContentTouches,或者使用UITapGestureRecognizer并设置cancelsTouchesInView,确保点击不会误触发拖动。
五、Core Image性能优化与边界情况处理
高分辨率全景图可能达到8000x4000甚至更高,直接用Core Image处理会占用大量内存。建议在显示层做降采样,将图像缩小到屏幕所需的2倍以内。热点合成可以在降采样后的图像上进行,既能保证清晰度,又降低CIContext渲染压力。
另外,CIContext要作为单例复用,CIImage对象本身不是位图,创建后不会立即占内存,只有调用createCGImage或渲染到Metal才会消耗资源。因此可以提前构建热点合成链,在需要刷新时再渲染。对于热点数量较多的场景,不必每次点击都重新合成整张全景图,可以把热点图标用独立的UIImageView放在全景图上方,跟随全景图滚动而移动,这样性能更好。
边界情况主要是热点靠近图像左右边缘时,经度会出现正负180度跳变。例如热点在179.9度,点击位置在-179.9度,两者球面距离很近但经度相减很大。处理方式是把经度差值规范化到[-180,180]区间,或者使用三维向量点积避免经度跨越问题。另一个常见问题是全景图宽高比不是严格的2:1,会导致纹理坐标拉伸,因此建议使用严格2:1的等距柱状投影资源。
综合来看,用Core Image实现iOS端全景图热点交互的核心不在滤镜本身,而在于正确的坐标映射和触摸检测。Core Image负责热点标记的图像合成,UIKit负责手势,二者配合就能在较短时间内搭建出可用的全景图热点交互功能。对于需要完全沉浸式球面渲染和复杂动画的项目,建议后续再引入SceneKit或RealityKit。

