{"metadata":{"kernelspec":{"display_name":"Python 3","language":"python","name":"python3"},"language_info":{"name":"python","version":"3.12.12","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"kaggle":{"accelerator":"nvidiaTeslaT4","dataSources":[{"sourceType":"competition","sourceId":117682,"databundleVersionId":15062069},{"sourceType":"datasetVersion","sourceId":14871255,"datasetId":9162764,"databundleVersionId":15733423},{"sourceType":"modelInstanceVersion","sourceId":754664,"databundleVersionId":15736721,"modelInstanceId":533084,"modelId":546812},{"sourceType":"kernelVersion","sourceId":292173623}],"isInternetEnabled":false,"language":"python","sourceType":"notebook","isGpuEnabled":true},"papermill":{"default_parameters":{},"duration":209.891144,"end_time":"2026-01-11T18:42:57.527346","environment_variables":{},"exception":null,"input_path":"__notebook__.ipynb","output_path":"__notebook__.ipynb","parameters":{},"start_time":"2026-01-11T18:39:27.636202","version":"2.6.0"},"widgets":{"application/vnd.jupyter.widget-state+json":{"state":{"0665f7c44f314b3f917647b2d8596beb":{"model_module":"@jupyter-widgets/controls","model_module_version":"2.0.0","model_name":"FloatProgressModel","state":{"_dom_classes":[],"_model_module":"@jupyter-widgets/controls","_model_module_version":"2.0.0","_model_name":"FloatProgressModel","_view_count":null,"_view_module":"@jupyter-widgets/controls","_view_module_version":"2.0.0","_view_name":"ProgressView","bar_style":"success","description":"","description_allow_html":false,"layout":"IPY_MODEL_2a4ff535c7554c44a3b74d217a96d183","max":1,"min":0,"orientation":"horizontal","style":"IPY_MODEL_daf7ec7532674455b832f42fc9065b67","tabbable":null,"tooltip":null,"value":1}},"0b3f1dc5054c453bb2bd61085b03c37f":{"model_module":"@jupyter-widgets/controls","model_module_version":"2.0.0","model_name":"HBoxModel","state":{"_dom_classes":[],"_model_module":"@jupyter-widgets/controls","_model_module_version":"2.0.0","_model_name":"HBoxModel","_view_count":null,"_view_module":"@jupyter-widgets/controls","_view_module_version":"2.0.0","_view_name":"HBoxView","box_style":"","children":["IPY_MODEL_19a6694466cf4d98bcea077026cf5aef","IPY_MODEL_0665f7c44f314b3f917647b2d8596beb","IPY_MODEL_222f5a4307dc4d9ea94e4fb03bdbb23f"],"layout":"IPY_MODEL_514306d67d5a447f91bbed117447433e","tabbable":null,"tooltip":null}},"0d7113f8aaa1426a8bbd53f09ebf5753":{"model_module":"@jupyter-widgets/controls","model_module_version":"2.0.0","model_name":"HBoxModel","state":{"_dom_classes":[],"_model_module":"@jupyter-widgets/controls","_model_module_version":"2.0.0","_model_name":"HBoxModel","_view_count":null,"_view_module":"@jupyter-widgets/controls","_view_module_version":"2.0.0","_view_name":"HBoxView","box_style":"","children":["IPY_MODEL_4107e762555048358ae30927a6f0b083","IPY_MODEL_1ce44d2a8b78474a98818d3de19e3731","IPY_MODEL_731f810308a64fc0ab69ee69a4a0c14c"],"layout":"IPY_MODEL_7ea8cfd481db4073962e176bdb0d9fe3","tabbable":null,"tooltip":null}},"19a6694466cf4d98bcea077026cf5aef":{"model_module":"@jupyter-widgets/controls","model_module_version":"2.0.0","model_name":"HTMLModel","state":{"_dom_classes":[],"_model_module":"@jupyter-widgets/controls","_model_module_version":"2.0.0","_model_name":"HTMLModel","_view_count":null,"_view_module":"@jupyter-widgets/controls","_view_module_version":"2.0.0","_view_name":"HTMLView","description":"","description_allow_html":false,"layout":"IPY_MODEL_36868d4e705b4327bbe779da39f8cbc2","placeholder":"​","style":"IPY_MODEL_6c97d4b136544142ade30cc5e6b674ab","tabbable":null,"tooltip":null,"value":"Preparing test data: 100%"}},"1ce44d2a8b78474a98818d3de19e3731":{"model_module":"@jupyter-widgets/controls","model_module_version":"2.0.0","model_name":"FloatProgressModel","state":{"_dom_classes":[],"_model_module":"@jupyter-widgets/controls","_model_module_version":"2.0.0","_model_name":"FloatProgressModel","_view_count":null,"_view_module":"@jupyter-widgets/controls","_view_module_version":"2.0.0","_view_name":"ProgressView","bar_style":"success","description":"","description_allow_html":false,"layout":"IPY_MODEL_49da856241c14eedb09818684697c5ca","max":1,"min":0,"orientation":"horizontal","style":"IPY_MODEL_6d67e183ad5c4491921ada04259ac346","tabbable":null,"tooltip":null,"value":1}},"222c814e56ce4710b8fbe29c8653c986":{"model_module":"@jupyter-widgets/base","model_module_version":"2.0.0","model_name":"LayoutModel","state":{"_model_module":"@jupyter-widgets/base","_model_module_version":"2.0.0","_model_name":"LayoutModel","_view_count":null,"_view_module":"@jupyter-widgets/base","_view_module_version":"2.0.0","_view_name":"LayoutView","align_content":null,"align_items":null,"align_self":null,"border_bottom":null,"border_left":null,"border_right":null,"border_top":null,"bottom":null,"display":null,"flex":null,"flex_flow":null,"grid_area":null,"grid_auto_columns":null,"grid_auto_flow":null,"grid_auto_rows":null,"grid_column":null,"grid_gap":null,"grid_row":null,"grid_template_areas":null,"grid_template_columns":null,"grid_template_rows":null,"height":null,"justify_content":null,"justify_items":null,"left":null,"margin":null,"max_height":null,"max_width":null,"min_height":null,"min_width":null,"object_fit":null,"object_position":null,"order":null,"overflow":null,"padding":null,"right":null,"top":null,"visibility":null,"width":null}},"222f5a4307dc4d9ea94e4fb03bdbb23f":{"model_module":"@jupyter-widgets/controls","model_module_version":"2.0.0","model_name":"HTMLModel","state":{"_dom_classes":[],"_model_module":"@jupyter-widgets/controls","_model_module_version":"2.0.0","_model_name":"HTMLModel","_view_count":null,"_view_module":"@jupyter-widgets/controls","_view_module_version":"2.0.0","_view_name":"HTMLView","description":"","description_allow_html":false,"layout":"IPY_MODEL_c523c7eee2364258b727c90fbc40a2e7","placeholder":"​","style":"IPY_MODEL_b2acf527b7de4985865361095a5f7daa","tabbable":null,"tooltip":null,"value":" 1/1 [00:00&lt;00:00,  3.05it/s]"}},"2a4ff535c7554c44a3b74d217a96d183":{"model_module":"@jupyter-widgets/base","model_module_version":"2.0.0","model_name":"LayoutModel","state":{"_model_module":"@jupyter-widgets/base","_model_module_version":"2.0.0","_model_name":"LayoutModel","_view_count":null,"_view_module":"@jupyter-widgets/base","_view_module_version":"2.0.0","_view_name":"LayoutView","align_content":null,"align_items":null,"align_self":null,"border_bottom":null,"border_left":null,"border_right":null,"border_top":null,"bottom":null,"display":null,"flex":null,"flex_flow":null,"grid_area":null,"grid_auto_columns":null,"grid_auto_flow":null,"grid_auto_rows":null,"grid_column":null,"grid_gap":null,"grid_row":null,"grid_template_areas":null,"grid_template_columns":null,"grid_template_rows":null,"height":null,"justify_content":null,"justify_items":null,"left":null,"margin":null,"max_height":null,"max_width":null,"min_height":null,"min_width":null,"object_fit":null,"object_position":null,"order":null,"overflow":null,"padding":null,"right":null,"top":null,"visibility":null,"width":null}},"32f9d62a8c54433e89bac7c162648cbb":{"model_module":"@jupyter-widgets/controls","model_module_version":"2.0.0","model_name":"FloatProgressModel","state":{"_dom_classes":[],"_model_module":"@jupyter-widgets/controls","_model_module_version":"2.0.0","_model_name":"FloatProgressModel","_view_count":null,"_view_module":"@jupyter-widgets/controls","_view_module_version":"2.0.0","_view_name":"ProgressView","bar_style":"success","description":"","description_allow_html":false,"layout":"IPY_MODEL_222c814e56ce4710b8fbe29c8653c986","max":1,"min":0,"orientation":"horizontal","style":"IPY_MODEL_42a1123dccca4ad9b1d482ebb109d966","tabbable":null,"tooltip":null,"value":1}},"34b390ddb0784b09a120da4e36d50166":{"model_module":"@jupyter-widgets/controls","model_module_version":"2.0.0","model_name":"HTMLModel","state":{"_dom_classes":[],"_model_module":"@jupyter-widgets/controls","_model_module_version":"2.0.0","_model_name":"HTMLModel","_view_count":null,"_view_module":"@jupyter-widgets/controls","_view_module_version":"2.0.0","_view_name":"HTMLView","description":"","description_allow_html":false,"layout":"IPY_MODEL_46bf1fd2d48d436da9de3e6cf7dcdd97","placeholder":"​","style":"IPY_MODEL_d0afb6a8d855452fbe37894f8d629f89","tabbable":null,"tooltip":null,"value":"Zipping predictions: 100%"}},"36868d4e705b4327bbe779da39f8cbc2":{"model_module":"@jupyter-widgets/base","model_module_version":"2.0.0","model_name":"LayoutModel","state":{"_model_module":"@jupyter-widgets/base","_model_module_version":"2.0.0","_model_name":"LayoutModel","_view_count":null,"_view_module":"@jupyter-widgets/base","_view_module_version":"2.0.0","_view_name":"LayoutView","align_content":null,"align_items":null,"align_self":null,"border_bottom":null,"border_left":null,"border_right":null,"border_top":null,"bottom":null,"display":null,"flex":null,"flex_flow":null,"grid_area":null,"grid_auto_columns":null,"grid_auto_flow":null,"grid_auto_rows":null,"grid_column":null,"grid_gap":null,"grid_row":null,"grid_template_areas":null,"grid_template_columns":null,"grid_template_rows":null,"height":null,"justify_content":null,"justify_items":null,"left":null,"margin":null,"max_height":null,"max_width":null,"min_height":null,"min_width":null,"object_fit":null,"object_position":null,"order":null,"overflow":null,"padding":null,"right":null,"top":null,"visibility":null,"width":null}},"4107e762555048358ae30927a6f0b083":{"model_module":"@jupyter-widgets/controls","model_module_version":"2.0.0","model_name":"HTMLModel","state":{"_dom_classes":[],"_model_module":"@jupyter-widgets/controls","_model_module_version":"2.0.0","_model_name":"HTMLModel","_view_count":null,"_view_module":"@jupyter-widgets/controls","_view_module_version":"2.0.0","_view_name":"HTMLView","description":"","description_allow_html":false,"layout":"IPY_MODEL_fa5e7083a1124a309aa386971acf575c","placeholder":"​","style":"IPY_MODEL_6cc8a0a7f79a435887cdd772d3af9d71","tabbable":null,"tooltip":null,"value":"Post-processing: 100%"}},"42a1123dccca4ad9b1d482ebb109d966":{"model_module":"@jupyter-widgets/controls","model_module_version":"2.0.0","model_name":"ProgressStyleModel","state":{"_model_module":"@jupyter-widgets/controls","_model_module_version":"2.0.0","_model_name":"ProgressStyleModel","_view_count":null,"_view_module":"@jupyter-widgets/base","_view_module_version":"2.0.0","_view_name":"StyleView","bar_color":null,"description_width":""}},"46bf1fd2d48d436da9de3e6cf7dcdd97":{"model_module":"@jupyter-widgets/base","model_module_version":"2.0.0","model_name":"LayoutModel","state":{"_model_module":"@jupyter-widgets/base","_model_module_version":"2.0.0","_model_name":"LayoutModel","_view_count":null,"_view_module":"@jupyter-widgets/base","_view_module_version":"2.0.0","_view_name":"LayoutView","align_content":null,"align_items":null,"align_self":null,"border_bottom":null,"border_left":null,"border_right":null,"border_top":null,"bottom":null,"display":null,"flex":null,"flex_flow":null,"grid_area":null,"grid_auto_columns":null,"grid_auto_flow":null,"grid_auto_rows":null,"grid_column":null,"grid_gap":null,"grid_row":null,"grid_template_areas":null,"grid_template_columns":null,"grid_template_rows":null,"height":null,"justify_content":null,"justify_items":null,"left":null,"margin":null,"max_height":null,"max_width":null,"min_height":null,"min_width":null,"object_fit":null,"object_position":null,"order":null,"overflow":null,"padding":null,"right":null,"top":null,"visibility":null,"width":null}},"49da856241c14eedb09818684697c5ca":{"model_module":"@jupyter-widgets/base","model_module_version":"2.0.0","model_name":"LayoutModel","state":{"_model_module":"@jupyter-widgets/base","_model_module_version":"2.0.0","_model_name":"LayoutModel","_view_count":null,"_view_module":"@jupyter-widgets/base","_view_module_version":"2.0.0","_view_name":"LayoutView","align_content":null,"align_items":null,"align_self":null,"border_bottom":null,"border_left":null,"border_right":null,"border_top":null,"bottom":null,"display":null,"flex":null,"flex_flow":null,"grid_area":null,"grid_auto_columns":null,"grid_auto_flow":null,"grid_auto_rows":null,"grid_column":null,"grid_gap":null,"grid_row":null,"grid_template_areas":null,"grid_template_columns":null,"grid_template_rows":null,"height":null,"justify_content":null,"justify_items":null,"left":null,"margin":null,"max_height":null,"max_width":null,"min_height":null,"min_width":null,"object_fit":null,"object_position":null,"order":null,"overflow":null,"padding":null,"right":null,"top":null,"visibility":null,"width":null}},"514306d67d5a447f91bbed117447433e":{"model_module":"@jupyter-widgets/base","model_module_version":"2.0.0","model_name":"LayoutModel","state":{"_model_module":"@jupyter-widgets/base","_model_module_version":"2.0.0","_model_name":"LayoutModel","_view_count":null,"_view_module":"@jupyter-widgets/base","_view_module_version":"2.0.0","_view_name":"LayoutView","align_content":null,"align_items":null,"align_self":null,"border_bottom":null,"border_left":null,"border_right":null,"border_top":null,"bottom":null,"display":null,"flex":null,"flex_flow":null,"grid_area":null,"grid_auto_columns":null,"grid_auto_flow":null,"grid_auto_rows":null,"grid_column":null,"grid_gap":null,"grid_row":null,"grid_template_areas":null,"grid_template_columns":null,"grid_template_rows":null,"height":null,"justify_content":null,"justify_items":null,"left":null,"margin":null,"max_height":null,"max_width":null,"min_height":null,"min_width":null,"object_fit":null,"object_position":null,"order":null,"overflow":null,"padding":null,"right":null,"top":null,"visibility":null,"width":null}},"5271cb6f27c14452a596ee93f78a632f":{"model_module":"@jupyter-widgets/controls","model_module_version":"2.0.0","model_name":"HTMLStyleModel","state":{"_model_module":"@jupyter-widgets/controls","_model_module_version":"2.0.0","_model_name":"HTMLStyleModel","_view_count":null,"_view_module":"@jupyter-widgets/base","_view_module_version":"2.0.0","_view_name":"StyleView","background":null,"description_width":"","font_size":null,"text_color":null}},"5c4ad5885f2e4852b64e31e71ac005df":{"model_module":"@jupyter-widgets/base","model_module_version":"2.0.0","model_name":"LayoutModel","state":{"_model_module":"@jupyter-widgets/base","_model_module_version":"2.0.0","_model_name":"LayoutModel","_view_count":null,"_view_module":"@jupyter-widgets/base","_view_module_version":"2.0.0","_view_name":"LayoutView","align_content":null,"align_items":null,"align_self":null,"border_bottom":null,"border_left":null,"border_right":null,"border_top":null,"bottom":null,"display":null,"flex":null,"flex_flow":null,"grid_area":null,"grid_auto_columns":null,"grid_auto_flow":null,"grid_auto_rows":null,"grid_column":null,"grid_gap":null,"grid_row":null,"grid_template_areas":null,"grid_template_columns":null,"grid_template_rows":null,"height":null,"justify_content":null,"justify_items":null,"left":null,"margin":null,"max_height":null,"max_width":null,"min_height":null,"min_width":null,"object_fit":null,"object_position":null,"order":null,"overflow":null,"padding":null,"right":null,"top":null,"visibility":null,"width":null}},"6108cb6aa940410fbe5358f61751710c":{"model_module":"@jupyter-widgets/controls","model_module_version":"2.0.0","model_name":"HTMLModel","state":{"_dom_classes":[],"_model_module":"@jupyter-widgets/controls","_model_module_version":"2.0.0","_model_name":"HTMLModel","_view_count":null,"_view_module":"@jupyter-widgets/controls","_view_module_version":"2.0.0","_view_name":"HTMLView","description":"","description_allow_html":false,"layout":"IPY_MODEL_aea2768193614bc6a429634a6311146c","placeholder":"​","style":"IPY_MODEL_5271cb6f27c14452a596ee93f78a632f","tabbable":null,"tooltip":null,"value":" 1/1 [00:00&lt;00:00, 26.38it/s]"}},"6c97d4b136544142ade30cc5e6b674ab":{"model_module":"@jupyter-widgets/controls","model_module_version":"2.0.0","model_name":"HTMLStyleModel","state":{"_model_module":"@jupyter-widgets/controls","_model_module_version":"2.0.0","_model_name":"HTMLStyleModel","_view_count":null,"_view_module":"@jupyter-widgets/base","_view_module_version":"2.0.0","_view_name":"StyleView","background":null,"description_width":"","font_size":null,"text_color":null}},"6cc8a0a7f79a435887cdd772d3af9d71":{"model_module":"@jupyter-widgets/controls","model_module_version":"2.0.0","model_name":"HTMLStyleModel","state":{"_model_module":"@jupyter-widgets/controls","_model_module_version":"2.0.0","_model_name":"HTMLStyleModel","_view_count":null,"_view_module":"@jupyter-widgets/base","_view_module_version":"2.0.0","_view_name":"StyleView","background":null,"description_width":"","font_size":null,"text_color":null}},"6d67e183ad5c4491921ada04259ac346":{"model_module":"@jupyter-widgets/controls","model_module_version":"2.0.0","model_name":"ProgressStyleModel","state":{"_model_module":"@jupyter-widgets/controls","_model_module_version":"2.0.0","_model_name":"ProgressStyleModel","_view_count":null,"_view_module":"@jupyter-widgets/base","_view_module_version":"2.0.0","_view_name":"StyleView","bar_color":null,"description_width":""}},"731f810308a64fc0ab69ee69a4a0c14c":{"model_module":"@jupyter-widgets/controls","model_module_version":"2.0.0","model_name":"HTMLModel","state":{"_dom_classes":[],"_model_module":"@jupyter-widgets/controls","_model_module_version":"2.0.0","_model_name":"HTMLModel","_view_count":null,"_view_module":"@jupyter-widgets/controls","_view_module_version":"2.0.0","_view_name":"HTMLView","description":"","description_allow_html":false,"layout":"IPY_MODEL_ea4b2fddf45f47238e6a65a737f8cc51","placeholder":"​","style":"IPY_MODEL_9d9d50acff6b4a3982062bfe023c84b8","tabbable":null,"tooltip":null,"value":" 1/1 [00:37&lt;00:00, 37.74s/it]"}},"792784678aac4a0586579b8a750c1b63":{"model_module":"@jupyter-widgets/controls","model_module_version":"2.0.0","model_name":"HBoxModel","state":{"_dom_classes":[],"_model_module":"@jupyter-widgets/controls","_model_module_version":"2.0.0","_model_name":"HBoxModel","_view_count":null,"_view_module":"@jupyter-widgets/controls","_view_module_version":"2.0.0","_view_name":"HBoxView","box_style":"","children":["IPY_MODEL_34b390ddb0784b09a120da4e36d50166","IPY_MODEL_32f9d62a8c54433e89bac7c162648cbb","IPY_MODEL_6108cb6aa940410fbe5358f61751710c"],"layout":"IPY_MODEL_5c4ad5885f2e4852b64e31e71ac005df","tabbable":null,"tooltip":null}},"7ea8cfd481db4073962e176bdb0d9fe3":{"model_module":"@jupyter-widgets/base","model_module_version":"2.0.0","model_name":"LayoutModel","state":{"_model_module":"@jupyter-widgets/base","_model_module_version":"2.0.0","_model_name":"LayoutModel","_view_count":null,"_view_module":"@jupyter-widgets/base","_view_module_version":"2.0.0","_view_name":"LayoutView","align_content":null,"align_items":null,"align_self":null,"border_bottom":null,"border_left":null,"border_right":null,"border_top":null,"bottom":null,"display":null,"flex":null,"flex_flow":null,"grid_area":null,"grid_auto_columns":null,"grid_auto_flow":null,"grid_auto_rows":null,"grid_column":null,"grid_gap":null,"grid_row":null,"grid_template_areas":null,"grid_template_columns":null,"grid_template_rows":null,"height":null,"justify_content":null,"justify_items":null,"left":null,"margin":null,"max_height":null,"max_width":null,"min_height":null,"min_width":null,"object_fit":null,"object_position":null,"order":null,"overflow":null,"padding":null,"right":null,"top":null,"visibility":null,"width":null}},"9d9d50acff6b4a3982062bfe023c84b8":{"model_module":"@jupyter-widgets/controls","model_module_version":"2.0.0","model_name":"HTMLStyleModel","state":{"_model_module":"@jupyter-widgets/controls","_model_module_version":"2.0.0","_model_name":"HTMLStyleModel","_view_count":null,"_view_module":"@jupyter-widgets/base","_view_module_version":"2.0.0","_view_name":"StyleView","background":null,"description_width":"","font_size":null,"text_color":null}},"aea2768193614bc6a429634a6311146c":{"model_module":"@jupyter-widgets/base","model_module_version":"2.0.0","model_name":"LayoutModel","state":{"_model_module":"@jupyter-widgets/base","_model_module_version":"2.0.0","_model_name":"LayoutModel","_view_count":null,"_view_module":"@jupyter-widgets/base","_view_module_version":"2.0.0","_view_name":"LayoutView","align_content":null,"align_items":null,"align_self":null,"border_bottom":null,"border_left":null,"border_right":null,"border_top":null,"bottom":null,"display":null,"flex":null,"flex_flow":null,"grid_area":null,"grid_auto_columns":null,"grid_auto_flow":null,"grid_auto_rows":null,"grid_column":null,"grid_gap":null,"grid_row":null,"grid_template_areas":null,"grid_template_columns":null,"grid_template_rows":null,"height":null,"justify_content":null,"justify_items":null,"left":null,"margin":null,"max_height":null,"max_width":null,"min_height":null,"min_width":null,"object_fit":null,"object_position":null,"order":null,"overflow":null,"padding":null,"right":null,"top":null,"visibility":null,"width":null}},"b2acf527b7de4985865361095a5f7daa":{"model_module":"@jupyter-widgets/controls","model_module_version":"2.0.0","model_name":"HTMLStyleModel","state":{"_model_module":"@jupyter-widgets/controls","_model_module_version":"2.0.0","_model_name":"HTMLStyleModel","_view_count":null,"_view_module":"@jupyter-widgets/base","_view_module_version":"2.0.0","_view_name":"StyleView","background":null,"description_width":"","font_size":null,"text_color":null}},"c523c7eee2364258b727c90fbc40a2e7":{"model_module":"@jupyter-widgets/base","model_module_version":"2.0.0","model_name":"LayoutModel","state":{"_model_module":"@jupyter-widgets/base","_model_module_version":"2.0.0","_model_name":"LayoutModel","_view_count":null,"_view_module":"@jupyter-widgets/base","_view_module_version":"2.0.0","_view_name":"LayoutView","align_content":null,"align_items":null,"align_self":null,"border_bottom":null,"border_left":null,"border_right":null,"border_top":null,"bottom":null,"display":null,"flex":null,"flex_flow":null,"grid_area":null,"grid_auto_columns":null,"grid_auto_flow":null,"grid_auto_rows":null,"grid_column":null,"grid_gap":null,"grid_row":null,"grid_template_areas":null,"grid_template_columns":null,"grid_template_rows":null,"height":null,"justify_content":null,"justify_items":null,"left":null,"margin":null,"max_height":null,"max_width":null,"min_height":null,"min_width":null,"object_fit":null,"object_position":null,"order":null,"overflow":null,"padding":null,"right":null,"top":null,"visibility":null,"width":null}},"d0afb6a8d855452fbe37894f8d629f89":{"model_module":"@jupyter-widgets/controls","model_module_version":"2.0.0","model_name":"HTMLStyleModel","state":{"_model_module":"@jupyter-widgets/controls","_model_module_version":"2.0.0","_model_name":"HTMLStyleModel","_view_count":null,"_view_module":"@jupyter-widgets/base","_view_module_version":"2.0.0","_view_name":"StyleView","background":null,"description_width":"","font_size":null,"text_color":null}},"daf7ec7532674455b832f42fc9065b67":{"model_module":"@jupyter-widgets/controls","model_module_version":"2.0.0","model_name":"ProgressStyleModel","state":{"_model_module":"@jupyter-widgets/controls","_model_module_version":"2.0.0","_model_name":"ProgressStyleModel","_view_count":null,"_view_module":"@jupyter-widgets/base","_view_module_version":"2.0.0","_view_name":"StyleView","bar_color":null,"description_width":""}},"ea4b2fddf45f47238e6a65a737f8cc51":{"model_module":"@jupyter-widgets/base","model_module_version":"2.0.0","model_name":"LayoutModel","state":{"_model_module":"@jupyter-widgets/base","_model_module_version":"2.0.0","_model_name":"LayoutModel","_view_count":null,"_view_module":"@jupyter-widgets/base","_view_module_version":"2.0.0","_view_name":"LayoutView","align_content":null,"align_items":null,"align_self":null,"border_bottom":null,"border_left":null,"border_right":null,"border_top":null,"bottom":null,"display":null,"flex":null,"flex_flow":null,"grid_area":null,"grid_auto_columns":null,"grid_auto_flow":null,"grid_auto_rows":null,"grid_column":null,"grid_gap":null,"grid_row":null,"grid_template_areas":null,"grid_template_columns":null,"grid_template_rows":null,"height":null,"justify_content":null,"justify_items":null,"left":null,"margin":null,"max_height":null,"max_width":null,"min_height":null,"min_width":null,"object_fit":null,"object_position":null,"order":null,"overflow":null,"padding":null,"right":null,"top":null,"visibility":null,"width":null}},"fa5e7083a1124a309aa386971acf575c":{"model_module":"@jupyter-widgets/base","model_module_version":"2.0.0","model_name":"LayoutModel","state":{"_model_module":"@jupyter-widgets/base","_model_module_version":"2.0.0","_model_name":"LayoutModel","_view_count":null,"_view_module":"@jupyter-widgets/base","_view_module_version":"2.0.0","_view_name":"LayoutView","align_content":null,"align_items":null,"align_self":null,"border_bottom":null,"border_left":null,"border_right":null,"border_top":null,"bottom":null,"display":null,"flex":null,"flex_flow":null,"grid_area":null,"grid_auto_columns":null,"grid_auto_flow":null,"grid_auto_rows":null,"grid_column":null,"grid_gap":null,"grid_row":null,"grid_template_areas":null,"grid_template_columns":null,"grid_template_rows":null,"height":null,"justify_content":null,"justify_items":null,"left":null,"margin":null,"max_height":null,"max_width":null,"min_height":null,"min_width":null,"object_fit":null,"object_position":null,"order":null,"overflow":null,"padding":null,"right":null,"top":null,"visibility":null,"width":null}}},"version_major":2,"version_minor":0}}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"markdown","source":"## 1. Configuration","metadata":{"papermill":{"duration":0.007589,"end_time":"2026-01-11T18:39:31.207942","exception":false,"start_time":"2026-01-11T18:39:31.200353","status":"completed"},"tags":[]}},{"cell_type":"code","source":"!python3 -V","metadata":{"execution":{"iopub.status.busy":"2026-02-14T17:13:25.212602Z","iopub.execute_input":"2026-02-14T17:13:25.212865Z","iopub.status.idle":"2026-02-14T17:13:25.335171Z","shell.execute_reply.started":"2026-02-14T17:13:25.212835Z","shell.execute_reply":"2026-02-14T17:13:25.334452Z"},"papermill":{"duration":0.125727,"end_time":"2026-01-11T18:39:31.340109","exception":false,"start_time":"2026-01-11T18:39:31.214382","status":"completed"},"tags":[],"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os\nimport sys\nimport json\nimport shutil\nimport subprocess\nimport zipfile\nimport numpy as np\nimport tifffile\nfrom tqdm import tqdm\nfrom pathlib import Path\nfrom typing import Optional, Tuple, List, Union\n\n# Add code folder to path for custom modules\nif os.path.join(os.getcwd(), \"code\") not in sys.path:\n    sys.path.append(os.path.join(os.getcwd(), \"code\"))\n\n# =============================================================================\n# CONFIGURATION - MODIFY THESE FOR YOUR SETUP\n# =============================================================================\n\n# Competition data path\nINPUT_DIR = Path(\"/kaggle/input/vesuvius-challenge-surface-detection\")\n\n# Pre-trained model checkpoint path (Kaggle Model format)\n# Path directly to the trainer folder containing fold_all/, plans.json, etc.\nMODEL_DATASET_PATH = Path(\"/kaggle/input/models/zy1343930734/nnunet-results/pytorch/default/23/nnUNet_results/Dataset501_VesuviusSurface/nnUNetTrainer_SkeletonRecall_2000epochs__nnUNetResEncUNetMPlans__3d_lowres\")\n\n# Working directories\nWORKING_DIR = Path(\"/kaggle/temp\")\nOUTPUT_DIR = Path(\"/kaggle/working\")\n\n\n# nnUNet directory structure\nNNUNET_BASE = WORKING_DIR / \"nnUNet_data\"\nNNUNET_RAW = NNUNET_BASE / \"nnUNet_raw\"\nNNUNET_PREPROCESSED = NNUNET_BASE / \"nnUNet_preprocessed\"\nNNUNET_RESULTS = WORKING_DIR / \"nnUNet_results\"\n\n# Dataset configuration\nDATASET_ID = 501\nDATASET_NAME = f\"Dataset{DATASET_ID:03d}_VesuviusSurface\"\n\n# Model configuration (must match trained model)\nFOLD = \"all\"\nCONFIGURATION = \"3d_lowres\"\nPLANS_NAME = \"nnUNetResEncUNetMPlans\"\nEPOCHS = 500\n\n# Inference settings\nNUM_PROCESSES_PREPROCESSING = 2\nNUM_PROCESSES_SEGMENTATION = 2\n\nprint(f\"Competition data: {INPUT_DIR}\")\nprint(f\"Model path: {MODEL_DATASET_PATH}\")\nprint(f\"Configuration: {CONFIGURATION}, Epochs: {EPOCHS}, Fold: {FOLD}\")","metadata":{"papermill":{"duration":0.191118,"end_time":"2026-01-11T18:39:31.537527","exception":false,"start_time":"2026-01-11T18:39:31.346409","status":"completed"},"tags":[],"trusted":true,"execution":{"iopub.status.busy":"2026-02-14T17:13:25.337386Z","iopub.execute_input":"2026-02-14T17:13:25.337655Z","iopub.status.idle":"2026-02-14T17:13:25.610685Z","shell.execute_reply.started":"2026-02-14T17:13:25.337625Z","shell.execute_reply":"2026-02-14T17:13:25.609868Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 2. Install Packages","metadata":{"papermill":{"duration":0.006229,"end_time":"2026-01-11T18:39:31.550535","exception":false,"start_time":"2026-01-11T18:39:31.544306","status":"completed"},"tags":[]}},{"cell_type":"code","source":"# Create temp directories\n!mkdir -p /kaggle/temp\n!mkdir -p /kaggle/working/predictions_tiff\n\n# Install packages (offline mode for Kaggle)\n# !pip install nnunetv2 nibabel tifffile tqdm -q --no-index -f \"/kaggle/input/vesuvius-python311\"\n!pip install imagecodecs==2025.8.2 nibabel tifffile tqdm -q --no-index -f \"/kaggle/input/vesuvius-python312\"\n\n!cp -r /kaggle/input/nnunet/nnUNet /kaggle/temp/nnUNet\n!cd /kaggle/temp/nnUNet && pip install . --no-index -f \"/kaggle/input/vesuvius-python312\"\n# Set nnUNet environment variable before importing\nos.environ[\"nnUNet_USE_BLOSC2\"] = \"1\"\n\nimport numpy as np\nimport tifffile\nfrom tqdm.auto import tqdm\n\n# Verify nnunetv2 is properly installed\n# try:\n#     import nnunetv2\n#     print(f\"nnunetv2 version: {nnunetv2.__version__}\")\n# except ImportError as e:\n#     print(f\"ERROR: Failed to import nnunetv2: {e}\")\n\n# Check if nnUNetv2_predict is available\nimport shutil\nnnunet_predict_path = shutil.which(\"nnUNetv2_predict\")\nif nnunet_predict_path:\n    print(f\"nnUNetv2_predict found at: {nnunet_predict_path}\")\nelse:\n    print(\"WARNING: nnUNetv2_predict not in PATH, will use Python module directly\")\n\nprint(\"Packages installed successfully!\")","metadata":{"papermill":{"duration":66.659184,"end_time":"2026-01-11T18:40:38.215918","exception":false,"start_time":"2026-01-11T18:39:31.556734","status":"completed"},"tags":[],"trusted":true,"execution":{"iopub.status.busy":"2026-02-14T17:13:25.611798Z","iopub.execute_input":"2026-02-14T17:13:25.612104Z","iopub.status.idle":"2026-02-14T17:13:52.780837Z","shell.execute_reply.started":"2026-02-14T17:13:25.612075Z","shell.execute_reply":"2026-02-14T17:13:52.780063Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 3. Environment Setup","metadata":{"papermill":{"duration":0.009077,"end_time":"2026-01-11T18:40:38.234159","exception":false,"start_time":"2026-01-11T18:40:38.225082","status":"completed"},"tags":[]}},{"cell_type":"code","source":"def setup_environment():\n    \"\"\"Set up nnUNet environment variables and directories.\"\"\"\n    for d in [NNUNET_RAW, NNUNET_PREPROCESSED, NNUNET_RESULTS, OUTPUT_DIR]:\n        d.mkdir(parents=True, exist_ok=True)\n    \n    os.environ[\"nnUNet_raw\"] = str(NNUNET_RAW)\n    os.environ[\"nnUNet_preprocessed\"] = str(NNUNET_PREPROCESSED)\n    os.environ[\"nnUNet_results\"] = str(NNUNET_RESULTS)\n    os.environ[\"nnUNet_compile\"] = \"false\"  # Disable torch.compile for inference\n    \n    print(f\"nnUNet_raw: {NNUNET_RAW}\")\n    print(f\"nnUNet_preprocessed: {NNUNET_PREPROCESSED}\")\n    print(f\"nnUNet_results: {NNUNET_RESULTS}\")\n\n\ndef get_trainer_name(epochs: int) -> str:\n    \"\"\"Get trainer class name based on epochs.\"\"\"\n    if epochs == 1000:\n        return \"nnUNetTrainer\"\n    else:\n        return f\"nnUNetTrainer_{epochs}epochs\"\n\n\nsetup_environment()","metadata":{"papermill":{"duration":0.016958,"end_time":"2026-01-11T18:40:38.259689","exception":false,"start_time":"2026-01-11T18:40:38.242731","status":"completed"},"tags":[],"trusted":true,"execution":{"iopub.status.busy":"2026-02-14T17:13:52.782054Z","iopub.execute_input":"2026-02-14T17:13:52.782290Z","iopub.status.idle":"2026-02-14T17:13:52.788689Z","shell.execute_reply.started":"2026-02-14T17:13:52.782262Z","shell.execute_reply":"2026-02-14T17:13:52.788027Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 4. Link Pre-trained Model\n\nThe model checkpoint needs to be in the expected nnUNet results folder structure:\n```\nnnUNet_results/\n└── Dataset501_VesuviusSurface/\n    └── nnUNetTrainer_500epochs__nnUNetResEncUNetMPlans__3d_lowres/\n        ├── fold_all/\n        │   ├── checkpoint_final.pth\n        │   └── checkpoint_best_vesuvius.pth\n        ├── dataset.json\n        ├── plans.json\n        └── dataset_fingerprint.json\n```","metadata":{"papermill":{"duration":0.008733,"end_time":"2026-01-11T18:40:38.277235","exception":false,"start_time":"2026-01-11T18:40:38.268502","status":"completed"},"tags":[]}},{"cell_type":"code","source":"def link_pretrained_model(model_dataset_path: Path) -> bool:\n    \"\"\"\n    Link pre-trained model from Kaggle dataset to nnUNet results folder.\n    \n    Handles multiple possible upload structures:\n    1. Direct upload of trainer folder: contains fold_all/, plans.json, etc.\n    2. Upload with Dataset folder: contains Dataset501_VesuviusSurface/...\n    3. Upload of full nnUNet_results: contains Dataset501_.../nnUNetTrainer_.../\n    \"\"\"\n    if not model_dataset_path.exists():\n        print(f\"ERROR: Model dataset not found: {model_dataset_path}\")\n        return False\n    \n    trainer_name = get_trainer_name(EPOCHS)\n    trainer_folder_name = f\"{trainer_name}__{PLANS_NAME}__{CONFIGURATION}\"\n    \n    # Target directory structure\n    target_dataset_dir = NNUNET_RESULTS / DATASET_NAME\n    target_trainer_dir = target_dataset_dir / trainer_folder_name\n    \n    # Try to find the model folder in different structures\n    source_trainer_dir = None\n    \n    # Case 1: Direct trainer folder (contains fold_all and plans.json)\n    if (model_dataset_path / \"fold_all\").exists() and (model_dataset_path / \"plans.json\").exists():\n        source_trainer_dir = model_dataset_path\n        print(f\"Found direct trainer folder: {model_dataset_path}\")\n    \n    # Case 2: Contains trainer folder by name\n    elif (model_dataset_path / trainer_folder_name).exists():\n        source_trainer_dir = model_dataset_path / trainer_folder_name\n        print(f\"Found trainer folder: {source_trainer_dir}\")\n    \n    # Case 3: Contains Dataset folder\n    elif (model_dataset_path / DATASET_NAME / trainer_folder_name).exists():\n        source_trainer_dir = model_dataset_path / DATASET_NAME / trainer_folder_name\n        print(f\"Found trainer in dataset folder: {source_trainer_dir}\")\n    \n    # Case 4: Search for trainer folder\n    else:\n        for trainer_dir in model_dataset_path.rglob(trainer_folder_name):\n            if trainer_dir.is_dir():\n                source_trainer_dir = trainer_dir\n                print(f\"Found trainer folder by search: {source_trainer_dir}\")\n                break\n    \n    if source_trainer_dir is None:\n        print(f\"ERROR: Could not find trainer folder '{trainer_folder_name}' in {model_dataset_path}\")\n        print(f\"Available contents: {list(model_dataset_path.iterdir())}\")\n        return False\n    \n    # Verify checkpoint exists\n    checkpoint_final = source_trainer_dir / \"fold_all\" / \"checkpoint_final.pth\"\n    checkpoint_best_vesuvius = source_trainer_dir / \"fold_all\" / \"checkpoint_best_vesuvius.pth\"\n    \n    if not checkpoint_final.exists() and not checkpoint_best_vesuvius.exists():\n        print(f\"ERROR: No checkpoint found in {source_trainer_dir / 'fold_all'}\")\n        return False\n    \n    # Create symlink\n    target_dataset_dir.mkdir(parents=True, exist_ok=True)\n    \n    if target_trainer_dir.exists():\n        if target_trainer_dir.is_symlink():\n            target_trainer_dir.unlink()\n        else:\n            shutil.rmtree(target_trainer_dir)\n    \n    target_trainer_dir.symlink_to(source_trainer_dir.resolve())\n    print(f\"Linked model: {target_trainer_dir} -> {source_trainer_dir}\")\n    \n    # Verify\n    final_check = target_trainer_dir / \"fold_all\" / \"checkpoint_final.pth\"\n    best_check = target_trainer_dir / \"fold_all\" / \"checkpoint_best_vesuvius.pth\"\n    if final_check.exists():\n        print(f\"✓ checkpoint_final.pth found\")\n    elif best_check.exists():\n        print(f\"✓ checkpoint_best_vesuvius.pth found\")\n    \n    return True\n\n\n# Link the model\nif not link_pretrained_model(MODEL_DATASET_PATH):\n    print(\"\\nFailed to link model. Please check the model path.\")","metadata":{"papermill":{"duration":0.023645,"end_time":"2026-01-11T18:40:38.30937","exception":false,"start_time":"2026-01-11T18:40:38.285725","status":"completed"},"tags":[],"trusted":true,"execution":{"iopub.status.busy":"2026-02-14T17:13:52.790455Z","iopub.execute_input":"2026-02-14T17:13:52.790664Z","iopub.status.idle":"2026-02-14T17:13:52.817387Z","shell.execute_reply.started":"2026-02-14T17:13:52.790645Z","shell.execute_reply":"2026-02-14T17:13:52.816747Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 5. Prepare Test Data","metadata":{"papermill":{"duration":0.008781,"end_time":"2026-01-11T18:40:38.327018","exception":false,"start_time":"2026-01-11T18:40:38.318237","status":"completed"},"tags":[]}},{"cell_type":"code","source":"def create_spacing_json(output_path: Path, shape: tuple, spacing: tuple = (1.0, 1.0, 1.0)):\n    \"\"\"Create JSON sidecar with spacing info for TIFF files.\"\"\"\n    json_data = {\"spacing\": list(spacing)}\n    with open(output_path, \"w\") as f:\n        json.dump(json_data, f)\n\n\ndef prepare_test_data(input_dir: Path, output_dir: Path) -> Path:\n    \"\"\"\n    Prepare test TIFF images for nnUNet inference.\n    \n    Creates symlinks with _0000 suffix and JSON sidecar files.\n    \"\"\"\n    output_dir.mkdir(parents=True, exist_ok=True)\n    \n    test_images_dir = input_dir / \"test_images\"\n    \n    if not test_images_dir.exists():\n        print(f\"ERROR: {test_images_dir} not found!\")\n        return output_dir\n    \n    test_files = sorted(test_images_dir.glob(\"*.tif\"))\n    print(f\"Found {len(test_files)} test cases\")\n    \n    for img_path in tqdm(test_files, desc=\"Preparing test data\"):\n        case_id = img_path.stem\n        dest_path = output_dir / f\"{case_id}_0000.tif\"\n        json_path = output_dir / f\"{case_id}_0000.json\"\n        \n        # Get shape for JSON\n        with tifffile.TiffFile(img_path) as tif:\n            shape = tif.pages[0].shape if len(tif.pages) == 1 else (len(tif.pages), *tif.pages[0].shape)\n        \n        # Create symlink\n        if not dest_path.exists():\n            dest_path.symlink_to(img_path.resolve())\n        \n        # Create JSON sidecar\n        create_spacing_json(json_path, shape)\n    \n    print(f\"Test data prepared: {output_dir}\")\n    return output_dir\n\n\n# Prepare test data\ntest_input_dir = WORKING_DIR / \"test_input\"\nprepare_test_data(INPUT_DIR, test_input_dir)","metadata":{"papermill":{"duration":0.360733,"end_time":"2026-01-11T18:40:38.696345","exception":false,"start_time":"2026-01-11T18:40:38.335612","status":"completed"},"tags":[],"trusted":true,"execution":{"iopub.status.busy":"2026-02-14T17:13:52.818058Z","iopub.execute_input":"2026-02-14T17:13:52.818292Z","iopub.status.idle":"2026-02-14T17:13:53.204057Z","shell.execute_reply.started":"2026-02-14T17:13:52.818266Z","shell.execute_reply":"2026-02-14T17:13:53.203262Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 7. Post-Processing: Convert predictions to TIFF format\n\nThis section converts the raw nnUNet predictions to the competition's required TIFF format with **Frangi post-processing**.\n\n### Post-processing pipeline:\n1. **Frangi filter** (detects 3D ridge/sheet structures)\n\n### Key parameters:\n**Frangi filter** (detects 3D surfaces/ridges):\n- `frangi_sigma=6.0`: Scale of papyrus thickness\n- `frangi_gauss_sigma=2.0`: Pre-smoothing sigma\n- `ridge_threshold=0.5`: Binarization threshold","metadata":{"papermill":{"duration":0.009881,"end_time":"2026-01-11T18:40:38.716896","exception":false,"start_time":"2026-01-11T18:40:38.707015","status":"completed"},"tags":[]}},{"cell_type":"code","source":"def run_inference(\n    input_dir: Path,\n    output_dir: Path,\n    dataset_id: int = DATASET_ID,\n    config: str = CONFIGURATION,\n    fold: str = FOLD,\n    plans: str = PLANS_NAME,\n    epochs: int = EPOCHS,\n    save_probabilities: bool = True,\n    use_best_checkpoint: bool = True\n) -> bool:\n    \"\"\"\n    Run nnUNet inference.\n    \n    Args:\n        input_dir: Directory with test images (must have _0000 suffix)\n        output_dir: Directory to save predictions\n        save_probabilities: Whether to save probability maps (.npz files)\n        use_best_checkpoint: Use checkpoint_best_vesuvius.pth instead of checkpoint_final.pth\n    \n    Returns:\n        True if inference succeeded\n    \"\"\"\n    output_dir.mkdir(parents=True, exist_ok=True)\n    \n    trainer = get_trainer_name(epochs)\n    \n    # Determine which checkpoint to use\n    checkpoint_name = \"checkpoint_best_vesuvius.pth\" if use_best_checkpoint else \"checkpoint_final.pth\"\n    \n    cmd = f\"nnUNetv2_predict -d {dataset_id:03d} -c {config} -f {fold}\"\n    cmd += f\" -i {input_dir} -o {output_dir} -p {plans} -tr {trainer}\"\n    cmd += f\" -chk {checkpoint_name}\"  # Specify checkpoint file\n    cmd += f\" -npp {NUM_PROCESSES_PREPROCESSING} -nps {NUM_PROCESSES_SEGMENTATION}\"\n    cmd += \" --verbose\"\n    \n    if save_probabilities:\n        cmd += \" --save_probabilities\"\n    \n    print(f\"Using checkpoint: {checkpoint_name}\")\n    print(f\"Running: {cmd}\")\n    result = subprocess.run(cmd, shell=True)\n    \n    if result.returncode != 0:\n        print(f\"Inference FAILED with return code {result.returncode}\")\n        return False\n    \n    print(\"Inference complete!\")\n    return True\n\n\n# Run inference with best checkpoint\npredictions_dir = WORKING_DIR / \"predictions\"\nsuccess = run_inference(test_input_dir, predictions_dir, use_best_checkpoint=False)\n\nif success:\n    print(f\"\\nPredictions saved to: {predictions_dir}\")\nelse:\n    print(\"\\nInference failed!\")","metadata":{"papermill":{"duration":100.283516,"end_time":"2026-01-11T18:42:19.00956","exception":false,"start_time":"2026-01-11T18:40:38.726044","status":"completed"},"tags":[],"trusted":true,"execution":{"iopub.status.busy":"2026-02-14T17:13:53.204939Z","iopub.execute_input":"2026-02-14T17:13:53.205162Z","iopub.status.idle":"2026-02-14T17:15:31.018646Z","shell.execute_reply.started":"2026-02-14T17:13:53.205141Z","shell.execute_reply":"2026-02-14T17:15:31.018015Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 7. Convert Predictions to TIFF","metadata":{"papermill":{"duration":0.010401,"end_time":"2026-01-11T18:42:19.031118","exception":false,"start_time":"2026-01-11T18:42:19.020717","status":"completed"},"tags":[]}},{"cell_type":"code","source":"from __future__ import annotations\n\nfrom collections.abc import Iterable, Mapping\nfrom dataclasses import dataclass\nfrom functools import lru_cache\nfrom typing import Callable, Final, Sequence\n\nimport numpy as np\nimport torch\nimport torch.nn.functional as F\n\nfrom tqdm import tqdm\n\nEPS: Final[float] = 2**-52\nGAMMA: Final[float] = 0.01\nCM: Final[float] = 7.2848\n\n# structure_tensor.py\n\"\"\"\nShared utilities for computing structure tensors using Pavel Holoborodko\nderivative kernels. This provides the canonical implementation used by\nStructureTensorInferer and other subsystems.\n\"\"\"\n\nDEFAULT_LAYOUT_3D: Sequence[str] = (\"Jzz\", \"Jzy\", \"Jzx\", \"Jyy\", \"Jyx\", \"Jxx\")\nDEFAULT_LAYOUT_2D: Sequence[str] = (\"Jyy\", \"Jyx\", \"Jxx\")\n\n\ndef _cache_key(device: torch.device, dtype: torch.dtype, extra: Iterable[int] | None = None) -> tuple:\n    \"\"\"Build a hashable cache key for kernel builders.\"\"\"\n    base = (device.type, device.index if device.index is not None else -1, str(dtype))\n    if not extra:\n        return base\n    return base + tuple(extra)\n\n\n@lru_cache(maxsize=None)\ndef _get_gaussian_kernel_3d(device: torch.device, dtype: torch.dtype, sigma: float) -> tuple[torch.Tensor, int]:\n    radius = int(3 * sigma)\n    coords = torch.arange(-radius, radius + 1, device=device, dtype=dtype)\n    g1 = torch.exp(-coords**2 / (2 * sigma * sigma))\n    g1 = g1 / g1.sum()\n    g3 = g1[:, None, None] * g1[None, :, None] * g1[None, None, :]\n    kernel = g3.unsqueeze(0).unsqueeze(0).contiguous()\n    return kernel, radius\n\n\n@lru_cache(maxsize=None)\ndef _get_gaussian_kernel_2d(device: torch.device, dtype: torch.dtype, sigma: float) -> tuple[torch.Tensor, int]:\n    radius = int(3 * sigma)\n    coords = torch.arange(-radius, radius + 1, device=device, dtype=dtype)\n    g1 = torch.exp(-coords**2 / (2 * sigma * sigma))\n    g1 = g1 / g1.sum()\n    g2 = g1[:, None] * g1[None, :]\n    kernel = g2.unsqueeze(0).unsqueeze(0).contiguous()\n    return kernel, radius\n\n\n@lru_cache(maxsize=None)\ndef _get_gaussian_kernel_1d_h(device: torch.device, dtype: torch.dtype, sigma: float) -> tuple[torch.Tensor, int]:\n    \"\"\"Get separable 1D Gaussian kernel for horizontal convolution.\"\"\"\n    radius = int(3 * sigma)\n    coords = torch.arange(-radius, radius + 1, device=device, dtype=dtype)\n    g1 = torch.exp(-coords**2 / (2 * sigma * sigma))\n    g1 = g1 / g1.sum()\n    # Shape for horizontal: (1, 1, 1, width)\n    return g1.view(1, 1, 1, -1).contiguous(), radius\n\n\n@lru_cache(maxsize=None)\ndef _get_gaussian_kernel_1d_v(device: torch.device, dtype: torch.dtype, sigma: float) -> tuple[torch.Tensor, int]:\n    \"\"\"Get separable 1D Gaussian kernel for vertical convolution.\"\"\"\n    radius = int(3 * sigma)\n    coords = torch.arange(-radius, radius + 1, device=device, dtype=dtype)\n    g1 = torch.exp(-coords**2 / (2 * sigma * sigma))\n    g1 = g1 / g1.sum()\n    # Shape for vertical: (1, 1, height, 1)\n    return g1.view(1, 1, -1, 1).contiguous(), radius\n\n\ndef _apply_separable_gaussian_2d(x: torch.Tensor, kernel_h: torch.Tensor, kernel_v: torch.Tensor, pad: int) -> torch.Tensor:\n    \"\"\"Apply separable Gaussian blur efficiently.\"\"\"\n    x = F.conv2d(x, kernel_h, padding=(0, pad))\n    x = F.conv2d(x, kernel_v, padding=(pad, 0))\n    return x\n\n\n@lru_cache(maxsize=None)\ndef _get_pavel_kernels_3d(device: torch.device, dtype: torch.dtype) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:\n    d = torch.tensor([2.0, 1.0, -16.0, -27.0, 0.0, 27.0, 16.0, -1.0, -2.0], device=device, dtype=dtype)\n    s = torch.tensor([1.0, 4.0, 6.0, 4.0, 1.0], device=device, dtype=dtype)\n\n    kz = (d.view(9, 1, 1) * s.view(1, 5, 1) * s.view(1, 1, 5)) / (96 * 16 * 16)\n    ky = (s.view(5, 1, 1) * d.view(1, 9, 1) * s.view(1, 1, 5)) / (96 * 16 * 16)\n    kx = (s.view(5, 1, 1) * s.view(1, 5, 1) * d.view(1, 1, 9)) / (96 * 16 * 16)\n\n    return (\n        kz.unsqueeze(0).unsqueeze(0).contiguous(),\n        ky.unsqueeze(0).unsqueeze(0).contiguous(),\n        kx.unsqueeze(0).unsqueeze(0).contiguous(),\n    )\n\n\n@lru_cache(maxsize=None)\ndef _get_pavel_kernels_2d(device: torch.device, dtype: torch.dtype) -> tuple[torch.Tensor, torch.Tensor]:\n    kz3d, ky3d, kx3d = _get_pavel_kernels_3d(device, dtype)\n    # slice the central plane along the orthogonal dimension to obtain 2D kernels\n    ky2d = ky3d[0, 0, 2, :, :].unsqueeze(0).unsqueeze(0).contiguous()\n    kx2d = kx3d[0, 0, 2, :, :].unsqueeze(0).unsqueeze(0).contiguous()\n    return ky2d, kx2d\n\n\ndef _ensure_tensor(data: torch.Tensor | np.ndarray, device: torch.device, dtype: torch.dtype) -> torch.Tensor:\n    if isinstance(data, torch.Tensor):\n        tensor = data.to(device=device, dtype=dtype)\n    else:\n        tensor = torch.as_tensor(data, device=device, dtype=dtype)\n    return tensor\n\n\ndef _components_to_matrix_structure(components: torch.Tensor, layout: Sequence[str]) -> torch.Tensor:\n    \"\"\"Convert flattened components into symmetric matrices.\"\"\"\n    if components.dim() < 2:\n        raise ValueError(\"components tensor must have channel dimension\")\n    ch = components.shape[1]\n    if ch == 6:\n        Jzz, Jzy, Jzx, Jyy, Jyx, Jxx = components.unbind(dim=1)\n        mats = torch.stack(\n            [\n                torch.stack([Jxx, Jyx, Jzx], dim=-1),\n                torch.stack([Jyx, Jyy, Jzy], dim=-1),\n                torch.stack([Jzx, Jzy, Jzz], dim=-1),\n            ],\n            dim=-2,\n        )\n        return mats\n    if ch == 3:\n        Jyy, Jyx, Jxx = components.unbind(dim=1)\n        mats = torch.stack(\n            [\n                torch.stack([Jxx, Jyx], dim=-1),\n                torch.stack([Jyx, Jyy], dim=-1),\n            ],\n            dim=-2,\n        )\n        return mats\n    raise ValueError(f\"Unsupported number of channels for structure tensor: {ch}\")\n\n\ndef components_to_matrix(components: torch.Tensor | np.ndarray) -> torch.Tensor | np.ndarray:\n    \"\"\"Convert flattened components to symmetric matrices.\"\"\"\n    is_numpy = isinstance(components, np.ndarray)\n    tensor = torch.as_tensor(components)\n    layout = DEFAULT_LAYOUT_3D if tensor.shape[1] == 6 else DEFAULT_LAYOUT_2D\n    mats = _components_to_matrix_structure(tensor, layout)\n    if is_numpy:\n        return mats.detach().cpu().numpy()\n    return mats\n\n\ndef eigendecompose(\n    components: torch.Tensor | np.ndarray,\n    smallest_first: bool = True,\n) -> tuple[torch.Tensor | np.ndarray, torch.Tensor | np.ndarray]:\n    \"\"\"\n    Compute eigenvalues/vectors for structure tensor components.\n    Returns eigenvalues and eigenvectors, leveraging torch/numpy as needed.\n    \"\"\"\n    if isinstance(components, np.ndarray):\n        tensor = torch.as_tensor(components)\n        layout = DEFAULT_LAYOUT_3D if tensor.shape[1] == 6 else DEFAULT_LAYOUT_2D\n        if tensor.shape[1] == 3:\n            return _eigendecompose_2d_numpy(tensor, smallest_first=smallest_first)\n        mats = _components_to_matrix_structure(tensor, layout).cpu().numpy()\n        w, v = np.linalg.eigh(mats)\n        if smallest_first:\n            order = np.argsort(w, axis=-1)\n            w = np.take_along_axis(w, order, axis=-1)\n            v = np.take_along_axis(v, order[..., None], axis=-1)\n        return w, v\n    if components.shape[1] == 3:\n        return _eigendecompose_2d_torch(components, smallest_first=smallest_first)\n    layout = DEFAULT_LAYOUT_3D if components.shape[1] == 6 else DEFAULT_LAYOUT_2D\n    mats = _components_to_matrix_structure(components, layout)\n    w, v = torch.linalg.eigh(mats)\n    if smallest_first:\n        w, idx = torch.sort(w, dim=-1)\n        v = torch.gather(v, dim=-1, index=idx.unsqueeze(-1).expand_as(v))\n    return w, v\n\n\ndef _eigendecompose_2d_torch(\n    components: torch.Tensor,\n    *,\n    smallest_first: bool = True,\n) -> tuple[torch.Tensor, torch.Tensor]:\n    Jyy, Jyx, Jxx = components.unbind(dim=1)\n    trace = Jxx + Jyy\n    diff = Jxx - Jyy\n    alpha = torch.sqrt(diff * diff + 4.0 * (Jyx * Jyx))\n    lam1 = 0.5 * (trace - alpha)\n    lam2 = 0.5 * (trace + alpha)\n    if not smallest_first:\n        lam1, lam2 = lam2, lam1\n    w = torch.stack([lam1, lam2], dim=-1)\n\n    # Eigenvectors\n    v1x = torch.where(\n        torch.abs(Jyx) > torch.abs(lam1 - Jxx),\n        -(lam1 - Jxx),\n        -Jyx,\n    )\n    v1y = torch.where(\n        torch.abs(Jyx) > torch.abs(lam1 - Jxx),\n        Jyx,\n        Jxx - lam1,\n    )\n    norm = torch.sqrt(v1x * v1x + v1y * v1y).clamp_min(1e-12)\n    v1x = v1x / norm\n    v1y = v1y / norm\n    v2x = -v1y\n    v2y = v1x\n    if not smallest_first:\n        v1x, v2x = v2x, v1x\n        v1y, v2y = v2y, v1y\n    v = torch.stack(\n        [\n            torch.stack([v1x, v2x], dim=-1),\n            torch.stack([v1y, v2y], dim=-1),\n        ],\n        dim=-2,\n    )\n    return w, v\n\n\ndef _eigendecompose_2d_numpy(\n    components: torch.Tensor,\n    *,\n    smallest_first: bool = True,\n) -> tuple[np.ndarray, np.ndarray]:\n    tensor = components.cpu().numpy()\n    Jyy, Jyx, Jxx = tensor\n    trace = Jxx + Jyy\n    diff = Jxx - Jyy\n    alpha = np.sqrt(diff * diff + 4.0 * (Jyx * Jyx))\n    lam1 = 0.5 * (trace - alpha)\n    lam2 = 0.5 * (trace + alpha)\n    if not smallest_first:\n        lam1, lam2 = lam2, lam1\n    w = np.stack([lam1, lam2], axis=-1)\n\n    v1x = np.where(\n        np.abs(Jyx) > np.abs(lam1 - Jxx),\n        -(lam1 - Jxx),\n        -Jyx,\n    )\n    v1y = np.where(\n        np.abs(Jyx) > np.abs(lam1 - Jxx),\n        Jyx,\n        Jxx - lam1,\n    )\n    norm = np.sqrt(v1x * v1x + v1y * v1y) + 1e-12\n    v1x = v1x / norm\n    v1y = v1y / norm\n    v2x = -v1y\n    v2y = v1x\n    if not smallest_first:\n        v1x, v2x = v2x, v1x\n        v1y, v2y = v2y, v1y\n    v = np.stack(\n        [\n            np.stack([v1x, v2x], axis=-1),\n            np.stack([v1y, v2y], axis=-1),\n        ],\n        axis=-2,\n    )\n    return w, v\n\n\n@dataclass\nclass StructureTensorComputer:\n    sigma: float = 1.0\n    component_sigma: float | None = None\n    smooth_components: bool = False\n    device: torch.device | str | None = None\n    dtype: torch.dtype = torch.float32\n\n    def __post_init__(self) -> None:\n        resolved_device = torch.device(self.device) if self.device is not None else torch.device(\"cpu\")\n        if resolved_device.type == \"cuda\" and not torch.cuda.is_available():\n            resolved_device = torch.device(\"cpu\")\n        self.device = resolved_device\n\n    def compute(\n        self,\n        volume: torch.Tensor | np.ndarray,\n        *,\n        sigma: float | None = None,\n        component_sigma: float | None = None,\n        smooth_components: bool | None = None,\n        device: torch.device | str | None = None,\n        as_numpy: bool = False,\n        spatial_dims: int | None = None,\n    ) -> torch.Tensor | np.ndarray:\n        \"\"\"Compute the structure tensor components for a 2D or 3D scalar field.\"\"\"\n        sigma_val = float(self.sigma if sigma is None else sigma)\n        smooth_components_val = self.smooth_components if smooth_components is None else bool(smooth_components)\n\n        component_sigma_val: float | None = (\n            float(component_sigma) if component_sigma is not None else self.component_sigma\n        )\n        if smooth_components_val:\n            if component_sigma_val is None:\n                component_sigma_val = sigma_val\n        else:\n            component_sigma_val = 0.0\n\n        target_device = torch.device(device) if device is not None else self.device\n        if target_device.type == \"cuda\" and not torch.cuda.is_available():\n            target_device = torch.device(\"cpu\")\n\n        x = _ensure_tensor(volume, target_device, self.dtype)\n\n        if spatial_dims is not None and spatial_dims not in (2, 3):\n            raise ValueError(\"spatial_dims must be 2 or 3 when specified.\")\n\n        if spatial_dims is None:\n            if x.dim() >= 5:\n                inferred_dims = 3\n            elif x.dim() == 4:\n                inferred_dims = 2 if x.shape[-3] == 1 else 3\n            elif x.dim() == 3:\n                inferred_dims = 2 if x.shape[-3] == 1 else 3\n            elif x.dim() == 2:\n                inferred_dims = 2\n            else:\n                raise ValueError(\"Unsupported input dimensionality for structure tensor computation.\")\n        else:\n            inferred_dims = spatial_dims\n\n        remove_batch_dim = False\n\n        if inferred_dims == 3:\n            if x.dim() == 5:\n                if x.shape[1] != 1:\n                    raise ValueError(\"Expected single-channel input for 3D structure tensor.\")\n            elif x.dim() == 4:\n                if x.shape[1] != 1:\n                    x = x.unsqueeze(1)\n            elif x.dim() == 3:\n                x = x.unsqueeze(0).unsqueeze(0)\n                remove_batch_dim = True\n            elif x.dim() == 2:\n                x = x.unsqueeze(0).unsqueeze(0).unsqueeze(0)\n                remove_batch_dim = True\n            else:\n                raise ValueError(\"Unable to broadcast input to `[batch,1,D,H,W]`.\")\n            components = self._compute_3d(x, sigma_val, component_sigma_val or 0.0)\n        else:\n            # 2D case\n            if x.dim() == 4:\n                if x.shape[1] != 1:\n                    x = x.unsqueeze(1)\n            elif x.dim() == 3:\n                if x.shape[0] == 1:\n                    x = x.unsqueeze(0)\n                    remove_batch_dim = True\n                else:\n                    x = x.unsqueeze(1)\n            elif x.dim() == 2:\n                x = x.unsqueeze(0).unsqueeze(0)\n                remove_batch_dim = True\n            else:\n                raise ValueError(\"Unable to broadcast input to `[batch,1,H,W]`.\")\n            components = self._compute_2d(x, sigma_val, component_sigma_val or 0.0)\n\n        if remove_batch_dim and components.shape[0] == 1:\n            components = components.squeeze(0)\n\n        if as_numpy:\n            return components.detach().cpu().numpy()\n        return components\n\n    def _compute_3d(self, x: torch.Tensor, sigma: float, component_sigma: float) -> torch.Tensor:\n        device = x.device\n        dtype = x.dtype\n\n        if sigma > 0:\n            kernel, pad = _get_gaussian_kernel_3d(device, dtype, sigma)\n            x = F.conv3d(x, kernel, padding=(pad, pad, pad))\n\n        kz, ky, kx = _get_pavel_kernels_3d(device, dtype)\n        gz = F.conv3d(x, kz, padding=(4, 2, 2))\n        gy = F.conv3d(x, ky, padding=(2, 4, 2))\n        gx = F.conv3d(x, kx, padding=(2, 2, 4))\n\n        Jxx = gx * gx\n        Jyx = gx * gy\n        Jzx = gx * gz\n        Jyy = gy * gy\n        Jzy = gy * gz\n        Jzz = gz * gz\n\n        J = torch.cat([Jzz, Jzy, Jzx, Jyy, Jyx, Jxx], dim=1)\n\n        if component_sigma > 0:\n            kernel, pad = _get_gaussian_kernel_3d(device, dtype, component_sigma)\n            kernel = kernel.expand(J.shape[1], 1, -1, -1, -1).contiguous()\n            J = F.conv3d(J, kernel, padding=(pad, pad, pad), groups=J.shape[1])\n        return J\n\n    def _compute_2d(self, x: torch.Tensor, sigma: float, component_sigma: float) -> torch.Tensor:\n        device = x.device\n        dtype = x.dtype\n\n        if sigma > 0:\n            # Use separable convolution for Gaussian blur (faster)\n            kernel_h, pad = _get_gaussian_kernel_1d_h(device, dtype, sigma)\n            kernel_v, _ = _get_gaussian_kernel_1d_v(device, dtype, sigma)\n            x = _apply_separable_gaussian_2d(x, kernel_h, kernel_v, pad)\n\n        ky, kx = _get_pavel_kernels_2d(device, dtype)\n        gy = F.conv2d(x, ky, padding=(4, 2))\n        gx = F.conv2d(x, kx, padding=(2, 4))\n\n        # Compute all products at once\n        Jxx = gx * gx\n        Jyx = gx * gy\n        Jyy = gy * gy\n        J = torch.cat([Jyy, Jyx, Jxx], dim=1)\n\n        if component_sigma > 0:\n            # Use separable convolution for component smoothing (faster)\n            kernel_h, pad = _get_gaussian_kernel_1d_h(device, dtype, component_sigma)\n            kernel_v, _ = _get_gaussian_kernel_1d_v(device, dtype, component_sigma)\n            # Expand for grouped convolution\n            kernel_h_expanded = kernel_h.expand(J.shape[1], 1, -1, -1).contiguous()\n            kernel_v_expanded = kernel_v.expand(J.shape[1], 1, -1, -1).contiguous()\n            J = F.conv2d(J, kernel_h_expanded, padding=(0, pad), groups=J.shape[1])  # horizontal\n            J = F.conv2d(J, kernel_v_expanded, padding=(pad, 0), groups=J.shape[1])  # vertical\n        return J\n\n\ndef _make_progress_iterator(steps: int, show_progress: bool) -> Iterable[int]:\n    if show_progress and tqdm is not None:\n        return tqdm(range(steps), desc=\"Diffusion steps\", leave=False)\n    return range(steps)\n\n\ndef _structure_tensor_components_2d(\n    img: torch.Tensor, st_computer: StructureTensorComputer\n) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:\n    \"\"\"Compute 2D structure tensor components using the shared StructureTensorComputer.\"\"\"\n    if st_computer.device != img.device:\n        st_computer.device = img.device\n    if st_computer.dtype != img.dtype:\n        st_computer.dtype = img.dtype\n    comps = st_computer.compute(img, device=img.device, spatial_dims=2)\n    s22 = comps[:, 0:1]  # Jyy\n    s12 = comps[:, 1:2]  # Jyx\n    s11 = comps[:, 2:3]  # Jxx\n    return s11, s12, s22\n\n\ndef _prepare_tensor(\n    data: torch.Tensor | np.ndarray,\n    *,\n    device: torch.device | None = None,\n) -> tuple[torch.Tensor, Callable[[torch.Tensor], torch.Tensor], bool, np.dtype | torch.dtype]:\n    \"\"\"Convert input data to a 4D float32 tensor and provide a restorer.\"\"\"\n    from_numpy = isinstance(data, np.ndarray)\n\n    if from_numpy:\n        np_array = np.asarray(data)\n        original_dtype = np_array.dtype\n        tensor = torch.from_numpy(np_array.astype(np.float32, copy=False))\n    elif isinstance(data, torch.Tensor):\n        tensor = data\n        original_dtype = data.dtype\n    else:\n        raise TypeError(\"coherence_enhancing_diffusion expects torch.Tensor or np.ndarray input.\")\n\n    original_shape = tensor.shape\n\n    if tensor.ndim == 4:\n        pass\n    elif tensor.ndim == 3:\n        tensor = tensor.unsqueeze(0)\n    elif tensor.ndim == 2:\n        tensor = tensor.unsqueeze(0).unsqueeze(0)\n    else:\n        raise ValueError(\"Input must have 2, 3, or 4 dimensions representing [B,C,H,W] data.\")\n\n    if device is not None:\n        tensor = tensor.to(device=device)\n\n    tensor = tensor.to(dtype=torch.float32)\n\n    def restore_fn(result: torch.Tensor) -> torch.Tensor:\n        restored = result\n        if len(original_shape) == 2:\n            restored = restored.squeeze(0).squeeze(0)\n        elif len(original_shape) == 3:\n            restored = restored.squeeze(0)\n        return restored\n\n    return tensor, restore_fn, from_numpy, original_dtype\n\n\n@torch.jit.script\ndef compute_alpha(s11: torch.Tensor, s12: torch.Tensor, s22: torch.Tensor) -> torch.Tensor:\n    \"\"\"Compute eigenvalue measure alpha.\"\"\"\n    a = s11 - s22\n    return torch.sqrt(a * a + 4.0 * s12 * s12)\n\n\n@torch.jit.script\ndef compute_c2(alpha: torch.Tensor, lambda_param: float, m: float, eps: float = 2.220446049250313e-16, cm: float = 7.2848, gamma: float = 0.01) -> torch.Tensor:\n    \"\"\"Compute diffusivity function c2.\"\"\"\n    h1 = (alpha + eps) / lambda_param\n    h2 = torch.pow(h1, m) if abs(m - 1.0) > 1e-10 else h1\n    h3 = torch.exp(-cm / (h2 + eps))\n    return gamma + (1.0 - gamma) * h3\n\n\n@torch.jit.script\ndef compute_diffusion_tensor(\n    s11: torch.Tensor,\n    s12: torch.Tensor,\n    s22: torch.Tensor,\n    alpha: torch.Tensor,\n    c2: torch.Tensor,\n    c1: float = 0.01,\n    eps: float = 2.220446049250313e-16,\n) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:\n    \"\"\"Compute diffusion tensor components D11, D12, D22 - optimized.\"\"\"\n    alpha_inv = 1.0 / (alpha + eps)\n    c_sum = c1 + c2\n    c_diff = c2 - c1\n    dd = c_diff * (s11 - s22) * alpha_inv\n    d11 = 0.5 * (c_sum + dd)\n    d12 = (c1 - c2) * s12 * alpha_inv\n    d22 = 0.5 * (c_sum - dd)\n    return d11, d12, d22\n\n\n@torch.jit.script\ndef _diffusion_step_optimized(\n    img: torch.Tensor, d11: torch.Tensor, d12: torch.Tensor, d22: torch.Tensor, step_size: float\n) -> torch.Tensor:\n    \"\"\"Highly optimized single explicit diffusion step.\"\"\"\n    # Pad all tensors at once using a more cache-friendly pattern\n    img_pad = F.pad(img, (1, 1, 1, 1), mode=\"replicate\")\n    d11_pad = F.pad(d11, (1, 1, 1, 1), mode=\"replicate\")\n    d12_pad = F.pad(d12, (1, 1, 1, 1), mode=\"replicate\")\n    d22_pad = F.pad(d22, (1, 1, 1, 1), mode=\"replicate\")\n\n    # Extract slices - ordered for better cache locality\n    img_c = img_pad[:, :, 1:-1, 1:-1]\n    img_n = img_pad[:, :, :-2, 1:-1]\n    img_s = img_pad[:, :, 2:, 1:-1]\n    img_w = img_pad[:, :, 1:-1, :-2]\n    img_e = img_pad[:, :, 1:-1, 2:]\n    \n    # Fused computation: compute all d11 terms together\n    d11_c = d11_pad[:, :, 1:-1, 1:-1]\n    a_apo = d11_pad[:, :, :-2, 1:-1] + d11_c\n    a_amo = d11_pad[:, :, 2:, 1:-1] + d11_c\n    \n    # Fused computation: compute all d22 terms together\n    d22_c = d22_pad[:, :, 1:-1, 1:-1]\n    c_com = d22_c + d22_pad[:, :, 1:-1, 2:]\n    c_cop = d22_c + d22_pad[:, :, 1:-1, :-2]\n\n    # First derivative - single fused expression\n    first_deriv = (\n        c_cop * img_w + a_amo * img_s + a_apo * img_n + c_com * img_e\n        - (a_amo + a_apo + c_com + c_cop) * img_c\n    )\n\n    # Second derivative - extract corner pixels and compute\n    bpo_bop = d12_pad[:, :, :-2, 1:-1] + d12_pad[:, :, 1:-1, :-2]\n    bmo_bom = d12_pad[:, :, 2:, 1:-1] + d12_pad[:, :, 1:-1, 2:]\n    bmo_bop = d12_pad[:, :, 2:, 1:-1] + d12_pad[:, :, 1:-1, :-2]\n    bpo_bom = d12_pad[:, :, :-2, 1:-1] + d12_pad[:, :, 1:-1, 2:]\n\n    second_deriv = (\n        bpo_bop * img_pad[:, :, :-2, :-2]\n        + bmo_bom * img_pad[:, :, 2:, 2:]\n        - bmo_bop * img_pad[:, :, 2:, :-2]\n        - bpo_bom * img_pad[:, :, :-2, 2:]\n    )\n\n    # Single fused update\n    return img + step_size * (0.5 * first_deriv + 0.25 * second_deriv)\n\n\ndef coherence_enhancing_diffusion(\n    data: torch.Tensor | np.ndarray,\n    config: Mapping[str, float],\n    *,\n    show_progress: bool = False,\n    device: torch.device | None = None,\n    return_numpy: bool | None = None,\n    structure_tensor_update_freq: int = 1,\n) -> torch.Tensor | np.ndarray:\n    \"\"\"\n    Run coherence-enhancing diffusion on a tensor or numpy array.\n\n    Args:\n        data: Input in shape (B,C,H,W), (C,H,W), or (H,W).\n        config: Mapping with 'lambda', 'sigma', 'rho', 'step_size', 'm', and 'num_steps'.\n        show_progress: Display tqdm progress if available.\n        device: Optional device override for tensor inputs or numpy conversion.\n        return_numpy: Force numpy output (True/False). Defaults to matching input type.\n        structure_tensor_update_freq: Update structure tensor every N steps (1=every step, higher=faster but less accurate).\n    \"\"\"\n    tensor, restore_fn, from_numpy, original_dtype = _prepare_tensor(data, device=device)\n\n    rho = float(config[\"rho\"])\n    sigma = float(config[\"sigma\"])\n    lambda_param = float(config[\"lambda\"])\n    step_size = float(config[\"step_size\"])\n    m = float(config[\"m\"])\n    num_steps = int(config[\"num_steps\"])\n\n    component_sigma = rho if rho > 0 else None\n    st_computer = StructureTensorComputer(\n        sigma=sigma,\n        component_sigma=component_sigma,\n        smooth_components=component_sigma is not None,\n        device=tensor.device,\n        dtype=tensor.dtype,\n    )\n\n    iterator = _make_progress_iterator(num_steps, show_progress)\n\n    # Main diffusion loop - optimized with torch.inference_mode() for inference\n    with torch.inference_mode():\n        # Cache diffusion tensors for multiple steps if update frequency > 1\n        d11_cache, d12_cache, d22_cache = None, None, None\n        \n        for step_idx in iterator:\n            # Update structure tensor and diffusion coefficients\n            if d11_cache is None or (step_idx % structure_tensor_update_freq == 0):\n                s11, s12, s22 = _structure_tensor_components_2d(tensor, st_computer)\n                alpha = compute_alpha(s11, s12, s22)\n                c2 = compute_c2(alpha, lambda_param, m)\n                d11_cache, d12_cache, d22_cache = compute_diffusion_tensor(s11, s12, s22, alpha, c2)\n            \n            \n            tensor = _diffusion_step_optimized(tensor, d11_cache, d12_cache, d22_cache, step_size)\n\n    result_tensor = restore_fn(tensor)\n\n    if return_numpy is None:\n        return_numpy = from_numpy\n\n    if return_numpy:\n        out = result_tensor.detach().cpu().numpy()\n        if isinstance(original_dtype, np.dtype):\n            out = out.astype(original_dtype, copy=False)\n        return out\n\n    return result_tensor.to(dtype=original_dtype) if isinstance(original_dtype, torch.dtype) else result_tensor\n\n\ndef coherence_enhancing_diffusion_fast(\n    data: torch.Tensor | np.ndarray,\n    config: Mapping[str, float],\n    *,\n    show_progress: bool = False,\n    device: torch.device | None = None,\n    return_numpy: bool | None = None,\n    structure_tensor_update_freq: int = 3,\n) -> torch.Tensor | np.ndarray:\n    \"\"\"\n    Fast mode: Update structure tensor less frequently for ~2-3x speedup with minimal quality loss.\n    \n    Args:\n        data: Input in shape (B,C,H,W), (C,H,W), or (H,W).\n        config: Mapping with 'lambda', 'sigma', 'rho', 'step_size', 'm', and 'num_steps'.\n        show_progress: Display tqdm progress if available.\n        device: Optional device override.\n        return_numpy: Force numpy output.\n        structure_tensor_update_freq: Update structure tensor every N steps (default 3).\n    \"\"\"\n    return coherence_enhancing_diffusion(\n        data=data,\n        config=config,\n        show_progress=show_progress,\n        device=device,\n        return_numpy=return_numpy,\n        structure_tensor_update_freq=structure_tensor_update_freq,\n    )\n\n\ndef apply_ced_postprocessing(\n    prediction: np.ndarray,\n    lambda_param: float = 1.0,\n    sigma: float = 3.0,\n    rho: float = 5.0,\n    step_size: float = 0.24,\n    m: float = 1.0,\n    num_steps: int = 100,\n) -> np.ndarray:\n    \"\"\"应用 Coherence Enhancing Diffusion 后处理（逐切片处理 3D 体积）\n    \n    Args:\n        prediction: 输入预测 (二值化后的 0/1 数组，3D 体积)\n        lambda_param: 边缘阈值参数\n        sigma: 梯度计算的高斯平滑\n        rho: 结构张量的高斯平滑\n        step_size: 扩散时间步长\n        m: 扩散函数指数\n        num_steps: 扩散迭代次数\n    \n    Returns:\n        后处理后的二值预测\n    \"\"\"\n    device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')\n    \n    # CED 配置\n    config = {\n        'lambda': lambda_param,\n        'sigma': sigma,\n        'rho': rho,\n        'step_size': step_size,\n        'm': m,\n        'num_steps': num_steps,\n    }\n    \n    # 逐切片处理 3D 体积\n    if prediction.ndim == 3:\n        depth, height, width = prediction.shape\n        result_volume = np.zeros_like(prediction, dtype=np.uint8)\n        \n        print(f\"    处理 {depth} 个切片...\")\n        for z in tqdm(range(depth), desc=\"    CED 切片处理\", leave=False):\n            slice_2d = prediction[z, :, :]\n            \n            # 跳过全零切片\n            if slice_2d.max() == 0:\n                continue\n            \n            # 转换为 float32 tensor 并添加批次和通道维度 (B, C, H, W)\n            slice_tensor = torch.from_numpy(slice_2d.astype(np.float32)).to(device)\n            slice_tensor = slice_tensor.unsqueeze(0).unsqueeze(0)  # [1, 1, H, W]\n            \n            # 应用 CED 滤波\n            result_slice = coherence_enhancing_diffusion_fast(slice_tensor, config)\n            \n            # 移除批次和通道维度\n            result_slice = result_slice.squeeze(0).squeeze(0)\n            \n            # 转回 numpy 并二值化\n            result_np = result_slice.detach().cpu().numpy()\n            binary_slice = (result_np > 0.5).astype(np.uint8)\n            \n            result_volume[z, :, :] = binary_slice\n        \n        print(f\"    CED 完成，前景像素: {np.count_nonzero(result_volume)} / {result_volume.size}\")\n        return result_volume\n    \n    else:\n        raise ValueError(f\"期望 3D 输入，但得到形状: {prediction.shape}\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-14T17:15:31.019864Z","iopub.execute_input":"2026-02-14T17:15:31.020116Z","iopub.status.idle":"2026-02-14T17:15:32.686067Z","shell.execute_reply.started":"2026-02-14T17:15:31.020096Z","shell.execute_reply":"2026-02-14T17:15:32.685320Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# 导入形态学操作\nimport numpy as np\nfrom scipy.ndimage import binary_closing\nfrom skimage.morphology import remove_small_objects\n\n\ndef build_anisotropic_struct(z_radius: int, xy_radius: int) -> np.ndarray:\n    \"\"\"构建各向异性的 3D 结构元素（椭球体）\n    \n    支持 Z 和 XY 方向不同的半径，用于各向异性形态学操作。\n    \n    Args:\n        z_radius: Z 方向（深度）的半径\n        xy_radius: XY 方向（平面）的半径\n        \n    Returns:\n        3D 布尔数组，表示结构元素\n    \"\"\"\n    z, r = z_radius, xy_radius\n    \n    # 两个半径都为 0，返回 None（不进行操作）\n    if z == 0 and r == 0:\n        return None\n    \n    # 只有 XY 方向半径，构建 2D 圆盘（单层）\n    if z == 0 and r > 0:\n        size = 2 * r + 1\n        struct = np.zeros((1, size, size), dtype=bool)\n        y, x = np.ogrid[-r:r+1, -r:r+1]\n        mask = x**2 + y**2 <= r**2\n        struct[0] = mask\n        return struct\n    \n    # 构建完整的各向异性椭球体\n    depth = 2 * z + 1\n    size = 2 * r + 1\n    struct = np.zeros((depth, size, size), dtype=bool)\n    \n    # 在每个 Z 层上应用相同的 XY 圆盘\n    y, x = np.ogrid[-r:r+1, -r:r+1]\n    mask = x**2 + y**2 <= r**2\n    for dz in range(depth):\n        struct[dz] = mask\n    \n    return struct\n\n\ndef apply_anisotropic_closing_3d(\n    mask3d: np.ndarray, \n    z_radius: int, \n    xy_radius: int\n) -> np.ndarray:\n    \"\"\"应用各向异性闭运算（3D）\n    \n    闭运算 = 膨胀 + 腐蚀，可以填充小孔洞和连接邻近对象。\n    各向异性结构元素允许在不同方向上有不同的连接性。\n    \n    Args:\n        mask3d: 输入二值图像（3D 体积）\n        z_radius: Z 方向半径\n        xy_radius: XY 方向半径\n        \n    Returns:\n        闭运算后的二值图像\n    \"\"\"\n    struct = build_anisotropic_struct(z_radius, xy_radius)\n    if struct is None:\n        return mask3d\n    return binary_closing(mask3d, structure=struct)\n\n\ndef apply_dust_removal(\n    mask3d: np.ndarray, \n    min_size: int\n) -> np.ndarray:\n    \"\"\"移除小对象（Dust Removal）\n    \n    移除体积小于指定阈值的连通组件，用于清除噪声和小碎片。\n    \n    Args:\n        mask3d: 输入二值图像（3D 体积）\n        min_size: 最小保留体素数\n        \n    Returns:\n        移除小对象后的二值图像\n    \"\"\"\n    if min_size <= 0:\n        return mask3d\n    return remove_small_objects(mask3d.astype(bool), min_size=min_size).astype(np.uint8)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-14T17:15:32.687091Z","iopub.execute_input":"2026-02-14T17:15:32.687533Z","iopub.status.idle":"2026-02-14T17:15:33.006257Z","shell.execute_reply.started":"2026-02-14T17:15:32.687500Z","shell.execute_reply":"2026-02-14T17:15:33.005665Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import numpy as np\nfrom numpy import linalg as LA\nfrom scipy.ndimage import gaussian_filter\n\n\ndef divide_nonzero(array1, array2, eps=1e-10):\n    denominator = np.copy(array2)\n    denominator[denominator == 0] = eps\n    return np.divide(array1, denominator)\n\n\ndef _prepare_image(image: np.ndarray, target_dtype: np.dtype | type = np.float32) -> np.ndarray:\n    arr = np.asarray(image)\n    if target_dtype is not None:\n        arr = arr.astype(target_dtype, copy=False)\n    return arr\n\ndef normalize_minmax(\n    image: np.ndarray,\n    *,\n    target_dtype: np.dtype | type = np.float32,\n) -> np.ndarray:\n    \"\"\"\n    Min-max normalisation that rescales intensities into the [0, 1] range.\n    \"\"\"\n    arr = _prepare_image(image, target_dtype)\n    min_val = float(arr.min())\n    max_val = float(arr.max())\n\n    if max_val > min_val:\n        arr -= min_val\n        arr /= max(max_val - min_val, 1e-8)\n    else:\n        arr.fill(0.0)\n\n    return arr\n\ndef hessian_curvature_3d(volume, gauss_sigma=2, sigma=6):\n    volume_smoothed = gaussian_filter(volume, sigma=gauss_sigma)\n    volume_smoothed = normalize_minmax(volume_smoothed)\n\n    joint_hessian = np.zeros((volume.shape[0], volume.shape[1], volume.shape[2], 3, 3), dtype=float)\n\n    Dz = np.gradient(volume_smoothed, axis=0, edge_order=2)\n    joint_hessian[:, :, :, 2, 2] = np.gradient(Dz, axis=0, edge_order=2)\n    del Dz\n\n    Dy = np.gradient(volume_smoothed, axis=1, edge_order=2)\n    joint_hessian[:, :, :, 1, 1] = np.gradient(Dy, axis=1, edge_order=2)\n    joint_hessian[:, :, :, 1, 2] = np.gradient(Dy, axis=0, edge_order=2)\n    del Dy\n\n    Dx = np.gradient(volume_smoothed, axis=2, edge_order=2)\n    joint_hessian[:, :, :, 0, 0] = np.gradient(Dx, axis=2, edge_order=2)\n    joint_hessian[:, :, :, 0, 1] = np.gradient(Dx, axis=1, edge_order=2)\n    joint_hessian[:, :, :, 0, 2] = np.gradient(Dx, axis=0, edge_order=2)\n    del Dx\n\n    joint_hessian = joint_hessian * (sigma ** 2)\n    zero_mask = np.trace(joint_hessian, axis1=3, axis2=4) == 0\n    return joint_hessian, zero_mask\n\n\ndef detect_ridges_3d(volume, gamma=1.5, beta1=0.5, beta2=0.5, gauss_sigma=2, sigma=6):\n    \"\"\"Sheetness (plate-like) measure for 3D surface/laminar structures.\n\n    Notes:\n        - Assumes \"bright sheet on dark background\" by default (keeps voxels where\n          the largest-magnitude eigenvalue is negative).\n        - Eigenvalues are sorted by absolute value: |λ1| <= |λ2| <= |λ3|.\n        - Sheet-like structures typically satisfy: |λ1| ~ 0, |λ2| ~ 0, |λ3| >> 0.\n    \"\"\"\n    return detect_surfaces_3d(\n        volume,\n        gamma=gamma,\n        beta1=beta1,\n        beta2=beta2,\n        gauss_sigma=gauss_sigma,\n        sigma=sigma,\n        bright=True,\n    )\n\n\ndef _sorted_hessian_eigvals(volume, gauss_sigma=2, sigma=6):\n    joint_hessian, zero_mask = hessian_curvature_3d(volume, gauss_sigma, sigma)\n    eigvals = LA.eigvalsh(joint_hessian, 'U')\n    idxs = np.argsort(np.abs(eigvals), axis=-1)\n    eigvals = np.take_along_axis(eigvals, idxs, axis=-1)\n    eigvals[zero_mask, :] = 0\n    return eigvals\n\n\ndef detect_surfaces_3d(\n    volume,\n    gamma=1.5,\n    beta1=0.5,\n    beta2=0.5,\n    gauss_sigma=2,\n    sigma=6,\n    *,\n    bright: bool = True,\n):\n    \"\"\"Detect sheet-like (surface/laminar) structures in a 3D volume.\n\n    Args:\n        bright: If True, enhances bright sheets on dark background (λ3 < 0).\n                If False, enhances dark sheets on bright background (λ3 > 0).\n    \"\"\"\n    eigvals = _sorted_hessian_eigvals(volume, gauss_sigma=gauss_sigma, sigma=sigma)\n\n    L1 = np.abs(eigvals[:, :, :, 0])\n    L2 = np.abs(eigvals[:, :, :, 1])\n    L3 = eigvals[:, :, :, 2]\n    L3abs = np.abs(L3)\n\n    # Overall second-order structure strength\n    S = np.sqrt(np.square(eigvals).sum(axis=-1))\n    background_term = 1 - np.exp(-0.5 * np.square(S / gamma))\n\n    # Sheetness: penalize non-zero in-plane curvature (|λ1|, |λ2|) relative to |λ3|\n    R2 = divide_nonzero(L2, L3abs)\n    R1 = divide_nonzero(L1, L3abs)\n    sheet_term_2 = np.exp(-0.5 * np.square(R2 / beta1))\n    sheet_term_1 = np.exp(-0.5 * np.square(R1 / beta2))\n\n    sheetness = background_term * sheet_term_2 * sheet_term_1\n    if bright:\n        sheetness[L3 > 0] = 0\n    else:\n        sheetness[L3 < 0] = 0\n    return sheetness\n\n\ndef detect_vessels_3d(volume, gamma=1.5, beta1=0.5, beta2=0.5, gauss_sigma=2, sigma=6):\n    \"\"\"Vesselness (tubular ridge) measure preserved from the original implementation.\"\"\"\n    eigvals = _sorted_hessian_eigvals(volume, gauss_sigma=gauss_sigma, sigma=sigma)\n\n    L1 = np.abs(eigvals[:, :, :, 0])\n    L2 = np.abs(eigvals[:, :, :, 1])\n    L3 = eigvals[:, :, :, 2]\n    L3abs = np.abs(L3)\n\n    S = np.sqrt(np.square(eigvals).sum(axis=-1))\n    background_term = 1 - np.exp(-0.5 * np.square(S / gamma))\n\n    Ra = divide_nonzero(L2, L3abs)\n    planar_term = np.exp(-0.5 * np.square(Ra / beta1))\n\n    Rb = divide_nonzero(L1, np.sqrt(L2 * L3abs))\n    blob_term = np.exp(-0.5 * np.square(Rb / beta2))\n\n    vesselness = background_term * planar_term * blob_term\n    vesselness[L3 > 0] = 0\n    return vesselness\n","metadata":{"papermill":{"duration":0.147366,"end_time":"2026-01-11T18:42:19.188619","exception":false,"start_time":"2026-01-11T18:42:19.041253","status":"completed"},"tags":[],"trusted":true,"execution":{"iopub.status.busy":"2026-02-14T17:15:33.007156Z","iopub.execute_input":"2026-02-14T17:15:33.007556Z","iopub.status.idle":"2026-02-14T17:15:33.024518Z","shell.execute_reply.started":"2026-02-14T17:15:33.007533Z","shell.execute_reply":"2026-02-14T17:15:33.023829Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def predictions_to_tiff_frangi_ced(\n    pred_dir: Path, \n    output_dir: Path,\n    # Frangi parameters\n    frangi_sigma: float = 6.0,\n    frangi_gauss_sigma: float = 2.0,\n    frangi_gamma: float = 1.0,\n    frangi_beta1: float = 0.5,\n    frangi_beta2: float = 0.5,\n    ridge_threshold: float = 0.5,\n    # CED parameters\n    ced_lambda: float = 1.0,\n    ced_sigma: float = 3.0,\n    ced_rho: float = 5.0,\n    ced_step_size: float = 0.24,\n    ced_m: float = 1.0,\n    ced_num_steps: int = 300,\n    # 形态学后处理参数\n    morphology_z_radius: int = 1,\n    morphology_xy_radius: int = 0,\n    dust_min_size: int = 100,\n):\n    \"\"\"\n    Convert nnUNet predictions to 3D TIFF with Frangi+CED post-processing.\n    \n    Post-processing pipeline:\n    1. Frangi filtering (detects 3D ridge/sheet structures)\n    2. CED filtering (coherence enhancing diffusion)\n    3. Morphological operations:\n       - Anisotropic closing (填充小孔洞和连接邻近对象)\n       - Dust removal (移除小对象/噪声)\n    \"\"\"\n    output_dir.mkdir(parents=True, exist_ok=True)\n    \n    tif_files = sorted(list(pred_dir.glob(\"*.tif\")))\n    if not tif_files:\n        print(\"ERROR: No .tif files found!\")\n        return\n\n    print(f\"Processing {len(tif_files)} files with Frangi+CED+Morphology post-processing\")\n    print(f\"  Frangi: sigma={frangi_sigma}, ridge_threshold={ridge_threshold}\")\n    print(f\"  CED: lambda={ced_lambda}, sigma={ced_sigma}, rho={ced_rho}, steps={ced_num_steps}\")\n    print(f\"  Morphology: z_radius={morphology_z_radius}, xy_radius={morphology_xy_radius}, dust_min_size={dust_min_size}\")\n\n    for tif_path in tqdm(tif_files, desc=\"Post-processing\"):\n        case_id = tif_path.stem\n        volume = tifffile.imread(str(tif_path))\n        binary_vol = (volume > 0).astype(np.float32)\n        \n        # Step 1: Apply Frangi filter\n        ridges = detect_ridges_3d(\n            binary_vol,\n            gamma=frangi_gamma,\n            beta1=frangi_beta1,\n            beta2=frangi_beta2,\n            gauss_sigma=frangi_gauss_sigma,\n            sigma=frangi_sigma,\n        )\n        frangi_result = (ridges > ridge_threshold).astype(np.uint8)\n        \n        # Step 2: Apply CED filter\n        ced_result = apply_ced_postprocessing(\n            frangi_result,\n            lambda_param=ced_lambda,\n            sigma=ced_sigma,\n            rho=ced_rho,\n            step_size=ced_step_size,\n            m=ced_m,\n            num_steps=ced_num_steps,\n        )\n        \n        # Step 3: Apply morphological operations\n        postprocessed = ced_result\n        \n        # 3a. 各向异性闭运算（填充小孔洞和连接邻近对象）\n        if morphology_z_radius > 0 or morphology_xy_radius > 0:\n            postprocessed = apply_anisotropic_closing_3d(\n                postprocessed,\n                z_radius=morphology_z_radius,\n                xy_radius=morphology_xy_radius,\n            )\n        \n        # 3b. 小对象移除（清除噪声和小碎片）\n        if dust_min_size > 0:\n            postprocessed = apply_dust_removal(\n                postprocessed,\n                min_size=dust_min_size,\n            )\n        \n        tifffile.imwrite(output_dir / f\"{case_id}.tif\", postprocessed, compression='zlib')\n    \n    print(f\"Done: {output_dir}\")\n\n\n# Convert predictions to TIFF with Frangi+CED post-processing\ntiff_output_dir = OUTPUT_DIR / \"predictions_tiff\"\n\npredictions_to_tiff_frangi_ced(\n    predictions_dir, \n    tiff_output_dir, \n    # Frangi parameters\n    frangi_sigma=6.0,\n    frangi_gauss_sigma=2.0,\n    frangi_gamma=1.0,\n    frangi_beta1=0.5,\n    frangi_beta2=0.5,\n    ridge_threshold=0.4,\n    # CED parameters (与 test_postprocessing_score.py 保持一致)\n    ced_lambda=1.0,\n    ced_sigma=3.0,\n    ced_rho=5.0,\n    ced_step_size=0.24,\n    ced_m=1.0,\n    ced_num_steps=300,\n    morphology_z_radius=2,      # 各向异性闭运算 Z 方向半径\n    morphology_xy_radius=1,     # 各向异性闭运算 XY 方向半径\n    dust_min_size=1000,          # 小对象移除的最小体素数\n)","metadata":{"papermill":{"duration":37.789416,"end_time":"2026-01-11T18:42:56.996981","exception":false,"start_time":"2026-01-11T18:42:19.207565","status":"completed"},"tags":[],"trusted":true,"execution":{"iopub.status.busy":"2026-02-14T17:15:33.025463Z","iopub.execute_input":"2026-02-14T17:15:33.025696Z","iopub.status.idle":"2026-02-14T17:16:33.459278Z","shell.execute_reply.started":"2026-02-14T17:15:33.025677Z","shell.execute_reply":"2026-02-14T17:16:33.458452Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 8. Create Submission","metadata":{"papermill":{"duration":0.010585,"end_time":"2026-01-11T18:42:57.0181","exception":false,"start_time":"2026-01-11T18:42:57.007515","status":"completed"},"tags":[]}},{"cell_type":"code","source":"def generate_submission(\n    predictions_tiff_dir: Path = OUTPUT_DIR / \"predictions_tiff\",\n    output_zip: Path = OUTPUT_DIR / \"submission.zip\",\n    delete_after_zip: bool = True\n) -> Optional[Path]:\n    \"\"\"\n    Create submission ZIP from TIFF predictions.\n    \n    Args:\n        predictions_tiff_dir: Directory containing predicted TIFF files\n        output_zip: Output ZIP file path\n        delete_after_zip: Delete TIFF files after adding to ZIP (saves space)\n    \n    Returns:\n        Path to submission ZIP if successful, None otherwise\n    \"\"\"\n    if not predictions_tiff_dir.exists():\n        print(f\"ERROR: Predictions directory not found: {predictions_tiff_dir}\")\n        return None\n    \n    tiff_files = sorted(predictions_tiff_dir.glob(\"*.tif\"))\n    \n    if not tiff_files:\n        print(f\"No TIFF files found in {predictions_tiff_dir}\")\n        return None\n    \n    print(f\"Creating submission ZIP with {len(tiff_files)} files...\")\n    \n    with zipfile.ZipFile(output_zip, 'w', zipfile.ZIP_DEFLATED) as zipf:\n        for tiff_path in tqdm(tiff_files, desc=\"Zipping predictions\"):\n            # Add file with just the filename (no directory structure)\n            zipf.write(tiff_path, tiff_path.name)\n            \n            if delete_after_zip:\n                tiff_path.unlink()\n    \n    zip_size_mb = output_zip.stat().st_size / (1024 * 1024)\n    print(f\"\\n✓ Submission saved: {output_zip} ({zip_size_mb:.1f} MB)\")\n    \n    return output_zip\n\n\n# Generate submission\nsubmission_path = generate_submission(delete_after_zip=True)\n\nif submission_path:\n    print(f\"\\n\" + \"=\"*60)\n    print(\"SUBMISSION READY!\")\n    print(\"=\"*60)\n    print(f\"Download: {submission_path}\")","metadata":{"papermill":{"duration":0.058446,"end_time":"2026-01-11T18:42:57.086947","exception":false,"start_time":"2026-01-11T18:42:57.028501","status":"completed"},"tags":[],"trusted":true,"execution":{"iopub.status.busy":"2026-02-14T17:16:33.460360Z","iopub.execute_input":"2026-02-14T17:16:33.460601Z","iopub.status.idle":"2026-02-14T17:16:33.501782Z","shell.execute_reply.started":"2026-02-14T17:16:33.460580Z","shell.execute_reply":"2026-02-14T17:16:33.501086Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 9. Visualization (Optional)","metadata":{"papermill":{"duration":0.010364,"end_time":"2026-01-11T18:42:57.108222","exception":false,"start_time":"2026-01-11T18:42:57.097858","status":"completed"},"tags":[]}},{"cell_type":"code","source":"import matplotlib.pyplot as plt\n\ndef visualize_sample_prediction(\n    predictions_dir: Path = WORKING_DIR / \"predictions\",\n    test_images_dir: Path = INPUT_DIR / \"test_images\"\n):\n    \"\"\"\n    Visualize a sample prediction with the input image.\n    \"\"\"\n    # Find first prediction\n    tif_files = list(predictions_dir.glob(\"*.tif\"))\n    if not tif_files:\n        print(\"No predictions found to visualize\")\n        return\n    \n    pred_path = tif_files[0]\n    case_id = pred_path.stem\n    image_path = test_images_dir / f\"{case_id}.tif\"\n    \n    if not image_path.exists():\n        print(f\"Input image not found: {image_path}\")\n        return\n    \n    print(f\"Visualizing: {case_id}\")\n    \n    # Load volumes\n    image_vol = tifffile.imread(str(image_path))\n    mask_vol = tifffile.imread(str(pred_path)).astype(np.uint8)\n    \n    # Get middle slices\n    d, h, w = image_vol.shape\n    z_mid = d // 2\n    \n    fig, axes = plt.subplots(1, 2, figsize=(12, 6))\n    \n    axes[0].imshow(image_vol[z_mid], cmap='gray')\n    axes[0].set_title(f'Input Image (Z={z_mid})')\n    axes[0].axis('off')\n    \n    axes[1].imshow(mask_vol[z_mid], cmap='gray')\n    axes[1].set_title(f'Prediction (Z={z_mid})')\n    axes[1].axis('off')\n    \n    plt.tight_layout()\n    plt.savefig(OUTPUT_DIR / 'sample_prediction.png', dpi=150, bbox_inches='tight')\n    plt.show()\n\n\n# Uncomment to visualize\n# visualize_sample_prediction()","metadata":{"papermill":{"duration":0.018481,"end_time":"2026-01-11T18:42:57.137137","exception":false,"start_time":"2026-01-11T18:42:57.118656","status":"completed"},"tags":[],"trusted":true,"execution":{"iopub.status.busy":"2026-02-14T17:17:46.024265Z","iopub.execute_input":"2026-02-14T17:17:46.024577Z","iopub.status.idle":"2026-02-14T17:17:46.083745Z","shell.execute_reply.started":"2026-02-14T17:17:46.024552Z","shell.execute_reply":"2026-02-14T17:17:46.082748Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Summary\n\nThis notebook:\n1. ✓ Sets up nnUNet environment\n2. ✓ Links pre-trained model from Kaggle dataset\n3. ✓ Prepares test data with proper naming convention\n4. ✓ Runs inference with nnUNetv2_predict\n5. ✓ Converts predictions to TIFF format\n6. ✓ Creates submission.zip\n\n### To use this notebook on Kaggle:\n\n1. **Upload Model Checkpoint as Kaggle Dataset:**\n   - Upload the folder: `nnUNetTrainer_500epochs__nnUNetResEncUNetMPlans__3d_lowres`\n   - This should contain: `fold_all/`, `plans.json`, `dataset.json`, `dataset_fingerprint.json`\n\n2. **Add Required Datasets:**\n   - `vesuvius-challenge-surface-detection` (competition data)\n   - Your model checkpoint dataset\n   - `surface-packages-offline` (or install packages online)\n\n3. **Update Configuration:**\n   - Set `MODEL_DATASET_PATH` to your uploaded model dataset path\n\n4. **Run All Cells**\n\n5. **Submit** `submission.zip`","metadata":{"papermill":{"duration":0.010477,"end_time":"2026-01-11T18:42:57.158251","exception":false,"start_time":"2026-01-11T18:42:57.147774","status":"completed"},"tags":[]}},{"cell_type":"code","source":"","metadata":{"papermill":{"duration":0.010655,"end_time":"2026-01-11T18:42:57.179327","exception":false,"start_time":"2026-01-11T18:42:57.168672","status":"completed"},"tags":[],"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"","metadata":{"papermill":{"duration":0.010424,"end_time":"2026-01-11T18:42:57.200147","exception":false,"start_time":"2026-01-11T18:42:57.189723","status":"completed"},"tags":[],"trusted":true},"outputs":[],"execution_count":null}]}