CV 入門 3
裁切/拼貼你的第一張圖片
1. 取出圖片中的蘋果
1.1. 用 numpy 裁切圖片
對 np.ndarray 操作, 除了要記得是 HWC 順序 以外, 還有影像的 座標系定義:
影像的座標原點 (0,0) 是在左上角, x 往右為正, y 往下為正
以下是把蘋果區域 ( x: 415 ~ 935, y: 360 ~ 880 ) 裁切下來的步驟。
import cv2
import numpy as np
import matplotlib.pyplot as plt
image = cv2.imread("/content/red_apple_on_grass.png")[:,:,::-1].copy()
x1, y1 = 415, 360
x2, y2 = 935, 880
apple = image[y1:y2, x1:x2, :].copy()
image_with_box = image.copy()
cv2.rectangle(image_with_box, (x1,y1), (x2,y2), color=(0,0,0), thickness=2)
fig, axes = plt.subplots(1, 2, figsize=(15, 8),
gridspec_kw={"width_ratios": [apple.shape[1], image_with_box.shape[1]]})
axes[0].imshow(apple)
axes[0].set_title("numpy: image[y1:y2, x1:x2]")
axes[1].imshow(image_with_box)
axes[1].set_title("cv2.rectangle: (x1, y1), (x2, y2)")
plt.show()

- numpy 接收
[y, x]— 順序跟shape一致(H, W, C) cv2.rectangle接收(x, y)— 第二、三個參數是長方形的左上角和右下角座標width_ratios設定兩張圖顯示的真實比例 — 否則imshow會把每張圖各自放大到塞滿自己的 axes
numpy 和 cv2 接收座標的順序是相反的, 這是最容易搞混的地方。
1.2. 為什麼需要 .copy()
1.1 在很多 numpy 陣列後面都加了 .copy(), 這是什麼原因呢?
下面刻意不加 .copy(), 並把蘋果區域的數值全部設為 0 (畫面會變全黑), 觀察結果變化。
import cv2
import numpy as np
import matplotlib.pyplot as plt
image = cv2.imread("/content/red_apple_on_grass.png")[:,:,::-1].copy()
x1, y1 = 415, 360
x2, y2 = 935, 880
apple_without_copy = image[y1:y2, x1:x2, :]
apple_without_copy[:] = 0
fig, axes = plt.subplots(1, 2, figsize=(15, 8),
gridspec_kw={"width_ratios": [apple_without_copy.shape[1], image.shape[1]]})
axes[0].imshow(apple_without_copy)
axes[0].set_title("apple_without_copy[:] = 0")
axes[1].imshow(image)
axes[1].set_title("image changed too (shares memory)")
plt.show()
print(apple_without_copy.base is image) # True → 共用記憶體
apple = image[y1:y2, x1:x2, :].copy()
print(apple.base is image) # False → 獨立的一份

只設定 apple_without_copy 變全黑, 原圖的那塊區域卻一起被改掉了!
- 裁切拿到的不是新的一份 —
apple_without_copy跟image指向同一塊記憶體, 改其中一個, 所有指向這塊記憶體的變數都會跟著變 - 要修改前先
.copy()— 複製一份獨立的記憶體給apple用, 之後怎麼改都跟image無關 .base is image可以檢查 —True表示共用記憶體;False表示apple是.copy()出來的獨立一份記憶體
一樣沒有任何警告 — 程式正常跑完, 可以透過視覺化看出原圖一起被更動了
2. 拼貼太陽到圖片的其他位置
裁切是把畫素讀出來, 貼上就是把畫素寫進去。
2.1. 用 numpy 拼貼影像
import cv2
import numpy as np
import matplotlib.pyplot as plt
image = cv2.imread("/content/red_apple_on_grass.png")[:,:,::-1].copy()
sun_x1, sun_y1 = 1000, 60
sun_x2, sun_y2 = 1240, 290
sun = image[sun_y1:sun_y2, sun_x1:sun_x2, :].copy()
px, py = 80, 700
canvas = image.copy()
canvas[py:py+sun.shape[0], px:px+sun.shape[1]] = sun
plt.figure(figsize=(10, 7))
plt.imshow(canvas)
plt.show()

