생성 파이프라인 · 후처리 기록
FLUX.2 로 이미지를 만들고 학습 데이터로 만드는 전 과정입니다. 시도했다가 측정 결과로 폐기한 후처리도 그대로 남겼습니다 — 실패한 시도가 왜 실패했는지가 설계의 근거이기 때문입니다.
현재 후처리는 축소와 저장뿐입니다. 아래는 그렇게 정착한 이유입니다.
학습에 쓰는 이미지는 real·합성 모두 256×256 JPEG q95 입니다. 겉보기 규격은 같지만 거기까지 오는 경로가 전혀 다릅니다.
| 단계 | real (ImageNet) | 합성 (FLUX.2) |
|---|---|---|
| 출발점 | 카메라·스캔 원본을 JPEG 로 저장한 파일 (이미 1회 압축됨) | 확산 모델이 만든 1024×1024 무손실 텐서 (압축 이력 없음) |
| 원본 크기 | 짧은 변 중앙값 372px, 종횡비 중앙 1.37 (정사각 아님) | 정확히 1024×1024 정사각 |
| 정사각 크롭 | 짧은 변 기준 중앙 크롭 → 372×372 (긴 변에서 화면 밖으로 잘려나감) | 이미 정사각 — 잘려나가는 것 없음 |
| 256 으로 축소 | 372 → 256 = 1.45배 축소 | 1024 → 256 = 4.0배 축소 |
| 결과 | 센서 노이즈·1차 JPEG 블록이 살아남음 | LANCZOS 저역통과가 고주파를 대부분 제거 |
4배 축소는 16픽셀을 1픽셀로 평균하는 것과 비슷합니다. LANCZOS 커널이 고주파를 걷어내면서 생성물 특유의 흔적도, 사진에 있어야 할 입자감도 함께 사라집니다. 반면 real 은 1.45배만 줄어 원본의 노이즈·압축 블록이 그대로 남습니다. 잔차 전용 탐지기(이미지 내용을 지우고 고주파만 입력)가 AUROC 0.951 을 낸 이유로 이 경로 차이를 의심한 겁니다.
맞추려면 먼저 재야 합니다. ImageNet 원본 3,000장의 헤더와 파일 크기를 직접 읽었습니다 (scripts/17_measure_real.py).
| 통계 | 짧은 변(px) | 긴 변(px) | 종횡비 |
|---|---|---|---|
| p05 | 182.9 | — | — |
| p25 | 333.0 | — | — |
| 중앙값 | 372.0 | — | 1.37 |
| p75 | 397.2 | — | — |
| p95 | 586.1 | — | — |
| 평균 | 380.7 | — | — |
짧은 변 히스토그램(경계 200·256·320·375·400·450·500·600·800):
| 구간(px) | <200 | 200–256 | 256–320 | 320–375 | 375–400 | 400–450 | 450–500 | 500–600 | 600–800 | 800+ |
|---|---|---|---|---|---|---|---|---|---|---|
| 장수 | 171 | 98 | 215 | 1040 | 736 | 320 | 156 | 124 | 93 | 47 |
375~400 구간에 몰려 있습니다. ImageNet 이 수집 당시 리사이즈를 거친 흔적으로 보이며, 256px 학습 이미지의 실질 해상도 상한이 대략 372px이라는 뜻입니다.
| 통계 | 픽셀당 바이트(bpp) |
|---|---|
| p05 | 0.1533 |
| p25 | 0.3568 |
| 중앙값 | 0.5687 |
| p75 | 0.7875 |
| p95 | 1.1437 |
| 평균 | 0.5931 |
합성에 같은 압축을 걸려면 real 의 품질값(q)을 알아야 합니다. JPEG 헤더의 양자화 테이블을 표준 테이블(품질 50 기준)과 나눠 배율을 구하고, IJG 공식으로 q 를 되돌리는 방법을 썼습니다:
ratio = median(qtable / STD_LUM) → s = ratio × 100 → q = (100 − s/2) if s > 100 else 5000/s
3,000장에 돌린 결과가 중앙값 100, p05 도 100 이었습니다. 전부 최고 품질이라는 건 말이 안 되니, 알려진 품질로 다시 인코딩해 추정기를 검증했습니다.
| 인코딩 품질 | 추정된 품질 | 실측 bpp | 판정 |
|---|---|---|---|
| 70 | 83 | 0.187 | 빗나감 |
| 80 | 100 | 0.238 | 포화 |
| 85 | 100 | 0.290 | 포화 |
| 90 | 100 | 0.348 | 포화 |
| 95 | 100 | 0.531 | 포화 |
| 100 | 100 | 0.992 | 우연히 맞음 |
q ≥ 80 에서 전부 100 으로 포화됩니다. 양자화 테이블이 이미 거의 1에 수렴해 표준 테이블과의 비율로는 구간이 분해되지 않습니다. ImageNet 대부분이 q90 대라서, 이 추정기로는 아무것도 구분할 수 없었습니다.
품질을 못 알아내니, 대신 파일 크기를 맞추기로 했습니다. real 에서 뽑은 목표 bpp 에 도달할 때까지 q 를 이진 탐색하는 방식입니다 (_encode_to_bpp, 탐색 범위 q 30–100, 6회 반복).
첫 시험에서 바로 깨졌습니다. 뽑힌 목표가 target_bpp = 0.0738 이었는데, 탐색은 하한 q = 30 까지 내려가서도 0.1298 에 멈췄습니다 — 목표의 1.76배입니다. 그런데도 이미지는 이미 q30 수준으로 뭉개졌습니다.
bpp 는 이미지 내용에 의존합니다. 하늘처럼 매끈한 사진은 q95 에서도 0.07 bpp 가 나오고, 잡초·군중처럼 복잡한 사진은 q95 에서 1.0 bpp 가 나옵니다. 저디테일 real 사진의 bpp 를 고디테일 합성 사진에 강요하면 같은 압축이 아니라 훨씬 심한 압축이 걸립니다. 목표 자체가 잘못 정의된 것이었습니다.
q 를 알아낼 수 없다면 알아낼 필요 없게 만들면 됩니다. JPEG 인코더의 동작은 사실상 양자화 테이블이 결정하므로, real 파일에서 테이블을 읽어 합성 이미지를 인코딩할 때 그 테이블을 그대로 넣었습니다. PIL 의 save(qtables=...) 가 이를 지원합니다.
| 항목 | 내용 |
|---|---|
| 파일당 저장 정보 | 짧은 변, 종횡비, 양자화 테이블 전체, 크로마 서브샘플링 코드 |
| 테이블 개수 | 2개(휘도+색차) 3,994장 / 1개 6장 |
| 서브샘플링 분포 | 4:4:4(코드 0) 3,299장 · 4:2:0(코드 2) 632장 · 4:2:2(코드 1) 60장 · 불명 9장 |
| 풀 파일 크기 | 1.9MB (results/real_jpeg_pool.json) |
서브샘플링까지 옮긴 이유: 4:2:0 은 색차를 절반으로 줄여 색 경계에 특유의 번짐을 남깁니다. 이것도 탐지 가능한 흔적이라 함께 이식했습니다.
| 시드 | 뽑힌 짧은변 | 뽑힌 종횡비 | 서브샘플링 | 결과 크기 | 결과 bpp |
|---|---|---|---|---|---|
| 0 | 333 | 1.502 | 4:4:4 | 500×333 | 0.809 |
| 1 | 420 | 1.190 | 4:4:4 | 280×333 | 0.894 |
| 2 | 333 | 1.502 | 4:4:4 | 222×333 | 0.918 |
bpp 0.81–0.92 로 real 분포(중앙 0.57, p75 0.79) 상단에 들어왔습니다. 즉 압축 강도 재현 자체는 성공했습니다.

