├── LICENSE ├── README.md ├── bins.txt ├── bins2.txt ├── lines.py ├── pypsd.py ├── sample_input.txt └── todo.txt /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2017 Sean M. Anderson and Liliana Villafaña-López 2 | 3 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 4 | 5 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 6 | 7 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 8 | 9 | 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 10 | 11 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | PyPSD 2 | ======== 3 | 4 | [![License](https://img.shields.io/badge/License-BSD%203--Clause-blue.svg)](https://opensource.org/licenses/BSD-3-Clause) 5 | 6 | PyPSD is a Python program for calculating the particle size distribution (PSD) of any sample. Measuring particle sizes and calculating their distribution in a sample is crucial for understanding diverse physical and chemical processes. Particle sizes can be determined with [ImageJ](https://fiji.sc), a free and open-source scientific imaging program (or any other software with this functionality). That data is then used by PyPSD in order to produce the relevant distributions. 7 | 8 | ![](http://i.imgur.com/eM8dUPk.png?1) 9 | 10 | 11 | Installation and use 12 | ------------------------------------ 13 | 14 | PyPSD has been tested with Python 3 and [Anaconda Python 4+](https://www.continuum.io/downloads) on macOS, Linux, and Windows. It should work on any system with the required Python packages installed. 15 | 16 | Python requirements: 17 | `numpy`, `matplotlib`, `argparse`, `os` 18 | 19 | Usage: 20 | `python pypsd.py [--help] -b BINSFILE -i INPUTFILE [-o OUTPUTDIR]` 21 | 22 | For maximum compatibility, PyPSD is designed to be executed directly from the command line which is available on all operating systems. The `--help` option will display a useful help message. Use the `-b` and `-i` flags to specify your bin and input files. The optional `-o` argument allows you to specify a directory to output your results. 23 | 24 | Your input data should be in a text (`.txt`) file with a single column containing the measured particle areas. Your bins should also be in a text file with a single column of bin limits. These are used to classify your particles by diameter. Your bins should obviously be in units compatible with the areas listed in your input file. Sample input (`sample_input.txt`) and bin (`bins.txt`) files are included. 25 | 26 | 27 | License 28 | ------------------------------------ 29 | 30 | Copyright 2017 [Sean M. Anderson](mailto:sma@cio.mx) and Liliana Villafaña-López. 31 | 32 | PyPSD is free software made available under the BSD-3-Clause License. For details please see the LICENSE file. 33 | -------------------------------------------------------------------------------- /bins.txt: -------------------------------------------------------------------------------- 1 | # di(bins) in micrometers 2 | 000.010 3 | 000.012 4 | 000.014 5 | 000.016 6 | 000.018 7 | 000.022 8 | 000.024 9 | 000.028 10 | 000.032 11 | 000.036 12 | 000.042 13 | 000.046 14 | 000.056 15 | 000.060 16 | 000.074 17 | 000.078 18 | 000.096 19 | 000.104 20 | 000.126 21 | 000.136 22 | 000.164 23 | 000.180 24 | 000.214 25 | 000.238 26 | 000.280 27 | 000.312 28 | 000.366 29 | 000.412 30 | 000.478 31 | 000.542 32 | 000.626 33 | 000.712 34 | 000.820 35 | 000.934 36 | 001.076 37 | 001.226 38 | 001.410 39 | 001.610 40 | 001.848 41 | 002.114 42 | 002.424 43 | 002.774 44 | 003.178 45 | 003.640 46 | 004.170 47 | 004.774 48 | 005.470 49 | 006.264 50 | 007.176 51 | 008.218 52 | 009.414 53 | 010.780 54 | 012.350 55 | 014.142 56 | 016.202 57 | 018.552 58 | 021.256 59 | 024.338 60 | 027.884 61 | 031.930 62 | 036.580 63 | 041.888 64 | 047.988 65 | 054.954 66 | 062.952 67 | 072.094 68 | 082.586 69 | 094.580 70 | 108.340 71 | 124.080 72 | 142.126 73 | 162.780 74 | 186.452 75 | 213.548 76 | 244.602 77 | 280.150 78 | 320.886 79 | 367.526 80 | 420.962 81 | 482.150 82 | 552.250 83 | -------------------------------------------------------------------------------- /bins2.txt: -------------------------------------------------------------------------------- 1 | # dmin dmax di(bins) 2 | 000.010 000.012 000.011 3 | 000.012 000.014 000.013 4 | 000.014 000.016 000.015 5 | 000.016 000.018 000.017 6 | 000.018 000.022 000.020 7 | 000.022 000.024 000.023 8 | 000.024 000.028 000.026 9 | 000.028 000.032 000.030 10 | 000.032 000.036 000.034 11 | 000.036 000.042 000.039 12 | 000.042 000.046 000.044 13 | 000.046 000.056 000.051 14 | 000.056 000.060 000.058 15 | 000.060 000.074 000.067 16 | 000.074 000.078 000.076 17 | 000.078 000.096 000.087 18 | 000.096 000.104 000.100 19 | 000.104 000.126 000.115 20 | 000.126 000.136 000.131 21 | 000.136 000.164 000.150 22 | 000.164 000.180 000.172 23 | 000.180 000.214 000.197 24 | 000.214 000.238 000.226 25 | 000.238 000.280 000.259 26 | 000.280 000.312 000.296 27 | 000.312 000.366 000.339 28 | 000.366 000.412 000.389 29 | 000.412 000.478 000.445 30 | 000.478 000.542 000.510 31 | 000.542 000.626 000.584 32 | 000.626 000.712 000.669 33 | 000.712 000.820 000.766 34 | 000.820 000.934 000.877 35 | 000.934 001.076 001.005 36 | 001.076 001.226 001.151 37 | 001.226 001.410 001.318 38 | 001.410 001.610 001.510 39 | 001.610 001.848 001.729 40 | 001.848 002.114 001.981 41 | 002.114 002.424 002.269 42 | 002.424 002.774 002.599 43 | 002.774 003.178 002.976 44 | 003.178 003.640 003.409 45 | 003.640 004.170 003.905 46 | 004.170 004.774 004.472 47 | 004.774 005.470 005.122 48 | 005.470 006.264 005.867 49 | 006.264 007.176 006.720 50 | 007.176 008.218 007.697 51 | 008.218 009.414 008.816 52 | 009.414 010.780 010.097 53 | 010.780 012.350 011.565 54 | 012.350 014.142 013.246 55 | 014.142 016.202 015.172 56 | 016.202 018.552 017.377 57 | 018.552 021.256 019.904 58 | 021.256 024.338 022.797 59 | 024.338 027.884 026.111 60 | 027.884 031.930 029.907 61 | 031.930 036.580 034.255 62 | 036.580 041.888 039.234 63 | 041.888 047.988 044.938 64 | 047.988 054.954 051.471 65 | 054.954 062.952 058.953 66 | 062.952 072.094 067.523 67 | 072.094 082.586 077.340 68 | 082.586 094.580 088.583 69 | 094.580 108.340 101.460 70 | 108.340 124.080 116.210 71 | 124.080 142.126 133.103 72 | 142.126 162.780 152.453 73 | 162.780 186.452 174.616 74 | 186.452 213.548 200.000 75 | 213.548 244.602 229.075 76 | 244.602 280.150 262.376 77 | 280.150 320.886 300.518 78 | 320.886 367.526 344.206 79 | 367.526 420.962 394.244 80 | 420.962 482.150 451.556 81 | 482.150 552.250 517.200 82 | -------------------------------------------------------------------------------- /lines.py: -------------------------------------------------------------------------------- 1 | # Usage: python lines.py 2 | # then run gnuplot on the individual gnuplot files to generate the plots in PDF. 3 | 4 | import numpy as np 5 | 6 | params = [('PAN500KD_1', 17, 2), # done 000 7 | ('PAN500KD_2', 5, 2), # done 0.86 8 | ('PES200KD_1', 7, 3), # done 0.77 9 | ('PES200KD_2', 9, 3), # done 1.51 10 | ('PES300KD_1', 2, 3), # done 0.37 11 | ('PES300KD_2', 6, 2), # done 1.22 12 | ('PVDF04u_1', 2, 4), # done 0.42 13 | ('PVDF04u_2', 3, 3), # done 0.66 14 | ('PVDF15u_1', 5, 3), # done 0.57 15 | ('PVDF15u_2', 2, 2)] # done 0.52 16 | 17 | for case in params: 18 | file = case[0] 19 | data = np.loadtxt('data/' + file + '.txt') 20 | elem = len(data)-1 21 | 22 | start_data = np.take(data, list(range(case[1])), 0) 23 | start_line = np.polyfit(start_data[:,0],start_data[:,2],1) 24 | 25 | end_data = np.take(data, list(range(elem, elem-case[2], -1)), 0) 26 | end_line = np.polyfit(end_data[:,0],end_data[:,2],1) 27 | 28 | intersect_x = (end_line[1] - start_line[1])/\ 29 | (start_line[0] - end_line[0]) 30 | intersect_y = (start_line[0]*end_line[1] - end_line[0]*start_line[1])/\ 31 | (start_line[0] - end_line[0]) 32 | intersection = [intersect_x, intersect_y] 33 | 34 | with open('gnuplot_' + str(file) + '.gp', 'w') as outfile: 35 | print('set terminal pdfcairo transparent enhanced fontscale 0.5 size 5.00in, 3.00in', file=outfile) 36 | print('set output "{0}.pdf"'.format(file), file=outfile) 37 | # print('set terminal pngcairo background "#ffffff" enhanced fontscale 1.0 size 1200, 720', file=outfile) 38 | # print('set output "{0}.png"'.format(file), file=outfile) 39 | # print('set locale "en_US.UTF-8"', file=outfile) 40 | print('GNUTERM = "wxt"', file=outfile) 41 | print('set grid', file=outfile) 42 | print('set xrange [0:*]', file=outfile) 43 | print('set yrange [0:*]', file=outfile) 44 | print('set xlabel "TMP (bar)"', file=outfile) 45 | print('set ylabel "J (L/h/m^{2})"', file=outfile) 46 | print('f(x) = (x < ({0:.4f} + {0:.4f}*0.1) ) ? {1:.4f}*x + {2:.4f} : 1/0'.format(intersection[0], start_line[0], start_line[1]), file=outfile) 47 | print('g(x) = (x > ({0:.4f} - {0:.4f}*0.1) ) ? {1:.4f}*x + {2:.4f} : 1/0'.format(intersection[0], end_line[0], end_line[1]), file=outfile) 48 | print('set label at {0:.4f},{1:.4f} "" point lw 2 pt 6 ps 1 front'.format(intersection[0], intersection[1]), file=outfile) 49 | print('set label at {0:.4f},0 "{0:.2f}" offset 1,2 front'.format(intersection[0], intersection[1]), file=outfile) 50 | print('set arrow from {0:.4f},{1:.4f} to {0:.4f},0 nohead lw 2 dt 2 front'.format(intersection[0], intersection[1]), file=outfile) 51 | print('p "data/{0}.txt" u 1:3:4 t "" w errorbars lw 1.5 pt 7 ps 0.5 lc rgb "#268bd2",\\'.format(file), file=outfile) 52 | # print('"" u 1:3 t "" lw 1.5 lc rgb "#268bd2" w l,\\'.format(file), file=outfile) 53 | print('f(x) t "" lw 1.5 lc rgb "#dc322f" w l,\\', file=outfile) 54 | print('g(x) t "" lw 1.5 lc rgb "#dc322f" w l', file=outfile) 55 | -------------------------------------------------------------------------------- /pypsd.py: -------------------------------------------------------------------------------- 1 | ''' 2 | usage: python pypsd.py [-h] -b BINSFILE -i INPUTFILE [-o OUTPUTDIR] 3 | 4 | A Python script for calculating the particle size distribution (PSD) of any 5 | sample. Please read the adjoining README.md file for more information. 6 | 7 | Written by Sean M. Anderson and Liliana Villafana-Lopez. 8 | ''' 9 | 10 | import os 11 | import argparse 12 | import numpy as np 13 | from matplotlib import pyplot as plt 14 | from matplotlib.ticker import ScalarFormatter 15 | 16 | # Argparse stuff 17 | PARSER = argparse.ArgumentParser() 18 | PARSER.add_argument("-b", dest='binsfile', help="file with particle diameter bins", type=str, required=True) 19 | PARSER.add_argument("-i", dest='inputfile', help="file with particle areas", type=str, required=True) 20 | PARSER.add_argument("-o", dest='outputdir', help="output directory", type=str, required=False) 21 | ARGS = PARSER.parse_args() 22 | 23 | # Read input file from command line, create arrays 24 | INFILE = ARGS.inputfile 25 | BASENAME = INFILE.split('.txt')[0] 26 | BINS = np.loadtxt(ARGS.binsfile, unpack=True) 27 | PARTICLES = np.loadtxt(INFILE) 28 | if ARGS.outputdir: 29 | OUTPATH = ARGS.outputdir + '/' 30 | os.makedirs(OUTPATH, exist_ok=True) 31 | else: 32 | OUTPATH = BASENAME + '_' 33 | 34 | 35 | def distribution(values, cutoff): 36 | """ a function for creating and then solving a linear equation """ 37 | counter = np.argmax(values >= cutoff) 38 | point2 = np.array([BINS[counter], values[counter]]) 39 | point1 = np.array([BINS[counter-1], values[counter-1]]) 40 | slope = (point2[1] - point1[1])/(point2[0] - point1[0]) 41 | intercept = point2[1] - slope*point2[0] 42 | dist = (cutoff - intercept) * (1/slope) 43 | return dist 44 | 45 | # Rolling mean for significant sample size 46 | AVGCUM = np.cumsum(PARTICLES, dtype=float)/np.arange(1, PARTICLES.size+1) 47 | 48 | # Binning and frequencies 49 | DIAMETERS = 2 * np.sqrt(PARTICLES/np.pi) 50 | NI = np.bincount(np.digitize(DIAMETERS, BINS), minlength=BINS.size) # dmin 51 | AI = 4 * np.pi * (BINS/2)**2 * NI 52 | VI = (4.0/3.0) * np.pi * (BINS/2)**3 * NI 53 | 54 | # Differential percentage 55 | NPCT = NI * (1/np.sum(NI)) * 100 56 | APCT = AI * (1/np.sum(AI)) * 100 57 | VPCT = VI * (1/np.sum(VI)) * 100 58 | 59 | # Cumulative percentage 60 | NCUM = np.cumsum(NPCT, dtype=float) 61 | ACUM = np.cumsum(APCT, dtype=float) 62 | VCUM = np.cumsum(VPCT, dtype=float) 63 | 64 | # Data for distributions 65 | np.savetxt(OUTPATH + 'distdata.txt', \ 66 | np.c_[BINS, NPCT, APCT, VPCT], \ 67 | fmt=('%07.3f', '%07.3f', '%07.3f', '%07.3f'), delimiter=' ', \ 68 | header='Bins[um]'+1*" "+ 'Number'+5*" "+'Area'+7*" "+'Volume') 69 | 70 | ### Plot with matplotlib 71 | FIG = plt.figure(1, figsize=(12, 9)) 72 | FIG.set_tight_layout(True) 73 | 74 | # Upper subplot, significant samples 75 | plt.subplot(211) 76 | plt.grid(True, which="both") 77 | plt.title("Significant Sample Average") 78 | plt.xlabel("Samples") 79 | plt.ylabel("Cumulative Average") 80 | plt.plot(AVGCUM, lw=2.0) 81 | 82 | # Lower subplot, size distributions 83 | AX = plt.subplot(212) 84 | plt.title("Droplet Size Distribution") 85 | plt.xlabel("Diameter (um)") 86 | plt.ylabel("Differential (%)") 87 | plt.grid(True, which="both") 88 | plt.xlim((0.01, 100.0)) 89 | plt.xscale('log') 90 | AX.xaxis.set_major_formatter(ScalarFormatter()) 91 | plt.plot(BINS, NPCT, label='Number', lw=2.5, color='red') 92 | plt.plot(BINS, APCT, label='Area', lw=2.5, color='purple') 93 | plt.plot(BINS, VPCT, label='Volume', lw=2.5, color='green') 94 | plt.legend() 95 | 96 | # Save plot to .png file 97 | FIG.savefig(OUTPATH + 'distributions.png') 98 | 99 | # Number distributions 100 | ND10 = distribution(NCUM, 10) 101 | ND50 = distribution(NCUM, 50) 102 | ND90 = distribution(NCUM, 90) 103 | 104 | # Area distributions 105 | AD10 = distribution(ACUM, 10) 106 | AD50 = distribution(ACUM, 50) 107 | AD90 = distribution(ACUM, 90) 108 | 109 | # Volume distributions 110 | VD10 = distribution(VCUM, 10) 111 | VD50 = distribution(VCUM, 50) 112 | VD90 = distribution(VCUM, 90) 113 | 114 | # Span 115 | NSPAN = (ND90 - ND10)/ND50 116 | ASPAN = (AD90 - AD10)/AD50 117 | VSPAN = (VD90 - VD10)/VD50 118 | 119 | # Mode 120 | NMODE = BINS[np.argmax(NPCT)] 121 | AMODE = BINS[np.argmax(APCT)] 122 | VMODE = BINS[np.argmax(VPCT)] 123 | UNIQ, INDICES = np.unique(PARTICLES, return_inverse=True) 124 | PMODE = UNIQ[np.argmax(np.bincount(INDICES))] 125 | 126 | # Median 127 | NMEDIAN = BINS[np.argmax(NCUM >= 50)] 128 | AMEDIAN = BINS[np.argmax(ACUM >= 50)] 129 | VMEDIAN = BINS[np.argmax(VCUM >= 50)] 130 | 131 | # D[1,0], D[3,2], D[4,3] 132 | D_1_0 = np.sum(NI * BINS)/PARTICLES.size 133 | D_3_2 = np.sum(NI * BINS**3)/np.sum(NI * BINS**2) 134 | D_4_3 = np.sum(NI * BINS**4)/np.sum(NI * BINS**3) 135 | # D_1_0 = np.sum(DIAMETERS)/PARTICLES.size 136 | # D_3_2 = np.sum(DIAMETERS**3)/np.sum(DIAMETERS**2) 137 | # D_4_3 = np.sum(DIAMETERS**4)/np.sum(DIAMETERS**3) 138 | 139 | # Print results to file 140 | with open(OUTPATH + 'granulometry.txt', 'w') as outfile: 141 | print(15*" " + 'Number' + 2*" " + 'Area' + 4*" " + 'Volume', file=outfile) 142 | print('D10: {0:6.3f} {1:6.3f} {2:6.3f}'.format(ND10, AD10, VD10), file=outfile) 143 | print('D50: {0:6.3f} {1:6.3f} {2:6.3f}'.format(ND50, AD50, VD50), file=outfile) 144 | print('D90: {0:6.3f} {1:6.3f} {2:6.3f}'.format(ND90, AD90, VD90), file=outfile) 145 | print('Span: {0:6.3f} {1:6.3f} {2:6.3f}'.format(NSPAN, ASPAN, VSPAN), file=outfile) 146 | print('Mode (bins): {0:6.3f} {1:6.3f} {2:6.3f}'.format(NMODE, AMODE, VMODE), file=outfile) 147 | print('Median: {0:6.3f} {1:6.3f} {2:6.3f}\n'.format(NMEDIAN, AMEDIAN, VMEDIAN), file=outfile) 148 | print('D[1,0]: {0:6.3f}'.format(D_1_0), file=outfile) 149 | print('D[3,2]: {0:6.3f}'.format(D_3_2), file=outfile) 150 | print('D[4,3]: {0:6.3f}\n'.format(D_4_3), file=outfile) 151 | print('Mode (diameter): {0}'.format(PMODE), file=outfile) 152 | print('Total particles: {0}'.format(PARTICLES.size), file=outfile) 153 | -------------------------------------------------------------------------------- /todo.txt: -------------------------------------------------------------------------------- 1 | # To-do 2 | * PyPSD: 3 | 1. Take bin limits AND bin average into account, see Merkus book and hariba 4 | website below. 5 | 2. Look into "critical pressure" program I did for Lili for simpler way to 6 | solve linear equations. 7 | 3. Optimize and reduce the program, use dictionaries, etc. consider how best 8 | to deliver the results, and if plotting is still worth it. 9 | 10 | 11 | # Useful links 12 | https://www.youtube.com/watch?v=D1qBaFwuF4E 13 | http://imagej.net/Particle_Analysis 14 | https://dbuscombe-usgs.github.io/DGS_Project/ 15 | https://github.com/marcoalopez/GrainSizeTools 16 | https://openresearchsoftware.metajnl.com/articles/10.5334/jors.bh/ 17 | https://www.researchgate.net/post/Can_anyone_suggest_evaluate_open_codes_for_particle_size_distribution_image_analysis 18 | http://www.horiba.com/fr/scientific/products/particle-characterization/education/general-information/data-interpretation/understanding-particle-size-distribution-calculations/ 19 | 20 | # Several ideas 21 | 1. Make a full blown package from PyPSD, including particle analysis. Check 22 | other packages and compare functionality. If viable, this can be published in 23 | both JOSS and JORS. 24 | 2. Create an in-depth method for doing particle analysis studies geared towards 25 | microalgae. Using two sets of distinct microalgae at two different stages 26 | (whole and disrupted), plus a model emulsion dataset (5 datasets total) would 27 | be ideal. The different series can also be analyzed in the granulometer, and 28 | also by hand counting/measuring, yielding a very well characterized reference 29 | set. This could be published in a journal concerning computer data analysis, 30 | citing the above references. 31 | --------------------------------------------------------------------------------