학습 자료

업무 자동화 ② 사진 (2) — 폴더째 돌리고 워터마크를 찍는다


Article

앞 강의에서 사진 한 장을 열고 줄였다. 백 장도 똑같다. 그 세 줄을 반복문에 넣으면 끝난다.

파일은 계속 ai-course 폴더에 넣는다. 이번 강의는 81번부터다. 앞 강의에서 만든 사진 폴더가 그대로 있어야 한다.

1. 폴더째 일괄 처리 — 여기가 이번 강의의 전부

앞 강의의 세 줄을 반복문에 넣기만 하면 된다.

ai-course/81_일괄.py
import os
import glob
from PIL import Image

os.makedirs("사진_작게", exist_ok=True)

for path in sorted(glob.glob("사진/*.jpg")):
    img = Image.open(path)                                         # 연다
    img.thumbnail((800, 800))                                      # 바꾼다
    img.save(f"사진_작게/{os.path.basename(path)}", quality=85)      # 저장한다

# 얼마나 줄었나 재본다
before = sum(os.path.getsize(p) for p in glob.glob("사진/*.jpg"))
after  = sum(os.path.getsize(p) for p in glob.glob("사진_작게/*.jpg"))

print(f"{before/1024:.0f} KB -> {after/1024:.0f} KB")
print(f"{(1 - after/before) * 100:.0f}% 줄어듦")
print(Image.open("사진_작게/행사_001.jpg").size)

# 원본은 그대로인지 확인한다. 이게 습관이 되어야 한다
print(f"원본 {before/1024:.0f} KB", Image.open("사진/행사_001.jpg").size)
터미널
  • 352 KB -> 34 KB
  • 90% 줄어듦
  • (800, 533)
  • 원본 352 KB (3000, 2000)

사진 3장에 눈 깜짝할 새다. 100장이면 5초 안팎이다. 그리고 마지막 줄이 중요하다 — 원본 폴더는 손대지 않았다.

2. 워터마크를 찍는다

사진 위에 글자를 얹는다. 아래쪽에 검은 띠를 깔고 그 위에 글자를 쓴다.

ai-course/82_워터마크.py
import os
import glob
from PIL import Image, ImageDraw

os.makedirs("사진_완성", exist_ok=True)

for path in sorted(glob.glob("사진/*.jpg")):
    img = Image.open(path)
    img.thumbnail((800, 800))

    draw = ImageDraw.Draw(img)          # 이 사진 위에 그릴 붓

    # 아래쪽 30픽셀에 검은 띠. (왼쪽, 위, 오른쪽, 아래) 순서다
    draw.rectangle((0, img.height - 30, img.width, img.height), fill=(0, 0, 0))

    # 그 위에 흰 글자. (x, y) 는 글자가 시작할 왼쪽 위 지점
    draw.text((10, img.height - 22), "(C) DCODELAB 2026", fill=(255, 255, 255))

    img.save(f"사진_완성/{os.path.basename(path)}", quality=85)

총합 = sum(os.path.getsize(p) for p in glob.glob("사진_완성/*.jpg"))
print(f"{총합/1024:.1f} KB")
터미널
  • 41.8 KB

ai-course/사진_완성 폴더를 열어 확인한다. 아래쪽에 검은 띠와 글자가 찍혀 있다.

좌표를 읽는 법
  1. 01

    왼쪽 위가 (0, 0) 이다

    수학 시간의 그래프와 다르다. 오른쪽으로 갈수록 x 가 커지고, 아래로 갈수록 y 가 커진다.

  2. 02

    img.height - 30 은 「아래에서 30픽셀 위」

    사진마다 높이가 다르므로 숫자를 박지 않고 계산한다. 세로 사진이든 가로 사진이든 항상 아래쪽에 붙는다.

  3. 03

    rectangle 은 (왼쪽, 위, 오른쪽, 아래)

    네 숫자가 사각형의 두 모서리다. 시작점과 크기가 아니라 두 점이라는 데 주의한다.

  4. 04

    text 는 (x, y) 두 개뿐

    글자가 시작할 왼쪽 위 지점이다. 띠보다 8픽셀 안쪽에 넣어야 글자가 띠 안에 들어온다.

