UPDATE
In this particular case it is quite sufficient just to apply Pruning
with 1
as the second argument:
i = Import["https://i.stack.imgur.com/K8dm3.png"];
ep = MorphologicalTransform[Pruning[Thinning@Binarize@i, 1], "EndPoints"];
PixelValuePositions[%, White]
{{271, 546}, {190, 471}, {694, 382}, {899, 366}}
The purpose of Pruning
is to remove just one pixel which causes additional end point caught by "EndPoints"
:
PixelValuePositions[
ImageDifference[Thinning@Binarize@i, Pruning[Thinning@Binarize@i, 1]], White]
{{357, 341}}
Original answer
One approach is to apply Pruning
and then take "SkeletonEndPoints"
:
i = Import["https://i.stack.imgur.com/K8dm3.png"];
ep1 = MorphologicalTransform[Pruning[Thinning@Binarize@i, 150], "SkeletonEndPoints"]

PixelValuePositions[%, White]
{{271, 546}, {190, 471}, {694, 382}, {899, 366}}
In this particular case we can make Pruning
much more efficient if we apply FillingTransform
before Thinning
:
ep2 = MorphologicalTransform[
Pruning[Thinning@Binarize@FillingTransform@i, 4], "SkeletonEndPoints"];
ep1 == ep2
True
Now we can ensure that we actually have found end points of the thinned image:
ppos = ImageValuePositions[ep2, White]
{{270.5, 545.5}, {189.5, 470.5}, {693.5, 381.5}, {898.5, 365.5}}
pic = Thinning@Binarize@i;
Show[ImageTrim[pic, {#}, 1], GridLines -> {Range[10], Range[10]}, ImageSize -> 100,
Method -> {"GridLinesInFront" -> True}] & /@ ppos

We can check what these pixels correspond to in the original image:
Show[ImageTrim[ReplaceImageValue[i, # -> Red], {#}, 5],
GridLines -> {Range[10], Range[10]}, ImageSize -> 100,
Method -> {"GridLinesInFront" -> True}] & /@ ppos