전체 이미지로는 차이가 거의 안 보입니다. 그래서 같은 영역을 4배로 확대하고, 직전 단계와의 차분을 12배 증폭해 무엇이 사라지고 무엇이 새겨지는지 봤습니다.

| 지표 | 계산식 | 의미 |
|---|---|---|
| 선명도 | ndimage.laplace(gray).var() — 라플라시안 2차미분의 분산 | 경계·질감의 강도. 초점이 맞고 디테일이 있으면 커집니다 |
| 고주파 에너지 | mean((gray − gaussian(gray, σ=1.5))²) | 저역통과로 뺀 나머지의 파워. 입자감·미세 질감·압축 링잉을 담습니다 |
| bpp | 파일 바이트 / (W×H) | 압축 강도의 대리 지표 |
두 지표 모두 내용에 의존합니다(클로즈업이 원경보다 큼). 그래서 비교는 ① 같은 프롬프트·시드의 짝 이미지 ② 클래스당 1장으로 균형을 맞춘 표본 두 방식으로 각각 했습니다. 처음에 real 표본을 앞에서 90장 자르는 실수를 했는데 그게 전부 같은 클래스여서, 클래스 균형 표본으로 다시 측정했습니다.
| 조건 | 선명도 | 고주파 | bpp |
|---|---|---|---|
| real (ImageNet) | 1408.8 | 183.49 | 0.489 |
| 합성 · 후처리 없음 (1024→256) | 321.3 | 66.77 | 0.321 |
| 합성 · 이력 매칭 | 160.4 | 41.66 | 0.290 |
가정이 틀렸습니다. real 이 우리 합성보다 이미 4배 선명했습니다. 합성이 '너무 깨끗하다'고 본 건 표면이 매끈하고 잡동사니가 없다는 화풍 얘기였고, 고주파 통계로는 오히려 합성이 훨씬 뭉개져 있었습니다. 여기에 축소·압축을 더 걸었으니 real 에서 더 멀어질 수밖에 없었습니다 (66.8 → 41.7, real 의 0.36배 → 0.23배).
| 설정 | 선명도 | 고주파 | real 대비 |
|---|---|---|---|
| real | 1619.4 | 186.90 | ×1.00 |
| Qwen-Image 2512 (v4) | 2646.2 | 338.41 | ×1.81 (과함) |
| Z-Image Turbo (v4) | 827.2 | 133.56 | ×0.71 |
| FLUX.2 (v4) | 779.9 | 114.96 | ×0.62 |
| Z-Image Turbo (v3) | 536.6 | 99.87 | ×0.53 |
| FLUX.2 (v3) | 385.0 | 85.77 | ×0.46 |
| FLUX.2 (v3) + 이력 매칭 | 181.3 | 40.04 | ×0.21 (최악) |