3. 함수로 묶는다

엑셀 때와 같다. 폴더 이름과 크기를 인자로 뺀다.

ai-course/83_함수.py
import os
import glob
from PIL import Image, ImageDraw


def 사진_일괄(src, dst, size=800, mark=None, quality=85):
    """src 폴더의 사진을 줄여 dst 폴더에 저장한다. mark 를 주면 워터마크를 찍는다."""
    os.makedirs(dst, exist_ok=True)

    paths = sorted(glob.glob(f"{src}/*.jpg")) + sorted(glob.glob(f"{src}/*.png"))
    if not paths:
        print(f"[경고] '{src}' 에서 사진을 못 찾았다.")
        return 0

    before = after = done = 0
    for path in paths:
        try:
            img = Image.open(path)
        except Exception as e:              # 깨진 파일 · 사진이 아닌 파일
            print(f"[건너뜀] {path} - {type(e).__name__}")
            continue

        done += 1                           # 찾은 수가 아니라 처리한 수를 센다
        before += os.path.getsize(path)
        img.thumbnail((size, size))

        if mark:
            draw = ImageDraw.Draw(img)
            draw.rectangle((0, img.height - 30, img.width, img.height), fill=(0, 0, 0))
            draw.text((10, img.height - 22), mark, fill=(255, 255, 255))

        out = f"{dst}/{os.path.basename(path)}"
        img.save(out, quality=quality)
        after += os.path.getsize(out)

    print(f"{done}장 처리 · {before/1024:.0f} KB -> {after/1024:.0f} KB "
          f"({(1 - after/before) * 100:.0f}% 줄어듦)")
    return done


사진_일괄("사진", "사진_완성", size=800, mark="(C) DCODELAB 2026")
사진_일괄("없는폴더", "결과")
터미널
  • 3장 처리 · 352 KB -> 42 KB (88% 줄어듦)
  • [경고] '없는폴더' 에서 사진을 못 찾았다.

이번 강의에 나온 것

정리
쓴 것하는 일
for path in sorted(glob.glob(...)):폴더째 훑는 반복문. 엑셀 때와 같다
os.path.basename(path)경로에서 파일 이름만
ImageDraw.Draw(img)이 사진 위에 그릴 붓
draw.rectangle((왼,위,오른,아래), fill=색)사각형. 두 모서리 점
draw.text((x, y), "글자", fill=색)글자. 왼쪽 위 지점
img.height - 30숫자를 박지 않고 계산한다
except Exception as e여기서만 허용. type(e).__name__ 을 남긴다
sum(os.path.getsize(p) for p in ...)폴더 전체 용량을 더한다

스스로 해보기

확인 문제 셋
  1. 01

    크기를 400 · 1200 으로 바꿔본다

    ai-course/84_연습1.py. 숫자 하나만 바꾸면 된다. 용량이 어떻게 달라지나. 가로가 3배 커지면 용량은 몇 배가 되나.

  2. 02

    워터마크를 오른쪽 아래로 옮긴다

    ai-course/85_연습2.py. 지금은 왼쪽에 붙어 있다. 오른쪽 끝에서 안쪽으로 들어오게 하려면 x 좌표를 어떻게 계산해야 하나. 힌트는 img.width 에서 빼는 것이다.

  3. 03

    사진이 아닌 파일을 폴더에 넣어본다

    ai-course/86_연습3.py. 글자가 든 파일을 가짜.jpg 라는 이름으로 사진 폴더에 넣고 돌려본다. 무슨 일이 생기나. 함수에 넣어둔 except 가 실제로 일하는지 확인하는 문제다.

1번 — 크기별 용량충분히 고민해본 뒤 꼭 필요한 경우에만 열어보세요

넓이로 계산되니 가로가 3배면 용량도 9배일 것 같지만, 실측하면 그렇지 않다.

400px 13KB · 800px 34KB · 1200px 66KB — 400에서 1200으로 3배 키웠는데 용량은 5배다.

for s in [400, 800, 1200]:
    사진_일괄("사진", f"결과_{s}", size=s)

