dev.to

PNG admits when it is damaged and JPEG does not

PNG는 손상을 드러내지만 JPEG는 숨깁니다

512×512 이미지의 PNG, JPEG 등 6개 형식에서 비트 하나를 바꾸고 디코딩 결과를 비교했습니다. PNG는 처리 도구가 손상을 감지해 거부했지만 Chromium은 손상 전까지의 픽셀을 그렸고, JPEG·WebP·AVIF는 손상된 이미지를 온전한 것처럼 보여주기도 했습니다.

AI 요약

저장 장치 오류나 업로드 중단으로 이미지 파일의 비트 하나가 바뀌면 어떤 일이 생길까요? 글쓴이는 같은 512×512 이미지를 PNG, JPEG, GIF, WebP, AVIF, BMP로 저장한 뒤 파일마다 비트 하나를 바꿨습니다. 형식별로 400회씩 시험했고, 브라우저가 화면에 그린 영역과 원본 픽셀의 일치율도 확인했습니다. 결과는 단순히 어떤 형식이 더 튼튼한지로 나뉘지 않았습니다. 손상을 거부하는 형식과 손상 사실을 알리지 않는 형식이 서로 반대되는 방식으로 실패했습니다.

PNG는 손상을 감지하지만 브라우저는 일부를 그립니다

PNG 파일의 비트를 바꾼 400회 실험에서 Pillow는 매번 디코딩에 실패했습니다. 글쓴이는 Pillow만 엄격하게 검사하는지 확인하려고 OpenCV에서도 같은 파일을 처리했습니다. OpenCV가 사용하는 libpng 역시 시험한 120개 파일을 모두 거부했습니다. 오류 메시지에는 IDAT 청크의 CRC 오류, 잘못된 적응형 필터 값, DEFLATE 비트 길이 오류가 나타났습니다.

PNG는 각 청크에 CRC32 체크섬을 저장해 데이터 손상을 감지합니다. 이미지 데이터는 하나의 DEFLATE 스트림으로 압축하므로, 체크섬 검사를 지나친 비트 오류도 압축 해제 흐름을 어긋나게 만들고 뒤쪽 데이터를 망가뜨릴 수 있습니다. 디코더가 파일을 거부하는 이유입니다.

하지만 같은 파일을 Chromium에서 열자 시험한 10개가 모두 화면에 나타났습니다. 글쓴이는 이미지의 naturalWidth가 0보다 큰지만 확인하는 대신 canvas에 그린 뒤 픽셀을 원본과 대조했습니다. 그 결과 브라우저는 이미지 중앙값 기준 48.6%를 그렸고, 그려진 픽셀은 모두 원본과 일치했습니다. 손상 지점까지는 정상적으로 디코딩하고, 그 뒤 영역은 비워둔 셈입니다. 따라서 브라우저에서 일부가 보인다는 사실만으로 파일 전체가 정상이라고 판단하면 안 됩니다.

손상된 이미지를 멀쩡한 것처럼 보여주는 형식

브라우저에서 측정한 결과는 다음과 같습니다.

  • PNG: 브라우저 거부 0/10, 그린 영역 중앙값 48.6%, 원본과 일치한 픽셀 48.6%입니다.
  • JPEG: 거부 0/10, 그린 영역 100%, 일치율 3.6%입니다.
  • WebP: 거부 0/10, 그린 영역 100%, 일치율 2.3%입니다.
  • GIF: 거부 0/10, 그린 영역 100%, 일치율 11.8%입니다.
  • AVIF: 거부 1/10, 그린 영역 중앙값 100%, 일치율 2.3%입니다.
  • BMP: 거부 0/10, 그린 영역 100%, 일치율 100%입니다.

JPEG는 이미지 전체를 그렸지만 픽셀의 96% 이상이 원본과 달랐습니다. 손상이 DC 계수에 닿으면 디코더가 그 오류를 스캔의 나머지 부분까지 이어갈 수 있습니다. 결과물은 화면 전체에 색조가 조금 달라진 이미지처럼 보여 손상 여부를 알아차리기 어렵습니다. PNG는 일부만 그린 뒤 멈추지만, JPEG는 틀린 이미지를 완성된 것처럼 내놓습니다. WebP와 AVIF도 높은 그리기 비율과 낮은 원본 일치율을 보였습니다.

BMP 결과는 실험 오류가 아닙니다. BMP는 압축이나 엔트로피 코딩 없이 픽셀을 저장하므로 비트 하나가 바뀌어도 영향을 받는 범위가 작습니다. 512×512 이미지의 픽셀은 262,144개입니다. 이 가운데 색상 채널 하나가 바뀌는 정도라 400회 실험의 중앙값에서는 손상이 비율로 드러나지 않았습니다. 글쓴이는 오류 검출 기능이 없는 BMP가 오히려 오류를 주변으로 퍼뜨리지 않아 손상에 점진적으로 대응한다고 설명합니다. 압축이 오류 하나를 파일 전체의 문제로 키울 수 있다는 뜻입니다.

이미지 파이프라인과 검증 방법

이미지를 나중에 처리하거나 원본이 중요하다면 이미지와 함께 해시를 저장하라고 권합니다. PNG에는 체크섬이 있지만 JPEG, WebP, AVIF는 데이터가 바뀌어도 디코더가 별다른 경고 없이 이미지를 내놓을 수 있기 때문입니다. 이미지가 파이프라인에 들어오는 시점에 디코딩하고, 실패를 무시하지 말고 오류로 처리하는 방법도 제안합니다. 디코딩이 성공했다고 해서 저장했던 바이트가 그대로라는 뜻은 아닙니다.

실험 과정에서는 브라우저 검사법도 수정했습니다. 처음에는 naturalWidth가 0보다 큰지를 기준으로 렌더링 여부를 판정했습니다. 그 기준에서는 손상된 PNG가 모두 정상적으로 그려진 것처럼 보였습니다. canvas에 이미지를 그린 뒤 픽셀을 비교하고 나서야 브라우저가 디코딩을 시작했을 뿐, 이미지 전체를 그리지는 않았다는 점을 확인했습니다. PNG가 브라우저에서 손상에 영향을 받지 않는다고 잘못 기록할 뻔한 대목입니다.

글쓴이는 실험용 앱을 DigitalOcean App Platform에 공개 Git 저장소에서 배포했습니다. 별도 컨테이너 레지스트리나 CI 없이 사양과 저장소 주소만으로 약 2분 만에 실행됐지만, Python 빌드 환경에 WebP 인코더가 없어 500 오류가 났습니다. Pillow의 features.check("webp")는 디코딩 지원 여부를 확인해 쓰기 기능이 없는 환경에서도 True를 반환했습니다. 저장 가능 형식을 확인하려면 실제 저장 시 참조하는 Image.SAVE 레지스트리에 형식이 있는지 검사해야 했습니다. 개발자 환경에서 잘 되던 실험이 다른 Python 빌드 환경에서 실패하면서 기능 확인 코드의 잘못된 전제를 발견한 사례입니다.

원문은 브라우저에서 이미지가 정상처럼 보이는데 처리 작업이 실패한다는 버그 보고도 두 결과가 동시에 참일 수 있다고 설명합니다. Chromium은 손상 전까지 그린 뒤 멈추고, libpng는 파일 전체를 거부합니다. 데모와 스크립트, 원시 데이터는 github.com/DimitrovK/bitrot-demo에서 확인할 수 있습니다.

원문: dev.to / 번역·요약: Trawling