Реализация AI-подсчёта объектов в кадре камеры мобильного приложения
Подсчёт объектов через камеру — задача с виду простая, но прячущая несколько нетривиальных проблем: перекрывающиеся объекты, объекты разного масштаба в одном кадре, и главная ловушка — double counting при движении камеры. Промышленный склад, стадо животных, монеты на столе — у каждого сценария свои особенности.
Два подхода: детекция vs density estimation
Detection-based counting — YOLOv8 или RT-DETR обнаруживает каждый объект, их количество = count. Работает при низкой плотности (до 50–100 объектов в кадре), объекты не перекрываются сильно.
Density map estimation — CNN предсказывает карту плотности (density map), интеграл карты = count. Используется при высокой плотности: толпа людей, зерно в бункере, клетки под микроскопом. CSRNet, DMCount, BL-model — актуальные архитектуры.
// iOS: выбор метода в зависимости от ожидаемой плотности
enum CountingStrategy {
case detection(model: VNCoreMLModel) // < 100 объектов
case densityMap(model: VNCoreMLModel) // > 100 объектов в кадре
case hybrid // смешанный, определяется адаптивно
}
class AdaptiveObjectCounter {
func selectStrategy(for objectClass: CountableObject) -> CountingStrategy {
switch objectClass {
case .vehicle, .person_sparse:
return .detection(model: vehicleDetector)
case .crowd, .grain, .cell:
return .densityMap(model: densityEstimator)
case .product_shelf:
return .hybrid
}
}
}
Detection-based: реализация с деduplication
class DetectionCounter {
func count(in sampleBuffer: CMSampleBuffer,
targetClass: String) async throws -> CountResult {
guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else {
throw CounterError.invalidFrame
}
let request = VNCoreMLRequest(model: detectionModel)
request.imageCropAndScaleOption = .scaleFill
try VNImageRequestHandler(cvPixelBuffer: pixelBuffer).perform([request])
let observations = (request.results as? [VNRecognizedObjectObservation]) ?? []
// Фильтрация по классу и confidence
let targetObjects = observations.filter { obs in
obs.labels.first?.identifier == targetClass &&
obs.confidence >= 0.4
}
// NMS для устранения дублирующих bounding box
let deduplicated = applyNMS(targetObjects, iouThreshold: 0.45)
return CountResult(
count: deduplicated.count,
detections: deduplicated,
confidence: deduplicated.map { $0.confidence }.average()
)
}
private func applyNMS(_ observations: [VNRecognizedObjectObservation],
iouThreshold: Float) -> [VNRecognizedObjectObservation] {
// Сортируем по confidence (убывание)
let sorted = observations.sorted { $0.confidence > $1.confidence }
var kept: [VNRecognizedObjectObservation] = []
for obs in sorted {
let overlapping = kept.contains { existingObs in
iou(obs.boundingBox, existingObs.boundingBox) > iouThreshold
}
if !overlapping { kept.append(obs) }
}
return kept
}
}
Vision framework не применяет NMS автоматически при использовании VNCoreMLRequest — это нужно делать вручную, иначе объекты на границе между зонами crop считаются дважды.
Density map для высокой плотности
// Android: density map estimation через TFLite
class DensityMapCounter(context: Context) {
private val interpreter: Interpreter by lazy {
val model = FileUtil.loadMappedFile(context, "csrnet_lite.tflite")
Interpreter(model, Interpreter.Options().apply {
addDelegate(GpuDelegate())
numThreads = 4
})
}
fun estimate(bitmap: Bitmap): Int {
// Входной размер модели — обычно 512×512 или кратный 16
val resized = Bitmap.createScaledBitmap(bitmap, 512, 512, true)
val inputBuffer = TensorImage.fromBitmap(resized).buffer
// Выходной тензор — density map того же разрешения
val outputBuffer = TensorBuffer.createFixedSize(
intArrayOf(1, 512, 512, 1), DataType.FLOAT32
)
interpreter.run(inputBuffer, outputBuffer.buffer)
// Сумма по всем пикселям density map = estimated count
val densitySum = outputBuffer.floatArray.sum()
// Масштабирование: сумма соответствует количеству объектов
return densitySum.roundToInt()
}
}
Подсчёт при движении камеры: tracking
Если пользователь плавно ведёт камерой (склад, аудитория), нужен трекинг чтобы не считать одни объекты дважды:
class TrackingObjectCounter {
private var tracker = ByteTracker() // BYTE алгоритм трекинга
private var countedIds: Set<Int> = [] // уникальные ID за сессию
func processFrame(_ detections: [Detection]) -> TrackingCountResult {
let tracks = tracker.update(detections: detections)
// Новые ID — новые объекты, которые вошли в кадр
let newIds = tracks.map { $0.trackId }.filter { !countedIds.contains($0) }
countedIds.formUnion(newIds)
return TrackingCountResult(
currentFrameCount: tracks.count, // в кадре сейчас
totalUniqueCount: countedIds.count // всего за сессию
)
}
}
ByteTracker — один из лучших алгоритмов трекинга для этой задачи, устойчивый к окклюзиям.
Ориентиры по срокам
Detection-based подсчёт с готовой моделью (один класс объектов) и UI счётчика — 3–5 дней. Адаптивная система с detection + density map, трекингом при движении камеры, несколькими классами объектов и поддержкой iOS + Android — 1–2 недели.