# 결과 폴더들을 나란히 재본다
for s in [400, 800, 1200]:
    kb = sum(os.path.getsize(p) for p in glob.glob(f"결과_{s}/*.jpg")) / 1024
    print(f"{s:5d}px  {kb:6.0f} KB")

이론과 어긋나는 이유는 JPEG 압축이 그림 내용에 따라 달라지기 때문이다. 우리 실습 사진은 하늘·산·해뿐이라 단순해서 압축이 잘 먹는다. 사람이 많은 실제 사진이라면 9배에 더 가까워진다.

숫자를 외우지 말고 자기 사진으로 재본다. 그게 이 문제의 진짜 목적이다.

게시판에 올릴 사진이면 800px 이면 충분하다.

2번 — 워터마크를 오른쪽 아래로충분히 고민해본 뒤 꼭 필요한 경우에만 열어보세요

글자가 차지할 가로 길이를 알아야 오른쪽 끝에서 얼마나 뺄지 정할 수 있다.

draw.textlength(글자) 가 그 길이를 준다. 거기에 여백 10픽셀을 더 빼면 오른쪽에서 살짝 안쪽에 붙는다.

text = "(C) DCODELAB 2026"

img = Image.open(sorted(glob.glob("사진/*.jpg"))[0])
img.thumbnail((800, 800))

draw = ImageDraw.Draw(img)
draw.rectangle((0, img.height - 30, img.width, img.height), fill=(0, 0, 0))

w = draw.textlength(text)              # 글자가 차지하는 가로 길이
x = img.width - w - 10                 # 오른쪽 끝에서 글자 길이만큼, 여백 10 더
draw.text((x, img.height - 22), text, fill=(255, 255, 255))

img.save("오른쪽_워터마크.jpg", quality=85)

print(img.width)                       # 800
print(w)                               # 94.0
print(x)                               # 696.0
print((img.width - w) / 2)             # 353.0 - 가운데 정렬이면 이 값

숫자를 박지 않고 계산하는 게 핵심이다. x = 696 이라고 적으면 사진 크기가 바뀌는 순간 어긋난다.

저장한 오른쪽_워터마크.jpg 를 열어 눈으로도 확인한다. 숫자만 보고 넘어가면 위아래가 뒤집혔어도 모른다.

3번 — 사진이 아닌 파일을 넣으면충분히 고민해본 뒤 꼭 필요한 경우에만 열어보세요

Image.open`UnidentifiedImageError` 를 낸다. 「이건 내가 아는 사진 형식이 아니다」라는 뜻이다.

함수에 except Exception 이 있어서 그 파일만 건너뛰고 나머지는 정상 처리된다. 없었다면 여기서 전체가 멈춘다.

# 가짜 사진을 하나 만든다
with open("사진/가짜.jpg", "w", encoding="utf-8") as f:
    f.write("이건 사실 글자다")

사진_일괄("사진", "결과_테스트")

os.remove("사진/가짜.jpg")             # 확인했으면 지운다

[건너뜀] 사진\가짜.jpg - UnidentifiedImageError 가 찍히고 나머지 3장은 처리된다.

확장자는 이름표일 뿐 내용을 보장하지 않는다. .jpg 라고 붙어 있어도 안이 사진이 아닐 수 있고, 그래서 열어보기 전에는 모른다.

찍히는 숫자가 4장 처리 가 아니라 `3장 처리` 인 것을 확인한다. done 을 따로 세지 않았다면 여기서 로그가 거짓말을 한다.

정리하면

앞 강의의 세 줄을 반복문에 넣은 것이 전부다. 폴더를 훑고, 하나씩 열고, 바꿔서, 저장한다 — 엑셀 두 편과 완전히 같은 틀이다.

그리고 두 가지를 얻었다. 원본을 건드리지 않는 습관과, 건너뛴 것을 숫자에 반영하는 습관이다. 둘 다 사진에만 해당하는 이야기가 아니다.

여기까지가 「파일을 다루는 일」이다. 다음 강의부터는 파일 안의 내용으로 돌아간다 — 문서를 조각내는 이야기를 마저 한다.