생성 파이프라인 · 후처리 기록

무엇을 했고, 무엇을 되돌렸는가

FLUX.2 로 이미지를 만들고 학습 데이터로 만드는 전 과정입니다. 시도했다가 측정 결과로 폐기한 후처리도 그대로 남겼습니다 — 실패한 시도가 왜 실패했는지가 설계의 근거이기 때문입니다.

전체 흐름

  1. 프롬프트 조립 — 클래스명에 카메라·맥락·조명·구도·화질 5개 축을 무작위 조합. 시드가 고정돼 있어 같은 목록을 언제든 재현하고, 클래스를 번갈아 돌아 중간에 멈춰도 클래스 균형이 유지됩니다.
  2. 생성 — FLUX.2-dev(32B) + 텍스트 인코더 Mistral-24B(fp8 양자화), 1024×1024 / 20스텝 / guidance 4.0, 배치 2.
  3. 후처리 — LANCZOS 로 256×256 축소 후 JPEG q95 저장. 1024px 원본 PNG 를 함께 보존해 후처리 방식을 바꿔도 재생성 없이 재처리 가능.
  4. 학습 시 처리 — real 과 완전히 같은 경로: 정사각 중앙 크롭 → 256px → q95 → RAM 상주 → GPU 에서 RandomResizedCrop+flip. 해상도·압축·증강이 조건 간 동일해야 성능 격차를 데이터 탓으로 돌릴 수 있습니다.

현재 후처리는 축소와 저장뿐입니다. 아래는 그렇게 정착한 이유입니다.

해상도 후처리를 건드린 이유 — 두 파이프라인의 축소 배율이 다르다

학습에 쓰는 이미지는 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 을 낸 이유로 이 경로 차이를 의심한 겁니다.

1단계 — real 의 처리 이력을 실측 (추측 금지)

맞추려면 먼저 재야 합니다. ImageNet 원본 3,000장의 헤더와 파일 크기를 직접 읽었습니다 (scripts/17_measure_real.py).

해상도 분포

통계짧은 변(px)긴 변(px)종횡비
p05182.9
p25333.0
중앙값372.01.37
p75397.2
p95586.1
평균380.7

짧은 변 히스토그램(경계 200·256·320·375·400·450·500·600·800):

구간(px)<200200–256256–320320–375375–400400–450450–500500–600600–800800+
장수1719821510407363201561249347

375~400 구간에 몰려 있습니다. ImageNet 이 수집 당시 리사이즈를 거친 흔적으로 보이며, 256px 학습 이미지의 실질 해상도 상한이 대략 372px이라는 뜻입니다.

압축 강도

통계픽셀당 바이트(bpp)
p050.1533
p250.3568
중앙값0.5687
p750.7875
p951.1437
평균0.5931

2단계 — JPEG 품질을 역추정하려다 실패

합성에 같은 압축을 걸려면 real 의 품질값(q)을 알아야 합니다. JPEG 헤더의 양자화 테이블을 표준 테이블(품질 50 기준)과 나눠 배율을 구하고, IJG 공식으로 q 를 되돌리는 방법을 썼습니다:

ratio = median(qtable / STD_LUM)s = ratio × 100q = (100 − s/2) if s > 100 else 5000/s

3,000장에 돌린 결과가 중앙값 100, p05 도 100 이었습니다. 전부 최고 품질이라는 건 말이 안 되니, 알려진 품질로 다시 인코딩해 추정기를 검증했습니다.

인코딩 품질추정된 품질실측 bpp판정
70830.187빗나감
801000.238포화
851000.290포화
901000.348포화
951000.531포화
1001000.992우연히 맞음

q ≥ 80 에서 전부 100 으로 포화됩니다. 양자화 테이블이 이미 거의 1에 수렴해 표준 테이블과의 비율로는 구간이 분해되지 않습니다. ImageNet 대부분이 q90 대라서, 이 추정기로는 아무것도 구분할 수 없었습니다.

3단계 — bpp 목표 방식도 실패 (내용 의존성)

품질을 못 알아내니, 대신 파일 크기를 맞추기로 했습니다. 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 를 고디테일 합성 사진에 강요하면 같은 압축이 아니라 훨씬 심한 압축이 걸립니다. 목표 자체가 잘못 정의된 것이었습니다.

