CV 入門 3

裁切/拼貼你的第一張圖片

  • numpy
  • opencv
  • matplotlib
  • 影像處理

CV 入門 2: 了解你的第一張圖片

1. 取出圖片中的蘋果

1.1. 用 numpy 裁切圖片

對 np.ndarray 操作, 除了要記得是 HWC 順序 以外, 還有影像的 座標系定義

影像的座標原點 (0,0) 是在左上角, x 往右為正, y 往下為正

以下是把蘋果區域 ( x: 415 ~ 935, y: 360 ~ 880 ) 裁切下來的步驟。

影像的座標系原點 (0, 0)(x1, y1)=(415, 360)(x2, y2)=(935, 880)第 2 維 x · 直行 column · Width第 1 維 y橫列 row Height蘋果區域
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()

1.1結果

  • 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 → 獨立的一份

1.2結果

只設定 apple_without_copy 變全黑, 原圖的那塊區域卻一起被改掉了!

  • 裁切拿到的不是新的一份apple_without_copyimage 指向同一塊記憶體, 改其中一個, 所有指向這塊記憶體的變數都會跟著變
  • 要修改前先 .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()

2.1結果

畫面上出現第二顆太陽, 但是有一個明顯的矩形接縫,
因為原本那塊區域裡的天空也一起被搬過來了, 所以跟新位置的背景接不起來。

  • 讀和寫是同一個語法, 差別是放在等號的不同邊
    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()

2.2結果

遮罩是怎麼做出來的

  • step1 == sky_color — 每個位置的通道數值逐一比對 step1sky_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()

2.2 final 結果

試試看:把取出來的 apple 區域背景設定成白色, 背景橫跨天空和草地, 蘋果包含紅色、褐色的梗、綠色的葉子和地上的陰影, 會比太陽麻煩得多。

筆記摘要

這篇主要有三個重點:

  1. 影像座標系定義 — 原點在左上角, x 往右、y 往下; numpy 的順序是 [y, x], cv2 是 (x, y), 兩者相反。
  2. 記憶體共享 — 裁切拿到的 np.ndarray 跟原圖共用同一塊記憶體, 改了數值原圖也會跟著變; 要使用 .copy() 才會有一份獨立記憶體。
  3. 遮罩的使用 — 用遮罩可以更改 np.ndarray 符合條件的數值;
    條件可以正向表述 (要換掉的數值), 也可以負向表述 (要保留的數值, 再用 ~ 反過來)