react-native-yolo 0.0.7 → 0.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Yolo.podspec +3 -0
- package/android/src/main/java/com/yolo/HybridYoloModel.kt +49 -2
- package/ios/HybridYolo.swift +52 -10
- package/ios/HybridYoloModel.swift +250 -0
- package/ios/Loader/YoloModelLoader.swift +139 -0
- package/ios/Utils/BitmapOrientationFixer.swift +64 -0
- package/ios/Utils/ContextProvider.swift +45 -0
- package/ios/Utils/FrameJpegConverter.swift +41 -0
- package/ios/Utils/FrameValidator.swift +40 -0
- package/ios/Utils/Nv12JpegEncoder.swift +58 -0
- package/ios/Utils/Yuv420ToNv12Converter.swift +71 -0
- package/package.json +1 -1
package/Yolo.podspec
CHANGED
|
@@ -11,6 +11,7 @@ import org.tensorflow.lite.DataType
|
|
|
11
11
|
import org.tensorflow.lite.Interpreter
|
|
12
12
|
import yolo.com.loader.YoloModelLoader
|
|
13
13
|
import kotlin.math.roundToInt
|
|
14
|
+
import com.margelo.nitro.camera.CameraOrientation
|
|
14
15
|
|
|
15
16
|
class HybridYoloModel(
|
|
16
17
|
modelPath: String
|
|
@@ -158,8 +159,15 @@ class HybridYoloModel(
|
|
|
158
159
|
|
|
159
160
|
for (dy in 0 until dstHeight) {
|
|
160
161
|
for (dx in 0 until dstWidth) {
|
|
161
|
-
val srcX =
|
|
162
|
-
|
|
162
|
+
val (srcX, srcY) = mapModelPixelToFramePixel(
|
|
163
|
+
dx = dx,
|
|
164
|
+
dy = dy,
|
|
165
|
+
dstWidth = dstWidth,
|
|
166
|
+
dstHeight = dstHeight,
|
|
167
|
+
srcWidth = srcWidth,
|
|
168
|
+
srcHeight = srcHeight,
|
|
169
|
+
orientation = frame.orientation
|
|
170
|
+
)
|
|
163
171
|
|
|
164
172
|
val yIndex = srcY * yRowStride + srcX
|
|
165
173
|
|
|
@@ -209,4 +217,43 @@ class HybridYoloModel(
|
|
|
209
217
|
|
|
210
218
|
input.rewind()
|
|
211
219
|
}
|
|
220
|
+
|
|
221
|
+
private fun mapModelPixelToFramePixel(
|
|
222
|
+
dx: Int,
|
|
223
|
+
dy: Int,
|
|
224
|
+
dstWidth: Int,
|
|
225
|
+
dstHeight: Int,
|
|
226
|
+
srcWidth: Int,
|
|
227
|
+
srcHeight: Int,
|
|
228
|
+
orientation: CameraOrientation
|
|
229
|
+
): Pair<Int, Int> {
|
|
230
|
+
val nx = dx.toFloat() / dstWidth
|
|
231
|
+
val ny = dy.toFloat() / dstHeight
|
|
232
|
+
|
|
233
|
+
return when (orientation) {
|
|
234
|
+
CameraOrientation.UP -> {
|
|
235
|
+
val srcX = (nx * srcWidth).toInt()
|
|
236
|
+
val srcY = (ny * srcHeight).toInt()
|
|
237
|
+
srcX.coerceIn(0, srcWidth - 1) to srcY.coerceIn(0, srcHeight - 1)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
CameraOrientation.DOWN -> {
|
|
241
|
+
val srcX = ((1f - nx) * srcWidth).toInt()
|
|
242
|
+
val srcY = ((1f - ny) * srcHeight).toInt()
|
|
243
|
+
srcX.coerceIn(0, srcWidth - 1) to srcY.coerceIn(0, srcHeight - 1)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
CameraOrientation.LEFT -> {
|
|
247
|
+
val srcX = (ny * srcWidth).toInt()
|
|
248
|
+
val srcY = ((1f - nx) * srcHeight).toInt()
|
|
249
|
+
srcX.coerceIn(0, srcWidth - 1) to srcY.coerceIn(0, srcHeight - 1)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
CameraOrientation.RIGHT -> {
|
|
253
|
+
val srcX = ((1f - ny) * srcWidth).toInt()
|
|
254
|
+
val srcY = (nx * srcHeight).toInt()
|
|
255
|
+
srcX.coerceIn(0, srcWidth - 1) to srcY.coerceIn(0, srcHeight - 1)
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
212
259
|
}
|
package/ios/HybridYolo.swift
CHANGED
|
@@ -1,14 +1,56 @@
|
|
|
1
|
-
//
|
|
2
|
-
// HybridYolo.swift
|
|
3
|
-
// Pods
|
|
4
|
-
//
|
|
5
|
-
// Created by Khaoula-Ghalimi on 22/06/2026.
|
|
6
|
-
//
|
|
7
|
-
|
|
8
1
|
import Foundation
|
|
2
|
+
import NitroModules
|
|
3
|
+
import VisionCamera
|
|
9
4
|
|
|
10
|
-
class HybridYolo: HybridYoloSpec {
|
|
11
|
-
|
|
12
|
-
|
|
5
|
+
public class HybridYolo: HybridYoloSpec {
|
|
6
|
+
private static let tag = "YOLO_TAG"
|
|
7
|
+
|
|
8
|
+
// Initialisateur obligatoire pour les modules Nitro
|
|
9
|
+
public required override init() {
|
|
10
|
+
super.init()
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Charge l'objet modèle YOLO spécifié par son chemin.
|
|
15
|
+
* Implémente la méthode obligatoire du protocole Nitro TypeScript.
|
|
16
|
+
*/
|
|
17
|
+
public func loadModel(modelPath: String) throws -> any HybridYoloModelSpec {
|
|
18
|
+
NSLog("[%@]: Trying to load model object: %@", HybridYolo.tag, modelPath)
|
|
19
|
+
|
|
20
|
+
// Initialise et retourne votre sous-classe de modèle Nitro (assurez-vous qu'elle s'appelle bien HybridYoloModel)
|
|
21
|
+
return try HybridYoloModel(modelPath: modelPath)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Valide un Frame de la caméra, le convertit en JPEG permanent (NV12 -> JPEG -> Fix Rotation),
|
|
26
|
+
* puis l'encode instantanément en chaîne de caractères Base64 standard.
|
|
27
|
+
*/
|
|
28
|
+
public func frameToBase64(frame: any HybridFrameSpec) throws -> String {
|
|
29
|
+
NSLog("[%@]: Trying to convert frame to base64", HybridYolo.tag)
|
|
30
|
+
|
|
31
|
+
do {
|
|
32
|
+
// 1. Validation du buffer vidéo via notre validateur adapté à iOS (NV12 à 2 plans minimum)
|
|
33
|
+
guard FrameValidator.isValidYuv(frame: frame) else {
|
|
34
|
+
return ""
|
|
35
|
+
}
|
|
36
|
+
NSLog("[%@]: frameToBase64: frame is valid YUV", HybridYolo.tag)
|
|
37
|
+
|
|
38
|
+
// 2. Traitement complet de l'image (Conversion NV12 -> Encodage GPU JPEG -> Rendu de sécurité)
|
|
39
|
+
let jpegBytes = FrameJpegConverter.toJpegBytes(frame: frame, quality: 80)
|
|
40
|
+
|
|
41
|
+
guard !jpegBytes.isEmpty else {
|
|
42
|
+
NSLog("[%@]: ❌ frameToBase64 failed: converted JPEG bytes array is empty", HybridYolo.tag)
|
|
43
|
+
return ""
|
|
44
|
+
}
|
|
45
|
+
NSLog("[%@]: frameToBase64: jpegBytes size: %d bytes", HybridYolo.tag, jpegBytes.count)
|
|
46
|
+
|
|
47
|
+
// 3. Encapsulation dans un conteneur Data pour un encodage Base64 matériel (sans sauts de ligne, équivalent de NO_WRAP)
|
|
48
|
+
let data = Data(jpegBytes)
|
|
49
|
+
return data.base64EncodedString(options: [])
|
|
50
|
+
|
|
51
|
+
} catch {
|
|
52
|
+
NSLog("[%@]: ❌ frameToBase64 failed: %@", HybridYolo.tag, error.localizedDescription)
|
|
53
|
+
return ""
|
|
54
|
+
}
|
|
13
55
|
}
|
|
14
56
|
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import NitroModules
|
|
3
|
+
import TensorFlowLite
|
|
4
|
+
import VisionCamera
|
|
5
|
+
import CoreVideo
|
|
6
|
+
|
|
7
|
+
public class HybridYoloModel: HybridYoloModelSpec {
|
|
8
|
+
private static let tag = "YOLO_MODEL_TAG"
|
|
9
|
+
|
|
10
|
+
private let modelLoader = YoloModelLoader()
|
|
11
|
+
|
|
12
|
+
private var interpreter: Interpreter? = nil
|
|
13
|
+
private var inputBuffer: Data? = nil
|
|
14
|
+
private var inputWidth = 0
|
|
15
|
+
private var inputHeight = 0
|
|
16
|
+
private var inputDataType: Tensor.DataType? = nil
|
|
17
|
+
|
|
18
|
+
private let threadLock = NSLock()
|
|
19
|
+
|
|
20
|
+
public required override init(modelPath: String) throws {
|
|
21
|
+
super.init()
|
|
22
|
+
try load(modelPath: modelPath)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
private func load(modelPath: String) throws {
|
|
26
|
+
do {
|
|
27
|
+
let modelData = try modelLoader.load(modelPath: modelPath)
|
|
28
|
+
|
|
29
|
+
// Initialisation de l'interpréteur TensorFlowLiteSwift
|
|
30
|
+
interpreter = try Interpreter(modelData: modelData)
|
|
31
|
+
guard let localInterpreter = interpreter else { return }
|
|
32
|
+
|
|
33
|
+
try localInterpreter.allocateTensors()
|
|
34
|
+
|
|
35
|
+
let inputTensor = try localInterpreter.inputTensor(at: 0)
|
|
36
|
+
let outputTensor = try localInterpreter.outputTensor(at: 0)
|
|
37
|
+
|
|
38
|
+
let shape = inputTensor.shape.dimensions
|
|
39
|
+
|
|
40
|
+
inputHeight = shape[1]
|
|
41
|
+
inputWidth = shape[2]
|
|
42
|
+
inputDataType = inputTensor.dataType
|
|
43
|
+
inputBuffer = try modelLoader.makeInputBuffer(interpreter: localInterpreter)
|
|
44
|
+
|
|
45
|
+
NSLog("[%@]: ✅ YOLO model instance loaded", HybridYoloModel.tag)
|
|
46
|
+
NSLog("[%@]: 📥 Input shape: %@", HybridYoloModel.tag, shape.description)
|
|
47
|
+
NSLog("[%@]: 📤 Output shape: %@", HybridYoloModel.tag, outputTensor.shape.dimensions.description)
|
|
48
|
+
} catch {
|
|
49
|
+
NSLog("[%@]: ❌ Failed to load model instance: %@", HybridYoloModel.tag, error.localizedDescription)
|
|
50
|
+
throw error
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
public func detect(frame: any HybridFrameSpec) throws -> [Detection] {
|
|
55
|
+
guard let localInterpreter = interpreter else {
|
|
56
|
+
NSLog("[%@]: ❌ This model instance is not loaded.", HybridYoloModel.tag)
|
|
57
|
+
return []
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
guard FrameValidator.isValidYuv(frame: frame) else {
|
|
61
|
+
NSLog("[%@]: ❌ Invalid frame provided for detection.", HybridYoloModel.tag)
|
|
62
|
+
return []
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
guard var input = inputBuffer else { return [] }
|
|
66
|
+
|
|
67
|
+
threadLock.lock()
|
|
68
|
+
defer { threadLock.unlock() }
|
|
69
|
+
|
|
70
|
+
// Remplissage du buffer d'entrée en analysant les données NV12
|
|
71
|
+
try fillInputFromYuvFrame(
|
|
72
|
+
frame: frame,
|
|
73
|
+
input: &input,
|
|
74
|
+
dstWidth: inputWidth,
|
|
75
|
+
dstHeight: inputHeight,
|
|
76
|
+
dataType: inputDataType ?? .float32
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
// Injection du tampon de données brut dans le tenseur d'entrée
|
|
80
|
+
try localInterpreter.copy(input, toInputAt: 0)
|
|
81
|
+
try localInterpreter.invoke()
|
|
82
|
+
|
|
83
|
+
// Récupération des résultats du tenseur de sortie
|
|
84
|
+
let outputTensor = try localInterpreter.outputTensor(at: 0)
|
|
85
|
+
|
|
86
|
+
// Extraction et conversion de la structure des tenseurs [1, 300, 6] en Float
|
|
87
|
+
let nativeOutputs = outputTensor.data.withUnsafeBytes { (ptr: UnsafeRawBufferPointer) -> [Float] in
|
|
88
|
+
let floatPtr = ptr.assumingMemoryBound(to: Float.self)
|
|
89
|
+
return Array(floatPtr)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return parseNmsOutput(outputArray: nativeOutputs, confidenceThreshold: 0.5)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
public func close() throws {
|
|
96
|
+
interpreter = nil
|
|
97
|
+
inputBuffer = nil
|
|
98
|
+
NSLog("[%@]: 🧹 YOLO model disposed", HybridYoloModel.tag)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private func parseNmsOutput(outputArray: [Float], confidenceThreshold: Float = 0.5) -> [Detection] {
|
|
102
|
+
var detections: [Detection] = []
|
|
103
|
+
|
|
104
|
+
let totalRows = 300
|
|
105
|
+
let columnsPerRow = 6
|
|
106
|
+
|
|
107
|
+
// Parcours du tableau aplati [300 * 6]
|
|
108
|
+
for i in 0..<totalRows {
|
|
109
|
+
let offset = i * columnsPerRow
|
|
110
|
+
guard offset + 5 < outputArray.count else { break }
|
|
111
|
+
|
|
112
|
+
let score = outputArray[offset + 4]
|
|
113
|
+
if score < confidenceThreshold { continue }
|
|
114
|
+
|
|
115
|
+
let box = BoundingBox(
|
|
116
|
+
x1: Double(outputArray[offset + 0]),
|
|
117
|
+
y1: Double(outputArray[offset + 1]),
|
|
118
|
+
x2: Double(outputArray[offset + 2]),
|
|
119
|
+
y2: Double(outputArray[offset + 3])
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
detections.append(
|
|
123
|
+
Detection(
|
|
124
|
+
boundingBox: box,
|
|
125
|
+
score: Double(score),
|
|
126
|
+
classId: Double(outputArray[offset + 5])
|
|
127
|
+
)
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return detections
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private func fillInputFromYuvFrame(
|
|
135
|
+
frame: any HybridFrameSpec,
|
|
136
|
+
input: inout Data,
|
|
137
|
+
dstWidth: Int,
|
|
138
|
+
dstHeight: Int,
|
|
139
|
+
dataType: Tensor.DataType
|
|
140
|
+
) throws {
|
|
141
|
+
let srcWidth = Int(frame.width)
|
|
142
|
+
let srcHeight = Int(frame.height)
|
|
143
|
+
|
|
144
|
+
// Extraction du CVPixelBuffer matériel pour un accès direct ultra-rapide
|
|
145
|
+
let nativeBuffer = try frame.getNativeBuffer()
|
|
146
|
+
guard let rawPointer = nativeBuffer.pointer else { return }
|
|
147
|
+
let pixelBuffer = Unmanaged<CVPixelBuffer>.fromOpaque(rawPointer).takeUnretainedValue()
|
|
148
|
+
|
|
149
|
+
CVPixelBufferLockBaseAddress(pixelBuffer, .readOnly)
|
|
150
|
+
defer { CVPixelBufferUnlockBaseAddress(pixelBuffer, .readOnly) }
|
|
151
|
+
|
|
152
|
+
guard let yBaseAddress = CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 0),
|
|
153
|
+
let uvBaseAddress = CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 1) else { return }
|
|
154
|
+
|
|
155
|
+
let yRowStride = CVPixelBufferGetBytesPerRowOfPlane(pixelBuffer, 0)
|
|
156
|
+
let uvRowStride = CVPixelBufferGetBytesPerRowOfPlane(pixelBuffer, 1)
|
|
157
|
+
|
|
158
|
+
let yPtr = yBaseAddress.assumingMemoryBound(to: UInt8.self)
|
|
159
|
+
let uvPtr = uvBaseAddress.assumingMemoryBound(to: UInt8.self)
|
|
160
|
+
|
|
161
|
+
// Allocation du flux d'écriture en octets
|
|
162
|
+
var bytes: [UInt8] = []
|
|
163
|
+
bytes.reserveCapacity(input.count)
|
|
164
|
+
|
|
165
|
+
for dy in 0..<dstHeight {
|
|
166
|
+
for dx in 0..<dstWidth {
|
|
167
|
+
let (srcX, srcY) = mapModelPixelToFramePixel(
|
|
168
|
+
dx: dx, dy: dy,
|
|
169
|
+
dstWidth: dstWidth, dstHeight: dstHeight,
|
|
170
|
+
srcWidth: srcWidth, srcHeight: srcHeight,
|
|
171
|
+
orientation: frame.orientation
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
let yIndex = srcY * yRowStride + srcX
|
|
175
|
+
|
|
176
|
+
// NV12 : Les composants U et V sont regroupés au même endroit (U, V, U, V)
|
|
177
|
+
// L'indice de ligne utilise uvRowStride. Chaque pixel partagé saute de 2 en 2 horizontalement.
|
|
178
|
+
let uvY = srcY / 2
|
|
179
|
+
let uvX = srcX / 2
|
|
180
|
+
let uvIndex = uvY * uvRowStride + uvX * 2
|
|
181
|
+
|
|
182
|
+
let y = Int(yPtr[yIndex])
|
|
183
|
+
let u = Int(uvPtr[uvIndex]) // Dans NV12, U se trouve au premier octet
|
|
184
|
+
let v = Int(uvPtr[uvIndex + 1]) // V se trouve juste à côté
|
|
185
|
+
|
|
186
|
+
// Formule standard de conversion YUV en RGB
|
|
187
|
+
let rFloat = Float(y) + 1.402 * Float(v - 128)
|
|
188
|
+
let gFloat = Float(y) - 0.344136 * Float(u - 128) - 0.714136 * Float(v - 128)
|
|
189
|
+
let bFloat = Float(y) + 1.772 * Float(u - 128)
|
|
190
|
+
|
|
191
|
+
let r = Int(rFloat.rounded()).clamped(to: 0...255)
|
|
192
|
+
let g = Int(gFloat.rounded()).clamped(to: 0...255)
|
|
193
|
+
let b = Int(bFloat.rounded()).clamped(to: 0...255)
|
|
194
|
+
|
|
195
|
+
if dataType == .float32 {
|
|
196
|
+
let rNorm = Float(r) / 255.0
|
|
197
|
+
let gNorm = Float(g) / 255.0
|
|
198
|
+
let bNorm = Float(b) / 255.0
|
|
199
|
+
|
|
200
|
+
withUnsafeBytes(of: rNorm) { bytes.append(contentsOf: $0) }
|
|
201
|
+
withUnsafeBytes(of: gNorm) { bytes.append(contentsOf: $0) }
|
|
202
|
+
withUnsafeBytes(of: bNorm) { bytes.append(contentsOf: $0) }
|
|
203
|
+
} else {
|
|
204
|
+
bytes.append(UInt8(r))
|
|
205
|
+
bytes.append(UInt8(g))
|
|
206
|
+
bytes.append(UInt8(b))
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Copie globale du flux binaire structuré dans l'espace mémoire d'entrée
|
|
212
|
+
input.withUnsafeMutableBytes { dstPtr in
|
|
213
|
+
bytes.withUnsafeBytes { srcPtr in
|
|
214
|
+
dstPtr.copyBytes(from: srcPtr)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private func mapModelPixelToFramePixel(
|
|
220
|
+
dx: Int, dy: Int,
|
|
221
|
+
dstWidth: Int, dstHeight: Int,
|
|
222
|
+
srcWidth: Int, srcHeight: Int,
|
|
223
|
+
orientation: CameraOrientation
|
|
224
|
+
) -> (x: Int, y: Int) {
|
|
225
|
+
let nx = Float(dx) / Float(dstWidth)
|
|
226
|
+
let ny = Float(dy) / Float(dstHeight)
|
|
227
|
+
|
|
228
|
+
switch orientation {
|
|
229
|
+
case .up:
|
|
230
|
+
let sx = Int(nx * Float(srcWidth))
|
|
231
|
+
let sy = Int(ny * Float(srcHeight))
|
|
232
|
+
return (sx.clamped(to: 0...(srcWidth - 1)), sy.clamped(to: 0...(srcHeight - 1)))
|
|
233
|
+
case .down:
|
|
234
|
+
let sx = Int((1.0 - nx) * Float(srcWidth))
|
|
235
|
+
let sy = Int((1.0 - ny) * Float(srcHeight))
|
|
236
|
+
return (sx.clamped(to: 0...(srcWidth - 1)), sy.clamped(to: 0...(srcHeight - 1)))
|
|
237
|
+
case .left:
|
|
238
|
+
let sx = Int(ny * Float(srcWidth))
|
|
239
|
+
let sy = Int((1.0 - nx) * Float(srcHeight))
|
|
240
|
+
return (sx.clamped(to: 0...(srcWidth - 1)), sy.clamped(to: 0...(srcHeight - 1)))
|
|
241
|
+
case .right:
|
|
242
|
+
let sx = Int((1.0 - ny) * Float(srcWidth))
|
|
243
|
+
let sy = Int(nx * Float(srcHeight))
|
|
244
|
+
return (sx.clamped(to: 0...(srcWidth - 1)), sy.clamped(to: 0...(srcHeight - 1)))
|
|
245
|
+
@unknown default:
|
|
246
|
+
return (0, 0)
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import NitroModules
|
|
3
|
+
import TensorFlowLite // Activé via votre s.dependency 'TensorFlowLiteSwift'
|
|
4
|
+
|
|
5
|
+
public class YoloModelLoader {
|
|
6
|
+
private static let tag = "YOLO_TAG_LOADER"
|
|
7
|
+
|
|
8
|
+
public init() {}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Charge un modèle YOLO à partir du chemin spécifié (URL, URI de fichier, chemin absolu, ou ressource brute).
|
|
12
|
+
* Retourne un objet `Data` contenant les octets du modèle configurés via mmap (équivalent du MappedByteBuffer).
|
|
13
|
+
*/
|
|
14
|
+
public func load(modelPath: String) throws -> Data {
|
|
15
|
+
if modelPath.hasPrefix("http://") || modelPath.hasPrefix("https://") {
|
|
16
|
+
NSLog("[%@]: Loading model from URL", YoloModelLoader.tag)
|
|
17
|
+
let cachedFileURL = try downloadToCache(urlString: modelPath)
|
|
18
|
+
return try mapFile(fileURL: cachedFileURL)
|
|
19
|
+
|
|
20
|
+
} else if modelPath.hasPrefix("file://") {
|
|
21
|
+
NSLog("[%@]: Loading model from file URI", YoloModelLoader.tag)
|
|
22
|
+
guard let url = URL(string: modelPath) else {
|
|
23
|
+
throw NSError(domain: "YoloModelLoader", code: 400, userInfo: [NSLocalizedDescriptionKey: "Invalid file URI"])
|
|
24
|
+
}
|
|
25
|
+
return try mapFile(fileURL: url)
|
|
26
|
+
|
|
27
|
+
} else if modelPath.hasPrefix("/") {
|
|
28
|
+
NSLog("[%@]: Loading model from absolute path", YoloModelLoader.tag)
|
|
29
|
+
let url = URL(fileURLWithPath: modelPath)
|
|
30
|
+
return try mapFile(fileURL: url)
|
|
31
|
+
|
|
32
|
+
} else if modelPath.hasPrefix("assets_") {
|
|
33
|
+
NSLog("[%@]: Loading model from RN raw resource", YoloModelLoader.tag)
|
|
34
|
+
let cachedFileURL = try copyRawResourceToCache(resourceName: modelPath)
|
|
35
|
+
return try mapFile(fileURL: cachedFileURL)
|
|
36
|
+
|
|
37
|
+
} else {
|
|
38
|
+
NSLog("[%@]: Loading model from Main App Bundle Assets", YoloModelLoader.tag)
|
|
39
|
+
let cleanName = modelPath.replacingOccurrences(of: ".tflite", with: "")
|
|
40
|
+
guard let bundleURL = ContextProvider.mainBundle.url(forResource: cleanName, withExtension: "tflite") else {
|
|
41
|
+
throw NSError(domain: "YoloModelLoader", code: 404, userInfo: [NSLocalizedDescriptionKey: "Model not found in main bundle: \(modelPath)"])
|
|
42
|
+
}
|
|
43
|
+
return try mapFile(fileURL: bundleURL)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private func copyRawResourceToCache(resourceName: String) throws -> URL {
|
|
48
|
+
guard let bundleURL = ContextProvider.mainBundle.url(forResource: resourceName, withExtension: "tflite") else {
|
|
49
|
+
throw NSError(domain: "YoloModelLoader", code: 404, userInfo: [NSLocalizedDescriptionKey: "Raw resource not found: \(resourceName)"])
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let destinationURL = ContextProvider.cacheDirectory.appendingPathComponent("\(resourceName).tflite")
|
|
53
|
+
|
|
54
|
+
if FileManager.default.fileExists(atPath: destinationURL.path) {
|
|
55
|
+
try? FileManager.default.removeItem(at: destinationURL)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
try FileManager.default.copyItem(at: bundleURL, to: destinationURL)
|
|
59
|
+
|
|
60
|
+
let attributes = try FileManager.default.attributesOfItem(atPath: destinationURL.path)
|
|
61
|
+
let fileSize = attributes[.size] as? Int64 ?? 0
|
|
62
|
+
|
|
63
|
+
NSLog("[%@]: Copied raw resource to: %@", YoloModelLoader.tag, destinationURL.path)
|
|
64
|
+
NSLog("[%@]: Copied raw resource size: %lld bytes", YoloModelLoader.tag, fileSize)
|
|
65
|
+
|
|
66
|
+
return destinationURL
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private func mapFile(fileURL: URL) throws -> Data {
|
|
70
|
+
let path = fileURL.path
|
|
71
|
+
if !FileManager.default.fileExists(atPath: path) {
|
|
72
|
+
throw NSError(domain: "YoloModelLoader", code: 404, userInfo: [NSLocalizedDescriptionKey: "Model file does not exist: \(path)"])
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let attributes = try FileManager.default.attributesOfItem(atPath: path)
|
|
76
|
+
let fileSize = attributes[.size] as? Int64 ?? 0
|
|
77
|
+
|
|
78
|
+
if fileSize <= 0 {
|
|
79
|
+
throw NSError(domain: "YoloModelLoader", code: 400, userInfo: [NSLocalizedDescriptionKey: "Model file is empty: \(path)"])
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// .alwaysMapped utilise mmap au niveau du noyau iOS pour des performances instantanées (Zero CPU copy)
|
|
83
|
+
return try Data(contentsOf: fileURL, options: .alwaysMapped)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private func downloadToCache(urlString: String) throws -> URL {
|
|
87
|
+
guard let url = URL(string: urlString) else {
|
|
88
|
+
throw NSError(domain: "YoloModelLoader", code: 400, userInfo: [NSLocalizedDescriptionKey: "Malformed URL"])
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let destinationURL = ContextProvider.cacheDirectory.appendingPathComponent("yolo_model.tflite")
|
|
92
|
+
let modelData = try Data(contentsOf: url)
|
|
93
|
+
try modelData.write(to: destinationURL, options: .atomic)
|
|
94
|
+
|
|
95
|
+
NSLog("[%@]: Downloaded model to: %@", YoloModelLoader.tag, destinationURL.path)
|
|
96
|
+
NSLog("[%@]: Downloaded model size: %d bytes", YoloModelLoader.tag, modelData.count)
|
|
97
|
+
|
|
98
|
+
return destinationURL
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Alloue un tampon d'entrée brut ('Data') calibré sur les dimensions du tenseur d'entrée du modèle.
|
|
103
|
+
* Remplace parfaitement Direct ByteBuffer de Java.
|
|
104
|
+
*/
|
|
105
|
+
public func makeInputBuffer(interpreter: Interpreter) throws -> Data {
|
|
106
|
+
// Récupérer le premier tenseur d'entrée du modèle TFLite
|
|
107
|
+
let inputTensor = try interpreter.inputTensor(at: 0)
|
|
108
|
+
let shape = inputTensor.shape.dimensions // Généralement: [1, 640, 640, 3]
|
|
109
|
+
let dataType = inputTensor.dataType
|
|
110
|
+
|
|
111
|
+
let batch = shape[0]
|
|
112
|
+
let height = shape[1]
|
|
113
|
+
let width = shape[2]
|
|
114
|
+
let channels = shape[3]
|
|
115
|
+
|
|
116
|
+
// Vérifications strictes (équivalents de require() en Kotlin)
|
|
117
|
+
guard batch == 1 else {
|
|
118
|
+
fatalError("YOLO Input Violation: Batch size must be 1")
|
|
119
|
+
}
|
|
120
|
+
guard channels == 3 else {
|
|
121
|
+
fatalError("YOLO Input Violation: Channels count must be 3 (RGB)")
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let bytesPerValue: Int
|
|
125
|
+
switch dataType {
|
|
126
|
+
case .float32:
|
|
127
|
+
bytesPerValue = 4
|
|
128
|
+
case .uInt8:
|
|
129
|
+
bytesPerValue = 1
|
|
130
|
+
default:
|
|
131
|
+
fatalError("Unsupported input type: \(dataType)")
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
let totalBytes = batch * width * height * channels * bytesPerValue
|
|
135
|
+
|
|
136
|
+
// Crée une structure Data Swift vide avec l'empreinte mémoire exacte
|
|
137
|
+
return Data(repeating: 0, count: totalBytes)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import UIKit
|
|
3
|
+
import NitroModules
|
|
4
|
+
import VisionCamera
|
|
5
|
+
|
|
6
|
+
public enum BitmapOrientationFixer {
|
|
7
|
+
public static func fix(
|
|
8
|
+
jpegBytes: [UInt8],
|
|
9
|
+
frame: any HybridFrameSpec,
|
|
10
|
+
quality: Int
|
|
11
|
+
) -> [UInt8] {
|
|
12
|
+
|
|
13
|
+
let data = Data(jpegBytes)
|
|
14
|
+
guard let image = UIImage(data: data),
|
|
15
|
+
let cgImage = image.cgImage
|
|
16
|
+
else {
|
|
17
|
+
return jpegBytes
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let uiOrientation: UIImage.Orientation
|
|
21
|
+
|
|
22
|
+
switch frame.orientation {
|
|
23
|
+
case .up:
|
|
24
|
+
uiOrientation = frame.isMirrored ? .upMirrored : .up
|
|
25
|
+
case .down:
|
|
26
|
+
uiOrientation = frame.isMirrored ? .downMirrored : .down
|
|
27
|
+
case .left:
|
|
28
|
+
uiOrientation = frame.isMirrored ? .leftMirrored : .left
|
|
29
|
+
case .right:
|
|
30
|
+
uiOrientation = frame.isMirrored ? .rightMirrored : .right
|
|
31
|
+
@unknown default:
|
|
32
|
+
uiOrientation = .up
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// 1. Wrap the image with the correct EXIF layout metadata
|
|
36
|
+
let orientedImage = UIImage(
|
|
37
|
+
cgImage: cgImage,
|
|
38
|
+
scale: 1.0, // Force 1.0 to preserve raw camera matrix resolution
|
|
39
|
+
orientation: uiOrientation
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
// 2. Configure a 1:1 hardware pixel canvas context format
|
|
43
|
+
let rendererFormat = UIGraphicsImageRendererFormat.default()
|
|
44
|
+
rendererFormat.scale = 1.0
|
|
45
|
+
rendererFormat.opaque = true // JPEG does not support transparency, making it opaque saves memory performance
|
|
46
|
+
|
|
47
|
+
// 3. Draw and physically bake the metadata rotation permanently into new pixel bytes
|
|
48
|
+
let bakedImage = UIGraphicsImageRenderer(
|
|
49
|
+
size: orientedImage.size,
|
|
50
|
+
format: rendererFormat
|
|
51
|
+
).image { _ in
|
|
52
|
+
orientedImage.draw(in: CGRect(origin: .zero, size: orientedImage.size))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let compressionQuality = CGFloat(max(0, min(quality, 100))) / 100.0
|
|
56
|
+
|
|
57
|
+
// 4. Compress the baked image. The metadata flag is now gone, and the pixels themselves are rotated.
|
|
58
|
+
guard let outputData = bakedImage.jpegData(compressionQuality: compressionQuality) else {
|
|
59
|
+
return jpegBytes
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return [UInt8](outputData)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import UIKit
|
|
3
|
+
import NitroModules
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Provides a way to access application-wide singletons and paths from anywhere in the app.
|
|
7
|
+
* This acts as the iOS equivalent to the Android ContextProvider.
|
|
8
|
+
*/
|
|
9
|
+
public enum ContextProvider {
|
|
10
|
+
|
|
11
|
+
/// Returns the main application instance (equivalent to Application instance)
|
|
12
|
+
public static var sharedApplication: UIApplication {
|
|
13
|
+
// Must be accessed on the main thread
|
|
14
|
+
if Thread.isMainThread {
|
|
15
|
+
return UIApplication.shared
|
|
16
|
+
} else {
|
|
17
|
+
var app: UIApplication!
|
|
18
|
+
DispatchQueue.main.sync {
|
|
19
|
+
app = UIApplication.shared
|
|
20
|
+
}
|
|
21
|
+
return app
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/// Returns the main bundle containing application assets (equivalent to context.resources)
|
|
26
|
+
public static var mainBundle: Bundle {
|
|
27
|
+
return Bundle.main
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/// Returns the application cache directory URL (equivalent to context.cacheDir)
|
|
31
|
+
public static var cacheDirectory: URL {
|
|
32
|
+
guard let url = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else {
|
|
33
|
+
fatalError("iOS Cache Directory is null or inaccessible")
|
|
34
|
+
}
|
|
35
|
+
return url
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/// Returns the application document directory URL (equivalent to context.filesDir)
|
|
39
|
+
public static var documentsDirectory: URL {
|
|
40
|
+
guard let url = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else {
|
|
41
|
+
fatalError("iOS Documents Directory is null or inaccessible")
|
|
42
|
+
}
|
|
43
|
+
return url
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import NitroModules
|
|
3
|
+
import VisionCamera
|
|
4
|
+
|
|
5
|
+
public enum FrameJpegConverter {
|
|
6
|
+
private static let tag = "YOLO_TAG_FrameJpegConverter"
|
|
7
|
+
|
|
8
|
+
public static func toJpegBytes(frame: any HybridFrameSpec, quality: Int = 80) -> [UInt8] {
|
|
9
|
+
// 1. Extraction et arrondi propre des dimensions du frame
|
|
10
|
+
let width = Int(frame.width.rounded())
|
|
11
|
+
let height = Int(frame.height.rounded())
|
|
12
|
+
|
|
13
|
+
// 2. Conversion ultra-rapide via memcpy vers le format matériel NV12
|
|
14
|
+
let nv12 = Yuv420ToNv12Converter.convert(frame: frame, width: width, height: height)
|
|
15
|
+
|
|
16
|
+
if nv12.isEmpty {
|
|
17
|
+
NSLog("[%@]: ❌ Failed to convert frame to NV12 array", tag)
|
|
18
|
+
return []
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// 3. Encodage matériel en JPEG via le GPU (CoreImage / CIContext)
|
|
22
|
+
let jpegBytes = Nv12JpegEncoder.encode(
|
|
23
|
+
nv12: nv12,
|
|
24
|
+
width: width,
|
|
25
|
+
height: height,
|
|
26
|
+
quality: quality
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
if jpegBytes.isEmpty {
|
|
30
|
+
NSLog("[%@]: ❌ Failed to encode NV12 data to JPEG bytes", tag)
|
|
31
|
+
return []
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// 4. Redessin de sécurité via UIGraphicsImageRenderer pour fixer définitivement les pixels
|
|
35
|
+
return BitmapOrientationFixer.fix(
|
|
36
|
+
jpegBytes: jpegBytes,
|
|
37
|
+
frame: frame,
|
|
38
|
+
quality: quality
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import NitroModules
|
|
3
|
+
import VisionCamera
|
|
4
|
+
|
|
5
|
+
public enum FrameValidator {
|
|
6
|
+
private static let tag = "YOLO_TAG_FrameValidator"
|
|
7
|
+
|
|
8
|
+
public static func isValidYuv(frame: any HybridFrameSpec) -> Bool {
|
|
9
|
+
// 1. Vérification de la validité globale du Frame
|
|
10
|
+
if !frame.isValid {
|
|
11
|
+
NSLog("[%@]: ❌ Frame is not valid", tag)
|
|
12
|
+
return false
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
do {
|
|
16
|
+
let planes = try frame.getPlanes()
|
|
17
|
+
|
|
18
|
+
// 2. Sur iOS, le format matériel natif NV12 contient exactement 2 plans.
|
|
19
|
+
// On s'assure d'avoir au moins ces 2 plans requis.
|
|
20
|
+
if planes.count < 2 {
|
|
21
|
+
NSLog("[%@]: ❌ Expected at least 2 YUV planes (NV12), got %d", tag, planes.count)
|
|
22
|
+
return false
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// 3. Parcours et validation de chaque plan individuel
|
|
26
|
+
for (index, plane) in planes.enumerated() {
|
|
27
|
+
if !plane.isValid {
|
|
28
|
+
NSLog("[%@]: ❌ Plane %d is not valid", tag, index)
|
|
29
|
+
return false
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return true
|
|
34
|
+
|
|
35
|
+
} catch {
|
|
36
|
+
NSLog("[%@]: ❌ Failed to retrieve planes from frame: %@", tag, error.localizedDescription)
|
|
37
|
+
return false
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import CoreImage
|
|
3
|
+
import UIKit
|
|
4
|
+
|
|
5
|
+
public enum Nv12JpegEncoder {
|
|
6
|
+
// Réutiliser le CIContext permet d'éviter des fuites de mémoire massives à chaque frame
|
|
7
|
+
private static let ciContext = CIContext(options: [CIContextOption.useSoftwareRenderer: false])
|
|
8
|
+
|
|
9
|
+
public static func encode(
|
|
10
|
+
nv12: [UInt8],
|
|
11
|
+
width: Int,
|
|
12
|
+
height: Int,
|
|
13
|
+
quality: Int
|
|
14
|
+
) -> [UInt8] {
|
|
15
|
+
|
|
16
|
+
// 1. Convertir la qualité (0-100 sur Android) en CGFloat (0.0-1.0 sur iOS)
|
|
17
|
+
let compressionQuality = CGFloat(max(0, min(quality, 100))) / 100.0
|
|
18
|
+
|
|
19
|
+
let ySize = width * height
|
|
20
|
+
let uvSize = ySize / 2
|
|
21
|
+
|
|
22
|
+
// Sécurité : s'assurer que la taille du tableau correspond bien aux dimensions fournies
|
|
23
|
+
guard nv12.count >= (ySize + uvSize) else { return [] }
|
|
24
|
+
|
|
25
|
+
// 2. Transformer le tableau d'octets [UInt8] en objet Data Swift
|
|
26
|
+
let rawData = Data(bytes: nv12, count: ySize + uvSize)
|
|
27
|
+
|
|
28
|
+
// 3. Spécifier le format de couleur NV12 pour CoreImage (kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange)
|
|
29
|
+
let imageOptions: [CIImageOption: Any] = [
|
|
30
|
+
.colorSpace: CGColorSpaceCreateDeviceRGB()
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
// On indique à CoreImage la structure exacte du NV12 (Plan 0: Y, Plan 1: UV entrelacé)
|
|
34
|
+
guard let ciImage = CIImage(
|
|
35
|
+
imageWithFormat: Int32(kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange),
|
|
36
|
+
size: CGSize(width: width, height: height),
|
|
37
|
+
data: rawData,
|
|
38
|
+
rowBytes: width,
|
|
39
|
+
options: imageOptions
|
|
40
|
+
) else {
|
|
41
|
+
return []
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 4. Rendu de l'image GPU vers une structure d'image CoreGraphics
|
|
45
|
+
guard let cgImage = ciContext.createCGImage(ciImage, from: ciImage.extent) else {
|
|
46
|
+
return []
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// 5. Conversion en UIImage puis compression matérielle en JPEG
|
|
50
|
+
let uiImage = UIImage(cgImage: cgImage)
|
|
51
|
+
guard let jpegData = uiImage.jpegData(compressionQuality: compressionQuality) else {
|
|
52
|
+
return []
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 6. Retourner le tableau d'octets natif [UInt8] requis par votre modèle
|
|
56
|
+
return [UInt8](jpegData)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import AVFoundation
|
|
3
|
+
import NitroModules
|
|
4
|
+
import VisionCamera
|
|
5
|
+
import CoreVideo
|
|
6
|
+
|
|
7
|
+
public enum Yuv420ToNv12Converter {
|
|
8
|
+
public static func convert(frame: any HybridFrameSpec, width: Int, height: Int) -> [UInt8] {
|
|
9
|
+
do {
|
|
10
|
+
let nativeBuffer = try frame.getNativeBuffer()
|
|
11
|
+
guard let rawPointer = nativeBuffer.pointer else { return [] }
|
|
12
|
+
|
|
13
|
+
let pixelBuffer = Unmanaged<CVPixelBuffer>
|
|
14
|
+
.fromOpaque(rawPointer)
|
|
15
|
+
.takeUnretainedValue()
|
|
16
|
+
|
|
17
|
+
CVPixelBufferLockBaseAddress(pixelBuffer, .readOnly)
|
|
18
|
+
defer {
|
|
19
|
+
CVPixelBufferUnlockBaseAddress(pixelBuffer, .readOnly)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
guard
|
|
23
|
+
let yBaseAddress = CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 0),
|
|
24
|
+
let uvBaseAddress = CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 1)
|
|
25
|
+
else {
|
|
26
|
+
return []
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let yRowStride = CVPixelBufferGetBytesPerRowOfPlane(pixelBuffer, 0)
|
|
30
|
+
let uvRowStride = CVPixelBufferGetBytesPerRowOfPlane(pixelBuffer, 1)
|
|
31
|
+
|
|
32
|
+
let ySize = width * height
|
|
33
|
+
let uvSize = ySize / 2
|
|
34
|
+
|
|
35
|
+
var nv12 = [UInt8](repeating: 0, count: ySize + uvSize)
|
|
36
|
+
|
|
37
|
+
nv12.withUnsafeMutableBytes { dstBuffer in
|
|
38
|
+
guard let dstBase = dstBuffer.baseAddress else { return }
|
|
39
|
+
|
|
40
|
+
let ySrc = yBaseAddress.assumingMemoryBound(to: UInt8.self)
|
|
41
|
+
let uvSrc = uvBaseAddress.assumingMemoryBound(to: UInt8.self)
|
|
42
|
+
let dst = dstBase.assumingMemoryBound(to: UInt8.self)
|
|
43
|
+
|
|
44
|
+
// 1. Copy Y Plane row-by-row to safely discard padding/stride
|
|
45
|
+
for row in 0..<height {
|
|
46
|
+
memcpy(
|
|
47
|
+
dst.advanced(by: row * width),
|
|
48
|
+
ySrc.advanced(by: row * yRowStride),
|
|
49
|
+
width
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 2. Copy UV Plane row-by-row safely
|
|
54
|
+
let uvDstStart = ySize
|
|
55
|
+
let chromaHeight = height / 2
|
|
56
|
+
|
|
57
|
+
for row in 0..<chromaHeight {
|
|
58
|
+
memcpy(
|
|
59
|
+
dst.advanced(by: uvDstStart + (row * width)), // Fixed layout math
|
|
60
|
+
uvSrc.advanced(by: row * uvRowStride),
|
|
61
|
+
width
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return nv12
|
|
67
|
+
} catch {
|
|
68
|
+
return []
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|