畫面上出現第二顆太陽, 但是有一個明顯的矩形接縫,
因為原本那塊區域裡的天空也一起被搬過來了, 所以跟新位置的背景接不起來。
- 讀和寫是同一個語法, 差別是放在等號的不同邊 —
sun = image[...]是讀取,canvas[...] = sun是寫入 - 貼上的範圍是矩形 —
[py:py+sun.shape[0], px:px+sun.shape[1]]
以(px, py)為左上角,py加上高度sun.shape[0]、px加上寬度sun.shape[1]就是結束位置, 這樣範圍就會跟sun一樣大。 =是直接覆寫 — 換成+=、-=會得到不同的效果;
但 uint8 做加減時, 數值可能 overflow / underflow (入門 2 有提過)
2.2. 將天空顏色替換成草地顏色
可以先在 sun 這塊矩形上動手腳 — 把天空換成草地, 之後貼到草地就不會有接縫。
import cv2
import numpy as np
import matplotlib.pyplot as plt
image = cv2.imread("/content/red_apple_on_grass.png")[:,:,::-1].copy()
sun_x1, sun_y1 = 1000, 60
sun_x2, sun_y2 = 1240, 290
sun = image[sun_y1:sun_y2, sun_x1:sun_x2, :].copy()
px, py = 80, 700
grass_color = image[py, px].copy() # 草地
sky_color = sun[0, 0].copy() # 天空, 取裁切的左上角
sun_color = sun[sun.shape[0] // 2, sun.shape[1] // 2].copy() # 太陽, 取裁切的正中央
# 第一步: 只把「精確等於天空色」的畫素換成草地色
step1 = sun.copy()
mask_sky = (step1 == sky_color).all(axis=-1)
step1[mask_sky] = grass_color
# 第二步: 改問「哪些不是太陽」, 其餘通通換成草地色
step2 = step1.copy()
mask_not_sun = ~(step2 == sun_color).all(axis=-1)
step2[mask_not_sun] = grass_color
fig, axes = plt.subplots(1, 3, figsize=(15, 5))
for ax, im, title in zip(axes, [sun, step1, step2],
["sun crop", "sky → grass", "everything but sun → grass"]):
ax.imshow(im)
ax.set_title(title)
ax.axis("off")
plt.show()

遮罩是怎麼做出來的
step1 == sky_color— 每個位置的通道數值逐一比對step1和sky_color, 得到的形狀還是(H, W, C)的 True / False.all(axis=-1)—axis=-1就是 HWC 的最後一項 Channel。三個通道都是 True 才算 True, 把最後一軸收掉之後, 結果才是(H, W)的遮罩step1[mask_sky] = grass_color— 只有遮罩為 True 的位置會被寫入草地色
為什麼第一步做完, 太陽外圈留下一圈藍色光暈
- sun crop 裡面並不是只有兩種顏色 — 太陽和天空的交界還有一圈淡藍色的過渡畫素, 看起來像天空, 但數值並不完全相同, 所以
(step1 == sky_color).all(axis=-1)在那些位置就是 False - 與其列舉所有要換掉的顏色, 不如描述要保留的東西 — 條件改成「不是太陽色的, 通通換成草地色」
~(step2 == sun_color).all(axis=-1)— 先找出跟太陽同色的位置, 再用~逐元素取 NOT, 把 True / False 整個翻過來
# 接續上續程式碼後, 可得到最終結果
canvas = image.copy()
canvas[py:py+step2.shape[0], px:px+step2.shape[1]] = step2
plt.figure(figsize=(10, 7))
plt.imshow(canvas)
plt.show()

試試看:把取出來的 apple 區域背景設定成白色, 背景橫跨天空和草地, 蘋果包含紅色、褐色的梗、綠色的葉子和地上的陰影, 會比太陽麻煩得多。
筆記摘要
這篇主要有三個重點:
- 影像座標系定義 — 原點在左上角, x 往右、y 往下; numpy 的順序是
[y, x], cv2 是(x, y), 兩者相反。 - 記憶體共享 — 裁切拿到的
np.ndarray跟原圖共用同一塊記憶體, 改了數值原圖也會跟著變; 要使用.copy()才會有一份獨立記憶體。 - 遮罩的使用 — 用遮罩可以更改
np.ndarray符合條件的數值;
條件可以正向表述 (要換掉的數值), 也可以負向表述 (要保留的數值, 再用~反過來)