업무 자동화 ② 사진 (2) — 폴더째 돌리고 워터마크를 찍는다
Article
앞 강의에서 사진 한 장을 열고 줄였다. 백 장도 똑같다. 그 세 줄을 반복문에 넣으면 끝난다.
파일은 계속 ai-course 폴더에 넣는다. 이번 강의는 81번부터다. 앞 강의에서 만든 사진 폴더가 그대로 있어야 한다.
1. 폴더째 일괄 처리 — 여기가 이번 강의의 전부
앞 강의의 세 줄을 반복문에 넣기만 하면 된다.
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. 워터마크를 찍는다
사진 위에 글자를 얹는다. 아래쪽에 검은 띠를 깔고 그 위에 글자를 쓴다.
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/사진_완성 폴더를 열어 확인한다. 아래쪽에 검은 띠와 글자가 찍혀 있다.
- 01
왼쪽 위가 (0, 0) 이다
수학 시간의 그래프와 다르다. 오른쪽으로 갈수록 x 가 커지고, 아래로 갈수록 y 가 커진다.
- 02
img.height - 30 은 「아래에서 30픽셀 위」
사진마다 높이가 다르므로 숫자를 박지 않고 계산한다. 세로 사진이든 가로 사진이든 항상 아래쪽에 붙는다.
- 03
rectangle 은 (왼쪽, 위, 오른쪽, 아래)
네 숫자가 사각형의 두 모서리다. 시작점과 크기가 아니라 두 점이라는 데 주의한다.
- 04
text 는 (x, y) 두 개뿐
글자가 시작할 왼쪽 위 지점이다. 띠보다 8픽셀 안쪽에 넣어야 글자가 띠 안에 들어온다.
3. 함수로 묶는다
엑셀 때와 같다. 폴더 이름과 크기를 인자로 뺀다.
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 ...) | 폴더 전체 용량을 더한다 |
스스로 해보기
- 01
크기를 400 · 1200 으로 바꿔본다
ai-course/84_연습1.py. 숫자 하나만 바꾸면 된다. 용량이 어떻게 달라지나. 가로가 3배 커지면 용량은 몇 배가 되나. - 02
워터마크를 오른쪽 아래로 옮긴다
ai-course/85_연습2.py. 지금은 왼쪽에 붙어 있다. 오른쪽 끝에서 안쪽으로 들어오게 하려면 x 좌표를 어떻게 계산해야 하나. 힌트는img.width에서 빼는 것이다. - 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 을 따로 세지 않았다면 여기서 로그가 거짓말을 한다.
정리하면
앞 강의의 세 줄을 반복문에 넣은 것이 전부다. 폴더를 훑고, 하나씩 열고, 바꿔서, 저장한다 — 엑셀 두 편과 완전히 같은 틀이다.
그리고 두 가지를 얻었다. 원본을 건드리지 않는 습관과, 건너뛴 것을 숫자에 반영하는 습관이다. 둘 다 사진에만 해당하는 이야기가 아니다.
여기까지가 「파일을 다루는 일」이다. 다음 강의부터는 파일 안의 내용으로 돌아간다 — 문서를 조각내는 이야기를 마저 한다.