clone

a photo toolkit for your phone that never uploads: fuji film looks for raw files, social crops, error level analysis

main
gudpics / scripts / neutral-lut.py
neutral-lut.py 4.7K
1"""Generate the neutral LUTs in assets/luts/neutral-dr*.png.2SPDX-License-Identifier: AGPL-3.0-or-later34A Hald image in the LibRaw output domain (sRGB-encoded, camera white balance, sRGB primaries)5is wrapped as a LinearRaw DNG carrying an X-Pro3 color matrix and as-shot neutral, then rendered6through darktable-cli with the default scene-referred (filmic) workflow. The result is the mapping7from LibRaw's render to darktable's default render, which is the input the film simulation LUTs8in assets/luts were fitted against. One LUT per Fujifilm DR mode, since darktable compensates9DR200 and DR400 by one and two stops. Requires darktable-cli, numpy and Pillow.10"""11import numpy as np, struct, subprocess, sys12N=33; BLOCK=413cam_xyz=[1.3426,-0.6334,-0.1177,-0.4244,1.2136,0.2371,-0.058,0.1303,0.598]14rgb_cam=np.array([[1.23842,0.0314905,-0.269914],[-0.146979,1.55701,-0.410031],[0.00372626,-0.395365,1.39164]])15cam_mul=np.array([567.,302.,545.]); wb=cam_mul/cam_mul.min()16neutral=[302/567,1.0,302/545]17def srgb_decode(u): return np.where(u<=0.04045,u/12.92,((u+0.055)/1.055)**2.4)18def write_cam_dng(path,rgb16,white=65535):19 h,w,_=rgb16.shape; data=rgb16.astype('<u2').tobytes(); entries=[]20 def add(tag,typ,vals):21 if typ==2: b=vals.encode()+b'\0'; cnt=len(b)22 elif typ==3: b=struct.pack('<%dH'%len(vals),*vals); cnt=len(vals)23 elif typ==4: b=struct.pack('<%dI'%len(vals),*vals); cnt=len(vals)24 elif typ==1: b=bytes(vals); cnt=len(vals)25 elif typ==5: b=b''.join(struct.pack('<II',n,d) for n,d in vals); cnt=len(vals)26 elif typ==10: b=b''.join(struct.pack('<ii',n,d) for n,d in vals); cnt=len(vals)27 entries.append((tag,typ,cnt,b))28 add(254,4,[0]); add(256,4,[w]); add(257,4,[h]); add(258,3,[16,16,16]); add(259,3,[1]); add(262,3,[34892])29 add(271,2,'FUJIFILM'); add(272,2,'X-Pro3'); add(273,4,[0]); add(274,3,[1]); add(277,3,[3]); add(278,4,[h]); add(279,4,[len(data)]); add(284,3,[1])30 add(50706,1,[1,4,0,0]); add(50707,1,[1,1,0,0]); add(50708,2,'FUJIFILM X-Pro3'); add(50714,4,[0,0,0]); add(50717,4,[white,white,white])31 add(50721,10,[(int(round(v*1e6)),1000000) for v in cam_xyz]); add(50728,5,[(int(round(n*1e6)),1000000) for n in neutral]); add(50778,3,[21])32 entries.sort(key=lambda e:e[0]); n=len(entries); ifd_off=8; extra_off=ifd_off+2+n*12+4; extra=b''; ifd=struct.pack('<H',n)33 for tag,typ,cnt,b in entries:34 if tag==273: ifd+=struct.pack('<HHII',tag,typ,cnt,0); continue35 if len(b)<=4: ifd+=struct.pack('<HHI',tag,typ,cnt)+b.ljust(4,b'\0')36 else: ifd+=struct.pack('<HHII',tag,typ,cnt,extra_off+len(extra)); extra+=b+(b'\0' if len(b)%2 else b'')37 ifd+=struct.pack('<I',0); data_off=extra_off+len(extra)38 idx=[e[0] for e in entries].index(273); pos=2+idx*12+8; ifd=ifd[:pos]+struct.pack('<I',data_off)+ifd[pos+4:]39 open(path,'wb').write(b'II*\0'+struct.pack('<I',ifd_off)+ifd+extra+data)40def hald_linear():41 g=np.arange(N)/(N-1)42 b,gg,r=np.meshgrid(g,g,g,indexing='ij') # index order [b][g][r], r fastest43 u=np.stack([r,gg,b],axis=-1).reshape(-1,3) # N^3 x 3 encoded sRGB44 lin=srgb_decode(u)45 cam=lin@np.linalg.inv(rgb_cam).T # linear sRGB -> camera space (post WB)46 raw=cam/wb # undo white balance47 return raw48def make_dng(gain,path):49 raw=hald_linear()*gain50 raw=np.clip(raw,0,0.995) # keep below clipping so highlight reconstruction stays out51 cells=raw.reshape(N*N,N,3) # rows = b*N+g, cols = r52 img=np.repeat(np.repeat(cells,BLOCK,axis=0),BLOCK,axis=1)53 write_cam_dng(path,(img*65535+0.5).astype(np.uint16))54 return img.shape55import os56DT=os.environ.get('DARKTABLE_CLI','/Applications/darktable.app/Contents/MacOS/darktable-cli')57OUT=os.path.join(os.path.dirname(os.path.abspath(__file__)),'..','assets','luts')58TMP='/tmp/gudpics-neutral-lut'; os.makedirs(TMP,exist_ok=True)59for gain,tag in [(1,'dr100'),(2,'dr200'),(4,'dr400')]:60 shape=make_dng(gain,f'{TMP}/hald_{tag}.dng')61 subprocess.run(['rm','-f',f'{TMP}/hald_{tag}.png'])62 r=subprocess.run([DT,f'{TMP}/hald_{tag}.dng',f'{TMP}/hald_{tag}.png','--core','--configdir',TMP+'/dtconf','--conf','plugins/darkroom/workflow=scene-referred (filmic)','--conf','plugins/imageio/format/png/bpp=8'],capture_output=True,text=True)63 print(tag,'dng',shape,'darktable:',[l for l in (r.stdout+r.stderr).splitlines() if 'export' in l or 'rror' in l][-1:])6465from PIL import Image66for tag in ['dr100','dr200','dr400']:67 im=np.asarray(Image.open(f'{TMP}/hald_{tag}.png').convert('RGB'),dtype=np.float32)68 cells=im.reshape(N*N,BLOCK,N,BLOCK,3)[:,1:3,:,1:3,:].mean(axis=(1,3)) # inner pixels of each block69 Image.fromarray(np.clip(cells+0.5,0,255).astype(np.uint8)).save(os.path.join(OUT,f'neutral-{tag}.png'))70 print('wrote',f'neutral-{tag}.png',cells.shape)