4단계 — 추정을 포기하고 양자화 테이블을 통째로 이식

q 를 알아낼 수 없다면 알아낼 필요 없게 만들면 됩니다. JPEG 인코더의 동작은 사실상 양자화 테이블이 결정하므로, real 파일에서 테이블을 읽어 합성 이미지를 인코딩할 때 그 테이블을 그대로 넣었습니다. PIL 의 save(qtables=...) 가 이를 지원합니다.

수집한 풀 (real 4,000장)

항목내용
파일당 저장 정보짧은 변, 종횡비, 양자화 테이블 전체, 크로마 서브샘플링 코드
테이블 개수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 은 색차를 절반으로 줄여 색 경계에 특유의 번짐을 남깁니다. 이것도 탐지 가능한 흔적이라 함께 이식했습니다.

적용 절차 (한 장에 대해)

  1. 풀에서 참조 항목 하나를 무작위 추출 → (짧은변 S, 종횡비 r, qtable, sub)
  2. 종횡비 크롭: 1024×1024 에서 비율 r 이 되도록 중앙 크롭. 가로형/세로형을 50% 확률로 선택 (real 은 둘 다 있음). 예) r=1.502 → 1024×682 로 크롭
  3. 축소: 짧은 변이 S 가 되도록 LANCZOS 축소. 확대는 하지 않음(S 가 현재보다 크면 건너뜀 — 없는 정보를 만들지 않기 위해). 예) S=333 → 500×333
  4. 인코딩: 이식한 qtable·서브샘플링으로 JPEG 바이트 생성
  5. 재디코딩: 그 바이트를 다시 열어 픽셀로 되돌림 — 이 왕복이 있어야 블록 경계·링잉이 실제로 픽셀에 새겨집니다
  6. 이후 real 과 동일한 마지막 단계: 정사각 중앙 크롭 → 256px LANCZOS → q95 저장

실제 적용 로그 (같은 입력, 시드만 다름)

시드뽑힌 짧은변뽑힌 종횡비서브샘플링결과 크기결과 bpp
03331.5024:4:4500×3330.809
14201.1904:4:4280×3330.894
23331.5024:4:4222×3330.918

bpp 0.81–0.92 로 real 분포(중앙 0.57, p75 0.79) 상단에 들어왔습니다. 즉 압축 강도 재현 자체는 성공했습니다.

단계별로 실제 무엇이 나오는가

한 장을 골라 각 단계의 산출물을 그대로 늘어놓은 것. 참조로 뽑힌 real 은 짧은 변 333px · 종횡비 1.50 이었고, 이식한 qtable 로 인코딩한 결과 bpp 0.407 이 나왔습니다. 맨 오른쪽이 후처리 없이 1024→256 으로 직행한 비교군입니다.
한 장을 골라 각 단계의 산출물을 그대로 늘어놓은 것. 참조로 뽑힌 real 은 짧은 변 333px · 종횡비 1.50 이었고, 이식한 qtable 로 인코딩한 결과 bpp 0.407 이 나왔습니다. 맨 오른쪽이 후처리 없이 1024→256 으로 직행한 비교군입니다.

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

왼쪽부터: ① 1024 원본에는 깃털 반점 같은 미세 질감이 살아 있습니다. ③ 333px 로 줄이면 그 반점이 뭉개집니다. ④ JPEG 왕복을 거치면 블록 경계와 링잉이 새로 새겨집니다(맨 오른쪽 차분 지도의 격자 무늬가 그것). ⑤ 매칭 최종과 후처리 없음 256 을 비교하면 매칭 쪽이 더 물렁합니다.
왼쪽부터: ① 1024 원본에는 깃털 반점 같은 미세 질감이 살아 있습니다. ③ 333px 로 줄이면 그 반점이 뭉개집니다. ④ JPEG 왕복을 거치면 블록 경계와 링잉이 새로 새겨집니다(맨 오른쪽 차분 지도의 격자 무늬가 그것). ⑤ 매칭 최종과 후처리 없음 256 을 비교하면 매칭 쪽이 더 물렁합니다.

5단계 — 무엇으로 판정했는가 (지표 정의)

지표계산식의미
선명도ndimage.laplace(gray).var() — 라플라시안 2차미분의 분산경계·질감의 강도. 초점이 맞고 디테일이 있으면 커집니다
고주파 에너지mean((gray − gaussian(gray, σ=1.5))²)저역통과로 뺀 나머지의 파워. 입자감·미세 질감·압축 링잉을 담습니다
bpp파일 바이트 / (W×H)압축 강도의 대리 지표

