>kx4n8DOjrM%FvpoC3o^sD>cxN@*T^4Q(+;UW+JFBt5hF<~X zfm;FOfiR1A5P5N^FfFd_8Fi-7zcdmWDRV2KcDM4J29Yv24Bp0%!j5S~)UP0-R|Lr2 zP40y>Fb8yx;)UD?l0G~LY)PQ_f#PqW1Q=%!=)o3xNC^Xfq(~r-vT_V~Vl6yzpu}4! z37{leC|yA5YN2dXx}ncxk%PPkT6or!PGfr*}jn9*1@`(^;s)X^$c%T#vqDn9i{k3jLo$I(soj#SV)?_X7|L?mCC% z#JiI1q{51pQ*{HqRGum-wOkk89-lsI35Hfc*JFQ8nt%@KmCN-n#QBV=0R1g5s+Mn7 zo0C+y$H@Am s~Oh;3}k?QFJ#Z11fw z2TDS!ea{lu9vW7}(B^b410(9DG^?3FVOxc_KYPE7-@^@*Vo+xBSyhK9)!zh~Mrg~x zVEHz;*^XU>Q&Sfvj-6=_TLU(^e#V@HzA t|l(^uP$ zr5z;lTx};Z4v}7QRs2q+=jFR&Ydx>lc46~w(mPb!1Etbr?FABum5$R0EMbe!#&eXN zox7pX=Ce8Gd&wO~<1Pr{r~}RvzVOGb4isHv&mLPGD2wbpq+#l|LBY+fgUvAu$48W@ z!;Sl$rl>kVNVD&F_=uq)!<&nJS|lG%5&o6W|u@8o |KrQsKy7MxcFeGGy(9# zkm~6z*>Go{LkG=U(zxNQz&&UI6=+LE&P3f=TEG~sEj6{`7;Oo{FMory5NMP(zm3u7 zTn#r_bVwLCuvCOwleE$6W+1l!&vNalxDnjJ0|7Yaj18(ATZ0N7)V6>W+nxrZ*gMVU z#hh()YlQdIRUUdD72AifII$4jFD@>d^YD`PE Ge82qA1(0=z6;&Sg_hIPj5TPss8Z z`HcCVOjV1A(JVI(32bdKSpd|R8V`C@1r>h@KO+Mfz%WAmp`Yz4jaOq`H-~QwmybN? z+jYNhq|!I?Ai4d1a$hC6ubO=RK~lV*++RuVzn6Te=5_V;t`i~NTN;1=D4cz 4 zAk=+7G*Af*l#P3#ouxNF^@pyzue`qM>}F6-Md;hOI^m6V8Oh}pHOtnF*%tE?>@|ZT zeApd}&R_}O2iOw@#hVkwxRT<*Ptm5>jOY}W@D#Hx&+|HTzLZrDQgmB*WiSpxR^vI2 z`;0{XLB{Ws@xPPsW5LhG;EWx9@h*2OyG~+U T1BjmE$cz5j$_O3O%1{|#` zSLz`_NEE3RNNpt}Rh27x@R4Khy?ARRTSE?!dg4YEPCa#I{irdhNFB+Z`RD)Un}7cQ zo7vwz9w&j??LWTD@^*sw6C1r|>JD~C(cnJ85geH#u98M?x@zW3UlUg?B{FV)hJnen zxr@i03;nRYdk3+gwWMbHp7_yJHmf9d Y4nRBCzQ8;%Z`K#wqw`yh2 zz&h{i)@klO);oapzX9tTzy@D~^=V$sgSfl7EBiIadgN8y0Lz|Ny_#R6deQYVK@u9p znKnEqf7G3yEyMsr^fe3)&D>)!avmrLwcsTp?qxTS?3^@eD_K#Yg`$*JMOmWLfM-=+ zp;?LkWNG2(>QaIh#2ilxK+e-FU0@}SRcMY?Spl$lo=(e>%8HUGWoR|a(|KNHv6Ges z!&cE+WzyJZ7G#uZV}y6b4n~v&nVu+clk;fFdCV7|{$Jdf5k(@%AkTA5o(0!=pwJUJ zQBe^i?&zDMnl(a_aM<1OdPq?+Txl5qUMa{DhtDGejt5<&2xzuY;90;*X`b%F H2j*AAj3f$(Pg0pRQB?qb;{d*H&?XCl!mDu-+C-^PRF${~nsdAaO$81*`Xz?; z43LY3wGujEI?Lx-Xj@D!UtYNcohysy&oe9lYzexS6SK*Mo-ub}Hks@i?&ZbRg~X+< zWy58WIOtkjn*FGUcO&UCJt@XRFz|{tpap9W`W0DFZ?b@AZXm%R{X?oZ1HF>W$O4nj zvXaE-6zJ_smf^&-3LSkLdW;hx<>4s>dW?=q%SB1$0kn#FHiPPlXBX$8)wnp2>cxkv zCkW75$gvU+?cM#L%_y}3%}8a?j{cZi`J56$iN<0dYJbnoO{Xp^=nSbVC^i`^rvP7& zmDD+;G8~-5F-UAGV2xXz>WVLgX>S4%MLAhOO6y#H2F-#~Xe0EP63T6)&kFH8Ncf|T z=tN_0yx}=gxv(9HHb#y#BX3udJK=-XPpfl!IMxo0wUMFLF6yzV#`v@zo^D4DA^XC^ zFB{{h^~h=LUa3tyyw(^yp@&bj_rFt9>k0kXY-94AzW-eN(DC|w Bm*9IIZduQr1-oA&L%-d12t4~$hmukrPj z$5YL~{HCo !|jF%bU&xldHUkGp-k56JR lbtW%!#T-8a-bW^WaTNT8du}F4@+tAwQzEo$i;*E?#Xl;*glPZ( literal 0 HcmV?d00001 diff --git a/backsub/background_sub.py b/backsub/background_sub.py new file mode 100644 index 0000000..76f2a69 --- /dev/null +++ b/backsub/background_sub.py @@ -0,0 +1,355 @@ +#standard libraries +import pandas as pd +import numpy as np +import tifffile as tifff +from loguru import logger +from skimage.transform import resize,rescale +import time +import dask.array as da +from dask.diagnostics import ProgressBar,ResourceProfiler +import tracemalloc +from dask_image.ndfilters import gaussian_filter +from skimage.util import img_as_float32 +from skimage.exposure import rescale_intensity +import ome_types +import zarr +#local libraries +import CLI +import ome_writer +from metadata2markers_table import meta_from_file,assign_background + +#Decorator function to measure run-time and memory peak of a function +def memocron(func): + def wrapper(*args,**kwargs): + tracemalloc.start() + st = time.time() + result=func(*args,**kwargs) + print(f'\nRESOURCES USED by {func.__name__} function ') + print("Memory peak:",((10**(-9))*tracemalloc.get_traced_memory()[1],"GB")) + rt = time.time() - st + tracemalloc.stop() + print("Runtime:",f"{rt // 60:.0f}m {rt % 60:.0f}s") + return result + return wrapper + + +def pyramidal_levels(img_arr,sub_levels,dims_schedule=None): + + if dims_schedule is None: + down_factor=2 + height,width=img_arr.shape + factor_schedule=[ down_factor**i for i in range(1,sub_levels+1) ] + dims_schedule= [np.rint([height/f,width/f]) for f in factor_schedule] + else: + down_factor=np.rint( + np.mean( [ dims_schedule[i][0]/dims_schedule[i+1][0] + for i in range(0,len(dims_schedule)-1)] + ) + ).astype("int") + + ref_dtype=img_arr.dtype.name + val_range=(np.min(img_arr), np.max(img_arr)) + std_dev=np.ceil((down_factor - 1)/2) + img_aux=img_arr + for dims in dims_schedule: + img_dask=da.from_array(img_aux,chunks="auto") + result=gaussian_filter(img_dask, sigma=std_dev, order=0,truncate=down_factor) + img_aux=resize(img_as_float32(result.compute()), dims, order=1, preserve_range=True, anti_aliasing=False) + img_aux=np.rint(rescale_intensity(img_aux,out_range=val_range) ).astype(ref_dtype) + yield img_aux + + +def pyramid_save_ram(img_arr,sub_levels): + chunksize=(2048,2048)#This value was found to be optimal, in terms of balanced RAM and runtime, for uint16 data + down_factor=2 + #TODO:need to optimize speed and match dimensions of levels with those of the src image + ref_dtype=img_arr.dtype.name + border_overlap=down_factor-1 + max_val=np.max(img_arr) + min_val=np.min(img_arr) + ref_dtype=img_arr.dtype.name + rescale_args={ + "scale":1/down_factor, + "order":1, + "preserve_range":True, + "anti_aliasing":True + } + img_aux=img_arr + for _ in range(sub_levels): + img_chunk=da.from_array(img_aux,chunks=chunksize) + float_half=img_chunk.astype("float32") + dim_rescale=da.map_overlap(rescale,float_half,depth=border_overlap,boundary="reflect",**rescale_args) + img_aux=(da.rint(dim_rescale).astype(ref_dtype)).compute() + #TODO: check why all the lines below corrupt the image intensity. + #img_aux_persist=dim_rescale.astype(ref_dtype).persist() + #img_aux=img_aux_persist.compute() + #level_max=np.max(img_aux) + #level_min=np.min(img_aux) + #int_rescale_factor=(max_val-min_val)/(level_max-level_min) + #img_aux=(da.rint(min_val-img_aux_persist*int_rescale_factor).astype(ref_dtype) ).compute() + yield img_aux + + +def process_markers(markers): + markers['ind'] = range(0, len(markers)) + if 'remove' not in markers: + markers['remove'] = ["False" for i in range(len(markers))] + else: + markers['remove'] = markers['remove'] == True + + markers['keep'] = markers['remove'] == False + + markers = markers.drop(columns=['remove']) + + markers.insert(markers.shape[1], "processed", ~ markers.background.isnull()) + + scaling_factor=np.full(markers.shape[0],np.nan) + background_idx=np.full(markers.shape[0],np.nan) + + for channel in range(len(markers)): + + if markers.processed[channel]: + bg_idx = markers.loc[ markers.marker_name == markers.background[channel],"ind" ].tolist() + + if len(bg_idx)>1: + print(f"""Warning: Background channel with name {markers.background[channel]} + appears several times in the column "marker_name". + Only the first occurrence will be used for the subtraction.""" ) + bg_idx=bg_idx[0] + scaling_factor[channel] = markers.exposure[channel] / markers.exposure[bg_idx] + background_idx[channel] = bg_idx + + markers.insert(markers.shape[1], "factor", scaling_factor) + markers.insert(markers.shape[1], "bg_idx", background_idx) + + return markers + + +def extract_img_props(img_path,pixel_size=None): + + #Extract data_type, pyramidal specs, height, width + with tifff.TiffFile(img_path) as tif: + pyr_levels=len(tif.series[0].levels) + is_pyramid=pyr_levels > 1 + data_type=tif.series[0].dtype.name + height,width=tif.series[0].shape[-2::] + dask_chunksize=da.from_array(tif.pages[0].asarray(), chunks='auto').chunksize + if is_pyramid: + #dimensions of the reduced resolution layers(subresolution_dimensions) + subres_dims=[tif.series[0].levels[lvl].pages[0].shape + for lvl in range(1,pyr_levels) + ] + else: + subres_dims=None + + #Try to extract pixel size from ome-xml + if pixel_size is None: + print('Pixel size not specified in the arguments (-mpp)') + try: + metadata = ome_types.from_tiff(img_path) + pixel_size = metadata.images[0].pixels.physical_size_x + pixel_size_unit = metadata.images[0].pixels.physical_size_x_unit + except Exception as err: + print(err) + print('Pixel size or pixel size unit detection using ome-types failed') + pixel_size = 1 + pixel_size_unit="pixel" + else: + pixel_size=pixel_size + pixel_size_unit="µm" + + + img_props={"pixel_size":pixel_size, + "pixel_size_unit":pixel_size_unit, + "data_type":data_type, + "pyramid":is_pyramid, + "levels":pyr_levels, + "sub_levels_dims":subres_dims, + "size_x":width, + "size_y":height , + "chunksize":dask_chunksize + } + + return img_props + + +def subtract_channels(src_img_path, + signal_index, + background_index, + factor, + ref_chunksize, + ref_dtype, + task_no + ): + """ + This function executes the background substraction using generators, each element of the generator + is a tuple with 3 values, such that: + + tuple=(img_with_backsub[array],calculate or extract pyramid [str],pyramid_from_index[int]) + + The second entry of the tuple indicates in the writing process if the pyramid should be calculated using + pyramid_gaussian from scikit image. If extract, the index given in the third entry will fetch all the pyramid + levels from the original image stack(src_img_path). + """ + factor=np.float32(factor)#limiting precision to float32 saves memory + signal_as_zarr = zarr.open( tifff.imread( src_img_path, aszarr=True, series=0, level=0,key=int(signal_index) ) ) + background_as_zarr =zarr.open( tifff.imread( src_img_path, aszarr=True, series=0, level=0,key=int(background_index) ) ) + signal=da.from_zarr(signal_as_zarr, chunks=ref_chunksize ) + background=da.from_zarr(background_as_zarr, chunks=ref_chunksize) + subtraction=da.clip(signal-factor*background,0,65535).astype(ref_dtype) + with ResourceProfiler(dt=0.25) as resources: + with ProgressBar(): + result=subtraction.compute() + print(f"Resources used by dask during subtraction {task_no}:") + print(resources.results[0],"([sec],[MB],[% CPU usage])") + return result + +def extract_sublevels_from_tiff(path,ch,levs): + with tifff.TiffFile(path) as tif: + for l in range(1,levs): + yield tif.series[0].levels[l].pages[ch].asarray() + +def write_pyramid(src_img_path, + tasks_table, + outdir, + levels, + sub_lvls_dims, + file_name, + src_data_type, + is_src_pyramid=False, + save_ram=False + ): + + outdir.mkdir(parents=True, exist_ok=True) + out_file_path=outdir / file_name + sub_levels=levels-1 + + total_operations=tasks_table['processed'].values.sum()#Count True values + count=1 + + with tifff.TiffWriter(out_file_path,bigtiff=True) as tif: + #write first the original resolution image,i.e. first layer + for _,channel in tasks_table.iterrows(): + if channel.processed: + operation_count=f"({count}/{total_operations})" + print(f"\n {operation_count} Calculating subtraction of background {channel.background} from {channel.marker_name} signal:") + first_layer=subtract_channels(src_img_path, channel.ind, channel.bg_idx, channel.factor, (4096,4096), src_data_type,operation_count) + pyramid_action="calculate" + count+=1 + else: + first_layer=tifff.imread(src_img_path,series=0,level=0,key=int(channel.ind)) + + if (save_ram or not is_src_pyramid): + pyramid_action="calculate" + else: + pyramid_action="extract" + + tif.write( + first_layer, + subifds=sub_levels, + tile=(256, 256), + photometric='minisblack', + compression="lzw" + ) + + if pyramid_action=="calculate": + if save_ram: + pyramid=pyramid_save_ram(first_layer,sub_levels) + else: + pyramid=pyramidal_levels(first_layer,sub_levels,sub_lvls_dims) + + elif pyramid_action=="extract": + pyramid=extract_sublevels_from_tiff(src_img_path,int(channel.ind),levels) + + + for sub_layer in pyramid: + tif.write( + sub_layer, + subfiletype=1, + tile=(256, 256), + photometric='minisblack', + compression="lzw"#lzw works better when saving channel-by-channel and jpeg 2000 when saving the whole stack at once + ) + + return out_file_path + +@memocron +def main(version): + args=CLI.get_args() + in_path = args.root + out_path = args.output + + # 0) Validate input_path is not the same as output_path,pixel data is read into RAM lazily, cannot overwrite input file + assert out_path != in_path + + # 1) Extract image properties + src_props = extract_img_props(in_path, args.pixel_size,) + # 2) Modify pyramid_levels if required + if src_props["pyramid"]: + levels=src_props["levels"] + else: + levels=args.pyramid_levels + + # 3) Read/Create markers table and update it to include the information of the processing tasks + if args.comet_metadata: + registration_marker="DAPI" + meta_table=meta_from_file(in_path,registration_marker) + markers = process_markers( assign_background(meta_table,rmv_ref=True,ref_marker=registration_marker) ) + + elif args.markers: + markers = process_markers(pd.read_csv(args.markers)) + + markers_updated=markers.loc[ markers.keep] + #4) Write updated markers.csv without appended columns. This file contains the markers information of the final image stack + markers_preview = markers_updated.drop(columns=['keep','ind','processed','factor','bg_idx']) + markers_preview["channel_number"] = np.arange(1, len(markers_preview)+1) + markers_preview.to_csv(args.markerout, index=False) + + logger.info("\nTASKS PREVIEW:\n{}",markers_updated) + tasks=1 + for _,channel in markers_updated.iterrows(): + if channel.processed: + print(f"\n(Task_{tasks}): background subtraction, Channel {channel.marker_name} (Background {channel.background})") + tasks+=1 + + #5) Calculate subtractions and write output file + out_file_name=f'{ (in_path.stem).split(".ome")[0] }_backsub.ome.tif' + logger.info(f"\nTASKS PROGRESS" ) + + print(f"\nCommencing writing of pyramidal ome.tif file into {out_path / out_file_name}") + print(f"\nCommencing subtraction tasks\n") + + pyramid_abs_path=write_pyramid( + in_path, + markers_updated, + out_path, + levels, + src_props["sub_levels_dims"], + out_file_name, + src_props["data_type"], + is_src_pyramid=src_props["pyramid"], + save_ram=args.save_ram + ) + + #6) Write metadata in OME format into the pyramidal file + channel_names=markers_updated["marker_name"].tolist() + ome_xml=ome_writer.create_ome(channel_names,src_props,version) + tifff.tiffcomment(pyramid_abs_path, ome_xml.encode("utf-8")) + + + + logger.info(f'\nSCRIPT FINISHED PROCESSING TASKS ') + print(f'\nPyramidal image with {levels} levels was successfully written ') + + + + +if __name__ == '__main__': + _version = 'v0.5.0' + main(_version) + + + + + + diff --git a/backsub/metadata2markers_table.py b/backsub/metadata2markers_table.py new file mode 100644 index 0000000..2451cb8 --- /dev/null +++ b/backsub/metadata2markers_table.py @@ -0,0 +1,129 @@ +import pathlib +from ome_types import from_tiff +import argparse +import pandas as pd +import numpy as np + +#CLI +def get_args(): + parser=argparse.ArgumentParser() + parser.add_argument('-i', + '--input_img', + required=True, + type=pathlib.Path, + help='absolute path of the input image stack (.tif)' + ) + + parser.add_argument('-o', + '--output_dir', + required=True, + type=pathlib.Path, + help='absolute path of the directory where the output .csv file will be written' + ) + + parser.add_argument('-fn', + '--output_file_name', + required=False, + type=str, + default="markers.csv", + help='name of the csv file' + ) + + parser.add_argument('-rr', + '--remove_background_references', + required=False, + action='store_true', + help='setup the removal of all reference background channels and all DAPI except the first occurrence.' + ) + + parser.add_argument('-rm', + '--registration_marker', + required=False, + type=str, + default="DAPI", + help='name of the csv file' + ) + + + args=parser.parse_args() + return args + +def meta_from_file(src_img_path,ref_marker_name): + #Fetch metadata object + ome=from_tiff(src_img_path) + #Fetch image attributes from ome + ch_names = [ element.name for element in ome.images[0].pixels.channels ] + exp_times = [ element.exposure_time for element in ome.images[0].pixels.planes ] + #cycles=[int(element.attributes["CycleID"])+1 for element in ome.structured_annotations[0].value.any_elements[0].children] + filters= [ element.attributes["FluorescenceChannel"] for element in ome.structured_annotations[0].value.any_elements[0].children ] + background=[None if ref_marker_name in element + else element for element in filters] + + aux_dict={"channel_number":list(range(1,len(ch_names)+1)), + #"cycle_number":cycles, + "marker_name":ch_names, + "Filter":filters, + "background":background, + "exposure":exp_times + } + + df=pd.DataFrame(aux_dict) + return df + + +def assign_background(df,rmv_ref=False,ref_marker="DAPI"): + #Create column ["backsub_process"] indicating which rows will be processed with backsub + filters_=df.Filter.unique().tolist() + #Strings corresponding to filters/background names are set to False, since the are not processed + backsub_process=df["marker_name"].replace(filters_,value=False,regex=True) + #Marker_name corresponding to signal will be set to True for processing + backsub_process=np.where(backsub_process==False,False,True) + df.insert(df.shape[1],"backsub_process",backsub_process) + + #Assign the latest mention of the autofluorescence channel to the correspondent row in the background column + rename_background=[] + #List with row indices of background channels to be removed + + for idx,row in df.iterrows(): + + if row.backsub_process: + previous_channels=reversed(df.iloc[:idx].marker_name.to_list()) + for element in previous_channels: + #Supposes background name is a subset of the channel/marker name + if row.background in element: + rename_background.append(element) + break + else: + rename_background.append(None) + + df.drop(columns=["backsub_process"],inplace=True) + df.background=rename_background + if rmv_ref: + remove_val=len(df)*[""] + df.insert(df.shape[1],"remove",remove_val) + df.loc[ df["background"].isnull() , ["remove"] ]="TRUE" + first_ref_marker=df[df.marker_name== ref_marker].index[0] + df.loc[first_ref_marker,"remove"]="" + return df + + + +def main(): + args=get_args() + img_path=args.input_img + out_dir=args.output_dir + file_name=args.output_file_name + global_ref_marker=args.registration_marker + + df=meta_from_file(img_path,global_ref_marker) + df_updated=assign_background(df,args.remove_background_references,global_ref_marker) + df_updated.to_csv( out_dir/file_name ,index=False) + + +if __name__ == '__main__': + main() + + + + + diff --git a/backsub/ome_schema.py b/backsub/ome_schema.py new file mode 100644 index 0000000..4c19bb9 --- /dev/null +++ b/backsub/ome_schema.py @@ -0,0 +1,166 @@ +#!/usr/bin/python +import ome_types +from ome_types.model import OME,Image,Pixels,TiffData,Channel,Plane +import platform + + +def INPUTS(frame): + """ + This function creates a dictionary with the metadata of the tiles. + Args: + frame (pd.DataFrame): dataframe containing the metadata of the tiles. + conformed_markers (list): list of tuples with the name of the markers and their corresponding fluorophore. + Returns: + dict: dictionary with the metadata of the tiles. + """ + inputs=frame.to_dict('list') + + return inputs + + +def TIFF_array(no_of_channels, inputs={'offset':0}): + """ + This function creates a list of TIFFData objects. + Args: + no_of_channels (int): number of channels. + inputs (dict): dictionary with the metadata of the tiles. + Returns: + list: list of TIFFData objects. + """ + TIFF = [ + TiffData( + first_c=ch, + ifd=n, + plane_count=1 + ) + for n,ch in enumerate(range(0,no_of_channels), start=inputs['offset']) + ] + + return TIFF + + +def PLANE_array(no_of_channels, inputs): + """ + This function creates a list of Plane objects. + Args: + no_of_channels (int): number of channels. + inputs (dict): dictionary with the metadata of the tiles. + Returns: + list: list of Plane objects. + """ + + PLANE = [ + Plane( + the_c=ch, + the_t=0, + the_z=0, + position_x= inputs['position_x'][ch] if 'position_x' in inputs.keys() else 0, + position_y= inputs['position_y'][ch] if 'position_y' in inputs.keys() else 0, + position_z=0, + position_x_unit= inputs['position_x_unit'][ch] if 'position_x_unit'in inputs.keys() else "pixel" , + position_y_unit= inputs['position_y_unit'][ch] if 'position_y_unit'in inputs.keys() else "pixel" + ) + for ch in range(0,no_of_channels) + ] + + return PLANE + + +def CHANN_array(no_of_channels, inputs): + """ + This function creates a list of Channel objects. + Args: + no_of_channels (int): number of channels. + inputs (dict): dictionary with the metadata of the tiles. + Returns: + list: list of Channel objects. + """ + + CHANN = [ + Channel( + id=f"Channel:{str(ch)}", # 'Channel:{y}:{x}:{marker_name}'.format(x=ch,y=100+int( inputs['tile'][ch] ) ,marker_name=inputs['marker'][ch] ) + name=inputs["name"][ch], + color=(255,255,255) + ) + for ch in range(0,no_of_channels) + ] + + return CHANN + + +def PIXELS_array(chann_block, plane_block, tiff_block, inputs): + """ + This function creates a Pixels object. + Args: + chann_block (list): list of Channel objects. + plane_block (list): list of Plane objects. + tiff_block (list): list of TIFFData objects. + inputs (dict): dictionary with the metadata of the tiles. + Returns: + Pixels: Pixels object. + """ + + PIXELS = Pixels( + id=f"Pixels:{inputs['tile'][0]}", + dimension_order='XYCZT', + size_c=len(chann_block), + size_t=1, + size_x=inputs['size_x'][0], + size_y=inputs['size_y'][0], + size_z=1, + type=inputs['type'][0],#bit_depth + big_endian=False, + channels=chann_block, + interleaved=False, + physical_size_x=inputs['physical_size_x'][0], + physical_size_x_unit=inputs['physical_size_x_unit'][0], + physical_size_y=inputs['physical_size_y'][0], + physical_size_y_unit=inputs['physical_size_y_unit'][0], + physical_size_z=1.0, + planes=plane_block, + significant_bits=inputs['significant_bits'][0], + tiff_data_blocks=tiff_block + ) + + return PIXELS + + +def IMAGE_array(pixels_block, imageID): + """ + This function creates an Image object. + Args: + pixels_block (Pixels): Pixels object. + imageID (int): identifier of the image. + Returns: + Image: Image object. + """ + + IMAGE = Image( + id =f'Image:{imageID}', + pixels=pixels_block + ) + + return IMAGE + + +def OME_metadata(image_block,software): + """ + This function creates an OME object. + Args: + image_block (list): list of Image objects. + Returns: + OME: OME object. + """ + ome = OME() + ome.creator = " ".join([software, + ome_types.__name__, + ome_types.__version__, + '/ python version-', + platform.python_version() + ] + ) + + ome.images = image_block + ome_xml = ome_types.to_xml(ome) + + return ome, ome_xml diff --git a/backsub/ome_writer.py b/backsub/ome_writer.py new file mode 100644 index 0000000..ba698ff --- /dev/null +++ b/backsub/ome_writer.py @@ -0,0 +1,44 @@ +import ome_schema as schema +import pandas as pd + + +def create_ome(conformed_markers,info,software_version): + """ + This function creates an OME-XML file from a pandas dataframe containing the metadata of the tiles. + Args: + tile_info (pd.DataFrame): dataframe containing the metadata of the tiles. + conformed_markers (list): list with the name of the markers in the corresponding order of their appearance in the ome.tif file . + Returns: + str: OME-XML file. + """ + software=f'backsub {software_version}' + no_of_channels = len(conformed_markers) + tile_info_dict={ + "tile": no_of_channels *[1], + "name":conformed_markers , + "type": no_of_channels *[ info["data_type"] ], + "size_x":no_of_channels *[ info["size_x"] ] , + "size_y":no_of_channels *[info["size_y"]], + "physical_size_x": no_of_channels *[info["pixel_size"]], + "physical_size_x_unit": no_of_channels *[info["pixel_size_unit"]], + "physical_size_y": no_of_channels *[info["pixel_size"]], + "physical_size_y_unit": no_of_channels *[info["pixel_size_unit"]], + "significant_bits": no_of_channels *["16"] + } + tile_info=pd.DataFrame(tile_info_dict) + + grouped_tiles = tile_info.groupby(['tile']) + + tiles_counter = 0 + image = [] + for tileID, frame in grouped_tiles: + metadata = schema.INPUTS(frame) + tiff = schema.TIFF_array(no_of_channels, inputs={'offset': no_of_channels * tiles_counter}) + plane = schema.PLANE_array(no_of_channels, metadata) + channel = schema.CHANN_array(no_of_channels, metadata) + pixels = schema.PIXELS_array(channel, plane, tiff, metadata) + image.append(schema.IMAGE_array (pixels, tiles_counter)) + tiles_counter += 1 + ome, ome_xml = schema.OME_metadata(image,software) + + return ome_xml diff --git a/environment.yml b/environment.yml index 957a760..db53f01 100644 --- a/environment.yml +++ b/environment.yml @@ -2,17 +2,15 @@ name: backsub channels: - conda-forge - defaults - - anaconda dependencies: - - "python=3.9" - - "openslide=3.4.1" - - "scikit-image=0.19.2" - - "numexpr=2.8.3" - - "tifffile=2022.8.12" - - "scipy=1.9.3" - - "pandas=2.1.1" - - "zarr=2.3.2" - - procps-ng - - pip + - python=3.12.11 + - pandas=2.3.1 + - tifffile=2025.6.11 + - scikit-image=0.25.2 + - dask=2025.7.0 + - ome-types=0.6.0 + - numpy=2.3.2 + - loguru=0.7.3 + - dask-image=2024.5.3 - pip: - - palom \ No newline at end of file + - "zarr==3.1.1" \ No newline at end of file From f9dde2a910c7b0459f7a95c06b3c58b119d6c9d8 Mon Sep 17 00:00:00 2001 From: Kresimir Bestak Date: Mon, 3 Nov 2025 15:01:42 +0100 Subject: [PATCH 02/21] moved zarr under conda dependencies --- environment.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/environment.yml b/environment.yml index db53f01..1c46961 100644 --- a/environment.yml +++ b/environment.yml @@ -1,4 +1,4 @@ -name: backsub +name: backsub_testing channels: - conda-forge - defaults @@ -12,5 +12,4 @@ dependencies: - numpy=2.3.2 - loguru=0.7.3 - dask-image=2024.5.3 - - pip: - - "zarr==3.1.1" \ No newline at end of file + - zarr=3.1.1 \ No newline at end of file From 20bcc1da97704486236c9cbb18cac44626fac2d5 Mon Sep 17 00:00:00 2001 From: Kresimir Bestak Date: Mon, 3 Nov 2025 15:04:10 +0100 Subject: [PATCH 03/21] tile_size, downscale_factor, output path --- .gitignore | 1 + backsub/CLI.py | 51 +++++++++++++++++++---- backsub/background_sub.py | 88 ++++++++++++++++++++++----------------- backsub/ome_writer.py | 22 +++++----- 4 files changed, 104 insertions(+), 58 deletions(-) create mode 100644 .gitignore mode change 100644 => 100755 backsub/background_sub.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ed8ebf5 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__ \ No newline at end of file diff --git a/backsub/CLI.py b/backsub/CLI.py index 932292e..b5d6d84 100644 --- a/backsub/CLI.py +++ b/backsub/CLI.py @@ -54,19 +54,42 @@ def get_args(): help="pixel size in microns,i.e. microns per pixel(mpp)" ) - inputs.add_argument("-pl", - "--pyramid_levels", - dest="pyramid_levels", + # inputs.add_argument("-pl", + # "--pyramid_levels", + # dest="pyramid_levels", + # required=False, + # type=int, + # default=8, + # help="""Total number of pyramid levels. + # This value will be only used if the input image is NOT pyramidal. + # If input image is pyramidal, the number of levels in the output image + # will be the same as in the input. + # """ + # ) + + inputs.add_argument("-ts", + "--tile_size", + dest="tile_size", + required=False, + type=int, + default=256, + help="""Tile size for image pyramid creation. Has to be a multiple of 16. + """ + ) + + inputs.add_argument("-dsf", + "--downscale_factor", + dest="downscale_factor", required=False, type=int, - default=8, - help="""Total number of pyramid levels. + default=2, + help="""Downscale factor for the image pyramid. This value will be only used if the input image is NOT pyramidal. If input image is pyramidal, the number of levels in the output image - will be the same as in the input. + will be the same as in the input so the downscale factor is not applied. """ ) - + inputs.add_argument('-sr', '--save_ram', action='store_true', @@ -75,10 +98,22 @@ def get_args(): the output pyramidal image will slightly differ when using and not using this argument. """ ) + + inputs.add_argument( + '--compression', + dest='compression', + required=False, + type=str, + default='LZW', + help="""If set, the output pyramidal image will be compressed using the specified + compression method. Set to "none" for no compression. Default is LZW. + """ + ) + #VERSION CONTROL inputs.add_argument("--version", action="version", - version="v0.5.0" + version="v0.5.0dev" ) #OUTPUTS diff --git a/backsub/background_sub.py b/backsub/background_sub.py old mode 100644 new mode 100755 index 76f2a69..73e116b --- a/backsub/background_sub.py +++ b/backsub/background_sub.py @@ -1,3 +1,5 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- #standard libraries import pandas as pd import numpy as np @@ -125,7 +127,7 @@ def process_markers(markers): return markers -def extract_img_props(img_path,pixel_size=None): +def extract_img_props(img_path,downscale_factor,tile_size,pixel_size=None): #Extract data_type, pyramidal specs, height, width with tifff.TiffFile(img_path) as tif: @@ -141,6 +143,11 @@ def extract_img_props(img_path,pixel_size=None): ] else: subres_dims=None + + if downscale_factor <= 1: + raise ValueError("downscale_factor must be greater than 1") + max_dim = max(height,width) + extracted_levels = int(np.floor(np.log(max_dim / tile_size) / np.log(downscale_factor)) + 1) #Try to extract pixel size from ome-xml if pixel_size is None: @@ -164,6 +171,7 @@ def extract_img_props(img_path,pixel_size=None): "data_type":data_type, "pyramid":is_pyramid, "levels":pyr_levels, + "extracted_levels": max(extracted_levels, 1), "sub_levels_dims":subres_dims, "size_x":width, "size_y":height , @@ -204,30 +212,32 @@ def subtract_channels(src_img_path, print(resources.results[0],"([sec],[MB],[% CPU usage])") return result -def extract_sublevels_from_tiff(path,ch,levs): +def extract_sublevels_from_tiff(path,ch,levels): with tifff.TiffFile(path) as tif: - for l in range(1,levs): + for l in range(1,levels): yield tif.series[0].levels[l].pages[ch].asarray() def write_pyramid(src_img_path, tasks_table, - outdir, + out_file, levels, sub_lvls_dims, - file_name, src_data_type, + pyramid_tile_shape, + compression, is_src_pyramid=False, save_ram=False ): - outdir.mkdir(parents=True, exist_ok=True) - out_file_path=outdir / file_name sub_levels=levels-1 - total_operations=tasks_table['processed'].values.sum()#Count True values + total_operations=tasks_table['processed'].values.sum() #Count True values count=1 - with tifff.TiffWriter(out_file_path,bigtiff=True) as tif: + if compression == 'none': + compression=None + + with tifff.TiffWriter(out_file,bigtiff=True) as tif: #write first the original resolution image,i.e. first layer for _,channel in tasks_table.iterrows(): if channel.processed: @@ -245,12 +255,12 @@ def write_pyramid(src_img_path, pyramid_action="extract" tif.write( - first_layer, - subifds=sub_levels, - tile=(256, 256), - photometric='minisblack', - compression="lzw" - ) + first_layer, + subifds=sub_levels, + tile=pyramid_tile_shape, + photometric='minisblack', + compression=compression + ) if pyramid_action=="calculate": if save_ram: @@ -266,12 +276,12 @@ def write_pyramid(src_img_path, tif.write( sub_layer, subfiletype=1, - tile=(256, 256), + tile=pyramid_tile_shape, photometric='minisblack', - compression="lzw"#lzw works better when saving channel-by-channel and jpeg 2000 when saving the whole stack at once - ) + compression=compression#lzw works better when saving channel-by-channel and jpeg 2000 when saving the whole stack at once + ) - return out_file_path + return out_file @memocron def main(version): @@ -283,12 +293,18 @@ def main(version): assert out_path != in_path # 1) Extract image properties - src_props = extract_img_props(in_path, args.pixel_size,) + if args.tile_size % 16 != 0: + raise ValueError("tile_size has to be a multiple of 16") + tile_shape = (args.tile_size, args.tile_size) + src_props = extract_img_props(in_path, + args.downscale_factor, + args.tile_size, + args.pixel_size) # 2) Modify pyramid_levels if required if src_props["pyramid"]: levels=src_props["levels"] else: - levels=args.pyramid_levels + levels=src_props["extracted_levels"] # 3) Read/Create markers table and update it to include the information of the processing tasks if args.comet_metadata: @@ -313,20 +329,25 @@ def main(version): tasks+=1 #5) Calculate subtractions and write output file - out_file_name=f'{ (in_path.stem).split(".ome")[0] }_backsub.ome.tif' + out_file=args.output + logger.info(f"\nTASKS PROGRESS" ) - print(f"\nCommencing writing of pyramidal ome.tif file into {out_path / out_file_name}") + print(f"\nCommencing writing of pyramidal ome.tif file into {out_path}\n") print(f"\nCommencing subtraction tasks\n") - + if args.compression=='none': + compression=None + else: + compression=args.compression pyramid_abs_path=write_pyramid( in_path, markers_updated, - out_path, + out_file, levels, src_props["sub_levels_dims"], - out_file_name, src_props["data_type"], + tile_shape, + compression, is_src_pyramid=src_props["pyramid"], save_ram=args.save_ram ) @@ -335,21 +356,10 @@ def main(version): channel_names=markers_updated["marker_name"].tolist() ome_xml=ome_writer.create_ome(channel_names,src_props,version) tifff.tiffcomment(pyramid_abs_path, ome_xml.encode("utf-8")) - - logger.info(f'\nSCRIPT FINISHED PROCESSING TASKS ') - print(f'\nPyramidal image with {levels} levels was successfully written ') - - - + print(f'\nPyramidal image with {levels} levels was successfully written{'' if compression is None else f' with {compression} compression'}.') if __name__ == '__main__': - _version = 'v0.5.0' + _version = 'v0.5.0dev' main(_version) - - - - - - diff --git a/backsub/ome_writer.py b/backsub/ome_writer.py index ba698ff..44f2447 100644 --- a/backsub/ome_writer.py +++ b/backsub/ome_writer.py @@ -14,17 +14,17 @@ def create_ome(conformed_markers,info,software_version): software=f'backsub {software_version}' no_of_channels = len(conformed_markers) tile_info_dict={ - "tile": no_of_channels *[1], - "name":conformed_markers , - "type": no_of_channels *[ info["data_type"] ], - "size_x":no_of_channels *[ info["size_x"] ] , - "size_y":no_of_channels *[info["size_y"]], - "physical_size_x": no_of_channels *[info["pixel_size"]], - "physical_size_x_unit": no_of_channels *[info["pixel_size_unit"]], - "physical_size_y": no_of_channels *[info["pixel_size"]], - "physical_size_y_unit": no_of_channels *[info["pixel_size_unit"]], - "significant_bits": no_of_channels *["16"] - } + "tile": no_of_channels *[1], + "name":conformed_markers , + "type": no_of_channels *[ info["data_type"] ], + "size_x":no_of_channels *[ info["size_x"] ] , + "size_y":no_of_channels *[info["size_y"]], + "physical_size_x": no_of_channels *[info["pixel_size"]], + "physical_size_x_unit": no_of_channels *[info["pixel_size_unit"]], + "physical_size_y": no_of_channels *[info["pixel_size"]], + "physical_size_y_unit": no_of_channels *[info["pixel_size_unit"]], + "significant_bits": no_of_channels *["16"] + } tile_info=pd.DataFrame(tile_info_dict) grouped_tiles = tile_info.groupby(['tile']) From be0fb0d2dea7da1d577186b80302e866bda4a392 Mon Sep 17 00:00:00 2001 From: Kresimir Bestak Date: Wed, 5 Nov 2025 15:24:10 +0100 Subject: [PATCH 04/21] moved input_markers_table under inptus --- backsub/CLI.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backsub/CLI.py b/backsub/CLI.py index b5d6d84..0a0ab01 100644 --- a/backsub/CLI.py +++ b/backsub/CLI.py @@ -17,7 +17,7 @@ def get_args(): # INPUT GROUPS inputs = parser.add_argument_group(title="INPUTS") - input_markers_table = parser.add_mutually_exclusive_group(required=True) + input_markers_table = inputs.add_mutually_exclusive_group(required=True) inputs.add_argument("-r", "--root", @@ -27,7 +27,7 @@ def get_args(): required=True, help="File path to root image file.") - input_markers_table.add_argument("-m", + input_markers_table.add_argument( "--markers", dest="markers", action="store", From ec82904a17eb4d187c31622cd6c78660bce9440e Mon Sep 17 00:00:00 2001 From: Kresimir Bestak Date: Wed, 5 Nov 2025 17:13:09 +0100 Subject: [PATCH 05/21] Restructured README --- CHANGELOG.md | 30 +++++- README.md | 175 +++++++++++++++++++++++--------- example/application_example.png | Bin 0 -> 1376963 bytes 3 files changed, 151 insertions(+), 54 deletions(-) create mode 100644 example/application_example.png diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b25f54..5c32d88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,15 +3,34 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## v0.5.0 - [2025.11.##] + +Rework of Backsub to not have Palom as a dependency reducing the environment size and making it lightweight, and reducing the output file size, while keeping the time and memory usage efficiency. + +### `Added` +- `compression` parameter +- hidden argument `comet`, which extracts the metadata on-the-fly for Lunaphore COMET images. When using this argument, the `markers.csv` file is not required. +- two RAM profiles: (1) default, uses moderate RAM. (2) Uses approximately half of the default RAM at the cost of a slight loss in precision of the calculation of the downsized dimensions of the pyramidal output image. This means the dimensions of the pyramidal level will differ between profile 1 and 2. The high-resolution level is not affected by this. +- organizes the tool in five scripts: (1) CLI, (2) ome-schema structure, (3) ome-schema writer, (4) background substraction and writing of output image and (5) extraction of metadata from Lunaphore Comet images. +- logger has been re-designed. +- restructured README + +### `Fixed` +- output image file-size is reduced by applying lossless compression ("LZW" by default) + +### `Removed` +- Palom and OpenCV as dependencies + + + ## v0.4.1 - [2023.11.21] -Complete rework of Backsub to include Palom's pyramid writer (https://github.com/labsyspharm/palom). -Added dask array chunking and delayed execution for subtraction that happenes while the output pyramidal `ome.tif` is being created. -Added `CHANGELOG.md`. +The script has been rewritten to perform channel subtraction in a RAM-efficient manner - updating is highly recommended. If the output file is much bigger than expected, adjust the `--tile-size` parameter to a smaller value (e.g `512`). Changing the `--chunk-size` parameter may affect performance (lower values increase execution time, higher values increase RAM usage). ### `Added` -- `--chunk-size` parameter +- `--chunk-size` parameter for dask array chunking and delayed execution for subtraction that happens while the output pyramidal OME-TIFF is being created. - Palom's pyramid writer +- `CHANGELOG.md` ### `Fixed` - Fixed issue with RAM inefficiency - reworked Backsub. @@ -19,5 +38,6 @@ Added `CHANGELOG.md`. ### `Removed` - `--pyramid` tag introduced in v0.3.4, for smaller images, a smaller tile size should be specified now. +## Versions v0.2.0 and older: -I did not keep a changelog before version v0.4.1. \ No newline at end of file +The `markers.csv` file which gives details about the channels needs to contain the following columns: "Filter", "background" and "exposure". An exemplary [markers_old.csv](https://github.com/SchapiroLabor/Background_subtraction/files/9549686/markers.csv) file is given. The "Filter" column should specify the Filter used when acquiring images. If different stains are aquired with the same filter, the *exact same value* needs to be written (including background) as it is used for determining which background channel should be subtracted. The "background" column should contain logical `TRUE` values for channels which represent autofluorescence. The "exposure" column should contain the exposure time used for channel acquisition, and the measure unit should be consistent across the column. Exposure time is used for scaling the value of the background to be comparable to the processed channel. Usage of these versions is strongly disencouraged. \ No newline at end of file diff --git a/README.md b/README.md index f970494..91ff0d0 100644 --- a/README.md +++ b/README.md @@ -1,83 +1,160 @@ -# Background_subtraction +# Backsub - pixel-by-pixel channel subtraction tool for multiplexed immunofluorescence data -Pixel-by-pixel channel subtraction scaled by exposure times, primarily developed for images produced by the COMET platform and to work within the MCMICRO pipeline. Main usecase is autuofluorescence subtraction for multichannel and multicycle images for visualization of images from tissues with high autofluroescence (FFPE), improved segmentation, and quantification (if the previous two usecases aren't necessary, downstream subtraction of autofluorescent signal is encouraged as the script is memory inefficent). +Backsub performs pixel-by-pixel background subtraction between marker and background channels scaled by their respective exposure times. The outputs are saved as pyramidal OME-TIFF files. It was originally developed for data produced by the Lunaphore COMET platform and is fully compatible with the [MCMICRO](https://mcmicro.org) pipeline. + + +Example of pixel-wise autofluorescence subtraction with Backsub: + +
## Introduction -If there are background (autofluorescence) channels present in a `.tif` image, background subtraction should be performed so as not to skew the quantification counts of markers. The most precise way of subtracting background would be on a pixel-to-pixel basis. An alternative would be on a cell basis by just subtracting the background measurements from the marker measurements for each cell, however, for visual inspection of images, as well as future use of images as figures in published work, it is preferred to use this. +In multiplexed immunofluorescence images, autofluorescence and background signals can cause improper cell segmentation, and can affect downstream intensity quantification which is why, if possible, they should be subtracted from raw channel intensities. The most precise way of subtracting background would be on a pixel-to-pixel basis. An alternative would be to subtract the background measurements from the marker measurements for each cell after quantification, however, for visual inspection of images, segmentation, and data presentation, it is preferred to use the corrected values. -Background subtraction is performed using the following formula: +The primary use case is autofluorescence subtraction for multichannel and multicycle microscopy images to improve: +* image visualization of tissues with strong autofluorescence +* segmentation accuracy +* quantification quality (if the previous two usecases are not necessary, downstream subtraction of autofluorescence signal is encouraged instead) -Marker*corrected* = Marker*raw* - Background / Exposure*Background* * Exposure*Marker* +Background subtraction is performed using the following formula: +$Marker_{corrected} = Marker_{raw} - Background \times \frac{Exposure_{Marker}}{Exposure_{Background}}$ -## Usage +## Installation -The `markers.csv` file which gives details about the channels needs to contain the following columns: "marker_name", "background" and "exposure". An exemplary [markers.csv](https://github.com/SchapiroLabor/Background_subtraction/blob/main/example/markers.csv) file is given. The "marker_name" column should indicate the marker for the acquired channel and all values should be unique. The "background" column should indicate the marker name of the channel which needs to be subtracted. This value must match the "marker_name" value of the background channel. The "exposure" column should contain the exposure time used for channel acquisition, and the measure unit should be consistent across the column. Exposure time is used for scaling the value of the background to be comparable to the processed channel. The "remove" column should contain logical `TRUE` values for channels which should be excluded in the output image. +Backsub can be run either in a preconfigured Docker container or in a local conda environment. -### Version v0.5.10: -This version, which is a rework of v0.4.1, introduces the following features: -* File-size of output image is reduced by using lossless compression ("LZW"). -* Facilitates container creation by prescinding from PALOM and opencv libraries. -* Introduces the hidden argument, `-comet`, which extracts the metadata on-the-fly for Lunaphore Comet images. When using this argument, the `markers.csv` file is not required. -* Two RAM-profiles: (1) default mode, uses moderate RAM. (2) Uses approximately half-of the RAM of profile 1 at the cost of a slight loss in precision of the calculation of the downsized dimensions of the pyramidal output image. This means the dimensions of the pyramidal level will differ between profile 1 and 2, the high-resolution level is not affected by this. -* Organizes the tool in five scripts: (1) CLI, (2) ome-schema structure, (3) ome-schema writer, (4) background substraction and writing of output image and (5) extraction of metadata from Lunaphore Comet images. -* Logger has been re-designed. +### Option 1: Docker +Pull the latest container from the GitHub Container Registry: +``` +docker pull ghcr.io/schapirolabor/background_subtraction:latest +``` +You can then run Backsub directly, mounting your input and output directories: +``` +docker run --rm -v $(pwd):/data ghcr.io/schapirolabor/background_subtraction:latest \ + python background_sub.py \ + -r /data/input_image.tif \ + -o /data/corrected_image.ome.tif \ + -m /data/markers.csv \ + -mo /data/markers_corrected.csv +``` +Note that all required dependencies are already included inside the container. -### Versions v0.4.1 and newer: -The script has been rewritten to perform channel subtraction in a RAM-efficient manner - updating is highly recommended. If the output file is much bigger than expected, adjust the `--tile-size` parameter to a smaller value (e.g `512`). Changing the `--chunk-size` parameter may affect performance (lower values increase execution time, higher values increase RAM usage). +If you want to build the container yourself, clone the repository first, then build it from the provided Dockerfile: +``` +git clone https://github.com/SchapiroLabor/Background_subtraction.git +cd Background_subtraction +docker build -t background_subtraction:latest . +``` +### Option 2: Conda +Clone the repository and create the Conda environment: +``` +git clone https://github.com/SchapiroLabor/Background_subtraction.git +cd Background_subtraction +conda env create -f environment.yml +conda activate backsub_env +``` +You can now run Backsub locally (note that you need to point to the tool's script): +``` +python backsub/background_sub.py -h +``` -### Versions v0.2.0 and older: -The `markers.csv` file which gives details about the channels needs to contain the following columns: "Filter", "background" and "exposure". An exemplary [markers_old.csv](https://github.com/SchapiroLabor/Background_subtraction/files/9549686/markers.csv) file is given. The "Filter" column should specify the Filter used when acquiring images. If different stains are aquired with the same filter, the *exact same value* needs to be written (including background) as it is used for determining which background channel should be subtracted. The "background" column should contain logical `TRUE` values for channels which represent autofluorescence. The "exposure" column should contain the exposure time used for channel acquisition, and the measure unit should be consistent across the column. Exposure time is used for scaling the value of the background to be comparable to the processed channel. +## Execution and usage +### Inputs -### CLI +A `TIFF` or `OME-TIFF` file containing multiplexed immunofluorescence data. -Minimal required arguments: +A `markers.csv` file should be provided to describe the channels of the image. Needs to contain the following columns: -* the path to the input image given with `-r` or `--root` -* the path to the output image given with `-o` or `--output` -* the path to the `markers.csv` file given with `-m` or `--markers` -* the path to the markers output file given with `-mo` or `--markerout` +| Column | Description | Required | +|------------- |------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |---------- | +| marker_name | Contains the channel names, all values **must** be unique. | yes | +| background | Specifies the channel that should be subtracted from the specified channel. The `background` value, if present, **must** match the `marker_name` value of the background channel. If no subtraction is necessary, the field can be left empty. | yes | +| exposure | Contains the exposure time used for channel acquisition in ms. | yes | +| remove | Optional column that allows the user to exclude certain channels from the output file by setting that channel's `remove` value to `TRUE`. | no | -Optional arguments: +An exemplary [markers.csv](https://github.com/SchapiroLabor/Background_subtraction/blob/main/example/markers.csv) file is provided. -* `-mpp` or `--pixel-size` microns per pixel, i.e. pixel size of the input image in microns. If not specified the script will attempt -to extract the pixel size and its units from the metadata, if failed, it will assign a pixel size of 1 with "pixel" as units. -* `-pl` or `--pyramid_levels` total number of pyramidal levels of the output image, this number should also include the high-resolution level. Default value is 8, this argument will be only implemented if the input image is not pyramidal. If input image is pyramidal, the output image will have the same levels and this argument will be ignored. -* `-sr` or `--save_ram` using this argument will provide the low RAM usage of version 0.4.1. -* `--version` to print version. +### Command Line Interface -Hidden argument !!!: +| Argument | Long form | Description | Specification | Default | Required | +|---------- |-------------------- |--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |-------------------------------------------------------------- |--------- |---------- | +| -in | --input | File path to the input image file. | string, ends with `.tif`, `.tiff`, `.ome.tif` or `.ome.tiff` | | yes | +| -o | --output | File path where the output pyramidal OME-TIFF will be saved. | string, ends with `.ome.tif` or `.ome.tiff` | | yes | +| -m | --markers | File path to the markers (CSV) file containing the list of marker names and their respective background channels. | string, ends with `.csv` | | yes | +| -mo | --marker-output | File path where the output marker (CSV) file matching the output image will be saved. | string, ends with `.csv` | | yes | +| -mpp | --pixel-size | Pixel size provided in microns (microns per pixel). If not provided, image metadata will be checked. If that is not successful, the value will be set to 1. | float | None | no | +| -sr | --save-ram | Optional flag to approximately cut RAM usage in half. Note that the dimensions of the reduced resolution levels (sub-levels) of the output pyramidal image will slightly differ whether or not the argument is used. | boolean flag | false | no | +| -comp | --compression | The output pyramidal OME-TIFF will be compressed using the specified compression. Set to "none" for no compression. | string, either "lzw", "zlib", or "none" | "lzw" | no | +| -ts | --tile-size | Tile size used for writing pyramidal outputs. Note that the file size is smaller for smaller tile size values. | integer, multiple of 16 | 256 | no | +| -dsf | --downscale-factor | Downscale factor for pyramid layer creation. This value will only be used if the input image is NOT pyramidal. If the input image is pyramidal, the number of levels in the output image will be the same as in the input so the downscale factor won't be applied. | integer, larger than 1 | 2 | no | +| -v | --version | Prints Backsub version. | | | | +| -comet | --comet-metadata | Flag to obtain the markers table on the fly for images acquired with the reference background acquisition implemented in the Lunaphore Comet at the TSPC (https://www.tspc-hd.com/). When this flag is used, the argument `-m`/`--markers` is ignored since the markers information will be extracted from the metadata of the input image. | boolean flag | false | hidden | -* `-comet` Flag to obtain the markers table on the fly for images acquired with the reference background acquisition implemented in the Lunaphore Comet at the TSPC (https://www.tspc-hd.com/). When this flag is used, the argument `-m`/`--markers` is ignored since the markers information will be extracted from the metadata of the input image. +Example of a full command (note to provide full paths where applicable): +``` +python Background_subtraction/backsub/background_sub.py \ + --input /data/input_image.tif \ + --output /data/corrected_image.ome.tif \ + --markers /data/markers.csv \ + --marker-output /data/markers_corrected.csv \ + --pixel-size 0.65 \ + --tile-size 256 \ + --downscale-factor 2 \ + --compression zlib +``` +### Outputs -### Output +The output image file will be a pyramidal `OME-TIFF` file containing the processed channels. The channels tagged for removal will be excluded from the final image. -The output image file will be a pyramidal `ome.tif` file containing the processed channels. The channels tagged for removal will be excluded from the final image. -The output markers file will be a `csv` file containing the following columns: "marker_name", "background", "exposure". The "marker_name" column will contain the marker names of the processed channels. The "background" column will contain the marker names of the channels used for subtraction. The "exposure" column will contain the exposure times of the processed channels. +The output markers file will be a `CSV` file containing the following columns: "marker_name", "background", "exposure". The "marker_name" column will contain the marker names of the processed channels. The "background" column will contain the marker names of the channels used for subtraction. The "exposure" column will contain the exposure times of the processed channels. -### Docker usage +## Features -If you want to run the background subtraction directly from a pre-configured container with all the required packages, you can either build the docker container yourself or pull it from the Github container registry. +* Pixel-wise channel subtraction scaled by exposure time. +* Autofluroescence correction for multiplexed immunofluroescence images. +* Pyramidal OME-TIFF output compatible with the MCMICRO pipeline. +* Optional image compression to not bloat data size of large projects. +* Low-memory mode for local processing of large datasets. +* Automatic metadata extraction for Lunaphore COMET data. -To build the container run: +## Contributing +Contributions are welcome! If you would like to contribute, please: +1. Fork the repository +2. Create a feature branch: ``` -git clone https://github.com/SchapiroLabor/Background_subtraction.git -docker build -t background_subtraction:latest . -docker run background_subtraction:latest python background_sub.py +git checkout -b feature/my-feature ``` +3. Commit your changes and open a pull request -To pull the container from the Github container registry (ghcr.io): +For questions of issues, please open a GitHub issue. -``` -## Login to ghcr.io -docker login ghcr.io +## Contributors -## Pull container -docker pull ghcr.io/schapirolabor/background_subtraction:latest -``` +Author and maintainer: +* [Krešimir Beštak](@kbestak) + +Contributors: +* [Victor Perez](@VictorDidier) +* [Florian Wünnemann](@FloWuenne). + +## Changelog + +See the [CHANGELOG](https://github.com/SchapiroLabor/Background_subtraction/blob/main/CHANGELOG.md) file for deatils about new features, bug fixes, and version history. + +## License + +This project is licensed under the terms of the [MIT License](https://github.com/SchapiroLabor/Background_subtraction/blob/main/LICENSE). + +## Citation + +If you use Backsub in your work, please cite it as: + +> Bestak, K., Perez, V., & Wuennemann, F. (2025). Backsub: pixel-by-pixel channel subtraction tool for multiplexed immunofluorescence data. +> Available at: [https://github.com/SchapiroLabor/Background_subtraction](https://github.com/SchapiroLabor/Background_subtraction) \ No newline at end of file diff --git a/example/application_example.png b/example/application_example.png new file mode 100644 index 0000000000000000000000000000000000000000..e0c2947f9f9ddc2814ac6cbb6f4f85d864979e4c GIT binary patch literal 1376963 zcmd43g;!Q<7d?u4Oe{n~Kx`TW2_*yrP*O<&2}z|>I`jyNq!NmBr-C#{DGCS*NH++G zG)Onxx&6L-?-;-DANajvoN+
=uk#i&{isi`d}r9G&vOl+of@; GwWpq6i|eQTh3AJqyQEwDTL%cvI11A* zomdcFTKg~+9BG}t^iV@UBH-*{vj2Gv$@6veY&-qGFZRsaexCpHvf`zz`Jc}qlS{9> z#{a(`QPZNrbVKxiUWGKIL;v%^o=kUiW9a_(qlb*xg3kQUN5`uBdRYC>2a5)!{oj4j zwH!r)SArCF?%a8bpTFbYTj!NoCWS=lR$I@vb>Ci}w_Ke!>?nBsnyTg%dzi=N)2COr zbaZqq7RHo1?8jT*#Wam(z5lzOdR|rjCk9s+# U;>{}$zvr)N$%_J{iby%6=OQ>S+6avOaA=)RXctKf#7Dc1H!p6T6=<@wPj zF2lxnwh1z_hAq3XX6EJ9)tX9fZf@1To2XaeM}OJNUu08-kN)uCN3szD`r7{g-A1d# zcX#u3CfYN1=|)CI;?1uE^kgrU3l>=ZFW%5l4O_cM|3@4$<=>Q%5mQtQGAUL}k$<19 zTeyo%PI*vE^Z$&%VD&<)od;-mUie}|J~lORlR6 Lu&n`>$QQc9@d#X?wd$vE#x%#}axzK0XUOyJydy?XC%8enLd4_UEU` zT4lm?baYwo`d+?#dC6h+Y+aOyzthsxDK@tK=e0_|cvGE^ZW?`Y(OmOaZ5Y*1P4E*O z2I6ZCYC~flG{kna4u}72NxO5&X5=vOr=(OICj|s(>Z3$%I6Di?4t?8w=gu9)boFDo zMlILY7ANJ3Y)waOK1@vI$sHjl&ve^zo#@Eky)@PHIqru4i|}xte|M6`+Ke_nXiGo- z^w5ceH*VbU?yC9z{fh@Fl?6_Kda3iVrRlyZ{Pv%*7S{NLgpD;#M+b*bU0t-kv=^WH z`yW!*&``2Zn9uwET{>LQ?%=b7$IC~Xl4h1?bcI}2j~W^pehs^LUg+{=xk4*_9>d0e zjtkga=`Z)le&@gY5dD{2 ;`j{Hk?Ui0LsQ`=T%hpxw5^Z41{KQnEY;JUHevAT5- z@g%P$F21WSod3z^XNLxH2cCX@mD%^}u~eIvsUG}@-iGD%*mZ#BV{dQoMBZ76@?h3G z@6G#!|Jk+c^o0vYZbb=u`1&5`?(V*wYoyeip>^W2)1qicmcEj6aLv5q<#dgrr_s?T z)r;-PVq#)EeSAdK)#)0WnzVIwcQ`pY9c8*rk|6b3ywGascA2XSk5TiUU>2pC#!?oC zg)s_0I{tqjJtFBYu-tJgQs^qSVIKvH`;l{!(GSO4|IE!@f4qHXR#BSjRCOTZCEE$= zy2#7kUHRr`u;a|k%=Vk>{M|i0{bOTZ0q3M*9T&!u?_|(-<{CdMc9=7;%kP_+9g?EE zWI^JzIPob$(C!b`wb*`U`%Y3y(dvM64M<%3PYBaF%>JsLN 4YfH#mkMCq?B26Egnwqk(wS9_6WKn**Jbk%#qF~zSZBWor;-nPWO;;|A zw~>*PKPf1 sFM8z)35RB}Cy}r_vhRP55)tvDWcT`A z8>aj9#VIC@4>>H8Jta<5Rer}mBkG7~@bughA0L16+_{}*W@f}6)C99Wqv17*wj8W{ z(E9e+#6(v9#7So6z4z Zr<7x3C2- 2x#smt2_N+K!YoYVSTc3wV=a{8#OOhjHm!NU|)0Og=* zM7U&fak6W9&|l=eVH1Op>w0_qumC@QjBb@5^##2>0uRUH$M$YacID5k3`c#dylB$? z=$gmAlva9@<+U~4jn#!+x)dymAE(Q<%xu#8T-Od1*-l=dPSYsjGpGwU(6NDRC@AEX`NkM^rNEQyxB`j%ONZ+DjJ{o?9d4v#AVhDK0F#FaC+Uz%Idx>kn=TeHMA$`uL#x!J*}5~|+&@JjDHaJ|JYUz( zxGi0~!i!>gZMuw%f fK9@m8d0Q=&}Y%F+Q^zMojrS1y`3S6IZe-%Z~tr73GqcIIZy;P-V$ z8xtt0QuX!p?jh_|N}c(ET8tV?_8_(A7`Gk7@{6S@zhfJ#3Zp!)>BD|EkJ?8mL-RPk zj*Hr9euNCG`yAPB09B@(bqYx!COr_H;T4*KiLhr~SS54zs>e)7iPn$;pO7>-) vOl*v`XnNnssxI7Ou<<->8qg%;mbi%yu~=`0QSk!Ou}{ zo6nJ?E9T6u16q3D5?=WSrQ}AYPKC7V=Fd-0$c`ODNQHzj(WTAL%y1cM@1mlj0-XE& zOV|sk=pI&AB~xc7f=##4dhS>C6Aq0J_tuwZP|r(KH;z2_@%gg8GB-0E<#w8bqi$qa zDMfx~l*{6=T?gqNSj0BsXI!=uDo^Sc=I61N?j0@+r&-UCy;Ma)W!lEM9KxkrDOgqJ zxFETT@I8C??%j@I=F2GOjJ4tX3X)P%O%o3|G0uT85eE5+5y zTuVAu;$matH#gR#08}VTq7ZklQOagzH#16CB^UWo-T6{7u0P%XvbLqrdf3kI)~#Ew z8E!?g@n5<`y&-_2{N3@yba&yIhts`114}ataYikvjdc-%wPW6=keA8wsE!>=QM;QV z6UrlzSAs|l2{LX@mdh&a%rVsc!;iZ03J2-?=6)N22%dW%NtpTglzsCDk2{uVs=dGW zSTXv_Ht$kaF|VU9^Gv%IWj6t^_eH&a^CqQw9AEAj)l=-Cu)Nh5cG0|aE*$YEaZc)0 z&WQTGt<4Sbh3VcBC;87$57Y`@q`B;v6BHi)cCm{=1B+nPS6UL^G^)t|de_O0jt&Ww zy4SI>v20^qIfhMoUCHtZUY~}MHEz~~UCd~3x$H0-|49g?kxKad$&(*r+ZH=fxjdFG z08GRKFy8I1Lj`qTQj>N7uF(2mKNGtzLo=Y#mzJ^1&)>gZu 3dwTHGXby!VN@M0>aU_b@+xqy~Oor2YE*|i$>&7!3=SlQ+wmW zvAZTt$VfcP;W%JnJes!iBXUn1_)I&`uF)Y}p44|_>OND u_t zR;p~n0yih!I>9U;(D}ABWB@TEk5Z+$=37uua8t{5B_Svz I-uxOJO_A)wmYwl%cTa8=x!%~=7>_b@)ymQ`GdTkYHBdJE(v0k;!`$y`;i{%4 zg{C%fQC076?uYds-_19Rv7PK(UTD*L`R0wGZ6s>uV3&CrkrTd0UOu07zn FYDIr1*7gFzJ_#GFtRaqA THU^Cu&00G10p#Nb>!^ntbeSQ5$ zf)rJt@&l)^vl1%V1_x;POqhzwlM9a=Ir5{#$ Iqc81v)j_?|E4%hpc|nPKmj>)keWXZO#x0k{&kk**=wzwn$H z0CcAIVqdgoiDP@2TbU|r#JB3NUq9l$2q@j5 EiE7V%QV0Ps*Q;W2k9<7 z0&K4C{RYHViPI>TBzru&%YJp98KtfPQP5JC_T }O6R0G_-&bAx3nRVNkaELuK65~ZckXsHujH>XKEC(nZ) zB>84N^5>G6BSqZ&u;RL3Jjqi1Uo%U{$W$P|{fN79s6y!%+r!CDUIKxmqQ7Gqyccj* z8;PDcbrZ7JUOBGJp4)YZ-Uq;ewq)N2u7JQmE~iBkMbsxo+3?%!8NB-r9on)>yI0uq zNBZH&k5kQWRmsr (cwJ? z0_e~0b5&qZ;-Bg%dWPolBq!(XCRpAZt=ovx>gf1k zY^9G9EdzdW0QEF9Ukt~ru&KMyx* 7vx0Mh?_+I`~Qr+;^b-db+Y)ZGn) z9&eP{VGOjqJQC-xc!Jc?rLM$kNV2v!>dzmG9MdjJ;I5XlZnlCC$BvG*q&-6qn;N-> z&tG1jt0zuY^6Cl^CD?>L`}WDRCuwF>E%mvbFFJRDY{-lVm)mF$2#6K#fyQY+3gA&z z<^2K?btBzeTwKIXV^8l}iroJm@))a5!%Gs#c#EFLted*TY3XTH)CnSGq}@5+oT9K> zR#sL#*ADd7etMzyMl%90j`S4Sz54#8%xQ_EGtbl;so${o6<6eCr>Dr7r-X#m7eg}2 zHfhKiWqDY)kh?gYmo2=NuN*yg(M&y0U2ppp6B9N8+ldDasdSY@UxIGVWYLQ*(Y{3T z>xYNqJm##7-@bmm0xV>?zHHfIeM4NlysfQmpznLyos1tb*LSO<9416=WT5mL^Tl~1 zQ8j0qbX))+yR%TNTj_K4!Gn>l^)4iw8D#J@NAfj9C{XT)j`~}YlGo6%Sx&Tb#rZA$ z`Ev#Tm_b6-1v*^*6?p4MuJIjoUv pC^NTFys6AEk*m+SS$7)I1LkyROgNsO yub9BdsDjBW{nL!6QAvAwzdYVY1EtI*Oq8V+2-sej{dQq?{R#O4aV%?48+n4fk za(MRB@Gwc0_Nf{IVLa&hkef=XM|}YY2^mK$SREXL7=S!)ky-c!{e5}ned!Wrr?WKw zQcK+|XU^<6;kKUniJc5 7>H(($ c*kf4oQBO$7p>gH*y7-oe;0@{`9s;jHtjuqVt>~fNi zuQgu33gwK;ZPVH3gh=m%@aoD6$R$$o<9C6ckivGO|91xm2aW~B{^^e&x5N3ZcL~}} zeF5O=bLd5K_6C#trsoEcN|kXJm43%rg&07IFe#-TH8C|Mqo+5UH#&?0yS%wJz13sH zbN1{%fR^Nx)rBAo>}Li@9)ZcCE|F>a{)T&|nlXx?SHzERC2rfX>*S?ND)!Q7GdLaR zAMB^*BzUA(mpMS$xovG+1d@CP-lAg=h5wG0iw7>`6N2KGu(xz5&)%e4QOdkYuZX&V zazI*k2VK(pkq?8wmLu(%XHaH2gz73QccCK`#SZ+Oob*M-yOz{+3IGHA_tks%P67`^ zUwUkY!j!C(Mk%~D!JWOvb^*<+dYP+6U^jAf(AqoA5*l&_Y0fbf#1z5Mfo2j1IVM+Z zZ+S^j@C-UHOH0cF+esQ+pU0EkhxSd+L $=d#DiZ%4@b*q z@}oGSHtgc(`ubazGy54C8HrR*T+*8h`nQvBpNdaP8pOFLGTy-x!ahj3EPhSCfIwKe z2i*mk9WF$!#fkFmT}eUHHq!cz7D&w#YqKdh74YK4BP=;VULy#OawNZa^X54=DjJuY zte6te;gU}YK;)j0kuktq5)pAc^rE>Rw&3%(Z=~o&9&2XYke1$W*7Lywhn8T3Zc0dy z{eFYi->CKNb0qK3o?$Sdh`g$i(}hH%50sj$UU&fv=u6PL1h0ko0cqThu1avw769ni z*OyqdHys;iW9e?5KAo-jRz+_6y+7y)_Zx!O0|SA!I{`(%D|sETyc3y15z*aYt0^Mx zL#^Fv?CIqdu xN+i5%XCNxbv3~Gj7!6)jeMEw zMm*9EZSWGB<;E}1$ t-N8ZWNoV)3Q06;!_`dy4XdUcS_}GLs01 zM=DG~h4PwaCrLr=Y{X|!SOf6g8S5=6`b5WT)ck@7v<^O^Ajxk%+kfnISeo^4T_euC z-XOjt7@3K610|&qd|y1#@_=G~y?w`y1pHf4qsTURVsbJ8B%t;Qs6X*wp;$}Zu&Iqu zY~rywOr`0~&5DMo4B{J?-{ Ay7GI4r1*38A+ zmMj;iXPs- ;0!97|uT^a~#a=FfY7*tjx0-CucRq0!DpBPSTJia5l z*B@DrwnZF#Z#*Jj63ZWQ{>+){L @|AQS9xYVEGCTH+)Z!WuHrn+5|qjM)-={F z0XIzJgMRrYFj4?G* $nu;0L5#{ZW<@L-O6WHszh2izOVI(3F92s)-~o zb)D$U{T5%03Q8WCuTfxeb+N?C#wG}WfB^?xO~7gK?NJJfa~>WZ65v1^v-H2k9}-!= zjdt#(Y^0Fkd((uuDljF1n%-w6 #JW=Qykxi(csQ+Jids!BFmTL@9%H3{6||Nsp*(c5&BF(qE~z-?I&@PWxf3T zUR}F>JpoZB1ybkPJfPi8KR>@lELyDtD;i`bsshJ_)L#PwvG!)ae*Qe^vNH5qyyw=d zvl7JL3a|Vk@0bf}^9OhhYwp8Fr4Uq`hMn{3Xp(q@+vtKL^U0G}QZ-9H=xCBO4pex{ zjLjK?q Uuns z6-h{%cbpF)#F+R61xfp~1O){Rm;dxPqRLaufjGTQ!)@R*ut2bbwWa195&=9GKd;1< z(jp227pHoRn@c8 _$9fJHDLdjF6lAP%F4=A zr?u?u3ls}1w6(SV+w#Y`W&@W5CrZDGUDjYxc^{2NfIEAYO=6&LqARKabUeizFvLyt z;} o;Q4j+{cXRm(y`YH(Q)6*%gfuEGdJzZ3mzC4kTzpH zb?T}SV9-FdcXxIE*Z10=-5o$I8qJHnK1G~o5F~nYR4j^L-(K@Uf@O+4ibEs`I31+A z(OYDf@#j}MYNtFJeI5E-(jkIZNEe>jaQ=V202)R6tO28XW4(P 50F`oqsgEk|vP@bMQT_ioMtS&Z$@ftJq zl)BvI+CtQyH8(e}u=YY1e@tkpZPca_D;4kV{!c|7h*_56UMCk#qZ)oOdHDdv_?u+f zb bzZUC?5V4hr2(=& zN=_aZvITLf5#pot6cxbrkn@{2Z{$lH7mn8V0n@Vf6k1ok<#XNIu+z>|M`n?!Swi+^ zUCc*$Z3HbKJ!Omza2_oc6Xg;=@B(klfN5+j?fLWPpF~xR?>t5??3^4Tg7xz9_2s2j zU iwlfzl%tkDUCN)Wb`*~^V z4dJ?w6mE_0XIX-r)(E;gTeeP9N=8PB&5vtAhk=1X+{(h@MeQ(>6e?Hkgm4)jT5s(# z-(wRFO;}Zm^8sKK3@qj90ZTnhskwhf8^ow1wm^c2XqN)a(JK8pGmIY0>xn#$m;uGM z5?i9J+UMKrER%ScqUM+*Iw{!?RzqGL8N;S%YL!f~5lvZC-ztBMFEk=Vj&^Z9tf=f( zXSzQkhsBFi7Ip3-zWTnsD-t5Z@Np5>V$LMlq`=?b-{@NPwh?CynJ#Re{;yl)Z`eLn z&flE dW&Nn8Tw#0 zQD4a-Gs3PZ65kDUHu|UkGiAxswq~{ L}+_ImrBfMf$McQvDb#e0c9zz0VH#x(@)0`}XdgaB+ z^O{3L?StKPq}T?kTg^{{$MPe2AV{z`^xyb04+ zp)Dr>r-wvr^?!aQ|NO`~y}6>IA}Qc{h9PMQiI*t2j1&3U+1WWRYqr^I>=FS-DJdC- zYQyg2`99vUJ2beBX5Y}}^w#FB_Gj2isn_SV_ypZhel2)!A{^uP$@I0N+1D QpyuK(xHM_?o3 <`za7hv64`3ZQvo0EK?ca0L4iJf@+1id2Zup^CK4KYus!~&5Qd0EnBDcDx0v5{ zs@t$(`YJo>?+ptJPNU}J7|Buyyl6ut?4{8Qkb$Cta2(R(C!6#cwBQdgiT##t8w*tb zK6z4W#(0s?4uQ%Ex-l>cRJy|V5U%U?()aE~OYol%arFTex7U9KEjOoG_ocwu(rUDg z!1`B+%2!9=rcS9_x3;3THbElHpvINAwziJY(bLlt1fF}<%>ei2=4Q98F8mofVsRi? z=`nNUf(Fov{ai#C3G|^$@|f96&JZ%MNv)%+`{b`x$83x@l^G}SDG+E3NX^h(Z9m-E z{7A#~Wz_}nVp6X;JY-MB%R}1NR8W>0pirrnO!mFA-+kQqT zdydr*ZUda1XW*fYTXzx3^5E#`=rS}+k1B$^2D^3*35VTi9O#lf@&<0cJ6TA}ZG(5- zvBadiw)Oy&{Kv0fzpi$1ZW?KbqnOUsDz=xqWT3;0Lh;Y;-ElHue4)9gl29B8gUGJ^ z)X{2nNX-Mdofp1P=c%D`ia{5sHH*`$4rniS$eyrchj{|a$;mv7-9Ai1!$ONl{*3Ev z05Of_eg~3*`jSPoNq2jnXYO+^uX2!5`h$oGv>}XWT?}n$p=A=>-$Y*uFbCV+yzlYh zgbfBz)4d9kEURDOb13B6V6K?t<2^h0J(-4eV(4BzwGlWDC2>DwDQ|0l-FrA_1Sr33 z0J2VexCro;)2!Ne^df339aQ)LUlIF1A4k+-`8Z0sLjZpOcmt8Yj9G){YhB$z)a+ni za8+vEPct{y`nL9YKOm?EK)ugBJw36uLAywWH|7<<#(a(CBZ b6QSB;?YGC&zd1*VAKrC z{JA^?w@zQFOZjQ| kOl=@p0E|d26S1&Q>u^l>eNPAll*WhtjFz&EXB0?xr z6;pJqw)Vdfc$hlyi=d(RQYmxZNs&*m0I5!tn}u#k0J!%s4D~Mu@e{h=-CxN~qlA z!pW|AMjWm%jhzF>dr|Vfiine$Y88FC6$630PQv(CE5(OBe}{VTkG5)^7RJ^ zsQ>37?YD2=q*S^;=v3?rbWMYVMnyk8Jp2qwWLWc!>yklCkGIy!wz4lBA{!FwD2qQN z$MJi7oD?XpWtLD?Pq@sJK`+wfEQP;E2&6bVCi(B^1zEMPzDY_->ez6;+Yl?JPYRGa z;8T}v)WV|e40BGZhWoayx2cTz7c}t8O2#+zfcptD)1 %dM}UVOC+Ph6aHuj~DEfxAK&| z0uBO!F#h@HpX7kQx{)0E`9OwiCAAe5J8{*YiK6a5*_=WSZOPR3E`q>4EiKK&QWb{1 zhC)y(`WXtqQa^wHHc&p%m#Fv^JzG+Zdp;z< C z8ttn oh;!Mo+Y^aT7wMrZt468Kk+z%wvY=Qa9Kgw>Dn>{;rE zz0T Mo#ex2ALja& Zk#>s$IyB3D_j3QrRZXE-g`| z*Ac`OTwGUzDfR}vD#G>`q^4!?3kc|d-=SppHc5wolw7*;3-@lZGOOQF;O*_rkS&ip z^MGhroxT~}HlArvOCt@cY$4A&7D;(ue#Zqd@B(_ILnPE1OjJjYo~09-YEB6p>uUp( z@#e4PQrQeB`D-AfzZieb>+=~ZO!r~`0r8}!7-Vj2?2Sfv5cJBfLG%gY!;Y)-DIFV} z9>74HW;J6UD^Z@Fr3M-_Ux0VvEyx2tz|Mz=PL*|ye;xr*pN3@5w}~59Q} 1GF1E{HWJTz5tOE1`vHN;|H=yn*;*5F$#&`%F2z zGubfmeFF1Wpg)4Nrg%AHyfJ|k? z zU8cVv{>iARA2drgN)9Tc;^wBd9VV1uu+)m*UiaWqJiuLb6eKKd@BKY6@Gq=!-_p0+ zLmY|QHv~q@1Gu1L`O&r=ipMi@GBZDc#A|?#@i!a>Thl~oKqsC?_Dpo_h1W0|#W!mP zWht#U@s-c %PnWgh+^hByr+m&{0LVCHN1iHAvNP_Ml%gAMMwI(mC^9Ols%V4n1Lc$# zP?nV-^+w}zQ-=ww?L#Q9SAuDCDouF%%G}(Pyk^B%scA!H%qE*Tf)uP1cygihQj~2h zhM7RpoV{f6^O40lM+MJ)vxKP*>^@P82pg0xM3mILgvBpJ8L8>PPuzh6I+mlaKDl@Q z{$=9`IwAYRkPfnD0DT5UwpRQZO#QLn)P4EpQ4?O)zrWU5FQIu&(KHExGzi}8TP2{* zbFeRIoea@+h08$Edb)5b%9Vuh ?QJzU)6!=bV769?Yu5^p7wO8#!eCl?2l=BGWQW4Qe;^+r uBoIUfnu?B2ooz?Ii7ac!volzVpgewE2IE+YsRk+phkM z(vgFx_#_j-kq2l`+!@Eki4@0P*jK+)R8&`r-St(Al?Y_W;?k|FICJKV<#65c4trRjJc?9H zrprHnwuF@3QJ@^<1T9T3KZ*CZ_3N{H0SsRI!jYyCxLpK!dVZ?s?n1GsUJ0fb;>p+z zu9qpti67`tL9_Gc7h}=~i~fH(dl46GW6$$YMR!0v>*!Dg1=F5mNDc>Aj9M57?kv`} zZC8L6m5j$tRZH9*a1l!NDQ)BW`ERz90F%flzjm(u^y#YoAXGQjP1oMt;0Bc+9 p;H4)w#mvL&(TC5ekU{|Ow^{- z;h0xQ7r_?6li=TU2*F5cyUauEWdG8YwC7JhijW)ZcUWJ}G-BOtWEmlW#V!mRaebGT z#tKLj;87xH3*T%<2;9Q(PR1FoNd~&Muml-*<-KDdzvR>KUi fbW?{N5Zc z(IQrM8!~2#^4f)Xr1|n?^j^HiU5aQXQuq51@(iCcibfEJM&|%uRu@#fAAkmD&z(Ew z)79Sov9C{SfxI2|1kypY;BvVo7ZE4trCz@<@61UV;|H^$C&K+Rsx5ipqYA_-j~R3j zMxCy*(Th8SP+R_3^BPbDz@M`>f9DS?r)B__SeRKuhZwQKbFgXcAY4Gd W_gH0>cp2A7K*Df9jHFkpkwOZELrk!nMn_){{^4Er{ zsvAwyq1{X6A^$Qrgq)0?EKo8#`C(U@T-?*dGSx!MXD|)IW3w+RZNAVrw}Sgmy}b{w zjMbUbrmFS3mP89Z(OfZbi)fO+W7?fu IDOE#JQ|3mY;j?wl;Zje!T5gV4he`@lRCGLqz`wsAk++8P-hyXIoGota@W9 zy@@HNdTo9G7{_8!5i#N6;d*ys4!>($?nH5Pw`4!R`pC`f1GDKIVW3o%G9>wW6a-=H zq<6E4R7P7`s~ R}jYPg6SJ@|x5TpAe-O{o5)vE>0BzcV-dqS5+ik@Mn{--DG< zDx=DHt}Mrabh5d*X5P}}Jc<4{i*QjYb-sU9AA;Ou^%TNMzupmJRuV5y&%?6&TCEy9 z+tA_dg~kKzjMgXZ;g+Ca{dkViT}9Cmpjrf;aU;h`kvAYT=^=2a#52;6H;>wc_6gRH ziniSYr_wxc4HogWZ$)M0KE~ZF?El0K*}un`DK0L)6e&I*c@JP|7wSop;pTrN$ZAR* zCW@)#b1>gS#;kn9eV@2Zb;uEcE!zhM2R$5yUd_}p$Hv9|HPIYcM+>5jo;)VB@enfOEDrZ5+1g@AOpG1tZNaCW@Mr!ra6MVuf#mp!z|HA3%w} ce|!g<_{ho$i)H|Id!?k!t5=JWF}LMAcdOmE$oo^YPdeIUriMTX-N=xg3xKnCVh zu|z}nEF RIK$ijc;V1%{`Q{Ea z^BqRU#-wnlqM|q6Q@0r~ehv*J9Hv>q>k6EEG=D $j}3#1 >9QSaPy41f$_#YVC%zo(w!Ul@^;#0Vi= zseT-m^@h1%VJh}J3_G)a7ZK?*QEpC6epFqoHZRC)HfR1 57ZK3Y%oJp8ZJnL^t^qOpbrnViQR@7> znS;g!TL;#&AGy=zPPyH$u)nFCXZmiq|B+Qp_yhGHovvqE8JL+R3R3Ud%755JzVXV` z)bu4hIJrghBBE~hJ(=vZiO9@~{ZrM_qLg==1HYVW3!6GPvV(8$<{tzP{K`~8N|I5Q zeuu;tTnkBX;}UloM>=>2{qxQ(>tQd* z4HquAj@nRO-hv8RUVvsyY$aH4DVm+IsH9o+m!}TNhcexMfA15Uv6QKtoSaIDBX6Eb z$G=F&2IF3qry+;dww_LfBn=z&-hAo^NaL_hB&(jlRlW=fNmg~0N>=*M{Mo()E=ps; zviFH=eOAOzBO?#uOlQx5y}mo|EM+)@beQ~ wsQ`i6h?ZI7VS~G+P6+FPod2D)0s7)qlMuBSTU7ZnC^V1(@?Q zZq1`AT*%=FWStvecX-F2jjRQP$?nTtjbt({d80HPGa~-@T-OsL=CoZr+14K9WDR|M zC~o~e0=&Nx`!14^$(g+j9%g@)?ibbq^7%bhAB@r<==!hUzki?NV6K@mZb9{DF`LZQ z|3(Qbhi3LChQdkBcaLfMVkS0y2uW~QPCz6pZ;sTMIY>b*`t-ST6|vXyCra<6ime$* z?{DyGEG!tY3+Rao&dqL}IeBHpn#%W$Ud6DPshivLn}Xf6k8bWz h2xst* zCOiEl$=zDcGdn>Do)r2GQ`SgVeuja^vsQJcq^JMy5;cr3lo#as+Npe7_Gxsnm73-? zzHF!pi^eWceW}ATQc?<+?< wwn6RY{V02>TWZ0PTNqLBp1q$0 z{&x_RBiu+S6I_@<5swmfc@K+kdq>LF{kt}g-%xPh%3<5?9H=aIL$WL%JT%)IkJBud za#{n|-i9{3is;gK?rG+VB6WtBSE=cmlyRgg;P8BDO%1zrwquc^H8Ht^=CT54NZK{s z+nW^o^&?S+mbfYyeb$$XIaydFC(~*44h+X&zpleD3}1o1|K2@&K3?o|egd&9t+%vs zVI2^iSL3QiJlHqn@u&YOov*_AdbPiAOuvoh>F;fPex<`fGpT8mr$b&or#uqh2B&k3 zV_4 )2rEpm)38mz^%g5YQ*6=jchkt3$fdds7Qql9rY|F{g 6XZbUZW@n2AymsJ1OwR55OAwsq)^ELNg8K_peI^DCisg#rt)1 zgfAP+O=@1o{4w~(qT&5X%_9=NUaeK9{WGqmvh078k4MPf&kd}|4jjpL!YCY2MoRU- zext)1vj2%M*KGKmh`G6} `PDcJ z3i90du#1OKe9Muei7}g6??;5U#b oaCk>|q_04w5E)cE&KOJ>A48889?1XRv0Jw-*-hTwpiPAd9~M$Jrgg0|8v zgmURlZU-2rXi6}4Uptv`__~Z#`TIBX|Du+xA)DCg(6bWCC7zFyr} Aqc| zk>)W72oE=z^?Q&w5VE&n?Fk4Q8f<(Ej~j;dfLZkiW6P5+dcdKI&P?3YtvyR$iVo&> znEA@7w4Al&xgsCja?ph*Roea~IW4WRoU8HAsVP6mki jGCd_ |FOkys3|4Zy5ybXXoK8M1=5-I9 z^Chcad!Rj yQppa~JZ6#_5${S`eG6JT(Np;@>WpU{yXV5#W1QH%47et 2rTk}L z2cFtn#4$<5T0=9qI#U3f-ypy-!9ju@(6th5&6B*L48-fJL#BgCQ;hbdj=86x2dg10 z`$=ko_ 0B!Yefxpo%8DeP^ z@rlb0`bsh3H^J^wSbHkJB56J!BnVnNeWfQ 4_$6OiVX}`EbBIt-R28N5goK zWoaCyt_ioxQFb*&KG**8o&9Icc#|!+)?6^(<32o)BpXpv-Wwur7~a(B%J{p{$Rh{T zKy~HY73(`R+=bAk3{0PSc&IKIIf=$|;HeIVzd(zJ4pBysfTHW)`@@)3`R(D#h=_ DdRz2lpE z#};yN>quLwf!(Sp#3VUJUQO0PyM~%+k&*y`QdW*Pr^JO^=gHo`Je>bF5q)2B=y^t# zJL%yTMf&WdL$XoA?A_&IxO=9wgv3N0Fh%9MgfS5{k4bj+w$ c2 zw}y)2bBtR&coCL*R@x$ Q;xKb3$sEkv#)`vy1GU3K2oZwB1Ed^T} zrAJCGV$|Ymd1Q!q$}(5d(HhV#dk3L%G{ 2H#S$UA8{$z%!K*GwjH@YR6Q) 0So?9yNJNqpP5 z#|(^SPzh6er;sBEFA}Xe+*dk&xOh=9vFgg>bab3HBe%0xm-rIMNfVZ4a-m`G$ V?Fu@QKyUMR2faTf_teh4gk6_YNQsL0BkpSOYUwtE0fY1=p#F)(Irkc4j_9aKBG z4v 2U{oPn1z z_CIGm0G$2a!B)QB7@I98q;fckTjD2iJ*14YZ5aJICmHl3ezKc*AcF-AjJ{ioUFNFo zw1ZGq_K`{=XnjC&;VB*82Y>xK>tn3MbSFWQ0-~NcpV_-8!ISjd{I2W70Jx(09GaZa z$#gN%*5?Rh19^ebw($i*4;!0gq3E6;Ymwq29)jRLgG3&OzK&tx+Y1Otgu|j-*QaMS z1MiiqUPYy@<>J2SGX!3RxXk>8e;&b+pd!@NALlQ8gya4?#@~oXpTzR@f>U527?E7( zj(3>9Dt~j87`+GVQ#8K_IUjY0lKu?Kzs`YhAz+|gZxBvm-V}2ynCnYde$y&h4ebJm z%SwyA84zFnJslA?0M_9DTmOftjVyzeomu+c1Ikg;-zXRGU=l+1v-ruz%}ov&^<_m@ z%i?sO79S-&eR{zp2hy6H8Qf{t|0dK@^kFzCdfNV92!mXB&JkK#Z#ec-dd*;78o0Kt zvUkf>tFWNpV_VxDzEaaJzBKkr*#9t2VYkh7FvBab+ @NvzF(-Jwe-v2iRtQPDoYyQa zsCMDj13dQh_ot%QtsxPG4a9v0qc^Dnb7fe1c+Gd6xa>H8``z)E@N5upt-Pfa>xEo= zzk$yec9L@Mgydu<$Y>oC>*ikm{#AB;E=iIc#xF(OTr1vL!F2O(_3el0(zSzln8Qn7 zDt5Kz_-P~ful03}Oyi(8n;=(K4+s^C;XPa`X~!}+DvWx?s5wE;B?di>R){*)96-^Q zf|)DA8A9Rjq_uB|gpejMH|T32)kQo!0JZ?Holj4rh*D7g@;xC!4-w8__#)ikXR_Mn zp?u7^?!=zFREArKz$P`7ut?3oa+=m@fXu32@68|^&g7g9gF{qQ)SnRaiuC>`M3l3? zspLE$BTT1%O(5{1Dw=X%JW_H4SA$`g7pY6p+wP&fX7yc!<0Q_?dAa{td5_il#)jqE zqG|32f)FIlIPqPznhiTih=(TKje8|dV*@EUUi3q&|MEptsY@MqGO@<^f! ObrX#>JwUw(cBzP5t34c4?+h}WrFoEVK2UXxzlMESS}IXCt15Eg`JQTy+N z*(2>s$vdl|nygGH#^C}FYev+c0@&}z%wHDM4KtLXlO65IdP+U3_E4L_3o5gpAT~v9 zA0-=;Pt}(%D&3Xv8&c%k@9fRE#{V-)$uD@{p6l1I_m7VoH}bBB;S_M`eA&}s51@y6 zFvU0FbDe|^@=fMl8I;se(hhB4n>i7-(7c0-hZ2Y}_~38%i}`brNswj8EwzlRC$=iV ={V;E)Xkfce)$Bu6xwy*cnIr~QuSzDq*mhFS=lhh88lF}<9K=^r zY&gBSdC!F-D!*`AM$9*|UM<_;cdvDQ2cAY@D>xk4b@0pJjI!I)S-dg%=VjBv!osDY zAO(2=YM3-XnY*oZDYx8#vNpR3srR;~CT^4>I5>E@CBxO@!-o%)-}3ag#YRVy3`f{$ z)?8>)w>}W*IF_Q^_GUjMx?2v{y3V`KcpuDfprmt|Z(w(=u>*`<9y;&Vk)E$ca Zcfv2#`l*D4L_TpgS zaOAiJ;Q^xX1I`U%WO@}8l*9jZWu@LNa@O?*Uu@}Io#3U#c3n3sZ@+h+u*eFoAAZ)b zW=OwLdB9E#27Tiwdi#0d(tlqjhY;rMLmP*Lm!q#Q)Q9tDS9tIZB#mT)lOVev>0nTE zVY+zkpzBgmUv7~vYkI+USoI~Q%Vuj>Bl2#O`&Z5U`Fx16q-<+*ElEAZ*5W^}_TKh~ zZ5j%$YK?P3;qZ!vaNn1G*5!ynW(8Mei$0en(@ejBfFtTw)#sSM2`w}$6b$+ac1lNy z>Fb}voK)CsP8#q-w@s7GakKHHe`(E&xrR322cX9T7W9&Ga>1MnZLQBqOrNm$xb0o~ zcxqa@ HVPa8f99FZwHgIS!+{jKf`C|h!mxx|FDa!Rjk13nRhojHv9hp%5 z!-9s)N)}p!yl-E{0Ig`v8t%c|@^hlcu!H^2$GhR+WKz6yhp}6VVq`t&1uFln^%o^2 zB}`JD1)}{i#Ya5*L)VXg7v9{=ZKZ!W1rreQ`lCVkb(p7N+L{9``LJj#5_T~#32kG` zb4dkq2TACi|6HYW9Em|d%ziyHskuIZ#?+hC27_3YZ|+BSOi_LGylY-U5#>B9(YTf( ztNh 4X?n@K^`R xA16JU12Qumjyfq^dVr(?k~VPMS464|H=3SXEY)aO1sOoiIHN zzqH>CdWW+^v;s5sbSQ4+Q130;H0% 5XR@O<_M4Zk7?hVlLDFfAmtY=! zE*@t({&9(z-?#^-67x469v 0RdK@FbjKvBFIv-DcMd7qvVj z$Mw5;S50U+jwVoZ8%TSH AzUEa$vF3I3s=u zfX(~KAz|wCCG+xO H)MvRH$6FZsir&6`+oJC#{S}W)0H{z?w gDP=-g8aQc zcW !0Qg~aG*{*!&Ox`!QcnTyGVs-COB$7|;A9S8OB(fMN1Gbn zd~{v?U&wDw{17iwEa-nqi)Zpg2kB203?8IIZf4fvlYFYe_VoYZ>#M_}T)VGPEJVOY zNfo6e3_1m*q`N@{BnL!7YCu6ir6p9lq+4cCT2YWex*0;Lk&+x@VBp)&dEfK>_x|QO z7oJOyndg4)d+)W^UVA;rg+chb-+jyH?`NAtx=WuI%3HOaenNZbCauUX3J^F}s}a3Z zLLd$u1D- Jq_mf}S=+2TR#N9nv`FzrQmswD}u`#huFj!eLti%)2qM;)$#3FR@rM zgB)b8pwJ}bz5uy(3~qE{MK~yOu yVb$o{+{tT zEY9?(2Oeb(LVq0T-o1NoB9gxYWY_6;Z{@A5C|Ejo?%WvM-h}eMnw(RTkmDBf$JZp5 z;!T|F&iQi%LS;r&mnOR{y;0#?;j7<$vIGMSn;USqvTiZahSUm=c{Z*|bX4!Iw#M{J zKKl7is0%`n%~NAD<}dI;@!Vr^v9X>57XG!BAQ;PXkBg7@1~z_W12&$1pD0X6m%vUP zt syR9A)n1WBC^_oh&o_$tdOPS>T%qzubGk z+^1+0MqxjgRvLl3kg&_dU4H1BTBG3zh75Fec0NjgqVKPMS7Kt~ycvY~F>qyj6FZiW zz_2vFzgG#2f3AMt5KJ2&3Px1+5Nr{^D=NlI5J`viY#dhLgL9z7Prq^;z@9^vtDv8S zE&AotuT&hKfFP3$w6vnj+d-}Fg+!YHXe^wcM&|w&GkidXZq9kuJ}xYbK@bO@r@00h z&>`v>82D&Nth|7q-@zD;_h?9u@+F*uL+4nQ3{&x5bdu|u+I|DQz5nc!r{B*Rc={}2 z3~+l!h!YL(9OeDi;R%p%)&j*=^BvO(iaZ~Hu@=Z_#B!=Yx#*X;B@g1*% WZ{9>-6-rVWksdz|)_t(+|i+pUJIL z2KBe`atKoZ466K^erus_oLyLW0&vs$zqOm@NdVB1z*+`?^UCR0hyfSkn`iDld#z^q zEA{hKdC@; N z%vT)ane<|57nwW(@}QEw=IraRyNzStzMX^sVqfMc!O{$>v*C54A8_(4K}E$Izhfb; z74ZpBw-%Ho6CC=8%^l=`QxG=T;E;x!c?=x}6$@mkJM!{}VS$4A>$Wob6wLw6w|g#P z28?~HxzF)cFWegO-zQV0{!tAEA3DVK4wWx`_V{loei)%6+-ZK=zv6 {lSvGy#X?vf9F7xjFh&5wt8?Iy!-f4 F)7X ug`ze2bDH*)1p&TBXPKUu=190 z8Vxb_=Oj8D$`+xdqJt_Kb*2{b&_6HynJ(M29TICNXeE~K6oPl4^-y=WHsY=TAV?O5 zMY=#U>$Wt;435OP`T1X=1yKr2o_=yvQetALA}hM0&Ea-KyoNkXb$|ZO-vpp~5<=Yf z+Rd@hiHV0Nz#Id7Y<{$~$iUr01jZ09uMt~$5w}JN<9*Zd;?LM3L=M7Yv9<%Ij@<)L zc{>HYVI;A$@bTr{D=?IE3GUysni>fpP&2Bk7(gfU4cdFrs|9j*VHqJ(N5W~xg#Lxx z`8&a(*iLJP;unlseoJ(&dj-`O48k0PQB7Hh?to(>^YgiZ={*f&E~vh$RuJz;oY9ad zGZVP(1kmm$GzeBaCG6gV&U|9wV@DI~-ttl8FhEB@!Nuh!B-%)@e>)Cyyt0;-nHF_| zD|9eMas!bNb0j^Mm1RDkLfD?COV>Px=;c8wt*NQ`Y-Ge#S5NN&AW>-j+Z9Orcu$54 zKSZiW!J^kN9y$SKAF>_d-^{>}`a=Mvh$jp5RgwIik(G4_?h$g;i1#EY#U6sh ~Efp7H3Kg}5XAa>?~=N?_wYH(0;Cf3it}VPHwf{m^pr1`-X`$4I-ly}jt{ zk5BjW1r%lO=eOsPz6cJKoA&nh67ZAr#npV)Ltvm*8M=r3tLjB8-ea`G)YR0* bQ=%L40Adc zy;7b|#~FBVqoKd ;^{Y(Wv6Jcy3sA~WwShl#!pr(WHCyieYo1Q;+>yjXD# zDg#y+c@NO!jIH>Is6HUTV7KD^Zkf7#Kj%4LR0-3IsT*%*Gjnr~feG6!zS{Nf7xO?W zDW`5*NNUjzUJ-^upAM;PrlzM+l^(L`C*kn=!s#jKacct-D!i4yzD8&6%GcjF{?_d{ z!6<&2&OG_%1X8+zS z_eueNdz@ho&^g|C^138UQv}OCF zAUtRo@4-!VSh>2Ldazw=zVJMG6SU(e5gs8?{q`kV@r4^Tn`1tIU_ cuaEYX{4Z3O%y^^7n@PQ^eF=zx&x-JZGy?#;}Cko0r4HRv%_y@dLcpyC}@WB z9CRy3Jxp?v)CW!_`L4FS6Lrn5kEoS&8Fe3U$5Ekn?{yufH|?a-y#_vMlz?Im1=BJQ z&$ eYcj??Zn~3BZ3u+ebPWIW0rB~ zDVvIlhJUpHYzn`=(_1W@%>nOCKTp7w$a`Mv^!Sq=a)n8~*hy@4yMA}qS2b0Nd4Fl2 zEImaaR92^0{TV1>g%P#BiJX)5{2(x65gvIghr0y>C0qdS*JBmMa=s#H1OoU2sC^en z3yzN-vBTETMy8gaoF1F;%eU-veez^@WQ>lceveqP0y_cqwqpmTIW9od bzymG-MpcC`^ldNWdobFXr>iS!^MgZBm;_3ynbbz5rfexLa@*uL4rb z_UMy_4j`hRp*Qz@kP}uZudGa!0zAwv5*7c3+WngrnZF}h7Pb=|;9^v@)T`msP_wZ5 z?yfY8%;jc^6}2N_8`!L7Q9=@!Y@0%IS^mKm`3nxHhT&7!KTNx;Z|f1uQ_2C97$ww5 z-+=^Qfzw&1GhYkjz%nGs55C)ZwJ#MiKan@^P $mPWl9P{1 z!-1oSshh}RMGsbmj7Z)bN@|1Q=;!s2F21_EJW#)PE>d#CFbDM)c&4M~5ZD*}E`&mK zqdVi2nG`4|9%Y|t3X&Ks)e+ |1P;7@lj5uvz?q!`Lq6|=N z3!(Ht6a6bKkaq4d8h-|86lQkScTwd(p@I*AkB=QiS=rc3LazRTAX3PA6TLbb-{A;H zRrqRYodUvGNn-0)pkcKK??35;#!3ejI}ahD+HUW1I`l1IE>R4?880|HRyBV40y@bR z2*m%~kZ47gA!S{s(A(`Ilpx4Ko? R3qQKi7r$R0AWmn2#8_dH(%OxB+6n2Kq*0 zG3!b;emC;Fle8f#gO-sG012ua{cU1&{szCQuUP1btE&tA-9zMFLJci$tpx^M7lcRM z&cHN(K#Yde=SHIFf1#zX0jNH|@m7umfZxvq@aVD%3JM|fpd02#!Zxe};tZaA$NCDS zjb$h!nPGPOJ6x#PZU6#v9zzI!h~V`$XTKh0U9m&yU~=L~>@z?dlMre$S(^Y=*)o*6 zmS7FO1)TdBm%H)f9s*mWL^szhX2=%+RUbkp5fH|NhxjY8)31I3g#QX!lTy4>HR`R9 zaUdia3E<}ub3q_(2i1qBsv2p q2o?c^lZ9@~628dhO7t;+*b+SE zH5U|_T<&t{{DHJy9}qPVJ<0{-h*qI7<~V2)scYUQCx-*9lb-zur1Lv5cWxH{U6&fD z5ps)))QpAB9)AF_wGl#hC}adifHsSqv%C+X*|^neu@~^x+?xt2LR9w?D8Z2u$)#6I z@d5PFIYA?1v;+l+cK_2KmqXl!8MT40@&vAH8Q6;75Xip+2W3n&g)HzGdjFFz&Zswl zdxV&;tbv*u4U84XtnUG&>nyRTr^U#8dw8=3a1n3(ys8wXiBLEI_|tuTeYQ?c3Luc{ zgb4>BW{e-a_ix}hscSYNJXCO zbKa`^aC=1-N*f<{D|7sAprjk&%i+!Xikuaw_9>8`1+($UnVFd^YZ%OZ2_+ycAYvCB zO&kniG(!iTa%q}Cumpo;J3%y}iLC>(^adXgzK!yw@^}Ij^2SdLT*1E#x->J37Zm~I zmw-3lf?yH^+M KTn ziQP|GBs^~*a2E*oY Ls!tCT>A#Ym~dTFQ;x20%xoqg z+<%9A`DSb!n2uq0ZqJj5Edj82A9!No;({n?Mba8ka5$+z)+u0)Ch8&Rfb;rwhdx&X zngbc~0x1~at}MjLTT^U-w?`SMPhpi{JiUVd*uQ1oAq2O-6GAbVd;UI*(0hG#T}Y(} zO_#Gkfjr3}?G=3i3Sz!T0#u=Z^D$dLVdKAmvv2~41&IieiI?hY(`T8D@bjEV{eVQ$ zF=_}kqEDtg9IAN@v5prJ^IHflzo6eCYi4E^+Hv>FsS}JsClD6>6-UZTi`@zOaXWJ$ zRX~Ju6ajsRyCXyg>>RZU7|IMn?S2!qppduys{LgLzrqi`yGaS9 YM6I_o zGOp|bIBy1C8F0j7&_8AOsJEFzkVR5p4N%-gDIZNMC#vS27TJK#FT{UuEft6;4r8V@ zTY=ZV8$%|6bo-{vksP}o6`}h%+*W`TC~f%t%a?TGW5#JFraDCdA)$v5Q4GPJU&whZ zY;Mlp%E~GYYOM=5?o)UG4p}0K%pwQ^T-yKE2q}V# @ zky8MY1}!5as{5^pfe;6f_lNYi8YpI5myvN0ye9vt<)D+FiN&6Wti{R2^#w+@pxoVG zO8mwu2q0n^=5z0ZkN`G~9M5? DC)YN5wtC83bd3#*={l9o2IRdz01YH7c)dXf( zNTCT;A5702g+fOT^jme#r MM+PlzJ2GVY6%}UIS+kq)3Ds7BO@LH$MEP zYSI74I?%A%Yi)))Yp6*W*U_N}6MO!eby`p)BjgRZP@EX|T??=80@F20xK6})5)6WO zlbyAn*j}Wep+R;RjE6sm(fsuMe4PLD>eAAa5D19VMIWU?hA!sj<~9H+RR+eevGMU4 zH8o5P@ozt^uC8{%xZL{EIIB1iEgsw7Tzoo5M#4Z3iDU(kZ%G2ziC6=|txE%C3F6h7 z!(7wFV2ngGgzo@CDz~ukuACg@GhT}eFs2;ZQNQ6QU-S`%Je~tzdko|q@^*Gy3=9ki z;{-7G0kQ?G- r9U3j2_@FjyDR_H1po6(2rC=XB86AT5)Ih?D|?p! dZcvi=(kD2={5}CCp{wZPq=a*$mh2c%LXaCfsn~`u#JyYmCok0qV z)FEQEZ6uQSf7-hKz$7M*W&d|`{Et5xHU0s9@LfuZw8BE3WfDy1VO4yPiUInRluJRf zmzmsqweTOz{^t_>^No;HmXRETf36v6hGcD;drilwJ3E6AJBO+g>RospfC5R`dkJc-1~?p$dVr2W`i`&-m5gVA%Kuxj@qaBb^1c7w zoTEulc7Y*^y7zvpj{29|9iSoeK%N_dG{_)ks|@vnt)bXR{Qp>|fB%~D- s+?u0Xs+=JgX#sT7#v*5{|12n^=ID6Rf&$Bx>x@I z$*sMv-rg6`*JXm{m%lL&$V-qYd<2sEB{;NU%uh#d{`Rx9M4kch9P%(yk~RQ%a@N(? z&o2B3a&%89-EUg`h@vF|>ixf8Us18H$h$5Bu^IvH30V?E-ir{x5SLeI{l~_|VP=Pb zWcXcFIrbGIAAY3x-xc{Ef6A%+6XoF!oYvIS3xWkd12WYe^ 7SN8c| zYw_Q|mzI1{?|kr|o9t)!qqVgWeo-KoPIekxAHFCVX6jAR#6E;)!e9Ll_wzpwPN`WJ z7YD8u>L)N~KL{)Y@C_iWKXp9lWMnh+UO^pz= j&_yd9g8zV8@k4uC% );L~s9>e5Z!Y;W`Fwb5c_07rlEn`P zX2o{q{^%1t9ilBOR{Qw{1< (|oX4K1>;>z1-Zi}63_C7CxX zxfk}I>E&pBy7AndtDb?8vGTL}kd>LoST0Nd_&xsVA9JIZa(xf2=vzNqO d4b*y|s@1b7uK7U?*lM zBksxiK(s>~v!S8pyVK@vnA7LOKyB#HclP*Yz+99^X}>{FFdQ1TM*)AqeuYB;Uj+pP zq0DK5LIV**`$A | z*+WNH!)rcP>F;FvrSuO0vdGTPM(sY_LAGDGLNpYBaByC~K0ZDGJped#cdnjB4kjGv zhh-NeI^@u3N%#t?k%WW<-+G;9L-fluFB{am2#fM<$0Y}}*kA9qcAHg+)2}Xp36snt z;7G}P;F*0Hnr#%CAYuYz*>96BAlKO1y}T5-E4O+F-2p)Y^5qmrp$F4oPs8`eLvnaJ zk6tU$V?WkvaouL>dDEj8!&PabKVfc45O#W}EUWQ6VEMXK4Tb|-Yi~)dYZ9P^rba-Y zxj73`rNPMh&gWn#rr`Hpf^!HjC}ZVOs70ZQyd hJLrd3NM$Gs^S!ZceiU(1cF~ z4Y_9oFI!t%rI&4B_llOxtif(GZ|J!SakGhyZx_xe={D&`<}v@??|lnDo?B9)C?^-_ zOy6s8RSiBo($C{LzqS}OEXsU+juj{6)mC CJcx_F(h(E24cBgd}CRAR2e~AAk2mgbQ z?W+Uj8pj_?6* w>_DhaQ8Iul+Dd>ofs3U#Ub2_%F zZ+ZNb`VBVZQL5ZhN4_)TIQnx+*T(zv$l6uzg?jNr^i3adigR1gUJwu#&V&dl!w*#l z9jpnJ0MnBnmq7 z?4k08gLC}&ao mT|QIhMq) z+l(_@?I~!$-g=juyjU`j%hKaQ=97_p|1 ~|ZyvZca`MU4_XBiFRZZhhiO(pfi$s&q6h>^>ed z&y>
>FBtC3+qfb6KLJgYnzLvKJ#Ko5OGN%wqkHxLcsn=zy@OdWJKHr>T_r z=X-sHcD`H?yMF!pbMuHIoF?blRg? ARJZb)JSQ7 zfHk9w6QVu2j&j+u1X&r6rZ=%Vd(7bqfGnn3(});~Em4(4j^>~z1sl&?!@Q`6)w_Re ztC|my&ys{&^QG^j{rgTaEHzEK+|AUI9?Kk8&yBo~HpkEza}b4h?s*GFTqFN q6jphdqoNX2PwZ4JHUdyiwe8pOEf=H{@<+_|^Y zaS_&r^!_I4{ZGyw;l8K$634j8WBX*7RP>;i=HlGCgE*E?O}4Ct+ifxAhz3s1{ZPQ0 zJ%(I+3&B_Ei`Ia*CR;c9K6lsQ4~5k#e9vls;ocBSSP0>}RUl`#U95gW#%sWD#W{|n zI;~JWZZVL31+|*Au5%H;Vjb{IJrBnt)65NR76`bI>=4L (OjS7f_e}C82KgJN~A`6QL4=%&RW++Go zD3;M>fcpWVBeM+%$A>@*(Btlqd3br{jf~zq=M?P`b~F8SE%edaQi{@A!W?;y+|HuG zs+?UG`V4<4OHq8rHxB563 $PuHJ|oL4Qi ohMgA;a11fq`zXirAFQdC9$*_O87-za@GN-;rlNxwhx|mr%Pfh!1*M z5-#Cwx|0@N)6?tf#v%ub#(L?+D)#4ujW24Yj*}X3($Q WOA?VI|vDo0NN!9vr- zWPXRMDi=uKX~F|rozKy4Epw?nu+D@85+C<%ZebY@wbsr$Lw|GYx0Y=|SF3;7lk3ua z<{VDeWP4ql-=9s=C94@>tP9m1nlTudFn_HVJ UbcJd>I-D|t{e-(!V@48S!tb(q< pf z8iFdSc%Q#<_#87m?j|asUHrbu{`-#E;8SzAFyRTeX+j$J0{G~o;f`sVnJr3#!~fL0 z;us;|F%(|TA-0r-#aGZm{Ds~+)`o<%wx^vo{XPpZq Oq|Whes6~F;pyg^*W^WsMo3LQ+VIJur0w&-zghr7_RPQ! zy3rC#^o>4?DupaYU**(dWqT(r^wnw6ZS|DgI?T#T;%wlARonH0dBWX?hddG$^{b0l z(^qp*gBY{LkT#oq|Kj=Jipbcb;X|_NnqS!`+wbH!#!_O n~ z%<-0PRC8~4wt8_&%|q+pBd-;LzW77w b2)i)l@syy;{OW{ln zu+p{bDfckvlKvCI>GnXO`SQypdkxQV_H|52e6x&@EWHz^*6P*Ev=vPYx%8@=FZr>2 z7#i;mTx4J1y^#I~%|jcb-A?qq6;f#SV>0NnRcBNodjL=URz2pLn1(=ldX1KOe$pQi zTQd(i%*HDr&1vNrgL{D(W*Og7^2E59pSK1ojaj-6GshaGKopMc$kLi-Q;HB&>Z`M@ z)@Qvb+Ptz_Q$%$zxg;w!hv&wRi4iOst9g;`jdDRwA#dO3?-U Z8(K=qRMQE_`!lw=FL(-@T=(gm=+4Ic@be0OwLu zING(Hu~|gc89ifFy}k$|Vj4(Yv@RTU -d!QIG6o8{ @B zemgAgV98@Ku~Nj*QC=wdK5Q*Gv?poOW6v`s1X<=YauxhCdlid>$iqVHH090xZ}p|= z8-zMsX?rZRQ{Q*oO8w~xD(9WVYSeDS+tR+jhSFT8Uu`k6em-?4y~5tSe{6vC{O)JE zH%4te-1TlTO!F0F8|INuZEB+p=S@-oi0~U4= M;wh#!#J9MYEQKetgmn$(CJN^s5r4n=uEW!@H*`}kIi2)k z<*5Is4dvn4k07^82P)})d<83a!M&1*bI)Fw-h6h*q6ug3W2 |z+zDis|?X}^G zHkzB5(=~rYR-*RCqzdZzWXxb> @<_5A)&jXDVVt=3Gm`bj9i5A#D2tUb!(7e-mOU!Hdc@sHXI0)=cy4crL5$+G9r* z;ruS=2gy@$b26i6v-zX6Sb9^ZE@=LB+?ClI6g(ZL7|vSAFPj^o?cJw8=xeXJU`4J? zGvxTuzU)uTp=xz4dh7mCqhCYUc>9V%6b*__43%kmEBMW2`!v+@sR~<}Yt21}Wz#!_ zUe$tq3yAM_l% Sks*o127aOn={qj-ZHox2u_ROUbFQC%fjDc7jZkPRUw` z^ZOWcbAnCAU@%T74$Fcrer2^9s&H?|x#aqIz*h5kLZ=SPastC$Nul`AqukU zu_e FN#N&2gy8{s$R8;*9RSr*rn4lIuR5_G?`?u=Kg4mERUPsO;sIl@bS_L*UB z%tOl1v`g&o+qG)#cI;+*sum=$0>**POBky(ofef_%%=qW_3BXLj*McgJkoY-MnXSm zhwi?_9=_Txi5HHe#} csF>X| zhMbG!79&up`vpG-zePKh>x%_ivH#dVK2fjsXvpP6w~WUa-tW-iJ1y%forx&1e$~6B zd>Fip^0n)!Exw%7aY4xz+<`S6<5k $yHMSJWiO%!)9!19T%UGrjI7)Bfar`?y zfZJ1}nx@j`c9BZR#%3o~2|8j3b$lh7m!o^7NUb}QquVIO?upuX9R^)qCt-O2S4X-a zvuaN~JYOnPf#w@BX-_aU@RuX24SPv4w|tE-;$|FWI5f-Ox3eI;WWsVPmWGTXvo|@& zc)XqtTzD1{gi#z3XspLbS3T!``6%(kgkURUoVb)mVzx^zdt7Wzp4H$Sv%|+lL%WfT z0nm@H9h}k=V33E;l+l%2|KKLjcFrBzaF0%i^ItJp}av$sX67DLRnHBagHkteN898V9dDz=)K*-SE06OX>x z7SG$gSnGr;=VhImAM~hTAs#N$P%|{klfIWo3@wsWidUp6PnPjwkMCo}``6kKR9)BN zW2P2D(G?oL3SMU_8XR{{ep>>K^)eTv7gPo5lcSvL8`C?#6huw>`I b#}7*tP!9RyHIn&dNZLe! zC5}*fsNXA |hD(l3e_6p*&ZMWnFPyogC$&>rf(l&ZY6 z=KR5;B&hK>N{I2AgGp-HWHz~c*TABObYc!~JhoyaZg6o@bJK{`a6rpTtvLbRIo@kV z>ZaVW5*Ezw$lp*Qh}b<0%VZ?3uivWPj+Uq>mys&t`=H{UMf$_Avn-a{g2N3r_1o7k zni^Ob3_KrF&}3;O1>xwKot=EUOXQ;rNJ+z#s~Q6zh}Zb4OYUr+of7W(YwX`W{K!XR zI-p{1HaWqk>+?gh*6JOv;@ug+`FRQx4d<(Lz0%qYi#6Fb4&>c8VazHh6Khps5h+SP zQd0`RU~S%7Tt!ba-SB+6*t_Z8ru@CqwT6ujT;LozEh!ooJzdo1mHX44x#h{rJae(q zPK=QQHR^NPP35LQY=(|TY|k%s`X09%ePz871rG;k5Rn4(E&LdL3(7xxwYKPg|Nb46 zLy7xd{qQ8ZG;nF5cm`dDom31M(!=OYkiP)ec&CA)cHBGKt5Kiu#^WsAQiexO4oBa6 z6Oi>< 6sPbF9DP)tu+!G23UV3@A<8Gp_c9x{nIG zw3@$*J?T%^WN=eIeC?LNgD=~v@2QOc=ySZg<>=M1Dkj|f<49bNP8urD)t076s)Ve4 zu}cWUN24^Tf$i{0f5q@YgYa5RaPFk-XuLm3+Dk+9(M85}yDRFHC+7|GUDMo#z8!S0 z!5v7xK@)WhgRZd=4mCDu@>{O-JE%U*{==o4P&8sFHNVA~EVkG0az$E?y)cEUTxZvW zQ0Tszd%N%N+z2*QBVQ5Y=ROjrzR0%Ue<^iM2Nm^UD54q{)_!!cdXBds6cbrBWRz+% z>f0kvEW+K@Jn1}b;B`7StIY3Byyd6|EoU;f2JUEIq&{CS9rfzRW7j*@b$HPXy}AA$ zNb>XLVv%+7ngOY6Z&8E7F#?Y(NpTfg?S+5K>#}LG*1e_^lgH!VlIoe?rj-rUJu!cB z+sNEJ#ko~3J!&7D+Al*||Hv~%Mbyr>O2Y*m>5>s(_wg|x$3g4L5nP#ptVeWuU7#)S zcY4lv5E;?~$r54~3_?UiXZK%cx {_L?|9eM7pud=5BF#q#_zYR?g~5rNkRRr%DDH52@OqEYqAMmsySu< z_W91w=^Q* {tUj@TR1ZdDM!|)$m2Ar+E z+YFXg*4CH7Y)e`+A4uE*nmZFM`X0s6ESOfN%}e{wB|7PSMGW4X+SJyhg5ne=vY|?3 z_hpfHz4a5*8SxQ~D>3WDR6Vm~wNlZ|K9|7wYg}KwXg^yDLo)5JE89yOd5+6zk4c-C z@vBQ#+%=h`u60Ibt*Q{;@1f`mJlk+|^P2B9u$L}C*HfMbLuS~~RNp>dH)mZiALneZ z7@K`*^wGT5-eXn0fkWFeZEYrXr@x$G+iKL&?QPOFh lbgss>c_5-s~p_&CgL=mKV1=cB&rZuzIPbQsN=HZ|5%<`^S@Y@TaYgx~mEjGRg}C z{=CcQmmSTrY^-W=#c?$sxbxNKDT-Bh&vA#_fGNkSE)|!%7&HBx_ yNimQ4wA|wsVthnU|GFi7w81{V+^4BA8caHCg;@I zSb%B%?qEZTZjRgFCy5^(CT8zX$Y8yEo=fP6{t{%Ss}o7%zv#mA`G<3J)WCa=8I{w$ z7}Lxve>8xq+qQOR8pOsQ#^P99x2A4Sn$G!+K0}dNT3!3wZgTMEhq|f1kTsE>Zk398 zJEu{=-y+uA+$*Bn{1UbHU1(So$1$C}cE?hoI{ih51M9+!m(MRsS3Rbl$0A8l1zHwA z*RV;NncF8x+V(MSY-Zd>Hlsf!4^6irE&AivQq ={!5SDx&EWN dc0fBPW`Mau9W&LwT=>77n|oDf2)8L=yuR# z(sj;mzS`DkPtAglpTDDne0clrT&5;_Uyf#?Ov;}NdF{HaZz^Wd5%sv}VgI)8+Jw?E z5V(TR){CAVuZemygPA<*r2U`tWs-e4b2%~klBZI=jRrP80>!d>a_1XUAQ5f5wr*`Y zCy3*fK3d|?1mzxnt3H+seL&xWWVx@q@00R&S&yo(WlDJQhL<{7W6y!bgpek={(WW; z6rrbS^)IJ#%Hee8u0}Y6otnl|*Z>1 +32_>TY7>4iXwwJ@QmHqVC`NSWcFt-whx)Qv{5hNdke!BmDSxZza+l5r&lh|e zF1j{)UXR8drzgTB^VF>Kv$LCoJxF$E1Kfwztn6beqy$eVoc&0}tajh^0MF@HY0&Eh z=>e6S-Oh>Fz6X~(-k^ND4v91;s8dn+N8p9&hxm4Kztq=lr~7tal8reYD=Aha^Ceg? zIN#!>)vMdd{JR#@Cd7ofryE-I*u;c{)29`LUgfQY_@+~SS&cWJ@6G+INwy+bb4zCJ z4v>1iG>@8XGsN~zpfREznK?y~H5iHRhL}zrZMVAXbZ(P{!x*bw1F77}5?}2Uj>+fK zwGra`1Hv|r-Op$*rRsp_B+p5U Op7_DtVXe(o%3^^@4$+VS)psdZl4E6wUg;M z13YRQ=kKsoxT`a+CFE8X>3+D)tbk2#FV0`gIcC;0j&jGh^N4k_;#5Ju%uepDbY;Kp z3?a4Yrs3ns10JTZ0_=TXHoR7!y?TLA2s1OJL>aJ;0dX(+#p%djGlsJVPnY03zT_C+ z?e;s|lCnA;Q8`W3jI`Q8NG=1jEQss_o>Z6Wbib8g4`T1#@I7Qi8m8fZB}GQm**G|w z_jMbQkrNyacStJF|A+j6{t_yBX1Sd!&r%LH`Q@e--E#2C7Hb&3!Dkh `xCNN}c$&x&OuUhTv;GL27Gf+*{#bLi#MdQ4|0PgD}iVb jc zh2=jzVh8N&-N#)0?07C@t#LJqMZSNg&um4wyz~C2!XS2G7vswJqeBz_$PTl|G_TEI zmn!PKa64AUf#1E@m^q!we32TTKK79`G)L7m!E|(3YQ}fnRdn}mQa@@ Ds$CAWfM@Ng=u9K7_EGp$(2ve zAD@3ab2>K=o_IkH`T%-L=y&(__mg(B)O&&`+q%3`-agJhyQY*Cjh;z@#vzsS9~0*M zRcka8h7~7gUb(`V+IQG8P7xPBcM(THpxepxqIXiiT>tq-+V&xA0hNb%2d2aHSy@Fv zc&1;bs>4VQc|zisGwu{VTa{}GCGp7eW?!6Ghd@)g<$Yov(L}Gq&`VP?x9%-*sRA`m z$SpagE4@>+UaIy%29NO$Umr?JYgfVL0=W6Lw(;V`*Ev(>>(kF8&OM+%QB-5Qoi;*C zpdll} =)LP>davW*pYC-G67& zYbY!12V(-;iJ#m8X;7EvDY3)meQXSyk5??D*_6%NbQ-q5o Q`}Mpg?g>t+v;3V@F+bmVi-e>rCf@@6^nR(U%lgXJUUak`G<-2MHSD^ib$x z>!f5U+UD}HW`=Y9P%3n+0T*{w#(;?jGW_M?fvx9?loD@Fu*}M7(XNAZwi|BS+d^^r z;b8x>^;@**sdDw->+Z<;WJ6T_{Ghz9js;;ucYAMMHWpvPA5S#t#aDqMW z2`Np+q+yA!4OPz-P=#%3Kw^Uk2?VwKXHsf3%;YZKL-zLjLDN8@V(-y@LSAZxhK$6( zJl3htS|$-)L5qtf87Db-2Z)hf+r`5l 7DY^% z8jNdAKXY?nYFZ(DI>h`Y{qv!{nX00?1r`CJ-qhjjlnW{;);2cakkQ() $;3pcv1Q#>4-7I-H(|`0-zcKr`JYsQCa~b3MB2N5FUWkd_C?djJJXo6(*BrIoh^b z^wn`-g~I?dL95Kxmt1;+8C)57^u$^Y`Xzp%I8ABe9^9*GpOJ!#K5~a9i2dX9q84Sd zPu(r|DGky1yMa`K64-H%ho=$_RUE&^k?ejXmQKGjE;36d=9bKtJoAAdsl5{PFUiGF z_jhp~UsrX6TMozd%*%Xnx`k+;tj> +`ub4m?!Gwl z1;P>{PlLIz?bIh9OpWnvE3_FF?!jv6E=&u-*ZcEodV-Emhw?GpK9#m!$};*mGxW-E z#GF6rWmtjRgCNW7$@C{q%_{D}-R(Aos)uG-!hLi-dOJ$XyMfepw#wl9bx>VjKYmHN zT%Gs|@}}vhW4SdpvRH_A5Gbio1x|#S QmI}P^s45TUA?Ct54P5VwTu5h}Bj-R?*rLs-?AJ zMwJ*fV#i*wgCKS!2;q14^Lu^$LH>}ua^Ls+{XXYh=Q`H~#NaP)2v>^=!%LZ8e(nQc za-auxhle#E7-IvPN0$(whaw_m_A{M?pJHi%5_I_yYk1hpn#QGX4F*k5LOI6rnD5-0 z15V4Z6W@i`em_Np#XrP8&;QFv`0HE^Btq|WC1v&VD*I%AigB4wzN;Qx&TcKjk2sCY z0qe-%Zm4jLZI>N$aZgE3h0o*@NFLc$M&hU6!?44AMP1 -`- MBS6T~i?%~=!iKU3Z!d|M(KYO B$6owS5 z{fy0jsA=RhrQn!{*Ub6G%~gtYLKs?^9Dyyc40osQ&?-oM>tUn3>l}|*%;3t*LtW~j z#%Md5(HBEv)&yllY*_bGh}F?o{C5S{?iz}gkE*}0;r1#IYh`{neSAigy+8(w(cH(g z;zpJsmLj Dh%k zKvC=~(8RyQjUEy8NvJ{q+|)~Aimt4v`=1c%ie|?nQ_zVO%7g$@^W5!oZc1S y@IP?wdG^