From f634e41299a8cdc9b7a2e9d351abe6deb0c21507 Mon Sep 17 00:00:00 2001 From: Reza Asakereh Date: Thu, 15 Feb 2024 09:40:48 -0500 Subject: [PATCH] Fine-tuned models can be loaded --- MedSAM/MedSAMLite/MedSAMLite.py | 130 +++++++++++++------ MedSAM/MedSAMLite/Resources/UI/MedSAMLite.ui | 28 +++- server/server.py | 7 +- server_essentials.zip | Bin 36888289 -> 36888273 bytes 4 files changed, 122 insertions(+), 43 deletions(-) diff --git a/MedSAM/MedSAMLite/MedSAMLite.py b/MedSAM/MedSAMLite/MedSAMLite.py index c8aecbd..24b6f65 100644 --- a/MedSAM/MedSAMLite/MedSAMLite.py +++ b/MedSAM/MedSAMLite/MedSAMLite.py @@ -32,6 +32,7 @@ try: from numpysocket import NumpySocket + import psutil except: pass # no installation anymore, shorter plugin load @@ -213,6 +214,29 @@ def setup(self) -> None: self.layout.addWidget(uiWidget) self.ui = slicer.util.childWidgetVariables(uiWidget) + ############################################################################ + # Model Selection + if hasattr(self.ui, 'ctkPathModel'): + self.model_path_widget = self.ui.ctkPathModel + # self.ui.clbtnOperation.layout().addWidget(self.ui.lblModelSelection, 0, 0) + # self.ui.clbtnOperation.layout().addWidget(self.ui.ctkPathModel, 0, 1) + else: + import ctk + from PythonQt.QtGui import QLabel + + path_instruction = QLabel('MedSAM Model:') + + self.model_path_widget = ctk.ctkPathLineEdit() + self.model_path_widget.filters = ctk.ctkPathLineEdit.Files + self.model_path_widget.nameFilters = ['*.pth'] + + self.ui.clbtnOperation.layout().addWidget(path_instruction, 0, 0) + self.ui.clbtnOperation.layout().addWidget(self.model_path_widget, 0, 1) + + self.model_path_widget.currentPath = os.path.join(self.logic.server_dir, 'medsam_lite.pth') + self.logic.new_model_loaded = True + ############################################################################ + ############################################################################ # Segmentation Module @@ -222,7 +246,7 @@ def setup(self) -> None: self.selectParameterNode() self.editor.setMRMLScene(slicer.mrmlScene) # print(self.ui.clbtnOperation.layout().__dict__) - self.ui.clbtnOperation.layout().addWidget(self.editor) + self.ui.clbtnOperation.layout().addWidget(self.editor, 3, 0, 1, 2) # self.layout.addWidget(self.editor) # self.editor.currentSegmentIDChanged.connect(print) ############################################################################ @@ -251,6 +275,8 @@ def setup(self) -> None: self.ui.pbAttach.connect('clicked(bool)', lambda: self._createAndAttachROI()) self.ui.pbTwoDim.connect('clicked(bool)', lambda: self.makeROI2D()) + self.model_path_widget.connect('currentPathChanged(const QString&)', lambda: setattr(self.logic, 'new_model_loaded', True)) + # Make sure parameter node is initialized (needed for module reload) self.initializeParameterNode() @@ -419,6 +445,7 @@ class MedSAMLiteLogic(ScriptedLoadableModuleLogic): progressbar = None server_dir = None widget = None + new_model_loaded = True def __init__(self) -> None: """ @@ -540,15 +567,23 @@ def upgrade(self, download, event): github_base = 'https://raw.githubusercontent.com/bowang-lab/MedSAMSlicer/v%.2f/'%latest_version server_url = github_base + 'server/server.py' module_url = github_base + 'MedSAM/MedSAMLite/MedSAMLite.py' + ui_url = github_base + 'MedSAM/MedSAMLite/Resources/UI/MedSAMLite.ui' + + server_file_path = os.path.join(self.server_dir, 'server.py') + module_file_path = __file__ + ui_file_path = os.path.join(os.path.dirname(__file__), 'Resources/UI/MedSAMLite.ui') self.progressbar.setLabelText('Downloading updates...') server_req = requests.get(server_url) module_req = requests.get(module_url) + module_req = requests.get(ui_url) - with open(os.path.join(self.server_dir, 'server.py'), 'w') as server_file: + with open(server_file_path, 'w') as server_file: server_file.write(server_req.text) - with open(__file__, 'w') as module_file: + with open(module_file_path, 'w') as module_file: module_file.write(module_req.text) + with open(ui_file_path, 'w') as ui_file: + ui_file.write(ui_url.text) self.progressbar.setLabelText('Upgraded successfully, please restart Slicer.') else: @@ -560,6 +595,29 @@ def upgrade(self, download, event): event.set() + def check_server(self, serverUrl, max_retries, event = None): + retry_cnt = 0 + while True: + try: + retry_cnt += 1 + response = requests.post(f'{serverUrl}/getServerState') + server_ready = json.loads(response.json()) + if server_ready['ready']: + if event: + event.set() + return True + elif retry_cnt == max_retries: + if event: + event.set() + return False + except Exception as e: + if retry_cnt == max_retries: + if event: + event.set() + return False + time.sleep(1) + + def run_on_background(self, target, args, title): self.progressbar = slicer.util.createProgressDialog(autoClose=False) @@ -576,27 +634,27 @@ def run_on_background(self, target, args, title): self.progressbar.close() - def run_server(self): - print('Running server...') - - # buggy_file_path = os.getcwd() + '/lib/Python/lib/python3.9/site-packages/typing_extensions.py' - - # with open(buggy_file_path, 'r') as file: - # lines = file.readlines() - - # # Update the value in line 173 - # buggy_line_num = 173 - # new_value = '\t\t\tt, (typing._GenericAlias, _types.GenericAlias, _types.UnionType)' + def run_server(self, serverUrl, numpyServerAddress): + print('Terminating possible server duplicates...') + # Terminate image transferrer + try: + with NumpySocket() as s: + s.connect(numpyServerAddress) + s.sendall(np.array([])) + except: + pass - # if 1 <= buggy_line_num <= len(lines): - # lines[buggy_line_num - 1] = f"{new_value}\n" + # Terminate whole server + server_port = int(serverUrl.split(':')[-1]) + try: + server_process = list(filter(lambda proc: proc.laddr.port == server_port and psutil.Process(proc.pid).name() == 'python-real', psutil.net_connections()))[0] + psutil.Process(server_process.pid).terminate() + except: + pass - # # Write the updated content back to the file - # with open(buggy_file_path, 'w') as file: - # file.writelines(lines) - + print('Running server...') - self.server_process = subprocess.Popen(['PythonSlicer', os.path.join(self.server_dir, 'server.py')])#, stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.PIPE, start_new_session=True) + self.server_process = subprocess.Popen(['PythonSlicer', os.path.join(self.server_dir, 'server.py'), self.widget.model_path_widget.currentPath])#, stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.PIPE, start_new_session=True) def cleanup(): timeout_sec = 5 @@ -609,9 +667,7 @@ def cleanup(): self.server_process.kill() # atexit.register(cleanup) - self.server_ready = True - - time.sleep(4) #Change + self.run_on_background(self.check_server, (serverUrl, 10), 'Backend is loading...') def progressCheck(self, serverUrl='http://127.0.0.1:5555'): @@ -628,13 +684,12 @@ def progressCheck(self, serverUrl='http://127.0.0.1:5555'): def captureImage(self): ######## Set your image path here self.volume_node = slicer.util.getNodesByClass('vtkMRMLScalarVolumeNode')[0] - self.img_path = self.volume_node.GetStorageNode().GetFullNameFromFileName() - self.img_sitk = sitk.ReadImage(self.img_path) self.image_data = slicer.util.arrayFromVolume(self.volume_node) ################ Only one node? def sendImage(self, serverUrl='http://127.0.0.1:5555', numpyServerAddress=("127.0.0.1", 5556)): - if not self.server_ready: - self.run_server() + if self.new_model_loaded or not self.check_server(serverUrl, max_retries=1, event=None): + self.run_server(serverUrl, numpyServerAddress) + self.new_model_loaded = False print('sending setImage request...') response = requests.post(f'{serverUrl}/setImage', json={"wmin": -160, "wmax": 240}) # wmin, wmax as input? print('Response from setImage:', response.text) @@ -678,14 +733,8 @@ def inferSegmentation(self, serverUrl='http://127.0.0.1:5555'): return seg_result def showSegmentation(self, segmentation_mask): - segmentation_res_file = os.path.dirname(self.img_path) + '/lite_seg_' + os.path.basename(self.img_path) - seg_sitk = sitk.GetImageFromArray(segmentation_mask) - seg_sitk.CopyInformation(self.img_sitk) - sitk.WriteImage(seg_sitk, segmentation_res_file) ########## Set your segmentation output here - loaded_seg_file = slicer.util.loadSegmentation(segmentation_res_file) - - segment_volume = slicer.mrmlScene.AddNewNodeByClass("vtkMRMLLabelMapVolumeNode") - slicer.modules.segmentations.logic().ExportAllSegmentsToLabelmapNode(loaded_seg_file, segment_volume, slicer.vtkSegmentation.EXTENT_REFERENCE_GEOMETRY) + segment_volume = slicer.mrmlScene.AddNewNodeByClass("vtkMRMLLabelMapVolumeNode", 'segment_'+str(int(time.time()))) + slicer.util.updateVolumeFromArray(segment_volume, segmentation_mask) current_seg_group = self.widget.editor.segmentationNode() if current_seg_group is None: @@ -696,14 +745,17 @@ def showSegmentation(self, segmentation_mask): try: check_if_node_is_removed = slicer.util.getNode(current_seg_group.GetID()) # if scene is closed and reopend, this line will raise an error - slicer.modules.segmentations.logic().ImportLabelmapToSegmentationNode(segment_volume, current_seg_group) except: self.segment_res_group = slicer.mrmlScene.AddNewNodeByClass("vtkMRMLSegmentationNode") self.segment_res_group.SetReferenceImageGeometryParameterFromVolumeNode(self.volume_node) - slicer.modules.segmentations.logic().ImportLabelmapToSegmentationNode(segment_volume, self.segment_res_group) + current_seg_group = self.segment_res_group + + + current_seg_group.SetReferenceImageGeometryParameterFromVolumeNode(self.volume_node) + slicer.modules.segmentations.logic().ImportLabelmapToSegmentationNode(segment_volume, current_seg_group) + slicer.util.updateSegmentBinaryLabelmapFromArray(segmentation_mask, current_seg_group, segment_volume.GetName(), self.volume_node) slicer.mrmlScene.RemoveNode(segment_volume) - slicer.mrmlScene.RemoveNode(loaded_seg_file) def applySegmentation(self, serverUrl='http://127.0.0.1:5555'): segmentation_mask = self.inferSegmentation(serverUrl) diff --git a/MedSAM/MedSAMLite/Resources/UI/MedSAMLite.ui b/MedSAM/MedSAMLite/Resources/UI/MedSAMLite.ui index 631acac..64f366b 100644 --- a/MedSAM/MedSAMLite/Resources/UI/MedSAMLite.ui +++ b/MedSAM/MedSAMLite/Resources/UI/MedSAMLite.ui @@ -98,20 +98,39 @@ Start Segmentation - + Send Image - + Segmentation + + + + ctkPathLineEdit::Executable|ctkPathLineEdit::Files|ctkPathLineEdit::NoDot|ctkPathLineEdit::NoDotDot|ctkPathLineEdit::Readable + + + + *.pth + + + + + + + + MedSAM Model: + + + @@ -124,6 +143,11 @@
ctkCollapsibleButton.h
1 + + ctkPathLineEdit + QWidget +
ctkPathLineEdit.h
+
qMRMLWidget QWidget diff --git a/server/server.py b/server/server.py index d97093b..a5972ef 100644 --- a/server/server.py +++ b/server/server.py @@ -63,8 +63,7 @@ def medsam_inference(medsam_model, img_embed, box_1024, height, width): # settings and app states SAM_MODEL_TYPE = "vit_b" -PARENT_DIR = os.path.dirname(os.path.abspath(__file__)) -MedSAM_CKPT_PATH = os.path.join(PARENT_DIR , "medsam_lite.pth") +MedSAM_CKPT_PATH = sys.argv[1] MEDSAM_IMG_INPUT_SIZE = 1024 device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu") @@ -293,6 +292,10 @@ def set_image(params: ImageParams, background_tasks: BackgroundTasks): def get_progress(): return json.dumps({'layers': image.shape[0], 'generated_embeds': len(embeddings)}) +@app.post("/getServerState") +def get_server_state(): + return json.dumps({'ready': True}) + class InferenceParams(BaseModel): slice_idx: int diff --git a/server_essentials.zip b/server_essentials.zip index 6cb3b8eeb7e4ea9ef358a18200996f7058f81ee8..82fab91b807d41a4ba7833f10be5ad91663c9841 100644 GIT binary patch delta 8051 zcmZwM2RzmL|G;s+WFDhLk<7>*$0{jG$|@zZtja7RQ5iW_CCX?a^0i7?8D%D;j7Wt@ zM2MnPR6;lPf4}Aa{@wfg-N)ngJbgc(&v$gbU+22_3|6%)Zi6^>7%?y|rNe(-8k@v% zNU_t2J$9mp;59_&qz?TZ9YVNq@$)m=t>#w&+R}7%E6Bk*C1M%LRzW2wG>dhuxPCt0 z?VzH#|(NeOudj=7&f+O`Fn^g9S&~^UGyF5@D$lJzaYC zJl7s)e_AmvokaIX6|KLU$J!R<37x-a-uhL?Wd2w#GBJA73El4+b&G~(+ zL z)#azt-tM)hYx1!lOVlQ@wxlFwj$*f{GJ}>-Czh%04A<&IX#H6nypJN@Fg5&4?3W6D zpTpHR!`q(8I^3JQjKiru?Zvfv^+!9IygF)w7~`r2mCtt3m!Fa_|5EZaMuTg_$suVMvDT-I)j z;<)9v_=pLYzIAZ+XZexYE34_P;{z&elC`;-qO*E!d16nix*h#Fa<=AV@e=Jbx7!m^ zITSZr2h25Rt~Jv#{gRaH7`$rG!Ci~G;^cvvc&iSMhsOu`hts+XX_0@eifk(HIaXF$ zUR_0Ly5&^M;9V{@9DZDz$&o+ytq7})xQqpReu=TpTS8hod2x+1yLfKqq8<6hH7dQY z%`dsNMd%-L?WostlzO+?TPupMV4u3Iuxsn0)@F|$y7fN#bP5@)WVc<4YSh=J_M|s+ zKk8e8?*>Ny`jJ@4Y4(}Mm@#-+!|dU8qGK0J{8sJ*g-4hBhe{6jEtcFlCH2)poT59W z)oR`EJYcxf!Kv5GgQseLqSDaOVcSkEWeeGM54-DU-pbtnl<;xp@=}w%e#=5dCn{uA zy1Xn6)Ezz9#ZC;zy%myfG1Xyc3%nY-XDByinn%v4bdjE7>%l=KmpEF5$Jda=^k+eo z`cLu|;!n6kmYh8ab$HmGtQ*Kf6>VXNtWZI(YZ*`}`VzOQ`C_W37v%Be}=GjwG zVDaQB7hAvT=NloFV!7EYn+s1xy~uhlJl6QC|L&ZQ=TF}`KJ_aZ0rpQ^g#AK~XwP|{ z(7k2<>h5W2mK4#P(&^Hc&T-o7UX3*;rY2qdr`}I#9~#Zdp}qG0xg@H-42`X; z{mT&0WWuM#7D4!FKK9^1=1vG(xd zde+%k_2i}jqx{J03KuLIO0AQXMOC@=L~coK3O=Zkzh1%LlP!7$qpqzkj~1<$(Xn}> zPg_f3ai8IUzgpws1nKHyRq1&(%VyI=A9yd)(Hk9SAM#4E-H}B38rYS=K5Dusm4SNB zM?^`xKy&h<)^L~1S>8+abv4J*_0CC64`29o@qWmt{dpoikn-Smsu`t7dUzQz_Ehz} z=vywgV)3xDz_HPR7~3r6+r$R8a+A$AMoq7l z`eubG*u4@u{ww%S*1lU^x}I`jUXk(9ywQ=*#-&c0`*_xD*vxRT(Z==oiP1a$H1?l5 z%1@axb2~hCJKUqhbnQ%f^7hToZ3aUXxiVjwmL~*9d|fOg8Di38Lihgq(Vi1$dX*@H zu0EBo6fC}Vc;6{1__0!BP+ZGom?u)>IkzKqa6(GMck5+YnwMF$6aVp*t*V(~TX%Q# zY`T`)t7>$EbC$nBN#5&V1gCho!b^V(n)0Pffoa#iX6UqYM&24t7rr2ActLepTi$&_ zKswxp>sQ4K<74tq?4`N=I_4-2b_%MLeq;Wh^|PCZ8heJPjXmZjZgn}%%w{Pq7a|y~ zFT@74t#mnag;`E9IN{`?iP%ktv(B0Yo~ah}#1F^d&i`uL??sS0J= z`!czY(yuJNvMIh@&er^4=L|(|9yTiFUpH~ZS& zsDYC4#X+^LSo?g8hl98G*<( zAp?Bf5^l|(qXQFT8P_t*KCmzRc%M*v=bsbIMGI&ADf7{Uo$^fU&Di=zhJsV~Z4N~= z1&cIoEMOa;e0EvOy}Ze}v6H)>SNE*U`F$ItS*vsGURsQ9F9Qy#n!h6w^3M!D`uQ+pNHB?B$h+7m+e!rVJc??n5B8mSu~_LRUeMfg?~_E z>6MNB#3W*vkkuTR;<4_%U&;Ctk{*T}Ge5(F=sc3I(RhbAA{MQqk5tlGNz4uh3>nl_ zysg&p2~^%OWcBdl?Ph9CkrJ!tyL>HKcBeOtf*Z_M>&9ITR%5r*a1{K!eOR%pYA&T{ z=%ip`LRxR}2-{XgCfBs3y^rjTzDNqX3v8b`@8F|YBfH7K`>7*+KD&yY=X257a`&&v zFM|V>15QZLj%ZvTvs*$(Ir{uWR!Z}=10J%XBjcR0rE1-ZQu-xg>TVY14ow$MW#JoT7k_zn&oE}RBlAYw{H6Co%UgeM6DgE zrhe?qjYUCyD{2!nY!}NJE9~>tsnPS2jN50l`IuhnL-ZlDyM5;@n|MTY6!H-y=Lb9Y)=2y5s~zbu)CU9&$w1vnKx zS#tYshf6~cgYLA|nu@g3RDr@7@!kkyGHfaNcm$Kblr5FoO;=7??lo(P3aQEbYV z{x^=S*}Ze7-Y7xGR_v}~R?!Gee7oYAr0tBuJ}vCgdD)w%+_;%Cd=(jg=fs5dDPE4tx4Rt;9J?fPR$9{z8=94g zJl;K~!W}d2C!QT5DY08#)9+PMh3ul%%KbEp<Wn7^bZm^wfE68h=exZnD73S#Y3 z=T^<+2JAjYUKwaf4rsKrs?7JPNORec)6H2eYo2-cnMJV6Sc~PX2u<~MN@Wx;wJq;k z+~;l$wW`Ga27;&8L1$X&d2D}0BF&KDuw=m(t*gBujaQrBGObN^f3qR3!g}Qi{Z&u< zm3k{yGQVvoeMkRrmp%L0DaE{J(YG6igVk!RULK9k{}y>R@oQtN!G3>=a$`PcHdB%n z=a7=Zh}8|&JSvyb-j|wE_O=gVz|ilE3C$!-D2yK6B7GG zCM)IYWUjy4P!me{wf7$} z6fp?%v@T2g!Id(}Q!%>8M#|B>-S?J|Q1f?A`^_6SPTB7;VrH@Zv^|sLfdAx+KhQ}S zGw_K$ju-7Bmd5R1i*M;7D5qa{5)cE#2rYt`AZCaKVujcsb_myUK%5X4#0@QmmOwnv zQivDggO)+dA$~{zS^)_{DW~Js23iYgLhB$cXg#z6(uQ=PjnF1Y7upQzL0h1$kUnGp8A3*oF|-ZZ z4w*nZpq-E@v>zu{0on`ggZ4v?kQ3w#xj?Rv8*~6V z2)RQZ&>_eZ@`4US-p~=q2l9oELdT%vkRRj^1wet&2`C7nLBUW66bgkw;m}Fw6m%Mj zfX+aXP!x0)ItN8VF;FZN2c3s5Ko_A)&}AqdN`MlfD^L=Y3|)m%AQDQ2(x7xG1ImQ1 zLD!)y=mvBX%7${FTqqC9hi*XyP$5(V6+^e7J5UK!3Y9_SPz6*8-G!>4dr&o01Jy!x z(0%9uR1Y;kjZhQx5Nd{6phr+E^cZ>qJ%!q!cBlh-26aMR&~vC8dI9x7z0gaj59)_r zK?BerGz7hdhM^JY4fGZog~p(F(0gbc`T%`|K0y=EB=i~j0)2(1pl{GLGy}~--=V*t zAJ8216Z#ePx|2QzLI{KbVMG=oOb9c=g0LcN2s=VSI1o;R3*klF624Vjl4j5kY3~^(uedTuaE&`5E(*V zBg4oD@&iq2m`{1EJBzNW`qS{Mc5E_go1D&oCp`fjVwl%AUw!YgcspMmLba#enbFS zfe0ci5g|kv5kW)|F+?0$g-9Th$ZA9ikw#B#u(5mEI~5eH#Cot5y(JCYB}rYjj}`?+Rr?M(E#jxCf>BVYf1I zX4C(kqphG4A$$Hk_xmd3k5P$$jSTRe2ANYvCDi}?T7!&(FD(Q~mU1fbhy%aa_|I>- z334M`5KsKoPJ$m#Isbbw8C(Tle~@{wMu?&6KWpYXi8TvT z5|ubr*!9Qm+@u=}+A*wozA#Ak!L~E~7P9s(m5?CaKuh|*`7M72{hq&fjp!j1=Z`8w zcqEWIHB@3LSp(;{CL;3}HWlwtiJkPeWC*;v5&|{z!+fMojS?X_zbgx=SWP9w2=lu6 zDG7?X83!G6)U2k{-*;Gq6oDf%k~d(T08z5AjvRwcb9#AF8z!TOxX$0ZlS7?KgdCX) zgWRNiE$+eGyD)VKHZ6$MH>6G-l~DMfCxQtfdNxuUUc-r?(cde{?olN|df}LI_wkrl z-!Du}zgHrZ7N$yYiawHTgVzY6h#(g?!g)vvgEFDKu%!6`?j1#0m>Qud!`txtVh!tY zYLItfY8{_4?2C7kg$!uJceWoEa1tp(^9wY|@lGni`kzN_6=?-O3h8#1r!JB>f|}6Xe~U%5Xzt+wtvDY{EURZ(P`;9~N+vf}8$-LG%0q;VlabL>^KJ zCt}0Sg-tI~nOOdh19CRw0W<9v7SueZ61=3a10L}AV=2;#H!^>IS;@hLWq74aNCkI% zEB;(&+=54Q^O#?@mAnO;JBUfY`M*dSu{363cf4!|BI&}yCSD{T84D-c0XK3GUNeZq zyoE`;A}3;`e121cRC=ULFp*mD!~XwoXhV>*e+(M_>9LFG22ojz8%5w_HNv zp*YCMn7?nx-w$98JaL@FxWN2k<^Q^V!7e<3^@@dQyscZwg89K;f(wHVFQ^0$$qgqn zp*JQS;PoPLPGw>8Y!7@8EId$uJ_teGc;IsVKMwrAo);cCwgBO1_{XXC6BRq>H#d{{ zaB%o}f`^5L)P_F`5WIW;IIlNRcyxYqDLD{FC76DHI#_1^_s5W7FYe5I?9bI?FbwV@ zJ_r3{@DmJL5fZWg7(5tDg%8c&Kl14>@ldI8e=PX>$>Hk5skyX2rkF@Q7~BKTPa3=) vChBi3OnPNe2{w|efJ(?N9N42D_l~~vkG-GWp%PML3ruk{orQk`(b4@MG;e)0 delta 8046 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 OFvQ1x4*q#zVfjB~(