두 지표 모두 내용에 의존합니다(클로즈업이 원경보다 큼). 그래서 비교는 ① 같은 프롬프트·시드의 짝 이미지 ② 클래스당 1장으로 균형을 맞춘 표본 두 방식으로 각각 했습니다. 처음에 real 표본을 앞에서 90장 자르는 실수를 했는데 그게 전부 같은 클래스여서, 클래스 균형 표본으로 다시 측정했습니다.

6단계 — 판정: 매칭은 방향이 반대였다

짝 비교 (같은 프롬프트·시드 90쌍)

조건선명도고주파bpp
real (ImageNet)1408.8183.490.489
합성 · 후처리 없음 (1024→256)321.366.770.321
합성 · 이력 매칭160.441.660.290

가정이 틀렸습니다. real 이 우리 합성보다 이미 4배 선명했습니다. 합성이 '너무 깨끗하다'고 본 건 표면이 매끈하고 잡동사니가 없다는 화풍 얘기였고, 고주파 통계로는 오히려 합성이 훨씬 뭉개져 있었습니다. 여기에 축소·압축을 더 걸었으니 real 에서 더 멀어질 수밖에 없었습니다 (66.8 → 41.7, real 의 0.36배 → 0.23배).

클래스 균형 표본 (클래스당 1장, n=100)

설정선명도고주파real 대비
real1619.4186.90×1.00
Qwen-Image 2512 (v4)2646.2338.41×1.81 (과함)
Z-Image Turbo (v4)827.2133.56×0.71
FLUX.2 (v4)779.9114.96×0.62
Z-Image Turbo (v3)536.699.87×0.53
FLUX.2 (v3)385.085.77×0.46
FLUX.2 (v3) + 이력 매칭181.340.04×0.21 (최악)
최종 두 결과의 직접 비교. 3번째 칸이 두 결과의 차분(×12) — 매칭이 바꾼 양입니다. 4·5번째 칸은 고주파 지도로, 밝은 점이 많을수록 미세 질감이 살아 있다는 뜻입니다. 매칭 쪽이 눈에 띄게 어둡습니다 — real 을 닮게 하려던 처리가 반대로 질감을 더 지웠습니다.
최종 두 결과의 직접 비교. 3번째 칸이 두 결과의 차분(×12) — 매칭이 바꾼 양입니다. 4·5번째 칸은 고주파 지도로, 밝은 점이 많을수록 미세 질감이 살아 있다는 뜻입니다. 매칭 쪽이 눈에 띄게 어둡습니다 — real 을 닮게 하려던 처리가 반대로 질감을 더 지웠습니다.
같은 프롬프트·시드의 짝 비교. 위 real, 가운데 후처리 없음, 아래 이력 매칭. 맨 아래가 눈에 보이게 더 뭉개졌습니다 — 고주파 66.8 → 41.7.
같은 프롬프트·시드의 짝 비교. 위 real, 가운데 후처리 없음, 아래 이력 매칭. 맨 아래가 눈에 보이게 더 뭉개졌습니다 — 고주파 66.8 → 41.7.

7단계 — 최종 후처리 (지금 쓰는 것)

두 번의 실패를 거쳐 후처리는 최소로 정착했습니다.

순서연산파라미터산출물
1생성FLUX.2-dev, 1024×1024, 20스텝, guidance 4.0, 배치 2PIL 이미지
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 에 남겨 뒀습니다.

학습 단계에서의 처리 (real 과 완전 동일)

  1. real: 원본 → 정사각 중앙 크롭 → 256px LANCZOS → JPEG q95 로 미리 캐시 (45,000장, 1.5GB, 28프로세스로 2분 36초)
  2. 합성: 위 4단계 산출물이 이미 같은 규격
  3. 양쪽 모두 160px 로 RAM 에 uint8 상주 → GPU 에서 affine_grid+grid_sample 로 per-sample RandomResizedCrop(scale 0.35–1.0) + 좌우반전 → 128px 학습 입력

해상도·압축·증강을 조건 간 완전히 동일하게 두는 것이 이 실험의 전제입니다. 이게 어긋나면 성능 격차가 데이터 품질 때문인지 처리 차이 때문인지 구분할 수 없습니다.

