ArcFace 2.0 API目前支援多種影象格式:BGR24
、NV21
、NV12
、I420
、YUYV
(Android、IOS只支援其中的部分)。接下來將開始介紹這幾種影象格式以及部分轉換方式。
一、相關影象顏色空間介紹
1. RGB顏色空間
RGB顏色空間
以Red、Green、Blue三種基本色為基礎,進行不同程度的疊加,產生豐富而廣泛的顏色,所以俗稱三基色模式。
常見的RGB格式有:RGB_565
、RGB_888
、ARGB_8888
、ARGB_4444
等。
2. YUV顏色空間
在YUV顏色空間
中,Y用來表示亮度,U和V用來表示色度。
常見的YUV格式有以下幾大類:
planar: Y、U、V全部連續儲存,如I420
、YV12
packed: Y、U、V交叉儲存,如YUYV
semi-planar: Y連續儲存,U、V交叉儲存,如NV21
、NV12
二、相關影象格式介紹
1. BGR24影象格式
BGR24
影象格式是一種採用24bpp(bit per pixel)的格式。每個顏色通道B、G、R各佔8bpp。
排列方式如:
B G R B G R B G R B G R B G R B G R B G R B G R
B G R B G R B G R B G R B G R B G R B G R B G R
B G R B G R B G R B G R B G R B G R B G R B G R
B G R B G R B G R B G R B G R B G R B G R B G R
複製程式碼
2. NV21影象格式
NV21
影象格式屬於 YUV顏色空間中的YUV420SP
格式,每四個Y分量共用一組U分量和V分量,Y連續排序,U與V交叉排序。
排列方式如:
Y Y Y Y Y Y Y Y
Y Y Y Y Y Y Y Y
Y Y Y Y Y Y Y Y
Y Y Y Y Y Y Y Y
V U V U V U V U
V U V U V U V U
複製程式碼
3. NV12影象格式
NV12
影象格式屬於 YUV顏色空間中的YUV420SP
格式,每四個Y分量共用一組U分量和V分量,Y連續排序,U與V交叉排序(NV12
和NV21
只是U與V的位置相反)。
排列方式如:
Y Y Y Y Y Y Y Y
Y Y Y Y Y Y Y Y
Y Y Y Y Y Y Y Y
Y Y Y Y Y Y Y Y
U V U V U V U V
U V U V U V U V
複製程式碼
4. I420影象格式
I420
影象格式屬於 YUV顏色空間中的YUV420P
格式,每四個Y分量共用一組U分量和V分量,Y、U、V各自連續排序。(為了便於說明Y、U、V的共用關係,U和V都未換行)
排列方式如:
Y Y Y Y Y Y Y Y
Y Y Y Y Y Y Y Y
Y Y Y Y Y Y Y Y
Y Y Y Y Y Y Y Y
U U U U U U U U
V V V V V V V V
複製程式碼
5. YV12影象格式
YV12
影象格式屬於 YUV顏色空間中的YUV420P
格式,每四個Y分量共用一組U分量和V分量,Y、U、V各自連續排序(為了便於說明Y、U、V的共用關係,U和V都未換行)(YV12
和I420
只是U與V的位置相反)。
排列方式如:
Y Y Y Y Y Y Y Y
Y Y Y Y Y Y Y Y
Y Y Y Y Y Y Y Y
Y Y Y Y Y Y Y Y
V V V V V V V V
U U U U U U U U
複製程式碼
6. YUYV影象格式
YUYV
影象格式屬於 YUV顏色空間中的YUV422
格式,每兩個Y分量共用一組U分量和V分量,Y、U、V交叉排序。
排列方式如:
Y U Y V Y U Y V Y U Y V Y U Y V
Y U Y V Y U Y V Y U Y V Y U Y V
Y U Y V Y U Y V Y U Y V Y U Y V
Y U Y V Y U Y V Y U Y V Y U Y V
複製程式碼
三、影象格式轉換
由於影象的格式多種多樣,轉換的方法也不勝列舉,只要瞭解了YUV
和RGB
資料的排列方式,自己編寫影象轉換程式碼也花不了多少時間。以下列出部分的影象轉換的Java程式碼供參考。
1. 從Bitmap
中獲取ARGB_8888
影象格式資料(Android平臺)
Bitmap
支援多種格式:ALPHA_8,RGB_565,ARGB_4444,ARGB_8888,RGBA_F16,HARDWARE
。我們目前主要選擇ARGB_8888
進行格式轉換。
我們可使用Bitmap
類中的
public void getPixels(@ColorInt int[] pixels, int offset, int stride, int x, int y, int width, int height)
方法獲取int[]
型別的argb資料或
public void copyPixelsToBuffer (Buffer dst)
方法獲取byte[]
型別的ARGB_8888
資料。
2. ARGB_8888
轉換為BGR_24
舉個例子,對於4x2的圖片,ARGB_8888
格式內容為:
A1 R1 G1 B1 A2 R2 G2 B2 A3 R3 G3 B3 A4 R4 G4 B4
A5 R5 G5 B5 A6 R6 G6 B6 A7 R7 G7 B7 A8 R8 G8 B8
複製程式碼
那麼若需要轉化為BGR_24
,內容將變成:
B1 G1 R1 B2 G2 R2 B3 G3 R3 B4 G4 R4
B5 G5 R5 B6 G6 R6 B7 G7 R7 B8 G8 R8
複製程式碼
BGR_24
內容為3個byte
一組,ARGB_8888
內容為4個byte
一組。因此,對於第一組ARGB_8888(A1 R1 G1 B1)
和第一組BGR_24(B1 G1 R1)
,其對應關係為:
bgr24[0] = argb8888[3];
bgr24[1] = argb8888[2];
bgr24[2] = argb8888[1];
複製程式碼
對應的轉換程式碼:
public static byte[] argb8888ToBgr24(byte[] argb8888) {
if (argb8888 == null){
throw new IllegalArgumentException("invalid image params!");
}
int groupNum = argb8888.length / 4;
byte[] bgr24 = new byte[groupNum * 3];
int bgr24Index = 0;
int argb8888Index = 0;
for (int i = 0; i < groupNum; i++) {
bgr24[bgr24Index] = argb8888[argb8888Index + 2];
bgr24[bgr24Index + 1] = argb8888[argb8888Index + 1];
bgr24[bgr24Index + 2] = argb8888[argb8888Index];
bgr24Index += 3;
argb8888Index += 4;
}
return bgr24;
}
複製程式碼
3. ARGB_8888
轉換為NV21
rgb
轉yuv
的演算法:
int y = (66 * r + 129 * g + 25 * b + 128 >> 8) + 16;
int u = (-38 * r - 74 * g + 112 * b + 128 >> 8) + 128;
int v = (112 * r - 94 * g - 18 * b + 128 >> 8) + 128;
複製程式碼
轉換方法:
int[]
型別的ARGB_8888
資料轉換為NV21
:
private static byte[] argbToNv21(int[] argb, int width, int height) {
if (argb == null || argb.length == 0 || width * height != argb.length) {
throw new IllegalArgumentException("invalid image params!");
}
int yIndex = 0;
int uvIndex = width * height;
int argbIndex = 0;
byte[] nv21 = new byte[width * height * 3 / 2];
for (int j = 0; j < height; ++j) {
for (int i = 0; i < width; ++i) {
//對於int型color資料,格式為0xAARRGGBB,可進行與運算後移位取對應A R G B,
//但是該YUV轉換公式中不需要ALPHA,因此我們只需要取 R G B 即可。
int r = (argb[argbIndex] & 0xFF0000) >> 16;
int g = (argb[argbIndex] & 0x00FF00) >> 8;
int b = argb[argbIndex] & 0x0000FF;
//獲取該畫素點的R G B,並轉換為Y U V,但byte範圍是0x00~0xFF,因此在賦值時還需進行判斷
int y = (66 * r + 129 * g + 25 * b + 128 >> 8) + 16;
nv21[yIndex++] = (byte) (y < 0 ? 0 : (y > 0xFF ? 0xFF : y));
if ((j & 1) == 0 && (argbIndex & 1) == 0 && uvIndex < nv21.length - 2) {
int u = (-38 * r - 74 * g + 112 * b + 128 >> 8) + 128;
int v = (112 * r - 94 * g - 18 * b + 128 >> 8) + 128;
nv21[uvIndex++] = (byte) (v < 0 ? 0 : (v > 0xFF ? 0xFF : v));
nv21[uvIndex++] = (byte) (u < 0 ? 0 : (u > 0xFF ? 0xFF : u));
}
++argbIndex;
}
}
return nv21;
}
複製程式碼
byte[]
型別的ARGB_8888
資料轉換為NV21
(原理同方法1):
private static byte[] argbToNv21(byte[] argb, int width, int height) {
if (argb == null || argb.length == 0 || width * height * 4 != argb.length) {
throw new IllegalArgumentException("invalid image params!");
}
int yIndex = 0;
int uvIndex = width * height;
int argbIndex = 0;
byte[] nv21 = new byte[width * height * 3 / 2];
for (int j = 0; j < height; ++j) {
for (int i = 0; i < width; ++i) {
argbIndex++;
int r = argb[argbIndex++];
int g = argb[argbIndex++];
int b = argb[argbIndex++];
r &= 0x000000FF;
g &= 0x000000FF;
b &= 0x000000FF;
int y = ((66 * r + 129 * g + 25 * b + 128 >> 8) + 16);
nv21[yIndex++] = (byte) (y > 0xFF ? 0xFF : (y < 0 ? 0 : y));
if ((j & 1) == 0 && ((argbIndex >> 2) & 1) == 0 && uvIndex < nv21.length - 2) {
int u = ((-38 * r - 74 * g + 112 * b + 128 >> 8) + 128);
int v = ((112 * r - 94 * g - 18 * b + 128 >> 8) + 128);
nv21[uvIndex++] = (byte) (v > 0xFF ? 0xFF : (v < 0 ? 0 : v));
nv21[uvIndex++] = (byte) (u > 0xFF ? 0xFF : (u < 0 ? 0 : u));
}
}
}
return nv21;
}
複製程式碼
4. NV21
轉換為BGR24
yuv
轉rgb
演算法:
int r = (int) ((y & 0xFF) + 1.4075 * ((v & 0xFF) - 128));
int g = (int) ((y & 0xFF) - 0.3455 * ((u & 0xFF) - 128) - 0.7169 * ((v & 0xFF) - 128));
int b = (int) ((y & 0xFF) + 1.779 * ((u & 0xFF) - 128));
複製程式碼
轉換方法:
private static byte[] nv21ToBgr24(byte[] nv21, int width, int height) {
if (nv21 == null || nv21.length == 0 || width * height * 3 / 2 != nv21.length) {
throw new IllegalArgumentException("invalid image params!");
}
byte[] bgr24 = new byte[width * height * 3];
int bgrLineSize = width * 3;
//偶數行的bgr資料下標
int evenLineBgrIndex = 0;
//奇數行的bgr資料下標
int oddLineBgrIndex = bgrLineSize;
//當前一行y資料最左邊的下標
int yLineStart = 0;
//uv資料的下標
int uvIndex = width * height;
//由於NV21的共用關係,每2行做一次轉換
for (int i = 0; i < height; i += 2) {
for (int widthOffset = 0; widthOffset < width; widthOffset++) {
byte v = nv21[uvIndex];
byte u = nv21[uvIndex + 1];
byte yEven = nv21[yLineStart + widthOffset];
byte yOdd = nv21[yLineStart + width + widthOffset];
//偶數行YUV轉RGB
int r, g, b;
r = (int) ((yEven & 0xFF) + 1.4075 * ((v & 0xFF) - 128));
g = (int) ((yEven & 0xFF) - 0.3455 * ((u & 0xFF) - 128) - 0.7169 * ((v & 0xFF) - 128));
b = (int) ((yEven & 0xFF) + 1.779 * ((u & 0xFF) - 128));
r = r < 0 ? 0 : r > 0xFF ? 0xFF : r;
g = g < 0 ? 0 : g > 0xFF ? 0xFF : g;
b = b < 0 ? 0 : b > 0xFF ? 0xFF : b;
bgr24[evenLineBgrIndex++] = (byte) b;
bgr24[evenLineBgrIndex++] = (byte) g;
bgr24[evenLineBgrIndex++] = (byte) r;
//奇數行YUV轉RGB
r = (int) ((yOdd & 0xFF) + 1.4075 * ((v & 0xFF) - 128));
g = (int) ((yOdd & 0xFF) - 0.3455 * ((u & 0xFF) - 128) - 0.7169 * ((v & 0xFF) - 128));
b = (int) ((yOdd & 0xFF) + 1.779 * ((u & 0xFF) - 128));
r = r < 0 ? 0 : r > 0xFF ? 0xFF : r;
g = g < 0 ? 0 : g > 0xFF ? 0xFF : g;
b = b < 0 ? 0 : b > 0xFF ? 0xFF : b;
bgr24[oddLineBgrIndex++] = (byte) b;
bgr24[oddLineBgrIndex++] = (byte) g;
bgr24[oddLineBgrIndex++] = (byte) r;
//每兩個y將uv下標增1
if ((widthOffset & 1) == 1) {
uvIndex += 2;
}
}
//由於在內層迴圈中已經做過width * 3次自增,所以外層迴圈中只需要增加一行
evenLineBgrIndex += bgrLineSize;
oddLineBgrIndex += bgrLineSize;
//y增2行
yLineStart += width * 2;
}
return bgr24;
}
複製程式碼
5. NV12
和NV21
的互換
NV21
和NV12
只是U與V的資料位置不同,因此,NV21
轉換為NV12
的程式碼同樣適用於NV12
轉換為NV21
。可參考如下程式碼:
public static byte[] nv21ToNv12(byte[] nv21, int width, int height) {
if (nv21 == null || nv21.length == 0 || width * height * 3 / 2 != nv21.length) {
throw new IllegalArgumentException("invalid image params!");
}
final int ySize = width * height;
int totalSize = width * height * 3 / 2;
byte[] nv12 = new byte[nv21.length];
//複製Y
System.arraycopy(nv21, 0, nv12, 0, ySize);
//UV互換
for (int uvIndex = ySize; uvIndex < totalSize; uvIndex += 2) {
nv12[uvIndex] = nv21[uvIndex + 1];
nv12[uvIndex + 1] = nv21[uvIndex];
}
return nv12;
}
複製程式碼
6. NV21
轉YV12
NV21
轉化為YV12
的過程主要是將其UV資料的交叉排序修改為連續排序。可參考如下程式碼:
public static byte[] nv21ToYv12(byte[] nv21, int width, int height) {
if (nv21 == null || nv21.length == 0 || width * height * 3 / 2 != nv21.length) {
throw new IllegalArgumentException("invalid image params!");
}
final int ySize = width * height;
int totalSize = width * height * 3 / 2;
byte[] yv12 = new byte[nv21.length];
int yv12UIndex = ySize;
int yv12VIndex = ySize * 5 / 4;
//複製Y
System.arraycopy(nv21, 0, yv12, 0, ySize);
//複製UV
for (int uvIndex = ySize; uvIndex < totalSize; uvIndex += 2) {
yv12[yv12UIndex++] = nv21[uvIndex];
yv12[yv12VIndex++] = nv21[uvIndex + 1];
}
return yv12;
}
複製程式碼
7. YUYV
轉NV12
在YUYV
格式中,兩個Y
共用一組U
和V
,而NV12
是四個Y
共用一組U
和V
,因此,這是一個YUV422
轉YUV420
的過程,需要捨棄一半的U
和V
。可參考如下程式碼:
public static byte[] yuyvToNv12(byte[] yuyv, int width, int height) {
if (yuyv == null || yuyv.length == 0) {
throw new IllegalArgumentException("invalid image params!");
}
int ySize = yuyv.length / 2;
byte[] nv12 = new byte[yuyv.length * 3 / 4];
int nv12YIndex = 0;
int nv12UVIndex = ySize;
boolean copyUV = false;
int lineDataSize = width * 2;
for (int i = 0, yuyvIndex = 0; i < height; i++, yuyvIndex += lineDataSize) {
if (copyUV) {
for (int lineOffset = 0; lineOffset < lineDataSize; lineOffset += 4) {
//複製Y
nv12[nv12YIndex++] = yuyv[yuyvIndex + lineOffset];
nv12[nv12YIndex++] = yuyv[yuyvIndex + lineOffset + 2];
//複製UV
nv12[nv12UVIndex++] = yuyv[yuyvIndex + lineOffset + 1];
nv12[nv12UVIndex++] = yuyv[yuyvIndex + lineOffset + 3];
}
} else {
for (int lineOffset = 0; lineOffset < lineDataSize; lineOffset += 4) {
//複製Y
nv12[nv12YIndex++] = yuyv[yuyvIndex + lineOffset];
nv12[nv12YIndex++] = yuyv[yuyvIndex + lineOffset + 2];
}
}
copyUV = !copyUV;
}
return nv12;
}
複製程式碼
8. I420
和YV12
的互換
I420
和YV12
只是U
與V
的資料位置不同,因此,I420
轉換為YV12
的程式碼同樣適用於YV12
轉換為I420
。可參考如下程式碼:
public static byte[] i420ToYv12(byte[] i420) {
if (i420 == null || i420.length == 0 || i420.length % 6 != 0) {
throw new IllegalArgumentException("invalid image params!");
}
int ySize = i420.length * 2 / 3;
int uvSize = i420.length / 6;
byte[] yv12 = new byte[i420.length];
//複製Y
System.arraycopy(i420, 0, yv12, 0, ySize);
//UV互換
System.arraycopy(i420, ySize, yv12, ySize + uvSize, uvSize);
System.arraycopy(i420, ySize + uvSize, yv12, ySize, uvSize);
return yv12;
}
複製程式碼
9. I420
轉換為YUYV
I420
和YUYV
相比,I420
的U
和V
只有YUYV
的一半,這是一個YUV420
轉YUV422
的過程,缺損的資料只能通過複用U
和V
彌補。
public static byte[] i420ToYuyv(byte[] i420, int width, int height) {
if (i420 == null || i420.length == 0 || i420.length != width * height * 3 / 2) {
throw new IllegalArgumentException("invalid image params!");
}
byte[] yuyv = new byte[width * height * 2];
int yuyvLineSize = width * 2;
int i420YIndex = 0;
int i420UIndex = width * height;
int i420VIndex = width * height * 5 / 4;
int yuyvLineStart = 0;
for (int i = 0; i < height; i += 2) {
for (int lineOffset = 0; lineOffset < yuyvLineSize; lineOffset += 4) {
byte u = i420[i420UIndex++];
byte v = i420[i420VIndex++];
//偶數行資料賦值
int yuyvOffset = yuyvLineStart + lineOffset;
yuyv[yuyvOffset] = i420[i420YIndex];
yuyv[yuyvOffset + 1] = u;
yuyv[yuyvOffset + 2] = i420[i420YIndex + 1];
yuyv[yuyvOffset + 3] = v;
//奇數行資料賦值
int yuyvNextLineOffset = yuyvLineStart + yuyvLineSize + lineOffset;
yuyv[yuyvNextLineOffset] = i420[i420YIndex + width];
yuyv[yuyvNextLineOffset + 1] = u;
yuyv[yuyvNextLineOffset + 2] = i420[i420YIndex + width + 1];
yuyv[yuyvNextLineOffset + 3] = v;
i420YIndex += 2;
}
i420YIndex += width;
yuyvLineStart += (width << 2);
}
return yuyv;
}
複製程式碼
四、影象裁剪
與格式轉換相同,只要瞭解的影象的排列方式,影象的裁剪也並不困難。本文提供一種RGB
顏色空間的影象裁剪和一種YUV
顏色空間的影象裁剪。
1. 裁剪NV21
public static byte[] i420ToYv12(byte[] i420) {
if (i420 == null || i420.length == 0 || i420.length % 6 != 0) {
throw new IllegalArgumentException("invalid image params!");
}
int ySize = i420.length * 2 / 3;
int uvSize = i420.length / 6;
byte[] yv12 = new byte[i420.length];
//複製Y
System.arraycopy(i420, 0, yv12, 0, ySize);
//UV互換
System.arraycopy(i420, ySize, yv12, ySize + uvSize, uvSize);
System.arraycopy(i420, ySize + uvSize, yv12, ySize, uvSize);
return yv12;
}
複製程式碼
9. I420
轉換為YUYV
I420
和YUYV
相比,I420
的U
和V
只有YUYV
的一半,這是一個YUV420
轉YUV422
的過程,缺損的資料只能通過複用U
和V
彌補。
public static byte[] i420ToYuyv(byte[] i420, int width, int height) {
if (i420 == null || i420.length == 0 || i420.length != width * height * 3 / 2) {
throw new IllegalArgumentException("invalid image params!");
}
byte[] yuyv = new byte[width * height * 2];
int yuyvLineSize = width * 2;
int i420YIndex = 0;
int i420UIndex = width * height;
int i420VIndex = width * height * 5 / 4;
int yuyvLineStart = 0;
for (int i = 0; i < height; i += 2) {
for (int lineOffset = 0; lineOffset < yuyvLineSize; lineOffset += 4) {
byte u = i420[i420UIndex++];
byte v = i420[i420VIndex++];
//偶數行資料賦值
int yuyvOffset = yuyvLineStart + lineOffset;
yuyv[yuyvOffset] = i420[i420YIndex];
yuyv[yuyvOffset + 1] = u;
yuyv[yuyvOffset + 2] = i420[i420YIndex + 1];
yuyv[yuyvOffset + 3] = v;
//奇數行資料賦值
int yuyvNextLineOffset = yuyvLineStart + yuyvLineSize + lineOffset;
yuyv[yuyvNextLineOffset] = i420[i420YIndex + width];
yuyv[yuyvNextLineOffset + 1] = u;
yuyv[yuyvNextLineOffset + 2] = i420[i420YIndex + width + 1];
yuyv[yuyvNextLineOffset + 3] = v;
i420YIndex += 2;
}
i420YIndex += width;
yuyvLineStart += (width << 2);
}
return yuyv;
}
複製程式碼
四、影象裁剪
與格式轉換相同,只要瞭解的影象的排列方式,影象的裁剪也並不困難。本文提供一種RGB
顏色空間的影象裁剪和一種YUV
顏色空間的影象裁剪。
1. 裁剪NV21或NV12
public static byte[] cropYuv420sp(byte[] yuv420sp, int width, int height, int left, int top, int right, int bottom) {
if (yuv420sp == null || yuv420sp.length == 0 || width * height * 3 / 2 != yuv420sp.length) {
throw new IllegalArgumentException("invalid image params!");
}
if (left < 0 || top < 0 || right > width || bottom > height) {
throw new IllegalArgumentException("rect out of bounds!");
}
if (right < left || bottom < top) {
throw new IllegalArgumentException("invalid rect!");
}
if (((right - left) & 1) == 1 || ((bottom - top) & 1) == 1) {
throw new IllegalArgumentException("yuv420sp width and height must be even!");
}
if ((left & 1 )== 1){
throw new IllegalArgumentException("yuv420sp crop left borderIndex and right borderIndex must be even!");
}
int cropImageWidth = right - left;
int cropImageHeight = bottom - top;
byte[] cropYuv420sp = new byte[cropImageWidth * cropImageHeight * 3 / 2];
//複製Y
int originalYLineStart = top * width;
int targetYIndex = 0;
//複製UV
int originalUVLineStart = width * height + top * width / 2;
int targetUVIndex = cropImageWidth * cropImageHeight;
for (int i = top; i < bottom; i++) {
System.arraycopy(yuv420sp, originalYLineStart + left, cropYuv420sp, targetYIndex, cropImageWidth);
originalYLineStart += width;
targetYIndex += cropImageWidth;
if ((i & 1) == 0) {
System.arraycopy(yuv420sp, originalUVLineStart + left, cropYuv420sp, targetUVIndex, cropImageWidth);
originalUVLineStart += width;
targetUVIndex += cropImageWidth;
}
}
return cropYuv420sp;
}
複製程式碼
2. 裁剪BGR24
public static byte[] cropBgr24(byte[] bgr24, int width, int height, int left, int top, int right, int bottom) {
if (bgr24 == null || bgr24.length == 0 || width * height * 3 != bgr24.length) {
throw new IllegalArgumentException("invalid image params!");
}
if (left < 0 || top < 0 || right > width || bottom > height) {
throw new IllegalArgumentException("rect out of bounds!");
}
if (right < left || bottom < top) {
throw new IllegalArgumentException("invalid rect!");
}
int cropImageWidth = right - left;
int cropImageHeight = bottom - top;
byte[] cropBgr24 = new byte[cropImageWidth * cropImageHeight * 3];
int originalLineStart = top * width * 3;
int targetIndex = 0;
for (int i = top; i < bottom; i++) {
System.arraycopy(bgr24, originalLineStart + left * 3, cropBgr24, targetIndex, cropImageWidth * 3);
originalLineStart += width * 3;
targetIndex += cropImageWidth * 3;
}
return cropBgr24;
}
複製程式碼