두 번의 실패를 거쳐 후처리는 최소로 정착했습니다.
| 순서 | 연산 | 파라미터 | 산출물 |
|---|---|---|---|
| 1 | 생성 | FLUX.2-dev, 1024×1024, 20스텝, guidance 4.0, 배치 2 | PIL 이미지 |
| 2 | 원본 보존 | PNG 무손실 저장 | bakeoff_{tag}_orig/*.png (장당 약 1.5MB) |
| 3 | 축소 | resize((256,256), Image.LANCZOS) — 4배 축소 | 256×256 픽셀 |
| 4 | 저장 | save(quality=95) (서브샘플링 기본값) | bakeoff_{tag}/*.jpg (장당 약 25KB) |
2번이 핵심 보험입니다. 13만 장 원본은 약 200GB 인데 HDD 에 15TB 가 남아 있어, 후처리 방침을 바꾸고 싶어질 때 31일을 다시 기다리지 않고 재처리할 수 있습니다. 폐기한 이력 매칭 코드도 src/match_real.py 에 남겨 뒀습니다.
affine_grid+grid_sample 로 per-sample RandomResizedCrop(scale 0.35–1.0) + 좌우반전 → 128px 학습 입력해상도·압축·증강을 조건 간 완전히 동일하게 두는 것이 이 실험의 전제입니다. 이게 어긋나면 성능 격차가 데이터 품질 때문인지 처리 차이 때문인지 구분할 수 없습니다.
축소 배율이 문제라면 애초에 작게 생성하면 될 것 같았습니다. 속도도 3~5배 빨라지니 일석이조로 보였습니다. 그래서 512·384 로 실측했습니다.
| 생성 해상도 | 속도(img/s) | 13만 장 | real 대비 고주파 |
|---|---|---|---|
| 1024 / 20스텝 | 0.048 | 31일 | ×0.38 |
| 768 / 20 | 0.081 | 18.6일 | — |
| 512 / 20 | 0.146 | 10.3일 | ×0.25 |
| 512 / 12 | 0.244 | 6.2일 | — |
| 384 / 20 | — | — | ×0.20 |
또 반대였습니다. FLUX 는 저해상도에서 오히려 디테일을 잃습니다(×0.38 → 0.25 → 0.20). 게다가 512/12 에서는 개의 다리가 뭉개지는 해부학 오류가 생겼습니다 — 사람이 즉시 알아채는 다른 종류의 단서입니다.

| 설정 | 선명도 | 고주파 | real 대비 |
|---|---|---|---|
| real (클래스당 1장) | 1619.4 | 186.90 | — |
| flux2 v3 (1024→256) | 385.0 | 85.77 | ×0.46 |
| flux2 v3 + 이력매칭 | 181.3 | 40.04 | ×0.21 |
| flux2 v4 | 779.9 | 114.96 | ×0.62 |
| zimage v3 | 536.6 | 99.87 | ×0.53 |
| zimage v4 | 827.2 | 133.56 | ×0.71 |
| qwen v4 | 2646.2 | 338.41 | ×1.81 |
후처리보다 프롬프트가 결과를 훨씬 크게 바꿨습니다. 그리고 실패는 매번 같은 패턴이었습니다 — 모델이 '어떻게 찍혔는가'를 '무엇이 찍혔는가'로 바꿔버립니다.
| 버전 | 증상 | 원인 | 조치 |
|---|---|---|---|
| v1 | 8장 중 4장에 손가락으로 새를 집은 장면, 화면에 스마트폰 | handheld, taken on a smartphone 을 피사체로 해석 | 카메라 지칭 명사 제거 |
| v2 | 폴라로이드 테두리, 사진 속 옛날 가족사진 | old family album photo 를 '사진을 찍은 사진'으로 해석 | 해당 토큰 제거 + 네거티브 보강 |
| v3 | 선명도가 20배 하락 (라플라시안 분산 77 → 4) | low resolution photo 를 충실히 반영 (FLUX 는 지시 이행도가 높다) | v4 에서 화질 열화 토큰 전면 제거 |

그런데 v3 를 채택했습니다. 고주파 통계는 v4(×0.84)가 real 에 가깝지만, blind 2AFC 에서 사람을 더 잘 속인 쪽은 v3 였습니다. '사람이 보기에 진짜 같은 것'과 '통계적으로 real 에 가까운 것'이 다르다는 뜻이고, 이 어긋남 자체가 이 연구의 주제입니다.

위 모든 결정은 ImageNet 을 타깃으로 한 프로파일입니다. 타깃이 바뀌면 프롬프트 원천·화질 방침·후처리가 함께 바뀌므로, 설정을 src/profiles.py 에 이름 붙여 동결했습니다.
| 지표 (256px 기준) | ImageNet | COCO 2017 | 현재 산출물 |
|---|---|---|---|
| 짧은 변 중앙값 | 372px | 428px | 정사각 1024 생성 |
| 종횡비 중앙값 | 1.37 | 1.37 | 1.00 |
| bpp 중앙값 | 0.569 | 0.570 | 0.352 |
| 선명도 | 1619.4 | 1728.4 | 385.0 |
| 고주파 | 186.9 | 205.65 | 85.8 (ImageNet 대비 ×0.46 · COCO 대비 ×0.42) |
| 서브샘플링 | 4:4:4 82% + 4:2:0 16% | 4:4:4 99.8% | 기본값 |
| 프롬프트 원천 | 조립형(캡션 없음) | 사람이 쓴 캡션 5개/장 | 조립형 v3 |
다음 타깃은 COCO 2017 입니다. 화질 목표가 ImageNet 보다 조금 더 높고(고주파 205.7 vs 186.9), 결정적으로 사람이 쓴 캡션이 있습니다. 조립형 프롬프트가 만들어낸 '탱크가 상점에' 류의 부자연스러운 맥락 문제가 원천적으로 사라집니다. 대신 캡션에 없는 촬영 조건은 모델 기본값(=AI 화풍)으로 떨어질 위험이 있어, 수식어를 얼마나 덧붙일지가 다음 실험의 핵심 변수입니다.
전체 기록: docs/09_profiles.md · 기계 판본: results/profiles.json
맥락 12개를 클래스와 무관하게 뿌리고 있습니다. 그 결과 탱크가 상점 진열대 앞에, 코브라가 부엌 조리대에, 천문관이 주차장에 놓입니다. real 은 클래스마다 맥락이 정해져 있어(탱크는 야외·박물관) 사람이 즉시 알아채는 단서가 됩니다. LLM 으로 클래스별 맥락 후보를 만들면 해결되고, 원래 계획서에도 그렇게 적혀 있었는데 고정 목록으로 구현한 탓입니다.
현재 생산은 이 문제 때문에 보류 상태입니다. 적용 후 시작할지, 지금 설정으로 갈지 결정을 기다리고 있습니다.