폐기한 후처리 ② — 생성 해상도 낮추기

축소 배율이 문제라면 애초에 작게 생성하면 될 것 같았습니다. 속도도 3~5배 빨라지니 일석이조로 보였습니다. 그래서 512·384 로 실측했습니다.

생성 해상도속도(img/s)13만 장real 대비 고주파
1024 / 20스텝0.04831일×0.38
768 / 200.08118.6일
512 / 200.14610.3일×0.25
512 / 120.2446.2일
384 / 20×0.20

또 반대였습니다. FLUX 는 저해상도에서 오히려 디테일을 잃습니다(×0.38 → 0.25 → 0.20). 게다가 512/12 에서는 개의 다리가 뭉개지는 해부학 오류가 생겼습니다 — 사람이 즉시 알아채는 다른 종류의 단서입니다.

왼쪽부터 1024/20 · 768/20 · 640/12 · 512/12. 맨 오른쪽에서 다리 구조가 깨집니다.
왼쪽부터 1024/20 · 768/20 · 640/12 · 512/12. 맨 오른쪽에서 다리 구조가 깨집니다.

설정별 고주파 통계 (클래스당 1장, n=100)

설정선명도고주파real 대비
real (클래스당 1장)1619.4186.90
flux2 v3 (1024→256)385.085.77×0.46
flux2 v3 + 이력매칭181.340.04×0.21
flux2 v4779.9114.96×0.62
zimage v3536.699.87×0.53
zimage v4827.2133.56×0.71
qwen v42646.2338.41×1.81

프롬프트에서 세 번 실패한 기록

후처리보다 프롬프트가 결과를 훨씬 크게 바꿨습니다. 그리고 실패는 매번 같은 패턴이었습니다 — 모델이 '어떻게 찍혔는가'를 '무엇이 찍혔는가'로 바꿔버립니다.

버전증상원인조치
v18장 중 4장에 손가락으로 새를 집은 장면, 화면에 스마트폰handheld, taken on a smartphone 을 피사체로 해석카메라 지칭 명사 제거
v2폴라로이드 테두리, 사진 속 옛날 가족사진old family album photo 를 '사진을 찍은 사진'으로 해석해당 토큰 제거 + 네거티브 보강
v3선명도가 20배 하락 (라플라시안 분산 77 → 4)low resolution photo 를 충실히 반영 (FLUX 는 지시 이행도가 높다)v4 에서 화질 열화 토큰 전면 제거
같은 시드, 프롬프트만 다름. 왼쪽 중립(전형적 AI 화풍) · 가운데 장면만 지정(가장 그럴듯) · 오른쪽 v3(화질 열화 지시).
같은 시드, 프롬프트만 다름. 왼쪽 중립(전형적 AI 화풍) · 가운데 장면만 지정(가장 그럴듯) · 오른쪽 v3(화질 열화 지시).

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

위 real · 가운데 v3(고주파 ×0.38) · 아래 v4(×0.84). v3 의 손에 든 도토리·라이터는 실제로 그 클래스를 찍는 방식과 같습니다.
위 real · 가운데 v3(고주파 ×0.38) · 아래 v4(×0.84). v3 의 손에 든 도토리·라이터는 실제로 그 클래스를 찍는 방식과 같습니다.

이 설정은 'ImageNet 프로파일' 이다 (2026-08-20 동결)

위 모든 결정은 ImageNet 을 타깃으로 한 프로파일입니다. 타깃이 바뀌면 프롬프트 원천·화질 방침·후처리가 함께 바뀌므로, 설정을 src/profiles.py 에 이름 붙여 동결했습니다.

지표 (256px 기준)ImageNetCOCO 2017현재 산출물
짧은 변 중앙값372px428px정사각 1024 생성
종횡비 중앙값1.371.371.00
bpp 중앙값0.5690.5700.352
선명도1619.41728.4385.0
고주파186.9205.6585.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 으로 클래스별 맥락 후보를 만들면 해결되고, 원래 계획서에도 그렇게 적혀 있었는데 고정 목록으로 구현한 탓입니다.

현재 생산은 이 문제 때문에 보류 상태입니다. 적용 후 시작할지, 지금 설정으로 갈지 결정을 기다리고 있습니다.