카메라를 USB로 연결하지도, 유료 앱을 쓰지도, 서비스센터에 맡기지도 않고 CR3(RAW) 파일 하나만으로 EOS RP의 셔터 카운트를 읽어내는 방법. 직접 리버스 엔지니어링해서 찾았고, 캐논 서비스센터 공식 값과 정확히 일치 검증까지 마침.
EOS RP의 셔터 카운트는 CR3 파일 안 아래 위치에 uint32 (리틀엔디언) 으로 들어있다.
CR3 파일
└─ moov
└─ (마지막) trak ← 메타데이터 트랙 (nmhd 핸들러)
└─ mdia → minf → stbl → co64 ← CTMD 청크의 파일 오프셋
↓
CTMD 청크 (Canon Timed MetaData)
└─ 레코드 type 8
└─ TIFF IFD
└─ tag 0x000D 블롭
└─ 바이트 오프셋 0x960 (2400), uint32 LE ← ★ 셔터 카운트
핵심 한 줄: CTMD record 8 → TIFF tag 0x000D → blob offset 2400 (0x960), uint32 LE
같은 카메라로 연속 촬영한 3장의 CR3에서 이 오프셋만 촬영 장수만큼 정확히 +1씩 증가했다. (동영상 MVI_0002는 정지화상 번호를 건너뛰므로 실제 셔터 순서는 0003→0004→0005)
| 파일 | offset 0x960 값 |
|---|---|
| IMG_0003.CR3 | 63396 |
| IMG_0004.CR3 | 63397 |
| IMG_0005.CR3 | 63398 |
바이트로 보면 a4 f7 00 00 → a5 f7 00 00 → a6 f7 00 00 로 깔끔하게 1씩 증가.
그리고 캐논 서비스센터에서 받은 CAM_INFO.XML 의 공식 값과 대조:
<TotalShoot>63398</TotalShoot> ← 서비스센터 공식 셔터 카운트IMG_0005(마지막 촬영) = 63398 = 서비스센터 값 63398. 완전 일치. ✅
서비스센터 XML에는 비슷해 보이는 필드가 또 있다.
| 필드 | 값(예시) | 의미 |
|---|---|---|
TotalShoot |
63398 | ✅ 이게 셔터(촬영) 카운트. 우리가 찾는 값 |
TotalShutter |
1087010866 | ❌ 셔터 컷수 아님. 10억대 내부 카운터(미세 동작/틱 추정) |
PowerOnCount |
178 | 전원 켠 횟수 |
TotalRunningTime |
1892048 | 누적 작동 시간(초) |
→ 셔터 컷수로 봐야 하는 건 TotalShoot 쪽. TotalShutter에 낚이지 말 것.
- EOS RP 같은 미러리스 신형은
gphoto2 --get-config /main/status/shuttercounter로 안 나온다. (구형 DSLR은 됨. RP는 해당 config 항목 자체가 없음 —shutterspeed만 있고 카운터 없음) - RP는 셔터 카운트를 사진 파일의 EXIF에 저장하지 않는다. 그래서 일반 셔터카운트 웹사이트(.JPG 업로드형)로는 안 됨.
- 단, CR3의 CTMD 메타데이터 블록에는 들어있다. R5/R6 등은 이 위치가 공개돼 있으나 (R5/R6=0x0AF1, R6II/R8/R50=0x0D29 등) RP는 공개 정보가 없어서 직접 찾아야 했다. → 우리가 찾은 RP의 위치: tag 0x000D blob offset 0x960.
- CR3 컨테이너 구조는 ISO Base Media File Format (ISOBMFF) = MP4와 같은 박스 구조.
의존성 없음. 순수 파이썬 표준 라이브러리(struct)만 사용.
#!/usr/bin/env python3
"""Canon EOS RP shutter count reader — CR3 파일에서 직접 읽기."""
import struct
import sys
# RP에서 우리가 찾아낸 위치
CTMD_RECORD_TYPE = 8
TIFF_TAG = 0x000D
BLOB_OFFSET = 0x960 # = 2400, uint32 LE
def _boxes(buf, start, end):
"""ISOBMFF 박스 워커: (type, offset, size, header_len) 리스트 반환."""
o = start
out = []
while o < end - 8:
size = struct.unpack('>I', buf[o:o+4])[0]
typ = buf[o+4:o+8]
hdr = 8
if size == 1:
size = struct.unpack('>Q', buf[o+8:o+16])[0]; hdr = 16
elif size == 0:
size = end - o
out.append((typ, o, size, hdr))
if size <= 0:
break
o += size
return out
def _find(buf, parent, name):
bs = _boxes(buf, parent[1]+parent[3], parent[1]+parent[2])
return [b for b in bs if b[0] == name]
def read_rp_shutter_count(path):
f = open(path, 'rb').read()
# 1) moov → 마지막 trak(=메타데이터 트랙) → stbl 의 co64/stsz
top = _boxes(f, 0, len(f))
moov = next(b for b in top if b[0] == b'moov')
traks = [b for b in _boxes(f, moov[1]+moov[3], moov[1]+moov[2]) if b[0] == b'trak']
mt = traks[-1]
mdia = _find(f, mt, b'mdia')[0]
minf = _find(f, mdia, b'minf')[0]
stbl = _find(f, minf, b'stbl')[0]
co64 = _find(f, stbl, b'co64')[0]
stsz = _find(f, stbl, b'stsz')[0]
# 2) CTMD 청크 위치/크기 (첫 청크만 사용)
o = co64[1]; chunk_off = struct.unpack('>Q', f[o+16:o+24])[0]
o = stsz[1]; ss = struct.unpack('>I', f[o+12:o+16])[0]
chunk_size = ss if ss else struct.unpack('>I', f[o+20:o+24])[0]
chunk = f[chunk_off:chunk_off+chunk_size]
# 3) CTMD 레코드 파싱: <size:uint32 LE><type:uint16 LE><payload>
off = 0; records = {}
while off < len(chunk) - 6:
rs = struct.unpack('<I', chunk[off:off+4])[0]
rt = struct.unpack('<H', chunk[off+4:off+6])[0]
if rs < 6 or off + rs > len(chunk):
break
records[rt] = chunk[off:off+rs]
off += rs
rec = records[CTMD_RECORD_TYPE]
# 4) 레코드 안의 TIFF IFD 파싱 → tag 0x000D 블롭 찾기
p = rec.find(b'II*\x00') # 리틀엔디언 TIFF 헤더
d = rec[p:]
ifd = struct.unpack('<I', d[4:8])[0]
q = ifd
n = struct.unpack('<H', d[q:q+2])[0]; q += 2
TYPE_SIZE = {1:1,2:1,3:2,4:4,5:8,7:1,8:2,9:4,10:8}
for _ in range(n):
e = d[q:q+12]; q += 12
tag = struct.unpack('<H', e[0:2])[0]
typ = struct.unpack('<H', e[2:4])[0]
cnt = struct.unpack('<I', e[4:8])[0]
vo = e[8:12]
blen = TYPE_SIZE.get(typ, 1) * cnt
raw = vo[:blen] if blen <= 4 else d[struct.unpack('<I', vo)[0]:][:blen]
if tag == TIFF_TAG:
# 5) ★ 셔터 카운트
return struct.unpack('<I', raw[BLOB_OFFSET:BLOB_OFFSET+4])[0]
raise ValueError("tag 0x000D를 찾지 못함 — RP가 아니거나 포맷이 다를 수 있음")
if __name__ == '__main__':
if len(sys.argv) < 2:
print("usage: python3 rp_shutter.py <file.CR3> [more.CR3 ...]")
sys.exit(1)
for path in sys.argv[1:]:
try:
print(f"{path}: {read_rp_shutter_count(path):,} 컷")
except Exception as ex:
print(f"{path}: 읽기 실패 — {ex}")사용:
python3 rp_shutter.py IMG_0005.CR3
# IMG_0005.CR3: 63,398 컷- 검증 도구를 내장하면 견고함: 컷수가 다른 2장 이상을 받아서 "오프셋 0x960 값이 장수 차이만큼 증가"를 확인하면, 다른 RP 개체/펌웨어에서도 자가검증 가능.
- 다른 모델 확장: tag 0x000D 블롭 자체는 R 시리즈 공통. 모델별로 blob offset만 다를 수 있으니
(모델명 → offset)매핑 테이블로 빼두면 좋다. RP = 0x960. 모델명은moov/uuid(CMT1...)또는 EXIF에서 읽을 수 있음. - 브라우저에서도 가능: 전부 바이트 오프셋 계산이라 JS(ArrayBuffer/DataView)로 그대로 포팅 가능. 파일 업로드 없이 클라이언트 사이드에서 처리하면 프라이버시도 좋음.
- uint16로 읽어도 무방: 상위 2바이트가 0이라 현실적인 컷수 범위에선 uint16/uint32 결과 동일. 단 약 6.5만 컷(65536) 넘어가면 uint32로 읽어야 정확.
- 주의: 이 값이 기계식 셔터만 세는지 전자셔터 포함인지는 펌웨어 거동에 따라 다를 수 있음.
서비스센터
TotalShoot와 일치했으므로 "누적 촬영 수" 지표로는 신뢰 가능.
EOS RP의 셔터 카운트 = CR3의 CTMD record 8 → TIFF tag 0x000D → offset 0x960 (uint32 LE). 서비스센터 공식값과 정확히 일치 검증 완료. 파일 하나면 끝.